ccai 2026-09-18 17:28:25 发布摘要
项目初期把网络请求、数据转换、加载状态和页面展示放在同一个文件中,开发速度较快,但功能增加后很难维护和测试。本文以巡检工单应用为例,介绍如何使用页面层、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 状态、业务流程和数据来源保持清晰边界,并且方便在下一次需求变化时继续演进。
相关推荐
203
0
52
0
少女写代码
208
0
174
0
ccai
我还没有写个人简介......
帖子
提问
粉丝
一笔加油费到月度报表:用 ArkTS 做一个离线优先的“车账本”
2026-09-18 17:38:18 发布大文件上传的断点续传:HarmonyOS 客户端与 Java 服务端协作
2026-09-18 17:36:40 发布

京公网安备:11010502051901号