单文件不是原罪
“心晴手记”HarmonyOS 第一版的主页面承担:
- 隐私门禁;
- 三步引导;
- 今日、历史、统计、习惯、设置五个 Tab;
- 习惯编辑和删除确认;
- 历史日期编辑;
- 应用锁遮罩;
- 页面状态和统计派生函数。
对于需要快速完成平台迁移和功能对齐的阶段,这种结构有现实优势:
- 状态都在一个组件中,联调路径短;
- 不需要过早设计跨组件状态同步;
- 容易与 iOS 功能表逐项对照;
- 小团队可以迅速跑通完整体验。
问题不在于“曾经写在一个文件”,而在于功能稳定后是否继续无限增长。
一、出现哪些信号时应该开始拆分
1. 一个修改需要在文件中跳转很远
改习惯模型,却要来回寻找编辑弹窗、今日行、统计和删除确认。
2. Builder 数量持续增长
页面、卡片、弹窗和小组件混在同一命名空间。
3. 状态变量无法按功能归类
selectedMood、selectedMonthMillis、habitNameDraft、locked 全部并列。
4. 测试只能通过完整页面进行
日期、统计和 CSV 逻辑无法独立验证。
5. 多人修改冲突频繁
不同功能都编辑同一个大文件。
满足两三项时,就可以规划渐进拆分。
二、不要从“把文件切成五份”开始
最危险的重构是机械移动:
TodayPage.ets
HistoryPage.ets
InsightsPage.ets
HabitsPage.ets
SettingsPage.ets
然后才发现每个页面都需要:
- habits;
- completions;
- settings;
- persist();
- named();
- uiRevision;
- 多个回调。
结果可能只是把一个大文件变成大量参数传递和双向状态混乱。
正确顺序应先分离“纯逻辑”和“系统边界”,最后才移动有状态 UI。
三、第一步:先抽离纯函数
适合最先拆出的逻辑:
dayIdentifier();- CSV 转义;
- 心情和标签定义;
- 图标 Key 归一化;
- 习惯统计分母计算;
- 格式版本迁移函数。
纯函数特点:
输入明确
→ 输出明确
→ 不直接访问 ArkUI 状态
→ 不调用系统能力
例如:
export function completionRate(
completed: number,
eligible: number
): number {
if (eligible === 0) {
return 0;
}
return Math.min(
100,
Math.round(completed / eligible * 100)
);
}
抽离后可以直接写单元测试,不必渲染整个统计页。
四、第二步:系统能力保持服务化
当前项目已经把这些能力分开:
AppRepository
AppLockService
ExternalService
页面只调用:
await authenticate(title)
await exportTextFile(context, fileName, content)
await appRepository.save(snapshot)
服务层不应该反过来修改 ArkUI 页面状态。它返回结果,页面决定显示 Toast、锁定层或错误信息。
这层边界在继续拆页面时非常重要,因为多个 Feature 都可以复用服务,而不需要彼此引用。
五、第三步:按领域组织,不按控件类型组织
一个可演进目录示意:
entry/src/main/ets/
├─ model/
│ ├─ MoodModels.ets
│ ├─ HabitModels.ets
│ └─ SettingsModels.ets
├─ data/
│ ├─ AppRepository.ets
│ └─ StateMigration.ets
├─ services/
│ ├─ AppLockService.ets
│ └─ ExternalService.ets
├─ features/
│ ├─ today/
│ ├─ history/
│ ├─ insights/
│ ├─ habits/
│ └─ settings/
└─ components/
├─ AppIcon.ets
└─ SectionHeading.ets
features/insights 中应同时包含统计页 UI 和它专属的派生逻辑,而不是建立一个装着所有函数的 utils/ 垃圾桶。
六、第四步:定义唯一状态所有者
拆分前必须回答:
谁拥有 moodEntries?
谁拥有 habits?
谁负责 persist?
子页面如何发起修改?
对于当前规模,可以保留根页面为单一状态所有者:
App Root
├─ 状态数组
├─ Repository
└─ 把只读数据 + 业务回调传给 Feature
例如习惯页接收:
@Prop habits: Habit[];
onSaveHabit: (draft: HabitDraft) => void;
onArchiveHabit: (id: string) => void;
onDeleteHabit: (id: string) => void;
子页面不直接拿 Repository,也不自己维护另一份 habits 副本。
如果应用继续变大,再引入更系统的状态管理。不要因为拆文件就立即增加复杂框架。
七、区分“编辑草稿”和“持久化实体”
当前页面有:
habitNameDraft
habitIconDraft
habitColorDraft
拆分后这些草稿应该属于习惯编辑 Feature,而不是全局 AppState。
持久化状态:
habits[]
编辑状态:
当前弹窗是否打开
正在编辑哪个 ID
输入框临时值
把两者分离能减少根状态数量,也避免用户取消弹窗时污染真实数据。
八、统计逻辑应该接收快照,不直接读 UI
可以建立:
interface InsightInput {
days: number;
nowMillis: number;
moodEntries: MoodEntry[];
habits: Habit[];
completions: HabitCompletion[];
}
然后:
function buildInsights(
input: InsightInput
): InsightResult {
// 计算记录天数、分布、完成率和总结数据
}
好处:
- 测试可以固定
nowMillis; - 不依赖系统当前日期;
- 7 天和 30 天只是不同输入;
- UI 只负责展示 InsightResult;
- 将来更换页面结构不影响算法。
九、组件拆分后重点防范状态失联
原来同一组件内直接赋值:
this.habits = nextHabits;
this.refreshUi();
拆成子组件后,容易出现:
- 子组件修改自己的副本,根状态没变;
- 回调改变根状态,但子组件 Key 没更新;
@Prop、@Link或状态管理方式选择不当;- Builder 捕获旧闭包;
- 列表复用导致局部仍显示旧值。
因此每拆一个 Feature,都要回归:
新增 → 编辑 → 归档 → 恢复 → 删除
不要一次移动全部页面后再统一排查。
十、推荐的渐进式拆分顺序
阶段 1:无风险抽离
- 模型常量;
- 纯函数;
- 图标映射;
- 格式化工具。
阶段 2:视觉组件
- AppIcon;
- IconBadge;
- SectionHeading;
- 通用空状态。
阶段 3:无复杂编辑的 Feature
- 设置展示;
- 隐私支持区;
- 统计展示卡片。
阶段 4:带草稿和弹窗的 Feature
- 习惯编辑;
- 历史日期编辑;
- 今日记录。
阶段 5:重构根状态
只有前面稳定后,再评估状态容器、页面路由和模块化。
十一、每一步都需要可回滚验证
重构前记录:
- QA 清单;
- 当前模拟器截图;
- 关键统计样本;
- 导出 JSON 示例;
- 多语言资源 Key;
- 构建命令。
每次只拆一个边界,并运行:
静态自检
→ ArkTS 编译
→ HAP 构建
→ 相关功能回归
重构成功的标准不是文件变短,而是行为保持一致、职责更清楚、测试更容易。
十二、什么时候不应该继续拆
如果抽出一个组件后:
- 参数超过十几个;
- 所有状态仍从根透传;
- 组件只使用一次且没有独立语义;
- 调试必须跨五个文件;
- 测试并没有变容易;
说明拆分边界可能选错了。
架构目标不是文件数量最多,而是变化能够局部发生。
总结
从单文件 MVP 演进到分层架构,推荐遵循:
- 承认单文件在早期的效率价值;
- 先抽纯函数,再抽系统服务;
- 按领域组织 Feature;
- 明确唯一状态所有者;
- 草稿状态与持久化实体分离;
- 统计逻辑接收确定快照;
- 每次只拆一个边界并完整回归;
- 不为了“看起来架构化”制造参数地狱。
好的重构不是推倒重来,而是让已经验证过的产品行为在新结构中继续成立。
本文案例来自“心晴手记(MoodMemoir)”HarmonyOS 版当前架构及后续模块化规划。

1085

被折叠的 条评论
为什么被折叠?



