背景

以土方测量场景为例,在我们现在的数据处理流程中,首先通过采集端进行数据采集,飞行任务中的所有图片存储在飞机上的 SD 卡中。为了完成后序的建模,我们需要将采集到的图片上传至服务器。 现在的做法是,完成飞行任务后,取出飞机上的 SD 卡,通过 USB 设备连接电脑,并使用 MeshKit Studio (桌面端程序)进行上传。目前这个流程能很好地工作,但是效率并不高,拿到 SD卡后,我们必须使用一个桌面端程序才能完成图片上传,而这可能就是完成飞行任务几个小时之后了。如果能在移动设备上完成图片上传,那么就会节省很多时间,也免去了需要去熟悉另一个桌面端程序的麻烦了。

读取外部设备中的文件

Apple 为我们提供了很多简洁的 API 来管理、访问 iOS 设备中的文档,当然也包括外部 USB 设备中的文档。在 iOS 13 中我们可以显示一个 UIDocumentPickerViewController 来让用户选择文件夹:

  1. let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open)
  2. documentPicker.directoryURL = url
  3. documentPicker.delegate = self
  4. present(documentPicker, animated: true, completion: nil)

在代理方法中我们可以获得用户选择的文件夹:

  1. func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
  2. guard let url = urls.last else { return }
  3. DispatchQueue.global(qos: .background).async {
  4. self.progressFiles(at: url)
  5. }
  6. }

在获得用户选择的文件夹后,我们使用 NSFileCoordinator 来递归获取文件夹下面的所有子文件夹,主要代码如下:

  1. let shouldStopAccessing = url.startAccessingSecurityScopedResource()
  2. defer {
  3. if shouldStopAccessing { url.stopAccessingSecurityScopedResource() }
  4. }
  5. // 读取目录下的子目录
  6. var error: NSError?
  7. NSFileCoordinator().coordinate(readingItemAt: url, options: [.withoutChanges], error: &error) { foldURL in
  8. let keys: [URLResourceKey] = [.nameKey]
  9. guard let fileList = FileManager.default.enumerator(at: url, includingPropertiesForKeys: keys) else {
  10. return
  11. }
  12. for case let file as URL in fileList {
  13. // 获取到了文件的 URL,可以将文件拷贝到应用的沙盒中
  14. }
  15. }

我们从外部 USB 设置中读取了文件后,可以进行常规的文件操作了,当然我也可以在这里直接进行文件上传操作,但是我们并不能保证外部设备能一直保持着连接,所以这里还是采用先从外部设备中拷贝文件到应用沙盒目录中,然后就可以随心所欲地做后序处理了。

这里有一个点是,当进行跨卷文件移动时,如果我们使用 NSTemporaryDirectory 来保存文件的临时版本,我们应该替换成 FileManager.default.url(for: .itemReplacementDirectory, in: [.userDomainMask], appropriateFor: url, create: true) 。后者总是会为我们提供正确的临时目录来写入文件。

还有一点是:始终在后台队列上执行文件系统操作。

下面两张图是对从外部 USB 设备拷贝文件的测试情况:
image.png
Picture1.png

上传文件

当我们将文件从外 USB 设备中拷贝到应用的沙盒目录中就可以就行文件上传了。我们的图片等媒体文件都是存储在阿里云对象存储 OSS 中。所以使用对象存储 OSS SDK 就可以实现文件上传功能。

在调用 SDK 进行文件上传前,首先需要进行授权。iOS SDK 提供了 STS 鉴权模式和自签名模式。我们采用的是 STS 授权模式,主要过程就是访问我们服务端接口来获取访问令牌。

STS 授权

在发送授权请求时,我们需要根据服务端的规则构造请求参数,规则如下图所示:
Screen Shot 2020-09-09 at 11.32.51 AM.png
转成 Swift 代码如下:

  1. let obj: [String: Any] = [
  2. "bucket": "mesh-ios-log", // 测试用的 bucket
  3. "applytime": Date().timeIntervalSince1970
  4. ]
  5. let paramtoData = try! JSONSerialization.data(withJSONObject: obj, options: [])
  6. // 转为 Base64 编码的 json 字符串
  7. let str = paramtoData.base64EncodedString()
  8. // 再将字符串中的加号 “+” 换成中划线 “-”,并且将斜杠 “/” 换成下划线 “_”
  9. let formatedStr = str.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
  10. // 加盐
  11. let salt = "iF0DcUu1xNj8IjTBIw02scEaZjCW1n0oYuRpWk9G"
  12. // 对待签名字符串计算 HMAC-SHA1 签名
  13. let sign = formatedStr.hmac(algorithm: .SHA1, key: salt)
  14. let param: [String: String] = ["param": formatedStr, "sign": sign]

