鸿蒙PC 桌面应用实战:ArkTS + Canvas 自绘高斯点云工作台,兼论 ArkUI 跨平台框架的选型边界
鸿蒙PC 桌面应用实战:ArkTS + Canvas 自绘高斯点云工作台,兼论 ArkUI 跨平台框架的选型边界
欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
导读:本文围绕开源鸿蒙PC 生态,以一个真实可运行的「澜境 · Gaussian Studio」工程为样本,完整拆解鸿蒙PC 桌面形态(2in1)应用的窗口断点、鼠标交互、Canvas 2D 自绘渲染管线与前端状态机;同时把 ArkUI 跨平台框架 作为贯穿全文的选型参照,讨论在鸿蒙PC 上「哪些层适合跨端复用、哪些层必须保持原生」。文中所有代码均摘自工程源码,所有界面截图均为鸿蒙PC 设备上的完整桌面运行截图。

一、为什么要在鸿蒙PC 上讨论一个「三维重建工作台」
很多开发者把鸿蒙应用等同于「手机上的一屏 UI」,但鸿蒙PC 带来的变化不是简单的分辨率放大。它至少改变了五件事:窗口可以自由缩放、鼠标与触控板成为主输入、键盘快捷键与悬停态成为基本交互语言、任务可以长时间在后台运行、本地磁盘与本地算力可以被当作系统资源来调度。这些变化决定了:把一个手机页面直接搬到鸿蒙PC 上,几乎一定会得到一个「四周留白、控件稀疏、鼠标无处下手」的界面。也正因为如此,鸿蒙PC 应用的架构讨论,天然要和具体跨平台框架放在一起谈——因为跨平台框架解决的是「一套界面代码跑在多个系统」,而桌面形态解决的是「同一套代码如何适应宽屏、鼠标与多窗口」,两者并不等价。
选择「三维重建/数字馆藏」作为样例场景,是因为它同时命中鸿蒙PC 的三项优势。第一,文物的多视角影像往往涉及馆藏数据,不适合随意外传,端侧处理天然契合「数据不出本机」的诉求;第二,三维场景预览需要一块足够大的连续画布,鸿蒙PC 的宽屏比手机更合适;第三,重建、优化、导出都是耗时任务,桌面设备更适合承载长任务与任务中心。3D Gaussian Splatting(3DGS)近年在实时新视角合成领域很受关注,其核心思路是用大量带协方差与球谐系数的三维高斯基元表达场景,再通过可微光栅化渲染,因此一个面向它的工作台必然包含「视口 + 参数检查器 + 流水线 + 质量诊断」这几块。
这里必须先做一个非常重要的交代,避免读者产生误读:本文配套工程是一个界面与交互原型,不是一套已经训练出高斯模型的重建引擎。界面上的 1.24M Gaussians、PSNR 31.8 dB、SSIM 0.961、LPIPS 0.083、本机 NPU 加速、第 30,000 次迭代完成 均为演示用占位文案;三维视口是 ArkUI 的 Canvas 2D 画布用确定性伪随机点云实时绘制出来的视觉效果。把这一点讲清楚,比把截图包装成「端侧大模型推理」更有价值——因为读者可以据此判断哪些代码值得直接复用(窗口适配、交互、布局、状态管理),哪些必须自己补上(真实重建、推理、质量评测)。后文第九节会给出一条从原型走向真实管线的可落地路线。
为什么要引入 ArkUI-X 跨平台框架 作为对照?因为在开源鸿蒙PC 社区里,团队最常问的一个问题是:「我们已经有一套 ArkTS/ArkUI 界面,要不要为鸿蒙PC 单独写一套,或者干脆引入 ArkUI-X 跨平台框架把界面复用到更多系统?」这个问题没有统一答案。本文的做法是:先把鸿蒙PC 原生工程的每一层拆开,标出哪些是平台无关能力、哪些是强平台相关能力,再给出一份可执行的选型矩阵。这样讨论跨平台框架时才不会停留在口号层面。本文所称「跨平台框架」如无特别说明,即指 ArkUI-X 跨平台框架;其他方案只在对比表中作为技术参照出现。
二、工程全貌:一个零依赖、零 .so 的鸿蒙PC 原生工程
先看事实。工程根目录名 Gaussian_ohos,应用名「澜境 · Gaussian Studio」,包名 com.lanjing.gaussianstudio,模块描述是「端侧 3DGS 重建与质量诊断工作台」。工程级 oh-package.json5 里 dependencies 是空对象,entry/oh-package.json5 同样为空,hvigor/hvigor-config.json5 也为空;所有源码只导入了 @kit.AbilityKit、@kit.ArkUI、@kit.PerformanceAnalysisKit 三个系统 Kit,其余都是本地相对导入。换句话说,这是一个 100% 原生 ArkTS + ArkUI 声明式 UI 的 Stage 模型应用,没有 WebView、没有 three.js、没有 C++/NAPI、没有 .so,也没有引入任何第三方跨平台框架。构建产物 entry-default-signed.hap 约 482 KB,base_native_libs.json 里的 libs 为空,进一步印证了它并不包含原生渲染库。
这一点对「跨平台框架」的讨论尤其关键:如果工程里已经嵌入了某个跨平台框架,我们讨论的就是「框架如何适配鸿蒙PC」;而这里的工程恰好是纯原生实现,因此更适合用来回答反过来那个问题——「如果将来要引入 ArkUI-X 跨平台框架,哪些代码可以平滑搬过去,哪些代码会卡住」。后文第七节会沿着这条线索展开。
| 文件 / 目录 | 在鸿蒙PC 工程中的职责 |
|---|---|
AppScope/app.json5 | 应用级信息:包名 com.lanjing.gaussianstudio、版本 1.0.0、厂商标识 |
build-profile.json5 | 签名配置、product default、compatibleSdkVersion 与 targetSdkVersion 均为 5.0.5(17)、runtimeOS: HarmonyOS |
oh-package.json5 | 工程描述明确写着面向 HarmonyOS PC;license: Apache-2.0;dependencies: {} |
entry/src/main/module.json5 | 只声明 deviceTypes: ['2in1'],桌面形态适配的起点 |
ets/entryability/EntryAbility.ets | 唯一 UIAbility,loadContent('pages/Index') 并打三条 hilog |
ets/pages/Index.ets | 唯一页面(约 620 行):16 个 @State、顶栏、图标栏、三栏工作区、流水线与状态栏 |
ets/common/Models.ets | 三个文物项目与四段流水线的字面量数据 |
ets/common/Theme.ets | 深色配色与字号常量 |
ets/components/AppIcon.ets | 用 Path 命令字符串手绘的二十余个矢量图标,无图片资源 |
ets/components/GaussianViewport.ets | 三维视口:Canvas、拖拽旋转、双指捏合、滚轮缩放、四种着色模式 |
ets/components/InspectorPanel.ets | 右侧场景检查器:质量环、三项指标、质量预设、两个 Slider、优化按钮 |
ets/components/ProjectSidebar.ets | 左侧项目列表、搜索框、工作空间容量条 |
ets/components/WorkspaceDialogs.ets | 新建重建、导出场景、任务中心、偏好、视图帮助五种对话框 |
ets/rendering/GaussianRenderer.ets | 唯一的「渲染引擎」:确定性点云生成 + 2D 软件投影与绘制 |
整个应用的分层关系如下图所示。左侧是自上而下的五层原生实现,右侧标出了 ArkUI-X 跨平台框架在其中的可复用边界:声明式界面层是跨端友好的,而 2in1 设备形态、鼠标轴事件、高分屏像素换算与 Canvas 渲染管线都带有明显的鸿蒙PC 平台属性。

