〖共创稿事节〗HarmonyOS 7 新特性实战(47):把装机门临时提到 26,让 linter 把迁移债一次吐干净
# 〖共创稿事节〗HarmonyOS 7 新特性实战(47):把装机门临时提到 26,让 linter 把迁移债一次吐干净
我原本以为这个工程的 API 26 迁移已经做完了。targetSdkVersion 写着 26.0.0,需要按新版分支处理的地方都用 deviceInfo.sdkApiVersion 判断过了,构建一路全绿。直到有一天把 compatibleSdkVersion 也临时改到 26,重新编一遍——31 条警告冒出来,其中 5 条是我完全没意识到的。
问题出在一个容易忽略的地方:linter 按哪个版本检查,取决于 compatibleSdkVersion,而它同时是装机门。
官方文档:应用版本兼容性配置(核对日期 2026-10-29)
一、装机门有两副面孔
compatibleSdkVersion 在构建配置里只是一个字符串,但它同时决定两件毫不相干的事:
| 它决定什么 | 表现 | 谁在关心 |
|---|---|---|
| 这台设备能不能装 | 低于该版本的设备直接装不上 | 用户、应用市场 |
| linter 按哪个口径检查 | 检查规则集、弃用 API 清单都跟着走 | 编译器 |
第二件事是我之前没意识到的。compatibleSdkVersion 是 6.0.0(20),linter 就老老实实按 API 20 的规则集检查——那些"API 26 起建议改用 XX"的提示,一条都不会出现,因为按 20 的口径,它们本来就不成立。
这不是 bug,是设计。你告诉编译器"我要兼容到 20",它就不该拿 26 的规矩挑你的刺。
但对迁移来说这是个陷阱:targetSdkVersion 提到 26 只说明"我愿意按 26 的行为跑",不代表代码里没有 26 才废弃的用法。想看到这些债,得让 linter 站到 26 的角度去看。

二、先踩一个格式坑
改之前我随手写了个 "26.0",两段。构建直接拒绝:
> hvigor ERROR: 00306042 Specification Limit Violation
Error Message: In HarmonyOS configuration:
- For API versions 10 to 25, the value of compatibleSdkVersion/targetSdkVersion
must be a string (for example, '5.0.0(12)').
- For API version 26 and later, the value of compatibleSdkVersion/targetSdkVersion
must be a string (for example, '26.0.0').
这条报错把规则说全了,而且是两种互斥的写法:
| API 区间 | 合法写法 | 例子 |
|---|---|---|
| 10 – 25 | 三段 + 括号里的数字 | '5.0.0(12)' |
| 26 及以上 | 纯三段,不带括号 | '26.0.0' |
跨过 26 这道坎,写法就得换。这一点靠猜是猜不出来的,"26.0"、"26.0.0(26)" 都会被拒。改成 "26.0.0" 后格式校验通过,linter 这才开始按 26 的口径检查。
三、31 条警告,只有 5 条是债
拿到手的 31 条按文件分布如下:
| 出警告的模块 | 条数 | 警告正文 |
|---|---|---|
| 音频路由监控 | 21 | The system capacity of this api 'audio' is not supported on all devices |
| 异兽超分查看器 | 5 | 4×'px2vp' has been deprecated + 1×'getMediaContent' has been deprecated |
| 空间音频封装 | 3 | 1×module for 'libshanhaiaudio.so' is not verified + 2×Function may throw exceptions |
| 讲解服务 | 1 | The system capacity of this api 'media' is not supported on all devices |
| 沉浸式外框 | 1 | This API is unavailable to 2in1 |
合计 COMPILE RESULT:FAIL {ERROR:1 WARN:31}。31 条看着吓人,但按"要不要动代码"分,是两类完全不同的问题:
- 5 条
deprecated是真债。 SDK 26 里有明确的替代 API,现在是警告,将来可能是错误。必须改。 - 其余 26 条不是"你写错了"。 其中 21 条 audio + 1 条 media 是设备容量提示——这类能力在部分设备上不存在;1 条 2in1 不可用;1 条 napi 模块未验证(SDK 后续版本才启用校验);2 条
Function may throw exceptions是提醒补异常处理。它们要的答案是能力探测 + 降级——代码里那唯一一处getDevicesSync本来就包在 try/catch 里,取不到就返回上一次快照。
把这 26 条当债去"修",只会写出毫无意义的空判断或多余的 try/catch。这一点值得单独说:同一份警告列表里,性质不同的东西混在一起,先分类再动手,比逐条处理快得多。

