[iOS 8] App Extension #6 – Embedded Framework と App Group を利用して Action Extension を実装する [後編]

Embedded Framework と App Group を利用して Action Extension を実装する [前編]の続きです。ソースコードは GitHub に公開してあるので参考にして下さい。

Embedded Framework の実装

Embedded Framework に containing app と Extension で利用するイメージの永続化モジュールを実装します。このクラスには、イメージを保存するsaveLatestImage:error: メソッドと、イメージを取り出す loadLatestImage メソッドを定義します。

ImageService.h

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

@interface ImageService : NSObject

- (BOOL)saveLatestImage:(UIImage *)image error:(NSError * __autoreleasing *)error;
- (UIImage *)loadLatestImage;

@end

イメージの保存

イメージ保存処理のコードは下記の通りです。

ImageService.m

- (BOOL)saveLatestImage:(UIImage *)image error:(NSError *__autoreleasing *)error {
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSURL *destURL = [fileManager containerURLForSecurityApplicationGroupIdentifier:AppGroupIdentifier];

    NSDateFormatter *dateFormatter = [NSDateFormatter new];
    dateFormatter.dateFormat = @"yyyyMMddHHmmss";
    NSString *dateString = [dateFormatter stringFromDate:[NSDate new]];

    NSString *filePath = [dateString stringByAppendingPathExtension:@"png"];
    destURL = [destURL URLByAppendingPathComponent:filePath];

    NSData *data = UIImagePNGRepresentation(image);

    BOOL succeeded = [data writeToURL:destURL atomically:YES];
    if (!succeeded) {
        *error = [NSError errorWithDomain:NSCocoaErrorDomain
                                     code:NSFileWriteUnknownError
                                 userInfo:nil];
        return NO;
    }

    NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroupIdentifier];
    [defaults setObject:filePath forKey:UserDefaultsLastestImagePathKey];
    [defaults synchronize];

    return YES;
}

ローカルストレージの共有領域へのファイルの保存

まず、NSFileManagercontainerURLForSecurityApplicationGroupIdentifier: メソッドで shared container によるローカルストレージの共有領域のパスを取得します。

ImageService.m

NSFileManager *fileManager = [NSFileManager defaultManager];
NSURL *destURL = [fileManager containerURLForSecurityApplicationGroupIdentifier:AppGroupIdentifier];

メソッドの引数には、先程設定した App Group の identifier を渡します。

ActionExtensionNoUIConstants.h

static NSString * const AppGroupIdentifier = @"group.jp.classmethod.ActionExtensionNoUISample";

パスを取得したら、共有領域のルート直下に日時をファイル名としてイメージを保存します。

UserDefaults の共有領域へのデータの書き込み

さらに、最新のイメージの保存先についての情報を containing app から取得できるよう、共有領域のルートからの相対パスを shared container による UserDefaults の共有領域に保存しています。

ImageService.m

NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroupIdentifier];
[defaults setObject:filePath forKey:UserDefaultsLastestImagePathKey];
[defaults synchronize];

UserDefaults の共有領域には、initWithSuiteName: イニシャライザに App Group の identifier を渡して NSUserDefaults をインスタンス化するとアクセスできるようになります。

イメージの取り出し

イメージ取り出し処理のコードは下記の通りです。

ImageService.m

- (UIImage *)loadLatestImage {
    UIImage *image = nil;

    NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:AppGroupIdentifier];
    NSString *relativePath = [defaults objectForKey:UserDefaultsLastestImagePathKey];

    if (relativePath) {
        NSFileManager *fileManager = [NSFileManager defaultManager];
        NSURL *containerURL = [fileManager containerURLForSecurityApplicationGroupIdentifier:AppGroupIdentifier];
        NSString *filePath = [containerURL.path stringByAppendingPathComponent:relativePath];

        image = [UIImage imageWithContentsOfFile:filePath];
    }

    return image;
}

shared container による UserDefaults の共有領域からイメージ保存先の相対パスを取得します。それを元に、shared container によるローカルストレージの共有領域からイメージを読み込みます。

これで共通モジュールの実装は完了です。あとは、作成したクラスを Embedded Framework 外から利用できるように公開の設定をして下さい。 設定については、こちらの記事を参照して下さい。

Extension の実装

共通モジュールができたので、続いて host app から受け取ったイメージをローカルストレージに保存する Extension を実装します。

NSExtensionRequestHandling プロトコルの適合

先程、Extension のターゲットを作成した際に ActionRequestHandler というクラスが生成されています。このクラスは Info.plist で NSExtensionPrincipalClass に指定されており、host app からの呼び出しをハンドリングします。生成されたヘッダファイルは下記のように NSExtensionRequestHandling プロトコルに適合しています。

