普段はReactとかRailsとか触ってるエンジニアが、
MIXIさんの新卒技術研修資料でFlutterに入門してみたのでメモを残してみる。
やったやつ
スライド / リポジトリ / 実際の研修時動画があってわかりやすかった!
簡単な基礎学習の後にOpenAI APIを使ったチャットアプリを作っていく感じ。
環境構築
資料では環境構築について特に制約なさそうだったので自由に構築してみた。
まずは以下の記事などを参考にFVM(Flutter SDKのバージョン管理ツール)を入れる。
開発環境の構築は以下の記事を参考にした。
とりあえずこれでアプリの新規作成と起動はできるようになった。
トピックごとのメモ
ウィジェット
基本的にはウィジェットというものを組み合わせて画面を構築していく。Reactだとコンポーネント的なもの?
アプリを新規作成した際に用意されているコードを見ていく。
- main関数でrunAppにMyAppWidgetを渡してルートとなるウィジェットを定義している
- MyApp内ではbuildメソッドをorverrideしている。このbuildメソッドの中でreturnしているウィジェットが画面に描画される
- MyHomePageもウィジェットで、この中で色々なウィジェットを使って画面を構築している(後述
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
State
以下のようにWidgetを定義する
- Stateを扱わない=状態を持たない場合は、StatelessWidgetを継承する
- Stateを扱う=状態をもつ場合は、StatefulWidgetを継承する
StatefulWidgetについて
- クラス定義(お作法的なもの)
- StatefulWidgetを継承したクラスを用意する(MyHomePage
- Stateを継承したクラスを用意する(_MyHomePageState
- StatefulWidgetクラスのcreateStateメソッドでStateクラスを返す
- Stateの更新、再描画
- Stateクラスの中で変数を用意する(_counter
- setStateメソッド内で変数を更新する(setStateを使わないと次の再描画が行われない
- 変数が更新されるとbuildメソッドが再実行され描画も更新される
再描画周りについてはReactとイメージ近そう
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
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.headlineMedium,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
画面遷移
Flutterの画面遷移はいくつか方法があるらしい。今回の資料ではNavigatorを使っている。
箱の中にページウィジェットを積んでいき、一番上のものが画面に描画されるイメージ。スライド内のイラストがわかりやすかった。
- Navigator.pushで積む
- Navigator.popで取り除く(戻る)
push先のページでpopした際に戻ったページに値を渡したりもできる
以下の例だと、
pageAでpageBにpush → pageBでpopとした際に、text変数には'Test Text'が入る。
// pageA
final String text = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PageB(),
fullscreenDialog: true,
),
);
// pageB
Navigator.pop(context, 'Test Text');
HTTP通信
dartの標準ライブラリhttpを使った。
特にクセはなさそう?
import 'package:http/http.dart' as http;
var url = Uri(
scheme: 'https',
host: 'api.openai.com',
path: '/v1/chat/completions',
);
final response = await http.post(
url,
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer $token",
},
body: json.encode({
"model": "gpt-3.5-turbo",
"messages":
state.messages.map((message) => message.toJson()).toList(),
}),
).timeout(const Duration(seconds: 20));
オブジェクト ↔ JSONの変換
json_serializableを使った。
以下のようなクラスを用意してflutter pub run build_runner build
を実行すると、
fromJson、toJsonメソッドを生やしてくれるやつ。便利!
import 'package:json_annotation/json_annotation.dart';
part 'answer.g.dart';
@JsonSerializable()
class Answer {
String id;
int created;
String model;
Usage usage;
List<Choice> choices;
Answer(
this.id,
this.created,
this.model,
this.usage,
this.choices,
);
factory Answer.fromJson(Map<String, dynamic> json) => _$AnswerFromJson(json);
Map<String, dynamic> toJson() => _$AnswerToJson(this);
}
こんな感じで使える
final answer = Answer.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
);
ローカルDB
端末上へのデータ保存方法としてhiveを使った。キーバリューストア型のDB
テーブル?をBoxという形で表す。
独自クラスのオブジェクトを保存するためにhive_generatorも使う。
こんな感じでHiveObjectを継承したクラスを定義する。
import 'package:hive_flutter/hive_flutter.dart';
part 'message.g.dart';
@HiveType(typeId: 0)
class MessageItem extends HiveObject {
@HiveField(0)
String content;
@HiveField(1)
String role;
MessageItem({
required this.content,
required this.role,
});
}
こんな感じで使える。
// Boxの取得
late final Future<Box<MessageItem>> messageBox = Hive.openBox('/messages');
final box = await messageBox;
// Box内のオブジェクトを取得
final messages = box.values.toList();
// Boxへオブジェクトを追加
final message = MessageItem(
role: 'user',
content: event.message,
);
box.add(message);
// Box内の全オブジェクトを削除
box.deleteAll(box.keys);
状態管理
状態管理はBLoCパターンというデザインパターンで実装。
ライブラリにはflutter_blocを使う。
BLoCパターンについては以下の記事がわかりやすかった。
BLoCパターン
Googleが提唱するデザインパターン。
Business Logic Componentの略で、ビジネスロジックをコンポーネントに集約して扱う。
Streamと状態管理クラスを使って以下のように状態を管理、UIに反映していく。
- 状態管理クラスにStreamでEventを流す
- Eventを受け取った状態管理クラスはEventにしたがって新しい状態を生成する
- Providerを介してUIに状態を流す。UI側は状態の変更通知があれば新しい状態で再描画する
flutter_bloc
BLoCパターンを実装しやすくするライブラリ
主に以下の3つのクラスを使用して実現する?
- Stateクラス(状態を表すクラス)
- Eventクラス(Blocクラスに渡すイベントを表す)
- Blocクラス(状態を管理するクラス)
クラス定義
// 状態を表す。
class ChatState extends Equatable {
final List<MessageItem> messages;
const ChatState({
this.messages = const [],
});
@override
List<Object?> get props => [messages];
}
// イベントの基底クラス
abstract class ChatEvent {
const ChatEvent();
}
// messageを追加するための具象クラス
class ChatSend extends ChatEvent {
final String message;
const ChatSend({required this.message});
}
// 状態管理クラス。ChatEventを受け取り、ChatStateを管理する
class ChatBloc extends Bloc<ChatEvent, ChatState> {
// イベントごとにハンドラをマッピングする
ChatBloc() : super(const ChatState()) {
on<ChatSend>(_onChatSend);
}
// イベントを受け取り、新しい状態を流す。
Future<void> _onChatSend(ChatSend event, Emitter<ChatState> emit) async {
final message = MessageItem(
role: 'user',
content: event.message,
);
// 新しい状態を生成してemitで流す。
emit(ChatState(messages: [...state.messages, message]));
}
}
UI側で別途Blocのプロバイダを指定したり、状態の変更通知Listenerを設定する必要はあるが、以下のように使える
//stateからmessagsを取得する
state.messages.toList()
// ChatBlocを取得し、ChatSendイベントを流す(messageを追加する)
final chatBloc = context.read<ChatBloc>();
chatBloc.add(ChatSend(message: 'text'));
なんとなくRedux味がある
状態管理には他にもProviderやRiverpodなるものがあるらしいのでそちらも見てみたい。
まとめ
個人的には、Reactと似ている部分も多く入門しやすかったなーという印象。
ひとまず何かしらのアプリをリリースするところまでやってみる!