しめ鯖日記

swift, iPhoneアプリ開発, ruby on rails等のTipsや入門記事書いてます

XcodeGenでxcodeprojファイルをyamlで管理する

XcodeGenというxcodeprojファイルをyaml管理できるツールを試してみました。
これを使う事でxcodeprojファイルのコンフリクトを避ける事ができるようになります。

github.com

インストール

今回はbrewを使ってインストールしました。

brew install xcodegen

初期設定

ルートディレクトリにproject.ymlを設置するだけで使う事ができます。

name: MyApp
options:
  bundleIdPrefix: com.myApp
targets:
  MyApp:
    type: application
    platform: iOS
    deploymentTarget: "13.0"
    sources: MyApp

yamlファイルを置いたら下コマンドを実行します。

xcodegen generate

実行すると以下のようにMyApp.xcodeprojファイルが生成されます。

f:id:llcc:20200531192816p:plain

使い方

Carthageを使っている場合、下のように依存関係を記載する事ができます。
CocoaPodsの場合はPodfileを使う必要があります。

targets:
  MyApp:
    dependencies:
      - carthage: Kingfisher

Run Scriptの追加をする事もできます。

targets:
  MyApp:
    preBuildScripts:
      - script: printf hello

xcodegen generateを実行すると下のように追加されます。

f:id:llcc:20200531194458p:plain

その他の利用可能オプションは下URLに詳しく記載されています。

github.com

nmコマンドで.frameworkにUIWebViewが入っているか確認する

nmコマンドでUIWebViewを使っているか判別できるという事で検証してみました。
参考にしたのは下サイトです。

qiita.com

まずは下記事を参考に自作のframeworkを作ります。

llcc.hatenablog.com

作成したframeworkに下のようなクラスを1つ追加します。

import UIKit

public class MyClass {
}

追加したらビルドしてframeworkを生成します。
生成したframeworkはProductsグループの中に入っています。

f:id:llcc:20200525220616p:plain

実際に下コマンドでUIWebViewが含まれているか確かめてみます。

nm MyFramework.framework/MyFramework | grep UIWeb

実行結果は下の通りです。
UIWebViewを使っていないので何も出力されませんでした。

f:id:llcc:20200525220804p:plain

次は実際にUIWebViewを使うとどうなるか確かめてみます。
先程作ったクラスで下のようにUIWebViewのインスタンスを生成します。

public class MyClass {
    func test() {
        let webView = UIWebView()
    }
}

ビルドしてコマンド実行した結果は以下の通りです。
無事にUIWebViewを使っているかどうか判別できました。

f:id:llcc:20200525221023p:plain

UIWebViewは以下のようにprivateなクラスで使っている場合も検出できます。

private class MyClass {
    private func test() {
        let webView = UIWebView()
    }
}

Xcodeの[プロジェクト名].entitlementsというファイルについて調べてみる

Xcodeで自動的に作られる[プロジェクト名].entitlementsについて調べてみました。

[プロジェクト名].entitlementsとは

entitlementsとはプッシュ通知やApp Groupsなど、プロジェクトのCapabilityでの設定に関するファイルです。
Capabilityで通知の追加やApp Groupsのcontainer追加時に自動更新されます。

f:id:llcc:20200524212420p:plain

ファイルをXcodeで開くと下のようになっています。
App Groupsのコンテナ名やプッシュ通知の環境などが設定されています。

f:id:llcc:20200524213258p:plain

設定可能なキーは下のドキュメントで確認する事が可能です。

https://developer.apple.com/documentation/bundleresources/entitlements

entitlementsファイルはプロジェクト設定でパス指定する事でDebugとReleaseのファイルを分けることも可能です。

f:id:llcc:20200524222517p:plain

Realm Swift + SwiftUIでテーブル表示・編集・削除

Realm SwiftとSwiftUIを使ったテーブル実装を試してみました。

注) 2020/04/26時点でRealmはSwiftUIを公式サポートしていません。
対応状況は下Issueを参考にしてみて下さい。

github.com

表示

プロジェクトを作ったらCocoaPodsでRealmSwiftを追加します。

platform :ios, '13.0'

target 'MyApp' do
  use_frameworks!

  pod 'RealmSwift'
end

Realmの追加が終わったらEntityを作成します。
定義は下の通りです。

import UIKit
import RealmSwift

class MyModel: Object {
    @objc dynamic var id = UUID().uuidString
    @objc dynamic var title = ""
}

次はこのデータをテーブル表示します。
後々のことを考えて、データ一覧はViewModelに入れています。

import SwiftUI
import RealmSwift

struct ContentView: View {
    @ObservedObject var model = ContentViewModel()
    
    var body: some View {
        List {
            ForEach(model.myModels, id: \.id) { myModel in
                Button(action: {
                }) {
                    Text("\(myModel.title)")
                }
            }
        }
    }
}

class ContentViewModel: ObservableObject {
    @Published var myModels: [MyModel] = (try? Realm().objects(MyModel.self).map { $0 }) ?? []
}

テストデータを3件作ってから実行すると下のようになります。
日付をそのままtitleに入れたので下のような表示になっています。

f:id:llcc:20200426201026p:plain

編集

次はデータの更新をします。
Cellをタップしたら現在時刻をtitleにセットするような実装にしたいと思います。

まずはViewModeを以下のように修正します。
ここではRealmのobserveを使って更新を受け取るように変更しています。

class ContentViewModel: ObservableObject {
    private var token: NotificationToken?
    private var myModelResults = try? Realm().objects(MyModel.self)
    @Published var myModels: [MyModel] = []
    
    init() {
        token = myModelResults?.observe { [weak self] _ in
            self?.myModels = self?.myModelResults?.map { $0 } ?? []
        }
    }
    
    deinit {
        token?.invalidate()
    }
}

続けてView側も修正します。

struct ContentView: View {
    @ObservedObject var model = ContentViewModel()
    
    var body: some View {
        List {
            ForEach(model.myModels, id: \.id) { myModel in
                Button(action: {
                    try? Realm().write {
                        myModel.title = "\(Date())"
                    }
                }) {
                    Text("\(myModel.title)")
                }
            }
        }
    }
}

これで、ボタンをタップする度にtitleが更新されるようになりました。

追加

次はデータの追加を実装します。
ナビゲーションバーの追加ボタンを押したらデータが増えるような仕様にしたいと思います。

Viewを下のように変更します。
追加したのはnavigationBarItems以下の処理です。

struct ContentView: View {
    @ObservedObject var model = ContentViewModel()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(model.myModels, id: \.id) { myModel in
                    Button(action: {
                        try? Realm().write {
                            myModel.title = "\(Date())"
                        }
                    }) {
                        Text("\(myModel.title)")
                    }
                }
            }.navigationBarItems(trailing: Button(action: {
                let myModel = MyModel()
                myModel.title = "\(Date())"
                let realm = try? Realm()
                try? realm?.write {
                    realm?.add(myModel)
                }
            }) {
                Text("Add")
            })
        }
    }
}

これでデータの追加ができるようになりました。

f:id:llcc:20200426203109p:plain

削除

最後に削除処理を実装します。
削除処理は下のようにonDeleteを付ける事で実現できます。

struct ContentView: View {
    @ObservedObject var model = ContentViewModel()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(model.myModels, id: \.id) { myModel in
                    Button(action: {
                        try? Realm().write {
                            myModel.title = "\(Date())"
                        }
                    }) {
                        Text("\(myModel.title)")
                    }
                }.onDelete { indexSet in
                    if let index = indexSet.first {
                        let realm = try? Realm()
                        try? realm?.write {
                            realm?.delete(self.model.myModels[index])
                        }
                    }
                }
            }.navigationBarItems(trailing: Button(action: {
                let myModel = MyModel()
                myModel.title = "\(Date())"
                let realm = try? Realm()
                try? realm?.write {
                    realm?.add(myModel)
                }
            }) {
                Text("Add")
            })
        }
    }
}

