AI 工作流2026-09-21

一天的工作:把阅读器升到 1.1.0、让扫码平台上线,和那些只有真跑才暴露的坑

三条线并行的一天:鸿蒙阅读器走完语音朗读与离线导出的大版本并提交审核,通用扫码平台从设计文档走到上线,官网上三个只在真实渲染里才暴露的缺陷收尾。

2026-09-21 | 工作日报 | 分类:AI 工作流

今天是三条线并行的一天:一个鸿蒙阅读器走完了"语音朗读 + 离线语音导出"的大版本并提交审核;一个通用扫码平台从设计文档走到生产环境;官网上三个只在真实渲染里才会露头的缺陷收了尾。此外还有两套系统开源、一条"任务完成"通知链路打通,以及每天凌晨自动跑起来的运维任务。

下面是这一天的工作全景。


一、阅读器 1.1.0:从"能不能做"到一份提交审核的版本

这是今天工程量最大的一块。从上午一个可行性的问句开始,到晚上把正式签名包提交应用市场审核,中间经过了一次选型、一次关键的架构验证、四轮真机调试和一次自查回退。

需求里藏着一票否决权

起点是一个朴素的想法:让这个纯离线的 Markdown 阅读器把正文读出来。

技术上有三条路可走:

路线长处致命处
用系统自带的朗读控件播放面板、后台播报、通知栏控制、多音色,整套外壳系统全给没有任何音频导出接口——合成与播放全在系统侧,永远拿不到文件
直接调用系统语音合成引擎零额外权限、能拿到原始音频流播放界面、后台、通知栏全部要自研
让系统助手反向拉起应用交互自然需报备意图定义,收益与成本不匹配

需求里有一句"要能保存成音频文件",这一条具有一票否决权——直接把第一条路砍掉。最终选了第二条:自己合成、自己收流,外壳自己造。

过程中顺手纠正了两个想当然的认知:

  • "调用系统助手朗读"这个说法不成立。 应用没有办法唤起另一个 App 替自己朗读;能用的只是系统级的语音引擎(音色同源)。这不是降级,只是把边界说清楚。
  • "某条路线音质更好"是伪命题。 两者用的是同一个引擎、同一套音色。"听起来更好"的真正来源是"在线音色 vs 本地音色",而本工程所用的 SDK 版本里在线模式尚不支持——所以离线是唯一选项,也是最好的选项:飞行模式下照读,还多换回了"零网络"这条卖点。

先花二十行代码,把整个架构的前提验掉

这条路能走通的前提只有一个:合成的时候,能不能真的拿到那串原始音频数据。

这件事没有任何文档能给你确定答案——它取决于具体 SDK 版本上的行为。如果等整功能写完才发现拿不到,等于全部推倒。

做法是:写一个最小 Demo,跑在真机上,用日志看结果。结论是稳定收到 26 个数据块、约 289 KB 音频,格式与预期一致。前提成立,架构定稿:

一次解析 → 生成朗读稿并按语义分篇 → 逐句合成 → 同一条音频流三个出口:边收边播(熄屏可听)、边收边编码、进度按字符权重回推。

这次验证的价值在于:它把"要不要做"变成了"怎么做"。 架构级假设,永远值得先用最小实验验掉,而不是等整功能做完。

真机是唯一的裁判

代码写完之后,问题才真正开始。以下四个缺陷,有且只有在真机上、用真实长文跑完整流程才会暴露:

其一:第二次播放没声音,保存卡在 70%。

两个毛病同源。播放器停止的时候把底层音频渲染器释放了并置空,但续用时没有重建。后果一分二:重新开始时判定"没有渲染器"直接返回——于是没声音;数据队列没人消费——于是不断堆积,程序认为"还没放完","完成"信号永远不触发,保存流程就永远收不了尾。

修法:抽出一个"确保渲染器存在"的动作,初始化与开始前都先调用它。

其二:保存走到一半就不动了。

保存走的是"只合成、不出声"的模式。但这一模式从不启动播放器,而合成完成的回调恰好挂在播放链路上——于是"完成"信号再次永不触发,按钮永远显示"保存中"。

修法:加一个看门狗兜底——持续盯着"收到的数据还在不在增长",一段时间没有新增就主动收尾。无论上层回调是否可靠,保存都能结束。这条经验很通用:当完成信号依赖一条可能不发生的回调时,必须有一个基于事实(数据真的不再增长)的兜底判据。

其三:长文保存必然卡死。

前面两个修完,短稿正常了,长文依旧。根因是整篇一次性提交,超过引擎单次合成的字数上限后静默挂起:不报错、不再吐数据、也不触发完成回调。

