Compose搭檔 — ViewModel、LiveData

2021-05-29 07:00:40

Compose如虎添翼 – 搭配ViewModel、LiveData!!!

Compose系列文章,請點原文閱讀。原文:是時候學習Compose了!

單純的使用Compose來進行UI的展示,相信我們已經運用自如了,接下來的文章我們一起搭配其他Jetpack元件,例如LiveData,ViewModel、Room等來了解下Compose在現代化的開發上是多麼的簡單、舒適!

一、需求一覽

我們一起來完成一個需求:首先我們需要一個搜尋方塊,在搜尋方塊中輸入城市名,點選鍵盤迴車按鈕後請求網路介面獲取到該城市的天氣資訊 – 今日天氣,9日天氣,並展示在頁面上。

大致顯示的UI效果及功能如下所示:
GIF 2021-5-25 18-39-21.gif

二、架構、流程

假如使用之前 View + MVP架構 的模式,整體的流程圖應該是如下所示:

在這裡插入圖片描述

那麼在 Compose + MVVM架構 中的話,流程圖會有什麼變化呢?(其實想使用MVI架構,但是又需要加入一定的解釋成本,所以後續文章再專門結合MVI做範例吧)

在這裡插入圖片描述

如上所示,很明顯的Activity和Compose在這裡只要一個 setContent{} 的關係,後續都是Compose直接和ViewModel之間的互動,Presenter和Model、ViewModel和Model這兩層類似,不做贅述。

三、Compose UI開發

接下來我們先使用Compose編寫UI,根據需求,我們需要一個搜尋方塊用來輸入資料,然後搜尋到資料後需要展示今日天氣資料、9日天氣資料。那麼簡潔一點,我們就把今日資料用一行文字表示出來,9日溫度資料用一個自定義折線圖表示出來。

3.1、搜尋方塊

首先是輸入框(搜尋方塊),我們使用TextField來完成搜尋方塊功能,通過設定colors相關引數來隱藏其預設自帶的下劃線指示器,通過shape和modifier引數來控制其圓角邊框樣式。通過設定keyboardOptions和keyboardActions來獲取點選鍵盤的確認鍵時觸發的事件。 還需要注意一點,這裡我們為了在點選確認鍵後隱藏鍵盤使用了還在實驗階段的API – LocalSoftwareKeyboardController。整體搜尋方塊程式碼如下所示:

@ExperimentalComposeUiApi
@Composable
fun SearchView(
    onClick: (city: String) -> Unit
) {
    val input = remember {
        mutableStateOf("")
    }

    //鍵盤控制器,可控制鍵盤的展示和隱藏
    val keyboardController = LocalSoftwareKeyboardController.current

    //輸入框圓角設定
    val corner = 20.dp

    TextField(
        value = input.value,
        onValueChange = {
            input.value = it
        },
        colors = TextFieldDefaults.textFieldColors(
            //輸入框下部的指示線
            focusedIndicatorColor = Color.Transparent,
            unfocusedIndicatorColor = Color.Transparent,
        ),

        //外觀設定
        modifier = Modifier
            .fillMaxWidth()
            .border(
                width = 2.dp,
                color = Color.Black,
                shape = RoundedCornerShape(corner)
            ),
        shape = RoundedCornerShape(corner),

        //鍵盤設定,輸入完畢後隱藏鍵盤
        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(
            onDone = {
                keyboardController?.hide()
                onClick(input.value)
            }
        )
    )
}

預覽圖如下,簡簡單單一個輸入框:
Snipaste_2021-05-25_19-23-59.png

3.2、折線圖

接下來是自定義溫度折線圖,首先我們來分析下9天的資料,那麼需要9個點,也就是螢幕需要8等分,然後分別繪製線段和端點就可以了。整體關於Canvas繪製的請檢視之前的文章,這裡我們需要注意一點就是:繪製的端點是有半徑的,我們繪製區域的時候,x軸前後需要留出來這個半徑能把首尾的端點全部展示出來,否則首尾的端點只能顯示半個。程式碼如下:

