news 2026/11/3 8:52:48

重做Android常用代码片段库:生产可用与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
重做Android常用代码片段库:生产可用与避坑指南

1. 我为什么要重做一份自己的代码片段库

整理代码片段这件事,我从入行第二年就开始做了。那时候项目里反复出现同样的 dp 换算、同样的 SharedPreferences 封装、同样的点击防抖,每换一个项目就重新搜一遍、复制一遍,改改包名塞进去,下一次再重来。后来踩的坑多了才发现,网上流传的那些 Android 开发常用经典代码段集锦,很大一部分是七八年前的老写法,直接贴进现在的工程里,编译能过,跑起来却处处别扭:文件读写返回空、通知发出去没反应、权限回调永远走不到、后台任务被系统掐掉还不报错。

所以我把自己这些年沉淀下来的 Android 常用代码合集重新梳理了一遍,按“能不能直接上生产”这条线过筛子,把版本差异、边界条件、失败场景写在代码旁边,而不是只留一段光秃秃的方法。这份内容适合刚接触 Android、还在到处搜代码段的新人——你能拿到可以直接跑的写法;也适合带项目多年的老手——你可以对照看看自己的工具箱里是不是还躺着几个过期的片段。

我筛片段的四条标准很土但很好用:第一,依赖要少,能不引第三方库就不引,引了就要说清楚为什么;第二,代码要能独立编译,不依赖某个项目里的私有类;第三,写清楚适用的 API 区间和已知失效场景;第四,一定是项目里真实跑过的,不是我看文档脑补出来的。后面所有的代码段都按这套标准来。

2. 尺寸、像素与适配相关的高频片段

2.1 dp、sp、px 换算,那个 +0.5f 不是玄学

这是被复制次数最多的一段,也是最容易被改错的一段。很多网上流传的版本把+0.5f删掉了,觉得多余,结果在低密度屏上算出来的边距永远差一两个像素。

object DensityUtil { fun dp2px(context: Context, dp: Float): Int { val density = context.resources.displayMetrics.density return (dp * density + 0.5f).toInt() } fun px2dp(context: Context, px: Float): Int { val density = context.resources.displayMetrics.density return (px / density + 0.5f).toInt() } fun sp2px(context: Context, sp: Float): Int { val scaledDensity = context.resources.displayMetrics.scaledDensity return (sp * scaledDensity + 0.5f).toInt() } }

+0.5f的作用是四舍五入。Float 转 Int 是直接截断,3.9f.toInt()得到 3 而不是 4,长年累月叠加下来,一个列表项的左边距就会和设计稿对不上。加上 0.5 再截断,等价于四舍五入,这是最省事的写法,比Math.round()少一次函数调用开销。

scaledDensity这个字段在新版本里被标记为废弃,官方建议改用TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp, resources.displayMetrics)。我实测两种写法结果一致,但如果你要处理“用户在系统里把字体调到超大”的场景,用applyDimension更稳妥,因为它走的是系统统一的换算路径。

注意:displayMetrics不要缓存成静态变量。分屏、折叠屏展开、外接显示器切换分辨率的时候,resources.displayMetrics是会变的,缓存下来会导致界面在切换形态后整体错位。每次调用时从context.resources现取,代价可以忽略。

2.2 图片采样率计算,为什么结果必须是 2 的幂

做图片列表的时候,不加采样直接BitmapFactory.decodeFile是内存爆炸的头号原因。一张 4000×3000 的照片用 ARGB_8888 解码,占用是 4000×3000×4 字节,约 45.8MB,四张就够触发中低端机型的 OOM。

正确的做法是先读边界再按需采样:

fun calculateInSampleSize( options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int ): Int { val height = options.outHeight val width = options.outWidth var inSampleSize = 1 if (height > reqHeight || width > reqWidth) { val halfHeight = height / 2 val halfWidth = width / 2 while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth ) { inSampleSize *= 2 } } return inSampleSize }

