news 2026/9/8 1:22:44

Android实战:从网络请求到协程、沉浸式与深色主题的完整落地笔记

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android实战:从网络请求到协程、沉浸式与深色主题的完整落地笔记

前阵子把《第一行代码》里网络请求那一章啃完,想着光看不行,就直接拿手头一个练手App开刀,把列表接口、图片加载、状态栏颜色、夜间模式这些点全部串起来做了一遍。做完之后感触挺深:书里是分章节讲网络、讲协程、讲Jetpack、讲主题适配的,但真实项目里这些内容全是同时工作的,而且“跑通接口”和“界面好看、体验顺畅”之间,差着一大堆细节。

这篇笔记就是记录我从“能请求到数据”到“做一个网络层、异步层、UI主题层都顺眼的Android App”之间踩过的坑和最终落地方案。适合刚学完Android基础、准备把知识连成体系的人参考,也适合正在做课程设计、毕设或者个人作品集的朋友直接抄作业。

1. 内容整体设计与思路拆解

1.1 为什么把网络、协程、主题颜色、沉浸式放在一起做

教材和网课都喜欢把知识点拆开讲:今天学网络请求,明天学协程,后天学Material Design。但真实开发里,用户打开一个App看到的是一个整体:点开页面后要拉数据,数据没回来之前要有加载态,数据回来要刷新列表,页面顶部不能跟状态栏重叠,晚上开深色模式界面颜色还要跟着变。

所以这篇笔记的核心思路是:用一个“每日推荐卡片”这样的小App作为载体,把一条完整链路走通——网络请求拿数据、协程切线程不卡UI、Jetpack组件管理生命周期和状态、状态栏融合做出沉浸式效果、深色主题自动适配。每一个环节都是实际项目里绕不开的,串起来之后你会突然发现,原来书上每个章节之间是这么衔接的。

1.2 技术选型:为什么用Retrofit、协程、ViewModel这套组合

我在选型的时候其实纠结过。原生HttpURLConnection虽然不用引依赖,但写起来又臭又长,解析JSON要自己处理;OkHttp比原生好用,但回调嵌套多了之后代码还是乱;最后选了Retrofit + OkHttp + Gson这套组合,原因很直接:Retrofit把接口定义、参数拼接、返回解析全封装好了,配合协程甚至不用写回调,代码读起来像同步调用一样自然。

协程选Kotlin Coroutines而不是传统的回调或者RxJava,原因也简单:回调嵌套写起来痛苦,RxJava学习曲线陡,而协程的suspend函数可以用同步的写法完成异步操作,完全符合“代码可读性优先”的原则。在ViewModel里用viewModelScope启动协程还有一个好处,页面销毁时协程会自动取消,不会出现“请求发出去了但页面已经关了”的野任务。

Jetpack这层我用了ViewModel + StateFlow + ViewBinding这套组合。ViewModel负责在屏幕旋转时保住数据,StateFlow负责把UI状态单向传递给界面,ViewBinding替代findViewById减少样板代码。没有上DataBinding全家桶,因为小项目里DataBinding的收益不大,反而会引入不少编译期的问题。

1.3 这个实现方案的优势和它避开的坑

这套方案最大的优势是“各层边界清晰”。网络层只管网络,协程只管异步,ViewModel只管状态,Activity/Fragment只负责渲染。出了问题定位也快,比如列表不刷新,先看接口返回,再看数据流,最后看适配器,不用在一个塞满逻辑的文件里翻来翻去。

它避开的坑也值得一提。我没在Activity里直接new线程去请求网络,避开了NetworkOnMainThreadException;没用GlobalScope启动协程,避开了协程无法取消导致的内存泄漏;没在任何布局里硬编码颜色值,为后面的深色主题省了一大堆返工时间。这些坑后面都会单独展开讲,每一个都是实操中真的会遇到的问题。

2. 环境准备与工程基础

2.1 版本匹配:Android Studio、Gradle、AGP、Kotlin

动手之前先把环境对齐,不然依赖引入之后编译报错能折腾一晚上。我这里用的是相对稳定的组合:Android Studio版本保持较新,AGP(Android Gradle Plugin)用8.x,Gradle用8.x,Kotlin用1.9.x。注意AGP和Gradle是有对应关系的,不是随便配的,比如AGP 8.2要求Gradle最低8.2,配低了直接给你报错。

提示:具体版本号建议以官方兼容性表格为准,Android Studio的New Project向导会自动生成一套默认可用的组合,没有特殊需求千万别手动乱改。

如果你刚装好Android Studio,想让它显示中文界面,其实在Settings -> Plugins里搜索“Chinese Language Pack”,安装重启就是中文了。这个不影响编译,纯粹是IDE界面语言的事,用顺手了还是建议切回英文,因为报错信息、官方文档、网上的Stack Overflow回答全是英文,界面保持英文反而少一层转换。

2.2 依赖引入清单和配置细节

在app模块的build.gradle.kts里,我引入了这些核心依赖:

dependencies { // 网络层 implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") // 协程 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // Jetpack implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.activity:activity-ktx:1.8.2") // UI implementation("com.google.android.material:material:1.11.0") implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.constraintlayout:constraintlayout:2.1.4") }

有三个细节容易踩坑。第一,用协程一定要引入kotlinx-coroutines-android,只引入core的话很多Android相关的调度器不可用。第二,lifecycle-viewmodel-ktx提供了viewModelScope扩展属性,这是我们在ViewModel里发起协程的基础,漏了这依赖代码里怎么都编译不过。第三,logging-interceptor版本和okhttp版本要保持一致,不然可能因为方法签名对不上而崩溃。

2.3 权限声明和网络安全配置

网络请求最基本的权限是INTERNET,放在AndroidManifest.xml里:

<uses-permission android:name="android.permission.INTERNET" />

如果你调试时用的是http明文地址(比如模拟器访问本机的10.0.2.2),Android 9及以上默认禁止明文流量,会报“Cleartext HTTP traffic not permitted”。调试阶段有两种处理方式:一种是临时在application标签加android:usesCleartextTraffic="true",另一种是配置networkSecurityConfig只对调试域名放开明文。我建议用后一种,因为不记得关掉就给上线埋雷,明文流量在生产环境无论如何都不该开。

<application android:networkSecurityConfig="@xml/network_security_config" ... > </application>

network_security_config放在res/xml目录下:

<?xml version="1.0" encoding="utf-8"?> <network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">10.0.2.2</domain> <domain includeSubdomains="true">localhost</domain> </domain-config> </network-security-config>

3. 网络层与协程的落地:让请求代码不再像“回调地狱”

3.1 API定义与数据模型的完整示例

我把“每日推荐”的接口定义写成这样,麻雀虽小五脏俱全:

interface ApiService { @GET("api/daily") suspend fun getDailyFeed(): BaseResponse<DailyFeed> } data class BaseResponse<T>( val code: Int, val message: String, val data: T ) data class DailyFeed( val title: String, val content: String, val imageUrl: String, val updateTime: Long )

注意getDailyFeed是suspend函数,这就是Retrofit与协程结合的关键点。Retrofit从2.6.0开始支持suspend函数,它会自动把请求放到IO线程执行,等结果返回后再切回主线程,中间的过程完全不需要你写withContext或者enqueue回调。

OkHttp的日志拦截器是排查问题的一把好手,建议调试环境一定加上:

val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } val client = OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .addInterceptor(logging) .build() val retrofit = Retrofit.Builder() .baseUrl("https://your-api-server.com/") .client(client) .addConverterFactory(GsonConverterFactory.create()) .build() val apiService = retrofit.create(ApiService::class.java)

日志级别选BODY之后,每次请求的URL、请求头、响应JSON都会打在Logcat里。我调试时很多诡异问题都是靠这个日志定位的,比如“为什么列表没刷新”——一看响应,原来是接口报错了;“为什么Model的字段永远是null”——一看响应字段名,原来是后端返回的是snake_case而我写的是camelCase。

3.2 协程的核心用法:launch、async、withContext到底怎么选

协程第一个要搞清楚的是launch和async的区别。launch是“启动一个不关心结果的协程”,适合做提交任务、写日志这类操作;async是“启动一个期望拿到结果的协程”,最后要调用await()获取返回值。我的日常推荐接口只需要拉数据然后更新UI,所以用launch就够了;如果同时需要请求两个接口、都成功后再合并展示,才会用到async + await。

第二个要搞清楚的是调度器。Dispatchers.Main在主线程跑,负责更新UI;Dispatchers.IO在IO线程池跑,负责网络请求和数据库操作。Retrofit的suspend函数内部已经做了线程切换,所以你在viewModelScope.launch里直接调用apiService.getDailyFeed(),它会自动在IO线程执行,回来后在主线程继续,不需要你手动指定withContext(Dispatchers.IO)。

有基础的读者会问:那withContext什么时候用?当你直接操作非Retrofit的耗时方法时用,比如读取本地文件、处理Bitmap、跑一个复杂计算。写法如下:

viewModelScope.launch { val bitmap = withContext(Dispatchers.IO) { loadBitmapFromFile(path) } // 回到主线程,直接更新UI _uiState.value = UiState.Success(bitmap) }

这里的核心认知是:suspend函数不指定线程,它只是“可以被挂起”的函数。真正切线程的是调度器,Retrofit因为它内部用了enqueue,天然帮你切好了;普通函数则要自己用withContext来切。

3.3 在ViewModel里发起请求:UI状态三态模型

新手写网络请求最容易犯的毛病是直接用返回值更新UI,但网络还没回来时UI是空的,报错了也没地方展示。我参考了不少项目的做法,最后用密封类定义UI状态:

sealed class UiState { object Loading : UiState() data class Success(val feed: DailyFeed) : UiState() data class Error(val message: String) : UiState() }

ViewModel里维护一个StateFlow对外暴露状态:

class DailyViewModel : ViewModel() { private val _uiState = MutableStateFlow<UiState>(UiState.Loading) val uiState: StateFlow<UiState> = _uiState fun loadDaily() { viewModelScope.launch { _uiState.value = UiState.Loading try { val response = apiService.getDailyFeed() if (response.code == 0) { _uiState.value = UiState.Success(response.data) } else { _uiState.value = UiState.Error(response.message) } } catch (e: Exception) { _uiState.value = UiState.Error(e.message ?: "未知错误") } } } }

Activity里通过repeatOnLifecycle收集状态,这样能保证页面在后台时不更新UI,避免不必要的资源消耗甚至崩溃:

lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { state -> when (state) { is UiState.Loading -> showLoading() is UiState.Success -> showContent(state.feed) is UiState.Error -> showError(state.message) } } } }

这个三态模型看起来简单,但真实项目里非常管用。加载态、成功态、错误态都有明确的呈现,不会出现“按钮点了没反应”的迷糊状态。

3.4 Flow结合协程:热流和冷流怎么理解

网上搜“android flow结合协程”的人很多,说明大家都卡在这。我尽量用大白话讲:Flow是协程里的数据流,分冷流和热流两种。冷流是“有人订阅才开始发数据”,像播放器的单曲循环,collect被调用时才执行;热流是“不管有没有人订阅都在发”,像广播电台,你打开收音机就能听到。

StateFlow正是热流,所以ViewModel里可以一直持有最新的UI状态,Activity一collect就能立刻收到当前值,不会等下一次数据更新才显示。这对“屏幕旋转后恢复界面”非常有用:ViewModel存活,StateFlow保存着最后一次状态,页面重建后立刻拿到旧数据渲染,体验比LiveData还要好。

Flow最常见的结合场景是列表分页加载或者连续事件流,比如下拉刷新后重新拉数据,流程是这样的:

private val refreshTrigger = MutableStateFlow(0) fun refresh() { refreshTrigger.value += 1 } init { viewModelScope.launch { refreshTrigger .flatMapLatest { flow { emit(apiService.getDailyFeed()) } } .catch { e -> _uiState.value = UiState.Error(e.message ?: "") } .collect { feed -> _uiState.value = UiState.Success(feed) } } }

flatMapLatest的作用是:如果有新的事件来,就取消上一次的网络请求,只响应最新的一次,完美解决“连续快速刷新导致旧响应覆盖新响应”的问题。协程较新版本里,直接在collect块中做更新即可,不用再手动发后台线程。

3.5 协程的取消与异常处理:避免后台任务泄漏

协程可以被取消是它比线程优雅的地方之一。viewModelScope在ViewModel的onCleared被调用时会自动取消所有子协程,所以我们正常写viewModelScope.launch其实不用操心取消。但要小心,如果你手动创建了CoroutineScope,比如:

val scope = CoroutineScope(Dispatchers.Main + Job())

那必须在Activity的onDestroy里手动cancel,否则协程就会泄漏,即使页面关了它还在跑,更糟糕的是它可能在销毁后的View上调用方法,直接崩溃。

异常处理我用的是最直接的try/catch,在3.3的代码里已经展示。这里补充一个重试思路:用户点击“重试”按钮时,重新调用loadDaily(),而不是重新创建ViewModel。这也是StateFlow的好处,把状态维持在一个稳定的管理层,界面只管渲染。

4. Jetpack组件串联应用层

4.1 ViewModel + StateFlow:单Activity架构的基础

Jetpack这套东西有时候觉得抽象,其实你现在看到的ViewModel、LiveData/StateFlow、Lifecycle,都是为了解决一个非常朴素的问题:页面在生命周期里来回切换时,数据不能乱、任务不能漏、代码不能散。

我最开始写Android是把所有逻辑都堆在Activity里,按钮点击直接发请求,请求回来直接改TextView,项目小还好,一旦页面复杂,Activity轻松上千行。用了ViewModel之后,数据存活在配置变更之外,屏幕旋转、用户切走再切回来,数据不会重新请求一遍。

基于ViewModel + StateFlow的单Activity架构,是现在很多现代Android应用的底层骨架。Activity只负责把UiState渲染出来,ViewModel负责拿数据、加工数据、决定状态。这样分工之后,连单元测试都变得好写多了,因为不用启动模拟器就能测试ViewModel的逻辑。

4.2 Lifecycle在协程中的应用:repeatOnLifecycle的机制

我上面用了repeatOnLifecycle来收集Flow,很多人可能不理解为什么不能直接在onCreate里collect。原因是collect是一个阻塞式操作,如果没有Lifecycle机制兜底,它会一直收数据;页面到了后台,UI已经不可见了,这时候StateFlow还不停发数据过来,collect里如果做了更新View的操作,就可能触发“View not attached to window manager”之类的异常。

repeatOnLifecycle的逻辑是:当Lifecycle进入STARTED状态(页面可见可交互)时开始收集,进入STOPPED状态(页面不可见)时自动取消收集,再回到STARTED时重新开始。这样既保证了界面可见时必然能拿到最新状态,又避免了后台无谓的更新。这是Android官方推荐的标准写法,Jetpack的Lifecycle库把这条路铺平了。

4.3 ViewBinding和Jetpack Compose怎么选

一直用findViewById写布局是一件很痛苦的事,每次都要写一行类型转换,项目大了还容易混。我用的是ViewBinding,在buildFeatures里开启:

android { buildFeatures { viewBinding = true } }

开启后每个XML布局都会自动生成一个对应的Binding类,比如activity_main.xml生成ActivityMainBinding,用起来是这样:

class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) binding.loadButton.setOnClickListener { viewModel.refresh() } } }

至于Jetpack Compose,它是全新的声明式UI方案,相当于用Kotlin代码直接描述界面,取代XML布局。如果你要开新项目,并且团队里没人排斥学新东西,我建议直接Compose起步,因为它是Google钦定的未来方向,社区和库的支持会越来越全。但如果你的目标是快速完成一个毕业设计或者作品集,ViewBinding依然完全够用,而且学习成本低得多。

4.4 页面跳转和Activity Result API

页面跳转这块,普通跳转就是startActivity加Intent。如果你需要在页面间传递数据并拿回结果,旧写法是startActivityForResult + onActivityResult,现在已经废弃了,要改用Activity Result API:

