しめ鯖日記

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

CocoaPodsのライブラリをstatic化して高速化

下記事を参考にアプリ起動の高速化を試してみました。

iOSアプリの起動速度を2倍にするために、複数のDynamic FrameworkをStaticにして、ひとつのDynamic Frameworkを作る with Swift | by Muukii (Hiroshi Kimura) | Eureka Engineering | Medium

まずはDynamic Frameworkを使った時の速度を調べてみたいと思います。

まずは新規アプリを作成します。

作成したら起動時の設定でDYLD_PRINT_STATISTICSをYESにします。
こうする事でDynamic Frameworkのロードにどの程度時間がかかっているかも確認してみます。 (DYLD_PRINT_STATISTICSは現在のXcodeでは利用できないのでInstrumentsなどで測定してください)

実行結果は下の通りです。
「dylib loading time」というのがDynamicなライブラリの読み込み時間です。
3回試した所、「dylib loading time」の数字は平均で19.5msでした。

今回の検証はiPhone6sで試しました。
最初はシミュレータで試したのですが、ライブラリを追加しても読み込み時間にほとんど変化がなかったので実機を使うことにしました。

DYLD_PRINT_STATISTICSのログの見方は下URLを参考にしました。

[レポート] Improving Application Startup Performance #AltConf2018 #WWDC18 | DevelopersIO

次はCocoaPodsを導入して変化があるかを調べます。
空のPodfileを作ってpod installをします。

platform :ios, '13.7'

target 'MyApp' do
  use_frameworks!
end

結果は下の通りです。
3回試した時の「dylib loading time」の平均は23.3msでした。
CocoaPodsなしよりは少しだけ伸びていました。

今度はDynamic Frameworkを複数入れてどうなるか調べてみました。
導入ライブラリは下の5つです。

platform :ios, '13.7'

target 'MyApp' do
  use_frameworks!

  pod 'SwiftDate'
  pod 'R.swift'
  pod 'RealmSwift'
  pod 'Alamofire'
  pod 'SwiftyJSON'
end

Xcodeで見るとこれらのライブラリがDynamicになっている事が分かります。

結果は下の通りです。
先程に比べると読み込み時間が大幅に伸びています。
3回試した時の平均読み込み時間は255.0msでした。

今回はiPhone6sですが、iPhone 11 proでも241.94msほどかかったので最新端末でも読み込み時間はかなり長いようです。

それと2回目の起動時は初回起動に比べると圧倒的に読み込み時間が短くなります。
おそらく2回目以降は前回の読み込みをキャッシュしているのかと思います。

端末再起動をする事で読み込み時間は再び長くなります。
アプリのプロセスの切っても長くなる時があったので、何かしらのルールでキャッシュクリアしているのだと思います。

次はライブラリの数を3つに減らしてみました。

platform :ios, '13.7'

target 'MyApp' do
  use_frameworks!

  pod 'SwiftDate'
  pod 'R.swift'
  pod 'RealmSwift'
end

平均は216.5msなので、ライブラリの数に応じて起動時間が変わる事が分かります。

Dynamic Frameworkのリンク時間の測定ができたので、次はStatic化してどうなるかを見ていきます。
CocoaPodsでStatic化するにはuse_frameworks!を外します。

target 'MyApp' do
  pod 'SwiftDate'
  pod 'R.swift'
  pod 'RealmSwift'
  pod 'Alamofire'
  pod 'SwiftyJSON'
end

普通に外すだけだと以下のようにmodule mapが必要というエラーになります。

上記ではRealmのmodule mapが必要というエラーだったので、Realmを追加してmodular_headers: trueオプションを付けます。

target 'MyApp' do
  pod 'SwiftDate'
  pod 'R.swift'
  pod 'Realm', modular_headers: true
  pod 'RealmSwift'
  pod 'Alamofire'
  pod 'SwiftyJSON'
end

Xcodeで見るとStatic Libraryになっている事が分かります。

Dynamic Frameworkの読み込み時間を調べたら平均20.1msまで減っていました。
Static化で起動時間が大きく削られた事が分かります。

起動画面の比較結果は下の通りです。
Staticの方が早い事が分かります。

use_frameworks!について

use_frameworks!はCocoaPodsのライブラリをDynamic Frameworkとして読み込む機能です。
Xcode 9まではSwiftのライブラリを静的にできなかったのでこの記述が必須でした。

参考URLは以下の通りです。

CocoaPods 1.5.0 — Swift Static Libraries - CocoaPods Blog

gRPCをSwiftで使ってみる

gRPCを使ってgoのサーバーにSwiftでアクセスする処理を実装しました。
Swiftは5.2、Go言語はgo1.14.4、protobufは3.12.3を使っています。

gRPCとは

gRPCとはGoogleが開発したRPCを実現するためのプロトコルのことです。
RPCはremote procedure callの略で、別のサーバーのメソッドなどを呼び出すことができる技術です。

要するにgRPCを使えば今までRESTなどでAPI通信した箇所を置き換えることができます。

今回はSwiftからgRPCを使ってサーバーにアクセスしてみたいと思います。

Go言語でgRPCサーバーを作る

サーバー立ち上げは下サイトを参考にさせて頂きました。

https://tech.smartcamp.co.jp/entry/2019/03/28/175137

Go言語とprotobufが入っていない場合はbrewでインストールします。
protobufは

brew install go
brew install protobuf

下コマンドで必要なライブラリをインストールします。

go get -u google.golang.org/grpc
go get -u github.com/golang/protobuf/protoc-gen-go

データ構造を示すprotoファイルを作成します。
pingerというフォルダを作ってpinger.protoというファイルを作って下のように記述します。

syntax = "proto3";

package pinger;

service Pinger {
  rpc Ping(Empty) returns (Pong) {}
}

message Empty {}

message Pong {
  string text = 1;
}

protoファイルを作ったら、下コマンドでgo言語のファイルを生成します。

protoc ./pinger/pinger.proto --go_out=plugins=grpc:.

もし下エラーになった場合、goのライブラリへのパスが足りていない可能性があります。
PATH="${PATH}:${HOME}/go/bin"でパスを追加すると解消することがあります。

protoc-gen-go: program not found or is not executable
Please specify a program using absolute path or make sure the program is available in your PATH system variable
--go_out: protoc-gen-go: Plugin failed with status code 1.

protocコマンドがうまく動けばpingerフォルダにpinger.pb.goというファイルが生成されます。

f:id:llcc:20200621212321p:plain

最後にserverを作成します。
カレントディレクトリにserver.goというファイルを作って下のように記述します。

package main

import (
    "context"
    "log"
    "net"

    "./pinger"
    "google.golang.org/grpc"
)

func main() {
    listener, err := net.Listen("tcp", ":5300")
    if err != nil {
        log.Fatalf("failed to listen: %v\n", err)
        return
    }
    grpcSrv := grpc.NewServer()
    pinger.RegisterPingerServer(grpcSrv, &server{})
    log.Printf("Pinger service is running!")
    grpcSrv.Serve(listener)
}

type server struct{}

func (s *server) Ping(ctx context.Context, req *pinger.Empty) (*pinger.Pong, error) {
    pong := &pinger.Pong{
        Text: "pong",
    }
    return pong, nil
}

ファイルを作ってから下コマンドを実行するとサーバーが起動します。

go run server.go

起動したら動作確認をします。
今いるディレクトリに下のようなcommand.goを作成します。

package main

import (
    "context"
    "fmt"
    "os"

    "./pinger"
    "google.golang.org/grpc"
)

func main() {
    conn, err := grpc.Dial("localhost:5300", grpc.WithInsecure())
    if err != nil {
        fmt.Fprintf(os.Stderr, "grpc.Dial: %v\n", err)
        return
    }
    defer conn.Close()
    client := pinger.NewPingerClient(conn)
    req := &pinger.Empty{}
    pong, err := client.Ping(context.Background(), req)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Ping: %v\n", err)
        return
    }
    fmt.Fprintf(os.Stdout, "Pong: %s\n", pong.Text)
}

下コマンドを実行してPong: pongという表示になれば問題なく動いています。

go run command.go

Swiftからサーバーのメソッドを呼び出す

次はSwiftでクライアントを作ります。
適当なプロジェクトを作成したらCocoaPodsでgRPC-Swiftをインストールします。

target 'MyApp' do
  use_frameworks!

  pod 'gRPC-Swift', '1.0.0-alpha.12'
end

次は下コマンドでprotocのswiftプラグインをインストールします。

git clone https://github.com/grpc/grpc-swift.git
cd grpc-swift
make plugins

生成したプラグインはパスの通っている場所に移動します。
今回は一旦試したいだけなのでgrpc-swiftのパスを追加するだけにしました。

PATH="${PATH}:${HOME}/XXXX/grpc-swift"

ここまで終わったらクライアント用のファイルを生成します。

protoc pinger/pinger.proto --swift_out=. --grpc-swift_out=.

pinger.pb.swiftpinger.grpc.swiftというファイルが作られるので、これをプロジェクトに追加します。

追加したら実際に通信を行います。
AppDelegateを下のように修正します。

import UIKit
import GRPC
import NIO

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
        let channel = ClientConnection.insecure(group: group).connect(host: "localhost", port: 5300)
        let client = Pinger_PingerClient(channel: channel)
        let request = Pinger_Empty()
        let call = client.ping(request)
        print(try! call.response.wait())
        
        return true
    }
}

実行すると下のようにレスポンスが返る事がわかります。

f:id:llcc:20200628171237p:plain

参考URL

遠隔手続き呼出し - Wikipedia

iOSエンジニアの為のgrpc-swift入門 - Speaker Deck

SwiftUIのGeometryReaderを試してみる

SwiftUIのGeometryReaderという構造体を使ってみました。
GeometryReaderとは自身の座標やサイズを子Viewに渡せる構造体です。
これを使う事で、動的に変化するViewなどが作りやすくなります。

今回は下URLを参考にさせて頂きました。

qiita.com

参照URLにもある中心に行くほど大きくなるViewを作ってみます。
まずはSwiftUIのプロジェクトを作成します。

struct ContentView: View {
    var body: some View {
        Text("Hello world")
    }
}

次はScrollViewの上に10個の円を並べます。

struct ContentView: View {
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(0..<10) {_ in
                    Circle().frame(width: 100, height: 100).foregroundColor(Color.gray)
                }
            }
        }
    }
}

実行すると下のように円が並びます。

f:id:llcc:20200607154829p:plain

次は中心に来たときに大きくなるような実装をします。
ここでGeometryReaderを使います。

実装は下のとおりです。
CircleをGeometryReaderで囲む事でCircleの位置が分かるようにしています。

geometry.frame(in: .global)でCircleの位置を把握したら、それを使って円のサイズを変更しています。

struct ContentView: View {
    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(0..<10) {_ in
                    GeometryReader { geometry in
                        Circle().foregroundColor(Color.gray)
                            .scaleEffect(self.scale(x: geometry.frame(in: .global).midX))
                    }.frame(width: 100, height: 200)
                }
            }
        }
    }
    
    func scale(x: CGFloat) -> CGFloat {
        let centerX = UIScreen.main.bounds.width / 2
        return max(1.5 - abs(x - centerX) / centerX, 1)
    }
}

実行すると下のように中心の円が大きくなります。

f:id:llcc:20200607160519p:plain

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