Widgets sur Android. Une caractéristique rare à comprendre

Bonjour, Habr! Je m'appelle Alexander Khakimov, je suis développeur Android chez FINCH.

Est-il arrivé que votre conception soit pour iOS et que vous deviez l'adapter pour Android? Si oui, vos concepteurs utilisent-ils souvent des widgets? Malheureusement, un widget est un cas rare pour de nombreux développeurs, car il est rare que quelqu'un travaille avec lui.

Dans cet article, je vais vous expliquer en détail comment créer un widget, qui mérite une attention particulière et partagera mon cas.

Création de widgets


Pour créer un widget, vous devez savoir:

  1. Caractéristiques des composants de widget.
  2. Caractéristiques d'affichage du widget dans la grille d'écran.
  3. Mises à jour des widgets.

Nous analyserons chaque élément séparément.

Caractéristiques des composants de widget


Tout développeur qui a travaillé avec RemoteViews au moins une fois connaît cet élément. Si vous en faites partie, n'hésitez pas à passer au point suivant.

RemoteViews est conçu pour décrire et gérer les hiérarchies de vues qui appartiennent à un processus dans une autre application. À l'aide de la gestion de la hiérarchie, vous pouvez modifier les propriétés ou les méthodes d'appel qui appartiennent à la vue, qui fait partie d'une autre application. RemoteViews comprend un ensemble limité de composants de la bibliothèque de composants standard android.widget.

La vue à l'intérieur des widgets fonctionne dans un processus séparé (il s'agit généralement de l'écran d'accueil), par conséquent, pour modifier l'interface utilisateur du widget, utilisez l'extension BroadcastReceiver - AppWidgetProvider, qui fonctionne dans notre application.

Caractéristiques d'affichage du widget dans la "grille" de l'écran


En fait, ce point n'est pas si compliqué, si vous regardez les directives officielles :
Chaque widget doit définir un minWidth et minHeight, indiquant la quantité minimale d'espace qu'il doit consommer par défaut. Lorsque les utilisateurs ajoutent un widget à leur écran d'accueil, il occupera généralement plus que la largeur et la hauteur minimales que vous spécifiez. Les écrans d'accueil Android offrent aux utilisateurs une grille d'espaces disponibles dans lesquels ils peuvent placer des widgets et des icônes. Cette grille peut varier selon un appareil; par exemple, de nombreux combinés offrent une grille 4x4, et les tablettes peuvent offrir une grille 8x7 plus grande.

Traduction en russe: chaque widget doit définir sa largeur et sa hauteur minimales pour indiquer l'espace minimum qu'il occupera par défaut.

image
Exemple de paramètres de widget lors de la création dans Android Studio Un

widget qui a été ajouté à l'écran d'accueil prendra généralement plus d'espace que la largeur et la hauteur minimales de l'écran que vous définissez. Les écrans d'accueil d'Android fournissent aux utilisateurs une grille d'espaces disponibles dans lesquels se trouvent des widgets et des icônes. Cette grille peut varier selon l'appareil; par exemple, de nombreux téléphones proposent des grilles 4x4 et les tablettes peuvent proposer de grandes grilles 8x4.

De cela, il devient clair que la grille de l'appareil peut être n'importe quoi, et la taille des cellules peut varier, selon la taille de la grille. Par conséquent, le contenu du widget doit être conçu en tenant compte de ces fonctionnalités.

La largeur et la hauteur minimales du widget pour un nombre donné de colonnes et de lignes peuvent être calculées à l'aide de la formule:

minSideSizeDp = 70 × n - 30, où n est le nombre de lignes ou de colonnes. Pour

le moment, la grille minimale maximale que vous pouvez définir est 4x4. Cela garantit que votre widget sera affiché sur tous les appareils.

Mises à jour des widgets


Étant donné que AppWidgetProvider est essentiellement une extension de BroadcastReceiver, vous pouvez faire la même chose qu'avec un BroadcastReceiver standard. AppWidgetProvider analyse simplement les champs correspondants de l'intention reçue dans onReceive et appelle les méthodes d'interception avec les extras reçus.

La difficulté est apparue avec la fréquence de mise à jour du contenu - tout l'intérêt est la différence dans le fonctionnement interne des widgets sur iOS et Android. Le fait est que les données sur les widgets iOS sont mises à jour lorsque le widget devient visible pour l'utilisateur. Sous Android, un tel événement n'existe pas. Nous ne pouvons pas savoir quand l'utilisateur voit le widget.

Pour les widgets sur Android, la méthode de mise à jour recommandée est une mise à jour par minuterie. Les paramètres du minuteur sont définis par le paramètre de widget updatePeriodMillis. Malheureusement, ce paramètre ne permet pas de mettre à jour le widget plus d'une fois toutes les 30 minutes. Ci-dessous, je vais en parler plus en détail.

Cas de widget


Plus loin, nous parlerons du cas que nous avons eu à FINCH dans une grande application de loterie avec l'application Stoloto pour participer à des loteries d'État.

La tâche de l'application est de simplifier et de rendre transparent pour l'utilisateur le choix d'une loterie et l'achat d'un billet. Par conséquent, la fonctionnalité requise du widget est assez simple: montrez les jeux recommandés par l'utilisateur à l'achat et appuyez sur pour aller à celui correspondant. La liste des jeux est déterminée sur le serveur et mise à jour régulièrement.

Dans notre cas, la conception du widget comprenait deux états:

  • Pour utilisateur autorisé
  • Pour un utilisateur non autorisé

Un utilisateur autorisé doit montrer ses données de profil: l'état du portefeuille interne, le nombre de billets en attente du tirage et le nombre de gains perdus. Pour chacun de ces éléments, une transition vers l'écran à l'intérieur de l'application est prévue, différente des autres.

image

image

Comme vous l'avez peut-être remarqué, une autre fonctionnalité pour un utilisateur autorisé est le bouton «rafraîchir», mais plus à ce sujet plus tard.

Pour implémenter l'affichage de deux états, en tenant compte de la conception, j'ai utilisé RemoteAdapter comme implémentation de RemoteViewsService pour générer des cartes de contenu.

Et maintenant un peu de code et comment tout fonctionne à l'intérieur. Si vous avez déjà eu de l'expérience avec le widget, vous savez que toute mise à jour des données du widget commence par la méthode 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)
          }
    }

Nous écrivons une mise à jour pour chaque instance de notre widget.

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
        )
    }

Mise à jour de l'adaptateur.

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)
    }

Nous écrivons la mise en œuvre de notre service. Dans ce document, il est important pour nous d'indiquer quelle implémentation de l'interface RemoteViewsService.RemoteViewsFactory utiliser pour générer du contenu.

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

Il s'agit en fait d'une mince enveloppe sur l'adaptateur. Grâce à lui, nous pouvons associer nos données à une vue de collecte à distance. RemoteViewsFactory fournit des méthodes pour générer des RemoteViews pour chaque élément du jeu de données. Le constructeur n'a pas d'exigences - tout ce que je fais, c'est y passer le contexte.

Ensuite, quelques mots sur les principales méthodes:

  1. onCreate - création d'un adaptateur.
  2. getLoadingView - la méthode suggère de retourner la vue, que le système affichera au lieu des éléments de la liste lors de leur création. Si vous ne créez rien ici, le système utilise une vue par défaut.
  3. getViewAt - la méthode suggère de créer des éléments de liste. Voici l'utilisation standard de RemoteViews.
  4. onDataSetChanged est appelé lorsqu'une demande de mise à jour des données de la liste est reçue. Ceux. Dans cette méthode, nous préparons les données pour la liste. La méthode est affinée par l'exécution de code long et lourd.
  5. onDestroy est appelé lorsque la dernière liste que l'adaptateur utilisé est supprimée (un adaptateur peut être utilisé par plusieurs listes).
  6. RemoteViewsFactory vit tant que toutes les instances de la liste sont vivantes, nous pouvons donc y stocker des données actuelles, par exemple une liste d'éléments en cours.

Définissez une liste de données que nous afficherons:

private val widgetItems = ArrayList<WidgetItem>()

Lors de la création de l'adaptateur, nous commençons à charger les données. Ici, vous pouvez effectuer en toute sécurité toutes les tâches difficiles, y compris marcher tranquillement dans le réseau en bloquant le flux.

override fun onCreate() {
        updateDataSync()
}

Lors de l'appel de la commande pour mettre à jour les données, nous appelons également updateDataSync ()

   override fun onDataSetChanged() {
        updateDataSync()
    }

Dans updateDataSync, tout est aussi simple. Nous effaçons la liste actuelle des éléments. Téléchargez le profil et les données du jeu.

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

C'est plus intéressant ici

private fun updateProfileSync() {

Puisqu'il est important pour nous d'afficher le profil uniquement à un utilisateur autorisé, nous devons télécharger les informations de profil uniquement dans ce cas:

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

Le modèle WidgetProfile est assemblé à partir de différentes sources, de sorte que la logique de leur réception et ses valeurs par défaut sont organisées de telle sorte qu'une valeur de portefeuille négative indique des données incorrectes ou des problèmes avec leur réception.

Pour la logique métier, le manque de données de portefeuille est critique, par conséquent, dans le cas d'un portefeuille incorrect, un modèle de profil ne sera pas créé et ajouté à la liste des éléments.

  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 ""
                    )
                )
            }
        }
    }

La méthode updateGamesSync () utilise getWidgetGamesInteractor et ajoute un ensemble de jeux pertinents pour le widget à la liste widgetItems.

Avant de passer à la génération de cartes, considérez le modèle WidgetItem plus en détail. Il est implémenté via la classe scellée kotlin, ce qui rend le modèle plus flexible et il est plus pratique de travailler avec.

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()
}

Créez des vues à distance et déterminez leur réponse via 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
                        )
                    )
                }
            }
        }
    }

La méthode setOnClickFillInIntent attribue l'intention viewId spécifiée, qui sera combinée avec le parent PendingIntent pour déterminer le comportement lorsque vous cliquez sur la vue avec cet viewId. De cette façon, nous pouvons répondre aux clics des utilisateurs dans notre WidgetProvider.

Mise à jour manuelle du widget


Une heure de mise à jour d'une demi-heure a été fixée pour notre widget. Vous pouvez le mettre à jour plus souvent, par exemple, en dansant avec WorkManager, mais pourquoi charger votre réseau et votre batterie? Un tel comportement aux premiers stades de développement semblait adéquat.

Tout a changé lorsque «l'entreprise» a remarqué que lorsque l'utilisateur regarde le widget, les données non pertinentes y sont affichées: «Ici, sur mon iPhone, j'ouvre le widget et il y a les données LES PLUS fraîches de mon profil.»

La situation est courante: iOS génère de nouvelles cartes pour CHAQUE affichage de widget, car pour cela, ils ont un écran spécial, et Android n'a pas de tels événements pour le widget en principe. J'ai dû tenir compte du fait que certaines loteries ont lieu une fois toutes les 15 minutes, donc le widget devrait donner des informations à jour - vous voulez participer à une sorte de tirage, mais c'est déjà passé.

Afin de sortir de cette situation désagréable et de résoudre le problème de la mise à jour des données, j'ai proposé et implémenté une solution éprouvée - le bouton «mise à jour».

Ajoutez ce bouton à la mise en page avec la liste et initialisez son comportement lorsque updateWidget est appelé.

...
// 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)

Les premiers développements ont montré une triste image: de l’appui sur le bouton «mise à jour» à la mise à jour proprement dite, plusieurs secondes pouvaient s'écouler. Bien que le widget soit généré par notre application, il est en réalité sous le contrôle du système et communique avec notre application via des diffusions.

Ceux. lorsque vous cliquez sur le bouton "mettre à jour" de notre widget, la chaîne démarre:

  1. Obtenez l'intention dans l'action du fournisseur onReceive.
  2. AppWidgetManager.ACTION_APPWIDGET_UPDATE.
  3. Appelez onUpdate pour tous les widgetsIds spécifiés dans intent-e.
  4. Allez en ligne pour de nouvelles données.
  5. Actualisez les données locales et affichez de nouvelles cartes de liste.

En conséquence, la mise à jour du widget n'était pas très agréable, car en cliquant sur le bouton, nous avons regardé le même widget pendant quelques secondes. Il n'était pas clair si les données étaient mises à jour. Comment résoudre le problème de la réponse visuelle?

Tout d'abord, j'ai ajouté le drapeau isWidgetLoading avec un accès global via l'interacteur. Le rôle de ce paramètre est assez simple - n'affichez pas le bouton d'actualisation pendant le chargement des données du widget.

Deuxièmement, j'ai divisé le processus de chargement des données dans l'usine en trois étapes:

enum class LoadingStep {
   START,
   MIDDLE,
   END
}

START - début du téléchargement. À ce stade, l'état de toutes les vues de l'adaptateur et l'indicateur de téléchargement global deviennent «chargement».

MIDDLE - l'étape du chargement des données principales. Une fois téléchargés, l'indicateur de téléchargement global est mis à l'état «chargé» et les données téléchargées sont affichées dans l'adaptateur.

END - fin du téléchargement. L'adaptateur n'a pas besoin de modifier les données de l'adaptateur à cette étape. Cette étape est nécessaire pour traiter correctement l'étape de mise à jour des vues dans WidgetProvider.

Voyons plus en détail à quoi ressemble la mise à jour des boutons dans le fournisseur:

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

Voyons maintenant ce qui se passe dans l'adaptateur:

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
       }
   }
}

image

Logique de travail:

  1. À la fin des étapes START et MIDDLE, j'appelle la méthode updateWidgets pour mettre à jour l'état d'affichage géré par le fournisseur.
  2. START «» , MIDDLE.
  3. MIDDLE, «».
  4. MIDDLE, END.
  5. , END, «». , END loadingStep START.



Avec l'aide d'une telle implémentation, je suis parvenu à un compromis entre l'exigence de "l'entreprise" de voir les données réelles sur le widget et la nécessité de "tirer" la mise à jour trop souvent.

J'espère que l'article vous a été utile. Si vous aviez de l'expérience dans la création de widgets pour Android, dites-le nous dans les commentaires.

Bonne chance

All Articles