Jetpack Composeで無理やりSharedFlowを監視したら失敗だった話
IT技術
こんにちは、Androidアプリエンジニアのみなさん。
はじめに
Jetpack Composeの導入、進んでいますか?
新規プロダクトなら「フルCompose」で気持ちよく開発できますが、歴史あるプロダクトや大規模なアプリでは、まだまだActivityやFragmentの遷移を残しつつ、
Viewの中身だけComposeに置き換えるというハイブリッドな構成で戦っている方も多いのではないでしょうか。
そんなActivity/FragmentとComposeが共存するハイブリッドな開発環境において、
「Activityのコードを減らしたい」「将来の完全Compose移行を考慮したい」という動機から、
SharedFlowをComposable内で処理しようとして
失敗してしまった経験を共有します。
やってしまった失敗実装
ViewModelからのワンショットイベント(SharedFlow)をComposable側で受け取るため、以下のような拡張関数を自作しました。
1// Composable
2@Composable
3@SuppressLint("ComposableNaming")
4inline fun SharedFlow.collectWithLifecycle(
5 lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
6 minActiveState: Lifecycle.State = Lifecycle.State.CREATED,
7 collector: FlowCollector
8) {
9 LaunchedEffect(Unit) {
10 lifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) {
11 this@collectWithLifecycle.collect(collector)
12 }
13 }
14}実装イメージ(真似しないで)
これを使えば、Composable関数側でイベントハンドリングが完結するように見えます。
1// Composable
2@Composable
3fun HogeScreen( // ActivityのsetContentで呼び出すComposable関数
4 viewModel: HogeViewModel = hiltViewModel(),
5 onNavigate: () -> Unit // Activity側ではstartActivityを呼び出す想定
6) {
7 // ViewModelのイベントを監視
8 viewModel.navigateEvent.collectWithLifecycle {
9 // イベントが来たらコールバックを実行(Activity遷移など)
10 onNavigate()
11 }
12
13 // ... UIの実装
14}一見、とてもスマートに見えました。Activityのコードは減り、ロジックはComposableに寄る。
しかし、このアプローチは大きな間違いでした。
なぜ失敗だったのか
結論から言うと、SharedFlowだと監視外のイベントが喪失してしまうためです。
SharedFlowは、監視前に発火したイベントを拾えません。
(replayを使えば拾えますが、replay = 1などでキャッシュを持たせると、今度は「画面回転しただけでイベントが発火する」というバグを生み出します。)
なので基本的に
画面遷移直後に「トースト表示イベント」や「画面遷移のイベント」を発火したい。
という要件があっても
ライフサイクルが非アクティブ(Resumedじゃない)状態だとイベントを拾えません。
Android開発公式も「UIレイヤーでのワンショットイベント監視」はアンチパターンとしています。
https://proandroiddev.com/android-one-off-events-approaches-evolution-anti-patterns-add887cd0250
Composeは「宣言的UI」です。「イベントが起きたらどうする」ではなく、
「今の状態(State)はどうあるべきか」を記述するフレームワークです。
どうすべきだったか
今回の失敗を経て、2つの解決策にたどり着きました。
Googleが推奨する「State管理」への移行か、あるいは「Activityでの監視」に戻るかです。
アプローチ1:【推奨】Stateで管理する(Composeの流儀)
最もComposeらしい解決策は、イベントを流すのをやめ、「遷移すべき状態かどうか」をStateとして保持することです。
1// ViewModel
2class HogeViewModel : ViewModel() {
3 private val _uiState = MutableStateFlow(HogeUiState())
4 val uiState = _uiState.asStateFlow()
5
6 fun onLoginSuccess() {
7 // イベントではなく、フラグを立てる
8 _uiState.update { it.copy(shouldNavigateToHome = true) }
9 }
10
11 fun onConsumedNavigation() {
12 // 遷移完了したらフラグを下ろす
13 _uiState.update { it.copy(shouldNavigateToHome = false) }
14 }
15}1// Composable
2@Composable
3fun HogeScreen(
4 viewModel: HogeViewModel = hiltViewModel(),
5 onNavigate: () -> Unit
6) {
7 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
8
9 // 状態の変化を検知して副作用を実行
10 LaunchedEffect(uiState.shouldNavigateToHome) {
11 if (uiState.shouldNavigateToHome) {
12 onNavigate()
13 viewModel.onConsumedNavigation()
14 }
15 }
16}アプローチ2:【現実解】SharedFlowを維持し、Activityで監視する
もし、既存のViewModelが大量にあり、すべてをState管理に書き換えるコストが高い場合、
あるいは「ワンショットイベントはイベントとして扱いたい」という場合はどうすべきでしょうか。
その場合の正解は、「Activityのコードを減らしたい」という欲求をグッとこらえて、素直にActivity/Fragmentで監視することです。
Composableには「イベントが起きたときのコールバック」だけを渡し、イベントの監視と発火は親であるActivityが行います。
1// Activity
2class HogeActivity : ComponentActivity() {
3 private val viewModel: HogeViewModel by viewModels()
4
5 override fun onCreate(savedInstanceState: Bundle?) {
6 super.onCreate(savedInstanceState)
7
8 // イベント監視はActivityの責務とする
9 lifecycleScope.launch {
10 repeatOnLifecycle(Lifecycle.State.STARTED) {
11 viewModel.navigateEvent.collect {
12 // ここで画面遷移などの処理を行う
13 startActivity(...)
14 }
15 }
16 }
17
18 setContent {
19 HogeScreen(
20 // ComposableにはViewModelを渡さず、
21 // 必要なアクション(UIイベント)だけをコールバックで受け取る設計にするのが理想
22 onButtonTap = { viewModel.onLogin() }
23 )
24 }
25 }
26}まとめ
Activity/Fragmentからの移行期には、つい「命令的」な思考のままComposeを書いてしまいがちです。
特にSharedFlowを使ったイベントバス的な処理をComposable内で自作拡張関数を使って処理しようとすると、
今回のような落とし穴にはまります。
- 理想: フラグ管理(State)に移行して、Composeのライフサイクルに委ねる。
- 現実解: イベント(SharedFlow)のまま行くなら、Composableで無理せずActivityで監視する。
「無理やりSharedFlowをComposableで監視する拡張関数」を作りたくなったら、この2つのどちらかの道を選ぶことを強くおすすめします。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!カジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ






