Пару месяцев назад мы с Трауном Лейденом обратились к Pasin Suriyentrakorn с идеей создать безоблачное P2P-приложение с использованием Couchbase Mobile. Эта статья следует этому процессу своими словами:
Пасин, убери это …
Спасибо, Уэйн. Одна из замечательных особенностей Couchbase Lite, которая не была так популярна, — это возможность выполнять P2P-репликацию между двумя устройствами. Couchbase Lite поставляется с дополнительным компонентом, называемым Couchbase Lite Listener, который позволяет вашему приложению принимать HTTP-соединения от других устройств, работающих под управлением Couchbase Lite, и синхронизировать данные с ними.
С тех пор я был помолвлен и провел пару дней, работая над этим небольшим P2P-приложением для обмена фотографиями для iOS под названием PhotoDrop. В этом посте я покажу вам, как я использовал Couchbase Lite для разработки приложения. Это видео о том, чем вы закончите в конце: http://youtu.be/glsujG99hMc
обзор
PhotoDrop — это приложение для обмена фотографиями P2P, аналогичное функции iOS AirDrop, которую можно использовать для отправки фотографий на устройства. Прежде чем углубляться в код, я хотел бы кратко обсудить проблемы P2P и варианты дизайна, которые я сделал при разработке приложения.
Peer Discovery можно сделать несколькими способами. В iOS вы можете использовать Bonjour Service для обнаружения пиров, но это может быть проблемой, если позже вы захотите разработать приложение на других платформах. В PhotoDrop я использую более простой и прямой способ использования QRCode. Я использую QRCode для рекламы URL-адреса конечной точки adhoc, который отправитель может сканировать и отправлять фотографии.
Идентичность сверстников — это связанный с Discovery Discovery предмет. Идентификацию одноранговой сети, как правило, трудно решить без введения в приложение некоторых нежелательных шагов, таких как регистрация пользователя, вход в систему пользователя и утверждение со стороны партнера. Используя QRCode и требуя, чтобы два человека имели прямое явное взаимодействие для отправки и получения фотографий, эта проблема устраняется.
Аутентификация необходима для того, чтобы гарантировать, что управление доступом, в данном случае доступ на запись для отправки фотографий в базу данных другого партнера, будет предоставлено нужному человеку. В PhotoDrop я использую базовую аутентификацию, которую Couchbase Lite уже поддерживает. Я надежно генерирую одноразовые имя пользователя и пароль, связываю их с конечной точкой URL и кодирую их все в QRCode, предоставляемом получателем отправителю. После того, как отправитель отсканирует QRCode, отправитель получит имя пользователя и пароль для базовой аутентификации.
Безопасные каналы связи требуются специально для отправки конфиденциальной информации. Я не реализовал безопасную связь в этом приложении. Однако недавно Джен Алфке добавила поддержку TLS, в том числе API, для создания самозаверяющего сертификата на лету в iOS Couchbase Lite Listener. Поскольку вся тяжелая работа была проделана, вы можете добавить поддержку для этого довольно легко.
В конце концов, текущий поток PhotoDrop довольно прост. Вы выбираете фотографии, которыми хотите поделиться с другом, и открываете сканер QRCode, чтобы отсканировать конечную точку, на которую будут отправлены выбранные фотографии. С другой стороны, ваш друг открывает приложение, показывает QRCode и ждет, пока вы отсканируете и отправите фотографии. В следующем разделе будут представлены все детали реализации приложения.
Выбор фотографий
PhotoDrop использует ALAssetsLibrary для доступа к фотографиям в альбоме Camera Roll на устройстве iOS, потому что UIImagePickerViewController, который не поддерживает выбор нескольких фотографий.
// ViewController.swift
// MARK: - ALAssetsLibrary
func reloadAssets() {
if library == nil {
library = ALAssetsLibrary()
}
assets.removeAll(keepCapacity: true)
library.enumerateGroupsWithTypes(ALAssetsGroupSavedPhotos, usingBlock:
{ (group:ALAssetsGroup!, stop:UnsafeMutablePointer<ObjCBool>) -> Void in
if group != nil {
group.setAssetsFilter(ALAssetsFilter.allPhotos())
group.enumerateAssetsWithOptions(NSEnumerationOptions.Reverse,
usingBlock: { (asset:ALAsset!, index:Int,
stop: UnsafeMutablePointer<ObjCBool>) -> Void in
if asset != nil {
self.assets.append(asset)
}
})
} else {
dispatch_async(dispatch_get_main_queue(), {
self.collectionView.reloadData()
})
}
}) { (error:NSError!) -> Void in
}
}
Фотографии отображаются в UICollectionViewController. Простой пользовательский UICollectionViewCell с именем PhotoViewCell создается для отображения миниатюры фотографии и флажка, указывающего выбранный статус фотографии.
// ViewController.swift
// MARK: - UICollectionView
func collectionView(collectionView: UICollectionView,
cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier("photoCell",
forIndexPath: indexPath) as PhotoViewCell
let asset = assets[indexPath.row]
cell.imageView.image = UIImage(CGImage: asset.thumbnail().takeUnretainedValue())
cell.checked = contains(selectedAssets, asset)
return cell
}
func collectionView(collectionView: UICollectionView,
didSelectItemAtIndexPath indexPath: NSIndexPath) {
let cell = collectionView.cellForItemAtIndexPath(indexPath) as PhotoViewCell
let asset = assets[indexPath.row]
if let foundIndex = find(selectedAssets, asset) {
selectedAssets.removeAtIndex(foundIndex)
cell.checked = false
} else {
selectedAssets.append(asset)
cell.checked = true
}
collectionView.reloadItemsAtIndexPaths([indexPath])
self.enableSendMode(selectedAssets.count > 0)
}
Отправка фотографий
Когда выбранные фотографии готовы к отправке и нажата кнопка отправки, SendViewController будет представлен с выбранными фотографиями.
Как только SendViewController представлен, мы оказываемся в функции viewDidLoad (), в которой мы получаем пустой объект базы данных с именем «db» из класса DatabaseUtil. Причина, по которой мы получаем новую базу данных, состоит в том, чтобы гарантировать отсутствие ожидающих документов из предыдущего сеанса обмена. Функция getEmptyDatabase () класса DatabaseUtil возвращает пустую базу данных с заданным именем, удаляя базу данных, если она существует, и заново создавая новую.
// DatabaseUtil.swift
class func getEmptyDatabase(name: String!, error: NSErrorPointer) -> CBLDatabase? {
if let database = CBLManager.sharedInstance().existingDatabaseNamed(name, error: nil) {
if !database.deleteDatabase(error) {
return nil;
}
}
return CBLManager.sharedInstance().databaseNamed(name, error: error)
}
Как только SendViewController будет представлен, мы будем в функции viewDidAppear (animated: Bool). В функции viewDidAppear мы запускаем сеанс захвата QRCode AVFoundation, чтобы представить сканер QRCode.
// SendViewController.swift
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
if database != nil && session == nil {
startCaptureSession()
}
}
func startCaptureSession() {
let app = UIApplication.sharedApplication().delegate as AppDelegate
let device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)
if device == nil {
AppDelegate.showMessage("No video capture devices found", title: "")
return
}
var error: NSError?
let input = AVCaptureDeviceInput.deviceInputWithDevice(device, error: &error)
as AVCaptureDeviceInput
if error == nil {
session = AVCaptureSession()
session.addInput(input)
let output = AVCaptureMetadataOutput()
output.setMetadataObjectsDelegate(self, queue: dispatch_get_main_queue())
session.addOutput(output)
output.metadataObjectTypes = [AVMetadataObjectTypeQRCode]
previewLayer = AVCaptureVideoPreviewLayer.layerWithSession(session)
as AVCaptureVideoPreviewLayer
previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill
previewLayer.frame = self.previewView.bounds
self.previewView.layer.addSublayer(previewLayer)
session.startRunning()
} else {
AppDelegate.showMessage("Cannot start QRCode capture session", title: "Error")
}
}
Когда захватывается QRCode, мы извлекаем URL конечной точки, который является удаленным URL, на который мы будем отправлять фотографии. Основной код для создания и отправки фотодокументов начинается и заканчивается в функции replicate (url: NSURL), которая содержит около 50 строк кода.
Код начинается с зацикливания каждой фотографии в массиве sharedAssets. Для каждой фотографии мы получаем двоичное представление и прикрепляем к пустому документу Couchbase Lite.
// SendViewController.swift
func replicate(url: NSURL) {
self.previewView.hidden = true;
self.statusLabel.text = "Sending Photos ..."
UIApplication.sharedApplication().networkActivityIndicatorVisible = true
var docIds: [String] = []
for asset in sharedAssets! {
let representation = asset.defaultRepresentation()
var bufferSize = UInt(Int(representation.size()))
var buffer = UnsafeMutablePointer<UInt8>(malloc(bufferSize))
var buffered = representation.getBytes(buffer, fromOffset: 0,
length: Int(representation.size()), error: nil)
var data = NSData(bytesNoCopy: buffer, length: buffered, freeWhenDone: true)
var error: NSError?
let doc = database.createDocument()
let rev = doc.newRevision()
rev.setAttachmentNamed("photo", withContentType: "application/octet-stream", content: data)
let saved = rev.save(&error)
if saved != nil {
docIds.append(doc.documentID)
}
}
...
}
После того как мы завершим создание фотодокументов, мы отправляем эти документы на другое устройство. Для этого мы создаем push-репликатор и устанавливаем идентификаторы документов, которые мы хотели бы тиражировать. В общем, установка идентификаторов документов для репликатора не является обязательной. Если идентификаторы не указаны, репликатор просто скопирует все документы в базе данных.
Перед запуском репликатора мы настраиваем наблюдателя уведомлений для наблюдения за состоянием репликации, чтобы мы могли надлежащим образом отображать состояние репликации, когда репликация выполняется.
// SendViewController.swift
func replicate(url: NSURL) {
...
if docIds.count > 0 {
replicator = database.createPushReplication(url)
replicator.documentIDs = docIds
NSNotificationCenter.defaultCenter().addObserverForName(kCBLReplicationChangeNotification,
object: replicator, queue: nil) { (notification) -> Void in
if self.replicator.lastError == nil {
var totalCount = self.replicator.changesCount
var completedCount = self.replicator.completedChangesCount
if completedCount > 0 && completedCount == totalCount {
self.statusLabel.text = "Sending Completed"
UIApplication.sharedApplication().networkActivityIndicatorVisible = false
}
} else {
self.statusLabel.text = "Sending Abort"
UIApplication.sharedApplication().networkActivityIndicatorVisible = false
}
}
replicator.start()
}
}
Получение фотографий
Прием фотографий осуществляется в ReceiveViewController, который отображается на экране ViewController, когда пользователь нажимает кнопку «Получить». Когда ReceiveViewController представлен, мы получаем новую базу данных с именем «db» в методе viewDidLoad (). В функции viewDidAppear (animated: Bool) мы вызываем startListener () для создания и запуска объекта CBLListener. CBLListener — это встроенный облегченный HTTP-сервер, который прослушивает HTTP-запросы и направляет эти запросы соответствующим обработчикам для выполнения операций репликации. Как только слушатель запущен, мы можем получить URL слушателя и представить QRCode, кодирующий URL. В качестве бонуса, iOS CoreImage поддерживает фильтр QRCode, поэтому генерация изображения QRCode действительно проста ?
// ReceiveViewController.swift
override func viewDidLoad() {
super.viewDidLoad()
collectionView.hidden = true;
var error: NSError?
database = DatabaseUtil.getEmptyDatabase("db", error: &error)
if error != nil {
AppDelegate.showMessage("Cannot get a database with error : \(error!.code)", title: "Error")
}
}
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
if database == nil {
return;
}
if (!startListener()) {
AppDelegate.showMessage("Cannot start listener", title: "Error")
return;
}
if syncUrl != nil {
imageView.image = UIImage.qrCodeImageForString(syncUrl.absoluteString,
size: imageView.frame.size)
}
}
// MARK: - Listener
func startListener() -> Bool {
if listener != nil {
return true
}
var error: NSError?
listener = CBLListener(manager: CBLManager.sharedInstance(), port: 0)
// Enable Basic Authentication
listener.requiresAuth = true
let username = secureGenerateKey(NSCharacterSet.URLUserAllowedCharacterSet())
let password = secureGenerateKey(NSCharacterSet.URLPasswordAllowedCharacterSet())
listener.setPasswords([username : password])
var success = listener.start(&error)
if success {
// Set a sync url with the generated username and password:
if let url = NSURL(string: database.name, relativeToURL: listener.URL) {
if let urlComp = NSURLComponents(string: url.absoluteString!) {
urlComp.user = username
urlComp.password = password
syncUrl = urlComp.URL
}
}
// Start observing for database changes:
startObserveDatabaseChange()
return true
} else {
listener = nil
return false
}
}
Когда мы настраиваем прослушиватель в startListener (), мы включаем базовую аутентификацию с созданной парой имя пользователя / пароль. Сгенерированная пара имя пользователя / пароль служит одноразовой парой, которая обеспечивает безопасное (достаточное) решение для предотвращения отправки неавторизованными пользователями изображений в приемник. Для генерации имени пользователя и пароля мы используем API сервисов рандомизации iOS (SecRandomCopyBytes), который генерирует криптографически безопасные случайные значения.
func secureGenerateKey(allowedCharacters: NSCharacterSet) -> String {
let data = NSMutableData(length:32)!
SecRandomCopyBytes(kSecRandomDefault, 32, UnsafeMutablePointer<UInt8>(data.mutableBytes))
let key = data.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.Encoding64CharacterLineLength)
return key.stringByAddingPercentEncodingWithAllowedCharacters(allowedCharacters)!
}
В конце функции startListener () вызывается функция startObserveDatabaseChange () для наблюдения за любыми изменениями, происходящими в базе данных, с помощью уведомлений с именем kCBLDatabaseChangeNotification. Теперь мы можем наблюдать, когда фотодокументы с другого устройства копируются, и сохранять фотографии в фотоаппарате устройства.
// ReceiveViewController.swift
// MARK: - Database Change
func startObserveDatabaseChange() {
NSNotificationCenter.defaultCenter().addObserverForName(kCBLDatabaseChangeNotification,
object: database, queue: nil) {
(notification) -> Void in
if let changes = notification.userInfo!["changes"] as? [CBLDatabaseChange] {
for change in changes {
dispatch_async(dispatch_get_main_queue(), {
if self.collectionView.hidden {
self.collectionView.hidden = false
}
self.saveImageFromDocument(change.documentID)
})
}
}
}
}
Функция сохранения фотографии в рулон камеры устройства находится ниже. Функция просто получает изображение из вложения документа, создает CGImage и сохраняет его в кадре камеры через функцию writeImageDataToSavedPhotosAlbum () ALAssetsLibrary. После сохранения фотографии мы показываем миниатюру на экране.
// ReceiveViewController.swift
func saveImageFromDocument(docId: String) {
let app = UIApplication.sharedApplication().delegate as AppDelegate
if let doc = app.database.existingDocumentWithID(docId) {
if doc.currentRevision.attachments.count > 0 {
let attachment = doc.currentRevision.attachments[0] as CBLAttachment
if let image = UIImage(data: attachment.content)?.CGImage {
let library = assetsLibrary()
library.writeImageDataToSavedPhotosAlbum(attachment.content, metadata: nil,
completionBlock: { (url: NSURL!, error: NSError!) -> Void in
if url != nil {
library.assetForURL(url, resultBlock:
{(asset: ALAsset!) -> Void in
self.assets.insert(asset, atIndex: 0)
dispatch_async(dispatch_get_main_queue(), {
self.collectionView.insertItemsAtIndexPaths(
[NSIndexPath(forRow: 0, inSection: 0)])
})
})
{(error: NSError!) -> Void in
}
}
})
}
}
}
}
Завершение
Существует множество способов разработки приложения PhotoDrop, и использование Couchbase Lite, пожалуй, один из самых простых. Основной код для отправки и получения фотографий составляет всего 100 строк кода и содержит ноль строк кода, непосредственно участвующих в сетевом взаимодействии. Я надеюсь, что этот пост и само приложение PhotoDrop дадут вам вдохновение и идеи для использования Couchbase Lite для приложений P2P. Клон репо PhotoDrop GitHub , играть с ним, и дайте мне знать , что вы думаете!