Jetpack 之Glance+Compose實現一個小元件

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第18天,點選檢視活動詳情

Glance,官方對其解釋是使用 Jetpack Compose 樣式的 API 構建遠端 Surface 的佈局,通俗的講就是使用Compose風格的API來搭建小外掛佈局,其最新版本是2022年2月23日更新的1.0.0-alpha03。眾所周知,Compose樣式的API與原生差別不小,至於widget這塊改動如何,接下來讓我們來一探究竟。

宣告依賴項

第一步肯定要新增對應依賴,相應的都是在build.gradle中新增,如果你的工程還沒支援Compose,要先新增: android {     buildFeatures {         compose = true     }     composeOptions {         kotlinCompilerExtensionVersion = "1.1.0-beta03"     }     kotlinOptions {         jvmTarget = "1.8"     } } 如果已經支援,上述依賴可以省略,但下述依賴不能省略,繼續新增: dependencies { implementation("androidx.glance:glance-appwidget:1.0.0-alpha03") implementation("androidx.glance:glance-wear-tiles:1.0.0-alpha03") } 以上是官方的標準依賴方式,同樣以下面這種方式依賴也可以: implementation 'androidx.glance:glance-appwidget:+' implementation 'androidx.glance:glance:+' implementation "androidx.glance:glance-appwidget:1.0.0-alpha03"

建立對應 widget

首先編寫對應佈局,放在對應/layout/xml目錄下:

widget_info.xml ```

我在上一篇介紹widget的文章中說過,widget其實就是個廣播,廣播屬於四大元件,而四大元件都要在AndroidManifest清單檔案中註冊: 對應CounterWidgetReceiver程式碼為: import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import com.ktfly.comapp.ui.theme.CounterWidget

class CounterWidgetReceiver : GlanceAppWidgetReceiver(){ override val glanceAppWidget: GlanceAppWidget = CounterWidget() } 可能看到這裡你就迷惑了,widget對應廣播類不是要繼承AppWidgetProvider然後實現相應方法的嗎,其實Glance提供的GlanceAppWidgetReceiver類就已經繼承了AppWidgetProvider,我們使用Glance需要GlanceAppWidgetReceiver: abstract class GlanceAppWidgetReceiver : AppWidgetProvider() {

private companion object {
    private const val TAG = "GlanceAppWidgetReceiver"
}

/**
 * Instance of the [GlanceAppWidget] to use to generate the App Widget and send it to the
 * [AppWidgetManager]
 */
abstract val glanceAppWidget: GlanceAppWidget

@CallSuper
override fun onUpdate(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetIds: IntArray
) {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
        Log.w(
            TAG,
            "Using Glance in devices with API<23 is untested and might behave unexpectedly."
        )
    }
    goAsync {
        updateManager(context)
        appWidgetIds.map { async { glanceAppWidget.update(context, appWidgetManager, it) } }
            .awaitAll()
    }
}

@CallSuper
override fun onAppWidgetOptionsChanged(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
    newOptions: Bundle
) {
    goAsync {
        updateManager(context)
        glanceAppWidget.resize(context, appWidgetManager, appWidgetId, newOptions)
    }
}

@CallSuper
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
    goAsync {
        updateManager(context)
        appWidgetIds.forEach { glanceAppWidget.deleted(context, it) }
    }
}

private fun CoroutineScope.updateManager(context: Context) {
    launch {
        runAndLogExceptions {
            GlanceAppWidgetManager(context)
                .updateReceiver([email protected], glanceAppWidget)
        }
    }
}

override fun onReceive(context: Context, intent: Intent) {
    runAndLogExceptions {
        if (intent.action == Intent.ACTION_LOCALE_CHANGED) {
            val appWidgetManager = AppWidgetManager.getInstance(context)
            val componentName =
                ComponentName(context.packageName, checkNotNull(javaClass.canonicalName))
            onUpdate(
                context,
                appWidgetManager,
                appWidgetManager.getAppWidgetIds(componentName)
            )
            return
        }
        super.onReceive(context, intent)
    }
}

}

private inline fun runAndLogExceptions(block: () -> Unit) { try { block() } catch (ex: CancellationException) { // Nothing to do } catch (throwable: Throwable) { logException(throwable) } } ``` 基本流程方法跟原生widget的差別不大,其含義也無差別,如果對原生Widget不太瞭解的同學可以翻閱我上一篇文章,這裡還有官方註釋:"Using Glance in devices with API<23 is untested and might behave unexpectedly."。在6.0版本以下的Android系統上使用Glance的情況未經測試可能有出乎意料的情況發生。在開始編寫widget程式碼之前,我們先來了解下其使用元件與Compose中的對應元件的些許差別。

