ArkUI MVI 架构实战:单向数据流设计模式在 HarmonyOS NEXT 中的深度实践

前言:从 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 层的存在有三重意义:

  1. 解耦 View 与 Store:View 不直接持有 Store 实例,通过 ViewModel 间接交互,降低视图层与业务逻辑的耦合度。
  2. 聚合多个 Intent 来源:一个页面可能有多个数据源(用户操作、网络响应、定时器),ViewModel 负责统一编排这些 Intent 的分发。
  3. 生命周期管理: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 更接近一个领域对象 ------AddTodoIntentToggleTodoIntent 不仅仅是字符串标识符,它们本身就是带类型和字段的完整对象。这种设计在 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 架构,不仅能让复杂页面的状态逻辑变得可预测和可测试,更能为团队提供一套统一的事件驱动编程范式,使多人协作时代的代码一致性成为可能。

相关推荐
L-影8 小时前
单体与微服务区别
微服务·云原生·架构
信仰8748 小时前
HCIA-华为数通基础理论与实践10
网络·华为·智能路由器
TechEdu2026068 小时前
HarmonyOS鸿蒙操作系统:架构、分布式能力与生态概览
操作系统·harmonyos·计算机科学
信仰8748 小时前
HCIA-华为数通基础理论与实践06
网络·华为
2301_768103498 小时前
HarmonyOS趣味相机实战第17篇:BackupExtensionAbility备份恢复与Preferences版本迁移
harmonyos·arkts·数据迁移·preferences·corefilekit
运维大师9 小时前
【K8S 运维实战】01-K8s架构深度剖析
运维·架构·kubernetes
千逐689 小时前
HarmonyOS 实战 | 数据存本地——Preferences 轻量存储入门
华为·harmonyos·鸿蒙
XUHUOJUN9 小时前
Azure Stack Hub 容量规划指南(上篇:Microsoft 软件层 + 架构原理)
架构·azure stack
seacracker9 小时前
HPC存储I/O抖动无解?PowerFS基于CRDT弱一致架构,彻底实现前台零抖动
架构·分布式存储·powerfs·hpc存储·i/o零抖动