Compose编程思想 -- Compose中重组风险和优化

前言

在上一篇文章Compose编程思想 -- 深入理解声明式UI的状态订阅与自动更新 中,我详细介绍了Compose作为声明式UI框架,如何完成数据的自动刷新,常用的就是通过mutableStateOfmutableStateListOf的订阅能力实现对数据变化的监听,那么本节将会介绍从触发重组到重组操作的执行过程。

1 重组的风险和优化

来看一个非常简单的例子,通过Text显示一个文案,当点击文案时,刷新为最新的数据。

kotlin 复制代码
setContent {
    ComposeStudyTheme {
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
        })
    }
}

那么在这个过程中,当点击Text时,将name设置为新值,此时Text使用了name,那么Text所在的作用域,也就是ComposeStudyTheme会触发重组,当屏幕下一帧刷新的时候,重组作用域内的所有代码都会执行。

接下来,我们使用Column替换ComposeStudyTheme,如果按照我们的之前的设想,当name发生变化时,那么在只有Column大括号内部的代码会重组,然而是这样吗?

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    Column {
        Log.d("TAG", "onCreate: composition 2")
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
        })
    }
}

通过打印日志发现,当name发生变化之后,在Column外层的组件也发生了重组,其实我们只需要Text刷新即可,但是换了Column之后,导致重组作用域变成了setContent,其实处理一些不必要的刷新,带来系统资源的浪费。

js 复制代码
2024-03-19 15:35:45.339 20899-20899 TAG                     com.lay.composestudy                 D  onCreate: composition 1
2024-03-19 15:35:45.340 20899-20899 TAG                     com.lay.composestudy                 D  onCreate: composition 2

那么为什么会这样呢?我们看下Column的源码:

kotlin 复制代码
@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {
    val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
    Layout(
        content = { ColumnScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

我们发现Column是内联函数,这就意味着编译器在执行的时候,其实Column这个大括号是不存在的,相当于直接加到了setContent代码中,因此在判断重组作用域的时候,需要注意一下父容器是否为内联函数,如果为内联函数,那么重组作用域将会依次往上提升,直到达到非内联函数为止。

1.1 重组的风险

前面我们介绍了如何判断重组作用域的范围,因为Compose自身的特性,可能会导致重组作用域扩大,引发一些不必要的刷新,从而影响系统的性能。

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    NoParamsCompose()
    Column {
        Log.d("TAG", "onCreate: composition 2")
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
        })
    }
}

@Composable
fun NoParamsCompose() {
    Log.d("TAG", "NoParamsCompose: ----composition")
    Text(text = "我没有参数")
}

看下上面的例子,既然使用了Column组件导致重组作用域变大,那么NoParamsCompose在重组的过程中必然会被执行,但是运行之后发现,NoParamsCompose在重组的时候并没有执行。

我们之前担心的问题并没有出现,是我们代码写的有问题吗?其实并不是,而是Compose自身的性能优化带来的。Compose在重建的过程中,会比较Composable函数的参数与上次是否发生了变化,如果没有发生变化,那么在重组的过程中就会跳过代码的执行。

NoParamsCompose函数没有参数,因此并没有参数变化这一说,因此在重组的过程中便会跳过执行。

kotlin 复制代码
@Composable
fun NoParamsCompose(content: String) {
    Log.d("TAG", "NoParamsCompose: ----composition")
    Text(text = "我有参数 $content")
}

现在我们对NoParamsCompose改造一下,加了一个参数,那么如果在代码中入参写死,重组的过程中依然会跳过;如果将入参改为name,那么在重组的过程中就会刷新组件。

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    NoParamsCompose("Alex ooo")
    Column {
        Log.d("TAG", "onCreate: composition 2")
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
        })
    }
}

那么问题来了!

1.2 如何判断Composable函数入参是否变化?

其实在Compose当中,是通过结构性判断Composable函数的入参是否发生变化,即通过equals来判断内容是否发生变化,如果没有变化,那么在重组的过程中就会跳过。

前面我们验证了基本数据类型,那么如果是实体对象,还能经得起验证吗?

kotlin 复制代码
data class Weather(
    val city:String,
    val temperature:Int
)

通过ShowWeather可组合函数来展示一个地区的温度,当点击Text触发重组之后,即便是每次调用ShowWeather时,都会新建一个Weather对象,但是并没有触发重组,这就说明Compose对于入参的判断就是通过结构化检测,即equals检测。

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    Column {
        Log.d("TAG", "onCreate: composition 2")
        ShowWeather(weather = Weather("北京", 12))
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
        })
    }
}

@Composable
fun ShowWeather(weather: Weather) {
    Log.d("TAG", "ShowWeather: ----composition")
    Column {
        Text(text = "城市:${weather.city}")
        Text(text = "温度:${weather.temperature}")
    }
}

何为equals检测,就是检查Weather的成员变量前后是否发生了变化,虽然前后两次对象的引用都发生了变化,但是内容是一致的,data class是默认重写了equals方法,不需要我们自己手动处理

kotlin 复制代码
class Weather(
    val city: String,
    val temperature: Int
) {
    override fun equals(other: Any?): Boolean {
        return city == (other as Weather).city
                && temperature == (other as Weather).temperature
    }

    override fun hashCode(): Int {
        return super.hashCode()
    }
}

但是当我们将参数的val变为var之后,发现在重组的时候,ShowWeather可组合函数内部的代码被执行了,但是内容并没有发生变化。

kotlin 复制代码
data class Weather(
    var city:String, 
    var temperature:Int
)

其实这也是Compose做的一个安全机制 ,因为当参数变为var的时候,它可以在任何地方都被修改,它不能保证一直不变,因此Compose会无脑刷新页面,从而保证一直持有最新的值

kotlin 复制代码
var w = Weather("北京", 12)
val w2 = Weather("北京", 12)
val w3 = Weather("北京", 12)
w = w2

setContent {
    Log.d("TAG", "onCreate: composition 1")
    Column {
        Log.d("TAG", "onCreate: composition 2")
        ShowWeather(weather = w)
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
            w = w3
        })
    }
}

w3.city = "上海"

1.3 @Stable注解使用

那我们会想,只要有被var修饰的成员,就认为这个类不可靠,在重组的时候Compose就会强制侵入本不需要刷新的Composable代码中,未免有些太暴力,那么有什么手段能够阻止吗?

kotlin 复制代码
@Stable
data class Weather(
    var city:String,
    var temperature:Int
)

Compose中将不可靠的对象,转换为可靠的对象,就是使用@Stable注解,它会告诉Compose编译器,Weather这个类从始至终都不会发生变化,当然这个保证需要我们自己做控制,保证不会在任何地方对其做修改。那么Compose就会在重组的时候,跳过执行。

但是作为程序员的我们,保证前后两个对象永远不发生变化,一直相等,其实还是很难的 ,而且在多方合作中也很容易打破这个规则,所以我们的前提是先保证安全性,然后再考虑性能,因此不再重写equals方法,只要两个对象不相同就刷新,我们只保证同一个对象就跳过刷新即可

kotlin 复制代码
@Stable
class Weather(
    var city:String,
    var temperature:Int
)

因此不再使用data class(默认重写了equals),而是使用普通的class类即可。

kotlin 复制代码
//@Stable
class Weather(
    city: String,
    temperature: Int
) {
    // 通过mutableStateOf修饰
    
    var city by mutableStateOf(city)
    var temperature by mutableStateOf(temperature)
}

如果我们不想使用@Stable注解,那么对于类中的public成员变量使用mutableStateOf存储,那么Compose也会认为这个类是可靠的,在重组的时候,会跳过执行。

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    Column {
        Log.d("TAG", "onCreate: composition 2")
        ShowWeather(weather = w)
        // 重组作用域
        var name by remember {
            mutableStateOf("Alex")
        }
        Text(text = name, Modifier.clickable {
            name = "Alex ++"
            w.city = "上海"
        })
    }
}

而且,在类中的成员变量发生变化的时候,也会触发重组进行刷新,所以使用这种写法的好处就是:

  • 在成员变量不变的情况下是稳定的,recomposition的过程中可以跳过;
  • 在成员变量变了的情况下,会触发重组,进行页面的刷新;

所以@Stable不建议使用得原因:因为我们自己无法保证做到完全的不可变。

2 derivedStateOf和remember的比较

在之前的文章中,我介绍了remember的使用场景,主要用于修饰mutableStateOf,防止被多次初始化影响界面刷新,这一小节中将会介绍derivedStateOf的用法,以及和remember的区别。

2.1 derivedStateOf

derived词义为衍生的,派生的。derivedStateOf从官方意思来看:state就是由一个或者多个state对象 派生或者衍生出来的,当任意state对象发生变化时,衍生state对象都会重新计算,并拿到一个最新状态的值

kotlin 复制代码
fun <T> derivedStateOf(
    calculation: () -> T,
): State<T> = DerivedSnapshotState(calculation, null)

从源码看,当state对象发生变化时,会重新执行calculation中的代码。

kotlin 复制代码
@Composable
fun ShowCity(data: MutableList<String> = mutableListOf("北京", "上海", "杭州")) {
    // 被移除的城市
    val removeCity = remember { mutableStateListOf<String>() }
    val showCity = remember {
        derivedStateOf {
            // 如果removeCity发生改变,这里会重新计算
            for (city in removeCity) {
                if (data.contains(city)) {
                    data.remove(city)
                }
            }
            //返回筛选后的data
            data
        }
    }
    LazyColumn {
        // 注意 LazyColumn不是内联函数,因此重组只会走这块区域
        items(showCity.value) {
            Text(text = it, Modifier.clickable {
                removeCity.add(it)
            })
        }
    }

}

所以根据derivedStateOf的特性,需要一个状态依赖另一个状态,我这里写了一个列表用于展示城市信息,当点击任意一个城市的时候,会将城市信息添加到removeCity中,从而触发了重组。

当重组的时候,执行到derivedStateOf,因为showCity的状态依赖于removeCity,而且在触发重组的时候,removeCity发生了变化,因此会执行derivedStateOf代码块中的代码,并将删除的城市信息从data中移除,此时showCity中的数据被删除了一条,再刷新展示的时候这一条数据将不再显示在屏幕上。

那么问题来了,为什么要使用derivedStateOf,好像使用mutableStateListOf也能实现这个功能,那么到底什么时候才会使用derivedStateOf

2.2 derivedStateOf的使用场景

首先我们先看一个简单的例子:

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    val data = remember {
        mutableStateListOf("北京", "上海", "杭州")
    }

    val showData = remember(data) {
        Log.d("TAG", "onCreate: add val")
        data.map {
            it.plus(" ++")
        }
    }

    Column {
        showData.forEach {
            Text(text = it,Modifier.clickable {
                data.add("苏州")
            })
        }
    }
}

关键看下showData这个变量,与以往不同的是,remember采用了下面的方法进行调用,将data属性作为了key。

kotlin 复制代码
/**
 * Remember the value returned by [calculation] if [key1] is equal to the previous composition,
 * otherwise produce and remember a new value by calling [calculation].
 */
@Composable
inline fun <T> remember(
    key1: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T
): T {
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}

官方的解释:

如果key值与组建的时候一样,没有发生变化,那么就返回缓存中的值,不会执行calculation中的代码;否则将会执行calculation中的代码,重新生成一个新的值。

看官方的解释,哎,好像跟derivedStateOf有点相似,derivedStateOf也是当内部的state发生变化的时候,执行calculation中的代码,那么我直接使用remember这种方式就好了,但是是这样的吗?

回到本小节开头的例子,当点击Column中的item时,会将data中添加一个元素,而showData则是将数据进行了一次转化,添加了++,当运行时我们发现并没有新增一条item。

data数据发生了变化,但是showData没有生成新的数据。那么这就是remember对于key是否相等的判断出现了问题,一般对象变化主要分为两种:

  • 直接赋值,例如给String赋值新值;
  • 对象内部变化,例如List增加或者删除一个元素。

就这个例子来看,data内部增加一个元素,但是data得引用还是没变,还是认为是一个对象,那么自然是无法生成一个新值,因此需要通过derivedStateOf装饰一下,用来处理因为内部数据发生变化时,remember认为没有发生变化,例如List的添加和删除操作。

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    val data = remember {
        mutableStateListOf("北京", "上海", "杭州")
    }

    val showData = remember(data) {
        Log.d("TAG", "onCreate: add val")
        derivedStateOf {
            data.map {
                it.plus(" ++")
            }
        }
    }

    Column {
        showData.value.forEach {
            Text(text = it, Modifier.clickable {
                data.add("苏州")
            })
        }
    }
}

2.3 derivedStateOf注意事项

先看看起来,derivedStateOf好像比remember(key)更加灵活,但是实际的场景中,我们能无脑使用derivedStateOf吗,看下面的例子:

kotlin 复制代码
@Composable
fun ShowUp() {
    val name = remember {
        mutableStateListOf("Alex", "Tom")
    }
    val showName = remember {
        derivedStateOf {
            name.map {
                it.uppercase()
            }
        }
    }
    Column {
        showName.value.forEach {
            Text(text = it, Modifier.clickable {
                name.add("Jerry")
            })
        }
    }
}

showName用于将英文变为大写,然后点击列表中的Item新增一个元素Jerry,此时name发生变化,Compose监听到从而导致showName重新计算,发生重组后新增了一条元素。

此时ShowUp是有状态的,那么我们将状态提升,将ShowUp变成无状态。

kotlin 复制代码
@Composable
fun ShowUp(name: String, onClick: (() -> Unit)? = null) {
    Log.d("TAG", "ShowUp: name-- $name")
    val showName by remember {
        // 第一次初始化会进来
        derivedStateOf { name.uppercase() }
    }
    Text(text = showName, Modifier.clickable {
        onClick?.invoke()
    })

}

首先我们先看一个最简单的例子,当点击某个Text之后,将Text的文案替换并且目的是将文案转成大写。

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    
    var name by remember {
        mutableStateOf("Alex")
    }
    ShowUp(name) {
        name = "Tom"
    }
}

我们在使用的时候如上所示,当点击Text时,我们将name赋值为Tom,此时会触发重组,重组作用域是调用name的父作用域,也就是setContent,此时ShowUp会再次执行,我们发现文案并没有像我们预想的那样变化。

为什么没有变化呢?其实当重组时执行ShowUp,此时作为参数传入的name只是一个代理值,就是一个String类型的value,那么在ShowUp中,derivedStateOf就无法对其进行监听,从而导致数据一直没有刷新。

解决这个问题的方案有两种:

  • ShowUp参数改为State对象,从而穿透Composable函数,在内部derivedStateOf就可以对其完成订阅,但不建议这么做,因为传值只能传递State对象,不具备通用性;
  • 采用remember(key)的方式订阅。
kotlin 复制代码
@Composable
fun ShowUp(name: String, onClick: (() -> Unit)? = null) {
    Log.d("TAG", "ShowUp: name-- $name")
    val showName = remember(name) {
        name.uppercase()
    }
    Text(text = showName, Modifier.clickable {
        onClick?.invoke()
    })

}

采用remember(key)的方式,当重组时进入ShowUp,remember会检查前后两次name是否发生变化,显然是变化了,因此页面完成了刷新。

回到本小节开始的例子:

kotlin 复制代码
@Composable
fun ShowUp(name: MutableList<String>, onClick: (() -> Unit)? = null) {
    val showName = remember(name) {
        name.map {
            it.uppercase()
        }
    }
    Column {
        showName.forEach {
            Text(text = it, Modifier.clickable {
                onClick?.invoke()
            })
        }
    }
}

当完成状态提升之后,在Composable函数内部还是采用remember(key)这种形式,很显然按照我们之前的结论,因为List修改属于内部改变,通过remember(key)校验前后两个key是一致的,从而不会刷新新值,需要使用derivedStateOf

kotlin 复制代码
// 注意这里不再是代理,而是=,会穿透Composable函数,derivedStateOf能够订阅这个变量
val name = remember {
    mutableStateListOf("Alex", "Tom")
}

ShowUp(name) {
    name.add("Jerry")
}

那么有些伙伴可能会有疑问,ShowUp函数参数为List类型,能够被derivedStateOf订阅?其实我们需要看传参的地方,不再是通过by拿到代理的value,而是货真价实的State对象,mutableStateListOf具备订阅的能力,而且继承自MutableList

kotlin 复制代码
@Composable
fun ShowUp(name: MutableList<String>, onClick: (() -> Unit)? = null) {
    val showName by remember {
        derivedStateOf {
            name.map {
                it.uppercase()
            }
        }
    }
    Column {
        showName.forEach {
            Text(text = it, Modifier.clickable {
                onClick?.invoke()
            })
        }
    }
}

那么这样真能做到万无一失吗👆🏻?

kotlin 复制代码
setContent {
    Log.d("TAG", "onCreate: composition 1")
    var count by remember {
        mutableStateOf(0)
    }
    val name = remember(count) {
        if (count > 5){
            mutableStateListOf("Alex", "Tom")
        }else{
            mutableStateListOf("北京","上海","广州")
        }
    }
    ShowUp(name) {
        count++
    }
}

假设对于ShowUp函数的入参有变化,那么derivedStateOf只会对第一个name订阅,后续name发生了变化都不会刷新,只会刷新第一个列表数据,因此官方的案例中建议我们这么写:

kotlin 复制代码
@Composable
fun ShowUp(name: MutableList<String>, onClick: (() -> Unit)? = null) {
    val showName by remember(name) {
        derivedStateOf {
            name.map {
                it.uppercase()
            }
        }
    }
    Column {
        showName.forEach {
            Text(text = it, Modifier.clickable {
                onClick?.invoke()
            })
        }
    }
}

derivedStateOfremember(key)一起使用,能够保证在name发生变化时,重新计算帮助derivedStateOf重新订阅,从而保证数据的准确刷新:不管是换了新对象,还是内部的状态发生了变化,Compose都可以监听到。

3 CompositionLocal

这又是一个新的概念,从字面意思上来看,就是Composition的局部变量

kotlin 复制代码
setContent {
    val name = "Alex"
    ShowText(name = name)
}
kotlin 复制代码
@Composable
fun ShowText(name: String) {
    Text(text = name)
}

setContent重组作用域内,name属于这个作用域内的局部变量,在这个作用域外的成员无法直接调用,包括ShowText可组合函数。

那么假设,我想把ShowText中的参数去掉,并且可以在ShowText中使用setContent重组作用域中的局部变量,看似天方夜谭,实则Compose已经帮我们做好了。

3.1 CompositionLocal的使用

CompositionLocalProvider可以看做是Composition局部变量的提供者,在其提供的content作用域内执行的Composable函数,局部变量具有穿透性,即在Composable函数中可以直接使用局部变量。

kotlin 复制代码
@Composable
@OptIn(InternalComposeApi::class)
fun CompositionLocalProvider(vararg values: ProvidedValue<*>, content: @Composable () -> Unit) {
    currentComposer.startProviders(values)
    content()
    currentComposer.endProviders()
}
  • 首先需要定义一个Composition局部变量,可以通过compositionLocalOf来创建。
kotlin 复制代码
val LocalName = compositionLocalOf<String> { error("LocalName is not init") }
  • 通过CompositionLocalProvider来给局部变量LocalName赋值,可以使用provides函数来为其赋值。这里需要注意,如果想要使用LocalName,那么就需要将@Composable函数放在CompositionLocalProvider作用域内。
kotlin 复制代码
CompositionLocalProvider(LocalName provides "Alex") {
    ShowText()
}
  • 获取值,可以通过CompositionLocal的current成员对象来获取。
kotlin 复制代码
@Composable
fun ShowText() {
    Text(text = LocalName.current)
}

以上就是CompositionLocal的简单使用,通过这种方式可以移除@Composable函数中的参数,直接调用局部变量的成员对象。

3.2 CompositionLocal有什么用?

CompositionLocal的使用中我们大概能够知道,CompositionLocal是一个具有穿透功能的局部变量,从而取代@Composable函数中的参数,在日常的开发中,我们已经熟悉了通过传值的方式进行透传,而如果使用CompositionLocal取代全部的入参,那么可能会影响到更大的范围,因为我们如果采用传参的方式,那么只会影响一个组合函数的内部执行,而CompositionLocal属于作用域内部的全局变量,在任意组合中改动,都可能会影响到其他的组合。

因此使用CompositionLocal一般是用来表示上下文、环境、主题等可能会在组合函数中使用到,也有可能使用不到,而一定会在组合函数中使用到的参数,请在组合函数中显示地声明。

kotlin 复制代码
val LocalActivity = compositionLocalOf<Activity> { error("LocalActivity error") }


CompositionLocalProvider(LocalActivity provides this) {
    ShowText2()
}

像如果使用LocalActivity来表示全局的上下文,那么在CompositionLocalProvider作用域内,所有的组合函数都可以拿到上下文使用,这也避免了我们在ShowText2中传入context参数,其实我们在开发中也是尽可能地减少这种参数传递,经常会在一个静态类中注入Context,然后直接调用,不知道是否有这么干的伙伴。

除了上下文,我们还可以定义主题,例如:

kotlin 复制代码
// 当前主题的局部变量
val LocalBackground = compositionLocalOf<Color> { error("LocalBackground error") }
kotlin 复制代码
CompositionLocalProvider(LocalBackground provides Color.Blue) {
    ShowBackground()
}


@Composable
fun ShowBackground() {
    Text(text = "检测文案展示", modifier = Modifier.background(LocalBackground.current))
}

假设这个页面的主题颜色为蓝色,那么就可以使用LocalBackground定义主题颜色,并且在ShowBackground中可以直接使用这个局部变量,好处是:当UI发生变更,主题色变为红色,那么只需要修改LocalBackground即可。

假设我们定义一个App的主题:

kotlin 复制代码
//定义局部变量
val LocalBackground = compositionLocalOf<Color> { error("") }
val LocalTextSize = compositionLocalOf<TextUnit> { error("") }
val LocalTextColor = compositionLocalOf<Color> { error("") }

@Composable
fun MyAppTheme(
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalBackground provides Color.Blue,
        LocalTextSize provides 20.sp,
        LocalTextColor provides Color.Black,
    ) {
        content.invoke()
    }
}

那么我们在使用的时候,就可以将其放在setContent之下,那么所有的组件都可以使用这些主题资源信息。

kotlin 复制代码
setContent {
    MyAppTheme {
        Column(Modifier.background(LocalBackground.current)) {
            Text(
                text = "测试主题1",
                color = LocalTextColor.current,
                fontSize = LocalTextSize.current
            )
            Text(
                text = "测试主题2",
                color = LocalTextColor.current,
                fontSize = LocalTextSize.current
            )
        }

    }
}
相关推荐
C4rpeDime2 小时前
自建MD5解密平台-续
android
鲤籽鲲4 小时前
C# Random 随机数 全面解析
android·java·c#
m0_548514777 小时前
2024.12.10——攻防世界Web_php_include
android·前端·php
凤邪摩羯8 小时前
Android-性能优化-03-启动优化-启动耗时
android
凤邪摩羯8 小时前
Android-性能优化-02-内存优化-LeakCanary原理解析
android
喀什酱豆腐8 小时前
Handle
android
m0_7482329210 小时前
Android Https和WebView
android·网络协议·https
m0_7482517210 小时前
Android webview 打开本地H5项目(Cocos游戏以及Unity游戏)
android·游戏·unity
m0_7482546612 小时前
go官方日志库带色彩格式化
android·开发语言·golang
zhangphil12 小时前
Android使用PorterDuffXfermode模式PorterDuff.Mode.SRC_OUT橡皮擦实现“刮刮乐”效果,Kotlin(2)
android·kotlin