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

機能ごとに動作するミニアプリでプレビューサイクルを爆速にした話

C60bcac530f3b3b4d33b653ad03fbacb?s=47 aoi
September 18, 2021

 機能ごとに動作するミニアプリでプレビューサイクルを爆速にした話

C60bcac530f3b3b4d33b653ad03fbacb?s=128

aoi

September 18, 2021
Tweet

Transcript

  1. ػೳ͝ͱʹಈ࡞͢ΔϛχΞϓϦͰ ϓϨϏϡʔαΠΫϧΛര଎ʹͨ͠࿩ iOSDC JAPAN 2021 2021/09/18 Track C @aomathwift

  2. ࣗݾ঺հ • Aoi Okawa @aomathwift • Cookpad Inc. iOS App

    Developer
  3. ։ൃதͷը໘Ͳ͏΍ͬͯ֬ೝͯ͠·͔͢ʁ

  4. શ෦Ϗϧυͯ͠ؤுΔ • ͕͔͔࣌ؒΔ • ֬ೝ͍ͨ͠ը໘ʹ͙ͨ͢ͲΓ͚ͭͳ͍ • ։ൃ్தͰಋઢ͕ͳ͍

  5. શ෦Ϗϧυͯ͠ؤுΔ • ͕͔͔࣌ؒΔ • ֬ೝ͍ͨ͠ը໘ʹ͙ͨ͢ͲΓ͚ͭͳ͍ • ։ൃ్தͰಋઢ͕ͳ͍ • ͕͔͔࣌ؒΔ ޮ཰ΘΔ͍😇

  6. ϑϧϏϧυ͢ΔͷΛ΍ΊΑ͏💡

  7. ϛχΞϓϦͷಋೖ

  8. ର৅ • ΞϓϦಈ࡞֬ೝͷͨΊʹ௕͍Ϗϧυ࣌ؒΛ଴͍ͬͯΔਓ • ։ൃաఔͷϓϨϏϡʔαΠΫϧΛվળ͍ͨ͠ਓ

  9. Agenda • ΫοΫύουiOSΞϓϦͰͷϛχΞϓϦӡ༻ࣄྫ ‣ ୯ମىಈՄೳͳϞδϡʔϧߏ੒ ‣ ϛχΞϓϦͷ࢓૊Έ ‣ νʔϜશମͰར༻ͯ͠΋Β͏޻෉ ‣

    ΑΓ࢖͍΍͍͢࢓૊ΈΛ໨ࢦͯ͠ • ৽͍͠ΞϓϦ΁ͷԠ༻
  10. ΫοΫύουΞϓϦ • 50 Releases / year • 15 developers /

    release • 40 ~ 50 PR / release • 328,000 lines
  11. ୯ମىಈՄೳͳϞδϡʔϧߏ੒

  12. Cookpad TechConf 2019 ʮΫοΫύου iOS ΞϓϦͷഁյͱ૝૾ɺͦͯ͠ະདྷʯ

  13. Cookpad TechConf 2019 ʮΫοΫύου iOS ΞϓϦͷഁյͱ૝૾ɺͦͯ͠ະདྷʯ

  14. Core Module • ந৅ԽͷͨΊͷΠϯλʔϑΣΠε • ڞ௨ͷ UI ίϯϙʔωϯτ

  15. Cookpad TechConf 2019 ʮΫοΫύου iOS ΞϓϦͷഁյͱ૝૾ɺͦͯ͠ະདྷʯ

  16. Application • Application Target • ϓϩδΣΫτ಺ͷ͢΂ͯͷ࣮૷Λ஌͍ͬͯΔ

  17. Cookpad TechConf 2019 ʮΫοΫύου iOS ΞϓϦͷഁյͱ૝૾ɺͦͯ͠ະདྷʯ

  18. Feature Module • 1 ػೳʹؔ࿈͢Δෳ਺ը໘Λ 1 ͭͷϞδϡʔϧͱͯ͠·ͱΊΔ ‣ ങ͍෺ɺϨγϐৄࡉɺϨγϐ౤ߘɺͭ͘ΕΆɹͳͲ

  19. None
  20. Feature Module • υϝΠϯ૚Λڞ༗Ͱ͖Δ୯ҐͰ෼ྨ͢Δ • Ϟδϡʔϧ୯ମͰ 1 ͭͷػೳʹඞཁͳ࣮૷͕׬݁͢Δ → Ϟδϡʔϧ୯ମͰϏϧυ͕Ͱ͖Δ

    🎉
  21. None
  22. None
  23. ϛχΞϓϦͷ࢓૊Έ

  24. ϛχΞϓϦ࣮ݱͷ՝୊ • ֎෦ϥΠϒϥϦ΁ͷґଘΛݮΒ͢ • ଞͷ Feature Module ʹґଘ͠ͳ͍

  25. Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test ґଘͷ໰͍߹ΘͤΛ

    ड͚࣮ͯ૷Λฦ͢ ґଘͷ஫ೖ
  26. Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test ֎෦ϥΠϒϥϦ΍ଞͷ

    ϞδϡʔϧΛ஌͍ͬͯΔ Ϟδϡʔϧ֎ͷ࣮૷ʹ ґଘ͠ͳ͍μϛʔ࣮૷
  27. Environment import Foundation import UIKit public protocol Environment { var

    client: ServiceClient { get } var serviceAccountProvider: ServiceAccountProvider { get } var activityLogger: ActivityLogger { get } var urlSchemeOpener: URLSchemeOpener { get } /// … func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Outpu t }
  28. Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test StubbableEnvironment

    ωοτϫʔΫ௨৴ StubbableServiceClient ը໘ભҠ StubbableResolver
  29. Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test StubbableEnvironment

    ωοτϫʔΫ௨৴ StubbableServiceClient
  30. class RecipeDetailsDataStoreTest: XCTestCase { let dataStore = RecipeDetailsDataStore( ) private

    let recipeID: Int64 = 100 func testSuccessFetchRecipeDetailsRecipes() { let expectation = self.expectation(description: "Could get recipe details recipe" ) dataStore.request { (data, error ) if let error = error { XCTFail( ) } else { expectation.fulfill( ) let recipe = try! JSONEncoder().decode(Recipe.self, from: data ) XCTAssertEqual(recipe.name, "Ͱ͔͍ΠʔϒΠ" ) XCTAssertEqual(recipe.title, "ϚϦτοπΥ" ) } } waitUntilDefaultTimeout(for: expectation ) } }
  31. class RecipeDetailsDataStoreTest: XCTestCase { let testingEnvironment = StubbableEnvironment( ) lazy

    var dataStore = RecipeDetailsDataStore(environment: testingEnvironment ) private let disposeBag = DisposeBag( ) private let recipeID: Int64 = 100 func testSuccessFetchRecipeDetailsRecipes() { let responseData = loadJSONData(named: "recipe_details_recipe" ) testingEnvironment.registerClientResponse ( responseData , for: "/recipes/\(recipeID)" , method: .get ) let expectation = self.expectation(description: "Could get recipe details recipe" ) dataStore.fetchRecipe(recipeID: recipeID ) .subscribe(onSuccess: { response in expectation.fulfill( ) XCTAssertEqual(response.author?.name, "Ͱ͔͍ΠʔϒΠ" ) XCTAssertEqual(response.title, "ϚϦτοπΥ" ) } ) .disposed(by: disposeBag ) waitUntilDefaultTimeout(for: expectation ) } }
  32. public protocol GarageRequest { associatedtype Respons e var baseURL: URL?

    { get } var method: HTTPMethod { get } var path: String { get } var parameters: [String: Any] { get } var headerFields: [String: String] { get } func makeResponse(from data: Data, urlResponse: HTTPURLResponse) throws -> Respons e } public protocol ServiceClient { @discardableResul t func sendRequest<Request: GarageRequest> ( _ request: Request , handler: @escaping (Result<Request.Response, ClientTaskError>) -> Void) -> Cancellabl e } αʔόʔ΁ͷRequest ʹඞཁͳ΋ͷΛڞ௨Խ Request ϝιου
  33. struct ServiceClientStub { typealias Response = Swift.Result<Data?, APIRequestError > var

    path: Strin g var method: HTTPMetho d var parameters: [String: Any ] var fields: [GarageFieldDescriptor] ? enum ParametersMatchingPattern { case exactMatc h case includin g } var parametersMatchingPattern: ParametersMatchingPatter n var result: Respons e var urlResponse: HTTPURLRespons e var parameterSet: Set<Parameter> { return makeParameterSet(from: parameters ) } } RequestʹରԠ͢Δμϛʔ ͷResponseΛηοτͰ࣋ͭ Response Request
  34. // StubbableServiceClient private var responseStubs: [ServiceClientStub] = [ ] func

    registerClientResponse<Response: Data?> ( _ response: Response , for path: String , method: HTTPMethod , statusCode: Int , parameters: [String: Any] = [:] , fields: [GarageFieldDescriptor]? = nil , parametersMatchingPattern: ServiceClientStub.ParametersMatchingPattern , httpHeaders: [String: String]? = nil ) { let stub = ServiceClientStub ( path: path , method: method , parameters: parameters , fields: fields , parametersMatchingPattern: parametersMatchingPattern , result: .success(response.jsonData) , urlResponse: makeURLResponse(path: path, statusCode: statusCode, httpHeaders: httpHeaders ) ) responseStubs.append(stub ) } StubbableServiceClient ͷதͰStubΛอ࣋͢Δ ϝιουɾPath͝ͱʹҰҙʹ ͳΔΑ͏ʹϞοΫσʔλΛొ࿥
  35. let matchingStubs = responseStubs.filter { stub in if stub.path !=

    request.path || stub.method != request.method { return false } if let stubFields = stub.fields, stubFields != request.fields { return false } switch stub.parametersMatchingPattern { case .exactMatch : return stub.parameterSet == request.parameterSe t case .including : return stub.parameterSet.isSubset(of: request.parameterSet ) } } Pathɺύϥϝʔλɺ fi eld͕ొ࿥ࡁΈ ͷStubͱҰக͢Δ͔νΣοΫ
  36. public enum StubbingError: Error { case noMatchingStubs(AnyGarageRequest ) case multipleMatchingStubs(AnyGarageRequest,

    [ServiceClientStub] ) public var localizedDescription: String { switch self { case let .noMatchingStubs(request) : return "No matching stubs found for \(request)" case let .multipleMatchingStubs(request, stubs) : return "Multiple stubs matching \(request): \(stubs)" } } } // StubbableServiceClient let matchingStub: ServiceClientStu b switch matchingStubs.count { case 0 : return stubbingErrorHandler(.noMatchingStubs(AnyGarageRequest(request)) ) case 1 : matchingStub = matchingStubs[0 ] default : return stubbingErrorHandler(.multipleMatchingStubs(AnyGarageRequest(request), matchingStubs) ) } ద੾ͳελϒ͕ͳ͍ɺ·ͨ͸ॏෳͯ͠ ଘࡏ͢Δ৔߹͸ErrorΛੜ੒͢Δ
  37. switch matchingStub.result { case let .success(data) : do { let

    response = try request.makeResponse(from: data ?? Data(), urlResponse: matchingStub.urlResponse ) handler(.success(response) ) } catch { switch error { case let clientTaskError as ClientTaskError : handler(.failure(clientTaskError) ) case let responseError as ResponseError : handler(.failure(.rawNetworkingTaskError(.responseError(responseError))) ) case let decodingError as DecodingError : handler(.failure(.rawNetworkingTaskError(.responseError(.serializationError(decodingError)))) ) default : handler(.failure(.rawNetworkingTaskError(.responseError(.unknownError(error)))) ) } } case let .failure(error) : handler(.failure(error) ) }
  38. let environment = StubbableEnvironment( ) let categoriesJSONData = fixtureLoader.loadJSONData(named: "categories"

    ) environment.registerClientResponse ( categoriesJSONData , for: "/v1/top_categories" , method: .get ) let viewController = RecipeCategoryListViewBuilder.build(environment: environment )
  39. Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test StubbableEnvironment

    ը໘ભҠ StubbableResolver
  40. Resolver • ଞͷϞδϡʔϧͷ࣮૷ΛऔΓग़͢࢓૊Έ • Environment ܦ༝Ͱந৅Խ͞Εͨଞͷ FeatureModule ͷ࣮૷ ΛऔΓग़͢

  41. Resolver for ϛχΞϓϦ • Resolver ͷ࢓૊ΈΛϛχΞϓϦͰ΋Ԡ༻ • ଞϞδϡʔϧͷը໘͸શͯμϛʔͷը໘Ͱฦ͢

  42. ΫοΫύου։ൃऀϒϩά ίʔυੜ੒Λ༻͍ͨiOSΞϓϦϚϧνϞδϡʔϧԽͷͨΊͷґଘղܾ https://techlife.cookpad.com/entry/2021/06/16/110000

  43. None
  44. ͜͜·Ͱͷ·ͱΊ • Ϟδϡʔϧ୯ҐͰҰͭͷػೳ͕׬݁͢ΔΑ͏ʹ෼཭͢Δ • Ϟδϡʔϧಉ࢜͸૬ޓґଘ͠ͳ͍ • ଞϞδϡʔϧʹ͋ΔͳͲ௚઀ࢀরͰ͖ͳ͍෭࡞༻͸μϛʔ࣮૷ Λ༻ҙ͢Δ

  45. νʔϜͰར༻ͯ͠΋Β͏޻෉

  46. ϛχΞϓϦಋೖॳظͷ՝୊ • ϛχΞϓϦ༻ͷίʔυͷهड़͕ͱʹ͔͘໘౗ • ಉ͡ηοτΞοϓΛԿճ΋΍Γͨ͘ͳ͍

  47. ίʔυͷڞ༗ɾڞ௨Խ

  48. public struct SandboxScene { public init(sceneName: String, initializer: @escaping (SandboxInitializer)

    -> UIViewController) { self.sceneName = sceneNam e self.initializer = initialize r } var sceneName: Strin g var initializer: (SandboxInitializer) -> UIViewControlle r } // AppDelegate.swift let rootViewController = SandboxSceneSelectTableViewController ( scenes: [ .recipeCategoryList , .subCategories , ] ) Sandboxͷը໘ Λߏ੒͢Δ΋ͷ SandboxScene ͷ഑ྻΛڞ༗
  49. None
  50. ίʔυͷڞ௨Խ • ϛχΞϓϦ༻ͷμϛʔ෭࡞༻͕ͲΜͲΜ૿͑Δ • ىಈ࣌ʹඞཁͳίʔυ͕૿͑Δ

  51. CookpadCore CookpadComponent SandboxCore Feature B Sandbox Feature A Sandbox Feature

    C Sandbox Sandbox༻ͷڞ௨࣮૷ ຊମͱͷڞ௨࣮૷
  52. ίʔυͷࣗಈੜ੒

  53. ίʔυͷࣗಈੜ੒ • AppDelegate ΍֤ Scene ͷॳظઃఆͳͲɺϘΠϥʔϓϨʔτ ίʔυͷهड़͕໘౗ → ίʔυ͸πʔϧΛ࢖ͬͯࣗಈੜ੒͢Δ

  54. Genesis • https://github.com/yonaskolb/Genesis • Stencil ͱ͍͏ςϯϓϨʔτΤϯδϯΛར༻

  55. options : - name: sceneName question: Sandbox scene name? description:

    new Sandbox scene name to generate. (e.g. recipeDetails). type: string required: true - name: moduleName question: Destination target? description: module name to generate new sandbox scene for. (e.g. RecipeDetails) type: string required: true files : - template: Sandbox/SandboxScene.stencil path: "Sandbox/{{ moduleName }}Sandbox/{{ sceneName }}SandboxScene.swift" - template: Sandbox/AppDelegate.swift.stencil path: "Sandbox/{{ moduleName }}Sandbox/{{ moduleName }}AppDelegate.swift"
  56. None
  57. @testable import {{ moduleName } } import UIKit @UIApplicationMain class

    AppDelegate: UIResponder, UIApplicationDelegate { private let environment = StubbableEnvironment( ) var window: UIWindow ? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds ) // Inject Scenes to RootTableViewController let rootViewController = {{ sceneName }}ViewBuilder.build(environment: environment ) window?.rootViewController = rootViewControlle r window?.makeKeyAndVisible( ) return true } } https://github.com/stencilproject/Stencil
  58. @testable import RecipeDetails import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate

    { private let environment = StubbableEnvironment( ) var window: UIWindow ? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds ) // Inject Scenes to RootTableViewController let rootViewController = RecipeDetailsViewBuilder.build(environment: environment ) window?.rootViewController = rootViewControlle r window?.makeKeyAndVisible( ) return true } }
  59. Sandbox ࠲ஊձͷ։࠵

  60. ΑΓ࢖͍΍͍͢࢓૊ΈΛ໨ࢦͯ͠

  61. ࠷ޙʹىಈͨ͠ը໘ΛهԱ • ։ൃத͸ಉ͡ը໘Λ܁Γฦ֬͠ೝ͍ͨ͠৔߹͕ଟ͍ → ࠷ޙʹ։͍ͨ Scene ΛهԱͯ͠ɺىಈ࣌ʹͦΕΛ։͘Α͏ʹ

  62. None
  63. ωοτϫʔΫͷ஗Ԇͷઃఆ • ελϒͨ͠ϨεϙϯεΛฦ͢෦෼ͰਓҝతʹϦΫΤετॲཧΛ ஗Ԇͤ͞ΔΑ͏ʹ // StubbableServiceClient.swift open func sendRequest<Request> (

    _ request: Request , handler: @escaping (Result<Request.Response, ClientTaskError>) -> Void) -> Cancellable where Request: GarageRequest { let workItem = DispatchWorkItem { self.handleSendRequest(request, handler: handler ) } DispatchQueue.main.asyncAfter(deadline: .now() + requestDelayDuration, execute: workItem ) return workIte m }
  64. ͔͜͜Βཧ૝࿦

  65. ࣮ϦΫΤετͷ࣮ݱ • ୯ػೳͰͷ API ͷૄ௨֬ೝ • σʔλͷελϒ͕ཁΒͳ͍ͷͰศར

  66. ελϒͷ؆қԽ • Ϩεϙϯεͷ JSON Λ౎౓खಈͰ༻ҙͯͯ͠େม • ϞοΫσʔλੜ੒πʔϧͷར༻Λݕ౼

  67. Advanced

  68. ৽͍͠ΞϓϦ΁ͷԠ༻

  69. Cookpad Mart • 2018 ೥ʹ։࢝ͨ͠৽͍͠ϓϩμΫτ • ը໘΍ػೳ͕૿͑ෳࡶʹͳ͖ͬͯͨ • ؔΘͬͨ։ൃऀ΋૿͖͑ͯͨʢ㲈 ίʔυ͕൥ࡶʹͳ͖ͬͯͨʣ

  70. Cookpad Mart ͷ՝୊ • ֤ঢ়ଶͷ࠶ݱ৚͕݅ෳࡶ • ը໘ભҠ͕໘౗

  71. Ϟδϡʔϧ෼཭·Ͱ͸Ͱ͖ͯͳ͍͚Ͳ ಈ࡞֬ೝΛগ͠Ͱ΋ָʹ͍ͨ͠ʂ

  72. ୯ը໘ىಈͷΈͷ༌ೖ

  73. ୯ը໘ىಈ • Scheme ͷ؀ڥม਺Λར༻ • XcodeGenΛ࢖͍ͬͯΕ͹༰қʹઃఆՄೳ

  74. schemes : CookpadMartECSandbox : build : config: Debug targets :

    CookpadMartEC: all run : config: Debug environmentVariables : RUNNING_SANDBOX: 1 profile : config: Debug
  75. None
  76. func application ( _ application: UIApplication , didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey:

    Any] ? ) -> Bool { if isRunningSandbox { window = UIWindow(frame: UIScreen.main.bounds ) let rootViewController = SandboxSceneSelectTableViewController { Scene ( name: "Coupon" , CouponListViewController.create(dependency: .init(launchMode: .setting, shouldCloseButtonHidden: false) ) ) { Mock(path: "/v2/coupons", statusCode: 200, method: .get) { JSONData(fromBundle: "coupons" ) } } Scene ( name: "Coupon (Error)" , CouponListViewController.create(dependency: .init(launchMode: .setting, shouldCloseButtonHidden: false) ) ) { Mock(path: "/v2/coupons", statusCode: 403, method: .get) { JSONData("\"error\": \"message\"" ) } } } let navigationController = UINavigationController(rootViewController: rootViewController ) window?.rootViewController = navigationControlle r window?.makeKeyAndVisible( ) return true } /// .... return true }
  77. ϦΫΤετ͸ελϒ͢Δ let stubbableServiceClient = StubbableServiceClient( ) if let jsonData =

    mock.json.data { stubbableServiceClient.registerClientResponse ( jsonData , for: mock.path , method: mock.method , statusCode: mock.statusCod e ) } ServiceClientProvider.injectionContainer.serviceClient = stubbableServiceClien t
  78. ୯ը໘ىಈ • ఆٛͨ͠ Scheme Λબ୒ͯ͠Ϗϧυ͢Δ

  79. None
  80. ୯ը໘ىಈ • ୯ը໘ىಈͷ࢓૊Έ͕Θ͔Δ SandboxKit • https://github.com/aomathwift/SandboxKit

  81. ·ͱΊ

  82. ·ͱΊ • ϛχΞϓϦ͸։ൃޮ཰Λ্͛ΔҰखஈ ‣ ػೳ୯ҐͰΘ͚Δ ‣ ґଘΛݮΒ͢ ‣ ηοτΞοϓΛ؆୯ʹ͢Δ ‣

    ୯ը໘ػೳ͚ͩͰ΋݁ߏศར
  83. એ఻

  84. એ఻1 • େن໛ͳΞϓϦͷϚϧνϞδϡʔϧߏ੒ͷ࣮ફ

  85. એ఻2 • After Party iOSDC Japan 2021 • https://cookpad.connpass.com/event/222056

  86. ͝ਗ਼ௌ͋Γ͕ͱ͏͍͟͝·ͨ͠