Android上的小部件。难得的功能

哈Ha!我叫Alexander Khakimov,我是FINCH的一名Android开发人员。

碰巧您的设计是针对iOS的,而您不得不将其改编成适用于android的软件吗?如果是这样,您的设计师是否经常使用小部件?不幸的是,对于许多开发人员来说,小部件很少见,因为很少有人使用它。

在本文中,我将详细介绍如何创建一个小部件,值得关注并分享我的案例。

小部件创建


要创建小部件,您需要了解以下内容:

  1. 小部件组件的功能。
  2. 在屏幕网格中显示小部件的功能。
  3. 功能部件更新。

我们将分别分析每个项目。

小部件组件的功能


任何至少使用过RemoteViews的开发人员都对此项目很熟悉。如果您是其中之一,请继续进行下一个步骤。

RemoteViews旨在描述和管理属于另一个应用程序中的进程的View层次结构。使用层次结构管理,您可以更改属于另一个应用程序的View的属性或调用方法。RemoteViews包含标准android.widget组件库中的有限组件集。

在内部视图小部件中工作的过程是独立的(通常是主屏幕),因此,要更改小部件的UI,请使用在我们的应用程序中工作的BroadcastReceiver扩展-AppWidgetProvider。

在屏幕的“网格”中显示小部件的功能


实际上,如果您查看官方指南,这一点并不那么复杂
每个小部件都必须定义一个minWidth和minHeight,以指示默认情况下它应消耗的最小空间量。当用户将小部件添加到其主屏幕时,它通常会占用比您指定的最小宽度和高度更多的空间。Android主屏幕为用户提供了可用空间的网格,可以在其中放置小部件和图标。该网格可能因设备而异。例如,许多手机都提供4x4网格,而平板电脑可以提供更大的8x7网格。

翻译成俄文:每个小部件都必须设置自己的最小宽度和高度,以表示默认情况下将占用的最小空间。

图片
在Android Studio中创建时的窗口小部件设置示例

添加到主屏幕的窗口小部件通常会占用比您设置的屏幕的最小宽度和高度更多的空间。 Android主屏幕为用户提供了可用空间的网格,可以在其中放置小部件和图标。该网格可能因设备而异;例如,许多手机提供4x4网格,而平板电脑可以提供8x4大型网格。

由此可见,设备的网格可以是任何东西,并且单元的大小可能会有所不同,具体取决于网格的大小。因此,在设计小部件的内容时应牢记这些功能。

对于给定的列数和行数,小部件的最小宽度和高度可以使用以下公式计算:

minSideSizeDp = 70×n-30,其中n是行数或列数

。目前,您可以设置的最大最小网格为4x4。这样可以确保您的窗口小部件将显示在所有设备上。

功能部件更新


由于AppWidgetProvider本质上是BroadcastReceiver的扩展,因此您可以像使用常规BroadcastReceiver一样对其进行相同的操作。 AppWidgetProvider只需解析onReceive中接收到的Intent中的相应字段,然后使用接收到的附加函数调用拦截方法。

困难之处在于更新内容的频率-整个问题在于iOS和Android上的小部件的内部操作有所不同。事实是,当小部件对用户可见时,iOS小部件上的数据会更新。在Android中,此类事件不存在。我们无法确定用户何时看到窗口小部件。

对于Android上的小部件,建议的更新方法是计时器更新。计时器设置由窗口小部件参数updatePeriodMillis设置。不幸的是,此设置不允许每30分钟更新一次小部件。下面我将更详细地讨论这一点。

小工具箱


进一步,我们将讨论在FINCH的大型彩票申请中的案例,以及Stoloto申请参加州彩票的案例。

该应用程序的任务是简化彩票的选择和彩票的购买并使用户透明。因此,小部件所需的功能非常简单:向用户显示要购买的推荐游戏,然后点按以转到相应的游戏。游戏列表在服务器上确定并定期更新。

在我们的例子中,小部件设计包括两个状态:

  • 对于授权用户
  • 对于未经授权的用户

授权用户需要显示其个人资料数据:内部钱包的状态,等待抽奖的彩票数量以及丢失的彩金。对于这些元素中的每一个,都提供了到应用程序内部屏幕的过​​渡,与其他元素不同。

