积分中心
从页面堆逻辑到可测试分层:Stage 模型下的巡检工单架构实践
头像 ccai 2026-09-18 17:28:25    发布
2349 浏览 4 点赞 0 收藏

摘要

项目初期把网络请求、数据转换、加载状态和页面展示放在同一个文件中,开发速度较快,但功能增加后很难维护和测试。本文以巡检工单应用为例,介绍如何使用页面层、ViewModel、Repository 和数据源分层,并区分 AbilityStage、UIAbility 和 WindowStage 的职责。文章重点讨论状态建模、重复请求、缓存切换和错误处理,不把某一种架构宣传成唯一答案。

一、先识别需要分层的信号

以下现象出现两三个时,就值得考虑整理架构:

  • 页面文件持续增长;
  • 相同接口被多个页面调用;
  • 网络请求和 UI 代码互相穿插;
  • 空数据、错误和刷新状态难以区分;
  • 需要加入缓存、分页或离线能力;
  • 业务逻辑无法脱离页面进行测试。

分层的目的不是增加文件数量,而是让修改影响范围更加可预测。

二、区分 Stage、Ability 和页面职责

在 Stage 模型中:

  • AbilityStage 是模块级生命周期入口;
  • UIAbility 管理能力实例及其前后台生命周期;
  • WindowStage 通常在 UIAbility.onWindowStageCreate() 中参与窗口初始化;
  • 页面组件负责布局和交互展示。

不能把 Stage 模型简单描述成“页面生命周期”,也不能假设应用退出时一定会按顺序执行所有销毁回调,因为进程可能被系统直接回收。

一种清晰的依赖关系如下:

EntryAbility
   └── 页面 View
         └── ViewModel
               └── Repository
                     ├── RemoteDataSource
                     └── LocalDataSource

三、用统一状态代替多个布尔变量

页面状态可以先定义为有限集合:

enum PageStatus {
  Idle = 'idle',
  Loading = 'loading',
  Success = 'success',
  Empty = 'empty',
  Error = 'error'
}

interface TicketPageState {
  status: PageStatus
  tickets: TicketViewData[]
  errorMessage: string
  refreshing: boolean
}

比起同时维护:

isLoading
isRefreshing
hasError
hasData

统一状态更容易推导 UI。特别是“已有旧数据但刷新失败”和“首次加载失败”应当区别对待。

四、Repository 隔离数据来源

先定义业务需要的接口:

interface TicketRepository {
  getTickets(forceRefresh?: boolean): Promise<Ticket[]>
}

网络实现只负责网络数据转换:

class RemoteTicketRepository implements TicketRepository {
  private client: TicketApiClient

  constructor(client: TicketApiClient) {
    this.client = client
  }

  async getTickets(forceRefresh: boolean = false): Promise<Ticket[]> {
    const response = await this.client.queryTickets()
    return response.data
  }
}

加入缓存后,页面不需要知道数据来自网络还是本地:

class CachedTicketRepository implements TicketRepository {
  private remote: TicketRepository
  private local: TicketLocalStore

  constructor(remote: TicketRepository, local: TicketLocalStore) {
    this.remote = remote
    this.local = local
  }

  async getTickets(forceRefresh: boolean = false): Promise<Ticket[]> {
    if (!forceRefresh) {
      const cached = await this.local.read()
      if (cached.length > 0) {
        return cached
      }
    }

    const latest = await this.remote.getTickets(true)
    await this.local.save(latest)
    return latest
  }
}

Repository 是业务抽象,具体网络库、数据库 API 和缓存格式都应该被隔离在更底层。

五、ViewModel 管理流程,不直接操作组件

class TicketViewModel {
  private repository: TicketRepository

  state: TicketPageState = {
    status: PageStatus.Idle,
    tickets: [],
    errorMessage: '',
    refreshing: false
  }

  constructor(repository: TicketRepository) {
    this.repository = repository
  }

  async load(forceRefresh: boolean = false): Promise<void> {
    if (forceRefresh) {
      this.state.refreshing = true
    } else {
      this.state.status = PageStatus.Loading
    }

    try {
      const records = await this.repository.getTickets(forceRefresh)
      const viewData = records.map(toTicketViewData)

      this.state.tickets = viewData
      this.state.status = viewData.length === 0
        ? PageStatus.Empty
        : PageStatus.Success
      this.state.errorMessage = ''
    } catch (_) {
      if (this.state.tickets.length > 0) {
        this.state.errorMessage = '刷新失败,当前显示的是上次数据'
      } else {
        this.state.status = PageStatus.Error
        this.state.errorMessage = '工单加载失败,请稍后重试'
      }
    } finally {
      this.state.refreshing = false
    }
  }
}

