在前两篇文章中,我们像产品经理一样规划了“会议随记 Pro”的商业蓝图,又像架构师一样搭建了分层的工程目录。现在,我们要正式进入代码的世界。但在我们写下第一个 UI 组件之前,我必须和你聊聊一个可能会决定项目生死的关键话题:代码架构。

你一定有过这样的经历,接手一个老项目,打开一个名为 MeetingDetail.ets 的文件,发现里面洋洋洒洒写了两千行代码。UI 布局、网络请求、数据库操作、甚至还有复杂的业务算法,全部纠缠在一起。你想修改一个按钮的颜色,结果不小心删掉了一行逻辑代码,导致整个页面无法加载数据。

这种代码,我们形象地称之为面条代码。它们纠缠不清,不仅难以维护,更是 Bug 的温床。

今天这篇文章,我们要彻底解决这个问题。我们将引入 MVVM(Model-View-ViewModel) 架构模式,并结合 HarmonyOS NEXT 特有的状态管理机制,为我们的应用打造一套坚固的代码脚手架。我们要明确一个核心原则:让 UI 只负责渲染,让逻辑只负责思考

一、 为什么要折腾 MVVM?从面条代码的痛点说起

在传统的开发模式或者早期的 FA 模型中,我们往往习惯于在 UI 组件的生命周期里直接写业务逻辑。比如在 aboutToAppear 方法里,你可能会直接调用 http.createHttp().request(...),然后在回调里拿到数据,直接赋值给 @State 变量。这看起来很直观,写起来也快。

但请 思考一下我们的“会议随记 Pro”的录音页面。这个页面非常复杂,它不仅要显示波形图(UI),还要实时计算时长(逻辑),要监听麦克风的状态(逻辑),要计算当前的会议成本(逻辑),还得在录音结束时把数据写入数据库(数据层)。

如果你把这一切都写在 RecordPage.ets 这个 UI 文件里,这个文件很快就会膨胀到不可阅读的程度。

当我们采用 MVVM 模式时,情况就完全不同了。我们将代码拆分为三个部分。Model(模型层) 是我们在上一篇中定义好的 MeetingProject 等数据结构,它们代表了数据的真实形态。View(视图层) 是我们用 ArkUI 写的界面,它只负责长得好看,比如按钮是圆的还是方的,文字是红的还是黑的。而连接这两者的桥梁,就是 ViewModel(视图模型层)

ViewModel 是 UI 的管家。它持有 UI 所需的数据状态,并暴露操作这些数据的方法。比如,View 层有一个“开始录音”的按钮,View 不会自己去调用麦克风,它只会告诉 ViewModel:“嘿,用户想录音了”。ViewModel 收到指令,去调用底层的录音服务,更新“正在录音”的状态。View 监听到状态变化,自动把按钮图标从“播放”变成“暂停”。

这种单向数据流状态驱动的开发模式,是 HarmonyOS 开发的精髓。不仅让代码逻辑清晰,更重要的是方便测试。你甚至可以在不启动 UI 的情况下,单独对 ViewModel 进行单元测试,这在商业级项目的开发中是至关重要的。

二、 鸿蒙状态管理的三剑客 State、Prop 与 Link

在构建 MVVM 之前,我们必须先精通手中的武器。HarmonyOS NEXT 提供了一套强大的状态管理机制,其中最常用的就是 @State@Prop@Link。很多开发者只是机械地使用它们,却不理解它们在架构中的真正含义。

让我们结合“会议随记 Pro”的场景来深度剖析一下。

@State,是组件内部的灵魂。当你把一个变量标记为 @State,你就告诉了编译器:这个变量是我的私有财产,但我允许 UI 监听它的变化。一旦它变了,用到它的 UI 必须自动刷新。在本应用中,BaseViewModel 里定义的 isLoading 状态,或者录音页面里当前的 duration(时长),都属于这种类型。它们不需要外界干涉,完全由组件或 ViewModel 内部控制。

@Prop,是一种单向的父子传递。想象一下,我们在首页有一个“会议列表”,列表里有一个个“会议卡片”组件。父组件(列表)把会议标题传给子组件(卡片)。这时候,子组件里的标题变量就应该用 @Prop 修饰。这意味着:父组件改了标题,子组件会跟着变;但子组件如果自己改了标题,是不会影响父组件的,而且下一次父组件更新时,子组件的修改会被覆盖。这就像是上级给下级下达命令,下级只能执行,不能反向修改命令本身。在我们的项目中,通用的展示型组件,比如“项目颜色标签”,就大量使用 @Prop

