从一次 OOPIF Touch Bug 到 Chromium 合入:我的首次上游协作复盘
write by ai
这是一次完整的 Chromium 上游贡献记录:从一个跨 OOPIF 的触摸模拟问题开始,经过可复现 demo、Chromium Bug、Gerrit 评审、坐标契约重构、CQ 验证,最后由提交队列自动合入 main。最终 CL 是 Fix injected touch coordinates for OOPIF touch emulation,上游提交是 ec8beb01845b094c40f503eea848a0a7c7d4843c。
它不是“写完代码、发一个 PR”就结束的过程。对我而言,更重要的收获是:上游协作的核心并不是说服 reviewer 接受一个补丁,而是共同把问题的边界、可复现证据和设计契约解释清楚。
问题:OOPIF 中的 touch emulation 坐标错位
问题出现在 DevTools 注入触摸事件、页面包含 OOPIF(Out-Of-Process iframe)时。注入的触点本来以 root view 坐标表达;在 hit test 找到 OOPIF 后,事件会被路由到对应的 target view。错误路径在中间进行了不恰当的坐标往返,导致后续合成的 compatibility click 和 gesture 可能叠加 OOPIF offset,表现为点击/触摸位置错位。
一开始,这个现象很像“某个坐标转换少了或多了”。但 Chromium 的输入链路涉及 DevTools、输入路由、TouchEmulatorImpl、gesture synthesis、renderer dispatch 等多个组件。只在错误位置加一段逆转换,虽然可能让 demo 表面恢复正常,却不一定说明坐标语义正确。
因此最先要做的不是改代码,而是把问题变成任何人都能验证的事实。
先准备可复现证据
我将复杂的复现页面独立成了 GitHub 仓库:vimerzhao/oopif-touch-demo。这一步很关键:reviewer 不必从巨大的 Chromium checkout 中猜测页面结构,也不必依赖作者本地环境。
demo 的说明应该至少包含:
- 一键启动命令;
- 用官方 Chrome 可稳定复现的步骤;
- 官方 Chrome 的精确版本;
- 修复版 Chromium 的版本或 revision;
- 预期坐标和实际坐标;
- 失败截图与成功截图,放在同一位置并清晰命名;
- 为什么需要开启
SyntheticPointerActions。
其中 SyntheticPointerActions 不是这次新发明的开关,而是已有实验性能力。它让页面的输入路径能够触发本次要观察的 synthetic pointer 行为;没有它,demo 可能根本不会走到目标路径。把“为什么必须开它”写进 README,能避免 reviewer 把复现失败理解成问题本身不稳定。
之后创建 Chromium Bug,并在 Bug 中链接到固定版本的 demo、两张对比截图和可复制的复现命令。这样 Gerrit CL 里的 Bug: 不再是 None,评审者也获得了讨论设计时所需的上下文。
这里有一个很朴素的经验:截图并不是装饰材料。对于输入坐标 bug,截图、坐标日志和复现命令共同构成最短的证据链。截图只说“结果不对”,而精确版本与步骤说明“别人也能得到同一个结果”。
第一版修复与评审的开始
初始修复上传到 Gerrit 后,CL 经历了多个 patchset。review 早期出现的状态很典型:没有 Code-Review 投票、存在未解决评论、Code Owners 尚未批准、Review Enforcement 不满足。
这些都不是单一的“失败”。它们分别意味着:
- Code Review:负责代码正确性、设计与可维护性的 reviewer 尚未给出认可;
- Code Owners:改动文件所属目录需要相应 owner 的批准;
- Review Enforcement:项目规则认为当前审批链仍不完整;
- Unresolved comments:行级讨论还没有被作者和 reviewer 共同结束。
有位 reviewer 说他愿意做 input 侧的 review,但希望先补充更多 context,例如一个 Bug,以便理解待修问题。这并不等于他认为改动一定错误,而是指出了很实际的评审障碍:没有最小可复现环境和问题陈述,就很难判断坐标语义的改动是否合理。
这也是为什么先补齐 Bug 与 demo,比急着逐条“Done”评论更能推进评审。
Gerrit 里的 Done 不是“我回复了”
在 Gerrit 中,评论会形成 thread(讨论线程)。一个 thread 通常由某条行级评论、回复以及最终的 resolve/Done 状态组成。
我一度把“回复 done”与“关闭 thread”混在一起。实际上,文字回复 Done 只表示作者声称已经处理;点击评论 thread 的 Done 才是把该讨论标记为 resolved。更重要的是,是否应关闭不能只由作者决定:如果 reviewer 的意见属于设计讨论,应该先给出可验证的回应或新 patchset,等待 reviewer 确认,而不是把 thread 当作待办事项自行勾掉。
Arthur Sonzogni 的一句话很好地说明了这种协作边界:Please close this thread once he approved. 它的意思不是“现在点击 Done”,而是“等待 Kartar 对核心方案认可后,再关闭这个讨论线程”。
关键分歧:是否需要 root → target → root 的往返转换
Kartar Singh 的评论中,最值得认真对待的不是格式问题,而是对整个坐标设计的质疑:为什么 DevTools injected touch 要先从 root 坐标转到 target 坐标,随后又在 TouchEmulatorImpl::InjectTouchEvent 转回 root 坐标?能不能把前一次转换和后一次反向转换一起删除?
这个问题不能只回答“另一条 mouse 模拟 touch 的路径就是这样做的”。“历史上存在”不等于“当前专用路径必须复制”。需要先抽出坐标契约,再比较两条路径。
最终梳理出的契约是:
DevTools injected touch root-view 坐标
TouchEmulator / GestureProvider root-view 坐标
Router 内部路由 root-view 坐标
最终发往 OOPIF renderer target-view 坐标(仅在这里转换一次)
PositionInScreen 由 root view 计算的屏幕绝对坐标
此前的往返转换受到普通 mouse-to-touch emulation 路径的影响:共享 mouse dispatch 在 hit test 后已有 target 坐标,而 TouchEmulator 为 gesture synthesis 需要 root 坐标,所以该共享路径必须进行回转。DevTools injected-touch 却是一条专用路径,原始数据本来就是 root 坐标;复制 mouse 路径的坐标往返没有必要,也会让 offset 被错误处理。
因此接受了 reviewer 的简化建议:DevTools touch 从注入到 touch emulator 一直保持 root-view 坐标,只有输入事件路由器在最终发往 OOPIF renderer 时做一次 root-to-target 转换。PositionInScreen 同样从 root view 推导。
这次修改带来的不只是少两段转换代码,而是一个可被单测和后续维护者理解的边界:哪一层拥有哪种坐标,转换只能发生在哪里。
其他评论:小修改也要认真收口
评审还包含几条较直接的意见:
- root view 的 fallback 写法应更直观;
- 不再使用
last_mouse_move_root_view_的理由应写进 commit description; - 单测比较表达式应遵循 Chromium C++ 风格,写成
foo == 0,而不是0 == foo。
第一项最终写成先保存 root_view,再明确回退到 target 的形式。这样既处理了 target->GetRootView() 可能为空的情况,也让变量语义一目了然。
第三项看似只是风格。0 == foo 是为防止误写赋值而流行过的 “Yoda condition”;现代 C++ 编译器和静态检查已经能够有效发现这种错误,Chromium 选择自然的“被比较对象在左、常量在右”写法。我最初没有遵守这一点,是沿用了旧习惯且自检不够严谨;随后已在 patchset 中修正。
这些评论提醒我,代码审查并非只讨论算法。清晰的 fallback、完整的提交说明和一致的表达式风格,都降低了后来者理解正确设计的成本。
Patchset、回复与测试如何配合
比较顺畅的节奏是:
- 先分析评论是否指出了真实问题;
- 对需要变更的意见,修改代码并补充回归测试;
- 在 commit description 中记录关键设计取舍;
- 上传新的 patchset;
- 逐条用英文在 Gerrit 回复:说明具体改了什么、为什么这样改、相应测试覆盖什么;
- 不抢先关闭仍需 reviewer 确认的 thread。
在这次 CL 中,核心设计调整、可读性改动、提交说明补充和测试风格修改被组织进后续 patchset。回归测试验证了 root 坐标注入之后,touch emulator 与 gesture synthesis 保持同一坐标系,最终 renderer dispatch 才转换到 target 坐标。
本地完成相关 content_unittests 验证后,Gerrit 的 presubmit 也通过。最后的 commit description 明确记录了三个关键事实:历史路径的往返转换、修复后的单次转换,以及为什么 injected touch 不再依赖此前 mouse move 保存的 root view。
审批、Gold 与 CQ
在 patchset 10,Kartar Singh 给出 Code-Review +1 并触发 CQ dry run;dry run 通过后,Arthur Sonzogni 给出 Code-Review +1 和 Commit-Queue +2。
这里的 CQ(Commit Queue) 是 Chromium 的自动提交队列。它会将 CL 与最新主干组合,运行完整验证;通过后自动 rebase、提交到 main。+2 不是已经合入,而是授权 CQ 尝试合入。
CL 页面中的 Gold 信息也值得正确理解。例如 “5 new images on patchset 10” 表示该 patchset 的测试结果中有 5 张此前没有基线的图像;“3 untriaged” 表示其中有 3 项视觉结果尚未人工归类。它不自动等同于当前代码导致的失败,是否阻塞以 CQ 的最终状态和具体 builder 日志为准。
这次先是 dry run 成功,随后 full CQ run 启动。最终 Gerrit 记录为:
Change has been successfully rebased and submitted as
ec8beb01845b094c40f503eea848a0a7c7d4843c
CL 进入 MERGED,未解决评论归零。CQ 自动 rebase 后会产生新的上游 SHA,因此本地工作分支中的 commit SHA 与上游最终 SHA 不同是正常现象。
事后检查:为什么本地还看不到合入提交
Gerrit 已显示 MERGED 并不代表本地 clone 的 origin/main 已经更新。本地远端引用只是上次 fetch 时保存的快照;如果它仍停在旧 revision,就会误以为提交没有进入主干。
可用下面的命令同步并确认:
cd C:\Users\vimerzhao\work\chromium_research\src
git fetch origin
git merge-base --is-ancestor ec8beb01845b094c40f503eea848a0a7c7d4843c origin/main
命令返回成功即表示该提交已包含在本地更新后的 origin/main 历史中。Chromium 当前主线名为 main,而不是早期常见的 master。
小结:第一次上游提交学到的不是一条命令
这次贡献最终改动并不庞大,但把一次代码修改变成可合入的上游协作,至少需要完成以下几件事:
- 用独立 demo、版本、截图和 Bug 证明问题真实且可复现;
- 先建立坐标契约,再讨论某一行转换是否应删改;
- 将 reviewer 的设计意见当作重新验证假设的机会,而不只是待回复的评论;
- 把测试、commit description 与代码一起作为变更的一部分;
- 理解 Gerrit 的 thread、审批、Code Owners、Gold 和 CQ 各自解决什么问题;
- CQ 合入后,用
git fetch区分“上游已合入”和“本地引用尚未更新”。
最后,最有价值的并不是获得一个 MERGED 标记,而是形成一套可以迁移到下一次上游协作的工作方式:让证据先行,让语义清楚,让评审能够独立复现、质疑并最终相信这个改动。