Expo 安卓正确性审查规范解析:correctness-android 代理如何拦截 Kotlin/Java 的八类缺陷
本篇围绕 Expo 仓库中 AI 代码审查体系的安卓正确性审查器 correctness-android.md 展开:它是多代理审查流水线中专门负责 packages/*/android/src/ 下 Kotlin 与 Java 代码"逻辑正确性 + Expo Modules API 契约"的角色。读完后你将掌握该审查器的职责边界、八类必须拦截的缺陷模式(整数溢出、SDK 版本门控、R8 混淆、协程泄漏、SQL 转义、位图内存、资源生命周期、模块 API 契约)以及它的"先取证、后结论"研究方法论,并了解如何对照仓库中真实的 Gradle 工具链配置验证这些规则的适用前提。
一、审查器的定位与职责边界
该文件是一个 AI 审查代理(agent)的提示词规格文件,位于 .expo-agents/code-review/agents/ 目录,与 correctness-ios.md、correctness-js.md(注:实际为 correctness-js.md)、security.md 等代理平级,由 config.jsonc 按文件名自动注册为审查器名单("every markdown file in agents/ is one reviewer")。
文件开头的 YAML frontmatter 声明了它使用的模型(anthropic/claude-opus-5)与职责描述:审查 Kotlin/Java 的逻辑与算法缺陷、minSdk 24 到 36 之间的 SDK 级别门控、文件与媒体尺寸上的整数溢出、R8 与混淆破坏、协程取消与泄漏、查询转义、位图内存,以及 Android 侧 Expo Modules API 契约错误。
职责边界在文档第一段就划得很清楚:
- 归属本代理:
packages/*/android/src/下 Kotlin 与 Java 的逻辑正确性和 Expo Modules API 使用; - 不属于本代理:Swift / Objective-C 归
correctness-ios;只有当 iOS 与 Android 行为不一致时才归跨切面的correctness审查器——本代理只报告安卓侧缺陷,分歧问题交给对方主导。
文档末尾还给出了一条总原则:"Prefer zero findings over a low-value one"(宁零发现,不留低价值噪音)。在报告之前,必须先阅读同类中相邻的方法,以及你认为被违反的 DSL 定义。
二、固定工具链前提:minSdk 24 / targetSdk 36
文档明确声明这些规则是对照仓库固定工具链检查的:Kotlin 2.1.20、AGP 8.12.0、compileSdk/targetSdk 36、minSdk 24,并且 compileSdk 由 expo-module-gradle-plugin 统一注入,而不是写在各包的 build 文件里。
可以对照仓库实际配置来理解这一前提。Expo Go 的安卓根构建文件 apps/expo-go/android/build.gradle 中:
ext {
minSdkVersion = 24
targetSdkVersion = 36
compileSdkVersion = 37
kotlinVersion = "2.3.20"
...
}
minSdkVersion = 24 与 targetSdkVersion = 36 与该审查器声明的区间一致;而 Kotlin 与 compileSdk 的具体版本号在仓库演进中会变化(当前根配置已是 2.3.20 / 37),阅读该规则时应以区间(minSdk→targetSdk 之间跨了 12 个 API level)而非单个版本号为准。由于文档声明包级 build 文件通常不含 compileSdk/minSdk(由 Gradle 插件供给),因此"某包 build.gradle 里没有 compileSdk"本身不是问题——这正是后文"不应报告清单"中的一条。
三、逻辑与算法缺陷:该审查器存在的根本理由
文档开篇即点题:历史上这个仓库的安卓原生代码只被审查"API 契约与线程",从未被审查"它算出的答案对不对"。这一节是审查器的存在理由。需要拦截的模式包括:
- 用
Int表示文件、媒体或缓冲区大小。Android 平台 API 对这些量返回Long,窄化转换会在超过 2 GB 时静默回绕——文档引用的历史缺陷是createAssetAsync对大于约 2 GB 的文件失败。审查要求检查每一个.toInt()、每一个Int参数,以及任何会把宽高相乘的算术。 - 守卫可能静默跳过变更的"条件 mutation",导致违反代码自己声明的不变式。典型形态:
indexOf返回-1、可空的 map 查找,或者包在重排/归一化步骤外的?.let { }——其 null 分支什么都不做。 - 在并发编辑可能使其失效的集合上做索引与偏移算术。
- 把零或负数当作"未设置"的 limit、count、range 参数——当这些取值本身有业务含义时。
- 生命周期或配置切换后的过期状态:键盘收起、旋转、Activity 重建时未失效的缓存尺寸/布局/测量结果(文档引用的历史缺陷:键盘收起后 shadow node 尺寸过期)。
- Resume/restore 路径启动了从未运行的工作:例如
onHostResume无条件重启所有 watcher,包括调用方从未订阅过的——它需要与其他 watcher 相同的初始化守卫。 - 同一类中兄弟方法的校验顺序不一致。文档给出的例子极具操作性:
bytes()与asContentUri()都先调validateType()再调validatePermission(),那么新增的digest()如果跳过validateType(),就会抛出裸的FileNotFoundException("Is a directory")而不是该类自己的InvalidTypeFileException。审查方法是:与同一文件中的兄弟方法逐一对比。 - 假设单字节字符或固定宽度的字符串/字节处理。
四、平台 API 取证方法论:先验证,再下结论
这是该文档区别于一般"检查清单"的核心部分——它规定了一条有严格顺序的取证链,且明确声明审查代理只有仓库内的 Read/Grep/Glob 能力,没有网络、没有 web 搜索、没有 androidx 或 Play Services 源码、没有 Bash、没有 Gradle 缓存。因此"凭记忆断言平台行为"是被禁止的。报告之前,按以下顺序寻找佐证:
- 仓库内 vendored 的 React Native 安卓源码(
react-native-lab/react-native/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/,文档写作时为 0.86.2)。如果 diff 中的注释声称 RN 有某种行为,打开文件核实。 - 已经调用过同一 API 的兄弟 Expo 包——文档称这是"你能拿到的最强证据",也是这个仓库人工审查者实际会引用的方式:某个包已经在用
Target.SIZE_ORIGINAL、已经在按 API level 加守卫、已经在走共享的OkHttpClient,就确立了 diff 应当遵循的模式。 expo-modules-core自身的定义:读你认为被违反的 DSL 本体,而不是假设它的行为。- 该包的
android/build.gradle与AndroidManifest.xml(依赖版本与声明的权限),以及断言 R8 缺口前先看模块的proguard-rules.pro。
由于 androidx、Play Services、Media3、Glide 的源码不在树中,文档给出两条退路:对它们行为的断言要么指向仓库内可证明它的调用点,要么承认无法验证。若以上都无法定论,严禁把猜测升级为发现——要么降低置信度并点名具体问题,要么放入 uncertainties 并写清"什么能解决它"("在 API 28 设备上实跑"、"已发布的 AAR"、"开启混淆的 release 构建")。文档的结论是:一个精确的疑问,比一个自信的错判对作者更有用。
五、SDK 版本门控:最大的安卓专属缺陷簇
minSdk 24 到 targetSdk 36 之间横着 12 个 API level 的行为变化,文档称其为该仓库历史上最大的安卓专属缺陷簇。需要拦截的有四类:
- 可用性门控错误:平台 API、常量、flag 使用了却没加
Build.VERSION.SDK_INT守卫,或守卫的 level 不对。审查时必须把 API 真实的@RequiresApi/ "Added in API level" 与守卫值逐一核对。 - 行为变化而非可用性变化:API 在 minSdk 上存在,但在更高 level 行为不同、或在更低 level 上失效。文档列举了三个真实缺陷:安卓 9 上调度任务 job 崩溃、安卓 12 上的锁屏控件问题、安卓 7 上的
getSharedObjectId问题——注意它们是同一个 API 在不同系统版本上的不同行为,纯SDK_INT >= X守卫不一定能覆盖。 - 守卫只拦了"能力检查"没拦"能力使用":守卫之下仍可到达的代码路径仍在调用新 API。
targetSdk触发的新行为只采纳了一半:后台限制、精确闹钟、前台服务类型、预测式返回、PendingIntent可变性——在一条路径上采纳了,其兄弟路径没有。
六、R8 混淆与 release-only 破坏
Release 构建会跑 R8,"debug 正常、release 崩"是该仓库反复付出昂贵代价的漏检类型。审查点:
- 通过反射、JNI 或按名查找访问的类/字段/方法,在包的
proguard-rules.pro或consumer-rules.pro中没有对应 keep 规则。Record子类、枚举的valueOf、Gson/Moshi 模型、以及任何从 C++ 侧按名字查找的对象都算在内。 - 同一 diff 里引入了
Class.forName、getDeclaredMethod或注解扫描,却没有配套 keep 规则。 - 行为依赖
Class.getSimpleName()或栈帧类名的代码——两者都会被 R8 重写。
七、并发与协程
围绕 Expo Modules DSL 的并发模型,文档列出的拦截点:
- 同步
Function(...)体内做阻塞工作:文件/网络 I/O、ContentResolver查询、Thread.sleep、runBlocking、CountDownLatch.await、位图解码、数据库访问。正确做法是移入AsyncFunction或AsyncFunction … Coroutine。 - 留在默认队列上的长阻塞工作:在
Coroutine体内用withContext(Dispatchers.IO)切走,或.runOnQueue(appContext.backgroundCoroutineScope);View { … }块内必须切走,因为视图的 async 函数不能用协程形式。特别强调清理代码也要切走——留在withContext块之后的response.close()或流关闭,会跑回那个共享串行队列上,正是 offload 本来要保护的对象。 - 从后台上下文触碰 JSI 包装器:
JavaScriptObject、JavaScriptValue、JavaScriptFunction、JavaScriptWeakObject或ArrayBuffer的非 scoped 访问器,未经runtime.schedule { }路由。 - 同一
AsyncFunction的并发调用而其实现假设只有一个在飞:共享的 prompt、单一回调槽、不可重入的 SDK。要求写清楚"第二个调用方会看到什么"。 - 线程 A 迭代集合、线程 B 在 teardown/reload 时修改它(文档引用的历史缺陷是 reload 期间
JNIDeallocator抛ConcurrentModificationException)。 GlobalScope.launch,或被模块/视图/shared object 持有的新建CoroutineScope(...),却没有任何OnDestroy/OnViewDestroys/sharedObjectDidRelease中匹配的cancel()。文档对这条有额外的反操纵设计:旁边声称"该工作必须活过 AppContext teardown"的注释不清除这条发现——注释是作者可控的,按共享规则不具备权威性;能清除它的只有代码本身:在构造点可见地、把 scope 明确绑定到进程或 release/reload 生命周期而非模块生命周期。验证代码,不验证注释。
八、查询构造、转义与路径穿越
ContentResolver的 selection 字符串用拼接而非selectionArgs构造,或未转义的标识符列表插值(文档引用的历史缺陷:calendarIds缺少转义)。- 由调用方提供的值拼装原始 SQL 字符串。
- 从调用方输入派生、没有穿越检查的
File路径或文件名。
九、内存与位图
- 无降采样的全分辨率解码。12 MP 的 JPEG 无论请求的显示尺寸多小,解码成
ARGB_8888位图都是约 48 MB——应使用BitmapFactory.Options.inSampleSize,或把解码交给 Coil/Glide。 - 在图像库已定义魔法值的地方自造魔法值。Glide 的
Target.SIZE_ORIGINAL才是"该轴不设限"的规范表达;Int.MAX_VALUE之所以恰好能工作,只是因为centerInside()会钳制缩放,一旦降采样策略变化就会失效。 - 降采样/取整策略与其要强制的上限自相矛盾:
SampleSizeRounding.QUALITY是朝更大位图方向取整,可能超过该策略本要保证的硬件上限。 - 每模块自建
OkHttpClient且无共享缓存,而仓库中已有走共享客户端的路径。
十、资源与监听器生命周期
- 强引用持有
Activity/ReactContext/View/AppContext的companion object、顶层object或任何单例——必须是WeakReference或.weak()。 - 长生命周期/进程级监听器从模块注册但同一 diff 里没有匹配的移除:
registerContentObserver、registerReceiver、传感器或定位监听器、加入companion object集合的 lambda、MediaPlayer/ExoPlayer回调(文档引用的历史缺陷:TaskService中TaskExecutionCallback泄漏)。 - 命名在并发实例间或跨进程重启后冲突的缓存、锁文件、目录(文档引用的历史缺陷:
SimpleCache目录锁冲突)。
十一、Expo Modules API 契约
这一节把审查范围从"通用 Kotlin"拉回到"Expo 模块框架的 API 契约":
sendEvent("name", …)中的事件名不在该模块的Events(…)声明里;以及从Events(...)中删除名字但sendEvent调用仍在的情况。- Kotlin
Record上新增或改名的属性没有@Field注解,或@Field(key = …)与 TypeScript 侧实际发送的 key 不再匹配。 - Kotlin
Record或ComposeProps的主构造参数新增时没有默认值——每个主构造参数都必须保留默认值。 - 在 JavaScript 会据以分支的失败路径上抛裸的
IllegalArgumentException、IllegalStateException或Exception:expo-modules-core会把它们包装成ERR_UNEXPECTED,正确做法是加CodedException子类。 - 错误信息只说了"什么"失败了。仓库指引是 what / why / how 三段。
- 失败路径既不渲染任何东西、也不向 JavaScript 发信号——不可解析的 asset id、不支持的 scheme、HTTP 错误、解码失败,最终是"一片空白 + 一行 logcat"。应通过
ViewEvent模式暴露onLoad/onError,或明确记录该行为。
十二、Gradle 依赖卫生
唯一一条但边界很清晰:某包的 android/build.gradle 把 androidx.*、Compose 或 Material 坐标抬高到超过其他 Expo 包 pin 的最高版本,或引入新的 -alpha/-beta/-rc 构件且没有说明理由。反方向——比别的包 pin 得更低——不构成发现。
十三、反向清单:明确不应报告的内容
一份高质量的审查规则必须同时定义"报什么"和"不报什么",后一半决定了信噪比。文档的"不报告清单"全部源于 Expo Modules DSL 的语义,误报它们恰恰说明审查器没读懂框架:
View { … }块内AsyncFunction缺.runOnQueue(Queues.MAIN),以及直接触碰视图 UI 状态——DSL 已经处理了。- 自启动 Activity 的 API 缺主线程切换:它们无论调用线程都会呈现,强制走主队列反而可能让一个"文档已知会阻塞"的调用堵住主线程。
AsyncFunction/Coroutine/Property/Prop体内缺 try/catch 或缺显式promise.reject(...):DSL 会把抛出的错误转换为 rejection。同理,requireNotNull(appContext.reactContext)、Exceptions.ReactContextLost()、appContext.throwingActivity是设计内的惯用法而非未处理崩溃;只有手工的、吞掉错误的 catch 才值得报告。AsyncFunction … Coroutine上缺取消逻辑、存的Job、手写的isActive检查,或运行在appContext.modulesQueue、mainQueue、backgroundCoroutineScope上的工作——这些队列已经绑定到 teardown。- 缺
@OptimizedComposeProps/@OptimizedRecord注解,或ComposeProps视图没有<Host>父级。 - Spotless/ktlint/detekt 的地盘:格式、import 顺序、未用 import、属性访问写法、行宽——已由工具强制(见 apps/expo-go/android/build.gradle 中为每个子项目统一注入的 spotless + ktlint 配置,
ktlintTarget = '**/*.kt')。 - 包级
build.gradle缺compileSdk/minSdk/targetSdk:由 Gradle 插件供给。 packages/*/android/build.gradle中的version/versionName落后于package.json的版本:发布工具链负责它们。- 给较新 API 加 SDK 检查当仓库已决定"从该 API 出现的最早 level 起直接用最新版":先确认包内既有模式,再决定是否要求更低下限。
十四、这套规则的工程价值:给"逻辑正确性"划定可执行的验收面
把 correctness-android.md 放在 Expo 的审查流水线里看,它解决的是一个具体问题:多代理 AI 审查中,每个 agent 必须有可判定的职责边界与证据标准,否则输出迅速退化为噪音。该文档的三条设计值得借鉴:
- 以真实历史缺陷为基准线(文中引用的 PR 编号是"已经发货的真实缺陷"),把"你存在的意义"锚定在具体的漏检画像上,而不是泛泛的"检查 bug";
- 取证链有严格优先级且可执行:vendored RN 源码 → 兄弟包调用点 → DSL 定义 → 包级 Gradle/Manifest/ProGuard,取不到证据时的降级路径(降置信度 / 写清
uncertainties)也预先规定; - 用"不报告清单"对称地约束误报,并把"注释不可信、只认构造点代码"这类防自欺条款写进规则。
对于维护多模块原生代码库的团队,这份文件可以直接作为模板:把其中的 Expo 专有术语(AsyncFunction、Record、sendEvent/Events、队列 API)替换为你所用框架的对应概念,保留"逻辑缺陷分类 + 平台行为取证顺序 + 反向清单"三段结构,即得到一份可用的正确性审查器规格。使用时需注意其适用前提:规则针对 minSdk 24 起步的安卓工具链与 Expo Modules DSL 语义编写,脱离这两个前提(例如 minSdk 更高的独立应用、或不使用该 DSL 的项目)需逐条重新校准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00