Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

ϛχΞϓϦͷಋೖ

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

Agenda • ΫοΫύουiOSΞϓϦͰͷϛχΞϓϦӡ༻ࣄྫ ‣ ୯ମىಈՄೳͳϞδϡʔϧߏ੒ ‣ ϛχΞϓϦͷ࢓૊Έ ‣ νʔϜશମͰར༻ͯ͠΋Β͏޻෉ ‣ ΑΓ࢖͍΍͍͢࢓૊ΈΛ໨ࢦͯ͠ • ৽͍͠ΞϓϦ΁ͷԠ༻

Slide 10

Slide 10 text

ΫοΫύουΞϓϦ • 50 Releases / year • 15 developers / release • 40 ~ 50 PR / release • 328,000 lines

Slide 11

Slide 11 text

୯ମىಈՄೳͳϞδϡʔϧߏ੒

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

No content

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

No content

Slide 22

Slide 22 text

No content

Slide 23

Slide 23 text

ϛχΞϓϦͷ࢓૊Έ

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test ґଘͷ໰͍߹ΘͤΛ ड͚࣮ͯ૷Λฦ͢ ґଘͷ஫ೖ

Slide 26

Slide 26 text

Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test ֎෦ϥΠϒϥϦ΍ଞͷ ϞδϡʔϧΛ஌͍ͬͯΔ Ϟδϡʔϧ֎ͷ࣮૷ʹ ґଘ͠ͳ͍μϛʔ࣮૷

Slide 27

Slide 27 text

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: Descriptor) -> Descriptor.Outpu t }

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test StubbableEnvironment ωοτϫʔΫ௨৴ StubbableServiceClient

Slide 30

Slide 30 text

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 ) } }

Slide 31

Slide 31 text

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 ) } }

Slide 32

Slide 32 text

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: Request , handler: @escaping (Result) -> Void) -> Cancellabl e } αʔόʔ΁ͷRequest ʹඞཁͳ΋ͷΛڞ௨Խ Request ϝιου

Slide 33

Slide 33 text

struct ServiceClientStub { typealias Response = Swift.Result 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 { return makeParameterSet(from: parameters ) } } RequestʹରԠ͢Δμϛʔ ͷResponseΛηοτͰ࣋ͭ Response Request

Slide 34

Slide 34 text

// StubbableServiceClient private var responseStubs: [ServiceClientStub] = [ ] func registerClientResponse ( _ 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͝ͱʹҰҙʹ ͳΔΑ͏ʹϞοΫσʔλΛొ࿥

Slide 35

Slide 35 text

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ͱҰக͢Δ͔νΣοΫ

Slide 36

Slide 36 text

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Λੜ੒͢Δ

Slide 37

Slide 37 text

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) ) }

Slide 38

Slide 38 text

let environment = StubbableEnvironment( ) let categoriesJSONData = fixtureLoader.loadJSONData(named: "categories" ) environment.registerClientResponse ( categoriesJSONData , for: "/v1/top_categories" , method: .get ) let viewController = RecipeCategoryListViewBuilder.build(environment: environment )

Slide 39

Slide 39 text

Transition Environment Network CookpadEnvironment StubbableEnvironment Logger Cookpad Sandbox Test StubbableEnvironment ը໘ભҠ StubbableResolver

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

ίʔυͷڞ༗ɾڞ௨Խ

Slide 48

Slide 48 text

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 ͷ഑ྻΛڞ༗

Slide 49

Slide 49 text

No content

Slide 50

Slide 50 text

ίʔυͷڞ௨Խ • ϛχΞϓϦ༻ͷμϛʔ෭࡞༻͕ͲΜͲΜ૿͑Δ • ىಈ࣌ʹඞཁͳίʔυ͕૿͑Δ

Slide 51

Slide 51 text

CookpadCore CookpadComponent SandboxCore Feature B Sandbox Feature A Sandbox Feature C Sandbox Sandbox༻ͷڞ௨࣮૷ ຊମͱͷڞ௨࣮૷

Slide 52

Slide 52 text

ίʔυͷࣗಈੜ੒

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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"

Slide 56

Slide 56 text

No content

Slide 57

Slide 57 text

@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

Slide 58

Slide 58 text

@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 } }

Slide 59

Slide 59 text

Sandbox ࠲ஊձͷ։࠵

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

No content

Slide 63

Slide 63 text

ωοτϫʔΫͷ஗Ԇͷઃఆ • ελϒͨ͠ϨεϙϯεΛฦ͢෦෼ͰਓҝతʹϦΫΤετॲཧΛ ஗Ԇͤ͞ΔΑ͏ʹ // StubbableServiceClient.swift open func sendRequest ( _ request: Request , handler: @escaping (Result) -> Void) -> Cancellable where Request: GarageRequest { let workItem = DispatchWorkItem { self.handleSendRequest(request, handler: handler ) } DispatchQueue.main.asyncAfter(deadline: .now() + requestDelayDuration, execute: workItem ) return workIte m }

Slide 64

Slide 64 text

͔͜͜Βཧ૝࿦

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

Advanced

Slide 68

Slide 68 text

৽͍͠ΞϓϦ΁ͷԠ༻

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

୯ը໘ىಈͷΈͷ༌ೖ

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

schemes : CookpadMartECSandbox : build : config: Debug targets : CookpadMartEC: all run : config: Debug environmentVariables : RUNNING_SANDBOX: 1 profile : config: Debug

Slide 75

Slide 75 text

No content

Slide 76

Slide 76 text

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 }

Slide 77

Slide 77 text

ϦΫΤετ͸ελϒ͢Δ 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

Slide 78

Slide 78 text

୯ը໘ىಈ • ఆٛͨ͠ Scheme Λબ୒ͯ͠Ϗϧυ͢Δ

Slide 79

Slide 79 text

No content

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

·ͱΊ

Slide 82

Slide 82 text

·ͱΊ • ϛχΞϓϦ͸։ൃޮ཰Λ্͛ΔҰखஈ ‣ ػೳ୯ҐͰΘ͚Δ ‣ ґଘΛݮΒ͢ ‣ ηοτΞοϓΛ؆୯ʹ͢Δ ‣ ୯ը໘ػೳ͚ͩͰ΋݁ߏศར

Slide 83

Slide 83 text

એ఻

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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