Пару месяцев назад мы с Трауном Лейденом обратились к 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 , играть с ним, и дайте мне знать , что вы думаете!