鸿蒙 PC 桌面端应用上架实战:科仿科学计算与仿真从桌面图标跳应用管理页的三轮误判到发布前体检

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

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

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_ScilabXcos

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


科仿 科学计算与仿真是一款跑在 OpenHarmony PC 上的数值计算与建模仿真工具:受限表达式解释器、矩阵运算库(求逆、行列式、转置、左除解线性方程组)、六类功能块的框图编辑器,以及带反馈环路的仿真求解器。业务实现只有六个文件、两千四百九十三行——index.html 134 行、main.js 88 行、renderer.js 867 行、scilab-engine.js 847 行、styles/scilab.css 548 行、package.json 9 行。壳工程是标准的 Electron 双层结构,包名 com.wei.kefang,vendor wei,versionCode 1000000。

这一篇要写的不是它怎么实现,而是它出过一次很难看的故障:在 DevEco Studio 里点运行一切正常,停止运行、去桌面点它的图标,却不打开应用,而是跳到"应用与原服务"那个管理页面。 一个已经上架的工程,用户点开图标看到的是应用信息页,第一反应一定是"这应用坏了"或者"我装错了"。这类故障不留崩溃日志,构建不报错,静态扫描也不报错。

排这个故障我走了三轮误判。三轮的共性是:每一轮都能自圆其说,都能给出一个"合理"的修法,改完照样不好。 把这三条死路完整地写出来,是这篇的主要价值;第五节给出真正一步命中的那个动作,第六节把"缺了它就必然坏"从一句解释变成一个可以当场跑的静态证明。

后面的章节是这次排错的连带产出:一次差点写错的权限结论、一处真实存在但一直没人发现的品牌漏改、一份可以直接拿去跑的发布前体检脚本,以及把业务引擎拿到 Node 里做离线数值体检后量出来的五个事实。这些内容全部落在当前工程上,文中每一个脚本都实跑过,每一段输出都是实跑结果的原样,包括那个把我自己的错误结论纠正掉的脚本。


一、故障现场:调试路径正常,桌面路径不通

先把现象钉死,因为后面所有误判都是从"现象没钉死"开始的。

触发动作结果
DevEco Studio 里点 Run ‘electron’窗口正常弹出,控制台、编辑器、工作区全部可用
停止运行后,从桌面图标点击不弹窗口,跳到"应用与原服务"页面

第二行还藏着一个更准确的描述:不是窗口闪出来又消失,不是白屏,也不是启动到一半退出——根本没有任何窗口被创建过。系统把这次点击解释成"这个应用没有可被拉起的首页入口",于是转去展示应用信息。

这个区分是整个排错过程的第一个岔路口,我一开始没有抓住它,于是多花了两天:窗口生命周期出问题,表现一定是"先有窗口,然后没了";而这里窗口从来没有出现过。现象的形状本身就在排除一大类原因。

【配图位 2:故障对照截图两张拼一张。左边是从桌面点击科仿图标后落到的"应用与原服务"页面(能看到应用名与卸载、清除数据入口),右边是同一时刻在 DevEco Studio 里点 Run 拉起的同一个应用窗口。两张都要带系统标题栏。】


二、误判一:到 Ability 里找一个本该属于 Stage 的回调

工程的 module.json5 里,EntryAbility 声明了 launchType 为 specified。当时的判断是:specified 模式下系统要先问"这个 want 该交给哪个实例",所以 Ability 必须实现 onAcceptWant;没实现就选不出实例,于是点了没反应。

这个说法听上去很专业,而且有表面支撑:在 EntryAbility.ets 里确实搜不到 onAcceptWant。

问题是这个方法本来就不挂在 Ability 上,它是 AbilityStage 的回调。壳工程里有一份 WebAbilityStage.ets,onAcceptWant 就在里面,实现得完完整整:

  onAcceptWant(want: Want): string {
    LogUtil.info(TAG, '[ohoswindow] in WebAbilityStage onAcceptWant, want is ' + JSON.stringify(want));
    let instanceKey = want.parameters?.instanceKey;
    if (instanceKey) {
      return instanceKey.toString();
    }
    if (GlobalThisHelper.isLaunched()) {
      if (want && want.parameters) {
        if (want.parameters["appId"] !== undefined) {
          let appId = want.parameters['appId'] as string;
          let xcomponentId = GlobalThisHelper.getWebappWindow(appId);
          if (xcomponentId !== '') {
            return xcomponentId;
          }
        }
      }
      let lastActiveBrowserId = GlobalThisHelper.getLastActiveBrowserId();
      if (lastActiveBrowserId !== undefined) {
        return lastActiveBrowserId;
      }
      let widgetId = this.nativeContext?.GetLastActiveWidgetId();
      if (widgetId) {
        return ConfigData.WINDOW_PREFIX + widgetId;
      }
    }
    this.context.getApplicationContext().tempDir;
    return ConfigData.DEFAULT_WINDOW_ID;
  }

electron 模块那边的 AbilityStage.ets 只有十一行,全部职责就是把上面这份实现接过来:

// Copyright (c) 2024 Huawei Device Co., Ltd. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import { WebAbilityStage } from 'web_engine';

export default class MyAbilityStage extends WebAbilityStage {
  onCreate(): void {
    super.onCreate();
  }
}

第一条死路的根因不是知识不够,而是取证范围放错了对象:在多 Ability 的壳工程里,实例复用逻辑挂在 Stage 上,我要找的东西从一开始就在另一个文件里。

这一轮什么都没改成,因为压根没有可改的。唯一的收获是顺手把整个工程扫了一遍,看 onAcceptWant 到底在哪些文件里:

import os, sys, re
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
KEEP = ('oh_modules', 'build', '.hvigor', '.idea', 'node_modules')

print('== every file in the project that mentions onAcceptWant ==')
for root, dirs, files in os.walk(B):
    dirs[:] = [d for d in dirs if d not in KEEP]
    for fn in files:
        if not fn.endswith(('.ets', '.ts', '.json5')):
            continue
        fp = os.path.join(root, fn)
        if b'onAcceptWant' in open(fp, 'rb').read():
            print('  %s' % fp.replace(B + os.sep, '').replace(os.sep, '/'))

print()
print('== and what the entry Ability class actually contains ==')
f = os.path.join(B, 'electron', 'src', 'main', 'ets', 'entryability',
                 'EntryAbility.ets')
s = open(f, encoding='utf-8').read()
print('  class declaration : %s' % re.search(r'class .*', s).group(0))
print('  methods in this file : %s' % sorted(set(
    re.findall(r'\n\s{2}(?:async\s+)?(\w+)\(', s))))
print('  onAcceptWant present : %s' % ('onAcceptWant' in s))
== every file in the project that mentions onAcceptWant ==
  web_engine/src/main/ets/application/WebAbilityStage.ets