@Composable
fun TempLineChart(
    modifier: Modifier,
    weatherDaily: List<WeatherDaily>
) {

    if (weatherDaily.isEmpty()) {
        return
    }

    val days = weatherDaily.size

    Canvas(
        modifier = modifier
    ) {

        //圓點的集合
        val points: ArrayList<Offset> = ArrayList()

        //溫度的差值(最大溫度的差值)
        val tempMax = weatherDaily.maxOf {
            it.tempMax.toInt()
        }
        val tempMin = weatherDaily.minOf {
            it.tempMax.toInt()
        }
        val diff = tempMax - tempMin


        //繪製的直線的寬度
        val lineStrokeWidth = 8f

        //繪製的最大圓點的直徑,注意是半徑,繪製時候需要乘以2
        val pointStrokeWidth = 16f

        val path = Path()

        //起點位置
        val startX = pointStrokeWidth
        val startY = size.height

        //平均每天的步長,需剔除圓點的寬度
        val xOffset = (size.width - pointStrokeWidth * 2) / (days - 1)

        val endX = size.width - pointStrokeWidth


        path.moveTo(startX, startY)

        var lastOffset: Offset? = null

        for ((index, weatherDailyBean) in weatherDaily.withIndex()) {

            val x = startX + xOffset * index

            val y =
                startY - (size.height / (diff + 2) * ((weatherDailyBean.tempMax.toInt() - tempMin) + 1))

            val offset = Offset(x, y)
            points.add(offset)

            //路徑
            path.lineTo(x, y)

            //繪製直線
            if (lastOffset != null) {
                drawLine(
                    color = Color(0xFF357AFF),
                    start = lastOffset,
                    end = offset,
                    strokeWidth = lineStrokeWidth,
                )
            }

            lastOffset = offset
        }


        path.lineTo(endX, startY)
        path.close()

        //繪製路徑
        drawPath(
            path = path,
            brush = Brush.verticalGradient(
                colors = arrayListOf(Color(0x80357AFF), Color(0x00000000))
            ),
        )

        //繪製藍色圓點
        drawPoints(
            pointMode = PointMode.Points,
            color = Color(0xFF357AFF),
            strokeWidth = pointStrokeWidth * 2,
            points = points,
            cap = StrokeCap.Round,
        )

        //繪製白色圓點
        drawPoints(
            pointMode = PointMode.Points,
            color = Color.White,
            strokeWidth = pointStrokeWidth,
            points = points,
            cap = StrokeCap.Round,
        )

    }
}

OK,然後造幾條偽資料,我們使用@Preview來預覽下顯示效果:

@Preview
@Composable
fun TempLineChartPreview() {

    val weatherDailyList = ArrayList<WeatherDaily>()
    for (i in 1..9) {
        weatherDailyList.add(WeatherDaily(tempMax = i.toString()))
    }

    TempLineChart(
        modifier = Modifier
            .height(200.dp)
            .fillMaxWidth(),
        weatherDailyList
    )
}

Snipaste_2021-05-25_19-24-26.png


四、ViewModel 業務開發

至此,我們單獨的UI已經編寫完畢了,接下來是ViewModel的部分,網路請求這塊無疑是Retrofit套餐,但是Retrofit和Compose沒有任何關係,所以這裡我們暫時不花篇幅講解其使用方式,直接使用偽資料來代替網路請求結果,後續文章我們會結合Hilt來範例Retrofit、Room等相關知識。ViewModel相關程式碼如下:

class MainViewModel : ViewModel() {

    /**
     * 城市名
     */
    private val _cityName = MutableLiveData<String>()

    /**
     * 對外單獨暴漏修改城市名方法
     */
    fun updateCityName(name: String) {
        _cityName.value = name
    }

