언어로 XAML, C# 을 사용한다.
그로 인해, 커뮤니티도 적고, 문제가 발생해도 찾기 힘들다.
언어로 JS, HTML, CSS 를 이용한다.
이번에 3.0으로 업데이트 되었으며,
Android / iOS / Web / Window / MacOS / Linux
하나의 코드로 6개의 플랫폼에서 앱을 만들 수 있다.
docs를 보며, 설치부터 튜토리얼, 심화 과정까지 나아갈 수 있을 정도로 공식 문서가 잘 쓰여 있습니다.
정적, 동적 타입을 유동적으로 지닐 수 있다. -> JIT(Just In Time), AOT(Ahead Of Time) 컴파일 방식을 둘 다 사용할 수 있다.
dart 언어보다는 js를 익힌 개발자들이 훨씬 더 많다. -> dart를 배우기 위해 시간을 어느정도 소비해야 한다. 그리고 dart를 익혀도 현재로써는 플러터에서만 사용이 가능하다.
react를 사용한 웹 사이트를 모바일로 만들기 비교적 쉽고, 또 react-native 를 사용한 모바일 앱을 웹으로 확장하기 쉽습니다.
개발자 수가 가장 많고, 이미 많은 사람들이 여러 App을 React Native를 이용해 만들었다. 응용 방식과 개발자들의 수 많은 사용방식을 알 수 있다.
kotlin, swift를 추천하고, 권장하는 지금은 아쉬운 점이다.
결국에는 flutter 와 react native 사이에서 고민하게 되었습니다.
목표인 "백 그라운드에서 지속적으로 사용자의 위치를 기록하고, 서버에 전송할 수 있는 어플리케이션 만들기" 를 다시 생각해보며 여러 가지에 대해 검색해보았습니다.
flutter gps / flutter gps tracker
react native gps / react native gps tracker 등으로 검색해보니, 300 ~ 400 만개 정도로 비슷한 숫자의 검색 개수가 나왔습니다.
찾을 수 있는 정보의 양
flutter == react native
배우기 쉬운 정도
flutter >= react native
공식 참고 문서 (docs)나, 인터넷 페이지에 나와있는 정보의 양은 비슷하지만, 동영상 강의와 튜토리얼 예제등이 아주 잘 나와있어 flutter 가 마음에 들어서, flutter 를 선택하게 되었습니다.
5가지 크로스 플랫폼의 비교
https://doit.software/blog/flutter-vs-react-native#screen6
참고 : flutter docs // 처음 hello world 앱 만들어보기
https://docs.flutter.dev/get-started/codelab
lib/main.dart 코드를 아래의 코드로 바꾸면 됩니다.
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: "hello world",
home: Scaffold(
appBar: AppBar(
title: const Text('hello World!'),
),
body: const Center(
child: Text('Hello, world!'),
),
),
);
}
}
튜토리얼을 따라 진행해보았습니다.
Android :
iOS :
코드는 github / Cryptolab-App_Flutter
https://github.com/happinessee/Cryptolab-App_Flutter/tree/main/week00
어떤 프로그램과 규칙을 사용할 지 고르기.
다른 사람들이 최대한 보기 쉽게 코드를 작성하기 위해, 그리고 다른 사람들과 협업할 때를 위해.
사람들은 모두 다 다른 코딩 스타일을 가지고 있고, lint는 같은 코드 규칙을 지키게 만들어 보기 쉽게 만들어줍니다.
google에서 기본으로 제공해주는 lint는 flutter_lints 입니다.
include: package:flutter_lints/flutter.yaml
그리고 저는 이 "flutter_lints" lint를 아래와 같은 이유로 사용하기로 결정했습니다.
-> 거의 모든 사람이 사용하는 표준입니다. // 이 이유 하나 만으로도 결정할 수 있었습니다.
세부 사항은 다른 사람들과 함께 조절할 수 있겠지만 거의 모든 사람들이 표준으로 알고 있고, 이 lint를 기준으로 코드를 작성하고 있는 것을 알 수 있습니다.
# 세부사항의 예제
avoid_print: false # Uncomment to disable the `avoid_print` rule
참고 : VScode 에서의 formatter
VScode 에서는 Flutter Extension을 깔기만 하면 바로 자동으로 formatting이 됩니다.
또한 MacOS : option + shift + F 로도 정렬할 수 있습니다.
CLI 환경에서도 물론 가능합니다.
참고 : https://docs.flutter.dev/development/tools/formatting
사용할 수 있는 상태 관리 방법을 찾고 그 중 한가지 상태 관리 방법을 고르세요.
선택한 상태 관리 방법을 통해 버튼을 누를 경우 숫자가 더해지는 앱을 개발해보세요.
넓은 의미에서 상태란 앱이 돌아가고 있을 때 메모리에 존재하는 모든 것을 뜻합니다. 그러나 모든 상태를 관리해야 하는 것은 아니므로 (ex / textures ...)
Flutter는 선언적이다. -> 앱의 현재 상태를 반영하도록 사용자 인터페이스를 만든다.
상태를 변경하면, UI의 redraw 가 작동된다.
flutter docs에서의 정의는
"Ephemeral state (sometimes called UI state or local state) is the state you can neatly contain in a single widget."
라고 되어있고, 문서에서 스스로 애매모호한 정의라면서 예제를 보여준다.
다음 설명에서 조금 더 이해가 되었다.
there is no need to use state management techniques (ScopedModel, Redux, etc.) on this kind of state.
직역하자면, 상태 관리 기법을 사용할 필요 없는 상태라는 것이다.
오직, Stateful Widget 만이 상태 관리 기법이 필요하다.
아래는 예이다.
class MyHomepage extends StatefulWidget {
const MyHomepage({super.key});
_MyHomepageState createState() => _MyHomepageState();
}
class _MyHomepageState extends State<MyHomepage> {
int _index = 0;
Widget build(BuildContext context) {
return BottomNavigationBar(
currentIndex: _index,
onTap: (newIndex) {
setState(() {
_index = newIndex;
});
},
// ... items ...
);
}
}
해당 bottom nevigation bar는 _index 변수를 가지고 있고,
_index 변수는 ephemeral state 이다.
앱의 많은 부분에서 공유할, 그리고 유저 세션에서 유지하고 싶은 상태가 App state 이다.
예로는
가 있다.
그래서, 큰 그림으로 보면 상태는 보통 이렇게 결정된다.
Redux를 만든 사람은, 상태관리기법 중 "최고는 덜 어색한 것이다." 라고 말했다.
Ephemeral state는 "State" 와 "setState()" 에 의해 구현되며, 보통 단일 위젯의 안에 사용된다. 그리고, 어느 플러터 앱에도 두 가지의 상태가 있으므로 개발자의 선호도와, 앱의 복잡성 사이에서 덜 어색하게 잘 결정하면 될 것이다.
이제는 상태 관리 기법에 대해 알아볼 차례이다.
docs 에서는 "provider" package 를 이용해 설명해준다. (시작하기 적합하고 많은 코드를 사용하지 않아 좋다고 한다.)
예제는 간단한 쇼핑 앱을 보여준다. 쇼핑 앱의 형식은 카탈로그와 내 장바구니로 되어있다.
여기에서, MyListItem은 MyCatalog에도, 또 MyCart에도 속해있을 수 있다. 그리고 MyListItem들을 추가할 수도 있어야 한다.
그렇다면, 우리는 카트의 현재 상태를 어디에 놓아야 할까??
// BAD: DO NOT DO THIS
void myTapHandler() {
var cartWidget = somehowGetMyCartWidget();
cartWidget.updateWith(item);
}
// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
return SomeWidget(
// The initial state of the cart.
);
}
void updateWith(Item item) {
// Somehow you need to change the UI from here.
}
선언적 프레임워크인 플러터는 UI를 바꾸기 위해서는 rebuild를 반드시 해야 한다. 그것은 결코 쉬운 일이 아니다. 다른 말로 하자면, 위젯 밖에서 메소드를 호출한다고 해서 해당 위젯을 강제로 바꾸기 어렵다.
우리는 UI의 현재 상태를 고려하여 새 데이터를 추가해야 한다. 위의 방식으로 데이터를 추가한다면 버그를 피하기 어렵다...
플러터에서는 위젯의 내용물이 변경된다면 위젯을 새로 구성한다. 메소드를 호출해 업데이트하는 방식 대신, Constructor 를 이용한다.
BAD (MyCart.updateWith(new))
GOOD (MyCart(Content))
대신, 이 방식은 해당 위젯의 부모나 그 위가 살아있어야 가능한 방식이다. (당연히 자식을 새로 생성하려면 부모가 있어야 할 것이다.)
// GOOD
void myTapHandler(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
cartModel.add(item);
}
// GOOD
Widget build(BuildContext context) {
var cartModel = somehowGetMyCartModel(context);
return SomeWidget(
// Just construct the UI once, using the current state of the cart.
// ···
);
}
예제에서는, content가 반드시 MyApp에 있어야 한다. content가 변경된다면, 위쪽에서 MyCart를 rebuild한다. 이 방식 때문에, MyCart는 생애 주기에 대해 걱정할 필요가 없어진다. MyCart는 그저 안에 있는 내용물을 보여주기만 하면 된다! Content가 변경되면, 위젯은 사라졌다가 완전히 새 것으로 대체된다.
이것이 플러터에서 말하는 위젯의 불변함이다. 위젯은 변하지 않는다. 사라지고 새로운 위젯으로 대체 될 뿐이다.
이제 카탈로그에서 아이템에 클릭해, 카트에 담았다. 그러나, 카트가 MyListItem 위에 살아있을 때부터는 우리는 아이템을 카트에 담기 위해 어떻게 해야할까??
가장 간단한 방법은 MyListItem에게 클릭될 때마다 call할 수 있는 callback을 제공하는 것이다.
build(BuildContext context) {
return SomeWidget(
// Construct the widget, passing it a reference to the method above.
MyListItem(myTapCallback),
);
}
void myTapCallback(Item item) {
print('user tapped on $item');
}
Widget
이 방법은 꽤 괜찮다. 하지만 App state 를 많은 다른 곳에서 수정해야할 때, 수 많은 콜백들이 지나쳐가서 위젯을 금방금방 대체해야 할 것이다.
다행히, 플러터는 자신의 데이터와 서비스를 그 밑의 후손 위젯들에게 (단순히 자식 뿐만이 아니라 아래의 위젯들도 포함) 제공해주는 기능을 가지고 있다.
플러터는 모든것이 위젯이므로, 이 기능들도 특별한 종류의 위젯에 불과하다.
(InheritedWidget, InheritedNotifier, InheritedModel 등등...)
이것들은 우리가 하려는 작업에 비해 꽤 low-level의 작업이므로, 이러한 low-level의 작업들을 쉽게 할 수 있게 해주는 패키지를 사용할 것이다.
해당 패키지가 바로 "provider" 이다.
해당 패키지를 사용하려면, pubspec.yaml 파일의 dependencies 부분에 provider를 추가해야 한다.
추가했다면,
import 'package:provider/provider.dart';
로 import해 provider를 사용할 수 있다!!!
provider 를 사용하기 위해서는, 아래의 3가지 개념에 대해 알아야 한다.
Flutter의 SDK 에 있는 간단한 클래스이다. listener에게 변경 알림(?)을 제공한다.
ChangeNotifier는 App state를 캡슐화하기 위한 한 가지 방법이다.
그래서 간단한 앱에서는 하나의 ChangeNotifier면 되겠지만, 복잡한 앱에서는 여러 개의 ChangeNotifier을 사용해야 한다.
class CartModel extends ChangeNotifier {
/// Internal, private state of the cart.
final List<Item> _items = [];
/// An unmodifiable view of the items in the cart.
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
/// The current total price of all items (assuming all items cost $42).
int get totalPrice => _items.length * 42;
/// Adds [item] to cart. This and [removeAll] are the only ways to modify the
/// cart from the outside.
void add(Item item) {
_items.add(item);
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}
/// Removes all items from the cart.
void removeAll() {
_items.clear();
// This call tells the widgets that are listening to this model to rebuild.
notifyListeners();
}
}
item을 추가하고, 지울 때마다 notifyListener() 를 통해서 이 모델을 rebuild 한다.
ChangeNotifier는 flutter:foundation의 일부이며, 다른 높은 클래스에 의존하지 않는다.
ChangeNotifierProvider는 ChangeNotifier를 후손들에게 제공해주는 위젯이다. 이름에 맞게, provider 패키지에 들어가있다.
ChangeNotifierProvider는 사용하는 위젯 위에서 접근해야하기 때문에, 여기에서는 MyCart 와 MyCatalog 위에 존재한다.
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CartModel(),
child: const MyApp(),
),
);
}
ChangeNotifierProvider는 상당히 똑똑해서, rebuild가 필요할 때가 아니면 rebuild를 하지 않고, 또 해당 instance가 필요 없어지면 자동으로 dispose()를 선언한다.
여러 개를 사용하고 싶다면, MultiProvider: 를 사용하면 된다
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => CartModel()),
Provider(create: (context) => SomeOtherClass()),
],
child: const MyApp(),
),
);
}
우리가 provider를 사용하기 위해서, 접근하고자 하는 모델의 타입을 특정해야 한다. 여기서는 CartModel이므로, 우리는 CartModel 타입을 특정해 사용할 것이다.
return Consumer<CartModel>(
builder: (context, cart, child) {
return Text('Total price: ${cart.totalPrice}');
},
);
Consumer 위젯은 간단하게 이런 식으로 사용된다.
-> 우리가 notifyListener()을 호출한다.
-> Consumer 안에 있는 모든 builder 메소드가 실행된다.
return Consumer<CartModel>(
builder: (context, cart, child) => Stack(
children: [
// Use SomeExpensiveWidget here, without rebuilding every time.
if (child != null) child,
Text('Total price: ${cart.totalPrice}'),
],
),
// Build the expensive widget here.
child: const SomeExpensiveWidget(),
);
인자가 3개 필요한데,
1번째 인자는 context 이다. 모든 build method 를 사용할 때 필요하다.
2번째 인자는 instance of the ChangeNotifier 이다. 이 인자로 인해서, 우리는 해당 모델의 데이터를 사용해 정해진 지점에서 UI의 모양을 정의할 수 있다.
3번째 인자는 child 이다. 이 인자는 최적화를 위해 필요하다. (?) Consumer 아래에 모델이 변경되어도 변경되지 않을 거대한 트리 위젯들이 있다면 한 번 만들고, 빌더로 넘겨줄 수 있다.
// DON'T DO THIS
return Consumer<CartModel>(
builder: (context, cart, child) {
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Text('Total price: ${cart.totalPrice}'),
),
);
},
);
// DO THIS
return HumongousWidget(
// ...
child: AnotherMonstrousWidget(
// ...
child: Consumer<CartModel>(
builder: (context, cart, child) {
return Text('Total price: ${cart.totalPrice}');
},
),
),
);
가끔, 우리는 모델 안의 데이터는 필요없지만 그 데이터에 접근은 해야 하는 상황이 온다. Cart 안의, ClearCart가 그 예이다. 카트의 모든 것을 삭제하지만, 카트의 내용물을 보여줄 필요는 없다.
우리는 Consumer<CartModel>을 사용할 수도 있지만, 우리는 rebuilt 할 필요가 없기 때문에 많은 부분이 낭비된다.
이런 경우에는, Provider.of 를 사용할 수 있다. listen parameter를 false 로 바꾸어주기만 하면 된다.
Provider.of<CartModel>(context, listen: false).removeAll();
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:window_size/window_size.dart';
void main() {
setupWindow();
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: const MyApp(),
),
);
}
const double windowWidth = 360;
const double windowHeight = 640;
void setupWindow() {
if (!kIsWeb && (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
WidgetsFlutterBinding.ensureInitialized();
setWindowTitle('Provider Counter');
setWindowMinSize(const Size(windowWidth, windowHeight));
setWindowMaxSize(const Size(windowWidth, windowHeight));
getCurrentScreen().then((screen) {
setWindowFrame(Rect.fromCenter(
center: screen!.frame.center,
width: windowWidth,
height: windowHeight,
));
});
}
}
class Counter with ChangeNotifier {
int value = 0;
void increment() {
value += 1;
notifyListeners();
}
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'button_app',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('push_button_app'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('You have pushed the button this many times:'),
Consumer<Counter>(
builder: (context, counter, child) => Text(
'${counter.value}',
style: Theme.of(context).textTheme.headline4,
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
var counter = context.read<Counter>();
counter.increment();
},
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
전에 말한대로, pubspec.yaml 파일의 dependencies 에 패키지를 추가해야 한다!!!
Bloc, provider, Riverpod, getX 등의 많은 상태관리 기법, 패키지들이 있는데 이런 간단한 앱을 만들기 위해서 어려운 상태관리 기법들을 적용하지는 않아도 될 것이라고 생각했습니다.
참고 :
flutter docs의 상태와 기본적인 상태관리 기법
https://docs.flutter.dev/development/data-and-backend/state-mgmt/intro
상태 관리 라이브러리란? (Redux, Mobx 등)
https://velog.io/@velopert/redux-or-mobx
1번 화면에서 버튼을 누르면 2번 화면으로,
2번 화면에서 버튼을 누르면 1번 화면으로 이동하는 앱을 개발해보자.
왜 과제 이름이 Route일까?
Flutter에서는 화면들과 페이지들을 Route라고 부른다!
예제를 따라 진행해보았습니다.
일단 2개의 화면을 만듭니다
class FirstRoute extends StatelessWidget {
const FirstRoute({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Route'),
),
body: Center(
child: ElevatedButton(
child: const Text('Open route'),
onPressed: () {
// Navigate to second route when tapped.
},
),
),
);
}
}
class SecondRoute extends StatelessWidget {
const SecondRoute({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Second Route'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Navigate back to first route when tapped.
},
child: const Text('Go back!'),
),
),
);
}
}
화면 2개를 만들었다면, 1번째 페이지가 기본, push 버튼을 통해서 2번 페이지로 이동시켜야 합니다.
1번째 페이지에 버튼을 만듭니다.
Nevigator.push() 를 이용합니다.
이 때, Nevigator는 화면을 stack처럼 쌓이게 만들어줍니다.
그리고, 2번째 페이지에도 버튼을 만듭니다.
이 때는 Nevigator.pop()을 이용해, 원래 있던 1번째 화면으로 이동할 것입니다.
// Within the `FirstRoute` widget
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SecondRoute()),
);
}
// Within the SecondRoute widget
onPressed: () {
Navigator.pop(context);
}
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
title: 'Navigation Basics',
home: FirstRoute(),
));
}
class FirstRoute extends StatelessWidget {
const FirstRoute({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Route'),
),
body: Center(
child: ElevatedButton(
child: const Text('Open route'),
style: ElevatedButton.styleFrom(primary: Colors.red),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondRoute()),
);
},
),
),
);
}
}
class SecondRoute extends StatelessWidget {
const SecondRoute({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Second Route"),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Go back!'),
),
),
);
}
}
참고 :
Nevigate to a new screen and back
https://docs.flutter.dev/cookbook/navigation/navigation-basics
화면을 바꾸는데 많이 쓰이는 방법 중 하나는 'Navigation bar'입니다.
예시와 같이 화면 하단에 1, 2, 3번 화면으로 이동할 수 있는 Navigation bar가 있는 앱을 개발해보세요.
보통의 바텀 네비게이션 바의 형식은 이렇게 된다.
일반 네비게이션 바의 형식이다.
만드려면, 토글 버튼 형식으로 만들어야 할 것 같았지만
일단, 보통의 네비게이션 바 형식으로 만들어보기로 결정했다.
예제가 정말 잘 나와있어서 처음에는 예제를 따라 만들었다.
예제는 아래의 네비게이션 바 버튼을 누를 때마다, 인덱스를 받아와서 텍스트 위젯을 새로 써주는 방식이었다.
이렇게 텍스트 위젯 하나만 있을 때는, 화면을 새로 쓰지 않고 위젯을 바꾸어주기만 하면 되겠지만 위젯이 여러 개로, 복잡하게 구성되어 있다면 매우 비효율적인 방법이었다.
그래서 일단 화면을 3개 만들기로 했다.
그 다음에 화면에 네비게이션 바를 집어넣고 네비게이션 바를 누를 때마다 화면이 움직이게 만들어주어야 했다.
처음에는 4에서 이용했던 Navigator의 pushNamed 를 이용해 만드려고 했었다. NavigationBar의 요소를 클릭하면 Navigator의 pushNamed가 작동해 해당 페이지로 이동하는 것이다.
두 번째로 생각한 방법은 "네비게이션 바의 기능 중 내가 원하는 route change 같은 기능이 분명히 있을 것이다." 라고 생각하고 네비게이션 바 자체만 이용하는 방법이었다.
-> 방법이 있었다. 네비게이션 바의 버튼을 누르면 눌린 버튼의 index를 가져올 수 있다. 바뀐 index를 화면 배열의 index에 넣어서 새로운 스크린을 다시 만든다.
참고 : https://api.flutter.dev/flutter/material/BottomNavigationBar-class.html
Flutter Tutorial - NEW Material You Navigation Bar | The New Way [2021] Flutter Navigation Bar