[내배캠] 사전캠프

nbcamp dart flutter
Written on Jun 27, 2023


Table of Contents


2023년 06월 27일

내일배움캠프 iOS 과정을 본격적으로 시작하기에 앞서 앱 개발 전반의 이해를 위해 Dart와 Flutter를 학습했습니다.

지원을 거의 막바지에 하게 되었는데 사전캠프가 이미 진행 도중이었습니다. 다른 분들보다 진도가 느릴까 걱정되었지만, 본 캠프는 9 to 9(…) 과정이니만큼 사전캠프도 바쁘게 해보고자 최대한 빨리 나가고자 했습니다.

강의는 스파르타코딩클럽의 [왕초보] 플러터(Flutter)로 시작하는 앱개발 종합반으로 진행했습니다.

Introduction

Flutter는 크로스 플랫폼 개발을 위한 프레임워크로 Dart라는 언어로 작성합니다. 동일한 역할을 수행하는 React Native와 비교했을 때 등장 시기는 늦지만 등장부터 빠르게 치고 올라가 Github Star 수는 이미 압도적이라 할 수 있습니다.

Star History Chart 출처: Github Star History | Flutter vs. React Native

공식 문서 정리에 유튜브 채널까지…
심지어 성능 또한 네이티브 못지않게 빠르다고 하니 Flutter를 써보지 않을 이유가 없을 듯 합니다.

Installation

brew로 flutter를 설치해줍시다.

Language:bash
$ brew install --cask flutter

설치 후 flutter 명령어를 사용할 수 있습니다. doctor 명령어로 정상적으로 설치됐는지 확인할 수 있습니다.

Language:bash
$ flutter doctor -v

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.10.5, on macOS 13.4.1 22F82 darwin-arm64, locale en-KR)
[!] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    ✗ cmdline-tools component is missing
      Run `path/to/sdkmanager --install "cmdline-tools;latest"`
      See https://developer.android.com/studio/command-line for more details.
    ✗ Android license status unknown.
      Run `flutter doctor --android-licenses` to accept the SDK licenses.
      See https://flutter.dev/docs/get-started/install/macos#android-setup for more details.
[✓] Xcode - develop for iOS and macOS (Xcode 14.3.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2022.2)
[✓] VS Code (version 1.79.2)
[✓] Connected device (2 available)
[✓] Network resources

! Doctor found issues in 1 category.

문제없이 실행하기 위해 필요한 도구 중 없는 것을 알려줍니다. 구글에서 제작한 flutter에서 vscode 설치 여부를 확인하고 있는게 좀 당황스럽긴 하지만 그만큼 vscode를 대신할만한 에디터가 없다는거겠죠.

Android에서 문제가 발생했지만 노트북 용량이 부족하니 그냥 넘어가줍니다.

VSCode에서는 다음 두 가지 Extension을 설치합니다. Flutter extension을 설치하면 dart는 알아서 설치됩니다.

Command Palette를 열고 Dart: Use Recommended Settings 명령어를 수행합니다. 추천하는 다트 설정을 vscode 전역 설정에 추가되는데 전 그게 싫어서 Workspace에 .vscode/settings.json 생성 후 옮겼습니다.

Language:bash
$ flutter create hello_flutter --empty
$ cd hello_flutter && code .

위 명령어로 프로젝트를 생성한 뒤 flutter 프로젝트를 시작합니다.

Practice

Flutter는 Widget이라고 불리는 가장 작은 단위의 모듈이 겹겹이 쌓여 전체 프로젝트를 구성합니다. 이를 위젯 트리라 부릅니다.

Widget Catalog에서 다양한 위젯을 제공합니다. iOS 스타일의 Cupertino 위젯Android 스타일의 위젯을 활용하면 쉽게 네이티브 스타일을 구현할 수 있습니다.

Command Palette에서 Flutter: Launch Emulator 명령을 실행하여 에뮬레이터의 실행을 마친 뒤, lib/main.dart 파일을 열고 main 함수 상단에 Run을 클릭하여 프로젝트를 실행합니다.

첫 주차에선 다양한 위젯을 활용하여 로그인 페이지와 간단한 영화 리스트 페이지를 작성했습니다.