关键在于inSampleSize *= 2。很多人会写成inSampleSize = width / reqWidth,觉得这样更精确,实际上解码器内部对inSampleSize的处理是向下取到 2 的幂,你传 3 进去,它按 2 算,传 7 按 4 算。既然结果一定是 2 的幂,那就在循环里直接按 2 的幂递增,逻辑和实际行为对齐,不会出现“我算出来是 3,实际是 2”的困惑。

用的时候按两步走,第一步inJustDecodeBounds = true只读尺寸不分配像素内存,第二步带上算好的采样率真正解码,最后把inJustDecodeBounds改回 false。

DensityUtil 和采样率算下来,一个图片列表的内存占用能做到原来的一成左右。我习惯在代码旁边写一行注释标注计算公式,比如// 4000*3000*4 ≈ 45.8MB,下次有人想改成不采样,看到这行字会犹豫一下。

2.3 用最小宽度限定符做尺寸适配,别再用宽高限定符

早年的适配方案是values-480x800、values-720x1280这种按具体分辨率切 dimens,问题是机型多到根本枚举不完。现在的做法统一走smallestWidth,也就是values-sw360dp、values-sw411dp这类。

判断依据是设备最短边换算成 dp 后的值,公式是min(宽像素, 高像素) / (densityDpi / 160)。比如一台 1080×2400、densityDpi 为 480 的设备,最短边 1080 除以 3,得到 360dp,就会命中sw360dp目录。同一份设计稿在不同厂商、不同分辨率下只要最短边 dp 相同,布局表现就一致,这是它比宽高限定符可靠的地方。

实际操作不用手写,有成熟的脚本或者插件能按设计稿批量生成多个 sw 目录的 dimens 文件。我会生成 sw320 到 sw600 之间的若干档,再往上交给平板布局单独处理。手写的话工作量太大,而且极容易算错。

实操心得:sw 限定符只管横向尺寸,不管横竖屏。如果你的界面在横屏下需要完全不同的布局,要配合layout-land一起用,光靠 sw 是分不出来的。

3. 界面交互里几个“看起来简单”的片段

3.1 点击防抖,别在业务里到处写时间戳

按钮连点是客户端最经典的问题。网上常见的写法是在每个点击回调里存一个lastClickTime,写十次就有十份重复逻辑,还容易漏。

我一般抽一个独立的小类:

class ClickGuard(private val intervalMs: Long = 500L) { private var lastClickTime = 0L fun allow(): Boolean { val now = SystemClock.elapsedRealtime() if (now - lastClickTime < intervalMs) { return false } lastClickTime = now return true } }

这里用elapsedRealtime()而不是System.currentTimeMillis(),原因是后者受系统时间影响,用户手动改时间或者网络校时都可能导致时间回跳,防抖就会失效,甚至出现“怎么点都没反应”的诡异现象。elapsedRealtime是开机后的单调递增时间,不受这两件事干扰。

500ms 这个值不是拍脑袋定的。人手指快速连点的间隔一般在 100 到 200ms 之间,设成 300ms 基本能拦住误触,设成 500ms 更保守一些,适合提交订单、支付这类不能重复触发的场景。如果是列表项跳转,300ms 就够了,太长会让人觉得卡。

用法上,全局一个实例控制同一个按钮,或者每个按钮一个实例。别所有按钮共用一个实例,否则点了 A 按钮,B 按钮在 500ms 内点不动,用户会觉得界面卡死。

3.2 沉浸式状态栏和软键盘打架的处理

沉浸式这块的代码,我见过最常见的错误是把setDecorFitsSystemWindows(false)加上去之后,界面直接顶到状态栏下面,内容被遮住,然后开发者在根布局里硬写一个 24dp 的 padding,换成刘海屏又不对了。

正确的处理是监听窗口插入变化,动态拿 insets:

WindowCompat.setDecorFitsSystemWindows(window, false) ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets -> val bars = insets.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() ) view.setPadding(bars.left, bars.top, bars.right, bars.bottom) insets }

把systemBars()和ime()一起取,好处是软键盘弹起时底部 padding 会自动变成键盘高度,输入框自然被顶上来,不需要再手动调adjustResize或者算键盘高度。这个写法在大多数机型上表现一致,也不需要额外的键盘高度监听。

