Jetpack Compose重组优化:机制剖析与性能提升策略

前言

Jetpack Compose 通过声明式编程和数据驱动 UI 简化了 Android 开发。然而,高效利用其性能潜力需要深刻理解重组------即当状态变化时更新 UI 的过程。本文旨在深入探讨 Compose 的重组机制,并提供切实可行的优化策略,以避免常见性能陷阱,构建更流畅的应用。

一、Jetpack Compose 重组机制

Compose 会通过几个不同的阶段来渲染帧。比如Android View 系统有 3 个主要阶段:测量、布局和绘制。而Compose 和它非常相似,但开头多了一个叫做"组合"的重要阶段。

官方有一张图片很清楚表述了上述内容,我贴在下面:

Compose的 3 个主要阶段

  1. 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  2. 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。一般是父布局首先将自己的约束传给子布局,子布局测量自己,再将大小传给父布局,父布局再进行放置。(但另一些布局如LazyColumn不是这样。因为着重介绍重组性能优化,此处不展开)
  3. 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

二、Compose 重组优化策略

2.1 减小重组的范围

2.1.1 跳过可组合函数重组

可组合函数是否可跳过可以看下面的流程图

Compose跳过单个可组合函数重组的5个核心条件

  1. 调用点位置不变(第一个判断节点,"否"分支继续);
  2. 返回类型为Unit(第二个判断节点,"是"分支继续);
  3. 无禁止跳过的注解(第三个判断节点,"否"分支继续);
  4. 所有参数类型稳定(第四个判断节点,"是"分支继续;"否"分支需检查是否被标记为稳定);
  5. 参数值未更改(最后一个判断节点,"是"分支跳过重组)。

如果某种类型要被视为稳定类型,则必须符合以下协定:

  • 对于相同的两个实例,其 equals 的结果将始终相同。**
  • 如果类型的某个公共属性发生变化,组合将收到通知。
  • 所有公共属性类型也都是稳定。

除了上面的条件,还有一些是Compose 编译器直接视为稳定的类型:

  • 所有基元值类型:BooleanIntLongFloatChar 等。
  • 字符串
  • 所有函数类型 (lambda)

上面这些都比较好理解,着重讲下调用点和lambda参数

调用点

为了更直观地理解调用点的概念,我们用一个生活化的例子说明:

我们可以将界面UI的构成比作一辆长途客车:

  • 可组合函数 就像是客车上的座位 。它们的位置(在代码树中的位置)类型(是Text还是Button) 是相对固定的。
  • 每次组合时,生成并显示在座位上的数据(状态) 就像是乘客

重组(Recomposition) 就像是乘务员核对并更新乘客信息:

  1. 乘客信息变化了 (状态变化)-> 乘务员更新该座位对应的乘客信息表 -> 重组这个座位(可组合项)
  2. 座位本身发生了变化 (调用点变化,例如在 if 语句中动态插入或移除了一个可组合项)-> 整辆客车的座位布局关系变了 -> 乘务员需要更新整个座位关系表,并可能引发一系列座位的核对 -> 发生重组

跳过重组就像是:某个座位上的乘客信息没变,且这个座位在客车中的位置也没变 -> 乘务员就跳过这个座位的核对。

乘务员如何工作:

  1. 乘客信息没有更新,位置关系也没变化 -> 调用点、输入信息均无变化 -> 跳过重组
  2. 乘客信息没有更新,但位置关系变化了 -> 调用点变化 -> 执行重组
  3. 乘客信息变化,位置关系没变化 -> 输入信息更改 -> 执行重组
lambda参数

使用lambda参数有一点需要注意的地方,我们直接看一个简单的栗子

在页面上现在有红绿蓝3个按钮,按钮上面有一个色块用来显示更新的颜色,默认为红色。

代码如下:

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             ...
             val viewModel by viewModels<MainActivityViewModel>()
             RgbSelector(color = viewModel.color, onColorClick = {
                 viewModel.changeColor(it)  
             })
             ...
        }
    }
}
kotlin 复制代码
class MainActivityViewModel : ViewModel() {
    var color by mutableStateOf(Color.Red)
        private set

    fun changeColor(color: Color) {
        this.color = color 
    }
}
kotlin 复制代码
@Composable
fun RgbSelector(
    color: Color,
    onColorClick: (Color) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
        Box(modifier = Modifier
            .size(100.dp)
            .background(color))
        Spacer(modifier = Modifier.height(16.dp))
        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
            Button(
                onClick = {
                onColorClick(Color.Red)
            }) {
                Text(text = "Red")
            }
            Button(onClick = {
                onColorClick(Color.Green)
            }) {
                Text(text = "Green")
            }
            Button(onClick = {
                onColorClick(Color.Blue)
            }) {
                Text(text = "Blue")
            }
        }
    }
}

运行上面代码,点击绿色或蓝色按钮时,色块和所有按钮都会触发重组,但理想情况下,只需重组色块和被点击的按钮。

原因就是lambda参数传了{ viewModel.changeColor(it) }:

  • Kotlin编译器会将lambda函数编译为简单的匿名类
  • ViewModel会作为参数传递给这个匿名类
  • ViewModel因为是一个复杂对象,不会被标记为稳定类

具体看看{ viewModel.changeColor(it) }对应的Java代码:

csharp 复制代码
     new Function1() { // 无固定 receiver,每次创建新实例
         public void invoke_8_81llA(long it) {
             null.invoke$lambda$0(viewModel$delegate).changeColor-8_81llA(it); // 通过委托访问 viewModel
         }
     }
  • Lambda 捕获了 viewModel 变量(即使 viewModel 是 val,但其指向的对象可能变化)。
  • 每次父组件重组时,都会创建新的 Function1 实例(因为 lambda 重新声明)。
  • Compose 将其视为 不稳定参数

所以{ viewModel.changeColor(it) }每次重新组合时都会被视为一个新的实例,也就导致了RgbSelector不是全部参数类型稳定,然后整个RgbSelector都重组了。

下面是Layout Inspector查看RgbSelector重组情况。右下3个按钮组合次数都+1

修改方案有2种

  • 使用函数引用(Function Reference)语法,直接传递方法引用
  • 使用remember

传递方法引用

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             ...
             val viewModel by viewModels<MainActivityViewModel>()
             RgbSelector(color = viewModel.color, onColorClick = viewModel::changeColor)
             ...
        }
    }
}

对应的Java代码:

csharp 复制代码
   new Function1(invoke$lambda$0(viewModel$delegate)) { // 使用固定 receiver(viewModel 实例)
         public void invoke_8_81llA(long p0) {
             ((MainActivityViewModel) this.receiver).changeColor-8_81llA(p0); // 直接调用固定函数
         }
     }
  • viewModel::changeColor 是一个 函数引用,指向viewModel的固定方法。
  • 编译后生成单例Function1对象,实例不会变化(即使父组件重组)。
  • Compose 将其视为 稳定参数

下面是Layout Inspector查看RgbSelector重组情况,右下仅点击按钮组合次数+1

使用remember

对于无法使用 :: 的方式传入Lambda参数,可以使用remember

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             ...
             val viewModel by viewModels<MainActivityViewModel>()
             val changeColorLambda = remember<(Color) -> Unit> {
                 {
                     viewModel.changeColor(it)
                 }
             }
             RgbSelector(color = viewModel.color, onColorClick = changeColorLambda)
             ...
        }
    }
}

对应的Java代码:

ini 复制代码
// 检查是否已有记忆值
if (it$iv$iv == Composer.Companion.getEmpty()) { 
    // 首次组合:创建新的 Function1 实例
    Object value$iv$iv = new Function1() {
        public void invoke_8_81llA(long it) {
            null.invoke$lambda$0(viewModel$delegate).changeColor-8_81llA(it);
        }
        public Object invoke(Object p1) {
            this.invoke-8_81llA(((Color)p1).unbox-impl());
            return Unit.INSTANCE;
        }
    };
    
    // 存储实例以便后续重组复用
    $composer.updateRememberedValue(value$iv$iv); 
    var10000 = value$iv$iv;
} else {
    // 后续重组:复用已存储的实例
    var10000 = it$iv$iv; 
}

// 获取记忆的 lambda 实例
Function1 changeColorLambda = (Function1)var14; 

// 传递稳定的实例给 RgbSelector
RgbSelector(color = viewModel.color, onColorClick = changeColorLambda)

用 remember 让 lambda 参数为稳定类型的原因:重组会使用之前的实例

下面是Layout Inspector查看RgbSelector重组情况,右下仅点击按钮组合次数+1 在Android Studio中,我们可以使用Compose编译器报告,来快速查看哪些可组合函数是稳定/不稳定的

2.1.2 在列表中使用 key

LazyColumn等列表中,为每一项提供一个唯一且稳定的key。这帮助Compose在数据集变化(如排序、增删)时,准确识别出哪些项是新增、移动或移除的,从而重用现有组件实例,避免不必要的重组。

举个栗子

kotlin 复制代码
@Composable
fun LazyColumnItem(modifier: Modifier = Modifier) {
    var items by remember { mutableStateOf(listOf(1, 2, 3)) }
    Column(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
        LazyColumn {
            items(items) { item ->
                Text("Item $item")
            }
        }
        Button(onClick = {
            items = listOf(items.size + 1) + items
        }) { Text("add item") }
    }
}

点击按钮,添加一个Text 看看不使用Key重组情况 可以看到,每次点击按钮,之前的几个Text也会跟着重组。

下面再看看使用Key

kotlin 复制代码
@Composable
fun LazyColumnItem(modifier: Modifier = Modifier) {
    var items by remember { mutableStateOf(listOf(1, 2, 3)) }
    Column(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
        LazyColumn {
            items(items, key = { it }) { item -> // 1
                Text("Item $item")
            }
        }
        Button(onClick = {
            items = listOf(items.size + 1) + items
        }) { Text("add item") }
    }
}

只是在代码1处添加了key参数 可以看到,已经存在的Text,会自动跳过组合阶段的重新执行

2.1.3 状态下沉

原理: 状态下沉通过将状态移至实际使用它的最小可组合函数范围内,实现:

  1. 调用点保持不变:父组件调用结构不受影响,位置稳定性得以保留。
  2. 输入变化精准定位:状态变化被隔离在子组件内部,父组件输入参数未变。
  3. 跳过不必要的重组判断
    • 父组件满足调用点不变 + 输入未变 → 直接跳过整个父组件的重组
    • 子组件因直接绑定状态 → 仅执行必要的重组"

举个栗子

kotlin 复制代码
@Composable
fun ListItem(title: String) {
    var isClicked by remember { mutableStateOf(false) } // 状态放在ListItem(高层组件)
    Column {
        Text(text = title) // 不变的部分
        Button(onClick = { isClicked = !isClicked }) {
            Text(if (isClicked) "Clicked" else "Click Me") // 变化的部分
        }
    }
}

当点击按钮时,整个Column的作用域都会重新执行,但实际上只有Button需要重新执行,可以将isClicked下沉到Button中,从而优化重组性能

kotlin 复制代码
@Composable
fun ListItem(title: String) {
    Column {
        Text(text = title) // 不变的部分
        ClickableButton() // 状态下沉到该子组件
    }
}

@Composable
fun ClickableButton() {
    var isClicked by remember { mutableStateOf(false) } // 状态放在需要的子组件
    Button(onClick = { isClicked = !isClicked }) {
        Text(if (isClicked) "Clicked" else "Click Me") // 仅此处重组
    }
}

2.2 优化重组阶段

Compose的重组分为组合、布局、绘制三个阶段。我们应该尽量将工作从组合阶段转移到布局或绘制阶段。

2.2.1 使用Lambda处理动态Modifier

很多Modifier(如offsetgraphicsLayer)接受Lambda表达式。如果其值是动态的,使用Lambda形式可以让计算从组合阶段推迟到布局/绘制阶段。

举个栗子

kotlin 复制代码
@Composable
fun OffsetItem() {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        TestWithoutLambda()
        TestWithLambda()
    }
}

@Composable
fun TestWithoutLambda() {
    var offsetX by remember { mutableIntStateOf(0) }
    Text(
        text = "hello world",
        modifier = Modifier.offset(x = offsetX.dp, y = 0.dp)
    )
    Button(onClick = { offsetX += 100 }) { Text("Move") }
}

@Composable
fun TestWithLambda() {
    var offsetX by remember { mutableIntStateOf(0) }
    Text(
        text = "hello world",
        modifier = Modifier.offset { IntOffset(offsetX, 0) }
    )
    Button(onClick = { offsetX += 100 }) { Text("Move") }
}

在Column中分别使用offset的非Lambda和Lambda方式,点击按钮,偏移Text

使用Layout Inspector查看重组阶段组合的次数,可以看到使用Modifier.offset()时,Button和Text组合次数均+1,而offset使用Lambda方式则只有Button组合次数+1,Text 成功地跳过了本次重组的组合阶段。

我们来看看源码,为什么使用Modifier(如offset)的Lambda能跳过组合阶段

kotlin 复制代码
fun Modifier.offset(offset: Density.() -> IntOffset) =
    this then
        OffsetPxElement(
            offset = offset,
            ...
        )

private class OffsetPxElement(
    val offset: Density.() -> IntOffset,
    val rtlAware: Boolean,
    val inspectorInfo: InspectorInfo.() -> Unit,
) : ModifierNodeElement<OffsetPxNode>() {
    override fun create(): OffsetPxNode {
        return OffsetPxNode(offset, rtlAware) // 1
    }
    ...
}


private class OffsetPxNode(var offset: Density.() -> IntOffset, var rtlAware: Boolean) :
    LayoutModifierNode, Modifier.Node() {
    ...
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        return layout(placeable.width, placeable.height) {
            val offsetValue = offset()    // 2
            ...
        }
    }
}

Modifier.offset{}实现类是OffsetPxNode,将Lambda表达式传到了MeasureScope.measure中才执行,也就是布局阶段才会执行Lambda。

2.3 减少非必要的重组次数

2.3.1 避免重组循环

重组循环: 在重组过程中修改状态 → 导致输入变化 → 触发新一轮重组 我们直接看官网的栗子:

kotlin 复制代码
Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

在第一帧的组合阶段,imageHeightPx 最初为 0。因此,该代码会提供带有 Modifier.padding(top = 0) 的文本。后续的布局阶段会调用 onSizeChanged 修饰符的回调,该回调会将 imageHeightPx 更新为图片的实际高度。这会更新 imageHeightPx 的值,从而发生第二帧,也就触发新一轮重组。

这是一个典型的重组循环案例。在实际开发中,应尽量避免这种在布局阶段回调中修改状态触发新一轮组合的模式。更推荐使用 Compose 的布局系统本身来解决这类问题,例如使用 Column 的天然流式布局特性,或者利用 Modifier.weight() 等来动态分配空间,从而无需手动计算和设置高度/间距。

2.3.2 使用derivedStateOf合并高频状态更新

使用场景:状态或键的变化超过想要更新 UI 时

举个栗子:向下滚动,列表的第一项已经不可见时,在滚动列表右侧显示一个按钮

kotlin 复制代码
@Composable
fun DerivedStateOfItem(modifier: Modifier = Modifier) {
    val listState = rememberLazyListState()
    val list = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)
    Row {
        LazyColumn(state = listState) {
            items(list, key = { it }) { item ->
                Text("Item $item", modifier = modifier.height(150.dp))
            }
        }
        val showButton = listState.firstVisibleItemIndex > 0
        // Log.d("DerivedStateOfItem", "ComposeRecomposition")
        if (showButton) {
            Button(onClick = {}) {
                Text("button")
            }
        }
    }
}

可以从上图看出,每次向下滚动一个子项是,都会发生重组。也可以log日志打开,每次向下滚动一个子项,都会打印一次日志。

但实际上我们列表的第一项已经不可见时重组,后续向下滚动都不需要重组了。这里就是状态变化超过想要更新 UI。

使用derivedStateOf修改

kotlin 复制代码
@SuppressLint("FrequentlyChangingValue")
@Composable
fun DerivedStateOfItem(modifier: Modifier = Modifier) {
    ...
    val showButton by remember {
        derivedStateOf {
            // 只有条件变化时才会触发重组
            listState.firstVisibleItemIndex > 0
        }
    }
    ...
}

现在就仅有 listState.firstVisibleItemIndex > 0变化时才会重组了。

三、Compose重组优化工具:布局检察器、编译器报告

3.1 Layout Inspector

在第2小节的一些示例中,我们使用了Layout Inspector的重组计数功能来量化优化效果。

以下是它的详细操作步骤:

3.1.1 打开Layout Inspector:运行项目到模拟器,点击下图中的图标

可能会报错:AS Layout Inspector报错:Could not download androidx.compose.ui;ui-android:1.6.6 from maven.google.com, Check the internet connection For offline repositories (not common) please specify -Dappinspection.use.dev,ar=true as a custom VM property.
解决办法: zhuanlan.zhihu.com/p/661454651

3.1.2 查看Layout Inspector界面,下图红框部分

3.1.3 点击app中的组件,查看Layout Inspector页面变化

图中1,2,3含义

  • 1. Compositions (组合次数) :此可组合项进入组合阶段的总次数。这包括了初始组合和所有后续的重组。
  • 2. Recomposing Children (重组子项次数) :此可组合项的直接子项进入组合阶段的总次数。这是一个累计值,有助于了解变更的影响范围。
  • 3. Skipped (跳过次数) :此可组合项在可能发生重组时,被成功跳过组合阶段的次数。这个数值越高,通常意味着性能优化得越好。

它适合用于对比优化前后的差异,可以直观验证策略有效性。

3.2 Compose 编译器报告

3.2.1 集成

在每个模块的 build.gradle 文件中添加以下内容

kotlin 复制代码
  android { ... }

  composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
  }

这是在 AGP 8.0 及更高版本中的配置方式。如果您使用的是较早版本的 Android Gradle 插件,可能需要使用 reportsDestination = file("$buildDir/compose_compiler") 的格式。

3.2.2 构建项目时会生成 Compose 编译器报告

reportsDestination 会输出三个文件。

  • <modulename>-classes.txt 关于本模块中类稳定性的报告。
  • <modulename>-composables.txt 关于模块中可组合项的可重启和可跳过程度的报告。。
  • <modulename>-composables.csv 可组合项报告的 CSV 版本,您可以将其导入电子表格或使用脚本进行处理。
  • <modulename>-module.json 编译阶段的优化指标
可组合项报告

composables.txt 文件中会详细说明给定模块的每个可组合函数的相关情况,包括其参数的稳定性以及它们是否可重启或可跳过

kotlin 复制代码
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun RgbSelector(
  stable color: Color
  stable onColorClick: Function1<Color, Unit>
  stable modifier: Modifier? = @static Companion
)

可以看到2.1.1小节用到的RgbSelector是可重启、可跳过,并且都是稳定参数。

类报告

文件 classes.txt 中包含关于给定模块中的类的类似报告。以下代码段是针对类 MainActivityViewModel 的输出:

