Upgrade to Pro — share decks privately, control downloads, hide ads and more …

iOSにKMMを導入するtips

 iOSにKMMを導入するtips

Flutter × Kotlin Multiplatform by CyberAgent #2
https://cyberagent.connpass.com/event/204014/

Taiki Suzuki

February 24, 2021
Tweet

More Decks by Taiki Suzuki

Other Decks in Programming

Transcript

  1. SwiftͰݺͼग़ͤΔϝιου #ca_flutter_kmm videoRankingApi.getGenreRanking( type: .free, limit: nil completionHandler: { (response:

    GenreRankingResponse?, error: Error?) in } ) ※ϝιουͷฦΓ஋͕VoidͳͷͰAPI௨৴ͳͲΛΩϟϯηϧ͢Δज़͕Swift͔ΒΞΫηεͰ͖Δੈքʹ͸ͳ͍
  2. SwiftͰҎԼͷΑ͏ʹݺͼग़ͤΔΑ͏ʹ͢Δ #ca_flutter_kmm let job = videoRankingApi.getGenreRanking(type: .free, limit: nil) .subscribe(

    onSuccess: { (response: GenreRankingResponse?) in }, onThrow: { (throwable: KotlinThrowable) in } ) job.cancel(cause_: nil)
  3. Suspend functionΛϥοϓ͢Δ #ca_flutter_kmm class SuspendWrapper<T>(private val suspender: suspend () ->

    T) { suspend fun suspend(): T = suspender() fun subscribe( onSuccess: (item: T) -> Unit, onThrow: (error: Throwable) -> Unit ) : Job = CoroutineScope(SupervisorJob() + Dispatchers.Main).launch { try { onSuccess(suspender()) } catch (error: Throwable) { onThrow(error) } } }
  4. Suspend functionΛϥοϓ͢Δ #ca_flutter_kmm public interface VideoRankingApi { public suspend fun

    getGenreRanking( type: GenreRankingType, limit: Int? ): SuspendWrapper<GenreRankingResponse> } ˞%FGFSSFE5Ͱฦ͢ํ๏΋͋Δ͕ɺ%FGFSSFE͸JOUFSGBDFͰ͋ΔͨΊ0CKFDUJWF$ʹม׵͢ΔͱQSPUPDPMʹͳΔ ɹ0CKFDUJWF$ͷQSPUPDPM͸(FOFSJD"SHVNFOUΛ࣋ͨͳ͍ͨΊɺ5ͱఆ͍ٛͯͯ͠΋"OZʹͳͬͯ͠·͏
  5. SwiftͰҎԼͷΑ͏ʹݺͼग़ͤΔ #ca_flutter_kmm let job = videoRankingApi.getGenreRanking(type: .free, limit: nil) .subscribe(

    onSuccess: { (response: GenreRankingResponse?) in }, onThrow: { (throwable: KotlinThrowable) in } ) job.cancel(cause_: nil) ˞σϝϦοτͱͯ͠J04Ͱ্هͷݺͼग़͠ΛͰ͖ΔΑ͏ʹ͢ΔͨΊʹɺ"OESPJEଆͰ͸ຊདྷෆཁͳTVTQFOE Λ ɹܦ༝ͯ͠TVTQFOEGVODUJPOΛݺͼग़͢Α͏ʹͳͬͯ͠·͏
  6. KoruͰࣗಈੜ੒͞ΕΔΫϥε #ca_flutter_kmm public class VideoRankingApiNative( private val wrapped: VideoRankingApi )

    { public fun getGenreRanking( type: GenreRankingType, limit: Int? ): SuspendWrapper<GenreRankingResponse> = SuspendWrapper(exportedScopeProvider_mainScopeProvider) { wrapped.getGenreRanking(type, limit) } }
  7. KoruΛར༻͢Δ #ca_flutter_kmm @ToNativeClass(launchOnScope = MainScopeProvider::class) public interface VideoRankingApi { public

    suspend fun getGenreRanking( type: GenreRankingType, limit: Int? ): GenreRankingResponse }
  8. KoruͰࣗಈੜ੒͞ΕͨΫϥεͰϥοϓͯ͠ݺͼग़͢ #ca_flutter_kmm let videoRankingApi: VideoRankingApi let api = VideoRankingApiNative(wrapped: videoRankingApi)

    let job = api.getGenreRanking(type: .free, limit: nil) .subscribe( onSuccess: { (response: GenreRankingResponse?) in }, onThrow: { (throwable: KotlinThrowable) in } ) job.cancel(cause_: nil)
  9. SuspendWrapperΛRxSwiftͰදݱ͢ΔͱSingleͱͯ͠ѻ͑Δ #ca_flutter_kmm enum SuspendWrapperError: Error { case invalidResponse case throwable(KotlinThrowable)

    } extension Single where Element: AnyObject { static func create(_ suspendWrapper: SuspendWrapper<Element>) -> Single<Element> { Single.create { observer in let job = suspendWrapper.subscribe( onSuccess: { value in guard let value = value else { observer(.failure(SuspendWrapperError.invalidResponse)) return } observer(.success(value)) }, onThrow: { throwable in observer(.failure(SuspendWrapperError.throwable(throwable))) } ) return Disposables.create { job.cancel(cause_: nil) } } } }
  10. ೚ҙͷΫϥεͰར༻͢Δ৔߹ #ca_flutter_kmm final class VideoRankingRepository { private let videoRankingApi: VideoRankingApiNative

    init(videoRankingApi: VideoRankingApi) { self.videoRankingApi = VideoRankingApiNative(wrapped: videoRankingApi) } func getGenreRanking() -> Single<[GenreRanking]> { Single.create(videoRankingApi.getGenreRanking(type: .free, limit: nil)) .map { $0.genres.map(GenreRanking.init) } } }
  11. iOSͰͷςετ༻ʹImmediateScopeΛ௥Ճ #ca_flutter_kmm @Suppress("FunctionName") public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() +

    Dispatchers.Main) https://github.com/Kotlin/kotlinx.coroutines/blob/1.4.2/kotlinx-coroutines-core/common/src/CoroutineScope.kt#L84-L104
  12. MainScopeΛར༻͍ͯ͠ΔͷͰඇಉظͳςετΛॻ͘ඞཁ͕͋Δ #ca_flutter_kmm class SampleProjectTests: XCTestCase { func testGenreRanking() throws {

    let fakeApi = FakeVideoRankingApi() let suspender = MockSuspendFunction<GenreRankingResponse>() fakeApi.genreRankingResponse = suspender.asSuspendWrapper() let repository = VideoRankingRepository(videoRankingApi: fakeApi) let expect = expectation(description: "Wait") var response: [GenreRanking]? let disposable = repository.getVideoGenreRanking() .subscribe(onSuccess: { (value: [GenreRanking]) in response = value expect.fulfill() }) defer { disposable.dispose() } DispatchQueue.main.async { suspender.respond(.success(RankingVideoSeriesResponse.Companion().fake())) } wait(for: [expect], timeout: 0.1) XCTAssertNotNil(response) } } ᶃ ᶄ ᶅ ᶆ ᶇ
  13. iOSͰͷςετ༻ʹImmediateScopeΛ௥Ճ #ca_flutter_kmm @Suppress("FunctionName") public fun ImmediateScope() = CoroutineScope( SupervisorJob() +

    object : CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { block.run() } } )
  14. iOSͰͷςετ༻ʹImmediateScopeΛ௥Ճ #ca_flutter_kmm @ExportedScopeProvider public class MainScopeProvider : ScopeProvider { override

    val scope: CoroutineScope get() = scopeHolder.access { it.scope } private class ScopeHolder(var scope: CoroutineScope) private val scopeHolder = IsolateState<ScopeHolder> { ScopeHolder(MainScope()) } fun setScope(scope: CoroutineScope) { scopeHolder.access { it.scope = scope } } }
  15. ςετ༻ͷKotlinSuspendFunctionͷϞοΫΛ௥Ճ #ca_flutter_kmm final class MockSuspendFunction<T: AnyObject>: KotlinSuspendFunction0 { private var

    handler: ((Result<T, Error>) -> Void)? func invoke(completionHandler: @escaping (Any?, Error?) -> Void) { handler = { result in do { completionHandler(try result.get(), nil) } catch { completionHandler(nil, error) } } } func respond(_ result: Result<T, Error>) { handler?(result) } func asSuspendWrapper() -> SuspendWrapper<T> { SuspendWrapper(scopeProvider: ImmediateScopeProvider(), suspender: self) } }
  16. ImmediateScopeΛར༻ͯ͠ಉظతʹϢχοτςετΛ࣮ߦ͢Δ #ca_flutter_kmm class SampleProjectTests: XCTestCase { func testGenreRanking() throws {

    MainScopeProviderContainerKt.exportedScopeProvider_mainScopeProvider .setOverrideScope(scope: ImmediateScopeKt.ImmediateScope()) let fakeApi = FakeVideoRankingApi() let suspender = MockSuspendFunction<GenreRankingResponse>() fakeApi.genreRankingResponse = suspender.asSuspendWrapper() let repository = VideoRankingRepository(videoRankingApi: fakeApi) let response = BehaviorRelay<[GenreRanking]?>(value: nil) let disposable = repository.getGenreRanking().asObservable().bind(to: response) defer { disposable.dispose() } suspender.respond(.success(GenreRankingResponse.Companion().fake())) XCTAssertNotNil(response.value) } }
  17. ଥڠҊ: ReleaseͱDebugͰϦϯΫ͢ΔFrameworkΛ෼ذ͢Δ #ca_flutter_kmm target ‘SampleProject’ do pod 'iOSKMM', '0.1.0', :configuration

    => 'Release' pod 'iOSKMMMock', ‘0.1.0', :configuration => 'Debug' end ※ςετλʔήοτͷϏϧυ࣌ʹϦϯΫ͢ΔFrameworkΛมߋ͢ΔεΫϦϓτΛ࣮ߦ͢Δͱ͍͏ख΋͋Γ·͢
  18. Use a Kotlin Gradle project as a CocoaPods dependency #ca_flutter_kmm

    https://kotlinlang.org/docs/native-cocoapods.html#add-a-dependency-between-a- kotlin-pod-and-xcode-project-with-one-target
  19. όΠφϦΛCocoaPodsܦ༝Ͱ؅ཧ͢Δ #ca_flutter_kmm Private Repository Releases iOSKMM.zip target ‘SampleProject’ do pod

    'iOSKMM', '0.1.0' end iOSKMM.framwork iOSKMM.zip iOSKMM.zip Private Specs iOSKMM.podspec iOSKMM.podspec.json iOSKMM.framwork
  20. ௨ৗͷpodspecͷఆٛ #ca_flutter_kmm Pod::Spec.new do |spec| spec.name = 'iOSKMM' spec.version =

    '0.1.0' spec.homepage = 'https://github.com/marty-suzuki/kmm' spec.authors = 'marty-suzuki' spec.license = 'LICENSE' spec.summary = 'SUMMARY' spec.source = { :http => 'https://github.com/marty-suzuki/kmm/releases/download/0.1.0/iOSKMM.zip' } spec.vendored_frameworks = 'iOSKMM.framework' end
  21. assetsͷURLͷऔಘॲཧΛؚΜͩpodspec #ca_flutter_kmm Pod::Spec.new do |spec| module PrivateRepository class Spec attr_reader

    :version attr_reader :homepage attr_reader :source def initialize(owner, name, version, source) @version = version @homepage = "https://github.com/#{owner}/#{name}" @source = source end end class SpecLoader def initialize(owner, name, version, token) @owner = owner @name = name @version = version if !token.nil? && !token.empty? @authorizationToken = "Authorization: token #{token}" else @authorizationToken = nil end end def loadPrivateSpec tagResponse = loadTag assetURL = getAssetURL(tagResponse) headers = ["Accept: application/octet-stream"] if [email protected]? headers.push(@authorizationToken) end source = { :http => assetURL, :headers => headers, :type => "zip" } return PrivateRepository::Spec.new(@owner, @name, @version, source) end private def loadTag requestURL = "https://api.github.com/repos/#{@owner}/#{@name}/releases/tags/#{@version}" cmdElements = ["curl"] if [email protected]? cmdElements.push("-sL") cmdElements.push("-H \"#{@authorizationToken}\"") else cmdElements.push("-nsL") end cmdElements.push(requestURL) cmd = cmdElements.join(" ") response = %x[#{cmd}] if response.nil? puts "Failed Request: #{requestURL}" exit! else return response end end def getAssetURL(tagResponse) tag_hash = JSON.load(tagResponse) asset = tag_hash["assets"]&.first assetURL = asset && asset["url"] if assetURL.nil? puts "Can't get asset URL." exit! else return assetURL end end end end privateSpecLoader = PrivateRepository::SpecLoader.new("marty-suzuki", "kmm", "0.1.0", ENV["GITHUB_ACCESS_TOKEN"]) privateSpec = privateSpecLoader.loadPrivateSpec spec.name = 'iOSKMM' spec.version = privateSpec.version spec.homepage = privateSpec.homepage spec.authors = 'marty-suzuki' spec.license = 'LICENSE' spec.summary = 'SUMMARY' spec.source = privateSpec.source spec.vendored_frameworks = 'iOSKMM.framework' end
  22. pod repo push࣌ʹpodspec಺ͰassetsͷURLΛऔಘ͢Δ #ca_flutter_kmm iOSKMM.podspec GitHub API GET /repos/{OWNER}/{REPO}/releases/tag/{NAME} ೚ҙͷtagͷreleasesͷ৘ใ

    Ϩεϙϯεͷassets͔Β೚ҙͷzipϑΝΠϧ໊ͷassetΛऔಘ͢Δ asset[“url”]Λऔಘ͠ɺspecͷsourceʹ୅ೖ͢Δ
  23. ࠷ऴతͳpodspecͷ಺༰ #ca_flutter_kmm Pod::Spec.new do |spec| spec.name = 'iOSKMM' spec.version =

    '0.1.0' spec.homepage = 'https://github.com/marty-suzuki/kmm' spec.authors = 'marty-suzuki' spec.license = 'LICENSE' spec.summary = 'SUMMARY' spec.source = { :http => 'https://api.github.com/repos/marty-suzuki/kmm/releases/assets/12345678', :headers => ["Accept: application/octet-stream"], :type => "zip" } spec.vendored_frameworks = 'iOSKMM.framework' end
  24. Private RepositoryͷReleasesΛར༻͢Δ৔߹ͷ஫ҙ #ca_flutter_kmm • v1.9.xͷCocoaPodsͰ͸ɺ࣮ࡍʹpod installՄೳͳpodspecͰ͋ͬͯ΋pod lib lint࣌ʹ
 vendored_frameworksͷvalidationͰΤϥʔʹͳͬͯ͠·͏ͷͰɺlintΛ࣮ߦ͠ͳ͍
 ͱ͍͏ରԠํ๏͕ݱঢ়ͷճආࡦʢ࠷৽൛Ͱ΋௚ͬͯͳͦ͞͏ʣ

    • GITHUB_ACESS_TOKENΛར༻ͭͭ͠pod repo push࣌ʹ—use-jsonΛࢦఆ͢Δͱ
 Private Specsͷjsonͷheadersʹ௚઀GITHUB_ACESS_TOKEN͕هࡌ͞Εͯ͠·͏ͷͰ
 .netrcΛར༻͢Δ͜ͱΛਪ঑ • xcframeworkΛར༻͍ͯ͠Δͱ࣮ࡍʹϦϯΫՄೳͰ΋pod repo push࣌ʹlinkerͷΤϥʔ
 ʹͳΔ৔߹͕͋ΔͷͰɺ—skip-import-validationͱ—skip-testsΛࢦఆ͢Δ͜ͱͰճආ • pod install࣌ʹ΋.netrc·ͨ͸GITHUB_ACCESS_TOKEN͕ඞཁʢpod repo pushΛ
 ࣮ߦͨ͠ͱ͖ʹར༻ͨ͠ํʹ߹ΘͤΔʣ
  25. SwiftଆͰURLSessionͷinitializerΛswizzleͯ͠delegateΛೖΕସ͑Δ #ca_flutter_kmm extension URLSession { @objc dynamic fileprivate class func

    kotlinWorkaroundInitializer( configuration: URLSessionConfiguration, delegate: URLSessionDelegate?, delegateQueue queue: OperationQueue? ) -> URLSession { kotlinWorkaroundInitializer( configuration: configuration, delegate: delegate.map { // xxxio.ktor.client.engine.ios.IosResponseReader0 guard NSStringFromClass(type(of: $0)).contains("ktor") else { return $0 } return WorkaroundDelegateProxy(delegate: $0) }, delegateQueue: queue ) } }
  26. SwiftଆͰURLSessionͷinitializerΛswizzleͯ͠delegateΛೖΕସ͑Δ #ca_flutter_kmm fileprivate final class KotlinWorkaroundDelegateProxy: NSObject, URLSessionDataDelegate { private

    let delegate: URLSessionDataDelegate? init(delegate: URLSessionDelegate) { self.delegate = delegate as? URLSessionDataDelegate } … func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data ) { delegate?.urlSession?(session, dataTask: dataTask, didReceive: data) } }
  27. SwiftଆͰURLSessionͷinitializerΛswizzleͯ͠delegateΛೖΕସ͑Δ #ca_flutter_kmm extension ApiClient { private static var _once: ()

    = { typealias FunctionType = (URLSessionConfiguration, URLSessionDelegate?, OperationQueue?) -> URLSession let from = #selector(URLSession.init(configuration:delegate:delegateQueue:) as FunctionType) let to = #selector(URLSession.kotlinWorkaroundInitializer(configuration:delegate:delegateQueue:)) guard let fromMethod = class_getClassMethod(URLSession.self, from), let toMethod = class_getClassMethod(URLSession.self, to) else { return } method_exchangeImplementations(fromMethod, toMethod) }() static func swizzleURLSessionInitializer() { _ = _once } }