差別

根據官方提示,可使用的Compose組合項如下:Box、Row、Column、Text、Button、LazyColumn、Image、Spacer。原生widget是不支援自定義View的,但Compose能通過自定義元件的方式來“自定義”出我們想要的檢視,這一點來看相對更加靈活。

Compose中使用的修飾符是Modifier,這裡修飾可組合項的修飾符是GlanceModifier,使用方式並無二致,其餘元件也有些許差異,這個我們放到後面來說,

Action

以前使用widget跳轉頁面啥的,都離不開PendingIntent,但是Glance中則採取另一套方式:

actionStartActivity

看函式命名就得知,通過Action啟動Activity。共有三種使用方式: ``` // 通過包名啟動Activity public fun actionStartActivity(     componentName: ComponentName,     parameters: ActionParameters = actionParametersOf() ): Action = StartActivityComponentAction(componentName, parameters)

// 直接啟動Activity public fun actionStartActivity(     activity: Class,     parameters: ActionParameters = actionParametersOf() ): Action = StartActivityClassAction(activity, parameters)

//呼叫actionStartActivity啟動Activity,行內函數 public inline fun actionStartActivity(     parameters: ActionParameters = actionParametersOf() ): Action = actionStartActivity(T::class.java, parameters)\ ```

其對應的使用方式也簡單:

Button(text = "Jump", onClick = actionStartActivity(ComponentName("com.ktfly.comapp","com.ktfly.comapp.page.ShowActivity"))) Button(text = "Jump", onClick = actionStartActivity<ShowActivity>()) Button(text = "Jump", onClick = actionStartActivity(ShowActivity::class.java))

actionRunCallback

顧名思義,此函式是通過Action執行Callback,以下是官方提供的使用說明:\ ``` fun actionRunCallback(     callbackClass: Class,      parameters: ActionParameters = actionParametersOf() ): Action

inline fun actionRunCallback(parameters: ActionParameters = actionParametersOf()): Action ```

使用方式:

先建立一個繼承actionRunCallback的回撥類: class ActionDemoCallBack : ActionCallback { override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) { TODO("Not yet implemented") } } 然後在控制元件中呼叫: ``` Button(text = "CallBack", onClick = actionRunCallback())

Button(text = "CallBack", onClick = actionRunCallback(ActionDemoCallBack::class.java))\ ```

actionStartService

此函式是通過Action啟動Service,有以下四個使用方式: ``` fun actionStartService(     intent: Intent,      isForegroundService: Boolean = false ): Action

fun actionStartService(     componentName: ComponentName,      isForegroundService: Boolean = false ): Action

fun actionStartService(     service: Class,      isForegroundService: Boolean = false ): Action  

inline fun actionStartService(isForegroundService: Boolean = false): Action 這裡的isForegroundService引數含義是此服務是前臺服務。在呼叫之前也需要先建立對應Service: class ActionDemoService : Service() { override fun onBind(intent: Intent?): IBinder? { TODO("Not yet implemented") } } ``` 其在控制元件中使用方式如下:

``` Button(text = "start", onClick = actionStartService())

Button(text = "start", onClick = actionStartService(ActionDemoService::class.java)) ```

