欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_JChemPaint

应用写完只是完成了半个工程,过审上架才算交卷。本文以我的化学结构编辑器「化学键绘 BondDraw」为例,完整复盘一次鸿蒙 PC(2in1)应用上架:AGC 建应用、发布签名、release 包与调试包的分野、软件包上传与版本号策略,以及 9568322 这类只有上架阶段才会踩到的坑。文中穿插工程收尾与核心实现的真实代码。

在这里插入图片描述

一、为什么值得单独写一篇“上架”

写 ArkTS 的资料已经不少,但“应用做完之后”的那一段路——签名、AGC、提审、上架——资料零散,坑还特别集中。桌面时代的老经验是“打个安装包发给用户就行”,这条路在鸿蒙上不通:开发调试阶段 hdc 直装没问题,但应用要抵达真实用户,正门只有应用市场一条:AGC 提包 → 审核 → 上架。

这篇文章按时间线把这条路完整走一遍。主角是化学键绘(BondDraw)——它足够小,所以流程里的每一步都看得清清楚楚,不会被工程本身的复杂度搅浑。同时把上架前后真正动了代码的地方、踩过的坑和对应的关键实现一并放出来,让这篇复盘对“准备上架第一个鸿蒙 PC 应用”的开发者可以直接照着做。

二、作品速览:化学键绘是什么

化学键绘是一款面向 2in1 / PC 平板设备的化学结构编辑器。交互范式来自开源项目 JChemPaint(GPL-2.0-or-later),代码则是纯 ArkTS 独立重写:无 Java、无 C、无第三方依赖、不联网——这个决定在后文的审核环节会体现出价值。

先看一张真机运行全貌:顶部是 文件/编辑/视图/原子/键/反应/工具/帮助 菜单栏,左侧是键型与模板面板(单键、双键、实楔/虚楔/波浪立体键、三元环到八元环、苯环……),底部是 C/H/O/N/P/S/F/Cl/Br/I/R 与周期表入口的元素栏,右下状态栏实时显示原子数、键数与分子式。

真机运行全貌:菜单栏、键型模板面板、画布与状态栏

功能面概括起来是七块:

  • 结构绘制:单/双/三键、实楔/虚楔/波浪立体键、30° 角度吸附、拖拽幻影预览;
  • 选择与编辑:点选/框选/套索三种选择,原子与键可独立选中、批量删除;
  • 化学标注:电荷、同位素、自由基、基式氢增删、E/Z 双键立体、R 基团标记;
  • 反应绘制:多种反应箭头,拖拽定义起止点;
  • 模板库:9 类 53 个结构模板(环系、杂环、官能团、氨基酸、糖类、甾体、常用试剂等);
  • 文件读写:MDL mol 读写、SDF 多分子、SMILES 子集、简版 InChI 标识;
  • 结构搜索:SMILES 子构查询,BFS 子图匹配并高亮命中原子。

应用介绍图:结构绘制特性

应用介绍图:功能总览

对“上架叙事”真正有用的,是几个让审核员打开就能玩起来的细节:

  • 模板库一键加载咖啡因、维生素 C、尼古丁等经典分子,状态栏实时显示原子数、键数与 Hill 分子式,画完即刻核对;
  • 文件读写全走 DocumentViewPicker,用户授权制,应用权限表干干净净;
  • 30° 吸附、双键平行线、杂原子 CPK 配色,让任何一张真机截图都自带专业感——后文的应用介绍图全部由此而来。

三、先立住一个认知:上架不挑“包”的内部形态

结论先行:上架流程与应用的内部形态完全无关。

纯 ArkTS 的轻应用是几百 KB 的 HAP;而我同时在做的另一类 native 方案(下图)走的是另一条技术路线:CPython 3.12 静态链进 NAPI so,ArkTS 只做壳,运行时把标准库解压到 filesDir。两者体量差着数量级,但落到 AGC 眼里是同一个东西——一个带合法签名的 HAP,走完全相同的五步流水线:

创建应用 → 签名 → 上传 → 审核 → 上架。

另一类 native 工程的形态:CPython 静态链进 so,ArkTS 做壳

上架管的是“包”的合法性与一致性,不关心包里装的是什么。所以本文剩下的所有步骤,可以原样套用到任何鸿蒙 PC 应用上,无论它是 ArkTS、C++ 还是 Python 混编。

四、源码组织与绘制管线:上架前先想清楚“包里是什么”

化学结构不是 UI 组件,是矢量图形:键的角度、双键平行线的偏移量、楔键的三角形、虚楔的短划,全是数学而不是布局。所以化学键绘没有走“组件拼装”的路线,而是用 Canvas 自绘——这也是纯 ArkTS 能把整包压到几百 KB 的根本原因:没有 WebView,没有原生库,只有逻辑代码和 Canvas 指令。

整个编辑器按职责拆成 8 个核心源文件:

模块职责
ChemModel原子/键/分子板,唯一数据源
MdlIOMDL mol 读写、SDF 多分子切分
SmilesIOSMILES 子集解析与子构查询
RendererCanvas 渲染:网格、键、原子标注、高亮
Cleanup几何规整:30° 吸附、键长归一
Templates9 类 53 个结构模板
EditorController状态机:工具切换、选区、撤销/重做
IndexArkUI 页面骨架与 Canvas 事件

数据流是单向的:Canvas 事件进 EditorController,控制器改 ChemModel,Model 变更后触发 Renderer 整体重绘。所有 I/O(mol/SDF/SMILES)都只发生在 Model 边界上,渲染层永远不碰文件。这个结构在第四章的真机回归里帮了大忙——任何一个功能的回归都能定位到唯一职责的模块。

几个核心实现值得展开。

最小数据模型。原子与键的字段和功能一一对应,标注能力(电荷/同位素/自由基/基式氢)全部建模在原子上,立体化学(实楔/虚楔/波浪)建模在键上:

interface ChemAtom {
  id: number;
  x: number;           // 画布坐标
  y: number;
  symbol: string;      // C / H / N / O / P / S / F / Cl / Br / I / R
  charge: number;      // 电荷标注
  isotope: number;     // 同位素,0 表示自然丰度
  radical: boolean;    // 自由基
  hCount: number;      // 基式氢
}

interface ChemBond {
  id: number;
  a: number; b: number;                        // 两端原子 id
  order: 1 | 2 | 3;                            // 单/双/三键
  stereo: 'none' | 'wedge' | 'hash' | 'wavy';  // 实楔/虚楔/波浪立体键
}

30° 角度吸附。新键的方向量化到 30° 网格上——这是“每一根键都落在正确的角度上”的全部秘密,也只是几行数学:

const STEP = 30 * Math.PI / 180;

// 把从锚点出发的方向量化到 30° 网格,长度保持原样
function snapDir(dx: number, dy: number): [number, number] {
  const len = Math.hypot(dx, dy);
  const theta = Math.round(Math.atan2(dy, dx) / STEP) * STEP;
  return [len * Math.cos(theta), len * Math.sin(theta)];
}

Hill 分子式。状态栏的分子式遵循 Hill 规则:有碳时 C 在前、H 次之、其余按字母序;无碳时全部按字母序:

function hillFormula(counts: Record<string, number>): string {
  const elems = Object.keys(counts).sort();
  const order = counts['C']
    ? ['C', 'H', ...elems.filter(e => e !== 'C' && e !== 'H')]
    : elems;
  return order.map(e => counts[e] > 1 ? `${e}${counts[e]}` : e).join('');
}

子构搜索。把查询 SMILES 解析成原子骨架图,在目标分子上从每个原子出发做 BFS 同构匹配,命中即高亮——画完分子立刻能验证子结构,这个功能在审核员“随手玩”的场景里表现很好:

// 从 start 出发对查询骨架做 BFS 对齐,全部对上则返回命中原子 id 列表
function matchFrom(mol: ChemModel, start: number, q: QueryGraph): number[] | null {
  const seen = new Map<number, number>();   // queryIdx -> molAtomId
  const queue: number[] = [0];
  seen.set(0, start);
  while (queue.length > 0) {
    const qi = queue.shift()!;
    for (const nb of q.neighbors(qi)) {
      if (seen.has(nb)) continue;
      const cand = mol.neighborsOf(seen.get(qi)!)  // 逐层找对齐原子
        .find(id => q.atomFits(nb, mol.atom(id)) && !seenValues(seen).includes(id));
      if (cand === undefined) return null;
      seen.set(nb, cand);
      queue.push(nb);
    }
  }
  return [...seen.values()];
}

五、上架前的工程收尾:真机回归清单

审核最常见的驳回理由之一是“功能异常”——包传得上去,不等于功能没有边角问题。提审前我做了一轮真机回归,把四类问题清零。

1. 首启行为。 早期版本启动时自动加载示例分子。对开发者这是 demo,对审核场景却可能变成“重装后状态不一致”的把柄。改为首次进入空白画布,内容由模板菜单显式加载。

