- PVSM.RU - https://www.pvsm.ru -
В этой статье вы узнаете про интересные проблемы и их решения, которые возникали в процессе разработки «конструктора» приложений, построенного на модульной архитектуре, в компании Notissimus [1]. Проект находится в стадии активной разработки, поэтому будем рады узнать ваше мнение в комментариях, а также приглашаем на заключительный в 2016 году митап [2] для разработчиков на Xamarin. Всех заинтересовавшихся просим под кат.
Далее повествование будет вестись от имени авторов.
Клиент существо капризное, поэтому требования к конечному продукту (приложению) у каждого будут свои. Однако, можно выделить и общие хотелки:
Эти четыре хотелки никак не меняются от клиента к клиенту, но могут появляться/пропадать. После определения хотелок надо понять что же они значат для простого программиста:
Определившись со своими задачами, мы решили использовать следующую схему:
Что представляют собой базовые модули? Во-первых, это некая архитектурная единица, состоящая из трех основных элементов: API, Core и UI. Во-вторых, это структура, полностью независимая ни от чего, кроме фундаментального Base проекта, в котором собраны все наработки и базовые элементы для быстрой сборки и подключения новых модулей (например, проект для упрощения работы с API, *LookupService’ы, обертка над БД, базовые ViewModel’и, базовые классы для UIViewController’ов и прочее). Таким образом, в основе каждого модуля лежит та или иная часть или части фундаментального Base модуля.
Примерами базовых модулей являются:
Модуль навигации со * потому что он не является базовым модулем в чистом виде, так как от выбранного типа навигации (меню, или вкладки, или что-то еще) сильно зависит логика обработки этой навигации на UI слое и также зависит точка входа в приложение – стартовая ViewModel, с которой начинается запуск приложения.
Это те модули, которые зависят от сегмента бизнеса под который ведется разработка проекта. Причины, по которым было принято решение о выделение их в отдельный слой, очевидны, но мы все же перечислим их:
Примерами таких модулей являются:
Из модуля каталога необходимо добавлять товары в корзину и реализацию этого через дополнительные обертки без прямой ссылки на модуль корзины нельзя назвать удобным способом.
Это тот проект, с которым можно взаимодействовать клиенту или его разработчику. Он содержит:
Что может сделать с этим проектом обычный пользователь, руководствуясь спецификацией:
Что может сделать с этим проектом разработчик:
Это Portable Class Library – библиотека (проект) код в которой может исполняться на любой платформе будь то iOS или Android. Стандартный API проект содержит в себе такие элементы как:
public interface IAuthService
{
/// <summary>
/// Авторизация пользователя по e-mail и паролю
/// </summary>
/// <returns>Авторизационный токен пользователя</returns>
/// <param name="email">E-mail</param>
/// <param name="password">Пароль</param>
Task<string> SignIn(string email, string password);
/// <summary>
/// Авторизация пользователя по e-mail и типу соц. сети
/// </summary>
/// <returns>Авторизационный токен пользователя</returns>
/// <param name="email">E-mail</param>
/// <param name="socialTypeName">Название типа соц. сети</param>
/// <param name="additionalFields">Дополнительные поля</param>
Task<string> SignInSocial(string email, string socialTypeName, Dictionary<string, object> additionalFields = null);
/// <summary>
/// Регистрация пользователю по e-mail и паролю
/// </summary>
/// <returns>Авторизационный токен пользователя</returns>
/// <param name="email">E-mail</param>
/// <param name="password">Пароль</param>
/// <param name="additionalFields">Дополнительные поля</param>
Task<string> SignUp(string email, string password, Dictionary<string, object> additionalFields = null);
/// <summary>
/// Восстановление забытого пароля
/// </summary>
/// <returns>Сообщение для пользователя</returns>
/// <param name="email">E-mail</param>
Task<string> RecoveryPassword(string email);
/// <summary>
/// Завершение сессии
/// </summary>
/// <param name="token">Авторизационный токен пользователя</param>
Task SignOut(string token);
}
public class AuthService : BaseService, IAuthService
{
#region IAuthService implementation
public async Task<string> SignIn(string email, string password)
{
return await Post<string>(SIGN_IN_URL, ToStringContent(new { email, password }));
}
public async Task<string> SignInSocial(string email, string socialTypeName, Dictionary<string, object> additionalFields = null)
{
return await Post<string>(SIGN_IN_SOCIAL_URL, ToStringContent(new { email, socialTypeName, additionalFields }));
}
public async Task<string> SignUp(string email, string password, Dictionary<string, object> additionalFields = null)
{
return await Post<string>(SIGN_UP_URL, ToStringContent(new { email, password, additionalFields }));
}
public async Task<string> RecoveryPassword(string email)
{
return await Post<string>(RECOVERY_PASSWORD_URL, ToStringContent(new { email }));
}
public Task SignOut(string token)
{
return Post(SIGN_OUT_URL, ToStringContent(new { token }));
}
#endregion
}
После добавления сервиса в проект дополнительных действий для его регистрации не требуется, “регистратор” все делает сам благодаря следующим строкам:
CreatableTypes()
.EndingWith("Service")
.AsInterfaces()
.RegisterAsLazySingleton();
Это также PCL проект, полностью построенный на использовании возможностей, которые нам предоставляет MvvmCross. Стандартный Core проект содержит следующие элементы:
public interface IMenuVmService
{
public IEnumerable BuildItemsFromJsonConfig();
}
public class MenuVmService : IMenuVmService
{
public IEnumerable BuildItemsFromJsonConfig()
{
...
}
}
IDeviceService
, который получает информацию о текущем устройстве);Перед началом разработки мы обговорили, что большая часть логики в Core может быть переопределена и каждая может быть заменена полностью вашей реализацией. И если с заменой Service’ов через IoC все ясно, то с заменой ViewModel’ей не все очевидно. Встал вопрос: «как это реализовать?». Ответом стала реализация ViewModelLookupService
.
Это сервис, который позволяет по интерфейсу ViewModel’и регистрировать свою реализацию. Принцип похож на IoC, только ViewModelLookupService не работает с экземплярами VM’ок. Как же тогда происходит навигация? Дело в том, что метод VM ShowViewModel() принимает в себя тип VM, которую требуется отобразить. Таким образом, при регистрации вью модели в сервисе берется полная информация о типе интерфейса VM и тип реализации VM и сохраняется в сервисе. При обращении к сервису для получения зарегистрированной реализации, он обращается к сохраненным данным и обратно возвращает тип реализации.
Это нам дает возможность задавать свои реализации моделей в конфигах. Пример:
...
"items":
[
{
"icon":"res:Images/Menu/catalog.png",
"name":"Каталог",
"type":"AppRopio.ECommerce.Products.Core.ViewModels.IProductsViewModel",
"default":true
},
{
"icon":"res:Images/Menu/basket.png",
"name":"Корзина",
"type":"AppRopio.ECommerce.Basket.Core.ViewModels.IBasketViewModel",
"badge":true
},
{
"icon":"res:Images/Menu/history.png",
"name":"История заказов",
"type":"AppRopio.ECommerce.OrdersHistory.Core.ViewModels.IOrdersHistoryViewModel"
},
{
"icon":"res:Images/Menu/favorites.png",
"name":"Избранное",
"type":"AppRopio.ECommerce.Favorites.Core.ViewModels.IFavoritesViewModel"
}
]
...
Таким образом можно задать элементу списка: название, тип VM’ки, которую надо попытаться получить от ViewModelLookupService
при нажатии на элемент и вызова логики навигации, а также задать наличие бейджа у пункта и обозначить один из пунктов как стартовый экран.
Благодаря введению ViewModelLookupService
все VM’ки обзавелись собственным интерфейсом – это позволяет также не терять возможность замены логики при биндинге VM на UI слое. Также регистрация реализаций своих ViewModel’ей в ViewModelLookupService
является обязательным условием для каждого модуля.
На самом деле с навигацией из модуля Меню через ViewModelLookupService
не все так просто. После реализации этого механизма мы подумали, что у модуля навигации не должно быть явной привязки к навигируемому типу, а также должна быть возможность выполнить некоторую логику перед совершением навигации в пункт меню (например, в меню может быть пункт Личный кабинет или История заказов, доступ в которые должен быть заблокирован до авторизации пользователя). Поэтому было решено разработать механизм RouterService’а.
RouterService
– это сервис, который управляет навигацией по типу интерфейса VM’ки. Вызов его происходит следущим образом:
protected void OnItemSelected(IMenuItemVM item)
{
if (!RouterService.NavigatedTo(item.Type))
MvxTrace.Trace(MvvmCross.Platform.Platform.MvxTraceLevel.Error, "NavigationError: ", $"Can't navigate to ViewModel of type {item.Type}");
}
Для обработки события навигации на какой-либо тип модулю необходимо зарегистрировать на этот тип в RouterService’е свою реализацию IRouterSubscriber
, который в себе содержит всего два метода:
public interface IRouterSubscriber
{
bool CanNavigatedTo(string type);
void FailedNavigatedTo(string type);
}
Первый вызывается внутри RouterService.NavigatedTo(...)
методе, если по типу item.Type
был зарегистрирован подписчик. Второй, если первый метод вернул false или возникла какая-либо ошибка на других этапах навигации.
При реализации первого метода подписчик обязан обработать пришедший ему тип, выполнить требуемые проверки и в случае их прохождения получить от ViewModelLookupService
зарегистрированный тип реализации модели и выполнить на него навигацию, иначе необходимо вернуть false
. При реализации FailedNavigatedTo(...)
никаких ограничений нет.
Таким образом, обработка навигации на ключевые точки была вынесена из модуля Меню и позволила выполнять навигацию на любые ViewModel’и и выполнять любую логику (например, при тапе на пункт меню требуется выполнить навигацию не на экран, а открыть сайт компании)
Слой состоит из проектов двух типов:
Каждый из проектов обязательно содержит в себе:
Реализацию платформенных сервисов мы посмотрим чуть позже, реализация пользовательских интерфейсах не отличается от той, что делаете вы сейчас, поэтому разберемся подробнее в использовании различных клиентских настроек приложения.
Настройки бывают двух типов:
Сам по себе файл настроек – это .json документ. Настройки загружаются один раз в специальные сервисы, стартующие при запуске модуля. Конфигурирующие настройки загружаются в Core в ConfigService’ы, тематические – в UI в ThemeServices. Процедура загрузки json’а из файла достаточно стандартная, за исключением того, что Core – PCL, то есть инструменты работы с файлами там отсутствуют (см. .NET Standard 2.0). Это привело к внедрению специального сервиса ISettingsService
, реализация которого находится в UI слое фундаментального Base модуля, что позволяет выполнять логику загрузки информации о настройках без проблем.
Перед разработкой нового модуля необходимо будет приобрести и скачать с личного кабинета клиента исходные коды его приложения. Таким образом, у вас окажется решение с двумя запускаемыми проектами (под iOS и под Android) с уже созданной архитектурой и выбранными настройками. Сейчас будет рассматриваться лишь создание модуля фотогалереи с нуля для существующего iOS приложения. Модуль будет получать снимки с камеры устройства, отправлять их на сервер, сохранять в альбом, и отображать в коллекции.
Сперва для удобства создаем новую Solution Folder, называем ее Photogallery. После этого последовательно добавляем в эту папку три проекта:
Удаляем автоматически созданные MyClass.cs
и добавляем в проекты следующие ссылки:
Также необходимо к каждому проектам подключить MvvmCross пакет из NuGet.
При фотографировании наш плагин будет отправлять фотографии на некий сервер для сохранения истории (или, например, для публикации). Для этого необходимо добавить в API проект сервис, который будет выполнять эту работу. Создадим в проекте папку Services и добавим в нее интерфейс IPhotoService
в котором опишем требуемый функционал.
public interface IPhotoService
{
Task SendPhoto(byte[] photoData);
}
Теперь напишем реализацию сервиса:
public class PhotoService : BaseService, IPhotoService
{
private const string PHOTO_URL = "photo";
#region IPhotoService implementation
public async Task SendPhoto(byte[] photoData)
{
await Post(PHOTO_URL, new ByteArrayContent(photoData));
}
#endregion
}
Благодаря реализации BaseService
в Base.API проекте Base модуля, выполнение запроса по требуемому URL выполняется всего в одну строку. Аналогичным образом можно добавить реализацию метода получения фотографий от сервера. Точка входа API берется из настроек в запускаемом проекте и используется как префикс URL у всех запросов. Если по какой-то причине реализация Post(...) метода не устраивает, можно обратиться напрямую к сервису запросов.
Чтобы сервис заработал, осталось зарегистрировать его. Для этого создадим в API проекте класс App и напишем в нем следующий код:
public class App : MvxApplication
{
public override void Initialize()
{
CreatableTypes()
.EndingWith("Service")
.AsInterfaces()
.RegisterAsLazySingleton();
}
}
Здесь в методе Initialize
мы автоматически регистрируем все сервисы в API как Lazy синглтоны для их последующего вызова из Core части.
Для данного модуля мы сделаем простую VM, которая будет содержать лишь список полученных от пользователя фотографий и кнопку добавления в него новой фотографии. В проекте Core создаем папку ViewModels, внутри нее папку Photogallery и туда добавляем новый интерфейс IPhotogalleryViewModel
и новый класс PhotogalleryViewModel
, который наследуем от интерфейса и от BaseViewModel
.
В интерфейс IPhotogalleryViewModel добавим следующие строки:
ObservableCollection<IPhotoItemVM> Items { get; set; }
ICommand AddPhotoCommand { get; }
Items – список отображаемых фотографий, AddPhotoCommand – добавление новой фотографии в коллекцию.
Загрузка всех фотографий и логика получения новой фотографии будет в сервисе, реализующим интерфейс:
public interface IPhotogalleryVmService
{
Task<ObservableCollection<IPhotoItemVM>> LoadItems();
Task<IPhotoItemVM> GetPhotoFromUser();
}
VmService
будет для получения новой фотографии обращаться к сервису камеры устройства, реализация которого будет на каждой платформе своя, и, для загрузки фотографий из альбома, к сервису работы с альбомами.
public interface ICameraService
{
Task<byte[]> TakePhoto();
}
public interface IPhotoAlbumService
{
Task<List<byte[]>> LoadPhotosFrom(string albumName);
}
Осталось лишь зарегистрировать имеющиеся в Core сервисы и ViewModel’и (регистрация вьюмоделей происходит для возможности их последующей замены). Происходит все по аналогии с API – создается App.cs в котором переопределяется метод Initialize следующим образом:
public override void Initialize()
{
(new API.App()).Initialize();
CreatableTypes()
.EndingWith("Service")
.AsInterfaces()
.RegisterAsLazySingleton();
var vmLookupService = Mvx.Resolve<IViewModelLookupService>();
vmLookupService.Register<IPhotogalleryViewModel>(typeof(PhotogalleryViewModel));
}
Сперва реализуем все платформенные сервисы. Начнем с сервиса камеры. Создадим в iOS проекте папку Services и добавим в нее CameraService:
public class CameraService : ICameraService
{
public Task<byte[]> TakePhoto()
{
throw new NotImplementedException();
}
}
public async Task<byte[]> TakePhoto()
{
var mediaFile = await CrossMedia.Current.TakePhotoAsync(
new StoreCameraMediaOptions
{
DefaultCamera = CameraDevice.Rear
});
var stream = mediaFile.GetStream();
var bytes = new byte[stream.Length];
await stream.ReadAsync(bytes, 0, (int)stream.Length);
PHAssetCollection assetCollection = null;
var userCollection = PHAssetCollection.FetchAssetCollections(PHAssetCollectionType.Album, PHAssetCollectionSubtype.Any, null);
if (userCollection != null)
assetCollection = userCollection.FirstOrDefault(nsObject => (nsObject as PHAssetCollection).LocalizedTitle == ALBUM_NAME) as PHAssetCollection;
if (assetCollection == null)
{
string assetCollectionIdentifier = string.Empty;
PHPhotoLibrary.SharedPhotoLibrary.PerformChanges(() =>
{
var creationRequest = PHAssetCollectionChangeRequest.CreateAssetCollection(ALBUM_NAME);
assetCollectionIdentifier = creationRequest.PlaceholderForCreatedAssetCollection.LocalIdentifier;
}, (bool success, NSError error) =>
{
assetCollection = PHAssetCollection.FetchAssetCollections(new[] { assetCollectionIdentifier }, null).firstObject as PHAssetCollection;
PHPhotoLibrary.SharedPhotoLibrary.PerformChanges(() =>
{
var assetChangeRequest = PHAssetChangeRequest.FromImage(UIImage.LoadFromData(NSData.FromArray(bytes)));
var assetCollectionChangeRequest = PHAssetCollectionChangeRequest.ChangeRequest(assetCollection);
assetCollectionChangeRequest.AddAssets(new[] { assetChangeRequest.PlaceholderForCreatedAsset });
}, (bool s, NSError e) =>
{
});
});
}
else
{
PHPhotoLibrary.SharedPhotoLibrary.PerformChanges(() =>
{
var assetChangeRequest = PHAssetChangeRequest.FromImage(UIImage.LoadFromData(NSData.FromArray(bytes)));
var assetCollectionChangeRequest = PHAssetCollectionChangeRequest.ChangeRequest(assetCollection);
assetCollectionChangeRequest.AddAssets(new[] { assetChangeRequest.PlaceholderForCreatedAsset });
}, (bool success, NSError error) =>
{
});
}
return bytes;
}
Добавим также сервис для работы с фотоальбомами:
public class PhotoAlbumService : IPhotoAlbumService
{
public Task<List<byte[]>> LoadPhotosFrom(string albumName)
{
throw new NotImplementedException();
}
}
public Task<List<byte[]>> LoadPhotosFrom(string albumName)
{
var photos = new List<byte[]>();
var tcs = new TaskCompletionSource<List<byte[]>>();
var userCollection = PHAssetCollection.FetchAssetCollections(PHAssetCollectionType.Album, PHAssetCollectionSubtype.Any, null);
if (userCollection != null)
{
var meetUpAssetCollection = userCollection.FirstOrDefault(nsObject => (nsObject as PHAssetCollection).LocalizedTitle == "Xamarin MeetUp") as PHAssetCollection;
if (meetUpAssetCollection != null)
{
var meetUpPhotoResult = PHAsset.FetchAssets(meetUpAssetCollection, null);
if (meetUpPhotoResult.Count > 0)
meetUpPhotoResult.Enumerate((NSObject element, nuint index, out bool stop) =>
{
var asset = element as PHAsset;
PHImageManager.DefaultManager.RequestImageData(asset, null, (data, dataUti, orientation, info) =>
{
var bytes = data.ToArray();
photos.Add(bytes);
if (index == (nuint)meetUpPhotoResult.Count - 1)
tcs.TrySetResult(photos);
});
stop = index == (nuint)meetUpPhotoResult.Count;
});
else
return new Task<List<byte[]>>(() => photos);
}
}
else
return new Task<List<byte[]>>(() => photos);
return tcs.Task;
}
Не забываем добавить в Info.plist ключи NSCameraUsageDescription
и NSPhotoLibraryUsageDescription
.
Для верстки экрана добавим в проект папку View, в ней создадим папку Photogallery и в нее добавим PhotogalleryViewController
. Добавим в Interface Builder на PhotogalleryViewController
два элемента – UICollectionView
и UIButton
и создадим для них аутлеты _photoCollection
и _addPhotoBtn
соответственно. Тепер сбиндим их в методе BindControls
:
protected override void BindControls()
{
_photoCollection.RegisterNibForCell(PhotogalleryCell.Nib, PhotogalleryCell.Key);
var dataSource = new MvxCollectionViewSource(_photoCollection, PhotogalleryCell.Key);
var set = this.CreateBindingSet<PhotogalleryViewController, IPhotogalleryViewModel>();
set.Bind(dataSource).To(vm => vm.Items);
set.Bind(_addPhotoBtn).To(vm => vm.AddPhotoCommand);
set.Apply();
_photoCollection.DataSource = dataSource;
_photoCollection.ReloadData();
}
Сейчас наш модуль полностью готов к работе, осталось лишь подключить его к основному проекту.
Для подключения нашего модуля необходимо выполнить шесть шагов:
Первый. Добавить в Core проект класс PluginLoader
, который будет запускать инициализацию App.cs.
public class PluginLoader : IMvxPluginLoader
{
public static readonly PluginLoader Instance = new PluginLoader();
private bool _loaded;
public void EnsureLoaded()
{
if (_loaded)
return;
new App().Initialize();
var manager = Mvx.Resolve<IMvxPluginManager>();
manager.EnsurePlatformAdaptionLoaded<PluginLoader>();
MvxTrace.Trace("Auth plugin is loaded");
_loaded = true;
}
}
Второй. Добавить в UI проект класс Plugin, в котором будет регистрироваться ViewController и платформенные сервисы.
public class Plugin : IMvxPlugin
{
public void Load()
{
var viewLookupService = Mvx.Resolve<IViewLookupService>();
viewLookupService.Register<IPhotogalleryViewModel, PhotogalleryViewController>();
Mvx.RegisterSingleton<ICameraService>(() => new CameraService());
Mvx.RegisterSingleton<IPhotoAlbumService>(() => new PhotoAlbumService());
}
}
Третий. Добавить в запускаемый проект класс XMU_PhotogalleryPluginBootstrap
.
public class XMU_PhotogalleryPluginBootstrap
: MvxLoaderPluginBootstrapAction<PluginLoader, Photogallery.iOS.Plugin>
{
}
Четвертый. Прописать навигацию на фотогалерею из меню в конфиге.
{
"icon":"res:Images/Menu/photo.png",
"name":"Фотогалерея",
"type":"Photogallery.Core.ViewModels.Photogallery.IPhotogalleryViewModel"
}
Пятый. Добавить обработку события навигации в Core плагина.
public class PhotogalleryRouterSubscriber : MvxNavigatingObject, IRouterSubscriber
{
private string VM_TYPE = (typeof(IPhotogalleryViewModel)).FullName;
public override bool CanNavigatedTo(string type)
{
return type == VM_TYPE ? ShowViewModel(LookupService.Resolve(type)) : false;
}
public override void FailedNavigatedTo(string type)
{
//nothing
}
}
Шестой. И зарегистрировать его в App.cs.
var routerService = Mvx.Resolve<IRouterService>();
routerService.Register<IPhotogalleryViewModel>(new PhotogalleryRouterSubscriber());
Запустим наш проект и убедимся, что все работает как запланировали.
Мы рассмотрели основные моменты при работе с нашей платформой. Главные мысли, которые хотелось донести:
Обсуждение появившихся в процессе чтения мыслей предлагаем перенести в комментарии. Спасибо, что прочитали!
Максим Евтух [3] – Разработчик мобильных приложений на фреймворке Xamarin в компании «НОТИССИМУС». В мобильной разработке с 2013 года. В свободное время занимается изучением вопроса усовершенствования MvvmCross’а и поддержкой контрола GitHub [4] для реализации новых гайдов Material Design.
Денис Кретов – технический директор в компании «НОТИССИМУС». Специализируется на разработке мобильных приложений для интернет-магазинов, а также решений на базе iBeacon.
Другие статьи из нашего блога о Xamarin читайте по ссылке #xamarincolumn [5].
Автор: Microsoft
Источник [6]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/216030
Ссылки в тексте:
[1] Notissimus: http://notissimus.com
[2] митап: https://aka.ms/event_xamarin_81216
[3] Максим Евтух: https://habrahabr.ru/users/pocheshire/topics/
[4] GitHub: https://github.com/pocheshire/BottomNavigationBar
[5] #xamarincolumn: https://habrahabr.ru/search/?target_type=posts&q=%5Bxamarincolumn%5D&order_by=date
[6] Источник: https://habrahabr.ru/post/316714/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.