图片

图片

您可能已经注意到,授权用户的另一个功能是“刷新”按钮,但稍后会介绍更多。

为了实现两种状态的显示,同时考虑到设计,我使用RemoteAdapter作为RemoteViewsService的实现来生成内容卡。

现在有一些代码,以及里面的所有内容。如果您已经有过使用窗口小部件的经验,那么您将知道窗口小部件数据的任何更新均以onUpdate方法开始:

override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        injector.openScope(this, *arrayOf(this))
        // update each of the widgets with the remote adapter
        appWidgetIds
            .forEach {
                updateWidget(context, appWidgetManager, it)
          }
    }

我们正在为小部件的每个实例编写更新。

private fun updateWidget(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetId: Int
    ) {
// remoteViews   widgetId
        val remoteViews = RemoteViews(
            context.packageName,
            R.layout.app_widget_layout
...
//        
...
//    remoteViews
updateRemoteAdapter(context, remoteViews, appWidgetId)
 
//   remoteViews 
appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
// collection view  
appWidgetManager.notifyAppWidgetViewDataChanged(
            appWidgetId,
            R.id.lvWidgetItems
        )
    }

更新适配器。

private fun updateRemoteAdapter(context: Context, remoteViews: RemoteViews, appWidgetId: Int) {
//   RemoteViewsService   RemoteAdapter   
        val adapterIntent = Intent(context, StolotoAppWidgetRemoteViewsService::class.java).apply {
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        }
        remoteViews.setRemoteAdapter(R.id.lvWidgetItems, adapterIntent)
// actionIntent  pendingIntent      
        val actionIntent = Intent(context, StolotoAppWidgetProvider::class.java).apply {
            action = WIDGET_CLICK_ACTION
            putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
        }
        val pendingIntent = PendingIntent.getBroadcast(
            context, 0, actionIntent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )
// pendingIntent      
        remoteViews.setPendingIntentTemplate(R.id.lvWidgetItems, pendingIntent)
    }

我们正在编写服务的实现。在其中,对我们来说重要的是指出要使用哪种RemoteViewsService.RemoteViewsFactory接口的实现来生成内容。

class StolotoAppWidgetRemoteViewsService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
        StolotoAppWidgetRemoteViewsFactory(
            this.applicationContext,
            intent
        )
}

这实际上是适配器的薄包装。多亏了他,我们才能将我们的数据与远程集合视图相关联。RemoteViewsFactory提供了为数据集中的每个项目生成RemoteView的方法。构造函数没有要求-我要做的就是在其中传递上下文。

接下来,简要介绍一下主要方法:

  1. onCreate-创建适配器。
  2. getLoadingView-该方法建议返回视图,系统将在创建视图时显示该视图,而不是列表项。如果您在此处未创建任何内容,则系统将使用某些默认视图。
  3. getViewAt-该方法建议创建列表项。这是RemoteViews的标准用法。
  4. 收到更新列表中数据的请求时,将调用onDataSetChanged。那些。在这种方法中,我们为列表准备数据。通过执行繁重的长代码,该方法变得更加清晰。
  5. 当删除使用适配器的最后一个列表(一个适配器可以被多个列表使用)时,将调用onDestroy。
  6. RemoteViewsFactory处于活动状态,而列表的所有实例均处于活动状态,因此我们可以在其中存储当前数据,例如,当前项目的列表。

定义将显示的数据列表:

private val widgetItems = ArrayList<WidgetItem>()

创建适配器时,我们开始加载数据。在这里,您可以安全地执行任何困难的任务,包括安静地进入网络以阻止流量。

override fun onCreate() {
        updateDataSync()
}

当调用命令更新数据时,我们也调用updateDataSync()

   override fun onDataSetChanged() {
        updateDataSync()
    }

在updateDataSync内部,一切也很简单。我们清除了当前的物品清单。下载个人资料和游戏数据。

 private fun updateDataSync() {
        widgetItems.clear()
        updateProfileSync()
        updateGamesSync()
    }

这里比较有趣

