しめ鯖日記

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

SwiftUIのsomeキーワードについて調べてみる

SwiftUIでViewを作る時、以下のようにsomeというキーワードが出てきます。
今回はこのキーワードについて調べてみました。

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

調査にあたって以下の記事を参考にさせて頂きました。

Swift 5.1 に導入される Opaque Result Type とは何か - Qiita

someキーワードですが、実行時のパフォーマンス向上の為のキーワードになります。
具体的には「戻り値の型をプロトコルとして宣言するとメモリ使用量が増える」という悩みを解消してくれるものになります。

仮に下のようなクラスとプロトコルがあるとします。

protocol MyProtocol {
}

class MyClass: MyProtocol {
}

その時、下の2つのメソッドを比べるとMyProtocolを戻り値の型にしたほうがメモリ使用量が大きくなってしまいます。

// メモリ使用量が大きい
func myProtocol() -> MyProtocol { return MyClass() }
// メモリ使用量が小さい
func myProtocol() -> MyClass { return MyClass() }

それを解決するのがsomeキーワードです。
someを付ける事で、メソッドの実装によって戻り値の型が最適なものになります。

// メモリ使用量が大きい
func myProtocol() -> MyProtocol { return MyClass() }
// メモリ使用量が小さい
func myProtocol() -> some MyProtocol { return MyClass() }

実際に比較してみた結果は下のとおりです。
1,000,000個のインスタンスを作って、メモリ使用量を比較してみました。

結果を見るとsome MyProtocolMyClassとほぼパフォーマンスを出していることがわかります。

戻り値の型 1回目の測定結果 2回目の測定結果 3回目の測定結果 平均
MyClass 28.9mb 27.2mb 31.1mb 29.1mb
MyProtocol 48.7mb 58.3mb 61.4mb 56.2mb
some MyProtocol 28mb 32.4mb 31.6mb 30.7mb

100万回個のインスタンス生成の実装は以下の通りです。

let results = (0...1000000).map { _ in
    myProtocol()
}

メモリ使用量はXcodeのMemory reportのHighの値を使用しました。

f:id:llcc:20200127154802p:plain

以下のように実行速度についても調べたのですが、こちらは大きな差はありませんでした。

let d = Date()
let results = (0...1000000).map { _ in
    myProtocol()
}
print(Date().timeIntervalSince1970 - d.timeIntervalSince1970)

someキーワードはクラスにも使えるのですが、当然ながらsomeがあってもなくてもメモリ使用量に差は見えませんでした。

func myProtocol() -> some MyClass { return MyClass() }

余談ですがSwiftUIのViewからsomeを外すとProtocol 'View' can only be used as a generic constraint because it has Self or associated type requirementsというエラーになります。
これはViewが、associatedtypeを使っているためです。

struct ContentView: View {
    var body: View {
        Text("Hello, World!")
    }
}

someは引数には使うことができません。
引数はジェネリクスで対処する必要があります。

func myProtocol(myClass: some MyProtocol) { } // → x
func myProtocol<A: MyProtocol>(myClass: A) { } // → o

Today Extensionでウィジェット作成

Today Extensionを使ったウィジェット作成を試してみました。

プロジェクトを作成したらメニューからTargetの作成を行います。

f:id:llcc:20200113191348p:plain

Today Extensionを選択します。

f:id:llcc:20200113191455p:plain

作成するとToday Extension関連ファイルが作られます。

f:id:llcc:20200113191618p:plain

Today Extensionを起動するとHello worldが表示されます。

f:id:llcc:20200113191726p:plain

Storyboardでテキストを変更する事で文字を変える事ができます。

f:id:llcc:20200113192736p:plain

ラベルはUIViewControllerと紐付ける事で動的な変更もできます。

class TodayViewController: UIViewController, NCWidgetProviding {
    @IBOutlet weak var myLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        myLabel.text = "Hello!"
    }
}

ラベルだけでなくボタンも配置する事ができます。

f:id:llcc:20200113205708p:plain

widgetLargestAvailableDisplayModeをセットする事でウィジェットのサイズを大きくできます。
サイズはwidgetActiveDisplayModeDidChangeでセットする事が可能です。

class TodayViewController: UIViewController, NCWidgetProviding {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        extensionContext?.widgetLargestAvailableDisplayMode = .expanded
    }
    
    func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
        switch activeDisplayMode {
        case .compact:  preferredContentSize = maxSize
        case .expanded: preferredContentSize = CGSize(width: preferredContentSize.width, height: 400)
        }
    }
}