実行すると削除ボタンが出るようになります。

f:id:llcc:20200426203427p:plain

ただ、削除しようとすると以下のようなエラーが出てしまいます。
Terminating app due to uncaught exception 'RLMException', reason: 'Object has been deleted or invalidated.'

これはデータを削除した後もSwiftUIが削除済みデータにアクセスするからです。
今回はcellの対になるViewModelを作る事で対応しました。
ViewModelの定義は下の通りです。

struct ContentViewCellModel {
    let id: String
    let title: String
}

View全体のViewModelもContentViewCellModelの配列を持つようにします。

class ContentViewModel: ObservableObject {
    private var token: NotificationToken?
    private var myModelResults = try? Realm().objects(MyModel.self)
    @Published var cellModels: [ContentViewCellModel] = []
    
    init() {
        token = myModelResults?.observe { [weak self] _ in
            self?.cellModels = self?.myModelResults?.map { ContentViewCellModel(id: $0.id, title: $0.title) } ?? []
        }
    }
    
    deinit {
        token?.invalidate()
    }
}

Viewもこれに合わせて変更します。

struct ContentView: View {
    @ObservedObject var model = ContentViewModel()
    
    var body: some View {
        NavigationView {
            List {
                ForEach(model.cellModels, id: \.id) { cellModel in
                    Button(action: {
                        let myModel = try? Realm().objects(MyModel.self).filter("id = %@", cellModel.id).first
                        try? Realm().write {
                            myModel?.title = "\(Date())"
                        }
                    }) {
                        Text("\(cellModel.title)")
                    }
                }.onDelete { indexSet in
                    let realm = try? Realm()
                    if let index = indexSet.first, let myModel = realm?.objects(MyModel.self).filter("id = %@", self.model.cellModels[index].id).first {
                        try? realm?.write {
                            realm?.delete(myModel)
                        }
                    }
                }
            }.navigationBarItems(trailing: Button(action: {
                let myModel = MyModel()
                myModel.title = "\(Date())"
                let realm = try? Realm()
                try? realm?.write {
                    realm?.add(myModel)
                }
            }) {
                Text("Add")
            })
        }
    }
}

これで削除しても落ちなくなりました。

f:id:llcc:20200426204652p:plain

Swiftでframeworkを作成する

アプリ開発で良く出てくる.frameworkという拡張子のライブラリを自作してみました。

frameworkを作成する

Xcodeで新規プロジェクト作成時にFrameworkを選択します。

f:id:llcc:20200412163759p:plain

プロジェクトは下のようなファイル構成になっています。

f:id:llcc:20200412164450p:plain

Productsの中に入っているMyFramework.frameworkの構成は下の通りです。

f:id:llcc:20200412164630p:plain

続けてライブラリにファイルを追加してみます。
追加したのは下のようにメソッドが一つだけあるクラスです。

別プロジェクトから呼び出すためにアクセス修飾子はpublicにしています。

import UIKit

public class MyClass {
    static public func test() {
        print("MyFramework")
    }
}

ビルドするとMyFramework.frameworkも更新されます。
Finderで見るとSwift用のヘッダーが作られている事が分かります。

f:id:llcc:20200412164908p:plain

frameworkを使ってみる

まずはプロジェクトにライブラリを追加します。
追加はXcodeFrameworks, Libraries, and Embedded Contentにframeworkファイルをドラッグするだけです。

f:id:llcc:20200412165056p:plain

以下のように書くことでMyClassのメソッドを使う事ができます。

import UIKit
import MyFramework

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        MyClass.test()
    }
}

Frameworkがプロジェクト外に配置されている場合、Framework Search Pathsにライブラリのパスを追加する必要があります。

f:id:llcc:20200412165840p:plain

frameworkのユニバーサル対応