四、替代项不要猜,去读 d.ts
5 条 deprecated 集中在同一个文件。我没有查文档,直接翻了 SDK 的 .d.ts——被弃用的方法上面都有 @useinstead 字段,写着该换成什么。这一步值得强调:编译器的建议和它想让你用的替代品是同一份数据,查文档拿到的可能是过时描述,读 d.ts 拿到的是编译器真正据以判定的那条。
px2vp 那 4 处在一段避让区计算里,四行连着出现:
private loadSafeInsets(): void {
try {
const hostWindow: window.Window = this.getUIContext().getHostContext() as window.Window;
const types: window.AvoidAreaType[] = [
window.AvoidAreaType.TYPE_SYSTEM,
window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
];
let top: number = 0;
let bottom: number = 0;
let left: number = 0;
let right: number = 0;
for (const type of types) {
const area: window.AvoidArea = hostWindow.getWindowAvoidArea(type);
// 改前是全局 px2vp(...),SDK 26 linter 标 deprecated
top = Math.max(top, this.getUIContext().px2vp(area.topRect.height));
bottom = Math.max(bottom, this.getUIContext().px2vp(area.bottomRect.height));
left = Math.max(left, this.getUIContext().px2vp(area.leftRect.width));
right = Math.max(right, this.getUIContext().px2vp(area.rightRect.width));
hilog.info(SR_LOG_DOMAIN, 'ShanhaiSR',
'avoid type=%{public}d t=%{public}d b=%{public}d l=%{public}d r=%{public}d',
type, area.topRect.height, area.bottomRect.height,
area.leftRect.width, area.rightRect.width);
}
this.safeTop = top;
this.safeBottom = bottom;
this.safeLeft = left;
this.safeRight = right;
} catch (error) {
const failure = error as BusinessError;
hilog.warn(SR_LOG_DOMAIN, 'ShanhaiSR', 'avoid area unavailable code=%{public}d', failure.code);
}
}
px2vp 这类全局函数的问题在于它拿不到 UIContext——它只能按某种进程级的默认密度换算。在多窗口、多实例场景下,同一个数值在不同窗口里应该得到不同的 vp 结果,而全局函数给不出这个区分。改成 UIContext 的实例方法后,密度来源就明确了:是这个界面实例所在的窗口在换算。
getMediaContent 那处在解码原图的方法里,改完连带把整条解码链看得更清楚:
// 解码期就降到低清(长边 SR_INPUT_LONG_SIDE),按原始长宽比缩放,不做拉伸变形。
private async loadLowRes(): Promise<image.PixelMap | undefined> {
try {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
this.note = '无法获取应用上下文,请稍后再试。';
this.phase = ShanhaiSuperResolutionPhase.FAILED;
return undefined;
}
// 改前:await context.resourceManager.getMediaContent(...)
// 改后:同步版,SDK 26 linter 建议的替代
const raw = context.resourceManager.getMediaContentSync(shanhaiBeastImage(this.beastId));
const source = image.createImageSource(raw.buffer as ArrayBuffer);
const info = await source.getImageInfo();
this.srcW = info.size.width;
this.srcH = info.size.height;
// 拿到原图宽高就定屏幕方向,不等 4 倍重建跑完 —— 否则用户会先看一小条图
this.syncOrientation(info.size.width, info.size.height);
const longSide = Math.max(info.size.width, info.size.height);
const ratio = SR_INPUT_LONG_SIDE / longSide;
const options: image.DecodingOptions = {
desiredSize: {
width: Math.max(1, Math.round(info.size.width * ratio)),
height: Math.max(1, Math.round(info.size.height * ratio))
}
};
const pixelMap = await source.createPixelMap(options);
await source.release();
this.lowRes = pixelMap;
this.pane = SR_PANE_LOW;
return pixelMap;
} catch (error) {
const failure = error as BusinessError;
console.error('[shanhai-sr] decode failed code=' + failure.code + ' message=' + failure.message);
this.note = '这张插画暂时没能打开,请稍后再试。';
this.phase = ShanhaiSuperResolutionPhase.FAILED;
return undefined;
}
}
异步改同步,一开始我有点犹豫——不会卡住 UI 线程吗?实际这处读的是一张压缩过的图片资源,几十 KB 的量级,同步读完的开销比走一次 Promise 往返还小;而且紧接着的 createPixelMap 和 4 倍重建本身就是异步的,这一处省掉的往返不会改变整体的响应形态。SDK 把它标成 deprecated since 20 并推同步版,本身就是基于这个判断。
改的过程中还有个容易看错的地方:紧跟着的 syncOrientation(...) 里的 sync 和这次改动没有关系,它指的是「让屏幕方向和图片长宽比同步」,不是「同步调用」。同一个词在同一段代码里出现两种含义,我在改到这里时才意识到名字会误导人。
改完再编一次:BUILD SUCCESSFUL,ArkTS:WARN 计数 0,deprecated 计数 0。
五、改完必须还原,而且得证明还原了
这一步是最容易忘的。compatibleSdkVersion 是装机门,提到 26 之后所有低于 26 的设备都装不上;更隐蔽的是,代码里那些 deviceInfo.sdkApiVersion < 26 的判断会全部变成死代码——分支还在,但永远走不到。
还原之后我做了逐字节比对,和改之前的备份 diff 完全一致。这不是形式主义:做实验改的文件,必须能证明它回到了原样,否则"临时改一下"会变成一次没人发现的静默变更。
有个细节帮了大忙:这个文件在本机一直在 git 的 skip-worktree 里(git ls-files -v 前缀是 S),因为它要写本机签名路径,不能提交。这意味着改动不会污染仓库,git status 也看不见它——好处是不用担心误提交,代价是必须自己负责确认还原,git 不会提醒你。
六、换一条渠道再查一遍
linter 报完 31 条,我手上的信息是"规则认识的弃用项都清了"。但我不知道规则不认识的东西还有没有。
于是换了另一条路:用对话式 Agent 读一遍代码。用的是 DevEco Code 的 plan 模式——只读,不改文件。选 plan 而不是默认模式有个现实原因:当时另一个 agent 正在同一个仓库里写代码,不能有两个写者。
问的问题就是把这次迁移说清楚:compatibleSdkVersion 从 6.0.0(20) 提到 26.0.0,主模块的 ArkTS 源码里有哪些 deprecated API 要迁移,给文件 + 行号 + 替代写法。
它的回答分两半,第一半让我愣了一下:
它说 px2vp 和 getMediaContent 不用改。理由是这两处已经是 this.getUIContext().px2vp(...) 和 getMediaContentSync(...)。它说的没错——因为它看到的是修完之后的代码。它跑的时候,第四节那 5 处已经改完了。这是纯读码分析的一个固有局限:它不知道历史,只能看到当前快照。
第二半是 linter 一条都没报的:两处 @ohos.deviceInfo 旧导入路径。
| 出现位置 | 行 | 原写法 | 改为 |
|---|---|---|---|
| 首页入口 | 3 | import deviceInfo from '@ohos.deviceInfo' | import { deviceInfo } from '@kit.BasicServicesKit' |
| 异兽超分查看器 | 6 | 同上 | 同上(该文件第 3 行已从 @kit.BasicServicesKit 导 BusinessError,合并为一行) |
这个改动看着简单,实际要处理一个细节:超分查看器那个文件的顶部本来就有一行从 @kit.BasicServicesKit 导入 BusinessError,新加的 deviceInfo 必须并进同一行,不能另起一行重复导入同一个模块——重复导入在 ArkTS 里是编译错误,不是警告。改完的导入区是这样:
import { image } from '@kit.ImageKit';
import { imageSuperResolution, visionBase } from '@kit.CoreVisionKit';
// 原先这里是 import { BusinessError } from '@kit.BasicServicesKit';
// 外加一行 import deviceInfo from '@ohos.deviceInfo'; 合并成一行
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
// 下面还有一行导入工程自己的异兽插画资源表,与本次迁移无关,略
调用处一行都不用动——deviceInfo.sdkApiVersion 这种写法两种导入方式都支持,改的只是 deviceInfo 这个标识符从哪儿来。这也是这类迁移不难却容易漏的原因:它在调用处没有任何痕迹,只有导入区那一行能看出来。
它还给了个我没注意到的旁证:同一个工程里另外两个文件早就是 @kit.BasicServicesKit 的新写法,只有这两处漏了。这个旁证的价值在于——如果全仓库都是旧写法,那是"时代问题";只有两处漏了,那是"漏改",后者才值得改。
这个发现比"多改两行"更有价值的地方在于:它说明了同一份代码里两种写法并存。新的 @kit.* 聚合导入是后来的推荐写法,旧的 @ohos.* 分散导入并没有被标 deprecated,所以 linter 完全不管。规则管不到这种"风格漂移",但读整个仓库能看出来。顺带一提,@kit.* 这种聚合式导入是 API 12 之后主推的形态,它把同一套能力收进一个 Kit 命名空间,代价是从旧文档抄来的导入语句不会报错、只是不会更新。
两条渠道的对照
| 检查项 | linter(编译期事实) | 对话式 Agent(读码分析) |
|---|---|---|
px2vp ×4 | ✅ 5 条 deprecated | ❌ 已修完,看不到 |
@ohos.deviceInfo ×2 | ❌ 一条不报 | ✅ 抓到,并给了同仓库旁证 |
| 设备容量(audio / media) | ✅ 26 条 | 未提及 |
| 2in1 不可用接口 | ✅ 1 条 | 未提及 |
两边都只看到一半,而且是不重叠的那一半。linter 靠规则命中,报得准、不漏它认识的项,但只覆盖有规则的;Agent 读码靠语义理解,能发现"不一致"这种规则表达不出来的问题,但看不见改动历史,也拿不到编译器级别的设备能力清单。