修法从结构上解决:把朗读稿按句切分、逐句合成、顺序推进,每次都在可靠的回调上推进下一句;看门狗同步改为"字节是否继续增长"。单词级的状态机取代了整篇提交,这类问题就再也不会出现。

其四:进度条不动、按钮不知道何时复位。

进度读的是一个普通方法和几个私有变量——UI 框架不会把这些登记为渲染依赖,于是进度显示永远停在初始值。改为显式响应式状态、定时重算,并在合成完成时补满到 100%。顺带把进度做得可读:百分比保留两位小数,再加"第 N / M 句",让人一眼看出节奏和余量。

一个框架级陷阱:getter 不是依赖

这是今天最值得记下来的一个 bug。

现象是:播放开始后,灰色遮罩还在、标题没有切换;但同一行里的按钮文字、状态文字、语速禁用,全部刷新正常。

矛盾就在"同一批状态、有的刷新有的不刷新"这个细节里。真凶是:"是否处于播放态"被写成了一个 getter。

UI 框架的自动依赖追踪,只认你在构建函数里直接读到的状态。把它包进 getter 或方法里读,就读断了这条依赖链——getter 会一直返回挂载那一刻的旧值。

所以"依赖它的那几行"全都不刷新,而"在构建函数里内联判断的那几行"正常——正好互证。

修法:改成显式状态字段,在状态回调里赋值。

教训:「框架会自动追踪状态」这句话有前提——它追踪的是渲染路径上的直接读取。任何封装(getter、方法、工具函数)都会让它失效。凡是要驱动 UI 的派生值,老老实实存成状态。

边听边看:与其拦截点击,不如不渲染

需求是"播放语音时也能滚动正文"。第一版的做法是给全屏容器设置"不参与命中测试",再在下面垫一层禁用的占位层。

结果失效。原因有两条,都很典型:

  • 撑满屏幕的占位层仍然在参与命中测试,"放行"只放行了一层;
  • 而且这类命中属性是挂载期生效的,动态切换并不可靠。

第二版换成确定性做法:播放态干脆不渲染任何遮罩。正文上方没有任何覆盖节点,滚动必然可用。不去和框架的命中语义博弈,直接把那个会产生歧义的节点从这个状态里删掉。

再往前一步,把布局从"浮层"改成"上下分栏":主内容区自动占据剩余高度,朗读卡片占底部的自然高度,由布局引擎算——零测量、自适应横竖屏与字体缩放,滚到文末最后一行也完整可见。这就是把"盖在上面"改成"让出位置"。

反直觉的一次实测:它是真并行

语音按句切分之后,很自然会想到"能不能多句并行合成,更快"。

按常识预判,系统级语音引擎大概率是串行的,并行只会互相争抢。但这个判断不该靠猜——于是做了一个探针,在同一台真机上跑 20 句,分别测"单引擎串行"与"双引擎并行"。

实测结果推翻了预判:

单引擎串行  3861 ms
双引擎并行  2265 ms   ratio = 0.587   overlap = true

两个引擎的时间区间真实交叠,双引擎提速约 1.7 倍。既然有实打实的收益,就按此落地合成池。

顺带澄清了"要不要上 10 路并行":不必,也不该。系统层面的并发上限本就有限(约 3 路),而真正的瓶颈在合成、不在编码——把编码并行的收益上限算出来不到两成,为此把架构复杂化并不划算。找到瓶颈,比堆并发数重要。

"存出来的是 WAV,不是 MP3"

导出最初只能给 WAV。因为本工程所用的 SDK 版本里,媒体框架没有音频编码器和封装器——能播 MP3,但没有接口把原始音频压成 MP3。

绕路也想过(把应用自己的播放声音录下来),但三条都是死路:要么需要新增采集类敏感权限;要么只能亮屏录(与"熄屏听"的卖点直接冲突);最要命的是播放流可能被系统的隐私保护标记为"不可录制",录出来是整段静音,而且应用侧无法检测、无法干预。这条路与产品定位冲突,直接否掉。

最终方案干净得多:内置一个纯 TypeScript 的 MP3 编码器,边收流、边编码、边分片落盘。

  • 零权限、零网络、零原生依赖,全平台可播放;
  • 必须流式分片:一部长文对应几十兆原始数据,攒在内存里必然被系统回收;
  • 若某台设备缺少编码能力,降级为 WAV——同样是标准格式,全平台能播。

于是"语音导出"这条需求,最终不付出任何权限成本。权限只为"熄屏后台播放"付出——这是产品文案必须同版本修正的原因:卖点从"零权限"调整为「不联网 · 不收集 · 熄屏播放 · 语音导出」,把真实能力说清楚,比守一个已经破了的招牌更重要。

