Хабрахабр

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

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

Egram

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

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

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

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

Введение

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

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

Avalonia

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

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

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

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

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

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

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

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

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

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

Реализация MVVM

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

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

MVVM

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

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

App 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 управляет жизненным циклом вложенных контекстов: он отвечает за их инициализацию и высвобождение. В этом примере AppContext содержит в себе два дочерних контекста: MainContext и AuthContext.

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

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

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

State

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

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

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

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

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

Loaders

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

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

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

// 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; }); }); }
}

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

// 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(); }) }
}

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть