在这里插入图片描述

一、为什么要谈调试

写代码的时间,可能只占 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"。

这就是二分法的威力——每一步都把范围缩小一半,几步就能精准定位。

🎯 二分法的核心心法

  1. 先大后小:先分大块,再分小块
  2. 控制变量:每次只变一个因素,确保知道是哪部分导致的
  3. 大胆假设,小心验证:猜可能是哪的问题,然后用实验验证
  4. 记录过程:每一步的结果记下来,别到后面忘了哪步有问题哪步没问题

2.5 第三步:提出假设,验证假设

缩小到一定范围了,就可以开始猜了。

但猜不是瞎猜——要提出可验证的假设

假设:这个 bug 是因为 xxx 导致的
实验:如果我做 yyy 操作,应该能看到 zzz 结果
结果:
  - 符合假设 → 假设可能是对的,继续验证
  - 不符合 → 假设错了,换一个

好的假设 vs 坏的假设

好的假设(可验证) 坏的假设(玄学)
可能是接口返回数据格式变了 可能是人品问题
可能是这个版本的系统有兼容问题 可能是编译器有 bug
可能是异步顺序错了 可能是代码突然坏了
可能是缓存没清 重启试试?

⚠️ 注意:不要一开始就怀疑编译器、怀疑系统、怀疑框架。99.9% 的情况下,bug 在你自己的代码里。先从自己的代码查起,实在找不到再考虑其他因素。

2.6 第四步:修复与验证

找到原因了,修复就简单了。

但是改完一定要验证

  1. 原来的问题修好了吗?
  2. 有没有引入新的问题?
  3. 相关的功能有没有受影响?

改完就跑,大概率会改出第二个 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);

好日志的三要素

  1. 在哪:哪个模块/哪个页面
  2. 发生了什么:做什么操作
  3. 结果如何:成功/失败、关键数据
3.1.3 关键埋点位置

不是每一行都要打日志——在关键位置打就够了。

建议打日志的位置

位置 说明
函数入口 打参数,知道"用什么输入调用的"
函数出口 打返回值,知道"返回了什么"
分支处 if/else 的每个分支,知道走了哪条路
异步回调 Promise.then / catch,知道异步成功还是失败
错误捕获 try-catch 的 catch 里,打错误详情
关键状态变化 数据加载、用户登录、主题切换等
3.1.4 日志调试的技巧
  1. 二分法打日志:不知道问题出在哪?先在中间打个日志,看有没有执行到。再继续二分。

  2. 打印对象要小心

    // ❌ 不好:对象引用,展开时可能已经变了
    console.log(user);
    user.name = '新名字';
    
    // ✅ 好:打快照,当时的值是什么就是什么
    console.log(JSON.parse(JSON.stringify(user)));
    
  3. 用 console.table 打数组:列表数据用 table 展示,更直观。

  4. 用 console.time 计时

    console.time('加载数据');
    await loadData();
    console.timeEnd('加载数据'); // 输出:加载数据: 123ms
    
  5. 调试完记得删:不要让生产环境全是调试日志。

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;
  }
}

统一日志格式的好处

  1. 格式一致:所有日志都是同一个格式,看着舒服
  2. 方便过滤:按 tag、按级别、按 domain 过滤
  3. 容易替换:以后想换日志库,只改这一个文件
  4. 方便扩展:要加日志上报、日志脱敏,都在这加
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 断点调试的技巧
  1. 不要一行行单步

    • 知道大概范围,直接在那打断点,然后 Resume 过去
    • Step Over / Step Into / Step Out 配合用,不要只会按下一步
  2. 用调用栈回溯

    • 停下来了,但不知道怎么走到这的?看调用栈
    • 点击调用栈里的每一层,就能看到当时的变量值
  3. 修改变量值做实验

    • 停下来后,可以手动修改变量的值
    • 想试试"如果这个值是另一个会怎样",直接改了继续跑
  4. 禁用断点

    • 断点太多了?不想删,先禁用,后面可能还要用
3.2.6 断点调试高级实战

上面讲了各种断点的基本概念,接下来我们结合「民族图鉴」项目的实际场景,看看这些高级断点怎么用。

3.2.6.1 条件断点实战

场景:调试收藏功能,发现只有"回族"这个民族的收藏状态不对,其他民族都正常。

问题:如果在 toggleFavoriteEthnic 方法里打普通断点,每次点收藏都会停下来,要停很多次才能到"回族"。

解决方案:用条件断点,只有当 ethnicId === 'hui' 时才断住。

操作步骤

  1. toggleFavoriteEthnic 方法第一行打个普通断点
  2. 右键断点,选择"Condition"(条件)
  3. 输入条件表达式:ethnicId === 'hui'
  4. 确认,断点图标上会出现一个问号

这样,只有当收藏的是"回族"时,断点才会触发,其他民族直接跳过。

「民族图鉴」中的其他条件断点场景

场景 条件表达式 说明
列表第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)——不停下来,只打印日志。

操作步骤

  1. notifyListeners 方法里打个普通断点
  2. 右键断点,选择"Log breakpoint"(日志断点)
  3. 输入要打印的消息,比如:
    播放进度: {this.currentPosition}s / {currentMusic.durationSeconds}s
    
  4. 取消勾选"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 偶现崩溃,但不知道在哪报的错,控制台只看到一个模糊的错误信息。

问题:报错信息不明确,不知道是哪行代码抛的异常,也不知道调用路径是什么。

解决方案:用异常断点——只要抛出异常,不管有没有被捕获,都在抛出的那一行停下来。

操作步骤

  1. 打开 Debug 面板的 Breakpoints 标签页
  2. 点击 “+” 号,选择 “JavaScript Exception Breakpoints”
  3. 或者勾选 “Any Exception”(任何异常)
  4. 选择要捕获的类型:
    • 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),包含:

  • 函数名
  • 所在文件
  • 行号
  • 当时的变量值

怎么看调用栈

  1. 断点停下来后,看 Debug 面板的 “Frames”(或者 “Call Stack”)标签
  2. 从上到下是当前函数 → 调用者 → 调用者的调用者…
  3. 点击某一层,就能跳转到对应的代码行
  4. 同时 Variables 面板会显示那一层的变量值

「民族图鉴」中的调用栈实战

案例:收藏状态不同步

问题:详情页收藏了,返回列表页,收藏状态没更新。

我们在列表页的 build 方法里打个断点,看看页面是怎么刷新的:

Frames:
  ├─ EthnicListPage.build()           ← 当前停在这里
  ├─ ViewPU.createOrUpdateNode()
  ├─ ViewPU.updateComponent()
  ├─ ViewPU.forceUpdate()
  ├─ AppStorage.notifyListeners()
  ├─ StorageService.toggleFavoriteEthnic()  ← 收藏操作触发了更新
  └─ EthnicDetailPage.toggleFavorite()      ← 详情页点了收藏按钮

从调用栈可以看到:

  1. 用户在详情页点了收藏按钮
  2. 调用了 StorageService 的 toggleFavoriteEthnic
  3. StorageService 改了 AppStorage 里的值
  4. AppStorage 通知监听者更新
  5. 列表页收到通知,重新 build

但为什么列表页的收藏状态没更新呢?继续看变量,发现列表页用的是自己的 @State 变量,没有从 AppStorage 读——这就是问题所在。

调用栈分析的几个要点

  1. 从下往上看:最下面的是最早的调用,最上面的是当前函数
  2. 逐层点击:每一层都能看到当时的变量值
  3. 找业务代码:中间很多是框架内部的,找到你自己写的代码层
  4. 看参数变化:同一函数被调用多次,每次参数可能不同

💡 调用栈的价值
调用栈能告诉你"代码是怎么走到这的"——这是理解复杂逻辑的最好方式。
特别是在看别人的代码、或者看自己很久以前写的代码时,调用栈能快速帮你理清执行路径。

3.2.6.5 变量监视实战

场景:调试一个复杂功能,有十几个变量你都关心,但每次都要在 Variables 面板里找,太麻烦。

问题:Variables 面板里变量太多,关键变量被淹没在里面,每次都要找半天。

解决方案:用变量监视(Watches / Watch Expressions)——把你关心的变量加到监视列表里,它们的值会一直显示在最上面。

怎么用

  1. 打开 Debug 面板的 “Watches” 标签
  2. 点击 “+” 号,输入要监视的表达式
  3. 表达式的值会实时更新
  4. 可以随时添加、删除、修改

「民族图鉴」中的变量监视实战

调试「民族图鉴」的音乐播放功能时,我们可以监视这些变量:

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 计算耗时