kotlin 复制代码
stable class MainActivityViewModel {
  stable var color$delegate: MutableState<Color>
  stable var colorLambda$delegate: MutableState<Color>
  stable var colorRemember$delegate: MutableState<Color>
  <runtime stability> = Stable
}

2.1.1小节用到的MainActivityViewModel也是稳定的,代码如下:

kotlin 复制代码
class MainActivityViewModel : ViewModel() {
    var color by mutableStateOf(Color.Red)
        private set

    fun changeColor(color: Color) {
        this.color = color 
    }
}
优化指标

module.json是整个项目当前的一个概要

json 复制代码
{
 "skippableComposables": 17,    // 可跳过重组的函数
 "restartableComposables": 26,
 "readonlyComposables": 0,
 "totalComposables": 26,
 "restartGroups": 26,
 "totalGroups": 31,
 "staticArguments": 35,
 "certainArguments": 1,
 "knownStableArguments": 346,
 "knownUnstableArguments": 1,   // 不稳定参数数量
 "unknownStableArguments": 0,
 "totalArguments": 347,
 "markedStableClasses": 0,
 "inferredStableClasses": 2,  // 稳定类识别
 "inferredUnstableClasses": 0,
 "inferredUncertainClasses": 0,
 "effectivelyStableClasses": 2,
 "totalClasses": 2,
 "memoizedLambdas": 20, // 记忆化Lambda
 "singletonLambdas": 1,
 "singletonComposableLambdas": 7,
 "composableLambdas": 9,
 "totalLambdas": 21 // 总的Lambda
}

需要着重关注的几个数据:

  1. knownUnstableArguments(不稳定参数数量)
  2. skippableComposables(可跳过重组的函数)
  3. restartGroups / totalGroups(重启作用域效率)
  4. memoizedLambdas(记忆化Lambda)
  5. inferredStableClasses(稳定类识别)

四、总结与延伸

本文我们深入探讨了Compose的核心------重组机制的优化策略。通过上述方法,我们能有效提升重组性能。需要注意的是,一个完整的性能优化体系还包含:

  • 布局阶段优化: 例如使用 ConstraintLayout 减少测量次数,扁平化布局层级。
  • 绘制阶段优化: 例如使用 drawWithCache 复用昂贵对象,避免在 draw{} 块中频繁分配内存。
  • 遵守阶段职责: 这是一条绝对的原则 :严禁在 @Composable 函数或布局修饰符中执行任何 I/O 操作、密集计算或分配大量临时对象,这些阻塞性操作会直接摧毁整个渲染管线的性能,与重组次数无关。

注:文中优化方式涉及的代码(优化前后)已重新整合到 Demo

五、参考资料

  1. Jetpack Compose 官方文档
  2. Philipp Lackner - Jetpack Compose Recomposition Explained
  3. Compose 官方备忘录 - 何时使用 derivedStateOf
  4. Compose Snapshots: we got THE expert to go in-depth - with Chuck Jazdzewski
相关推荐
DemonAvenger19 小时前
分库分表实战:应对数据增长的扩展策略
数据库·sql·性能优化
全栈技术负责人21 小时前
webpack性能优化指南
webpack·性能优化·devops
百思可瑞教育1 天前
前端性能优化:请求和响应优化(HTTP缓存与CDN缓存)
前端·网络协议·http·缓存·性能优化·北京百思可瑞教育·百思可瑞教育
alexhilton1 天前
Android ViewModel数据加载:基于Flow架构的最佳实践
android·kotlin·android jetpack
DemonAvenger2 天前
从 MySQL 5.x 到 MySQL 8:新特性解析与升级实战指南
数据库·mysql·性能优化
月弦笙音2 天前
【Vite】vite常用配置,一篇即可通吃
前端·性能优化·vite
blueSky-fan2 天前
后端一次性返回十万条数据时,前端需要采用多种性能优化策略来避免页面卡顿
性能优化
不爱说话郭德纲2 天前
还记得第一次遇到内存泄漏的场景嘛?
前端·面试·性能优化