图 1 鸿蒙PC 原生工程分层架构,以及 ArkUI-X 跨平台框架的可复用边界(作者自绘)
需要说明的是,构建过程中出现的 *.msgpack、*.protoBin 文件位于 entry/build/default/cache/.../CompileArkTS/ 下,是 ArkTS 编译器的中间缓存与转译表示,不是模型权重;resources 目录里只有两个 app_icon.svg 和三个 JSON 资源,不存在 .ply、.splat、.onnx 之类的模型文件。把这类构建产物误当成「端侧模型」是阅读鸿蒙PC 工程时很常见的一个坑。
三、鸿蒙PC 真机运行截图:完整桌面界面
下面四张图都来自鸿蒙PC 设备上的实际运行界面,且均为完整桌面截图而非局部裁剪:可以看到屏幕底部的系统 Dock、右上角的系统状态栏与时间、桌面上同时运行的 DevEco Code 开发环境窗口,以及置于前台的「澜境」应用窗口。这种「桌面外壳 + 应用窗口 + 开发环境」同时可见的画面,能够一眼确认运行目标是鸿蒙PC,而不是手机模拟器或网页预览。图片均已上传图床,便于在社区文章中直接引用。

图 2 鸿蒙PC 设备完整桌面:项目「影青刻花盏」,三维场景页签下的点云视口与场景检查器,左侧可见工程目录树,底部可见系统 Dock 与状态栏
图 2 是默认三栏工作台:左侧项目侧栏列出「青釉弦纹瓶 / 影青刻花盏 / 铜鎏金兽」三个项目,中间是带透视网格地面的点云视口,右侧检查器显示 92.8 的质量环、31.8 / 0.961 / 0.083 三项指标、本机 NPU 加速 卡片、点尺寸 1.00× 与 不透明度 82% 两个 Slider。视口顶部还有「高斯 / 点云 / 法线 / 置信度」四个着色模式按钮。桌面宽度足够时,三栏同时展开而不互相挤压,这正是鸿蒙PC 相对手机端最直观的差别。

图 3 切换项目到「青釉弦纹瓶」后开启薄弱区域标记,视口出现「肩部反光区域 · 覆盖率 72%」提示气泡,质量环变为 94.6
图 3 展示了状态联动:切换项目后视口从盏形变成瓶形,右侧指标与质量环同步刷新;打开「薄弱区域标记」开关,视口里叠加一个提示气泡。这组交互全部由 ArkUI 状态驱动,没有重新创建窗口、也没有加载新的资源文件。注意右侧质量预设此时切到了「精细」,但视口里的点数量其实没有变化——这一点将在第六节解释。

图 4 切换到「重建日志」页签,完整显示 8 行带时间戳的端侧重建日志,右侧检查器保持可见
图 4 是第三个页签。日志从「创建端侧重建任务」到「质量评估完成」按时间倒序排列,包含特征提取、位姿优化、迭代 loss、自适应密度控制等术语。请再次注意:这 8 行是页面里的静态字符串数组,用于演示任务中心的呈现形式,不是运行时 hilog 输出。把它当成真实训练日志去做性能结论,是阅读这类鸿蒙PC 原型工程时最容易踩的第二个坑。

图 5 在鸿蒙PC 桌面打开 DevEco 的「项目结构 → 签名配置」,应用窗口保持在前台;可见完整桌面 Dock、状态栏与工程结构树
图 5 补充了工程侧的证据:DevEco 的 Project/Product、产品信息、签名配置、编译选项 面板与应用窗口同屏,左侧是 Gaussian_ohos 的 AppScope / entry / hvigor 目录树。它同时说明了一个很实际的鸿蒙PC 开发事实——签名配置与 bundleName 绑定,换机器或换调试证书后必须重新签名才能在设备上安装。文章第十节会把这条整理进复现清单。
四、桌面形态适配:从「一屏页面」到「鸿蒙PC 工作台」
4.1 设备形态声明是第一道门槛
适配鸿蒙PC 的第一步不是布局,而是声明。工程在 entry/src/main/module.json5 里只写了 2in1:
{
module: {
name: 'entry',
type: 'entry',
description: '$string:module_desc',
mainElement: 'EntryAbility',
deviceTypes: ['2in1'],
deliveryWithInstall: true,
installationFree: false,
pages: '$profile:main_pages',
abilities: [
{
name: 'EntryAbility',
srcEntry: './ets/entryability/EntryAbility.ets',
description: '$string:EntryAbility_desc',
icon: '$media:app_icon',
label: '$string:EntryAbility_label',
startWindowIcon: '$media:app_icon',
startWindowBackground: '$color:start_window_background',
exported: true,
skills: [
{
entities: ['entity.system.home'],
actions: ['action.system.home']
}
]
}
]
}
}
2in1 是 PC / 二合一设备形态,这意味着 HAP 从打包阶段就面向桌面分发。好处是系统不会按手机窄屏逻辑去约束窗口;代价是这个包不能指望直接装到手机或普通平板上。对开源鸿蒙PC 生态的开发者来说,这条声明是「我要为鸿蒙PC 写应用」最明确的信号,也是后面所有断点、鼠标、悬停设计的前提。如果同一套界面还要覆盖手机和平板,就需要在这里增加设备形态,并让布局断点覆盖更宽的区间——这也正是引入 ArkUI-X 跨平台框架时首先要重新审视的配置之一。
4.2 用 onAreaChange 手写 1180 / 950 双断点
工程没有使用 GridRow、SideBarContainer 或媒体查询,而是用最朴素的方式做响应式:根容器测量自身宽度,写入 @State windowWidth,再用两个阈值决定侧栏是否出现。
// entry/src/main/ets/pages/Index.ets : 412-424
if (this.windowWidth >= 1180) {
ProjectSidebar({
projects: this.projects,
selectedId: this.selectedId,
onSelect: (id: number) => this.selectProject(id),
onNew: () => {
this.dialogMode = 'new';
}
})
.width(226)
.height('100%')
.border({ width: { right: 1 }, color: StudioColors.subtleBorder })
}
// entry/src/main/ets/pages/Index.ets : 530-557(回调体按阅读长度折叠,语义不变)
if (this.windowWidth >= 950) {
InspectorPanel({
project: this.currentProject(),
pointScale: this.pointScale,
splatOpacity: this.splatOpacity,
showWeakAreas: this.showWeakAreas,
qualityMode: this.qualityMode,
isOptimizing: this.isOptimizing && this.optimizingProjectId === this.selectedId,
optimizeProgress: this.optimizeProgress,
onPointScale: (value: number) => { this.pointScale = value; },
onOpacity: (value: number) => { this.splatOpacity = value; },
onWeakAreas: (value: boolean) => { this.showWeakAreas = value; },
onQualityMode: (value: string) => {
this.qualityMode = value;
this.notify('已切换至' + value + '预设,将在下次优化时生效');
},
onOptimize: () => this.startOrPauseOptimization()
})
.width(288)
.height('100%')
.border({ width: { left: 1 }, color: StudioColors.subtleBorder })
}
// entry/src/main/ets/pages/Index.ets : 584-588
.width('100%')
.height('100%')
.onAreaChange((oldArea: Area, newArea: Area) => {
this.windowWidth = Number(newArea.width);
})
三段代码合起来构成一个非常清晰的桌面布局策略:宽度不小于 1180 vp 时是「图标栏 64 + 项目侧栏 226 + 自适应视口 + 检查器 288」的完整三栏;在 950–1179 vp 之间收起项目侧栏,保留视口与检查器;小于 950 vp 时只保留图标栏与视口,把有限的宽度全部让给画布。中间视口始终用 layoutWeight(1) 吃掉剩余空间,因此窗口被鼠标拖宽或拖窄时不会溢出。顶栏固定 58、状态栏固定 25,面包屑、Tab、工具按钮这些桌面化元素才有稳定的落位。

图 6 鸿蒙PC 上的三栏 → 两栏 → 单栏布局策略,阈值 1180 / 950(作者自绘)
手写断点的优点是零依赖、行为完全可预测,也不会因为框架版本升级而改变布局;缺点是阈值散落在页面代码里,跨多个页面复用时要自己抽常量。这里给出一个可直接放进 ets/common/ 的小工具,把阈值收敛成纯函数,避免同一个数字在页面里出现多次:
export type PcLayoutMode = 'triple' | 'double' | 'single';
export function layoutModeOf(widthVp: number): PcLayoutMode {
if (widthVp >= 1180) {
return 'triple';
}
if (widthVp >= 950) {
return 'double';
}
return 'single';
}
这个函数没有任何系统 Kit 依赖,属于典型的「平台无关逻辑」,在 ArkUI-X 跨平台框架里同样可以直接复用;真正需要平台适配的,是下面这些输入事件。
4.3 鼠标、悬停与高分屏:鸿蒙PC 交互的三个补完点
桌面用户不会像手机用户那样只用手指。GaussianViewport.ets 里除了 PanGesture 与 PinchGesture,还专门用 onAxisEvent 接住了滚轮:
// entry/src/main/ets/components/GaussianViewport.ets : 100-141
Canvas(this.context)
.width('100%')
.height('100%')
.onReady(() => this.draw())
.onAreaChange((oldArea: Area, newArea: Area) => {
const density = this.getUIContext().vp2px(1);
this.widthPx = Number(newArea.width) * density;
this.heightPx = Number(newArea.height) * density;
this.draw();
})
.gesture(
PanGesture({ fingers: 1, distance: 1 })
.onActionStart(() => {
this.startYaw = this.camera.yaw;
this.startPitch = this.camera.pitch;
this.startPanX = this.camera.panX;
this.startPanY = this.camera.panY;
})
.onActionUpdate((event: GestureEvent) => {
if (this.toolMode === 'pan') {
this.camera.panX = this.startPanX + event.offsetX;
this.camera.panY = this.startPanY + event.offsetY;
} else {
this.camera.yaw = this.startYaw + event.offsetX * 0.008;
this.camera.pitch = Math.max(-1.15, Math.min(1.15, this.startPitch + event.offsetY * 0.006));
}
this.draw();
})
)
.parallelGesture(
PinchGesture({ fingers: 2 })
.onActionStart(() => {
this.startZoom = this.camera.zoom;
})
.onActionUpdate((event: GestureEvent) => {
this.setZoom(this.startZoom * event.scale);
})
)
.onAxisEvent((event: AxisEvent) => {
const delta = event.getVerticalAxisValue();
this.setZoom(this.camera.zoom * Math.exp(-delta * 0.08));
})
三个细节值得单独拎出来。
其一,getUIContext().vp2px(1) 把 vp 尺寸换算成物理像素。鸿蒙PC 常接高 DPI 显示器,如果直接用 vp 数值当作画布像素,绘制结果会偏小或者出现锯齿。源码用 new CanvasRenderingContext2D(this.settings, LengthMetricsUnit.PX) 明确把上下文单位设成 PX,再在 onAreaChange 里同步 widthPx / heightPx,这条链路是高分屏下不糊的关键。
其二,滚轮缩放用指数映射 zoom * exp(-delta * 0.08),并在 setZoom 里钳制到 [0.55, 2.4];俯仰角钳制到 [-1.15, 1.15]。指数映射让小幅滚动细腻、大幅滚动仍然可控,钳制则避免相机翻转或模型缩到不可见。这类「交互手感」参数在手机端通常不存在,属于鸿蒙PC 适配的典型增量。
其三,悬停态由通用按钮组件统一处理:
// entry/src/main/ets/components/StudioControls.ets : 11-29
@State private hovered: boolean = false;
build() {
Stack() {
AppIcon({
iconColor: this.selected ? StudioColors.accent : this.hovered ? StudioColors.primaryText : StudioColors.secondaryText,
name: this.icon,
iconSize: this.iconSize,
stroke: 1.65
})
}
.width(this.buttonSize)
.height(this.buttonSize)
.backgroundColor(this.selected ? StudioColors.accentSoft : this.hovered ? StudioColors.hover : Color.Transparent)
.borderRadius(8)
.onHover((inside: boolean) => {
this.hovered = inside;
})
.onClick(() => this.action())
}
onHover + @State hovered 让二十多个图标按钮共享同一套桌面反馈。把这类反馈抽到组件里,而不是散落在每个按钮上,是鸿蒙PC 界面保持一致性最省事的做法。窗口尺寸变化时,onAreaChange 只更新画布尺寸并重绘,相机状态保存在组件成员里不会被重置——这也是桌面用户拖窗口时不会「视角突然跳回默认」的原因。
从跨平台框架视角看,PanGesture、PinchGesture 这类跨端手势相对容易在 ArkUI-X 跨平台框架中保持一致;而 onAxisEvent(滚轮轴事件)、onHover(悬停态)、vp2px(高分屏换算)以及 deviceTypes: ['2in1'](桌面分发形态)都强依赖鸿蒙PC 的平台能力。若将来把界面层迁到 ArkUI-X 跨平台框架,这几个点应当通过平台桥接单独实现,而不是假设「一套代码自动具备桌面交互」。这也是本文反复强调的选型边界:跨平台框架降低的是界面复用成本,不降低桌面形态适配成本。
五、渲染核心:Canvas 2D 上的软件 Splat 管线
5.1 数据结构决定了能力上限
先看清渲染器操作的是什么。GaussianRenderer.ets 定义的点只有位置、尺寸、透明度、深度和三组预计算颜色:
// entry/src/main/ets/rendering/GaussianRenderer.ets : 1-32
export interface CameraState {
yaw: number;
pitch: number;
zoom: number;
panX: number;
panY: number;
}
interface GaussianPoint {
x: number;
y: number;
z: number;
size: number;
alpha: number;
depth: number;
color: string;
normalColor: string;
heatColor: string;
}
class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
next(): number {
this.seed = (this.seed * 1664525 + 1013904223) % 4294967296;
return this.seed / 4294967296;
}
}
SeededRandom 是线性同余发生器,固定种子意味着每次进入页面生成的点云完全一致,旋转、缩放、切换着色模式时不会出现「模型闪一下变了」的跳变。对一个演示型鸿蒙PC 应用来说,确定性比随机性更重要;对真实 3DGS 来说则完全不够——真实高斯基元至少需要三维均值、协方差(通常由缩放与四元数旋转表达)、不透明度与球谐系数,还要配合按视锥剔除与分块光栅化。这里没有这些字段,因此它是「长得像高斯点云的 2D 绘制」,不是 3DGS 光栅化器。
5.2 用参数化旋转体「造」出三件文物
点云不是从文件加载的,而是按器形参数方程采样出来的。以青釉弦纹瓶为例:
// entry/src/main/ets/rendering/GaussianRenderer.ets : 94-124
private vaseRadius(t: number): number {
if (t < 0.08) {
return 0.48 + t * 2.2;
}
if (t < 0.36) {
return 0.66 + Math.sin((t - 0.08) / 0.28 * Math.PI * 0.7) * 0.43;
}
if (t < 0.66) {
return 1.02 - (t - 0.36) * 0.58;
}
if (t < 0.84) {
return 0.85 - (t - 0.66) * 2.7;
}
return 0.36 + Math.pow((t - 0.84) / 0.16, 1.4) * 0.12;
}
private createVase(): void {
const random = new SeededRandom(7319);
for (let index = 0; index < 26500; index++) {
const t = random.next();
const theta = random.next() * Math.PI * 2;
const flute = Math.sin(theta * 12 + t * 2) * 0.009;
const rings = Math.sin(t * 94) * 0.008;
const radius = this.vaseRadius(t) + flute + rings;
const x = Math.cos(theta) * radius;
const z = Math.sin(theta) * radius;
const y = (t - 0.5) * 3.55;
const key = Math.max(0, Math.cos(theta + 0.65)) * 0.72;
const rim = Math.pow(t, 5) * 0.34;
const groove = Math.max(0, Math.sin(t * 94)) * 0.12;
this.addPoint(x, y, z, 0.012 + random.next() * 0.017, 0.34 + key + rim + groove, 0, random);
}
}
五段分段函数 vaseRadius(t) 描述瓶底、腹、肩、颈、口的轮廓;sin(theta * 12 + t * 2) 叠出十二道弦纹,sin(t * 94) 叠出细密的拉坯环纹;key 用余弦模拟固定方向的主光,rim 提亮口沿,groove 强化凹处明暗。盏与兽分别由 createBowl()(约 27,100 点)和 createGuardian()(六个椭球组合,约 29,500 点)生成。每个器形约 2.7–3.0 万点,全部是运行时现算,没有任何模型文件——这解释了为什么 HAP 只有几百 KB,也解释了为什么界面上写着 1.24M Gaussians 却能在纯 CPU 软件绘制下流畅拖动:真实绘制的点数比文案少了一到两个数量级。
5.3 一帧是怎么画出来的
render() 的顺序是:清屏 → 径向渐变背景 → 透视地面网格 → 阴影椭圆 → 相机旋转求深度 → 深度排序 → 透视投影 → 逐点绘制椭圆 → 恢复 globalAlpha。
// entry/src/main/ets/rendering/GaussianRenderer.ets : 213-249
const cosineYaw = Math.cos(camera.yaw);
const sineYaw = Math.sin(camera.yaw);
const cosinePitch = Math.cos(camera.pitch);
const sinePitch = Math.sin(camera.pitch);
for (let index = 0; index < this.points.length; index++) {
const point = this.points[index];
const rotatedX = cosineYaw * point.x - sineYaw * point.z;
const rotatedZ = sineYaw * point.x + cosineYaw * point.z;
point.depth = sinePitch * point.y + cosinePitch * rotatedZ;
}
this.points.sort((first: GaussianPoint, second: GaussianPoint) => first.depth - second.depth);
for (let index = 0; index < this.points.length; index++) {
const point = this.points[index];
const rotatedX = cosineYaw * point.x - sineYaw * point.z;
const rotatedZ = sineYaw * point.x + cosineYaw * point.z;
const rotatedY = cosinePitch * point.y - sinePitch * rotatedZ;
const perspective = 6 / Math.max(2.4, 6 - point.depth);
const screenX = centerX + rotatedX * scale * perspective;
const screenY = centerY - rotatedY * scale * perspective;
const radius = Math.max(0.45, point.size * scale * perspective * pointScale);
if (screenX < -radius || screenX > width + radius || screenY < -radius || screenY > height + radius) {
continue;
}
context.fillStyle = mode === '法线' ? point.normalColor : mode === '置信度' ? point.heatColor : point.color;
context.globalAlpha = Math.min(1, point.alpha * opacity);
if (mode === '点云') {
context.fillRect(screenX, screenY, Math.max(0.7, radius * 0.65), Math.max(0.7, radius * 0.65));
} else {
context.beginPath();
context.ellipse(screenX, screenY, radius * 1.25, radius * 0.72,
Math.atan2(rotatedY, rotatedX) * 0.28, 0, Math.PI * 2);
context.fill();
}
}
context.globalAlpha = 1;
几个设计点值得注意。三角函数只在每帧开头算一次,避免在数万次循环里反复求值;深度排序是画家算法(由远及近覆盖),用一次 sort 换取近似的遮挡关系;透视因子 6 / max(2.4, 6 - depth) 中的 max 防止相机贴近时分母趋零导致点被放大到失控;屏幕外的点用一次包围判断直接跳过;椭圆的长短轴按 1.25 : 0.72 拉伸,并用 atan2(rotatedY, rotatedX) * 0.28 做轻微旋转,视觉上更像有方向的 Splat。addPoint() 已经把物体色、法线色、置信度热力色预先算成 rgb(...) 字符串,因此循环里只做选择、不做字符串拼接,这是保证逐点绘制不掉帧的必要优化。
绘制调度则由组件负责,用 16 ms 的单次定时器把高频事件合并成一帧:
// entry/src/main/ets/components/GaussianViewport.ets : 71-81
private draw(): void {
if (this.drawTimer >= 0) {
return;
}
this.drawTimer = setTimeout(() => {
this.drawTimer = -1;
const density = this.getUIContext().vp2px(1);
this.renderer.render(this.context, this.widthPx, this.heightPx, this.camera, this.variant,
this.pointScale, this.splatOpacity, this.renderMode, density);
}, 16);
}
PanGesture 的 onActionUpdate 在鼠标拖动时可能一帧内触发多次,这个「已有定时器就不再排新任务」的写法把多次失效请求合并为一次重绘;组件销毁时 aboutToDisappear() 会 clearTimeout,避免页面退出后仍有回调。四种着色模式(高斯 / 点云 / 法线 / 置信度)只改变 fillStyle 的选择与是否用 fillRect,不重新生成点云,因此切换非常轻。
对鸿蒙PC 而言,这套软件渲染的价值在于零依赖、零原生库、任何设备都能跑起来,非常适合做交互原型与布局验证;局限也很直接:三万点量级尚可,一旦真的要渲染百万级高斯基元,sort 的复杂度、逐点 ellipse 的路径开销、每点切换 fillStyle/globalAlpha 的状态切换都会成为瓶颈,必须转向 XComponent + GPU 光栅化。把这条边界写清楚,比含糊地说「已在鸿蒙PC 上实现端侧 3DGS」更诚实,也更有助于后来者选对技术路线。同样地,如果引入 ArkUI-X 跨平台框架,Canvas 2D 这类基础绘制能力通常属于跨端可复用部分,但 GPU 视口与设备侧推理仍应留在原生层,这与第四节得出的边界是一致的。
六、状态机与数据流:把「重建流程」做成可演示的前端闭环
鸿蒙PC 工作台的魅力在于多面板联动:点左侧项目,中间视口换模型,右侧指标刷新,底部流水线跟着变;拖动右侧 Slider,视口立刻改变 Splat 大小与透明度;点击优化按钮,进度条走完再弹一条 toast。这一切由 Index.ets 里的 16 个 @State 驱动,没有路由跳转、没有数据库、没有网络请求。
数据底座是 Models.ets 里的两个纯函数:
// entry/src/main/ets/common/Models.ets : 22-36 / 66-72
export function createProjects(): ReconstructionProject[] {
return [
{
id: 1,
name: '青釉弦纹瓶',
collection: '宋代器物研究组',
variant: 0,
images: 186,
gaussians: '1.24M',
score: '94.6',
resolution: '0.18 mm',
updated: '刚刚更新',
accent: '#72D9BE',
duration: '08:42'
}
// id 2「影青刻花盏」、id 3「铜鎏金兽」结构相同
];
}
export function createPipeline(project: ReconstructionProject): PipelineStage[] {
return [
{ index: '01', title: '影像对齐', detail: project.images.toString() + ' / ' + project.images.toString() + ' 张', state: '完成' },
{ index: '02', title: '稀疏重建', detail: '42.8K 特征点', state: '完成' },
{ index: '03', title: '高斯优化', detail: project.gaussians + ' Gaussians', state: '完成' },
{ index: '04', title: '质量评估', detail: project.score + ' 综合评分', state: '完成' }
];
}
createPipeline() 用当前项目的字段拼出底部四张卡片,所以切换项目时「186 / 186 张」「1.24M Gaussians」「94.6 综合评分」会同步变化。这是 ArkUI 声明式 UI 最典型的用法:数据是一个纯函数的结果,UI 只负责把结果画出来,不需要手工同步 DOM 式的节点。
日志页同样是一份静态数组:
// entry/src/main/ets/pages/Index.ets : 31-40
private readonly logLines: string[] = [
'[21:08:42] INFO 质量评估完成,综合得分 94.6 / 100',
'[21:08:39] INFO 合并 8 个局部高斯块,生成 1,248,632 个高斯',
'[21:08:12] INFO 第 30,000 次迭代完成,loss = 0.0048',
'[21:06:54] INFO 自适应密度控制:分裂 18.4K,克隆 9.2K,剪枝 3.1K',
'[21:04:31] INFO NPU 混合精度优化已启用,峰值内存 3.8 GB',
'[21:02:18] INFO COLMAP 位姿优化完成,186 / 186 张影像已注册',
'[21:00:03] INFO 本地特征提取完成,检测到 42,816 个稳定特征点',
'[20:59:58] INFO 创建端侧重建任务,所有数据仅在本机处理'
];
图 4 里那 8 行就是它。它模拟了真实重建管线的阶段术语,但本质上只是 ForEach 渲染的文本。右侧检查器里的三项指标同理,是三个字符串常量:
// entry/src/main/ets/components/InspectorPanel.ets : 107-111
Row() {
this.metric('PSNR', '31.8', 'dB')
this.metric('SSIM', '0.961', '')
this.metric('LPIPS', '0.083', '')
}
.width('100%')
它们不会随项目、随优化进度改变。质量预设「流畅 / 平衡 / 精细」也只切换 0.60M / 1.25M / 2.00M 的预算文字并弹一条 toast,不改变点云数量——这解释了图 3 里切到「精细」后视口密度不变的观感。把这些「占位」明确标出来,是本文坚持的真实性底线;同时它也让工程边界变得清晰:读者要替换的是数据层与任务层,布局与交互层可以直接拿去用。
「开始增量优化」按钮背后的状态机如下:
// entry/src/main/ets/pages/Index.ets : 79-108
private startOrPauseOptimization(): void {
if (this.isOptimizing && this.optimizingProjectId === this.selectedId) {
this.isOptimizing = false;
if (this.optimizeTimer >= 0) {
clearInterval(this.optimizeTimer);
this.optimizeTimer = -1;
}
this.notify('优化已暂停,当前检查点已保留');
return;
}
if (this.isOptimizing && this.optimizingProjectId !== this.selectedId) {
this.dialogMode = 'tasks';
this.notify('已有重建任务正在运行');
return;
}
if (this.optimizingProjectId !== this.selectedId) {
this.optimizeProgress = 0;
}
this.optimizingProjectId = this.selectedId;
this.isOptimizing = true;
this.optimizeTimer = setInterval(() => {
this.optimizeProgress = Math.min(100, this.optimizeProgress + 2);
if (this.optimizeProgress >= 100) {
clearInterval(this.optimizeTimer);
this.optimizeTimer = -1;
this.isOptimizing = false;
this.notify('增量优化完成,质量报告已更新');
}
}, 120);
}
这段代码有三个值得学习的工程习惯。第一,用 optimizingProjectId 记录「哪个项目在跑」,从而区分「同项目暂停」「跨项目冲突」「新项目重新开始」三种情况;第二,Math.min(100, ...) 保证进度不越界;第三,达到 100 或暂停时都主动 clearInterval,并把句柄置回 -1,不留野定时器。桌面应用会话时间长,定时器泄漏比手机端更容易被察觉,这类清理不可忽视。
跨组件的参数刷新依赖 @Prop + @Watch:
// entry/src/main/ets/components/GaussianViewport.ets : 9-14 / 55-57
@Prop @Watch('onModelChanged') variant: number = 0;
@Prop @Watch('onRenderParamsChanged') pointScale: number = 1;
@Prop @Watch('onRenderParamsChanged') splatOpacity: number = 0.82;
@Prop gaussianCount: string = '1.24M';
@Prop showWeakAreas: boolean = false;
@Prop @Watch('onReset') resetToken: number = 0;
private onRenderParamsChanged(): void {
this.draw();
}
Slider 的 onChange 改的是父组件的 @State,值沿 @Prop 下传,@Watch 回调再触发一次合并后的重绘。resetToken 是一个很实用的小技巧:切换项目时父组件只需让这个数字自增,子组件的 @Watch('onReset') 就知道要重置相机,而不必比较项目对象是否相等。类似的思路在 ArkUI-X 跨平台框架里同样成立,因为状态管理与组件通信属于声明式 UI 的通用能力,不依赖鸿蒙PC 特有 API。
七、ArkUI-X 跨平台框架:什么时候该上,边界在哪里
现在回到开篇那个问题。ArkUI-X 是 OpenHarmony 项目群技术指导委员会孵化的跨平台应用开发框架,它把 ArkUI 声明式 UI 从 OpenHarmony / HarmonyOS 扩展到 Android、iOS 等操作系统,目标是让开发者基于一套主代码构建多平台应用;其公开资料里强调的特征包括统一跨平台 API 能力集与扩展机制、高效的渲染管线与平台一致性渲染、以及动态化能力。本文把它作为「具体跨平台框架」的参照,并不是说配套工程已经接入了它——恰恰相反,配套工程是纯原生实现,因此更适合用来判断「哪些代码值得交给跨平台框架、哪些必须留在鸿蒙PC 原生层」。
先给一张按本工程代码事实归纳的选型矩阵(其中跨平台框架一侧为技术适配分析,不是本工程的跑分结果):
| 维度 | 鸿蒙PC 原生 ArkTS | ArkUI-X 跨平台框架 | 建议 |
|---|---|---|---|
| 设备形态声明 | deviceTypes: ['2in1'] 直接声明桌面分发 | 需按目标平台分别配置与打包 | 桌面优先、窗口行为复杂时保留原生配置 |
| 鼠标 / 悬停 / 高分屏 | onAxisEvent、onHover、vp2px 开箱可用 | 需要平台桥接或按端判断 | 把输入层收敛成统一接口,逐端实现 |
| 声明式界面 | 与跨端写法一致 | @Component / @State / 布局可直接复用 | 项目列表、检查器、对话框优先复用 |
| 纯逻辑与数据 | 纯函数、状态机、伪随机生成器 | 完全可复用 | 抽到 common 层,避免依赖系统 Kit |
| 高性能图形 | 可走 XComponent / 原生 GPU 管线 | 跨端一致性需额外验证 | 渲染引擎保持平台原生,界面层才复用 |
| 包体与依赖 | 本工程零三方依赖、约 482 KB HAP | 需引入框架运行时 | 只做鸿蒙PC 单端时,原生更轻 |
| 团队与维护 | 单端一套代码 | 多端一套主代码 + 平台桥接 | 端数越多,跨端收益越大 |
为了把「具体跨平台框架」这个维度说清楚,再放一张中性的方案对照。下表只讨论技术适配关系,不涉及任何产品推荐:
| 方案 | 与鸿蒙PC 的关系 | 本文中的角色 |
|---|---|---|
| ArkUI-X 跨平台框架 | 与 ArkTS / ArkUI 同源,声明式界面层复用成本最低 | 主参照 |
| Flutter / React Native | 自带渲染或控件桥接,桌面滚轮、悬停、窗口断点仍需单独适配 | 技术参照 |
| Qt / Electron | 桌面生态成熟,但与 ArkUI 的声明式体系属于不同技术栈 | 技术参照 |
| Kotlin Multiplatform / Compose Multiplatform | 逻辑层或 UI 层跨端,平台能力依旧要逐端落地 | 技术参照 |
结论可以浓缩成一句话:跨平台框架的价值随目标操作系统数量增长,而桌面形态适配的成本随窗口与输入复杂度增长,两者是两条独立的轴。如果产品只面向鸿蒙PC,原生 ArkTS 在包体、平台能力调用和调试链路上都更直接;如果还要覆盖 Android / iOS,且主要工作量在表单、列表、检查器这类界面上,那么把声明式界面层交给 ArkUI-X 跨平台框架是划算的;但如果核心工作量在三维视口、GPU 光栅化、端侧推理与系统级文件能力上,那么跨平台框架能复用的只是外壳,收益有限。
基于上面的分层图,给出一条可执行的迁移顺序。第一步,把不依赖任何系统 Kit 的纯逻辑抽到 common(layoutModeOf、SeededRandom、数据模型、状态机都属于这一类)。第二步,把界面组件按「是否触碰平台 API」分类:项目侧栏、检查器、对话框、流水线卡片属于可复用界面;Canvas 上下文创建、vp2px、onAxisEvent、deviceTypes 属于平台相关。第三步,为平台相关能力定义窄接口,例如「注册滚轮回调」「查询像素密度」「触发重绘」「安装 HAP 并签名」,鸿蒙PC 侧用原生实现,其他端各自实现。第四步,把渲染引擎留在原生层,界面层只通过状态与回调交互,避免把 CanvasRenderingContext2D 之类的具体类型泄漏进跨端代码。第五步,在鸿蒙PC 设备上做回归:拖动窗口跨越 1180 / 950 两个阈值、滚轮缩放、悬停态、高分屏缩放,确认平台桥接没有丢失桌面行为。
这里还有一个容易被忽略的协作细节:源码托管。本文配套工程目前是本地工程,若要把改造版本开源,建议统一托管到 AtomGit,并通过开源鸿蒙PC 社区在 AtomGit 上申请新建项目,让 issue、PR、构建脚本与截图素材集中归档;不要把同一份源码分散到名称不一致的多个托管位置,否则读者很难判断哪个版本对应文中的截图。发布文章时给出一个明确的 AtomGit 仓库地址,并在仓库中记录设备信息与截图版本,社区内被检索、被引用、被复现时才不会出现歧义。
八、性能与工程化:让三万点在鸿蒙PC 上画得稳
鸿蒙PC 的大屏与高 DPI 会放大 CPU 绘制的成本:物理像素更多、画布更大、窗口拉宽后一帧要填的点也更多。把本工程的瓶颈按影响排序,可以做一张清单:
| 瓶颈 | 代码位置 | 影响 | 优化方向 |
|---|---|---|---|
每帧 points.sort() | GaussianRenderer.ets:224 | 数万点的 O(n log n) 比较 | 相机不变时缓存排序结果,仅在 yaw / pitch 变化后重排 |
逐点 ellipse 路径 | GaussianRenderer.ets:242 | 每点一次 beginPath + fill | 按颜色分桶批量绘制,或用离屏缓存复用静态视角 |
逐点切换 fillStyle / globalAlpha | GaussianRenderer.ets:237-238 | 大量状态切换 | 预计算颜色字符串(源码已做),按 alpha 分档减少切换 |
| 两次循环重复计算旋转 | GaussianRenderer.ets:217-229 | 旋转矩阵算两遍 | 第一轮把 rotatedX/Y/Z 缓存进点结构,第二轮直接取值 |
| 高 DPI 画布尺寸 | GaussianViewport.ets:104-109 | 像素面积随密度平方增长 | 交互期间动态降采样,静止后恢复全分辨率 |
| 高频手势事件 | GaussianViewport.ets:118-127 | 一帧多次触发重绘 | 用脏标记合并请求(源码已用 16 ms 定时器合并) |
源码里的 16 ms 节流还可以再抽象一层,把「失效请求合并 + 脏标记 + 可取消」做成通用工具类,方便界面层与渲染层共用。下面这段代码不依赖任何系统 Kit,可直接放进 ets/rendering/ 并在组件中调用:
export class FrameScheduler {
private timer: number = -1;
private dirty: boolean = false;
private readonly intervalMs: number;
onFrame: () => void = () => {};
constructor(intervalMs: number = 16) {
this.intervalMs = intervalMs;
}
invalidate(): void {
this.dirty = true;
if (this.timer >= 0) {
return;
}
this.timer = setTimeout(() => {
this.timer = -1;
if (this.dirty) {
this.dirty = false;
this.onFrame();
}
}, this.intervalMs);
}
cancel(): void {
if (this.timer >= 0) {
clearTimeout(this.timer);
this.timer = -1;
}
this.dirty = false;
}
}
调用方式很直接:在 aboutToAppear() 里把 onFrame 指向真正的绘制函数,在手势回调、Slider 回调、onAreaChange 里只调用 invalidate(),在 aboutToDisappear() 里调用 cancel()。这样无论一帧内来了多少次事件,最多只画一次;页面退出时也不会留下悬空回调。
想量化优化效果,建议不要凭肉眼判断「变流畅了」,而是加一层最小测量:在 render() 前后取时间戳,把耗时写入环形数组,再算均值与 P95。下面这个纯逻辑类可以直接使用:
export class RenderTimer {
private samples: number[] = [];
begin(): number {
return Date.now();
}
record(startMs: number): number {
const cost = Date.now() - startMs;
this.samples.push(cost);
if (this.samples.length > 200) {
this.samples.shift();
}
return cost;
}
percentile(p: number): number {
if (this.samples.length === 0) {
return 0;
}
const sorted = this.samples.slice().sort((a: number, b: number) => a - b);
const index = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
return sorted[index];
}
}
本文没有给出「优化前多少帧、优化后多少帧」的具体数字,因为那需要在同一台鸿蒙PC 设备、同一窗口尺寸、同一缩放级别下多轮受控测量才有意义;凭一次截图编造性能数据既不真实,也会误导读者。更稳妥的做法是把上面的 RenderTimer 接进 draw(),配合 hilog 输出,按「窗口 1440×900 / 缩放 100% / 点尺寸 1.00× / 不透明度 82%」这样的固定口径记录多轮数据,再比较改动前后的均值与 P95。这个口径本身比某个漂亮的数字更有复用价值。
最后强调一点:这些优化都建立在「Canvas 2D 软件绘制」的前提上。当点数从三万增长到百万级,或者需要真实 3DGS 的协方差投影与 α 混合时,应该把视口迁移到 XComponent 承载的 GPU 管线,界面层继续保持 ArkUI 声明式结构;此时 ArkUI-X 跨平台框架仍可复用外壳,但渲染与推理必须在鸿蒙PC 原生侧实现。这条分工在第四节、第五节、第七节反复出现,正是本文想留给读者的核心判断。
九、从原型走向真实 3DGS 管线:一条可落地的路线图
如果读者想把这个鸿蒙PC 工作台继续做成能产出真实模型的工具,建议按下面六个阶段推进。每个阶段都尽量只替换一层,保持第四、五、六节已经验证过的界面与状态机不变,这样回归成本最低。
阶段一:影像接入与工程目录。 用系统文件选择能力把多视角影像导入应用沙箱,建立「项目 ID / 影像列表 / 输出目录」的目录约定,并校验数量、分辨率与 EXIF 完整性。当前 Models.ets 里的 images 字段可以直接升级为真实列表长度,createPipeline() 的第一张卡片也就从静态字符串变成真实进度。
阶段二:位姿估计与稀疏重建。 用 SfM 类流程(工程界面里写的是 COLMAP)估计每张影像的相机内外参并得到稀疏点云,输出一个可供后续初始化高斯基元的场景文件。这一步计算量大、耗时长,应放在独立任务或工作线程中,通过事件把「已注册影像数 / 特征点数」回传给现有状态机,而不是在 UI 线程里同步执行。
阶段三:高斯基元优化。 把稀疏点初始化为带协方差、不透明度与球谐系数的高斯基元,迭代优化并执行自适应密度控制(分裂、克隆、剪枝)。端侧推理或 NPU 加速如果可用,应封装成独立能力模块,通过统一接口暴露「开始 / 暂停 / 进度 / 检查点」,再替换 startOrPauseOptimization() 里的 setInterval。这一步做完,界面上 第 30,000 次迭代完成,loss = 0.0048 才从文案变成真实日志。
阶段四:视口升级。 保留 GaussianViewport.ets 的相机、手势、滚轮与 HUD,把 CanvasRenderingContext2D 的逐点椭圆绘制替换为 XComponent 承载的 GPU 光栅化;GaussianPoint 需要扩展为完整的高斯基元结构。第四节讲的输入适配、第五节讲的节流策略、第八节讲的测量口径都可以继续沿用,只是渲染后端换了。
阶段五:真实质量评估。 用保留视角渲染结果与真值影像计算 PSNR、SSIM、LPIPS,并把结果写回 InspectorPanel,替换 31.8 / 0.961 / 0.083 三个常量。质量环的分数也应该由评估函数产生,而不是读 Models.ets 里的字面量。薄弱区域标记可以基于视角覆盖率与置信度做实际统计,替换当前的固定气泡。
阶段六:导出与协作。 按 PLY / SPZ / 3DGS 等格式写出场景,记录相机坐标系(界面上写的是 Y-up · 米制)与参数版本,把项目源码、构建脚本、设备截图与测量数据一起归档到 AtomGit 仓库。开源鸿蒙PC 社区的项目通常需要可复现的素材,完整桌面截图与固定口径的测量表格比一句「运行流畅」更有说服力。
整条路线里,ArkUI-X 跨平台框架的定位始终一致:项目侧栏、检查器、对话框、流水线卡片、纯逻辑工具属于跨端可复用层;2in1 声明、鼠标轴事件、悬停、高分屏换算、HAP 签名、GPU 视口与端侧推理属于鸿蒙PC 原生层。先在鸿蒙PC 上把原生层跑通,再按业务需要把界面层迁到跨平台框架,比反过来「先选框架再补平台能力」风险低得多。文物影像往往涉及馆藏数据,全流程坚持本地处理、数据不出本机,也更容易通过协作方的合规审查。
十、复现清单与十条踩坑记录
10.1 在鸿蒙PC 上复现本文截图
- 准备与工程匹配的 DevEco Studio 环境,
build-profile.json5中compatibleSdkVersion/targetSdkVersion为5.0.5(17),runtimeOS为HarmonyOS。 - 用 DevEco 打开
Gaussian_ohos根目录,等待 hvigor 同步完成。因为oh-package.json5的dependencies为空,不需要额外安装第三方 ohpm 依赖。 - 检查
entry/src/main/module.json5的deviceTypes为['2in1'],并在「项目结构 → 签名配置」里确认调试证书有效(图 5 显示的就是这个面板)。签名材料路径与本机账号绑定,换机器后需重新配置。 - 连接鸿蒙PC 设备或可用的 2in1 目标,执行构建并生成
entry-default-signed.hap;也可用命令行确认设备在线后安装:
hdc list targets
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
- 运行应用,按图 2 到图 4 依次验证:切换「影青刻花盏 / 青釉弦纹瓶」、拖动视口旋转、滚轮缩放、开启薄弱区域标记、拖动点尺寸与不透明度 Slider、切到「重建日志」页签、点击「开始增量优化」看进度与 toast。
- 把应用窗口拖窄跨过 1180 与 950 两个阈值,确认左栏、右栏依次收起而相机视角不重置(对应图 6)。
- 截图时保留完整桌面:底部 Dock、系统状态栏、DevEco 窗口与应用窗口同屏,这样读者一眼就能确认运行平台是鸿蒙PC;随后把图片上传到图床再嵌入文章,避免本地路径在社区里失效。
如果需要把改造后的版本分享给社区,建议在 AtomGit 新建仓库时同步放上 README、签名注意事项、设备信息、截图原图与测量口径说明,并在开源鸿蒙PC 社区提交项目申请,方便他人按同一口径复现。
10.2 十条踩坑记录
| # | 现象 / 风险 | 原因 | 处理 |
|---|---|---|---|
| 1 | 手机或普通平板装不上 HAP | module.json5 只声明 2in1 | 明确目标设备形态;需要多端时补齐 deviceTypes |
| 2 | 高分屏上点云发虚、位置偏移 | 把 vp 当物理像素用 | LengthMetricsUnit.PX + vp2px(1) 换算画布尺寸 |
| 3 | 拖窗口后视角跳回默认 | 在尺寸回调里重建了相机对象 | 相机状态保存在组件成员中,尺寸变化只重绘 |
| 4 | 改 Slider 后画面不刷新 | @Watch 只改了状态、没触发绘制 | 在 @Watch 回调里显式调用 draw() |
| 5 | 拖动时明显掉帧 | 一帧内多次排重绘、每帧都排序 | 16 ms 合并请求;相机不变时缓存深度顺序 |
| 6 | 把 31.8 / 0.961 / 0.083 当成评测结论 | 三项指标是字符串常量 | 用真实渲染结果与真值影像计算后再引用 |
| 7 | 页面退出后仍有回调 | setInterval / setTimeout 未清理 | aboutToDisappear() 中 clearInterval / clearTimeout |
| 8 | 误以为 *.msgpack、*.protoBin 是模型权重 | 它们是 ArkTS 编译器缓存 | 只把 entry/src 视为源码;构建产物不入仓 |
| 9 | 期待「NPU 加速」带来性能变化 | 无 C++、无 NAPI、无 .so、无推理调用 | 需要时按第九节阶段三单独接入能力模块 |
| 10 | 换机器后安装失败 | 调试签名材料路径与本机绑定 | 重新配置签名并确认 bundleName 一致 |
| 11 | 迁移到 ArkUI-X 跨平台框架后滚轮失效 | 桌面输入能力未做平台桥接 | 把滚轮、悬停、像素密度收敛成窄接口逐端实现 |
| 12 | 窗口变窄后侧栏「消失」被当成 bug | 1180 / 950 手写断点的设计行为 | 用 layoutModeOf 统一判定,并在帮助里说明 |
这些坑大多不是算法问题,而是鸿蒙PC 桌面形态带来的工程细节:设备形态、像素单位、输入事件、窗口生命周期、签名材料。它们零散却决定了一个应用「能不能在真机上稳定跑起来」。把它们记在表格里,比记一段漂亮但无法复现的性能数字更有长期价值。
十一、总结
本文以「澜境 · Gaussian Studio」这个零依赖、零 .so 的鸿蒙PC 原生工程为样本,逐层拆解了面向 2in1 设备形态的窗口断点(1180 / 950)、鼠标滚轮与悬停交互、vp2px 高分屏换算、Canvas 2D 软件 Splat 渲染管线,以及由 16 个 @State 驱动的多面板状态机;同时用四张鸿蒙PC 设备完整桌面截图与两张自绘示意图,把「界面长什么样」和「代码怎么跑」对应起来。
更重要的是,文章始终没有回避两条边界。第一条是事实边界:界面上的百万级高斯数、PSNR / SSIM / LPIPS、NPU 加速与重建日志都是演示占位数据,视口里真正绘制的是约三万点确定性点云;把它当成真实 3DGS 引擎会得出错误结论。第二条是选型边界:ArkUI-X 跨平台框架擅长复用声明式界面与纯逻辑,而 2in1 声明、桌面输入、高分屏换算、GPU 视口与端侧推理必须留在鸿蒙PC 原生层。理解这两条边界,读者既能把本文的布局、交互、状态管理代码直接拿去用,也能按第九节的路线把它逐步升级成真正的端侧重建工具。
如果你正在为开源鸿蒙PC 生态做技术选型,希望这篇「把原型讲透、把边界讲明」的文章能少让你走一段弯路;也欢迎在 AtomGit 上提交你的复现结果与改进实现,让社区里的每一张运行截图都有对应的代码版本与测量口径。
检索关键词:鸿蒙PC、鸿蒙 PC、开源鸿蒙PC、ArkUI-X 跨平台框架、跨平台框架选型、ArkTS、ArkUI 声明式 UI、Canvas 2D、3D Gaussian Splatting、2in1 桌面形态、AtomGit。
更多推荐



所有评论(0)