[Swift] Plugin アーキテクチャでの Plugin 対応

     

TAGS:

開発したアプリケーションに、外部から拡張機能を追加できるようにする仕組みを説明します

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.5 Beta
  • Xcode 14.0 beta

アプリの機能を拡張する

Xcode 等の IDE でもよくありますが、アプリケーションに機能を追加する/したい場合があります。

アプリによっては、課金等で機能が解放されることもありますし、外部からファイル等を追加することで、アプリケーションの機能が追加されるというパターンもあります。

前者は、アプリケーション内部にはあらかじめ機能が実装されていてその機能を使用できるようにする/しない という制御をおこなっていると思われますので、プログラム的には、if 文(相当)での制御で可能です。

この記事では、外部からファイル等を追加することでアプリの機能を拡張できるようにする方法を説明します。

このような外部から追加して機能拡張する仕組みは Plugin と呼ばれたりします。

Eclipse は、このようなアーキテクチャをベースとしていることは有名だと思いますし、近いところでは、Safari にも数多くの機能拡張が Plugin として用意されています。

アプリによっては、Plugin 向けの API が公開されていて、自分で機能拡張を作成することもできるようになっています。

Apple のアプリであれば、Mail や Photos がそのような機能拡張を実装できるようになっています。

この記事では、そのような機能拡張を実装する方法を説明していきます。

Photos の機能拡張を作る方法自体は、以下の記事で説明しています。

SwiftUI[SwiftUI][Image] イメージ処理アプリを作る(7)

Pluginアーキテクチャ

以下の説明では以下のような意味で言葉を使用しています。

・HostApp アプリ本体のこと。Plugin によって機能が拡張されるアプリ。
・Plugin 機能拡張するための Plugin のこと。HostApp 内部に読み込まれ実行されることで機能拡張が実現される。

HostApp に Plugin を読み込む仕組みを作っておけば、HostApp を変更せず、Plugin を追加するだけで、HostApp の機能が拡張される(ように見える)ことになります。

サンプルアプリ

実装のサンプル向けに 以下のような機能のアプリを考えます。

HostApp については、以下です。
・HostApp は、起動時に、Plugin を確認し、メインウィンドウに、Plugin それぞれの機能を実行するため Button を作成・表示する

Plugin は、以下のような特徴をもっているとします。
・Plugin は、自分の名称と実行すべき機能を持っている

多くのプラグインをインストールされれば 多くの Button が表示されるでしょうし、プラグインが見つからなければ、1つの Button も表示されない形になります。

AppImage

上記は、"Hallo", "こんにちわ", "ちわっ" という 3つの Plugin をインストールしている状態です。

起動しているアプリケーション自体は同一のバイナリを使いますが、Plugin がインストールされているかどうかで、表示されるボタンが増減するように作ります。

例えば、1つしかインストールされていないと 同じバイナリを起動しても、以下のようになる予定です。

AppWithonly1Plugin

Bundle

Plugin を実現する仕組みとしては、Bundle を使います。

Apple のドキュメントは、こちら

Bundle には、画像等のリソースだけではなく、コードを含めておくこともできます。
その仕組みを利用して、Bundle からコードを取り込み実行できるようにします。

Bundle の扱い方については、こちら
# 少し古いドキュメントですが、更新された新しいドキュメントは見つかりませんでした・・・

Plugin となる Bundle を見つける

macOS アプリは、Plugin 向けのフォルダを内包することができるようになっています。

その Plugin 向けフォルダは、以下のようにして取得することができます。


let pluginsURL = Bundle.main.builtInPlugInsURL

この場所に置かれている Bundle をプラグインかどうかをチェックしつつ 使っていくことにします。
実際に、Bundle を取り込むコードは次のようになります。


        guard let pluginsURL = Bundle.main.builtInPlugInsURL else { return [] }
        
        guard let bundleURLs = try? FileManager.default.contentsOfDirectory(at: pluginsURL, includingPropertiesForKeys: nil).filter({$0.pathExtension == "bundle"}) else { return [] }
        for bundleURL in bundleURLs {
            if let pluginBundle = Bundle(url: bundleURL) {
                // process bundle
            }
        }

Bundle からコードを取り込む

plugin 向けのフォルダに含まれている Bundle を読み込みましたが、そこからコードを取り込まなければいけません。
# UIKit/AppKit での Storyboard や XIB から ViewController や View を取り込むのと似ていますが、Bundle にインスタンス化されたオブジェクトが保存されているわけではありません。

Bundle を読み込めても、使うべきクラス名がわからないと、使いようがありません。
Bundle 自身は自分で定義しているので 使うべきクラスがわかりますが、Bundle を Plugin として使用するアプリ本体からは、どのようなクラスが定義されていて かつ どのクラスを使うのかは不明です。

アプリ本体のバイナリ作成後に Bundle が作成されるケースを考えると、アプリ本体に Bundle の情報を持たせることはできません。

macOS の Bundle では Bundle 外から Bundle の情報を取得することができるように、Bundle には、principalClass と呼ばれる情報を設定することができるようになっています。

principalClass

principalClass は、Bundle に含まれるクラス等を利用するためのきっかけとして使用することを目的とした情報です。

例えば、principalClass として設定されているクラスから Bundle に含まれる class 情報を取得することで、アプリ本体/Bundle 外 からも Bundle に含まれている class を知ることができます。

この principalClass の情報は Bundle に含まれる Info.plist に設定します。

なお、何らかの問題(principalClass に指定されたクラスが実際には定義されていなかった等)が発生すると principalClass には、nil が設定されます。

principalClass をインスタンス化

principalClass の型は AnyObject ですが、型情報が入っているだけです。取得した後に instance 化して使う必要があります。


            let bundle: Bundle = ... 
            if let principalType = bundle.principalClass {
                let instance = principalType.init()
                // do something with principalType instance
            }

Plugin プロトコル

Bundle から クラスをロードできても、どのようなメソッドが用意されていて・・・ということがわからないと使えません。動的にメソッドの有無を確認して使用していくという方法もありますが、少し手間です。

Plugin のアーキテクチャとして よく使われるのは、Plugin が満たすべき Protocol を定義しておき、Plugin として使用されるクラスは、その Protocol に 準拠する。Plugin を使う側はその Protocol に準拠していることを確認して使用していく という方法かと思います。

ということで、Plugin が満たすべき Protocol を定義します。PluginFunction という名前にしてみました。


public protocol PluginFunction: Identifiable, Hashable {
    var id: UUID { get }
    var title: String { get }
    func execute()
}

機能名称として title プロパティを、実行機能として execute メソッドを持つと定義しています。

Plugin としては、この PluginFunction に準拠した class を定義し、execute に機能を実装するという想定です。

実際には title と execute だけでは十分ではないと思いますが、Plugin に期待する機能依存ですので、深く検討しません。

さらに、Bundle の principalClass が満たすべき Protocol も定義しておくと、その Protocol を使って、Bundle を Plugin として扱って良いかどうかが判断できるようになりますので、あわせて定義します。PluginProtocol としました。


public protocol PluginProtocol {
    init()
    func functions() -> [any PluginFunction]
}

1つの Bundle 内に含まれる機能情報を取得できるように関数 functions を定義します。
1つの Bundle に 複数の 機能を持つケース をサポートできるように、返り値は、PluginFunction の配列にしています。

なお、関数 functions の返り値には、any が使われていますが、Swift5.6 で導入されたキーワードです。[any Type] は、Type に準拠するさまざまな型を1つの配列内に持つことができます。

HostApp 側実装

HostApp 側は以下のような処理になります。
・Plugin フォルダから Bundle を見つける
・Bundle から principalClass を取得する
・principalClass からPlugin クラスを取得する
・取得した Plugin クラスを使ってアプリを動かす
今回は、取得した Plugin クラス それぞれに、その機能を実行できるボタンを作成し配置します。

今回は起動時に Plugin の有無をチェックしています。

UIKit や AppKit では AppDelegate に組み込んで 処理することを考えるかもしれませんが、
SwiftUI と組み合わせてサンプルを作りたかったので、1つの class として定義しました。


//
//  AppPluginManager.swift
//
//  Created by : Tomoaki Yagishita on 2022/06/09
//  © 2022  SmallDeskSoftware
//

import Foundation
import PluginInterface

class AppPluginManager: ObservableObject {
    @Published var pluginFunctions: [any PluginFunction] = []
    init() {
        let bundles = findPluginBundles()
        pluginFunctions = loadPlugins(from: bundles)
    }
    
    func findPluginBundles() -> [Bundle] {
        var ret:[Bundle] = []
        
        // bundle path
        guard let pluginsURL = Bundle.main.builtInPlugInsURL else { return [] }
        
        guard let bundleURLs = try? FileManager.default.contentsOfDirectory(at: pluginsURL, includingPropertiesForKeys: nil).filter({$0.pathExtension == "bundle"}) else { return [] }
        for bundleURL in bundleURLs {
            if let pluginBundle = Bundle(url: bundleURL) {
                ret.append(pluginBundle)
                //pluginBundle.load() // FIXME: where should we load? or we can rely on implicit load?
            }
        }
        return ret
    }
    
    func loadPlugins(from bundles:[Bundle]) -> [any PluginFunction] {
        var ret: [any PluginFunction] = []
        for bundle in bundles {
            if let principalType = bundle.principalClass as? PluginProtocol.Type {
                let instance = principalType.init()
                ret.append(contentsOf: instance.functions())
            }
        }
        return ret
    }
    
}

PluginInterface 実装

HostApp が期待する Interface を Plugin 側と共有しなければいけません。今回は、PluginInterface という Framework として作りました。そして、この Framework を HostApp / Plugin 両方へ組み込む形にしています。


//
//  PluginProtocol.swift
//
//  Created by : Tomoaki Yagishita on 2022/06/09
//  © 2022  SmallDeskSoftware
//

import Foundation
public protocol PluginFunction: Identifiable, Hashable {
    var id: UUID { get }
    var title: String { get }
    func execute()
}


public protocol PluginProtocol {
    init()
    func functions() -> [any PluginFunction]
}

上記のコードだけを含む Framework を作成しました。

Plugin 実装

Plugin を実装していきます。Plugin 側では PluginInterface で定義している PluginProtocol, PluginFunction それぞれに準拠する class を実装していきます。

以下のクラスは、Hallo と print するための Plugin です。

この Bundle には、HalloFunction というクラス1つのみが含まれるので、HalloPlugin の functions メソッドは、HalloFunction 1つのみを含む配列を返しています。

HallFunction は、title として、ボタンタイトルになる "Hallo" 、機能自体として、execute メソッドで、print("Hallo") を実行するような定義になっています。


//
//  HalloPlugin.swift
//
//  Created by : Tomoaki Yagishita on 2022/06/09
//  © 2022  SmallDeskSoftware
//

import Foundation
import PluginInterface

final class HalloPlugin: PluginProtocol {
    init() {}
    func functions() -> [any PluginInterface.PluginFunction] {
        return [HalloFunction()]
    }
    

}


class HalloFunction: PluginFunction, Hashable {
    static func == (lhs: HalloFunction, rhs: HalloFunction) -> Bool {
        lhs.title != rhs.title
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(title)
    }
    var id: UUID
    init() {
        id = UUID()
    }
    
    var title: String { "Hallo" }
    func execute() {
        print("Hallo")
    }
}

まとめ

アプリケーションを Bundle を使って Plugin 対応する方法の基礎部分を説明しました。

長くなってしまったので、サンプルアプリ等は、別記事で説明します。

アプリケーションで Plugin 対応する方法
  • Bundle に拡張機能のコードを実装する
  • HostApp 起動時に、Plugin の有無をチェックし HostApp に読み込む
  • HostApp / Plugin で Plugin 向けプロトコルを共有する

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

Swift ポケットリファレンス

Swift を学んでも、プログラミング言語の文法を全て記憶しておくことは無理なので、ちょっとした文法の確認をするために、リファレンス本を手元に置いておくと便利です。

注意
Swift4 までしか対応していないので、相違点を理解して参照する必要があります。

そろそろ Swift5 に対応した版が欲しいですね・・・

コメントを残す

メールアドレスが公開されることはありません。