七、改完的验证
导入改完,全量搜一遍 @ohos. 前缀,只剩一条注释里提到 @ohos.measure,实际导入路径清零。
- 构建:
BUILD SUCCESSFUL in 21 s 822 ms,0 ERROR - 门禁:项目里 50 个
verify-*.mjs全部通过 - 模拟器(phone 实例,API 26):安装成功 → 启动成功 → 日志 446 行中 0 条
jscrash/Unhandled,ForegroundLifecycle EntryAbility与UIContent OnNewWant都在位,进程稳定 3 分钟未重启
模拟器画面停在了锁屏,没取到应用界面截图。查了日志,是模拟器实例的 UpdateWindowVisible: 0,不是应用没起来——ForegroundLifecycle 和 OnNewWant 都在,进程存活,能证明的是"装得上、起得来、不崩"。界面截图这一环我没有硬凑。
八、这套做法没覆盖的部分
- 只查了主模块。主模块之外的代码没进这次扫描范围,如果工程里还有别的模块,迁移债统计不完整。
os.*前缀的新 API 一个都没用。API 26 引入的新能力这次没碰,这篇只处理"旧用法要不要换",不涉及"新能力要不要用"。- 对话式 Agent 的结论没有全部核实。它说"
px2vp已迁移"是对的(当时确实改完了),但我没法假设它的每一条判断都准确——这次只采纳了它给的@ohos.deviceInfo那两处,并且逐条核对过行号和写法。AI 给的清单要当线索用,不能当结论用。 - 模拟器版本 vs 真机。模拟器是 phone 实例 API 26,真机上"低于 26 的会不会走到降级分支"这件事,靠模拟器验证不了。
九、收个尾
compatibleSdkVersion 写多少,决定了 linter 用哪一套规矩审你。装机门和检查口径是同一个字段的两副面孔——想看清 26 的债,就得让检查站到 26 的位置上,看完再把它放回去,并且能证明放回去了。
下一篇把这条线索往回接:上架包怎么做到可追溯——签名、APP 构建、预检和版本回滚。
更多推荐

所有评论(0)