Litho 是什么
Litho 是一个用于在 Android 上构建高效用户界面(UI)的声明性框架。但不同以往的 UI 框架,它的底层是 Yoga, 它通过将不需要交互的 UI 转换为 Drawable 来渲染视图,通过 Yoga 来完成组件布局的异步或同步(可根据场景定制)测量和计算,实现了布局的扁平化。加速了 UI 渲染速度
在 Litho 中,使用组件(Component)来构建 UI,而不是直接与传统的 Android 视图进行交互。组件本质上是一个函数,它接受不可变的输入(称为属性 props),并返回描述用户界面的组件层次结构。
如果有 Flutter 开发经验,那么 Litho 的开发方式有点类似
接下来的教程都将结合代码进行讲解
基础配置
gradle
apply plugin: 'kotlin-kapt'
dependencies 中加入
// Litho
implementation 'com.facebook.litho:litho-core:0.37.1'
implementation 'com.facebook.litho:litho-widget:0.37.1'
kapt 'com.facebook.litho:litho-processor:0.37.1'
// SoLoader
implementation 'com.facebook.soloader:soloader:0.9.0'
// For integration with Fresco
implementation 'com.facebook.litho:litho-fresco:0.37.1'
// Sections
implementation 'com.facebook.litho:litho-sections-core:0.37.1'
implementation 'com.facebook.litho:litho-sections-widget:0.37.1'
compileOnly 'com.facebook.litho:litho-sections-annotations:0.37.1'
kapt 'com.facebook.litho:litho-sections-processor:0.37.1'
初始化 SoLoader.Litho
依赖,SoLoader
用于加载底层布局引擎 Yoga
SoLoader.init(this, false);
使用基础 Component
Component Specs
Litho 中的视图单元叫做 Component
,可以直观的翻译为组件
组件分为两种类型 :
Layout Spec:将其他组件组合到特定的布局中。这相当于 Android 上的 ViewGroup 。
Mount Spec:可以渲染 View 或 Drawable 组件。
现在,让我们来看看 Layout Spec 的整体结构:
Component 的类名必须以 Spec 结尾,不然会报错
/**
* Component
* 组件 Spec 只是一个普通的java类,带有一些特殊的注解。
* 组件 Spec 是完全无状态的,没有任何类成员。
* 使用 @Prop 标注的参数将自动成为组件构建器的一部分。
*/
@LayoutSpec // 将其他组件组合到特定的布局中。这相当于 Android 上的 ViewGroup
class MainLithoViewSpec {
/**
* @OnCreateLayout 注解的方法必须具有 ComponentContext 作为其第一个参数
* 后跟使用 @Prop 标注的参数列表。注解处理器将在构建时对参数列表以及API中其他约束条件进行验证。
*/
@OnCreateLayout
fun onCreateLayout(
context: ComponentContext,
@Prop color: Int,
@Prop title: String
): Component {
return Column.create(context)
.paddingDip(YogaEdge.ALL, 16f)
.backgroundColor(Color.DKGRAY)
.child(
Text.create(context).text(title)
.textColor(color)
.textSizeDip(25f)
)
.child(
Text.create(context).text("这是小标题")
.textColor(Color.GREEN)
.textSizeDip(16f)
)
.build()
}
}
在 Activity 中使用
···
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val c = ComponentContext(this);
// 这两方式都可以,但是第一种方式需要编译
val component2 = MainLithoView.create(c).color(Color.WHITE).title("这是一个Title")
val component = MainLithoViewSpec.onCreateLayout(c, Color.WHITE, "这是一个Title")
// 这里不在使用xml,使用 Litho的Component
setContentView(LithoView.create(c, component));
}
···
组件 Spec 类在编译时期会生成与 Spec 名相同但没有 Spec 后缀的 ComponentLifecycle 子类。例如,MainLithoViewSpec 类会生成一个 MainLithoView 类。
生成的类种暴露的唯一 API 是 create(...)方法,它为 spec 类中声明的 @Props 返回相应的 Component.Builder。
在运行时,特定类型的所有组件实例共享相同的 ComponentLifecycle 引用。这意味着每个组件类型只有一个 spec 实例,而不是每个组件实例。
MountSpec 相比于 Layout Spec 更复杂一些,它拥有自己的生命周期
目前我自己的理解是 LayoutSpec 中你可以使用官方提供的一些组件来构建 UI,但是官方组件毕竟数量有限不可能全部实现 UI 设计。这时候 MountSpec
的作用就凸显出来了。MountSpec
把 Android 上的 View 转化
Mount Specs
Mount Specs 用来生成渲染具体 View 或者 Drawable 的组件。
Mount spec 必须使用 @MountSpec 注解来标注,并至少实现一个标注了 @onCreateMountContent 的方法。
Mount Spec 相比于 Layout Spec 更复杂一些,它拥有自己的生命周期:
- @OnPrepare,准备阶段,进行一些初始化操作。
- @OnMeasure,负责布局的计算。
- @OnBoundsDefined,在布局计算完成后挂载视图前做一些操作。
- @OnCreateMountContent,创建需要挂载的视图。
- @OnMount,挂载视图,完成布局相关的设置。
- @OnBind,绑定视图,完成数据和视图的绑定。
- @OnUnBind,解绑视图,主要用于重置视图的数据相关的属性,防止出现复用问题。
- @OnUnmount,卸载视图,主要用于重置视图的布局相关的属性,防止出现复用问题
Android 小伙伴应该对上面这几个状态比较熟悉
下面这个代码,只是一个单纯的 ColorDrawable,你也可以替换成你需要实习的 View 例如 ImageView:
/**
* 挂载操作有一个非常类似于Android的RecyclerView Adapter的API。
* 它有一个 onCreateMountContent 方法,用于在回收池为空时创建和初始化 View 和 Drawable 内容 onMount 使用当前信息对复用的内容进行更新。
*
* 预分配
* 当挂载 MountSpec 组件时,其 View 或 Drawable 内容需要从回收池中初始化或重用。
* 如果池为空,那么将创建一个新实例,这可能会使UI线程过于繁忙并丢弃一个或多个帧。为了缓解这种情况,Litho 可以预先分配一些实例并放入回收池中。
*
*/
@MountSpec(poolSize = 0, canPreallocate = true, isPureRender = true)
class MainColorViewSpec {
private const val TAG = "MainColorViewSpec"
// onCreateMountContent 的返回类型应该始终与 onMount 的第二个参数的类型匹配。它们必须是 View 或 Drawable 子类。参数在构建时进行校验。
// onCreateMountContent 不能接收 @Prop 或任何带有其他注解的参数。
@OnCreateMountContent
fun onCreateMountContent(context: Context): ColorDrawable {
Log.d(TAG, "OnCreateMountContent() 在组件挂接到宿主 View 之前运行")
return ColorDrawable()
}
/**
* 挂载必须在主线程,因为需要处理 Android View。
* @OnMount 方法不知执行耗时操作,原因跟上面类似,Android 主线程不能执行耗时操作
* 在任何 @MountSpec 方法中使用Output <?> 会自动为之后的阶段创建一个输入。在这种情况下,@OnPrepare 输出为 @OnMount 的输入。
*/
@OnMount
fun onMount(
context: ComponentContext,
colorDrawable: ColorDrawable,
@FromPrepare color: Int // 名称必须对应
) {
Log.d(TAG, "OnMount() 在组件挂接到宿主 View 之前运行")
colorDrawable.color = color
}
// 该方法在执行布局计算之前只运行一次,并且可以在后台线程中执行。
@OnPrepare
fun onPrepare(
context: ComponentContext,
@Prop colorName: Int,
color: Output<Int> // 名称必须对应
) {
Log.d(TAG, "onPrepare() 在布局测量之前运行")
color.set(colorName)
}
/**
* 如果要在布局计算过程中自定义组件的测量,就要实现 @OnMeasure 方法。
* 假设想要 ColorComponent 具有默认宽度,并在其高度未定义时强制执行特定的高宽比。
*/
@OnMeasure
fun onMeasure(
context: ComponentContext,
layout: ComponentLayout,
widthSpec: Int,
heightSpec: Int,
size: Size
) {
Log.d(TAG, "onMeasure() 在布局测量期间选择性运行")
if (SizeSpec.getMode(widthSpec) == SizeSpec.UNSPECIFIED) {
size.width = 40
} else {
size.width = SizeSpec.getSize(widthSpec)
}
// If height is undefined, use 1.5 aspect ratio.
if (SizeSpec.getMode(heightSpec) == SizeSpec.UNSPECIFIED) {
size.height = (size.width * 1.5).toInt()
} else {
size.height = SizeSpec.getSize(heightSpec)
}
}
@OnBoundsDefined
fun onBoundsDefined(c: ComponentContext, layout: ComponentLayout) {
Log.d(TAG, "onBoundsDefined() 在布局测量之后运行")
}
@OnBind
fun onBind(c: ComponentContext, view: ColorDrawable) {
Log.d(TAG, "onBind() 在组件挂接到宿主 View 后运行")
}
@OnUnbind
fun onUnbind(c: ComponentContext, view: ColorDrawable) {
Log.d(TAG, "onUnbind() 在将组件从宿主 View 分离之前运行")
}
@OnUnmount
fun onUnmount(context: ComponentContext, mountedView: ColorDrawable) {
Log.d(TAG, "OnUnmount() 在组件从宿主 View 分离后,选择性运行")
}
/**
* Mount Spec可以使用@ShouldUpdate注释定义一个方法来避免在更新时进行重新测试和重新挂载。
* @ShouldUpdate 的调用的前提是component是"纯渲染函数'。
* 一个组件如果是纯渲染函数,那么它的渲染结果只取决于它的prop和状态.
* 这意味着在@OnMount期间,组件不应该访问任何可变的全局变量。
* 一个@MountSpec可以通过使用@MountSpec注释的pureRender参数来定自己为"纯渲染的"。
* 只有纯渲染的Component可以假设当prop不更改时就不需要重新挂载
*/
@ShouldUpdate(onMount = true)
fun shouldUpdate(@Prop(optional = true) someStringProp: Diff<String>): Boolean {
return someStringProp.previous.equals(someStringProp.next)
}
}
使用:
val component2 = MainColorView.create(c)
.widthDip(26f)
.heightDip(46f)
//colorName 就是我们定义的属性
.colorName(Color.GREEN).build()
运行后打印的 log:
MainColorViewSpec: onPrepare() 在布局测量之前运行
MainColorViewSpec: onBoundsDefined() 在布局测量之后运行
MainColorViewSpec: OnCreateMountContent() 在组件挂接到宿主 View 之前运行
MainColorViewSpec: OnMount() 在组件挂接到宿主 View 之前运行
MainColorViewSpec: onBind() 在组件挂接到宿主 View 后运行
MainColorViewSpec: onUnbind() 在将组件从宿主 View 分离之前运行
到这里 MountSpec 的基本用法就讲完了。有了这两个 Component 就乐意做很多事了。
Litho 中包含的的两种数据类型
Litho 的两种属性分别是:
- 不可变属性称为 Props
- 可变属性称为 State
不可变属性 Props
定义和使用 props
Props 属性:Component
中使用 @Prop
注解的参数集合,具有单向性和不可变性,可以在左右的方法中访问它的指。在同一个 Component 中我们可以定义和访问相同的 prop
下面这个例子,定义了两个 Prop,一个 string 类型 text,一个 int 类型 index,text 的注解中 optional = true
表示它是一个可选参数。
当 Component
的生命周期方法被调用的时候,@Prop 参数会保存 component 创建时从它们的父级传递过来的值 (或者它们的默认值)
设置 props
prop 参数其实在前几篇文章中都有使用过,用起来也没有什么特别的地方,这里不在赘述,制作一个简单的说明。
Component
中的 prop 参数会在编译时候自动加入到 Builder 中,以上面的代码举例:
PropComponent.create(c).index(10)./*text("测试文本").*/build()
Prop 的默认值
对于可选的 Prop 如果不设置值,就是 java 的默认值。或者你也可以使用 @PropDefault
注解然后添加默认值。
如果你使用 Kotlin,那还需要加上 @JvmFiel
把该字段编辑为 public 才行。
@MountSpec
object PropComponentSpec {
@JvmField
@PropDefault
val prop1 = "default"
@JvmField
@PropDefault
val prop2 = -1
资源类型
在 Android 开发中,我们经常会限定参数的类型。比如:
fun doSomething(@ColorInt color: Int, @StringRes str: Int, @DimenRes width: Int){}
在 Compontent
的 Prop 中也有类似的操作,具体看代码:
fun onMount(
c: ComponentContext, textView: TextView,
@Prop(optional = true,resType = ResType.STRING) text: String?,
@Prop index: Int
) {}
需要注意的是,Conpontent
中修改一个 Prop 后,其他使用想用 Prop 的地方也需要修改
当你按照上面的方法修改并且 build 后,会自动生成 Res,Attr,Dip,Px 方法。
你可以像下面这样使用:
PropComponent.create(c).index(14).textRes(R.string.app_name).build()
ResType 中包含以下这些类型:
可变属性 State
定义和使用 State
State 一般用在与用户交互的场景中,比如:点击、输入框、Checkbox。但是这些都是由当前 Compontent
内部感知,并更新 State,他的父级并需要关心他的状态。正因为 State 是 Compontent
,所以当 Compontent
创建后,如果我们需要修改 State, 只能通过单独定义一个 Prop 属性来修改 State 的初始值
State 的声明和 Prop 区别不是很大:
@LayoutSpec
object StateComponentSpec {
/**
* 定义一个State参数isCheck
*/
@OnCreateLayout
fun onCreateLayout(c: ComponentContext, @State isCheck: Boolean): Component {
return Column.create(c).child(
Image.create(c).drawableRes(
if (isCheck) android.R.drawable.checkbox_on_background
else android.R.drawable.checkbox_off_background
).build()
).child(
Text.create(c).text(if (isCheck) "Checked" else "Uncheck").textColor(Color.BLACK)
.textSizeDip(16f).marginDip(YogaEdge.TOP, 10f).build()
).clickHandler(StateComponent.onClick(c)).build()
}
@OnUpdateState
fun updateCheckedState(isCheck: StateValue<Boolean>) {
}
}
State 初始化
State 需要在 @OnCreateInitialState
注解的方法中初始化: OnCreateInitialState
方法需要注意:
- 第一个参数必须是
ComponentContext
(大部分的 Componetn 方法都要求第一个参数必须是ComponentContext
) - State 相关的参数的名称必须和其他生命周期方法中的 @State 参数保持一致,并且这些参数的类型必须是
StateValue
, 其中泛型的类型与对应的 @State 一致 - 如果没有定义
@OnCreateInitialState
,State 的值就是 java 默认值 - 只有在
Component
第一次被添加到 Component 树的时候才会调用一次@OnCreateInitialState
方法。如果 Component 的 key 没有改变,后续对 Component 树布局的重新计算并不会重新调用 @OnCreateInitialState 方法 - 不需要自己调用
@OnCreateInitialState
方法.
@OnCreateInitialState
fun updateCheckState(
c: ComponentContext, isCheck: StateValue<Boolean>,
@Prop initChecked: Boolean
) {
isCheck.set(initChecked)
}
更新 State
State 需要在 @OnUpdateState
注解的方法中更新: OnUpdateState
方法需要注意:
- 可以定义多个
OnUpdateState
方法来更新不同的 State ,但是OnUpdateState
方法每次调用都会对它所在的Component
重新计算一次。所以为了更好的性能,应该尽可能少的调用OnUpdateState
,或者合并多个 State 的更新,来提升性能 - 和初始化时候一个样。State 相关的参数的名称必须和其他生命周期方法中的 @State 参数保持一致,并且这些参数的类型必须是
StateValue
, 其中泛型的类型与对应的 @State 一致 - 如果你的 State 的值需要依赖于 Prop, 你可以在
@OnupdateState
函数的参数中使用@Param
声明,这样就可以在更新被触发的时候传递 prop 的值进来了.
跟我们的 Check 增加一个更新方法:
@OnUpdateState
fun updateCheckedState(isCheck: StateValue<Boolean>) {
val check = isCheck.get()
isCheck.set(check?.let { !check })
}
合并多个 State 更新,并且使用 Param 来更新 State:
/**
* 多个State合并更新,同时 使用Param来更新 State
*/
@OnUpdateState
fun updateCheckedStateTwo(isCheck: StateValue<Boolean>,isCheckTwo: StateValue<Boolean>,
@Param checked:Boolean) {
isCheck.set(!checked)
isCheck.set(isCheckTwo.get())
}
调用 State 更新
对于使用 @OnUpdateState
注解的方法,编译后自动生成两个更新方法:
- 一个
@OnUpdateState
同名的方法,它会同步的调用 state 的更新。 - 一个加上 Async 后缀的静态方法,它会异步的调用 state 的更新。
(图中的 updateCheckedState
,updateCheckedStateSync
最终调用的都是同一个方法,所以这里说生成了两个方法)
让我们的点击事件调用更新方法:
@OnEvent(ClickEvent::class)
fun onClick(c: ComponentContext, @State isCheck: Boolean) {
StateComponent.updateCheckedStateAsync(c)
}
关于调用更新方法有一下几点需要注意:
- LayoutSpec 中避免在
onCreateLayout
中直接调用更新方法,因为更新会触发布局重新计算,而重新计算又会触发onCreateLayout
,很容易造成死循环 - MountSpec 中,不要在
onMount
、onBind
方法中直接调用更新方法,如果你真的需要在这类方法中更新 State 的值,那么应该使用下面会讲到的懒汉式 State 更新来替代. - 当调用一个 State 更新方法的时候 (
StateComponent.updateCheckedStateAsync(c)
),参数中的ComponentContext
必须是当前需要更新传递过来的ComponentContext
,因为它包含了现有的 State 等其他重要的信息,在重新计算的时候回替换原有的 Component,生成新的 Component.
懒汉式更新 State
懒汉式更新可以更新 State 的值,但是又不会立刻触发 Component
的布局计算,当调用懒汉式更新后,Component 将会保持现有的 State 值,在下次被别的机制 (例如收到一个新的 prop 或者或者 State 的定期更新) 触发是,才会更新 State 的值,在不需要立刻进行布局计算的情况下,懒汉式更新对想要更新内部 Component 信息并且在 Component 树的重新布局中保持这些信息是非常实用的.
要是用懒汉式更新,需要在 @State 注解中设置 canUploadLazily = true
/**
* 懒更新State
*/
@LayoutSpec
object LazilyUpdateComponentSpec {
@OnCreateLayout
fun onCreateLayout(
c: ComponentContext,
@State(canUpdateLazily = true) name: String
): Component {
// 在这里直接调用 更新 State 方法
LazilyUpdateComponent.lazyUpdateName(c,"UpdateName")
return Column.create(c)
.child(
Text.create(c).text(name)
).build()
}
@OnCreateInitialState
fun stateInit(c: ComponentContext, name: StateValue<String>, @Prop initName: String) {
name.set(initName)
}
}
调用:
val component = LazilyUpdateComponent.create(c).initName("initName").build()
根据代码,我们在 onCreateLayout
方法中调用了更新 State 的方法,但是由于是懒更新,所以并不是对布局进行重新计算,所以界面上显示的还是初始化的值。
对上面的代码修改一下,增加一个点击事件,点击后更新另一个 State 的值:
/**
* 懒汉式更新State
*/
@LayoutSpec
object LazilyUpdateComponentSpec {
private const val TAG = "LazilyUpdateComponentSp"
@OnCreateLayout
fun onCreateLayout(
c: ComponentContext,
@State(canUpdateLazily = true) name: String,
@State testData: String?
): Component {
// 在这里直接调用 更新 State 方法
LazilyUpdateComponent.lazyUpdateName(c, "UpdateName")
Log.i(TAG, "onCreateLayout: $name")
Log.i(TAG, "onCreateLayout: ${testData ?: ""}")
return Column.create(c).clickHandler(LazilyUpdateComponent.onClick(c))
.child(
Text.create(c).text(name)
).build()
}
@OnCreateInitialState
fun stateInit(c: ComponentContext, name: StateValue<String>, @Prop initName: String) {
name.set(initName)
}
@OnEvent(ClickEvent::class)
fun onClick(c: ComponentContext) {
LazilyUpdateComponent.updateTestDataAsync(c)
}
@OnUpdateState
fun updateTestData(testData: StateValue<String>) {
testData.set("TestData")
}
}
logcat:
点击前:
LazilyUpdateComponentSp: onCreateLayout: initName
LazilyUpdateComponentSp: onCreateLayout:
···
点击后:
LazilyUpdateComponentSp: onCreateLayout: UpdateName
LazilyUpdateComponentSp: onCreateLayout: TestData
同时 UI 上也被更新:
Litho 底层使用的是 Yoga,Yoga 是 Facebook 的另一个开源项目,它是一个跨 iOS、Android、Windows 平台在内的布局引擎,兼容 Flexbox 布局方式。
所以只要熟悉 Flexbox 布局,那么在使用 Litho 进行 UI 布局时基本毫无压力。
如果熟悉 Flutter 开发,那在使用 Litho 时,会有一些似曾相识的感觉,Litho 中的 Row 与 Column 相关属性与 Flutter 中的 Row 与 Column 几乎无二。
本来想写一点示例代码,但是感觉没什么可写的。下面这个链接是 Yoga 官网的 playground。
https://yogalayout.com/playground
你可以通过它可视化的调整 UI,构建你需要的 layout。同时可以生成相应的 Litho 代码
在线可视化构建 UI:
在线可视化构建 UI:
直接生成的 Litho 代码:
在 Flexbox 中可以通过 positionType (ABSOLUTE) 属性来实现 Android 中的 FrameLayout
效果:
@OnCreateLayout
fun createLayout(c: ComponentContext): Component {
return Column.create(c)
.child(
SolidColor.create(c)
.color(Color.MAGENTA)
.widthDip(100f)
.heightDip(100f)
)
.child(
Text.create(c)
.text("FrameLayout")
.marginDip(YogaEdge.TOP, 30f)
.positionType(YogaPositionType.ABSOLUTE)
)
.build();
}
运行效果:
在最开始入门介绍中,我们曾经用 SingleComponentSection
完成了一个简单的列表,当时的做法是使用 for 构造出了多个子 Component。其实在 Litho 中提供了一个性能更好的方式,专门处理这种数据(这种数据其实就是类似于 Android 中的 adapter 与其绑定的数据)。
Litho 中专门处理这种模板与列表支持的组件叫做
DataDiffSection
的使用
DataDiffSection
。下面用 DataDiffSection
我们重构一下之前写的 MainListViewSpec
。
- 首先生成我们的数据:
val data = arrayListOf<Int>()
for (i in 0 until 32) {
data.add(i)
}
- 在
MainListViewSpec
中增加一个创建ListItemView
组件的方法:
@OnEvent(RenderEvent::class)
fun onRender(c: SectionContext, @FromEvent model: Int): RenderInfo {
return ComponentRenderInfo.create().component(
ListItemView.create(c)
.color(if (model % 2 == 0) Color.WHITE else Color.LTGRAY)
.title(if (model % 2 == 0) "hello word" else model.toString())
.build()
).build()
}
- 接下来改造一下
onCreateChildren
方法:
@OnCreateChildren
fun createChildren(c: SectionContext, @Prop listData: ArrayList<Int>):Children {
return Children.create()
.child(
DataDiffSection.create<Int>(c)
.data(listData)
.renderEventHandler(MainListView.onRender(c))
)
.build()
}
- 最后运行一下:
···
val recycleView = RecyclerCollectionComponent.create(c).disablePTR(false)
.section(MainListView.create(SectionContext(this)).listData(data)).build()
setContentView(LithoView.create(c, recycleView))
···
可能大部分到这里都有点蒙,DataDiffSection
到底是从哪里来的呢?DataDiffSection
有点类似于 Android 的 DiffUtil
它是一个内置的一个事件:
- 每当一个 Item 被渲染的时候,DataDiffSection 会产生一个 RenderEvent。
- 创建 DataDiffSection 的时候,我们要传入自己的
renderEventHandler
,就是上面代码中的MainListView.onRender(c)
。
可以看到效果跟之前的没有区别:
标题
嵌套一个横向滚动的列表
在最开始我们是使用 SingleComponentSection
构建列表的。这里如果需要嵌套一个横向滚动的列表,同样也可以用 SingleComponentSection
来完成:
val config = ListRecyclerConfiguration.create()
.orientation(LinearLayoutManager.HORIZONTAL)
.reverseLayout(false)
.snapMode(SnapUtil.SNAP_TO_CENTER)
.build()
RecyclerCollectionComponent.create(c)
.disablePTR(true)
.recyclerConfiguration(config)
.section(DataDiffSection.create<Int>(c)
.data(listData)
.renderEventHandler(SectionItem.onRender(c)))
.canMeasureRecycler(true)
标题