条件断点 + 变量监视 = 黄金组合

调试复杂问题时,最好的组合是:

  1. 用条件断点在正确的时机停下来
  2. 用变量监视看关键状态
  3. 用调用栈看怎么走到这的
  4. 用单步执行看下一步会发生什么

这四件套配合使用,大部分 bug 都能很快定位。

🎯 断点调试的核心心法

  1. 先猜后验:先猜问题可能在哪,再用断点验证
  2. 善用条件:用条件断点过滤掉无关的调用
  3. 用好栈帧:调用栈是理解执行路径的最好工具
  4. 监视关键变量:把关心的变量加到 Watches,一目了然
  5. 大胆实验:停下来后可以修改变量值做实验,验证你的猜测

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.id
  • favoriteIds
  • isFavorite

这样收藏状态的变化一目了然。

技巧四:异常断点定位

不知道哪行报错?开启异常断点:

  1. 打开 Breakpoints 面板
  2. 勾选 “Any Exception”
  3. 选择"捕获的异常"和"未捕获的异常"

这样只要抛出异常,调试器就会在抛出的那一行停下来,直接定位问题。

技巧五:日志断点替代 console.log

想打日志但又不想改代码?用日志断点:

  1. 在想打日志的行右键断点
  2. 选择 “Log breakpoint”
  3. 输入要打印的消息,比如:"加载民族数据: {list.length} 条"
  4. 取消勾选 “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)
  • 启动时间
  • 网络请求

性能问题的排查思路

  1. 先测,找到瓶颈
  2. 分析瓶颈原因
  3. 优化
  4. 再测,确认有效

四、常见问题与排查思路

4.1 页面白屏 / 黑屏

可能的原因

原因 排查方法
JS 报错了 看控制台有没有报错
数据没加载出来 看接口返回了没
组件渲染出错 二分法注释组件,看哪个导致的
布局问题(高度为0) 给根节点加个背景色,看有没有渲染出来
路由错了 看路由配置对不对

排查步骤

  1. 先看控制台有没有报错
  2. 写死静态数据,看能不能显示
  3. 二分法注释,定位到具体组件
  4. 检查布局参数(高度、宽度有没有设)

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
事件订阅没取消 全局事件、系统事件有没有取消订阅
闭包引用 回调里引用了页面对象,页面销毁了回调还在
大图不释放 长列表滚动,图片内存一直涨

排查方法

  1. 用内存分析工具看内存曲线
  2. 反复进出同一个页面,看内存是不是只升不降
  3. 堆快照对比,看什么对象泄漏了
  4. 查代码,找上面说的几个常见泄漏点

4.4 网络请求失败

症状:接口调不通、返回错误、超时。

排查步骤

1. 看请求发出去了吗?
   │
   ├─ 没发出去 → 检查 URL、参数、网络状态
   │
   └─ 发出去了 → 看状态码是什么?
        │
        ├─ 4xx(客户端错误)
        │   ├─ 400 → 参数不对
        │   ├─ 401 → 没登录 / Token 过期
        │   ├─ 403 → 没权限
        │   └─ 404 → 地址错了
        │
        ├─ 5xx(服务器错误)
        │   └─ 后端的锅,找后端
        │
        └─ 超时 / 失败 → 网络问题、跨域、证书问题

工具:用抓包工具(Charles、Fiddler、DevEco 自带的网络面板)看请求和响应的详细内容。

4.5 样式不对 / 布局错乱

症状:UI 显示和预期不一样。

常见原因

原因 表现
宽高没设对 组件不显示 / 太大 / 太小
对齐方式错了 位置不对
Flex 布局属性错了 排列不符合预期
定位问题 绝对/相对定位搞错了
权重(layoutWeight) 没按比例分配空间
优先级问题 多个样式冲突,没用上预期的

排查技巧

  1. 给组件加背景色(红的绿的蓝的),看它到底占了多大地方
  2. 用 DevEco 的布局检查器看每个组件的尺寸和位置
  3. 二分法:注释掉一半代码,看问题还在不在
  4. 简化:把问题简化成最小可复现的例子

4.6 偶现问题

最难搞的 bug——偶现的、神出鬼没的。