== and what the entry Ability class actually contains ==
  class declaration : class EntryAbility extends WebAbility {
  methods in this file : ['onBackground', 'onConfigurationUpdate', 'onCreate', 'onDestroy', 'onForeground', 'onPrepareToTerminateAsync', 'onWindowStageCreate', 'onWindowStageDestroy']
  onAcceptWant present : False

输出里有两句要紧的话。第一句:整个工程里提到 onAcceptWant 的文件只有一个,就是 web_engine 里的 WebAbilityStage.ets,也就是上面那段实现。最后一句:入口 Ability 那个文件里一共八个生命周期方法,onAcceptWant present 是 False。

如果第一轮那个推断成立——specified 的 Ability 必须自己实现 onAcceptWant——那这个 Ability 从来就没有满足过它,应用却在 IDE 里跑得好好的。一个假设只要解释不了"那为什么还能跑",就不成立;能同时解释两条启动路径的假设才有资格进入下一步。 这句话是排完错之后才总结出来的,但它记录的正是第一轮的错处。


三、误判二:壳层代码里那条看起来很危险的分支

第二轮换了方向,去读壳层的窗口生命周期代码。

WebAbility.ets 的 onCreate 里有一段:检测到 kDestroy 状态就把窗口 closeWindow() 掉。据此可以编出一个比第一轮顺得多、也像是真的故事:从桌面拉起时进程还活着、状态残留在 kDestroy、于是新建的窗口一创建就被关掉,看起来正是"点了没反应,跳回桌面或者管理页"。

这个故事有具体的代码行,所以我直接照着改了——把那次 closeWindow() 换成一次状态重置,重新打包,装到设备上。

回复是:还是有问题。

然后才去做那件本该在动手之前做的事:先弄清楚这五份文件属于谁。 electron 模块和 web_engine 模块里的这几份,是随壳一起进来的库代码,本工程从来没有改写过它们;既然没改写过,它们就不可能是"只有这里坏"的原因。这条判断不能靠记忆,只能靠哈希——改动前记一次,还原后再算一次:

import os, sys, hashlib
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
SHELL = ['electron/src/main/ets/entryability/EntryAbility.ets',
         'web_engine/src/main/ets/ability/WebAbility.ets',
         'web_engine/src/main/ets/adapter/AppLifecycleAdapter.ets',
         'web_engine/src/main/ets/application/WebAbilityStage.ets',
         'web_engine/src/main/ets/adapter/SubWindowAdapter.ets']

print('== the five shell files: sha256, bytes, lines ==')
for rel in SHELL:
    raw = open(os.path.join(B, rel.replace('/', os.sep)), 'rb').read()
    print('  %-56s %s  %6d B  %3d lines' % (
        rel, hashlib.sha256(raw).hexdigest()[:8], len(raw),
        raw.count(b'\n') + 1))
== the five shell files: sha256, bytes, lines ==
  electron/src/main/ets/entryability/EntryAbility.ets      fbd07a01    1142 B   45 lines
  web_engine/src/main/ets/ability/WebAbility.ets           048fffdf   26087 B  590 lines
  web_engine/src/main/ets/adapter/AppLifecycleAdapter.ets  e39f8723    1306 B   52 lines
  web_engine/src/main/ets/application/WebAbilityStage.ets  4d9fbe70    4851 B  124 lines
  web_engine/src/main/ets/adapter/SubWindowAdapter.ets     f0495f58   15763 B  403 lines

五个值与动手之前记下的那份基线一字不差。改了不该改的东西,还原的判据也是哈希,不是"我记得我 revert 了"。

更关键的是这一轮的推理错在哪:我把"这段代码看起来危险"当成了"这段代码是故障原因"。它确实危险——检测到 kDestroy 状态就把窗口关掉,读起来就像一个必然出事的写法;但危险不等于相关。改动之前该问的是"它会不会产生我看到的这个现象",而不是"它像不像 bug"。

我改的是随壳进来的适配层,改完重新打包、装到设备上,现象一模一样:仍然没有窗口,仍然落到应用信息页。而这个结果本来是可以提前推出来的——这条分支的动作是关闭一个已经存在的窗口,可故障现场连窗口都没有出现过。


四、误判三:把模板自带的排版当成了"修复化石"

前两轮之后,我手里已经有一台很便宜的判别机器:任何"这段代码有问题"的猜测,先去问它会不会进到交付物里。 第三轮我用上了它,但用错了方向。

翻 module.json5 时我注意到 EntryAbility 块里有一处写法很扎眼:skills 数组的收尾是一个右方括号加逗号,紧接着下一行就是右大括号——一个尾随逗号。JSON 里这是非法的,JSON5 里合法但反常。当时的推断是:这是那次把 skills 数组删掉又补回来的操作留下的"编辑化石",只要把它抹平,就说明这块被手改过,也就能定位到故障。

这个推断的问题在于,它同样没有回答"这处写法会不会被运行时看到"。而这个问题有确定答案——包内那份 module.json 是构建器重新序列化出来的严格 JSON:

import io, json, os, re, sys, zipfile
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
TRAIL = re.compile(r',\s*\}')

src = open(os.path.join(B, 'electron', 'src', 'main', 'module.json5'),
           encoding='utf-8').read()
i = src.find('"name": "EntryAbility"')
j = src.find('"name": "BrowserAbility"', i)
blk = src[i:j]
print('== a comma sitting directly before a closing brace ==')
print('  EntryAbility block, module.json5 line(s) %s' % [
    src[:i].count('\n') + blk[:m.start()].count('\n') + 1
    for m in TRAIL.finditer(blk)])
print('  whole source file                        : %d' % len(TRAIL.findall(src)))

app = zipfile.ZipFile(os.path.join(B, 'build', 'outputs', 'default',
                                   'ohos_hap-default-signed.app'))
z = zipfile.ZipFile(io.BytesIO(app.read('electron-default.hap')))
pkg = z.read('module.json').decode('utf-8')
print('  inside the packaged module.json          : %d' % len(TRAIL.findall(pkg)))
json.loads(pkg)
print('  the packaged file is strict JSON         : True')
== a comma sitting directly before a closing brace ==
  EntryAbility block, module.json5 line(s) [55]
  whole source file                        : 2
  inside the packaged module.json          : 0
  the packaged file is strict JSON         : True

源文件里两处(第 55 行与第 101 行),包内那份 module.json 里是零处。构建器在生成包内配置时做了规范化,这处写法连运行时的边都没碰到。 它既不是故障的原因,也不是"编辑化石"的证据,它就是模板自带的排版风格。

于是第三轮也是白走的,但它的白走方式与前两轮不同:前两轮是找错了因果,第三轮是把不会进包的东西当成了线索。同一轮里唯一有价值的收获是位置——第 55 行正是 skills 数组的收尾,故障就埋在我误判为"化石"的那段正常声明里,只是我读错了它的含义。


五、第四个动作才命中:把配置按字段展开,问每一项由谁读

走到这里,正确的方向已经被三轮错误框出来了:故障既不在壳层代码里,也不在排版上,那它只能落在配置里那些决定"谁能被谁拉起"的字段上。所以第四步不读代码,只读配置——把 EntryAbility 这一段按字段展开,逐个问一句:这一项在两条启动路径里分别被谁读?

import os, sys, re
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
NAME = re.compile(r'^\s{8}"name":\s*"([A-Za-z]+Ability)"\s*,\s*$', re.M)
ENT = re.compile(r'"entities":\s*\[(.*?)\]', re.S)
ACT = re.compile(r'"actions":\s*\[(.*?)\]', re.S)
STR = re.compile(r'"([^"]+)"')

src = open(os.path.join(B, 'electron', 'src', 'main', 'module.json5'),
           encoding='utf-8').read()
marks = [(m.start(), m.group(1)) for m in NAME.finditer(src)] + [(len(src), None)]
print('== EntryAbility, field by field ==')
print('  %-8s %-10s %-6s %-10s %-9s %-9s %s' % (
    'skills', 'launchType', 'home', 'browsable', 'act.home', 'viewData',
    'scheme-blocks'))
for i in range(len(marks) - 1):
    if marks[i][1] != 'EntryAbility':
        continue
    blk = src[marks[i][0]:marks[i + 1][0]]
    e = STR.findall(ENT.search(blk).group(1)) if ENT.search(blk) else []
    a = STR.findall(ACT.search(blk).group(1)) if ACT.search(blk) else []
    lt = re.search(r'"launchType":\s*"(\w+)"', blk)
    print('  %-8s %-10s %-6s %-10s %-9s %-9s %d' % (
        '"skills"' in blk, lt.group(1) if lt else '-',
        'entity.system.home' in e, 'entity.system.browsable' in e,
        'action.system.home' in a, 'ohos.want.action.viewData' in a,
        len(re.findall(r'\{\s*"scheme"', blk))))
== EntryAbility, field by field ==
  skills   launchType home   browsable  act.home  viewData  scheme-blocks
  True     specified  True   True       True      True      1

这一段声明里,launchType 管的是实例复用(一个应用同时开几个窗口),removeMissionAfterTerminate 管任务清理,exported 管别的应用能不能显式拉起,icon 与 label 管显示。只有 skills 参与"点桌面图标"那一次隐式匹配——它是唯一一个回答"系统拿一个 action 为 home 的 want 来找,谁应答"的字段。故障恰好发生在隐式匹配这一步,嫌疑就从一个 Ability 块收窄到一个字段,而这一段里这个字段的取值只有一个可疑形态:“skills”: [],空数组。

这里要如实交代一件事:那个坏状态已经无法从工作区取回。这个工程目录没有版本库历史,构建产物也是在修复之后重新出过的——第六节那份扫描读的就是签名包里的 module.json,skill-blocks=3、home-carriers 里有 EntryAbility,说明当前这个包携带的已经是修好的声明。所以全文对那个坏状态只能给出描述。但它的等价构造可以当场复现——第六节就是在真包里把这一行改成空数组再数一次。

修好之后的现状长这样,取自 electron/src/main/module.json5:

        "launchType": "specified",
        "removeMissionAfterTerminate": true,
        "exported": true,
        "skills": [
          {
            "entities": [
              "entity.system.home",
              "entity.system.browsable"
            ],
            "actions": [
              "action.system.home",
              "ohos.want.action.viewData"
            ],
            "uris": []
          },
          {
            "actions": [
              "ohos.want.action.viewData"
            ],
            "uris": [
              {
                "scheme": "link"
              }
            ]
          }
        ],
      },

因果链到这里就闭合了。entity.system.home 加 action.system.home 是桌面启动器做隐式匹配时用的声明。缺了它,桌面没有任何一条路由能把"点击图标"这个 want 匹配到某个 Ability,于是系统退回到"这个应用没有可拉起的首页入口"的处理,把你丢到应用信息页。

那 DevEco 里点运行为什么正常?因为 IDE 走的是 aa start 直接指定 bundleName 与 Ability 名,这是显式启动,不查 skills。同一个应用的两条启动路径,一条经过这份声明,一条完全不经过它——这就是"IDE 里好得很、桌面上打不开"的全部原因,也是它最容易被误判成玄学的原因。

【配图位 3:EntryAbility 配置片段截图。DevEco Studio 里打开 electron/src/main/module.json5,高亮 skills 数组那二十三行,能同时看到上下文的 launchType 与 exported。】


六、把"必然坏"从一句解释变成一次静态证明

上一节给出的因果是"缺 skills 就路由不到"。严格按证据说话,这仍然只是对现象的一种解释。有没有可能桌面另有兜底:模块的 mainElement 字段本身就够拉起?exported 为 true 是不是就能被隐式匹配到?

可以证伪,而且不用真机。做法是拿签名包里的 module.json(不是源文件),把包内 abilities 与 extensionAbilities 的所有 skill 块全部展开,数 entity.system.home 有几个承载者;然后在内存里把 EntryAbility 的 skills 改成空数组,再数一次。

import io, json, os, sys, zipfile
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
HOME = 'entity.system.home'


def inner_hap():
    app = zipfile.ZipFile(os.path.join(B, 'build', 'outputs', 'default',
                                       'ohos_hap-default-signed.app'))
    return zipfile.ZipFile(io.BytesIO(app.read('electron-default.hap')))


def carriers(mj):
    m = mj['module']
    got, nblk = [], 0
    for kind in ('abilities', 'extensionAbilities'):
        for ab in m.get(kind, []):
            for s in (ab.get('skills') or []):
                nblk += 1
                if HOME in (s.get('entities') or []):
                    got.append(ab['name'])
    return nblk, got


z = inner_hap()
mj = json.loads(z.read('module.json').decode('utf-8'))
n, c = carriers(mj)
print('== who carries %s inside the signed package ==' % HOME)
print('  bundle=%-16s skill-blocks=%d  home-carriers=%s' % (
    mj['app']['bundleName'], n, c))

print()
print('== reproduce the broken declaration in memory: EntryAbility.skills = [] ==')
for tag in ['as-built', 'skills=[]']:
    mj = json.loads(z.read('module.json').decode('utf-8'))
    if tag == 'skills=[]':
        for ab in mj['module']['abilities']:
            if ab['name'] == 'EntryAbility':
                ab['skills'] = []
    n, c = carriers(mj)
    print('  %-9s skill-blocks=%d  mainElement=%s  abilities=%d  '
          'home-carriers=%s' % (tag, n, mj['module']['mainElement'],
                                len(mj['module']['abilities']), c))
== who carries entity.system.home inside the signed package ==
  bundle=com.wei.kefang   skill-blocks=3  home-carriers=['EntryAbility']

== reproduce the broken declaration in memory: EntryAbility.skills = [] ==
  as-built  skill-blocks=3  mainElement=EntryAbility  abilities=3  home-carriers=['EntryAbility']
  skills=[] skill-blocks=1  mainElement=EntryAbility  abilities=3  home-carriers=[]

两个结论直接从输出里读:

  • 这个包里 skill-blocks 是 3,home-carriers 只有 EntryAbility 一个。 包里没有第二个候选。
  • 置空之后 skill-blocks 从 3 掉到 1(剩下 BrowserAbility 那条 viewData),home-carriers 变成空列表。

也就是说,包内 home entity 唯一的来源就是 EntryAbility 的第一条 skill,别处没有任何兜底路径。mainElement 只是在模块内部指明主 Ability 的名字,exported 管的是能不能被其他应用显式拉起,两者都不参与桌面的隐式匹配。缺了那条声明,这个包在桌面启动器眼里就没有任何可路由入口——不是"可能"坏,是结构上必然坏。


七、这个坑是怎么被制造出来的:删除类改动不能用"补个空数组"收场

更要紧的问题是:那一行空数组是哪来的?没人会给一个正常工作的工程手动填个空 skills。

答案在更早一次 AGC 配置整改里。当时要清理模板残留的桌面快捷方式元数据,动作有两个:删掉 electron/src/main/module.json5 里 ohos.ability.shortcuts 那个 metadata 块,删掉配套的 shortcuts_config.json。批量删嵌套 JSON 块的时候,把 EntryAbility 的闭合括号和 skills 数组一起吞了,JSON 直接解析失败。当时的"修复"是补回一个空数组加闭合大括号——语法立刻通了,构建立刻过了,桌面启动能力没了。

