背景
以土方测量场景为例,在我们现在的数据处理流程中,首先通过采集端进行数据采集,飞行任务中的所有图片存储在飞机上的 SD 卡中。为了完成后序的建模,我们需要将采集到的图片上传至服务器。 现在的做法是,完成飞行任务后,取出飞机上的 SD 卡,通过 USB 设备连接电脑,并使用 MeshKit Studio (桌面端程序)进行上传。目前这个流程能很好地工作,但是效率并不高,拿到 SD卡后,我们必须使用一个桌面端程序才能完成图片上传,而这可能就是完成飞行任务几个小时之后了。如果能在移动设备上完成图片上传,那么就会节省很多时间,也免去了需要去熟悉另一个桌面端程序的麻烦了。
读取外部设备中的文件
Apple 为我们提供了很多简洁的 API 来管理、访问 iOS 设备中的文档,当然也包括外部 USB 设备中的文档。在 iOS 13 中我们可以显示一个 UIDocumentPickerViewController 来让用户选择文件夹:
let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open)documentPicker.directoryURL = urldocumentPicker.delegate = selfpresent(documentPicker, animated: true, completion: nil)
在代理方法中我们可以获得用户选择的文件夹:
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {guard let url = urls.last else { return }DispatchQueue.global(qos: .background).async {self.progressFiles(at: url)}}
在获得用户选择的文件夹后,我们使用 NSFileCoordinator 来递归获取文件夹下面的所有子文件夹,主要代码如下:
let shouldStopAccessing = url.startAccessingSecurityScopedResource()defer {if shouldStopAccessing { url.stopAccessingSecurityScopedResource() }}// 读取目录下的子目录var error: NSError?NSFileCoordinator().coordinate(readingItemAt: url, options: [.withoutChanges], error: &error) { foldURL inlet keys: [URLResourceKey] = [.nameKey]guard let fileList = FileManager.default.enumerator(at: url, includingPropertiesForKeys: keys) else {return}for case let file as URL in fileList {// 获取到了文件的 URL,可以将文件拷贝到应用的沙盒中}}
我们从外部 USB 设置中读取了文件后,可以进行常规的文件操作了,当然我也可以在这里直接进行文件上传操作,但是我们并不能保证外部设备能一直保持着连接,所以这里还是采用先从外部设备中拷贝文件到应用沙盒目录中,然后就可以随心所欲地做后序处理了。
这里有一个点是,当进行跨卷文件移动时,如果我们使用 NSTemporaryDirectory 来保存文件的临时版本,我们应该替换成 FileManager.default.url(for: .itemReplacementDirectory, in: [.userDomainMask], appropriateFor: url, create: true) 。后者总是会为我们提供正确的临时目录来写入文件。
还有一点是:始终在后台队列上执行文件系统操作。
上传文件
当我们将文件从外 USB 设备中拷贝到应用的沙盒目录中就可以就行文件上传了。我们的图片等媒体文件都是存储在阿里云对象存储 OSS 中。所以使用对象存储 OSS SDK 就可以实现文件上传功能。
在调用 SDK 进行文件上传前,首先需要进行授权。iOS SDK 提供了 STS 鉴权模式和自签名模式。我们采用的是 STS 授权模式,主要过程就是访问我们服务端接口来获取访问令牌。
STS 授权
在发送授权请求时,我们需要根据服务端的规则构造请求参数,规则如下图所示:
转成 Swift 代码如下:
let obj: [String: Any] = ["bucket": "mesh-ios-log", // 测试用的 bucket"applytime": Date().timeIntervalSince1970]let paramtoData = try! JSONSerialization.data(withJSONObject: obj, options: [])// 转为 Base64 编码的 json 字符串let str = paramtoData.base64EncodedString()// 再将字符串中的加号 “+” 换成中划线 “-”,并且将斜杠 “/” 换成下划线 “_”let formatedStr = str.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")// 加盐let salt = "iF0DcUu1xNj8IjTBIw02scEaZjCW1n0oYuRpWk9G"// 对待签名字符串计算 HMAC-SHA1 签名let sign = formatedStr.hmac(algorithm: .SHA1, key: salt)let param: [String: String] = ["param": formatedStr, "sign": sign]
在上面我们使用了 SHA1 算法对待签名的字符串进行签名。下面的代码是 SHA1 算法在 Swift 中的实现:
import CommonCryptoenum HMACAlgorithm {case MD5, SHA1, SHA224, SHA256, SHA384, SHA512func toCCHmacAlgorithm() -> CCHmacAlgorithm {var result: Int = 0switch self {case .MD5:result = kCCHmacAlgMD5case .SHA1:result = kCCHmacAlgSHA1case .SHA224:result = kCCHmacAlgSHA224case .SHA256:result = kCCHmacAlgSHA256case .SHA384:result = kCCHmacAlgSHA384case .SHA512:result = kCCHmacAlgSHA512}return CCHmacAlgorithm(result)}func digestLength() -> Int {var result: CInt = 0switch self {case .MD5:result = CC_MD5_DIGEST_LENGTHcase .SHA1:result = CC_SHA1_DIGEST_LENGTHcase .SHA224:result = CC_SHA224_DIGEST_LENGTHcase .SHA256:result = CC_SHA256_DIGEST_LENGTHcase .SHA384:result = CC_SHA384_DIGEST_LENGTHcase .SHA512:result = CC_SHA512_DIGEST_LENGTH}return Int(result)}}extension String {func hmac(algorithm: HMACAlgorithm, key: String) -> String {let cKey = key.cString(using: String.Encoding.utf8)let cData = self.cString(using: String.Encoding.utf8)var result = [CUnsignedChar](repeating: 0, count: Int(algorithm.digestLength()))CCHmac(algorithm.toCCHmacAlgorithm(), cKey!, strlen(cKey!), cData!, strlen(cData!), &result)let hmacData:NSData = NSData(bytes: result, length: (Int(algorithm.digestLength())))let hmacBase64 = hmacData.base64EncodedString(options: NSData.Base64EncodingOptions.lineLength76Characters)return String(hmacBase64)}}
我们获取的授权凭证都有个过期时间,当凭证失效时,客户端需要向服务器申请新的有效访问凭证,并重新构造新的 OSSClient。如果想让 SDK 自动更新授权凭证,则要求我们在 SDK 的应用中实现回调。这个回调通过我们实现的方式去获取一个 Federation Token(即StsToken),然后返回。SDK 会利用这个 Token 来进行加签处理,并在需要更新时主动调用这个回调来获取Token。完整的代码如下:
let credentialProvider = OSSAuthCredentialProvider { () -> OSSFederationToken? inlet tcs = OSSTaskCompletionSource<STS>()let obj: [String: Any] = ["bucket": "mesh-ios-log","applytime": Date().timeIntervalSince1970]let paramtoData = try! JSONSerialization.data(withJSONObject: obj, options: [])// 转为Base64编码的 json 字符串let str = paramtoData.base64EncodedString()// 再将字符串中的加号 “+” 换成中划线 “-”,并且将斜杠 “/” 换成下划线 “_”let formatedStr = str.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")// 加盐let salt = "iF0DcUu1xNj8IjTBIw02scEaZjCW1n0oYuRpWk9G"let sign = formatedStr.hmac(algorithm: .SHA1, key: salt)let param: [String: String] = ["param": formatedStr, "sign": sign]AF.request("https://test.meshkit.cn/api/oss/ststoken",method: .post,parameters: param,encoding: JSONEncoding.default,headers: ["Content-Type": "application/json"]).responseJSON { response inswitch response.result {case .failure(let error):tcs.setError(error)case .success(let data):let result = try! JSONDecoder().decode(KWResult.self, from: response.data!)tcs.setResult(result.data)}}// 需要阻塞等待请求返回tcs.task.waitUntilFinished()if let error = tcs.task.error {return nil} else {let sts = tcs.task.result!let token = OSSFederationToken()token.tAccessKey = sts.accessKeytoken.tSecretKey = sts.accessKeySecrettoken.tToken = sts.tokenreturn token}}let clien = OSSClient(endpoint: "https://oss-cn-hangzhou.aliyuncs.com",credentialProvider: credentialProvider)
文件上传
获取了授权后就可以进行文件上传了。下面是通过 URL 上传本地文件的主要代码:
let put = OSSPutObjectRequest()// 配置必填字段,其中bucketName为存储空间名称;objectKey等同于objectName,// 表示将文件上传到OSS时需要指定包含文件后缀在内的完整路径,例如abc/efg/123.jpg。put.bucketName = "mesh-ios-log"put.objectKey = "test/\(file.lastPathComponent)"put.uploadingFileURL = filelet task = self.clien.putObject(put)task.continue(successBlock: { aTask inif let error = aTask.error {print("upload error: ", error)} else {print("upload object success!")self.lock.lock()succeedCount += 1self.lock.unlock()OperationQueue.main.addOperation {progress?(succeedCount, total)}}return nil})task.waitUntilFinished()put.cancel()
上传文件比较简单,需要我们处理的东西比较少。需要注意的是进行多文件上传时要控制好子线程的数量。像我们的应用中上传的文件都是在几百张以上,如果不加控制,任由系统为我们创建子线程,将会影响应用的性能,毕竟创建线程和频繁地在多个线程中切换是要消耗系统资源的。
总结
总的来说,从外部 USB 设备拷贝文件并上传的过程并不复杂,复杂的部分主要集中在对意外情况的处理,列如:拷贝文件过程中 USB 设备与手机(iPad)断连,上传过程中部分文件上传失败。这些问题都需要在做产品的过程中去仔细去思考,并能用技术手段去解决问题。

