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

Posted on | 851 words | ~4 mins

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

題材

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

モジュール構成

module type description
twsearch application 本体アプリ
AppFeature dynamic framework 各FeaturesをまとめあげるUmbrella的なFeatureモジュール
SearchStatusFeature dynamic framework 検索機能のFeatureモジュール
APIClient dynamic framework Twitter APIクライアント
SwiftUIHelpers dynamic framework SwiftUIのヘルパ
Core dynamic framework ドメインモデル定義など
Umbrella static 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ライブラリとの付き合い方

参考