24/7 twenty-four seven

iOS/OS X application programing topics.

UIKeyCommandを使ってバーコードリーダーの入力を受け取る

TL;DR

  • ●バーコードリーダーは外部キーボードとして扱える
  • UITextFieldなどの入力コンポーネントを使って入力を受け取れる
  • UITextFieldなどを使いたくない場合がある
  • UIKeyCommandを使うと入力コンポーネントを使わずに入力を受け取れる

ユビレジでは商品の入力に市販のバーコードリーダーを利用することができます。

一般的なBluetoothのバーコードリーダーはHID(Human Interface Device)とSPP(Serial Port Profile)の両方のプロファイルに対応しています。

HIDとして接続する場合は外部キーボードと同じ扱いになります。 外部キーボードが繋がっているのと同じなので、UITextFieldUITextViewを使って特別なSDKを必要とせずに入力を受け取ることができます。

ただし、このやり方は簡単なのですが、入力を受け取るにはUITextFieldなどの入力コンポーネントがアクティブになっている必要があるので少し不便です。

ユビレジでは商品点数の多い小売の店舗などではバーコードリーダーメインの入力として使っていますが、飲食店などではバーコードリーダーはまったく使いません。 このように利用者によって、使うところはメインの入力で使うし、使わないところはまったく使わないという状況なので、どちらにも使いやすくなっている必要があります。

そのため、バーコードリーダーの入力を受けるためにはテキスト入力エリアをタップする必要がある、というのは常時バーコードリーダーを利用するユーザーは面倒に感じるでしょう。 かといって常にテキスト入力エリアが自動的にアクティブになるようにすると、バーコードリーダーが接続されてない店舗では常にソフトウェアキーボードが表示されてしまいます。

そこで、ユビレジではバーコードリーダーの入力を受け取るのにUIKeyCommandというiOS 7から新しく追加されたAPIを利用しています。

UIKeyCommandは本来は外部キーボードが接続された状態でキーボードショートカットによる操作をサポートするためのAPIです。 任意のキー入力の組み合わせを受け取ることができるので、入力される可能性のあるすべての文字に反応するようにしておくことで、任意のキー入力を受け取る仕組みとして利用することができます。

UIKeyCommandについては前に別の記事でも書きましたのでそちらもご覧ください。

iOSアプリケーションでキーボードショートカットに対応する - 24/7 twenty-four seven

例えば一般的なバーコードリーダーの入力を受け取るには下記のように実装します。

@interface ViewController ()

@property (nonatomic) NSArray *commands;
@property (nonatomic) NSString *barcode;

@end

@implementation ViewController

- (BOOL)canBecomeFirstResponder {
    return YES;
}

- (NSArray *)keyCommands {
    if (!self.commands) {
        NSMutableArray *commands = [[NSMutableArray alloc] init];
        NSArray *characterSets = @[[NSCharacterSet characterSetWithRange:NSMakeRange(0x20, 0x7f - 0x20)],
                                   [NSCharacterSet newlineCharacterSet]];
        for (unichar i = 0x00; i < 0x7f; i++) {
            for (NSCharacterSet *characterSet in characterSets) {
                if ([characterSet characterIsMember:i]) {
                    NSString *string = [[NSString alloc] initWithCharacters:&i length:1];
                    UIKeyCommand *command = [UIKeyCommand keyCommandWithInput:string modifierFlags:kNilOptions action:@selector(handleKeyCommand:)];
                    [commands addObject:command];
                    break;
                }
            }
        }
        
        self.commands = commands.copy;
    }
    
    return self.commands;
}

- (void)handleKeyCommand:(UIKeyCommand *)command {
    NSString *key = command.input;
    NSCharacterSet *newlineCharacterSet = [NSCharacterSet newlineCharacterSet];
    if ([key rangeOfCharacterFromSet:newlineCharacterSet].location != NSNotFound) {
        NSLog(@"Barcode: %@", self.barcode);

        self.barcode = nil;
    } else {
        if (self.barcode) {
            self.barcode = [self.barcode stringByAppendingString:key];
        } else {
            self.barcode = key;
        }
    }
}

@end

まず、UIKeyCommandでキー入力を受け取るにはcanBecomeFirstResponderメソッドでYESを返す必要があります。 そしてkeyCommandsプロパティをオーバーライドして、受け取りたいキーコンビネーションのUIKeyCommandインスタンスの配列を返すようにします。

上記の例ではすべてのASCII文字と、改行文字に反応します。

これで接続されたバーコードリーダーでバーコードを読み取るたびにhandleKeyCommand:メソッドが呼ばれます。 handleKeyCommand:メソッドは1文字ずつ連続で呼ばれるので、終わりが来るまで読み取った文字列を結合します。 たいていのバーコードリーダーでは終わりに改行文字を送ってくるため、それをバーコードの終わりと判断して、そこまでを一つのバーコードと扱います。

このコードはバーコードリーダーが接続されてない場合にはまったく影響ありません。

このように、UIKeyCommandを利用すると、バーコードリーダーを利用している場合は特別な操作を必要とせずにバーコードリーダーからの入力を受け取ることができ、利用しない場合は今までどおり使えるという先述の問題をキレイに解決することができます。

マイナーなAPIですが、意外と便利で、おそらくバーコードリーダー以外にも応用できる場面はあるのではないかと思います。

Swiftで使いやすいAPIを書くために気をつけていること

先日iOSオールスターズ勉強会でSwiftでライブラリを書く際に良いと思ってることについて話しました。そこで好評だったり同意してもらえた何点かについてあらためてまとめます。

オーバーロードを積極的に活用しよう

Swiftではメソッドのオーバーロード(引数の数、型、および戻り値の型が異なる同じ名前のメソッドが定義できる)が言語仕様としてサポートされています。これは上手に使うと利用する側にとってとても書きやすくなるので積極的に使っていきましょう。

例えば下記のような例では、Objective-Cではデータ型によって複数のメソッドを使い分ける必要がありました。 (引数の型がNSStringNSDataかでメソッドの名前が異なる)

- (BOOL)setString:(NSString *)string forKey:(NSString *)key;
- (BOOL)setData:(NSData *)data forKey:(NSString *)key;

Swiftでは次のように書けるので、利用者は型の違いを意識することなく、setメソッドのみを覚えていればよいことになります。

func set(value: String, key: String) -> NSError?
func set(value: NSData, key: String) -> NSError?

下記は引数の数が違う例です。

convenience init()
convenience init(service: String)
convenience init(accessGroup: String)
convenience init(service: String, accessGroup: String)

必須でない引数を省略したメソッドを用意することで、利用するときは次のように必要なときだけ引数を渡せばよいことになります。

let keychain = Keychain()
let keychain = Keychain(service: "com.example.github-token")
let keychain = Keychain(accessGroup: "12ABCD3E4F.shared")
let keychain = Keychain(service: "com.example.github-token", accessGroup: "12ABCD3E4F.shared")

デフォルト引数とオーバーロードならオーバーロードの方がわかりやすい

Swiftではデフォルト引数(引数を指定しなかった場合、あらかじめ指定したデフォルト値が設定される)もサポートされており、オーバーロードと似たような効果を与えることができます。

例えば下記のように3つの引数を取るメソッドのうち、必須でない2つの引数にデフォルト値を与えます。

convenience init(server: String, 
                 protocolType: ProtocolType = .HTTPS, 
                 authenticationType: AuthenticationType = .HTMLForm)

そうすると次のように必須の引数以外は省略して呼び出すことができます。

let keychain = Keychain(server: "https://github.com")

ただ、私はこのような場合はオーバーロードによって同様のことを実現する方が良いと考えています。

理由は2つあって、まず一つは、Xcodeの入力補完を利用する際に、常にすべての引数を取る補完候補が表示されてしまうので、引数がオプションであることがわかりづらい点です。

もう一つは、オプション引数を省略するケースの方が一般的な使い方の場合、補完された引数を消すという行為が必要になるため書くリズムが崩れる、というのがイマイチ使い勝手を悪くすると思うからです。

↑上記のメソッドは呼び出すときにこのように補完されます。引数を省略するには補完された引数をわざわざ消す必要があります。

↑オーバーロードで同じことを実現した場合はこのように補完されます。引数を省略できるメソッドが用意されてることがひと目でわかりますし、どのように書きたい場合でも補完から選ぶことができます。

さらにデフォルト引数として何が与えられているのかという情報も、利用する段階では欠如してしまうので、省略した場合にデフォルト値として何が指定されるのか明示したい、という用途にも今のところ使えないことになります。

convenience init(server: NSURL, 
                 protocolType: KeychainAccess.ProtocolType = default,
                 authenticationType: KeychainAccess.AuthenticationType = default)

↑デフォルト値はこのようにすべてdefaultと表示されるため何が指定されるのか、わからない。

例外: コールバックとしてのクロージャはデフォルト引数で空の実装を与えよう

非同期メソッドのコールバックとしてクロージャを与えるというインターフェースはよくあります。

この場合については、オーバーロードで省略形を用意するよりは、デフォルト引数として何もしないクロージャを与えるのが良いと思います。

func setSharedPassword(password: String,
                       account: String,
                       completion: (error: NSError?) -> () = { e -> () in })

理由としては、非同期メソッドのコールバックは省略することの方が少なそうであること(そうであるなら常に複数のメソッドが補完されるのは面倒)、省略された場合でも呼び出される方でクロージャがnilかどうかをチェックする必要がないのでスッキリ書ける、などです。

keychain.setSharedPassword(password, account: username)
keychain.setSharedPassword(password, account: username) { (error) -> () in
    println(error)
}

↑上記のメソッドはこのようにコールバックを必要に応じて省略できます。省略した場合でもクロージャはデフォルト引数によって必ず渡されるので、呼び出される側の実装は常にクロージャを呼べばよいです。

戻り値のオーバーロードはあまり便利じゃない

Swiftでは戻り値の型だけが異なるメソッドもオーバーロードとして同じ名前で定義できます。

func get(key: String) -> String?
func get(key: String) -> NSData?

しかし上記のようなメソッドを用意した場合、呼び出すときにどちらのメソッドを呼び出しているのか名前だけでは区別できないため、戻り値の型を指定するか、キャストが必要になってしまいます。

↓下のように左辺の変数の型を指定するか、

let data: NSData? = keychain.get("somedata")

↓戻り値のキャストを書く必要がある。

let data = keychain.get("somedata") as NSData?

たいていの場合、気にせず書いていたらコンパイルエラーが出たので直す、ということになってリズムが狂うので良くないと考えています。