UserDefaultsなどのデータ共有にはApp Groupsが必要になります。
まずはXcode上でApp Groupsを作成します。
この時はアプリのターゲットだけでなくExtension側にも追加が必要です。

f:id:llcc:20200113193701p:plain

あとは作成したグループを使ってデータ作成すればデータ共有できるようになっています。

UserDefaults(suiteName: "group.mygroup")?.set(100, forKey: "Key")
print(UserDefaults(suiteName: "group.mygroup")?.integer(forKey: "Key"))

Realmなどのデータベースのデータ共有もApp Groupsを使います。

var config = Realm.Configuration.defaultConfiguration
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.mygroup")
config.fileURL = url?.appendingPathComponent("default.realm")
Realm.Configuration.defaultConfiguration = config

データだけでなくクラスも共有できないので、Embedded Frameworkの利用やCompile Sourcesへの追加などが必要です。

f:id:llcc:20200113203354p:plain

CocoaPodsを使う場合、Extension用の処理も必要があるので注意が必要です。

target 'MyApp' do
  use_frameworks!

  pod 'RealmSwift'
end

target 'MyExtension' do
  use_frameworks!

  pod 'RealmSwift'
end

SwiftUIを触ってみる

SwiftUIで簡単なUIを作ってみました。

まずはXcode11で新規プロジェクトを作成します。

f:id:llcc:20191201174101p:plain

テンプレートは「Single View App」を選びました。

f:id:llcc:20191201174134p:plain

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

f:id:llcc:20191201175809p:plain

Xcode11からはSceneDelegate.swiftというファイルが追加されています。 これはiPadで画面に複数アプリ表示する際に使うクラスで、今までのAppDelegateの役割の一部を担っています。 今回はSwiftUIを中心に見たいので深堀りしないことにします。

まずはアプリを立ち上げてみます。 アプリを起動すると下のようにHello, World!と表示されます。

f:id:llcc:20191201180656p:plain

その下にラベルを1つ追加してみたいと思います。 ContentViewクラスを以下のように修正します。 2つ縦に並べるのはVStackというクラスを使っています。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, World!")
            Text("Goodnight, World!")
        }
    }
}

アプリを起動するとラベルが2つ表示されています。

f:id:llcc:20191201180957p:plain

次にラベル間を少し空けてみます。 VStackの初期化時にspacingを渡します。

struct ContentView: View {
    var body: some View {
        VStack(spacing: 200) {
            Text("Hello, World!")
            Text("Goodnight, World!")
        }
    }
}

これで間隔を200pxにする事ができました。

f:id:llcc:20191201181253p:plain

ラベルと色やフォントも変更してみます。

struct ContentView: View {
    var body: some View {
        Text("Hello, World!").font(Font.system(size: 50)).foregroundColor(Color(red: 1, green: 1, blue: 0))
    }
}

少し見づらいですが黄色の大きめの文字に変わりました。

f:id:llcc:20191201182626p:plain

次はボタンの追加とタップ時のアクション追加を行います。

struct ContentView: View {
    var body: some View {
        Button("ボタン") {
            print(10)
        }
    }
}

ボタンをタップするとデバッグエリアに10が表示されます。

f:id:llcc:20191201181647p:plain

次はボタンタップ時にアラートも表示してみます。

struct ContentView: View {
    @State var isPresented = false
    
    var body: some View {
        Button("ボタン") {
            self.isPresented = true
        }.alert(isPresented: $isPresented) {
            Alert(title: Text("Title"), message: Text("Message"))
        }
    }
}

アラートを表示する事ができました。

f:id:llcc:20191201183827p:plain

この.alert(isPresented:)はButtonに付いている必要はなく以下のようにTextに付いていても動きます。

struct ContentView: View {
    @State var isPresented = false
    
    var body: some View {
        VStack {
            Text("Hello, World!").alert(isPresented: $isPresented) {
                Alert(title: Text("Title"), message: Text("Message"))
            }
            Button("ボタン") {
                self.isPresented = true
            }
        }
    }
}

次はボタンを押した時にテキストの内容を変える処理をします。

struct ContentView: View {
    @State var text = "Hello, World"
    
