SwiftUI で SKStoreProductViewController を使って AppStore を表示する
SKStoreProductViewController を使うと、アプリから離脱せずに、AppStore で特定のアプリを表示することができる。これを使うと、アプリ内で他のアプリのインストールを促すことに使えたりする。
ただし、SKStoreProductViewController に delegate
を設定するとき、SwiftUI ではそのままでは使えないので、UIViewControllerRepresentable
を使ったパータンを考えてみる。(delegate を設定しない場合は、UIViewControllerRepresentable は不要で、そのまま使える)
まず、SKStoreProductViewController
で AppStore を開く機能を持った ViewController を用意する。
import UIKit import SwiftUI import StoreKit class StoreProductViewController: UIViewController { private var isPresentStoreProduct: Binding<Bool> private let appId: Int init(isPresentStoreProduct: Binding<Bool>, appId: Int) { self.isPresentStoreProduct = isPresentStoreProduct self.appId = appId super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) } func presentStoreProduct() { let storeProductViewController = SKStoreProductViewController() storeProductViewController.delegate = self let parameters = [SKStoreProductParameterITunesItemIdentifier: self.appId] storeProductViewController.loadProduct(withParameters: parameters) { status, error -> Void in if status { self.present(storeProductViewController, animated: true, completion: nil) } else { if let error = error { print("Error: \(error.localizedDescription)") } } } DispatchQueue.main.async { self.isPresentStoreProduct.wrappedValue = false } } } // MARK: - SKStoreProductViewControllerDelegate extension StoreProductViewController: SKStoreProductViewControllerDelegate { func productViewControllerDidFinish(_ viewController: SKStoreProductViewController) { viewController.presentingViewController?.dismiss(animated: true, completion: nil) } }
上記の ViewController の UIViewControllerRepresentable
を用意する。
import SwiftUI struct StoreProductViewControllerRepresentable: UIViewControllerRepresentable { typealias UIViewControllerType = StoreProductViewController var isPresentStoreProduct: Binding<Bool> let appId: Int func makeUIViewController(context: Context) -> UIViewControllerType { let viewController = StoreProductViewController(isPresentStoreProduct: isPresentStoreProduct, appId: appId) return viewController } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { if self.isPresentStoreProduct.wrappedValue { uiViewController.presentStoreProduct() } } }
作成した StoreProductViewControllerRepresentable
を使った SwiftUI の View が以下になる。
import SwiftUI struct ContentView: View { @State private var isPresentStoreProduct: Bool = false var body: some View { VStack { Button { self.isPresentStoreProduct = true } label: { Text("App Store Link") } StoreProductViewControllerRepresentable(isPresentStoreProduct: self.$isPresentStoreProduct, appId: 284602850) .frame(width: 0, height: 0) } } }
処理の流れはこうなる。
SwiftUI, UIViewControllerRepresentable, UIViewController 間で、Bool のフラグ isPresentStoreProduct
を Binding しておいて
- SwiftUI 側で
isPresentStoreProduct
を true にする。 - UIViewControllerRepresentable で
isPresentStoreProduct
が true になったら ViewController の AppStore を開くメソッドを実行する。 - UIViewController で、AppStore を開いた後、
isPresentStoreProduct
を false に戻す。- このとき、即時に false にすると UIViewControllerRepresentable 側で連続してイベントが発生してしまうため、 非同期に
DispatchQueue.main.async
で false にする。
- このとき、即時に false にすると UIViewControllerRepresentable 側で連続してイベントが発生してしまうため、 非同期に
動作させるとこんな感じになる。
ちなみに id 284602850 のアプリは、 Texas Hold’em である。
コードは GitHub にもあります。
https://github.com/daisuke-t-jp/SwiftUIPresentAppStoreProduct
Swift Dictionary の key, value を weak 参照したい
たとえば
UIView
を key にして、Date
を value にした Dictionary
を使いたいと考えたとき。
ここで問題があるのは Dictionary の key, value は strong
で参照するため、 UIView を key にするとメモリリークが発生する。(本来の UView のライフサイクルが終了しても、解放されない)
この対応策が以下。
2つ方法を書いたが、他にもあると思う。
1. NSMapTable を使用する
NSMapTable
は Dictionary と似ているが、key と value の参照を weak, strong からそれぞれ選ぶことができる。
https://developer.apple.com/documentation/foundation/nsmaptable
key を weak, value を strong にすると以下のコードになる。
let table: NSMapTable = NSMapTable<UIView, NSDate>(keyOptions: .weakMemory, valueOptions: .strongMemory) autoreleasepool { let view1 = UIView() table.setObject(Date() as NSDate, forKey: view1) } let view2 = UIView() table.setObject(Date() as NSDate, forKey: view2) let view3 = UIView() table.setObject(Date() as NSDate, forKey: view3) // 追加した要素は3個だが、うち1つは autoreleasepool ブロック内でライフサイクルが終わっている(=weak 参照の NSMapTable の要素も消える)ので、count は 2 となる。 print(table.count)
2. NSCache を使用する
要素の数が限られていて良い。という場合は、NSCache
を使う方法も取れる。
その場合、UIView をそのままキーにするのではなく ObjectIdentifier
を key にする。
https://developer.apple.com/documentation/swift/objectidentifier
ObjectIdentifier を key にすることで、UIView の参照カウントを増やさずに、UIView を一意なキーとして Dictionary で扱える。
コードはこんな感じ。
let cache = NSCache<AnyObject, NSDate>() cache.countLimit = 2 let view1 = UIView() cache.setObject(Date() as NSDate, forKey: ObjectIdentifier(view1) as AnyObject) let view2 = UIView() cache.setObject(Date() as NSDate, forKey: ObjectIdentifier(view2) as AnyObject) let view3 = UIView() cache.setObject(Date() as NSDate, forKey: ObjectIdentifier(view3) as AnyObject) // キャッシュ最大個数(2)を超えているので、一番最初に追加した要素はなくなっている print("view1 -> \(cache.object(forKey: ObjectIdentifier(view1) as AnyObject))") // view1 -> nil print("view2 -> \(cache.object(forKey: ObjectIdentifier(view2) as AnyObject))") // view2 -> Optional(2022-01-09 06:12:40 +0000) print("view3 -> \(cache.object(forKey: ObjectIdentifier(view3) as AnyObject))") // view3 -> Optional(2022-01-09 06:12:40 +0000)
Swift プロジェクトの テスト CI を Travis CI から GitHub Actions へ移行
Travis CI を使って GitHub のプロジェクトに push した時に、自動でテストが実行されるようになったいたのを GitHub Actions に変えてみた。
GitHub actions の yaml の書き方や、Travis CI からの移行については以下のドキュメントが参考になる。
実施した手順のメモ。
1. プロジェクトのルートに .github
フォルダを作成
2. .github
フォルダ内に workflows
フォルダを作成
3. workflows
に yml ファイルを作成し、そこにテストの内容を記述していく。
たとえば、こんな感じ。
name: ci on: push: branches: - master pull_request: branches: - '*' jobs: testing_macos: runs-on: macos-11 steps: - uses: actions/checkout@v1 - name: "Xcode13 iOS" run: xcodebuild clean test -project Test.xcodeproj -scheme Test-iOS -sdk iphonesimulator -destination "platform=iOS Simulator,OS=15.0,name=iPhone 8" -configuration Debug
4. ブランチを新たに作り、今までの作業内容をコミットして、プルリクエストする。
on: push: branches: - master pull_request: branches: - '*'
この部分の記述で pull request した時も GitHub アクションが実施されるようにしているので、このプルリクエストのタイミングで実際にテストが開始されるのを確認できる。
5.プルリクエストのテストが成功したら、main ブランチにマージする。
GitHub のページから UI でワークフロー・GitHub アクションも作れるのだけど、上のような感じで UI を介さずに、作ることもできる。
実際 Travis CI と GitHub Actions だとどう yaml が変わったというと、、、
Travis CI の時(macOS 上で iOS, macOS, tvOS のテスト+Linux 上のテスト)
branches: only: - master matrix: include: - os: osx language: swift osx_image: xcode12 script: # iOS - xcodebuild clean -project "MurmurHash.xcodeproj" - xcodebuild test -project "MurmurHash.xcodeproj" -scheme "MurmurHash-iOS" -sdk iphonesimulator -destination "platform=iOS Simulator,name=iPhone 11 Pro Max" -configuration Debug # macOS - xcodebuild clean -project "MurmurHash.xcodeproj" - xcodebuild test -project "MurmurHash.xcodeproj" -scheme "MurmurHash-macOS" -destination "platform=OS X" -configuration Debug # tvOS - xcodebuild clean -project "MurmurHash.xcodeproj" - xcodebuild test -project "MurmurHash.xcodeproj" -scheme "MurmurHash-tvOS" -sdk appletvsimulator -destination "OS=12.2,name=Apple TV 4K" -configuration Debug - os: linux language: generic dist: xenial sudo: false addons: apt: packages: - wget # Ubuntu needs following packages to build Swift. # # Reference: # https://github.com/apple/swift#System-Requirements - git - cmake - ninja-build - clang - python - uuid-dev - libicu-dev - icu-devtools - libbsd-dev - libedit-dev - libxml2-dev - libsqlite3-dev - swig - libpython-dev - libncurses5-dev - pkg-config - libblocksruntime-dev - libcurl4-openssl-dev - systemtap-sdt-dev - tzdata - rsync script: - wget https://swift.org/builds/swift-5.3-release/ubuntu1604/swift-5.3-RELEASE/swift-5.3-RELEASE-ubuntu16.04.tar.gz - tar xvfz swift-5.3-RELEASE-ubuntu16.04.tar.gz - export PATH=$(pwd)/swift-5.3-RELEASE-ubuntu16.04/usr/bin:"${PATH}" - swift build - swift test
GitHub Actions の時(テストの内容は、Travis CI と同じ。Xcode や OS のバージョンは上げている)
name: ci on: push: branches: - master pull_request: branches: - '*' jobs: testing_macos: runs-on: macos-11 steps: - uses: actions/checkout@v1 - name: "Xcode13 iOS" run: xcodebuild clean test -project MurmurHash.xcodeproj -scheme MurmurHash-iOS -sdk iphonesimulator -destination "platform=iOS Simulator,OS=15.0,name=iPhone 8" -configuration Debug env: DEVELOPER_DIR: /Applications/Xcode_13.0.app/Contents/Developer - name: "Xcode13 macOS" run: xcodebuild clean test -project MurmurHash.xcodeproj -scheme MurmurHash-macOS -destination "platform=OS X" -configuration Debug env: DEVELOPER_DIR: /Applications/Xcode_13.0.app/Contents/Developer - name: "Xcode13 tvOS" run: xcodebuild clean test -project MurmurHash.xcodeproj -scheme MurmurHash-tvOS -sdk appletvsimulator -destination "platform=tvOS Simulator,OS=15.0,name=Apple TV" -configuration Debug env: DEVELOPER_DIR: /Applications/Xcode_13.0.app/Contents/Developer testing_linux: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v1 - name: "Linux" run: swift test
となった。
まあ、そこまで変わらない感じでかけた。(使っていないけど、GitHub actions でも matrix を書けるらしい)
あと、Travis CI の時は、Linux で Swift 実行環境を作るためにパッケージインストールをしていたのが、GitHub だと最初から入っているので、楽。
GitHub で使える OS 環境は以下を見た。
ちなみに GitHub アクションでも、ビルドステータスのバッチがつけれます。
マルエツのレシート応募キャンペーンの最大口数を知りたい
スーパーのマルエツのキャンペーンでは、レシート3000円で1口応募できる。(レシート合算可) https://www.ichance.jp/cp/maruetsu-dreamwinter/
たとえば
以下のレシートがあった場合は
- 600
- 1000
- 2000
- 2500
以下のようにまとめることで「2口応募」できる。
- 1口目 600, 2500
- 2口目 1000, 2000
以下のレシートがあった場合はどうだろう。
- 1676
- 843
- 1000
- 233
- 1731
- 321
- 179
- 259
- 460
- 1594
- 3544
- 632
- 2172
- 2623
- 1102
- 84
- 466
- 306
- 855
- 3255
- 1609
- 1345
- 1170
- 4220
- 6231
- 180
- 91
- 2937
- 544
- 1577
- 421
- 3899
- 585
- 671
- 1811
- 875
- 384
- 950
- 1618
これは、パッとわからない。
パッとわからないので、テキトーに組み合わせて出来た結果が「10口」だとする。
もうすこし組み合わせを頑張れば「11口」や「12口」になったりするかもしれない。
しかし、テキトーに手作業だとだいぶ疲れるし、無意味なような気がするので、以下のアルゴリズムを考えた。
計算1
- レシートから1枚ランダムに選んで、テーブルに置く
- テーブルの上にあるレシートを見る
- レシートの合計が3000円以上なら、コップを用意してそこにテーブルの上のレシートを入れる。1. を実行する
- レシートの合計が3000円未満なら、1. を実行する
- と 2. をレシートを取り尽くすまで実行する。
- レシートを取り尽くしたら、出来たコップ(3000円以上のレシートが入ったコップ)の数を記録する
計算1をN回繰り返し、出来たコップの数の記録を見る。
最も多い「コップの数」が、乱択の結果、確率的に分かった応募できる最大の口数。(=必ずしも正解とは言えない)
上のコードを swift で書いた。(Nは100万回)
import Foundation let passValue: Int = 3000 // 基準値 let tryMax: Int = 1000000 // 試行回数 // 入力 let inputValues: [Int] = [ 1676, 843, 1000, 233, 1731, 321, 179, 259, 460, 1594, 3544, 632, 2172, 2623, 1102, 84, 466, 306, 855, 3255, 1609, 1345, 1170, 4220, 6231, 180, 91, 2937, 544, 1577, 421, 3899, 585, 671, 1811, 875, 384, 950, 1618 ] // あらかじめ基準値以上のグループを作成する var passedGroups: [[Int]] = [] // すでに基準値以上のグループ var inputValues2: [Int] = [] for value in inputValues { if value >= passValue { passedGroups.append([value]) continue } inputValues2.append(value) } var groupNumMap: [Int: Int] = [:] // グループ数の出現頻度のマップ var bestGroups: [[Int]] = [] // 最適なグループ配列 var lastPercent: Int = 0 // print("- count \(inputValues2.count)") // print("- sum \(inputValues2.reduce(0, +))") // print("") // ランダムに基準値を超えるグループを作成する for i in 0..<tryMax { // 一時変数を準備する var tempGroups: [[Int]] = [] // 一時的なグループの配列 var tempValues: [Int] = inputValues2 // 一時的な値の配列 var tempGroup: [Int] = [] // 一時的なグループ // ランダムに基準値以上のグループの配列を作成する while true { // ランダムなインデックスを得る let index: Int = Int.random(in: 0..<tempValues.count) let value: Int = tempValues[index] tempValues.remove(at: index) // 一時的なグループに値を追加する tempGroup.append(value) let reduceValue: Int = tempGroup.reduce(0, +) if reduceValue >= passValue { // 一時的なグループの合計が基準値以上の場合 // -> グループを、一時的なグループ配列に追加する tempGroups.append(tempGroup) tempGroup = [] // 一時的なグループをクリア } if tempValues.count > 0 { continue } // 値がなくなったので試行終了 break } // グループ数の出現カウントを控える let groupsCount: Int = tempGroups.count + passedGroups.count groupNumMap[groupsCount] = (groupNumMap[groupsCount] ?? 0) + 1 if tempGroups.count > bestGroups.count { // 最適なグループ数を超えたら、最適なグループを更新する bestGroups = tempGroups } let percent: Int = Int((Float(i) / Float(tryMax)) * 100) if percent > lastPercent { lastPercent = percent print("\(percent)%") } } // 計算の結果得た最適なグループ配列に、あらかじめ作成したすでに基準値以上のグループを合成する bestGroups.insert(contentsOf: passedGroups, at: 0) print("\n\n") // 結果を出力 print("# Result\n") // 最適なグループ配列を出力 print("## Best groups") print("group numbers : \(bestGroups.count)") print("") for i in 0..<bestGroups.count { print("### Group\(i + 1)") for j in 0..<bestGroups[i].count { print("- \(bestGroups[i][j])") } print("") } // グループ数の出現頻度 print("## Frequency of group numbers\n") let sortedKeys: [Int] = groupNumMap.keys.sorted() for key in sortedKeys { print("### \(key)") print("- count \(groupNumMap[key] ?? 0)") let percent: Float = (Float(groupNumMap[key] ?? 0) / Float(tryMax)) * 100 print("- percent \(percent)%") }
これを実行すると、結果の出力は以下。
15口応募できるようだ。
全体のうち、口数の出現頻度が
- 12口が 0.7%
- 13口が 45%
- 14口が 52%
- 15口が 1%
となる。
# Result ## Best groups group numbers : 15 ### Group1 - 3544 ### Group2 - 3255 ### Group3 - 4220 ### Group4 - 6231 ### Group5 - 3899 ### Group6 - 2623 - 1102 ### Group7 - 306 - 855 - 259 - 1609 ### Group8 - 233 - 1594 - 544 - 180 - 632 ### Group9 - 1676 - 1345 ### Group10 - 466 - 2937 ### Group11 - 950 - 460 - 321 - 1731 ### Group12 - 1577 - 1000 - 585 ### Group13 - 84 - 1170 - 2172 ### Group14 - 91 - 1618 - 384 - 671 - 843 ### Group15 - 179 - 1811 - 421 - 875 ## Frequency of group numbers ### 12 - count 7584 - percent 0.7584% ### 13 - count 453582 - percent 45.3582% ### 14 - count 528201 - percent 52.8201% ### 15 - count 10633 - percent 1.0633%
青森県のオープンデータを使った「公衆 Wi-Fi スポット」を探せる iOS アプリを作った
青森県のオープンデータは「青い森オープンデータカタログ」にある。
オープンデータで作成されたものはサイト内の「アプリマーケット」というところに登録できるが、みた感じスマホアプリがなく、
アプリマーケット - 青い森オープンデータカタログAoi Mori Open Data Catalog
「活用事例」を見ても個人でスマホアプリ作っている感じがなかったので、今回試しに作ってみた。
活用事例 - 青い森オープンデータカタログAoi Mori Open Data Catalog
アプリの元になったデータはこの「青森県公衆無線LANアクセスポイント一覧」
青森県公衆無線LANアクセスポイント一覧 - 青い森オープンデータカタログAoi Mori Open Data Catalog
データは CSV であるが、実際にはこれをそのままアプリで使うのではなく、アプリで使いやすいように Python で加工して、json ファイルにしたものをアプリで使用。
たとえば、まったく同じ内容の施設が重複していたりするので、それをひとつにしたりとか、ひとつの施設に複数の Wi-Fi スポットがあったりするのでまとめたりとか。。
そんな感じで作りましたよ。
あ、、青森県が出身地です。
今回 iOS 15 以降がターゲットのアプリにしたのだけど
iOS 15 だと属性付き文字列を NSAttributedString
ではなく新しい AttributedString
を使って作れるので、以前より直感的に作れる気がする。
たとえばこんな表示をしたければ、AttributedString
はこう作る。
var text1 = AttributedString("これは赤く小さい ") text1.foregroundColor = .red text1.font = .systemFont(ofSize: 16) var text2 = AttributedString("これは青く大きい ") text2.foregroundColor = .blue text2.font = .boldSystemFont(ofSize: 20) label.attributedText = NSAttributedString(text1 + text2)
iOS の Google AdMob のテスト広告が出なくなってしまった時の対応
ここに書いてある「デモ広告」のユニット ID を使って、テスト広告を表示させていたのだが、いつの間にか出なくなっていた。
実行時にこんなログが出る。
<Google> Cannot find an ad network adapter with the name(s): com.google.DummyAdapter. Remember to link all required ad network adapters and SDKs, and set -ObjC in the 'Other Linker Flags' setting of your build target.
ドキュメントをよく見たら
注意:アプリで app-ads.txt ファイルを設定している場合は、デモ広告ユニットを使って広告を読み込むために、次の行を app-ads.txt ファイルに含める必要があります。
と記載があったので、 app-ads.txt にコードを追加する。
... 既存の記載 ... google.com, pub-3940256099942544, DIRECT, f08c47fec0942fa0
そうして数時間経ったら、前のように「デモ広告」が表示されるようになった。 以前はドキュメントにデモ用の app-ads.txt の記載あったかなぁ・・・
ちなみに、↑とは別に「テストデバイス」設定をする方法でも、デモ広告は表示される。
SwiftUI で下から出てくる Picker を作ってみる
UIKit の場合だと UITextField の inputView に UIPicker を設定して、UITextField をタッチすると下からニュッと Picker が出てくる。ができる。
それを SwiftUI でやろうとすると、適当なものが用意されていなかったので、作ってみた。
こんな感じ。
コードは GitHub にあります。