HarmonyOS 7的API 26 破坏性变更与迁移
我们维护的 3DGS 商品展示应用跑在 API 25 上半年了,存量代码 8000 多行。HarmonyOS 7(API 26)发布后,IDE 提示 Spatial Recon Kit 有更新,我们顺手把 compileSdkVersion 改成 26,结果编译报 47 个错。不是少导了什么,是 Kit 接口真的改了。这篇文章记录我们把 API 25 存量代码迁到 API 26 的过程,重点是那些接口名没变但语义变了的坑——编译能过,行为静默错误,比编译失败难排查十倍。
能力面:API 26 破坏性变更清单
迁移前先把变更摸清楚。我们对照 API 25 和 26 的 d.ts 文件 diff,整理出三类变更。
接口签名变更(编译期可发现)
| 接口 | API 25 | API 26 | 影响 |
|---|---|---|---|
GSPlugin.loadGSNode | (scene, uri: string, parent) | (scene, opts: {uri, offset}, parent) | 第二参从 string 改成对象 |
TiledGSImportSettings.uri | 仅支持 file:// | 支持 file:// 和 OhosRawFile:// | 兼容性扩展,无破坏 |
GSNode.renderToImage | 无此接口 | 新增 | 新能力,无破坏 |
ReconSession.config | config: ReconConfig | config: ReconConfig | undefined | 允许 undefined,向后兼容 |
ReconSession.start | 返回 Promise<void> | 返回 Promise<ReconResult> | 返回值多了结果对象 |
这类变更编译期会报错,照着改就行。真正麻烦的是下面两类。
行为变更(编译期不可见)
| 接口 | API 25 行为 | API 26 行为 | 风险 |
|---|---|---|---|
loadPlugin 重复调用 | 返回 false,无副作用 | 抛异常 PluginAlreadyLoaded | 旧代码 try-catch 没接住异常 |
GSNode.position 赋值 | 立即生效 | 下一帧生效 | 依赖立即生效的逻辑会闪一帧 |
ReconSession 并发创建 | 第二个返回 null | 第二个抛 SessionConflict | 旧代码判 null 走降级,现在直接崩 |
renderToImage 默认分辨率 | 无此接口 | 默认 1080p | 不指定尺寸时内存峰值高 |
isSupported 返回值 | 仅查 SDK | 查 SDK + 硬件 | 低端机从 true 变 false |
isSupported 这条最坑。API 25 时它只查 SDK 是否包含 Kit,返回 true;API 26 改成查 SDK + 硬件,低端机返回 false。我们存量代码靠 isSupported 判定走 3DGS 还是降级,升级后低端机原本走 3DGS(虽然卡)现在直接走降级,用户感知是"3D 效果没了"。
废弃 API(编译期 warning)
| 接口 | API 26 状态 | 替代方案 |
|---|---|---|
GSPlugin.loadFromMp4 | deprecated | 用 loadGSNode 统一入口,uri 指向 mp4 |
ReconSession.setProgressCallback | deprecated | 用 on('progress', cb) 事件订阅 |
GSNode.setVisible | deprecated | 用 GSNode.visible 属性赋值 |
废弃的接口 API 26 还能用,但编译 warning,且官方说 API 27 会移除。我们选择这次迁掉,避免下次再迁。
约束面:迁移工作量
迁移工作量不只是改代码,还有验证。下表是我们这次迁移的实测数据。
| 工作项 | 数量 | 单项耗时 | 小计 |
|---|---|---|---|
| 接口签名变更修改 | 23 处 | 5-15 分钟 | 4 小时 |
| 行为变更排查与修复 | 8 处 | 1-4 小时 | 18 小时 |
| 废弃接口替换 | 6 处 | 20-40 分钟 | 3 小时 |
| 编译错误修复 | 47 个 | 2-10 分钟 | 6 小时 |
| 真机回归测试 | 12 台设备 | 1 小时/台 | 12 小时 |
| 行为差异验证 | 5 处语义变更 | 2 小时/处 | 10 小时 |
| 合计 | 53 小时 |
行为变更排查占了 18 小时,比编译错误修复还多。编译错误改完就过,行为变更要逐个验证"原来这么调用现在还是不是这个效果"。
场景落地:一段存量代码的迁移
下面是我们商品展示页加载 3DGS 的存量代码,API 25 写的,迁到 API 26 改了三处。
API 25 原版
// API 25 版本,2025 年写的
import { Scene, RenderContext, Node } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';
@Entry
@Component
struct ProductViewer {
private scene: Scene | null = null;
private pluginLoaded: boolean = false;
aboutToAppear(): void {
const ctx: RenderContext | null = Scene.getDefaultRenderContext();
if (ctx === null) { return; }
// API 25: 重复调用返回 false,这里写了个 if 判断
if (!this.pluginLoaded) {
ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
this.pluginLoaded = true;
}
Scene.load().then(async (scene: Scene) => {
this.scene = scene;
const root: Node = scene.root as Node;
// API 25: loadGSNode 第二参是 string
const gsNode = await spatialRender.GSPlugin.loadGSNode(
scene, 'OhosRawFile://assets/teapot.glb', root
);
gsNode.position = { x: 0, y: -0.2, z: 0 };
// API 25: 立即生效,下一行就能读到
console.log(gsNode.position.y); // -0.2
});
}
build() {
Column() {
if (this.scene) {
Component3D({ scene: this.scene }).width('100%').height('100%')
}
}
}
}
API 26 迁移版
// API 26 版本
import { Scene, RenderContext, Node } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';
@Entry
@Component
struct ProductViewer {
private scene: Scene | null = null;
private pluginLoaded: boolean = false;
aboutToAppear(): void {
const ctx: RenderContext | null = Scene.getDefaultRenderContext();
if (ctx === null) { return; }
// 变更 1:API 26 重复调用抛异常,要 try-catch
if (!this.pluginLoaded) {
try {
ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
this.pluginLoaded = true;
} catch (e) {
// 已加载属于正常情况,不报错
if (e.code !== 'PluginAlreadyLoaded') {
console.error('load plugin failed: ' + e.message);
}
this.pluginLoaded = true;
}
}
Scene.load().then(async (scene: Scene) => {
this.scene = scene;
const root: Node = scene.root as Node;
// 变更 2:loadGSNode 第二参改成对象
const gsNode = await spatialRender.GSPlugin.loadGSNode(
scene,
{ uri: 'OhosRawFile://assets/teapot.glb', offset: 0 },
root
);
gsNode.position = { x: 0, y: -0.2, z: 0 };
// 变更 3:position 赋值下一帧才生效,不能立即读
// 如果逻辑依赖立即生效,要改成 await nextFrame() 后再读
await nextFrame();
console.log(gsNode.position.y); // -0.2
});
}
build() {
Column() {
if (this.scene) {
Component3D({ scene: this.scene }).width('100%').height('100%')
}
}
}
}
三处变更里,第三处最容易被漏掉。position 赋值从立即生效改成下一帧生效,编译不报错,运行也不崩,但如果代码里紧接着读 gsNode.position 做后续计算,读到的是旧值。我们有一处布局逻辑依赖赋值后立即读 position 算偏移,迁到 API 26 后偏移全错,画面位置偏了 0.2 米,排查了两小时才发现。
迁移不是改完编译错误就结束。把这条链路画出来,能看清行为回归为什么占了大头。
从编译到行为回归是一条串行链。有差异就回到回归环节,直到全部用例通过。
真机数据:迁移前后行为差异
迁移完跑了真机回归,重点验证 5 处行为变更。
| 行为变更 | API 25 表现 | API 26 表现 | 是否符合预期 |
|---|---|---|---|
loadPlugin 重复调用 | 返回 false | 抛异常 | 是,但需 try-catch |
position 立即读 | 读到新值 | 读到旧值 | 是,但易引入 bug |
| 并发 session | 第二个 null | 第二个抛异常 | 是,错误更明确 |
isSupported 低端机 | true | false | 是,更准确 |
renderToImage 默认分辨率 | 无此接口 | 1080p,内存 200MB | 否,应默认 512 |
renderToImage 默认 1080p 这条我们提了 issue。新接口默认分辨率太高,端侧调用不指定尺寸时内存峰值 200 MB,容易 OOM。建议默认 512 或在文档强调要指定尺寸。目前看 Kit 团队认可,可能 API 27 改。
真机数据:编译错误分布
47 个编译错误的分布,给同样要迁移的人参考。
| 错误类型 | 数量 | 占比 |
|---|---|---|
| 参数类型不匹配(string vs object) | 18 | 38% |
| 接口不存在(废弃接口未替换) | 12 | 26% |
| 返回值类型不匹配 | 9 | 19% |
| 缺少新增必填字段 | 5 | 11% |
| 其他 | 3 | 6% |
参数类型不匹配占 38%,都是 loadGSNode 第二参从 string 改成对象导致的。这类错误改起来快,但容易改漏——有些地方 string 是变量传入的,IDE 不会标红,运行时才崩。我们用全局搜索 loadGSNode( 逐个核对,又揪出 3 处漏改的。
踩坑与取舍
坑一:接口名没变但参数语义变了
loadGSNode 接口名没变,第二参从 string 改成 {uri, offset}。我们有一处代码是这样写的:
// API 25 写法,uri 是变量
const uri: string = getAssetUri(skuId);
const gsNode = await spatialRender.GSPlugin.loadGSNode(scene, uri, root);
迁到 API 26,IDE 提示第二参类型错,改成:
// 错误改法:把 uri 直接包进对象,但漏了 offset
const gsNode = await spatialRender.GSPlugin.loadGSNode(scene, { uri }, root);
offset 漏了。API 26 的 offset 是文件偏移量,用于一个文件存多个 3DGS 场景的情况,默认值不是 0 而是 undefined,行为是"读整个文件"。我们的场景恰好一个文件一个场景,漏了 offset 没崩。但有个同事的场景是一个 mp4 存了 3 个商品,漏 offset 后只读到第一个,排查了半天。
教训:接口参数从简单类型改成对象时,每个字段都要显式赋值,不能依赖默认值。默认值的语义可能和旧接口不一致。
坑二:废弃 API 没文档
GSPlugin.loadFromMp4 在 API 26 标 deprecated,但 d.ts 里没写替代方案,官方迁移文档也没提。我们翻了 API 26 的 release note 才找到一句"统一用 loadGSNode"。废弃接口应该在 d.ts 里直接标替代方案,这是文档该做的事,不该让开发者翻 release note。
类似地,setProgressCallback 废弃后改用 on('progress', cb),但 on 接口的 cb 参数类型和旧 callback 不完全一致——旧 callback 收 number,新 cb 收 {progress: number, stage: string}。我们直接搬旧函数过去,类型不匹配编译报错,但报错信息没指出是废弃接口迁移导致的,绕了一圈才搞明白。
坑三:被放弃的方案——同时兼容 API 25 和 26
中途想过用条件编译同时兼容两个版本,#if API_VERSION >= 26 走新接口 else 走旧接口。问题是行为变更没法条件编译——position 赋值何时生效是运行时行为,不是编译期能切的。而且维护两套代码路径成本翻倍,回归测试要跑两遍。
这个方案被放弃了。我们决定一刀切迁到 API 26,最低支持版本提到 API 26。代价是低版本设备收不到更新,但产品评估后接受——3DGS 功能本来就是新设备卖点。
坑四:isSupported 语义变更导致降级链失效
前面提到 isSupported 在低端机从 true 变 false。我们的降级链(见第 35 篇)依赖 isSupported 判定,升级后低端机原本能跑 3DGS(虽然卡)现在直接走 Mesh 降级。
这事本身是好事——低端机跑 3DGS 体验确实差,走 Mesh 更合理。但用户感知是"3D 效果没了",有用户投诉。我们的处理是在 changelog 里写明"低端设备 3D 展示优化为更流畅的方案",并在首次进入时弹一次提示。接口语义变更的影响要传导到用户沟通,不能只在代码层面改完就完事。
迁移检查清单
迁移完用这个清单逐项核对,避免漏改。
// entry/src/main/ets/migration/MigrationChecklist.ets
// 迁移自检清单,逐项确认
export class MigrationChecklist {
// 1. loadGSNode 第二参是否都改成对象
// 全局搜索 loadGSNode(,逐个核对第二参是 {uri, offset} 不是 string
static checkLoadGSNode(): boolean {
// 用 grep 搜 loadGSNode( 调用点,人工核对
return true; // 已确认 23 处全部改完
}
// 2. loadPlugin 重复调用是否都 try-catch
static checkLoadPlugin(): boolean {
// 搜 loadPlugin(,确认外层有 try-catch PluginAlreadyLoaded
return true;
}
// 3. position/rotation/scale 赋值后是否避免立即读
static checkPositionRead(): boolean {
// 搜 .position = ,确认后续没有同步读 .position
return true;
}
// 4. 并发 session 创建是否改成 catch SessionConflict
static checkSessionConflict(): boolean {
// 搜 new ReconSession,确认 catch 了 SessionConflict
return true;
}
// 5. renderToImage 是否都指定了 width/height
static checkRenderToImage(): boolean {
// 搜 renderToImage(,确认传了 {width: 512, height: 512} 之类
return true;
}
// 6. 废弃接口是否都替换
static checkDeprecated(): boolean {
// 搜 loadFromMp4 / setProgressCallback / setVisible,应为 0 处
return true;
}
}
这份清单看着蠢,但迁移第 3 天我们靠它发现了 2 处漏改。人肉改 8000 行代码不可能不漏,清单是兜底。
注意一下下哦
- 接口参数从简单类型改成对象时,每个字段显式赋值,不依赖默认值,默认值语义可能和旧接口不一致
position赋值在 API 26 下一帧生效,赋值后不能同步读,要await nextFrame()loadPlugin重复调用 API 26 抛异常,要 try-catchPluginAlreadyLoaded,不能只判返回值- 并发 session 创建 API 26 抛
SessionConflict,不能只判 null,否则直接崩 renderToImage必须指定 width/height,默认 1080p 内存峰值 200 MB,端侧易 OOMisSupported语义从查 SDK 改成查 SDK + 硬件,低端机返回值会变,降级链要重新验证- 废弃接口在 API 26 还能用但 API 27 会移除,这次迁掉,避免下次再迁
- 行为变更的影响要传导到用户沟通,不能只在代码层面改,用户感知到的变化要写 changelog
更多推荐

所有评论(0)