一张插画完成 4 倍超分,只代表算法链路跑通。用户真正看到的却可能仍是一张“小图”:横构图被塞进竖屏,长边贴在手机窄边;系统栏与挖孔挤占画面;放大后拖不动,或者未放大时整页乱晃;控件常驻,又把本就不高的横屏画面遮掉近一半。
《山海万灵》最初把超分做成详情页中的双格对比卡,适合验证能力,却不适合长时间看图。后续版本把它升级成独立的全屏查看器。下面聚焦这段产品化过程:让图的构图决定屏幕方向,让内容真正铺满物理屏幕,再用状态约束手势、控件和降级路径。
一、先把“可查看”拆成六种状态
查看器不是“一个 PixelMap 加一个 Image”。它至少同时受重建阶段、当前图层、缩放倍数、屏幕方向和控件显隐影响。先把状态列清楚,后面的条件渲染才不会互相打架。
| 维度 | 取值 | 对界面的影响 |
|---|---|---|
| 重建阶段 | IDLE / RUNNING / DONE / FAILED / UNSUPPORTED | 决定提示、重试和降级文案 |
| 图层 | 低清原图 / 超分高清 | 决定主图与模糊垫层的数据源 |
| 缩放 | 1×~4× | 决定是否允许平移、是否强制隐藏控件 |
| 构图 | 横图 / 竖图 / 近方图 | 决定进入查看器后的屏幕方向 |
| 控件 | 显示 / 自动收起 | 决定顶栏、提示和恢复把手是否渲染 |
| 主题 | 亮色 / 深色 | 决定垫层透明度和浮层对比度 |
重建阶段用枚举表达,并给“不支持”单独留一个状态。若把它并入失败,用户会误以为重试可以解决;若什么都不显示,模拟器或无对应能力的设备就会停在“正在准备”。
enum SuperResolutionPhase { IDLE = 0, RUNNING = 1, DONE = 2, FAILED = 3, UNSUPPORTED = 4 } @State private phase: SuperResolutionPhase = SuperResolutionPhase.IDLE; @State private pane: string = 'high'; @State private lowRes: image.PixelMap | undefined = undefined; @State private enhanced: image.PixelMap | undefined = undefined; @State private zoom: number = 1; @State private offsetX: number = 0; @State private offsetY: number = 0; @State private chromeVisible: boolean = true; private srAvailable(): boolean { if (deviceInfo.sdkApiVersion < 26) { return false; } return canIUse('SystemCapability.AI.Vision.VisionBase'); }能力判断放在入口处,失败路径仍然先解码低清图。用户至少能继续查看插画,而不是被系统能力差异挡在空白页上。
private async prepare(): Promise<void> { if (!this.srAvailable()) { this.note = '这台设备暂不支持端侧超分,先显示低清原图。'; await this.loadLowRes(); if (this.phase !== SuperResolutionPhase.FAILED) { this.phase = SuperResolutionPhase.UNSUPPORTED; } return; } await this.run(); }二、方向必须由原图决定,而且要尽早决定
横图留在竖屏里,即使输出已经达到 2048×1364,显示时仍会被压成手机宽度。问题发生在展示层,继续提高超分输出尺寸没有意义。
查看器读取原图信息后立即判断宽高比。宽高比达到 1.2 才转横屏,竖图和近方图保持竖屏。阈值不能简单写成width > height:很多接近正方形的插画横竖屏显示面积相差很小,强制旋转只会增加一次视觉跳变。
const INPUT_LONG_SIDE: number = 512; const LANDSCAPE_RATIO: number = 1.2; private syncOrientation(width: number, height: number): void { if (this.orientationApplied) { return; } this.orientationApplied = true; const landscape = width > height * LANDSCAPE_RATIO; this.applyOrientation( landscape ? window.Orientation.LANDSCAPE : window.Orientation.PORTRAIT ); setTimeout(() => this.loadSafeInsets(), 600); } private async loadLowRes(): Promise<image.PixelMap | undefined> { const source = image.createImageSource(raw.buffer as ArrayBuffer); const info = await source.getImageInfo(); this.srcW = info.size.width; this.srcH = info.size.height; this.syncOrientation(info.size.width, info.size.height); const longSide = Math.max(info.size.width, info.size.height); const ratio = 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)) } }; return await source.createPixelMap(options); }方向判断放在低清解码阶段,而不是等超分完成后再做。这样用户进入查看器时就能看到正确的画布,不会先出现一条缩小的横图,约一秒后才突然旋转。
退出时则恢复UNSPECIFIED,让窗口重新跟随系统和用户设置;写死为PORTRAIT会改掉用户原有的旋转偏好。HarmonyOS 官方的窗口旋转开发指导也给出了运行时设置首选方向的接口和方向枚举。
三、铺满屏幕不等于让内容压进挖孔
查看器的根布局使用Stack,图区占满整屏,顶栏与状态提示叠在图上。主图用Contain保证异兽完整;同一张图再铺一层Cover + blur作为垫层,填补长宽比不一致留下的空白。
Stack({ alignContent: Alignment.Center }) { if (this.enhanced !== undefined) { Image(this.enhanced) .objectFit(ImageFit.Cover) .width('100%') .height('100%') .blur(40) .opacity(this.isDarkMode ? 0.18 : 0.5) Image(this.enhanced) .objectFit(ImageFit.Contain) .width('100%') .height('100%') } } .width('100%') .height('100%') .clip(true) .expandSafeArea( [SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END] )图区可以延伸到物理屏幕边缘,交互控件却必须留在安全区域内。横屏真机上,左侧 76px 左右属于挖孔与圆角区域,仅查询系统栏避让区会得到左右为 0,结果就是关闭按钮左半边被系统吞掉。
因此查看器同时读取系统栏、挖孔和导航指示条三种避让区,再按四个方向分别取最大值:
const types: window.AvoidAreaType[] = [ window.AvoidAreaType.TYPE_SYSTEM, window.AvoidAreaType.TYPE_CUTOUT, window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR ]; for (const type of types) { const area = hostWindow.getWindowAvoidArea(type); top = Math.max(top, px2vp(area.topRect.height)); bottom = Math.max(bottom, px2vp(area.bottomRect.height)); left = Math.max(left, px2vp(area.leftRect.width)); right = Math.max(right, px2vp(area.rightRect.width)); }旋转会改变系统栏所在边,因此窗口尺寸变化后还要重新读取一次。最终形成两层不同的规则:图区向安全区外扩展,按钮用safeTop / safeLeft / safeRight加内边距避让。
四、手势不是越多越好,关键是给状态加门
第一版全屏手势有两个相反的问题:允许单指拖动时,1× 状态下整页会跟着手指晃;只保留双指操作后,放大了还得一直搭两根手指才能挪图,实际操作显得迟钝。
最终规则很简单:
1. 双指负责 1×~4× 捏合,捏合中心的移动同时用于平移; 2. 单指只在zoom > 1时平移,1× 时直接返回; 3. 两类手势放进GestureMode.Parallel,缩放过程中不必换手; 4. 每次更新后把偏移夹在图像真实可移动范围内; 5. 回到 1× 时偏移归零,图像自动居中。
.gesture(GestureGroup(GestureMode.Parallel, PanGesture({ fingers: 1, distance: 4 }) .onActionStart(() => { this.offsetBaseX = this.offsetX; this.offsetBaseY = this.offsetY; }) .onActionUpdate((event: GestureEvent) => { if (this.zoom <= 1 || !event) { return; } this.offsetX = this.offsetBaseX + Number(event.offsetX); this.offsetY = this.offsetBaseY + Number(event.offsetY); this.clampOffsets(); }), PinchGesture({ fingers: 2, distance: 1 }) .onActionStart((event: GestureEvent) => { this.zoomBase = this.zoom; this.offsetBaseX = this.offsetX; this.offsetBaseY = this.offsetY; this.pinchStartX = Number(event.pinchCenterX); this.pinchStartY = Number(event.pinchCenterY); }) .onActionUpdate((event: GestureEvent) => { this.zoom = Math.min(4, Math.max(1, this.zoomBase * event.scale)); this.offsetX = this.offsetBaseX + Number(event.pinchCenterX) - this.pinchStartX; this.offsetY = this.offsetBaseY + Number(event.pinchCenterY) - this.pinchStartY; this.clampOffsets(); }) ))PanGesture 官方参考中的offsetX / offsetY表示从手势开始到当前的累计位移,因此要叠加在手势开始时保存的基准偏移上,不能在每帧重复累加。并行、互斥和顺序识别的选择可参考ArkUI 手势组合说明。
偏移上限必须根据Contain后的实际显示尺寸计算,而不是简单按原图像素裁切:
private clampOffsets(): void { if (this.zoom <= 1 || this.stageW <= 0 || this.stageH <= 0) { this.offsetX = 0; this.offsetY = 0; return; } const fit = Math.min(this.stageW / this.srcW, this.stageH / this.srcH); const maxX = Math.max(0, (this.srcW * fit * this.zoom - this.stageW) / 2); const maxY = Math.max(0, (this.srcH * fit * this.zoom - this.stageH) / 2); this.offsetX = Math.min(maxX, Math.max(-maxX, this.offsetX)); this.offsetY = Math.min(maxY, Math.max(-maxY, this.offsetY)); }这样某个方向原本就有Contain留白时,该方向的可拖范围会保持为 0,用户无法把留白继续拖进画面中央。
五、把控件从“常驻栏”改成“短暂工具”
横屏设备高度只有 1260px 时,旧顶栏和底栏合计占用 524px,遮挡达到 41.6%。继续把按钮缩小,只能缓解,无法解决看图时被控件打断的问题。
新方案把控件生命周期与重建、缩放状态绑定:
- 重建中、失败和不支持时保留底部状态区,因为用户需要阅读或重试; - 重建完成后不再渲染底栏,只显示 3.2 秒手势提示; - 顶栏静止 2.5 秒自动收起,轻点画面恢复; - 缩放大于 1× 时立即收起顶栏; - 控件收起后保留一个半透明把手,提供退出和恢复入口。
这里使用条件渲染,而不是把控件透明度设为 0。透明按钮仍可能参与命中测试,视觉上消失了,触摸却还会被它拦截。
| 横屏状态 | 控件占用高度 | 占屏比例 |
|---|---|---|
| 改造前:顶栏 + 常驻底栏 | 524px | 41.6% |
| 改造后:顶栏显示 | 133px | 10.6% |
| 改造后:静止自动收起 | 0px | 0% |
六、深色模式不能直接复用亮色垫层
模糊垫层的结果是“插画颜色 × 透明度 + 页面底色”。亮色插画在深色页面上若继续使用 0.5 透明度,两侧留白会形成突兀的灰白亮带。真机取样后,将深色模式透明度降到 0.18,亮色模式维持 0.5。
| 场景 | 底部留白 mean / p90 | 结果 |
|---|---|---|
| 深色模式,调整前 | 85.9 / 118.8 | 形成明显灰白带 |
| 深色模式,透明度 0.18 | 40.7 / 54.2 | 与深色页面融合 |
| 亮色模式 | 177.6 / 229.6 | 调整前后不变 |
这个参数不适合凭感觉选。页面底色亮度约为 23,原垫层采样约为 149,希望合成结果落在 45 左右,可反推透明度接近 0.17,因此工程值取 0.18。它仍保留插画色彩提示,又不会把深色主题切成两段。
七、重建完成不代表资源生命周期结束
分析器只在一次任务中存在,成功或失败都在finally中销毁。查看器退出时清理控件定时器、提示定时器,并把窗口方向恢复为UNSPECIFIED。
let analyzer: imageSuperResolution.ImageSRAnalyzer | undefined; try { analyzer = await imageSuperResolution.ImageSRAnalyzer.create(); const imageData: visionBase.ImageData = { pixelMap: source }; const request: visionBase.Request = { inputData: imageData }; const response = await analyzer.process(request); this.enhanced = response.pixelMap; this.phase = SuperResolutionPhase.DONE; } catch (error) { this.note = '这台设备没能完成端侧重建,先显示低清原图。'; this.phase = SuperResolutionPhase.FAILED; } finally { if (analyzer !== undefined) { await analyzer.destroy(); } } aboutToDisappear(): void { this.clearChromeTimer(); this.clearHintTimer(); this.applyOrientation(window.Orientation.UNSPECIFIED); }Core Vision Kit 的 API 索引可在 HarmonyOS ArkTS API 在线认证目录中查询,其中包含imageSuperResolution能力入口。
八、用三类构图验收,而不是只测一张方图
查看器在 API 26 真机上分别使用横图、竖图和方图验证。输入统一把长边控制在 512,输出保持 4 倍;横图自动转横屏,其余保持竖屏。
| 图源 | 输入 → 输出 | 方向与耗时 | 观察结果 |
|---|---|---|---|
| 赤鱬,3:2 横图 | 512×341 → 2048×1364 | 横屏,895~1515ms | 主图铺满整屏高度 |
| 巴蛇,2:3 竖图 | 341×512 → 1364×2048 | 竖屏,约 798ms | 主图铺满屏幕宽度 |
| 应龙,方图 | 512×512 → 2048×2048 | 竖屏,957~1133ms | 顶栏不压状态栏 |
除了画面,还验证了低清/高清切换、横竖屏关闭、方向恢复、控件 2.5 秒收起、亮暗主题对比和偏移边界。当前版本的 HAP 构建成功,超分专属源码门禁通过,覆盖能力判断、对象字面量请求、解码期降采样、屏幕方向、全屏图层和详情页入口。
放大后的单指平移已经按zoom > 1加入状态门,并通过确定性源码门禁;这项交互仍需在下一次真机连接时补一轮手感复验。它不影响横竖屏、控件收隐和超分结果的既有实测结论,但会决定 2×~4× 浏览时的最终顺滑度。
九、这次产品化留下的四条判断
第一,算法输出尺寸和屏幕可视尺寸是两件事。横图若仍装进竖屏窄边,输出再大也只是“清晰的小图”。
第二,全屏展示要区分内容层与交互层。内容可以扩到物理边缘,按钮必须避让系统栏、挖孔和导航指示条。
第三,手势冲突可以通过状态门化解。1× 禁止单指平移,放大后允许单指平移,比“始终能拖”和“始终要双指”都自然。
第四,沉浸感来自控件生命周期。真正看图时,控件应该退场;需要操作时,它又必须有明确、可触达的恢复入口。端侧超分把像素重建出来,查看器才负责把这些像素真正交到用户眼前。