ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HarmonyOS 7的API 26 破坏性变更与迁移

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 25API 26影响
GSPlugin.loadGSNode(scene, uri: string, parent)(scene, opts: {uri, offset}, parent)第二参从 string 改成对象
TiledGSImportSettings.uri仅支持file://支持file://和OhosRawFile://兼容性扩展,无破坏
GSNode.renderToImage无此接口新增新能力,无破坏
ReconSession.configconfig: ReconConfigconfig: 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.loadFromMp4deprecated用loadGSNode统一入口,uri 指向 mp4
ReconSession.setProgressCallbackdeprecated用on('progress', cb)事件订阅
GSNode.setVisibledeprecated用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@Componentstruct ProductViewer{privatescene:Scene|null=null;privatepluginLoaded:boolean=false;aboutToAppear():void{constctx: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;constroot:Node=scene.rootasNode;// API 25: loadGSNode 第二参是 stringconstgsNode=awaitspatialRender.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@Componentstruct ProductViewer{privatescene:Scene|null=null;privatepluginLoaded:boolean=false;aboutToAppear():void{constctx:RenderContext|null=Scene.getDefaultRenderContext();if(ctx===null){return;}// 变更 1:API 26 重复调用抛异常,要 try-catchif(!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;constroot:Node=scene.rootasNode;// 变更 2:loadGSNode 第二参改成对象constgsNode=awaitspatialRender.GSPlugin.loadGSNode(scene,{uri:'OhosRawFile://assets/teapot.glb',offset:0},root);gsNode.position={x:0,y:-0.2,z:0};// 变更 3:position 赋值下一帧才生效,不能立即读// 如果逻辑依赖立即生效,要改成 await nextFrame() 后再读awaitnextFrame();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 米,排查了两小时才发现。

迁移不是改完编译错误就结束。把这条链路画出来,能看清行为回归为什么占了大头。

签名或参数变更

废弃接口

编译通过

全绿

有差异

存量代码改 compileSdkVersion 到 26

编译并收集错误

错误类型

逐项替换接口且显式赋值每个字段

替换为统一入口或事件订阅

跑行为差异用例回归

真机验证 5 处行为变更

行为是否符合预期

迁移完成

定位语义变更点回改代码

从编译到行为回归是一条串行链。有差异就回到回归环节,直到全部用例通过。

真机数据:迁移前后行为差异

迁移完跑了真机回归,重点验证 5 处行为变更。

行为变更API 25 表现API 26 表现是否符合预期
loadPlugin重复调用返回 false抛异常是,但需 try-catch
position立即读读到新值读到旧值是,但易引入 bug
并发 session第二个 null第二个抛异常是,错误更明确
isSupported低端机truefalse是,更准确
renderToImage默认分辨率无此接口1080p,内存 200MB否,应默认 512

renderToImage默认 1080p 这条我们提了 issue。新接口默认分辨率太高,端侧调用不指定尺寸时内存峰值 200 MB,容易 OOM。建议默认 512 或在文档强调要指定尺寸。目前看 Kit 团队认可,可能 API 27 改。

真机数据:编译错误分布

47 个编译错误的分布,给同样要迁移的人参考。

错误类型数量占比
参数类型不匹配(string vs object)1838%
接口不存在(废弃接口未替换)1226%
返回值类型不匹配919%
缺少新增必填字段511%
其他36%

参数类型不匹配占 38%,都是loadGSNode第二参从 string 改成对象导致的。这类错误改起来快,但容易改漏——有些地方 string 是变量传入的,IDE 不会标红,运行时才崩。我们用全局搜索loadGSNode(逐个核对,又揪出 3 处漏改的。

踩坑与取舍

坑一:接口名没变但参数语义变了

loadGSNode接口名没变,第二参从 string 改成{uri, offset}。我们有一处代码是这样写的:

// API 25 写法,uri 是变量consturi:string=getAssetUri(skuId);constgsNode=awaitspatialRender.GSPlugin.loadGSNode(scene,uri,root);

迁到 API 26,IDE 提示第二参类型错,改成:

// 错误改法:把 uri 直接包进对象,但漏了 offsetconstgsNode=awaitspatialRender.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// 迁移自检清单,逐项确认exportclassMigrationChecklist{// 1. loadGSNode 第二参是否都改成对象// 全局搜索 loadGSNode(,逐个核对第二参是 {uri, offset} 不是 stringstaticcheckLoadGSNode():boolean{// 用 grep 搜 loadGSNode( 调用点,人工核对returntrue;// 已确认 23 处全部改完}// 2. loadPlugin 重复调用是否都 try-catchstaticcheckLoadPlugin():boolean{// 搜 loadPlugin(,确认外层有 try-catch PluginAlreadyLoadedreturntrue;}// 3. position/rotation/scale 赋值后是否避免立即读staticcheckPositionRead():boolean{// 搜 .position = ,确认后续没有同步读 .positionreturntrue;}// 4. 并发 session 创建是否改成 catch SessionConflictstaticcheckSessionConflict():boolean{// 搜 new ReconSession,确认 catch 了 SessionConflictreturntrue;}// 5. renderToImage 是否都指定了 width/heightstaticcheckRenderToImage():boolean{// 搜 renderToImage(,确认传了 {width: 512, height: 512} 之类returntrue;}// 6. 废弃接口是否都替换staticcheckDeprecated():boolean{// 搜 loadFromMp4 / setProgressCallback / setVisible,应为 0 处returntrue;}}

这份清单看着蠢,但迁移第 3 天我们靠它发现了 2 处漏改。人肉改 8000 行代码不可能不漏,清单是兜底。

注意一下下哦

  • 接口参数从简单类型改成对象时,每个字段显式赋值,不依赖默认值,默认值语义可能和旧接口不一致
  • position赋值在 API 26 下一帧生效,赋值后不能同步读,要await nextFrame()
  • loadPlugin重复调用 API 26 抛异常,要 try-catchPluginAlreadyLoaded,不能只判返回值
  • 并发 session 创建 API 26 抛SessionConflict,不能只判 null,否则直接崩
  • renderToImage必须指定 width/height,默认 1080p 内存峰值 200 MB,端侧易 OOM
  • isSupported语义从查 SDK 改成查 SDK + 硬件,低端机返回值会变,降级链要重新验证
  • 废弃接口在 API 26 还能用但 API 27 会移除,这次迁掉,避免下次再迁
  • 行为变更的影响要传导到用户沟通,不能只在代码层面改,用户感知到的变化要写 changelog
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进