Jetpack 内核实战(十):实战(下)——编辑页、全局设置与内存泄漏排查

实战(下)------编辑页、全局设置与内存泄漏排查

各位 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. 监听输入框内容变化,每次输入重置防抖计时
  2. 停止输入 1 秒后,自动将当前内容保存到 DataStore 草稿缓存
  3. 新建笔记进入页面时,优先读取草稿并恢复到输入框
  4. 主动保存成功后,清除对应草稿,避免冗余

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

官方自带性能分析工具,标准排查步骤:

  1. 连接设备运行 App,打开 Profiler → Memory
  2. 点击垃圾桶图标强制 GC,点击「Dump Java Heap」生成堆快照
  3. 按包名筛选,查看 Activity/Fragment 是否存在多余实例
  4. 查看引用链,定位泄漏的具体根源

(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:两层保障彻底解决:

  1. ViewModel 层:输入状态同步到 ViewModel,旋转屏幕 ViewModel 不销毁,页面重建后自动恢复
  2. 持久化层:自动草稿功能,输入过程中定时保存到 DataStore,即使进程被杀也能恢复
  3. 核心原则:不要把状态只存在 Activity 变量中,必须下沉到 ViewModel 或持久化层

Q3:主题切换闪屏如何优化?

A:通过多层手段弱化闪屏感:

  1. 统一窗口背景:给 Activity 配置和主题一致的 windowBackground
  2. 启动初始化主题:冷启动在 Application 中设置主题
  3. 过渡动画:配置 overridePendingTransition 淡入淡出动画

Q4:ViewModel 相关的内存泄漏 90% 是什么原因?

A:最高发的三个场景:

  1. 持有 Activity 引用:ViewModel 错误持有 Activity 或 View,生命周期不一致导致泄漏
  2. observeForever 不解绑:使用无生命周期的观察方法,页面销毁不手动移除
  3. 错误的协程作用域:用 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 应用架构。

项目完整代码


🌟 点赞 + 收藏 + 关注,第一时间收到更新推送

💬 评论区聊聊:你在项目开发中都遇到过哪些内存泄漏的坑?是如何排查和解决的?欢迎留言交流!

相关推荐
雨白19 小时前
Kotlin 泛型基础:“万能魔法盒”的诞生
kotlin
小小测试开发1 天前
RAG评测:把忠实度、检索命中率、噪声敏感度做成可回归的流水线
人工智能·数据挖掘·回归·kotlin
撩得Android一次心动1 天前
Kotlin 语言【知识点整理3】
java·开发语言·笔记·学习·kotlin
Kapaseker1 天前
我常用的 5 个 Kotlin 优化小技巧
android·kotlin
名剑走天下1 天前
Kotlin_LiveData
开发语言·kotlin
JMchen1232 天前
Jetpack 内核实战(九):实战(上)——记事本首页列表 + 搜索功能开发
kotlin·android 实战·记事本 app 开发·recyclerview 优化·jetpack 综合实战
pengyu2 天前
【Kotlin 协程修仙录 · 炼虚境 · 初阶】 | 虚空造物:Channel 基础与协程间通信的管道艺术
android·前端·kotlin
小强闯江湖2 天前
ViewCompose:让原生 Android View 进入声明式时代
android·开源·kotlin
雨白2 天前
Kotlin 委托机制:搞懂“老板与秘书”模式
kotlin