いものやま。

雑多な知識の寄せ集め

Mac用とiOS用のフレームワークを作ってみた。

以前、強化学習用のニューラルネットワークをSwiftで書いていた。

ここで最後に触れた問題が、これ。

NSKeyedArchiver/NSKeyedUnarchiverでオブジェクトをエンコード/デコードすると、クラス名が(モジュール名).(クラス名)となるため、ある実行ファイルで保存したファイルを他の実行ファイルでロードすると、例外が発生する。

この問題があるので、例えば、

  1. Mac用のアプリを作って学習を行う。
  2. 学習で得られたAIをエンコードしてファイルに保存する。
  3. iOS用のアプリで保存されたファイルをデコードしてAIを復元する。

としようとすると、モジュール名が異なってデコードに失敗するという問題が出てくる。

もちろん、保存/復元するときに、NSKeyedArchiver/NSKeyedUnarchiverを使わず、Dictionaryとかにデータを置き直せば、このような問題は起きない。
強化学習用のニューラルネットワークをSwiftで書いてみた。(その6) - いものやま。でちょっと言及してたのは、このこと。
ただ、上記の記事でも言及してる通り、再帰的な構造を持つので、かなり面倒・・・

これを解決するもう一つの方法は、フレームワークを作ること。
フレームワークを作ってMac用のアプリとiOS用のアプリでそれぞれ使えば、フレームワークはアプリとモジュールが異なるので、独立したモジュール名になり、Mac用のアプリでもiOS用のアプリでもクラス名が同じ(フレームワークのモジュール名).(クラス名)になってくれる。

実際にやろうとすると起こる問題

ただ、これをいざやろうとすると、思った以上にいろいろな罠が・・・

まず、同じバイナリのフレームワークMac用のアプリとiOS用のアプリの両方に使うことは出来ない。
これは考えてもみれば当然で、Macx86_64アーキテクチャ、一方iOSARMアーキテクチャなので、機械語のレベルで動作環境が異なってる。
なので、Mac用のフレームワークiOS用のフレームワークは、それぞれ別のバイナリを用意してやらないといけない。

その上で、Mac用とiOS用のフレームワーク名は同じにしないといけない。
というのも、フレームワーク名がそのままモジュール名となるので、それが変わってしまったら、やろうとしていたNSKeyedArchiver/NSKeyedUnarchiverによるエンコード/デコードが出来なくなってしまうから。

このとき壁となるのが、1つのプロジェクトで同名のターゲットを作ることが出来ないという制約。
この制約がなければ、プロジェクトにMac用のフレームワークのターゲットとiOS用のフレームワークのターゲットを同名で用意すればいいだけなんだけど、この制約があるので、このようには出来ない。

解決方法

結果、試行錯誤して得られたのが、次の方法:

  1. 入れ物となるプロジェクトを作る。
  2. 入れ物となるプロジェクトにソースコードを用意する。
  3. ビルド用のサブプロジェクトをMac用とiOS用に用意する。
  4. ビルド用のサブプロジェクトにターゲットを追加する。
  5. ビルド用のサブプロジェクトにソースコードを追加する。
  6. それぞれのサブプロジェクトでビルドを行う。

正直、かなり手間・・・
まぁ、確かにちょっと特殊なユースケースなので、仕方ないのかもしれないけど。

以下で、手順の詳細を見ていく。

入れ物となるプロジェクトを作る

まずは、入れ物となるプロジェクトを作るところから。

その前に。後々アプリを作るプロジェクトも追加するので、最初にワークスペースを用意しておくといい。
ここでは、MultiTargetというフォルダを用意して、その中にMultiTarget.xcworkspaceというワークスペースを用意した。

f:id:yamaimo0625:20160619232551p:plain

まだ中身は空っぽ。
ここに、入れ物となるプロジェクトを作っていく。

まず、左下の"+"ボタンから、"New Project..."を選択。

f:id:yamaimo0625:20160619232921p:plain

ここで、空のプロジェクトを作るために、"Other"の中にある"Empty"という雛形を選ぶ。

f:id:yamaimo0625:20160619233031p:plain

"Empty"という雛形を選んだら、プロジェクト名を入力し(ここでは安直に"Test"とした。実際には"XYZFramework"といったようにした方がいい)、プロジェクトを作成する。
このとき、MultiTargetワークスペースに追加するようにしておく。
バージョン管理を行いたい場合、"Create Git repository on ..."にチェックも忘れずに。

f:id:yamaimo0625:20160619233416p:plain

これで入れ物となるプロジェクトが出来た。

入れ物となるプロジェクトにソースコードを用意する

入れ物となるプロジェクトが出来たら、ソースコードの用意。
Mac用のフレームワークiOS用のフレームワークも、ソースコード自体は共通したものを使うので、ソースコードはこの入れ物となるプロジェクトに用意する。

"Common"というグループ(※名前は任意)を入れ物となるプロジェクトの下に作り、フォルダも作っておく。
そして、そのフォルダに共通で使われるソースコードを用意。

ここでは、以下のような簡単なコードを用意した。

f:id:yamaimo0625:20160619234038p:plain

ビルド用のサブプロジェクトをMac用とiOS用に用意する

さて、ここからが肝心。
ビルドを行うためのサブプロジェクトを、この入れ物のプロジェクトに追加していく。

まず、入れ物となるプロジェクトを選択した状態で、左下の"+"ボタンから、"New Project..."を選択。

f:id:yamaimo0625:20160619234447p:plain

そして、"Other"から"Empty"の雛形を選ぶ
Mac用のフレームワークを作るときも、iOS用のフレームワークを作るときも、"Cocoa Framework"や"Cocoa Touch Framework"を選ばない、というのがポイント。

f:id:yamaimo0625:20160619234846p:plain

なんでこんなことをするのかというと、プロジェクト名とターゲット名を別にしたいから

冒頭で述べた通り、今、ターゲット名はMac用でもiOS用でも同じにしたいという要求がある。
けど、一方で、同じプロジェクト名は使えない。
ここで、もし"Cocoa Framework"や"Cocoa Touch Framework"を選んでしまうと、プロジェクト名と同じターゲット名が自動的に作られてしまう。
そうなると、プロジェクト名を同じに出来ないということから、異なるターゲット名が自動で作られてしまうことになる。
これは都合が悪い。

もちろん、あとでターゲット名を変えたりすることも出来るのだけど、そのためにいろいろと修正が必要になるので、そういった手間が必要になるくらいなら、最初は空のプロジェクトを作って、あとからターゲットを追加した方が簡単。

なお、サブプロジェクトを作るとき、入れ物となるプロジェクト(今回の場合、"Test")のグループに追加するというのを間違えないように。

f:id:yamaimo0625:20160619235558p:plain

さて、ここではMac用のサブプロジェクトを"Test_Mac"という名前、iOS用のサブプロジェクトを"Test_iOS"という名前で、それぞれ用意した。

f:id:yamaimo0625:20160619235656p:plain

見ての通り、まだ中身は空っぽ。
ここにターゲットを追加していく。

ビルド用のサブプロジェクトにターゲットを追加する

ターゲットを追加するプロジェクトを選択した状態で、"TARGETS"の下の方にある"+"ボタンを押して、ターゲットを追加する。

f:id:yamaimo0625:20160620222953p:plain

そして、Mac用のフレームワークを作るなら、"Cocoa Framework"、iOS用のフレームワークを作るなら、"Cocoa Touch Framework"を選択。

f:id:yamaimo0625:20160620223053p:plain

f:id:yamaimo0625:20160620223224p:plain

そのあと、"Product Name"にフレームワーク名にしたい名前をに入力する。

f:id:yamaimo0625:20160620223132p:plain

このとき、Mac用のフレームワークiOS用のフレームワークも同じ名前にしておくのがポイント。

両方にターゲットを追加すると、以下のようになる。

f:id:yamaimo0625:20160620223959p:plain

まだビルドされていないので赤字になっているけど、同名のフレームワークがビルドされるようになっていることが分かると思う。

ビルド用のサブプロジェクトにソースコードを追加する

ターゲットも追加できたので、あとはサブプロジェクトにソースコードを追加するだけ。

ソースコードを追加するサブプロジェクトを選んだ状態で、コンテキストメニューから"Add Files to (サブプロジェクト)..."を選択。

f:id:yamaimo0625:20160620234255p:plain

そして、共通で使用するソースコードが入っているフォルダを選択する。

f:id:yamaimo0625:20160620234841p:plain

追加すると、こんな感じ。

f:id:yamaimo0625:20160620235301p:plain

ちなみに、同じ名前のソースコードが3つあるように見えるけど、実体は1つで、全部同じファイルになっている。
なので、1つのファイルを編集すれば、他のファイル(と言っても同じファイルなんだけど)の内容も同時に変わる。

最終的なフォルダ階層は、以下のような感じ。

f:id:yamaimo0625:20160620235527p:plain

それぞれのサブプロジェクトでビルドを行う

ここまできたら、あとはビルドするだけ。

スキームを切り替えて、"Product"メニューから"Build"を選べば、それぞれのフレームワークがビルドされる。

f:id:yamaimo0625:20160620235646p:plain

アプリの作成

せっかくなので、作ったフレームワークを使って簡単なアプリを作ってみる。

アプリを作る場合、同じワークスペースでプロジェクトを追加すると、作ったフレームワークをそのまま参照できるのでいい。
ということで、ワークスペースにプロジェクトを追加。
"Command Line Tool"の雛形を選ぶ。

f:id:yamaimo0625:20160621000246p:plain

追加するときには、入れ物となるプロジェクトに追加するのではなく、ワークスペースに追加するように注意。

f:id:yamaimo0625:20160621000427p:plain

プロジェクトを作ったら、ターゲットにリンクするフレームワークを追加する。
"TARGETS"の"Build Phases"、"Link Binary with Libraries"の"+"ボタンを押して、リンクするフレームワークを追加。

f:id:yamaimo0625:20160621000653p:plain

ダイアログの"Workspace"のところに作ったフレームワークが候補として出るので、適切なものを選択する。
Mac用とiOS用が同じ名前であるので、間違えないようにw(プロジェクト名でMac用かiOS用かは分かる)

f:id:yamaimo0625:20160621000917p:plain

あとはmain.swiftをちょろちょろっと。

f:id:yamaimo0625:20160621000941p:plain

作ったフレームワークは、import "(作ったフレームワーク名)"とすれば、使うことが出来る。

あとはビルドして実行すればOK。

・・・で終わらせたいんだけど、コマンドラインツールの場合、ちょっと問題があって、もう一手間(^^;

このまま実行すると、ビルドは成功するんだけど、"libswiftXXX.dylib"がないと怒られて、実行時エラーになるかもしれない。
その場合、フレームワークターゲットのビルド設定で、"Build Settings" - "Build Options" - "Embedded Contnt Contains Swift Code"の設定を"YES"に変えてやる必要がある。
こうして実行すれば、実行時に警告がたくさん出る問題が別に起きてくるんだけど、実行自体は出来るようになる。

※これは現状のSwiftの仕様バグだと思う。
現状、Swiftで書かれたコードを動かすには、ランタイムで"libswiftXXX.dylib"という動的ライブラリが必要になっている。
Swiftで普通のアプリを作った場合には、このライブラリがバンドルのFrameworks内に存在するので、問題なくロード出来るのだけど、コマンドラインアプリの場合には、バンドルではないので、これらのライブラリが存在しない。
じゃあ、それらのライブラリが存在しない状態で、コマンドラインアプリがどうして動くのかといえば、コマンドラインアプリの場合、おそらくこのライブラリが静的にリンクされているから。
けど、フレームワークを使おうとした場合には、動的ライブラリはファイル名で検索がかけられてロードされるので、実際にはリンクすべきシンボル自体はコマンドラインアプリのバイナリの中に存在しているのだけど、フレームワークのSwiftのコードからはこのライブラリを見つけることが出来ず、ロードに失敗するのだと思う。
なお、"Embedded Contnt Contains Swift Code"のオプションは、フレームワークObjective-Cなどで書かれたアプリから利用されたときのためのもの。
このオプションをつけると、フレームワークのバンドル内にFrameworksフォルダが用意され、そこにこれらのライブラリがコピーされるようになっている。
なんでそうするのかというと、Objective-Cなどで書かれたアプリの場合、アプリのバンドル内に"libswiftXXX.dylib"などの動的ライブラリが存在しないので、フレームワークは自前でこの動的ライブラリを用意してやらないといけないから。
そこで、このオプションをつけて動的ライブラリのコピーを自前で持っておけば、アプリ側がこれらのライブラリを持っていなくても、ちゃんと動くようになってくれる。
ということで、このような挙動になっているので、このオプションをつけると、コマンドラインアプリの場合もちゃんとロードが出来るようになる。
ただ、今度はロードされたモジュール内と、静的にリンクされたコマンドラインアプリ内に、同じシンボルが2つ存在してしまうことになる。
これが新たに出てくる警告の正体。
なので、本当は"libswiftXXX.dylib"などの動的ライブラリはシステムのロード可能なパスに置かれていて、コマンドラインアプリを作った場合も、ランタイムで動的にロードするとなっていないといけない。

今日はここまで!