actionStartBroadcastReceiver

此函式是通過Action啟動BroadcastReceiver,有以下使用方式:

``` fun actionSendBroadcast(     action: String,      componentName: ComponentName? = null ): Action

fun actionSendBroadcast(intent: Intent): Action

fun actionSendBroadcast(componentName: ComponentName): Action

fun actionSendBroadcast(receiver: Class): Action

inline fun actionSendBroadcast(): Action

fun actionStartActivity(     intent: Intent,      parameters: ActionParameters = actionParametersOf() ): Action ``` 其各函式用法跟actionStartActivity函式差不多,這裡不做贅述。你會發現以上函式中經常出現ActionParameters。其實ActionParameters就是給Action提供引數,這裡不做贅述。

建立widget

建立對應的widget類,通過GlanceStateDefinition來保留GlanceAppWidget的狀態,通過點選事件回撥自定義的ActionCallBack達到更改widget中數字的目的: ``` import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.intPreferencesKey import androidx.glance. import androidx.glance.action.ActionParameters import androidx.glance.action.actionParametersOf import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.action.ActionCallback import androidx.glance.appwidget.action.actionRunCallback import androidx.glance.appwidget.state.updateAppWidgetState import androidx.glance.layout. import androidx.glance.state.GlanceStateDefinition import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider

private val countPreferenceKey = intPreferencesKey("widget-key") private val countParamKey = ActionParameters.Key("widget-key")

class CounterWidget : GlanceAppWidget(){

override val stateDefinition: GlanceStateDefinition<*> =
    PreferencesGlanceStateDefinition

@Composable
override fun Content(){
    val prefs = currentState<Preferences>()
    val count = prefs[countPreferenceKey] ?: 1

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalAlignment = Alignment.CenterVertically,
        modifier = GlanceModifier
            .background(Color.Yellow)
            .fillMaxSize()
    ) {
        Text(
            text = count.toString(),
            modifier = GlanceModifier.fillMaxWidth(),
            style = TextStyle(
                textAlign = TextAlign.Center,
                color = ColorProvider(Color.Blue),
                fontSize = 50.sp
            )
        )

        Spacer(modifier = GlanceModifier.padding(8.dp))

        Button(
            text = "變兩倍",
            modifier = GlanceModifier
                .background(Color(0xFFB6C0C9))
                .size(100.dp,50.dp),
            onClick = actionRunCallback<UpdateActionCallback>(
                parameters = actionParametersOf(
                    countParamKey to (count + count)
                )
            )
        )
    }
}

}

class UpdateActionCallback : ActionCallback{ override suspend fun onRun(context: Context, glanceId: GlanceId, parameters: ActionParameters) {

    val count = requireNotNull(parameters[countParamKey])

    updateAppWidgetState(
        context = context,
        definition = PreferencesGlanceStateDefinition,
        glanceId = glanceId
    ){ preferences ->
        preferences.toMutablePreferences()
            .apply {
                this[countPreferenceKey] = count
            }
    }

    CounterWidget().update(context,glanceId)
}

} ``` 執行後效果如下:

Glance- Widget.gif

也許你會發現上述導包與平常Compose導包不一樣:

image.gif

控制元件導的包都是glance包下的,當然不僅是Column,還有Button、Image等引數都有變化,但變化不大,例如Image的差異: ``` 原Compose中: Image(         modifier = modifier,         painter = BitmapPainter(bitmap),         contentDescription = "",         contentScale = contentScale     )

Image(        modifier = modifier,        painter = painterResource(資源id),        contentDescription = "",        contentScale = contentScale     )

Glance中: public fun Image(     provider: ImageProvider,     contentDescription: String?,     modifier: GlanceModifier = GlanceModifier,     contentScale: ContentScale = ContentScale.Fit ) ```

其餘控制元件差異大同小異,這裡不做贅述。