HarmonyOS应用《民族图鉴》开发第91篇:项目重构实战——从MVP到生产级的演进之路

📖 引言
做过几个项目之后,你会发现一个规律:任何一个活得够久的项目,最终都会走向"重构"。
「民族图鉴」也不例外。从最开始的 MVP(最小可行产品)只有3个页面,到现在拥有民族百科、音乐馆、知识测验、AI助手、个人中心等十多个功能模块,代码量从几千行膨胀到几万行。问题也随之而来——
- 首页
Index.ets一个文件就有 800 多行,一个build()方法看不完 - 同样的卡片样式在3个页面里各写了一遍,改个圆角要改3个地方
- 收藏状态散落在5个页面里,有时候收藏了列表没刷新,有时候详情页和列表页对不上
- 新人接手要花一周时间才能搞懂"这个状态到底在哪管的"
这不是代码写得不好,而是项目成长的必然结果。MVP 阶段追求速度,怎么快怎么来;但到了生产级,追求的就是质量和可维护性了。
这篇文章,我们就来聊聊「民族图鉴」的重构实战。不空谈理论,而是从真实问题出发,讲清楚:
- 为什么要重构?什么时候该重构?
- 重构的原则和步骤是什么?
- 「民族图鉴」具体是怎么一步步重构的?
- 重构过程中有哪些坑?怎么规避?
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解重构的本质——不是重写,而是改善既有代码的设计
- ✅ 掌握重构的四大原则:不改变行为、小步快跑、测试保护、随时可回退
- ✅ 学会诊断项目中的"代码坏味道"
- ✅ 掌握五步重构法:测试基线→服务抽离→组件拆分→状态统一→规范落地
- ✅ 了解重构的风险控制与效果评估方法
- ✅ 避开重构中的常见陷阱:越改越乱、改出Bug、进度失控
📖 引言
3.1 什么是重构
先澄清一个误区:重构≠重写。
重构(Refactoring)是在不改变代码外在行为的前提下,对代码做出修改,以改进程序的内部结构。
—— Martin Fowler《重构》
关键词是:不改变外在行为。用户感知不到任何变化,但代码内部变得更清晰、更易维护、更易扩展。
| 重构 | 重写 | |
|---|---|---|
| 行为 | 不变 | 可能变 |
| 周期 | 持续、小步 | 一次性、大步 |
| 风险 | 可控 | 高风险 |
| 成本 | 渐进投入 | 一次性大投入 |
| 用户感知 | 无 | 可能有 |
3.2 技术债与代码腐化
为什么项目会越来越难维护?因为技术债在不断累积。
技术债就像金融债务——借的时候很爽,能快速交付;但如果不及时偿还,利息会越来越高,最终压垮项目。
「民族图鉴」发展过程中的技术债累积路径:
第1个月:MVP阶段,3个页面,怎么快怎么来
↓
第3个月:加了音乐馆,复制了列表页的代码改了改
↓
第5个月:加了收藏功能,每个页面自己管自己的收藏状态
↓
第7个月:加了AI助手,又引入了一套新的状态管理方式
↓
第9个月:新人入职,改一个按钮样式改了5个文件,还漏了2个
↓
现在:加一个新功能要改N个地方,改完不知道会不会炸
3.3 「民族图鉴」重构前的问题分析
我们用了一周时间,对项目做了一次全面的"体检",发现了以下问题:
问题一:页面臃肿,职责不清
最典型的就是 Index.ets(首页):
Index.ets 现状:
- 行数:800+ 行
- 状态变量:12 个
- @Builder 方法:8 个
- 业务逻辑:搜索过滤、每日一签、推荐算法、Tab切换...
- 职责:导航框架 + 首页内容 + 搜索 + 推荐 + 每日一签
一个组件做了五件事,违反了单一职责原则。后果就是:
- 想改搜索框,得在800行里找
- 想复用每日一签卡片,复制粘贴
- 出了bug,不知道是哪段逻辑引起的
问题二:状态管理散乱,不同步
收藏状态就是重灾区:
收藏状态分布:
├── EthnicListPage.ets → 自己管列表里的收藏状态
├── EthnicDetailPage.ets → 自己管详情页的收藏状态
├── CollectionPage.ets → 自己管收藏列表
├── ProfilePage.ets → 自己管收藏数量
└── MusicPage.ets → 音乐收藏又是一套
每个页面都从 StorageService 读一份数据存到自己的 @State 里,改的时候各改各的。结果就是:
- 在详情页收藏了,返回列表页不刷新
- 收藏列表删了一项,返回上一页数量没变
- 同一个民族,在A页面显示已收藏,B页面显示未收藏
这就是典型的状态源不唯一问题。
问题三:复用性差,复制粘贴泛滥
我们做了个统计:
| 重复逻辑 | 出现次数 | 所在文件 |
|---|---|---|
| 民族卡片样式 | 4次 | Index、EthnicList、Collection、SearchResult |
| isChinese() + getLocalizedText() | 6次 | 几乎每个页面都有 |
| 时间格式化 formatTime() | 3次 | MusicPage、HistoryPage、ProfilePage |
| 防抖函数 | 2次 | SearchBar、QuizPage |
| 收藏按钮UI | 3次 | 列表、详情、收藏页 |
复制粘贴的后果就是:改一个样式要找N个地方,漏一个就是不一致的bug。
问题四:可维护性差,新人上手难
我们做了个实验:让一个新入职的同事"在民族列表页加一个按人口排序的功能",结果他花了3天——
- 第一天:搞懂数据从哪来、状态在哪管
- 第二天:找到排序逻辑该加在哪,发现跟搜索过滤、地区筛选搅在一起
- 第三天:改完了,发现收藏状态同步又出问题了
而如果架构清晰的话,这个功能半天就能做完。
3.4 代码坏味道识别
怎么判断代码"烂不烂"?有哪些典型的"坏味道"?Martin Fowler 在《重构》这本书里总结了20多种代码坏味道,我们挑最常见的几种,结合「民族图鉴」的例子来讲。
坏味道1:重复代码(Duplicated Code)
表现:同样/类似的代码出现在多个地方。
「民族图鉴」的例子:
- 民族卡片样式在4个页面里各写了一遍
isChinese()+getLocalizedText()在6个页面里重复- 收藏按钮的UI和逻辑在3个地方重复
危害:
- 改一个逻辑要找N个地方,漏一个就是bug
- 代码膨胀,维护成本高
- 容易出现不一致
重构手法:提取函数、提取类、上移到父类
坏味道2:过长函数(Long Method)
表现:一个函数写了几十上百行,做了太多事。
「民族图鉴」的例子:
- 首页的
buildHomePage()有 200 多行 aboutToAppear()里塞了初始化、数据加载、埋点、配置检查……
危害:
- 难读懂:看半天不知道这个函数到底干了啥
- 难复用:这么长的函数,没法复用
- 难修改:改一点怕影响其他逻辑
判断标准:
- 函数超过50行就要警惕
- 超过80行基本可以确定有问题
- 一个函数如果需要滚动屏幕才能看完,那就太长了
重构手法:提取函数、用对象代替参数、以命令对象取代函数
坏味道3:过大类(Large Class)
表现:一个类/组件做了太多事,变量太多,方法太多。
「民族图鉴」的例子:
- 重构前的
Index.ets:800多行,12个状态变量,8个@Builder方法 - 做了:首页布局 + 搜索 + 推荐 + 每日一签 + Tab导航
危害:
- 职责不清:一个组件管五件事
- 难以理解:没人能搞清楚这个类的全部细节
- 修改风险大:改一个地方可能影响其他功能
重构手法:提取类、提取子类、提取接口
坏味道4:深层嵌套(Deeply Nested)
表现:if 套 if,for 套 for,一层套一层,像俄罗斯套娃。
// 反例:深层嵌套
if (userInfo) {
if (userInfo.permission) {
if (userInfo.permission.edit) {
if (isOwner) {
if (status === 'active') {
// 业务逻辑...
}
}
}
}
}
危害:
- 可读性差:看到第3层就忘了第1层的条件
- 容易出bug:漏考虑某种组合情况
- 测试用例多:各种条件组合爆炸
重构手法:
- 卫语句(Guard Clauses):提前 return,减少嵌套
- 提取条件逻辑为函数
- 用策略模式替代条件分支
坏味道5:命名混乱(Confusing Names)
表现:变量名、函数名、类名不能准确表达含义,或者命名风格不一致。
「民族图鉴」重构前的例子:
- 有的叫
list,有的叫dataList,有的叫items,其实都是民族列表 - 有的函数叫
loadData(),有的叫getData(),有的叫fetchList() - 变量
temp、data、info、result满天飞,不知道存的啥
危害:
- 可读性差:得猜这个变量到底是什么意思
- 容易误用:名字差不多,用错了也不容易发现
- 增加沟通成本:团队成员讨论时还要先对齐命名
重构手法:重命名变量、重命名函数、统一命名规范
坏味道6:发散式变化(Divergent Change)
表现:一个类因为不同的原因,在不同的方向上被修改。
比如「民族图鉴」的 EthnicService,如果:
- 加搜索功能要改它
- 加收藏功能要改它
- 加数据统计要改它
- 加同步功能还要改它
那这个类的职责就太多了。
危害:
- 单一职责原则被破坏
- 改一个功能可能影响另一个功能
- 合并代码容易冲突
重构手法:提取类,把不同职责分到不同的类里
坏味道7:霰弹式修改(Shotgun Surgery)
表现:加一个小功能,要改N个地方、N个文件。
「民族图鉴」重构前的例子:
- 加一个"按地区筛选"的功能,要改列表页、首页推荐、搜索页、收藏页……
- 改一下卡片样式,要改4个页面的文件
危害:
- 容易漏改,产生不一致bug
- 修改成本高
- 新人不知道该改哪些地方
重构手法:搬移函数、搬移字段、把分散的逻辑集中到一起
💡 代码坏味道不是"有就一定要改"。而是提醒你"这里可能有问题,可以考虑优化"。要不要重构,还是要看ROI(投入产出比)。
3.5 什么时候该重构
不是所有问题都要靠重构解决,也不是越早重构越好。判断是否该重构,可以看这几个信号:
🔴 红色信号(必须重构):
- 加一个简单功能要改3个以上模块
- Bug率持续上升,改一个出三个
- 团队成员普遍抱怨"代码太烂了"
- 新人上手时间超过2周
🟡 黄色信号(考虑重构):
- 代码重复率超过20%
- 单个文件超过500行
- 函数超过50行
- 一个组件有超过10个状态变量
🟢 绿色信号(暂时不用):
- 项目即将结束,不再维护
- 只是个人小项目,只有你一个人
- 需求稳定,长期不会变
💡 需求分析
重构不是"想到哪改到哪",必须遵循一些基本原则,否则容易越改越乱。
4.1 原则一:不改变外在行为
这是重构的底线。重构是为了让代码更好,而不是让功能不同。
怎么保证不改变行为?
- 有测试的话,跑测试,测试全过才算完成
- 没测试的话,先写测试再重构(测试基线)
- 重构过程中,不要同时加新功能
- 每一步重构后,手动验证核心流程
💡 一个常见的误区:重构的时候顺便"优化"一下这个逻辑、"改进"一下那个交互。结果改完发现行为变了,出了bug不知道是重构引起的还是"优化"引起的。
正确做法:重构和加功能分开做。先重构,再加功能。
4.2 原则二:小步快跑,每一步都可运行
不要想着"一次性把所有问题都解决",那是重写,不是重构。
正确的做法是:把大重构拆成很多小步骤,每一步都能独立运行、独立验证。
「民族图鉴」的重构,我们拆成了20多个小步骤,每个步骤平均半天:
大目标:首页重构
├── 步骤1:抽出 SearchBar 组件(半天)
├── 步骤2:抽出 DailyTriviaCard 组件(半天)
├── 步骤3:抽出 FeaturedSection 组件(半天)
├── 步骤4:抽出 QuickEntryGrid 组件(半天)
├── 步骤5:抽出 RecommendList 组件(半天)
└── 步骤6:首页只留布局逻辑(1小时)
每做完一步,跑一遍、测一遍,没问题再继续下一步。出了问题也容易回退——反正只改了一点点。
4.3 原则三:测试保护
重构的最大风险是:改完之后不知道有没有破坏原有功能。
测试就是重构的"安全网"。有了测试,你可以放心大胆地改,因为测试会告诉你有没有改坏。
「民族图鉴」的测试策略:
- 冒烟测试:核心流程手工测一遍(启动→浏览列表→看详情→收藏→播放音乐)
- 单元测试:工具函数、Service 层的单元测试
- UI 测试:关键页面的快照比对(可选)
4.4 原则四:随时可回退
万一改到一半发现不对,要能快速回到之前的状态。
怎么保证可回退?
- 版本控制:每完成一步就提交一次 Git,提交信息写清楚"refactor: 抽出SearchBar组件"
- 特性开关:大的重构可以用开关控制新旧逻辑,出问题关掉开关就行
- 灰度发布:重构完的版本先给部分用户用,没问题再全量
🛠️ 核心实现
知道了为什么重构,也知道了代码有哪些坏味道。接下来,我们看看具体有哪些重构手法。
Martin Fowler 的《重构》里总结了几十种手法,我们挑最常用的20种,结合鸿蒙/ArkTS的场景来讲。
5.1 提取类手法
1. 提取函数(Extract Method)
场景:一个函数太长,或者某段逻辑可以复用。
// 重构前
aboutToAppear() {
// 加载数据
this.loading = true;
this.ethnicList = ETHNIC_GROUPS.filter(item =>
item.name.includes(this.searchText)
);
this.loading = false;
// 埋点
TrackService.track('page_view', { page: 'ethnic_list' });
// 刷新收藏状态
this.collectionService.refresh();
}
// 重构后
aboutToAppear() {
this.loadEthnicList();
this.trackPageView();
this.refreshCollectionStatus();
}
private loadEthnicList(): void {
this.loading = true;
this.ethnicList = ETHNIC_GROUPS.filter(item =>
item.name.includes(this.searchText)
);
this.loading = false;
}
private trackPageView(): void {
TrackService.track('page_view', { page: 'ethnic_list' });
}
private refreshCollectionStatus(): void {
this.collectionService.refresh();
}
效果:函数更短,职责更单一,可读性更好,还能复用。
2. 提取类(Extract Class)
场景:一个类太大,做了太多事,拆成两个类。
「民族图鉴」的例子:从 Index.ets 里抽出 SearchBar、DailyTriviaCard 等组件。
效果:每个类职责单一,容易理解和维护。
3. 提取变量(Extract Variable)
场景:表达式太复杂,看不懂。
// 重构前
if (userInfo && userInfo.permission && userInfo.permission.edit && isOwner && status === 'active') {
// 业务逻辑
}
// 重构后
const hasEditPermission = userInfo?.permission?.edit ?? false;
const isResourceOwner = isOwner;
const isActiveStatus = status === 'active';
const canEdit = hasEditPermission && isResourceOwner && isActiveStatus;
if (canEdit) {
// 业务逻辑
}
效果:代码自文档化,一看变量名就懂。
4. 内联函数(Inline Method)
场景:函数体和函数名一样清楚,间接层没有必要。
// 重构前
function isEmpty(list: any[]): boolean {
return list.length === 0;
}
if (isEmpty(resultList)) { ... }
// 重构后
if (resultList.length === 0) { ... }
效果:减少不必要的间接层,代码更直接。
5.2 搬移类手法
5. 搬移函数(Move Method)
场景:一个函数用另一个类的数据比用自己类的数据还多。
「民族图鉴」的例子:把收藏逻辑从各个页面搬到 CollectionService。
效果:函数放在最适合它的地方,减少耦合。
6. 搬移字段(Move Field)
场景:一个字段被另一个类用得更多。
例子:把 favoriteIds 从页面组件搬到 CollectionService。
5.3 组织数据手法
7. 以对象取代数据值(Replace Data Value with Object)
场景:一个简单的数据项,后来有了自己的行为和数据。
// 重构前
@State favoriteIds: string[] = [];
// 操作到处都是
const isFavorite = this.favoriteIds.includes(id);
this.favoriteIds.push(id);
this.favoriteIds = this.favoriteIds.filter(x => x !== id);
// 重构后
// CollectionService 类管理收藏状态,有自己的方法
const isFavorite = this.collectionService.isFavorite(id);
this.collectionService.toggleFavorite(id);
8. 以查询取代临时变量(Replace Temp with Query)
场景:临时变量存着表达式的结果,可以改成查询方法。
// 重构前
build() {
const filteredList = this.ethnicList.filter(item =>
item.region === this.selectedRegion
);
const sortedList = filteredList.sort((a, b) => a.population - b.population);
List() {
ForEach(sortedList, item => {
EthnicCard({ ethnic: item })
})
}
}
// 重构后
getFilteredAndSortedList(): EthnicGroup[] {
return this.ethnicList
.filter(item => item.region === this.selectedRegion)
.sort((a, b) => a.population - b.population);
}
build() {
List() {
ForEach(this.getFilteredAndSortedList(), item => {
EthnicCard({ ethnic: item })
})
}
}
5.4 简化条件表达式手法
9. 分解条件表达式(Decompose Conditional)
场景:复杂的 if-else,看不懂。
// 重构前
if (date.before(SUMMER_START) || date.after(SUMMER_END)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}
// 重构后
if (isWinter(date)) {
charge = winterCharge(quantity);
} else {
charge = summerCharge(quantity);
}
10. 合并条件表达式(Consolidate Conditional Expression)
场景:一串条件判断,结果一样。
// 重构前
if (age < 18) return 0;
if (senior) return 0;
if (disabled) return 0;
// 重构后
if (isEligibleForFree()) return 0;
11. 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
场景:深层嵌套的 if-else。
// 重构前
function getPayAmount(employee: Employee): number {
let result: number;
if (employee.isDead) {
result = deadAmount();
} else {
if (employee.isSeparated) {
result = separatedAmount();
} else {
if (employee.isRetired) {
result = retiredAmount();
} else {
result = normalPayAmount();
}
}
}
return result;
}
// 重构后
function getPayAmount(employee: Employee): number {
if (employee.isDead) return deadAmount();
if (employee.isSeparated) return separatedAmount();
if (employee.isRetired) return retiredAmount();
return normalPayAmount();
}
效果:减少嵌套,逻辑清晰,一眼就能看懂。
12. 以多态取代条件表达式(Replace Conditional with Polymorphism)
场景:根据类型不同做不同的事,有一大串 if-else 或 switch。
这是面向对象的经典手法,ArkTS 里也可以用继承/接口来实现。
5.5 简化函数调用手法
13. 函数改名(Rename Method)
场景:函数名不能准确表达它的用途。
// 重构前
function handleClick() { ... }
// 重构后
function handleFavoriteClick() { ... }
看似简单,但非常重要。好的命名是可读性的基础。
14. 添加参数(Add Parameter)
场景:函数需要更多信息才能工作。
15. 移除参数(Remove Parameter)
场景:参数不再被使用。
16. 将查询函数和修改函数分离(Separate Query from Modifier)
场景:一个函数既返回值又修改状态。
// 重构前 - 既查又改
function getNextId(): number {
this.currentId = this.currentId + 1;
return this.currentId;
}
// 重构后 - 查改分离
function getNextId(): number {
return this.currentId + 1;
}
function incrementId(): void {
this.currentId = this.currentId + 1;
}
5.6 处理概括关系手法
17. 上移字段(Pull Up Field)
场景:两个子类有相同的字段。
18. 上移方法(Pull Up Method)
场景:两个子类有相同的方法,做相同的事。
19. 下移方法(Push Down Method)
场景:父类的方法只和部分子类有关。
20. 塑造模板函数(Form Template Method)
场景:两个子类的算法步骤相同,但具体实现不同。
可以把相同的步骤抽到父类,不同的步骤用抽象方法/钩子方法让子类实现。
💡 重构手法就像工具箱。你不需要记住所有,但要知道有哪些工具、什么时候用。遇到具体问题时,知道该用哪个手法就行。
🛠️ 核心实现
我们总结了一套五步重构法,在「民族图鉴」项目中亲测有效。
5.1 第一步:建立测试基线
没有测试的重构,就是裸奔。
在动手改代码之前,先建立测试基线。不求全覆盖,但求核心流程有保障。
「民族图鉴」的测试基线建设:
1. 梳理核心用户流程
核心流程 Top 5:
1. 启动 App → 浏览民族列表 → 查看详情 → 返回
2. 搜索民族 → 查看结果 → 进入详情
3. 收藏民族 → 查看收藏列表 → 取消收藏
4. 进入音乐馆 → 播放音乐 → 切换歌曲
5. 知识测验 → 答题 → 查看结果
2. 编写冒烟测试用例
手工测试用例,每条包含:操作步骤、预期结果。
用例1:浏览民族详情
步骤:
1. 启动App,进入首页
2. 点击底部"百科"Tab
3. 点击第一个民族卡片
4. 查看详情页内容
预期:
- 列表页显示56个民族
- 详情页正确显示该民族信息
- 返回按钮可正常返回
3. 编写单元测试
对 Service 层和工具函数补单元测试,这部分最容易写,也最有价值。
// StorageService.test.ets 示例
import { describe, it, expect } from '@ohos/hypium';
import { StorageService } from '../services/StorageService';
describe('StorageService', () => {
it('should save and get string value', () => {
const storage = StorageService.getInstance();
storage.set('test_key', 'hello');
expect(storage.get('test_key')).assertEqual('hello');
});
it('should return default value when key not exists', () => {
const storage = StorageService.getInstance();
const result = storage.get('non_existent_key', 'default');
expect(result).assertEqual('default');
});
});
4. 跑一遍基线,记录结果
把所有测试跑一遍,确保在重构之前是全通过的。这就是你的"基线"——重构完之后,这些测试还得全过。
5.2 第二步:服务层抽离
页面应该只做两件事:渲染UI、处理用户交互。业务逻辑应该放到 Service 层。
重构前的「民族图鉴」,很多业务逻辑直接写在页面里:
// ❌ 重构前:业务逻辑写在页面里
@Component
struct EthnicListPage {
@State ethnicList: EthnicGroup[] = [];
@State searchText: string = '';
aboutToAppear() {
// 直接在这里读Mock数据、做过滤
this.ethnicList = ETHNIC_GROUPS.filter(item =>
item.name.includes(this.searchText)
);
}
}
重构后,业务逻辑都搬到 Service 层:
// ✅ 重构后:页面只调用Service
@Component
struct EthnicListPage {
private ethnicService: EthnicService = EthnicService.getInstance();
@State ethnicList: EthnicGroup[] = [];
aboutToAppear() {
this.ethnicList = this.ethnicService.searchEthnics(this.searchText);
}
}
「民族图鉴」服务层抽离清单:
| 服务 | 职责 | 来源 |
|---|---|---|
| EthnicService | 民族数据查询、搜索、筛选、收藏 | 散落在各个页面 |
| MusicService | 音乐播放、播放列表、播放模式 | MusicPage + 悬浮播放条 |
| QuizService | 测验题目、答题记录、错题本 | QuizPage |
| CollectionService | 收藏管理(统一收藏状态) | 4个页面各管各的 |
| HistoryService | 浏览历史 | HistoryPage + 各详情页 |
抽离 Service 的好处:
- 页面更薄:页面只负责UI,逻辑清晰
- 逻辑复用:多个页面共用同一个 Service
- 便于测试:Service 是纯逻辑,好写单元测试
- 易于替换:以后从Mock换成真实API,只改Service就行
5.3 第三步:组件化拆分
大组件拆小组件,公共组件抽离。
服务层抽离完了,接下来处理UI层的问题。
拆分原则:
- 单一职责:一个组件只做一件事
- 可复用:多处用到的组件抽出来
- 可维护:组件太大就拆小
「民族图鉴」组件拆分实战:
1. 首页拆分
重构前:Index.ets 800多行,一个 buildHomePage() 里面塞了所有内容。
重构后:
components/
├── SearchBar.ets // 搜索栏
├── DailyTriviaCard.ets // 每日一签卡片
├── FeaturedSection.ets // 精选民族区块
├── QuickEntryGrid.ets // 快捷入口网格
├── RecommendList.ets // 推荐列表
└── TabBar.ets // 底部导航栏
首页变成了这样:
// ✅ 重构后的首页,清爽多了
@Component
struct Index {
@State currentIndex: number = 0;
build() {
Tabs({ barPosition: BarPosition.End, index: this.currentIndex }) {
TabContent() { this.buildHomePage() }
.tabBar(...)
TabContent() { EthnicListPage() }
.tabBar(...)
// ... 其他Tab
}
}
@Builder
buildHomePage() {
Scroll() {
Column({ space: 16 }) {
SearchBar({ onSearch: (query) => this.handleSearch(query) })
DailyTriviaCard()
FeaturedSection()
QuickEntryGrid()
RecommendList()
}
}
}
}
每个子组件都有自己独立的状态和逻辑,互不干扰。
2. 民族卡片抽离
民族卡片在4个地方用到,之前是各写各的。抽成通用组件:
// EthnicCard.ets
interface EthnicCardProps {
ethnic: EthnicGroup;
showFavorite?: boolean;
onFavoriteChange?: (isFavorite: boolean) => void;
onClick?: () => void;
}
@Component
export struct EthnicCard {
@Prop ethnic: EthnicGroup;
@Prop showFavorite: boolean = true;
@Link isFavorite: boolean = false;
onFavoriteChange?: (isFavorite: boolean) => void;
onClick?: () => void;
build() {
Column() {
Image(this.ethnic.coverImage)
.width('100%')
.aspectRatio(1)
.borderRadius(8)
Text(this.ethnic.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
if (this.showFavorite) {
Image(this.isFavorite ? '❤️' : '🤍')
.onClick(() => {
this.isFavorite = !this.isFavorite;
this.onFavoriteChange?.(this.isFavorite);
})
}
}
.onClick(() => this.onClick?.())
}
}
抽离之后,4个页面都用同一个 EthnicCard,样式统一,改一次就全生效。
3. 工具函数抽离
把重复的工具函数抽到 utils/ 目录:
utils/
├── DateUtils.ets // 日期格式化
├── FormatUtils.ets // 数字、时间格式化
├── DebounceUtils.ets // 防抖节流
├── LangUtils.ets // 语言判断、文本本地化
└── TypeUtils.ets // 类型判断
比如每个页面都有的 isChinese() + getLocalizedText(),抽到 LangUtils 里:
// LangUtils.ets
import { AppLanguage } from '../models/EnumModels';
export class LangUtils {
static isChinese(language: AppLanguage): boolean {
return language === AppLanguage.ZH_CN;
}
static getLocalizedText(
language: AppLanguage,
zhText: string,
enText: string
): string {
return this.isChinese(language) ? zhText : enText;
}
}
页面里用的时候:
// 之前:每个页面都写一遍
private isChinese(): boolean {
return this.currentLanguage === AppLanguage.ZH_CN;
}
// 之后:直接调用工具类
const text = LangUtils.getLocalizedText(this.currentLanguage, '你好', 'Hello');
5.4 第四步:状态统一管理
单一数据源(Single Source of Truth)。
这是最关键、也最难的一步。
问题回顾: 收藏状态散落在5个页面,每个页面自己管自己的,经常不同步。
解决方案: 用 AppStorage + Service 模式,统一管理全局状态。
核心思路:
- 状态存在 Service 里,Service 是唯一数据源
- Service 通过
AppStorage把状态暴露出去 - 页面通过
@StorageLink订阅状态变化 - 页面要修改状态,只能调用 Service 的方法,不能直接改
「民族图鉴」收藏状态重构:
// CollectionService.ets - 唯一数据源
import { EthnicGroup } from '../models/EthnicModels';
import { StorageService } from './StorageService';
import { STORAGE_KEY_FAVORITES } from '../common/constants/StorageConstants';
export class CollectionService {
private static instance: CollectionService;
private storage: StorageService = StorageService.getInstance();
private favoriteIds: Set<string> = new Set();
static getInstance(): CollectionService {
if (!CollectionService.instance) {
CollectionService.instance = new CollectionService();
}
return CollectionService.instance;
}
constructor() {
this.loadFavorites();
}
private loadFavorites(): void {
const saved = this.storage.get<string[]>(STORAGE_KEY_FAVORITES, []);
this.favoriteIds = new Set(saved);
// 同步到 AppStorage
AppStorage.SetOrCreate('favoriteIds', Array.from(this.favoriteIds));
AppStorage.SetOrCreate('favoriteCount', this.favoriteIds.size);
}
isFavorite(ethnicId: string): boolean {
return this.favoriteIds.has(ethnicId);
}
toggleFavorite(ethnicId: string): boolean {
if (this.favoriteIds.has(ethnicId)) {
this.favoriteIds.delete(ethnicId);
} else {
this.favoriteIds.add(ethnicId);
}
this.saveFavorites();
return this.favoriteIds.has(ethnicId);
}
private saveFavorites(): void {
const ids = Array.from(this.favoriteIds);
this.storage.set(STORAGE_KEY_FAVORITES, ids);
// 同步更新 AppStorage
AppStorage.SetOrCreate('favoriteIds', ids);
AppStorage.SetOrCreate('favoriteCount', ids.length);
}
getFavoriteIds(): string[] {
return Array.from(this.favoriteIds);
}
getFavoriteCount(): number {
return this.favoriteIds.size;
}
}
页面里用的时候:
// EthnicListPage.ets - 订阅状态,通过Service修改
@Component
struct EthnicListPage {
@StorageLink('favoriteIds') favoriteIds: string[] = [];
private collectionService: CollectionService = CollectionService.getInstance();
isFavorite(ethnicId: string): boolean {
return this.favoriteIds.includes(ethnicId);
}
handleToggleFavorite(ethnicId: string): void {
// 不直接改 this.favoriteIds,而是调用 Service
// Service 会更新 AppStorage,然后通过 @StorageLink 自动同步到这里
this.collectionService.toggleFavorite(ethnicId);
}
}
这样做的好处:
- ✅ 状态唯一:所有页面的数据都来自同一个地方
- ✅ 自动同步:一个地方改了,所有订阅的地方自动更新
- ✅ 可追踪:所有状态变更都走 Service 方法,好调试
- ✅ 易测试:Service 是纯逻辑,单元测试好写
「民族图鉴」全局状态清单:
| 状态 | 存储位置 | 说明 |
|---|---|---|
| 主题模式 | AppStorage + ThemeService | 深色/浅色模式 |
| 语言设置 | AppStorage + I18nService | 中文/英文 |
| 收藏列表 | AppStorage + CollectionService | 收藏的民族ID列表 |
| 播放状态 | AppStorage + MusicService | 当前播放、播放列表、播放模式 |
| 浏览历史 | AppStorage + HistoryService | 浏览历史记录 |
| 内容模式 | AppStorage + ConfigService | 精简/全量模式 |
5.5 第五步:代码规范与 Lint
没有规范的代码,重构完很快又会乱掉。
最后一步,把规范落地,防止"边重构边腐化"。
「民族图鉴」的规范建设:
1. 代码风格规范
文件命名:大驼峰 + 类型后缀
- 页面:EthnicListPage.ets
- 组件:EthnicCard.ets
- 服务:StorageService.ets
- 工具:DateUtils.ets
变量命名:
- 普通变量:小驼峰 userName
- 常量:全大写下划线 MAX_PAGE_SIZE
- 布尔值:is/has/can 前缀 isVisible
函数命名:动词开头
- 获取数据:getUserInfo
- 处理事件:handleClick
- 格式化:formatDate
文件结构(按顺序):
1. import 导入
2. interface 类型定义
3. @Component 组件
4. 导出
2. Lint 工具配置
项目里的 code-linter.json5:
{
"ruleSet": "HarmonyOS",
"enable": true,
"rules": {
"naming-convention": "error",
"no-unused-vars": "warn",
"no-console": "warn",
"max-lines": ["warn", { "max": 500 }],
"max-lines-per-function": ["warn", { "max": 80 }]
}
}
3. Code Review 流程
- 所有代码都要走 PR/MR
- 至少一个人 Review 才能合并
- Review 重点:架构设计、代码规范、可维护性
- 不纠结空格换行(工具自动格式化)
🛠️ 核心实现
重构有风险,动手前要想好怎么控风险。
6.1 灰度发布
大的重构不要一下子全量上线,要灰度。
「民族图鉴」的灰度策略:
阶段1:内部测试(1周)
→ 只给团队内部用,收集bug
阶段2:小流量灰度(1周)
→ 给 5% 的用户用新版
→ 观察崩溃率、错误日志
阶段3:逐步放量(2周)
→ 5% → 20% → 50% → 100%
→ 每个阶段至少观察24小时
→ 出问题立刻暂停,回退到上一比例
6.2 回滚方案
万一上线后发现大问题,要能快速回滚。
回滚手段:
- 应用市场回退:华为应用市场支持版本撤回
- 特性开关:用开关控制新旧逻辑,出问题关掉开关
- 热修复:小问题用热修复,不用发版
6.3 监控告警
重构完了不是就完事了,要盯着数据看。
重点监控指标:
- 崩溃率:有没有升高
- ANR 率:有没有卡顿
- 启动时间:有没有变慢
- 页面加载时间:有没有变长
- 错误日志:有没有新的报错
🛠️ 核心实现
重构做完了,怎么证明做得好不好?用数据说话。
「民族图鉴」重构前后对比:
| 指标 | 重构前 | 重构后 | 变化 |
|---|---|---|---|
| 首页代码行数 | 823 行 | 186 行 | ↓ 77% |
| 重复代码率 | ~25% | ~8% | ↓ 68% |
| 平均文件行数 | 320 行 | 150 行 | ↓ 53% |
| 收藏状态同步 Bug | 5 个/月 | 0 个/月 | -100% |
| 新人上手时间 | 7 天 | 2 天 | ↓ 71% |
| 新功能开发周期 | 5 天/功能 | 2 天/功能 | ↓ 60% |
| 线上 Bug 率 | 12 个/月 | 4 个/月 | ↓ 67% |
这些数据就是重构的价值证明——短期看花了时间,长期看省了更多时间。
🛠️ 核心实现
下面是「民族图鉴」项目重构的完整时间线,供你参考:
第1周:准备阶段
├── 代码审计,问题盘点
├── 制定重构计划
├── 编写冒烟测试用例
└── Service 层单元测试补充
第2周:服务层抽离
├── 抽离 EthnicService
├── 抽离 CollectionService
├── 抽离 HistoryService
└── 抽离 QuizService
第3周:组件化拆分(上)
├── 首页组件拆分(SearchBar、DailyTriviaCard...)
├── 民族卡片抽离(EthnicCard)
├── 工具函数抽离(LangUtils、FormatUtils...)
└── 收藏状态统一管理
第4周:组件化拆分(下)
├── 音乐页组件拆分
├── 测验页组件拆分
├── 个人中心组件拆分
└── 公共组件整理
第5周:规范与优化
├── Lint 规则落地
├── 代码风格统一
├── 目录结构调整
└── 文档补充
第6周:验证与上线
├── 全量回归测试
├── 性能测试对比
├── 灰度发布
└── 全量上线
总共6周时间,2个开发人员,没有影响正常的需求迭代(重构和需求并行,各占50%时间)。
🛠️ 核心实现
讲完了方法论和步骤,我们再聊聊更宏观的话题:重构应该以什么样的节奏来做?
9.1 两种节奏:小步快跑 vs 大刀阔斧
重构有两种典型的节奏,各有适用场景:
| 维度 | 小步快跑(渐进式) | 大刀阔斧(运动式) |
|---|---|---|
| 做法 | 日常开发中顺手做,每天一点 | 专门花一段时间集中做 |
| 周期 | 持续进行 | 1-2周一次小重构,几个月一次大重构 |
| 风险 | 低,每步都可回退 | 较高,改动大出问题影响大 |
| 对业务影响 | 几乎不影响 | 可能影响,重构期间新功能放慢 |
| 并行开发 | 可以并行 | 需要专门人手 |
| 适用场景 | 日常维护、代码腐化初期 | 代码烂到加不动了、架构大升级 |
| 风格 | 持续改进 | 运动式治理 |
9.2 「民族图鉴」的选择:80% 小步 + 20% 集中
我们的实践下来,最佳实践是:80% 小步快跑 + 20% 集中重构。
日常小步快跑(80%):
- 加新功能时,顺手把相关的烂代码重构一下
- 改 bug 时,把周围看不顺眼的代码收拾一下
- 代码评审时发现问题,马上提个小重构
- 每次改动小,风险低,积少成多
**定期集中重构(20%):
- 每个迭代留 20% 的时间做重构
- 每季度来一次稍大的重构
- 解决那些日常不好穿插在版本的系统性问题
为什么不是 80/20?
- 全是小步快跑:太慢了,大的架构问题解决不了
- 全是大刀阔斧:风险太高,影响业务
- 80/20 搭配:日常保持代码不会快速响应业务,又能持续改进代码质量
9.3 重构的时机选择
什么时候做重构比较好?
✅ 好的时机:
- 新功能开发前:先把地基打好再加新功能
- 改 bug 时:顺手把周围代码收拾干净
- 代码评审后:发现问题及时改
- 迭代间隙/版本间隙:时间相对充裕的时候
- 新人入职前:代码干净点,新人上手快
❌ 不好的时机:
- 马上要发版了:风险太高
- 业务高峰期:出问题影响大
- 团队人手不足:没人手做
- 代码马上就要下线的代码:重了也白搭
💡 最佳的重构时机:事不过三原则。
- 第一次做:先做出来再说,怎么快怎么来
- 第二次做:复制粘贴改一改,先应付过去
- 第三次做:该抽出来好好做了,是时候重构了
当你要第三次做同一件事的时候,就是重构的最佳时机。
🛠️ 核心实现
重构不是慈善,要算投入产出。花了时间,值不值?
10.1 重构的成本
**显性成本:
- 开发时间:工程师的时间,这是最直接的成本
- 机会成本:这段时间本来可以做新功能
- 学习成本:新的新的架构,团队需要学习成本
隐性成本:
- 引入新的风险:改出 bug 的风险
- 团队适应期的阵痛:重构期间团队效率可能暂时下降
- 用户感知:用户可能遇到一些小问题
10.2 重构的收益
**短期收益(1-3个月):
- 开发速度加快:加新功能更快了
- Bug 减少:代码清晰,出错率下降
- 团队士气提升:不用对着烂代码心情也好了
中期收益(3-12个月):
- 维护成本降低:改 bug 花的时间少了
- 新人上手快:新人入职更快融入
- 技术债利息少了:不用再越拖成本越来越高
长期收益(1年以上):
- 架构灵活:应对需求变化能力强
- 人才吸引:好的代码质量好的人才
- 产品竞争力:产品迭代更快
10.3 「民族图鉴」的 ROI 计算
我们来算一笔账:
重构投入:
- 2个开发 × 6周 = 12人周
- 按一个开发月薪 2万/人月 = 约1.4万/人月
- 总成本 = 12 × 1.4万/4 ≈ 4.2万
重构前:
- 新功能开发周期从 5天/功能 → 2天/功能
- 假设每月做 10 个功能
- 每月节省:(5-2) × 10 = 30天 = 3.75人周/月
- 每月节省成本:3.75 × 1.4 ≈ 5.25万
回本时间:4.2万 ÷ 5.25万/月 ≈ 0.8个月 ≈ 24天
也就是说,**不到一个月就能回本!之后每个月都是净赚。
还不算:
- Bug 率下降减少的维护成本
- 新人上手快节省的时间
- 团队士气提升的隐性收益
💡 **重构不是花钱,而是投资。而且是回报率很高的投资。
10.4 怎么说服老板/产品经理的艺术
很多人想重构的一个问题:想重构,老板/产品经理就是不相信重构的价值,不给时间做?
**错误说法 ❌:
- “代码太烂了,要重构”
- “现在不加新功能做不动了”
- “技术债太多了”
**正确说法 ✅:
- “现在这个架构,加这个功能要5天,重构后只要2天,后面功能都更快”
- “最近bug率上升了30%,都是因为这块代码太乱了,重构完能降下来”
- “新人要花一周才能上手,重构后2天就能上手,节省的时间”
- “这个季度的3个大功能,基于现在架构风险很高,建议先重构再做”
核心技巧:
- 用业务语言,别说技术术语
- 用数据说话,别光说感受
- 把重构和业务目标挂钩
- 给选项,不给难题
⚠️ 常见问题与解决方案
Q1:重构越改越乱怎么办?
A:大概率是步子迈太大了。
- 停下来,回到上一个稳定的提交点
- 把大步骤拆成更小的步骤,每一步都验证
- 不要同时改多个东西,一次只做一件事
- 每完成一步就提交 Git,留好回退点
Q2:重构改出 Bug 怎么办?
A:正常,重构不可能零 Bug。关键是怎么减少和快速发现。
- 改之前先写测试,用测试兜底线
- 小步快跑,每步都手动验证核心流程
- 灰度发布,先给少量用户用
- 做好监控,出了问题早发现早回滚
Q3:进度失控,重构做不完怎么办?
A:重构是无底洞,永远有可以改进的地方。要学会适可而止。
- 设定明确的重构目标和完成标准
- 列出优先级,先做收益最高的
- 时间不够就砍掉低优先级的
- 不要追求"完美","够好"就可以上线,剩下的以后再说
Q4:团队抵触重构怎么办?
A:没人喜欢改老代码,抵触是正常的。
- 用数据说话:让大家看到重构前的问题有多严重
- 展示价值:做完一步让大家感受到好处(比如"这个组件抽出来了,以后改样式只改一个地方")
- 全员参与:不要一个人闷头改,让大家一起参与,有参与感才会支持
- 循序渐进:不要一下子改太多,慢慢渗透,大家习惯了就好
Q5:什么时候不该重构?
A:这几种情况不建议重构:
- 项目马上要下线了,重构了也没用
- 马上要发版了,风险太高
- 团队人手不够,重构影响核心业务
- 代码太烂了,重构的成本比重写还高(这种情况可以考虑重写,但要慎重)
📝 本章小结
这篇文章讲了「民族图鉴」项目的重构实战,核心要点:
- 为什么重构:技术债累积、代码腐化、维护成本越来越高
- 重构四原则:不改变行为、小步快跑、测试保护、随时可回退
- 五步重构法:
- 第一步:建立测试基线(安全网)
- 第二步:服务层抽离(业务逻辑从页面搬到Service)
- 第三步:组件化拆分(大组件拆小,公共组件抽离)
- 第四步:状态统一管理(AppStorage + Service 模式)
- 第五步:代码规范与Lint(防止边重构边腐化)
- 风险控制:灰度发布、回滚方案、监控告警
- 效果评估:用数据说话——代码量、复用率、Bug率、迭代速度
重构不是一次性的大动作,而是持续的习惯。就像整理房间——不是等乱成猪窝才收拾,而是每天顺手整理一下,保持整洁。
更多推荐


所有评论(0)