介绍 (Introduction)
Jetpack Compose is one of the most discussed topics covered in the recent Android 11 video series. It is expected to solve most of the problems with the current Android UI toolkit, which contains lots of legacies. Another promising tool for Android-development is Kotlin Coroutines and especially Flow API which is supposed to help avoid over engineering with RxJava.
Jetpack Compose是最近的Android 11视频系列中讨论最多的主题之一。 期望使用当前的Android UI工具包解决大多数问题,该工具包包含很多遗留问题。 Android开发的另一个有前途的工具是Kotlin Coroutines,尤其是Flow API,它应该有助于避免RxJava的过度设计。
In this article I would like to show you a small application built with Jetpack Compose UI and with the use of Coroutines StateFlow as a tool for sharing state between screens. Moreover, MVI architecture will be also used.
在本文中,我想向您展示一个使用Jetpack Compose UI构建的小应用程序,并使用Coroutines StateFlow作为在屏幕之间共享状态的工具。 此外,还将使用MVI体系结构。
I would like to note that the approaches shown may differ a little bit from the last recommended ones. I am still exploring these tools, so if you find anything that can be improved — feel free to mention it in a comment or repository issues.
我想指出的是,所示的方法可能与最近推荐的方法有些不同。 我仍在探索这些工具,因此,如果您发现任何可以改进的地方,请随时在评论或存储库问题中提及它。
Let’s start with the application idea. I am a big fan of coffee. And I have noticed that I drink it too much. Therefore, I need an app to track my coffee intake. There are lots of such apps for water and alcoholic beverages, but not for coffee. The first implementation of the app should contain two screens: a month table with the icons of coffee cups and a list of various coffee types with an amount of coffee of each type taken on a particular day.
让我们从应用程序的想法开始。 我是咖啡迷。 而且我注意到我喝得太多了。 因此,我需要一个应用程序来跟踪咖啡摄入量。 有许多此类应用程序用于水和酒精饮料,但不适用于咖啡。 该应用程序的第一个实现应包含两个屏幕:带有咖啡杯图标的月表和各种咖啡类型的列表,以及在特定日期摄取的每种咖啡的量。
Jetpack撰写 (Jetpack Compose)
If you take a look at Jetpack Compose for the first time after layouts in XML, you will probably have a feeling of confusion because you write the UI-code in Kotlin files and should always think about the state.
如果您在XML布局之后第一次看一下Jetpack Compose,您可能会感到困惑,因为您在Kotlin文件中编写了UI代码,并且应该始终考虑状态。
I had an experience of using Flutter which is built on widgets and their states. This helped me to understand the conception more easily and make the prototype of the app in 6 evenings after work. Also, I will make a comparison between Flutter and Compose in the article.
我有使用Flutter的经验,该Flutter是基于小部件及其状态构建的。 这有助于我更轻松地理解概念,并在下班后的6个晚上制作该应用程序的原型。 另外,我将在本文中对Flutter和Compose进行比较。
Another helpful resource here can be a website with a correlation between usual UI components and Compose’s ones and an example of their usage.
这里的另一个有用的资源可以是一个网站 ,该网站在通常的UI组件和Compose的UI组件之间具有相关性,并提供了其用法示例。
As well as in Flutter, in Compose you can use a MainActivity as an entry point to your application, while routing can be made by the composition of views without any other activities or fragments. The entry point can also be put in any new activity of already existing common-UI applications.
与Flutter一样,在Compose中,您可以使用MainActivity作为应用程序的入口点,而路由可以通过视图的组合来进行,而无需任何其他活动或片段。 入口点也可以放在已经存在的通用UI应用程序的任何新活动中。
I have started with sample Compose project in Android Studio. Here is a code of MainActivity.kt:
我已经开始使用Android Studio中的示例Compose项目。 这是MainActivity.kt的代码:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CoffeegramTheme {
Greeting("Android")
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
CoffeegramTheme {
Greeting("Android")
}
}
If you have already seen a Compose, you can read this part fluently.Compose is built on the basis of functions marked with @Composable annotation. It allows Kotlin compiler plugin to generate the required code.
如果您已经看过Compose,则可以流利地阅读此部分。 Compose基于标有@Composable批注的功能构建 。 它允许Kotlin编译器插件生成所需的代码。
Instead of usual setContentView() function called in Activity.onCreate(), a setContent() function with parameter taking Composable function should be called in.
代替在Activity.onCreate()中调用通常的setContentView()函数,应调用带有采用Composable函数的参数的setContent()函数。
Another new thing here is a @Preview annotation above Composable function. It allows to preview the look of inner components via a new version of Android Studio (I use Android Studio 4.2 Canary 2).
这里的另一件事是Composable函数上方的@Preview批注。 它允许通过新版本的Android Studio预览内部组件的外观(我使用Android Studio 4.2 Canary 2)。
To update the preview you should rebuild your project. It is similar to Flutter’s hot reload, but a bit slower and without a real-time code analyzer showing current compile errors. Thus, you can change UI in one file and you will not be able to preview it with errors in others.
要更新预览,您应该重建项目。 它类似于Flutter的热重装,但速度稍慢,并且没有实时代码分析器显示当前的编译错误。 因此,您可以在一个文件中更改UI,而在其他文件中则将无法预览并显示错误。
Another problem I had struggled with was the removal of the whole .idea directory from source control and files from it after committing. A Preview was not available anymore and I started the project from scratch again.
我遇到的另一个问题是从源代码管理中删除整个.idea目录,并在提交后从其中删除文件。 预览不再可用,我再次从头开始了该项目。
Nevertheless, I would recommend to have at least one Preview function in each file with UI-code to have an opportunity to see the changes made in the current file.
不过,我建议在每个文件中都至少要有一个带有UI代码的Preview功能,以便有机会查看当前文件中所做的更改。
Let’s create the first custom view for the application. It will be a list item with a coffee type, an amount of coffee, and buttons to change the number of cups.
让我们为应用程序创建第一个自定义视图。 这将是带有咖啡类型,咖啡量以及用于更改杯数的按钮的列表项。

