LoginSignup
6
4

More than 5 years have passed since last update.

Framworkをまたぐ&NSObjectを継承しないSwift TypeをNSCodingでデコードする時の注意点

Posted at

KeychainやUserDefaults、ファイルなどにカスタムタイプを保存する場合、NSCodingを実装しますが、NSObjectを継承しないPure swift typeのような場合そのままでは実装できません。

NSObjectを継承しないPure swift typeをNSKeyedArchiver/NSKeyedUnarchiverを使ってエンコード・デコードする場合、下記のように先人たちが色々書いているように方法があります。

内包するWrapperのNSObjectクラスにNSCodingを実装する方法をとったところ、XCTestsで動作確認も取れたので安心しきってました。

SharedKit.framework/SecureCodingWrapper.swift
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
    }
}
tests.swift
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)
AccountsKit.framework/EmailPasswordCredential.swift
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つほど解決方法がありました。

Cannot decode object of class

一つは @objc(name) アノテーションをつけてnamespaceを無視する方法ですが、NSCodingを実装できない swift structを使っているので使えません。
もう一つは NSKeyedUnarchiver/NSKeyedArchiverでアーカイブするクラスを特定のクラス名にマッピングしてしまう方法です。
下のようにarchive/unarchive時にマッピングします。

SharedKit.framework/SecureCodingWrapper.swift
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の問題か?)

SharedKit.framework/SecureCodingWrapper.swift
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などに変換して直接アーカイブしてしまった方が良いかもしれないです。

参考

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4