Главная > Coding, Новости, Творчество > +(AppStore *) Timera: создай свое окно во времени

+(AppStore *) Timera: создай свое окно во времени

Настала пора поведать общественности о моем скромном участии в интересном и перспективном проекте с весьма оригинальным названием Timera (от слов time и camera). По удачному стечению обстоятельств я пребывал в активной фазе поисков новых гаризонтов развития в тот самый момент, когда руководство стартапа искало кандидата на вакансию iOS-разработчика. Суть проекта довольно проста — ее визуальное представление можно видеть на главной иллюстрации к посту (изображение кликабельно). Пользователю представляется инструмент для создания временнОго туннеля путем совмещения фотографии из прошлого с фотографией, сделанной камерой смартфона. Идея мне очень понравилась и я незамедлительно начал интегрироваться в процесс разработки, в ходе которого пришлось столкнуться и преодолеть немало сложных и интересных задач.
Прежде всего хотелось бы пару слов сказать о глобальной архитектуре. Центральным звеном является сервер, выполняющий функции back-end, front-end, data storage, web-api. Подробно описывать серверную начинку не стану, поскольку никакого касательства к ней не имею. Cкажу лишь, что production-версия работает на облачном хостинге от Microsoft, а для реализации server-side использовался классичиский на сегодняшний момент стэк: IIS+MSSQL+ASP.NET
Front-end системы (веб-сайт http://www.timera.com) предоставляет пользователю возможность просматривать фотографии других пользователей, а так же оснащен функционалом социальной сети: присутствует возможность ставить лайки, оставлять комментарии, есть функция Поделиться в других социальных сетях. Так же у пользователя веб-сайта есть возможность загрузить старое фото. Что это значит, и для чего это нужно? Об этом далее.
Помимо веб-сайта неотъемлемую часть архитектуры составляет мобильное приложение. На сегодняшний день оно имплементировано для двух наиболее популярных платформ — это конечно же Android и iOS. Функионал приложения практически полностью воспроизводит функционал веб-сайта, за исключением единственной особенности, которой у веб-сайта быть не может. Речь идет о таймераграфии. Термин «таймераграфия» был специально изобретен, чтобы внести ясность в процесс разработки. Своими корнями он уходит в историю возникновения проекта. Его придумал основатель timera — Дэвид Вэбб (David Webb). С год назад он наткнулся на работы питерского фотографа Сергея Ларенкова, который создавал цикл фотографий посвященных блокаде Ленинграда и одним из первых использовал эффект таймераграфии. Сейчас самое время немного пояснить саму механику таймераграфии и то, как этот процесс реализован в мобильном приложении Timera.
Предположим, у нас есть старая фотография Эйфелевой башни. Мне лично нравится фотография 1889 года, на которой башня построена лишь до первого уровня. Чтобы создать timera-снимок, нужно найти ракурс, с которого была создана старая фотография, сделать снимок, «перегнать» на компьютер, и в каком-нибудь графическом редакторе произвести совмещение, используя различные фильтры и градиенты. Задачка, скажем, не каждому по силам. Но зато она по силам каждому пользователю приложения Timera. Весь процесс максимально упрощен и даже может показаться весьма забавным (по крайней мере лично я его таковым и считаю). Пользователь открывает экран с картой, на которой синими пинами отображаются старые фотографии доступные в том или ином месте. Естественно, приложение дает возможность автоматического местоположения пользователя и отображения объектов вокруг. После того, как пользователь определился с тем, что он будет таймерографировать, запускается экран с камерой. На этом экране присутствует одновременно живое изображение с камеры и поверх него полупрозрачное изображение старой фотографии (степень прозрачности можно менять). Пользователь может двигать старую фотографию по экрану, менять ее размер с помощью возможностей touch-screen технологии, таким образом производить первичное прицеливание. После того, как снимок сделан, открывается экран более точного совмещения.

Самое интересное начинается на третьем экране, где производится настройка time-tunnel (тайм-туннеля). Здесь пользователю доступен тулбар с различными инструментами, такими как:

— слайдеры прозрачности и резкости границы тайм-тоннеля (transparency and softness)
— поворот тайм-тоннеля вокруг оси
— функция, которая получила название swap (поменять местами старую и новую фотографии, то есть старая уходит на задний план, а новая на передний)
— изменение формы тайм-тоннеля: эллиптический или прямоугольный
— обрезка (crop)
Во всех режимах доступно изменение размера и положения тайм-туннеля с помощью экранных жестов.
Казалось бы негусто? Возможно, но даже с этим набором инструментов создаются очень неплохие композиции. И плюс ко всему, планируется расширять данный функционал, идей по этому поводу хватает.
После редактирования возникает получившееся изображение можно загрузить на сервер и/или расшарить в соцсети. При загрузке на сервер можно установиь флаг приватности, если нет желания делать свое произведение достоянием общественности.
Здесь хотелось бы рассказать немного нюансов о сложностях, возникших при создании основного use-case (таймераграфировнии).
Во-первых, неприятной неожиданностью стало наличие двух значений типа UIDeviceOrientation. В прошлом мне доводилось работать с iOS-акселерометром и концепцией ориентации устройства, и тогда количество возможных ориентаций устройства равнялось четырем: UIDeviceOrientationPortrait, UIDeviceOrientationPortraitUpsideDown, UIDeviceOrientationLandscapeLeft, UIDeviceOrientationLandscapeRight. На экране фотографирования ориентация устройства весьма критична, поскольку overlay-изображение необходимо ориентировать соответствующим образом, чтоб старая фотка в итоге не получилась вверх ногами, или лежащей на боку. Как выяснилось, сущеустует еще два положения устройства:UIDeviceOrientationFaceUp и UIDeviceOrientationFaceDown. Немало человекочасов ушло, чтобы осознать этот факт и распознать в нем причину неадекватного поведения программы.
Другим интересным моментом, связанным с экраном фотографирования, была проблема с кнопками громкости на боковой поверхности девайса: в стандартном приложении Камера нажатие на кнопки громкости приводит к срабатыванию затвора, что является весьма удобным.
Далее дело техники. По какой-то причине, на нашем экране фотографирования эти кнопки не вызывали никакой реакции, какие бы ухищрения нами не производились, не помогало даже принудительное «прослушивание» нотификаций от медиа-системы.
Делается это примерно так. ViewController подписывается на получение уведомлений об изменении громкости

[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(volumeChanged:)
name:@"AVSystemController_SystemVolumeDidChangeNotification"
object:nil];

Как выяснилось позже, связано это с тем, что у нас используется overlayView, на которой пользователь может менять прозрачность старой фотограции, и вообще видеть ее. Такое же точно поведение было обнаружено в других популярных фото-приложениях. В то же время приложения, которые используют стандартные контролы для фотографирования не страдают от отсутствия реакции на нажатие кнопок громкости.
Наибольшее время разработки ушло на реализацию экрана с эффетами тайм-туннеля. Алгоритм тайм-туннеля основан на методе вычитания градиентного трафарета из исходного изображения: чем темнее пиксель изображения на маске, тем меньше значение прозрачности одноименного пикселя маскируемого изображения. Вся магия реализована при помощи стандартного фреймворка CoreGraphics и функции CGImageCreateWithMask, которой передаются на вход два вышеупомянутых изображения в виде CGImageRef объектов.
Формирование таймерографии происходит в три этапа. Условимся использовать для изображения на заднем плане обозначение «Изображение А», а для изображения на переднем плане «Изобраежение Б».
1. Отрисовывается прямоугольная маска с градиентными краями и применятся к Изображению Б. Это было сделано, чтобы временной туннель нигде резко не обрывался. Следует упомянуть, что пользователь имеет возможность повернуть «Изображение Б», например, если горизонт завален. Таким образом, маска формируется исходя из слелующих параметров: размер «Изображения Б», угол поворота «Изображения Б», величина размытости границы (регулируется слайдером). Полученная маска вычитается из «Изображения Б», в итоге получается «Изображение Б» с размытыми краями.

Изрядно пришлось повозиться с разворотом градиента, который осуществлялся при помощи все того же CoreGraphics через преобразования матрицы координат графического контекста:

CGFloat angleInRadians = angle * (M_PI / 180);
CGContextRotateCTM(bmContext, angleInRadians);

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

Я даже задал вопрос на stackoverflow, после безуспешных поисков подсказки. Однако, не получив ответа, нашел решение эмпирическим путем, и оставил ответ на собственный вопрос.

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

2. Отрисовывается градиентная маска и применяется к «Изображению Б», полученному в результате обработки на первом этапе. В отличие от первого этапа пользователь может задавать вид маски: прямоугольный или эллептический плюс те же самые размеры маски, угол поворота, степень размытости границы. Размеры маски и положение пользователь меняет пальцами на экране. Таким образом формируется изображение тайм-туннеля.

3. На последнем этапе полученное изображение тайм-туннеля отрисовывается в нужных координатах на «Изображении А»

Должен заметить, что CoreGraphics далеко не самая производительная вещь на свете, и особенно это заметно на старых девайсах, начиная с iPhone 4 и ниже.
В связи с этим был принят ряд мер по оптимизации функционала таймерографии.
Здесь интересно будет упомянуть, что при реализации кэширования я обнаружил, что объекты типа CGContextRef можно освобождать только в рамках графического контектса, внутри которого они были созданы. Следить за освобождением объектов CoreGraphics очень важно, поскольку механизм Automatic Reference Count не делает этого за разработчика, что приводит к серьезным утечкам памяти и out-of-memory завершению. Утечки CoreGraphics объектов прекрасно видны в profile-инструменте Memory Leaks.
Дальнейший вектор оптимизации, который планируется применить в ближайшее время — переход на на Open GL. По идее, будет достаточно переделать отрисовку градиентных масок, потому что наложение маски работает довольно сносно в CoreGraphics в плане скорости.

Второй по интересности реализации задачей явилась гео-позиционно-картографическая функциональность. Изначально timera проектировалась, как средство создания временнЫх туннелей. Согласно многим научным теориям пространство и время имеют непосредственную связь, поэтому местоположение для таймеры является очень важным аспектом. Первая реализация таймеры подразумевала поиск старых фотографий исключительно на карте: пользователь открывал в приложении экран с картой, находил старые фотографии вокруг себя, выбирал понравившуюся, и запускал процесс таймераграфии. Ради этого был разработан веб-сервис, возвращающий все старые фотографии в регионе, который определялся видимой областью на экране карты (minlat,maxlat,minlng,maxlng). На этапе тестирования стало ясно, что данный процесс необходимо оптимизировать, поскольку количество пинов в определенной области может достичь такого количества, что будет непонятное месиво, и в итоге выбрать какой-то объект на карте станет очень сложно.
Устранять эту проблему было решено с помощью кластеризации. Кластеры — это пины на карте, связанные с группой нежели с каким-то определенным объектом. Визуально — это иконка с числом, отражающим количество сгруппированых объектов.
Здесь следует упомянуть, что в качестве картографического сервиса мы избрали Google Maps. Почему мы предпочли их нативным Apple картам? Скорее всего, это решение в  ближайшем будущем будет пересмотрено. Просто на момент проектирования еще были свежы воспоминания о фиаско Apple-карт, а так же мой личный опыт использования обоих фреймворков.
Возвращаемся к кластеризации. Изначально рассматривалась возможность реализации кластеров локально. Выяснилось, что Google Maps имеет возможность делать это буквально в одну строчку кода, однако это свойство было доступно только на Андроиде. Мы уже начали локальную реализацию кластеров на iOS, как в коллективное сознание взбрела здравая мысль: усовершенствовать веб-сервис, таким образом, чтобы он возвращал кластеры. Серверное решение имеет большое преимущество в плане оптимизации: во-первых, уменьшение количества передаваемых объектов (читай, уменьшение трафика), и во-вторых снижение стоимости сохранения данных в Core Data. Модель Core Data так же пришлось модифицировать — добавилась новая сущность MapCluster, который обладает такими атрибутами, как latitude, longitude, zoom, count, objectId
где
latitude, longitude — координаты кластера
zoom — уровень зума, которую выставляет пользователь
count — количество объектов, привязанных к кластеру.
objectId — кластер может быть привязан к конкретному объекту, таким образом, его нужно отображать в виде настоящего кликабельного пина.
Далее дело техники: если пользователь меняет местоположение карты или уровень зума, то сначала делается запрос в локальном хранилище для выбранной области, и кластеры из полученной коллекции наносятся на карту, а также следом отправляется запрос на сервер с теми же параметрами. Если со связью все хорошо, и сервер возвращает ответ, локальные кластеры из базы удаляются, и на их место заливаются новые — так происходит обновление.
Уверен, что много всего интересного о реализации веб-сервиса могли бы расказать наши сервер-сайд разработчики. Могу лишь сказать, что ради этого так же создавались новые сущности БД для агрегации объектов в кластеры, которые заполняются задачей, запускаемой по расписанию.
Еще пару интересных моментов хотелось бы рассказать об iOS реализации кластеров. Для отрисовки кластеров пришлось создавать небольшой класс, который возвращает изображение в виде кружка с числом, ведь методу setIcon класса GMSMarker из GoogleMaps.framework нужен именно UIImage, в виде которого он отобразит соответствуюший пин.
В итоге созданный класс представляет собой наследника UIView, который содержит вложенные элементы, формирующие изображение кластера, а UIImage из этого всего получается следующим методом:

-(UIImage *) renderedClusterImage {
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, [UIScreen mainScreen].scale);
    [self.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *capturedImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return capturedImage;
}

Также хотелось бы рассказать об одной особенности GoogleMaps фреймворка, с которой мне пришлось изрядно повозиться. Речь идет о способе создания кастомного представления InfoWindow (окошко с описанием, которое появляется, когда пользователь тыкает по пину).
Для отображения кастомного окна информации гугл-карты вызывают метод делегата

- (UIView *)mapView:(GMSMapView *)mapView markerInfoWindow:(GMSMarker *)marker;

как видно, этот метод должен вернуть объект UIView. На этой вьюхе можно располагать разные компоненты (UILabel, UIImageView etc), и все это в итоге будет отображено рядом с выбранным пином. Вроде все понятно и не вызывает подозрений. Однако в нашем случае возникла необходимость перерисовки окна в связи с тем, что картинка превью на момент открытия InfoWindow может не быть загружена с сервера. В таком случае запускается процесс загрузки изображения, по завершению которого нужно перерисовать InfoWindow. И тут возник нюанс. Я думал, достаточно будет сохранить указатель на UIView, который мы возвращаем в методе делегата, а потом через свойства поменять изображение вложенному UIImageView. Оказалось, GoogleMaps растеризует (переводит в UIImage) отданный ему UIView, возможно из соображений оптимизации, поэтому все попытки перерисовать его задуманным способом оказались тщетны.
Поэтому пришлось изобретать хак. Заключался он в следующем: при тапе на пин показывается пустое InfoWindow, если данных еще нет, запускается процесс загрузки, и далее происходит следующее:

- (UIView *)mapView:(GMSMapView *)mapView markerInfoWindow:(GMSMarker *)marker {
    TMMapImagePreview *view =  [[TMMapImagePreview alloc] initWithFrame:CGRectMake(0, 0, mapView.frame.size.width * 0.7, 64)];
    id image = marker.userData;
    NSData *imgData = (((MapCluster *)image).image).imageThumbnailData;
    if (imgData)
        view.imgView.image = [UIImage imageWithData:imgData];
    else {
        NSString * url = (((MapCluster *)image).image).imageThumbnailURL;        
        if (url) {
            [[ImageCache sharedInstance]
             downloadDataAtURL:[NSURL URLWithString:url]
             completionHandler:^(NSData *data) {
            	(((MapCluster *)image).image).imageThumbnailData = data;
                [marker setSnippet:@""];
                [mapView_ setSelectedMarker:marker];
             }];
        }
    }
    return view;
}

здесь TMMapImagePreview — это класс-наследник UIView, в нем формируется лэйаут InfoWindow. Вся магия принудительной перерисовки заключена в compeltion-блоке метода downloadDataAtURL синглтона ImageCache, который как не трудно догадаться, занимается скачиванием и кэшированием графического контента.

  1. Пока что нет комментариев.
Подписаться на комментарии по RSS