私は上記のケースではオーバーロードは使用せずにメソッドの名前を変えることにしました。

func getString(key: String) -> String?
func getData(key: String) -> NSData?

↑スマートではないですが、補完を働かせることもできるのでこちらのほうが良いと考えました。

メソッドチェーンを上手に使おう

Objective-Cの文法ではメソッドチェーンは非常にやりにくいものでした。 (チェーンするなら、まずチェーンする数のカッコを開く必要がある。足りなかったら最初に戻ってカッコを足すことになる。)

Swiftでは自然な形でメソッドチェーンが利用でき、もちろん補完も働くので、有効に使えるケースでは上手に活用しましょう。

keychain
    .accessibility(.AfterFirstUnlock)
    .synchronizable(true)
    .set("01234567-89ab-...", key: "kishikawakatsumi")
keychain
    .accessibility(.WhenUnlocked)
    .set("01234567-89ab-...", key: "kishikawakatsumi")

エラーはEither型として返すことを検討しよう

Objective-Cでは複数の戻り値を返すことはできなかったので、戻り値を返すメソッドでエラーの情報も返す必要がある場合は、NSErrorのダブルポインタを引数に渡す方法が一般的でした。

- (NSString *)stringForKey:(NSString *)key error:(NSError * __autoreleasing *)error;

Swiftでもinoutパラメータを使えば同様のことができますが、errorの変数をわざわざ用意しなくてはいけなかったり、とても使いにくい仕組みなので、別の方法を考えましょう。

単純に戻り値とエラーをタプルで返すというのもアリですが、Swiftには強力なEnumが存在するので、Enumで実装したEither型を返すことをまず検討しましょう。

Either型とはScalaやHaskellなどの関数型プログラミング言語にはたいてい組み込み型として入っている、2つの別個の型の値のどちらかを返す型です。

戻り値としてEither型を返すメソッドのエラー処理は次のように書けます。

let failable = keychain.getStringOrError("kishikawakatsumi")

switch failable {
case .Success:
    println("token: \(failable.value)")
case .Failure:
    println("error: \(failable.error)")
}

基本的にはエラー処理をしてほしいということを型として明示できますし、正常系とエラー系の分岐を自然に書けます。

タプルで戻り値とエラーの両方を返す場合と異なり、正常時のエラー、あるいはエラー時の戻り値といった条件を考慮する必要もなくなります。

非同期メソッドのコールバックではObjective-Cの時と同様にエラーオブジェクトをコールバックの引数で返せばよいかと思います。

keychain.getSharedPassword(username) { (password, error) -> () in
    if error != nil {

    } else {

    }
}

コードを公開する際にはPlaygroundを同梱しよう

ライブラリを公開する際には、READMEなどのほかにPlaygroundをセットで公開すると、すぐに動かして試すことができて非常にわかりやすいのでオススメです。

SwiftでKeychainを簡単に使うライブラリ "KeychainAccess" を書きました

kishikawakatsumi/KeychainAccess · GitHub

そろそろSwiftをちゃんと勉強しようと思って作りました。 Swiftで書かれたKeychainのラッパーの中ではもっとも高機能でかつ簡単に使えるものができたと思います。

機能としては下記を備えています

  • 簡単に使えるインタフェース
  • アプリ間のキーチェーン共有
  • アクセシビリティ(バックグラウンド動作時の制限など)属性のサポート
  • iCloudによるキーチェーンの同期
  • Touch IDによるキーチェーンの保護(iOS 8〜)
  • iOSとOS Xの両方の動作をサポート

インストール

github "kishikawakatsumi/KeychainAccess"

pod 'KeychainAccess'
CocoaPodsを使う場合、CocoaPodsのバージョンはbeta版の0.36が必要です。
Pod Authors Guide to CocoaPods Frameworks - CocoaPods Blog

  • マニュアルインストール

1. Lib/KeychainAccess.xcodeprojをプロジェクトに追加(ドラッグ&ドロップ)します。
2. KeychainAccess.frameworkをターゲットにリンクします。
3. Build PhasesにCopy Files Build Phaseを追加して、上記のKeychainAccess.frameworkをバンドルのFrameworksディレクトリにコピーするようにします。

使い方

詳しくは READMEを見てください。

基本

値の保存、取得、削除

保存

まずはじめにKeychainのインスタンスを作成します。 サービス名を指定しなかった場合は、自動的にアプリケーションのバンドルIDが設定されます。 そして、作成したキーチェーンのset(value: String, key: String) -> NSError?メソッドでキーと値を設定します。 setメソッドはNSDataを引数に取るメソッドも用意してあります。

Objective-Cでは型の違う引数によるオーバーロードはできなかったので、名前を変える必要がありましたが(例えばsetString:setDataなど)Swiftでは型違いの引数によるメソッドのオーバーロードがサポートされているので、型の違いを気にすることなく自然に書けます。

let keychain = Keychain(service: "com.example.github-token")
keychain.set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

Subscriptingも利用できます。Subscriptingについては型違いによるオーバーロードはサポートされていない(Xcode 6.0ではできていた)ので、Stringのみ受け付けます。

keychain["kishikawakatsumi"] = "01234567-89ab-cdef-0123-456789abcdef"

エラー処理

値の保存に失敗した場合はNSErrorオブジェクトが返るので、必要ならエラー処理をします。

if let error = keychain.set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi") {
    println("error: \(error)")
}
取得

値を取り出すには、getで始まるメソッド群を使用します。 戻り値の型の違いによって以下の5つのメソッドが用意されています。

get(key: String) -> String?
getString(key: String) -> String?
getData(key: String) -> NSData?
getStringOrError(key: String) -> KeychainAccess.FailableOf<String>
getDataOrError(key: String) -> KeychainAccess.FailableOf<NSData>

String型として値を取得する場合はgetもしくはgetStringを使用します。

let token = keychain.get("kishikawakatsumi")
let token = keychain.getString("kishikawakatsumi")

NSData型として値を取得する場合はgetDataを使用します。

let data = keychain.getData("kishikawakatsumi")

Swiftでは戻り値の型の違いによるオーバーロードもサポートされていますが、戻り値だけが異なるメソッドのオーバーロードは、受ける側の変数で型を明示するか、戻り値をキャストする必要があるため(そうしないとどちらの呼び出しか区別できない)、却って書きにくくなると思ったので、メソッド名自体を変えることにしました。 入力補完が使えるぶん、この方が書きやすいと思います。

エラー処理

エラーを区別(キーに対応する値が無いのかエラーが起こって取得に失敗したのか)したい場合getStringOrErrorもしくはgetDataOrErrorメソッドを使用します。

このメソッドの戻り値の型はFailableOf<String>またはFailableOf<NSData>になります。 FailableOf<T>SuccessFailureのいずれかの状態を取るenumです(ScalaやHaskellなど他の言語ではEitherとして知られています)。 FailableOf<T>の状態がSuccessなら処理は成功しています。(キーに対応する値が無い場合も成功になります。その場合、値はnilになります。 ) FailableOf<T>の状態がFailureの場合は、何らかの理由によりキーチェーンのアクセスに失敗しました。 このとき、errorオブジェクトにはエラーの詳細がNSErrorオブジェクトとして格納されます。 値は必ずnilになります。

実際の使い方は下記のようになります。

まず、getStringOrError(またはgetDataOrError)メソッドを呼び出します。

let failable = keychain.getStringOrError("kishikawakatsumi")

failableオブジェクトはSuccessまたはFailureのいずれかの状態を取ります。 Successならばvalueプロパティから実際の値が取得できます。(キーに対応する値が無い場合はnilFailureならばerrorプロパティにNSErrorオブジェクトが格納されています。(valueプロパティは必ずnil

switch failable {
case .Success:
  println("token: \(failable.value)")
case .Failure:
  println("error: \(failable.error)")
}

エラー処理についてはかなり悩んだのですが、Swiftにおいて同期処理で正常時には別の戻り値を返す必要があるという場合、Eitherを返すというのがたぶん良い方法だと思います。

非同期処理の場合はObjective-Cと同様に、コールバックのクロージャを分ける(onSuccessとonFailureなど)なり、エラーをクロージャの引数で渡すなどするのが良いかと思います。

単純に戻り値とエラーをタプルで返すという手もありますが、これはあまり使い勝手が良くなかったのでやめたほうが良いと思います。 ただし、下記にあるように現在のSwiftのコンパイラでは単にタプルを返すのに比べて多くのメモリが必要になる、という話もありますので注意が必要なケースもあります。

削除

removeメソッドを使うか、Subscriptingでnilを代入することで削除になります。 エラー処理はsetの時と同様です。

keychain["kishikawakatsumi"] = nil
keychain.remove("kishikawakatsumi")

設定を変えて保存する

アクセシビリティを変更したり、iCloudの同期を指定したりなど、項目によって設定を変えることができます。

下記はフォアグラウンド動作時のみ読み出せるようにして保存しています。 (この項目はバックグラウンド動作時は読み出せません。)

keychain
    .accessibility(.WhenUnlocked)
    .set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

下記はiCloudによる同期を有効にする例です。

keychain
    .synchronizable(true)
    .set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

メソッドチェーンによる設定の変更は、その時だけ一時的に有効になります。 共通の設定として使用する場合は、チェーンせずに変数に保管して使い回すようにします。

let keychain = Keychain(service: "com.example.github-token")
    .synchronizable(true)

keychain["kishikawakatsumi"] = "01234567-89ab-cdef-0123-456789abcdef"
keychain["hirohamada"] = "..."

Touch IDまたはパスコードで値を保護する(iOS 8〜)

iOS 8からTouch IDを使うAPIが追加されました。 同時にKeychainのAPIにもTouch IDのサポートが追加され、値を読み出すときと更新するときにTouch ID(またはパスコード)による認証を必要とすることができるようになりました。

KeychainAccessでは下記のようにaccessibilityの指定に加えてauthenticationPolicyを指定することで、その値はTouch IDによる保護が有効になります。 (authenticationPolicyで指定できるのは今の所UserPresenceだけです)

認証を必要とする可能性のある処理はバックグラウンドスレッドで実行する必要があります。 Touch ID(またはパスコード)による認証が必要な値にアクセスすると、システムが自動的にTouch IDまたはパスコードによる認証画面を表示します。 このとき、メソッドの実行はブロックされているので、認証画面を出そうとするけど、メインスレッドは止まっているという状態になってアプリケーション全体が止まってしまいます。 そのため、下記のようにバックグラウンドスレッドから実行するようにします。 (値の追加は認証を必要としませんが、すでに値が存在してそれが保護された値の場合、追加ではなく更新になるので認証が必要になります。つまり、削除以外はバックグラウンドスレッドから実行するようにしておくのが安全です。)

let keychain = Keychain(service: "com.example.github-token")

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    let error = keychain
        .accessibility(.WhenPasscodeSetThisDeviceOnly, authenticationPolicy: .UserPresence)
        .set("01234567-89ab-cdef-0123-456789abcdef", key: "kishikawakatsumi")

    if error != nil {
        // Error handling if needed...
    }
}

保護された値の取得は普通の値を取得するときと同様です。 ただし、認証を必要とするのでバックグラウンドスレッドから実行する必要があります。

let keychain = Keychain(service: "com.example.github-token")

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    let failable = keychain
        .authenticationPrompt("Authenticate to login to server")
        .getStringOrError("kishikawakatsumi")

    if failable.successed {
        println("value: \(failable.value)")
    } else {
        println("error: \(failable.error?.localizedDescription)")
        // Error handling if needed...
    }
}

削除については認証を必要としないので普通の値のときとまったく同様の書き方になります。

let keychain = Keychain(service: "com.example.github-token")

let error = keychain.remove("kishikawakatsumi")

if error != nil {
    println("error: \(error?.localizedDescription)")
    // Error handling if needed...
}

Swiftについて

Keychain APIはCのインタフェース(しかも使いにくい)しか用意されていなくて、Objective-Cでもラッパーが必要になるので、Swiftで書いたらまったくSwiftっぽくならなくて大変なだけじゃないのと思ってましたが、意外にもObjective-Cで書くよりも簡単に書けました。

例えば値を読み出すコードはObjective-Cだと下記のようになります。

NSMutableDictionary *query = [[NSMutableDictionary alloc] init];
[query setObject:(__bridge id)kSecClassGenericPassword forKey:(__bridge id)kSecClass];
[query setObject:(__bridge id)kCFBooleanTrue forKey:(__bridge id)kSecReturnData];
[query setObject:(__bridge id)kSecMatchLimitOne forKey:(__bridge id)kSecMatchLimit];
[query setObject:service forKey:(__bridge id)kSecAttrService];
[query setObject:key forKey:(__bridge id)kSecAttrGeneric];
[query setObject:key forKey:(__bridge id)kSecAttrAccount];

CFTypeRef data = nil;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &data);
if (status == errSecSuccess) {
    NSData *ret = [NSData dataWithData:(__bridge NSData *)data];
    if (data) {
        CFRelease(data);
    }
    return ret;
}

Swiftで書いた場合は下記のようになります。

var query = [String: AnyObject]()

query[kSecClass] = kSecClassGenericPassword
query[kSecMatchLimit] = kSecMatchLimitOne
query[kSecReturnData] = kCFBooleanTrue
query[kSecAttrService] = service
query[kSecAttrAccount] = key

var result: AnyObject?
var status = withUnsafeMutablePointer(&result) { SecItemCopyMatching(query, UnsafeMutablePointer($0)) }

switch status {
case errSecSuccess:
    if let data = result as NSData? {
        return data
    }
default: ()
}

変なのはSecItemCopyMatchingでポインタのポインタを引数に与えてるところだけで、それ以外は非常にスッキリと書けています。 SwiftのDictionaryNSDictionaryが透過的に扱えて、NSDictionaryCFDictionaryRefがtoll-freeブリッジということでこのように簡単に書けるのですが、これは私にとっては意外でした。

Optionalについても、このライブラリではUIKitはまったく使ってないので特に困ることはありませんでした。 サンプルコードで少しUIを書きましたが、きちんと考えればOptionalはCocoaと一緒に使っても、パズルを組み立てるような気持ち良さが感じられると思います。

そんな感じで、基本的にはやはりObjective-Cに比べると言語自体の表現力が格段に高いので、少ない記述でキレイに書くことができます。 必然的に外部に公開するインタフェースも使いやすいものが書きやすいと思います。

ただし、現状ではSwiftにはリフレクションの仕組みがほぼサポートされていないので、メタプログラミングによって簡単に使えるAPIを提供する、ということはできません。

また、コンパイラはまだまだ未完成なようで、このライブラリを書いてる間に2つほどコンパイラがクラッシュするコード[1][2]を見つけましたし、本来なら型推論で書かなくて済むはず、というコードが実際は型やパラメータを明示する必要があったり、などという動作が散見されました。

また、コードの書き方によってはリリースビルド(コンパイラの最適化が働く)のときだけキーチェーンの値が取れないという問題もあったので、SwiftがObjective-C並みに安定するまでにはまだしばらくかかりそうです。

しかし、やはり言語による強力な表現のサポートは魅力的で、実際に少ない記述で美しく書けるので、使えるところでは積極的に使っていって、フィードバックをどんどんすべきかと思います。

Swiftの言語仕様はObjective-Cに比べるとそれなりに巨大ですが、まったく新しく設計された言語ということで一貫性もあるし、作りながら1週間もやればだいたいなんとかなるんじゃないでしょうか。

ただ個人的にはObjective-C 3.0としてGenericsと賢いenumとstructを入れる、、、とかから始めたら良かったんじゃないのと思ってたりします。

Travis CIでビルドごとにiTunes ConnectのValidationを自動的に実行する

コマンドラインからiOSアプリケーションをiTunes Connectにアップロードする - 24/7 twenty-four seven

↑ こちらの記事で書いたように、コマンドラインからiTunes Connectへのアップロードや、バリデーションができるのを利用して、Travis CIを使ってビルドするたびに自動的にバリデーションを実行するようにしました。

これにより、プライベートAPIを利用していたり、必須なサイズのアイコンやLaunchImageが無いなどの理由でバリデーションエラーになってアップロードが失敗するということが未然に防げます。

ARCを使う場合、ヘッダに載っていないメソッドを呼ぶのはコンパイルエラーになるので、知らずにプライベートAPIを使ってしまうようなことは現在はほぼありません。 しかし、iTunes Connectのバリデーションはあまり賢くないので(おそらく単純な文字列のマッチング?)たまたまプライベートAPIと同名のメソッドを全然関係ないクラスに定義してしまった場合などでもエラーになることがあります。

通常なら申請する段階になって初めてわかるので、エラーの原因がサードパーティのライブラリにある場合など、すぐには直せずリリースを延期せざるを得ないこともあります。

バリデーションはAdHocの署名では失敗するようなので、AppStoreの署名でビルドしてバリデーションをするタスクを追加することにしました。 実際のコマンドは下記になります。

script:
  - bundle exec rake $(ACTION)
env:
  matrix:
    - ACTION="profile:install certificate:add distribute:CONFIG certificate:remove"
    - ACTION="profile:install certificate:add version:bump:patch validate certificate:remove"
    - ACTION=test

バリデーションのタスクは上から2番目のこのコマンドです。

profile:install certificate:add version:bump:patch validate certificate:remove

プロビジョニングプロファイルに署名をする必要があるので証明書を追加と、既存のバイナリとバージョン番号が異なっている必要があるのでバージョン番号の末尾を自動的にあげる処理を前処理として入れています。

実際のバリデーションを行うコマンドは下記になります。

task :validate do
  build_xcarchive(configuration: "Release")
  clean_ipa
  sh %[xcodebuild -exportArchive -exportFormat IPA -archivePath "#{ARCHIVE_FILE}" -exportPath "#{IPA_FILE}" -exportProvisioningProfile "Ubiregi App Store" | xcpretty -c; exit ${PIPESTATUS[0]}]
  validate_ipa(IPA_FILE)
end
def validate_ipa(ipa_file)
  sh %['/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework/Support/altool' --validate-app --file "#{ipa_file}" --username ENV["DEV_APPLE_ID"] --password ENV["DEV_PASSWORD"]]
end

xcarchiveからipa形式にエクスポートして、そのipaファイルをaltoolに指定しています。

以下はわざとプライベートAPIを使うようにして失敗させた時のTravis CIのログです。

