当化学结构编辑器遇上鸿蒙 PC:我把 JChemPaint 移植到了鸿蒙
当化学结构编辑器遇上鸿蒙 PC:我把 JChemPaint 移植到了鸿蒙
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_JChemPaint
一、为什么做这件事
做化学、学化学的人对这类场景一定不陌生:写实验报告要画反应式,备课要画分子结构,写论文要导出一张干净的结构图。这类图有个共同要求——键角要标准、原子标注要符合规范、导出要干净,手画或者用通用绘图软件凑合,出来的东西一眼假。
桌面端的化学结构编辑器并不少,老牌的 JChemPaint 从上世纪末迭代至今,功能成熟、开源免费,背后还有 CDK(Chemistry Development Kit)这个化学信息学库撑腰。但它是 Java/Swing 应用——在 Windows 上要装 JRE,在 ARM 架构的国产 PC 生态上更是基本跑不起来。
鸿蒙 PC 生态起来之后,我检索了一圈:办公、笔记、视频这些大众品类已经有人补位,但垂直领域的专业工具,恰恰是生态里最缺的。大型跨平台软件可以等容器方案,但一个化学结构编辑器,容器里拖着整套 JVM 只为了画几根化学键——包体数百 MB、启动慢、跟系统窗口管理完全脱节,这不是正路。
我的选择是第三条路:把 JChemPaint 移植到鸿蒙 PC,用 ArkTS 原生实现。数据模型、渲染引擎、文件格式处理、交互逻辑整体迁移,最终整个应用只有一个 entry 模块、十个源文件,HAP 包体不到 1 MB,启动即用。

二、迁移路线的取舍:为什么是 T0
三方软件迁移到鸿蒙 PC,业内一般分三个等级:
| 等级 | 方式 | 代价 | 体验 |
|---|---|---|---|
| T2 兼容迁移 | 容器/兼容层跑原平台二进制 | 携带完整原运行时,包体巨大 | 系统集成度低,窗口管理脱节 |
| T1 框架迁移 | 跨平台框架编译过来,共享业务代码 | 仍需框架适配层 | 与原生有差距 |
| T0 原生迁移 | 原生技术栈移植实现,全量适配 | 移植成本最高 | 包体、启动、集成度全部拉满 |
对化学结构编辑器这个体量的应用,功能边界清晰、领域逻辑自洽、不依赖庞大的既有代码库——恰好是 T0 移植的甜点区:成本可控,收益极致。按 T0 清单逐项核对:
- 窗口与页面:Stage 模型 UIAbility + ArkUI 页面路由,跟随系统窗口管理与焦点模型;
- 键盘:完整的
Ctrl+Z/Y/C/V/A/S修饰键组合 + 单键工具切换 +Del/Esc,基于 KeyEvent 与修饰键状态判定; - 鼠标:
onMouse悬停追踪 + 命中测试,原子和键悬停高亮,符合桌面交互习惯; - 触控:双指捏合缩放(0.2x–5x)、双指平移,全部走原生手势接口;
- 文件:全部走系统文件选择器,用户逐次授权,应用自身零文件权限;
- 无私有运行时:没有 JVM、没有浏览器内核、没有 GUI 框架副本。
对照一下:T2 方案要背着 JVM 和整套图形栈,包体轻松破几百 MB;T1 方案也得带框架运行时。这个项目最终 HAP 只有约 0.5 MB——这就是 T0 的意义。
三、总体架构:三层解耦

分层的铁律是:模型层零 ArkUI 依赖(渲染器只依赖 CanvasRenderingContext2D 这个抽象接口),控制器持有全部编辑逻辑,视图层只做事件转发和重绘。这个约束带来的好处马上就能兑现——后面讲子结构搜索的时候,算法直接跑在纯数据结构上,不需要 mock 任何 UI。
数据模型的核心是四个类,对应化学结构图(molecular graph)的四个要素:
export class Atom {
id: number;
symbol: string; // 元素符号
x: number; // 画布坐标
y: number;
charge: number = 0; // 形式电荷
isotope: number = 0; // 同位素质量数,0 = 自然丰度
radical: boolean = false; // 自由基
pseudo: string = ''; // R-基团标签
}
export class Bond {
id: number;
a: number; // 原子 id
b: number; // 原子 id
order: number; // 1..4 键级
stereo: number = 0; // 0无 1实楔 2虚楔 3交叉 4波浪
aromatic: boolean = false;
ez: number = 0; // 0无 1=E(反式) 2=Z(顺式)
}
用 id 而不是对象引用来连键,好处是序列化(撤销快照、mol 文件读写、剪贴板)时不需要处理循环引用,坏处是每次取原子都是一次线性查找——下面性能那一节会讲怎么权衡。

四、核心实现拆解
4.1 整模型快照式撤销/重做
撤销/重做是编辑器的生命线。教科书做法是 Command 模式:每种操作实现 execute()/revert()。但对化学编辑器,"把苯环拖歪了再撤销"要精确还原六个原子的坐标、"合并两个原子再撤销"要还原被删的键——逆向命令的实现量会爆炸,而且每加一个新功能都要配对写一个逆操作,漏一个就是脏状态 bug。
我选择了整模型 JSON 快照:每次变更前 checkpoint(),恢复时重建对象:
private snapshot(): string {
return JSON.stringify([this.atoms, this.bonds, this.nextId, this.arrows]);
}
private restore(s: string): void {
const raw = JSON.parse(s) as Object[];
this.atoms = (raw[0] as Atom[]).map((o: Atom) => {
const a = new Atom(o.id, o.symbol, o.x, o.y);
a.charge = o.charge;
a.isotope = o.isotope;
a.radical = o.radical;
a.pseudo = o.pseudo;
return a;
});
// bonds / arrows 同理重建……
this.nextId = raw[2] as number;
}
checkpoint(): void {
this.undoStack.push(this.snapshot());
if (this.undoStack.length > 100) {
this.undoStack.shift();
}
this.redoStack = [];
}
两个关键细节:
- 快照必须包含
nextId计数器——否则撤销后新建原子会分配到历史 ID,键的端点、选中集合全部错乱。这是自测时踩出来的真实 bug; - 恢复时重建对象而不是直接赋值——JSON.parse 出来的是裸对象,没有类方法,直接赋给
this.atoms后续调用会崩。ArkTS 严格模式下没有as any可用,老实写映射代码反而最稳。
4.2 Canvas 渲染:IUPAC 标签规则与芳环内切圆
渲染层最见功力的不是画线,而是何时画、画什么。IUPAC 惯例是链中间的碳原子不写字母,端点碳也不写,读者从键的折线端点自动脑补 CH₃;而 N、O、S 这些杂原子必须标注,并带上隐式氢计数。这一套规则决定了一张结构图是"像专业软件画的"还是"像随手画的":
/** IUPAC 规则:非端点碳隐藏标签 */
private labelVisible(model: ChemModel, a: Atom): boolean {
if (a.symbol === 'C' && a.pseudo.length === 0) {
const nb = model.bondsOf(a.id);
if (nb.length === 0) {
return true; // 孤立碳按 CH4 处理
}
if (!this.params.showEndCarbons && nb.length === 1) {
return false; // 端点碳隐藏
}
if (nb.length >= 2) {
return false; // 链中间碳隐藏
}
}
return true;
}
配套的隐式氢计算按默认价态表减去键级和,带电原子再修正(N⁺ 价态 4、O⁻ 价态 1、C± 价态 3):
private implicitH(model: ChemModel, a: Atom): number {
if (a.symbol === 'H' || a.pseudo.length > 0) {
return 0;
}
let sum = 0;
for (const k of model.bondsOf(a.id)) {
sum += k.order; // 键级求和:双键算 2
}
const defaults: Record<string, number> = {
'B': 3, 'C': 4, 'N': 3, 'O': 2, 'P': 3, 'S': 2,
'F': 1, 'Cl': 1, 'Br': 1, 'I': 1
};
let val = defaults[a.symbol] ?? 0;
if (a.charge !== 0) {
if (a.symbol === 'N') {
val = a.charge > 0 ? 4 : 2;
} else if (a.symbol === 'O' || a.symbol === 'S') {
val = a.charge > 0 ? 3 : 1;
} else if (a.symbol === 'C') {
val = 3;
}
}
const h = val - sum;
return h > 0 ? h : 0;
}
键本身的绘制按立体语义分派:实楔是从窄到宽的实心三角,虚楔是七根渐宽的横杠,波浪键是锯齿折线,交叉键是两根交叉斜线。普通多键则沿法向平行偏移——双键左右各一条,三键中间一条加两侧各一条:
const nx = -dy / len; // 键向量的单位法向
const ny = dx / len;
let offs: number[] = [0];
if (k.order === 2) {
offs = [-sep, sep];
} else if (k.order === 3) {
offs = [-2 * sep, 0, 2 * sep];
} else if (k.order === 4) {
offs = [-3 * sep, -sep, sep, 3 * sep];
}
for (let i = 0; i < offs.length; i++) {
c.beginPath();
c.moveTo(x1 + nx * offs[i], y1 + ny * offs[i]);
c.lineTo(x2 + nx * offs[i], y2 + ny * offs[i]);
c.stroke();
}
还有两个容易被忽略但对成图质量影响巨大的细节:
- 端点回缩:键要向两端原子标签方向各回缩 10px,并垫一块白色背景框,让键"看起来停在字母边缘"而不是穿过字母;
- 芳环内切圆:芳香环的键全部画单线,环中心画一个表示离域大 π 键的圆,半径按环上原子到质心的平均距离乘以缩放系数计算。相比 Kekulé 交替双键,它对萘、蒽、芘这类稠环的视觉表现更干净,也不用处理交替双键在共享边上的冲突分配。
4.3 命中测试:编辑器手感的根基
编辑器的手感好不好,八成取决于命中测试。原子用圆形距离判定,键用点到线段距离——注意不是点到直线,端点外的情况要截断:
hitBond(x: number, y: number, tol: number): Bond | undefined {
let best: Bond | undefined = undefined;
let bestD = tol;
for (const k of this.bonds) {
const p = this.getAtom(k.a);
const q = this.getAtom(k.b);
if (p === undefined || q === undefined) {
continue;
}
const dx = q.x - p.x;
const dy = q.y - p.y;
const l2 = dx * dx + dy * dy;
let t = l2 === 0 ? 0 : ((x - p.x) * dx + (y - p.y) * dy) / l2;
t = Math.max(0, Math.min(1, t)); // 截断到线段范围
const d = Math.hypot(x - (p.x + t * dx), y - (p.y + t * dy));
if (d <= bestD) {
bestD = d;
best = k;
}
}
return best;
}
命中容差也有讲究:原子 12px、键 8px——原子判定更宽松,因为用户 intent 上"点到原子附近"就是想选中原子;键更严格,避免稠密区域抢命中。每次鼠标移动都跑一遍命中测试驱动悬停高亮,鼠标手势和最终点击行为完全一致,所见即所得。
4.4 指针状态机:30° 吸附与幻影预览
绘制交互的状态机分按下/移动/抬起三态。最有意思的是"从已有原子拖出新键"这条路径:移动超过 6px 才确认为拖拽(区分点击升级键级),拖拽过程画灰色幻影预览,抬起时目标角度吸附到 30° 网格:
// 抬起阶段:从已有原子拖出
const from = this.dragAtom;
let target = this.model.hitAtom(x, y, 12);
if (target === undefined || target.id === from.id) {
// 没有落在其他原子上:按 30° 吸附角生成新原子
const ang = Math.round(Math.atan2(y - from.y, x - from.x) / (Math.PI / 6)) * (Math.PI / 6);
target = this.model.addAtom(this.element,
from.x + BOND_LEN * Math.cos(ang),
from.y + BOND_LEN * Math.sin(ang));
}
this.model.addBond(from.id, target.id, spec[0], spec[1]);
这一条规则同时解决了三个问题:键长恒定 40px(吸附距离钳制)、键角永远落在 30° 的整数倍上(Math.PI / 6 的整数倍)、拖到已有原子上自动成环键而不是叠一个新原子。链工具复用同一套吸附逻辑,按拖拽距离除以键长算段数,之字形交替 ±30° 生成。
4.5 原子合并:拖拽落点的拓扑修复
把一个原子拖到另一个原子上时触发合并,这不是删一个加一个的事——要把被合并原子的所有键改接到目标原子上,然后清理由此产生的自环和重复键:
mergeNear(drag: Atom, tol: number): void {
for (const a of this.atoms) {
if (a.id === drag.id) {
continue;
}
if (Math.hypot(a.x - drag.x, a.y - drag.y) <= tol) {
// 改接所有键
for (const k of this.bonds) {
if (k.a === drag.id) {
k.a = a.id;
} else if (k.b === drag.id) {
k.b = a.id;
}
}
this.removeAtom(drag.id);
// 去自环(合并后 a==b 的键)
this.bonds = this.bonds.filter((k: Bond) => k.a !== k.b);
// 去重复键(两个原子间出现两条平行键)
const seen: Set<string> = new Set();
this.bonds = this.bonds.filter((k: Bond) => {
const key = k.a < k.b ? `${k.a}-${k.b}` : `${k.b}-${k.a}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
this.updateRings();
return;
}
}
}
这两个清理步骤缺一不可:环上相邻两个原子合并必然产生自环,而如果两个原子本来就有共享邻居,合并后会出现重复边。合并完成后立刻重跑找环,保证芳香性判定跟拓扑同步。
4.6 SMILES 子集解析器
SMILES 是化学结构的线性文本表示,CC(=O)O 就是乙酸,c1ccccc1 是苯。它本质上是一个深度优先遍历的编码,所以解析器用单趟扫描 + 分支栈就够了:( ) 压栈/弹栈切换连接点,数字标记环闭合(第一次出现登记等待配对,第二次出现成环键),[Na+] 括号原子支持电荷:
if (ch >= '1' && ch <= '9') {
const d = parseInt(ch, 10);
i++;
const open = ringOpen.get(d);
if (open === undefined) {
ringOpen.set(d, prevId); // 环闭合号首次出现,登记原子
} else {
m.addBond(open, prevId, ord); // 二次出现,成环
ringOpen.delete(d);
}
pendingOrder = 1;
continue;
}
小写字母 c n o s p 解析为芳香原子, aromatic 标记沿键传播,配合找环算法就能正确识别苯环并触发内切圆渲染。
解析完成后接一个简化版二维布局器,让从 SMILES 插入的分子落上画布就是标准构型:
// 首个环:正多边形放置
const nn = firstRing.atoms.length;
const rr = L / (2 * Math.sin(Math.PI / nn)); // 内接圆半径
for (let idx = 0; idx < nn; idx++) {
const ang = -Math.PI / 2 + (idx * 2 * Math.PI) / nn;
a.x = cx + rr * Math.cos(ang);
a.y = cy + rr * Math.sin(ang);
}
// 其余原子:BFS 展开,按入射方向 120° 扇形均分
const off = (idx - (k - 1) / 2) * (Math.PI / 3);
const ang = baseAng + off;
na.x = ca.x + L * Math.cos(ang);
na.y = ca.y + L * Math.sin(ang);
L / (2·sin(π/n)) 这个式子值得念叨一句:正 n 边形边长 40px,外接圆半径就是 40 / (2·sin(π/n))——三角函数在这里保证了无论几元环,键长都严格相等。
4.7 子结构搜索:BFS 子图匹配
"搜索"对话框输入一个 SMILES 查询式,命中的原子自动高亮选中。算法是最朴素的子图匹配:对每个与查询首原子同符号的模型原子做锚点尝试,BFS 逐层扩展,邻居符号与键级同时匹配才算命中:
for (const ma of this.model.atoms) {
if (ma.symbol !== qAtoms[0].symbol) {
continue; // 锚点符号不匹配,跳过
}
const matchMap: Map<number, number> = new Map(); // 查询原子id -> 模型原子id
matchMap.set(qAtoms[0].id, ma.id);
const queue: number[] = [qAtoms[0].id];
let ok = true;
while (queue.length > 0 && ok) {
const qid = queue.shift() ?? -1;
const mid = matchMap.get(qid) ?? -1;
for (const qb of qBonds) {
// 对查询原子 qid 的每条键,在模型原子 mid 的键里找匹配
if (qb.a !== qid && qb.b !== qid) {
continue;
}
const qOther = qb.a === qid ? qb.b : qb.a;
// 在 model.bondsOf(mid) 中查找
// 符号相同且键级相同的邻居
}
}
if (ok && matchMap.size === qAtoms.length) {
matches.push(...matchMap.values());
}
}
因为模型层零 UI 依赖,这段算法可以直接在纯数据上开发和验证,最后在对话框里调用三行接上高亮——分层设计在这个功能上兑现了全部承诺。

4.8 模板库:SMILES 即数据
53 个模板没有一个是画出来的——除了 3~8 元环用程序化正多边形生成,其余全部用 SMILES 字符串定义,插入时走解析器 + 布局器:
export const TEMPLATES: TemplateEntry[] = [
{ name: '萘', category: '芳环', smiles: 'c1ccc2ccccc2c1' },
{ name: '咖啡因骨架', category: '杂环', smiles: 'CN1C=NC2=C1C(=O)N(C)C(=O)N2C' },
{ name: '乙酸', category: '试剂', smiles: 'CC(=O)O' },
{ name: '胆固醇骨架', category: '甾体', smiles: 'CC(C)CCCC(C)C1CCC2C1(C)CCC1C3CCC4CC(O)CCC4(C)C3CCC12C' },
// ……共 53 个,9 个分类
];
这个设计的好处是模板库的维护成本趋近于零:想加一个新模板,往数组里加一行 SMILES 就行,坐标是自动算的。甾体骨架这种四环稠合的复杂结构,SMILES 写对,落上画布就是标准构型。
4.9 零权限的文件读写
读 mol/SDF、保存 mol、导出 PNG,全部通过系统 DocumentViewPicker 完成——用户在系统对话框里选中哪个文件,应用才拿到哪个文件的临时访问授权。应用清单里不申请任何持久权限,也不申请网络权限,结构数据全程不出沙箱。
导出 PNG 的链路是三段式:画布快照 → 图像打包 → 保存选择器:
componentSnapshot.get('chemcanvas')
.then((pm: image.PixelMap) => {
const packer = image.createImagePacker();
const opts: image.PackingOption = { format: 'image/png', quality: 100 };
packer.packToData(pm, opts) // SDK 6.0.1:pack() 已移除
.then((buf: ArrayBuffer) => {
const docSave = new picker.DocumentSaveOptions();
docSave.newFileNames = ['structure.png'];
new picker.DocumentViewPicker().save(docSave)
.then((uris: Array<string>) => {
const file = fs.openSync(uris[0],
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, buf);
fs.closeSync(file);
});
});
});
文件打开时做格式嗅探:内容含 $$$$ 按 SDF 多分子处理,含 V2000 按 mol 处理,否则尝试按 SMILES 解析——用户不用记自己存的到底是什么格式。
五、运行效果
在鸿蒙 PC 真机上,从 dock 启动到可绘制不到一秒。主界面截图里,咖啡因(五元咪唑环稠合六元嘧啶环)用模板 + 双键工具半分钟画完:30° 角度吸附让每根键角度干净利落,N/O 原子自动带 CPK 配色(氮蓝氧红),双键平行线间距均匀,羰基的 C=O 一眼可辨。
状态栏实时显示原子数、键数和 Hill 系统分子式——画完咖啡因的一瞬间,分子式: C8N4O2(加氢后即 C₈H₁₀N₄O₂)直接给你核对答案,这是画图工具和化学工具的分野。
示例图版一键加载咖啡因、阿司匹林、尼古丁、香草醛、肾上腺素、维生素 C 六个经典分子,全部经 SMILES 解析 + 自动布局落位,稠环、链状、带电荷结构都有覆盖,可以直接当素材库用。

六、踩坑记录
坑 1:图像打包 API 的版本差异。 早期资料里的 imagePacker.pack(pixelMap) 在 SDK 6.0.1 已经移除,必须改用 packToData(source, PackingOption) 直接拿 ArrayBuffer,再用 fs.writeSync 写入。查 API 文档时一定要对照自己的 compileSdkVersion,别照抄旧博客。
坑 2:签名配置污染构建文件。 DevEco 自动签名会把 signingConfigs 连同本机绝对路径(C:/Users/xxx/...)、加密密码串一起写进根目录 build-profile.json5。这个文件一旦提交仓库,别人克隆后构建必然失败(材料路径不存在),签名 profile 文件名里的中文还会因编码问题变成乱码。正确做法:入库前把签名块整个删掉,签名交给每个人本地的 DevEco 自动签名;同时在 .gitignore 里把 *.p7b / *.p12 / *.cer / *.csr / *.keystore 全部拦住——p12 里可是私钥。
坑 3:命令行构建找不到 Java。 hvigor 的打包工具依赖 Java,命令行构建报 spawn java ENOENT,需要把 DevEco 自带 JBR 的 bin 目录前置到 PATH,并设 DEVECO_SDK_HOME 指向 SDK 目录。
坑 4:快照式撤销的 ID 陷阱。 前面提过,撤销快照漏掉 nextId 计数器,会让撤销后新建的原子复用历史 ID。表现为:撤销→画新原子→之前指向这个位置的其他分子键全部"接"到了新原子上。调试了半小时才发现是 ID 计数器被回退了。
坑 5:剪贴板方案的取舍。 系统剪贴板在沙箱应用间传递富文本并不可靠,最终用应用内自维护的 mol 文本缓冲做复制/粘贴。顺带的好处是剪贴板内容就是标准 MDL 格式,黏贴到其他化学软件里也能认——格式即接口。
七、复现指南
git clone https://atomgit.com/OpenHarmonyPCDeveloper/ohos_JChemPaint.git
- DevEco Studio 打开工程根目录(无外部依赖,同步秒完成);
Build > Build Hap(s)直接出未签名 HAP;File > Project Structure > Signing Configs勾选自动签名;- 连接设备运行。
命令行构建:
$env:DEVECO_SDK_HOME = "<DevEco 安装路径>\sdk"
$env:JAVA_HOME = "<DevEco 安装路径>\jbr"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon
工程要求 API 12+,目标设备 2in1 / 平板。仓库内附完整的 README.md(架构图、算法说明、快捷键表、渲染参数说明)与 README.OpenHarmony_CN.md(开源合规披露与 T0 迁移核对清单),签名材料、构建产物、IDE 缓存均已在 .gitignore 中排除。
八、性能账本
几个量级估算,给关心性能的读者:
- 撤销快照:单次快照是一个 JSON 字符串,100 个原子的分子约 20KB,栈深 100 上限 2MB 内存——完全无感;
- 命中测试:原子和键都是 O(n) 线性扫描,n 是单个分子的规模(通常 < 200),单次测试微秒级,鼠标移动事件驱动毫无压力;
- 找环:DFS + 回边检测,O(V+E),只在拓扑变化后调用,不在渲染帧内;
- 渲染:每帧一次全量重绘,Canvas 2D 画几百条线段在 PC 上远不到 1ms,无需脏矩形优化。
数据结构上的取舍:键用原子 id 而非对象引用连接,换来序列化零成本(快照/文件/剪贴板三处受益),代价是 getAtom 线性查找——在百级原子规模下,这笔账怎么算都是赚的。
九、写在最后
这个项目给我的最大感受是:垂直领域小工具是原生生态最好的填坑素材。它体量小到一个人几周能移植完,又完整覆盖了数据建模、Canvas 渲染、文件格式解析、手势交互、撤销系统这些桌面应用的核心命题。移植的过程本质上是把二十多年开源社区打磨出的交互资产,翻译成新生态的原生语言——License 合规上做的是概念性移植,只复用功能设计与交互理念,不携带上游源码,上游 LGPL-2.1 与本仓库许可证在 README 中均已明确披露。
后续规划按优先级排:手性立体标注(@/@@ 与楔形键自动推导)→ V3000 / RXN 反应式格式 → 力导向布局优化 → 分子力学优化。如果你也在给鸿蒙 PC 生态填坑,欢迎交流踩坑经验;Issue 和 PR 都开放。
更多推荐





所有评论(0)