首页 / 资讯中心 / 文章详情

Android常用代码沉淀指南:公共代码目录边界与工程实践

Android常用代码沉淀指南:公共代码目录边界与工程实践 ★ FEATURED ARTICLE
接手一个新项目时我最先看的往往不是架构图而是它的公共代码目录。原因很简单——业务代码写得再花哨也只是这一个版本的事而那些被反复复制粘贴的android 常用的代码才决定了这个项目未来三年是越写越顺手还是每次改动都提心吊胆。我见过不少工程utils包里有三十多个类其中二十个只被引用过一次剩下十个每个都有三四个版本的分身同一件事在不同页面有不同写法出了问题谁也不敢删。也见过另一种极端项目干净得过分连个 dp 转 px 都要每次现写同一个转换逻辑在八个文件里各有一份改一次需求改八次代码。这篇内容想聊的就是怎么在这两端之间找到平衡哪些 Android 常用代码真正值得沉淀成固定积木哪些看起来通用其实是个陷阱以及我这些年积累下来的、可以直接拿走改造的片段和它们背后的取舍逻辑。不管你是刚开始做 Android、还在被各种模板代码绕晕还是已经写了几年、想把自己的代码库收拾一遍下面这些内容应该都能让你少走一些弯路。1. 常用代码的边界哪些该沉淀哪些压根不该进公共目录1.1 一个判断标准比一百个工具类更重要刚工作那两年我有个毛病看到什么好用的方法都想往utils里塞觉得这就是沉淀。后来维护成本狠狠教育了我DateUtils里塞了日期格式化、日期比较、日期加减、农历转换、时区处理最后一个类四百多行任何一个人想改其中一小块都得先读完整个文件还得担心动到别处的调用方。真正的转折点是我开始用一个很朴素的三问来判断某段代码要不要进公共目录这个东西是不是在三个以上的业务模块里被用到它是不是完全不携带业务语义它解决的问题是不是平台本身就该解决却没解决的。第一问筛掉了大量我觉得以后可能有用的代码。三个模块是个经验阈值低于这个数字你完全可以在各自的模块里写一份等真的出现第四次复制的时候再抽——这个原则在很多地方被叫做三次法则听着土但极其有效。因为复制两次的成本远低于过早抽象的成本尤其当你不确定这两个地方的未来需求是否会分叉时。第二问筛掉的是那种表面通用、实际和业务强绑定的代码比如一个叫formatPrice的方法看着像个工具但它内部的取整规则、货币符号、是否显示小数位全都是某一条业务规则决定的这种代码放进公共目录等于把一个业务决策藏进了一个所有人都能改的地方早晚出事。第三问是最容易被忽略的。Android 平台在演进过程中留下了大量坑比如不同版本的权限申请流程不一致、不同厂商的状态栏高度获取方式不一致、软键盘弹出时布局是否被顶起不一致。这些平台缺陷的补丁是公共代码目录里最有价值的部分因为它们几乎不可能携带业务语义而且一旦某个地方踩过坑修好了别的地方复制一份大概率会漏掉当初那个边界条件。我现在的习惯是utils目录里超过一半的代码都属于这一类——它们不是为了方便而存在的是为了不让同一个坑被踩第二次而存在的。1.2 共享代码目录的组织方式比代码本身更影响长期体验很多人只关心代码内容忽略了放置位置。我自己的项目里一般这样分platform放平台缺陷补丁比如尺寸转换、状态栏、软键盘、版本判断infra放基础设施比如日志、存储封装、线程调度ui放自定义控件和通用交互extension放 Kotlin 扩展函数。这个分法的好处是当你看到某个类在platform里你就知道它是一段跟系统版本或厂商差异作斗争的代码改动它必须考虑兼容性当它在infra里你就知道它可能被全局调用改动影响面大。反过来说我不太建议把所有东西都堆进一个utils包。名字本身不产生问题问题是这个名字传递的信号太弱了——它暗示这里什么都可以放久而久之就成了垃圾桶。我接手过一个项目utils包下四十几个文件有做网络请求的、有做图片压缩的、有做埋点的、还有几个明显是 App 类和 Application 配置被误放进来的。这种包最大的坏处不是不整洁而是当你需要找一个东西时根本不知道该往哪找新人更是完全靠全局搜索,而全局搜索的结果往往是四五个同名不同实现的版本选哪个全凭运气。另外提一句无论怎么分目录请在代码里对这段代码为什么长这样写一句注释。尤其是补丁类代码比如某个方法里多了一次post到主线程队列的操作不写清楚的话下一个维护者很可能觉得这是多余的顺手删掉然后线上问题原样复现。注释不用长一行就够但它是这类代码能活下来的关键。1.3 常见但不该沉淀的几类代码有几类代码我见过无数人往公共目录里搬但搬完之后收益非常有限。第一类是 Activity 之间的跳转封装很多人喜欢写一个Navigation类把所有页面的跳转参数都包一层。这在小项目里确实省事但一旦页面数量过百、参数经常变动这个类就会变成一个巨大的、需要频繁修改的公共依赖改一个参数要牵动整个编译单元。相比之下我更倾向于每个页面自己暴露一个静态的启动方法参数校验就在那个方法里做跳转逻辑跟着页面走改动只影响一个文件。第二类是简单的集合操作封装比如isEmpty(list)、getOrNull(list, index)这种。在 Java 时代这类代码还有点价值转 Kotlin 之后标准库已经覆盖得很好了isNullOrEmpty()、getOrNull()都是现成的再包一层纯属增加认知负担。第三类是针对具体第三方库的二次封装比如给某个图片加载库包一个ImageLoader。这个做法在当年是合理的因为库可能换但现在的常见做法是先抽象接口再决定要不要包而不是上来就包一层薄的壳——薄壳除了增加一次跳转什么也没解决。2. 界面层最高频的几段代码尺寸、状态栏、列表与输入2.1 dp、px、sp 的互转以及为什么不要自己写一套这是几乎每个 Android 项目都有的方法但写错的比例高得吓人。最常见的错误是直接用density而不用scaledDensity或者在 sp 转 px 时忽略了系统字体缩放。下面这份是我目前用的版本object DensityUtils { fun dp2px(context: Context, dp: Float): Float dp * context.resources.displayMetrics.density fun px2dp(context: Context, px: Float): Float px / context.resources.displayMetrics.density fun sp2px(context: Context, sp: Float): Float sp * context.resources.displayMetrics.scaledDensity fun px2sp(context: Context, px: Float): Float px / context.resources.displayMetrics.scaledDensity }density和scaledDensity的区别是理解这套代码的关键。前者是物理密度系数用来把 dp设备无关像素换算成真实像素它只和屏幕本身有关后者在系统字体大小被用户调大或调小之后会跟着变化所以凡是跟文字相关的尺寸都必须走scaledDensity。我曾经遇到过一个问题某个页面的标题栏高度用的是sp2px换算用户把系统字体调大之后标题栏高度跟着涨把下面的内容挤得只剩一条缝。修复方式很简单把高度那一处换成dp2px——高度是布局尺寸不该受字体缩放影响而文字大小才该。还有一个更常见的错误把转换结果缓存成静态常量。比如在 Application 里算一次screenWidth存起来然后全局使用。这套逻辑问题不大但要注意它是基于当时的Configuration算出来的如果应用支持分屏、折叠屏形态切换或者用户在运行期间改了字体大小这个缓存值就过期了。我的做法是缓存屏幕宽高这类稳定的值但密度相关的转换每次都现算代价不过是几次乘法完全没必要为了省这点开销引入状态不一致的风险。2.2 状态栏与沉浸式一套能覆盖多数机型的写法状态栏这块的代码我见过的实现版本大概有十几种核心差异在于是否透明图标颜色是深是浅内容是否延伸到状态栏下方这三件事的组合。现在的系统版本Android 6.0 之后已经支持设置状态栏图标的亮暗所以主流方案变得简单了不少fun setLightStatusBar(activity: Activity, light: Boolean) { val window activity.window if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { val controller WindowCompat.getInsetsController(window, window.decorView) controller.isAppearanceLightStatusBars light } }light为 true 时状态栏图标变深色适合白底页面为 false 时变浅色适合深色背景。这里用WindowCompat而不是直接操作decorView.systemUiVisibility是因为它把不同版本的分支都收进去了代码量少一半可读性也好得多。真正需要小心的是是否让内容延伸到状态栏下方。如果你在主题里设置了windowTranslucentStatus或者调用了setDecorFitsSystemWindows(false)内容会铺满整个屏幕这时候必须自己在布局里加上状态栏高度的 padding否则第一行文字会被状态栏压住。状态栏高度不要硬编码成24dp也不要用getIdentifier反射去查——这两种做法都有坑。更稳的做法是监听窗口插入区域ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets - val top insets.getInsets(WindowInsetsCompat.Type.statusBars()).top v.setPadding(v.paddingLeft, top, v.paddingRight, v.paddingBottom) insets }这套写法在横屏、分屏、刘海屏下都能拿到正确值代价是需要处理重复回调每次插入区域变化都会触发一次所以在里面设置 padding 时要基于原始值不要累加。2.3 RecyclerView 的通用写法把刷新逻辑收成一处列表是 Android 里用得最多的控件也是最容易写出问题的地方。我见过最典型的毛病是全量刷新导致闪烁和滚动位置丢失这两者的根源都是调用了notifyDataSetChanged()。正确的做法是用DiffUtilclass ItemDiffCallback( private val oldList: ListItem, private val newList: ListItem ) : DiffUtil.Callback() { override fun getOldListSize() oldList.size override fun getNewListSize() newList.size override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean oldList[oldPos].id newList[newPos].id override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean oldList[oldPos] newList[newPos] } fun RecyclerView.Adapter*.submit(old: ListItem, new: ListItem) { val result DiffUtil.calculateDiff(ItemDiffCallback(old, new)) result.dispatchUpdatesTo(this) }areItemsTheSame判断是不是同一个对象用唯一 id 比较areContentsTheSame判断内容有没有变用equals。这两个方法的实现质量直接决定了动画效果对不对、闪烁会不会出现。我踩过的一个坑是areContentsTheSame里只比较了部分字段结果某个字段变了但列表不刷新排查了半天才发现是自己漏写。后来我的做法是让数据类成为data class直接用默认的equals除非有明确的性能考量比如列表里带了一个很大的 Bitmap 字段否则不要手写比较逻辑。另一个容易被忽略的点是DiffUtil.calculateDiff在主线程执行列表项上千时会卡。这时候可以换AsyncListDiffer或者ListAdapter把计算放到后台线程。代价是刷新变成异步的如果你的业务里存在刷新完立刻滚动到某个位置这类需求要处理好时序。2.4 软键盘与输入框那段永远调不完的代码软键盘相关的代码我愿称之为Android 开发的成人礼——几乎每个人都会被它折磨过一次。核心的难点在于键盘弹出时你希望布局怎么响应。系统提供了三种windowSoftInputModeadjustResize、adjustPan、adjustNothing。adjustResize会压缩可用区域让底部按钮跟着上移这是表单页最需要的行为adjustPan是整体上推容易把顶部内容推出屏幕adjustNothing就是不响应需要自己监听高度变化手动处理。我常用的判断是有输入框且底部有提交按钮的页面用adjustResize纯浏览页面用adjustNothing聊天类页面手动监听。监听键盘高度的做法是给根布局设置OnGlobalLayoutListener比较窗口可见区域和屏幕高度的差值fun View.observeKeyboard(block: (visible: Boolean, height: Int) - Unit) { viewTreeObserver.addOnGlobalLayoutListener { val rect Rect() getWindowVisibleDisplayFrame(rect) val screenHeight rootView.height val heightDiff screenHeight - rect.bottom val visible heightDiff screenHeight * 0.15 block(visible, heightDiff) } }那个0.15的阈值很重要。不同设备底部的导航栏、手势条高度不一样如果把阈值设成 0 或者一个很小的像素值键盘明明没弹出也会被判为弹出。0.15 是很多人验证过的一个经验值能覆盖绝大多数情况。另外记得在合适的时机移除监听ViewTreeObserver在onDestroyView之后还留着回调是经典的泄漏点。3. 数据落地偏好设置、文件与数据库的常用写法3.1 偏好设置封装以及 apply 和 commit 该怎么选SharedPreferences的代码几乎是每个项目的标配但封装质量差异很大。我见过最简的封装就是一个putString一个getString也见过把整个偏好设置做成一个带三十个字段的类。我自己倾向于用小范围的、按业务聚合的封装而不是一个大而全的class UserPrefs(context: Context) { private val sp context.applicationContext .getSharedPreferences(user_prefs, Context.MODE_PRIVATE) var token: String? get() sp.getString(KEY_TOKEN, null) set(value) sp.edit().putString(KEY_TOKEN, value).apply() fun clear() { sp.edit().clear().apply() } companion object { private const val KEY_TOKEN token } }这里有个细节值得单独说apply()和commit()的区别。commit()是同步写磁盘返回是否成功会阻塞调用线程apply()是先改内存再异步落盘不返回结果。绝大多数场景都该用apply()因为你在主线程写一个偏好设置如果用了commit()一次磁盘 IO 可能就是几毫秒到几十毫秒的卡顿用户能感知到。但apply()有个坑如果进程在异步落盘完成前被杀掉这次写入就丢了。所以对于必须写进去的关键数据比如登录态我会在onPause或者退出流程里用一次commit()兜底。还有一点SharedPreferences的实例本身是被缓存的同一个名字多次getSharedPreferences拿到的是同一个对象所以不用担心重复创建。但要小心在 Application 里保存了非常多的键值对——它在启动时会全部加载进内存几百条还行上千条就要考虑换方案了。3.2 文件读写的正确姿势文件操作的代码不长但错误处理经常被省略。我最常用的读写封装是这样的fun File.writeTextSafely(content: String) { var out: OutputStream? null try { out FileOutputStream(this) out.write(content.toByteArray(Charsets.UTF_8)) out.flush() } finally { runCatching { out?.close() } } } fun File.readTextSafely(): String? runCatching { inputStream().use { it.readBytes().toString(Charsets.UTF_8) } }.getOrNull()两个要点。第一字符集必须显式指定不要依赖平台默认值否则在某些环境下中文会变乱码。第二读的时候用use确保流被关闭写的时候用try/finally避免异常路径下句柄泄漏。至于读不到返回 null还是读不到抛异常这个取决于调用方——如果是配置文件缺失返回 null 让上层给默认值更合适如果是必须存在的文件抛出去让上层处理更清晰。目录选择也是个常被问的点。应用私有的缓存数据放cacheDir系统空间紧张时会被清理适合放可以重新下载的东西需要长期保留的放filesDir不会被系统自动删对外可见的文件比如导出的图片再走媒体库或者公共目录。把不该放cacheDir的东西放进去用户清一次缓存数据就没了这类问题排查起来往往很费劲因为本地复现不了。3.3 数据库模板代码只是少写几个字还是真的有用关于数据库我的态度比较明确能上 Room 就上 Room手写SQLiteOpenHelper除非有特殊需求。原因不是 Room 更高级而是它把那堆重复到令人厌烦的模板代码自动生成了。建表、查询、游标转换、版本迁移手写一遍要几百行而且每次改表结构都要小心翼翼地改onUpgrade里的分支。Entity(tableName note) data class NoteEntity( PrimaryKey(autoGenerate true) val id: Long 0, ColumnInfo(name title) val title: String, ColumnInfo(name created_at) val createdAt: Long ) Dao interface NoteDao { Query(SELECT * FROM note ORDER BY created_at DESC) fun observeAll(): FlowListNoteEntity Insert(onConflict OnConflictStrategy.REPLACE) suspend fun upsert(note: NoteEntity): Long Query(DELETE FROM note WHERE id :id) suspend fun deleteById(id: Long) }这段代码里有几个值得展开的地方。Flow返回值意味着表数据变化时会自动推送新结果配合界面层的收集就不需要手动在每次写入后重新查询——这是减少数据改了界面没变这类 bug 的有效手段。onConflict REPLACE处理的是主键冲突适合有则更新无则插入的场景但如果你的业务需要区分这两种情况就得换成IGNORE或者先查后写。迁移是另一个高频踩坑点。新增字段时如果不写迁移fallbackToDestructiveMigration()会让整个库被清空重建——开发期无所谓线上就是数据丢失。我的习惯是每次改表结构都老老实实写一个Migration哪怕只是加一个带默认值的字段val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(ALTER TABLE note ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0) } }注意NOT NULL必须给DEFAULT否则已有行无法填充新列迁移会直接失败。4. 线程切换与生命周期安全异步代码的固定套路4.1 几种线程切换方式以及我现在的选择早期做 Android 的时候异步基本靠AsyncTask后来是HandlerThread再后来是各种线程池封装现在主流是协程。这几种方案没有绝对的对错关键看你团队的技术栈和项目的复杂度。我整理了一个对比供选型时参考方案适用场景主要问题Handler Thread简单的一次性后台任务手动管理生命周期易泄漏线程池 Executor大量短任务、需要控制并发数回调嵌套后代码难以阅读HandlerThread串行的后台任务队列需要自己维护消息循环协程绝大多数异步场景需要理解作用域与取消机制现在我基本只用协程偶尔在跟老代码打交道时用线程池。原因很实际协程的取消是结构化的父作用域取消时子任务全部跟着取消这解决了一个非常折磨人的问题——页面销毁了后台任务还在跑跑完回来更新一个已经不存在的界面然后崩溃。用线程池实现同样的效果你得手动维护一个任务集合在onDestroy里逐个取消还得处理取消过程中的竞态。4.2 协程作用域为什么不能在 ViewModel 里随便 launch协程最容易犯的错是在错误的作用域里启动任务。GlobalScope.launch是最典型的反例它的生命周期跟整个进程一样长任务不会随页面销毁而取消等于把线程池时代的老问题原样搬了过来。正确的做法是绑定到生命周期界面相关的用viewLifecycleOwner.lifecycleScope业务逻辑相关的用viewModelScope纯后台的用自定义作用域并在合适的时机取消。class DetailViewModel : ViewModel() { fun load(id: Long) { viewModelScope.launch { val data withContext(Dispatchers.IO) { repository.fetch(id) } _state.value UiState.Success(data) } } }viewModelScope在ViewModel被清理时自动取消所以它不会泄漏。withContext(Dispatchers.IO)把阻塞操作切到 IO 线程池回到主线程继续更新状态这个写法比回调链清晰太多。还有一个细节Dispatchers.IO和Dispatchers.Default别混用。前者是为阻塞 IO 准备的线程池较大后者是 CPU 密集计算用的线程数等于 CPU 核心数。把一个大文件解析放在Default里会占满计算线程导致其他并行任务排队反过来把纯计算放在IO里线程数虽多但频繁切换反而更慢。4.3 防抖与节流两个短小但极其实用的片段这两个东西几乎在每个项目里都要用到但每次都现写、每次都写错。防抖是连续触发只在最后一次执行适合搜索框输入、按钮连点节流是固定时间内只执行第一次适合滚动监听、频繁点击。用协程实现很简洁fun CoroutineScope.debounce( delayMillis: Long, action: suspend () - Unit ): Job { var job: Job? null return launch { job?.cancel() job launch { delay(delayMillis) action() } } }这段代码的思路是每次触发都取消上一个待执行的任务重新计时只有最后一次能走完整个 delay。用起来大概是在搜索框的文本变化回调里调用它传 300 毫秒用户停止输入 300 毫秒后才真正发起请求。注意job变量的作用域如果在launch外面定义了var job: Job?又在外层函数里反复调用会因为闭包问题导致取消失效——我之前就因为把它写成了类成员变量导致多个页面共用一个 job互相取消表现是搜索偶尔没反应查了很久。节流更简单记录上次执行时间戳比较当前时间差值即可fun throttleClick(interval: Long 600L, onClick: () - Unit): () - Unit { var last 0L return { val now SystemClock.elapsedRealtime() if (now - last interval) { last now onClick() } } }用elapsedRealtime而不是currentTimeMillis是因为前者不受系统时间调整影响。用户手动改了时间后者可能变成负数或者大幅跳跃导致节流失效或永久卡死。5. 出问题时的排查链路三个真实场景的完整复现5.1 页面退出后崩溃从堆栈到根因的走法先说一个我遇到过的场景。某个列表页快速进入再返回偶尔闪退堆栈指向一个空指针说某个 View 是 null。看代码onClick里调用了binding.title.text ...看起来没毛病。往上看这个点击回调绑定在一个网络请求的回调里请求返回后执行 UI 更新。问题就在这——请求返回时页面可能已经销毁binding被置空于是空指针。排查这类问题的路径是固定的先看崩溃堆栈里最上面一帧属于你的代码在哪再看那一行用到的对象是什么生命周期然后反推这个对象的销毁时机和回调执行时机有没有重叠。上面这个例子里binding的生命周期是onCreateView到onDestroyView网络请求的生命周期是整个应用两者的交集不完整所以必然有重叠不了的时刻。修复方式有几种我按推荐顺序排第一把请求发到viewModelScope界面只负责渲染状态ViewModel销毁时请求自动取消第二如果必须用回调在回调里判断viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)再更新第三用viewLifecycleOwner.lifecycleScope包裹回调里的更新逻辑。最不推荐的是加if (binding ! null)判断因为这在多线程下不是原子的而且它掩盖了真正的问题。5.2 列表刷新闪烁一次完整的现象还原现象是下拉刷新时列表闪一下滚动位置跳回顶部。第一反应是网络返回的数据顺序变了但对比日志发现顺序一致。第二反应是用了notifyDataSetChanged改成了DiffUtil闪烁没消失。第三反应才是找到真因局部刷新时调用了notifyItemRangeInserted但没同时处理被替换的那部分导致动画重叠。排查这个问题的关键手段是把每次刷新前后的列表快照打出来对比差异。我用的是在submit前比较新旧列表的长度和首尾元素 idLog.d(TAG, old${oldList.size} head${oldList.firstOrNull()?.id} new${newList.size} head${newList.firstOrNull()?.id})这个方法很土但特别有效。因为闪烁和跳顶这两类现象绝大多数都来自你以为是增量刷新实际上是全量替换或者id 不稳定导致 DiffUtil 判断成全部新增。后者尤其隐蔽——如果areItemsTheSame里用了hashCode或者位置索引数据内容变了 id 就变了DiffUtil 会把整批条目都当成新条目全部走插入动画视觉上就是闪一下。还有一个可能被忽略的原因RecyclerView外面套了一层自适应高度的容器或者本身的高度是wrap_content。这种情况下条目变化会触发布局重新测量测量过程中列表可能短暂回到顶部。修复办法是给RecyclerView一个确定的高度或者设置setHasFixedSize(true)前提是条目尺寸不受内容变化影响。5.3 内存问题单例持有上下文的常见变体内存问题在 Android 上有个非常有辨识度的入口一个长生命周期的对象持有了短生命周期的上下文。最常见的写法是单例类里保存了一个Context字段传入的是 Activity。这类问题排查时可以借助工具看引用链但更快的方法是在代码里全局搜索context这个词重点看单例、伴生对象、静态变量里的。我遇到过的一个变体是某个工具类做成了object里面存了一个Context用于获取字符串资源注释写着这里传的是 Application。但实际上是某个页面调用时传了this。这种约定靠注释的写法极其危险因为没有任何机制保证调用方守规矩。修复方式是在构造函数里强制转换class ResHelper(context: Context) { private val appContext: Context context.applicationContext }applicationContext这个调用即便传入的是 Activity也会返回 Application 的上下文所以它是安全的兜底。代价是如果你真的需要 Activity 上下文比如弹对话框得显式传不给它走这个兜底的机会。另一类变体是匿名内部类持有外部引用。比如在 Activity 里handler.postDelayed({ ... }, 60000L)这个 lambda 隐式持有 Activity六十秒内 Activity 都无法回收。解决方式是把这个延时任务绑到生命周期上或者用viewLifecycleOwner.lifecycleScope配合delay页面销毁时自动取消。6. 把这些碎片收成体系扩展函数、日志与兼容代码6.1 Kotlin 扩展函数好用但别滥用扩展函数是 Kotlin 里最容易上瘾的东西因为它让给已有类型加方法变得极其廉价。我在项目里用得最多的是这几类View 的可见性控制、Context 的快捷获取、字符串的空值安全处理。fun View.visible() { visibility View.VISIBLE } fun View.gone() { visibility View.GONE } fun View.visibleIf(condition: Boolean) { visibility if (condition) View.VISIBLE else View.GONE } fun Context.dp(value: Float): Float value * resources.displayMetrics.densityvisibleIf这个比单独写if/else好用因为它把条件决定可见性这个意图表达得很清楚读代码的时候不用在脑子里做一次分支推演。但扩展函数有个陷阱它不能访问私有成员也不能被重写。所以如果你需要多态还是得用继承或者接口。另外扩展函数的解析是静态的——写在哪个文件里就按那个文件的导入解析如果两个文件里有同名同签名的扩展函数很容易搞混。我的经验是扩展函数只放那些一眼能看懂干什么的短逻辑超过五行就改成普通函数或者工具类否则调试的时候栈里全是扩展函数名找不到真正出问题的地方。6.2 日志封装为将来能关掉做准备直接调Log.d的问题不在于难用而在于很难统一管理。上线包想关掉调试日志得全局搜索替换想把日志上报到崩溃平台得每处都改。所以从项目一开始就该有个薄薄的封装object Logger { var enabled BuildConfig.DEBUG private const val TAG App fun d(msg: String) { if (enabled) Log.d(TAG, msg) } fun d(tag: String, msg: String) { if (enabled) Log.d(tag, msg) } }这个封装只有十几行但它提供了三个好处一个开关能全局关闭将来加日志上报只需要改一处统一了默认 TAG看日志时不会一堆不同的前缀。我不建议在这里做太复杂的处理比如自动添加调用栈或者线程名——那些会让每次日志调用都变慢而且大多数时候你并不需要。真需要定位问题时临时加回来就是。6.3 版本兼容代码的收敛Android 的版本分支代码是必要的恶但如果散落在各处很快就会失控。我的做法是把所有Build.VERSION.SDK_INT判断收进一个文件业务代码永远调封装后的方法object OsCompat { val isAtLeastM: Boolean get() Build.VERSION.SDK_INT Build.VERSION_CODES.M val isAtLeastQ: Boolean get() Build.VERSION.SDK_INT Build.VERSION_CODES.Q }这样做的直接好处是将来最低支持版本提升时你只需要在这个文件里删掉一两个常量而不必去全项目搜索那些散落的版本判断。更实际的好处是代码里出现Build.VERSION的地方变少了review 时一眼就能看出哪些地方涉及兼容性处理改动风险更可控。顺带一提关于旧版本兼容我个人的取舍是只保留对那些仍有真实用户占比的版本的适配。判断依据应该是实际的设备分布数据而不是万一还有人用的假设。保留过多历史分支维护成本会越滚越高而且每一段兼容代码都是一个可能出问题的地方。定期清理这些分支比不断新增兼容处理更重要。最后再分享一个自己的习惯我每隔半年会把platform目录过一遍把那些因为系统升级而失效的代码删掉。删代码这件事往往比写代码更能提升项目质量。
阅读完成 · 觉得有帮助?
咨询建站