【Compose】Compose编译到显示的流程解析

【Compose】Compose编译到显示的流程解析

本文介绍了Compose声明式框架从可组合项方法的编写到最终屏显的流程解析

Jetpack Compose 是 Google 推出的用于构建原生 Android UI 的现代声明式框架,它简化了 Android 应用的 UI 开发过程。

将XMl+Java的开发方式转变为Kotlin语法的Compose的开发方式,让开发者可以使用更简洁、更直观的代码来构建用户界面,开发体验上极致的统一。

那么一个 @Composable 方法是如何变成屏幕上的显示内容的呢?下面从Android平台为切入点,从编译阶段到运行时阶段,详细解析 Compose 的显示流程。再看看Compose Multiplatform这个跨平台框架和 Android 平台上的原型Jetpack Compose有何异同。

回顾View架构

我们的应用要加载一个显示界面时,会经历以下几个阶段。首先将xml的布局文件,按照内部的父控件子控件的包含关系,将它们解析成View树,然后将View树交给WindowManager进行显示。

具体的:

1. xml文件解析构建View树

当调用 setContentView(R.layout.xxx)LayoutInflater.inflate() 时,系统会通过 LayoutInflater 解析 XML 文件。

使用 XmlPullParser 逐行解析 XML 标签,转换为内存中的视图对象(如 TextView、LinearLayout 等)。

根据标签属性(如 android:text、android:layout_width)设置视图的初始参数。

解析后的 XML 会生成一个对应的 视图树(View Hierarchy),根节点是顶层布局(如 ConstraintLayout),子节点是嵌套的视图。

每个视图的构造函数会被调用,并通过 AttributeSet 读取 XML 中的属性值(如 textSize、background)。

2. 测量(Measure)

onMeasure(int widthMeasureSpec, int heightMeasureSpec) ,父视图通过 MeasureSpec 向子视图传递尺寸约束(如 match_parent、wrap_content 或固定值)。

视图根据约束计算自身尺寸(可能需要多次测量,尤其是嵌套布局)。

最终通过 setMeasuredDimension() 保存测量结果。

3. 布局(Layout)

onLayout(boolean changed, int l, int t, int r, int b) ,父视图根据测量结果确定子视图的位置(左上右下坐标)。

例如:LinearLayout 会按垂直/水平方向依次排列子视图。

4. 绘制(Draw)

onDraw(Canvas canvas) ,视图通过 Canvas 和 Paint 绘制自身内容(如文本、背景、边框)。

绘制顺序:背景 → 主体内容(如文本/图片) → 子视图 → 前景(如滚动条)。

支持硬件加速时,绘制指令会转为 RenderNode 并交由 GPU 处理。

5. 合成送显

SurfaceFlinger 合成。各层的绘制结果(Surface)由 SurfaceFlinger 合成为最终帧。提交到屏幕时,通过 VSync 信号同步,将帧数据发送到屏幕缓冲区显示。

一般View架构的应用,在做布局相关性能优化时,有如下手段:

减少布局层级:避免嵌套过深,用 ConstraintLayout 替代多层 LinearLayout。

避免过度绘制:通过 onDraw() 优化或设置 android:background=null。

使用 ViewStub:延迟加载复杂但非立即显示的布局。

Compose 的 UI Tree

前言一 Gap Buffer

Gap Buffer 是一种用于优化局部更新的高效数据结构。其核心思想是通过维护一个 可移动的“间隙” 来优化局部性操作,在数组中预留一个空白区域(Gap)来实现高效的插入和删除操作,可以减少内存移动的开销。

最常见的应用——文本编辑器

​缓冲区结构​​,将内存分为三部分:左文本区、间隙(Gap)和右文本区。 初始时,间隙通常位于缓冲区末尾(如 [文本][间隙] ),但随着输入的光标移动,这个间隙会动态调整位置。

  • ​当在光标处插入字符时,直接将数据填入间隙。若间隙不足,则扩展间隙(如重新分配更大的内存)。
  • 删除字符时,通过调整间隙边界“吸收”被删除的字符,避免立即移动数据。
  • ​​光标移动​​:移动光标时,间隙会同步移动到新位置,此时需要将原间隙两侧的文本交换位置(例如,光标右移时,将右文本区的左端字符移到左文本区末尾)。

关键操作示例​​:

​​插入字符​​:
假设缓冲区状态:[Hello][ ][World]([]表示间隙)。
在Hello后插入! → [Hello!][ ][World],间隙缩小。

​​移动光标​​:
光标从Hello!后移动到W前:
原状态:[Hello!][ ][World]
移动后:[Hello! ][W][orld](间隙移动到W前,W从右文本区移到左文本区)。

​​删除字符​​:
删除W → [Hello! ][][orld],间隙扩展“吸收”W。

其优势主要为高效的局部操作,劣势为大范围的操作时,需要移动大量数据调整间隙的位置,最坏的情况下可能需要O(n)的时间复杂度。同时间隙填满后,扩展的成本也较高。

前言二 Slot Table

数据结构描述

SlotTable 是 Compose 里的内部数据结构,用于跟踪 组合层次结构 中的视图数据,包括 节点、组、键和记忆值 。这个数据结构上的各个组的结构及其值由编译器决定,并在运行时随着层次结构和应用状态的建立和更新而变化。

SlotTable 是一个 树形结构 ,其中每个节点都是一个组,且内部可能有子项。其每个元素被称作“插槽(Slot)”。每个插槽能存储特定类型的数据,像组件的类型、属性、状态等信息。它以扁平化的方式存储 UI 树的信息,取代了传统的树形结构,从而简化了 UI 的管理与更新操作。

组(Groups)包含以下信息:

  1. : 用于区分组的识别符,通过快速识别组的变化来帮助重新组合。它不需要是唯一的。
  2. 标志: 有关分组的元数据,包括分组所含节点数的计数器。
  3. : 为组存储的值的有序列表,可以修改或删除。槽支持引用类型和基元,可独立跟踪以避免自动排序惩罚。实用槽由槽表管理,其他槽则由金豪编译器生成,跟踪记忆值和可编译函数参数。

还有以下的可选属性:

  1. 节点:与组相关联的节点,由 Applier 使用。Composer 通过 SlotTable 在内部维护这些节点。
  2. 对象键:补充标准整数键的可选键。
  3. 辅助值*:与节点相关联的辅助数据值,设置与组的其他槽无关。它用于记录 CompositionLocal 地图。

SlotTable 的实现是一个基于页面的链接表,它将组信息编码成整数,并将其打包成数组,以避免额外的分配。组内部维护了 几个指针 指向其父组、第一个子组和下一个同级组的指针,编码为指向页面的地址和页面内的索引。

该数据结构返回和使用的所有 GroupAddresses 都是稳定的。一旦分配,地址将不会改变,除非将组删除并重新添加到表中。

一个 SlotTable 可以与另一个 SlotTable 共享地址空间,这样就可以通过指针重新分配而不是内存复制,在表之间有效地移动组。

编译器对 Composable 函数的转换

我们编写界面UI时,使用的可组合项都会添加一个 @Composable 注解,被 @Composable 所注解的函数称为 可组合函数

添加该注解的函数会被真实地改变类型,改变方式与 suspend 类似,在编译期进行处理,只不过 Compose 并非语言特性,无法采用语言关键字的形式进行实现。

示例:

@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

// 编译器生成的近似结构(概念性表示)
fun Greeting(name: String, parentComposer: Composer, changed: Int) {
    val composer = parentComposer.startRestartGroup(GROUP_HASH)

    val dirty = calculateState(changed)
    
    if (stateHasChanged(dirty) || composer.skipping) {
        Text("Hello $name", composer = composer, changed = ...)
    } else {
        composer.skipToGroupEnd()
    }

    composer.endRestartGroup()?.updateScope {
        Greeting(name, changed)
    }
}

可见被 @Composable 注解后,函数增添了额外的参数,其中的 Composer 类型参数 作为运行环境 贯穿在整个可组合函数调用链中,所以可组合函数无法在普通函数中调用,因为 不包含相应的环境

可组合函数实现的起始与结尾通过 Composer.startRestartGroup()Composer.endRestartGroup() 在 Slot Table 中创建 Group,而可组合函数内部所调用的可组合函数在两个调用之间创建新的 Group,从而 在 Slot Table 内部完成视图树的构建

Composer 根据当前是否正在修改视图树而确定这些调用的实现类型。

在视图树构建完成后,若数据更新导致部分视图需要刷新,此时非刷新部分对应可组合函数的调用就不再是进行视图树的构建,而是视图树的访问,正如代码中的 Composer.skipToGroupEnd() 调用,表示在访问过程中直接跳到当前 Group 的末端。

Composer 对 Slot Table 的操作是读写分离的,只有写操作完成后才将所有写入内容更新到 Slot Table 中。

除此之外,可组合函数还将通过 传入标记参数的位运算 判断内部的可组合函数执行或跳过,这可以 避免访问无需更新的节点 ,提升执行效率。

Gap Buffer在 Compose 中的应用

Gap Buffer 是 Compose 内部用于管理 Slot Table 的核心数据结构。

Compose 编译器会将 @Composable 函数编译成上面的形式。当Compose 运行时执行这些函数,并将组合项的信息添加到一个名为 Slot Table 的数据结构中。Slot Table 的每个槽可以存储关于 Composable 的信息,如其参数、内部状态(如 remember 持有的值)以及其他组合细节。它本质上是记录组合过程的。

Gap BufferSlot Table 的底层实现。在 Slot Table 的上下文中,当 UI 由于状态变化而需要更新时(重组),Compose 需要更新 Slot Table 中的特定部分。由于移动 gap 本身是一个 O(n) 操作,这在典型的 UI 更改中并不频繁。大多数 UI 更新涉及的为小的、局部化的修改,Gap Buffer 在这些场景中非常高效。

Compose 不直接构建传统的 “view tree” 像 Android Views 那样,但它确实在 Composition 阶段 构建了一个 UI tree (node tree) 。这个 UI tree 代表了 UI 元素的层次结构。Slot Table会使用 Gap Buffer 来存储与这个 UI tree 相关的元数据和状态信息。当状态改变时,Compose 会确定哪些 @Composable 函数需要重组。即基于 Slot Table 中的信息来确定哪些部分的 UI tree 需要更新。由于 gap buffer 的高效性,Compose 可以在 Slot Table 中高效地插入、删除或移动 “组” 中的 composables 可组合项,而不需要重建整个 UI tree。这就是 Compose 实现 “smart recomposition” 的关键所在,即只更新受状态变化影响的部分,从而显著提高性能。

整体结构的工作流程如下:

1. 组合阶段 在首次运行或状态改变时,Composable 函数会被执行,生成 UI 描述树。此时,Composer 会遍历这个 UI 描述树,把相关信息写入 Slot Table。例如,可组合函数实现的起始与结尾通过 Composer.startRestartGroup() 与 Composer.endRestartGroup() 在 Slot Table 中创建 Group,以此来表示 UI 树的层次结构。

2. 差异比较阶段 当可组合项所观测的 mutableStateOf 值发生变化,导致部分组合无效。 Compose 重新执行受到影响的 @Composable 函数。即触发了重组,Composer 会将新生成的 UI 描述树与 Slot Table 里存储的上一次组合结果进行比较,找出需要更新的部分,生成一个变更列表(Change List)。

3. 更新阶段 依据变更列表,Composer 对 Slot Table 进行更新,仅修改那些发生变化的插槽,而不改变未变化的部分。这可能涉及插入新的槽来表示新的 composables,删除槽来表示移除的 composables,或者更新现有槽的数据以反映参数/状态的变化。这种局部更新的方式提升了 UI 更新的效率。

Compose的测量过程

Android View 系统,内部可能会进行多次测量,这样测量次数随嵌套层数加深会成几何式增长。而 Compose 采用了 单遍测量(Single-pass Measurement) 的机制,这大大提升了性能和可预测性。

Compose 的测量流程是自上而下、递归进行的,遵循以下三步算法:

  1. 测量子项 (Measure children)
    • 父节点(Composable)会向其所有子节点发出测量请求。
    • 这个请求会向下传递约束条件 (Constraints)。约束条件定义了子项可用的最小和最大宽度、高度。
    • 子项在这些约束条件下决定自己的尺寸。
  2. 确定自身尺寸 (Decide own size)
    • 在子项完成测量并报告其尺寸后,父节点会根据其所有子项的测量结果和自身的逻辑(例如 Row 会累加子项的宽度,Column 会累加子项的高度)来决定自己的最终尺寸。

约束条件 (Constraints) 的传递

在测量过程中,约束条件从父节点向下传递给子节点:

  • 父节点决定了子节点的最大可用空间。它会根据自身被赋予的约束条件和其布局逻辑,生成并传递新的约束条件给子节点。
  • 子节点在这些约束范围内,根据自身内容(如文本长度、图片大小等)和修饰符(Modifiers)的设置,决定自己的尺寸。
  • 叶子节点(没有子节点的 Composable,如 TextImage)直接根据收到的约束条件和自身内容来决定尺寸并报告给其父节点。

测量示例 (以 Row 包含 ImageColumn 为例)

假设我们有一个 UI 结构:

Row {
    Image(...)
    Column {
        Text("Hello")
        Text("Compose")
    }
}

测量流程如下:

  1. Row 节点被要求测量自身。
  2. Row 首先会要求其子项 ImageColumn 进行测量。
  3. 测量 Image
    • Image 是一个叶子节点,它没有子节点。
    • 它根据收到的约束条件和图片自身的尺寸来决定其最终尺寸,并报告给 Row
  4. 测量 Column
    • Column 会先要求其子项(两个 Text Composable)进行测量。
    • 测量第一个 Text 它是叶子节点,根据自身文本内容和约束条件决定尺寸,并报告给 Column
    • 测量第二个 Text 同上,决定尺寸并报告给 Column
    • Column 收到两个 Text 的尺寸后,根据其布局逻辑(通常是最大子项宽度和子项高度之和)来决定自己的尺寸,并报告给 Row
  5. Row 确定自身尺寸和放置子项:
    • Row 收到 ImageColumn 的尺寸后,根据其布局逻辑(通常是子项宽度之和和最大子项高度)来决定自己的尺寸。
    • 最后,Row 会相对于自身的位置来放置 ImageColumn

固有特性测量 (Intrinsic Measurements)

虽然 Compose 强制单遍测量,但在某些情况下,父节点可能需要在 实际测量子节点之前 ,了解子节点的一些 “固有”尺寸信息 (例如,一个 Text 在无限宽度下能达到的最小高度)。这时就用到了固有特性测量 (Intrinsic Measurements)

固有特性测量允许父节点“查询”子节点,获取其在给定约束条件下的最小或最大固有尺寸(如 minIntrinsicWidthmaxIntrinsicWidthminIntrinsicHeightmaxIntrinsicHeight)。这些查询并是真正的测量,它们不会导致子节点被实际测量两次。它们只是让父节点能够根据这些预估信息,来更好地计算在实际测量时应该传递给子节点的约束条件。

例如,当你使用 Modifier.height(IntrinsicSize.Min) 时,它会要求父级布局根据其子项的最小固有高度来确定自身高度。

Android平台的Compose显示

在 Android 平台,Compose UI 实际上运行在一个 ComposeView 内部, ComposeView 是一个特殊的 View 类(它继承自 androidx.compose.ui.platform.AbstractComposeView,而 AbstractComposeView 又是一个 ViewGroup),它充当了 Compose UI 内容的容器。它本身是一个标准的 Android View,可以像其他任何 TextView 或 LinearLayout 一样被添加到 View 层次结构中。

渲染

Compose 在 Android 上的实现最终依赖于 AndroidComposeView,且这是一个 ViewGroup,那么按原生视图渲染的角度,看一下 AndroidComposeView 对 onDraw() 与 dispatchDraw() 的实现,即可看到 Compose 渲染的原理。

@SuppressLint("ViewConstructor", "VisibleForTests")
@OptIn(ExperimentalComposeUiApi::class)
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
internal class AndroidComposeView(context: Context) :
    ViewGroup(context), Owner, ViewRootForTest, PositionCalculator {
    
    ...
    
    override fun onDraw(canvas: android.graphics.Canvas) {
    }
      
    ...
    
    override fun dispatchDraw(canvas: android.graphics.Canvas) {
        ...
        measureAndLayout()

        // we don't have to observe here because the root has a layer modifier
        // that will observe all children. The AndroidComposeView has only the
        // root, so it doesn't have to invalidate itself based on model changes.
        canvasHolder.drawInto(canvas) { root.draw(this) }

        ...
    }
    
    ...
}

可以看到,在 dispatchDraw() 中,调用了 measureAndLayout() 方法,这个方法会执行 Compose 中的测量和布局过程,最终会调用 root.draw() 方法,这个方法会执行 Compose 中的绘制过程,最终将绘制结果绘制到 Canvas 上。

Compose和View兼容使用

ComposeView的使用

在原来的Android项目中使用Compose,可以使用 ComposeView 来嵌入Compose UI。

ComposeView 的工作原理:

  • 当 ComposeView 被添加到 View 层次结构并依附到窗口时,它会启动一个 Compose 组合(Composition)。这个组合会运行你传递给 setContent 的 @Composable 函数。
  • Compose UI 的整个生命周期(组合、布局、绘制)都发生在 ComposeView 的边界内。
  • ComposeView 负责将其内部 Compose UI 的绘制结果,最终通过标准的 Android 渲染管道(HWUI 和 Skia)呈现在屏幕上,就像其他 View 一样。
  • ComposeView 也负责管理 Compose UI 的生命周期,例如在 ComposeView 被从窗口分离或宿主 Lifecycle 销毁时,正确地处置 Compose 组合。

AndroidView

在Compose中,使用传统的View,需要使用 AndroidView 来包裹,这样才能正常显示。

使用示例:

@Composable
fun MyViewInCompose() {
    var counter by remember { mutableStateOf(0) }

    Column {
        Text("Compose counter: $counter")
        AndroidView(
            factory = { context ->
                // 第一次组合时创建并返回一个传统的 Button
                android.widget.Button(context).apply {
                    text = "Click me (View)"
                    setOnClickListener {
                        counter++
                    }
                }
            },
            update = { button ->
                // 每次重组时更新 Button 的文本
                button.text = "Click me (View) - Count: $counter"
            }
        )
    }
}

可以看到 AndroidView 接受一个 factory lambda,这个 lambda 会返回你想要嵌入的 View 实例。这个 factory 只会在首次组合时执行一次。

它还接受一个 update lambda,这个 lambda 会在 Compose 重组时(当 AndroidView 的参数发生变化时)被调用,允许你更新嵌入 View 的属性,以响应 Compose 状态的变化。

AndroidViewBinding

另一个在Compose中使用传统View的方案是 AndroidViewBinding ,使用示例:

// my_layout.xml
// <LinearLayout ...>
//     <TextView android:id="@+id/my_text_view" ... />
// </LinearLayout>

@Composable
fun MyXmlLayoutInCompose() {
    var message by remember { mutableStateOf("Hello from XML!") }

    Column {
        Text("Compose Message: $message")
        AndroidViewBinding(MyLayoutBinding::inflate) {
            // 在这里可以访问 my_text_view
            myTextView.text = message
            myTextView.setOnClickListener {
                message = "XML updated!"
            }
        }
    }
}

这个组件用于嵌入整个 XML 布局文件,AndroidViewBinding 接受一个 View Binding 类的 inflate 方法引用作为参数。也提供了一个 update lambda,你可以在其中访问绑定对象并更新 XML 布局中各个 View 的属性。

AndroidView / AndroidViewBinding 的工作原理

当这些 Composable 被组合时,它们会创建一个传统的 Android View 实例(或一个 View 层次结构)。 Compose 会将这个 View 实例插入到其内部的 LayoutNode 树中,确保它能够参与 Compose 的布局和绘制过程。 尽管这些 View 是传统的 Android View,它们仍然被 Compose 的单遍测量和布局规则所管理。Compose 会向它们传递约束,并根据它们报告的尺寸进行布局。 绘制时,Compose 会在适当的时机调用嵌入 View 的 draw() 方法,并将结果集成到 Compose 自身的渲染中。

Compose和View指标对比

以下来自Google官方文档,有删减。

比较 Compose 指标和 View 指标

APK 大小

将库添加到项目中会增加其 APK 大小。

首次将 Compose 添加到 Sunflower 后,APK 大小从 2,252 KB 增加到 3,034 KB,增加了 782 KB。生成的 APK 包含混合了 View 和 Compose 的界面 build。由于向 Sunflower 添加了其他依赖项,因此出现这种增加是意料之中的。

相反,将 Sunflower 迁移为 仅使用 Compose 的应用 后,APK 大小从 3,034 KB 减少到 2,966 KB,减少了 68 KB。

之所以减少,是因为移除了未使用的 View 依赖项,例如 AppCompat 和 ConstraintLayout。

构建时间

添加 Compose 会增加应用的构建时间,因为 Compose 编译器会处理应用中的可组合项。以下结果是使用独立的 gradle-profiler 工具获得的,该工具会多次执行构建,以便为 Sunflower 的调试 build 时长获取平均构建时间。

首次将 Compose 添加到 Sunflower 时,平均构建时间从 299 毫秒增加到 399 毫秒,增加了 100 毫秒。这是因为 Compose 编译器会执行其他任务来转换项目中定义的 Compose 代码。

相反,在完成 Sunflower 向 Compose 的迁移后,平均构建时间缩短至 342 毫秒,减少了 57 毫秒。构建时间缩短可以归因于多种因素,这些因素共同缩短了构建时间,例如移除数据绑定、将使用 kapt 的依赖项迁移到 KSP,以及将多个依赖项更新到最新版本。

使用基准配置文件帮助Compose实现AOT编译

又可能仅为Google Play实现,国内商店尚未调研。

由于 Jetpack Compose 是未捆绑库,因此它无法受益于 Zygote,后者会 预加载 View 系统的界面工具包类和可绘制对象 。Jetpack Compose 1.0 利用了 release build 的配置文件安装。ProfileInstaller 可让应用指定要在安装时进行预编译 (AOT) 的关键代码。Compose 随附配置文件安装规则,可减少 Compose 应用的启动时间和卡顿。

基准配置文件是加快常见用户体验历程的绝佳方式。在应用中添加基准配置文件可以避免对包含的代码路径执行解译和即时 (JIT) 编译步骤,从而使应用首次启动时的代码执行速度即可提高约 30%。

Jetpack Compose 库包含自己的基准配置文件,当您在应用中使用 Compose 时,系统会自动获取这些优化。不过,这些优化仅会影响 Compose 库内的代码路径,因此我们建议您向应用添加基准配置文件,以涵盖 Compose 之外的代码路径。

跨平台框架Compose Multiplatform的显示渲染

最后一个章节是由Jetbrains维护的Compose跨平台版本,在各个平台上渲染方式的总结。

Compose Multiplatform (CMP) 的核心理念是共享 UI 代码,并尽可能在不同平台上提供原生级别的性能和外观。它实现这一目标的关键在于其底层的渲染机制,尤其是对 Skia 图形库的依赖。

核心原理:Skia 作为跨平台图形引擎

Compose Multiplatform 利用 Skiko (Skia for Kotlin) 这个库,将强大的 Skia 图形库引入到 Kotlin/JVM 和 Kotlin/Native 环境中。Skia 是 Google 开发的一个开源 2D 图形库,用于绘制文本、几何图形和图像。Chrome 浏览器、Android 操作系统、Flutter 等都使用 Skia 进行图形渲染。

这意味着,在大部分支持的平台上,Compose Multiplatform 的 UI 并不是直接映射到平台的原生 UI 组件(例如 Android 上的 View 或 iOS 上的 UIView),而是将 UI 描述转化为 Skia 绘制指令,然后由 Skia 在平台的画布上进行硬件加速渲染。

让我们逐一看看不同平台的渲染方式:

1. Android

  • 渲染方式: Compose Multiplatform 在 Android 平台上直接使用 Jetpack Compose 的渲染机制。Jetpack Compose 内部也会将 Composable 的 UI 描述转换为 RenderNode,然后通过 Android 的 HWUI (Hardware Accelerated UI) 渲染管道,利用 GPU (通常是 OpenGL ES) 进行绘制。
  • 与传统 View 的集成: 如前所述,Compose UI 运行在一个特殊的 ComposeView 中,这个 ComposeView 本身是一个传统的 Android View,它负责承载 Compose 内容并将其绘制到屏幕上。因此,最终 Compose 的绘制内容会通过 ComposeViewCanvas 传递给底层的 Android 渲染系统。
  • 性能: 高性能,得益于 Jetpack Compose 的单遍测量、智能重组以及 Android 硬件加速的渲染管道。

2. iOS

  • 渲染方式: 这是 Compose Multiplatform 最引人注目的突破之一。在 iOS 上,Compose Multiplatform 不使用 UIKit/SwiftUI 原生组件。它利用 Kotlin/Native 技术将 Kotlin 代码编译为原生二进制代码,并通过 Skiko 库将 Compose UI 的绘制指令转化为 Skia 调用。
  • 底层: 最终,这些 Skia 绘制指令会在一个特殊的 UIView (通常是一个 UIViewController 的内容 View) 上进行渲染。这个 UIView 充当一个画布,Compose 通过 Skia 直接在上面绘制所有 UI 元素。
  • 优势: 实现了高度一致的 UI 表现,因为绘制逻辑是共享的。同时,它也提供了与原生 iOS 行为(如滚动物理、文本编辑、辅助功能)的紧密集成,以确保应用感觉原生。
  • 互操作性: 尽管 Compose UI 自身是基于 Skia 绘制的,但它仍然提供了与 UIKit 和 SwiftUI 的互操作性,允许你在 Compose 屏幕中嵌入原生 iOS View,或将 Compose UI 嵌入到现有的 iOS 应用中。
  • 性能: 通过直接利用 GPU 和高效的 Skia 渲染,Compose Multiplatform 在 iOS 上也能实现接近原生的性能。

3. Desktop (macOS, Windows, Linux)

  • 渲染方式: 在桌面平台上,Compose Multiplatform 运行在 JVM 上。它也通过 Skiko 库,将 Compose UI 的绘制指令传递给 Skia。
  • 底层: Skia 会利用底层的图形 API(如 OpenGL、DirectX 或 Vulkan,具体取决于平台和驱动)在桌面窗口中进行硬件加速渲染。
  • 平台特性: Compose Multiplatform for Desktop 提供了桌面平台特有的功能,如窗口管理、菜单栏、系统托盘、文件选择器等,这些功能通常通过平台特定的 API 实现,并与 Compose UI 集成。
  • 性能: 高性能,充分利用桌面硬件加速,提供流畅的 UI 体验。

4. Web (Wasm / JavaScript)

  • 渲染方式: Compose Multiplatform for Web 目标是 Kotlin/Wasm(或之前的 Kotlin/JS),它将 Compose UI 编译为 WebAssembly 或 JavaScript。
  • 底层: 渲染机制主要是将整个 Compose UI 绘制到一个 HTML 的 <canvas> 元素中。同样,这得益于 Skiko 库,Skia 会被编译为 WebAssembly,并在浏览器中进行渲染。
  • 特点:
    • 全屏画布: 你的整个 Compose Multiplatform Web 应用通常被渲染为一个大的画布元素。
    • 非原生 DOM: 这意味着你的 UI 元素不是标准的 HTML DOM 元素(如 <div>, <p>, <button>)。因此,一些传统的 Web 特性(如文本选择、右键上下文菜单、SEO 优化)可能需要额外的处理或适配。
    • 性能: 依赖于浏览器的 <canvas> 性能和 Skia/Wasm 的渲染效率。对于复杂的、动态的 UI,它通常表现良好。
  • Compose HTML (补充): 值得注意的是,JetBrains 还提供了一个独立的库 Compose HTML。Compose HTML 不是 Compose Multiplatform 的一部分,它仅用于 Kotlin/JS,允许你使用 Compose 的声明式 API 来直接构建和操作 HTML DOM 元素。这意味着它能更好地与 Web 的原生特性集成,但不能共享 UI 渲染代码到移动/桌面平台。Compose Multiplatform Web 主要关注基于 Skia 的画布渲染。

总结

Compose Multiplatform 的核心渲染策略是使用 Skia 作为跨平台图形引擎。它将你的声明式 UI 描述转化为 Skia 的绘制指令,然后在每个平台的画布上进行硬件加速渲染。这使得 UI 能够保持高度的一致性,同时通过平台特定的集成层,尽可能地提供原生性能和用户体验。

平台渲染引擎/方式底层技术/API
AndroidJetpack Compose (HWUI)RenderNode, OpenGL ES
iOSSkia (通过 Skiko 库直接绘制到 UIView 画布上)Kotlin/Native, Skia, Metal/OpenGL ES
DesktopSkia (通过 Skiko 库直接绘制到桌面窗口)JVM, Skia, OpenGL/DirectX/Vulkan (取决于平台)
WebSkia (通过 Skiko 库绘制到 HTML <canvas> 元素)Kotlin/Wasm (或 JS), Skia, WebGL

【Compose】Jetpack Compose的MVI架构设计

【Compose】Jetpack Compose的MVI架构设计

本文抽离自内部的一次课题分享,普及Android平台新的声明式开发框架。

课题分享,对不熟悉 Compose 及其架构设计的老师,介绍一下 Compose 这个声明式UI框架,还有其官方推荐的MVI架构最佳实践。

声明式UI框架

‌Jetpack Compose‌是由Google在2019年推出的一个现代化的声明式UI工具包,旨在简化Android UI的开发过程。

其历史和发展可以追溯到2019年Google I/O大会上的公布,并在2021年7月29日正式发布1.0版本‌。

主要特点和优势:

  • ‌声明式编程‌:使用声明式编程范式,代码更简洁、可读性更高‌
  • Kotlin原生支持‌:完全使用Kotlin编写,与Kotlin语言特性无缝集成‌
  • 简化UI开发‌:减少了样板代码,开发者可以更专注于UI逻辑‌
  • 实时预览‌:支持实时预览功能,开发者可以即时查看UI效果‌
  • 强大的社区支持‌:拥有丰富的文档、教程和社区资源‌

目前在较新版本的Android Studio里新建项目,默认排第一位的就是Compose的UI框架的项目。

下面是一个例子,在屏幕中央显示一个文本,并且可以直接在Android Studio的右侧预览实机画面:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Box(
        modifier = modifier.fillMaxSize(1f),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
    }
}

实时预览:

同View的写法差别

在View命令式UI架构中,对视图的创建,更新等都是设置一条条的命令来进行,每个View都是维护自己的一个状态,并且对外暴露get和set接口来供外接交互。

比如TextView的setText()方法,setBackground()方法,就是更改了这个TextView实例的mText文本,背景属性等。

TextView tvTest = findViewById(R.id.test);

String userName = viewModel.getUserName();

tvTset.setText(userName);

对于Compose这种声明式的Ui架构,不会以对象的方式来提供组建,而是以可组合项的形式来使用。相对来说没有状态,其状态靠外部调用方的变量去维护。

例如显示一个可变字符串:

@Composable
fun ComposeDemo(){
    val textState = remember { mutableStateOf("Hello, Android!") }
    Text(
        text = textState.value,
        modifier = Modifier.padding(16.dp)
    )
}

Text可组合项和 textState 的设计可以理解为观察者模式,另外一个地方对textState进行修改,这个变化可以直接被Text可组合项接收到,并自动更新界面显示状态。

原理就是在编译期,Compose框架就可以分析出会受到这个 textState 变化所影响的代码块,并记录其引用,当此 state 变化时,会根据引用找到这些代码块并标记为 Invalid 。下一帧的渲染周期到来之前,触发重组,这个过程中就会执行这些标记 Invalid 的代码块,以达到更改视图内容的目的。

Compose中的一般组件

View中的页面布局,外面使用的是一个个的Layout,像LinearLayout,FrameLayout等。利用ViewGroup来包裹View,在内部按照不同的Layout的特性,给子View设置不同的属性。

例如在LinearLayout中直接设置weight属性来实现分比例布局,在ConstraintLayout里,通过设置startToStart属性来进行相对约束布局设置。

在Compoe中,最常用的布局组件一般有Column,Row,Box几种,最近也增加了ConstraintLayout的Compose版本。

Column行布局,其内部的组件会沿着竖直方从上至下排列。Row则为水平方向从左至右排列。Box则是在原位置上,一层一层地叠加排列。

例如,我要显示一个简单的列表:

@Composable
fun ComposeDemo(){
    val textState = remember { mutableStateOf("Hello, Android!") }
    Column {
        repeat(8) {
            Text(
                text = textState.value,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

Compose可以完美地使用Kotlin语音来编写,布局中可以无缝使用很多方便的api,这里就用到了repeat循环函数。我们可推算出 Text() 这个可组合函数,会被调用了8次,就会在屏幕上显示8个文本。

这在相对静态的View架构中是难以想象的。要显示一个列表视图,即使使用简化后的第三方库,比如像 BaseRecyclerViewAdapterHelper ,也至少需要创建一个 list_item 的xml布局,一个适配器 Adapter 类,有时候还需要写一个 ViewHolder 类。

使用Compose的列表预览效果如下:

视图结构

View视图结构

经典框架不做多余赘述。

Compose视图结构

Composable可组合项在Android平台的实现,是利用 ViewGroup 来显示的,并且最终也是使用到Android的原生控件来显示内容。

通过打印堆栈可以看出,在页面布局的创建阶段,使用到了AndroidComposeView这个类。

ComposeView其实就是一个ViewGroup,它继承自AbstractComposeView,负责对Android平台的Activity的窗口进行适配。取而代之的是AndroidComposeView这个ViewGroup,Composable可组合项的内容就在这里面来渲染显示。

同View架构类似,Compose也是通过一个树形结构SlotTable来管理内部节点LayoutNode的。

View架构通过解析xml文件,得到页面的结构,再对内部组件进行测量布局绘制。Compose架构的第一步被替换为组合阶段,一个个的Composable可组合项,按照写好的声明式代码,添加到SlotTable中。

然后再进行测量放置,绘制。

固有特性测量

谈到Compose架构,这个是绕不过去的话题,固有特性测量的机制,也是为什么Compose可以采用疯狂嵌套而不会指数级影响测量时间的原因。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <View
        android:layout_width="match_parent"
        android:layout_height="48dp" />

</LinearLayout>

这个内部的View并没有给定宽度,而是对齐父控件最大宽度。而父控件的宽度又是 wrap_content 的配置。

这时候, LinearLayout 就会先以 0 为强制宽度测量一下这个子 View,并正常地测量剩下的其他子 View,然后再用其他子 View 里最宽的那个的宽度,二次测量这个 match_parent 的子 View,最终得出它的尺寸,并同时把这个宽度作为自己最终的宽度。

有些场景甚至会有三次及以上的测量。如果是嵌套场景,层级每深一级,测量次数就会以指数级增长。

固有特性测量实际是固有尺寸测量,在父可组合项对内部的子可组合项正式测量之前,会先遍历一遍内部所有的组件,得出他们的固有尺寸,就是显示内容所需的最小和最大尺寸究竟是多少,在正式测量没有给定具体宽高时,就使用这个尺寸来作为最终测量的数据。不会像View架构一样,首次遍历测量完成之后还要再次测量一遍。

使用固有测量获得的参数:

@Composable
fun IntrinsicTest() {
    Column(
        modifier = Modifier
            .height(IntrinsicSize.Min)
            .background(Color.Red)
    ) {
        Text(text = "Hello Test!", modifier = Modifier.fillMaxSize(1f))
    }
}

事件分发机制

Jetpack Compose 和传统的 View 架构在触摸事件分发方面有一些不同。

在 View 架构中,事件分发遵循”责任链”模式,从顶层 ViewGroup 开始,自上而下传递。

各级使用 dispatchTouchEvent()、onInterceptTouchEvent() 和 onTouchEvent() 方法来处理和分发事件。

在 Jetpack Compose 中,使用 Modifier 修饰符来处理触摸事件,没有一个明确的分发链。在Android平台,由于是基于ViewGroup来承载,事件机制经过测试也是类似的自上而下的分发。通过 Modifier.pointerInput() 或 Modifier.clickable() 等修饰符来添加触摸事件监听。

@Composable
fun ComposeDemo() {
    val textState = remember { mutableStateOf("Hello, Compose!") }
    Text(
        text = textState.value,
        fontSize = 70.sp,
        modifier = Modifier
            .padding(20.dp)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        textState.value = "Tap detected"
                    },
                    onDoubleTap = {
                        textState.value = "Double tap detected"
                    },
                    onLongPress = {
                        textState.value = "Long press detected"
                    },
                    onTap = {
                        textState.value = "Tap detected"
                    }
                )
            }
    )
}

双击后的变化:

手势判断的简化

在View架构里想要监听手势,比如要自定义一个View,重写其onTouchEvent,对MOVE事件里的滑动方向进行计算后判断,或者将touch事件传递给GestureDetector对象。

Compose里的手势也有相应的简化,举例一个对手指左滑的监听,同样在pointerInput函数中,需要使用 detectHorizontalDragGestures 函数,根据 dragAmount参数的正负来判断手势方向,然后再进行对应的处理。

@Composable
fun ComposeDemo() {
    val textState = remember { mutableStateOf("Hello, Compose!") }
    Text(
        text = textState.value,
        fontSize = 70.sp,
        modifier = Modifier
            .padding(20.dp)
            .pointerInput(Unit) {
                detectHorizontalDragGestures { _, dragAmount ->
                    if (dragAmount < 0) {
                        // 左滑逻辑
                       textState.value = "Left swipe detected"
                    }
                }
            }
    )
}

同View架构性能对比

整体来看,Compose的性能表现依然比View要差,毕竟View框架已经经过了多年的迭代和优化。主要体现在初始化时长,滑动流畅度还有动画等方面。

初始化显示较慢的原因之一,是Jetpack Compose为了实现了compose和Android版本之间的向后兼容,设计为了一个单独的库,并不包含在Android操作系统中。因此,库中的代码应在首次运行时使用即时(JIT)编译。这使得它在本质上比基于Android View的代码慢,后者是使用的提前编译(AOT)策略,并且二进制文件存储在设备上的操作系统中。

还有过度重组导致的性能问题,在Compose中,当可组合项观测的状态发生变化时,会触发其重组,进一步会使所有相关的可组合项进行重绘。如果状态发生变化的频率非常高,那么就会导致UI频繁地重绘,从而影响性能。

另外Compose的动画实现逻辑,同样基于重组机制,相较于View也更加复杂,性能上会差一些。

优化方案

  1. 尽可能缩小重组范围,遵循Google官方的最小重组范围实践。
  2. 避免过度使用状态,使用状态时,尽量使用不可变的状态。
  3. 使用 mutableStateOf 函数来创建可观察的状态。
  4. 使用 remember 函数来缓存可组合项的状态,重组前后可以保存状态。
  5. 使用LazyColumn和LazyRow时,使用Key来标记每个项,避免扩大重组范围。

协程基础使用

协程是Kotlin为异步任务设计的一个解决方案。在Android平台,其内部依然是基于Handler和线程池。

举例

最常见的使用方式,在 ViewModel 或者 Controller 里写业务逻辑,在 Activity 里调用,这样就可以在IO线程执行网络请求,拿到结果后自动切换到主线程更新UI。

// viewModel或者controller里获取数据逻辑
// 使用suspend限制在协程里使用;withContext切换调度器,指定在IO线程执行下面的任务
suspend fun getUserName() = withContext(Dispatchers.IO) {
    debugLog("thread name: ${Thread.currentThread().name}")
    ServiceCreator.createService<UserService>()
        .getUserName("2cd1e3c5ee3cda5a")
        .execute()
        .body()
}

// Activity调用处
override fun onCreate(savedInstanceState: Bundle?){
    // 最直接的声明方法,在主线程执行下面的逻辑
    lifeCycleScope.launch {
        // 相当于get这一半是在IO线程执行
        //拿到结果后的变量赋值这一半操作由调度器自动切换到主线程来执行了
        val userName = mViewModel.getUserName()
        infoLog("userName: $userName")
        binding.tvUserName.text = userName
    }
}

基础概念

四个主要概念:

  • suspend function。即挂起函数,delay() 就是协程库提供的一个用于实现非阻塞式延时的挂起函数
  • CoroutineScope。即协程作用域,GlobalScope 是 CoroutineScope 的一个实现类,用于指定协程的作用范围,可用于管理多个协程的生命周期,所有协程都需要通过 CoroutineScope 来启动
  • CoroutineContext。即协程上下文,包含多种类型的配置参数。Dispatchers.IO 就是 CoroutineContext 这个抽象概念的一种实现,用于指定协程的运行载体,即用于指定协程要运行在哪类线程上
  • CoroutineBuilder。即协程构建器,协程在 CoroutineScope 的上下文中通过 launch、async 等协程构建器来进行声明并启动。launch、async 均被声明为 CoroutineScope 的扩展方法

挂起函数

内部有耗时逻辑的函数,都可以标记位suspend函数,挂起函数只能在另一个suspend函数或者协程中调用。这是实现协程非阻塞特性的关键。

协程作用域

协程作用域是协程的容器,用于管理协程的生命周期。

  • 顶级作用域:GlobalScope–>全局范围,不会自动结束执行,无法取消。
  • 协同作用域:coroutineScope –>抛出异常会取消父协程
  • 主从作用域:supervisorScope –>抛出异常,往下传递,不会取消父协程

三种作用域真正常用的其实只有主从作用域,谁也不想让自己写的协程挂了导致整个app崩溃。常用的主从作用域我们也肯定接触过:

  • MainScope:主线程的作用域,全局范围,可以取消。
  • lifecycleScope: 生命周期范围,用于activity等有生命周期的组件,在Desroyed的时候会自动结束。
  • viewModelScope:ViewModel范围,用于ViewModel中,在ViewModel被回收时会自动结束。

在设置异常处理时,可以使用 CoroutineExceptionHandler ,作用类似Java的 UncaughtExceptionHandler ,来捕获协程中未捕获的异常。可以兜住其内部子协程所抛出的异常,防止整个app崩溃。

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    // 处理异常
}

lifecycleScope.launch(exceptionHandler) {
    // 协程代码

}

协程上下文

协程上下文是协程的配置参数,用于指定协程的运行载体。即用于指定协程要运行在哪类线程上。

主要使用以下三种:

  • Dispatchers.IO:用于执行IO密集型任务,如网络请求、文件读写等。
  • Dispatchers.Main:用于执行主线程任务,如UI更新、动画等。
  • Dispatchers.Default:用于执行CPU密集型任务,如计算、数据处理等。

协程构建器

协程构建器是协程的声明方式,用于声明并启动协程。常用以下两种

  • launch:用于启动一个新的协程,返回一个 Job 对象,可以通过 Job 对象来控制协程的生命周期。
  • async:用于启动一个新的协程,返回一个 Deferred 对象,可以通过 Deferred 对象来获取协程的返回值。

async方法启动举例:

// 耗时函数
suspend fun returnString(): String {
    delay(3000L)
    return "Hello, Compose!"
}

// 启动协程等待结果
val result = async { returnString() }.await()
println(result)

注意 await() 函数也为挂起函数,在结果返回之前,不会往下执行剩余代码。

MVI架构

进入正题,MVI架构(Model-View-Intent),是一种用于构建用户界面的架构模式,它将应用程序的逻辑分为三个部分:Model、View和Intent。

Model:表示应用程序的数据和状态。它是应用程序的核心,负责管理应用程序的业务逻辑和数据。 View:表示应用程序的用户界面。它负责将Model中的数据呈现给用户,并接收用户的输入。 Intent:表示用户的操作或事件。它是View和Model之间的桥梁,负责将用户的操作转换为Model可以理解的格式。

核心思想是保证唯一可信的单向数据流来更新UI,用户事件自上而下,数据自下而上。

举例

以网络请求一张图片为例,最简单的状态表达,可以设置一个加载态,一个成功后的展示态,一个失败提示。

首先定义状态数据传输的数据类:

data class ImageState(
    val loading: Boolean = false,
    val imageUrl: String? = null,
    val error: String? = null  
)

在数据层设置网络接口,发起网络请求,网络框架选用 JetbrainsKtor

class KtorClient {

    companion object {
        const val TAG = "KtorClient"
    }

    private val client = HttpClient(CIO) {
        install(Logging) {
            level = LogLevel.ALL
        }
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
            })
        }
    }

    suspend fun getOneCatImage() = withContext(Dispatchers.IO) {
        client.get("https://api.thecatapi.com/v1/images/search").body<List<PicKtorItem>>()
    }
}

在ViewModel层,注入 KtorClient ,维护页面加载状态,发起网络请求,获取数据,然后更新状态。

class MainViewModel(private val ktorClient: KtorClient) : ViewModel() {

    private val _imageState = MutableStateFlow(ImageState())
    val imageState: StateFlow<ImageState> = _imageState.asStateFlow()

    fun loadCatPicture() {
        viewModelScope.launch {
            _imageState.value = _imageState.value.copy(loading = true) 
            try {
                val catPictures = ktorClient.getOneCatImage()
                if (catPictures.isNotEmpty()) {
                    _imageState.value = _imageState.value.copy(
                        loading = false,
                        imageUrl = catPictures[0].url
                    ) 
                } 
            } catch (e: Exception) {
                _imageState.value = _imageState.value.copy(
                    loading = false,
                    error = e.message
                ) 
            }
        } 
    }
}

在Activity里,对viewmodel维护的状态的消费与界面展示:

class ComposeTestActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NetDataDemoTheme {
                val mainStateHolder: MainStateHolder by viewModel()
                val imageState = mainStateHolder.imageStateFlow.collectAsState()

                LaunchedEffect(Unit) {
                    mainStateHolder.loadCatPicture()
                }
                
                ImageTest(
                    loading = imageState.value.loading,
                    imageUrl = imageState.value.imageUrl,
                    error = imageState.value.error
                )
            }
        }
    }
}

@Composable
fun ImageTest(loading: Boolean, imageUrl: String?, error: String?) {
    Column {
        Text(text = "loading: $loading, imageUrl: $imageUrl, error: $error")
        AsyncImage(
            model = imageUrl,
            contentDescription = "cat pic",
            modifier = Modifier
                .fillMaxWidth(1f)
                .weight(1f)
        )
    }
}

运行效果:

可以看到各项数据被成功取到并展示,进一步设计界面还可以根据loading, imageUrl, error的不同状态,展示不同的界面。比如loading时加入加载态蒙层,失败后加入toast提示。

链路传递原理

数据类data class

在Kotlin中,数据类(data class)自动为我们提供了一个copy方法。这个方法的主要作用是 创建一个当前对象的副本 ,并且可以 选择性地修改 其中的某些属性。

在上面的例子中,ImageState是一个数据类,使用copy方法可以方便地更新_imageState的状态,而不需要手动创建一个新的ImageState对象并复制所有的属性。

MutableStateFlow

在Kotlin中,StateFlow 是一种响应式数据流,它会保存一个当前值,并且可以在这个值发生变化时通知所有的订阅者。MutableStateFlow 则是可以支持允许你通过 value 属性来修改这个当前值,从而触发更新通知。

在上面的示例中,_imageState 是一个 MutableStateFlow 类型的变量,它被初始化为 ImageState() 的一个实例。这意味着 _imageState 会持有一个 ImageState 类型的对象,并且可以在这个对象的状态发生变化时通知所有订阅者。

在Java语境中,StateFlow的作用甚至用法,都和LiveData几乎完全一致。

asStateFlow

上面已经维护了一个MutableStateFlow的变量,为了防止使用方更改,需要将其转换成一个只读类型的StateFlow。所以下面紧随其后定义了一个 imageStateFlow ,使用 asStateFlow() 方法将其转换成一个只读的StateFlow。

collectAsState

以上在ViewModel里的两个步骤,在View框架的也是可以通用的。使用collect收集Flow数据再操作View更新属性显示界面。例如:

mainStateHolder.imageStateFlow.collect {
    if(it.error!=null){
        Toast.makeText(thisComposeTestActivity, it.error, Toast.LENGTH_SHORT).show()
    }
}

在Jetpack Compose中,对这个Flow使用 collectAsState() ,它主要用于将一个 Flow 类型的数据流转换为一个可观察的 State 对象。

在上面获取图片url的示例中,collectAsState 函数的作用是将 MainStateHolder 类中的 imageStateFlow 这个 Flow 类型的数据流转换为一个 State 对象,这个 State 对象可以在Compose的UI中使用,并且当 imageStateFlow 中的数据发生变化时,Compose会自动重新组合UI以反映这些变化。

具体来说,collectAsState 函数做了以下几件事情:

  • 订阅数据流:它会订阅 imageStateFlow 这个 Flow,开始接收其中的数据更新。
  • 保存最新状态:每当 imageStateFlow 发出一个新的值时,collectAsState 会将这个新值保存到一个 State 对象中。
  • 触发UI更新:由于Compose是响应式的,当 State 对象的值发生变化时,Compose会自动重新组合依赖于这个 State 对象的UI组件,从而实现UI的自动更新。

以上就是简要的关于Jetpack Compose的MVI架构的分享,实际使用中,最好配合依赖注入,模块化等方案进一步解耦,使代码架构更清晰易于维护。

【Compose】Compose自定义视图

【Compose】Compose自定义视图

本文介绍了Jetpack Compose里如何自定义视图

View体系回顾

在View体系中,自定义view的流程已经比较熟悉了。主要有以下几个情景:

  • 第一种,继承于现成的View,比如TextView,ImageView,一般都是自己初始化Paint类,在构造器里初始化,在onDraw里画到画布上。
  • 第二种,直接继承自View,需要考虑wrapcontent和padding属性的特殊配置,因为分析源码发现其ATMOST和EXACTLY属性没有区分,所以要实现wrapcontent就需要在onMeasure里自行判断。
  • 第三种是继承自现成的ViewGroup,像LinearLayout,只需要在布局文件里放置想要的子控件,再到构造方法里初始化,配置即可,一般使用于可大量重用的格式化组件。
  • 第四种是直接继承自ViewGroup,需要自行实现onMeasure和onLayout方法,来达到自己想要的组件效果。这种需要特殊注意子控件的处理。

【Android进阶】Android自定义View

Jetpack Compose自定义视图

Jetpack Compose中对于UI的写法更加简单,也是有两种实现方式,一个是基于现有的Composable函数来组合,二是自己使用Canvas来绘制。

基于现有的Composable函数来组合

Compose的UI是基于函数来实现的,所以我们可以直接使用现有的Composable函数来组合成我们想要的UI。

举例,使用LazyColumn和Text,制作一个上下滑动的时间选择器:

@Composable
fun TimePicker() {
    val hours = (0..23).toList()
    // 扩展列表,前后各添加 2 个空项
    val extendedHours = List(2) { -1 } + hours + List(2) { -1 }
    val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = extendedHours.size / 2) // 初始默认滚动到中间
    val selectedHour by remember { derivedStateOf { calculateCenterItem(lazyListState, extendedHours) } }

    LaunchedEffect(lazyListState) {
        snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
            .map { calculateCenterItem(lazyListState, extendedHours) }
            .distinctUntilChanged()
            .collect { }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Select Hour",
            fontSize = 24.sp,
            fontWeight = FontWeight.Bold,
            modifier = Modifier.padding(bottom = 16.dp)
        )

        Box(
            modifier = Modifier
                .background(Color.LightGray, RoundedCornerShape(8.dp))
                .padding(8.dp)
        ) {
            LazyColumn(
                state = lazyListState,
                modifier = Modifier
                    .height(200.dp)
            ) {
                items(extendedHours.size) { index ->
                    val hour = extendedHours[index]
                    if (hour != -1) { // 只显示有效的小时项
                        HourItem(
                            hour = hour,
                            isSelected = hour == selectedHour,
                            selectedHour = selectedHour,
                            onClick = { }
                        )
                    } else {
                        Spacer(
                            modifier = Modifier
                                .fillMaxWidth()
                                .height(40.dp) // 空项占位高度
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun HourItem(hour: Int, isSelected: Boolean, selectedHour: Int, onClick: () -> Unit) {
    // 计算当前项与选中项的差值
    val difference = kotlin.math.abs(hour - selectedHour)
    // 根据差值动态计算字体大小
    val fontSize = when (difference) {
        0 -> 30.sp // 选中项最大
        1 -> 24.sp // 靠近选中项
        2 -> 20.sp // 次靠近选中项
        else -> 16.sp // 其他项
    }

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .clickable { onClick() }
            .padding(vertical = 8.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (hour < 10) "0$hour" else hour.toString(),
            fontSize = fontSize,
            color = if (isSelected) Color.Blue else Color.Black,
            fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
            textAlign = TextAlign.Center
        )
    }
}

// 计算当前位于屏幕中部的项
private fun calculateCenterItem(lazyListState: LazyListState, extendedHours: List<Int>): Int {
    val layoutInfo = lazyListState.layoutInfo
    val visibleItems = layoutInfo.visibleItemsInfo
    if (visibleItems.isEmpty()) return -1

    val centerY = layoutInfo.viewportStartOffset + layoutInfo.viewportSize.height / 2
    val centerItem = visibleItems.find {
        it.offset <= centerY && it.offset + it.size >= centerY
    } ?: visibleItems.first()

    return extendedHours[centerItem.index]
}

运行截图:

基于Canvas来绘制

Compose中使用Canvas来绘制UI,我们需要使用 Canvas(modifier = Modifier) 来调用Canvas,看看 Canvas 在Compose框架里面的实现:

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

可以看到需要传入一个DrawScope的参数,并传入了 modifier.drawBehind(onDraw) 中,用于在Composable的背景上绘制自定义图形。在这个作用域内,我们可以使用 drawXXX 方法来绘制UI。

以下是 DrawScope 中一些常用的方法:

drawLine: 绘制一条直线。
drawRect: 绘制一个矩形。
drawCircle: 绘制一个圆形。
drawOval: 绘制一个椭圆形。
drawArc: 绘制一个弧形。
drawPath: 绘制一个自定义路径。
drawImage: 绘制一个图像。
drawText: 绘制文本。
drawIntoCanvas: 在画布上执行自定义绘制操作。
clipRect: 裁剪画布到一个矩形区域。
clipPath: 裁剪画布到一个自定义路径。
rotate: 旋转画布。
scale: 缩放画布。
translate: 平移画布。
save: 保存当前画布状态。
restore: 恢复之前保存的画布状态。

采用上面同样的例子,绘制一个时钟表盘:


@Composable
fun Clock() {
    var time= remember { mutableStateOf(Calendar.getInstance()) }

    LaunchedEffect(Unit) {
        while (true) {
            time.value = Calendar.getInstance()
            delay(1000) // 每秒更新一次
        }
    }

    Canvas(modifier = Modifier.fillMaxSize()) {
        val center = Offset(size.width / 2, size.height / 2)
        val radius = size.minDimension / 2 - 20.dp.toPx()

        // 绘制表盘
        drawCircle(color = Color.LightGray, radius = radius, style = Stroke(4.dp.toPx()))

        // 绘制刻度
        for (i in 0..59) {
            val angle = i * 6f
            val length = if (i % 5 == 0) 20.dp.toPx() else 10.dp.toPx()
            val start = Offset(
                center.x + (radius - length) * cos(Math.toRadians(angle.toDouble())).toFloat(),
                center.y + (radius - length) * sin(Math.toRadians(angle.toDouble())).toFloat()
            )
            val end = Offset(
                center.x + radius * cos(Math.toRadians(angle.toDouble())).toFloat(),
                center.y + radius * sin(Math.toRadians(angle.toDouble())).toFloat()
            )
            drawLine(color = Color.Black, start = start, end = end, strokeWidth = 2.dp.toPx())
        }

        // 绘制时针
        val hour = time.value.get(Calendar.HOUR)
        val minute = time.value.get(Calendar.MINUTE)
        val hourAngle = (hour * 30 + minute * 0.5).toFloat()
        rotate(hourAngle) {
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(center.x, center.y - radius * 0.5f),
                strokeWidth = 8.dp.toPx()
            )
        }

        // 绘制分针
        val minuteAngle = (minute * 6).toFloat()
        rotate(minuteAngle) {
            drawLine(
                color = Color.Black,
                start = center,
                end = Offset(center.x, center.y - radius * 0.7f),
                strokeWidth = 6.dp.toPx()
            )
        }

        // 绘制秒针
        val second = time.value.get(Calendar.SECOND)
        val secondAngle = (second * 6).toFloat()
        rotate(secondAngle) {
            drawLine(
                color = Color.Red,
                start = center,
                end = Offset(center.x, center.y - radius * 0.9f),
                strokeWidth = 4.dp.toPx()
            )
        }

        // 绘制中心圆
        drawCircle(color = Color.Black, radius = 10.dp.toPx())
    }
}

运行截图:

还有一个类似的方法是 Modifier.drawWithContent ,它也可以在Composable的内容上绘制自定义图形,区别可以看成这个方法是在Composable的内容上绘制,而不是在Composable的背景上绘制。

/**
 * Creates a [DrawModifier] that allows the developer to draw before or after the layout's
 * contents. It also allows the modifier to adjust the layout's canvas.
 */
fun Modifier.drawWithContent(
    onDraw: ContentDrawScope.() -> Unit
): Modifier = this then DrawWithContentElement(onDraw)

里面这个 ContentDrawScope 是继承自 DrawScope 的。

/**
 * Receiver scope for drawing content into a layout, where the content can
 * be drawn between other canvas operations. If [drawContent] is not called,
 * the contents of the layout will not be drawn.
 */
@JvmDefaultWithCompatibility
interface ContentDrawScope : DrawScope {
    /**
     * Causes child drawing operations to run during the `onPaint` lambda.
     */
    fun drawContent()
}

使用举例:

@Composable
fun DrawWithContentExample() {
    Box(
        modifier = Modifier
           .size(200.dp)
           .background(Color.LightGray)
           .drawWithContent {
               drawContent() // 绘制原始内容
               drawCircle(
                   color = Color.Blue,
                   radius = 50f,
                   center = Offset(size.width / 2, size.height / 2)  
               )    
           } 
    )  
}

以上就是对Compose自定义视图两个主要方法的介绍。

【Compose】Compose中的动画

【Compose】Compose中的动画

本文介绍了Jetpack Compose中一系列动画API的使用

出现消失动画

AnimatedVisibility 是Jetpack Compose中一个非常有用的动画API,它可以让我们在Composable函数中实现元素的出现和消失动画。它的使用非常简单,只需要在需要添加动画效果的元素上使用AnimatedVisibility即可。

AnimatedVisibility 的方法签名如下:

@Composable
fun ColumnScope.AnimatedVisibility(
    visible: Boolean,
    modifier: Modifier = Modifier,
    enter: EnterTransition = fadeIn() + expandVertically(),
    exit: ExitTransition = fadeOut() + shrinkVertically(),
    label: String = "AnimatedVisibility",
    content: @Composable AnimatedVisibilityScope.() -> Unit
) {
    val transition = updateTransition(visible, label)
    AnimatedVisibilityImpl(transition, { it }, modifier, enter, exit, content = content)
}

visible是一个布尔值,用于控制元素的出现和消失。 modifier是一个Modifier对象,用于修改元素的外观。 enter是一个EnterTransition对象,用于控制元素出现时的动画效果。 exit是一个ExitTransition对象,用于控制元素消失时的动画效果。 label是一个字符串,用于标识元素。 content是一个Composable函数,用于定义元素的内容。

举例,设置两个text,上面那个text点击后开始退场,2s后重新出现。

源码如下:

@Composable
fun AnimatedVisibilityDemo() {
    Column(modifier = Modifier.width(IntrinsicSize.Max)) {

        val scope = rememberCoroutineScope()

        val isShow = remember { mutableStateOf(true) }

        AnimatedVisibility(visible = isShow.value) {
            Text(
                text = "How are you?",
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .background(Color.Red)
                    .clip(RoundedCornerShape(10))
                    .clickable {
                        isShow.value = false // 点击后消失
                        scope.launch {
                            delay(2000)
                            isShow.value = true // 2秒后重新出现
                        }
                    }
                    .padding(20.dp)
            )
        }

        Text(
            text = "I'm fine, thank you! And you?",
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .background(Color.Blue)
                .clip(RoundedCornerShape(10))
                .padding(20.dp)
        )
    }
}

可以看到 Compose 为了简化使用,已经预设了进出场的动画,进场是fadeIn() + expandVertically(),出场是fadeOut() + shrinkVertically()。如果需要自定义动画效果,可以手动声明并传入 AnimatedVisibility 的enter和exit参数。enter参数是一个EnterTransition对象,用于控制元素出现时的动画效果。

可以像官方例程里那样,自定义这两个参数传入:

AnimatedVisibility(visible = isShow.value,
            enter = slideInVertically {
                // Slide in from 40 dp from the top.
                with(density) { -40.dp.roundToPx() }
            } + expandVertically(
                // Expand from the top.
                expandFrom = Alignment.Top
            ) + fadeIn(
                // Fade in with the initial alpha of 0.3f.
                initialAlpha = 0.3f
            ),
            exit = slideOutVertically() + shrinkVertically() + fadeOut()) {

}

进场参数自定义,设定以top为基准,扩张的时候从上往下,滑动的时候从上往下。

更改动画时长

AnimatedVisibility 中的动画时长可以通过修改 enter 和 exit 中的动画参数来控制。例如,我们可以使用 fadeIn() 动画,并将持续时间设置为 2 秒:

AnimatedVisibility(
    visible = isShow.value,
    enter = fadeIn(
        animationSpec = tween(2000)
    ),
    exit = fadeOut(
        animationSpec = tween(2000)
    )
)

使用MutableTransitionState控制动画

AnimatedVisibility 还可以使用 MutableTransitionState 来控制动画。MutableTransitionState 是一个可变的 TransitionState,它可以在运行时更改其目标状态。通常可以用作在一开始就触发动画,还可以实时地 观察到动画状态

@Composable
fun AnimatedVisibilityDemo() {
    Column(modifier = Modifier.width(IntrinsicSize.Max)) {

        val scope = rememberCoroutineScope()

        val state = remember {
            MutableTransitionState(false).apply {
                // Start the animation immediately.
                targetState = true
            }
        }

        AnimatedVisibility(visibleState = state) {
            Text(
                text = "How are you?",
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .background(Color.Red)
                    .clip(RoundedCornerShape(10))
                    .clickable {
                        state.targetState = !state.targetState
                        scope.launch {
                            delay(2000)
                            state.targetState = !state.targetState
                        }
                    }
                    .padding(20.dp)
            )
        }

        Text(
            text = when {
                state.isIdle && state.currentState -> "Visible"
                !state.isIdle && state.currentState -> "Disappearing"
                state.isIdle && !state.currentState -> "Invisible"
                else -> "Appearing"
            },
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .background(Color.Blue)
                .clip(RoundedCornerShape(10))
                .padding(20.dp)
        )
    }
}

观察效果:

给子项添加动画

有时候我们需要给子项单独添加动画,以获得更灵活的效果。AnimatedVisibility 里面的子项可以使用 animateEnterExit 修饰符,来添加更精细的动画效果。

    AnimatedVisibility(
        visible = isShow.value,
        enter = EnterTransition.None,
        exit = ExitTransition.None
    ) {
        Text(
            text = "How are you?",
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .background(Color.Red)
                .clip(RoundedCornerShape(10))
                .clickable {
                    isShow.value = false // 点击后消失
                    scope.launch {
                        delay(2000)
                        isShow.value = true // 2秒后重新出现
                    }
                }
                .padding(20.dp)
        )
    }

需要注意的是AnimatedVisibility和其子项设置的动画效果是叠加的,我们如果不想要外面父组合项自带的动画效果,可以显示的传入EnterTransition.Noneh和ExitTransition.None。

添加自定义动画

通过 AnimatedVisibility 的内容 lambda 内的 transition 属性访问底层 Transition 实例。添加到 Transition 实例的所有动画状态都将与 AnimatedVisibility 的进入和退出动画同时运行。AnimatedVisibility 会等到 Transition 中的所有动画都完成后再移除其内容。对于独立于 Transition(例如使用 animate*AsState)创建的退出动画,AnimatedVisibility 将无法解释这些动画,因此可能会在完成之前移除内容可组合项。

例如在原有进场动画的基础上,添加一个颜色的动画:

 AnimatedVisibility(
) {
     val background by transition.animateColor(label = "color") { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
        Text(
            text = "How are you?",
            color = Color.White,
            modifier = Modifier
               .fillMaxWidth(1f)
               .background(background)
               .clip(RoundedCornerShape(10))
        )
    }

使用 Crossfade 在两个布局之间添加动画效果

Crossfade 可使用淡入淡出动画在两个布局之间添加动画效果。通过切换传递给 current 参数的值,可以使用淡入淡出动画来切换内容。

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage, label = "cross fade") { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

使用 AnimatedContent 根据状态切换内容

AnimatedContent 可以观测状态,并在状态更改时添加动画效果。

基础使用:

Row {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(
        targetState = count,
        label = "animated content"
    ) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

其方法签名中,可以传入一个 animationSpec 参数,用于控制动画效果。默认的效果还是渐入渐出。

    transitionSpec: AnimatedContentTransitionScope<S>.() -> ContentTransform = {
        (fadeIn(animationSpec = tween(220, delayMillis = 90)) +
            scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)))
            .togetherWith(fadeOut(animationSpec = tween(90)))
    },

自定义动画效果:

transitionSpec = {
    // Compare the incoming number with the previous number.
    if (targetState > initialState) {
        // If the target number is larger, it slides up and fades in
        // while the initial (smaller) number slides up and fades out.
        slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
    } else {
        // If the target number is smaller, it slides down and fades in
        // while the initial number slides down and fades out.
        slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
    }.using(
        // Disable clipping since the faded slide-in/out should
        // be displayed out of bounds.
        SizeTransform(clip = false)
    )
}

上面运行之后可以看到,当目标值大于初始值时,会有一个向上滑动加上渐出的动画效果。

尺寸改变动画

使用 animateContentSize 修饰符可以让Composable函数在大小发生变化时进行动画效果。注意需要添加在任何尺寸修饰符之前,以防止动画效果被错误地应用。

@Composable
fun AnimatedVisibilityDemo() {
    Column(modifier = Modifier.width(IntrinsicSize.Max)) {

        val scope = rememberCoroutineScope()

        var state by remember { mutableStateOf(false) }

        Text(
            text = "How are you?",
            color = Color.White,
            modifier = Modifier
                .animateContentSize()
                .fillMaxWidth(1f)
                .height(if (state) 160.dp else 80.dp)
                .background(Color.Red)
                .clip(RoundedCornerShape(10))
                .clickable {
                    state = true
                    scope.launch {
                        delay(2000)
                        state = false
                    }
                }
                .padding(20.dp)
        )

        Text(
            text = "I am fine.",
            color = Color.White,
            modifier = Modifier
                .fillMaxWidth(1f)
                .background(Color.Blue)
                .clip(RoundedCornerShape(10))
                .padding(20.dp)
        )
    }
}

列表项动画

为列表的每个项添加动画效果,使用 animateItem 修饰符。

@Composable
fun ListItemAnimateDemo() {
    val listState = remember { mutableStateListOf<ListItem>() }

    LaunchedEffect(Unit) {
        repeat(10) {
            listState.add(ListItem(it, "Item $it"))
            delay(1000)
        }
        delay(1000)
        listState.removeAt(5)
    }

    LazyColumn(modifier = Modifier.fillMaxSize()) {
        items(listState, key = { it.id }) { item ->
            Text(
                text = item.title,
                color = Color.White,
                modifier = Modifier
                    .fillMaxWidth(1f)
                    .animateItem()
                    .background(Color.Blue)
                    .clip(RoundedCornerShape(10))
            )
        }
    }
}

data class ListItem(
    val id: Int,
    val title: String,
)

基于Value的动画

animate*AsState系列函数

这个系列函数的用法类似ValueAnimator,通过定义两个端点目标值,当使用标志位触发两端的值切换时,会自动进行动画效果。

例如改变Box的高度

这里的效果和上面提到的 animateContentSize() 是一样的。

@Composable
fun ValueAnimation() {
    var enable by remember {
        mutableStateOf(false)
    }
    val heightValueAnim by animateIntAsState(if (enable) 400 else 200, label = "box height anim")
    Box(
        modifier = Modifier
            .width(200.dp)
            .height(heightValueAnim.dp)
            .clickable {
                enable = !enable
            }
            .background(Color.Red),
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Hello")
    }
}

heightValueAnim 设定了400和200两个端点值,通过 enable 标志位来切换。

Box 可组合项被点击后,更改 enable ,就会自动触发动画效果。

注意,无需创建任何动画类的实例,也不必处理中断。在后台,系统会在调用点创建并记录一个动画对象(即 Animatable 实例),并将第一个目标值设为初始值。此后,只要您为此可组合项提供不同的目标值,系统就会自动向该值播放动画。如果已有动画在播放,系统将 从其当前值(和速度)开始向目标值 播放动画。在播放动画期间,这个可组合项会重组,并且每帧都会返回一个已更新的动画值。

再例如对背景颜色的值添加动画

触发之后,会按照色阶上的值平滑过渡。

@Composable
fun ValueAnimation() {
    var animateBackgroundColor by remember {
        mutableStateOf(false)
    }
    val animatedColor by animateColorAsState(
        if (animateBackgroundColor) Color.Green else Color.Blue,
        label = "color"
    )
    Box(
        modifier = Modifier
            .drawBehind {
                drawRect(animatedColor)
            }
            .fillMaxSize(1f)
            .clickable {
                animateBackgroundColor = !animateBackgroundColor
            },
        contentAlignment = Alignment.Center
    ) {
        Text(text = "Hello")
    }
}

更改偏移量来移动可组合项

更改offset偏移量不会影响其布局测量的参数,只会变更它的绘制流程。因此不算做真正的移动,不会对其父组件或者平级组件产生影响。

@Composable
fun ValueAnimation() {
    var moved by remember { mutableStateOf(false) }
    val pxToMove = with(LocalDensity.current) {
        100.dp.toPx().roundToInt()
    }
    val offset by animateIntOffsetAsState(
        targetValue = if (moved) {
            IntOffset(pxToMove, pxToMove)
        } else {
            IntOffset.Zero
        },
        label = "offset"
    )

    Box(
        modifier = Modifier
            .offset {
                offset
            }
            .background(Color.Blue)
            .size(100.dp)
            .clickable {
                moved = !moved
            }
    )
}

要实现真正的移动动画效果,则需要重写Modifier的layout方法,来改变其测量的流程。假如是在一个Column中,这个Box下面的子控件就会被其挤下去了。

@Composable
fun ValueAnimation() {
    var toggled by remember { mutableStateOf(false) }
    
    val offsetTarget = if (toggled) IntOffset(150, 150) else IntOffset.Zero

    val offset = animateIntOffsetAsState(targetValue = offsetTarget, label = "offset")

    Column(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxSize()
            .clickable {
                toggled = !toggled
            }
    ) {
        Box(
            modifier = Modifier
                .layout { measurable, constraints ->
                    val offsetValue = if (isLookingAhead) offsetTarget else offset.value
                    val placeable = measurable.measure(constraints)
                    layout(placeable.width + offsetValue.x, placeable.height + offsetValue.y) {
                        placeable.placeRelative(offsetValue)
                    }
                }
                .size(100.dp)
                .background(Color.Green)
        )
        Box(
            modifier = Modifier
                .size(100.dp)
                .background(Color.Blue)
        )
    }
}

添加阴影动画

想要为可组合项添加阴影动画,需要使用graphicsLayer方法来修改其阴影的大小。

@Composable
fun ValueAnimation() {
    val mutableInteractionSource = remember {
        MutableInteractionSource()
    }
    val pressed = mutableInteractionSource.collectIsPressedAsState()
    val elevation = animateDpAsState(
        targetValue = if (pressed.value) {
            32.dp
        } else {
            8.dp
        },
        label = "elevation"
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .graphicsLayer {
                this.shadowElevation = elevation.value.toPx()
            }
            .clickable(interactionSource = mutableInteractionSource, indication = null) {
            }
            .background(Color.Green)
    )
}

这里用到了 MutableInteractionSource ,这是一个可观察的交互源,它可以用于监听用户与可组合项的交互事件,例如点击、长按、拖拽等。它提供了一个 collectIsPressedAsState() 方法,用于收集用户是否正在与可组合项进行交互的状态。

使用Transition

Transition 可管理一个或多个动画作为其子项,并在多个状态之间同时运行这些动画。

@Composable
fun TransitionAnimation() {
    var boxState by remember { mutableStateOf(BoxState.Collapsed) }
    val transition = updateTransition(targetState = boxState, label = "box")

    val color by transition.animateColor(
        label = "color",
        targetValueByState = { state ->
            when (state) {
                BoxState.Collapsed -> Color.Red
                BoxState.Expanded -> Color.Green
            }
        }
    )

    val height by transition.animateDp(
        label = "height",
        targetValueByState = { state ->
            when (state) {
                BoxState.Collapsed -> 100.dp
                BoxState.Expanded -> 300.dp
            }
        }
    )

    Box(modifier = Modifier
        .fillMaxWidth(1f)
        .height(height)
        .background(color)
        .clickable {
            boxState = if (boxState == BoxState.Collapsed) {
                BoxState.Expanded
            } else {
                BoxState.Collapsed
            }
        })
}

enum class BoxState {
    Collapsed,
    Expanded
}

首先定义一个enum类,来表示可组合项的两种状态。然后使用 updateTransition 方法来创建一个Transition实例。

在Transition实例上调用 animateColoranimateDp 方法来创建两个动画。这两个动画会在 Collapsed 和 Expanded 状态之间切换。

通过点击触发状态变化,可以看到可组合项的颜色和高度会随着状态的变化而变化。

【Compose】附带作用

【Compose】附带作用

本文介绍了Jetpack Compose里附带作用的使用及特性

Side Effect, 官方有一段时间翻译为副作用,后来终于改为了更为精准的附带作用。

设计目的

官方定义:

附带效应是指发生在可组合函数作用域之外的应用状态的变化。由于可组合项的生命周期和属性(例如不可预测的重组、以不同顺序执行可组合项的重组或可以舍弃的重组),可组合项在理想情况下应该是无附带效应的。不过,有时附带效应是必要的,例如,触发一次性事件(例如显示信息提示控件),或在满足特定状态条件时进入另一个屏幕。这些操作应从能感知可组合项生命周期的受控环境中调用。在本页中,您将了解 Jetpack Compose 提供的不同附带效应 API。

简单点说,就是Composable函数设计之初就已经规定好了,每一次Composable的函数的重组会尽量以最小重组范围来进行。而且,顺序写在一起的Composable函数,他们之间的调用顺序不是确定的。理想情况不需要跳脱到这个规则之外,否则可能会产生未知的数据问题。

但是很多情况,我们用这个设计理念来写代码,会产生很多不优雅的情况,比如超长的变量传递链,不合理不直观的架构设计。

所以就设计了一些附带作用的API,在某些情况下代码块里面的跳脱到重组的范围之外,比如只有初次进入时调用一次,后面都不参与重组;还比如每次退出Composable函数时调用一次。

LauncheEffect

如需在可组合项的 生命周期内执行工作并能够调用挂起函数 ,请使用 LaunchedEffect 可组合项。

当它的内部逻辑要触发时,它会启动一个协程,并将代码块作为参数传递。

如果 LaunchedEffect 退出组合,协程将取消。

源码:

@Composable
@NonRestartableComposable
@OptIn(InternalComposeApi::class)
fun LaunchedEffect(
    key1: Any?,
    block: suspend CoroutineScope.() -> Unit
) {
    val applyContext = currentComposer.applyCoroutineContext
    remember(key1) { LaunchedEffectImpl(applyContext, block) }
}
  • key参数是其监听变化状态的值
  • block就是丢给协程执行的代码块。

当这个外部的key的值发生变化时,协程会被取消,重新启动。并且,当LaunchedEfftect的所属可组合项退出时,协程会被直接取消。

最常用用法是用来触发网络数据获取,在挂起函数执行完毕数据返回来之后,更新界面的状态。

如下面例子所示:

@Composable
fun LaunchedEffectComposable() {
    val isLoading = remember { mutableStateOf(false) }
    val data = remember { mutableStateOf(listOf<String>()) }

    // 定义一个 LaunchedEffect 来异步执行长时间运行的操作,
    // 如果 isLoading.value 发生变化,LaunchedEffect 将取消并重新启动
    LaunchedEffect(isLoading.value) {
        if (isLoading.value) {
            val newData = fetchData()  // 执行长时间运行的操作,例如从网络获取数据
            data.value = newData       // 使用新数据更新状态
            isLoading.value = false
        }
    }

    Column {
        Button(onClick = { isLoading.value = true }) {
            Text("Fetch Data")
        }
        if (isLoading.value) {
            CircularProgressIndicator()  // 显示加载指示器
        } else {
            LazyColumn {
                items(data.value.size) { index ->
                    Text(text = data.value[index])
                }
            }
        }
    }
}

remeberCoroutineScope

由于 LaunchedEffect 是可组合函数,因此只能在其他可组合函数中使用。

remeberCoroutineScope 可以在可组合项外启动协程,但存在作用域限制,以便协程在退出组合后自动取消,请使用 rememberCoroutineScope。 此外,如果您需要手动控制一个或多个协程的生命周期,请使用 rememberCoroutineScope,例如在用户事件发生时取消动画。

rememberCoroutineScope 是一个可组合函数,会返回一个 CoroutineScope,该 CoroutineScope 绑定到调用它的组合点。调用退出组合后,作用域将取消。

@Composable
fun SideEffectPage() {

    val isShowButtton = remember {
        mutableStateOf(true)
    }
    LaunchedEffect(Unit) {
        delay(3000)
        isShowButtton.value = false
    }
    
    if (isShowButtton.value) {
        val scope = rememberCoroutineScope()
        Button(onClick = {

            scope.launch {
                delay(2000)
                Log.i("rememberCoroutineScope", "fdgbfv")
            }
        }) {
            Text(text = "SidsfgvdcsPage ")
        }
    }
}

在isShowButtton作用域代码块里面使用这个附加作用获取一个协程对象,在点击按钮时,启动一个协程,在协程里面执行一些耗时操作。

同时外部的 LaunchedEffect 作用下,3s后,这个协程所属的可组合项会退出生命周期,这个协程里的所有任务也会被取消。

SideEffect

想象这样一个需求,要求用户进入某个界面之后,记录他们的某个操作的埋点数据。

如果直接在Composable方法中执行这个记录工作,那么即使重组失败,数据仍然会更新。需要确保Composable重组成功的情况下,才会记录埋点数据。

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

DisposableEffect

DisposableEffect 可组合项会在每次重新组合时调用其效果,并且在每次重新组合时,都会在退出组合时调用其 onDispose 回调。可以在 onDispose 回调中执行清理工作。回收该Composable函数中使用的所有资源。

看这样一个场景,在isShowButton.value为true时,在作用域内启动一个协程任务,在3s后更新Text的数据。

注意直接在Composable函数中启动协程任务,会导致协程任务的生命周期和Composable函数的生命周期不一致,导致协程任务无法被正确的回收。这里仅作演示。

@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun SideEffectPage() {
    val isShowButton = remember { mutableStateOf(true) }
    
    LaunchedEffect (Unit){
        delay(1000)
        isShowButton.value = false
    }

    if(isShowButton.value) {
        val text = remember { mutableStateOf("Hello") }
        val scope = CoroutineScope(Dispatchers.Main)
        val job = scope.launch {
            delay(3000)
            Log.d("SideEffectPage", "SideEffectPage: ")
            text.value = "Hello World"
        }
        Text(text = text.value)
    }
}

1s后尽管已经隐藏了Button,退出了可组合项的生命周期,但是协程任务依然在运行,导致内存泄漏。

这时候我们可以使用DisposableEffect,在onDispose回调中,取消协程任务。

更改后如下:

@SuppressLint("CoroutineCreationDuringComposition")
@Composable
fun SideEffectPage() {
    val isShowButton = remember { mutableStateOf(true) }
//
    LaunchedEffect (Unit){
        delay(1000)
        isShowButton.value = false
    }

    if(isShowButton.value) {
        val text = remember { mutableStateOf("Hello") }
        DisposableEffect(Unit) {
            val scope = CoroutineScope(Dispatchers.Main)
            val job = scope.launch {
                delay(3000)
                Log.d("SideEffectPage", "SideEffectPage: ")
                text.value = "Hello World"
            }
            onDispose {
                job.cancel()
            }
        }

        Text(text = text.value)
    }
}

rememberUpdateState

如果我们使用LaunchedEffect时要引用一个值,但是如果该值发生更改,则不应重新启动,请使用 rememberUpdatedState。

当关键参数的值之一更新时, LaunchedEffect 会重新启动,但有时我们希望在不重新启动的情况下捕获效果中更改的值。

如果我们有长时间运行的选项,重新启动成本很高,则此过程会很有帮助。

@Composable
fun SideEffectPage() {

    val netText = remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        delay(3000L)
        netText.value = "got a new String from network"
    }

    UpdateText(netText.value)
}

@Composable
fun UpdateText(test: String) {
    val uiText = remember { mutableStateOf("initial local text") }

    val updateText = rememberUpdatedState(test)

    LaunchedEffect(Unit) {
        delay(5000L)
        uiText.value = updateText.value
    }
    
    Box(
        modifier = Modifier.fillMaxSize(1f),
        contentAlignment = Alignment.Center
    ) {
        Text(text = uiText.value)
    }
}

例如在5s后更新一次Text的数据,但是在3s后,网络请求已经返回了新的数据,这个时候我们就可以使用 rememberUpdateState 先将这个更新的数据存储起来。5s后就可以使用更新的正确的数据。

另外还有几个附带作用的函数,我日常使用的时候几乎没有使用过,暂时先不做记录。

【Python】Python环境报错记录

【Python】Python环境报错记录

本文记录了我在使用Python过程中的一些环境报错记录。

类似于之前的一篇Desktop环境配置记录:

Desktop系统环境适配等操作记录

这篇文章记录了我在配置Python环境时遇到的一些问题。以供日后查阅。

pip安装库报错CERTIFICATE_VERIFY_FAILED

此错误通常是因为 Python 无法验证 SSL 证书的颁发者,导致无法建立安全的 HTTPS 连接。这在 macOS 上尤其常见,通常是由于缺少或配置不正确的 SSL 证书链。

1. 运行证书安装脚本 (推荐)

最简单且最有效的解决方法是运行 Python 自带的证书安装脚本。

  • Python 3.6 及更高版本: 在 Finder 中,前往“应用程序” -> “Python 3.x”文件夹,然后双击运行 Install Certificates.command 文件。

  • 通过命令行运行: 你也可以在终端中运行以下命令。

    /Applications/Python\ 3.x/Install\ Certificates.command
    

    请将 3.x 替换为你安装的 Python 版本号,例如 3.10

这个脚本会下载并安装用于验证 SSL 连接所需的证书链,通常能解决大多数问题。

配置环境变量 (备用方案)

如果前两种方法都不可行,可以尝试设置 SSL_CERT_FILE 环境变量来指定正确的证书路径。

  1. 找到证书文件: 通常在 /etc/ssl/certs//usr/local/etc/openssl/ 目录下。

  2. 设置环境变量: 在你的终端配置文件(如 ~/.zshrc~/.bash_profile)中添加以下行,然后重启终端或运行 source ~/.zshrc 使其生效。

    export SSL_CERT_FILE=/usr/local/etc/openssl/cert.pem
    

发现均未解决问题。未安装openssl,使用homebrew来安装一下。

brew install openssl

找到证书目录:

export LDFLAGS="-L/opt/homebrew/opt/openssl/lib"
export CPPFLAGS="-I/opt/homebrew/opt/openssl/include"

重启电脑后,可以正常安装库了。

虚拟环境 .venv 配置

到项目根目录下执行

python3 -m venv .venv

激活虚拟环境:

source .venv/bin/activate

安装库:

pip install requests

退出:

deactivate

##

【Python】Python基础扫盲

【Python】Python基础扫盲

本文记录了Python里的基础知识,方便调试AI工具代码

经常有一些电脑端的批量的小任务,比如后台截屏,轮询检查文件变化,批量处理文件等。

之前可能会专门搜一个软件安装下来,或者找一个工具网站,再去使用其提供的功能。现在第一时间想到的是打开 PyCharm ,然后把需求描述给AI,再不断地运行测试与优化。大部分情况下,都是可以一次解决问题的。

小例子——后台截屏

比如上周想对一个软件界面截个图,但是各个截图快捷键按下时,都会导致这个软件界面发生变化。就想能不能先让这个软件保持界面,弄一个服务在后台自动截图并存文件。AI实现的功能如下:

from time import sleep

from PIL import ImageGrab

# 捕获整个屏幕
print("等待10秒")
sleep(10)
print("开始截图")
screenshot = ImageGrab.grab()
screenshot.save("full_screen_capture.png")
print("已捕获整个屏幕并保存为 full_screen_capture.png")

问题

在不能一次搞定的任务中,除了不断地更改需求,向AI粘贴报错信息(这也不一定能获得完美的结果)之外,我希望自己也具备基础的调适和开发能力,一些简单的问题点可以立即发现,并精准地提出修改需求和意见,这样可以大大提高AI开发这个模式的完成效率。

目前对Python的了解尚少,比如看到项目中的:

if __name__ == '__main__':

还有

__init__.py

不知道它们的含义和运行时机。此文作为基础性的学习记录,语法层面快速带过,主要是项目中的一些最佳实践,运行环境的配置,代码的组织架构,了解实际的工程中是如何书写的,因为AI的训练数据就来自实际的工程项目代码,所以它生成的代码应该是更符合实际工程项目的风格。

下面大部分例子来自菜鸟教程的Python基础教程:菜鸟教程

Python代码运行方式

Python 代码的运行可以分为两个主要阶段:编译解释。但与传统的编译型语言(如 C++)不同,Python 的编译过程更轻量、更灵活。

C++的编译阶段,编译完成就是面向特定平台可直接运行的二进制文件。Python的编译成果也是平台无关的,但是是给Python虚拟机使用的,由Python虚拟机来逐行解释成机器码,然后由计算机 CPU 执行。这个过程就是解释执行

py文件的直接运行和作为模块运行

开头背景中的那个问题,if __name__ == '__main__': 是做什么用的?这是Python 中一个非常常见的结构,它的主要作用是控制代码块的执行时机

简单来说,它的意思是:“如果当前文件是作为单个脚本直接被运行的,那么就执行这个判断语句下面的代码。”

在 Python 中,一个 .py 文件有两种用途:

  1. 作为脚本直接运行: 你在命令行中输入 python your_script.py 来运行它。
  2. 作为模块导入: 你在另一个文件中,使用 import your_script 来导入它的功能。

当你直接运行一个文件时,Python 解释器会给 一个特殊的内置变量 __name__ 赋值为 '__main__'

而当你将它作为模块导入时,__name__ 的值会被设置为模块的名字(也就是文件名,比如 'your_script')。

因此,if __name__ == '__main__': 这个条件判断就成了区分这两种情况的“开关”。

总结下来,这个结构的主要作用是:

  • 保护代码: 确保某些代码(通常是程序的主体逻辑、测试代码或示例调用)只在文件被作为主程序运行时才执行。
  • 提高模块化: 让你的文件既可以作为独立的脚本运行,也可以作为一个可导入的库,提供函数和类给其他程序使用,而不会在导入时意外执行主程序逻辑。

这个习惯在 Python 项目开发中非常重要,可以帮助你更好地组织和复用代码。

__init__.py 的作用

__init__.py 是 Python 包(Package)中一个特殊的文件,它的主要作用是将一个目录变成一个可被导入的 Python 包

简单来说,只要一个目录下存在 __init__.py 文件,Python 解释器就会把这个目录当作一个包来处理。

__init__.py 文件也可以为空,它在包的导入机制和初始化中扮演着关键角色:

  1. 标识包: 这是它最基础的作用。当 Python 解释器看到一个带有 __init__.py 文件的目录时,它就知道这个目录不是普通的文件夹,而是一个可以包含多个模块的包。这允许你通过 import 语句来访问包内的模块。

  2. 包的初始化: 当你导入一个包时,比如 import my_package,这个包下的 __init__.py 文件会自动执行。你可以利用这个特性来做一些初始化工作,例如:

    • 设置包级别的变量。
    • 执行某些初始化代码。
    • 导入包内的常用模块,以简化外部调用。
  3. 简化导入: 这是 __init__.py 最实用的功能之一。假设你有一个包结构如下:

    my_package/
    ├── __init__.py
    ├── module_a.py
    └── module_b.py
    

    通常情况下,如果你想导入 module_a 中的函数 func_a,你需要写 from my_package.module_a import func_a

    如果你在 __init__.py 中添加一行代码:

    from . import module_afrom .module_a import func_a

    那么外部就可以直接通过 from my_package import func_a 来导入,从而简化了调用路径。

简化外部导入的示例

假设你有如下文件结构:

animals/
├── __init__.py
├── cat.py
└── dog.py

dog.py 的内容:

def bark():
    print("Woof!")

cat.py 的内容:

def meow():
    print("Meow!")

如果你想在 main.py 中使用这两个函数,你可以这样做:

main.py

import animals.dog
import animals.cat

animals.dog.bark()
animals.cat.meow()

现在,如果你想让导入更方便,你可以在 __init__.py 中做些手脚:

animals/__init__.py

# 这会将 dog 和 cat 模块导入到 animals 包的命名空间中
from . import dog
from . import cat

# 也可以直接导入具体的函数,让它们成为包的直接成员
from .dog import bark
from .cat import meow

现在,你的 main.py 就可以写得更简洁了:

main.py

from animals import bark, meow

bark()
meow()

__init__.py 还可以控制包的 __all__ 变量,来明确定义 from animals import * 这种写法可以导入哪些模块,这有助于提高代码的清晰度和安全性。

__all__ 是 Python 中一个特殊的列表,它定义了当用户使用 from <module> import * 语句时,可以从模块或包中导入哪些名称(比如变量、函数、类等)。简单来说,__all__ 就像一个“白名单”,它明确地告诉 Python 解释器:“请只导出这个列表中列出的东西。”

命名风格

在Python中,主流的命名规范通常遵循 PEP 8(Python Enhancement Proposal 8),这是Python社区公认的代码风格指南。遵循这些规范能让你的代码更具可读性,也更符合Python生态的习惯。

文件命名

Python文件名(模块名)应该全部使用小写,并可以用下划线连接单词。

  • 正确示例: my_module.py, data_processing.py
  • 错误示例: MyModule.py, data-processing.py

方法和函数命名

方法和函数名也应该全部使用小写,并用下划线连接单词。这是最常见和推荐的风格。

  • 正确示例: calculate_total_price(), get_user_info()
  • 错误示例: calculateTotalPrice(), GetUser_Info()

变量命名

变量名同样使用小写,并用下划线连接单词。

  • 正确示例: user_name, total_count, is_active
  • 错误示例: userName, TotalCount, is-active

类和常量命名

  • 类名 (Class Names): 使用 驼峰式命名法 (CamelCase)。每个单词的首字母都大写,不使用下划线。
    • 正确示例: MyClass, UserInfo, HttpClient
  • 常量名 (Constants): 全部使用大写字母,并用下划线连接单词。
    • 正确示例: MAX_SIZE, PI_VALUE, DEFAULT_TIMEOUT

总的来说,PEP 8 的核心思想是让代码清晰易读。作为一名Android开发者,可能习惯了Java的驼峰命名法(CamelCase),但在Python中,下划线命名法(snake_case)才是更主流的约定。

注释风格

在Python中,主流的注释风格也遵循 PEP 8 规范,主要分为两种类型:行内注释文档字符串(Docstrings)

行内注释 (Inline Comments)

行内注释用于解释代码中特定行或段落的用途。

  • 使用一个井号 # 开始。
  • 通常用于解释为什么这样做,而不是做什么。例如:
    • 好的注释:
      x = x + 1  # 增加计数器,以防止无限循环。
      
    • 不好的注释(解释了显而易见的事情):
      x = x + 1  # x加1
      
  • 注释内容和井号 # 之间应至少有一个空格。
  • 如果行内注释和代码在同一行,通常在 # 前面留两个空格。

文档字符串 (Docstrings)

文档字符串(简称 docstrings)是Python特有的,用于为模块、函数、类和方法提供详细说明。它们不是简单的代码注释,而是可以被工具自动解析和提取的。

在Python中,单引号 ' ' 和双引号 " " 在定义字符串字面量时是完全相同的,它们没有功能上的区别。Python提供了两种引号,这主要是为了方便处理字符串中包含引号的特殊情况,可以避免使用转义字符。如果你想在字符串中包含一个单引号(例如 'It's a great day!'),使用双引号来定义字符串,就无须使用转义符。反之亦然,如果字符串中包含双引号,使用单引号来定义会更方便。并且,Python 没有单独的字符类型,一个字符就是长度为 1 的字符串。

反斜杠可以用来转义,使用 r 可以让反斜杠不发生转义。 如 r”this is a line with \n” 则 \n 会显示,并不是换行。

  • 使用三引号 """''' 包围。
  • 用途
    • 函数/方法:解释函数的功能,参数(Args),返回值(Returns)和可能引发的异常(Raises)。
    • :解释类的用途,属性和使用方法。
    • 模块:解释模块的整体功能。
  • 风格
    • 对于简单的单行文档字符串,可以这样写:
      def add(a, b):
          """返回两个数字的和。"""
          return a + b
      
    • 对于多行文档字符串,通常在第一行写简短摘要,然后空一行,再写详细说明。这在大型项目中非常常见。
      def complex_calculation(value, factor=1.0):
          """对输入值执行复杂的数学计算。
      
          此函数首先将值乘以一个因子,然后进行平方,
          最后加上一个预设的常量。
      
          Args:
              value (int): 需要计算的整数值。
              factor (float, optional): 乘法因子。默认为 1.0。
      
          Returns:
              float: 计算后的结果。
          """
          result = (value * factor) ** 2 + 10
          return result
      

有意思的区别,Java的注释写在方法的上面,Python的注释写在方法的下面。

行与缩进

python最具特色的就是使用缩进来表示代码块,不需要使用大括号 { }

缩进的空格数是可变的,但是同一个代码块的语句必须包含相同的缩进空格数。实例如下:

if True:
    print ("True")
else:
    print ("False")

以下代码最后一行语句缩进数的空格数不一致,会导致运行错误:

if True:
    print ("Answer")
    print ("True")
else:
    print ("Answer")
  print ("False")    # 缩进不一致,会导致运行错误

Python的 print() 函数功能很强大,特别是在处理多个参数和格式化输出方面,它提供了更多灵活的选项。

在Python中,print() 不是一个语句,而是一个内置函数。它是学习Python的第一个重要工具,用于在控制台输出信息。

1. 基本用法

print() 函数最简单的用法是直接传入你想要输出的对象。它可以是字符串、数字、变量,甚至更复杂的对象。

print("Hello, Python!")
name = "Gemini"
age = 2
print("My name is", name, "and I'm", age, "years old.")

上面的代码会输出:Hello, Python!My name is Gemini and I'm 2 years old.

print() 函数会自动在每个参数之间添加一个空格。

2. 参数详解

print() 函数有很多可选参数,可以帮助你更好地控制输出的格式。最常用的有:

  • sep (separator):分隔符。用来指定多个参数之间的分隔符,默认是空格。

    print("apple", "banana", "cherry", sep=", ")
    # 输出: apple, banana, cherry
    
    print("2025", "09", "05", sep="-")
    # 输出: 2025-09-05
    
  • end:结尾符。用来指定输出结束后在行尾添加的字符,默认是换行符 \n

    print("Hello", end=" ")
    print("world!")
    # 输出: Hello world!
    # 注意,两个 print 语句的输出在同一行
    
  • file:指定输出到哪个文件对象。默认是标准输出 sys.stdout,也就是控制台。

    with open("output.log", "w") as f:
        print("This message will be written to a file.", file=f)
    
  • flush:布尔值,是否强制刷新缓冲区。当你将内容输出到文件或管道时,内容可能会先缓存在内存中。flush=True 会立即将内容写入目标。在通常的控制台输出中,你很少需要用到这个参数。

3. 高级格式化输出

除了 print() 函数本身,Python还提供了几种强大的字符串格式化方法,可以和 print() 配合使用。

  • % 运算符(旧式):类似C语言的 printf()

    print("My name is %s and I'm %d years old." % (name, age))
    

    这种方式在现代Python代码中已经不那么常见了,但你可能会在一些老项目中看到它。

  • str.format() 方法:更灵活和可读性更好的方式。

    print("My name is {} and I'm {} years old.".format(name, age))
    
  • f-string (推荐):从Python 3.6开始引入,语法简洁,可读性极高,是目前最主流的格式化方式。

    print(f"My name is {name} and I'm {age} years old.")
    

类型问题

Python中变量的类型是动态的,也就是说,你不需要在定义变量时指定它的类型,Python会根据赋值自动确定。并且在运行过程中,变量类型是可以随时更改的:

a = int(0)
print(f"a: {a}, type: {type(a)}, address: {id(a)}")
a = "test"
print(f"a: {a}, type: {type(a)}, address: {id(a)}")
a = {"keyOne": "valueOne"}
print(f"a: {a}, type: {type(a)}, address: {id(a)}")

变量a可以指向不同类型的对象,这就是动态类型的含义。

判断类型的方法,isinstance 和 type 的区别在于:

  • type()不会认为子类是一种父类类型。
  • isinstance()会认为子类是一种父类类型。

字符串截取

索引值以 0 为开始值,-1 为从末尾的开始位置。

a = "hello"
print(a[-4:-1])
# 输出: ell

print(a[1:3])
# 输出: el

不管索引方向是哪一个,这个区间都是左闭右开的。即含头不含尾。

List类型

List(列表) 是 Python 中使用最频繁的数据类型。

列表可以完成大多数集合类的数据结构实现。 列表中元素的类型可以不相同 ,它支持数字,字符串甚至可以包含列表(所谓嵌套)。

列表是写在方括号 [] 之间、用逗号分隔开的元素列表。

和字符串一样,列表同样可以被索引和截取,列表被截取后返回一个包含所需元素的新列表。

列表截取的语法格式如下:

变量[头下标:尾下标]

索引值以 0 为开始值,-1 为从末尾的开始位置。

list = [ 'abcd', 786 , 2.23, 'runoob', 70.2 ]  # 定义一个列表
tinylist = [123, 'runoob']

print (list)            # 打印整个列表
print (list[0])         # 打印列表的第一个元素
print (list[1:3])       # 打印列表第二到第四个元素(不包含第四个元素)
print (list[2:])        # 打印列表从第三个元素开始到末尾
print (tinylist * 2)    # 打印tinylist列表两次
print (list + tinylist)  # 打印两个列表拼接在一起的结果

Python 列表截取可以接收第三个参数,参数作用是截取的步长,以下实例在索引 1 到索引 4 的位置并设置为步长为 2(间隔一个位置)来截取字符串。如果是负数就表示逆向截取。

def reverseWords(input): 
      
    # 通过空格将字符串分隔符,把各个单词分隔为列表
    inputWords = input.split(" ") 
  
    # 翻转字符串
    # 假设列表 list = [1,2,3,4],  
    # list[0]=1, list[1]=2 ,而 -1 表示最后一个元素 list[-1]=4 ( 与 list[3]=4 一样) 
    # inputWords[-1::-1] 有三个参数
    # 第一个参数 -1 表示最后一个元素
    # 第二个参数为空,表示移动到列表末尾
    # 第三个参数为步长,-1 表示逆向
    inputWords=inputWords[-1::-1] 
  
    # 重新组合字符串
    output = ' '.join(inputWords) 
      
    return output 
  
if __name__ == "__main__": 
    input = 'I like runoob'
    rw = reverseWords(input) 
    print(rw)

Tuple(元组)

元组(tuple)与列表类似,不同之处在于元组的元素不能修改。元组写在小括号 () 里,元素之间用逗号隔开。元组中的元素类型也可以不相同。

#!/usr/bin/python3

tuple = ( 'abcd', 786 , 2.23, 'runoob', 70.2  )
tinytuple = (123, 'runoob')

print (tuple)             # 输出完整元组
print (tuple[0])          # 输出元组的第一个元素
print (tuple[1:3])        # 输出从第二个元素开始到第三个元素
print (tuple[2:])         # 输出从第三个元素开始的所有元素
print (tinytuple * 2)     # 输出两次元组
print (tuple + tinytuple) # 连接元组

构造包含 0 个或 1 个元素的元组比较特殊,所以有一些 额外的语法规则

tup1 = ()    # 空元组
tup2 = (20,) # 一个元素,需要在元素后添加逗号

如果你想创建只有一个元素的元组,需要注意 在元素后面添加一个逗号 ,以区分它是一个元组而不是一个普通的值,这是因为在没有逗号的情况下,Python会将括号 解释为数学运算中的括号 ,而不是元组的表示。

如果不添加逗号,如下所示,它将被解释为一个普通的值而不是元组:

not_a_tuple = (42)

【网络】使用Reqable抓包Https请求记录

【网络】使用Reqable抓包Https请求记录

本文记录了使用Reqable抓包Https请求的过程。

在集成DeepSeek的Chat对话API时,我遇到了一个典型的用户体验问题:启用流式输出(streaming response)后,服务器返回的数据过于密集且速度快,导致前端页面渲染出现卡顿,文字滚动不连贯,严重影响交互流畅度。

为了精准定位问题根源,我需要分析实际网络传输过程中的数据包特征——包括响应头、数据分块频率、SSE协议实现细节等。此时,专业的抓包工具成为不可或缺的助手。经过对比,我选择了 Reqable 这款支持跨平台、全协议(尤其是HTTPS解密)的抓包工具,它不仅能捕获明文和加密流量,还提供直观的过滤和可视化分析功能。

本文将详细记录从环境搭建到HTTPS抓包配置的全过程,并解析DeepSeek流式接口返回的 text/event-stream 数据特征。

抓包工具工作原理

我们都知道HTTPS协议是加密传输,是安全的,抓包工具是如何插到客户端和服务器中间,来明文地查看通信报文的呢?

本文介绍的Reqable,或者其他工具如 Fiddler、Charles 等抓包工具能够拦截并显示 HTTPS 报文的通信信息的原理,核心在于利用了 “中间人攻击”(Man-in-the-Middle, MITM) 技术,但它是一种 受控的、合法的、且需要用户授权的操作

本质上,抓包工具扮演了一个 “代理服务器”(Proxy Server) 的角色,将自己插入到客户端(浏览器、App)和目标服务器之间。

以下是抓包工具拦截和解密 HTTPS 报文的详细步骤:

1. 代理和证书的安装

这是实现 HTTPS 抓包的前提条件。必须让APP信任抓包工具提供的证书,才可能往下推进。

  1. 设置代理: 您需要将客户端设备(如手机、电脑)的网络配置指向抓包工具运行的机器的 IP 和端口,让所有的网络流量都流经抓包工具。
  2. 安装根证书(Root Certificate): 抓包工具会生成一个自己的根证书。您必须将这个证书安装到客户端设备(手机或电脑)的系统信任证书列表中。这是最关键的一步,它让客户端设备“相信”抓包工具是合法的证书颁发机构(CA)。

1.1 需要注意的情况

从 Android 7 开始,App 默认不再信任用户安装的 CA 证书(即抓包工具的根证书),除非 App 的 network_security_config.xml 文件中显式配置允许,Reqable就是采用了这个方案。如果缺少证书的验证,只能看到客户端的所有网络请求地址,在校验初期就会失败而中断连接。

另外,有些高度安全的 App 会将目标服务器的真实证书公钥硬编码到 App 代码中。在这种情况下,即使您安装了抓包工具的根证书,App 也会发现抓包工具发送的假证书与硬编码的真实证书不匹配,从而立即终止连接,防止抓包。要绕过证书固定,通常需要使用 Hook 工具在 App 验证证书的代码执行前将其拦截和修改,这也是移动安全研究的一个重要领域。

2. TLS 握手时的“中间人”行为

当客户端(App 或浏览器)尝试通过 HTTPS 连接目标服务器时,抓包工具开始扮演“中间人”的角色。

1. 客户端问候 ,客户端向抓包工具发送 Client Hello。抓包工具将 Client Hello 转发给目标服务器。 2. 服务器响应 ,抓包工具收到目标服务器的 Server Hello真实证书。目标服务器发送真实证书3. 伪造证书 ,抓包工具动态生成一个“假证书”,其域名与目标服务器的域名(如 www.google.com)一致。然后,抓包工具使用自己安装在客户端上的“根证书”来签名这个假证书。 4. 抓包工具问候,抓包工具将这个伪造的、已签名的假证书发送给客户端。 5. 客户端信任 ,客户端收到证书后,发现这个假证书的签发者(抓包工具)的根证书在自己的信任列表里(因为您手动安装了),因此客户端认为这个假证书是合法的

3. 双重加密和解密

握手完成后,抓包工具实际上建立了两个独立的 TLS 连接:

  • 客户端 $\leftrightarrow$ 抓包工具** ,抓包工具是服务器的角色,使用假证书协商出的对称密钥 $K_A$ ,报文被加密(用 $K_A$)
  • 抓包工具 $\leftrightarrow$ 目标服务器,抓包工具是客户端的角色,使用真实证书协商出的对称密钥 $K_B$ ,报文被加密(用 $K_B$)

数据流转过程:

  1. 客户端发送数据: 客户端用 $K_A$ 加密数据 $\rightarrow$ 发给抓包工具。
  2. 抓包工具解密(第一次): 抓包工具收到数据后,用自己的 $K_A$ 将报文解密成明文
  3. 显示与修改: 此时,Reqable 等工具就能以明文形式显示通信信息,甚至允许您在转发前修改报文内容。
  4. 抓包工具加密(第二次): 抓包工具用 $K_B$ 重新加密报文 $\rightarrow$ 发给目标服务器。
  5. 目标服务器解密: 目标服务器用自己的 $K_B$ 将报文解密。

通过建立并控制这两个独立的 TLS 通道,抓包工具成功地看到了 中间的明文数据。

Reqable 的安装

Reqable是一款集API抓包调试与测试于一体的高效工具,支持以下核心能力:

  • 多平台覆盖:Windows/macOS/Linux/Android/iOS全主流操作系统
  • HTTPS解密:通过安装CA证书实现加密流量的明文查看
  • 协议支持全面:HTTP/1.1、HTTP/2、WebSocket、gRPC、SSE等
  • 过滤与分析强大:支持按域名、状态码、内容类型等条件快速筛选目标请求

在下载页面下载并安装Reqable的MACOS和Android的安装包,地址为:

Reqable下载页面

要抓取HTTPS请求,必须解决中间人攻击防护机制导致的加密流量不可见问题。Reqable采用标准CA证书信任机制,需要在客户端(Mac和Android)分别安装其根证书并信任。

Android平台需要手动下载并配置Android端的证书,下载Reqable的证书后,在设置的安全页面,点击安装证书。

Android证书安装

关键步骤为手动配置证书信任:

  1. 手机与电脑处于同一局域网,连接Wi-Fi后进入 Wi-Fi设置 → 选择当前网络 → 修改网络 → 高级 → 代理,设置为 手动代理
  2. 输入PC的本地IP(如 192.168.1.5)和Reqable默认监听端口 9000(可在Reqable的「设置→代理」中确认)。
  3. 在Reqable中导出CA证书(设置 → 证书 → 导出),或直接通过手机浏览器访问证书下载链接(若失败则手动导出并传输到手机):链接2。
  4. 在手机 设置 → 安全 → 加密与凭据 → 安装证书 → CA证书,选择下载的证书文件完成安装(需输入锁屏密码授权)。
  5. 最后进入 设置 → 安全 → 加密与凭据 → 信任的凭据 → 用户,确认证书已成功安装。

📌 注意:若你的Android项目未自动信任用户证书(例如非Debug包或Flutter项目),需额外配置网络安全策略(详见下文)。

添加网络安全配置文件开发者在项目源码中配置网络安全文件用户证书,并重新打包,选择下面任意一种方式即可。注意,网络安全配置不适用于Flutter项目。

build.gradle中配置依赖(推荐)

dependencies {
    debugImplementation 'com.reqable:reqable-android:1.0.0'
}

Debug包将自动集成网络安全配置文件,如果无法连接到Maven中央仓库,请按照下方的指引手动创建并配置网络安全文件。

手动创建网络安全文件新建文件 res/xml/network_security_config.xml

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
  <base-config cleartextTrafficPermitted="true">
    <trust-anchors>
      <certificates src="system" />
      <certificates src="user" />
    </trust-anchors>
  </base-config>
</network-security-config>

配置到 AndroidManifest.xml

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>
    ...
</application>

完成以上步骤后,重新打包应用,进行双端互联,即可开始抓包。

抓包配置

完成双端配置后,启动Reqable的抓包功能(默认开启代理监听)。界面会实时显示所有经过代理的网络请求,包括请求方法、URL、状态码、响应时间等基础信息。

初始状态下,列表可能包含大量无关请求(如广告、资源加载)。为聚焦目标,我们通过 域名过滤 快速定位DeepSeek相关流量。

点击启动按钮后,在列表中可以看到所有的网络请求的情况。

对于Deepseek的调试,我将服务器地址这里的 https://api.deepseek.cn 添加到Reqable的筛选中。

在AI助手页面内,输入对应的功能,触发网络请求之后,即可看到服务器返回的情况。

根据原始数据可以看出,服务器返回的类型确实是一个流式的数据 text/event-stream 。选中某一条目标请求,展开详情面板。从响应头中可以看到关键信息:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
...

重点字段解析:

  • Content-Type: text/event-stream:表明服务器返回的是Server-Sent Events(SSE)格式的流式数据,专为实时推送设计。
  • Transfer-Encoding: chunked:数据以分块(chunked)形式传输,适合不确定长度的流式内容。

进一步切换到 SSE 标签页(Reqable针对SSE协议提供的专用解析视图),可以清晰看到服务端推送的数据块结构。根据DeepSeek官方文档说明,当参数 stream=true 时,API会以SSE形式持续返回增量内容,每条数据以 data: {...} 格式发送,最终以 data: [DONE] 标志结束。

通过观察原始数据流,我们发现服务端推送频率较高(例如每秒多次),且单次数据块较小——这正是前端渲染卡顿的直接原因。

根据Deepseek官方文档,如果设置为 True,将会以 SSE(server-sent events)的形式以流式发送消息增量。消息流以 data: [DONE] 结尾。SSE(Server-Sent Events)返回类型是一种用于实现服务器向客户端单向实时推送数据的技术。它基于 HTTP 协议,允许服务器通过一个长连接持续向客户端发送数据流。

优化思路

基于抓包分析结果,我们可以针对性地优化用户体验。对接收到的SSE数据块进行缓冲,合并短时间内的多次推送后再更新UI,减少频繁重绘。或者在每一个数据包推送过来之后,手动加一段100-200ms的延时,让界面有一定的响应时间,避免用户体验上的卡顿。或者与DeepSeek团队沟通,探讨是否支持调整推送频率或合并小数据块(例如按语义段落聚合)。

【网络】Retrofit设计模式解析

【网络】Retrofit设计模式解析

本文介绍了流行框架retrofit的设计理念和实现原理

Retrofit 是一个由 Square 公司(现在叫 Block)开发的 类型安全 (Type-safe) 的 HTTP 客户端,专门用于 Android 应用开发。

它的核心思想非常巧妙:将一个 HTTP API 抽象成一个 Java 接口。你只需要通过 注解(Annotations) 来描述网络请求的各个部分(如请求方法、URL、请求头、请求体等),Retrofit 就会在运行时自动为你生成实现这个接口的 动态代理对象 ,然后你就可以像调用一个普通的 Java 方法一样来发起网络请求了。

Retrofit解决的痛点

在 Retrofit 出现之前,开发者通常使用 HttpURLConnection 或 Apache HttpClient,代码冗长、易错且难以维护。后来出现的 OkHttp 极大地改善了底层通信,但直接使用 OkHttp 仍然需要手动构建 Request 和处理 Response,不够直观。

声明式 API

接口的定义设计为了声明式 API,代码简洁优雅,你只需定义一个接口和一些方法,用注解来描述 API,完全无需关心如何构建 Request 对象、如何解析 Response 等繁琐细节。

传统方式需要手动拼接 URL,设置请求头,创建请求体,执行请求,检查状态码,读取输入流,然后用 Gson/Jackson 手动解析 JSON 字符串。整个过程可能需要几十行代码。使用Retrofit只需要定义一个接口方法,一行代码调用即可。

类型安全

类型安全也是 Retrofit 最强大的特性之一。网络请求的返回数据会通过转换器(Converter)自动反序列化成你指定的 Java 或 Kotlin 对象(如一个 UserList<Repo>)。

如果在接口中定义的返回类型与服务器实际返回的 JSON 结构不匹配,会在运行时通过转换器抛出异常,而不是得到一个 null 或导致 ClassCastException,这使得错误能够被快速定位和处理。请求参数的类型也是确定的,减少了手动拼写等错误的概率。

高度集成与可扩展性

Retrofit 底层默认且推荐使用 OkHttp 来执行实际的网络请求。因此,它自动继承了 OkHttp 的所有优点,如连接池、Gzip 压缩、请求重试和响应缓存等。你还可以自定义 OkHttpClient 来配置拦截器(Interceptor)、超时时间等。

通过可插拔的 Converter,Retrofit 不仅支持 JSON (通过 Gson, Moshi, Jackson),还支持 XML, Protobuf 等多种数据格式。

除了直接使用默认的 Call<T> 回调方式。通过 CallAdapter,Retrofit 可以将请求的返回类型适配成不同的异步工具,完美支持 Kotlin 协程 (Coroutines)、RxJava、Flow、Guava 等。

性能优异

由于底层依赖于高性能的 OkHttp,Retrofit 本身的开销非常小。它主要是在初始创建时通过反射生成接口实现,一旦创建完成,后续调用的性能非常高。

Retrofit 的核心组件

下面这几个组件是掌握 Retrofit 的关键。

API 接口 (The API Interface)

定义一个 interface 接口类,在接口里定义网络请求方法,然后使用注解来描述每个请求方法。

常用注解

  • 请求方法: @GET, @POST, @PUT, @DELETE, @HEAD, @PATCH
  • URL 处理:
    • @Path: 替换 URL 中的路径段,如 /users/{id} 中的 id
    • @Query: 添加查询参数,如 /users?sort=desc 中的 sort
    • @Url: 当需要动态指定完整 URL 时使用。
  • 请求体:
    • @Body: 指定一个对象作为请求体,通常用于 POSTPUT,会被 Converter 序列化。
    • @Field: 用于表单提交 (application/x-www-form-urlencoded),需要与 @FormUrlEncoded 配合使用。
  • 请求头: @Header, @Headers
// api declaration GithubApiService.kt
interface GithubApiService {
  @GET("users/{username}")
  fun getUser(@Path("username") username: String): Call<User>
}

Retrofit 类 (The Retrofit Class)

这是整个框架的入口,通过建造者模式 Retrofit.Builder 来配置和构建一个 Retrofit 实例。

主要配置项一般为:

  • baseUrl(): API 的基础 URL,必须以 / 结尾。
  • addConverterFactory(): 添加数据转换器工厂,用于序列化和反序列化。
  • addCallAdapterFactory(): 添加调用适配器工厂,用于支持不同的异步库。
  • client(): 传入一个自定义的 OkHttpClient 实例。
// create retrofit instance
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.github.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .build()

// create api service instance
val githubApiService = retrofit.create(GithubApiService::class.java)

转换器 (Converter)

负责将 Java 对象(POJO)与网络请求的 Body(如 JSON, XML)进行相互转换。 * 请求时:将 @Body 注解的对象序列化成网络请求体。 * 响应时:将网络响应体反序列化成接口方法定义的返回类型对象。 * 常用转换器: * converter-gson: 使用 Google 的 Gson 库。 * converter-moshi: 使用 Square 的 Moshi 库,性能更高,对 Kotlin 更友好。 * converter-jackson: 使用流行的 Jackson 库。

调用适配器 (Call Adapter)

这个配置决定了你接口方法的返回类型。默认情况下,方法返回 Call<T> 类型,通过添加适配器,你可以让方法直接返回其他类型,从而与现代异步编程范式结合。

示例

  • 不加适配器:fun getUser(): Call<User>
  • 使用协程:suspend fun getUser(): User (需要 CallAdapter 在幕后处理)
  • 使用 RxJava:fun getUser(): Single<User>

一个完整的 Kotlin + 协程示例

下面这个具体的例子,从 GitHub API 获取一个用户的信息。

第 1 步:添加依赖 (build.gradle.kts)

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0") // 请使用最新版本
// Gson Converter
implementation("com.squareup.retrofit2:converter-gson:2.11.0")
// OkHttp (可选,用于自定义配置和日志拦截器)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")

第 2 步:定义数据模型 (Data Class/POJO)

data class User(
    val login: String,
    val id: Int,
    val avatar_url: String,
    val name: String?,
    val company: String?
)

第 3 步:定义 API 接口

import retrofit2.http.GET
import retrofit2.http.Path

interface GithubApiService {
    /**
     * 使用 suspend 关键字,使其成为一个协程挂起函数
     * Retrofit 会自动处理线程切换和结果返回
     */
    @GET("users/{username}")
    suspend fun getUser(@Path("username") username: String): User
}

第 4 步:创建 Retrofit 实例 通常我们会把它放在一个单例或者依赖注入的模块中。

import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

object RetrofitClient {

    private const val BASE_URL = "https://api.github.com/"

    // 创建一个日志拦截器,用于打印网络请求和响应的日志
    private val loggingInterceptor = HttpLoggingInterceptor().apply {
        level = HttpLoggingInterceptor.Level.BODY // 打印最详细的日志
    }

    // 创建一个 OkHttpClient,并添加日志拦截器
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(loggingInterceptor)
        .build()

    val instance: GithubApiService by lazy {
        val retrofit = Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(okHttpClient) // 设置自定义的 OkHttpClient
            .addConverterFactory(GsonConverterFactory.create())
            // 注意:对于 suspend 函数,Retrofit 内部已经自动处理了 CallAdapter,无需显式添加
            .build()
        
        retrofit.create(GithubApiService::class.java)
    }
}

第 5 步:发起网络请求 在 ViewModel 或者其他有协程作用域的地方调用。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

class MyViewModel : ViewModel() {

    fun fetchGithubUser(username: String) {
        // 在 ViewModel 的协程作用域中启动一个新协程
        viewModelScope.launch {
            try {
                // 直接调用接口方法,就像调用一个普通函数一样
                val user = RetrofitClient.instance.getUser(username)
                
                // 请求成功,处理 user 数据
                println("User Name: ${user.name}")
                println("User Company: ${user.company}")
                
            } catch (e: Exception) {
                // 请求失败,处理异常
                println("Request failed: ${e.message}")
            }
        }
    }
}

这个例子清晰地展示了 Retrofit 的威力:定义接口、创建实例、然后直接调用方法。所有复杂的网络细节都被完美地隐藏了起来。

小结

Retrofit 不是一个网络请求库,而是一个网络请求的“封装”或“适配”库。 它站在巨人 OkHttp 的肩膀上,通过注解和动态代理技术,将开发者从繁琐的网络请求实现中解放出来,让我们能够用更符合直觉、更健壮、更易于维护的方式与 REST API 进行交互。

在今天的 Android 开发中,Retrofit + OkHttp + Kotlin Coroutines + Moshi/Gson 的组合已经成为应用架构网络层的“黄金标准”,是每个 Android 开发者都应该熟练掌握的技能。

Ktor

Ktor 是一个由 JetBrains 开发的,用于在 Kotlin 中构建连接应用的异步框架。它旨在提供一个轻量级、灵活且高度可扩展的网络应用框架,既可以用于构建服务器端应用(如 RESTful API、微服务、Web 网站),也可以用于构建多平台 HTTP 客户端应用。

Android平台上的基础使用

首先添加gradle依赖:

implementation "io.ktor:ktor-client-core:$ktor_version"
implementation "io.ktor:ktor-client-android:$ktor_version"

之后就可以使用HttpClient定制化,例如添加日志、内容协商、序列化等功能。然后就可以使用HttpClient发送请求了。

class KtorClient {

    companion object {
        const val TAG = "KtorClient"
    }

    private val client = HttpClient(CIO) {
        install(Logging) {
            level = LogLevel.ALL
        }
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
            })
        }
    }

    suspend fun getGithubRepos(userName: String) = withContext(Dispatchers.IO) {
        client.get("https://api.github.com/users/${userName}/repos")
            .body<List<GithubRepoItem>>()
    }

    fun release() {
        client.close()
    }
}

CIO 是 Ktor 自己的纯 Kotlin 实现的 I/O 引擎。它的设计目标是轻量级、无额外依赖、并完全基于 Kotlin 协程构建。这意味着它能最大程度地利用 Kotlin 协程的异步特性,提供高效且非阻塞的 I/O 操作。它直接利用 Kotlin 协程的调度和挂起机制来处理网络事件。它的内部实现尽可能地避免了阻塞操作,并且通过协程调度来管理并发连接。

设计理念

纯 Kotlin 和协程优先(Kotlin and Coroutines First)

Ktor 完全基于 Kotlin 语言构建,充分利用 Kotlin 的语言特性,例如 DSL(领域特定语言)、扩展函数、协程等。

Ktor 的异步编程模型是基于 Kotlin 协程实现的。这意味着开发者可以使用看似 同步的命令式代码来编写异步逻辑 ,极大地简化了并发编程的复杂性,避免了回调地狱,并提高了代码的可读性和可维护性。每个请求都会在 Ktor 中启动一个新的协程来处理,从而实现高效的并发。

轻量级和非侵入式(Lightweight and Unopinionated)

Ktor 不强加固定的项目结构或技术栈。它允许开发者根据自己的需求选择日志、模板引擎、消息队列、持久化、序列化、依赖注入等各种技术。

它提供了一个松散耦合的架构,你可以只使用你需要的功能,而不是一个庞大的全功能框架。这种灵活性使得 Ktor 非常适合构建微服务或需要高度定制化的应用。

Ktor 的 API 大多是函数调用和 Lambda 表达式,结合 Kotlin 的 DSL 能力,使代码看起来声明式且简洁。

高度可扩展性(Highly Extensible via Plugins/Features)

Ktor 采用 “插件” 机制来实现其核心功能和可扩展性。诸如内容协商、身份验证、日志、会话管理、压缩等功能都是通过安装插件来实现的。

这种统一的拦截机制允许在请求/响应处理管道的不同阶段插入自定义逻辑。开发者可以轻松地编写自己的插件来扩展 Ktor 的功能,或者集成第三方库。

多平台支持(Multiplatform Support)

Ktor 不仅仅局限于 JVM。Ktor Client 模块支持 Kotlin Multiplatform Mobile (KMM),允许在 Android、iOS、桌面以及服务器端共享网络逻辑。这使得 Ktor 成为构建跨平台应用的理想选择。

类型安全路由(Type-Safe Routing)

Ktor 提供了类型安全的路由机制,允许通过类而不是字符串来定义 API 结构和路由参数。这在编译时就可以验证路由参数和路径,减少了常见的运行时错误,并使得重构更加安全和可管理。

Pagination