Aengus 的技术小筑

Aengus | Blog

聊聊 Activity 持有 ViewModel 的清理

1246
2021-12-31

在"单 Activity"架构的 App 中,页面功能常常由 Fragment 承载,而 Fragment 之间的通信方式往往有两种:通过 Fragment Result API 或 ViewModel;通过 Fragment Result API 的格式如下:

class FragmentA : Fragment() {
	override fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
    	// 此函数在 fragment-ktx 中
    	setFragmentResultListener("requestKey") { requestKey, bundle ->
    	    val result = bundle.getString("bundleKey")
    	}
	}
}

class FragmentB : Fragment() {
  	override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  		button.setOnClickListener {
  	 		val result = "result"
	  	  	// 此函数在 fragment-ktx 中
  		  	setFragmentResult("requestKey", bundleOf("bundleKey" to result))
        }
  	}
}

FragmentA只有处于STARTED状态下才会接收到结果。此种方式适用于比较简单的参数通信,对于比较大的参数(比如Bitmap)或者比较复杂的通信逻辑,ViewModel 才是更好的选择。Fragment 通过 ViewModel 通信也比较简单,只需要在创建 ViewModel 时,指定ViewModelStore为 Fragment 的宿主 Activity 即可,这样多个 Fragment 中获取到的是同一个 ViewModel,fragment-ktx 库也提供了activityViewModels()函数可以直接获取宿主 Activity 持有的 ViewModel。

上面的方式非常简单也非常方便,但也带来了一些问题,由于 ViewModel 存储在 Activity 的 ViewModelStore 中,当使用此 ViewModel 的 Fragment 全都被销毁时,ViewModel 仍然不会被释放,事实上此 ViewModel 已经泄漏了,除此以外,当我们再次创建 Fragment 使用 ViewModel 时,ViewModel 中仍然是上一次使用时产生的数据,由此可能会带来一些难以预料的问题。

为了解决上面的两个问题,比较常见的解决方案是在使用的 Fragment 销毁时,手动将 ViewModel 中的数据重置,如下:

class TestViewModel : ViewModel() {
	private val dataList: MutableList<String> = mutableListOf()
  
	fun addData(string: String) {
    	dataList.add(string)
	}
  
    // 没有使用 onCleared() 是因为此函数无法在外部调用
  	fun tearDown() {
    	dataList.clear()
  	}
}

然后在 Fragment 的onDestory()中调用TestViewModel#tearDown()即可。

这种手动调用的方式比较繁琐,可以借助LifecycleEventObserver以及 ViewModel 的onCleared()函数来自动进行“拆卸”ViewModel:

open class AutoTearDownViewModel(
    private val lifecycleOwner: LifecycleOwner
) : ViewModel(), LifecycleEventObserver {
    
    init {
        if (lifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) {
            onCleared()
        } else {
            lifecycleOwner.lifecycle.addObserver(this)
        }
    }
    
    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        if (event == Lifecycle.Event.ON_DESTROY) {
            onCleared()
            lifecycleOwner.lifecycle.removeObserver(this)
        }
    }
}

class TestViewModel(val lifecycleOwner: LifecycleOwner) : AutoTearDownViewModel(lifecycleOwner) {
   	private val dataList: MutableList<String> = mutableListOf()
	
    override fun onCleared() {
        dataList.clear()
    }
}

class AutoTearDownViewModelFactory(
    private val lifecycleOwner: LifecycleOwner
) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return modelClass.getConstructor(LifecycleOwner::class.java).newInstance(lifecycleOwner)
    }
}

在 Fragment 中可以通过使用ViewModelProvider(requireActivity(), AutoTearDownViewModelFactory(this))创建 ViewModel,在多个 Fragment 中使用时,创建的 ViewModel 会跟随**第一个创建此 ViewModel **的 Fragment 的生命周期。