private val detailLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { val data = result.data // 处理返回的数据 } } // 发起跳转 fun openDetail(feedId: String) { val intent = Intent(this, DetailActivity::class.java) intent.putExtra("feed_id", feedId) detailLauncher.launch(intent) }

在《第一行代码》的场景里,这个需求可能就是一两个页面之间互相跳转的简单流程,但用Activity Result API能保证写法不被后续系统版本变化淘汰。

5. 主题颜色与Material Design适配

5.1 Material 3与动态取色是什么

做UI适配避不开Material Design,现在的最新方案是Material 3(M3)。M3最大的视觉特征是圆角更大、颜色更加鲜明、强调“动态取色”能力。从Android 12开始,系统支持基于用户壁纸提取颜色生成应用主题,这就是“Material You”动态取色。

在MD3里,你要在主题里定义若干颜色角色:primary(主色)、onPrimary(主色上的内容色)、surface(表面色)、background(背景色)、onBackground(背景上的内容色)等。布局里的按钮、文字、背景全部引用这些角色,而不是直接引用某个具体的色值。这样设计的最大好处是:只要换一套颜色角色定义,整个应用就换了一套皮肤,完全不影响布局结构。

5.2 建立自己的颜色资源体系

我强烈建议从第一天起就别在布局XML里直接写颜色值,比如android:textColor="#333333",而是抽到colors.xml里。我的做法是分成三层:

  • 品牌色:主要按钮、链接色
  • 中性色:背景、文字、分割线
  • 功能色:成功、失败、警告

values/colors.xml示例:

<resources> <color name="brand_primary">#4A6CF7</color> <color name="text_primary">#212121</color> <color name="text_secondary">#757575</color> <color name="bg_page">#F5F5F5</color> <color name="divider">#E0E0E0</color> <color name="status_success">#2E7D32</color> <color name="status_error">#C62828</color> </resources>

把这些颜色在主题里组好,Material组件(Button、Card、TextInput等)会自动使用,自定义布局用?attr/colorPrimary这类主题属性引用,避免直接引用@color/xxx造成换肤后颜色不一致。

5.3 手动切换主题和跟随系统的选择

做主题切换之前先明确需求:是只跟随系统深色模式,还是要在应用内手动切换?我的练手App选择两者都支持:默认跟随系统,同时在设置页提供一个“深色/浅色/跟随系统”三选一开关。

跟随系统最简单,不用写任何代码,系统切到深色模式时App自动应用values-night里的资源。手动切换则要靠AppCompatDelegate:

// 切换深色 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) // 切换浅色 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) // 跟随系统 AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)

调用setDefaultNightMode之后,系统会按新配置重建当前Activity,所以不需要再手动finish和startActivity。要记住把选择存到SharedPreferences里,否则App重启又回到跟随系统了。

6. 状态栏融合与沉浸式实现:这里坑最多

6.1 沉浸式到底是什么意思

“沉浸式”、“状态栏融合”、“edge-to-edge”这几个词经常混着用,但它们其实不完全是一回事。我说下自己的理解:真正的沉浸式,是指应用内容可以绘制到系统状态栏和底部导航栏的区域,让状态栏背景和应用背景融为一体,视觉上内容“顶到屏幕顶端”,而不是在状态栏下方露出一条突兀的白边或黑边。

最开始用Android的人对状态栏的印象大多是:状态栏永远是黑的或者白的,和App内容割裂。沉浸式解决的就是这个问题。Android 15开始强制edge-to-edge,所有应用在目标SDK 35之后都默认启用这个行为,所以现在学会这套适配方法是迟早的事,早学比晚学好。

6.2 从旧方案到新方案的迁移:enableEdgeToEdge与Insets

我的笔记里记录了三个阶段的方案。早期方案是给Window设置flag,比如:

// 旧方案,不推荐直接使用 window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR

这个方案的逻辑是:让布局全屏绘制到状态栏底下,同时把状态栏图标颜色调成深色。但它有个明显问题:状态栏区域会被后来的页面内容遮住,如果顶部正好是标题文字,文字就会顶到状态栏下面,丑得不行。

新方案是使用ViewCompat和WindowInsetsCompat,控制应用内容是否适配系统窗口:

WindowCompat.setDecorFitsSystemWindows(window, false)

这句代码的意思是:让业务布局不再被系统窗口强制压缩到安全区内,而是允许它扩展到全屏。然后我们需要自己处理内容不被状态栏遮住的问题,用到的正是WindowInsets。具体做法是在根布局上设置监听:

ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) // 根据 bars.top 调整顶部间距 // 根据 bars.bottom 调整底部间距 insets }

Android 15提供的enableEdgeToEdge()方法其实就是把这些步骤统一封装了,它会自动把系统栏设为透明,并处理好亮暗图标。

6.3 状态栏透明化的正确做法

如果你想把状态栏直接做成透明(状态栏本身没有背景色,只显示图标),Android 15及以上直接用enableEdgeToEdge()即可。Android 14及以下,兼容写法建议用WindowCompat和WindowInsetsControllerCompat:

class BaseActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) val controller = WindowInsetsControllerCompat(window, window.decorView) controller.isAppearanceLightStatusBars = true // 深色图标 controller.isAppearanceLightNavigationBars = true // 深色导航图标 window.statusBarColor = Color.TRANSPARENT window.navigationBarColor = Color.TRANSPARENT } }

这里有个细节:状态栏变成透明后,状态栏图标颜色必须根据背景色调整。如果应用顶部是浅色背景,那么状态栏图标就应该用深色(isAppearanceLightStatusBars = true);如果应用顶部是深色背景,就要用浅色图标。搭配深色主题时这步很容易漏,不做的话浅色状态栏配浅色图标,用户根本看不见时间。

6.4 WindowInsets与安全区:给布局加动态padding

你可能会问:既然内容可以绘制到状态栏底下,那顶部标题栏不就被状态栏挡了吗?答案是:我们自己给标题栏加上安全距离,而不是让系统替我们排除。标准做法是利用systemBars insets给Toolbar或标题容器加paddingTop,这是动态的,不同机型的状态栏高度都能正确处理,不用写死24dp或25dp这种像素值。

给一个通用的工具方法,放在BaseActivity里:

fun applySystemBarPadding(view: View) { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) v.setPadding(0, bars.top, 0, bars.bottom) insets } }

底部同理。如果页面底部有个“下一项”的按钮,不处理导航栏insets,它就会被底部手势条挡住。我见过很多App的按钮被手势条遮住,用户点不到,就是这样漏了适配。

6.5 沉浸式页面弹出软键盘的崩溃排坑

沉浸式做完了,弹软键盘时容易出问题。因为在沉浸式下,默认的adjustResize有时不生效,键盘弹出来会直接盖住输入框,或者整个布局被压缩得很难看。Android 10之后系统提供了WindowInsets.Type.ime(),可以在软键盘弹出时收到它占据的高度,然后手动调整布局。

