Android 开发入门实践(十四):Compose 副作用入门与四个常用 API

我在 NewsApp 的详情分支中,通过日志观察协程启动、返回时取消、资源配对清理和等待后读取最新搜索词。本文记录第146---149课的了解练习,也保留"两次都打印体育"之后核对等待时间、调整输出顺序的过程。

学习范围 第14章「Compose 副作用了解」,第146---149课,共4课

关键词 Kotlin、Jetpack Compose、LaunchedEffect、rememberCoroutineScope、DisposableEffect、onDispose、rememberUpdatedState

整理日期 2026-11-10

学习到这一章时,我已经用 ViewModel 和 StateFlow 保存页面状态,也能通过返回意图清空选中编号,让页面回到列表。

这一章采用了解难度,围绕四个常见 API 各做一个短例子。我重点关注任务何时启动、何时取消、清理代码放在哪里,以及等待中的任务怎样取得最新值。

一、先确定例子所在的组合位置

本章代码都放在 MainActivity.kt 的有效新闻详情分支中,位于 NewsDetailScreen(...) 调用的上方。

页面已经通过编号在完整新闻列表中找到 currentNews。当它有值时,进入下面这个分支。

kotlin 复制代码
if (currentNews != null) {
    NewsDetailScreen(
        news = currentNews,
        onBack = {
            newsViewModel.onIntent(NewsIntent.BACK_TO_LIST)
        },
        modifier = Modifier.padding(innerPadding)
    )
}

这是已有详情调用的相关片段,使用页面中的 newsViewModel、currentNews 和 Scaffold 提供的 innerPadding。后面的学习代码逐步插入这个分支。

在本例中,进入体育详情时,该分支进入组合。返回列表、选中编号变为 null 后,页面选择其他显示分支,这里的副作用调用随之离开组合。
#mermaid-svg-eZeOHYhIdLJT7ugA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-eZeOHYhIdLJT7ugA .error-icon{fill:#552222;}#mermaid-svg-eZeOHYhIdLJT7ugA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-eZeOHYhIdLJT7ugA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-eZeOHYhIdLJT7ugA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-eZeOHYhIdLJT7ugA .marker.cross{stroke:#333333;}#mermaid-svg-eZeOHYhIdLJT7ugA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-eZeOHYhIdLJT7ugA p{margin:0;}#mermaid-svg-eZeOHYhIdLJT7ugA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA .cluster-label text{fill:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA .cluster-label span{color:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA .cluster-label span p{background-color:transparent;}#mermaid-svg-eZeOHYhIdLJT7ugA .label text,#mermaid-svg-eZeOHYhIdLJT7ugA span{fill:#333;color:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA .node rect,#mermaid-svg-eZeOHYhIdLJT7ugA .node circle,#mermaid-svg-eZeOHYhIdLJT7ugA .node ellipse,#mermaid-svg-eZeOHYhIdLJT7ugA .node polygon,#mermaid-svg-eZeOHYhIdLJT7ugA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-eZeOHYhIdLJT7ugA .rough-node .label text,#mermaid-svg-eZeOHYhIdLJT7ugA .node .label text,#mermaid-svg-eZeOHYhIdLJT7ugA .image-shape .label,#mermaid-svg-eZeOHYhIdLJT7ugA .icon-shape .label{text-anchor:middle;}#mermaid-svg-eZeOHYhIdLJT7ugA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-eZeOHYhIdLJT7ugA .rough-node .label,#mermaid-svg-eZeOHYhIdLJT7ugA .node .label,#mermaid-svg-eZeOHYhIdLJT7ugA .image-shape .label,#mermaid-svg-eZeOHYhIdLJT7ugA .icon-shape .label{text-align:center;}#mermaid-svg-eZeOHYhIdLJT7ugA .node.clickable{cursor:pointer;}#mermaid-svg-eZeOHYhIdLJT7ugA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-eZeOHYhIdLJT7ugA .arrowheadPath{fill:#333333;}#mermaid-svg-eZeOHYhIdLJT7ugA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-eZeOHYhIdLJT7ugA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-eZeOHYhIdLJT7ugA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eZeOHYhIdLJT7ugA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-eZeOHYhIdLJT7ugA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eZeOHYhIdLJT7ugA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-eZeOHYhIdLJT7ugA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-eZeOHYhIdLJT7ugA .cluster text{fill:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA .cluster span{color:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-eZeOHYhIdLJT7ugA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-eZeOHYhIdLJT7ugA rect.text{fill:none;stroke-width:0;}#mermaid-svg-eZeOHYhIdLJT7ugA .icon-shape,#mermaid-svg-eZeOHYhIdLJT7ugA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-eZeOHYhIdLJT7ugA .icon-shape p,#mermaid-svg-eZeOHYhIdLJT7ugA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-eZeOHYhIdLJT7ugA .icon-shape rect,#mermaid-svg-eZeOHYhIdLJT7ugA .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-eZeOHYhIdLJT7ugA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-eZeOHYhIdLJT7ugA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-eZeOHYhIdLJT7ugA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 打开体育详情
详情分支进入组合
启动任务、取得作用域或注册资源
返回列表,详情分支离开组合
取消关联协程,执行已注册的清理

组合代码可能因为状态变化再次执行。本章通过副作用 API,把相关工作与组合位置、key 或清理时机联系起来。官方文档也按任务启动、作用域、清理和最新值等用途介绍这些 API。Compose 副作用说明

本章涉及的导入与日志标签

四课用到的相关导入集中列在下面,按现有文件中的导入对照阅读。

kotlin 复制代码
import android.util.Log
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

日志沿用文件级标签。

kotlin 复制代码
private const val TAG = "EasyhoodNewsApp"

页面已有 Button、Text、Modifier 和详情组件的调用环境。下面的短片段分别放在前面说明的详情分支内部。

二、LaunchedEffect 让任务跟随组合位置

第146课先观察进入详情与返回列表时的任务行为。下面是当时给出的完整练习片段,日志和等待时间按教学示例保留。

kotlin 复制代码
LaunchedEffect(currentNews.id) {
    Log.d(TAG, "详情任务开始:${currentNews.id}")

    try {
        delay(10000)
        Log.d(TAG, "详情任务完成:${currentNews.id}")
    } finally {
        Log.d(TAG, "详情任务结束:${currentNews.id}")
    }
}

LaunchedEffect 的 L、E 大写,delay 使用小写。这里把 currentNews.id 作为 key,体育新闻的编号是 2。

从这段代码能读出三个阶段。

  1. 这处调用进入组合时,启动协程并打印开始日志。
  2. 等待结束后,打印完成日志,再执行 finally。
  3. 等待期间返回列表,这处调用离开组合,关联协程被取消,执行 finally。

我的运行反馈

这一课按两种情况观察。

操作 预期日志顺序
进入体育详情,等待完成 开始、完成、结束
进入详情后,在等待结束前返回 开始、结束

我反馈"符合预期"。这张表对应当时指定的验证项,日志顺序按代码说明。

理解问答还加入了两个口头判断。我回答 key 从2变成3时会重新开始,key 保持2、只发生普通重组时沿用原任务。辅导进一步明确,key 变化会取消旧协程并启动新协程。

我曾把离开时的行为表述为"随着组合的结束而结束"。更准确地说,是这处 LaunchedEffect 离开组合时,取消与它关联的协程。LaunchedEffect 说明

第146课最初给出的等待时间为10000毫秒。第147课读取项目时,这段历史示例已调整为1000毫秒。两种时间分别对应指导片段与后续源码快照。第149课随后用最新值读取例子接替了这段任务代码。

三、rememberCoroutineScope 提供点击时使用的作用域

第147课在详情分支中增加一个"延迟打印"按钮。完整相关片段如下。

kotlin 复制代码
val detailScope = rememberCoroutineScope()

Button(
    onClick = {
        detailScope.launch {
            Log.d(TAG, "点击任务开始")
            delay(3000)
            Log.d(TAG, "点击任务完成")
        }
    }
) {
    Text(text = "延迟打印")
}

rememberCoroutineScope 的 C、S 大写,launch 全部小写,变量名 detailScope 的 S 大写。

这里分开完成两个动作。

  • rememberCoroutineScope() 取得与当前组合位置关联的作用域。
  • 点击按钮时,detailScope.launch { ... } 在这个作用域中启动协程。

这个作用域会随它所在的组合位置离开而取消。本例把调用放在详情分支,因此返回列表时,其中等待中的点击任务也会取消。rememberCoroutineScope 说明

我的运行反馈

我先点击按钮并停留超过3秒,观察到开始与完成两项验证符合预期。随后再次点击,立即返回列表,对应验证是先打印开始,等待中的任务随详情分支退出而取消。我反馈"结果符合预期"。

在问答中,我能区分获取作用域和启动任务这两个动作,也指出 detailScope 关联此位置,viewModelScope 由 ViewModel 对象管理。

把两者放回当前项目中理解,detailScope 服务于详情组合位置中的界面相关任务,viewModelScope 服务于 ViewModel 管理的任务。

四、DisposableEffect 与 onDispose 负责配对清理

第148课用两条日志模拟注册与清理,代码如下。

kotlin 复制代码
DisposableEffect(currentNews.id) {
    Log.d(TAG, "模拟注册:${currentNews.id}")

    onDispose {
        Log.d(TAG, "模拟清理:${currentNews.id}")
    }
}

DisposableEffect 的 D、E 大写,onDispose 的 D 大写。进入时执行外层的注册部分,清理动作写在最后的 onDispose { ... } 中。

在本例中,返回列表会让这处调用离开组合,触发清理。key 改变也会先清理原来的效果,再建立新的效果。DisposableEffect 说明

我的运行反馈

我按进入体育详情、返回列表、再次进入的顺序观察,反馈"结果符合预期"。

按代码,这组操作对应下面的日志正文。这里以预期内容展示。

text 复制代码
模拟注册:2
模拟清理:2
模拟注册:2

问答中,我说明 onDispose 在离开组合时执行。如果注册处真正添加了监听器,清理处应取消监听。

这次练习通过日志模拟了一对操作。理解实际用途时,可以把它们对应为添加监听与移除监听,或者取得资源与释放资源。

五、rememberUpdatedState 让等待中的任务读取最新值

第149课使用现有搜索框做观察。搜索输入由页面中的 searchText 管理,读取 searchText.text.toString() 可以得到当前字符串。

这一课希望观察两个值。

  • 任务启动时捕获的搜索词。
  • 同一个任务等待后读取的最新搜索词。

rememberUpdatedState 在重组时更新保存的值,正在运行的任务可以从返回的 State 中读取最新内容。rememberUpdatedState 说明

第一次观察出现了问题

最初的写法先等待,再连续打印启动时字符串和保存的最新值。我运行后发现两次都是"体育",提出了疑问。

都是体育,我感觉你给的代码写的不对

核对实际文件时,等待语句是下面这一行。

kotlin 复制代码
delay(1000)

它只等待1秒,实际可修改输入的时间窗口很短。我随后提出了调整顺序的建议。

delay 1000 应该在两次中间

接着把两个观察点分开,并将等待调整为10000毫秒。当前片段如下,整理时统一了缩进。

kotlin 复制代码
val searchSnapshot = searchText.text.toString()
val latestSearchText = rememberUpdatedState(searchSnapshot)

LaunchedEffect(currentNews.id) {
    Log.d(TAG, "最新值任务开始")
    Log.d(TAG, "启动时搜索词:$searchSnapshot")

    delay(10000)

    Log.d(TAG, "等待后最新搜索词:${latestSearchText.value}")
}

这个调整让观察顺序更清楚。先输出启动时的值,等待10秒,再输出最新值。调整后的验证同时改变了等待时长和日志顺序,两项变化一起构成这次再次运行的条件。

两个变量分别保存什么

标识符 本例中的类型 作用
searchSnapshot String 本次组合读取到的搜索字符串,任务启动时会捕获当时的值
latestSearchText State<String> 保存重组时传入的最新字符串
latestSearchText.value String 读取该 State 当前保存的字符串
currentNews.id Int 作为任务的 key,当前体育新闻为2

rememberUpdatedState 的 U、S 大写。latestSearchText 是 State 对象,后面的 .value 读取其中保存的内容。

修改搜索词时,相关组合代码重新读取字符串,并更新 latestSearchText 中保存的值。当前新闻编号仍然为2,因此这处 LaunchedEffect 沿用原来的任务。

调整后的运行结果

我在列表中输入"体育",进入体育详情,在等待结束前把顶部输入改为"科技",并停留在详情。

按当前代码,预期的两个观察结果如下。两次搜索词日志之间等待约10秒。

text 复制代码
启动时搜索词:体育
等待后最新搜索词:科技

再次运行后,我反馈"结果符合预期"。这个确认对应的是调整后的操作和两个搜索词的对照。

我最初将用途概括为"在长任务中更新保存的值",并回答搜索词变化时 key 没有变化。辅导补充后,表述可以进一步明确为重组时更新保存的值,长任务通过 .value 读取。

六、章末相关代码集中对照

下面按章末实际源码提取有效新闻详情分支,统一缩进并保留完整的相关调用。它承接页面已有的 currentNews、searchText、newsViewModel 与 innerPadding,并使用本章前面列出的导入、日志标签和已有 UI 组件。

这个片段包含模拟清理、最新搜索词任务和点击启动的任务。前面的第146课片段用于回看最初的启动、取消与 finally 观察过程。

kotlin 复制代码
if (currentNews != null) {

    DisposableEffect(currentNews.id) {
        Log.d(TAG, "模拟注册:${currentNews.id}")

        onDispose {
            Log.d(TAG, "模拟清理:${currentNews.id}")
        }
    }
    val searchSnapshot = searchText.text.toString()
    val latestSearchText = rememberUpdatedState(searchSnapshot)

    LaunchedEffect(currentNews.id) {
        Log.d(TAG, "最新值任务开始")
        Log.d(TAG, "启动时搜索词:$searchSnapshot")
        delay(10000)

        Log.d(TAG, "等待后最新搜索词:${latestSearchText.value}")

    }
    val detailScope = rememberCoroutineScope()

    Button(
        onClick = {
            detailScope.launch {
                Log.d(TAG, "点击任务开始")
                delay(3000)
                Log.d(TAG, "点击任务完成")
            }
        }
    ) {
        Text(text = "延迟打印")
    }

    NewsDetailScreen(
        news = currentNews,
        onBack = {
            newsViewModel.onIntent(NewsIntent.BACK_TO_LIST)
        },
        modifier = Modifier.padding(innerPadding)
    )
}

这些短例子各自服务于一个观察点。点击任务通过 detailScope 启动,最新搜索词任务通过 LaunchedEffect 启动,模拟清理由 DisposableEffect 管理。

七、四个 API 的用途回顾

API 本章中的使用方式 我需要记住的基本作用
LaunchedEffect 进入详情启动等待任务,以新闻编号作为 key 将协程任务与组合位置和 key 联系起来
rememberCoroutineScope 取得 detailScope,按钮点击时调用 launch 取得可供回调使用、与组合位置关联的作用域
DisposableEffect 打印模拟注册,在 onDispose 打印清理 管理需要配对清理的操作
rememberUpdatedState 重组时更新最新搜索词,等待后通过 .value 读取 让长任务读取最新保存值

章末对应题中,对进入组合启动协程、获取回调使用的作用域、注册并清理这三种用途,我依次回答了 LaunchedEffect、rememberCoroutineScope、DisposableEffect。

结合最新值例子的再次运行与补充说明,第146---149课完成了本章的基本用途确认。

八、本章成果与下一章

这一章的练习集中在有效新闻详情分支。通过返回列表观察关联协程取消,通过模拟日志理解清理位置,通过搜索词变化理解任务捕获的旧值与 State 保存的最新值。

练习深度为了解基本作用、读懂关键代码和观察代表性结果。SharedFlow 页面收集与生命周期的综合专题已列为后续可选阅读。

下一章是第15章「正式页面导航了解」,第150---153课,共4课。接下来会对照一个两页面示例认识路由、新闻编号传参、返回栈与基础状态保留。

九、记录来源与资料

本文依据第146---149课对话中的运行反馈、理解问答和纠正过程整理,并按章末 MainActivity.kt 核对详情分支代码。示例采用现有新闻、搜索输入、返回意图和页面布局环境。

代码分别标明了第146课指导片段、后续读取到的等待时间、当前第149课写法和章末完整相关分支。整理时统一缩进,集中列出相关导入。当前最新值任务的等待时间为10000毫秒,按钮点击任务为3000毫秒。

本章运行与日志结果来自学习时对指定验证项的反馈。日志代码块展示按代码推导的预期正文,原始 Logcat 文本未随本章反馈提交。key 从2变成3的情况是口头题,真实资源注册与清理是用途判断。任务启动日志的具体次数未作为独立运行结果补记。

四种 API 的基本说明可在官方资料中继续核对。

相关推荐
白杨尚青1 小时前
C++进阶篇(六):map 和 set 的模拟实现——用红黑树封装 mymap/myset(迭代器 + operator[])
开发语言·c++·迭代器·stl·set·map·红黑树
FlightYe1 小时前
音视频修炼之容器与协议(四):RTSP详解
android·linux·ffmpeg·音视频·dash
疯华整猫3 小时前
删掉 LeakCanary 那天,我用 Trae 自己撸了一套:最难的不是分析堆,是决定什么时候 dump
android·人工智能·性能优化
饮水桶_T4 小时前
JAVA IO流笔记
开发语言·数据结构·算法
Felven7 小时前
C. Brr Brrr Patapim
c语言·开发语言
路溪非溪8 小时前
python标准库使用总结
开发语言·python
SomeOtherTime10 小时前
Rust 面向对象介绍(AI回答)
开发语言·python·rust
松仔log10 小时前
Kotlin中级——callbackFlow
java·前端·kotlin
zh_xuan10 小时前
kotlin常见面试题
开发语言·kotlin·面试题