存量项目迁移改造到鸿蒙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,而不是手机模拟器或网页预览。图片均已上传图床,便于在社区文章中直接引用。

鸿蒙PC 设备上的发起现场补采对话框

图 1 鸿蒙PC 完整桌面:「发起现场补采」对话框,含补采路线曲线与优先补采目标卡片;背景可见场景视口、右侧校核面板与左侧街巷档案

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

鸿蒙PC 设备上的安福路 128 号立面测绘视口

图 2 「安福路 128 号」场景页签:立面点云视口 + 尺寸标注 + 右侧「测绘校核」面板,评分 98.2、尺度误差 4.2 mm、覆盖率 94%、立面面积 285 m²

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

鸿蒙PC 设备上的阜丰里仓库立面切换

图 3 切换项目到「阜丰里仓库」:视口换成工业遗产外墙的点云立面,右侧评分变为 97.1,左侧选中态同步更新

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

鸿蒙PC 设备上的导出测绘成果对话框

图 4 「导出测绘成果」对话框:PLY + 测绘元数据 / OBJ 测量模型 / BIM 交换包三种格式卡片,以及「包含控制点基准」开关

图 4 对应存量系统里最常见的「导出能力迁移」。这里要如实说明:当前工程只把所选格式记进状态并弹一条「已加入本地队列」的 toast,没有真正写文件。因此它演示的是导出界面的结构,而不是导出实现;第八节会给出补齐文件 IO 与权限的具体建议。把这一点写清楚,读者才不会把演示界面误当成可交付能力。

鸿蒙PC 设备上的采集序列与覆盖平面图

图 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);
}

把清单按层分组后再排期,能显著降低「改到一半发现底层也要动」的风险。这也是本文把后续内容拆成骨架、界面、输入、渲染、数据五个阶段的原因:每一层都能独立验收,不必等全部改完才知道对不对。

存量项目迁移改造到鸿蒙PC 的六阶段落地路径

图 6 六阶段迁移路径(作者自绘):从资产盘点到数据与能力补齐,底部标注 ArkUI-X 跨平台框架在迁移中的位置

接着看清目标态的工程事实,它是后面所有映射关系的锚点:

项值证据
应用 / 包名「织界 · SiteWeave」 / com.siteweave.urbansurveyAppScope/app.json5
模块描述城市更新沿街立面端侧 3DGS 测绘工作台entry/src/main/resources/base/element/string.json
设备形态仅 ['2in1']entry/src/main/module.json5:7、pack.info
SDKcompatibleSdkVersion / targetSdkVersion 均为 5.0.5(17),runtimeOS: HarmonyOSbuild-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、零 CMakeListsbase_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,窗口可以被用户随意拖宽拖窄,这是迁移时最先暴露的问题。目标态的做法是:根容器测宽度,用两个阈值决定侧栏去留,中间画布始终吃掉剩余空间。

存量能力到鸿蒙PC 的落点映射与窗口断点

图 7 左侧为存量能力 → 鸿蒙PC 落点的逐项映射,右侧为 1210 / 1030 双断点下的三栏 → 两栏 → 单栏布局(作者自绘)

先看映射关系,这张表是迁移排期最实用的部分:

存量项目常见实现鸿蒙PC 落点本工程代码证据
窗口 / 页面根容器Column + onAreaChange → windowWidthIndex.ets:642-644
布局断点 / 媒体查询if (windowWidth >= 1210 / 1030)Index.ets:422、:575
鼠标滚轮事件onAxisEvent + getVerticalAxisValue()FacadeViewport.ets:193-196
悬停态 CSS / hover@State hovered + onHoverStudioControls.ets:28-30
高分屏适配getUIContext().vp2px(1) + LengthMetricsUnit.PXFacadeViewport.ets:24,79,160
自绘视图 / 2D 画布Canvas + CanvasRenderingContext2DFacadeViewport.ets:155
弹窗 / ModalStack 覆盖层 + constraintSize 限宽SurveyDialogs.ets:434-436
状态管理 / Store16 个 @State + @Prop @WatchIndex.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 设备上按下面的清单逐项走一遍。

复现步骤

  1. 准备与工程匹配的 DevEco Studio 环境:build-profile.json5 中 compatibleSdkVersion / targetSdkVersion 为 5.0.5(17),runtimeOS 为 HarmonyOS。
  2. 用 DevEco 打开 SiteWeave_ohos 根目录,等待 hvigor 同步。三处 dependencies 均为空,不需要额外安装三方 ohpm 依赖。
  3. 在「项目结构 → 签名配置」确认调试证书有效。签名材料路径与本机账号绑定,换机器要重新配置;bundleName 为 com.siteweave.urbansurvey,versionCode 为 1000000,覆盖安装时注意版本不能回退。
  4. 连接鸿蒙PC 设备或可用的 2in1 目标,构建生成 entry-default-signed.hap;命令行可用:
hdc list targets
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
  1. 按图 2 到图 5 依次验证:切换三个项目、拖拽旋转立面、滚轮缩放、点击关键帧时间轴、切换四种着色模式、调整点径与不透明度 Slider、打开「发起现场补采」与「导出测绘成果」对话框、切到「采集」页签查看关键帧网格与覆盖平面图。
  2. 把窗口拖窄跨过 1210 与 1030 两个阈值,确认左侧档案栏、右侧校核栏依次收起,且相机视角不重置(对应图 7)。
  3. 截图时保留完整桌面:底部 Dock、系统状态栏、DevEco 窗口与应用窗口同屏,读者一眼可确认运行平台是鸿蒙PC;再把图片上传到图床嵌入文章,避免本地路径在社区里失效。

十条踩坑记录

#现象 / 风险原因处理
1手机或普通平板装不上 HAPmodule.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,让社区里的每一次迁移讨论都有明确的代码版本与验证口径。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