<input id="0qass"><u id="0qass"></u></input>
  • <input id="0qass"><u id="0qass"></u></input>
  • <menu id="0qass"><u id="0qass"></u></menu>

    Compose搭檔 — ViewModel、LiveData

    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的思想?!?/p>


    五、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才會根據此變量更改顯示狀態。
    相關推薦
    ??2020 CSDN 皮膚主題: 數字20 設計師:CSDN官方博客 返回首頁
    多乐彩