HarmonyOS应用《民族图鉴》开发第45篇:调试与排错——常见问题排查方法论

一、为什么要谈调试
写代码的时间,可能只占 30%;剩下的 70%,都在调试和改 bug。
会不会调试、调试效率高不高,直接决定了一个程序员的产出。
同样一个 bug:
- 新手可能花一天,各种瞎试,最后"偶然"解决了
- 老手可能只需要半小时,一步步定位、分析、验证、解决
差别在哪里?在于方法论——有没有一套系统的排查问题的思路。
这一篇,我们来聊聊调试的方法论,以及 HarmonyOS 开发中常见的坑和排查思路。
二、调试的科学方法
调试不是瞎猜、不是瞎试、也不是靠运气。它是一个科学的过程——提出假设、设计实验、验证假设、得出结论。
2.1 经典调试流程
1. 复现问题
↓
2. 收集信息
↓
3. 提出假设
↓
4. 验证假设
↓
5. 找到原因 → 修复 → 验证
↓
6. 没找到 → 回到第3步,换个假设
这和科学研究的方法是一样的——观察现象、提出假设、设计实验、验证结论。
2.2 第一步:稳定复现
不能稳定复现的 bug,等于不存在的 bug。
复现问题是调试的第一步。如果复现不了,你连"改好了没"都验证不了。
复现的时候要搞清楚:
| 问题 | 说明 |
|---|---|
| 必现还是偶现? | 每次都有,还是有时候有? |
| 什么设备? | 手机型号、系统版本 |
| 什么网络? | WiFi / 4G / 无网? |
| 什么操作路径? | 从哪进、点了什么、怎么操作的? |
| 出现频率? | 十次里出几次? |
💡 经验之谈:如果用户说"有时候会出现",不要信——一定有规律,只是你还没找到。把所有变量列出来,一个个控制变量地试,总能找到规律。
2.3 第二步:缩小范围
问题范围太大,无从下手?用"二分法"快速缩小范围。
二分法的思路:
- 把可能的范围分成两半
- 测试哪一半有问题
- 有问题的那一半再分两半
- 重复,直到定位到具体原因
举个例子:页面白屏了,不知道为什么。
1. 是数据的问题还是 UI 的问题?
→ 写死假数据看看 → 还白屏 → 不是数据的问题
2. 是整个页面的问题还是某个组件的问题?
→ 注释掉一半组件 → 不白屏了 → 问题在注释掉的那一半
3. 那一半里再分两半
→ 继续注释 → ...
→ 最后定位到某个组件的某个属性写反了
二分法是最高效的调试方法之一——不管问题多复杂,几步就能缩到很小的范围。
2.4 实战案例:民族详情页白屏二分法排查
光说不练假把式。我们用一个「民族图鉴」项目中真实发生过的"民族详情页白屏"案例,完整演示二分法的排查过程。
案例背景
现象:点击民族列表页的某一项,进入民族详情页,页面一片空白,什么内容都没有。
环境:
- 设备:Mate 60 Pro
- 系统:HarmonyOS 4.2
- 必现:每次进入都白屏
已知信息:
- 列表页正常,数据能加载出来
- 只有详情页白屏
- 控制台没有明显报错
第一步:确定排查范围
首先,我们需要明确"完整的范围"是什么——也就是从进入页面到页面渲染完成的整个流程。
详情页的大致结构:
EthnicDetailPage
├── 头部区域(Header)
│ ├── 背景图
│ ├── 返回按钮
│ ├── 收藏按钮
│ ├── 播放按钮
│ └── 民族名称/人口信息
├── 基本信息卡片(BasicInfoCard)
│ ├── 宗教
│ ├── 语系
│ ├── 文字
│ └── 排行
├── 语言与文字卡片(LanguageCard)
├── 分布地区卡片(RegionCard)
└── 详细介绍卡片(DescriptionCard)
├── 描述文本
└── TTS 按钮
假设范围:从 aboutToAppear 加载数据,到 build 渲染所有组件。
第二步:第一次二分——数据还是UI?
问题:白屏是因为数据没加载出来,还是因为UI渲染出错?
实验:把数据写死,看看页面能不能显示。
aboutToAppear(): void {
// 注释掉原来的动态加载
// const params = router.getParams() as Record<string, string>;
// if (params?.ethnicId) {
// this.ethnic = getEthnicById(params.ethnicId);
// ...
// }
// 直接写死一个民族数据
this.ethnic = {
id: 'han',
name: '汉族',
nameEn: 'Han',
population: '约13亿',
populationRank: 1,
// ... 其他必填字段
};
}
结果:还是白屏。
结论:不是数据的问题,问题在UI渲染层。
💡 思路解析:
第一步二分非常关键——先把"数据"和"UI"这两大块分开。
如果写死数据后页面正常了,说明问题在数据加载流程(接口、参数、解析等);
如果写死数据还是白屏,说明问题在UI渲染(组件、布局、样式等)。
第三步:第二次二分——上半部分还是下半部分?
既然是UI的问题,我们继续二分。页面结构从上到下有好几个卡片,我们先注释掉一半。
实验:注释掉下半部分(语言卡片、地区卡片、描述卡片),只保留头部和基本信息卡片。
build() {
Column() {
if (!this.ethnic) {
// 错误视图(暂时保留)
...
} else {
Column({ space: 0 }) {
// 头部区域(保留)
this.buildHeader()
Scroll() {
Column({ space: $r('app.float.spacing_lg') }) {
// 基本信息卡片(保留)
this.buildBasicInfoCard()
// 语言与文字卡片(先注释掉)
// this.buildLanguageCard()
// 分布地区卡片(先注释掉)
// this.buildRegionCard()
// 详细介绍卡片(先注释掉)
// this.buildDescriptionCard()
}
...
}
...
}
}
}
...
}
结果:页面显示出来了!能看到头部和基本信息卡片。
结论:问题在被注释掉的那三个卡片里(语言、地区、描述)。
💡 思路解析:
这一步把"整个页面"的范围缩小到了"下半部分三个卡片"。
范围从 N 个组件缩小到了 N/2 个组件。
第四步:第三次二分——三个卡片中的哪一个?
现在有三个可疑的卡片:语言卡片、地区卡片、描述卡片。继续二分。
实验:保留语言卡片,注释掉地区和描述卡片。
Scroll() {
Column({ space: $r('app.float.spacing_lg') }) {
// 基本信息卡片(保留)
this.buildBasicInfoCard()
// 语言与文字卡片(保留)
this.buildLanguageCard()
// 分布地区卡片(注释掉)
// this.buildRegionCard()
// 详细介绍卡片(注释掉)
// this.buildDescriptionCard()
}
...
}
结果:页面正常,能显示语言卡片。
再实验:保留地区卡片,注释掉语言和描述卡片。
Scroll() {
Column({ space: $r('app.float.spacing_lg') }) {
// 基本信息卡片(保留)
this.buildBasicInfoCard()
// 语言与文字卡片(注释掉)
// this.buildLanguageCard()
// 分布地区卡片(保留)
this.buildRegionCard()
// 详细介绍卡片(注释掉)
// this.buildDescriptionCard()
}
...
}
结果:页面正常,能显示地区卡片。
再实验:保留描述卡片,注释掉语言和地区卡片。
Scroll() {
Column({ space: $r('app.float.spacing_lg') }) {
// 基本信息卡片(保留)
this.buildBasicInfoCard()
// 语言与文字卡片(注释掉)
// this.buildLanguageCard()
// 分布地区卡片(注释掉)
// this.buildRegionCard()
// 详细介绍卡片(保留)
this.buildDescriptionCard()
}
...
}
结果:白屏了!
结论:问题在描述卡片(buildDescriptionCard)里。
💡 思路解析:
三个组件,用了 2-3 次实验就定位到了具体是哪一个。
如果有 100 个组件,理论上只需要 7 次实验(2^7 = 128)就能定位到。
这就是二分法的威力——对数级别的复杂度。
第五步:第四次二分——描述卡片的哪一部分?
现在范围缩小到了描述卡片。让我们看看描述卡片的结构:
buildDescriptionCard
├── 标题行
│ ├── 图标
│ ├── 标题文字
│ └── TTS 按钮
├── 中文描述文本
└── 英文描述文本(条件渲染)
继续二分。
实验:注释掉TTS按钮和英文描述,只保留标题和中文描述。
@Builder
buildDescriptionCard(): void {
Column({ space: $r('app.float.spacing_md') }) {
// 标题行 + TTS按钮
Row() {
Text('\uD83D\uDCDC\u{FE0F}')
.fontSize($r('app.float.icon_size_md'))
Text($r('app.string.detail_introduction'))
.fontSize($r('app.float.font_size_lg'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
// TTS按钮(先注释掉)
// if (!this.isBasicMode) {
// Row({ space: 4 }) { ... }
// ...
// }
}
.width('100%')
// 详细描述文本(保留)
Text(this.getLocalizedText(this.ethnic!.description, this.ethnic!.descriptionEn))
.fontSize($r('app.float.font_size_md'))
.fontColor($r('app.color.text_secondary'))
.lineHeight(24)
.width('100%')
// 英文描述(先注释掉)
// if (this.isChinese()) {
// Divider()
// .color($r('app.color.divider_color'))
// Text(this.ethnic!.descriptionEn)
// ...
// }
}
...
}
结果:页面正常显示了。
结论:问题在TTS按钮或者英文描述里。
第六步:第五次二分——TTS按钮还是英文描述?
实验一:只保留TTS按钮,注释掉英文描述。
// TTS按钮(保留)
if (!this.isBasicMode) {
Row({ space: 4 }) {
Text(this.isPlaying ? '\u{23F8}' : '\u{25B6}')
.fontSize($r('app.float.icon_size_sm'))
Text(this.isPlaying
? $r('app.string.detail_btn_stop')
: $r('app.string.detail_btn_read'))
.fontSize($r('app.float.font_size_xs'))
.fontColor(this.isPlaying
? $r('app.color.error_color')
: $r('app.color.primary_color'))
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.isPlaying
? $r('app.color.background_color')
: '#E8F0FE')
.onClick(() => {
this.toggleTTS();
})
}
// 英文描述(注释掉)
// if (this.isChinese()) {
// Divider()
// Text(this.ethnic!.descriptionEn)
// ...
// }
结果:页面正常。
实验二:只保留英文描述,注释掉TTS按钮。
// TTS按钮(注释掉)
// if (!this.isBasicMode) { ... }
// 英文描述(保留)
if (this.isChinese()) {
Divider()
.color($r('app.color.divider_color'))
Text(this.ethnic!.descriptionEn)
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_hint'))
.lineHeight(20)
.width('100%')
}
结果:白屏!
结论:问题在英文描述部分。
第七步:定位根因
现在范围已经很小了——就是这段英文描述的代码。让我们仔细看看:
if (this.isChinese()) {
Divider()
.color($r('app.color.divider_color'))
Text(this.ethnic!.descriptionEn)
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_hint'))
.lineHeight(20)
.width('100%')
}
看起来没问题啊?this.ethnic! 用了非空断言,我们确定 ethnic 不为空才会走到这里。
等等,if (this.isChinese()) 这个条件有问题吗?
让我们看看 isChinese 方法:
private isChinese(): boolean {
return this.currentLanguage === AppLanguage.ZH_CN;
}
再看看 currentLanguage 的定义:
@StorageLink('currentAppLanguage') currentLanguage: AppLanguage = AppLanguage.ZH_CN;
看起来也没问题。那为什么会白屏呢?
等等,白屏不一定是"报错了",也可能是"布局问题"——比如高度为0?
让我们给英文描述加个背景色和固定高度,看看:
if (this.isChinese()) {
Divider()
.color($r('app.color.divider_color'))
Text(this.ethnic!.descriptionEn)
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_hint'))
.lineHeight(20)
.width('100%')
.backgroundColor(Color.Red) // 加个红色背景
.height(200) // 加个固定高度
}
结果:页面显示了!能看到红色的一块区域。
结论:不是报错,是布局问题——英文描述的文本可能有什么特殊字符,导致 Text 组件高度计算出了问题?
让我们检查一下数据… 等等,我们用的是写死的测试数据, descriptionEn 是什么?
哦!刚才写死测试数据的时候,只写了几个必填字段,descriptionEn 忘了写,值是 undefined!
this.ethnic = {
id: 'han',
name: '汉族',
nameEn: 'Han',
population: '约13亿',
populationRank: 1,
description: '汉族是中国的主体民族...',
// descriptionEn: 'The Han people are the main ethnic group of China...',
// ↑ 这个忘了写!值是 undefined
};
Text(undefined) 会怎么样?在 ArkUI 中,如果 Text 的内容是 undefined,可能会导致渲染异常,父组件的高度计算也会出问题,最终导致整个 Scroll 的高度为 0,看起来就是白屏。
验证修复
找到了原因,修复就简单了——给 Text 加个空值保护:
Text(this.ethnic?.descriptionEn ?? '')
.fontSize($r('app.float.font_size_sm'))
.fontColor($r('app.color.text_hint'))
.lineHeight(20)
.width('100%')
或者,干脆在条件判断里再多加一层:
if (this.isChinese() && this.ethnic?.descriptionEn) {
Divider()
.color($r('app.color.divider_color'))
Text(this.ethnic.descriptionEn)
...
}
验证:恢复真实数据加载,页面正常显示了。
案例总结
| 步骤 | 范围 | 操作 | 结果 |
|---|---|---|---|
| 1 | 整个页面 | 写死数据 | 还是白屏 → UI问题 |
| 2 | 整个UI | 注释下半部分 | 显示了 → 下半部分有问题 |
| 3 | 3个卡片 | 逐个保留测试 | 描述卡片有问题 |
| 4 | 描述卡片 | 注释TTS和英文 | 显示了 → TTS或英文有问题 |
| 5 | 2个元素 | 分别保留测试 | 英文描述有问题 |
| 6 | 英文描述 | 加背景色+高度 | 显示了 → 内容问题 |
| 7 | 文本内容 | 检查数据 | descriptionEn 是 undefined |
7 步,从"整个页面白屏"定位到"某个字段是 undefined"。
这就是二分法的威力——每一步都把范围缩小一半,几步就能精准定位。
🎯 二分法的核心心法:
- 先大后小:先分大块,再分小块
- 控制变量:每次只变一个因素,确保知道是哪部分导致的
- 大胆假设,小心验证:猜可能是哪的问题,然后用实验验证
- 记录过程:每一步的结果记下来,别到后面忘了哪步有问题哪步没问题
2.5 第三步:提出假设,验证假设
缩小到一定范围了,就可以开始猜了。
但猜不是瞎猜——要提出可验证的假设。
假设:这个 bug 是因为 xxx 导致的
实验:如果我做 yyy 操作,应该能看到 zzz 结果
结果:
- 符合假设 → 假设可能是对的,继续验证
- 不符合 → 假设错了,换一个
好的假设 vs 坏的假设:
| 好的假设(可验证) | 坏的假设(玄学) |
|---|---|
| 可能是接口返回数据格式变了 | 可能是人品问题 |
| 可能是这个版本的系统有兼容问题 | 可能是编译器有 bug |
| 可能是异步顺序错了 | 可能是代码突然坏了 |
| 可能是缓存没清 | 重启试试? |
⚠️ 注意:不要一开始就怀疑编译器、怀疑系统、怀疑框架。99.9% 的情况下,bug 在你自己的代码里。先从自己的代码查起,实在找不到再考虑其他因素。
2.6 第四步:修复与验证
找到原因了,修复就简单了。
但是改完一定要验证:
- 原来的问题修好了吗?
- 有没有引入新的问题?
- 相关的功能有没有受影响?
改完就跑,大概率会改出第二个 bug。
2.7 第五步:复盘与预防
bug 修完了,不要急着放下。花两分钟想想:
- 这个 bug 为什么会出现?
- 怎么防止以后再出现类似的?
- 加测试用例?
- 加类型检查?
- 加 Lint 规则?
- 加代码评审检查项?
一个 bug 是一次学习的机会。修好了不白修,要让它有长期价值。
三、常用调试工具与手段
3.1 日志调试法(Log Debugging)
最古老、最有效、永不淘汰的调试方法——打日志。
console.log('进入函数了');
console.log('参数是:', param);
console.log('执行结果是:', result);
什么时候用日志?
- 需要看执行顺序
- 需要看变量的值
- 需要看某段代码跑没跑
- 断点不方便的时候(比如偶现问题、真机调试)
3.1.1 日志级别
不是所有日志都一样重要——用不同的级别区分。
HarmonyOS 的 hilog 有 6 个级别:
| 级别 | 含义 | 使用场景 | 生产环境 |
|---|---|---|---|
| DEBUG | 调试信息 | 开发调试用的详细信息 | ❌ 关闭 |
| INFO | 一般信息 | 关键流程、重要状态变化 | ⚠️ 少量保留 |
| WARN | 警告 | 不影响主流程,但需要注意 | ✅ 保留 |
| ERROR | 错误 | 出错了,影响功能 | ✅ 保留 |
| FATAL | 致命错误 | App 要崩了 | ✅ 必须保留 |
原则:
- 开发时可以打很多 DEBUG 日志
- 上线前要清理,只保留必要的 INFO/WARN/ERROR
- 敏感信息(密码、Token、手机号)绝对不能打日志
3.1.2 日志格式规范
好的日志,一看就知道"什么时候、在哪、发生了什么"。
推荐格式:
[时间] [级别] [模块] 描述信息 - 参数: xxx, 结果: yyy
「民族图鉴」的日志示例:
// ❌ 不好:什么信息都没有
console.log('进来了');
console.log(data);
// ✅ 好:清晰明了
console.info('[EthnicList] 页面加载开始');
console.info('[EthnicList] 加载民族数据成功,数量:', list.length);
console.error('[EthnicList] 加载民族数据失败:', error.message);
好日志的三要素:
- 在哪:哪个模块/哪个页面
- 发生了什么:做什么操作
- 结果如何:成功/失败、关键数据
3.1.3 关键埋点位置
不是每一行都要打日志——在关键位置打就够了。
建议打日志的位置:
| 位置 | 说明 |
|---|---|
| 函数入口 | 打参数,知道"用什么输入调用的" |
| 函数出口 | 打返回值,知道"返回了什么" |
| 分支处 | if/else 的每个分支,知道走了哪条路 |
| 异步回调 | Promise.then / catch,知道异步成功还是失败 |
| 错误捕获 | try-catch 的 catch 里,打错误详情 |
| 关键状态变化 | 数据加载、用户登录、主题切换等 |
3.1.4 日志调试的技巧
-
二分法打日志:不知道问题出在哪?先在中间打个日志,看有没有执行到。再继续二分。
-
打印对象要小心:
// ❌ 不好:对象引用,展开时可能已经变了 console.log(user); user.name = '新名字'; // ✅ 好:打快照,当时的值是什么就是什么 console.log(JSON.parse(JSON.stringify(user))); -
用 console.table 打数组:列表数据用 table 展示,更直观。
-
用 console.time 计时:
console.time('加载数据'); await loadData(); console.timeEnd('加载数据'); // 输出:加载数据: 123ms -
调试完记得删:不要让生产环境全是调试日志。
3.1.5 日志进阶技巧
上面讲了日志的基本用法,接下来我们聊聊更高级的技巧——怎么让日志更有价值、更好用、更安全。
3.1.5.1 日志级别最佳实践
很多人打日志只有一个级别——console.log,所有信息都用同一个级别。这会导致:
- 排查问题时,重要信息被淹没在海量日志里
- 上线时不敢删日志,怕删错了
- 日志太多,影响性能,也浪费存储空间
不同级别,不同用途——该用什么级别就用什么级别。
| 级别 | 使用场景 | 示例 | 生产环境 |
|---|---|---|---|
| DEBUG | 开发调试用的详细信息,帮你追踪代码执行路径 | “进入函数xxx,参数是xxx” | ❌ 关闭 |
| INFO | 关键流程节点、重要状态变化,记录"发生了什么事" | “用户登录成功”、“数据加载完成,共56条” | ⚠️ 少量保留 |
| WARN | 不影响主流程,但需要注意的异常情况 | “接口超时,重试第2次”、“缓存失效,重新加载” | ✅ 保留 |
| ERROR | 出错了,影响功能,需要关注和修复 | “数据加载失败”、“存储写入异常” | ✅ 保留 |
| FATAL | 致命错误,App 要崩了,必须立即处理 | “内存溢出”、“核心服务初始化失败” | ✅ 必须保留 |
「民族图鉴」中的实践:
// DEBUG:开发调试用,上线后关掉
hilog.debug(DOMAIN, TAG, '进入 getEthnicList 函数,page=%{public}d, pageSize=%{public}d', page, pageSize);
// INFO:关键流程,记录一下
hilog.info(DOMAIN, TAG, '民族数据加载成功,共 %{public}d 条', list.length);
// WARN:不影响主流程,但需要注意
hilog.warn(DOMAIN, TAG, '远程接口请求失败,降级到 Mock 数据');
// ERROR:出错了,需要关注
hilog.error(DOMAIN, TAG, '存储数据失败,key=%{public}s, error=%{public}s', key, error.message);
// FATAL:致命错误,App可能要崩
hilog.fatal(DOMAIN, TAG, '核心服务初始化失败,App 无法正常运行');
判断该用什么级别的小技巧:
- 问自己:“如果线上出问题了,我需不需要看到这条日志?”
- 不需要 → DEBUG
- 可能需要 → INFO
- 一定要看到 → WARN/ERROR/FATAL
3.1.5.2 统一日志格式与封装
每个人打日志的格式都不一样,排查问题时很难受。最好的做法是:封装一个统一的日志工具,全项目用同一个格式。
「民族图鉴」的日志封装示例:
// common/utils/Logger.ets
import hilog from '@ohos.hilog';
const DOMAIN = 0x0001;
export class Logger {
private tag: string;
constructor(tag: string) {
this.tag = tag;
}
debug(format: string, ...args: any[]): void {
hilog.debug(DOMAIN, this.tag, format, ...args);
}
info(format: string, ...args: any[]): void {
hilog.info(DOMAIN, this.tag, format, ...args);
}
warn(format: string, ...args: any[]): void {
hilog.warn(DOMAIN, this.tag, format, ...args);
}
error(format: string, ...args: any[]): void {
hilog.error(DOMAIN, this.tag, format, ...args);
}
fatal(format: string, ...args: any[]): void {
hilog.fatal(DOMAIN, this.tag, format, ...args);
}
// 打印方法入口日志
enter(methodName: string, params?: Record<string, any>): void {
const paramStr = params ? JSON.stringify(params) : '';
this.debug('→ %{public}s enter, params: %{public}s', methodName, paramStr);
}
// 打印方法出口日志
exit(methodName: string, result?: any): void {
const resultStr = result !== undefined ? JSON.stringify(result) : '';
this.debug('← %{public}s exit, result: %{public}s', methodName, resultStr);
}
// 打印耗时日志
time(methodName: string, durationMs: number): void {
this.info('⏱ %{public}s 耗时: %{public}dms', methodName, durationMs);
}
}
// 使用示例
const logger = new Logger('EthnicList');
async function loadData() {
logger.enter('loadData', { page: 1, pageSize: 20 });
const startTime = Date.now();
try {
const data = await fetchData();
logger.time('loadData', Date.now() - startTime);
logger.exit('loadData', { count: data.length });
return data;
} catch (e) {
logger.error('loadData failed: %{public}s', e.message);
logger.exit('loadData', null);
throw e;
}
}
统一日志格式的好处:
- 格式一致:所有日志都是同一个格式,看着舒服
- 方便过滤:按 tag、按级别、按 domain 过滤
- 容易替换:以后想换日志库,只改这一个文件
- 方便扩展:要加日志上报、日志脱敏,都在这加
3.1.5.3 关键埋点设计原则
不是所有地方都要打日志——打太多了是噪音,打太少了不够用。
关键埋点的"5个必打"原则:
| 位置 | 为什么要打 | 打什么内容 |
|---|---|---|
| 入口 | 知道"谁调用的、传了什么参数" | 方法名、入参、调用来源 |
| 出口 | 知道"返回了什么结果" | 方法名、返回值、耗时 |
| 分支 | 知道"走了哪条路" | 分支条件、判断依据 |
| 异常 | 知道"哪里出错了、什么错" | 错误信息、错误堆栈、上下文 |
| 耗时 | 知道"花了多长时间" | 操作名称、耗时毫秒数 |
「民族图鉴」ApiService 的埋点示例:
public async getEthnicList(page: number = 1, pageSize: number = 56): Promise<EthnicGroup[]> {
// 1. 入口:打参数
logger.enter('getEthnicList', { page, pageSize, mode: this.currentMode });
const startTime = Date.now();
if (this.currentMode === DataSourceMode.MOCK) {
// 3. 分支:知道走了 Mock 分支
logger.info('getEthnicList 使用 Mock 数据');
const start = (page - 1) * pageSize;
const result = ETHNIC_GROUPS.slice(start, start + pageSize);
// 2. 出口 + 4. 耗时:打返回值和耗时
logger.exit('getEthnicList', { count: result.length });
logger.time('getEthnicList(mock)', Date.now() - startTime);
return result;
}
try {
const response = await this.httpClient.get<EthnicListResponse>(
`/ethnics?page=${page}&pageSize=${pageSize}`
);
if (response.success && response.data) {
const result = this.mapApiToModel(response.data.ethnics);
logger.info('getEthnicList 远程加载成功,数量: %{public}d', result.length);
logger.exit('getEthnicList', { count: result.length });
logger.time('getEthnicList(remote)', Date.now() - startTime);
return result;
}
// 3. 分支:失败降级
logger.warn('getEthnicList 接口返回失败,降级到 Mock: %{public}s', response.message);
const fallbackResult = ETHNIC_GROUPS.slice(0, pageSize);
logger.exit('getEthnicList', { count: fallbackResult.length, fallback: true });
return fallbackResult;
} catch (e) {
// 4. 异常:打错误详情
logger.error('getEthnicList 异常: %{public}s', e.message);
logger.warn('getEthnicList 异常降级到 Mock 数据');
const fallbackResult = ETHNIC_GROUPS.slice(0, pageSize);
logger.exit('getEthnicList', { count: fallbackResult.length, fallback: true, error: true });
return fallbackResult;
}
}
埋点的"度":
- 太少:出了问题不知道在哪,像瞎子摸象
- 太多:日志泛滥,重要信息被淹没,还影响性能
- 刚好:关键路径有日志,出问题能顺着日志定位到具体哪一步
3.1.5.4 日志脱敏处理
日志里经常会打印用户数据,但敏感信息绝对不能明文打在日志里!
常见的敏感信息:
- 手机号、身份证号、银行卡号
- 密码、Token、密钥
- 家庭住址、精准定位信息
- 其他个人隐私信息
脱敏规则示例:
| 类型 | 原始数据 | 脱敏后 | 规则 |
|---|---|---|---|
| 手机号 | 13812345678 | 138****5678 | 中间4位打码 |
| 身份证 | 110101199001011234 | 110101********1234 | 中间8位打码 |
| 邮箱 | zhangsan@example.com | z***@example.com | 用户名只留首字母 |
| Token | eyJhbGciOiJIUzI1NiIs… | eyJ*****************… | 只留前3位 |
| 姓名 | 张三 | 张* | 只留姓 |
「民族图鉴」的脱敏工具:
// common/utils/MaskUtils.ets
export class MaskUtils {
// 手机号脱敏:138****5678
static maskPhone(phone: string): string {
if (!phone || phone.length < 7) return phone;
return phone.substring(0, 3) + '****' + phone.substring(phone.length - 4);
}
// 身份证脱敏:110101********1234
static maskIdCard(idCard: string): string {
if (!idCard || idCard.length < 10) return idCard;
return idCard.substring(0, 6) + '********' + idCard.substring(idCard.length - 4);
}
// 邮箱脱敏:z***@example.com
static maskEmail(email: string): string {
if (!email || !email.includes('@')) return email;
const [name, domain] = email.split('@');
if (name.length <= 1) return '*@' + domain;
return name[0] + '***@' + domain;
}
// Token脱敏:只留前3位
static maskToken(token: string): string {
if (!token || token.length <= 5) return '***';
return token.substring(0, 3) + '********************';
}
// 姓名脱敏:张*
static maskName(name: string): string {
if (!name || name.length <= 1) return name;
return name[0] + '*'.repeat(name.length - 1);
}
}
// 使用示例
logger.info('用户登录成功,手机号: %{public}s', MaskUtils.maskPhone(phone));
logger.debug('请求头 Token: %{public}s', MaskUtils.maskToken(token));
⚠️ 重要提醒:
日志脱敏不是"可选的",是"必须的"。
泄露用户隐私信息,轻则违反公司规定,重则违反法律法规。
打日志之前,先想想——这条日志里有没有敏感信息?
💡 HarmonyOS hilog 的隐私标记:
hilog 有个很贴心的设计——格式化字符串里可以用%{public}s和%{private}s标记隐私。
%{public}s:公开信息,可以明文显示%{private}s:隐私信息,默认显示为<private>这样即使不小心打了敏感信息,默认也是看不到的,需要手动开启隐私日志才能看到。
💡 HarmonyOS 的日志系统:
HarmonyOS 推荐用
hilog而不是console.log。hilog 有更完善的级别控制、域过滤、格式化能力,是系统级的日志方案。hilog 还支持日志域(domain),可以按模块过滤日志,排查问题更方便。
3.2 断点调试法(Breakpoint Debugging)
日志是"事后看",断点是"停下来看"。
断点调试能做什么:
- 代码执行到某一行停下来
- 查看当前所有变量的值
- 单步执行,一步一步看怎么走的
- 查看调用栈,知道是从哪调过来的
什么时候用断点?
- 逻辑复杂,不知道代码怎么走的
- 变量多,打日志打不过来
- 需要看调用栈的时候
3.2.1 普通断点
最基础的断点——在某一行点一下,代码执行到这就停下来。
适用场景:
- 想看看某段代码有没有执行
- 想看看执行到这的时候变量的值是什么
3.2.2 条件断点(Conditional Breakpoint)
普通断点每次都停,太烦了?用条件断点——只有满足条件的时候才停。
使用场景:
- 循环里第 100 次才出问题 → 条件设为
index === 99 - 只有某个特定用户才会出问题 → 条件设为
userId === '123' - 只有值为 null 时才想看看 → 条件设为
value === null
怎么用:
在断点上右键,设置条件表达式。表达式为 true 时才暂停。
3.2.3 日志断点(Logpoint)
不想暂停执行,只想打个日志?用日志断点——不会停,只会打印日志。
比手动加 console.log 好在哪:
- 不用改代码,不会忘删
- 可以随时加、随时删
- 生产环境也能用(有些调试工具支持)
适用场景:
- 偶现问题,需要打很多日志但又不想改代码
- 想看看执行顺序,但不想每次都暂停
3.2.4 异常断点(Exception Breakpoint)
不知道哪行报错?用异常断点——抛出异常的时候自动停下来。
两种异常断点:
- 捕获的异常:被 try-catch 捕获的异常
- 未捕获的异常:没被捕获的,会导致崩溃的异常
适用场景:
- 报错了,但不知道在哪报的
- 偶现的异常,不知道什么时候触发
3.2.5 断点调试的技巧
-
不要一行行单步:
- 知道大概范围,直接在那打断点,然后 Resume 过去
- Step Over / Step Into / Step Out 配合用,不要只会按下一步
-
用调用栈回溯:
- 停下来了,但不知道怎么走到这的?看调用栈
- 点击调用栈里的每一层,就能看到当时的变量值
-
修改变量值做实验:
- 停下来后,可以手动修改变量的值
- 想试试"如果这个值是另一个会怎样",直接改了继续跑
-
禁用断点:
- 断点太多了?不想删,先禁用,后面可能还要用
3.2.6 断点调试高级实战
上面讲了各种断点的基本概念,接下来我们结合「民族图鉴」项目的实际场景,看看这些高级断点怎么用。
3.2.6.1 条件断点实战
场景:调试收藏功能,发现只有"回族"这个民族的收藏状态不对,其他民族都正常。
问题:如果在 toggleFavoriteEthnic 方法里打普通断点,每次点收藏都会停下来,要停很多次才能到"回族"。
解决方案:用条件断点,只有当 ethnicId === 'hui' 时才断住。
操作步骤:
- 在
toggleFavoriteEthnic方法第一行打个普通断点 - 右键断点,选择"Condition"(条件)
- 输入条件表达式:
ethnicId === 'hui' - 确认,断点图标上会出现一个问号
这样,只有当收藏的是"回族"时,断点才会触发,其他民族直接跳过。
「民族图鉴」中的其他条件断点场景:
| 场景 | 条件表达式 | 说明 |
|---|---|---|
| 列表第50项才出bug | index === 49 |
循环里第N次才断 |
| 只有特定用户有问题 | userId === '12345' |
特定数据才断 |
| 空指针排查 | data === null |
值为null时才断 |
| 收藏数超过10个时出问题 | favorites.length > 10 |
满足某个数量条件才断 |
| TTS播放失败时 | isPlaying === false && errorCode !== 0 |
多个条件组合 |
💡 条件断点的小技巧:
- 条件表达式支持完整的 JavaScript/TypeScript 语法
- 可以用
&&、||组合多个条件- 可以调用简单的方法(但注意不要有副作用)
- 条件太复杂会影响性能,尽量简洁
3.2.6.2 日志断点实战
场景:调试音乐播放的进度更新,想看看每一秒的进度值,但又不想每次都暂停。
问题:用普通断点的话,每一秒都要手动Resume,太麻烦;用console.log的话,要改代码,调试完还要删。
解决方案:用日志断点(Logpoint)——不停下来,只打印日志。
操作步骤:
- 在
notifyListeners方法里打个普通断点 - 右键断点,选择"Log breakpoint"(日志断点)
- 输入要打印的消息,比如:
播放进度: {this.currentPosition}s / {currentMusic.durationSeconds}s - 取消勾选"Suspend"(暂停)
这样,每次执行到这一行,就会在控制台打印日志,但不会暂停执行。
「民族图鉴」中的日志断点场景:
| 场景 | 日志消息 | 说明 |
|---|---|---|
| 列表滚动性能 | 列表项 index: {index}, 渲染耗时: {Date.now() - startTime}ms |
看哪项渲染慢 |
| 页面生命周期 | 页面 {routeName} 进入 aboutToAppear |
追踪页面跳转 |
| 服务初始化 | {serviceName} 初始化完成,耗时: {duration}ms |
看启动性能 |
| 状态变化 | 主题切换: {oldTheme} → {newTheme} |
追踪状态变化 |
| 网络请求 | 请求 {url},状态: {status},耗时: {duration}ms |
看接口性能 |
💡 日志断点 vs console.log:
- 日志断点不用改代码,不会忘删
- 可以随时加、随时删,不用重新编译运行
- 支持变量插值,语法类似模板字符串
- 适合临时调试,console.log 适合长期保留的日志
3.2.6.3 异常断点实战
场景:App 偶现崩溃,但不知道在哪报的错,控制台只看到一个模糊的错误信息。
问题:报错信息不明确,不知道是哪行代码抛的异常,也不知道调用路径是什么。
解决方案:用异常断点——只要抛出异常,不管有没有被捕获,都在抛出的那一行停下来。
操作步骤:
- 打开 Debug 面板的 Breakpoints 标签页
- 点击 “+” 号,选择 “JavaScript Exception Breakpoints”
- 或者勾选 “Any Exception”(任何异常)
- 选择要捕获的类型:
- Caught Exceptions:被 try-catch 捕获的异常
- Uncaught Exceptions:未被捕获的异常(会导致崩溃)
建议配置:
- 开发阶段:两种都开,任何异常都停下来看看
- 定位崩溃问题:只开 Uncaught Exceptions,只看会崩溃的异常
- 太多干扰:可以加过滤条件,只关注特定类型的异常
「民族图鉴」中的异常断点实战:
有一次,「民族图鉴」的收藏功能偶现崩溃,报错信息很模糊:“Cannot read property ‘push’ of undefined”。
开了异常断点后,问题很快定位到了:
// StorageService.ets
public async toggleFavoriteEthnic(ethnicId: string): Promise<boolean> {
const favorites = await this.getFavoriteEthnics(); // ← 偶现返回 undefined
const index = favorites.indexOf(ethnicId); // ← 这里报错:favorites 是 undefined
// ...
}
getFavoriteEthnics 方法在某些异常情况下会返回 undefined,而不是空数组。加上异常断点后,一报错就停在了抛出的那一行,立刻就看到了问题所在。
💡 异常断点的价值:
90% 的偶现崩溃,开了异常断点后,第一次复现就能定位到具体代码行。
不知道哪报错的时候,异常断点是神器。
3.2.6.4 调用栈分析实战
场景:断点停下来了,但你不知道代码是怎么走到这的——谁调用了这个方法?经过了哪些中间层?
问题:一个方法可能被很多地方调用,你不确定当前这次调用是从哪来的。
解决方案:看调用栈(Call Stack / Frames)。
调用栈是什么:
调用栈就是函数的调用链条——A调用B,B调用C,那调用栈就是:
C ← B ← A
每一层都是一个栈帧(Frame),包含:
- 函数名
- 所在文件
- 行号
- 当时的变量值
怎么看调用栈:
- 断点停下来后,看 Debug 面板的 “Frames”(或者 “Call Stack”)标签
- 从上到下是当前函数 → 调用者 → 调用者的调用者…
- 点击某一层,就能跳转到对应的代码行
- 同时 Variables 面板会显示那一层的变量值
「民族图鉴」中的调用栈实战:
案例:收藏状态不同步
问题:详情页收藏了,返回列表页,收藏状态没更新。
我们在列表页的 build 方法里打个断点,看看页面是怎么刷新的:
Frames:
├─ EthnicListPage.build() ← 当前停在这里
├─ ViewPU.createOrUpdateNode()
├─ ViewPU.updateComponent()
├─ ViewPU.forceUpdate()
├─ AppStorage.notifyListeners()
├─ StorageService.toggleFavoriteEthnic() ← 收藏操作触发了更新
└─ EthnicDetailPage.toggleFavorite() ← 详情页点了收藏按钮
从调用栈可以看到:
- 用户在详情页点了收藏按钮
- 调用了 StorageService 的 toggleFavoriteEthnic
- StorageService 改了 AppStorage 里的值
- AppStorage 通知监听者更新
- 列表页收到通知,重新 build
但为什么列表页的收藏状态没更新呢?继续看变量,发现列表页用的是自己的 @State 变量,没有从 AppStorage 读——这就是问题所在。
调用栈分析的几个要点:
- 从下往上看:最下面的是最早的调用,最上面的是当前函数
- 逐层点击:每一层都能看到当时的变量值
- 找业务代码:中间很多是框架内部的,找到你自己写的代码层
- 看参数变化:同一函数被调用多次,每次参数可能不同
💡 调用栈的价值:
调用栈能告诉你"代码是怎么走到这的"——这是理解复杂逻辑的最好方式。
特别是在看别人的代码、或者看自己很久以前写的代码时,调用栈能快速帮你理清执行路径。
3.2.6.5 变量监视实战
场景:调试一个复杂功能,有十几个变量你都关心,但每次都要在 Variables 面板里找,太麻烦。
问题:Variables 面板里变量太多,关键变量被淹没在里面,每次都要找半天。
解决方案:用变量监视(Watches / Watch Expressions)——把你关心的变量加到监视列表里,它们的值会一直显示在最上面。
怎么用:
- 打开 Debug 面板的 “Watches” 标签
- 点击 “+” 号,输入要监视的表达式
- 表达式的值会实时更新
- 可以随时添加、删除、修改
「民族图鉴」中的变量监视实战:
调试「民族图鉴」的音乐播放功能时,我们可以监视这些变量:
Watches:
├─ this.currentMusic.name ← 当前播放的歌曲名
├─ this.isPlaying ← 是否在播放
├─ this.currentPosition ← 当前播放进度
├─ this.playMode ← 播放模式
├─ this.playlist.length ← 播放列表长度
└─ this.currentIndex ← 当前索引
这样,音乐播放的所有关键状态都一目了然,不用在 Variables 里翻找。
监视的不只是变量:
Watches 里可以放任何表达式,不只是变量名:
| 表达式 | 用途 |
|---|---|
this.favorites.includes('han') |
看汉族是否被收藏 |
this.list.filter(item => item.populationRank < 10).length |
前10名的民族数量 |
JSON.stringify(this.ethnic, null, 2) |
格式化显示对象 |
this.currentMusic.durationSeconds - this.currentPosition |
剩余播放时间 |
Date.now() - this.startTime |
计算耗时 |
条件断点 + 变量监视 = 黄金组合:
调试复杂问题时,最好的组合是:
- 用条件断点在正确的时机停下来
- 用变量监视看关键状态
- 用调用栈看怎么走到这的
- 用单步执行看下一步会发生什么
这四件套配合使用,大部分 bug 都能很快定位。
🎯 断点调试的核心心法:
- 先猜后验:先猜问题可能在哪,再用断点验证
- 善用条件:用条件断点过滤掉无关的调用
- 用好栈帧:调用栈是理解执行路径的最好工具
- 监视关键变量:把关心的变量加到 Watches,一目了然
- 大胆实验:停下来后可以修改变量值做实验,验证你的猜测
3.3 DevEco Studio 调试器完整使用指南
DevEco Studio 内置了强大的调试器,是 HarmonyOS 开发的"瑞士军刀"。花点时间熟悉它,调试效率能提升好几倍。
3.3.1 调试器面板详解
DevEco Studio 的调试界面主要由这些面板组成:
| 面板 | 作用 | 快捷键 |
|---|---|---|
| Debug 工具窗口 | 调试控制中心,显示所有调试信息 | Alt + 5 |
| Frames(调用栈) | 函数调用链,点击可跳转到对应层 | - |
| Variables(变量) | 当前作用域的所有变量及其值 | - |
| Watches(监视) | 自定义监视表达式,重点关注特定变量 | - |
| Breakpoints(断点) | 管理所有断点,启用/禁用/删除 | - |
| Console(控制台) | 日志输出,也可以执行表达式 | - |
3.3.2 常用调试操作
开始调试:
- 工具栏点击 Debug 按钮(虫子图标)
- 快捷键:Shift + F9
- 可以选择调试模式:ArkTS 调试、C++ 调试、混合调试
调试控制按钮:
| 按钮 | 功能 | 快捷键 | 说明 |
|---|---|---|---|
| ▶️ Resume | 继续执行 | F9 | 运行到下一个断点 |
| ⏸️ Pause | 暂停 | - | 暂停当前执行 |
| ⏹️ Stop | 停止 | Ctrl + F2 | 结束调试会话 |
| ↗️ Step Over | 单步跳过 | F8 | 执行下一行,不进入函数 |
| ↘️ Step Into | 单步进入 | F7 | 进入函数内部 |
| ↖️ Step Out | 单步跳出 | Shift + F8 | 从当前函数跳出 |
| 🔄 Drop Frame | 丢帧 | - | 回退到上一个栈帧(重跑函数) |
| 🎯 Run to Cursor | 运行到光标处 | Alt + F9 | 运行到光标所在行 |
3.3.3 高级调试技巧
技巧一:修改变量值做实验
暂停时,可以在 Variables 面板直接修改变量的值,然后继续执行,看看不同的值会导致什么结果。
// 比如这段代码
function calculateTotal(price: number, count: number): number {
const total = price * count;
return total; // 在这里打断点
}
在断点处把 price 改成 100,看看结果对不对——不用改代码重新运行。
技巧二:条件表达式求值
在调试时,可以在 Console 面板输入任意表达式,实时计算结果:
> this.list.length
20
> this.list[0].name
"汉族"
> this.isFavorite(this.list[0].id)
true
甚至可以调用函数,看看返回值是什么。
技巧三:监视表达式(Watches)
有几个变量特别关注?加到 Watches 里,它们的值会一直显示在最上面,不用在 Variables 里找。
比如调试「民族图鉴」的收藏功能时,可以监视:
currentEthnic.idfavoriteIdsisFavorite
这样收藏状态的变化一目了然。
技巧四:异常断点定位
不知道哪行报错?开启异常断点:
- 打开 Breakpoints 面板
- 勾选 “Any Exception”
- 选择"捕获的异常"和"未捕获的异常"
这样只要抛出异常,调试器就会在抛出的那一行停下来,直接定位问题。
技巧五:日志断点替代 console.log
想打日志但又不想改代码?用日志断点:
- 在想打日志的行右键断点
- 选择 “Log breakpoint”
- 输入要打印的消息,比如:
"加载民族数据: {list.length} 条" - 取消勾选 “Suspend”(不停下来,只打日志)
这样代码执行到这就会打印日志,但不会暂停——比手动加 console.log 方便多了,还不用担心忘记删。
3.3.4 真机调试 vs 模拟器调试
| 维度 | 模拟器 | 真机 |
|---|---|---|
| 启动速度 | 快 | 慢(要装包) |
| 性能 | 取决于电脑 | 真实性能 |
| 系统能力 | 部分能力不可用 | 完整能力 |
| 传感器 | 模拟的 | 真实的 |
| 适用场景 | 日常开发、UI调试 | 最终验证、性能测试、系统能力 |
建议:
- 日常开发用模拟器,快
- 涉及传感器、相机、定位等,用真机
- 上线前一定要在真机上跑一遍
3.4 调试工具大全
工欲善其事,必先利其器。除了 DevEco Studio 自带的调试器,还有很多工具能帮你更快地定位问题。
3.4.1 系统工具
1. HiLog(系统日志)
HarmonyOS 的系统级日志工具,比 console.log 强大得多:
import hilog from '@ohos.hilog';
const TAG = 'EthnicChronicles';
const DOMAIN = 0x0001;
hilog.debug(DOMAIN, TAG, '调试信息: %{public}s', message);
hilog.info(DOMAIN, TAG, '一般信息: %{public}d', count);
hilog.warn(DOMAIN, TAG, '警告信息: %{public}s', warning);
hilog.error(DOMAIN, TAG, '错误信息: %{public}s', error);
特点:
- 6 个日志级别(DEBUG/INFO/WARN/ERROR/FATAL)
- 支持日志域(domain),按模块过滤
- 支持格式化字符串,有隐私标记(public/private)
- 系统级日志,App 崩溃了也能看到
2. SmartPerf(性能分析)
华为的性能分析工具,可以测:
- CPU 占用率
- 内存使用情况
- 帧率(FPS)
- 启动时间
- 电量消耗
做性能优化必备,用数据说话,不靠"感觉"。
3. DevEco Profiler
DevEco Studio 内置的性能分析器:
- CPU Profiler:看函数调用耗时
- Memory Profiler:看内存分配、找内存泄漏
- Network Profiler:看网络请求
- Frame Profiler:看帧率和渲染性能
3.4.2 第三方工具
1. 抓包工具
- Charles:Mac/Windows 都有,功能强大,HTTPS 抓包、断点修改都支持
- Fiddler:Windows 下经典的抓包工具
- mitmproxy:命令行抓包工具,适合自动化
调试网络问题必备——看看请求发出去没、参数对不对、返回了什么。
2. 数据库查看工具
- Stetho(Android):Chrome DevTools 查看数据库
- 各种数据库浏览器:直接看本地数据库里的数据
调试数据持久化问题时,不用打日志,直接看数据库里存了什么。
3.4.3 自研工具与调试辅助
1. 调试面板(Debug Panel)
在 App 里内置一个调试面板,开发版可以打开:
┌─────────────────────┐
│ 🔧 调试面板 │
├─────────────────────┤
│ 📋 日志查看器 │
│ 🔄 模拟弱网 │
│ 🎨 主题快速切换 │
│ 📊 性能监控 │
│ 🗑️ 清除缓存 │
│ 📋 环境信息 │
└─────────────────────┘
「民族图鉴」项目可以加这样一个调试面板,开发时方便很多。
2. 错误边界(Error Boundary)
全局捕获未处理的异常,崩溃前收集信息:
- 错误堆栈
- 当前页面
- 用户操作路径
- 设备信息
- App 版本
然后上报到监控平台,或者显示一个友好的错误页面。
3. 埋点与用户行为回放
在关键位置埋点,记录用户操作:
- 页面进入/退出
- 按钮点击
- 网络请求
- 错误异常
出了问题,可以回看用户的操作路径,更容易复现。
🎯 工具的价值:
工具不能代替思考,但好的工具能让你把时间花在"思考问题"上,而不是"收集信息"上。
日志工具帮你快速收集信息,断点工具帮你深入代码,性能工具帮你找到瓶颈——各有所长,组合使用效果最好。
3.5 性能分析工具
性能问题不能靠"感觉",要用工具测。
DevEco Studio 自带的性能分析工具可以看:
- CPU 占用
- 内存使用
- 帧率(FPS)
- 启动时间
- 网络请求
性能问题的排查思路:
- 先测,找到瓶颈
- 分析瓶颈原因
- 优化
- 再测,确认有效
四、常见问题与排查思路
4.1 页面白屏 / 黑屏
可能的原因:
| 原因 | 排查方法 |
|---|---|
| JS 报错了 | 看控制台有没有报错 |
| 数据没加载出来 | 看接口返回了没 |
| 组件渲染出错 | 二分法注释组件,看哪个导致的 |
| 布局问题(高度为0) | 给根节点加个背景色,看有没有渲染出来 |
| 路由错了 | 看路由配置对不对 |
排查步骤:
- 先看控制台有没有报错
- 写死静态数据,看能不能显示
- 二分法注释,定位到具体组件
- 检查布局参数(高度、宽度有没有设)
4.2 列表不刷新
症状:数据改了,但是列表没变。
常见原因:
原因一:直接修改数组/对象属性
// ❌ 这样改,ArkUI 可能检测不到
this.list.push(item);
this.user.name = '新名字';
// ✅ 替换整个对象/数组
this.list = [...this.list, item];
this.user = { ...this.user, name: '新名字' };
ArkUI 的响应式系统是浅监听——直接改属性可能检测不到。要整体替换,或者用 @Observed / @ObjectLink。
原因二:key 用了 index
// ❌ 用 index 做 key,数据顺序变了可能不刷新
ForEach(this.list, (item, index) => {
ListItem() { ... }
}, (_, index) => index.toString())
// ✅ 用数据的唯一 ID 做 key
ForEach(this.list, (item) => {
ListItem() { ... }
}, (item) => item.id)
原因三:状态不是响应式的
// ❌ 普通变量,改了不触发刷新
list: Item[] = [];
// ✅ @State 装饰的变量,改了才会刷新
@State list: Item[] = [];
4.3 内存泄漏
症状:App 越用越卡,或者退出页面后内存不下降。
常见泄漏点:
| 泄漏点 | 排查方法 |
|---|---|
| 监听器没移除 | 检查 addListener 和 removeListener 是不是配对 |
| 定时器没清理 | setInterval / setTimeout 有没有对应 clear |
| 事件订阅没取消 | 全局事件、系统事件有没有取消订阅 |
| 闭包引用 | 回调里引用了页面对象,页面销毁了回调还在 |
| 大图不释放 | 长列表滚动,图片内存一直涨 |
排查方法:
- 用内存分析工具看内存曲线
- 反复进出同一个页面,看内存是不是只升不降
- 堆快照对比,看什么对象泄漏了
- 查代码,找上面说的几个常见泄漏点
4.4 网络请求失败
症状:接口调不通、返回错误、超时。
排查步骤:
1. 看请求发出去了吗?
│
├─ 没发出去 → 检查 URL、参数、网络状态
│
└─ 发出去了 → 看状态码是什么?
│
├─ 4xx(客户端错误)
│ ├─ 400 → 参数不对
│ ├─ 401 → 没登录 / Token 过期
│ ├─ 403 → 没权限
│ └─ 404 → 地址错了
│
├─ 5xx(服务器错误)
│ └─ 后端的锅,找后端
│
└─ 超时 / 失败 → 网络问题、跨域、证书问题
工具:用抓包工具(Charles、Fiddler、DevEco 自带的网络面板)看请求和响应的详细内容。
4.5 样式不对 / 布局错乱
症状:UI 显示和预期不一样。
常见原因:
| 原因 | 表现 |
|---|---|
| 宽高没设对 | 组件不显示 / 太大 / 太小 |
| 对齐方式错了 | 位置不对 |
| Flex 布局属性错了 | 排列不符合预期 |
| 定位问题 | 绝对/相对定位搞错了 |
| 权重(layoutWeight) | 没按比例分配空间 |
| 优先级问题 | 多个样式冲突,没用上预期的 |
排查技巧:
- 给组件加背景色(红的绿的蓝的),看它到底占了多大地方
- 用 DevEco 的布局检查器看每个组件的尺寸和位置
- 二分法:注释掉一半代码,看问题还在不在
- 简化:把问题简化成最小可复现的例子
4.6 偶现问题
最难搞的 bug——偶现的、神出鬼没的。
排查思路:
- 找规律:什么时候出现?什么操作后出现?什么设备上出现?
- 加日志:在可能相关的地方打详细日志,出问题后回看日志
- 考虑竞态:是不是两个异步操作的顺序不确定?
- 考虑时序:是不是时机问题(初始化还没完成就调用了)?
- 考虑数据:是不是特定数据才会触发?
- 压力测试:反复操作,提高出现概率
- 断言 + 崩溃捕获:在怀疑的地方加断言,出问题直接崩在那
💡 经验之谈:偶现的 bug,90% 以上和"异步"、“时序”、"特定数据"有关。从这几个方向找,命中率很高。
五、常见的"坑"与避坑指南
5.1 异步顺序坑
// ❌ 以为是顺序执行,其实不是
const data = await fetchData();
this.list = data;
this.doSomething(); // 这里是对的,await 了
fetchData().then(data => {
this.list = data;
});
this.doSomething(); // ❌ 这里 list 还没赋值!
经典错误:把异步当同步写。
解决:搞清楚哪些是异步的,用 async/await 或者 .then 链式调用。
5.2 this 指向坑
// ❌ 回调里的 this 不是你想的那个
someFunction(function(result) {
this.result = result; // this 是 undefined 或者别的东西
});
// ✅ 用箭头函数,this 是词法作用域的
someFunction((result) => {
this.result = result; // this 是组件实例
});
记住:普通函数的 this 是调用时决定的,箭头函数的 this 是定义时决定的。
5.3 类型断言坑
// ❌ 强行断言,骗了编译器骗不了运行时
const user = data as User;
console.log(user.name); // 运行时 data 可能根本没有 name
// ✅ 先判断,再用
if (data && typeof data.name === 'string') {
console.log(data.name);
}
as 类型断言只是告诉编译器"我确定它是这个类型",运行时并不会检查。如果断言错了,运行时照样崩。
5.4 状态更新坑
// ❌ 以为是同步更新,其实是异步的
this.count = 1;
console.log(this.count); // 可能还是旧值
UI 框架的状态更新一般是异步批量的——不是一改就立刻刷新,而是收集一批一起更。
如果需要更新后立刻读最新值,用 nextTick 或者在下一个事件循环读。
5.5 数组/对象引用坑
// ❌ 同一个引用,改一个另一个也变
const list = this.list;
list.push(item); // 等于直接改了 this.list
// ✅ 先拷贝再改
const list = [...this.list];
list.push(item);
this.list = list;
对象和数组是引用类型,赋值赋的是引用(地址),不是值。
六、Bug 的分类与优先级
不是所有 bug 都一样重要——要分优先级,先修最紧急的。
6.1 按严重程度分类
| 严重程度 | 定义 | 示例 | 响应时间 |
|---|---|---|---|
| 致命(P0) | App 崩溃、核心功能完全不可用 | 启动就崩、支付失败 | 立即修复 |
| 严重(P1) | 核心功能受影响,没有替代方案 | 民族列表加载不出来 | 24 小时内 |
| 一般(P2) | 次要功能问题,有替代方案 | 某个民族的图片加载失败 | 本版本修复 |
| 轻微(P3) | 体验问题、UI 小问题 | 文字对齐差 1px、颜色略深 | 有空再修 |
| 建议(P4) | 优化建议、体验改进 | 建议加个动画 | 下个版本再说 |
6.2 按类型分类
| 类型 | 说明 | 常见程度 |
|---|---|---|
| 功能 bug | 功能不符合需求 | ⭐⭐⭐⭐⭐ |
| UI bug | 显示不对、布局错乱 | ⭐⭐⭐⭐ |
| 性能 bug | 卡顿、慢、耗电 | ⭐⭐⭐ |
| 兼容性 bug | 某些设备/系统版本有问题 | ⭐⭐⭐ |
| 安全 bug | 有安全隐患 | ⭐⭐ |
| 体验 bug | 能用但不好用 | ⭐⭐⭐⭐ |
6.3 怎么判断优先级
判断一个 bug 严不严重,看三个维度:
- 影响范围:多少用户会遇到?
- 影响程度:对用户的影响有多大?能不能用?
- 有没有替代方案:用户能不能绕过去?
影响大 + 范围广 + 没替代方案 = 高优先级
影响小 + 范围小 + 有替代方案 = 低优先级
🎯 Bug 优先级的本质:
不是"这个 bug 严不严重",而是"先修哪个 ROI 最高"——花同样的时间,修哪个能减少最多的用户痛苦。
七、「民族图鉴」常见 Bug 库
说了这么多理论,回到我们的项目。「民族图鉴」开发过程中,常见的 bug 有哪些?怎么修的?
这是我们的"避坑指南"——别人踩过的坑,你就别再踩了。
7.1 页面相关
Bug 1:详情页返回后列表位置丢失
症状:从列表页点进详情页,返回来,列表又回到顶部了。
原因:页面销毁重建了,滚动位置没保存。
解决:
- 用页面级状态管理保存滚动位置
- 或者用 Navigation 的页面栈,页面不会销毁
- 返回时恢复滚动位置
预防:列表页进入详情页的场景,都要考虑滚动位置恢复。
Bug 2:白屏/黑屏
症状:打开页面什么都没有,一片空白。
常见原因:
- JS 报错了 → 看控制台
- 数据没加载出来 → 看接口返回
- 布局高度为 0 → 给根节点加背景色
- 条件渲染写反了 → 检查 if/else
排查步骤:
- 先看控制台有没有报错
- 写死静态数据试试
- 二分法注释组件
- 检查布局参数
7.2 列表相关
Bug 3:列表不刷新
症状:数据改了,列表没变。
常见原因(按概率排序):
- 直接修改数组/对象属性,没替换整个引用
- key 用了 index,顺序变了检测不到
- 变量不是 @State 装饰的
- 改的是 props,不是组件自己的状态
解决:参考前面"列表不刷新"那一节。
Bug 4:列表滑动卡顿
症状:快速滑动列表的时候,一卡一卡的。
常见原因:
- 没用 LazyForEach,一次性渲染了所有项
- 列表项太复杂,嵌套层级太深
- 图片太大,解码耗时
- 滚动的时候做了很多计算
解决:
- 用 LazyForEach 懒加载
- 简化列表项结构
- 图片用合适尺寸 + 懒加载
- 滚动时暂停非关键操作
7.3 状态相关
Bug 5:收藏状态不同步
症状:详情页收藏了,返回列表页,那颗心还是空的。
原因:列表页和详情页各存了一份数据,改了一个另一个不知道。
解决:
- 用全局状态管理(Store/Service),数据只有一份
- 或者返回时刷新列表
- 或者用事件通知列表页更新
最佳实践:收藏这种全局状态,放在 Service 里统一管理,不要每个页面各存一份。
Bug 6:主题切换后部分页面没变化
症状:切换主题,当前页面变了,返回上一页,上一页还是旧主题。
原因:上一页的主题状态不是响应式的,或者页面没销毁重建。
解决:
- 主题状态放在全局,用 @Provide/@Consume 或者 Service + 事件
- 页面监听主题变化,变化时刷新
- 或者用系统的主题能力,自动适配
7.4 网络相关
Bug 7:弱网下白屏
症状:网络慢的时候,页面一直白屏,什么提示都没有。
原因:只有"正常态",没有"加载中"和"错误态"。
解决:
- 加 Loading 状态,加载中显示骨架屏或 Loading
- 加错误状态,加载失败显示错误提示 + 重试按钮
- 有缓存的话,先显示缓存,再刷新
原则:每个有网络请求的页面,都要有三态:加载中、空数据、错误。
Bug 8:图片加载失败
症状:有些图片显示不出来,一片空白。
常见原因:
- URL 错了
- 网络问题
- 图片格式不支持
- 图片太大,解码失败
解决:
- 图片组件加错误占位图
- 失败了可以重试
- 用合适尺寸和格式的图片
- 加日志,看失败原因
7.5 其他常见坑
Bug 9:TTS 播着播着就停了
症状:长文本朗读,读一半就停了。
原因:页面销毁了,或者 TTS 引擎资源被回收了。
解决:
- TTS 放在 Service 里,不要和页面生命周期绑定
- 长文本分段朗读
- 页面销毁时不要停,或者用后台服务
Bug 10:收藏数据丢失
症状:收藏了很多民族,过几天打开,收藏没了。
常见原因:
- 用了内存存储,App 被杀就没了 → 要用持久化存储
- Key 写错了,读写不是同一个 Key
- 存储操作失败了(但没捕获异常)
- 清缓存把数据清掉了
解决:
- 用 Preferences 持久化存储
- Key 定义成常量,不要手写字符串
- 存储操作加 try-catch
- 重要数据定期备份
7.6 Bug 库的维护
这个 Bug 库不是一成不变的——每遇到一个新坑,就加进来;每修一个 bug,就记录下来。
时间长了,这就是团队最宝贵的财富之一——新人来了先看一遍,80% 的坑都能避开。
💡 Bug 库的价值:
一个聪明人不会在同一个地方摔倒两次。
一个优秀的团队,也不会让每个人都踩一遍同样的坑。
把踩过的坑记录下来,让后面的人绕过去——这就是 Bug 库的意义。
八、调试工具大全
工欲善其事,必先利其器。掌握了调试的方法论之后,我们再来盘点一下 HarmonyOS 开发中常用的调试工具。
好的工具能让你事半功倍——把时间花在"思考问题"上,而不是"收集信息"上。
8.1 系统工具
HarmonyOS 系统本身提供了很多调试工具,这些是最基础、也是最常用的。
8.1.1 HiLog 日志工具
HiLog 是 HarmonyOS 的系统级日志工具,比 console.log 强大得多。
功能列表:
| 功能 | 说明 |
|---|---|
| 6个日志级别 | DEBUG / INFO / WARN / ERROR / FATAL |
| 日志域(Domain) | 用 16 进制数字标识模块,方便按模块过滤 |
| 日志标签(Tag) | 字符串标签,进一步细化分类 |
| 隐私标记 | %{public}s / %{private}s,保护敏感信息 |
| 格式化字符串 | 支持 printf 风格的格式化 |
| 系统级日志 | App 崩溃了也能看到日志 |
| 实时查看 | 通过 hdc 命令实时查看设备日志 |
适用场景:
- 日常开发调试
- 线上问题排查
- 性能监控(通过日志打点)
- 用户行为追踪
「民族图鉴」中的用法:
import hilog from '@ohos.hilog';
const DOMAIN = 0x0001; // 0x0001 表示民族图鉴模块
const TAG = 'ApiService';
hilog.debug(DOMAIN, TAG, '请求接口: %{public}s', url);
hilog.info(DOMAIN, TAG, '数据加载成功,共 %{public}d 条', count);
hilog.warn(DOMAIN, TAG, '接口超时,重试第 %{public}d 次', retryCount);
hilog.error(DOMAIN, TAG, '请求失败: %{public}s', error.message);
常用 hdc 命令:
# 查看所有日志
hdc hilog
# 按级别过滤(只看 ERROR 及以上)
hdc hilog -b error
# 按 domain 过滤
hdc hilog -b debug -D 0x0001
# 按 tag 过滤
hdc hilog -b debug -t ApiService
# 清空日志
hdc hilog -r
8.1.2 性能分析工具(SmartPerf)
SmartPerf 是华为的性能分析工具,能帮你找到性能瓶颈。
功能列表:
| 功能 | 说明 |
|---|---|
| CPU 分析 | CPU 占用率、函数调用耗时 |
| 内存分析 | 内存使用情况、内存泄漏检测 |
| 帧率分析 | FPS、掉帧检测、渲染耗时 |
| 启动分析 | 冷启动/热启动耗时、启动流程拆解 |
| 电量分析 | 电量消耗、功耗优化 |
| 网络分析 | 网络请求耗时、流量统计 |
| 火焰图 | 可视化展示函数调用栈和耗时占比 |
适用场景:
- 性能优化(启动慢、卡顿、耗电)
- 内存泄漏排查
- 列表滑动流畅度优化
- 启动速度优化
「民族图鉴」中的典型使用场景:
- 民族列表滑动卡顿 → 用帧率分析 + CPU 分析找瓶颈
- 内存持续上涨 → 用内存分析找泄漏点
- 启动太慢 → 用启动分析看哪一步最耗时
8.1.3 内存分析工具
内存问题是 App 开发中的常见问题——内存泄漏、内存溢出、大对象占用等。
功能列表:
| 功能 | 说明 |
|---|---|
| 实时内存监控 | 实时查看内存使用曲线 |
| 堆快照(Heap Dump) | 抓取当前内存中的所有对象 |
| 对象统计 | 各类对象的数量和占用大小 |
| 引用链分析 | 查看对象的引用关系,找泄漏原因 |
| 内存趋势 | 一段时间内的内存变化趋势 |
| GC 统计 | GC 次数、GC 耗时 |
适用场景:
- 内存泄漏排查(反复进出页面,内存只升不降)
- 内存优化(降低内存占用)
- OOM(内存溢出)问题定位
- 大图内存优化
排查内存泄漏的一般步骤:
- 进入页面前,记录初始内存
- 进入页面,操作一番
- 退出页面,手动触发 GC
- 看内存有没有降回来
- 如果没降,抓堆快照,分析什么对象泄漏了
- 看引用链,找到泄漏原因
8.1.4 网络抓包工具
网络问题调试必备——看看请求发出去没、参数对不对、返回了什么。
功能列表:
| 功能 | 说明 |
|---|---|
| 请求抓取 | 抓取所有 HTTP/HTTPS 请求 |
| 请求详情 | URL、Method、Headers、Body |
| 响应详情 | Status Code、Headers、Body |
| 耗时分析 | DNS、连接、发送、等待、接收各阶段耗时 |
| 修改请求/响应 | 断点修改请求或响应内容(Mock) |
| 弱网模拟 | 模拟慢速网络、高延迟、丢包 |
| 接口重放 | 重新发送某个请求 |
适用场景:
- 接口调试(参数不对、返回格式不对)
- 网络性能优化(看哪一步慢)
- 弱网测试(模拟各种网络环境)
- Mock 数据(修改响应内容,测试各种情况)
8.2 DevEco Studio 内置工具
DevEco Studio 内置了丰富的调试工具,不用装额外软件就能搞定大部分调试需求。
8.2.1 布局检查器(Layout Inspector)
布局检查器能让你看到 UI 的层级结构和每个组件的详细信息。
功能列表:
| 功能 | 说明 |
|---|---|
| 组件树 | 可视化展示 UI 组件的层级结构 |
| 属性面板 | 查看选中组件的所有属性值 |
| 实时预览 | 实时查看设备上的 UI 状态 |
| 3D 视图 | 3D 视角查看组件层级关系 |
| 测量工具 | 测量组件的尺寸、间距 |
| 边界高亮 | 高亮显示组件的边界和边距 |
适用场景:
- UI 布局问题排查(组件位置不对、尺寸不对)
- 布局性能优化(减少嵌套层级)
- 理解复杂页面的结构
- 对齐问题调试
「民族图鉴」中的典型用法:
- 详情页卡片间距不对 → 用布局检查器看 padding/margin 值
- 列表项高度不一致 → 用布局检查器看每个组件的高度
- 导航栏高度不对 → 用布局检查器看系统栏的高度
8.2.2 CPU Profiler
CPU 性能分析器,帮你找到哪些函数占用了太多 CPU 时间。
功能列表:
| 功能 | 说明 |
|---|---|
| 实时 CPU 占用率 | 实时查看 CPU 使用情况 |
| 函数调用统计 | 每个函数的调用次数、总耗时、自耗时 |
| 火焰图 | 可视化展示调用栈和耗时占比 |
| 调用栈追踪 | 查看函数的调用关系 |
| 线程分析 | 每个线程的 CPU 使用情况 |
| 时间轴 | 选择特定时间段进行分析 |
适用场景:
- 卡顿问题排查
- CPU 占用过高优化
- 启动速度优化
- 函数性能优化
常用概念:
- 自耗时(Self Time):函数本身执行的时间(不包括调用其他函数的时间)
- 总耗时(Total Time):函数执行的总时间(包括调用其他函数的时间)
- 调用次数(Count):函数被调用了多少次
- 火焰图(Flame Graph):宽度代表耗时,高度代表调用栈深度
8.2.3 Memory Profiler
内存分析器,和系统的内存工具类似,但集成在 IDE 里,使用更方便。
功能列表:
| 功能 | 说明 |
|---|---|
| 实时内存曲线 | 实时查看内存使用变化 |
| 堆快照 | 抓取内存中的对象详情 |
| 内存分类 | 按类型统计内存占用 |
| 泄漏检测 | 自动检测可能的内存泄漏 |
| 分配追踪 | 跟踪对象的分配和释放 |
| GC 分析 | 分析 GC 的频率和耗时 |
适用场景:
- 内存泄漏排查
- 内存占用优化
- OOM 问题定位
- 图片内存优化
「民族图鉴」中的典型场景:
- 快速滑动民族列表,内存一直涨 → 用 Memory Profiler 看是不是图片没释放
- 反复进出详情页,内存不下降 → 抓堆快照,看什么对象泄漏了
8.2.4 Network Profiler
网络分析器,在 IDE 里就能看网络请求,不用切到抓包工具。
功能列表:
| 功能 | 说明 |
|---|---|
| 请求列表 | 展示所有的网络请求 |
| 请求详情 | URL、Method、Headers、Body |
| 响应详情 | Status、Headers、Body、大小 |
| 时间轴 | 可视化展示请求的时间线 |
| 耗时分析 | DNS、连接、发送、等待、接收各阶段 |
| 流量统计 | 上传/下载流量统计 |
适用场景:
- 日常接口调试
- 网络性能优化
- 请求顺序排查
- 接口异常定位
和 Charles/Fiddler 相比:
- ✅ 不用装额外软件,IDE 里直接看
- ✅ 和代码调试结合更紧密
- ❌ 功能不如专业抓包工具多(没有断点修改、弱网模拟等)
- ❌ 只能看当前 App 的请求
8.2.5 Frame Profiler(帧率分析器)
帧率分析工具,专门用来分析 UI 渲染性能。
功能列表:
| 功能 | 说明 |
|---|---|
| 实时 FPS | 实时显示当前帧率 |
| 掉帧检测 | 自动检测掉帧并高亮标记 |
| 帧耗时拆解 | 每帧的各个阶段耗时(布局、绘制、合成) |
| 帧时间轴 | 可视化展示每帧的耗时 |
| Jank 统计 | 卡顿次数、卡顿率统计 |
适用场景:
- 列表滑动流畅度优化
- 动画卡顿排查
- 页面切换流畅度优化
- 渲染性能优化
「民族图鉴」中的典型用法:
- 民族列表快速滑动卡顿 → 用 Frame Profiler 看每帧耗时
- 页面转场动画不流畅 → 看动画过程中的帧率变化
8.3 第三方工具
除了系统和 IDE 自带的工具,还有很多优秀的第三方工具,能解决一些特殊场景的问题。
8.3.1 Charles / Fiddler 抓包工具
最经典的网络抓包工具,功能强大,调试网络问题必备。
功能列表:
| 功能 | 说明 |
|---|---|
| HTTP/HTTPS 抓包 | 抓取所有 HTTP/HTTPS 流量 |
| 断点调试 | 请求/响应断点,修改内容后再继续 |
| Map Local / Map Remote | 把请求映射到本地文件或其他地址 |
| 弱网模拟 | 模拟延迟、带宽限制、丢包 |
| 接口重放 | 重新发送请求,修改参数再发 |
| 保存/导出 | 保存抓包记录,分享给别人 |
| 过滤搜索 | 按域名、关键词过滤请求 |
Charles vs Fiddler:
| 维度 | Charles | Fiddler |
|---|---|---|
| 平台 | Mac / Windows / Linux | Windows 为主 |
| 界面 | 更美观、更现代 | 传统 Windows 风格 |
| 价格 | 付费(有免费版) | 免费 |
| 功能 | 更丰富 | 基础功能都有 |
| 上手难度 | 简单 | 中等 |
适用场景:
- 复杂的网络问题调试
- Mock 数据(用 Map Local 替换响应)
- 弱网测试
- 接口重放测试
「民族图鉴」中的典型用法:
- 接口返回格式不对 → 用 Charles 看完整的响应内容
- 测试加载失败的情况 → 用断点把响应改成错误
- 模拟弱网下的表现 → 用弱网模拟功能
- 后端还没做好接口 → 用 Map Local 读本地 JSON 文件
8.3.2 Wireshark
更底层的网络抓包工具,能抓到 TCP/UDP 级别的数据包。
功能列表:
| 功能 | 说明 |
|---|---|
| 全协议抓包 | TCP、UDP、HTTP、DNS… 几乎所有网络协议 |
| 数据包级分析 | 能看到每个数据包的详细内容 |
| 协议解析 | 自动解析各种协议的格式 |
| 过滤表达式 | 强大的过滤语法,精准定位数据包 |
| 流追踪 | 追踪一个 TCP 连接的完整会话 |
| 统计分析 | 各种网络统计数据 |
适用场景:
- 底层网络问题排查(连接失败、超时等)
- 非 HTTP 协议调试(TCP、UDP、WebSocket 等)
- 网络性能分析(丢包、重传、延迟)
- 安全分析
⚠️ 注意:Wireshark 比较底层,上手难度较大。一般的 HTTP 接口调试,用 Charles/Fiddler 就够了。只有遇到底层网络问题时,才需要用 Wireshark。
8.3.3 Postman / ApiPost
接口调试和测试工具,专门用来调试 API。
功能列表:
| 功能 | 说明 |
|---|---|
| 接口调试 | 发送各种 HTTP 请求,查看响应 |
| 集合管理 | 把接口按项目/模块组织起来 |
| 环境变量 | 不同环境(开发/测试/生产)切换 |
| 断言测试 | 自动验证返回结果是否正确 |
| 接口文档 | 自动生成接口文档 |
| Mock 服务 | 模拟接口返回 |
| 批量执行 | 批量运行多个接口,做自动化测试 |
适用场景:
- 后端接口开发和调试
- 接口测试
- Mock 接口(前端开发等后端接口时)
- 接口文档管理
「民族图鉴」中的用法:
- 开发 ApiService 前,先用 Postman 测一下接口通不通
- 测试各种边界情况(参数为空、参数非法等)
- 保存常用的接口请求,方便随时调用
8.4 自研工具与调试辅助
通用工具解决通用问题,但每个项目都有自己的特殊性。做一些自研的调试工具,能大大提升开发效率。
8.4.1 自定义调试面板(Debug Panel)
在 App 内置一个调试面板,开发版本可以随时打开,里面放各种调试功能。
「民族图鉴」调试面板设计:
┌─────────────────────────────────┐
│ 🔧 调试面板 [关闭] │
├─────────────────────────────────┤
│ │
│ 📱 设备信息 │
│ ├─ 设备型号: Mate 60 Pro │
│ ├─ 系统版本: HarmonyOS 4.2 │
│ ├─ App版本: 1.0.0 │
│ └─ 构建时间: 2026-06-29 │
│ │
│ 🔄 环境切换 │
│ ├─ [Mock] [测试] [正式] │
│ └─ 自定义BaseUrl: [输入框] │
│ │
│ 🎨 主题快速切换 │
│ ├─ [浅色] [深色] [跟随系统] │
│ └─ 字体大小: [小] [中] [大] │
│ │
│ 🌐 语言切换 │
│ └─ [中文] [English] │
│ │
│ 📊 性能监控 │
│ ├─ FPS: 60 │
│ ├─ CPU: 15% │
│ ├─ 内存: 156MB │
│ └─ [开启悬浮窗] │
│ │
│ 📋 日志查看器 │
│ └─ [打开日志面板] │
│ │
│ 🗑️ 数据操作 │
│ ├─ [清除缓存] │
│ ├─ [清除收藏] │
│ └─ [重置所有数据] │
│ │
│ 🚪 快捷入口 │
│ ├─ [收藏页] [历史页] │
│ ├─ [设置页] [关于页] │
│ └─ [任意页面路由] │
│ │
└─────────────────────────────────┘
调试面板的核心功能:
| 功能分类 | 具体功能 | 价值 |
|---|---|---|
| 环境切换 | Mock/测试/正式环境切换 | 不用重新打包就能切换环境 |
| 主题切换 | 浅色/深色/字体大小 | 快速验证不同主题下的 UI |
| 语言切换 | 中/英切换 | 快速验证多语言 |
| 性能监控 | FPS/CPU/内存实时显示 | 随时看性能数据 |
| 日志查看 | 内置日志查看器 | 不用连电脑就能看日志 |
| 数据操作 | 清除缓存/重置数据 | 快速重置到初始状态 |
| 快捷入口 | 跳转到任意页面 | 不用一步步点进去 |
实现思路:
- 用一个全局的 Service 管理调试状态
- 用 AppStorage 存调试配置
- 入口可以放在设置页的某个隐蔽位置(比如连点版本号 5 次)
- 只在 debug 包显示,release 包隐藏
8.4.2 调试开关
除了调试面板,还可以加一些"隐形"的调试开关,在特定条件下触发。
常见的调试开关:
| 开关 | 触发方式 | 作用 |
|---|---|---|
| 调试面板入口 | 连点版本号 5 次 | 打开调试面板 |
| 性能悬浮窗 | 三指下滑 | 显示 FPS/CPU/内存 悬浮窗 |
| 日志导出 | 摇一摇 | 导出最近的日志文件 |
| 布局边界 | 双击标题栏 | 显示所有组件的边界线 |
| Mock 数据切换 | 长按列表项 3 秒 | 切换 Mock/真实数据 |
「民族图鉴」中的实现示例:
// 调试服务 - 管理各种调试开关
export class DebugService {
private static instance: DebugService;
private clickCount: number = 0;
private clickTimer: number = -1;
// 连点版本号 5 次打开调试面板
onVersionClicked(): boolean {
this.clickCount++;
if (this.clickTimer !== -1) {
clearTimeout(this.clickTimer);
}
if (this.clickCount >= 5) {
this.clickCount = 0;
return true; // 触发打开调试面板
}
this.clickTimer = setTimeout(() => {
this.clickCount = 0;
}, 1000);
return false;
}
// 检查是否为调试版本
isDebug(): boolean {
// BuildConfig.DEBUG 或其他方式判断
return true; // 示例,实际根据构建配置判断
}
}
8.4.3 埋点调试工具
在关键位置埋点,记录用户操作和系统状态,出问题后可以"回放"。
埋点调试的核心功能:
| 功能 | 说明 |
|---|---|
| 用户行为埋点 | 页面进入/退出、按钮点击、滑动操作 |
| 系统状态埋点 | 内存、CPU、网络状态变化 |
| 网络请求埋点 | 请求开始/结束/失败、耗时、参数 |
| 错误埋点 | 异常、崩溃、错误信息 |
| 本地存储 | 把埋点数据存在本地 |
| 上传功能 | 出问题后上传埋点数据 |
| 可视化回放 | 用工具回放用户操作路径 |
「民族图鉴」中的埋点示例:
// 埋点类型定义
interface TrackEvent {
timestamp: number;
type: 'page' | 'click' | 'network' | 'error' | 'state';
name: string;
data?: Record<string, any>;
}
// 埋点服务
export class TrackingService {
private events: TrackEvent[] = [];
private maxEvents: number = 1000;
// 页面埋点
trackPage(pageName: string, action: 'enter' | 'exit'): void {
this.addEvent({
timestamp: Date.now(),
type: 'page',
name: `${pageName}_${action}`,
});
}
// 点击埋点
trackClick(elementName: string, data?: Record<string, any>): void {
this.addEvent({
timestamp: Date.now(),
type: 'click',
name: elementName,
data,
});
}
// 网络埋点
trackNetwork(url: string, status: 'start' | 'success' | 'error', duration?: number): void {
this.addEvent({
timestamp: Date.now(),
type: 'network',
name: url,
data: { status, duration },
});
}
// 错误埋点
trackError(error: Error, context?: Record<string, any>): void {
this.addEvent({
timestamp: Date.now(),
type: 'error',
name: error.message,
data: { stack: error.stack, ...context },
});
}
private addEvent(event: TrackEvent): void {
this.events.push(event);
// 超过最大数量,丢弃最早的
if (this.events.length > this.maxEvents) {
this.events.shift();
}
}
// 导出埋点数据
exportEvents(): string {
return JSON.stringify(this.events, null, 2);
}
}
埋点调试的价值:
- 偶现问题排查:用户反馈偶现 bug,但你复现不了?有埋点数据就能知道用户是怎么操作的
- 线上问题定位:线上出了问题,看埋点数据就能知道用户操作路径
- 性能分析:通过埋点数据计算页面加载时间、接口耗时等
- 用户行为分析:了解用户是怎么用产品的
🎯 工具的价值:
工具不能代替思考,但好的工具能让你把时间花在"思考问题"上,而不是"收集信息"上。
日志工具帮你快速收集信息,断点工具帮你深入代码,性能工具帮你找到瓶颈——各有所长,组合使用效果最好。
更重要的是,要根据自己的项目特点,打造适合自己的调试工具链。自研的调试工具虽然简单,但往往最贴合项目需求,效率最高。
九、调试的心态与习惯
9.1 保持冷静
遇到 bug 别慌、别乱试、别瞎改。
乱试一通,可能碰巧"修好"了,但你不知道为什么好的——下次还会再犯。
慢慢来,比较快。按流程一步步来,虽然看似慢,但其实效率更高。
9.2 写可调试的代码
调试是不可避免的,但可以让调试更容易:
- 代码清晰:逻辑清楚的代码,bug 少,就算有也容易找
- 小函数:函数越短,越容易看出问题
- 纯函数:相同输入总是相同输出,好测好调
- 错误信息全:报错信息说清楚"哪错了、为什么错、建议怎么改"
- 类型安全:类型错误编译时就能发现,不用等运行时
9.3 向别人求助的正确姿势
实在搞不定,要向别人求助。怎么求助效率最高?
坏的求助:
“我的页面白屏了,帮我看看怎么回事?”
别人不知道你做了什么、改了什么、什么环境下出的。只能猜。
好的求助:
“我在做民族列表页,点击某一项跳详情的时候白屏了。
设备是 Mate 60,系统版本 4.2。
必现,操作路径是:首页 → 民族列表 → 点击第一项。
控制台没有报错。
我试了写死静态数据也白屏,注释掉详情组件就好了,所以问题应该在详情组件里。
代码在 feature/detail 分支,这是关键代码片段…”
别人一看就知道:
- 问题是什么
- 怎么复现
- 你已经做了哪些排查
- 你的猜测是什么
这样别人帮你的效率就高多了。
🎯 黄金法则:求助的时候,把别人需要知道的信息一次性说清楚。别让别人一句一句问。
十、小结
| 主题 | 核心要点 | 难度 |
|---|---|---|
| 调试方法论 | 复现 → 缩小范围 → 假设验证 → 修复 → 复盘 | ⭐⭐⭐ |
| 二分法实战 | 民族详情页白屏案例,7步定位根因 | ⭐⭐ |
| 日志调试法 | 日志级别、格式规范、关键埋点、脱敏、统一封装 | ⭐⭐⭐ |
| 断点调试法 | 普通/条件/日志/异常断点、调用栈分析、变量监视 | ⭐⭐⭐ |
| 调试器 | DevEco Studio 调试器的完整使用指南 | ⭐⭐⭐ |
| 常见问题 | 白屏、列表不刷新、内存泄漏、网络失败、布局错乱 | ⭐⭐⭐⭐ |
| 常见坑 | 异步顺序、this指向、类型断言、状态更新、引用 | ⭐⭐⭐ |
| Bug 分类 | 严重程度、类型、优先级判断 | ⭐⭐ |
| 常见 Bug 库 | 「民族图鉴」十大常见 Bug 与解决方案 | ⭐⭐⭐ |
| 调试工具大全 | 系统工具、IDE内置工具、第三方工具、自研工具 | ⭐⭐⭐ |
| 调试心态 | 保持冷静、写可调试的代码、高效求助 | ⭐⭐ |
调试能力是程序员的"硬功夫"——它不是看一两篇文章就能学会的,要在实践中不断磨练。
但只要你掌握了科学的方法,遇到问题不再慌、不乱试,而是有条有理地一步步排查,调试效率就能提升一大截。
希望这篇的内容,能帮你建立系统化的调试思维,也能在「民族图鉴」项目中少踩坑、快速排错。
下一篇,也是本系列的最后一篇——我们来做一个全书总结,回顾所有的最佳实践,并聊聊项目未来的演进路线。
更多推荐



所有评论(0)