しめ鯖日記

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

SF Symbols + UIImageでアイコンを表示する

SF SymbolsというAppleの提供しているアイコンを使える機能を試してみました。

developer.apple.com

SF Symbols + UIImageでアイコンを表示する

使い方は簡単で、下のようにUIImageの初期化でアイコン名を渡すだけです。
引数のラベルがUIImage(name: "")ではなくUIImage(systemName: "")なのでそこだけ注意して下さい。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let imageView = UIImageView(image: UIImage(systemName: "doc.badge.gearshape.fill"))
        imageView.frame.size = CGSize(width: 100, height: 100)
        imageView.center = view.center
        view.addSubview(imageView)
    }
}

実行すると下のようにアイコンを表示できます。

f:id:llcc:20210107145814p:plain

アイコン名を確認したい時は下URLからアプリをダウンロードして下さい。

developer.apple.com

アプリを起動すると下のようにアイコン一覧とアイコン名が表示されます。

f:id:llcc:20210107150047p:plain

画像の色を変えたい場合、下のようにtintColorを変更します。

imageView.tintColor = .red

画像が赤になりました。

f:id:llcc:20210107150609p:plain

画像はベクターデータの為、大きくしても劣化しません。
下はサイズを500x500に変更したものです。

f:id:llcc:20210107150540p:plain

SF Symbols + UILabelで文字の中にアイコンを入れる

SF Symbolsは画像でしか取れないので、文字の中に入れたい場合は下のようにNSTextAttachmentを使います。

let label = UILabel()
let text = NSMutableAttributedString(string: "アイコン前")
text.append(NSAttributedString(attachment: NSTextAttachment(image: UIImage(systemName: "doc.badge.gearshape.fill")!)))
text.append(NSAttributedString(string: " 後"))
label.attributedText = text
label.sizeToFit()
label.center = view.center
view.addSubview(label)

実行すると文字の間にアイコンが入った事が分かります。

f:id:llcc:20210107151055p:plain

参考URL

Apple Developer Documentation

iOS13のカスタムフォントを試してみる

iOS13から使えるようになっていたカスタムフォントのAPIを試してみました。
この機能を使うと端末へのフォントインストールやフォントの確認ができます。

まずはフォント一覧の取得をしてみようと思います。 最初にテスト用のプロジェクトを作ります。

f:id:llcc:20201031141030p:plain

プロジェクトを作ったらプロジェクト設定の「Signing & Capabilities」でFontsを有効化します。

f:id:llcc:20201031141015p:plain

Storyboardで下のようにラベルとボタンだけ配置します。

f:id:llcc:20201031142117p:plain

最後にボタンを押したらUIFontPickerViewController(フォント一覧画面)を表示するような実装をします。

class ViewController: UIViewController {
    @IBAction func tapBtn() {
        let v = UIFontPickerViewController()
        present(v, animated: true, completion: nil)
    }
}

アプリを起動してボタンを押すとフォント一覧を見る事ができました。

f:id:llcc:20201031143020p:plain

次に一覧からフォントを選んで使います。
コードは下のとおりです。
ViewControllerをUIFontPickerViewControllerDelegateに準拠させました。

class ViewController: UIViewController {
    @IBOutlet weak var testLabel: UILabel!
    
    @IBAction func tapBtn() {
        let v = UIFontPickerViewController()
        v.delegate = self
        present(v, animated: true, completion: nil)
    }
}

extension ViewController: UIFontPickerViewControllerDelegate {
    func fontPickerViewControllerDidPickFont(_ viewController: UIFontPickerViewController) {
        guard let fontDescriptor = viewController.selectedFontDescriptor else {
            return
        }
        testLabel.font = UIFont(descriptor: fontDescriptor, size: 17)
    }
}

これでフォント選択したらラベルのフォントが変わるようになりました。

f:id:llcc:20201031143619p:plain

フォント一覧ですが、filteredLanguagesPredicateを使うと日本語対応フォントのみといった絞り込みができます。

let v = UIFontPickerViewController()
v.configuration.filteredLanguagesPredicate = UIFontPickerViewController.Configuration.filterPredicate(forFilteredLanguages: ["js"])

次はフォントのインストールをしてみます。
フォントはNikkyou Sansというフォントを使わせて頂きました。

Nikkyou Sans Font | daredemotypo | FontSpace

ダウンロードしたフォントをxcassetsに追加します。

f:id:llcc:20201031151439p:plain

フォントのインストール処理は下のとおりです。
CTFontManagerRegisterFontsWithAssetNamesメソッドでxcassetsのフォントを登録できます。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        CTFontManagerRegisterFontsWithAssetNames(["NikkyouSans-B6aV"] as CFArray, nil, .user, true, { arr, result in
            print(arr)
            print(result)
            return true
        })
    }
}

アプリを起動すると下のようにインストール確認画面が表示されます。

f:id:llcc:20201031151602p:plain

インストールしたフォントは下のようにフォント一覧画面で選べるようになります。

f:id:llcc:20201031151713p:plain

【SwiftUI】GeometryReaderでViewのサイズを取得する

画面サイズによって処理を変えたかったため、GeometryReaderというクラスを使って画面サイズを取得しました。

Viewのサイズは下の形で取得できます。

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            Text(String("\(geometry.size)"))
        }
    }
}

実行すると下のようにViewのサイズが取得できます。

f:id:llcc:20201028105427p:plain

ただ上記ViewサイズはSafeAreaが除外されているので注意が必要です。
SafeAreaを取得したい場合はしたのように記述します。

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            Text(String("\(geometry.safeAreaInsets)"))
        }
    }
}

View全体ではなく、内側にあるViewのサイズを取りたい場合は下のように入れ子にします。

struct ContentView: View {
    var body: some View {
        VStack {
            Spacer().frame(height: 100)
            GeometryReader { geometry in
                Text(String("\(geometry.size)"))
            }
            Spacer().frame(height: 100)
        }
    }
}

実行すると内側のViewのサイズを取れました。

f:id:llcc:20201028110442p:plain

GeometryReaderはWidget上でも使う事ができます。

f:id:llcc:20201028110852p:plain

frameメソッドを使う事で、xとyも取得できます。
引数はCoordinateSpaceというクラスです。

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            Text(String("\(geometry.frame(in: .global))"))
        }
    }
}

実行結果は下のとおりです。
SafeAreaも含めた結果になるので注意が必要です。

f:id:llcc:20201028111312p:plain

CoordinateSpaceは自分でcoordinateSpaceメソッドを使って定義する事も可能です。
下の例ではVStackでTestという名前のCoordinateSpaceを定義して、それに対する位置を取得しています。

struct ContentView: View {
    var body: some View {
        VStack {
            Spacer().frame(height: 100)
            GeometryReader { geometry in
                Text(String("\(geometry.frame(in: .named("Test")))"))
            }
            Spacer().frame(height: 100)
        }.coordinateSpace(name: "Test")
    }
}

実行するとVStackに対する位置情報が取得できている事が分かります。

f:id:llcc:20201028111637p:plain

Xcode 12でbuilding for iOS Simulator, but linking in object file built for iOS, for architecture arm64というエラーが出た時の対応

Xcode 12でビルドした時、下のようなエラーが出た時の対応です。
エラーはCocoaPodsで入れたRealmで発生していました。

building for iOS Simulator, but linking in object file built for iOS, for architecture arm64

結論としては、PodfileにEXCLUDED_ARCHSに関する記述を追加してpod installしたら動くようになりました。

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      if target.name.include?('Realm')
        config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
      end
    end
  end
end

参考にしたのは下URLです。

Xcode 12, building for iOS Simulator, but linking in object file built for iOS, for architecture arm64 - Stack Overflow

詳細なエラー内容は下のとおりです。
おそらくRealmのlibrealmcore-ios.aをシミュレータ向けにビルドしようとしたけどarm64になっていたという内容です。
シミュレータはx86_64というアーキテクチャを使うので、正しく指定できてなくてエラーになったのかと思います。

MyApp/Pods/Realm/core/librealmcore-ios.a(bptree.o), building for iOS Simulator, but linking in object file built for iOS, for architecture arm64

各端末で使っているアーキテクチャは以下URLが参考になりました。

iOS・tvOS・watchOS デバイスのアーキテクチャについてのまとめ – ymyzk’s blog

iOSデバイス一覧表 - Qiita

試しにfileコマンドでlibrealmcore-ios.aのアーキテクチャを見た所、arm64だけでなくx86_64にも対応していました。

f:id:llcc:20201006112100p:plain

対応自体はしているので、XcodeのRealmとRealmSwiftのExcluded Architecturesにarm64を指定すればx86_64でビルドできるようになります。

f:id:llcc:20201006113959p:plain

先程も書いた下処理をPodfileに書けば自動でRealmとRealmSwiftにExcluded Architecturesを設定してくれます。

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      if target.name.include?('Realm')
        config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
      end
    end
  end
end

この処理によってシミュレータの時だけarm64を除外する設定が追加されます。

f:id:llcc:20201006114732p:plain

DebugのBuild Active Architecture OnlyをYesに変える事で動かすこともできました。

f:id:llcc:20201006114402p:plain

Debugで常にBuild Active Architecture OnlyをYesにしたい場合は下処理をPodfileに記述することで実現できます。

post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      if config.name == 'Debug'
        config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES'
      end
    end
  end
end

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