BrickUI,基于Android View体系撸一个声明式UI框架

主流移动端新框架都在搞声明式UI

现代的移动应用UI开发框架,如Compose,Flutter,iOS的SwiftUI等都不约而同的使用了声明式UI的编程范式,这一类框架往往通过状态来驱动UI变化,UI代码主要描述了布局信息,以及控件与状态数据之间的关系。解决了以上提到的传统UI开发的一系列痛点。

image.png

这些声明式UI框架也都有一个特点,就是UI更新都是通过响应状态的变化来实现

image.png

那我们Android原生怎么写UI?

对于Android原生的UI开发,大家肯定对findVIewById不陌生。

对于命令式UI的编程范式,写一个UI的流程一般是:

  • 在xml上写layout,drawable等
  • 在代码中通过findVIewById或其他框架把View找出来
  • 根据交互稿,写UI控制代码来沟通UI和业务代码,UI控制代码主要指的是调用setText,setColor,setImage这一类设置View样式的方法。

image.png

Android命令式UI的一些痛点:

  • 冗余:需要编写冗余代码来处理UI变化
  • 耦合:有交互关联的View常常存在耦合,相互影响
  • xml:layout,drawable,代码之间的切换,对开发来说造成一定打断

DataBinding

MVVM架构兴起的时代,Jetpack推出了lifecycle组件和DataBinding来完善UI开发的架构。

DataBinding确实解决了传统命令式UI的一些痛点,但又引出了新的问题,导致仍然并不受开发者所待见:

  • xml上写代码逻辑
  • 对编译性能的影响
  • 常常导致编译异常的log难以定位

能不能基于View体系撸一个声明式UI框架

就Android而言,不管Compose还是Flutter,都基于自有的渲染体系,尽管在宣传上都声称其具有和原生一样的性能表现,但就现阶段而言,我在实际项目或官方demo中体验,在一些高频渲染场景,如不抬手的滚动长列表时,卡顿或顿挫感仍然是肉眼可见的,在低端机器,旧机器上的表现尤甚。

此外,当需要接入成熟项目时,往往需要采用混合开发的接入模式,由于这些框架的体系独立于Android的原生View体系之外,往往需要引入一些“”,这就导致了开发、性能和维护成本的增长。

对于Compose,目前的生态也是有待完善的,特别是Compose的热度目前可能还达不到Flutter的水平,只能说未来可期。

鉴于以上考虑到的一些痛点,我就考虑,能否基于Android原生View体系撸一个声明式UI框架?

生态位

我们知道,无论是Flutter还是Compose,其关键卖点在于跨平台。BrickUI不涉及跨平台,它旨在提升原生UI开发者的开发效率,对标的是DataBinding,ViewBinding这一类传统的原生UI开发框架,期望它的生态位是:

  • 比原生开发有更高的开发效率
  • 能真正具备和原生相仿的性能表现
  • 不会像Compose/Flutter那样,引入过多的混合开发的接入成本

BrickUI诞生了,希望有了它,你可以和xml说一句:

“xml吗?别再给我打电话了,我怕BrickUI误会”

目前已接入BrickUI到实际项目中,去实现社区广场列表这种复杂混排长列表。

image.png

项目地址:github.com/robin8yeung…

来2个example来体验下吧

1、经典example-计数器

image.png

首先实现TopBar

fun ViewGroup.TopBar() {
    // 行布局,通过dp扩展属性快速使用dp单位
    row(
        MATCH_PARENT, 44.dp,
    ) {
        imageView(
            44.dp, 44.dp,
            // 通过drawable扩展属性快速使用Drawable资源
            drawable = R.drawable.ic_back_dark.drawable,
            scaleType = ImageView.ScaleType.CENTER_INSIDE,
        ) {
            (context as? Activity)?.onBackPressed()
        }
        // 在线性布局中填充剩余区域
        expand()
    }
}

封装带阴影的按钮

原生UI开发时,并没有类似前端css中这么详细的定义box-shadow的方式,而BrickUI则提供了类似的方式来定义外阴影

private fun ViewGroup.button(
    text: String,
    onClick: View.OnClickListener
) = shadowBox(
    // shadowBox允许定义带圆角的外阴影,还可以定义阴影的blur,颜色,和x,y的offset
    radius = 14.dp, shadow = Shadow(blur = 8.dp),
    onClick = onClick
) {
    textView(
        width = 100.dp, height = 100.dp,
        text = text,
        textSize = 28.dp,
        textStyle = Typeface.BOLD,
        gravity = Gravity.CENTER
    )
}

把控件组装起来,放到Activity中

fun Context.CounterPage() = column(
    MATCH_PARENT, MATCH_PARENT,
    // 适配状态栏
    fitsSystemWindows = true,
) {

    // 定义计数值,即通过扩展属性live快速定义LiveData<Int>,初始值为0
    val count = 0.live

    TopBar()
    divider(background = ColorDrawable(Color.GRAY))
    row(
        MATCH_PARENT, 160.dp,
        gravity = Gravity.CENTER,
        fitsSystemWindows = true,
    ) {
        // 减号按钮
        button("-") {
            count.value = count.value - 1
        }
        // 计数数值,可绑定LiveData的控件由 brick-ui-live 提供
        liveText(
            84.dp,
            style = R.style.BigNumber,
            // 计数值显示
            text = count.map { it.toString() }
            // 根据计数值是偶数显示文字为红色,基数显示为黑色
            textColor = count.map { if (it % 2 == 0) Color.RED else Color.BLACK },
            // 借鉴了Flutter的EdgeInsets定义
            padding = EdgeInsets.all(12.dp),
        )
        // 加号按钮
        button("+") {
            count.value = count.value + 1
        }
    }
    expend()
}

以上的CounterPage函数,返回的即为一个View,可以通过Activity的setContentView来直接展示。

class CounterActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(CounterPage())
    }
}

至此,完成了计数器的实现。对于更复杂的业务则推荐使用ViewModel,如果ViewModel是绑定Activity的,那通过UI内的任意一个Context强转为Activity,均可拿到其所绑定的ViewModel,从而方便去实现UI和ViewModel的交互。如果不喜欢这种强转的方式,也可以借助其他依赖注入框架来获得期望的ViewModel对象。

需要特别指出的是:目前阶段只提供了基于LiveData的控件响应更新机制,下阶段会考虑通过Flow来控制控件的响应更新,为开发者提供便利。

2、社区动态的九宫格example

限于篇幅,这里仅举例一条社区动态的UI实现,且不包含数据交互。这里主要展示BrickUI中列表的实现和Drawable的快速实现。

前排叠甲:以下牛杂师傅的图片均来源于网络

image.png

头像实现

头像周围的环形描边,BrickUI可以直接通过代码去创建,而不需要另外去写Drawable的xml。

private fun ViewGroup.Photo() {
    // BrickUI对Glide加载单独封装了一个lib,避免耦合
    glideImage(
        urlOrPath = "头像图片实际url",
        // 图片圆形裁剪
        request0ptions = RequestOptions().transform(CircleCrop())
    ) {
        ImageView(
            60.dp, 60.dp,
            scaleType = ImageView.ScaleType.CENTER_CROP,
            padding = EdgeInsets.all(4dp),
            // 快速构造圆形描边Drawable,避免为Drawble又跑去写一个xml
            background = ovalDrawable(
                strokecolor = Color.parseColor("#331088"),
                strockWidth = 2.dp,
            ),
        )
    }
}

图片九宫格实现

BrickUI提供了简单快速构建RecyclerView的方法,可以快速实现九宫格UI。本例子仅针对静态列表,如果需要响应动态数据,可以在BrickUI的demo中去看实现举例。

private fun ViewGroup.ImageList() {
    // 构造RecyclerView
    simpleStatelessRecyclerView(
        WRAP_CONTENT, WRAP_CONTENT,
        //列表数据
        data = listof(
            "图片1 url",
            "图片2 url",
            "图片3 url",
            "图片4 url",
            "图片5 url",
        ),
        // 设置GridLayoutManager,每行3列
        layoutManager = GridLayoutManager (context, 3),
        padding = EdgeInsets.symmetric(vertical = 8.dp),
    ) { data, index ->
        // 为每一个position的图片url创建UI
        glideImage(data[index]) { 
           imageView(
               88.dp, 88.dp,
               scaleType = ImageView.ScaleType.CENTER_CROP,
               padding = EdgeInsets.all(2.dp),
           ) {
               // 此处回调点击事件
           }
        }
    }
}

