系列文章《鸿蒙开发从入门到专家》第 39 篇 · 专家
架构模式听起来"高大上",落地其实就是"代码怎么组织"。一个 5 万行的应用,没有清晰架构,到第三个人接手就开始乱。
这一篇把移动端架构模式——MVC、MVP、MVVM、MVI——在鸿蒙生态里的实践方式讲一遍,配上代码示例和权衡分析。鸿蒙的开发范式(声明式 UI + ViewModel + 响应状态)天然适合 MVVM/MVI,所以我们重点放后面两种。
一、架构在解决什么
不同架构模式解决的具体问题不一样:
代码组织:文件放哪、模块怎么分。
可测性:能不能脱离 UI 测业务。
可维护性:改一处会不会到处动。
可演进化:业务复杂度增长后能不能扛。
没有"最优架构",只有"匹配当前业务的架构"。一个工具类 App 不用 MVI;一个复杂交易系统用 MVC 会崩。
二、MVC:经典到失效
MVC(Model-View-Controller)在 Web 后端是真理,在鸿蒙里属于"用过但已经不能完整适配"。
1. 鸿蒙里的 MVC
View:ArkUI 的
@Component。Controller:
@Entry文件里那段 @Builder。Model:业务数据。
// Controller + View 混在一起,反例
@Entry
@Component
struct UserPage {
@State name: string = '';
aboutToAppear() {
http.get('/user/me').then((resp) => {
this.name = resp.name; // 直接在 View 里改状态
});
}
build() {
Column() { Text(this.name) }
}
}
问题在于 Controller 和 View 没真正分离,网络请求和 UI 状态混在一起没法测试。
三、MVP:用 Presenter 把 UI 摘出来
MVP(Model-View-Presenter)的核心是把 UI 无关的逻辑放进 Presenter:
interface UserContract {
interface View {
setName(name: string): void;
}
interface Presenter {
load(): void;
}
}
class UserPresenter implements UserContract.Presenter {
constructor(private view: UserContract.View) {}
load() {
http.get('/user/me').then((resp) => {
this.view.setName(resp.name);
});
}
}
@Entry
@Component
struct UserPage implements UserContract.View {
presenter: UserContract.Presenter = new UserPresenter(this);
setName(name: string): void { /* 设置 @State */ }
aboutToAppear() { this.presenter.load(); }
}
能写测试了,但 Presenter 写起来繁琐、回调链复杂,渐渐被 MVVM 取代。
四、MVVM:鸿蒙推荐的姿势
MVVM(Model-View-ViewModel)是主流选择。鸿蒙官方示例代码也是 MVVM 风格的:View 绑定 ViewModel 的状态,ViewModel 负责业务。
1. 一个标准 ViewModel
@ObservedV2
export class ProductListVM {
@Trace products: Product[] = [];
@Trace isLoading: boolean = false;
@Trace errorMsg: string = '';
async load() {
this.isLoading = true;
this.errorMsg = '';
try {
const resp = await api.get('/products');
this.products = resp.data;
} catch (err) {
this.errorMsg = '加载失败,请重试';
} finally {
this.isLoading = false;
}
}
}
@ObservedV2 + @Trace 是鸿蒙提供的响应式机制。当 products 变化时,所有依赖它的 UI 自动刷新。
2. View 端
@Entry
@Component
struct ProductListPage {
vm: ProductListVM = new ProductListVM();
aboutToAppear() { this.vm.load(); }
build() {
Column() {
if (this.vm.isLoading) LoadingPage()
else if (this.vm.errorMsg) ErrorView(this.vm.errorMsg)
else ListView({ products: this.vm.products })
}
}
}
UI 只读 ViewModel 状态,不直接发起请求,逻辑清晰。
3. ViewModel 复用
MVVM 的核心价值是 ViewModel 可测、可复用:
// 测试用例
const vm = new ProductListVM(mockApi);
await vm.load();
assert(vm.products.length === 10);
不需要起 UI、不需要模拟器,单元测试秒级跑完。
五、MVI:单向数据流的极致
MVI(Model-View-Intent)在 MVVM 基础上再加两个约束:
状态是唯一的真相:整个 View 只有一个 State 不可变对象。
所有修改通过 Intent:组件发意图,ViewModel 处理后产出新 State。
1. 一个标准 MVI
// State:整个页面的所有信息打包
@ObservedV2
export class CounterState {
@Trace count: number = 0;
@Trace history: string[] = [];
}
// Intent:所有用户可能的操作
export type CounterIntent =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' };
// ViewModel:处理 Intent,生成新 State
export class CounterVM {
@Trace state: CounterState = new CounterState();
dispatch(intent: CounterIntent) {
switch (intent.type) {
case 'increment':
this.state.count += 1;
this.state.history.push(`+1 -> ${this.state.count}`);
break;
case 'decrement':
this.state.count -= 1;
this.state.history.push(`-1 -> ${this.state.count}`);
break;
case 'reset':
this.state.count = 0;
this.state.history = [];
break;
}
}
}
View 端:
@Entry
@Component
struct CounterPage {
vm: CounterVM = new CounterVM();
build() {
Column() {
Text(`当前: ${this.vm.state.count}`)
Button('加一').onClick(() => this.vm.dispatch({ type: 'increment' }))
Button('减一').onClick(() => this.vm.dispatch({ type: 'decrement' }))
Button('重置').onClick(() => this.vm.dispatch({ type: 'reset' }))
}
}
}
2. MVI 的好处
可回放:所有动作都通过 Intent,调试时可以记录整条 Intent 流回放。
可撤销:Intent 都可序列化,撤销栈实现简单。
状态唯一:不会有"两个状态不一致"的诡异 bug。
3. 代价
写起来啰嗦:每个交互都要定义 Intent 类型。
对简单页面过度设计。
所以"是否采用 MVI"取决于页面复杂度:登录页用 MVVM 够了,下单结算页考虑 MVI。
六、Clean Architecture:六边形分层
Clean Architecture 是更宏观的分层思想,把应用分成几个环:
实体层 Entities(纯业务)
↑
用例层 Use Cases(业务规则)
↑
接口适配层 Interface Adapters
↑
框架 & 驱动层 Frameworks & Drivers
鸿蒙里的落地映射:
依赖方向:外向内永远单向。pages/ 依赖 vm/,vm/ 依赖 usecase/,usecase/ 只依赖 model/。
// usecase/GetProductList.ts
export class GetProductList {
constructor(private repo: ProductRepo) {}
async execute(params: { keyword: string }): Promise<Product[]> {
return this.repo.search(params.keyword);
}
}
// vm/ProductListVM.ts
export class ProductListVM {
constructor(private getProductList: GetProductList) {}
async load() {
this.products = await this.getProductList.execute({ keyword: '' });
}
}
VM 只通过 Use Case 拿数据,不知道是 HTTP 还是本地缓存。
七、状态机:复杂业务逻辑的最后一招
面对"订单状态""支付状态"这种业务流程,可以用有限状态机(FSM):
type OrderState = 'pending' | 'paid' | 'shipping' | 'completed' | 'cancelled';
type OrderEvent = 'pay' | 'ship' | 'complete' | 'cancel';
const transitions: Record<OrderState, Partial<Record<OrderEvent, OrderState>>> = {
pending: { pay: 'paid', cancel: 'cancelled' },
paid: { ship: 'shipping', cancel: 'cancelled' },
shipping: { complete: 'completed' },
completed: {},
cancelled: {},
};
export function nextState(s: OrderState, e: OrderEvent): OrderState {
return transitions[s][e] ?? s;
}
状态机的好处:状态迁移图视觉化,所有"非法状态"在编译期就能拦截,避免出现"已取消但又发货"的逻辑漏洞。
八、案例:电商 App 架构演进
V1 - 早期(MVC 风格)
所有逻辑直接写在 @Component。30 + 页面,代码 4 万行。已经出现改一处崩一片的问题。
V2 - 引入 MVVM
提炼出 ProductVM、CartVM、UserVM。状态集中在 @ObservedV2。单元测试覆盖率从 0 涨到 35%。
V3 - 引入 UseCase 层
发现 VM 里仍然夹杂业务逻辑。抽离 usecase/,VM 只做"调用 + 状态展示"。业务测试覆盖到 60%。
V4 - 复杂页面切 MVI
支付、订单详情等状态机复杂的页面用 MVI,单纯列表保留 MVVM。
pages/ 简单页面:直接 MVVM
复杂页面:MVI
↓
vm/ 视图状态
↓
usecase/ 业务用例
↓
repository/ 数据抽象
↓
net/ db/ 具体实现
各取所长,是务实做法。
小结
架构选择没有"最好",只有"合适":
MVC:过度时已暴露问题,可以放弃。
MVP:可测但啰嗦,很少单独选。
MVVM:鸿蒙默认推荐,主流选项。
MVI:复杂页面(状态机、可回放)适用。
Clean:分层明确,但要看团队接受度。
下一篇是这个 100 篇系列的"中段"总结——前 39 篇都讲了啥,后 60 篇要怎么走,帮读者画一张"接下来读什么"的路线图。
参考阅读
鸿蒙开发者文档《状态管理最佳实践》
《Clean Architecture》(Robert C. Martin)
《Domain Modeling Made Functional》(DDD 实务)
Eli Bendersky’s blog(架构对比分析)