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

iOSにKMMを導入するtips

Ae276805027a01983503c3edafbdb6b2?s=47 Taiki Suzuki
February 24, 2021

 iOSにKMMを導入するtips

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

Ae276805027a01983503c3edafbdb6b2?s=128

Taiki Suzuki

February 24, 2021
Tweet

Transcript

  1. Flutter × Kotlin Multiplatform by 2021/02/24 #2 #ca_flutter_kmm by Taiki

    Suzuki iOSΞϓϦʹKMMΛಋೖ͢Δtips
  2. ࣗݾ঺հ #ca_flutter_kmm marty_suzuki marty-suzuki Taiki Suzuki

  3. ΞδΣϯμ #ca_flutter_kmm • Suspend functionΛiOSͰΩϟϯηϧͰ͖ΔΑ͏ʹ͢Δ • ςετͷͱ͖͚ͩ೚ҙͷϞδϡʔϧΛimport͍ͨ͠ • KMMͷFrameworkΛϓϥΠϕʔτϦϙδτϦͷReleasesʹ
 Ξοϓϩʔυͯ͠CocoaPodsͰDLͰ͖ΔΑ͏ʹ͢Δ

    • KotlinͰ࣮૷͞ΕͨNSObjectͷαϒΫϥεΛSwizzle͢Δͱ
 Ϋϥογϡ͢Δ
  4. Suspend functionΛ iOSͰΩϟϯηϧͰ͖ΔΑ͏ʹ͢Δ #ca_flutter_kmm

  5. KotlinͰͷInterface #ca_flutter_kmm public interface VideoRankingApi { public suspend fun getGenreRanking(

    type: GenreRankingType, limit: Int? ): GenreRankingResponse }
  6. SwiftͰݺͼग़ͤΔϝιου #ca_flutter_kmm videoRankingApi.getGenreRanking( type: .free, limit: nil completionHandler: { (response:

    GenreRankingResponse?, error: Error?) in } ) ※ϝιουͷฦΓ஋͕VoidͳͷͰAPI௨৴ͳͲΛΩϟϯηϧ͢Δज़͕Swift͔ΒΞΫηεͰ͖Δੈքʹ͸ͳ͍
  7. 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)
  8. Working with Kotlin Coroutines and RxSwift #ca_flutter_kmm https://touchlab.co/kotlin-coroutines-rxswift/

  9. 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) } } }
  10. 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ʹͳͬͯ͠·͏
  11. 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Λݺͼग़͢Α͏ʹͳͬͯ͠·͏
  12. Suspend functionΛ iOSͰΩϟϯηϧͰ͖ΔΑ͏ʹ͢Δ #ca_flutter_kmm SuspendWrapperΛ΋ͬͱ׆༻͍ͯ͘͠

  13. Koru #ca_flutter_kmm https://github.com/FutureMind/koru

  14. 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) } }
  15. KoruΛར༻͢Δ #ca_flutter_kmm @ToNativeClass(launchOnScope = MainScopeProvider::class) public interface VideoRankingApi { public

    suspend fun getGenreRanking( type: GenreRankingType, limit: Int? ): GenreRankingResponse }
  16. KoruΛར༻͢Δ #ca_flutter_kmm @ExportedScopeProvider public class MainScopeProvider : ScopeProvider { override

    val scope: CoroutineScope = MainScope() }
  17. Suspend functionͷؔ਺Λͦͷ··ݺΜͩ৔߹ #ca_flutter_kmm videoRankingApi.getGenreRanking( type: .free, limit: nil completionHandler: {

    (response: GenreRankingResponse?, error: Error?) in } )
  18. 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)
  19. 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) } } } }
  20. ೚ҙͷΫϥεͰར༻͢Δ৔߹ #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) } } }
  21. Suspend functionΛ iOSͰΩϟϯηϧͰ͖ΔΑ͏ʹ͢Δ #ca_flutter_kmm SuspendWrapperΛར༻ͨ͠ΫϥεΛςετ͢Δ

  22. 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
  23. 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) } } ᶃ ᶄ ᶅ ᶆ ᶇ
  24. Dispatchers.Main.immediate͕Main Thread͔ΒͰ΋dispatch͞ΕΔ #ca_flutter_kmm https://github.com/Kotlin/kotlinx.coroutines/issues/2283

  25. iOSͰͷςετ༻ʹImmediateScopeΛ௥Ճ #ca_flutter_kmm @Suppress("FunctionName") public fun ImmediateScope() = CoroutineScope( SupervisorJob() +

    object : CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { block.run() } } )
  26. 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 } } }
  27. iOSͰͷςετ༻ʹImmediateScopeΛ௥Ճ #ca_flutter_kmm MainScopeProviderContainerKt .exportedScopeProvider_mainScopeProvider .setScope(scope: ImmediateScopeKt.ImmediateScope())

  28. ςετͷSuspendFunction޲͚ͷScopeProviderΛ௥Ճ #ca_flutter_kmm final class ImmediateScopeProvider: ScopeProvider { let scope: CoroutineScope

    = ImmediateScopeKt.ImmediateScope() }
  29. ςετ༻ͷ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) } }
  30. 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) } }
  31. ςετͷͱ͖͚ͩ ೚ҙͷϞδϡʔϧΛimport͍ͨ͠ #ca_flutter_kmm

  32. KMMͷߏ੒ #ca_flutter_kmm module A iOSKMM.framwork

  33. KMMͷߏ੒ #ca_flutter_kmm ios-kmm module A module B iOSKMM.framwork

  34. KMMͷߏ੒ #ca_flutter_kmm ios-kmm module A module B ios-kmm-mock iOSKMM.framwork iOSKMMMock.framwork

  35. KMMͷߏ੒ #ca_flutter_kmm ios-kmm module A module B ios-kmm-mock iOSKMM.framwork iOSKMMMock.framwork

  36. KMMͷߏ੒ #ca_flutter_kmm ios-kmm module A module B ios-kmm-mock iOSKMMMock.framwork ios-kmm

    module A module B iOSKMM.framwork
  37. == KMMͷߏ੒ #ca_flutter_kmm iOSKMM.framwork iOSKMMMock.framwork ❌ module A class ApiClient

    module A class ApiClient
  38. ଥڠҊ: 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Λมߋ͢ΔεΫϦϓτΛ࣮ߦ͢Δͱ͍͏ख΋͋Γ·͢
  39. KMMͷFrameworkΛϓϥΠϕʔτϦϙδτϦͷReleasesʹ
 Ξοϓϩʔυͯ͠CocoaPodsͰDLͰ͖ΔΑ͏ʹ͢Δ #ca_flutter_kmm

  40. Use a Kotlin Gradle project as a CocoaPods dependency #ca_flutter_kmm

  41. 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
  42. όΠφϦΛ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
  43. ௨ৗͷ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
  44. Private RepositoryͷReleases #ca_flutter_kmm ˞1SJWBUF3FQPTJUPSZͳͷͰʮIUUQTHJUIVCDPNNBSUZTV[VLJLNNSFMFBTFTEPXOMPBEJ04,..[JQʯʹ ɹೝূঢ়ଶͰΞΫηεͨ͠ͱͯ͠΋ɺΞΫηεՄೳͳ63-͸BTTFUͷVSMͱͳΔͨΊΤϥʔʹͳͬͯ͠·͏

  45. 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 !@authorizationToken.nil? 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 !@authorizationToken.nil? 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
  46. 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ʹ୅ೖ͢Δ
  47. ࠷ऴతͳ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
  48. Private RepositoryͷReleases༻ͷpodspec template #ca_flutter_kmm IUUQTHJTUHJUIVCDPNNBSUZTV[VLJ EGFDDCFFDGEEDBCEB

  49. 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Λ
 ࣮ߦͨ͠ͱ͖ʹར༻ͨ͠ํʹ߹ΘͤΔʣ
  50. KotlinͰ࣮૷͞ΕͨNSObjectͷαϒΫϥεΛ Swizzle͢ΔͱΫϥογϡ͢Δ #ca_flutter_kmm

  51. Fixed since kotlin-native v1.4.30 #ca_flutter_kmm https://github.com/JetBrains/kotlin-native/pull/4569

  52. e.g. ktor IosResponseReader #ca_flutter_kmm https://github.com/ktorio/ktor/blob/1.5.1/ktor-client/ktor-client-ios/darwin/ src/io/ktor/client/engine/ios/IosResponseReader.kt

  53. e.g. ktor IosResponseReader #ca_flutter_kmm

  54. e.g. ktor IosResponseReader #ca_flutter_kmm https://github.com/ktorio/ktor/blob/master/ktor-client/ktor-client-ios/ darwin/src/io/ktor/client/engine/ios/IosClientEngine.kt ~ ~

  55. e.g. ktor IosResponseReader #ca_flutter_kmm internalͳͷͰKMMଆͰ΋ରԠ͢Δ͜ͱ͕Ͱ͖ͳ͍ forkͯ͠मਖ਼ରԠ͢Δ͔͠ͳ͍?

  56. 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 ) } }
  57. 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) } }
  58. 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 } }
  59. One more thing… #ca_flutter_kmm

  60. Flutter × Kotlin Multiplatform by 2021/xx/xx #3 #ca_flutter_kmm KMMશମʹؔ͢ΔτʔΫ UBLBIJSPN

    !OFX@SVOOBCMF
  61. #ca_flutter_kmm ͝੩ௌ͋Γ͕ͱ͏͍͟͝·ͨ͠ by Taiki Suzuki iOSΞϓϦʹKMMΛಋೖ͢Δtips