组装控件到页面中,完成社区动态展示

fun Context.Moments() = row(
    MATCH_PARENT,
    gravity = Gravity.TOP,
    padding = EdgeInsets.symmetric(horizontal = 16.dp)
) {

    Photo()
    // 填满右侧空间
    expand(margins = EdgeInsets.only(start = 8.dp)) { 
        Contents()
    }
}

// 右侧内容
private fun ViewGroup.Contents () = column(width: 0) {
    textView(
        text = "刻睛",
        textColor = Color.parseColor("#331088"),
        textSize = 18.dp,
        textStyle = Typeface.BOLD,
    )    
    textView(
        text = "耽误太多时间,事情可就做不完了!",
        textSize = 18.dp,
        padding = EdgeInsets.only(top = 4.dp),
    )
    ImageList()
}

至此,单条动态的UI就实现完了,对于RecyclerView,BrickUI不再需要开发者自己去自定义Adapter和ViewHolder。相比原生UI开发,是不是节省了很多代码呢?

BrickUI完整的能力体验可以通过demo去体验~

demo可以通过扫码下载。除了简单易用的行列布局,也支持相对布局、约束布局、协调布局等,更有很多使用功能~

image.png

image.png

实现原理

BrickUI的实现原理并不复杂,无非也是通过Kotlin的扩展函数的特性,按照DSL的写法,把整个ViewTree的树状结构建立起来,感兴趣的同学可以直接查看源码。

此外,为了既能把View体系中RecyclerView这个神器利用起来,又能让开发者拥有类似Flutter的ListView那样的快捷列表开发体验,BrickUI对性能作了一定取舍,即不再把每一个ItemView都交给ViewHolder来进行回收再利用,但从profile看,如果图片这种大对象交给Glide之类的缓存框架来进行缓存了,那也并没有造成明显的内存抖动。

原生View的嵌入

既然BrickUI是基于原生View体系开发的,那么嵌入原生实现的View是否也是非常容易的?

fun ViewGroup.Markdown(
    text: String
): View {
    // 通过view函数可以很方便的把原生View嵌入到BrickUI的声明式UI中
    return view {
        // 第三方控件:MarkdownWebView
        MarkdownWebView(context).apply {
            // 初始化宽高
            init(MATCH_PARENT, MATCH_PARENT)
            setText(text)
        }
    }
}

BrickUI的局限性

1、BrickUI实际是一种”伪“声明式UI

如前面所述,常见的声明式UI框架,往往是通过状态驱动的,得益于这些框架都拥有自己的渲染体系,它们的渲染原理往往都是通过3棵树来实现。当状态变化时,对比前后两个状态的Element树,可以得到二者差分的补丁,最后把补丁应用到RenderObject树上,即可实现UI的局部刷新和状态响应。

image.png

这使得这样的实现成为可能:

// 每次切换状态后,当image的url不存在时,显示一个文字控件,否则加载图片到一个图片控件
image == null? Text("empty"): Image.network(image);

而View体系天然不支持这样的动态响应,更多的,我们会同时创建TextView和ImageView两个控件,根据实际情况对他们的visibility进行设置。

BrickUI也是基于View体系去实现的,所以往往也只能循序这样的实现。

缺少IDE的实时预览机制和快捷操作机制

由于不缺少Android Studio的插件开发能力,无法实现像xml那样的所见即所得的实时预览,最多只能借助自定义View的预览机制来进行预览。

image.png

同时,也无法实现像Flutter这样,通过快捷菜单,快速为Widget嵌套一个父Widget

image.png

其他局限性

  • 无法像xml那样动态创建id资源(View id)
  • 为了提升开发体验,构造了一些包装对象,造成了一些性能开销

总结

总的来说,软件设计没有银弹,任何设计也都是针对某种场景进行取舍。BrickUI也存在着很多不够成熟甚至拙劣的地方,也希望和大家一起交流来完善它。最后如果你觉得BrickUI或者本文对你有帮助,点个star再走吧~⭐️

BrickUI:github.com/robin8yeung…

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYlaHIYO' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片