提审,以及"你怎么证明设备上那个就是新包"

功能收口后出正式签名包,提交应用市场审核。流程本身由自动化接管:上传软件包 → 新建版本 → 选取新包 → 填"新版本特性" → 保存 → 提交审核。

提交之后做了一次自查,发现正式包里还残留着一个内部调试用的"探针"菜单——仅一行代码,用户看不见,但性质上属于调试残留进了生产包。处置很明确:撤回审核 → 摘掉探针与配套的命令行入口 → 重新编译 → 重新提交。已经进入审核队列的包,要么干净,要么不动。

重新提交后做了一次"硬证明":把控制台上的包下载回来,与本地构建件做 MD5 逐字节比对——完全一致;再解包检索,确认"探针"相关字样命中数为 0。

这一天在真机验收上还留下两条通用纪律:

  1. "安装命令返回成功"不等于"设备上的包真的换了"。 实测出现过命令报成功、但设备上仍是旧包的情况。可靠判据是包内的构建标记(每次构建打一个独特标签,启动时打日志、并在界面上角标显示)——以设备上跑着的那个包自报的标签为准。
  2. "变暗 / 变亮"这类视觉结论,要用像素数值判定,不要靠肉眼。 同一张截图,肉眼觉得"遮罩没了",量一下主区域平均亮度才发现在 117 与 232 之间——遮罩到底在不在,数字说了算。

二、扫码平台:从设计文档到上线

同一天里,另一个平台从一份设计文档送到了生产环境:三轮设计评审、一次编码、端到端测试 19 项全过、管理后台推倒重做了一遍登录方式。

最有价值的一个决策:二维码不该照抄文档分享的规则

文档分享服务用一个由内容哈希算出的标识符寻址——内容变则标识符变。这对文档完全合理:改过的文档就是另一份文档。

最初的设计想把这个规则原样搬到二维码上。评审时发现里面有个致命错位:

文档的"内容"就是实体本身;而二维码的实体是那张印出来、贴出去的纸,配置只是它的参数。

按内容寻址,运营改一次标题,墙上、群里所有已经发出去的码全部失效——这在业务上是荒谬的。

最终把标识符从"内容地址"降级为"创建时指纹,写入即冻结":创建时算一次用于去重,之后配置随便改,标识符不变。只借算法,不借语义。

围绕这个决策,一连串细节跟着定了下来:

问题处理方式
一次性码配置完全相同 → 算出同一个标识符配置里注入随机串,标识符随之不可预测
扫码地址用什么形式用路径而不是井号锚点——锚点不会发往服务端,服务端拿不到标识符就没法做网页授权跳转
配置既然可改,怎么防止改坏分字段:身份类字段冻结,改了直接拒绝;运营类字段可改
改过了怎么追溯每次修改写一条修订记录(改前、改后、操作人)
已开始的会话受不受影响不受影响——会话建了配置快照
有效期怎么算分四层:码本身寿命 → 单次确认窗口 → 取凭证窗口 → 登录态时长。码挂 1 小时不等于会话敞 1 小时

编码、上线,与最后那个"进不去后台"的问题

按终稿编码后部署到生产,联调阶段修掉 4 个缺陷。其中一个很典型:元数据编辑接口写修订记录时用错了列名——功能测试覆盖不到,一上线用户点"保存"就直接报错。这类错误靠肉眼评审很难发现,靠端到端测试一跑就现形。

上线前最后一个问题来自使用侧:管理后台原本走的是"微信网页授权",而网页授权只能在微信内完成——在电脑浏览器上打开后台,永远停在"请用微信打开"。

改法是让后台用自家平台的能力解决自家的问题:电脑端出登录码,手机扫码确认,电脑端拿凭证进入后台。第一个扫码的人自动成为超级管理员,之后非管理员扫码一律拒绝。


三、三个"真跑才暴露"的坑

这三个问题的共同点是:代码评审看不出来、单元测试测不出来,只有把页面在真实环境里渲染出来,才会发现不对。

坑 1:安全清洗误杀了自己人

现象:文章里的流程图、类图、状态图只有方框和连线,一个字都没有。

排查:把线上页面在无头浏览器里真实渲染一遍,逐张统计图形里的文本节点——8 张图里 5 张文本数为 0。差异不在写法,在渲染器输出标签的方式:正常的图把文字放在原生文本节点里;出问题的那几类图,把文字包在一个 HTML 容器标签里。

根因:这个容器标签正好在"安全清洗"的删除名单上。清洗逻辑是为用户手写的内容设计的,却被无差别地也套到了渲染器自己生成的产物上——于是把自己人一起干掉了。

还试过用渲染器自身的配置项绕开,实测四种配置下标签数量一个不差——只能改清洗逻辑,不能靠配置规避。

修法:清洗规则按来源分档。渲染器产物用一份"放行该容器标签"的名单;用户手写内容仍走完整名单,脚本、事件属性、危险协议一律删除,并跑攻击样本回归。

教训:「安全清洗」与「受信内容」必须分档。一套规则打天下,不是过严就是过松。

坑 2:真正让图形"没有文字"的,是坐标算少了

坑 1 修完后复验,反馈是"问题好像没有解决"。这一轮改成逐块截图 + 逐一测量元素的实际包围盒与画布视口,才找到真身。

  • 症状 A:整排节点被裁到画布外。 一张类图有 4 个节点,其中 3 个的纵坐标落在视口之外——方框只露一角,文字完全看不见。一句话判据:元素实际包围盒明显大于画布视口。成因是画布尺寸来自渲染器自己的测量阶段,而容器里的文字高度继承了页面的行高设置——站点的行高比测量环境的默认值大,于是"测量时够用、渲染时不够用"。
  • 症状 B:所有图形被压成 300 像素宽。 样式表给图形容器设了弹性布局,导致内部矢量图的宽度百分比解析失败,回退到替换元素默认宽度 300 像素。一个自然宽度近 1500 像素的甘特图被压到 300,文字小到看不清。页面容器明明是 750 像素,9 张图清一色 299.98 像素——这个数字本身就是证据。

修法:① 图形进入渲染树之后重新测量并修正画布视口(必须等它真的被布局,否则测出来是 0),同步修正最大宽度;② 容器改块级居中,让矢量图按自身尺寸显示。改完线上 10 张图全部通过。

方法上的两个收获:部署前先预演——用浏览器调试协议拦截线上请求,把响应替换成本地尚未部署的新版本,先看真实效果再上线;图形块是懒加载的——诊断脚本必须边滚动边等待,否则只水合了视口内的前几块,这一轮一度只渲染出 5/10,差点误判成"修复无效"。

坑 3:共享资源没有内容指纹

现象:改了线上脚本,用户反馈"清缓存才生效",甚至强制刷新都没用。

排查(先证明链路清白,再找真凶):无条件请求返回新内容、条件请求返回 304,服务端行为正确;探针文件实测"缓存 A、服务器改成 B",一次强制刷新即拿到 B,强刷机制有效;无 Service Worker。三条都排除后,问题锁定在"引用点没有指纹"这条链上:官网静态资源引用都带内容指纹,但共享目录下的两个文件不在构建号的覆盖范围内——引用是裸链接,构建脚本的匹配规则只认官网路径。

关键在于一个容易被忽略的规律:

缓存条目的有效期,由"当初缓存它的那一刻"服务器给出的响应头决定。

这个目录在某个时间点之前命中的是"通用 30 天过期"策略。服务端后来改成了不缓存,但浏览器在旧条目到期前根本不会发起请求,也就无从发现策略已变——这就是"清缓存才管用"的全部原因。

修法(三处,缺一不可):引用改走带构建号的地址;把共享文件纳入全局构建号计算(否则"只改内核、没动官网文件"时构建号不变,等于没修);分享服务端补齐内容哈希。

顺带揪出一个静默缺陷:站点脚本由两个独立作用域块组成,第二块里访问不到第一块内部的变量,抛出的异常被 catch 吞成了警告——页面静默不渲染,控制台里一条错误都没有。第一轮修复正是死在这里。教训直白:动态加载的 catch 里必须把原始错误打出来,「没报错」不等于「没失败」。


四、开源:两套系统,两个平台

今天把扫码平台与消息推送中心两套系统在 GitCode 与 Gitee 双平台建仓公开,采用 Apache-2.0 协议。

开源不是把代码复制出去,而是先做去耦合重构:切断对同机其他项目的路径依赖,改成各自内置配置、全程走环境变量;把写死在存储层里的事件分支抽成可注册的适配器;前端接口地址全部可配;静态页服务加白名单防路径穿越。

然后是固定的发布流程:跑敏感信息扫描(密钥、内网地址、个人信息等 15 类),确认零命中;再对匿名克隆下来的实际公开内容复扫一遍(98 个文本文件,0 命中);最后核对公开性。目前开源清单累计已有 25 个在线仓库。

这里踩过一个值得记的坑:推送成功不等于仓库公开。有一次脚本报告全部成功,但仓库实际仍是私有——原因是建仓后紧跟着的几次可见性修改请求全部失败,而脚本没有把它当成失败。现在匿名可访问被列为独立的验收项,不再依赖"推送成功"这种间接信号。


五、让"完成"有回执

自动化任务跑完了,人却不知道——今天把这条链路接通了:任务结束时自动取本轮摘要,经签名后推送到微信。

排查中暴露了两类"静默失败",都很有代表性:

其一:全局开关不等于个人订阅。 系统里"事件开关"和"个人订阅"是两套数据。把事件开关打开、却没有任何人订阅,事件来了投递 0 条——而接口照样返回成功,日志里连一条投递记录都没有。操作者会以为自己开了,实际什么都没发生。修法:打开开关时自动为操作者订阅,并把这个动作明确反馈到界面上。

其二:一个不该改权限的动作改了权限。 一次例行操作里,某个回调分支用"无差别覆盖"的方式写用户记录,把管理员的角色冲成了普通用户;接着后台判定"你不是管理员",将其降级为待审核——此后每次登录都走降级分支,形成永久锁死,没有任何自恢复路径。修法两条:订阅这类动作绝不碰权限属性;再加一道护栏——拒绝停用最后一个有效管理员,从机制上杜绝锁死。


六、自动化:每天早上的情报,与每晚的巡检

今天起,两条常驻自动化任务都扩了边界。

每天清晨的情报任务做成三阶段一体化:原料采集(应用商店榜单 + 开源平台榜单)→ 分析产出 → 发布后直接挂到官网的"进展记录"栏目。这一版的关键修正是:情报素材本身来自公开信息,采完即挂,不必再单独走一次脱敏流程(涉密内容仍按原红线处理)。于是"早上读到的行业信号"和"官网上能看到的记录"是同一条链路的产物,中间不再需要人工搬运。

每晚的巡检任务新增了开源榜单产出:核对当天榜单文件是否齐全、读取 star 快照基线、报增速榜头部与采集缺口。

关于榜单有一条方法论值得写下来:star 数不能跨平台比大小(不同平台含导入计数,口径不同);而且总量榜几乎没有信息量,上升榜才是信号。所以采集之外还留了一份每日快照,用差值来算"谁在真正往上走"。

今天情报里值得记一笔的三条:国家级机构把智能体的评测对象从"模型"改成了"Agent 产品",同一模型在不同产品上差异甚至超过模型底座本身;重庆交通委发布 AI 场景榜单并附自评分表、一周内评审;微信小游戏的分成让利政策落地成了具体金额。


七、运维与设备

每天凌晨两点,这台机器会自己重启一次,三点左右完成开机自检——服务、端口、进程、远端可达逐项打日志。今天照常执行。这类"无人值守的自我维护"最容易被忽略,但它是后面所有自动化的地基。

顺带给一台鸿蒙手机做了只读体检:采集内存与交换、存储、进程占用 TOP、应用清单与后台存活情况。结论是无泄漏、CPU 基本空闲,但发现自己的阅读器在后台驻留时占用偏高(主进程加渲染子进程合计约 430 MB,后台 15 分钟累计 CPU 25 秒)——已列入下一版的优化项。自己做的工具,自己先量一遍,比等用户投诉要划算。


收尾:今天沉淀下来的几条

  1. 架构级假设,先用最小实验验掉。 "能不能拿到原始音频流"这一个问题,决定整个功能成立与否——二十行代码 + 一次真机日志就能给答案,不值得赌。
  2. 完成信号依赖回调时,必须有基于事实的兜底。 判断"数据是否还在增长",比信任"完成回调一定会来"可靠得多。
  3. 框架的自动依赖,只认渲染路径上的直接读取。 getter、方法、工具函数都会读断依赖链——要驱动 UI 的值,就老老实实存成状态。
  4. 别和框架的语义博弈,把有歧义的节点删掉。 与其让遮罩"不拦截点击",不如在播放态根本不渲染它。
  5. "成功"是间接信号,"结果"才是判据。 安装成功不等于包换了、推送成功不等于仓库公开——验证要落在最终事实上(包内构建标记、匿名可访问、下载回包做校验)。
  6. 缓存问题问"当初",不问"现在"。 浏览器发不发请求,取决于它手上那份缓存条目,而那份条目的寿命在缓存那一刻就定死了。

附:今天的时间都花在哪了——将近一半在阅读器的语音功能上(选型、验证、四轮真机调试、提审自查),四分之一在扫码平台的设计评审与上线,剩下的分给官网渲染排障、开源与通知链路。

一天下来最实在的感受是:真正难的从来不是"写出来",而是"在真实环境里跑对"。 上面这些坑,没有一个能在代码评审里看出来。


← 返回文章列表