江江说技术
Jetpack Compose 中优雅完成数据持久化
Compose出来也好久了,各种remember和LocalXXX.current也是用得越来越熟。如果能在保持上述写法一致性的情况下完成数据的持久化工作,不是显得挺优雅的吗?基于此,我写出了开源库:ComposeDataSaver: 在Jetpack Compose中优雅完成数据持久化简单一瞥:// booleanExample 初始化值为false
// 之后会自动读取本地数据
var booleanExample by rememberDataSaverState(KEY_BOOLEAN_EXAMPLE, false)
// 直接赋值即可完成持久化
booleanExample = 可还行?ComposeDataSaver项目有以下特点:简洁:近似原生的写法低耦合:抽象接口,不限制底层保存算法实现轻巧:默认不引入除Compose外任何第三方库灵活:支持基本的数据类型和自定义类型引入在settings.gradle引入jitpack仓库位置 dependencyResolutionManagement {
repositories {
maven { url "https://jitpack.io" }
}
}在项目build.gradle引入 dependencies {
implementation 'com.github.FunnySaltyFish:ComposeDataSaver:v1.0.0'
}基本使用项目使用DataSaverInterface接口的实现类来保存数据,因此您需要先提供一个此类对象。项目默认包含了使用Preference保存数据的实现类DataSaverPreferences,可如下初始化: // init preferences
val dataSaverPreferences = DataSaverPreferences().apply {
setContext(context = applicationContext)
}
CompositionLocalProvider(LocalDataSaver provides dataSaverPreferences){
ExampleComposable()
}此后在ExampleComposable及其子微件内部可使用LocalDataSaver.current获取当前实例对于基本数据类型(如String/Int/Boolean): // booleanExample 初始化值为false
// 之后会自动读取本地数据
var booleanExample by rememberDataSaverState(KEY_BOOLEAN_EXAMPLE, false)
// 直接赋值即可完成持久化
booleanExample = true就这么简单!自定义存储框架只需要实现DataSaverInterface类,并重写saveData和readData方法分别用于保存数据和读取数据 interface DataSaverInterface{
fun <T> saveData(key:String, data : T)
fun <T> readData(key: String, default : T) : T
}然后将LocalDataSaver提供的对象更改为您自己的类实例 val dataStore = DataSaverDataStore()
CompositionLocalProvider(LocalDataSaver provides dataStore){
ExampleComposable()
}后续相同使用即可。保存自定义类型默认的DataSaverPreferences并不提供自定义类型的保存(当尝试这样做时会报错)。尽管不建议持久化实体类,但您仍可以这样做。您可以选择以下方式实现这一目标。重写自己的DataSaverInterface实现类(见上)并实现相关的保存方法将实体类序列化为其他基本类型(如String)再储存对于第二种方式,您需要为对应实体类添加转换器以实现保存时自动转换为String。方法如下: @Serializable
data class ExampleBean(var id:Int, val label:String)
// ------------ //
// 在初始化时调用registerTypeConverters方法注册对应转换方法
// 该方法接收两个参数:实体类Class和对应的转换方法(Lambda表达式)
registerTypeConverters(ExampleBean::class.java) {
val bean = it as ExampleBean
Json.encodeToString(bean)
}完整例子见示例项目更多设置如果在某些情况下你不想频繁持久化保存,可设置rememberDataSaverState的autoSave参数为false,此时对象的赋值操作将不会执行持久化操作,您在需要保存的位置手动保存:LocalDataSaver.current.saveData()
江江说技术
你需要懂的ViewModel那些事
1.ViewModel构造函数支持传入Application自定义一个ViewModel继承AndroidViewModel:class ApplicationViewModel(app: Application) : AndroidViewModel(app) {
//获取Applicaction
private val mApp: Application by lazy {
getApplication()
}
}
我们就可以在通过getApplication()方法获取Application。2.ViewModel构造函数支持传入其他类型参数ViewModel支持传入工厂类来创建具体的ViewModel类型,所以我们直接继承ViewModelProvider.Factory自定义一个工厂类,并在创建ViewModel的时候传入该自定义的工厂类。比如下面我们支持MainViewModel的构造参数传入一个Int类型:class CustomVMFactory(
private val factory: ViewModelProvider.Factory,
private val type: Int
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
return MainViewModel(type) as T
}
return factory.create(modelClass)
}
}
可以发现,我们自定义的CustomVMFactory工厂类还得传入一个Activity的默认工厂,以保证当我们自定义的工厂类无法创建ViewModel时交给原本Activity默认工厂进行处理,这就相当于一个兜底作用,本质上体现的是静态代理模式的一种实现。使用如下:private val mVM by viewModels<MainViewModel> {
CustomVMFactory(
defaultViewModelProviderFactory,
5
)
}
3.ViewModel中LiveData使用注意点一般我们在ViewModel中这样定义LiveData://私有可变
private val data: MutableLiveData<Response<String>> = MutableLiveData()
//对外暴漏不可变
val data1: LiveData<Response<String>> = data
我们对外暴漏不可变的data1本质上是额外声明了一个成员变量和一个成员方法:通过反编译代码来看,其实额外声明的属性根本没有必要,我们只需要getData1方法获取不可变的LiveData即可。所以我们可以这样改造下://对外暴漏不可变
val data1: LiveData<String> get() = data
反编译看下效果:通过反编译的代码可以看到,暴露不可变的LiveData只额外声明了一个方法,减少了一个额外属性的声明。4.ViewModel中协程的使用依赖implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"中为ViewModel提供了一个协程扩展属性:viewModelScope我们就可以直接使用viewModelScope属性来执行耗时操作和线程切换:fun request() {
viewModelScope.launch(Dispatchers.IO) {
//执行耗时代码,比如网络请求
}
}
江江说技术
Jetpack Compose LazyGrid使用全解
前言前段时间Compose发布了1.2.0beta版本,最大的变化之一莫过于LazyLayout去除了实验性标志。所以接下来,咱们不妨一起看看LazyGrid的用法 LazyGrid包含两种微件:LazyVerticalGrid和LazyHorizontalGrid。两者内部均由LazyLayout实现(包括LazyColumn和LazyRow也是由LazyLayout实现的)。不过今天我们不去考虑底层的LazyLayout,单纯着眼于Grid们为行文方便,此处仅以LazyVerticalGrid为例。基本使用最简单的使用如下所示:@Composable
fun SimpleLazyGrid(){
LazyVerticalGrid(
modifier = Modifier.fillMaxWidth(),
// 固定两列
columns = GridCells.Fixed(2) ,
content = {
items(12){
RandomColorBox(modifier = Modifier.height(200.dp))
}
}
)
}其中用到的RandomColorBox仅仅是Box加上随机颜色的背景,唯一注意的是对于LazyLayout,因为涉及到重组过程,所以如果需要记住这个Color(重组时颜色不变),则需要使用rememberSaveable,其余不再赘述上面的效果如下简单的网格布局就实现了添加间隙要为子元素之间添加空隙也很简单,指定一下arrangemnt为spacedBy即可 //...
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)效果如下当然也可以添加整体的外边距,设置contentPadding = PaddingValues()即可,如下:contentPadding = PaddingValues(12.dp)适应大小上述情况实际上会根据最大宽度来调整,在横屏状态下就可能会惨不忍睹(比如你加载有图片的情况)所以除了固定列数外,还可以固定宽度,由Compose自动确定要放几列。这个也很简单,就是设置columns参数为Adaptive即可// 固定宽度,自适应列数
columns = GridCells.Adaptive(200.dp) ,效果如下:横屏竖屏可以看到,我们指定的200.dp是最小值,由于能够容纳一个又无法容纳两个,Compose为我们自动调整为了只放一个,占满全部剩余宽度。异形与自定义某些元素占满全部宽度item和items均有span参数,设置此参数即可设定当前元素会占据几格对于下面的代码:// 固定列数
columns = GridCells.Fixed(3) ,
content = {
item(span = {
// 占据最大宽度
GridItemSpan(maxLineSpan)
}){
RandomColorBox(modifier = Modifier.height(50.dp))
}
items(12){
RandomColorBox(modifier = Modifier.height(200.dp))
}
},最上面那个元素就会占据一行,如下:上面用到的maxLineSpan即为当前行的最大Span,除此之外,还有另一个值maxCurrentLineSpan,二者之间关系如下更复杂的自定义columns其实可以自定义,比方说,我们需要让一行中三个元素,宽度分别为1:2:1,那其实可以这样写。具体细节请参考下面的源码,返回的值即为各元素的宽度组成的List// 自定义实现1:2:1
columns = object : GridCells {
override fun Density.calculateCrossAxisCellSizes(
availableSize: Int,
spacing: Int
): List<Int> {
// 总共三个元素,所以其实两个间隔
// |元素|间隔|元素|间隔|元素|
// 计算一下所有元素占据的空间
val availableSizeWithoutSpacing = availableSize - 2 * spacing
// 小的两个大小即为剩余空间(总空间-间隔)/4
val smallSize = availableSizeWithoutSpacing / 4
// 大的那个就是除以2呗
val largeSize = availableSizeWithoutSpacing / 2
return listOf(smallSize, largeSize, smallSize)
}
}效果如下其余的效果大家就发挥想象啦如果你想对排列方式也自定义,可以自己实现Arrangement.Vertical,视频中有给出例子(18:51左右)。这里感觉用处不大,不赘述了一些提示 For LazyLayout不要设置大小为0的控件 这类问题主要在异步加载的场景中,可能加载之前你会将原本的大小设置为0(就是什么也没有)。在这种情况下,Compose在初始时将测量所有内容(因为他们高度为0,所以都在屏幕内),之后当数据加载完后,Compose又会重新重组。 相反,你应当尽量保证数据加载前后item整体大小不变(如手动设置高度、使用placeholder等),以帮助LazyLayout正确计算哪些会被显示在屏幕上避免嵌套同方向的可滚动微件 避免使用如下代码(其实你这么用会直接报错) kotlin复制代码Column(modifier = Modifier.verticalScroll()) { LazyColumn(/*这里不设置高度*/) } 而应当改为: kotlin复制代码LazyColumn { item { Header() } items(){ } item{ Footer() } }谨慎将多个子微件放到同一item中 即谨慎写出类似下面的代码 kotlin复制代码LazyColumn { item { // 两个微件放在同一item里 RandomColorBox(modifier = Modifier.size(40.dp)) RandomColorBox(modifier = Modifier.size(40.dp)) } } 在这种情况下,Compose尽管可以按顺序渲染出这些子微件,但同一个item下的微件会被当作一个整体。如果某一部分可见,则其与部分也会被一起重组和绘制,可能会影响性能。在最严重情况下,整个LazyColumn仅包含一个item,那就完全失去了Lazy的特性。另一个问题是对于scrollToItem()这类方法,它们的index在计算时是按item而不是所有内部子元素排列的,也就是说,对于下面的例子,尽管总共有4个微件,但算index的时候只有0/1/2三个而已。不过也有些情况倒是推荐这么用,比如在item中包含微件本身和Divider。一是二者本身语义上就相关联,Divider也不该影响index;二是Divider较小,不影响性能使用Type如果你的列表项有多种不同的类型,可以在item或items方法中指定contentType,这有助于Compose在重组时选择相同Type的微件进行复用,可以提高性能。contentType = { data.type }Google画的饼参考的视频中Google其实画了些饼瀑布流布局正在开发中item添加和删除的动画也正在开发中目前RecyclerView对应的瀑布流布局Compose中还没有对应实现,我试图用VerticalLazyGrid实现然并不行,它摆放的时候会确保每行高度一样……目前的开源库都是多个LazyColumn并排实现的伪效果。所以还是等吧
江江说技术
深入Jetpack Compose——布局原理与自定义布局(一)
Jetpack Compose 正式版发布也已半年了,对我来说,应用到项目中也很久了。 目前很多文章还集中于初探上,因此萌生了写作本文的想法,算是为Compose中文资料提供绵薄之力。本文的内容来自Android官方视频:Deep dive into Jetpack Compose layouts总览Jetpack Compose 中,单个可组合项被显示出来,总体上经历三个过程Composition(组合) -> Layout(布局) -> Drawing(绘制) ,其中Layout阶段又存在两个方面的内容:Measure(测量) 和 Place(摆放)今天我们主要着眼于 Layout 阶段,看看各个 Composable 是如何正确确定各自位置和大小的LayoutLayout阶段主要做三件事情:测量所有子微件的大小确定自己的大小正确摆放所有子元素的位置为简化说明,我们先给出一个简单例子。该例子中,所有元素只需要遍历一次。如下图的 SearchResult微件,它的构成如下:现在我们来看看Layout过程在这个例子中是什么情况Measure请求测量根布局,即RoWRow为了知道自己的大小,就得先知道自己的子微件有多大,于是请求Image和Column测量它们自己对于Image,由于它内部没有其他微件,所以它可以完成自身测量过程并返回相关位置指令接下来是Column,因为它内部有两个Text,于是请求子微件测量。而对于Text,它们也会正确返回自己的大小和位置指令这时 Column 大小和位置指令即可正确确定最后,Row内部所有测量完成,它可以正确获得自己的大小和位置指令测量阶段到此结束,接下来就是正确的摆放位置了Place完成测量后,微件就可以根据自身大小从上至下执行各子微件的位置指令,从而确定每个微件的正确位置现在我们把目光转向Composition阶段。大家平时写微件,内部都是由很多更基本的微件组合而来的,而事实上,这些基本的微件还有更底层的组成部分。如果我们展开刚刚的那个例子,它就成了这个样子在这里,所有的叶节点都是Layout这个微件我们来看看这个微件吧Layout Composable此微件的签名如下:@Composable inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)我们先看看第三个参数,这是之前从未见过的东西;而它恰恰控制着如何确定微件大小以及它们的摆放策略那来写个例子吧。我们现在自定义一个简单的纵向布局,也就是低配版Column自定义布局 - 纵向布局写个框架fun VerticalLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables: List<Measurable>, constrains: Constraints ->
}
}Measurable代表可测量的,其定义如下:interface Measurable : IntrinsicMeasurable {
/**
* Measures the layout with [constraints], returning a [Placeable] layout that has its new
* size. A [Measurable] can only be measured once inside a layout pass.
*/
fun measure(constraints: Constraints): Placeable
}可以看到,这是个接口,唯一的方法measure返回Placeable,接下来根据这个Placeable摆放位置。而参数measurables其实也就是传入的子微件形成的列表而Constraints则描述了微件的大小策略,它的部分定义摘录如下:举个栗子,如果我们想让这个微件想多大就多大(类似match_parent),那我们可以这样写:如果它是固定大小(比如长宽50),那就是这样写接下来我们就先获取placeable吧val placeables = measurables.map { it.measure(constrains) }在这个简单的例子中,我们不对measure的过程进行过多干预,直接测完获得有大小的可放置项接下来确定我们的VerticalLayout的宽、高。对于咱们的布局,它的宽应该容纳的下最宽的孩子,高应该是所有孩子之和。于是得到以下代码:// 宽度:最宽的一项
val width = placeables.maxOf { it.width }
// 高度:所有子微件高度之和
val height = placeables.sumOf { it.height }最后,我们调用layout方法返回最终的测量结果。前两个参数为自身的宽高,第三个lambda确定每个Placeable的位置layout(width, height){
var y = 0
placeables.forEach {
it.placeRelative(0, y)
y += it.height
}
}这里用到了Placeable.placeRelative方法,它能够正确处理从右到左布局的镜像转换一个简单的Column就写好了。试一下?fun randomColor() = Color(Random.nextInt(255),Random.nextInt(255),Random.nextInt(255))
@Composable
fun CustomLayoutTest() {
VerticalLayout() {
(1..5).forEach {
Box(modifier = Modifier.size(40.dp).background(randomColor()))
}
}
}嗯,工作基本正常。接下来我们实现一个更复杂一点的:简易瀑布流自定义布局—简易瀑布流先把基本的框架撸出来,在这里只实现纵向的,横向同理@Composable
fun WaterfallFlowLayout(
modifier: Modifier = Modifier,
content: @Composable ()->Unit,
columns: Int = 2 // 横向几列
) {
Layout(
modifier = modifier,
content = content,
) { measurables: List<Measurable>, constrains: Constraints ->
TODO()
}
}我们加入了参数columns用来指定有几列。由于瀑布流宽度是确定的,所以我们需要手动指定宽度val itemWidth = constrains.maxWidth / 2
val itemConstraints = constrains.copy(minWidth = itemWidth, maxWidth = itemWidth)
val placeables = measurables.map { it.measure(itemConstraints) }在这里我们用新的 itemConstraints 对子微件的大小进行约束,固定了子微件的宽度接下来就是摆放了。瀑布流的摆放方式其实就是看看当前哪一列最矮,就把当前微件摆到哪一列,不断重复就行代码如下:@Composable
fun WaterfallFlowLayout(
modifier: Modifier = Modifier,
columns: Int = 2, // 横向几列
content: @Composable ()->Unit
) {
Layout(
modifier = modifier,
content = content,
) { measurables: List<Measurable>, constrains: Constraints ->
val itemWidth = constrains.maxWidth / columns
val itemConstraints = constrains.copy(minWidth = itemWidth, maxWidth = itemWidth)
val placeables = measurables.map { it.measure(itemConstraints) }
// 记录当前各列高度
val heights = IntArray(columns)
layout(width = constrains.maxWidth, height = constrains.maxHeight){
placeables.forEach { placeable ->
val minIndex = heights.minIndex()
placeable.placeRelative(itemWidth * minIndex, heights[minIndex])
heights[minIndex] += placeable.height
}
}
}
}这里用到了一个自定义的拓展函数minIndex,作用是寻找数组中最小项的索引值,代码很简单,如下:fun IntArray.minIndex() : Int {
var i = 0
var min = Int.MAX_VALUE
this.forEachIndexed { index, e ->
if (e<min){
min = e
i = index
}
}
return i
}效果如下(设置列数为3):现在的布局只是简单情况,然而事实上,很多时候往往涉及到其他内容。Modifier 的奥秘也等待我们进一步探索。再叙。
江江说技术
Jetpack Compose异步加载图片的实现
本文使用两种方式,实现Compose中图片的异步加载前言Android开发中异步加载图片是非常常见的需求。本文将带你实现这一需求。本文将分为如下两个方面:自己写函数用开源库实现借助Glide库自己写Glide开源库基本上成为了Android中加载图片的首选,其简单易用的API和强大的缓存能力让这一过程变得十分方便。自然在Jetpack Compose中也可以使用。引入依赖在模块中的build.gradle中加入implementation 'com.github.bumptech.glide:glide:4.12.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0'编写函数如何让Glide把图片加载到Compose组件上去呢?我们可以利用其提供的into(Target)指定自定义的target,再搭配上mutableState<Bitmap>的返回值,即可实现在图片加载完成后Compose自动更新图片加载时一般会有一个默认的loading图,我们可以如法炮制,让Glide先帮我们加载一张本地图片,然后再去加载网络图片即可。编写的函数如下:/**
* 使用Glide库加载网络图片
* @author [FunnySaltyFish](https://funnysaltyfish.github.io)
* @date 2021-07-14
* @param context Context 合理的Context
* @param url String 加载的图片URL
* @param defaultImageId Int 默认加载的本地图片
* @return MutableState<Bitmap?> 加载完成(失败为null)的Bitmap-State
*/
fun loadImage(
context: Context,
url: String,
@DrawableRes defaultImageId: Int = R.drawable.load_holder
): MutableState<Bitmap?> {
val TAG = "LoadImage"
val bitmapState: MutableState<Bitmap?> = mutableStateOf(null)
//为请求加上 Headers ,提高访问成功率
val glideUrl = GlideUrl(url,LazyHeaders.Builder().addHeader("User-Agent","Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.67").build())
//先加载本地图片
Glide.with(context)
.asBitmap()
.load(defaultImageId)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
//自定义Target,在加载完成后将图片资源传递给bitmapState
bitmapState.value = resource
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
//然后再加载网络图片
try {
Glide.with(context)
.asBitmap()
.load(glideUrl)
.into(object : CustomTarget<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
//自定义Target,在加载完成后将图片资源传递给bitmapState
bitmapState.value = resource
}
override fun onLoadCleared(placeholder: Drawable?) {}
})
} catch (glideException: GlideException) {
Log.d(TAG, "error: ${glideException.rootCauses}")
}
return bitmapState
}使用例子简单的例子如下:@Composable
fun LoadPicture(
url : String
){
val imageState = loadImage(
context = LocalContext.current,
url = url,
)
Card(modifier = Modifier
.padding(4.dp)
.clickable { }) {
//如果图片加载成功
imageState.value?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "",
modifier = Modifier
.padding(4.dp)
.fillMaxWidth()
)
}
}
}
//......
LazyColumn {
val urls = arrayListOf<String>()
for (i in 500..550){urls.add("https://nyc3.digitaloceanspaces.com/food2fork/food2fork-static/featured_images/$i/featured_image.png")}
itemsIndexed(urls){ _ , url -> LoadPicture(url = url)}
}效果如下图所示:加载中加载完毕P.S.:别忘了声明网络权限哦!借助开源框架事实上,谷歌在其开发文档中也给出了示例,用的是开源库Accompanist所以我们也可以用这个简单的例子implementation "com.google.accompanist:accompanist-coil:0.13.0"
/**
* @author [FunnySaltyFish](https://funnysaltyfish.github.io)
* @date 2021-07-14
* @param url 加载的链接
*/
@Composable
fun LoadPicture2(url:String){
val painter = rememberCoilPainter(url)
Box {
Image(
painter = painter,
contentDescription = "",
)
when (painter.loadState) {
is ImageLoadState.Loading -> {
// 显示一个加载中的进度条
CircularProgressIndicator(Modifier.align(Alignment.Center))
}
is ImageLoadState.Error -> {
// 如果发生了什么错误,你可以在这里写
Text(text = "发生错误", color = MaterialColors.RedA200)
}
else -> Text(text = "未知情况", color = MaterialColors.PurpleA400)
}
}
}
上面例子中的Material颜色来自开源库CMaterialColors效果如下:P.S.:如果想更好的看到加载的情况,可以在模拟器设置中将网络类型设置为较慢的类型一些限制个人感觉,这种方式有以下的问题:滑动加载时不如Glide流畅对Kotlin版本有要求,如(截止文章写作时)最新的0.13.0版就必须用kotlin1.5及以上版本,否则就会编译出错
江江说技术
Jetpack Compose 自定义布局+物理引擎 = ?
效果废话不说,先上图!所对应代码大致为:val physicsConfig = PhysicsConfig()
PhysicsLayout(modifier = modifier, physicsLayoutState = physicsLayoutState, boundSize = boundSize.toFloat()) {
RandomColorBox(modifier = Modifier
.size(40.dp)
.physics(physicsConfig, initialX = 300f, initialY = 500f))
// This one has a circle shape
// so you need to modify it with not only a `clip()` Modifier to make it "looks like" a circle
// but also a `physics(physicsConfig.copy(shape = PhysicsShape.CIRCLE)` Modifier to create a circle Body
RandomColorBox(modifier = Modifier
.clip(CircleShape)
.size(50.dp)
.physics(physicsConfig.copy(shape = PhysicsShape.CIRCLE), 300f, 1000f))
RandomColorBox(modifier = Modifier
.size(60.dp)
.physics(physicsConfig))
var checked by remember {
mutableStateOf(false)
}
Checkbox(...)
Card(...) {
...
}
}这之中的PhysicsLayout就是我实现的物理布局了如需体验上图的其他功能,可以到Github仓库下载demo;完整源码亦可见仓库这是怎么实现的?正如标题所言,这是一个自定义布局。关于这方面,我已经写了5篇文章详细描述了,感兴趣的同学可以点击我的头像查看。本文所用到的无非就是那里的知识加上JBox2d而已,看完之后你也写的出来JBox2dJBox2D是开源的2D物理引擎,能够根据开发人员设定的参数,如重力、密度、摩擦系数和弹性系数等,自动进行2D刚体物理运动的模拟。参考本布局参考自Jawnnypoo/PhysicsLayout: Android layout that simulates physics using JBox2D (github.com),其中部分代码也来自于那里,在此表示诚挚的感谢!( 不过我进行了大量的修改,以使原先用于 View的代码被用于Compose)实现定义首先,我们先来想一个事情:现在每个物体其实都有自己的位置、大小、形状这些参数,那么父布局怎么获得这些值呢?如果你读过我的深入Jetpack Compose——布局原理与自定义布局(四)ParentData,估计可以想到:这是利用ParentData传递的。所以咱们先写个自定义的ParentData吧class PhysicsParentData(
var physicsConfig: PhysicsConfig = PhysicsConfig(),
var initialX: Float = 0f,
var initialY: Float = 0f,
var width: Int = 0,
var height: Int = 0
)PhysicsConfig代表基本的物理配置,我们先不细究,其余的就是初位置和宽高了。有了ParentData,那是不是也得有对应的修饰符和作用域啊,所以咱们写一写interface PhysicsLayoutScope {
@Stable
fun Modifier.physics(physicsConfig: PhysicsConfig, initialX : Float = 0f, initialY : Float = 0f) : Modifier
}
internal object PhysicsLayoutScopeInstance : PhysicsLayoutScope {
@Stable
override fun Modifier.physics(
physicsConfig: PhysicsConfig,
initialX: Float,
initialY: Float
): Modifier = this.then(PhysicsParentData(physicsConfig, initialX, initialY))
}上面的代码都很简单,属于是自定义Modifier的基本操作了,如果你看不懂可以先了解了解再来使用现在Modifier的定义差不多了,接下来就是使用了。其实总结下来就是这个过程初始化各个物体和世界用代码不断模拟一下各个物体的运动过程在Layout过程中获取位置并正确摆放出来咱们分别来看*(下面的内容只是我的思路,有些地方可能不太优雅,如果您有更好的想法欢迎指出!)*整体来说,应该有一些代码专门负责物理模拟的过程,这一部分在代码中为Physics类,它负责进行具体的物理世界创造、进行物理模拟等过程。此处不赘述。初始化考虑到各子微件的具体信息要到Layout才能读取到,所以似乎只能在这里初始化;但是Layout又会反复进行,而初始化应该只进行一次。所以用个变量来控制吧var initialized by remember {
mutableStateOf(false)
}然后第一次Layout时读取各ParentData并存起来val placeables = measurables.mapIndexed { index, measurable ->
val physicsParentData = (measurable.parentData as? PhysicsParentData) ?: PhysicsParentData()
if (!initialized){
parentDataList.add(index, physicsParentData)
}
measurable.measure(childConstraints)
}然后开个副作用,在所有物体信息初始化好后创建世界并创建Body(在JBox2d中代表刚体的类)// 初始化世界
LaunchedEffect(initialized){
if (!initialized) return@LaunchedEffect
physics.createWorld { body, i ->
parentDataList[i].body = body
}
}其中createWord方法负责创建Body并在每个Body创建完后回调不断模拟模拟的工作交给JBox2d,我们要做的就是不断就行。所以while循环吧LaunchedEffect(key1 = Unit){
while (true){
delay(16)
physics.step() // 模拟 16ms
}
}读取并正确放置这个就很简单了,在Layout方法里layout()中读一下各个Body的位置并place就行不过这里注意,因为Body有旋转角度,所以在place的时候需要使用placeWithLayer,该方法签名如下:fun Placeable.placeWithLayer(
position: IntOffset,
zIndex: Float = 0f,
layerBlock: GraphicsLayerScope.() -> Unit = DefaultLayerBlock
)其中第三个参数layerBlock就提供了缩放、选择等方法。具体代码是:layout(constraints.maxWidth, constraints.maxHeight){
placeables.forEachIndexed { i, placeable: Placeable ->
val x = physics.metersToPixels(parentDataList[i].x).toInt() - placeable.width / 2
val y = physics.metersToPixels(parentDataList[i].y).toInt() - placeable.height / 2
placeable.placeWithLayer(IntOffset(x,y), zIndex = 0f, layerBlock = {
rotationZ = parentDataList[i].rotation
})
}
}上面的metersToPixels用于将物理世界的坐标映射到现实完工!后续其实目前来看,代码里还有些地方感觉不大对劲,比如,为了触发Layout过程,我实际使用了一个并无任何用处的state。因为在我的尝试里,只要layout块里不出现state的变化,它就不会重新触发(这点当然符合Compose的感觉喽);我想不到什么好点子,只好这么处理了。如果大家有什么好想法,欢迎探讨和PR如果你好奇有什么用……额,我也不知道有什么实际用处。我就是觉得很好玩儿,很早之前就想做了,最近下定决心,两天完成,感觉效果还不错。
江江说技术
一个典型问题:ViewModel如何在界面重建中保存数据?
众所周知,ViewModel可以在界面销毁重建后仍然保存之前的数据,而到底是怎么在界面销毁重建期间进行保存的呢,本篇文章就就该问题进行一个探究。由于ViewModel能够在界面销毁重建时保存数据,那我们就从Activity销毁的时机作为入口一探究竟。AMS通知ApplicationThread执行界面销毁应用执行界面销毁是通过AMS通过Binder跨进程通知应用这边的ApplicationThread这个Binder对象,而ApplicationThread通过Handler最终会执行到ActivityThread.handleDestroyActivity()方法。而这个方法又会调用performDestroyActivity()方法,我们看下源码:retainNonConfigurationInstances()就是ViewModel能在界面销毁重建后保存数据的关键方法,稍后会进行详细分析;Instrumentation.callActivityOnDestroy()这个方法最终就会回调大家熟悉的Activity.onDestroy()方法;Activity.retainNonConfigurationInstances()瞧一瞧NonConfigurationInstances retainNonConfigurationInstances() {
Object activity = onRetainNonConfigurationInstance();
//...
return nci;
}
紧接着就会调用onRetainNonConfigurationInstance()方法,ComponentActivity会对这个方法进行重写:public final Object onRetainNonConfigurationInstance() {
Object custom = onRetainCustomNonConfigurationInstance();
ViewModelStore viewModelStore = mViewModelStore;
if (viewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
viewModelStore = nc.viewModelStore;
}
}
if (viewModelStore == null && custom == null) {
return null;
}
NonConfigurationInstances nci = new NonConfigurationInstances();
nci.custom = custom;
nci.viewModelStore = viewModelStore;
return nci;
}
这个方法值得细细分析一下,首先先搞清楚mViewModelStore是个啥:看到这里是不是明白了:mViewModelStore是个ViewModelStore类型,而我们在Activity界面创建的ViewModel就会保存到这个对象之中。到了这里,我们就可以知道:ActivityThread.handleDestroyActivity()最终会一步步走到mViewModelStore,将其进行保存。接下来我们就看下这个值mViewModelStore经过一步步调用是怎么保存的 。mViewModelStore如何一步步调用保存?回到我们的方法onRetainNonConfigurationInstance()中,从源码中可以看到,mViewModelStore最终会保存到ComponentActivity$NonConfigurationInstances类的viewModelStore成员属性中,并返回ComponentActivity$NonConfigurationInstances对象。简单看下NonConfigurationInstances类结构:static final class NonConfigurationInstances {
Object custom;
ViewModelStore viewModelStore;
}
我们跳到调用onRetainNonConfigurationInstance()方法的Activity.retainNonConfigurationInstances()方法中:可以看到上面的onRetainNonConfigurationInstance()方法返回的ComponentActivity$NonConfigurationInstances对象最终会保存到Activity$NonConfigurationInstances类的activity成员变量中。请注意,别搞混了NonConfigurationInstances类,Acitivity和ComponentActivity都有定义这个类,我们看下Acitivity定义的NonConfigurationInstances结构:static final class NonConfigurationInstances {
Object activity;
HashMap<String, Object> children;
FragmentManagerNonConfig fragments;
ArrayMap<String, LoaderManager> loaders;
VoiceInteractor voiceInteractor;
}
最终Activity.retainNonConfigurationInstances()方法会将Activity$NonConfigurationInstances类对象返回到上一层。最终我们又回到了performDestroyActivity()方法中:最终Activity$NonConfigurationInstances会保存到ActivityClientRecord的lastNonConfigurationInstances属性中。而这个ActivityClientRecord是保存到ActivityThread的mActivities集合中,其中key就是token,value就是为ActivityClientRecord。界面重建怎么恢复数据呢?界面重新创建销毁后,AMS会通知ApplicationThread最终调用到performLaunchActivity()方法。这个方法就是用来创建Activity的,创建完毕后就会调用我们熟悉的Activity.attach()方法:可以看到这个方法传递的参数其中之一就是ActivityClientRecord的lastNonConfigurationInstances属性,继续深入看下Activity.attach()方法:final void attach(//...NonConfigurationInstances lastNonConfigurationInstances,//...) {
attachBaseContext(context);
//...
mLastNonConfigurationInstances = lastNonConfigurationInstances;
//...
}
最终这个lastNonConfigurationInstances会赋值给Acitivty的mLastNonConfigurationInstances属性。而mLastNonConfigurationInstances就是Activity$NonConfigurationInstances对象,就保存了之前存储ViewModel的ViewModelStore,这就间接实现了保存了ViewModel中持有的数据。ViewModel的获取流程我们看下如何在Activity中创建一个ViewModel:private val mViewModel: MainViewModel by viewModels()
关键就是viewModels()方法:最终ViewModel会尝试从viewModelStore中获取,获取不到通过反射创建。而viewModelStore是从哪里来的呢?可以看到,最终这个viewModelStore最终就是从上面的Activity.mLastNonConfigurationInstances属性中获取。总结本篇文章我们详细分析ViewModel如何实现在Activity界面销毁重建后还能够保存销毁前的数据的,希望对你有所帮助。
江江说技术
肢解LiveData:协程味的CoroutineLiveData了解一下(二)
上篇讲解了通过liveData{}创建CoroutineLiveData的基本使用,本篇文章主要介绍CoroutineLiveData的原理分析,这是一个基于协程+MediatorLiveData实现的一种build构建livedata的类。历史文章肢解LiveData:协程味的CoroutineLiveData了解一下(一)liveData{}入口分析public fun <T> liveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
@BuilderInference block: suspend LiveDataScope<T>.() -> Unit
): LiveData<T> = CoroutineLiveData(context, timeoutInMs, block)
实际上创建了一个CoroutineLiveData对象,我们看下这个对象:CoroutineLiveDatainternal class CoroutineLiveData<T>(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT,
block: Block<T>
) : MediatorLiveData<T>()
这个类继承了MediatorLiveData,这就为emitSource()的实现埋下了伏笔,稍后分析。MediatorLiveData有两个很关键的属性:blockRunner: 该类真正执行liveData{}中的代码块,实现给LiveData赋值;emittedSource:专门用于移除emitSource()方法添加的LiveData数据源监听;接下来我们从MediatorLiveData的方法onActive()作为入口进行分析。onActive()override fun onActive() {
super.onActive()
blockRunner?.maybeRun()
}
这个方法大家很熟悉,就是当界面状态大于Started时就会被回调执行,该方法会调用到blockRunner的maybeRun()方法:BlockRunner.maybeRun()@MainThread
fun maybeRun() {
//省略
runningJob = scope.launch {
val liveDataScope = LiveDataScopeImpl(liveData, coroutineContext)
block(liveDataScope)
onDone()
}
}
请注意scope协程作用域指定的调度器为Dispatchers.Main.immediate,所以说liveData{}中的代码块会默认在主线程中执行。首先创建一个LiveDataScopeImpl对象,我们在liveData{}中调用emit()和emitSource()方法都是来自于它:2.调用block(liveDataScope),这个block就是liveData{}中代码块;3.当代码块执行完毕就会执行传入onDone(),将CoroutineLiveData的blockRunner置null,看可以说是非常的严禁了。接下来我们来一一看下emit()和emitSource()的实现。LiveDataScopeImpl.emit()override suspend fun emit(value: T) = withContext(coroutineContext) {
target.clearSource()
target.value = value
}
这个target对象就是我们创建的CoroutineLiveData:调用clearSource()取消之前通过emitSource()添加的LiveData数据源监听,这也就是之前说的调用emit()方法会让emitSource()添加的数据源监听失效;调用CoroutineLiveData的方法setValue()进行赋值,也和我们自己平常创建LiveData并手动赋值一样,这样LiveData添加的监听者Observer就可以收到回调了。(界面状态大于Started)LiveDataScopeImpl.emitSource()override suspend fun emitSource(source: LiveData<T>): DisposableHandle =
withContext(coroutineContext) {
return@withContext target.emitSource(source)
}
最终调用CoroutineLiveData.的emitSource()方法。CoroutineLiveData.emitSource()internal suspend fun emitSource(source: LiveData<T>): DisposableHandle {
clearSource()
val newSource = addDisposableSource(source)
emittedSource = newSource
return newSource
}
清楚之前添加的其他LiveData数据源监听,也就是说通过emitSource()只能添加一个LiveData监听;调用addDisposableSource()方法internal suspend fun <T> MediatorLiveData<T>.addDisposableSource(
source: LiveData<T>
//1.
): EmittedSource = withContext(Dispatchers.Main.immediate) {
//2.
addSource(source) {
value = it
}
//3.
EmittedSource(
source = source,
mediator = this@addDisposableSource
)
}
指定协程代码块在主线程中执行;2.调用 MediatorLiveData的addSource()方法添加数据源监听:这个方法大家应该很熟悉了,这个source参数就是通过emitSource()方法的参数传入的。我们简单看下addSource()方法的实现:Source类的创建:即使给我们监听的LiveData添加一个Obserer监听,并赋值给上面的CoroutineLiveData。3.构造一个EmittedSource管理从MediatorLiveData移除上面通过addSource()添加的LiveData数据源监听。总结本篇文章分析了liveData{}的实现原理,核心在emit()和emitSource()的实现,以及使用过程中的注意点。
江江说技术
Jetpack Compose 性能优化参考:编译指标(下)
本文为文章《如何在 Jetpack Compose 中调试重组》中附录的文章,译者同样进行了翻译。限于译者水平,不免有谬误之可能,如有错误,欢迎指正。考虑到原文较长,本人分成了上下两部分。这是后一半。前一半见juejin.cn/post/712357…换条路走到这时我才重新回去看文档,并开始看第二个建议:通过将函数标记为 @NonRestartableComposable乍一看,这个建议更像是一种权宜之计(或末路之策),而不是像第一个建议那样去修复类稳定性。让我们看看注释的文档是怎么说的:此注解 [防止] 那些允许函数跳过或重启的代码被生成。这对于 直接调用另一个可组合函数、自身几乎不做什么、并且本身不太可能失效的小函数 来说可能是可取的。如果我们往回想想,我们的目标是让 Composable 可重启 和 可跳过,所以仅读这个注释并不太够。不过,Compose Metrics 指南提供了更多信息:如果Composable函数不直接读取任何State变量,那么 [使用这个注解] 是个好主意,[因为] 此重启作用域不太可能被使用。那么这个注释对我们有帮助吗?是也不是。此注解似乎让 Compose 编译器完全忽略了所有可组合函数的自动重启,从而否定了让我们的函数可重启 和可跳过 的初衷。我相信这意味着任何状态更改都需要 Compose Runtime 找到祖先重启范围,这就是为什么上面的文档说要避免它们用于读取State的函数。那么接下来干嘛呢?写就近的 UI Model 类需要添加大量的东西,因此对于很多团队来说,这条路不大可行。不过,我倒确实在 Compose Issue Tracker上找到了一个我非常喜欢的解决方案:允许将函数的参数标记为@Stable. 这将使得开发人员能够对Composable函数的参数强制指定稳定性/不变性,即使对于外部参数类型也是如此:@Composable
fun AirsInfoPanel(
@Stable show: TiviShow,
modifier: Modifier = Modifier,
)目前,它还不能用。@dynamic 的默认参数表达式从 Metrics 文档中,要注意的第二件事是@dynamic的默认参数表达式。大量Composable使用默认参数来提供灵活的 API。我最近写了一篇关于 Slot API 的文章,它就依赖于默认参数值:chris.banes.dev/slotting-in…默认参数的值可以是可组合的,或者不可组合的。使用可组合代码中的值意味着您正在调用的代码可能是可重启 的,并且返回值可以变化。这就是我们所指的@dynamic默认参数。如果默认参数值是@dynamic,则调用方函数也可能需要重启,这就是应避免意外的@dynamic的原因。编译指标将非@dynamic参数值称为@static,它可能构成您在composables.txt文件中找到的绝大多数内容。但也一些例外情况下,@dynamic是必要的:您正在显式读取可观察的dynamic变量关于这一点,您最常见的情况是在Composable上使用MaterialTheme.blah做默认值。这里我们有一个Composable,它有 3 个被标记为dynamic的参数。restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun TopAppBarWithBottomContent(
stable backgroundColor: Color = @dynamic MaterialTheme.colors.primarySurface
stable contentColor: Color = @dynamic contentColorFor(backgroundColor, $composer, 0b1110 and $dirty shr 0b1111)
stable elevation: Dp = @dynamic AppBarDefaults.TopAppBarElevation
)前两个参数backgroundColor和contentColor是dynamic(动态)的,因为我们在间接读取挂在MaterialTheme上的 composition locals . 由于主题是相对静态的(理论上来说),返回值实际上不应该经常改变,所以它是动态的也问题不大。但是对于elevation参数,我就不大确定为什么它被标记为动态的了。它使用来自 Material 提供的 AppBarDefaults.TopAppBarElevation 属性的值,该属性定义为:object AppBarDefaults {
val TopAppBarElevation = 4.dp
}dp属性被标记为@Stable,并且Dp类被标记为@Immutable。所以从我读到的情况来看,这可能是个bug?我在另一个函数上也发现了类似的问题:restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SearchTextField(
stable keyboardOptions: KeyboardOptions? = @dynamic Companion.Default
keyboardActions: KeyboardActions? = @dynamic KeyboardActions()
)keyboardOptions指的是KeyboardOptions(一个单例) 的伴生对象,并keyboardActions在创建一个新的空KeyboardActions实例,我读着感觉这两个实例都应该被推断为@static。与这篇博文的第一部分类似,我不确定我们在这里可以做些什么来影响 Compose 编译器。我们可以将@Stable和@Immutable添加到我们自己的类中,但从dp上面的示例来看,这似乎并不总是有效。为啥要在release下?在这篇博文的开头,我们提到您需要在release版本上启用 Compose Compiler 指标。当您在debug模式下构建应用程序时,Compose 编译器会启用许多功能来加快开发。其中之一是Live Literals,它使 Android Studio 能在不重新编译Composable的情况下“注入”某些参数值的新值。为了做到这一点,Compose 编译器将某些默认参数替换为另一些生成后的代码。然后,Android Studio 可以调用这些代码来设置新值。最终效果是生成的 Live Literal 代码将导致您的默认参数为@dynamic,即使它们实际上并不是动态的。您可以在下面看到一个示例。红色(译注:这里没颜色,以 - 开头的行)是debug模式输出,绿色(译注:同,+ 开头的行)来自release构建。release模式下,参数expanded变成了@static。--- debug.txt 2022-04-06 14:43:16.000000000 +0100
+++ release.txt 2022-04-06 14:43:24.000000000 +0100
@@ -1,11 +1,11 @@
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ExpandableFloatingActionButton(
stable text: Function2<Composer, Int, Unit>
stable onClick: Function0<Unit>
stable modifier: Modifier? = @static Companion
stable icon: Function2<Composer, Int, Unit>
- stable shape: Shape? = @dynamic MaterialTheme.shapes.small.copy(CornerSize(LiveLiterals$ExpandingFloatingActionButtonKt.Int$arg-0$call-CornerSize$arg-0$call-copy$param-shape$fun-ExpandableFloatingActionButton()))
+ stable shape: Shape? = @dynamic MaterialTheme.shapes.small.copy(CornerSize(50))
stable backgroundColor: Color = @dynamic MaterialTheme.colors.secondary
stable contentColor: Color = @dynamic contentColorFor(backgroundColor, $composer, 0b1110 and $dirty shr 0b1111)
stable elevation: FloatingActionButtonElevation? = @dynamic FloatingActionButtonDefaults.elevation(<unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), $composer, 0b1000000000000000, 0b1111)
- stable expanded: Boolean = @dynamic LiveLiterals$ExpandingFloatingActionButtonKt.Boolean$param-expanded$fun-ExpandableFloatingActionButton()
+ stable expanded: Boolean = @static true
)我刚刚学到了啥?到这会儿,您可能会认为您刚刚花了大约 30 分钟,阅读我的文章、光看我指出了许多可能的问题……好吧您大概也是对的 😅。但是我仍然认为这里有一些团队可以采取的行动:开始分析和跟踪性能统计信息。没有这个,任何的性能优化都是在摸黑瞎走。尽早更新到 Compose 的新版本!这将让您能尝试并获得性能上的更新(并报告可能的退步)。寻找那些被标记为@Composable的小的功能函数或lambda表达式. 这些往往会返回一个值(而不是更新 UI),并且往往只是为了可以引用 composition locals (根据我的经验,LocalContext是一个常见的罪魁祸首)才被标记为Composable。您可以通过传入依赖项轻松地拿掉这个Composable注解。最后的想法正如我在上面提到的,我认为这些新指标向前迈进了 ✨ 惊人的 ✨ 一步,可以看到我们的Composable实际被推断出的内容。我指出的问题实际上是一件好事,并表明这些指标和输出是有效的。如果没有这些,我们将完全不知道会被推断出什么来,也没法看到推断的结果何时不完全符合预期。有了这些信息,在Compose问题跟踪器上创建问题就变得更加容易。这些指标现在显然非常原始,但在知道 Compose + Android Studio 工具团队有多出色的前提下,我确信一个相应的 Android Studio GUI 版本不会等太久的。我期待着看到团队把让它成为现实!
江江说技术
探索 Compose 新输入框:BasicTextField2
省流:Compose 文本团队正在构建下一代的 TextField API,现在可以开始尝试了。 BasicTextField2 **在最新的 foundation 1.6.0 版本(现在还在 beta)**的 text 包中已经可以试用了。你也可以通过文章最后描述的各种渠道向团队提供反馈。注意 Compose 是分层构建的:Material -> Foundations -> UI。 TextField 和 OutlinedTextField 是 Material 组件,它们在 foundations 层上添加了样式,底层基于 BasicTextField 。在本文中,我们将描述 BasicTextField2 ,它旨在取代 BasicTextField 。 请注意, BasicTextField2 在 API 开发过程中是一个临时名称。Compose 的各个层级及每个 API 的位置回望一下咱们大多数估计是通过下面的代码接触到 Jetpack Compose 中的 TextField (或 BasicTextField )API 的:var textField by rememberSaveable { mutableStateOf("") }
TextField(
value = textField,
onValueChange = { textField = it },
)这个简单的 API 存在一系列缺点,其中最明显的是:使用 onValueChange 回调更新 BasicTextField 状态时,很容易引入异步行为,导致不可预测的行为。这个问题非常复杂,在下面这篇博文中有详细描述:VisualTransformation 也容易令人困惑( 并造成 bug )。以电话号码格式化为例,通常,您希望在输入电话号码时通过添加空格、破折号、括号来修改它。要使用 VisualTransformation API 实现这一点,您需要指定初始字符和转换后字符之间的映射关系。Visual Transformations 中的手动映射而编写这些映射关系并不容易。扩展 VisualTransformation 时格式化电话号码private val phoneNumberFilter = VisualTransformation { text ->
val trimmed = if (text.text.length >= 10) text.text.substring(0..9) else text.text
val filled = trimmed + "_".repeat(10 - trimmed.length)
val res = "(" + filled.substring(0..2) + ") " + filled.substring(3..5) + "-" + filled.substring(6..9)
TransformedText(AnnotatedString(text = res), phoneNumberOffsetTranslator(text.text))
}于是, ValidatingOffsetMapping 类 诞生 了 ,它的目的是验证 VisualTransformation ,并在映射错误时抛出有意义的异常信息。它被设计为向开发者提供更多信息,以更好地理解 crash 并进行调试。在此之前,如果碰到崩溃,那几乎没有信息能帮助找到问题。然而,这个改变并没有从解决上根本问题。整体的 TextField API 也可以改进。例如,配置单行非常简单:我们只需定义 singleLine = true 。然而,像下面这样写最终是什么结果,就不是很清楚:TextField(
value = textField,
onValueChange = { textField = it },
singleLine = true,
minLines = 2,
maxLines = 4
)此外,当前的 API 很难确定编辑过程中的确切更改,只能把这个工作留给开发者。想象一下,您希望在文档协作工具中显示每个用户所做的更改列表。TextField(
value = text,
// 这里到底发生了什么变化?你只有旧值和新值。
onValueChange = { newFullText -> /***/ }
)考虑到所有这些以及其他一些限制,Compose 团队聚集在一起,探讨了典型的 Text Field 使用情况,并想象了理想的 TextField API 是什么样的。下面的部分是对这些想法如何付诸实践并最终成为BasicTextField2的探索。看看新的定义状态我们有一个注册界面。对于“用户名”字段,在之前我们可能会这样定义 TextField :var username by rememberSaveable { mutableStateOf("") }
BasicTextField(
value = username,
onValueChange = { username = it },
)而新的 API BasicTextField2 的使用方式如下:val username = rememberTextFieldState()
BasicTextField2(state = username)我们不再需要回调函数,这样可以避免之前提到的引入异步行为的错误。使用 rememberTextFieldState 来定义类型为 TextFieldState 的状态变量。您可以方便地配置 initialText ,以及初始选区和光标位置。使用这个 API 定义的状态在重组、Configuration 变化时都能保留。使用 rememberTextFieldState 形式上其实也蛮熟悉,就类似于 rememberLazyListState 来定义 LazyListState 或者使用 rememberScrollState 来定义 ScrollState 一样。如果您需要对状态应用业务规则,或者需要将状态提升到 ViewModel 中,那么可以像这样定义一个类型为 TextFieldState 的变量:// ViewModel
val username = TextFieldState()
// Compose
BasicTextField2(state = viewModel.username)样式BasicTextField2 的 Material 封装还未准备好( BasicTextField2 位于 foundations 层)。您可以使用 TextStyle 块来修改 color 、 fontSize 、 lineHeight 等样式,通过使用各种 Modifier,特别是 border 来实现上面例子中的样式。或者可以使用 decorator 参数来进一步自定义 TextField 容器的外观(就像 OutlinedTextField 在 decorationBox 中实现的那样)。val username = rememberTextFieldState()
BasicTextField2(state = username,
textStyle = textStyleBodyLarge,
modifier = modifier.border(...),
decorator = @Composable {}
)行限制新的 API 通过提供类型为 TextFieldLineLimits 的 lineLimits 参数消除了配置单行/多行的不明确性:BasicTextField2(
/***/
lineLimits = TextFieldLineLimits.SingleLine
)
BasicTextField2(
/***/
lineLimits = TextFieldLineLimits.Multiline(5, 10)
)SingleLine : TextField 始终为单行,忽略换行符,并在文本溢出时水平滚动。Multiline :定义最小和最大行数。文本开始时的高度为 minHeightInLines ,当达到字段末尾时,文本会换到下一行。随着继续输入,文本会增长到高度为 maxHeightInLines 。如果继续输入超出范围,将纵向滚动。状态观察让我们看看如何观察用户名状态并进行一些验证,并对其应用业务上的规则。API: textAsFlow假设我们希望在选定的用户名不可用时显示错误信息。代码可能如下所示:// ViewModel
// 将 TextField 状态提升到 ViewModel 中
val username = TextFieldState()
// 观察用户名 TextField 状态的变化
val userNameHasError: StateFlow<Boolean> =
username.textAsFlow()
.debounce(500)
.mapLatest { signUpRepository.isUsernameAvailable(it.toString()) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)我们定义了一个变量 userNameHasError,它根据对 username 运行的验证结果为 true 或 false。我们可以使用 snapshotFlow API 来观察 TextFieldState 中可变状态的文本,对于每个新值,我们可以调用异步函数验证。snapshotFlow { username.text }这种使用 snapshotFlow 观察组合状态的方式非常常见,因此 BTF2 API 提供了一个名为 textAsFlow 的方法,它以简单的方式完成相同的操作,您只需调用该扩展函数即可。我们使用 Flow API 中的 debounce 来等待一段时间后触发验证,给用户一些缓冲时间来完成输入。然后我们使用 mapLatest 在每次输入新字符时调用验证。最后,stateIn 将此状态转换为 StateFlow。然后在界面上读取 userNameHasError 。如果该状态为 true,则在用户名下方显示一个 Text 。// Compose
if (signUpViewModel.userNameHasError) {
// 显示错误标签
}API: forEachTextValue在每个字符输入上执行异步操作的另一种便捷方法是使用 textAsFlow().collectLatest(block) 并触发异步验证。还有一个名为 forEachTextValue 的扩展函数,它为我们提供了一个挂起函数作用域,以在每个值更改时进行请求。我们可以重新编写之前的示例,定义一个挂起方法 validateUsername 。然后对于每个新的文本值,我们进行异步请求并设置 userNameHasError 的值。这在 ViewModel 中完成。在 Compose 中,我们定义一个 LaunchedEffect 来观察输入事件,并观察 userNameHasError 状态来修改视图。代码可能如下所示:// ViewModel
var userNameHasError by mutableStateOf(false)
suspend fun validateUsername() {
username.forEachTextValue {
userNameHasError = signUpRepository.isUsernameAvailable(it.toString())
}
}
// Compose
LaunchedEffect(Unit) {
signUpViewModel.validateUsername()
}
if (signUpViewModel.userNameHasError) {
// 显示错误标签
Text(
text = "用户名不可用,请选择一个不同的用户名。"
)
}onValueChange 重写让我们再看一种观察状态变化的方法。 BTF2 允许以以下方式定义状态:var username by rememberSaveable { mutableStateOf("") }
BasicTextField2(
value = username,
onValueChange = {
username = it
}
)这与当前 API BasicTextField v1 的形式完全相同。在这两个 API 之间进行过渡时,对于只有一个字符串的简单情况来说,这种形式更为熟悉。你可能会想:这不是还会出现之前提到的相同错误吗(在编辑过程中出现意外的异步延迟),那它有什么改进之处?虽然这个 API 在 BTF2 和 BTF1 中看起来完全一样,但在底层它们的行为非常不同。BasicTextField2(
// 当 BTF2 被 focus 时,下面这一行会被忽略
value = username,
onValueChange = {
// *** 可能存在的异步耗时任务 *** //
username = it
}
)在此API中,无论发生什么,lambda 内部的代码总是会被调用。因此,如果您在 lambda 内部进行异步调用,这些调用将会被执行。然而,在 TextField 获得焦点时,你的修改不会导致更新状态,它只会接受来自用户输入的内容。这会导致,用户在界面上不会看到任何在 lambda 内部进行的更改。该字段始终得到控制,使界面响应快速、并避免了异步问题。只有当 TextField 失去焦点时,您所做的最后一次更改才会被应用。这种内部机制确保了编辑过程中 TextField 状态的完整性,因为它始终保持了一个可信数据源(用户通过软键盘或开发者设置的),在必要的情况下忽略程序的更改。相反,如果您期望您的更改在特定时间点反映在UI上,那么您可能会感到失望。为避免歧义,此段落附上原文 (Whatever happens inside the lambda is always called, so if you’re making async calls those will be fired. However, the text field won’t update the state with your programmatic changes while the field is in focus, it respects only the input coming from typing events. The result is that the user won’t see any changes applied inside lambda reflected in the UI. The field has the control at all times making it snappy and responsive, avoiding the async issues. Only when your field loses focus, any programatic changes you have done last will be applied. This internal mechanism guarantees the integrity of the text field state during the editing process, because it keeps one source of truth at all times (either user through the software keyboard (IME) or the developer), ignoring programatic changes if it needs to. The counterpart is that you might expect your changes to be reflected in the UI at a certain point in time but they won’t be.)因此,建议只在最简单的情况下使用此 API 形式,例如仅需要一个字符串来表示状态,并且不需要在 TextField 获得焦点时做操作,如修改文本、选区或光标位置等。对于更复杂的情况,请使用我们已经介绍过的带有 TextFieldState 的API形式。用代码编辑文本在许多情况下,我们需要或希望手动操作 TextField 的内容。最简单的例子是添加一个清除按钮来删除 TextField 的内容。为了访问 Edit Session 以对 TextField 内容进行更改,新引入了一个名为 TextFieldBuffer 的 API。class TextFieldBuffer : Appendable {
val length: Int
val hasSelection: Boolean
var selectionInChars: TextRange
fun replace(...)
fun append(...)
fun insert(...)
fun delete(...)
fun selectAll(...)
fun selectCharsIn(...)
fun placeCursorAfterCharAt(...)
}这个类保存了有关 TextField 的信息,比如长度和选区(如果有),还有一些方法可以显式地修改文本,比如替换、追加、插入和删除,以及更改选择和光标位置。为了实现清除按钮,我们可以这样写:// ViewModel
val username = TextFieldState()
fun clearField() {
username.edit {
// 我们在 TextFieldBuffer 的 scope 中
delete(0, length)
}
}注意,我们使用 edit 函数来访问 TextFieldBuffer ,从而可以访问上面描述的所有方法。在上面的例子中,我们删除 TextField State 的内容。这个方法非常常见,所以 BTF2 提供了一个名为 clearText 的扩展函数,可以完全实现相同的功能。所以代码可以简化为:// ViewModel
val username = TextFieldState()
fun clearField() {
username.clearText()
}另一个例子,假设您有一个 Markdown 文本编辑器(例如 Github 的 PR 描述),您想要在选择的文本周围添加 “**” 字符,以表示该文本是粗体的:// Compose
val markdownText = rememberTextFieldState()
BasicTextField2(
state = markdownText
)
// ...
Button(
onClick = {
markdownText.edit {
if (selectionInChars.length > 0) {
insert(selectionInChars.start, "**")
insert(selectionInChars.end, "**")
}
}
},
) {
Text(text = "B", fontWeight = Bold)
}有关如何为 TextFieldBuffer 编写 Markdown 编辑器的进一步示例,请参见 此代码片段。或者在出现错误时选择所有文本:// ViewModel
val username = TextFieldState()
val userNameHasError: StateFlow<Boolean> =
username.textAsFlow()
.mapLatest {
val hasError = signUpRepository.isUsernameAvailable(it.toString())
if (hasError) highlight()
return@mapLatest hasError
}
.stateIn(...)
fun highlight() {
username.edit { selectAll() }
}此时,您可能会意识到这种方式命令 UI 进行文本 edit 与声明式 UI 的范例相悖。确实。为了防止状态同步问题,TextField 的状态更新完全交给内部组件处理,没有真正的状态观察来更新 UI。这是 TextField 的设计选择,就像在 ScrollableState 中的 animateScrollTo 方法一样,您可以命令 UI 做某事。过滤 | 转换输入假设我们要实现一个接受数字验证码的 TextField:为了过滤用户的输入,例如仅接受数字或者去掉特殊字符,你需要定义一个 InputTransformation 。这将在保存到 TextField 状态之前修改用户输入。这是一个不可逆的操作,所以你会丢失不符合你转换规则的输入。这就是为什么我们把它们称为“过滤器”。InputTransformation API 的形式如下:fun interface InputTransformation {
val keyboardOptions: KeyboardOptions? get() = null
fun transformInput(
originalValue: TextFieldCharSequence,
valueWithChanges: TextFieldBuffer
)
}transformInput 方法包含了原始输入文本和带有更改的值,以 TextFieldBuffer 的形式描述,该类在上面的部分中有介绍。 TextFieldBuffer API 提供了一个 ChangeList 类型的更改列表。class TextFieldBuffer {
// 其他字段和方法
val changes: ChangeList get()
interface ChangeList {
val changeCount: Int
fun getRange(changeIndex: Int): TextRange
fun getOriginalRange(changeIndex: Int): TextRange
}
}你可以对更改做任何操作,包括丢弃它们。object DigitsOnlyTransformation : InputTransformation {
override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
override fun transformInput(
originalValue: TextFieldCharSequence,
valueWithChanges: TextFieldBuffer
) {
if (!valueWithChanges.asCharSequence().isDigitsOnly()) {
valueWithChanges.revertAllChanges()
}
}
}
// Compose
BasicTextField2(
state = state,
inputTransformation = DigitsOnlyFilter
)
我们定义了一个实现了 InputTransformation 接口的对象。首先,我们需要实现 transformInput 方法。在我们的例子中,我们检查来自 TextFieldBuffer 的更改。如果它们只包含数字,我们保留这些更改。如果字符不是数字,我们撤销这些更改。这非常简单,因为差异是由内部为我们处理的。请注意,我们还设置了相应的键盘类型为 Number 。由于 InputTransformation 是一个函数式接口,你可以直接向 BasicTextField2 组合中传递 lambda 以描述你的转换,如下所示:BasicTextField2(
state = state,
inputTransformation = { originalValue, valueWithChanges ->
if (!valueWithChanges.asCharSequence().isDigitsOnly()) {
valueWithChanges.revertAllChanges()
}
},
// in this case pass the keyboardOptions to the BFT2 directly, which
// overrides the one from the inputTransformation
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number
)如果你只有一个需要特定 Transformation 的 TextField ,那么这样做就可以了;如果有多个 TextField 需要这种特定的转换,那么提取成 object 更好。接下来,我们需要构建一个最大长度为 6 个字符且全部大写的验证码字段。对于这种常见情况,我们有一些内置的输入转换: maxLengthInChars 用于限制字段长度, allCaps 用于将文本转换为大写。我们可以这样编写验证码:BasicTextField2(
state = state,
inputTransformation = InputTransformation.maxLengthInChars(6)
.then(InputTransformation.allCaps(Locale.current)),
)
我们使用 then 来链接输入转换,过滤器按顺序依次应用。Visual transformation | OutputTransformation ⚠️截至 2023 年 11 月初:⚠️ OutputTransformation API 正在建设中。您可以在 此处 查看进展。在我们的验证码 TextField 示例中,现在我们希望用点替换用户尚未输入的字符,并将它们分组成三个一组,在其中添加一个空格,如下所示:为了解决这个问题以及其他需要格式化 TextField 内容的情况(比如格式化电话号码或信用卡号)您可以定义一个 OutputTransformation 。它将在显示 UI 时格式化内部状态。请注意,与 InputTransformation 不同,应用 OutputTransformation 的结果不会保存到 TextField 状态中。OutputTransformation API 的形式如下:fun interface OutputTransformation {
fun transformOutput(buffer: TextFieldBuffer)
}新 API 的一个巨大好处是,我们无需提供原始原始文本和转换后文本之间的偏移映射。 TextField 会隐式地为我们处理这一点。在这个示例中,我们定义一个实现了 OutputTransformation 接口的对象,并实现 transformOutput :object VerificationCodeOutputTransformation : OutputTransformation {
override fun transformOutput(buffer: TextFieldBuffer) {
// 如果长度不足,则使用占位符字符填充文本
// ··· ···
val padCount = 6 - buffer.length
repeat(padCount) {
buffer.append('·')
}
// 123 456
if (buffer.length > 3) buffer.insert(3, " ")
}
}
首先,我们在 TextField Buffer上调用 append 方法,为任何尚未输入的字符插入“点”。然后我们调用 insert 方法在中间添加一个空格。完事儿,没有旧 API 导致的混乱和崩溃。很不错,对吧?SecureTextField让我们在注册界面中加上 Password Field 。编写 Password Field 是一个非常常见的用例,Jetpack Compose 为此专门提供了一个新的组合控件,基于 BasicTextField2 构建的 BasicSecureTextField :val password = rememberTextFieldState()
BasicSecureTextField(
state = password,
textObfuscationMode = TextObfuscationMode.RevealLastTyped
)textObfuscationMode 有 3 种有用的模式。 RevealLastTyped 是默认模式,与在 View 中将输入类型配置为 textPassword 时的 EditText 的行为相匹配。采用这种行为,您可以在超时之前或输入下一个字符之前短暂地看到最后一个输入的字符。然后是 Hidden ,在这种情况下,您永远看不到输入的字符,还有 Visible ,用于暂时使密码值可见。将 BasicSecureTextField 作为一个独立的组合控件非常强大。它允许团队在内部进行安全优化,确保字段内容在内存中不会比应该保存的时间更长,避免诸如内存欺骗之类的问题。它具有自带小眼睛的 UI 和 textObfuscationModes ,以及与之关联的明确行为,例如对 Text Toolbar 的修改(您无法剪切或复制 Password Field 的内容)。您无法剪切或复制 Password Field 的内容。还有更多…有很多东西可供讨论,但我将只在这儿说其中的3个亮点。新的 BasicTextField2 允许您访问内部滚动状态。像对其他可滚动组合一样(如 LazyLayout ),将滚动状态提升,然后将其传递给 BasicTextField2 ,现在您可以将另一个 Composable(例如 Vertical Slider ) 作为 TextField 的滚动条,以用代码控制滚动:val scrollState = rememberScrollState()
BasicTextField2(
state = state,
scrollState = scrollState,
// ...
)
Slider(
value = scrollState.value.toFloat(),
onValueChange = {
coroutineScope.launch { scrollState.scrollTo(it.roundToInt()) }
},
valueRange = 0f..scrollState.maxValue.toFloat()
)控制 TextField 的滚动团队增加了对更多手势的支持,例如双击选择单词。最后, TextFieldState 为您提供了对 UndoState 类的访问。这个类保存状态的历史值,并提供了 undo 或 redo 编辑更改的有用方法,建立在 TextFieldBuffer 的 ChangeList 之上。您可以用非常少的代码实现撤销/重做支持,就像这样:val state: TextFieldState = rememberTextFieldState()
Button(
onClick = { state.undoState.undo() },
enabled = state.undoState.canUndo
) {
Text("撤销")
}
Button(
onClick = { state.undoState.clearHistory() },
enabled = state.undoState.canUndo || state.undoState.canRedo
) {
Text("清除历史记录")
}非常强大的 API,而且一切都是开箱即用的 😊🎉欲了解更多,请查看 Zach 的演讲,他是该项目的主要工程师之一,负责谷歌的 Compose Text 项目,讲述了这个项目的起源以及当前的进展情况。重新构想 Compose 中的 TextField我们已经看到如何: 使用 InputTransformation 应用过滤器 使用 OutputTransformation 进行视觉转换 用 BasicSecureTextField 编写 Password Field 新的 API 结构还将允许进行一些当前不可能的操作,比如识别编辑过程中的显式更改并访问 TextField 的滚动状态。未来BasicTextField2 目前正在建设中。欢迎在最新的 Compose 1.6.0 中尝试这个 API,并向团队提供反馈。思考一下您正在处理的复杂编辑用例,您还希望 API 支持什么,以及当前的结构是否能够满足您的需求。在尝试之后,您可以提供反馈!🙏在 Google 的问题跟踪器上提交功能请求或 bug @ issuetracker.google.com在 Kotlin Lang Slack 上讨论,频道 #compose在社交媒体上联系 Compose Text 团队的成员:BasicTextField2 在一个名为 text2 的单独包中,因此它是明确可区分的。这只是一个临时包, BasicTextField2 是一个临时名称,API 正在稳定化过程中。接下来,团队即将完成 OutputTransformation API。此外,多文本样式编辑(Multi Text Style Editing)是一个备受期待的功能,而使用这种新的 API 结构使其变得可能。当然还有 Material 封装,以便它可以像任何其他 Material 可组合一样正常工作,并具有正确的样式。敬请关注 BasicTextField2 更多的发布信息。您可以在 Github playground repo 中找到本文中使用的所有代码.
江江说技术
Jetpack Compose 上新:Pager、跑马灯、FlowLayout
2023年3月底,Google 正式发布 Jetpack Compose 的 1.4 版本,它是 Android 的现代原生 UI 工具包。此版本包括新功能,如 Pager 和 Flow Layouts,以及文本样式的新方式,例如连字号和换行行为。它还提高了修饰符的性能并修复了许多错误。Pager 支持在 1.4 版本之前,使用 Pager 需要借助 accompanis 库。如今,Pager 终于被纳入了 Jetpack Compose 基础库中中。它可实现与 View 中的 ViewPager 类似的功能。在 API 设计上,Pager 类似于 LazyColumn,使用起来非常简洁 // 显示 10 个项目
HorizontalPager(pageCount = 10) { page ->
// 每一页的内容,比如显示个文本
Text(
text = "Page: $page",
modifier = Modifier.fillMaxWidth()
)
}总的来说,新的 Pager 基本继承了原先 Accompanist 的 API,只是个别参数有点改变(比如 count -> pageCount)。而对于 Indicator(指示器),仍然可以直接用 accompanist/pager-indicators,最新版已经对 Jetpack Compose 1.4 的 Pager 做了适配。比如下面这个简易的带 Tab 指示器的横向 Pager 例子: @Composable
fun HorizontalPagerWithIndicator() {
val pagerState = rememberPagerState()
TabRow(
// Our selected tab is our current page
selectedTabIndex = pagerState.currentPage,
// Override the indicator, using the provided pagerTabIndicatorOffset modifier
indicator = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
)
}
) {
// Add tabs for all of our pages
pages.forEachIndexed { index, title ->
Tab(
text = { Text(title) },
selected = pagerState.currentPage == index,
onClick = { /* TODO */ },
)
}
}
HorizontalPager(
pageCount = pages.size,
state = pagerState,
) { page ->
Text(
text = "Page $page",
style = MaterialTheme.typography.h5,
modifier = Modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center),
textAlign = TextAlign.Center,
)
}
}效果如下:要查看更多的 Pager 例子,或者想把 Accompanist 实现迁移到新版,请查看迁移指南。更多内容,请参阅Pager 的文档。Flow LayoutFlow Layout 包括 FlowRow 和 FlowColumn ,当一行(或一列)放不下里面的内容时,会自动换行。这些流式布局还允许使用权重进行动态调整大小,以将项目分配到容器中。以下是一个实现房地产应用程序过滤器列表的示例: @Composable
fun Filters() {
val filters = listOf(
"Washer/Dryer", "Ramp access", "Garden", "Cats OK", "Dogs OK", "Smoke-free"
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
filters.forEach { title ->
var selected by remember { mutableStateOf(false) }
val leadingIcon: @Composable () -> Unit = { Icon(Icons.Default.Check, null) }
FilterChip(
selected,
onClick = { selected = !selected },
label = { Text(title) },
leadingIcon = if (selected) leadingIcon else null
)
}
}
}FlowRow 的 content 有 RowScope 接收器,这意味着 RowScope 的一些特有 Modifier 也可以在这里使用,比如 weight修复 Modifier 中的性能问题我们在 10 月份版本中开始了一个重大的内部 Modifier 重构工作,将多个基础 Modifier 迁移到新的 Modifier.Node 架构中。这包括 graphicsLayer、更低层级的焦点修改器、padding、offset 等等。这个重构应该会带来这些 API 的性能改进,并且您不需要更改代码即可获得这些好处。这项工作仍在继续,我们预计在未来的发布中将 Modifiers 迁移到 ui 模块之外,从而获得更多收益。了解更多关于该变化背后原理的信息,请观看ADS演讲 “深入 Compose Modifiers”。增强 Text 和 TextField 的灵活性除了各种性能改进、API稳定性和 bug 修复之外,compose-text 1.4 发布还支持最新的emoji版本,包括向后兼容旧版 Android 🎉🙌。支持此功能无需更改应用程序。如果您正在使用自定义emoji解决方案,请查看 PlatformTextStyle(emojiSupportMatch)另外,我们还解决了使用 TextField 时的主要痛点之一。在某些情况下,在可滚动的 Column 或 LazyColumn 中的文本字段在被聚焦后可能会被屏幕键盘遮挡。我们重新设计了滚动和聚焦逻辑的核心部分,并添加了一些关键 API,如 PinnableContainer 来解决这个问题。最后,我们为 Text 及其 TextStyle 添加了许多新的自定义选项:使用 TextStyle.drawStyle 绘制分级显示的文本。使用 TextStyle.textMotion 改进动画过程中的文本过渡和易读性。使用 TextStyle.lineBreak 配置换行行为。使用内置语义配置(如标题、段落或简单),或使用所需的策略、严格性和分字符值构建您自己的换行符配置。使用 TextStyle.hyphens 添加断字支持。Text 和 TextField 中可使用 minLines 参数定义可见行的最小数量。通过应用 basicMarquee 修饰符打造出跑马灯效果。顺带,因为这是一个修饰符,你可以把它应用于任何 Composable!使用轮廓的选框文本,并使用 drawStyle API 在其上标记形状。 |这张图 webp 压缩的有点问题,可以去文末的代码仓库真实跑一下核心功能的改进和修复为了响应开发人员的反馈,我们在核心库中提供了一些特别受欢迎的功能和错误修复:Test waitUntil 现在接受 Matcher!可以使用此 API 轻松地在特定条件下将测试与 UI 同步animatedContent 现在正确支持中断并返回到其以前的状态。无障碍服务焦点顺序已得到改进:在常见情况下,例如顶部/底部栏,顺序现在更合乎逻辑。如果你提供了一个可选的 onReset lambda,则 AndroidView 可以在 LazyList 中被重用。此改进允许您在 LazyList 中使用复杂的非 Composable 的 View。(!!!)Color.lerp 性能已得到改进,现在执行零分配:由于此方法在淡入淡出动画期间以高频率调用,因此这应该可以减少垃圾回收暂停的数量,尤其是在较旧的 Android 版本上。许多其他次要 API 和错误修复作为常规清理的一部分。有关详细信息,请参阅发行说明。
江江说技术
Jetpack Compose 实现下拉动态渐变切换布局
标题写的好像有点抽象了,上个效果图看看总的来说,就是 A 页面下拉拖动时,B 页面开始从某一位置淡出,直至覆盖全屏。最终实现的布局,格式化后的代码不超过 70 行,一定程度上也体现出 Jetpack Compose 中自定义布局的简洁性。您可以到 这里 下载 APK 体验细节如图所见,我的小应用《译站》 最近做了次 UI 升级,希望参考谷歌翻译实现 “下拉打开历史记录” 的操作。具体来说,页面的变化如下:开始时:页面有一个 Main,其中又分为了 Upper(上面的背景)和 Lower(下面的功能栏) 两个部分,当开始拖动时,前景的初始大小为 MainUpper 的大小(与它重叠),随着用户的拖动逐渐展开,渐渐淡出;同时背景渐渐隐去,直到最后完成切换。实现通过思考要求,我们发现,要知道前景的大小,需要先知道 MainUpper 的大小。这种“子布局大小需要依赖其他子布局来确定”的情景,意味着我们需要使用 SubcomposeLayout 。如果你对此不了解,可以参考 SubcomposeLayout | 你好 Compose那么首先,写个大致的框架出来:@Composable
fun SwipeCrossFadeLayout(
modifier: Modifier = Modifier,
mainUpper: @Composable () -> Unit,
mainLower: @Composable () -> Unit,
foreground: @Composable () -> Unit,
) {
SubcomposeLayout(modifier = modifier) { constraints ->
layout(..., ...) {
}
}
}测量接下来就是测量。因为我的特殊需求,我希望 MainLower 先被测量,之后 MainUpper 再填满剩下的空间。最后才是根据 MainUpper 的大小测量 Foreground 。写几个变量用于保存几个大小,编写代码如下:@Composable
fun SwipeCrossFadeLayout(
modifier: Modifier = Modifier,
mainUpper: @Composable () -> Unit,
mainLower: @Composable () -> Unit,
foreground: @Composable () -> Unit,
) {
var containerHeight by remember { mutableStateOf(100) } // 容器的高度,最初设为 100
var mainUpperHeight by remember { mutableStateOf(0) } // 背景的上半部分的高度,最初设为 0
var lowerPartHeight by remember { mutableStateOf(100) } // 背景的下半部分的高度,最初设为 100
SubcomposeLayout(modifier = modifier) { constraints ->
containerHeight = constraints.maxHeight // 获取容器的最大高度作为 containerHeight
// 先通过 subcompose 和 measure 方法对背景的下半部分进行测量
val mainLowerPlaceable = subcompose(MainLowerKey, mainLower).first().measure(constraints.copy(
minWidth = 0,
minHeight = 0
))
lowerPartHeight = mainLowerPlaceable.height // 记录背景的下半部分的高度
// 再通过 subcompose 和 measure 方法对背景的上半部分进行测量
val mainUpperPlaceable = subcompose(MainUpperKey, mainUpper).first().measure(constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = constraints.maxHeight - lowerPartHeight
// 高度设为容器最大高度减去下半部分的高度
))
mainUpperHeight = mainUpperPlaceable.height // 记录背景的上半部分的高度
layout(..., ...) {
}
}
}到这里,Main 的大小就算完了,接下来就是计算 Foreground 部分的大小了,而这,就需要结合当前拖动的位置动态计算。滑动幸运的是,优秀的 Jetpack Compose 已经为我们提供了 swipeable 修饰符,利用它就可以轻松实现“带有回弹和动画的拖动”效果。如果你不了解,可以参考:滑动(Swipeable) | 你好 Compose。考虑到外部应该可以控制前景的显示与关闭,我们把对应的状态放到参数上:@Composable
fun SwipeCrossFadeLayout(
modifier: Modifier = Modifier,
state: SwipeableState<SwipeShowType> = rememberSwipeableState(SwipeShowType.Main),
mainUpper: @Composable () -> Unit,
mainLower: @Composable () -> Unit,
foreground: @Composable () -> Unit,
)然后添加 swipeable 修饰符:enum class SwipeShowType {
Main,
Foreground
}
SubcomposeLayout(
modifier = modifier.swipeable(
state = state, // 使用 SwipeableState 管理可滑动状态
// anchors 参数定义了滑动到各个位置时触发哪些状态
anchors = mapOf(
0f to SwipeShowType.Main, // 滑动到 0 时显示背景的上半部分
lowerPartHeight.toFloat() to SwipeShowType.Foreground // 滑动到 lowerPartHeight 时显示前景
),
orientation = Orientation.Vertical,
thresholds = { _, _ -> FractionalThreshold(0.5f) } // 设置触发阈值为 0.5f
)
) { constraints ->
}然后就是根据 SwipeableState 的 currentOffset 动态计算前景的高度了,这部分的代码如下: SubcomposeLayout(modifier = modifier) { constraints ->
// ...
val progress = (state.offset.value / lowerPartHeight).coerceIn(0f, 1f) // 计算当前滑动进度,progress 的值在 0 到 1 之间
// 根据滑动进度计算前景的高度
val foregroundHeight = mainUpperHeight + progress * lowerPartHeight
// 测量时固定高度
val foregroundPlaceable = subcompose(ForegroundKey, foreground).first().measure(
constraints.copy(
minWidth = constraints.minWidth,
minHeight = foregroundHeight.toInt(),
maxWidth = constraints.maxWidth,
maxHeight = foregroundHeight.toInt()
)
)
layout(..., ...) {
}
} 摆放最后就是 layout代码的实现,由于要改变 alpha ,因此选用 placeWithLayer 实现。有了上面的 progress,摆放时只需要将 Main 的透明度从 1->0,Foreground 从 0.5->1 (选择 0.5 而不是 0 开始,是因为 0 开始的话,最初的阶段实在看不见)。layout(constraints.maxWidth, constraints.maxHeight) {
if (progress != 1f) {
// 如果滑动进度不为 1,则渐变消失背景的上半部分和下半部分
mainUpperPlaceable.placeRelativeWithLayer(0, 0) {
alpha = 1f - progress
}
mainLowerPlaceable.placeRelativeWithLayer(0, containerHeight - lowerPartHeight) {
alpha = 1f - progress
}
}
if (progress > 0.01f) {
// 如果滑动进度大于 0.01,则渐变显示前景
foregroundPlaceable.placeRelativeWithLayer(0, 0) {
alpha = lerp(0.5f, 1f, progress)
// shadowElevation = if (progress == 1f) 0f else 8f
}
}
}
你可能注意到,上面的代码加了 if 作为判断,这是因为我希望当切换完成后,整个 Composable 只显示 前景 或者 背景 之一。处理嵌套滑动其实到这里,这个布局已经基本可用了。但是,由于我的前景为列表,因此当前景展开后,由于滑动事件被列表消费了,因此没法上滑关闭。因此这里我们要手动处理下嵌套滑动的问题。Jetpack Compose 为嵌套滑动提供了特别的修饰符 .nestedScroll(nestedScrollConnection),具体介绍可以参考 嵌套滑动(NestedScroll) | 你好 Compose。在这里,由于我们要将“列表滑动后多余的偏移量给父布局消费”,因此重写 postScroll 方法,代码如下:val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// 因为 HistoryScreen 是列表,如果滑到底部仍然有多余的滑动距离,就关闭
// Log.d("NestedScrollConnection", "onPostScroll: $available")
if (!swipeableState.isAnimationRunning
&& source == NestedScrollSource.Drag
&& available.y < -30.0f
) {
scope.launch {
swipeableState.animateTo(SwipeShowType.Main)
}
return Offset(0f, available.y)
}
return super.onPostScroll(consumed, available, source)
}
}
}然后就完成了~代码和其他本文的代码可以在 FunnyTranslation/SwipeCrossFadeLayout.kt 找到。作为我个人持续维护的 Jetpack Compose 开源项目,译站最近完成了一次 UI 大更新,除了本文提到的,还有其他有趣的效果。比如下面这个文字动画:码里还有一些花里胡哨的效果,比如 使用 Jetpack Compose 做一个年度报告页面 里展示的。
江江说技术
深入Jetpack Compose——布局原理与自定义布局(二)
在上一篇文章深入Jetpack Compose——布局原理与自定义布局(一) - 掘金 (juejin.cn) 中,我们大致了解了Layout过程并简单实现了两个自定义布局。本次让我们将目光转向Modifier和固有特性测量本文部分参考自Android官方视频:Deep dive into Jetpack Compose layoutsModifier本质关于Modifier的本质,RugerMc大佬在图解 Modifier 实现原理 ,竟然如此简单这篇文章中已经解释地非常清楚了,我就不画蛇添足了。不过为了后续行文方便,我还是在此简单说几点:Modifier 是个接口,包含三个直接实现类或接口:伴生对象 Modifier、内部子接口Modifier.Element和CombinedModifier。伴生对象Modifier是日常使用最多的,后面两者均为内部实现,实际开发中无需关注Modifier.xxx()方法实际上会创建一个Modifier接口的实现类的实例。如Modifier.size()会创建SizeModifer实例@Stable
fun Modifier.size(size: Dp) = this.then(
SizeModifier(
/*省略具体细节*/
)
)Modifier.xxx().yyy().zzz()实际上会创建一个Modifier链,内部顺序遵循 xxx -> yyy -> zzz ,由CombinedModifier连接。Modifie接口提供了foldIn/foldOut方法允许我们顺序/逆序遍历到每个Modifier 这里借上上面文章中的一张图来说明:我们可以简单遍历一下看看。例如:@Composable
fun TraverseModifier() {
val modifier = Modifier
.size(40.dp)
.background(Color.Gray)
.clip(CircleShape)
LaunchedEffect(modifier){
// 顺序遍历Modifier
modifier.foldIn(0){ index , element : Modifier.Element ->
Log.d(TAG, "$index -> $element")
index + 1
}
}
}它的输出为:0 -> androidx.compose.foundation.layout.SizeModifier@78000000
1 -> Background(color=Color(...), brush=null, alpha = 1.0, shape=RectangleShape)
2 -> SimpleGraphicsLayerModifier(...)作用接下来,我们看看Modifier是怎么在布局中起作用的先看一个例子@Composable
fun ModifierSample1() {
// 父元素
Box(modifier = Modifier
.width(200.dp)
.height(300.dp)
.background(Color.Yellow)){
// 子元素
Box(modifier = Modifier
.fillMaxSize()
.wrapContentSize(align = Alignment.Center)
.size(50.dp)
.background(Color.Blue))
}
}它实际显示的效果如下(左上角的圆角是屏幕边缘)我们来逐步看看这到底是怎么发生的。这里我们选择子元素,也就是那个小一点的蓝色Box,来看看它的measure和place过程。首先是measure。父元素明确了自身大小为200*300,该大小也就是子元素能占据的最大空间。因此初始:初始约束 w:0-200, h:0-300fillMaxSize():占据最大空间,约束的min值更改为与max相同,即 w:200-200, h:300-300wrapContentSize():适应内容大小,约束的min值重新变回了0,即 w:0-200, h:0-300size():指定了精准大小。约束变为 w:50-50, h:50-50background():对大小约束无影响最后,在Modifier的一顿操作之下,Box会收到一个 w:50-50, h:50-50 的约束。到这里走过的状态如下:接下来,Box内部的Layout微件执行measure方法得到了自己的大小:50*50。这个大小反向传回到Modifier链的最后一项,并开始place。接下来:background():此处略过size(50.dp):测得自己的大小:50*50,并据此创建自己的位置指令wrapContentSize():测得自己的大小:200*300,并知道自己的子元素大小50*50,且居中放置。据此创建自己的位置指令。fillMaxSize():解析自己的大小和位置这个过程很类似于Layout微件,区别就是每个Modifier只有一个子元素(也就是Modifier链上的下一个元素)。事实上,如果看代码,你也很容易感受到二者的相似之处拿wrapContentSize修饰符的代码举例,其实现类WrapContentModifier的measure方法如下fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
// 设置约束
val wrappedConstraints = Constraints(/* */)
// 测量得到可放置项
val placeable = measurable.measure(wrappedConstraints)
val wrapperWidth = placeable.width.coerceIn(constraints.minWidth, constraints.maxWidth)
val wrapperHeight = placeable.height.coerceIn(constraints.minHeight, constraints.maxHeight)
// layout函数放置到指定位置并返回结果
return layout(
wrapperWidth,
wrapperHeight
) {
val position = alignmentCallback(
IntSize(wrapperWidth - placeable.width, wrapperHeight - placeable.height),
layoutDirection
)
placeable.place(position)
}
}怎么样,是不是很相似?
江江说技术
深入Jetpack Compose——布局原理与自定义布局(三)
固有特性测量或许不少人已经知道,Compose为了提高测绘性能,强行规定了每个微件只能被测量一次。也就是说,我们不能写出类似下面这样的代码:val placeables = measurables.map { it.measure(constrains) }
// 尝试测量第二次,直接报错
val placeablesSecond = measurables.map { it.measure(constrains) }一个小问题那么接下来我们看一个小例子。我们想实现一个菜单,菜单里面有几个菜单栏。于是我们写出了类似这样按的代码但是效果不怎么样,因为每个Text的宽度不一样。看起来有点丑你可能会说,要解决这个问题很简单,为每个Text 添加修饰符fillMaxWidth,让它占满即可。效果如下:但是这样新的问题来了:由于每个Text的Constraint的maxWidth都是最大值,于是咱们的Column宽度也是最大值。于是这个菜单占满了全部屏幕空间。这可不妙!要解决这个问题,我们只需要为Column添加这样一个修饰符Modifier.width(IntrinsicSize.Max)它的宽度就是子微件宽度的最大值啦有Max应该就有Min,咱们试试?宽度变窄了!很神奇吗?这就是固有特性测量的功劳。(如果你好奇为什么最小宽度是这个,因为子微件是文本,而文本的最小宽度是它每行能容纳一个词时的宽度。在这个例子中,就是Send Feedback分成 Send \n Feedback时Feedback这行字的宽度)上面的例子中,Column就适配了固有特性测量这一特性。接下来,我们把自己的实现的VerticalLayout也来适应一下(VerticalLayout具体实现见第一篇)。适配固有特性测量让我们重新把目光转向Layout@Composable inline fun Layout(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
measurePolicy: MeasurePolicy
)之前对于第三个参数,我们是写成了SAM的形式。我们现在再来看看这个MeasurePolicy@Stable
fun interface MeasurePolicy {
fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult
/**
* The function used to calculate [IntrinsicMeasurable.minIntrinsicWidth]. It represents
* the minimum width this layout can take, given a specific height, such that the content
* of the layout can be painted correctly.
*/
fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int
fun IntrinsicMeasureScope.minIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int
fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int
fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurables: List<IntrinsicMeasurable>,
width: Int
): Int
}measure方法是我们之前就用过的,而其余几个拓展函数就是我们要适配 固有特性测量 所需要重写的啦。举个栗子,使用 Modifier.width(IntrinsicSize.Max) ,则会调用 maxIntrinsicWidth 方法,其余同理。接下来,咱们开干。先挑一个吧override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
TODO("Not yet implemented")
}我们以子微件宽度的最大值作为最大约束override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
var width = 0
measurables.forEach {
val childWidth = it.maxIntrinsicWidth(height)
if(childWidth > width) width = childWidth
}
return width
}效果如下:(宽度以单词 Funny 为标准)min的情况也差不多,效果如下:(宽度以单词 is 为标准)完整代码@Composable
fun VerticalLayoutWithIntrinsic(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val measurePolicy = object : MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map { it.measure(constraints) }
// 宽度:最宽的一项
val width = placeables.maxOf { it.width }
// 高度:所有子微件高度之和
val height = placeables.sumOf { it.height }
return layout(width, height) {
var y = 0
placeables.forEach {
it.placeRelative(0, y)
y += it.height
}
}
}
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
var width = 0
measurables.forEach {
val childWidth = it.maxIntrinsicWidth(height)
if (childWidth > width) width = childWidth
}
return width
}
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurables: List<IntrinsicMeasurable>,
height: Int
): Int {
var width = Int.MAX_VALUE
measurables.forEach {
val childWidth = it.maxIntrinsicWidth(height)
if (childWidth < width) width = childWidth
}
return width
}
}
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy
)
}后续关于固有特性测量我们就先看这些。下一篇,我们将探索ParentData和其它特性,继续我们的布局之旅
江江说技术
统一AppCompatActivity获取ViewModel、ViewBinding的入口
主要是统一下在AppCompatActivity获取ViewModel+ViewBinding的入口,先看下最终的封装效果:下面就让我们一步步的实现下这种效果:定义中间类BaseMvvmActivityabstract class BaseMvvmActivity() : AppCompatActivity() {}
这个类继承了AppCompatActivity,将作为之后界面继承的的基类,在这个类中统一获取ViewModel+ViewBinding的入口。封装ViewBinding的获取入口ViewBinding的具体创建函数是ViewBinding.inflate(),首先我们要明确一个点,函数和函数类型是可以相互转化的:private val vb: (LayoutInflater) -> ActivityMainBinding = ActivityMainBinding::inflate
我们将这个创建函数转换成一个函数类型并作为构造参数传入BaseMvvmActivity,其次还要在BaseMvvmActivity中传入<T: ViewBinding>,这样才能够获取ViewBinding的具体实现类型:abstract class BaseMvvmActivity<T: ViewBinding>(
private val vb: (LayoutInflater) -> ActivityMainBinding
): AppCompatActivity() {
protected lateinit var mBinding: VB
}
然后重写onCreate()方法,在该方法中完成具体ViewBinding的创建以及当前界面根布局的设置:override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = vb(layoutInflater)
setContentView(mBinding.root)
}
这样我们就可以在界面内中这样使用:class ZygoteActivity : BaseMvvmActivity<ActivityMainBinding>(
ActivityMainBinding::inflate
) {
fun test2() {
//直接从父类中获取ViewBinding的实现对象
mBinding.iconIv.visibility = View.VISIBLE
}
}
请注意,Fragment可不能进行如此封装,因为如果把ViewBinding的创建函数作为构造函数的一个参数传入,但Fragment创建销毁被重建后,该参数就直接丢失了,这就会造成后续在onCreateView()方法中调用该参数创建ViewBinding时直接空指针异常了。封装ViewModel的获取入口对于ViewModel的创建,我们只需要拿到具体要创建ViewModel的class对象即可,那我们就可以将这个class对象直接通过构造参数传入到BaseMvvmActivity中即可:abstract class BaseMvvmActivity<VB : ViewBinding, VM : ViewModel>(
private val vb: (LayoutInflater) -> VB, private val vmClass: Class<VM>
) : AppCompatActivity() {
}
在BaseMvvmActivity中通过懒加载的形式完成具体ViewModel的创建:protected val mViewModel: VM by lazy {
ViewModelProvider(viewModelStore, defaultViewModelProviderFactory).get(vmClass)
}
然后就可以这样使用:class ZygoteActivity : BaseMvvmActivity<ActivityMainBinding, MainViewModel>(
ActivityMainBinding::inflate,
MainViewModel::class.java
) {
fun test2() {
mBinding.iconIv.visibility = View.VISIBLE
//使用ViewModel
mViewModel.data1.observe(this) {
}
}
}
请注意,如果要创建的具体ViewModel的构造方法带有参数,请重写AppCompatActivity的getDefaultViewModelProviderFactory方法,实现ViewModelProvider.Factory接口自定义一个ViewModel的创建工厂并返回即可。总结经过上面的封装,我们就可以实现文章一开始使用的那种效果,使用起来也是很简单,除了传入的构造参数写起来稍微有一丢丢麻烦,看起来不美观哈!!
江江说技术
Jetpack Compose LazyColumn列表项动画/滑动删除
本文具体包括:列表项数据顺序变更时的轮换动画、添加/删除列表项时的小动画、侧滑删除动画不废话,本文主要介绍的就是一个修饰符:Modifier.animateItemPlacement()。该修饰符首次出现于Jetpack Compose 1.1.0-beta03版本(此版本于2021年11月17日发布),目前尚处于试验性阶段。但它能实现的效果是非常有趣的。顺序变更 动画简单看一个例子: var list by remember { mutableStateOf(listOf("A", "B", "C", "D", "E")) }
LazyColumn {
item {
Button(onClick = { list = list.shuffled() }) {
Text("打乱顺序")
}
}
items(items = list, key = { it }) {
Text("列表项:$it", Modifier.animateItemPlacement())
}
}运行效果如下:实现如此简单!侧滑删除动画在Jetpack Compose中,实现侧滑删除需要用到SwipeToDismiss微件简单例子如下:数据类: data class Student(val id:Int, val name:String)微件 var studentList by remember {
mutableStateOf( (1..100).map { Student(it, "Student $it") } )
}
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(studentList, key = {item: Student -> item.id }){ item ->
// 侧滑删除所需State
val dismissState = rememberDismissState()
// 按指定方向触发删除后的回调,在此处变更具体数据
if(dismissState.isDismissed(DismissDirection.StartToEnd)){
studentList = studentList.toMutableList().also { it.remove(item) }
}
SwipeToDismiss(
state = dismissState,
// animateItemPlacement() 此修饰符便添加了动画
modifier = Modifier.fillMaxWidth().animateItemPlacement(),
// 下面这个参数为触发滑动删除的移动阈值
dismissThresholds = { direction ->
FractionalThreshold(if (direction == DismissDirection.StartToEnd) 0.25f else 0.5f)
},
// 允许滑动删除的方向
directions = setOf(DismissDirection.StartToEnd),
// "背景 ",即原来显示的内容被划走一部分时显示什么
background = {
/*保证观看体验,省略此处内容*/
}
) {
// ”前景“ 显示的内容
/*省略一部分不重要修饰*/
Text(item.name, Modifier.padding(8.dp), fontSize = 28.sp)
}
}
}效果如下:上述代码来自于 android - SwipeToDismiss inside LazyColumn with animation - Stack Overflow其他animateItemPlacement修饰符仅在LazyColumn和LazyRow中有效,并且必须指定key参数。除重排外,由更改alignment或者arrangement引起的位置改变也会被添加动画。此修饰符有一个可选参数:animationSpec: FiniteAnimationSpec<IntOffset>,你可以修改此参数以更改动画效果。
江江说技术
Jetpack Compose + MVI 实现一个简易贪吃蛇
本文基于 Jetpack Compose 框架,采用 MVI 架构实现了一个简单的贪吃蛇游戏,展示了 MVI 在 Jetpack Compose 中的形式,并基于 CompositionLocal 实现了简单的换肤功能(可保存至本地)点此下载 demo:app-debug.apk运行效果环境Gradle 8.0,这需要 Java17 及以上版本Jetpack Compose BOM: 2023.03.00,我之后也会写一篇文章介绍这个版本的更新内容Compose 编译器版本:1.4.0什么是 MVIMVI 是 Model-View-Intent 的缩写,是一种架构模式,它的核心思想是将 UI 的状态抽象为一个单一的数据流,这个数据流由 View 发出的 Intent 作为输入,经过 Model 处理后,再由 View 显示出来。具体到本项目,View 是贪吃蛇的游戏界面,Model 是游戏的逻辑,Intent 是用户和系统的操作,比如开始游戏、更改方向等。View层:基于 Compose 打造,所有 UI 元素都由代码实现Model层:ViewModel 维护 State 的变化,游戏逻辑交由 reduce 处理V-M通信:通过 State 驱动 Compose 刷新,事件由 Action 分发至 ViewModelViewModel 基本结构如下:class SnakeGameViewModel : ViewModel() {
// snakeState,UI 观察它的变化来展示不同的画面
val snakeState = mutableStateOf(
SnakeState(
snake = INITIAL_SNAKE,
size = 400 to 400,
blockSize = Size(20f, 20f),
food = generateFood(INITIAL_SNAKE.body)
)
)
// 分发 GameAction
fun dispatch(gameAction: GameAction) {
snakeState.value = reduce(snakeState.value, gameAction)
}
// 根据不同的 gameAction 做不同的处理,并返回新的 snakeState(通过 copy)
private fun reduce(state: SnakeState, gameAction: GameAction): SnakeState {
val snake = state.snake
return when (gameAction) {
GameAction.GameTick -> state.copy(/*...*/)
GameAction.StartGame -> state.copy(gameState = GameState.PLAYING)
// ...
}
}
}完整代码见:SnakeGameViewModel.ktUI由于代码的逻辑均交给了 ViewModel,所以 UI 层的代码量非常少,只需要关注 UI 的展示即可。@Composable
fun ColumnScope.Playing(
snakeState: SnakeState,
snakeAssets: SnakeAssets,
dispatchAction: (GameAction) -> Unit
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.square()
.onGloballyPositioned {
val size = it.size
dispatchAction(GameAction.ChangeSize(size.width to size.height))
}
.detectDirectionalMove {
dispatchAction(GameAction.MoveSnake(it))
}
) {
drawBackgroundGrid(snakeState, snakeAssets)
drawSnake(snakeState, snakeAssets)
drawFood(snakeState, snakeAssets)
}
}上面的代码使用 Canvas 作为画布,通过自定义的 square 修饰符使其长宽相等,通过 drawBackgroundGrid、drawSnake、drawFood 绘制游戏的背景、蛇和食物。完整代码见:SnakeGame.kt主题本项目自带了一个简单的主题示例,设置不同的主题可以更改蛇的颜色、食物的颜色等看起来区别不大,但是主要目的在于演示 CompositionLocal 的基本用法主题功能的实现基于 CompositionLocal,具体介绍可以参考 官方文档:使用 CompositionLocal 将数据的作用域限定在局部 。简单来说,父 Composable 使用它,所有子 Composable 中都能获取到对应值,我们所熟悉的 MaterialTheme 就是通过它实现的。具体实现如下:定义类我们先定义一个密闭类,表示我们的主题sealed class SnakeAssets(
val foodColor: Color= MaterialColors.Orange700,
val lineColor: Color= Color.LightGray.copy(alpha = 0.8f),
val headColor: Color= MaterialColors.Red700,
val bodyColor: Color= MaterialColors.Blue200
) {
object SnakeAssets1: SnakeAssets()
object SnakeAssets2: SnakeAssets(
foodColor = MaterialColors.Purple700,
lineColor = MaterialColors.Brown200.copy(alpha = 0.8f),
headColor = MaterialColors.Blue700,
bodyColor = MaterialColors.Pink300
)
}上面的 MaterialColors 来自库 FunnySaltyFish/CMaterialColors: 在 Jetpack Compose 中使用 Material Design Color使用我们需要先定义一个 ProvidableCompositionLocal,在这里,因为主题的变动频率相对较低,因此选用 staticCompositionLocalOf 。之后,在 SnakeGame 外面通过 provide 中缀函数指定我们的 Assetsinternal val LocalSnakeAssets: ProvidableCompositionLocal<SnakeAssets> = staticCompositionLocalOf { SnakeAssets.SnakeAssets1 }
// ....
val snakeAssets by ThemeConfig.savedSnakeAssets
CompositionLocalProvider(LocalSnakeAssets provides snakeAssets) {
SnakeGame()
}只需要改变 ThemeConfig.savedSnakeAssets 的值,即可全局更改主题样式啦保存配置到本地(持久化)我们希望用户选择的主题能在下一次打开应用时仍然生效,因此可以把它保存到本地。这里借助的是开源库 FunnySaltyFish/ComposeDataSaver: 在Jetpack Compose中优雅完成数据持久化。通过它,可以用类似于 rememberState 的方式轻松做到这一点框架自带了对于基本数据类型的支持,不过由于要保存 SnakeAssets 这个自定义类型,我们需要提前注册下类型转换器。class App: Application() {
override fun onCreate() {
super.onCreate()
DataSaverUtils = DataSaverPreferences(this)
// SnakeAssets 使我们自定义的类型,因此先注册一下转换器,能让它保存时自动转化为 String,读取时自动从 String 恢复成 SnakeAssets
DataSaverConverter.registerTypeConverters(save = SnakeAssets.Saver, restore = SnakeAssets.Restorer)
}
companion object {
lateinit var DataSaverUtils: DataSaverInterface
}
}然后在 ThemeConfig 中创建一个 DataSaverState 即可val savedSnakeAssets: MutableState<SnakeAssets> = mutableDataSaverStateOf(DataSaverUtils ,key = "saved_snake_assets", initialValue = SnakeAssets.SnakeAssets1)之后,对 savedSnakeAssets 的赋值都会自动触发 异步的持久化操作,下次打开应用时也会自动读取。其他其实这个项目最早创建于 2022-02 ,作为学习 Compose MVI 的项目。但鉴于当时对 Compose 不那么熟练,写着写着放弃了;直到 2023-03-31,我在整理 FunnySaltyFish/JetpackComposeStudy: 本人 Jetpack Compose 主题文章所包含的示例,包括自定义布局、部分组件用法等 时又翻到了这个被我遗忘多时的老项目,于是一时兴起,花了两三个小时把它完成了,并写下了这个 README。或许对后人有些参考?项目还附带了一份 Python 的 Pygame 实现的版本,见 python_version 文件夹,运行 main.py 即可还有一点有趣的事情,当我把 AS 升级到 F(火烈鸟)RC 版本时,发现新建项目时,已经把 Material3 的 Compose 模块放到了第一位了。Google 官方对于推行 Jetpack Compose 的态度,看起来还是很高涨的。或许这样看来,Compose 还是有必要学一学的;毕竟即使是 ChatGPT,由于训练集只到 21 年,在写 Compose 的代码上表现也还不尽如人意呢。(当然,对于下一代、下下一代来说,或许这都不是问题了。但至少不是现在。)
江江说技术
基于Jetpack Compose打造一款翻译APP 【开源】
前言FunnyTranslation 是基于Jetpack Compose写成的翻译软件。它首先是个可以用的软件,其次也是个开源项目。开源地址:FunnySaltyFish/FunnyTranslation: 基于Jetpack Compose开发的翻译软件,支持多引擎、插件化~ | Jetpack Compose+MVVM+协程+Room (github.com)运行截图上面截图显示的所有UI都是基于Jetpack Compose搭建的挑点啥说一说项目开始时觉得没什么,但是真的写起来却发现不少坑。下面简单挑一个例子。导航栏内容与底部同步软件的导航栏是基于Row自己定义的,摘录部分如下: @ExperimentalAnimationApi
@Composable
fun CustomNavigation(
screens : Array<TranslateScreen>,
currentScreen: TranslateScreen = screens[0],
onItemClick: (TranslateScreen) -> Unit
) {
Row(
modifier = Modifier
.background(MaterialTheme.colors.background)
.padding(start=8.dp,end=8.dp,bottom = 4.dp,top = 2.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
screens.forEach{ screen->
CustomNavigationItem(item = screen, isSelected = currentScreen==screen) {
onItemClick(screen)
}
}
}
}UI实现并不复杂,问题在于用它导航的时候首先是点击Icon跳转到对应页面,这一部分理论上并不复杂,用navController.navigate(screen.route)就能达到最简单的效果然后第一个问题出现了:每次进行navigate的时候,都会重新创建对应的Screen,导致之前页面输入的内容啥的都会被清空;而且每一次点开对应页面就会有一个新的,导致返回的时候需要疯狂点击回退,在不同页面间反复横跳……这肯定不行怎么办呢?参考官方文档,说是加上launchSingleTop=true即可,也就是改成下面这样 navController.navigate(screen.route){
launchSingleTop = true
}然后……然后居然还是不行!我不得其解,终于在过了几天后找到了有效的写法: navController.navigate(screen.route){
//当底部导航导航到在非首页的页面时,执行手机的返回键 回到首页
popUpTo(navController.graph.startDestinationId){
saveState = true
}
//从名字就能看出来 跟activity的启动模式中的SingleTop模式一样 避免在栈顶创建多个实例
launchSingleTop = true
//切换状态的时候保存页面状态
restoreState = true
}这应该是从一篇博客上看到的,具体哪篇现在也找不到了。在此表示感谢!上面的问题解决了,新的问题又来了:点击返回键时,导航栏也应该跟着变动,回到主页面。这一部分的需求又该怎么实现呢?最终在开源项目里面一顿乱翻,终于找到了一个解决方案:在NavController.OnDestinationChangedListener回调中使用hierarchy进行匹配,让页面内容与底部导航栏始终保持同步。相关代码如下: val selectedItem = remember { mutableStateOf<TranslateScreen>(TranslateScreen.MainScreen) }
DisposableEffect(this) {
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
when {
destination.hierarchy.any { it.route == TranslateScreen.MainScreen.route } -> {
selectedItem.value = TranslateScreen.MainScreen
}
// ...省略类似的几个
destination.hierarchy.any { it.route == TranslateScreen.ThanksScreen.route } -> {
selectedItem.value = TranslateScreen.ThanksScreen
}
}
}
addOnDestinationChangedListener(listener)
onDispose {
removeOnDestinationChangedListener(listener)
}
}顺便用BackHandler实现了退出时二次确认: BackHandler(enabled = true) {
if (navController.previousBackStackEntry == null){
val curTime = System.currentTimeMillis()
if(curTime - activityVM.lastBackTime > 2000){
scope.launch { scaffoldState.snackbarHostState.showSnackbar(FunnyApplication.resources.getString(R.string.snack_quit)) }
activityVM.lastBackTime = curTime
}else{
exitAppAction()
}
}else{
Log.d(TAG, "AppNavigation: back")
//currentScreen = TranslateScreen.MainScreen
}
}这部分完整代码在这:FunnyTranslation/TransActivity.kt at compose · FunnySaltyFish/FunnyTranslation (github.com)这样类似的地方在应用开发时还有很多,我相信这里的各位能有同感,就不在这搞吐槽大会了。不过既然尝试了一门新技术,就得做好万事碰壁自己探索的准备。这样也才有点Coder的精神嘛~
江江说技术
用了lifecycle-runtime-ktx这些API,写出更优雅的代码
本篇文章主要是介绍lifecycle-runtime-ktx的两个大家用的比较少的API:findViewTreeLifecycleOwner和withCreated/Started/Resumed() 系列。View.findViewTreeLifecycleOwner()这个是ifecycle-runtime-ktx官方库提供的一个扩展方法简化获取LifecycleOwner的逻辑:public fun View.findViewTreeLifecycleOwner(): LifecycleOwner? = ViewTreeLifecycleOwner.get(this)
最终调用:public static LifecycleOwner get(@NonNull View view) {
LifecycleOwner found = (LifecycleOwner) view.getTag(R.id.view_tree_lifecycle_owner);
if (found != null) return found;
ViewParent parent = view.getParent();
while (found == null && parent instanceof View) {
final View parentView = (View) parent;
found = (LifecycleOwner) parentView.getTag(R.id.view_tree_lifecycle_owner);
parent = parentView.getParent();
}
return found;
}
可以看到最终是遍历view树,从View的tag中通过R.id.view_tree_lifecycle_owner获取的:@UnsupportedAppUsage
//键值为非装箱的基本数据类型int
private SparseArray<Object> mKeyedTags;
public Object getTag(int key) {
if (mKeyedTags != null) return mKeyedTags.get(key);
return null;
}
我们看下这个tag是在哪里赋值的:看下AppCompatActivity的setContentView()方法:@Override
public void setContentView(@LayoutRes int layoutResID) {
initViewTreeOwners();
getDelegate().setContentView(layoutResID);
}
走进initViewTreeOwners()方法看下:private void initViewTreeOwners() {
ViewTreeLifecycleOwner.set(getWindow().getDecorView(), this);
...
}
//ViewTreeLifecycleOwner.java
public static void set(@NonNull View view, @Nullable LifecycleOwner lifecycleOwner) {
view.setTag(R.id.view_tree_lifecycle_owner, lifecycleOwner);
}
可以看到,就是在这里进行赋值的,其中这个参数view就是DecorView。LifecycleOwner.withCreated/Started/Resumed()这里我们以LifecycleOwner.withStarted()举例,这个方法是带有返回值的且保证在主线程执行,在未达到指定执行生命周期且返回结果执行完毕之前,运行的协程会进行挂起:public suspend inline fun <R> LifecycleOwner.withStarted(
crossinline block: () -> R
): R = lifecycle.withStateAtLeastUnchecked(
state = Lifecycle.State.STARTED,
block = block
)
最终走到lifecycle.withStateAtLeastUnchecked()方法:@PublishedApi
internal suspend inline fun <R> Lifecycle.withStateAtLeastUnchecked(
state: Lifecycle.State,
crossinline block: () -> R
): R {
//1.指定主线程调度器,判断是否需要分发
val lifecycleDispatcher = Dispatchers.Main.immediate
val dispatchNeeded = lifecycleDispatcher.isDispatchNeeded(coroutineContext)
//2.直接执行,无需分发
if (!dispatchNeeded) {
if (currentState == Lifecycle.State.DESTROYED) throw LifecycleDestroyedException()
if (currentState >= state) return block()
}
//3.挂起执行
return suspendWithStateAtLeastUnchecked(state, dispatchNeeded, lifecycleDispatcher) {
block()
}
}
接下来我们来一步步进行分析:1.判断是否在主线程调度执行Dispatchers.Main.immediate代表主线程调度器,通过isDispatchNeeded判断当前的执行环境是否处于主线程,如果是将执行执行协程代码块,否则需要调度器分发到主线程再进行执行。2.主线程且大于等于STARTED直接进行执行主线程环境下,首先判断当前界面已经处于销毁状态,是直接抛出LifecycleDestroyedException异常,结束;如果界面状态大于等于STARTED状态,才直接执行协程代码块,如果界面状态小于STARTED,就需要调度3.非主线程或小于STARTED挂起协程下面走进suspendWithStateAtLeastUnchecked()函数:@PublishedApi
internal suspend fun <R> Lifecycle.suspendWithStateAtLeastUnchecked(
state: Lifecycle.State,
dispatchNeeded: Boolean,
lifecycleDispatcher: CoroutineDispatcher,
block: () -> R
): R = suspendCancellableCoroutine { co ->
val observer = object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.upTo(state)) {
removeObserver(this)
//2.达到执行`Started`状态恢复挂起的协程
co.resumeWith(runCatching(block))
} else if (event == Lifecycle.Event.ON_DESTROY) {
removeObserver(this)
//3.达到`DESTROYED`状态抛出异常
co.resumeWithException(LifecycleDestroyedException())
}
}
}
//1.添加观察者
if (dispatchNeeded) {
lifecycleDispatcher.dispatch(
EmptyCoroutineContext,
Runnable { addObserver(observer) }
)
} else addObserver(observer)
//3.移除观察者
co.invokeOnCancellation {
if (lifecycleDispatcher.isDispatchNeeded(EmptyCoroutineContext)) {
lifecycleDispatcher.dispatch(
EmptyCoroutineContext,
Runnable { removeObserver(observer) }
)
} else removeObserver(observer)
}
}
首先说明下suspendWithStateAtLeastUnchecked()为什么使用@PublishedApi注解修饰,由于内联函数中调用的方法只能是public方法,而suspendWithStateAtLeastUnchecked()是个internal,所以需要增加该注解声明。suspendCancellableCoroutine()方法捕捉Continuation并决定被挂起的协程的恢复时机。监听界面状态肯定需要添加观察者将协程与界面生命周期绑定,所以这步就是添加观察者;观察者主要干了两件事情:收到界面销毁ON_DESTROY,抛出异常LifecycleDestroyedException,并恢复挂起协程的执行;达到指定的Started状态,直接执行使用runCatching(捕捉异常)包裹的协程代码块,拿到结果后调用resumeWith恢复被挂起的协程;协程被取消则取消观察者注册这个就是为了兜底,一般都是建议使用Activity的lifecycleScope或viewModel的viewModelScope作为协程作用域,因为这两个是和对应组件的生命周期绑定的,这样当组件销毁/清楚,该作用域下的子协程就会被取消,我们通过invokeOnCancellation{}监听到,可以执行一些资源的释放工作,比如这里的取消观察者的注册。总结lifecycle-runtime-ktx库中其他的扩展方法大家都比较熟悉,这里就不再额外进行介绍了.
江江说技术
【杂谈】我用 Jetpack Compose 的这一年
关于这篇文章,其实本来并没有什么写作计划。触发它诞生的事情是,今天早上,QQ空间给我推了这样一条消息:一年前的今天,我敲下了第一行的Hello Android for Jetpack Compose,而到今天,我的个人小项目 译站 早已全面转向 Jetpack Compose 。关于 Compose,我也写了几个简易的开源库、作了几篇小文章。所以今天,不妨来谈谈我接触 Compose 的这一年。本文 不是技术文 ,其中观点仅代表我个人。受限于本人技术水平,难免有误,还望您包容谅解初我接触 Compose 的时间应该还算较早,虽然不是最早的一批,但也算是那时的少数(至少我以为)。彼时,Google 发布 beta 版本,尚且只有 AS 的 Beta 版本支持。为了体验,我将一直使用的AS Stable切成了AS Beta,用默认模板敲出了第一行代码。我是一个对新技术很感兴趣的人,这可能也是我去学习 Compose 的原因。声明式UI的概念虽然相较View很新奇,但鉴于我接触过Vue和Flutter,所以也还能上手。上手是可以上手,但奈何资料确实不多,尤其是中文的。对我来说,早期学习 Compose 的资料基本主要来自Google官网 和 Youtube 。看着英文资料和视频,我逐渐学会了使用Row和Column,学会了要remember,学会了几种副作用的使用,学会了animate*AsState 和 animateVisibility……这段过程中,Youtuber Philipp Lackner 的 Creating Your First Jetpack Compose App 系列对我帮助很大,在此也遥远的表示感谢。于是,在漫不经心地刷了些视频和文档后,我对 Compose 有了个大体的认识。于是我也想为中文资料做点力所能及的贡献,于是参考这个视频和当时的官方教程,写了第一篇关于Compose的文章录了三个之后我鸽了,好吧,这视频的质量实在是不咋滴。没得设备和技术,而且录视频也是个技术活。但是既然学了,肯定要用啊。于是,我把目光转向了我自己的小项目,译站。译站它是我2020年初写的一款简单的翻译小应用,最早是用View实现的。秉持着以用代学的想法,我开始了对这个应用的Compose化。到九月份,我基本实现了它的主页面。(忽略这粗犷的 commit ……)其实这里要说明的是,Compose 的迁移其实并没那么困难。Android 官方提供了非常棒的工具,允许你在View项目中部分使用Compose、或者在Compose中部分使用View。如果你原先的架构合理,View和Model没有非常强的耦合,那么其实改造起来很容易。LiveData或者Flow都可以通过.observeAsState一键转换成可以在 Compose 中使用的 State(Compose 的 UI 由 State 驱动)。我这里之所以花了那么久,有两个原因:一是我正好在重构代码的全部逻辑,包括一些类的分离、一些流程的改变等。秉持着一不做二不休的想法,我选择了把页面全部用 Compose 重写(而不是只改变一部分),故而比较花时间二是有一些很简单的效果实现起来并没有那么容易,尤其是在中文资料极度匮乏的情况下。出了问题或者想实现什么效果,除了翻官方的网站就是翻 Github,这也比较花时间。总之,在各种折腾下,译站的 Compose 版本也逐渐重构完成了,并在那之后均用(准确来说除了悬浮窗和CodeView) Compose 完成各种 UI 效果。不断学习与博客其实对我个人来说,开始学习 Compose 的时候有一个很困惑的点:不知道学完基础之后该学啥了。一方面是资料确实不多,而且视频教程也都是围绕着基本控件之类的来讲;二是我自己没有很大的东西去细究 Compose 的实现。于是三天打鱼两天晒网的,在不断更新应用的间隙间,也偶尔地学习一些新东西。这里很感谢的一份资料是 RugerMc 大佬牵头做的 jetpack-compose-book: Jetpack Compose 基础教程,持续更新 ,很详细的介绍了 Compose 各个方面的使用。对于现在卖课混杂、培训遍地、营销漫天的中文互联网社区环境来说,这样无私的技术分享、开源布道的精神真是说得上不多见了。我自己也为这个项目提了绵薄的几个PR。互联网技术的精神应当在于包容、共享、探索、钻研,至少我是这样觉得的。这段时间,不少大佬们的博客也在惊艳着我,比如程序员江同学、fundroid、Petterp、路很长OoO等,在我学习 Compose 的路上,他们的文章给了我很大帮助,再次均表示感谢。对我来说,尽管我是一个很懒的人,但我还是也尝试着写起了 Compose 相关的文章。一是助于自己提升,二是为中文资料做点绵薄贡献。一点点,到现在我也已经写了13篇关于 Compose 的文章了。或许不足为道,但若是有人看到,说,这个不错,对我有帮助,那便足够了。其中我自己觉得比较好玩的是 Jetpack Compose 自定义布局+物理引擎 = ? 这一篇。在那里,我用 Compose 自定义布局实现了这样的效果没有用,但很有趣。这个想法出现在我高三的时候,但是一直觉得会很难就一直不想做,直到前几天下定决心做了做。在巨人的肩膀上,花了两天就实现了。感觉也蛮有成就感的。如果您感兴趣,可以在Github查看完整源代码总结说点正题的话,就我个人而言,我觉得 Compose 开发有以下优劣:优代码即 UI,灵活、迅捷 (比如 for 循环创建几个控件,if 判断显不显示)对各种需求高度、统一的封装 (通过 LocalXXX 和 rememberXXX 获取和操作各种东西,动画、软键盘、状态栏…… )允许逐步迁移 ( View 体系 和 Compose 体系可良好共存)字面量热重载 (对于类似于 大小、字符串 的修改可直接反映到 App 上)快速的 UI 搭建,无需过多考虑嵌套但就目前来说,也有些问题部分硬需求尚不完善,比如还没有瀑布流布局等性能(如 滑动列表)上较传统 View(如 RV)仍有差距目前中文资料较为欠缺,部分需求实现需要自己摸索精通难度大( Compose 编译原理、性能优化等)
江江说技术
肢解LiveData:协程味的CoroutineLiveData了解一下(一)
本篇文章主要介绍CoroutineLiveData的使用,这是一个基于协程+MediatorLiveData实现的一种build构建livedata的类。普通LiveData的方式class MainViewModel: ViewModel() {
//可变不对外暴漏
private val _data = MutableLiveData<String>()
//对外暴漏
val data: LiveData<String>
get() = _data
fun login() {
//经过一些列网络请求
_data.value = "shengxu"
}
}
然后再Activity中添加观察者等等,请注意这里有个小细节:对外暴漏不可变的Livedata使用的是get() = _data而不是val data: LiveData<String> = _data,这样做的好处是前者减少了一个属性的声明。如果我们想要实现先从本地数据库读取数据,再从远程网络获取数据源,只能这样实现:fun getData() {
//从数据库读取数据
_data.value = "bendi"
//经过一些列网络请求
_data.value = "shengxu"
}
这样有一个不好的问题,可能由于程序的不当逻辑,发生多次getData()的操作,比如横竖屏切换,ViewModel不会销毁且保存着原来的数据,而程序错把getData()的请求放在了比如Activity的onStart()方法中,即使ViewModel有数据也会发生重复向服务器获取数据的情况。针对于这种情况,我们可以将getData()方法放到MainViewModel的init{}代码块中实现,而CoroutineLiveData提供了一种更好的方式。CoroutineLiveDatabuild模式构建val mLiveData = liveData {
//发送单个内容
emit("10")
//发送livedata
emitSource(_data)
}
实际上这个就是创建了一个CoroutineLiveData类型的LiveData,下面的emit()和emitSource()方法就是基于CoroutineLiveData实现。直接通过livedata{}扩展方法动态构建一个不可变的LiveData,调用emit()或者emitSource()方法就相当于调用之前LiveData的setValue()方法,当然后者实现上稍微复杂些。emit()这里随便拿官方的例子演示:val user = liveData<Model> {
var backOffTime = 1_000
var succeeded = false
while(!succeeded) {
try {
emit(api.fetch(id))
succeeded = true
} catch(ioError : IOException) {
delay(backOffTime)
//每次轮询执行的时间间隔都会增加,最大60s
backOffTime *= minOf(backOffTime * 2, 60_000)
}
}
}
请注意livedata{}代码块中的内容默认是执行在主线程中的。在这里我们调用api.fetch(id)模拟轮询执行网络请求直到成功,每次执行的时间间隔都比上次大一些,相比较于在MainViewModel的init{}执行该逻辑,放到livedata的构造代码块中执行显得更加方便,既不会导致init{}代码块过于臃肿,也更加符合单一原则或者封装性(自己感觉...)。emitSource()这个方法需要传入一个LiveData类型的参数:override suspend fun emitSource(source: LiveData<T>): DisposableHandle =
withContext(coroutineContext) {
return@withContext target.emitSource(source)
}
大家应该都用过MediatorLiveData,emitSource()底层的实现就是该类,所以该方法可以理解为监听传入的LiveData<T>数据源变化并将数据赋值到我们通过livedata{}扩展创建的LiveData中。请注意如果在调用emit()方法之前调用了emitSource(),会移除emitSource()添加的LiveData数据源监听,使之无效,具体的源码分析将放到下一篇文章中。总结本篇文章主要介绍了普通LiveData的构建方式和通过livedata{}构建:后者相比较前者更加的灵活,封装性更好,不过缺点是无法在livedata{}代码块之外进行赋值更新LiveData中的数据前者就可以在MainViewModel的各个地方更新LiveData的数据源,请大家根据具体场景进行使用。
江江说技术
Jetpack Compose 1.5 上新:性能升级,内存优化!
昨天,在 KUG 群看到了江佬分享 Compose 的新版本,这次的亮点在于性能上的升级。Compose 的大版本更新我都有发文章,那么这次自然也不落下。一起来看看新版本有些啥吧前几篇: 1.3.0:Jetpack Compose 上新:瀑布流布局、下拉加载、DrawScope.drawText 1.4.0:Jetpack Compose 上新:Pager、跑马灯、FlowLayout官方文档以下内容翻译自 android-developers.googleblog.com/2023/08/wha…今天(2023-08-09),作为 Compose 2023 年 8 月版本材料清单(BOM) 的一部分,我们发布了 Jetpack Compose 版本 1.5,这是安卓现代的本地 UI 工具包,被许多应用程序(例如 Play 商店、Dropbox 和 Airbnb)所使用。此版本主要专注于性能改进,因为我们在 2022 年 10 月版本开始的 Modifer 重构 的主要部分现在已合并。性能在我们首次发布 2021 年的 Compose 1.0 时,我们专注于确保 API 接口设计正确,为构建应用提供牢固的基础。我们希望有一个功能强大且表达能力强的 API,易于使用且稳定,以便开发人员可以自信地在生产中使用它。随着我们不断改进 API,性能成为了我们的首要任务,在 2023 年 8 月版本中,我们已经实现了许多性能改进。Modifer 的性能在此版本中, Modifer 在 Composition 时间上看到了大幅的性能改进,Composition 时间提升高达 80%。最棒的是,由于我们在第一个版本中确保了正确的 API 接口设计,大多数应用只需升级到 BOM 2023.08.00 版本,即可从中受益。我们有一套用于监控性能回归并指导我们改进性能的基准测试。在 Compose 初始的 1.0 版本发布后,我们开始关注可以进行改进的地方。基准测试显示,我们花费了比预期更多的时间用于实例化 Modifer 。 Modifer 占据了 Composition Tree 的绝大部分,因此占据了 Compose 首次组合时间的最大一块儿。在 2022 年 10 月发布的版本中,我们对 Modifer 进行了更高效的设计重构,该版本包含了新的 API 和性能改进,它位于我们的最底层模块 Compose UI 中。高级的 Modifer 依赖于更低级的 Modifier,所以我们开始在 Compose Foundation 将低级 Modifer 迁移到下一个版本,即 2023 年 3 月版本。这包括 graphicsLayer、低级焦点 Modifer 、Padding 和 Offset。这些低级 Modifer 被其他广泛使用的 Modifer (例如 Clickable)调用,并且还被许多基础 Composable(例如 Text)使用。在 2023 年 3 月版本中迁移 Modifer 为这些组件带来了性能改进,但真正的收益将在将更高级别的 Modifer 和 Composable 迁移到新的 Modifer 系统时产生。在 2023 年 8 月版本中,我们已经开始 迁移 Clickable Modifer 到新的 Modifer 系统中,这在某些情况下使 Composition 显著加快,高达 80%。这在包含可点击元素(如按钮)的 LazyColumn 中尤其重要。被 Clickable 使用的 Modifier.indication 仍在迁移过程中,因此我们预计在未来的版本中会有进一步的收益。作为这项工作的一部分,我们发现了在最初的重构中未涵盖的组合 Modifer 用例,并添加了一个新的 API,用于创建消耗 CompositionLocal 实例的 Modifier.Node 元素。我们正在撰写文档,指导您如何将您自己的 Modifer 迁移到新的 Modifier.Node API。要立即开始,请参考我们仓库中的示例。您可以在 Android Dev Summit '22 的 Compose Modifer 深入探讨 中了解更多关于这些变化背后的原因。内存占用此版本包含了许多在内存使用方面的改进。我们仔细检查了在不同的 Compose API 中发生的分配,并在许多方面,特别是在图形堆栈和矢量资源加载方面,减少了总的分配。这不仅减少了 Compose 的内存占用,还直接提高了性能,因为我们花费更少的时间分配内存并减少了垃圾回收。此外,我们修复了在使用 ComposeView 时的 内存泄漏,这将使所有应用受益,特别是那些使用多 Activity 架构或大量的 View/Compose 互操作的应用。文本BasicText 已经迁移到了一个由 Modifer 支持的新渲染系统,这给初始组合时间带来了平均 22% 的收益,而在涉及文本的复杂布局的一个基准测试中,收益高达 70%。一些文本 API 也已经稳定下来,包括:TextMeasurer and 相关 APIsLineHeightStyle.Alignment(topRatio)BrushDrawStyleTextMotionDrawScope.drawTextParagraph.paint (brush, drawStyle, blendMode)MultiParagraph.paint (brush, drawStyle, blendMode)PlatformTextInput核心功能的改进和修复我们还在核心 API 中添加了新功能和改进,同时稳定了一些 API:LazyStaggeredGrid 现在已经稳定。添加了 asComposePaint API,用于替换 toComposePaint,返回的对象包装了原始的 android.graphics.Paint。添加了 IntermediateMeasurePolicy,以支持 SubcomposeLayout 中的Lookahead 测量。添加了 onInterceptKeyBeforeSoftKeyboard Modifer ,以在软键盘出现之前拦截键盘事件。开始吧!我们对所有提交到我们的 问题追踪器 的错误报告和功能请求表示感谢 — 它们帮助我们改进 Compose 并构建您所需的 API。请继续提供您的反馈,帮助我们使 Compose 变得更好!想知道接下来会发生什么?请查看我们的路线图,了解我们目前正在思考和努力开发的功能。我们迫不及待地想看到您接下来会构建什么!Happy composing!看看代码我们可以挑一些变化,看看代码层面到底干了什么Clickable 迁移到新的 Modifier APIandroid.googlesource.com/platform/fr… fun Modifer.clickable(
// ...
onClick: () -> Unit
) = composed(
factory = {
- val onClickState = rememberUpdatedState(onClick)
- val onLongClickState = rememberUpdatedState(onLongClick)
- val onDoubleClickState = rememberUpdatedState(onDoubleClick)
val hasLongClick = onLongClick != null
- val hasDoubleClick = onDoubleClick != null
val pressInteraction = remember { mutableStateOf<PressInteraction.Press?>(null) }
val currentKeyPressInteractions = remember { mutableMapOf<Key, PressInteraction.Press>() }
if (enabled) {
@@ -314,48 +304,27 @@
}
}
}
- val delayPressInteraction = remember { mutableStateOf({ true }) }
+ val centreOffset = remember { mutableStateOf(Offset.Zero) }
val interactionModifier = if (enabled) {
ClickableInteractionElement(
interactionSource,
pressInteraction,
- currentKeyPressInteractions,
- delayPressInteraction
+ currentKeyPressInteractions
)
} else Modifier
- val centreOffset = remember { mutableStateOf(Offset.Zero) }
+ val pointerInputModifier = CombinedClickablePointerInputElement(
+ enabled,
+ interactionSource,
+ onClick,
+ centreOffset,
+ pressInteraction,
+ onLongClick,
+ onDoubleClick
+ )
- val gesture =
- Modifier.pointerInput(interactionSource, hasLongClick, hasDoubleClick, enabled) {
- centreOffset.value = size.center.toOffset()
- detectTapGestures(
- onDoubleTap = /**/,
- onLongPress = /**/,
- onPress = /**/,
- onTap = /**/
- )
- }
Modifier
.genericClickableWithoutGesture(
- gestureModifiers = gesture,
interactionSource = interactionSource,
indication = indication,
indicationScope = rememberCoroutineScope(),
@@ -368,6 +337,7 @@
onLongClick = onLongClick,
onClick = onClick
)相较而言,一些 State 被移除,pointerInput 从原有的 Modifier.pointerInput 改为了 CombinedClickablePointerInputElement,而这个类的实现如下:+private class ClickablePointerInputElement(
+ private val enabled: Boolean,
+ private val interactionSource: MutableInteractionSource,
+ private val onClick: () -> Unit,
+ private val centreOffset: MutableState<Offset>,
+ private val pressInteraction: MutableState<PressInteraction.Press?>
+) : ModifierNodeElement<ClickablePointerInputNode>() {
+ override fun create(): ClickablePointerInputNode = ClickablePointerInputNode(
+ enabled,
+ interactionSource,
+ onClick,
+ centreOffset,
+ pressInteraction
+ )
+
+ override fun update(node: ClickablePointerInputNode) = node.also {
+ it.updateParameters(enabled, interactionSource, onClick)
+ }
+
+ // omit codes like equals, hashCode, toString
+}
+
+private class CombinedClickablePointerInputElement(
+ private val enabled: Boolean,
+ private val interactionSource: MutableInteractionSource,
+ private val onClick: () -> Unit,
+ private val centreOffset: MutableState<Offset>,
+ private val pressInteraction: MutableState<PressInteraction.Press?>,
+ private val onLongClick: (() -> Unit)?,
+ private val onDoubleClick: (() -> Unit)?
+) : ModifierNodeElement<CombinedClickablePointerInputNode>() {
+ override fun create(): CombinedClickablePointerInputNode = CombinedClickablePointerInputNode(
+ enabled,
+ interactionSource,
+ onClick,
+ centreOffset,
+ pressInteraction,
+ onLongClick,
+ onDoubleClick
+ )
+
+ override fun update(node: CombinedClickablePointerInputNode) = node.also {
+ it.updateParameters(enabled, interactionSource, onClick, onLongClick, onDoubleClick)
+ }
+
+ // omit codes like equals, hashCode, toString
+}
+可以看到,原先的几个 State 被合并到了一个 CombinedClickablePointerInputElement 中,而原先的 Modifier.pointerInput 则被拆分成了两个 Modifier,一个是 CombinedClickablePointerInputElement,另一个是 ClickablePointerInputElement,这两个 Modifier 都实现了 ModifierNodeElement 接口,这个接口的作用是用来创建和更新 Modifier.Node部分源码如下:/**
* 一个 [Modifier.Element],用于管理特定 [Modifier.Node] 实现的实例。只有在将创建和更新该实现的 [ModifierNodeElement] 应用于布局时,才能使用给定的 [Modifier.Node] 实现。
*
* [ModifierNodeElement] 应该非常轻量级,除了保存创建和维护关联的 [Modifier.Node] 类型实例所需的信息外,几乎不做其他工作。
*
*/
abstract class ModifierNodeElement<N : Modifier.Node> : Modifier.Element, InspectableValue {
/** 省略一些 Inspect 相关的代码 */
/**
* 在第一次将 Modifier 应用于布局时将调用此函数,应构造并返回相应的 [Modifier.Node] 实例。
*/
abstract fun create(): N
/**
* 当将 Modifier 应用于输入与上次应用不同的布局时调用。此函数将以当前节点实例作为参数传入,预期该节点将被更新到最新状态。
*/
abstract fun update(node: N)
// 省略一些检查器相关的代码、hashCode、equals 等
}
如果我们观察一下它的几个实现,会发现 create 方法用于新建一个 Modifier.Node 实例,而 update 方法则用于更新这个实例。
// ClickableElement
private class ClickableElement(
private val interactionSource: MutableInteractionSource,
private val enabled: Boolean,
private val onClickLabel: String?,
private val role: Role? = null,
private val onClick: () -> Unit
) : ModifierNodeElement<ClickableNode>() {
override fun create() = ClickableNode(
interactionSource,
enabled,
onClickLabel,
role,
onClick
)
override fun update(node: ClickableNode) {
node.update(interactionSource, enabled, onClickLabel, role, onClick)
}
}
// LayoutElement
fun Modifier.layout(
measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) = this then LayoutElement(measure)
private data class LayoutElement(
val measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) : ModifierNodeElement<LayoutModifierImpl>() {
override fun create() = LayoutModifierImpl(measure)
override fun update(node: LayoutModifierImpl) {
node.measureBlock = measure
}
}
internal class LayoutModifierImpl(
var measureBlock: MeasureScope.(Measurable, Constraints) -> MeasureResult
) : LayoutModifierNode, Modifier.Node() {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
) = measureBlock(measurable, constraints)
override fun toString(): String {
return "LayoutModifierImpl(measureBlock=$measureBlock)"
}
}而作为对比,早期的 Modifier.layout 是这样的fun Modifier.layout(
measure: MeasureScope.(Measurable, Constraints) -> MeasureResult
) = this.then(
// 这里直接创建了一个 LayoutModifierImpl,而新版是通过 LayoutElement 来管理的
LayoutModifierImpl(
measureBlock = measure,
inspectorInfo = debugInspectorInfo {
name = "layout"
properties["measure"] = measure
}
)
)
private class LayoutModifierImpl(
val measureBlock: MeasureScope.(Measurable, Constraints) -> MeasureResult,
inspectorInfo: InspectorInfo.() -> Unit,
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
) = measureBlock(measurable, constraints)
// 省略 hashCode、equals、toString 等
}看起来,二者的区别就是早期的 Modifier.layout 直接创建了一个 LayoutModifierImpl,而新版则是通过 LayoutElement (ModifierNodeElement) 来管理的。这个 API 自 Compose 1.3.0-beta01 引入,具体来说是 这个 Commit。而实际上,二者在工作细节上已经有了很大变化。相较于原始的版本,新的 LayoutModifierImpl 改为继承自 Modifer.Node,并且实现了 LayoutModifierNode 接口。你可能很好奇,这样的变更到底有什么用呢?要了解这个问题,我十分推荐你去观看负责这部分更改的团队成员所做的解释:Compose Modifiers deep dive。直观点来说,对于下面这个简单的 Composable由于高级别 Modifier 实际依赖于单个或多个低级别 Modifier,而且有些 Modifier 还会持有状态,在旧的实现中,通过 Modifier.materialize 方法展开后,上面的 Composable 会被展开成下面这样的结构这还不是全部,只是再展开屏幕放不下了 😂而在新的实现中,通过 Modifier.Node 结构,每一个 Modifier 会被对应成一个 Node (也就是通过 ModifierNodeElement::create 创建,ModifierNodeElement::update 更新)。从结构上,它就能被缩减为新版下 Compose Tree 的大致模型更多细节,可以自行参阅源码内存占用关于内存的优化,我们截取 compose.animation 的一些变化来看看Removed allocations in recomposition, color animations, and AndroidComposeView (Ib2bfa)替换局部函数下面是 commit 的注释在组合中删除了最大的分配源(365个实例,也是其他测试中最大的源)。addPendingInvalidationsLocked() 使用了一个方法局部函数,导致每次调用时都会创建一个 Ref$ObjectRef。此更改将该函数升级为一个方法,接收必要的参数,并返回以前直接分配给 addPendingInvalidationsLocked() 中的被无效变量的值。旧的private fun addPendingInvalidationsLocked(values: Set<Any>, forgetConditionalScopes: Boolean) {
var invalidated: HashSet<RecomposeScopeImpl>? = null
fun invalidate(value: Any) {
// 省略具体实现
}
values.fastForEach { value ->
if (value is RecomposeScopeImpl) {
value.invalidateForResult(null)
} else {
// 这里调用了局部函数
invalidate(value)
derivedStates.forEachScopeOf(value) {
invalidate(it)
}
}
}
}新的// 原本的局部函数被分离为了一个 private 的扩展函数
private fun HashSet<RecomposeScopeImpl>?.addPendingInvalidationsLocked(
value: Any,
forgetConditionalScopes: Boolean
): HashSet<RecomposeScopeImpl>? {
var set = this
// 省略具体实现
return set
}
private fun addPendingInvalidationsLocked(values: Set<Any>, forgetConditionalScopes: Boolean) {
var invalidated: HashSet<RecomposeScopeImpl>? = null
values.fastForEach { value ->
if (value is RecomposeScopeImpl) {
value.invalidateForResult(null)
} else {
// 这里调用了上面的函数
invalidated =
invalidated.addPendingInvalidationsLocked(value, forgetConditionalScopes)
derivedStates.forEachScopeOf(value) {
invalidated =
invalidated.addPendingInvalidationsLocked(it, forgetConditionalScopes)
}
}
}
}从代码上无法直观看出,其实秘密藏在编译后。我们举个栗子:// 旧的
fun foo() {
var invalidated: HashSet<Any>? = null
fun bar() {
invalidated = HashSet()
}
bar()
invalidated?.add("1")
}
// 新的
fun foo2(value: Any) {
var invalidated: HashSet<Any>? = null
invalidated = invalidated?.bar2(value)
}
private fun HashSet<Any>.bar2(value: Any): HashSet<Any> {
val set = this
set.add(value)
return set
}看起来差不多,但是反编译后却大相径庭public final void foo() {
final Ref.ObjectRef invalidated = new Ref.ObjectRef();
invalidated.element = null;
<undefinedtype> $fun$bar$1 = new Function0() {
// $FF: synthetic method
// $FF: bridge method
public Object invoke() {
this.invoke();
return Unit.INSTANCE;
}
public final void invoke() {
invalidated.element = new HashSet();
}
};
$fun$bar$1.invoke();
HashSet var10000 = (HashSet)invalidated.element;
if (var10000 != null) {
var10000.add("1");
}
}
public final void foo2(@NotNull Object value) {
Intrinsics.checkNotNullParameter(value, "value");
HashSet invalidated = null;
invalidated = null;
}
private final HashSet bar2(HashSet $this$bar2, Object value) {
$this$bar2.add(value);
return $this$bar2;
}旧的实现中怎么莫名其妙多出了一个 Ref.ObjectRef 和 Function0?Ref.ObjectRef 是局部函数 bar 的闭包,因为 bar 里面用到了 invalidated,所以 invalidated 会被编译成一个 Ref.ObjectRef,而 Ref.ObjectRef 会被传入 bar 中,这样 bar 就能修改 foo 中的 invalidated 了。Function0:这是一个函数类型的匿名内部类,用于封装嵌套函数 bar() 的代码。在 Java 字节码中,函数类型被表示为接口和匿名类的组合。在这里,编译器生成了一个实现了 Function0 接口的匿名内部类,该接口代表一个没有参数和返回值的函数。这个匿名内部类的 invoke() 方法中放置了 bar() 函数的代码。这就是局部函数(可能会产生)的代价。而新的实现中,我们只需要一个 bar2 函数,就能完成同样的功能。这个小变化确实带来了内存开销的优化,如果各位老铁们有对内存开销非常敏感的场景,也可以考虑使用这种方式来替换局部函数。"MutableList +=" -> "for { add }"旧的// toComplete 是一个 MutableSet<> (实际为 LinkedHashSet),而 toApply 是一个 MutableList<> (实际为 ArrayList)
// val toApply = mutableListOf<ControlledComposition>()
// val toComplete = mutableSetOf<ControlledComposition>()
toComplete += toApply
toApply.fastForEach { composition ->
composition.applyChanges()
}新的// We could do toComplete += toApply but doing it like below
// avoids unncessary allocations since toApply is a mutable list
// toComplete += toApply
toApply.fastForEach { composition ->
toComplete.add(composition)
}
toApply.fastForEach { composition ->
composition.applyChanges()
}其中的 fastForEach 是一个内联函数,它的实现如下/**
* 通过 index 来遍历 [List],并且对每一个 item 调用 [action]。
* 这不会像 [Iterable.forEach] 那样分配一个 iterator。
*/
internal inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
contract { callsInPlace(action) }
for (index in indices) {
val item = get(index)
action(item)
}
}如上,区别就是把 += 操作改成了 for 循环。那么这个 += 操作到底做了什么呢?我们来看一下它的实现@kotlin.internal.InlineOnly
public inline operator fun <T> MutableCollection<in T>.plusAssign(elements: Iterable<T>) {
this.addAll(elements)
}可以看到,它实际上是调用了 MutableCollection.addAll 方法。通过 debug 发现,这个 MutableCollection 实际上是一个 LinkedHashSet,而 addAll 方法的实现如下public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}内部实际上是通过 for 循环来遍历,然后调用 add 方法来添加元素。而查看它们反编译后的字节码,确实也是类似的情况:// += 操作
CollectionsKt.addAll(var23, var24);
// for 循环
for(var52 = ((Collection)toApplyNew).size(); index$iv < var52; ++index$iv) {
item$iv = $this$fastForEach$iv.get(index$iv);
composition = (String)item$iv;
var33 = false;
toCompleteNew.add(composition);
}所以我就很好奇,这样的变化实际运行起来又是怎样的呢?借助 ChatGPT 的帮助,我写了一个简单的测试,来对比一下这两种方式的性能差异import org.junit.Test
import kotlin.system.measureNanoTime
class MemoryAllocationTest {
@Test
fun test() {
val iterations = 1000 // Adjust the number of iterations as needed
// Test the old implementation
var oldTimeUsage = 0L
val oldMemoryUsage = measureMemoryUsage {
repeat(iterations) {
val toComplete: MutableSet<String> = mutableSetOf("A", "B", "C")
val toApply: MutableList<String> = mutableListOf("D", "E", "F")
oldTimeUsage += measureNanoTime {
toComplete += toApply
toApply.fastForEach { composition ->
composition.applyChanges()
}
}
}
}
// Test the new implementation
var newTimeUsage = 0L
val newMemoryUsage = measureMemoryUsage {
repeat(iterations) {
val toCompleteNew: MutableSet<String> = mutableSetOf("A", "B", "C")
val toApplyNew: MutableList<String> = mutableListOf("D", "E", "F")
newTimeUsage += measureNanoTime {
toApplyNew.fastForEach { composition ->
toCompleteNew.add(composition)
}
toApplyNew.fastForEach { composition ->
composition.applyChanges()
}
}
}
}
println("Old time usage: $oldTimeUsage, new time usage: $newTimeUsage, ratio: ${oldTimeUsage.toDouble() / newTimeUsage}")
println("Old memory usage: $oldMemoryUsage, new memory usage: $newMemoryUsage, ratio: ${oldMemoryUsage.toDouble() / newMemoryUsage}")
}
@Test
fun test5times(){
repeat(5) {
Runtime.getRuntime().gc()
test()
println()
}
}
private inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
for (index in indices) {
val item = get(index)
action(item)
}
}
private fun String.applyChanges() {
// Simulate applying changes to the string
}
private inline fun measureMemoryUsage(block: () -> Unit): Long {
val runtime = Runtime.getRuntime()
val before = runtime.freeMemory()
block()
val after = runtime.freeMemory()
return before - after
}
} 测试结果如下Old time usage: 2060200, new time usage: 846300, ratio: 2.434361337587144
Old memory usage: 3921656, new memory usage: 547376, ratio: 7.164464645874134
Old time usage: 846600, new time usage: 898700, ratio: 0.9420273728719261
Old memory usage: 545328, new memory usage: 414432, ratio: 1.315844336344684
Old time usage: 595700, new time usage: 940100, ratio: 0.6336559940431868
Old memory usage: 503392, new memory usage: 589192, ratio: 0.8543768415049763
Old time usage: 640500, new time usage: 670500, ratio: 0.9552572706935123
Old memory usage: 587296, new memory usage: 463344, ratio: 1.267516143513243
Old time usage: 541900, new time usage: 772800, ratio: 0.7012163561076604
Old memory usage: 587288, new memory usage: 463368, ratio: 1.267433228017472我在 Kotlin 1.8.10 上运行了多次,结果均有类似的情况。第一次测试,新的实现在内存和时间上都有明显的优势,但是后续的测试,内存上的优势就不明显了,时间上反而经常取得劣势。这里也请教一下各位大佬,这是什么原因呢?结尾与实测文章写到此已经非常长了,不知不觉花了我一天半的时间。Jetpack Compose 一直因为列表性能问题的差距而被人诟病,而如今这一点点问题也在逐步越变越好。
江江说技术
Jetpack实践指南:lifecycle与协程的"猫腻"事(一)
本篇文章主要是讲解如何使用lifecycle创建协程 、源码解析以及lifecycle在协程中的应用。lifecycle创建协程private fun test55() {
//创建协程
lifecycleScope.launch {
}
}
如上很简单,如果想要和Activity的生命周期绑定,还有下面一系列方法供你使用:private fun test55() {
//创建协程
lifecycleScope.launchWhenResumed { }
lifecycleScope.launchWhenCreated { }
lifecycleScope.launchWhenStarted { }
}
只有当对应生命周期执行了,才会执行协程块中的代码。lifecycleScope是什么# LifecycleOwner.kt
val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope
get() = lifecycle.coroutineScope
通过调用链看到,它是lifecycle提供的一个扩展属性:val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
//1.
val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
if (existing != null) {
return existing
}
//2.
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main.immediate
)
//3.
if (mInternalScopeRef.compareAndSet(null, newScope)) {
//4.
newScope.register()
return newScope
}
}
}
1.mInternalScopeRef是lifecycle内部的一个属性:AtomicReference<Object> mInternalScopeRef = new AtomicReference<>();
初次调用其get()方法肯定是一个空的,所以会走到2处。2.创建一个LifecycleCoroutineScopeImpl对象,看下它是个啥:可以看到这就是一个CoroutineScope的子类,所以我们这样就创建了一个协程作用域对象,并且指定:job类型为SupervisorJob,这样子job间发生异常而不会互相影响,阻止向上传递异常;分发器类型为Dispatchers.Main.immediate,默认分发到主线程执行在这里顺便说下Dispatchers.Main.immediate和Dispatchers.Main的区别:首先这两个都是指定把协程块内容分发到主线程中执行,但是前者多了个immediate,这其实是一种优化手段,我们看下官方文档怎么说:简单说,如果创建协程块的线程和要指定的调度线程都是主线程,使用immediate的就不需要额外使用分发器进行分发了,这算是一个优化小手段。3.将创建的这个协程作用域对象通过CAS写入lifecycle的mInternalScopeRef,这样当 lifecycleScope.launch在此获取协程作用域就不会进行重复创建了,直接从mInternalScopeRef获取即可。综上所述, lifecycleScope就是个协程作用域对象,用来在特定job和主线程中执行协程块代码逻辑。4.注册观察者,当界面销毁时取消所有协程的执行LifecycleCoroutineScopeImpl本身就是观察者对象,所以看下register()就是注册观察者:fun register() {
launch(Dispatchers.Main.immediate) {
if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) {
lifecycle.addObserver(this@LifecycleCoroutineScopeImpl)
} else {
coroutineContext.cancel()
}
}
}
同时LifecycleCoroutineScopeImpl重写了onStateChanged()方法:override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (lifecycle.currentState <= Lifecycle.State.DESTROYED) {
lifecycle.removeObserver(this)
coroutineContext.cancel()
}
}
就是为了方便当界面销毁了移除观察者,并取消所有的子协程代码块的执行,避免内存的泄漏。总结之后还会有一篇文章介绍协程是如何绑定Activity的生命周期,即lifecycleScope.launchWhenXXX{}、lifecycle.repeatOnLifecycle(){}原理分析,了解两者的区别能帮助我们写出更加优雅的代码。
江江说技术
使用 Jetpack Compose 做一个年度报告页面
刚刚结束的 2022 年,不少应用都给出了自己的 2022 年度报告。趁着这股热潮,我自己维护的应用《译站》 也来凑个热闹,用 Jetpack Compose 写了个报告页面。效果如下:效果还算不错?如果需要实际体验的,可以前往 这里 下载翻译后打开底部最右侧 tab,即可现场看到。制作过程观察上图,需要完成的有三个难点:闪动的数字淡出 + 向上位移的微件们有一部分微件不参与淡出(如 Spacer)下面将详细介绍闪动的数字在我的上一篇文章 Jetpack Compose 十几行代码快速模仿即刻点赞数字切换效果 中,我基于 AnimatedContent 实现了 数字增加时自动做动画 的 Text,它的效果如下:诶,既然如此,那实现这个数字跳动不就简单了吗?我们只需要让数字自动从 0 变成 目标数字,不就有了动画的效果吗?此处我选择 Animatable ,并且使用 LauchedEffect 让数字自动开始递增,并把数字格式化为 0013(长度为目标数字的长度)传入到上次完成的微件中,这样一个自动跳动的动画就做好啦。代码如下:@Composable
fun AutoIncreaseAnimatedNumber(
modifier: Modifier = Modifier,
number: Int,
durationMills: Int = 10000,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black,
textWeight: FontWeight = FontWeight.Normal
) {
// 动画,Animatable 相关介绍可以见 https://compose.funnysaltyfish.fun/docs/design/animation/animatable?source=trans
val animatedNumber = remember {
androidx.compose.animation.core.Animatable(0f)
}
// 数字格式化后的长度
val l = remember {
number.toString().length
}
// Composable 进入 Composition 阶段时开启动画
LaunchedEffect(number) {
animatedNumber.animateTo(
targetValue = number.toFloat(),
animationSpec = tween(durationMillis = durationMills)
)
}
NumberChangeAnimatedText(
modifier = modifier,
text = "%0${l}d".format(animatedNumber.value.roundToInt()),
textPadding = textPadding,
textColor = textColor,
textSize = textSize,
textWeight = textWeight
)
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun NumberChangeAnimatedText(
modifier: Modifier = Modifier,
text: String,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black,
textWeight: FontWeight = FontWeight.Normal,
) {
Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor, fontWeight = textWeight)
}
}
}
}这样就完成啦~淡出 + 向上位移的微件们实际上,这个标题的难点在于“们”这个字,这意味着不但要完成“向上+淡出”的效果,还要有序,一个一个来。对于这个问题,因为我的需求很简单:所有微件竖着排列,自上而下逐渐淡出。因此,我选择的解决思路是:自定义布局。(这不一定是唯一的思路,如果你有更好的方法,也欢迎一起探讨)。下面我们慢慢拆解:微件竖着放这其实是最简单的一步,你可以阅读我曾经写的 深入Jetpack Compose——布局原理与自定义布局(一) 来了解。简单来说,我们只需要依次摆放所有微件,然后把总宽度设为宽度最大值,总高度设为高度之和即可。代码如下:@Composable
fun AutoFadeInComposableColumn(
modifier: Modifier = Modifier,
content: @Composable FadeInColumnScope.() -> Unit
) {
val measurePolicy = MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints.copy(minHeight = 0, minWidth = 0))
}
var y = 0
// 宽度:父组件允许的最大宽度,高度:微件高之和
layout(constraints.maxWidth, placeables.sumOf { it.height }) {
// 依次摆放
placeables.forEachIndexed { index, placeable ->
placeable.placeRelativeWithLayer(0, y){
alpha = 1
}
y += placeable.height
}.also {
// 重置高度
y = 0
}
}
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)
}上面的例子就是最简单的自定义布局了,它可以实现内部的 Composable 从上到下竖着排列。注意的是,在 place 的时候,我们使用了 placeRelativeWithLayer ,它可以调整组件的 alpha(还有 rotation/transform),这个未来会被用于实现淡出效果。一个一个淡出到了关键的一步了。我们不妨想一想,淡出就是 alpha 从 0->1,y 偏移从 offsetY -> 0 的过程,因此我们只需要在 place 时控制一下两者的值就行。作为一个动画过程,自然可以使用 Animatable。现在的问题是:需要几个 Animatable 呢?自然,你可以选择使用 n 个 Animatable 分别控制 n 个微件,不过考虑到同一时刻其实只有一个 @Composable 在做动画,因此我选择只用一个。因此我们需要增加一些变量:currentFadeIndex 记录当前是哪个微件在播放动画finishedFadeIndex 记录播放完成的最后一个微件的 index,用于检查动画是否结束了实话说这两个变量或许可以合成一个,不过既然写成了两个,那就先这样写下去吧。两个状态可以只放到 Layout 里面,也可以放到专门的 State 中,考虑到外部可能要用到(嘿嘿,其实是真的要用到)两个值,我们单独写一个 State 吧class AutoFadeInColumnState {
var currentFadeIndex by mutableStateOf(-1)
var finishedFadeIndex by mutableStateOf(0)
companion object {
val Saver = listSaver<AutoFadeInColumnState, Int>(
save = { listOf(it.currentFadeIndex, it.finishedFadeIndex) },
restore = {
AutoFadeInColumnState().apply {
currentFadeIndex = it[0]; finishedFadeIndex = it[1]
}
}
)
}
}
@Composable
fun rememberAutoFadeInColumnState(): AutoFadeInColumnState {
return rememberSaveable(saver = AutoFadeInColumnState.Saver) { AutoFadeInColumnState() }
}接下来,为我们的自定义 Composable 添加几个参数吧@Composable
fun AutoFadeInComposableColumn(
modifier: Modifier = Modifier,
state: AutoFadeInColumnState = rememberAutoFadeInColumnState(),
fadeInTime: Int = 1000, // 单个微件动画的时间
fadeOffsetY: Int = 100, // 单个微件动画的偏移量
content: @Composable FadeInColumnScope.() -> Unit
)接下来就是关键,修改 place 的代码完成动画效果。// ...
placeables.forEachIndexed { index, placeable ->
// @1 实际的 y,对于动画中的微件减去偏移量,对于未动画的微件不变
val actualY = if (state.currentFadeIndex == index) {
y + (( 1 - fadeInAnimatable.value) * fadeOffsetY).toInt()
} else {
y
}
placeable.placeRelativeWithLayer(0, actualY){
// @2
alpha = if (index == state.currentFadeIndex) fadeInAnimatable.value else
if (index <= state.finishedFadeIndex) 1f else 0f
}
y += placeable.height
}.also {
y = 0
}相较于之前,代码有两处主要更改。@1 处更改微件的 y,对于动画中的微件减去偏移量,对于未动画的微件不变,以实现 “位移” 的效果; @2 处则设置 alpha 值实现淡出效果,具体逻辑如下:如果是正在动画的那个,alpha 就是当前动画的值,实现渐渐淡出的效果否则,对于已经执行完动画的,alpha 正常为 1;否则为 0(还没轮到它们显示)接下来,问题在于执行完一个如何执行下一个了。我的思路是这样的:添加一个 LauchedState(state.currentFadeIndex) 使得在 currentFadeIndex 变化时(这表示当前执行动画的微件变了)重新把 Animatable 置0,开启动画效果。动画完成后又把 currentFadeIndex 加一,直至完成所有。代码如下:@Composable
fun xxx(...){
LaunchedEffect(state.currentFadeIndex){
if (state.currentFadeIndex == -1) {
// 找到第一个需要渐入的元素
state.currentFadeIndex = 0
}
// 开始动画
fadeInAnimatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = fadeInTime,
easing = LinearEasing
)
)
// 动画播放完了,更新 finishedFadeIndex
state.finishedFadeIndex = state.currentFadeIndex
// 全部动画完了,退出
if(state.finishedFadeIndex >= whetherFadeIn.size - 1) return@LaunchedEffect
state.currentFadeIndex += 1
fadeInAnimatable.snapTo(0f) // snapTo(0f) 无动画直接置0
}
}到这里,一个 内部子微件依次淡出 的自定义布局已经基本完成了。下面问题来了:在 Compose 中,我们使用 Spacer 创建间隔,但是往往 Spacer 是不需要动画的。因此我们需要支持一个特性:允许设置某些 Composable 不做动画,也就是直接跳过它们。这种子微件告诉父微件信息的时期,当然要交给 ParentData 来做允许部分 Composable 不做动画要了解 ParentData,您可以参考我的文章 深入Jetpack Compose——布局原理与自定义布局(四)ParentData,此处不再赘述。我们添加一个 class FadeInColumnData(val fade: Boolean = true) 和 对应的 Modifier,用于指定某些 Composable 跳过动画。考虑到这个特定的 Modifier 只能用在我们这个布局,因此需要加上 scope 的限制。这些代码如下:class FadeInColumnData(val fade: Boolean = true) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any =
this@FadeInColumnData
}
interface FadeInColumnScope {
@Stable
fun Modifier.fadeIn(whetherFadeIn: Boolean = true): Modifier
}
object FadeInColumnScopeInstance : FadeInColumnScope {
override fun Modifier.fadeIn(whetherFadeIn: Boolean): Modifier = this.then(FadeInColumnData(whetherFadeIn))
}有了这个,我们上面的布局也得做相应的更改,具体来说:需要增加一个列表 whetherFadeIn 记录 ParentData 提供的值开始的动画 index 不再是 0 ,而是找到的第一个需要做动画的元素currentFadeIndex 的更新需要找到下一个需要做动画的值具体代码如下:@Composable
fun AutoFadeInComposableColumn() {
var whetherFadeIn: List<Boolean> = arrayListOf()
// ...
LaunchedEffect(state.currentFadeIndex){
// 等待初始化完成
while (whetherFadeIn.isEmpty()){ delay(50) }
if (state.currentFadeIndex == -1) {
// 找到第一个需要渐入的元素
state.currentFadeIndex = whetherFadeIn.indexOf(true)
}
// 开始动画
// - state.currentFadeIndex = 0
for (i in state.finishedFadeIndex + 1 until whetherFadeIn.size){
if (whetherFadeIn[i]){
state.currentFadeIndex = i
fadeInAnimatable.snapTo(0f)
break
}
}
}
val measurePolicy = MeasurePolicy { measurables, constraints ->
// ...
whetherFadeIn = placeables.map { placeable ->
((placeable.parentData as? FadeInColumnData) ?: FadeInColumnData()).fade
}
// 宽度:父组件允许的最大宽度,高度:微件高之和
layout(constraints.maxWidth, placeables.sumOf { it.height }) {
// ...
}
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)
}完成啦!一点小问题事实上,整个布局的大体到目前已经趋于完成,不过目前有点小问题:对于 AutoIncreaseAnimatedNumber ,它的动画执行时机是错误的。你可以想象:尽管数字没有显示出来(alpha 为 0),但实际上它已经被摆放了,因此数字跳动的动画已经开始了。对于这个问题,我的解决方案是为 AutoIncreaseAnimatedNumber 额外添加一个 Boolean 参数 startAnim,只有该值为 true 时才真正开始执行动画。那么 startAnim 什么时候为 true 呢?就是 currentFadeIndex == 这个微件的 Index 时,这样就可以手工指定什么时候开始动画了。代码如下:@Composable
fun AutoIncreaseAnimatedNumber(
startAnim: Boolean = true,
...
) {
// Composable 进入 Composition 阶段,且 startAnim 为 true 时开启动画
LaunchedEffect(number, startAnim) {
if (startAnim)
animatedNumber.animateTo(
targetValue = number.toFloat(),
animationSpec = tween(durationMillis = durationMills)
)
}
NumberChangeAnimatedText(
...
)
}实际使用时Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedNumber(number = 110, startAnim = state.currentFadeIndex == 7) // 或者 >=,如果动画时间长于 fadeInTime 的话
ResultText(text = "次")
}完工!Pager?如你所想,整体的布局是用 Pager 实现的,这个用的是 google/accompanist: A collection of extension libraries for Jetpack Compose 内的实现。鉴于不是本篇重点,此处略过,感兴趣的可以看下面的代码。
江江说技术
Jetpack Compose 自定义绘制——高仿Keep周运动数据页面
废话之前先上图吧,如果不是有人告诉,你可以一眼看出哪个是真哪个是假吗?仿制整个页面(仅仅页面)大概花了我两个小时,不过仅仅是静态的、不可点击的。图有形似而无功能。自定义绘制Jetpack Compose 自定义绘制的文章其实并不少了,基本代码上和View体系基本类似,就是方法上有所差异详细的内容可以见其他作者的文章,如路很长OoO的JetPack-Compose - 自定义绘制 - 掘金 (juejin.cn)RugerMc的使用 Jetpack Compose 完成自定义绘制 - 掘金 (juejin.cn)……我就不赘述上述代码中,中间那块数据图就是自己画的(Keep 用的是 RecyclerView)。大致上,包括这几个部分四个浅色矩形和底部文字三条浅色横线和一条深色横线中间的深色矩形和底部文字中间竖线、矩形和底部的小线段其中1->3的顺序不能更改,因为三条浅色横线在浅色矩形之上,但是在深色矩形之下浅色矩形页面上一共4个浅色矩形,观察Keep可知,它们的高度与 对应数据和运动记录中最长时间之比 成正比所以先计算一下最大的数字,找一些变量存储宽高:// 画布的宽高
val w = size.width
val h = size.height
// 最高的矩形占的高度(3/6)
val maxH = h / 2
// 最大的数字,所有矩形以这个为基准计算高度
val maxNum = listData.maxOf { it.num }
// 以及一个参数
startIndex: Int //最左侧的矩阵对应的index其中列表BeanItemData定义如下:data class ItemData(val content :String, val num : Int)然后计算对应位置(左上角)和大小(宽高)即可for (i in 0 until min(5, listData.size - startIndex)){
val data = listData[startIndex + i]
if (i != 2){
// 画四个浅色矩形
val blockH = data.num.toFloat() / maxNum * maxH
drawRect(lightColor, Offset(w / 4f * i - blockW / 2, 0.833f * h - blockH), Size(blockW, blockH))
}
}浅色矩形对应的文字也是类似,不过由于Canvas并不能直接画文字,所以要先获取到canvas.nativeCanvas再在它上面drawTextfor(...){
// 浅色矩形下方的文字
paint.color = Color.Gray.toArgb()
drawIntoCanvas {
FunnyCanvasUtils.drawCenterText(it.nativeCanvas, data.content, w / 4f * i , h * 7 / 8 + 2f, rect, paint)
}
}
这里用到了一个方法drawCenterText就是让文字以给定的x,y为横向中心点,纵向baseline为基准进行绘制,感兴趣的可以看源码(见文末),此处不在赘述画横线画线的方法就是drawLine,给出两个Offset分别表示起点和终点即可。唯一注意的是,由于要画虚线,所以要设置pathEffect = PathEffect.dashPathEffect并给出一个二元数组(表示线长、间隔)// 三条浅色横线
for (i in 2 until 5){
drawLine(Color.Black.copy(alpha = 0.5f), Offset(0f, h * i / 6), Offset(w, h * i / 6), pathEffect = PathEffect.dashPathEffect(
floatArrayOf(10f,10f)))
}其余的就不赘述了,本质上就是计算着不同位置,画不同的东西而已。可以自行见源码其他内容至于图片上的其他部分,则是这样组成的:最上面一部分直接就是图片(哈哈)这一行是一个Row,两端对齐这一行也是一个Row,以文本baseline对齐下面是Row套三个Column后续暂时就是这样,Jetpack Compose 的 布局和绘制到此5篇(前几篇见我的文章),下一篇我会发个很好玩儿的内容,是Layout的蜜汁用法,敬请期待。
江江说技术
一年时间过去了,LiveData真的被Flow代替了吗? LiveData会被废弃吗?
前言:在去年的这个时候,谷歌官方推荐使用 Flow 替代LiveData,一年时间过去了,我相信还是有很多android开发的朋友和我一样有以下几个问题:Android开发人员需要从 LiveData 迁移到 Kotlin Flows 吗?LiveData 现在是否已弃用?官方文档:developer.android.google.cn/kotlin/flow推荐阅读:zhuanlan.zhihu.com/p/139582669推荐阅读:juejin.cn/post/697900…通过阅读本文你能了解到或学到什么:① Flow, Shared flow & State flow的使用(具体操作本文就不多说了,给大家推荐好文)② SharedFlow 和 StateFlow,它们也有自己的可变类型——MutableSharedFlow 和 MutableStateFlow,对比LiveData我到底用哪一个?③我们已经有了Flow,为啥还会有SharedFlow、StateFlow,Flow不够用吗?④我要迁移到Flow吗?有人问:LiveData是不是真的快要被废弃了。LiveData:你是故意找茬?我要迁移到Flow吗?我们先来回答这个大家最关心的问题结论:如果 LiveData 满足您的需求,那么就不急于替换它,如果是一个新项目,推荐在 UI 中用 LiveData,在Repo层 中用 Flow。下面请欣赏RxJava与LiveData&Flow的爱恨情仇:在2017年之前,大家都是使用RxJava去配合Retrofit实现网络请求,RxJava实现事件订阅。但是,谁用谁知道 (真的复杂,各种线程的切换,头脑爆炸,不过,现在用协程就可以啦) 。因为是真的复杂,对大部分开发者不是很友好,于是在2017年那样的环境下,谷歌推出了LiveData。但是LiveData的功能却完全可以使用RxJava来实现,那么谷歌为啥还要费那么大劲整这么个库出来呢?当然是因为LiveData比较简单啦~(而且RxJava不是谷歌自己的东西,谷歌:我可不想当大冤种)。于是在之后的一段时间中,对于简单场景大家开始使用LiveData了,对于复杂的场景大家还是在使用RxJava。因为LiveData驾驭不了复杂场景啊。(LiveData:我太难了)。不过好在,Flow出现了。Flow:LiveData老弟别怕,大哥给你撑腰来啦!(其实我来代替你来了,嘿嘿)至今,它们之间的爱恨情仇还在继续.....引用 扔物线(朱凯)大佬的话:协程的 Flow 和 RxJava 的功能范围非常相似——其实我觉得就是一样的——但是 Flow 是协程里必不可少的一部分,而协程是 Kotlin 里必不可少的一部分,而 Kotlin 是 Android 开发里必不可少的一部分——哦这个说的不对,重新说——而 Kotlin 又是 Android 现在主推的开发语言以及未来的趋势,这样的话,Flow 一出来,那就没 LiveData 什么事了。别说 LiveData 了,以后 RxJava 也没什么事了。LiveData会被废弃吗?LiveData会因为Flow而被废弃吗?虽然官方一直在推荐使用Flow代替LiveData,但是在GDG的社区中的答案和多位国内外的GDE口中的答案是:不会被废弃!原因有两点:在简单的场景下使用LiveData已经够了,而且LiveData比较简单,上手快,RxJava学习成本真的很高,Flow也相对没有那么简单。Flow 是协程的东西,如果你用的是Java来开发Android,那么你没有办法使用Flow。而且现在招聘平台至少有50%以上的Android岗位还在使用Java,所以LiveData不会被废弃!总结:如果不需要使用到 Kotlin 数据流的强大功能,就用 LiveData。Flow是比LiveData更好,但是在特定的场景下LiveData更合适!SharedFlow 和 StateFlow,对比LiveData我到底用哪一个?核心:LiveData 适用于 MVVM,但不适用于 MVI在MVI中,View通过触发事件与ViewModel通信,然后在ViewModel的内部处理完这些事件后,发出新的ViewState并更新UI。而且使用LiveData处理视图状态非常简单,可以同时用于MVVM和MVI,但是当我们想要像以前一样显示一个简单的Snackbar时问题就来了。如果我们使用LiveEvent类,那么整个单向状态流就会受到干扰,因为我们只是在ViewModel中触发了一个事件来与UI交互,但它应该是相反的。而使用StateFlow和SharedFlow则可以解决这个问题。StateFlow 和 LiveData 有相似之处,两者都是可观察的数据持有者类,它们的不同在于StateFlow需要将初始状态传递给构造函数,而 LiveData 不需要。当视图进入 STOPPED 状态时,LiveData.observe() 会自动取消注册消费者,而 StateFlow不会自动停止收集。如果想要实现相同的功能的话,需要在Lifecycle.repeatOnLifecycle中收集流。SharedFlow 和 StateFlow 之间的主要区别在于,StateFlow 通过构造函数获取一个默认值,并在有人开始收集时便立即发出,而 SharedFlow 不接受任何值,默认情况下什么也不发出。我们已经有了Flow,为啥还会有SharedFlow、StateFlow。Flow不够用吗?StateFlow 和 SharedFlow 是 Flow API,它们使流能够以最佳的方式发出状态更新,并向多个消费者发出消息。单单一个Flow当然是不够的啦Flow是无状态的,它没有 .value属性。它只是一个可以收集的数据流。Flow是声明性的(冷流)。它仅在收集时实现,并且对于每个新收集器都会创建一个新流。对于访问数据库和其他不必每次都重复的操作,这不是一个好的选择。Flow本来是不知道Android的生命周期,但是后来可以通过向LifecycleCoroutineScope添加扩展方法launchWhenStarted来解决,但大多数人不知道如何正确使用它。而且因为Flow有一个订阅计数属性,当Lifecycle.Event达到ON_STOP时该属性不会改变。这意味着Flow将在内存中仍然处于活动状态,就会可能导致内存泄漏!
江江说技术
深入Jetpack Compose——布局原理与自定义布局(四)ParentData
上一篇文章,我们接触了固有特性测量。这一篇,我们将探索ParentDataParentData曾经的例子让我们回忆一下第一篇文章中提到的例子,为了实现如下效果:我们当时使用了这样一串修饰符:Box(modifier = Modifier
.fillMaxSize()
.wrapContentSize(align = Alignment.Center)
.size(50.dp)
.background(Color.Blue))也就是说,子微件的居中是它自己的wrapContentSize(align = Alignment.Center)调整的结果。那么,如果我们现在知道了子微件(小的蓝色方块)被包裹在另一个方块(Box)里,我们能不能让父布局帮忙确定居中位置呢?答案是可以的!Box 在其content作用域中提供了align 方法,这可以让子微件自行告知父布局:我需要居中@Composable
inline fun Box(
modifier: Modifier = Modifier,
// content 提供了 BoxScope
content: @Composable BoxScope.() -> Unit
) {
val measurePolicy = rememberBoxMeasurePolicy(contentAlignment, propagateMinConstraints)
Layout(
content = { BoxScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}而 BoxScope的源码如下:@Immutable
interface BoxScope {
@Stable
fun Modifier.align(alignment: Alignment): Modifier
@Stable
fun Modifier.matchParentSize(): Modifier
}作为接口,在此作用域中,子微件就可以调用align告诉父微件自己的align方式了所以上面的效果这可以这样实现:@Composable
fun ModifierSample2() {
// 父元素
Box(modifier = Modifier
.width(200.dp)
.height(300.dp)
.background(Color.Yellow)){
// 子元素
Box(modifier = Modifier
.align(Alignment.Center)
.size(50.dp)
.background(Color.Blue))
}
}效果是一样的不像我们之前看到的布局修饰符,align是父级数据修饰符。本质上,这类由子微件向父布局通信就是由parentData实现的。如上面的align最终会涉及到如下代码:override val parentData: Any?
get() = with(modifier) {
/**
* ParentData provided through the parentData node will override the data provided
* through a modifier
*/
layoutNode.measureScope.modifyParentData(wrapped.parentData)
}源码ParentDataModifier源码如下:/**
* 一个修饰符[Modifier],为父布局[Layout]提供数据.
* 可在[Layout]的 measurement 和 positioning 过程中通过 [IntrinsicMeasurable.parentData] 读取到.
* parent data 通常被用于告诉父布局:子微件应该如何测量和定位
*/
interface ParentDataModifier : Modifier.Element {
/**
* Provides a parentData, given the [parentData] already provided through the modifier's chain.
*/
fun Density.modifyParentData(parentData: Any?): Any?
}尝试用用:咸鱼的“地摊”接下来我们尝试用用它。我们来假想这样一个布局:小咸鱼的地摊“地摊”里面有一些微件,它们一个一个纵向排列每个子微件都是“付费”的,比如某一个Box“售价”100,另一个Box“售价”200……以此类推每个子微件会显示自己的价格,而“地摊”会显示总价钱上述描述换成代码的话就是:每一个子微件通过自定义的Modifier定义自身的价格,并把它传递给父布局,父布局计算所有的价格累积在一起,并显示出来。开始写代码吧。我们先定义一个类,继承自ParentDataModifier// 作者 FunnySaltyFish (http://funnysaltyfish.fun)
class CountNumParentData(var countNum: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?) = this@CountNumParentData
}(为了简单起见,我们将modifyParentData这个方法直接返回自身了。在原版Column的实现中,这个方法实际类似这样:override fun Density.modifyParentData(parentData: Any?) =
((parentData as? RowColumnParentData) ?: RowColumnParentData()).also {
it.weight = weight
it.fill = fill
})然后我们编写一个简单的Modifier,返回一个实例fun Modifier.count(num: Int) = this.then(
// 这部分是 父级数据修饰符
CountNumParentData(num)
)接下来我们复用一下之前的VerticalLayout,只不过在里面读取一下ParentData而已,部分代码如下var num = 0
Layout(
modifier = modifier,
content = content
) { measurables: List<Measurable>, constraints: Constraints ->
val placeables = measurables.map {
if (it.parentData is CountNumParentData) {
num += (it.parentData as CountNumParentData).countNum
}
it.measure(constraints)
}
// 省略布局的其他代码
Log.d(TAG, "CountChildrenNumber: 总价格是:$num")
}最后运行一下这个例子@Composable
fun CountNumTest() {
CountChildrenNumber {
repeat(5) {
Box(
modifier = Modifier
.size(40.dp)
.background(randomColor())
.count(Random.nextInt(30, 100))
)
}
}
}
对应的总价格输出如下:你可能注意到了,上面的Box里面还用文字指明了自己的“售价”,但调用的代码却没用到Text。这里的文本又是怎么画的呢?答案就是刚刚的countModifier,除了作为父级数据修饰符外,它还发挥了修饰自身的作用。它的代码完整如下:fun Modifier.count(num: Int) = this.drawWithContent {
drawIntoCanvas { canvas ->
val paint = android.graphics
.Paint()
.apply {
textSize = 40F
}
canvas.nativeCanvas.drawText(num.toString(), 0F, 40F, paint)
}
// 绘制 Box 自身内容
drawContent()
}
.then(
// 这部分是 父级数据修饰符
CountNumParentData(num)
)这里用到了绘制时的部分内容,如果你感兴趣的话,后面我还可能介绍一下自定义绘制。嗯,挖了个坑,之后再填吧~ParentData的实际场景主要集中在父布局对子微件的特殊位置和大小的控制上,比如Box的align,Column和Row的align、alignBy、weight上。接下来我们来实现一个简化版的weight吧尝试用用:实现简易版weight为了简易起见,我们实现的weight有如下限制:所有子微件都有weight,按比例实现高度分配父布局的宽高是确定的所以代码的逻辑就是:读取所有weight,按比例分配高度就行。首先类似于Box,我们也写一个VerticalScope,让我们自定义的weight只能在自定义的布局中使用interface VerticalScope {
@Stable
fun Modifier.weight(weight: Float) : Modifier
}然后再自定义我们的ParentDataModifierclass WeightParentData(val weight: Float=0f) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?) = this@WeightParentData
}写一个object,让它实现我们的VerticalScopeobject VerticalScopeInstance : VerticalScope {
@Stable
override fun Modifier.weight(weight: Float): Modifier = this.then(
WeightParentData(weight)
)
}接下来,就是具体的Composable实现了。注意,在此处,我们的content需要加上VerticalScope.@Composable
fun WeightedVerticalLayout(
modifier: Modifier = Modifier,
content: @Composable VerticalScope.() -> Unit
)具体实现类似于之前的VerticalLayout,不同之处在于我们要获取到各个WeightParentData的值并保存下来,计算总的weight。这样就可以按比例分配高度了。关键代码如下:val measurePolicy = MeasurePolicy { measurables, constraints ->
val placeables = measurables.map {it.measure(constraints)}
// 获取各weight值
val weights = measurables.map {
(it.parentData as WeightParentData).weight
}
val totalHeight = constraints.maxHeight
val totalWeight = weights.sum()
// 宽度:最宽的一项
val width = placeables.maxOf { it.width }
layout(width, totalHeight) {
var y = 0
placeables.forEachIndexed() { i, placeable ->
placeable.placeRelative(0, y)
// 按比例设置大小
y += (totalHeight * weights[i] / totalWeight).toInt()
}
}
}
Layout(modifier = modifier, content = { VerticalScopeInstance.content() }, measurePolicy=measurePolicy)测试一下?我们预备让三个Box按1:2:7的高度显示WeightedVerticalLayout(Modifier.padding(16.dp).height(200.dp)) {
Box(modifier = Modifier.width(40.dp).weight(1f).background(randomColor()))
Box(modifier = Modifier.width(40.dp).weight(2f).background(randomColor()))
Box(modifier = Modifier.width(40.dp).weight(7f).background(randomColor()))
}最终效果如下,可以看到,三个Box正确按照1:2:7的比例显示高度成功!后续关于ParentData我们就先看这些。
江江说技术
基于 Compose Runtime 做 PPT UI 库?
当谈到 Jetpack Compose 时,你可能会想到:这是个 UI 库。但实际上,Compose 包含多个子库,也就是:上面的五个基本都和 UI 有关,但今天我们不看它们,我们来玩一玩下面两个:compose.compiler 和 compose.runtime。基于它们,我们可以自己做一个客户端库(Client Library)。Client Library在这里,我们要讨论的是 Client Library。大致上,它就是 “用于编写应用程序的API” 。比如,在 Jetpack Compose 中,它的各个组成部分有这样的层级自上而下, Material 用到了 Foundation,Foundation 用到了 UI,UI 又依赖于 Runtime。前三者都是 Client Library。而今天,我们要做的,就是基于 Runtime 写一个 “compose.ui” 出来Compose Compiler让我们先看看 Compose 编译器吧。在这里,我们不会很深入的涉及各项细节,而是给你一个大致的把控。基本上,Compose 编译器是:一个 Kotlin 编译器插件(Kotlin Compiler Plugin)处理 @Composable 函数生成 Compose Runtime 产物类似于 suspend 函数的处理,Compose 编译器在背后为我们做了许多事情。比如说:注入 Composer:我们之后看稳定性检查:用于判定某个 Composable 是否发生变化,是否需要重组管理 Group:这个和 Slot Table 有关,我们之后也会看到字面量热重载:AS 提供的功能,大家应该不陌生……Compose RuntimeCompose Runtime 可以说是 Compose 的大脑,没有它,就不会有 Jetpack Compose。它负责管理各种状态,是整个 Compose 编程的基石。我们将会从下面四个方面简要介绍它。ComposerComposer 是 Composable 和 Slot Table 间的桥梁。它由 Compose 编译器注入,所有与它相关的代码都由 Compose 编译器处理好了。Composer 负责:插入、更新或者结束 Groups(组) & Nodes(节点)Remember values:Composer 帮我们把各种需要 remember 的值放到 slot table 里去记录各种变化,以便 UI 更新到现在为止,我们的 Composable 经历了这么些流程:我们的 Composable 经过 Compose 编译器,现在多了个 Composer 作为参数。当我们开始运行时,又发生了什么呢?我们上面说,Composer 是 Composable 和 Slot Table 间的桥梁,现在就是和 Slot Table 交互的时机了Slot Table什么是 Slot Table?简单地说,它是一种线性的数据结构,能够高效地处理 “插入” 的过程。它存储着 Composition 的当前状态,也就是说,哪个 Composition 需要重新执行,哪个不需要,这个是记录在 Slot Table 里面的。Slot Table 的工作依赖于两个概念:Groups 和 Slots。Groups 用于将内存中上实际存储的线性结构转换为 UI 所需的树形结构。比方说,对于数组 array = [ComposableA, ComposableB, ComposableC],要能变成- ComposableA
- ComposableB
- ComposableCSlots 则负责存储 Group 中的真实数据,比如说 rememebr { "FunnySaltyFish" },里面的 FunnySaltyFish 就实际存储在 Slots 中。如需详细了解 Slot Table,可参阅 fundroid 大佬写的 探索 Jetpack Compose 内核:深入 SlotTable 系统 或者由 Google 工程师写的 Under the hood of Jetpack Compose — part 2 of 2现在,我们把目光重新转回去。我们现在知道,Composable 在 runtime 中被调用,它和 Slot Table 交互,而 Slot Table 中又包含着来自 Composable 的数据。但好像这都和用户没关系诶?目前的这些都是 Compose 内部在互相玩耍。接下来会发生什么呢?我们已经提到过,Composer 会记录状态的改变,比如说首次的 Composition,或者后来的 Re-composotion,它记录到了这些变化,然后,这些变化会被交给 Applier 来使用。来看看 Applier 吧ApplierApplier 负责应用那些 Composition 中被提交的基于树的操作。也就是说,Composer 提交了这些变化,Applier 就负责在 Node Tree 上执行它们。这些操作主要包含:插入、移动、删除(或者甚至清空整棵树)有趣的是,如果你查看 Applier 的源码,你会发现它是个接口:/**
* An Applier is responsible for applying the tree-based operations that get emitted during a
* composition. Every [Composer] has an [Applier] which it uses to emit a [ComposeNode].
*
* A custom [Applier] implementation will be needed in order to utilize Compose to build and
* maintain a tree of a novel type.
*/
@JvmDefaultWithCompatibility
interface Applier<N> {
xxx
}而它有个实现的抽象类 AbstractApplier
/**
* An abstract [Applier] implementation.
*/
abstract class AbstractApplier<T>(val root: T) : Applier<T> {
xxx
}因此,如果我们需要写自定义的 Applier 的话,就需要继承这个抽象类。好嘞,现在我们有了 Applier,我们知道它会把 change 作用到 Compose Node Tree 上。整个流程现在变成了这样:那么新的问题来了,这个 Compose Node Tree 是怎样被创建的呢?Compose Node Tree假设我们现在有两个 Composable,Hi 和 Dev现在,这两个 Composable 都会提交一个 Node 给 Applier,Applier 获取到了这个节点,就可以按情况创建或者更新 Compose Node Tree。那么,Compose Node 又是怎么被提交的呢?我们说 “提交一个节点”,大致上就是在说 “创建一个你自己的节点,让它代表一个 Composable”。为了让 Compose 知道我们想要提交什么类型的节点,我们需要选择合适的 Node 类型,它们包括:ComposableNode 和 ReusableComposeNode从字面上可以看到,ReusableComposeNode 就是 “可以被重新使用的 Compose Node”,即:避免重新创建一个新 Node,而是直接复用某个节点。它们的代码差异如下:/**
* Emits a node into the composition of type [T].
*
* This function will throw a runtime exception if [E] is not a subtype of the applier of the
* [currentComposer].
*
* @param factory A function which will create a new instance of [T]. This function is NOT
* guaranteed to be called in place.
* @param update A function to perform updates on the node. This will run every time emit is
* executed. This function is called in place and will be inlined.
*
*/
// ComposeNode is a special case of readonly composable and handles creating its own groups, so
// it is okay to use.
@Suppress("NONREADONLY_CALL_IN_READONLY_COMPOSABLE", "UnnecessaryLambdaCreation")
@Composable inline fun <T : Any, reified E : Applier<*>> ComposeNode(
noinline factory: () -> T,
update: @DisallowComposableCalls Updater<T>.() -> Unit
) {
if (currentComposer.applier !is E) invalidApplier()
// 注意看,这里是 startNode
currentComposer.startNode()
if (currentComposer.inserting) {
currentComposer.createNode { factory() }
} else {
currentComposer.useNode()
}
Updater<T>(currentComposer).update()
currentComposer.endNode()
}
/**
* Emits a recyclable node into the composition of type [T].
*
* This function will throw a runtime exception if [E] is not a subtype of the applier of the
* [currentComposer].
* @param factory A function which will create a new instance of [T]. This function is NOT
* guaranteed to be called in place.
* @param update A function to perform updates on the node. This will run every time emit is
* executed. This function is called in place and will be inlined.
*/
// ComposeNode is a special case of readonly composable and handles creating its own groups, so
// it is okay to use.
@Suppress("NONREADONLY_CALL_IN_READONLY_COMPOSABLE", "UnnecessaryLambdaCreation")
@Composable inline fun <T : Any, reified E : Applier<*>> ReusableComposeNode(
noinline factory: () -> T,
update: @DisallowComposableCalls Updater<T>.() -> Unit
) {
if (currentComposer.applier !is E) invalidApplier()
// 而这里是 startReusableNode
currentComposer.startReusableNode()
if (currentComposer.inserting) {
currentComposer.createNode { factory() }
} else {
currentComposer.useNode()
}
Updater<T>(currentComposer).update()
currentComposer.endNode()
}上面两个函数都是泛型函数,也就是说,你需要自己指定类型,告诉 Compose 你需要提交什么类型的 Node。例如,在刚刚的示例中,我们提交一个自定义的 TextNode,它们会交给 Applier,根据情况创建或更新 Compose Node Tree。现在我们已经知道 Compose Node Tree 是怎么创建或更新的了。但现在还差一步:我们还没画 UI,还没显示任何东西。到这里,Compose Runtime 已经都给你准备好了,最后的一步就是让它显示出来而已。这也就是我们今天的主题:做一个显示在 PPT 里的 UI 库。ComposePPT我们要做的最后一步叫做 Client Intergration(客户端集成),具体来说,它包括:获取到 Composable 的 content:你需要知道要显示啥创建 Frame Clock 和 Recomposer:Recomposer 负责管理 Recomposition,监听状态变化,让 Composable 知道:我需要 Recomposition 了创建最初的 Composition观察并应用 Snapshot(快照) objects 的变化刷新 Frame我们选择了 PowerPoint 作为客户端,其实你也可以选其他的,用 View 啊、用 Canvas 啊、用 HTML 啊或者什么乱七八糟的,但我们这选 PPT 做演示。For Fun!我们希望能呈现这样一个效果:一页幻灯片(Slide),里面包含一行文本我们需要啥?首先,我们需要定义一些节点,比如 TextNode、SlideNode,用来表示文本和幻灯片(当然也可以有其他的,比如 Video、Image,但我们这先演示这俩);其次,我们需要自定义的 Applier,让 Compose 知道如何去创建/插入我们的节点,如何更新之类的;再然后,我们需要 Composable,这里也是两个, Text 和 Slide;最后,我们需要做 Client Integration。让我们开始吧~自定义 Node我们先定义一个抽象类,表示咱们的 PPTNode它有一个成员 children, 表示子节点;一个方法 render 用于渲染,在我们这,就是渲染成 PPT。SlideNodeSlideNode 表示一张幻灯片,有点类似于 布局。它的代码就简单了,render 方法就循环调用一遍 children.render。TextNodeTextNode 略有不同,我们希望它有一些数据(text),并把它渲染出来。它的代码如下:这里用的是 Apache POI 实现的渲染,我们不会深入细节。如果你感兴趣,可以查看对应源代码、自定义 Applier下一步是自定义 Applier,也就是继承 AbstractApplier。实际上,AbstractApplier 已经实现了一些逻辑,因此我们需要重写一些方法。它们主要包括:AbstractApplier 提供了两个 insert 方法,分别是自顶向下和自底向上,我们只需要按要求实现即可。这里我们只用自顶向下的 insert 就行其他操作 包括 remove/move/clear 之类的创建 Composable下一步就是创建 Composable。听起来很简单,但它实际上和你平时写的有点不一样。因为,现在我们现在需要提交我们刚刚创建的 Node。我们调用 ComposeNode 函数,并传入刚刚写好的 Node 和 Applier。我们先从 Slide 开始吧这里的几个参数,factory 传入了 SlideNode 的构造函数update 会在每次 Composable 被重新调用时触发,但这里由于没有任何数据,这里就不写了Text 的代码类似,如下:不同的是,由于 Text 持有数据(text),因此需要更新一下。客户端集成好,现在最后一步。我们在这儿要给 Composable 一个入口(类似于 setContent {})。在这儿,我们用 runBlocking 执行一些逻辑我们首先需要创建一个 frameClock,这里调用的是 Compose 已经给出的实现;然后是 Recomposer 和 rootNode(在这里,也就是 SlideNode)然后是创建 Composition、设置 content 为我们传入的 content接下来我们需要观察快照的变更、运行 Recomposer、向 clock 不断发送帧……最后调用 rootNode.render() 完成渲染好耶,我们现在就完成啦~怎么用呢?比如说,在 main 函数调用我们的入口函数,即可当你运行时,你就发现生成了对应的 PPT最后本项目的源代码在:fgiris/composePPT: An experimental UI toolkit for generating PowerPoint presentation files using Compose,欢迎建议和 PR。我们最后做个总结吧:Compose 编译器转换 Composable 函数Composer 是 Composable 和 Slot Table 之间的桥梁Slot Table 持有当前 Composition 的状态Applier 负责对提交的 Node 做基于树的操作如果想基于 Compose 创建一个客户端库这就是今天的内容了,感谢大家的观看(在这里是阅读哈哈)。
江江说技术
Jetpack Compose 十几行代码快速模仿即刻点赞数字切换效果
缘由四点多刷掘金的时候,看到这样一篇文章: 自定义View模仿即刻点赞数字切换效果,作者使用自定义绘制的技术完成了数字切换的动态效果,也就是如图:不得不说,作者模仿的很像,自定义绘制玩的炉火纯青,非常优秀。不过,即使是这样简单的动效,使用 View 体系实现起来仍然相对麻烦。对上文来说,作者使用的 Kotlin 代码也达到了约 170 行。Composable如果换成 Compose 呢?作为声明式框架,在处理这类动画上会不会有奇效?答案是肯定的!下面是最简单的实现:Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor)
}
}
}你没看错,这就是 Composable 对应的简单模仿,核心代码不过十行。它的大致效果如下:能看到,在数字变化时,相应的动画效果已经非常相似。当然他还有小瑕疵,比如在 99 - 100 时,最后一位的 0 没有初始动画;比如在数字减少时,他的动画方向应该相反。但这两个问题都是可以加点代码解决的,这里核心只是思路原理与上文作者将每个数字当做一个整体对待不同,我将每一位独立处理。观察图片,动画的核心在于每一位有差异时要做动画处理,因此将每一位单独处理能更好的建立状态。Jetpack Compose 是声明式 UI,状态的变化自然而然就导致 UI 的变化,我们所需要做的只是在 UI 变化时加个动画就可以。而刚好,对于这种内容的改变,Compose 为我们提供了开箱即用的微件:AnimatedContentAnimatedContent此 Composable 签名如下:@Composable
fun <S> AnimatedContent(
targetState: S,
modifier: Modifier = Modifier,
transitionSpec: AnimatedContentScope<S>.() -> ContentTransform = {
...
},
contentAlignment: Alignment = Alignment.TopStart,
content: @Composable() AnimatedVisibilityScope.(targetState: S) -> Unit
)重点在于 targetState,在 content 内部,我们需要获取到用到这个值,根据值的不同,呈现不同的 UI。AnimatedContent 会在 targetState 变化使自动对上一个 Composable 执行退出动画,并对新 Composable 执行进入动画 (有点幻灯片切换的感觉hh),在这里,我们的动画是这样的:slideIntoContainer(AnimatedContentScope.SlideDirection.Up)
with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)上半部分的 slideIntoContainer 会执行进入动画,方向为自下向上;后半部分则是退出动画,由向上的路径动画和淡出结合而来。中缀函数 with 连接它们。这也体现了 Kotlin 作为一门现代化语言的优雅。关于 Compose 的更多知识,可以参考 Compose 中文社区的大佬们共同维护的 Jetpack Compose 博物馆。代码本文的所有代码如下:import androidx.compose.animation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun NumberChangeAnimationText(
modifier: Modifier = Modifier,
text: String,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black
) {
Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor)
}
}
}
}
@Composable
fun NumberChangeAnimationTextTest() {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
var text by remember { mutableStateOf("103") }
NumberChangeAnimationText(text = text)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
// 加一 和 减一
listOf(1, -1).forEach { i ->
TextButton(onClick = {
text = (text.toInt() + i).toString()
}) {
Text(text = if (i == 1) "加一" else "减一")
}
}
}
}
}这个示例也被收录到了我的 JetpackComposeStudy: 本人 Jetpack Compose 主题文章所包含的示例,包括自定义布局、部分组件用法等 里,感兴趣的可以去那里查看更多代码。
江江说技术
Jetpack Compose 性能优化指南:编译指标(上)
当一个团队开始使用 Jetpack Compose 时,他们中的大多数人最终会发现少了一块拼图:如何测量可组合项(Composable)的性能。在 Jetpack Compose 1.2.0中,Compose 编译器添加了一个新功能,它可以在构建时输出各种与性能相关的指标,让我们能够窥视幕后,看看潜在的性能问题在哪些地方。在这篇博文中,我们将探索新的指标,看看我们能找到什么。在开始阅读之前需要了解的一些事项:最终写完后的结果显示,这是一篇很长 的博文,涵盖了 Compose 的许多工作原理。所以阅读这篇文章可能得花点时间。本文仅仅设立了一些预期,到结尾也没有真正做成什么“明显的成效”😅。但是,希望您能更好地了解您在设计上的选择将如何影响 Compose 的工作方式。如果您没有立即理解这里的所有内容,请不要感到难过——这是一个高级 主题!如果您有什么疑惑,我已尝试列出相关资源以供进一步阅读。我们在这里捣鼓的一些事情可以被认为是“细微优化”。与任何涉及优化的任务一样:首先profile(分析)和test(测试)! 新的JankStats 库是一个很好的切入点。如果您在真实设备上的性能没有问题,那么在这上面您可能无需做太多事情。有了这个,让我们开始吧......🏞启用指标我们的第一步是通过一些编译器标志启用新的编译器指标。对于大多数应用程序,在所有模块上启用它的最简单方法是使用全局 开/关 开关。在您的根目录build.gradle中,您可以粘贴以下内容: subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {if (project.findProperty("myapp.enableComposeCompilerReports") == "true") {
freeCompilerArgs += ["-P","plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + project.buildDir.absolutePath + "/compose_metrics"]
freeCompilerArgs += ["-P","plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + project.buildDir.absolutePath + "/compose_metrics"]}}}
}每当您在myapp.enableComposeCompilerReports属性被启用的情况下运行 Gradle 构建时,这都会启用必要的 Kotlin 编译器标志,如下所示: ./gradlew assembleRelease -Pmyapp.enableComposeCompilerReports=true一些注意事项:请在release版本上运行它,这很重要。 我们稍后会看到为什么。您可以根据需要重命名该myapp.enableComposeCompilerReports属性。您可能会发现您需要同时使用 --rerun-tasks 选项运行上述命令,以确保 Compose 编译器即使在有缓存的情况下也正常运行。相应指标和结果报告将被写入每个模块的构建目录中的compose_metrics文件夹。一般情况来说,它将位于<module_dir>/build/compose_metrics. 如果您打开其中一个文件夹,您会看到如下内容:Compose 编译器指标的输出注意:从技术上讲,报告 ( module.json ) 和指标(其他 3 个文件)是单独启用的。我已将它们合并为一个标志并将它们设置为输出到同一目录以方便使用。如果需要,您可以拆分它们。\解释报告如上所示,每个模块有 4 个文件输出:module-module.json,其中包含一些整体统计数据。module-composables.txt,其中包含每个函数声明的详细输出。module-composables.csv,这是文本文件的表格版本module-classes.txt,其中包含从可组合项引用的类的稳定性信息。这篇博文不会深入探讨所有文件的内容。为此,我建议通读“解释 Compose 编译器指标”文档,也是本篇的参考文档:androidx/compiler-metrics.md at androidx-main · androidx/androidx相反,我将依次过一下上面文档中“注意事项”部分中列出的信息的要点👑 ,并看看我的Tivi 应用程序的某个模块是个什么情况。我要研究的ui-showdetails模块是包含“显示详细信息”页面的所有 UI 的模块。它是我在 2020 年 4 月 转成 Jetpack Compose 的首批模块之一,所以我确信还有一些需要改进的地方!好的,所以首先要注意的是...是restartable但不是skippable 的函数首先,让我们定义术语 “可重启(restartable) ” 和 “可跳过(skippable) ”。在学习 Compose 时,您会学习到重组——它是 Compose 工作方式的基础:重组是当输入改变时再次调用你的可组合函数的过程。当函数的输入发生变化时会发生这种情况。当 Compose 基于新输入进行重构时,它只调用可能已更改的函数或 lambda,并跳过其余部分。可重启“可重启”的函数是重组的基础。当 Compose 检测到函数输入发生变化时, 它便使用新输入重新启动(重新invoke)此函数。更进一步地看看 Compose 的工作原理,可重启的函数标志着composition“范围”的边界。Snapshot (比如 MutableState)被读取到的“范围”很重要,因为它定义了在 快照(snapshot)更改时被重新运行的代码块。理想情况下,快照更改将尽可能仅触发最近的 函数/lambda 重启,使得被重新运行的代码最少化。如果宿主代码块无法重启,则 Compose 需要遍历树以找到最近的祖先可重启的“范围”。这可能意味着很多函数需要重新运行。实际上,几乎所有@Composable函数都可以重启。可跳过如果 Compose 发现自上次调用以来参数未更改,则它可以完全跳过调用此函数,则可组合函数是“可跳过的”。 这对于“顶级”可组合项的性能尤为重要,因为它们往往位于 Composable树的最上面一部分。如果 Compose 可以跳过“顶级”调用,则也不需要调用其之下任何函数。在实践中,我们的目标是让尽可能多的可组合项可跳过,以允许 Compose '智能重组'。蛋疼的事情是,参数值是否发生变化 是怎么定义的——我们需要引入另外两个术语:稳定性(Stablility)和不变性(Immutability)。稳定性(Stablility)和不变性(Immutability)可重启和可跳过是 Compose 函数 的属性,而不变性和稳定性是对象实例的属性,尤指传递给可组合函数的对象。不可变的对象意味着“所有public属性和字段在构造实例后都不会更改”。这个特征意味着 Compose 可以很容易地检测到两个实例之间的“变化”。另一方面,稳定的对象不一定是不可变的。一个稳定的类可以保存可变数据,但所有可变数据都需要在发生变化时通知 Compose,以便在必要时进行重组。当 Compose 检测到所有函数参数都是稳定或不可变时,它可以在运行时启用许多优化,这也正是函数能够被跳过的关键。Compose 会尝试自动推断一个类是不可变的还是稳定的,但有时它无法正确推断。当发生这种情况时,我们可以在类上使用@Immutable和@Stable注解简要解释了这些术语后,让我们开始探索指标数据。探索指标数据我们将从module.json文件开始以了解整体统计信息: {
"skippableComposables": 64,
"restartableComposables": 76,
"readonlyComposables": 0,
"totalComposables": 76
}我们可以看到该模块包含 76 个可组合项:它们都是可重启 的,其中 64 个是可跳过 的,剩下 12 个则是可重启 但不可 跳过 的函数。现在我们需要找出具体的对应关系。我们有两种方法可以做到这一点:查看composables.txt文件,或者导入composables.csv文件并将其当做电子表格查看。我们稍后会查看文本文件,所以现在让我们看一下电子表格。将 CSV 导入您选择的电子表格工具后,您将得到如下结果:(原作者附的链接失效了……)在过滤Composable列表后(工作表上有一个“不可跳过”的过滤视图),我们可以轻松找到不可跳过的函数: ShowDetails()
ShowDetailsScrollingContent()
PosterInfoRow()
BackdropImage()
AirsInfoPanel()
Genres()
RelatedShows()
NextEpisodeToWatch()
InfoPanels()
SeasonRow()使函数可跳过现在我们的工作是依次查看上述的每一个函数,并确定它们不可跳过的原因。如果我们回到文档,它上面这样写到:如果您看到一个可重启但不可跳过的函数,这并不总是一件坏事;反之,有时,这告诉我们该做做下面这两件事之一: 1。通过确保函数的所有参数稳定来使函数可跳过 2. 通过 @NonRestartableComposable 将函数标记为不可重启函数现在,我们将专注于第一件事。所以让我们继续查看composables.txt文件,并找到不可跳过的Composable之一:AirsInfoPanel(): restartable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
unstable show: TiviShow
stable modifier: Modifier? = @static Companion
)我们可以看到该函数有 2 个参数:modifier参数是 'stable' (👍),但show参数是 'unstable' (👎),这很可能就是导致 Compose 确定该函数不可跳过的原因。但是现在问题变成了:为什么 Compose 编译器会认为TiviShow是不稳定的?它只是一个只包含不可变数据的数据类而已啊。🤔classes.txt理想情况下,我们应该参考此处引用的module-classes.txt文件以深入了解该类被推断为不稳定的原因。不幸的是,该文件的输出似乎零零散散的。在某些模块中,我可以看到必要的输出;但对于有些模块,它甚至可能是个空文件(这个模块就是)。不过,我们可以换个不同模块的示例看看。它看起来就蛮有用的: unstable class WatchedViewState {
unstable val user: TraktUser?
stable val authState: TraktAuthState
stable val isLoading: Boolean
stable val isEmpty: Boolean
stable val selectionOpen: Boolean
unstable val selectedShowIds: Set<Long>
stable val filterActive: Boolean
stable val filter: String?
unstable val availableSorts: List<SortOption>
stable val sort: SortOption
unstable val message: UiMessage?
<runtime stability> = Unstable
}从classes.txt输出的判断来看,Compose 编译器似乎只能推断 启用了 compose的模块下的类 的不变性和稳定性。Tivi 中的大多数Model类都构建在标准的 Kotlin 模块中(即没有包含 Android 或 Compose),然后在整个应用程序中使用。对于从外部库(比如ViewModel)使用的类,我们也有类似的情况。不幸的是,如果没有额外的工作,我们现在似乎无法解决这个问题。理想情况下,Compose 使用的注释(比如@Stable)将能被分离到一个纯 Kotlin 库中,允许我们在更多地方使用它们(如有必要,甚至可以是 Java 库)。把类包装一下如果您发现您的可组合项成了性能上的绊脚石,且启用可跳过性是实现无卡顿的关键时,您可以将被错误推断的、实际稳定的对象包装起来,例如: @Stable
class StableHolder<T>(val item: T) {operator fun component1(): T = item
}
@Immutable
class ImmutableHolder<T>(val item: T) {operator fun component1(): T = item
}缺点是您需要在可组合声明中这样使用它们: @Composable
private fun AirsInfoPanel(
show: StableHolder<ShowUiModel>,
modifier: Modifier = Modifier,
)不过,我们可以更进一步,探索许多团队推荐的模式:UI 相关的 Model 类。UI Model 类这些 Model 类是针对每个“屏幕”构建的,包含显示 UI 所需的最少信息。通常,您ViewModel会将数据层模型映射到这些 UI 模型中,以便您的 UI 易于使用。更重要的是,它们可以直接写在您的可组合项旁边,这意味着 Compose 编译器可以推断出它需要的所有内容;或者即使其他所有方法都没用,我们也可以根据需要添加@Immutableor @Stable。这正是我在以下PR中实现的:github.com/chrisbanes/…在我的数据层(比如数据库啥的)中,我们不再直接使用TiviShow作为模型,而是将显示数据映射到仅包含 UI 所需的必要信息的ShowUiModel 中。不幸的是,这还不足以让 Compose 编译器推断ShowUiModel为可跳过 😔: restartable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
unstable show: ShowUiModel
stable modifier: Modifier? = @static Companion
)同样不幸的是,指标中没有任何明显的东西可以说明为什么该类会被推断为不稳定。在查看了composables.txt文件的其余部分后,我注意到另一个函数也被认为是不稳定的: restartable scheme("[androidx.compose.ui.UiComposable]") fun Genres(
unstable genres: List<Genre>
)我的新ShowUiModel类是一个数据类,它包含许原始类型和枚举类,但一个属性略有不同,因为它包含枚举列表:genre: List<Genre>. 似乎 Compose 编译器不把List当做稳定的(public issue)。我发现强制让 Compose 认为ShowUiModel是稳定的唯一方法是:使用@Immutable或@Stable注解。因为其所有属性均不可变,所以我使用@Immutable, @Immutable
internal data class ShowUiModel(// ...
)
之后,AirsInfoPanel()终于被认为是可以跳过的了😅: restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
stable show: ShowUiModel
stable modifier: Modifier? = @static Companion
)
再看看在干完这些之后,您可能会认为我们在模块的整体统计数据方面做出了很大的改变。不幸的是,事实并非如此: {
"skippableComposables": 66,
"restartableComposables": 76,
"readonlyComposables": 0,
"totalComposables": 76,
"knownStableArguments": 890,
"knownUnstableArguments": 30,"unknownStableArguments": 1
}作为提醒,我们是 从64 个可跳过的组合开始的。这意味着我们将这个数字增加了...... 2——到66 🙃。大型应用程序足以包含数百个 UI 模块,这使得在每个模块中创建 UI 模型是不现实的。还有一些其他有趣的统计数据。Compose 已认为有 890 个稳定的可组合函数参数(这很好),但仍有30个被 Compose 视为不稳定。在检查了那些“不稳定”的参数后,我发现几乎所有的参数都可以安全地用作不可变的State。这个问题似乎和之前一样,但有其他问题:大多数类型来自外部库。对于来自外部库的简单数据类,我们可以 像以前一样那么干,并将它们映射到本地 UI 模型类(虽然这很费力)。然而,大多数应用程序最终会发现有些类无法在本地轻松映射。在ui-showdetails模块中,我有些来自 ThreeTen-bp 的时间相关的类:OffsetDateTime和LocalDate. 我不是特别想在本地重写日期/时间库!请注意,我们谈论的还仅仅是一个module的snapshot。Tivi 是一个相当小的应用程序,但它仍然包含 12 个 UI 模块。大型应用程序可以包含数百个 UI 模块,这使得在每个模块中创建本地 UI 模型是不现实的。正如我们在这篇博文开头提到的那样,您只需要在您已确定性能存在问题的地方才考虑这一点。
江江说技术
一个Bug引发对LiveData和协程的思考
一.LiveData相关知识MutableLiveData提供了两种更新数据的方法setValue()和postValue(),其中postValue()可以用于在子线程中更新数据,本质上也是通过Handler切换到主线程进行数据更新 简单的分析如下:#LiveData.java
protected void postValue(T value) {
//决定是否通过handler发送message通知观察者
boolean postTask;
synchronized (mDataLock) {
postTask = mPendingData == NOT_SET;
//全局变量赋值
mPendingData = value;
}
if (!postTask) {
return;
}
//切换到主线程通知观察者
ArchTaskExecutor.getInstance().postToMainThread(mPostValueRunnable);
}
注意:通过此处源码我们可以了解到,这个地方进行了一次防止同一时间频繁多次调用postValue()方法的处理,也就是说当我们多次调用postValue()多次更新值的时候,可能最终只会有一条被封装的message来实现通知观察者的逻辑这个地方处理是可以理解的,如果不加以控制并发生了postValue()某一时间内被多次调用的情况,这就导致主线程消息队列中被插入大量的message,以至于引发卡顿,这是原因之一接下来看一下postToMainThread()方法,这个里面藏着另一个为什么要对postValue()限制防止频繁调用的原因#ArchTaskExecutor.java
@Override
public void postToMainThread(Runnable runnable) {
//其中这个mDelegate是DefaultTaskExecutor实例
mDelegate.postToMainThread(runnable);
}
#DefaultTaskExecutor.java
@Override
public void postToMainThread(Runnable runnable) {
if (mMainHandler == null) {
synchronized (mLock) {
if (mMainHandler == null) {
//创建入口
mMainHandler = createAsync(Looper.getMainLooper());
}
}
}
//noinspection ConstantConditions
mMainHandler.post(runnable);
}
这个地方我们可以看到会创建一个Handler,并且传入的Looper是主线程的looper,实现线程切换,这个Handler创建的是通过方法creaeAsync()实现的,从函数名理解上创建一个异步handler进入方法creaeAsync()去看一看#DefaultTaskExecutor.java
private static Handler createAsync(@NonNull Looper looper) {
if (Build.VERSION.SDK_INT >= 28) {
//关键调用
return Handler.createAsync(looper);
}
...
return new Handler(looper);
}
#Handler.java
public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
if (looper == null) throw new NullPointerException("looper must not be null");
if (callback == null) throw new NullPointerException("callback must not be null");
//第三个参数代表是否异步
return new Handler(looper, callback, true);
}
public Handler(@NonNull Looper looper, @Nullable Callback callback, boolean async) {
mLooper = looper;
mQueue = looper.mQueue;
mCallback = callback;
//最终会将Handler内部的一个成员变量mAsynchronous=true
mAsynchronous = async;
}
mAsynchronous变量是不是很熟悉,如果这个变量为true的,那么在构造Message的时候会通过方法setAsynchronous()将当前message标识为一个异步消息,这个异步消息在碰到Looper的消息队列中加入了同步屏障后相比于同步消息被优先执行之前说的对postValue()限制防止频繁调用的第二个原因就是因为,最终创建message为异步消息;总所周知,View视图在刷新渲染的时候会首先发送一个同步屏障消息,接下来将刷新渲染等message设置为异步消息,这样是为了view刷新优先被处理,如果开发者频繁调用了postValue()方法且不加以限制,就会导致消息队列被插入大量的异步消息,这个时候如果发生了视图刷新,那么很有可能造成视图刷新等相关message无法及时被处理,造成卡顿二.协程相关知识我们手动创建协程作用域CoroutineScope的时候,构造参数中传入的Job类型一般有两种:Job():当子job发生异常的时候,会向上传递导致父job被取消;父job被取消会导致父job的所有子job被取消 SupervisorJob():当子job发生异常的时候,该异常不会向上传递,也就是说不会影响其父job,也就不会影响父job的其他子job的执行接下来我们通过两个例子来具体了解下:首先我们指定构造CoroutineScope是传入的是Job():fun main() {
//构造CoroutineScope中传入的job是job()
val scope = CoroutineScope(Job() + Dispatchers.Default)
//A协程块
scope.launch(CoroutineExceptionHandler { _, throwable ->
println("CoroutineExceptionHandler top: $throwable")
}) {
delay(1500)
throw Exception("hahahaha")
}
//B协程块
scope.launch(CoroutineExceptionHandler { _, throwable ->
println("CoroutineExceptionHandler top: $throwable")
}) {
withContext(Dispatchers.IO) {
val i = 0
while (true) {
println("aaaaa $i")
delay(200)
}
}
}
println("--- thread = " + Thread.currentThread())
Thread.sleep(10000)
}
看下运行效果:我们开启一个协程B每隔200ms打印一次字符串“aaaaa 0”,开启另一个协程A先休眠1500ms然后抛出异常,从运行结果中可以看到,当A抛出异常的时候协程B就被取消不执行了,这验证了我们上面关于Job()的结论接下来我们将Job()改成SupervisorJob() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
看下运行效果:从上图中可以看到:当A协程块发生异常的时候不会影响B协程块的执行,这就验证了之前讲的SupervisorJob()的结论PS:建议大家使用协程时不用直接try-catch捕捉异常,而是手动构建一个CoroutineExceptionHandler对象并作为一个Element元素在协程创建的时候作为参数传入三.为什么要谨慎使用MutableLiveData的postValue()方法简单叙述下我在项目的发现这个问题的经过在一个界面显示一个列表,数据从服务器去请求并缓存到本地,这样每次打开这个界面的时候,都优先从本地读取数据,然后在请求服务器,这是个很基本的业务流程假如此时发生了断网,当打开这个界面从本地读取到数据后我使用了postValue()方法更新LiveData中的数据并通知观察者,然后请求网络,因为这个时候断网了肯定会发生异常,然后在异常处理处又调用了postValue()方法将发生的异常通知到当前LiveData注册的观察者,这个时候就在非常短的时间内连续调用了两次postValue()方法最终发现界面在即使本地有缓存数据还是很高概率的显示一片空白,显示不出任何数据笔者第一个想法是协程的创建是不是存在问题,因为这个地方首先我会创建一个协程从本地读取网络数据,然后再创建一个协程去请求服务器数据,类似代码如下:class MainViewModel: ViewModel() {
private val _data = MutableLiveData<String>()
val data: LiveData<String> = _data
fun updateData() {
viewModelScope.launch {
//读取本地缓存数据
_data.postValue("读取的本地数据")
}
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
_data.postValue("请求发生异常")
}) {
//请求服务器数据
_data.postValue("读取的服务器数据")
}
}
}
怀疑是断网情况下当请求服务器的协程会发生异常导致读取本地缓存数据的协程被取消,这个问题也就转换成了viewModelScope这个ViewModel的扩展属性创建协程作用域的job类型是不是用的是Job()而不是SupervisorJob(),看下源码:public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
发现就是SupervisorJob(),那么这个问题就和协程的创建没有多大关系了5.这个时候就卡住了,然后通过打印日志排查发现视图层注册的观察者对象只接收到了一次通知,按道理来讲,本地读取完毕会通知视图层的观察者,然后网络请求失败也会通知视图层的观察者,应该是两次才对,所以怀疑是postValue()方法里做了特殊处理,所以研究了下postValue()源码,发现就是postValue()里面的逻辑做了处理,防止频繁通过hander创建更新LiveData中数据以及通知观察者的message消息,具体分析前文有讲所以这个问题出现原因就是读取本地成功后调用的postValue()和请求失败后调用的postValue()间隔极短,以至于真正通知到视图层观察者的通知只发生了一次,然后将postValue()替换成setValue()方法问题得到解决四.总结要根据业务场景再决定使用MutableLiveData类的setValue()方法还是postValue()方法通知观察者协程作用域的创建根据业务场景决定是使用Job()还是SupervisorJob()
江江说技术
Jetpack Room — 给你一种新的数据库操作体验!
前言:在我们日常开发中,经常要和数据打交道,所以存储数据是很重要的事。Android从最开始使用SQLite作为数据库存储数据,再到许多的开源的数据库,例如QRMLite,DBFlow,郭霖大佬开发的Litepal等等,都是为了方便SQLite的使用而出现的,因为SQLite的使用繁琐且容易出错。Google当然也意识到了SQLite的一些问题,于是在Jetpack组件中推出了Room,本质上Room也是在SQLite上提供了一层封装。因为它官方组件的身份,和良好的开发体验,现在逐渐成为了最主流的数据库ORM框架。Room官方文档:developer.android.google.cn/jetpack/and…SQL语法教程:www.runoob.com/sqlite/sqli…本文代码地址:github.com/taxze6/Jetp…为什么要使用Room?Room具有什么优势?Room在SQLite上提供了一个抽象层,以便在充分利用SQLite的强大功能的同时,能够享有更强健的数据库访问机制。Room的具体优势:有可以最大限度减少重复和容易出错的样板代码的注解简化了数据库迁移路径针对编译期SQL的语法检查API设计友好,更容易上手,理解与SQL语句的使用更加贴近,能够降低学习成本对RxJava、 LiveData 、 Kotlin协程等都支持Room具有三个主要模块Entity: Entity用来表示数据库中的一个表。需要使用@Entity(tableName = "XXX")注解,其中的参数为表名。Dao: 数据库访问对象,用于访问和管理数据(增删改查)。在使用时需要@DAO注解Database: 它作为数据库持有者,用@Database注解和Room Database扩展的类如何使用Room呢?①添加依赖最近更新时间(文章发布时的最新版本)稳定版Alpha 版2022 年 6 月 1 日2.4.22.5.0-alpha02plugins {
...
id 'kotlin-kapt'
}
def room_version = "2.4.2"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt 'androidx.room:room-compiler:$room_version'
②创建Entity实体类,用来表示数据库中的一张表(table)@Entity(tableName = "user")
data class UserEntity(
//主键定义需要用到@PrimaryKey(autoGenerate = true)注解,autoGenerate参数决定是否自增长
@PrimaryKey(autoGenerate = true) val id:Int = 0, //默认值为0
@ColumnInfo(name = "name", typeAffinity = ColumnInfo.TEXT) val name:String?,
@ColumnInfo(name = "age", typeAffinity = ColumnInfo.INTEGER) val age:Int?
)
其中,每个表的字段都要加上@ColumnInfo(name = "xxx", typeAffinity = ColumnInfo.xxx),name属性表示这张表中的字段名,typeAffinity表示改字段的数据类型。其他常用注解:@Ignore :Entity中的所有属性都会被持久化到数据库,除非使用@Ignore kotlin复制代码@Ignore val name: String?@ForeignKey:外键约束,不同于目前存在的大多数ORM库,Room不支持Entitiy对象间的直接引用。Google也做出了解释,具体原因请查看:developer.android.com/training/da…,不过Room允许通过外键来表示Entity之间的关系。ForeignKey我们文章后面再谈,先讲简单的使用。@Embedded :实体类中引用其他实体类,在某些情况下,对于一张表的数据,我们用多个POJO类来表示,所以在这种情况下,我们可以使用Embedded注解嵌套对象。③创建数据访问对象(Dao)处理增删改查@Dao
interface UserDao {
//添加用户
@Insert
fun addUser(vararg userEntity: UserEntity)
//删除用户
@Delete
fun deleteUser(vararg userEntity: UserEntity)
//更新用户
@Update
fun updateUser(vararg userEntity: UserEntity)
//查找用户
//返回user表中所有的数据
@Query("select * from user")
fun queryUser(): List<UserEntity>
}
Dao负责提供访问DB的API,我们每一张表都需要一个Dao。在这里使用@Dao注解定义Dao类。@Insert, @Delete需要传一个entity()进去kotlin复制代码Class<?> entity() default Object.class;@Query则是需要传递SQL语句kotlin复制代码public @interface Query { //要运行的SQL语句 String value(); }☀注意:Room会在编译期基于Dao自动生成具体的实现类,UserDao_Impl(实现增删改查的方法)。 🔥Dao所有的方法调研都在当前线程进行,需要避免在UI线程中直接访问!④创建Room database@Database(entities = [UserEntity::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
通过Room.databaseBuilder()或者 Room.inMemoryDatabaseBuilder()获取Database实例val db = Room.databaseBuilder(
applicationContext,
UserDatabase::class.java, "userDb"
).build()
☀注意:创建Database的成本较高,所以我们最好使用单例的Database,避免反复创建实例所带来的开销。单例模式创建Database:@Database(entities = [UserEntity::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
abstract fun getUserDao(): UserDao
companion object {
@Volatile
private var INSTANCE: UserDatabase? = null
@JvmStatic
fun getInstance(context: Context): UserDatabase {
val tmpInstance = INSTANCE
if (tmpInstance != null) {
return tmpInstance
}
//锁
synchronized(this) {
val instance =
Room.databaseBuilder(context, UserDatabase::class.java, "userDb").build()
INSTANCE = instance
return instance
}
}
}
}
⑤在Activity中使用,进行一些可视化操作activity_main:<LinearLayout
...
tools:context=".MainActivity"
android:orientation="vertical">
<Button
android:id="@+id/btn_add"
...
android:text="增加一条数据"/>
<Button
android:id="@+id/btn_delete"
...
android:text="删除一条数据"/>
<Button
android:id="@+id/btn_update"
...
android:text="更新一条数据"/>
<Button
android:id="@+id/btn_query_all"
...
android:text="查新所有数据"/>
</LinearLayout>
MainActivity:private const val TAG = "My_MainActivity"
class MainActivity : AppCompatActivity() {
private val userDao by lazy {
UserDatabase.getInstance(this).getUserDao()
}
private lateinit var btnAdd: Button
private lateinit var btnDelete: Button
private lateinit var btnUpdate: Button
private lateinit var btnQueryAll: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
init()
//添加数据
btnAdd.setOnClickListener {
//数据库的增删改查必须在子线程,当然也可以在协程中操作
Thread {
val entity = UserEntity(name = "Taxze", age = 18)
userDao.addUser(entity)
}.start()
}
//查询数据
btnQueryAll.setOnClickListener {
Thread {
val userList = userDao.queryUser()
userList.forEach {
Log.d(TAG, "查询到的数据为:$it")
}
}.start()
}
//修改数据
btnUpdate.setOnClickListener {
Thread {
userDao.updateUser(UserEntity(2, "Taxzeeeeee", 18))
}.start()
}
//删除数据
btnDelete.setOnClickListener {
Thread {
userDao.deleteUser(UserEntity(2, null, null))
}.start()
}
}
//初始化
private fun init() {
btnAdd = findViewById(R.id.btn_add)
btnDelete = findViewById(R.id.btn_delete)
btnUpdate = findViewById(R.id.btn_update)
btnQueryAll = findViewById(R.id.btn_query_all)
}
}
结果:到这里我们已经讲完了Room的最基本的使用,如果只是一些非常简单的业务,你看到这里已经可以去写代码了,但是还有一些进阶的操作需讲解一下,继续往下看吧!数据库的升级Room在2021 年 4 月 21 日发布的版本 2.4.0-alpha01中开始支持自动迁移,不过很多朋友反应还是有很多问题,建议手动迁移,当然如果你使用的是更低的版本只能手动迁移啦。具体信息请参考:developer.android.google.cn/training/da…具体如何升级数据库呢?下面我们一步一步来实现吧!①修改数据库版本在UserDatabase文件中修改version,将其变为2(原来是1)在此时,我们需要想一想,我们要对数据库做什么升级操作呢?我们这里为了演示就给数据库增加一张成绩表:@Database(entities = [UserEntity::class,ScoreEntity::class], version = 2)
添加表:@Entity(tableName = "score")
data class ScoreEntity(
@PrimaryKey(autoGenerate = true) var id: Int = 0,
@ColumnInfo(name = "userScore")
var userScore: Int
)
②创建对应的Dao,ScoreDao@Dao
interface ScoreDao {
@Insert
fun insertUserScore(vararg scoreEntity: ScoreEntity)
@Query("select * from score")
fun queryUserScoreData():List<ScoreEntity>
}
③在Database中添加迁移@Database(entities = [UserEntity::class,ScoreEntity::class], version = 2)
abstract class UserDatabase : RoomDatabase() {
abstract fun getUserDao(): UserDao
//添加一个Dao
abstract fun getScoreDao():ScoreDao
companion object {
//变量名最好为xxx版本迁移到xxx版本
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
create table userScore(
id integer primary key autoincrement not null,
userScore integer not null)
""".trimIndent()
)
}
}
@Volatile
private var INSTANCE: UserDatabase? = null
@JvmStatic
fun getInstance(context: Context): UserDatabase {
...
synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
UserDatabase::class.java,
"userDb"
)
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
return instance
}
}
}
}
④使用更新后的数据在xml布局中添加两个Button:<Button
android:id="@+id/btn_add_user_score"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="增加user的score数据"/>
<Button
android:id="@+id/btn_query_user_score"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="查询user的score数据"/>
在MainActivity中加入:private val userScoreDao by lazy {
UserDatabase.getInstance(this).getScoreDao()
}
...
private lateinit var btnAddUserScore: Button
private lateinit var btnQueryUserScore: Button
...
btnAddUserScore = findViewById(R.id.btn_add_user_score)
btnQueryUserScore = findViewById(R.id.btn_query_user_score)
...
btnAddUserScore.setOnClickListener {
Thread{
userScoreDao.insertUserScore(ScoreEntity(userScore = 100))
}.start()
}
btnQueryUserScore.setOnClickListener {
Thread{
userScoreDao.queryUserScoreData().forEach{
Log.d(TAG,"userScore表的数据为:$it")
}
}.start()
}
这样对数据库的一次手动迁移就完成啦!如果你想继续升级,就重复之前的步骤,然后将2→3private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
.... 再一次新的操作
""".trimIndent()
)
}
}
...
.addMigrations(MIGRATION_1_2,MIGRATION_2_3)
使用Room更多的骚操作!①想知道更多的Room数据库迁移的操作吗,那你可以看看这篇文章:www.modb.pro/db/139101②更优雅的修改数据在上面的修改数据操作中,我们是需要填入每个字段的值的,但是,大部分情况,我们是不会全部知道的,比如我们不知道User的age,那么我们的age字段就填个Null吗?val entity = UserEntity(name = "Taxze", age = null)
这显然是不合适的!当我们只想修改用户名的时,却又不知道age的值的时候,我们需要怎么修改呢?⑴创建UpdateNameBeanclass UpdateNameBean(var id:Int,var name:String)
⑵在Dao中加入新的方法@Update(entity = UserEntity::class)
fun updataUser2(vararg updataNameBean:UpdateNameBean)
⑶然后在使用时只需要传入id,和name即可userDao.updateUser2(updataNameBean(2, "Taxzeeeeee"))
当然你也可以给用户类创建多个构造方法,并给这些构造方法添加@lgnore③详解@Insert 插入@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertUsers(vararg userEntity: UserEntity)
}
其中onConflict用于设置当事务中遇到冲突时的策略。有如下一些参数可以选择:OnConflictStrategy.REPLACE : 替换旧值,继续当前事务OnConflictStrategy.NONE : 忽略冲突,继续当前事务OnConflictStrategy.ABORT : 回滚④@Query 指定参数查询每次都查表的全部信息这也不是事啊,我们要用到where条件来指定参数查询。@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
fun loadAllUsersBetweenAges(minAge: Int, maxAge: Int): Array<UserEntity>
}
大家可以自己学习一下SQL语法~⑤多表查询很多业务情况下,我们是需要同时在多张表中进行查询的。@Dao
interface UserDao {
@Query(
"SELECT * FROM user " +
"INNER JOIN score ON score.id = user.id " +
"WHERE user.name LIKE :userName"
)
fun findUsersScoreId(userName: String): List<UserEntity>
}
⑥@Embedded内嵌对象我们可以使用@Embedded注解,将一个Entity作为属性内嵌到另外一个Entity,然后我们就可以像访问Column一样去访问内嵌的Entity啦。data class Score(
val id:Int?,
val score:String?,
)
@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey(autoGenerate = true) val id:Int = 0,
.....
@Embedded val score: Score?
)
⑦使用@Relation 注解和 foreignkeys注解来描述Entity之间更复杂的关系可以实现一对多,多对多的关系⑧预填充数据库可以查看官方文档:developer.android.google.cn/training/da…⑨类型转换器 TypeConverter....Room配合LiveData和ViewModel下面我们通过一个Room+LiveData+ViewModel的例子来完成这篇文章的学习吧话不多说,先上效果图:①创建UserEntity@Entity(tableName = "user")
data class UserEntity(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name", typeAffinity = ColumnInfo.TEXT) val name: String?,
@ColumnInfo(name = "age", typeAffinity = ColumnInfo.INTEGER) val age: Int?,
)
②创建对应的Dao@Dao
interface UserDao {
//添加用户
@Insert
fun addUser(vararg userEntity: UserEntity)
//查找用户
//返回user表中所有的数据,使用LiveData
@Query("select * from user")
fun getUserData(): LiveData<List<UserEntity>>
}
③创建对应的Database代码在最开始的例子中已经给出了。④创建ViewModelclass UserViewModel(userDao: UserDao):ViewModel(){
var userLivedata = userDao.getUserData()
}
⑤创建UserViewModelFactory我们在UserViewModel类中传递了UserDao参数,所以我们需要有这么个类实现ViewModelProvider.Factory接口,以便于将UserDao在实例化时传入。class UserViewModelFactory(private val userDao: UserDao) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return UserViewModel(userDao) as T
}
}
⑥编辑xmlactivity_main:<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<EditText
android:id="@+id/user_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="请输入UserName" />
<EditText
android:id="@+id/user_age"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hint="请输入UserAge" />
<Button
android:id="@+id/btn_add"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="添加一条user数据" />
<ListView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
</ListView>
</LinearLayout>
创建一个simple_list_item.xml,用于展示每一条用户数据<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/userText"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
⑦在MainActivity中调用class MainActivity : AppCompatActivity() {
private var userList: MutableList<UserEntity> = arrayListOf()
private lateinit var arrayAdapter: ArrayAdapter<UserEntity>
private val userDao by lazy {
UserDatabase.getInstance(this).getUserDao()
}
lateinit var viewModel: UserViewModel
private lateinit var listView: ListView
private lateinit var editUserName: EditText
private lateinit var editUserAge: EditText
private lateinit var addButton: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
init()
arrayAdapter = ArrayAdapter(this, R.layout.simple_list_item, userList)
listView.adapter = arrayAdapter
//实例化UserViewModel,并监听LiveData的变化。
viewModel =
ViewModelProvider(this, UserViewModelFactory(userDao)).get(UserViewModel::class.java)
viewModel.userLivedata.observe(this, Observer {
userList.clear()
userList.addAll(it)
arrayAdapter.notifyDataSetChanged()
})
addButton.setOnClickListener {
addClick()
}
}
//初始化控件
private fun init() {
editUserName = findViewById(R.id.user_name)
editUserAge = findViewById(R.id.user_age)
addButton = findViewById(R.id.btn_add)
listView = findViewById(R.id.recycler_view)
}
fun addClick() {
if (editUserName.text.toString() == "" || editUserAge.text.toString() == "") {
Toast.makeText(this, "姓名或年龄不能为空", Toast.LENGTH_SHORT).show()
return
}
val user = UserEntity(
name = editUserName.text.toString(),
age = editUserAge.text.toString().toInt()
)
thread {
userDao.addUser(user)
}
}
}
这样一个简单的Room配合LiveData和ViewModel实现页面自动更新的Demo就完成啦具体代码可以查看Git仓库
江江说技术
Jetpack实践指南:lifecycle与协程的"猫腻"事(二)
本篇文章介绍lifecycle在协程中的应用,通过分析lifecycleScope.launchWhenXXX{}、lifecycle.repeatOnLifecycle(){}原理,加深我们对lifecycle和协程的认知。历史文章Jetpack实践指南:lifecycle与协程的"猫腻"事(一)协程是如何绑定Activity的生命周期这个问题很简单,大家应该都可以想到:协程借助于添加观察者LifecyclObserver的方式实现对Activity生命周期的监听,这里主要通过两个非常典型的源码例子进行分析:lifecycleScope.launchWhenXXX:可以指定在特定的生命周期执行协程协程代码,当不再该状态时就会暂停协程块的执行lifecycle.repeatOnLifecycle:可以指定在特定的生命周期执行协程协程代码,当不再该状态时就会取消协程块的执行请大家一定记住这两者的区别,当我们使用MutableStateFlow、MutableSharedFlow时,在协程作用域添加观察者时强烈推荐使用第二种方式,之后应该会写一篇文章进行分析的。lifecycleScope.launchWhenXXX我看先看下调用链分析,这里以launchWhenResumed举例:public fun launchWhenResumed(block: suspend CoroutineScope.() -> Unit): Job = launch {
lifecycle.whenResumed(block)
}
往下走:public suspend fun <T> Lifecycle.whenResumed(block: suspend CoroutineScope.() -> T): T {
return whenStateAtLeast(Lifecycle.State.RESUMED, block)
}
最终走到:public suspend fun <T> Lifecycle.whenStateAtLeast(
minState: Lifecycle.State,
block: suspend CoroutineScope.() -> T
): T = withContext(Dispatchers.Main.immediate) {
val job = coroutineContext[Job] ?: error("when[State] methods should have a parent job")
//1.
val dispatcher = PausingDispatcher()
val controller =
//2. 重点关注这个对象
LifecycleController(this@whenStateAtLeast, minState, dispatcher.dispatchQueue, job)
try {
withContext(dispatcher, block)
} finally {
controller.finish()
}
}
所以所有的lifecycleScope.launchWhenXXX都会走到whenStateAtLeast()这个方法:1.创建可以暂停的分发器,就是通过这个对象实现在指定生命周期执行协程代码块,超出该生命周期暂停协程代码块的执行canRun()方法是重点关注的,当暂停协程块执行时,该方法就会返回true,重新进行分发。2.LifecycleController实现Activity生命周期监听,并借助上面创建的分发器,实现协程代码块的暂停执行和恢复执行,深入该类源码查看:internal class LifecycleController(
private val lifecycle: Lifecycle,
private val minState: Lifecycle.State,
private val dispatchQueue: DispatchQueue,
parentJob: Job
) {
private val observer = LifecycleEventObserver { source, _ ->
if (source.lifecycle.currentState == Lifecycle.State.DESTROYED) {
handleDestroy(parentJob)
} else if (source.lifecycle.currentState < minState) {
//1.
dispatchQueue.pause()
} else {
//2.
dispatchQueue.resume()
}
}
}
一目了然,如果当前生命周期状态小于传入的minState,就调用DispatchQueue.pause()暂停协程代码块执行,也就是挂起,大于等于指定的生命周期就调用resume()方法恢复执行。综上所诉,该方法lifecycleScope.launchWhenXXX当小于指定生命周期状态时,是暂停协程的执行,而不是取消。lifecycle.repeatOnLifecycle直接看下源码:public suspend fun Lifecycle.repeatOnLifecycle(
state: Lifecycle.State,
block: suspend CoroutineScope.() -> Unit
) {
coroutineScope {
withContext(Dispatchers.Main.immediate) {
//1.
if (currentState === Lifecycle.State.DESTROYED) return@withContext
var launchedJob: Job? = null
var observer: LifecycleEventObserver? = null
try {
//2.
suspendCancellableCoroutine<Unit> { cont ->
val startWorkEvent = Lifecycle.Event.upTo(state)
val cancelWorkEvent = Lifecycle.Event.downFrom(state)
observer = LifecycleEventObserver { _, event ->
//3.
if (event == startWorkEvent) {
launchedJob = this@coroutineScope.launch {
block()
}
return@LifecycleEventObserver
}
//4.
if (event == cancelWorkEvent) {
launchedJob?.cancel()
}
//5.
if (event == Lifecycle.Event.ON_DESTROY) {
cont.resume(Unit)
}
}
//6.
this@repeatOnLifecycle.addObserver(observer as LifecycleEventObserver)
}
} finally {
//7.
launchedJob?.cancel()
observer?.let {
this@repeatOnLifecycle.removeObserver(it)
}
}
}
}
}
上面的代码是经过精简过的(也没啥可以精简的),我们来一步步进行分析:当页面状态处于销毁DESTROYED状态,直接return。suspendCancellableCoroutine是用来捕捉传递过来的Continuation,这样我们就可以决定挂起的协程什么时候可以恢复执行,比如delay()的实现机制就是如此。PS: 插一嘴,不管是suspendCancellableCoroutine还是coroutineScope底层方法都是通过suspendCoroutineUninterceptedOrReturn实现,但是由于这个方法不正确使用会对代码产生安全影响,比如栈溢出,所以官方提供了前面两个封装方法。创建了一个LifecycleEventObserver对象,用来绑定界面生命周期,当达到指定执行的生命周期后,就创建一个协程执行我们传递过来的代码块.当小于当前指定的生命周期状态,就直接取消协程执行(请注意,和上面的whenStateAtLeast区别)。当界面销毁时,才将挂起的协程恢复执行,所以这里我们就实现了根据具体场景来决定什么时候恢复协程执行。6处和7处就是注册和反注册观察者。综上所诉,该方法lifecycle.repeatOnLifecycle当小于指定生命周期状态时,是取消协程的执行,而不是暂停。总结这篇文章我们详细分析了lifecycleScope.launchWhenXXX{}、lifecycle.repeatOnLifecycle(){}实现原理以及两者的区别,大家请根据具体的场景选择使用。
江江说技术
Jetpack实践指南:你需要了解的Lifecycle开发技巧
本篇文章主要是讲解Lifecycle日常开发中,那些常见的注意事项以及使用技巧,希望对你有所帮助。添加观察者LifecycleObserver所有的观察者对象最后都会被包装成LifecycleEventObserver,一般实现观察者的方式有三种:1.继承LifecycleObserver实现观察者继承LifecycleObserver自定义一个观察者类:class CustomObserver : LifecycleObserver {
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun start() {
//Activity的onStart生命周期回调
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun stop() {
//Activity的onStop生命周期回调
}
}
这种添加注解的方式实现Activity/Fragment生命周期监听,官方有两种实现:1.1 APT注解处理器需要添加依赖:kapt("androidx.lifecycle:lifecycle-compiler:2.5.0")
这种方式编译器会在编译期借助于APT帮助我们生成模板代码,最终在Lifecycling的lifecycleEventObserve()方法包装成LifecycleEventObserver实现Activity/Fragment生命周期监听回调:1.2 反射注解当我们没用使用到lifecycle的注解处理器时,则也会在Lifecycling的lifecycleEventObserve()方法中包装成LifecycleEventObserver实现监听:具体的源码这里就不再深入,感兴趣的自行探索。不管是使用APT注解处理器还是反射的方式都已经被官方废弃了,因为APT会增加编译期运行耗时,运行期反射则会有性能风险。2. 继承LifecycleEventObserver实现观察者class CustomObserver2: LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
when(event) {
Lifecycle.Event.ON_CREATE -> {}
Lifecycle.Event.ON_START -> {}
Lifecycle.Event.ON_PAUSE -> {}
Lifecycle.Event.ON_STOP -> {}
else -> {}
}
}
}
这种方式实现的观察者有点麻烦,还需要我们手动判断具体的生命周期事件类型从而实现在对应的生命周期回调中执行具体逻辑。3. 继承DefaultLifecycleObserver实现观察者class CustomObserver3: DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
}
override fun onStop(owner: LifecycleOwner) {
}
}
这种方式实现起来就非常的灵活简单,也是强烈推荐的方式。DefaultLifecycleObserver实现FullLifecycleObserver接口,为什么不直接实现FullLifecycleObserver呢,因为如果实现FullLifecycleObserver要对里面的每一个方法进行重写,即使不会使用到。DefaultLifecycleObserver就在此基础上进行了一层封装,借助于default关键字给每个接口方法进行了默认实现,这样我们就可以选择性的重写我们需要的方法即可。最终DefaultLifecycleObserver也会在Lifecycling的lifecycleEventObserve()方法包装成LifecycleEventObserver对象:最终我们在Activity中直接添加该观察者即可:private fun test4() {
lifecycle.addObserver(CustomObserver3())
}
监听应用前后台切换引入依赖:implementation("androidx.lifecycle:lifecycle-process:2.5.0")
在Application中添加前后台监听:class CustomObserver3: DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
//应用进入前台
}
override fun onStop(owner: LifecycleOwner) {
//应用进入后台
}
}
private fun test4() {
ProcessLifecycleOwner.get().lifecycle.addObserver(CustomObserver3())
}
查看ProcessLifecycleOwner内部源码,也是间接通过registerActivityLifecycleCallbacks()这个熟悉的方法实现的,监听所有Activity的onStart和onStop生命周期并计算其两者回调的次数实现。顺便一说,lifecycle-process这个库会在Application的onCreate()回调前自行完成初始化,其中借助的就是ContentProvider这个类初始化的时机,同时为了收敛ContentProvider创建的数量,又引入了startup官方库:更多的详情大家可以参考我之前写的文章:SDK无侵入初始化并获取Application最后还会有一篇文章详细讲解使用lifecycle启动协程相关知识点技巧以及基于lifecycle自定义一个生命周期感知组件的实践,如果感觉文章写的不错,还请麻烦点一个赞!
江江说技术
如何在 Jetpack Compose 中调试重组
自从 Jetpack Compose 的第一个稳定版本上线以来,已经过去了好几个月 (译注:本文写于2022年4月)。多家公司已经使用了 Compose 来参与构建他们的 Android 应用程序,成千上万的 Android 工程师每天都在使用 Jetpack Compose 。虽然已经有大量的文档可以帮助开发人员接受这种新的编程模式,但仍有这么个概念让许多人摸不着头脑。它就是Recomposition,Compose 赖以运作的基础。重组是在输入更改时再次调用可组合函数的过程。当函数的输入发生更改时,它便会发生。当 Compose 基于新输入进行重组时,它仅调用可能已更改的函数或 lambda,并跳过其余部分。通过跳过所有未更改参数的函数或 lambda,Compose 可以有效地进行重组。如果您不熟悉此主题,我将在本文中详细介绍 Recomposition。对于大多数用例,除非传入的参数变了,否则我们不希望重新调用可组合函数(此处从简表示)。Compose 编译器在这方面也非常聪明,当它有足够的可用信息时(例如,所有原始值类型的参数在设计上都是Stable的),它会尽最大努力来做些对使用者无感的优化;当信息没那么多时,Compose 允许您通过使用 @Stable 和 @Immutable 注解提供元数据,以帮助 Compose 编译器正确做出决定。从理论上讲,这一切都是有道理的,但是,如果开发人员有办法了解他们的可组合函数是如何重组的,那将大有裨益。这类功能目前呼声很高,不过要使Android Studio 快捷地为您提供此信息,还有一吨的工作要做。如果你像我一样迫不及待,你可能也想知道在能正式上手工具前,要想在 Jetpack Compose 中调试重组,咱可以做些什么。毕竟嘛,重组在性能上起着重要作用——不必要的重组可能会导致 UI 卡顿。打日志调试重组的最简单方法是使用良好的 log 语句来查看正在调用哪些可组合函数以及调用它们的频率。这感觉上很直白,但注意这个坑 : 我们希望仅在发生重组时才触发这些日志语句。这听起来像是 SideEffect 的用武之地。SideEffect 是一个可组合的函数,每当成功的 Composition/ Recomposition 后便会被重新调用。Sean McQuillan 编写了如下代码片段,您可以使用它来调试您的重组。这只是一个框架,您可以根据需要进行调整。class Ref(var value: Int)
// 注意,此处的 inline 会使下列函数实际上直接内联到调用处
// 以确保 logging 仅在原始调用位置被调用
@Composable
inline fun LogCompositions(tag: String, msg: String) {
if (BuildConfig.DEBUG) {
val ref = remember { Ref(0) }
SideEffect { ref.value++ }
Log.d(tag, "Compositions: $msg ${ref.value}")
}
}实战如下:@Composable
fun MyComponent() {
val counter by remember { mutableStateOf(0) }
LogCompositions(TAG, "MyComposable function")
CustomText(
text = "Counter: $counter",
modifier = Modifier
.clickable {
counter++
},
)
}
@Composable
fun CustomText(
text: String,
modifier: Modifier = Modifier,
) {
LogCompositions(TAG, "CustomText function")
Text(
text = text,
modifier = modifier.padding(32.dp),
style = TextStyle(
fontSize = 20.sp,
textDecoration = TextDecoration.Underline,
fontFamily = FontFamily.Monospace
)
)
}在运行此示例时,我们注意到每次计数器的值更改时,两者都会重组。MyComponent``CustomText在运行时对重组可视化Google Play 团队是Google首批利用 Jetpack Compose 的内部团队之一。他们与 Compose 团队密切合作,甚至编写了一份case study,描述了他们迁移到 Compose 的经验。该帖子的宝藏之一是他们开发的可视化重组Modifier。您可以在此处找到修饰符的代码。为了方便,我在下面添加了该代码段;但不要夸我啊,它是由Google Play团队开发的。/*
* Copyright 2022 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.example.android.compose.recomposehighlighter
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlin.math.min
import kotlinx.coroutines.delay
/**
* A [Modifier] that draws a border around elements that are recomposing. The border increases in
* size and interpolates from red to green as more recompositions occur before a timeout.
*/
@Stable
fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier)
// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations
// Modifier.composed will still remember unique data per call site.
private val recomposeModifier =
Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) {
// The total number of compositions that have occurred. We're not using a State<> here be
// able to read/write the value without invalidating (which would cause infinite
// recomposition).
val totalCompositions = remember { arrayOf(0L) }
totalCompositions[0]++
// The value of totalCompositions at the last timeout.
val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) }
// Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions
// as the key is really just to cause the timer to restart every composition).
LaunchedEffect(totalCompositions[0]) {
delay(3000)
totalCompositionsAtLastTimeout.value = totalCompositions[0]
}
Modifier.drawWithCache {
onDrawWithContent {
// Draw actual content.
drawContent()
// Below is to draw the highlight, if necessary. A lot of the logic is copied from
// Modifier.border
val numCompositionsSinceTimeout =
totalCompositions[0] - totalCompositionsAtLastTimeout.value
val hasValidBorderParams = size.minDimension > 0f
if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) {
return@onDrawWithContent
}
val (color, strokeWidthPx) =
when (numCompositionsSinceTimeout) {
// We need at least one composition to draw, so draw the smallest border
// color in blue.
1L -> Color.Blue to 1f
// 2 compositions is _probably_ okay.
2L -> Color.Green to 2.dp.toPx()
// 3 or more compositions before timeout may indicate an issue. lerp the
// color from yellow to red, and continually increase the border size.
else -> {
lerp(
Color.Yellow.copy(alpha = 0.8f),
Color.Red.copy(alpha = 0.5f),
min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f)
) to numCompositionsSinceTimeout.toInt().dp.toPx()
}
}
val halfStroke = strokeWidthPx / 2
val topLeft = Offset(halfStroke, halfStroke)
val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
val fillArea = (strokeWidthPx * 2) > size.minDimension
val rectTopLeft = if (fillArea) Offset.Zero else topLeft
val size = if (fillArea) size else borderSize
val style = if (fillArea) Fill else Stroke(strokeWidthPx)
drawRect(
brush = SolidColor(color),
topLeft = rectTopLeft,
size = size,
style = style
)
}
}
}使用此修饰符实际上是直白明了 —— 只需将 recomposeHighlighter 修饰符加到要跟踪其重组的可组合项的修饰符链上即可。修饰符在其附加到的可组合体周围绘制一个框,并使用颜色和边框宽度来表示可组合中发生的重组量。边框颜色重组次数蓝1绿2黄色到红色3+让我们来看看它在实际使用时的样子。我们的示例有一个简单的可组合函数,该函数具有一个按钮,该按钮在单击计数器时递增计数器。我们在两个地方使用recomposeHighlighter 修饰符 -——MyButtonComponent本身和``MyTextComponent`,它是按钮的内容。@Composable
fun MyButtomComponent(
modifier: Modifier = Modifier.recomposeHighlighter()
) {
var counter by remember { mutableStateOf(0) }
OutlinedButton(
onClick = { counter++ },
modifier = modifier,
) {
MyTextComponent(
text = "Counter: $counter",
modifier = Modifier.clickable {
counter++
},
)
}
}
@Composable
fun MyTextComponent(
text: String,
modifier: Modifier = Modifier,
) {
Text(
text = text,
modifier = modifier
.padding(32.dp)
.recomposeHighlighter(),
)
}在运行此示例时,我们注意到按钮和按钮内的文本最初都有一个蓝色的边界框。这很合理,因为这是第一次重组,它对应于我们使用recomposeHighlighter()修饰符的两个地方。当我们单击按钮时,我们注意到边界框仅围绕按钮内的文本,而不是按钮本身。这是因为 Compose 在重组方面很聪明,它不需要重组整个按钮 —— 只需重组计数器值更改时依赖的那个 Composable 即可。recomposeHighlighter实战使用此修饰符,我们能够可视化可组合函数中如何发生重组。这是一个非常强大的工具,我能想象出基于此拓展的巨大潜力。Compose编译器指标前两种调试重组的方法非常有用,并且依赖于观察和可视化。但是,如果我们有一些更确凿的证据来证明Compose编译器如何解释我们的代码,那不是相当nice?这些感觉起来就像魔法一样,毕竟我们经常不知道编译器是否按照我们想要的方式在解释。事实证明,Compose 编译器确实有一种机制,能给出关于此信息的详细报告。我上个月发现了它,这让我大吃一惊🤯。这还有一些文档,我强烈建议大家阅读。启用此报告非常简单 :您只需在启用 Compose 的模块的build.gradle文件中添加这些编译器参数:compileKotlin {
// Compose Compiler Metrics
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=<directory>"
)
// Compose Compiler Report
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=<directory>"
)
}~~让我们更深入地了解一下这些指标告诉我们什么。~~在我写这篇博文的时候,工程师克里斯·巴恩斯(Chris Banes)发布了一篇博客文章,描述了这些编译器指标,他提供的信息与我希望涵盖的信息完全相同。所以我认为丢个链接到该博客文章会更好些,因为他已经写的很好,更详细地解释了它。(译注:上面提到的那篇博客我也进行了翻译,可以点击Jetpack Compose 性能优化指南:编译指标查看)这些指标包括每个类以及已配置模块中的可组合函数的详细信息。它主要关注对重组方式有直接影响的稳定性(译注:Stable相关)。我着实很想强调一些在我尝试时让我感到 surprised 的事情,我相信它也会让绝大多数人感到讶异的。注意: 我鼓励您至少浏览一下此文档,这样本文其余部分才有意义。对那里面已经提到的信息,我不再赘述。如果你使用的类所在模块没有启用compose,则Compose 编译器将无法推断其稳定性让我们看一个示例,以了解这意味着什么,以及 Compose 编译器报告如何帮助我发现这种细微差别 -data class ArticleMetadata(
val id: Int,
val title: String,
val url: String
)我们有一个名为ArticleMetadata 的简单数据类。由于它的所有属性都是原始值,因此 Compose 编译器将能够非常轻松地推断其稳定性。值得指出的是,此类是在未启用 Compose的模块中定义的。由于这是一个简单的数据类,因此我们直接在可组合函数中用它。此函数定义在启用了 Jetpack Compose 的其他模块中。@Composable
fun ArticleCard(
articleMetadata: ArticleMetadata,
modifier: Modifier = Modifier,
) { .. }当我们运行 Compose Compiler Metrics 时,以下是我们在 Compose 插件生成的其中一个文件 (composables.txt) 中找到的内容 -restartable fun ArticleCard(
unstable articleMetadata: ArticleMetadata
stable modifier: Modifier? = @static Companion
)我们看到可组合函数ArticleCard是可重新启动的,但不是可跳过的。这意味着Compose 编译器将无法执行智能优化,例如在参数未更改时跳过此函数的执行。有时这是出于实际选择,但在这种情况下,如果参数没有更改,我们肯定希望跳过此函数的执行。 🤔我们看到此行为的原因是,我们使用的是未启用 compose 的模块中的类。这阻止了 Compose 编译器智能地推断稳定性,因此它将此参数视为unstable ,这会影响了此可组合的重组方式。有两种方法可以解决此问题:向数据类所在的模块添加 compose 支持在启用 Compose 的模块中转换为其他类(例如 UI Model 类),并使可组合函数将其作为参数。List 参数无法被推断为 Stable,即使它的元素都是原始值让我们看一下另一个可组合函数,我们想要分析其指标@Composable
fun TagsCard(
tagList: List<String>,
modifier: Modifier = Modifier,
)当我们运行 Compose Compiler Metrics 时,我们看到的是 -restartable fun TagsCard(
unstable tagList: List<String>
stable modifier: Modifier? = @static Companion
)Uh oh! TagsCard具有与上一个示例相同的问题 —— 此函数可重新启动但不可跳过😭 。这是因为参数tagList不是 Stable 的—— 即使它是原始值类型(String)的 List,Compose 编译器也不会将 List 推断为稳定类型。这可能是因为 List 是一个接口,其实现可以是可变的,也可以是不可变的。解决此问题的一种方法是使用包装类并适当地对其进行注解,以使 Compose 编译器明确了解其稳定性。@Immutable
data class TagMetadata(
val tagList: List<String>,
)
@Composable
fun TagsCard(
tagMetadata: TagMetadata,
modifier: Modifier = Modifier,
)当我们再次运行 Compose Compiler Metrics 时,我们看到编译器能够正确推断出此函数的稳定性🎉restartable skippable fun TagsCard(
stable tagMetadata: TagMetadata
stable modifier: Modifier? = @static Companion
)由于这类用例相当常见,所以我很喜欢 Chris Banes 在博客文章中提出的可重用的包装类片段。(就是我贴的这段)总结正如您从本文中看到的,有好几种方法可以在 Jetpack Compose 中调试重组。您可能希望3种机制都来点,来在代码库中调试 Composable 函数。尤其是因为对大多数团队,这种构建Android应用程序的新方法还是刚刚发车的阶段。我着实很希望在 Android Studio 本身中对调试 Composable 提供一流的支持,但在那之前,您也有一些选择😉,我鼓励大家使用我在本文中展示的其中一些选项 - 我相信您会像我一样找到一些惊喜。
江江说技术
当你真的学会DataBinding后,你会发现“这玩意真香”!
前言🏀DataBinding只是一种工具,用来解决View和数据之间的绑定。Data Binding,顾名思义:数据绑定,它可以将布局页面中的组件和应用中的数据进行绑定,支持单向绑定和双向绑定,单向绑定就是如果数据有变化就会驱动页面进行变化,双向绑定就是除了单向绑定之外还支持页面的变化驱动数据的变化,如果页面中有一个输入框,那么我们就可以进行双向绑定,数据变化,它的显示内容就变了,我们手动输入内容也可以改变绑定它的数据。🌟官方文档:developer.android.google.cn/jetpack/and…🌟官方Demo地址:github.com/googlecodel…如何使用DataBinding呢?1.启用DataBinding引用官方文档: Databinding与 Android Gradle 插件捆绑在一起。您无需声明对此库的依赖项,但必须启用它。 ☀注意:即使模块不直接使用数据绑定,也必须为依赖于使用数据绑定的库的所有模块启用数据绑定。//在gradle的android下加入,然后点击sync
android {
...
//android studio 4.0以下
dataBinding{
}
//android studio4.1以后
buildFeatures {
dataBinding true
}
}
2.生成DataBinding布局在我们的布局文件中,选择根目录的View,按下Alt+回车键,点击Convert to data binding layout,就可以转换为DataBinding布局啦。然后我们的布局就会变成这样:<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
我们可以发现,最外面变成了layout元素,里面有data元素。我们将在data元素中声明这个布局中使用到的变量,以及变量的类型。举个例子:<data>
<import type="com.example...."/>
<variable
name="color"
type="java.lang.String" />
</data>
data: 在标签内进行变量声明和导入等variable: 进行变量声明import: 导入需要的类3.声明一个User实体类class User() {
var name = "Taxze"
var age = 18
fun testOnclick() {
Log.d("onclick", "test")
}
}
4.在xml中使用然后在data中声明变量,以及类名<data>
<!-- <variable-->
<!-- name="user"-->
<!-- type="com.taxze.jetpack.databinding.User" />-->
<import type="com.taxze.jetpack.databinding.User" />
<variable
name="user"
type="User" />
</data>
然后在布局中使用@{}语法//伪代码,请勿直接CV
<TextView
...
android:text="@{user.name}"
/>
5.在Activity或Fragment中使用DataBinding在Activity中通过DataBindingUtil设置布局文件,同时省略Activity的setContentView方法class MainActivity : AppCompatActivity() {
private lateinit var mainBinding: ActivityMainBinding
private lateinit var mainUser: User
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mainBinding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
mainUser = User()
mainBinding.user = mainUser
}
}
在Fragment中使用:class BlankFragment : Fragment() {
private lateinit var mainFragmentBinding:FragmentBlankBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
mainFragmentBinding = DataBindingUtil.inflate(inflater,R.layout.fragment_blank,container,false)
return mainFragmentBinding.root
}
}
系统会为每个布局文件都生成一个绑定类。一般默认情况下,类的名称是布局文件名称转化为Pascal大小写形式,然后在末尾添加Binding后缀,例如:名称为activity_main的布局文件,对应的类名就是ActivityMainBinding运行之后的效果:注意:只有当布局文件转换为layout样式之后,databinding才会根据布局文件的名字自动生成一个对应的binding类,你也可以在build/generated/data_binding_base_source_out目录下查看生成的类最最最基础的使用就是这样,接下来我们来讲讲如何更好的使用DataBinding如何在xml布局中更好的使用DataBinding1.使用集合中的元素加入我们传入了一个集合books,我们可以通过以下方式使用: 获取集合的值 android:text="@{books.get(0).name}" android:text="@{books.[0].name}" 添加默认值(⚡默认值无需加引号,且只在预览视图显示) android:text="@{books.pages,default=330}" 通过??或?:来实现 android:text="@{books.pages != null ? book.pages : book.defaultPages}" xml复制代码android:text="@{books.pages ?? book.defaultPages}"2.使用map中的数据map类型的结构也可以通过get和[]两种方式获取 //需要注意单双引号 android:text="@{books.get('name')}" android:text="@{books['name']}"3.转换数据类型因为DataBinding不会自动做类型转换,所有我们需要手动转换,例如在text标签内使用String.valueOf()转换为String类型,在rating标签内我们可以使用Float.valueOf()进行转换 android:text="@{String.valueOf(book.pages)}" android:rating="@{Float.valueOf(books.rating),default=2.0}"4.导入包名冲突处理如果我们导入的包名有冲突,我们可以通过alias为它设置一个别名 //伪代码,请勿直接CV <data> <import type="com.xxx.a.Book" alias="aBook"/> <import type="com.xxx.B.Book" alias="bBook"/> ... </data>5.隐式引用属性在一个view上引用其他view的属性//伪代码,请勿直接CV <import type="android.view.View"/> ... <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent"> <CheckBox android:id="@+id/checkOne" .../> <ImageView android:visibility="@{checkOne.checked ? View.VISIBLE : View.GONE}" .../> </LinearLayout>**include标签和ViewStub**标签 include和merge标签的作用是实现布局文件的重用。就是说,为了高效复用及整合布局,使布局轻便化,我们可以使用include和merge标签将一个布局嵌入到另一个布局中,或者说将多个布局中的相同元素抽取出来,独立管理,再复用到各个布局中,便于统一的调整。 比如,一个应用中的多个页面都要用到统一样式的标题栏或底部导航栏,这时就可以将标题栏或底部导航栏的布局抽取出来,再以include标签形式嵌入到需要的布局中,而不是多次copy代码,这样在修改时只需修改一处即可。而我们同样可以通过DataBinding来进行数据绑定。 例如://layout_title.xml <?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <import type="com.xxx.User" /> <variable name="userInfo" type="User" /> </data> <android.support.constraint.ConstraintLayout ... > <TextView ... android:text="@{userInfo.name}" /> </android.support.constraint.ConstraintLayout> </layout> 使用该布局,并传值<include layout="@layout/layout_title" bind:test="@{userInfo}"/> ViewStub也是类似的用法,这里就不说了。 DataBinding不支持merge标签6.绑定点击事件//伪代码,请勿直接CV onclick="@{()->user.testOnclick}" onclick="@{(v)->user.testOnclick(v)}" onclick="@{()->user.testOnclick(context)}" onclick="@{BindHelp::staticClick}" onclick="@{callback}" //例如: <Button android:layout_width="match_parent" android:layout_height="match_parent" android:onClick="@{()->user.testOnclick}" />💡文章到这里讲的都是DataBinding如何设置数据,以及通过DataBinding在xml中的一些基础使用。如果只是使用DataBinding这个功能,那就有点大材小用了。它还有一个很强大的功能我们还没有讲,那就是数据更新时自动刷新UI。实现数据变化时自动更新UI一个普通的实体类或者ViewModel被更新后,并不会让UI自动更新。而我们希望,当数据变更后UI要自动更新,那么要实现数据变化时自动更新UI,有三种方法可以使用,分别是BaseObservable,ObservableField,ObservableCollection💡单向数据绑定:BaseObservable BaseObservable提供了两个刷新UI的方法,分别是 notifyPropertyChanged() 和 notifyChange() 。 第一步:修改实体类 将我们的实体类继承与BaseObservable。需要响应变化的字段,就在对应变量的get函数上加 @Bindable 。然后set中notifyChange是kotlin的写法,免去了java的getter setter的方式。成员属性需要响应变化的,就在其set函数中,notify一下属性变化,那么set的时候,databinding就会感知到。 import androidx.databinding.BaseObservable import androidx.databinding.Bindable import androidx.databinding.library.baseAdapters.BR class User() : BaseObservable() { constructor(name: String, age: Int) : this() { this.name = name this.age = age } //这是单独在set上@bindable,name可以为声明private var name: String = "" set(value) { field = value notifyPropertyChanged(BR.name) } @Bindable get() = field //这是在整个变量上声明@bindable,所以必须是public的 @Bindable var age:Int = 18 set(value) { field = value notifyPropertyChanged(BR.age) } get() = field } 第二步:在Activity中使用 kotlin复制代码class MainActivity : AppCompatActivity() { private val TAG = "MainActivity" private lateinit var mainBinding: ActivityMainBinding private lateinit var mainUser: User override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) mainBinding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main) mainUser = User("Taxze", 18) mainUser.addOnPropertyChangedCallback(object : Observable.OnPropertyChangedCallback() { override fun onPropertyChanged(sender: Observable, propertyId: Int) { when { BR.user == propertyId -> { Log.d(TAG, "BR.user") } BR.age == propertyId -> { Log.d(TAG, "BR.age") } } } }) mainBinding.user = mainUser mainBinding.onClickPresenter = OnClickPresenter() } inner class OnClickPresenter { fun changeName() { mainUser.name = "Taxze2222222" } } } 需要注意的点 官方网站只是提示了开启DataBinding只需要在build.gradle中加入下面这行代码 buildFeatures { dataBinding true } 但是,如果你想更好的使用DataBinding这是不够的,你还需要添加这些配置:compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
} 🔥重点:在使用DataBinding得时候,BR对象,发现调用不了,生成也会报错,运行,需要在咱们build.gradle中进行一下配置: apply plugin: 'kotlin-kapt' kapt { generateStubs = true } 然后重新运行一遍代码,你就会发现,BR文件自动生成啦!ObservableField 讲解了BaseObservable后,现在来将建最简单也是最常用的。只需要将实体类变化成这样即可: //注意observable的属性需要public权限,否则dataBinding则无法通过反射处理数据响应 class User() : BaseObservable() { var name: ObservableField<String> = ObservableField("Taxze") var age:ObservableInt = ObservableInt(18) }ObservableCollection dataBinding 也提供了包装类用于替代原生的 List 和 Map,分别是 ObservableList 和 ObservableMap 实体类修改: //伪代码,请勿直接cv class User(){ var userMap = ObservableArrayMap<String,String>() } //使用时: mainUser.userMap["name"] = "Taxze" mainUser.userMap["age"] = "18" 使用ObservableCollection后,xml与上面的略有不同,主要是数据的获取,需要指定Key值 //伪代码,请勿直接cv ... <import type="android.databinding.ObservableMap" /> <variable name="userMap" type="ObservableMap<String, String>" /> //使用时: <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:hint="Name" android:text="@{userMap[`userName`]}" />💡双向数据绑定:只需要在之前的单向绑定的基础上,将布局文件@{}变为@={},用于针对属性的数据改变的同时监听用户的更新DataBinding在RecyclerView中的使用在RecyclerView中使用DataBinding稍有变化,我们在ViewHolder中进行binding对象的产生,以及数据对象的绑定。我们通过一个非常简单的例子来讲解如何在RecyclerView中使用DataBinding。效果图:第一步:创建实体类 就是我们之前的,使用了BaseObservable的那个实体类,这里就不放代码了第二步:创建activity_main用于存放recyclerview <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/activty_main" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView" android:layout_width="match_parent" android:layout_height="wrap_content" /> </RelativeLayout>第三步:创建text_item.xml用于展示recyclerview中的每一行数据 <?xml version="1.0" encoding="utf-8"?> <layout xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android"> <data> <import type="com.taxze.jetpack.databinding.User" /> <variable name="user" type="User" /> </data> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <LinearLayout android:layout_width="match_parent" android:layout_height="40dp" android:background="#ffffff" android:orientation="horizontal" android:paddingStart="10dp"> <TextView android:id="@+id/tv_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:text="@{`这个人的姓名是` + user.name}" /> <TextView android:id="@+id/tv_age" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:layout_marginLeft="20dp" android:text="@{String.valueOf(user.age)}" /> </LinearLayout> </LinearLayout> </layout>第四步:创建Adapter 有了之前的基础之后,大家看下面这些代码应该很容易了,就不做过多讲解啦 import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.databinding.DataBindingUtil import androidx.recyclerview.widget.RecyclerView import com.taxze.jetpack.databinding.databinding.TextItemBinding class FirstAdapter(users: MutableList<User>, context: Context) : RecyclerView.Adapter<FirstAdapter.MyHolder>() { //在构造函数中声明binding变量,这样holder才能引用到,如果不加val/var,就引用不到,就需要在class的{}内写get函数 class MyHolder(val binding: TextItemBinding) : RecyclerView.ViewHolder(binding.root) private var users: MutableList<User> = arrayListOf() private var context: Context init { this.users = users this.context = context } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyHolder { val inflater = LayoutInflater.from(context) val binding: TextItemBinding = DataBindingUtil.inflate(inflater, R.layout.text_item, parent, false) return MyHolder(binding) } override fun onBindViewHolder(holder: MyHolder, position: Int) { //java 写法可以setVariable holder.binding.user = users[position] holder.binding.executePendingBindings() } //kotlin中,return的方式,可以简写 override fun getItemCount() = users.size }第五步:在MainActivity中使用 import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) initView() } //给RecyclerView设置数据 private fun initView() { val recyclerView = findViewById<View>(R.id.recyclerView) as RecyclerView recyclerView.layoutManager = LinearLayoutManager(this) val users: MutableList<User> = ArrayList() for (i in 0..100) { val user = User() user.name = "Taxze" user.age = i users.add(user) } val adapter = FirstAdapter(users, this) recyclerView.adapter = adapter } }这样就完成了在RecyclerView中使用DataBinding啦。高级用法第一个:用于appCompatImageView的自定义属性//伪代码,请勿直接cv
/**
* 用于appCompatImageView的自定义属性,bind:imgSrc,命名空间bind:可以省略,也就是写作 imgSrc亦可。可以用于加载url的图片
* 函数名也是随意,主要是value的声明,就是新加的属性名了,可以多个属性同用,并配置是否必须一起作用
* 函数名随意,方法签名才重要,匹配对象控件,以及属性参数。
* 这里还可以添加old 参数,获取修改新参数 之前对应的值。
* todo 加载网络图片,需要网络权限!!!
*/
@JvmStatic
@BindingAdapter(value = ["bind:imgSrc"], requireAll = false)
fun urlImageSrc(view: AppCompatImageView, /*old: String?, */url: String) {
Glide.with(view)
.load(url)
.placeholder(R.drawable.img_banner)
.centerInside()
.into(view)
}
第二个:配合swipeRefreshLayout的刷新状态的感知第一步:单向的,数据变化,刷新UI//伪代码,请勿直接cv @JvmStatic @BindingAdapter("sfl_refreshing", requireAll = false) fun setSwipeRefreshing(view: SwipeRefreshLayout, oldValue: Boolean, newValue: Boolean) { //判断是否是新的值,避免陷入死循环 if (oldValue != newValue) view.isRefreshing = newValue }第二步:ui的状态,反向绑定给数据变化 //伪代码,请勿直接cv @JvmStatic @BindingAdapter("sfl_refreshingAttrChanged", requireAll = false) fun setRefreshCallback(view: SwipeRefreshLayout, listener: InverseBindingListener?) { listener ?: return view.setOnRefreshListener { //由ui层的刷新状态变化,反向通知数据层的变化 listener.onChange() } }第三步: 反向绑定的实现 //伪代码,请勿直接cv /** * 反向绑定的实现,将UI的变化,回调给bindingListener,listener就会onChange,通知数据变化 * 注意这里的attribute和event,是跟上面两步配合一致才有效 */ @JvmStatic @InverseBindingAdapter(attribute = "sfl_refreshing", event = "sfl_refreshingAttrChanged") fun isSwipeRefreshing(view: SwipeRefreshLayout): Boolean { return view.isRefreshing }DataBinding配合ViewModel&LiveData一起使用我将通过一个简单的例子带大家学习他们如何一起使用,话不多说,先上效果图:第一步:创建UserModel//将其继承于AndroidViewModel(AndroidViewModel也是继承于ViewModel的,但是ViewModel本身没有办法获得 Context,AndroidViewModel提供Application用作Context,并专门提供 Application 单例) //UserName 使用MutableLiveData class UserModel(application: Application) : AndroidViewModel(application) { var UserName = MutableLiveData("") }第二步:创建activity_main和对应的MainActivity<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="loginModel" type="com.taxze.jetpack.databinding.model.UserModel" /> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/linearLayout" style="@style/InputBoxStyle" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="17dp" android:layout_marginEnd="17dp" app:layout_constraintBottom_toTopOf="@+id/guideline2" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="parent" tools:ignore="MissingConstraints"> <EditText android:id="@+id/editText" style="@style/EditTextStyle" android:layout_width="match_parent" android:layout_height="50dp" android:hint="请输入账号" android:text="@={loginModel.UserName}" tools:ignore="MissingConstraints" /> </LinearLayout> <TextView android:id="@+id/textView2" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:text='@{"您输入的账号名是:"+loginModel.UserName,default=123123123}' android:textSize="24sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/button" tools:ignore="MissingConstraints" /> <androidx.constraintlayout.widget.Guideline android:id="@+id/guideline2" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal" app:layout_constraintGuide_percent="0.4" /> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:background="@drawable/button_drawable" android:text="登录" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/linearLayout" tools:ignore="MissingConstraints" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>第三步:在MainActivity中绑定页面和绑定声明周期 class MainActivity : AppCompatActivity() { lateinit var viewDataBinding: ActivityMainBinding lateinit var model: UserModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //绑定页面 viewDataBinding = DataBindingUtil.setContentView(this, R.layout.activity_main) //绑定生命周期 viewDataBinding.lifecycleOwner = this model = ViewModelProvider.AndroidViewModelFactory.getInstance(this.application) .create(UserModel::class.java) viewDataBinding.loginModel = model ... } }第四步:传值 viewDataBinding.button.setOnClickListener { val intent = Intent(MainActivity@ this, SecondActivity::class.java) intent.putExtra("user", "${model.UserName.value}") startActivity( intent ) }第五步:在另外一个activity中调用 class SecondActivity : AppCompatActivity() { lateinit var viewDataBinding: ActivitySecondBinding lateinit var userName: String override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //绑定页面 viewDataBinding = DataBindingUtil.setContentView(this, R.layout.activity_second) //绑定生命周期 viewDataBinding.lifecycleOwner = this userName = intent.getStringExtra("user").toString() viewDataBinding.tvName.text = "登录的账号是:$userName" } }帮你踩坑🍖:TextView的text属性,需要注意data不能为Number类型反射属性、函数必须是publicobservableField数据的时候,在某些场合需要初始化,否则会运行报错!使用LiveData作为dataBinding的时候,需要在ui中设置binding.lifecycleOwner名为ALuoBo的读者补充:xml中可以使用中文字符,格式如下: android:text='@{state.isChinese? "确定" :"OK"}'尾述这篇文章已经很详细的讲了DataBinding的大部分用法,不过在看完文章后,你仍需多多实践,相信你很快就可以掌握DataBinding啦😺 有问题欢迎在评论区留言讨论~
江江说技术
一步步基于ViewModel协程搭建通用网络请求工具
1.协程中异常的捕获协程中异常捕获的方式有两种:常见的try-catch相比较于CoroutineExceptionHandler,这种方式可以灵活的捕捉可能发生异常的代码块:lifecycleScope.launch {
//省略其他逻辑代码
try {
val result = 8 / 0
} catch (e: Exception) {
}
//省略其他逻辑代码
}
协程上下文元素CoroutineExceptionHandler这种方式捕捉的异常是直接捕捉的整个协程块的异常,颗粒度比较大,缺少灵活性。lifecycleScope.launch(CoroutineExceptionHandler { _, throwable ->
Log.e("ChapterActivity", "exception occur: ${throwable.message}")
}) {
//省略其他逻辑代码
val result = 8 / 0
//省略其他逻辑代码
}
2.封装网络请求首先定义一个类,类中分别定义三种函数类型属性,分别用作:发起请求、请求成功、请求失败。class Action<T> {
//发起请求
var request: (suspend () -> Response<T>)? = null
private set
//请求成功
var success: ((T) -> Unit)? = null
private set
//请求失败
var error: ((Throwable) -> Unit)? = null
private set
fun request(block: suspend () -> Response<T>) {
request = block
}
fun success(block: (T) -> Unit) {
success = block
}
fun error(block: (Throwable) -> Unit) {
error = block
}
}
data class Response<T>(val code: Int = -1, val data: T? = null)
定义一个扩展函数netRequest,以DSL的方式创建Action<T>对象:fun <T> ViewModel.netRequest(block: Action<T>.() -> Unit) {
val action = Action<T>().apply(block)
}
我们就可以这样创建Action<T>对象并为其函数类型的属性分别设置值:netRequest<String> {
request {
//模拟执行网络耗时
delay(5* 1000)
Response("dd")
}
success {
//请求成功执行代码逻辑
}
error {
//请求失败执行的代码逻辑
}
}
架子基本上我们已经搭建起来了,在处理网络请求之前先定义一个异常,专门用于处理请求响应失败的问题。data class CustomException(val code: Int = -1, val throwable: String? = null) : Exception(throwable)
现在就得在netRequest方法中利用协程来执行网络请求、处理请求结果回调以及异常的捕获:fun <T> ViewModel.netRequest(block: Action<T>.() -> Unit) {
val action = Action<T>().apply(block)
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
action.error?.invoke(throwable)
}) {
val result = withContext(Dispatchers.IO) {
action.request!!.invoke()
}
if (result.code == 0) {
action.success?.invoke(result.data)
} else {
action.error?.invoke(CustomException(result.code, "请求失败"))
}
}
}
通过viewModelScope开启一个协程,不过我们并没有直接在外层的launch就直接指定执行的线程,而是通过withContext指定分发器Dispatchers.IO去单独执行耗时请求,请求结束后会自动将线程从withContext指定的线程中切换到外层launch协程块所在的线程。如果直接在外层launch直接指定Dispatchers.IO,这就会导致请求成功action.success和请求失败action.error的执行的环境为非主线程,避免后续开发者再额外处理这种非主线程回调的情况。经过上述一步步流程,我们就最终搭建成功了一个简单网络请求工具类。搭配Retrofit提供的suspend适配器com.squareup.retrofit2:retrofit:2.9.0这个版本的retrofit已经适配了协程的suspend:判断是否为suspend修饰的请求方法:suspend修饰方法会在方法参数上增加一个Continuation参数:#RequestFactory.java在HttpServiceMethod中:具体的源码就不带大家看了,关键就在于SuspendForBody方法中的adapt方法中,内部会调用OkhttpClient#Call的方法enqueue开启线程去执行网络请求,执行完毕之后会再切换到调用请求的位置所在的线程,感兴趣的请自行查看。参考协程+Retrofit 让你的代码足够优雅
江江说技术
Jetpack Compose 通用加载微件的实现
加载数据在Android开发中应该算是非常频繁的操作了,因此简单在Jetpack Compose中实现一个通用的加载微件效果如下:加载中(转圈圈)另外加载失败后显示失败并可以点击重试实现实现这个微件其实非常简单,无非就是根据不同的状态加载不同页面加载状态Bean首先把加载的状态抽象出来,写个数据类 sealed class LoadingState<out R> {
object Loading : LoadingState<Nothing>()
data class Failure(val error : Throwable) : LoadingState<Nothing>()
data class Success<T>(val data : T) : LoadingState<T>()
val isLoading
get() = this is Loading
val isSuccess
get() = this is Success<*>
}此处借鉴了朱江大佬写的玩安卓Compose版本,在此感谢微件然后就是加载了。考虑到加载一般耗时,所以用协程。写成微件大概就是下面这个样子 private const val TAG = "LoadingWidget"
/**
* 通用加载微件
* @author [FunnySaltyFish](https://blog.funnysaltyfish.fun/)
* @param modifier Modifier 整个微件包围在Box中,此处修饰此Box
* @param loader 加载函数,返回值为正常加载出的结果
* @param loading 加载中显示的页面,默认为转圈圈
* @param failure 加载失败显示的页面,默认为文本,点击可以重新加载(retry即为重新加载的函数)
* @param success 加载成功后的页面,参数[T]即为返回的结果
*/
@Composable
fun <T> LoadingContent(
modifier: Modifier = Modifier,
loader : suspend ()->T,
loading : @Composable ()->Unit = { DefaultLoading() },
failure : @Composable (error : Throwable, retry : ()->Unit)->Unit = { error, retry->
DefaultFailure(error, retry)
},
success : @Composable (data : T?)->Unit
) {
var key by remember {
mutableStateOf(false)
}
val state : LoadingState<T> by produceState<LoadingState<T>>(initialValue = LoadingState.Loading, key){
value = try {
Log.d(TAG, "LoadingContent: loading...")
LoadingState.Success(loader())
}catch (e: Exception){
LoadingState.Failure(e)
}
}
Box(modifier = modifier){
when(state){
is LoadingState.Loading -> loading()
is LoadingState.Success<T> -> success((state as LoadingState.Success<T>).data)
is LoadingState.Failure -> failure((state as LoadingState.Failure).error){
key = !key
Log.d(TAG, "LoadingContent: newKey:$key")
}
}
}
}基于produceState加载并保存数据,然后根据不同的加载状态显示不同的页面。官方对此函数的翻译如下:/**
* Return an observable [snapshot][androidx.compose.runtime.snapshots.Snapshot] [State] that
* produces values over time from [key1].
*
* [producer] is launched when [produceState] enters the composition and is cancelled when
* [produceState] leaves the composition. If [key1] changes, a running [producer] will be
* cancelled and re-launched for the new source. [producer] should use [ProduceStateScope.value]
* to set new values on the returned [State].
*
* The returned [State] conflates values; no change will be observable if
* [ProduceStateScope.value] is used to set a value that is [equal][Any.equals] to its old value,
* and observers may only see the latest value if several values are set in rapid succession.
*
* [produceState] may be used to observe either suspending or non-suspending sources of external
* data, for example:
*
* @sample androidx.compose.runtime.samples.ProduceState
*
* @sample androidx.compose.runtime.samples.ProduceStateAwaitDispose
*/翻译过来就是:/**
* 返回一个可观察的[snapshot][androidx.compose.runtime.snapshots.Snapshot] [State] 对象,它的值由[key1]随时间变化产生.
*
* [producer] 在 [produceState] 进入 composition 后会被启动,当[produceState] 离开 composition 时被取消. 如果 [key1] 改变, 当前正在运行的 [producer] 将被取消并根据新来源重启.
* [producer] 应当使用 [ProduceStateScope.value] ,在返回的 [State] 中设置 value 的值.
*
* 返回的 [State] 与 value 一致; 如若新的值与旧value相等[Any.equals] ,则此变化不会被观察到
* 如果在短时间内多个新值被赋予,则观察着可能仅能观察到最新的值
*
* [produceState] 可被用在 suspending / non-suspending 的外部数据来源中,如:
*
* @sample androidx.compose.runtime.samples.ProduceState
*
* @sample androidx.compose.runtime.samples.ProduceStateAwaitDispose
*/除开数据加载外,上面的代码也给出了几个默认页面。分别有默认的加载页面(转圈圈)和默认的错误页面(点击重试) @Composable
fun DefaultLoading() {
CircularProgressIndicator()
}
@Composable
fun DefaultFailure(error: Throwable, retry : ()->Unit) {
Text(text = stringResource(id = R.string.loading_error), modifier = Modifier.clickable(onClick = retry))
}此微件使用起来也很简单直接 LoadingContent(
modifier = Modifier.align(CenterHorizontally) ,
loader = (vm.sponsorService::getAllSponsor)
) { list ->
list?.let{
Column {
SponsorList(it)
Text("加载完成")
}
}
}完整代码在这里:加载微件:FunnyTranslation/LoadingWidget.kt使用示例:FunnyTranslation/ThanksScreen.kt
江江说技术
入坑 Jetpack Compose :写一个简单的计算器
本文是一个综合的Compose小例子,涉及动画、自定义布局、列表等主题。本文并非教程,只是展示展示Compose开发应用是什么感觉,并试图拉人入坑。如果你还没接触过,不妨进来扫一扫代码,读一读单词,感受感受~ 本文所展示的思路仅为个人想法,并不代表最优解,也欢迎一起探讨前言8月份的时候,我关注了 fundroid 大佬的公众号,看到历史推文中有这么一篇,内容是Compose学习挑战赛,要求为“实现一个计算器 App”。正好自己对Compose有过一点经验 (这个可以点开头像看历史文章),抱着试试看的态度,我花大概4-5h完成并提交了作品。尽管作品比较简单,但结果还是(补充:看了看评论区大佬的图,发现这是个参与纪念奖 hhh):几天前,我收到了Google发来的这封邮件:所以就简单介绍下吧,或许也可以当做非常入门的小案例,说不定能帮到些人、拉入点坑。本文源码地址见文末效果可以看到,尽管开发的时间并不长,但是基本的小功能也还是有的。计算的时候也会有点简单的小动画,还适配了横屏的布局。顺带一提,由于Compose天然的特性,项目还自动适配了深色模式,如下:实现以竖屏的布局为例,它主要包括这几个部分或许我们可以分别叫它们:历史记录区、表达式区和输入区输入区之所以先看输入区,是因为这是页面的主体部分。从布局来看,整体为均匀的网格状。在Compose中,想实现这样的网格布局也有几种选择,比如使用Lazy系列的LazyGrid(可以参考我的 Jetpack Compose LazyGrid使用全解)。不过,某种程度上,出于教程的目的,我在这里用的是自定义布局+For循环。自定义布局?你可能比较疑惑:这里为啥需要自定义布局?这就要从我自己的数据结构说起了。为了表示按键的布局,我用了个二维字符数据val symbols = arrayOf(
charArrayOf('C','(',')','/'),
charArrayOf('7','8','9','*'),
charArrayOf('4','5','6','-'),
charArrayOf('1','2','3','+'),
charArrayOf('⌫','0','.','=')
)我希望的效果是呢,每个按键都是正方形,因此,输入区的长宽比需要和二维数组的行列比一致。也就是,竖屏的时候宽度固定,计算高度;横屏则反过来。整个输入区由一个Box包裹,因此只需要动态调整它自己的宽高即可。因此,此处使用Modifier.layout修饰自己。代码如下:// 每个正方形的宽度
var l by remember {
mutableStateOf(0)
}
Box(
modifier
.layout { measurable, constraints ->
val w: Int
val h: Int
if (isVertical) {
// 竖屏的时候宽度固定,计算高度
w = constraints.maxWidth
l = w / symbols[0].size
h = l * symbols.size
} else {
// 横屏的时候高度固定,计算宽度
h = constraints.maxHeight
l = h / symbols.size
w = l * symbols[0].size
}
val placeable = measurable.measure(
constraints.copy(
minWidth = w, // 宽度最大最小值相同,即为确定值
maxWidth = w,
minHeight = h, // 高度也是
maxHeight = h
)
)
// 调用 layout 摆放自己
layout(w, h) {
placeable.placeRelative(0, 0)
}
}) {
/*省略Childen,见下文*/
}如果你没有接触过自定义布局,可以参考如下文章:深入Jetpack Compose——布局原理与自定义布局(一) - 掘金 (juejin.cn)深入Jetpack Compose——布局原理与自定义布局(二) - 掘金 (juejin.cn)深入Jetpack Compose——布局原理与自定义布局(三) - 掘金 (juejin.cn)深入Jetpack Compose——布局原理与自定义布局(四)ParentData - 掘金 (juejin.cn)回到文章,上面已经正确的设置了Box的大小,接下来往里面放内容就好。在这里就是简单的双重for循环:symbols.forEachIndexed { i, array ->
array.forEachIndexed { j, char ->
Box(modifier = Modifier
.offset { IntOffset(j * l, i * l) }
.size(with(LocalDensity.current) { l.toDp() })
.padding(16.dp)
.clickable {
vm.click(char)
}) {
Text(modifier = Modifier.align(Alignment.Center), text = char.toString(), fontSize = 24.sp, color = contentColorFor(backgroundColor = MaterialTheme.colors.background))
}
}
}Box类似于View,是最基本的@Composable。在Compose中,各Composable的样式由Modifier修饰,以链式调用的方式设置。此处使用.size修饰符确定了每个按键的大小,offset确定了它们的位置(偏移)。这里有趣的地方是,因为padding先于clickable设置,所以点击的波纹是在padding区域内的(这是我希望的效果,不然有点丑)。这也是初学者需要注意的一点:Modifier的顺序很重要表达式区域这个区域很简单,有趣的地方在于,它是有动画的。实现这样的效果或许在xml里略显繁琐,但在Compose里却相当简单@Composable
fun CalcText(
modifier: Modifier,
formulaTextProvider: () -> String,
resultTextProvider: () -> String,
) {
val animSpec = remember {
TweenSpec<Float>(500)
}
Column(modifier = modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
val progressAnim = remember {
Animatable(1f, 1f)
} // 进度,1为仅有算式,0为结果
val progress by remember { derivedStateOf { progressAnim.value } }
// 根据 progress 的值计算字体大小
Text(text = formulaTextProvider(), fontSize = (18 + 18 * progress).sp, ...)
val resultText = resultTextProvider()
// 根据 progress 的值计算字体大小(与上面那个变化方向相反)
if (resultText != "") {
Text(text = resultText, (36 - 18 * progress).sp, ...)
}
LaunchedEffect(resultText) {
if (resultText != "") progressAnim.animateTo(0f, animationSpec = animSpec)
else progressAnim.animateTo(1f, animationSpec = animSpec)
}
}
}对,就这么点!这里的整体思路是,用Column(纵向布局)放置两个Text,并在resultText(也就是计算结果)改变时执行动画,改变二者的字体大小。这样的过程类似于View体系下的属性动画,但在Compose声明式 UI=f(State) 的理念下,写出的代码更自然。这或许是Compose开发上的另一有趣之处。历史记录区这个区域就更简单了,就是个列表呗。对于View用户,这时候就要开始建xml、写ViewHolder、设置Adapter一条龙了。但在Compose下,一切只需要交给LazyColumnLazyColumn(modifier, state = listState) {
items(vm.histories) { item ->
Text(modifier = Modifier.fillMaxWidth(), text = item.toString())
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}Compose的列表就是这么简单,不用花里胡哨,不用几个文件来回跳。告诉它数据源以及每个item长什么样就好。为了更好看一些,我还顺便给它加上了个Item进入动画:从右往左飞入。代码也很简单items(vm.histories) { item ->
// 偏移量
val offset = remember { Animatable(100f) }
LaunchedEffect(Unit) {
offset.animateTo(0f)
}
Text(modifier = Modifier
...
.offset { IntOffset(offset.value.toInt(), 0) }
...)
}上面的代码里出现了不少remember,可以理解为“记住”。Compose的刷新类似于在重新调用函数,于是为了让某个值能被保存下来,就得放在remember里。LaunchedEffect则为副作用的一种,当首次进入Composition或括号里的值(key)改变时才执行里面的内容,在这里用于启动动画。三个部分介绍完,接下来就是把它们合在一起啦合在一起竖屏状态下,合在一起似乎还有点困难:我们需要先摆放底部的输入区,等计算完它的宽高后,再在它上面放上历史记录和表达式。要解决这个问题也有挺多方法,比如Column+weight修饰符应该就可以。同样的,出于教程的目的,我这里还是换了个花里胡哨的做法:自定义布局。/**
* 纵向布局,先摆放Bottom再摆放,
* @param modifier Modifier
* @param bottom 底部的Composable,单个
* @param other 在它上面的Composable,单个
*/
@Composable
fun SubcomposeBottomFirstLayout(modifier: Modifier, bottom: @Composable () -> Unit, other: @Composable () -> Unit) {
SubcomposeLayout(modifier) { constraints: Constraints ->
var bottomHeight = 0
val bottomPlaceables = subcompose("bottom", bottom).map {
val placeable = it.measure(constraints.copy(minWidth = 0, minHeight = 0))
bottomHeight = placeable.height
placeable
}
// 计算完底部的高度后把剩余空间给other
val h = constraints.maxHeight - bottomHeight
val otherPlaceables = subcompose("other", other).map {
it.measure(constraints.copy(minHeight = 0, maxHeight = h))
}
layout(constraints.maxWidth, constraints.maxHeight) {
// 底部的从 h 的高度开始放置
bottomPlaceables[0].placeRelative(0, h)
otherPlaceables[0].placeRelative(0, 0)
}
}
}代码中使用到了SubcomposeLayout,可以参考ComposeMuseum的教程:SubcomposeLayout | 你好 Compose (jetpackcompose.cn)计算由于不是重点,所以本文直接跳过了。代码里直接使用的 JarvisJin/fin-expr: A expression evaluator for Java. Focus on precision, can be used in financial system. (github.com) 。如果需要自己实现,可以参考数据结构-栈以及BigDecimal类状态保存为了实现横竖屏切换时的状态保存,数据放在了ViewModel里。在Compose中,使用ViewModel非常简单。只需要引入androidx.activity:activity-compose:{version}包并在@Composable中如下获得对应ViewModel:val vm: CalcViewModel = viewModel()其他状态栏如果你仔细观察,上面的图中,为了更好的沉浸式,是没有状态栏的。这是借助的accompanist/systemuicontroller 库。accompanist是Google官方提供的一系列Compose辅助library,帮助快速实现一些常用功能,比如Pager、WebView、SwipeToRefresh等。使用起来也很简单:val systemUiController = rememberSystemUiController()
val isDark = isSystemInDarkTheme()
LaunchedEffect(systemUiController){
systemUiController.isSystemBarsVisible = false
// 设置状态栏颜色
// systemUiController.setStatusBarColor(Color.Transparent, !isDark)
}横竖屏判断此处判断的依据非常简单:当前屏幕的“宽度”。通过最外层的BoxWithConstraints获取到的constraints.maxWidth做判断依据,代码如下:BoxWithConstraints(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)) { // 小于720dp当竖屏
if (constraints.maxWidth / LocalDensity.current.density < 720) {
CalcScreenVertical(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp))
} else { // 否则当横屏
CalcScreenHorizontal(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 8.dp))
}
}通过if语句就能展示不同的布局,这也是Compose声明式UI的有趣之处。
江江说技术
Navigation — 这么好用的导航框架你确定不来看看?
前言什么是Navigation?官方文档的话有点不容易让人理解。所以,这里用我自己的话来总结一下,我们在处理Fragment是需要通过写Fragment的事务去操作Fragment的,而Navigation的出现是为了解决我们之前开发的一些痛点。Navigation主要用于实现Fragment代替Activity的页面导航功能,让Fragment能够轻松的实现跳转与传递参数,我们可以通过使用Navigation,让Fragment代替android项目中绝大多数的Activity。但需要注意的是在使用Navigation切换页面生命周期的变化情况,避免开发过程中踩坑。官方文档:developer.android.google.cn/jetpack/and…navigation项目地址:github.com/googlecodel…本文Demo地址:github.com/taxze6/Jetp…使用Navigation具有什么优势?处理Fragment事务默认情况下,能正确处理往返操作为动画和转换提供标准化资源实现和处理深层链接包括导航界面模式,例如抽屉式导航栏和底部导航,我们只需要完成少量的代码编写Safe Args - 可在目标之间导航和传递数据时提供类型安全的Gradle插件ViewModel支持 - 您可以将ViewModel的范围限定为导航图,以在图标的目标之间共享与界面相关的数据如何使用Navigation呢?Navigation目前仅AndroidStudio 3.2以上版本支持,如果您的版本不足3.2,请点此下载最新版AndroidStudio(2202年了应该没有人还在用3.2以下的版本吧!)在开始学习Navigation组件之前,我们需要先对Navigation主要组成部分有个简单的了解,Navigation由三部分组成:Navigation graph:一个包含所有导航相关信息的 XML 资源NavHostFragment:一种特殊的Fragment,用于承载导航内容的容器NavController:管理应用导航的对象,实现Fragment之间的跳转等操作下面我们正式开始学习Navigation啦第一步:添加依赖//project的Navigation依赖设置
dependencies {
//文章发布时的最新稳定版本:
def nav_version = "2.4.2"
// 使用java作为开发语言添加下面两行:
implementation "androidx.navigation:navigation-fragment:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version"
// Kotlin:
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
}
//Compose版本:
implementation "androidx.navigation:navigation-compose:$nav_version"
第二步:创建导航图①右键点击res目录,然后依次选择New→ Android Resource Directory。此时系统会显示 New Resource Directory对话框。Directory name输入你的文件夹名(一般为navigation),Resource type选择navigation②右键navigation文件夹,然后new →Navigation Resource File在File name中输入名称(常用nav_graph_main或nav_graph)第三步:创建Fragment为了让跳转更加的丰富,我们这里创建三个Fragment✔我们可以自己手动创建:FirstFragment:<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="hello world" />
</FrameLayout>
class FirstFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_first, container, false)
}
}
另外创建两个和FirstFragement一样的:SecondFragment,ThirdFragment我们也可以通过 Navigation graph 创建我们在新建好的nav_graph_main.xml下,在右上角切换到Design模式,然后在Navigation Editor中,点击Create new destination,选择所需要的Fragment后,点击Finish,你就会发现Fragment已经出现在我们可以拖动的面板中了。第四步:将Fragment拖入面板并进行跳转配置只需要在Navigation Editor中双击想要的Fragment就会被加入到面板中啦。点击其中一个Fragment,你会发现,在他的右边会有一个小圆点,拖曳小圆点指向想要跳转的那个Fragment,我们这里设置FirstFragment → SecondFragment → ThirdFragment → FirstFragment我们将nav_graph_main.xml切换到Code下,我们来解读一下xml<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph_main"
app:startDestination="@id/firstFragment">
<fragment
android:id="@+id/firstFragment"
android:name="com.taxze.jetpack.navigation.FirstFragment"
android:label="fragment_first"
tools:layout="@layout/fragment_first" >
<action
android:id="@+id/action_firstFragment_to_secondFragment2"
app:destination="@id/secondFragment" />
</fragment>
<fragment
android:id="@+id/secondFragment"
android:name="com.taxze.jetpack.navigation.SecondFragment"
android:label="fragment_second"
tools:layout="@layout/fragment_second" >
<action
android:id="@+id/action_secondFragment_to_thirdFragment2"
app:destination="@id/thirdFragment" />
</fragment>
<fragment
android:id="@+id/thirdFragment"
android:name="com.taxze.jetpack.navigation.ThirdFragment"
android:label="fragment_third"
tools:layout="@layout/fragment_third" >
<action
android:id="@+id/action_thirdFragment_to_firstFragment"
app:destination="@id/firstFragment" />
</fragment>
</navigation>
navigation是根标签,通过startDestination配置默认启动的第一个页面,这里配置的是firstFragment,我们可以在代码中手动改mainFragment(启动时的第一个Fragment),也可以在可视化面板中点击Fragment,再点击Assign Start Destination,同样可以修改mainFragmentfragment标签就代表这是一个Fragmentaction标签定义了页面跳转的行为,就是上图中的每条线,destination定义跳转的目标页,还可以加入跳转时的动画注意:在fragment标签下的android:name属性,其中的包名的是否正确声明第五步:处理MainActivity①编辑MainActivity的布局文件,在布局文件中添加NavHostFragment。我们需要告诉Navigation和Activity,我们的Fragment展示在哪里,所以NavHostFragment其实就是导航界面的容器<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<fragment
android:id="@+id/nav_host_fragment"
android:layout_width="0dp"
android:layout_height="0dp"
android:name="androidx.navigation.fragment.NavHostFragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph_main" />
</androidx.constraintlayout.widget.ConstraintLayout>
fragment标签下的android:name是用于指定NavHostFragmentapp:navGraph是用于指定导航视图的app:defaultNavHost=true是在每一次Fragment切换时,将点击记录在堆栈中保存起来,在需要退出时,按下返回键后,会从堆栈拿到上一次的Fragment进行显示。但是在某些情况下,这样的操作不是很友好,不过好在我们只需要将app:defaultNavHost=true改为app:defaultNavHost=false或者删除这行即可。在其为false的情况下,无论怎么切换Fragment,再点击返回键就都直接退出app。当然我们也可以对其堆栈进行监听,从而来实现,点击一次返回键回到主页,再点击一次返回键退出app。②修改MainActivity我们重写了onSupportNavigateUp,表示我们将Activity的back点击事件委托出去class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
override fun onSupportNavigateUp(): Boolean {
return findNavController(R.id.nav_host_fragment).navigateUp()
}
}
第六步:处理Fragment的对应跳转事件①Fragment布局:给一个按钮用于跳转,一个TextView用于标识,三个Fragment布局相同<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".FirstFragment">
<Button
android:id="@+id/firstButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="点击跳转到第二个fragment" />
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="@string/hello_first_fragment" />
</FrameLayout>
//secondFragment中加入:
<Button
android:id="@+id/firstButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="点击跳转到第三个fragment" />
//thirdFragment中加入:
<Button
android:id="@+id/firstButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="返回第一个fragment" />
②配置Fragment对应的跳转事件class FirstFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_first, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.firstButton).apply {
setOnClickListener {
it.findNavController().navigate(R.id.action_firstFragment_to_secondFragment2)
}
}
}
}
//secondFragment中加入:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.firstButton).apply {
setOnClickListener {
it.findNavController().navigate(R.id.action_secondFragment_to_thirdFragment2)
}
}
}
//thirdFragment中加入:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<Button>(R.id.firstButton).apply {
setOnClickListener {
it.findNavController().navigate(R.id.action_thirdFragment_to_firstFragment)
}
}
}
其中的R.id.action_firstFragment_to_secondFragment2这样的标识,是在nav_graph_main.xml的action标签下配置的。<action
android:id="@+id/action_firstFragment_to_secondFragment2"
app:destination="@id/secondFragment" />
③最后的效果图:跳转动画&自定义动画我们会发现,刚刚的例子中,我们在跳转界面时,没有左滑右滑进入界面的动画,显得很生硬,所以我们要给跳转过程中添加上动画。①添加默认动画:在nav_graph_main.xml文件中的Design模式下,点击连接两个Fragment的线。然后你会发现在右侧会出现一个Animations的面板,然后我们点击enterAnim这些选项最右侧的椭圆点,然后就会弹出Pick a Resoure的面板,我们可以在这里选择需要的动画,这里我们就设置nav_default_enter_anim。同理exitAnim我们也设置一个动画,nav_default_exit_anim。然后运行代码你就发现一个渐变的跳转动画。然后配置动画后会发现action多了动画相关的属性<fragment
android:id="@+id/firstFragment"
android:name="com.taxze.jetpack.navigation.FirstFragment"
android:label="fragment_first"
tools:layout="@layout/fragment_first" >
<action
android:id="@+id/action_firstFragment_to_secondFragment2"
app:destination="@id/secondFragment"
app:enterAnim="@anim/nav_default_enter_anim"
app:exitAnim="@anim/nav_default_exit_anim"
/>
</fragment>
enterAnim: 跳转时的目标页面动画exitAnim: 跳转时的原页面动画popEnterAnim: 回退时的目标页面动画popExitAnim:回退时的原页面动画②自定义动画 在真正的业务需求中是会有很多不同的跳转动画的,官方提供的默认动画是不够的,所以我们要学会自定义动画。⑴创建Animation资源文件右键点击res目录,然后依次选择New→ Android Resource File。此时系统会显示 New Resource File对话框。File name输入你的文件夹名(这里设置为slide_from_left),Resource type选择Animation,然后我们就可以发下在res目录下多了一个anim目录,里面存放着我们自定义的动画。⑵编写我们的动画代码这里举几个常用的例子://左滑效果
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="300"
android:fromXDelta="-100%"
android:toXDelta="0%">
</translate>
</set>
//右滑效果
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="300"
android:fromXDelta="0%"
android:toXDelta="100%">
</translate>
</set>
//旋转效果
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<scale
android:duration="1000"
android:fromXScale="0.0"
android:fromYScale="0.0"
android:pivotX="50%"
android:pivotY="50%"
android:toXScale="1.0"
android:toYScale="1.0" />
<rotate
android:duration="1000"
android:fromDegrees="0"
android:pivotX="50%"
android:pivotY="50%"
android:toDegrees="360" />
</set>
常用的属性:属性介绍alpha透明度设置效果scale大小缩放效果rotate旋转效果translate位移效果常用设置:属性介绍android:duration动画时长fromXX开始状态toXX结束状态⑶使用自定义动画文件使用自定义动画也是和使用默认动画是一样的。旋转效果:如何传递数据?Navigation 支持您通过定义目的地参数将数据附加到导航操作。一般情况下,建议传递少量数据。例如传递userId,而不是具体用户的信息。如果需要传递大量的数据,还是推荐使用ViewModel。在nav_graph_main.xml文件中的Design模式下。点击选中其中的Fragment。在右侧的Attributes 面板中,点击Arguments选项右侧的加号。添加需要的参数。添加参数后,在箭头 Action 上点击,会在右边的 Argument Default Values中显示这个userId变量,在xml中也可以看到//伪代码,请勿直接cv
<fragment
...
>
...
<argument
android:name="userId"
android:defaultValue="1"
app:argType="integer" />
</fragment>
代码处理://默认将 箭头 Action 中设置的参数传递过去
it.findNavController().navigate(R.id.action_firstFragment_to_secondFragment2)
动态传递数据①我们可以使用Bundle在目的地之间传递参数//伪代码,请勿直接cv
view.findViewById<Button>(R.id.firstButton).setOnClickListener {
val bundle = Bundle()
bundle.putString("userId", "1")
val navController = it.findNavController()
navController.navigate(R.id.action_firstFragment_to_secondFragment2, bundle)
}
②然后我们就可以接受参数啦在对应的Fragment:val tv = view.findViewById<TextView>(R.id.textView)
tv.text = arguments?.getString("userId")
在Activity使用setGraph切换不同的Navigation通常情况下,我们不止一个navigation的文件,我们需要根据业务情况去判断使用哪个,这里就可以用到我们的setGraph去切换不同的Navigation了。①把activity_main中fragment标签下的app:navGraph这个属性去除<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:defaultNavHost="true"/>
②在代码中通过setGraph设置app:navGraphclass MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initNav(1)
}
private fun initNav(id: Int) {
var controller = Navigation.findNavController(this@MainActivity, R.id.nav_host_fragment)
if (id == 1) {
//设置对应的app:navGraph
controller.setGraph(R.navigation.first)
}
if (id == 2) {
controller.setGraph(R.navigation.second)
}
this@MainActivity.finish()
}
}
到此,我们已经讲完了Navigation大部分的使用情况,下面来讲解几个重要的点,并通过一个案例来学习总结这篇文章吧。NavController 我们在前面已经讲了Navigation的使用,在处理Fragment的对应跳转事件时,我们用到了findNavController这个方法,其实呢,这个就是Navigation的三部分中的NavController中的一个api。那么什么是NavController呢?它是负责操作Navigation框架下的Fragment的跳转与退出、动画、监听当前Fragment信息,当然这些是基本操作。因为除了在Fragment中调用,在实际情况中它也可以在Activity中调用。如果能灵活的使用它,它可以帮你实现任何形式的页面跳转,也可以使用TabLayout配合Navigation在主页进行分页设计。如何获取NavController实例呢?在前面的基础使用中我们也用到了。//伪代码,请勿直接cv
//activity:
//Activity.findNavController(viewId: Int)
findNavController(R.id.nav_host_fragment).navigateUp()
//Fragment:
//Fragment.findNavController()
//View.findNavController()
findNavController().navigate(R.id.action_thirdFragment_to_firstFragment)
Navigation常用操作:①popBackStack弹出Fragment现在我们从oneFragment跳转到secondFragment在到thirdFragment,然后我们想从thirdFragment回到secondFragment那儿就可以使用popBackStackoverride fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
.....
btn.setOnClickListener{
//弹出Fragment
controller.popBackStack()
}
}
②弹出到指定的Fragment也是使用popBackStack,这个方法可以实现清空中间导航栈堆的需求。//xxxFragment弹出到指定的Fragment。
//第二个参数的布尔值如果为true则表示我们参数一的Fragment一起弹出,意思就是如果是false就popBackStack到
//xxxFragment,如果是true,就在xxxFragment在popBackStack()一次
controller.popBackStack(xxxFragment,true)
③navigateUp() 向上导航findNavController(R.id.nav_host_fragment).navigateUp()
navigateUp也是执行返回上一级Fragment的功能。和popBackStack功能一样。那么既然它存在肯定是有它特殊的地方的。navigateUp向上返回的功能其实也是调用popBackStack的。 但是,navigateUp的源码里多了一层判断,判断这个Navigation是否是最后一个Fragment。使用popBackStack()如果当前的返回栈是空的就会报错,因为栈是空的了,navigateUp()则不会,还是停留在当前界面。④添加导航监听val listener: NavController.OnDestinationChangedListener =
object : OnDestinationChangedListener() {
fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
@Nullable arguments: Bundle?
) {
Log.d(TAG, "onDestinationChanged: id = ${destination.getId()}")
}
}
//添加监听
controller.addOnDestinationChangedListener(listener)
//移除监听
controller.removeOnDestinationChangedListener(listener)
⑤获取当前导航目的地使用getCurrentDestination可以获取当前导航的目的地//获取
val destination = controller.getCurrentDestination()
Log.d(TAG, "onCreate: NavigatorName = ${destination.getNavigatorName()}")
Log.d(TAG, "onCreate: id = ${destination.getId()}")
Log.d(TAG, "onCreate: Parent = ${destination.getParent()}")
⑥判断当前页面显示的Fragment是不是目标Fragment//可直接cv
fun <F : Fragment> isActiveFragment(fragmentClass: Class<F>): Boolean {
val navHostFragment = this.supportFragmentManager.fragments.first() as NavHostFragment
navHostFragment.childFragmentManager.fragments.forEach {
if (fragmentClass.isAssignableFrom(it.javaClass)) {
return true
}
}
return false
}
使用 Safe Args 确保类型安全 通过上面的NavController我们就可以实现fragment之间的跳转,但是Google建议使用 Safe Args Gradle 插件实现。这个插件可以生成简单的对象和构建器类,这些类就可以在目的地之间进行安全的导航和参数的传递啦。那么,该如何使用 Safe Args呢。①在项目最外面的build.gradle中加入//将其放在plugins{}之前
buildscript {
repositories {
google()
}
dependencies {
def nav_version = "2.4.2"
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version"
}
}
②在app、module下的build.gradle加入如需生成适用于 Java 模块或Java 和 Kotlin混合模块的Java语言代码apply plugin: "androidx.navigation.safeargs"
如需生成适用于 Kotlin独有的模块的Kotlin代码,请添加以下行:apply plugin: "androidx.navigation.safeargs.kotlin"
要使用Safe Args,必须在gradle.properties这个文件中加入 ini复制代码android.useAndroidX=true android.enableJetifier=true为了避免生成的类名和方法名过于复杂,需要对导航图进行调整,修改了action的id,因为自动生成的id太长了,会导致插件生成的类名和方法名也很长。//伪代码,请勿直接cv
<fragment
android:id="@+id/blankFragment"
android:name="com.taxze.jetpack.navigation.BlankFragment"
android:label="fragment_blank"
tools:layout="@layout/fragment_blank" >
<action
android:id="@+id/toSecond"
修改此处id
app:destination="@id/blankFragment2" />
</fragment>
然后就会为我们生成BlankFragment和BlankFragment2的类啦,然后就可以和上面一样使用,传递参数啦。var action = BlankFragment.actionJump("111")
也可以使用set方法对对应的参数进行赋值action.setParam("222")
通过Navigation模仿WeChat底部跳转通过Navigation实现开发中超级常用的底部跳转功能话不多说,先上效果图:①右键点击res目录,然后依次选择New→ Android Resource File。此时系统会显示 New Resource File对话框。File name输入你的文件夹名(这里设置为menu),Resource type选择Menu,然后我们就可以发下在res目录下多了一个menu目录,里面存放着我们底部跳转的item。②进入menu.xml的Design下填入四个Item,并分别设置id,title,icon③创建四个Fragment,并在Navigation中建立链接Fragment的布局十分简单,这里就放一个的代码。<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".HomeFragment">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="Home" />
</FrameLayout>
class HomeFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_home, container, false)
}
}
在nav_graph_main.xml建立连接,注意这里每个Fragment的id要和menu.xml中的id一一对应④在activity_main中使用BottomNavigationView建立底部跳转这里在drawable下创建一个selector_menu_text_color.xml用于区分当前的Item<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#818181" android:state_checked="false"/>
<item android:color="#45C01A" android:state_checked="true"/>
</selector>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<fragment
android:id="@+id/nav_host_fragment"
android:layout_width="0dp"
android:layout_height="0dp"
android:name="androidx.navigation.fragment.NavHostFragment"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph_main" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/nav_view"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:itemIconTint="@drawable/selector_menu_text_color"
app:labelVisibilityMode="labeled"
app:itemTextColor="@drawable/selector_menu_text_color"
app:menu="@menu/menu" />
</androidx.constraintlayout.widget.ConstraintLayout>
⑤在MainActivty中设置切换逻辑class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//去除标题栏
supportRequestWindowFeature(Window.FEATURE_NO_TITLE)
setContentView(R.layout.activity_main)
val navController: NavController = Navigation.findNavController(this, R.id.nav_host_fragment)
val navigationView = findViewById<BottomNavigationView>(R.id.nav_view)
NavigationUI.setupWithNavController(navigationView, navController)
}
}
这样我们就可以实现效果图中的效果啦,具体代码可以查看Git仓库尾述这篇文章已经很详细的讲了Jetpack Navigation的大部分用法,不过在看完文章后,你仍需多多实践,相信你很快就可以掌握Navigation啦因为我本人能力也有限,文章有不对的地方欢迎指出,有问题欢迎在评论区留言讨论~
江江说技术
Jetpack Compose 上新:瀑布流布局、下拉加载、DrawScope.drawText
不久前,Jetpack Compose 发布了 1.3.0 正式版。经过一年多的发展,再回头去看,Compose 终于带来了缺失已久的瀑布流布局以及DrawScope.drawText方法。本文就简单介绍一下。截止此文写作时,Jetpack Compose 的最新 stable 版本为 1.3.1,而查阅 Compose 与 Kotlin 的兼容性对应关系 文档可知,此版本对应的 Kotlin 版本为 1.7.10。如需尝试部分代码,请确保对应版本设置正确。BOMCompose Bill of Materials 是 Compose 最近带来的新东西,它能帮你指定 Compose 各种库的版本,确保各个 Compose 相关的库是项目兼容的(但并不引入对应的库)。具体来说,当你在 build.gradle 中引入 BOM 后// Import the Compose BOM
implementation platform('androidx.compose:compose-bom:2022.10.00')再引入其它 Compose 相关的库就不需要手动指定版本号了,它们会由 BOM 指定implementation "androidx.compose.ui:ui"
implementation "androidx.compose.material:material"
implementation "androidx.compose.ui:ui-tooling-preview"BOM 指定的版本都是稳定版,你也可以选择覆写部分版本到 alpha 版本,如下:// Override Material Design 3 library version with a pre-release version
implementation 'androidx.compose.material3:material3:1.1.0-alpha01'需要注意的是,这样可能会使部分其它的 Compose 库也升级为对应的 alpha 版本,以确保兼容性。BOM 和 库版本 的映射可以在 Quick start | Jetpack Compose | Android Developers 找到,目前的两个版本对应如下Library groupVersion in 2022.10.00Version in 2022.11.00androidx.compose.animation:animation1.3.01.3.1androidx.compose.animation:animation-core1.3.01.3.1androidx.compose.animation:animation-graphics1.3.01.3.1androidx.compose.foundation:foundation1.3.01.3.1androidx.compose.foundation:foundation-layout1.3.01.3.1androidx.compose.material:material1.3.01.3.1androidx.compose.material:material-icons-core1.3.01.3.1androidx.compose.material:material-icons-extended1.3.01.3.1androidx.compose.material:material-ripple1.3.01.3.1androidx.compose.material3:material31.0.01.0.1androidx.compose.material3:material3-window-size-class1.0.01.0.1androidx.compose.runtime:runtime1.3.01.3.1androidx.compose.runtime:runtime-livedata1.3.01.3.1androidx.compose.runtime:runtime-rxjava21.3.01.3.1androidx.compose.runtime:runtime-rxjava31.3.01.3.1androidx.compose.runtime:runtime-saveable1.3.01.3.1androidx.compose.ui:ui1.3.01.3.1androidx.compose.ui:ui-geometry1.3.01.3.1androidx.compose.ui:ui-graphics1.3.01.3.1androidx.compose.ui:ui-test1.3.01.3.1androidx.compose.ui:ui-test-junit41.3.01.3.1androidx.compose.ui:ui-test-manifest1.3.01.3.1androidx.compose.ui:ui-text1.3.01.3.1androidx.compose.ui:ui-text-google-fonts1.3.01.3.1androidx.compose.ui:ui-tooling1.3.01.3.1androidx.compose.ui:ui-tooling-data1.3.01.3.1androidx.compose.ui:ui-tooling-preview1.3.01.3.1androidx.compose.ui:ui-unit1.3.01.3.1androidx.compose.ui:ui-util1.3.01.3.1androidx.compose.ui:ui-viewbinding1.3.01.3.1瀑布流布局在 Jetpack Compose 1.0 正式版发布一年多后,瀑布流组件终于是姗姗来迟。目前,此组件的用法与 LazyGrid 保持了高度一致,而后者我已经在 Jetpack Compose LazyGrid使用全解 做过详细演示。此处不做过多赘述,示例如下:// 纵向,横向的对应 Horizontal...
LazyVerticalStaggeredGrid(
// columns 参数类似于 LazyVerticalGrid
columns = StaggeredGridCells.Fixed(2),
// 整体内边距
contentPadding = PaddingValues(8.dp, 8.dp),
// item 和 item 之间的纵向间距
verticalArrangement = Arrangement.spacedBy(4.dp),
// item 和 item 之间的横向间距
horizontalArrangement = Arrangement.spacedBy(8.dp)
){
itemsIndexed(pages, key = { _, p -> p.first }){ i, pair ->
...
}
}效果如下上面的完整代码可以在我的项目 FunnySaltyFish/JetpackComposeStudy: 本人 Jetpack Compose 主题文章所包含的示例,包括自定义布局、部分组件用法等 找到,也是乘此机会整理了下之前文章出现的例子,方便查看下拉刷新使用新增的 Modifier.pullRefresh 可以用于下拉刷新的实现。它的签名如下:fun Modifier.pullRefresh(
state: PullRefreshState,
enabled: Boolean = true
) 第一个参数用于存储下拉的进度,第二个代表是否启用。相关联的这个 State 自然也有对应的 remember 方法用于创建/**
* 创建一个被 remember 的[PullRefreshState
*
* 对 [refreshing] 的更改会更新 [PullRefreshState].
*
* @sample androidx.compose.material.samples.PullRefreshSample
*
* @param refreshing 布尔值,代表当前是否正在刷新
* @param onRefresh 刷新时的回调
* @param refreshThreshold 若超过此阈值,则放手后会触发 [onRefresh]
* @param refreshingOffset 刷新时指示器的底部位置
*/
@Composable
@ExperimentalMaterialApi
fun rememberPullRefreshState(
refreshing: Boolean,
onRefresh: () -> Unit,
refreshThreshold: Dp = PullRefreshDefaults.RefreshThreshold, // 80.dp
refreshingOffset: Dp = PullRefreshDefaults.RefreshingOffset, // 56.dp
): PullRefreshState综合使用,示例代码如下@OptIn(ExperimentalMaterialApi::class)
@Composable
fun SwipeToRefreshTest(
modifier: Modifier = Modifier
) {
val list = remember {
List(4){ "Item $it" }.toMutableStateList()
}
var refreshing by remember {
mutableStateOf(false)
}
// 用协程模拟一个耗时加载
val scope = rememberCoroutineScope()
val state = rememberPullRefreshState(refreshing = refreshing, onRefresh = {
scope.launch {
refreshing = true
delay(1000) // 模拟数据加载
list+="Item ${list.size+1}"
refreshing = false
}
})
Box(modifier = modifier
.fillMaxSize()
.pullRefresh(state)
){
LazyColumn(Modifier.fillMaxWidth()){
// ...
}
PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter))
}
}上面的代码并不难理解,用 modifier.pullRefresh 将下拉的相关数值存在 state 中,之后 PullRefreshIndicator 再使用就行了。二者用 Box 堆叠。实现原理这个控件的源代码也异常简单,最终是基于 nestedScrollConnection(嵌套滑动)实现的@ExperimentalMaterialApi
fun Modifier.pullRefresh(
onPull: (pullDelta: Float) -> Float,
onRelease: suspend (flingVelocity: Float) -> Unit,
enabled: Boolean = true
) = Modifier.nestedScroll(PullRefreshNestedScrollConnection(onPull, onRelease, enabled))
关于嵌套滑动,RugerMc 佬在很早前就写过文章,可以前往 嵌套滑动(NestedScroll) | 你好 Compose 阅读。这篇文章里也实现了下拉刷新,并给出了伸缩 ToolBar 的实现。如果你懒得跳过去,简而言之,通过 NestedScrollConnection ,我们可以在滑动开始前/后拿到当前的偏移量、速度等信息,按情况提前消费或放着不管他。针对下拉刷新的情况,我们主要干这两件事:当我们手指向下滑时,我们希望滑动手势首先交给子布局中的列表进行处理,如果列表已经滑到顶部说明此时滑动手势事件没有被消费,此时再交由父布局进行消费。父布局可以消费列表消费剩下的滑动手势事件(为加载动画增加偏移)。 当我们手指向上滑时,我们希望滑动手势首先被父布局消费(为加载动画减小偏移),如果加载动画本身仍未出现时,则不进行消费。然后将剩下的滑动手势交给子布局列表进行消费。实现起来并不难private class PullRefreshNestedScrollConnection(
private val onPull: (pullDelta: Float) -> Float,
private val onRelease: suspend (flingVelocity: Float) -> Unit,
private val enabled: Boolean
) : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset = when {
!enabled -> Offset.Zero
// 向上滑动,父布局先处理(收回偏移),走 onPull 回调,并根据处理结果返回被消费掉的 Offset
source == Drag && available.y < 0 -> Offset(0f, onPull(available.y)) // Swiping up
else -> Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset = when {
!enabled -> Offset.Zero
// 向下滑动,如果子布局处理完了还有剩余(拉到顶了还往下拉),就展示偏移
source == Drag && available.y > 0 -> Offset(0f, onPull(available.y)) // Pulling down
else -> Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
onRelease(available.y)
return Velocity.Zero
}
}DrawScope.drawText先前 Compose 的 Canvas 内部,如果需要画文字,就需要 canvas.nativeCanvas 先获取到原生的 android.graphics.Canvas 再调用对应方法。现在终于有 drawText 方法了。目前给出了两种共四个 API (分别对应 textLayoutResult 和 textMeasurer 两类参数)接下来让我们尝试使用一下,先试试 textMeasurer 参数的@OptIn(ExperimentalTextApi::class)
@Composable
fun DrawTextTest() {
val textMeasurer = rememberTextMeasurer(cacheSize = 8)
Canvas(modifier = Modifier.fillMaxSize()){
drawText(textMeasurer, "Hello World\n This is a simple text", style = TextStyle(color = Color.Black))
}
}效果很直接读名称可以知道,TextMeasurer 负责对文本进行测量,此类的注释大致如下:TextMeasurer负责测量整个文本,以便准备绘制。 应通过 androidx.compose.ui.rememberTextMeasurer 在 @Composable 中创建 TextMeasurer 实例,以便从 Composable 上下文中接收到默认值 文本布局是一项计算成本高昂的任务。因此,该类使用内部的 LRU 缓存保存 layout 输入和输出对,以优化使用相同输入参数时的重复调用。 尽管大多数输入参数对布局有直接影响,但部分可以在布局过程中被忽略,如颜色、笔刷和阴影,并在最后进行设置。将 TextMeasurer 与适当的 cacheSize 一起使用,在为不影响布局的属性(如颜色)设置动画时,应该会有显著的改进。 此外,如果需要呈现多个静态文本,您可以按cacheSize提供文本的数量,并缓存它们的layout以供重复调用。请注意,即使对输入参数(如fontSize、maxLines、文本中的一个附加字符)进行轻微更改,也会创建一组不同的输入参数。这将计算新的layout,并将一组新的输入和输出对放置在 LRU 缓存中。旧结果可能会被遗弃。 ……读读注释,能感觉到这个类存在的意义:测量文本并做适当的缓存。那么测量出来的结果自然就是 TextLayoutResult 了。事实上,textMeasurer 参数对应的函数内部就是帮忙测量了下,得到 textLayoutResult 再绘制。@ExperimentalTextApi
fun DrawScope.drawText(
textMeasurer: TextMeasurer,
text: String,
topLeft: Offset = Offset.Zero,
...
) {
val textLayoutResult = textMeasurer.measure(
text = AnnotatedString(text),
style = style,
...
)
withTransform({
translate(topLeft.x, topLeft.y)
clip(textLayoutResult)
}) {
textLayoutResult.multiParagraph.paint(drawContext.canvas)
}
}因此,对于复杂的绘制,我们可以先手动测量得到结果后,再根据需要做相关绘制,以实现花里胡哨的效果。Halifax 佬在Compose把Text组件玩出新高度 做了大量骚操作,我就不赘述了。参考Android Developers Blog: What’s new in Jetpack Compose (googleblog.com)其余链接文中已给出本文涉及到的代码见 FunnySaltyFish/JetpackComposeStudy: 本人 Jetpack Compose 主题文章所包含的示例,包括自定义布局、部分组件用法等
江江说技术
Jetpack Compose 输入法的弹出与隐藏
本文基于Jetpack Compose1.0.4Jetpack Compose 提供了 SoftwareKeyboardController用于控制软键盘的显示与隐藏,可在Composable中通过LocalSoftwareKeyboardController.current获取使用隐藏 val keyboard = LocalSoftwareKeyboardController.current
// ...
onClick = {
keyboard?.hide()
}
该操作会试图关闭软键盘,如果因为各种原因软键盘暂时无法关闭,则此操作会被忽略打开打开软键盘涉及到焦点的获取 // 以下代码均在 @Composable 函数中
// 焦点请求器
val focusRequester = remember {
FocusRequester()
}
// 为需要获取焦点的TextField添加此Modifier
BasicTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
// 请求焦点
Button(onClick = {
focusRequester.requestFocus()
keyboard?.show()
})
该操作会试图打开软键盘,如果因为各种原因软键盘暂时无法被打开,则此操作会被忽略
江江说技术
2022 · 让我带你Jetpack架构组件从入门到精通 — Lifecycle
前言不是标题党!作者会尽力把文章写的更容易理解也更充满深度,本文也随作者的持续学习,持续更新,有问题欢迎在评论区提出~介绍Jetpack的正式亮相是在2018年的Google I/O大会上,距今已经过去了四年,在当初的基础上又多了许多的新组件,每个组件都给开发者提供了一个标准, 能够帮助开发者减少样板代码并编写可在各种 Android 版本和设备中一致运行的 代码,让开发者能够集中精力编写重要的业务代码。但是,也有很多Android工程师四年过去了都停留在:知道、了解过、但没用过。也有很多朋友想要好好学习Jetpack,但是又无奈网上的知识点太过分散。本系列文章目标就是带大家完整的学习Jetpack组件,由浅入深。常用架构组件图本系列源码地址:github.com/taxze6/Jetp…现在就让我们进入Jetpack的世界,第一站就是Lifecycle生命周期管理组件!Lifecycle🌟官方文档:developer.android.google.cn/jetpack/and…🌟推荐阅读:深入理解AAC架构 - Lifecycle整体机制源码🌟推荐阅读:Lifecycle,看完这次就真的懂了我相信,在你第一次看见Lifecycle时,你会有下面四个疑问:Lifecycle到底是什么呢?它是用来干什么的?它有什么优势呢?它要怎么用呢?Lifecycle是什么:life:生命,(某事物)的存在期cycle:周期Lifecycle就是生命周期的意思。它是一个生命周期感知型组件,用来感知响应别的组件,例如感知Activity和Fragment的生命周期状态的变化。Lifecycle用来干什么:💡 Lifecycle能够自动感知其他组件的生命周期,能够降低组件之间的耦合性。在android开发中,生命周期这个词很重要,因为内存泄漏和它有很大很大的关系,内存泄漏的最主要原因是因为对象的内存无法被回收,短生命周期对象被长生命周期对象所引用时,短生命周期对象不使用时无法被回收…..情况下,就造成了内存泄漏。(此处留个坑,也许以后会写关于内存泄漏如何解决方面的知识,现在大家可以先看其他资料学习)大家此时心里会想,我要管理生命周期,但是android的activity不是自带了生命周期的函数吗,我在它里面修改不就行了,你要说有耦合,那全抽到Base类中不就好了。办法总是有的嘛~ 确实,在平时开发时,我们会封装一个BaseActivity,然后让所有的Activity都继承于它。BaseActivity一般会覆写onCreate、onStart 、onResume、onPause、onStop、onDestroy以及onRestart函数,并在其中加上日志信息,方便观察每个活动的各种状态。我们可以想到封装BaseActivity,那么官方肯定也会想到,于是就出现了Lifecycle。Lifecycle有什么优势呢?既然,我们自己封装BaseActivity就基本能够管理生命周期了,那么官方为何还要 推出Lifecycle这个组件呢?优势:Lifecycler实现了执行的逻辑和活动的分离,代码解耦并且增加了代码的额可读性Lifecycler在活动结束时自定移除监听,避免了声明周期的问题如何使用Lifecycle呢?先来了解一下lifecycle的核心类:Lifecycle Lifecycle是一个抽象类,实现子类为LifecycleRegistry kotlin复制代码class LifecycleRegistry extends Lifecycle{ ....... }lifecycleRegister lifecycle的唯一子类,用于在生命周期变化时触发自身状态和相关观察者的订阅回调逻辑LifecycleOwner 用于连接有生命周期的对象 kotlin复制代码public interface LifecycleOwner { @NonNull Lifecycle getLifecycle(); }LifecycleObserver Lifecycle观察者State(Lifecycle的抽象类内部) 表示当前生命周期所处状态 kotlin复制代码public enum State { DESTROYED, INITIALIZED, CREATED, STARTED, RESUMED; public boolean isAtLeast(@NonNull State state) { return compareTo(state) >= 0; } }Event(Lifecycle的抽象类内部) 当前生命周期改变对应的事件 kotlin复制代码public enum Event { ON_CREATE, ON_START, ON_RESUME, ON_PAUSE, ON_STOP, ON_DESTROY, ON_ANY; ...... }在了解了这些类和接口的用处之后,再去学习如何使用和源码分析就简单很多了。Lifecycle的使用:gradle的使用dependencies {
def lifecycle_version = "2.5.0-rc01"
def arch_version = "2.1.0"
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"
testImplementation "androidx.arch.core:core-testing:$arch_version"
} 💡 这里可以发现我们导入了lifecycle-service,lifecycle-process这两个组件,因为,在新版的SDK中,Activity/Fragment已经默认实现了LifecycleOwner接口,针对Service,Android 单独提供了LifeCycleService,而不是像Activity、Fragment默认实现了LifeCycleOwner。针对Application,Android 提供了ProcessLifeCycleOwner 用于监听整个应用程序的生命周期。现在就让我们用两种方式实现对Activity生命周期的监听吧LifecycleObserver我们需要创建一个MyLifecycleTest并继承于LifecycleObserver ,使用OnLifecycleEvent(此方法已过时),实现对生命周期的监听。import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
//OnLifecycleEvent已经过时了
class MyLifecycleTest : LifecycleObserver {
companion object{
private const val TAG = "MyLifecycleTest"
}
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun create() {
Log.d(TAG, "create: ")
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun start() {
Log.d(TAG, "start: ")
}
@OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
fun resume() {
Log.d(TAG, "resume: ")
}
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
fun pause() {
Log.d(TAG, "pause: ")
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun stop() {
Log.d(TAG, "stop: ")
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun destroy() {
Log.d(TAG, "destroy: ")
}
@OnLifecycleEvent(Lifecycle.Event.ON_ANY)
fun any() {
// Log.d(TAG, "any: ")
}
}
在MainActivity onCreate种调用addObserver方法新添加一个LifecycleObserver。import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lifecycle.addObserver(MyLifecycleTest())
}
}
使用DefaultLifecycleObserver使用它需要映入androidx.lifecycle:lifecycle-common-java8,如果项目中使用了java8或者开启java8特性,那么官方推荐使用DefaultLifecycleObserver替代的@OnLifecycleEvent 注解实现(因为现在注解已经被弃用了),包括预编译。我们创建一个MyDefaultLifecycleObserver继承于DefaultLifecycleObserverimport android.util.Log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
class MyDefaultLifecycleObserver : DefaultLifecycleObserver {
companion object {
private const val TAG = "MyDefaultLifecycleObserver"
}
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
Log.d(TAG, "onCreate: ")
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
Log.d(TAG, "onStart: ")
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
Log.d(TAG, "onResume: ")
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
Log.d(TAG, "onPause: ")
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
Log.d(TAG, "onStop: ")
}
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
Log.d(TAG, "onDestroy: ")
}
}
然后我们再创建一个MyApplication通过addObserver()将Observer添加到LifecycleRegistry。使用ProcessLifecycleOwner.get().lifecycle.addObserver(MyDefaultLifecycleObserver())import android.app.Application
import androidx.lifecycle.ProcessLifecycleOwner
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(MyDefaultLifecycleObserver())
}
}
在AndroidManifest.xml中添加下面这行运行代码,当应用程序进程启动时,这个被指定的子类在任何应用的组件之前被实例化。使用起来是很简单的,当然,这只是一个简单的例子,所以我们还需要探索一下Lifecycle的具体实现,并多多练习才能掌握好它。举例几个Lifecycle的使用场景:Retrofit配合Lifecycle管理Http生命周期 这里推荐阅读:mp.weixin.qq.com/s/omCnmMX3X…暂停和恢复动画绘制视频的暂停与播放Handler 的消息移除........这里留下几个问题:Lifecycle的创建方式有哪几种(有什么不同,推荐使用哪一种)?Lifecycle是如何进行生命周期同步的?Event事件和State状态是什么关系?Lifecycle的注册,派发,感知的过程是怎么样的?什么叫做嵌套事件?发生的时机是什么?Lifecycle是如何解决的?
江江说技术
学会使用 LiveData 和 ViewModel,我相信会让你在写业务时变得轻松
介绍在2017年,那时,观察者模式有效的简化了开发,但是诸如RxJava一类的库有一些太过复杂,学习成本太高,为此,LiveData出现了,一个专用于Android的,具备自主生命周期感知能力的,可观测的数据存储类。同时也出现了ViewModel这个组件,配合LiveData,更方便的实现MVVM模式中Model与View的分离。那么就让本文来带大家来学习LiveData与ViewModel的使用吧。LiveData和ViewModel的关系:本文的案例代码:github.com/taxze6/Jetp…LiveData参考资料:🌟官方文档:developer.android.google.cn/topic/libra…🌟LiveData postValue详解:www.cnblogs.com/button123/p…LiveData是一种可观察的数据存储器类(响应式编程,类似Vue)。与常规的可观察类不同,LiveData 具有生命周期感知能力。LiveData最重要的是它了解观察者的生命周期,如Activity和Fragment。因此,当LiveData发送变化时,UI会收到通知,然后UI可以使用新数据重新绘制自己。换句话说,LiveData可以很容易地使屏幕上发生的事情与数据保持同步(响应式编程的核心)使用 LiveData 具有以下优势:UI与数据状态匹配 LiveData 遵循观察者模式。当底层数据发生变化时,LiveData 会通知Observer对象。您可以整合代码以在这些 Observer对象中更新界面。这样一来,您无需在每次应用数据发生变化时更新界面,因为观察者会替您完成更新。提高代码的稳定性 代码稳定性在整个应用程序生命周期中增加: 活动停止时不会发生崩溃。如果应用程序组件处于非活动状态,则这些更改不受影响。因此,您在更新数据时无需担心应用程序组件的生命周期。对于后台堆栈中的活动,它不会接受任何LiveData事件 内存泄漏会减少,观察者会绑定到Lifecycle对象,并在其关联的生命周期遭到销毁后进行自我清理 取消订阅任何观察者时无需担心 如果由于配置更改(如设备旋转)而重新创建了 Activity 或 Fragment,它会立即接收最新的可用数据。不再需要手动处理生命周期 界面组件只是观察相关数据,不会停止或恢复观察。LiveData 将自动管理所有这些操作,因为它在观察时可以感知相关的生命周期状态变化。数据始终保持最新状态 如果生命周期变为非活跃状态,它会在再次变为活跃状态时接收最新的数据。例如,曾经在后台的 Activity 会在返回前台后立即接收最新的数据。共享资源 像单例模式一样,我们也可以扩展我们的LiveData对象来包装系统服务,以便它们可以在我们的应用程序中共享。一旦LiveData对象连接到系统服务,任何需要资源的观察者可以轻松地观看LiveData对象。在以下情况中,不要使用LiveData:您需要在信息上使用大量运算符,尽管LiveData提供了诸如转换之类的工具,但只有Map和switchMap可以帮助您您没有与信息的UI交互您有一次性的异步操作您不必将缓存的信息保存到UI中如何使用LiveData一般来说我们会在 ViewModel 中创建 Livedata 对象,保证app配置变更时,数据不会丢失,然后再 Activity/Fragment 的 onCreate 中注册 Livedata 监听(因为在 onStart 和 onResume 中进行监听可能会有冗余调用)基础使用流程:1.创建一个实例LiveData来保存某种类型的数据。一般在你创建的ViewModel类中完成class MainViewModel : ViewModel() {
var mycount: MutableLiveData<Int> = MutableLiveData()
}
2.在Activity或者Fragment中获取到ViewModel,通过ViewModel获取到对应的LiveDataclass MainActivity : AppCompatActivity() {
lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
...
/**记住绝对不可以直接去创建ViewModel实例
一定要通过ViewModelProvider(ViewModelStoreOwner)构造函数来获取。
因为每次旋转屏幕都会重新调用onCreate()方法,如果每次都创建新的实例的话就无法保存数据了。
用上述方法后,onCreate方法被再次调用,
它会返回一个与MainActivity相关联的预先存在的ViewModel,这就是保存数据的原因。*/
viewModel = ViewModelProvider(this@MainActivity,ViewModelProvider.
NewInstanceFactory()).get(MainViewModel::class.java)
}
}
3.给LiveData添加观察者监听,用来监听LiveData中的数据变化,在Observer的onChanged中使用监听回调数据/**
* 订阅 ViewModel,mycount是一个LiveData类型 可以观察
* */
viewModel.mycount.observe(this@MainActivity) {
countTv.text = viewModel.mycount.value.toString()
}
// LiveData onchange会自动感应生命周期 不需要手动
// viewModel.mycount.observe(this, object : Observer<Int> {
// override fun onChanged(t: Int?) {
//
// }
// })
进阶用法:Transformations.map现在有一个场景:我们通过网络请求,获得了一个User类数据(LiveData),但是,我们只想把User.name暴露给外部观察者,这样我们就可以通过Transformations.map来转化LiveData的数据类型,从而来实现上述场景。这个函数常用于对数据的封装。//实体类
data class User(var name: String)
...
//Transformations.map接收两个参数,第一个参数是用于转换的LiveData原始对象,第二个参数是转换函数。
private val userLiveData: MutableLiveData<User> = MutableLiveData()
val userNames: LiveData<String> = Transformations
.map(userLiveData) { user ->
user.name
}
Transformations.switchMapswitchMap是根据传入的LiveData的值,然后判断这个值,然后再去切换或者构建新的LiveData。比如我们有些数据需要依赖其他数据进行查询,就可以使用switchMap。 例如,有一个学生,他有两门课程的成绩,但是在UI组件中,我们一次只能显示一门课的成绩,在这个需要判断展示哪门课程成绩的需求下,我们就可以使用switchMap。data class Student
(var englishScore: Double, var mathScore: Double, val scoreTAG: Boolean)
.....
class SwitchMapViewModel:ViewModel {
var studentLiveData = MutableLiveData<Student>()
val transformationsLiveData = Transformations.switchMap(studentLiveData) {
if (it.scoreTAG) {
MutableLiveData(it.englishScore)
} else {
MutableLiveData(it.mathScore)
}
}
}
//使用时:
var student = Student()
person.englishScore = 88.2
person.mathScore = 91.3
//判断显示哪个成绩
person.condition = true
switchMapViewModel.conditionLiveData.postValue(person)
MediatorLiveDataMediatorLiveData继承于MutableLiveData,在MutableLiveData的基础上,增加了合并多个LiveData数据源的功能。其实就是通过addSource()这个方法去监听多个LiveData。 例如:现在有一个存在本地的dbLiveData,还有一个网络请求来的LiveData,我们需要讲上面两个结果结合之后展示给用户,第一种做法是我们在Activity中分别注册这两个LiveData的观察者,当数据发生变化时去更新UI,但是我们其实使用MediatorLiveData可以简化这个操作。class MediatorLiveDataViewModel : ViewModel() {
var liveDataA = MutableLiveData<String>()
var liveDataB = MutableLiveData<String>()
var mediatorLiveData = MediatorLiveData<String>()
init {
mediatorLiveData.addSource(liveDataA) {
Log.d("This is livedataA", it)
mediatorLiveData.postValue(it)
}
mediatorLiveData.addSource(liveDataB) {
Log.d("This is livedataB", it)
mediatorLiveData.postValue(it)
}
}
}
解释:如果是第一次接触到LiveData的朋友可能会发现,我们虽然一直在提LiveData,但是用的时候却是MutableLiveData,这两个有什么关系呢,既然都没怎么用LiveData,那么把标题直接改成MutableLiveData吧其实,LiveData与MutableLiveData在概念上是一模一样的。唯一的几个区别分别是:💡“此处引用:LiveData与MutableLiveData的区别文章中的段落” MutableLiveData的父类是LiveData LiveData在实体类里可以通知指定某个字段的数据更新 MutableLiveData则是完全是整个实体类或者数据类型变化后才通知.不会细节到某个字段。原理探究:对于LiveData的基础使用我们就讲到这里,想要探索LiveData原理的朋友可以从下面几个角度:LiveData的工作原理LiveData的observe方法源码分析LifecycleBoundObserver源码分析activeStateChanged源码分析(用于粘性事件)postValue和setValueconsiderNotify判断是否发送数据分析粘性事件的分析相信大家从以上几个角度去分析LiveData会有不小的收获💪ViewModel官方文档:developer.android.google.cn/topic/libra…官方简介ViewModel类旨在以注重生命周期的方式存储和管理界面相关的数据。ViewModel类让数据可在发生屏幕旋转等配置更改后继续留存。生命周期ViewModel的生命周期会比创建它的Activity、Fragment的生命周期都要长。所以ViewModel中的数据会一直存活在Activity/Fragment中。基础使用流程:1.构造数据对象自定义ViewModel类,继承ViewModel,然后在自定义的ViewModel类中添加需要的数据对象class MainViewModel : ViewModel() {
...
}
2.获取数据有两种常见的ViewModel创建方式,第一种是在activity或fragment种直接基于 ViewModelProvider 获取。第二种是通过ViewModelFactory 创建//第一种 ViewModelProvider直接获取
ViewModelProvider(this@MainActivity).get(MainViewModel::class.java)
//第二种 通过 ViewModelFactory 创建
class TestViewModelFactory(private val param: Int) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return TestViewModel(param) as T
}
}
ViewModelProvider(this@MainActivity,TestViewModelFactory(0)).get(TestViewModel::class.java)
使用ViewModel就是这么简单🚢ViewModel常见的使用场景使用ViewModel,在横竖屏切换后,Activity重建,数据仍可以保存同一个Activity下,Fragment之间的数据共享与LiveData配合实现代码的解耦ViewModel和onSaveInstanceState的区别我相信大家一定知道onSaveInstanceState,它也是用来保存UI状态的,你可以使用它保存你所想保存的东西,在Activity被杀死之前,它一般在onStop或者onPause之前触发。虽然ViewModel被设计为应用除了onSaveInstanceState的另一个选项,但是还是有一些明显的区别。由于资源限制,ViewModel无法在进程关闭后继续存在,但onSaveInstance包含执行此任务。ViewModel是存储数据的绝佳选择,而onSaveInstanceState bundles不是用于该目的的合适选项。ViewModel用于存储尽可能多的UI数据。因此,在配置更改期间不需要重新加载或重新生成该数据。另一方面,如果该进程被框架关闭,onSaveInstanceState应该存储回复UI状态所需的最少数据量。例如,可以将所有用户的数据存放在ViewModel中,而仅将用户的数据库ID存储在onSaveInstanceState中。android onSaveInstanceState调用时机详细总结onSaveInstanceState用法及源码分析ViewModel和ContextViewModel不应该包含对Activity,Fragment或context的引用,此外,ViewModel不应包含对UI控制器(如View)的引用,因为这将创建对Context的间接引用。当您旋转Activity被销毁的屏幕时,您有一个ViewModel包含对已销毁Activity的引用,这就是内存泄漏。因此,如果需要使用上下文,则必须使用应用程序上下文 (AndroidViewModel) 。LiveData和ViewModel的基本用法我们已经介绍完了,现在用几个例子带大家来更好的使用它们案例一:计数器 — 两个Activity共享一个ViewModel话不多说,先上效果图:虽然这个案例是比较简单的,但是我相信可以帮助你更快的熟悉LiveData和ViewModel想要实现效果图的话需要从下面几步来写(只讲解核心代码,具体代码请自己查看仓库):第一步:创建ViewModelimport androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel() {
private var _mycount: MutableLiveData<Int> = MutableLiveData()
//只暴露不可变的LiveData给外部
val mycount: LiveData<Int> get() = _mycount
init {
//初始化
_mycount.value = 0
}
/**
* mycount.value若为空就赋值为0,不为空则加一
* */
fun add() {
_mycount.value = _mycount.value?.plus(1)
}
/**
* mycount.value若为空就赋值为0,不为空则减一,可以为负数
* */
fun reduce() {
_mycount.value = _mycount.value?.minus(1)
}
/**
* 随机参数
* */
fun random() {
val random = (0..100).random()
_mycount.value = random
}
/**
* 清除数据
* */
fun clear() {
_mycount.value = 0
}
}
第二步:标记ViewModel的作用域因为,我们是两个Activity共享一个ViewModel,所以我们需要标记ViewModel的作用域import androidx.lifecycle.*
/**
* 用于标记viewmodel的作用域
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
annotation
class VMScope(val scopeName: String) {}
private val vMStores = HashMap<String, VMStore>()
fun LifecycleOwner.injectViewModel() {
//根据作用域创建商店
this::class.java.declaredFields.forEach { field ->
field.getAnnotation(VMScope::class.java)?.also { scope ->
val element = scope.scopeName
var store: VMStore
if (vMStores.keys.contains(element)) {
store = vMStores[element]!!
} else {
store = VMStore()
vMStores[element] = store
}
val clazz = field.type as Class<ViewModel>
val vm = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory()).get(clazz)
field.set(this, vm)
}
}
}
class VMStore : ViewModelStoreOwner {
private var vmStore: ViewModelStore? = null
override fun getViewModelStore(): ViewModelStore {
if (vmStore == null)
vmStore = ViewModelStore()
return vmStore!!
}
}
第三步:在Activity中使用(都是部分代码)class MainActivity : AppCompatActivity() {
@VMScope("count") //设置作用域
lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
injectViewModel()
initEvent()
}
private fun initEvent() {
val cardReduce: CardView = findViewById(R.id.card_reduce)
.....
cardReduce.setOnClickListener {
//调用自定义ViewModel中的方法
viewModel.reduce()
}
.....
/**
* 订阅 ViewModel,mycount是一个LiveData类型 可以观察
* */
viewModel.mycount.observe(this@MainActivity) {
countTv.text = viewModel.mycount.value.toString()
}
}
在第二个Activity中也是类似...
这样就可以实现效果图啦🏀案例二:同一个Activity下的两个Fragment共享一个ViewModel话不多说,先上效果图这个效果就很简单了,在同一个Activity下,有两个Fragment,这两个Fragment共享一个ViewModel这个案例主要是想带大家了解一下ViewModel在Fragment中的使用第一步:依旧是创建ViewModelclass BlankViewModel : ViewModel() {
private val numberLiveData = MutableLiveData<Int>()
private var i = 0
fun getLiveData(): LiveData<Int> {
return numberLiveData
}
fun addOne(){
i++
numberLiveData.value = i
}
}
非常简单的一个ViewModel第二步:在Fragment中使用//左Fragment
class LeftFragment : Fragment() {
private val viewModel:BlankViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_left, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
//对+1按钮监听
left_button.setOnClickListener {
viewModel.addOne()
}
activity?.let {it ->
viewModel.getLiveData().observe(it){
left_text.text = it.toString()
}
}
}
}
//右Fragment
class RightFragment : Fragment() {
private val viewModel: BlankViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_right, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
right_button.setOnClickListener {
viewModel.addOne()
}
activity?.let { it ->
viewModel.getLiveData().observe(it) {
right_text.text = it.toString()
}
}
}
}
这样,这个简单的案例就实现啦。尾述终于把LiveData和ViewModel的大致使用讲解了一遍,但仅仅这样还是不够的,你还需要在更多更多的实践中去熟悉,去深入学习...
江江说技术
Jetpack架构组件实战精华
介绍Jetpack架构组件,从Room数据库操作,到Navigation框架使用,DataBinding技巧,以及LiveData和ViewModel的高效运用。最后深入Lifecycle组件,助你从入门到精通,提升Android开发效率。
江江说技术
Jetpack实践指南
全面剖析ViewModel的数据保持机制,深入LiveData特别是CoroutineLiveData,探讨lifecycle-runtime-ktx API的高效使用,讲解Lifecycle开发技巧,并指导如何统一在AppCompatActivity中获取ViewModel和ViewBinding。
江江说技术
Jetpack Compose 实用指南
全面覆盖Jetpack Compose的关键技术点,从异步加载图片、输入法处理,到实际应用开发,如翻译APP。涵盖通用加载微件、列表项动画、数据持久化以及布局原理和自定义布局,旨在帮助开发者深入理解并高效使用Jetpack
江江说技术
Jetpack 成员 Paging3 数据库实践以及源码分析(一)
前言前几天 Google 更新了几个 Jetpack 新成员 Hilt、Paging 3、App Startup 等等,在之前的文章里面分了 App Startup 是什么、App Startup 为我们解决了什么问题,如果之前没有看过可以点击下面连接前往查看文章和代码。Jetpack 最新成员 AndroidX App Startup 实践以及原理分析AppStartup 代码地址:https://github.com/hi-dhl/AndroidX-Jetpack-Practice通过这篇文章你将学习到以下内容:Paging3 是什么?Paging3 在项目中的架构以及类的职能源码分析?如何在项目中正确使用 Paging3?数据映射(Data Mapper)是什么?Kotlin Flow 是什么?在分析之前我们先来了解一下本文实战项目中用到的技术:使用 Koin 作为依赖注入,可以看我之前写的篇文章:[译][2.4K Star] 放弃 Dagger 拥抱 Koin。使用 Composing builds 作为依赖库的版本管理,可以看我之前写篇文章:再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度。JDataBinding 是我基于 DataBinding 封装的库,可以看我之前写篇文章:项目中封装 Kotlin + Android Databinding。数据映射(Data Mapper): 将数据源的实体,转换为上层用到的 model,在项目中起到了很大重要,我看了很多项目的,这个概念很少被提及到,看国外的大牛的写的文章时,它们提及到了这个概念,后面会对它详细的分析。项目中用到了一些 Kotlin 技巧,可以查看我另外一篇文章:为数不多的人知道的 Kotlin 技巧以及 原理解析。还有 Paging 3、Room、Anko、Repository 设计模式、MVVM 架构等等。Paging3 是什么?Paging 是一个分页库,它可以帮助您从本地存储或通过网络加载显示数据。这种方法使你的 App 更有效地使用网络带宽和系统资源。Paging3 是使用 Kotlin 协程完全重写的库,经历了从 Paging1x 到 Paging2x 在到现在的 Paging3,深刻领悟到 Paging3 比 Paging1 和 Paging2 真的方便了很多。Google 推荐使用 Paging 作为 App 架构的一部分,它可以很方便的和 Jetpack 组件集成,Paging3 包含了以下功能:在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。可配置 RecyclerView 的 adapters,当用户滚动到加载数据的末尾时自动请求数据。支持 Kotlin 协程和 Flow, 以及 LiveData 和 RxJava。内置的错误处理支持,包括刷新和重试等功能。Paging3 的架构以及类的职能源码分析Google 推荐我们使用 Paging3 时,在应用程序的三层中操作,以及它们如何协同工作加载和显示分页数据,如下图所示:但是我个人认为应该在增加一层 Data Mapper (下面会有详细的介绍),如下图所示:数据映射(Data Mapper)将数据源的实体,转换为上层用到的 model,往往会被我们忽略掉,但是在项目中起到了很大重要,我看了很多项目的,这个概念很少被提及到,我只在国外的大牛的写的文章中,它们提及到了这个概念。关于数据映射(Data Mapper) 后面会单独写一篇文章,配合 Demo 去验证,这里只是简单提及一下。Data Mapper在一个快速开发的项目中,为了越快完成第一个版本交付,下意识的将数据源和 UI 绑定到一起,当业务逐渐增多,数据源变化了,上层也要一起变化,导致后期的重构工作量很大,核心的原因耦合性太强了。使用数据映射(Data Mapper)优点如下:数据源的更改不会影响上层的业务。糟糕的后端实现不会影响上层的业务 (想象一下,如果你被迫执行2个网络请求,因为后端不能在一个请求中提供你需要的所有信息,你会让这个问题影响你的整个代码吗)。Data Mapper 便于做单元测试,确保不会因为数据源的变化,而影响上层的业务。在本文案例项目 Paging3Simple 中会用到 Data Mapper 作为数据映射,在代码中有详细的注释。Repository layer在 Repository layer 中的主要使用 Paging3 组件中的 PagingSource,每个 PagingSource 对象定义一个数据源以及如何从该数据源查找数据, PagingSource 对象可以从任何一个数据源加载数据,包括网络数据和本地数据。PagingSource 是一个抽象类,其中有两个重要的方法 load 和 和 getRefreshKey,load 方法如下所示:abstract suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value>这是一个挂起函数,实现这个方法来触发异步加载,另外一个 getRefreshKey 方法open fun getRefreshKey(state: PagingState<Key, Value>): Key? = null该方法只在初始加载成功且加载页面的列表不为空的情况下被调用。在这一层中还有另外一个 Paging3 的组件 RemoteMediator,RemoteMediator 对象处理来自分层数据源的分页,例如具有本地数据库缓存的网络数据源。ViewModel layer在 ViewModel layer 层主要用到了 Paging3 的组件 Pager,Pager 是主要的入口页面,在其构造方法中接受 PagingConfig、initialKey、remoteMediator、pagingSourceFactory,代码如下所示:class Pager<Key : Any, Value : Any>
@JvmOverloads constructor(
config: PagingConfig,
initialKey: Key? = null,
@OptIn(ExperimentalPagingApi::class)
remoteMediator: RemoteMediator<Key, Value>? = null,
pagingSourceFactory: () -> PagingSource<Key, Value>
)今天这篇文章和项目主要用到了 PagingConfig 和 PagingSource,PagingSource 上面已经说过了,所以我们主要来分一下 PagingConfig。val pagingConfig = PagingConfig(
// 每页显示的数据的大小
pageSize = 60,
// 开启占位符
enablePlaceholders = true,
// 预刷新的距离,距离最后一个 item 多远时加载数据
prefetchDistance = 3,
/**
* 初始化加载数量,默认为 pageSize * 3
*
* internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
* val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER
*/
initialLoadSize = 60,
/**
* 一次应在内存中保存的最大数据
* 这个数字将会触发,滑动加载更多的数据
*/
maxSize = 200
)将 ViewModel 层连接到 UI 层用到了 Paging3 的组件 PagingData,PagingData 对象是分页数据的容器,它查询一个 PagingSource 对象并存储结果。Google 推荐我们将组件 Pager 放到 ViewModel layer,但是我更喜欢放到 Repository layer,详见下文。UI layer在 UI layer 中的主要到了 Paging3 的组件 PagingDataAdapter,PagingDataAdapter 是一个处理分页数据的可回收视图适配器,您可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器,本文中用到是 PagingDataAdapter。Paging 3 如何在项目中使用在 App 模块中的 build.gradle 文件中添加以下代码:dependencies {
def paging_version = "3.0.0-alpha01"
implementation "androidx.paging:paging-runtime:$paging_version"
}接下来我将按照上面说的每层去实现,首先我们先来看一下项目的结构。bean: 存放上层需要的 model,会和 RecyclerView 的 Adapter 绑定在一起。loca: 存放和本地数据库相关的操作。mapper: 数据映射,主要将数据源的实体 转成上层的 model。repository:主要来处理和数据源相关的操作(本地、网络、内存中缓存等等)。di: 和依赖注入相关。ui:数据的展示。数据库部分@Dao
interface PersonDao {
@Query("SELECT * FROM PersonEntity order by updateTime desc")
fun queryAllData(): PagingSource<Int, PersonEntity>
@Insert
fun insert(personEntity: List<PersonEntity>)
@Delete
fun delete(personEntity: PersonEntity)
}
关于 Dao 这里需要解释一下, queryAllData 方法返回了一个 PagingSource,后面会通过 Pager 转换成 flow<PagingData<Value>>。Repository 部分通过 Koin 注入 RepositoryFactory,通过 RepositoryFactory 管理相关的 Repository,RepositoryFactory 代码如下:class RepositoryFactory(val appDataBase: AppDataBase) {
// 传递 PagingConfig 和 Data Mapper
fun makeLocalRepository(): Repository =
PersonRepositoryImpl(appDataBase, pagingConfig,Person2PersonEntityMapper(), PersonEntity2PersonMapper())
val pagingConfig = PagingConfig(
// 每页显示的数据的大小
pageSize = 60,
// 开启占位符
enablePlaceholders = true,
// 预刷新的距离,距离最后一个 item 多远时加载数据
prefetchDistance = 3,
/**
* 初始化加载数量,默认为 pageSize * 3
*
* internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
* val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER
*/
initialLoadSize = 60,
/**
* 一次应在内存中保存的最大数据
* 这个数字将会触发,滑动加载更多的数据
*/
maxSize = 200
)
}这里主要是生成 PagingConfig 和 Data Mapper 然后传递给 PersonRepositoryImpl,我们来看一下 PersonRepositoryImpl 相关代码。class PersonRepositoryImpl(
val db: AppDataBase,
val pageConfig: PagingConfig,
val mapper2PersonEntity: Mapper<Person, PersonEntity>,
val mapper2Person: Mapper<PersonEntity, Person>
) : Repository {
private val mPersonDao by lazy { db.personDao() }
override fun postOfData(): Flow<PagingData<Person>> {
return Pager(pageConfig) {
// 加载数据库的数据
mPersonDao.queryAllData()
}.flow.map { pagingData ->
// 数据映射,数据库实体 PersonEntity ——> 上层用到的实体 Person
pagingData.map { mapper2Person.map(it) }
}
}
}Pager 是主要的入口页面,在其构造方法中接受 PagingConfig、pagingSourceFactory。pagingSourceFactory: () -> PagingSource<Key, Value>pagingSourceFactory 是一个 lambda 表达式,在 Kotlin 中可以直接用花括号表示,在花括号内,执行加载数据库的数据的请求。最后调用 flow 返回 Flow<PagingData<Value>>,然后通过 Flow 的 map 将数据库实体 PersonEntity 转换成上层用到的实体 Person。Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable,本文主要用到了 Flow 当中的 map 方法进行数据转换,简单实例如下所示:flow{
for (i in 1..4) {
emit(i)
}
}.map {
it * it
}到这里我们在回过去看,项目中 pagingData.map { mapper2Person.map(it) } 这行代码,其中 mapper2Person 是我们自己实现的 Data Mapper,代码如下所示:class PersonEntity2PersonMapper : Mapper<PersonEntity, Person> {
override fun map(input: PersonEntity): Person = Person(input.id, input.name, input.updateTime)
}数据库实体 PersonEntity 转换为 上层用到的实体 Person。UI 部分通过 koin 依赖注入 MainViewModel,并传递参数 Repository。class MainViewModel(val repository: Repository) : ViewModel() {
// 调用 Flow 的 asLiveData 方法转为 LiveData
val pageDataLiveData3: LiveData<PagingData<Person>> = repository.postOfData().asLiveData()
}在 Activity 当中注册 observe,并将数据绑定给 Adapter,如下所示:mMainViewModel.pageDataLiveData3.observe(this, Observer { data ->
mAdapter.submitData(lifecycle, data)
})知识扩充刚才我们调用了 asLiveData 方法转为 LiveData,其实还有两种方法(作为了解即可)。方法一在 LifeCycle 2.2.0 之前使用的方法,使用两个 LiveData,一个是可变的,一个是不可变的,如下所示:// 私有的 MutableLiveData 可变的,对内访问
private val _pageDataLiveData: MutableLiveData<Flow<PagingData<Person>>>
by lazy { MutableLiveData<Flow<PagingData<Person>>>() }
// 对外暴露不可变的 LiveData,只能查询
val pageDataLiveData: LiveData<Flow<PagingData<Person>>> = _pageDataLiveData
_pageDataLiveData.postValue(repository.postOfData())准备一私有的 MutableLiveData,只对内访问。对外暴露不可变的 LiveData。将值赋值给 _pageDataLiveData。方法二在 LifeCycle 2.2.0 之后,可以用更精简的方法来完成,使用 LiveData 协程构造方法 (coroutine builder)。val pageDataLiveData2 = liveData {
emit(repository.postOfData())
}liveData 协程构造方法提供了一个协程代码块,产生的是一个不可变的 LiveData,emit() 方法则用来更新 LiveData 的数据。最后添加左右滑动删除功能调用 recyclerview 封装好的 ItemTouchHelper 实现 左右滑动删除 item 功能。private fun initSwipeToDelete() {
/**
* 位于 [androidx.recyclerview.widget] 包下,已经封装好的控件
*/
ItemTouchHelper(object : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int =
makeMovementFlags(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT)
override fun onMove(
recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
(viewHolder as PersonViewHolder).mBinding.person?.let {
// 当 item 左滑 或者 右滑 的时候删除 item
mMainViewModel.remove(it)
}
}
}).attachToRecyclerView(rvList)
}
关于 Paging 加载本地数据到这里就结束了,我们将在下一篇文章讲解如何加载网络数据,最后上一个效果图。总结这篇文章主要介绍了以下内容:Paging3 是什么以及它的优点Paging 是一个分页库,它可以帮助您从本地存储或通过网络加载和显示数据。这种方法使你的 App 更有效地使用网络带宽和系统资源,而 Paging3 是使用 Kotlin 协程完全重写的库:在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。可配置 RecyclerView 的 adapters,当用户滚动到加载数据的末尾时自动请求数据。支持 Kotlin 协程和 Flow, 以及 LiveData 和 RxJava。内置的错误处理支持,包括刷新和重试功能。Paging3 的架构以及类的职能源码分析PagingSource:每个 PagingSource 对象定义一个数据源以及如何从该数据源查找数据。RemoteMediator:RemoteMediator 对象处理来自分层数据源的分页,例如具有本地数据库缓存的网络数据源。Pager:是主要的入口页面,在其构造方法中接受 PagingConfig、initialKey、remoteMediator、pagingSourceFactory。PagingDataAdapter:是一个处理分页数据的可回收视图适配器,您可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器。数据映射(Data Mapper)数据映射(Data Mapper)将数据源的实体,转换为上层用到的 model,往往会被我们忽略掉的,但是在项目中起到了很大重要,使用 数据映射(Data Mapper)优点如下:数据源的更改不会影响上层的业务。糟糕的后端实现不会影响上层的业务 (想象一下,如果你被迫执行2个网络请求,因为后端不能在一个请求中提供你需要的所有信息,你会让这个问题影响你的整个代码吗)。Data Mapper 便于做单元测试,确保不会因为数据源的变化,而影响上层的业务。在本文案例项目 Paging3Simple 中会用到 Data Mapper 作为数据映射。Kotlin FlowFlow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable,本文主要用到了 flow 当中的 map 方法进行数据转换,如下面的例子所示:flow{
for (i in 1..4) {
emit(i)
}
}.map {
it * it
}到这里我相信应该理解了,项目中 pagingData.map { mapper2Person.map(it) } 这行代码的意思了。GitHub 地址:https://github.com/hi-dhl/AndroidX-Jetpack-Practice正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。结语致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,可以关注我,如果这篇文章对你有帮助给个 star,正在努力写出更好的文章,一起来学习,期待与你一起成长。算法由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。数据结构: 数组、栈、队列、字符串、链表、树……算法: 查找算法、搜索算法、位运算、排序、数学、……
江江说技术
国外大厂面试题, 7 个 Android Lifecycle 重要的知识点
习惯性的每天都会打开 medium 看一下技术相关的内容,偶然看到一位大佬分享和 Android Lifecycle 相关的面试题,觉得非常的有价值。在 Android 开发中 Android Lifecycle 是非常重要的知识点。但是不幸的是,我发现很多新的 Android 开发对 Android Lifecycle 不是很了解,导致在开发中遇到很多奇怪的问题。分享这些面试题,不仅仅是为了通过面试,更是为了让 Android 开发者基础更加的扎实,防止在开发中遇到很多奇怪的问题。面试题一:Launch Fragment by Default问题:花几秒钟思考一下,下面的代码有什么问题。class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
supportFragmentManager
.beginTransaction()
.replace(R.id.container, MainFragment())
.commit()
}
}错误的回答没有使用 KTX 添加 Fragment应该使用 .add 而不是 .replace正确的回答如果 Activity 因意外被杀死并被恢复,会再次执行 onCreate() 方法,创建新的 Fragment,因此在栈中会存在 2 个 Fragment。在 Fragment 上的任何操作都可能被执行两次,这将会导致出现奇怪的问题。为了防止 Activity 因意外被杀死而恢复,导致添加新的 Fragment,所以我们可以使用 stateInstanceState == null 作为判断条件,防止添加新的 Fragment,因此我们可以将上面的代码简单修改一下。class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.replace(R.id.container, MainFragment())
.commit()
}
}
}面试题二:Create Fragment with Constructor问题:如果往 Fragment 构造函数中添加参数,花几秒钟思考一下,下面的代码会有什么问题?supportFragmentManager
.beginTransaction()
.replace(R.id.container, MainFragment())
.commit()
class MainFragment(private val repository: Repository): Fragment() {
} 错误的回答我们可以使用 .replace(R.id.container, MainFragment(repository)) 方法来代替它不会编译和运行正确的回答我们不应该直接用带参数的构造函数实例化任何 Fragment(),如果想使用带参数的构造函数实例化 Fragment(),可以使用 FragmentFactory 解决这个问题,这是在 AndriodX Fragment 1.2.0 中新增加的 API,详情可以查看另外一篇文章 Google 建议使用这些 Fragment 的新特性。如果不使用 AndriodX Fragment 库,默认情况下系统是不支持的,虽然上面的代码可以正常编译运行,但是在运行过程当中,因为配置更改,导致在销毁恢复的过程中会崩溃,错误信息如下所示。Caused by: java.lang.NoSuchMethodException: MainFragment.<init>这是因为系统需要在某些情况下重新初始化它,比如配置更改,例如设备被旋转时,导致 Fragment 被销毁,如果没有默认空的构造函数,系统不知道如何重新初始化 Fragment 实例。因此,我们总是需要确保实例化 Fragment 的时候有一个空的构造函数。面试题三:Instantiate ViewModel DirectlyViewModel 是 Jetpack 架构组件成员之一,花几秒钟思考一下,下面的代码会有什么问题?class MainActivity: AppCompatActivity() {
private val viewModel = MainViewModel()
}
class MainViewModel(): ViewModel {
}错误回答没有什么问题,可以正常编译运行我们还需要向它注入一些依赖项,例如 MainViewModel (repository)正确回答我们不应该直接实例化 ViewModel。 ViewModel 是 Jetpack 架构组件成员之一,意味着它可以在配置更改时存活,例如设备旋转时,它比 Activity 有更长的生命周期。如果我们在代码中直接实例化 ViewModel,尽管它可以工作,但它将会变成一个普通的 ViewModel,失去原本拥有的特性。因此,要实例化 ViewModel,建议使用 ViewModel KTX,代码如下所示。class MainActivity: AppCompatActivity() {
private val viewMode:MainViewModel by viewModels()
}by viewModels () 会在重新启动或从已杀死的进程中恢复时,实例化一个新的 ViewModel。如果有配置更改,例如设备被旋转时,它将检查 ViewModel 是否已经创建,而不重新创建它ViewModels() 会根据需要自动注入 SavedInstancestate (例如 Activity 中的 SavedInstanceState 和 Intent),如果我们有其他依赖是通过 Dagger Hilt 注入,它将与 ViewModel 一起使用下面的参数@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: Repository,
savedStateHandle: SavedStateHandle
) : ViewModel {
}面试题四:ViewModel as StateRestoration Solution问题:Jetpack 架构组件提供的 ViewModel 的作用是什么?class MainActivity: AppCompatActivity() {
private val viewMode:MainViewModel by viewModels()
// Some other Activity Code
}错误回答ViewModel 是用于状态恢复,例如当 Activity 被杀死并重新启动时,ViewModel 是用来帮助恢复到原始状态。正确回答ViewModel 实际上是 google 提供的 Jetpack 架构组件之一,它鼓励 Android 开发者使用 MVVM 设计模式。它还有其它重要的功能,例如设备旋转时,即使 Activity 和 Fragment 被销毁,它们各自的 ViewModel 仍会保留,Google 在 ViewModel 中提供了一个名为 savedStateHandle 参数,该参数用于保存和恢复数据。面试题五:LiveData as State Restoration Solution问题:Jetpack 架构组件提供的 LiveData 的作用是什么。// Declaring it
val liveDataA = MutableLiveData<String>()
// Trigger the value change
liveDataA.value = someValue错误回答:它的存在是为了确保数据在 Activity 的生命周期中存活。当 Activity 在进程销毁返回时,数据将会自动恢复。正确回答LiveData 本身不能在进程销毁中存活。它是一种特殊类型的数据,根据观察者(Activity 或 Fragment)的生命周期来控制其发出的值。ViewModel 在配置变更后仍然存在,所以 ViewModel 内部的 LiveData 也一样。这确保 LiveData 发射值按照下图控制。然而 LiveData 本身不能在进程销毁中存活,当内存不足时,Activity 被系统杀死,ViewModel 本身也会被销毁。为了解决这个问题,Google 在 ViewModel 中提供了一个名为 savedStateHandle 参数,该参数用于保存和恢复数据,以便数据在进程销毁后继续存在。@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: Repository,
savedStateHandle: SavedStateHandle
) : ViewModel {
// Some other ViewModel Code
}它是一种增强的机制,可以处理 Intent 和 SavedInstanceState,在以前的时候,这些都是由 Activity 单独处理的。为了确保 Livedata 保存下来,我们可以在 SavedStateHandle 中检查 Livedata 是否已经创建。val liveData = savedStateHandle.getLiveData<String>(KEY)类似地,这也适用于 stateFlow,它可以在进程销毁中存活下来。val stateFlow = savedStateHandle.getStateFlow<String>(KEY, 0)因此 LiveData 本身并不是用来恢复数据的。面试题六:When is The View Destroyed But Not the Instance问题:在 Activity 或 Fragment 通常会有一个视图。你能给我提供一个场景,实例的视图被破坏了,但实例(例如 Activity 或 Fragment)还存在。错误回答当配置发生变化时(例如设备旋转)当内存不足时,App 在后台运行,进程会杀死 App正确回答实际上 Activity 总是与其视图一起被销毁。因此,在 Activity 中没有 onDestroyView () 生命周期方法。只有在 Fragment 中有 onDestroyView () 生命周期方法。在大多数情况下 Fragment 和它的视图一起被销毁。但是通过 Fragment transaction 用一个 Fragment 替换另一个 Fragment 时,栈下面的 Fragment 仍然存在,但是它的视图被破坏了。当一个 Fragment 被另一个 Fragment 替换时,会调用 onDestroyView () 方法,但不会调用 onDestroy () 或 onDetect () 方法。正因为这种复杂性,在使用 Fragment 时,会遇到许多奇怪的问题。和 Fragment 相关的问题,将会在后面的文章中分享。问题七:Lifecycle Aware Coroutine在 App 中使用协程,如何确保它们的生命周期可感知。错误回答对于普通视图,只需在 Activity 或 Fragment 中使用 lifecycleScope,在 ViewModel 中使用 viewModelScope对于组合视图,需要使用 stateFlow 中的 collectAsState() 方法,因为当可组合函数不活动时,它不会收集正确回答对于普通视图,即使 lifecycleScope 是可用的,它在 Activity 或 Fragment 的整个生命周期中都处于活动状态。因为有时我们希望某些场景只在 onStart() 或 onResume() 之后处于活动状态。为此,我们需要在 lifecycleScope 中使用像 repeatOnLifecycle 这样的 API 提供额外的作用域。lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.stateFlowValue.collect {
// Do something
}
}
}它们的变体如下图所示。对于组合视图 collectAsState() 不会确保在组合函数处于活动状态时安全使用数据,它也不会停止继续发送 StateFlow,这会导致资源浪费。为了确保只在 Activity 或 Fragment 处于正确的生命周期时,例如在 onStart () 之后发出,我们需要使用 collectAsStateWithLifecycle () 和 stateFlow 中的 WhileSubscribed (...)。当我们在研究 collectAsStateWithLifecycle() 源码时,发现它也在使用 repeatOnLifecycle(…)。全文到这里就结束了,感谢你的阅读,坚持原创不易,欢迎在看、点赞、分享给身边的小伙伴,我会持续分享原创干货!!!
江江说技术
神奇宝贝 眼前一亮的 Jetpack + MVVM 极简实战
前言Jetpack 实战项目 PokemonGo(神奇宝贝)基于 MVVM 架构和 Repository 设计模式,PokemonGo 项目中用到的技术,都是之前写过的一系列文章里面涉及到的知识点:Paging3(network + db),Dagger-Hilt,App Startup,DataBinding,Room,Motionlayout,Kotlin Flow,Coil,JProgressView 等等。动态效果图 | 静态图Jetpack 实战项目 PokemonGo 包含了以下功能:自定义 RemoteMediator 实现 network + db 的混合使用 ( RemoteMediator 是 Paging3 当中重要成员 )使用 Data Mapper 分离数据源 和 UIKotlin Flow 结合 Retrofit2 + Room 的混合使用Kotlin Flow 与 LiveData 的使用使用 Coil 加载图片使用 ViewModel、LiveData、DataBinding 协同工作使用 Motionlayout 做动画App Startup 与 Hilt 的使用PokemonGo 涉及的技术:Gradle Versions Plugin:检查依赖库是否存在最新版本Kotlin + Coroutines + Flow:flow 是对 Kotlin 协程的扩展,让我们可以像运行同步代码一样运行异步代码JetPackPaging3(network + db):用到了 Paging3 中的 RemoteMediator 用来实现 network + dbDagger-Hilt (2.28-alpha):依赖注入框架App Startup:设置组件初始化顺序DataBinding:以声明方式将可观察数据绑定到界面上Room:在 SQLite 上提供了一个抽象层,流畅地访问 SQLite 数据库LiveData:在底层数据库更改时通知视图ViewModel:以注重生命周期的方式管理界面相关的数据Andriod KTX:编写更简洁、惯用的 Kotlin 代码项目架构MVVM 架构Repository 设计模式Data Mapper 数据映射Retrofit2 & OkHttp3:用于请求网路数据Coil:基于 Kotlin 开发的首个图片加载库material-components-android:模块化和可定制的材料设计 UI 组件Motionlayout :MotionLayout 是一种布局类型,可帮助您管理应用中的动画Timber: 日志打印JProgressView :一个小巧灵活可定制的进度条,支持图形:圆形、圆角矩形、矩形等等如果之前对这些技术没有接触过,或者只是听说,对阅读本文没有什么影响,本文会对这些技术结合着项目 PokemonGo 来分析,为了文章的简洁性,本文不会细究技术细节,因为每个技术都需要花好几篇文章才能分析清楚,我会在后续的文章去详细分析。如何检查依赖库最新版本在之前的文章 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度 分析过,到目前为止大概管理 Gradle 依赖提供了 4 种不同方法:手动管理 :在每个 module 中定义插件依赖库,每次升级依赖库时都需要手动更改(不建议使用)使用 ext 的方式管理插件依赖库 :这是 Google 推荐管理依赖的方法 Android官方文档Kotlin + buildSrc:自动补全和单击跳转,依赖更新时 将重新 构建整个项目Composing builds:自动补全和单击跳转,依赖更新时 不会重新 构建整个项目新版的 AndroidStudio 只支持 ext 的方式 和 手动方式管理 检查依赖库是否存在最新版本,不支持 buildSrc、gradle-wrapper 版本的检查。满足不了 PokemonGo 项目的需求,在 PokemonGo 项目中采用 buildSrc 方式去管理所有依赖库,因为 PokemonGo 项目采用单模块结构,而且支持 自动补全 和 单击跳转 很方便,所这里用到了 Gradle Versions Plugin 插件去检查依赖库的最新版本,检查结果如下所示:The following dependencies have later release versions:
- androidx.swiperefreshlayout:swiperefreshlayout [1.0.0 -> 1.1.0]
https://developer.android.com/jetpack/androidx
- com.squareup.okhttp3:logging-interceptor [3.9.0 -> 4.7.2]
https://square.github.io/okhttp/
- junit:junit [4.12 -> 4.13]
http://junit.org
- org.koin:koin-android [2.1.5 -> 2.1.6]
- org.koin:koin-androidx-viewmodel [2.1.5 -> 2.1.6]
- org.koin:koin-core [2.1.5 -> 2.1.6]
Gradle release-candidate updates:
- Gradle: [6.1.1 -> 6.5.1]
会列出所有需要更新的依赖库的最新版本,并且 Gradle Versions Plugin 比 AndroidStudio 所支持的更加全面:支持手动方式管理依赖库最新版本检查支持 ext 的方式管理依赖库最新版本检查支持 buildSrc 方式管理依赖库最新版本检查支持 gradle-wrapper 最新版本检查支持多模块的依赖库最新版本检查那么如何使用呢?只需要三步1.将 PokemonGo 项目根目录 checkVersions.gradle 文件拷贝到你的项目根目录下面2.在项目的根目录 build.gradle 文件夹内添加以下代码 apply from: './checkVersions.gradle'
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath "com.github.ben-manes:gradle-versions-plugin:0.28.0"
}
}3.添加完成之后,在根目录下执行以下命令。./gradlew dependencyUpdates 会在当前目录下生成 build/dependencyUpdates/report.txt 文件。MVVM 架构Jetpack 实战项目 PokemonGo 基于 MVVM 架构和 Repository 设计模式,如今几乎所有的 Android 开发者至少都听过 MVVM 架构,在谷歌 Android 团队宣布了 Jetpack 的视图模型之后,它已经成为了现代 Android 开发模式最流行的架构之一,如下图所示:MVVM 有助于将应用程序的业务逻辑与 UI 完全分开。 如果业务逻辑与 UI 逻辑之间的联系非常紧密,那么维护将很困难,由于很难重用业务逻辑,因此编写单元测试代码非常困难,一堆重复的代码和复杂的逻辑。Jetpack 的视图模型的 MVVM 架构由 View + DataBinding + ViewModel + Model 组成。DataBindingDataBinding(数据绑定)实际上是 XML 布局中的另一个视图结构层次,视图 (XML) 通过数据绑定层不断地与 ViewModel 交互。我们来看一个例子,首页上有个 RecyclerView 用来展示神奇宝贝数据(名字、图片、点击事件等等),每一个 item 对应一个 ViewHolder,来看一下 ViewHolder 的实现。class PokemonViewModel(view: View) : DataBindingViewHolder<PokemonListModel>(view) {
private val mBinding: RecycleItemPokemonBinding by viewHolderBinding(view)
override fun bindData(data: PokemonListModel, position: Int) {
mBinding.apply {
pokemon = data
executePendingBindings()
}
}
}正如你所看到的,由于使用了数据绑定,ViewHolder 里面的代码变的非常简单,可能这个例子不够明显,我们来看一个劲爆的,点击首页每一个 item 会跳转到详情页面,详情页面如下图所示:详情页面(DetailActivity)展示了神奇宝贝的详细数据,先查询数据库,如果没有找到,读取网路数据然后保存到数据库,由于使用了数据绑定,代码变得非常简单,如下所示:class DetailActivity : DataBindingAppCompatActivity() {
private val mBindingActivity: ActivityDetailsBinding by binding(R.layout.activity_details)
private val mViewModel: DetailViewModel by viewModels()
lateinit var mPokemonModel: PokemonListModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBindingActivity.apply {
mPokemonModel = requireNotNull(intent.getParcelableExtra(KEY_LIST_MODEL))
pokemonListModel = mPokemonModel
lifecycleOwner = this@DetailActivity
viewModel = mViewModel.apply {
fectchPokemonInfo(mPokemonModel.name)
.observe(this@DetailActivity, Observer {})
}
}
}
}正如你所见 DetailActivity 代码变得非常简单,如果以后我们想要改变网络的 URL、Model、获取或保存数据的方式等等,我们不需要改变 DetailActivity 中的任何代码。更多关于 DataBinding 的使用请参考我另外一个仓库 JDataBinding:目前已经封装了一系列的组件包含 DataBindingActivity、DataBindingAppCompatActivity、DataBindingFragmentActivity、DataBindingFragment、DataBindingDialog、DataBindingListAdapter、DataBindingViewHolder 等等。ViewModelViewModel 是 MVVM 架构中非常重要的设计,它在 activities 或 fragments 和业务逻辑中起到了非常重要的作用,它不依赖于 UI 组件,使得单元测试更加容易,ViewModel 以生命周期的方式管理界面相关的数据,直到 Activity 被销毁。LiveData 与 ViewModel 具有很好的协同作用,LiveData 持有从数据源获取到的数据,并且它可以被 DataBinding 组件观察,当 Activity 被销毁时,它将被取消订阅。而详情页面(DetailActivity) 代码之所以能这么简单得益于 ViewModel、LiveData、DataBinding 协同工作, 我们来看一下 ViewModel 代码。class DetailViewModel @ViewModelInject constructor(
val polemonRepository: Repository
) : ViewModel() {
private val _pokemon = MutableLiveData<PokemonInfoModel>()
val pokemon: LiveData<PokemonInfoModel> = _pokemon
@OptIn(ExperimentalCoroutinesApi::class)
fun fectchPokemonInfo(name: String) = liveData<PokemonInfoModel> {
polemonRepository.featchPokemonInfo(name)
.collectLatest {
_pokemon.postValue(it)
emit(it)
}
.......
// 省略部分代码,
}
}activity_details.xml 代码<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.hi.dhl.pokemon.ui.detail.DetailViewModel" />
</data>
......
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/weight"
android:text="@{viewModel.pokemon.getWeightString}"/>
......
</layout>这是获取神奇宝贝的详细信息,通过 DataBinding 以声明方式将数据(神奇宝贝的体重)绑定到界面上,更多使用参考项目中的代码。RepositoryRepository 设计模式是最流行、应用最广泛的设计模式之一,在 Repository 层获取网络数据,并将数据存储到数据库中,在这一层中有两个非常重要的成员 Paging3 库中的 RemoteMediator 和 Data Mappers。RemoteMediator在之前的文章 Jetpack 成员 Paging3 实践以及源码分析(一) 和 Jetpack 新成员 Paging3 网络实践及原理分析(二) 分别分析了使用 Paging3 访问 数据库 和 网络,但是遗漏了 RemoteMediator 类的使用,RemoteMediator 是 Paging3 当中一个非常重要的成员,用于实现 数据库 和 网络 访问,所以这里是对之前的文章一个补充。RemoteMediator 很重要,需要单独花一篇文章去分析,为了节省篇幅,在这里不会详细的去分析它,如果对 RemoteMediator 不太理解没有关系,我会在后续的文章里面详细的分析它。项目中网络访问用的是 Retrofit2 & OkHttp3 用来请求网络数据,使用 Room 作为数据库存储,将获得的数据保存到数据库中,Room 在 SQLite 上提供了一个抽象层,流畅地访问 SQLite 数据库,同时拥有了 SQLite 全部功能,在编译的时候进行错误检查。@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator(
val api: PokemonService,
val db: AppDataBase
) : RemoteMediator<Int, PokemonEntity>() {
val mPageKey = 0
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, PokemonEntity>
): MediatorResult {
try {
......
val pageKey = when (loadType) {
// 首次访问 或者调用 PagingDataAdapter.refresh()
LoadType.REFRESH -> null
// 在当前加载的数据集的开头加载数据时
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
// 在当前数据集末尾添加数据
LoadType.APPEND -> {
......
if (remoteKey == null || remoteKey.nextKey == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
remoteKey.nextKey
}
}
......
// 使用 Retrofit2 获取网络数据
val page = pageKey ?: 0
val result = api.fetchPokemonList(
state.config.pageSize,
page * state.config.pageSize
).results
.......
db.withTransaction {
if (loadType == LoadType.REFRESH) { // 当首次加载,或者下拉刷新的时候,清空当前数据 }
......
// 存储获取到的数据
remoteKeysDao.insertAll(entity)
pokemonDao.insertPokemon(item)
}
return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
} catch (e: IOException) {
return MediatorResult.Error(e)
} catch (e: HttpException) {
return MediatorResult.Error(e)
}
}
}注意:使用了 @OptIn(ExperimentalPagingApi::class) 需要在 App 模块 build.gradle 文件内添加以下代码。android {
kotlinOptions {
freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
}
}在 RemoteMediator 的实现类 PokemonRemoteMediator 中的核心部分是关于参数 LoadType 的判断。LoadType.REFRESH:首次访问 或者调用 PagingDataAdapter.refresh() 触发,这里不需要做任何操作,返回 null 就可以LoadType.PREPEND:在当前列表头部添加数据的时候时触发,实际在项目中基本很少会用到直接返回 MediatorResult.Success(endOfPaginationReached = true) ,参数 endOfPaginationReached 表示没有数据了不在加载LoadType.APPEND:下拉加载更多时触发,这里获取下一页的 key, 如果 key 不存在,表示已经没有更多数据,直接返回 MediatorResult.Success(endOfPaginationReached = true) 不会在进行网络和数据库的访问接下来的逻辑和之前请求网络数据的逻辑没有什么区别了,使用 Retrofit2 获取网络数据,然后使用 Room 将数据保存到数据库中。接下来聊一下 Repository 中另外一个重要的成员 Data Mapper,在项目中起到了非常的重要,在一个快速开发的项目中,为了越快完成第一个版本交付,下意识的将数据源和 UI 绑定到一起,当业务逐渐增多,数据源变化了,上层也要一起变化,导致后期的重构工作量很大,核心的原因耦合性太强了。Data Mapper(个人建议)Data Mapper 的意识非常重要,在项目中起到了非常的重要,关于 Data Mappers 在 Repository 中的重要性可以看一下国外大神写的这篇文章 The “Real” Repository Pattern in Android 在 Medium 上获得了 4.9K 的赞。使用 Data Mapper 分离数据源的 Model 和 页面显示的 Model,不要因为数据源的增加、修改或者删除,导致上层页面也要跟着一起修改,换句话说使用 Data Mapper 做一个中间转换,如下图所示,来源于网络:使用 Data Mapper(数据映射)优点如下:数据源的更改不会影响上层的业务糟糕的后端实现不会影响上层的业务 ( 想象一下,如果你被迫执行2个网络请求,因为后端不能在一个请求中提供你需要的所有信息,你会让这个问题影响你的整个代码吗? )Data Mapper 便于做单元测试,确保不会因为数据源的变化,而影响上层的业务如果在一个大型项目中直接使用 Data Mapper 会有适得其反的效果,所以需要结合设计模式来完善,这不在本文讨论范围之内,其实在这里我想表达是,不要因为快速实现某个功能,下意识的将数据源的 model 和 UI 绑定在一起。Data Mappe 实现方式有很多种,可以手动实现,也可以通过引入第三方框架,其中有名框架 modelmapper,在 PokemonGo 项目中是手动实现的。Kotlin Flow停止使用 RxJava,尝试一下 Flow,不仅简单而且功能很强大,Retrofit2 和 Room 也都提供了对应的支持。Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable,在 PokemonGo 项目中也用到了 Flow。override suspend fun featchPokemonInfo(name: String): Flow<PokemonInfoModel> {
return flow {
val pokemonDao = db.pokemonInfoDao()
var infoModel = pokemonDao.getPokemon(name)
// 查询数据库是否存在,如果不存在请求网络
if (infoModel == null) {
// 网络请求
val netWorkPokemonInfo = api.fetchPokemonInfo(name)
......
pokemonDao.insertPokemon(infoModel) // 插入更新数据库
}
val model = mapper2InfoModel.map(infoModel) // 数据转换
emit(model)
}.flowOn(Dispatchers.IO)
}在这里做了三件事:查询数据库是否存在,如果不存在请求网络请求网络获取数据,更新数据库将数据源的 Model 转换为页面显示的 Model依赖注入Hilt、Dagger、Koin 等等都是依赖注入库,使用依赖注入库有以下优点:依赖注入库会自动释放不再使用的对象,减少资源的过度使用。在配置 scopes 范围内,可重用依赖项和创建的实例,提高代码的可重用性,减少了很多模板代码。代码变得更具可读性。易于构建对象。编写低耦合代码,更容易测试。在 PokemonGo 项目中使用的是 Hilt,Hilt 是在 Dagger 基础上进行开发的,减少了在项目中进行手动依赖,Hilt 集成了 Jetpack 库和 Android 框架类,并删除了大部分模板代码,让开发者只需要关注如何进行绑定,同时 Hilt 也继承了 Dagger 优点,编译时正确性、运行时性能、并且得到了 Android Studio 的支持,来看一下 Hilt 与 Room 在一起使用的例子。@Module
@InstallIn(ApplicationComponent::class)
object RoomModule {
/**
* @Provides 常用于被 @Module 注解标记类的内部的方法,并提供依赖项对象。
* @Singleton 提供单例
*/
@Provides
@Singleton
fun provideAppDataBase(application: Application): AppDataBase {
return Room
.databaseBuilder(application, AppDataBase::class.java, "dhl.db")
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build()
}
@Singleton
@Provides
fun provideTasksRepository(
db: AppDataBase
): Repository {
return PokemonFactory.makePokemonRepository(db)
}
}这里需要用到 @Module 注解,使用 @Module 注解的普通类,在其内部提供 Room 的实例,更多使用可以查看 PokemonGo 项目。小巧灵活的进度条神奇宝贝详情页的进度条使用的是 JProgressView :一个小巧灵活可定制的进度条,支持图形:圆形、圆角矩形、矩形等等,效果如下图所示:起源于当时想用一个现成的库,但是在网上找了很多,没有一个合适自己的,要不大而全,要不作者好久没更新了,要不不兼容 DataBinding,于是乎就自己封装了一个小巧灵活的进度条,项目长期维护并持续更新,如果有更好的建议欢迎告知我,JProgressView 使用非常的简单,根据自己的需求去配置即可。<com.hi.dhl.jprogressview.JProgressView
android:layout_width="match_parent"
android:layout_height="18dp"
android:layout_below="@+id/exp"
android:translationZ="100dp"
app:maxProgressValue="@{viewModel.pokemon.maxExp}"
app:progressValue="@{viewModel.pokemon.exp}"
app:progress_animate_duration="@integer/progress_animate_duration"
app:progress_color="@color/color_progress_4"
app:progress_color_background="@color/color_progress_bg"
app:progress_paint_bg_width="@dimen/circle_stroke_width"
app:progress_paint_value_width="@dimen/circle_stroke_width"
app:progress_text_color="@android:color/black"
app:progress_text_size="@dimen/text_size_12sp"
app:progress_type="@integer/porgress_tpye_round_rect" />名称值类型默认值备注progress_typeinteger圆形:1矩形:0;矩形:0;矩形:0progress_animate_durationinteger2000动画运行时间progress_colorcolorColor.GRAY当前进度颜色progress_color_backgroundcolorColor.GRAY进度条背景颜色progress_paint_bg_widthdimen10进度条背景画笔的宽度progress_paint_value_widthdimen10当前进度画笔的宽度progress_text_colorcolorColor.BLUE进度条上的文字的颜色progress_text_sizedimensp2Px(20f)进度条上的文字的大小progress_text_visibleboolean默认不显示:false是否显示文字progress_valueinteger0当前进度progress_value_maxinteger100当前进度条的最大值更多关于进度条的使用,查看 JProgressView 仓库,全文到这里就结束了,为了节省篇幅,很多在之前系列文章里面分析过的,这里不在详细分析了,更多技术细节会在后续的系列文章中分析。结语致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,文章中有什么没有写明白的地方,或者有什么更好的建议欢迎留言,欢迎一起来学习,在技术的道路上一起前进。