稳定性编码规范
华为官方稳定性编码规范已覆盖 NDK 开发、ArkTS 侧编码、Node‑API 开发、C++ 编码、libuv 使用与案例、易错 API 使用等通用内容。
https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-stability-coding-standard
本文聚焦 RNOH / React Native 场景,结合历史稳定性修复、框架代码模式和稳定性文档,对 RN 框架侧编码规范进行进一步补充和细化。 结合历史问题可以看到,框架中的高频稳定性问题主要集中在以下几个方向:
- 生命周期结束后回调没有及时解绑,导致对象已销毁但回调仍在执行。
- 多线程共享状态访问缺少同步保护,导致死锁、卡死或低概率崩溃。
- 初始化和销毁阶段的状态边界不清晰,导致空指针、UAF 或断言失败。
- JS、ArkTS 与 C++ 跨层调用缺少空值保护、异常边界和线程约束。
- 平台兼容、混淆和模块导出方式处理不当,导致 release 包或特定系统版本下崩溃。
因此,编码时应围绕内存与对象安全、生命周期与销毁清理、并发与线程、异常与边界处理、平台与模块兼容五个维度建立稳定性规范。
1. 内存与对象安全
1.1 异步回调不得持有对象强引用
异步回调,包括 VSync、Display、Timer、HTTP、NAPI 等,不得直接捕获 this 或持有宿主对象的强引用。回调与对象生命周期必须解耦,建议统一使用 weak_ptr 弱引用,在回调执行时先 lock 并判空,如果对象已销毁则立即返回。
如果回调只需要读取少量只读数据,例如 URI、路径、配置快照、事件参数等,应优先按值拷贝到回调闭包中,而不是继续引用宿主对象内部成员。对必须回到对象实例上执行的逻辑,统一使用 weak_ptr 弱引用并在回调入口判空。
示例:
// 正确
auto weakSelf = weak_from_this();
scheduleCallback([weakSelf]() {
auto self = weakSelf.lock();
if (!self) return;
self->doWork();
});
// 禁止
scheduleCallback([this]() {
this->doWork();
});
如果异步任务执行时对象已经销毁,而回调仍持有 this,就会形成典型的 UAF 或悬空引用访问。历史上,AnimatedTurboModule、ImageComponentInstance、EventBeat、UI tick 等路径都曾出现过这类问题。
历史案例:
1.2 共享资源多线程访问必须加锁
所有会被多个线程并发访问的共享容器、缓存和状态字段,都必须做同步保护。只保护写不保护读,或者只在部分调用链加锁,都会留下数据竞争窗口。
典型高风险对象包括:
- inflightAnimations_ 这类全局动画状态。
- TextMeasureRegistry、FontRegistry 这类跨线程读写注册表。
- listeners、callbacks、registry、cache 等共享集合。
当对象同时可能被 UI 线程、JS 线程、主线程或 Worker 线程访问时,必须从设计上明确访问模型,不能依赖“实际运行时大概率不会冲突”的假设。
示例:
// 正确
std::lock_guard<std::mutex> lock(mutex_);
listeners_.push_back(listener);
// 错误
listeners_.push_back(listener);
历史案例:
- 多个 Surface 并发访问 inflightAnimations_ 数据竞争导致崩溃
- TextMeasureRegistry 线程安全问题
- Marker listeners 多线程竞态导致 crash
1.3 工厂方法和 create 接口返回值必须检查
所有工厂方法、createNode()、lookup、find、getDescriptor()、optional 风格返回值,在使用前都必须做有效性检查。不能假设返回值一定存在,也不能在收到空对象后继续执行后续逻辑。
典型风险包括:
- NodeApi 或 ArkUINodeContext 未就绪时 createNode() 返回空。
- registry 查询不到 descriptor 时继续向下执行。
- lookup 返回 null 或 optional 为空时直接解引用。
修复这类问题的关键不是在崩溃点补 if,而是在对象创建和依赖获取入口统一建立失败分支和兜底逻辑。
示例:
// 正确
auto node = createNode();
if (!node) {
return nullptr;
}
node->mount();
// 错误
auto node = createNode();
node->mount();
历史案例:
1.4 指针、成员状态和上下文对象使用前必须判空
以下对象在历史问题中反复出现空指针风险,必须在使用前显式校验:
- 裸指针。
- weak_ptr::lock() 得到的对象。
- m_eventEmitter、m_props、m_state、m_surface、m_scheduler 等组件成员。
- 跨层返回的可选对象、回调参数和状态上下文。
尤其是在生命周期切换、异步回调晚到、页面销毁后事件继续触发、窗口变化等场景下,原本在正常路径下非空的对象,在边界情况下很容易变成空值。
示例:
// 正确
if (m_eventEmitter != nullptr) {
m_eventEmitter->dispatchEvent(event);
}
// 错误
m_eventEmitter->dispatchEvent(event);
历史案例:
1.5 避免 shared_ptr 循环引用
如果对象 A 持有对象 B 的 shared_ptr,而对象 B 又反向持有对象 A 的 shared_ptr,就会形成循环引用,导致对象长期无法释放,最终引发内存异常或资源泄漏。此时应将其中一侧改为 weak_ptr。
对于 listener、callback owner、上下文对象和 manager 之间的双向引用,代码评审时必须重点检查是否存在这类问题。
示例:
// 正确
class Child {
std::weak_ptr<Owner> owner_;
};
// 错误
class Child {
std::shared_ptr<Owner> owner_;
};
历史案例:
2. 生命周期与销毁清理
2.1 析构函数或 destroy() 必须停止所有异步来源
对象进入销毁阶段后,必须主动停止所有可能继续驱动该对象的外部回调源,而不能仅依赖对象被释放。需要重点检查的来源包括:
- VSync 或 Display 回调注销。
- Timer 取消,例如 cancelTimer、stopTimer。
- EventBeat、动画帧驱动、onUITick 等解绑。
- HTTP 请求中止或完成回调清理。
- observer、listener、event emitter 的反注册。
原则上,谁注册,谁释放;在哪一层注册,就必须在同一生命周期责任边界上显式清理。
示例:
// 正确
void destroy() {
stopTimer(timerId_);
unsubscribeVSync();
}
// 错误
void destroy() {
released_ = true;
}
历史案例:
2.2 销毁顺序必须与依赖链一致
销毁阶段不能只关注“对象是否析构”,还必须保证依赖链顺序正确。一般原则是:
- 先停止依赖上游对象的使用者。
- 再销毁被依赖的资源。
- 最后清理管理者和全局状态。
例如 surface 停止时,必须先保证 Scheduler、UIManager、LayoutAnimation 等对 surface 的访问路径已经停止,再做 unregisterSurface 和资源释放。否则就会出现下层对象已经销毁,但上层仍在继续调用 stopSurface、updateConstraints 或动画驱动的情况。
示例:
// 正确
stopSurface();
unregisterSurface();
releaseResources();
// 错误
releaseResources();
stopSurface();
历史案例:
2.3 初始化路径必须保证依赖已 ready
任何依赖外部上下文的对象,在进入创建或访问路径前,必须确认依赖已 ready。常见要求包括:
- ArkUINode 创建前确认 ArkUINodeContext 和 NodeApi 已初始化。
- 窗口属性读取前确认窗口状态稳定。
- bundle 加载、Ability 相关接口调用前确认平台能力可用。
初始化阶段的问题常见表现是:空指针、断言 abort、undefined error 或平台能力加载失败。编码时应把 ready 检查放在入口,而不是等调用失败后再补救。
示例:
// 正确
if (!context_ || !context_->nodeApi) {
return;
}
createNode();
// 错误
createNode();
历史案例:
2.4 状态机迁移必须保持顺序一致
组件内部状态变更不得在多个事件回调中交叉触发,尤其是在滚动、拖拽、动画结束、页面退出和布局提交等高频场景下。必须保证:
- 状态切换路径唯一且顺序稳定。
- 回调触发不会重入改变当前状态。
- 外部事件通知发生在内部状态完成更新之后,或通过明确设计保证顺序一致。
如果状态迁移与回调交错执行,很容易出现状态机进入非法分支,最终在下一次访问时崩溃。
示例:
// 正确
state_ = State::Idle;
emitScrollStop();
// 错误
emitScrollStop();
state_ = State::Idle;
历史案例:
2.5 注册与注销必须形成闭环
所有注册行为都必须有与之对称的注销行为,且注销必须发生在对象销毁时,而不能依赖 GC 或宿主环境自动回收。需要成对检查的接口包括:
- addListener / removeListener。
- subscribe / unsubscribe。
- addEventListener / removeEventListener。
- napi_create_reference / napi_delete_reference。
- 请求发起 / 请求完成后的回调上下文释放。
其中 NAPI reference、NAPI value 及其关联句柄的创建、访问和释放,必须严格遵守所属 env 和线程上下文约束。不能把引用或句柄跨线程传递后在错误线程释放,也不能把“当前在 JS 线程”当作可以任意回收 NAPI 资源的充分条件。
如果注册与注销没有形成闭环,短期可能只是对象未释放,长期则会演变成线程泄漏、句柄泄漏、内存膨胀或实例销毁后回调继续执行。
示例:
// 正确
listenerId_ = addListener(callback);
removeListener(listenerId_);
// 错误
addListener(callback);
历史案例:
3. 并发与线程
3.1 多把锁的获取顺序必须全局一致
只要某个模块会同时持有两把或多把锁,就必须规定全局一致的加锁顺序。不同线程如果使用不同顺序获取同一组锁,就会形成循环等待,导致死锁。
推荐做法:
- 使用 std::scoped_lock 或 std::lock 原子获取多把锁。
- 在注释或模块设计中明确锁顺序约束。
- 避免跨函数隐式持锁,防止调用方和被调用方叠加形成顺序错乱。
示例:
// 推荐
std::scoped_lock lock(mutexA, mutexB);
// 错误
std::lock_guard<std::mutex> lockA(mutexA);
std::lock_guard<std::mutex> lockB(mutexB);
FontRegistry、TextMeasureRegistry、动画注册表等都是历史上出现过锁顺序问题的高风险区域。
历史案例:
3.2 持锁期间不得发起跨线程同步等待
在持有锁的状态下,不得:
- 调用 waitForSyncTask 之类的同步等待接口。
- 向其他线程投递任务后阻塞等待结果。
- 直接调用用户回调或可能重新进入当前锁域的框架回调。
正确做法是:
- 先复制必要数据。
- 释放锁。
- 再执行跨线程调度或回调。
reportMount、scheduler 回调、页面生命周期切换等路径尤其要避免“持锁调度 + 等待返回”的写法。
示例:
// 正确
{
std::lock_guard<std::mutex> lock(mutex_);
pending_ = data;
}
runOnMainThread(task);
// 错误
std::lock_guard<std::mutex> lock(mutex_);
waitForSyncTask(task);
历史案例:
3.3 每类操作必须在规定线程上执行
不同对象和系统接口有明确的线程归属,编码时不能只看代码能不能调通,还必须保证调用线程合法。
常见要求如下:
- ArkUI 节点操作必须在 UI 线程。
- ArkUI 的 markDirty 这类节点脏标记操作必须放在主线程/UI 线程执行,不能在后台线程直接调用。
- NAPI 调用必须在 JS 线程。
- 窗口相关属性读取必须在窗口允许的线程上下文中执行。
- 平台接口如 getSDKApiVersion 这类能力调用必须符合其线程限制。
如果某个逻辑在 Worker 线程上运行,但需要访问主线程对象,则必须显式切换到主线程队列,而不能直接跨线程访问。
示例:
// 正确
runOnMainThread([this]() { updateWindow(); });
// 错误
updateWindow();
历史案例:
- Worker TurboModule 线程错误导致崩溃
- getSDKApiVersion 接口不支持多线程,SIGABRT Fatal: ecma_vm 多线程运行
- SafeAreaInsetsProvider 在 Worker 上下文同步取窗口属性导致卡死或崩溃
3.4 共享状态的 getter 也要考虑线程安全
线程安全问题不只出现在写路径,读路径同样可能有风险。尤其是 getter 如果内部读取的是可变共享状态,就必须保证:
- 读路径与写路径使用同一套同步保护。
- 不返回已经失效的引用、指针或上下文。
- 不把当前线程栈上的临时状态暴露到异步回调或其他线程。
历史上 HostObjectProxy、JSVMRuntime 等路径就曾因为 getter 跨线程共享状态而导致间歇性崩溃。
示例:
// 正确
std::string getName() {
std::lock_guard<std::mutex> lock(mutex_);
return name_;
}
// 错误
const std::string& getName() {
return name_;
}
历史案例:
3.5 事务处理必须保证数据源一致和顺序稳定
在 performTransaction、mutation 分桶、切片并行处理等高频事务链路中,必须确保:
- 前后阶段使用的是同一份 transaction 数据。
- 不会因为并行切片或异步调度打乱 Create、Insert、Delete 等 mutation 顺序。
- 所有依赖顺序的操作在拆分时保留顺序约束。
如果事务链路在不同阶段读取了不一致的数据副本,就可能造成挂载顺序错乱、状态不一致,最终演变成崩溃或低概率异常。
示例:
// 正确
auto &mutations = transaction.getMutations();
splitAndDispatch(mutations);
// 错误
splitAndDispatch(snapshotA);
applyLater(snapshotB);
历史案例:
4. 异常与边界处理
4.1 noexcept 只能用于真正不会抛异常的函数
noexcept 不是性能优化标签,而是异常边界承诺。凡是标注为 noexcept 的函数,都必须保证其内部调用链上不会有异常穿透出去。以下场景不能随意加 noexcept:
- 析构函数中包含复杂逻辑、回调、资源释放或跨层调用。
- 虚函数覆盖中调用了第三方库或自定义逻辑。
- 系统回调接口中仍可能抛出 runtime_error、logic_error 等异常。
如果函数内部可能抛异常,应采取以下方式之一:
- 去掉不合理的 noexcept。
- 在边界处 try-catch 并转化为可恢复错误。
- 把错误转为日志、状态码或显式失败分支。
否则一旦异常穿透 noexcept 边界,就会直接触发 terminate,最终表现为 SIGABRT。
示例:
// 正确
void flush() {
mayThrow();
}
// 错误
void flush() noexcept {
mayThrow();
}
历史案例:
4.2 非 void 函数所有分支都必须返回
声明了返回值的函数,必须保证所有控制流分支都有明确 return,不能依赖未定义行为。尤其是下面几类函数:
- 返回对象自身引用的链式接口。
- 返回状态枚举、布尔值或指针的工具函数。
- switch 或多层条件判断中的状态转换函数。
如果遗漏某个分支的返回值,调用方继续使用结果时可能落入未定义状态,表现为 SIGTRAP、SIGSEGV 或后续更难追踪的逻辑错误。
示例:
// 正确
int getWidth(bool ready) {
if (!ready) return 0;
return width_;
}
// 错误
int getWidth(bool ready) {
if (ready) return width_;
}
历史案例:
4.3 JS 和 ArkTS 层必须全面做空值保护
JS 和 ArkTS 层对 undefined、null 的容忍度低,未处理异常会直接演变成 JS Crash。以下场景必须做空值保护:
- 异步回调中访问对象属性。
- 关闭、销毁、重置路径中访问 socket、window、context 等对象。
- 可选参数、跨层返回值和平台 API 调用结果的读取。
推荐模式:
if (this.socket == undefined) {
return;
}
this.socket.close();
或者使用可选链、默认值和显式降级分支,而不是假设对象始终存在。
示例:
// 正确
if (socket != undefined) {
socket.close();
}
// 错误
socket.close();
历史案例:
4.4 边界输入必须在入口做合法性检查
所有来自外部的输入,包括 props、模块参数、路径、配置、事件数据,都必须在入口处校验类型和合法性。以下情况不能直接向下传递:
- 非法 keyboardType、枚举值或状态值。
- 目录、文件、句柄类型混用。
- 未提供 cache key、descriptor、component name 等关键字段。
- 约定结构不完整的对象。
正确做法是尽早失败、给出日志并进入可恢复分支,而不是等底层代码在未知状态下崩溃。
示例:
// 正确
if (!isValidKeyboardType(type)) {
return "default";
}
// 错误
return type;
历史案例:
4.5 容器和字符串访问必须检查边界
对 vector、array、map、string 等容器的访问,必须确认索引、位置和值存在,不能假设集合状态总是与上一步一致。尤其在并发场景或状态切换场景下,上一行读到的 size,下一行未必仍然有效。
需要重点检查的典型问题包括:
- 删除所有 item 后继续按旧索引访问。
- 字符串 pos 越界。
- 文本布局结果位置计算错误。
- 动态对象解包时未检查字段是否存在。
示例:
// 正确
if (index < items_.size()) {
return items_[index];
}
// 错误
return items_[index];
历史案例:
4.6 排序比较器必须满足严格弱序,需要稳定结果时使用 stable_sort
在 C++ 中使用 std::sort 时,比较器必须满足严格弱序要求,不能把“相等元素”也写成可互相判定为更小。对于不满足严格弱序的比较器,std::sort 的行为属于未定义行为,不能依赖“小数据量看起来正常”或“部分输入下没有复现问题”的现象判断实现正确。一旦比较器对相等元素返回错误结果,排序过程就可能出现错误结果、异常访问或崩溃。
如果元素之间可能相等,且业务上需要保持相等元素的原始相对顺序,应使用 std::stable_sort;如果不需要稳定性,也仍然必须保证 std::sort 使用的比较器满足严格弱序,不能写成 <=、>= 或其他会破坏比较关系的形式。
示例:
// 正确
std::stable_sort(items.begin(), items.end(), [](const Item& left, const Item& right) {
return left.priority < right.priority;
});
// 错误
std::sort(items.begin(), items.end(), [](const Item& left, const Item& right) {
return left.priority <= right.priority;
});
历史案例:
5. 平台与模块兼容
5.1 ArkTS 中禁止手工 declare 系统函数
在 ArkTS 文件中,不应通过 declare function 的方式手工声明系统函数,例如 px2vp。因为这类声明在 release 混淆后可能无法按原始符号名解析,最终引发 undefined error 或启动崩溃。
正确做法是使用平台正式提供的能力入口或导入方式,不要自行构造运行时解析依赖。
示例:
// 正确
import { LengthMetrics } from "@kit.ArkUI";
// 错误
declare function px2vp(value: number): number;
历史案例:
5.2 ArkTS 私有字段禁止使用双下划线前缀
在 release 构建或混淆场景下,以双下划线开头的字段和方法名可能被重命名,导致运行时找不到对应属性或方法。因此:
- 不要使用 __xxx 作为私有字段和方法命名。
- 优先使用 _xxx 或普通语义化命名。
- 对需要稳定暴露给运行时、反射或导出层的成员,更应避免混淆敏感命名。
示例:
// 正确
private _windowSize = 0
// 错误
private __windowSize = 0
历史案例:
5.3 平台 API 必须做版本兼容判断
系统 API 并不保证在所有 API Level 下都可用。编码时必须:
- 先判断平台版本。
- 再决定是否调用特定接口。
- 对不支持的平台提供降级实现或跳过路径。
尤其是 API12 相关能力、libability_runtime.so 加载链路、页面注册能力等,历史上都出现过平台兼容缺失导致崩溃的问题。
示例:
// 正确
if (apiVersion >= 12) {
useNewApi();
}
// 错误
useNewApi();
历史案例:
5.4 自定义组件命名不得与框架保留前缀冲突
组件注册、descriptor 查找、模块导出等路径,都依赖稳定的命名约定。自定义组件和模块不得使用框架保留前缀,例如 RCT,否则可能在运行时与框架内置对象冲突,导致注册失败或组件创建崩溃。
示例:
// 正确
export const FancyButton = ...
// 错误
export const RCTFancyButton = ...
历史案例:
5.5 导出符号和模块实现必须避免与三方库冲突
NativeNodeApi、NAPI 导出对象、动态链接符号等,都可能因为与三方库同名而被覆盖。编码时应避免使用过于泛化的导出名,并在模块边界上明确命名空间和导出策略,防止运行时符号解析错误。
示例:
// 正确
namespace rnoh {
class HarmonyNodeApi {};
}
// 错误
class NativeNodeApi {};
历史案例:
6. 代码评审中的稳定性自查清单
在提交代码或 CR 时,建议对照以下问题快速自查:
6.1 生命周期检查
- 每一个异步回调,对象销毁后还会不会继续执行。
- destroy() 或析构函数中是否停止了所有 VSync、Timer、HTTP、listener 和 observer。
- 是否存在析构过程中继续触发外部回调的路径。
6.2 线程与锁检查
- 多把锁的加锁顺序是否全局一致。
- 持锁期间是否做了跨线程同步等待。
- ArkUI、NAPI、窗口属性等操作是否运行在正确线程上。
- getter 或读路径是否遗漏了线程同步保护。
6.3 空值与边界检查
- 指针、optional、registry 查询结果是否都做了判空。
- 容器、字符串和文本位置访问是否检查了边界。
- 排序比较器是否满足严格弱序;元素可能相等且需要保持顺序时是否使用 stable_sort。
- 非法 props、配置、参数值是否在入口处被拦截。
6.4 异常边界检查
- noexcept 是否只用于真正不会抛异常的函数。
- 所有非 void 函数是否在所有分支都返回。
- JS 和 ArkTS 中的异步对象访问是否做了 undefined 或 null 保护。
6.5 兼容性与资源释放检查
- 是否调用了有平台版本限制的接口,但没有做兼容判断。
- ArkTS 中是否手工 declare 了系统函数。
- 是否使用了双下划线前缀的敏感命名。
- 每个 listener、reference、request、thread、cache 是否都有对称的释放路径。