ActionRequestHandler.h

#import <UIKit/UIKit.h>

@interface ActionRequestHandler : NSObject <NSExtensionRequestHandling>

@end

host app からの呼び出しに対するハンドリング

ActionRequestHandler の実装は下記の通りです。

ActionRequestHandler.m

#import "ActionRequestHandler.h"
#import <MobileCoreServices/MobileCoreServices.h>

@import ActionExtensionNoUIEmbeddedLib;

@interface ActionRequestHandler ()

@property (nonatomic, strong) NSExtensionContext *extensionContext;

@end

@implementation ActionRequestHandler

- (void)beginRequestWithExtensionContext:(NSExtensionContext *)context {
    self.extensionContext = context;
    
    NSExtensionItem *item = self.extensionContext.inputItems.firstObject;
    NSItemProvider *itemProvider = item.attachments.firstObject;
    
    if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) {
        
        __weak typeof(self) weakSelf = self;
        [itemProvider loadItemForTypeIdentifier:(NSString *)kUTTypeImage options:nil completionHandler:^(id<NSSecureCoding> item, NSError *error) {
            if (error) {
                [weakSelf.extensionContext cancelRequestWithError:error];
                return;
            }
            
            if (![(NSObject *)item isKindOfClass:[UIImage class]]) {
                NSError *unavailableError = [NSError errorWithDomain:NSItemProviderErrorDomain
                                                                code:NSItemProviderUnexpectedValueClassError
                                                            userInfo:nil];
                [weakSelf.extensionContext cancelRequestWithError:unavailableError];
                return;
            }
            
            UIImage *image = (UIImage *)item;
            
            NSError *serviceError = nil;
            ImageService *service = [ImageService new];
            [service saveLatestImage:image error:&serviceError];
            
            if (serviceError) {
                [weakSelf.extensionContext cancelRequestWithError:serviceError];
                return;
            }
            
            [weakSelf.extensionContext completeRequestReturningItems:nil completionHandler:nil];
        }];
    } else {
        NSError *unavailableError = [NSError errorWithDomain:NSItemProviderErrorDomain
                                                        code:NSItemProviderItemUnavailableError
                                                    userInfo:nil];
        [self.extensionContext cancelRequestWithError:unavailableError];
    }
}

@end

beginRequestWithExtensionContext: メソッドで host app からの呼び出しをハンドリングしています。

host app から渡されたアイテムの確認とロード

host app から渡されたアイテムの確認とロードに関しては、通常の Action Extension と同様です。

__weak typeof(self) weakSelf = self;
if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) {
    [itemProvider loadItemForTypeIdentifier:(NSString *)kUTTypeImage options:nil completionHandler:^(id<NSSecureCoding> item, NSError *error) {
        if (error) {
            [weakSelf.extensionContext cancelRequestWithError:error];
            return;
        }
        
        // ...
    }];
} else {
    NSError *unavailableError = [NSError errorWithDomain:NSItemProviderErrorDomain
                                                    code:NSItemProviderItemUnavailableError
                                                userInfo:nil];
    [self.extensionContext cancelRequestWithError:unavailableError];
}

イメージの保存処理の実行と host app への処理完了の通知

下記コードは、イメージの保存処理の実行と host app への処理完了の通知を行う部分を抜粋したものです。

UIImage *image = (UIImage *)item;

NSError *serviceError = nil;
ImageService *service = [ImageService new];
[service saveLatestImage:image error:&serviceError];

if (serviceError) {
    [weakSelf.extensionContext cancelRequestWithError:serviceError];
    return;
}

[weakSelf.extensionContext completeRequestReturningItems:nil completionHandler:nil];

Embedded Framework に実装した ImageService を利用してイメージを shared container による共有領域に保存しています。また、処理完了後、NSExtensionContextcompleteRequestReturningItems:completionHandler: を呼び出して host app に処理完了を通知しています。

containing app の実装

containing app は、Extension によって保存されたイメージのうち、最新のものを画面に表示するだけの簡単なものです。

UI の作成

Storyboard は下図のように、UIImageView を画面いっぱいに配置し、Content ModeAspect Fit にします。AutoLayout の制約を定義し忘れると実行時にイメージが画面からはみ出してしまうので注意して下さい。

ios-actionextension-noui_013

ViewController の実装

ViewController の実装もシンプルです。Embedded Framework に実装した ImageService を利用して、画面の初期化時に Extension によって保存された最新のイメージをロードし、UIImageView に渡しています。

ViewController.m

#import "ViewController.h"

@import ActionExtensionNoUIEmbeddedLib;

@interface ViewController ()

@property (weak, nonatomic) IBOutlet UIImageView *imageView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    ImageService *service = [ImageService new];
    self.imageView.image = [service loadLatestImage];
}

@end

host app の実装

最後に、Extension を利用する host app を作成します。host app では、カメラロールからイメージを取得し、それを Activity View から Extension に渡せるようにします。

host app 用に Xcode でプロジェクトを作成してください。

UI の作成

Storyboard は下図のような構成です。ViewController に UIImageView を配置し、Navigation Bar 部分にカメラボタンとアクションボタンを配置しています。

ios-actionextension-noui_014

カメラロールからイメージを取得

カメラロールからイメージを取得する部分のコードは下記の通りです。

- (IBAction)cameraButtonDidTouch:(id)sender {
    UIImagePickerController *controller = [UIImagePickerController new];
    controller.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
    controller.delegate = self;

    [self presentViewController:controller animated:YES completion:nil];
}

- (void)imagePickerController:(UIImagePickerController *)picker
didFinishPickingMediaWithInfo:(NSDictionary *)info {
    UIImage *image = [info objectForKey:UIImagePickerControllerOriginalImage];
    self.imageView.image = image;

    [self dismissViewControllerAnimated:YES completion:nil];
}

- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
    [self dismissViewControllerAnimated:YES completion:nil];
}

カメラボタンをタッチされた際に、UIImagePickerController を表示してカメラロール内にあるイメージを選択できるようにします。イメージが選択されたら、UIImagePickerControllerDelegateimagePickerController:difFinishPickingmedaWithInfo: が呼び出されるので、ここでイメージを取り出して UIImageView にセットしています。

Extension の呼び出し

Extension の呼び出しを行う部分のコードは下記の通りです。

- (IBAction)actionButtonDidTouch:(id)sender {
    UIActivityViewController *viewController = [[UIActivityViewController alloc] initWithActivityItems:@[self.imageView.image]
                                                                                 applicationActivities:nil];
    [viewController setCompletionWithItemsHandler:^(NSString *activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) {
        // ...
    }];

    [self presentViewController:viewController animated:YES completion:nil];
}

アクションボタンがタッチされた際に UIActivityViewController を表示します。initWithActivityItems:applicationActivities: の一番目のパラメータに Extension に引き渡したいデータを渡します。今回は、カメラロールから取得したイメージを渡しています。

また、Extension の処理完了後のコールバックとして、setCompletionWithItemsHandler: メソッドに Blocks をセットしています。

Extension 呼び出しのコールバック

下記コードは、Extension の処理が完了もしくはキャンセルされた際に呼び出されるコールバック部分の抜粋です。

[viewController setCompletionWithItemsHandler:^(NSString *activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) {
    NSString *message;

    if (activityError) {
        message = activityError.localizedDescription;
    } else {
        message = @"イメージを保存しました";
    }

    UIAlertController *alertController = [UIAlertController alertControllerWithTitle:nil
                                                                             message:message
                                                                      preferredStyle:UIAlertControllerStyleAlert];
    UIAlertAction *action = [UIAlertAction actionWithTitle:@"OK"
                                                     style:UIAlertActionStyleDefault
                                                   handler:^(UIAlertAction *action) {
                                                       [self dismissViewControllerAnimated:YES completion:nil];
                                                   }];
    [alertController addAction:action];

    [self presentViewController:alertController animated:YES completion:nil];
}];

処理結果に応じてメッセージを作成し、アラートを表示しています。

動作確認

さて、これで containing app, Extension, host app の全ての実装が完了しましたので、動作を確認してみたいと思います。動作確認をする前に、必ず Extension を Xcode からデバッグ起動して、端末もしくはシミュレータにインストールしておいて下さい。

まずは、host app を起動してカメラボタンをタップします。

ios-actionextension-noui_015

カメラロールへのアクセス許可を確認されますので、OK をタップしてください。

ios-actionextension-noui_016

Picker View が表示されたら、適当なイメージを選択します。

ios-actionextension-noui_017 ios-actionextension-noui_018

host app の画面に選択したイメージが表示されます。

ios-actionextension-noui_019

次に、アクションボタンをタップして Activity View を表示します。

ios-actionextension-noui_020

Activity View の下側のリストに Extension が候補として表示されています。

ios-actionextension-noui_023

Extension を選択して下さい。少し待つと、Extension の処理が完了して host app に通知され、アラートが表示されます。

ios-actionextension-noui_024

containing app を起動すると、先程 Extension に渡したイメージが画面に表示されているのが確認できると思います。

ios-actionextension-noui_025

まとめ

Embedded Framework と App Group は最初こそ設定が面倒に感じますが、一度手順を把握すれば利用するのは比較的簡単です。App Extension を開発する際には積極的に活用していきましょう。