ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android高频代码片段:从避坑到工程化复用

Android高频代码片段:从避坑到工程化复用

说实话,每个写 Android 的人手机里都躺着一堆"临时笔记"——某个对话框的背景要设成透明怎么写、软键盘怎么收、点击两次会崩怎么防。这些 Android 常用的代码片段,单拎出来没有一个能叫"技术难点",但真到写业务的时候,缺一个就得去翻三年前的聊天记录。这篇文章我打算把自己这些年攒下来的高频片段做一次系统整理,重点不是把代码贴出来,而是讲清楚每一段为什么这么写、换个写法会踩什么坑、在什么场景下不能直接抄。

内容面向的读者范围比较宽:刚接手第一个完整项目的同学,可以把这里当成一份"能直接落地"的起步模板;做过两三年、但代码习惯还停留在复制粘贴阶段的朋友,更值得看看中间那些"为什么不那样写"的分析;至于已经带团队的人,后面第 6 章的问题排查表和片段库维护思路,可以直接拿去改造成团队内部的规范。我不会把所有代码都堆成一个大而全的工具类,那种类最后没人敢改,反而会成为负担。

1. 先把"常用代码"这件事本身想明白

1.1 为什么大部分人的片段收藏夹最后都成了废纸篓

我先说说我自己踩过的坑。早年我的做法是建一个Utils类,什么方便往里塞什么,单位转换、字符串判空、日期格式化、网络判断、Toast 封装全在一起。写了两年,这个类膨胀到两千多行,里面的方法引用关系像一团毛线:改一个时间格式化函数,结果发现三个业务模块的展示都变了;想删一个"看起来没人用"的方法,IDE 提示有 17 处引用,其中 12 处我自己都看不懂当时为什么这么调。

问题出在哪?出在我把"常用"和"该放在一起"画了等号。一段代码之所以常用,是因为它解决的是横切关注点——它不属于任何一个业务模块,却被很多业务模块需要。这类代码的正确归宿不是聚合成一个巨型工具类,而是按依赖方向拆开:依赖 Android 框架层的,做成扩展函数;依赖某个具体能力的(比如存储、日志),做成有明确边界的接口实现;纯粹是纯函数的(比如格式化、加解密),单独放一个无副作用的小文件。这个划分方式看起来很学究,但它直接决定了三件事:这段代码能不能被单元测试、出问题的时候能不能被快速定位、以及半年后你还敢不敢动它。

还有一个更隐蔽的坑:很多人在收藏片段的时候只记住了"能跑",没记住"为什么这样能跑"。比如单位转换里的+ 0.5f,比如点击防抖里为什么用elapsedRealtime而不是currentTimeMillis。这些细节在你复制的那一刻不会报错,但会在某个特定机型、某个特定时序下变成线上问题。所以这篇里我贴的每一段代码,都会带上"为什么"的注释和一段说明,这是我给自己的要求,也是给读的人的一点诚意。

1.2 我判断一段代码该不该留下的三条标准

经过几次重构之后,我给自己定了三条很土的标准,用下来还挺管用。

第一条,这段代码是否会在至少两个不相关的业务里出现。只出现一次的,就老老实实写在业务里,不要提前抽象。我见过太多"预判式抽象",一开始就设计了三层回调,结果需求一变,抽象全废,还得回头拆。抽象的成本只有在复用时才摊得薄,一次性的抽象纯亏。

第二条,这段代码是否包含了一个"非显然"的知识点。什么叫非显然?就是那种不看文档、不踩过坑就想不到的地方。比如SharedPreferences的apply()和commit()在什么场景下会丢数据,比如软键盘隐藏时windowSoftInputMode和代码调用之间的先后顺序会影响结果,比如getGlobalVisibleRect在某些被裁剪的 View 上会返回错误的可见区域。这种东西才是片段库真正的价值,普通 API 调用 IDE 自然会提示你。

第三条,这段代码是否具备稳定边界。也就是说,它的输入输出是否明确、依赖是否可控。如果一段代码里隐式依赖了当前 Activity 的某个状态、或者依赖了全局单例的初始化时机,那它就不适合被抽成公共片段——你抽出去之后,别人在别的场景调用它,行为是不确定的。这种情况我会选择把它留在业务内,或者先重构成依赖注入的形式,再考虑复用。

