Widgets auf Android. Ein seltenes Merkmal, um es herauszufinden

Hallo Habr! Mein Name ist Alexander Khakimov, ich bin ein Android-Entwickler bei FINCH.

Ist es passiert, dass Ihr Design für iOS war und Sie es für Android anpassen müssen? Wenn ja, verwenden Ihre Designer häufig Widgets? Leider ist ein Widget für viele Entwickler ein seltener Fall, da selten jemand damit arbeitet.

In diesem Artikel werde ich Ihnen ausführlich erklären, wie Sie ein Widget erstellen, das es wert ist, beachtet zu werden und meinen Fall zu teilen.

Widget-Erstellung


Um ein Widget zu erstellen, müssen Sie Folgendes wissen:

  1. Funktionen von Widget-Komponenten.
  2. Funktionen zum Anzeigen des Widgets im Bildschirmraster.
  3. Bietet Widget-Updates.

Wir werden jeden Artikel separat analysieren.

Funktionen von Widget-Komponenten


Jeder Entwickler, der mindestens einmal mit RemoteViews gearbeitet hat, ist mit diesem Element vertraut. Wenn Sie einer von diesen sind, können Sie mit dem nächsten Punkt fortfahren.

RemoteViews dient zum Beschreiben und Verwalten von Ansichtshierarchien, die zu einem Prozess in einer anderen Anwendung gehören. Mithilfe der Hierarchieverwaltung können Sie Eigenschaften ändern oder Methoden aufrufen, die zur Ansicht gehören, die Teil einer anderen Anwendung ist. RemoteViews enthält einen begrenzten Satz von Komponenten aus der Standardkomponentenbibliothek android.widget.

Das Anzeigen in Widgets funktioniert in einem separaten Prozess (normalerweise ist dies der Startbildschirm). Verwenden Sie daher zum Ändern der Benutzeroberfläche des Widgets die BroadcastReceiver-Erweiterung - AppWidgetProvider, die in unserer Anwendung funktioniert.

Funktionen zum Anzeigen des Widgets im "Raster" des Bildschirms


In der Tat ist dieser Punkt nicht so kompliziert, wenn Sie sich die offiziellen Richtlinien ansehen :
Jedes Widget muss eine minWidth und eine minHeight definieren, die angeben, wie viel Speicherplatz standardmäßig mindestens belegt werden soll. Wenn Benutzer ihrem Startbildschirm ein Widget hinzufügen, belegt es im Allgemeinen mehr als die von Ihnen angegebene Mindestbreite und -höhe. Android-Startbildschirme bieten Benutzern ein Raster verfügbarer Bereiche, in denen sie Widgets und Symbole platzieren können. Dieses Raster kann je nach Gerät variieren. Beispielsweise bieten viele Mobilteile ein 4x4-Raster und Tablets ein größeres 8x7-Raster.

Übersetzen ins Russische: Jedes Widget muss seine eigene Mindestbreite und -höhe festlegen, um den Mindestplatz anzugeben, den es standardmäßig belegt.

Bild
Beispiel für Widget-Einstellungen beim Erstellen in Android Studio Das

Widget, das dem Startbildschirm hinzugefügt wurde, nimmt normalerweise mehr Platz ein als die von Ihnen festgelegte Mindestbreite und -höhe. Die Android-Startbildschirme bieten Benutzern ein Raster mit verfügbaren Bereichen, in denen sich Widgets und Symbole befinden können. Dieses Raster kann je nach Gerät variieren. Beispielsweise bieten viele Telefone 4x4-Raster und Tablets große 8x4-Raster.

Daraus wird deutlich, dass das Raster des Geräts beliebig sein kann und die Zellengröße je nach Größe des Rasters variieren kann. Dementsprechend sollte der Inhalt des Widgets unter Berücksichtigung dieser Funktionen gestaltet werden.

Die minimale Breite und Höhe des Widgets für eine bestimmte Anzahl von Spalten und Zeilen kann mithilfe der folgenden Formel berechnet werden:

minSideSizeDp = 70 × n - 30, wobei n die Anzahl der Zeilen oder Spalten ist