注意:如果你的界面里有 RecyclerView,键盘弹起时底部 padding 变化会触发一次重新布局。列表很长的时候会有轻微跳动,可以在ime()高度为 0 的时候跳过 padding 设置,减少无谓的重排。

还有一个细节:setDecorFitsSystemWindows(false)之后,状态栏图标颜色不会自动适配深浅主题,需要自己调WindowInsetsControllerCompat.isAppearanceLightStatusBars。深色背景配白色图标,浅色背景配深色图标,不设置的话在深色模式下会出现“白底白字”的状态栏。

3.3 RecyclerView 通用 Adapter 与 ViewBinding 的释放时机

用 ViewBinding 写 Adapter 能省掉大量 findViewById,但有一处特别容易漏:在onDestroyView里把 binding 置空。Fragment 里不置空会造成整棵视图树被持有,页面来回切换几次内存就上去了。

private var _binding: FragmentListBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentListBinding.inflate(inflater, container, false) return binding.root } override fun onDestroyView() { super.onDestroyView() _binding = null }

_binding用可空类型加一个非空的get()访问器,这个模式看起来绕,但它能在视图销毁后立刻抛出空指针,让你第一时间发现“在错误的生命周期里访问了 binding”,而不是拿到一个已经脱离窗口的旧视图继续操作,埋下更难查的 bug。

RecyclerView 的 Adapter 里则要注意onBindViewHolder里不要做耗时操作。我见过有人在 bind 里读 SharedPreferences、做字符串拼接、甚至发网络请求,滑动起来一顿一顿的。bind 只做赋值,数据准备放在 ViewHolder 创建时或者交给后台。

3.4 自定义 View 的测量,别急着用 getWidth

自定义 View 里在onCreate或者构造阶段调getWidth(),拿到的永远是 0。这个坑几乎每个人都踩过一次。

onMeasure阶段拿到的是测量值,onSizeChanged之后才是最终尺寸。如果需要在初始化时按宽高算比例,正确的钩子是onSizeChanged(w, h, oldw, oldh),参数里直接给你最终值。

测量的时候还要处理MeasureSpec的三种模式:EXACTLY直接采用给定值,AT_MOST取给定值和自身期望值的较小者,UNSPECIFIED用自身期望值。很多人图省事直接setMeasuredDimension(measuredWidth, measuredHeight),在wrap_content场景下会得到 0 宽 0 高,控件直接不显示。

实操心得:写完自定义 View,一定在布局里分别用固定值、match_parent、wrap_content三种方式各套一遍。很多测量 bug 只在wrap_content下才暴露。

4. 数据存储与文件读写的常用片段

4.1 SharedPreferences 的封装,先搞清楚 apply 和 commit 的区别

轻量配置项我还是用 SharedPreferences,但会包一层,主要解决两个问题:类型安全,以及避免到处写 key 字符串。

class AppPrefs(context: Context) { private val sp = context.applicationContext .getSharedPreferences("app_config", Context.MODE_PRIVATE) var lastSyncTime: Long get() = sp.getLong(KEY_LAST_SYNC, 0L) set(value) = sp.edit().putLong(KEY_LAST_SYNC, value).apply() private companion object { const val KEY_LAST_SYNC = "last_sync_time" } }

apply()是异步写内存、同步提交到磁盘,commit()是同步写并返回是否成功。日常配置项一律用apply(),只有在“写完立刻要读、而且必须在同一进程内保证读到”的极少数场景才用commit(),因为commit()在主线程调用会阻塞磁盘 IO。

还有一个历史遗留问题:MODE_MULTI_PROCESS这个模式早就废弃了,多进程共享配置不要指望 SharedPreferences。真有多进程需求,应该换成 ContentProvider 封装或者独立的存储方案,而不是继续用它。

4.2 分区存储下的路径选择,先问自己文件该不该被别人看见

分区存储强制之后,文件路径的选择必须按用途来分,不能一律往外部存储根目录扔:

