从 review v0.6 三轮迭代里积累的"被 Android specifics 坑过"清单。新人 / 新 agent
入职先扫一遍,跟 ARCHITECTURE.md 并读。
每条都给:症状、根因、正确姿势、关联 review finding。
症状:资源里写 " ⚠ truncated",运行时拼接结果 "…1500B⚠ truncated"
没有间距。
根因:Android 资源 XML 加载器默认 strip 字符串首尾空白 (官方文档)。
正确姿势:
- 资源里不放首尾空白
- 间距在调用点拼接(
" " + stringResource(R.string.x))或用Spacer(width=Xdp) - 要保留空白时用双引号包整段:
<string name="x">" trim-resistant"</string>(不推荐, 新增字符串容易疏忽)
关联:v0.6-round2 F-001
症状:英文 locale 看到 "1 packets" / "1 bytes" / "1 sessions"。
根因:硬拼 "%d packets" 永远是复数形式;英文需要单复数区分。
正确姿势:
<plurals name="count_packets">
<item quantity="one">%d packet</item>
<item quantity="other">%d packets</item>
</plurals>Compose 调用 pluralStringResource(R.plurals.count_packets, n, n);
非 Composable(Service)用 resources.getQuantityString(...)。
中文只需 quantity="other",俄语 / 阿拉伯语等还需 few / many / zero。
关联:v0.6-round3 F-001
症状:用户切语言后,系统设置 → 应用 → 通知里的 channel 名仍是首次创建时的语言。
根因:NotificationManager.createNotificationChannel(channel) 对已存在的 id
是 update 而非 no-op,但是只有当 name / description 不同时才真的更新。
正确姿势:ensureChannel() 每次都检查 existing 的 name/desc 跟当前 locale
资源是否一致,不一致就重 create(同 id 是 update 等价,importance 复用 existing
不降级)。
val existing = nm.getNotificationChannel(CHANNEL_ID)
if (existing != null && existing.name == name && existing.description == desc) return
nm.createNotificationChannel(NotificationChannel(CHANNEL_ID, name, existing?.importance
?: IMPORTANCE_LOW).apply { description = desc; ... })关联:v0.6-round2 F-010
症状:升 compileSdk 后测试基线截图没真覆盖新 SDK 的 system insets / behaviour。
根因:Robolectric 用 @Config(sdk=...) 选实际仿真的 SDK,跟 gradle
compileSdk 独立。
正确姿势:测试 @Config(sdk = [35]) 跟 compileSdk = 35 同步改。
Robolectric 版本要 cover 目标 SDK(4.13 不识别 SDK 35,升 4.14.1+)。
关联:v0.6-round2 F-003
症状:代码引用 BuildConfig.DEBUG 编译报 Unresolved reference 'BuildConfig'。
根因:AGP 8 起 buildFeatures.buildConfig 默认 false。
正确姿势:
android {
buildFeatures {
compose = true
buildConfig = true // 需要 BuildConfig.DEBUG 等才开
}
}关联:v0.6-round1 F-009
症状:写了 <exclude domain="file" path="cache/" /> 没起作用。
根因:
- Android 12+(API 31+)走
android:dataExtractionRules引用的 xml - Android 11 及以下走
android:fullBackupContent引用的 xml - domain 有
root/file/external/database/sharedpref等, 各对应不同目录;file不是 cache 目录 - Android 默认已经排除
cache/(内 + 外)—— 写 phantom 规则没意义
正确姿势:
- minSdk 26 → 两份 xml 都写(系统按 SDK 选用)
- 显式排除未来要落盘的具体路径:
frame_cache//pcap_index.db - 不重复 Android 默认已排除的项
关联:v0.6-round2 F-009 + round3 F-005
症状:composeRule.onNodeWithText("打开 PCAP 文件") 在英语 locale 下找不到节点。
正确姿势:
val context: Context = ApplicationProvider.getApplicationContext()
composeRule.onNodeWithText(context.getString(R.string.action_open_pcap)).performClick()测试硬编码字面量是脆性测试,i18n 后 locale 切换就破。
关联:v0.6 i18n round + round2 F-003 测试调整
症状:UI 改了 plurals / SDK 升 → verify 失败。
正确姿势:UI 文本 / API / SDK 变动后主动重录:
./gradlew recordRoborazziDebug && ./gradlew verifyRoborazziDebugrecord 是 destructive 操作(覆盖基线),改完代码自己确认像素差异符合预期再 commit。
基线 png 放 app/build/outputs/roborazzi/ 在 gitignore 内(不入库),每次新机器都
要 record 一次。
关联:v0.6-round1 F-001 / round2 F-001+F-003 / round3 F-001+F-003
症状:docker 跑完 ./gradlew clean 在宿主报 Permission denied。
正确姿势:所有 docker 命令加 -u "$(id -u):$(id -g)",配合 GRADLE_USER_HOME
指到一个用户能写的目录:
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$PWD":/work -w /work \
-v "$HOME/.gradle-docker":/work/.gradle-cache \
-e GRADLE_USER_HOME=/work/.gradle-cache \
mingc/android-build-box:latest \
./gradlew assembleDebug注意:切到 -u 跑之前要 sudo chown 已有缓存(被 root 写过的目录),或者用新
缓存目录。
关联:v0.6-round1 F-010 + round2 F-010
症状:宿主 curl dl.google.com 200,容器里 Gradle 拉 AGP/SDK 时 TLS
"Remote host terminated the handshake"。
根因:容器 JVM 默认 TLS cipher suite 跟某些 CDN 节点不兼容(CN 区 DNS 解析到的 IP 多变),或者宿主走透明代理容器没继承。
临时解法:
- 用
--network host让容器复用宿主网络栈 - 重试(DNS 重新解析换 CDN 节点)
- 实在不行:临时挂阿里云 maven init 脚本(仅本地
$HOME/.gradle-docker/init.d/不进仓库)
不该:把阿里云镜像写进 settings.gradle.kts 上游仓库
关联:与 round1 i18n session 网络抖动事件
症状:PCAPdroid "分享 pcap" 列表里看不到本应用。
根因:PCAPdroid 用 Intent.ACTION_SEND + EXTRA_STREAM + MIME application/cap;
本应用如果只声明 ACTION_VIEW 就拿不到分享。
正确姿势:分享接收专门加 ACTION_SEND intent-filter,MIME 列上下游约定的所有可能值
(application/cap、application/vnd.tcpdump.pcap...)。
关联:v0.6 OOB(PCAPdroid 分享接入)
症状:通知栏显示系统默认应用图标,看不到自定义 icon。
根因:Android 5.0+ 通知栏 smallIcon 只用 alpha 通道,系统按主题色着色。
有些 ROM(特别是 MIUI / EMUI 系)对 VectorDrawable strokeWidth 描边的渲染
不完整,会回退到 system fallback。
正确姿势:smallIcon vector 只用 fillColor="#FFFFFFFF" 纯填充 path,不用
strokeWidth。复杂形状用 fillType="evenOdd" 内圆挖外圆。
关联:v0.6 OOB(通知图标 round1)
症状:DatagramSocket(port) 抛 SecurityException。
根因:Android 把所有 socket 操作(包括 bind 本机端口)归在 INTERNET 下。
正确姿势:manifest 声明 <uses-permission android:name="android.permission.INTERNET" />。
症状:API 34+ 上 startForeground() 被系统拒绝。
正确姿势:根据 service foregroundServiceType 加对应 type-permission:
dataSync→FOREGROUND_SERVICE_DATA_SYNCmediaPlayback→FOREGROUND_SERVICE_MEDIA_PLAYBACK- 其它见 Android docs
症状:放进 HashMap / HashSet / Compose state 时性能差或行为不对。
根因:data class 默认 equals 走逐字段比较,ByteArray equals 是逐字节, 百 KB payload 进 hash 巨慢。
正确姿势(两种):
- 没有
copy()调用:去data修饰改普通 class(默认 equals 是身份比较) - 有
copy()调用:保留 data class 但手写equals/hashCode走身份 / 按 id 比较, 加 KDoc 注释明确说明原因避免下个 reviewer 删
关联:v0.6-round1 F-007
症状:remember(filter, frames) { frames.filter(...) } 在 frames 内容
变化时不重算。
根因:SnapshotStateList 的 equals 走结构比较,但 self.equals(self) 走
identity 短路恒为 true —— remember key 看到的是"还是同一个 list"。
正确姿势:用 frames.size 或 snapshot id 作为 key:
val filtered = remember(filter, frames.size, sortColumn, sortDesc) {
frames.filter(filter::matches)
}关联:早期 i18n round(中 frame list 显示空但 size 非零的 bug)
症状:调 clear() 后 ByteArray 引用被 drop 但 GC 异步回收前内容残留堆。 取证 / dump 堆能拿到 TLS session keys 等。
正确姿势:
fun clear() {
for (secret in keys.values) secret.fill(0)
keys.clear()
}load() 替换路径同样要先 clear 旧数据再装新数据,不要内联 byRandom.clear()。
关联:v0.6-round2 F-008 + round3 F-002
症状:./gradlew lintDebug 报 Aborting build since new baseline file was created。
正确姿势:第一次跑生成 app/lint-baseline.xml,之后跑就是 baseline mode。
入库 baseline 文件,CI 跑 lint 时新增问题才会 fail。
症状:直接跑 detekt 失败一堆 issue。
正确姿势:先跑 ./gradlew detektBaseline 把现有 issue 入库,之后 detekt
只 fail 新增问题。
症状:尝试 FileChannel.map(MODE_READ_ONLY, 0, fileSize) 对 > 2 GB
PCAP,抛 IllegalArgumentException: Size exceeds Integer.MAX_VALUE 或者
position() 截断到 Int 后访问越界。
根因:MappedByteBuffer extends ByteBuffer,position / limit /
capacity 都是 int。FileChannel.map 第三个参数虽是 long,实际只
接受 ≤ Int.MAX_VALUE (~2.147 GB)。
正确姿势:
- ≤ 2 GB:单 buffer 直接 map(v1.1
PcapMmapReader走这条路径) -
2 GB:必须多段 mmap——把文件按 1 GB 切片,每段一个
MappedByteBuffer,frame 跨段时拼接(PcapMultiMapReader,v2.0 待办) - 当前实现
PcapMmapReader.init显式拒绝 > Int.MAX_VALUE 文件, IOException 友好失败
关联:v1.1-round0 LAZY-005
症状(未来潜在 crash,当前未触发):背景协程在读 frame.data
(MmapBytes 视图)时,main thread 切 PCAP 触发 PcapHandle.close() →
reader.tryExplicitUnmap() 成功 → mmap 被 munmap → 协程下一次访问
SIGSEGV,进程闪退无 logcat 栈。
根因:lazy refactor 把 mmap 生命周期从 "channel 关闭即结束" 变成 "PcapHandle.close 时显式 unmap"。MmapBytes 持父 buffer 强引用挡 GC, 但挡不住显式 unmap——reflection clean 立即释放 native 内存,JVM 不再校验该 ByteBuffer 是否还有 reader。
当前没爆炸的原因(无意中存在的兜底):
- Android API 29+ dark list 让 reflection cleaner.clean() 大概率失败 → fallback 到 GC,mmap 长存
- filter / sort 等帧访问都走同步
remember { ... }block,main thread 序列化,与 onDispose 不存在并发时序
正确姿势:
- 任何 LaunchedEffect / coroutine 内访问
frame.data的 PR—— 先读本条 + ANDROID_PITFALLS 这一节 - 短期方案:
PcapHandle.close()内加 100ms delay(给协程一帧时间退出) - 长期方案:MmapBytes 引用计数(AtomicInteger users 归零才真 unmap)或 withMmap { ... } reader-lock pattern
- 不要靠 "reflection unmap 失败" 当并发锁——某天 OEM ROM 放开 hidden API / Android 升级、立刻变 crash 报告
- 当前 release 不强求修,写进本文件作风险点登记
关联:v1.1-round1 F-001、PcapHandle.kt、PcapMmapReader.tryExplicitUnmap
症状:反复加载大 PCAP 后 dumpsys meminfo 看 PSS / vmem 累积,慢慢
压系统内存。
根因:JDK MappedByteBuffer.cleaner 是 sun.misc.Cleaner,没公开
unmap API。Android API 28+ 还把 cleaner 字段放进 hidden API restriction
(28 grey list / 29+ dark list)。GC + finalizer 会自然回收 mmap,但时机
不可控,大文件场景下延迟显著。
正确姿势:
- 用 reflection best-effort 调
cleaner.clean(),runCatching兜底失败 - v1.1 LAZY-003 引入
PcapHandle:UI state 切换时显式 close → 触发 reflection unmap(成功→秒级释放;失败→GC fallback,跟 v0.9 行为一致) - Frame.data 还在使用时绝对不能 unmap——MmapBytes 视图持父 buffer 强引用,GC 不会过早回收;显式 unmap 必须等所有 frame 引用 drop
关联:v0.6-round7 F-008、v1.0-round1 REFACTOR-002、v1.1-round0 LAZY-003
| 你要做 | 想这些 |
|---|---|
| 加新 user-facing 字符串 | plurals?(任何含 %d)/ strings.xml 不带前后空白 / values + values-zh 同步 |
| 改 ContentResolver / 落盘 | dataExtractionRules + backup_rules 加 exclude |
| 加 ForegroundService | foregroundServiceType + 对应 type-permission |
| 加 Service 通知 | smallIcon 纯 fillColor / channel name locale 更新 |
| 升 compileSdk | 测试 @Config(sdk=...) 同步 + 跑 recordRoborazziDebug |
| 加 BuildConfig.X 引用 | buildFeatures.buildConfig = true |
| 写 ByteArray 字段 + data class | 想 equals 语义;没 copy() 用就去 data |
| 改协议 / 字段名 | 加进 Protocols / FieldNames,dissector + 消费端同步 |
| 抛 user-facing Exception | sealed error,core 不持中文 / 英文 message |