Jetpack Compose: Hello World
什么是 Jetpack Compose
这里 Jetpack Compose 的[项目地址](Jetpack Compose)。
一句话介绍:
A declarative toolkit for building UI inspired by React, Litho, Vue.js and Flutter.
因此 Jetpack Compose 是:
- 一个工具集
- 用于构建 UI
- 是声明式的
「构建 UI 的工具集」这个很常见,但是看到「声明式」的时候,有没有一种熟悉的感觉呢?嗯,如果你了解过 Flutter / RN,那么应当已经知道是怎么回事了——尽管底层渲染的实现都不一样,但是从思路上来说,大体都是一样的。
因此,在继续 Jetpack Compose 之前,想简单聊聊 Anko Layouts 和 Flutter。
Anko Layouts 和 Flutter
什么是 Anko?
Anko 是 Kotlin 官方出的一个帮助开发 Android 应用的工具库,其包含了:
- Anko Commons
- Anko Layouts
- Anko SQLite
- Anko Corotines
而什么是 Anko Layouts 呢?
Anko Layouts is a DSL for writing dynamic Android layouts.
先记住,Anko 是 Kotlin 出品的,Kotlin 是一个很适合发明 DSL 的语言:
- infix 很适合把函数调用转变为中缀表示法(忽略该调用的点与圆括号)
- Higher-Order Functions and Lambdas 适合把一个闭包放到函数调用之外
举个例子:
infix fun Int.isZero(block: Int.() -> Unit): Int {
if (this == 0) {
block()
}
return this
}
infix fun Int.orElse(block: Int.() -> Unit) {
if (this != 0) {
block()
}
}
fun foo() {
val value = 0
value isZero {
Log.i(TAG, "value is zero")
} orElse {
Log.i(TAG, "value is not zero")
}
}
依靠这些语法,Anko Layouts 发明了一套写 UI 的语法,举个例子:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
verticalLayout {
orientation = LinearLayout.VERTICAL
textView("Hello") {
padding = 10
}
textView("World") {
padding = 10
}
}
}
}
verticalLayout
和 textView
都是 Activity
的扩展方法,在 lambda 里返回的都是 Android 里的 View
,得益于 Kotlin 的 Function literals with receiver,lambda 里的 this
就是 Android 的 View
,因此你可以直接设置 View
的一些属性。
因为是 Activity
的扩展方法,因此里面已经帮你做了 setContentView
的操作了。因此如果你成功运行以上代码,那么跑起来的 UI 会是这样子的:
可以看到,Anko Layouts 其实就是通过 Kotlin 的语法糖,帮助你直接在 Kotlin 代码里写布局,最后生成出来的 View
就是 Android 里的 View
。本质上我认为还是指导式的写法,尽管形式上看起来像声明式。
Flutter
Flutter 很火,相信你多多少少都有了解过。如果你写过,你可能对它的 Declarative 式写法很有印象,举个简单的例子,这是一个 Button,点击一下会在文字左侧出现 ProgressBar,再点一下则会消失:
class ActionButton extends StatefulWidget {
@override
_ActionButtonState createState() => _ActionButtonState();
}
class _ActionButtonState extends State<ActionButton> {
bool isInProgress;
_ActionButtonState();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
isInProgress = !isInProgress;
});
},
child: Row(
children: createWidgets()
),
);
}
List<Widget> createWidgets() {
List<Widget> widgets = [];
if (isInProgress) {
widgets.add(CircularProgressIndicator());
}
widgets.add(Padding(padding: EdgeInsets.all(10), child: Text("Action")));
return widgets;
}
}
如果说 Anko Layouts 纯粹就是利用了 Kotlin 的语法做的「小技巧」,那么 Flutter 就是「黑魔法」:
- 底层使用 Skia 渲染,使用 Dart 编写的代码运行在 Dart VM 上,支持跨平台运行,可以理解为一个游戏引擎
- 上层实现一套独立于各平台的描述 UI 的语法,并使用 Dart 实现。
- Framework 层提供了遵守 Google Material Design 和 Apple HIG (Cupertino) 的控件库
- 支持使用 Channel 跟 Native 层通信
对比 Flutter 跟 Anko Layouts,是不是觉得从语法层上,其实有些相似?那么可能自然出现一个问题,为什么 Flutter 不支持使用 Kotlin 甚至是 Swift 编写?
在过去,Flutter 官方似乎没有一个很明确的答案,你可以认为,Flutter 跟 Dart 这两个是紧密关联的,无论从技术上还是团队上,推荐阅读Why Flutter Uses Dart – Hacker Noon 这篇文章。
不过,Google I/O 2019 后,Google 似乎用「Jetpack Compose」给了一个回答:
Jetpack Compose 就是 Flutter in Kotlin。
编译 Support / Jetpack lib
目前 Jetpack Compose 还是处于非常早期的阶段(说是实验阶段也不为过),同时,Compose 跟整个 Jetpack 其他组件的代码都是相关的,因此你需要拉全部 Jetpack 源码下来(注意有 6GB 这么大),再自行编译源码,可以参考 这里 checkout 源码和 这里 编译。
cd path/to/checkout/frameworks/support/ui/
./gradlew :ui-material:integration-tests:ui-material-studies:assembleDebug
官方 demo 跑起来后是这样子的:
Jetpack Compose Hello World
在开始之前,我再请来一位「前辈」:Reactive Native——你可以使用 JS/React 代码写你的 UI,然后就会翻译为原生的控件,比如 <Text>
对应为 TextView
。
那么 Jetpack Compose 的思路是不是一样的呢?看一个来自官方的图:
很标准的 Kotlin 代码,唯一特别醒目的就是 Composable
注解,这让我们知道在编译时肯定需要做点什么操作,一个猜想:会不会可能就是把这里的 Text 翻译为 TextView,然后此方法就是接收一个 List<String
对象,返回一个 List<TextView
而已呢?
而事实并不是如此。
那么我们来做个试验吧。
目前可以使用仓库里的 material-studios
测试,打开 RallyActivity
并修改代码为
class RallyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CraneWrapper {
createBody(listOf("Hello", "world"))
}
}
}
@Composable
fun createBody(words: List<String>) {
Column(crossAxisAlignment = 3) {
for (word in words) {
Padding(padding = 12.dp) {
Text(
word,
style = TextStyle(color = Color(0xFFFFFFFF.toInt()), fontSize = 100f)
)
}
}
}
}
}
- 你已经能看到熟悉的 Activity 模板代码了:override onCreate 并 setContentView
- 使用
@Composable
注解告诉编译器这个方法是Composable
的(呃,是不是很绕,请脑内自行翻译吧) - 写 Flutter 的同学们,看到
Column
,crossAxisAlignment
和Padding
的时候是不是觉得很熟悉:
class MyHomePage extends StatefulWidget {
MyHomePage({Key key}) : super(key: key);
@override
_MyHomePageState createState() => _MyHomePageState(words: ["Hello", "World"]);
}
class _MyHomePageState extends State<MyHomePage> {
List<String> words;
_MyHomePageState({this.words});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: words
.map((word) => Padding(
padding: EdgeInsets.all(10.0),
child: Text(
word,
style: TextStyle(color: Colors.black),
)))
.toList());
}
}
跑起来后应该长这样:
那么我们来看一下布局,你可以使用你熟知的工具,或者简单地使用 IDE 自带的 Layout inspector:
然后可以看到:
可以看到出现 AndroidCraneView
这个类。
到这里可以证明上述猜想是错误的。事实上,@Composable
注解就是告诉编译器,来自官方原话就是:
This tells the Composer compiler that your function should be treated as a widget.
同时,@Composable
方法只能在 @Composable
方法/闭包里调用,也就是说跟 Kotlin suspend 方法和其他 async/await 一样有调用的传染性。正是这样一层层 @Composable
方法的调用,组建了 Compose 的 Declarative UI。
关于 AndroidCraneView
这个类,先看类签名:
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
class AndroidCraneView constructor(context: Context)
: ViewGroup(context), Owner, SemanticsTreeProvider
先不管实现的 Owner
和 SemanticsTreeProvider
接口,它其实就是一个 ViewGroup
,而且设置了 setWillNotDraw(false)
——可以知道,它的 onDraw(canvas: Canvas)
方法将会被调用,同时作为一个 ViewGroup
, fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)
和 fun onLayout(changed: Boolean,l: Int,t: Int,r: Int,b: Int)
也自然会被调用。至于触控事件的传递,它是在 fun onTouchEvent(event: MotionEvent): Boolean
里接收触控事件,然后交由 PointerInputEventProcessor
处理。
它的意义很明确了:
- 负责整个 Compose UI 的测量、布局和绘制。
- 负责接收触控事件,然后分配给消费触控事件的 Node。
- 可以没有任何子 View,所有的可绘制元素都不是 Android 原生的 View,而是内部在维护的
ComponentNode
:可以是DrawNode
和LayoutNode
等。注意,上面说的是可以没有任何子 View,事实上 Google 在 I/O 上还介绍了一个注解叫@GenerateView
,这可以帮你生成一个 Android 里的View
。此特性在目前 Jetpack Compose 里还没有。
在 Draw.kt
下有这么个方法:
@Composable
fun Draw(
children: @Composable() () -> Unit = {},
@Children(composable = false)
onPaint: DrawScope.(canvas: Canvas, parentSize: PxSize) -> Unit
) {
// Hide the internals of DrawNode
<DrawNode onPaint={ canvas, parentSize ->
DrawScope(this).onPaint(canvas, parentSize)
}>
children()
</DrawNode>
}
可以看到内部还是通过 Canvas 来绘制。看前面都好理解,但是突然冒出 <DrawNode> </DrawNode>
这个代码块是怎么回事…( Compose 里用了一个特殊版本的 Kotlin,猜测跟这个有关)
const val COMPOSE_VERSION = "1.3.30-compose-20190503"
const val KOTLIN_COMPOSE_STDLIB = "org.jetbrains.kotlin:kotlin-stdlib:$COMPOSE_VERSION"
再看 Onwer
接口:
/**
* Owner implements the connection to the underlying view system. On Android, this connects
* to Android [android.view.View]s and all layout, draw, input, and accessibility is hooked
* through them.
*/
interface Owner
所以大概可以猜到以后可能有一个 IOSCraneView
。可以猜测整个关于 Compose 的底层引擎都尽可能地跟 Android 没关系,毕竟有 Kotlin Native 的存在,未来把整个包抽出来,成为了一个新的跨平台 UI 库也不是不可能。不过因为 Flutter 的存在,因此再搞这么一个东西意义何在,谁也不知道。
从各个方面可以看到整个 Compose 都有参考 Flutter 的实现,以 flutter
为关键字搜索一下代码:
可以看大 Emittable
这个接口就参考了 Flutter 的 RenderObject
概念,还有不少链接直接链到了 Flutter 的文档去了…因此,尽管目前来说官方文档甚少(毕竟是实验阶段),应该可以通过了解 Flutter 的技术细节(主要是 RenderTree 和 RenderObject 相关的)来提前了解 Jetpack Compose。
Tips
- 一些方法命名是首字母大写的,目的是应该为了更符合编写和阅读习惯。比如上文里的 Text,其实就是 Text.kt 里一系列方法,并没有 Text 这个类(其实并不严谨,因为 Text.kt 最后会生成
object TextKt
这个类)。 - 为了方便阅读源码,请 cd 到
/frameworks/support/ui
,然后使用./studiow
命令下载一个 Compose 版本的 Android Studio。 - 如果你像我一开始那样对
+onDispose{disposeComposition(rootTextSpan,ref) }
里的+
感到陌生,不妨查一下unaryPlus
这个一元运算符。
数据和事件流向
数据、UI 和业务逻辑的分离,是很多框架要实现的目的之一。Jetpack Compose 提供了声明式 UI 的构建方式:你使用代码描述 UI,不需要关心上层的业务逻辑会导致数据怎样地变更,反正,你给我一个数据, 把他转换为用户可见的 UI 就是了。
跟 @Composable
注解的相关的还有 @Model
注解,这告诉 Compose,这个 Model(通常是个 data class)是作为一个 source of true
而存在的,他持有可观察的数据,能在数据变更的时候触发 UI 的重绘,也就是会触发 @Composable
方法的调用。
一个简单的例子:
class RallyActivity : Activity() {
companion object {
private const val TAG = "RallyActivity"
}
@Model
data class Person(
var name: String
)
private var person = Person("Andy")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CraneWrapper {
body(person) {
Log.i(TAG, "clicked")
person.name = "Clicked andy"
}
}
}
}
@Composable
fun body(person: Person?, onClick: (() -> Unit)? = null) {
person ?: kotlin.run {
Text("Person is null")
return
}
Clickable(onClick = onClick) {
Padding(10.dp) {
Text(
text = person.name,
style = TextStyle(fontSize = 120f, color = Color(0xFFFFFFFF.toInt()))
)
}
}
}
}
一些要点:
- Person 类加了 @Model 注解,意味着他的属性是「可观察的」,当属性被赋新值的时候,就会触发
Recompose
操作。根据 I/O 上介绍的,Jetpack Compose 也会跟AAC
紧密结合,所以你可以使用LiveData<T>
作为数据发射源,每发射一次数据就能触发一次Recompose
操作。 - 数据位于
Activity
里——当然这里为了展示方便直接在这里写,事实上更应该在ViewModel
里。@Composable
注解标记的方法,仅仅接受数据,同时把事件处理往上抛出,最后交由持有数据的组件(此处为 Activity,但更应该是 ViewModel)处理。 - 上述代码点击 Text 后还不会更新 UI(但能看到代码打的 log),关于 @Model 资料还很少,这是根据 I/O 上的资料写的代码,所以意思到位了就行(官方 DEMO 也没展示这一点)
其他的一些事情
关于多线程
Jetpack Compose 的合成和绘制是在主线程里做的,可以从 Recomposer
的 current()
方法里看到:
@SuppressLint("SyntheticAccessor")
internal fun current(): Recomposer {
assert(Looper.myLooper() == Looper.getMainLooper())
return threadRecomposer.get() ?: error("No Recomposer for this Thread")
}
把这个过程移到子线程显然会更好,幸运的是,在 I/O 关于 Jetpack Compose 的最后,有提及到可能会使用多线程来测量布局以及合成。
当然,因为实际的绘制还是靠 Canvas,因此这个避免不了必须在主线程做——但是,这并不是必须的,记得 SurfaceView 吗?SurfaceView 本身就支持在子线程使用 Canvas 绘图。但是要抛离 Canvas 也不是不可能的。如果你了解过 Windows Composition API,那么应该知道其里面的 Visual Layer 其实是 Graphics Layer(其实就是 DirectX) 的封装,是 Framework Layer 跟 Graphics Layer 的桥梁。Jetpack Compose 要做到上述其实也是可以的:由 Framework 层处理测量布局以及合成,然后把绘制移交到子线程,调用 OpenGL API 进行绘制。
关于 Declarative UI
对于声明式 UI,可以说既爱又恨——意图显然是很好的,Dart 和 Kotlin 天生也很适合写声明式 UI,但是 Everything is a widget 这个概念,先不说从写指导式 UI 转过来的困难(毕竟还是能克服的),在上手写代码后,你难免会遇到这种情况:
当然 Intellij IDEA 系列的 Flutter Plugin 其实在帮我们解决这种问题了:一是使用各种线来表示 Widget 层级的关系,而是可以在 Widget 使用 option+enter 来快速移除一个 Widget 以及使用新的 Widget 包一层当前的 Widget,避免容易出错——相信我,手写的话在开始的时候随着需求变更你会崩溃的。
后话
正如之前说到的,Jetpack Compose 还是处于非常早期的阶段,官方称为 Experimental。本文没有对该框架做太多技术上的剖析,毕竟还是试验阶段,代码随时都有变更。考虑到此项目跟 Flutter 的关系,感兴趣的同学可以还请先直接学习 Flutter,了解其底层合成原理。
Links
Jetpack Compose | Android Developers
Declarative UI Patterns (Google I/O’19) - YouTube
GitHub - Kotlin/anko: Pleasant Android application development
Subscribe to JuniperPhoton's Blog
Get the latest posts delivered right to your inbox