/ android

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
            }
        }
    }
}

verticalLayouttextView 都是 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, crossAxisAlignmentPadding 的时候是不是觉得很熟悉:
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

先不管实现的 OwnerSemanticsTreeProvider接口,它其实就是一个 ViewGroup,而且设置了 setWillNotDraw(false)——可以知道,它的 onDraw(canvas: Canvas)方法将会被调用,同时作为一个 ViewGroupfun 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:可以是 DrawNodeLayoutNode 等。注意,上面说的是可以没有任何子 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 的合成和绘制是在主线程里做的,可以从 Recomposercurrent() 方法里看到:

@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,了解其底层合成原理。

Jetpack Compose  |  Android Developers

Declarative UI Patterns (Google I/O’19) - YouTube

Rally - Material Design

GitHub - Kotlin/anko: Pleasant Android application development

Inside Flutter  - Flutter