▸ Compiling UBDashboardCheckoutDetailCell.xib
▸ Compiling UBDataTransferViewController.xib
▸ Processing Ubiregi2-Info.plist
▸ Touching Ubiregi2.app
▸ Signing /Users/travis/Library/Developer/Xcode/DerivedData/Ubiregi2-gavhqalfhdytsueqelwxtefkbbio/Build/Intermediates/ArchiveIntermediates/Ubiregi2-Release/InstallationBuildProductsLocation/Applications/Ubiregi2.app
▸ Touching Ubiregi2.app.dSYM
xcodebuild -exportArchive -exportFormat IPA -archivePath "/Users/travis/build/ubiregiinc/ubiregi-client/build/Ubiregi2.xcarchive" -exportPath "/Users/travis/build/ubiregiinc/ubiregi-client/build/Ubiregi2.ipa" -exportProvisioningProfile "Ubiregi App Store" | xcpretty -c; exit ${PIPESTATUS[0]}
'/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework/Support/altool' --validate-app --file "/Users/travis/build/ubiregiinc/ubiregi-client/build/Ubiregi2.ipa" --username [DEV_APPLE_ID] --password [DEV_PASSWORD]
2014-11-02 11:16:00.289 altool[4121:d07] *** Error: (
    "Error Domain=ITunesConnectionOperationErrorDomain Code=1350 \"Your app contains non-public API usage. Please review the errors, correct them, and resubmit your application.\" UserInfo=0x7fd788f56f40 {NSLocalizedRecoverySuggestion=Your app contains non-public API usage. Please review the errors, correct them, and resubmit your application., NSLocalizedDescription=Your app contains non-public API usage. Please review the errors, correct them, and resubmit your application., NSLocalizedFailureReason=iTunes Store operation failed.}",
    "Error Domain=ITunesConnectionOperationErrorDomain Code=50 \"The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:\" UserInfo=0x7fd788f39310 {NSLocalizedRecoverySuggestion=The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:, NSLocalizedDescription=The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:, NSLocalizedFailureReason=iTunes Store operation failed.}",
    "Error Domain=ITunesConnectionOperationErrorDomain Code=-19000 \"If you think this message was sent in error and that you have only used Apple-published APIs in accordance with the guidelines, send the app's nine-digit Apple ID, along with detailed information about why you believe the above APIs were incorrectly flagged, to appreview@apple.com. For further information, visit the Technical Support Information page at http://developer.apple.com/support/technical/.\" UserInfo=0x7fd788d88170 {NSLocalizedRecoverySuggestion=If you think this message was sent in error and that you have only used Apple-published APIs in accordance with the guidelines, send the app's nine-digit Apple ID, along with detailed information about why you believe the above APIs were incorrectly flagged, to appreview@apple.com. For further information, visit the Technical Support Information page at http://developer.apple.com/support/technical/., NSLocalizedDescription=If you think this message was sent in error and that you have only used Apple-published APIs in accordance with the guidelines, send the app's nine-digit Apple ID, along with detailed information about why you believe the above APIs were incorrectly flagged, to appreview@apple.com. For further information, visit the Technical Support Information page at http://developer.apple.com/support/technical/., NSLocalizedFailureReason=iTunes Store operation failed.}"
)
rake aborted!
Command failed with status (3): ['/Applications/Xcode.app/Contents/Applicat...]
/Users/travis/build/ubiregiinc/ubiregi-client/Rakefile:202:in `validate_ipa'
/Users/travis/build/ubiregiinc/ubiregi-client/Rakefile:198:in `block in <top (required)>'
Tasks: TOP => validate
(See full trace by running task with --trace)

The command "bundle exec rake $(echo ${ACTION} | sed -e "s/CONFIG/${CONFIG}/g")" exited with 1.

Done. Your build exited with 1.

エラーが3つ返ってきていますが、いずれも非公開のAPIを使用していることについてのエラーです。 エラーのうちのこの部分に直接の原因が書いてあります。

The app references non-public selectors in Payload/Ubiregi2.app/Ubiregi2: _terminateWithStatus:

_terminateWithStatusUIApplicationで定義されているメソッドですが、iTunes Connectはあまり賢くないので、同じ名前のメソッドをうっかり他のクラスに定義してしまったとしても同様のエラーでバリデーションが失敗します。

このように「ついうっかり」プライベートAPIとして検出されるメソッドやプロパティを定義してしまっていたり、サードパーティのライブラリがエラーとして検出されてしまう、などという問題が、申請時になって発覚するのは大変なことなので、事前にわかるのはかなり便利ではないかと思います。

コマンドラインからiOSアプリケーションをiTunes Connectにアップロードする

参考
TL;DR

Xcode(厳密にはApplication Loader)に付属するiTMSTransporterまたはaltoolを使います。 上記のリンク先を見ればだいたいわかります。

altoolのほうが直感的なコマンドで簡単です。

iTMSTransporterはそもそもゲームのアイテムなど大量のIn-App Purchaseのメタデータを一括更新するためのソフトウェアで、POSTするデータはXMLで用意しなければならないなど、単に申請するアプリケーションをアップロードするという目的には少し面倒です。

altoolでアプリケーションをアップロードする

altoolはXcodeに付属しています。 --helpオプションを付けて実行すると表示される使い方を見ればだいたいわかると思います。

$ `/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/Frameworks/ITunesSoftwareService.framework/Support/altool` --help
Copyright (c) 2009-2014, Apple Inc.  Version 1.0

Usage: altool -v -f file -u username -p password
       altool --upload-app -f file -u username -p password

 -f, --file                Filename.
 -u, --username            Username. Required to connect for validation and upload.
 -p, --password            Password. Required if username specified.

 -v, --validate-app        Validate an archived app. The username, password, and file path to app archive are required.
     --upload-app          Uploads the given app.  The username, password, and file path to app archive are required.

 -h, --help                Display this output.

 
ipaファイルをiTunes Connectにアップロードするには下記のようにオプションを構成します。

$ altool --upload-app -f build/AppName.ipa -u USERNAME -p PASSWORD

 
--upload-appオプションを--validate-appまたは-vにすると、検証(プライベートAPIの有無やLaunch ImageやアイコンがDeploymentTargetと矛盾しないかどうかなどのチェック)だけしてアップロードは行いません。

--validate-appはCIなどで自動的に実行するようにしておくと良いと思います。

$ altool -v -f build/AppName.ipa -u USERNAME -p PASSWORD

 

iTMSTransporterでアプリケーションをアップロードする

まずApp Store packageを作る必要があります。 App Store packageは.itmspという拡張子を持つディレクトリです。 App Store packageにはそのアプリケーションのメタデータをmetadata.xmlという名前のファイルで格納します。

上述したIn-App Purchaseのメタデータをアップロードするのは項目がたくさんあって大変なのですが、アプリケーションのアップロードに必要なのは下記のようにAppID、ファイル名とサイズ、およびチェックサムがあればいいようです。

<?xml version="1.0" encoding="UTF-8"?>
<package version="software4.7" xmlns="http://apple.com/itunes/importer">
    <software_assets apple_id="123456789">
        <asset type="bundle">
            <data_file>
                <file_name>AppName.ipa</file_name>
                <checksum type="md5">8ef4f4c855a4d16d5710075c2caa6c85</checksum>
                <size>1174126</size>
            </data_file>
        </asset>
    </software_assets>
</package>

 
ipaファイルはApp Store package、つまりmetadata.xmlと同じ階層に格納します。 App Store packageがmybundle.itmspという名前ならば、ファイル構成は次のようになります。

$ tree mybundle.itmsp/
mybundle.itmsp/
├── AppName.ipa
└── metadata.xml

0 directories, 2 files

 
あとは必要なオプションとともに実行するだけです。 パッケージをアップロードはするには、-mオプションを使って動作モードをアップロードモードに指定します。

'/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/bin/iTMSTransporter' -m upload -f mybundle.itmsp -u USERNAME -p PASSWORD -v detailed

 

以前の方法

以前のValidationコマンドを使う方法はXcode 6からはerror: Unknown application extension '.' - expected '.app' or '.ipa'というエラーが出てしまうので使えなくなったようです。

xcrun -sdk iphoneos Validation -online -upload -verbose build/AppName.ipa 

 

iTunes Connectで必要な手続き

以前のiTunes Connectではバイナリのアップロードするには、アプリケーションの状態をReady to Uploadの状態にしておく必要がありましたが、現在のiTunes Connectではバージョン番号とビルド番号(CFBundleShortVersionStringとCFBundleVersion)さえ異なっていればいつでも任意のビルドをアップロードできるようです(審査中でも)。

 
アップルのTestFlightは自動化できないからワークフローに載せにくいと思っていましたが、アップロードが自動化できるのならけっこう使いものになるんじゃないかと考えています。

Travis CIでiOSアプリのリリース作業を自動化する

この記事において利用している.travis.ymlRakefileの全体はGistにて公開しています。


↓ Rakefileの全体はこちら


↓ .travis.ymlはこちら

概要

ユビレジではiOS アプリを申請する際に発生する作業の大部分をCIで自動化しています。

申請の作業としてユビレジでは下記のワークフローを決めています。


1. リリースブランチを作る
2. リリースするバージョンのバイナリをビルドする
3. 2と同等のアプリケーションを社内に配布して最終チェックをする
4. クラッシュレポートのサービスとしてCrittercismを利用しているので、そこにデバッグシンボル(dSYM)をアップロードする
5. 2のバイナリをiTunes Connectにアップロードする


図にすると下記のようになります。
手順としては難しいものではないのですが、定期的に発生する作業としてはかなり面倒な部類のものです。

20141022124901


また、3のチェックで不具合が見つかった場合は修正して再度やり直しになるので、できるだけ簡単にしたいところです。
さらに、リリースビルドが正しい構成でビルドされているかの確認はパッと見ただけではわからないので、各自の環境でビルドしている場合、それがデバッグ構成になってないかどうかなどを気をつけるのがけっこうストレスだったりします。
そして、この作業は手作業でやるぶんには分割しにくいので(TestFlightの登録だけ手伝います、って言われても別に楽にならない)、1人の時間をけっこう取ってしまうというのも問題です。


そこで、ユビレジではこの一連の作業のうち、下記の点線で囲んだ部分、iTunes Connectにアップロードする以外の作業をすべてCIで自動化しています。

20141022130752


そうすると、実際の行われているワークフローは下記のように変わります。


1. リリースするブランチ(普通はmaster)からreleaseで始まる名前のブランチを派生する(例:release/3.0.0)
(Travis CIによって)
a. リリースビルドが作られる
b. デバッグシンボルドがCrittercismにアップロードされる
c. リリースビルドがAdHoc署名されてTestFlightで配信される
d. リリースビルドがGithub Releasesに登録される
2. 該当ののリリースをダウンロードしてXcodeから申請する


ここで実際に手作業で行う必要がある作業は太字で強調した2つだけです。

自動化の仕組み

リリースビルドの作成

ここから実現方法について説明します。
まず、リリースビルドを自動で作る仕組みは.travis.ymlとrakeのスクリプトで実現しています。

(略)
script:
  - '[ ! -z $(echo ${TRAVIS_BRANCH} | grep "^release.*$") ] && CONFIG=release || CONFIG=adhoc'
  - echo ${TRAVIS_BRANCH}
  - echo ${CONFIG}
  - bundle exec rake $(echo ${ACTION} | sed -e "s/CONFIG/${CONFIG}/g")
(略)
  matrix:
    - ACTION="profile:install certificate:add distribute:CONFIG certificate:remove"
    - ACTION=test


.travis.ymlは上記のようになっていて、ブランチ名が"release"で始まるかどうかでCONFIG変数の値が変わります。
CONFIGの値はrakeのタスクにそのまま渡されて、その値によってリリース版かベータ版のどちらの構成でビルドするかが決定されます。


Rakefileの該当部分は下記になります。

def archive(configuration: "Release")
  build_xcarchive(configuration: configuration)

  clean_ipa
  export_ipa(configuration: configuration)

  zip_artifacts

  if configuration == "Release"
    upload_artifacts
  end
end


内容を1つずつ見ていきます。
build_xcarchiveでは下記のコマンドが実行されます。
(見やすさのため絶対パスを相対パスに書き換えています。以下同様)

xcodebuild -sdk "iphoneos"
           -workspace "./Ubiregi2.xcworkspace"
           -scheme "Ubiregi2-Release"
           -configuration "Release"
           CONFIGURATION_BUILD_DIR="./build"
           CONFIGURATION_TEMP_DIR="./build/temp"
           CODE_SIGN_IDENTITY="iPhone Distribution: Ubiregi Inc. (X123456YZ)"
           archive
           -archivePath "./build/Ubiregi2.xcarchive"


これでbuildディレクトリにUbiregi2.xcarchiveが生成されます。
このとき実行されたコマンドはTravis CIのログから後で確認できるので、デバッグ構成でビルドしてないだろうか?などという不安を抱えることはなくなります。

リリースビルドを元にAdHoc版を作成する

clean_ipaは以前のipaファイルがあったら次のコマンドが失敗するので消すメソッドです。手元で実行するときのためのものです。
export_ipaで今作った「Ubiregi2.xcarchive」をipaファイルに変換します。
このipaファイルを作るのは社内確認用にTestFlightにアップロードするためなので、ここで同時にAdHocプロビジョニングプロファイルで再署名します。


export_ipaで実行される実際のコマンドは下記になります。

xcodebuild -exportArchive
           -exportFormat "IPA"
           -archivePath "./build/Ubiregi2.xcarchive"
           -exportPath "./build/Ubiregi2.ipa"
           -exportProvisioningProfile "Ubiregi Ad Hoc"


これで先ほどビルドしたものと全く同じバイナリが、署名だけ変えて作成されます。
「Ubiregi2.xcworkspace」を単にipaに再パッケージしているだけなので、実行バイナリは完全に同一です。
こうすることによって、署名以外はストアで配布されるものと全く同じアプリケーションを社内で確認することができます。

成果物をzipにまとめる

xcarchiveはディレクトリなのでそのままでは不便なので次のzip_artifactsメソッドでzipファイルにまとめられます。
ここでdSYMディレクトリもついでにzipファイルにしてしまいます。

実際のコマンドは下記です。

(cd ./build; zip -ryq Ubiregi2.app.dSYM.zip Ubiregi2.app.dSYM)
mv ./build/Ubiregi2.app.dSYM ./build/Ubiregi2.xcarchive/dSYMs/Ubiregi2.app.dSYM
(cd ./build; zip -ryq Ubiregi2.xcarchive.zip Ubiregi2.xcarchive)
成果物を外部にアップロード

TestFlightとCrittercismへのアップロードについては以前に書いたものと同様なので、リリースビルドをGithubのReleasesにアップロードする部分を紹介します。

upload_artifactsメソッドは下記のコマンドになります。

curl -sSf
     -d "{\"tag_name\":\"v3.0.0-4321_2014-10-22T06-01-18Z\",
         \"target_commitish\":\"master\",
         \"name\":\"v3.0.0-4321_2014-10-22T06-01-18Z\",
         \"body\":\"Build: 3.0.0 (4321)\\nUploaded: 2014/10/22 15:01:18\\n\",
         \"draft\":false,
         \"prerelease\":false}"
         "https://api.github.com/repos/ubiregiinc/client-releases/releases?access_token=GITHUB_ACCESS_TOKEN"

curl -sSf
     -w "%{http_code} %{url_effective}\n"
     -o /dev/null
     -X POST https://uploads.github.com/repos/OWNER/REPO/releases/RELEASE_ID/assets?name=Ubiregi2-3.0.0-4321.xcarchive.zip
     -H "Accept: application/vnd.github.v3+json"
     -H "Authorization: token "
     -H "Content-Type: application/zip"
     --data-binary @"./build/Ubiregi2.xcarchive.zip"


Releasesへのアップロードは2段階で行います。
まず、最初のリクエストで該当のバージョンのReleaseを作成します。

そのリクエストの戻り値から、作成したReleaseのIDが取れるので、そこに向けてzipファイルをアップロードします。


ここまでで、以下の手順が自動的に完了します。

a. リリースビルドが作られる
b. デバッグシンボルドがCrittercismにアップロードされる
c. リリースビルドがAdHoc署名されてTestFlightで配信される
d. リリースビルドがGithub Releasesに登録される


TestFlightで配信されたアプリケーションを社内で検証して、問題があれば、修正して同じブランチにプッシュするだけで自動的にこの手順が再実行されます。

手戻りによる追加の手間はありません。

申請作業について

最終チェックが無事にすんだら申請です。

本当ははここが最も自動化したいところなのですが、iTunes ConnectはAPIなど提供されていないので、仕方なく手作業で行います。

Github Releaseからリリースする版のxcarchiveをダウンロードします。

20141022130752


zipファイルを展開すると、Xcodeで作成したのと同様のxcarchiveパッケージになるので、それをダブルクリックするとXcodeのオーガナイザに表示されます。

20141022130752


20141022130752


あとはSubmitボタンを押して申請すれば完了です。

おまけ(リリースブランチの作成)

ここまで自動化すると、トリガーとなるリリースブランチを作る作業がだんだん面倒に思えてきます。
(ブランチを派生させてInfo.plistのバージョンを書き換えて、など)
ただ、リリースブランチは再度修正が入ることもあるので、ガチガチにしてしまうとかえって面倒になるので、リリースブランチを簡単にプッシュできるrakeタスクを用意するくらいにとどめています。

リリースブランチを作るにはリリースしたいブランチ(普通はmaster)で

$ rake release

というタスクを実行します。
バージョンの更新についての選択肢が表示されるのでどれか1つを選ぶと、Info.plistの更新、リリースブランチの作成、リモートリポジトリにプッシュ、までをやってくれます。
(するとこれをトリガーとして自動的にCIが上述のビルドを開始します)

$ rake release
Select new version [current: 3.0.0 (4146)]:
1. 4.0.0 (4321)
2. 3.1.0 (4321)
3. 3.0.1 (4321)
4. 3.0.0 (4321)
?  

まとめ

リリースを自動化するのは様々なメリットがあります。
繰り返しの手作業が減ることや品質が一定に保てることももちろんですが、誰でもその作業ができるようになるとか、作業を分割できるようになるというのも大きなメリットです。
例えばリリースするコードをFixする作業(リリースブランチの作成)と社内への告知、iTunes Connectへのアップロードはそれぞれ別のひとがやることが可能になります。

自動化しなくても担当を分けることは可能ですが、手作業でやっていると互いに待ちの時間が発生したりバイナリの受け渡しの手間など、分業することで逆に面倒になります。

ということでユビレジで行っている自動化の仕組みを紹介しました。
参考になればいいなと思います。

親指シフトで日本語入力ができるカスタムキーボード「N+Keyboard」をリリースしました

20140918014637

N+Keyboard


20140918014637


20140918014637


iOS 8からサードパーティのキーボードを利用できるようになりました。

以前にも親指シフトキーボードの使えるエディタとしてN+Noteというものをリリースしましたが、当然そのアプリでしか使えないという制限がありました。

しかし、これからはどんなアプリでも好きなキーボードを使うことができるようになります。

正直、現状のAPIを使って日本語変換を実装するのはなかなか困難なのですが、やってやれないことはないのでいろいろなキーボードがリリースされて、APIも機能拡張が進んでいけばいいなと思います。


親指シフトを使ってるひとはぜひ(安い価格ではないですが)ダウンロードして使ってみてください。


カスタムキーボードはアプリケーションをダウンロードするだけでは使えるようになりません。
設定アプリからキーボードを有効にする必要があります。


「設定」アプリから「一般」>「キーボード」>「キーボード」>「新しいキーボードを追加」選択すると、標準のキーボードの他に、利用可能なサードパーティのキーボードが表示されるので選択します。
さらに、現行の版では日本語変換にインターネットアクセスが必要なので、その後で「フルアクセスを許可」というスイッチをONにしてください。
インターネットが不要な日本語変換もメドはついているので、近い将来バージョンアップにて対応したいと考えています。

20140918014449 20140918014449 20140918014449 20140918014449 20140918014449


N+Noteについても引き続き開発していきますのでよろしくお願いします。


20131107010430 N+Note for NICOLA - Katsumi Kishikawa

iOS 8のすべてのエクステンションはオプトイン方式

↓ 昨日Todayウィジェットについて書いた記事についてのコメントに、通知センターがスパムコンテンツだらけになるんじゃないの?と心配されてる方が少しいらっしゃいました。

通知センターから今日やるアニメをサッと確認できる「今日のアニメ」をリリースしました - 24/7 twenty-four seven


現状の仕組みではそれについてはそれほど心配はいらないと思います。
(通知センターに勝手に入る心配はないということであって、スパムアプリが氾濫しないという意味ではないです。
あと、特にTodayというセマンティクスに合ってない単なるランチャーのウィジェットも通っているので今のところ審査はそれほど厳格じゃないんじゃないかなと思います。
私個人としては、通知センターは単にアクセスのよい場所という認識なので、そこに置くのが便利なのであればランチャーでも何でもアリかなと思っています。)


エクステンション(Todayウィジェットに限らずカスタムキーボードや、Photo Editingなどすべてのエクステンション)はオプトイン方式です。


ユーザーが自分でそのエクステンション有効にしない限り、アプリケーションをインストールしたら勝手に通知センターのところに現れるということはありません。(カスタムキーボードなども同じ。ホストアプリケーションをインストールした後で、そのキーボードを有効にする必要がある)

上記以外のエクステンション (Share, Action, Photo Editing, Document Provider) についても、それぞれ微妙に異なりますが、ユーザーが有効にする設定をしないといけないというところは同様です。

そして、どのエクステンションも、有効にする設定はなかなか奥深くにあるので、ユーザーがその操作をしなければならないというハードルを超えて、使ってもらうまでに至るには、かなり魅力的な機能が無いと難しいと思います。


以下、それぞれのエクステンションを有効にするための操作を示します。
アップルが意図的にそうしているのかは不明ですが、どのエクステンションも普通に使ってて自然と気づくような操作にはなってないことがわかると思います。
この設定のハードルのこともあり、ただ便利なものを作っただけでは、使ってもらうのはなかなか厳しそうだと私は思っています。

Today

通知センターを表示して、画面下部の「編集」ボタンを押すとインストールされているウィジェットの一覧が表示されるので、そこから有効にするウィジェットを選択します。

20140918014449 20140918014449

Custom Keyboard

エクステンションの中で最も有効にするまでの操作が複雑です。
「設定」アプリから「一般」>「キーボード」>「キーボード」>「新しいキーボードを追加」選択すると、標準のキーボードの他に、利用可能なサードパーティのキーボードが表示されるので選択します。
さらに、日本語変換にインターネットアクセスが必要な場合は、その後で「フルアクセスを許可」というスイッチをONにしてもらう必要があります。

20140918014449 20140918014449 20140918014449 20140918014449 20140918014449

Photo Editing

「写真」アプリを開いて任意の写真を表示したところで「編集」を押します。
その時、エクステンションが利用可能であれば左上に丸いボタンが表示されているのでそれを押すと、有効になっていればエクステンションが表示されます。
新しくインストールしたエクステンションを有効にするにはさらに「その他」ボタンを押して表示される画面から有効にするエクステンションを選択します。

20140918014449 20140918014449 20140918014449

Share, Action

共有およびアクションメニューの「その他」を選択して表示される画面から有効にする必要があります。

20140918014449 20140918014449 20140918014449

Document Provider

良い例がなかったので割愛

通知センターから今日やるアニメをサッと確認できる「今日のアニメ」をリリースしました

20140918014637

iTunes の App Store で配信中の iPhone、iPod touch、iPad 用 今日のアニメ


※ 通知センターに表示できるのはiOS 8を使っている場合だけです

iOS 8から通知センターに任意のウィジェットを表示することができるようになりました。
通知センターといえば、ロック画面、ホーム画面に次ぐ一等地であり、そこをほぼ自由に使える存在というのはかなりすごいことで(ホーム画面はそもそも開放されてない、ロック画面は制限付き(通知とPassbook, iBeaconなど))、間違いなく戦争が始まるので、今のうちにいろいろ確認しておくべきだろうと考えて作ってみました。


20140918014449


↑ アプリケーションとしては通知センターに今日放映されるアニメ番組表を表示するというものです。
どこからでも(ロック画面からでも)サッと呼び出せるので、まあ必要なひとにはそれなりに便利かと思います。


で、まあせっかく新しいAPIを使うのでいろいろ実験してみました。
詳しいことはおいおい書いていこうと思いますので、ここでは簡単に説明します。

1. 使える画面の高さには限界がある

横の幅はもちろんデバイスのサイズに制約を受けますが、縦の長さは自由にいくらでもできるわけではありません。

iPhone 5/5sで60ポイント前後、iPhone 4sで55ポイント前後です。
正式にドキュメント化されてるわけではないので、若干の余裕をみて設計しておいたほうがよいでしょう。

ちなみにiPhone 6 Plusだとホーム画面が横を向くのでiPadのように横向きで通知センターを出すことができます。
そのときに使用できる縦の長さが実は一番短くて、40ポイント前後です。
なのでiPhone 4でギリギリ表示されるからOKという設計をしてしまうと、iPhone 6 Plusで横向きに表示した時に切れてカッコ悪いことになったりします。
iPadでの表示はサイズを気にするようなことはほぼ起こらないと思うので調べてません。

上記の数字は間違いです。ソースコードを見ながら書いていたのですが、1時間ぶんの長さの定数を書いてしまっていました。
実際にはこのアプリは6時間ぶん+ヘッダを一度に表示するので、計算すると、iPhone 5/5s (4インチ) のタテ画面で約400ポイント、iPhone 4s (3.5インチ) で約360ポイント、iPhone 6 Plusのヨコ画面で約300ポイントが最大の高さになります。

2. スクロールは一切できない

左右のスワイプは通知との切り替えに使用されていることもあってできません。
縦のスクロールはできるんじゃないかと思いましたが、それもできません。
(スクロールビューを置いてもスクロールしない。イベント自体が発生しない?)


ただし、後述しますが、タッチイベントやボタンは一般のビューと同様に使えるので、アクションによって画面を切り替えたりすることで擬似的に画面ぶん以上の情報を表示することは可能です。

3. キーボードの入力はできない

これはドキュメント化されてますしWWDCのセッションでも制限事項として言われていたのでご存知と思います。
テキストを入力させたりはできないので、リードオンリーで設計するか、簡単なボタンだけでできることを考えるべきです。

ただし、タッチイベントは普通に取れるので、すごく上手にハンドリングすれば、フリーハンドで字を書いたり、タップの組み合わせで高度な入力もやってやれないことはないと思います。

4. 使えないコンポーネントがある (MKMapView)

Widgetの作り方は大ざっぱに言うと、Widget用のビューコントローラが提供されるので、その上にビューをおいたり通信してデータを取得したりして、作ります。
そのビューコントローラのビューの上で全部やらなければならない、という以外は普通のアプリを作るのと一緒です。

なので、何でもできるぞ、と思っていろいろ考えてたのですが、使えないコンポーネントがありました。
地図を表示するMKMapViewです。

Beta版で試したときの結果なので、今はもしかしたらできるかもしれませんが、そのときはダメでした。
標準のMapがダメならサードパーティのはどうかと思って、Yahoo Maps SDKを試しましたがそちらもダメでした。
ただ、MKMapViewが何も言わずに異常終了するのに比べて、Yahoo Maps SDKの場合はメモリ不足というエラーがでていたので、モノによっては使えるものがあるかもしれません。

5. WebViewは使える

いろいろ試した中でおもしろかったのは、WebViewが使えることです。

20140918021434


テキスト入力ができないので、実用的にするのはかなり難しいですが、複雑なレイアウトをHTMLで組むというのもアリかもしれません。いちおうリンクもクリックできたりできなかったり微妙な動きでしたが、できなくはなかったです。

6. カスタムフォントが使える

20140918022454


↑ こちらのスクリーンショットで、上半分の番組の表示と下半分の表示で使われているフォントが異なるのがわかりますか?
どうしてもチャンネルが多くなってくるとiPhoneの小さい画面に収めるのが難しくなるのですが、それでもなんとか多くのテキストを表示できるように、幅によって細いフォントで表示するようにしています。

このフォントはもちろん標準では入っていないので、2/3角フォントというのをバンドルしてカスタムフォントとして使用しています。

普通のアプリでカスタムフォントを利用するのと同じやり方で使用できます。

7. マージンをゼロで画面いっぱい使っても審査に通る

このウィジェットは番組表アプリという性格上、小さい領域に非常に多くのテキストを表示する必要がありました。
そのため、標準のマージンを守っていてはまったく実用的ではないと思ったので、思い切ってマージンをゼロにして限界いっぱい画面を利用することにしました。

これについて、どんなアプリでも通るかはわかりませんが、今回は特に問題ありませんでした。

8. ボタンによって画面を切り替えても審査に通る

上述したように画面をめいっぱい使っても24時間の番組表を表示するには足りません。
しょうがないので、24時間はあきらめて、半分の12時間だけを表示するようにしました。
(重要なのは夕方から深夜なので真っ昼間と早朝は捨てた)

ただ、12時間にしてもわかるように表示するのには前述の高さ制限により、高さが足りないため6時間ずつ2画面を切り替えて表示するようにしました。

画面の切替は、上部のボタンを使って行います。
太陽のボタンを押せば夕方(16〜22時)の、月のボタンを押せば深夜(22〜4時)の時間帯の番組表を切り替えて表示します。

20140918022454


これも特に何も言われなかったのでOKなのかなと思います。

ちなみに画面の切り替えに、UIViewのアニメーションを使用するなどは自由にできます。
(フェードでも反転でも何でもOK)

9. コンテナアプリとのデータ共有が可能

これもドキュメント化されていることなので特に詳しく説明はしません。
App Groupを使ってUserDefaultsを共有したり、キーチェーンでホストアプリのログイン情報を使ったり、UIPasteBoardで簡単にやるのもよいでしょう。

10. ウィジェットは土地争い

最初にも述べたようにウィジェットは通知センターという好立地の場所を使えるのが最大のメリットです。
ただし、ウィジェットのプログラミングは非常に簡単で誰にでも作れるので、アイデアとずっとそこに置いてもらえるような価値を提供できるかどうかが勝負になります。
通常のアプリと違ってウィジェットを何十個も並べるというひとはほぼいないと思われるので、限られた土地の争奪戦になります。
(おそらくユーザー1人あたりが常時表示するウィジェットは多くて4,5個でしょう)

定番のウィジェット、というのはまだまだこれからなので、個人でも企業でもいろいろ試行錯誤したりチャレンジすると面白いと思います。

iOS 8 beta5にてポップオーバーをキャンセルするための暗いところを連続でタップすると下にあるモーダルビューも閉じてしまうバグ

一昨日くらいにiOS 8でテストをしていたら見つけました。
問題となる画面構成を多用するアプリケーションにとっては、修正が間に合わなければけっこう致命的と思われるので共有します。
(Base SDKをiOS 8にしてビルドしない限りは起こりません。リリース済みのアプリケーションをiOS 8で動かすぶんには問題無いです。)

↓ バグレポートした内容は下記の記事で公開しています。
重ねてレポートすると優先順位があがるかもしれません。

Radar: When double-tap the dimming view to dismiss the popover, it will also close modal view under - 24/7 twenty-four seven

サンプルコード

↓ バグレポートに添付した問題が再現するサンプルコードです。
Xcode 6でビルドして、iOS 8のiPadで実行すると問題が再現できます。

https://dl.dropboxusercontent.com/u/285673/sample.zip

事象について

事象を簡単にまとめると、モーダルビューの上にポップオーバーを表示している時、暗いところをタップしたらポップオーバーが消える、のが通常だけど、それを2回以上タップすると、下のモーダルビューまで閉じてしまう。

調べると、下のビューのdismissViewController〜メソッドが呼ばれることでポップオーバーが閉じるのだけど、
ダブルタップ以上すると、それが複数回呼ばれるので、ポップオーバー以外のビューも閉じられてしまう、という理屈です。


20140831200047

具体的な影響

このダブルタップの間隔は暗くなっているビューのalphaがゼロになるまでにすれば再現するので、かなりゆっくりでも起こってしまいます。
ちょっと反応が悪いかな?と思ってもう一回タップすると画面全体が消えてしまった、みたいなことが普通に起こります。


やっかいな点は、ポップオーバー形式で表示するビューは何でもこの問題の対象となることです。
iPadでは自動的にポップオーバーで表示されてしまうものや、ポップオーバーで表示しなければならない標準のビューはけっこうあって、UIActionSheetやUIImagePickerController、共有につかうUIActivityViewController、UIDocumentInteractionControllerなどが全部対象になります。


↓ 例えば、下のような画面でモーダルビューでUIWebViewを表示して、そこに共有のためのボタンがある、みたいな画面も対象になります。
(下の状態で暗いところをダブルタップすると、UIWebViewを表示しているモーダルビューも閉じてしまう)


20140831203642


救いなのは、Xcode 6つまりBase SDKをiOS 8にしてビルドしない限りはこの問題は起こらないことです。
(リリース済みのアプリケーションをiOS 8で使うぶんには問題ない)


ただ、満を持してiOS 8のリリースと同時にアップデートしたアプリケーションで問題が起きたらいろいろ大変だと思うので、iPad対応アプリケーションを出してるひとは気をつけてください。

対策

もし、GMで修正されていなかったときのためにいちおう対策を考えました。
標準のUIActionSheetとかで起こるので、呼び出し側で工夫するのは限界があるので、問題の起こっている、暗いところをタップしたときのイベントハンドラをSwizzlingして同じインスタンスの呼び出しなら1度しか実行しない、というようにしてみました。

正直、こういうコードをプロダクトに入れたくはないので、修正が間に合うことを願っています。

static IMP __Original_UIPopoverPresentationController_dimmingViewWasTapped;

void __Swizzle_UIPopoverPresentationController_dimmingViewWasTapped(id self, SEL _cmd, id sender)
{
    static id dimmingView;
    if (dimmingView == sender) {
        return;
    }
    dimmingView = sender;
    __Original_UIPopoverPresentationController_dimmingViewWasTapped(self, _cmd, sender);
}

+ (void)load
{
    Class clazz = NSClassFromString(@"UIPopoverPresentationController");
    SEL selector = @selector(dimmingViewWasTapped:);
    Method method = class_getInstanceMethod(clazz, selector);
    __Original_UIPopoverPresentationController_dimmingViewWasTapped = method_setImplementation(method, (IMP)__Swizzle_UIPopoverPresentationController_dimmingViewWasTapped);
}

Radar: When double-tap the dimming view to dismiss the popover, it will also close modal view under

If you display a popover on a modal view, when performing multiple tap the dimmig view to close popover, modal view will also be closed not just popover.

Tapping dimming view calls the event handler(-[UIPopoverPresentationController#dimmingViewWasTapped:]).
The method calls presenting view controller's `dismissViewController:animated:completion:` method.
So the method called twice or more, `dismissViewController~` method is called multiple times, also close other views not just pop over.

Submitted as rdar://18186956 and to OpenRadar.

Summary:

If showing a pop over on the modal view, double-tap the dimming view to dismiss the popover.
It will also close the modal view together.

Steps to Reproduce:
  1. Open and run the project https://dl.dropboxusercontent.com/u/285673/sample.zip
  2. Push the right bar button (named "Modal") on navigation bar
  3. Shown the table view modally
  4. Push the right bar button (named "Popover") on navigation bar, shown popover
  5. "Double-Tap" the dimming view
Expected Results:

Dismiss the popover

Actual Results:

Dismiss the popover, and also dismiss modal view under

Regression:

iPad Air, 32GB, WiFi
iOS 8.0 (12A4345d)

Note:

Not only UIPopoverController, this occurs in all views to be displayed in a pop-over style.
For example UIAlertControler(ActionSheet), UIPrintInteractionController, and so on.

Travis CI (Pro) の実行をジョブの並列化とBundlerとCocoaPodsのキャッシュで速くした

ユビレジではTravis CIを使って、テストの実行とベータ版のTestFlightへのアップロードを自動化しています。

Pull Requestが送られた時と、マージされた時に自動でマージした結果のベータ版が配布されるので、手元で変更をすぐに試すことができて便利です。


【参考】
Travis CIでiOSアプリのテスト&ベータ版の配信に使っているRakefileを改善したメモ - 24/7 twenty-four seven
ユビレジのiPadアプリのCI環境をJenkinsからTravis CIに移行したときのまとめ - 24/7 twenty-four seven


ただ、これは導入当初からあった問題なのですが、Travis CIにジョブが登録されてから終了するまで、だいたい20〜25分くらいと、少し時間がかかるのが気になっていました。


そこでジョブの並列化と、BundlerとCocoaPodsのキャッシュ2点で高速化を試みました。

まずジョブの並列化は、テストの実行とTestFlightへのアップロードを分けて並列で実行することにしました。
これまでテストがすべて成功した後に再度デバイス用のバイナリをビルドしてTestFlightにアップロードしていました。

それをテストの終了を待たずに、TestFlightのアップロードをするように変更しました。

テストが失敗するビルドがTestFlightで配信されることが起こりますが、テストが通らないビルドがマージされることは無いので、手元で早く確認できるということを優先しました。


変更は簡単で.travis.ymlの呼び出し方法を少し直すだけでした。

↓ もともとこうだったのが、

language: objective-c
script:
- bundle exec rake test
after_success:
- bundle exec rake profile:download
- bundle exec rake certificate:add
- bundle exec rake distribute
- bundle exec rake certificate:remove


↓ まず、Rakeタスクの呼び出しをそれぞれ1行にします。

language: objective-c
script:
  - bundle exec rake test
after_success:
  - bundle exec rake profile:download certificate:add distribute certificate:remove


↓ タスクをBuild Matrixのパラメータに振り分けます。

language: objective-c
script:
  - bundle exec rake ${ACTION}
env:
  matrix:
    - ACTION='profile:download certificate:add distribute certificate:remove'
    - ACTION=test


これだけで、Matrixの上段はTestFlightの配信、下段はテストの実行で並列に実行されます。



次にBundlerとCocoaPodsをキャッシュする設定です。

↓ OSXのビルド環境でもキャッシュの設定が有効になったということで試しました。

The Travis CI Blog: Caching (all the things) on the Mac platform

language: objective-c
cache:
  - bundler
  - cocoapods


↑ のオーソドックスな設定ではなぜかうまく動かなかったので、下のようにディレクトリをキャッシュする設定を使いました。

language: objective-c
cache:
  directories:
    - vendor/bundle
    - Pods
install:
  - bundle install --path=vendor/bundle --binstubs=vendor/bin
  - bundle exec pod install


ビルド時間の半分以上がBundlerとCocoaPodsのインストールにかかる時間だったので、これはかなり有効でした。


最終的に、TestFlightへのアップロードが3〜5分、テストの実行が4〜6分くらいで合計で8〜12分くらい、並列で実行されるのでPull Requestしてからだいたい5分くらいで手元で試せるベータ版が届くという環境になっています。


iOS 8でIn-App Purchaseの状態に追加されるSKPaymentTransactionStateDeferredの影響を考える

In-App Purchaseでプロダクトの購入を扱うときにはStoreKitのSKPaymentTransactionStateを使います。
例えばPurchasedなら購入完了なのでプロダクトのダウンロードを始める、Failedなら失敗なのでアラートを出すなどとします。

iOS 8からはその状態に新しくSKPaymentTransactionStateDeferredが追加されます。


2014/8/6時点ではまだドキュメントに解説はありません。
API diffとヘッダには記載されています。
WWDCのセッション218, 303ではそれなりに詳しく解説されていますので参考になると思います。


Deferredという状態は「Ask to Buy」というiOS 8のApp Storeの新機能のために導入されました。
「Ask to Buy」はiOS 8で搭載される「ファミリー共有 (Family Sharing)」の1機能で、子どもがApp Storeから購入する際に親の許可を必要とすることができるというペアレンタルコントロールの強化です。

20140806155004 20140806155004

Apple - iOS 8 - ファミリー共有


「Ask to Buy」が有効になっているアカウントで購入しようとしたとき、購入をいったん保留して許可を求める通知が親のアカウントに届きます。
このとき、購入は「完了(Purchased)」でも「失敗(Failed)」でもなく「Deferred」になります。


いずれDeferredは「完了(Purchased)」か「失敗(Failed)」のどちらかになりますが、それがいつになるかは親のアカウントがいつ判断をするかによるので、場合によっては何日かかかることも考えられます。
そのため、セッション303によると、この状態のときは通常通り(購入前の状態で)アプリケーションが使えるようにしなければならないとのことです。


さて、上記を踏まえて、既存のアプリケーションについてどのような対応が必要になるか考えてみます。


通常In-App Purchaseをハンドリングするオーソドックスな実装は下記のようになっていると思います。

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                break;
            case SKPaymentTransactionStatePurchased:
                [self sendReceipt:transaction];
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                [self restoreTransaction:transaction];
                break;
            default:
                break;
        }
    }
}