    var body: some View {
        VStack {
            Text(text)
            Button("ボタン") {
                self.text = "Goodnight, World"
            }
        }
    }
}

ボタンを押すとテキストを変更する事ができました。

f:id:llcc:20191201184821p:plain

以下のようにViewModelを別途作る事もできます。

class MyViewModel : ObservableObject, Identifiable {
    @Published
    var text: String = "Hello, World"
}

struct ContentView: View {
    @ObservedObject var viewModel = MyViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.text)
            Button("ボタン") {
                self.viewModel.text = "Goodnight, World"
            }
        }
    }
}

データをTextFieldと紐付ける事もできます。 TextFieldの引数はBinding<String>なので$viewModel.textとドルを付けてBindingオブジェクトに変えて上げる必要があります。

class MyViewModel : ObservableObject, Identifiable {
    @Published
    var text: String = "Hello, World"
}

struct ContentView: View {
    @ObservedObject var viewModel = MyViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.text)
            TextField("Placeholder", text: $viewModel.text)
        }
    }
}

f:id:llcc:20191201190104p:plain

画面遷移はNavigationViewを使うと実現できます。

struct ContentView: View {
    var body: some View {
        NavigationView {
            NavigationLink(destination: DestinationView()) {
                Text("ボタン")
            }
        }
    }
}

struct DestinationView: View {
    var body: some View {
        Text("遷移先")
    }
}

Googleのデータポータル(旧DataStudio)でアプリの新規ユーザーをグラフ化

先日の記事でFirebaseのデータをBigQueryに追加しました。 今度はBigQueryのデータをデータポータルというツールを使ってグラフ化してみようと思います。

www.cl9.info

データポータルとは

データポータルとはGoogleの提供しているデータをビジュアライズ化するツールです。 今回はBigQueryのデータをグラフ化するのですが、BigQuery以外にGoogleアナリティクス・Googleスプレットシート・MySQLのデータなど色々なデータを取り込んで表示する事ができます。

datastudio.google.com

取り込んだデータは下のように様々な形で表示する事ができます。

f:id:llcc:20191201114725p:plain

BigQueryで取り込んだデータを表示する

データポータルのページに移動してから、「空のレポート」を選んでレポート作成します。

f:id:llcc:20191201115052p:plain

レポートを作ったら右下の「新しいデータソースを作成」ボタンからデータソースを作ります。

f:id:llcc:20191201115158p:plain

ボタンを押すとデータ取得元を選ぶ画面に移動します。 今回はBigQueryを選択します。

f:id:llcc:20191201115248p:plain

選択と下のようにデータセット選択ページに飛ぶのでプロジェクトのAnalyticsのデータを選択します。 events_intraday_YYYYMMDDは直近のデータのみなので、今回はevents_YYYYMMDDを選択しました。

f:id:llcc:20191201115907p:plain

フィールドを選ぶ画面が出るんですがここは何も変更せず保存します。

f:id:llcc:20191201171001p:plain

それが終わると画面にイベント数の入ったテーブルが表示されます。 今回は新規ユーザー数を表示したいのでこれは削除します。

f:id:llcc:20191201171031p:plain

削除してから「グラフを追加」で時系列グラフを追加します。

f:id:llcc:20191201171127p:plain

デフォルトだと全イベントの数が入っているので、新規ユーザー数のみに絞ります。 右側の「フィルタを追加」ボタンから、新規登録数を表示するためのフィルターを作成します。

f:id:llcc:20191201171243p:plain

フィルターはEvent Nameがfirst_open(初めてアプリ立ち上げ)のイベントで絞り込みます。

f:id:llcc:20191201171318p:plain

以上で設定は完了です。 2日分しかデータ入れてないのですが、無事に新規ユーザー数を表示する事ができました。

f:id:llcc:20191201171554p:plain

FirebaseのデータをBigQueryに入れて分析する

Firebase単体だと見づらいデータが多いのでBigQueryに入れて分析してみました。
今回はFirebaseとBigQueryを連携して新規ユーザー数を取得するところまでやります。

ドキュメントは下URLになります。

Firebase 向け Google アナリティクスのデータを BigQuery にインポート  |  ソリューション  |  Google Cloud

BigQueryの料金

FirebaseとBigQueryの連携をする場合、まずはFirebaseをBlaze(従量課金)プランに上げる必要があります。
ただ、実際試したところSparkプランでもある程度動かす事ができました。
動かした感じ、無料プランだと制限付きBigQuery(サンドボックス)が適用されるのだと思います。

Firebaseの料金詳細は下URLの通りです。

firebase.google.com

BigQuery側の料金は下のとおりです。
ストレージ容量、クエリの容量などに応じた従量課金で一定まで無料で使う事ができます。

cloud.google.com

FirebaseをBlazeプランに変更

Blazeプランへの変更はFirebaseのプロジェクト画面左下の「アップグレード」ボタンから行う事ができます。

f:id:llcc:20191127144703p:plain

BigQueryとFirebaseの連携

連携はプロジェクト設定の「統合」から行う事ができます。

f:id:llcc:20191127144811p:plain

連携が完了すると下のようにBigQueryにプロジェクト、データセット、テーブルが作られます。
データの反映は半日 ~ 1日くらいかかる事があります。

f:id:llcc:20191127145627p:plain

BigQueryで新規ユーザー数を見る

まずは下のようなSQLでAnalyticsデータセットのテーブル一覧を取得します。
analytics_xxxxxxは自分の環境に置き換えて下さい。

SELECT * FROM analytics_xxxxxx.__TABLES__;

実行すると下のようにテーブル一覧が表示されます。

f:id:llcc:20191127150726p:plain

特定のテーブルのデータは下のようなSQLで取得する事ができます。

SELECT * FROM analytics_xxxxxx.events_20191126;

もし11月26日の新規ユーザー数を取りたい場合は下のようなSQLで取得する事ができます。

SELECT count(*) FROM analytics_xxxxxx.events_20191126 where event_name = "first_open";

複数日のデータをまとめて取りたい場合はテーブル名にワイルドカードを入れる事で実現できます。

SELECT event_date, count(*) FROM `analytics_xxxxxx.events_*` where event_name = "first_open" group by event_date;

データ容量ですがユーザーがほとんどいないプロジェクトでは1日20MB程でした。

f:id:llcc:20191129113832p:plain

ユーザー数も多くてイベントを大量に送っているプロジェクトでは2GB程ありました。

f:id:llcc:20191129113858p:plain

iOSでアプリ内課金したのに反映されないという問い合わせが来た時の対応策

タイトルのような問題が起きたので、その原因と対策について調べてみました。

起こったこと

  • iOSでアプリ内課金したのに反映されない
  • もう一度購入しようとすると、「このアイテムは購入済みです。無料で再入手しますか?」と出て来るが、「OK」を押しても反映されない
  • リストアをしようとしても、購入済みアイテムが見つからないというエラーになる

原因

課金処理途中で離脱するとこのような現象が発生します。 具体的には、購入途中でクレカ情報入力するなどしてアプリ外で購入&アプリのプロセス終了した時に発生します。

システム的には、上のようなフローをたどる事でアプリ上でSKPaymentTransactionが終了せずに残ってしまい、それが悪さをしていました。

再現方法

開発環境では下のようなフローで再現する事ができます。 1. アプリ内アイテムの購入をする 2. パスワード入力画面などで、Xcodeからアプリを終了する 3. そのままアプリ内課金を最後まで実施する

上フローの後にトランザクションの状態を見ると、たしかに1件未終了のトランザクションが残っていました。

print(SKPaymentQueue.default().transactions.count) // → 1

対策

購入画面のviewDidLoadに下処理を行う事で解決しました。 未終了トランザクションが残っていたらトランザクションを終了します。

SKPaymentQueue.default().transactions.forEach {
    if $0.transactionState != .purchasing {
        SKPaymentQueue.default().finishTransaction($0)
    }
}

最初はAppDelegateのdidFinishLaunchingWithOptionsでやろうと思ったのですが、「SKPaymentQueue.default().transactions」が常に0件になってしまったので、購入画面で実施しました。

参考URL

gosyuin-map.seesaa.net

docker runで起動したコンテナを削除する

docker runで生成したコンテナの削除方法です。

まずは下コマンドでコンテナ一覧を表示します。
-aオプションを付けることで起動中でないコンテナも表示する事ができます。

docker ps -a

下のようにコンテナ一覧が出てきます。

f:id:llcc:20181103222502p:plain

最後にコンテナIDを指定してrmコマンドを打てば削除する事ができます。

docker rm CONTAINER_ID

下コマンドでコンテナを一括削除することもできます。

docker rm `docker ps -aq`