鸿蒙 PC 桌面端应用上架实战:主进程只有 81 行的 PLC 编辑器,字节码里却找不到一行梯形图代码
鸿蒙 PC 桌面端应用上架实战:主进程只有 81 行的 PLC 编辑器,字节码里却找不到一行梯形图代码
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_OpenPLC


这个应用叫逻控 PLC 编辑器。它做的事不小:梯形图编程、七类逻辑元件、简易仿真执行、项目 JSON 的打开与保存,全部在本地完成。
但它的壳薄得反常:主进程 81 行,只有两个 IPC 通道。
更反常的是包里装的东西。把提审的 .app 拆开,取出里面的字节码文件 modules.abc(912,608 字节),在里面搜业务代码的特征串:
- ladderCanvas:0 次
- file:open:0 次
- openplc:0 次
一行梯形图代码都找不到。
同一份 912,608 字节里,另一个串出现了 107 次:QuickLoginButtonComponent。还有一个 9 次:LoginWithHuaweiIDButton。
这三组数字就是本篇要讲的事:壳可以很薄,但模板留下的东西不会自己消失。
先把边界划清楚。梯形图数据模型怎么设计、Canvas 怎么画触点与线圈、变量表怎么联动、项目怎么序列化,这些引擎与界面的实现细节本篇不展开。
本篇只回答上架前必须弄明白的三个问题:
- 一个功能这么密的桌面应用,壳为什么能只有 81 行,逻辑到底放在哪;
- 名称改了 12 处之后,为什么上游名字还留了 1 处,留得对不对;
- 包里那 912,608 字节到底装了谁,为什么业务代码一个字都没进去。
一、先把六个文件量一遍
业务代码没有打包器、没有 node_modules、没有 bundle 产物,就平铺在 resfile 的 app 目录下,六个文件:
index.html 6281 B 行 142 crlf 142
main.js 2767 B 行 81 crlf 81
renderer.js 18864 B 行 521 crlf 521
plc-engine.js 17825 B 行 544 crlf 544
styles/openplc.css 9351 B 行 437 crlf 437
package.json 213 B 行 8 crlf 8
合计 55301 B 行 1733
package.json 全文只有八行:
{
"name": "luokongplc",
"version": "1.0.0",
"description": "逻控 PLC 编辑器 - 鸿蒙应用(梯形图 + 结构化文本)",
"main": "main.js",
"author": "wei",
"license": "Apache-2.0"
}
没有 dependencies,没有 scripts,没有 build 字段。Electron 直接 loadFile 打开 index.html,六个文件就是全部。
这套结构有一个直接好处:上架前核对包内资源,只需要比对六个文件。后面第十三节会用到这一点,包内这六个文件与磁盘逐字节相同,6 个全中。
顺便说一句命名:包的 name 是 luokongplc,是"逻控 PLC"的拼音;bundleName 是 com.wei.luokongplc,同一个词根。名字换了,包名跟着换,这条链路是通的。
二、81 行的主进程,只干两件事
main.js 全文如下,81 行,一行不多:
// 逻控 PLC 编辑器 主进程
// 职责:窗口管理 + 文件操作 IPC(打开/保存 PLC 项目 JSON)
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var dialog = electron.dialog;
var Menu = electron.Menu;
var fs = require('fs');
var path = require('path');
app.disableHardwareAcceleration();
// 移除应用级默认菜单栏(File/Edit/View/Window/Help)
Menu.setApplicationMenu(null);
var mainWindow = null;
function createWindow() {
try {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.95),
height: Math.floor(display.workAreaSize.height * 0.90),
frame: true,
transparent: false,
resizable: true,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
// 窗口级菜单移除(与应用级双保险)
mainWindow.setMenu(null);
} catch (e) {
console.warn('[逻控] 创建窗口失败:', e.message);
}
}
app.whenReady().then(function() { createWindow(); });
app.on('window-all-closed', function() { app.quit(); });
// ========== 文件操作 IPC ==========
// 打开 PLC 项目 JSON
ipcMain.handle('file:open', function() {
return dialog.showOpenDialog(mainWindow, {
title: '打开 PLC 项目',
filters: [{ name: 'PLC 项目', extensions: ['plc'] }, { name: 'JSON 文件', extensions: ['json'] }],
properties: ['openFile']
}).then(function(result) {
if (result.canceled || result.filePaths.length === 0) {
return { success: false, message: '取消打开' };
}
var filePath = result.filePaths[0];
try {
var content = fs.readFileSync(filePath, 'utf-8');
return { success: true, filePath: filePath, content: content };
} catch (e) {
return { success: false, message: '读取失败: ' + e.message };
}
});
});
// 保存 PLC 项目 JSON
ipcMain.handle('file:save', function(event, content) {
return dialog.showSaveDialog(mainWindow, {
title: '保存 PLC 项目',
defaultPath: 'PLC项目.plc',
filters: [{ name: 'PLC 项目', extensions: ['plc'] }, { name: 'JSON 文件', extensions: ['json'] }]
}).then(function(result) {
if (result.canceled || !result.filePath) {
return { success: false, message: '取消保存' };
}
try {
fs.writeFileSync(result.filePath, content, 'utf-8');
return { success: true, filePath: result.filePath };
} catch (e) {
return { success: false, message: '保存失败: ' + e.message };
}
});
});
读下来只有四个动作:
| 动作 | 落点 | 说明 |
|---|---|---|
| 关硬件加速 | L13 | disableHardwareAcceleration,壳内渲染走软件路径 |
| 关菜单 | L16、L33 | 应用级与窗口级各关一次,双保险 |
| 开窗 | L20-L37 | 屏幕可用区 95% 宽、90% 高,带边框、可缩放 |
| 两个 IPC | L45、L65 | 只做对话框与读写文件,不碰业务 |
窗口参数值得单独看:
width workAreaSize.width * 0.95 后取整
height workAreaSize.height * 0.90 后取整
frame true
transparent false
resizable true
nodeIntegration true
contextIsolation false
比例取 95% 与 90%,不是写死分辨率,也不是全屏。留边的好处是窗口贴边时系统任务栏与窗口控制按钮都不会被遮住;带边框的好处是拖动、缩放、最大化全部交给系统,壳里不用自己画标题栏。
nodeIntegration 打开、contextIsolation 关闭,是这套 Electron 壳的既定前提(渲染进程可以直接 require)。安全边界因此不在 preload,而在这两个 IPC 上:dialog 由主进程弹,路径由系统返回,业务侧拿不到任意路径的写权限。
取消分支也做了:打开取消返回"取消打开",保存取消返回"取消保存",都是 success 为 false,渲染侧据此不覆盖当前项目。这类分支最容易漏,漏了就会出现"点了取消,画布被清空"的问题。
【配图位 2:主进程 81 行的编辑器截图,左侧行号 1-81,滚动到中段,能同时看到 L16 的 Menu.setApplicationMenu(null) 与 L33 的 mainWindow.setMenu(null),以及 L45 的 file:open。用来证明"主进程真的只有 81 行",不是只截了个开头。】
三、逻辑下沉:521 行渲染器与 544 行引擎
主进程不管业务,业务全在渲染进程。分工是这样切的:
| 文件 | 字节 | 行数 | 管什么 |
|---|---|---|---|
| main.js | 2,767 | 81 | 窗口、对话框、文件读写 |
| renderer.js | 18,864 | 521 | 事件绑定、状态管理、列表渲染、画布调度 |
| plc-engine.js | 17,825 | 544 | 数据模型、七类元件、Canvas 绘制、仿真执行、序列化 |
| styles/openplc.css | 9,351 | 437 | 三段式布局、深色主题、对话框 |
渲染器开头就把要用的 DOM 集中成一个对象,后面只认这个对象,不再零散地 getElementById:
el = {
canvas: document.getElementById('ladderCanvas'),
canvasContainer: document.getElementById('canvasContainer'),
programList: document.getElementById('programList'),
画布尺寸不写死,初始化时从容器量:
function resizeCanvas() {
var container = el.canvasContainer;
el.canvas.width = container.clientWidth;
el.canvas.height = container.clientHeight;
}
这一步是 Canvas 应用最容易出错的地方:canvas 的 width、height 属性是绘图缓冲区尺寸,与 CSS 显示尺寸是两套值。只设 CSS 不设属性,画面会被拉伸模糊;只设属性不随窗口变化,缩放窗口后内容会错位。这里用容器实测值同时喂给属性,窗口一改就重新量。
初始化顺序也能看出耦合关系:
L50 bindAboutEvents(); 绑定关于对话框
L53 requestAnimationFrame(renderLoop); 启动渲染循环
两行都在 DOMContentLoaded 回调里,L54 的 }); 收尾,所以顺序是固定的:先把关于按钮、关闭按钮、遮罩三处监听挂上,再排第一帧。
四、引擎 544 行:七类元件、27 个函数、零依赖
引擎文件头三行就把约束写死了:
// 逻控 PLC 编辑器 引擎
// 职责:梯形图数据模型 + Canvas 2D 渲染 + 简化执行引擎 + 序列化
// 约束:纯 Canvas 2D 渲染,无第三方库依赖
第三行是关键。整个引擎 require 出现 0 次,module.exports 出现 1 次,没有任何第三方绘图库。文件里一共 27 个函数定义,全部自己写。
元件类型只有七种:
var ELEMENT_TYPES = {
CONTACT_NO: 'contact_no', // 常开触点 --| |--
CONTACT_NC: 'contact_nc', // 常闭触点 --|/|--
COIL: 'coil', // 线圈 --( )--
TIMER_TON: 'timer_ton', // 接通延时定时器
TIMER_TOF: 'timer_tof', // 断开延时定时器
COUNTER_CTU: 'counter_ctu', // 加计数器
COUNTER_CTD: 'counter_ctd' // 减计数器
};
七种类型在文件里的出现次数很能说明复杂度分布:
CONTACT_NO 4 次
CONTACT_NC 4 次
COIL 4 次
TIMER_TON 8 次
TIMER_TOF 6 次
COUNTER_CTU 8 次
COUNTER_CTD 7 次
触点与线圈各 4 次,因为它们是纯布尔运算,画一遍、算一遍就完了。定时器与计数器出现 6 到 8 次,因为它们有内部状态:计时起点、累计值、复位条件、位输出,每个环节都要单独处理。复杂度的重量不在图形,在时序。
数据模型也很克制:
function createPlcProject() {
return {
programs: [],
variables: {
inputs: {}, // %IX0.0, %IX0.1, ...
outputs: {}, // %QX0.0, %QX0.1, ...
markers: {}, // %MX0.0, %MX0.1, ... (内部位)
timers: {}, // %T0, %T1, ...
counters: {} // %C0, %C1, ...
},
nextId: 1
};
}
五类变量用 IEC 61131-3 的地址前缀区分:%IX 输入位、%QX 输出位、%MX 内部位、%T 定时器、%C 计数器。这个选择有实际意义:项目文件存成 JSON 之后,地址就是键,读回来不需要再猜类型。id 用单调递增的字符串:
function genId(project) {
return 'plc_' + (project.nextId++);
}
不用随机数、不用时间戳。随机数有碰撞概率,时间戳在同一毫秒内批量创建会重号,单调计数是这里最省事又不重复的做法。
五、每帧一次全量重绘
渲染循环在 renderer.js 的 L474 到 L492,有效代码就三行:
// ========== 渲染循环 ==========
function renderLoop() {
var ctx = el.canvas.getContext('2d');
ctx.fillStyle = '#1e1e2e';
ctx.fillRect(0, 0, el.canvas.width, el.canvas.height);
if (state.currentProgId) {
renderLadder(ctx, state.project, state.currentProgId, {
width: el.canvas.width,
height: el.canvas.height
});
// 绘制选中元素的高亮框
if (state.selectedElemId) {
drawSelectionHighlight(ctx, state.project, state.currentProgId, state.selectedElemId);
}
}
requestAnimationFrame(renderLoop);
}
没有脏矩形、没有分层画布、没有离屏缓存。每一帧:刷底色、画全部梯级、有条件地画选中框、再排下一帧。
这种写法在游戏里会被骂,在这个场景里没问题,原因在几何常量上:
function renderLadder(ctx, project, progId, options) {
var opt = options || {};
var prog = findProgram(project, progId);
if (!prog) return;
var rungHeight = 100;
var startY = 30;
梯级高度固定 100 像素,起始 Y 固定 30。一个梯级占的绘制工作量是常数,总工作量只与梯级数量线性相关。一个项目里放几十个梯级已经很多了,每帧画几十条水平线、几百个小图形,对桌面级 GPU 或软件光栅都是可以忽略的量。
梯级本身的画法是"先母线、后导线":
function drawRung(ctx, rung, y, opt) {
var startX = 40;
var endX = (opt.width || 800) - 40;
var midY = y + 50;
// 左侧母线
ctx.strokeStyle = opt.railColor || '#89b4fa';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(startX, y + 80);
ctx.stroke();
// 右侧母线
ctx.beginPath();
ctx.moveTo(endX, y);
ctx.lineTo(endX, y + 80);
ctx.stroke();
// 梯级编号
ctx.fillStyle = opt.labelColor || '#a6adc8';
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.fillText('梯级 ' + rung.number, 5, y + 15);
左母线在 x 等于 40,右母线在宽度减 40,母线高 80。左右各留 40 像素,是为了放梯级编号与右侧留白;母线只画 80 高而梯级间距是 100,中间自然留出 20 像素的空隙,视觉上梯级之间不会粘连。
导线这一段有个细节注释:
// 导线(分段绘制:不穿过触点两竖线之间与功能块内部)
if (rung.elements.length > 0) {
ctx.strokeStyle = opt.wireColor || '#cdd6f4';
ctx.lineWidth = 1.5;
ctx.beginPath();
var sorted = rung.elements.slice().sort(function(a, b) { return a.x - b.x; });
var cursor = startX;
for (var k = 0; k < sorted.length; k++) {
var sx = startX + 60 + sorted[k].x;
if (sx > cursor) {
ctx.moveTo(cursor, midY);
ctx.lineTo(sx, midY);
}
cursor = Math.max(cursor, sx + elemVisualWidth(sorted[k]));
}
if (endX > cursor) {
ctx.moveTo(cursor, midY);
ctx.lineTo(endX, midY);
}
ctx.stroke();
}
元件按 x 坐标排序后分段连线,而不是一条长线从头拉到尾。原因很实际:触点的符号是两条短竖线中间留空,功能块是一个矩形框,如果导线一笔画过去,会从符号内部穿过去,看起来像符号裂了。分段绘制让导线止于符号边缘、再从另一侧接上,这是梯形图能不能"看着像梯形图"的分水岭。
排序用的是 slice 后的副本,没有直接对 rung.elements 排序。这一点保住了数据顺序:用户摆放元件的先后顺序在序列化后要还原,绘制顺序不能反过来污染数据。
六、对话框不用 hidden,用 .open 类加 grid 同格叠加
界面里有两类弹窗:变量编辑与关于。它们的显隐不用 hidden 属性,用类名切换。
CSS 里先定负空间:
/* ========== 对话框(grid 同格叠加) ========== */
.dialog-layer {
display: none;
background: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.dialog-layer.open { display: flex; }
默认 display 为 none,加上 open 类才变成 flex。遮罩层同时是居中容器,align-items 与 justify-content 一起给,弹窗盒子在遮罩里居中,不需要再用 transform 位移。
更关键的是布局方式,以及作者写在文件头的约束:
/* 逻控 PLC 编辑器 Catppuccin Mocha 深色主题 */
/* 约束:不使用绝对/固定定位、层级叠放、形变与动画(鸿蒙禁项) */
这不是事后总结,是开工时定下的红线。这一份 CSS 里数下来:
position: fixed 0 次
position: absolute 0 次
z-index 0 次
transform 0 次
animation 0 次
transition 0 次
六个统计项全是 0。浮层的显隐与覆盖,全部交给 grid:
body {
font-family: var(--font-ui);
font-size: 13px;
background: var(--base);
color: var(--text);
height: 100vh;
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-columns: 1fr;
overflow: hidden;
user-select: none;
}
body > .toolbar { grid-area: 1 / 1; }
body > .main { grid-area: 2 / 1; }
body > .statusbar { grid-area: 3 / 1; }
body > .dialog-layer { grid-area: 1 / 1 / 4 / 2; }
工具栏占第一行、主区占第二行、状态栏占第三行;对话框的 grid-area 是 1 / 1 / 4 / 2,从第一行跨到第四行、只占第一列,正好盖住整页。后渲染的元素自然压在前面的上面,不需要 z-index。
窗口怎么缩放,行高跟着 1fr 变,遮罩跟着变,与视口尺寸无关。这是 grid 同格叠加与 fixed 的本质差别:fixed 相对视口定位,窗口边界一变就得重算;grid 把浮层与内容绑进同一个格子,单元格多大它就多大。
代价是遮罩只覆盖这一格,格外的区域它盖不到。对单窗口应用来说这正好:该盖住的是应用自己,不是屏幕。
有的项目会在遮罩上用 position: fixed,这一款一次都没有。差别不在对错,而在这一款的约束清单里明确禁了固定定位。
绑定只有三处,都在 bindAboutEvents 里:
// ========== 关于对话框(开源信息) ==========
function bindAboutEvents() {
var layer = document.getElementById('aboutDialogLayer');
document.getElementById('btnAbout').addEventListener('click', function() {
layer.classList.add('open');
});
document.getElementById('btnAboutClose').addEventListener('click', function() {
layer.classList.remove('open');
});
// 点击遮罩关闭
layer.addEventListener('click', function(e) {
if (e.target === layer) layer.classList.remove('open');
});
}
第三个监听器值得说一句:点遮罩关闭,判断条件是 e.target 等于 layer 本身。如果不判断,点弹窗内部任意位置冒泡上来也会命中,用户刚点进输入框,弹窗就关了。这一行 if 就是"点里面不关、点外面才关"的全部实现。
【配图位 3:关于对话框的截图。要求同时拍到对话框与背后被遮罩压暗的主界面,能看到标题"关于 逻控 PLC 编辑器"、三段小标题(开源声明、免责声明、完全开源)与底部确定按钮。这张图对应第八节那句致谢,也是"改名之后上游名字只留一处"的现场证据。】
七、名称 12 处:资源 4、界面 3、注释与描述 5
上架名与上游名不是一个词。上游叫 OpenPLC,上架名叫逻控 PLC 编辑器。改名落点十二处:
| 类别 | 数量 | 落点 |
|---|---|---|
| 资源 | 4 | AppScope 的 app_name;base、en_US、zh_CN 三套 EntryAbility_label |
| 界面 | 3 | index.html 的 L5 标题、L11 工具栏品牌、L127 关于框标题 |
| 注释与描述 | 5 | main.js L1、renderer.js L1、plc-engine.js L1、openplc.css L1、package.json 的 description |
四处资源的取值:
AppScope/resources/base/element/string.json app_name 逻控 PLC 编辑器
electron/.../base/element/string.json EntryAbility_label 逻控 PLC 编辑器
electron/.../en_US/element/string.json EntryAbility_label 逻控 PLC 编辑器
electron/.../zh_CN/element/string.json EntryAbility_label 逻控 PLC 编辑器
三套语言的 EntryAbility_label 写同一个值。对中文应用来说这是常见做法:名字不翻译,保持品牌一致。要翻译的只有按钮与状态栏。
三处界面的落点:
L5 <title>逻控 PLC 编辑器</title>
L11 <div class="toolbar-brand">逻控 PLC 编辑器</div>
L127 <div class="dialog-title">关于 逻控 PLC 编辑器</div>
标题栏、工具栏品牌、关于框,三处都是用户一眼能看到的位置。属性文本与窗口标题(如果有)另算,但这一款应用窗口标题取自页面 title,所以改 L5 一处就同时改了两个地方。
五处注释与描述:
main.js L1 // 逻控 PLC 编辑器 主进程
renderer.js L1 // 逻控 PLC 编辑器 渲染进程
plc-engine.js L1 // 逻控 PLC 编辑器 引擎
openplc.css L1 /* 逻控 PLC 编辑器 Catppuccin Mocha 深色主题 */
package.json "description": "逻控 PLC 编辑器 - 鸿蒙应用(梯形图 + 结构化文本)"
四个源文件的头注释全部换名。文件名没有换:openplc.css 仍然叫 openplc,这是文件系统里的名字,用户看不到,包内也在 resfile 里,不参与显示。
这里有个容易漏的点:注释里的名字不参与界面,但参与代码审查。审核方如果抽看源码包,头注释就是第一眼。四个文件改名成本是四次替换,收益是"打开任何一个文件都看不到旧名"。结论很直接:改注释不影响签名,但改完必须重新打包。
四份 string.json 的键数并不相同,这也是模板留下的痕迹:
| 文件 | 键数 | 模板里多出来的键 |
|---|---|---|
| AppScope/base | 1 | 只有 app_name |
| electron/base | 12 | BrowserAbility_label、BrowserAbility_desc、StatelessAbility_label、StatelessAbility_desc |
| electron/en_US | 8 | 无 |
| electron/zh_CN | 8 | 无 |
base 比另外两套多四个键,是壳模板自带的两个能力(BrowserAbility、StatelessAbility)的标签。这两个能力在本工程的包里确实存在(第十三节会看到三个能力、两个扩展),所以键保留是对的,只是取值还是模板串:
BrowserAbility_label BrowserAbility
StatelessAbility_label StatelessAbility
StatusBarEntryAbility_label Application status bar(en)/ 应用状态栏(zh)
OpenNewWindow_label OpenNewWindow(en)/ 打开新窗口(zh)
这些串对应的能力不是主入口,正常使用多半碰不到。但"碰不到"不是"不存在",严格做法是把能看到的都改成正式文案。这一项进了待办,没有在本轮动,因为它不阻塞上架,改动反而要重新走一遍包内核对。
八、上游名字只留 1 处:那句致谢
改名 12 处之后,全工程还剩 1 处 OpenPLC,在 index.html 的 L130,关于对话框的开源声明里:
<p>本应用是运行于鸿蒙 PC 的 Electron 壳应用(Electron 基于 MIT 协议开源),提供梯形图(LD)编程与仿真运行环境。梯形图引擎、Canvas 2D 渲染与项目序列化均使用 JavaScript 独立实现,无第三方运行时依赖;界面设计参考 OpenPLC 等开源 PLC 项目的通用交互习惯,全部代码独立编写,不包含其任何源代码,在此谨向原作者与开源社区致谢。</p>
一句话里有四个信息,缺一个都不合适:
- 壳是什么:Electron 壳应用,Electron 基于 MIT;
- 引擎哪来的:独立实现,无第三方运行时依赖;
- 参考了什么:只参考交互习惯,明确"不包含其任何源代码";
- 态度:致谢。
另一类情况正好相反:旧名在源码里必须 0 处。两种做法不矛盾,因为名字的性质不同。如果旧名本身就是"品类加技术缩写"的组合、与上架名构成近似,那它必须彻底消失;而 OpenPLC 是一个开源项目的名字,问题不在"提到它",而在"让用户以为这是它"。
所以这一处的判断标准不是"有没有旧名",而是"有没有造成来源混淆"。现在的写法把来源说清楚了:参考交互、不抄代码、独立实现、给出致谢。这是引用声明,不是品牌露出。
配套还有两处证据,可以一起看:
- 包内 index.html 里,新名 3 次、OpenPLC 1 次、Apache-2.0 1 次,与磁盘完全一致;
- 六个业务文件里,OpenPLC 只在这一句出现,其余文件 0 次。
如果一个应用把上游名字从代码里删得干干净净、却在界面上照搬了对方的布局与术语,那才是真问题。反过来,代码全自写、只在声明里致谢,这一处留着比删掉更稳。
【配图位 4:工程目录里搜索 OpenPLC 的结果截图。要求显示整个工程的命中列表,只有 index.html 一行,且能看清 L130 的内容。这张图用来证明"12 处已改、只剩致谢 1 处"不是口头说法。】
九、权限 14 项:一份通用的引擎清单,一条都没动
web_engine 的 module.json5 里,requestPermissions 有 14 条,definePermissions 有 1 条:
INTERNET
GET_NETWORK_INFO
ACCESS_CERT_MANAGER
RUNNING_LOCK
PRINT
PREPARE_APP_TERMINATE
ACCESS_BIOMETRIC
PRIVACY_WINDOW
WINDOW_TOPMOST
GYROSCOPE
ACCELEROMETER
SET_ABILITY_INSTANCE_INFO
GET_FILE_ICON
LOCK_WINDOW_CURSOR
最后一条同时在 definePermissions 里再出现一次。但 LOCK_WINDOW_CURSOR 是系统权限,不是应用自定义权限,放进 definePermissions 段落里语义上并不成立,属于模板残留;清理时连它后面的尾逗号一起删掉。
文件里还有一段被注释掉的申请:
// WEB_NATIVE_MESSAGING 是系统权限,不可在 requestPermissions 中自定义声明
// {
// "name": "ohos.permission.WEB_NATIVE_MESSAGING"
// }
这段注释解释了为什么不能申请:WEB_NATIVE_MESSAGING 属于系统权限,不能在 requestPermissions 里自定义声明。注释留着是对的,下一个接手的人不会再试一次。
真正值得注意的是这份清单的成分。把它和这个应用的功能对一遍:
| 权限 | 与"梯形图编辑 + 仿真"的关系 |
|---|---|
| INTERNET、GET_NETWORK_INFO | 应用不联网 |
| 不打印 | |
| ACCESS_BIOMETRIC、PRIVACY_WINDOW、WINDOW_TOPMOST | 不做指纹、不做隐私窗口、不置顶 |
| GYROSCOPE、ACCELEROMETER | 桌面形态没有这两个传感器 |
| ACCESS_CERT_MANAGER、RUNNING_LOCK、PREPARE_APP_TERMINATE、SET_ABILITY_INSTANCE_INFO、GET_FILE_ICON、LOCK_WINDOW_CURSOR | 引擎侧的系统能力,业务代码零入口 |
十四条里,能在业务代码里找到对应入口的为零。这不是巧合,而是模板基线:拷一份壳,权限清单原样带走;文件 63 行、申请 14 条、定义 1 条,本工程没有任何一条是"按需申请"出来的。
这件事的意义在于:包里的权限条数,不能用来判断这个应用要什么权限。 它反映的是壳的出身,不是应用的功能。上架时如果审核问"为什么 PLC 编辑器要申请 ACCESS_BIOMETRIC",唯一站得住的回答是"这是模板带来的,我们还没删",而不是现编一个理由。
十、这 14 条,在业务代码里 0 条对应调用
把六个业务文件放在一起,逐条找功能入口:
| 权限 | 业务文件里的对应调用 |
|---|---|
| INTERNET、GET_NETWORK_INFO | fetch 0 处、XMLHttpRequest 0 处、WebSocket 0 处 |
| ACCESS_CERT_MANAGER | crypto 0 处 |
| RUNNING_LOCK | 0 处 |
| print 与 Print 均 0 处 | |
| PREPARE_APP_TERMINATE | 只有 window-all-closed 时的 app.quit,与权限无关 |
| ACCESS_BIOMETRIC | biometric 与 Biometric 均 0 处 |
| PRIVACY_WINDOW | privacy 0 处(业务)、setWindowPrivacyMode 0 处(壳层) |
| WINDOW_TOPMOST | topmost 与 setWindowTopmost 均 0 处 |
| GYROSCOPE、ACCELEROMETER | gyro 0 处、sensor 0 处 |
| SET_ABILITY_INSTANCE_INFO | 0 处 |
| GET_FILE_ICON | fileIcon 与 FileIcon 均 0 处 |
| LOCK_WINDOW_CURSOR | cursor 16 处,全部是 Canvas 画笔与 CSS 鼠标样式 |
两处需要说明,否则会被误读:
第一,acceler 在 main.js 里有 1 处命中,是 disableHardwareAcceleration,关硬件加速,不是加速度传感器。做关键词核对时这种同形词必须逐条看,不能只看命中数。
第二,cursor 有 16 处命中(引擎 7 处、样式 9 处),指的是画布上的光标形状与 CSS 的 cursor: pointer,跟 LOCK_WINDOW_CURSOR 这个"锁定窗口光标"权限无关。
再看壳层。electron 模块的 ArkTS 一共 18 个 ets 文件,搜这些 API:
getFileIcon 0 处
setWindowTopmost 0 处
setCursor 0 处
setWindowPrivacyMode 0 处
print 0 处
setAbilityInstanceInfo 0 处
requestPermissions 0 处
startAbility 0 处
preferences 0 处
业务层 0 处,壳层也 0 处。
结论要说得准确:在本工程的 JS 与 ArkTS 源码里,这 14 条权限找不到一条对应调用。 但这不等于"这 14 条全都没用",因为系统框架可能在某些路径上内部使用(例如窗口图标、光标样式由框架代理),本文没有做权限拒绝态的运行时实验,所以不下"可以全删"的结论。
能做的是把顺序摆正:先做权限与功能的映射表,把"有入口"的三四条留下,其余逐条论证后删除,再重出包验证功能不缺。这个流程没走完之前,删权限比留权限风险大——删错了,功能直接不可用;留着,最多是审核多问一句。
这一条进了待办清单,位置排在签名之前。
十一、壳层 18 个 ets:最大的一份是 639 行登录组件
主进程只有 81 行,不代表壳里代码就少。把 electron 模块的 ArkTS 挨个数一遍:
| 文件 | 字节 | 行数 |
|---|---|---|
| Application/AbilityStage.ets | 329 | 11 |
| entryability/BrowserAbility.ets | 1,218 | 48 |
| entryability/EntryAbility.ets | 1,142 | 44 |
| entryability/StatelessAbility.ets | 1,303 | 49 |
| entryability/StatusBarEntryAbility.ets | 1,161 | 35 |
| entryability/TaskManagerAbility.ets | 1,206 | 44 |
| extensionAbility/BrowserEmbeddedAbility.ets | 863 | 33 |
| pages/EmbeddedWindow.ets | 653 | 25 |
| pages/Index.ets | 2,607 | 87 |
| pages/Login.ets | 881 | 35 |
| pages/NodeHandleSubWindow.ets | 1,839 | 43 |
| pages/NodeHandleWindow.ets | 4,061 | 109 |
| pages/QuickLoginButtonComponent.ets | 25,241 | 639 |
| pages/StatusBarPage.ets | 307 | 15 |
| pages/SubWindow.ets | 406 | 18 |
| pages/WebPage.ets | 2,785 | 91 |
| pages/WindowNode.ets | 641 | 25 |
| process/CustomChildProcess.ets | 412 | 15 |
| 合计 | 47,055 | 1,366 |
十八个文件、四万七千多字节里排第一的是 QuickLoginButtonComponent.ets:25,241 字节、639 行,占壳层源码字节数的 54%,行数的 47%。
它是什么?打开头两行就明白了:
import { loginComponentManager, LoginWithHuaweiIDButton } from '@kit.AccountKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { promptAction, router } from '@kit.ArkUI';
import { connection } from '@kit.NetworkKit';
import { authentication } from '@kit.AccountKit';
import { util } from '@kit.ArkTS';
import { WebPage } from './WebPage'
import { ILoginInfo } from 'web_engine';
这是华为账号快捷登录按钮组件。依赖 @kit.AccountKit,做的是拉起账号登录、拿到登录信息、回调给宿主页面。
九行 import 里,第五行是 @kit.NetworkKit 的 connection。这一行值得记下来:即便业务代码一行网络请求都没有,壳里的登录组件本身就带着网络依赖。这是 INTERNET 与 GET_NETWORK_INFO 两条权限"看起来有出处"的来源之一,也是第十节不敢下"全删"结论的实际理由。
这个应用需要登录吗?不需要。它做的是本地梯形图编辑与仿真,项目存本地 JSON,六个业务文件里没有任何账号、登录、云同步的入口。主进程的两个 IPC 也只有打开与保存文件。
再看引用关系:
QuickLoginButtonComponent.ets <- 只被 pages/Login.ets 引用
pages/Login.ets <- 没有被任何 ets 引用
Login.ets 里引用了它:
L5 import { ILoginInfo, ILogin } from 'web_engine';
L6 import { QuickLoginButtonComponent } from './QuickLoginButtonComponent'
...
L29 QuickLoginButtonComponent({ loginCallback: this.loginCallback_ })
而 Login.ets 自己,在十八个 ets 里找不到第二个引用者。Index.ets 不引它,EntryAbility 不引它,WebPage 也不引它。
这就解释了本篇开头那个反差:主进程 81 行是真的,壳里躺着 639 行没人调用的登录组件也是真的。 两件事同时成立,因为它们不在同一个目录:一个在 resfile 的 app 里(业务),一个在 electron 的 ets 里(模板)。
要强调的是,这里不说它是"死代码"。ArkTS 的编译行为是把模块内所有源文件编进字节码,是否被引用不决定是否进包。第十四节会给出直接证据:这个组件的名字在包内的 modules.abc 里出现 107 次。它在包里,只是不在界面上。
这类残留的处置不宜一刀切。删掉 Login.ets 与 QuickLoginButtonComponent.ets 能省字节,但风险在于:WebPage 或其他 ets 可能通过字符串路由、动态 import 引用它们,静态 grep 看不出来;删完必须整包回归。更稳的顺序是先确认它没有出现在任何 module.json5 的 pages 或路由配置里,再删。
本工程没有动这两个文件。理由是:它们不影响界面、不影响权限、不影响签名,属于"技术上该清、但不在本次上架阻塞项里"的一类。
十二、签名:又是 debugKey
build-profile.json5 的签名段,脱敏后是这样:
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"certpath": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_...=.cer",
"keyAlias": "debugKey",
"keyPassword": "0000001B...(共 86 字符,已截断)",
"profile": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_...=.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "C:\\Users\\<you>\\.ohos\\config\\default_ohos_hap_...=.p12",
"storePassword": "0000001B...(共 86 字符,已截断)"
}
}
]
四个判断点:
| 项 | 值 | 含义 |
|---|---|---|
| keyAlias | debugKey | 别名还是调试默认值 |
| 三件套 | .p12、.p7b、.cer 都在 .ohos\config 下 | 用的是 DevEco 自动生成的调试证书 |
| 两个密码 | 均 86 字符、均以 000000 开头 | 与调试证书配套的默认口令格式 |
| 文件时间 | 2026-10-27 12:23:33 | 这份配置最后一次被改动的时间 |
这一套是 DevEco 新建工程时自动生成的默认调试证书:别名 debugKey,口令是自动生成的长串,三件套都落在 .ohos\config 下。文件名的随机段是 DevEco 为本工程生成调试材料时留下的,不能跨工程复用。
调试证书不是不能出包,它能出、能装、能跑。问题在提审:正式上架要用发布证书与正式 Profile,调试证书签出来的包不具备上架资格。这一条在 AGC 提交环节会直接卡住,不是"建议",是硬门槛。
时间线也值得看一眼:
build-profile.json5 2026-10-27 12:23:33 ← 配置最后一次改动
signed hap 2026-10-27 12:23:58 ← 25 秒后出包
.app(提审物) 2026-10-14 15:28:29 ← 13 天前
配置在 10-27 中午改过一次,25 秒后就出了一版签名 hap。但那个 hap 不是提审物,提审物是 13 天前打的 .app。这个错位值得单独记一笔:改配置之后必须重出包,旧包不会自己变新。
十三、提审包:86.9 MB、release 构建、28 个条目
拆开 build/outputs/default 下的产物:
| 项 | 值 |
|---|---|
| ohos_hap-default-signed.app | 86,902,599 B(2026-10-14 15:28:29) |
| 包内 hap | electron-default.hap,193,805,041 B,crc=af5a427f |
| bundleName | com.wei.luokongplc |
| versionCode / versionName | 1000000 / 1.0.0 |
| label | $string:app_name |
| minAPIVersion / targetAPIVersion | 50005017 / 60000020 |
| 构建模式 | buildMode 为 release,debug 为 false |
| 权限 | 14 条 |
| 能力 | 3 个 |
| 扩展能力 | 2 个 |
| 包内条目 | 28 个 |
| ets/modules.abc | 912,608 B |
| .pages.info | 缺失 |
| ets/sourceMaps.map | 缺失 |
构建模式是 release。 这一条要单独说,因为它和权限、签名是三件不同的事:
- 构建模式决定产物里有没有调试信息(.pages.info、sourceMaps、未裁剪的字节码);
- 权限决定系统给不给能力;
- 签名决定能不能上架。
这三件事混在一起看最容易误判:包是 release 构建,但签名配置停在 debugKey,10-27 又出了一版 debug 构建的 hap。分开记就清楚了:
10-14 的 .app release 构建 调试证书签名 ← 提审物
10-27 的 signed hap debug 构建 调试证书签名 ← 不是提审物
10-27 那版 hap 的包里能直接看到证据:条目 30 个,.pages.info 21,384 字节,ets/sourceMaps.map 572,033 字节,modules.abc 1,499,320 字节,buildMode 字段写的是 debug、debug 为 true。同一份壳代码,debug 版的字节码比 release 版大 64%,多出来的就是调试信息。
包内条目 28 个,.pages.info 与 sourceMaps 都不在里面,这是 release 构建的正常形态。反过来,debug 构建一定多出这两个条目——本工程 10-27 那版 hap 就是 30 个。这个差值可以直接当构建模式的判据用,比读 buildMode 字段更直观:字段可能被改,条目不会说谎。
包内六文件与磁盘的比对:
index.html 相同
main.js 相同
renderer.js 相同
plc-engine.js 相同
styles/openplc.css 相同
package.json 相同
六个文件逐字节相同 6 个
六个全中,说明这次出包确实用的是当前仓库代码。这一步看着像废话,但这套工程里提审物与代码错位是常态:本包的 .app 是 10-14 打的,build-profile 到 10-27 才改过。所以每次核对包,第一件事都是比这六个文件。
包内 index.html 里的名字分布也对得上:
逻控 PLC 编辑器 3 次
OpenPLC 1 次
Apache-2.0 1 次
PLC 5 次
新名 3 次(标题、品牌、关于框),上游名 1 次(致谢句),许可证 1 次。与磁盘一致,无遗漏、无残留。
三个能力与两个扩展,名字与入口都能对上:
EntryAbility ./ets/entryability/EntryAbility.ets 图标 $media:app_icon
BrowserAbility ./ets/entryability/BrowserAbility.ets 无图标
StatelessAbility ./ets/entryability/StatelessAbility.ets 无图标
StatusBarEntryAbility statusBarView
BrowserEmbeddedAbility embeddedUI
主入口 EntryAbility 带图标,另外两个能力不带。状态栏与内嵌浏览器页各占一个扩展能力,都是壳的能力位,本应用没有主动调用。
十四、912,608 字节里,装的是谁
这是本篇最硬的一段证据。
从提审的 .app 里取出 hap,再取出 ets/modules.abc,912,608 字节。直接在这个二进制文件里做字节串匹配:
| 搜索串 | 命中次数 | 说明 |
|---|---|---|
| QuickLoginButtonComponent | 107 | 登录组件,进了包 |
| LoginWithHuaweiIDButton | 9 | 华为账号登录按钮,进了包 |
| ladderCanvas | 0 | 梯形图画布 id,没进包 |
| file:open | 0 | 主进程 IPC 通道名,没进包 |
| file:save | 0 | 主进程 IPC 通道名,没进包 |
| openplc | 0 | 业务文件名前缀,没进包 |
| luokongplc | 1,587 | 包名,进了包 |
| web_engine | 1,423 | 模块名,进了包 |
| EntryAbility | 13 | 入口能力,进了包 |
前两组数字放在一起,就是结论:
业务代码一行都没进字节码,登录组件进了 107 次。
原因在编译范围。ArkTS 的字节码只收 ets/ts 源文件与它们依赖的模块;resfile 下的 JS、HTML、CSS 是资源,原样塞进包,不参与编译。所以:
- 六个业务文件(55,301 字节)在 resources/resfile/resources/app 下,原样在包里;
- 十八个 ets(47,055 字节)与它们依赖的 ohpm 包(例如 inversify)编成 modules.abc;
- 两者在包里是两套东西,互不影响。
【配图位 5:二进制搜索结果的终端截图。要求在解包后的 ets/modules.abc 上执行搜索,一屏内同时看到 QuickLoginButtonComponent 命中 107 次与 ladderCanvas 命中 0 次。这张图是第十四节的硬证据,缺了它,前文那句"业务代码不进字节码"就只是推断。】
这也解释了另一个现象:这套壳的字节码尺寸会稳定在一个很窄的区间里。本工程 912,608 字节,18 个 ets 合计 47,055 字节,其余全是它们依赖的 ohpm 模块与框架代码;业务代码从 55,301 字节再涨,这个数字也不会变。
反过来,同一个工程换构建模式,尺寸会明显跳:10-27 那版 debug hap 的 modules.abc 是 1,499,320 字节,比 release 版多 64%,多出来的就是 .pages.info、sourceMaps 与未裁剪的调试信息。
10-14 .app 内 modules.abc 912,608 B release
10-27 signed hap 内 modules.abc 1,499,320 B debug
所以尺寸这条基线有两个用法:同一构建模式前后比,尺寸变了说明壳被动过;跨构建模式比,尺寸差就是调试信息的量。业务代码多寡不参与这个数字,这也是本篇开头那个反差的另一半。
十五、图标:546,647 字节,三件同哈希
AppScope/resources/base/media 下三个文件:
app_icon.png 546,647 B sha256 e66a1fde62574dfb34dc4c1e...
product_logo_32.png 546,647 B sha256 e66a1fde62574dfb34dc4c1e...
startIcon.png 546,647 B sha256 e66a1fde62574dfb34dc4c1e...
三件套字节数完全相同、哈希完全相同:同一张图复制了三份、换了三个文件名。
同时,electron 模块下没有 media 目录:
electron/src/main/resources/base/media 存在 False
这是这套壳的标准形态:图标只放 AppScope 一处,模块级不覆盖,三个位置用同一张源图。
三个文件名各自有用:
- app_icon 是应用图标,包内 EntryAbility 的 icon 字段指向 $media:app_icon;
- startIcon 是启动页图标;
- product_logo_32 是较小的产品标识位。
三个都在,且内容一致,说明改图标时是一次性替换三份。这比"三份不同图"更省心:任何一处显示不一致的投诉,都可以从哈希相同这一点上排除。
三件套同字节同哈希、模块目录为空、全链回落到 AppScope,这是壳模板的固定行为,不是某个工程的个案。
十六、待办与边界
按优先级排,六条:
- 签名换 Release,这是硬门槛。当前 build-profile.json5 是 debugKey 加默认调试证书三件套,提审前必须换成发布证书与正式 Profile,换完重出包,重新核对包内六文件与权限条数。10-27 那版 hap 不能直接当提审物。
- 权限做减法,先出映射表。14 条里在业务与壳层都找不到入口的,逐条论证后删除;有入口的留下并在提交说明里写清用途。删完必须整包回归(打开、保存、画布交互、窗口缩放)。
- 模板残留清理,Login.ets 与 QuickLoginButtonComponent.ets 两份共 674 行。确认无动态引用后再删,删完看 modules.abc 的尺寸变化,再决定是否值得。
- 四份 string.json 的模板串,BrowserAbility_label、StatelessAbility_label、StatusBarEntryAbility_label、OpenNewWindow_label 还是模板值。不阻塞上架,但属于"打开设置页可能看到英文"的隐患。
- 真机截图补齐,首图已补入,剩余四个配图位需要真机环境出图,尤其是关于对话框那张。
- 包与代码的对应关系固化成流程,每次出包后自动比对包内六文件与磁盘、比对权限条数、记录 modules.abc 尺寸与哈希。这一条做完,前面五条里的"核对"就不再依赖人眼。
边界也要说清楚,免得读者把结论用过头:
- 没有做权限拒绝态的运行时实验。 本文只证明了源码里没有调用入口,没有证明系统不会用到这些权限,所以不下"14 条可以全删"的结论。
- 没有反编译 modules.abc。 登录组件的 107 次命中是字节串匹配的结果,能证明"名字进了包",不能算出它占了多少字节。54% 这个比例是源文件字节占比,不是字节码占比。
- 没有用 hap-sign-tool 验签历史包。 提审物的签名信息来自配置文件与产物时间线,没有做证书链校验。
- .app 与 10-27 那版 hap 的关系没有定论。 10-27 的 hap 没有打进 .app,两者是两次独立构建。至于为什么要重新出一版,工程里没有留下记录,本文不猜。
- 业务代码规模只统计了本工程的 6 个业务文件。 不含 ohpm 依赖与 ets 源码,也不把它与字节码尺寸做因果关联。
最后一句话留给这套壳的判断方法。这次核对下来,最有用的一条不是权限条数,也不是签名别名,而是那个 912,608:
业务代码不进字节码,所以字节码的尺寸只反映壳。 壳的尺寸相同、哈希不同,说明两个包是同一套模板换了名字;壳的尺寸变了,说明壳被动过。把这条当基线,下次再看到包体涨了几 MB,先看 resfile 里多了什么,而不是去翻 ets。
把这几条串起来看,这套壳的核对方法其实只有一句:先分清哪些数字来自壳、哪些来自业务,再决定拿哪个数字当判据。 权限条数与字节码尺寸都来自壳,业务代码的规模则在 resfile 里原样躺着——把这三件事分开记,下次再遇到包体异常或权限质疑,排查方向就不会跑偏。
如果你也在走 Electron 壳方案上鸿蒙桌面端,欢迎加入文首的开源鸿蒙 PC 社区,一起交流更多踩坑经验。
更多推荐



所有评论(0)