页面层只负责把 ViewModel 状态映射到 ArkUI:

@Entry
@Component
struct TicketPage {
  @State private status: PageStatus = PageStatus.Idle
  @State private tickets: TicketViewData[] = []
  @State private errorMessage: string = ''

  private viewModel: TicketViewModel =
    new TicketViewModel(createTicketRepository())

  aboutToAppear(): void {
    this.loadData(false)
  }

  private async loadData(forceRefresh: boolean): Promise<void> {
    await this.viewModel.load(forceRefresh)
    this.status = this.viewModel.state.status
    this.tickets = this.viewModel.state.tickets
    this.errorMessage = this.viewModel.state.errorMessage
  }

  build() {
    Column() {
      if (this.status === PageStatus.Loading) {
        LoadingProgress()
      } else if (this.status === PageStatus.Empty) {
        Text('暂无待处理工单')
      } else if (this.status === PageStatus.Error) {
        Column() {
          Text(this.errorMessage)
          Button('重新加载')
            .onClick(() => this.loadData(false))
        }
      } else {
        TicketList({ items: this.tickets })
      }
    }
    .width('100%')
    .height('100%')
  }
}

这段代码采用显式状态同步,便于说明分层思路。实际项目也可以根据目标 SDK 选择合适的可观察对象方案。

六、处理生命周期和重复请求

页面重新出现、窗口变化或路由切换时,生命周期方法可能再次执行。不能假设每次出现都必须重新请求。

可以先增加一次性加载标记:

private hasLoaded: boolean = false

aboutToAppear(): void {
  if (!this.hasLoaded) {
    this.hasLoaded = true
    this.loadData(false)
  }
}

需要刷新时由用户主动触发,或由更上层的状态协调器决定。跨页面共享的数据不要全部堆在组件中,可由仓储或业务对象统一管理。

同时应注意:

  • 网络失败不能清空已有内容;
  • 空数据不是错误;
  • 超时、鉴权失败和服务端错误应区分;
  • 系统可能直接回收进程,不能把关键数据只放在内存;
  • module.json5、Ability 配置和窗口初始化要与目标 Stage 工程模板保持一致。

七、如何做独立测试

Repository 接口可以替换成假的数据源:

class FakeTicketRepository implements TicketRepository {
  public shouldFail: boolean = false

  async getTickets(forceRefresh: boolean = false): Promise<Ticket[]> {
    if (this.shouldFail) {
      throw new Error('fake network error')
    }

    return [
      { id: '1', title: '检查设备状态', status: 'pending' }
    ]
  }
}

这样可以独立测试:

  • 首次加载;
  • 空数据;
  • 网络失败;
  • 已有旧数据时刷新失败;
  • 重复调用 load;
  • 数据转换错误。

测试不依赖真实网络,结果也更稳定。

八、总结

MVVM、Repository 和 ViewModel 不是 HarmonyOS 强制要求的固定模板。单页面小工具完全可以保持简单。真正需要分层时,通常是因为页面已经同时承担了布局、请求、转换、缓存和错误处理。

好的架构不一定是最复杂的架构,而是让 UI 状态、业务流程和数据来源保持清晰边界,并且方便在下一次需求变化时继续演进。


©本站发布的所有内容,包括但不限于文字、图片、音频、视频、图表、标志、标识、广告、商标、商号、域名、软件、程序等,除特别标明外,均来源于网络或用户投稿,版权归原作者或原出处所有。我们致力于保护原作者版权,若涉及版权问题,请及时联系我们进行处理。
分类
HarmonyOS
头像

ccai

我还没有写个人简介......

30

帖子

0

提问

0

粉丝

关注
地址:北京市朝阳区北三环东路三元桥曙光西里甲1号第三置业A座1508室 电话:13391790444或(010)62178877
版权所有:电脑商情信息服务集团 北京赢邦策略咨询有限责任公司
声明:本媒体部分图片、文章来源于网络,版权归原作者所有,我司致力于保护作者版权,如有侵权,请与我司联系删除

京ICP备:2022009079号-2

京公网安备:11010502051901号

ICP证:京B2-20230255