SwiftPMでマルチモジュール構成

Posted on | 851 words | ~4mins

github repo: https://github.com/hrfmmr/swiftui-sandbox

題材

「twsearch」という、Twitter APIのApplication’s only authenticationをつかったシンプルなtweet検索アプリ

モジュール構成

moduletypedescription
twsearchapplication本体アプリ
AppFeaturedynamic framework各FeaturesをまとめあげるUmbrella的なFeatureモジュール
SearchStatusFeaturedynamic framework検索機能のFeatureモジュール
APIClientdynamic frameworkTwitter APIクライアント
SwiftUIHelpersdynamic frameworkSwiftUIのヘルパ
Coredynamic frameworkドメインモデル定義など
Umbrellastatic framework共通で使用されるライブラリ群がembedされたモジュール

依存関係

Embedded Frameworkをつかったモジュール構成

  • XcodeGen project.yml

    name: twsearch
    
    options:
      bundleIdPrefix: example.com
      deploymentTarget:
        iOS: 15.0
    
    targetTemplates:
      Application:
        type: application
        platform: iOS
        sources:
          - path: ${target_name}
      EmbeddedFramework:
        platform: iOS
        type: framework
        sources:
          - path: ${target_name}
          - path: ${target_name}
            excludes:
              - Info.plist
      EmbeddedFrameworkTests:
        type: bundle.unit-test
        platform: iOS
        dependencies:
          - target: twsearch
        sources:
          - path: ${target_name}
          - path: ${target_name}
            excludes:
              - Info.plist
            buildPhase: resources
      FeatureFramework:
        templates:
          - EmbeddedFramework
        dependencies:
          - target: Core
          - target: APIClient
          - target: SwiftUIHelpers
    
    targets:
      twsearch:
        templates:
          - Application
        dependencies:
          - target: AppFeature
    
      AppFeature:
        templates:
          - FeatureFramework
        dependencies:
          - target: SearchStatusFeature
      SearchStatusFeature:
        templates:
          - FeatureFramework
      SearchStatusFeatureTests:
        templates:
          - EmbeddedFrameworkTests
    
      Core:
        templates:
          - EmbeddedFramework
        transitivelyLinkDependencies: true
        dependencies:
          - target: Umbrella
      Umbrella:
        platform: iOS
        type: framework.static
        dependencies:
          - package: swift-composable-architecture
            product: ComposableArchitecture
    
      APIClient:
        templates:
          - EmbeddedFramework
        dependencies:
          - target: Core
      APIClientTests:
        templates:
          - EmbeddedFrameworkTests
        dependencies:
          - target: twsearch
      SwiftUIHelpers:
        templates:
          - EmbeddedFramework
    
    schemeTemplates:
      EmbeddedFrameworkScheme:
        templates:
          - TestScheme
        build:
          targets:
            ${scheme_name}: all
    
      TestScheme:
        test:
          gatherCoverageData: true
          targets:
            - ${testTargetName}
    
    schemes:
      twsearch:
        build:
          targets:
            twsearch: all
      APIClient:
        templates:
          - EmbeddedFrameworkScheme
        templateAttributes:
          testTargetName: APIClientTests
      SearchStatusFeature:
        templates:
          - EmbeddedFrameworkScheme
        templateAttributes:
          testTargetName: SearchStatusFeatureTests
    
    packages:
      swift-composable-architecture:
        url: https://github.com/pointfreeco/swift-composable-architecture
        from: 0.33.0
    

これをベースに移行していく

Swift Packageをつかったモジュール構成への移行

  1. Swift Package(モジュール群)とxcodeproj(アプリエントリポイント)双方をXcodeで扱えるように、rootに.xcworkspaceを追加

  2. Package.swiftを追加

    $ swift package init
    
    • 実行後のrootディレクトリ構成
      ├── APIClient
      ├── APIClientTests
      ├── AppFeature
      ├── Core
      ├── Makefile
      ├── Mintfile
      └── Package.swift 🆕
      ├── project.yml
      ├── SearchStatusFeature
      ├── SearchStatusFeatureTests
      ├── Sources 🆕
      ├── SwiftUIHelpers
      ├── Tests 🆕
      ├── twsearch
      └── twsearch.xcodeproj
      └── twsearch.xcworkspace 🆕
      
  3. App/ディレクトリ下にxcodeprojとエントリポイントとなるファイルを配置

    ├── App 🆕
    │  ├── Package.swift
    │  ├── project.yml
    │  ├── twsearch
    │  │  ├── Assets.xcassets
    │  │  ├── Info.plist
    │  │  ├── prefs.plist
    │  │  ├── Preview Content
    │  │  └── twsearchApp.swift
    │  └── twsearch.xcodeproj
    
    • Xcode上からrootのSwift Packageからは認識されないよう、空のPackage.swiftを設置
  4. Sources/下にモジュール群を移行

    ├── Sources
    │  ├── APIClient
    │  ├── AppFeature
    │  ├── Core
    │  ├── SearchStatusFeature
    │  ├── SwiftUIHelpers
    │  └── Umbrella
    
  5. Tests/下にテストターゲット群を移行

    ├── Tests
    │  ├── APIClientTests
    │  └── SearchStatusFeatureTests
    
  6. App/下に.xcodeprojを移行したので、contents.xcworkspacedataのFileRefを編集して参照解決する

    <?xml version="1.0" encoding="UTF-8"?>
    <Workspace
       version = "1.0">
       <FileRef
          location = "group:App/twsearch.xcodeproj">
       </FileRef>
       <FileRef
          location = "group:">
       </FileRef>
    </Workspace>
    
  7. rootのPackage.swiftにモジュール間の依存関係を記述

    // swift-tools-version: 5.5
    
    import PackageDescription
    
    let package = Package(
        name: "twsearch",
        platforms: [
            .macOS(.v10_15),
            .iOS(.v15),
        ],
        products: [
            .library(name: "Core", targets: ["Core"]),
            .library(name: "APIClient", targets: ["APIClient"]),
            .library(name: "SwiftUIHelpers", targets: ["SwiftUIHelpers"]),
            .library(name: "SearchStatusFeature", targets: ["SearchStatusFeature"]),
            .library(name: "AppFeature", targets: ["AppFeature"]),
        ],
        dependencies: [
            .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.33.0")
        ],
        targets: [
            .target(
                name: "AppFeature",
                dependencies: [
                    "Core",
                    "SearchStatusFeature",
                ]
            ),
            .target(
                name: "SearchStatusFeature",
                dependencies: [
                    "Core",
                    "APIClient",
                    "SwiftUIHelpers",
                ]
            ),
            .testTarget(
                name: "SearchStatusFeatureTests",
                dependencies: [
                    "SearchStatusFeature",
                ]
            ),
            .target(
                name: "SwiftUIHelpers",
                dependencies: []
            ),
            .target(
                name: "APIClient",
                dependencies: [
                    "Core",
                ]
            ),
            .testTarget(
                name: "APIClientTests",
                dependencies: [
                    "APIClient",
                ]
            ),
            .target(
                name: "Core",
                dependencies: [
                    "Umbrella",
                ]
            ),
            .target(
                name: "Umbrella",
                dependencies: [
                    .product(name: "ComposableArchitecture", package: "swift-composable-architecture")
                ]
            ),
        ]
    )
    

最終的な構成

.
├── App
│  ├── Package.swift
│  ├── project.yml
│  ├── twsearch
│  └── twsearch.xcodeproj
├── Package.swift
├── Sources
│  ├── APIClient
│  ├── AppFeature
│  ├── Core
│  ├── SearchStatusFeature
│  ├── SwiftUIHelpers
│  └── Umbrella
├── Tests
│  ├── APIClientTests
│  └── SearchStatusFeatureTests
└── twsearch.xcworkspace
   ├── contents.xcworkspacedata
   ├── xcshareddata
   └── xcuserdata
  • Xcode上の見え方
  • 最終的なApp/project.yml
    name: twsearch
    
    options:
      bundleIdPrefix: example.com
      deploymentTarget:
        iOS: 15.0
    
    targetTemplates:
      Application:
        type: application
        platform: iOS
        sources:
          - path: ${target_name}
    
    targets:
      twsearch: # 👈 モジュール群をSwift Packageに寄せられたので、Targetはapplicationのみでよくなった
        templates:
          - Application
        dependencies:
          - package: twsearch
            product: AppFeature
    
    schemes:
      twsearch: # 👈 schemeも然り
        build:
          targets:
            twsearch: all
    
    packages:
      twsearch:
        path: .. # 👈 localのSwift Packageを相対パス参照
    

最終的なモジュール構成図

  • xcodeprojで扱うのはapplicationターゲットのみ

ビルド&テスト

(タスクランナに振り切った)Makefile

APP_DIR := App
APP_NAME := twsearch
DESTINATION := "platform=iOS Simulator,name=iPhone 13"
TEST_SCHEMES := SearchStatusFeature

sourcery := Pods/Sourcery/bin/sourcery
xcbeautify := Pods/xcbeautify/xcbeautify
mint-run := mint run

# Set up

default: bootstrap

.PHONY: bootstrap
bootstrap:
	mint bootstrap
	pod install
	"$(MAKE)" build-xcodeproj

# Build

.PHONY: build-xcodeproj
build-xcodeproj:
	$(mint-run) xcodegen -s ${APP_DIR}/project.yml

.PHONY: build
build: build-xcodeproj
	xcodebuild \
		-scheme ${APP_NAME} \
		-sdk iphonesimulator \
		-destination ${DESTINATION} \
		build | ${xcbeautify}

# Test

.PHONY: generate-mocks
generate-mocks:
	@find ./Tests -name ".sourcery.yml" -type f | xargs -I {} ${sourcery} --config {}

.PHONY: test
test: generate-mocks
	for scheme in ${TEST_SCHEMES}; do \
		xcodebuild \
			-scheme $$scheme \
			-sdk iphonesimulator \
			-destination ${DESTINATION} \
			test | ${xcbeautify} --report junit; \
	done
  • Swift標準ライブラリ以外のUIKitなどのiOS SDKへの依存が存在している為、swift cliでなく xcodebuild をつかう
  • swift-Xswiftc オプションでsdkとtargetを指定することも可能
    • eg.
      swift \
          build \
              -Xswiftc "-sdk" \
              -Xswiftc "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
              -Xswiftc "-target" \
              -Xswiftc "x86_64-apple-ios13.0-simulator"
      
    • なんだけどswift test 実行するとXCTestのシンボル解決でコケる🙄(これ原因わからず..)
      ld: warning: Could not find or use auto-linked framework 'XCTest'
      Undefined symbols for architecture x86_64:
        "_OBJC_CLASS_$_XCTestCase", referenced from:
            _$s14APIClientTests07Twittera11IntegrationB0CN in TwitterAPIClientTests.swift.o
      

ハマりポイント

テストターゲットが依存先ライブラリに紐付かない場合がある

  • とは
    • たとえばAPIClient schemeにAPIClientTestsがTest actionとして紐付いていないなど
  • Edit scheme > Testで左下の「+」からテストターゲットを追加
  • .swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme をgit管理下に置く
    --- a/.swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme
    +++ b/.swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme
    @@ -28,16 +28,6 @@
           selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
           shouldUseLaunchSchemeArgsEnv = "YES">
           <Testables>
    +         <TestableReference
    +            skipped = "NO">
    +            <BuildableReference
    +               BuildableIdentifier = "primary"
    +               BlueprintIdentifier = "APIClientTests"
    +               BuildableName = "APIClientTests"
    +               BlueprintName = "APIClientTests"
    +               ReferencedContainer = "container:">
    +            </BuildableReference>
    +         </TestableReference>
           </Testables>
        </TestAction>
        <LaunchAction
    

所感

  • なにがうれしいか
    • XcodeGenのproject.ymlの「依存解決」部分を 標準に乗っかるカタチで 分離できるようになった
  • TODO
    • assetsのリソース管理とかその辺
    • SwiftPM非対応/private repoライブラリとの付き合い方

参考