KeychainやUserDefaults、ファイルなどにカスタムタイプを保存する場合、NSCodingを実装しますが、NSObjectを継承しないPure swift typeのような場合そのままでは実装できません。
NSObjectを継承しないPure swift typeをNSKeyedArchiver/NSKeyedUnarchiverを使ってエンコード・デコードする場合、下記のように先人たちが色々書いているように方法があります。
内包するWrapperのNSObjectクラスにNSCodingを実装する方法をとったところ、XCTestsで動作確認も取れたので安心しきってました。
import Foundation
public protocol SecureCodable {
func encode(with aCoder: NSCoder)
init?(coder aDecoder: NSCoder)
var codingWrapper: SecureCodingWrapper<Self> { get }
// MARK: Archive helpers
func archive() -> Data
static func unarchive(with data: Data) -> Self?
}
public extension SecureCodable {
var codingWrapper: SecureCodingWrapper<Self> {
return SecureCodingWrapper(for: self)
}
func archive() -> Data {
return NSKeyedArchiver.archivedData(withRootObject: codingWrapper)
}
static func unarchive(with data: Data) -> Self? {
let codingWrapper = NSKeyedUnarchiver.unarchiveObject(with: data) as? SecureCodingWrapper<Self>
return codingWrapper?.object
}
}
public class SecureCodingWrapper<Base: SecureCodable>: NSObject, NSSecureCoding {
var object: Base?
fileprivate init(for object: Base) {
self.object = object
super.init()
}
public func encode(with aCoder: NSCoder) {
object?.encode(with: aCoder)
}
public required init?(coder aDecoder: NSCoder) {
object = Base(coder: aDecoder)
super.init()
}
public static var supportsSecureCoding: Bool {
return true
}
}
import XCTest
@testable import SharedKit
struct TestCoder {
let id: Int?
let text: String?
}
struct TestCoder2 {
let id: Int?
let text: String?
}
extension TestCoder: SecureCodable {
func encode(with aCoder: NSCoder) {
aCoder.encode(id, forKey: "id")
aCoder.encode(text, forKey: "text")
}
init?(coder aDecoder: NSCoder) {
self.id = aDecoder.decodeObject(forKey: "id") as? Int
self.text = aDecoder.decodeObject(forKey: "text") as? String
}
}
extension TestCoder2: SecureCodable {
func encode(with aCoder: NSCoder) {
aCoder.encode(id, forKey: "id")
aCoder.encode(text, forKey: "text")
}
init?(coder aDecoder: NSCoder) {
self.id = aDecoder.decodeObject(forKey: "id") as? Int
self.text = aDecoder.decodeObject(forKey: "text") as? String
}
}
class SecureCodingWrapperTests: XCTestCase {
let testCoderSources: [(id: Int?, text: String?)] = [
(id: nil, text: nil),
(id: nil, text: ""),
(id: 12345, text: nil),
(id: 12345, text: "test"),
(id: nil, text: "test")
]
override func setUp() {
super.setUp()
}
override func tearDown() {
super.tearDown()
}
func testCodings() {
print("test1")
for (id, text) in testCoderSources {
let testCoder = TestCoder(id: id, text: text)
let data = testCoder.archive()
let unarchived = TestCoder.unarchive(with: data)
XCTAssertNotNil(unarchived)
XCTAssertEqual(unarchived?.id, id)
XCTAssertEqual(unarchived?.text, text)
}
for (id, text) in testCoderSources {
let testCoder = TestCoder2(id: id, text: text)
let data = testCoder.archive()
let unarchived = TestCoder2.unarchive(with: data)
XCTAssertNotNil(unarchived)
XCTAssertEqual(unarchived?.id, id)
XCTAssertEqual(unarchived?.text, text)
}
}
}
Keychainに永続化して再起動後デコードしてみると。。。
アプリにKeychainに保存するパスワード構造体を実装し、再起動後、Keychainから読み出してデコードしてみると予期せぬ実行時エラーが発生しました。
ちなみに、NSCodingのWrapperクラスは、SharedKit.framework
に、アーカイブしたいTypeがあるのは、 AccountsKit.framework
と、別々のTargetにあります。
*** -[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (_TtGC9SharedKit19SecureCodingWrapperV11AccountsKit23EmailPasswordCredential_) for key (root); the class may be defined in source code or a library that is not linked(null)
import Foundation
import SharedKit
/// this credential struct is that provides email and password for auth provider.
public struct EmailPasswordCredential: Credential {
public var schemaVersion: Int = 0
public var authProvider: String
public var email: String
public var password: String
public var attributes: [String : Any] = [:]
public init(schemaVersion: Int = 0, authProvider: String, email: String, password: String, attributes: [String: Any]? = nil) {
self.schemaVersion = schemaVersion
self.authProvider = authProvider
self.email = email
self.password = password
if let attributes = attributes {
self.attributes = attributes
}
}
}
extension EmailPasswordCredential: SecureCodable {
public func encode(with aCoder: NSCoder) {
aCoder.encode(schemaVersion, forKey: "schemaVersion")
aCoder.encode(authProvider, forKey: "authProvider")
aCoder.encode(email, forKey: "email")
aCoder.encode(password.encrypt, forKey: "password")
aCoder.encode(attributes, forKey: "attributes")
}
public init?(coder aDecoder: NSCoder) {
let schemaVersion = aDecoder.decodeInteger(forKey: "schemaVersion")
guard let authProvider = aDecoder.decodeObject(forKey: "authProvider") as? String else { return nil }
guard let email = aDecoder.decodeObject(forKey: "email") as? String else { return nil }
guard let encryptedPassword = aDecoder.decodeObject(forKey: "password") as? String else { return nil }
guard let attributes = aDecoder.decodeObject(forKey: "attributes") as? [String: Any] else { return nil }
self.schemaVersion = schemaVersion
self.authProvider = authProvider
self.email = email
self.password = encryptedPassword.decrept
self.attributes = attributes
}
}
解決策
ログをよくみるとnamaspaceの中に番号が入っているよくわからないものも付いてますね。これってApplication SupportやDocumentディレクトリのように起動毎に変わってしまうのではと思い、調べてみると2つほど解決方法がありました。
一つは @objc(name)
アノテーションをつけてnamespaceを無視する方法ですが、NSCodingを実装できない swift structを使っているので使えません。
もう一つは NSKeyedUnarchiver/NSKeyedArchiverでアーカイブするクラスを特定のクラス名にマッピングしてしまう方法です。
下のようにarchive/unarchive時にマッピングします。
import Foundation
public protocol SecureCodable {
// [...]
}
public extension SecureCodable {
var codingWrapper: SecureCodingWrapper<Self> {
return SecureCodingWrapper(for: self)
}
func archive() -> Data {
// 追加 ---->
NSKeyedArchiver.setClassName(String(describing: type(of: self)), for: SecureCodingWrapper<Self>.self)
// <-----
return NSKeyedArchiver.archivedData(withRootObject: codingWrapper)
}
static func unarchive(with data: Data) -> Self? {
// 追加 ---->
NSKeyedUnarchiver.setClass(SecureCodingWrapper<Self>.self, forClassName: String(describing: self))
// <-----
let codingWrapper = NSKeyedUnarchiver.unarchiveObject(with: data) as? SecureCodingWrapper<Self>
return codingWrapper?.object
}
}
public class SecureCodingWrapper<Base: SecureCodable>: NSObject, NSSecureCoding {
// [...]
}
本来はクラス初期化時などに設定したいところですが、NSObject継承クラスにある、class func initialize()
がありません。
なので、毎回マッピングするようになっています。
Wrapperクラスで一度設定してみたんですが、うまくマッピングできませんでした。(Genericsの問題か?)
public class SecureCodingWrapper<Base: SecureCodable>: NSObject, NSSecureCoding {
public class func initialize() {
NSKeyedArchiver.setClassName(String(describing: Base.self), for: SecureCodingWrapper<Base>.self)
NSKeyedUnarchiver.setClass(SecureCodingWrapper<Base>.self, forClassName: String(describing: Base.self))
}
}
追記
こんなことしなくても、stringやInt, DoubleなどのRaw typeで表現できる(RawRepresentable)プロパティを持つカスタムタイプをアーカイブするだけであれば、NSCodingを実装せずともDictionaryなどに変換して直接アーカイブしてしまった方が良いかもしれないです。