一张插画完成 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(): Promisevoid { 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(): Promiseimage.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。透明按钮仍可能参与命中测试视觉上消失了触摸却还会被它拦截。横屏状态控件占用高度占屏比例改造前顶栏 常驻底栏524px41.6%改造后顶栏显示133px10.6%改造后静止自动收起0px0%六、深色模式不能直接复用亮色垫层模糊垫层的结果是“插画颜色 × 透明度 页面底色”。亮色插画在深色页面上若继续使用 0.5 透明度两侧留白会形成突兀的灰白亮带。真机取样后将深色模式透明度降到 0.18亮色模式维持 0.5。场景底部留白 mean / p90结果深色模式调整前85.9 / 118.8形成明显灰白带深色模式透明度 0.1840.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横屏8951515ms主图铺满整屏高度巴蛇2:3 竖图341×512 → 1364×2048竖屏约 798ms主图铺满屏幕宽度应龙方图512×512 → 2048×2048竖屏9571133ms顶栏不压状态栏除了画面还验证了低清/高清切换、横竖屏关闭、方向恢复、控件 2.5 秒收起、亮暗主题对比和偏移边界。当前版本的 HAP 构建成功超分专属源码门禁通过覆盖能力判断、对象字面量请求、解码期降采样、屏幕方向、全屏图层和详情页入口。放大后的单指平移已经按zoom 1加入状态门并通过确定性源码门禁这项交互仍需在下一次真机连接时补一轮手感复验。它不影响横竖屏、控件收隐和超分结果的既有实测结论但会决定 2×4× 浏览时的最终顺滑度。九、这次产品化留下的四条判断第一算法输出尺寸和屏幕可视尺寸是两件事。横图若仍装进竖屏窄边输出再大也只是“清晰的小图”。第二全屏展示要区分内容层与交互层。内容可以扩到物理边缘按钮必须避让系统栏、挖孔和导航指示条。第三手势冲突可以通过状态门化解。1× 禁止单指平移放大后允许单指平移比“始终能拖”和“始终要双指”都自然。第四沉浸感来自控件生命周期。真正看图时控件应该退场需要操作时它又必须有明确、可触达的恢复入口。端侧超分把像素重建出来查看器才负责把这些像素真正交到用户眼前。
阅读完成 · 觉得有帮助?