背景
以土方测量场景为例,在我们现在的数据处理流程中,首先通过采集端进行数据采集,飞行任务中的所有图片存储在飞机上的 SD 卡中。为了完成后序的建模,我们需要将采集到的图片上传至服务器。 现在的做法是,完成飞行任务后,取出飞机上的 SD 卡,通过 USB 设备连接电脑,并使用 MeshKit Studio (桌面端程序)进行上传。目前这个流程能很好地工作,但是效率并不高,拿到 SD卡后,我们必须使用一个桌面端程序才能完成图片上传,而这可能就是完成飞行任务几个小时之后了。如果能在移动设备上完成图片上传,那么就会节省很多时间,也免去了需要去熟悉另一个桌面端程序的麻烦了。
读取外部设备中的文件
Apple 为我们提供了很多简洁的 API 来管理、访问 iOS 设备中的文档,当然也包括外部 USB 设备中的文档。在 iOS 13 中我们可以显示一个 UIDocumentPickerViewController 来让用户选择文件夹:
let documentPicker = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open)
documentPicker.directoryURL = url
documentPicker.delegate = self
present(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 in
let 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 CommonCrypto
enum HMACAlgorithm {
case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
func toCCHmacAlgorithm() -> CCHmacAlgorithm {
var result: Int = 0
switch self {
case .MD5:
result = kCCHmacAlgMD5
case .SHA1:
result = kCCHmacAlgSHA1
case .SHA224:
result = kCCHmacAlgSHA224
case .SHA256:
result = kCCHmacAlgSHA256
case .SHA384:
result = kCCHmacAlgSHA384
case .SHA512:
result = kCCHmacAlgSHA512
}
return CCHmacAlgorithm(result)
}
func digestLength() -> Int {
var result: CInt = 0
switch self {
case .MD5:
result = CC_MD5_DIGEST_LENGTH
case .SHA1:
result = CC_SHA1_DIGEST_LENGTH
case .SHA224:
result = CC_SHA224_DIGEST_LENGTH
case .SHA256:
result = CC_SHA256_DIGEST_LENGTH
case .SHA384:
result = CC_SHA384_DIGEST_LENGTH
case .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? in
let 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 in
switch 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.accessKey
token.tSecretKey = sts.accessKeySecret
token.tToken = sts.token
return 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 = file
let task = self.clien.putObject(put)
task.continue(successBlock: { aTask in
if let error = aTask.error {
print("upload error: ", error)
} else {
print("upload object success!")
self.lock.lock()
succeedCount += 1
self.lock.unlock()
OperationQueue.main.addOperation {
progress?(succeedCount, total)
}
}
return nil
})
task.waitUntilFinished()
put.cancel()
上传文件比较简单,需要我们处理的东西比较少。需要注意的是进行多文件上传时要控制好子线程的数量。像我们的应用中上传的文件都是在几百张以上,如果不加控制,任由系统为我们创建子线程,将会影响应用的性能,毕竟创建线程和频繁地在多个线程中切换是要消耗系统资源的。
总结
总的来说,从外部 USB 设备拷贝文件并上传的过程并不复杂,复杂的部分主要集中在对意外情况的处理,列如:拷贝文件过程中 USB 设备与手机(iPad)断连,上传过程中部分文件上传失败。这些问题都需要在做产品的过程中去仔细去思考,并能用技术手段去解决问题。