Simple Login Page

  • 사용자가 입력하기 위해 입력폼을 클릭했을 때 올라오는 키보드에 입력폼이 가려지는 문제가 있습니다. ListView와 같이 scrollable한 위젯에선 발생하지 않는 문제지만 스크롤이 없을 때 발생합니다. 스크롤이 없는 단일 페이지에서 해당 문제가 발생할 시 SingleChildScrollView 위젯을 사용해야 합니다.

Simple Movie List

  • ListView.builder 위젯으로 리스트를 그릴 수 있습니다. ListViewColumn가 내부에서 사용할 때 Vertical viewport was given unbounded height. 에러가 발생할 수 있는데, ListView가 항상 최대 공간을 차지하려는 성질이 있어 높이가 무한대로 계산되는 문제가 발생하기 때문입니다. Expanded 위젯으로 감싸서 문제를 해결할 수 있습니다.

2023년 06월 28일

오늘은 간단하게 StatelessWidget과 StatefulWidget 그리고 Navigation에 대해 학습했습니다. (이번이 2주차 내용입니다.)

StatelessWidget vs. StatefulWidget

  • StatelessWidget: 상태가 없는 위젯으로 처음에 한번만 build를 호출합니다.
  • StatefulWidget: 상태를 가진 위젯으로 상태가 변경될 때마다 build 메서드를 호출하여 다시 그립니다.

위 두 위젯은 보통 상속하여 build 메서드를 override하는 방식으로 사용합니다. StatefulWidget은 State를 변경하는 클래스가 별도로 필요합니다.

Navigation은 Route라고 불리는 페이지를 전환하는 것을 의미하고 Navigator 위젯으로 수행합니다. push로 페이지를 이동하고 pop으로 이전 페이지로 이동할 수 있습니다.

당근마켓(Daangn) 앱 클론

Stateful한 Feed 위젯을 구현해보았고, 파일을 분리하여 작성해보았습니다. (dartpad는 파일 분리를 지원하지 않아 main.dart에 전부 있습니다…. 22년도에 올라온 이슈인데...)

여기서 놀랐던 점은 ListView.builderitemCount를 명시하지 않으면 리스트 요소를 무한히 만들어낸다는 점입니다. 예전에 Recycle Scrolling을 구현하면서 겪은 제한으로는 적어도 요소의 최소 크기가 결정되어 있어야 하고, 요소의 개수를 어느정도 알고 있어야 했는데 그런거 없이 무한정 그려낼 수 있다는 점이 놀라웠습니다.

해당 문서에서 ListView 자체가 내부적으로 요소를 재활용(recycle)하는 동작으로 수행됨을 알 수 있습니다.

The recommended, efficient, and effective way to build a list uses a ListView.Builder. This method is great when you have a dynamic List or a List with very large amounts of data. This is essentially the equivalent of RecyclerView on Android, which automatically recycles list elements for you…

샤잠(Shazam) 앱 클론

사실 과제는 페이지 세 개 중 하나를 택해 수행하는 건데 그까이꺼 전부 클론해봤습니다. 리팩토링이 필요한 부분이 굉장히 많아보이긴 하지만… 항상 언젠가 한다는 마음가짐으로…

탭 생성 및 이동 방법에 대해서 알게 되었고, 많고 많은 삽질을 하며 스크롤 요소를 어디에 어떻게 배치해야하는지 요령을 습득했습니다.

가장 난해했던 부분… overflow… Scrollable하지 않은 페이지에서 요소가 화면을 벗어나면 A ListView A RenderFlex overflowed by <number> pixels on the bottom. 에러가 발생합니다. Scrollable하게 만들어주기 위해 SingleChildScrollView를 이용하거나 ListView로 감싸줘야하고, Expanded로 감싸야합니다.


2023년 06월 29일

오늘은 심플한 메모앱을 만들면서 CRUD 기능 구현과 함께 상태 관리 패키지인 Provider 그리고 shared_preferences를 이용하여 데이터를 기기에 저장하여 앱을 종료 후 다시 열어도 정보가 유지되도록 기능을 구현했습니다.

flutter의 패키지는 pub.dev에서 조회할 수 있습니다. 패키지를 설치하기 위해서도 flutter pub 명령어를 사용합니다.

Language:bash
$ flutter pub add provider shared_preferences

Provider

Provider는 flutter의 상태 관리 패키지로 전역에서 데이터를 쥐고 제어할 수 있는 서비스를 관리해줍니다. Memo 클래스와 함께 MemoService를 추가해보았습니다. MemoService는 메모 목록을 관리하고 CRUD 기능을 제공합니다.

Language:dart
// lib/services/memo.dart

class Memo {
  Memo({ required this.content });

  String content;
}

class MemoService extends ChangeNotifier {
  List<Memo> memos = [];

  void create(String content) { ... }
  void update(int index, String content) { ... }
  void delete(int index) { ... }
}

main.dartrunApp에 Provider를 통해 MemoService를 등록해야 합니다.

Language:dart
// lib/main.dart
void main() {
  runApp(
    MultipleProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => MemoService()),
      ],
      child: const MyApp(),
    ),
  );
}

이제 위젯트리 전체를 Consumer<MemoService>로 감싸주면 MemoService 내에서 notifyListeners()가 호출될 때마다 전체 화면을 다시 그립니다.

Language:dart
Widget build(BuildContext context) {
  return Consumer<MemoService>(builder: (context, memoService, child) {
    return Scaffold(
      ...
    )
  }
}

Consumer는 전체 화면을 다시 그리지만, 화면 리렌더링 없이 MemoService의 인스턴스를 가져오고 싶다면, context.read<MemoService>()를 통해 가져올 수 있습니다.

shared_preferences

SharedPreferences는 앱을 껐다 켜도 데이터를 유지할 수 있는 기능을 제공합니다. 다만 SharedPreferences와 같이 데이터를 유지해줄 수 있는 방법은 다양하므로 언제든 교체할 수 있도록 외부에서 주입해주는 방식으로 구현했습니다.

MemoService에 데이터를 저장하고 불러오는 함수를 매개변수를 받을 수 있게끔 추가합니다.

Language:dart
class MemoService extends ChangeNotifier {
  final List<Memo> _memos = [];
  final Future<void> Function(String payload)? save;
  final Future<String?> Function()? load;

  MemoService({
    this.save,
    this.load,
  }) {
    ...
  }
}

main.dart에서 SharedPreferences의 인스턴스를 생성한 후 데이터를 저장하고 불러오는 함수를 전달합니다.

Language:dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  SharedPreferences pref = await SharedPreferences.getInstance();
  runApp(MultiProvider(
    providers: [
      ChangeNotifierProvider(
        create: (_) => MemoService(
          save: (String payload) async => await pref.setString("memo", payload),
          load: () async => pref.getString("memo"),
        ),
      ),
    ],
    child: const MyApp(),
  ));
}

MemoService에서 이를 적절하게 저장하고 불러올 때 호출합니다.

Language:dart
String payload = jsonEncode(_memos.map((m) => m.toJson()).toList());
return save!(payload);
Language:dart
String? payload = await load!();
if (payload == null) return;
_memos.clear();
_memos.addAll(jsonDecode(payload).map((e) => Memo.fromJson(e)));

이로써 데이터를 항시 유지할 수 있고, 언제든 main.dart만 수정하여 데이터 유지 방식을 변경할 수 있습니다.

My Memo

구현한 앱은 다음의 기능을 제공합니다.

  • 하단 + 아이콘을 눌러 새로운 메모를 생성할 수 있습니다. (메모 작성 페이지로 이동합니다.)
  • 아무런 내용을 작성하지 않는다면 메모가 추가되지 않습니다.
  • 내용을 추가하고 뒤로 가기 버튼을 누르면 새로운 메모가 추가됩니다.
  • 메모를 클릭하여 내용을 수정할 수 있습니다.
  • 메모를 클릭하고 쓰레기통 아이콘을 눌러 삭제할 수 있습니다.
  • 변경일 내림차순으로 정렬됩니다. (가장 최근에 변경된 메모가 상단에 위치합니다.)
  • 고정한 메모를 상단으로 올립니다.
  • 메모 목록은 앱을 종료 후 재시작해도 유지됩니다.

Trouble Shooting

  • shared_preferences 때문에 프로젝트가 실행되지 않을 때 대처법
  • SharedPreferences의 인스턴스 변수를 전역에서 관리하는 것이 아닌, 데이터를 저장하고 불러오는 함수를 MemoService에 주입하는 방식으로 리팩토링했습니다. (강의에선 아마 콜백함수에 대한 내용을 학습하지 않았으므로 전역에서 관리하도록 구현하신 것 같습니다.)
  • 웹에서는 SharedPreferences의 인스턴스를 생성할 수 없습니다. shared_preferences_web이 포함되어 있다는데 왜 동작을 안 하는지는 모르겠네요… import 'package:flutter/foundation.dart' show kIsWeb;를 불러와서 웹 환경이 아닐 때만 인스턴스를 생성하도록 수정했습니다.
  • 날짜 포맷팅을 위해 import 'package:intl/intl.dart'; 라이브러리를 추가로 활용했습니다.

전반으로 리액트나 뷰와 비슷한 느낌이라 기능 구현은 별로 어렵진 않았어서 다트 언어에 더 익숙해질 수 있는 시간이었네요.


2023년 06월 30일

5주차 강의는 HTTP API 요청 방법을 익혔습니다. 다음주는 광고 붙이는건데… 이번이 마지막일 듯 하네요.

Requesting Network Data

HttpClient로는 dio 패키지를 활용했습니다.
책 정보를 불러올 수 있는 Google API를 활용하여 Watcha Pedia 서비스를 구현했습니다.

Language:dart
// main.dart
void main() async {
  late SharedPreferences pref;
  if (!kIsWeb) {
    WidgetsFlutterBinding.ensureInitialized();
    pref = await SharedPreferences.getInstance();
  }
  runApp(MultiProvider(
    providers: [
      ChangeNotifierProvider(
        create: (_) => BookService(
          get: <T>(String query) async {
            String url =
                'https://www.googleapis.com/books/v1/volumes?q=$query&startIndex=0&maxResults=40';
            Response res = await Dio().get(url);
            if (res.statusCode != 200) {
              throw Exception('http.get error: statusCode= ${res.statusCode}');
            }
            return res.data['items'];
          },
          save: (String payload) => pref.setString('likedBooks', payload),
          load: () => pref.getString('likedBooks'),
        ),
      ),
    ],
    child: const MainApp(),
  ));
}
Language:dart
// services/book.dart
final Future<T> Function<T>(String url) get;

final FutureOr Function(String payload)? save;
final FutureOr Function()? load;

BookService({required this.get, this.save, this.load}) {
  _load().then((_) => notifyListeners());
}

BookService 클래스에서 책 정보를 가져오는 함수(get)와 정보를 저장(save)하고 불러오는(load) 함수를 주입할 수 있도록 구현했습니다.

WebView Page

앱 상에서 외부 웹 링크 페이지를 띄우기 위해선 webview_flutter 패키지를 사용해야 합니다.
책 목록에서 책을 클릭하면 해당 책의 정보를 보여주는 페이지를 띄웁니다.

Language:dart
// screens/webview.dart
class WebViewScreen extends StatelessWidget {
  WebViewScreen({
    super.key,
    required this.url,
  });

  String url;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.grey,
        title: Text(url),
      ),
      body: WebView(initialUrl: url),
    );
  }
}
Language:dart
// widgets/book_tile.dart
onTap: () {
  Navigator.push(
    context,
    MaterialPageRoute(
      builder: (_) => WebViewScreen(
          url: book.previewLink.replaceFirst('http:', 'https:')),
    ),
  );
},

Watcha Pedia

| Dartpad에서 Image.network 불러오기가 안 되는 문제가 있습니다.
| dio가 동작하지 않아, http 모듈로 대체했습니다.
| webview가 동작하지 않습니다.

Project Structure

Dartpad에서는 확인할 수 없지만, 프로젝트 구조에 대해 고민해보았습니다.

  • screens: 화면
  • models: 데이터 모델 (자료구조)
  • services: 서비스 (비즈니스 로직)
  • widgets: 위젯 (컴포넌트)
Language:txt
lib/
 ├── models/
 │  └── book.dart
 ├── screens/
 │  ├── home.dart
 │  ├── like.dart
 │  ├── search.dart
 │  └── webview.dart
 ├── services/
 │  └── book.dart
 ├── widgets/
 │  └── book_tile.dart
 └── main.dart