Objective-C からの見え方を意識した Swift の Error

TL; DR

Objective-C からも利用できる Swift コードを書く場合、

  • throws キーワード付きのメソッドは戻り値の型に制限がある
  • 引数や戻り値にエラーを指定する際はカスタムエラー型ではなく Swift.Error 型を使う

の 2 点に気をつけたほうがよいです。

Objective-C → Swift の連携で困った

Swift でライブラリを書いていて、 Objective-C から呼ぶときのエラーの扱われ方にハマってしまいました。

この記事によると、 Swift のエラーを Objective-C でもハンドリングできるようにするには 列挙型の raw value に Int を指定したカスタムエラーを定義すると良いようです。 qiita.com

しかし、それだけではうまくいかないケースがあったので、もう少し実験してみました。

準備

参考記事と同じように、 raw value に Int を指定したカスタムエラーを定義しておきます。

@objc public enum CustomError: Int, Swift.Error {
    case error1 = 1
    case error2 = 2
}

この Swift コードをビルドすると、以下のような Objective-C コードが生成されます。

typedef SWIFT_ENUM(NSInteger, CustomError) {
  CustomErrorError1 = 1,
  CustomErrorError2 = 2,
};
static NSString * _Nonnull const CustomErrorDomain = @"MySwiftFramework.CustomError";

ケース 1: throws キーワード

Swift ライブラリに、以下のように throws キーワードの付いたメソッドを作成します。

@objc public func throwError() throws {
    throw CustomError.error1
}

Objective-C アプリからはこのように見えます。

- (BOOL)throwErrorAndReturnError:(NSError * _Nullable * _Nullable)error;

引数に out パラメータの NSError が追加され、戻り値が BOOL 型になりました。 メソッド名も ~AndReturnError と変化しています。

このとき、ライブラリ側の処理が成功すると引数 error は nil@YES が返り、 エラーが発生すると引数 error にエラーオブジェクトが渡され @NO が返ります。

ケース 2: 戻り値ありの throws キーワード

戻り値があり throws キーワードが付いたメソッドの場合、戻り値の型に制限がつきます。

@objc public func throwsWithReturnValue() throws -> Data {
    throw CustomError.error1
}

Objective-C アプリからの見え方はこうなります。

- (NSData * _Nullable)throwsWithReturnValueAndReturnError:(NSError * _Nullable * _Nullable)error;

Swift 側の戻り値が NonNull Data 型なのに対し、 Objective-C では Nullable NSData 型に変化しています。 このとき、ライブラリ側の処理が成功すると NSData オブジェクトが返り、エラーが発生すると nil が返ります。

nil を返すことで処理の成否を表現するため、 Swift 側の戻り値を Optional 型やプリミティブ型にすることはできません。

f:id:takasfz:20180702200132p:plain

nilObjective-C で失敗を表すので戻り値に optional 型は指定できないよ

f:id:takasfz:20180702200150p:plain

戻り値は Void か Objective-C のクラスにブリッジされる型しか指定できないよ

ケース 3: 戻り値に Error

throws キーワードではなく、戻り値の型を Error とした場合、 Objective-C アプリからの見え方は Swift と変わりません。

@objc public func returnError() -> Error {
    return CustomError.error1
}
- (NSError * _Nonnull)returnError;

Swift 側で CustomError を返すと、 Objective-C 側では適切な domain / code が設定された NSError オブジェクトが返ります。

ケース 4: 戻り値に CustomError

戻り値を CustomError 型とした場合、 Objective-C では戻り値が NSError ではなく列挙型の CustomError になりました。

@objc public func returnCustomError() -> CustomError {
    return CustomError.error1
}
- (enum CustomError)returnCustomError;

CustomError はただの NSInteger な列挙型なので、 NSError オブジェクトのように扱うことはできません。

ケース 5: 引数に Error

クロージャ等で引数を Error 型とする場合、 Objective-C アプリからの見え方は Swift と変わりません。

@objc public func passError(to closure: (Error) -> Void) {
    closure(CustomError.error1)
}
- (void)passErrorTo:(SWIFT_NOESCAPE void (^ _Nonnull)(NSError * _Nonnull))closure;

Swift 側で引数に CustomError を渡すと、ケース 3 と同じく Objective-C 側では適切な domain / code が設定された NSError オブジェクトが返ります。

ケース 6: 引数に CustomError

引数を CustomError 型とした場合、ケース 4 と同じく Objective-C では引数が NSError ではなく列挙型の CustomError になりました。

@objc public func passCustomError(to closure: (CustomError) -> Void) {
    closure(CustomError.error1)
}
- (void)passCustomErrorTo:(SWIFT_NOESCAPE void (^ _Nonnull)(enum CustomError))closure;

この場合、 Objective-C 側では CustomError を NSError オブジェクトのように扱うことはできません。

まとめ

Objective-C からも利用できる Swift コードを書く場合、 throws キーワード付きのメソッドは戻り値の型に制限があることに注意する必要があります。 また引数や戻り値にエラーを指定する際は、特定のカスタムエラー型しか返さない場合でも Swift.Error 型を指定するほうがよいでしょう。


出典: - Swift3時代のErrorとNSErrorに関するいくつかの実験

WKWebView を継承したカスタムクラスで Web ページが表示されなくなった

表題の通りで、 WKWebView を継承したカスタムクラスを作って色々いじっていたら、 Web ページが表示されなくなってしまいました。

  • 何も表示されず真っ白
  • スクロールやピンチイン・ピンチアウトはできる
  • スクロールできる量は表示したい Web ページと同じっぽい
f:id:takasfz:20180413194035p:plain

原因は、 didMoveToWindow をオーバーライドしたときに、スーパークラスのメソッドを呼んでいないことでした。 UIView のドキュメントには「このメソッドのデフォルト実装は何もしない」と書いてあったのと WKWebView のドキュメントでも特に触れられていなかったので油断していたんですが、 WKWebView の実装ではこのメソッドで状態の変更を通知しているのですね。

didMoveToWindow() - UIView | Apple Developer Documentation

The default implementation of this method does nothing. Subclasses can override it to perform additional actions whenever the window changes.

WKWebView.mm - WebKit/webkit - Sourcegraph

- (void)didMoveToWindow
{
    _page->activityStateDidChange(WebCore::ActivityState::AllFlags);
}

オーバーライドしたメソッドで super.didMoveToWindow() を呼ぶと、ちゃんと表示されるようになりました。

f:id:takasfz:20180413194048p:plain

「 window オブジェクトの変更を通知してくれるメソッド」くらいの理解だったのであんまり深く考えていなかったのですが、処理を上書きしたい明確な意図がない限りは、メソッドをオーバーライドしたらちゃんとスーパークラスのメソッドを呼ぼうと思いました。


出典:

アプリのコミットハッシュを取得する

開発中のアプリを複数の端末に入れたり消したりしながら動作確認していると、「これはどの時点のコミットからビルドしたんだっけ?」を知りたくなることが増えてきたので、 git のコミットハッシュを取得して Android / iOS アプリに表示する方法を調べました。

HEAD のコミットハッシュを取得する

下記どちらかのコマンドで取得できます。体感値ですが rev-parse のほうが若干速い気がします。

  • git rev-parse --short HEAD
    • HEAD が指す先の、コミットハッシュの短縮形を表示
  • git log -1 --format='%h'
    • コミット履歴の先頭 1 件だけ、コミットハッシュの短縮形を表示

Android アプリに表示する

アプリの versionName の末尾にコミットハッシュを付加します。

アプリモジュールの build.gradle に下記の設定を追加します。

def commitHash = ["sh",  "-c",  "cd ${project.rootDir}; git rev-parse --short HEAD"].execute().in.text.trim()

android {
    buildTypes {
        debug {
            versionNameSuffix "_" + commitHash
        }
    }
}

アプリのコードで PackageInfo を取得すると、 versionName に 1.0.0_abcd1234 のような値が入っています。

val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val versionName = packageInfo.versionName

サンプルコードはこちら github.com

iOS アプリに表示する

iOS の CFBundleShortVersionString は x.y.z 形式以外の文字列を受け付けないため、コミットハッシュをそのまま付加することはできません。したがって、 Info.plist に独自のキーを定義し、そこにコミットハッシュを設定するようにします。

まず、 Xcode の Targets > Info に以下の key-value pair を追加します。

Key Type Value
CommitHash String undefined

次に Build Settings の Preprocess Info.plist File を YES に変更します。

Build Phases の Compile Sources の前に Run Script Phase を追加し、下記のコマンドを設定します。

plistBuddy="/usr/libexec/PlistBuddy"
infoPlistFile="${TEMP_DIR}/Preprocessed-Info.plist"

commitHash=$(git rev-parse --short HEAD)
$plistBuddy -c "Set :CommitHash $commitHash" $infoPlistFile

アプリのコードで CFBundleShortVersionString と CommitHash をそれぞれ取得し、連結すると 1.0.0_abcd1234 のような値を得ることができます。

let info = Bundle.main.infoDictionary!
let versionName = "\(info["CFBundleShortVersionString"]!)_\(info["CommitHash"]!)"

Info.plist のプリプロセスを有効にし、もとのファイルは書き換えず中間ファイルにだけ値を設定するのがポイントです。

サンプルコードはこちら github.com

JS デバッグに便利な GCS の Static Website Hosting

最近はもっぱら JavaScript ( TypeScript ) を書いています。ドメインを跨ぐときの挙動をデバッグするのに Google Cloud Storage の静的ホスティングが便利でした。

f:id:takasfz:20171102200456p:plain

やりたかったこと

ドメインが複数必要なのにドメインを取得したくない、そんなワガママを GCS が叶えてくれました。

Web サイトを公開するまでの手順

1. ストレージバケットを作成する

ストレージ バケットの作成  |  Cloud Storage ドキュメント  |  Google Cloud Platform

公式ドキュメントを参考に、ストレージバケットを作成します。 データ量が少なく、データへのアクセスがそれなりに発生するので、ストレージクラスは Regional 、リージョンは asia-northeast1 を選択しました。

2. 公開するファイルをアップロードする

作成したバケットを開き、コンソールの上部にある ファイルをアップロード または フォルダをアップロード から公開するファイルを選択し、アップロードします。 公開するファイルをドラッグ&ドロップでアップロードすることもできます。

f:id:takasfz:20171102195246p:plain

3. キャッシュ設定を変更する

公開するファイルは初期設定で 1 時間キャッシュが有効になっているため、頻繁にファイルを変更してデバッグするには不便なことがあります。そこで、アップロードしたファイルの その他のオプション (︙) から メタデータを編集 をクリックし、 Cache-Control の値を設定してキャッシュの有効期限を短くします。

f:id:takasfz:20171102195303p:plain
f:id:takasfz:20171102195309p:plain

4. ファイルを公開する

アップロードしたファイルの 一般公開で共有するチェックボックスを ON にします。ファイルが公開されるとチェックボックスの横に 公開リンク が表示され、これをクリックすると公開されたファイルが新しいウィンドウで表示されます。

f:id:takasfz:20171102195255p:plain

公開されたファイルは http(s)://storage.googleapis.com/{バケット名}/{ファイル名} または http(s)://{バケット名}.storage.googleapis.com/{ファイル名} でアクセスできます。

5. ファイルのアップロード ~ 公開設定を一発で

手順 4 までで Web サイトの公開はできるのですが、複数のファイルに対して 1 つずつキャッシュの設定をしたり、ファイルを更新する度にキャッシュを設定し直したりするのは正直面倒です。 gsutil コマンドを使用すると、ファイルのアップロードとキャッシュ設定、一般公開を一度に行うことができます。

gsutil -h 'Cache-Control:max-age=1' cp -a public-read ${ファイル名} gs://${バケット名}/${パス}

gsutil コマンドについて、詳しくは gsutil ツール  |  Cloud Storage ドキュメント  |  Google Cloud Platform を参照ください。