用途推荐路径是否随应用卸载删除是否需要权限
应用私有缓存context.cacheDir是否
应用私有文件context.filesDir是否
外部私有缓存context.externalCacheDir是否
外部私有文件context.getExternalFilesDir(null)是否
用户可见的图片MediaStore.Images否写入时需要
用户自选目录存储访问框架(SAF)否否

绝大多数“保存到本地”的需求,用getExternalFilesDir就够了。路径类似Android/data/包名/files/,不需要任何权限,卸载时系统自动清理,不会在用户手机里留垃圾。只有给用户看、要被相册扫描、或者要被别的应用读取的文件,才需要走 MediaStore。

走 MediaStore 插入图片要用ContentValues加ContentResolver.insert,拿到 Uri 之后再openOutputStream写入。这个流程比直接 File 写麻烦,但它是唯一在分区存储下让文件被相册识别的方式。

注意:getExternalFilesDir在外部存储被移除或者挂载异常时会返回 null。别直接点下去,加一层判空回退到filesDir,否则在少数设备上启动就崩。

4.3 数据库批量写入,事务不是可选项

用 Room 批量插入几百条数据,如果一条一条insert,速度慢得让人怀疑人生。原因每次插入都是一次独立的磁盘事务,开销全在 fsync 上。

@Dao interface RecordDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(items: List<RecordEntity>) @Transaction suspend fun replaceAll(items: List<RecordEntity>) { clear() insertAll(items) } @Query("DELETE FROM record") suspend fun clear() }

@Transaction让整批操作在一个事务里完成,中间任何一步失败都整体回滚,不会出现“删完了但没插进去”的空表状态。

实测数据量在 1000 条这个级别,加事务能比逐条插入快一个数量级。数据量上万的话,还要考虑分批提交,一次塞太多会撑爆内存,一般 500 到 1000 条一批比较合适。

5. 线程、协程与生命周期的常用片段

5.1 从 Handler 到协程,什么时候该切换

Handler 不是不能用,而是它和生命周期的配合太容易出错。handler.postDelayed发出去的任务,页面销毁了还在跑,回调里访问已销毁的视图就崩了。用协程的lifecycleScope能自动跟着生命周期取消:

lifecycleScope.launch { val data = withContext(Dispatchers.IO) { repository.loadData() } render(data) }

launch出来的协程绑定在lifecycleScope上,页面走到DESTROYED时自动取消,withContext(Dispatchers.IO)保证阻塞操作不占主线程。这个写法比 Handler 加手动 removeCallbacks 干净得多。

但也不是所有场景都该换。毫秒级的延迟任务、需要精确按时间点执行的轮询、和一些老 SDK 的 Java 回调接口打交道,Handler 反而更直接。我的判断标准是:和界面生命周期相关的用协程,纯后台的、生命周期无关的用线程池或者 Handler。

5.2 内存泄漏的三种典型写法,每种都有对应的改法

第一种,内部类持有外部 Activity 引用。非静态内部类默认持有外部类实例,Runnable或者Handler被延迟执行时,Activity 就被钉住释放不掉。改法是改成静态内部类加WeakReference,或者干脆用协程。

第二种,ViewBinding没有在onDestroyView里置空。这个前面说过,是最常见的泄漏源,Fragment 反复进出几次,内存曲线就是阶梯上升。

第三种,监听器注册了没反注册。广播接收器、传感器监听、ConnectivityManager.NetworkCallback、SharedPreferences.OnSharedPreferenceChangeListener都属于这一类。统一在onStart注册、onStop反注册,或者用repeatOnLifecycle包裹,都比onCreate/onDestroy更安全,因为后者在页面只可见不可交互的状态下仍然持有监听。

排查手段:跑一段时间操作后,在内存分析工具里看 Activity 实例数量。正常应该只剩当前一个,如果看到好几个,说明有地方没释放,抓一条引用链顺着找,基本十分钟内能定位。

5.3 repeatOnLifecycle,Flow 收集的正确姿势

用 Flow 做数据流的时候,直接在launch里collect是错的——页面切到后台,收集还在跑,白耗电,还可能在不可见状态下弹出提示。

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

