一、副作用管理
"副作用"指的是:你在写UI代码时,偷偷干了和"画界面"无关的事情。
在 Compose 的声明式模型中,可组合函数 (@Composable 函数) 会被框架频繁调用(重组)。很多时候我们不仅仅需要在可组合函数的内部进行UI操作,可能还会进行一些和UI无关的操作。例如:资源初始化和释放、异步操作等等。如果直接在可组合函数体内执行这些副作用,会导致严重问题:
- 副作用被重复执行: 每次重组都会触发副作用(如每次重组都发起一次网络请求),导致性能灾难、资源耗尽或逻辑错误。
- 资源泄漏:如果在副作用中注册了监听器或持有了资源,但没有在不再需要时清理,每次重组都会创建新的监听器/资源,旧的却未被释放,导致内存泄漏。
- 执行时机错误:副作用可能在不该执行的时候执行(例如,UI 已经不在屏幕上显示)。
- 破坏声明式纯度: 可组合函数的输出(UI)应该只依赖于其输入参数(状态)。直接执行副作用引入了不可预测的外部因素,破坏了这一原则,使 UI 行为难以理解和调试。
- 生命周期不匹配:Android 组件(Activity/Fragment)和 Compose UI 都有生命周期。副作用需要在这些生命周期事件(如暂停、销毁)发生时做出响应(暂停、取消、清理)。
因此,Compose 设计了副作用效应器 ,将副作用操作"隔离"到特定的、由框架管理的"槽位"中,确保它们只在必要且安全的时刻执行一次或响应特定状态变化,并能与组合的生命周期绑定进行清理。
1.副作用效应器
Compose提供了多种副作用效应器,让我们来管理这些和"画界面"无关的事情。这些副作用效应器可以让开发者在组件树中存储和使用协程作用域。
1.1 为什么要放在可组合函数内部完成?
与 Composable 生命周期对齐:
Compose UI 有独立的生命周期(组合/重组/销毁),不同于 Activity/Fragment。当 Composable 被移除或配置变更时,副作用效应器能自动在正确的时机创建、释放资源,避免内存泄漏。
响应式设计需求:
某些资源依赖 Composable 上下文(如 LocalContext、LocalLifecycleOwner),只能在组合函数内获取:
Kotlin
DisposableEffect(Unit) {
val context = LocalContext.current
val sensorManager = context.getSystemService<SensorManager>()!
// ...
onDispose { sensorManager.unregisterListener(this) }
}
条件化资源管理:
资源可能只在特定 Composable 树分支需要(如条件语句中):
Kotlin
if (showFeature) {
DisposableEffect(Unit) {
startFeature()
onDispose { stopFeature() }
}
}
1.2 为什么不直接使用 onCreate/onDestroy?
|-------------|----------------------------|----------------------------------------|
| 场景 | Compose (DisposableEffect) | Activity/Fragment (onCreate/onDestroy) |
| UI 局部资源管理 | ✅ 精确控制作用域 | ❌ 全局生命周期,易导致资源泄露 |
| 条件分支中的资源 | ✅ 自动按需创建/销毁 | ❌ 需手动管理状态 |
| 配置变更(旋转屏幕等) | ✅ 自动重建不泄露 | ❌ 需额外处理ViewModel等 |
| 动态 UI(如列表项) | ✅ 每个条目独立管理 | ❌ 难以实现细粒度控制 |
2.SideEffect
SideEffect:将 Compose 状态同步到非 Compose 代码。
Compose 成功完成一次 UI 更新(重组)后,如何安全、可靠地把 Compose 内部的"最新状态"告诉给外面的"非 Compose "
- 何时使用:将 Compose 状态同步到外部非 Compose 代码(如遗留 View 系统、Analytics SDK 设置用户属性)。
- 生命周期: 在每次成功的重组之后执行。不保证执行次数(可能跳过),但保证在重组完成后且 UI 树已更新后执行。
- 清理: 通常不需要显式清理,因为它不持有资源,只是同步状态。
- 关键点:轻量级,无 key 参数。用于"推送"状态,而不是"拉取"或执行耗时操作。
- 语法:
Kotlin
@Composable
fun AnalyticsUserTracker(userId: String?) {
val analyticsSdk = remember { AnalyticsSdk.getInstance() }
// 每次重组后,如果 userId 有变化,更新 SDK 的用户 ID
SideEffect {
analyticsSdk.setUserId(userId)
}
}
3.LaunchedEffect
LaunchedEffect: 在组合中安全启动协程。
Compose 里专门用来处理"需要时间完成的副作用"的工具,比如网络请求、延时操作、监听数据流(Flow)。等界面画完后,悄悄干点耗时的事,离开屏幕时自动取消。本质就是一个可组合函数,用于在 Compose 的生命周期中启动协程,确保协程在离开组合时自动取消。
- 假如直接在onCreate 启动协程后,忘记在 onDestroy 取消 → 内存泄漏。
- 在重组中直接调用挂起函数 → 阻塞 UI 线程(Compose 禁止此操作)。
- LaunchedEffect的优势:
- 自动取消:当组合退出时,协程自动取消。
- 安全执行:在 Compose 的协程作用域中执行,符合生命周期。
- 精准控制:通过 key 控制执行时机,避免无效请求。
3.1 常见用法
3.1.1 我们会使用参数key来控制是否启动或者重启这个协程
- 规则:
- 当 key 变化时,前一个协程会被取消,新协程启动。
- 使用 key1 = Unit 表示整个组合生命周期内只执行一次。
- 可传入多个键:LaunchedEffect(key1, key2) { ... }。
- 示例
Kotlin
// 当 userId 或 forceRefresh 变化时重启协程
LaunchedEffect(userId, forceRefresh) {
loadData(userId)
}
3.1.2 初次加载数据
Kotlin
@Composable
fun UserProfile(userId: String) {
var userData by remember { mutableStateOf<User?>(null) }
// 当 userId 变化时重新执行
LaunchedEffect(userId) {
userData = fetchUser(userId) // 挂起函数
}
//ui操作:数据不为空就处理,否则加载错误信息
if (userData == null) LoadingSpinner()
else UserDetails(userData)
}
效果:
- 用户第一次进入页面 → 加载数据
- 如果 userId 变化(比如切换到另一个用户)→ 取消旧请求,加载新用户
- 离开页面 → 自动取消未完成的请求
3.1.3 响应状态变化(如搜索联想)
Kotlin
@Composable
fun SearchBar() {
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<String>>(emptyList()) }
// 当 query 变化时,取消前一个请求,发起新请求
LaunchedEffect(query) {
if (query.length > 2) {
results = searchApi(query) //发起POST网络请求 搜索关键词
}
}
}
3.1.4 定时任务(如倒计时)
java
@Composable
fun Timer(seconds: Int) {
var time by remember { mutableIntStateOf(seconds) }
LaunchedEffect(Unit) { // 使用 Unit 确保只执行一次
while (time > 0) {
delay(1000) // 挂起 1 秒
time--
}
}
Text("$time seconds left")
}
4.rememberCoroutineScope
rememberCoroutineScope:获取与组合生命周期绑定的协程作用域,是 Jetpack Compose 中另一个关键的协程管理 API,与 LaunchedEffect 互补使用。
rememberCoroutineScope函数。它返回一个CoroutineScope对象(协程作用域),您可以使用它来启动和取消协程。每个记忆的CoroutineScope都与它所属的组件相关联,这样当组件被移除时,与之关联的作用域也将被取消。
4.1 举个例子
4.1.1 传统方式风险
Kotlin
// ❌ 危险示例:作用域泄露
Button(onClick = {
GlobalScope.launch { // 全局作用域
updateUI() // 可能访问已销毁的组件
}
})
- 使用全局作用域:协程不会自动取消 → 内存泄漏
- 直接调用挂起函数:阻塞 UI 线程 → 应用崩溃
4.1.2 使用rememberCoroutineScope
Kotlin
@Composable
fun MyComposable() {
val scope = rememberCoroutineScope()
var text by remember { mutableStateOf("") }
Button(onClick = {
// 启动一个协程,更新text变量
scope.launch {
text = "Button clicked" //假装发起了一个耗时请求
}
}) {
Text("Click me")
}
Text(text)
}
优势:
- 自动取消:当组合点退出时,作用域内所有协程自动取消
- 安全访问状态:在组合存续期间安全更新状态
- 精确控制:可手动保存 Job 进行细粒度控制
注意:不要在可组合函数顶层直接使用 scope.launch { ... },这会导致每次重组都启动新协程!而是只在事件回调(如 onClick)或明确需要的位置启动协程。
5.DisposableEffect
DisposableEffect:管理需要清理的资源,在退出组合或键变化时调用 onDispose。它确保了资源在不再需要时被正确释放,防止内存泄漏和资源浪费。
比如: 注册监听器(BroadcastReceiver, 传感器监听器)、获取/释放系统资源、设置回调...
5.1 举个例子
在 Compose 中监听网络连接状态变化
Kotlin
@Composable
fun NetworkStatusMonitor() {
// 获取当前上下文
val context = LocalContext.current
// 创建状态变量存储网络状态
val networkStatus = remember { mutableStateOf("Unknown") }
// 使用 DisposableEffect 注册和注销广播接收器
DisposableEffect(Unit) { // Unit 表示只做一次创建、销毁,其他时候的重组不会被触发
// 1. 初始化操作:创建广播接收器和过滤器
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkStatus.value = "Connected" // 网络可用时更新状态
}
override fun onLost(network: Network) {
networkStatus.value = "Disconnected" // 网络丢失时更新状态
}
}
// 2. 注册网络状态监听器
connectivityManager.registerDefaultNetworkCallback(networkCallback)
// 3. 初始状态检查
val activeNetwork = connectivityManager.activeNetworkInfo
networkStatus.value = if (activeNetwork?.isConnectedOrConnecting == true) "Connected" else "Disconnected"
// 4. 必须返回的清理函数 (关键步骤!)
onDispose {
// 5. 当组件离开组合时取消注册
connectivityManager.unregisterNetworkCallback(networkCallback)
}
}
// UI 显示网络状态
Text(
text = "Network status: ${networkStatus.value}",
color = when (networkStatus.value) {
"Connected" -> Color.Green
"Disconnected" -> Color.Red
else -> Color.Gray
}
)
}
关键点说明
- 参数 Unit:确保效果只在初始组合时运行一次
- onDispose 必须返回:这是资源清理的保证
- 避免内存泄漏:通过注销监听器防止回调持有组件引用
- 状态管理:网络状态变化自动触发 UI 更新
- 生命周期安全:自动处理组件的创建和销毁
6.如何选择
|---------------|-----------------------------------------|
| 场景 | 推荐 API |
| 状态变化自动触发异步操作 | LaunchedEffect |
| 用户交互(点击/滑动)触发 | rememberCoroutineScope |
| 组合生命周期内持续监听 | rememberCoroutineScope+DisposableEffect |
| 单次初始化操作 | LaunchedEffect(Unit) |
总的来说:
- 需要自动响应状态变化,使用LaunchedEffect
- 只需要单次初始化操作,使用LaunchedEffect(Unit)
- 需要在用户交互后,手动触发协程使用rememberCoroutineScope
7.实际开发中的使用场景
|------------------------|------------|--------------|
| 副作用管理器 | 使用频率 | 主要场景 |
| LaunchedEffect | ⭐⭐⭐⭐⭐ (极高) | 单次副作用、响应状态变化 |
| rememberCoroutineScope | ⭐⭐⭐⭐ (高) | UI动画、手势处理 |
| DisposableEffect | ⭐⭐⭐ (中) | 资源清理、订阅管理 |
8.为什么感觉副作用管理器"不常用"
这种感觉可能来自以下几个原因:
- 业务逻辑集中在ViewModel:大部分异步操作和状态管理确实应该在ViewModel中处理
- 简单应用需求有限:对于简单UI,可能只需要基本的LaunchedEffect
- 学习曲线:副作用管理器的概念需要时间理解和掌握
- 架构选择影响:不同架构模式对副作用处理的方式不同
二、在Compose中使用View
有些时候Compose的组件无法满足我们需求,此时可以考虑使用传统的View控件,比如:
- 兼容现有成熟组件:如 WebView、MapView 等尚未提供纯 Compose 替代方案
- 复杂自定义视图:迁移遗留自定义 View 成本过高
- 性能敏感区域:特定场景下 View 仍具性能优势
- 渐进式迁移:混合开发时的过渡方案
1.举个例子
使用webview来举个例子,展示混合用法:往下面的可组合函数中传递一个网址,可以打开页面
Kotlin
@Composable
fun CustomWebView(url: String) {
// 状态管理:记录加载进度
var progress by remember { mutableStateOf(0) }
// 关键:AndroidView 桥接传统 View
AndroidView(
//factory:创建传统 View 实例的工厂函数,在composable首次进入组合时执行且仅执行一次,并且返回一个传统view对象
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
// 页面加载完成处理
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress // 更新 Compose 状态
}
}
}
},
//update:接收收 factory 创建的 View 实例
update = { webView ->
webView.loadUrl(url) // URL 变化时重新加载
}
)
// 组合式进度条
LinearProgressIndicator(
progress = progress / 100f,
modifier = Modifier.fillMaxWidth(),
color = Color.Blue
)
}
//引用
setContent {
CustomWebView("https://titok.fzqq.fun/agreement.html")
}
再用webview来举个例子,展示混合用法:往下面的可组合函数中传递一段富文本,可以借助webview直接显示富文本片段
Kotlin
// 声明一个可组合函数,用于安全加载HTML内容
@Composable
fun SafeHtmlWebView(htmlContent: String) {
// 使用AndroidView桥接传统WebView
AndroidView(
// factory定义如何创建WebView实例(仅执行一次)
factory = { context ->
// 创建WebView实例并立即配置(apply作用域内)
WebView(context).apply {
// 配置WebView设置
settings.apply {
// 启用JavaScript支持
javaScriptEnabled = true
// 启用DOM存储API(HTML5本地存储)
domStorageEnabled = true
// 允许访问本地文件
allowFileAccess = true
// 允许WebView访问内容提供器
allowContentAccess = true
// 缩放至适合屏幕宽度
loadWithOverviewMode = true
// 使用宽视口模式(适应移动端页面)
useWideViewPort = true
// 启用内置缩放控件
builtInZoomControls = true
// 隐藏缩放按钮(使用手势控制)
displayZoomControls = false
// 支持缩放操作
setSupportZoom(true)
// 缓存策略:使用默认缓存模式
cacheMode = WebSettings.LOAD_DEFAULT
// 允许加载混合内容(HTTPS页面中的HTTP资源)
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
}
},
// update定义如何响应状态变化(重组时触发)
update = { webView ->
// 对原始HTML内容进行安全处理
val formattedHtml = formatHtmlContent(htmlContent)
// 加载处理后的HTML内容(比loadData更安全)
webView.loadDataWithBaseURL(
// 基础URL:用于解析相对路径资源,如果是加载富文本,这里的url最好是填写资源所属的服务器,而不是填null
"https://titok.fzqq.fun/null",
// 处理后的HTML内容
formattedHtml,
// 内容MIME类型
"text/html",
// 编码格式
"UTF-8",
// 历史记录URL(null表示不记录)
null
)
// 注意:使用baseURL而不是null,可以:
// 1. 允许同源策略正常工作
// 2. 使相对路径的CSS/JS/图片能正确加载
}
)
}
//引用
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 构建一个富文本
val richText = """
今天聊一下字节的硬件业务,按照昨天、今天和明天的结构。
<img src="http://dayhot.fzqq.fun/uploads/20250824/bb3d9966f5b4e3c42d04d10fc8ebb5e6.png" alt="" />
## 01 硬件探索
互联网基因的公司,往往被认为是做不好硬件的。所以,字节最初踏入硬件圈时,也采用了收购的方式。
2019年用了近亿元收购了锤子科技坚果手机团队,该团队在内部命名为 "新石实验室"。
当时罗永浩正遭遇经营压力,正好字节也可以快速建立硬件基础,避免从0到1。所以也一拍即合。但是随后发布的几款手机效果不佳。2021年该团队被并入字节的教育硬件团队。
2020年,字节启动了教育品牌"大力教育",教育硬件负责人阳陆育,也是因着收购加入了字节。大力教育的产品是大力智能台灯,具备拍照传题等功能,这个产品在字节内部被评为"蛮有亮点的突破"。
我也给孩子买了一台,但智能业务确实用的不多。多少也算是对其产品体验的例证。后来赶上教育行业整顿,大力台灯业务也就此翻篇了。
但字节的硬件试水并没有停。
2021 年8 月,字节重金收购了国内 vr 出货量第一的厂商 pico。这一笔投资,多少和Meta画的元宇宙大饼有关。当然,结局也很类似。
2022年PICO 4经过一番宣传后,销量不佳。这主要也是行业问题,VR所构建的使用场景,仍不是高频、刚需;产品的成本、重量等因素也还不够满足现实使用,所以整个VR赛道都又回到了探索的阶段。
字节在VR硬件这一波,估计花了200亿元。也是价格不菲的一堂课。
这波AI硬件浪潮,字节也入局了。2024年9月,字节收购了耳机品牌Oladance,随后就发布了一款搭载豆包大模型的智能耳机Ola。在竞争激烈的AI耳机赛道,几个月下来,也并没有看到非常惊艳的数据。
<img src="http://dayhot.fzqq.fun/uploads/20250824/afed996537360bacf583e4552550e765.png" alt="" /><br><br>
## 02 屡战屡败又屡败屡战
为什么字节在硬件领域,屡战屡败又屡败屡战?
首先,不只是字节,不少软件起家的科技巨头,都有一颗做硬件的心。微软买过诺基亚、做过笔记本电脑;Meta甚至要all in VR;谷歌也尝试过手机、眼镜;百度、阿里也都有智能音箱的业务
纷纷尝试硬件的道理,也很简单。毕竟,硬件是各类应用的载体,是流量的重要入口。尤其现在消费电子陷入了一个混沌期,各路玩家包括苹果在内,都在寻找下一个国民级硬件产品。而AI大模型,又非常需要一个良好的载体,才能保持领先。
因此,谁能率先找到新载体、形成新品牌,既是硬件的事儿,也是AI的事儿。公司的业务版图里,可能会长出半个苹果,也可能长出一个OpenAI。不管哪个,都是一笔巨大财富。
但硬件的路并不好走。互联网公司在硬件领域,屡战屡败的原因,也不复杂。
首先,开发模式大不相同。
一款应用的开发,可能依靠数百位码农就能搞定;也不需要什么合作伙伴。但一款硬件的开发,并不是一个团队就能成功的。一款产品,少则几十个、多则几百个零部件,必须和供应商一起进行设计、试错。
因此,也导致,一款应用的开发周期可能就是几个月或者一年,但一款硬件产品的开发周期可能要两年甚至更久。这对于习惯了快速迭代、投流就要见效果的互联网企业而言,非常不适应。
另外,迭代路径不同。软件产品可以迅速获得反馈,哪些数据好哪些数据差,如何迭代,很快就知道了。因此,也往往可以采用先试试再说的做法,成本也相对可控。
但是硬件产品就不一样了,很难快速获得反馈数据,往往只能等产品上市后的销售状况才能判断好坏。所以,小米才在早期通过发动用户广泛参与来减少试错成本。而伟大的硬件产品,需要尽力做好市场前瞻判断的时候,也往往也有不少押注的成分。
而且硬件产品的成本结构,和软件也不同。零部件,尤其是定制化的零部件,销量不同、成本差异也很大。经常会形成一个负循环:销量越不行、成本越高,成本越高、价格越贵,销量也会越惨。
所以,硬件、软件的差异,就是基因里自带的。
## 03 成功的要素
字节采用收购的打法,也是对的。但要做好硬件,也有三个核心要素:
第一,对于硬件团队的研发文化、考核制度,是按照硬件、还是软件来管理?
第二,考虑到成熟的应用业务,和探索的硬件业务,会有非常大的差异。因此,硬件业务,也是应该是"一把手工程"。
对于创新业务,尤其是和原有业务差异较大的创新业务,往往只有一把手,才能在公司内部顶住不一样的工作方式、思维方法,在关键节点有魄力去做判断、做选择。
第三,做好供应链管理,发挥供应商的能力。这一点对于电子公司而言,就是比较大的机会了。2011-2014年,苹果带着电子供应链赚钱;2018年华为扶持了不少国产供应链转正;当下,市场也非常期待字节能带着供应链壮大。
而和供应商的利益、协作,也是技术活了。自己既是甲方,又不能纯当甲方。
目前字节在AI硬件上的资金投入,和之前相比,规模小很多。也许是因为大模型研发,依然需要投入重金;或者,经历了VR的滑铁卢之后,试错意愿降低了。但估计,一旦市面有爆款AI产品出现,字节应该还是会重金追赶。
如果字节真的放下了自己做硬件的执念的话,那就如之前传言,和苹果合作,成为其大模型服务商,也是不错选择。还有另一个不错的选择,就是:小米。
现在,小米如果想发力大模型,大概率是晚了些的。当然,电动车一战,也展示了,雷军不怕晚,真有能力逆风翻盘。
那咱们就假设小米不做大模型的话,和字节合作也挺好的,小米的手机、智能家居等等硬件,搭载字节的大模型,双赢局面、肉眼可见。
唯一尴尬的就是,用户如果要唤醒AI,是喊siri、小米、还是豆包。名字,可能是阻止这个双赢合作的唯一要素。
04 双料王
软硬双料王,很难实现。虽然苹果的软件业务收入也很高,但也是集成在苹果硬件产品上。跨平台的爆款应用,并不多。
谷歌有不少国民级应用,但硬件业务也是试了又试,仍未有起色。
2010年,小米推出了米聊,杀入即时通讯领域,但可惜,后来被微信截胡。不然,小米就会是国民级应用、国民级硬件的双料王。
如果认为WPS也算得上顶级应用的话,虽然小米没实现双料王,但雷军已经是了。(写到这儿,真的是路转粉。)
此时此刻,机会则留给了字节。
""".trimIndent()
setContent {
CustomWebView("https://titok.fzqq.fun/agreement.html")
}
}
使用网页代码包裹富文本内容
Kotlin
private fun formatHtmlContent(html: String): String {
return """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
padding: 16px;
margin: 0;
background-color: #ffffff;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 12px 0;
display: block;
}
h1, h2, h3, h4, h5, h6 {
margin: 24px 0 12px 0;
color: #2c3e50;
line-height: 1.3;
}
h2 {
font-size: 1.4em;
border-left: 4px solid #2196F3;
padding-left: 12px;
}
p {
margin: 16px 0;
text-align: justify;
word-break: break-word;
}
ul, ol {
margin: 16px 0;
padding-left: 24px;
}
li {
margin: 8px 0;
}
a {
color: #2196F3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.image-placeholder {
background: #f5f5f5;
padding: 40px;
text-align: center;
color: #999;
border-radius: 8px;
margin: 12px 0;
}
</style>
</head>
<body>
$html
</body>
</html>
""".trimIndent()
}