На злобу дня: кроссплатформенный клиент для Telegram на .NET Core и Avalonia

в 6:19, , рубрики: .net, .net core, avalonia, C#, linux, MacOS, windows, Разработка под Linux, Разработка под OS X, разработка под windows, скажи нет электрону и наркотикам

В этой статье я расскажу, как реализовать кроссплатформенное приложение на .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

Автор: Sergey Khabibullin

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js