现在工程里 shortcuts 确实清干净了,这一点可以当场复核:

import os, sys
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
KEEP = ('oh_modules', 'build', '.hvigor', '.idea', 'node_modules')

print('== every file in the project still mentioning "shortcut" ==')
for root, dirs, files in os.walk(B):
    dirs[:] = [d for d in dirs if d not in KEEP]
    for fn in sorted(files):
        if not fn.endswith(('.json5', '.json', '.ets', '.ts', '.js', '.html')):
            continue
        fp = os.path.join(root, fn)
        c = open(fp, encoding='utf-8', errors='ignore').read().lower()
        if 'shortcut' in c:
            print('  %-62s %d occurrences' % (
                fp.replace(B + os.sep, '').replace(os.sep, '/'), c.count('shortcut')))

print()
print('== the two configuration-side traces of the old shortcuts block ==')
prof = os.path.join(B, 'electron', 'src', 'main', 'resources', 'base', 'profile')
print('  profile dir contents      : %s' % sorted(os.listdir(prof)))
print('  shortcuts_config.json     : %s' % os.path.isfile(os.path.join(prof, 'shortcuts_config.json')))
src = open(os.path.join(B, 'electron', 'src', 'main', 'module.json5'),
           encoding='utf-8').read()
print('  shortcuts mentioned in electron/src/main/module.json5 : %s' %
      ('shortcut' in open(os.path.join(B, 'electron', 'src', 'main', 'module.json5'),
                          encoding='utf-8').read().lower()))
print('  shortcuts mentioned in AppScope/resources/base/profile/configuration.json : %s' %
      ('shortcut' in open(os.path.join(B, 'AppScope', 'resources', 'base', 'profile',
                                       'configuration.json'), encoding='utf-8').read().lower()))
== every file in the project still mentioning "shortcut" ==
  web_engine/src/main/ets/adapter/WebAppAdapter.ets              29 occurrences
  web_engine/src/main/ets/interface/CommonInterface.ts           2 occurrences
  web_engine/src/main/ets/jsbindings/WebAppAdapterBind.ets       8 occurrences

== the two configuration-side traces of the old shortcuts block ==
  profile dir contents      : ['main_pages.json']
  shortcuts_config.json     : False
  shortcuts mentioned in electron/src/main/module.json5 : False
  shortcuts mentioned in AppScope/resources/base/profile/configuration.json : False

profile 目录只剩 main_pages.json,shortcuts_config.json 已经不存在,两份配置文件里都再没有 shortcut 字样。剩下的命中全在 web_engine 适配层的三个文件里(WebAppAdapter.ets 二十九处、WebAppAdapterBind.ets 八处、CommonInterface.ts 两处),那是壳提供的快捷方式 API 实现,不是本应用的快捷方式声明,删不得也不需要删。

于是这条教训就具体了:

结构性删除之后的校验,判据不能是"括号配平了"和"能解析"。 这两条只能证明文件重新变成了合法 JSON5,完全不能证明它恢复了原来的语义。空数组是合法值,构建器看不出问题,签名器看不出问题,AGC 静态扫描也看不出问题——它会一路顺畅地打进包里,直到有用户在桌面点一下图标。

当时该用的判据有两个,都很便宜。一是删之前先看清被删的块里、以及块的兄弟位置还有什么内容,删完做一次前后逐字段对照;二是留一份基线再动——把这一段每个字段的值抄下来,或者直接用上一次构建产物的 module.json,它记录的就是改动前的字段值——删完比值,不比结构。前者防漏,后者兜底。第五节那个一步命中的动作用的正是第二个判据——只不过我把它用在了排错上,而它本该用在改动复核上。


八、这一块声明里,哪几项是桌面启动必需的

修好之后再回头看这一段,会发现它其实包含两组声明,回答的是两个不同的问题:

        "skills": [
          {
            "entities": [
              "entity.system.home",
              "entity.system.browsable"
            ],
            "actions": [
              "action.system.home",
              "ohos.want.action.viewData"
            ],
            "uris": []
          },
          {
            "actions": [
              "ohos.want.action.viewData"
            ],
            "uris": [
              {
                "scheme": "link"
              }
            ]
          }
        ],

第一组里的 entity.system.home 与 action.system.home 是一对:前者说明"我能响应桌面这个入口",后者说明"我能响应 home 这个动作",两边同时命中,桌面图标才有一条路由。这一对是第六节已经量死的部分——把它置空,承载者归零。

同一组里的 entity.system.browsable 与 ohos.want.action.viewData 回答的是另一个问题:当别的应用发来一个"查看某类数据"的隐式 want 时,要不要把这个 Ability 放进候选。它跟桌面图标启动没有关系,但 uris 是空数组,意味着没有限定数据类型,所以它是一个很宽的候选声明。静态分析能证明"缺了 home 那一对必然坏",证明不了"多了 browsable 会不会有副作用"——后者只能上真机,把这几个字段逐个删掉、每删一次点一次桌面图标、再看一次"打开方式"里的候选列表。

第二组只匹配 scheme 为 link 的 want。一个纯本地的计算工具确实不该收到这种 want,但删掉它省下的只是一个永远不会命中的候选位,代价是一轮出包与安装验证。在没有真实驳回压力的情况下,我选择留着,并把判断写在这里。

这一节的方法论比结论更值钱:配置类改动的静态判据只能证伪,不能证安全。"删了它一定坏"可以用穷举承载者来证;"留着它一定没事"没有静态证法,只有实验。


九、图标三把尺:哈希、尺寸、像素

图标这件事有三把尺:哈希、尺寸、像素。哈希只回答"字节是否相同",尺寸回答"是不是被缩放到了别的规格",像素回答"内容是不是同一张"。三把尺单独用都会误判,合起来才有判别力。

import io, os, sys, hashlib, zipfile
sys.stdout.reconfigure(encoding='utf-8')
from PIL import Image
import numpy as np

B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
SLOT = 'resources/base/media/app_icon.png'
sp = os.path.join(B, 'AppScope', 'resources', 'base', 'media', 'app_icon.png')
sraw = open(sp, 'rb').read()
si = Image.open(io.BytesIO(sraw))
app = zipfile.ZipFile(os.path.join(B, 'build', 'outputs', 'default',
                                   'ohos_hap-default-signed.app'))
z = zipfile.ZipFile(io.BytesIO(app.read('electron-default.hap')))
praw = z.read(SLOT)
pi = Image.open(io.BytesIO(praw))
pk = np.asarray(pi.convert('RGB'), dtype=np.int32)

print('%-12s %-9s %-17s %-9s %-17s %s' % (
    'project', 'src-hash', 'src-size/px', 'pkg-hash', 'pkg-size/px', 'mad'))
print('%-12s %-9s %-17s %-9s %-17s %.2f' % (
    'ScilabXcos', hashlib.sha256(sraw).hexdigest()[:8],
    '%d/%dx%d' % (len(sraw), si.size[0], si.size[1]),
    hashlib.sha256(praw).hexdigest()[:8],
    '%d/%dx%d' % (len(praw), pi.size[0], pi.size[1]),
    np.abs(np.asarray(si.convert('RGB').resize(pi.size, Image.LANCZOS),
                      dtype=np.int32) - pk).mean()))

print()
print('== the same source, four resamplers, same target size ==')
for name, res in [('LANCZOS', Image.LANCZOS), ('BILINEAR', Image.BILINEAR),
                  ('NEAREST', Image.NEAREST), ('BOX', Image.BOX)]:
    r = np.asarray(si.convert('RGB').resize(pi.size, res), dtype=np.int32)
    print('  %-8s %.2f' % (name, np.abs(r - pk).mean()))
project      src-hash  src-size/px       pkg-hash  pkg-size/px       mad
ScilabXcos   506eefda  163040/1024x1024  68b07641  72792/512x512     0.74

== the same source, four resamplers, same target size ==
  LANCZOS  0.74
  BILINEAR 0.54
  NEAREST  1.06
  BOX      0.64

源图 1024 见方,包内 512 见方,正好 2:1;两者哈希不同,字节数差一半以上。如果只看哈希,结论会是"包里的图标和源图不一样"——然后你就会去查缓存、查覆盖、查打包脚本,全是死路。

尺寸把关系定下来了:这是一次缩放,不是换图。像素把内容定下来了:0.74。

但 0.74 这个数本身还不是判据,除非你知道"同一张图"在这把尺上是什么量级。所以同一段脚本里又拿四种重采样各算了一遍:同一张源图缩到同一个尺寸,仅仅换一种算法,mad 就落在 0.54 到 1.06 之间。包内图与 LANCZOS 结果的差距(0.74)正落在这个区间里。 换句话说,它在像素层面就是这张源图的缩放版。

这也说明阈值不能拍脑袋定:0.74 看着小,可要是把线划在 0.5,同一张图换个算法就会被判成"被换过"。判据要落在"它属于哪种操作的量级"上,不是落在一个孤立的数字上。 而"两张不同的 512×512 图标"的逐像素平均差是几十到上百,不是零头——所以 0.74 只有"同一张图重编码"这一种解释。

顺带说清一件容易误判的事:"包里的图标和源不一样"是构建管线的常态,不是故障信号。 拿哈希不等当驳回原因去排查,每次都会走进死路。


十、品牌清理要看语法角色,不是全文计数

科仿是从上游实现改写而来的,改名是一档必须做细的活。工程笔记里那条"用户可见文字零品牌残留"我之前是靠全文计数确认的,这次实测把它推翻了一半——一个总数里混着改不得的 token、代码标识符、注释和真正该改的文案,它既证明不了"零残留",也回答不了"有几处要改"。

正确的做法是按品牌字样所处的语法角色分桶:它在注释里、在一个选择器字符串里、在一个文件名引用里,还是在一句会显示给人看的话里。只有最后一类需要动。分桶必须保位扫描,因为行号要落在原始文件上,不能落在"去掉注释之后的字符串"上——那样数出来的行号全是错的。

import os, sys, re
sys.stdout.reconfigure(encoding='utf-8')
RB = r'D:\hwAppLaunch\ScilabXcos\ohos_hap\web_engine\src\main\resources\resfile\resources\app'
BR = re.compile(r'scilab|xcos', re.I)
FILES = ['index.html', 'main.js', 'renderer.js', 'scilab-engine.js',
         os.path.join('styles', 'scilab.css'), 'package.json']
SEL = re.compile(r'(querySelector|getElementById|classList|setAttribute|id=|class=)')
REF = re.compile(r'(href|src|require\(|path\.join|\./)')


def literal_spans(s):
    """Comments and string literals as (kind, text, offset), offsets kept exact."""
    out, i, n = [], 0, len(s)
    while i < n:
        c = s[i]
        if c == '/' and i + 1 < n and s[i + 1] in '/*':
            j = s.find('\n' if s[i + 1] == '/' else '*/', i + 2)
            j = n if j < 0 else (j if s[i + 1] == '/' else j + 2)
            out.append(('comment', s[i:j], i))
            i = j
        elif c in '"\'`':
            q, j = c, i + 1
            while j < n:
                if s[j] == '\\':
                    j += 2
                    continue
                if s[j] == q or (q != '`' and s[j] == '\n'):
                    break
                j += 1
            out.append(('string', s[i:j + 1], i))
            i = j + 1
        else:
            i += 1
    return out


print('== brand residue: a raw text count is not the same thing as a user-visible count ==')
print('  %-20s %-11s %-9s %-9s %s' % ('file', 'raw-cases', 'comment', 'token', 'visible'))
report = {}
for fn in FILES:
    s = open(os.path.join(RB, fn), encoding='utf-8').read()
    raw = len(BR.findall(s))
    com, tok, vis = 0, 0, []
    for kind, t, o in literal_spans(s):
        n = len(BR.findall(t))
        if not n:
            continue
        if kind == 'comment':
            com += n
            continue
        v = t[1:-1]
        if not BR.search(v):
            continue
        ctx = s[max(0, o - 44):o]
        if REF.search(ctx) and re.search(r'\.(js|css|html|json)$', v):
            tok += n                                # a file name
        elif SEL.search(ctx):
            tok += n                                # an id or a class name
        elif not re.search(r'[\u4e00-\u9fff ]', v):
            tok += n                                # a bare lowercase token: a key, not copy
        else:
            vis.append((s[:o].count('\n') + 1, v))
    if fn.endswith('.html'):                        # HTML text nodes are copy too
        body = re.sub(r'<!--.*?-->', lambda m: ' ' * len(m.group(0)), s, flags=re.S)
        for m in re.finditer(r'>([^<>{}]+)<', body):
            if BR.search(m.group(1)):
                vis.append((s[:m.start()].count('\n') + 1, m.group(1).strip()))
    uniq = sorted(set(vis))
    report[fn] = uniq
    print('  %-20s %-11d %-9d %-9d %d' % (fn.replace(os.sep, '/'), raw, com, tok, len(uniq)))

print()
print('== the user-visible list, which is the only one worth editing ==')
for fn, v in report.items():
    for l, t in v:
        print('  %-20s L%-4d %s' % (fn.replace(os.sep, '/'), l, t[:70]))
== brand residue: a raw text count is not the same thing as a user-visible count ==
  file                 raw-cases   comment   token     visible
  index.html           22          0         13        1
  main.js              2           1         1         0
  renderer.js          105         5         14        1
  scilab-engine.js     18          3         0         0
  styles/scilab.css    12          4         0         0
  package.json         0           0         0         0

== the user-visible list, which is the only one worth editing ==
  index.html           L117  本应用是运行于鸿蒙 PC 的 Electron 壳应用,提供数值计算与动态系统建模的演示环境。表达式解释器、矩阵运算库与 Xcos 框图仿真
  renderer.js          L741  已加载 Xcos 模型

renderer.js 全文有 105 处品牌字样:5 处在注释、14 处是 token(id、类名、扩展名、标签页键名这类机器标识),剩下 85 处是代码标识符(xcosModel、createXcosModel、renderXcosDiagram 这类变量名与函数名),真正会显示给人看的只剩 1 处;index.html 22 处里 13 处是属性名与文件名,2 处在 HTML 注释里,7 处全部落在第 117 行那一段关于声明里,可见的也是 1 处;main.js 2 处,1 处在注释里,1 处是文件扩展名列表。最后一张表把可见的两处直接列出来:index.html 第 117 行关于对话框里那句"表达式解释器、矩阵运算库与 Xcos 框图仿真",以及 renderer.js 第 741 行的状态栏文字"已加载 Xcos 模型"。

这两处都不该改:前者是刻意的技术来源说明,属于免责性质的表述;后者描述的是文件格式本身(扩展名就是 .xcos),改成"已加载模型文件"反而丢信息。所以"零残留"这句在可见文案这一层是成立的,只是成立的方式和当初记下来的方式不一样——当初靠全文计数,而 159 这个数字里绝大多数是改不了也不该改的。反过来也成立:六个文件里落在注释里的共有 13 处(renderer.js 5、styles/scilab.css 4、scilab-engine.js 3、main.js 1),它们才是真正可以清的残留;不影响审核,但会影响下一个接手的人对代码来源的判断,值不值得清取决于你有没有维护者视角。


十一、一处真实漏改:保存对话框里的文件类型名

分桶之后可见文案只剩两处、并且两处都判定为"该留"。但就是这张表把我引向了一处真正的漏改,它恰好卡在两个判定之间。

主进程这边,打开文件对话框的类型名是硬编码的:

// 打开文件(脚本 .sce 或模型 .xcos/.json)
ipcMain.handle('file:open', function() {
  return dialog.showOpenDialog(mainWindow, {
    title: '打开文件',
    filters: [
      { name: '脚本文件', extensions: ['sce', 'sci'] },
      { name: '模型文件', extensions: ['xcos', 'json'] },
      { name: '所有文件', extensions: ['*'] }
    ],
    properties: ['openFile']

而保存对话框不同类型——它的 filters 是渲染进程当参数传进来的:

// 保存文件
ipcMain.handle('file:save', function(event, content, defaultName, filters) {
  return dialog.showSaveDialog(mainWindow, {
    title: '保存文件',
    defaultPath: defaultName,
    filters: filters || [{ name: '所有文件', extensions: ['*'] }]
  }).then(function(result) {
  document.getElementById('btn-save').addEventListener('click', function() {
    var content = serializeModel(xcosModel);
    ipcRenderer.invoke('file:save', content, 'model.xcos', [
      { name: '模型文件', extensions: ['xcos', 'json'] }
    ]).then(function(result) {

这就是漏改的结构性原因:同一段面向用户的文字在主进程与渲染进程各存了一份,改名时只扫了主进程那份(main.js 里"脚本文件"“模型文件”"所有文件"三组),渲染进程传进来的那组一直还写着上游原名。这类"参数透传 + 双份文案"的写法,注定单侧全文计数查不出来。

这一处我已经改掉了,改动只有一行,就是上面 renderer.js 第 755 行那个 name 值。改完立刻用产物反查验证了一次"源已改、包未改":

import os, sys, io, hashlib, zipfile
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
RB = os.path.join(B, 'web_engine', 'src', 'main', 'resources', 'resfile',
                  'resources', 'app')
app = zipfile.ZipFile(os.path.join(B, 'build', 'outputs', 'default',
                                   'ohos_hap-default-signed.app'))
pkg = zipfile.ZipFile(io.BytesIO(app.read('electron-default.hap')))
PRE = 'resources/resfile/resources/app/'
sha = lambda b: hashlib.sha256(b).hexdigest()[:8]

print('== every business file: working tree vs inside the signed hap ==')
for root, dirs, files in os.walk(RB):
    dirs[:] = [d for d in dirs if d != 'node_modules']
    for fn in sorted(files):
        fp = os.path.join(root, fn)
        rel = fp.replace(RB + os.sep, '').replace(os.sep, '/')
        src = open(fp, 'rb').read()
        key = PRE + rel
        if key not in pkg.namelist():
            print('  %-22s src=%s/%d  pkg=ABSENT' % (rel, sha(src), len(src)))
            continue
        got = pkg.read(key)
        print('  %-22s src=%s/%d  pkg=%s/%d  %s' % (
            rel, sha(src), len(src), sha(got), len(got),
            'SAME' if src == got else 'DIFFERENT'))

print()
print('== the lines that really differ, side by side ==')
src = open(os.path.join(RB, 'renderer.js'), encoding='utf-8', newline='').read().splitlines()
old = pkg.read(PRE + 'renderer.js').decode('utf-8').splitlines()
print('  tree lines=%d  package lines=%d  crlf in tree=%d  crlf in package=%d' % (
    len(src), len(old),
    open(os.path.join(RB, 'renderer.js'), 'rb').read().count(b'\r\n'),
    pkg.read(PRE + 'renderer.js').count(b'\r\n')))
for i, (a, b) in enumerate(zip(old, src), 1):
    if a != b:
        print('  line %d' % i)
        print('    in package : %s' % a.strip())
        print('    in tree    : %s' % b.strip())
== every business file: working tree vs inside the signed hap ==
  index.html             src=8ebdbda6/6407  pkg=8ebdbda6/6407  SAME
  main.js                src=d1aa96f2/3107  pkg=d1aa96f2/3107  SAME
  package.json           src=f6e088d6/184  pkg=f6e088d6/184  SAME
  renderer.js            src=6d4ea98f/30114  pkg=a428944f/30113  DIFFERENT
  scilab-engine.js       src=076399b6/26438  pkg=076399b6/26438  SAME
  styles/scilab.css      src=7d84ec34/9644  pkg=7d84ec34/9644  SAME

== the lines that really differ, side by side ==
  tree lines=866  package lines=866  crlf in tree=866  crlf in package=866
  line 755
    in package : { name: 'Xcos 模型', extensions: ['xcos', 'json'] }
    in tree    : { name: '模型文件', extensions: ['xcos', 'json'] }

输出里三行值得停一下。第一,六个业务文件中五个 SAME,只有 renderer.js DIFFERENT,字节数 30114 对 30113——差一个字节,正好是这次改动的大小。第二,tree lines 与 package lines 都是 866,两边的 crlf 计数也都是 866,说明行尾风格完全一致,那份包就是这一份源改之前的状态,不存在换行符漂移。这里数的是有内容的行,所以它比导语里那个 867 少一——文件末尾有一个换行,编辑器会在它后面多显示一行空行。同一个文件两种口径并不罕见,但把它说清比统一更重要,否则两张表放在一起就像有人在改数。第三,真正不同的只有第 755 行,两行并排打出来一目了然。

顺便交代一个我在这段脚本上踩的坑:这版输出之前,我第一版的比对把 866 行全部报成不同。 原因是工作区那份用文本模式读(Python 自动把 CRLF 折成 LF),包内那份是字节读进来再 decode(CRLF 保留),于是每一行都差一个回车。判据本身写错的时候,它会告诉你一个彻头彻尾的假结果——比对文本必须先统一换行口径,而且最好把 crlf 计数打进输出里当自检。

这份包现在落后于工作区,下次发布必须重新出包,这条已经进第十七节的待办清单。


十二、权限数的三个口径:14、15、14

体检脚本第一版跑出来时,权限这一列先给了我一个"差额":requestPermissions 块里 14 项,包内也是 14 项,可全文搜 ohos.permission 却能数出 15 条。三个数字摆在一起,很容易编出"构建链吞了一条"的结论。

编之前先数清楚。同一份文件里,权限字符串其实出现在两个块里:

import io, json, os, re, sys, zipfile
sys.stdout.reconfigure(encoding='utf-8')
B = r'D:\hwAppLaunch\ScilabXcos\ohos_hap'
PERM = re.compile(r'"(ohos\.permission\.[A-Za-z_]+)"')


def block(s, key):
    """Slice from "key": [ to the matching right bracket."""
    i = s.find('"%s"' % key)
    j, d = s.find('[', i), 0
    for k in range(j, len(s)):
        if s[k] == '[':
            d += 1
        elif s[k] == ']':
            d -= 1
            if d == 0:
                return s[j:k + 1]
    return ''


src = open(os.path.join(B, 'web_engine', 'src', 'main', 'module.json5'),
           encoding='utf-8').read()
req = PERM.findall(block(src, 'requestPermissions'))
defn = PERM.findall(block(src, 'definePermissions'))
print('== three ways to count permissions in one file ==')
print('  inside requestPermissions only   : %d' % len(req))
print('  every ohos.permission string     : %d' % len(PERM.findall(src)))
print('  unique names                     : %d' % len(set(PERM.findall(src))))
print('  name shared by both blocks       : %s' % sorted(set(req) & set(defn)))

app = zipfile.ZipFile(os.path.join(B, 'build', 'outputs', 'default',
                                   'ohos_hap-default-signed.app'))
z = zipfile.ZipFile(io.BytesIO(app.read('electron-default.hap')))
mj = json.loads(z.read('module.json').decode('utf-8'))
pk = [x['name'] for x in mj['module'].get('requestPermissions', [])]
print()
print('  inside the package               : %d' % len(pk))
print('  same list, same order as source  : %s' % (pk == req))
print('  definePermissions in the package : %s' % ('definePermissions' in mj['module']))
== three ways to count permissions in one file ==
  inside requestPermissions only   : 14
  every ohos.permission string     : 15
  unique names                     : 14
  name shared by both blocks       : ['ohos.permission.LOCK_WINDOW_CURSOR']

  inside the package               : 14
  same list, same order as source  : True
  definePermissions in the package : False

三个数都"对",只是口径不同:14 是 requestPermissions 块内的条数;15 是全文出现次数,多出来的那条 LOCK_WINDOW_CURSOR 同时出现在 definePermissions 里;14 是去重后的名字数,也是包内的条数。

所以没有"静默剔除"这回事。LOCK_WINDOW_CURSOR 在两个块里各出现一次,是跨块同名,不是块内重复,也不是配置错误——一份是"申请",一份是"定义"。而 definePermissions 这个键根本不进包,包内 module.json 里没有它,所以包内只有 requestPermissions 那 14 项,且与源逐项同序。

这一节留在这里主要是给自己提个醒:"构建链做了我没看见的事"这类结论,举证责任在说它的人。 数之前先声明口径——块内、全文、包内是三个不同的数,混着用就能造出一个不存在的问题;而这句话一旦被写出去,下一个照着排查的人会浪费掉比我更多的时间。

体检脚本里 perm 与 src=pkg 两列之所以写成 14->14 与 EQ,也是同一个道理:它比的是块内声明与包内声明,不是全文计数。口径一致,两列才有意义。


十三、把引擎拿到 Node 里做离线体检:解释器的边界在哪

前面十二节都在壳层。这一节起进入业务代码,但先说清凭什么能在不开设备、不开 Electron 的情况下谈它的行为。

scilab-engine.js 末尾有一段不起眼的导出:

// ========== 导出 ==========
if (typeof module !== 'undefined') {
  module.exports = {
    createScilabEnv: createScilabEnv,
    evaluateScilab: evaluateScilab,
    assignVariable: assignVariable,
    createMatrix: createMatrix,
    eyeMatrix: eyeMatrix,
    matrixAdd: matrixAdd,
    matrixMultiply: matrixMultiply,
    matrixTranspose: matrixTranspose,
    matrixDeterminant: matrixDeterminant,
    matrixInv: matrixInv,
    matrixSolve: matrixSolve,
    solveLinearValues: solveLinearValues,
    formatMatrix: formatMatrix,
    BLOCK_TYPES: BLOCK_TYPES,
    createXcosModel: createXcosModel,
    addBlock: addBlock,
    addConnection: addConnection,
    simulateXcos: simulateXcos,
    renderXcosDiagram: renderXcosDiagram,
    serializeModel: serializeModel,
    deserializeModel: deserializeModel
  };
}

它用 typeof module 做了判断:在渲染进程里 module 不存在,这段整体跳过,文件照常以全局对象的形式被页面使用;在 Node 里 module 存在,二十一个导出项就都能 require 出来。就这一行判断,让这个引擎变成可离线测试的。 下面所有数值结论都不需要真机,也不需要打开浏览器。

先量解释器本身。一个受限表达式引擎的核心问题不是"能算什么",而是它以什么姿态对待不能算的东西:

// offline checkup of the shipped engine, no browser, no Electron, no device
const E = require('D:/hwAppLaunch/ScilabXcos/ohos_hap/web_engine/src/main/resources/' +
                  'resfile/resources/app/scilab-engine.js');

function num(x, d) {
  return typeof x === 'number' ? +x.toFixed(d === undefined ? 6 : d) : x;
}
function val(r) {
  if (r && r.type === 'matrix')
    return r.rows + 'x' + r.cols + JSON.stringify(r.data.map(row => row.map(c => num(c, 6))));
  return num(r);
}
function run(env, s) {
  const r = E.evaluateScilab(env, s);
  return r.success ? val(r.result) : 'ERR: ' + r.error;
}

console.log('== 1. precedence and associativity, one fresh environment per line ==');
['2+3*4', '(2+3)*4', '2^3^2', '-2^2', '-2*3', '10/4', '10\\4', '1+',
 '[1,2;3,4]', '[1,2;3,4]*[1;1]', 'sqrt(2)', '2*%pi', '%e', 'foo(1)',
 'A=[1,2]', 'system("dir")'].forEach(s =>
  console.log('  ' + s.padEnd(16) + ' -> ' + run(E.createScilabEnv(), s)));

console.log('');
console.log('== 2. assignment lives outside the parser ==');
const e0 = E.createScilabEnv();
console.log('  evaluateScilab("x=5") -> ' + JSON.stringify(E.evaluateScilab(e0, 'x=5')));
E.assignVariable(e0, 'x', 5);
console.log('  assignVariable(e,"x",5) then "x*2" -> ' + run(e0, 'x*2'));
console.log('  env.ans after that line             -> ' + JSON.stringify(e0.ans));

console.log('');
console.log('== 3. linear algebra accuracy ==');
const e1 = E.createScilabEnv();
E.assignVariable(e1, 'A', E.evaluateScilab(e1, '[4,3;6,3]').result);
E.assignVariable(e1, 'B', E.evaluateScilab(e1, '[10;12]').result);
console.log('  det(A)      = ' + run(e1, 'det(A)') + '   hand check 4*3-6*3 = -6');
console.log('  A\\B         = ' + run(e1, 'A\\B') + '   exact solution is [1;2]');
console.log('  A*inv(A)    = ' + run(e1, 'A*inv(A)'));
const I = E.evaluateScilab(e1, 'A*inv(A)').result;
console.log('  max|A*inv(A)-I| = ' +
  Math.max(...I.data.flatMap((r, i) => r.map((c, j) => Math.abs(c - (i === j ? 1 : 0))))).toExponential(3));
const X = E.evaluateScilab(e1, 'A\\B').result;
const res = [[4, 3], [6, 3]].map((r, i) =>
  r.reduce((s, v, k) => s + v * X.data[k][0], 0) - [10, 12][i]);
console.log('  residual A*(A\\B)-B = ' + JSON.stringify(res.map(v => +v.toFixed(12))));
console.log('  inv(A)*A  = ' + run(e1, 'inv(A)*A'));

console.log('');
console.log('== 4. matrix constructors and their guards ==');
const e2 = E.createScilabEnv();
['[1,2;3]', '[1;2;3]', 'zeros(2,3)', 'ones(1,4)', 'eye(3)',
 'linspace(0,1,5)', 'linspace(0,1,1)', 'det([1,2;1,2])', '[1,2;3,4]^2'].forEach(s =>
  console.log('  ' + s.padEnd(16) + ' -> ' + run(e2, s)));
== 1. precedence and associativity, one fresh environment per line ==
  2+3*4            -> 14
  (2+3)*4          -> 20
  2^3^2            -> 512
  -2^2             -> 4
  -2*3             -> -6
  10/4             -> 2.5
  10\4             -> ERR: 左除 \ 左侧必须是矩阵
  1+               -> ERR: 表达式意外结束
  [1,2;3,4]        -> 2x2[[1,2],[3,4]]
  [1,2;3,4]*[1;1]  -> 2x1[[3],[7]]
  sqrt(2)          -> 1.414214
  2*%pi            -> 6.283185
  %e               -> 2.718282
  foo(1)           -> ERR: 未定义函数: foo
  A=[1,2]          -> ERR: 非法字符: =
  system("dir")    -> ERR: 非法字符: "

== 2. assignment lives outside the parser ==
  evaluateScilab("x=5") -> {"success":false,"error":"非法字符: ="}
  assignVariable(e,"x",5) then "x*2" -> 10
  env.ans after that line             -> 10

== 3. linear algebra accuracy ==
  det(A)      = -6   hand check 4*3-6*3 = -6
  A\B         = 2x1[[1],[2]]   exact solution is [1;2]
  A*inv(A)    = 2x2[[1,0],[0,1]]
  max|A*inv(A)-I| = 0.000e+0
  residual A*(A\B)-B = [0,0]
  inv(A)*A  = 2x2[[1,0],[0,1]]

== 4. matrix constructors and their guards ==
  [1,2;3]          -> ERR: 矩阵各行元素数量不一致
  [1;2;3]          -> 3x1[[1],[2],[3]]
  zeros(2,3)       -> 2x3[[0,0,0],[0,0,0]]
  ones(1,4)        -> 1x4[[1,1,1,1]]
  eye(3)           -> 3x3[[1,0,0],[0,1,0],[0,0,1]]
  linspace(0,1,5)  -> 1x5[[0,0.25,0.5,0.75,1]]
  linspace(0,1,1)  -> ERR: linspace 点数至少为 2
  det([1,2;1,2])   -> 0
  [1,2;3,4]^2      -> NaN

先说让我放心的一部分。

字符层面就拒了。 A=[1,2] 报"非法字符: =",system(“dir”) 报"非法字符: “”。分词器扫到不在白名单里的字符直接抛,等不到语法分析。我另外全文查过这个文件里有没有 eval、new Function、require、process、fetch——一个都没有,搜到的 eval 全是 evaluateScilab 这个标识符自己的子串。所以这个"计算器"没有任何一条通往宿主 API 的边:不是它防得好,是它压根没有可越的界。对一个把用户输入当表达式求值的工具,这是我最想确认的一条。

赋值不在解析器里。 evaluateScilab(“x=5”) 返回 success:false,但 assignVariable(env,“x”,5) 之后再算 x*2 得到 10,且 env.ans 自动变成 10。也就是说 name = expr 这个语法是渲染进程在调用引擎之前自己拆的,引擎只接纯表达式。

该有守卫的构造函数有守卫。 矩阵各行元素数量不一致、linspace 点数至少为 2、奇异矩阵 det 返回 0 而不是崩溃,都如期报错。矩阵运算的精度也够:Ainv(A) 与单位阵的最大逐元素偏差是 0.000e+0,A\B 解出 [1;2],残差 [0,0],det([4,3;6,3]) 得 -6 与手算 43-6*3 一致。

再说两处静默的语义偏差,这是我这次真正量出来的东西。第一处是幂运算的结合层次:

  // 因子:幂运算
  function parseFactor() {
    var base = parseUnary();
    if (peek() === '^') {
      consume();
      var exp = parseFactor();  // 右结合
      return Math.pow(base, exp);
    }
    return base;
  }

  // 一元运算符
  function parseUnary() {
    if (peek() === '-') {
      consume();
      return -parseUnary();
    }
    if (peek() === '+') {
      consume();
      return parseUnary();
    }
    return parsePrimary();
  }

parseFactor 先调 parseUnary 取底数,而 parseUnary 遇到负号会一路递归到 parsePrimary。于是 -2^2 的底数是 -2,结果是 4。而它模仿的对象 Scilab 里一元负号绑得比幂松,-2^2 是 -4。232 得 512,这条与上游一致(右结合)。

第二处在同一份代码的下一行:Math.pow(base, exp)。base 是矩阵对象时,Math.pow 返回 NaN——[1,2;3,4]^2 不报错,返回 NaN。上游的 Scilab 里 A^2 就是矩阵乘法 A*A,是合法且常用的写法。

这两处的共同点是都算"成功":success 为 true,没有 error,界面上就是一个数(4)或者一个 NaN。用户如果不知道约定,看不出自己被换了语义。对一个宣称做科学计算的工具,这类偏差比崩溃危险得多,因为崩溃至少会提醒人。

我不打算在这里给"该怎么改"的完整方案——真要改,涉及词法层次调整和矩阵乘方的引入,属于功能决策而不是排错。这一节的产出是把两处偏差钉在具体的行上,它们已经作为缺陷记进第十七节。


十四、求解器的五个数值事实,以及我自己量错的那一次

框图仿真部分同样可以离线跑:createXcosModel、addBlock、addConnection、simulateXcos 都在导出表里。我固定用同一个一阶反馈模型做五组实验——常数输入 1 进 sum,sum 减掉反馈后走 gain(2) 进 integrator,积分器同时输出到 output 块并反馈回 sum 的第二个输入端口。这个网络的解析解是 y(t) = 1 - e^(-2t),y(1) = 0.8646647167633873。

// numeric checkup of the Xcos solver: same network, five questions
const E = require('D:/hwAppLaunch/ScilabXcos/ohos_hap/web_engine/src/main/resources/' +
                  'resfile/resources/app/scilab-engine.js');

// input(1) -> sum(+,-) -> gain(2) -> integrator -> output, integrator fed back to sum
function loop(reverse, feedback) {
  const m = E.createXcosModel();
  const mk = (t, p) => E.addBlock(m, t, 0, 0, p).id;
  let id, sm, g, int, ou;
  if (!reverse) {
    id = mk('input', { value: 1 }); sm = mk('sum', { signs: [1, -1] });
    g = mk('gain', { k: 2 }); int = mk('integrator', {}); ou = mk('output', {});
  } else {
    int = mk('integrator', {}); g = mk('gain', { k: 2 });
    sm = mk('sum', { signs: [1, -1] }); id = mk('input', { value: 1 }); ou = mk('output', {});
  }
  E.addConnection(m, id, 0, sm, 0);
  E.addConnection(m, sm, 0, g, 0);
  E.addConnection(m, g, 0, int, 0);
  E.addConnection(m, int, 0, ou, 0);
  if (feedback) E.addConnection(m, int, 0, sm, 1);
  return { m: m, int: int };
}
const y = t => 1 - Math.exp(-2 * t);          // analytic solution of dy/dt = 2*(1-y)
const state = (m, id) => m.blocks.find(b => b.id === id).state.value;
// the integrator is the only state, so the true time reached is steps*dt, not `duration`
const reached = (dur, dt) => Math.floor(dur / dt) * dt;

console.log('== A. does the order of blocks in the array matter? ==');
['forward', 'reversed'].forEach(o => {
  ['rk4', 'euler'].forEach(sv => {
    const k = loop(o === 'reversed', true);
    E.simulateXcos(k.m, 1, 0.01, sv);
    const v = state(k.m, k.int);
    console.log('  ' + o.padEnd(9) + ' ' + sv.padEnd(6) +
      ' y(1.0) = ' + v.toFixed(15) + '  err = ' + Math.abs(v - y(1)).toExponential(3));
  });
});

console.log('');
console.log('== B. same blocks, feedback wire removed ==');
{
  const k = loop(false, false);
  E.simulateXcos(k.m, 1, 0.01, 'rk4');
  console.log('  open loop y(1.0) = ' + state(k.m, k.int).toFixed(12) +
    '   exact = 2.0 (integral of a constant 2 over t=0..1)');
}

console.log('');
console.log('== C. order of convergence on the closed loop, error at the time actually reached ==');
['forward', 'reversed'].forEach(o => {
  ['rk4', 'euler'].forEach(sv => {
    console.log('  ' + o + ' ' + sv + ':');
    let prev = null;
    [100, 200, 400, 800, 1600].forEach(N => {
      const k = loop(o === 'reversed', true);
      E.simulateXcos(k.m, 1, 1 / N, sv);
      const e = Math.abs(state(k.m, k.int) - y(reached(1, 1 / N)));
      console.log('    N=' + String(N).padStart(5) + ' dt=' + (1 / N).toFixed(5) +
        ' err=' + e.toExponential(4) + (prev ? '  reduced by x' + (prev / e).toFixed(2) : ''));
      prev = e;
    });
  });
});

console.log('');
console.log('== C2. what time does the returned log claim to end at? ==');
{
  const k = loop(false, true);
  const r = E.simulateXcos(k.m, 1, 0.01, 'rk4');
  console.log('  duration=1  dt=0.01  log points=' + r.log.length +
    '  first t=' + r.log[0].t.toFixed(2) + '  last t=' + r.log[r.log.length - 1].t.toFixed(2));
  console.log('  state after the last step = ' + state(k.m, k.int).toFixed(16));
  console.log('  y(last t)                 = ' + y(r.log[r.log.length - 1].t).toFixed(16));
  console.log('  y(last t + dt)            = ' + y(1).toFixed(16) +
    '   <- matches the state, so the point labelled t=n*dt carries the value at t=(n+1)*dt');
  const fine = loop(false, true);
  E.simulateXcos(fine.m, 1, 1e-6, 'rk4');
  console.log('  with dt=1e-6 the same comparison still holds, err vs y(1.0) = ' +
    Math.abs(state(fine.m, fine.int) - y(1)).toExponential(3));
}

console.log('');
console.log('== D. is the RK4 integrator itself wrong? transfer block alone ==');
['rk4', 'euler'].forEach(sv => {
  const m = E.createXcosModel();
  const id = E.addBlock(m, 'input', 0, 0, { value: 1 }).id;
  const tr = E.addBlock(m, 'transfer', 0, 0, { numerator: [1], denominator: [2, 1] }).id;
  const ou = E.addBlock(m, 'output', 0, 0, {}).id;
  E.addConnection(m, id, 0, tr, 0);
  E.addConnection(m, tr, 0, ou, 0);
  E.simulateXcos(m, 1, 0.001, sv);
  console.log('  ' + sv.padEnd(6) + ' y(1.0) = ' + state(m, tr).toFixed(12) +
      '   analytic 1-exp(-0.5) = ' + (1 - Math.exp(-0.5)).toFixed(12));
});

console.log('');
console.log('== E. is an algebraic loop iterated to a fixpoint? ==');
{
  const m = E.createXcosModel();
  const id = E.addBlock(m, 'input', 0, 0, { value: 1 }).id;
  const sm = E.addBlock(m, 'sum', 0, 0, { signs: [1, -1] }).id;
  const g1 = E.addBlock(m, 'gain', 0, 0, { k: 1 }).id;
  E.addConnection(m, id, 0, sm, 0);
  E.addConnection(m, sm, 0, g1, 0);
  E.addConnection(m, g1, 0, sm, 1);
  const gain = () => m.blocks.find(b => b.id === g1).state.output;
  // no dynamic block here, so one simulate call = one settle pass; repeat it and watch
  const seq = [];
  for (let i = 0; i < 8; i++) { E.simulateXcos(m, 0.01, 0.01, 'rk4'); seq.push(gain()); }
  console.log('  gain output per settle pass: ' + seq.join(' '));
  console.log('  fixpoint of u = 1 - u is 0.5');
}
== A. does the order of blocks in the array matter? ==
  forward   rk4    y(1.0) = 0.864664716396427  err = 3.670e-10
  forward   euler  y(1.0) = 0.867380444105247  err = 2.716e-3
  reversed  rk4    y(1.0) = 0.864666995092968  err = 2.278e-6
  reversed  euler  y(1.0) = 0.864731867001448  err = 6.715e-5

== B. same blocks, feedback wire removed ==
  open loop y(1.0) = 2.000000000000   exact = 2.0 (integral of a constant 2 over t=0..1)

== C. order of convergence on the closed loop, error at the time actually reached ==
  forward rk4:
    N=  100 dt=0.01000 err=3.6696e-10
    N=  200 dt=0.00500 err=2.2744e-11  reduced by x16.13
    N=  400 dt=0.00250 err=1.4155e-12  reduced by x16.07
    N=  800 dt=0.00125 err=8.8263e-14  reduced by x16.04
    N= 1600 dt=0.00063 err=5.9952e-15  reduced by x14.72
  forward euler:
    N=  100 dt=0.01000 err=2.7157e-3
    N=  200 dt=0.00500 err=1.3556e-3  reduced by x2.00
    N=  400 dt=0.00250 err=6.7724e-4  reduced by x2.00
    N=  800 dt=0.00125 err=3.3848e-4  reduced by x2.00
    N= 1600 dt=0.00063 err=1.6920e-4  reduced by x2.00
  reversed rk4:
    N=  100 dt=0.01000 err=2.2783e-6
    N=  200 dt=0.00500 err=5.6673e-7  reduced by x4.02
    N=  400 dt=0.00250 err=1.4133e-7  reduced by x4.01
    N=  800 dt=0.00125 err=3.5288e-8  reduced by x4.01
    N= 1600 dt=0.00063 err=8.8164e-9  reduced by x4.00
  reversed euler:
    N=  100 dt=0.01000 err=6.7150e-5
    N=  200 dt=0.00500 err=1.6275e-5  reduced by x4.13
    N=  400 dt=0.00250 err=4.0073e-6  reduced by x4.06
    N=  800 dt=0.00125 err=9.9427e-7  reduced by x4.03
    N= 1600 dt=0.00063 err=2.4763e-7  reduced by x4.02

== C2. what time does the returned log claim to end at? ==
  duration=1  dt=0.01  log points=100  first t=0.00  last t=0.99
  state after the last step = 0.8646647163964265
  y(last t)                 = 0.8619307626891072
  y(last t + dt)            = 0.8646647167633873   <- matches the state, so the point labelled t=n*dt carries the value at t=(n+1)*dt
  with dt=1e-6 the same comparison still holds, err vs y(1.0) = 3.508e-14

== D. is the RK4 integrator itself wrong? transfer block alone ==
  rk4    y(1.0) = 0.393469340287   analytic 1-exp(-0.5) = 0.393469340287
  euler  y(1.0) = 0.393545177160   analytic 1-exp(-0.5) = 0.393469340287

== E. is an algebraic loop iterated to a fixpoint? ==
  gain output per settle pass: 1 0 1 0 1 0 1 0
  fixpoint of u = 1 - u is 0.5

事实一:块在数组里的顺序会影响结果,而且影响的是收敛阶,不是末位噪声。 A 段四个数用的是同一张图、同一组连线,只把块的注册顺序倒过来:forward 下 rk4 误差 3.670e-10,reversed 下变成 2.278e-6,相差近四个数量级。原因在实现里能直接读到:settleOutputs 与 derivNetwork 都是对 model.blocks 做一次按数组顺序的遍历,遍历时 getInputValue 取的是上游块的 state.output——上游在这轮里还没被访问过,取到的就是上一步留下的旧值。所以"这个方程组在这一轮里有几次传播机会"是由数组顺序决定的,跟图的拓扑无关。

事实二:开环是精确的。 把反馈那根线拔掉,同样的步长下 y(1.0) = 2.000000000000,正好等于常数 2 在 0 到 1 上的积分。也就是说积分器本身、步长循环本身没有系统性偏差,误差全部来自上面那条单遍传播。

事实三:rk4 的实现没有写错。 D 段单独放一个传函块(分母 2s+1),dt=0.001,rk4 得 0.393469340287,解析值 1-e^(-0.5) = 0.393469340287,前十二位完全相同;euler 同条件下 0.393545177160,差在第五六位。所以 rk4 的四个斜率加权和是对的。

事实四:C 段是这次最有信息量的一张表。 我把同一模型的误差随步长加密做了五档,对比值取"实际走到的时刻"而不是名义 duration:

  • forward rk4:误差比 16.13、16.07、16.04 —— 四阶,教科书值;到 1600 档变成 14.72,因为误差已经掉到 5.9952e-15,撞上双精度舍入墙,这一步比值本来就该失真。
  • forward euler:2.00、2.00、2.00、2.00 —— 一阶,也是教科书值。
  • reversed rk4:4.02、4.01、4.01、4.00 —— 二阶。同样的代码,仅仅因为块顺序,四阶掉到二阶。
  • reversed euler:4.13、4.06、4.03、4.02 —— 二阶,比正序的一阶"更好"。这不是 euler 变准了,是反馈量晚一步带来的相位滞后凑出了类似中心差分的抵消。它提醒我一件事:阶数高于你用的格式,通常意味着误差结构里有你没建模的东西,不能当性能提升来读。

现在说我自己量错的那一次,因为这一段的初稿是错的。

初版 C 段的对比值我取的是 r.log[log.length-1].t,也就是最后一个日志点的时刻。跑出来五档的误差比全是 2.00 上下,包括正序 rk4。当时的结论已经写好了:“闭环反馈让 rk4 退化成一阶”。这是一个纯粹的测量错误。 日志点个数是 100,最后一个点的 t 标的是 0.99,而状态向量的值在 t=1.0,两者差了整整一个步长。y(1.0) 与 y(0.99) 相差 2.73e-3,比真实截断误差大六个数量级——它直接盖住了 rk4 的 3.67e-10,于是"误差正比于 dt"成了必然结果,那个 2.00 是我自己造出来的。

更要命的是这个假数长得非常像真数:2.7340e-3 与正序 euler 的真实误差 2.7157e-3 只差 0.7%。如果我做的是"看一眼数量级合不合理"的抽查,它会通过。凡是能骗过抽查的错误,都只能靠让它自证来拦:C2 段就是为此加的,它把日志点数、首末时刻、状态值、y(last t)、y(last t + dt) 五个量并排打出来,一眼就能看到状态值跟哪个对齐。

C2 段顺带量出一个真实的实现事实:*标着 t=n*dt 的那个日志点装的是 t=(n+1)dt 的值,正序逆序两个分支都一样;而且最后一个点是 duration-dt,日志永远不包含 duration 那一刻,仿真跑 1 秒,曲线只画到 0.99。画图时这会让整条曲线在时间轴上早一步,导出数值表时会让人误读末点。这条也进了第十七节。

事实五:纯代数环不会被迭代到定点。 E 段那个网络里没有动态块,sum 与 gain 直接互为输入,方程 u = 1 - u 的定点是 0.5。我连续调用八次(每次 simulateXcos 在无状态网络里等于做一遍 settle),拿到的序列是 1 0 1 0 1 0 1 0——它不收敛,它振荡。单遍传播在一个环路里只提供一次赋值机会,下一次调用又读到上一轮的值,于是两状态交替。用户在这上面点运行,得到的是取决于块顺序和调用次数的数,而界面上没有任何提示。

这一节五个事实的共同点是:没有一条能靠"跑一次看看对不对"发现。 开环对、闭环看着也像对(0.8646647 与 0.8646669 在前五位相同),代数环给出的 1 或 0 也像是个"结果"。只有把误差当量来测、把步长当参数扫、把顺序当变量翻,它们才会露出来。

【配图位 4:科仿仿真结果对照截图两张拼一张。同一个一阶反馈模型,两张图曲线都看不出差别,但控制台或数据面板上的末值要分别体现正序与逆序块顺序的差异(0.8646647164 与 0.8646669951 量级)。左图标注块顺序 forward,右图标注 reversed,用来支撑"肉眼看不出、量才量得出"这句。】


十五、把前面十四节的判据装进一个能跑的脚本

排错做完,留下的不应该是"我记得我查过"。我把这篇里真正立得住的判据合并成一个脚本,对这个工程跑一遍,输出就是一行发布前体检结果:

import os, sys, re, io, json, zipfile, hashlib
sys.stdout.reconfigure(encoding='utf-8')
ROOT = r'D:\hwAppLaunch'
PROJ = ['ScilabXcos']
HOME, ACT_HOME = 'entity.system.home', 'action.system.home'
PERM = r'"(ohos\.permission\.[A-Za-z_]+)"'


def strip_comments(s):
    """Drop // and /* */ comments of a json5 source, leaving quoted text alone."""
    out, i, n = [], 0, len(s)
    while i < n:
        c = s[i]
        if c in '"\'':
            q = c
            out.append(c)
            i += 1
            while i < n:
                out.append(s[i])
                if s[i] == '\\':
                    i += 2
                    continue
                if s[i] == q:
                    i += 1
                    break
                i += 1
            continue
        if c == '/' and i + 1 < n and s[i + 1] == '/':
            j = s.find('\n', i)
            i = n if j < 0 else j
            continue
        if c == '/' and i + 1 < n and s[i + 1] == '*':
            j = s.find('*/', i + 2)
            i = n if j < 0 else j + 2
            continue
        out.append(c)
        i += 1
    return ''.join(out)


def block(src, key):
    i = src.find('"%s"' % key)
    if i < 0:
        return ''
    j, d = src.find('[', i), 0
    for k in range(j, len(src)):
        if src[k] == '[':
            d += 1
        elif src[k] == ']':
            d -= 1
            if d == 0:
                return src[j:k + 1]
    return ''


def pkg_of(p):
    """The hap inside the signed .app, as a ZipFile. This is the only truth source."""
    app = os.path.join(ROOT, p, 'ohos_hap', 'build', 'outputs', 'default',
                       'ohos_hap-default-signed.app')
    if not os.path.isfile(app):
        return None
    a = zipfile.ZipFile(app)
    h = [n for n in a.namelist() if n.endswith('.hap')]
    if not h:
        return None
    return zipfile.ZipFile(io.BytesIO(a.read(h[0])))


def src_read(p, *rel):
    return strip_comments(open(os.path.join(ROOT, p, 'ohos_hap', *rel),
                              encoding='utf-8').read())


def label_values(p):
    out = []
    for rel, key in [(('AppScope', 'resources', 'base', 'element', 'string.json'), 'app_name'),
                     (('electron', 'src', 'main', 'resources', 'base', 'element', 'string.json'), 'EntryAbility_label'),
                     (('electron', 'src', 'main', 'resources', 'en_US', 'element', 'string.json'), 'EntryAbility_label'),
                     (('electron', 'src', 'main', 'resources', 'zh_CN', 'element', 'string.json'), 'EntryAbility_label')]:
        d = json.load(open(os.path.join(ROOT, p, 'ohos_hap', *rel), encoding='utf-8'))
        out.append({x['name']: x['value'] for x in d['string']}.get(key))
    return out


print('%-14s %-7s %-9s %-8s %-11s %-8s %s' % (
    'project', 'skills', 'perm', 'src=pkg', 'resfile', 'iconmad', 'label'))
notes = []
for p in PROJ:
    B = os.path.join(ROOT, p, 'ohos_hap')
    ab = src_read(p, 'electron', 'src', 'main', 'module.json5')
    i = ab.find('"name": "EntryAbility"')
    j = ab.find('"name": "BrowserAbility"', i)
    ab = ab[i:j if j > 0 else len(ab)]
    e = re.search(r'"entities":\s*\[(.*?)\]', ab, re.S)
    a = re.search(r'"actions":\s*\[(.*?)\]', ab, re.S)
    sk = 'OK' if (e and HOME in e.group(1) and a and ACT_HOME in a.group(1)) else 'BAD'

    we = src_read(p, 'web_engine', 'src', 'main', 'module.json5')
    req = re.findall(PERM, block(we, 'requestPermissions'))
    z, pm, pset, res, mad = pkg_of(p), 'n/a', 'n/a', 'n/a', 'n/a'
    if z is not None:
        pr = [x['name'] for x in json.loads(z.read('module.json').decode('utf-8'))
              ['module'].get('requestPermissions', [])]
        pm, pset = '%d->%d' % (len(req), len(pr)), ('EQ' if pr == req else 'DIFF')
        RB = os.path.join(B, 'web_engine', 'src', 'main', 'resources', 'resfile',
                          'resources', 'app')
        bad = []
        for root, dirs, files in os.walk(RB):
            dirs[:] = [d for d in dirs if d != 'node_modules']
            for fn in files:
                fp = os.path.join(root, fn)
                key = 'resources/resfile/resources/app/' + \
                    fp.replace(RB + os.sep, '').replace(os.sep, '/')
                if key not in z.namelist():
                    bad.append(fn + ':absent')
                elif z.read(key) != open(fp, 'rb').read():
                    bad.append(fn)
        res = 'all-same' if not bad else ','.join(sorted(bad))[:11]
        from PIL import Image
        import numpy as np
        sp = os.path.join(B, 'AppScope', 'resources', 'base', 'media', 'app_icon.png')
        si, pi = Image.open(sp), Image.open(io.BytesIO(z.read('resources/base/media/app_icon.png')))
        mad = ('same-size' if si.size == pi.size else '%.2f' % np.abs(
            np.asarray(si.convert('RGB').resize(pi.size, Image.LANCZOS), dtype=np.int32) -
            np.asarray(pi.convert('RGB'), dtype=np.int32)).mean())
    v = label_values(p)
    lab = 'OK' if len(set(v)) == 1 else 'MISMATCH'
    if lab == 'MISMATCH' or pset == 'DIFF' or (res not in ('n/a', 'all-same')):
        notes.append((p, lab, v))
    print('%-14s %-7s %-9s %-8s %-11s %-8s %s' % (p, sk, pm, pset, res, mad, lab))

print()
print('== things this run flagged ==')
for p, lab, v in notes:
    print('  %-14s label=%s  values=%s' % (p, lab, v))

print()
print('== the deletion experiment, replayed on the shipped package ==')
z = pkg_of('ScilabXcos')
for tag in ['as-built', 'skills=[]']:
    mj = json.loads(z.read('module.json').decode('utf-8'))
    if tag == 'skills=[]':
        for x in mj['module']['abilities']:
            if x['name'] == 'EntryAbility':
                x['skills'] = []
    car, n = [], 0
    for kind in ('abilities', 'extensionAbilities'):
        for x in mj['module'].get(kind, []):
            for s in (x.get('skills') or []):
                n += 1
                if HOME in (s.get('entities') or []):
                    car.append(x['name'])
    print('  %-9s skill-blocks=%d  mainElement=%s  abilities=%d  home-carriers=%s' % (
        tag, n, mj['module']['mainElement'], len(mj['module']['abilities']), car))
project        skills  perm      src=pkg  resfile     iconmad  label
ScilabXcos     OK      14->14    EQ       renderer.js 0.74     OK

== things this run flagged ==
  ScilabXcos     label=OK  values=['科仿 科学计算与仿真', '科仿 科学计算与仿真', '科仿 科学计算与仿真', '科仿 科学计算与仿真']

== the deletion experiment, replayed on the shipped package ==
  as-built  skill-blocks=3  mainElement=EntryAbility  abilities=3  home-carriers=['EntryAbility']
  skills=[] skill-blocks=1  mainElement=EntryAbility  abilities=3  home-carriers=[]

七列各自的判据,以及它为什么是这条而不是别的:

  • skills:从剥掉注释的 module.json5 里切出 EntryAbility 那一段,只在这段里查 entity.system.home 与 action.system.home。必须切块——同一个文件里 BrowserAbility 也声明了 ohos.want.action.viewData,整文件搜会让一个 Ability 的声明去替另一个过关。这一列就是第五、六节那条故障的正面拦截。
  • perm 与 src=pkg:前者打印"源里几项、包里几项",后者比较两个列表逐项同序是否相等。这两列必须建立在同一个口径上——都只数 requestPermissions 块内的条目(第十二节),而不是全文计数。
  • resfile:把包内 resources/resfile/resources/app 下每个文件与工作区做逐字节比对,列出所有不一致的文件名。这是第十一节那套产物反查的通用版,跳过 node_modules。
  • iconmad:源图缩到包内尺寸后的逐像素平均绝对差,数值要配合第九节那组重采样对照读。
  • label:AppScope 的 app_name 与 electron 三份 string.json 的 EntryAbility_label 四个值必须完全一致。

这次实跑抓到一件必须处理的事。

它就是第十一节已经写过的:resfile 列是 renderer.js,其余五份业务文件与包内逐字节相同。也就是说当前这个签名包落后于工作区,那次保存对话框改名还没进包,下次发布必须重新出包。

这一列的价值也在这里:它是唯一一列直接回答"交付物是不是你以为的那一份"的判据。label 与 perm 两列回答"包里的声明对不对",只有 resfile 回答"包里的内容是不是最新的"。前者能过审,后者决定用户拿到的是哪个版本。

明细表只对 label 打印全部取值,因为 perm 与 resfile 的差异用计数和文件名就够了,而 label 差一个字符是看不出哪里差的。

表格最后那一段是第六节的删除实验,我把它一并留在了体检脚本末尾:拿当前这个签名包,先看 as-built 的 skill-blocks=3、home-carriers 是 EntryAbility,再在内存里把它的 skills 置空,得到 skill-blocks=1、home-carriers 空列表。每次跑体检都顺便重放一次这个实验,是为了让"这一列的 OK 是有意义的"这件事不用回头翻文章。

【配图位 5:体检脚本在终端里跑完的整屏截图,能看到表头与这一行结果、flagged 明细,以及最后的删除实验两行。终端宽度要够,不能折行。】


十六、这一篇沉淀下来的判据

把上面所有内容压成可复用的条目。每条都注明它在本文哪一节落地,不写空对空的话。

  1. 一个假设如果只能靠"这个工程特殊"成立,就必须指出特殊在哪一行。 第二、三节两条死路都死在这里:onAcceptWant 缺失与壳层 kDestroy 分支都拿不出任何可核对的差异证据,只拿得出一个能自圆其说的故事。
  2. 在多 Ability 的壳工程里,实例路由逻辑在 AbilityStage,不在 Ability。 到 EntryAbility.ets 里搜 onAcceptWant 一定搜不到,这不是缺陷。(第二节)
  3. 库代码的判据是哈希,不是阅读。 五份壳文件的哈希在改动前记一遍、还原后再算一遍,两次一致才算干净;"我确实 revert 了"这句话本身不是证据。(第三节)
  4. 模板风格与编辑痕迹长得很像,区分办法是问它有没有进到交付物里。 尾随逗号源文件里两处、包内 module.json 里零处。(第四节)
  5. 配置类故障最快的定位是把整段声明按字段展开、问每一项由哪条路径读,不是从头读代码。读代码我花了两天,按字段展开一步命中。(第五节)
  6. IDE 里点运行不查 skills。 aa start 显式指定 bundleName 与 Ability 名,所以"IDE 正常、桌面打不开"是这类故障的特征而不是谜。(第五节)
  7. 能证伪的判据优先于讲得通的叙事。 用签名包里的 module.json 穷举 home entity 的承载者,把"缺了它可能坏"变成"别处没有兜底、必然坏"。(第六节)
  8. 结构性删除之后,判据必须是字段值比对,不能是括号配平和可解析。 空数组是合法值,全链条放行。(第七节)
  9. 图标不能用哈希做判据,构建管线必然重编码;用缩放后逐像素平均绝对差,并且要拿同一张图的多种重采样当对照(0.54 到 1.06),阈值本身不构成判据。(第九节)
  10. 文案残留按语法角色分桶统计,一个总数同时混着改不得的 token、代码标识符、注释和真正要改的可见文案,既不能当"零残留"的证据,也回答不了"改几处";分桶必须保位扫描,否则行号全是错的。(第十节)
  11. "参数透传 + 双份文案"的写法注定单侧查不出来。 同一句用户可见文字在主进程硬编码、在渲染进程当参数传,只扫一边必然漏。(第十一节)
  12. 比对文本先统一换行口径,并把 crlf 计数打进输出自检。 口径错了,866 行会全部报不同。(第十一节)
  13. "构建链静默剔除"这类结论,举证责任在说它的人。 数之前先声明口径:块内、全文、包内是三个数,混着用就能造出一个不存在的问题。(第十二节)
  14. 受限求值器的安全性优先看有没有越界通道(eval、动态 require、宿主 API),而不是看它拒绝了哪些关键字——两者都成立才算数。(第十三节)
  15. 静默语义偏差比崩溃危险,因为它不触发怀疑。-2^2 得 4、矩阵幂得 NaN,都是 success:true。(第十三节)
  16. 数值实现的正确性要"把误差当量测、把步长当参数、把顺序当变量"。 单次运行对不对,回答不了收敛阶。(第十四节)
  17. 测量脚本必须自证对齐。 我的第一次收敛阶实验量的是一个假的一阶,而那个假数与真 euler 误差只差 0.7%,抽查绝对过不了关——只有把状态值与两个候选解析值并排打出来才能当场看见。(第十四节)
  18. 一条判据如果值得信,就该合并进体检脚本,让它每次发布都被重跑。(第十五节)

十七、提审前待办

写在这里是为了下一次真的去清,不是归档。分三档。

必须做,阻塞发布:

  1. 重新出包。 当前签名包落后于工作区,缺的是 renderer.js 第 755 行那次保存对话框文件类型名修正。第十五节 resfile 列会确认它归零。

该做,影响可信度:

  1. 矩阵幂返回 NaN 与 -2^2 得 4:要么实现对齐上游语义,要么在界面/帮助里明确写清本引擎的幂只对标量生效、一元负号先于幂绑定。现在是"看起来算对了"。(第十三节)
  2. 仿真日志的时间标签与取值差一个步长,且末点是 duration-dt。 用户要 1 秒的结果,图只画到 0.99。(第十四节)
  3. 纯代数环不迭代到定点,输出在 1 与 0 之间振荡。 至少要检测出环路并给提示,现在的表现是一个看起来像结果的数。(第十四节)
  4. 注释里的 13 处上游品牌字样是否清理(renderer.js 5、styles/scilab.css 4、scilab-engine.js 3、main.js 1)。不影响审核,影响下一个接手的人对代码来源的判断。(第十节)

待验证,需要真机:

  1. 第一块 skills 里 entity.system.browsable 与 ohos.want.action.viewData 各自是否必需,uris 为空数组是否有副作用。静态层面区分不出来,只能在真机上逐字段删掉再点一次桌面图标。注意做这件事的前提:整块不能删(第六节已证),只动块内字段,且每改一次都要重跑第十五节体检。
  2. 第二块 skills 只匹配 scheme link,一个纯本地工具是否该保留这个拉起入口。我的判断是留着(第八节),但这属于产品决策,等有实际驳回意见再动。
  3. 完整窗口真机截图(文首到文末那五个图位)待补拍,包括故障对照图与体检表终端截图。

这一篇的骨架其实是一句话:我用了两天读代码找那个 bug,正确答案在一份我没读过的配置文件里,而发现它只花了一次逐字段对照的时间。 中间那两天不是白费——它们换来了第三、第四、第六节里那三条能复用的判据,和第十五节那张表。但如果下一次我先跑那张表,两天就没有了。

Logo

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

更多推荐