    /**
     * 當日天氣【當_cityName值變更的時候,這裡會響應】
     */
    val weatherNow: LiveData<String> = Transformations.switchMap(_cityName) {
        MutableLiveData(" ${_cityName.value} 地區,今日天氣好的不能再好了!")
    }

    /**
     * n天天氣【當_cityName值變更的時候,這裡會響應】
     */
    val weatherDays: LiveData<List<WeatherDaily>> = Transformations.switchMap(_cityName) {
        val weatherDailyList = ArrayList<WeatherDaily>()
        for (i in 1..9) {
            val temp = (15..20).random()
            weatherDailyList.add(WeatherDaily(tempMax = temp.toString()))
        }
        MutableLiveData(weatherDailyList)
    }
}

注意:我們使用了Transformations類,當_cityName的值變化的時候, switchMap( _cityName ) 會響應,我們處理過後返回一個新的LiveData的值,weatherNow和weatherDays這兩個變數就會被賦值。

【其實這裡的程式碼設計方式再深入想一下,好像又能感受到一絲 MVI Intent的思想。】


五、Compose和ViewModel建立關係

Compose UI和ViewModel都搞定了,那麼他們之間如何像上文流程圖中表示的那樣可以建立聯絡呢?其實官方給我們提供了一個庫:androidx.lifecycle:lifecycle-viewmodel-compose:$latestVersion,該庫提供了一個**viewModel()**函數,可以直接在@Composable 函數中存取到相關ViewModel的範例,例如:

@ExperimentalComposeUiApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {

    val weatherNow = viewModel.weatherNow.observeAsState()
    val weatherDays = viewModel.weatherDays.observeAsState(arrayListOf())

}

如上,我們在引數中直接使用viewModel()來獲取MainViewModel範例,而在MainScreen()函數中我們還使用到了一個 observeAsState() 函數,使用該函數也需要參照一個擴充套件庫:androidx.compose.runtime:runtime-livedata:$latestVersion,該函數的作用就是將ViewModel提供的LiveData資料轉換為Compose需要的State資料。

當LiveData資料更新後,LiveData轉換為State,而Compose會根據State資料來自行重新整理,所以將之前的UI控制元件組合起來,再將State資料設定進去,相關程式碼如下所示:

@ExperimentalComposeUiApi
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {

    val weatherNow = viewModel.weatherNow.observeAsState()
    val weatherDays = viewModel.weatherDays.observeAsState(arrayListOf())

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {

        Spacer(modifier = Modifier.height(20.dp))
        SearchView(
            onClick = {
                viewModel.updateCityName(it)
            })
        Spacer(modifier = Modifier.height(20.dp))

        Text(
            text = weatherNow.value ?: ""
        )

        TempLineChart(
            modifier = Modifier
                .height(200.dp)
                .fillMaxWidth(),
            weatherDaily = weatherDays.value
        )
    }
}

OK,至此整體就大功告成了,執行下程式碼試試吧,能不能達到如下效果呢?
GIF 2021-5-25 18-39-21.gif

六、總結

整體的話,重點在於Compose和ViewModel的結合、以及LiveData和State的使用。這其中我們還要注意Compose的架構思想:

  • 事件向上傳遞,例如搜尋方塊的回車事件,暴漏出來給上層處理;
  • 狀態向下傳遞,例如網路請求結果資料等向UI層傳遞顯示,可以封裝成網路請求中、請求成功、請求失敗等狀態向UI傳遞;

還有一個也比較重要:

  • 單一信任源,上文中沒有明顯的範例,但是你可以觀察到TextField中輸入的資料是根據 input 的值來進行變化的。舉個View中的例子,比如CheckBox,當你點選的時候,狀態會立即進行改變,此時如果網路請求失敗了,我們還需要把CheckBox的顯示狀態重置。但是在Compose中,CheckBox的顯示狀態改變和TextField一樣,只有input的值變化了,它才變化。也就是說CheckBox需要訂閱一個變數,你只有請求網路成功後或者失敗後更改此變數,CheckBox才會根據此變數更改顯示狀態。