🐤

[Android]LeakCanaryに怒られない実装をする

2021/11/22に公開

はじめに

Androidアプリのメモリリークを検出してくれる LeakCanary というライブラリがあります。

プロジェクトに導入するだけでメモリリーク発生時にログを出力してくれて便利なのですが、わりと普通に組んでいるはずなのにメモリリークで怒られて「あれー?」となることがあります。

本記事では、わりとカジュアルに発生するメモリリーク事例をピックアップし、解消方法を紹介していきます。

1. DataBinding/ViewBindingでメモリリーク

公式でも紹介されている話なので知っている方も多いかと思います。
Fragmentで以下のような実装を行うとメモリリークします。

class MainFragment(R.layout.fragment_main) {
    private lateinit var binding: FragmentMainBinding
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        binding = DataBindingUtil.bind(view)
        binding.lifeCycleOwner = viewLifeCycleOwner
    }
}

Fragment のライフサイクルと View のライフサイクルが別であり、View が破棄されても Fragment のライフサイクルが続くことが原因でリークが発生します。

解消方法には以下のようなものがあります。

  • a. onDestoryView に解放処理を入れる
  • b. binding の宣言をローカル変数にする
  • c. DataBinding-ktx / ViewBinding-ktx を使用する ←(個人的オススメ)

a. onDestroyViewに解放処理を入れる

lateinit var で宣言していた bindingprivate var で宣言し、 onDestroyView で解放処理を入れます。公式で紹介されているスタンダードな方法です。

class MainFragment(R.layout.fragment_main) {
    private var _binding: FragmentMainBinding? = null
    private val binding get() = _binding!!

    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        _binding = DataBindingUtil.bind(view)
        binding.lifeCycleOwner = viewLifeCycleOwner
    }
    
    override fun onDestroyView() {
        _binding = null
    }
}

少し冗長な以外は特に問題点はありません。

b. binding の宣言をローカル変数にする

onViewCreated 内でだけ binding を使う場合には、 binding をローカル変数として宣言すればメモリリークは防げます。

class MainFragment(R.layout.fragment_main) {
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val binding = DataBindingUtil.bind(view)
        binding.lifeCycleOwner = viewLifeCycleOwner
    }
}

基本的に DataBinding でレイアウトを組んでいて、Fragmentからは参照しない場合には使える方法かなと思います。

問題点は、 ViewBinding ではあまり使えないことです。

c. DataBinding-ktx / ViewBinding-ktx を使用する

wada811さんが公開している DataBinding-ktx / ViewBinding-ktx というライブラリを使用する方法です。

ライブラリを導入して by dataBinding() で変数を宣言するだけでバインディングを行ってくれ、解放も自動的に行ってくれるので解放忘れなども防ぐことができます。

class MainFragment(R.layout.fragment_main) {
    private val binding: FragmentMainBinding by dataBinding()
}

使用するライブラリに制限がなければ、積極的に利用していくことをオススメします。

2. RecyclerViewでメモリリーク

Fragment で RecyclerView を使用している時、他の画面へ遷移した後などに RecyclerView.adapter がメモリリークを起こすことがあります。

これはどうやら Fragment が Adapter への参照を持ってしまっていると起きるようで、

class MainFragment : Fragment() {
    val adapter = MainAdapter()
}

こちらの RecyclerView 周りで割と簡単に起きるメモリリーク 2つとその解決方法 の記事のように、 onDestroyViewRecyclerView.adapter = null を入れてあげることで解消しました。

override fun onDestroyView() {
    recyclerView.adapter = null
    super.onDestroyView()
}

3. ViewPager2でメモリリーク

ViewPager2 + FragmentStateAdapter + TabLayout の組み合わせで、別画面へ遷移した際にメモリリークが発生した例です。

Fragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    binding.pager.adapter = PagerAdapter(this)

    TabLayoutMediator(binding.tabs, binding.pager) { tab, position ->
        ...
    }.attach()
}

Adapter

class PagerAdapter(
    fragment: Fragment
) : FragmentStateAdapter(fragment) {
    ...
}

特別なことは何もしていないはずだけどメモリリークが発生しておかしい…と思っていたところ、 IssueTracker に報告がありました。

ViewPager2 holds on to FragmentStateAdapter after detach

ViewPager2 が detach 後も FragmentStateAdapter への参照を持ち続けてしまっているらしく、メモリリークが発生してしまいます。

これは IssueTracker のコメント欄にもあるように、 Adapter に Fragment を直接渡さず、 childFragmentManagerviewLifeCycleOwner.lifecycle を渡すことで解消できました。

Fragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    val lifecycle = viewLifecycleOwner.lifecycle
    binding.pager.adapter = PagerAdapter(childFragmentManager, lifecycle)

    TabLayoutMediator(binding.tabs, binding.pager) { tab, position ->
        ...
    }.attach()
}

    override fun onDestroyView() {
        binding.pager.adapter = null
        super.onDestroyView()
    }

Adapter

class PagerAdapter(
    childFragmentManager: FragmentManager,
    lifecycle: Lifecycle
) : FragmentStateAdapter(childFragmentManager, lifecycle) {
    ...
}

おわりに

ひとまずは以上です。

普通に生きているだけなのに LeakCanary に怒られてつらい…となっている人のお役に立てれば幸いです。

Discussion