本原创文章帖发布在华为开发者联盟社区,欢迎开发者前往访问评论交流,更多与该内容相关讨论,请点击原帖查看:

如何通过日志分析定位onWillChange深拷贝引发的GC卡死-华为开发者话题 | 华为开发者联盟

一、案例背景

       某应用使用 RichEditor 组件的 StyledString 模式,用户在输入框中每打一个字,UI 就会偶发冻结 5 秒以上,WatchDog 上报 FREEZE 事件。

       开发者拿到 freeze 堆栈,长这样:

#00 libace_engine.so!~SpanItem()
#01 libace_engine.so!std::vector<RefPtr<SpanItem>>::clear()
#02 libace_engine.so!~MutableSpanString()
#03 ... (GC finalization 上下文)

       第一反应:堆栈停在 ~SpanItem(),是不是 SpanItem 析构有 bug?

       真相:SpanItem 析构本身没 bug。根因是开发者在 onWillChange 回调里调用了 20 次 getStyledString(),堆积了大量临时副本,GC 回收时析构链重入 JS 引擎死锁。

       这个案例讲的就是:如何从 freeze 现象一路追到这 20 次调用。

二、关键认知:getStyledString 是"拍快照"不是"拿引用"

       这是理解整个案例的前提。

// 开发者直觉上以为:
let s = this.controller.getStyledString();  // 拿到内部对象的引用?
s.setString("hack");                         // 以为能改内部状态?
// 实际行为:
// getStyledString() 每次都"拍一张快照"——深拷贝整个 StyledString
// 返回的是全新副本,改它不影响 RichEditor 内部

设计目标

为什么这么设计

状态隔离

开发者拿到的副本可以随便改,不会意外破坏 RichEditor 内部状态

数据一致性

内部状态只能通过 setStyledString 等显式接口修改

代价

每次调用 = 1 个新 MutableSpanString + N 个 SpanItem 深拷贝 + N×K 个 JS 回调句柄副本

       一句话记忆:getStyledString() 不是"获取引用",是"获取快照"。调 N 次 = 拍 N 张快照 = N 份临时副本。

三、问题代码(案发现场)

       开发者在 onWillChange 中为了拿三个不同的信息,调用了三次 getStyledString(),外面又套了一个循环:

// ❌ 问题代码
onWillChange: (value: StyledStringChangeValue) => {
    for (let i = 0; i < 7; i++) {                          // 循环 7 次
        let styled = this.controller.getStyledString();    // 第 1 次深拷贝
        let text = this.controller.getStyledString().getString();   // 第 2 次
        let length = this.controller.getStyledString().length;      // 第 3 次
        this.validate(styled, i);
    }
    return true;
}

       算一笔账:一次文本变化 = 7 次循环 × 3 次调用 = 21 次深拷贝。

       假设 StyledString 里有 10 个 GestureSpan,每个带 onClick 回调:

   • 每次调用产生:10 个 SpanItem + 10 个 CopyableGlobal(JS 句柄)

   • 21 次调用产生:210 个 SpanItem + 210 个 JS 句柄,全部等待 GC 回收

   • GC 回收时,CopyableGlobal::Reset() 要重入 JS 引擎,而 GC 本身已经持有了 JS 引擎锁 → 死锁 → UI Freeze

四、修复方案

4.1 核心原则

       一次文本变化只调用一次 getStyledString(),结果在本次回调内复用。

4.2 修复后代码

// ✅ 修复后
onWillChange: (value: StyledStringChangeValue) => {
    let styled = this.controller.getStyledString();  // 仅 1 次深拷贝
    let text = styled.getString();                   // 复用
    let length = styled.length;                      // 复用
    for (let i = 0; i < 7; i++) {
        this.validate(styled, i);                    // 复用同一份
    }
    return true;
}

       调用次数从 21 降到 1,临时对象从 210 份降到 10 份,GC 压力下降 95%。

4.3 更优方案:用回调参数替代

       其实本案例根本不需要调 getStyledString()——onWillChange 的回调参数 value 已经带了变化信息:

// ✅✅ 更优:完全不用 getStyledString
onWillChange: (value: StyledStringChangeValue) => {
    let range = value.range;                            // 无拷贝
    let newText = value.replacementString.getString();  // 无拷贝
    // 如果业务只关心变化范围和替换内容,这就够了
    return true;
}

       优先级:能用回调参数就别用 getStyledString();必须用就只调一次。

五、一句话总结

       getStyledString() 是拍快照不是拿引用。一次文本变化只拍一次,能拿回调参数就别拍。

       定位手段:hilog -T C0391A | grep -E "BeforeSSChange|GetStyledString|AfterSSChange",看 Before 和 After 之间的调用数,>5 就是高风险。

Logo

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

更多推荐