@Link,是双向同步的强力胶水。比如我们在“设置页”里封装了一个“时薪设置组件”。父页面持有“当前时薪”这个数据,子组件(设置组件)不仅要显示它,还要允许用户修改它。当用户在子组件里输入了新的金额,父页面里的数据必须同步更新,以便进行存储。这时候,我们就必须使用 @Link。它让父子组件共享了对同一份数据的读写权限。但请注意,@Link 不能滥用,因为它破坏了单向数据流的封闭性,如果传递层级过深,会让数据流向变得难以追踪。我们只在那些确实需要双向控制的场景下使用它。

除了这三剑客,还有一个针对复杂对象的 @Observed@ObjectLink。我们的 Meeting 对象是一个嵌套了多层属性的类。如果我们在数组里修改了某个 Meeting 的 title 属性,普通的 @State 数组可能监听不到这个深层的变化。这时候,我们需要把 Meeting 类标记为 @Observed,并在子组件中用 @ObjectLink 接收它。这是很多新手容易踩坑的地方,也是导致“明明改了数据,界面却不刷新”的罪魁祸首。

三、 封装 BaseViewModel

理解了理论,我们开始动手。在写具体的业务 ViewModel 之前,我们需要一个基类来处理通用的逻辑。

在一个商业 App 中,几乎每个页面都面临三个共性问题:加载中(Loading)的状态管理、错误信息(Error)的提示、以及异步任务(Async Task)的异常捕获。如果我们把 isLoading = truetry-catch 复制粘贴到每一个方法里,那简直是重复劳动的地狱。

我们需要封装一个 BaseViewModel

请在 entry/src/main/ets/commons/base 目录下新建 BaseViewModel.ts。我们不需要它继承任何系统类,它就是一个纯粹的 TypeScript 类,但我们要利用好泛型和异步封装。

你看,我们这样做。首先定义两个基础状态:loadingerror

// entry/src/main/ets/commons/base/BaseViewModel.ts

/**
 * 所有 ViewModel 的基类
 * 负责统一处理 Loading 状态、错误捕获和资源释放
 */
export class BaseViewModel {
  /**
   * 页面是否正在加载数据
   * UI 层可以监听这个变量来显示/隐藏 Loading 组件
   */
  isLoading: boolean = false;

  /**
   * 错误信息提示
   * 当发生异常时,这里会被赋值,UI 层可以监听并弹出 Toast
   */
  errorMessage: string = '';

  /**
   * 统一的异步任务执行器
   * 自动管理 isLoading 的状态切换,并自动捕获异常
   * * @param task 需要执行的异步函数
   * @param onError 可选的错误回调,如果需要特殊处理错误可传入
   */
  async launch<T>(task: () => Promise<T>, onError?: (e: Error) => void): Promise<T | null> {
    try {
      // 任务开始前,自动显示 Loading
      this.isLoading = true;
      this.errorMessage = '';
      
      // 执行真正的业务逻辑
      const result = await task();
      return result;
    } catch (error) {
      // 统一的错误日志打印
      console.error(`[ViewModel] Task Failed: ${JSON.stringify(error)}`);
      
      // 更新错误状态
      this.errorMessage = error.message || '未知错误,请重试';
      
      // 如果调用者提供了自定义错误处理,则执行
      if (onError) {
        onError(error as Error);
      }
      return null;
    } finally {
      // 无论成功还是失败,任务结束时自动关闭 Loading
      this.isLoading = false;
    }
  }
}

但是,如果没有这个封装,我们在“获取会议列表”时,代码可能是这样的:

// 不好的写法
async getMeetings() {
  this.isLoading = true;
  try {
    const list = await db.query();
    this.list = list;
  } catch (e) {
    this.errorMessage = e.message;
  } finally {
    this.isLoading = false;
  }
}

有了 BaseViewModel,我们的代码就变成了这样:

// 优雅的写法
async getMeetings() {
  await this.launch(async () => {
    this.list = await db.query();
  });
}