そして、購入を開始してから終了(完了または失敗)までをモーダルに処理しているアプリケーションも多いと思います。
(そのほうがわかりやすいことも多いので、購入開始〜完了までをモーダルにするのは別に悪いUIでは無いと思います)


ただ、その場合は上記の実装をそのままにしておくと、iOS 8でSKPaymentTransactionStateDeferredが返ってきたときに完了にも失敗にもならないので、購入中のモーダルな状態のままになってしまうことになります。

そこで、上記のswitch文にSKPaymentTransactionStateDeferredの分岐を追加します。
おそらくいったん失敗(Failed)と同じ処理をするのが簡単じゃないかと思います。
ただし、おそらく失敗のときとは異なり、トランザクション自体は終了させない(+ [SKPaymentQueue finishTransaction:]を呼ばない)のではないかと思います。
(このあたり、Sandbox環境で試すことはまだできないようですし、ドキュメントも無いので実際にどうなのかは不明です)


もともと、購入中でもモーダルにしてなくて自由に通常の操作ができる、という作りのアプリケーションの場合は特に気にせずゆっくり対応しても大丈夫そうかなと思います。
Deffered以外の既存の購入フローには影響はなさそうなので。

- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions
{
    for (SKPaymentTransaction *transaction in transactions) {
        switch (transaction.transactionState) {
            case SKPaymentTransactionStatePurchasing:
                break;
            case SKPaymentTransactionStatePurchased:
                [self sendReceipt:transaction];
                [self completeTransaction:transaction];
                break;
            case SKPaymentTransactionStateFailed:
                [self failedTransaction:transaction];
                break;
            case SKPaymentTransactionStateRestored:
                [self restoreTransaction:transaction];
                break;
            case SKPaymentTransactionStateDeferred: 
                // Deferredの状態に対応する処理を追加する
                // おそらく「失敗(Failed)」と同様にするのが簡単
                // ただし、トランザクションは終了させない(と思う)
                break;
            default:
                break;
        }
    }
}


