天天看點

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

文章目錄

  • Compose如虎添翼 -- 搭配ViewModel、LiveData!!!
    • 一、需求一覽
    • 二、架構、流程
    • 三、Compose UI開發
      • 3.1、搜尋框
      • 3.2、折線圖
    • 四、ViewModel 業務開發
    • 五、Compose和ViewModel建立關系
    • 六、總結

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

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

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

一、需求一覽

我們一起來完成一個需求:首先我們需要一個搜尋框,在搜尋框中輸入城市名,點選鍵盤回車按鈕後請求網絡接口擷取到該城市的天氣資訊 – 今日天氣,9日天氣,并展示在頁面上。

大緻顯示的UI效果及功能如下所示:

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

二、架構、流程

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

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

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

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

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

三、Compose UI開發

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

3.1、搜尋框

首先是輸入框(搜尋框),我們使用TextField來完成搜尋框功能,通過設定colors相關參數來隐藏其預設自帶的下劃線訓示器,通過shape和modifier參數來控制其圓角邊框樣式。通過配置keyboardOptions和keyboardActions來擷取點選鍵盤的Enter鍵時觸發的事件。 還需要注意一點,這裡我們為了在點選Enter鍵後隐藏鍵盤使用了還在實驗階段的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)
            }
        )
    )
}
           

預覽圖如下,簡簡單單一個輸入框:

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

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
    )
}
           
Compose搭檔 — ViewModel、LiveDataCompose如虎添翼 – 搭配ViewModel、LiveData!!!

四、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,至此整體就大功告成了,運作下代碼試試吧,能不能達到如下效果呢?

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

六、總結

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

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

還有一個也比較重要:

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

繼續閱讀