存量项目迁移改造到鸿蒙PC:六阶段落地清单,兼谈 ArkUI 跨平台框架的复用边界
存量项目迁移改造到鸿蒙PC:六阶段落地清单,兼谈 ArkUI 跨平台框架的复用边界
欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
导读:把一套已有的桌面业务系统搬到鸿蒙PC,难点从来不是「新建一个页面」,而是把存量的界面、输入、渲染、数据与构建资产逐层翻译过去。本文以真实可运行的
SiteWeave_ohos(「织界 · SiteWeave」,面向城市更新沿街立面测绘的鸿蒙PC 工作台)作为迁移目标态的参考实现,反向推导出一份六阶段落地清单:资产盘点 → 工程骨架 → 界面映射 → 输入改造 → 渲染改造 → 数据与能力补齐。文中所有代码逐字摘自工程源码,五张界面图均为鸿蒙PC 设备上的完整桌面截图;同时把 ArkUI-X 跨平台框架 作为迁移选型的对照轴,讨论存量工程若本来就是跨端项目,哪些层能直接复用、哪些层必须按鸿蒙PC 原生重写。
关键词:鸿蒙PC、鸿蒙 PC、开源鸿蒙PC、存量项目迁移、迁移改造、ArkUI跨平台框架、跨平台框架选型、ArkTS、ArkUI 声明式 UI、Canvas 2D、2in1 桌面形态、AtomGit。
一、先把话说清楚:本文所说的「迁移改造」是什么
做存量项目迁移,最忌讳的是把「迁移」写成一个含混的故事。所以开篇先给出一条必须讲明的边界:配套工程 SiteWeave_ohos 内部没有任何「从旧平台移植而来」的证据。我把整个工程(含隐藏目录与构建产物)翻了一遍,结果是:没有 README、CHANGELOG、UPSTREAM、LICENSE 或迁移说明;没有 .git 目录与提交历史;没有旧平台的源码文件(.java、.kt、.dart、.jsx、.html、.cpp、.py、.qml 等一律为零);没有 shim、polyfill、兼容层;注释里也没有「原项目」「移植」「ported」「migrated」这类字样。唯一看起来像「迁移」的词是业务文案里的「改造方案 D-04 雨棚」,那属于城市更新的设计冲突项,和代码迁移毫无关系。
因此本文不编造一个「从某某平台迁到鸿蒙PC」的出身。我们换一个更实用、也更诚实的写法:把这个工程当作「存量项目迁移改造完成后的目标形态」,逐层反推一份可复用的迁移清单。换句话说,文章回答的是这样一个问题——「假如我手上有一套存量桌面测绘系统,要把它落到鸿蒙PC 上,最终代码应该长什么样、每一层要改哪些东西、按什么顺序改、怎么验收?」这比虚构一段迁移史更有指导价值,也不会误导读者去仓库里寻找根本不存在的旧版本。
还要交代第二类起点。很多团队的存量系统并不是纯原生,而是构建在某个具体跨平台框架之上:Qt / Electron 这类桌面老项目、Flutter 这类移动跨端项目,或者 ArkUI-X 跨平台框架 这类与 ArkTS 同源的跨端工程。不同起点的复用比例差别很大,但它们迁移到鸿蒙PC 时都会撞上同一堵墙:桌面形态的窗口、鼠标、高分屏与打包分发,无法靠「框架自动兼容」解决。本文第七节之前按「通用存量项目」讲落地,第十节再集中讨论 ArkUI-X 跨平台框架与其他方案的决策矩阵。
第二个必须讲明的边界是数据真实性。界面上的 98.2 分、4.2 mm 尺度误差、94% 覆盖率、285 m² 立面面积、186 / 312 帧、2.1 m² 遮挡面积等,全部是 Models.ets 里的硬编码字面量;导出、补采清单、文件写入都不存在真实 IO,只是 toast 文案加一个模拟进度条。真正被计算出来的,是 Canvas 2D 画布上由一万五千余到一万八千余个墙面候选点、叠加门窗与街面细部后生成的确定性伪点云及其投影。把这两类东西分开,读者才知道哪些代码可以直接搬进生产项目、哪些必须自己补上。
二、目标态长什么样:鸿蒙PC 真机完整界面截图
下面五张图都来自鸿蒙PC 设备上的实际运行界面,且都是完整桌面截图:屏幕底部有系统 Dock,右上角有系统状态栏与时间,桌面上同时开着 DevEco Code 开发环境(左侧能看到 SiteWeave_ohos 的 AppScope / entry / hvigor 目录树),前台是「织界」应用窗口。这种「桌面外壳 + 应用窗口 + 工程结构」同屏的画面,可以一眼确认运行平台是鸿蒙PC,而不是手机模拟器或网页预览。图片均已上传图床,便于在社区文章中直接引用。

图 1 鸿蒙PC 完整桌面:「发起现场补采」对话框,含补采路线曲线与优先补采目标卡片;背景可见场景视口、右侧校核面板与左侧街巷档案
图 1 覆盖的是迁移后最典型的桌面交互——模态对话框。对话框宽 92% 但被 constraintSize({ maxWidth: 520 }) 限宽,中央用一条贝塞尔曲线画出补采路线,标注起点与终点;背景主界面被压暗但依然可见三栏结构。这类「大屏中央弹窗 + 背景信息保持可读」的设计,是存量桌面系统迁移到鸿蒙PC 时最需要重新校准的一环:手机端习惯全屏覆盖,鸿蒙PC 上则必须保留上下文。

图 2 「安福路 128 号」场景页签:立面点云视口 + 尺寸标注 + 右侧「测绘校核」面板,评分 98.2、尺度误差 4.2 mm、覆盖率 94%、立面面积 285 m²
图 2 是默认三栏工作台。左侧「街巷档案」列出三个项目并带搜索框;中间视口用密集点描绘砖木店铺立面,叠加总宽标注、比例尺与「实景 / 点云 / 高程 / 线稿」四种着色模式;右侧校核面板集中了评分、三项指标、点径与不透明度两个 Slider、量测与方案开关。宽屏把「项目列表—工作画布—参数检查器」三件事同时铺开,正是鸿蒙PC 相对手机端最直观的差别。

图 3 切换项目到「阜丰里仓库」:视口换成工业遗产外墙的点云立面,右侧评分变为 97.1,左侧选中态同步更新
图 3 展示状态联动:切换项目后视口器形、评分、选中态一起刷新,而窗口、相机状态与三栏布局保持不变。对存量系统迁移来说,这一条很重要——桌面用户期望「切换数据不重置工作环境」,所以相机、缩放、页签这类状态应该挂在组件成员上,而不是随数据源一起重建。

图 4 「导出测绘成果」对话框:PLY + 测绘元数据 / OBJ 测量模型 / BIM 交换包三种格式卡片,以及「包含控制点基准」开关
图 4 对应存量系统里最常见的「导出能力迁移」。这里要如实说明:当前工程只把所选格式记进状态并弹一条「已加入本地队列」的 toast,没有真正写文件。因此它演示的是导出界面的结构,而不是导出实现;第八节会给出补齐文件 IO 与权限的具体建议。把这一点写清楚,读者才不会把演示界面误当成可交付能力。

图 5 切换到「采集」页签:312 帧注册影像、96% 覆盖、3.8 mm 尺度误差,八格关键帧序列与街面采集覆盖平面图
图 5 是第二页签,展示关键帧网格与用 Rect / Path 手绘的街面覆盖平面图。值得注意的是,整个工程只有两个 SVG 应用图标,没有任何位图资源:立面缩略图、图标、平面图、补采路线全部由 ArkUI 的 Path、Rect、Row 等矢量形状拼出。对迁移工作而言,这既是好消息(无需搬运图片资源与多套倍图),也是约束(复杂图形需要用代码描述)。
三、阶段 00:资产盘点——先分类,再动手
迁移失败最常见的原因,不是技术能力不足,而是把「改造」理解成「整体重写」。重写意味着同时丢掉存量系统里已经验证过的业务规则、边界条件与交互细节;而全盘照搬又会在鸿蒙PC 的窗口模型、输入模型和打包模型上处处碰壁。可操作的做法是先做一张三色清单,把每一项存量资产归入三类:
| 分类 | 判定标准 | 存量测绘系统里的例子 |
|---|---|---|
| 可复用(reuse) | 不依赖具体操作系统 API 的纯逻辑与数据 | 项目/关键帧/校核记录的数据结构、评分格式化、角度与帧号计算、配色与字号常量 |
| 需桥接(bridge) | 语义相同但 API 需要换一层实现 | 滚轮缩放、悬停反馈、文件选择、高分屏像素换算、定时器与进度回调 |
| 需重写(rewrite) | 与目标平台形态强绑定 | 窗口/多文档结构、布局断点、HAP 打包签名、设备能力声明、桌面快捷键 |
盘点产出建议落成一份可版本化的清单,而不是散在会议记录里。下面这个纯 ArkTS 模块不依赖任何系统 Kit,可直接放进 ets/common/ 并在迁移各阶段持续维护:
export type MigrationAction = 'reuse' | 'bridge' | 'rewrite';
export interface MigrationItem {
name: string;
layer: string;
action: MigrationAction;
note: string;
}
export function summarize(items: MigrationItem[]): string {
let reuse = 0;
let bridge = 0;
let rewrite = 0;
for (let index = 0; index < items.length; index++) {
const item = items[index];
if (item.action === 'reuse') {
reuse++;
} else if (item.action === 'bridge') {
bridge++;
} else {
rewrite++;
}
}
return 'reuse=' + reuse + ' bridge=' + bridge + ' rewrite=' + rewrite;
}
export function itemsOfLayer(items: MigrationItem[], layer: string): MigrationItem[] {
return items.filter((item: MigrationItem) => item.layer === layer);
}
把清单按层分组后再排期,能显著降低「改到一半发现底层也要动」的风险。这也是本文把后续内容拆成骨架、界面、输入、渲染、数据五个阶段的原因:每一层都能独立验收,不必等全部改完才知道对不对。