下面这张表是我自己做的分类,后面所有章节都按这个思路组织:

类别典型内容放置位置是否适合直接复制
纯函数工具单位换算、字符串处理、格式化独立顶层函数文件适合,但要看清副作用
框架扩展View、Context、Activity 的扩展按接收者分文件的扩展函数适合,注意导入冲突
能力封装存储、日志、设备信息接口 + 实现,可替换谨慎,先看初始化时机
业务胶水特定页面的状态处理留在业务模块内不建议抽出

2. 工程配置层:新建项目后最先动手改的那几处

2.1 用版本目录统一管理依赖,别再让版本号散落各处

一个项目超过三个模块之后,依赖版本散落的问题就会暴露得非常明显:主模块用的是 A 库的 2.3,某个基础模块用的是 2.1,编译能过,运行时的表现却不一致,出问题的时候查半天。解决方式是把版本集中到一处。现在比较通行的做法是用版本目录文件,写法如下:

# gradle/libs.versions.toml [versions] kotlin = "2.0.20" coroutines = "1.8.1" lifecycle = "2.8.4" [libraries] coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } [plugins] kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

然后在模块的构建脚本里直接引用别名:

dependencies { implementation(libs.coroutines.core) implementation(libs.lifecycle.runtime) }

这套东西最大的好处不是"少写几个字",而是版本升级变成了一次原子操作。你想把协程升到新版本,只需要改versions里的一行,所有模块同时生效,构建一次就知道有没有兼容问题。如果沿用以前那种每个模块各写各的版本号的方式,升级就变成了一场逐个模块排查的持久战。

这里有个实操细节值得提醒:版本目录文件里的别名转换成代码里的引用时,中划线会变成点号。也就是coroutines-core在脚本里要写成libs.coroutines.core。刚开始用的人十个里有八个会写成libs.coroutines-core,然后对着报错发呆。另外,如果某个依赖只在单个模块用、且版本很敏感(比如某些编译期插件),我倾向于仍然直写,不放进目录——过度集中会让目录文件变成另一个不敢动的巨型文件,那就换了个地方重蹈覆辙。

2.2 构建类型与包体控制:几个一劳永逸的开关

打包配置里有几项,我每次新建项目都会顺手加上,因为它们的收益是长期的,而成本只有几分钟。

android { buildTypes { release { isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } defaultConfig { // 只保留需要的语言和图片密度,减小包体 resourceConfigurations += listOf("zh", "en") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") } } packaging { resources { excludes += setOf( "META-INF/*.kotlin_module", "META-INF/DEPENDENCIES", "META-INF/LICENSE*" ) } } }

这里的取舍逻辑值得说清楚。资源裁剪和代码混淆打开之后,构建时间会明显变长,如果你的机器配置一般,日常开发调试会被拖得很痛苦。所以我的做法是:调试构建坚决不开,只在打包机或者本地打 release 包时开启,同时把混淆规则当成代码的一部分来维护——每次引入新的序列化模型、反射调用、JNI 接口,都要同步补规则。这件事拖到最后再做,基本等于给自己埋一颗定时炸弹,因为那时候你早就忘了哪些类依赖反射了。

abiFilters这一项也要注意场景。如果你的应用里带了原生库,把所有架构都打进去,包体会直接翻倍;但如果只留arm64-v8a,在某些老旧设备上会装不上或者崩在UnsatisfiedLinkError。我的经验值是:国内市场用双架构(arm64-v8a+armeabi-v7a)基本覆盖,纯 64 位方案只适合用户设备比较新的产品线。

3. 界面层:出现频率最高的一批代码片段

3.1 单位换算看着简单,写错的概率却出奇地高

单位换算大概是所有 Android 开发者写的第一批工具函数,但我见过太多项目里这几个函数是错的。先看写法:

val Context.density: Float get() = resources.displayMetrics.density fun Context.dp2px(dp: Float): Int = (dp * density + 0.5f).toInt() fun Context.px2dp(px: Float): Float = px / density fun Context.sp2px(sp: Float): Int = (sp * resources.displayMetrics.scaledDensity + 0.5f).toInt()

那个+ 0.5f不是玄学,是四舍五入。Float转Int是直接截断的,1.9会变成1。如果一路上都是截断,误差会不断累积,最后表现为"1dp 的线在某些屏幕上消失"。加 0.5 再截断,等价于四舍五入。这是个小细节,但它决定了你画的 1px 分割线在低密度屏上到底存不存在。

另一个更容易出问题的地方是sp。scaledDensity会跟着用户在系统设置里调的字号变化,这正是sp存在的意义——尊重用户的无障碍设置。但也正因为如此,用sp做布局尺寸是灾难,用户把字号调大,你的按钮会被撑破。我的规矩是:所有文字用sp,所有间距、宽高、圆角用dp,中间不混用。见过有人说"我就用 sp 画了个图标",测试机上调大字号之后整个页面全乱。

还有一个坑:density和scaledDensity在运行时会因为字体缩放设置变化而重新计算,如果你在Application创建时把它们缓存成常量,用户在设置里改完字号再切回你的应用,缓存值就是错的。要么每次从resources现取,要么注册配置变化监听后清缓存。

3.2 点击防抖:一段代码解决线上最常见的一类重复提交

按钮连点导致重复提交、重复下单、重复跳转,大概是客户端里最古老也最难彻底根治的问题之一。最省事的做法是给点击加节流:

class ThrottleClickListener( private val intervalMs: Long = 600L, private val onClick: (View) -> Unit ) : View.OnClickListener { private var lastClickAt = 0L override fun onClick(v: View) { val now = SystemClock.elapsedRealtime() if (now - lastClickAt < intervalMs) return lastClickAt = now onClick(v) } } // 使用 button.setOnClickListener(ThrottleClickListener { doSubmit() })

为什么用elapsedRealtime()而不是System.currentTimeMillis()?因为后者是墙上时钟,用户可以手动改,系统也会做时间同步校准,可能往前跳也可能往后跳。如果时间突然往回跳一年,你的防抖逻辑会长期失效;如果往前跳,按钮会在一段时间内完全点不动。elapsedRealtime()是从设备开机开始单调递增的,不受这些影响,是这类"计算间隔"场景的正解。

但要清醒一点:客户端防抖只能挡住误触,挡不住恶意请求。真正要保证幂等,必须在服务端做唯一性校验或者幂等键。我见过有团队把防抖当成业务保障,结果被自动化脚本刷了一遍,直接损失订单。客户端的定位是"提升体验",不是"保证正确"。

另外,节流的时间间隔要按场景调。600ms 适合一般按钮;表单提交这种有网络延迟的场景,我在请求发出后还会把按钮置为不可用,收到响应再恢复,两层保护叠加才稳。单纯靠时间节流,用户等了三秒没反应又点一次,还是会重复。

3.3 软键盘收起与点击空白区域收起焦点

软键盘相关的代码,几乎每个项目里的写法都不一样,因为官方没有提供一行调用的 API。我用的是一套组合方案。

主动收起键盘(比如提交完成后):

fun Activity.hideSoftInput() { val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val token = currentFocus?.windowToken ?: window.decorView.windowToken imm.hideSoftInputFromWindow(token, 0) }

点击输入框以外的区域收起键盘并清除焦点,这个要挂在根布局上:

fun Activity.setupTapOutsideToHideKeyboard() { val root = window.decorView root.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { currentFocus?.let { focused -> val rect = Rect() focused.getGlobalVisibleRect(rect) val hit = rect.contains(event.rawX.toInt(), event.rawY.toInt()) if (!hit && focused is EditText) { focused.clearFocus() hideSoftInput() } } } false // 必须返回 false,否则会吞掉后续的点击事件 } }

这里有两个关键点,都是踩出来的。第一,setOnTouchListener必须返回false。因为返回true意味着你消费了这个触摸事件,后续的点击、长按、滑动就全没了——表现是所有按钮突然失灵。我第一次写这段的时候就这么干过,排查了半小时才想到是自己把事件吞了。

第二,用getGlobalVisibleRect而不是getGlobalVisibleRect的近似版本、也不是简单的坐标比较,是因为输入框可能被滚动容器裁剪。用可见区域的矩形判断,能正确处理"输入框一半露在外面"的情况。还见过有人用Rect(0, 0, view.width, view.height)加位置偏移手算,遇到嵌套滚动就崩了。

第三个细节:有些页面把根布局设成了ScrollView,这时候decorView上的触摸监听可能收不到滚动事件后的抬升。稳妥做法是把监听挂在你自己的根ViewGroup上,并且确保它没有拦截子 View 的点击。

3.4 沉浸式状态栏与窗口 Insets 的正确处理姿势

状态栏适配是另一个"每个项目都要重新写一遍"的老问题。从较新的版本开始,官方已经给出了稳定的路径:

// Activity.onCreate 中 WindowCompat.setDecorFitsSystemWindows(window, false) ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) view.updatePadding( left = bars.left, top = bars.top, right = bars.right, bottom = bars.bottom ) insets }

旧的做法是fitsSystemWindows="true"配合给状态栏设置半透明色,问题在于这种方式会把状态栏高度直接吃掉,做不了"内容延伸到状态栏下方但控件不遮挡"的效果。新路径的思路是:先声明"我的窗口不自动避让系统栏",再自己决定把哪个 View 的 padding 调整多少。

实际接的时候有几个高频问题。第一,如果根布局被多次设置 Insets 监听,padding 会累加,页面会越撑越开,症状是旋转屏幕或者切到多窗口后内容明显下移。解决办法是在监听里用固定基准值重新计算,而不是在当前 padding 上叠加。第二,底部导航栏的避让和输入法的避让要分开处理,输入法类型是Type.ime(),混在一起会导致键盘弹出时整个页面被顶得过高。第三,如果页面本身是可滚动的,通常只需要给列表的最后一项加底部 padding,而不是给整个容器加——给容器加会让背景色到不了屏幕底边,视觉上出现一条断层。

4. 数据与状态:读写、缓存、状态判断

4.1 轻量键值存储:apply 和 commit 不能随便换

SharedPreferences是用了十几年的老面孔,但用它出问题的人依然不少。核心在于apply()和commit()的差异:

class SettingsStore(context: Context) { private val prefs = context.applicationContext .getSharedPreferences("app_settings", Context.MODE_PRIVATE) fun putString(key: String, value: String) { prefs.edit().putString(key, value).apply() } fun putSync(key: String, value: String): Boolean { return prefs.edit().putString(key, value).commit() } fun getString(key: String, default: String = ""): String = prefs.getString(key, default) ?: default }

apply()是异步写内存、异步落盘,commit()是同步落盘并返回是否成功。绝大多数场景用apply()就对了,主线程不会卡。但有一个场景必须用commit():进程即将退出、且这个值必须持久化。比如某些关键的初始化标记,用apply()写下去,进程如果立刻被杀,数据可能还没落盘。我在做首次启动引导标记的时候就在这上面栽过一次,测试同学每次都能复现"引导页重复出现",而我本地怎么都复现不了——原因就是本地点击之后会停留一会儿,落盘完成了,测试是点完立刻杀进程。

另一个常识性的坑是applicationContext。如果你的SettingsStore是用 Activity 构造的,一定记得转成应用级上下文,否则就是一个典型的内存泄漏点:SharedPreferences被静态持有,Activity 被跟着拖住。

还有一件事需要说清:键值存储适合存小配置,不适合存结构化数据。我见过拿它存 JSON 列表的,数据量一上去,主线程读的时候明显卡顿,因为第一次读取会把整个文件加载进内存。超过几十 KB 的配置我就换方案了,用文件或者数据库,不要硬扛。

4.2 缓存目录的选择:三种目录对应三种生命周期

应用目录下有好几个缓存位置,名字长得像,行为完全不同。选错了表现为"缓存清了还在"或者"文件莫名其妙消失了"。

fun Context.appDirs() { val innerCache = cacheDir // 应用内部缓存,系统空间不足时可能被清理 val innerFiles = filesDir // 应用内部持久目录,卸载才清除 val externalCache = externalCacheDir // 外部缓存,行为同 cacheDir val externalFiles = getExternalFilesDir(null) // 外部持久目录 }

选择逻辑是这样的:随时可以重新生成的(图片、接口响应、临时解压文件)放cacheDir;用户数据、离线草稿这种丢了会出事的放filesDir。externalCacheDir和cacheDir的区别主要是空间配额和用户可见性,如果你的应用有大量媒体文件,用外部缓存更合适,因为内部存储的空间通常更紧张。

还有一个非常容易被忽略的细节:externalCacheDir在某些设备上可能返回空,尤其是外部存储被拔出或者未挂载的时候。所以调用时要判空,不能想当然直接File(externalCacheDir, "xxx"),那会拼出一个相对路径,文件写到奇怪的地方去。

缓存清理也是必写的片段,但别自己一个个目录遍历,容易漏。我一般写一个统一入口:遍历cacheDir的所有子项逐个删除,同时记录清理前后的总大小,方便在日志里看效果。清理逻辑要放到 IO 线程,File.length()在大目录下是很慢的,主线程调用会把页面卡住。

4.3 网络与设备状态的判断,别只看"有没有连接"

判断网络状态看似一行代码就能搞定,但只用isConnected会带来大量误判。正确姿势是结合能力判断:

fun Context.isOnline(): Boolean { val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) }

两个关键能力:NET_CAPABILITY_INTERNET表示这条网络声称自己能上网,NET_CAPABILITY_VALIDATED表示系统实际探测验证过了。只看前者的话,连上一个需要认证的公共网络时,系统会告诉你"已连接",但实际什么都访问不了,用户就会看到页面无限转圈。加上验证标志,能过滤掉相当一部分这种情况。

即便如此,这个函数也不是银弹。它反映的是系统视角的网络状态,不能保证你的服务端可达——公司内网、代理、服务端限流都会导致"网络正常但请求失败"。所以调用处一定要有超时和失败重试,不能把它当成请求成功的前置保证。我一般把它用在两个地方:请求前的快速失败提示(省一次无意义的等待),以及网络恢复后触发重试。

权限方面,读取网络状态需要声明ACCESS_NETWORK_STATE。这个权限是普通权限,装上就有,不需要运行时申请,但如果漏了声明,运行时不会有任何报错,只会一直返回"无网络",非常难查。这是我在排查一个"新用户首屏永远空白"的问题时发现的,日志里所有网络判断都是 false,最后发现是清单文件里少了一行。

5. 线程、生命周期与权限

5.1 主线程切换与延时任务,别再用裸 Handler 了

延时执行、切主线程这两件事,如果用裸的Handler写,最常见的后果是内存泄漏和任务重复。我用协程封装成两个小工具:

fun CoroutineScope.runOnMain(block: () -> Unit) { launch(Dispatchers.Main.immediate) { block() } } suspend fun delayTask(scope: CoroutineScope, delayMs: Long, block: suspend () -> Unit): Job { return scope.launch { delay(delayMs) block() } }

Dispatchers.Main.immediate这个immediate值得说一下:如果当前已经在主线程,它会直接执行而不重新投递到消息队列。这对于"从主线程调用但代码里不明确"的场景很有用,能避免一次不必要的消息排队导致的状态顺序被打乱。

更重要的是作用域。delayTask必须挂在有明确生命周期的 scope 上,比如lifecycleScope或者viewModelScope,这样页面销毁时任务会自动取消。用裸Handler.postDelayed的话,Activity 已经销毁了任务还在跑,回调里又去碰 View,就是一次典型的崩溃。这类崩溃在日志里的表现是空指针或者"View 未附着到窗口",堆栈看起来和页面逻辑毫无关联,非常难定位。

顺带提一句:需要"防抖式延时"的时候(比如搜索框输入停顿 300ms 再请求),不要每次输入都新建任务,而是保存上一个 Job 然后cancel(),否则用户快速输入十个字符就会发出很多请求,最后返回顺序还可能错乱,界面上显示的是第一个请求的结果。

5.2 生命周期感知的数据收集

页面销毁后还在收集数据流,是导致泄漏和崩溃的另一大来源。标准写法是用生命周期感知的收集器:

// Activity / Fragment 中 lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> render(state) } } }

repeatOnLifecycle的作用是:进入 STARTED 状态时启动收集,离开时取消,再次进入时重新启动。为什么不能直接用lifecycleScope.launch { collect }?因为lifecycleScope的生命周期是到 DESTROY,也就是说页面退到后台(STOPPED)之后收集依然在进行。这时候如果数据流发出了新值,界面代码还是会执行,可能触发不必要的刷新甚至访问已经不可见的视图。

再往上一层,如果这个数据只在页面可见时需要,用带WhileSubscribed策略的共享流更省资源:没有订阅者时自动停止上游的数据生产。这一点在需要持续监听传感器、定位或者轮询接口的场景里效果非常明显,能让应用在后台真正安静下来。

5.3 运行时权限的收敛写法

权限申请散落在各个页面里,是非常难维护的一种状态。我更倾向于集中到一个入口:

class PermissionRequester(private val activity: ComponentActivity) { private val launcher = activity.registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result -> val denied = result.filterValues { !it }.keys onResult?.invoke(denied) } private var onResult: ((Set<String>) -> Unit)? = null fun request(permissions: Array<String>, callback: (Set<String>) -> Unit) { onResult = callback launcher.launch(permissions) } }

几个实操要点。第一,registerForActivityResult必须在 Activity 创建阶段调用,不能放在点击回调里,否则会抛"必须在初始化时注册"的异常。很多人图省事写在onClick里,测试一遍能过(因为第一次可能还没触发校验),换个版本就崩了。第二,回调里要区分"拒绝"和"永久拒绝",后者需要引导用户去设置页,前者只需要解释一句再请求一次。区分方式是请求后再调一次系统的权限检查,如果检查结果是拒绝、但请求对话框没弹出来,说明是被永久拒绝了。第三,不要一次性请求一堆无关权限,用户看到一个长列表会本能地拒绝全部。

6. 线上问题排查实录

6.1 崩溃日志里最该先看的三个位置

线上崩溃天天有,但我发现很多人看日志的顺序是错的——从堆栈第一行开始往下读,读到业务代码就停。更高效的顺序是:先看异常类型和消息,再看Caused by链,最后才看自己的代码行。

异常类型决定了排查方向:空指针先想"哪个 View 或者哪个单例在此时为空",非法状态先想"调用时机对不对",OutOfMemoryError直接去查大图和集合。Caused by链里经常藏着真正的原因,比如业务层抛的是空指针,但Caused by指向的是某个序列化框架解析失败,那问题就在数据而不在 UI。

还有一个自检点:崩溃日志里的线程名。如果线程名是main,那就是主线程问题,和并发无关,别往线程安全方向查;如果是DefaultDispatcher-worker-x,才需要考虑并发和可见性。

6.2 内存问题的排查顺序

内存问题我一般按这个顺序走,从成本最低的开始:

顺序检查项判断依据处理方式
1静态集合是否无限增长集合 size 随操作次数单调上升加容量上限或改用弱引用
2单例是否持有 Activity/View静态字段类型是 Context 子类改传应用级上下文
3未取消的监听与任务页面销毁后回调仍触发挂生命周期作用域
4图片是否按目标尺寸解码大图解码后内存占用远超显示尺寸采样率 + 目标尺寸解码
5缓存是否缺少淘汰策略缓存目录/内存缓存持续变大加 LRU 或定期清理

前两项占了我实际遇到的泄漏问题的绝大多数。它们的共同特征是"引用链上有一个生命周期不匹配的持有者",只要顺着引用链往上找,总能找到那个活得太久的对象。

6.3 一份可以贴在工位上的速查表

最后整理一份我自己常用的速查表,遇到问题时先对着看一遍:

现象最可能的原因快速验证方式
按钮点击无反应上层 View 的触摸监听返回了 true临时移除触摸监听再试
页面内容整体下移Insets 监听里 padding 被累加打印每次监听时的 padding 值
输入框获取焦点但键盘不弹窗口软键盘模式设置冲突检查清单或代码里的模式设置
缓存清理后文件仍在写到了持久目录而非缓存目录打印文件绝对路径
请求一直转圈网络判断只看了连接状态打印能力判断的两个标志位
页面销毁后仍崩溃任务未挂生命周期作用域在回调里打印页面状态

这张表不是万能的,但它能帮你在一分钟内排除掉那些"看一眼就知道"的问题,把时间留给真正难查的。

我个人在实际维护这套片段的过程中最大的体会是:片段库的价值不在数量,在于每一段你都说得清它的边界。我现在这个库里也就三四十个函数,但每一个我都知道它在什么情况下不能直接用、改哪个参数会有什么后果,这比收藏五百个"看起来能用"的代码有用得多。另外一个小建议,给每个片段加一句"什么时候不要用它"的注释,这句话比代码本身更能省下将来的时间。例如单位转换那一段,我写的注释是"不要用于 sp 与 dp 的互换,字体缩放会让结果失效";权限那一段写的是"不要在 onCreate 之外注册 launcher"。这些反向说明,才是真正防止误用的部分。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进