SwiftUIのEnvironment/EnvironmentObjectとはなにか
SwiftUIのEnvironment / EnvironmentObjectという記法について調べてみました。
EnvironmentObjectとは
EnvironmentObjectとはObservableObjectを外部から渡したりできるようになる記法です。
宣言は下のように行います。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { Text("Hello") } } class MyViewModel: ObservableObject { }
プロパティーの型はObservableObjectプロトコルに準拠している必要があります。
また、通常のプロパティーと違って初期値をセットする必要はありません。
@EnvironmentObject var testValue: MyViewModel
EnvironmentObjectの値は、下のようにenvironmentObjectメソッドで外部から注入することができます。
let contentView = ContentView().environmentObject(MyViewModel())
通常のプロパティー同様、値を以下のように取得することができます。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { print(testValue) return Text("Hello") } }
MyViewModelのプロパティーを2つ宣言すると、両方に同じ値がセットされます。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel @EnvironmentObject var testValue2: MyViewModel var body: some View { print(testValue === testValue2) // → true return Text("Hello") } }
environmentObjectでセットした値は、子ビューにも自動でセットされます。
struct ContentView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { ChildView() } } struct ChildView: View { @EnvironmentObject var testValue: MyViewModel var body: some View { print(testValue) // 値が入っている return Text("Hello, World!") } }
Environmentとは
EnvironmentもEnvironmentObjectと似て、外部から値をセットできるようになるクラスです。
下のようにenvironmentメソッドを使って値をセットできます。
let contentView = ContentView().environment(\.font, Font.system(size: 100))
ここでセットできるのは、fontなどEnvironmentValuesのプロパティーとして宣言されているものだけです。
詳細は以下URLに参考にしてください。
https://developer.apple.com/documentation/swiftui/environmentvalues
フォントをセットした場合、以下のようにTextのデフォルトフォントサイズが変更されます。
セットした値にアクセスするためには、以下のようにEnvironmentを使ってプロパティーを宣言する必要があります。
struct ContentView: View { @Environment(\.font) var myFont: Font? var body: some View { print(myFont) return Text("Hello") } }
参考URL
SwiftUIの機能 @State, @ObservedObject, @EnvironmentObjectの違いとは| 開発者ブログ | 株式会社アイソルート
Understanding Property Wrappers in SwiftUI | Majid’s blog about Swift development
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 MyProtocol
はMyClass
とほぼパフォーマンスを出していることがわかります。
戻り値の型 | 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の値を使用しました。
以下のように実行速度についても調べたのですが、こちらは大きな差はありませんでした。
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の作成を行います。
Today Extensionを選択します。
作成するとToday Extension関連ファイルが作られます。
Today Extensionを起動するとHello worldが表示されます。
Storyboardでテキストを変更する事で文字を変える事ができます。
ラベルはUIViewControllerと紐付ける事で動的な変更もできます。
class TodayViewController: UIViewController, NCWidgetProviding { @IBOutlet weak var myLabel: UILabel! override func viewDidLoad() { super.viewDidLoad() myLabel.text = "Hello!" } }
ラベルだけでなくボタンも配置する事ができます。
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側にも追加が必要です。
あとは作成したグループを使ってデータ作成すればデータ共有できるようになっています。
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
への追加などが必要です。
CocoaPodsを使う場合、Extension用の処理も必要があるので注意が必要です。
target 'MyApp' do use_frameworks! pod 'RealmSwift' end target 'MyExtension' do use_frameworks! pod 'RealmSwift' end
SwiftUIを触ってみる
SwiftUIで簡単なUIを作ってみました。
まずはXcode11で新規プロジェクトを作成します。
テンプレートは「Single View App」を選びました。
プロジェクト構成は以下のようになっています。
Xcode11からはSceneDelegate.swiftというファイルが追加されています。 これはiPadで画面に複数アプリ表示する際に使うクラスで、今までのAppDelegateの役割の一部を担っています。 今回はSwiftUIを中心に見たいので深堀りしないことにします。
まずはアプリを立ち上げてみます。 アプリを起動すると下のようにHello, World!と表示されます。
その下にラベルを1つ追加してみたいと思います。 ContentViewクラスを以下のように修正します。 2つ縦に並べるのはVStackというクラスを使っています。
struct ContentView: View { var body: some View { VStack { Text("Hello, World!") Text("Goodnight, World!") } } }
アプリを起動するとラベルが2つ表示されています。
次にラベル間を少し空けてみます。 VStackの初期化時にspacingを渡します。
struct ContentView: View { var body: some View { VStack(spacing: 200) { Text("Hello, World!") Text("Goodnight, World!") } } }
これで間隔を200pxにする事ができました。
ラベルと色やフォントも変更してみます。
struct ContentView: View { var body: some View { Text("Hello, World!").font(Font.system(size: 50)).foregroundColor(Color(red: 1, green: 1, blue: 0)) } }
少し見づらいですが黄色の大きめの文字に変わりました。
次はボタンの追加とタップ時のアクション追加を行います。
struct ContentView: View { var body: some View { Button("ボタン") { print(10) } } }
ボタンをタップするとデバッグエリアに10が表示されます。
次はボタンタップ時にアラートも表示してみます。
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")) } } }
アラートを表示する事ができました。
この.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" } } } }
ボタンを押すとテキストを変更する事ができました。
以下のように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) } } }
画面遷移は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のデータをデータポータルというツールを使ってグラフ化してみようと思います。
データポータルとは
データポータルとはGoogleの提供しているデータをビジュアライズ化するツールです。 今回はBigQueryのデータをグラフ化するのですが、BigQuery以外にGoogleアナリティクス・Googleスプレットシート・MySQLのデータなど色々なデータを取り込んで表示する事ができます。
取り込んだデータは下のように様々な形で表示する事ができます。
BigQueryで取り込んだデータを表示する
データポータルのページに移動してから、「空のレポート」を選んでレポート作成します。
レポートを作ったら右下の「新しいデータソースを作成」ボタンからデータソースを作ります。
ボタンを押すとデータ取得元を選ぶ画面に移動します。 今回はBigQueryを選択します。
選択と下のようにデータセット選択ページに飛ぶのでプロジェクトのAnalyticsのデータを選択します。 events_intraday_YYYYMMDDは直近のデータのみなので、今回はevents_YYYYMMDDを選択しました。
フィールドを選ぶ画面が出るんですがここは何も変更せず保存します。
それが終わると画面にイベント数の入ったテーブルが表示されます。 今回は新規ユーザー数を表示したいのでこれは削除します。
削除してから「グラフを追加」で時系列グラフを追加します。
デフォルトだと全イベントの数が入っているので、新規ユーザー数のみに絞ります。 右側の「フィルタを追加」ボタンから、新規登録数を表示するためのフィルターを作成します。
フィルターはEvent Nameがfirst_open(初めてアプリ立ち上げ)のイベントで絞り込みます。
以上で設定は完了です。 2日分しかデータ入れてないのですが、無事に新規ユーザー数を表示する事ができました。
FirebaseのデータをBigQueryに入れて分析する
Firebase単体だと見づらいデータが多いのでBigQueryに入れて分析してみました。
今回はFirebaseとBigQueryを連携して新規ユーザー数を取得するところまでやります。
ドキュメントは下URLになります。
Firebase 向け Google アナリティクスのデータを BigQuery にインポート | ソリューション | Google Cloud
BigQueryの料金
FirebaseとBigQueryの連携をする場合、まずはFirebaseをBlaze(従量課金)プランに上げる必要があります。
ただ、実際試したところSparkプランでもある程度動かす事ができました。
動かした感じ、無料プランだと制限付きBigQuery(サンドボックス)が適用されるのだと思います。
Firebaseの料金詳細は下URLの通りです。
BigQuery側の料金は下のとおりです。
ストレージ容量、クエリの容量などに応じた従量課金で一定まで無料で使う事ができます。
FirebaseをBlazeプランに変更
Blazeプランへの変更はFirebaseのプロジェクト画面左下の「アップグレード」ボタンから行う事ができます。
BigQueryとFirebaseの連携
連携はプロジェクト設定の「統合」から行う事ができます。
連携が完了すると下のようにBigQueryにプロジェクト、データセット、テーブルが作られます。
データの反映は半日 ~ 1日くらいかかる事があります。
BigQueryで新規ユーザー数を見る
まずは下のようなSQLでAnalyticsデータセットのテーブル一覧を取得します。
analytics_xxxxxx
は自分の環境に置き換えて下さい。
SELECT * FROM analytics_xxxxxx.__TABLES__;
実行すると下のようにテーブル一覧が表示されます。
特定のテーブルのデータは下のような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程でした。
ユーザー数も多くてイベントを大量に送っているプロジェクトでは2GB程ありました。
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件になってしまったので、購入画面で実施しました。