在这里插入图片描述

📖 引言

做过几个项目之后,你会发现一个规律:任何一个活得够久的项目,最终都会走向"重构"。

「民族图鉴」也不例外。从最开始的 MVP(最小可行产品)只有3个页面,到现在拥有民族百科、音乐馆、知识测验、AI助手、个人中心等十多个功能模块,代码量从几千行膨胀到几万行。问题也随之而来——

  • 首页 Index.ets 一个文件就有 800 多行,一个 build() 方法看不完
  • 同样的卡片样式在3个页面里各写了一遍,改个圆角要改3个地方
  • 收藏状态散落在5个页面里,有时候收藏了列表没刷新,有时候详情页和列表页对不上
  • 新人接手要花一周时间才能搞懂"这个状态到底在哪管的"

这不是代码写得不好,而是项目成长的必然结果。MVP 阶段追求速度,怎么快怎么来;但到了生产级,追求的就是质量和可维护性了。

这篇文章,我们就来聊聊「民族图鉴」的重构实战。不空谈理论,而是从真实问题出发,讲清楚:

  1. 为什么要重构?什么时候该重构?
  2. 重构的原则和步骤是什么?
  3. 「民族图鉴」具体是怎么一步步重构的?
  4. 重构过程中有哪些坑?怎么规避?

🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解重构的本质——不是重写,而是改善既有代码的设计
  • ✅ 掌握重构的四大原则:不改变行为、小步快跑、测试保护、随时可回退
  • ✅ 学会诊断项目中的"代码坏味道"
  • ✅ 掌握五步重构法:测试基线→服务抽离→组件拆分→状态统一→规范落地
  • ✅ 了解重构的风险控制与效果评估方法
  • ✅ 避开重构中的常见陷阱:越改越乱、改出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()
  • 变量 tempdatainforesult 满天飞,不知道存的啥

危害

  • 可读性差:得猜这个变量到底是什么意思
  • 容易误用:名字差不多,用错了也不容易发现
  • 增加沟通成本:团队成员讨论时还要先对齐命名

重构手法:重命名变量、重命名函数、统一命名规范


坏味道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 原则三:测试保护

重构的最大风险是:改完之后不知道有没有破坏原有功能。

测试就是重构的"安全网"。有了测试,你可以放心大胆地改,因为测试会告诉你有没有改坏。

「民族图鉴」的测试策略:

  1. 冒烟测试:核心流程手工测一遍(启动→浏览列表→看详情→收藏→播放音乐)
  2. 单元测试:工具函数、Service 层的单元测试
  3. 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 里抽出 SearchBarDailyTriviaCard 等组件。

效果:每个类职责单一,容易理解和维护。


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 的好处:

  1. 页面更薄:页面只负责UI,逻辑清晰
  2. 逻辑复用:多个页面共用同一个 Service
  3. 便于测试:Service 是纯逻辑,好写单元测试
  4. 易于替换:以后从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 模式,统一管理全局状态。

核心思路:

  1. 状态存在 Service 里,Service 是唯一数据源
  2. Service 通过 AppStorage 把状态暴露出去
  3. 页面通过 @StorageLink 订阅状态变化
  4. 页面要修改状态,只能调用 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 回滚方案

万一上线后发现大问题,要能快速回滚。

回滚手段:

  1. 应用市场回退:华为应用市场支持版本撤回
  2. 特性开关:用开关控制新旧逻辑,出问题关掉开关
  3. 热修复:小问题用热修复,不用发版

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:这几种情况不建议重构:

  • 项目马上要下线了,重构了也没用
  • 马上要发版了,风险太高
  • 团队人手不够,重构影响核心业务
  • 代码太烂了,重构的成本比重写还高(这种情况可以考虑重写,但要慎重)

📝 本章小结

这篇文章讲了「民族图鉴」项目的重构实战,核心要点:

  1. 为什么重构:技术债累积、代码腐化、维护成本越来越高
  2. 重构四原则:不改变行为、小步快跑、测试保护、随时可回退
  3. 五步重构法
    • 第一步:建立测试基线(安全网)
    • 第二步:服务层抽离(业务逻辑从页面搬到Service)
    • 第三步:组件化拆分(大组件拆小,公共组件抽离)
    • 第四步:状态统一管理(AppStorage + Service 模式)
    • 第五步:代码规范与Lint(防止边重构边腐化)
  4. 风险控制:灰度发布、回滚方案、监控告警
  5. 效果评估:用数据说话——代码量、复用率、Bug率、迭代速度

重构不是一次性的大动作,而是持续的习惯。就像整理房间——不是等乱成猪窝才收拾,而是每天顺手整理一下,保持整洁。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