在上面我们使用了 SHA1 算法对待签名的字符串进行签名。下面的代码是 SHA1 算法在 Swift 中的实现:

  1. import CommonCrypto
  2. enum HMACAlgorithm {
  3. case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
  4. func toCCHmacAlgorithm() -> CCHmacAlgorithm {
  5. var result: Int = 0
  6. switch self {
  7. case .MD5:
  8. result = kCCHmacAlgMD5
  9. case .SHA1:
  10. result = kCCHmacAlgSHA1
  11. case .SHA224:
  12. result = kCCHmacAlgSHA224
  13. case .SHA256:
  14. result = kCCHmacAlgSHA256
  15. case .SHA384:
  16. result = kCCHmacAlgSHA384
  17. case .SHA512:
  18. result = kCCHmacAlgSHA512
  19. }
  20. return CCHmacAlgorithm(result)
  21. }
  22. func digestLength() -> Int {
  23. var result: CInt = 0
  24. switch self {
  25. case .MD5:
  26. result = CC_MD5_DIGEST_LENGTH
  27. case .SHA1:
  28. result = CC_SHA1_DIGEST_LENGTH
  29. case .SHA224:
  30. result = CC_SHA224_DIGEST_LENGTH
  31. case .SHA256:
  32. result = CC_SHA256_DIGEST_LENGTH
  33. case .SHA384:
  34. result = CC_SHA384_DIGEST_LENGTH
  35. case .SHA512:
  36. result = CC_SHA512_DIGEST_LENGTH
  37. }
  38. return Int(result)
  39. }
  40. }
  41. extension String {
  42. func hmac(algorithm: HMACAlgorithm, key: String) -> String {
  43. let cKey = key.cString(using: String.Encoding.utf8)
  44. let cData = self.cString(using: String.Encoding.utf8)
  45. var result = [CUnsignedChar](repeating: 0, count: Int(algorithm.digestLength()))
  46. CCHmac(algorithm.toCCHmacAlgorithm(), cKey!, strlen(cKey!), cData!, strlen(cData!), &result)
  47. let hmacData:NSData = NSData(bytes: result, length: (Int(algorithm.digestLength())))
  48. let hmacBase64 = hmacData.base64EncodedString(options: NSData.Base64EncodingOptions.lineLength76Characters)
  49. return String(hmacBase64)
  50. }
  51. }

我们获取的授权凭证都有个过期时间,当凭证失效时,客户端需要向服务器申请新的有效访问凭证,并重新构造新的 OSSClient。如果想让 SDK 自动更新授权凭证,则要求我们在 SDK 的应用中实现回调。这个回调通过我们实现的方式去获取一个 Federation Token(即StsToken),然后返回。SDK 会利用这个 Token 来进行加签处理,并在需要更新时主动调用这个回调来获取Token。完整的代码如下:

  1. let credentialProvider = OSSAuthCredentialProvider { () -> OSSFederationToken? in
  2. let tcs = OSSTaskCompletionSource<STS>()
  3. let obj: [String: Any] = [
  4. "bucket": "mesh-ios-log",
  5. "applytime": Date().timeIntervalSince1970
  6. ]
  7. let paramtoData = try! JSONSerialization.data(withJSONObject: obj, options: [])
  8. // 转为Base64编码的 json 字符串
  9. let str = paramtoData.base64EncodedString()
  10. // 再将字符串中的加号 “+” 换成中划线 “-”,并且将斜杠 “/” 换成下划线 “_”
  11. let formatedStr = str.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
  12. // 加盐
  13. let salt = "iF0DcUu1xNj8IjTBIw02scEaZjCW1n0oYuRpWk9G"
  14. let sign = formatedStr.hmac(algorithm: .SHA1, key: salt)
  15. let param: [String: String] = ["param": formatedStr, "sign": sign]
  16. AF.request("https://test.meshkit.cn/api/oss/ststoken",
  17. method: .post,
  18. parameters: param,
  19. encoding: JSONEncoding.default,
  20. headers: ["Content-Type": "application/json"]
  21. ).responseJSON { response in
  22. switch response.result {
  23. case .failure(let error):
  24. tcs.setError(error)
  25. case .success(let data):
  26. let result = try! JSONDecoder().decode(KWResult.self, from: response.data!)
  27. tcs.setResult(result.data)
  28. }
  29. }
  30. // 需要阻塞等待请求返回
  31. tcs.task.waitUntilFinished()
  32. if let error = tcs.task.error {
  33. return nil
  34. } else {
  35. let sts = tcs.task.result!
  36. let token = OSSFederationToken()
  37. token.tAccessKey = sts.accessKey
  38. token.tSecretKey = sts.accessKeySecret
  39. token.tToken = sts.token
  40. return token
  41. }
  42. }
  43. let clien = OSSClient(endpoint: "https://oss-cn-hangzhou.aliyuncs.com",
  44. credentialProvider: credentialProvider)


文件上传

获取了授权后就可以进行文件上传了。下面是通过 URL 上传本地文件的主要代码:

  1. let put = OSSPutObjectRequest()
  2. // 配置必填字段,其中bucketName为存储空间名称;objectKey等同于objectName,
  3. // 表示将文件上传到OSS时需要指定包含文件后缀在内的完整路径,例如abc/efg/123.jpg。
  4. put.bucketName = "mesh-ios-log"
  5. put.objectKey = "test/\(file.lastPathComponent)"
  6. put.uploadingFileURL = file
  7. let task = self.clien.putObject(put)
  8. task.continue(successBlock: { aTask in
  9. if let error = aTask.error {
  10. print("upload error: ", error)
  11. } else {
  12. print("upload object success!")
  13. self.lock.lock()
  14. succeedCount += 1
  15. self.lock.unlock()
  16. OperationQueue.main.addOperation {
  17. progress?(succeedCount, total)
  18. }
  19. }
  20. return nil
  21. })
  22. task.waitUntilFinished()
  23. put.cancel()

上传文件比较简单,需要我们处理的东西比较少。需要注意的是进行多文件上传时要控制好子线程的数量。像我们的应用中上传的文件都是在几百张以上,如果不加控制,任由系统为我们创建子线程,将会影响应用的性能,毕竟创建线程和频繁地在多个线程中切换是要消耗系统资源的。

总结

总的来说,从外部 USB 设备拷贝文件并上传的过程并不复杂,复杂的部分主要集中在对意外情况的处理,列如:拷贝文件过程中 USB 设备与手机(iPad)断连,上传过程中部分文件上传失败。这些问题都需要在做产品的过程中去仔细去思考,并能用技术手段去解决问题。

参考