上記の方法では、シミュレータか実機のどちらかでだけ動くライブラリのみ作れます。
これを両方の環境で動くように修正してみようと思います。

修正は下URLを参考にしました。

Swiftでフレームワークを作成する(第1回) | GMOアドパートナーズグループ TECH BLOG byGMO

まずは両対応ライブラリをつくためのターゲットを作成します。
XcodeのFile → New → Targetからターゲットを選んで、その中のAggregateを選びます。

f:id:llcc:20200412175631p:plain

AggregateにRun scriptを追加して下コードを貼り付けます。

#!/bin/sh
 
UNIVERSAL_OUTPUTFOLDER=${BUILD_DIR}/${CONFIGURATION}-universal
 
# make sure the output directory exists
mkdir -p "${UNIVERSAL_OUTPUTFOLDER}"
 
# Step 1. Build Device and Simulator versions
xcodebuild -target "${PROJECT_NAME}" ONLY_ACTIVE_ARCH=NO -configuration ${CONFIGURATION} -sdk iphoneos  BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build
xcodebuild -target "${PROJECT_NAME}" -configuration ${CONFIGURATION} -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build
 
# Step 2. Copy the framework structure (from iphoneos build) to the universal folder
cp -R "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework" "${UNIVERSAL_OUTPUTFOLDER}/"
 
# Step 3. Copy Swift modules from iphonesimulator build (if it exists) to the copied framework directory
SIMULATOR_SWIFT_MODULES_DIR="${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule/."
if [ -d "${SIMULATOR_SWIFT_MODULES_DIR}" ]; then
cp -R "${SIMULATOR_SWIFT_MODULES_DIR}" "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule"
fi
 
# Step 4. Create universal binary file using lipo and place the combined executable in the copied framework directory
lipo -create -output "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework/${PROJECT_NAME}"
 
# Step 5. Convenience step to copy the framework to the project's directory
cp -R "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework" "${PROJECT_DIR}"
 
# Step 6. Convenience step to open the project's directory in Finder
open "${PROJECT_DIR}"

あとはAggregateを実行するだけで両対応フレームワークを作る事ができます。

続けて上記スクリプトの内容を見ていきます。
下ではユニバーサルフレームワーク用のディレクトリの作成をしています。

#!/bin/sh
 
UNIVERSAL_OUTPUTFOLDER=${BUILD_DIR}/${CONFIGURATION}-universal
 
# make sure the output directory exists
mkdir -p "${UNIVERSAL_OUTPUTFOLDER}"

以下ではシミュレータ/実機それぞれ向けのフレームワークを作っています。
xcodebuildはXcodeに付いているコマンドラインツールで、プロジェクトのビルドをする事ができます。

# Step 1. Build Device and Simulator versions
xcodebuild -target "${PROJECT_NAME}" ONLY_ACTIVE_ARCH=NO -configuration ${CONFIGURATION} -sdk iphoneos  BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build
xcodebuild -target "${PROJECT_NAME}" -configuration ${CONFIGURATION} -sdk iphonesimulator ONLY_ACTIVE_ARCH=NO BUILD_DIR="${BUILD_DIR}" BUILD_ROOT="${BUILD_ROOT}" clean build

以下では生成したフレームワークやモジュールファイルのコピーをしています。

# Step 2. Copy the framework structure (from iphoneos build) to the universal folder
cp -R "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework" "${UNIVERSAL_OUTPUTFOLDER}/"
 
# Step 3. Copy Swift modules from iphonesimulator build (if it exists) to the copied framework directory
SIMULATOR_SWIFT_MODULES_DIR="${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule/."
if [ -d "${SIMULATOR_SWIFT_MODULES_DIR}" ]; then
cp -R "${SIMULATOR_SWIFT_MODULES_DIR}" "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/Modules/${PROJECT_NAME}.swiftmodule"
fi

以下ではlipoというツールを使ってユニバーサルフレームワークを作っています。
lipoはXcodeに付属しているツールで、シミュレータ/実機向けのフレームワークを結合する事ができます。

# Step 4. Create universal binary file using lipo and place the combined executable in the copied framework directory
lipo -create -output "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphonesimulator/${PROJECT_NAME}.framework/${PROJECT_NAME}" "${BUILD_DIR}/${CONFIGURATION}-iphoneos/${PROJECT_NAME}.framework/${PROJECT_NAME}"

最後にプロジェクトのルートディレクトリにフレームワークをコピーしています。

# Step 5. Convenience step to copy the framework to the project's directory
cp -R "${UNIVERSAL_OUTPUTFOLDER}/${PROJECT_NAME}.framework" "${PROJECT_DIR}"
 
# Step 6. Convenience step to open the project's directory in Finder
open "${PROJECT_DIR}"

実際にUniversal対応になったかどうかはfileコマンドで確認する事ができます。

file MyFramework.framework/MyFramework

fileコマンドを実行すると下のように両環境に対応できている表示が出てきます。

MyFramework.framework/MyFramework: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit dynamically linked shared library x86_64] [arm64:Mach-O 64-bit dynamically linked shared library arm64]
MyFramework.framework/MyFramework (for architecture x86_64):    Mach-O 64-bit dynamically linked shared library x86_64
MyFramework.framework/MyFramework (for architecture arm64): Mach-O 64-bit dynamically linked shared library arm64

iOSシミュレータ用と実機用のバイナリの場合は下のような表示になります。

# iOSシミュレータ
MyFramework.framework/MyFramework: Mach-O 64-bit dynamically linked shared library x86_64
# 実機
MyFramework.framework/MyFramework: Mach-O 64-bit dynamically linked shared library arm64

Sequenceプロトコルを使ってみる

Sequenceというプロトコルを試してみました。
Sequenceは配列に使われているプロトコルで、準拠することでforEachやmapなどのメソッドを使うことができます。

実装例は下の通りです。
deferを使ってreturnの後にcountを-1しています。

これで10から1までを順番に取り出せる構造体を作ることができました。

struct Countdown: Sequence, IteratorProtocol {
    var count: Int

    mutating func next() -> Int? {
        if count == 0 {
            return nil
        } else {
            defer { count -= 1 }
            return count
        }
    }
}

Countdown(count: 10).forEach { print($0) } // → 10, 9, 8...2, 1

サンプルは下URLのものを使っています。

https://developer.apple.com/documentation/swift/sequence

下のようにInt以外を扱うことも可能です。

struct MySequence: Sequence, IteratorProtocol {
    var index = 0
    let texts = ["a", "b", "c", "d", "e"]
    
    mutating func next() -> String? {
        if index >= texts.count {
            return nil
        } else {
            defer { index += 1 }
            return texts[index]
        }
    }
}

MySequence().forEach { print($0) } // → "a", "b", "c", "d", "e"

Swiftで文字列を一定の長さ毎に区切って配列にする方法

下のように文字を一定の長さで区切って配列にする方法を調べてみました。

"あいうえお" → ["あい", "うえ", "お"]

標準のメソッドは用意されてないようなので、下のように独自のメソッドを追加しました。

extension String {
    func split(length: Int) -> [String] {
        var texts: [String] = []
        var start = self.startIndex
        while start < self.endIndex {
            let end = self.index(start, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex
            texts.append(String(self[start..<end]))
            start = end
        }
        return texts
    }
}

使い方は下の通りです。

"あいうえお".split(length: 2) // → ["あい", "うえ", "お"]
"あいうえお".split(length: 1) // → ["あ", "い", "う", "え", "お"]

メソッドでは下の書き方で文字列の一部を取り出しています。
startとendはString.Index型になります。

self[start..<end]

endは下の書き方で作っています。
limitedByはlengthが大きすぎる時にFatal error: String index is out of boundsエラーが発生するので付けています。

let end = self.index(start, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex