ViewModelを共有したら地獄を見た話 〜LiveData暴走事件〜

※ChatGPTを使用して記事を作成しています。

はじめに

Android開発において、ViewModelはもはや欠かせない存在です。
UIの状態管理、データ保持、ライフサイクルとの連携。
ActivityやFragmentの境界を超えてデータを安全に扱える、まさに救世主のような存在──。

…のはずでした。

しかし、そんな「ViewModel」を安易に共有した結果、私はLiveDataの暴走地獄に巻き込まれることになりました。

今回は、Fragment間でViewModelを共有したときに起きたリアルなバグとその修正方法を、実際のコードとともに紹介します。

きっかけ:Fragment間でデータを共有したい!

当時、開発していたアプリには次のような構成がありました。

  • メイン画面(MainFragment
  • 詳細画面(DetailFragment

MainFragment のリストでアイテムを選ぶと、DetailFragment に選択したデータを表示する──という単純な流れです。

Fragment同士で選択データを共有する方法として、私は次のように考えました。

「ViewModelをActivityスコープで共有すれば、Fragment間通信いらないじゃん!」

これは多くの開発者が一度は通る道でしょう。
しかし、この判断が地獄の入り口でした。

失敗コード例:共有ViewModelで大混乱

class SharedViewModel : ViewModel() {
    val selectedItem = MutableLiveData<Item>()

    fun select(item: Item) {
        selectedItem.value = item
    }
}

MainFragment ではアイテム選択時に select() を呼び出します。

class MainFragment : Fragment() {
    private val viewModel: SharedViewModel by activityViewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        adapter.onItemClick = { item ->
            viewModel.select(item)
            findNavController().navigate(R.id.action_main_to_detail)
        }
    }
}

DetailFragment では、その値を監視してUIに反映します。

class DetailFragment : Fragment() {
    private val viewModel: SharedViewModel by activityViewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel.selectedItem.observe(viewLifecycleOwner) { item ->
            binding.textView.text = item.name
        }
    }
}

この時点では「うまく動いてる!」と思いました。

しかし、アプリを何度か操作しているうちに、
謎の挙動 が発生しました。

発生したバグ

  • 以前に選択したアイテムが別の画面で突然表示される
  • DetailFragment を閉じても LiveData のイベントが再度発火する
  • 別のFragmentが意図せず observe() の通知を受ける

まさに、LiveDataの幽霊現象
UIが勝手に更新されたり、非表示Fragmentがデータを受け取ったり。

原因を探るため、Log.d を埋め込んで調査してみると──

D/ViewModel: selectedItem changed: Item(id=5)
D/DetailFragment: observed item: 5
D/DetailFragment: observed item: 5 ← なぜか2回目が来る!

再表示時に古い値が通知されていたのです。

原因:LiveDataは「最後の値」を覚えている

LiveDataは、最後にセットされた値を保持し続けるという特性を持ちます。
そのため、新しく observe() したタイミングで、過去の値が再通知されるのです。

さらに今回のようにActivityスコープでViewModelを共有している場合、Fragmentを閉じてもViewModelは生き続けるため、古いデータが新しいFragmentにも届いてしまうことになります。

修正版1:イベントを一度きりにする(SingleLiveEvent)

まず最初に試したのが、「一度だけ通知されるLiveData」に変更する方法です。

class SingleLiveEvent<T> : MutableLiveData<T>() {
    private val observers = HashMap<Observer<in T>, Observer<in T>>()

    override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
        val wrapper = Observer<T> { t ->
            if (t != null) {
                observer.onChanged(t)
                value = null // 一度だけ通知してリセット
            }
        }
        observers[observer] = wrapper
        super.observe(owner, wrapper)
    }

    override fun removeObserver(observer: Observer<in T>) {
        val realObserver = observers.remove(observer)
        super.removeObserver(realObserver ?: observer)
    }
}

ViewModel側ではこれを使うようにします。

class SharedViewModel : ViewModel() {
    val selectedItem = SingleLiveEvent<Item>()

    fun select(item: Item) {
        selectedItem.value = item
    }
}

これにより、DetailFragment が再度作成された場合でも、古いイベントは再通知されません

ただし、この方法にも欠点があります。
値を保持しないため、画面再生成(例: 画面回転)時にはデータが失われるのです。

修正版2:StateFlowで状態管理する

よりモダンで安全な方法がこちら。
StateFlow を使うと、値の再送や重複通知を制御しやすくなります。

class SharedViewModel : ViewModel() {
    private val _selectedItem = MutableStateFlow<Item?>(null)
    val selectedItem: StateFlow<Item?> = _selectedItem

    fun select(item: Item) {
        _selectedItem.value = item
    }

    fun clearSelection() {
        _selectedItem.value = null
    }
}

DetailFragment 側では、collect() で監視します。

lifecycleScope.launchWhenStarted {
    viewModel.selectedItem.collect { item ->
        item?.let {
            binding.textView.text = it.name
        }
    }
}

StateFlowは常に最新値を保持しつつ、
clearSelection() を呼ぶことで不要な再通知を防止できます。

さらに、Lifecycle と連動しているため、
非表示Fragmentが勝手に更新されることもありません。

修正版3:FragmentスコープのViewModelを使う

「そもそも共有しない」という選択肢も有効です。
Fragmentごとに独立したViewModelを使い、必要なデータだけを引き渡す設計です。

val viewModel: DetailViewModel by viewModels {
    DetailViewModelFactory(itemId)
}

あるいは、SafeArgsなどでデータを安全に渡す方法もあります。

findNavController().navigate(
    R.id.action_main_to_detail,
    bundleOf("itemId" to item.id)
)

共有ViewModelは便利ですが、依存関係が強すぎると管理不能になります。
「スコープを広げる」ほど、バグの影響範囲も広がるのです。

教訓

この事件を通して学んだのは、「ViewModel共有」は魔法ではない、ということです。

Fragment間でデータを共有できるのは確かに便利ですが、適切なライフサイクル設計とスコープ管理をしなければ、思わぬ再通知・データ汚染・UI暴走 が簡単に起こります。

再発防止チェックリスト ✅

  1. activityViewModels() は「状態共有が本当に必要なときだけ」使う
  2. 一度きりのイベントには SingleLiveEvent / SharedFlow を使う
  3. StateFlow で状態を明示的に管理する
  4. Fragmentを再生成しても動作が破綻しないように設計する
  5. LiveDataの「最後の値が再通知される」特性を理解して使う

まとめ

ViewModelの共有は、設計次第で強力にも危険にもなります。

「どのスコープで状態を保持すべきか?」

「どのイベントは一度きりなのか?」

その線引きを明確にしないと、LiveDataが暴走してアプリが壊れます。

タイトルとURLをコピーしました