十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android ViewModel传参全攻略:Factory、SavedStateHandle与依赖注入实战

Android ViewModel传参全攻略:Factory、SavedStateHandle与依赖注入实战 写在前面的废话这几天好几个群里都在问“ViewModel怎么传参”点开一看翻来覆去就是那几个答案要么是new ViewModelProvider套一层Factory要么甩一个官方文档链接很少有人把这事的来龙去脉讲清楚。我刚入行那会儿也在这上面栽过跟头构造函数里传了个Intent的extra一旋转屏幕直接崩报错信息还特别抽象什么Cannot create an instance of class当时差点把电脑砸了。这篇文章我把ViewModel传参这件事从头到尾捋一遍从为什么非传不可到各种传参姿势的适用场景和踩坑点再到带依赖注入框架的写法最后列几个我实际开发中遇到的报错和排查思路。保证都是能直接拿来用的东西不是那种抄来抄去的八股文。1. 传参这件事绕不开的三类场景ViewModel传参看着简单实际一动手就乱根本原因在于我们没搞清楚到底要传什么、为什么传。我梳理了一下日常开发里翻来覆去就三种需求第一类从Activity或Fragment把初始化数据带进ViewModel。典型场景是详情页——列表页点了一个条目跳详情页时带了id详情页的ViewModel得拿着这个id去仓库层拉数据。这类参数通常是String、Long这类轻量级数据或者序列化之后的实体对象。第二类把Application级别的依赖注入ViewModel。比如需要SharedPreferences、RoomDatabase、Retrofit实例这些对象的生命周期是跟着Application走的不能每次创建ViewModel都new一个得复用同一个实例。第三类把UI层的状态恢复信息传进去。比如ViewModel内部有个当前页码屏幕旋转后不能回到第一页得把旋转前的状态带回来。这类参数比较特殊它属于系统级的“状态保存与恢复”机制和普通的业务参数处理方式不一样。重要不管哪种场景都不要把Activity或Fragment的引用直接塞进ViewModel。内存泄漏的锅十有八九就是这么来的ViewModel的生命周期比Activity长它拿着Activity引用Activity销毁时就没法被回收。很多新手传参翻车根本原因不是不会写代码而是没搞清楚自己属于哪一类场景然后拿着一把锤子到处乱敲。接下来我把每一类场景对应的方法拆开讲。2. 最常规的操作自定义ViewModelProvider.Factory2.1 为什么必须用Factory直接传不行吗很多第一次接触的人会问ViewModel的构造函数带个参我直接new MyViewModel(id)不就行了你要是真在Activity里这么写了表面上可能不报错但一旋转屏幕或者系统回收进程后重建Activity问题就来了。原因在于ViewModel的创建和恢复机制是受ViewModelStore管理的它并不关心你怎么new它只认ViewModelProvider的创建流程。当ViewModel被系统重建时ViewModelProvider会调用无参构造器反射创建实例。如果你的ViewModel没有无参构造器系统压根不知道该怎么创建它直接抛异常。所以正确做法是告诉ViewModelProvider“这个类应该怎么被创建”这个“告诉”的动作就是通过ViewModelProvider.Factory来完成的。2.2 Factory的标准写法假设场景是详情页接收一个newsIdViewModel要拿这个id去请求新闻详情。抛开网络层代码核心就三块。class NewsDetailViewModel(private val newsId: String) : ViewModel() { // 这里用newsId做数据加载或状态管理 val detailLiveData MutableLiveDataString() init { loadNews(newsId) } private fun loadNews(id: String) { // 模拟加载逻辑 detailLiveData.value News detail for $id } } class NewsDetailViewModelFactory( private val newsId: String ) : ViewModelProvider.Factory { override fun T : ViewModel create(modelClass: ClassT): T { if (modelClass.isAssignableFrom(NewsDetailViewModel::class.java)) { Suppress(UNCHECKED_CAST) return NewsDetailViewModel(newsId) as T } throw IllegalArgumentException(Unknown ViewModel class) } }Activity里面这样用val newsId intent.getStringExtra(EXTRA_NEWS_ID) ?: val factory NewsDetailViewModelFactory(newsId) val viewModel ViewModelProvider(this, factory)[NewsDetailViewModel::class.java]这里有个关键点要强调在create方法里一定要检查modelClass是不是和目标类匹配不匹配就抛IllegalArgumentException。这个不是多此一举ViewModelProvider可能会用多个类名调用同一个Factory不做判断容易埋坑。2.3 使用ViewModelProvider(this, factory)而不是直接传ViewModelStore我见过有人用ViewModelStore手动搞一套然后自己管理ViewModel的缓存代码写得要多绕有多绕最后还容易出内存泄漏。实际上ViewModelProvider的第一个参数传Activity或Fragment都行它内部会帮你拿到对应的ViewModelStoreOwner旋转屏幕时Activity重建但ViewModelStore是保留的所以ViewModelProvider再次用同一个Factory获取ViewModel时拿到的是同一个旧的ViewModel实例不会重复创建。这也是为什么Factory里的初始化参数不会“覆盖”掉已有ViewModel的原因。如果你第一次用的是newsId100旋转后Activity重建重新调用了ViewModelProvider(this, factory)这里的factory如果还是newsId100那么拿到的就是原来那个实例如果Activity重建后从Intent里拿到的id变了比如别的入口进了同一个页面那拿到的仍然是旧实例——这个行为很反直觉但确实是设计如此后面我会在常见问题里展开讲。2.4 多个参数怎么办业务一复杂参数就多比如有userId、pageIndex、source全塞构造函数里写出来的Factory代码又臭又长。建议传一个数据容器data class NewsDetailArgs( val newsId: String, val userId: String, val pageIndex: Int ) class NewsDetailViewModel(private val args: NewsDetailArgs) : ViewModel()这样构造函数只留一个参数后面加需求改NewsDetailArgs就行Factory不用动。这个小习惯能省很多麻烦尤其是老项目代码各种地方共享ViewModel的时候。3. 省事进阶SavedStateHandle帮你搞定状态恢复3.1 一个容易被忽视的系统级参数前面提到的NewsDetailViewModel如果只传newsId进去逻辑上没问题但Android系统会不定期地把进程杀掉回收内存用户切回来时Activity会重建ViewModelStore里的数据全没了ViewModel重新走一遍创建流程。这种时候普通的Factory传参就帮不上忙了因为你的id参数从Intent里拿到后经历进程被杀再恢复Activity的Intent会被系统重新注入一份如果是onCreate里通过savedInstanceState恢复甚至可能拿不到原来的值。正确的做法是让系统帮你把状态缓存下来等重新创建时自己取回来。SavedStateHandle就是干这个的。它是AndroidX提供的一个轻量级键值对存储容器和Bundle类似但它由系统接管即使在进程被系统杀掉后也能靠它恢复数据。在ViewModel的构造函数里直接声明SavedStateHandle参数ViewModelProvider会自动帮你注入。class NewsDetailViewModel( private val savedStateHandle: SavedStateHandle ) : ViewModel() { val newsId: String? get() savedStateHandle[news_id] init { loadNews(newsId ?: ) } fun updatePage(index: Int) { savedStateHandle[page_index] index } }Activity里获取ViewModel的代码和之前一样不需要手动创建SavedStateHandle也不需要改Factory。SavedStateHandle内部的默认实现会把数据存到Activity的Bundle里配置变更和进程恢复这两条路都给你管了。这里有一点值得注意使用SavedStateHandle的ViewModel它的Factory是系统默认的SavedStateViewModelFactory不需要再传业务参数的Factory。如果想要同时传业务参数比如id又想要状态恢复就得用AbstractSavedStateViewModelFactory或者继承SavedStateViewModelFactory做定制。这是很多人的知识盲区下面我展开讲。3.2 用AbstractSavedStateViewModelFactory组合参数与状态如果业务初始化参数和状态恢复都要用最简单的方式是自定义一个AbstractSavedStateViewModelFactoryclass NewsDetailViewModel( private val newsId: String, private val savedStateHandle: SavedStateHandle ) : ViewModel() { val newsIdFromState: String? get() savedStateHandle[news_id] // ... } class NewsDetailViewModelFactory( owner: SavedStateRegistryOwner, private val newsId: String ) : AbstractSavedStateViewModelFactory(owner, null) { override fun T : ViewModel create( key: String, modelClass: ClassT, handle: SavedStateHandle ): T { if (modelClass.isAssignableFrom(NewsDetailViewModel::class.java)) { Suppress(UNCHECKED_CAST) return NewsDetailViewModel(newsId, handle) as T } throw IllegalArgumentException(Unknown ViewModel class) } }Activity里val factory NewsDetailViewModelFactory(this, newsId) val viewModel ViewModelProvider(this, factory)[NewsDetailViewModel::class.java]AbstractSavedStateViewModelFactory的构造参数需要传SavedStateRegistryOwnerActivity和Fragment都实现了这个接口所以直接传this就行。它内部会在进程恢复时自动调用create方法并把恢复好的SavedStateHandle传进来这样业务参数和状态恢复就不打架了。注意AbstractSavedStateViewModelFactory在使用前需要保证SavedStateRegistry已经初始化。在Activity的onCreate里使用基本没问题但如果过早调用比如在FragmentonAttach之前可能拿不到owner导致SavedStateHandle创建失败。3.3 什么时候不用SavedStateHandle并不是所有参数都要塞SavedStateHandle。如果那个参数是一次性的、不可序列化的比如一个回调Listener、一个临时对象放进SavedStateHandle反而麻烦。这类对象不属于状态更适合通过Factory传或者依赖注入来拿。判断标准很简单这个值在进程被系统杀掉之后用户切回来时还需不需要需要就放SavedStateHandle不需要就走Factory。我自己的习惯是一切从Intent带过来的原始参数以及页面上需要跨配置变更保持的页码、筛选条件等通通走SavedStateHandle而网络请求的Repository、业务处理类这种和UI状态无关的依赖都通过构造器注入或依赖注入框架传不混在一起。4. 容易被忽略的Application参数AndroidViewModel的坑与解AndroidViewModel算是ViewModel族谱里比较特殊的一个它继承自ViewModel构造函数里自带一个Application参数。很多人一看到它就往ViewModel里塞什么getApplication()用得飞起结果写出来的代码到处都是硬编码上下文可测性极差。先看用法class MyAppViewModel(application: Application) : AndroidViewModel(application) { fun getAppName(): String getApplicationMyApp().getAppName() }创建时不需要自定义Factory用默认的AndroidViewModelFactory就能创建val viewModel ViewModelProvider(this)[MyAppViewModel::class.java]问题是AndroidViewModel能拿到Application但拿不到Activity的Context也不能保证应用一定走你继承的MyApp万一用了别的Application子类强转直接崩。要拿到系统服务应该用getApplication().getSystemService()这样的方式而不是用Activity的Context去拿服务否则就可能引发内存泄漏。更关键的是很多人在AndroidViewModel里写业务依赖比如val repository (getApplication() as MyApp).repository。这看起来顺理成章但实际上把“依赖注入”和“ViewModel”耦合死了。换个工程Application类的结构一变你这里就跟着改维护成本翻倍。如果要传一个自定义的依赖实例比如Repository实例最干净的方式还是用Factoryclass NewsRepository(application: Application) { /* ... */ } class NewsViewModel( private val repository: NewsRepository, private val newsId: String ) : ViewModel() { /* ... */ } class NewsViewModelFactory( private val repository: NewsRepository, private val newsId: String ) : ViewModelProvider.Factory { override fun T : ViewModel create(modelClass: ClassT): T { if (modelClass.isAssignableFrom(NewsViewModel::class.java)) { Suppress(UNCHECKED_CAST) return NewsViewModel(repository, newsId) as T } throw IllegalArgumentException(Unknown ViewModel class) } }Activity里创建Repository实例时用Application的Context避免持有Activity级Context。这是最直观、最不容易出问题的组合方式也是依赖注入框架出现之前的主流玩法。经验之谈AndroidViewModel并不是不能用但仅限你真的需要Application提供系统级服务且不要在里面做业务依赖的组装。业务依赖用Factory传是一种更可控也更方便测试的方式。如果团队已经上了Hilt或Koin那AndroidViewModel的存在感会进一步降低。5. Fragment之间共享ViewModel谁传参、谁收参是个大问题5.1 不靠谱的Intent传参思路共用同一个Activity的两个Fragment要共享数据比如主列表Fragment和详情Fragment都持有同一个SharedViewModel它们之间的数据传递就涉及“如何把参数带到共享ViewModel”。有人会想让FragmentA把数据塞到Activity的Intent里然后FragmentB再从Intent里取这绕了一大圈而且还得处理Fragment重建时Intent上下文的问题非常不优雅。更常见的是直接把参数放在Bundle.arguments里传给目标Fragment然后每个Fragment各自去创建自己的ViewModel——但这就不叫共享了这是两个独立的ViewModel实例数据同步会非常麻烦。5.2 官方推荐的共享ViewModel玩法官方推荐的写法是同一个Activity下的Fragment用Activity作为ViewModelStoreOwner去获取同一个ViewModel实例。class SharedViewModel : ViewModel() { val selected MutableLiveDataString() fun select(item: String) { selected.value item } } class ListFragment : Fragment() { private val viewModel: SharedViewModel by activityViewModels() // 点击时通知 fun onItemClick(item: String) { viewModel.select(item) } } class DetailFragment : Fragment() { private val viewModel: SharedViewModel by activityViewModels() // 观察变化更新UI override fun onViewCreated(view: View, savedInstanceState: Bundle?) { viewModel.selected.observe(viewLifecycleOwner) { item - // 更新UI } } }这里by activityViewModels()是关键它保证两个Fragment拿到的是同一个ViewModel不需要传参不需要FactoryActivity销毁时统一清理。5.3 真正需要传参数的共享场景怎么办上面那种写法适合两个Fragment共用同一个模块的ViewModel不需要初始化参数。但如果你确实需要传一个初始化参数进去那就在Activity里先用Factory创建SharedViewModel然后把Activity作为owner传给Fragment使用。更简单粗暴的方案是把参数塞到Activity的Intent里让创建出来的Fragment通过requireActivity().intent拿到。但如果Activity被回收重建Intent对象还是原来的那个参数不会丢。不过这种做法代码异味很重不够干净。我现在的习惯是如果共享ViewModel需要初始化参数就把参数放到Activity的ViewModel使用Factory创建里然后Fragment全部通过activityViewModels()拿同一个Activity类ViewModel这样参数只需要在Activity里传一次Fragment全部自动共享。如果参数是一次性的也可以放到SavedStateHandle里由Fragment自己按需读取。6. 依赖注入框架加持Hilt与Koin的传参姿势6.1 Hilt下的ViewModel传参如果你已经用了HiltViewModel传参就轻松多了。Hilt提供了一个HiltViewModel注解配合Inject constructor可以直接把依赖注入进去HiltViewModel class NewsViewModel Inject constructor( private val repository: NewsRepository, private val savedStateHandle: SavedStateHandle ) : ViewModel() { // 直接从SavedStateHandle里取id private val newsId: String? get() savedStateHandle[news_id] }在Activity里by viewModels()就能拿到自动创建的ViewModel不用手动写Factory。如果savedStateHandle里没有id你就需要通过SavedStateHandle的setArguments在创建前塞入Bundleval args Bundle().apply { putString(news_id, newsId) } // 在Fragment中 arguments args如果是Activity可以通过intent.extras注入到handle里。Hilt内部对SavedStateHandle的支持是比较完整的它会自动把Activity或Fragment的参数转成savedStateHandle里的键值对。传一个在Hilt容器之外动态计算的值得用SavedStateHandle的set或者用ViewModelProvider.Factory加Hilt的AssistedInject。这个功能在Hilt 2.31以上的版本中是可用的。6.2 用AssistedInject传动态参数AssistedInject是和Dagger/Hilt配合的另一个利器。以往要传一个在运行时才能确定的参数如id你得自己写Factory。有了AssistedInjectFactory这个模板代码也能省了class NewsDetailViewModel AssistedInject constructor( private val repository: NewsRepository, Assisted private val newsId: String, Assisted private val savedStateHandle: SavedStateHandle ) : ViewModel() { AssistedInject.Factory interface Factory { fun create( newsId: String, savedStateHandle: SavedStateHandle ): NewsDetailViewModel } }Hilt会帮你生成NewsDetailViewModel.Factory的实现你在Activity里直接调用val factory viewModelFactory { initializer { val assistedFactory hiltViewModelFactory() assistedFactory.create(newsId, SavedStateHandle()) } }不过老实说AssistedInject用起来还是有学习成本的模板代码不一定减少多少但代码的清晰度和可维护性确实好很多。如果团队已经用了Hilt且对动态参数的需求密集值得投入。6.3 Koin另一种减负姿势Koin的核心哲学是用DSL描述依赖关系创建ViewModel只需要声明viewModel块val myModule module { viewModel { (newsId: String) - NewsViewModel(get(), newsId) } }在Activity或Fragment里val viewModel: NewsViewModel by viewModel { parametersOf(newsId) }get()会自动从Koin容器中解析NewsRepository。Koin的运行时代价高一点但胜在简单直接适合中小型项目快速迭代。Koin对SavedStateHandle的支持也还行但动态参数传递方式还是parametersOf为主。依赖注入框架这东西没有万能银弹。团队小、项目急用Koin掉坑几率低项目大、规范要求高Hilt的编译期校验能帮你省很多心智负担。选型看团队别盲目跟风。7. 传参路上的经典报错与排查实录7.1 报错一Cannot create an instance of class现象java.lang.RuntimeException: Cannot create an instance of class com.example.NewsDetailViewModel原因ViewModelProvider默认用反射调用无参构造器你的ViewModel没有无参构造器自然创建失败。排查思路第一步检查ViewModel构造器是否只有一个带参数的版本。第二步确认是否在ViewModelProvider里正确传入了自定义Factory。第三步如果确认传了Factory看看create方法里的modelClass.isAssignableFrom判断是否正确是否包了泛型转换。这个报错有90%的概率是Factory没传或者Factory的create方法里判断分支写错了。7.2 报错二工厂类抛出Unknown ViewModel class现象java.lang.IllegalArgumentException: Unknown ViewModel class原因我几乎每次看到这个报错都是因为在create方法里写着if (modelClass NewsDetailViewModel::class.java)判断条件太严格或者太宽松。严格来说应该用isAssignableFrom而非因为ViewModelProvider可能用父类类型来查找也可能使用不同ClassLoader加载的类。用判断也未必出错多数场景没问题但用isAssignableFrom更稳健。这就是为什么我前面一直强调用isAssignableFrom。7.3 报错三参数传了但没生效拿到的是旧实例现象Activity里用Factory传了一个新的idViewModel里的id还是旧的。原因这个不是“没生效”而是ViewModel的实例压根没被重新创建。Activity重建后ViewModelStore里还存着上次创建的那个ViewModel实例ViewModelProvider发现已经存在就直接返回旧实例不会调用Factory的create方法。解决思路如果新页面本身就是复用同一个Activity但想要不同数据你需要确保每次进入是“新的Activity回退栈”而不是singleTask或singleTop那种复用Activity的模式。更推荐的做法是让ViewModel内部逻辑用可观察数据驱动——ViewModel的初始化参数只负责首次创建后续参数变化通过暴露方法或事件通知来更新数据。7.4 报错四多Fragment共享ViewModel时各自传参互不相同现象两个Fragment用同一个activityViewModels()拿同一个ViewModel但数据不一致互相没通知到。原因大概率是其中一个Fragment不小心用viewModels()而不是activityViewModels()导致各自持有自己所属Fragment的ViewModelStore各自创建了新的实例。排查思路在Fragment里分别打印两个ViewModel实例的hashCode如果不一致就是ViewModelStoreOwner选错了。7.5 关于传参的额外建议传入LiveData或者Flow类型的参数注意不是所有数据都要在构造函数里传很多时候用公开方法回调更灵活。不要试图在ViewModel里持有Fragment或者Activity的View引用哪怕是用WeakReference也会增加排查难度。参数对象如果特别大比如列表数据几千条塞到Bundle里会触发TransactionTooLargeException这时候应该考虑持久化存储或本地数据源。ViewModel的构造函数里不要写耗时操作构造函数发生在主线程网络请求和数据库操作放init里用协程或线程池。写在最后的实操心得我折腾ViewModel传参也有五六年了从早期傻乎乎地手写Factory到后来用SavedStateHandle恢复状态再到现在项目里全面铺开Hilt踩过的坑不算少。最后分享一个我真心觉得有用的判断标准先搞清楚参数的生命周期再决定怎么传。一次性业务参数比如id、token走Factory构造器或者直接放SavedStateHandle应用级依赖Repository、DataSource走构造器注入或依赖注入框架跨配置变更的UI状态页码、筛选条件走SavedStateHandleActivity级别的共享数据直接在共享ViewModel里暴露方法。规则就这么简单但真正能做到的人不多因为太多人被各种教程带跑偏了见到参数就想着往构造函数里塞。还有一个小技巧如果你不想让Factory类写得太多可以用一个通用的ViewModelFactory配合ViewModelProvider.Factory的默认实现用by viewModels委托懒加载。但这一套在多人协作时容易引起命名混乱你们自己权衡。篇幅有限没法把所有组合方式都贴全但核心思路已经摊开了看清参数种类选对传递通道别让内存泄漏和数据错乱找上门。你要是正卡在某个具体的报错上把代码和日志梳理一下大概率能从上面的排查思路里找到答案。
返回列表