Android Activityの再生成地獄:AAC ViewModelとStateFlowで『God Class』を解体した全記録

Android開発において、開発者が長年直面してきた最大のボトルネックは、OS主導のライフサイクル管理と、それに伴う状態保持の複雑さです。特に、画面回転やシステムによるプロセス破棄が発生した際、非同期処理の結果が失われたり、UI参照が切れてクラッシュしたりする問題は日常茶飯事でした。実際、私が担当していた月間アクティブユーザー(MAU)50万人のECアプリでは、以下のようなスタックトレースがCrashlyticsのトップを独占していました。

God Class症候群とライフサイクルの罠

当時のプロジェクト環境は、Android 12ターゲット、Kotlin 1.6系を使用していましたが、アーキテクチャは古いMVC(Model-View-Controller)パターンを引きずっていました。ActivityやFragmentが「UIの描画責任」と「データ管理責任」の両方を負う、いわゆるGod Class(神クラス)と化していたのです。

具体的な症状としては、ユーザーが決済画面で「購入」ボタンを押した直後に画面を回転させると、APIレスポンスが返ってきたタイミングでActivityが既に破棄されており、再生成された新しいActivityには結果が渡らない、あるいは最悪の場合ヌルポインタ参照(NPE)でクラッシュするという事象でした。

典型的なクラッシュログ:

java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState

または、非同期コールバック内での
View.findViewById(...) must not be null

本来、私たちはAndroid Architecture Components (AAC)のガイドラインに従うべきですが、レガシーコードの負債により、ライフサイクルメソッド(onCreate, onStop, onDestroy)の中にビジネスロジックが散乱し、メモリリークの温床となっていました。

失敗したアプローチ:onSaveInstanceStateへの過信

最初に試みた修正は、onSaveInstanceStateを活用して全てのデータをBundleに詰め込むことでした。しかし、このアプローチはすぐに破綻しました。

Bundleには容量制限(推奨50KB以下、最大でも1MB未満)があり、大量の商品リストや高解像度の画像データをシリアライズして保存しようとすると、TransactionTooLargeExceptionが発生しました。さらに、非同期処理(API通信)の状態そのものはBundleでは保存できないため、通信中に回転すると「ローディング表示が消えない」といったUI不整合が発生しました。これは根本的なアーキテクチャの欠陥であり、パッチワーク的な修正では対応できないことが明白でした。

ViewModelとStateFlowによるMVVMの実装

解決策は、UIコントローラー(Activity/Fragment)からデータ保持の責任を完全に分離することです。ここでは、AACの中核であるViewModelと、Kotlinの最新のリアクティブストリームであるStateFlowを組み合わせた実装を紹介します。

// 依存関係: build.gradle (App)
// implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2"
// implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

// UIの状態を表現するSealed Class(推奨)
sealed class UiState {
    object Loading : UiState()
    data class Success(val data: List<Product>) : UiState()
    data class Error(val message: String) : UiState()
}

class ProductViewModel(
    private val repository: ProductRepository
) : ViewModel() {

    // Backing propertyによるカプセル化
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState.asStateFlow()

    init {
        fetchProducts()
    }

    fun fetchProducts() {
        // viewModelScopeはViewModelのクリア時に自動的にキャンセルされる
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val result = repository.getProducts()
                _uiState.value = UiState.Success(result)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message ?: "Unknown Error")
            }
        }
    }
    
    // ViewModelはActivity再生成後も生き続けるため、
    // ここで保持したデータは回転後も即座に利用可能
}

上記のコードで重要なのは、viewModelScopeの使用とStateFlowによる状態管理です。viewModelScopeを使用することで、ViewModelが破棄される(ユーザーが画面を閉じる)際に自動的にコルーチンがキャンセルされ、メモリリークを防ぎます。また、StateFlowは初期値を持ち、常に最新の状態を保持するため、画面回転後の再講読時にも即座にデータをUIに反映できます。

Activity側の実装(Lifecycle-Aware)

Activity側では、ロジックを持たず、ViewModelの状態を「監視(Observe)」するだけに留めます。これにより、Activityは純粋なViewとしての役割に集中できます。

class ProductActivity : AppCompatActivity() {

    // ‘by viewModels()’ delegateを使用(要Fragment KTX)
    private val viewModel: ProductViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_product)

        // repeatOnLifecycleを使用することで、バックグラウンド時の無駄な更新を停止
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is UiState.Loading -> showProgressBar()
                        is UiState.Success -> updateList(state.data)
                        is UiState.Error -> showError(state.message)
                    }
                }
            }
        }
    }
}

ここでrepeatOnLifecycleを使用している点がパフォーマンス上の鍵です。アプリがバックグラウンドに回った際、古いLiveDataでは監視が継続されるケースがありましたが、このAPIを使用することで、UIが表示されていない間のリソース消費を完全にカットできます。

指標レガシー実装 (MVC/Callback)モダン実装 (MVVM/AAC)
画面回転時のクラッシュ率2.4%0.05%未満
Activityの行数850行以上220行
メモリリーク発生件数頻発 (Context保持)ほぼゼロ
テスタビリティ困難 (Android依存)容易 (JUnitのみで可)

導入後の成果は劇的でした。上記の表が示す通り、最も重要だったのはクラッシュ率の激減です。ViewModelがライフサイクルのオーナーシップを適切に分離したことで、Activityが破棄されてもデータ取得プロセスは継続し、再生成されたActivityが「結果だけを受け取る」構造が確立されました。これにより、ユーザー体験(UX)が大幅に向上しました。

公式ドキュメント: StateFlowとSharedFlowの詳細

注意点:システムによるプロセス死への対応

ViewModelは「構成変更(Configuration Change)」には耐えられますが、「システムによるプロセス破棄(Low Memory Killer)」には耐えられません。アプリがバックグラウンドに長時間置かれ、OSによってプロセスごとキルされた場合、ViewModelのメモリ内データも消滅します。

警告: プロセス復帰後もデータを維持する必要がある場合は、AACのSavedStateHandleを併用する必要があります。

SavedStateHandleをViewModelのコンストラクタに注入することで、最低限のIDや検索クエリなどを永続化し、プロセス復帰時にそのIDを使ってリポジトリからデータを再取得するロジックを組むのが、エンタープライズ品質のAndroidアプリにおける鉄則です。

結論

Android Architecture Components (AAC) の導入は、単なるコードの整理整頓ではありません。それは、Activityのライフサイクルという「制御不能な波」からビジネスロジックを守るための防波堤です。ViewModelとStateFlow、そしてCoroutinesを適切に組み合わせることで、開発者はボイラープレートコードとの戦いから解放され、本質的な機能開発に集中できるようになります。もし現在もGod Class化したActivityと格闘しているのであれば、まずは小さな画面からViewModelへの移行を試みることを強く推奨します。

Post a Comment