Это вторая статья из цикла, где мы проводим сравнение Kivy, Xamarin.Forms и React Native. В ней я постараюсь написать такой же планировщик задач, но с использованием Xamarin.Forms. Посмотрю, как у меня это получится, и с чем мне придется столкнуться.

Повторять ТЗ я не буду, его можно посмотреть в первой статье: Kivy. Xamarin. React Native. Три фреймворка — один эксперемент

Третья часть про React Native: Kivy. Xamarin. React Native. Три фреймворка — один эксперимент (часть 3)

Для начала скажу пару слов о платформе Xamarin.Forms и о том, как я буду подходить к решению поставленной задачи. Xamarin.Forms является надстройкой над Xamarin.iOs и Xamarin.Android. После сборки общая часть “разворачивается” в стандартные нативные контролы, так что по сути вы получаете полностью нативные приложения под все поддерживаемые платформы.

Синтаксис Xamarin.Forms крайне близок к синтаксису WPF, а сама общая часть написана на .NET Standard. В результате вы получаете возможность использования MVVM подхода при разработке приложения, а также доступ к огромному количеству сторонних библиотек, написанных для .NET Standard и уже лежащих в NuGet, которые вы спокойно можете использовать у себя в Xamarin.Forms приложениях.

Исходные коды приведённого здесь приложения доступны на GitHub.

Итак, давайте создадим пустое Xamarin.Forms приложение и начнём. Модель данных у нас будет простая, всего два класса Note и Project:

public class Note {
    public string UserIconPath { get; set; }
    public string UserName { get; set; }
    public DateTime EditTime { get; set; }
    public string Text { get; set; }
}

public class Project {
    public string Name { get; set; }
    public ObservableCollection<Note> Notes { get; set; }

    public Project() {
        Notes = new ObservableCollection<Note>();
    }
}

Я буду стараться придерживаться MVVM подхода, но какую-то специальную библиотеку использовать не буду, чтобы не усложнять код. Все классы моделей и вью моделей будут реализовывать интерфейс INotifyPropertyChanged. Его реализацию в приводимых примерах кода я уберу для лаконичности.

Первым экраном у нас будет список проектов с возможностью создавать новый или удалять текущий. Сделаем для него вью модель:

public class MainViewModel {
    public ObservableCollection<Project> Projects { get; set; }

    public MainViewModel() {
        Projects = Project.GetTestProjects();
    }

    public void AddNewProject(string name) {
        Project project = new Project() { Name = name };
        Projects.Add(project);
    }

    public void DeleteProject(Project project) {
        Projects.Remove(project);
    }
}

Код самого экрана:

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:local="clr-namespace:TodoList.View" x:Class="TodoList.View.ProjectsPage">
    <ContentPage.ToolbarItems>
        <ToolbarItem Clicked="AddNew_Clicked" Icon="plus.png"/>
    </ContentPage.ToolbarItems>

    <ListView ItemsSource="{Binding Projects}" ItemTapped="List_ItemTapped">
        <ListView.ItemTemplate>
            <DataTemplate>
                <TextCell Text="{Binding Name}" TextColor="Black">
                    <TextCell.ContextActions>
                        <MenuItem Clicked="DeleteItem_Clicked" IsDestructive="true" CommandParameter="{Binding .}" Text="Delete"/>
                    </TextCell.ContextActions>
                </TextCell>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>

Разметка получилась достаточно простая, единственное, на чем хочется остановиться — это реализация свайп-кнопок для удаления проектов. В ListView есть понятие ContextActions, если его задать, то в iOS они будут реализованы через свайп, в Android — через длинный тап. Данный подход реализован в Xamarin.Forms, ибо он является нативным для каждой из платформ. Однако если мы захотим свайп в андроиде, нам надо будет руками реализовывать его в нативной части андроида. У меня нет задачи тратить много времени на это, поэтому я удовлетворился стандартным подходом :) В результате свайп в iOS и контекстное меню в Android реализуются достаточно просто:

<TextCell.ContextActions>
    <MenuItem Clicked="DeleteItem_Clicked" IsDestructive="true" CommandParameter="{Binding .}" Text="Delete"/>
</TextCell.ContextActions>

Подставив тестовые данные, получаем вот такой список:



Теперь перейдём к обработчику событий. Начнём с простого — удаления проекта:

MainViewModel ViewModel { get { return BindingContext as MainViewModel; } }

async Task DeleteItem_Clicked(object sender, EventArgs e) {
    MenuItem menuItem = sender as MenuItem;
    if (menuItem == null)
        return;

    Project project = menuItem.CommandParameter as Project;
    if (project == null)
        return;

    bool answer = await DisplayAlert("Are you sure?", string.Format("Would you like to remove the {0} project", project.Name), "Yes", "No");
    if(answer)
        ViewModel.DeleteProject(project);
}

Нехорошо удалять что-то без вопроса пользователю, и в Xamarin.Forms это элементарно сделать, используя штатный метод DisplayAlert. После его вызова покажется следующее окошко:



Данное окошко из iOs. На Android будет свой вариант подобного окна.

Следующим реализуем добавление нового проекта. Казалось бы, это делается по аналогии, но в Xamarin.Forms нет реализации диалога, подобного тому, которым я подтверждал удаление, но позволяющего вводить текст. Варианта решения есть два:

  • написать свой сервис, который будет поднимать нативные диалоги;
  • реализовать какой-то воркэраунд на стороне Xamarin.Forms.

Мне не хотелось тратить время на поднятие диалога через натив, и я решил воспользоваться вторым подходом, реализацию которого взял из треда: How to do a simple InputBox dialog?, а именно метод Task InputBox(INavigation navigation).

async Task AddNew_Clicked(object sender, EventArgs e) {
    string result = await InputBox(this.Navigation);
    if (result == null)
        return;

    ViewModel.AddNewProject(result);
}

Теперь обработаем тап по строкам, для открытия проекта:

void List_ItemTapped(object sender, Xamarin.Forms.ItemTappedEventArgs e) {
    Project project = e.Item as Project;
    if (project == null)
        return;

    this.Navigation.PushAsync(new NotesPage() { BindingContext = new ProjectViewModel(project) });
}

Как видно из кода выше, чтобы перейти на окно проекта, нам нужны его view model и объект page окна.

Хотелось бы сказать пару слов про Navigation. Свойство Navigation определяется в VisualElement class, и позволяет работать с навигационной панелью в любой view вашего приложения без прокидывания её туда руками. Однако, чтобы этот подход работал, создать данную панель всё-таки надо самому. Поэтому в App.xaml.cs напишем:

NavigationPage navigation = new NavigationPage();
navigation.PushAsync(new View.ProjectsPage() { BindingContext = new MainViewModel() });
MainPage = navigation;

Где ProjectsPage — это как раз то окно, которое я сейчас описываю.

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

Разметка этого окна получилась посложнее, ибо отображать каждая строка должна больше информации:

Notes View
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="TodoList.View.NotesPage"
             xmlns:local="clr-namespace:TodoList.View"
             xmlns:utils="clr-namespace:TodoList.Utils"
             Title="{Binding Project.Name}">
    <ContentPage.Resources>
        <ResourceDictionary>
            <utils:PathToImageConverter x:Key="PathToImageConverter"/>
        </ResourceDictionary>
    </ContentPage.Resources>
    <ContentPage.ToolbarItems>
        <ToolbarItem Clicked="AddNew_Clicked" Icon="plus.png"/>
    </ContentPage.ToolbarItems>

    <ListView ItemsSource="{Binding Project.Notes}" x:Name="list" ItemTapped="List_ItemTapped" HasUnevenRows="True">
        <ListView.ItemTemplate>
            <DataTemplate>
                <ViewCell>
                    <local:MyCellGrid Margin="5">
                        <local:MyCellGrid.RowDefinitions>
                            <RowDefinition Height="40"/>
                            <RowDefinition Height="*"/>
                        </local:MyCellGrid.RowDefinitions>
                        <local:MyCellGrid.ColumnDefinitions>
                            <ColumnDefinition Width="40"/>
                            <ColumnDefinition Width="*"/>
                            <ColumnDefinition Width="40"/>
                        </local:MyCellGrid.ColumnDefinitions>
                        <Image Grid.Row="0" Grid.Column="0" Source="{Binding UserIconPath, Converter={StaticResource PathToImageConverter}}" />
                        <StackLayout Grid.Row="0" Grid.Column="1">
                            <Label Text="{Binding UserName}" FontAttributes="Bold"/>
                            <Label Text="{Binding EditTime}"/>
                        </StackLayout>
                        <Button Grid.Row="0" Grid.Column="2" BackgroundColor="Transparent" Image="menu.png" Margin="5" HorizontalOptions="FillAndExpand" Clicked="RowMenu_Clicked"/>
                        <local:MyLabel Grid.Row="1" Grid.Column="1" Margin="0,10,0,0" Grid.ColumnSpan="2" Text="{Binding Text}"/>
                    </local:MyCellGrid>

                    <ViewCell.ContextActions>
                        <MenuItem Clicked="DeleteItem_Clicked" IsDestructive="true" CommandParameter="{Binding .}" Text="Delete"/>
                    </ViewCell.ContextActions>
                </ViewCell>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>
</ContentPage>


В контенте окна у нас опять лежит ListView, прибинженная к коллекции заметок. Однако мы хотим высоту ячеек по контенту, но не более 150, для этого выставим HasUnevenRows=«True», чтобы ListView позволил ячейкам занимать столько места, сколько они попросят. Но в такой ситуации строки могут запросить высоту более 150 и ListView им позволит так отобразиться. Чтобы этого избежать в ячейке я использовал своего наследника Grid панели: MyCellGrid. Данная панель на операции measure запрашивает высоту внутренних элементов и возвращает ее либо 150, если она больше:

public class MyCellGrid : Grid {
    protected override SizeRequest OnMeasure(double widthConstraint, double heightConstraint) {
        SizeRequest sizeRequest = base.OnMeasure(widthConstraint, heightConstraint);
        if (sizeRequest.Request.Height <= 150)
            return sizeRequest;
            
        return new SizeRequest(new Size() { Width = sizeRequest.Request.Width, Height = 150 });
    }
}

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

<Button Grid.Row="0" Grid.Column="2" BackgroundColor="Transparent" Image="menu.png" Margin="5" HorizontalOptions="FillAndExpand" Clicked="RowMenu_Clicked"/>

С тестовыми данными наша форма выглядит вот так:



Обработка пользовательский действий в этой форме полностью аналогична той, которая писалась для окна списка проектов. Остановиться хочется только на контекстном меню по нашей кнопке в углу строки. Сначала я думал, что без проблем это сделаю на уровне Xamarin.Forms.

Действительно, нам надо всего лишь создать view примерно такого вида:

<StackLayout>
    <Button Text=”Edit”/>
    <Button Text=”Delete”/>
</StackLayout>

И показывать её рядом с кнопкой. Однако проблема в том, что мы не можем точно узнать, где это “рядом с кнопкой”. Данное контекстное меню должно быть расположено поверх ListView и, при открытии, позиционироваться в координатах окна. Для этого надо знать координаты нажатой кнопки относительно окна. Мы же можем получить координаты кнопки только относительно внутреннего ScrollView, расположенного в ListView. Так что когда строки не сдвинуты, то все нормально, но когда строки проскроллированы, мы должны учитывать то, на сколько произошел скролл при расчете координат. ListView нам не отдает величину скролла. Так что его надо вытягивать из натива, что делать очень не хотелось. Поэтому я решил пойти по пути более стандартному и простому: показать стандартное системное контекстное меню. В результате обработчик нажатия на кнопку получится следующий:

async Task RowMenu_Clicked(object sender, System.EventArgs e) {
    string action = await DisplayActionSheet("Note action:", "Cancel", null, "Edit", "Delete");
    if (action == null)
        return;
            
    BindableObject bindableSender = sender as BindableObject;
    if(bindableSender != null) {
        Note note = bindableSender.BindingContext as Note;
        if (action == "Edit") {
            EditNote(note);
        } else if(action == "Delete") {
            await DeleteNote(note);
        }
    }
}

Вызов метода DisplayActionSheet как раз и показывает штатное контекстное меню:



Если вы заметили, текст заметки у меня выводится в моем контроле MyLabel, а не в штатном Label. Это сделано вот для чего. Когда пользователь изменяет текст заметки, срабатывает биндинг, и в Label автоматически прилетает новый текст. Однако Xamarin.Forms не пересчитывает размер ячейки при этом. Разработчики Xamarin заявляют, что это достаточно дорогостоящая операция. Да и у самого ListView нет какого-то метода, который заставил бы его пересчитать свой размер, InvalidateLayout тоже не помогает. Единственное, что у них для этого есть, это метод ForceUpdateSize у объекта Cell. Поэтому, чтобы до него добраться и в нужный момент дёрнуть, я написал свой наследник Label и дёргаю этот метод на каждое изменение текста:

public class MyLabel : Label {
    protected override void OnPropertyChanged([CallerMemberName] string propertyName = null) {
        base.OnPropertyChanged(propertyName);

        if (propertyName == "Text") {
            ((this.Parent as MyCellGrid).Parent as Cell).ForceUpdateSize();
        }
    }
}

Теперь после редактирования заметки ListView автоматически поправит размер ячейки под новый текст.

При редактировании или создании новой заметки открывается окно с Editor в контенте и кнопкой Save на тулбаре:



Данное окно немного отличается от того, что у нас в ТЗ: отсутствием круглой кнопки снизу. Если её расположить просто поверх редактора, то она будет перекрыта выезжающей клавиатурой. Красивого решения как её подвинуть и не уходить при этом в натив быстрым поиском я не нашёл. Поэтому убрал её и оставил только кнопку Save в верхней панели. Само по себе данное окно очень простое, так что его описание я опущу.

Что хочется сказать в итоге.

Xamarin.Forms хорошо подойдёт тем, кто хорошо знаком с инфраструктурой .NET и давно с ней работает. Им не придётся переходить на новые IDE и фреймворки. Как видно, код приложения мало чем отличается от кода любого другого XAML based приложения. К тому же Xamarin позволяет разрабатывать и билдить iOS приложения в Visual Studio под Windows. При разработке конечного приложения для его тестирования и сборки потребуется потребуется подключение к машине с MacOS. А библиотеки можно делать без нее.

Для того чтобы начать писать приложения на Xamarin.Forms, вам не надо никакого красноглазия с консолью. Просто ставите Visual Studio и пишете приложения. Обо всём остальном за вас уже позаботились. При этом, как бы Microsoft не ассоциировался с платными продуктами, Xamarin бесплатен и есть бесплатные версии Visual Studio.

То, что Xamarin.Forms под капотом использует .NET Standard, даёт доступ к куче библиотек, уже написанных под него, которые будут облегчать жизнь при разработке своих приложений.

Xamarin.Forms позволяет без особых трудностей дописывать что-то в нативных частях вашего приложения, если требуется реализовать что-то платформоспецифичное. Там вы получаете тот же C#, но уже API родное для каждой из платформ.

Однако, конечно же, не обошлось и без недостатков.

API, доступное в общей части, достаточно скудное, ибо содержит в себе только то, что является общим для всех платформ. Например, как видно в моём примере, все платформы содержат alert-сообщения и контекстные меню, и эта вещь доступна в Xamarin.Forms. Однако стандартное меню, позволяющее ввести текст, доступно лишь в iOS, поэтому в Xamarin.Forms его нет.

Так же подобные ограничения встречаются и в использовании компонентов. Что-то сделать можно, что-то нельзя. Тот же свайп для удаления проекта или заметки работает лишь в iOS. В Android данный context action будет представлен в виде меню, показывающемся на длинном тапе. А если хочется свайп в андроиде, то welcome в андроид часть и писать это руками.

Ну и конечно же производительность. Скорость работы приложения на Xamarin.Forms в любом случае будет ниже скорости работы нативного приложения. Так что сам Microsoft заявляет, что если вам надо приложение без особых изысков в плане дизайна и требований к производительности, то Xamarin.Forms для вас. Если нужны красивости или скорость, то тут надо уже опускаться в натив. Благо Xamarin имеет версии и под натив, которые уже оперируют сразу родным платформенным API и работают быстрее, чем формсы.

Комментарии (20)


  1. ZAMnoTEX
    28.08.2018 13:13
    +1

    Все-таки Xamarin.Forms и Xamarin.iOS+Xamarin.Android — существенно отличаются с точки зрения разработчика. Поэтому, для большей объективности сравнения фреймворков следовало бы описать и процесс реализации указанного ТЗ и на Xamarin.iOS+Xamarin.Android. Но авторам эксперимента все равно спасибо за более-менее объективное сравнение фреймворков, а то слишком уж «холиварная» тема.


    1. Mirimon Автор
      28.08.2018 13:27

      Для этого надо иметь опыт на тех платформах, ну или на нативе. Я iOS немного знаю, а в Android ни в зуб ногой :)


  1. ZAMnoTEX
    28.08.2018 13:18
    +1

    Кстати, в предыдущей статье был комментарий о сборке приложения под iOS на Windows. Насколько я знаю, это невозможно. Хотелось спросить у автора — вам известно что-то об этом?


    1. Mirimon Автор
      28.08.2018 13:50

      Поисследовал подробнее. Да, все не так радужно, как я писал ранее, извиняюсь за дезинформацию. Для разработки конечных приложений будет нужна хостовая MacOS машина. Мы же у себя разрабатываем компоненты под Xamarin.Forms. Вот их можно билдить и под Windows. Поправил текст статьи.


  1. HomoLuden
    28.08.2018 13:27

    Либо сделайте свойства классов Note, Project только для чтения, либо добавьте в них INotifyPropertyChanged. Иначе будут утечки памяти через PropertyDescriptor (если команда Xamarin не переделала нутро биндингов при портировании WPF на мобилы).

    В WPF у биндингов по умолчанию выставлялся режим TwoWay. Это означало, что CLR должен как-то реализовать оповещение изменения свойства в обе стороны. Если нет INotify… то CLR вынужден создавать в памяти спец. прокси, реализующие событие изменения свойства. И хранятся эти прокси в таблице рутовых хэндлов, к которым явного доступа для разраба нет.

    Использовать POCO-объекты во ViewModel — короткий путь отстрелить себе все возможные колени. Особенно с ListView и ItemTemplate с биндингом. Крутите список, контейнеры айтемов создаются и выбрасываются. Но GC их собрать не может, т.к. они держатся в памяти через PropertyDescriptor. Удачи в отладке утечек памяти в мобиле :)


    1. Mirimon Автор
      28.08.2018 13:33

      Просто процитирую текст из статьи: «Все классы моделей и вью моделей будут реализовывать интерфейс INotifyPropertyChanged. Его реализацию в приводимых примерах кода я уберу для лаконичности.»


      1. HomoLuden
        28.08.2018 13:51

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

        Для длинного кода есть спойлеры, если что.


        1. Mirimon Автор
          28.08.2018 13:58

          Согласен, тут скорее вопрос стилистики статьи. Мне не очень нравится, когда куча кода под спойлерами. Если можно вставить короткий кусок кода, но inline, то мне кажется это будет чуть более читабельно. А для копирования мы специально репу сделали. Но, еще раз повторюсь, я не претендую на то, что мой подход самый правильный.


          1. HomoLuden
            28.08.2018 14:08

            Уместно реализацию ViewModelBase спрятать в спойлер, а в конкретных ViewModel лаконичность кода не изменится (см. мой коммент ниже).


            1. Mirimon Автор
              28.08.2018 14:24

              Да, как вариант.


      1. HomoLuden
        28.08.2018 14:06

        Реализация ViewModelBase
        public class ViewModelBase : INotifyPropertyChanged
        {
            private readonly Dictionary<string, object> _properties = new Dictionary<string, object>();
        
            public event PropertyChangedEventHandler PropertyChanged;
        
            protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
            {
                var handler = PropertyChanged;
                handler?.Invoke(this, new PropertyChangedEventArgs(propertyName));
            }
        
            protected void SetValue<T>(T value, [CallerMemberName] string propertyName = null)
            {
                if (!_properties.ContainsKey(propertyName))
                {
                    _properties.Add(propertyName, default(T));
                }
        
                var oldValue = GetValue<T>(propertyName);
                if (!EqualityComparer<T>.Default.Equals(oldValue, value))
                {
                    _properties[propertyName] = value;
                    OnPropertyChanged(propertyName);
                }
            }
        
            protected T GetValue<T>([CallerMemberName] string propertyName = null)
            {
                if (!_properties.ContainsKey(propertyName))
                {
                    return default(T);
                }
        
                return (T)_properties[propertyName];
            }
        }
        


        1. worldbeater
          30.08.2018 10:46

          Ещё можно взять Fody и страшный шаблонный код совсем исчезнет.


          [AddINotifyPropertyChangedInterface]
          public class ExampleViewModel 
          {
              // И backing field, и уведомления
              // оно реализует само, за нас.
              public string Title { get; set; }
          }

          Либо можно взять ReactiveUI.Fody, кому как нравится.


        1. rzakirovt
          30.08.2018 11:22

          Здравствуй, boxing-unboxing


    1. ZAMnoTEX
      28.08.2018 13:40

      если команда Xamarin не переделала нутро биндингов при портировании WPF на мобилы
      Нет. Хотите «портировать WPF на мобилы» — используйте сторонние реализации паттерна MVVM, которые работают и на WPF, и на Xamarin. Например, MVVMCross


      1. HomoLuden
        28.08.2018 13:55

        Xamarin.Forms я и назвал портом WPF на мобилы в данном случае
        :)


  1. HomoLuden
    28.08.2018 13:28

    [del] форма ввода коммента задублировала…


  1. HomoLuden
    28.08.2018 13:46
    +3

    public class MyLabel : Label {
        protected override void OnPropertyChanged([CallerMemberName] string propertyName = null) {
            base.OnPropertyChanged(propertyName);
    
            if (propertyName == "Text") {
                ((this.Parent as MyCellGrid).Parent as Cell).ForceUpdateSize();
            }
        }
    }
    


    Для решения этой проблемы уместнее использовать Behavior. Этот зверь можно сделать более универсальным, чтобы иметь возможность навешивать его не только на Label.
    Behavior может быть таргетом биндинга на любое количество свойств VM, а также он хранит ссылку на UI элемент. Соответственно, с его помощью можно гибко настраивать поведение UI элемента без жесткой привязки к конкретному свойству «Text».


    1. Mirimon Автор
      28.08.2018 13:51

      Да, конечно. Это будет более красиво.


  1. rombick
    28.08.2018 19:40

    А если сравнить скорость работы этих двух приложений(Kivy. Xamarin.Forms), какое из них будет быстрее работать?


    1. Mirimon Автор
      28.08.2018 19:44

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