みなさん、こんにちは!Androidアプリ開発グループでAndroidエンジニアをしている
大西 泰生(@taisei59119317)です!
今年の3月に入社して早4ヶ月、まだ慣れないこともたくさんありますが、日々楽しくAndroid開発ライフを送ることができています✨ 今回は入社エントリも兼ねた初めての投稿になります。
これまで約3年間Flutterをメインに使ってきて、個人でもチームでもアプリ開発を行ってきました。クロスプラットフォームで動く手軽さと、UIの柔軟さが気に入って、ずっとFlutter中心でやってきましたが、今回思い切って、Androidネイティブ開発に挑戦することを決めました。
転向したきっかけとしては、Flutterを触る上でネイティブ知識が要求される場面があり、「モバイル開発をもっと深く理解したい」と思ったことでした。そして、ちょうどそんなチャレンジができる環境がkubellにあることを知り、思い切って飛び込んだという感じです。
というわけで、この記事ではFlutterエンジニアからAndroidエンジニアに転向した経験を通して、何が活きて、何が大変だったのかをまとめてみました。同じようにFlutterからAndroid開発への転向に興味がある方の参考になれば嬉しいです!
なお、この記事はFlutterとAndroidネイティブの優位性を比較・評価するものではありません。あくまで一人のエンジニアとしての体験と気づきをベースに書いています。
🎯 対象読者
- Flutter経験者でAndroidネイティブ開発に興味がある方
- クロスプラットフォーム開発からネイティブ開発への転向を考えている方
📚 これまでの経験
私は新卒で国家公務員として働いていましたが、「もっと手を動かしてものづくりがしたい」という想いから、思い切ってベンチャー企業に転職しました。とはいえ、当時はまだ自分のやりたいことがはっきりしておらず、動画編集やカスタマーサポート、イベントカメラマンなど、さまざまな業務を経験しながら、少しずつ自分のキャリアを模索していました。そんな中で転機となったのが、自社サービスのデータを集計し、社内チャットへ自動通知する業務でした。Google Apps Scriptを使って「毎日の単純作業を自動化できる」という体験がとても新鮮で、感動したのを覚えています。
そこから独学でプログラミングを学び始め、Flutterに出会ったことでモバイルアプリ開発に本格的に取り組むようになりました。Flutterのホットリロード機能や宣言的なUI構築は直感的で、「エンジニアになりたい」と強く思うようになり、無事にエンジニア転職を果たしました。
その後、約3年間Flutterを使ったモバイルアプリケーションの開発を行ってきました。
🚀 kubellに入社
kubellでは、ChatworkのAndroidアプリの開発に携わっています。ベンチャー時代に実際にユーザーとして使っていたサービスの開発に携われるというのは、ちょっと不思議な気持ちもありつつ、素直に嬉しい体験です。
🔧 入社して取り組んだこと
入社して最初に担当したのは、既存のテストコードの改修でした。具体的には、プロジェクト内で使われていたKotest
(Kotlinのテスティングフレームワーク)のStringSpecベースのテストを、より構造化されたDescribeSpec形式に書き換えていくというタスクです。
ここで少しこのタスクに取り組んだ背景をお話しすると、もともとこのプロジェクトではStringSpecとDescribeSpecが混在していて、完全に統一されていない状態でした。実際、テストの書き方が統一されていないと「どちらの記法で書くべきか」と迷ってしまったり、既存のテストを読む際にも構造がバラバラで把握しづらかったりします。特に自分のように新しく入ったメンバーにとっては、こうした状況が学習コストの高さにつながっていると感じていました。
そこで、DescribeSpecに統一したことでテストの記法が揃い、どのテストも一貫したルールで理解できるようになりました。このタスクを通じて、「describe/itの階層で仕様ごとにグループ化され、初めて見るテストでも全体像がすぐにつかめる」「他の人が書いたテストを参考に、自分のテストも迷わず書ける」といったメリットを、日々の開発の中で感じるようになりました。
そして、テストコードの改修が一段落したタイミングで、実際の施策実装を担当することになりました。ここから初めて、Jetpack Composeを使ったUI実装や、ViewModelによる状態管理に取り組むことになったのですが、開発を進める中で「これ、Flutterでも見たことあるな〜」と感じる場面が何度かありました。
✨ Flutterの経験が活きた瞬間
そんな「Flutterでも見たことあるな〜」という瞬間について、実際の業務や個人での学習を通じて、Flutterでの経験が具体的にどう活かされたのか、特に印象深かったポイントをまとめてみました。
1. 状態管理の理解
Flutterでは、Riverpodを使った状態管理に慣れていました。「状態が変わったらUIが自動で更新される」というリアクティブプログラミングの考え方です。
AndroidのViewModel + StateFlowを初めて見た時、Riverpodの状態管理と似ていることに気づきました。Riverpodで「Providerを監視してウィジェットを再描画する」という概念に慣れていたおかげで、StateFlowの値をcollectAsStateWithLifecycleで監視し、ライフサイクルに応じて自動的に購読・解除されるComposeのStateに変換し、そのStateを監視してComposableが再コンポーズされる仕組みもすぐに理解できました。
以下は、ボタンを押すたびにカウントが1ずつ増えるカウンターアプリのサンプルコードです。
// Flutter(Riverpod) class CounterNotifier extends Notifier<int> { @override int build() => 0; void increment() => state++; // ← 状態を更新 } final counterProvider = NotifierProvider<CounterNotifier, int>(() => CounterNotifier()); class CounterScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final count = ref.watch(counterProvider); // ← 状態を監視 return Column( children: [ Text('Count: $count'), ElevatedButton( onPressed: () => ref.read(counterProvider.notifier).increment(), child: Text('Increment'), ), ], ); } }
// Android(Jetpack Compose + StateFlow) class CounterViewModel : ViewModel() { private val _count = MutableStateFlow(0) val count: StateFlow<Int> = _count.asStateFlow() fun increment() { _count.value++ } // ← 状態を更新 } @Composable fun CounterScreen(counterViewModel: CounterViewModel = viewModel()) { val count by counterViewModel.count.collectAsStateWithLifecycle() // ← StateFlowをComposeのStateに変換し監視 Column { Text(text = "Count: $count") Button(onClick = { counterViewModel.increment() }) { Text("Increment") } } }
状態を保持してUIに反映するという「状態管理」の考え方はFlutterと共通しており、過去の経験がそのまま活かせたのは大きな助けになりました。
2. UI構築の考え方
Jetpack ComposeとFlutterは、どちらも宣言的UIを採用していて、「現在の状態に基づいてUIを構築する」という考え方が完全に一致していました。
UIを組んでいく過程もほとんど似通っており、FlutterのWidget構成(Column
、Row
、Container
)と、Composeの構成(Column
、Row
、Box
)がほぼ1対1で対応していました。
以下は、FlutterとJetpack Composeで同じUIを構築する場合のサンプルです。
// Flutter Column( children: [ Container( padding: EdgeInsets.all(16), child: Text('Hello World!'), ), Row( children: [ Expanded(child: TextField()), IconButton( onPressed: () {}, icon: Icon(Icons.send), ), ], ), ], );
// Jetpack Composeでの同等UI構築例 Column { Box( modifier = Modifier.padding(16.dp) ) { Text("Hello World!") } Row { TextField( value = "", onValueChange = {}, modifier = Modifier.weight(1f) ) IconButton(onClick = {}) { Icon(Icons.Default.Send, contentDescription = "Send") } } }
一つ面白い違いとして、Jetpack ComposeにはModifier
という概念があります。FlutterではWidgetごとに個別のプロパティ(padding、margin、widthなど)を設定しますが、ComposeではModifier
を使ってレイアウトやスタイリングを統一的に扱えます。最初はModifier
の適用順序によってレイアウトが崩れるなど苦戦しましたが、慣れてみるとModifier.padding().fillMaxWidth().clickable()
のようにチェーンして書けるのがとても便利でした。
このように、基本的なUI構築の考え方は非常に似ているのですが、パフォーマンス面でも共通点が多く、Flutterの経験がそのまま活かせました。ComposeのRecompositionはFlutterのWidget rebuildと同じ考え方で、状態が変わった時に必要な部分だけを再描画する仕組みです。また、flutter_hooksを使っていた経験から、remember
やderivedStateOf
による値のメモ化が、flutter_hooksのuseMemoized
と似ていることもすぐに理解できました。
ただ、実際に使ってみると、再コンポジションの最適化でComposeとFlutterに違いがありました。Flutterでは親ウィジェットがリビルドされると、基本的には子ウィジェットも一緒にリビルドされます。一方、Composeは親コンポーザブルが再コンポジションされても、子コンポーザブルに渡されるパラメータが変わっていなければ、子の再コンポジションを自動的にスキップしてくれます。
Flutterエンジニアには、Riverpodのref.watch(hogeProvider.select(...))
のような最適化をComposeが自動でやってくれる、と言えば分かりやすいかもしれません。Flutterでパフォーマンスを意識していた部分を、Composeでは自動処理してくれるのはかなり便利だなと感じました。
⚠️ 大変だったこと
ここまでAndroidの開発においてFlutterの経験が活かせた部分について書いてきましたが、もちろんすべてが順調だったわけではありません。Android独特の概念に慣れるまでは、本当に苦労しました。(現在も苦労しています)
1. Android View(XML)開発の複雑さ
ChatworkのAndroidアプリでは、新機能はJetpack Composeで開発していますが、既存のコードは従来のAndroid View(XML)で構築されているため、両方の理解が必要でした。
Flutterの宣言的UIに慣れていた私にとって、XMLベースのUI構築は想像以上に複雑でした。FlutterではColumn
、Row
、Container
などのWidgetで、一つのファイル内でUIとUIロジックが完結するのに対し、Android ViewではConstraintLayout
、LinearLayout
、RelativeLayout
といった複数のレイアウトを使い分け、XMLでUIを定義した後にKotlinコードで操作するという分離されたアプローチに戸惑いました。
以下は、Android View(XML)とFlutterで同じ横並びレイアウトを実現する場合の比較例です。
<!-- Android View(XML)での横並びレイアウト例 --> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <TextView android:id="@+id/title" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" /> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </LinearLayout>
// Flutterでの同等レイアウト例 Row( children: [ Expanded(child: Text('title')), TextButton(onPressed: () {}, child: Text('button')), ], )
上記の例を見ると、同じ横並びレイアウトでも、Android ViewではXMLの属性設定(layout_weight
、layout_width
など)が複雑で、FlutterのExpanded
ウィジェットの方が直感的に感じました。
2. Android独自のコンポーネントとライフサイクル
Activity
とFragment
の使い分けや、画面遷移時のIntent
を使ったデータ受け渡しなどFlutterにはないAndroid独自のコンポーネントの理解が大変でした。
以下は、FlutterとAndroidで画面遷移を行う場合のサンプルです。
// Flutterでの画面遷移例
Navigator.push(context, MaterialPageRoute(
builder: (context) => DetailScreen(user: user)
));
// Androidでの画面遷移例 val intent = Intent(this, DetailActivity::class.java).apply { putExtra("USER_ID", user.id) putExtra("USER_NAME", user.name) } startActivity(intent)
また、ライフサイクルについても、FlutterではStatelessWidget
やStatefulWidget
のライフサイクルを理解すれば済んだのに対し、AndroidではActivityとFragmentでそれぞれ異なるライフサイクルがあり、しかもそれらが組み合わさって動作するという点の理解に苦労しました。
💡 転向を通じて得られたもの
ここまで苦労した点を書いてきましたが、振り返ってみると転向してよかったなと感じています。
一番の収穫は、技術の背景にある設計思想や実装理由への理解が深まったことです。Flutterだけを使っていた頃は、フレームワークが提供するAPIや仕組みをそのまま受け入れていましたが、Androidネイティブの実装やプラットフォーム固有の制約を知ることで、Flutterが抽象化している部分や、なぜそのような設計になっているのかが見えるようになりました。
そして何より嬉しかったのは、「宣言的UI」や「状態管理」といった考え方が、プラットフォームを超えて共通していることを実感できたことです。今後iOS開発や他のクロスプラットフォーム開発に携わる際にも応用が効く知識だと思っています。
🌟 おわりに
FlutterからAndroidへの転向は「似てるけど違う」という発見の連続で、想像以上に学びの多い体験でした。今後はこの経験を活かして、Chatworkアプリをより良いものにしていきたいと考えています!
特にFlutter経験者の方にとっては、現在のAndroid開発の主流であるJetpack ComposeがFlutterと非常に似ているため、思っている以上にスムーズに転向できるのではないかと思います。実際、私自身も記事で紹介したように「あ、これFlutterと同じ考え方だ」と感じる場面が何度もあり、きっと同じような発見をたくさん得られるはずです。
そして、もし私と同じような挑戦をしてみたい、ChatworkのAndroidアプリをより良いものにしていきたいという方がいらっしゃれば、ぜひ一緒に働きませんか?