Jetpack Compose 新的嘗試

概述

Android 中的佈局文件是藉助 XML 實現的,描述的很直觀,也很容易複用,可是 XML 畢竟只是簡單的標記語言,只能用來描述頁面結構,而數據和頁面元素的關係以及其餘複雜的業務邏輯還須要經過其餘程序代碼主動處理。在 Activity 中,經過顯式編程的方式解析 XML 文件找到你的控件,而後經過同步或者異步的方式獲取控件相關的數據,最後將數據顯示到控件上。這是一個很傳統、很簡單、頗有效的流程,可是隨着需求的不斷變化,愈來愈多的弊端暴露了出來:android

  • 文件數劇增:因爲佈局文件是做爲資源而非源碼存在的,因此只能存放在 res/layout 這個惟一目錄下,隨着產品的不斷迭代,該目錄下的文件急劇增長,命名、查找、維護的難度變得愈來愈大
  • 關係不直觀:一個頁面(Activity)中可能包含幾個甚至十幾個佈局文件,沒有辦法直觀精確地查看和定位一個頁面涉及到的佈局文件,在源碼和這些佈局文件之間來回切換閱讀和修改是異常艱難的,而對於 res/layout 目錄下的佈局文件也沒有辦法直觀地瞭解哪些地方正在使用這些佈局文件
  • 繁瑣:在佈局文件中咱們給每一個控件起一個名字(ID),而在程序中咱們須要根據這個 ID 查找咱們須要的具體控件,如 TextView nameTextView = (TextView) rootView.findViewById(R.id.tv_name);,頁面中的每一個控件都須要這樣的操做獲取,還要從新再起一個符合源碼規範的新名字,對於幾十甚至幾百個控件的頁面來講,完成這樣的操做是至關繁瑣至關痛苦的
  • 耦合嚴重:隨着應用迭代,XML 佈局文件和源碼都要同步地進行更改,也就是說每加一個控件,XML 文件和源碼都要相應地進行更改,若是 XML 中移除了某個控件而源碼沒有更改就會出現運行時異常,因此它們其實是強耦合的
  • 方式不統一:一個頁面佈局既能夠經過 XML 文件的方式靜態指定,也能夠經過編寫源碼的方式動態建立,這兩種大相徑庭的方式雖然均可以實現頁面佈局,但畢竟是不一樣的語言,不一樣的系統,很難統一管理和維護
  • 架構缺陷:數據驅動視圖思想的實現須要視圖能夠方便的與數據進行單向或者雙向的綁定,只有 Data Binding 技術能夠實現 XML 裏插入代碼,完成和數據的綁定,可是這樣的操做就像 JSP 在 HTML 文件中插入 Java 代碼同樣,雖然簡單直接,可是代碼邏輯的連貫性、一致性以及可維護性會面臨史無前例的挑戰

那什麼樣的 UI 構建方式才能避免上述的問題呢?什麼樣的構建方式纔是簡單有效的呢?我相信大多數人的回答都是 「聲明式(declarative)」,在源碼中聲明式地構建 UI 既直觀又不會損失源碼的能力。可是這個願景在實現上卻又困難重重,怎麼讓 Java 或 Kotlin 擁有聲明式語法的能力,怎麼讓排版佈局更加的簡潔直觀,怎麼避免 UI 邏輯和業務邏輯的耦合等等都是須要重點解決的問題,而我以爲 Jetpack Compose 是個很不錯的嘗試算法

框架思想

關注點分離

關注點分離(separation of concerns)是最多見最出名的軟件設計原則,也是每一個開發者都應該瞭解並遵循的,其實關注點分離最初是對另外兩個詞的歸納:耦合(coupling)和內聚(cohesion)。理論上,當咱們寫代碼時,咱們會把應用當作多個模塊,並且還可能把每一個模塊當作多個單元,這些模塊或單元之間的依賴關係就是耦合,也就是說,若是我在某處對一些代碼進行了更改,那麼我還必須對其餘文件進行多少更改?因此咱們通常的想法就是儘量的減小耦合。有時耦合是隱式的,那些咱們依賴的依賴或者其餘咱們依賴的東西其實是不肯定的,可是仍是會由於咱們的更改而被破壞。另外一方面,內聚指的是模塊中的單元如何相互歸屬,它們彼此相關,高內聚一般被視爲一件好事。所以關注點分離就是將盡量多的相關代碼組織在一塊兒,以便咱們的代碼能夠隨着時間推移而更好地維護,隨着應用的成長而真正地擴展
在 Android 中通常的作法是用 XML 佈局顯示東西,用 ViewModel 給這個佈局提供數據,事實上這裏隱含了不少依賴,ViewModel 和佈局之間存在不少耦合,若是 XML 中新增了控件,ViewModel 中也要新增對應的數據,這個關係是隱式的,但又是真實存在的。若是咱們用相同的語言如 Kotlin 構建 UI,那麼這個關係就可能會變成顯式的了,甚至咱們接下來開始重構一些代碼,將一些東西移到它們所屬的地方,實際上減小了某些耦合,增長了一些內聚。你可能會問了,這不是把業務邏輯和 UI 混在一塊兒了嗎?好吧,咱們換個角度看一下,一些業務邏輯難道不是 UI 的一部分嗎?其實任何框架都不能完美地幫你分離你的關注點,也不能阻止你將邏輯和 UI 混在一塊兒,可是 Jetpack Compose 提供了工具可讓你很容易進行分離,這個工具就是組合式函數(composable functions),一個加了 @Composable 註解的函數,因此你以前寫函數時重構,寫可靠、可維護性、整潔代碼的技巧一樣適用於組合式函數編程

聲明式 vs 命令式

聲明式編程(declarative)和命令式編程(imperative)是不一樣的編程思想,好比有個需求是這樣的,未讀消息數是 0 的時候顯示一個空信封的圖標,有幾個消息的時候在信封圖標上加個信件圖標和消息數 badge,消息數超過 100 時再加個火苗而且 badge 再也不是具體數字而是 99+。若是是命令式編程,咱們確定要寫一個根據數量進行更新的函數:架構

fun updateCount(count: Int) {
    if (count > 0 && !hasBadge()) {
        addBadge()
    } else if (count == 0 && hasBadge()) {
        removeBadge()
    }
    if (count > 99 && !hasFire()) {
        addFire()
        setBadgeText("99+")
    } else if (count <= 99 && hasFire()) {
        removeFire()
    }
    if (count > 0 && !hasPaper()) {
        addPaper()
    } else if (count == 0 && hasPaper()) {
        removePaper()
    }
    if (count <= 99) {
        setBadgeText("$count")
    }
}
複製代碼

咱們弄清楚如何調整 UI 以使其呈現正確的狀態,實際上可能還有不少極端狀況,這個邏輯並不簡單,可是這已經算是相對簡單的例子了。而若是你用聲明式的方式寫這段邏輯那麼會是這樣的:框架

@Composable
fun BadgeEnvelope(count: Int) {
    Envelope(fire = count > 99, paper = count > 0) {
        if (count > 0) {
            Badge(text = if (count > 99) "99+" else "$count")
        }
    }
}
複製代碼

你會發現至少在 UI 操做上來講聲明式編程要更加直觀,更加簡潔
而 UI 開發者最關心的是什麼呢?對於給定的數據 UI 該怎麼顯示?怎麼響應事件讓 UI 進行交互?UI 隨着時間應該怎樣變化?,有了聲明式編程,有了 Jetpack Compose,咱們再也不須要考慮 UI 隨時間的變化,注意,這是最重要,最關鍵的點,由於在咱們拿到數據後咱們就定義了它在各個狀態下應該怎麼展現,以後框架會控制如何從一個狀態進入另外一個狀態,即 「根據提供的參數來描述 UI」。組合式函數,是個函數定義,可是它在一個地方描述了 UI 全部可能的狀態,並且是本地定義的,這就是組合(composition),所以有了 Compose 和 @Composable 這兩個名字異步

組合 vs 繼承

組合(composition)和繼承(Inheritance)是面向對象編程中最多見的關聯關係,繼承是擴展類功能最簡單直接的方式,可是多繼承弊端太大致使除了 C++ 的大部分語言都是隻容許單繼承的,若是咱們把 View 系統經過繼承實現,那麼就會出現相似這樣的問題,若是我想要個 Input,那麼我繼承 View,若是我想要個 ValidatedInput 那麼我繼承 Input,若是我想要個 DateInput 那麼我繼承 ValidatedInput,若是我想要個 DateRangeInput 怎麼辦呢?我不能繼承 DateInput 由於我有兩個 Date,但我又想擁有 DateInput 的能力,因此,咱們最終仍是遇到了單繼承的限制。而在 Jetpack Compose 中這個問題就很簡單了,咱們無非多組合一個 DateInput 而已ide

封裝

Jetpack Compose 另外一個作得比較好的地方就是封裝,一個 composable 就是 給定參數,一個 composable 能夠 管理狀態,這是你開放你的 API 時惟一須要考慮的。另外一方面,composable 能夠管理和建立狀態,而後它能夠將狀態以及接收到的數據做爲參數傳遞給其餘 composable,子 composable 也能夠經過回調的方式通知你狀態的更改函數

重組

重組(Recomposition)最基本的就是任何組合式函數都有 隨時被再次調用 的能力,這也就意味着,若是你有一個很大的層級結構,當一部分層級改變後,你不須要重建整個層級。你能夠利用這個特性作一些大事,好比對於以前這樣的操做:工具

fun bind(liveMsgs: LiveData<MessageData>) {
    liveMsgs.observe(this) { msgs ->
        updateBody(msgs)
    }
}
複製代碼

咱們觀察這個 LiveData,每次 LiveData 更新的時候都會調用咱們傳入的 lambda,而後更新 UI。可是這畢竟是異步回調的形式,不符合咱們的習慣,而在 Jetpack Compose 中咱們就能夠把這個關係轉換過來:佈局

@Composable
fun Messages(liveMsgs: LiveData<MessageData>) {
    val msgs = +observe(liveMsgs)
    for (msg in msgs) {
        Message(msg)
    }
}
複製代碼

在這裏咱們調用了 observe() 函數,它作了兩件事,首先是解封裝 LiveData 來返回它的當前值,這也就意味着你能夠在函數體中直接使用這個值。其次,它還隱式地將 LiveData 訂閱到這個它會被解封裝的組合式函數做用域中。這也就意味着,咱們再也不須要傳遞 lambda 表達式了,咱們只須要知道這個組合式函數每次在 LiveData 變化時都會重組就好了。讓咱們再次比較上面兩段代碼,雖然在代碼量上沒有什麼差別,可是在思想上後者要更加符合咱們的思惟習慣,更加直觀

數據驅動視圖

數據驅動視圖的思想既能簡化 UI 操做又能保證數據展現的一致性,而 Data Binding 對於數據驅動視圖的嘗試雖然有效,可是並不優雅,一個 Model 能夠插入到 XML 中,能夠進行一些簡單的處理,而若是讓視圖跟隨 Model 變化還須要將 Model 轉化成 Observable,這個轉化是須要手動完成的。而 Jetpack Compose 對於數據驅動視圖的嘗試要更優雅一些,如這裏的一個計數器功能:

@Composable
fun Counter() {
    val count = +state { 0 }
    Button(
        text = "Count: ${count.value}",
        onClick = { count.value += 1 }
    )
}
複製代碼

state() 函數能夠直接返回包裹了給定值的 State 狀態類實例,State 類用了 @Model 註解,而 @Model 註解就意味着這個類的全部屬性的讀寫操做都是 observable 的,Jetpack Compose 作得就是當你執行你的組合式函數時,若是你讀取了一些 Model 實例,那麼 Jetpack Compose 將自動訂閱所在的做用域以便進行 Model 的讀寫。所以這個例子中的 Counter 是獨立自給的,每次 Model 的值發生更改時 Counter 都會重組

使用

組合式函數

Jetpack Compose 是創建在組合式函數(composable functions)的基礎上的,這些函數可讓你以編程的方式定義 UI(經過描述它的形狀和數據依賴),而不是關注 UI 的構建過程
一個組合式函數只能被另外一個組合式函數調用,因此組合式函數須要添加 @Composable 註解

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Greeting("Android")
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text("Hello $name!")
}
複製代碼

沒有參數的組合式函數是能夠直接預覽的,只須要添加 @Preview 註解便可

preview

Jetpack Compose 團隊認爲 「建立未被應用調用的單獨的預覽函數是最佳的作法,專門的預覽函數不但能夠提升性能,還能夠方便地提供多個預覽」,不過我以爲有點雞肋,單個組合式函數應該能夠自動或者手動預覽,不該該書寫額外的預覽函數,而對於多個預覽函數,應該屬於 UI 測試的範疇,不須要也不該該出如今源碼中
multiple previews

佈局

使用 Column() 函數能夠豎向堆疊元素,能夠經過它的 crossAxisSize 參數指定列的大小,經過它的 modifier 參數指定修飾樣式

Column(
    crossAxisSize = LayoutSize.Expand,
    modifier = Spacing(16.dp)
) {
    Text("A day in Shark Fin Cove")
    Text("Davenport, California")
    Text("December 2018")
}
複製代碼

Container() 能夠做爲通用容器包裹和限制裏面的元素。HeightSpacer() 能夠用做留白。Clip() 能夠裁剪,參數是用來裁剪的 ShapeShape 是不可見的。MaterialTheme() 能夠給組件應用主題,而後就能夠給文本應用樣式了,如 Text("A day in Shark Fin Cove", style = +themeTextStyle { h6 })

@Composable
private fun TopicItem(topicKey: String, itemTitle: String) {
    val image = +imageResource(R.drawable.placeholder_1_1)
    Padding(left = 16.dp, right = 16.dp) {
        FlexRow(
            crossAxisAlignment = CrossAxisAlignment.Center
        ) {
            inflexible {
                Container(width = 56.dp, height = 56.dp) {
                    Clip(RoundedCornerShape(4.dp)) {
                        DrawImage(image)
                    }
                }
            }
            expanded(1f) {
                Text(
                    text = itemTitle,
                    modifier = Spacing(16.dp),
                    style = +themeTextStyle { subtitle1 })
            }
            inflexible {
                val selected = isTopicSelected(topicKey)
                SelectTopicButton(
                    onSelected = {
                        selectTopic(topicKey, !selected)
                    },
                    selected = selected
                )
            }
        }
    }
}
複製代碼

這是一個包含三個元素的列表項,看起來還算直觀,可是仍是感受哪裏有點彆扭
因爲藉助了 Kotlin 的 trailing lambda 表達式的語法,代碼最終看起來仍是可以表達出某種層次或結構的

思考

Jetpack Compose 是個頗有趣的嘗試,讓我看到了 Android 新的構建 UI 方式的可能,從語法上來看仍是有一些 HTML 和 Flutter 的影子的,對於頁面複雜嵌套層級過深狀況下的處理應該還有很長一段路要走,Flex 佈局可否解決這個問題,Flex 佈局是否真的適合 Android,Jetpack Compose 的性能如何保證,調試是否方便,Gap Buffer 的算法是否比 Diff 算法有優點等等都是須要面對,須要思考,須要時間去解決的問題
總之,Jetpack Compose 目前只是一個嘗試,還缺乏足夠的控件支持,還缺乏足夠的工具支持,還缺乏足夠的穩定性,不過我很樂意看到這種新的嘗試出現,刀耕火種的時代老是要過去的

參考

相關文章
相關標籤/搜索