private fun updateProfileSync() {

由于仅向授权用户显示个人资料对我们很重要,因此在这种情况下,我们仅需要下载个人资料信息:

val isUserFullAuth = isUserFullAuthInteractor
            .execute()
            .blockingGet()
        if (isUserFullAuth) {
            val profile = getWidgetProfileInteractor
                .execute()
                .onErrorReturn {
                    WidgetProfile()
//           
                }
                .blockingGet()

WidgetProfile模型是从不同来源组装而成的,因此其收货逻辑及其默认值的排列方式为负的钱包值表示数据不正确或收货有问题。

对于业务逻辑,缺少钱包数据至关重要,因此,如果钱包不正确,将不会创建配置文件模型并将其添加到项目列表中。

  if (profile.walletAmount >= 0L) {
                widgetItems.add(
                    WidgetItem.Profile(
                        wallet = profile.walletAmount.toMoneyFormat(),
                        waitingTickets = if (profile.waitingTicketsCount >= 0) profile.waitingTicketsCount.toString() else "",
                        unpaidPrizeAmount = if (profile.unpaidPrizeAmount >= 0) profile.unpaidPrizeAmoount.toMoneyFormat() else ""
                    )
                )
            }
        }
    }

updateGamesSync()方法使用getWidgetGamesInteractor,并将与该小部件相关的一组游戏添加到widgetItems列表中。

在继续生成卡片之前,请更详细地考虑WidgetItem模型。它通过kotlin密封类实现,从而使模型更灵活,并且使用起来更方便。

sealed class WidgetItem {
 
    data class Profile(
        val wallet: String,
        val waitingTickets: String,
        val unpaidPrizeAmount: String
    ) : WidgetItem()
 
    data class Game(
        val id: String,
        val iconId: Int,
        val prizeValue: String,
        val date: String
    ) : WidgetItem()
}

创建RemoteViews并通过FillInIntent确定它们的响应

override fun getViewAt(position: Int): RemoteViews {
        return when (val item = widgetItems[position]) {
            is WidgetItem.Profile -> {
              RemoteViews(
                        context.packageName,
                        R.layout.item_widget_user_profile
                    ).apply {
                        setTextViewText(R.id.tvWidgetWalletMoney, item.wallet)
                        setTextViewText(R.id.tvWidgetUnpaidCount, item.unpaidPrizeAmount)
                        setTextViewText(R.id.tvWidgetWaitingCount, item.waitingTickets)
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileWallet, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_USER_WALLET
                            )
                        )
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileUnpaid, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_UNPAID_PRIZE
                            )
                        )
                        setOnClickFillInIntent(
                            R.id.llWidgetProfileWaiting, Intent().putExtra(
                                StolotoAppWidgetProvider.KEY_PROFILE_OPTIONS,
                                StolotoAppWidgetProvider.VALUE_WAITING_TICKETS
                            )
                        )
                    }
 
            is WidgetItem.Game -> {
                RemoteViews(
                    context.packageName,
                    R.layout.item_widget_game
                ).apply {
                    setImageViewResource(R.id.ivWidgetGame, item.iconId)
                    setTextViewText(R.id.tvWidgetGamePrize, item.prizeValue)
                    setTextViewText(R.id.tvWidgetGameDate, item.date)
                    setOnClickFillInIntent(
                        R.id.llWidgetGame, Intent().putExtra(
                            StolotoAppWidgetProvider.KEY_GAME_CLICK, item.id
                        )
                    )
                }
            }
        }
    }

setOnClickFillInIntent方法分配指定的viewId意图,该意图将与父PendingIntent结合使用,以确定在单击带有该viewId的视图时的行为。这样,我们可以响应WidgetProvider中的用户点击。

手动小部件更新


我们为小部件设置了半小时的更新时间。您可以更频繁地更新它,例如,通过与WorkManager跳舞,但是为什么要加载网络和电池?在开发的早期阶段,这种行为似乎是足够的。

当“企业”注意到用户查看窗口小部件时,一切都改变了,无关的数据显示在上面:“在iPhone上,我打开窗口小部件,并且我的配置文件中有最新鲜的数据。”

这种情况很常见:iOS会为每个小部件显示生成新的卡片,因为这会产生特殊的屏幕,而Android原则上不会为小部件产生此类事件。我不得不考虑到某些彩票每15分钟举行一次,因此小部件应提供最新信息-您想参加某种抽奖,但已经过去了。

为了摆脱这种不愉快的情况并以某种方式解决更新数据的问题,我提出并实施了经过时间考验的解决方案-“更新”按钮。

将此按钮添加到带有列表的布局布局中,并在调用updateWidget时初始化其行为。

...
// Intent   AppWidgetManager.ACTION_APPWIDGET_UPDATE
val intentUpdate = Intent(context, StolotoAppWidgetProvider::class.java)
intentUpdate.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE

//    
val ids = AppWidgetManager.getInstance(context)
   .getAppWidgetIds(ComponentName(context, StolotoAppWidgetProvider::class.java))
intentUpdate.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)

//  intent  PendingIntent,  PendingIntent.getBroadcast()
val pendingUpdate = PendingIntent.getBroadcast(
   context,
   appWidgetId,
   intentUpdate,
   PendingIntent.FLAG_UPDATE_CURRENT
)
//  pendingIntent      ‘’
remoteViews.setOnClickPendingIntent(R.id.ivWidgetRefresh, pendingUpdate)

最初的发展显示出令人沮丧的景象:从按下“更新”按钮到实际更新,可能需要几秒钟的时间。尽管小部件是由我们的应用程序生成的,但实际上它是在系统的控制之下,并通过广播与我们的应用程序进行通信。

那些。当您单击窗口小部件的“更新”按钮时,链开始:

  1. 在onReceive提供程序的“操作”中获取Intent。
  2. AppWidgetManager.ACTION_APPWIDGET_UPDATE。
  3. 为intent-e中指定的所有widgetsId调用onUpdate。
  4. 在线获取新数据。
  5. 刷新本地数据并显示新的列表卡。

结果,更新窗口小部件看起来不是很好,因为通过单击按钮,我们观察了同一窗口小部件几秒钟。目前尚不清楚数据是否已更新。如何解决视觉反应问题?

首先,我添加了isWidgetLoading标志,可通过交互器进行全局访问。此参数的作用非常简单-加载小部件数据时不要显示刷新按钮。

其次,我将工厂中的数据加载过程分为三个阶段:

enum class LoadingStep {
   START,
   MIDDLE,
   END
}

开始-开始下载。在此阶段,所有适配器视图和全局下载标志的状态更改为“正在加载”。

MIDDLE-主数据加载阶段。它们下载后,全局下载标志置于“已加载”状态,并且已下载的数据显示在适配器中。

END-下载结束。在此步骤中,适配器不需要更改适配器数据。需要此步骤才能正确处理WidgetProvider中视图的更新阶段。

让我们详细了解按钮更新在提供程序中的外观:

if (isFullAuthorized && !widgetLoadingStateInteractor.isWidgetLoading) {
   remoteViews.setViewVisibility(R.id.ivWidgetRefresh, View.VISIBLE)
...
//     ,    
...   
} else {
   remoteViews.setViewVisibility(
       R.id.ivWidgetRefresh,
       if (isFullAuthorized) View.INVISIBLE else View.GONE //       .
   )
}

现在让我们看一下适配器中发生的情况:

private fun updateDataSync() {
   when (loadingStep) {
       START -> {
           widgetItems.forEach { it.isLoading = true }
           widgetLoadingStateInteractor.isWidgetLoading = true
           loadingStep = MIDDLE
           widgetManager.updateWidgets()
       }
       MIDDLE -> {
           widgetItems.clear()
           updateProfileSync()
           updateGamesSync()
           widgetLoadingStateInteractor.isWidgetLoading = false
           loadingStep = END
           widgetManager.updateWidgets()
       }
       END -> {
           loadingStep = START
       }
   }
}

图片

工作逻辑:

  1. 在START和MIDDLE步骤的最后,我调用updateWidgets方法以更新提供程序管理的视图状态。
  2. START «» , MIDDLE.
  3. MIDDLE, «».
  4. MIDDLE, END.
  5. , END, «». , END loadingStep START.



在这种实现的帮助下,我在“业务”看到小部件上的实际数据的需求与过于频繁地“拉”更新的需求之间达成了折衷。

我希望这篇文章对您有用。如果您有创建Android小部件的经验,请在评论中告诉我们。

祝好运

All Articles