此种方式虽然不用我们手动去调用清理函数,但是仍然无法解决 ViewModel 泄漏的问题,并且还是依赖开发者手动在onCleared()中清理或重置数据,在开发时很容易忘记。

那如何能够让不再使用的 ViewModel 能够自动从 Activity 的 ViewModelStore 中消失呢?在上面我们通过LifecycleEventObserver实现了当 Fragment 销毁时自动清理 ViewModel,剩下需要做的就是当 Fragment 销毁时,将 ViewModel 从 Activity 的 ViewModelStore 中删掉。通过查看ViewModelStore的源码不难发现,ViewModel 是存储在HashMap<String, ViewModel>中,此字段是private的,所以我们需要通过反射拿到这个 Map,从而将 ViewModel 删除,所以我们将AutoTearDownViewModel进行以下改造:

// AutoTearDownViewModel.kt
const val VIEW_MODEL_KEY = "auto_tear_down_view_model"

fun <T> getVmKey(clazz: Class<T>): String {
    return VIEW_MODEL_KEY + ":" + clazz.canonicalName
}

open class AutoTearDownViewModel(
    private val fragment: Fragment
) : ViewModel(), LifecycleEventObserver {

    init {
        if (fragment.lifecycle.currentState == Lifecycle.State.DESTROYED) {
            onCleared()
        } else {
            fragment.lifecycle.addObserver(this)
        }
    }

    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        if (event == Lifecycle.Event.ON_DESTROY) {
            val mapField = ViewModelStore::class.java.getDeclaredField("mMap")
            mapField.isAccessible = true
            val viewModelMap = mapField.get(fragment.requireActivity().viewModelStore) as HashMap<String, ViewModel>
            viewModelMap.remove(getVmKey(this::class.java))?.let {
                val method = ViewModel::class.java.getDeclaredMethod("clear")
                method.isAccessible = true
                method.invoke(it)
            }
            fragment.lifecycle.removeObserver(this)
        }
    }
}

class AutoTearDownViewModelFactory(
    private val fragment: Fragment,
) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        return modelClass.getConstructor(Fragment::class.java).newInstance(fragment) as T
    }
}

通过上面的改造后,ViewModel 会跟随**第一个创建它的 Fragment **的生命周期,当此 Fragment 销毁时,ViewModel 也将被清理掉,下次再进入到此 Fragment 时,会创建一个新的 ViewModel。

需要注意的是,上面的代码在removeViewModel后,会调用一次ViewModelclear()方法,而此方法是在androidx.lifecycle:lifecycle-viewmodel:2.1.0-alpha01版本之后才被添加进去,如果是之前的版本,则需要调用onCleared()方法。

为了使用方便,我们可以借助 Kotlin 扩展函数以及委派,实现一个类似activityViewModels()的函数:

// AutoTearDownViewModelProvider.kt
inline fun <reified VM : ViewModel> Fragment.autoTearDownViewModel(): Lazy<VM> {
    return AutoTearDownViewModelLazy(VM::class, this)
}

class AutoTearDownViewModelLazy<VM : ViewModel>(
    private val viewModelClass: KClass<VM>,
    private val fragment: Fragment
) : Lazy<VM> {
    private var cached: VM? = null

    override val value: VM
        get() {
            return cached
                ?: ViewModelProvider(
                    fragment.requireActivity(),
                    AutoTearDownViewModelFactory(fragment)
                ).get(getVmKey(viewModelClass.java), viewModelClass.java).also {
                    cached = it
                }
        }

    override fun isInitialized() = cached != null
}

上述提到的解决方案也并非完美,由于用到了反射,并且反射的字段和方法非public,所以在之后的版本中如果 ViewModel 或 ViewModelStore 修改了方法声明可能会导致失效;对于配置更改的情况(比如旋转屏幕),ViewModel 仍然会被重建,这其实是和 ViewModel 的初衷相背的,但如果 App 限制了只能使用竖屏,那么此种方案也不失为一种比较好的方式。