🌉

PythonistaでRubicon-ObjCを使う

2024/04/04に公開

PythonistaでObjective-Cの機能を使うときは、基本的にはobjc_utilを使うと思います。

しかし、Rubicon-ObjCはより使いやすく、できることもかなり増えています。
ここでは、PythonistaでRubicon-ObjCを使うための基本的な方法を紹介します。

Rubicon-ObjCとは

Pythonistaでは、標準のobjc_utilモジュールを使うことで、Objective-C(iOSの内部で使われているプログラミング言語)のクラスにアクセスでき、uiモジュールなどには用意されていない機能も使用することができます。

このobjc_utilモジュールは、Rubicon-ObjCというライブラリの昔のバージョンを元にしたものなのだそうです。

https://github.com/beeware/rubicon-objc

Rubicon-ObjCは、PytoというPythonistaに似たアプリでは標準で使われていて、現在のバージョンはobjc_utilよりもかなり使い勝手がいいです。

また、個人的にはSwiftに近い書き方ができるので楽だと感じています。Rubicon-ObjCの特徴としては、例えば以下のようなものがあります。

  • クラスの定義・継承をPythonクラスとして直接行える
  • プロトコルがPythonオブジェクトとして扱え、自作もできる
  • ポインタからObjCInstanceへの自動変換がある
  • プロパティには()を付ける必要がない
  • send_superが使える

あと、objc_utilのObjCBlockは、Pythonista 3.4で使うとクラッシュしてしまいますが、Rubicon-ObjCでは使用できます。

仕組み

Pythonでは、ctypesモジュールを用いて、C言語の機能へアクセスできます。macOSやiOSでは、Cから間接的にObjective-Cクラスなどの操作ができ、その機能をPythonで使いやすくしたのがRubicon-ObjCです。

rubicon.objc.runtime.libcは、ctypes.CDLLを使って/usr/lib/libc.dylibのライブラリを読み込んだものであり、rubicon.objc.runtime.libobjcは、同様に/usr/lib/libobjc.dylibのライブラリを読み込んだものです。

libobjcには、例えばobjc_allocateClassPair(Objective-Cのクラスを割り当てる関数)などの、Objective-Cランタイムにアクセスする関数が含まれており、これらを使ってよりPythonで使いやすいような仕組みが作られています。

Pythonistaに導入

Rubicon-ObjCはStaShなどでインストールできます。(詳しい使い方はここでは説明しません)

https://github.com/ywangd/stash

$ pip install rubicon-objc

インポートしてみてエラーが出る場合は

$ pip install setuptools_scm

こちらもインストールしましょう。

ちなみに、こちらのPythonista3 pip configration toolもおすすめです。

https://github.com/CrossDarkrix/Pythonista3_pip_Configration_Tool

使用方法

ここからは、基本的な使い方を挙げていきます。より詳細な使い方は、ドキュメントを参照してください。

https://rubicon-objc.readthedocs.io/

Objective-Cクラスの使用

NSObject *obj1 = [[NSObject alloc] init];
NSObject *obj2 = NSObject.new;

これはRubicon-ObjCでは次のようになります。

from rubicon.objc import ObjCClass

NSObject = ObjCClass("NSObject")

obj1 = NSObject.alloc().init()
obj2 = NSObject.new()  # alloc().init()と同様

ObjCClassを使用することで、Objective-Cのクラスを取得できます。
取得したクラスのメソッドは、Pythonのメソッドと同じように実行できます。

ただし、バージョン0.4.8現在は、このような書き方はできません。

NSObject()

NSObjectなどのいくつかの基本的なクラスは、from rubicon.objc import NSObjectのようにインポートすることもできます。

メソッドの呼び出しと引数、プロパティ

Rubicon-ObjCでは、基本的にObjective-Cメソッド(セレクタ)の:_に置き換えます。

MyClass *obj2 = [obj someMethod:value1 arg2:value2];

これは次のようになります。

obj2 = obj.someMethod_arg2_(value1, value2)

または

obj2 = obj.someMethod(value1, arg2=value2)

後者のほうがよりPythonらしい書き方になっていて、メソッド名が長くなりにくく、改行がしやすいのでおすすめです。

プロパティを使う場合は、()を付ける必要はありません。

var1 = obj.someProperty  # プロパティを取得
obj.someProperty = var2  # プロパティに代入

Objective-Cクラスの定義

Pythonistaでクラスを定義する場合、注意点があります。
Pythonistaでは、Objective-Cランタイムがアプリを立ち上げ直さない限りリセットされません。そのため、同じPythonファイルを複数回実行すると、クラスの定義が重複してしまい、エラーになってしまいます。
ObjCClass.auto_renameをTrueにしておくことで、クラス定義が重複した場合、Objective-Cランタイム内の名前を自動で変更するようにできます。(例えば、MyObjectMyObject_2になります。)

ObjCClass.auto_rename = True

Rubicon-ObjCでは、Pythonのクラスを定義するようにObjective-Cのクラスを定義できます。

class MyClass(NSObject):
    ...  # メソッド、プロパティなど

Objective-Cのクラスを定義する場合は、必ずNSObjectかそのサブクラスを指定する必要があります。また、複数のクラスを親クラスとして指定することはできません。

定義時に、キーワード引数としてauto_rename=Trueを設定すれば、ObjCClass.auto_renameFalseになっていても、重複したクラス名が許容されるようになります。

class MyClass(NSObject, auto_rename=True):
    ...

クラス定義の際に指定するauto_renameは、デフォルトではNoneになっており、TrueまたはFalseが指定されている場合のみその値が優先され、Noneの場合はObjCClass.auto_renameが優先されます。

メソッドの定義

メソッドの定義は、objc_methodobjc_classmethodを用いて行います。

class MyClass(NSObject):

    @objc_method
    def someMethodWithArg1_Arg2_(self, arg1, arg2):
        ...

    @objc_classmethod
    def someClassMethod(cls):
        ...

obj = MyObject.new()
obj.someMethod("message")

MyObject.someClassMethod("message")

objc_utilとは違って、_cmd引数は必要なく、self = ObjCInstance(_self)のようにPythonオブジェクトに変換する必要もありません。

ただし、NSIntegerCGRectなどは、Objective-Cのオブジェクトではなく、Cの型にあたるので、このように特別にアノテーションを付ける必要があります。

    @objc_method
    def someMethodWithInteger_(self, integer: NSInteger) -> NSInteger:
        ...

何も指定していないと、objc_idの扱いになります。
idは、Objective-Cオブジェクト全てを示す型のようなものです。

実際のPythonの型とは違いますが、この方法で型を簡単に指定できるのはRubicon-ObjCの利点の一つです。

[super init]したい場合は、rubicon.objc.runtime.send_superを使います。

    @objc_method
    def someMethod(self):
        send_super(__class__, self, "someMethod")
        ...

基本的には第一引数に__class__、第二引数にselfを指定します。第三引数は、メソッドのセレクタを示す文字列です。
引数を渡したり、引数の型を指定しなければならない場合は、第四引数以降に渡します。

    @objc_method
    def someMethodWithArg1_Arg2(self, arg1, arg2):
        send_super(__class__, self, "someMethodWithArg1:Arg2:", arg1, arg2)
        ...

イニシャライザの実装

イニシャライザは次のように実装できます。

class MyClass(NSObject):

    @objc_method
    def init(self):
        send_super(__class__, self, "init")
        ...
        return self

    @objc_method
    def initWithSomeArg_(self, arg):
        self.init()
        ...
        return self

obj1 = MyObject.new()
obj2 = MyObject.alloc().initWithSomeArg(value)

Pythonの__init__と違って、return selfが必要です。書かないとNoneが帰ってきてしまいます。

プロパティの定義

rubicon.objc.api.objc_propertyを使用することで、Objective-Cのプロパティを指定できます。

class MyClass(NSObject):
    someProperty1 = objc_property()
    someProperty2 = objc_property(NSInteger)  # 型を指定
    someProperty3 = objc_property(weak=True)  # 弱参照

    @objc_method
    def someMethod(self):
        print(self.someProperty1)  # None
        self.someProperty2 = 3
        print(self.someProperty2)  # 3

第一引数vertypeにはプロパティの型を、第二引数weakには弱参照にするかどうかを指定します。
プロパティはメソッド内から通常のプロパティのようにアクセス可能です。通常、クラス下に直接変数を書くと、Pythonではクラス変数になってしまいますが、Rubicon-ObjCではインスタンスプロパティになります。

また、somePropertysetSomePropertyのような名前のメソッドを定義することで、プロパティのように扱われるようになり舞す。

プロトコルの使用

rubicon.objc.api.ObjCProtocolを使用することで、プロトコルに関する様々な操作ができます。

NSCopying = ObjCProtocol("NSCopying")

このように、クラスと同様に取得できます。

クラスの定義時にprotocols=(プロトコル1, プロトコル2, ...)とすることで、プロトコルに準拠させることができます。

class MyClass(NSObject, protocols=(NSCopying,)):
    ...

プロトコルの定義

クラスの定義と同様に、Pythonistaではauto_renameを指定する必要があります。

ObjCProtocol.auto_rename = True

プロトコルを定義する際も、Pythonクラスの定義を使用して行います。

class MyProtocol(metaclass=ObjCProtocol):
    ...

このようにすることで、プロトコルを定義できます。他のプロトコルを継承する場合は、次のようにします。

class MyProtocol(NSObjectProtocol):
    ...

プロトコルの場合は、複数継承することができます。

ブロックの使用

Rubicon-ObjCでは、rubicon.objc.api.ObjCBlockが、ブロックのPythonオブジェクトとなっています。

ObjCBlockは、通常の関数のように実行可能です。

block = obj.someMethodReturnsBlock()
block(arg1, arg2)

自分で定義する際には、基本的には通常の関数を定義するだけで問題ありません。

def some_handler() -> None:
    ...

