
前言:从 Flux 到 MVI,状态管理的范式演进
在前端与跨平台开发领域,状态管理始终是架构设计的核心命题。ArkUI 作为 HarmonyOS NEXT 的声明式 UI 框架,虽然内置了 @State、@Link、@Provide 等响应式状态装饰器,但当业务复杂度持续增长时,单纯依赖装饰器的「数据驱动视图」模式会逐渐暴露出状态逻辑散落各处、难以追踪数据流向、副作用处理混乱等问题。
本文选择 MVI(Model-View-Intent) 架构作为深度切入角度。MVI 起源于 Facebook 在 2014 年提出的 Flux 模式,其核心思想是通过单向数据流确保应用状态的可预测性:用户操作产生 Intent,Intent 触发状态转换函数 reduce,Model(状态)更新后驱动 View 重渲染。这一模式在 ArkUI 中的落地,既继承了响应式声明式 UI 的优势,又为复杂业务逻辑提供了结构化的工程组织方式。
在展开具体实现之前,先梳理一下业界几种主要状态管理范式的差异,有助于理解 MVI 的设计取舍。
| 特性维度 | MVC / MVP | Redux / Vuex | MVI |
|---|---|---|---|
| 数据流向 | 双向绑定或回调传递 | 单向流动(Store 中心化) | 单向流动(Intent 显式化) |
| 状态组织 | 分散在 Controller/Model 中 | 单一 Store + Reducer 纯函数 | 每个模块独立 Store + reduce |
| Intent 表达 | 隐式(方法调用) | 隐式(Action Creator) | 显式(Intent 对象/DI 类型) |
| 副作用处理 | Controller 自行管理 | Middleware(thunk/saga) | 独立的 Effect 层 |
| 可追溯性 | 差 | 中(依赖 time-travel) | 高(Intent→State 链路清晰) |
接下来,我们从 MviStore 基类封装开始,逐步构建一套完整的 MVI 工程化方案。
一、MviStore 基类封装:reduce 纯函数状态转换的核心
1.1 设计理念
ArkUI 的响应式系统基于 @Track 和 @Observed 装饰器实现属性级别的变化追踪。在 MVI 架构中,我们需要一个 Store 基类来完成以下职责:
- 状态不可变性(Immutability):每次 reduce 后生成新状态对象,而非原地修改。
- Intent → State 转换:提供类型安全的 reduce 方法签名,确保状态转换的纯函数特性。
- 订阅与通知:当状态变化时,自动驱动 UI 层重渲染。
下面给出 MviStore 的完整实现代码。该实现充分利用了 ArkUI API 12+ 的响应式 API,同时兼容 TypeScript 风格的泛型约束。
typescript
// MviStore.ets - MVI Store 基类
// 封装状态管理核心逻辑,支持 reduce 纯函数和状态订阅
import { observable, track } from '@kit.ArkUI'
/**
* MVI Intent 基接口
* 所有业务 Intent 都应实现此接口
*/
export interface MviIntent {
// Intent 的唯一标识,用于日志和调试
readonly type: string
}
/**
* MVI State 基接口
* 所有业务 State 都应实现此接口
*/
export interface MviState {
// 标识状态是否正在加载
isLoading?: boolean
// 标识是否有错误
error?: string
}
/**
* MVI Effect 基接口
* 用于处理副作用:网络请求、导航、日志等
*/
export interface MviEffect {
readonly type: string
}
/**
* MviStore 泛型约束
* S: State 类型
* I: Intent 类型
* E: Effect 类型
*/
export abstract class MviStore<S extends MviState, I extends MviIntent, E extends MviEffect> {
// 受保护的状态属性,使用 @State 保证响应式
@State protected state: S = this.initState()
// 状态变更前的快照,用于 diff 判断(可选优化)
private prevStateSnapshot: S | null = null
/**
* 子类必须实现的初始化状态方法
*/
abstract initState(): S
/**
* 纯函数 reduce:根据 Intent 计算新状态
* 这是 MVI 架构的核心------所有状态转换都在这里完成
*/
abstract reduce(state: S, intent: I): S
/**
* 可选的副作用处理方法
* 当 reduce 后的状态包含触发副作用的标记时调用
*/
protected handleEffect(effect: E): void {
// 子类可重写此方法处理具体副作用
}
/**
* 处理用户 Intent 的主入口
* 1. 调用 reduce 计算新状态
* 2. 检测状态是否真正变化
* 3. 触发副作用(如有)
*/
dispatch(intent: I): void {
const currentState = this.state
const newState = this.reduce(currentState, intent)
// 使用浅比较判断状态是否真正改变,避免不必要的重渲染
if (this.isStateChanged(currentState, newState)) {
this.prevStateSnapshot = { ...currentState } as S
this.state = newState
// 检查是否需要触发副作用
this.checkAndDispatchEffect(newState, intent)
}
}
/**
* 检测状态变化
* 基础版本使用浅比较,子类可根据字段特点重写深度比较
*/
protected isStateChanged(oldState: S, newState: S): boolean {
return JSON.stringify(oldState) !== JSON.stringify(newState)
}
/**
* 检查并分发 Effect
* 子类可重写此方法实现自定义副作用分发逻辑
*/
protected checkAndDispatchEffect(newState: S, intent: I): void {
// 基础实现为空,子类可扩展
}
/**
* 获取当前状态(供 View 层消费)
*/
getState(): S {
return this.state
}
/**
* 重置状态到初始值
*/
reset(): void {
this.state = this.initState()
this.prevStateSnapshot = null
}
}
以上 MviStore 的设计有几个关键点值得深入说明。
不可变状态 是 MVI 架构的基础信念。reduce 方法接收当前状态和一个 Intent,返回全新的状态对象,而不是修改原对象。这种设计使得状态变更可以被精确追踪------任意两个时间点的状态都是可比较的。在 ArkUI 中,我们将 state 属性标记为 @State,当状态引用发生变化时,框架会自动触发相关组件的重渲染。
副作用隔离 是另一个重要设计考量。dispatch 方法负责纯函数的状态转换,而 handleEffect 则处理非纯操作(如网络请求、持久化存储、外部 SDK 调用等)。这种分离确保了状态逻辑的可测试性------reduce 方法完全由输入决定,单元测试只需验证「给定状态 + Intent → 预期新状态」的映射关系。
二、ViewModel 层:Intent 事件处理的编排中心
2.1 为什么需要 ViewModel 层
在 MVI 架构中,View 层负责渲染 UI 并捕获用户交互。ViewModel 层的存在有三重意义:
- 解耦 View 与 Store:View 不直接持有 Store 实例,通过 ViewModel 间接交互,降低视图层与业务逻辑的耦合度。
- 聚合多个 Intent 来源:一个页面可能有多个数据源(用户操作、网络响应、定时器),ViewModel 负责统一编排这些 Intent 的分发。
- 生命周期管理:ViewModel 可以感知组件的生命周期事件,在适当时机初始化或清理资源。
2.2 ViewModel 的实现模式
typescript
// TodoViewModel.ets - 待办事项 MVI ViewModel
// 负责 Intent 的收集、派发,以及与 Store 的协作
import { MviStore, MviIntent, MviState, MviEffect } from '../mvi/MviStore'
import { TodoStore } from './TodoStore'
import { TodoItem, FilterType } from '../model/TodoItem'
/**
* Intent 定义 - 所有可能的用户操作/系统事件
*/
export enum TodoIntentType {
LOAD_TODOS = 'LOAD_TODOS',
ADD_TODO = 'ADD_TODO',
TOGGLE_TODO = 'TOGGLE_TODO',
DELETE_TODO = 'DELETE_TODO',
SET_FILTER = 'SET_FILTER',
CLEAR_COMPLETED = 'CLEAR_COMPLETED'
}
export interface TodoIntent extends MviIntent {
readonly type: TodoIntentType
payload?: any
}
export class AddTodoIntent implements TodoIntent {
readonly type = TodoIntentType.ADD_TODO
constructor(public readonly title: string) {}
}
export class ToggleTodoIntent implements TodoIntent {
readonly type = TodoIntentType.TOGGLE_TODO
constructor(public readonly id: string) {}
}
export class SetFilterIntent implements TodoIntent {
readonly type = TodoIntentType.SET_FILTER
constructor(public readonly filter: FilterType) {}
}
export class LoadTodosIntent implements TodoIntent {
readonly type = TodoIntentType.LOAD_TODOS
}
export class DeleteTodoIntent implements TodoIntent {
readonly type = TodoIntentType.DELETE_TODO
constructor(public readonly id: string) {}
}
export class ClearCompletedIntent implements TodoIntent {
readonly type = TodoIntentType.CLEAR_COMPLETED
}
/**
* Effect 定义 - 副作用事件
*/
export enum TodoEffectType {
SHOW_TOAST = 'SHOW_TOAST',
NAVIGATE_BACK = 'NAVIGATE_BACK',
LOG_ERROR = 'LOG_ERROR'
}
export interface TodoEffect extends MviEffect {
readonly type: TodoEffectType
payload?: any
}
export class ShowToastEffect implements TodoEffect {
readonly type = TodoEffectType.SHOW_TOAST
constructor(public readonly message: string) {}
}
/**
* ViewModel 类
* 封装 TodoStore,提供与 UI 层对接的接口
*/
export class TodoViewModel {
private store: TodoStore
// 暴露给 View 的状态(使用 getter 保持响应式引用)
get state() {
return this.store.getState()
}
// 已完成项数量
get completedCount(): number {
return this.state.todos.filter(t => t.completed).length
}
// 未完成项数量
get activeCount(): number {
return this.state.todos.filter(t => !t.completed).length
}
// 根据当前筛选条件过滤后的待办列表
get filteredTodos(): TodoItem[] {
const { todos, filter } = this.state
switch (filter) {
case FilterType.ACTIVE:
return todos.filter(t => !t.completed)
case FilterType.COMPLETED:
return todos.filter(t => t.completed)
default:
return todos
}
}
constructor() {
this.store = new TodoStore()
}
/**
* 初始化页面数据
*/
onPageShow(): void {
this.dispatch(new LoadTodosIntent())
}
/**
* 分发 Intent 到 Store
* 这是 View 层与 Store 之间唯一的通信通道
*/
dispatch(intent: TodoIntent): void {
this.store.dispatch(intent)
}
/**
* 添加新待办
*/
addTodo(title: string): void {
if (!title.trim()) {
return
}
this.dispatch(new AddTodoIntent(title.trim()))
}
/**
* 切换待办完成状态
*/
toggleTodo(id: string): void {
this.dispatch(new ToggleTodoIntent(id))
}
/**
* 删除待办
*/
deleteTodo(id: string): void {
this.dispatch(new DeleteTodoIntent(id))
}
/**
* 设置筛选条件
*/
setFilter(filter: FilterType): void {
this.dispatch(new SetFilterIntent(filter))
}
/**
* 清除已完成项
*/
clearCompleted(): void {
this.dispatch(new ClearCompletedIntent())
}
}
ViewModel 层的抽象带来一个额外的好处:可测试性 。当需要测试「用户点击切换按钮后,UI 是否正确反映完成状态」这一场景时,只需构造一个 Intent 对象并调用 dispatch,然后断言 Store 中的状态是否如预期变化。整个测试过程不涉及任何 UI 渲染,运行速度快,定位问题精准。
三、TodoStore:reduce 函数的完整实现
3.1 数据模型定义
在展开 Store 实现之前,先定义好数据模型。良好的类型定义是 TypeScript/ArkUI 工程可维护性的基石。
typescript
// TodoItem.ets - 待办事项数据模型
/**
* 待办项优先级
*/
export enum Priority {
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high'
}
/**
* 筛选类型
*/
export enum FilterType {
ALL = 'all',
ACTIVE = 'active',
COMPLETED = 'completed'
}
/**
* 待办项实体
*/
export class TodoItem {
id: string
title: string
completed: boolean
priority: Priority
createdAt: number
completedAt?: number
constructor(title: string, priority: Priority = Priority.NORMAL) {
this.id = this.generateId()
this.title = title
this.completed = false
this.priority = priority
this.createdAt = Date.now()
}
private generateId(): string {
return `todo_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`
}
/**
* 创建 TodoItem 的不可变更新版本
*/
static update(item: TodoItem, updates: Partial<TodoItem>): TodoItem {
const newItem = new TodoItem(item.title, item.priority)
newItem.id = item.id
newItem.completed = item.completed
newItem.createdAt = item.createdAt
newItem.completedAt = item.completedAt
// 应用更新
Object.assign(newItem, updates)
return newItem
}
}
3.2 Store 状态与 reduce 实现
typescript
// TodoStore.ets - 待办事项 MVI Store
// 包含状态定义、reduce 纯函数和副作用分发
import { MviStore } from '../mvi/MviStore'
import {
TodoIntent,
TodoIntentType,
AddTodoIntent,
ToggleTodoIntent,
SetFilterIntent,
LoadTodosIntent,
DeleteTodoIntent,
ClearCompletedIntent,
ShowToastEffect,
TodoEffect
} from './TodoViewModel'
import { TodoItem, Priority, FilterType } from '../model/TodoItem'
/**
* 待办列表页面状态
*/
export interface TodoState {
isLoading: boolean
error?: string
todos: TodoItem[]
filter: FilterType
editingId?: string // 当前正在编辑的待办 ID
}
/**
* TodoStore - 继承 MviStore 基类
* 负责状态管理和 Intent → State 转换
*/
export class TodoStore extends MviStore<TodoState, TodoIntent, TodoEffect> {
/**
* 初始化状态
*/
initState(): TodoState {
return {
isLoading: false,
error: undefined,
todos: [],
filter: FilterType.ALL,
editingId: undefined
}
}
/**
* reduce 纯函数 - MVI 架构的核心
* 每个 Intent 对应一个状态转换逻辑
* 输入:当前状态 + Intent → 输出:全新状态对象
*/
reduce(state: TodoState, intent: TodoIntent): TodoState {
switch (intent.type) {
case TodoIntentType.LOAD_TODOS: {
// 模拟从本地存储加载数据
const savedTodos = this.loadFromStorage()
return {
...state,
isLoading: false,
todos: savedTodos,
error: undefined
}
}
case TodoIntentType.ADD_TODO: {
const addIntent = intent as AddTodoIntent
const newTodo = new TodoItem(addIntent.title, Priority.NORMAL)
return {
...state,
todos: [...state.todos, newTodo],
error: undefined
}
}
case TodoIntentType.TOGGLE_TODO: {
const toggleIntent = intent as ToggleTodoIntent
const updatedTodos = state.todos.map(todo => {
if (todo.id === toggleIntent.id) {
const now = Date.now()
// 使用 TodoItem.update 保证不可变性
return TodoItem.update(todo, {
completed: !todo.completed,
completedAt: !todo.completed ? now : undefined
})
}
return todo
})
return {
...state,
todos: updatedTodos
}
}
case TodoIntentType.DELETE_TODO: {
const deleteIntent = intent as DeleteTodoIntent
return {
...state,
todos: state.todos.filter(todo => todo.id !== deleteIntent.id)
}
}
case TodoIntentType.SET_FILTER: {
const filterIntent = intent as SetFilterIntent
return {
...state,
filter: filterIntent.filter
}
}
case TodoIntentType.CLEAR_COMPLETED: {
return {
...state,
todos: state.todos.filter(todo => !todo.completed)
}
}
default:
return state
}
}
/**
* 重写副作用分发逻辑
* 根据状态变化触发相应的副作用
*/
protected checkAndDispatchEffect(newState: TodoState, intent: TodoIntent): void {
// 当添加新待办时,显示成功提示
if (intent.type === TodoIntentType.ADD_TODO) {
this.handleEffect(new ShowToastEffect('待办已添加'))
}
// 当清除已完成项时,显示提示
if (intent.type === TodoIntentType.CLEAR_COMPLETED && newState.todos.length > 0) {
const removedCount = newState.todos.filter(t => !t.completed).length
if (removedCount < this.state.todos.length) {
const cleared = this.state.todos.length - newState.todos.length
this.handleEffect(new ShowToastEffect(`已清除 ${cleared} 项已完成待办`))
}
}
}
/**
* 模拟从本地存储加载数据
*/
private loadFromStorage(): TodoItem[] {
// 在实际项目中,这里会调用 AppStorage 或用户首选项存储
// 这里返回模拟数据用于演示
const mockTodos: TodoItem[] = [
new TodoItem('完成 MVI 架构文章初稿', Priority.HIGH),
new TodoItem('Review 代码评审意见', Priority.NORMAL),
new TodoItem('整理下周会议议程', Priority.LOW)
]
// 模拟其中一项已完成
mockTodos[1].completed = true
mockTodos[1].completedAt = Date.now()
return mockTodos
}
}
到这里,MVI 架构的三个核心要素------Intent 定义、State 管理和 reduce 转换------已经全部就位。下面来关注 View 层的实现,看看 UI 如何与 ViewModel 联动。
四、UI 层:TodoList 页面完整实现
4.1 页面组件结构
ArkUI 的声明式语法与 React/Vue 有相似之处,但在组件组织上有自己的特点。以下 TodoList 页面涵盖了添加待办、列表展示、状态切换、筛选和批量操作等核心场景。
typescript
// TodoListPage.ets - 待办列表 MVI 页面
// 完整展示 MVI 架构在 ArkUI 中的应用
import router from '@ohos.router'
import promptAction from '@ohos.promptAction'
import { TodoViewModel, FilterType } from '../viewmodel/TodoViewModel'
import { TodoItem, Priority } from '../model/TodoItem'
/**
* 优先级颜色映射
*/
const PriorityColors: Record<Priority, string> = {
[Priority.HIGH]: '#FF3B30',
[Priority.NORMAL]: '#007AFF',
[Priority.LOW]: '#8E8E93'
}
/**
* 优先级文字映射
*/
const PriorityLabels: Record<Priority, string> = {
[Priority.HIGH]: '高',
[Priority.NORMAL]: '中',
[Priority.LOW]: '低'
}
/**
* 筛选 Tab 配置
*/
const FilterTabs: { type: FilterType; label: string }[] = [
{ type: FilterType.ALL, label: '全部' },
{ type: FilterType.ACTIVE, label: '进行中' },
{ type: FilterType.COMPLETED, label: '已完成' }
]
/**
* TodoListPage - MVI 架构中的 View 层
* 职责:
* 1. 观察 ViewModel 的状态变化
* 2. 捕获用户交互,转化为 Intent 派发给 ViewModel
* 3. 根据状态渲染 UI
*/
@Entry
@Component
struct TodoListPage {
// 实例化 ViewModel
private viewModel: TodoViewModel = new TodoViewModel()
// 本地状态:输入框文本
@State private inputText: string = ''
// 标记是否显示添加输入框
@State private showAddInput: boolean = false
// 标记输入框是否获取焦点
@State private inputFocus: boolean = false
aboutToAppear(): void {
// 页面即将显示时,加载初始数据
this.viewModel.onPageShow()
}
/**
* 渲染页面
*/
build() {
Column() {
// 顶部标题栏
this.buildHeader()
// 筛选 Tab
this.buildFilterTabs()
// 待办列表
this.buildTodoList()
// 底部统计栏
this.buildFooter()
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F7')
.padding({ left: 16, right: 16, top: 16 })
}
/**
* 顶部标题栏
*/
@Builder
buildHeader() {
Row() {
Text('待办事项')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#1C1C1E')
Blank()
// 添加按钮
Button() {
SymbolGlyph($r('sys.symbol.plus'))
.fontSize(24)
}
.type(ButtonType.Circle)
.width(44)
.height(44)
.backgroundColor('#007AFF')
.onClick(() => {
this.showAddInput = !this.showAddInput
if (!this.showAddInput) {
this.inputText = ''
}
})
}
.width('100%')
.margin({ bottom: 16 })
}
/**
* 筛选 Tab 栏
*/
@Builder
buildFilterTabs() {
Row() {
ForEach(FilterTabs, (tab: { type: FilterType; label: string }) => {
Column() {
Text(tab.label)
.fontSize(15)
.fontColor(
this.viewModel.state.filter === tab.type
? '#007AFF'
: '#8E8E93'
)
.fontWeight(
this.viewModel.state.filter === tab.type
? FontWeight.Medium
: FontWeight.Regular
)
// 选中指示条
if (this.viewModel.state.filter === tab.type) {
Divider()
.width(20)
.height(2)
.backgroundColor('#007AFF')
.borderRadius(1)
.margin({ top: 4 })
} else {
Divider()
.width(20)
.height(2)
.backgroundColor(Color.Transparent)
.margin({ top: 4 })
}
}
.flexBasis(0)
.flexGrow(1)
.onClick(() => {
this.viewModel.setFilter(tab.type)
})
})
}
.width('100%')
.margin({ bottom: 12 })
}
/**
* 待办列表
*/
@Builder
buildTodoList() {
Column() {
// 添加输入框(条件渲染)
if (this.showAddInput) {
this.buildAddInput()
Divider().margin({ top: 8, bottom: 12 })
}
// 列表主体
if (this.viewModel.filteredTodos.length === 0) {
this.buildEmptyState()
} else {
List({ space: 8 }) {
ForEach(
this.viewModel.filteredTodos,
(todo: TodoItem) => {
ListItem() {
this.buildTodoItem(todo)
}
},
(todo: TodoItem) => todo.id
)
}
.width('100%')
.layoutWeight(1)
.alignListItem(ListItemAlign.Start)
}
}
.width('100%')
.layoutWeight(1)
}
/**
* 添加待办输入框
*/
@Builder
buildAddInput() {
Row() {
TextInput({
placeholder: '输入新待办...',
text: this.inputText
})
.width('100%')
.height(44)
.placeholderColor('#8E8E93')
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding({ left: 12, right: 12 })
.caretColor('#007AFF')
.onChange((value: string) => {
this.inputText = value
})
.onSubmit(() => {
this.submitNewTodo()
})
Button('添加')
.type(ButtonType.Capsule)
.height(36)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor(this.inputText.trim() ? '#007AFF' : '#C7C7CC')
.enabled(this.inputText.trim().length > 0)
.margin({ left: 8 })
.onClick(() => {
this.submitNewTodo()
})
}
.width('100%')
}
/**
* 单个待办项
*/
@Builder
buildTodoItem(todo: TodoItem) {
Row() {
// 复选框
Checkbox()
.select(todo.completed)
.selectedColor('#007AFF')
.shape(CheckBoxShape.CIRCLE)
.onChange((checked: boolean) => {
this.viewModel.toggleTodo(todo.id)
})
Column() {
// 待办标题
Text(todo.title)
.fontSize(16)
.fontColor(todo.completed ? '#8E8E93' : '#1C1C1E')
.decoration(todo.completed ? TextDecorationType.LineThrough : TextDecorationType.None)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
// 优先级和创建时间
Row() {
Text(PriorityLabels[todo.priority])
.fontSize(11)
.fontColor(PriorityColors[todo.priority])
.backgroundColor(PriorityColors[todo.priority] + '20')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
Text(this.formatDate(todo.createdAt))
.fontSize(11)
.fontColor('#8E8E93')
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.layoutWeight(1)
.margin({ left: 12 })
// 删除按钮
Button() {
SymbolGlyph($r('sys.symbol.trash'))
.fontSize(18)
.fontColor('#FF3B30')
}
.type(ButtonType.Circle)
.width(36)
.height(36)
.backgroundColor('#FFFFFF')
.onClick(() => {
this.viewModel.deleteTodo(todo.id)
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({
radius: 4,
color: '#00000010',
offsetX: 0,
offsetY: 2
})
}
/**
* 空状态提示
*/
@Builder
buildEmptyState() {
Column() {
SymbolGlyph($r('sys.symbol.list_bullet'))
.fontSize(64)
.fontColor('#C7C7CC')
Text('暂无待办事项')
.fontSize(17)
.fontColor('#8E8E93')
.margin({ top: 16 })
Text('点击上方 + 按钮添加新待办')
.fontSize(14)
.fontColor('#C7C7CC')
.margin({ top: 8 })
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
/**
* 底部统计栏
*/
@Builder
buildFooter() {
Row() {
Text(`${this.viewModel.activeCount} 项进行中`)
.fontSize(13)
.fontColor('#8E8E93')
Blank()
if (this.viewModel.completedCount > 0) {
Text('清除已完成')
.fontSize(13)
.fontColor('#FF3B30')
.onClick(() => {
this.viewModel.clearCompleted()
})
}
}
.width('100%')
.padding({ top: 12, bottom: 12 })
}
/**
* 提交新待办
*/
private submitNewTodo(): void {
if (this.inputText.trim()) {
this.viewModel.addTodo(this.inputText.trim())
this.inputText = ''
// 收起输入框
this.showAddInput = false
}
}
/**
* 格式化日期
*/
private formatDate(timestamp: number): string {
const date = new Date(timestamp)
const month = date.getMonth() + 1
const day = date.getDate()
return `${month}月${day}日`
}
}
这段代码完整展示了一个 MVI 架构下的 ArkUI 页面实现。值得注意几个设计细节:View 层完全不知道数据是如何存储和处理的,它只知道「当前状态长什么样」和「用户做了什么操作」。当 viewModel.state 中的任意属性变化时,ArkUI 的响应式系统会自动触发受影响组件的重渲染,开发者无需手动调用 setState 或传递回调。
五、进阶案例:电商商品列表 MVI 页面
5.1 需求场景与架构设计
待办事项足够简单,可以清晰地展示 MVI 骨架。但真实业务场景往往更加复杂。接下来我们实现一个电商商品列表页面,涵盖以下需求:
- 商品分类级联筛选(一级分类 → 二级分类 → 商品列表)
- 搜索框防抖(300ms 内仅响应最后一次输入)
- 分页加载(滚动到底部自动加载更多)
- 价格排序(升序/降序/默认)
- 骨架屏占位(加载中状态)
这些需求叠加在一起,正好能展示 MVI 架构在复杂异步场景下的组织能力。
5.2 商品数据模型与状态定义
typescript
// Product.ets - 商品数据模型
export interface Product {
id: string
name: string
price: number
originalPrice: number
imageUrl: string
categoryId: string
subCategoryId: string
rating: number
salesCount: number
stock: number
}
export interface Category {
id: string
name: string
children?: Category[]
}
export enum SortType {
DEFAULT = 'default',
PRICE_ASC = 'price_asc',
PRICE_DESC = 'price_desc',
SALES = 'sales'
}
/**
* 电商商品列表状态
*/
export interface ProductListState {
isLoading: boolean
isLoadingMore: boolean // 加载更多中
error?: string
// 数据
products: Product[]
categories: Category[]
// 筛选条件
selectedCategoryId?: string
selectedSubCategoryId?: string
keyword: string
sortType: SortType
// 分页
page: number
pageSize: number
hasMore: boolean
// 防抖计时器 ID(用于取消上一次的防抖)
searchDebounceId?: number
}
/**
* 电商 Intent 定义
*/
export enum ProductIntentType {
INIT = 'INIT',
SELECT_CATEGORY = 'SELECT_CATEGORY',
SELECT_SUBCATEGORY = 'SELECT_SUBCATEGORY',
SEARCH = 'SEARCH',
SORT = 'SORT',
LOAD_MORE = 'LOAD_MORE',
REFRESH = 'REFRESH'
}
export interface ProductIntent {
readonly type: ProductIntentType
payload?: any
}
5.3 ProductStore 的完整 reduce 实现
typescript
// ProductStore.ets - 电商商品 MVI Store
import { MviStore } from '../mvi/MviStore'
import { ProductIntent, ProductIntentType } from './ProductModel'
import { Product, Category, SortType, ProductListState } from './ProductModel'
/**
* 电商商品 Store
*/
export class ProductStore extends MviStore<ProductListState, ProductIntent, any> {
initState(): ProductListState {
return {
isLoading: false,
isLoadingMore: false,
error: undefined,
products: [],
categories: [],
selectedCategoryId: undefined,
selectedSubCategoryId: undefined,
keyword: '',
sortType: SortType.DEFAULT,
page: 1,
pageSize: 20,
hasMore: true
}
}
reduce(state: ProductListState, intent: ProductIntent): ProductListState {
switch (intent.type) {
case ProductIntentType.INIT: {
return {
...this.initState(),
isLoading: true,
categories: this.getMockCategories()
}
}
case ProductIntentType.SELECT_CATEGORY: {
const { categoryId } = intent.payload
return {
...state,
selectedCategoryId: categoryId,
selectedSubCategoryId: undefined,
products: [], // 清空当前商品
page: 1,
hasMore: true,
isLoading: true
}
}
case ProductIntentType.SELECT_SUBCATEGORY: {
const { subCategoryId } = intent.payload
return {
...state,
selectedSubCategoryId: subCategoryId,
products: [],
page: 1,
hasMore: true,
isLoading: true
}
}
case ProductIntentType.SEARCH: {
const { keyword } = intent.payload
return {
...state,
keyword: keyword,
products: [],
page: 1,
hasMore: true,
isLoading: true
}
}
case ProductIntentType.SORT: {
const { sortType } = intent.payload
// 排序不改变商品数据,只改变显示顺序
// 重新计算排序后的列表(实际场景会发请求)
const sortedProducts = this.applySort([...state.products], sortType)
return {
...state,
sortType: sortType,
products: sortedProducts
}
}
case ProductIntentType.LOAD_MORE: {
if (state.isLoadingMore || !state.hasMore) {
return state
}
return {
...state,
isLoadingMore: true,
page: state.page + 1
}
}
case ProductIntentType.REFRESH: {
return {
...state,
isLoading: true,
page: 1,
products: []
}
}
default:
return state
}
}
/**
* 排序应用函数
* 纯函数,输入商品列表和排序类型,返回排序后的新列表
*/
private applySort(products: Product[], sortType: SortType): Product[] {
const sorted = [...products]
switch (sortType) {
case SortType.PRICE_ASC:
return sorted.sort((a, b) => a.price - b.price)
case SortType.PRICE_DESC:
return sorted.sort((a, b) => b.price - a.price)
case SortType.SALES:
return sorted.sort((a, b) => b.salesCount - a.salesCount)
default:
return sorted.sort((a, b) => a.id.localeCompare(b.id))
}
}
/**
* 模拟获取分类数据
*/
private getMockCategories(): Category[] {
return [
{
id: 'cat_1',
name: '数码电子',
children: [
{ id: 'cat_1_1', name: '手机' },
{ id: 'cat_1_2', name: '平板' },
{ id: 'cat_1_3', name: '笔记本' }
]
},
{
id: 'cat_2',
name: '智能穿戴',
children: [
{ id: 'cat_2_1', name: '智能手表' },
{ id: 'cat_2_2', name: '手环' },
{ id: 'cat_2_3', name: '耳机' }
]
},
{
id: 'cat_3',
name: '智能家居',
children: [
{ id: 'cat_3_1', name: '路由器' },
{ id: 'cat_3_2', name: '摄像头' },
{ id: 'cat_3_3', name: '音箱' }
]
}
]
}
}
5.4 防抖与异步数据加载服务
MVI 架构中,异步副作用不直接写在 Store 的 reduce 方法里,而是由 ViewModel 层负责调用服务层。以下是封装了防抖逻辑的搜索服务。
typescript
// ProductService.ets - 商品数据服务
// 负责异步数据获取,包含防抖逻辑
import { Product, Category, SortType } from '../model/ProductModel'
/**
* 商品服务类
* 封装所有商品相关的异步操作
*/
export class ProductService {
/**
* 模拟获取商品列表
* 在实际项目中,这里会调用 HTTP 接口
*/
async fetchProducts(params: {
categoryId?: string
subCategoryId?: string
keyword?: string
sortType: SortType
page: number
pageSize: number
}): Promise<{ products: Product[]; hasMore: boolean }> {
// 模拟网络延迟 300-800ms
const delay = 300 + Math.random() * 500
await this.sleep(delay)
// 生成模拟商品数据
const products: Product[] = []
const startIndex = (params.page - 1) * params.pageSize
const count = Math.min(params.pageSize, 10 + Math.floor(Math.random() * 10))
for (let i = 0; i < count; i++) {
const index = startIndex + i + 1
const basePrice = 99 + Math.floor(Math.random() * 5000)
products.push({
id: `product_${params.page}_${i}`,
name: `${params.keyword || '商品'}_${index}_${this.randomChinese()}`,
price: basePrice,
originalPrice: Math.round(basePrice * 1.2),
imageUrl: `https://picsum.photos/200/200?random=${index}`,
categoryId: params.categoryId || 'cat_1',
subCategoryId: params.subCategoryId || 'cat_1_1',
rating: 3.5 + Math.random() * 1.5,
salesCount: Math.floor(Math.random() * 10000),
stock: Math.floor(Math.random() * 500)
})
}
return {
products,
hasMore: params.page < 10 // 最多加载 10 页
}
}
/**
* 搜索防抖实现
* 使用闭包和 setTimeout,500ms 内只响应最后一次输入
*/
debounceSearch(
keyword: string,
onExecute: (keyword: string) => void,
delayMs: number = 500
): () => void {
let timerId: number | null = null
// 返回取消函数
return () => {
if (timerId !== null) {
clearTimeout(timerId)
}
timerId = setTimeout(() => {
onExecute(keyword)
timerId = null
}, delayMs) as unknown as number
}
}
/**
* 生成随机中文字符(模拟商品名)
*/
private randomChinese(): string {
const chars = ['旗舰版', 'Pro', 'Max', '青春版', '标准版', '尊享版', '套装', '迷你款']
return chars[Math.floor(Math.random() * chars.length)]
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
5.5 ProductViewModel:整合 MVI 与异步流程
typescript
// ProductViewModel.ets - 商品列表 ViewModel
import { ProductStore } from '../store/ProductStore'
import { ProductService } from '../service/ProductService'
import { Product, SortType, ProductIntentType } from '../model/Product'
export class ProductViewModel {
private store: ProductStore
private service: ProductService
private searchDebouncer: (() => void) | null = null
get state() {
return this.store.getState()
}
constructor() {
this.store = new ProductStore()
this.service = new ProductService()
}
/**
* 初始化页面
*/
async onPageShow(): Promise<void> {
this.store.dispatch({ type: ProductIntentType.INIT })
await this.loadProducts()
}
/**
* 选择一级分类
*/
async selectCategory(categoryId: string): Promise<void> {
this.store.dispatch({
type: ProductIntentType.SELECT_CATEGORY,
payload: { categoryId }
})
await this.loadProducts()
}
/**
* 选择二级分类
*/
async selectSubCategory(subCategoryId: string): Promise<void> {
this.store.dispatch({
type: ProductIntentType.SELECT_SUBCATEGORY,
payload: { subCategoryId }
})
await this.loadProducts()
}
/**
* 防抖搜索
* 500ms 内用户连续输入只触发最后一次搜索
*/
onSearchInput(keyword: string): void {
if (this.searchDebouncer) {
this.searchDebouncer()
}
this.searchDebouncer = this.service.debounceSearch(
keyword,
(kw: string) => {
this.store.dispatch({
type: ProductIntentType.SEARCH,
payload: { keyword: kw }
})
this.loadProducts()
},
500
)
this.searchDebouncer()
}
/**
* 排序切换
*/
onSortChange(sortType: SortType): void {
this.store.dispatch({
type: ProductIntentType.SORT,
payload: { sortType }
})
}
/**
* 加载更多(触底刷新)
*/
async onLoadMore(): Promise<void> {
const state = this.state
if (state.isLoadingMore || !state.hasMore) {
return
}
this.store.dispatch({ type: ProductIntentType.LOAD_MORE })
await this.loadProducts(true)
}
/**
* 下拉刷新
*/
async onRefresh(): Promise<void> {
this.store.dispatch({ type: ProductIntentType.REFRESH })
await this.loadProducts()
}
/**
* 核心数据加载方法
*/
private async loadProducts(isLoadMore: boolean = false): Promise<void> {
const state = this.state
try {
const result = await this.service.fetchProducts({
categoryId: state.selectedCategoryId,
subCategoryId: state.selectedSubCategoryId,
keyword: state.keyword,
sortType: state.sortType,
page: state.page,
pageSize: state.pageSize
})
// 直接修改状态中的 products 数组(响应式更新)
if (isLoadMore) {
this.store.state.products = [...state.products, ...result.products]
this.store.state.isLoadingMore = false
} else {
this.store.state.products = result.products
this.store.state.isLoading = false
}
this.store.state.hasMore = result.hasMore
this.store.state.error = undefined
} catch (err) {
this.store.state.error = '加载失败,请重试'
this.store.state.isLoading = false
this.store.state.isLoadingMore = false
}
}
/**
* 计算折扣比例
*/
getDiscount(product: Product): string {
if (product.originalPrice <= product.price) {
return ''
}
const discount = Math.round((product.price / product.originalPrice) * 100) / 10
return `${discount}折`
}
}
5.6 商品列表 UI 实现
typescript
// ProductListPage.ets - 电商商品列表页面
import router from '@ohos.router'
import { ProductViewModel } from '../viewmodel/ProductViewModel'
import { Product, SortType } from '../model/Product'
/**
* 排序选项配置
*/
const SortOptions: { type: SortType; label: string }[] = [
{ type: SortType.DEFAULT, label: '综合' },
{ type: SortType.SALES, label: '销量' },
{ type: SortType.PRICE_ASC, label: '价格↑' },
{ type: SortType.PRICE_DESC, label: '价格↓' }
]
@Entry
@Component
struct ProductListPage {
private viewModel: ProductViewModel = new ProductViewModel()
@State private searchText: string = ''
@State private showSortMenu: boolean = false
aboutToAppear(): void {
this.viewModel.onPageShow()
}
build() {
Column() {
// 顶部搜索栏
this.buildSearchBar()
// 分类横向滚动
this.buildCategoryTabs()
// 商品网格
this.buildProductGrid()
}
.width('100%')
.height('100%')
.backgroundColor('#F2F2F7')
}
@Builder
buildSearchBar() {
Row() {
// 返回按钮
Button() {
SymbolGlyph($r('sys.symbol.arrow_left'))
.fontSize(20)
.fontColor('#1C1C1E')
}
.type(ButtonType.Circle)
.width(36)
.height(36)
.backgroundColor('#FFFFFF')
.onClick(() => router.back())
// 搜索框
Search({
placeholder: '搜索商品',
value: this.searchText
})
.width('100%')
.height(36)
.placeholderColor('#8E8E93')
.backgroundColor('#FFFFFF')
.borderRadius(8)
.margin({ left: 8, right: 8 })
.onChange((value: string) => {
this.searchText = value
this.viewModel.onSearchInput(value)
})
// 排序按钮
Button() {
SymbolGlyph($r('sys.symbol.line_horizontal_3_decrease'))
.fontSize(20)
.fontColor('#1C1C1E')
}
.type(ButtonType.Circle)
.width(36)
.height(36)
.backgroundColor('#FFFFFF')
.onClick(() => {
this.showSortMenu = !this.showSortMenu
})
}
.width('100%')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#FFFFFF')
}
@Builder
buildCategoryTabs() {
Scroll() {
Row({ space: 12 }) {
ForEach(
this.viewModel.state.categories,
(category) => {
Column() {
Text(category.name)
.fontSize(15)
.fontColor(
this.viewModel.state.selectedCategoryId === category.id
? '#007AFF'
: '#3C3C43'
)
.fontWeight(
this.viewModel.state.selectedCategoryId === category.id
? FontWeight.Medium
: FontWeight.Regular
)
if (this.viewModel.state.selectedCategoryId === category.id) {
Divider()
.width(24)
.height(2)
.backgroundColor('#007AFF')
.borderRadius(1)
.margin({ top: 4 })
}
}
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.onClick(() => {
this.viewModel.selectCategory(category.id)
})
}
)
}
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.backgroundColor('#FFFFFF')
.margin({ top: 1 })
}
@Builder
buildProductGrid() {
if (this.viewModel.state.isLoading) {
// 骨架屏
this.buildSkeletonGrid()
} else if (this.viewModel.state.error) {
// 错误状态
this.buildErrorState()
} else if (this.viewModel.state.products.length === 0) {
// 空状态
this.buildEmptyState()
} else {
// 商品网格(2 列)
Grid() {
ForEach(
this.viewModel.state.products,
(product: Product) => {
GridItem() {
this.buildProductCard(product)
}
},
(product: Product) => product.id
)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.padding(8)
.width('100%')
.layoutWeight(1)
.onReachEnd(() => {
this.viewModel.onLoadMore()
})
}
}
@Builder
buildProductCard(product: Product) {
Column() {
// 商品图片
Stack({ alignContent: Alignment.TopEnd }) {
Image(product.imageUrl)
.width('100%')
.height(120)
.objectFit(ImageFit.Cover)
.borderRadius({ topLeft: 8, topRight: 8 })
.backgroundColor('#EEEEEE')
// 折扣标签
if (this.viewModel.getDiscount(product)) {
Text(this.viewModel.getDiscount(product))
.fontSize(10)
.fontColor('#FFFFFF')
.backgroundColor('#FF3B30')
.padding({ left: 4, right: 4, top: 2, bottom: 2 })
.borderRadius({ topRight: 8, bottomLeft: 4 })
}
}
.width('100%')
Column() {
// 商品名称
Text(product.name)
.fontSize(13)
.fontColor('#1C1C1E')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// 价格
Row() {
Text('¥')
.fontSize(11)
.fontColor('#FF3B30')
Text(product.price.toFixed(2))
.fontSize(16)
.fontColor('#FF3B30')
.fontWeight(FontWeight.Bold)
}
.width('100%')
.margin({ top: 4 })
// 原价与销量
Row() {
Text('¥' + product.originalPrice.toFixed(2))
.fontSize(11)
.fontColor('#8E8E93')
.decoration({ type: TextDecorationType.LineThrough })
Blank()
Text('销量 ' + product.salesCount)
.fontSize(10)
.fontColor('#8E8E93')
}
.width('100%')
.margin({ top: 2 })
}
.padding(8)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(8)
.shadow({
radius: 4,
color: '#00000008',
offsetX: 0,
offsetY: 2
})
}
@Builder
buildSkeletonGrid() {
Column() {
LoadingProgress()
.width(40)
.height(40)
.color('#007AFF')
Text('加载中...')
.fontSize(14)
.fontColor('#8E8E93')
.margin({ top: 12 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
@Builder
buildErrorState() {
Column() {
SymbolGlyph($r('sys.symbol.exclamationmark_circle'))
.fontSize(48)
.fontColor('#FF3B30')
Text(this.viewModel.state.error || '加载失败')
.fontSize(15)
.fontColor('#8E8E93')
.margin({ top: 12 })
Button('重试')
.type(ButtonType.Capsule)
.height(36)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#007AFF')
.margin({ top: 16 })
.onClick(() => this.viewModel.onRefresh())
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
@Builder
buildEmptyState() {
Column() {
SymbolGlyph($r('sys.symbol.bag'))
.fontSize(64)
.fontColor('#C7C7CC')
Text('未找到相关商品')
.fontSize(17)
.fontColor('#8E8E93')
.margin({ top: 16 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
至此,一个完整的电商商品列表 MVI 页面实现完毕。该实现覆盖了搜索防抖、分类筛选、排序切换、分页加载、骨架屏和错误处理等真实场景的核心逻辑,所有状态流转均通过 Intent 显式表达。
六、MVI 与 Redux/Vuex 的横向对比
理解 MVI 最好的方式之一,是将它与业界已广泛使用的状态管理方案进行对比。以下从多个维度展开分析。
6.1 数据流模型对比
Redux(React 生态) 的数据流可以概括为:UI 触发 Action → Action 通过 Middleware(如 thunk 或 saga)产生副作用 → Reducer 接收 Action 并计算新 State → 新 State 通过 React 的虚拟 DOM 差异对比驱动组件重渲染。Redux 的核心优势在于其中间件生态------日志、持久化、异步请求、路由同步等都可以通过 Middleware 优雅地插入数据流中。
Vuex(Vue 生态) 与 Redux 的思路非常接近,但有一个关键区别:Vuex 的状态是响应式的。当 State 变化时,Vue 会自动追踪依赖关系并精确重渲染受影响的组件,无需手动调用 forceUpdate。在 ArkUI 的 MVI 实现中,我们通过将 Store 的 state 标记为 @State,同样获得了类似的自动追踪能力。
MVI(ArkUI) 的核心差异在于 Intent 的显式化 。Redux 的 Action 通常是一个包含 type 字符串和 payload 对象的简单结构,而 MVI 中的 Intent 更接近一个领域对象 ------AddTodoIntent 和 ToggleTodoIntent 不仅仅是字符串标识符,它们本身就是带类型和字段的完整对象。这种设计在 TypeScript 环境中可以获得更好的类型安全性和 IDE 自动补全体验。
6.2 代码组织方式对比
以「添加一个待办事项」这一操作为例,看三种架构的代码组织差异。
Redux 需要:Action Type 常量 → Action Creator 函数 → Reducer Case → UI 组件 dispatch 调用,至少 4 个文件的协作。
MVI 同样需要:Intent 定义 → Store reduce Case → ViewModel dispatch 封装 → UI 组件调用。但 Intent 作为独立类/接口的存在,使得每个用户意图的边界更加清晰。在业务增长时,MVI 的 Intent 集合天然就成为了一份「功能清单」。
Vuex 的流程介于两者之间:Mutation(同步状态变更) + Action(异步包装) + Getter(派生状态)的三层结构,清晰但相对冗长。
6.3 在 ArkUI 中的适配考量
ArkUI 的响应式系统与 React 有显著不同。React 依赖虚拟 DOM 的差异比对(reconciliation),而 ArkUI 基于属性级别的变化追踪。当 @State 装饰的属性引用发生变化时,框架直接触发相关组件的重渲染,无需比对虚拟树。这意味着在 MVI 架构中,reduce 函数返回的新状态对象会被 ArkUI 自动感知并传播到订阅了该状态的组件。
一个需要特别关注的点在于:ArkUI 的 @State 仅支持简单值和类实例的引用变更,不支持数组和对象的深度响应式追踪 。因此在 TodoStore 的 reduce 中,我们使用 [...state.todos, newTodo](展开运算符创建新数组)而非 state.todos.push(newTodo),确保状态引用发生变化,从而触发响应式更新。
七、工程实践建议:如何在真实项目中落地 MVI
7.1 目录结构组织
一个基于 MVI 架构的 ArkUI 项目,建议采用以下目录结构:
entry/src/main/ets/
├── mvi/ # MVI 架构基础设施
│ ├── MviStore.ets # Store 基类
│ └── MviTypes.ets # 公共类型定义
├── features/
│ ├── todo/ # 待办功能模块
│ │ ├── model/
│ │ │ └── TodoItem.ets
│ │ ├── store/
│ │ │ └── TodoStore.ets
│ │ ├── viewmodel/
│ │ │ └── TodoViewModel.ets
│ │ └── view/
│ │ ├── TodoListPage.ets
│ │ └── components/
│ │ └── TodoItemView.ets
│ └── product/ # 商品功能模块
│ ├── model/
│ ├── store/
│ ├── viewmodel/
│ └── view/
├── services/ # 公共服务层
│ └── ProductService.ets
└── pages/
└── Index.ets
这种结构将「按功能组织代码」作为第一原则,每个功能模块内都包含 Model-Store-ViewModel-View 的完整闭环,模块之间通过接口通信,避免循环依赖。
7.2 适用场景判断
MVI 架构并非银弹。在以下场景中,MVI 能发挥最大价值:
- 业务逻辑复杂:页面存在多种用户操作路径,每条路径都涉及数据获取、状态转换和 UI 反馈的完整链路。
- 状态可追溯性要求高:如审批流程、表单多步骤填写、购物车结算等,需要精确追踪每一步操作的场景。
- 团队规模较大:清晰的 Intent 边界和 reduce 纯函数使得代码审查和单元测试的门槛大幅降低。
而在以下场景中,可以考虑更轻量的方案:
- 页面极其简单:仅展示静态内容或少数几个交互,直接用 @State + @Link 即可,MVI 的工程复杂度反而是负担。
- 快速原型验证:MVP 或最简单的状态提升模式更适合早期探索。
7.3 与 ArkUI 原生方案的协同
最后需要强调的是,MVI 架构并非要替代 ArkUI 的 @State、@Link、@Provide 等原生状态管理机制,而是对这些机制的一层组织封装。
具体而言,ViewModel 中的 get state() 访问器返回的是 Store 的 @State 响应式状态,View 层消费这个状态进行渲染。两者的关系是:ArkUI 负责「如何响应变化」,MVI 负责「谁来管理变化以及如何定义变化」。两者协同,才能在工程规模和代码可维护性之间找到最佳平衡点。
小结
本文围绕 MVI 架构在 HarmonyOS NEXT / API 12+ 中的深度实践,从基类封装、ViewModel 设计、Store reduce 实现到完整的 UI 页面,展示了单向数据流架构从理论到落地的完整闭环。
核心要点回顾:
- MviStore 基类通过泛型约束和不可变状态更新机制,为所有业务 Store 提供了统一的架构骨架。
- Intent 显式化使得每个用户操作都有清晰的数据表达,调试时可精确回溯任意状态变更的前因后果。
- ViewModel 层作为 View 与 Store 的中间层,承担了 Intent 编排、异步流程管理(防抖/分页)和生命周期协调的职责。
- 完整的 TodoList 和电商列表两个案例覆盖了从简单到复杂的不同业务场景,代码可直接在 ArkUI 工程中运行。
- 与 Redux/Vuex 的横向对比,明确了 MVI 在 ArkUI 生态中的定位和取舍。
掌握 MVI 架构,不仅能让复杂页面的状态逻辑变得可预测和可测试,更能为团队提供一套统一的事件驱动编程范式,使多人协作时代的代码一致性成为可能。