Создание простого приложения для Apple Watch. Личный опыт на примере Рамблер.Новостей

    24 апреля 2015 года Apple выпустила в продажу умные часы Apple Watch, спустя полгода после их первого анонса на презентации в Калифорнии. Рамблер не мог остаться в стороне. Ознакомившись с WatchKit SDK и гайдлайнами нам стало ясно, что на данный момент возможностей немного и в целом, разработка не должна занять много времени.

    image  image  image  image  



    Приложение Рамблер.Новости.

    Приложения для Apple Watch


    Приложение для часов (WatchKit app) работает в тандеме с приложением на телефоне (WatchKit extension). Вычисления, запросы к сети и вся прочая работа выполняются на телефоне и результаты передаются на часы. WatchKit app в свою очередь хранят ресурсы приложения — UI, изображения, звуки.

    До выхода документации казалось, что WatchKit app будут независимыми и смогут работать без помощи телефона. На данный момент такой возможности нет, не считая нескольких нативных приложений от Apple. Будем надеятся, последующие версии WatchKit SDK позволят создавать standalone приложения.

    image

    Если часы не спарены с телефоном, то большинство приложений сигнализируют об отсутствии телефона и пользователю предоставляется небольшое число возможностей:
    image

    1. Прослушивание музыки, с часов. Пока не ясно, какие приложения смогут проигрывать и хранить музыку.
    image

    2. Управление временем, будильниками, таймерами.
    image   image   image

    3. Доступ к Apple Pay и Passbook.
    image

    Apple не разрешает делать приложения, которые показывают время или же дублируют любой другой нативный функционал. Также, не рекомендуется упоминать на странице своего приложения умные часы Pebble.

    Рамблер.Новости


    Мы хотели выпустить приложение максимально быстро, чтобы попасть в первую волну разработчиков, адаптировавших свои приложения для работы с Apple Watch. Процесс разработки оказался несложным, но пришлось столкнуться с парочкой трудностей. Видимо, большинство разработчиков прошли по тому же пути. Проблема заключается в том, что документация + гайдлайны дают ответы лишь на простые вопросы. Конечно, помогает профильный форум разработчиков, где люди делятся своим опытом и проблемами. Если повезет, вам может ответить один из разработчиков WatchKit.

    Мы остановились на следующем списке, требуемого от приложения функционала:

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


    Задача несложная: новости есть, интерфейс для их отображения тоже. Проблема в том, как передать новости и изображение с телефона на часы. Напоминаю, операции происходят на телефоне. WatchKit SDK предоставляет метод класса WKInterfaceController + openParentApplication:reply:, который посылает из WatchKit extension в родительское приложение сформированый словарь с запросом свежих новостей. Так же, этот метод предоставляет колбэк, который вызовет родительское приложение, в котором можно передать нужные нам новости. Если родительское приложение неактивно, то запрос обрабатывается как background и время выполнения предполагается коротким. На данный момент есть проблема с обработкой запроса на стороне телефона — iOS считает, что запрос длится слишком долго и преждевременно завершает его. Временное решение предложили тут.

    Обмен данными между телефоном и часами, используя App Groups


    Если кратко, то предоставляется общее хранилище чтения/записи для родительского приложения и WatchKit extension. Рассмотрим возможности использования этого общего хранилища. Рассмотрим на примере простого приложения, добавляющего-удаляющего элементы из списка. Код доступен на гитхабе, смотрите по веткам — каждая соответствует новому способу использования App Groups.

    Используя NSUserDefaults

    NSUserDefaults хорошо подходят, когда надо передать небольшую порцию данных.

    Код в ViewController основного приложения.
    - (NSUserDefaults *)defaults {
        if (_defaults == nil) {
            // group.com.rambler.demo.shared - идентификатор группы, создается в Developer Center
            _defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.rambler.demo.shared"];
        }
        
        return _defaults;
    }
    
    ...
    
    - (IBAction)add:(id)sender {
        [self addListItem:[@"Item #" stringByAppendingString:@(self.list.count + 1).stringValue]];
    }
    
    - (IBAction)remove:(id)sender {
        [self removeLastListItem];
    }
    
    ...
    
    - (void)addListItem:(id)listItem {
        [self.list addObject:listItem];
        [self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:self.list.count - 1 inSection:0]]
                              withRowAnimation:UITableViewRowAnimationAutomatic];
        [self.defaults setObject:self.list forKey:@"list"];
        [self.defaults synchronize];
    }
    
    - (void)removeLastListItem {
        if (self.list.count == 0) {
            return;
        }
        [self.list removeLastObject];
        [self.tableView deleteRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:self.list.count inSection:0]]
                              withRowAnimation:UITableViewRowAnimationAutomatic];
        [self.defaults setObject:self.list forKey:@"list"];
        [self.defaults synchronize];
    }
    



    Код контроллера для WatchKit extension
    - (NSUserDefaults *)defaults {
        if (_defaults == nil) {
            _defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.rambler.demo.shared"];
        }
        
        return _defaults;
    }
    
    ...
    
    - (void)loadList {
        [self.defaults synchronize];
        self.list = [self.defaults objectForKey:@"list"];
        [self updateListView];
    }
    
    - (void)updateListView {
        if (self.table.numberOfRows) {
            [self.table removeRowsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.table.numberOfRows)]];
        }
        if (self.list.count > 0) {
            [self.table insertRowsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.list.count)]
                                withRowType:@"ItemListRowControllerId"];
            NSUInteger idx = 0;
            for (id item in self.list) {
                ItemListRowController *rowController = [self.table rowControllerAtIndex:idx++];
                [rowController.label setText:item];
            }
        }
    }
    
    - (void)willActivate {
        [super willActivate];
        [self loadList];
    }
    
    - (IBAction)refresh:(id)sender {
        [self loadList];
    }
    



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

    Весь код работы с NSUserDefaults доступен в этой ветке проекта.

    Используя NSFileCoordinator

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

    Код в ViewController основного приложения.
    - (NSFileCoordinator *)fileCoordinator {
        if (_fileCoordinator == nil) {
            _fileCoordinator = [[NSFileCoordinator alloc] init];
        }
        
        return _fileCoordinator;
    }
    
    - (void)viewDidLoad {
        [super viewDidLoad];
        [self.fileCoordinator
         coordinateReadingItemAtURL:[self presentedItemURL]
         options:NSFileCoordinatorReadingWithoutChanges
         error:nil
         byAccessor:^(NSURL *newURL) {
             NSData *data = [NSData dataWithContentsOfURL:newURL];
             id object = [NSKeyedUnarchiver unarchiveObjectWithData:data];
             self.list = object != nil ? [NSMutableArray arrayWithArray:object] : [@[] mutableCopy];
             [self.tableView reloadData];
        }];
    }
    
    ...
    
    - (IBAction)add:(id)sender {
        [self addListItem:[@"Item #" stringByAppendingString:@(self.list.count + 1).stringValue]];
    }
    
    - (IBAction)remove:(id)sender {
        [self removeLastListItem];
    }
    
    #pragma mark Table delegate
    
    - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
        return self.list.count;
    }
    
    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
        UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ListItemCellId"];
        cell.textLabel.text = self.list[indexPath.row];
        return cell;
    }
    
    #pragma mark List actions
    
    - (void)saveListWithCompletion:(void (^)(void))completion {
        [self.fileCoordinator
         coordinateWritingItemAtURL:[self presentedItemURL]
         options:NSFileCoordinatorWritingForReplacing
         error:nil
         byAccessor:^(NSURL *newURL) {
             NSData *data = [NSKeyedArchiver archivedDataWithRootObject:self.list];
             [data writeToURL:newURL atomically:YES];
             if (completion != nil) {
                 completion();
             }
        }];
    }
    
    - (void)addListItem:(id)listItem {
        [self.list addObject:listItem];
        [self saveListWithCompletion:^{
            [self.tableView insertRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:self.list.count - 1 inSection:0]]
                                  withRowAnimation:UITableViewRowAnimationAutomatic];
        }];
    }
    
    - (void)removeLastListItem {
        if (self.list.count == 0) {
            return;
        }
        [self.list removeLastObject];
        [self saveListWithCompletion:^{
            [self.tableView deleteRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:self.list.count inSection:0]]
                                  withRowAnimation:UITableViewRowAnimationAutomatic];
        }];
    }
    
    #pragma mark NSFilePresenter impl
    
    - (NSURL *)presentedItemURL {
        NSURL *containerURL =
            [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.rambler.demo.shared"];
        return [containerURL URLByAppendingPathComponent:@"list"];
    }
    
    - (NSOperationQueue *)presentedItemOperationQueue {
        return [NSOperationQueue mainQueue];
    }
    



    Код контроллера для WatchKit extension
    - (NSFileCoordinator *)fileCoordinator {
        if (_fileCoordinator == nil) {
            _fileCoordinator = [[NSFileCoordinator alloc] init];
        }
        
        return _fileCoordinator;
    }
    
    - (void)awakeWithContext:(id)context {
        [super awakeWithContext:context];
        [NSFileCoordinator addFilePresenter:self];
    }
    
    - (void)loadList {
        [self.fileCoordinator
         coordinateReadingItemAtURL:[self presentedItemURL]
         options:NSFileCoordinatorReadingWithoutChanges
         error:nil
         byAccessor:^(NSURL *newURL) {
             NSData *data = [NSData dataWithContentsOfURL:newURL];
             id object = [NSKeyedUnarchiver unarchiveObjectWithData:data];
             self.list = object != nil ? [NSMutableArray arrayWithArray:object] : [@[] mutableCopy];
             [self populateListView];
         }];
    }
    
    - (void)populateListView {
        if (self.table.numberOfRows) {
            [self.table removeRowsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.table.numberOfRows)]];
        }
        if (self.list.count > 0) {
            [self.table insertRowsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, self.list.count)]
                                withRowType:@"ItemListRowControllerId"];
            NSUInteger idx = 0;
            for (id item in self.list) {
                ItemListRowController *rowController = [self.table rowControllerAtIndex:idx++];
                [rowController.label setText:item];
            }
        }
    }
    
    - (void)updateListView:(NSArray *)newList {
        NSIndexSet *newItemsIndexSet = [newList indexesOfObjectsPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) {
            return ![self.list containsObject:obj];
        }];
        NSIndexSet *removedItemsIndexSet = [self.list indexesOfObjectsPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) {
            return ![newList containsObject:obj];
        }];
        
        [self.table removeRowsAtIndexes:removedItemsIndexSet];
        
        for (id newItem in [newList objectsAtIndexes:newItemsIndexSet]) {
            [self.table insertRowsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(self.table.numberOfRows, 1)]
                                withRowType:@"ItemListRowControllerId"];
            ItemListRowController *rowController = [self.table rowControllerAtIndex:self.table.numberOfRows - 1];
            [rowController.label setText:newItem];
        }
    }
    
    - (void)willActivate {
        [super willActivate];
        [self loadList];
    }
    
    - (void)didDeactivate {
        [super didDeactivate];
    }
    
    - (IBAction)refresh:(id)sender {
        [self loadList];
    }
    
    #pragma mark NSFilePresenter impl
    
    - (NSURL *)presentedItemURL {
        NSURL *containerURL =
        [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.rambler.demo.shared"];
        return [containerURL URLByAppendingPathComponent:@"list"];
    }
    
    - (NSOperationQueue *)presentedItemOperationQueue {
        return [NSOperationQueue mainQueue];
    }
    
    - (void)presentedItemDidChange {
        [self.fileCoordinator
         coordinateReadingItemAtURL:[self presentedItemURL]
         options:NSFileCoordinatorReadingWithoutChanges
         error:nil
         byAccessor:^(NSURL *newURL) {
             NSData *data = [NSData dataWithContentsOfURL:newURL];
             id object = [NSKeyedUnarchiver unarchiveObjectWithData:data];
             NSArray *newItems = [NSMutableArray arrayWithArray:object];
             [self updateListView:newItems];
             self.list = newItems;
         }];
    }
    



    Полный код доступен здесь

    Главное улучшение — использование метода — (void)presentedItemDidChange из протокола NSFilePresenter, который сообщает, если файл изменился. Значит, мы может отслеживать изменение данных в коде WatchKit extension и обновлять интерфейс в соответствии с этими изменениями.

    Умные люди из Mutual Mobile написали удобную обертку MMWormhole, которая сильно упрощает работу с NSFileCoordinator и использует Darwin Notifications для уведомления об изменении данных. Библиотека доступна на github.

    Следующая трудность: из часов нельзя “разбудить” приложение на телефоне. Мы хотели, чтобы по Force Touch появлялось меню с возможность открыть приложение на телефоне с выбранной новостью. К сожалению, сейчас SDK не позволяет этого сделать. Однако, приложения Camera и iMessage запускают соответствующие приложения на телефоне. Думаю, в новой версии SDK появится аналогичная функциональность. Apple взамен предлагает использовать Handoff в качестве средства общения. Слабая, признаться, замена, но она работает. К моменту релиза мы добавили возможность открывать новость на телефоне, при условии, что приложение активно.

    Не столько сложность, сколько неопределенность была в неизвестной скорости обмена данными между телефоном и часами — используются Bluetooth и Wi-Fi. В начале разработки, когда тестирование производилось на симуляторе, установить, насколько быстрой будет связь, было невозможно. Предполагая, что скорость будет небольшой, на часы передаются только нужные данные и сжатые изображения.

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

    В целом, серьезных проблем разработка не принесла. Основная сложность — временное отсутствие Apple Watch для тестирования, поэтому полагались на симулятор и документацию. На форуме разработчиков так же жаловались на частые отказы при попытке разместить приложения с поддержкой Apple Watch, причем причины были разными:

    • Приложение не следует гайдлайнам
    • Скриншоты приложения Apple Watch делаются в обрамлении изображений самих часов, например используя Bezel
    • Использование private API
    • Описание приложения в App Store не следует указаниям Apple


    Мы с такими проблемами не столкнулись и опубликовали приложение с первого раза. Для тех, кто собирается выкладывать приложения с Apple Watch, рекомендую прочесть эту статью

    Складывается впечатление, что Apple прощупывает пути развития платформы, определяя, что можно открыть разработчикам. И в скором времени (предположительно на WWDC'15) нам представят новый SDK.

    Ссылки по теме


    Форум разработчиков (требуется аккаунт)
    Краткие заметки по разработке
    Полезные ресурсы для дизайнеров и программистов
    Блог с серией статей по WatchKit SDK
    • +8
    • 12,5k
    • 4
    Rambler Group
    83,00
    Компания
    Поделиться публикацией

    Похожие публикации

    Комментарии 4

      +1
      Данные передаются, но нет возможности отслеживать, когда они изменяются, чтобы обновить интерфейс. Поэтому, приходится обновлять его в ручную.
      NSUserDefaultsDidChangeNotification, например.
      Ну или такой вариант .
        +2
        Опередили. Только вчера закончили наш первый проект под Apple Watch, хотел начать писать статью.

        У нас вышло вот так (приложение для довольно крупной страховой компании):


        Если будут какие-то вопросы про разработку под Apple Watch — пишите. Постараюсь ответить.
          0
          Круто. Интересно скоро часы подхватят тенденции телефонов и часы будут с экраном в пол руки?
            +1
            Мне кажется, что не будет такого. Скорее тоньше / меньше отступ от грани.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое