SKIEとは
SKIEは2023年9月6日にTouchlabがOSSとして公開したKMPで使用するライブラリです。
2023年3月にブログ/Open Source Updatesで開発中であることがアナウンスされてから半年後の公開でした。
Apache License V2で公開されており、誰でも使用できます。
TouchLabはSQLiterやStatelyを開発しており、KMPでアプリを作ろうとしたことがある方は見かけた方も多いでしょう。
🚀 SKIE, our Swift Kotlin Interface Enhancer, has officially joined Touchlab's critical suite of #KotlinMultiplatform Open Source libraries! https://t.co/wavPutiXSN
— Touchlab (@TouchlabHQ) September 5, 2023
なぜSKIEが必要なのか
KMPはKotlinのみでandroid, iOSへドメインロジック処理を提供できる素晴らしいツールです。
一方でチーム開発もしくはiOSエンジニアから見た際に幾つかの課題を抱えています。
- 生成されたFrameworkの一部のAPIはSwiftらしく呼べない
- enumは網羅性が欠落する
- sealed interface、sealed classは単なる実装もしくは継承しているクラスとしてみなされる
- Swift.async/awaitを使ってsuspend functionを呼べるのはmain threadのみ
- genericsを持ったinterface(protocol)はgenericsの型情報が欠落する
- default argumentの欠落
- build時間
- Kotlinであること
SwiftらしくKMPのAPIを呼び出せないのは開発時にObj-CもしくはKotlin側の実装を意識せざるを得なくなるため、非常に認知負荷がかかります。
これらの課題は基本的にKMPがObj-Cを経由してSwiftへとAPIを提供するために発生します。(androidエンジニアの方はJavaからKotlinを呼び出した際の制約をイメージしていただけるとわかりやすいです)
せっかく1つのコードで共通した処理の実装を実現できたとしても使用にハードルがあるとすればiOSエンジニアがKMPの導入に諸手を挙げて賛成する、という状況にはなりにくいでしょう。
またSKIE以前にも同様のサポートをするライブラリもありましたがCocoaPodsによる依存解決が必要になるなど現在のiOS開発の主流に追いついていない面が否定できませんでした。
そのような状況を打破する可能性があるため、SKIEは非常にセクシーなプロジェクトというわけです。
SKIEはなにを改善するのか
具体的にSKIEは以下を改善します。
- Kotlin.Enum、sealed interface、sealed classの網羅性担保
- Kotlin Coroutineのwrap
- Default Argumentのサポート
Kotlin.Enum, sealed interface、sealed classの網羅性担保
Kotlin.Enum without value
以下のenumを例にしてみていきます。
enum class AnimalType {
Dog,
Cat,
Human;
}
before
このようなenumはSKIEを使わずにビルドするとObj-Cのヘッダーでは以下のように表現されます。
__attribute__((swift_name("KotlinEnum")))
@interface SharedKotlinEnum<E> : SharedBase <SharedKotlinComparable>
- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer));
@property (class, readonly, getter=companion) SharedKotlinEnumCompanion *companion __attribute__((swift_name("companion")));
- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)")));
- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)")));
- (NSUInteger)hash __attribute__((swift_name("hash()")));
- (NSString *)description __attribute__((swift_name("description()")));
@property (readonly) NSString *name __attribute__((swift_name("name")));
@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal")));
@end
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("AnimalType")))
@interface SharedAnimalType : SharedKotlinEnum<SharedAnimalType *>
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable));
@property (class, readonly) SharedAnimalType *dog __attribute__((swift_name("dog")));
@property (class, readonly) SharedAnimalType *cat __attribute__((swift_name("cat")));
@property (class, readonly) SharedAnimalType *human __attribute__((swift_name("human")));
+ (SharedKotlinArray<SharedAnimalType *> *)values __attribute__((swift_name("values()")));
@property (class, readonly) NSArray<SharedAnimalType *> *entries __attribute__((swift_name("entries")));
@end
上記のようなObj-CヘッダーをSwiftでは以下のように解釈します。
open class KotlinEnum<E> : KotlinBase, KotlinComparable where E : AnyObject {
public init(name: String, ordinal: Int32)
open class var companion: KotlinEnumCompanion { get }
open func compareTo(other: E) -> Int32
open func isEqual(_ other: Any?) -> Bool
open func hash() -> UInt
open func description() -> String
open var name: String { get }
open var ordinal: Int32 { get }
}
public class AnimalType : KotlinEnum<AnimalType> {
open class var dog: AnimalType { get }
open class var cat: AnimalType { get }
open class var human: AnimalType { get }
open class func values() -> KotlinArray<AnimalType>
open class var entries: [AnimalType] { get }
}
そうです、Kotlin.Enumはenumではないのです。
そのためKMPで定義したenumを呼び出す際にはdefault句が必要となり網羅性が欠如します。
以下のようなラッパーをextension等で手で作成することも可能ですが列挙が追加された場合にコンパイルエラーにならず気付けないため運用にはハードルがあります。
func call(value: AnimalType) {
switch value {
case .cat:
print("mewo")
case .dog:
print("bow")
case .human:
print("...")
default:
fatalError("unknown animal")
}
}
after
ではSKIEを導入するとどうなるでしょうか?
__attribute__((swift_name("KotlinEnum")))
@interface SharedKotlinEnum<E> : SharedBase <SharedKotlinComparable>
@property (class, readonly, getter=companion) SharedKotlinEnumCompanion *companion __attribute__((swift_name("companion")));
@property (readonly) NSString *name __attribute__((swift_name("name")));
@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal")));
- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer));
- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)")));
- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)")));
- (NSUInteger)hash __attribute__((swift_name("hash()")));
- (NSString *)description __attribute__((swift_name("description()")));
@end
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("AnimalType")))
@interface SharedAnimalType : SharedKotlinEnum<SharedAnimalType *>
@property (class, readonly) SharedAnimalType *dog __attribute__((swift_name("dog")));
@property (class, readonly) SharedAnimalType *cat __attribute__((swift_name("cat")));
@property (class, readonly) SharedAnimalType *human __attribute__((swift_name("human")));
@property (class, readonly) NSArray<SharedAnimalType *> *entries __attribute__((swift_name("entries")));
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable));
+ (SharedKotlinArray<SharedAnimalType *> *)values __attribute__((swift_name("values()")));
@end
@frozen public enum AnimalType : String, Hashable, CaseIterable {
case dog
case cat
case human
public var name: String { get }
public var ordinal: Int32 { get }
public static func _forceBridgeFromObjectiveC(_ source: shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_AnimalType, result: inout shared.AnimalType?)
public static func _conditionallyBridgeFromObjectiveC(_ source: shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_AnimalType, result: inout shared.AnimalType?) -> Bool
public static func _unconditionallyBridgeFromObjectiveC(_ source: shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_AnimalType?) -> shared.AnimalType
public func _bridgeToObjectiveC() -> shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_AnimalType
public typealias _ObjectiveCType = shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_AnimalType
}
extension AnimalType {
public func toKotlinEnum() -> shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_AnimalType
}
見事にenumになっていますね、一体どんな黒魔術をしているのでしょうか。
enumとして認識されているため、Xcode上でも不要なdefault caseだよとsuggestされるようになりました。
Kotlin.Enum with value
値なしのベーシックなKotlin.Enumを綺麗なSwift.Enumに変換してくれることはわかりました。
では値ありのKotlin.Enumはどうでしょうか?
Swiftではraw valuesと呼ばれ、宣言1つにつき1つの値を持つことができます。
整数値
、浮動小数点数値
、文字列
のみを割り当てられるという制約があります。
enum class Color(val rgb: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF)
}
before
Obj-C上ではrgb
は単なるクラスのメンバ変数として認識されています。
そのためrgbを取得するのは普段のraw value enumと変わらない感触で実装できます。
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Color")))
@interface SharedColor : SharedKotlinEnum<SharedColor *>
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable));
@property (class, readonly) SharedColor *red __attribute__((swift_name("red")));
@property (class, readonly) SharedColor *green __attribute__((swift_name("green")));
@property (class, readonly) SharedColor *blue __attribute__((swift_name("blue")));
+ (SharedKotlinArray<SharedColor *> *)values __attribute__((swift_name("values()")));
@property (class, readonly) NSArray<SharedColor *> *entries __attribute__((swift_name("entries")));
@property (readonly) int32_t rgb __attribute__((swift_name("rgb")));
@end
public class Color : KotlinEnum<Color> {
open class var red: Color { get }
open class var green: Color { get }
open class var blue: Color { get }
open class func values() -> KotlinArray<Color>
open class var entries: [Color] { get }
open var rgb: Int32 { get }
}
func call(value: Color) {
switch value {
case .red:
print(value.rgb)
case .green:
print(value.rgb)
case .blue:
print(value.rgb)
default:
fatalError("unknown color")
}
}
after
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("Color")))
@interface SharedColor : SharedKotlinEnum<SharedColor *>
@property (class, readonly) SharedColor *red __attribute__((swift_name("red")));
@property (class, readonly) SharedColor *green __attribute__((swift_name("green")));
@property (class, readonly) SharedColor *blue __attribute__((swift_name("blue")));
@property (class, readonly) NSArray<SharedColor *> *entries __attribute__((swift_name("entries")));
@property (readonly) int32_t rgb __attribute__((swift_name("rgb")));
+ (instancetype)alloc __attribute__((unavailable));
+ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable));
- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable));
+ (SharedKotlinArray<SharedColor *> *)values __attribute__((swift_name("values()")));
@end
@frozen public enum Color : String, Hashable, CaseIterable {
case red
case green
case blue
public var name: String { get }
public var ordinal: Int32 { get }
public var rgb: Int32 { get }
public static func _forceBridgeFromObjectiveC(_ source: shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_Color, result: inout shared.Color?)
public static func _conditionallyBridgeFromObjectiveC(_ source: shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_Color, result: inout shared.Color?) -> Bool
public static func _unconditionallyBridgeFromObjectiveC(_ source: shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_Color?) -> shared.Color
public func _bridgeToObjectiveC() -> shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_Color
public typealias _ObjectiveCType = shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_Color
}
extension Color {
public func toKotlinEnum() -> shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_Color
}
同様にenumとして認識されているため、Xcode上でも不要なdefault caseだよとsuggestされるようになりました。
2種類のKotlin.Enumを見ていてお気づきの方もいると思いますが、SKIEはKotlin.Enumをstring raw value enum
として解釈するようです。
2個目の値付きKotlin.Enumであればenum Color: Int
のような定義に変換されて欲しいところですが、その点はマッピングの制約が発生しているので注意が必要です。
Kotlinはvalue部にInt, Float, String以外も割り当てることが可能なためこのような制約が発生しています。
Kotlin.Enumの変数名(先述の例のrgbにあたる部分)をrawValue
にすると__rawValue
というメンバ変数として変換してくれるため、予約後の衝突を心配する必要はありません。
sealed class, sealed interface
sealed class, sealed interfaceは継承を同一ファイル内に制限することでコンパイル時にパターン網羅をチェックしてくれるenumのようでもあり、union typeのようでもある言語機能です。
SwiftのAssociated Valueが言語仕様として近いです。
以下のようなsealed class, sealed interfaceを例にしてみていきます。
sealed interface SealedError
sealed class IOError : SealedError
class FileReadError(val file: String) : IOError()
class DatabaseError(val source: String) : IOError()
data object RuntimeError : SealedError
今回の定義では👇のようなSwift.Enumに見えるとSwiftらしくswitch文が書けるでしょう。
enum SealedError {
case IOError
case RuntimeError
}
enum IOError {
case FileReadError(String)
case DatabaseError(String)
}
before
SKIEを使わない場合は以下のような定義になります。
__attribute__((swift_name("SealedError")))
@protocol SharedSealedError
@required
@end
__attribute__((swift_name("IOError")))
@interface SharedIOError : SharedBase <SharedSealedError>
@end
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("DatabaseError")))
@interface SharedDatabaseError : SharedIOError
- (instancetype)initWithSource:(NSString *)source __attribute__((swift_name("init(source:)"))) __attribute__((objc_designated_initializer));
@property (readonly) NSString *source __attribute__((swift_name("source")));
@end
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("FileReadError")))
@interface SharedFileReadError : SharedIOError
- (instancetype)initWithFile:(NSString *)file __attribute__((swift_name("init(file:)"))) __attribute__((objc_designated_initializer));
@property (readonly) NSString *file __attribute__((swift_name("file")));
@end
public protocol SealedError {}
open class IOError : KotlinBase, SealedError {}
public class DatabaseError : IOError {
public init(source: String)
open var source: String { get }
}
public class FileReadError : IOError {
public init(file: String)
open var file: String { get }
}
Kotlinでの言語仕様の定義通り、protocolの準拠もしくはクラスの継承で定義されることがわかります。
そのためsealed class, sealed interfaceをSwiftからType Safeに呼び出すためにはtype castが必要になります。
type castのタイミングでチェックすべき順番、クラスが漏れ落ちるため網羅性が欠落します。
実際には以下のようなコードでtype castを行い、プロパティを取得することになります。
func call(error: SealedError) {
switch error {
case let error as IOError:
print("io error")
switch error {
case let io as FileReadError:
print("file read error \(io.file)")
case let database as DatabaseError:
print("database error \(database.source)")
default:
fatalError("unknown io error")
}
case _ as RuntimeError:
print("runtime error")
default:
fatalError("unknown error")
}
}
見るからに大変ですね。手運用でこの網羅性をカバーするのは非常にしんどいです。
after
ではSKIEを使用するとどうなるでしょうか?
まずはObj-Cです
__attribute__((swift_name("SealedError")))
@protocol SharedSealedError
@required
@end
__attribute__((swift_name("IOError")))
@interface SharedIOError : SharedBase <SharedSealedError>
@end
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("DatabaseError")))
@interface SharedDatabaseError : SharedIOError
@property (readonly) NSString *source __attribute__((swift_name("source")));
- (instancetype)initWithSource:(NSString *)source __attribute__((swift_name("init(source:)"))) __attribute__((objc_designated_initializer));
@end
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("FileReadError")))
@interface SharedFileReadError : SharedIOError
@property (readonly) NSString *file __attribute__((swift_name("file")));
- (instancetype)initWithFile:(NSString *)file __attribute__((swift_name("init(file:)"))) __attribute__((objc_designated_initializer));
@end
次にSwiftです。
public protocol SealedError {}
open class IOError : KotlinBase, SealedError {}
public class DatabaseError : IOError {
open var source: String { get }
public init(source: String)
}
public class FileReadError : IOError {
open var file: String { get }
public init(file: String)
}
extension __SwiftGen.IOError {
@frozen public enum Enum {
case databaseError(shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_DatabaseError)
case fileReadError(shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_FileReadError)
}
}
extension __SwiftGen.SealedError {
@frozen public enum Enum {
case iOError(shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_IOError)
case runtimeError(shared.__Skie.class__DeepDiveIntoSKIE_shared__io_github_ryunen344_skie_RuntimeError)
}
}
public func onEnum<SEALED>(of sealed: SEALED) -> shared.__SwiftGen.IOError.Enum where SEALED : IOError
public func onEnum<SEALED>(of sealed: SEALED) -> shared.__SwiftGen.SealedError.Enum where SEALED : SealedError
SKIEは内部でsealed class, sealed interfaceに対応したAssociated Valueを定義することで網羅的にハンドルするAPIを提供します。
そのため以下のようにdefault句なしでsealed class, sealed interfaceを呼び出せます。
func call(error: SealedError) {
switch onEnum(of: error) {
case .iOError(let ioError):
switch onEnum(of: ioError) {
case .databaseError(let db):
print("database error \(db.source)")
case .fileReadError(let file):
print("file read error \(file.file)")
}
case .runtimeError(_):
print("runtime error")
}
}
onEnum
メソッドでunwrapする点は注意ですが、コンパイル時に網羅性チェックが行えるようになりました。
Kotlin Coroutineのwrap
そもそもK/NはKotlin Coroutineのネイティブサポートを謳っています。
これはKotlinのsuspend functionをObj-CにcompletionHandlerとして提供することで非同期処理をiOS側から呼び出せるようにしている、ということです。
SwiftのcompilerがcompletionHandler
をasync/awaitとして呼べるように解釈してくれるため、特別な設定なしでSwift.async/awaitからKotlin suspend functionを呼べるようになっています。
Interoperability with Swift/Objective-C#Mappings
SKIEを使わずに👇のようなKotlinコードをビルドすると
suspend fun callSuspend() {
println("called suspend function")
}
👇のようなObj-Cヘッダーにexportするため
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("SuspendKt")))
@interface SharedSuspendKt : SharedBase
+ (void)callSuspendWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("callSuspend(completionHandler:)")));
@end
👇のようにSwiftは解釈されます
public class SuspendKt : KotlinBase {
open class func callSuspend(completionHandler: @escaping (Error?) -> Void)
open class func callSuspend() async throws
}
結果として👇のように特別な呼び出し方をせずとも呼び出せるようになっています
func callKmpSuspend() {
// async/awaitの場合
Task {
try! await SuspendKt.callSuspend()
}
// completion handlerの場合
SuspendKt.callSuspend { _ in
print("called completion handler")
}
}
このように特別な設定をせずともiOSから呼び出せるCoroutineですが、実はいくつかの課題があります。
- Mainスレッドからしか呼べない(experimentalなcompiler flagを使用することで制限解除できる)
- Coroutineのキャンセルを行うためにはCoroutineのAPIをSwiftに露出させなければならない
- Coroutine FlowがGenericsを持った
interface(protocol)
のため型情報が欠落する - Coroutine FlowをSwift.async/awaitで使用するためにはラッパークラスが必要
ではSKIEはどのように改善してくれるのかを見ていきます。
Suspend Function
今まではCoroutineScopeをSwiftに露出させ、自前でwrapper実装を書いたり、KMP-NativeCoroutinesなどを用いることでキャンセル可能にすることができました。
SKIEでも同様に内部でキャンセル可能な実装でラップし、その実装を強制的に使用させるextensionをビルド時に生成します。
public extension SuspendKt {
@available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, * )
static func callSuspend() async throws {
return try await SwiftCoroutineDispatcher.dispatch {
__Skie.file__shared____SkieSuspendWrappersKt.Skie_Suspend__0__callSuspend(suspendHandler:)($0)
}
}
}
実際にラップしている実装を深ぼっていきます。SwiftとKotlinコード両方が登場する点、ライブラリがruntimeとして含めているコードと自動生成されるコードが入り乱れる点に注意してください。
ポイントは3つです。
- __SkieSuspendWrappers.kt
- SwiftCoroutineDispatcher.swift
- Skie_CancellationHandler.kt, Skie_SuspendHandler.kt
__SkieSuspendWrappers.kt
__SkieSuspendWrappers
はskie-compiler-plugin
によって生成される、susupend function
を強制的にラップして呼び出すためのクラスです。
このクラスとこのクラスのメソッドを呼び出すextensionをcompiler pluignで生成することによって後述するSKIEの仕組みを強制的に差し込んでいます。
Coroutine Flowのcollect, emitメソッドもsuspend function
なためヘッダーファイルに登場しています。
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("__SkieSuspendWrappersKt")))
@interface Shared__SkieSuspendWrappersKt : SharedBase
+ (void)Skie_Suspend__0__callSuspendSuspendHandler:(SharedSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__0__callSuspend(suspendHandler:)")));
+ (void)Skie_Suspend__1__hasNextDispatchReceiver:(SharedSkieColdFlowIterator<id> *)dispatchReceiver suspendHandler:(SharedSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__1__hasNext(dispatchReceiver:suspendHandler:)")));
+ (void)Skie_Suspend__2__collectDispatchReceiver:(id<SharedKotlinx_coroutines_coreFlow>)dispatchReceiver collector:(id<SharedKotlinx_coroutines_coreFlowCollector>)collector suspendHandler:(SharedSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__2__collect(dispatchReceiver:collector:suspendHandler:)")));
+ (void)Skie_Suspend__3__emitDispatchReceiver:(id<SharedKotlinx_coroutines_coreFlowCollector>)dispatchReceiver value:(id _Nullable)value suspendHandler:(SharedSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__3__emit(dispatchReceiver:value:suspendHandler:)")));
@end
SwiftCoroutineDispatcher.swift
次に見ていくのは生成されたextensionの内部で使用されているCoroutineDispatcherと名付けられているstructです。
これはライブラリがruntime用に提供しているためGitHub上でコードを確認することができます。
SwiftCoroutineDispatcher.swift
CoroutineDispatcherと名付けられていますがkotlinx.coroutines.CoroutineDispatcher
を継承していません。
内部で後述するSkie_CancellationHandlerとSkie_SuspendHandlerを使用し、Swift.async/awaitによるキャンセルを実現しています。
SwiftCoroutineDispatcher#dispatch
のメソッドの中身を大まかに説明すると以下のような内容になっています。
struct SwiftCoroutineDispatcher {
static func dispatch<T>(
coroutine: (Skie_SuspendHandler) -> Swift.Void
) async throws -> T {
// cancelをハンドリングするためのクラス、後述で説明
let cancellationHandler = Skie_CancellationHandler()
// Swift.async/awaitのwithTaskCancellationHandlerを用いてキャンセル時にCoroutineのキャンセルを行う
return try await _Concurrency.withTaskCancellationHandler(operation: {
var result: Swift.Result<T, Swift.Error>? = nil
let dispatcher = _Concurrency.AsyncStream<Kotlinx_coroutines_coreRunnable> { continuation in
// Swift.AsyncSequenceでCoroutineのRunnableを保持するためのdelegate
let dispatcherDelegate = AsyncStreamDispatcherDelegate(continuation: continuation)
// 内部でcoroutine dispatcherを保持し、dispatchDelegate内のRunnableを逐次処理していく
let suspendHandler = Skie_SuspendHandler(
cancellationHandler: cancellationHandler,
dispatcherDelegate: dispatcherDelegate,
onResult: { suspendResult in
let convertedResult: Swift.Result<T, Swift.Error> = convertToResult(suspendResult: suspendResult)
result = convertedResult
dispatcherDelegate.stop()
}
)
coroutine(suspendHandler)
}
// cancelなしのTaskとしてAsyncStreamのRunnableを実行していく
await _Concurrency.Task {
for await block in dispatcher { block.run() }
}.value
// cast類を行いSwiftのResult
return try unwrap(result: result)
}, onCancel: {
// 呼び出し元からキャンセルが実行された場合はcoroutineもキャンセルする
cancellationHandler.cancel()
})
}
}
Skie_CancellationHandler.kt, Skie_SuspendHandler.kt
Skie_CancellationHandler, Skie_SuspendHandler共にライブラリがruntime用に提供しているクラスです。
Skie_CancellationHandler.kt
Skie_SuspendHandler.kt
Skie_CancellationHandler
は内部でキャンセル処理が実行されたかをenumで管理しています。CoroutineContext, CoroutineScopeを用いずにcancelされたかを管理するためのhandlerクラスです。
Skie_SuspendHandler
はCoroutineScopeを完全に隠蔽しきるため、Swift側にCoroutineScopeを露出させずに非同期処理をSwiftから扱える様にします。
class Skie_SuspendHandler(
private val cancellationHandler: Skie_CancellationHandler,
dispatcherDelegate: Skie_DispatcherDelegate,
private val onResult: (Skie_SuspendResult) -> Unit,
) {
// DispacheQueue, Swift.async/awaitなどをCoroutine Dispatcher以外の実行制御機構をCoroutine上で認識させるためのラッパー
private val dispatcher = CoroutineDispatcherWithDelegate(dispatcherDelegate)
// __SkieSuspendWrappersでラップされたsuspend functionが呼び出すentry point
internal fun launch(checkedExceptions: Array<KClass<out Throwable>>, coroutine: suspend () -> Any?) {
CoroutineScope(dispatcher).launch {
cancellationHandler.setCancellationCallback {
// cancellationHandlerでcancelされた場合はCoroutineをcancelする
cancel()
}
try {
val result = coroutine.invoke()
onResult(Skie_SuspendResult.Success(result))
} catch (_: CancellationException) {
onResult(Skie_SuspendResult.Canceled)
} catch (e: Throwable) {
if (e.isCheckedException(checkedExceptions)) {
//
throwSwiftException(e)
} else {
throw e
}
}
}
}
// @Throwsで事前に定義されているExceptionかチェックする
private fun Throwable.isCheckedException(checkedExceptions: Array<out KClass<out Throwable>>): Boolean =
checkedExceptions.any { it.isInstance(this) }
// Kotlin.ThrowableをNSErrorに変換してSwift.async/awaitのResultに伝える
private fun throwSwiftException(e: Throwable) {
val error = e.toNSError()
onResult(Skie_SuspendResult.Error(error))
}
}
Suspend Functionまとめ
SKIEは自動生成とkotlin側でCoroutineをcallback形式で管理する仕組みを提供することでSwift.async/awaitを用いつつ
Coroutine APIをSwift側から叩くことなく
キャンセルさせる仕組みを提供します。
Coroutine Flow
Kotlin.Flow#collect
やKotlin.Flow#emit
はsuspend functionのため先述の仕組みで使用できるようになっていますが、Coroutine Flowが抱えている残りの問題はどのように対応しているのでしょうか?
Coroutine FlowがGenericsを持ったinterface(protocol)のため型情報が欠落する
Coroutine FlowをSwift.async/awaitで使用するためにはラッパークラスが必要
SKIEのFlowラッパーを見てみるとFlow, SharedFlow, StateFlowそれぞれのラッパーがkotlinとswiftの両方で定義されています。
SkieKotlinFlow, SkieSwiftFlowを例にしてそれぞれを見ていきましょう。
@OptIn(ExperimentalObjCName::class)
class SkieKotlinFlow<out T : Any>(@ObjCName(swiftName = "_") private val delegate: Flow<T>) : Flow<T> by delegate
SkieKotlinFlowはFlowをそのままSwiftから参照するとprotocolのgenericsが欠落してしまうためkotlin側で一度ラップした上で型情報が欠落させないためのクラスです。
ObjCName
はObj-Cヘッダー上で変数名がぶつかった際にビルドができなくならないようにヘッダーにexportする変数名を変更するアノテーションです。
public final class SkieSwiftFlow<T>: _Concurrency.AsyncSequence, Swift._ObjectiveCBridgeable {
public typealias AsyncIterator = SkieSwiftFlowIterator<T>
public typealias Element = T
public typealias _ObjectiveCType = SkieKotlinFlow<Swift.AnyObject>
internal let delegate: Skie.org_jetbrains_kotlinx__kotlinx_coroutines_core.Flow.__Kotlin
internal init(internal flow: Skie.org_jetbrains_kotlinx__kotlinx_coroutines_core.Flow.__Kotlin) {
delegate = flow
}
public func makeAsyncIterator() -> SkieSwiftFlowIterator<T> {
return SkieSwiftFlowIterator(flow: delegate)
}
public func _bridgeToObjectiveC() -> _ObjectiveCType {
return SkieKotlinFlow(delegate)
}
public static func _forceBridgeFromObjectiveC(_ source: _ObjectiveCType, result: inout SkieSwiftFlow<T>?) {
result = fromObjectiveC(source)
}
public static func _conditionallyBridgeFromObjectiveC(_ source: _ObjectiveCType, result: inout SkieSwiftFlow<T>?) -> Bool {
result = fromObjectiveC(source)
return true
}
public static func _unconditionallyBridgeFromObjectiveC(_ source: _ObjectiveCType?) -> SkieSwiftFlow<T> {
return fromObjectiveC(source)
}
private static func fromObjectiveC(_ source: _ObjectiveCType?) -> SkieSwiftFlow<T> {
return SkieSwiftFlow(internal: source!)
}
}
delegateとしてkotlin.Flowを保持しつつ_Concurrency.AsyncSequence
, Swift._ObjectiveCBridgeable
を準拠することでswiftから見るとシンプルなAsyncSequence
として見える様にラップしています。
以下の様なStateFlowが公開されているクラスをビルドすると
class ObservableHolder {
private val _state: MutableStateFlow<Int> = MutableStateFlow(0)
val state = _state.asStateFlow()
}
__attribute__((objc_subclassing_restricted))
__attribute__((swift_name("ObservableHolder")))
@interface SharedObservableHolder : SharedBase
@property (readonly) id<SharedKotlinx_coroutines_coreStateFlow> state __attribute__((swift_name("state")));
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));
+ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead")));
@end
ObservableHolder.stateはObj-Cのヘッダー上ではkotlin.StateFlowとして公開されていますがXcode上で確認するとSkieSwiftStateFlow
となっています。
SkieSwiftStateFlowはAsyncSequenceを準拠しているため素のAsyncSequenceとしてSwift側で扱うことができます。
func hoge() async {
let holder = ObservableHolder()
Task {
for await num in holder.state {
print(num)
}
}
}
つまりSKIEは👇のような構成でgenericsの欠落を防ぐと同時にSwift.async/awaitで扱える様に変換しています。
Obj-Cヘッダー上ではKotlinx_coroutines_coreStateFlow
とされているのにXcode上では直接SkieSwiftStateFlow
として参照できる理由はPart2で解説します。