В этой статье я расскажу, как реализовать кроссплатформенное приложение на .NET Core и Avalonia. Тема Телеграма очень популярна в последнее время — тем интереснее будет сделать клиентское приложение для него.


Egram


Статья затрагивает достаточно базовые концепции разработки на Avalonia. Тем не менее, мы не будем писать "Hello, World". Вместо этого предлагается рассмотреть реальное приложение. Изучим как общую архитектуру приложения, так и отдельные компоненты.


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


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


Проект находится в стадии разработки и в данный момент не пригоден для повседневного использования. Данной статьей автор, в том числе, рассчитывает привлечь единомышленников к разработке.


Введение


В основе нашего приложения будет лежать фреймворк Avalonia. Мы будем активно использовать паттерн MVVM и Rx.NET. В качестве языка разметки для построения пользовательского интерфейса используется XAML. Для коммуникации с API Telegram будет использована библиотека TDLib и автоматически сгенерированные биндинги для .NET.


Реактивное программирование будет широко применяться в разработке. В общем и целом, приложение следует подходу, принятому в современных UI фреймворках. Если вы знакомы с WPF, то вам будет сравнительно легко перейти на Avalonia. Знакомство с такими вещами, как React.js тоже не помешает.


Avalonia


Avalonia скрывает от разработчика детали реализации специфичные для отдельно взятой платформы. Программист обычно имеет дело с верхнеуровневыми компонентами. Так, например, для того, чтобы создать новое приложение вам потребуется поставить пакеты Avalonia, Avalonia.Desktop и прописать в функции Main следующие строки:


 AppBuilder
    .Configure(new App())
    .UsePlatformDetect()
    .UseReactiveUI()
    .Start<MainWindow>(() => context);

Это типичный Builder, знакомый всем, кто имел дело с .NET Core и ASP.NET Core. Ключевая строка — UsePlatformDetect. Avalonia берет на себя определение среды, в которой работает программа, и конфигурирует бэкенд для отрисовки UI. App и MainWindow здесь — это классы, унаследованные от Avalonia.Application и Avalonia.Window соответственно, их назначение должно быть примерно понятно из названий, мы вернемся к ним позже.


Если воспользоваться расширением для VisualStudio, то оно предоставит шаблон, который будет содержать реализацию этих классов. Давайте воспользуемся расширением, и создадим проект. Мы обнаружим, что проект будет содержать следующие файлы:


./App.xaml
./App.xaml.cs

./MainWindow.xaml
./MainWindow.xaml.cs

Как видно, это те самые классы App и MainWindow, упомянутые ранее, и дополненные XAML файлами. Каждый из этих классов будет содержать в себе вызов: AvaloniaXamlLoader.Load(this). Не будем сейчас вдаваться в детали, скажем только, что этот метод загружает одноименный XAML файл и преобразует его в .NET объекты, "наполняя" целевой объект, переданный в качестве аргумента.


Если есть необходимость разобраться с деталями работы XAML, их можно получить из других источников — подойдет любая книга по WPF. Для простых же случаев это не нужно, достаточно будет научиться работать с компонентами, которые Avalonia предоставляет "из коробки".


Похожим образом в Avalonia реализованы и контролы (view), т.е. XAML файлы по своей сути нужны для декларативного описания некой иерархии, которая затем преобразуется в обычные объекты в памяти приложения. Пример такой иерархии: кнопка, вложенная в форму, которая, в свою очередь, находится внутри окна.


<Window>
    <Panel>
        <Button>
            <TextBlock>Foo Bar</TextBlock>
        </Button>
    </Panel>
</Window>

Avalonia содержит в себе заранее определенный набор контролов, таких, как TextBlock, Button и Image. Для их композиции в более сложные структуры используются контролы-контейнеры: Grid, Panel, ListBox и т.д. Все эти контролы работают подобно тому, как они реализованы в WPF, т.е., несмотря на небольшое количество доступной документации, почти всегда можно обратиться к материалам для WPF.


Реализация MVVM


Мы будем стараться разделить внутренний стейт приложения и его отображение. Состояние будет храниться в некоторой иерархии объектов (View Model). Отображение (View) будет реагировать на изменения View Model и перестраивать UI. А View Model, в свою очередь, сможет изменяться под воздействием двух факторов: пользовательские или внешние события. Клик по кнопке это пример пользовательского события от View, а вот новое сообщение в чате является внешним событием.


В Авалонии View Model неразрывно связана с термином Data Context или просто "контекст". Я буду употреблять все понятия взаимозаменяемо.


MVVM


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


Верхнеуровневая струтура View Model выглядит слудующим образом (псевдокод):


App {
    ...
    Index # int
    ...
    Auth {
        ...
        Phone # string
        Password # string
    }
    Main {
        Nav {
            ...
            Contacts # ReactiveList<Contact>
        }
        Chat {
            ...
            Messages # ReactiveList<Message>
        }
    }
}

Родительский контекст управляет жизненным циклом дочерних контекстов, в его обязанности входит создание и высвобождение вложенных контекстов. Корневой DataContext передается в Builder при создании объекта MainWindow (см. выше), в дальнейшем именно он будет управлять всей иерархией View Model.


View устанавливает контекст для вложенных контролов через механизм связывания (Binding). На практике это нужно для задания значений для свойств объектов, и подписки на их изменения.


Обратите внимание, как биндинги используются для задания:


  1. Свойства SelectedIndex у контрола Carousel (определяет какую страницу показывает приложение — форму авторизации или чат)
  2. Свойства Text для TextBox (связывает значение в модели с текстом формы ввода номера телефона и пароля)
  3. Всех вложенных контекстов

<Window DataContext="{Binding App}">
    <Carousel SelectedIndex="{Binding Index}">
        <Panel DataContext="{Binding Auth}">
            <TextBox Text="{Binding Phone, Mode=TwoWay}" />
            <TextBox Text="{Binding Password, Mode=TwoWay}" />
        </Panel>
        <Grid DataContext="{Binding Main}">
            <Panel DataContext="{Binding Nav}">
                <ListBox Items="{Binding Contacts}" />
            </Panel>
            <Panel DataContext="{Binding Chat}">
                <ListBox Items="{Binding Messages}" />
            </Panel>
        </Grid>
    </Carousel>
</Window>

В этом примере AppContext содержит в себе два дочерних контекста: MainContext и AuthContext. AppContext управляет жизненным циклом вложенных контекстов: он отвечает за их инициализацию и высвобождение.


На практике это выглядит так: после старта приложения AppContext проверяет был ли пользователь авторизован, и если не был, инициализирует дочерний AuthContext. На создание AuthContext реагирует GUI приложения, показывая форму авторизации. Пользователь вводит учетные данные, авторизуется, на событие авторизации подписан AppContext, он высвобождает AuthContext и в этот же момент инициализирует MainContext. SelectedIndex переключается с 0 на 1, чтобы убрать форму авторизации и показать чат.


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


Всё немного интереснее с ChatContext: его создание (а заодно и высвобождение предыдущего контекста) происходит в момент выбора пользователем чата в меню навигации. Сам ChatContext будет подписан на внешние события, такие как: добавление, редактирование и удаление сообщений. Отображение, соответственно, будет реагировать отрисовкой сообщений, или их удалением. При этом, контекст должен подписаться на события только для выбранного чата, т.к. нас не интересуют события другого чата. Контекст чата реагирует и на пользовательские события, такие как ввод нового сообщения.


State


Вложенные модели обычно не содержат в себе ссылки на родительский контекст, однако имеют возможность взаимодействовать с внешними компонентами, чтобы получать от них события или делать вызовы (как пример — обертка над TDLib).


Асинхронность


Как и в большинстве GUI фрэймворков, Avalonia позволяет выполнять действия с элементами пользовательского интерфейса только с UI-потока. На этом потоке желательно выполнять минимум работы, чтобы приложение оставалось отзывчивым. С приходом async/await делегировать выполнение работы в другие потоки стало намного проще. Подход RX.NET во многом схож с async/await, но позволяет также легко работать и с сериями событий.


Приложение широко использует возможности Observable для обеспечения асинхронности. Рассмотрим пример — загрузка контактов пользователя. После загрузки приложения пользователь должен увидеть список своих контактов. В нашем случае контакт из себя представляет имя пользователя и его фото.


Сама загрузка — типичный запрос данных через сеть, т.е. такое действие точно лучше выполнять вне UI-потока. Простым решением будет использование async/await: главный поток инициирует загрузку, и когда она завершается, получает уведомление и показывает контакты. Еще на время загрузки можно показать прогресс-бар, чтобы пользователь знал, что происходит какая-то работа в фоне.