できたもの

クロスオリジンアクセスの簡単なデモを作ってみました。

location.html

<!doctype html>
<meta charset="utf-8"/>
<meta name='viewport' content='width=device-width,initial-scale=1.0,minimum-scale=1.0' />
<style>
    body { margin: 8px; }
    dl { word-wrap: break-word; }
    iframe { display: block; margin: 8px auto; width: 100%; height: 100%; border: 1px solid black; }
</style>
<script>
    window.addEventListener('DOMContentLoaded', function() {
        var html  = '<dl><dt>window.location :</dt><dd>' + window.location + '</dd></dl>';
        document.body.insertAdjacentHTML('afterbegin', html);
    });
</script>
<iframe src="https://storage.googleapis.com/takasfz/location_iframe.html"></iframe>
<iframe src="https://takasfz.storage.googleapis.com/location_iframe.html"></iframe>

location_iframe.html

<!doctype html>
<meta charset="utf-8"/>
<meta name='viewport' content='width=device-width,initial-scale=1.0,minimum-scale=1.0' />
<style>
    dl { width: 100%; word-wrap: break-word; }
</style>
<script>
    window.addEventListener('DOMContentLoaded', function() {
        var html  = '<dl><dt>window.location :</dt><dd>' + window.location + '</dd></dl>';
        document.body.insertAdjacentHTML('afterbegin', html);
        document.body.style.background = (!!window.frameElement) ? "lightskyblue" : "pink";
    });
</script>

iframe の src に 2 通りの URL を指定し、同一オリジンであれば背景色を青、クロスオリジンであれば背景色をピンクに設定する、というものです。

f:id:takasfz:20171102193142p:plain

まとめ

簡単な設定しか行っていませんが、特に不自由なく、わりと便利に使えています。 独自ドメインを取得しなくても、サードパーティ JavaScript やクロスオリジンアクセスの挙動を確認できるのが嬉しいですね。

ほかにも Tips があれば教えてください!


出典:

iOS 11 の SFSafariViewController は Cookie が共有されない

iOS 11 で SFSafariViewController の仕様が変わり、 Cookie 等のデータが Safari とは独立した領域に保存されるようになりました。 これによってユーザの行動がアプリをまたいで追跡されるのを防ぎ、閲覧履歴の公開範囲を個々のアプリに限定することができるとしています。

新・ Web サービスの認証方法

SFSafariViewController の認証情報が Safari と共有されなくなるため、 SFSafariViewController で Web サービスにログインしているときに「 Safari で開く」をタップすると、 Safari に遷移したあと改めてログインする必要があります。

In iOS 9 and 10, it shares cookies and other website data with Safari. ( 中略 ) If you would like to share data between your app and Safari in iOS 11 and later, so it is easier for a user to log in only one time, use SFAuthenticationSession instead.

そこで、 OAuth / Single Sign-On の認証情報を共有するために新たに SFAuthenticationSession クラスが追加されました。 SFAuthenticationSession で認証リクエストを行うと、以下のように認証情報の利用を確認するダイアログが表示されます。

f:id:takasfz:20170922233903p:plain

「続ける」をタップすると Web サービスの認証ページが表示され、認証が成功するとアプリへリダイレクトします。

f:id:takasfz:20170922233921p:plain

認証済みの場合、「続ける」をタップすると認証ページを表示せず、すぐにアプリへリダイレクトします。 SFAuthenticationSession と Safari で認証情報が共有されるため、どちらかで認証済みであれば OK です。

プライベートブラウズモードは削除された..?

また、iOS 11 では SFSafariViewController からプライベートブラウズ機能が削除された模様です。 iOS 10 までは Safari をプライベートブラウズモードにしていると SFSafariViewController も Safari と同じ黒ベースの配色に変わっていたのですが、 iOS 11 では Safari をプライベートブラウズモードにしていても SFSafariViewController には Cookie が保存され、配色も変わりませんでした。

Safari とは独立した領域に Cookie を保存するようにしたことで、プライベートブラウズという概念は不要になったという判断でしょうか..?


