MVVM

Creación de una capa de presentación multiplataforma con MVVM

Brent Edwards

Descargar el ejemplo de código

Con el lanzamiento de Windows 8 y Windows Phone 8, Microsoft dio un gran paso hacia el desarrollo verdaderamente multiplataforma. Ambos sistemas ahora emplean el mismo kernel, lo que significa que basta con un poco de planeación para usar gran parte del código de una aplicación en ambas plataformas. Al emplear el patrón Model-View-ViewModel (MVVM), otros patrones de diseño comunes y algunos trucos, usted podrá escribir una capa de presentación multiplataforma que funcionará tanto en Windows 8 como en Windows Phone 8.

En este artículo echaré una mirada a algunos problemas específicos del desarrollo multiplataforma a los que me enfrenté y hablaré sobre algunas soluciones prácticas para mantener separados los conceptos en la aplicación, sin sacrificar la capacidad de escribir pruebas unitarias de calidad.

Acerca de la aplicación de ejemplo

En la edición de julio de 2013 de MSDN Magazine presenté el código de una aplicación de ejemplo de la Tienda Windows y el comienzo de un marco multiplataforma de código abierto que desarrollé, llamado Charmed (“Cómo aprovechar las características de Windows 8 con MVVM” msdn.microsoft.com/magazine/dn296512). En este artículo mostraré cómo tomé esa aplicación con ese marco de ejemplo y los transformé para que fueran más multiplataforma. Con el mismo marco también desarrollé una aplicación complementaria para Windows Phone 8 que cuenta la misma funcionalidad base. El marco y las aplicaciones de ejemplo están disponibles en GitHub en github.com/brentedwards/Charmed. El código seguirá desarrollándose a medida que avanzo hacia el artículo final en esta serie sobre MVVM, que ahondará en cómo realizar las pruebas para la capa de presentación y otras consideraciones adicionales sobre el código que es apto para someterlo a pruebas.

La aplicación es un simple lector de blogs, llamado Charmed Reader. La versión de la aplicación para cada plataforma tiene solo la funcionalidad suficiente para ilustrar algunos conceptos claves sobre el desarrollo multiplataforma. Ambas versiones tienen experiencias de usuario similares pero se ajustan en apariencia y comportamiento al sistema operativo correspondiente.

Estructura de la solución

Un análisis debido del desarrollo multiplataforma con Visual Studio 2012 debe comenzar por el principio: la estructura de la solución. Aunque Windows 8 y Windows Phone 8 emplean el mismo kernel, sus aplicaciones se compilan de manera diferente y tienen diferentes tipos de proyectos. Existen varias formas para crear una solución con diferentes tipos de proyectos, pero yo prefiero tener una solución que incluya todos los proyectos para todas las plataformas. En la Figura 1 se muestra la estructura de la solución para las aplicaciones de ejemplo que analizaré.

Cross-Platform Charmed Reader Solution StructureFigura 1 Estructura de la solución multiplataforma de Charmed Reader

En Visual Studio más de un archivo de proyecto puede hacer referencia a un mismo archivo de clase físico, lo que nos permite agregar un archivo de clase al seleccionar Agregar como vínculo, tal como se aprecia en la Figura 2.

Adding an Existing Item with Add As LinkFigura 2 Adición de un elemento existente con Agregar como vínculo

Al aprovechar la funcionalidad de Agregar como vínculo, puedo escribir gran parte del código una sola vez y usarlo tanto en Windows 8 como en Windows Phone 8. Sin embargo, no quiero hacer esto para todos los archivos de clase. Como veremos más adelante, hay situaciones donde cada plataforma tiene su propia implementación.

Diferencias en la vista

Aunque podré reutilizar gran parte del código que contiene la lógica de presentación (escrita en C#), no podré hacerlo con el código de presentación propiamente tal (escrito en XAML). Esto se debe a que Windows 8 y Windows Phone 8 tienen variantes ligeramente diferentes de XAML que no son suficientemente compatibles como para poder intercambiarlas. En parte, el problema es sintáctico (puntualmente en el caso de las declaraciones de espacios de nombres), pero, en su mayoría, se debe a que las plataformas cuentan con diferentes controles, además de diferentes conceptos para aplicar estilos. Por ejemplo, Windows 8 se vale mucho de GridView y ListView, pero estos controles no están disponibles en Windows Phone 8. Windows Phone 8, por otra parte, tiene el control Pivot y LongListSelector, que no están disponibles en Windows 8.

Pero, a pesar de que el código XAML no se puede reutilizar, algunos activos de diseño sí se pueden usar en ambas plataformas, especialmente el diseño de la interfaz de usuario de Windows Phone. Esto se debe a que Windows 8 tiene el concepto de vista acoplada, que tiene un ancho fijo de 320 píxeles. La vista acoplada usa 320 píxeles, ya que los diseñadores móviles emplean un ancho de pantalla de 320 píxeles en sus diseños desde hace muchísimo tiempo. En el desarrollo multiplataforma, esto me acomoda, ya que no tengo que inventar un diseño completamente nuevo para la vista acoplada: simplemente puedo adaptar el diseño de Windows Phone. Por supuesto, tengo que tener en mente que cada plataforma tiene sus propios principios de diseño, de modo que quizás tendré que realizar unas pocas variaciones para que cada aplicación se inserte naturalmente en la plataforma.

Como se puede apreciar en la Figura 3, implementé la interfaz de usuario para la vista acoplada de Windows 8 en forma muy similar pero no idéntica a la interfaz de usuario de Windows Phone 8. Evidentemente no soy diseñador, lo que se trasluce en el diseño totalmente aburrido de la interfaz de usuario. Pero espero poder ilustrar lo parecidos que pueden ser los diseños de la interfaz de usuario en la vista acoplada de Windows 8 y Windows Phone 8.

Sample App UI for Windows 8 Snap View (left) and Windows Phone 8 (right)Figura 3 Interfaz de usuario de la aplicación de ejemplo para la vista acoplada de Windows 8 (izquierda) y Windows Phone 8 (derecha)

Diferencias en el código

Uno de los retos interesantes a los que me enfrenté al embarcarme en el desarrollo multiplataforma con Windows 8 y Windows Phone 8 fue que cada plataforma trata ciertas tareas en forma diferente. Por ejemplo, aunque ambas plataformas emplean un esquema de navegación basado en identificadores URI, difieren en los parámetros que aceptan. La creación de los iconos secundarios también es diferente. Aunque ambas plataformas los aceptan, lo que ocurre en cada plataforma cuando se pulsa un icono secundario es fundamentalmente diferente. Cada plataforma también tiene su propia manera de lidiar con la configuración de las aplicaciones y cuenta con clases diferentes para interactuar con estos valores.

Y, por último, hay algunas características en Windows 8 que no existen en Windows Phone 8. Los principales conceptos que emplea mi aplicación de ejemplo para Windows 8 y que no son reconocidos por Windows Phone 8 son los contratos y el menú de accesos. Esto significa que Windows Phone no es compatible con los accesos Compartir y Configuración.

¿Entonces, qué hacemos con estas diferencias fundamentales en el código? Podemos usar diferentes técnicas.

Directivas de compilación Cuando Visual Studio crea los tipos de proyecto de Windows 8 y Windows Phone 8, define automáticamente directivas de compilación propias para cada plataforma en la configuración del proyecto: ­NETFX_CORE para Windows 8 y WINDOWS_PHONE para Windows Phone 8. Con estas directivas de compilación, podemos indicarle a Visual Studio qué es lo que queremos compilar para cada plataforma. De las técnicas que se pueden usar, esta es la más básica, pero también la más desordenada. El código resultante se parece a un queso suizo, ya que queda lleno de agujeros. Aunque esto a veces es un mal necesario, en muchos casos se pueden emplear técnicas mejores.

Abstracción Esta es la técnica más limpia que empleo para lidiar con diferencias en las plataformas e implica abstraer las diferencias propias de cada plataforma en una interfaz o clase abstracta. Esta técnica permite proporcionar implementaciones para cada plataforma de las interfaces y, al mismo tiempo, una interfaz coherente que se puede usar en toda la base de código. En aquellos casos donde hay código auxiliar que funciona en todas las plataformas, podemos implementar una clase abstracta con este código compartido y, luego, proporcionamos las implementaciones propias para cada plataforma. Esta técnica requiere que la interfaz o clase abstracta esté disponible en ambos proyectos, mediante la funcionalidad Agregar como vínculo que mencionamos previamente.

Abstracción más directivas de compilación La técnica final que se puede usar es una combinación de las anteriores. Podemos abstraer las diferencias en una interfaz o clase abstracta y, luego, emplear directivas de compilación en la implementación misma. Esto resulta práctico en los casos donde las diferencias entre las plataformas son lo suficientemente pequeñas como para que no valga la pena separarlas para cada tipo de proyecto.

En la práctica, encontré que rara vez uso las directivas de compilación solas, especialmente en los modelos de vista. Prefiero mantener los modelos limpios, siempre cuando sea posible. Por lo tanto, cuando las directivas de compilación son la mejor solución, generalmente también intercalo un poco de abstracción para ocultar el queso suizo al menos en parte.

Una de las dificultades con las que me topé en mis viajes multiplataforma fue la navegación. La navegación no es igual en Windows 8 y en Windows Phone 8, pero es muy parecida. Windows 8 ahora emplea una navegación basada en identificadores URI, tal como lo ha hecho Windows Phone desde hace siempre. Lo que difiere son los parámetros que se pasan. Windows 8 recibe un objeto único como parámetro, mientras que Windows Phone 8 acepta todos los parámetros que queramos, pero a través de la cadena de consulta. Como Windows Phone usa la cadena de consulta, todos los parámetros deben estar serializados en forma de cadena. Al final, Windows 8 no es tan diferente en este aspecto.

Aunque Windows 8 recibe un solo objeto como parámetro, ese objeto debe serializarse de algún modo cuando otra aplicación pasa a primer plano y mi aplicación se desactiva. El sistema operativo escoge el camino fácil y llama ToString en el parámetro, que no es muy útil para mí cuando la aplicación se vuelve a activar. Como quiero acercar ambas plataformas lo máximo posible en términos de trabajo de desarrollo, conviene serializar el parámetro como cadena antes de la navegación y, luego, deserializarlo una vez que la navegación terminó. Incluso puedo facilitar ese proceso con la implementación de mi navegador.

Observe que deseo evitar referencias directas a las vistas desde mis modelos de vista, así que quiero que la navegación esté controlada por el modelo de vista. Mi solución es emplear una convención en la que las vistas se colocan en una carpeta/espacio de nombres llamada Views y los modelos de vista se colocan en una carpeta/espacio de nombres llamada ViewModels. También me aseguraré de que las vistas tengan el nombre {Algo}Page y los modelos de vista tengan el nombre {Algo}ViewModel. Al respetar esta convención, puedo proporcionar una lógica sencilla para resolver una instancia de una vista, en función del tipo del modelo de vista.

Ahora tengo que decidir qué otras funcionalidades necesito para la navegación:

  • Una navegación controlada por el modelo de vista
  • La capacidad de retroceder
  • Para Windows Phone 8, la capacidad de eliminar una entrada de la pila de retroceso

Las primeras dos son fáciles. Después explicaré la necesidad de eliminar una entrada de la pila de retroceso, pero esa funcionalidad está incorporada en Windows Phone 8 pero no en Windows 8.

Tanto Windows 8 como Windows Phone 8 emplean clases para la navegación que no se simulan fácilmente. Como uno de mis principales objetivos con estas aplicaciones es que sean aptas para someterlas a pruebas, quiero abstraer este código detrás de una interfaz que sea simulable. Por lo tanto, mi navegador empleará una combinación de abstracción y directivas de compilación. Esto me deja con la siguiente interfaz:

public interface INavigator
{
  bool CanGoBack { get; }
  void GoBack();
  void NavigateToViewModel<TViewModel>(object parameter = null);
#if WINDOWS_PHONE
  void RemoveBackEntry();
#endif // WINDOWS_PHONE
}

Observe el uso de #if WINDOWS_PHONE. Esto le indica al compilador que compile RemoveBackEntry en la definición de la interfaz solo cuando la directiva de compilación WINDOWS_PHONE está definida, como en el caso de los proyectos para Windows Phone 8. Y esta es mi implementación, tal como se observa en la Figura 4.

Figura 4 Implementación de INavigator

public sealed class Navigator : INavigator
{
  private readonly ISerializer serializer;
  private readonly IContainer container;
#if WINDOWS_PHONE
  private readonly Microsoft.Phone.Controls.PhoneApplicationFrame frame;
#endif // WINDOWS_PHONE
  public Navigator(
    ISerializer serializer,
    IContainer container
#if WINDOWS_PHONE
    , Microsoft.Phone.Controls.PhoneApplicationFrame frame
#endif // WINDOWS_PHONE
    )
  {
    this.serializer = serializer;
    this.container = container;
#if WINDOWS_PHONE
    this.frame = frame;
#endif // WINDOWS_PHONE
  }
  public void NavigateToViewModel<TViewModel>(object parameter = null)
  {
    var viewType = ResolveViewType<TViewModel>();
#if NETFX_CORE
    var frame = (Frame)Window.Current.Content;
#endif // NETFX_CORE
      if (parameter != null)
                             {
#if WINDOWS_PHONE
      this.frame.Navigate(ResolveViewUri(viewType, parameter));
#else
      frame.Navigate(viewType, this.serializer.Serialize(parameter));
#endif // WINDOWS_PHONE
    }
    else
    {
#if WINDOWS_PHONE
      this.frame.Navigate(ResolveViewUri(viewType));
#else
      frame.Navigate(viewType);
#endif // WINDOWS_PHONE
    }
  }
  public void GoBack()
  {
#if WINDOWS_PHONE
    this.frame.GoBack();
#else
    ((Frame)Window.Current.Content).GoBack();
#endif // WINDOWS_PHONE
  }
  public bool CanGoBack
  {
    get
    {
#if WINDOWS_PHONE
      return this.frame.CanGoBack;
#else
      return ((Frame)Window.Current.Content).CanGoBack;
#endif // WINDOWS_PHONE
    }
  }
  private static Type ResolveViewType<TViewModel>()
  {
    var viewModelType = typeof(TViewModel);
    var viewName = viewModelType.AssemblyQualifiedName.Replace(
      viewModelType.Name,
      viewModelType.Name.Replace("ViewModel", "Page"));
    return Type.GetType(viewName.Replace("Model", string.Empty));
  }
  private Uri ResolveViewUri(Type viewType, object parameter = null)
  {
    var queryString = string.Empty;
    if (parameter != null)
    {
      var serializedParameter = this.serializer.Serialize(parameter);
      queryString = string.Format("?parameter={0}", serializedParameter);
    }
    var match = System.Text.RegularExpressions.Regex.Match(
      viewType.FullName, @"\.Views.*");
    if (match == null || match.Captures.Count == 0)
    {
      throw new ArgumentException("Views must exist in Views namespace.");
    }
    var path = match.Captures[0].Value.Replace('.', '/');
    return new Uri(string.Format("{0}.xaml{1}", path, queryString),
      UriKind.Relative);
  }
#if WINDOWS_PHONE
  public void RemoveBackEntry()
  {
    this.frame.RemoveBackEntry();
  }
#endif // WINDOWS_PHONE
}

Debo hacer hincapié en algunas partes de la implementación de Navigator en la Figura 4, especialmente el uso de las directivas de compilación WINDOWS_PHONE y NETFX_CORE. Esto me permite mantener separado el código propio de cada plataforma dentro de un mismo archivo de código. También quiero llamar la atención sobre el método ResolveViewUri y, en concreto, la forma en que se define el parámetro de la cadena de consulta. Para que las cosas sean coherentes en ambas plataformas, permito que se pase un solo parámetro. Ese parámetro luego se serializará y transmitirá en la navegación específica de cada plataforma. En el caso de Windows Phone 8, ese parámetro se pasará a través de la variable “parameter” en la cadena de consulta.

Por supuesto, esta implementación del navegador es sumamente limitada, sobre todo porque depende de una convención demasiado simplista. Cuando usamos una biblioteca de MVVM, como por ejemplo Caliburn.Micro, esta puede encargarse de la navegación en forma más robusta. En nuestra propia navegación, sin embargo, es probable que de todas maneras empleemos esta técnica de abstracción con directivas de compilación para nivelar las diferencias en las plataformas que puedan existir en las bibliotecas mismas.

Configuración de la aplicación

La configuración de la aplicación es otra área donde Windows 8 y Windows Phone 8 divergen. Cada plataforma tiene la capacidad de guardar la configuración de la aplicación con bastante facilidad y sus implementaciones son, de hecho, bastante similares. Difieren en las clases que emplean, pero ambas usan clases que no se simulan fácilmente, lo que se contrapone a las pruebas unitarias para el modelo de vista. Por lo tanto, optaré una vez más por la abstracción con directivas de compilación. Primero tengo que decidir cómo se verán mis interfaces. La interfaz debe:

  • Agregar o actualizar un valor de configuración.
  • Tratar de obtener un valor sin generar una excepción o error.
  • Eliminar un valor.
  • Determinar si existe un valor para una clave dada.

Todo esto es relativamente sencillo, así que la interfaz será bastante simple:

public interface ISettings
{
  void AddOrUpdate(string key, object value);
  bool TryGetValue<T>(string key, out T value);
  bool Remove(string key);
  bool ContainsKey(string key);
}

Como ambas plataformas tendrán la misma funcionalidad, no tengo que lidiar con directivas de compilación en la interfaz, así que los modelos de vista quedan limpios y ordenados. En la Figura 5 vemos la implementación de la interfaz ISettings.

Figura 5. Implementación de ISettings

public sealed class Settings : ISettings
{
  public void AddOrUpdate(string key, object value)
  {
#if WINDOWS_PHONE
    IsolatedStorageSettings.ApplicationSettings[key] = value;
    IsolatedStorageSettings.ApplicationSettings.Save();
#else
    ApplicationData.Current.RoamingSettings.Values[key] = value;
#endif // WINDOWS_PHONE
  }
  public bool TryGetValue<T>(string key, out T value)
  {
#if WINDOWS_PHONE
    return IsolatedStorageSettings.ApplicationSettings.TryGetValue<T>(
      key, out value);
#else
    var result = false;
    if (ApplicationData.Current.RoamingSettings.Values.ContainsKey(key))
    {
      value = (T)ApplicationData.Current.RoamingSettings.Values[key];
      result = true;
    }
    else
    {
      value = default(T);
    }
    return result;
#endif // WINDOWS_PHONE
  }
  public bool Remove(string key)
  {
#if WINDOWS_PHONE
    var result = IsolatedStorageSettings.ApplicationSettings.Remove(key);
    IsolatedStorageSettings.ApplicationSettings.Save();
    return result;
#else
    return ApplicationData.Current.RoamingSettings.Values.Remove(key);
#endif // WINDOWS_PHONE
  }
  public bool ContainsKey(string key)
  {
#if WINDOWS_PHONE
    return IsolatedStorageSettings.ApplicationSettings.Contains(key);
#else
    return ApplicationData.Current.RoamingSettings.Values.ContainsKey(key);
#endif // WINDOWS_PHONE
  }
}

La implementación que aparece en la Figura 5 es bastante simple, igual que la interfaz ISettings misma. Ilustra cómo cada plataforma difiere solo ligeramente y requiere solo de código ligeramente diferente para agregar, recuperar y eliminar los valores de configuración de la aplicación. El único punto que quiero resaltar aquí es que la versión para Windows 8 del código emplea una configuración móvil: una funcionalidad propia de Windows 8 que permite que una aplicación almacene la configuración en la nube, de modo que el usuario podrá abrir la aplicación en otro dispositivo con Windows 8 y se aplicará la misma configuración.

Iconos secundarios

Tal como dije antes, tanto Windows 8 como Windows Phone 8 permiten crear iconos secundarios: iconos que se crean desde el programa y se anclan en la pantalla principal del usuario. Los iconos secundarios ofrecen funcionalidades de vínculos profundos, lo que significa que el usuario puede pulsar un icono secundario y saltar directamente a una parte específica de la aplicación. Esto resulta valioso para los usuarios, ya que esto les permite, en esencia, marcar partes de la aplicación y saltar directamente a esta sin perder tiempo. En el caso de mis aplicaciones de ejemplo, quiero que el usuario pueda crear un marcador de un mensaje publicado en el blog (FeedItem) y saltar directamente a este desde la pantalla principal.

Lo interesante de los iconos secundarios es que, aunque ambas plataformas cuentan con ellos, la forma de implementarlas es bastante diferente. Esto es un caso perfecto para explorar un ejemplo de abstracción un poco más complejo.

Si leyó mi artículo de julio de 2013, podrá recordar que hablé sobre cómo abstraer los iconos secundarios para el desarrollo con MVVM en Windows 8. La solución que presenté funciona perfectamente con Windows 8, pero ni siquiera se compila para Windows Phone 8. Lo que presento aquí es la evolución natural de esa solución para Windows 8, que funciona bien tanto en Windows 8 como en Windows Phone 8.

Así se veía la interfaz en Windows 8:

public interface ISecondaryPinner
{
  Task<bool> Pin(FrameworkElement anchorElement,
    Placement requestPlacement, TileInfo tileInfo);
  Task<bool> Unpin(FrameworkElement anchorElement,
    Placement requestPlacement, string tileId);
  bool IsPinned(string tileId);
}

Tal como mencioné, esta interfaz no se compila para Windows Phone 8. Especialmente problemático es el uso de FrameworkElement y la enumeración Placement. Ninguno de estos es igual en Windows Phone 8 que en Windows 8. Pretendo modificar esta interfaz un poco, para poder usarla sin problemas en ambas plataformas. Como puede ver, el método ISecondaryPinner.Pin recibe como parámetro un objeto TileInfo. TileInfo es un simple objeto de transferencia de datos (DTO) que creé con la información necesaria para crear un icono secundario. Lo más fácil para mí es trasladar los parámetros que necesita la versión para Windows 8 a la clase TileInfo y, luego, emplear directivas de compilación para compilarlos en la versión para Windows 8 de la clase TileInfo. Al hacer esto, la interfaz ISecondaryPinner cambia a:

public interface ISecondaryPinner
{
  Task<bool> Pin(TileInfo tileInfo);
  Task<bool> Unpin(TileInfo tileInfo);
  bool IsPinned(string tileId);
}

Puede ver que los métodos siguen siendo los mismos, pero los parámetros para Pin y Unpin cambiaron un poco. Como resultado de esto, el aspecto de la clase TileInfo también cambió:

public sealed class TileInfo
{
  public string TileId { get; set; }
  public string ShortName { get; set; }
  public string DisplayName { get; set; }
  public string Arguments { get; set; }
  public Uri LogoUri { get; set; }
  public Uri WideLogoUri { get; set; }
  public string AppName { get; set; }
  public int? Count { get; set; }
#if NETFX_CORE
  public Windows.UI.StartScreen.TileOptions TileOptions { get; set; }
  public Windows.UI.Xaml.FrameworkElement AnchorElement { get; set; }
  public Placement RequestPlacement { get; set; }
#endif // NETFX_CORE
}

En realidad, prefiero proporcionar constructores para cada situación en la que se emplean objetos de transferencia de datos auxiliares como este, para explicitar qué parámetros se necesitan en qué momento. Para abreviar la exposición, dejé los diferentes constructores fuera de este fragmento del código de TileInfo, pero puede verlo en toda su gloria y majestad en el código de ejemplo.

TileInfo ahora tiene todas las propiedades necesarias para Windows 8 y Windows Phone 8, así que el siguiente paso es implementar la interfaz ISecondaryPinner. Como la implementación será bastante diferente para cada plataforma, usaré la misma interfaz en ambos tipos de proyecto, pero entregaré implementaciones específicas para cada plataforma en cada proyecto. Esto reducirá el efecto de queso suizo que provocarían las directivas de compilación en este caso. En la Figura 6 vemos la implementación para Windows 8 de ISecondaryPinner, ahora con las firmas actualizadas de los métodos.

Figura 6 Implementación de ISecondaryPinner para Windows 8

public sealed class Win8SecondaryPinner : ISecondaryPinner
{
  public async Task<bool> Pin(TileInfo tileInfo)
  {
    if (tileInfo == null)
    {
      throw new ArgumentNullException("tileInfo");
    }
    var isPinned = false;
    if (!SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(
        tileInfo.TileId,
        tileInfo.ShortName,
        tileInfo.DisplayName,
        tileInfo.Arguments,
        tileInfo.TileOptions,
        tileInfo.LogoUri);
      if (tileInfo.WideLogoUri != null)
      {
        secondaryTile.WideLogo = tileInfo.WideLogoUri;
      }
        isPinned = await secondaryTile.RequestCreateForSelectionAsync(
          GetElementRect(tileInfo.AnchorElement), tileInfo.RequestPlacement);
    }
    return isPinned;
  }
  public async Task<bool> Unpin(TileInfo tileInfo)
  {
    var wasUnpinned = false;
    if (SecondaryTile.Exists(tileInfo.TileId))
    {
      var secondaryTile = new SecondaryTile(tileInfo.TileId);
      wasUnpinned = await secondaryTile.RequestDeleteForSelectionAsync(
        GetElementRect(tileInfo.AnchorElement), tileInfo.RequestPlacement);
    }
    return wasUnpinned;
  }
  public bool IsPinned(string tileId)
  {
    return SecondaryTile.Exists(tileId);
  }
  private static Rect GetElementRect(FrameworkElement element)
  {
    GeneralTransform buttonTransform = element.TransformToVisual(null);
    Point point = buttonTransform.TransformPoint(new Point());
    return new Rect(point, new Size(element.ActualWidth,
      element.ActualHeight));
  }
}

Es importante tener en cuenta que en Windows 8 no podemos crear discretamente un icono secundario en forma programática sin que lo apruebe el usuario. Esto es diferente en Windows Phone 8, donde sí está permitido. Por lo tanto, debo crear una instancia de SecondaryTile y llamar RequestCreateForSelectionAsync, que mostrará un cuadro de diálogo en la posición que yo indique, para pedirle al usuario que apruebe la creación (o eliminación) del icono secundario. El método auxiliar GetElementRect recibe un FrameworkElement (que será el botón que el usuario presione para anclar el icono secundario) y luego calcula el rectángulo necesario para posicionar el cuadro de diálogo de la solicitud.

En la Figura 7 vemos la implementación para Windows Phone 8 de ISecondaryPinner.

Figura 7 Implementación de ISecondaryPinner para Windows Phone 8

public sealed class WP8SecondaryPinner : ISecondaryPinner
{
  public Task<bool> Pin(TileInfo tileInfo)
  {
    var result = false;
    if (!this.IsPinned(tileInfo.TileId))
    {
      var tileData = new StandardTileData
      {
        Title = tileInfo.DisplayName,
        BackgroundImage = tileInfo.LogoUri,
        Count = tileInfo.Count,
        BackTitle = tileInfo.AppName,
        BackBackgroundImage = new Uri("", UriKind.Relative),
        BackContent = tileInfo.DisplayName
      };
      ShellTile.Create(new Uri(tileInfo.TileId, UriKind.Relative), 
        tileData);
      result = true;
    }
  return Task.FromResult<bool>(result);
  }
  public Task<bool> Unpin(TileInfo tileInfo)
  {
    ShellTile tile = this.FindTile(tileInfo.TileId);
    if (tile != null)
    {
      tile.Delete();
    }
    return Task.FromResult<bool>(true);
  }
  public bool IsPinned(string tileId)
  {
    return FindTile(tileId) != null;
  }
  private ShellTile FindTile(string uri)
  {
    return ShellTile.ActiveTiles.FirstOrDefault(
      tile => tile.NavigationUri.ToString() == uri);
  }
}

Quisiera destacar algunos elementos de la implementación para Windows Phone 8. La primera es cómo el icono secundario se crea mediante la clase StandardTileData y el método estático ShellTile.Create. El segundo es que la implementación para Windows Phone 8 para crear iconos secundarios no es asincrónica. Como la implementación para Windows 8 es asincrónica, tuve que programa la interfaz para que funcionara con el patrón async/await. Afortunadamente, resulta muy fácil lograr que un método, que de lo contrario sería no-asincrónico, resulte compatible con el patrón async/await al emplear el método genérico y estático Task.FromResult. Los modelos de vista que usan la interfaz ISecondaryPinner no tienen que preocuparse de que Windows 8 de hecho sea inherentemente asincrónico mientras que Windows Phone 8 no lo es.

Ahora vemos cómo Windows 8 (Figura 6) y Windows Phone 8 (Figura 7) difieren en la implementación de los iconos secundarios. Sin embargo, esto tampoco lo es todo acerca de los iconos secundarios. Solo mostré la implementación de la interfaz ISecondaryPinner. Como cada plataforma es diferente y debe entregar valores para propiedades diferentes de la clase TileInfo, también tengo que proporcionar implementaciones específicas para cada plataforma de los modelos de vista que la usan. Para mi aplicación de ejemplo, proporcionaré la posibilidad de anclar un mensaje de blog FeedItem, de modo que el modelo de vista en cuestión es FeedItemViewModel.

Existen algunas funcionalidades en común entre Windows 8 y Windows Phone 8, desde el punto de vista del modelo de vista. Cuando el usuario ancla un FeedItem, quiero que ambas plataformas guarden el elemento FeedItem en forma local, para que se pueda volver a cargar una vez que el usuario pulse el icono secundario. Por el otro lado, cuando el usuario desancla un FeedItem, quiero que ambas plataformas eliminen ese FeedItem del almacenamiento local. Ambas plataformas tendrán que implementar esa funcionalidad compartida, pero deberán expandirla con las implementaciones propias de cada plataforma para la funcionalidad del icono secundario. Por lo tanto, conviene proporcionar una clase base que implemente la funcionalidad compartida y dejar disponible esa clase base en ambas plataformas. Luego, cada plataforma puede heredar esa clase base con clases que proporcionen las implementaciones específicas para cada plataforma para anclar y desanclar los iconos secundarios.

En la Figura 8 vemos el FeedItemViewModel, que es la clase base de la que se derivarán ambas plataformas. FeedItemViewModel contiene todo el código compartido por ambas plataformas.

Figura 8 Clase base FeedItemViewModel

public abstract class FeedItemViewModel : ViewModelBase<FeedItem>
{
  private readonly IStorage storage;
  protected readonly ISecondaryPinner secondaryPinner;
  public FeedItemViewModel(
    ISerializer serializer,   
    IStorage storage,
    ISecondaryPinner secondaryPinner)
    : base(serializer)
  {
    this.storage = storage;
    this.secondaryPinner = secondaryPinner;
  }
  public override void LoadState(FeedItem navigationParameter,
    Dictionary<string, object> pageState)
  {
    this.FeedItem = navigationParameter;
  }
  protected async Task SavePinnedFeedItem()
  {
    var pinnedFeedItems =
      await this.storage.LoadAsync<List<FeedItem>>(
      Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems == null)
    {
      pinnedFeedItems = new List<FeedItem>();
    }
    pinnedFeedItems.Add(feedItem);
    await this.storage.SaveAsync(Constants.PinnedFeedItemsKey, 
      pinnedFeedItems);
  }
  protected async Task RemovePinnedFeedItem()
  {
    var pinnedFeedItems =
      await this.storage.LoadAsync<List<FeedItem>>(
      Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
       var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id ==
         this.FeedItem.Id);
       if (pinnedFeedItem != null)
       {
         pinnedFeedItems.Remove(pinnedFeedItem);
       }
      await this.storage.SaveAsync(Constants.PinnedFeedItemsKey, 
        pinnedFeedItems);
    }
  }
  private FeedItem feedItem;
  public FeedItem FeedItem
  {
    get { return this.feedItem; }
    set { this.SetProperty(ref this.feedItem, value); }
  }
  private bool isFeedItemPinned;
  public bool IsFeedItemPinned
  {
    get { return this.isFeedItemPinned; }
    set { this.SetProperty(ref this.isFeedItemPinned, value); }
  }
}

Con una clase base para facilitar el guardado y la eliminación de FeedItems, puedo pasar a las implementaciones específicas para cada plataforma. La implementación concreta de FeedItemViewModel para Windows 8 y el uso de la clase TileInfo con las propiedades importantes para Windows 8 aparecen en la Figura 9.

Figura 9 Implementación concreta de FeedItemViewModel para Windows 8

public sealed class Win8FeedItemViewModel : FeedItemViewModel
{
  private readonly IShareManager shareManager;
  public Win8FeedItemViewModel(
    ISerializer serializer,
    IStorage storage,
    ISecondaryPinner secondaryPinner,
    IShareManager shareManager)
    : base(serializer, storage, secondaryPinner)
  {
  this.shareManager = shareManager;
  }
  public override void LoadState(Models.FeedItem navigationParameter,
    Dictionary<string, object> pageState)
  {
    base.LoadState(navigationParameter, pageState);
    this.IsFeedItemPinned =
      this.secondaryPinner.IsPinned(FormatSecondaryTileId());
  }
  public override void SaveState(Dictionary<string, object> pageState)
  {
    base.SaveState(pageState);
    this.shareManager.Cleanup();
  }
  public async Task Pin(Windows.UI.Xaml.FrameworkElement anchorElement)
  {
    // Pin the feed item, then save it locally to make sure it is still
    // available when they return.
    var tileInfo = new TileInfo(
      this.FormatSecondaryTileId(),
      this.FeedItem.Title,
      this.FeedItem.Title,
      Windows.UI.StartScreen.TileOptions.ShowNameOnLogo |
        Windows.UI.StartScreen.TileOptions.ShowNameOnWideLogo,
      new Uri("ms-appx:///Assets/Logo.png"),
      new Uri("ms-appx:///Assets/WideLogo.png"),
      anchorElement,
      Windows.UI.Popups.Placement.Above,
      this.FeedItem.Id.ToString());
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
    if (this.IsFeedItemPinned)
    {
       await SavePinnedFeedItem();
    }
  }
  public async Task Unpin(Windows.UI.Xaml.FrameworkElement anchorElement)
  {
    // Unpin, then delete the feed item locally.
  var tileInfo = new TileInfo(this.FormatSecondaryTileId(), anchorElement,
    Windows.UI.Popups.Placement.Above);
    this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(tileInfo);
    if (!this.IsFeedItemPinned)
    {
      await RemovePinnedFeedItem();
    }
  }
  private string FormatSecondaryTileId()
  {
    return string.Format(Constants.SecondaryIdFormat, this.FeedItem.Id);
  }
}