「ファミリー共有 (Family Sharing)」および「Ask to Buy」の機能がいつ、既存のアプリケーションに対して適用されるのかとかもよくわかりませんが、In-App Purchaseを利用している場合は何らかの対応を検討する必要がありそうだなと思います。

Storyboardを1画面ごとに分割した話

今年の5月くらいの話なのですが、ユビレジのiPadアプリケーションのプロジェクトで使っているStoryboardを基本的に1画面(≒1 View Controller)の単位に分割するということをしました。


1画面1Storyboardメソッドについてはnakiwoさんが書かれた記事も参考になります。

1画面から始めるStoryboard - Cocoaメモ


↑ 上記の資料はどちらかというとStoryboardを使い始めるにあたって、1画面単位で少しずつ使っていこうという感じですが、ユビレジではもともとほぼ全部の画面がStoryboardになっていました。

ただ複数人で共同作業をするにあたっては、1画面単位を1ファイルにしておくくらいがメンテナンスしやすいんじゃないかなあという結論になったのでしばらくそういうふうに運用することにしました。


また、XIBと違ってStoryboardは単純にコピー&ペーストで別のファイルにまったく同じ構成のものを移すことができるので試すのも戻すのも非常に簡単なので合わなかったら戻せばよいと考えました。
(XIBだと単純なコピー&ペーストだとアクションの関連が外れたりします。まあXIBに複数のコンポーネントがあってそれを分けたいということは滅多にないのでそれは特に問題ではないですが)


