WinPhone: пути к совершенству

  • Tutorial
Внимание!
Более свежие и прогрессивные материалы по MVVM паттерну представлены в статье Context Model Pattern via Aero Framework и подробно разобраны в следующем цикле статей

В этой статье я расскажу о том, как сделать не сложный, но интерактивный и функциональный графический редактор-рисовалку под Windows Phone. Думаю, даже опытные разработчики смогут найти для себя что-то интересное и новое. Уникальной фишкой редактора будет история, которую можно в буквальном смысле отматывать на нужный момент с помощью ползунка-слайдера. И да, в завершение мы нарисуем радугу! Поехали…

Конечно же, я подготовил качественный пример.

image


Расширения разметки (markup extensions)

Когда я только начал разрабатывать на WinPhone, практически сразу был разочарован целым рядом ограничений этой платформы. Например, оказалось, что здесь даже нет привычных расширений разметки, как в WPF или Silverlight. Ведь, например, для локализации или картинок намного красивее писать следующий код в xaml:

<TextBlock Text={Localizing Hello}/>
<Button Content={Picture New.png}/>

«Как же так?! Ведь настолько удобная штука», — подумал я и на досуге решил исследовать этот вопрос детальнее, и не зря.

Покопавшись дизассемблером от решарпера в библиотечных классах, я вдруг заметил, что класс Binding не помечен атрибутом sealed. А что если унаследоваться от него, мелькнуло в голове? Попробвал, и получилось!

   public abstract class MarkupExtension : Binding, IValueConverter
    {
        protected MarkupExtension()
        {
            Source = Converter = this;
        }

        protected MarkupExtension(object source) // set Source to null for using DataContext
        {
            Source = source;
            Converter = this;
        }

        protected MarkupExtension(RelativeSource relativeSource)
        {
            RelativeSource = relativeSource;
            Converter = this;
        }

        public abstract object Convert(object value, Type targetType, object parameter, CultureInfo culture);

        public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }

Вот такой базовый класс вышел. А дальше пример реализации расширения для локализации.

    public class Localizing : MarkupExtension
    {
        public static readonly LocalizingManager Manager = new LocalizingManager();

        public Localizing()
        {
            Source = Manager;
            Path = new PropertyPath("Source");
        }

        public string Key { get; set; }

        public override string ToString()
        {
            return Convert(Manager.Source, null, Key, Thread.CurrentThread.CurrentCulture) as string ??
                   string.Empty;
        }

        public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        {
            var key = Key;
            var resourceManager = value as ResourceManager;
            if (resourceManager == null || string.IsNullOrEmpty(key)) return ":" + key + ":";
            var localizedValue = resourceManager.GetString(key);
            return localizedValue ?? ":" + key + ":";
        }
    }

    public class LocalizingManager : INotifyPropertyChanged
    {
        private ResourceManager _source;
        public ResourceManager Source
        {
            get { return _source; }
            set
            {
                _source = value;
                PropertyChanged(this, new PropertyChangedEventArgs("Source"));
            }
        }

        public event PropertyChangedEventHandler PropertyChanged = (sender, args) => { };
    }

Теперь и на WinPhone можно писать

 <TextBlock Text="{f:Localizing Key=ApplicationTitle}"/>

К сожалению, от префикса f: избавиться не удалось из-за ограничений платформы, также обязательно указывать имя свойства Key, но и это уже лучше, чем стандартная запись

<TextBlock Text="{Binding Path=LocalizedResources.ApplicationTitle, Source={StaticResource LocalizedStrings}}"/>

Кстати, наша реализация поддерживает горячую смену языка во время работы приложения, что называется налету. Используя базовый класс MarkupExtension можно сделать гораздо больше интересных вещей. Рекомендую для этого посмотреть примеры кода.

Предостерегу от возможного подводного камня. Если вы используете конструктор MarkupExtension(RelativeSource relativeSource), то в метод Convert в параметре value придёт контрол, к которому осуществляется привязка. С контролом вы можете делать всё, что угодно, но если будете хранить на него жёсткую ссылку, то может возникнуть ситуация с утечкой памяти (например, если расширение связано со статическим экземпляром класса, то будет удерживаться от сборки мусора часть интерфейса, даже в том случае, когда представление закрыто и не нужно). Поэтому используйте для подобных целей ленивые ссылки (WeakReferences).

Разделяемые команды (shared commands)

С командами в WinPhone дела обстоят не очень хорошо, их нужно реализовывать самому. Причём, тут тоже нужно быть осторожным, поскольку контрол подписывается на событие CanExecuteChanged, и если эту подписку неграмотно реализовать, то можно получить всё те же утечки памяти. Я и сам не обращал внимания на данный нюанс, но мне указал на него мой товарищ и отличный разработчик Юрий Калинов, за что хочу поблагодарить его. Почитать об этой проблеме можно тут.

