【譯】LiveData-Flow在MVVM中的最佳實踐
點擊上方藍字關(guān)注我,知識會給你力量
最近在Medium上看到了Flow開發(fā)者寫的幾篇文章,覺得很不錯,推薦給大家。
1
原文鏈接:https://proandroiddev.com/using-livedata-flow-in-mvvm-part-i-a98fe06077a0
最近,我一直在尋找MVVM架構(gòu)中Kotlin Flow的最佳實踐。在我回答了這個關(guān)于LiveData和Flow的問題后,我決定寫這篇文章。在這篇文章中,我將解釋如何在MVVM模式中使用Flow與LiveData。然后我們將看到如何通過使用Flow來改變應(yīng)用程序的主題。
sample地址:https://github.com/fgiris/LiveDataWithFlowSample
什么是Flow?
Flow是coroutines庫中的一個反應(yīng)式流,能夠從一個Suspend函數(shù)中返回多個值。
盡管Flow的用法似乎與LiveData非常相似,但它有更多的優(yōu)勢,比如:
-
本身是異步的,具有結(jié)構(gòu)化的并發(fā)性 -
用map、filter等操作符簡單地轉(zhuǎn)換數(shù)據(jù) -
易于測試
如何在MVVM中使用Flow
如果你的應(yīng)用程序有MVVM架構(gòu),你通常有一個數(shù)據(jù)層(數(shù)據(jù)庫、數(shù)據(jù)源等)、ViewModel和View(Fragment或Activity)。你可能會使用LiveData在這些層之間進行數(shù)據(jù)傳輸和轉(zhuǎn)換。但LiveData的主要目的是什么?它是為了進行數(shù)據(jù)轉(zhuǎn)換而設(shè)計的嗎?
?LiveData從來沒有被設(shè)計成一個完全成熟的反應(yīng)式流構(gòu)建器
——Jose Alcérreca在2019年Android Dev峰會上說
?
由于LiveData是一個具有生命周期意識的組件,因此最好在View和ViewModel層中使用它。但數(shù)據(jù)層呢?我認為在數(shù)據(jù)庫層使用LiveData的最大問題是所有的數(shù)據(jù)轉(zhuǎn)換都將在主線程上完成,除非你啟動一個coroutine并在里面進行工作。這就是為什么你可能更喜歡在數(shù)據(jù)層中使用Suspend函數(shù)。
假設(shè)你想從網(wǎng)絡(luò)上獲取天氣預(yù)報數(shù)據(jù)。那么在你的數(shù)據(jù)庫中使用Suspend函數(shù)就會類似于下面的情況。
class WeatherForecastRepository @Inject constructor() {
suspend fun fetchWeatherForecast(): Result<Int> {
// Since you can only return one value from suspend function
// you have to set data loading before calling fetchWeatherForecast
// Fake api call
delay(1000)
// Return fake success data
return Result.Success((0..20).random())
}
}
你可以在ViewModel中用viewModelScope調(diào)用這個函數(shù)。
class WeatherForecastOneShotViewModel @Inject constructor(
val weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private var _weatherForecast = MutableLiveData<Result<Int>>()
val weatherForecast: LiveData<Result<Int>>
get() = _weatherForecast
fun fetchWeatherForecast() {
// Set value as loading
_weatherForecast.value = Result.Loading
viewModelScope.launch {
// Fetch and update weather forecast LiveData
_weatherForecast.value = weatherForecastRepository.fetchWeatherForecast()
}
}
}
這種方法對于每次被調(diào)用時都會運行的單次請求來說效果不錯。但是在獲取數(shù)據(jù)流的時候呢?
這里就是Flow發(fā)揮作用的地方。如果你想從你的服務(wù)器上獲取實時更新,你可以用Flow來做,而不用擔(dān)心資源的泄露,因為結(jié)構(gòu)化的并發(fā)性迫使你這樣做。
讓我們轉(zhuǎn)換我們的數(shù)據(jù)庫,使其返回Flow。
class WeatherForecastRepository @Inject constructor() {
/**
* This methods is used to make one shot request to get
* fake weather forecast data
*/
fun fetchWeatherForecast() = flow {
emit(Result.Loading)
// Fake api call
delay(1000)
// Send a random fake weather forecast data
emit(Result.Success((0..20).random()))
}
/**
* This method is used to get data stream of fake weather
* forecast data in real time
*/
fun fetchWeatherForecastRealTime() = flow {
emit(Result.Loading)
// Fake data stream
while (true) {
delay(1000)
// Send a random fake weather forecast data
emit(Result.Success((0..20).random()))
}
}
}
現(xiàn)在,我們能夠從一個Suspend函數(shù)中返回多個值。你可以使用asLiveData擴展函數(shù)在ViewModel中把Flow轉(zhuǎn)換為LiveData。
class WeatherForecastOneShotViewModel @Inject constructor(
weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecast()
.asLiveData(viewModelScope.coroutineContext) // Use viewModel scope for auto cancellation
val weatherForecast: LiveData<Result<Int>>
get() = _weatherForecast
}
這看起來和使用LiveData差不多,因為沒有數(shù)據(jù)轉(zhuǎn)換。讓我們看看從數(shù)據(jù)庫中獲取實時更新。
class WeatherForecastDataStreamViewModel @Inject constructor(
weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.map {
// Do some heavy operation. This operation will be done in the
// scope of this flow collected. In our case it is the scope
// passed to asLiveData extension function
// This operation will not block the UI
delay(1000)
it
}
.asLiveData(
// Use Default dispatcher for CPU intensive work and
// viewModel scope for auto cancellation when viewModel
// is destroyed
Dispatchers.Default + viewModelScope.coroutineContext
)
val weatherForecast: LiveData<Result<Int>>
get() = _weatherForecast
}
當你獲取實時天氣預(yù)報數(shù)據(jù)時,map函數(shù)中的所有數(shù)據(jù)轉(zhuǎn)換將在Flow collect的scope內(nèi)以異步方式完成。
?注意:如果你在資源庫中沒有使用Flow,你可以通過使用liveData builder實現(xiàn)同樣的數(shù)據(jù)轉(zhuǎn)換功能。
?
private val _weatherForecast = liveData {
val response = weatherForecastRepository.fetchWeatherForecast()
// Do some heavy operation with response
delay(1000)
emit(transformedResponse)
}
再次回到Flow的實時數(shù)據(jù)獲取,我們可以看到它在觀察數(shù)據(jù)流的同時更新文本字段,并沒有阻塞UI。
class WeatherForecastDataStreamFragment : DaggerFragment() {
...
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// Obtain viewModel
viewModel = ViewModelProviders.of(
this,
viewModelFactory
).get(WeatherForecastDataStreamViewModel::class.java)
// Observe weather forecast data stream
viewModel.weatherForecast.observe(viewLifecycleOwner, Observer {
when (it) {
Result.Loading -> {
Toast.makeText(context, "Loading", Toast.LENGTH_SHORT).show()
}
is Result.Success -> {
// Update weather data
tvDegree.text = it.data.toString()
}
Result.Error -> {
Toast.makeText(context, "Error", Toast.LENGTH_SHORT).show()
}
}
})
lifecycleScope.launch {
while (true) {
delay(1000)
// Update text
tvDegree.text = "Not blocking"
}
}
}
}
那么它將看起來像這樣:
用Flow改變你的應(yīng)用程序的主題
由于Flow可以發(fā)出實時更新,我們可以把用戶的輸入看作是一種更新,并通過Flow發(fā)送。為了做到這一點,讓我們創(chuàng)建一個主題數(shù)據(jù)源,它有一個用于廣播更新的主題channel。
class ThemeDataSource @Inject constructor(
private val sharedPreferences: SharedPreferences
) {
private val themeChannel: ConflatedBroadcastChannel<Theme> by lazy {
ConflatedBroadcastChannel<Theme>().also { channel ->
// When there is an access to theme channel
// get the current theme from shared preferences
// and send it to consumers
val theme = sharedPreferences.getString(
Constants.PREFERENCE_KEY_THEME,
null
) ?: Theme.LIGHT.name // Default theme is light
channel.offer(Theme.valueOf(theme))
}
}
@FlowPreview
fun getTheme(): Flow<Theme> {
return themeChannel.asFlow()
}
fun setTheme(theme: Theme) {
// Save theme to shared preferences
sharedPreferences
.edit()
.putString(Constants.PREFERENCE_KEY_THEME, theme.name)
.apply()
// Notify consumers
themeChannel.offer(theme)
}
}
// Used to change the theme of the app
enum class Theme {
DARK, LIGHT
}
正如你所看到的,沒有從外部直接訪問themeChannel,themeChannel在被發(fā)送之前被轉(zhuǎn)換為Flow。
在Activity層面上消費主題更新是更好的,因為所有來自其他Fragment的更新都可以被安全地觀察到。
讓我們在ViewModel中獲取主題更新。
class MainViewModel @Inject constructor(
private val themeDataSource: ThemeDataSource
) : ViewModel() {
// Whenever there is a change in theme, it will be
// converted to live data
private val _theme: LiveData<Theme> = themeDataSource
.getTheme()
.asLiveData(viewModelScope.coroutineContext)
val theme: LiveData<Theme>
get() = _theme
fun setTheme(theme: Theme) {
themeDataSource.setTheme(theme)
}
}
而且在Activity中可以很容易地觀察到這一點。
class MainActivity : DaggerAppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
...
observeTheme()
}
private fun observeTheme() {
// Observe and update app theme if any changes happen
viewModel.theme.observe(this, Observer { theme ->
when (theme) {
Theme.LIGHT -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
Theme.DARK -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
})
}
}
剩下的事情就是按下Fragment中的按鈕。
class MainFragment : DaggerFragment() {
private lateinit var viewModel: MainViewModel
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
btnDarkMode.setOnClickListener {
// Enable dark mode
viewModel.setTheme(Theme.DARK)
}
}
}
瞧瞧! 剛剛用Flow改變了主題。
2
原文鏈接:https://proandroiddev.com/using-livedata-flow-in-mvvm-part-ii-252ec15cc93a
在第一部分中,我們已經(jīng)看到了如何在資源庫層中使用Flow,以及如何用Flow和LiveData改變應(yīng)用程序的主題。在這篇文章中,我們將看到如何移除LiveData(甚至是MediatorLiveData),在所有層中只使用Flow。我們還將深入研究常見的Flow操作,如map、filter、transform等。最后,我們將實現(xiàn)一個搜索欄的例子,這個例子是由Sean McQuillan在 "Fragmented Podcast - 187: 與Manuel Vivo和Sean McQuillan的Coroutines "中給出的例子,使用了Channel和Flow。
Say ?? to LiveData
使用LiveData可以確保在生命周期所有者銷毀的情況下,你不會泄露任何資源。如果我告訴你,你幾乎可以(后面會解釋為什么不一樣,但幾乎)用Flow獲得同樣的好處呢?
讓我們來看看我們?nèi)绾巫龅竭@一點。
儲存庫
存儲庫層保持不變,因為我們已經(jīng)在返回Flow。
/**
* This method is used to get data stream of fake weather
* forecast data in real time with 1000 ms delay
*/
fun fetchWeatherForecastRealTime() : Flow<Result<Int>> = flow {
// Fake data stream
while (true) {
delay(1000)
// Send a random fake weather forecast data
emit(Result.Success((0..20).random()))
}
}
ViewModel
我們不需要用asLiveData將Flow轉(zhuǎn)換為LiveData,而只是在ViewModel中使用Flow。
之前是這樣的。
class WeatherForecastDataStreamViewModel @Inject constructor(
weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.map {
// Do some heavy operation. This operation will be done in the
// scope of this flow collected. In our case it is the scope
// passed to asLiveData extension function
// This operation will not block the UI
delay(1000)
it
}
.asLiveData(
// Use Default dispatcher for CPU intensive work and
// viewModel scope for auto cancellation when viewModel
// is destroyed
Dispatchers.Default + viewModelScope.coroutineContext
)
val weatherForecast: LiveData<Result<Int>>
get() = _weatherForecast
}
只用Flow,它就變成了。
class WeatherForecastDataStreamFlowViewModel @Inject constructor(
weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
val weatherForecast: Flow<Result<Int>>
get() = _weatherForecast
}
但是,等等。map過程缺少了,讓我們添加它,以便在繪制地圖時將攝氏溫度轉(zhuǎn)換為華氏溫度。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.map {
// Do some heavy mapping
delay(500)
// Let's add an additional mapping to convert
// celsius degree to Fahrenheit
if (it is Result.Success) {
val fahrenheitDegree = convertCelsiusToFahrenheit(it.data)
Result.Success(fahrenheitDegree)
} else it // Do nothing if result is loading or error
}
/**
* This function converts given [celsius] to Fahrenheit.
*
* Fahrenheit degree = Celsius degree * 9 / 5 + 32
*
* @return Fahrenheit integer for [celsius]
*/
private fun convertCelsiusToFahrenheit(celsius: Int) = celsius * 9 / 5 + 32
你可能想在用戶界面中顯示加載,那么onStart就是一個完美的地方。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart {
emit(Result.Loading)
}
.map { ... }
如果你想過濾數(shù)值,那就去吧。你有過濾運算符。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.filter {
// There could be millions of data when filtering
// Do some filtering
delay(2000)
// Let's add an additional filtering to take only
// data which is less than 10
if (it is Result.Success) {
it.data < 10
} else true // Do nothing if result is loading or error
}
.map { ... }
你也可以用transform操作符對數(shù)據(jù)進行轉(zhuǎn)換,這使你可以靈活地對一個單一的值發(fā)出你想要的信息。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.filter { ... }
.map { ... }
.transform {
// Let's send only even numbers
if (it is Result.Success && it.data % 2 == 0) {
val evenDegree = it.data
emit(Result.Success(evenDegree))
// You can call emit as many as you want in transform
// This makes transform different from filter operator
} else emit(it) // Do nothing if result is loading or error
}
由于Flow是順序的,collecting一個值的總執(zhí)行時間是所有運算符的執(zhí)行時間之和。如果你有一個長期運行的運算符,你可以使用buffer,這樣直到buffer的所有運算符的執(zhí)行將在一個不同的coroutine中處理,而不是在協(xié)程中對Flow collect。這使得總的執(zhí)行速度更快。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.filter { ... }
// onStart and filter will be executed on a different
// coroutine than this flow is collected
.buffer()
// The following map and transform will be executed on the same
// coroutine which this flow is collected
.map { ... }
.transform { ... }
如果你不想多次收集相同的值呢?那么你就可以使用distinctUntilChanged操作符,它只在值與前一個值不同時發(fā)送。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.distinctUntilChanged()
.filter { ... }
.buffer()
.map { ... }
.transform { ... }
比方說,你只想在顯示在用戶界面之前緩存修改過的數(shù)據(jù)。你可以利用onEach操作符來完成每個值的工作。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.distinctUntilChanged()
.filter { ... }
.buffer()
.map { ... }
.transform { ... }
.onEach {
// Do something with the modified data. For instance
// save the modified data to cache
println("$it has been modified and reached until onEach operator")
}
如果你在所有運算符中做一些繁重的工作,你可以通過使用flowOn運算符簡單地改變整個運算符的執(zhí)行環(huán)境。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.distinctUntilChanged()
.filter { ... }
.buffer()
.map { ... }
.transform { ... }
.onEach { ... }
.flowOn(Dispatchers.Default) // Changes the context of flow
錯誤怎么處理?只需使用catch操作符來捕捉下行流中的任何錯誤。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.distinctUntilChanged()
.filter { ... }
.buffer()
.map { ... }
.transform { ... }
.onEach { ... }
.flowOn(Dispatchers.Default)
.catch { throwable ->
// Catch exceptions in all down stream flow
// Any error occurs after this catch operator
// will not be caught here
println(throwable)
}
如果我們有另一個流要與_weatherForecast流合并呢?(你可能會認為這是一個有多個LiveData源的MediatorLiveData)你可以使用合并函數(shù)來合并任何數(shù)量的流量。
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart { ... }
.distinctUntilChanged()
.filter { ... }
.buffer()
.map { ... }
.transform { ... }
.onEach { ... }
.flowOn(Dispatchers.Default)
.catch { ... }
private val _weatherForecastOtherDataSource = weatherForecastRepository
.fetchWeatherForecastRealTimeOtherDataSource()
// Merge flows when consumer gets
val weatherForecast: Flow<Result<Int>>
get() = merge(_weatherForecast, _weatherForecastOtherDataSource)
最后,我們的ViewModel看起來像這樣。
@ExperimentalCoroutinesApi
class WeatherForecastDataStreamFlowViewModel @Inject constructor(
weatherForecastRepository: WeatherForecastRepository
) : ViewModel() {
private val _weatherForecastOtherDataSource = weatherForecastRepository
.fetchWeatherForecastRealTimeOtherDataSource()
private val _weatherForecast = weatherForecastRepository
.fetchWeatherForecastRealTime()
.onStart {
emit(Result.Loading)
}
.distinctUntilChanged()
.filter {
// There could be millions of data when filtering
// Do some filtering
delay(2000)
// Let's add an additional filtering to take only
// data which is less than 10
if (it is Result.Success) {
it.data < 10
} else true // Do nothing if result is loading or error
}
.buffer()
.map {
// Do some heavy mapping
delay(500)
// Let's add an additional mapping to convert
// celsius degree to Fahrenheit
if (it is Result.Success) {
val fahrenheitDegree = convertCelsiusToFahrenheit(it.data)
Result.Success(fahrenheitDegree)
} else it // Do nothing if result is loading or error
}
.transform {
// Let's send only even numbers
if (it is Result.Success && it.data % 2 == 0) {
val evenDegree = it.data
emit(Result.Success(evenDegree))
} else emit(it) // Do nothing if result is loading or error
}
.onEach {
// Do something with the modified data. For instance
// save the modified data to cache
println("$it has modified and reached until onEach operator")
}
.flowOn(Dispatchers.Default) // Changes the context of flow
.catch { throwable ->
// Catch exceptions in all down stream flow
// Any error occurs after this catch operator
// will not be caught here
println(throwable)
}
// Merge flows when consumer gets
val weatherForecast: Flow<Result<Int>>
get() = merge(_weatherForecast, _weatherForecastOtherDataSource)
/**
* This function converts given [celsius] to Fahrenheit.
*
* Fahrenheit degree = Celsius degree * 9 / 5 + 32
*
* @return Fahrenheit integer for [celsius]
*/
private fun convertCelsiusToFahrenheit(celsius: Int) = celsius * 9 / 5 + 32
}
唯一剩下的就是Fragment中對Flow實現(xiàn)collect。
class WeatherForecastDataStreamFlowFragment : DaggerFragment() {
...
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
// Obtain viewModel
viewModel = ViewModelProviders.of(
this,
viewModelFactory
).get(WeatherForecastDataStreamFlowViewModel::class.java)
// Consume data when fragment is started
lifecycleScope.launchWhenStarted {
// Since collect is a suspend function it needs to be called
// from a coroutine scope
viewModel.weatherForecast.collect {
when (it) {
Result.Loading -> {
Toast.makeText(context, "Loading", Toast.LENGTH_SHORT).show()
}
is Result.Success -> {
// Update weather data
tvDegree.text = it.data.toString()
}
Result.Error -> {
Toast.makeText(context, "Error", Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
這些只是部分Flow運算符。你可以從這里找到整個操作符的列表。
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/index.html
注意:移除LiveData會增加配置變化的額外工作。為了保留配置變化,你需要緩存最新的值。你可以從這里查看Dropbox存儲庫如何處理緩存。
Search bar using Channel and Flow
在這個播客中,Sean McQuillan舉了一個例子,說明如何使用Channel和Flow創(chuàng)建一個搜索欄。這個想法是要有一個帶有過濾列表的搜索欄。每當用戶在搜索欄中輸入一些東西時,列表就會被搜索欄中的文本過濾掉。這是通過在channel中保存文本值和觀察通過該channel的流量變化來實現(xiàn)的。
為了演示這個例子,讓我們有一個城市列表和一個搜索欄。最后,它看起來會是這樣的。
我們將在Fragment里有一個EditText。每當文本被更新時,我們將把它發(fā)送到存儲在ViewModel中的channel。
etCity.doAfterTextChanged {
val key = it.toString()
// Set loading indicator
pbLoading.show()
// Offer the current text to channel
viewModel.cityFilterChannel.offer(key)
}
當channel被更新為最新值時,我們將過濾城市并將列表發(fā)送給訂閱者。
class SearchCityViewModel @Inject constructor() : ViewModel() {
val cityList = listOf(
"Los Angeles", "Chicago", "Indianapolis", "Phoenix", "Houston",
"Denver", "Las Vegas", "Philadelphia", "Portland", "Seattle"
)
// Channel to hold the text value inside search box
val cityFilterChannel = ConflatedBroadcastChannel<String>()
// Flow which observes channel and sends filtered list
// whenever there is a update in the channel. This is
// observed in UI to get filtered result
val cityFilterFlow: Flow<List<String>> = cityFilterChannel
.asFlow()
.map {
// Filter cities with new value
val filteredCities = filterCities(it)
// Do some heavy work
delay(500)
// Return the filtered list
filteredCities
}
override fun onCleared() {
super.onCleared()
// Close the channel when ViewModel is destroyed
cityFilterChannel.close()
}
/**
* This function filters [cityList] if a city contains
* the given [key]. If key is an empty string then this
* function does not do any filtering.
*
* @param key Key to filter out the list
*
* @return List of cities containing the [key]
*/
private fun filterCities(key: String): List<String> {
return cityList.filter {
it.contains(key)
}
}
}
然后,只需觀察Fragment中的變化。
lifecycleScope.launchWhenStarted {
viewModel.cityFilterFlow.collect { filteredCities ->
// Hide the progress bar
pbLoading.hide()
// Set filtered items
adapter.setItems(filteredCities)
}
}
好了,我們剛剛實現(xiàn)了一個使用channel和流??的搜索和過濾機制。
3
https://proandroiddev.com/using-livedata-flow-in-mvvm-part-iii-8703d305ca73
第三篇文章主要是針對Flow的測試,這篇文章我相信大家在國內(nèi)幾乎用不上,所以,感興趣的朋友可以自己去看下。
向大家推薦下我的網(wǎng)站 https://xuyisheng.top/ 專注 Android-Kotlin-Flutter 歡迎大家訪問
向大家推薦下我的網(wǎng)站 https://xuyisheng.top/ 點擊原文一鍵直達
專注 Android-Kotlin-Flutter 歡迎大家訪問
往期推薦
更文不易,點個“三連”支持一下??