複数のView Controllerを含む大きなStoryboardに比べて、小さなStoryboardのメリットしては、

  • コンフリクトの可能性が減る(大きなStoryboardだと、全く別の画面の修正にもかかわらず同じファイルになるので)
  • Auto LayoutのOn/Offを画面ごとに柔軟に設定できる(Auto Layoutの設定はファイルごとなので)
  • XIBと同様に使える(StoryboardはXIBの上位互換)

があります。

あと大きなStoryboardは作った当人は慣れているので、どこに何があるかすぐ分かるけど、他のひとにとっては目的の画面を探すのもけっこう大変だったりします。


特にユビレジでは4/5月にiOSのエンジニアが増えたので、Storyboardのマージに気を使うことが多くなることを避けたいというのが第一の大きな理由で、次に新しい人が既存のコードを追っていくなかでStoryboardを見るときに迷わないようにしたいという点です。

デメリットとしては、基本的に1つのStoryboardファイルには1つの画面しかないので、画面の遷移にSegueを利用することはできなくなります。

ただその点については、Segueを使うこと自体がそれほどメリットではないということで、トータルで考えるとそれほどデメリットにはならないという判断をしました。


Segueを使う場合であまり良くないと考えていることの1つは、多くのケースでは画面を遷移するときにいくつかデータを受け渡す必要があり、Segueを使う場合は標準のAPIではその部分がどうしても微妙になってしまうという点です。


