絶対にアプリを死なせない!プロセス分離による堅牢なSDKの実現方法

はじめまして。プレイドの @tomoponzoo です。
2018年4月に入社、現在プレイド暦4ヶ月目のエンジニアです。
プレイドでは、今年の3月にローンチした「KARTE for App」のモバイルSDK開発をメインで担当しております。

開発に携わって4ヶ月が経ちましたが、今でもSDKをリリースする際は緊張してしまいます。それはSDKに非常に高いレベルの品質が求められるからです。

今回は、SDK開発の勘所や品質の重要性、仮にSDKで問題が発生してもアプリを絶対に死なせないための方法について考えていきたいと思います。

SDK開発の勘所 - 品質の重要性

冒頭で述べた通り、非常に高いレベルの品質が求められるSDKですが、それはなぜでしょうか?

仮に低品質なSDKを組み込んだ時に起こり得ることについて、想定してみます。

  • アプリがクラッシュするなどの問題が頻発
  • アプリクラッシュにより、エンドユーザーの体験が悪化
  • エンドユーザーの体験が悪化することにより、クライアントのビジネスに悪影響を及ぼす
  • 最終的には自らのビジネスも毀損する

以上のように考えると、なぜSDKに高い品質が求められるのか理解できると思います。

このように高いレベルの品質が求められるSDKですが、開発するためには、エンジニアの想像力や品質への拘りが重要になります。これは非常に大変ですが奥深く面白い部分でもあります。

品質維持のためのアプローチ

では、高い品質の維持、また万が一問題が発生したときに影響を最小限に抑えるためにどんなことができるでしょうか?

  • 問題を起こさない仕組みを整える
    • ソースコードレビューの実施
    • テストの自動化
  • 問題が発生したときに影響を最小限に抑える仕組みを整える
    • 確実なエラーハンドル
    • プロセス分離

このように品質維持のためのアプローチは様々ですが、今回は表題にもあるとおり、堅牢なSDKを実現するための方法として「プロセス分離」について考えてみます。

堅牢なSDKを目指す!

プロセス分離 - これはアプリケーションのプロセスと、SDKのプロセスを分離することです。
プロセスを分けることで、仮にSDKのプロセスがクラッシュしたとしても、アプリケーションのプロセスには影響を与えず、処理を継続することが可能になります。

なお本質的には、SDKがクラッシュしないように実装すべきです。
ただし人間が作る以上、バグが入り込む可能性は0ではありません。もちろんバグが入り込まないように様々な対策を行うわけですが、それらをすり抜けバグが入り込む可能性は否定できません。
万が一の事態におけるフェイルセーフとしてプロセス分離を活用すべきです。

ちなみに、iOSプラットフォームにおいてプロセス分離を行うのは至難の業です。というのも通常のアプリケーションからはプロセスを自由に生成することができません。そのため今回はその制限の中でどうやってプロセス分離を実現するか試行錯誤しながら考えていきたいと思います。

マルチプロセス(プロセス分離)を実現するための手段

では、マルチプロセスを実現する場合にどんな手段があるでしょうか?
パッと思いつくものを列挙してみました。

  • fork
  • App Extension

forkとは?

あまりにも有名で説明は不要かと思いますが、forkはUNIXシステムにおけるシステムコールのひとつです。
forkを使うことで、これを呼び出したプロセスのコピーを生成することができます。

iOSは、UNIX系システムのひとつであるため、forkを使うことが可能です。

forkを試す

では、早速forkを使ってプロセスの生成ができるか試してみたいと思います。
まずXcodeで適当なプロジェクトを作成、main.c を以下のように修正し、シミュレーターで実行してみます。
うまく行けば、子プロセスが作成されプロセス分離への道が開けます。

int main(int argc, char * argv[]) {
  pid_t pid = fork();
  if (pid == -1) {
    NSLog(@"fork() failed");
  } else if (pid) {
    NSLog(@"[Parent] PID: %d, PPID: %d", getpid(), getppid());
  } else {
    NSLog(@"[Child]  PID: %d, PPID: %d", getpid(), getppid());
  }
}
2018-07-28 17:35:45.858546+0900 MultiProc[39705:13798053] [Child]  PID: 39705, PPID: 39702
2018-07-28 17:35:45.858704+0900 MultiProc[39702:13797947] [Parent] PID: 39702, PPID: 39703

おっ!意外にも、あっさりプロセスの生成ができてしまいました。
子プロセスの親プロセスIDを確認すると、親プロセスのIDと一致しており、親子関係があることが確認できます。

同じことを実機を使ってやってみたいと思います。

2018-07-28 18:17:47.617257+0900 MultiProc[4926:1877048] fork() failed

今度は失敗しました。
iOSでは、アプリケーションのバックグラウンド実行にかなりの制限があります。そのため独自にプロセスを生成して動かすこと自体不可能なのでは?と思っていましたが、やはり当初の想定通りダメなようです。

App Extensionとは

forkがダメだったので、もうひとつのアプローチについて考えてみます。

App Extensionですが、 App Extensionプログラミングガイドで説明されている通り。
iOSアプリケーションを拡張する仕組みのひとつです。アプリケーションとは分離された別のプロセスとして動作するのが特徴です。

アプリケーションを拡張するために用意された仕組みであるため、通常はAppleが用意した拡張ポイントに対応するExtensionしか開発できません。
今回は、プロセス分離を実現すべく非公開の拡張ポイントを利用して独自のExtensionを作ってみます。

App Extensionの仕組み

独自のExtensionを作る前に、Extensionの仕組みについてみておきます。

Extensionのライフサイクル
通常は停止しています。
アプリケーションからのリクエストに応じて起動し、処理を実行、最後に結果をレスポンスし、終了する流れになります。

Extensionとのやりとり
アプリケーションとExtension間のやりとりには、XPCと呼ばれる仕組みが利用されており、シリアライズしたプロパティリストをやりとりすることでプロセス間通信が実現されています。
なお実際にやりとりを行う際は、XPCをラップしたNSExtensionと呼ばれる非公開クラスを利用して、やりとりを行うことになります。

extension

App Extensionを作る

注意!!!
App Extensionの作成過程で非公開APIを利用しています。
非公開APIを利用した場合、審査でリジェクトされる可能性がありますのでご注意ください。

では、Extensionを作っていきましょう。
適当なプロジェクトを1個作成しておきます。

1. App Extensionを作成

任意のテンプレートを利用してExtensionを作成します。

2. App Extensionの設定を修正

Info.plistにExtensionの拡張ポイントや主要クラスの設定があるので、以下のように修正します。

<key>NSExtension</key>
<dict>
 <key>NSExtensionPointIdentifier</key>
 <string>com.apple.app.non-ui-extension</string>
 <key>NSExtensionPrincipalClass</key>
 <string>Extension</string>
</dict>

3. 非公開APIのインターフェースを定義

NSExtensionは非公開のAPIです。
利用するためには、APIに対応するヘッダーファイルを各々で用意する必要があります。

なお非公開APIのインターフェースを調べるには、class-dumpなどのツールを利用します。

今回は、以下のようなヘッダーファイルを作ります。

@interface NSExtension : NSObject
+ (instancetype)extensionWithIdentifier:(NSString *)identifier error:(NSError **)error;

- (void)beginExtensionRequestWithInputItems:(NSArray *)inputItems completion:(void (^)(NSUUID *requestIdentifier))completion;
- (int)pidForRequestIdentifier:(NSUUID *)requestIdentifier;
- (void)cancelExtensionRequestWithIdentifier:(NSUUID *)requestIdentifier;

- (void)setRequestCancellationBlock:(void (^)(NSUUID *uuid, NSError *error))cancellationBlock;
- (void)setRequestCompletionBlock:(void (^)(NSUUID *uuid, NSArray *extensionItems))completionBlock;
- (void)setRequestInterruptionBlock:(void (^)(NSUUID *uuid))interruptionBlock;
@end

4. ホストアプリケーションを実装

Extensionとプロセス間通信を行うために、NSExtensionクラスのインスタンスを生成し、リクエスト処理やレスポンス処理を実装します。

NSExtensionインスタンスの生成

NSExtension *extension = [NSExtension extensionWithIdentifier:@"jp.co.plaid.MultiProc.Extension" error:nil];

[extension setRequestCompletionBlock:^(NSUUID *uuid, NSArray *extensionItems) {
  // リクエスト完了
  NSLog(@"Request completed: %@", uuid);
}];

Extensionにリクエストする

NSExtensionItem *item = [[NSExtensionItem alloc] init];
[item setAttachments:@[@"VALUE"]];

[extension beginExtensionRequestWithInputItems:@[item] completion:^(NSUUID *requestIdentifier) {
  int pid = [extension pidForRequestIdentifier:requestIdentifier];
  NSLog(@"Began extension request: %@. Extension PID: %i", requestIdentifier, pid);
}];

5. Extensionを実装

ヘッダーファイルを修正します。
ExtensionクラスがNSExtensionRequestHandlingプロトコルに適合することを宣言します。

@interface Extension : NSObject <NSExtensionRequestHandling>
@end

実装ファイルを修正し、NSExtensionRequestHandlingプロトコルに適合させます。

@implementation Extension
- (void)beginRequestWithExtensionContext:(NSExtensionContext *)context {
  // レスポンスを返す
  [context completeRequestReturningItems:@[] completionHandler:^(BOOL expired) {
    NSLog(@"Completed request.");
  }];
}
@end

App Extensionを動かしてみる

ここまでできたらシミュレーターで実行してみます。
以下のようなログが出ればOKです。
ホストアプリケーションのプロセスIDが74665、ExtensionのプロセスIDが74671となっており、それぞれ別のプロセスで動作していることが確認できます。

2018-07-29 22:19:03.749751+0900 MultiProc[74665:14952372] App PID: 74665
2018-07-29 22:19:12.725051+0900 MultiProc[74665:14952478] Began extension request: 34E68F5B-520A-48EC-93EE-03599A310B24. Extension PID: 74671
2018-07-29 22:19:12.730459+0900 MultiProc[74665:14952478] Received response.

App Extensionをクラッシュさせてみる

さて、ここまで色々試してきましたが、ここで当初の目的である「SDKがクラッシュしても、アプリが絶対に死なない!」ことを確認してみたいと思います。

以下のように、Extensionをクラッシュさせるためのコードを入れて実行してみます。

NSExtensionItem *inputItem = context.inputItems[1];

Extensionでは、NSRangeExceptionが発生しクラッシュしますが、アプリケーションは影響を受けず何事もなかったかのように動作しています。

まとめ

今回は「プロセス分離」という手段を用いて、堅牢なSDKを実現する方法とその可能性について探ってみました。

現状はプラットフォームとしての制約が大きく、また非公開APIを利用しないと実現できないなど、実際にプロダクトに適用するのは難しいということが分かりました。
アプリケーションからプロセス生成できない理由として、端末の性能やリソース制約の問題が大きいと考えています。将来端末のリソース制約について考えなくて良くなった時には、このような手段も現実味を帯びてくるのではないかと思いました。

なお今回作成したアプリのソースコードはGitHubで公開していますので、動作や実装が気になる方は、ぜひダウンロードして動かしてみてもらえればと思います。

最後に

CXプラットフォーム「KARTE」を運営するプレイドでは、品質にこだわりたい!想像力をフルに発揮したい!モバイルエンジニア(インターンも!)を募集しています。
詳しくは弊社採用ページ
またはWantedly
をご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!