Видео монитор для ребенка на iOS устройствах (без WebRTC)
В данном посте пойдет речь о том, как написать приложение — baby monitor, когда одно устройство (планшет) вы устанавливаете возле кроватки ребенка, а второе (телефон), берете с собой, скажем на кухню, и время от времени поглядываете за ребенком через экран.Как новоиспеченный родитель, хочу сказать, что такое приложение экономит кучу нервов — не нужно прислушиваться к каждому шороху или детскому крику с улицы, можно одним взглядом убедиться, что c чадом всё в порядке. Немного о технической части: в приложении используется наша библиотечка iOS видеочата, включая серверную часть (сигналинг и TURN сервер для NAT traversal), это всё в открытом доступе. Видеопоток будет работать как через Wi-Fi, так и через 2G/3G/4G. В аппсторе до недавнего времени не было приложения детского видеомонитора, который бы работал через мобильный интернет (видимо из-за трудностей с NAT traversal), но пока мы прокрастинировали готовили пост, одно из приложений лидеров выпустили платную версию с поддержкой этого функционала. В любом случае, статья будет полезна вам, если вы хотите запилить видеомониторинг или двухсторонний видеозвонок в своём iOS приложении. Специально указываем, что это версия без WebRTC, потому что о веб-совместимой версии (как и об Android) собираемся написать отдельно, там есть свои нюансы.
ТЗ: В нашем случае приложение представляет собой мониторинг маленьких детей (грудного возраста) посредством мобильного устройства под управлением iOS. При старте приложение должно было найти соседнее устройство, синхронизироваться с ним и далее выполнить видео-звонок. В ходе соединения, родитель видит ребенка, а также может управлять устройством на той стороне — включить свет (вспышку), проиграть колыбельную, поговорить туда в микрофон.
Собственно, проект не тяжелый, основные сложности лежали в реализации 2х пунктов:
поиск и синхронизация устройств видеосвязь Рассмотрим эти пункты чуть подробнее: Поиск и синхронизация устройств
Синхронизация происходит по сети Wi-Fi или Bluetooth. Погуглив, обнаружили 4 способа как это можно сделать. Приведем их краткое описание, преимущества и недостатки:
Bonjour service — синхронизация по Wi-Fi. В интернетах найти такой семпл не составляет труда. Работает на iOS 6–7 Core Bluetooth — работает, как бы это неожиданно ни звучало, по каналу Bluetooth с iOS 5 и выше. Но вот в чем нюанс — поддерживается только Bluetooth 4 LE. GameKit. Крутая штука. В принципе, все просто как двери. Работает нормально, на обычном bluetooth (для устройств iPhone 4 и даже ниже). Также работает Bonjour — и для WiFi сетей. Но есть небольшой недостаток — deprecated начиная с iOS 7. Multipeer Connectivity — новый фреймворк, добавленный в iOS 7. По сути, для нас это выглядело как аналог GameKit, только для iOS 7. Его мы в будущем и использовали. Мы смогли инкапсулировать эти сервисы под один интерфейс и неважно, каким бы из этих четырех сервисов мы выбрали пользоваться в итоге. Это оказалось очень удобным.Общий интерфейс такого сервиса выглядит так (префикс «BB» это от нашего названия приложения, вы, естественно, можете назвать как-то по-другому):
#import «BBDeviceModel.h»
typedef enum CommonServiсeType { CommonServiceTypeBonjour = 0, CommonServiceTypeBluetoothLE, CommonServiceTypeGameKitWiFi, CommonServiceTypeGameKitBluetooth, CommonServiceTypeMultipeer, }CommonServiсeType;
@protocol BBCommonServiceDelegate;
@interface BBCommonService: NSObject
@property (nonatomic, weak) id
-(void) setConnectionType:(CommonServiсeType)type;
-(void) startServerSide; -(void) stopServerSide;
-(void) startSearchDevices; -(void) stopSearchDevices; -(void) selectDevice:(BBDeviceModel *)deviceModel;
-(void) clean;
@end
@protocol BBCommonServiceDelegate
@optional
-(void) service:(BBCommonService *)serviсe didFindDevice:(BBDeviceModel *)device; -(void) service:(BBCommonService *)serviсe didRemoveDevice:(BBDeviceModel *)device;
-(void) service:(BBCommonService *)serviсe serverDidFinishedSync:(BOOL)isFinished; -(void) service:(BBCommonService *)serviсe clientDidFinishedSync:(BOOL)isFinished;
@end
@interface BBDeviceModel: NSObject
@property (nonatomic, strong) id device;
-(NSString *)deviceName; -(void)setDeviceName:(NSString *)name;
-(BOOL) isDeviceEqualTo:(id)otherDevice;
@end Далее наследуемся от BBCommonService в зависимости от вида подключения и переопределяем методы start- и stop-, clean, а также в нужных местах вызываем потом методы делегата.Видеосвязь
Для видеосвязи мы использовали QuickBlox. Для начала нужно зарегистрироваться — в результате чего вы получите доступ к админ панели. В ней вы создаете свое приложение. Далее скачиваете сам фреймворк с официального сайта. Подключение более детально описано здесь — http://quickblox.com/developers/IOS-how-to-connect-Quickblox-framework. Если вкратце, то:
1) скачиваем Quickblox.framework, добавляем в проект, подключаем штук 15 библиотек — их список есть в туториале2) После этого, нужно вернуться в админ панель, выбрать свое приложение и скопировать три параметра — Application id, Authorization key и Authorization secret в настройки проекта:
[QBSettings setApplicationID: APP_ID]; [QBSettings setAuthorizationKey: AUTH_KEY]; [QBSettings setAuthorizationSecret: AUTH_SECRET]; Все, теперь можно работать.1. Сессия
Для того, чтобы производить клиент-серверные взаимодействия с QuickBlox, нужно создать сессию. Делается это очень просто:
[QBAuth createSessionWithDelegate: self]; Таким образом посылается запрос на создание сессии и ответ приходит в метод делегата: — (void)completedWithResult:(Result *)result { QBAAuthResult *authResult = (QBAAuthResult *)result; if ([authResult isKindOfClass:[QBAAuthResult class]]) { // do something } } 2. Создание юзера или логин.
Для дальнейшей работы нам нужен пользователь. Без него никуда. Делается это тоже довольно просто:
// registration
QBUUser *user = [QBUUser new]; user.password = aPass; user.login = aLogin; [QBUsers signUp: user delegate: self]; или // login
[QBUsers logInWithUserLogin: aLogin password: aPass delegate: self]; Для этих и всех запросов ответ от сервера приходит в метод делегата completedWithResult: Соответственно, логин/пароль при желании можно брать с UITextField«ов. В нашем случае, чтобы не заставлять пользователя еще что-то дополнительно вводить, мы делали скрытую авторизацию, поэтому создавали логин и пароль на базе vendorID.
3. Хранение информации о паре
После выполнения синхронизации, мы решили создать сущность Pair, в которой хранить свой id и оппонента (второе устройство, синхронизированное с данным). Также, ее не мешало отправлять где-нибудь на сервер, чтобы в будущем не делать синхронизацию. В этом нам помог модуль Custom Objects, который по сути является БД с настраиваемыми полями. Итак, выглядело это приблизительно следующим образом:
QBCOCustomObject *customObject = [QBCOCustomObject customObject]; customObject.className = @«Pair»; // Object fields [customObject.fields setObject:@(userID) forKey:@«opponentID»]; customObject.userID = self.currentUser.ID; // permissions QBCOPermissions *permissions = [QBCOPermissions permissions]; permissions.readAccess = QBCOPermissionsAccessOpen; permissions.updateAccess = QBCOPermissionsAccessOpen; customObject.permissions = permissions; [QBCustomObjects createObject: customObject delegate: self];
— (void)completedWithResult:(Result *)result { QBCOCustomObjectResult *coResult = (QBCOCustomObjectResult *)result; if ([authResult isKindOfClass:[QBCOCustomObjectResult class]]) { // do something QBCOCustomObjectResult *customObjectResult = (QBCOCustomObjectResult *)result; BBPair *pair = [BBPair createEntityFromData: customObjectResult.object]; self.currentPair = pair; // … } } Единственное — тут нужно пойти в админ панель и во вкладке Custom Objects создать соответствующую модель с полями. Там все очень просто и интуитивно понятно, так что пример приводить не буду (на что нужно обратить внимание — поддерживаемые типы данных для полей — integer, float, boolean, string, file).Если нужно достать с БД какие-нибудь сущности, делается это следующим образом —
NSMutableDictionary *getRequest = [NSMutableDictionary dictionary]; [getRequest setObject:@(self.currentUser.ID) forKey:@«user_id[or]»]; [getRequest setObject:@(self.currentUser.ID) forKey:@«opponentID[or]»]; [QBCustomObjects objectsWithClassName:@«Pair» extendedRequest: getRequest delegate: self]; Данный запрос ищет все сущности, где данный пользователь является или текущим юзером или оппонентом.Удалить кастомный объект еще проще — нужно только знать его ID.
NSString *className = @«Pair»; [QBCustomObjects deleteObjectWithID: self.currentPair.pairID className: className delegate: self]; Для нас это нужно когда юзер захочет рассинхронизировать свой ipad/ipod/iphone затем, например, чтобы связать его потом с другим устройством. В приложении мы предусмотрели для этого кнопочку «Unpair» в интерфейсе Settings.4. Видеосвязь
Здесь уже чуть посложнее. Во-первых, мы должны кроме создания сессии и логина еще дополнительно залогиниться в чате, т.к. чат-сервер используется для видео сигналлинга. Это делается следующим образом —
[QBChat instance].delegate = self; [[QBChat instance] loginWithUser: self.currentUser]; // self.currentUser — QBUUser таким образом мы берем текущего пользователя и логиним его в чат, предварительно установив делегат. Если все хорошо, то практически сразу сработает один из методов: -(void)chatDidLogin { self.presenceTimer = [NSTimer scheduledTimerWithTimeInterval:30 target:[QBChat instance] selector:@selector (sendPresence) userInfo: nil repeats: YES]; }
-(void)chatDidNotLogin { }
-(void)chatDidFailWithError:(NSInteger)code {
} в зависимости от исхода. Также, мы сразу вешаем на таймер отправку presence в случае успешного логина. Без них мы автоматически уйдем в оффлайн где-то через минуту.Если все прошло успешно, то можно приступать к самой тяжелой части. Для работы с видеосвязью нам предлагают класс QBVideoChat.
«Звонящая» сторона вначале создает экземпляр при помощи
self.videoChat = [[QBChat instance] createAndRegisterVideoChatInstance]; Делее настраиваем view для себя и оппонента если нужно, состояние звука (вкл/выкл) и дополнительные настройки — например useBackCamera: self.videoChat.viewToRenderOwnVideoStream = myView; self.videoChat.viewToRenderOwnVideoStream = opponentView; self.videoChat.useHeadphone = NO; self.videoChat.useBackCamera = NO; self.videoChat.microphoneEnabled = YES; и выполняем звонок: [self.videoChat callUser: currentPair.opponentID conferenceType: QBVideoChatConferenceTypeAudioAndVideo]; Следующим шагом реализуем методы делегата согласно поведению. Если все успешно — у оппонента должен отработать следующий метод:
-(void) chatDidReceiveCallRequestFromUser:(NSUInteger)userID withSessionID:(NSString *)_sessionID conferenceType:(enum QBVideoChatConferenceType)conferenceType { self.videoChat = [[QBChat instance] createAndRegisterVideoChatInstanceWithSessionID:_sessionID]; // video chat setup self.videoChat.viewToRenderOwnVideoStream = nil;
self.videoChat.useHeadphone = NO; self.videoChat.useBackCamera = NO; if (self.videoSide == BBVideoParentSide) { self.videoChat.viewToRenderOpponentVideoStream = self.renderView; self.videoChat.viewToRenderOwnVideoStream = nil; self.videoChat.microphoneEnabled = NO; }else if (self.videoSide == BBVideoChildSide) { self.videoChat.viewToRenderOpponentVideoStream = nil; self.videoChat.viewToRenderOwnVideoStream = self.renderView; self.videoChat.microphoneEnabled = NO; } BBPair *currentPair = [QBClient shared].currentPair; [self.videoChat acceptCallWithOpponentID: currentPair.opponentID conferenceType: QBVideoChatConferenceTypeAudioAndVideo]; } или -(void) chatCallUserDidNotAnswer:(NSUInteger)userID { } Надеемся на успешный исход :) В нашем случае мы конкретно от стороны по своему настраиваем view и звук. Тут все одинаково как и в начале, с той лишь разницей, что в конце мы посылаем accept инициатору звонка.У него должен сработать метод
-(void) chatCallDidAcceptByUser:(NSUInteger)userID { } И потом на обоих сторонах сработает -(void)chatCallDidStartWithUser:(NSUInteger)userID sessionID:(NSString *)sessionID {
} этот метод полезен для UI — например у вас крутится спиннер, пока все это дело происходит, и потом вы его прячете в этом методе. С этого момента должна работать видеосвязь.Когда нужно закончить сеанс и «положить трубку» — вызываем
[self.videoChat finishCall]; после чего срабатывает метод делегата на противоположной стороне -(void)chatCallDidStopByUser:(NSUInteger)userID status:(NSString *)status {
} Имеется также версия этого метода с параметрами, на случай если вам необходимо что-то еще передать.В данном случае используется стандартная аудио- видео сессия. В зависимости от ТЗ — если нужно например записывать видео и аудио и потом с ним что-то сделать — то вам лучше использовать кастомные аудио- видео сессии. SDK это позволяет. В этой статье это не рассматривается, но более подробно можно почитать здесь: http://quickblox.com/developers/SimpleSample-videochat-ios#Use_custom_capture_session
Итак, видеосвязь налажена. Теперь последнее что нужно сделать — это реализовать включение колыбельной на устройстве ребенка, поменять камеру, сделать скриншот и т.д…Все это делается довольно просто. Помните мы логинились дополнительно в чате? так вот — это еще один модуль, он так и называется — Chat:)В нем можно отправлять сообщения. Что мы сделаем — просто будем отправлять разные сообщения, а на стороне оппонента их парсить и, в зависимости от сообщения, выполнять какие-либо действия — включить вспышку например или еще что-нибудь.
Отправка сообщения делается просто (мы вынесли в отдельный метод) —
-(void) sendServiceMessageWithText:(NSString *)text parameters:(NSMutableDictionary *)parameters{ BBPair *currentPair = [QBClient shared].currentPair; QBChatMessage *message = [QBChatMessage new]; message.text = text; message.senderID = currentPair.myID; message.recipientID = currentPair.opponentID; if (parameters) message.customParameters = parameters; [[QBChat instance] sendMessage: message]; } Text — это и есть наш тип сообщения в данном случае.Сообщение приходит сюда —
-(void)chatDidReceiveMessage:(QBChatMessage *)message { if ([message.text isEqualToString: kRouteFlashMessage]) { // … do something }else if ([message.text isEqualToString: kRouteCameraMessage]) { // …
} } Все остальное — это UI и некоторые дополнительные фишки. В целом, все получилось неплохо. В конце хотел бы обратить внимание на два нюанса:1) срок жизни сессии — 2 часа. Он автоматически продлевается после каждого выполненного запроса. Но если например юзер свернул приложение на полдня, то ее нужно как-то восстановить. Делается это несложно — при помощи extended request:
QBASessionCreationRequest *extendedRequest = [QBASessionCreationRequest new]; extendedRequest.userLogin = self.currentUser.login; extendedRequest.userPassword = self.currentUser.password; [QBAuth createSessionWithExtendedRequest: extendedRequest delegate: self]; запускать можно, например, в applicationWillEnterForeground.2) Метод — (void)completedWithResult:(Result *)result очень быстро разрастается, что становится довольно неудобно. Почти каждый метод есть в 2х версиях — простой и с контекстом. Как вариант можно использовать блоки — передавать их как контекст. Вот как это выглядит на примере создания сессии:
typedef void (^qbSessionBlock)(BOOL isCreated, NSError *anError);
-(void) createSessionWithBlock:(qbSessionBlock)aBlock { void (^block)(Result *) = ^(Result *result) { if (! result.success) { aBlock (NO, [self errorWithQBResult: result]); } else { aBlock (YES, nil); } }; [QBAuth createSessionWithDelegate: self context:(__bridge_retained void *)(block)]; }
— (void)completedWithResult:(Result *)result context:(void *)contextInfo { void (^myBlock)(Result *result) = (__bridge void (^)(Result *__strong))(contextInfo); myBlock (result); Block_release (contextInfo); } Так намного проще.На этом, в принципе, всё. Если что не понятно — пишите в личку или комментарии. Здесь можно добавить, что приложение, о котором шла речь в данной статье, было рекомендовано Apple, вышло в US Appstore на первые места и из трёх платных in-app purchase, функция видеомонитора оказалась самой востребованной. Мы много работаем над приложениями, связанными с видео звонками под iOS, Android, Web — обычно это дейтинг/социалки или безопасность/видеонаблюдение, так что буду рад помочь советом или примерами кода, если вы делаете что-то подобное.
