从 JChemPaint 到 BondDraw:一个化学结构编辑器的鸿蒙 PC 上架实录
欢迎加入开源鸿蒙 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,走完全相同的五步流水线:
创建应用 → 签名 → 上传 → 审核 → 上架。

上架管的是“包”的合法性与一致性,不关心包里装的是什么。所以本文剩下的所有步骤,可以原样套用到任何鸿蒙 PC 应用上,无论它是 ArkTS、C++ 还是 Python 混编。
四、源码组织与绘制管线:上架前先想清楚“包里是什么”
化学结构不是 UI 组件,是矢量图形:键的角度、双键平行线的偏移量、楔键的三角形、虚楔的短划,全是数学而不是布局。所以化学键绘没有走“组件拼装”的路线,而是用 Canvas 自绘——这也是纯 ArkTS 能把整包压到几百 KB 的根本原因:没有 WebView,没有原生库,只有逻辑代码和 Canvas 指令。
整个编辑器按职责拆成 8 个核心源文件:
| 模块 | 职责 |
|---|---|
| ChemModel | 原子/键/分子板,唯一数据源 |
| MdlIO | MDL mol 读写、SDF 多分子切分 |
| SmilesIO | SMILES 子集解析与子构查询 |
| Renderer | Canvas 渲染:网格、键、原子标注、高亮 |
| Cleanup | 几何规整:30° 吸附、键长归一 |
| Templates | 9 类 53 个结构模板 |
| EditorController | 状态机:工具切换、选区、撤销/重做 |
| Index | ArkUI 页面骨架与 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 开发者中心的“应用管理”页,先建项目、再在项目下创建应用。以本应用为例(下图),几个必须想清楚的字段:

- 应用类型: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 加一的事。
更多推荐


所有评论(0)