На примере простого Windows 8.1 приложения посмотрим насколько просто переносить приложения с WinRT (Windows 8.1) на Silverlight (WP8.0) и по ходу разберем несколько подводных камней.
Вы наверняка слышали о продвигаемом способе разработки Windows / Windows Phone приложений – Universal Apps. Подход здравый, но рыночная доля WP8.1 пока еще только начинает расти, а приложение нужно делать сейчас, поэтому остановимся на WP8.0 (Silverlight). Преимущества: поддержка девайсов как на WP8.0 так и на WP8.1, поддержка всех типов экранов без «черных полос» (в отличие от WP7 приложений), стабильные сторонние библиотеки т.д.
Кратко об исходном приложении
Подопытным кроликом будет выступать почти готовое одностраничное приложение Windows 8.1 для отображения основных курсов валют.
Поскольку приложение бесплатное и без рекламы, функционал жестко лимитирован. Только самые необходимые функции: отображение среднего курса валют (USD, EUR, RUB), график флуктуации за последние 30 дней, отображение курсов по банкам, конвертер валют и поддержка Live Tiles.
Серверную часть оставим за кадром т.к. там нет ничего интересного.
Структура проекта
С прицелом на Universal Apps основные блоки вынесены в юзер контролы, классы данных вынесены в Portable Library, а код отвечающий за получение данных отделен от UI.
Переносим код на Windows Phone 8.0
Создаем пустой проект, выкидываем все лишнее из MainPage, добавляем нужные референсы и NuGet пакеты. Первым делом добавляем как ссылки (Add -> Existing Item -> Add As Link ) файлы классов для обработки данных из Windows 8 проекта.
Проблемы:
◊ Ожидать что неймспейсы сойдутся не приходится (WinRT vs Silverlight), поэтому используем теплый ламповый #if #endif, получается что то типа такого:
#if NETFX_CORE
using Windows.Web.Http;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Networking.BackgroundTransfer;
#endif
#if WINDOWS_PHONE
using System.Net.Http;
#endif
Должен отметить, что хоть мы и не используем Universal Apps, Visual Studio 2013 Update 2 сильно упрощает работу с #if #endif для разных платформ. Появился еще один выпадающий список прямо над кодом, позволяющий быстро переключить платформу без переоткрытия файла. IntelliSense больше не падает, Resharper не отваливается в самый не подходящий момент. Никаких больше «This document is opened by another project.» и т.п.
◊ Поскольку данные мы загружаем с помощью HttpClient (позже будет понятно, почему именно HttpClient), который отсутствует в WP8.0, добавляем NuGet пакет Microsoft.Net.Http. Но и тут не без сюрпризов, без #if не обошлось:
var client = new HttpClient();
byte[] buff;
#if NETFX_CORE
var ibuff = await client.GetBufferAsync(uri);
buff = ibuff.ToArray();
#endif
#if WINDOWS_PHONE
buff = await client.GetByteArrayAsync(uri);
#endif
Вопрос на засыпку, сколько официальных реализаций HttpClient вы знаете?
◊ Полученные данные мы сохраняем в памяти устройства, что бы было что показать следующий раз при отсутствии интернет подключения, но… ну вы поняли:
#if NETFX_CORE
var file = await ApplicationData.Current.LocalFolder.CreateFileAsync(LOCAL_DATA_FILENAME, CreationCollisionOption.ReplaceExisting);
await FileIO.WriteTextAsync(file, json);
#endif
#if WINDOWS_PHONE
var fs = await ApplicationData.Current.LocalFolder.OpenStreamForWriteAsync(LOCAL_DATA_FILENAME, CreationCollisionOption.ReplaceExisting);
using (StreamWriter streamWriter = new StreamWriter(fs))
{
await streamWriter.WriteAsync(json);
}
#endif
Тут стоит помнить о разнице между LocalFolder и LocalCache (начиная с WP8.1), основное отличие в том, что LocalFolder в отличие от LocalCache подвержен встроенному beckup/restore (не путать с roaming) и как следствие ваши данные могут оказаться на совсем другом устройстве (или нескольких) со всеми вытекающими. Подробней как это работает можно посмотреть тут. В нашем случае LocalCache на WP8.0 не доступен, поэтому используем что имеем.
◊ Отдельно стоит упомянуть поддержку шифрования данных, исторически сложилось что у меня уже был самописный кроссплатформенный бинарно совместимый класс шифрования используя AES, который кочует из проекта в проект. Описание этого класса выходит за рамки статьи, скажу только что под WP8.0 существует замечательный класс AesManaged, а под WinRT использую мапперы на нативную реализацию:
#if NETFX_CORE
using Windows.Security.Cryptography;
using Windows.Security.Cryptography.Core;
using Windows.Storage.Streams;
#else
using System.Security.Cryptography;
#endif
Переносим UI на Windows Phone 8.0
После того как вспомогательный код перенесен и успешно компилируется, беремся за интерфейс. Т.к. телефон, планшет или десктоп это совсем не одно и то же. Поэтому и приложение будет выглядеть по-разному. Для этого Windows 8 и Windows Phone проекты будут иметь свои собственные MainPage а юзер контролы мы будем добавлять как ссылки (Add As Link, в будущем все это просто переедет в Shared project). Плюс сами контролы будут адаптироваться под ситуацию, например ширину доступного пространства (не забывает про snapped mode в Windows 8).
Проблемы:
◊ Первое что нужно сделать, это разделить неймспейсы в code behind классах с помощью все тех же #if #endif, пример приводить не буду, просто пользуемся Shift+Alt+F10 на всем что подчеркнуто красным.
◊ Так сложилось что в WP8.0 нет события DataContextChanged, поэтому немного меняем логику, что бы от него избавится.
◊ Поскольку в WP8.0 нет встроенных стилей из WinRTшного XAMLа, таких как «SymbolThemeFontFamily», «BaseTextBlockStyle», «BodyTextBlockStyle» и др. создаем отдельный ResourceDictionary в WP8.0 проекте. Открываем «c:Program Files (x86)Windows Kits8.1Includewinrtxamldesigngeneric.xaml» и выдираем все что нам нужно и кладем в только что созданный ResourceDictionary, попутно заменяя или выкидывая то что не поддерживается на телефоне (CharacterEllipsis -> WordEllipsis, SemiLight -> Light, Typography.* -> c:NUL).
Потом мержим этот справочник в App.xaml:
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="CommonStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
<local:LocalizedStrings xmlns:local="clr-namespace:BXFinanceWP" x:Key="LocalizedStrings"/>
</ResourceDictionary>
</Application.Resources>
Стоит обратить внимание, если у вас уже есть что-то в Application.Resources (например, LocalizedStrings, если вы его не выкинули на первом этапе) его нужно будет поместить внутрь ResourceDictionary, как показано выше.
◊ Уж не знаю почему, но IValueConverter интерфейсы немного отличаются между WinRT и Silverlight, так что правим все общие (добавленные As Link) конверторы что есть в проекте:
public object Convert(object value, Type targetType, object parameter
#if NETFX_CORE
,string language
#endif
#if WINDOWS_PHONE
,System.Globalization.CultureInfo culture
#endif
)
◊ Следующее с чем пришлось столкнуться, это разница объявления сторонних неймспейсов в XAML. В WinRT неймспейсы описываются через «using:», а в Silverlight через «clr-namespace:», пример:
xmlns:Common="using:BXFinanceDashboard.Common"
xmlns:Common="clr-namespace:BXFinanceDashboard.Common"
Решений этой проблемы не так много, и ни одного хорошего, #ifdef для XAML нет. В таких случаях, исходя из конкретной ситуации, можно создавать необходимые контролы в коде, заморачиваться с кстомными build action, выносить все что можно в стили и/или разделять интерфейс на общие и платформо-зависимые файлы. На крайний случай copy-paste.
Но в нашем случае не все так сложно, т.к. в большинстве случаев объявление неймспейса было необходимо что бы подключить конверторы. Поэтому я просто создал по одному ResourceDictionary в каждом проекте и объявил конверторы там. И поскольку приложение не большое, подключил его глобально в App.xaml. Плюс в том, что конверторы не создаются повторно, но нужно помнить, что имена теперь глобальные и не могут повторяться.
На всякий случай приведу листинг, WinRT (Windows 8.1):
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Common="using:BXFinanceDashboard.Common">
<Common:HighlightValueConverter x:Key="HighlightUSDBuyConverter" HighlightBrush="#FFFF4646" />
<Common:HighlightValueConverter x:Key="HighlightUSDSellConverter" HighlightBrush="#FFFF4646" />
<Common:HighlightValueConverter x:Key="HighlightEURBuyConverter" HighlightBrush="#FFFF4646" />
<Common:HighlightValueConverter x:Key="HighlightEURSellConverter" HighlightBrush="#FFFF4646" />
<Common:HighlightValueConverter x:Key="HighlightRUBBuyConverter" HighlightBrush="#FFFF4646" />
<Common:HighlightValueConverter x:Key="HighlightRUBSellConverter" HighlightBrush="#FFFF4646" />
<Common:BoolToVisibilityConverter x:Key="BoolVisibilityConverter"/>
<Common:BoolToVisibilityConverter x:Key="BoolNotVisibilityConverter" IsReversed="True"/>
<Common:OpacityConverter x:Key="OpacityConverter"/>
<Common:OpacityConverter x:Key="OpacityNotConverter" IsReversed="True"/>
</ResourceDictionary>
Silverlight (WP8.0):
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Common="clr-namespace:BXFinanceDashboard.Common">
<Common:HighlightValueConverter x:Key="HighlightUSDBuyConverter" HighlightBrush="#FFFF4646" NormalBrush="{StaticResource PhoneForegroundBrush}" />
<Common:HighlightValueConverter x:Key="HighlightUSDSellConverter" HighlightBrush="#FFFF4646" NormalBrush="{StaticResource PhoneForegroundBrush}" />
<Common:HighlightValueConverter x:Key="HighlightEURBuyConverter" HighlightBrush="#FFFF4646" NormalBrush="{StaticResource PhoneForegroundBrush}" />
<Common:HighlightValueConverter x:Key="HighlightEURSellConverter" HighlightBrush="#FFFF4646" NormalBrush="{StaticResource PhoneForegroundBrush}" />
<Common:HighlightValueConverter x:Key="HighlightRUBBuyConverter" HighlightBrush="#FFFF4646" NormalBrush="{StaticResource PhoneForegroundBrush}" />
<Common:HighlightValueConverter x:Key="HighlightRUBSellConverter" HighlightBrush="#FFFF4646" NormalBrush="{StaticResource PhoneForegroundBrush}" />
<Common:BoolToVisibilityConverter x:Key="BoolVisibilityConverter"/>
<Common:BoolToVisibilityConverter x:Key="BoolNotVisibilityConverter" IsReversed="True"/>
<Common:OpacityConverter x:Key="OpacityConverter"/>
<Common:OpacityConverter x:Key="OpacityNotConverter" IsReversed="True"/>
</ResourceDictionary>
◊ Сюрпризом стало отличие шрифтов на Windows и Windows Phone. По какой-то причине для отображения стрелочек изменения курса я выбрал символы #128314 / #128315 из «Segoe UI Symbol» но недоглядел, что они называются «Up/Down-Pointing Red Triangle». На Windows они выглядят именно так как нужно, а на Windows Phone они оба красные как не перекрашивай, и к тому же символ уже по ширине. Порывшись еще, нашел более подходящие #9650 / #9660. Не сразу было понятно в чем дело, пришлось даже провести сравнение на телефоне:
Кстати не забывайте использовать именно глифы (особенно в кнопках на апп баре), а не свои картинки. Задолбаетесь генерить десяток картинок под разные DPI, для примера скрин другого приложения с Nokia 1520:
Адаптируем приложение под телефон
Приложение собралось, хорошо, но останавливаться не стоит. Все-таки мобильный это не то же самое что планшет или десктоп (кстати приложение на десктопе само обновляется если висит на экране, удобно для всяких информационных панелей).
Layout
Для переключения графика курсов, списка банков и конвертера валют берем более привычный на Windows Phone контрол Pivot. Включаем трей (прятать трей без веской причины плохо), подгоняем верхний отступ, что бы трей не съедал много места. Благо в WP8 с треем меньше проблем чем на WP7, прыгает реже, но не забываем что в коде цвет трея можно указывать не раньше Page.Loaded. Что б избежать некрасивой полоски между треем и страницей ствим прозрачность 0.99.
Поскольку экран телефона уже по ширине, необходимо что бы все основные контролы адаптировались соответственно (так же это полезно для snapped mode на десктопе).
Список банков автоматически отображает только одну валюту если места недостаточно, а внизу появляется переключатель. Причем важным требованием здесь было, что бы прокрутка списка оставалась на месте при переключении валют. Т.е. что бы не приходилось заново листать до необходимого банка.
Конвертер валют, в свою очередь, заворачивает некоторые элементы и выравнивается по ширине:
Добиться такого поведения можно несколькими способами, например, используя состояния (states), но в этот раз я просто прибиндил свойства HorizontalAlignment к самому контролу, главное разбросать элементы гридами а не StackPanel. Пример:
<UserControl
…
x:Name="Parent" HorizontalAlignment="Left">
<Grid x:Name="RootPanel">
…
<Grid Grid.Column="1" HorizontalAlignment="{Binding HorizontalAlignment, ElementName=Parent}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ToggleButton x:Name="btnActionSell" Content="Продажа" Grid.Column="0" VerticalAlignment="Center" />
<ToggleButton x:Name="btnActionBuy" Content="Покупка" Grid.Column="1" VerticalAlignment="Center" />
</Grid>
А в MainPage на телефоне добавляем Stretch:
<Controls:CurrencyConverter x:Name="currencyConverterControl" HorizontalAlignment="Stretch" />
Также с точки зрения юзабилити, было очень желательно, что бы на всех поддерживаемых DPI клавиатура не закрывала кнопки выбора. Плюс добавил, что бы при вводе (или вставке) суммы, можно было прямо на клавиатуре нажать знак доллара или евро (знак рубля на клавиатуре и в шрифте пока отсутствует) с соответствующим переключением валюты.
Live Tiles
Поскольку приложение под Windows Phone 8.0, о WNS мечтать не приходится, поэтому на телефоне используем старый, добрый Periodic Background Agent. Код ничем особенным не отличается, нужно только не забыть спросить пользователя при первом старте, хочет ли он получать обновления в фоне. Побочным положительным эффектом от использования своего агента, стало то что, когда пользователь, например, в дороге и без интернета, у нас уже есть не сильно старые данные для отображения (на Windows 8.1 эту проблему решаем немого по-другому, см. ниже). При подготовке изображений для тайлов, помним, что в Windows Phone логотип располагается строго по центру (с подписью или без), а в Windows 8 – немного смещается вверх.
Графики
Отдельно стоит упомянуть перенос графика флуктуаций. По привычке на Windows график был реализован с помощью Controls.DataVisualization (приложение делалось с прицелом на Universal Apps, а Телериковские контролы еще не вышли (на момент написания)). И как оказалось, именно под Windows Phone 8.0 нормального порта DataVisualization нет. Под WinRT (Windws 8 и Windows Phone 8.1) есть WinRTXamlToolkit, даже на Windows Phone 7 можно было использовать либу от обычного Silverlight. А вот под WP8.0 никак (в WPToolkit при переносе на WP8 DataVisualization потерялся). Вообще еще с WP7, WPToolkit не перестает «радовать».
В общем, потратив полчаса на поиски, кинул Телериковый контрол на телефоне (возможно и на Windows 8 на него перейду при порте на Universal Apps), подогнал внешний вид и забыл как страшный сон.
Кеширование
В Windows 8.1 (в WP8.1 отсутствует) появилась замечательная фича: можно подсказать ОС какие урлы кешировать. Т.е. если звезды сойдутся, (а точнее, если ваше приложение регулярно используется) Windows сама закеширует нужные вам данные еще до запуска приложения. Называется эта штука Content Prefetcher. Можно добавить конкретные урлы или xml файл-список с сервера (если данные динамические, например новости). Простой пример:
public static void RegisterPrefetchUrls()
{
if (!ContentPrefetcher.ContentUris.Any(u => u.AbsoluteUri == LIVE_DATA_URL))
{
ContentPrefetcher.ContentUris.Clear();
ContentPrefetcher.ContentUris.Add(new Uri(LIVE_DATA_URL));
}
}
Соответственно грузим данные как обычно через HttpClient. Но работает только с Windows.Web.Http.HttpClient (тот, который работает через WinInet, не путать с другими реализациями HttpClient). Если нужно загрузить данные только из кеша, если они есть, применяем фильтр Filters.HttpCacheReadBehavior.OnlyFromCache.
К слову в VS Update 2 появилась полезная менюшка что бы заставить ОС закешировать данные в целях отладки, раньше было только с консоли. Но нужно не забыть сначала запустить само приложение, что бы указать урлы, подробней тут.
Статистика использования
В этот раз было решено подключить Google Analytics, а не Flurry. Уж больно Flurry показывает неправильные цифры по сравнению с собственной статистикой (не только у меня). Плюс бедная поддержка платформ отличных от iOS/Android и жутко тупящая веб-панель (80+ http запросов при каждой перезагрузке). Как по мне, есть смысл использовать Flurry только для кросс-платформенных игр т.к. есть очень полезные инструменты отслеживания установок, in-app и т.п.
Для Windows / Windows Phone есть готовая либа «Google Analytics SDK for Windows 8 and Windows Phone». Интеграция простая, только я бы посоветовал вызывать SendView() не из OnNavigatedTo() как в примере, а из Page.Loaded что бы засчитать показы когда пользователь возвращается кнопкой назад. Также я добавил платформу в поле версии в analytics.xml:
<appVersion>1.0.0.0 (WP)</appVersion>
В зависимости от специфики приложения есть смысл добавить кастомные события (SendEvent()). Как пример, мне интересно сколько пользователей действительно будет использовать конвертер валют, или какой процент отключил Live Tile.
Финальные штрихи
Делаем страничку приложения, не забываем добавить msApplication-ID в мета теги, что бы пользователь мог поставить приложение из меню IE. Если есть желание, регистрируем приложение в Bing, что бы пользователь мог поставить приложение прямо со страницы результатов веб поиска (на Windows 8.1 / Windows Phone 8.0/8.1).
Единственное что огорчает это отсутствие русскоязычной кнопки «скачать» для Windows Phone Store (может представители MS прокомментируют?). Если следовать правилам использования торговых марок Microsoft, нужно использовать только уже готовые изображения, есть даже целый гайд где и как их использовать. В итоге получается, что для Windows Phone Store нет кнопки на русском, а для Windows Store нет без слова «Download». Приходится использовать английские версии кнопок:
Заключение
Получилось как-то длинно и сумбурно, хоть я и старался выбрать только интересные моменты. Если что упустил, спрашивайте в комментариях.
Автор: berlox