En la Figura 10 vemos la implementación concreta de FeedItemViewModel para Windows Phone 8. Al usar TileInfo en Windows Phone 8 se usan menos propiedades que en Windows 8.

Figura 10 Implementación concreta de FeedItemViewModel para Windows Phone 8

public sealed class WP8FeedItemViewModel : FeedItemViewModel
{
  public WP8FeedItemViewModel(
    ISerializer serializer,
    IStorage storage,
    ISecondaryPinner secondaryPinner)
    : base(serializer, storage, secondaryPinner)
  {
  }
  public async Task Pin()
  {
    // Pin the feed item, then save it locally to make sure it is still
    // available when they return.
    var tileInfo = new TileInfo(
      this.FormatTileIdUrl(),
      this.FeedItem.Title,
      Constants.AppName,
      new Uri("/Assets/ApplicationIcon.png", UriKind.Relative));
    this.IsFeedItemPinned = await this.secondaryPinner.Pin(tileInfo);
    if (this.IsFeedItemPinned)
    {
      await this.SavePinnedFeedItem();
    }
  }
  public async Task Unpin()
  {
    // Unpin, then delete the feed item locally.
    var tileInfo = new TileInfo(this.FormatTileIdUrl());
    this.IsFeedItemPinned = !await this.secondaryPinner.Unpin(tileInfo);
    if (!this.IsFeedItemPinned)
    {
      await this.RemovePinnedFeedItem();
    }
  }
  private string FormatTileIdUrl()
  {
    var queryString = string.Format("parameter={0}", FeedItem.Id);
    return string.Format(Constants.SecondaryUriFormat, queryString);
  }
}

Después de las implementaciones para Windows 8 (Figura 9) y Windows Phone 8 (Figura 10) de FeedItemViewModel, mis aplicaciones están configuradas para anclar los iconos secundarios en las pantallas principales respectivas. Lo único que queda por hacer para completar la funcionalidad es procesar el caso cuando el usuario pulsa los iconos secundarios anclados. Lo que pretendo en ambas aplicaciones es iniciar la aplicación para que se abra en el mensaje de blog representado por el icono secundario, pero permitir que el usuario presione el botón Atrás y que vuelva a la pantalla principal con todos los blogs, en vez de salir de la aplicación misma.

Desde el punto de vista de Windows 8, nada cambia en comparación con lo que vimos en el artículo de julio de 2013. En la Figura 11 vemos el código de Windows 8 sin cambios para controlar en qué momento se inicia la aplicación, que es un fragmento del archivo de clase App.xaml.cs.

Figura 11 Inicio de la aplicación desde Windows 8

protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
  Frame rootFrame = Window.Current.Content as Frame;
  if (rootFrame.Content == null)
  {
    Ioc.Container.Resolve<INavigator>().
      NavigateToViewModel<MainViewModel>();
  }
  if (!string.IsNullOrWhiteSpace(args.Arguments))
  {
    var storage = Ioc.Container.Resolve<IStorage>();
    List<FeedItem> pinnedFeedItems =
      await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
    if (pinnedFeedItems != null)
    {
      int id;
      if (int.TryParse(args.Arguments, out id))
      {
        var pinnedFeedItem = pinnedFeedItems.FirstOrDefault(fi => fi.Id == id);
        if (pinnedFeedItem != null)
        {
          Ioc.Container.Resolve<INavigator>().           
             NavigateToViewModel<FeedItemViewModel>(pinnedFeedItem);
        }
      }
    }
  }
  Window.Current.Activate();
}

En Windows Phone 8 la cosa se pone un poco más complicada. En este caso, el icono secundario recibe un identificador URI en vez de simplemente parámetros. Esto significa que Windows Phone 8 puede iniciar automáticamente la aplicación en la página que yo deseo, sin pasar primero por un punto de inicio centralizado, como en Windows 8. Como quiero entregar una experiencia coherente en ambas plataformas, creé un punto de inicio centralizado por mi propia cuenta para Windows Phone 8, al que llamé SplashViewModel, tal como se aprecia en la Figura 12. Configuré mi proyecto para que se inicie a través de este, cada vez que se inicia la aplicación, sin importar si es mediante un icono secundario o no.

Figura 12 SplashViewModel para Windows Phone 8

public sealed class SplashViewModel : ViewModelBase<int?>
{
  private readonly IStorage storage;
  private readonly INavigator navigator;
  public SplashViewModel(
    IStorage storage,
    INavigator navigator,
    ISerializer serializer)
    : base(serializer)
  {
    this.storage = storage;
    this.navigator = navigator;
  }
  public override async void LoadState(
    int? navigationParameter, Dictionary<string, object> pageState)
  {
    this.navigator.NavigateToViewModel<MainViewModel>();
    this.navigator.RemoveBackEntry();
    if (navigationParameter.HasValue)
    {
      List<FeedItem> pinnedFeedItems =
        await storage.LoadAsync<List<FeedItem>>(Constants.PinnedFeedItemsKey);
      if (pinnedFeedItems != null)
      {
        var pinnedFeedItem =
          pinnedFeedItems.FirstOrDefault(fi => 
            fi.Id == navigationParameter.Value);
        if (pinnedFeedItem != null)
        {
  this.navigator.NavigateToViewModel<FeedItemViewModel>(pinnedFeedItem);
        }
      }
    }
  }
}

El código de SplashViewModel es bastante sencillo. El único punto que pretendo destacar es que quiero asegurarme de eliminar esta página de la pila de retroceso. De lo contrario, al retroceder, el usuario jamás podría salir de la aplicación. Siempre volvería a la aplicación, cada vez que retroceda a esta página. Esto es donde entra en juego una adición valiosa de INavigator para Windows Phone 8: RemoveBackEntry. Después de navegar a MainViewModel, llamo RemoveBackEntry para sacar la página de presentación de la pila de retroceso. Ahora esta página se usa una sola vez, cuando se inicia la aplicación.

En resumen

En este artículo analicé el desarrollo multiplataforma con Windows 8 y Windows Phone 8. Hablé sobre lo que se puede reutilizar entre las plataformas (diseños y código) y qué no (XAML). También vimos algunas de las dificultades a las que se enfrentan los desarrolladores en las aplicaciones multiplataforma y presenté varias soluciones para estos problemas. Espero que estas soluciones se puedan aplicar más que solamente al caso específico de la navegación, la configuración de aplicaciones y los iconos secundarios que vimos en este artículo. Estas soluciones permiten que los modelos de vista se presten para someterlos a pruebas, al poder abstraer algunas de las interacciones con el sistema operativo y usar interfaces para simular esas interacciones.

En el siguiente artículo veremos en forma más específica las pruebas unitarias mismas para las aplicaciones multiplataforma, ahora que ya están listas para ser probadas. Analizaré en mayor detalle por qué tomé algunas de las decisiones que tomé en cuanto a las pruebas unitarias y cómo lo hago yo para realizar las pruebas en las aplicaciones.

Al enfrentar el desarrollo multiplataforma con la intención de proporcionar una experiencia de usuario similar en ambas plataformas y al planear un poco por adelantado, estamos en condiciones de escribir aplicaciones que maximicen la reutilización del código y fomenten las pruebas unitarias. Podemos aprovechar las características propias de cada plataforma, como el menú de Accesos de Windows 8, sin sacrificar la experiencia que ofrece cada plataforma.

Brent Edwards es un consultor asociado principal en Magenic, una firma que desarrolla aplicaciones personalizadas que se enfoca en la pila de Microsoft y en el desarrollo de aplicaciones móviles. También es el cofundador del grupo de usuarios Twin Cities Windows 8 User Group en Minneapolis, Minn. Puede ponerse en contacto con él en brente@magenic.com.

GRACIAS al siguiente experto técnico por su ayuda en la revisión de este artículo: Jason Bock (Magenic)