图 6 六阶段迁移路径(作者自绘):从资产盘点到数据与能力补齐,底部标注 ArkUI-X 跨平台框架在迁移中的位置
接着看清目标态的工程事实,它是后面所有映射关系的锚点:
| 项 | 值 | 证据 |
|---|---|---|
| 应用 / 包名 | 「织界 · SiteWeave」 / com.siteweave.urbansurvey | AppScope/app.json5 |
| 模块描述 | 城市更新沿街立面端侧 3DGS 测绘工作台 | entry/src/main/resources/base/element/string.json |
| 设备形态 | 仅 ['2in1'] | entry/src/main/module.json5:7、pack.info |
| SDK | compatibleSdkVersion / targetSdkVersion 均为 5.0.5(17),runtimeOS: HarmonyOS | build-profile.json5 |
| 源码规模 | 12 个 .ets 手写文件,唯一页面 Index.ets 676 行 | entry/src/main/ets/ |
| 依赖 | 工程级 / 模块级 / hvigor 三处 dependencies 全为空 | oh-package.json5、entry/oh-package.json5、hvigor/hvigor-config.json5 |
| 原生库 | 零 .so、零 C++/NAPI、零 CMakeLists | base_native_libs.json 为 {"libs":{},"stripped":{}} |
| 资源 | 仅 2 个 SVG 图标 + 3 个 JSON 配置 | resources/base/ |
| 产物 | 签名 HAP 约 695 KB,deviceType: ["2in1"] | entry/build/default/outputs/default/ |
一句话概括:这是一个纯 ArkTS + ArkUI 声明式 UI 的 Stage 模型单模块应用,零第三方依赖、零原生库、零 WebView。它给了迁移一个很干净的参照——不引入任何框架运行时,只靠系统 Kit 就把桌面工作台跑起来了。当然,干净也意味着「什么都得自己写」,这正是 ArkUI-X 跨平台框架之类的方案在存量项目里会被重新评估的原因,第十节再展开。
另外提醒一个很容易误判的细节:构建目录里的 *.msgpack、*.protoBin 位于 CompileArkTS 缓存下,是 ArkTS 编译器的中间表示,不是模型权重;entries 目录里也没有 .ply、.ifc、.obj 之类的测绘成果文件。盘点时只把 entry/src 当源码,构建产物不入仓( .gitignore 里已经忽略了 **/build/ 与 *.hap)。
四、阶段 01:工程骨架——把存量配置翻译成鸿蒙PC 配置
存量项目的第一处「翻译」发生在配置层。它不产生任何可见 UI,却决定了应用能否被安装到鸿蒙PC、能否以桌面窗口形态启动、能否在调试证书下运行。目标态的模块声明如下(entry/src/main/module.json5 全文):
{
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']
}
]
}
]
}
}
迁移时最需要盯住的是 deviceTypes: ['2in1']。2in1 是 PC / 二合一设备形态,HAP 从打包阶段就面向桌面分发;如果存量系统还要覆盖手机与平板,就得补齐设备形态,并让布局断点覆盖更宽的区间。这里没有 requestPermissions、没有 extensionAbilities、没有 native 声明,说明当前工程只用到最基础的桌面能力。存量系统若涉及文件导出、相机采集或跨应用打开,权限和能力声明都要在这一步补上,不能等到功能写完再回头改配置。
工程级配置里值得逐项对照的是:
// build-profile.json5 : products[0]
{
"name": "default",
"compatibleSdkVersion": "5.0.5(17)",
"targetSdkVersion": "5.0.5(17)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"useNormalizedOHMUrl": true
}
},
"signingConfig": "default"
}
// oh-package.json5
{
modelVersion: '5.0.0',
name: 'siteweave-urban-survey',
version: '1.0.0',
description: 'On-device 3D Gaussian Splatting facade survey studio for HarmonyOS PC.',
main: '',
author: '',
license: 'Apache-2.0',
dependencies: {}
}
三条迁移经验。第一,bundleName 与调试签名材料绑定,build-profile.json5 里的 certpath / profile / storeFile 指向 /storage/Users/currentUser/.ohos/config/ 下带随机后缀的文件,换机器后必须重新配置签名,否则安装会失败——这是存量团队多人协作时最频繁踩的坑。第二,versionCode 决定能否覆盖安装,多个工程共用同一包名时,低版本会被 install -r 拒绝,先卸载旧包更省事。第三,dependencies: {} 意味着不需要额外的 ohpm 三方依赖,同步与构建链路很短;若存量项目引入了某个跨平台框架,这一步会多出运行时与构建插件,需要单独评估包体与升级成本。
入口能力也极简,只包含加载页面与三条 hilog:
// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'SiteWeave', 'Ability onCreate');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'SiteWeave', 'Failed to load content: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'SiteWeave', 'Content loaded');
});
}
}
这段代码的结构值得注意:onCreate 做初始化,onWindowStageCreate 里用 loadContent 加载唯一页面,失败时通过 hilog.error 把错误码打印出来,成功时打一条 Content loaded。存量项目迁移时,原来的启动流程(登录态恢复、配置读取、崩溃上报初始化)应放在 onCreate 或其调用的独立模块里,不要塞进 loadContent 的回调,否则首屏会被长任务拖慢。若存量系统依赖原生动态库,还要在这一步确认 .so 的打包与 System.loadLibrary 时机;本工程 base_native_libs.json 为空,说明这条链路尚未启用。
五、阶段 02:界面层映射——从「固定尺寸」到「三栏自适应」
存量桌面系统常把窗口尺寸写死,或用一套绝对坐标布局;到了鸿蒙PC,窗口可以被用户随意拖宽拖窄,这是迁移时最先暴露的问题。目标态的做法是:根容器测宽度,用两个阈值决定侧栏去留,中间画布始终吃掉剩余空间。

图 7 左侧为存量能力 → 鸿蒙PC 落点的逐项映射,右侧为 1210 / 1030 双断点下的三栏 → 两栏 → 单栏布局(作者自绘)
先看映射关系,这张表是迁移排期最实用的部分:
| 存量项目常见实现 | 鸿蒙PC 落点 | 本工程代码证据 |
|---|---|---|
| 窗口 / 页面根容器 | Column + onAreaChange → windowWidth | Index.ets:642-644 |
| 布局断点 / 媒体查询 | if (windowWidth >= 1210 / 1030) | Index.ets:422、:575 |
| 鼠标滚轮事件 | onAxisEvent + getVerticalAxisValue() | FacadeViewport.ets:193-196 |
| 悬停态 CSS / hover | @State hovered + onHover | StudioControls.ets:28-30 |
| 高分屏适配 | getUIContext().vp2px(1) + LengthMetricsUnit.PX | FacadeViewport.ets:24,79,160 |
| 自绘视图 / 2D 画布 | Canvas + CanvasRenderingContext2D | FacadeViewport.ets:155 |
| 弹窗 / Modal | Stack 覆盖层 + constraintSize 限宽 | SurveyDialogs.ets:434-436 |
| 状态管理 / Store | 16 个 @State + @Prop @Watch | Index.ets:14-29 |
| 定时器 / 后台任务 | setInterval + aboutToDisappear 清理 | Index.ets:34-41 |
| 打包配置文件 | module.json5 + build-profile.json5 | 见第四章 |
断点实现非常朴素,没有依赖 GridRow、SideBarContainer 或媒体查询:
// entry/src/main/ets/pages/Index.ets : 422-434
if (this.windowWidth >= 1210) {
ProjectSidebar({
projects: this.projects,
selectedId: this.selectedId,
onSelect: (id: number) => this.selectProject(id),
onNew: () => {
this.dialogMode = 'new';
}
})
.width(232)
.height('100%')
.border({ width: { right: 1 }, color: SurveyColors.line })
}
// entry/src/main/ets/pages/Index.ets : 575-608(回调体按阅读长度折叠,语义不变)
if (this.windowWidth >= 1030) {
SurveyInspector({
project: this.currentProject(),
pointScale: this.pointScale,
splatOpacity: this.splatOpacity,
showMeasurements: this.showMeasurements,
showAnnotations: this.showAnnotations,
showDesign: this.showDesign,
isProcessing: this.isProcessing,
processProgress: this.processProgress,
onPointScale: (value: number) => { this.pointScale = value; },
onOpacity: (value: number) => { this.splatOpacity = value; },
onMeasurements: (value: boolean) => { this.showMeasurements = value; },
onAnnotations: (value: boolean) => { this.showAnnotations = value; },
onDesign: (value: boolean) => { this.showDesign = value; },
onCapture: () => this.openCapture(),
onExport: () => { this.dialogMode = 'export'; }
})
.width(286)
.height('100%')
.border({ width: { left: 1 }, color: SurveyColors.line })
}
// entry/src/main/ets/pages/Index.ets : 640-644
.width('100%')
.height('100%')
.onAreaChange((oldArea: Area, newArea: Area) => {
this.windowWidth = Number(newArea.width);
})
三段合起来构成清晰的策略:宽度不小于 1210 vp 时是「图标栏 66 + 街巷档案 232 + 自适应视口 + 校核栏 286」的完整三栏;1030–1209 vp 收起档案栏;小于 1030 vp 只保留图标栏与视口。中间工作区用 .layoutWeight(1) 吃剩余宽度,顶栏 57、状态栏 24 固定,面包屑、Tab、工具条这些桌面化元素才有稳定落位。@State private windowWidth: number = 1440 给了首帧一个合理初值,避免测量回调到达前闪一下窄屏布局。
把阈值收敛成纯函数,可以避免同一个数字散落在多个页面:
export type PcLayoutMode = 'triple' | 'double' | 'single';
export function layoutModeOf(widthVp: number): PcLayoutMode {
if (widthVp >= 1210) {
return 'triple';
}
if (widthVp >= 1030) {
return 'double';
}
return 'single';
}
这个函数没有任何系统 Kit 依赖,属于典型的「可复用」资产;在 ArkUI-X 跨平台框架里同样能直接编译。也就是说,如果存量工程本来就是基于 ArkUI-X 跨平台框架的跨端项目,界面结构与这类纯逻辑可以低成本带到鸿蒙PC,真正要重做的是下面这些平台相关层。
弹窗迁移同样有讲究。目标态的对话框用 Stack 覆盖在主界面之上,并按不同模式给不同高度:
// entry/src/main/ets/components/SurveyDialogs.ets : 434-441
.width('92%')
.height(this.mode === 'new' ? 548 : this.mode === 'help' ? 390 : 470)
.constraintSize({ maxWidth: 520, maxHeight: '92%' })
.backgroundColor(SurveyColors.ivory)
.borderRadius(12)
.border({ width: 1, color: '#CEC7B7' })
.shadow({ radius: 36, color: '#3D000000', offsetY: 15 })
.clip(true)
maxWidth: 520 保证对话框在超宽鸿蒙PC 屏幕上不会被拉成一条横幅,maxHeight: '92%' 保证矮窗口下仍能滚动或裁切,.clip(true) 避免圆角处内容溢出。存量系统若把弹窗写死成固定像素宽高,迁移到鸿蒙PC 后一定要补这类约束,否则在 2in1 窗口里很容易出现按钮被挤出可视区的情况。
六、阶段 03:输入层改造——桌面交互是迁移最容易漏的一层
很多迁移项目在界面画完后才发现问题:手机端靠触摸就能完成的操作,到了鸿蒙PC 上用户第一反应是用鼠标滚轮缩放、把光标悬停在按钮上看反馈、按住拖拽平移视图。这些不是「锦上添花」,而是桌面可用性基线。目标态把四类输入一次性补齐:
// entry/src/main/ets/components/FacadeViewport.ets : 155-196
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.006;
this.camera.pitch = Math.max(-0.7, Math.min(0.7, this.startPitch + event.offsetY * 0.004));
}
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 换成物理像素,配合 new CanvasRenderingContext2D(this.settings, LengthMetricsUnit.PX),高 DPI 显示器上画布才不发虚;这是桌面迁移比手机端多出来的一步。其二,滚轮用指数映射 zoom * exp(-delta * 0.08),缩放钳制在 [0.58, 2.15],俯仰角钳制在 [-0.7, 0.7],小幅滚动细腻、大幅滚动可控,也不会把模型翻过去。其三,PanGesture 与 PinchGesture 分别用 .gesture 与 .parallelGesture 挂载,拖拽与捏合可以并存。其四,拖拽过程中相机状态保存在组件成员里,onAreaChange 只更新画布尺寸并重绘,所以拖窗口不会把视角重置——这正好对应图 3 强调的「切换数据不重置工作环境」。
悬停态由通用控件统一处理,避免每个按钮各写一份:
// entry/src/main/ets/components/StudioControls.ets : 5-32
@Component
export struct UtilityButton {
@Prop icon: string = 'plus';
@Prop selected: boolean = false;
@Prop dark: boolean = false;
@Prop buttonSize: number = 32;
@Prop iconSize: number = 16;
action: () => void = () => {};
@State private hovered: boolean = false;
build() {
Stack() {
AppIcon({
name: this.icon,
iconSize: this.iconSize,
iconColor: this.selected ? (this.dark ? SurveyColors.rail : SurveyColors.ivory) : (this.dark ? '#C4C8BD' : SurveyColors.muted),
stroke: 1.55
})
}
.width(this.buttonSize)
.height(this.buttonSize)
.backgroundColor(this.selected ? (this.dark ? SurveyColors.accent : SurveyColors.ink) :
this.hovered ? (this.dark ? SurveyColors.railSoft : '#ECE9DF') : Color.Transparent)
.borderRadius(this.dark ? 7 : 6)
.onHover((inside: boolean) => {
this.hovered = inside;
})
.onClick(() => this.action())
}
}
迁移时必须诚实评估「还没补的部分」。本工程没有注册任何键盘快捷键:全工程没有 onKeyEvent / KeyEvent / keyCode 绑定,帮助对话框里列的五项全是鼠标与触控操作(拖拽鼠标、滚轮/双指捏合、平移工具、点击时间轴关键帧、尺寸标注开关)。也没有多窗口、全屏 API 或自定义标题栏,只有一句「可使用系统窗口全屏进行沉浸校验」的提示文案。如果存量桌面系统依赖大量快捷键(例如 Ctrl+Z 撤销、Space 切换平移工具),这部分要作为独立任务排期,不能假设跨平台框架会自动提供。同理,ArkUI-X 跨平台框架 这类跨端方案在桌面滚轮、悬停与窗口事件上通常需要平台桥接,把输入层收敛成窄接口逐端实现,是迁移里最划算的一次抽象。
七、阶段 04:渲染层改造——先跑通,再加速
存量测绘系统通常有一块核心画布:CAD 视图、点云视口、影像浏览或三维场景。迁移时最容易犯的错,是一上来就追求 GPU 光栅化与百万级基元,结果卡在原生库编译、ABI 与签名上,连界面都跑不起来。更稳妥的顺序是:先用平台原生 2D 能力把交互链路跑通,再按性能需求替换渲染后端。目标态正是这一策略的样本。
它的几何数据不是从文件加载的,而是用固定种子的线性同余发生器现场生成:
// entry/src/main/ets/rendering/FacadeRenderer.ets : 35-46
class SurveyRandom {
private value: number;
constructor(seed: number) {
this.value = seed;
}
next(): number {
this.value = (this.value * 1664525 + 1013904223) % 4294967296;
return this.value / 4294967296;
}
}
// entry/src/main/ets/rendering/FacadeRenderer.ets : 60-99
ensureModel(variant: number): void {
if (this.variant === variant && this.points.length > 0) {
return;
}
this.variant = variant;
this.points = [];
const random = new SurveyRandom(2601 + variant * 971);
const bays = this.windowBays(variant);
const width = this.facadeWidth(variant);
const height = this.facadeHeight(variant);
const wallCount = variant === 2 ? 18500 : 15200;
for (let index = 0; index < wallCount; index++) {
const x = (random.next() - 0.5) * width;
const y = random.next() * height;
if (this.inStoreOpening(variant, x, y) || this.inWindow(bays, x, y)) {
continue;
}
const brick = variant === 1 ? Math.floor(y / 0.12) % 7 === 0 : false;
const shade = 0.82 + Math.max(0, (x + width / 2) / width) * 0.18 + random.next() * 0.06;
if (brick) {
this.pushPoint(x, y, -0.01 + random.next() * 0.04, 0.013 + random.next() * 0.012,
185, 164, 140, 0.72, true, shade, variant, height);
} else {
this.pushWall(x, y, random, shade, variant, height);
}
}
for (let index = 0; index < bays.length; index++) {
this.addWindow(bays[index], random, variant, height);
}
this.addStorefront(random, variant, width, height);
this.addFacadeTrim(random, variant, width, height);
this.addStreet(random, variant);
if (variant === 0) {
this.addTree(random);
} else if (variant === 1) {
this.addUtilityLine(random, height);
} else {
this.addIndustrialDetails(random, width, height);
}
}
固定种子保证每次进入页面得到完全相同的立面,旋转、缩放、切换着色模式时不会出现「模型闪一下变了」。墙面按 variant 生成 15,200 或 18,500 个候选点,用 inStoreOpening / inWindow 挖去店面与窗洞,再叠加窗框、店面、檐口、街面、行道树、电线或工业细节。ensureModel 开头那个「variant 相同且已有数据就直接返回」的判断,是避免每次重绘都重建点云的关键。
一帧的绘制顺序是:清屏 → 线性渐变天空 → 径向光晕 → 天际线 → 街面透视网格 → 相机旋转求深度 → 深度排序 → 透视投影 → 逐点绘制 → 线稿/改造方案叠加。
// entry/src/main/ets/rendering/FacadeRenderer.ets : 380-447(节选核心投影与绘制)
render(context: CanvasRenderingContext2D, width: number, height: number, camera: SurveyCamera,
variant: number, pointScale: number, opacity: number, mode: string, density: number, showDesign: boolean): void {
this.ensureModel(variant);
context.globalAlpha = 1;
context.clearRect(0, 0, width, height);
// 此处省略天空渐变、光晕、天际线与街面网格的背景绘制
const centerX = width * 0.49 + camera.panX * density;
const centerY = height * 0.61 + camera.panY * density;
const facadeHeight = this.facadeHeight(variant);
const scale = Math.min(width, height) * 0.13 * (4.8 / facadeHeight) * camera.zoom;
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 rotatedZ = sineYaw * point.x + cosineYaw * point.z;
point.depth = sinePitch * point.y + cosinePitch * rotatedZ;
}
this.points.sort((first: FacadePoint, second: FacadePoint) => first.depth - second.depth);
for (let index = 0; index < this.points.length; index++) {
const point = this.points[index];
if (mode === '线稿' && !point.edge) {
continue;
}
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 = 9 / Math.max(3, 9 - point.depth);
const screenX = centerX + rotatedX * scale * perspective;
const screenY = centerY - rotatedY * scale * perspective;
const radius = Math.max(0.42, point.size * scale * perspective * pointScale);
if (screenX < -radius || screenX > width + radius || screenY < -radius || screenY > height + radius) {
continue;
}
context.fillStyle = mode === '高程' ? point.elevationColor :
mode === '线稿' ? '#464940' : mode === '点云' ? point.densityColor : point.color;
context.globalAlpha = Math.min(1, point.alpha * opacity);
if (mode === '点云' || mode === '线稿') {
context.fillRect(screenX, screenY, Math.max(0.65, radius), Math.max(0.65, radius));
} else {
context.beginPath();
context.ellipse(screenX, screenY, radius * 1.15, radius * 0.74,
Math.atan2(rotatedY, rotatedX) * 0.18, 0, Math.PI * 2);
context.fill();
}
}
context.globalAlpha = 1;
if (mode === '线稿') {
this.drawWireframe(context, width, height, camera, variant, scale, centerX, centerY, density, false);
}
if (showDesign) {
this.drawWireframe(context, width, height, camera, variant, scale, centerX, centerY, density, true);
}
}
几个可直接迁移的工程手法:三角函数只在每帧开头算一次;sort 做画家算法的远到近覆盖;9 / Math.max(3, 9 - depth) 中的 max 防止分母趋零;屏幕外点先做包围判断再跳过;颜色预先算成字符串,循环里只做选择不做拼接;「实景 / 点云 / 高程 / 线稿」四种模式共用同一份点云,切换成本极低。
重绘调度同样值得照搬:
// entry/src/main/ets/components/FacadeViewport.ets : 73-95
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, this.showDesign);
}, 16);
}
private resetView(): void {
this.renderer.resetCamera(this.camera);
this.zoomText = '100%';
this.draw();
}
private setZoom(value: number): void {
this.camera.zoom = Math.max(0.58, Math.min(2.15, value));
this.zoomText = Math.round(this.camera.zoom * 100).toString() + '%';
this.draw();
}
「已有定时器就不再排新任务」把一帧内的多次失效请求合并成一次重绘;aboutToDisappear() 里 clearTimeout(this.drawTimer) 避免页面退出后回调悬空;五个 @Watch(variant / pointScale / splatOpacity / showDesign / frameAngle)回调都显式调用 draw(),保证状态变化一定触发重绘。存量项目迁移时,若原系统用 requestAnimationFrame 或自研消息循环,这一层就是最直接的替换点。
性能边界也要写清楚。这套实现是 Canvas 2D 软件绘制的确定性伪点云,不是训练出来的 3D Gaussian Splatting 模型:数据结构里没有协方差、球谐系数与分块光栅化,也没有 NPU 推理或质量评测函数。当点数从万级涨到百万级,每帧 sort 的 O(n log n)、逐点 ellipse 路径与 fillStyle/globalAlpha 状态切换都会成为瓶颈,届时把视口换成 XComponent 承载的 GPU 管线更合理。对引入 ArkUI-X 跨平台框架 的存量工程来说,Canvas 2D 这类基础绘制能力通常属于跨端可复用层,但 GPU 视口与设备侧推理由应留在鸿蒙PC 原生层——这与第六节得出的输入层边界一致。
八、阶段 05:数据与能力层——把占位数据换成真实管线
迁移的最后一公里,是把「看起来能用」变成「真的能用」。目标态的数据层是三个接口加几个工厂函数,全部是字面量:
// entry/src/main/ets/common/Models.ets : 1-33 / 35-52
export interface SurveyProject {
id: number;
name: string;
descriptor: string;
district: string;
variant: number;
frames: number;
captureDuration: string;
coverage: number;
scaleError: string;
score: number;
area: number;
facadeWidth: string;
facadeHeight: string;
updated: string;
}
export interface SurveyIssue {
id: string;
title: string;
detail: string;
location: string;
severity: string;
area: string;
status: string;
}
export interface CaptureFrame {
index: number;
time: string;
quality: number;
angle: number;
}
export function createSurveyProjects(): SurveyProject[] {
return [
{
id: 1,
name: '安福路 128 号',
descriptor: '沿街砖木店铺',
district: '上海 · 静安',
variant: 0,
frames: 186,
captureDuration: '09:42',
coverage: 94,
scaleError: '4.2 mm',
score: 98.2,
area: 285,
facadeWidth: '12.40 m',
facadeHeight: '6.80 m',
updated: '今天 10:24'
}
// id 2「武夷路 46 弄」、id 3「阜丰里仓库」结构相同
];
}
SurveyProject 的字段设计(帧数、覆盖率、尺度误差、评分、面积、立面尺寸、更新时间)本身是可复用资产,迁移时保留结构、把工厂函数换成数据库查询或本地文件读取即可。但 98.2 这类数值必须从评测函数产出,不能继续留在字面量里,否则质量面板永远是假的。
导出与补采同样只有界面,没有 IO:
// entry/src/main/ets/pages/Index.ets : 111-129
private beginCapture(route: string): void {
this.dialogMode = 'none';
this.isProcessing = true;
this.processProgress = 0;
this.processTimer = setInterval(() => {
this.processProgress = Math.min(100, this.processProgress + 3);
if (this.processProgress >= 100) {
clearInterval(this.processTimer);
this.processTimer = -1;
this.isProcessing = false;
this.notify(route + '清单已生成 · 预计现场用时 6 分钟');
}
}, 45);
}
private exportSurvey(format: string): void {
this.dialogMode = 'none';
this.notify(format + '导出任务已加入本地队列');
}
beginCapture 用 setInterval 每 45 ms 加 3 的模拟进度,达到 100 后清理定时器并弹 toast;exportSurvey 只改状态、不写文件。全工程没有 @kit.CoreFileKit、没有 picker、没有 writeFile、没有 requestPermissions。图 4 的导出对话框因此只是导出能力的界面契约。这段代码仍有可学之处:Math.min(100, ...) 防越界、完成时主动 clearInterval 并把句柄置回 -1、aboutToDisappear() 兜底清理,这些习惯在换成真实任务后依然成立。
给迁移团队一个可直接落地的替换骨架。把「进度来源」抽象成事件,UI 层就不需要知道任务跑在定时器、Worker 还是原生模块里:
export interface ProgressEvent {
taskId: string;
percent: number;
message: string;
}
export class ProgressReporter {
private percent: number = 0;
private readonly taskId: string;
onEvent: (event: ProgressEvent) => void = () => {};
constructor(taskId: string) {
this.taskId = taskId;
}
update(delta: number, message: string): void {
this.percent = Math.min(100, Math.max(0, this.percent + delta));
const event: ProgressEvent = {
taskId: this.taskId,
percent: this.percent,
message: message
};
this.onEvent(event);
}
current(): number {
return this.percent;
}
reset(): void {
this.percent = 0;
}
}
这个类不依赖任何系统 Kit,可直接放进 ets/common/,在 ArkUI-X 跨平台框架的跨端代码里也能编译。真实改造时,把 beginCapture 里的 setInterval 换成任务模块回调 reporter.update(...),把 exportSurvey 换成「选择目录 → 写 PLY/OBJ/IFC → 回传结果」,再按需补齐文件权限与错误分支即可。对存量系统里已有的导出逻辑,应尽量保留其文件格式与校验代码,只替换平台相关的文件访问层——这正是阶段 00 三色清单里「可复用」与「需桥接」的价值所在。
九、回归验收:在鸿蒙PC 上按固定口径验证
迁移不是「编译通过」就结束。桌面形态的验收必须覆盖窗口、输入、状态与产物四类,建议在鸿蒙PC 设备上按下面的清单逐项走一遍。
复现步骤
- 准备与工程匹配的 DevEco Studio 环境:
build-profile.json5中compatibleSdkVersion/targetSdkVersion为5.0.5(17),runtimeOS为HarmonyOS。 - 用 DevEco 打开
SiteWeave_ohos根目录,等待 hvigor 同步。三处dependencies均为空,不需要额外安装三方 ohpm 依赖。 - 在「项目结构 → 签名配置」确认调试证书有效。签名材料路径与本机账号绑定,换机器要重新配置;
bundleName为com.siteweave.urbansurvey,versionCode为1000000,覆盖安装时注意版本不能回退。 - 连接鸿蒙PC 设备或可用的 2in1 目标,构建生成
entry-default-signed.hap;命令行可用:
hdc list targets
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
- 按图 2 到图 5 依次验证:切换三个项目、拖拽旋转立面、滚轮缩放、点击关键帧时间轴、切换四种着色模式、调整点径与不透明度 Slider、打开「发起现场补采」与「导出测绘成果」对话框、切到「采集」页签查看关键帧网格与覆盖平面图。
- 把窗口拖窄跨过 1210 与 1030 两个阈值,确认左侧档案栏、右侧校核栏依次收起,且相机视角不重置(对应图 7)。
- 截图时保留完整桌面:底部 Dock、系统状态栏、DevEco 窗口与应用窗口同屏,读者一眼可确认运行平台是鸿蒙PC;再把图片上传到图床嵌入文章,避免本地路径在社区里失效。
十条踩坑记录
| # | 现象 / 风险 | 原因 | 处理 |
|---|---|---|---|
| 1 | 手机或普通平板装不上 HAP | module.json5 只声明 2in1 | 明确目标设备形态,多端分发时补齐 deviceTypes |
| 2 | 高分屏上点云发虚、位置偏移 | 把 vp 当成物理像素 | LengthMetricsUnit.PX + vp2px(1) 换算画布尺寸 |
| 3 | 拖窗口后视角跳回默认 | 在尺寸回调里重建了相机对象 | 相机状态存组件成员,尺寸变化只重绘 |
| 4 | 改 Slider 后画面不刷新 | @Watch 只改状态、没触发绘制 | 在回调里显式调用 draw() |
| 5 | 拖动时明显掉帧 | 一帧内多次排重绘、每帧都排序 | 16 ms 合并请求;相机不变时缓存深度顺序 |
| 6 | 把 98.2 / 4.2 mm / 94% 当成评测结论 | 指标是 Models.ets 里的字面量 | 由真实评测函数产出后再引用 |
| 7 | 页面退出后仍有回调 | setInterval / setTimeout 未清理 | aboutToDisappear() 中统一清理并复位句柄 |
| 8 | 误以为 *.msgpack / *.protoBin 是模型权重 | 它们是 ArkTS 编译器缓存 | 只把 entry/src 当源码,构建产物不入仓 |
| 9 | 期待「导出」真的产出文件 | 无文件 IO、无权限声明、无 picker | 按第八节补齐文件访问层与权限 |
| 10 | 期望快捷键或全屏能力 | 无 onKeyEvent、无 window API,仅提示文案 | 作为独立任务排期,不要假设框架自动提供 |
| 11 | 换机器后安装失败 | 调试签名材料路径绑定本机 | 重新配置签名并确认 bundleName 一致 |
| 12 | 迁移到跨端框架后滚轮失效 | 桌面输入能力未做平台桥接 | 把滚轮、悬停、像素密度收敛成窄接口逐端实现 |
这些坑大多不是算法问题,而是鸿蒙PC 桌面形态带来的工程细节:设备形态、像素单位、输入事件、窗口生命周期、签名材料。它们零散,却决定了迁移后的应用能不能真机上稳定跑起来。把验收口径和踩坑表一起归档到 AtomGit 仓库,配合完整桌面截图与设备信息,社区里的其他开发者才能按同一标准复现你的迁移结果;若同时维护多个平台分支,建议统一在 AtomGit 上管理,避免版本与截图对不上号。
十、ArkUI-X 跨平台框架:三种迁移起点的决策矩阵
回到最开始那个问题:存量项目本身可能构建在某个具体跨平台框架上,这会怎样改变迁移到鸿蒙PC 的成本?先把 ArkUI-X 跨平台框架 的定位说准:它是 OpenHarmony 项目群技术指导委员会孵化的跨平台应用开发框架,把 ArkUI 声明式 UI 从 OpenHarmony / HarmonyOS 扩展到 Android、iOS 等操作系统,目标是让开发者基于一套主代码构建多平台应用;公开资料强调的特征包括统一的跨平台 API 能力集与扩展机制、高效渲染管线与平台一致性渲染。
必须强调:本文配套工程是纯 ArkTS 原生实现,并未接入 ArkUI-X 跨平台框架。 这里把它作为迁移选型的对照轴,是因为在三类常见起点里,它的界面层复用成本最低,而平台相关层的改造量并不比其他方案少:
| 存量起点 | 界面层复用 | 桌面输入 / 窗口 | 渲染与设备能力 | 迁移到鸿蒙PC 的主工作量 |
|---|---|---|---|---|
| Qt / Electron 等桌面老项目 | 低,UI 体系不同栈 | 原桌面经验可借鉴,但 API 全换 | 需重新评估原生库与打包 | 界面重写 + 能力桥接 |
| Flutter 等移动跨端项目 | 中,需换用 ArkUI 声明式写法 | 桌面交互普遍缺失 | 自绘引擎需按目标平台验证 | 界面映射 + 输入补齐 |
| ArkUI-X 跨平台框架工程 | 高,@Component / @State / 布局同源 | 仍需按端实现滚轮、悬停、窗口断点 | 仍需鸿蒙PC 原生层承载 | 平台桥接 + 形态适配 |
| ArkTS 原生工程(本工程) | 不适用 | 已具备 | 已具备 | 按六阶段清单做能力补齐 |
由此得到一条可执行的判断:跨平台框架降低的是「界面代码跨操作系统复用」的成本,不降低「桌面形态适配」的成本。这两条轴相互独立。若产品只面向鸿蒙PC,原生 ArkTS 在包体、平台能力调用和调试链路上更直接(本工程零依赖、HAP 约 695 KB);若还要覆盖 Android / iOS 且主要工作量在列表、表单、检查器这类界面上,把声明式界面层交给 ArkUI-X 跨平台框架是划算的;若核心工作量在三维视口、文件导出、端侧推理这类系统能力上,那么跨平台框架能复用的只是外壳。
对 ArkUI-X 跨平台框架的迁移,建议按五步走。第一步,把纯逻辑抽到 common(layoutModeOf、summarize、ProgressReporter、数据模型都属于此类),这部分跨端直接编译。第二步,按「是否触碰系统 Kit」给组件分类:街巷档案、校核面板、对话框、关键帧网格属于可复用界面;Canvas 上下文、vp2px、onAxisEvent、deviceTypes 属于平台相关。第三步,为平台相关能力定义窄接口(注册滚轮回调、查询像素密度、触发重绘、导出文件),鸿蒙PC 侧用原生实现,其他端各自实现。第四步,把渲染引擎留在原生层,界面层只通过状态与回调交互,避免把 CanvasRenderingContext2D 之类的具体类型泄漏进跨端代码。第五步,在鸿蒙PC 设备上做回归:拖动窗口跨越 1210 / 1030 两个阈值、滚轮缩放、悬停态、高分屏显示,确认平台桥接没有丢掉桌面行为。
换句话说,无论起点是 Qt、Flutter 还是 ArkUI-X 跨平台框架,本文六阶段清单的后四步都省不掉:工程骨架要按鸿蒙PC 重新声明,界面要按窗口宽度重新布局,输入要补齐桌面语义,渲染与数据要按平台能力落地。把这份清单先跑通,再决定要不要引入跨平台框架,比「先选框架再补平台能力」风险低得多。
十一、总结
存量项目迁移改造到鸿蒙PC,本质是一次分层翻译:配置层换成 Stage 模型与 2in1 设备声明,界面层换成声明式组件与 1210 / 1030 双断点,输入层补上滚轮、悬停、拖拽与高分屏换算,渲染层先用 Canvas 2D 跑通再谈 GPU,数据层把硬编码示例换成真实来源与文件能力,最后用固定口径在鸿蒙PC 设备上验收。本文以「织界 · SiteWeave」这个零依赖、零原生库的真实工程作为目标态样本,把每一层的映射关系、可运行代码与踩坑记录都摊开,读者可以直接照着排期。
文章也守住了两条边界。第一条是事实边界:工程内没有任何迁移痕迹,我们不虚构「从某平台移植而来」的出身,而是把它当作迁移完成后的参考形态;界面上的评分、尺度误差与覆盖率是演示占位数据,导出与补采尚未接入真实 IO。第二条是选型边界:ArkUI-X 跨平台框架擅长复用声明式界面与纯逻辑,而设备形态、桌面输入、高分屏换算、GPU 视口与端侧能力必须落在鸿蒙PC 原生层。
如果你正在为开源鸿蒙PC 生态做存量系统改造,希望这份「先盘点、再分层、每层可独立验收」的清单能少让你走一段弯路;也欢迎把复现结果、设备截图与踩坑补充提交到 AtomGit,让社区里的每一次迁移讨论都有明确的代码版本与验证口径。
更多推荐



所有评论(0)