安稳的 Glance 来了,安卓小部件有救了!

安稳版别的 Glance 总算发布了,来一起看看吧,看看这一路的旅程,看看好用么,再看看怎样运用!

前世今生

故事发生在两年的一天吧,其实夸张了,不到两年,而是 633 天前。。。

稳定的 Glance 来了,安卓小部件有救了!

Jetpack 的更新网站上发现多了一个名叫 Glance 的库,版别为 1.1.0-alpha01,发现这个库后就从速点击进去看看是干啥用的:

稳定的 Glance 来了,安卓小部件有救了!

看到这个库的简介的时分给我高兴坏了,大致意思是:能够运用 Compose 风格的 API 来为小部件构建布局。然后就尝试了下并写了一篇文章:Jetpack Glance?小部件的春天来了

小部件这个东西尽管是安卓中首先发布的,可是这么多年来一直平平无奇,直到苹果 IOS 中也“推出”了小部件之后,才唤起了小部件的第二春,然后安卓官方、也便是谷歌才想起来自己本来也有这么个东西,就在 Android 12 中才对小部件做了一些改进,不简单啊,这么多年来第一次给安卓小部件增加了一些内容。。。

之后接着官方也看不下去了,看不下去什么呢?多年前的安卓开发运用起小部件没有问题,可是现在的安卓开发变为了 Compose ,而小部件仍是只能运用 XML ,于是乎,Glance 应运而生!

短短几行字,基本聊了下 Glance 的前世今生,一个库,要 635 天才能从 alpha 版别变为 stable,假如再加上第一个 alpha 版别的开发时间的话,肯定超过了两年。。。这个速度假如放到国内的话。。。。算了,咱们了解就好。其实也不能怪他们,Jetpack 中的库实在是太多了,都需求时间和人力维护嘛!

下面再来看一下 Glance 的发布时间线吧:

稳定的 Glance 来了,安卓小部件有救了!

没有辜负我这么久的等待,哈哈哈!

之前那篇文章运用的是我写的一个天气,这回改下,改为运用 “玩安卓” 吧!

本文中的代码地址:玩安卓 Github:https://github.com/zhujiang521/PlayAndroid

增加依靠

dependencies {
  implementation "androidx.glance:glance:1.0.0"
}
​
android {
  buildFeatures {
    compose true
  }
​
  composeOptions {
    kotlinCompilerExtensionVersion = "1.5.3"
  }
}

依靠增加很简略,假如你的项目中有 Compose 的话,只需求增加下 dependencies 中的内容即可。

创立小部件

首先来创立一个小部件,咱们都知道,小部件其实便是一个 BroadcastReceiver,所以需求在 AndroidManifest 中声明下:

<receiver
  android:name=".widget.ArticleListWidget"
  android:exported="false">
  <intent-filter>
    <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
  </intent-filter>
​
  <meta-data
    android:name="android.appwidget.provider"
    android:resource="@xml/article_list_widget_info" />
</receiver>

上面的代码大部分咱们都很了解了,唯一和普通播送不同的便是多了一个装备项,假如写过小部件的应该也很了解了:

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
  android:description="@string/app_widget_description"
  android:initialKeyguardLayout="@layout/glance_default_loading_layout"
  android:initialLayout="@layout/glance_default_loading_layout"
  android:minWidth="110dp"
  android:minHeight="69dp"
  android:minResizeWidth="110dp"
  android:minResizeHeight="69dp"
  android:resizeMode="horizontal|vertical"
  android:targetCellWidth="2"
  android:targetCellHeight="2"
  android:updatePeriodMillis="86400000"
  android:widgetCategory="home_screen" />

这儿的装备项其实不少,上面所列举的仅仅常用的一些,那到底都能够装备那些项呢?点进去看看不得了!

<declare-styleable name="AppWidgetProviderInfo">
  <!-- AppWidget的最小宽度 -->
  <attr name="minWidth"/>
  <!-- AppWidget的最小高度 -->
  <attr name="minHeight"/>
  <!-- AppWidget能够调整巨细的最小宽度. -->
  <attr name="minResizeWidth" format="dimension"/>
  <!-- AppWidget能够调整巨细的最小高度. -->
  <attr name="minResizeHeight" format="dimension"/>
  <!-- AppWidget能够调整巨细的最大宽度. -->
  <attr name="maxResizeWidth" format="dimension"/>
  <!-- AppWidget能够调整巨细的最大高度. -->
  <attr name="maxResizeHeight" format="dimension"/>
  <!-- AppWidget的默许宽度,以桌面网格单元为单位 -->
  <attr name="targetCellWidth" format="integer"/>
  <!-- AppWidget的默许高度,以桌面网格单元为单位 -->
  <attr name="targetCellHeight" format="integer"/>
  <!-- 更新周期(以毫秒为单位),假如AppWidget将更新自己,则为0 -->
  <attr name="updatePeriodMillis" format="integer" />
  <!-- 初始布局的资源id -->
  <attr name="initialLayout" format="reference" />
  <!-- 初始Keyguard布局的资源id -->
  <attr name="initialKeyguardLayout" format="reference" />
  <!-- 要发动装备的AppWidget包中的类名。假如没有供给,则不会发动任何活动 -->
  <attr name="configure" format="string" />
  <!-- 在可制作的资源id中预览AppWidget装备后的姿态。假如没有供给,则将运用AppWidget的图标 -->
  <attr name="previewImage" format="reference" />
  <!-- 预览AppWidget装备后的姿态的布局资源id。与previewImage不同,previewLayout能够更好地在不同的区域、体系主题、显现巨细和密度等方面展现AppWidget。假如供给了,它将优先于支持的小部件主机上的previewImage。不然,将运用previewImage -->
  <attr name="previewLayout" format="reference" />
  <!-- AppWidget子视图的视图id,应该是主动高级的。经过小部件的主机 -->
  <attr name="autoAdvanceViewId" format="reference" />
  <!-- 可选参数,指示是否以及怎样调整此小部件的巨细。支持运用|运算符组合值,也便是说能够横向和纵向能够同时运用 -->
  <attr name="resizeMode" format="integer">
    <flag name="none" value="0x0" />
    <flag name="horizontal" value="0x1" />
    <flag name="vertical" value="0x2" />
  </attr>
  <!-- 可选参数,指示能够显现此小部件的方位,即。主屏幕,键盘维护,搜索栏或其任何组合. -->
  <attr name="widgetCategory" format="integer">
    <flag name="home_screen" value="0x1" />
    <flag name="keyguard" value="0x2" />
    <flag name="searchbox" value="0x4" />
  </attr>
  <!-- 指示小部件支持的各种特性的标志。这些是对小部件主机的提示,实际上并不改动小部件的行为 -->
  <attr name="widgetFeatures" format="integer">
    <!-- 小部件能够在绑定后随时重新装备 -->
    <flag name="reconfigurable" value="0x1" />
    <!-- 小部件由应用程序直接增加,不需求出现在可用小部件的大局列表中 -->
    <flag name="hide_from_picker" value="0x2" />
    <!-- 小部件供给了一个默许装备。主机或许决议不发动所供给的装备活动 -->
    <flag name="configuration_optional" value="0x4" />
  </attr>
  <!-- 包含小部件简略描述的字符串的资源标识符 -->
  <attr name="description" />
</declare-styleable>

由于装备项确实不少,所以直接写了下注释,咱们依据需求进行运用即可,现在这是所有的小部件装备项,有一些是在 Android 12 中新增的。

工欲善其事,必先利其器

装备项写好了,接下来该编写小部件的代码了!

GlanceAppWidgetReceiver

之前编写小部件的时分都会用到 AppWidgetProvider ,它承继自 BroadcastReceiver ,但现在运用 Glance 需求承继 GlanceAppWidgetReceiver ,那么 GlanceAppWidgetReceiver 是个啥?来,3、2、1,上代码!

abstract class GlanceAppWidgetReceiver : AppWidgetProvider() {
​
   ......
​
  /**
   * 用于生成AppWidget并将其发送给AppWidgetManager的GlanceAppWidget的实例
   * 注意:这不会为GlanceAppWidget设置CoroutineContext,它将始终在主线程上运转。
   */
  abstract val glanceAppWidget: GlanceAppWidget
    ......
}

经过上面代码能够看出 GlanceAppWidgetReceiver 承继自 AppWidgetProvider ,是一个抽象类,而且需求完成一个抽象函数 glanceAppWidget ,这个函数需求回来的目标为 GlanceAppWidget

GlanceAppWidget

那就再来看下 GlanceAppWidget 吧,来,3、2、1,上代码!

abstract class GlanceAppWidget(
  @LayoutRes
  internal val errorUiLayout: Int = R.layout.glance_error_layout,
) {
​
    ......
 
  /**
   * 重写此函数以供给 Glance Composable
   */
  abstract suspend fun provideGlance(
    context: Context,
    id: GlanceId,
   )/**
   * 界说对巨细的处理。
   */
  open val sizeMode: SizeMode = SizeMode.Single
​
  /**
   * 特定于视图的小部件数据的数据存储。
   */
  open val stateDefinition: GlanceStateDefinition<*>? = PreferencesGlanceStateDefinition
​
  /**
   * 当应用程序小部件从其主机上删去时由结构调用。当该办法回来时,与glanceId相关的状况将被删去。
   */
  open suspend fun onDelete(context: Context, glanceId: GlanceId) {}
 
    ......
}

能够看到 GlanceAppWidget 也是一个抽象类,构建这个类时有一个可选参数,意思是遇到错误时需求展现的布局。然后有几个子类能够重写的函数,还有一个有必要完成的抽象函数,下面来分别看下吧:

  • provideGlance 此函数为抽象函数,子类有必要重写;重写此函数以供给 Glance Composable,也便是说这个函数是用来编写布局的。一旦数据准备好,运用 provideContent 供给可组合目标。provideGlance 作为 CoroutineWorker 在后台运转,以响应 updateupdateAll 的调用,以及来自Launcher 的恳求。在 provideContent 被调用之前,provideGlance 受限于 WorkManager 时间限制(现在为十分钟),在调用 provideContent 之后,组合持续运转并重新组合大约45秒。当接收到UI交互或更新恳求时,会增加额外的时间来处理这些恳求。需求注意的是:假如 provideGlance 已经在运转,updateupdateAll 不会重新发动。因此应该在调用 provideContent 之前加载初始数据,然后在组合中调查数据源(例如 collectasstate)。这能够保证小部件在组合处于活动状况时持续更新,当从应用程序的其他地方更新数据源时,保证调用update,以防这个小部件的Worker当时没有运转。
  • sizeMode 界说对小部件巨细的处理,这个会在下面打开来说
  • stateDefinition 特定于视图的小部件数据的数据存储,当存储数据发生改变时,小部件会进行改写
  • onDelete 应用程序小部件从其主机上删去时由结构调用。当该办法回来时,与glanceId相关的状况将被删去。

SizeMode

OK,上面简略看了下 GlanceAppWidget 中的公开函数,接下来看下 SizeMode ,老规矩,3、2、1,上代码!

sealed interface SizeMode {
  /**
   * GlanceAppWidget供给了一个UI。LocalSize将是AppWidget的最小尺度,在AppWidget供给程序信息中界说,单个
   */
  object Single : SizeMode {
    override fun toString(): String = "SizeMode.Single"
   }
​
  /**
   * 为每个AppWidget或许显现的巨细供给了一个UI。巨细列表由选项包供给(参见getAppWidgetOptions)。每个巨细都将调用可组合目标。在调用期间,LocalSize将是生成UI的目标。
   */
  object Exact : SizeMode {
    override fun toString(): String = "SizeMode.Exact"
   }
​
  /**
   * 在Android 12及今后的版别中,每个供给的巨细将调用一次composable,而且从巨细到视图的映射将被发送到体系。然后结构将依据App Widget的当时巨细来决议显现哪个视图。在Android 12之前,composable将被调用用于显现App Widget的每个巨细(如Exact)。关于每种尺度,将选择最佳视图,即适合可用空间的最大视图,或许假如不适合则选择最小视图。Params: sizes -要运用的巨细列表,不能为空。
   */
  class Responsive(val sizes: Set<DpSize>) : SizeMode {
​
    init {
      require(sizes.isNotEmpty()) { "The set of sizes cannot be empty" }
     }
​
     ......
   }
}

能够看到 SizeMode 是一个接口,一共有三个类完成了 SizeMode 接口,SingleExact 好了解一些,Responsive 不太好了解,可是还记得 Android 12 中小部件的更新么?RemoteView 增加了一个结构函数,来看下吧:

public RemoteViews(@NonNull Map<SizeF, RemoteViews> remoteViews)

即每个供给的巨细将调用一次 composable ,而且从巨细到视图的映射将被发送到体系,也便是说会将界说好的巨细做缓存,能够优化小部件的展现。

爱码士

上面说了半天还没进入正题,一行正派代码都还没写。。。

先来搞一个 GlanceAppWidget 吧:

class ArticleListWidgetGlance : GlanceAppWidget() {
​
  override suspend fun provideGlance(context: Context, id: GlanceId) {
    provideContent {
      // 编写 Glance 代码
     }
   }
​
}

预料之中,承继自 GlanceAppWidget ,完成抽象函数 provideGlance ,但仍是无法在 provideGlance 中直接运用 Glance 来编写 Compose 风格的布局,还需求调用 provideContent ,上面其实也说到过了,那就来看下 provideContent 吧,3、2、1,上代码!

suspend fun GlanceAppWidget.provideContent(
  content: @Composable @GlanceComposable () -> Unit
): Nothing {
  coroutineContext[ContentReceiver]?.provideContent(content)
    ?: error("provideContent requires a ContentReceiver and should only be called from " + "GlanceAppWidget.provideGlance")
}

能够看到这是一个扩展函数,只有一个参数,看到这个参数是不是就了解了,总算看到了咱们了解的 @Composable ,需求注意的是:假如此函数与自身并发调用,则前一个调用将抛出 CancellationException,新内容将替换它。还有便是这个函数只能从 GlanceAppWidget.provideGlance 调用。

OK,GlanceAppWidget 编好了之后就该写下 GlanceAppWidgetReceiver 了,上代码!

class ArticleListWidget : GlanceAppWidgetReceiver() {
  override val glanceAppWidget: GlanceAppWidget = ArticleListWidgetGlance()
}

更简略了,只有三行代码,同样地,也完成了 GlanceAppWidgetReceiver 的抽象函数,并回来了刚创立好的 ArticleListWidget

其实到这儿为止 Glance 的整套流程就简略跑通了。接下来就来编写下布局吧:

override suspend fun provideGlance(context: Context, id: GlanceId) {
  val articleList = getArticleList()
  provideContent {
    GlanceTheme {
      Column {
        Text(
          text = stringResource(id = R.string.widget_name),
         )
        LazyColumn {
          items(articleList) { data ->
            GlanceArticleItem(context, data)
           }
         }
       }
     }
   }
}

啊!了解的配方!了解的滋味!

爽,爽,爽

看着上面了解的滋味是不是很舒畅,哈哈哈,写小部件总算也能够优雅一些了!

耗时操作优化

不知道咱们注意到没有,provideGlance 竟然是一个挂起函数,这是什么意思,莫非是???

没错!能够放心地在这儿履行耗时操作了!比方你就能够这样:

override suspend fun provideGlance(context: Context, id: GlanceId) {
  val name = getName()
  provideContent {
    Text(text = name)
   }
}
​
private suspend fun getName():String {
  delay(5000L)
  return "我喜欢你啊"
}

下面来运转看下作用!

稳定的 Glance 来了,安卓小部件有救了!

是不是挺好,解决了小部件的一大坑!

小部件更新

小部件的更新一直也是个问题,比方反正屏转换后小部件的改写、体系装备修改了之后的改写,这些都是没有的,体系应用能够和体系进行一些骚操作,可是普通应用不能够啊,所以 Glance 中就引入了 WorkManager 来改进这个问题,最低能够设置十分钟的距离改写。

下面就来简略看下运用吧:

class WorkWorker(
  private val context: Context,
  workerParameters: WorkerParameters
) : CoroutineWorker(context, workerParameters) {
​
  companion object {
​
    private val uniqueWorkName = WorkWorker::class.java.simpleName
​
    // 排队进行作业
    fun enqueue(context: Context, size: DpSize, glanceId: GlanceId, force: Boolean = false) {
      val manager = WorkManager.getInstance(context)
      val requestBuilder = OneTimeWorkRequestBuilder<WorkWorker>().apply {
        addTag(glanceId.toString())
        setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
        setInputData(
          Data.Builder()
             .putFloat("width", size.width.value.toPx)
             .putFloat("height", size.height.value.toPx)
             .putBoolean("force", force)
             .build()
         )
       }
      val workPolicy = if (force) {
        ExistingWorkPolicy.REPLACE
       } else {
        ExistingWorkPolicy.KEEP
       }
​
      manager.enqueueUniqueWork(
        uniqueWorkName + size.width + size.height,
        workPolicy,
        requestBuilder.build()
       )
     }
​
    /**
     * 撤销任何正在进行的作业
     */
    fun cancel(context: Context, glanceId: GlanceId) {
      WorkManager.getInstance(context).cancelAllWorkByTag(glanceId.toString())
     }
   }
​
  override suspend fun doWork(): Result {
    // 需求履行的操作
    return Result.success()
   }
}

OK,先创立了一个 Work,然后看下在 Glance 中怎样运用吧!

override suspend fun onDelete(context: Context, glanceId: GlanceId) {
  super.onDelete(context, glanceId)
  WorkWorker.cancel(context, glanceId)
}
​
override suspend fun provideGlance(context: Context, id: GlanceId) {
  provideContent {
    val size = LocalSize.current
    GlanceTheme {
      CircularProgressIndicator()
      // 在组成完成后,运用glanceId作为标记为worker排队,以便在小部件实例被删去的状况下撤销所有作业
      val glanceId = LocalGlanceId.current
      SideEffect {
        WorkWorker.enqueue(context, size, glanceId)
       }
     }
   }
}

很简略,在 provideGlance 中排队履行操作,然后在 onDelete 中将 Work 撤销了即可。

快捷的 ListView

写过小部件的都知道 ListView 特别坑,原生小部件想要完成 ListView 需求完成 FactoryService 等,而在 Glance 这儿直接两三行代码搞定。

LazyColumn(
  modifier = GlanceModifier.fillMaxSize().padding(horizontal = 10.dp)
) {
  items(articleList) { data ->
    GlanceArticleItem(context, data)
   }
}

没错,和 Compose 中相同,名字也相同,都是 LazyColumn ,写起来十分快捷。

更便利的 LocalXXX

咱们都知道 Compose 中的 LocalXXX 十分便利好用,Glance 中也供给了一些:

/**
 * 生成的概览视图的巨细。概览视图至少有那么多空间能够显现。切当的含义或许会依据外表及其装备方法而改变。
 */
val LocalSize = staticCompositionLocalOf<DpSize> { error("No default size") }
​
/**
 * 生成概览视图时应用程序的上下文。
 */
val LocalContext = staticCompositionLocalOf<Context> { error("No default context") }
​
/**
 * 本地视图状况,在surface完成中界说。用于特定于视图的状况数据的可定制存储。
 */
val LocalState = compositionLocalOf<Any?> { null }
​
/**
 * 当时组成生成的概览视图的唯一Id。
 */
val LocalGlanceId = staticCompositionLocalOf<GlanceId> { error("No default glance id") }

不过这块需求注意包的导入问题。

Action

小部件中之前假如想要完成点击作用的话只能运用 PendingIntent ,这样很麻烦,现在 Glance 为咱们供给了 Action ,运用办法如下:

Button(text = "Glance按钮", onClick = actionStartActivity(ComponentName("包名","包名+类名")))
Button(text = "Glance按钮", onClick = actionStartActivity<MainActivity>())
Button(text = "Glance按钮", onClick = actionStartActivity(MainActivity::class.java))

不仅如此,还能够像下面这样操作:

Text(text = "点击", modifier = GlanceModifier.clickable {
  Log.e("TAG", "provideGlance: click")
})

这个实在是太便利了!推荐咱们运用。但这个需求注意,假如想运用这个完成动画作用的话是不可的,由于它没有办法在特别短的时间内改写,我之前尝试过 Compose 中的属性动画 animate*AsState ,结果便是只履行了最终的结果,中间过程全部疏忽了。。。。

坑,坑,坑

“人家官方废了这么大劲开发出来的库,怎样能说人家坑呢?”

“由于它确实坑啊!”

坑一

方才看到的了解的代码,其实一点也不了解,为什么这么说,来看下导入的包就知道了:

import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.glance.GlanceModifier
import androidx.glance.GlanceTheme
import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.layout.Alignment
import androidx.glance.layout.Column
import androidx.glance.layout.Row
import androidx.glance.layout.Spacer
import androidx.glance.layout.fillMaxWidth
import androidx.glance.layout.height
import androidx.glance.layout.padding
import androidx.glance.layout.size
import androidx.glance.layout.wrapContentWidth
import androidx.glance.text.Text

尽管 Composable 仍是运用的 Compose 的,可是里边的可组合项全部是 Glance 中重写的。。。。

咱便是说啊!有没有一种或许,便是你在写的时分自然地就导入了 Compose 的包?运转直接报错!也没有任何提醒。。

稳定的 Glance 来了,安卓小部件有救了!

是不是?没有一点提醒,这种状况官方有没有一种或许,便是像是 Glance 中的 Modifier 相同,也在前面加一个前缀,让开发者能够简单区别一点?即使加前缀不好看,你们不想加,有没有或许修改下编译器,让编译器告知开发者不能这么写行不可?

坑二

图片的加载,图片是安卓开发中太常见的东西了,以前咱们运用 ImageView 来进行图片的展现,现在有了 Compose 了咱们运用 Image 来进行展现,Glance 中同样是运用 Image 来展现,来玩个游戏吧,找不同!先来看下 Compose 中的 Image

@Composable
fun Image(
  painter: Painter,
  contentDescription: String?,
  modifier: Modifier = Modifier,
  alignment: Alignment = Alignment.Center,
  contentScale: ContentScale = ContentScale.Fit,
  alpha: Float = DefaultAlpha,
  colorFilter: ColorFilter? = null
)

再来看下 Glance 中的 Image

@Composable
fun Image(
  provider: ImageProvider,
  contentDescription: String?,
  modifier: GlanceModifier = GlanceModifier,
  contentScale: ContentScale = ContentScale.Fit,
  colorFilter: ColorFilter? = null
)

是不是很像,可是 Glance 由于 RemoteView 的限制少了一些功用,在 Compose 中咱们能够经过 painterResource 来构建出 Painter,但在 Glance 中又换了个名字 ImageProvider ,咱便是说啊,有没有一种或许,便是要不你就都学 Compose ,要不你就都不学。。。。

还有便是文字,来看下 Glance 中的 Text 吧:

@Composable
fun Text(
  text: String,
  modifier: GlanceModifier = GlanceModifier,
  style: TextStyle = defaultTextStyle,
  maxLines: Int = Int.MAX_VALUE,
)

尽管 Compose 中的 Text 接收的也是一个 String,可是人家有 stringResource 函数啊,你呢。。。忘写了么?

算了,自己写一个吧:

@Composable
fun stringResource(@StringRes id: Int): String {
  return LocalContext.current.getString(id)
}

这个函数我个人觉得能够放到 Glance 中。。。。

总结

今天所讲的 Glance 其实也是基于 Compose 的,由此可见,Google 现在对 Compose 发力十分足,假如咱们想体系地学习 Compose 的话,能够购买我的新书《Jetpack Compose:Android全新UI编程》进行阅览,里边有完整的 Compose 结构供咱们学习。

京东购买地址

当当购买地址

本文中的代码地址:玩安卓 Github:https://github.com/zhujiang521/PlayAndroid

假如对你有协助的话,别忘记点个 Star,感激不尽,咱们假如有疑问的话能够在谈论区提出来。