主流移动端新框架都在搞声明式UI
现代的移动应用UI开发框架,如Compose,Flutter,iOS的SwiftUI等都不约而同的使用了声明式UI的编程范式,这一类框架往往通过状态来驱动UI变化,UI代码主要描述了布局信息,以及控件与状态数据之间的关系。解决了以上提到的传统UI开发的一系列痛点。
这些声明式UI框架也都有一个特点,就是UI更新都是通过响应状态的变化来实现
那我们Android原生怎么写UI?
对于Android原生的UI开发,大家肯定对findVIewById不陌生。
对于命令式UI的编程范式,写一个UI的流程一般是:
- 在xml上写layout,drawable等
- 在代码中通过findVIewById或其他框架把View找出来
- 根据交互稿,写UI控制代码来沟通UI和业务代码,UI控制代码主要指的是调用setText,setColor,setImage这一类设置View样式的方法。
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到实际项目中,去实现社区广场列表这种复杂混排长列表。
来2个example来体验下吧
1、经典example-计数器
首先实现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的快速实现。
前排叠甲:以下牛杂师傅的图片均来源于网络
头像实现
头像周围的环形描边,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可以通过扫码下载。除了简单易用的行列布局,也支持相对布局、约束布局、协调布局等,更有很多使用功能~
实现原理
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的url不存在时,显示一个文字控件,否则加载图片到一个图片控件
image == null? Text("empty"): Image.network(image);
而View体系天然不支持这样的动态响应,更多的,我们会同时创建TextView和ImageView两个控件,根据实际情况对他们的visibility进行设置。
BrickUI也是基于View体系去实现的,所以往往也只能循序这样的实现。
缺少IDE的实时预览机制和快捷操作机制
由于不缺少Android Studio的插件开发能力,无法实现像xml那样的所见即所得的实时预览,最多只能借助自定义View的预览机制来进行预览。
同时,也无法实现像Flutter这样,通过快捷菜单,快速为Widget嵌套一个父Widget
其他局限性
- 无法像xml那样动态创建id资源(View id)
- 为了提升开发体验,构造了一些包装对象,造成了一些性能开销
总结
总的来说,软件设计没有银弹,任何设计也都是针对某种场景进行取舍。BrickUI也存在着很多不够成熟甚至拙劣的地方,也希望和大家一起交流来完善它。最后如果你觉得BrickUI或者本文对你有帮助,点个star再走吧~⭐️
BrickUI:github.com/robin8yeung…