27-《ARKit by Tutorials》读书笔记6: AR持久化与共享

104 阅读5分钟

ARKit文章目录

本文是Ray Wenderlich上《ARKit by Tutorials》2.0 版新增章节的读书笔记,主要讲内容概要和读后感  

成品效果

本章的成果是做一个 AR 绘图 app,并可以保存并分享给其他人。

世界追踪和场景持久化

ARKit 依靠 ARWorldMap 来提供数据持久化,其中 ARSession 起到了重要作用。

向 ARSession 中添加 ARAnchor 对象时,它既是 app 中的虚拟物体,也是真实世界中的特征。

放置锚点

只有两个步骤:

  • renderer(_:willRenderScene:atTime:):中创建并添加锚点ARLineAnchor
  • renderer(_:didAdd:for:)中取出锚点,并根据锚点创建节点SCNLineNode
// 添加锚点的代码
func addLineAnchorForObject(sourcePoint: SCNVector3?,
                       destinationPoint: SCNVector3?) {
    // 获取命中测试的第一个结果
    guard let hitTestResult = sceneView
        .hitTest(self.viewCenter!, types: [.existingPlaneUsingGeometry,
                                           .estimatedHorizontalPlane])
        .first
        else { return }
    // 创建 ARLineAnchor 锚点
    currentLineAnchorName = "virtualObject\(count)"
    count = count+1
    let lineAnchor = ARLineAnchor(name: currentLineAnchorName!,
                             transform: hitTestResult.worldTransform,
                           sourcePoint: sourcePoint,
                      destinationPoint: destinationPoint)
    // 添加到 session 中
    sceneView.session.add(anchor: lineAnchor)
    lineObjectAnchors.append(lineAnchor)
}

保存世界地图

世界地图的状态,可以从session(_ session: ARSession, didUpdate frame: ARFrame)中的frame.worldMappingStatus来判断。

func session(_ session: ARSession, didUpdate frame: ARFrame) {
  switch frame.worldMappingStatus {
  case .extending, .mapped:
    // 可用状态
  default:
}

世界地图的实际获取是从 session 中获取的,同时还会保存一张截图

sceneView.session.getCurrentWorldMap { worldMap, error in
  // 取值
  guard let map = worldMap else {
    return
  }

  // 配上一张截图
  guard let snapshotAnchor = SnapshotAnchor(capturing: self.sceneView) else {
    fatalError("Can't take snapshot")
  }
  map.anchors.append(snapshotAnchor)

  do {
    // 归档保存
    let data = try NSKeyedArchiver.archivedData(withRootObject: map,
                                                requiringSecureCoding: true)
    try data.write(to: self.mapSaveURL, options: [.atomic])
  } catch {
    fatalError("Can't save map: \(error.localizedDescription)")
  }
}

加载和恢复

取出保存的世界地图

// 反归档
guard let worldMap =
      try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self,
                                             from: data)
    else {
      fatalError("No ARWorldMap in archive.")
    }
 // 取出截图
if let snapshotData = worldMap.snapshotAnchor?.imageData,
  let snapshot = UIImage(data: snapshotData) {
  
} else {
  print("No snapshot image in world map")
}

// 删除截图
worldMap.anchors.removeAll(where: { $0 is SnapshotAnchor })

// 加载使用
let configuration = self.defaultConfiguration
configuration.initialWorldMap = worldMap
sceneView.session.run(configuration,
                      options: [.resetTracking, .removeExistingAnchors])

共享 AR 体验

多人 AR 依赖的是苹果的MultipeerConnectivity框架。这个框架使用 Wifi 和蓝牙来传输数据。你需要一个MCSession对象来广播自己,发现其他设备,及连接到其他 peer 上

import MultipeerConnectivity
class PeerSession: NSObject {
  // 用来沟通的 ID,及协议serviceType
  private let peerID = MCPeerID(displayName: UIDevice.current.name)
  static let serviceType = "arsketchsession"
  // MCSession对象,用来支持多方数据传输
  private(set) var mcSession: MCSession!
  // MCAdvertiserAssistant对象,用来告诉其他 peer,你的 app 可以加入一个多方会话网络中。它还自带了一个标准用户界面,用来接受别人的会话邀请或者主动邀请别人进入会话
  private var advertiserAssistant: MCAdvertiserAssistant!
  // 处理接收到的数据
  private let receivedDataHandler: (Data, MCPeerID) -> Void
 }

建立 peer 连接

代理方法

建立连接,传输数据需要在代理方法中处理,虽然MCAdvertiserAssistantDelegate中的方法不需要我们实现,但MCSessionDelegate中还是需要处理的:

// 当附近 session 中的 peer 状态发生改变时调用。状态变为 MCSessionState.connected 或者 MCSessionState.notConnected时调用
  func session(_ session: MCSession,
               peer peerID: MCPeerID,
               didChange state: MCSessionState) {
  }
  // 当自己收到 session 中的其他 peer 发来的`NSData`数据时调用:
  func session(_ session: MCSession,
               didReceive data: Data,
               fromPeer peerID: MCPeerID) {
// 处理收到的数据
    receivedDataHandler(data, peerID)
  }
  // 当附近的 peer 和自己建立byte stream connection时调用,本 app 中不需要这个,出现后直接报错
  func session(_ session: MCSession,
               didReceive stream: InputStream,
               withName streamName: String,
               fromPeer peerID: MCPeerID) {
    fatalError("This service does not send/receive streams.")
  }
  // 当自己从附近的 peer 上接收资源时调用,进度由`NSProgress`获取,本 app 中不需要这个,所以提示错误
  func session(_ session: MCSession,
               didStartReceivingResourceWithName resourceName: String,
               fromPeer peerID: MCPeerID,
               with progress: Progress) {
    fatalError("This service does not send/receive resources.")
  }
  // 当资源传输完成时调用,本 app 中不需要这个,所以提示错误
  func session(_ session: MCSession,
               didFinishReceivingResourceWithName resourceName: String,
               fromPeer peerID: MCPeerID,
               at localURL: URL?,
               withError error: Error?) {
    fatalError("This service does not send/receive resources.")
  }
}

系统自带 UI:MCBrowserViewController

要真正建立连接,还需要发现 peer 并选择要连接的设备。因此,app 需要广播 service type,也就是前面定义的static let serviceType = "arsketchsession"。我们要做的就是用一个服务浏览器,来查找支持的服务类型service type,一般有两种方式:

  • 使用一个MCNearbyServiceBrowser对象,它可以让你的 app 使用代码来邀请其他 peer 连接到会话中。并且你可以控制连接的超时时长,以及向其他 peer 发送邀请相关的性质。
  • 使用一个MCBrowserViewController对象,它提供一个标准用户界面,可以展示出哪里 peer 是可用的,并允许我们建立连接。

这里我们使用第二种方法,直接用自带的标准用户界面,整个过程和 iOS 中相册类似:

// 创建一个浏览控制器
let mcBrowserVC =
  MCBrowserViewController(serviceType: PeerSession.serviceType,
                          session: peerSession.mcSession)
mcBrowserVC.delegate = self                          
// 弹出控制器                         
self.present(mcBrowserVC, animated: true, completion: nil)

还需要实现代理:

// 实现代理方法
extension ViewController: MCBrowserViewControllerDelegate {
  func browserViewControllerDidFinish(
    _ browserViewController: MCBrowserViewController) {
    browserViewController.dismiss(animated: true,
                                  completion: nil)
  }

  func browserViewControllerWasCancelled(
    _ browserViewController: MCBrowserViewController) {
    browserViewController.dismiss(animated: true,
                                  completion: nil)
  }
}
 

发送数据到 peers

拿到先前归档得到的 data 数据,调用MCSession类的send(_: toPeers:with:)方法就可以了

if let data =
  try? NSKeyedArchiver.archivedData(withRootObject: lineAnchor,
                                    requiringSecureCoding: true)
{
  self.peerSession.sendToAllPeers(data)
}
private(set) var mcSession: MCSession!

func sendToAllPeers(_ data: Data) {
  do {
    try mcSession.send(data,
                       toPeers: mcSession.connectedPeers,
                       with: .reliable)
  } catch {
    print("""
      error sending data to peers:
      \(error.localizedDescription)
      """)
  }
}

接收数据和重定位

接收到的数据在handleReceivedData(_:from:)代理方法里处理:

do {
  // 如果收到的是世界地图
  if let worldMap = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data) {
    DispatchQueue.main.async {
      self.displaySnapshotImage(from: worldMap)
    }
    configureARSession(for: worldMap)
    mapProvider = peer
    return
  }
} catch {
  print("can't decode data received from \(peer)")
}
// 如果收到的是线段
if !isRelocalizingMap {
  do {
    if let anchor = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARLineAnchor.self, from: data) {
      sceneView.session.add(anchor: anchor)
    }
  } catch {
    print("unknown data received from \(peer)")
  }
}

其他

教程中的其他操作,比如 UI 搭建,重置游戏,文本提示等比较简单,不再赘述。