常见的compat写法是监听ime的insets:

ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) if (ime.bottom > 0) { // 键盘弹出,给底部留出 ime.bottom 的高度 binding.root.updatePadding(bottom = ime.bottom) } else { binding.root.updatePadding(bottom = 0) } insets }

需要结合根布局的fitsSystemWindows属性来调,不同情况区别很大。这块是我花时间最多的,建议实操时先跑通一个简单页面(一个输入框加一个按钮),再复制到其他页面。

7. 深色主题的完整实现

7.1 深色主题与状态栏融合的高级联动

我在状态栏适配那一步发现一个复杂问题:沉浸式加深色主题,意味着状态栏区域的颜色会随着应用背景变化,浅色模式下状态栏区域是浅色背景配深色图标,深色模式下是深色背景配浅色图标。所以isAppearanceLightStatusBars这个值不能写死,要根据当前是深色还是浅色模式动态判断。

判断当前模式的代码:

val isNight = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES

然后根据它来设置图标颜色深浅:

controller.isAppearanceLightStatusBars = !isNight controller.isAppearanceLightNavigationBars = !isNight

如果把这段逻辑放在BaseActivity的onResume里,那么深色模式下状态栏图标自动变浅色,浅色模式下变深色,页面切换、系统模式切换时都能保持一致。

7.2 values-night资源限定符的正确使用

深色主题最核心的机制就是资源限定符。res/values目录里放默认资源,res/values-night里放深色模式覆盖资源,系统会在深色模式下自动选择values-night里的值。我的做法是把colors.xml里的颜色做成两套:

  • values/colors.xml:浅色模式的颜色变量(如bg_page为浅灰)
  • values-night/colors.xml:深色模式的颜色变量(如bg_page为深黑)

布局文件里始终引用@color/bg_page这样的名字,具体在浅色还是深色下解析成什么值,交给系统。这样从浅色切到深色,整个App的背景色、文字色、分割线色全部自动更新,不需要写任何条件判断。

除了colors,drawable和drawable-night也可以做同样的策略。比如深色模式下logo图太亮很刺眼,就可以在drawable-night放一张压暗过的版本,不用在代码里做任何处理。

7.3 状态栏图标和导航栏图标的深浅切换

状态栏图标的深浅切换我说过了,用的是WindowInsetsControllerCompat。深色模式下要注意的不只是状态栏,底部手势条也一样。如果底部导航栏是浅色背景,手势条黑色就能看见;如果底部是深色背景,手势条就要变浅色。

这块属于那种“平时不起眼、但错了特别明显”的适配。你可以拿一台深色背景的机器看一下底部的横条,颜色不对会很突兀。我在真机上反复切换了几次之后才确定规律:isAppearanceLightNavigationBars = true表示导航按钮/手势条使用深色,需要底部背景是浅色;为false时使用浅色,配合深色底部背景。

7.4 图片和WebView在深色下的处理

深色主题的图片适配容易漏。位图(Bitmap)不会因为切换模式自动变暗,如果页面有一张白色底的插画,深色模式下会非常刺眼。稳妥的做法是给图片容器设置一个深色背景色,再给图片加一层半透明黑色遮罩或者说使用支持tint的矢量图。矢量图可以配合?attr/colorControlNormal这类主题属性自动换色,位图则需要准备两套源或者加遮罩。

WebView也需要单独处理。如果加载的是本地HTML,可以在页面里加CSS判断深色模式:

@media (prefers-color-scheme: dark) { body { background-color: #121212; color: #E0E0E0; } }

这种做法的好处是WebView里的内容也会跟随系统模式变化,体验一致。如果加载的是第三方页面,没法控制CSS,可以考虑在深色模式下给WebView背景设置深色,避免加载时白屏闪烁。

8. 常见问题与排查技巧实录

8.1 编译期和运行期的报错速查表

以下是我在这次实操中遇到过的几类典型问题,整理成了表格,方便排查时快速对照。

报错或现象常见原因处理方案
android.os.NetworkOnMainThreadException在主线程发了网络请求使用Retrofit + suspend函数,或手动切到IO线程
Cleartext HTTP traffic not permitted使用了http明文地址但被系统拦截配置networkSecurityConfig,仅对调试域名放开明文
Unable to instantiate ViewModelViewModel的构造函数有参数但没写Factory用ViewModelProvider.Factory或依赖注入框架创建
状态栏盖住了标题栏沉浸式开启后没有给顶部安全区加padding用WindowInsets给根布局加动态paddingTop
底部按钮被手势条挡住没处理底部systemBars insets监听insets加paddingBottom
深色模式下界面没有刷新Activity没有正确处理配置变更,或资源没有写night版本创建values-night目录,检查资源名是否一致
Lifecycle的重建让StateFlow重复接收旧数据使用了repeatOnLifecycle后,回到前台会重新收集这是正常现象,StateFlow持有最新值,重新收集即恢复状态
协程版本冲突kotlinx-coroutines相关库版本不一致统一使用相同版本,或者用BOM管理版本

8.2 真机适配的额外坑:国产ROM和屏幕形状

模拟器上表现正常的沉浸式,在真机上往往有额外的惊喜。我手头有几台不同厂商的测试机,发现国产ROM对系统栏的处理并不完全一致,主要体现在:深色模式开关的位置不同,部分ROM的深色模式还分“强制深色”和“跟随系统”,强制深色下就算App没写深色适配,系统也会暴力转色,布局可能出现奇怪的对比度问题。

刘海屏的处理也需要单独留意。因为不同机型的刘海大小不一样,用DisplayCutout相关的API获取安全区更靠谱。常规做法是判断刘海高度并给页面顶部留出相应空间,避免刘海挡住重要内容。模拟器测不出这个问题,建议有条件的真的借一台刘海屏真机试一下。

8.3 调试沉浸式的两个神技

调试沉浸式和系统UI相关的代码,最直观的工具是抓取系统UI层级。在Android Studio的Layout Inspector里,可以查看当前页面的View树和每个View的边界,直接看出内容是不是被状态栏挡了。另一个方法是打开开发者选项里的“显示布局边界”,屏幕上会直接画出每个View的边框,哪里有重叠一目了然。

我在排查“底部按钮被手势条挡住”的问题时,就是开着布局边界发现按钮底部和导航条有交叠,然后转头去处理WindowInsets的bottom值。这两个技巧学会之后,这类问题几乎不需要猜,看一眼就定位了。

9. 实操总结与个人心得

9.1 学完这一套之后,我发现最值钱的能力是“兜底”

《第一行代码》这类书给的是“标准路径”,但实际项目里没有那么多标准场景。比如沉浸式这一块,Android 15强制edge-to-edge之后,网络上的老教程很多都过时了,如果你只背了旧flag的代码,在新系统上可能直接失效。但如果你理解了WindowInsets的原理,知道自己为什么要处理statusBars、navigationBars和ime,那么无论是Android 12还是Android 15,你都能根据API变化快速调整。

9.2 给正在学Android的人三个具体建议

第一,不要一次追求把架构做得多“潮”,先把“网络请求到UI显示”这条链路跑通,再逐步加协程、加Flow、加沉浸式。每一步都做一个小版本提交,出问题能回退。第二,解决问题时先看Logcat最前面的那几行,很多时候错误信息已经告诉你怎么修了,只是被下面一大段无关日志淹没了。第三,代码写完之后一定要在真机上验证一遍,模拟器可以做功能验证,但状态栏、手势、键盘、深色模式这些UI相关的效果必须靠真机。

我自己在写这个练手项目的时候,最深的体会是:Android开发的知识点像是一颗颗珠子,网络、协程、Jetpack、主题、沉浸式、深色模式,每颗珠子单独拿出来都能看懂,但把它们穿成一条项链也就是串成一个完整项目时,才会真正理解每颗珠子存在的意义。希望这篇笔记能帮你少踩几个我踩过的坑。

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

用Python写一个带GUI的CRC16/CRC32计算工具:原理、实现与踩坑

简介&#xff1a;面向嵌入式开发与数据校验场景的Python版CRC计算工具&#xff0c;基于Python 3.8实现&#xff0c;提供图形界面&#xff0c;支持字符串和文件的CRC16_XMODEM、CRC32计算&#xff0c;文件可通过拖拽载入。工具内置C语言动态库以加速计算&#xff0c;并允许在纯P…

作者头像 李华
网站建设 2026/9/8 1:18:48

AI Challenger违约风险预测Top2方案:特征工程与模型融合实战

简介&#xff1a;面向机器学习竞赛选手与金融风控从业者&#xff0c;这份压缩包完整收录“马上AI全球挑战赛-违约用户风险预测”赛道亚军方案。方案基于Anaconda3中的Python 3.6环境&#xff0c;融合scikit-learn、pandas、numpy、xgboost等常用工具库&#xff0c;围绕信贷违约…

作者头像 李华
网站建设 2026/9/8 1:18:18

磁盘空间分析利器TreeSize:免费版与绿色便携版使用指南

简介&#xff1a;TreeSize绿色版是一款免安装的磁盘空间分析工具&#xff0c;适用于普通电脑用户、IT运维人员以及经常需要清理磁盘空间的办公场景。它能在数秒内完成硬盘扫描&#xff0c;借助树状列表、柱状图、饼图等多种直观视图&#xff0c;清晰展示各级目录与大文件的占用…

作者头像 李华
网站建设 2026/9/8 1:15:48

移动端SEO优化全攻略:从适配方案到用户体验提升

1. 移动端网站优化的起点&#xff1a;先搞懂适配方案&#xff0c;别急着上插件我在接触移动端SEO项目时经常遇到一类情况&#xff1a;站长拿着一个PC网站&#xff0c;说想快速搞移动端优化。问了一句“你的移动端是怎么实现的”&#xff0c;对方要么是“做了个自适应”&#xf…

作者头像 李华
网站建设 2026/9/8 1:15:45

4篇3章8节:临床试验安全性分析集与不良事件分类体系解析

前面七节我们从多重比较、纵向重复测量,到期中分析的完整实操。但从这一节开始,我们要补上章名里承诺的另一半——安全性统计分析。安全性统计分析是新药研发、临床试验审评审批的核心关键环节,直接决定药物的安全性结论与上市价值,而标准化的分析体系是保障临床试验数据合…

作者头像 李华
网站建设 2026/9/8 1:15:35

国产PLM系统选型实战指南与检查清单

1. 国产PLM系统选型深度检查清单最近在帮制造业客户做PLM系统选型时&#xff0c;发现很多企业面对国产PLM产品时容易陷入"功能对比陷阱"——只看表面功能清单而忽略实际落地细节。我整理了这份包含237个检查项的实战清单&#xff0c;涵盖从底层架构到日常操作的完整评…

作者头像 李华