例えば典型的なSegueを使った画面の遷移は下記のようになります。

- (IBAction)nextButtonTapped:(id)sender
{
    [self performSegueWithIdentifier:NSStringFromClass([UBCheckoutViewController class]) sender:self];
}

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([segue.identifier isEqualToString:NSStringFromClass([UBCheckoutViewController class])]) {
        UBCheckoutViewController *controller = segue.destinationViewController;
        controller.delegate = self;
        controller.checkout = self.checkout;
    }
}

↑ このように、ユーザーのアクションを受けて次の画面に必要なパラメータを渡して遷移するというごく普通のケースを記述するのに、画面を遷移するという処理と、別の処理で遷移先を判断してパラメータをセットするという2つに少なくとも分かれてしまいます。
さらに、Storyboard上で設定するSegueのIDがコードに文字列として(!)現れてしまうというのも残念なところです。


いちおう、SegueのIDは遷移先のビューコントローラの名前と同じにするというルールにして、コード上ではクラス名から取得するという形で、リファクタリングなどがある程度有効になるようにしていますが、Storyboard上でIDを修正し忘れたり、両方を一緒に修正しなければならないというのは結局変わらないので、やっぱりイマイチだなと思います。


1画面1Storyboardの場合は、従来の遷移先のビューコントローラを作ってデータをセットして遷移という形式で書くことができます。

- (IBAction)nextButtonClicked:(id)sender
{
    UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Checkout" bundle:nil];
    UBCheckoutViewController *controller = [storyboard instantiateInitialViewController];
    controller.delegate = self;
    controller.checkout = self.checkout;
    
    [self presentViewController:controller animated:YES completion:NULL];
}


↑ 上記の場合でもUBCheckoutViewControllerをインスタンス化するには「Checkout」という名前のStoryboardを使うという遷移元のビューコントローラが特に知る必要のない情報が書かれてしまうのが残念なので、少し工夫して下記のように、Storyboardからインスタンス化する部分をそのビューコントローラのクラスメソッドに隠蔽しています。
(ちなみにStoryboardのファイル名もビューコントローラ名にすればいいかと悩みましたが、厳密に1画面だけにするべき、とはしたくなかったのでそれはやめました)

- (IBAction)nextButtonClicked:(id)sender
{
    UBCheckoutViewController *controller = [UBCheckoutViewController checkoutViewController];
    controller.delegate = self;
    controller.checkout = self.checkout;
    
    [self presentViewController:controller animated:YES completion:NULL];
}


初期データを設定するだけのためにプロパティを公開するのは嫌だ、という場合にはイニシャライザで渡すように書くのも簡単です。

- (IBAction)nextButtonClicked:(id)sender
{
    UBCheckoutViewController *controller = [UBCheckoutViewController checkoutViewControllerWithCheckout:self.checkout delegate:self];
    
    [self presentViewController:controller animated:YES completion:NULL];
}


こうしておけば、UBCheckoutViewControllerの作成方法は使う側には関係がないので、仮にStoryboardを使わずにコードで生成するように変わったとしても使う側に影響はありません。


他社のプロジェクトで私が見た例だと、Factoryクラスを用意してそれを通してView Controllerを取得するように作っているところもよく見ます。
もちろんケースバイケースですが、1クッション挟むわりには特に抽象度も結合度もそれほど変わらないと思ったので、それなら直接相手のView Controllerを使うほうがわかりやすいと考えて上記の方法を採用しています。


余談ですが、下記の所さんの記事など、ヘルパーライブラリを作って解決する方法もあります。
ただ、我々は画面の遷移という基本的な処理を理解するのに標準でないAPIをまず知らなければならないという状況は、少なくとも人の変化がこれからも頻繁に起こることが考えられる中では避けたいと判断しました。

Storyboardでの画面遷移をスマートにやる方法 - TOKOROM BLOG


また、少なくとも私は上記の記事にある遷移元に遷移先の情報が必要になる(依存関係がある)という状況は特に問題とは考えていないので(逆はかなり問題があります)先に書いたくらいがバランスいいのではと思っています。


別にSegueはダメな子なので使うなというわけではもちろんありません。
新しいものをイチから作っていくときなど、試行錯誤が必要なときで一人で作るぶんには1つのStoryboardでSegueを使っていくのはとても早く作れるので非常に便利ですし、私も新しいものはガンガンSegueで繋いで作っています。


ただ、ある程度UIが落ち着いてきて、細かい改修だったり共同作業が増えてきた段階になると、
細かい単位にStoryboardを分けることで変更がやりやすくなるかどうかを検討してもいいんじゃないかという話です。


参考までに分割前と、後のスクリーンショットを載せておきます。


分割前:
(Storyboardはこれを含めて3つあった中でメインに使っていたものです)
20131117181446


分割後:
(基本的には1画面1Storyboardですが、一部の設定画面やモーダルビューは関連する画面をまとめたまま残しています)

20131117181446

20131117181446

20131117181446

20131117181446

20131117181446

Travis CIでiOSアプリのテスト&ベータ版の配信に使っているRakefileを改善したメモ

↓ コード署名に失敗する問題を直すついでに、今まで運用していく中でいくつか改善したい点があったので少し手を加えました。
Travis CIでipaを作るときのCode Signが失敗するのを修正したメモ - 24/7 twenty-four seven

Provisioning Profileのダウンロードにnomad/cupertinoを使う

これまではapple-devというRubyのスクリプトを使用していたのですが、それをやめて、cupertinoというGemを使うようにしました。


もともとcupertinoの存在は知っていたのですが、READMEを見て対話的な使い方しかできないものだと思ってしまっていたので、CIでは使えないと勘違いしていました。


実はコードを読んでみると、次のように`-u`と`-p`オプションでユーザ名とパスワードを渡すことがどのコマンドについてもサポートされているようだったので試してみるとあっさり成功したのでこれを使うように置き換えました。

global_option('-u', '--username USER', 'Username') { |arg| agent.username = arg unless arg.nil? }
global_option('-p', '--password PASSWORD', 'Password') { |arg| agent.password = arg unless arg.nil? }
global_option('--team TEAM', 'Team') { |arg| agent.team = arg if arg }


apple-devはBundlerでインストールできるようになっていなかったので、リポジトリにスクリプトを含めていましたが、それが不要になったので少しリポジトリがスッキリしました。

Pull Requestに対するビルドについてもTestFlightでベータ版を配信する

これまではmasterブランチにマージしたときだけTestFlightでベータ版を配信していたのですが、Pull Requestを簡単に動かして確認したいという声があったので、試しにPull RequestについてもTestFlightにアップロードするようにしてみました。


↓ この対応についてはTravis CIの実行時に設定される環境変数を見て処理を判断していたところを消しただけです。

task :testflight => ["version:set_build_version", IPA_FILE, :crittercism] do |t|
  iff ENV['TRAVIS_PULL_REQUEST'] != "false"
    puts "This is a pull request. No deployment will be done."
    next
  end
  if ENV['TRAVIS_BRANCH'] != "master"
    puts "Testing on a branch other than master. No deployment will be done."
    next
  end


また、Travis CIにはPull Requestの場合`TRAVIS_PULL_REQUEST`という環境変数にPull Requestの番号を入れてくれてるということなので、ビルド番号にPull Requestの番号を加えて配信することにしました。

task :set_build_version do
  rev = `git rev-parse --short HEAD`.strip

  pr_number = ENV["TRAVIS_PULL_REQUEST"];
  rev << " ##{pr_number}" if pr_number != "false"

  puts "Setting build version to: #{rev}"

  InfoPlist.build_version = rev
end

↑ これで、Pull Requestから作られたビルドについては、"Ubiregi2 2.67 (5bf4bc9 #590)"のような形でPull Requestの番号が表示されます。

Travis CI Pro(有料版の環境)のRubyが2.0.0でもCocoaPodsの実行に失敗しなくなった

今まではTravis CI Proの環境だとRuby 2.0.0を使うと`pod install`に失敗していたので、わざわざRuby 1.9.3の環境を指定していたのですが、それが直ったのでRuby 2.0.0を使うことができるようになりました。

↓ それで何が良くなったかというと、Rakefileでキーワード引数が使えるようになったので少しシンプルに書けるようになりました。

def join_option(options: {}, prefix: "", seperator: "")
  _options = options.map { |k, v| %(#{prefix}#{k}#{seperator}"#{v}") }
  _options = _options.join(" ")
end


↓ これらの変更を加えたRakefileの全体はこちら
Rakefile for testing, building and uploading to Testflight/Crittercism