data class CoffeeType(
@DrawableRes
val image: Int,
val name: String,
val count: Int = 0
)
@Composable
fun CoffeeTypeItem(type: CoffeeType) {
Row(
modifier = Modifier.padding(16.dp)
) {
Image(
imageResource(type.image), modifier = Modifier
.preferredHeightIn(maxHeight = 48.dp)
.preferredWidthIn(maxWidth = 48.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(24.dp))
.gravity(Alignment.CenterVertically),
contentScale = ContentScale.Crop
)
Spacer(Modifier.preferredWidth(16.dp))
Text(
type.name, style = typography.body1,
modifier = Modifier.gravity(Alignment.CenterVertically).weight(1f)
)
Row(modifier = Modifier.gravity(Alignment.CenterVertically)) {
val count = state { type.count }
Spacer(Modifier.preferredWidth(16.dp))
val textButtonModifier = Modifier.gravity(Alignment.CenterVertically)
.preferredSizeIn(
maxWidth = 32.dp,
maxHeight = 32.dp,
minWidth = 0.dp,
minHeight = 0.dp
)
TextButton(
onClick = { count.value-- },
padding = InnerPadding(0.dp),
modifier = textButtonModifier
) {
Text("-")
}
Text(
"${count.value}", style = typography.body2,
modifier = Modifier.gravity(Alignment.CenterVertically)
)
TextButton(
onClick = { count.value++ },
padding = InnerPadding(0.dp),
modifier = textButtonModifier
) {
Text("+")
}
}
}
}
This list item is represented by Row widget (analog of ListView with horizontal orientation). There is an image (loaded now from a png in drawable) inside, a spacer making a margin, a text with a coffee name, filling all the available space because of weight(1f) modifier (similar as in ListView) and an inner Row with two buttons and text for count representation.
此列表项由“ 行”小部件(具有水平方向的ListView的模拟)表示。 里面有一个图像(现在从可绘制的png中加载),一个带空白的间隔符,一个带有咖啡名称的文本,由于weight(1f)修饰符(与ListView中类似)填充了所有可用空间以及一个内部行有两个按钮和用于计数表示的文本。
Android Studio Preview allows to run the widget in Interactive mode. It will make taps and other actions leading to change of the state (the number of cups) available right in Preview. Or it can be launched on an emulator for more complicated cases.
Android Studio Preview允许以交互模式运行小部件。 它将使“水龙头”和其他导致状态(杯数)变化的动作在“预览”中可用。 或者,它可以在更复杂的情况下在仿真器上启动。
州 (State)
The code above is already interactive because of the count wrapped with the state.val count = state { type.count } is taking the count from the data object as an initial value and represents it as a state. Inner widgets can achieve its current value by count.value. When you assign a new value to it — the subtree starting from the widget, containing state function call, will be redrawn.
上面的代码已经是交互式的,因为状态中包含了计数。 val count = state {type.count}将数据对象中的计数作为初始值并将其表示为状态。 内部小部件可以通过count.value实现其当前值。 当您给它分配一个新值时-从小部件开始的包含状态函数调用的子树将被重绘。
Unlike Flutter, Compose has no division between Stateful and Stateless widgets. Each widget with state function inside can be considered as Stateful, while others as Stateless.
与Flutter不同,Compose在有状态和无状态小部件之间没有区别。 内部具有状态功能的每个小部件都可以被认为是有状态的,而其他的则被认为是无状态的。
Now we can create a list of different coffee types. The most simple way is to make a column of them and to add a scroller if list is to long:
现在,我们可以创建不同咖啡类型的列表。 最简单的方法是将它们做成一列,如果list很长,则添加一个滚动条:
@Composable
fun CoffeeList(coffeeTypes: List<CoffeeType>) {
Column {
coffeeTypes.forEach { type ->
CoffeeTypeItem(type)
}
}
}
@Composable
fun ScrollableCoffeeList(coffeeTypes: List<CoffeeType>) {
VerticalScroller(modifier = Modifier.weight(1f)) {
CoffeeList(coffeeTypes: List<CoffeeType>)
}
}
Composable functions can be nested inside Control Flow operators like if, for, when, … Column is an analog of ListView with vertical orientation, and VerticalScroller is analog of ScrollView. The problem in this code is obvious. The list will lag while scrolling. Where is a RecyclerView? Compose has an AdapterList analog. Now scrollable CoffeeList will be implemented as following:
可组合函数可以嵌套在Control Flow运算符中,例如,是否,何时,…… Column是具有垂直方向的ListView的模拟,而VerticalScroller是ScrollView的模拟。 此代码中的问题很明显。 滚动时列表将滞后。 RecyclerView在哪里? Compose有一个AdapterList模拟。 现在,可滚动的CoffeeList将实现如下:
@Composable
fun CoffeeList( coffeeTypes: List<CoffeeType>, modifier: Modifier = Modifier) {
AdapterList(data = coffeeTypes, modifier = modifier.fillMaxHeight()) { type ->
CoffeeTypeItem(type)
}
}
As for now, there is no RecyclerView analog for GridLayoutManager. However, the app already has one of two desirable screens ready.
到目前为止,GridLayoutManager还没有RecyclerView类似物。 但是,该应用程序已经准备好两个理想的屏幕之一。

Before making a second one, let’s think about navigation.
在开始第二篇之前,让我们考虑一下导航。
Material design navigation elements are implemented very similar in Flutter and Compose. The root element should be a Scaffold widget, wrapped with a Theme. It can include TopAppBar, BottomAppBar (maybe with FAB integrated) or Drawer (left). To implement BottomNavigationView I have put Column with BottomNavigation widget inside:
材质设计导航元素在Flutter和Compose中的实现非常相似。 根元素应该是一个Scaffold小部件,并用Theme包装。 它可以包括TopAppBar , BottomAppBar (可能集成了FAB)或Drawer(左侧)。 为了实现BottomNavigationView,我将带有BottomNavigation小部件的列放在其中:
@Composable
fun DefaultPreview() {
CoffeegramTheme {
Scaffold() {
Column() {
var selectedItem by state { 0 }
when (selectedItem) {
0 -> {
Column(modifier = Modifier.weight(1f)){}
}
1 -> {
CoffeeList(listOf(...))
}
}
val items =
listOf(
"Calendar" to Icons.Filled.DateRange,
"Info" to Icons.Filled.Info
)
BottomNavigation {
items.forEachIndexed { index, item ->
BottomNavigationItem(
icon = { Icon(item.second) },
text = { Text(item.first) },
selected = selectedItem == index,
onSelected = { selectedItem = index }
)
}
}
}
}
}
}
The state of the chosen screen tab is contained in selectedItem. With when operator the content that should be displayed is chosen. BottomNavigation widget on click changes the selectedItem value. It seems that we do not need fragments or other activities to implements screens anymore.
所选屏幕选项卡的状态包含在selectedItem中 。 使用何时操作符选择应显示的内容。 单击BottomNavigation小部件可更改 selectedItem值。 似乎我们不再需要片段或其他活动来实现屏幕。
The implementation of the second screen with a table and phased code of the application can be found in the repository. The interesting thing I have found there is a way of retrieving Context for getting resources, or locale, or anything else. For that you should call ContextAmbient.current.context inside of the Composable widget. And a month table screen looks like this:
可以在存储库中找到第二个屏幕的实现,其中包含表和应用程序的分阶段代码。 我发现有趣的是,有一种检索Context的方法来获取资源,语言环境或其他任何东西。 为此,您应该在Composable小部件内调用ContextAmbient.current.context 。 月份表屏幕如下所示:

I have also switched from using png images for coffee types to vector drawables. For this purpose imageResource from Image widget should be replaced by vectorResource. You may also want to use an Icon widget for that (as I have done first), but it makes an icon monochrome.
我也已经从使用png图像作为咖啡类型切换到矢量可绘制对象。 从图片为此imageResource小部件应vectorResource更换。 您可能还想为此使用图标小部件(就像我首先做的那样),但是它将图标变成单色。
状态流 (StateFlow)
Let’s move on to the second part of the article’s title. Coroutines introduced the analog of reactive streams — Flow. It can be considered as a cold sequence of data. It starts to emit data when something subscribes on it (calls terminal function). For passing the state between different components of the application the analog of BehaviorSubject from Rx could be helpful. Since 1.3.6 Coroutines’ team introduced it — StateFlow.As a BehaviorSubject, it can be observable by several subscribers and has an initial state.
让我们继续文章标题的第二部分。 协程推出了React流的类似物-Flow。 可以将其视为冷数据序列。 当有人订阅时,它开始发出数据(调用终端功能)。 为了在应用程序的不同组件之间传递状态,Rx的BehaviorSubject的模拟可能会有所帮助。 从1.3.6 Coroutines的团队引入它以来,它就是StateFlow 。作为BehaviorSubject,它可以被多个订户观察到并具有初始状态。
Simple example of it’s usage: in sample above the selectedItem state can be replaced by selectedItemFlow:
一个简单的用法示例:在selectedItem状态上方的示例中,可以用selectedItemFlow代替:
val selectedItemFlow = MutableStateFlow(0)
@Composable
fun DefaultPreview() {
...
val selectedItem by selectedItemFlow.collectAsState()
when (selectedItem) {
0 -> TablePage()
1 -> CoffeeListPage()
}
...
BottomNavigationItem(
selected = selectedItem == index,
onSelected = { selectedItemFlow.value = index }
)
}
The state of selectedItem is retrieved by extension function collectAsState() of Flow. It is still used for determining the need to redraw and can be used for value retrieving.To change the state we pass an index into selectedItemFlow.value.
通过Flow的扩展函数collectAsState()检索selectedItem的状态。 它仍然用于确定是否需要重画,并且可以用于取值。要更改状态,我们将索引传递给selectedItemFlow.value 。
As value retrieving can be achieved also by val smth = selectedItemFlow.value, it is important not to forget to call collectAsState() inside the widget. It will not be updated otherwise.A possible pattern here is to use State for reading and MutableStateFlow for writing.
由于还可以通过val smth = selectedItemFlow.value来实现值检索,因此重要的是不要忘记在小部件内调用collectAsState() 。 否则将不会更新。这里的一种可能模式是使用State进行读取,使用MutableStateFlow进行写入。
In prototype of the app that allows to move between months tables, see and change the number of cups taken each day, I used 3 StateFlows:
在该应用程序的原型中,该原型允许在月份表之间移动,查看并更改每天取杯的数量,我使用了3个StateFlows:
val yearMonthFlow = MutableStateFlow(YearMonth.now())
val dateFlow = MutableStateFlow(-1)
val daysCoffeesFlow: DaysCoffeesFlow = MutableStateFlow(mapOf())
yearMonthFlow is responsible for the current visible month table; dateFlow is responsible for the chosen day in table and navigation between screens. If it is -1 — TablePage is showing. Otherwise — CoffeeListPage is showing for a particular date;daysCoffeesFlow is a stub of a repository, containing all recorded coffee numbers. Its structure is a solution to the following problem.
yearMonthFlow负责当前的可见月份表; dateFlow负责表中选定的日期以及屏幕之间的导航。 如果为-1 ,则显示TablePage。 否则-CoffeeListPage显示特定日期; daysCoffeesFlow是存储库的存根,其中包含所有记录的咖啡编号。 它的结构可以解决以下问题。
When user navigates from TablePage to CoffeeListPage, its state should be a subset of the common state represented in daysCoffeesFlow. The state of item inside the CoffeeList should also contain a subset of the whole list’s state. When the number of coffee cups inside changes, the item by itself can not know how to change the parent daysCoffeesFlow. We should help it by some mapping of parent’s Flow into successor’s and vice versa.
当用户从TablePage导航到CoffeeListPage时,其状态应为daysCoffeesFlow表示的常见状态的子集。 CoffeeList中项目的状态也应包含整个列表状态的子集。 当内部的咖啡杯数发生变化时,该项目本身不知道如何更改父代daysCoffeesFlow 。 我们应该通过将父级的Flow映射到后继的,或者相反的映射来帮助它。
My temporal solution here was to introduce some boilerplate types shown in DayCoffee.kt file.
我这里的临时解决方案是介绍DayCoffee.kt文件中显示的一些样板类型。
MVI (MVI)
This additional mapper classes brought lots of hard-readable code into view functions. That is why I have decided to try to use MVI architecture. Existing solutions like MVICore seemed coupled with RxJava or other asynchronous frameworks and, thus, too complicated for this case. My solution is based on Android MVI with Kotlin Coroutines & Flow article. Basics of MVI with diagrams can be also found there. Here I will show the code of the base Store class:
这个额外的映射器类将许多难以理解的代码带入了视图函数。 这就是为什么我决定尝试使用MVI架构的原因。 现有的解决方案(例如MVICore)似乎与RxJava或其他异步框架结合在一起,因此对于这种情况而言太复杂了。 我的解决方案基于带有Kotlin Coroutines&Flow文章的Android MVI 。 带有图表的MVI基础知识也可以在此处找到。 在这里,我将显示基本Store类的代码:
abstract class Store<Intent : Any, State : Any>(private val initialState: State) {
protected val _intentChannel: Channel<Intent> = Channel(Channel.UNLIMITED)
protected val _state = MutableStateFlow(initialState)
val state: StateFlow<State>
get() = _state
fun newIntent(intent: Intent) {
_intentChannel.offer(intent)
}
init {
GlobalScope.launch {
handleIntents()
}
}
private suspend fun handleIntents() {
_intentChannel.consumeAsFlow().collect { _state.value = handleIntent(it) }
}
protected abstract fun handleIntent(intent: Intent): State
}
Store works on incoming Intent-s and provides a StateFlow<State> to subscribers. It contains helper functions, therefore it’s inheritors should only implement reducer-like handleIntent() function together with a hierarchy of custom Intents and States.Users of Store’s inheritors can get a state by state property returning StateFlow or push new Intent with newIntent() function.
Store对传入的Intent-s 起作用,并向订户提供StateFlow <State> 。 它包含辅助函数,因此它的继承者仅应实现类似于reducer的handleIntent()函数以及自定义Intent和States的层次结构.Store的继承者的用户可以通过返回StateFlow的state属性获取状态,或者使用newIntent()函数推送新的Intent 。
Lets look at NavigationStore implementing logic for navigation:
让我们看一下NavigationStore实现导航的逻辑:
class NavigationStore : Store<NavigationIntent, NavigationState>(
initialState = NavigationState.TablePage(YearMonth.now())
) {
override fun handleIntent(intent: NavigationIntent): NavigationState {
return when (intent) {
NavigationIntent.NextMonth -> {
increaseMonth(_state.value.yearMonth)
}
NavigationIntent.PreviousMonth -> {
decreaseMonth(_state.value.yearMonth)
}
is NavigationIntent.OpenCoffeeListPage -> {
NavigationState.CoffeeListPage(
LocalDate.of(
_state.value.yearMonth.year,
_state.value.yearMonth.month,
intent.dayOfMonth
)
)
}
NavigationIntent.ReturnToTablePage -> {
NavigationState.TablePage(_state.value.yearMonth)
}
}
}
private fun increaseMonth(yearMonth: YearMonth): NavigationState {
return NavigationState.TablePage(yearMonth.plusMonths(1))
}
private fun decreaseMonth(yearMonth: YearMonth): NavigationState {
return NavigationState.TablePage(yearMonth.minusMonths(1))
}
}
sealed class NavigationIntent {
object NextMonth : NavigationIntent()
object PreviousMonth : NavigationIntent()
data class OpenCoffeeListPage(val dayOfMonth: Int) : NavigationIntent()
object ReturnToTablePage : NavigationIntent()
}
sealed class NavigationState(val yearMonth: YearMonth) {
class TablePage(yearMonth: YearMonth) : NavigationState(yearMonth)
data class CoffeeListPage(val date: LocalDate) : NavigationState(
YearMonth.of(date.year, date.month)
)
}
Starting from the bottom. Here there are two sealed classes corresponding to possible Intents and States. Intents represent corresponding UI-actions. And states in navigation — corresponding pages of the application.
从底部开始。 这里有两个对应于可能的Intent和State的密封类。 意图表示相应的UI动作。 并在导航中指明状态-应用程序的相应页面。
Parameter initialState of NavigationStore represents the State, which the user will see first when opening the application.
NavigationStore的参数initialState表示状态,用户在打开应用程序时将首先看到该状态。
And function handleIntent() contains a business logic for transformation of Intents to States.The second store as all other code of the application can be found in the repository:
并且handleIntent()函数包含用于将Intent转换为States的业务逻辑。第二个存储区是应用程序的所有其他代码,可以在存储库中找到:
Despite childhood diseases of Jetpack Compose and the need to change your mind for full understanding, it seems to be ready to be used in non-critical applications and looks like a future of Android development. It allows easily adopt modern techniques such as unidirectional dataflow architectures and Coroutines, while easier solving some tasks such as navigation. In my opinion, developers should start to at least try it, in order not to miss the moment when it (together with Coroutines) will appear as a must requirement in vacancies.
尽管Jetpack Compose出现了儿童期疾病,并且需要改变主意以进行全面理解,但它似乎已准备好在非关键性应用程序中使用,并且看起来像Android开发的未来。 它允许轻松采用现代技术,例如单向数据流体系结构和协程,同时更轻松地解决某些任务,例如导航。 在我看来,开发人员应该至少开始尝试一下,以免错过空缺时(与协程一起)出现的那一刻。
Given the popularity of declarative UI-frameworks, such as Compose, Flutter, and SwiftUI, mobile development becomes more similar to Web-development. It can cause unification of used architectures, as well as sharing code between most client platforms.
鉴于声明性UI框架(例如Compose,Flutter和SwiftUI)的普及,移动开发变得与Web开发更加相似。 这可能导致统一使用的体系结构,以及在大多数客户端平台之间共享代码。