. Derzeit können Sie maximal 4x4 festlegen. Dadurch wird sichergestellt, dass Ihr Widget auf allen Geräten angezeigt wird.

Bietet Widget-Updates


Da AppWidgetProvider im Wesentlichen eine Erweiterung von BroadcastReceiver ist, können Sie damit dasselbe tun wie mit einem normalen BroadcastReceiver. AppWidgetProvider analysiert einfach die entsprechenden Felder aus der in onReceive empfangenen Absicht und ruft die Abfangmethoden mit den empfangenen Extras auf.

Die Schwierigkeit ergab sich aus der Häufigkeit der Aktualisierung des Inhalts - der springende Punkt ist der Unterschied im internen Betrieb von Widgets unter iOS und Android. Tatsache ist, dass Daten in iOS-Widgets aktualisiert werden, wenn das Widget für den Benutzer sichtbar wird. In Android existiert ein solches Ereignis nicht. Wir können nicht herausfinden, wann der Benutzer das Widget sieht.

Für Widgets unter Android ist die empfohlene Aktualisierungsmethode ein Timer-Update. Die Timer-Einstellungen werden über den Widget-Parameter updatePeriodMillis festgelegt. Leider erlaubt diese Einstellung nicht, das Widget mehr als einmal alle 30 Minuten zu aktualisieren. Im Folgenden werde ich näher darauf eingehen.

Widget-Fall


Weiter werden wir über den Fall sprechen, den wir bei FINCH in einem großen Lotterieantrag mit dem Stoloto-Antrag auf Teilnahme an staatlichen Lotterien hatten.

Die Aufgabe des Antrags besteht darin, die Auswahl einer Lotterie und den Kauf eines Tickets für den Benutzer zu vereinfachen und transparent zu machen. Daher ist die erforderliche Funktionalität des Widgets recht einfach: Zeigen Sie den vom Benutzer empfohlenen Spielen zum Kauf an und tippen Sie auf, um zum entsprechenden zu gelangen. Die Liste der Spiele wird auf dem Server ermittelt und regelmäßig aktualisiert.

In unserem Fall enthielt das Widget-Design zwei Zustände:

  • Für autorisierte Benutzer
  • Für einen nicht autorisierten Benutzer

Ein autorisierter Benutzer muss seine Profildaten anzeigen: den Status der internen Brieftasche, die Anzahl der Tickets, die auf die Ziehung warten, und die Anzahl der verlorenen Gewinne. Für jedes dieser Elemente wird ein Übergang zum Bildschirm innerhalb der Anwendung bereitgestellt, der sich von den anderen unterscheidet.

Bild

Bild

Wie Sie vielleicht bemerkt haben, ist eine weitere Funktion für einen autorisierten Benutzer die Schaltfläche "Aktualisieren", aber dazu später mehr.

Um die Anzeige von zwei Zuständen unter Berücksichtigung des Designs zu implementieren, habe ich den RemoteAdapter als Implementierung von RemoteViewsService verwendet, um Inhaltskarten zu generieren.

Und jetzt ein kleiner Code und wie alles drinnen funktioniert. Wenn Sie bereits Erfahrung mit dem Widget haben, wissen Sie, dass jede Aktualisierung der Widgetdaten mit der onUpdate-Methode beginnt:

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

Wir schreiben ein Update für jede Instanz unseres Widgets.

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

Adapter aktualisieren.

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

Wir schreiben die Implementierung unseres Service. Darin ist es wichtig anzugeben, welche Implementierung der RemoteViewsService.RemoteViewsFactory-Schnittstelle zum Generieren von Inhalten verwendet werden soll.

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

Dies ist eigentlich eine dünne Hülle über dem Adapter. Dank ihm können wir unsere Daten mit der Remote-Erfassungsansicht verknüpfen. RemoteViewsFactory bietet Methoden zum Generieren von RemoteViews für jedes Element im Dataset. Der Konstruktor hat keine Anforderungen - alles, was ich tue, ist, den Kontext darin zu übergeben.

Als nächstes ein paar Worte zu den wichtigsten Methoden:

  1. onCreate - Erstellen eines Adapters.
  2. getLoadingView - Die Methode schlägt vor, die Ansicht zurückzugeben, die das System während der Erstellung anstelle der Listenelemente anzeigt. Wenn Sie hier nichts erstellen, verwendet das System eine Standardansicht.
  3. getViewAt - Die Methode schlägt vor, Listenelemente zu erstellen. Hier kommt die Standardverwendung von RemoteViews.
  4. onDataSetChanged wird aufgerufen, wenn eine Anforderung zum Aktualisieren der Daten in der Liste empfangen wird. Jene. Bei dieser Methode bereiten wir die Daten für die Liste vor. Die Methode wird durch die Ausführung von schwerem, langem Code geschärft.
  5. onDestroy wird aufgerufen, wenn die zuletzt verwendete Liste des Adapters gelöscht wird (ein Adapter kann von mehreren Listen verwendet werden).
  6. RemoteViewsFactory lebt, während alle Instanzen der Liste aktiv sind, sodass wir aktuelle Daten darin speichern können, z. B. eine Liste aktueller Elemente.

Definieren Sie eine Liste der Daten, die angezeigt werden sollen:

private val widgetItems = ArrayList<WidgetItem>()

Beim Erstellen des Adapters beginnen wir mit dem Laden der Daten. Hier können Sie alle schwierigen Aufgaben sicher ausführen, einschließlich des leisen Gehens in das Netzwerk, das den Fluss blockiert.

override fun onCreate() {
        updateDataSync()
}

Wenn wir den Befehl zum Aktualisieren von Daten aufrufen, rufen wir auch updateDataSync () auf.

   override fun onDataSetChanged() {
        updateDataSync()
    }

In updateDataSync ist auch alles einfach. Wir löschen die aktuelle Liste der Elemente. Laden Sie Profil- und Spieldaten herunter.

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

Hier ist es interessanter

private fun updateProfileSync() {

Da es für uns wichtig ist, das Profil nur einem autorisierten Benutzer anzuzeigen, müssen wir die Profilinformationen nur in diesem Fall herunterladen:

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

Das WidgetProfile-Modell wird aus verschiedenen Quellen zusammengestellt, sodass die Logik ihres Empfangs und seine Standardwerte so angeordnet sind, dass ein negativer Brieftaschenwert auf falsche Daten oder Probleme mit dem Empfang hinweist.

Für die Geschäftslogik ist das Fehlen von Brieftaschendaten von entscheidender Bedeutung. Im Falle einer falschen Brieftasche wird daher kein Profilmodell erstellt und der Liste der Elemente hinzugefügt.

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

Die updateGamesSync () -Methode verwendet getWidgetGamesInteractor und fügt der WidgetItems-Liste eine Reihe von Spielen hinzu, die für das Widget relevant sind.

Bevor Sie mit der Kartengenerierung fortfahren, sollten Sie das WidgetItem-Modell genauer betrachten. Die Implementierung erfolgt über die versiegelte Kotlin-Klasse, wodurch das Modell flexibler wird und die Arbeit damit bequemer ist.

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

Erstellen Sie RemoteViews und bestimmen Sie deren Antwort über 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
                        )
                    )
                }
            }
        }
    }

Die setOnClickFillInIntent-Methode weist die angegebene viewId-Absicht zu, die mit dem übergeordneten PendingIntent kombiniert wird, um das Verhalten beim Klicken auf die Ansicht mit dieser viewId zu bestimmen. Auf diese Weise können wir auf Benutzerklicks in unserem WidgetProvider reagieren.

Manuelles Widget-Update


Für unser Widget wurde eine Aktualisierungszeit von einer halben Stunde festgelegt. Sie können es häufiger aktualisieren, indem Sie beispielsweise mit WorkManager tanzen. Warum sollten Sie jedoch Ihr Netzwerk und Ihren Akku laden? Ein solches Verhalten in den frühen Entwicklungsstadien schien angemessen.

Alles änderte sich, als das „Unternehmen“ bemerkte, dass beim Betrachten des Widgets die irrelevanten Daten darauf angezeigt werden: „Hier auf meinem iPhone öffne ich das Widget und es gibt die MEISTEN frischen Daten meines Profils.“

Die Situation ist alltäglich: iOS generiert neue Karten für JEDE Widget-Anzeige, da sie dafür einen speziellen Bildschirm haben und Android solche Ereignisse für das Widget im Prinzip nicht hat. Ich musste berücksichtigen, dass einige Lotterien alle 15 Minuten stattfinden, daher sollte das Widget aktuelle Informationen liefern - Sie möchten an einer Verlosung teilnehmen, diese ist jedoch bereits abgelaufen.

Um aus dieser unangenehmen Situation herauszukommen und das Problem mit der Aktualisierung der Daten irgendwie zu lösen, habe ich eine bewährte Lösung vorgeschlagen und implementiert - die Schaltfläche „Aktualisieren“.

Fügen Sie diese Schaltfläche dem Layout-Layout mit der Liste hinzu und initialisieren Sie das Verhalten beim Aufruf von 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)

Die ersten Entwicklungen zeigten ein trauriges Bild: Vom Drücken der Schaltfläche "Aktualisieren" bis zum eigentlichen Update konnten einige Sekunden vergehen. Obwohl das Widget von unserer Anwendung generiert wird, unterliegt es tatsächlich der Kontrolle des Systems und kommuniziert mit unserer Anwendung über Broadcasts.

Jene. Wenn Sie auf die Schaltfläche "Aktualisieren" unseres Widgets klicken, beginnt die Kette:

  1. Holen Sie sich die Absicht in die onReceive-Provider-Aktion.
  2. AppWidgetManager.ACTION_APPWIDGET_UPDATE.
  3. Rufen Sie onUpdate für alle in intent-e angegebenen WidgetsIds auf.
  4. Gehen Sie online für neue Daten.
  5. Aktualisieren Sie lokale Daten und zeigen Sie neue Listenkarten an.

Infolgedessen sah das Aktualisieren des Widgets nicht besonders gut aus, da wir durch Klicken auf die Schaltfläche einige Sekunden lang dasselbe Widget angesehen haben. Es war unklar, ob die Daten aktualisiert wurden. Wie kann man das Problem der visuellen Reaktion lösen?

Zuerst habe ich das isWidgetLoading-Flag mit globalem Zugriff über den Interaktor hinzugefügt. Die Rolle dieses Parameters ist recht einfach: Zeigen Sie die Schaltfläche "Aktualisieren" nicht an, während die Widgetdaten geladen werden.

Zweitens habe ich den Datenladevorgang in der Fabrik in drei Phasen unterteilt:

enum class LoadingStep {
   START,
   MIDDLE,
   END
}

START - Start des Downloads. Zu diesem Zeitpunkt ändert sich der Status aller Adapteransichten und das globale Download-Flag in "Laden".

MITTEL - die Phase des Ladens der Hauptdaten. Nach dem Herunterladen wird das globale Download-Flag in den Status "geladen" versetzt und die heruntergeladenen Daten werden im Adapter angezeigt.

ENDE - Ende des Downloads. Der Adapter muss in diesem Schritt die Adapterdaten nicht ändern. Dieser Schritt ist erforderlich, um die Aktualisierungsphase der Ansichten in WidgetProvider korrekt zu verarbeiten.

Sehen wir uns genauer an, wie das Button-Update im Provider aussieht:

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

Schauen wir uns nun an, was im Adapter passiert:

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

Bild

Logik der Arbeit:

  1. Am Ende der Schritte START und MIDDLE rufe ich die updateWidgets-Methode auf, um den vom Anbieter verwalteten Ansichtsstatus zu aktualisieren.
  2. START «» , MIDDLE.
  3. MIDDLE, «».
  4. MIDDLE, END.
  5. , END, «». , END loadingStep START.



Mit Hilfe einer solchen Implementierung habe ich einen Kompromiss zwischen der Anforderung des "Unternehmens", die tatsächlichen Daten im Widget anzuzeigen, und der Notwendigkeit, das Update zu oft "abzurufen", erzielt.

Ich hoffe, der Artikel hat Ihnen geholfen. Wenn Sie Erfahrung mit der Erstellung von Widgets für Android haben, teilen Sie uns dies in den Kommentaren mit.

Viel Glück

All Articles