obj.someMethodWithBlock(some_handler)

こうすることで、メソッドに関数が渡された際、自動でブロックに変換されます。

手動で最初からブロックとして定義する場合は、rubicon.objc.api.Blockを使用してください。

@Block
def some_handler() -> None:
    ...

また、Cの型のブロックは、ObjCBlockに変換可能です。定義したメソッドにcompletionHandlerが渡されるときなどは、自分で引数と返り値の型を設定する必要があるようです。

block = ObjCBlock(block_ptr, None, NSInteger)
block(10)

第二引数にはブロックの返り値の、第三引数以降にはブロックの引数の型を指定します。

メインスレッド

objc_utilでは、on_main_threadを使うことで、メインスレッドで関数を実行できます。

iOSアプリでは、メインスレッド以外でUIの更新を行うことは禁止されているため、これを使わなければ、UIKitの機能を使う際にクラッシュしてしまいます。

しかし、この機能はRubicon-ObjCにはありません。

解決策1: objc_util.on_main_threadをそのまま使う

import objc_util

@objc_util.on_main_thread
def main():
    ...  # ViewControllerの表示など

if __name__ == "__main__":
    main()

このようにするだけでなんの問題もありません。
が、せっかくRubicon-ObjCを使っているのでそっちで実装したいと個人的には思います。

解決策2: dispatch_asyncを使う

これは、ドキュメントで紹介されている方法です。

dispatch_asyncを使った方法は、Objective-Cでは最も標準的なもののようですが、メインスレッドを取得するために使用するdispatch_get_main_queueはインライン関数として定義されているため、ctypesから読み込むことができず、自分で定義しないといけません。

from ctypes import byref, cast, Structure
import functools

from rubicon.objc import Block, objc_block, objc_id, ObjCClass, ObjCInstance
from rubicon.objc.runtime import libobjc

class struct_dispatch_queue_s(Structure):
    pass

_dispatch_main_q = struct_dispatch_queue_s.in_dll(libobjc, '_dispatch_main_q')

def dispatch_get_main_queue():
    return ObjCInstance(cast(byref(_dispatch_main_q), objc_id))

libobjc.dispatch_async.restype = None
libobjc.dispatch_async.argtypes = (objc_id, objc_block)

NSThread = ObjCClass("NSThread")

def main_actor(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if NSThread.isMainThread:
            func(*args, **kwargs)
        block = Block(functools.partial(func, *args, **kwargs), None)
        libobjc.dispatch_async(dispatch_get_main_queue(), block)
    return wrapper

if __name__ == "__main__":
    @main_actor
    def func(string):
        ...  # ViewControllerの表示など

解決策3: NSOperationQueueを使う

こちらは、NSOperationQueueを使った方法です。NSOperationを継承して処理を書きます。

NSOperation = ObjCClass("NSOperation")
NSOperationQueue = ObjCClass("NSOperationQueue")

class MainOperation(NSOperation):

    @objc_method
    def main(self):
        send_super(__class__, self, "main")
        ...  # ViewControllerの表示など

if __name__ == "__main__":
    operation = MainOperation.new()
    queue = NSOperationQueue.mainQueue
    queue.addOperation(operation)
    queue.waitUntilAllOperationsAreFinished()

解決策4: performSelectorOnMainThread:withObject:waitUntilDone:を使う

from rubicon.objc import NSObject, objc_method, SEL

class MyClass(NSObject):

    @objc_method
    def someMethod(self):
        ...  # ViewControllerの表示など

if __name__ == "__main__":
    obj = MyClass.new()
    obj.performSelectorOnMainThread(SEL("someMethod"), withObject=None, waitUntilDone=True)

この方法は、objc_util.on_main_threadの内部で使われている方法です。

サンプルコード

最後に、ViewControllerを表示するサンプルコードです。

from rubicon.objc import objc_method, ObjCClass, send_super

ObjCClass.auto_rename = True

NSOperation = ObjCClass("NSOperation")
NSOperationQueue = ObjCClass("NSOperationQueue")
UIApplication = ObjCClass('UIApplication')
UIColor = ObjCClass('UIColor')
UIViewController = ObjCClass('UIViewController')

class MyViewController(UIViewController):

    @objc_method
    def viewDidLoad(self):
        send_super(__class__, self, "viewDidLoad")
        self.view.backgroundColor = UIColor.blueColor
        print("Viewが読み込まれました")

class MainOperation(NSOperation):

    @objc_method
    def main(self):
        send_super(__class__, self, "main")
        app = UIApplication.sharedApplication
        rootVC = app.keyWindow.rootViewController
        while childVC := rootVC.presentedViewController:
            rootVC = childVC
        mainVC = MyViewController.new().autorelease()
        rootVC.presentViewController(mainVC, animated=True, completion=None)

if __name__ == "__main__":
    operation = MainOperation.new()
    queue = NSOperationQueue.mainQueue
    queue.addOperation(operation)
    queue.waitUntilAllOperationsAreFinished()

Discussion