Kivyで作ったiOSアプリでHTMLコンテンツを表示するの巻その2(ローカルファイル編)

Kivyで作ったiOSアプリでHTMLコンテンツを表示するの巻その2(ローカルファイル編)

はじめに

この記事は、

Kivyで作ったiOSアプリでHTMLを表示するの巻

の続きです。

前回は、「webページの表示およびアプリのリソースファイルとしてiPhoneデバイス内に保存しているHTMLファイルの表示ができましたよ。」

っていう話でしたが、今回はiPhoneのローカルにあるダウンロードフォルダに保存しているHTMLファイルなどの表示についての記録でござる。

かなり手こずりましたが、iPhoneのローカルにあるダウンロードフォルダに置いたファイル群もWKWebViewを使って何とか表示することができたのでした。やればできるじゃないの。

というわけで、「もしかしたらこの世にもう1人くらいワタシと同じようなことをやりたい方がいるかも」と思ったので記事にしてみました。

記事の下の方にコード載せてます。もしご興味ございましたらどうぞ。

なお、HTMLファイルの表示はiPhoneシミュレータ上で確認しています(実機では未検証です)。

実施環境

MacOS Ventura 13.3.1

Xcode Version 14.3 (14E222b)

Python 3.9.12 (ただし、toolchainが連れてくる?のはPython 3.9.9 )

Kivy 2.1.0 (ただし、toolchainが連れてくる?のは Kivy v2.2.0.dev0)

Kivy-iOS 2022.7.19

MacBook Pro(2021), (Apple M1 Pro, メモリ 16GB)

ちょっと経緯

本題に入る前にちょっと経緯を。

KivyのFilechooserを使ってみる

iOSデバイス内部に保存されたファイルを表示したいので、まずは目的のファイルを探して選択できるようにする必要がありますやん。

ファイルを選択したときに、目的のファイルのPATHを取得できれば、

Kivyで作ったiOSアプリでHTMLを表示するの巻

で紹介したのと同じような方法で、HTMLコンテンツを表示できるはずですし。

で、KivyにはFilechooserというウィジェットがあって、ワタシはこれをAndroidアプリに実装したことがありますので、何だか簡単にいけそうな気がしておりますよ。

そこで、

参考記事:filechooserKivyのFileChooserのListViewで複数選択したときも色を変えてどれを選択したか分かるようにするの巻

で作ったコードをほぼそのまま使ってiOSアプリ化してみました。

iOSではKivyのFilechooserの動作がもっさりするということがわかる(Simulatorで)

するとどうでしょう。

無事にiOSアプリ化できたのは良かったのですが、いざ触ってみたら、あらら、動作がもっさり。遅延がひどくてなんかダメ。

Androidアプリで使ったときはそんなことなかったんですけど、simulatorだからなのか?(実機だったら大丈夫かもしれませんが、試していません)それともiOSとは相性が悪いのか?ワタシのコードがダメなだけかもしれんけど。。。うーんこれは困った。

ーーーー2023.4.29追記ここからーーーー

後日談。実機でやってみたらKivyのFileChooserがスムーズに動きました。あらー。

でも、実機ではファイルの表示ができなかった(simulatorではうまくいく)。。。なんでや( ´ ▽ ` )

“WebPageProxy::Ignoring request to load this main resource because it is outside the sandbox”

と言われるので、実機ではもうちょっと手を加える必要があるみたい。

ーーーー2023.4.29追記ここまでーーーー

なのでiOSのファイル選択機能を使うことにしました

まあそんなわけで、別のアプローチを考えることにしました。KivyのFilechooserウィジェットを使わず、iOSに備わっているファイル選択機能を使ってみよう、と。

んで、調べてみると、iOSアプリケーションでファイルを選択するためには、UIDocumentPickerViewControllerを使用すればいいのですと。

UIDocumentPickerViewControllerは、iOSアプリケーション内でファイルを選択するための標準的な方法で、ユーザーがiOSのファイルシステムをブラウズしてファイルを選択できるようにするものなのだとか。iCloud Driveやその他のクラウドストレージサービスへのアクセスも可能なんだそうな。ドキュメントピッカーとも呼ばれているようです。

じゃあこれをKivyのボタンなどのインターフェースを通して呼び出した上で、ファイルの情報をWKWebViewに渡してやればいいじゃないのってことなのですが、「でもじゃあどうすんのよ、イヤだわイヤだわ、さっぱりわからないわ。だってiOSアプリ作成のお作法を知らないんだもの。」って話ですよ。

はい。以下、KivyでiOSのUIDocumentPickerViewControllerを呼び出してHTMLファイル等を選択し、WKWebViewでファイルコンテンツを表示したときの記録です。ようやく始まるんかい。

なお、本記事では以降、UIDocumentPickerViewControllerによるiOSのファイル選択機能のことをドキュメントピッカーと呼ぶことにします。

やり方(概要)

ここからが本題です。ちょっと長くなるのでやり方の概要をば。

  • main.pyを作成(Python)
    • ボタンを押したら何かの関数を発動するコードを作る(この記事ではサンプルコードとして1個のボタンウィジェットを作成しています)

  • Xcodeでmain.pyに対応するプロジェクトを作成

  • XcodeプロジェクトにUniformTypeIdentifiersフレームワークを追加

  • main.mにコードを追加(Objective-C)
    • ドキュメントピッカーを表示するコードを追加
    • WKWebViewを使ってファイル内容を表示するコードを追加
  • main.pyにコードを追加(Python)
    • ボタンを押したらmain.mで定義したドキュメントピッカーを表示するメソッドを実行するようにコードを修正
    • ドキュメントピッカーでファイルを選択したときに呼び出されるコードを追加
    • ドキュメントピッカーをキャンセルしたときに呼び出されるコードを追加(必要なければ追加しなくても良い)

をやります。

で、Xcodeでビルドする、という流れです。

それでは詳しくみていきます。

やり方(詳細)

KivyでiOSのUIDocumentPickerViewControllerを呼び出してHTMLファイル等を選択し、WKWebViewでファイルコンテンツを表示したときの方法の記録です。

*「main.pyを作成(Python)」と「Xcodeでmain.pyに対応するプロジェクトを作成」は割愛します。

XcodeプロジェクトにUniformTypeIdentifiersフレームワークを追加

下準備として、

UniformTypeIdentifiersフレームワークに含まれているUTTypeに関するシンボルのリンクとやらが必要なので、XcodeプロジェクトにUniformTypeIdentifiersフレームワークを追加します。

1.Xcodeのプロジェクトナビゲーターで、プロジェクトファイルを選択します。

2.”General”タブを選択します。

3.”Frameworks, Libraries, and Embedded Content”セクションで、UniformTypeIdentifiers.frameworkがなければ、”+”ボタンをクリックして、

kivy_ios_app_display_html_2_2

4. UniformTypeIdentifiers.frameworkを選択して”Add”をクリックして追加します。

kivy_ios_app_display_html_2_1

これでファイルの種類を識別するためのUTTypeが使えるようになります。

main.mにコードを追加(Objective-C)

次のコードをmain.mに追加します。各処理の説明についてはコメント行をご参照ください。

コードの追加場所は次に示した場所です。

main.m(抜粋)

#include <dlfcn.h>

<ここにコードを追加します>

void export_orientation();
void load_custom_builtin_importer();


main.mに追加するコード

//UTTypeを使用するためにimport
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>

//WebViewを使用するためにimport
#import <Webkit/Webkit.h>


// Document picker関連処理を行うためのクラスを宣言
@interface MyObjCClass:NSObject <UIDocumentPickerDelegate>

//ドキュメントピッカーを表示する自作メソッド
- (void) showFilePicker : (id<UIDocumentPickerDelegate>) delegate;

//WKWebViewを使ってファイルコンテンツを表示するための自作メソッド
- (void) loadurlwebview:(NSURL *)url;

//NSURL型としてのURL格納用の変数
@property (nonatomic, strong) NSURL *url; 

@end

// Document pickerを取り扱うためのクラスの実装を定義
@implementation MyObjCClass

//@synthesizeしてインスタンス変数を自動で作ってもらう
@synthesize url;

//showFilePickerの実装を定義
- (void) showFilePicker : (id<UIDocumentPickerDelegate>) delegate {
    NSLog(@"fire showFilePicker");

    if (@available(iOS 14.0, *)) {
        //UTTypeはiOS 14以降なので、切り分ける。というかiOS 14以上を対象とする。
        NSLog(@"iOS 14 > ");

        // 拡張子txt/png/htmlのUniform Type Identifier (UTI)オブジェクトを返すよう指定するためにcontentTypes変数をNSArrayで定義
        NSArray<UTType *> *contentTypes = @[
            [UTType typeWithFilenameExtension:@"txt"],
            [UTType typeWithFilenameExtension:@"png"],
            [UTType typeWithFilenameExtension:@"html"]
        ];
        
        //UIDocumentPickerViewControllerクラスのインスタンス用にdocumentpicker変数を作ってnilを初期値として設定
        UIDocumentPickerViewController *documentpicker = nil;

        //initForOpeningContentTypesメソッドを使用して、contentTypesにて表示するファイルの種類を指定する形でUIDocumentPickerViewControllerクラスのインスタンス作成し、documentpicker変数に代入。
        documentpicker = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes: contentTypes];

        //domumentpicker.delegateにPythonから持ってきたMainappクラスのインスタンス(delegate)を設定する。これでpython側のコードと繋がる。
        documentpicker.delegate = delegate; 

    //ドキュメントピッカーをモーダル表示するために、presentViewController:animated:completion:メソッドを使用する。これに表示するViewControllerのインスタンスを渡す。アプリの最上位のViewControllerを取得して親ViewControllerとして使用するためにuiapp変数として使用する。
    UIViewController *uiapp = [[[[UIApplication sharedApplication] windows] objectAtIndex:0] rootViewController];
    [uiapp presentViewController:documentpicker animated:YES completion:nil];
    } else {
        //特に何もしない
    }
}

//Xcodeのコードナビゲーションメニューで検索やグループ化をするためのマーカーを作成
#pragma mark - UIDocumentPickerDelegate

//WKWevViewを呼び出して、指定したURIのファイルコンテンツを表示するメソッド
- (void)loadurlwebview:(NSURL *)url {

    // 画面のサイズを取得
    CGRect rect = [[UIScreen mainScreen] bounds]; 

    // WKWebViewインスタンスを作成し、フレームを画面のサイズに設定
    WKWebView *webView = [[WKWebView alloc] initWithFrame: rect]; 

    // 現在アクティブなウィンドウの中で最前面に表示されているウィンドウ(keyWindow)を取得
    UIWindow *window = [[UIApplication sharedApplication] keyWindow]; 

    // keyWindowのルートビューコントローラーのビュー(最初に表示された画面)を取得
    UIView *view = [window.rootViewController view]; 

    // ルートビューコントローラーのビューにWKWebViewを追加。これで前面に表示される。
    [view addSubview:webView]; 

    // ファイルアクセスのためのセキュリティースコープを取得し成功したら以下を実行
    if ([url startAccessingSecurityScopedResource]) { 
        @try {

            // リクエストを作成
            NSURLRequest *req = [[NSURLRequest alloc] initWithURL: url]; 
           
            // WKWebViewにリクエストをロードする
            [webView loadRequest: req]; 

            // ボタンを追加する
            // ビューのサイズと幅を取得
            CGSize contentSize = view.bounds.size; 
            CGFloat viewWidth = contentSize.width; 
            
            // UIButtonを初期化
            UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect]; 
            
            // ボタンのテキストカラーを設定
            [button setTitleColor:[UIColor blackColor] forState:UIControlStateNormal]; 

            //ボタンのタイトルを設定            
            [button setTitle:@"X" forState:UIControlStateNormal]; 

            // ボタンのフレームを設定。右上に設置。
            button.frame = CGRectMake(viewWidth-40, 0.0, 40, 40); 
            // ボタンにアクションを追加
            [button addTarget:webView action:@selector(removeFromSuperview) forControlEvents:UIControlEventTouchDown]; 
            // WKWebViewにボタンを追加
            [webView addSubview:button];
            
        } @catch (NSException *exception) {
            NSLog(@"何かがうまくいかなかったようです");
        } @finally {
            // セキュリティスコープを解放
            [url stopAccessingSecurityScopedResource]; 
        }
    }
}
@end

補足です。

・UTTypeを使用するためにUniformTypeIdentifiers/UniformTypeIdentifiers.hをimportする

・WKWebViewを使用するためにWebkit/Webkit.hをimportする

・Document picker関連処理のためのクラスMyObjCClassを作成する

NSObjectを継承して、UIDocumentPickerDelegateというプロトコルを採用した独自のMyObjCClassクラスを作成しておきます。

・ドキュメントピッカーを表示する自作メソッドをMyObjCClassに追加する

引数として、id<UIDocumentPickerDelegate>型のdelegateオブジェクトを受け取るようにします。UIDocumentPickerDelegateプロトコルを採用したオブジェクトを渡すことで、ドキュメントピッカーで選択されたファイルを、UIDocumentPickerDelegateプロトコルで定義されたメソッドを使って、処理することができるようになるんですと。

取り扱う拡張子の指定をします。今回はhtml、png、txtファイルを対象にしています。

取り扱う拡張子の指定およびデリゲートの設定をします。

・WKWebViewを使ってファイルコンテンツを表示する自作メソッドをMyObjCClassに追加する

指定されたURIのファイルコンテンツを表示するためにWKWebViewを使用しています。最初に、画面サイズを取得し、WKWebViewインスタンスを作成し、そのフレームを画面サイズに設定しています。次に、現在アクティブなウィンドウの中で最前面に表示されているウィンドウを取得し、そのルートビューコントローラーのビューを取得します。そこにWKWebViewを追加して前面に表示します。

その後、URLのアクセス権を取得し、NSURLRequestを作成して、WKWebViewにロードします。さらに、WKWebViewの上に「X」ボタンを追加し、ボタンを押すことでWKWebViewを削除するアクションを追加します。

最後に、例外処理を追加して、何かがうまくいかなかった場合に備え、セキュリティスコープを解放してリソースを開放します。

main.pyにコードを追加(Python)

main.pyは次のように作成しました。

from kivy.app import App 
from kivy.uix.button import Button
from kivy.uix.boxlayout import BoxLayout

#pyobjusのautoclassとprotocolをimport
from pyobjus import autoclass, protocol

class MainApp(App):
    def __init__(self):
        super(MainApp, self).__init__()

    def build(self):
        #button widgetを作成
        layout = BoxLayout(orientation='vertical')
        button = Button(text='Select File', on_press=self.show_filepicker)
        layout.add_widget(button)

        #main.mで作成したMyObjCClassのインスタンス作成
        self.myobj = autoclass('MyObjCClass').alloc().init()
        return layout

    #.mファイルの記述する版
    def show_filepicker(self, instance):
        #main.mのshowFilePicker_ メソッドを呼び出す
        self.myobj.showFilePicker_(self)


    #'UIDocumentPickerDelegate'プロトコルのdocumentPicker_didPickDocumentsAtURLs_ が呼び出された時(選択したファイルが開かれようとした時)に発動
    @protocol('UIDocumentPickerDelegate')
    def documentPicker_didPickDocumentsAtURLs_(self, controller, urls):
        #urlsの最初のオブジェクトを取り出して、main.mのloadurlwebview_ メソッドを呼び出す
        url = urls.firstObject()
        #選択したファイルのPATHをprint
        print('file path; ', url.path.UTF8String())
        self.myobj.loadurlwebview_(url)

    #'UIDocumentPickerDelegate'プロトコルのdocumentPickerWasCancelled_ が呼び出された時(filepickerがキャンセルされた時) に発動。なくても良い。
    #@protocol('UIDocumentPickerDelegate')
    #def documentPickerWasCancelled_(self, controller):
    #    print("Document picker was cancelled.")

if __name__ == '__main__':
    MainApp().run()

補足

コードの段取りは、要は

  1. PythonからMyObjCClassクラスを呼び出してドキュメントピッカーを表示する
  2. ドキュメントピッカーでファイルを選択したときにファイルのURIを取得する
  3. WKWebViewで表示する

ということなのですが、これをどうやってコードに落とし込むのが、そもそもの仕組みから理解できていないので骨が折れました。

まあ、では順番にみていきたいと思います。

  1. PythonからMyObjCClassクラスを呼び出してドキュメントピッカーを表示する

これはpyobiousのautoclassを使ってやれば簡単に呼び出せるのでいいとして、、、

2. ドキュメントピッカーでファイルを選択したときにHTMLファイルのURIを取得する
これがよく分からんかったのです。

iOSにおいて、ドキュメントピッカーでファイルが選択されたときには

UIDocumentPickerDelegateプロトコルの

documentPicker(_:didPickDocumentsAt:)メソッドが呼ばれます。

で、documentPicker(_:didPickDocumentsAt:)メソッドの戻り値がURLのarrayになっているので、これを受け取ることができればいいことがわかったのですが。。。。

ここで、いわゆるクラスメソッドだったらpyobjusのautoclassを使ってPythonから呼び出せるんですけど、UIDocumentPickerDelegateのようなプロトコルに従属するメソッドの場合はautoclassが使えない。

これについては、Pythonにおいてデコレータの@protocol(’プロトコル名’)をつけた後の行にプロトコルのメソッドに相当する関数を記述すれば、pyobjusを通じてプロトコールのメソッドを呼び出せる、と。

今回はUIDocumentPickerDelegateプロトコルのメソッドなので、

@protocol('UIDocumentPickerDelegate')

を付けます。

んが、使いたいそのメソッドはObjective-Cで起こったイベントにより発火するメソッドなので、Pythonから直接呼べない。で、どうやってObjective-Cで起こったイベントとPythonで記述したプロトコルに相当するメソッドを結びつけたらいいのか、さらに色々調べながら試行錯誤してみると、

・PythonからshowFilePickerメソッドを実行する時に、自身のインスタンスを渡しておく

・main.mのshowFilePickerの中で、受け渡したインスタンスをデリゲートに設定する

こうすることで、ドキュメントピッカーでファイルを選択した時に、Pythonで設定したdocumentPicker(_:didPickDocumentsAt:)メソッドとPython側の関数が結び付けられるということのよう。

「でりげーと?何それおいしそう。」っていうようなところから始めたのでここに至るまでにかなり時間かかりました。。。iOSアプリを作り慣れてる人だったらすぐ気がつくようなことなのかもしれません。。。

はい、とにかくこれで、Kivyのボタンを押した時にMyObjCClassクラスのshowFilePickerメソッドが呼ばれてドキュメントピッカーが表示され、ファイルが選択された時にdocumentPicker(_:didPickDocumentsAt:)メソッドの戻り値のURLのArrayをPython側で受け取ることができるようになりました。

ドキュメントピッカーがキャンセルされたときには

UIDocumentPickerDelegateプロトコルのdocumentPickerWasCancelled(_:)メソッドが呼ばれますので、このときに何かやりたければ同様にmain.pyにおいて関数を作ってやれば良いです。

ちなみに、Pythonに持ってくるときはObjective-Cのメソッド名の中の ”:” を ”_” にしておきます。例えば、Objective-Cで

documentPickerWasCancelled(_:)であれば、Python側では

def documentPickerWasCancelled_(引数):    
    関数が呼ばれた時の処理

と言ったように関数を定義してやればよくて、

documentPicker(_:didPickDocumentsAt:)の場合は、

def documentPicker_didPickDocumentsAt_(引数):    
    関数が呼ばれた時の処理

としてやればOKです。

結果

冒頭の動画をご参照ください。

おわりに

はい。Kivyで作ったiOSアプリから、ローカルに置いてあるHTMLファイル等の表示をする方法になんとか辿り着けたのでした。

正直、右も左もよくわからず、ああこれが暗中模索、五里霧中っていうのねって感じでした。分からないことを調べるにしても、ズバリの解答があるわけでもないので、iOSのお作法のほうからPythonに繋げられる方法がないかと調べていくことになるのですが、iOSアプリ作成の試みが初めてのワタシにはちんぷんかんぷんな説明ばっかり出てくるし(たぶん基礎的な内容なのでしょうけど、その素養がないワタシにはさっぱり。。。)、なんかすごい疲弊しました。

ああ、もうこりゃ、iOSアプリの王道の作り方をゼロから学んでプログラムを全部書き直したほうが楽なのでは??と思い始めた矢先、Python側からローカルファイルのPATHを取得することができました。らっきー。

と、Kivyを使っての初めてのiOSアプリの公開に向けて少しずつ進んでいけてる感じがしますが、まだまだ対応しないといけないことがモリモリで道のりは長そうです。挫折しないことを願う。。。

次はファイルを作ってそれをデバイスに保存する方法を調べたいと思っています。

おしまい。最後までお読みいただきありがとうございました。

*本記事のコードにご興味のある方はライセンス遵守下でご自由にご使用ください。

本記事に記載したコードのライセンスについて

main.pyおよびmain.mのコード

MIT License

Copyright (c) 2023 bu

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

参考にしたページなど

ありがとうございました。

https://pyobjus.readthedocs.io/en/latest/pyobjus_internal.html#using-objective-c-properties

https://github.com/kivy/kivy-ios/blob/master/kivy_ios/recipes/ios/src/ios_browser.m

https://stackoverflow.com/questions/74716493/how-to-read-files-in-ios

https://github.com/kivy/pyobjus/issues/78

https://qiita.com/yosshi4486/items/b8d1ff757d8986b1f2b1

https://qiita.com/mittsu/items/5e027f4cb62719abba72

https://github.com/tyfkda/GawaNativeIos

https://selection9.blogspot.com/2011/04/uiwindow-uikit.html

https://github.com/kivy/pyobjus/blob/master/examples/delegate.py