代码量减少了一半,而且逻辑更加聚焦于业务本身,而不是状态的维护。这就是架构设计的魅力。

四、 构建录音页面的 MVVM 脚手架

有了基类,我们现在来实战构建 录音页面 RecordPage 的 MVVM 结构。这将是我们 App 中最复杂的页面之一,用它来练手最合适不过。

我们需要创建两个文件:RecordViewModel.tsRecordPage.ets

首先是 RecordViewModel。它继承自 BaseViewModel。它需要持有录音时长、当前分贝值(用于波形图)、以及录音状态。注意,这里我们不使用 @State 装饰器,因为 ViewModel 本身是一个普通的类。我们将依靠 View 层来实例化它,并利用 View 层的状态机制来驱动更新。为了保持兼容性和易理解性,我们采用 View 持有 State,ViewModel 操作数据 的模式。

// entry/src/main/ets/features/record/viewmodel/RecordViewModel.ts

import { BaseViewModel } from '../../../commons/base/BaseViewModel';

export class RecordViewModel extends BaseViewModel {
  // 录音时长(秒)
  duration: number = 0;
  
  // 当前录音状态:idle, recording, paused
  recordStatus: 'idle' | 'recording' | 'paused' = 'idle';
  
  // 模拟一个定时器引用
  private timer: number = -1;

  /**
   * 开始录音
   * 这里的逻辑纯粹是状态的流转和底层服务的调用
   */
  async startRecording() {
    await this.launch(async () => {
      // 1. 调用底层 AudioService 开始录音 (后续章节实现)
      // await AudioService.start();
      
      // 2. 更新状态
      this.recordStatus = 'recording';
      
      // 3. 开启计时器
      this.startTimer();
    });
  }

  /**
   * 停止录音
   */
  async stopRecording() {
    await this.launch(async () => {
      // await AudioService.stop();
      this.recordStatus = 'idle';
      this.stopTimer();
      // 这里未来会加入写入数据库的逻辑
    });
  }

  private startTimer() {
    this.stopTimer();
    this.timer = setInterval(() => {
      this.duration++;
      // 在这里,我们其实修改了类内部的属性
      // UI 层需要一种机制来感知这个 duration 的变化
    }, 1000);
  }

  private stopTimer() {
    if (this.timer !== -1) {
      clearInterval(this.timer);
      this.timer = -1;
    }
  }
}

RecordPage,是 View 层。这里的关键在于如何让 UI 感知到 ViewModel 中 duration 的变化。

在 HarmonyOS 开发中,如果不使用 V2 版本的 @Observable,普通的类属性变化是不会自动触发 UI 刷新的。所以,我们通常会在 View 层定义 @State 变量,并通过 ViewModel 的方法来更新这些 @State 变量,或者让 ViewModel 返回一个可观察的对象。

但在更现代的开发实践中,为了让代码更整洁,我们推荐一种状态代理的模式。即 ViewModel 负责计算,View 负责存储状态。或者,我们让 ViewModel 本身成为一个被 @State 管理的对象(但这需要 ViewModel 是 Struct 或者是被深度监听的 Class)。

为了降低理解门槛,我们这里采用最稳妥的ViewModel 驱动 View 状态的方式。但为了让大家体验更高级的写法,我这里演示一种基于Callback或者UIState对象的模式。

不过,最直白的方式其实是在 View 中把 ViewModel 标记为 @State。这要求 ViewModel 必须是一个可以被观察的对象。在 ArkTS 中,我们可以简单地把 ViewModel 的属性通过 View 的方法暴露出来,或者在 View 中定义对应的 @State 变量,在 ViewModel 的回调中更新它们。

让我们看一个结合了实际可行性的代码架构。我们在 View 中定义状态,ViewModel 只是纯逻辑类。

// entry/src/main/ets/features/record/pages/RecordPage.ets

import { RecordViewModel } from '../viewmodel/RecordViewModel';

@Entry
@Component
struct RecordPage {
  // 1. 实例化 ViewModel
  // 注意:这里没有用 @State 修饰 vm,因为 vm 本身的方法不直接触发 UI 刷新
  // 我们用单独的 @State 变量来驱动 UI
  private vm: RecordViewModel = new RecordViewModel();

  // 2. 定义 UI 状态
  @State duration: number = 0;
  @State status: string = 'idle';
  @State isLoading: boolean = false;

  aboutToAppear() {
    // 可以在这里做一些初始化
  }

  build() {
    Column() {
      // 顶部导航栏
      Text('正在录音')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 20 })

      // 核心内容区:显示时长
      // 这里使用了 formatTime 工具函数(假设在 commons 里)
      Text(this.formatDuration(this.duration))
        .fontSize(60)
        .fontColor($r('app.color.brand_color'))
        .fontWeight(FontWeight.Bold)

      // 状态显示
      Text(`当前状态: ${this.status}`)
        .fontSize(16)
        .fontColor($r('app.color.text_secondary'))
        .margin({ top: 10 })

      // 底部操作区
      Row() {
        if (this.status === 'recording') {
          Button('停止')
            .onClick(() => {
              this.handleStop();
            })
            .backgroundColor(Color.Red)
        } else {
          Button('开始')
            .onClick(() => {
              this.handleStart();
            })
            .backgroundColor($r('app.color.brand_color'))
        }
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 50 })

      // Loading 遮罩
      if (this.isLoading) {
        LoadingProgress()
          .width(50)
          .height(50)
      }
    }
    .height('100%')
    .width('100%')
    .backgroundColor($r('app.color.bg_page'))
  }

  // --- 胶水代码区:连接 View 和 ViewModel ---

  async handleStart() {
    // 更新 Loading 状态
    this.isLoading = true;
    
    // 调用 VM 的逻辑
    await this.vm.launch(async () => {
      // 模拟异步启动录音
      await new Promise<void>(resolve => setTimeout(resolve, 500));
      
      this.status = 'recording';
      
      // 启动一个界面定时器来同步显示
      // 注意:这里为了 UI 刷新,我们在 View 层维护了一个定时器来读取 VM 的数据
      // 或者直接在 View 层计时。为了演示 MVVM,我们假设 VM 内部有复杂逻辑
      // 真实场景中,VM 可以通过 callback 通知 View 更新
      this.startUITimer();
    });
    
    this.isLoading = false;
  }

  async handleStop() {
    this.isLoading = true;
    await this.vm.stopRecording();
    this.status = 'idle';
    this.stopUITimer();
    this.isLoading = false;
  }

  // UI 层的定时器,只负责从 VM 同步数据或者自我更新
  private uiTimer: number = -1;
  
  startUITimer() {
    this.uiTimer = setInterval(() => {
      // 假设 VM 中有真实的音频时长(可能来自底层 SDK 回调)
      // 这里我们简单自增
      this.duration++; 
    }, 1000);
  }

  stopUITimer() {
    clearInterval(this.uiTimer);
  }

  formatDuration(seconds: number): string {
    const min = Math.floor(seconds / 60);
    const sec = seconds % 60;
    return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
  }
}

在这个架构中,View 层非常傻。它不知道录音文件存在哪里,也不知道数据库怎么写。它只知道:用户点了按钮 -> 我叫 ViewModel 去干活 -> ViewModel 干完了 -> 我更新 statusduration -> 界面重绘。

当然,随着鸿蒙生态的发展,V2 版本的状态管理(@ObservedV2 和 @Trace) 正在变得越来越流行,它允许 ViewModel 中的属性变化直接驱动 UI 更新,不再需要像上面代码那样在 View 层写胶水代码和手动同步状态。

但在现阶段,为了保证你的代码能稳定运行在绝大多数设备上,掌握这种基础的逻辑与视图分离的写法,是理解更高级框架的必经之路。

五、 总结

今天我们没有写太多花哨的界面,但我们完成了一次代码灵魂的洗礼。

我们拒绝了将所有逻辑堆砌在 UI 组件里的面条代码,而是坚定地选择了 MVVM 架构。我们深度辨析了 @State@Prop@Link 的使用场景,确保数据流向清晰可控。

更重要的是,我们手写了一个功能强大的 BaseViewModel,它将成为我们未来几十个页面开发的基石,帮我们统一处理 Loading 和 Error,让业务代码变得清爽无比。

现在,你的工程里已经有了合理的目录结构,有了通用的 ViewModel 封装,有了清晰的数据模型。你已经做好了迎接复杂业务挑战的准备。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