排查思路

  1. 找规律:什么时候出现?什么操作后出现?什么设备上出现?
  2. 加日志:在可能相关的地方打详细日志,出问题后回看日志
  3. 考虑竞态:是不是两个异步操作的顺序不确定?
  4. 考虑时序:是不是时机问题(初始化还没完成就调用了)?
  5. 考虑数据:是不是特定数据才会触发?
  6. 压力测试:反复操作,提高出现概率
  7. 断言 + 崩溃捕获:在怀疑的地方加断言,出问题直接崩在那

💡 经验之谈:偶现的 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 严不严重,看三个维度:

  1. 影响范围:多少用户会遇到?
  2. 影响程度:对用户的影响有多大?能不能用?
  3. 有没有替代方案:用户能不能绕过去?
影响大 + 范围广 + 没替代方案 = 高优先级
影响小 + 范围小 + 有替代方案 = 低优先级

🎯 Bug 优先级的本质

不是"这个 bug 严不严重",而是"先修哪个 ROI 最高"——花同样的时间,修哪个能减少最多的用户痛苦。

七、「民族图鉴」常见 Bug 库

说了这么多理论,回到我们的项目。「民族图鉴」开发过程中,常见的 bug 有哪些?怎么修的?

这是我们的"避坑指南"——别人踩过的坑,你就别再踩了。

7.1 页面相关

Bug 1:详情页返回后列表位置丢失

症状:从列表页点进详情页,返回来,列表又回到顶部了。

原因:页面销毁重建了,滚动位置没保存。

解决

  • 用页面级状态管理保存滚动位置
  • 或者用 Navigation 的页面栈,页面不会销毁
  • 返回时恢复滚动位置

预防:列表页进入详情页的场景,都要考虑滚动位置恢复。

Bug 2:白屏/黑屏

症状:打开页面什么都没有,一片空白。

常见原因

  1. JS 报错了 → 看控制台
  2. 数据没加载出来 → 看接口返回
  3. 布局高度为 0 → 给根节点加背景色
  4. 条件渲染写反了 → 检查 if/else

排查步骤

  1. 先看控制台有没有报错
  2. 写死静态数据试试
  3. 二分法注释组件
  4. 检查布局参数

7.2 列表相关

Bug 3:列表不刷新

症状:数据改了,列表没变。

常见原因(按概率排序)

  1. 直接修改数组/对象属性,没替换整个引用
  2. key 用了 index,顺序变了检测不到
  3. 变量不是 @State 装饰的
  4. 改的是 props,不是组件自己的状态

解决:参考前面"列表不刷新"那一节。

Bug 4:列表滑动卡顿

症状:快速滑动列表的时候,一卡一卡的。

常见原因

  1. 没用 LazyForEach,一次性渲染了所有项
  2. 列表项太复杂,嵌套层级太深
  3. 图片太大,解码耗时
  4. 滚动的时候做了很多计算

解决

  1. 用 LazyForEach 懒加载
  2. 简化列表项结构
  3. 图片用合适尺寸 + 懒加载
  4. 滚动时暂停非关键操作

7.3 状态相关

Bug 5:收藏状态不同步

症状:详情页收藏了,返回列表页,那颗心还是空的。

原因:列表页和详情页各存了一份数据,改了一个另一个不知道。

解决

  • 用全局状态管理(Store/Service),数据只有一份
  • 或者返回时刷新列表
  • 或者用事件通知列表页更新

最佳实践:收藏这种全局状态,放在 Service 里统一管理,不要每个页面各存一份。

Bug 6:主题切换后部分页面没变化

症状:切换主题,当前页面变了,返回上一页,上一页还是旧主题。

原因:上一页的主题状态不是响应式的,或者页面没销毁重建。

解决

  • 主题状态放在全局,用 @Provide/@Consume 或者 Service + 事件
  • 页面监听主题变化,变化时刷新
  • 或者用系统的主题能力,自动适配

7.4 网络相关

Bug 7:弱网下白屏

症状:网络慢的时候,页面一直白屏,什么提示都没有。

原因:只有"正常态",没有"加载中"和"错误态"。

解决

  • 加 Loading 状态,加载中显示骨架屏或 Loading
  • 加错误状态,加载失败显示错误提示 + 重试按钮
  • 有缓存的话,先显示缓存,再刷新

原则:每个有网络请求的页面,都要有三态:加载中、空数据、错误。

Bug 8:图片加载失败

症状:有些图片显示不出来,一片空白。