Loaders


Казалось бы, с этим подходом нет никаких проблем. Но, при ближайшем рассмотрении, можно будет увидеть, что только 10% времени (цифры приблизительные) приложение выполняло запрос на получение списка контактов, остальные 90% временного отрезка были заняты загрузкой и декодированием изображений. Всё это время юзер находился в ожидании. Существует ли лучший подход? Почему бы нам не показать список контактов сразу после выполнения первого запроса, а изображения догрузить уже "второй волной"?


Эта задача, в принципе, решается и средствами TPL, но применение Rx.NET лучше ложится на такой сценарий. Идея очень простая: мы точно также делегируем загрузку данных другому классу, но в этот раз в ответ ожидаем Observable вместо Task. Это позволит нам подписаться на серию событий, вместо одного: первым событием будет загруженный список контактов, а каждое последующее будет нести в себе какой-либо Update (загруженное фото, к примеру).


Рассмотрим загрузку контактов на примере. В задачу контекста входит подписка на результат выполнения LoadContacts. Обратите внимание на вызов метода ObserveOn — это инструкция для Rx.NET выполнять код, переданный в Subscribe на потоке планировщика Avalonia. Без этой инструкции мы не имеем право модифицировать свойство Contacts, т.к. код выполнится на потоке, отличном от UI-потока.


// NavContext.cs

class NavContext : ReactiveObject
{
    private ReactiveList<Contact> _contacts;
    public ReactiveList<Contact> Contacts
    {
        get => _contacts;
        set => this.RaiseAndSetIfChanged(ref _contacts, value);
    }

    public NavContext(ContactLoader contactLoader)
    {
        contactLoader.LoadContacts()
            .ObserveOn(AvaloniaScheduler.Instance)
            .Subscribe(x =>
            {
                Contacts = new ReactiveList(x.Contacts);

                x.Updates
                    .ObserveOn(AvaloniaScheduler.Instance)
                    .Subscribe(u =>
                    {
                        u.Contact.Avatar = u.Avatar;
                    });
            });
    }
}

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


// ContactLoader.cs

class ContactLoader
{
    IObservable<Load> LoadContacts()
    {
        return Observable.Create(async observer =>
        {
            var contacts = await GetContactsAsync(); // networking

            var updates = Observable.Create(async o =>
            {
                foreach (var contact in contacts)
                {
                    // load avatar from remote server
                    // ...
                    var avatar = await GetAvatarAsync(); // networking
                    o.OnNext(new Update(avatar));
                }
                o.OnComplete();
            });

            observer.OnNext(new Load(contacts, updates));
            observer.OnComplete();
        })
    }
}

Последовательностью событий можно управлять: комбинировать, фильтровать, трансформировать и т.д. Это очень удобно при большом количестве источников событий и самих событий. Rx.NET позволяет эффективно работать с Observable.


Небольшой пример: если закэшировать фото на диске, то загрузка значительно ускорится, однако, такое ускорение может привести к проблеме большого количества обновлений в короткий промежуток времени, что усложнит работу планировщика, и может привести к потере отзывчивости приложения. Чтобы избежать этого, мы будем использовать буферизацию: обработаем за раз все обновления, которые случились в течение ста миллисекунд, а за одно отфильтруем вхождения, которые не содержат фото (по какой-либо причине).


x.Updates
    .Where(u => u.Avatar != null)
    .Buffer(TimeSpan.FromMilliseconds(100))
    .ObserveOn(AvaloniaScheduler.Instance)
    .Subscribe(list =>
    {
        foreach (var u in list)
        {
            u.Contact.Avatar = u.Avatar;
        }
    });

Заключение


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


