Реализуем видео звонки в iOS приложении (на примере детского монитора и без WebRTC)

  • Tutorial
imageВ данном посте пойдет речь о том, как написать приложение — baby monitor, когда одно устройство (планшет) вы устанавливаете возле кроватки ребенка, а второе (телефон), берете с собой, скажем на кухню, и время от времени поглядываете за ребенком через экран.

Как новоиспеченный родитель, хочу сказать, что такое приложение экономит кучу нервов — не нужно прислушиваться к каждому шороху или детскому крику с улицы, можно одним взглядом убедиться, что c чадом всё в порядке. Немного о технической части: в приложении используется наша библиотечка iOS видеочата, включая серверную часть (сигналинг и TURN сервер для NAT traversal), это всё в открытом доступе. Видеопоток будет работать как через Wi-Fi, так и через 2G/3G/4G. В аппсторе до недавнего времени не было приложения детского видеомонитора, который бы работал через мобильный интернет (видимо из-за трудностей с NAT traversal), но пока мы прокрастинировали готовили пост, одно из приложений лидеров выпустили платную версию с поддержкой этого функционала. В любом случае, статья будет полезна вам, если вы хотите запилить видеомониторинг или двухсторонний видеозвонок в своём iOS приложении. Специально указываем, что это версия без WebRTC, потому что о веб-совместимой версии (как и об Android) собираемся написать отдельно, там есть свои нюансы.

ТЗ:
В нашем случае приложение представляет собой мониторинг маленьких детей (грудного возраста) посредством мобильного устройства под управлением iOS. При старте приложение должно было найти соседнее устройство, синхронизироваться с ним и далее выполнить видео-звонок. В ходе соединения, родитель видит ребенка, а также может управлять устройством на той стороне — включить свет (вспышку), проиграть колыбельную, поговорить туда в микрофон.

Собственно, проект не тяжелый, основные сложности лежали в реализации 2х пунктов:
  • поиск и синхронизация устройств
  • видеосвязь

Рассмотрим эти пункты чуть подробнее:

Поиск и синхронизация устройств


Синхронизация происходит по сети Wi-Fi или Bluetooth. Погуглив, обнаружили 4 способа как это можно сделать. Приведем их краткое описание, преимущества и недостатки:

  1. Bonjour service — синхронизация по Wi-Fi. В интернетах найти такой семпл не составляет труда. Работает на iOS 6-7
  2. Core Bluetooth — работает, как бы это неожиданно ни звучало, по каналу Bluetooth с iOS 5 и выше. Но вот в чем нюанс — поддерживается только Bluetooth 4 LE.
  3. GameKit. Крутая штука. В принципе, все просто как двери. Работает нормально, на обычном bluetooth (для устройств iPhone 4 и даже ниже). Также работает Bonjour — и для WiFi сетей. Но есть небольшой недостаток — deprecated начиная с iOS 7.
  4. 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<BBCommonServiceDelegate> delegate;

-(void) setConnectionType:(CommonServiсeType)type;

-(void) startServerSide;
-(void) stopServerSide;

-(void) startSearchDevices;
-(void) stopSearchDevices;
-(void) selectDevice:(BBDeviceModel *)deviceModel;

-(void) clean;

@end

@protocol BBCommonServiceDelegate <NSObject>

@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 — обычно это дейтинг/социалки или безопасность/видеонаблюдение, так что буду рад помочь советом или примерами кода, если вы делаете что-то подобное.
Поделиться публикацией

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

    +1
    У меня брат пользуется мотороловским монитором. Ну ему надо, коттедж, 3 этажа. А вот зачем он нужен в трёхкомнатной квартире — ума не приложу :)
      +1
      Мы так использовали — оставляли айпад возле ребенка, когда он спит, и сидим на кухне, через две плотно закрытых двери, поглядывая на картинку на экране айфона. Таким образом, мы ему своими шумами не мешаем, в то же время не надо прислушиваться и гадать, проснулся он там или нет. Или еще бывает, открываешь дверь посмотреть как он там, и от этого шума ребенок просыпается.
      Хардверные мониторы, наверное, лучше, но из личного опыта, это — вполне рабочая замена.

      Вообще целью поста была не концентрация именно на видео мониторе для ребенка, а показать как в iOS приложении довольно просто сделать видеозвонок между двумя устройствами. Аналогично будет работать и на Android и на Web. Наверное, стоит чуть подкорректировать название.

      Еще, почему-то получил целых три минуса в карму с момента публикации, в то же время пост имеет рейтинг +7 и 27 человек добавили в «избранное». Буду благодарен за обратную связь на тему, что можно в посте улучшить.
        0
        Я вам карму не портил, чесслово.
          +1
          Не-не-не, сорри, это я к слову спросил, т.к. большого опыта постов здесь нет, может кто поможет обратной связью по улучшению статьи. К сожалению, минусы все были без комментариев, остается гадать — не тот хаб, замечания по коду, картинка не понравилась, ХЗ что еще.
      0
      А почему именно видео? Не эффективнее было бы обновлять картинку через определенные промежутки времени?
        +1
        В данном случае такую задачу поставил заказчик. Можно и картинку обновлять, но канал позволяет видео + аудио, что на практике довольно удобно.
          0
          С точки зрения эффективности использования канала, передавать видео более эффективно, нежели картинку (хотя и сильно зависит от фреймрейта и интервала картинок).
          0
          А почему не использовать для наблюдения FaceTime или Skype?
            +1
            В принципе, можно. Тут идет речь о том, как самому сделать подобный функционал, в случае когда это необходимо в собственном приложении. В данном случае, например, это часть бОльшего приложения на детскую тематику. Ну и плюс специфические вещи релизовываются:
            • на стороне ребенка не показывать видео родителя, а показывать скринсейвер или черный экран (по выбору)
            • на стороне ребенка включать подсветку, переключать колыбельные, менять камеру и включать/выключать передачу звука по управлению родителя
            и т.п.
            0
            Какое максимальное расстояние нормального функционирования программы? В условиях стен и открытого воздуха.
              0
              Будет работать пока присутствует интернет соединение, по дальности ограничено только дальностью Wi-Fi сети, но также будет работать через 3G/4G, тогда в любом месте с покрытием. В плане качества в приложении и в самом SDK по умолчанию выставлены настройки на низкий FPS и детализацию картинки, поэтому пропускная способность канала нужна минимальная.

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

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