То ли дело в WPF существует прекрасный механизм RoutedCommands и CommandBindings. В предыдущей статье я рассказал, как его можно красиво использовать. А что если реализовать на WinPhone нечто подобное? Посвятив вечер этой задачей, я-таки достиг некоторово результата и реализовал концепцию разделяемых команд (shared commands). Они не маршрутизируются по визуальному дереву, но для задач приложения подходят как нельзя лучше. Сразу отсылаю читателя к примеру, чтобы увидеть их реализацию, здесь же расскажу, как ими пользоваться. Всё просто и удобно, во вью модели пишем как-то так

this[SharedCommands.Back].CanExecute += (sender, args) => args.CanExecute = TouchIndex > 0;
this[SharedCommands.Next].CanExecute += (sender, args) => args.CanExecute = TouchIndex < _toches.Count;
this[SharedCommands.Back].Executed += (sender, args) => TouchIndex--;
this[SharedCommands.Next].Executed += (sender, args) => TouchIndex++; 

А на представлении примерно следующее

<Button Command="{f:Command Key=Back}" Content="{f:Picture Key=/Resources/IconSet/Next.png, Width=32, Height=32}"/>
<Button Command="{f:Command Key=Next}" Content="{f:Picture Key=/Resources/IconSet/Next.png, Width=32, Height=32}"/>


Лямбда-выражения

Нотифиация свойств вью-модели посредством лямбда-выражений это классика

        public Tool Tool
        {
            get { return Get(() => Tool); }
            set { Set(() => Tool, value); }
        }

Ещё очень удобно перегрузить индексатор

            this[() => Background].PropertyChanged += (sender, args) =>
            {
                Canvas.Children.Clear();
                Canvas.Background = Background;
                _toches.GetRange(0, TouchIndex).ForEach(Canvas.Children.Add);
            };

Если у кого-то ещё остаются опасения насчёт скорости работы таких конструкций, то смею их развеять — всё работает быстро. Помню, однажды кто-то выложил на хабре дизассемблированные исходники клиентской версии скайпа для WinPhone, а затем эти исходники оперативно убрали, но я успел их скачать и подсмотреть, как там устроены вью-модели. Это не показатель, конечно, но как раз-таки в них применялись лямбда-выражения для нотификации свойств. Думаю, что скайп делали умные люди из Майкрасофт, поэтому некоторое доверие к ним есть. А исходники скайпа потом были удалены с компьютера ;)

Рисование

Мы подошли к самому интересному – рисованию. Как его лучше всего организовать с учётом мобильности платформы? На мой взгляд, проще и логичнее всего будет воспользоваться стандартными средствами, а именно использовать примитивы Canvas, Polyline и тому подобные. Почему рекомендую именно их, а не велосипед на основе, например, WritableBitmap, да потому что для рендеринга интерфейса используется графическое ядро либо SIMD инструкции процессора, но если мы будем делать рисование руками, то просто переложим нагрузку на обычные инструкции процессора, что снизит производительность и значительно усложнит разработку.

Суть механизма в следующем. У нас есть холст (Canvas), который отображается на интерфейсе, а любые прикосновения к нему мы интерпретируем как мазки определённой кистью. Для каждого мазка создаётся примитив, а затем он добавляется в коллекцию Canvas.Children. Но что если у нас будет тысяча таких мазков, отразится ли это на производительности? Да, отразится существенно, поэтому нужно хотя бы временами делать растеризацию изображения, то есть очищать Canvas.Children и устанавливать в Canvas.Background тот рисунок, который получился на данный момент. Выглядит это примерно так

            var raster = new WriteableBitmap(Canvas, null);
            Canvas.Background = new ImageBrush
            {
                AlignmentX = AlignmentX.Left,
                AlignmentY = AlignmentY.Top,
                Stretch = Stretch.None,
                ImageSource = raster,
            };

            Canvas.Children.Clear();

Растеризация в примере происходит каждый раз перед добавлением нового примитива, но как же нам тогда организовать историю, ведь при растеризации она утрачивается? Здесь тоже ничего сложного – всего лишь сохраним изначальный Background холста, который был до начала рисования, и заведём коллекцию List _toches для хранения всех прикосновений. Когда нам нужно отмотать историю на определённый момент, мы просто восстанавливаем первоначальнвй фон у холста и переносим нужное число элементов из _toches в Canvas.Children, при этом элементы не удаляются из коллекции _toches.

В приложении рисование реализовано двумя способами: на основе Polyline и свойства OpacityMask у Canvas. Второй способ мне подсказал талантливый программист Ярошевич Юрий, за что говорю ему спасибо.

Интерес представляет также логика работы стёрки (вода). Рассказывать о ней сейчас не буду, но самые пытливые могут сами изучить этот вопрос.

Радуга

Как и обещал, рассказываю о рисовании палитры цветов, которую можно применять для выбора кисти. Всё, что нам нужно, это радужная кисть.

        <LinearGradientBrush x:Key="RainbowBrush" StartPoint="0 0" EndPoint="0 0.6">
            <LinearGradientBrush.Transform>
                <ScaleTransform ScaleY="2.8"/>
            </LinearGradientBrush.Transform>
            <LinearGradientBrush.GradientStops>
                <GradientStop Color="#FFFF0000" Offset="0.0"/>
                <GradientStop Color="#FFFFFF00" Offset="0.1"/>
                <GradientStop Color="#FF00FF00" Offset="0.2"/>
                <GradientStop Color="#FF00FFFF" Offset="0.3"/>
                <GradientStop Color="#FF0000FF" Offset="0.4"/>
                <GradientStop Color="#FFFF00FF" Offset="0.5"/>
                <GradientStop Color="#FFFF0000" Offset="0.6"/>
            </LinearGradientBrush.GradientStops>
        </LinearGradientBrush>


Пожалуй, на этом всё – изучайте пример, в нём можно найти намного больше, чем я рассказал здесь. Для лучшего понимания кода рекомендую прочитать статью MVVM: новый взгляд. Программу я тестировал на эмуляторе, поэтому не гарантирую, что на реальных устройствах всё будет работать корректно, однако серьёзных трудностей возникнуть не должно.

Всем мир!

P.S. По непонятным для меня причинам довольно много людей минусуют статью. Если вы всё же решили поставить минус, то, пожалуйста, оставьте комментарий с описанием того, что не понравилось. Это поможет исправить ошибки и улучшить статью.
Метки:
Поделиться публикацией
Комментарии 16
  • +6
    Трюк с Markup Extension реально крут, спасибо!
    • +1
      И вам спасибо! Но на самом деле Markup Extension — это лишь то, что лежит на поверхности.
      Если глубже изучить примеры кода, то можно обнаружить ещё несколько ценных вещей…
    • +3
      Классная статья, только, пожалуйста, не вздумайте публиковать приложение в таком «дизайне».

      На самом деле, пример тоже стоит поправить, а то есть много людей которые выложат это без изменений.
      • +1
        Спасибо! Над дизайном, что называется, я сильно не заморачивался, поэтому тот, кто захочет, думаю, доработает его.
        Однако если есть замечания и предложения именно по коду приложения, то я готов их выслушать и внести определённые правки, где нужно.
      • 0
        Недавно наткнулся на один очень странный и очень гадкий баг, в приложении некоторые WeakRefference становились не валидными при вполне живом объекте, который гарантированно был доступен из рута. Пришлось отказаться от слабых ссылок и жестко контролировать все подписки руками, что добавило головной боли на порядок. К сожалению я не смог понять почему это случается.
        • +1
          Хотелось бы взглянуть на пример кода. Возможно, в корневом объекте так или иначе была слабая ссылка на ваш объект, поэтому он был доступен лишь до поры до времени. Также, вполне может быть, корневой объект ссылался на точную копию вашего оригинального объекта, поэтому вы приняли их за один и тот же экземпляр, а сборщик мусора удалил оригинал, оставив лишь копию. Думаю, что второй вариант наиболее вероятный.
          • 0
            Это самые очевидные идеи, но увы они обе были проверены и не состоятельны. Код к сожалению уже затерян во времени.
        • +1
          Спасибо за интересную статью, мы в своих проектах тоже сделали такие же решения, но пришлось отказаться от многих вкусностей из за больших проблем производительности при использовании в списках.

          Так, к примеру, более шустрый вариант на котором остановились это упрощенная этого же решения, т.е. в BaseViewModel
          private static readonly Localization.UI LocaleUI=new Localization.UI();
          public Localization.UI Locale
          {
          get
          {
          return LocaleUI;
          }
          }

          и в UI чуть более лаконичная запись:
          Text="{Binding Locale.PhonePage_Title}"

          Впрочем, значительно больше проблем с производительностью дало использовании лямбда выражений для нотификаций UI. С классическим подходом скроллинг списка был ощутимо плавнее.

          В целом статья очень интересная, еще раз спасибо!
          • +1
            Спасибо! Ваш вариант локализации тоже довольно интересный.
            Плюс его в экономии ресурсов, но минус в том, что не всегда удобно им пользоваться, например, в DataTemplate

                        <ListBox ItemsSource="{Binding Points}">
                            <ListBox.ItemTemplate>
                                <DataTemplate>
                                    <StackPanel>
                                        <TextBlock Text="{f:Localizing Key=СoordinateX, StringFormat={}'{0}: '}"/>
                                        <TextBlock Text="{Binding X}"/>
                                    </StackPanel>
                                </DataTemplate>
                            </ListBox.ItemTemplate>
                        </ListBox>
            

            То есть, чтобы ваш вариант сработал в такой ситуации нужно, чтобы данные были обязательно унаследованы от базовой вью-модели. Но это не всегда выполнимо, плюс ведёт к разрастанию класса Point.

            Использование лямбда-выражений для нотификации UI во многих случаях оправдано, хоть и работает немного медленнее.
            Бояться этого не стоит, прото нужно аккуратно ими пользоваться.
            Понятно, что если свойство изменяется с большой частотой, то конкретно для него лучше нотификацию реализовать другим образом.

            С другой стороны, такие ситуации в приложениях встречаются не столь часто.

            Да и намного красивее писать что-то вроде

            this[() => Name].PropertyChanged += (o, e) => { ... };
            

            чем глобально подписываться на событие PropertyChanged и использовать if со строковой константой

            PropertyChanged += (o, e) => { if (e.PropertyName == "Name") { ... } };
            

            Подписка, как правило, выполняется только один раз, поэтому влияние лямбда-выражения на скорость здесь минимальное.
            • +1
              Согласен что ваш вариант намного проще сопровождать и рефакторить. Но в наших проектах этот подход давал существенные проблемы с производительностью в списках, особенно на таком железе как HTC Mozart и Lumia 610. (тесты проводились пока еще не было WP8). Надо будет проверить как сейчас все эти вкусности будут работать на самом популярном WP8 устройстве на текущий момент — Lumia 520
          • 0
            Кому интересно, вот что получилось из этого всего
            Easel (Мольберт)

            Творите!
            • +1
              У вас есть девайс? Вы пальцами по кнопкам попадаете?
              • 0
                К сожалению, пока девайса нету, поэтому всё делаю на эмуляторе. Да, я запускал приложение у друзей и заметил, что мелковато получилось… Возможно, подправлю этот недостаток позже. По этой же причине пока что нету мультача в приложении.

                Спасибо за обратную связь, если есть и другие пожелания к программе, то смело их высказывайте, постараюсь по возможности учесть.

                Вообще хотелось бы в обозримом будущем добавить:
                — мультитач для рисования, масштабирования и вращения
                — больше кистей
                — звуки при касаниях
                — темы для интерфейса
                — эффекты для изображений

                P.S. Буду признателен за объективные отзывы в Store, поскольку, как я понял, они сильно влияют на рейтинг приложения и число загрузок :)
            • +1
              Теперь и на WinPhone можно писать
              <TextBlock Text="{f:Localizing Key=ApplicationTitle}"/>

              <...>, также обязательно указывать имя свойства Key, <...>

              Не совсем так. Запись можно сократить до "{f:Localizing ApplicationTitle}", если у Localizing сделать конструктор с именем ключа:
                public Localize(string key): this()
                {
                    Key = key;
                }
              

              Это подсказывает хинт в XAML-редакторе ;)
              • 0
                Да уж. Не работает. Я недостаточно хорошо проверил после убирания «Key=». На самом деле студия просто не обновила XAML на девайсе.
                А вот после ребилдола всё сломалось. Обидно :(
                Впрочем, вы правы, что даже в таком виде это намного удобнее, чем монстр, который советуется в MSDN.
                • 0
                  В своё время я тоже столкнуляся с этой же проблемой) Вариант с конструктором прекрасно работае в WPF и даже в дизайнере вижуал студии тоже всё проходит, но на реальном устройстве или эмуляторе — увы. Вероятно, связано с какими-то оптимизациями xaml-парсера для WP.

                  Но зато такой вариант локализации позволяет полностью абстрагироваться от реализации. Можно как угодно получать локализованные строки. Более того, есть возможность навесить ряд усвершенствований, например, управлять регистром вывода букв:

                      public enum Cases
                      {
                          Default,
                          Lower, 
                          Upper
                      }
                  

                          public string Key { get; set; }
                          public Cases Case { get; set; }
                  
                          public override object Convert(object value, Type targetType, object parameter, CultureInfo culture)
                          {
                              var key = Key;
                              var resourceManager = value as ResourceManager;
                              if (resourceManager == null || string.IsNullOrEmpty(key)) return ":" + key + ":";
                              var localizedValue = resourceManager.GetString(key) ?? ":" + key + ":";
                              switch (Case)
                              {
                                   case  Cases.Lower:return localizedValue.ToLower();
                                   case  Cases.Upper:return localizedValue.ToUpper();
                                   default:return localizedValue;
                              }
                          }
                  

                   Title="{m:Localizing Key=Hello, Case=Upper}" 
                  

                  В общем, полезных применений расширениям разметки можно найти очень много, стоит лишь применить немного фантазии…

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.