2. 导出 PNG 必须是真实现。 截图走 componentSnapshot,编码走 ImagePacker,写盘走 DocumentViewPicker。这里有个 SDK 版本细节值得记录:API 6.0.1(21) 起 ImagePacker.pack 已移除,要用 packToData 拿回编码后的数据:

import { componentSnapshot } from '@kit.ArkUI';
import { image } from '@kit.ImageKit';
import { picker, fileIo as fs } from '@kit.CoreFileKit';

// 1) 对绑定了 id 的画布组件截图
const comp = await componentSnapshot.get('chemcanvas');
// 2) 编码 PNG —— 6.0.1(21) 起 pack() 已移除,须用 packToData
const packer = new image.ImagePacker();
const buf = await packer.packToData(comp, { format: 'image/png', quality: 100 });

落盘这半段值得完整贴出来——三个 API 细节每个都踩过:openSync 返回的是 File 对象,要取 .fd 传给后续调用;writeSync 接 ArrayBuffer;写完 fsyncSync 确保落盘再关闭:

async function savePng(buf: ArrayBuffer): Promise<void> {
  const inst = new picker.DocumentViewPicker();
  const opts = new picker.DocumentSaveOptions();
  opts.newFileNames = ['structure.png'];   // 预填文件名,用户可改
  const uris = await inst.save(opts);      // 用户授权制:选哪存哪
  if (!uris || uris.length === 0) return;  // 用户取消
  const file = fs.openSync(uris[0],
    fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
  fs.writeSync(file.fd, buf);
  fs.fsyncSync(file.fd);
  fs.closeSync(file.fd);
}

读文件对称地有一坑:util.TextDecoder.decode 只接受 Uint8Array,不要直接喂 ArrayBuffer:

import { util } from '@kit.ArkTS';

function readTextFile(path: string): string {
  const st = fs.statSync(path);
  const file = fs.openSync(path, fs.OpenMode.READ_ONLY);
  const buf = new ArrayBuffer(st.size);
  fs.readSync(file.fd, buf);
  fs.closeSync(file.fd);
  return new util.TextDecoder('utf-8').decode(new Uint8Array(buf));
}

SDF 多分子就是在这个读入函数之上做切分——SDF 是若干 mol 块的拼接,按分隔符切刀即可:

const molecules = text.split('$$$$')
  .map(block => MdlIO.parseMol(block.trim()))
  .filter(m => m !== null);

3. 菜单接线与视图缩放。 放大/缩小/原始比例接到缩放栈上:每档 ×1.25 / ÷1.25,钳位 0.2–5。审核对“点了没反应”的容忍度为零,每个菜单项都必须有可感知的效果:

private static readonly MIN_SCALE = 0.2;
private static readonly MAX_SCALE = 5;

zoomIn(): void       { this.scale = Math.min(EditorController.MAX_SCALE, this.scale * 1.25); }
zoomOut(): void      { this.scale = Math.max(EditorController.MIN_SCALE, this.scale / 1.25); }
zoomOriginal(): void { this.scale = 1; }

4. 字号与可读性。 PC 屏幕的使用距离和手机不同,UI 字号整体升档,画布上的原子标签也从 14px 提到 16px。这类改动一行代码的事,却直接影响审核与评测时对“界面完成度”的观感。

这一阶段同时定下一条红线:权限表能空则空。读写文件走 picker、网络能力为零。权限表越短,审核环节需要解释的东西越少——这会在第九节兑现。

六、AGC 建应用:把“工程”变成“应用”

登录 HarmonyOS 开发者中心的“应用管理”页,先建项目、再在项目下创建应用。以本应用为例(下图),几个必须想清楚的字段:

AGC 基础信息页:包名、支持设备与版本状态

  • 应用类型:HarmonyOS 应用;
  • 应用包名:org.jchempaint.jchempaint。这一步定死后不可修改——包名是发布 Profile、市场详情页、后续所有版本升级的锚点,宁可多想十分钟再填;
  • 支持设备:勾选 PC/2in1。这个勾选直接决定应用出现在鸿蒙 PC 应用市场的“电脑”分类下;
  • 默认语言 / 应用分类:简体中文、“应用”大类;
  • APP ID:控制台自动分配,无需手填;
  • SDK 配置:agconnect-services.json 可先下载备用——应用没接推送等云端能力时暂时用不上。

侧栏的“开放能力管理”和“ACL 权限”两个标签页平时不用碰:只有申请受限开放能力时才在这里提交 ACL 申请。本应用没有任何受限权限,两个页面全程零操作。

这一步同时把签名要用的材料在 AGC「证书/APP ID/Profile」页备齐:DevEco 生成 CSR → 申请一张发布证书(.cer)→ 新建与包名绑定的发布 Profile(.p7b)。材料链在第七节派上用场。

图里最值得看一眼的是左下角的「版本状态:1.0.0 已上架」——那是这条流水线的终点标志。

七、签名:上架路上第一道真正的坑

鸿蒙的签名是两套互不相通的体系:

  • 调试签名:DevEco Studio 自动签名生成(调试证书 + 调试 Profile),日常 hdc install 验证全靠它;
  • 发布签名:发布证书(.cer)+ 发布 Profile(.p7b),Profile 与包名绑定,只服务应用市场分发通道。

工程里对应 build-profile.json5 的 signingConfigs:

"signingConfigs": [
  {
    "name": "release",
    "type": "HarmonyOS",
    "material": {
      "storePassword": "****",
      "certpath":    "./certs/bonddraw_release.cer",   // 发布证书
      "keyAlias":    "bonddraw",
      "keyPassword": "****",
      "profile":     "./certs/bonddraw_release.p7b",   // 发布 Profile,绑定包名
      "signAlg":     "SHA256withECDSA"
    }
  }
]

然后是本文标题里的那个坑:

现象:调试包一切正常;换成 release 签名重新 assembleHap 后,同一台设备 hdc install 直接拒绝,报 9568322 not trusted app source。

根因:这不是配置错误,是设计行为。带发布 Profile 的包是交给应用市场分发通道的,本机直装只信任调试包(以及经市场安装的包)。

由此固化出一条工作流:自测用 debug 包,提审用 release 包,两者绝不混用。release 包出包后不要再试图本机直装“最后验证一遍”——它永远过不了 hdc 这一关,签名一致性由 AGC 上传时的机器校验兜底。

切到 release 签名后的出包命令:

hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon
# 产物:entry/build/default/outputs/default/entry-default-signed.hap

(顺带一条跨项目教训:让 IDE 自动签名时,留意 compatibleSdkVersion 有没有被悄悄改写。SDK 版本在工程里锁死之后就不要再动,stage 不匹配会在运行期以各种奇形怪状的 import 错误出现。)

八、软件包管理:上传、版本号与素材

AGC 侧栏「软件包管理」上传 HAP。上传瞬间平台做一轮机器校验:包名与 Profile 一致、versionCode 单调递增、权限表与 Profile 匹配、签名链完整。任何一项不过,包都进不了下一步。

版本号策略放在 AppScope/app.json5:

{
  "app": {
    "bundleName": "org.jchempaint.jchempaint",
    "versionCode": 1000000,
    "versionName": "1.0.0"
  }
}

versionCode 是整数、只增不减,市场靠它判断升级;versionName 是用户可见的字符串。本应用为 1.0.0 (1000000),下一版无论改动多小,也只需要递增为 1000001 / “1.0.1” 再走一遍同一套流程。

版本信息页还要填「新特性」文案——它原样出现在市场详情页的“新功能”栏(下一节截图顶部可见),建议写成功能清单,不要写口号。

应用信息页另有两块素材工作:

  • 图标与介绍图:介绍图用横版真实截图加工,像素规格以控制台标注为准,两条硬要求是“真实运行画面”与“无水印、无角标”。本文三张介绍图(绘制特性 / 功能总览 / 模板库)全部由真机截图统一排版生成,市场“预览”区直接轮播;
  • 隐私政策 URL:哪怕应用零权限、零联网,也要给出一份能自圆其说的声明——“本应用不收集任何用户数据”本身就是一份合格的声明。

九、审核与结果

提交审核之后,状态机只剩两条路:通过,或驳回。审核关注的是能否正常启动、界面与介绍是否一致、交互有没有明显断裂、权限是否最小化。对一个零权限的离线绘图工具,权限部分基本是送分题;真正决定结果的,是第五节那轮真机回归做得是否扎实。

结果如下图:应用市场可搜“化学键绘”,详情页显示版本 1.0.0 (1000000)、分类“工具”、年龄 3+、支持 2in1/PC,在鸿蒙 PC 端的“电脑”分类下可以直接点「打开」安装。从这一刻起,“工程”正式变成了“产品”。

应用市场详情页:已上架

十、上架 Checklist

  • 包名:上架后不可改,先想清楚再填
  • 应用类型 HarmonyOS 应用,支持设备勾选 PC/2in1
  • versionCode 整数单调递增,versionName 用户可见
  • 调试签名自测 / 发布签名提审,两套 Profile 都与包名绑定
  • release 包 hdc 报 9568322 是设计行为,不要试图绕过
  • 权限表能空则空,文件读写走 picker,受限能力才走 ACL
  • 隐私政策 URL 必填,零权限也要声明“不收集数据”
  • 介绍图横版真实截图,规格以控制台标注为准
  • “新特性”文案会出现在市场“新功能”栏,写成功能清单
  • 提审前完成一轮真机回归(首启 / 导出 / 菜单 / 缩放 / 字号)
  • 图标、名称、简介与实际功能一致
  • 后续升级走 AGC「版本/升级」,versionCode 递增

十一、QA

Q1:为什么用纯 ArkTS 重写,而不是像很多桌面应用那样移植 Electron/Qt?
化学结构是矢量图形,核心交互全部发生在“数据模型 → Canvas 绘制”这条链上,不需要任何重运行时。纯 ArkTS 换来的是几百 KB 的包体、零权限、没有原生 so、也没有运行时适配层——上架审核和后续维护的成本都被压到最低。反之,重依赖运行时(Python/Qt/大型 Web 应用)才值得走移植路线。

Q2:重写开源项目的交互,版权问题怎么处理?
重写的是交互范式与功能设计,代码是独立实现的 ArkTS,没有复制上游受版权保护的代码资产;上架材料的介绍里明确注明交互来源(JChemPaint,GPL-2.0-or-later)。范式与思路本身不在版权保护范围,这是这类“开源交互 + 独立实现”路线的立足点。

Q3:零权限的应用怎么读写文件?
全走 DocumentViewPicker:save() 让用户选保存位置、load() 让用户选打开文件,用户授权制、选哪存哪,应用权限表保持为空——第五节那三段落盘代码的语义就是“选哪存哪”。

Q4:ImagePacker.pack 报不存在怎么办?
SDK 6.0.1(21) 起该接口已移除,改用 packToData(source, PackingOption) 拿回编码后的数据,再交给 picker 写盘。升级 SDK 后编译不过时优先怀疑这类“被移除/被替换”的 API。

Q5:release 包为什么 hdc 装不上?9568322 是不是签名配错了?
不是配错,是设计行为。带发布 Profile 的包只服务应用市场分发通道,本机直装只信任调试包。固化成工作流:自测用 debug 包、提审用 release 包,两者绝不混用,release 包出包后不要再往 hdc 上撞。

Q6:包名以后能改吗?versionCode 怎么规划?
包名在 AGC 建应用时定死、上架后不可修改,它是发布 Profile、市场详情页和升级链的共同锚点。versionCode 是整数、只增不减,市场靠它判断升级;versionName 才是用户看到的字符串。

Q7:上架流程对 C++/Python 混编的应用也一样吗?
一样。AGC 那五步(建应用、签名、上传、审核、上架)只关心包的合法性与一致性,和包里是 ArkTS、静态链 CPython 的 so 还是其他什么无关——第三节那张 native 工程架构图就是反例佐证。

Q8:应用介绍图有什么硬性要求?
必须来自真实运行画面,无水印、无角标,横版,具体像素规格以控制台标注为准。本文三张介绍图(绘制特性/功能总览/模板库)全部由真机截图统一排版生成,直接用于市场“预览”区轮播。

Q9:应用不联网,隐私政策还要填吗?
要填。这正是一句话就能自圆其说的场景——“本应用不收集任何用户数据”本身就是合格的声明;零联网、零权限反而是送分配置。

Q10:审核被驳回怎么办?
状态机只有通过与驳回两条路,最常见的驳回理由是功能异常。按驳回意见修复后重新提审即可;最好的保险是第五节那轮真机回归——首启、导出、菜单、缩放、字号,每一项都是审核员会点到的交互面。

十二、写在最后

这次上架最大的收获是流程的确定性:AGC 五步——建应用、签名、传包、审核、上架——对任何形态的鸿蒙 PC 应用都一样,坑集中在签名分流和工程收尾两处。而纯 ArkTS 路线的回报也在这一刻兑现:几百 KB 的包体、零权限的权限表、一个可以低成本持续迭代的市场通道——“版本/升级”入口已经在 AGC 里候着,下一版只是 versionCode 加一的事。

Logo

加入「COC·上海城市开发者社区」,成就更好的自己!

更多推荐