实战(下)------编辑页、全局设置与内存泄漏排查
各位 Android 开发者好,欢迎来到《Jetpack 内核实战》系列第 10 篇。
在上一篇中,我们完成了记事本首页的列表展示、实时搜索与多状态管理,完成了应用核心浏览能力。本篇作为实战收尾篇,我们将完成笔记编辑页、全局设置页两大核心页面,实现自动草稿保存、夜间模式切换等体验优化功能,同时进行系统的内存泄漏排查与上线打包配置,交付一个完整、稳定、可上线的记事本应用。
一、笔记编辑页:ViewBinding 与软键盘适配
编辑页是内容生产的核心页面,需要兼顾交互流畅性与输入稳定性。我们采用 ViewBinding 实现视图绑定,同时解决软键盘遮挡、内容回显、状态保存等经典问题。
1. 编辑页布局设计
编辑页采用顶部操作栏 + 正文输入区的经典结构,用 ScrollView 包裹输入区,适配软键盘弹出,保证输入区域始终可见。
在 res/layout 下创建 activity_edit.xml:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="?android:attr/windowBackground">
<!-- 顶部操作栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:background="?attr/colorSurface"
android:elevation="4dp">
<ImageView
android:id="@+id/ivBack"
android:layout_width="32dp"
android:layout_height="32dp"
android:src="@drawable/ic_back"
android:contentDescription="返回" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="编辑笔记"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginStart="16dp" />
<TextView
android:id="@+id/tvSave"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="保存"
android:textSize="16sp"
android:textColor="?attr/colorPrimary" />
</LinearLayout>
<!-- 正文输入区,ScrollView 适配软键盘滚动 -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<EditText
android:id="@+id/etContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:textSize="16sp"
android:hint="输入笔记内容..."
android:background="@null"
android:gravity="top"
android:imeOptions="flagNoExtractUi" />
</ScrollView>
</LinearLayout>
2. 清单文件配置软键盘模式
在 AndroidManifest.xml 中给编辑页配置 adjustResize,软键盘弹出时布局自动压缩上移,从根源避免遮挡输入框:
xml
<activity
android:name=".ui.edit.EditActivity"
android:windowSoftInputMode="adjustResize|stateHidden"
android:screenOrientation="portrait" />
3. 编辑页 ViewModel 实现
编辑页 ViewModel 负责笔记加载、保存与草稿管理,所有数据操作通过 Repository 完成,不直接接触数据库实现。
在 ui/edit 包下创建 EditViewModel.kt:
kotlin
package com.example.jetpacknote.ui.edit
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.example.jetpacknote.JetpackNoteApplication
import com.example.jetpacknote.data.entity.Note
import com.example.jetpacknote.data.repository.NoteRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
class EditViewModel(application: Application) : AndroidViewModel(application) {
private val repository: NoteRepository
init {
val container = (application as JetpackNoteApplication).container
repository = container.noteRepository
}
// 当前编辑的笔记
private val _currentNote = MutableLiveData<Note?>()
val currentNote: LiveData<Note?> = _currentNote
// 保存状态:一次性事件
private val _saveState = MutableLiveData<Boolean?>()
val saveState: LiveData<Boolean?> = _saveState
// 草稿防抖任务
private var draftJob: Job? = null
/**
* 加载笔记详情
* @param noteId 笔记ID,为null表示新建笔记
*/
fun loadNote(noteId: Long?) {
if (noteId == null) return
viewModelScope.launch {
val note = repository.getNoteById(noteId)
_currentNote.value = note
}
}
/**
* 保存笔记
*/
fun saveNote(content: String) {
if (content.isBlank()) return
viewModelScope.launch {
val note = _currentNote.value
val result = if (note == null) {
repository.addNote(Note(content = content))
} else {
repository.updateNote(note.copy(content = content))
}
// 保存成功清除草稿
if (result.isSuccess) {
repository.clearDraft()
}
_saveState.value = result.isSuccess
}
}
/**
* 输入内容变化,触发防抖保存草稿
*/
fun onContentChanged(content: String, noteId: Long) {
draftJob?.cancel()
draftJob = viewModelScope.launch {
delay(1000) // 输入停止1秒后保存草稿
repository.saveDraft(content, noteId)
}
}
/**
* 获取草稿内容
*/
fun getDraftFlow(noteId: Long): Flow<String> {
return repository.getDraftContent(noteId)
}
}
4. EditActivity 完整实现
在 ui/edit 包下创建 EditActivity.kt,实现页面跳转、数据回显、自动草稿恢复:
kotlin
package com.example.jetpacknote.ui.edit
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.viewModels
import com.example.jetpacknote.databinding.ActivityEditBinding
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class EditActivity : AppCompatActivity() {
private val binding by lazy { ActivityEditBinding.inflate(layoutInflater) }
private val viewModel: EditViewModel by viewModels()
// 笔记ID,null表示新建
private val noteId: Long? by lazy {
intent.getLongExtra(EXTRA_NOTE_ID, -1).takeIf { it > 0 }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
initView()
observeData()
initDraftRestore()
viewModel.loadNote(noteId)
}
private fun initView() {
binding.ivBack.setOnClickListener { finish() }
binding.tvSave.setOnClickListener {
val content = binding.etContent.text?.toString().orEmpty()
viewModel.saveNote(content)
}
// 输入监听,触发自动草稿
binding.etContent.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
viewModel.onContentChanged(
s?.toString().orEmpty(),
noteId ?: -1L
)
}
})
}
private fun observeData() {
// 笔记加载完成,回显内容
viewModel.currentNote.observe(this) { note ->
note?.let {
binding.etContent.setText(it.content)
binding.etContent.setSelection(it.content.length)
}
}
// 保存结果回调
viewModel.saveState.observe(this) { success ->
success?.let {
if (it) {
Toast.makeText(this, "保存成功", Toast.LENGTH_SHORT).show()
finish()
} else {
Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show()
}
_saveState.value = null // 消费一次性事件
}
}
}
/**
* 新建笔记时恢复草稿
*/
private fun initDraftRestore() {
if (noteId != null) return
lifecycleScope.launch {
val draft = viewModel.getDraftFlow(-1L).first()
if (draft.isNotBlank()) {
binding.etContent.setText(draft)
binding.etContent.setSelection(draft.length)
Toast.makeText(this@EditActivity, "已恢复草稿", Toast.LENGTH_SHORT).show()
}
}
}
companion object {
private const val EXTRA_NOTE_ID = "extra_note_id"
fun start(context: Context, noteId: Long? = null) {
val intent = Intent(context, EditActivity::class.java).apply {
noteId?.let { putExtra(EXTRA_NOTE_ID, it) }
}
context.startActivity(intent)
}
}
}
二、自动草稿保存:防抖 + DataStore 持久化
编辑页面最影响体验的痛点就是意外退出导致输入内容丢失。我们实现输入停止自动存草稿机制,输入过程中停止输入 1 秒自动保存到 DataStore,下次进入自动恢复,大幅降低内容丢失风险。
1. 实现思路
- 监听输入框内容变化,每次输入重置防抖计时
- 停止输入 1 秒后,自动将当前内容保存到 DataStore 草稿缓存
- 新建笔记进入页面时,优先读取草稿并恢复到输入框
- 主动保存成功后,清除对应草稿,避免冗余
2. 草稿 Key 与 Repository 扩展
在 SettingsKeys 中新增草稿相关配置:
kotlin
// data/datastore/SettingsKeys.kt 新增
object SettingsKeys {
// ... 已有 Key
// 草稿内容
val DRAFT_CONTENT = stringPreferencesKey("draft_content")
// 草稿对应笔记ID,-1表示新建草稿
val DRAFT_NOTE_ID = longPreferencesKey("draft_note_id")
}
在 SettingsRepository 中新增草稿读写方法:
kotlin
// data/repository/SettingsRepository.kt 新增
/**
* 获取指定笔记的草稿内容
*/
fun getDraftContent(noteId: Long): Flow<String> {
return context.dataStore.data
.catch { emit(emptyPreferences()) }
.map { prefs ->
// ID匹配才返回内容
if (prefs[SettingsKeys.DRAFT_NOTE_ID] == noteId) {
prefs[SettingsKeys.DRAFT_CONTENT] ?: ""
} else ""
}
}
/**
* 保存草稿
*/
suspend fun saveDraft(content: String, noteId: Long) {
context.dataStore.edit {
it[SettingsKeys.DRAFT_CONTENT] = content
it[SettingsKeys.DRAFT_NOTE_ID] = noteId
}
}
/**
* 清除草稿
*/
suspend fun clearDraft() {
context.dataStore.edit {
it.remove(SettingsKeys.DRAFT_CONTENT)
it.remove(SettingsKeys.DRAFT_NOTE_ID)
}
}
三、全局设置:DataStore 配置 + 主题实时切换
全局设置是应用的标配功能,我们基于 DataStore 持久化配置,实现夜间模式切换、字体大小调节,配置修改后全局实时生效。
1. 设置页 ViewModel
在 ui/settings 包下创建 SettingsViewModel.kt:
kotlin
package com.example.jetpacknote.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.jetpacknote.JetpackNoteApplication
import com.example.jetpacknote.data.repository.SettingsRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val repository: SettingsRepository
init {
val container = (application as JetpackNoteApplication).container
repository = container.settingsRepository
}
val nightMode: StateFlow<Boolean> = repository.nightModeFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(500),
initialValue = false
)
val fontSize: StateFlow<Int> = repository.fontSizeFlow
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(500),
initialValue = 16
)
fun toggleNightMode(enable: Boolean) {
viewModelScope.launch {
repository.setNightMode(enable)
}
}
fun setFontSize(size: Int) {
viewModelScope.launch {
repository.setFontSize(size.coerceIn(12, 20))
}
}
}
2. 全局主题切换实现
Application 初始化主题
冷启动时读取配置并设置全局主题,避免启动后切换导致闪屏:
kotlin
// JetpackNoteApplication.kt
class JetpackNoteApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = DefaultAppContainer(this)
// 初始化夜间模式(使用生命周期感知方式)
lifecycleScope.launch {
val isNight = container.settingsRepository.nightModeFlow.first()
AppCompatDelegate.setDefaultNightMode(
if (isNight) AppCompatDelegate.MODE_NIGHT_YES
else AppCompatDelegate.MODE_NIGHT_NO
)
}
}
}
SettingsActivity 实时切换
kotlin
package com.example.jetpacknote.ui.settings
import android.os.Bundle
import android.widget.SeekBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModels
import com.example.jetpacknote.databinding.ActivitySettingsBinding
import kotlinx.coroutines.launch
class SettingsActivity : AppCompatActivity() {
private val binding by lazy { ActivitySettingsBinding.inflate(layoutInflater) }
private val viewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
initView()
observeSettings()
}
private fun initView() {
binding.ivBack.setOnClickListener { finish() }
binding.switchNightMode.setOnCheckedChangeListener { _, isChecked ->
if (binding.switchNightMode.isPressed) {
viewModel.toggleNightMode(isChecked)
AppCompatDelegate.setDefaultNightMode(
if (isChecked) AppCompatDelegate.MODE_NIGHT_YES
else AppCompatDelegate.MODE_NIGHT_NO
)
recreate()
}
}
binding.seekFontSize.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) {
val size = 12 + progress
viewModel.setFontSize(size)
binding.tvFontSize.text = getFontSizeDesc(size)
}
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
})
}
private fun observeSettings() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.nightMode.collect { isNight ->
binding.switchNightMode.isChecked = isNight
}
}
launch {
viewModel.fontSize.collect { size ->
binding.seekFontSize.progress = size - 12
binding.tvFontSize.text = getFontSizeDesc(size)
}
}
}
}
}
private fun getFontSizeDesc(size: Int): String = when (size) {
12 -> "特小"
14 -> "小"
16 -> "标准"
18 -> "大"
20 -> "特大"
else -> "标准"
}
}
优化提示:给 Activity 配置与主题一致的 windowBackground,可以大幅弱化主题切换时的闪屏感。
四、内存泄漏排查:定位、修复与最佳实践
内存泄漏是 Android 开发的高频问题,轻则导致应用卡顿、后台耗电,重则引发 OOM 崩溃。我们系统梳理常见泄漏场景、排查工具与修复方案,同时验证 Jetpack 组件的防泄漏机制。
1. 常见内存泄漏四大根源
| 类型 | 说明 | 典型场景 |
|---|---|---|
| 生命周期不一致引用 | 长生命周期对象持有短生命周期 Activity/Fragment 引用 | 静态变量持有 Activity、单例持有页面回调、匿名内部类 Handler |
| 未取消的异步任务 | 页面销毁后,后台任务仍持有页面引用 | Thread 未中断、网络回调未解绑、定时器未取消 |
| 未注销的系统服务 | 注册了监听但页面销毁时未注销 | BroadcastReceiver 只注册不注销、EventBus 未取消订阅 |
| 资源未关闭 | 流、游标等资源用完未关闭 | 数据库游标、文件流未关闭 |
2. 排查工具
(1)Android Studio Profiler
官方自带性能分析工具,标准排查步骤:
- 连接设备运行 App,打开 Profiler → Memory
- 点击垃圾桶图标强制 GC,点击「Dump Java Heap」生成堆快照
- 按包名筛选,查看 Activity/Fragment 是否存在多余实例
- 查看引用链,定位泄漏的具体根源
(2)LeakCanary(开发阶段推荐)
Square 出品的自动化泄漏检测库,集成方式:
kotlin
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}
集成后无需额外配置,正常使用 App 即可,泄漏时会自动收到通知与引用链分析。
3. 典型泄漏修复与 Jetpack 防泄漏验证
ViewModel 相关泄漏(占 90% 场景)
| ❌ 错误做法 | ✅ 正确做法 |
|---|---|
| ViewModel 持有 Activity Context / View | 只持有 Application Context(AndroidViewModel)或不持有 Context |
| 使用 observeForever 不手动移除 | 使用 observe(this, observer) 生命周期自动解绑 |
| 用 GlobalScope 启动协程 | 使用 viewModelScope 启动,页面销毁自动取消 |
Jetpack 组件原生防泄漏机制
| 组件 | 防泄漏机制 |
|---|---|
| viewModelScope | ViewModel 销毁时自动取消所有协程 |
| LiveData.observe() | 页面销毁自动解绑观察者 |
| DataStore | 基于 Flow,页面销毁取消收集,不持有页面引用 |
| Room | 数据库连接全局单例,不持有页面级引用 |
4. 匿名内部类泄漏修复
kotlin
// ❌ 错误:匿名内部类持有 Activity 引用
class LeakyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Handler(Looper.getMainLooper()).postDelayed({
updateUI() // 如果延迟较长,Activity 销毁后仍在执行
}, 10000)
}
}
// ✅ 正确:使用 lifecycleScope
class SafeActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
delay(10000)
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
updateUI()
}
}
}
}
五、项目打包:签名、混淆与构建配置
开发完成后,配置签名、混淆与资源压缩,构建正式 release 包,满足上线要求。
1. 签名配置
在 app/build.gradle.kts 中配置签名信息:
kotlin
android {
// ... 其他配置
signingConfigs {
create("release") {
storeFile = file("../release.keystore")
storePassword = "your_store_password"
keyAlias = "your_key_alias"
keyPassword = "your_key_password"
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
2. ProGuard 混淆规则
在 app/proguard-rules.pro 中添加 Jetpack 相关规则:
proguard
# Room
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class * { *; }
-keep class * implements androidx.room.RoomDao { *; }
-dontwarn androidx.room.**
# DataStore
-keep class androidx.datastore.** { *; }
-dontwarn androidx.datastore.**
# Kotlin 协程
-keepnames class kotlinx.coroutines.**
-dontwarn kotlinx.coroutines.**
# 实体类不混淆
-keep class com.example.jetpacknote.data.entity.** { *; }
# 移除日志输出
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
3. 构建 release 包
在 Android Studio 中选择 Build → Generate Signed Bundle / APK,选择 APK,选择 release 签名,即可构建出正式安装包。
六、FAQ / 避坑指南
Q1:软键盘遮挡布局有哪些解决方案?
A:根据场景选择对应方案:
| 方案 | 说明 | 适用场景 |
|---|---|---|
| adjustResize | 布局自动压缩,配合 ScrollView 可滚动 | 编辑页(推荐) |
| adjustPan | 布局整体上移,不压缩 | 内容少的页面 |
| WindowInsets AP | I 监听软键盘高度,动态设置边距 | 沉浸式场景 |
Q2:旋转屏幕编辑内容丢失如何修复?
A:两层保障彻底解决:
- ViewModel 层:输入状态同步到 ViewModel,旋转屏幕 ViewModel 不销毁,页面重建后自动恢复
- 持久化层:自动草稿功能,输入过程中定时保存到 DataStore,即使进程被杀也能恢复
- 核心原则:不要把状态只存在 Activity 变量中,必须下沉到 ViewModel 或持久化层
Q3:主题切换闪屏如何优化?
A:通过多层手段弱化闪屏感:
- 统一窗口背景:给 Activity 配置和主题一致的 windowBackground
- 启动初始化主题:冷启动在 Application 中设置主题
- 过渡动画:配置 overridePendingTransition 淡入淡出动画
Q4:ViewModel 相关的内存泄漏 90% 是什么原因?
A:最高发的三个场景:
- 持有 Activity 引用:ViewModel 错误持有 Activity 或 View,生命周期不一致导致泄漏
- observeForever 不解绑:使用无生命周期的观察方法,页面销毁不手动移除
- 错误的协程作用域:用 GlobalScope 或自定义作用域启动协程,ViewModel 销毁时协程不取消
七、项目最终功能清单
| 功能 | 实现状态 | 涉及组件 |
|---|---|---|
| 笔记列表 | ✅ | RecyclerView + ListAdapter + DiffUtil |
| 实时搜索 | ✅ | Room 模糊查询 + 防抖 |
| 笔记编辑 | ✅ | ViewBinding + EditText |
| 自动存草稿 | ✅ | DataStore + lifecycleScope 防抖 |
| 夜间模式 | ✅ | DataStore + AppCompatDelegate |
| 字体大小 | ✅ | DataStore + Configuration |
| 离线优先 | ✅ | Room + DataStore |
| 数据同步 | ✅ | DataSyncManager |
| 内存泄漏检测 | ✅ | LeakCanary |
| 混淆打包 | ✅ | ProGuard |
写在最后
到这里,《Jetpack 内核实战》的记事本实战篇就全部结束了。我们从架构设计、组件拆解到完整项目落地,用 10 篇内容系统掌握了 ViewModel、LiveData、协程、Room、DataStore 五大核心组件,最终交付了一个功能完整、架构规范、性能稳定的记事本应用。
但 Jetpack 的生态远不止于此,LiveData 正在逐步被 Flow/StateFlow 替代,架构也在向更现代化的 MVI 演进。下一篇我们将进入 Flow/StateFlow 进阶,学习响应式流的高阶操作,实现从 LiveData 到 Flow 的平滑迁移,打造更现代的 Kotlin 应用架构。
🌟 点赞 + 收藏 + 关注,第一时间收到更新推送
💬 评论区聊聊:你在项目开发中都遇到过哪些内存泄漏的坑?是如何排查和解决的?欢迎留言交流!