Jetpack Compose 知识点整理3【个人用】

一、副作用管理

"副作用"指的是:你在写UI代码时,偷偷干了和"画界面"无关的事情。

在 Compose 的声明式模型中,可组合函数 (@Composable 函数) 会被框架频繁调用(重组)。很多时候我们不仅仅需要在可组合函数的内部进行UI操作,可能还会进行一些和UI无关的操作。例如:资源初始化和释放、异步操作等等。如果直接在可组合函数体内执行这些副作用,会导致严重问题:

  1. 副作用被重复执行: 每次重组都会触发副作用(如每次重组都发起一次网络请求),导致性能灾难、资源耗尽或逻辑错误。
  2. 资源泄漏:如果在副作用中注册了监听器或持有了资源,但没有在不再需要时清理,每次重组都会创建新的监听器/资源,旧的却未被释放,导致内存泄漏。
  3. 执行时机错误:副作用可能在不该执行的时候执行(例如,UI 已经不在屏幕上显示)。
  4. 破坏声明式纯度: 可组合函数的输出(UI)应该只依赖于其输入参数(状态)。直接执行副作用引入了不可预测的外部因素,破坏了这一原则,使 UI 行为难以理解和调试。
  5. 生命周期不匹配: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
        }
    )
}

关键点说明

  1. 参数 Unit:确保效果只在初始组合时运行一次
  2. onDispose 必须返回:这是资源清理的保证
  3. 避免内存泄漏:通过注销监听器防止回调持有组件引用
  4. 状态管理:网络状态变化自动触发 UI 更新
  5. 生命周期安全:自动处理组件的创建和销毁

6.如何选择

|---------------|-----------------------------------------|
| 场景 | 推荐 API |
| 状态变化自动触发异步操作 | LaunchedEffect |
| 用户交互(点击/滑动)触发 | rememberCoroutineScope |
| 组合生命周期内持续监听 | rememberCoroutineScope+DisposableEffect |
| 单次初始化操作 | LaunchedEffect(Unit) |

总的来说:

  • 需要自动响应状态变化,使用LaunchedEffect
  • 只需要单次初始化操作,使用LaunchedEffect(Unit)
  • 需要在用户交互后,手动触发协程使用rememberCoroutineScope

7.实际开发中的使用场景

|------------------------|------------|--------------|
| 副作用管理器 | 使用频率 | 主要场景 |
| LaunchedEffect | ⭐⭐⭐⭐⭐ (极高) | 单次副作用、响应状态变化 |
| rememberCoroutineScope | ⭐⭐⭐⭐ (高) | UI动画、手势处理 |
| DisposableEffect | ⭐⭐⭐ (中) | 资源清理、订阅管理 |

8.为什么感觉副作用管理器"不常用"

这种感觉可能来自以下几个原因:

  1. 业务逻辑集中在ViewModel:大部分异步操作和状态管理确实应该在ViewModel中处理
  2. 简单应用需求有限:对于简单UI,可能只需要基本的LaunchedEffect
  3. 学习曲线:副作用管理器的概念需要时间理解和掌握
  4. 架构选择影响:不同架构模式对副作用处理的方式不同

二、在Compose中使用View

有些时候Compose的组件无法满足我们需求,此时可以考虑使用传统的View控件,比如:

  1. 兼容现有成熟组件:如 WebView、MapView 等尚未提供纯 Compose 替代方案
  2. 复杂自定义视图:迁移遗留自定义 View 成本过高
  3. 性能敏感区域:特定场景下 View 仍具性能优势
  4. 渐进式迁移:混合开发时的过渡方案

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()
}
相关推荐
坤坤子吖1 小时前
Linux 线程同步与互斥——互斥锁
linux·服务器·c++·笔记·学习
Kapaseker1 小时前
别再只会写 <T>:Kotlin 泛型的实战用法
android·kotlin
威嵌神州2 小时前
国产化加固计算机项目选型笔记|板卡、整机、平板与定制模块评估要点
笔记·fpga开发·电脑
阿梦Anmory2 小时前
规划领域定义语言(PDDL)学习笔记
笔记·学习
绛橘色的日落(。・∀・)ノ2 小时前
C#学习笔记
笔记·学习·c#
留岁2 小时前
Git 命令行学习记录
git·学习·elasticsearch
小黄人软件2 小时前
aipy学习 核心循环
学习
ss2732 小时前
AI时代首先数据库-PostgreSQL实战笔记 | 16 PostgreSQL子查询与嵌套查询(下)
数据库·笔记·postgresql
FlightYe2 小时前
音视频修炼之容器与协议(一):MP4和MOV的box剖析
android·linux·c++·ffmpeg·音视频