Everything is ViewModel:让状态管理回到对象世界

做客户端这么多年,Android、Flutter、iOS、RN 都折腾过。各种状态管理方案层出不穷,实际开发绕不开就几件事:状态放哪?页面之间要不要共享实例?什么时候销毁?退出再进来该怎么处理?模块之间怎么拿依赖。
市面上绝大多数状态库,重心都放在数据变化如何通知 UI 刷新。但很容易忽略一件事:承载业务状态的对象,什么时候创建、什么时候回收。
view_model主要解决对象生命周期管理。整体是服务注册 + 引用绑定管理 + 响应式这套组合。它不走 Dagger/Hilt 那种编译期全自动 DI。现在 AI 写代码也多,靠一套简单运行时规则,就能规避生命周期 bug,不用扛重型 DI 的各种负担。
整套体系分成两层:
- 响应式层:对象内部状态变更,通知界面刷新;
- 对象管理层:控制对象创建、谁在使用、是否共享、何时销毁。
举个很常见的例子:登录会话,首页、个人中心、订单页都要读取登录信息。 状态变化触发页面刷新,是响应式的工作;多个页面是否共用同一个对象、页面关闭要不要关掉网络监听,属于对象管理。单纯的响应式只能管通知,管不了对象的生和死。
响应式只管对象内部数据;view_model管对象完整生命周期。
Everything is ViewModel
这里的意思是:不局限页面,业务里每一个模块都可以写成 ViewModel。
页面、详情、弹窗可以是 ViewModel;Repository、业务服务、播放器、后台任务、会话管理,同样可以包装成 ViewModel 交给 Runtime 托管。 它们统一享受同一套能力:响应式状态、引用绑定生命周期、实例隔离 / 共享控制、自动资源回收。
普通 DTO、工具函数、纯工具类不需要托管,正常写就行。
四个核心角色
- ViewModel:业务对象,可以是页面模块,也可以是数据层、服务层模块;存放状态、执行业务逻辑,持有定时器、网络订阅等资源。
- Spec:对象注册描述,定义如何构造对象,配置 key 和存活策略。Spec 只是配置,本身不会 new 实例。
属于服务式注册。不像 Hilt 编译扫描自动装配,不会自动注入字段,需要使用者通过 Binding 主动读取 Spec 拿实例。
- Binding:使用者句柄,可以是页面、弹窗、另一个 ViewModel。每次通过 Binding 读取 Spec,就给目标对象增加一条绑定;Binding 销毁,绑定就移除。
- Runtime :底层管理器,记录全部绑定关系,做实例查找、创建、销毁。业务代码只调用
watch/read,绑定与销毁逻辑全部封装在 Runtime 内部。
Hilt/Dagger 追求依赖全自动装配,但编译慢、上手门槛高、排错麻烦。
view_model选择声明注册 + 运行时绑定,不做全自动注入。规则直白,AI 也能按规则写代码,绝大多数业务场景够用。
对象规则:默认非单例,key 开启共享,绑定决定销毁
- 默认不做单例,每次打开页面就新建对象 没有指定 key 的时候,每一次页面拿到的都是全新 new 出来的 ViewModel。 比如打开商品列表 A,筛选选了价格区间;退出去打开商品列表 B,拿到全新实例,A 的筛选条件不会带到 B。
- 显式共享:配置相同 Spec + key,多个 Binding 可以绑定同一个实例
就算加了 key,也不等于永久存活。全部绑定释放之后,对象依旧会销毁,尽量少用永久常驻模式。
Runtime 不是简单数字计数,而是记录每一条真实的绑定来源。 拿商品详情页举例:页面本身绑定 ViewModel,页面里面的评论弹窗、收藏子组件也绑定同一个 ViewModel。 用户点返回,页面根 Binding 释放,但评论弹窗还留在界面,弹窗还持有绑定,对象就不能销毁。直到弹窗关闭,所有绑定全部释放,才真正销毁对象,清理轮询、订阅、定时器。
带 key 的多实例行为
给 Spec 带上业务 key,不等于一定是单例 。 同一个Spec+key,当所有绑定全部释放、旧对象销毁之后;下次再访问这个 key,会重新 new 一份全新对象。
真实业务例子:
- 用户详情 user:123 打开用户 123,new 一份实例;退出页面,所有绑定释放,对象销毁,关掉粉丝数轮询。再次点开 user:123,重新 new 新对象,不会复用上次残留的加载状态。
- 订单编辑弹窗 order:456 打开订单 456 编辑,填了一半直接关掉弹窗,绑定全部释放,对象销毁。再次打开这个订单弹窗,又是全新对象,上次填到一半的表单状态全部清空。
- 聊天会话 chat:789 进入会话 789,开启消息监听;退出页面,绑定全部释放,对象销毁断开长连接。再进入同一会话,重新 new 实例,重新建连接,旧对象回调不会乱刷界面。
全局登录会话、全局配置这种需要常驻内存的,不要用上面这种行为,用永久单例。
模块依赖:没有父子强所有权
ViewModel 依赖其他托管对象,不要手动 new,也不要手动调用销毁。 每个 ViewModel 内部自带 Dependency Binding,读取别的 Spec 就产生一次绑定。父 ViewModel 销毁,这条绑定随之解除;被依赖对象只要还有别的地方在用,就继续存活;没有任何绑定,自动销毁。
例子:订单详情 ViewModel 依赖订单 Repository。关闭订单详情页,父 ViewModel 销毁,减掉对应绑定;如果还有其他页面在用这个 Repository,它就继续存活。
✅推荐,每次走 Runtime 拿实例
ini
SessionViewModel get session => viewModelBinding.read(sessionSpec);
watch 和 read:都会产生绑定
很多人以为 read 只是读个值,不影响生命周期。 实际上watch、read 只要走 Binding 访问,都会产生绑定,参与存活判断。
区别:
watch:绑定 + 监听状态变更,UI 页面使用;read:仅绑定,不监听,业务方法内部调用。
状态监听 和 对象绑定生命周期,是两套独立逻辑。selector 只管细粒度 UI 更新,不会改变绑定销毁规则。
架构取舍:优先把对象生命周期做稳,细粒度更新是可选优化
很多状态库上来就吹细粒度响应更新。但普通业务页面基本就是 loading/data/error,UI 渲染通常不是瓶颈。
view_model思路很务实:优先搞定对象隔离、共享、销毁这套逻辑。selector 细粒度更新当作可选优化,遇到真实性能问题再打开。
跨平台:统一的是运行规则,不是 API
Flutter、Android、iOS、JS 各端 API 写法不一样,但底层逻辑一致:Spec 描述对象,Binding 维护绑定,key 标记业务身份,全部绑定释放触发销毁;不带 key 默认每次新建对象,带 key 可以共享,但销毁后再访问会重新 new。
配套 AI Skill 固化编码规则,规避生命周期常见坑。
总结
view_model不是全能 DI 容器。 核心:服务注册 + Runtime 引用绑定管理 + 响应式通知。 放弃编译 DI 全自动能力,换更低复杂度,靠运行时绑定保证安全,AI 辅助开发也能产出可靠代码。
复杂度没有消失,收拢到 Runtime 内部。业务写普通业务对象,对象负责业务逻辑;Runtime 通过绑定,统一处理创建、共享、销毁。
业务对象组成业务图,Runtime 维护引用绑定图。
框架可以变,但对象什么时候创建、谁在用、什么时候销毁、退出再进来怎么处理,是客户端通用问题。跨平台统一的不是 API,而是这套对象生命周期模型。
GitHub Repositories
-
Flutter 版本,也是整套设计最早开始和持续演进的实现。
-
面向 SwiftUI、UIKit 和 Apple 平台生命周期的实现。
-
面向 Compose、Activity、Fragment、View 和普通 Kotlin 类的实现。
-
面向 React Native、Electron 和普通 TypeScript host 的实现。