首页
/ Expo 安卓正确性审查规范解析:correctness-android 代理如何拦截 Kotlin/Java 的八类缺陷

Expo 安卓正确性审查规范解析:correctness-android 代理如何拦截 Kotlin/Java 的八类缺陷

2026-09-05 14:50:36作者:蔡丛锟

本篇围绕 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,并且 compileSdkexpo-module-gradle-plugin 统一注入,而不是写在各包的 build 文件里。

可以对照仓库实际配置来理解这一前提。Expo Go 的安卓根构建文件 apps/expo-go/android/build.gradle 中:

ext {
  minSdkVersion = 24
  targetSdkVersion = 36
  compileSdkVersion = 37
  kotlinVersion = "2.3.20"
  ...
}

minSdkVersion = 24targetSdkVersion = 36 与该审查器声明的区间一致;而 Kotlin 与 compileSdk 的具体版本号在仓库演进中会变化(当前根配置已是 2.3.20 / 37),阅读该规则时应以区间(minSdk→targetSdk 之间跨了 12 个 API level)而非单个版本号为准。由于文档声明包级 build 文件通常不含 compileSdk/minSdk(由 Gradle 插件供给),因此"某包 build.gradle 里没有 compileSdk"本身不是问题——这正是后文"不应报告清单"中的一条。

三、逻辑与算法缺陷:该审查器存在的根本理由

文档开篇即点题:历史上这个仓库的安卓原生代码只被审查"API 契约与线程",从未被审查"它算出的答案对不对"。这一节是审查器的存在理由。需要拦截的模式包括:

  1. Int 表示文件、媒体或缓冲区大小。Android 平台 API 对这些量返回 Long,窄化转换会在超过 2 GB 时静默回绕——文档引用的历史缺陷是 createAssetAsync 对大于约 2 GB 的文件失败。审查要求检查每一个 .toInt()、每一个 Int 参数,以及任何会把宽高相乘的算术。
  2. 守卫可能静默跳过变更的"条件 mutation",导致违反代码自己声明的不变式。典型形态:indexOf 返回 -1、可空的 map 查找,或者包在重排/归一化步骤外的 ?.let { }——其 null 分支什么都不做。
  3. 在并发编辑可能使其失效的集合上做索引与偏移算术
  4. 把零或负数当作"未设置"的 limit、count、range 参数——当这些取值本身有业务含义时。
  5. 生命周期或配置切换后的过期状态:键盘收起、旋转、Activity 重建时未失效的缓存尺寸/布局/测量结果(文档引用的历史缺陷:键盘收起后 shadow node 尺寸过期)。
  6. Resume/restore 路径启动了从未运行的工作:例如 onHostResume 无条件重启所有 watcher,包括调用方从未订阅过的——它需要与其他 watcher 相同的初始化守卫。
  7. 同一类中兄弟方法的校验顺序不一致。文档给出的例子极具操作性:bytes()asContentUri() 都先调 validateType() 再调 validatePermission(),那么新增的 digest() 如果跳过 validateType(),就会抛出裸的 FileNotFoundException("Is a directory")而不是该类自己的 InvalidTypeFileException审查方法是:与同一文件中的兄弟方法逐一对比。
  8. 假设单字节字符或固定宽度的字符串/字节处理

四、平台 API 取证方法论:先验证,再下结论

这是该文档区别于一般"检查清单"的核心部分——它规定了一条有严格顺序的取证链,且明确声明审查代理只有仓库内的 Read/Grep/Glob 能力,没有网络、没有 web 搜索、没有 androidx 或 Play Services 源码、没有 Bash、没有 Gradle 缓存。因此"凭记忆断言平台行为"是被禁止的。报告之前,按以下顺序寻找佐证:

  1. 仓库内 vendored 的 React Native 安卓源码react-native-lab/react-native/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/,文档写作时为 0.86.2)。如果 diff 中的注释声称 RN 有某种行为,打开文件核实。
  2. 已经调用过同一 API 的兄弟 Expo 包——文档称这是"你能拿到的最强证据",也是这个仓库人工审查者实际会引用的方式:某个包已经在用 Target.SIZE_ORIGINAL、已经在按 API level 加守卫、已经在走共享的 OkHttpClient,就确立了 diff 应当遵循的模式。
  3. expo-modules-core 自身的定义:读你认为被违反的 DSL 本体,而不是假设它的行为。
  4. 该包的 android/build.gradleAndroidManifest.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.proconsumer-rules.pro 中没有对应 keep 规则。Record 子类、枚举的 valueOf、Gson/Moshi 模型、以及任何从 C++ 侧按名字查找的对象都算在内。
  • 同一 diff 里引入了 Class.forNamegetDeclaredMethod 或注解扫描,却没有配套 keep 规则
  • 行为依赖 Class.getSimpleName() 或栈帧类名的代码——两者都会被 R8 重写。

七、并发与协程

围绕 Expo Modules DSL 的并发模型,文档列出的拦截点:

  • 同步 Function(...) 体内做阻塞工作:文件/网络 I/O、ContentResolver 查询、Thread.sleeprunBlockingCountDownLatch.await、位图解码、数据库访问。正确做法是移入 AsyncFunctionAsyncFunction … Coroutine
  • 留在默认队列上的长阻塞工作:在 Coroutine 体内用 withContext(Dispatchers.IO) 切走,或 .runOnQueue(appContext.backgroundCoroutineScope)View { … } 块内必须切走,因为视图的 async 函数不能用协程形式。特别强调清理代码也要切走——留在 withContext 块之后的 response.close() 或流关闭,会跑回那个共享串行队列上,正是 offload 本来要保护的对象。
  • 从后台上下文触碰 JSI 包装器JavaScriptObjectJavaScriptValueJavaScriptFunctionJavaScriptWeakObjectArrayBuffer 的非 scoped 访问器,未经 runtime.schedule { } 路由。
  • 同一 AsyncFunction 的并发调用而其实现假设只有一个在飞:共享的 prompt、单一回调槽、不可重入的 SDK。要求写清楚"第二个调用方会看到什么"。
  • 线程 A 迭代集合、线程 B 在 teardown/reload 时修改它(文档引用的历史缺陷是 reload 期间 JNIDeallocatorConcurrentModificationException)。
  • 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/AppContextcompanion object、顶层 object 或任何单例——必须是 WeakReference.weak()
  • 长生命周期/进程级监听器从模块注册但同一 diff 里没有匹配的移除registerContentObserverregisterReceiver、传感器或定位监听器、加入 companion object 集合的 lambda、MediaPlayer/ExoPlayer 回调(文档引用的历史缺陷:TaskServiceTaskExecutionCallback 泄漏)。
  • 命名在并发实例间或跨进程重启后冲突的缓存、锁文件、目录(文档引用的历史缺陷:SimpleCache 目录锁冲突)。

十一、Expo Modules API 契约

这一节把审查范围从"通用 Kotlin"拉回到"Expo 模块框架的 API 契约":

  • sendEvent("name", …) 中的事件名不在该模块的 Events(…) 声明里;以及从 Events(...) 中删除名字但 sendEvent 调用仍在的情况。
  • Kotlin Record 上新增或改名的属性没有 @Field 注解,或 @Field(key = …) 与 TypeScript 侧实际发送的 key 不再匹配。
  • Kotlin RecordComposeProps 的主构造参数新增时没有默认值——每个主构造参数都必须保留默认值。
  • 在 JavaScript 会据以分支的失败路径上抛裸的 IllegalArgumentExceptionIllegalStateExceptionExceptionexpo-modules-core 会把它们包装成 ERR_UNEXPECTED,正确做法是加 CodedException 子类。
  • 错误信息只说了"什么"失败了。仓库指引是 what / why / how 三段。
  • 失败路径既不渲染任何东西、也不向 JavaScript 发信号——不可解析的 asset id、不支持的 scheme、HTTP 错误、解码失败,最终是"一片空白 + 一行 logcat"。应通过 ViewEvent 模式暴露 onLoad/onError,或明确记录该行为。

十二、Gradle 依赖卫生

唯一一条但边界很清晰:某包的 android/build.gradleandroidx.*、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.modulesQueuemainQueuebackgroundCoroutineScope 上的工作——这些队列已经绑定到 teardown。
  • @OptimizedComposeProps/@OptimizedRecord 注解,或 ComposeProps 视图没有 <Host> 父级。
  • Spotless/ktlint/detekt 的地盘:格式、import 顺序、未用 import、属性访问写法、行宽——已由工具强制(见 apps/expo-go/android/build.gradle 中为每个子项目统一注入的 spotless + ktlint 配置,ktlintTarget = '**/*.kt')。
  • 包级 build.gradlecompileSdk/minSdk/targetSdk:由 Gradle 插件供给。
  • packages/*/android/build.gradle 中的 version/versionName 落后于 package.json 的版本:发布工具链负责它们。
  • 给较新 API 加 SDK 检查当仓库已决定"从该 API 出现的最早 level 起直接用最新版":先确认包内既有模式,再决定是否要求更低下限。

十四、这套规则的工程价值:给"逻辑正确性"划定可执行的验收面

correctness-android.md 放在 Expo 的审查流水线里看,它解决的是一个具体问题:多代理 AI 审查中,每个 agent 必须有可判定的职责边界与证据标准,否则输出迅速退化为噪音。该文档的三条设计值得借鉴:

  1. 以真实历史缺陷为基准线(文中引用的 PR 编号是"已经发货的真实缺陷"),把"你存在的意义"锚定在具体的漏检画像上,而不是泛泛的"检查 bug";
  2. 取证链有严格优先级且可执行:vendored RN 源码 → 兄弟包调用点 → DSL 定义 → 包级 Gradle/Manifest/ProGuard,取不到证据时的降级路径(降置信度 / 写清 uncertainties)也预先规定;
  3. 用"不报告清单"对称地约束误报,并把"注释不可信、只认构造点代码"这类防自欺条款写进规则。

对于维护多模块原生代码库的团队,这份文件可以直接作为模板:把其中的 Expo 专有术语(AsyncFunctionRecordsendEvent/Events、队列 API)替换为你所用框架的对应概念,保留"逻辑缺陷分类 + 平台行为取证顺序 + 反向清单"三段结构,即得到一份可用的正确性审查器规格。使用时需注意其适用前提:规则针对 minSdk 24 起步的安卓工具链与 Expo Modules DSL 语义编写,脱离这两个前提(例如 minSdk 更高的独立应用、或不使用该 DSL 的项目)需逐条重新校准。

登录后查看全文
热门项目推荐
相关项目推荐