出典:

Safari 11 の Auto-Play Blocking を試してみた

TL; DR

Auto-Play Blocking は音声つき動画が自動再生されないよう Safari を設定できる機能ですが、 「音声なし動画の自動再生」「音声つき動画をミュートで自動再生」「音声つき動画を、ユーザ操作を契機として再生」することは可能です。

Auto-Play Blocking とは

macOS 10.13 High Sierra に搭載される Safari 11.0 に、動画の自動再生を抑制する Auto-Play Blocking 機能が採用されます。 Auto-Play Blocking は、 Web サイトごとに以下 3 種類のルールを適用することができます。

  • Allow All Auto-Play
    • 自動再生を許可します。これまでの Safari の動作です。
  • Stop Media with Sound
    • 音声ありの動画の自動再生を抑制します。デフォルトではこのルールが適用されます。
  • Never Auto-Play
    • すべての自動再生を抑制します。

これは macOS 向けの機能で、 iOS 11 の Mobile Safari には採用されていません。 ( 追記: Mobile Safari 向けには、これとは別に iOS 10 から採用された自動再生抑制のポリシーがあります → New <video> Policies for iOS )

試してみた

音声なし動画の自動再生

Auto-Play Blocking は「音声つき動画を自動再生しない」機能なので、まずは音声なし動画を再生してみます。 検証に使用する HTML タグは以下のとおりです。

<video autoplay>
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

結果は以下のようになりました。 音声なし動画なので Stop Media with Sound は再生され、音声有無を問わない Never Auto-Play は再生されませんでした。

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound
Never Auto-Play ×

音声つき動画の自動再生

ここからが本題です。音声つき動画を自動再生してみます。

<video autoplay>
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

Stop Media with Sound も再生されません。

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound ×
Never Auto-Play ×

音声つき動画をミュートで自動再生

音声つき動画の音声を OFF にした場合はどうでしょうか。

<video autoplay muted>
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

今度は Stop Media with Sound でも再生できました。

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound
Never Auto-Play ×

音声つき動画を JS で再生

autoplay 属性を付けなければいいのでは?と思い、 autoplay ではなく JS で再生を試みました。

<script>
    window.addEventListener('load', function() {
        let video = document.getElementById('v');
        video.play();
    });
</script>
<video id="v">
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

結果は autoplay 属性を指定した場合と変わらず、 Stop Media with Sound は再生されません。 play メソッドでエラーが発生していました。

Unhandled Promise Rejection: NotAllowedError (DOM Exception 35): The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.
ルール 再生可否
Allow All Auto-Play
Stop Media with Sound ×
Never Auto-Play ×

音声つき動画をページロード後数秒待ってから再生

ページロード時に自動再生されないなら、ロード完了後に少し時間をおいて再生するのはどうでしょうか。

<script>
    window.addEventListener('load', function() {
        window.setTimeout(function() {
            let video = document.getElementById('v');
            video.play();
        }, 3000);
    });
</script>
<video id="v">
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

これもダメで、 Stop Media with Sound は再生されず、 play メソッドでエラーが発生しました。

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound ×
Never Auto-Play ×

音声つき動画を画面がクリックされたら再生

ページロード契機が全くダメなので、ユーザ操作を契機としてみました。

<script>
    window.addEventListener('mousedown', function() {
        let video = document.getElementById('v');
        video.play();
    });
</script>
<video id="v">
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

今度は Stop Media with Sound も再生されました。 ユーザ操作を契機とする場合は「自動再生」にあたらないようで、 Never Auto-Play でも再生されました。

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound
Never Auto-Play

音声つき動画を画面がスクロールされたら再生

何らかの操作を契機にすればいいなら、と思い、ページスクロールも試してみました。

<script>
    window.addEventListener('scroll', function() {
        let video = document.getElementById('v');
        video.play();
    });
</script>
<video id="v">
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

Stop Media with Sound も Never Auto-Play も再生されず、 play メソッドでエラーが発生しました。 scrollTo() を使えばユーザが操作しなくてもスクロールを行えるからでしょうか..?

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound ×
Never Auto-Play ×

音声つき動画をミュートで自動再生したあと、ミュート解除

最後に、自動再生を開始したあとにミュートを解除できるのか試してみました。

<script>
    window.addEventListener('load', function() {
        let video = document.getElementById('v');
        video.addEventListener('play', function() {
            video.muted = false;
        });
    });
</script>
<video id="v" autoplay muted>
    <source src="https://example.com/video.mp4" type="video/mp4">
</video>

結果はダメで、 Stop Media with Sound も Never Auto-Play も以下のエラーが発生し、

Unhandled Promise Rejection: [object DOMError]

以下のいずれかの動作となりました。

  • ミュートを解除できず、音声 OFF のまま再生を続ける
  • 再生自体止まってしまう

( どちらの動作となるかはタイミング依存のようです )

ルール 再生可否
Allow All Auto-Play
Stop Media with Sound ×
Never Auto-Play ×

まとめ

まとめると、 3 種類のルールはそれぞれ以下の動作となります。

  • Allow All Auto-Play
    • これまで通り、自動再生可能
  • Stop Media with Sound
    • 「音声なし動画」「音声つき動画をミュート」は自動再生可能、それ以外はユーザ操作が必要 ( = iOS 10 の Mobile Safari のインライン再生と同じ挙動 )
  • Never Auto-Play
    • すべての動画はユーザ操作を契機としたときのみ再生可能

デフォルトは Stop Media with Sound なので、音声なしでの再生はユーザが設定を変更しない限りこれまで通り可能です。 音声つき動画を再生したい場合や、 Never Auto-Play でも動画を再生したい場合は、ユーザ操作イベントを契機に再生開始する必要があります。 ( この機能が採用された意図を考えると、再生制御をやめてビデオコントロールを表示するのが一番素直な対応だとは思いますが.. )

WKWebView と SFSafariViewController の見分け方

iOS アプリに搭載されているアプリ内ブラウザが WebView なのか SafariViewController なのかは、実は見た目で判別することができます。 iOS エンジニアでない方にはあまり馴染みがないようなので、まとめました。

WKWebView とは

ウェブコンテンツを表示するための view です。アプリ内にウェブコンテンツを埋め込むことができ、全画面表示である必要はありません。 ( アプリ内に HTML 広告を表示するケースなどでも使われていますね。 )

表示内容の制御など、ウェブコンテンツにアプリが関与したいときは、こちらを使います。

SFSafariViewController とは

Safari の機能 ( リーダー表示、オートフィル、コンテンツブロックなど ) を持つ view controller です。基本的に全画面で表示します。

クッキーやウェブサイトのデータは Safari と共有されます。 SFSafariViewController 上でのユーザ操作などはアプリからは見えず、オートフィルや閲覧履歴などのデータを取得することはできません。 SFSafariViewController は最早アプリではなく、 Safari の一部と言ってよいでしょう。

ウェブサイトを表示するだけでよければ、こちらを使います。

見分け方

SFSafariViewController の UI には以下の特徴があります。

  • 読み取り専用のアドレスバー、鍵マーク、リーダー表示モード
  • アクションボタン
  • 完了ボタン、履歴を進む・戻る、 Safari で開く
f:id:takasfz:20170529184616p:plain

また、 Safari をプライベートブラウズモードに変更すると、 SFSafariViewController も黒ベースの配色に変わります。 追記: iOS 11 以降 SFSafariViewController はプライベートブラウズモード非対応となったようです。 → iOS 11 の SFSafariViewController は Cookie が共有されない - takasfz blog

f:id:takasfz:20170529184606p:plain

これらの特徴をすべて備えていれば、それは SFSafariViewController です。

まとめ

判断に迷ったら、 Safari をプライベートブラウズモードに変更してみて配色が変わるかを確認するのが一番手軽で確実です。 iOS11 以降、配色を見て判断することができなくなったので、アドレスバー / タブバーの UI で判断しましょう..


出典: