Jetpack Compose 实用指南 - .9图与launchEffect与添加/编辑/复用原生AndroidView

2,807 阅读6分钟

此文转自我CSDN同名文章,点击查看

开头感谢我的粉丝头子 (这位同学拒绝提供博客地址)提供的水群话题 不多说废话,直接进入正题

Compose中显示.9图

现在有.9图如下—— .9图示意

总所周知,compose中加载图片用Image() image 的第一个参数:painter, 大家一般用自带的,或者官方推荐的coil

当前文章使用的两者API版本分别为compose 1.2.0-alpha02coil-compose:1.4.0

于是我们加载图片试试

  • 前者使用painterResource(),会报错——

java.lang.IllegalArgumentException: Only VectorDrawables and rasterized asset types are supported ex. PNG, JPG

简直是滑天下之大稽!我这个图是不是png我自己心里没点数?

  • 后者使用rememberImagePainter()倒是能够加载图片出来,来,我们一步步试试看。

    • 首先,很自然的一个想法:coil库支持直接填入类型为int的ResIdDrawable,我们直接——
Image(
    rememberImagePainter(R.drawable.xxx), 
    "testFor.9",
    Modifier.size(300.dp, 50.dp),
    contentScale = ContentScale.FillBounds //拉伸图片以填充
   )

得到图片如下——

典型的被拉伸

显然是玩崩了,这个.9图片失去了它的特性,变成了普通的PNG图片

然后我们想到,在传统ImageView直接使用Drawable其实也会被警告并推荐使用ContextCompat.getDrawable(context , @ResId resId)方法。 那我们这里试试看——

Image(
        rememberImagePainter(ContextCompat.getDrawable(context R.drawable.xxx)), 
        "testFor.9",
        Modifier.size(300.dp, 50.dp),//设置一个与图片明显不符的宽高测试
        contentScale = ContentScale.FillBounds //拉伸图片以填充
      )

得到图片如下——

正常显示的.9png

wow~ awesome!

但显然大部分人会蹉跎这么一个小小的操作,根本想不到这里去,并认为compose不支持.9图,起码目前不支持

这时候大家就会想到——我添加一个AndroidView,里面展示.9图,岂不美哉!

添加AndroidView

语法很简单,直接看代码——

AndroidView({ it:Context -> //传入了一个context供你初始化该view
//AndroidView中第一个参数默认返回一个传统View
//在这里进行view的初始化,它只会被调用一次,且保证在UI线程上被调用
ImageView(it).apply {
    this.setImageDrawable(ContextCompat.getDrawable(it, R.drawable.left_bg))
    this.scaleType = ImageView.ScaleType.FIT_XY
                     }
            }, 
            Modifier.size(300.dp, 50.dp)){ it:ImageView ->
            //这是一个可选参数,它是最后一个参数,所以可以用kotlin的语法糖挪出来   
            //此lambda在view每次recompose过程中被调用,也运行在UI线程,
            //方便你根据数据进行一些view状态的更新        
            }

好,我会了,然后呢

然后群友提出一个有意思的问题:这个传统view啊,我想给其他模块使用

听起来很简单,好说好说——

//这个参数用mutableState和放在这里只是为了方便演示
//实际上应该它的位置应该在ViewModel或者其他地方
//也需要按需使用LiveData或者其他数据结构
var dotNineImage: ImageView? by remember { mutableStateOf(null) }
    
AndroidView(
        {
            ImageView(it).apply {
            this.setImageDrawable(ContextCompat.getDrawable(it, R.drawable.left_bg))
                this.scaleType = ImageView.ScaleType.FIT_XY
                dotNineImage = this //初始化时把当前View保存
            }
        },
        Modifier.size(300.dp, 50.dp)
)

按理来说这个dotNineImage对象就能被挪作他用了?

肯定不行啊!

你要是直接拿此view添加到其他地方,会得到报错如下——

java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.

查看堆栈,出现在addView()的时候。

简单说就是:孩子只能有一个父亲!!

如果要给孩子换个家庭,那他得先和已有的parent断绝关系!!

这个报错已经很明显了,并且提示了你应该怎么做,但切记在hide此view后再将其移除

否则会在recompose过程由于尝试设置其属性出现类似以下错误的报错(具体报错可能因为各种原因略有不同,但肯定都是同一原因引发的)——

java.lang.NullPointerException: Attempt to invoke virtual method 'void android.view.View.setMinimumWidth(int)' on a null object reference

关键代码如下——

var curImageView: ImageView? by remember { mutableStateOf(null) }
var show by remember { mutableStateOf(true) }

if (show)
    AndroidView({
                    if (curImageView == null)
                        ImageView(it).apply {
                            this.setImageDrawable(ContextCompat.getDrawable(it, R.drawable.left_bg))
                            this.scaleType = ImageView.ScaleType.FIT_XY
                            curImageView = this
                        }
                    else curImageView!!
                }, Modifier
        .size(300.dp, 50.dp))
LaunchedEffect(show) {
    if (!show) {
        (curImageView?.parent as? ViewGroup)?.removeView(curImageView)
    }
}

//来个button简单切换show的值试试
Button({show=!show}){
    Text("显隐AndroidView")
}

如上代码,关键就是这个launchEffect——

它在此处被设定为观察show

被观察的值一旦改变,

它一定会在compose过程中紧跟着执行它的block中描述的内容。


显而易见,

show的值为false,当compose过程到launchEffect所在位置时,

AndroidView已经隐藏了,

show的值下一次改变之前,该AndroidView因为已经从compose树中移除,不会再参与后续的compose过程

所以它此时可以被安全移除

更妙的是:当show值改变,下一次尝试显示该AndroidView时,

只要那时候——它没有变成别人的孩子已经被断绝亲子关系

它——还会被自动执行addView()操作添加回来

基于这段逻辑的原理稍作修改,它已经具备了出现在其他地方,又从其他地方再回来的基础。

——提出这个问题的童鞋终于可以“理论上实现将视频转到小窗播放且无需操心进度、加载、加载时的空白等等一系列问题了。

错误典型

然后本来这个文章按理来说该结尾了,但童鞋说不对不对,他说他最终还是另开了一个view解决问题—— 在这里插入图片描述

显然,之所以多用了一个view,是因为他没get到launchEffect的真谛

敲黑板

两个共用同一AndroidView的Composable绝对不能用同一个key去控制两者的显隐,否则必然会出现图中童鞋出现的问题!

两个共用同一AndroidView的Composable绝对不能用同一个key去控制两者的显隐,否则必然会出现图中童鞋出现的问题!

两个共用同一AndroidView的Composable绝对不能用同一个key去控制两者的显隐,否则必然会出现图中童鞋出现的问题!


用本文的例子说人话就是:

当我想在另外的地方使用这个ImageView时,我必须用launchEffect,确保这一处的ImageView消失后,再让该ImageView显示在另一处。

上代码自己领悟吧——

var curImageView: ImageView? by remember { mutableStateOf(null) }
var show by remember { mutableStateOf(true) }
var showSecond by remember { mutableStateOf(false) }
Box {
    LazyColumn {
        item {
            if (show)
                AndroidView(
                        {
                            if (curImageView == null)
                                ImageView(it).apply {
                                    this.setImageDrawable(ContextCompat.getDrawable(it, R.drawable.left_bg))
                                    this.scaleType = ImageView.ScaleType.FIT_XY
                                    curImageView = this
                                }
                            else curImageView!!
                        },
                        Modifier.size(300.dp, 50.dp)
                )
            LaunchedEffect(show) {
                if (!show) {
                    //看这里,当show == false 时,让showSecond = true
                    //launchEffect写在AndroidView后面,能确保androidView被show控制着消失后,让它在另一处出现
                    showSecond = true
                }
            }
        }
    }
    Box {
        if (showSecond)
            AndroidView({ curImageView!! },
                    Modifier.size(360.dp, 70.dp)
            )
        LaunchedEffect(showSecond) {
            if (!showSecond) {
                show = true
            }
        }
    }
}
Button({
           if (show)
               show = !show
           else
               showSecond = !showSecond
       }) {
    Text("显隐")
}

这里用lazyColumn在写法错误的情况下必出问题

把LazyColumn替换成Column,或者将两个AndroidView放在同一Column中,即便用一个key同时控制两个view,问题也可能不会出现

但如果你没认识到问题的本质,常在河边走,必定会湿鞋!

问题的本质

本质1:

必须保证一个view已经在一处被移除后,才被添加到另一处——这是Android要求的。

本质2:

通过写在View后面,且观察view的控制keyLaunchEffect去保证此view已经在compose层次结构中消失,

此时这个view才可以被安全地从整个compose代码中移除,然后添加到其他地方

(或者从compose代码的这一处消失,然后出现在compose代码的另一处