移动应用架构设计:MVVM实战,顺便聊聊那些年我踩过的坑
作为一个用了快两年 GitHub Copilot 的付费用户(别问,问就是公司报销),我得说这玩意儿确实让我少写了不少重复代码。不过最近在准备跳槽,边工作边刷 LeetCode,感觉光靠 Copilot 可不行——面试官可不关心你有没有开“外挂”,他们更关心你能不能把 MVVM 讲清楚。
说到 MVVM,上周五晚上加班到十一点,产品经理突然在群里@我说:“我们要重构移动端项目,用 MVVM 架构,下个月上线。” 我当时真的想砸电脑——这都双十二预热了,还重构?但转念一想,这不正好是我刷题之余练手的好机会吗?毕竟现在大厂面试题里,“谈谈你对 MVVM 的理解” 几乎成了标配。
所以今天这篇,就结合我最近的项目实战,聊聊如何在真实移动项目中落地 MVVM 架构,并兼顾性能优化。顺便,也会穿插一些关于 JavaScript、爬虫数据对接、以及被测试同学追着改 Bug 的血泪史。
为啥非得用 MVVM?
先说背景。我们当前的项目是一个跨平台移动 App(React Native + Expo),主要功能是展示商品信息、用户行为追踪、以及一个基于爬虫抓取的实时价格比对模块。之前代码全是“面条式”的:状态散落在各个组件,API 调用和 UI 更新混在一起,连个像样的 store 都没有。
结果上个月线上出了一次事故:用户切换 Tab 时,某个商品详情页偶尔会加载旧数据。排查半天发现是因为组件卸载后,异步请求才回来,直接 setState 到已 unmount 的组件上——经典的内存泄漏问题。
领导拍板:“必须重构!要用 MVVM,解耦逻辑,提升性能。”
行吧,那就干。
MVVM 是啥?真不是前端专属!
很多人一听 MVVM 就想到 Vue,但其实它在移动端一样香。核心思想就三点:
- Model:负责数据和业务逻辑(比如从 API 或爬虫拿数据)
- View:纯 UI,只负责渲染和用户交互
- ViewModel:连接 View 和 Model,暴露可观察的状态(比如
isLoading、productList)
重点来了:ViewModel 不持有 View 的引用,完全通过响应式机制通信。这意味着你可以轻松做单元测试、避免内存泄漏、还能复用逻辑。
我在 Mac 上用 VS Code + Copilot 写起来飞快,但 Windows 测试机上跑起来卡成 PPT——这才意识到,架构只是基础,性能优化才是 MVVM 落地的关键。
实战:用 JavaScript 搭建 MVVM(React Native 版)
虽然主流 MVVM 框架多用于原生开发(如 Android 的 Jetpack ViewModel),但在 React Native 里,我们完全可以借助 MobX 或 Zustand 来实现响应式 ViewModel。
我选了 Zustand,轻量、无样板代码、支持时间旅行调试(对刷题党友好)。
第一步:定义 Model 层
我们的 Model 要处理两类数据:
- 后端 API(商品列表)
- 爬虫数据(竞品价格,来自 Python 脚本定期抓取并存入 MongoDB)
// models/ProductModel.js
class ProductModel {
async fetchProducts() {
const res = await fetch('/api/products');
return res.json();
}
// 注意:这里爬虫数据是通过内部 API 代理的,避免 CORS
async fetchCompetitorPrices(productId) {
const res = await fetch(`/api/scraper/prices?pid=${productId}`);
return res.json(); // 返回 { jd: 299, tmall: 305, pdd: 288 }
}
}
🐛 踩坑点:最初我们直接在 View 里调
fetchCompetitorPrices,结果用户快速滑动列表时触发了几十个并发请求,不仅卡顿,还被目标网站限流。后来加了防抖 + 缓存,才稳住。
第二步:ViewModel 响应式状态管理
// viewmodels/ProductViewModel.js
import { create } from 'zustand';
import { ProductModel } from '../models/ProductModel';
const productModel = new ProductModel();
export const useProductStore = create((set, get) => ({
products: [],
loading: false,
competitorPrices: {},
fetchAllProducts: async () => {
set({ loading: true });
try {
const products = await productModel.fetchProducts();
set({ products, loading: false });
// 预加载前5个商品的竞品价格(懒加载优化)
products.slice(0, 5).forEach(p => {
productModel.fetchCompetitorPrices(p.id).then(prices => {
set(state => ({
competitorPrices: { ...state.competitorPrices, [p.id]: prices }
}));
});
});
} catch (err) {
set({ loading: false });
console.error('Fetch failed:', err);
}
},
loadMorePrices: (productId) => {
if (get().competitorPrices[productId]) return; // 已加载,跳过
productModel.fetchCompetitorPrices(productId).then(prices => {
set(state => ({
competitorPrices: { ...state.competitorPrices, [productId]: prices }
}));
});
}
}));
关键优化点:
- 懒加载竞品价格:只在用户点击“比价”按钮时才加载,避免首屏压力
- 缓存机制:同一个 productId 不重复请求
- 错误边界:即使爬虫挂了,主流程不受影响
第三步:View 层——纯函数组件
// views/ProductListScreen.js
import { useProductStore } from '../viewmodels/ProductViewModel';
export default function ProductListScreen() {
const { products, loading, loadMorePrices } = useProductStore();
useEffect(() => {
useProductStore.getState().fetchAllProducts();
}, []);
if (loading) return <LoadingSpinner />;
return (
<FlatList
data={products}
renderItem={({ item }) => (
<ProductCard
product={item}
onComparePress={() => loadMorePrices(item.id)}
/>
)}
// 关键:用 getItemLayout 优化滚动性能
getItemLayout={(data, index) => ({ length: 120, offset: 120 * index, index })}
/>
);
}
💡 性能 Tip:
FlatList的getItemLayout能避免动态测量,大幅提升长列表滚动帧率。这点在低端 Android 机上尤其明显。
性能对比:重构前后数据说话
我们用 Firebase Performance Monitoring 跟踪了关键指标:
| 指标 | 重构前 | 重构后(MVVM + 优化) | 提升 |
|---|---|---|---|
| 首屏加载时间 | 2.8s | 1.4s | 50%↓ |
| 列表滚动 FPS | 42 | 58 | +38% |
| 内存占用(首页) | 180MB | 120MB | 33%↓ |
| 异常崩溃率 | 1.2% | 0.3% | 75%↓ |
最爽的是,测试同学终于不再每天追着我说“这个页面又白屏了”。
面试题挑战:MVVM 如何避免内存泄漏?
这题我最近面了两家都遇到了。答案其实藏在 ViewModel 的设计里:
- 自动清理副作用:Zustand 的 store 在组件卸载时不会自动取消请求,所以我们封装了一个
useAsyncEffectHook:
// hooks/useAsyncEffect.js
export function useAsyncEffect(asyncFn, deps) {
useEffect(() => {
const controller = new AbortController();
asyncFn(controller.signal);
return () => controller.abort(); // 组件卸载时取消请求
}, deps);
}
避免在 ViewModel 中持有 View 引用:这是 MVVM 的铁律。如果你在 ViewModel 里写了
this.view.showToast(),那你其实还在写 MVC。爬虫数据的兜底策略:当爬虫服务不可用时,ViewModel 应返回空对象而非报错,保证 View 正常渲染。
关于爬虫与合规性的一点提醒
虽然我们用爬虫抓价格很爽,但千万别碰验证码、登录态或反爬强的网站。我们现在的策略是:
- 只抓公开商品页(robots.txt 允许)
- 请求间隔 > 2s
- 数据仅用于内部比价,不直接展示原始链接
法务部上周还专门发邮件警告:“再乱爬,HR 直接找你谈话。” 所以兄弟们,技术可以炫,底线不能破。
写在最后:架构不是银弹,但值得投入
用了 MVVM 之后,我最大的感受是:代码变得“可预测”了。以前改一个需求要翻五个文件,现在只需要看 ViewModel 的输入输出。
而且,Copilot 在这种结构化代码里表现更好——它能准确猜出 useProductStore 有哪些方法,甚至帮我写单元测试。
当然,MVVM 也不是万能的。对于特别简单的页面(比如设置页),硬套反而增加复杂度。我的原则是:状态超过两个来源,或者涉及异步+缓存,就上 ViewModel。
最近投了几家大厂,面试官问 MVVM 时,我直接掏出手机展示我们 App 的性能监控图,效果拔群。果然,能用数据说话的程序员,走到哪都吃香。
所以,别光刷题了,抽空重构下你的项目吧。说不定下一份 offer,就藏在你优化的那个 ViewModel 里。
P.S. 如果你也正在跳槽,欢迎交流 MVVM 面试题 or 内推(base 杭州/远程)。别问为啥不 base 北京——冬天太冷,Mac 键盘冻得敲不动 😅

评论 0