Widgets en Android. Una característica rara para descubrir

Hola Habr! Mi nombre es Alexander Khakimov, soy desarrollador de Android en FINCH.

¿Sucedió que su diseño era para iOS y tiene que adaptarlo para Android? Si es así, ¿sus diseñadores a menudo usan widgets? Desafortunadamente, un widget es un caso raro para muchos desarrolladores, porque rara vez alguien trabaja con él.

En este artículo te contaré en detalle cómo crear un widget, al cual vale la pena prestarle atención y compartiré mi caso.

Creación de widgets


Para crear un widget necesitas saber:

  1. Características de los componentes del widget.
  2. Características de mostrar el widget en la cuadrícula de la pantalla.
  3. Presenta actualizaciones de widgets.

Analizaremos cada artículo por separado.

Características de los componentes del widget


Cualquier desarrollador que haya trabajado con RemoteViews al menos una vez está familiarizado con este elemento. Si eres uno de estos, no dudes en pasar al siguiente punto.

RemoteViews está diseñado para describir y administrar las jerarquías de Vistas que pertenecen a un proceso en otra aplicación. Mediante la administración de jerarquía, puede cambiar las propiedades o los métodos de llamada que pertenecen a la Vista, que forma parte de otra aplicación. RemoteViews incluye un conjunto limitado de componentes de la biblioteca de componentes estándar android.widget.

Ver dentro de los widgets funciona en un proceso separado (por lo general, esta es la pantalla de inicio), por lo tanto, para cambiar la interfaz de usuario del widget, use la extensión BroadcastReceiver: AppWidgetProvider, que funciona en nuestra aplicación.

Características de mostrar el widget en la "cuadrícula" de la pantalla


De hecho, este punto no es tan complicado si nos fijamos en las pautas oficiales :
Cada widget debe definir un minWidth y minHeight, que indica la cantidad mínima de espacio que debe consumir de manera predeterminada. Cuando los usuarios agregan un widget a su pantalla de inicio, generalmente ocupará más que el ancho y la altura mínimos que especifique. Las pantallas de inicio de Android ofrecen a los usuarios una cuadrícula de espacios disponibles en los que pueden colocar widgets e íconos. Esta cuadrícula puede variar según el dispositivo; por ejemplo, muchos teléfonos ofrecen una cuadrícula de 4x4, y las tabletas pueden ofrecer una cuadrícula más grande de 8x7.

Traducción al ruso: cada widget debe establecer su propio ancho y alto mínimos para indicar el espacio mínimo que ocupará de forma predeterminada.

imagen
Ejemplo de configuración de widgets al crear en Android Studio Un

widget que se agregó a la pantalla de inicio generalmente ocupará más espacio que el ancho y la altura mínimos de la pantalla que configuró. Las pantallas de inicio de Android proporcionan a los usuarios una cuadrícula de espacios disponibles en los que se pueden ubicar widgets e íconos. Esta cuadrícula puede variar según el dispositivo; Por ejemplo, muchos teléfonos ofrecen cuadrículas de 4x4, y las tabletas pueden ofrecer cuadrículas de 8x4 grandes.

A partir de esto, queda claro que la cuadrícula del dispositivo puede ser cualquier cosa, y el tamaño de la celda puede variar, dependiendo del tamaño de la cuadrícula. En consecuencia, el contenido del widget debe diseñarse teniendo en cuenta estas características.

El ancho y la altura mínimos del widget para un número determinado de columnas y filas se pueden calcular mediante la fórmula:

minSideSizeDp = 70 × n - 30, donde n es el número de filas o columnas.

En este momento, la cuadrícula mínima máxima que puede establecer es 4x4. Esto asegura que su widget se mostrará en todos los dispositivos.

Presenta actualizaciones de widgets


Dado que AppWidgetProvider es esencialmente una extensión de BroadcastReceiver, puede hacer lo mismo con un BroadcastReceiver normal. AppWidgetProvider simplemente analiza los campos correspondientes de la intención recibida en onReceive y llama a los métodos de intercepción con los extras recibidos.

La dificultad surgió con la frecuencia de actualización del contenido: el punto central es la diferencia en el funcionamiento interno de los widgets en iOS y Android. El hecho es que los datos en los widgets de iOS se actualizan cuando el widget se vuelve visible para el usuario. En Android, tal evento no existe. No podemos saber cuándo el usuario ve el widget.