常见原因

  1. URL 错了
  2. 网络问题
  3. 图片格式不支持
  4. 图片太大,解码失败

解决

  • 图片组件加错误占位图
  • 失败了可以重试
  • 用合适尺寸和格式的图片
  • 加日志,看失败原因

7.5 其他常见坑

Bug 9:TTS 播着播着就停了

症状:长文本朗读,读一半就停了。

原因:页面销毁了,或者 TTS 引擎资源被回收了。

解决

  • TTS 放在 Service 里,不要和页面生命周期绑定
  • 长文本分段朗读
  • 页面销毁时不要停,或者用后台服务
Bug 10:收藏数据丢失

症状:收藏了很多民族,过几天打开,收藏没了。

常见原因

  1. 用了内存存储,App 被杀就没了 → 要用持久化存储
  2. Key 写错了,读写不是同一个 Key
  3. 存储操作失败了(但没捕获异常)
  4. 清缓存把数据清掉了

解决

  • 用 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(内存溢出)问题定位
  • 大图内存优化

排查内存泄漏的一般步骤

  1. 进入页面前,记录初始内存
  2. 进入页面,操作一番
  3. 退出页面,手动触发 GC
  4. 看内存有没有降回来
  5. 如果没降,抓堆快照,分析什么对象泄漏了
  6. 看引用链,找到泄漏原因
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 写可调试的代码

调试是不可避免的,但可以让调试更容易:

  1. 代码清晰:逻辑清楚的代码,bug 少,就算有也容易找
  2. 小函数:函数越短,越容易看出问题
  3. 纯函数:相同输入总是相同输出,好测好调
  4. 错误信息全:报错信息说清楚"哪错了、为什么错、建议怎么改"
  5. 类型安全:类型错误编译时就能发现,不用等运行时

9.3 向别人求助的正确姿势

实在搞不定,要向别人求助。怎么求助效率最高?

坏的求助

“我的页面白屏了,帮我看看怎么回事?”

别人不知道你做了什么、改了什么、什么环境下出的。只能猜。

好的求助

“我在做民族列表页,点击某一项跳详情的时候白屏了。
设备是 Mate 60,系统版本 4.2。
必现,操作路径是:首页 → 民族列表 → 点击第一项。
控制台没有报错。
我试了写死静态数据也白屏,注释掉详情组件就好了,所以问题应该在详情组件里。
代码在 feature/detail 分支,这是关键代码片段…”

别人一看就知道:

  • 问题是什么
  • 怎么复现
  • 你已经做了哪些排查
  • 你的猜测是什么

这样别人帮你的效率就高多了。

🎯 黄金法则:求助的时候,把别人需要知道的信息一次性说清楚。别让别人一句一句问。

十、小结

主题 核心要点 难度
调试方法论 复现 → 缩小范围 → 假设验证 → 修复 → 复盘 ⭐⭐⭐
二分法实战 民族详情页白屏案例,7步定位根因 ⭐⭐
日志调试法 日志级别、格式规范、关键埋点、脱敏、统一封装 ⭐⭐⭐
断点调试法 普通/条件/日志/异常断点、调用栈分析、变量监视 ⭐⭐⭐
调试器 DevEco Studio 调试器的完整使用指南 ⭐⭐⭐
常见问题 白屏、列表不刷新、内存泄漏、网络失败、布局错乱 ⭐⭐⭐⭐
常见坑 异步顺序、this指向、类型断言、状态更新、引用 ⭐⭐⭐
Bug 分类 严重程度、类型、优先级判断 ⭐⭐
常见 Bug 库 「民族图鉴」十大常见 Bug 与解决方案 ⭐⭐⭐
调试工具大全 系统工具、IDE内置工具、第三方工具、自研工具 ⭐⭐⭐
调试心态 保持冷静、写可调试的代码、高效求助 ⭐⭐

调试能力是程序员的"硬功夫"——它不是看一两篇文章就能学会的,要在实践中不断磨练。

但只要你掌握了科学的方法,遇到问题不再慌、不乱试,而是有条有理地一步步排查,调试效率就能提升一大截。

希望这篇的内容,能帮你建立系统化的调试思维,也能在「民族图鉴」项目中少踩坑、快速排错。

下一篇,也是本系列的最后一篇——我们来做一个全书总结,回顾所有的最佳实践,并聊聊项目未来的演进路线。

Logo

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

更多推荐