«Сверхзвуковая» загрузка фотографий в Облако с помощью собственного NSInputStream



    Максимально быстрая загрузка фотографий и видео с устройства на сервер была нашим основным приоритетом при разработке мобильного приложения Облако Mail.Ru для iOS. Кроме того, с самой первой версии приложения мы предоставили пользователям возможность включить автоматическую загрузку на сервер всего содержимого системной галереи. Это очень удобно для тех, кто волнуется о возможной потере телефона, однако, как вы понимаете, увеличивает объем передаваемых данных в разы.

    Итак, мы поставили перед собой задачу сделать загрузку фото и видео из мобильного приложения Облака Mail.Ru не просто хорошей, а близкой к идеальной. Результатом стала наша библиотека POSInputStreamLibrary, которая реализует потоковую загрузку в сеть фото и видео из системной галереи iOS. Благодаря ее тесной интеграции с фреймворками ALAssetLibrary и CFNetwork загрузка в приложении происходит очень быстро и не требует ни байта свободного места на устройстве. О реализации собственного наследника класса NSInputStream из iOS Developer Library я расскажу в этом посте.

    За время службы на благо Облака Mail.Ru поток POSBlobInputStream оброс весьма богатой функциональностью:

    • инициализация потока URL-ом ALAsset
    • поддержка синхронного и асинхронного режимов работы
    • автоматическая переинициализация после инвалидации объекта ALAsset
    • кеширующее чтение данных из ALAsset
    • возможность указать смещение, с которого будет начато чтение
    • возможность интеграции с произвольным источником данных

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

    Инициализация потока URL-ом ALAsset


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

    @interface NSInputStream (NSInputStreamExtensions)
    // ...
    + (id)inputStreamWithFileAtPath:(NSString *)path;
    // ...
    @end
    

    @interface NSMutableURLRequest (NSMutableHTTPURLRequest)
    // ...
    - (void)setHTTPBodyStream:(NSInputStream *)inputStream;
    // ...
    @end
    

    Кликабельно:



    Требование поддержать загрузку видеофайлов сделало этот подход непригодным. Огромный размер роликов порождал следующие проблемы:

    • для загрузки требовалось наличие большого количества свободного места на устройстве
    • время сохранения видео во временный файл могло достигать 10 и более минут

    Для преодоления этих неудобств был разработан класс POSBlobInputStream. Он инициализируется URL-ом объекта галереи и читает данные напрямую без создания временных файлов.

    @interface NSInputStream (POS)
    + (NSInputStream *)pos_inputStreamWithAssetURL:(NSURL *)assetURL;
    + (NSInputStream *)pos_inputStreamWithAssetURL:(NSURL *)assetURL asynchronous:(BOOL)asynchronous;
    + (NSInputStream *)pos_inputStreamForCFNetworkWithAssetURL:(NSURL *)assetURL;
    @end
    

    Кликабельно:



    Поначалу у меня было ощущение, что реализация POSBlobInputStream займет минимум времени, поскольку интерфейс его базового класса тривиален.

    @interface NSInputStream : NSStream
    - (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len;
    - (BOOL)getBuffer:(uint8_t **)buffer length:(NSUInteger *)len;
    - (BOOL)hasBytesAvailable;
    @end
    

    Более того, согласно документации, getBuffer:length: поддерживать необязательно, так что, казалось бы, нужно реализовать всего 2 метода. Их отображение на интерфейс ALAssetRepresentation вопросов также не вызывало.

    @interface ALAssetRepresentation : NSObject
    // ...
    - (long long)size;
    - (NSUInteger)getBytes:(uint8_t *)buffer fromOffset:(long long)offset length:(NSUInteger)length error:(NSError **)error;
    // ...
    @end
    

    Однако, спустив новоиспеченный POSBlobInputStream на воду, я был неприятно удивлен. Вызов любого метода базового класса NSStream завершался исключением вида:
    *** -propertyForKey: only defined for abstract class.  Define -[POSBlobInputStream propertyForKey:]
    

    Причина заключается в том, что NSInputStream — это абстрактный класс, а каждый из его init-методов создает объект одного из классов-наследников. В Objective-C этот паттерн называется class cluster. Таким образом, реализация собственного потока требует реализации в том числе и всех методов NSStream, а их там полна горница.

    @interface NSStream : NSObject
    - (void)open;
    - (void)close;
    - (id <NSStreamDelegate>)delegate;
    - (void)setDelegate:(id <NSStreamDelegate>)delegate;
    - (id)propertyForKey:(NSString *)key;
    - (BOOL)setProperty:(id)property forKey:(NSString *)key;
    - (void)scheduleInRunLoop:(NSRunLoop *)aRunLoop forMode:(NSString *)mode;
    - (void)removeFromRunLoop:(NSRunLoop *)aRunLoop forMode:(NSString *)mode;
    - (NSStreamStatus)streamStatus;
    - (NSError *)streamError;
    @end
    

    Синхронный и асинхронный режимы работы POSBlobInputStream


    При разработке POSBlobInputStream наиболее сложным было реализовать механизм асинхронного уведомления об изменении состояния. В NSStream за него отвечают методы scheduleInRunLoop:forMode:, removeFromRunLoop:forMode: и setDelegate:. Благодаря им можно создавать такие потоки, которые на момент открытия не располагают ни байтом информации. POSBlobInputStream эксплуатирует эту возможность для следующих целей:

    • Реализация неблокирующей версии метода open. POSBlobInputStream считается открытым, как только ему удалось получить объект ALAssetRepresentation по его NSURL. Как известно, с помощью iOS SDK это можно сделать только асинхронно. Таким образом, наличие механизма для асинхронного уведомления об изменении состояния потока с NSStreamStatusNotOpen на NSStreamStatusOpen или NSStreamStatusError здесь как нельзя кстати.
    • Информирование о наличии у потока данных для чтения посредством отправки события NSStreamEventHasBytesAvailable.

    В иллюстративных целях ниже приводятся реализации подсчета контрольной суммы файла с использованием POSBlobInputStream. Начнем с рассмотрения синхронного варианта.

    NSInputStream *stream = [NSInputStream pos_inputStreamWithAssetURL:assetURL asynchronous:NO];
    [stream open];
    if ([stream streamStatus] == NSStreamStatusError) {
        /* Информируем об ошибке */
        return;
    }
    NSParameterAssert([stream streamStatus] == NSStreamStatusOpen);
    while ([stream hasBytesAvailable]) {
        uint8_t buffer[kBufferSize];
        const NSInteger readCount = [stream read:buffer maxLength:kBufferSize];
        if (readCount < 0) {
            /* Информируем об ошибке */
            return;
        } else if (readCount > 0) {
            /* Логика подсчета контрольной суммы */
        }
    }
    if ([stream streamStatus] != NSStreamStatusAtEnd) {
        /* Информируем об ошибке */
        return;
    }
    [stream close];
    

    При всей простоте у этого кода есть одна невидимая особенность. Если исполнять его в главном треде, то произойдет deadlock. Дело в том, что метод open блокирует вызывающий тред до тех пор, пока iOS SDK не вернет в главном потоке ALAsset. Если же функция open сама по себе будет вызвана в главном потоке, то получится классическая взаимоблокировка. Зачем вообще понадобилась синхронная реализация потока, будет описано ниже в разделе “Особенности интеграции с NSURLRequest”.
    Асинхронная версия подсчета контрольной суммы выглядит немного сложнее.

    @interface ChecksumCalculator () <NSStreamDelegate>
    @end
    
    @implementation ChecksumCalculator
    
    - (void)calculateChecksumForStream:(NSInputStream *)aStream {
        aStream.delegate = self;
        [aStream open];
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
                [aStream scheduleInRunLoop:runLoop forMode:NSDefaultRunLoopMode];
                for (;;) { @autoreleasepool {
                    if (![runLoop runMode:NSDefaultRunLoopMode
                                     beforeDate:[NSDate dateWithTimeIntervalSinceNow:kRunLoopInterval]]) {
                        break;
                    }
                    const NSStreamStatus streamStatus = [aStream streamStatus];
                    if (streamStatus == NSStreamStatusError || streamStatus == NSStreamStatusClosed) {
                        break;
                    }
                }}
        });
    }
    
    #pragma mark - NSStreamDelegate
    
    - (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode {
        switch (eventCode) {
            case NSStreamEventHasBytesAvailable: {
                [self updateChecksumForStream:aStream];
            } break;
            case NSStreamEventEndEncountered: {
                [self notifyChecksumCalculationCompleted];
                [_stream close];
            } break;
            case NSStreamEventErrorOccurred: {
                [self notifyErrorOccurred:[_stream streamError]];
                [_stream close];
            } break;
        }
    }
    
    @end
    

    ChecksumCalculator устанавливает себя в качестве обработчика событий POSBlobInputStream. Как только у потока появляются новые данные, либо, наоборот, заканчиваются, либо происходит ошибка, он шлет соответствующие события. Обратите внимание, что существует возможность указать, в какой тред их слать. Например, в приведенном листинге кода они будут приходить в некий рабочий поток, созданный GCD.

    Особенности интеграции с ALAssetLibrary


    При работе с ALAssetLibrary следует учитывать следующее:

    • Вызовы методов ALAssetRepresentation обходятся очень дорого. POSBlobInputStream старается минимизировать их количество за счет кеширования полученных результатов. Например, существует минимальный блок данных, который будет вычитан при вызове метода read:maxLength:, и только по его исчерпании произойдет новое обращение.
    • ALAssetRepresentation может становиться недействительным. Так, на iOS 5.x это происходит при сохранении фотографии в галерею телефона. С точки зрения клиентского кода это выглядит как возврат нулевого значения методом getBytes:fromOffset:length:error: объекта ALAssetRepresentation. При этом заведомо известно, что данные до конца не прочитаны. В этом случае POSBlobInputStream получает ALAssetRepresentation заново. Нелишним будет отметить, что при работе в синхронном режиме на время переинициализации вызывающий поток блокируется, а в асинхронном — нет.


    Особенности интеграции с NSURLRequest


    В основе реализации сетевого уровня iOS SDK в целом и NSURLRequest в частности лежит фреймворк CFNetwork. За долгие годы жизни он накопил немало шкафов со скелетами. Но обо всем по порядку.

    NSInputStream является одним из "toll-free bridged" классов iOS SDK. Его можно привести к CFReadStreamRef и работать с ним в дальнейшем как с объектом данного типа. Это свойство лежит в основе реализации NSURLRequest. Последний выдает POSBlobInputStream за своего брата-близнеца, и CFNetwork общается с ним уже с помощью С-интерфейса. В теории все C-вызовы к CFReadStream должны проксироваться на вызовы соответствующих им методов NSInputStream. Однако на практике есть два серьезных отклонения:

    1. Не все вызовы проксируются. Для некоторых эту процедуру приходится делать самостоятельно. Останавливаться на этом здесь не буду, поскольку в интернете есть хорошие статьи на эту тему: How to implement a CoreFoundation toll-free bridget NSInputStream, Subclassing NSInputStream.
    2. Проксирование CFReadStreamGetError приводит к падению приложения. Это эксклюзивное знание было получено путем анализа crash-логов приложения и медитаций над исходниками CFStream. Видимо, по этой причине указанная функция помечена в документации устаревшей, но, тем не менее, ее использование еще не искоренено изо всех мест CFNetwork. Так, каждый раз, когда NSInputStream информирует CFNetwork об ошибке, фреймворк пытается получить ее описание, используя эту злосчастную функцию. Итог печален.

    Для борьбы со второй проблемой вариантов не так много. Поскольку отрефакторить CFNetwork невозможно, остается только не провоцировать его на враждебные действия. Чтобы CFNetwork не пытался получить описание ошибки, нужно ни при каких условиях не сообщать ему о ее появлении. По этой причине POSBlobInputStream обзавелся свойством shouldNotifyCoreFoundationAboutStatusChange. Если флаг выставлен, то:

    1. поток не будет слать уведомления об изменении своего статуса посредством callback-ов C
    2. метод streamStatus никогда не вернет значение NSStreamStatusError

    Единственный способ узнать о возникновении ошибки при поднятом флаге — реализовать неким классом протокол NSStreamDelegate и установить его в качестве делегата потоку (см. пример подсчета контрольной суммы выше).

    Еще одним неприятным открытием стало то, что CFNetwork работает с потоком в синхронном режиме. Несмотря на то, что фреймворк подписывается на уведомления, он все равно зачем-то занимается его poll-ингом. Например, метод open вызывается в цикле несколько раз, и если поток за этот интервал времени не успевает перейти в открытое состояние, он признается испорченным. Эта особенность сетевого фреймворка и была причиной поддержки в POSBlobInputStream синхронного режим работы, пусть и с ограничениями.

    Поддержка чтения данных со смещением


    iOS-приложение Облака Mail.Ru умеет дозагружать файлы. Данная функциональность позволяет экономить трафик и время пользователя в случае, когда часть загружаемого файла уже находится в хранилище. Для реализации этого требования POSBlobInputStream был обучен считыванию содержимого фотографии не с начала, а с некоторой позиции. Смещение в нем задается свойством NSStreamFileCurrentOffsetKey. Благодаря тому, что оно же используется для сдвига начала стандартного файлового потока, появляется возможность указывать его единообразно.

    Поддержка произвольных источников данных


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

    @protocol POSBlobInputStreamDataSource <NSObject>
    //
    // Self-explanatory KVO-compliant properties.
    @property (nonatomic, readonly, getter = isOpenCompleted) BOOL openCompleted;
    @property (nonatomic, readonly) BOOL hasBytesAvailable;
    @property (nonatomic, readonly, getter = isAtEnd) BOOL atEnd;
    @property (nonatomic, readonly) NSError *error;
    //
    // This selector will be called before anything else.
    - (void)open;
    //
    // Data Source configuring.
    - (id)propertyForKey:(NSString *)key;
    - (BOOL)setProperty:(id)property forKey:(NSString *)key;
    //
    // Data Source data.
    // The contracts of these selectors are the same as for NSInputStream.
    - (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)maxLength;
    - (BOOL)getBuffer:(uint8_t **)buffer length:(NSUInteger *)bufferLength;
    @end
    

    Свойства используются не только для получения состояния источника данных, но и для информирования потока о его изменении с помощью механизма KVO.

    Итог


    За время работы над потоком я провел немало времени в сети в поисках каких-либо аналогов. Во-первых, не хотелось изобретать велосипед, а во-вторых, дело идет гораздо быстрее, если держать перед глазами некий образец. К сожалению, хороших реализаций мне найти не удалось. Бичом большинства аналогов является реализация асинхронной работы. В лучшем случае как в HSCountingInputStream для диспетчеризации событий используется внутренний объект одного из стандартных потоков, что некорректно. Зачастую асинхронный режим работы не поддерживается вовсе, как, например, в NTVStreamMux:

    #pragma mark Undocumented but necessary NSStream Overrides (fuck you Apple)
    
    - (void) _scheduleInCFRunLoop:(NSRunLoop*) inRunLoop forMode:(id)inMode {
        /* FUCK YOU APPLE */
    }
    
    - (void) _setCFClientFlags:(CFOptionFlags)inFlags
                      callback:(CFReadStreamClientCallBack)inCallback
                       context:(CFStreamClientContext)inContext {
        /* NO SERIOUSLY, FUCK YOU */
    }
    

    POSBlobInputStream, в свою очередь, является одним из ключевых компонентов приложения Облака Mail.Ru. За время службы он был проверен в бою армией пользователей. Было собрано и нивелировано множество граблей, и в данный момент поток является одним из наиболее стабильных компонентов. Пользуйтесь, пишите расширения, и, конечно, буду рад любой обратной связи.

    Павел Осипов,
    Руководитель команды разработки Облака для iOS

    Mail.ru Group
    1,217.91
    Building the Internet
    Share post

    Similar posts

    Comments 16

      +1
      Спасибо, что поделились. Кроме самой библиотеки интересны техники, которые в ней использованы. Я как-то пробовал наследоваться от class cluster-a. Но бросил эту затею, т.к. постоянно чего-то системе не хватало. А тут еще и на toll-free bridge посмотреть можно.
        +5
        Да, затея была не для слабонервных. Были моменты, когда я тоже подумывал бросить это дело и пойти другим проверенным и одновременно более легким путем. Например, можно было бы вручную формировать HTTP пакеты типа multipart/form-data. Но, очевидно, такой подход менее гибок по сравнению с реализацией собственного потока. Во-первых, он узкоспециализирован, а во-вторых, слабо интегрирован с сетевым слоем iOS SDK. Так, сейчас представленный в статье поток мы намерены использовать и для другой цели — формирования миниатюр фотографий из галереи.
          0
          Ох, как мне сейчас неловко стало. Ведь при передаче файлов в одном известном мессенджере именно что multipart/form-data. А про class cluster, помню, усиленно спорили с одним хорошим плюсовиком. Не лучше ли обычное наследование? В итоге ничья.
            +1
            О каком обычном наследовании речь? Нельзя же просто так взять и обычно унаследоваться от NSInputStream.
              0
              Нельзя. И спор был о том, хорошо ли, что нельзя.
          +4
          А webdav работает? Или есть ли планы, если сейчас не реализован?
            +2
            Он раньше работал, потом его закрыли. Нет оснований полагать, что он вдруг снова начнет работать. Это я вам со всей серьезностью диванного аналитика говорю.
            +1
            вы извините конечно, но лучше бы немного больший ресурс на работу десктопных приложений кинули. Как дурак до нового года по акйии зарегистрировался, уже третий месяц версия одна и та же, а проблемы как были так и остались, не решаются, в итоге решил плюнуть и взять платный аккаунт на гугле на 1 ТБ — не нарадуюсь.

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

            как-то на этом фоне очередной восьмисотый клиент для выгрузки фоточек с афйончика смотрится смешно и обидно.
              0
              Добрый день! Отправил свои контакты в личку. Ответьте и я помогу решить проблему.
                0
                Интересно, как? У вас есть какие-то отдельные рабочие версии софта которые вы даете тем, кто потратил все нервы на борьбу с публичными?
                  0
                  Отправил письмо, однако справедливости ради продублирую основной перечень проблем тут дабы сообщество подтвердило что не выдумываю.

                  Версия агента 13.12.2000 — не менялась с прошлого года, даже когда скачивал клиент еще раз, по всей видимости клиент не обновлялся с декабря 2013.

                  на данный момент использовано 67,63 Гб, (около 7% от 1 Тб) храню архивы проектов и они синхронизируются между тремя компами (два iMac и один Macbook Air), везде версия клиента и системы одинаковая — Mac OS X 10.9.2 и облако 13.12.2000.

                  чудит так:
                  1. на одном компе удаляю папку, он заново ее качает, потом еще раз удаляю и опять ее качает, приходится заходить в веб-интерфейс и удалять оттуда, после чего клиент долго тупит и начинает нормально синхронизировать только после перезагрузки компа (если выключаешь клиент и включаешь обратно — его не отпускает).
                  2. кроме того, вообще не попадают в синхронизацию файлы в названии которых встречаются парные кавычки (на Маке это возможно, без проблем)
                  3. периодически перестает синхронизироваться и начинает запрашивать пароль, иной раз неделю может работать нормально, а иной раз по 3-4 раза в день начинает выбрасывать и спрашивать пароль
                  4. с каждого компа попадают в синхронизацию файлы .DS_Store и считаются конфликтующими копиями. Раньше вообще они множились бесконтрольно, сейчас стали чуть пореже, когда не сношу лишние, может пару дней не появляться дубликат этого файла. Писал об этом через веб-интерфейс через форму обратной связи в поддержку, а также писал об этом в блоке Облака в комментарии, но почему-то там мой комментарий так и не опубликовали — плюнул.
                  5. в случае ошибки синхронизации или ошибки выгрузки на сайт клиент не пишет в чем именно проблема и не дает какую-либо информацию об этом и возможность что-либо сделать. Приходится методом тыка смотреть что не утянулось, перемещать файл в другую папку, отключать синхронизацию и включать ее обратно, после чего закидывать нужный файл.
                  6. в папках и файлах нет иконки которая показывает статус синхронизации, приходится угадывать по выпадающему меню из иконки в трее что будет синхронизироваться и в каком порядке.

                  и это только то что успел вспомнить, раздражающих моментов было куда больше.

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

                    0
                    Подтверждаю, письмо получил.
                    Отвечу по пунктам:
                    1. Проблема странная, будем выяснять. Свяжусь с Вами по почте до 26.03 для уточнения ситуации, либо раньше по результату расследования.
                    2. Да, на Маке возможно создание таких файлов, но оно запрещено на Windows и поэтому мы добавили запрет на синхронизацию файлов с таким именем. Возможно, это не самое лучшее решение. Мы подумаем, как улучшить usability в данном вопросе.
                    3. Исправим в след. версии (по срокам чуть ниже)
                    4. Исправим в след. версии
                    5. Исправим в след. версии
                    6. Тут вопрос сложный. Есть два варианта решения: не делать значки и тогда приложение можно разместить в AppStore, что в свою очередь решит проблему с правами на установку приложения. Это путь Onedrive(бывший Skydrive) и Yandex.Disk. Второй вариант — это сделать значки и закрыть себе пусть в AppStore. Это путь Google.Drive и Dropbox. В данный момент мы склоняемся к первому варианту, так как большое число пользователей сталкивается с проблемами при установке из-за настроек безопасности ОС.

                    Что касается нового релиза под Mac. У нас есть RC версия и первую стадию тестирования мы закончим на следующей неделе, после чего можем выслать ее персонально Вам. В публичный доступ версия попадет в течении двух недель.
                      0
                      Все еще ждем обновления клиента, уже чисто ради интереса посмотреть что исправлено.

                      Пока версия все еще висит 13.12.2000

                      Нигде нет ни планов разработки ни баг-трекера публичного, никакой информации о планах релизов. На самом деле жаль, было бы неплохо хотя бы подумать в этом направлении.
                +5
                Продолжаю ждать WebDAV
                  –4
                  Я тоже продолжаю ждать WebDAV.
                    +1
                    Как раз страдал из-за проблем, связанных с необходимостью загружать тяжелое видео с устройства на сервер в своем проекте — а тут такой подарок! Спасибо огромное!

                    Only users with full accounts can post comments. Log in, please.