Para los widgets en Android, el método de actualización recomendado es una actualización del temporizador. La configuración del temporizador se establece mediante el parámetro de actualización updatePeriodMillis. Desafortunadamente, esta configuración no permite actualizar el widget más de una vez cada 30 minutos. A continuación hablaré sobre esto con más detalle.

Caso de widget


Además, hablaremos sobre el caso que tuvimos en FINCH en una gran aplicación de lotería con la aplicación Stoloto para participar en loterías estatales.

La tarea de la aplicación es simplificar y hacer transparente para el usuario la elección de una lotería y la compra de un boleto. Por lo tanto, la funcionalidad requerida del widget es bastante simple: muestre los juegos recomendados por el usuario para la compra y toque para ir al correspondiente. La lista de juegos se determina en el servidor y se actualiza regularmente.

En nuestro caso, el diseño del widget incluía dos estados:

  • Para usuarios autorizados
  • Para un usuario no autorizado.

Un usuario autorizado debe mostrar sus datos de perfil: el estado de la billetera interna, la cantidad de boletos que esperan el sorteo y la cantidad de victorias perdidas. Para cada uno de estos elementos, se proporciona una transición a la pantalla dentro de la aplicación, diferente de los demás.

imagen

imagen

Como habrás notado, otra característica para un usuario autorizado es el botón "actualizar", pero más sobre eso más adelante.

Para implementar la visualización de dos estados, teniendo en cuenta el diseño, utilicé RemoteAdapter como una implementación de RemoteViewsService para generar tarjetas de contenido.

Y ahora un pequeño código y cómo funciona todo dentro. Si ya tenía experiencia con el widget, sabe que cualquier actualización de los datos del widget comienza con el método 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)
          }
    }

Estamos escribiendo una actualización para cada instancia de nuestro 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
        )
    }

Actualizando el adaptador.

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

Estamos escribiendo la implementación de nuestro servicio. En él, es importante que indiquemos qué implementación de la interfaz RemoteViewsService.RemoteViewsFactory usar para generar contenido.

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

Esto es en realidad una envoltura delgada sobre el adaptador. Gracias a él, podemos asociar nuestros datos con la vista de recopilación remota. RemoteViewsFactory proporciona métodos para generar RemoteViews para cada elemento del conjunto de datos. El constructor no tiene requisitos: todo lo que hago es pasarle el contexto.

A continuación, algunas palabras sobre los métodos principales:

  1. onCreate: creación de un adaptador.
  2. getLoadingView: el método sugiere devolver la Vista, que el sistema mostrará en lugar de los elementos de la lista mientras se crean. Si no crea nada aquí, el sistema usa alguna Vista predeterminada.
  3. getViewAt: el método sugiere crear elementos de lista. Aquí viene el uso estándar de RemoteViews.
  4. Se llama a onDataSetChanged cuando se recibe una solicitud para actualizar los datos de la lista. Aquellos. En este método, preparamos los datos para la lista. El método se agudiza mediante la ejecución de un código largo y pesado.
  5. Se llama a onDestroy cuando se elimina la última lista que utilizó el adaptador (varias listas pueden usar un adaptador).
  6. RemoteViewsFactory vive mientras todas las instancias de la lista están activas, por lo que podemos almacenar datos actuales en ella, por ejemplo, una lista de elementos actuales.

Defina una lista de datos que mostraremos:

private val widgetItems = ArrayList<WidgetItem>()

Al crear el adaptador, comenzamos a cargar los datos. Aquí puede realizar con seguridad cualquier tarea difícil, como caminar silenciosamente hacia la red bloqueando el flujo.

override fun onCreate() {
        updateDataSync()
}

Cuando llamamos al comando para actualizar datos, también llamamos a updateDataSync ()

   override fun onDataSetChanged() {
        updateDataSync()
    }

Dentro de updateDataSync, todo es simple también. Borramos la lista actual de artículos. Descargar perfil y datos del juego.

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

Es más interesante aquí.

private fun updateProfileSync() {

Dado que es importante para nosotros mostrar el perfil solo a un usuario autorizado, necesitamos descargar la información del perfil solo en este caso:

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

El modelo WidgetProfile se ensambla a partir de diferentes fuentes, por lo que la lógica de su recibo y sus valores predeterminados se organizan de tal manera que un valor negativo de la billetera indica datos incorrectos o problemas con su recibo.

Para la lógica empresarial, la falta de datos de billetera es crítica, por lo tanto, en el caso de una billetera incorrecta, no se creará un modelo de perfil ni se agregará a la lista de elementos.

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

El método updateGamesSync () usa getWidgetGamesInteractor y agrega un conjunto de juegos relevantes para el widget a la lista widgetItems.

Antes de proceder a la generación de tarjetas, considere el modelo WidgetItem con más detalle. Se implementa a través de la clase sellada kotlin, lo que hace que el modelo sea más flexible, y trabajar con él es más conveniente.

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

Cree RemoteViews y determine su respuesta a través de 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
                        )
                    )
                }
            }
        }
    }

El método setOnClickFillInIntent asigna la intención viewId especificada, que se combinará con el PendingIntent principal para determinar el comportamiento al hacer clic en la vista con este viewId. De esta manera podemos responder a los clics de los usuarios en nuestro WidgetProvider.

Actualización manual de widgets


Se estableció un tiempo de actualización de media hora para nuestro widget. Puede actualizarlo más a menudo, por ejemplo, bailando con WorkManager, pero ¿por qué cargar su red y batería? Tal comportamiento en las primeras etapas de desarrollo parecía adecuado.

Todo cambió cuando el "negocio" notó que cuando el usuario mira el widget, los datos irrelevantes se muestran en él: "Aquí en mi iPhone, abro el widget y hay los datos MÁS actualizados de mi perfil".

La situación es común: iOS genera nuevas tarjetas para CADA pantalla de widget, porque para esto tienen una pantalla especial, y Android no tiene tales eventos para el widget en principio. Tenía que tener en cuenta que algunas loterías se llevan a cabo una vez cada 15 minutos, por lo que el widget debe proporcionar información actualizada: desea participar en algún tipo de sorteo, pero ya ha pasado.

Para salir de esta situación desagradable y de alguna manera resolver el problema con la actualización de datos, propuse e implementé una solución probada con el tiempo: el botón "actualizar".

Agregue este botón al diseño con la lista e inicialice su comportamiento cuando se llama a 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)

Los primeros desarrollos mostraron una imagen triste: desde presionar el botón "actualizar" hasta la actualización real, podrían pasar varios segundos. Aunque el widget es generado por nuestra aplicación, en realidad está bajo el control del sistema y se comunica con nuestra aplicación a través de transmisiones.

Aquellos. Cuando hace clic en el botón "actualizar" de nuestro widget, la cadena comienza:

  1. Obtenga la intención en la 'acción' del proveedor onReceive.
  2. AppWidgetManager.ACTION_APPWIDGET_UPDATE.
  3. Llame a onUpdate para todos los widgetsIds especificados en intent-e.
  4. Conéctese en línea para obtener nuevos datos.
  5. Actualice los datos locales y muestre nuevas tarjetas de lista.

Como resultado, la actualización del widget no se veía muy bien, porque al hacer clic en el botón miramos el mismo widget durante un par de segundos. No estaba claro si los datos se actualizaron. ¿Cómo resolver el problema de la respuesta visual?

En primer lugar, agregué el indicador isWidgetLoading con acceso global a través del interactor. La función de este parámetro es bastante simple: no muestre el botón Actualizar mientras se cargan los datos del widget.

En segundo lugar, dividí el proceso de carga de datos en la fábrica en tres etapas:

enum class LoadingStep {
   START,
   MIDDLE,
   END
}

INICIO: inicio de la descarga. En esta etapa, el estado de todas las vistas del adaptador y el indicador de descarga global cambia a "cargando".

MEDIO - la etapa de la carga de datos principal. Después de que se descargan, el indicador de descarga global se coloca en el estado "cargado" y los datos descargados se muestran en el adaptador.

FIN: fin de la descarga. El adaptador no necesita cambiar los datos del adaptador en este paso. Este paso es necesario para procesar correctamente la etapa de actualización de las vistas en WidgetProvider.

Veamos con más detalle cómo se ve la actualización del botón en el proveedor:

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

Ahora veamos qué sucede en el adaptador:

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

imagen

Lógica de trabajo:

  1. Al final de los pasos START y MIDDLE, llamo al método updateWidgets para actualizar el estado de vista administrado por el proveedor.
  2. START «» , MIDDLE.
  3. MIDDLE, «».
  4. MIDDLE, END.
  5. , END, «». , END loadingStep START.



Con la ayuda de dicha implementación, logré un compromiso entre el requisito del "negocio" de ver los datos relevantes en el widget y la necesidad de "extraer" la actualización con demasiada frecuencia.

Espero que el artículo te haya sido útil. Si tienes experiencia creando widgets para Android, cuéntanoslo en los comentarios.

Buena suerte

All Articles