repeatOnLifecycle(STARTED)的效果是:页面进入 STARTED 时启动收集,掉到 STOPPED 时取消,再回来时重新启动。它保证收集只在界面可见期间进行,且重新可见时能拿到最新数据。

更省事的写法是用flowWithLifecycle,但注意它需要在 Flow 链路上调用,位置不对会失效。我一般直接用repeatOnLifecycle包住整块收集逻辑,语义更清楚。

6. 系统能力调用与权限适配的片段

6.1 权限申请,别再写 onRequestPermissionsResult

老的onRequestPermissionsResult写法又长又容易漏分支,现在统一用 Activity Result API:

private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result: Map<String, Boolean> -> val denied = result.filterValues { !it }.keys if (denied.isEmpty()) { onAllGranted() } else { handleDenied(denied) } } fun requestPermissions() { permissionLauncher.launch( arrayOf( Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO ) ) }

这个写法把申请和回调绑在一起,不会出现“申请了但不知道是哪个请求回来”的情况。

handleDenied里要区分两种情况:用户点了“拒绝”和用户勾选了“不再询问”。前者还能再弹一次,后者再弹就是无效的,只能引导用户去应用设置页手动开。判断方式是shouldShowRequestPermissionRationale返回 false 且权限未授予,就属于永久拒绝。

注意:权限申请的时机不要在onCreate里直接发,页面还没画出来就弹系统弹窗,用户会一头雾水。等用户点了某个按钮、明确知道要干什么的时候再申请,通过率会高很多。

6.2 通知渠道,少一步就发不出来

从 Android 8.0 开始,没有渠道的通知会被系统直接丢弃,而且不会有任何报错。很多人调完notify没反应,就是因为漏了建渠道。

private fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val manager = context.getSystemService(NotificationManager::class.java) if (manager.getNotificationChannel(CHANNEL_ID) == null) { val channel = NotificationChannel( CHANNEL_ID, "消息提醒", NotificationManager.IMPORTANCE_DEFAULT ).apply { description = "任务进度与提醒" enableVibration(true) } manager.createNotificationChannel(channel) } } }

渠道的IMPORTANCE一旦创建就改不了,只能在渠道设置里让用户自己调。所以重要通知(比如来电、闹钟)和不重要的推广提醒必须分成两个渠道,混在一起用户嫌吵会直接关掉整个渠道,重要通知就一起没了。

新版本的平台还要求动态申请通知权限,用户不同意的话,notify依然是无声无息。发通知前先查areNotificationsEnabled(),返回 false 就别白费劲了,改成应用内的横幅提示。

6.3 跳转系统页面的 Intent 合集

这几段代码用得极其频繁,但每段都有坑:

// 打开应用设置详情页 fun openAppSettings(context: Context) { val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null) ).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } runCatching { context.startActivity(intent) } } // 分享文本 fun shareText(context: Context, text: String) { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, text) } context.startActivity( Intent.createChooser(intent, "分享到") ) }

FLAG_ACTIVITY_NEW_TASK在从非 Activity 的 Context 启动时是必须的,否则直接抛异常。所有startActivity都建议用runCatching包一层,因为某些定制系统可能没有对应的处理组件,ActivityNotFoundException会直接崩掉应用。

分享则一定要用Intent.createChooser包一层。不包的话,如果用户装了多个可分享应用,系统可能直接用一个默认的打开,或者干脆弹出“没有应用可执行此操作”。包一层之后,选择器统一,体验也更符合预期。

7. 调试、日志与线上兜底的片段

7.1 OkHttp 日志拦截器,只在 Debug 里开

网络请求排查离不开日志,但这件事有明确的前提:绝对不能在正式包里打印完整请求体和响应体。

class LoggingInterceptor( private val enabled: Boolean ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { if (!enabled) return chain.proceed(chain.request()) val request = chain.request() val start = System.nanoTime() val response = chain.proceed(request) val costMs = (System.nanoTime() - start) / 1_000_000 Log.d( "HTTP", "${request.method} ${request.url} -> ${response.code} (${costMs}ms)" ) return response } }

只打印方法、URL、状态码和耗时,不打印 Header 和 Body。原因很实际:请求头里经常带着登录凭证,响应体里可能有用户信息,这些进了日志系统就是安全隐患。真要看 Body,用开发机的抓包工具看,别留在代码里。

enabled参数从BuildConfig.DEBUG传进来,正式包编译时被优化掉,连判断都省了。

7.2 网络状态监听的正确写法

判断有没有网,用NetworkCallback而不是轮询getActiveNetworkInfo:

val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { onNetworkChanged(true) } override fun onLost(network: Network) { onNetworkChanged(false) } } val request = NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() connectivityManager.registerNetworkCallback(request, callback)

getActiveNetworkInfo()这个老接口在较新版本上已经废弃,而且它只能反映“有没有连接”,反映不了“连接能不能上网”。NetworkCallback的onAvailable和onLost是系统主动通知,实时性更好。

回调注册了必须反注册,放在onDestroy里unregisterNetworkCallback(callback)。忘记反注册是内存泄漏的经典案例,因为ConnectivityManager持有 callback 引用,callback 又持有 Activity。

注意:onAvailable只代表网络已连接,不代表真的能访问外网。公共场所的认证页面前,网络是连上的但访问会被重定向。真正可靠的判断是加一次轻量的探测请求,或者读系统的NET_CAPABILITY_VALIDATED能力标志。

7.3 全局异常兜底,别让用户看到系统弹窗

线上崩溃至少要让用户看到一个友好的提示,而不是系统的“应用已停止运行”。做法是接管默认的未捕获异常处理器:

class CrashHandler( private val context: Context, private val default: Thread.UncaughtExceptionHandler? ) : Thread.UncaughtExceptionHandler { override fun uncaughtException(thread: Thread, throwable: Throwable) { runCatching { val file = File(context.filesDir, "crash_last.log") file.writeText( "${System.currentTimeMillis()}\n" + Log.getStackTraceString(throwable) ) } default?.uncaughtException(thread, throwable) } } Thread.setDefaultUncaughtExceptionHandler( CrashHandler(applicationContext, Thread.getDefaultUncaughtExceptionHandler()) )

要注意两点。第一,写文件本身也可能失败,整个写入过程必须用runCatching包住,否则异常处理器里再抛异常,会变成死循环。第二,处理完之后必须把异常交还给默认处理器,让它走正常的崩溃流程,否则应用会卡在一个僵死状态,用户以为卡住了,实际进程已经坏了。

日志只留最后一次就够了,不要做成无上限追加,文件越写越大,下次启动读日志还要花时间。

8. 常见问题排查速查

8.1 一张表看完高频故障

现象大概率的根因排查方向
读取文件返回 null分区存储下用了老的外部存储路径改用getExternalFilesDir或媒体库
通知发不出来没建渠道,或通知权限未授予建渠道,检查通知开关
权限回调不触发用了老的 requestPermissions 通道检查是否混用两套 API
界面顶到状态栏下只加了setDecorFitsSystemWindows没处理 insets加OnApplyWindowInsetsListener
图片内存暴涨直接解码原图未采样加两步解码,算 inSampleSize
页面退出后仍在跑逻辑没绑生命周期改lifecycleScope或repeatOnLifecycle
首次安装后部分功能异常权限或渠道在首次启动时未初始化检查初始化时机是否是懒加载
切到后台再回来数据不刷新监听只在onCreate注册一次改到onStart或repeatOnLifecycle

这张表是我这几年查得最多的几类问题,绝大多数都能在表里找到线索。

8.2 版本适配里几个容易忽略的雷区

后台任务的限制是很多人踩坑的地方。页面退到后台之后,普通的定时任务会被系统挂起,Handler.postDelayed在设备进入深度休眠后不再按预期触发。需要长时间运行的任务应该用系统的调度方案,短时的延迟任务则在回到前台时重新计算剩余时间。

屏幕方向变化的处理也常出错。旋转时 Activity 默认重建,如果数据存在成员变量里就会丢。要么用ViewModel保存,要么用onSaveInstanceState存必要字段。用configChanges拦掉重建看着省事,但那样一来资源目录(比如layout-land)就不会重新加载了,要自己手动处理。

再一个是onBackPressed的废弃。过渡期间新旧两套 API 混用,会导致部分机型上返回逻辑执行两次,比如“双击退出”变成单击就退。要么全部换新的返回回调,要么全部用旧的,两套混着用必然出问题。

最后分享一个小习惯:每段代码进我的片段库之前,我都会在注释里写三行——适用版本、已知失效场景、当年踩过的坑。半年后回头看,这三行比代码本身值钱得多。

我在实际维护这套片段库的过程中体会最深的一点是,Android 开发的绝大多数“坑”都不是逻辑不会写,而是不知道某条规则在哪个版本被改过、在哪个场景下不生效。代码本身十分钟能敲完,判断它能不能用、什么时候会失效,才是真正花时间的地方。所以与其收藏一百篇零散的代码合集,不如自己动手过一遍,把不生效的场景记下来,这份笔记才是别人拿不走的东西。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 8:51:55

大模型入门系统学习路线:从机器学习基础到Transformer与PyTorch实战

1. 大模型入门到底在学什么 1.1 从热搜词看真实需求 把“大模型,机器学习,深度学习,Transformer,框架”这几个词摆在一起&#xff0c;再扫一眼那串热搜词&#xff0c;其实能看出一个很典型的困境&#xff1a;想入门大模型的人&#xff0c;面对的不是“没资料”&#xff0c;而是…

作者头像 李华
网站建设 2026/11/3 8:51:54

豆包智能体聊天记录备份与批量导出:数据存储原理与实操指南

1. 为什么我会盯上豆包智能体的聊天记录备份这件事 先说一个我自己的真实经历。去年年底我在豆包上搭了一个专门用来做技术资料整理的智能体&#xff0c;前后跑了大概三个月&#xff0c;积累了几百轮对话&#xff0c;里面有不少我随手记下来的配置片段、排查思路和临时结论。结…

作者头像 李华
网站建设 2026/11/3 8:51:24

OpenCV笔迹识别:基于图像处理的可解释手写特征提取方法

简介&#xff1a;本资源是一套面向计算机专业本科生的毕业设计实战项目&#xff0c;聚焦笔迹识别这一司法鉴定与金融验签中的关键需求&#xff0c;提供基于OpenCV图像处理与PyQt界面开发的完整可运行系统。项目通过逐字图像比对与相似度分析实现签名真伪判别&#xff0c;有效替…

作者头像 李华
网站建设 2026/11/3 8:51:02

Jev 框架:类型安全 AI 调用与 System One Model 实践

1. 从"模型很强"到"调用很稳"&#xff1a;Jev 想解决的真正问题 过去两年&#xff0c;大家已经习惯了"模型能力每隔几个月翻一倍"的叙事。但真正在一线做产品的人心里都清楚&#xff1a;模型再强&#xff0c;落到工程里&#xff0c;最难的从来不…

作者头像 李华
网站建设 2026/11/3 8:50:48

JavaWeb合同管理系统实战:JSP+Servlet+MySQL搭建企业级合同管理平台

简介&#xff1a;本资源是一套基于JavaWeb技术开发的合同管理系统完整源码工程&#xff0c;面向Java初学者与Web开发入门者&#xff0c;解决企业级合同信息管理、查询、执行跟踪与统计分析等实际业务问题。项目采用ServletJSPJSTL架构&#xff0c;集成MySQL数据库&#xff0c;涵…

作者头像 李华
网站建设 2026/11/3 8:47:46

STM32H750嵌入式GUI中高效加载PNG图像的完整方案

简介&#xff1a;本资源是一份面向嵌入式开发工程师与STM32进阶学习者的GUI实战项目&#xff0c;聚焦STM32H750平台下EMWIN图形库对PNG图像的完整支持方案&#xff0c;解决高性能Cortex-M7芯片上资源受限场景下的无损图像解码与高效显示难题。压缩包共547个文件&#xff0c;以2…

作者头像 李华