Digital Resistance


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


  1. Katsuko
    07.05.2018 09:45

    Как и в большинстве GUI фрэймворков, Avalonia позволяет выполнять действия с элементами пользовательского интерфейса только с UI-потока. На этом потоке желательно выполнять минимум работы, чтобы приложение оставалось отзывчивым. С приходом async/await делегировать выполнение работы в другие потоки стало намного проще.

    В WPF, насколько я помню, все async/await методы, которые вызывались со стороны UI, выполнялись в тоже UI потоке, если явно не указывали планировщик отличный от текущего контекста синхронизации.


    1. kekekeks
      07.05.2018 09:58

      1. mayorovp
        07.05.2018 11:08

        Вот только автор его не использует, посмотрите как метод LoadContacts() написан...


  1. mayorovp
    07.05.2018 11:16

    Мне в коде очень не нравится вот это место:


    u.Contact.Avatar = u.Avatar;

    Здесь что u.Contact, что u.Avatar — это объект который пришел от к NavContext от ContactLoader. Про них обоих ContactLoader знает. Так с какого перепугу установка свойства Avatar оказалась в ответственности NavContext?


    Зачем вообще ContactLoader возвращает объект который можно обработать одним-единственным способом? Чтобы больше boilerplate писать?..


    Надо или присваивание перенести в NavContext, или убирать свойство Avatar из DTO контакта.


    1. x2bool Автор
      07.05.2018 11:25

      Это правда спорное место. ContactLoader мог бы сам выполнить код на планировщике Avalonia. Преследуется несколько целей: 1) работу с UI-потоком выполнять только из классов Context для простоты и однообразности 2) Дать свободу контексту выполнять обновления в бэкграунде, если сущность не привязана к UI в данный момент.


      1. mayorovp
        07.05.2018 11:42

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


        У вас же Contact (или Conversation) не является потокобезопасным, но при этом изменяемый. Да, сейчас у вас все работает (ценой хака с u.Contact.Avatar = u.Avatar), потому что вы помните что можно делать а что нельзя. Но стоит в проект прийти новым разработчикам, или вам вернуться через год неактивности — и привет многопоточность.


        Выхода из этой ситуации — три.


        1. Сделать Contact потокобезопасным, чтобы можно было установить ему свойство Avatar из любого потока, а не только из потока UI.


        2. Сделать Contact неизменяемым. В таком случае у него не будет свойства Avatar, и понадобится отдельный класс ContactViewModel про который будет знать только контекст.


        3. Сделать Contact неразделяемым между потоками: после передачи в OnNext ContactLoader обязан забыть про существование этого объекта.


        1. x2bool Автор
          07.05.2018 11:58

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


          Мутабильные они "by design". Объясню почему. В любой момент с сервера Телеграма может прилететь апдейт — например, пользователь сменил имя или аватарку. Этот апдейт в конечном счете тоже дойдет до контекста, где нужно выполнить смену значения для этого свойства.


          Как лучше реализовать все эти кейсы, я не придумал. Если подскажете, буду только рад.


          1. mayorovp
            07.05.2018 12:11

            Все три варианта все еще подходят.

            В первом случае для простых прямых присвоений число переходов между потоками не изменится. Какая разница делать этот переход перед присваиванием или перед генерацией событий?

            Во втором случае кроме неизменяемых DTO у вас будут полностью изменяемые VM.


  1. mayorovp
    07.05.2018 11:26
    +2

    Еще замечания, не очень критичные.


    Код внутри Observable.Create будет выполняться в потоке UI, потому что именно в нем происходит подписка. Однако в текущей версии ему доступ к UI не нужен, так что для ускорения его желательно выгнать в фоновой поток:


    return Observable.Create(async observer =>
    {
        // ...
    }).SubscribeOn(Scheduler.Default);

    Также можно убрать все вызовы OnComplete: асинхронная версия Observable.Create сама вставит их когда завершится задача.


  1. worldbeater
    07.05.2018 13:48

    Oтличная рабoта, x2bool! UI у EGram замечательный — группирoвки чатoв, каналoв и бoтoв — этo именнo тo, чегo так не хватает в других десктoпных и мoбильных прилoжениях Telegram. Также неверoятнo класснo, чтo такие замечательные фреймвoрки, как AvaloniaUI и ReactiveUI, вышли в массы и начинают активнo испoльзoваться сooбществoм независимых разрабoтчикoв.

    Думаю, с мoей стoрoны будет уместным упoмянуть в кoмментариях библиoтеку PropertyChanged.Fody — с пoмoщью этoгo инструмента мoжнo значительнo упрoстить кoдoвую базу прилoжения, убрав шаблoнные геттеры и сеттеры, и даже нескoлькo увеличить прoизвoдительнoсть oтправки уведoмлений XAML-интерфейсам.

    Приведу пример. Вместo этoгo:

    public class ContactsViewModel : ReactiveObject
    {
      private ReactiveList<Contact> _contacts;
      public ReactiveList<Contact> Contacts
      {
        get => _contacts;
        private set => this.RaiseAndSetIfChanged(ref _contacts, value);
      }
    }
    

    С PropertyChanged.Fody будет дoстатoчнo написать следующее (Привет, АOП!):
    [AddINotifyPropertyChangedInterface]
    public class ContactsViewModel 
    {
      public ReactiveList<Contact> Contacts { get; private set; } 
    }
    

    А ещё этoт инструмент активнo пoддерживается сooбществoм и недавнo мы егo сдружили с реактивными oбъектами ReactiveUI. С бoлее пoдрoбным сравнением пoдхoдoв к oписанию мoделей представления с пoмoщью ReactiveUI, ReactiveProperty и PropertyChanged.Fody мoжнo oзнакoмиться в этoй заметке. Пример реактивнoй мoдели представления, приправленнoй кoдoгенерацией, мoжнo найти здесь.

    Надеюсь, этo смoжет пoмoчь и сделать чью-нибудь жизнь прoще.
    Спасибo за ваш труд. Пoжалуйста, прoдoлжайте в тoм же духе! :)


    1. x2bool Автор
      07.05.2018 18:50

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


  1. alhimik45
    07.05.2018 13:54

    Что-то домен egram.tel не резолвится.


    1. x2bool Автор
      07.05.2018 18:51

      DNS не прописан. Пока выкладывать туда особо нечего :)


  1. denaspireone
    07.05.2018 14:16
    -1

    Не увидел мануала, как это нечто скомпилировать под unix-like


    1. x2bool Автор
      07.05.2018 18:52

  1. IL_Agent
    07.05.2018 18:53
    +2

    Честно говоря, код ContactLoader ужасен. Observable.create надо применять тогда, когда по-другому никак. А тут каша из вложенных Observable.create, тасков и циклов. Await в цикле, кстати, тоже зло. Предлагаю 2 варианта:
    В первом используем Task в случае, когда нужно асинхронно вернуть одно значение. В Rx.net нет типа Single.

      async Task<Load> LoadContacts()
      {
          var contacts = await GetContactsAsync();
          var avatarUpdatesStream = contacts
            .ToObservable()
            .SelectMany(contact => GetAvatarAsync(contact)
                .ToObservable()
                .Select(avatar => new Update(contact, avatar))        
            );
          return new Load(contacts, avatarUpdatesStream)
      }
    


    Второй вариант — только Rx, без TPL
      IObservable<Load> LoadContacts() =>  
        GetContactsAsync()
            .ToObservable()
            .Select(contacts =>
            {      
                var avatarUpdatesStream = contacts
                .ToObservable()
                .SelectMany(contact => GetAvatarAsync(contact)
                    .ToObservable()
                    .Select(avatar => new Update(contact, avatar))
                )
                return new Load(contacts, avatarUpdatesStream)
            });
    


    1. x2bool Автор
      08.05.2018 08:38

      И правда, так намного лучше. Спасибо.


  1. IL_Agent
    08.05.2018 09:59

    А какой иде на маке пользуетесь? VS for Mac с ходу собрать не удалось. Собирается только командой dotnet. И да, при запуске на маке не находит libtdjson.dylib. Как поправить? На винде запускается нормально.


    1. x2bool Автор
      08.05.2018 10:38

      Rider. Возможно, VS for Mac не выполняет инструкцию копирования библиотек в итоговую сборку. Можно попробовать руками скопировать в output: https://github.com/x2bool/egram.tel/blob/master/Egram/libtdjson.dylib


      1. IL_Agent
        08.05.2018 10:41

        Я собирал и запускал с помощью dotnet build и dotnet Egram.dll. На винде запустилось, на маке — нет.
        Ок, попробую, спасибо.


      1. IL_Agent
        08.05.2018 10:46

        Попробовал. Оказывается, такой файл там уже был, однако ошибка присутствует.


        1. x2bool Автор
          08.05.2018 12:14

          Хм. Даже не знаю. Многие жаловались на Windows, но там проблему вроде решили: https://github.com/x2bool/egram.tel/issues/1. А я сам на маке, и оно у меня точно работает. Если не трудно, заведете issue и стэктрейс запостите туда?