Добрый день.
Как многие наверное знают, Павел Дуров разрабатывает новый клон What’s App и прочих популярных менеджеров на базе своего собственного протокола MTProto.
Недавно американская компашка выпустила iOS клиент под этот протокол под названием Telegram. Параллельно с этим проводится — конкурс на разработку Android клиента.
Недавно завершился второй этап, народ отправил свои поделки и я в том числе. Скажу сразу, второй этап я не прошел.
В отличие от многих участников, для разработки я пользовался языком C# и Xamarin о чем и хочу рассказать подробнее ниже, так как по Xamarin в рунете информации скажем прямо немного.
Заместо вступления
Я дотнетчик. Я работаю с дотнетом со второй версии, когда еще был студентом, хорошо знаю возможности и особенности этой платформы. Не так давно мне захотелось разрабатывать на мобильные устройства, запрос «Android C#» и вывел меня на Xamarin — MonoDroid. Но до сих пор я писал только игрался с ним, это был первый серьезный проект на Android, о нем я и хочу рассказать. Для понимания этой статьи требуется знание C#, .NET и хотя бы примитивное понимание Android.
Что это — в двух словах
Xamarin это компания (а также мобильная платформа) созданная Нэтом Фридменом и Мигелем де Икаса — автором GNOME и Mono. Таким образом Xamarin является логичным развитием Mono.
Xamarin позволяет писать нативные приложения для Anroid и iOS на C# и это прекрасно. Я лично считаю, что за гибридной кросс-платформой будущее. А еще Mac. А еще активно голосуют за MonoBerry, который возможно когда-то войдет в состав Xamarin.
Задача
В задачах конкурса была реализация предоставленного протокола MTProto (первый этап) и создание полноценного приложения (второй этап). На третьем этапе доработка.
Протокол в целом представляет из себя реализацию RPC со всякими плюшками типа продвинутого шифрования и всякое разное.
Решение
Здесь и далее я буду рассказывать как я решал эти задачи. Итак, приступим.
Получение Xamarin
Xamarin стоит 2000$. Да, это так. Если вы хотите писать в любимой студии его цена — 999$ за платформу. Если вам хватит неплохой среды MonoDevelop — ее стоимость 299$ за платформу. В переписке с авторами я смог выклянчить скидку до 799$ за платформу.
Как же можно получить xamarin? Ну для начала его можно скачать с торрентов. Xamarin предлагает академическую лицензию за 99$ за платформу которая дает все возможности Business кроме поддержки по почте. И да, если твоя жена аспирант это тоже работает.
Создание структуры решения
Как я уже упоминал Xamarin создает нативный код для каждой мобильной ОС. Это значит что под каждую ОС будет своя сборка, но код между ними должен быть разделен. Создатели Xamarin предлагают целых три способа как это сделать, но для Visual Studio самый простой — прекрасная утилита Project Linker, встраиваемая в непосредственно в среду.
Пара кликов мышки и кроссплатформенное решение готово:
Все файлы подключены как ссылки, любые изменения в основном проекте будут отображены во все привязанные проекты.
Утилитка ставиться из «Диспетчера расширений» студии.
Структура решения
Основные сборки это MTProto.Core и Talks.Backend. Это обычные сборки под .net 4.5 и покрытые Unit-тестами.
Mono.Stub — это несколько специфических классов из Mono, в частности я использую оттуда BigInteger.
Папка Droid — содержит в себе андройдовские клоны проектов, Dataflow — это исходники TPL.Dataflow с гитхаба. Я активно использую в своем проекте асинхронные возможности C# 5.0.
В папке Platfrom — конкретные реализации под каждую платформу. Пока это только Android.
MTProto.Core
Это реализация протокола. Протокол в целом представляет из себя RPC с продвинутым шифрованием и некоторыми дополнительными возможностями, типа формирования контейнера или отправки/получения файлов кусочками. Таким образом для реализации IM клиента нам необходимо научиться выполнять RPC запрос, получать ответ, а так же обрабатывать входящие системные сообщения и обновления состояний.
Из особенностей: async all the way и Dataflow.
async all the way
C# 5.0 ввел пару ключевых слов которые чрезвычайно упрощают разработку асинхронного кода на основании Task Asynchronous Pattern (TAP). Очень хорошо они описаны в MSDN.
Все операции IO должны быть асинхронными, это должно быть как заповедь.
public async Task RunAsync() { await _cl.LoadSettings().ConfigureAwait(false); if (await _cl.CheckAndGenerateAuth().ConfigureAwait(false)) { await _cl.RunAsync().ConfigureAwait(false); } if ((_cl.Settings.DataCenters == null) || (_cl.Settings.DataCenters.Count == 0)) { await _cl.GetConfig().ConfigureAwait(false); } _db = await TalksDatabase.GetDatabase().ConfigureAwait(false); _ldm = new LocalDataManager(_db); _cl.ProcessUpdateAsync = ProcessUpdateAsync; }
Стивен Клири — один из ведущих специалистов по асинхронному программированию на C# — написал несколько принципов использования async-await которые уже де-факто стали стандартами его использования. Если вы еще не читали, советую.
Суть подхода «async all the way» в том что все методы по дереву вызовов асинхронны начиная с event и заканчивая непосредственно операцией IO (в данном случае).
Например если необходимо асинхронно обработать клик по кнопке:
async void button_Click(object sender, EventArgs e) { _button.Enabled = false; await _presenter.SendMessage(); }
То вы делаете асинхронными все методы по дереву вызовов в Presenter:
public Task<bool> SendMessage() { return SendMessageToUser(); }
public async Task<bool> SendMessageToUser() { ... try { _imv.AddMineMessage(msg); string msgText = _imv.PendingMessage; _imv.PendingMessage = ""; // messages.sendMessage#4cde0aab peer:InputPeer message:string random_id:long = messages.SentMessage; var result = await _model.PerformRpcCall("messages.sendMessage", InputPeerFactory.CreatePeer(_model, PeerType.inputPeerContact, _imv.ChatId), msgText, LongRandom(r)); if (result.Success) { // messages.sentMessage#d1f4d35c id:int date:int pts:int seq:int = messages.SentMessage; msg.Id = result.Answer.ExtractValue<int>("id"); ... msg.State = BL.Messages.MessageState.Sent; _imv.IvalidateList(); await _model.ProcessSentMessage(result.Answer, _imv.ChatId, msg); return true; } else { msg.State = BL.Messages.MessageState.Failed; _imv.SendSmallMessage("Problem sending message: " + result.Error.ToString()); return false; } } catch (Exception ex) { ... } }
И в Core:
public Task<RpcAnswer> PerformRpcCall(string combinatorName, params object[] pars) { return _cl.PerformRpcCall(combinatorName, pars); }
public async Task<RpcAnswer> PerformRpcCall(string combinatorName, params object[] pars) { try { /*...*/ var confirm = CreateConfirm(); // Буфер для ответа WriteOnceBlock<RpcAnswer> answer = new WriteOnceBlock<RpcAnswer>(e => e); IOutputCombinator oc; if (confirm != null) { var cntrn = new MsgContainer(); cntrn.Add(rpccall); // прикрепим к RPC Call все ожидающие подтверждения cntrn.Add(confirm); cntrn.Combinator = _tlc.Decompose(0x73f1f8dc); // Добавим в общую очередь oc = new OutputMsgContainer(uniqueId, cntrn); } else // не используем контейнер { oc = new OutputTLCombinatorInstance(uniqueId, rpccall); } var uhoo = await SendRpcCallAsync(oc).ConfigureAwait(false); _inputAnswersBuffer.LinkTo(answer, new DataflowLinkOptions { MaxMessages = 1 }, i => i.SessionId == _em.SessionId); return await answer.ReceiveAsync(TimeSpan.FromSeconds(60)).ConfigureAwait(false); // таймаут если ответа нету слишком долго } catch (Exception ex) { ... } }
Как видите далеко не все методы помечены ключевыми словами async-await. Общая практика такова: если вам не нужно ничего делать после асинхронного вызова и если у вас один асинхронный вызов то имеет смысл просто вернуть его как Task из метода.
Еще одна практика (так же описанная в статье Клири) — асинхронные методы внутри библиотек не должны захватывать контекст и пытаться вернуться к нему после выполнения. Т.е. все асинхронные вызовы должны содержать .ConfigureAwait(false)
Сделано это чтобы предотвратить deadlock’и. Подробнее об этом можно прочитать в статье выше.
Dataflow
TPL.Dataflow — это библиотека разработанная для реализации шаблона проектирования Data Flow или конвейера обработки. Исходный код библиотеки доступен на гитхабе что позволяет использовать ее и на мобильных устройствах. Желающих поподробнее узнать о возможностях этой библиотеки отправляю в MSDN.
В двух словах, библиотека позволяет построить конвейер состоящий из блоков хранения или обработки данных, связав их по какому-либо условию. Изначально в моем проекте таких конвейеров было две штуки: для входных и для выходных пакетов. После рефакторинга я решил оставить только один для входящих пакетов.
Выглядит оно так:
а процесс создания выглядит так:
BufferBlock<byte[]> _inputBufferBytes = new BufferBlock<byte[]>(); BufferBlock<InputTLCombinatorInstance> _inputBuffer = new BufferBlock<InputTLCombinatorInstance>(); ActionBlock<byte[]> _inputBufferParcer; ActionBlock<TLCombinatorInstance> _inputUpdates; ActionBlock<TLCombinatorInstance> _inputSystemMessages; TransformBlock<InputTLCombinatorInstance, RpcAnswer> _inputAnswers; BufferBlock<RpcAnswer> _inputAnswersBuffer = new BufferBlock<RpcAnswer>(); BufferBlock<RpcAnswer> _inputRejectedBuffer = new BufferBlock<RpcAnswer>(); BufferBlock<InputTLCombinatorInstance> _inputUnsorted = new BufferBlock<InputTLCombinatorInstance>(); // -- // выходная сетка _inputBufferParcer = new ActionBlock<byte[]>(bytes => ProcessInputBuffer(bytes)); _inputSystemMessages = new ActionBlock<TLCombinatorInstance>(tlci => ProcessSystemMessage(tlci)); _inputUpdates = new ActionBlock<TLCombinatorInstance>(tlci => ProcessUpdateAsync(tlci)); _inputAnswers = new TransformBlock<InputTLCombinatorInstance, RpcAnswer>(tlci => ProcessRpcAnswer(tlci)); // from [_inputBufferBytes] to [_inputBufferTransformer] _inputBufferBytes.LinkTo(_inputBufferParcer); // from [_inputBufferTransformer] to [_inputBuffer] //_inputBufferTransformer.LinkTo(_inputBuffer); // if System then from [_inputBuffer] to [_inputSystemMessages] _inputBuffer.LinkTo(_inputSystemMessages, tlciw => _systemCalls.Contains(tlciw.Combinator.Name)); // if Updates then from [_inputBuffer] to [_inputUpdates] _inputBuffer.LinkTo(_inputUpdates, tlciw => tlciw.Combinator.ValueType.Equals("Updates")); // if rpc_result then from [_inputBuffer] to [_inputRpcAnswers] _inputBuffer.LinkTo(_inputAnswers, tlciw => tlciw.Combinator.Name.Equals("rpc_result")); // if rpc_result then from [_inputBuffer] to [_inputRpcAnswers] //_inputBuffer.LinkTo(_inputUnsorted); // and store it [_inputAnswers] to [_inputAnswersBuffer] to process it _inputAnswers.LinkTo(_inputAnswersBuffer); _inputRejectedBuffer.LinkTo(_inputAnswersBuffer);
Как видите входные байтовые массивы разбираются, классифицируются и раскладываются по буферам, откуда уже разбираются по необходимости. В частности updates и systemMessages обрабатываются сразу по приходу в ActionBlock
, а rpcAnswers сначала преобразуется с помощью TransformBlock
а потом складывается в BufferBlock
. Классификация типа пакета происходит внутри BufferBlock
на основании условий связывания блоков.
Непосредственно после вызова метода мы создаем WriteOnceBlock — блок куда можно записать только 1 значение:
WriteOnceBlock<RpcAnswer> answer = new WriteOnceBlock<RpcAnswer>(e => e);
И линкуем его к буферу RPC ответов:
_inputAnswersBuffer.LinkTo(answer, new DataflowLinkOptions { MaxMessages = 1 }, i => i.SessionId == _em.SessionId);
А дальше асинхронно ждем пока придет ответ:
return await answer.ReceiveAsync(TimeSpan.FromSeconds(60)).ConfigureAwait(false); // таймаут если ответа нету слишком долго
Отдельно хочу отметить что до этого момента я не написал ни строчки кода под Android. Вся разработка и тестирование велось для обычной сборки под .net 4.5
Talks.Backend
Бэкэнд клиента. Я решил реализовывать клиент по шаблону проектирования MVP с IoC, причем изначально я нацеливался на Passive View вариацию, когда представление не содержит никакой логики, но в итоге я пришел к пониманию что Supervising Controller сработал бы намного лучше.
Какие же проблемы возникли передо мной при создании бэкэнда? Доступ к записной книжке, доступ к базе данных, доступ к файловой системе (для хранения фоточек). Остальная часть бэкэнда это обыкновенная реализация MVP: набор Presenter’ов и IView’шек
Доступ к записной книжке
Для доступа к записной книжке команда Xamarin все уже придумала за нас. Они разработали библиотеку Xamarin.Mobile которая инкапсулирует набор функций на мобильном устройстве — записная книжка, GPS, камера, в кроссплатформенной манере. К тому же с полной поддержкой async-await.
Таким образом доступ к записной книжке получить чрезвычайно просто:
#if __ANDROID__ public async Task GetAddressbook(Android.Content.Context context) { contacts = new AddressBook(context); #else public async Task GetAddressbook() { contacts = new AddressBook(); #endif if (!await contacts.RequestPermission()) { Trace.WriteLineIf(clientSwitch.TraceInfo, "Permission for contacts denied", "[ContactsPresenter.PopulateAddressbook]"); _view.SendSmallMessage("CONTACTS PERMISSON DENIED"); return; } else { _icv.PlainContacts = new ListItemCollection<ListItemValue>( (from c in contacts where (c.Phones.Count() > 0) select new ListItemValue(c)).ToList()); } }
Константа компиляции __ANDROID__
введена потому, что для получения списка контактов на Android контекст требуется, а на других ОС — нет.
Тут виден один из недостатков Passive View для кроссплатформенного решения. По заданию нам было необходимо группировать контакты по первой букве фамилии. Для Android это делается через создание класса ListItemCollection, который осуществляет группировку, классический пример этого доступен в интернете. На iOS абсолютно другой подход к созданию такой группировки, что на WinPhone — я не знаю. Так что тут уместно получать и группировать контакты непосредственно во View.
Это и есть основная проблема в гибридной кроссплатформенной разработке на мой взгляд. Надо четко понимать где тебе необходимо абстрагироваться от платформы, а где не стоит. Думаю, это приходит с опытом.
Доступ к базе данных
Доступ к базе данных Xamarin рекомендует через простую ORM SQLite.Net. Когда-то я пробовал игнорировать эти рекомендации и работать с базой напрямую, через драйвер, но в итоге понял что лучше слушать советы более опытных разработчиков.
Описывать как работать с SQLite.Net я не вижу особого смысла, скажу только что для тестирования сборки с подключенным SQlite.Net необходимо иметь в проекте бинарники sqlite, которые доступны на официальном сайте www.sqlite.org/download.html
Отдельно отмечу что SQLite.Net полностью поддерживает TAP и async-await.
Класс SQLite.SQLiteAsyncConnection я рекомендую расширить набором Generic классов для упрощения доступа к БД:
#region Public Methods public Task<List<T>> GetItemsAsync<T>() where T : IBusinessEntity, new() { return Table<T>().ToListAsync(); } public Task<T> GetItemAsync<T>(int id) where T : IBusinessEntity, new() { return GetAsync<T>(id); } public async Task<bool> CheckRowExistAsync<T>(int id) where T : IBusinessEntity, new() { string tblName = typeof(T).Name; return await ExecuteScalarAsync<int>("select 1 from " + tblName + " where Id = ?", id).ConfigureAwait(false) == 1; } public async Task<int> SaveItemAsync<T>(T item) where T : IBusinessEntity, new() { if (await CheckRowExistAsync<T>(item.Id)) { return await base.UpdateAsync(item).ConfigureAwait(false); } else { return await base.InsertAsync(item).ConfigureAwait(false); } } public Task<int> DeleteItemAsync<T>(int id) where T : IBusinessEntity, new() { return DeleteAsync(new T() { Id = id }); } #endregion
Так же стоит помнить, что правила доступа к файловой системе на каждой ОС различаются.
Поэтому путь к базе данных можно получить следующим образом:
public static string DatabaseFilePath { get { var sqliteFilename = "TalksDb.db3"; #if SILVERLIGHT // Windows Phone expects a local path, not absolute var path = sqliteFilename; #else #if __ANDROID__ // Just use whatever directory SpecialFolder.Personal returns string libraryPath = Environment.GetFolderPath(Environment.SpecialFolder.Personal); #else // we need to put in /Library/ on iOS5.1 to meet Apple's iCloud terms // (they don't want non-user-generated data in Documents) string documentsPath= Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); // Documents folder string libraryPath = Path.Combine(documentsPath, "..", "Library"); // Library folder #endif var path = Path.Combine(libraryPath, sqliteFilename); #endif return path; } }
Доступ к файловой системе
Одна из задач конкурса было получение и хранение картинок. Для решения этой задачи мною был взят и доработан кроссплатформенный класс дискового кэша отсюда. Вообще, работа с файловой системой одна из наменее портируемых частей, так как требования к работе с файлами на всех ОС разные. Частично особенности файловых систем описаны в официальных доках Xamarin
Talks.Droid
Андройд версия приложения. В идеальном варианте к моменту создания проекта на конкретную платформу у вас может быть полностью рабочий и протестированный бэкэнд. В моем варианте так не получилось, но в дальнейшем я буду к этому стремиться.
Основные сложности начинаются здесь.
В основе приложения лежит Bound Service к которому биндится класс App — синглтон реализующий «приложение». Сделано это для того чтобы любое Activity могло получить доступ к сервису с помощью App.Current.MainService
.
Внутри сервиса отдельным потоком создается Model, так же присутствует класс с помощью которого Activity забирают свои Presenter’ы, примерно так:
_presenter = App.Current.MainService.CreatePresenter<ChatListPresenter>(typeof(ChatListPresenter), this);
Cледует помнить что Xamarin формирует AndroidManifest самостоятельно и не дает напрямую редактировать его. Все параметры Activity записываются в виде аттрибутов:
[Activity(Label = "Settings", Theme = "@style/Theme.TalksTheme")] [MetaData("android.support.PARENT_ACTIVITY", Value = "talks.ChatListActivity")] public class SettingsActivity : SherlockActivity, IView
В основном код Activity мало чем отличается от java варинта, CamelCase, да некоторые геттеры/сеттеры обернуты в свойства
protected override void OnCreate(Bundle bundle) { base.OnCreate(bundle); // Set our view from the "main" layout resource SetContentView(Resource.Layout.MessagesScreen); AndroidUtils.SetRobotoFont(this, (ViewGroup)Window.DecorView); _presenter = App.Current.MainService.CreatePresenter<MessagePresenter>(typeof(MessagePresenter), this); _presenter.PlatformSpecificImageResize = AndroidResizeImage; this.ChatId = Intent.GetIntExtra("userid", 0); userName = Intent.GetStringExtra("username"); _button = FindViewById<ImageButton>(Resource.Id.bSendMessage); _button.Click += button_Click; _button.Enabled = false; _message = FindViewById<EditText>(Resource.Id.etMessageToSend); _message.TextChanged += message_TextChanged; _lv = FindViewById<ListView>(Resource.Id.lvMessages); _lv.Adapter = new Adapters.MessagesScreenAdapter(this, this.Messages); }
Login Activity
Login Activity должно содержать 3 пункта — ввод телефона, получение кода и его ввод, регистрация. Для удобства это сделано с помощью фрагментов.
Проще всего это сделать с помощью фрагментов. Однако, использование фрагментов и MVP абсолютно неочевидно!
В итоге я пришел к тому, что сделал один presenter, а LoginActivity просто оборачивал реализации IView фрагментов:
PhoneFragment _pf = null; CodeFragment _cf = null; SignUpFragment _suf = null; public string PhoneNumber { get { if (_pf != null) { return _pf.Phone; } else { return ""; } } } public string AuthCode { get { return _cf.Code; } } public string Name { get { return _suf.FirstName; } } public string Surname { get { return _suf.Surname; } }
Съемка фото/видео
Интересный и не очевидный момент. Одной из задач было получение фото/видео с камеры для отправки его собеседнику или установки в качестве аватарки.
Осуществляется это через меню с помощью Xamarin.Mobile.
public override bool OnMenuItemSelected(int featureId, Xamarin.ActionbarSherlockBinding.Views.IMenuItem item) { switch (item.ItemId) { // Respond to the action bar's Up/Home button case Android.Resource.Id.Home: NavUtils.NavigateUpFromSameTask(this); return true; case Resource.Id.messages_action_takephoto: _presenter.TakePhoto(this); return true; case Resource.Id.messages_action_gallery: _presenter.PickPhoto(this); return true; case Resource.Id.messages_action_video: _presenter.TakeVideo(this); return true; } return base.OnMenuItemSelected(featureId, item); }
Однако event отвечающий за выбор пункта меню возвращает bool, следовательно мы не можем применить к нему конструкцию async-await. Решается это очень просто, следует помнить что async-await это всего лишь синтаксический сахар который в итоге генерирует все те же Continuation. И ничего не запрещает нам написать это как раньше:
#if __ANDROID__ /// <summary> /// Взятие фото с камеры /// </summary> /// <param name="context"></param> /// <returns></returns> public bool TakePhoto(Android.Content.Context context) { var picker = new MediaPicker(context); #else public bool TakePhoto() { var picker = new MediaPicker(); #endif if (picker.IsCameraAvailable) { picker.TakePhotoAsync(new StoreCameraMediaOptions { Name = String.Format("{0:dd_MM_yyyy_HH_mm}.jpg", DateTime.Now), Directory = "TalksPictures" }) .ContinueWith((prevTask) => { if (prevTask.IsCanceled) { _imv.SendSmallMessage("User canceled"); return; } if (PlatformSpecificImageResize != null) { string path = PlatformSpecificImageResize(prevTask.Result); // Создать сообщение DomainModel.Message msg = new DomainModel.Message(r.Next(Int32.MaxValue), 0, _imv.ChatId, _imv.PendingMessage, "", 0); _imv.AddMineMessage(msg); } }) .ContinueWith((prevTask) => { if (!prevTask.IsCanceled) { Console.WriteLine("User ok"); } }, TaskScheduler.FromCurrentSynchronizationContext()); return true; } return false; }
Xamarin помимо кроссплатформенности предлагает Component Store, где находятся порты популярных Android и/или iOS библиотек и компонентов, как бесплатные так и платные. В частности там присутствует ActionBar.Scherlok и недавно появился Android.Support.v7, причем компоненты можно ставить прямо из среды, как в NuGet, что очень удобно
Таким образом в два клика можно получить поддержку ActionBar на устройствах с Android 2.3 и выше.
Публикация
Публикация приложения осуществляется по утвержденной Google схеме
Это включает довольно много действий. Но специально для нас команда из Xamarin сделала мастер встроенный в VS который позволяет подготовить приложение для публикации в несколько шагов.
и готово
Правда у меня не получилось сразу создать KeyStore с помощью этого мастера. Что то с временем жизни ключа было. Пришлось создавать ручками.
Тестирование
Небольшое замечание по тестированию. Тестировать на эмуляторе — это ужасно и невозможно. От этого следует отказаться как можно быстрее. Самый дешевый андройд стоит сейчас 3000 руб, китайский планшет можно найти по схожей цене. Я с началом конкурса сразу купил жене Fly с Android 4.0.1, т.к. у меня был только старый HTC с 2.3.
По поводу тестирования и разработки под iOS сложнее. Конечно лучший вариант — взять самый дешевый макбук, этого будет достаточно.
Но покупать пару iPhone и iPAD для тестирования… не знаю, не самый лучший вариант. Сейчас я рассматриваю возможность MacInCloud и если там все будет хорошо, я подробно опишу весь процесс.
Итог
Сейчас сложно подводить итог. В процессе разработки я хорошо изучил особенности Android платформы,
разработал хороший, покрытый тестами и, главное, кроссплатформенный бэкэнд.
Говорят, впереди будет конкурс под WinPhone и iPad. Что же, мне остается только нарисовать интерфейсы.
Работа над ошибками
«Note to self» как говорится. Просто замечания на будущее что я делал не так.
1. Отсутствие проектирования. Я дважды рефакторил MTProto.Core практически целиком. Причина этого в том что я не сел с бумажкой и не нарисовал полностью как должно это ядро выглядеть. Многие решения принимались спонтанно и без расчета на будущее.
2. Плохое понимание Android платформы. Я долго пытался понять каким образом организовывать взаимодействие с сервисом Android. Признаться, я и теперь не знаю лучшего способа обеспечить это взаимодействие. Надо понимать что гайды d.android.com тут бесполезны, сервис штука для андройда специфичная, а нам надо отвязаться от платформы и сделать нечто кроссплатформенное.
3. Упрямость и жадность. У меня была возможность привлечь еще одного программиста и возможно вдвоем мы бы показали более лучший результат. Но ведь я сам, все сам.
ссылка на оригинал статьи http://habrahabr.ru/post/194404/
Добавить комментарий