기기 위치(위도, 경도)를 받아 Vworld에서 api요청으로 해당 좌표의 주소를 받아오는 앱을 구현 중,

 

국내의 유효한 좌표를 입력해도 유효한 주소를 받을 수 없는 문제가 지속적으로 발생했다. 

 

함수의 어느 부분에서 코드가 생각대로 실행되지 않는지 확인하기 위해 진행상황을 확인하는 print() 문을 사이사이에 넣었다.

 

디버그 콘솔 결과

해당 좌표는 경복궁 좌표.

 

 

찾아보니 산 같은 곳은 주소가 제대로 잡히지 않는 경우가 있다고 해서 확실히 주소가 있을, 건물 위주로 좌표를 바꿔가며 시도해 보았지만 여전히 3번 단계에서부터 null 값이 출력되었다.

 

 

뭔가 놓치고 있는 점이 있나 확인하기 위해 vworld에서 api레퍼런스를 찾아보았다.

https://www.vworld.kr/dev/v4dv_search2_s001.do

지금까지 road유형을 사용했으니 parcel 유형을 시도해보았다.

 

 

허무할 정도로 잘 실행되는 모습

 

좌표를 도로 주소로 받아오는 데 api상의 문제가 있는 모양이다. 좌표를 지번 주소로 받아오는 것은 깔끔하게 잘 실행되었다.

 

 

다만, 이 이후에는 검색어가 너무 길어 검색이 잘 이루어지지 않는 문제가 있었다. 

이것은 주소 데이터 중 '동', '읍', '면' 이 포함된 어절을 사용해 검색하는 것으로 해결했다.

 

 

++ 25.04.21 수정

 

기존에는 브이월드 검색 api를 사용하였으나 관련 강의를 듣던 중 2D 데이터 api - '읍면동'이 더 적합하다는 것을 알게 되었다. 

리퀘스트와 기타 파라미터들을 변경해 해당 api를 불러왔고, 결과적으로 검색어 후처리를 하지 않아도 읍, 면, 동 단위의 검색이 가능하게 되었다.

 

수정 코드

MVVM 방식, 네이버 오픈 API를 사용한 아주 간단한 앱을 구현했다.

복잡한 기능은 모두 빼고, 위 두 가지 기능의 실제 사용에 집중했다.

 

사용한 패키지: flutter_riverpod, http

 

 

결과화면

 

페이지 하나로 구성된 앱으로, 검색어를 입력하면 네이버 오픈 api 검색 기능을 이용해 책을 검색한다.

검색결과는 그리드뷰 리스트로 출력되며, 이미지만 표시된다.

 

파일 구조

 

 

book.dart

class Book {
  final String image;

  Book({required this.image});

  Book.fromJson(Map<String, dynamic> json) : this(image: json["image"]);

  Map<String, dynamic> toJson() => {"image": image};
}

네이버 API가 제공하는 정보 중 이미지만 사용할 예정이기에, class Book 내에 image 속성만 담았다.

 

 

book_repository.dart

import 'dart:convert';

import '/data/model/book.dart';
import 'package:http/http.dart';

class BookRepository {
  Future<List<Book>?> search(String query) async {
    try {
      Client client = Client();
      Response result = await client.get(
        Uri.parse(
          'https://openapi.naver.com/v1/search/book.json?query=$query&display=30',
        ),
        headers: {
          'X-Naver-Client-Id': '미리 발급받은 Id 입력',
          'X-Naver-Client-Secret': '미리 발급받은 Secret 입력',
        },
      );
      final json = jsonDecode(result.body);

      if (result.statusCode == 200) {
        return List.from(json['items']).map((e) => Book.fromJson(e)).toList();
      }
      return null;
    } catch (e) {
      print(e);
      return null;
    }
  }
}

api에 검색어를 포함한 url의 결과를 요청하면서, 필요한 인증 정보를 함께 보낸다.

Get요청 성공 시 응답코드 200이 반환되는데, 이 코드(statusCode)를 사용해 성공여부를 판단한다.

 

참고:

https://developer.mozilla.org/ko/docs/Web/HTTP/Status/200

 

200 OK - HTTP | MDN

HTTP 200 OK 는 요청이 성공했음을 나타내는 성공 응답 상태 코드입니다. 200 응답은 기본적으로 캐시 가능합니다.

developer.mozilla.org

 

Get 요청 성공 시 결과값(json String 타입) jsonDecode로 변환한 값을 사용해 List<Book> 의 형태로 반환한다.

home_page.dart, 

home_view_modle.dart 가 해당 리스트를 사용하여 화면에 결과를 출력할 수 있다.

 

 

RiverPod이란? 

개발자가 상태관리를 편하게 할 수 있도록 도와주는 라이브러리

MVVM 아키텍처 구현과 관리 용이

 

사용법

A. ViewModel 만들기

1. 관리할 데이터를 담을 상태 클래스 만들기

class HomeState{
	int counter;
	HomeState(this.counter);
}

 

2. Notifier를 상속받는 ViewModel 클래스 만들기

class HomeViewModel extends Notifier<HomeState> {
  @override
  HomeState build() {
    return HomeState(1);
  }
  
  void updateState(){
	  state = HomeState(state.counter + 1);
  }
}

 

- 1) Notifier를 상속받으면 상태를 저장하고 업데이트할 수 있는 기능을 갖는다. 

     - ViewModel은 상태가 업데이트될 때 따로 Statefull widget처럼 setState() 하지 않아도 상태가 없데이트되는데, Notifier를 상속받는 것이 그 기능을 하게 해준다.

 

 - 2) Notifier 상속 시 이 ViewModel이 어떤 상태를 관리하는지 제너릭으로 지정해줘야 한다(위 코드 중 Notifier<HomeState>)

 

 - 3) Notifier 클래스의 build 메서드를 재정의해 초기 상태값을 return 한다.

     - Notifier 클래스의 build 메서드는 추상 메서드로 선언되어 있어, Notifier를 상속받은 ViewModel은 반드시 build를 재정의해주어야 한다.

     - '초기값을 리턴한다' 까지만 저장된 규칙이고, 해당 값을 어떻게 설정할 것인지는 사용자가 결정하는 것.

 

3. 뷰모델을 관리 및 공급해 줄 NotifierProvider 객체를 변수에 담는다

final homeViewModelProvider = NotifierProvider<HomeViewModel, HomeState>((){
		return HomeViewModel();
});

 

앞서 만든 HoveViewModel을 Provider로 감싸줌으로써

 - 상태 변경 시 UI 자동 업데이트

 - 필요 없는 경우 정리(dispose) 가 가능해진다. 

 

Provider 생성 시 제너릭으로 관리하는 뷰모델 타입과 관리하는 상태를 명시한다(<HomeViewModel, HomeState>)

 

 

B. 위젯에서 불러오기

Consumer(
	builder: (context, ref, child){
		ref.watch(homeViewModelProvider);
		return Column(children:[
			Text()
		]);
	}
)

 

Consumer : Riverpod 이 정의하는 위젯. Provider의 변경을 감지하고 필요할 때 해당 부분만 리빌드해줌.

 

ref.read(프로바이더) : 1회성. 실행될 때 상태를 읽어 오고 변경은 감지하지 못함

ref.watch(프로바이더) : 상태변경을 감지하여 상태 변경 일어날 때마다 재빌드

 

여기서는 ref.watch를 사용했으므로 HomeViewModel의 HomeState가 변경될 때마다 재빌드가 이루어진다.

 

 

C. 최상위 위젯 ProviderScope로 감싸기

void main() {
  // 이 앱에서 ViewModel을 RiverPod이 관리하게 해주게 해줌
  runApp(const ProviderScope(child: MyApp()));
}

MVVM 이란?

Model + View + ViewModel

 

Model, View, ViewModel로 계층을 나누어서 개발하는 방식

코드의 역할을 명확하게 나누어 사용하는 방식으로, 유지보수와 테스트가 용이함.

 

 

Model

  • 데이터를 관리하는 부분
  • 서버, 데이터베이스 같은 곳에서 데이터를 가져오는 역할
  • 데이터 클래스 자체도 Model로 봄
  • 서버에서 데이터를 가져오는 경우, Repository로 관리

 

View

  • 화면(UI)를 담당
  • 위젯들을 담고 있음
  • 직접 데이터를 다루지 않고, ViewModel로부터 데이터를 받아 사용함
  • ViewModel을 구독함 => ViewModel이 변경되면 바로 알 수 있고, 필요한 데이터를 받을 수 있음 

 

ViewModel

  • ViewModel을 연결하는 중간다리 역할
  • Model(Repository)에서 데이터를 가져와 가공하고, View에 맞는 형태로 변환

 

 

 

MVVM은 플러터 기본 기능으로도 구현 가능하지만, 상태관리 라이브러리를 쓰면 더욱 편하게 구현 가능하다.

상태관리 라이브러리로는 RiverPod이 많이 사용됨

삼항연산자 

조건문의 하나. 플러터 위젯 내에서는 if문의 사용이 제한되어 있기 때문에 이 문법을 많이 사용한다.

 

기본 형식

조건 ? A : B

 

다음 if 문과 같은 역할을 한다.

if (조건) { A }  else { B }

//조건이 참이면 A 실행, 거짓이면 B 실행

 

예시

bool isLast = true;

Padding(
	padding: 
		isLast ? EdgeInsets.zero : EdgeInsets.only(bottom:20)
)
//bool 값에 따라 다른 패딩 값을 사용

 

 

 

null 병합 연산자

기본값 설정에 주로 사용한다.

 

기본 형식

A ?? B

 

다음 if 문과 같은 역할을 한다.

if ( A == null ) { B }

// A가 null이면 B 실행

 

 

예시

String? userName;

Text(userName ?? '이름 없음'),

//userName 값이 없으므로 대신 '이름 없음' 이라는 String 사용

 

 

꼭 플러터 위젯 내부가 아니더라도 간단한 조건문을 한 줄로 표현할 수 있기 때문에 자주 사용한다.

문제상황

 

 

유사한 레이아웃을 가진 페이지 SeatPage(좌), MySeat(우)이 동일한 코드를 반복적으로 사용중

 

레이아웃 코드의 위젯화가 필요한데, 좌석정보 관련 변수가 SeatPage내부에서 선언되어 MySeat로 전달하려면 번거로운 과정을 거쳐야 했다.

 

우선 지역 변수 대신 전역 변수를 사용해 기존 코드를 개선하고, 자주 사용하는 위젯을 분리하기 쉽게 해 주었다.

 

 

기존 방식

SeatPage -> MyTicket -> MySeat로 페이지가 넘어갈 때마다 지역 변수를 전달함

 

좌측

SeatPage => MyTicket 으로 페이지 이동

4개의 지역변수를 전달한다.

 

우측

MyTicket => MySeat 으로 페이지 이동

2개의 지역변수를 전달한다.

 

세 페이지에서 같은 값을 사용함에도 매번 코드를 작성해 줘야 하는 불편함이 있다.

 

 

개선 방식

station_data.dart

seat_data.dart

파일을 만들어 전역변수를 선언했다.

유저 입력에 따라 값을 받을 변수이므로 ?를 사용해 기본값으로 null을 가질 수 있게 한다.

 

 

전역 변수를 사용하면 기존 SeatPage, MySeat에서 반복되는 코드를 외부에 저장하는 것이 편해진다.

 

 

 

기존 코드

MySeat 내부 seat 메서드, textBox 메서드

 

textBox메서드의 경우 레이아웃 부분만 정의하기 때문에 어디서든 사용할 수 있지만, 

seat 메서드는 지역 변수인 selectedRowNum, selectedColStr을 사용하기 때문에 외부로 꺼내 쓰려면 변수 재정의 또는 전달이 필요하다.

 

 

개선 코드

widgets_seats.dart 파일 내 seat 메서드, textBox 메서드 

전역 변수인 selectedRowGlobal, selectedColGlobal을 임포트 한 번으로 쉽게 불러왔다. 

 

 

전역함수 사용 및 리팩토링 전
전역함수 사용 및 리팩토링 후

레이아웃과 기능상 차이는 없지만 전역함수와 외부 메서드 사용으로 짧고 깔끔하게 구현이 가능해졌다.

코드

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          fourContainers(),
          fourContainers(),
          fourContainers(),
          fourContainers(),
        ],
      ),
    );
  }

  Padding fourContainers() {
    return Padding(
      padding: EdgeInsets.only(bottom: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
        ],
      ),
    );
  }
}

 

결과

ListView 내에서 fourContainers라는 메서드를 네 번 호출해서 이미지와 같은 레이아웃을 그리고 있다. 

현재는 메서드를 4번 호출하기 때문에 위와 같이 작성해도 문제가 없지만, 호출 횟수가 많아지면 코드의 가독성이 떨어지고 유지보수가 어려워진다.

 

 

https://devlogmj.tistory.com/15

 

[Dart] .generate와 .filled 사용하여 리스트 만들기

.generate 주어진 길이만큼 리스트를 생성하고, 각 요소에 대해 반복적인 패턴의 값을 할당하는 데 사용한다. List.generate() 문법List.generate(length, (index) => expression); length - 생성할 리스트의 길이index

devlogmj.tistory.com

이전에 정리한 .generate 함수를 사용하여 반복되는 메서드 호출을 깔끔하게 구현할 수 있다.

 

 

만약 fourContainers를 20번 반복해야 한다면?

 

기존 코드

몇 번 반복되는지 한 눈에 알아볼 수 없음

 

 

.generate로 개선한 코드

generate 함수 내 인자를 통해 20번 반복된다는 것을 알아볼 수 있다. 

 

또한, generate 함수의 특성상 규칙을 가진 리스트 요소를 간단하게 생성할 수도 있다.

 

예시

인덱스를 사용하여 줄마다 번호를 부여했다.

 

 

사용 코드

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          ...List.generate(20, (index) {
            index = index + 1; // index를 1부터 시작하도록 처리
            return fourContainers(index);
          }),
        ],
      ),
    );
  }

  Padding fourContainers(index) {
    return Padding(
      padding: EdgeInsets.only(bottom: 8),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          SizedBox(width: 50, height: 50, child: Text('$index')),

          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
          SizedBox(width: 8),
          Container(
            width: 50,
            height: 50,
            decoration: BoxDecoration(color: Colors.amber),
          ),
        ],
      ),
    );
  }
}

 

전체 코드

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: HomePage());
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('HOME')),
      body: Center(


        ///여기부터 Container 테두리 예시코드 
        ///
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.black, width: 1),
              ),
            ), // all: 모든 테두리

            SizedBox(height: 50),

            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border(
                  left: BorderSide(color: Colors.black, width: 1),
                  // right: BorderSide(color: Colors.black, width: 1),
                  top: BorderSide(color: Colors.black, width: 1),
                  bottom: BorderSide(color: Colors.black, width: 1),
                ),
              ),
            ), // BorderSide: 지정 테두리 -- 좌, 우, 상, 하 지정하여 각각 그리기

            SizedBox(height: 50),

            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border.symmetric(
                  horizontal: BorderSide(color: Colors.black, width: 1),
                ),
              ),
            ), // symmetric: 상하 또는 좌우 테두리 -- 좌우 테두리

            SizedBox(height: 50),

            Container(
              width: 200,
              height: 100,
              decoration: BoxDecoration(
                border: Border.symmetric(
                  vertical: BorderSide(color: Colors.black, width: 1),
                ),
              ),
            ), // symmetric: 상하 또는 좌우 테두리 -- 상하 테두리
          ],
        ),
      ),
    );
  }
}

 

결과물

 

 

 

전체 테두리

Border.all(color: {색상} , width: {테두리 두께} )

 

 

지정 테두리

Border(
	left: BorderSide(color: {색상} , width: {테두리 두께} ),   // 왼쪽 테두리
    right: BorderSide(color: {색상} , width: {테두리 두께} ),  // 오른쪽 테두리
    top: BorderSide(color: {색상} , width: {테두리 두께} ),    // 상단 테두리
    bottom: BorderSide(color: {색상} , width: {테두리 두께} ), // 하단 테두리
)​

 

상하 / 좌우 테두리

Border.symmetric(
	horizontal: BorderSide(color: Colors.black, width: 1),
),
// horizontal 이면 좌우, vertical 이면 상하

title

  • AppBar 가운데 또는 왼쪽에 위치
  • 페이지 제목, 앱 이름 등을 표시하는 데 사용
  • centerTitle로 정렬 방향 조정 가능 ( centerTitle = true 인 경우 가운데 정렬)(안드로이드는 기본 false, ios는 기본 true)

예시

AppBar(
  title: Text('Home'),
  centerTitle: true,
)

 

leading

  • AppBar 맨 왼쪽에 위치
  • 뒤로가기 버튼, 햄버거 메뉴 아이콘에 주로 사용

예시

AppBar(
  leading: IconButton(
    icon: Icon(Icons.arrow_back),
    onPressed: () {
      Navigator.pop(context);
    },
  ),
)

뒤로가기 버튼 예시

 

actions

  • AppBar 오른쪽에 위치
  • 설정, 알림, 검색 아이콘 등에 주로 사용

예시

AppBar(
  actions: [
    IconButton(
      icon: Icon(Icons.search),
      onPressed: () {},
    ),
    IconButton(
      icon: Icon(Icons.settings),
      onPressed: () {},
    ),
  ],
)

검색, 설정 아이콘 사용 예시

플러터에서 폰트를 추가할 때는 ttf, otf 형식의 파일을 사용한다.

 

 

1. 폰트 파일 추가

프로젝트 최상위 경로에 fonts폴더를 만들어 준비한 폰트 파일을 넣어준다.

 

 

2. pubspec.yaml에서 폰트 선언

pubspec.yaml에서 

fonts:

 

라인을 찾아 주석 처리를 해제, 하단에 폰트 패밀리와 패밀리 내의 폰트를 선언한다.

 

weight는 일반적으로 폰트의 굵기를 말한다. 

일반적인 굵기(normal)의 폰트는 400, 굵은(bold) 폰트는 700의 weight 값을 가진다.

 

 

3. Theme에 폰트 적용하고 사용하기

MaterialApp 내부에서 theme를 선언하고 사용할 fontFamliy를 지정한다.

 

 

fontWeight 설정 전과 후

 

 

Text 위젯 코드

 

 

 

 

여기서, 

다양한 굵기의 폰트가 준비되어 있지 않더라도 물론 fontfamily 사용이 가능하다.

 

 

하나의 폰트만 사용하는 예시

 

폰트 선언

pubspec.yaml파일에서 똑같이 작성해주되, 보통 굵기의 폰트 하나만 넣었다.

 

이 경우에도 기존 방식(Theme에서 사용할 폰트패밀리 선언)으로 사용이 가능하나, fontWeight 설정이 적용되지 않는다.

 

 

앞선 예시파일과 동일한 코드를 사용했으나, 폰트웨이트가 적용되지 않는다.

 

대신, 가장 비슷한 대체 폰트 - NotoSansKR-Regular - 을 자동으로 대신 사용한다.

 

 

 

위의 예시코드처럼 100부터 900까지 다양한 폰트웨이트를 선언하지는 않더라도, 보통 normal(w400), bold(w700) 두 가지는 포함하여 사용하는 경우가 많다.

+ Recent posts