読者です 読者をやめる 読者になる 読者になる

脱・文字列ハードコーディング

こんにちは、iOSエンジニアのにこらすです。

SwiftがiOSの主な開発言語になってから、多くの良いプログラミング習慣が標準になっています。 型安全な設計やコンパイル時のエラー検出が当たり前になりましたが、まだSwiftの型システムを活用せずに、Objective-C時代から残る慣習でランタイムエラーになりやすいところがあります。

今回の記事は、古くてインタフェースが良くないAPIをいかに現代のSwiftプロジェクトに取り入れるかという話です。 古いAPIを使う前に、拡張するかラッパークラスを作ることが必要になるかもしれません。 特にUIFontNSAttributedStringが良くないと思うので二つのライブラリを作りました。 この記事を読みながら付随するUIFontライブラリとNSAttributedStringのライブラリを参考してください。

ここに付随するライブラリがあります:

もし気になったらスターをつけると嬉しいです!

github.com github.com

例:Selector

Swift 2.1 以前は Selector はまだただの文字列で、このAPIデザインには二つのデメリットがありました。 一つはコンパイラがSelector文字列が本当に存在するメソッド名のチェックをしないため、存在しないメソッド名の場合は実行時にクラッシュしてしまいます。 もう一つの問題は、メソッド名のコード補完が効かないのでミスタイプの可能性が高くなります。

Swift 2.1までのSelector使い方:

// Swift 2.1
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .Add,
                                                    target: self,
                                                    action: "addNewMemo")

幸いにもSwiftチームがこの問題を解決してくれたので、Swift 2.2でもっと良い構文になりました。

// Swift 2.2
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .Add,
                                                    target: self,
                                                    action: #selector(addNewMemo))

Swift 2.2からSelectorはただの文字列から型になりました。 #selector(methodName)methodNameが存在しないメソッド名なら、コンパイル時にエラーを検出できます。 この新しい型のおかげでSelector系のランタイムクラッシュの可能性がなくなりました。 さらにコード補完でメソッド名が出てくるようになったので、このプログラミングインタフェースがもっと使いやすくなりました。

例:UIFont

UIFontのコンストラクタは一見簡単ですが、正しいフォント名の文字列をタイプしないといけません。 現在のUIFontコンストラクタを使ってフォントオブジェクトを作ろうとしたら下記のようになります:

let font = UIFont(name: "Arial-BoldItalicMT", size: 12.0)!

このインタフェースはあまり良くないと思います。 さきほどのSelectorと同じ問題があり、フォント名の文字列を間違っていてもコンパイル時に気付く事はできません。 UIFontFileManagerURLと違って、実行時にiOS標準のフォントの存在が保証されています。

iOS標準のフォントは有限なので、enumを使うのが良いと思います。 UIFontで使えるフォントを全てenumにすると、正しいフォント名を知らなくてもコード補完で使いたいフォントを探せます。

extension UIFont {
    /// Create a UIFont object with a `Font` enum
    public convenience init?(font: Font, size: CGFloat) {
        let fontIdentifier: String = font.rawValue
        self.init(name: fontIdentifier, size: size)
    }
}
public enum Font: String {
    
    // Font Family: Copperplate
    case copperplateLight = "Copperplate-Light"
    case copperplate = "Copperplate"
    case copperplateBold = "Copperplate-Bold"
    .
    .
    .
    // Font Family: Bodoni 72 Oldstyle
    case bodoniSvtyTwoOSITCTTBook = "BodoniSvtyTwoOSITCTT-Book"
    case bodoniSvtyTwoOSITCTTBold = "BodoniSvtyTwoOSITCTT-Bold"
    case bodoniSvtyTwoOSITCTTBookIt = "BodoniSvtyTwoOSITCTT-BookIt"
}

下記のようにUIFontを宣言できます:

let font = UIFont(name: .arialBoldItalicMT, size: 12.0)!

UIFontGif

enumを使えば、コード補完が効くようになります。もし名前が間違っていてもコンパイル時にエラーが検出できます。

例:NSAttributedString

NSAttributedStringを使ったことあるなら、不愉快な経験をしたことがあるかもしれません。 NSAttributedStringを作るとき、属性をDictionaryで指定しますが、キー名と対応する値の型を調べる必要があります。

例えば、Chalkdusterというフォントで、文字色を赤、文字に下線を引くNSAttributedStringオブジェクトを作ろうとすると、下記のようなコードになります。

let attributes: [String: Any] = [
    NSForegroundColorAttributeName: UIColor.red,
    NSFontAttributeName: UIFont(name: "Chalkduster", size: 24.0)!,
    NSUnderlineStyleAttributeName: 1,
]

let text = NSAttributedString(string: "Hello", attributes: attributes)

f:id:vasilyjp:20161227105910p:plain

attributesのDictionaryの型は [String: Any]型なので、 NSFontAttributeName: UIColor.redと書いてしまっても、コンパイル時にエラーを検出することはできません。 属性付き文字列に値を設定するたびに、そのキーの識別子を確認する必要がありますし、キーに対する値のコード補完も効かないため、正しい値を見つけることができません。

このインターフェースは現代のAPI標準と同じではなく、不便だとみなされるべきだと私は考えています。 私たちができることは、単純な薄いラッパーにこのインタフェースをラップすることです。各ラッパーは、各属性に対して明示的に定義された型を持つすべての値を設定するメソッドを持ちます。

let attributes = Attributes {
    return $0.foreground(color: .red)
             .font(UIFont(name: "Chalkduster", size: 24.0)!)
             .underlineStyle(.styleSingle)
}

"Hello".attributed(with: attributes)

ここでは、入力された値を使って各属性を設定するためのメソッドを定義しているので、どの型の値が期待されているのか正確に知ることができます。 NSUnderlineStyleAttributeNameもマジックナンバーを使用しなくてもよくなりました。 また、アンダーラインスタイルが通常の下線 (.styleSingle) であることをはっきりと見て取ることができます。

このような拡張機能のさらなる利点として、異なる属性の単語を含む属性付き文字列を作成することが簡単にできます。 属性付き文字列のための+演算子を定義すると、連結のための非常に簡単なインターフェースを作成できます。

たとえば、ユーザー名が白で強調表示され、テキストを目立たせるために特別なカーニングを使用して赤いテキストのベースを作成する場合、これは通常のNSAttributedString APIを使用して構築できます。

let attributes: [String: Any] = [
    NSForegroundColorAttributeName: UIColor.red,
    NSFontAttributeName: UIFont(name: "Chalkduster", size: 14.0)!,
]

let userName: String = "@trent"
let attributedString = NSMutableAttributedString(string: "\(userName) has commented on your post.", attributes: attributes)

let nameAttributes: [String: Any] = [
    NSForegroundColorAttributeName: UIColor.white,
    NSKernAttributeName: 4.0,
]
attributedString.addAttributes(nameAttributes, range: NSRange(location: 0, length: userName.characters.count))

このコードでも正しく動作しますが、少し不自由な感じです。 上記のコードを入力している間は、共通の属性ベースを持っていることが明らかです。フォントのような既存の属性に加えて、文字列の特定の部分に追加の属性を適用したいだけです。

最初に基本となる属性を宣言し、基本属性から派生したuserNameのみにかかる明示的な属性を作成します。

let baseAttributes = Attributes {
    return $0.foreground(color: .red)
             .font(UIFont(name: "Chalkduster", size: 24.0)!)
}

let nameAttributes = baseAttributes.foreground(color: .white)
                                   .underlineStyle(.styleSingle)
                                   .kerning(4.0)

let message = " has commented on your post.".attributed(with: baseAttributes)
let userName = "@trent".attributed(with: nameAttributes)


messageLabel.attributedText = userName + message

両方の方法で同じ結果が得られますが、後者の方がはるかに単純です。

f:id:vasilyjp:20161222164328p:plain

まとめ

今回は、文字列で値を設定するメソッドの改善方法について紹介しました。

UIKitやFoundationなどの標準の仕組みであっても、使いにくいインターフェースであれば、独自のラッパーを作っても良いと思います。 今回の記事のコード例は、より簡単に操作できる安全なAPIを設計する方法の例ですが、決して唯一の方法ではありません。

私は、今後も既存のAPIを管理しやすくするため、GitHubの既存のプロジェクトなどを見ることを楽しみにしています。

VASILYではiQONを一緒に開発してくれるiOSエンジニアを募集しています。興味がある方は以下のリンクをご覧ください。 www.wantedly.com

また次回。

にこらす