搜狗输入法双端界面统一之后,伴随AI能力接入越来越频繁,我们遇到一个新的工程化问题:业务侧需求文档写得飞快,客户端落地却总是慢半拍——AI功能的服务端接口、字段含义、交互状态往往一周一小改、一月一大改,传统“先发版再热修”的模式根本追不上模型迭代的速度。整个移动客户端团队被拖进了“需求评审、排期、开发、联调、上线”的循环里,真正花在体验打磨和性能优化上的时间反而越来越少。
正是在这个背景下,我们开始认真探索 Spec coding 这套思路,并把它真正用到了Kuikly框架的生产链路上。所谓 Spec coding,简单说就是让“规格”成为需求、设计、客户端代码之间唯一可信的翻译层——我们把AI功能的行为定义成机器可解析的规格文件,再通过代码生成器和大模型辅助,把规格直接变成Kuikly层面可运行的业务模块。这套机制跑通之后,单功能从需求确认到客户端联调通过的平均周期明显缩短,同时跨端行为不一致的问题也少了很多。这篇文章把我从方案选型、spec设计、生成链路搭建到落地踩坑的全过程拆开讲一遍,希望给同样在折腾“AI能力客户端化”和“跨端统一”的团队一点参考。
1. 项目背景与整体设计思路拆解
1.1 多端输入法客户端的工程困境
搜狗输入法客户端不是一个小体量的App,Android端、iOS端、甚至部分内嵌场景都在跑。传统做法里,一个AI新功能上线,客户端这边要经历这样一轮流程:算法团队先给出一份接口文档,客户端同学按照文档各自在两端写请求逻辑、写弹窗逻辑、写状态管理,Design侧还可能中途调整交互文案。三个端并行开发的结果往往是“三个端三套代码、三个端三种行为”,最典型的就是同一个词条推荐功能,iOS端下拉刷新的时机和Android端差了两帧,用户那边感受就是“这个手机输入法怎么一会儿灵一会儿钝”。
更深层的问题在于,输入法客户端的很多AI功能,本质上是“云端模型结果在端上的呈现”。模型迭代快,接口字段随时可能加一个“置信度阈值”,交互层可能加一种“轻提示样式”,按照传统开发方式,每一次变更都要走一遍完整客户端发版流程,排期紧凑的时候根本做不完。我们需要的不是让开发同学写更快的代码,而是让“从规格到代码”的转换过程本身变快。
1.2 为什么是Kuikly
选Kuikly不是拍脑袋。当时我们也在对比其他跨端方案,有一个很现实的条件——输入法里面的键盘弹层、候选词列表、设置页这些场景对UI性能和原生手感要求极高,纯WebView方案在按键延迟上过不了关;普通的动态化方案在双端原生能力调用上又不够顺。Kuikly这套基于类Kotlin语法的多端框架吸引我们的点在于:它可以把UI描述和业务逻辑编译到双端原生控件层级,接近原生性能,同时对外暴露了比较规整的生命周期和组件化边界,适合我们把输入法主流程之外的功能模块逐步迁移进去。
另外一个理由是增量迁移。输入法这种体量的工程不可能推倒重来,我们需要的是一个能“先旁路接入、再逐步替换”的框架。Kuikly的容器能力允许我们在原生工程里嵌入选定页面,和既有代码共存,灰度范围可以精确到用户群。这种机制让我们在早期验证spec coding的时候,不需要等整个输入法客户端改造完,可以先把一两个AI功能用新链路做出来,放进线上做对比测试。
1.3 Spec coding是什么,为什么选它
Spec coding这个概念,字面意思是用规格来驱动编码。它不是让机器完全替代人写代码,而是先定义一份足够精确的“功能规格”——包含交互状态、数据接口、组件结构、事件流转——然后让生成器和大模型在这份规格的约束下产出代码。类比一下,传统开发像是让不同厨师各自按感觉做同一道菜,成品总会有微妙差异;Spec coding是先写一份“精确到克数”的菜谱,厨师按谱执行,出品就能稳定。
我们最终决定走这条路线,直接原因是AI功能的需求变更频率和客户端发版节奏之间的矛盾。算法侧的接口字段会变,但“输入法里一个候选词条组件应该长什么样、点击后应该发什么事件”这件事是相对稳定的。这部分稳定结构就是spec的天然素材。与其每次让客户端同学去翻接口文档、猜交互细节,不如把稳定的部分固化成规格模板,把易变的部分通过字段映射注入模板,生成出来的代码天然具备一致性和可追踪性。
2. 核心机制解析:Spec coding的关键设计与落地要点
2.1 Spec文件的定义规范——从“人话”到“机器可解析的规格”
这是整个方案里最需要花心思的部分。我们最初踩过一个坑:把spec写成了“给人看的Word文档”,字段描述全靠自然语言,生成器根本无法稳定解析。后来调整为“YAML结构 + 枚举约束 + 补充说明”三段式,才真正跑通。
一份spec文件,核心包含以下区块:
meta:功能名、模块ID、版本号、归属产品线,用于生成代码里的包名、路由注册Key和日志埋点前缀。api:接口地址、请求参数列表、响应字段映射,每个字段都必须声明类型和是否可空,直接对应客户端网络层的请求Bean。ui:页面或组件的层级结构,我们限制了可选类型,比如candidate_list、toolbar、popup_card、toast,不允许自由描述UI形态,避免生成器发挥不可控。action:用户触发的事件集合,比如click、long_press、swipe,每个事件必须指定对应的方法名。state:模块内部状态机定义,比如加载中、成功、空数据、失败,状态之间的跳转关系也在YAML里声明。
从交付规范上讲,我们要求产品经理在提需求时先填写一份模板化的spec草稿,算法侧补充接口字段,客户端同学校对UI结构和交互细节。整个过程看起来比以前多了一步,但实际上是把原本大家口头对齐的信息沉淀成了结构化资产——后期算法加字段,只看spec diff就能预判客户端的影响范围。
2.2 基于Spec的代码生成链路
spec文件本身不会自动变成App里的功能,中间需要一套生成链路。我们的生成链路分两层:一层是规则引擎,负责把spec里的稳定结构映射到Kuikly的UI组件和方法签名;另一层是大模型辅助生成,负责填充样板代码里比较灵活的交互细节和业务注释。
拿一个“emoji智能推荐”功能举例。规则引擎拿到spec的ui段,看到candidate_list类型,就会自动选择Kuikly预设的LazyColumn模板,并根据api段的字段定义创建对应的数据类和请求方法。大模型这一层,则负责生成网络回调后的状态流转代码,比如“成功时更新列表数据并关闭loading,失败时展示轻提示并保留上次数据”——这类逻辑如果让规则引擎硬编码,模板会膨胀得不可维护,而让大模型按spec约束生成,再用一小段单测做切入校验,是比较务实的折中。
生成完成后,所有代码不是直接合入主工程,而是先进入一个“生成产物暂存区”。该目录结构如下:
ai_features/ ├── emoji_smart_reply/ │ ├── spec.yaml │ ├── generated/ │ │ ├── EmojiReplyRouter.kt │ │ ├── EmojiReplyViewModel.kt │ │ └── EmojiReplyPage.kt │ └── patches/ │ └── manual_fix_001.diff └── next_word_assistant/ ├── spec.yaml └── generated/patches目录非常重要。手动修复补丁和生成代码分开管理,重新生成时自动尝试将补丁重新应用到新代码上,解决“改完代码、重跑生成又被覆盖”的经典痛点。
2.3 人工兜底与半自动闭环
我们必须对任何“看起来全自动”的方案保持清醒。Spec coding在生产环境里真正可行的姿态是“半自动闭环”,而不是“一键生成就能上线”。
人工兜底的点有三个:第一,生成代码的Code Review是强制的,我们专门约定生成代码里不允许包含任何业务魔法值,所有常量必须从spec或配置中心读取,否则review直接打回;第二,自动化生成的单测框架只验证“输入输出契约”,不验证真实服务器行为,所以在测试环境跑全链路联调仍然不可或缺;第三,spec变更后,生成的代码如果和手写补丁发生冲突,一律采用“手动合并后重跑测试”的方式,避免自动冲突解决引入隐藏缺陷。
这套闭环设计下来,实际开发中的分歧点反而少了很多——因为大家争论的不再是“我觉得这里应该这样写”,而是“spec里这一段的定义是否有歧义”,后者是可以通过讨论快速收敛的。
3. 实操过程:从Spec到可发布代码的完整流程
3.1 Spec仓库与版本管理
我们选择把所有spec文件放在独立仓库,和客户端主仓库分开维护。原因很简单:spec的变更频率与代码不完全同步,它经常在需求评审阶段就已经更新了,如果和代码放一起,容易被还没实现的代码逻辑阻塞。独立仓库的好处是,产品、算法、客户端都能直接提PR,diff清晰。
仓库目录按功能域组织,每个功能域下是一份或多份spec文件。命名规则是“模块名_版本号.yaml”,版本号跟随需求迭代递增。客户端构建时,CI拉取最新的spec仓库,和主仓库代码一起参与构建,构建日志里记录使用的spec commit号,出现问题可以直接回溯是“哪一版本的spec导致的行为变化”。
3.2 三段式生成流程拆解
我们的生成器不是一个大而全的monolith,而是拆成三段,每段有独立的输入输出,方便单独调试和替换组件。三段分别是:规格解析段、中间表示转换段、代码渲染段。
规格解析段负责把YAML读进来,做完整性校验。比如api段里的字段类型不在白名单里,直接抛错;action段没有对应ui组件,也会提示“死事件”。校验不通过不进入下一段,从一开始就拦住低级错误。
中间表示转换段是核心。我们定义了一套内部IR,把spec里的ui、state、action统一转换成Kuikly框架能识别的组件树描述。这套描述不直接是代码,而是一棵树,树节点上挂着数据绑定和事件绑定信息。
代码渲染段则相对机械,根据IR树套用预先写好的模板文件,生成最终Kotlin代码。模板文件我们维护在生成器仓库里,一旦Kuikly框架升级导致API变化,只改模板文件,不需要动前面两段逻辑。
整个生成过程用命令行工具触发,一条命令处理整个功能目录,日志输出到JSON文件,方便CI解析。
kgen gen ./specs/emoji_smart_reply.yaml --out ./generated/ --log ./gen_log.json3.3 端侧接入与联调验证
生成的代码不是结束,接入才是真正见真章的地方。我们在Kuikly工程里加了一个自动注册机制——生成器输出的Router.kt文件里会通过注解标记路由,组件初始化时扫描这些注解,自动注册到导航表。也就是说,一个新AI功能只要spec定义齐全、生成成功,端侧几乎不需要额外的“接线代码”。
联调阶段的验证顺序也很有讲究。正确顺序是:先用本地MockServer验证UI状态流转,再做服务端联调。Mock验证阶段重点看spec里定义的状态是否都被触发了:loading态有没有一闪而过、失败态有没有展示、空数据态有没有兜底。
我们曾犯过一个错误——跳过Mock直接联调真实服务,结果发现一个异常分支在线上环境难以稳定复现,排查了大半天才定位到是UI状态没切回来。从那以后,Mock阶段被列为必选项,单测里也必须覆盖每个状态的出现与消失,减少对真实环境的依赖。
4. 落地过程中的常见问题与排查记录
4.1 生成代码风格漂移问题
大模型辅助生成最大的副作用是代码风格不可控。同一个toast提示,上一段生成的代码可能用showToast方法,下一段可能用了displayMessage,本身功能没问题,但review时看得人崩溃。
后来我们把风格约束写进了生成提示词和模板两层。模板层固定了骨架结构,比如方法命名、参数顺序、注释格式;大模型只负责填充两个骨架之间的“肉”,并强制要求不允许新增工具方法。同时CI里加了一个简单的静态扫描规则,检测生成代码是否调用了模板白名单之外的UI方法,违规直接构建失败。这套组合拳打下来,代码风格基本稳定在可接受范围。
4.2 端侧行为差异与兼容性
Kuikly本身在双端表现已经比较统一,但真正接入输入法场景后,我们发现了几个框架层没覆盖到的细节。最典型的是输入法软键盘弹出时机——同一段状态切换代码在Android端正常,在iOS端会先收键盘再刷新列表,体验很割裂。
这类问题不能靠改spec解决,需要在模板层做平台适配。我们的处理方式是,在IR树里增加一个platform_hint节点,生成代码时根据Node的取值自动插入平台分支。虽然代码里多了一些分支,但胜在改动是受控的,且每个分支都对应着线上真实反馈的问题,不搞“预见性兼容”那套浪费行为。
4.3 调试链路与日志追踪
spec生成的代码和手写代码混在一起之后,一个绕不开的问题是:出Bug时怎么快速判断“是spec问题、是生成器问题、还是手写补丁问题”。
我们目前的方案是三层标志:spec文件里每个区块带一个trace_id前缀,生成代码里所有日志自动带上这个前缀;手写补丁统一通过patches目录进入,文件名后缀区分补丁归属;CI构建时记录spec commit和生成器版本。这三层信息叠加,线上Bug一上报,先看日志前缀,再查构建记录,通常五分钟内能定位是哪一环节出的问题,省去了以前那种“代码里来回翻”的原始排查方式。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查路径 |
|---|---|---|
| 生成的代码里字段总是为null | spec中字段可空声明与接口实际返回不符 | 检查api段字段的required标记,对齐算法接口文档 |
| 同一操作点击后行为双端不一致 | 模板层缺少platform_hint定义 | 补IR节点并确认两端原生行为差异符合预期 |
| 重新生成后手写修改丢失 | patches补丁未正确记录到patches目录 | 查看patches文件状态,确认重新生成时补丁已应用 |
| 编译输出日志中看不到生成器版本 | CI未把kgen版本写入构建产物 | 更新构建脚本,强制写入kgen版本字符串 |
| UI出现模板之外的组件样式 | 大模型绕开了白名单直接生成自定义UI | 收紧静态扫描规则,生成代码中加入组件白名单校验 |
| 联调时Mock正常但线上异常 | 服务端真实字段值不在枚举白名单内 | 在spec的枚举声明中补充候选值,并通知算法侧收敛取值 |
排查这些问题的核心思路始终是“先定环节,再抠细节”。因为spec coding链路里环节多,每一环产生的错误特征并不一样,如果一上来就盯着代码本身,很容易进入盲区。
5. 个人实操后的经验沉淀
整个项目推进过程中,我比较大的体会是:Spec coding的收益不在“少写代码”,而在“代码变更之前先想清楚”。以前我们拿到需求文档就开始敲代码,写着写着才发现交互边界没对齐,现在必须先在spec里把状态、事件、字段全部定下来,虽然前期多花了半天时间,但后面联调和返工的时间省回来的远超投入。
另一个值得注意细节的是“模板比大模型重要”。第一版我们过分依赖大模型生成代码,输出结果散乱且不可控;后来把更多稳定性责任转移到模板和IR层,让大模型只在严格约束下发挥,整体可靠性才真正上来。一个可复用的建议是:凡是结构性强的逻辑,尽量用规则引擎和模板固化;凡是灵活性高的业务判断,才适合交给模型。
这个方案后续还可以扩展的方向有两个:一是把spec直接接入AB实验配置中心,让spec字段映射到实验参数,实现“客户端无需发版、线上调整AI功能策略”;二是把生成器从“生成代码”扩展到“生成联调Mock数据和服务端契约测试用例”,进一步把链路前推。这些我们已经在逐步试点,等跑出结果再找机会单独总结。