iOS 15 から使える GCVirtualController のメモ

GCVirtualControlleriOS 15 から使用できるソフトウェアゲームコントローラー。
これを使うことで、アプリで自作のゲームコントローラーを作成しなくてよい、というメリットがある。

GCVirtualController - Apple Developer

アプリを作る際に GCVirtualController を触ってみて、わかったことを以下にまとめる。

バーチャルコントローラーを作成する

コントローラー作成の簡単なコードが以下になる。

import UIKit
import GameController

class ViewController: UIViewController {

    private var virtualController: GCVirtualController!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let config = GCVirtualController.Configuration()
        config.elements = [
            GCInputDirectionPad,
            GCInputButtonA,
            GCInputButtonB,
        ]
        self.virtualController = GCVirtualController(configuration: config)
        
        self.virtualController.connect { error in
            if let error = error {
                print(error)
            }
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        self.virtualController.disconnect()
    }

}

これを実行すると、このようにボタンが表示される。

f:id:daisuke-t-jp:20220302205010p:plain

使えるボタンの種類はここに書いてあり、 https://developer.apple.com/documentation/gamecontroller/gcvirtualcontroller/configuration/3752038-elements

以下が使えるボタンのようだ。

  • GCInputButtonA
  • GCInputButtonB
  • GCInputButtonX
  • GCInputButtonY
  • GCInputDirectionPad
  • GCInputLeftThumbstick
  • GCInputRightThumbstick
  • GCInputLeftShoulder
  • GCInputRightShoulder
  • GCInputLeftTrigger
  • GCInputRightTrigger

ボタンの入力を知りたい

以下のようにボタンに valueChangedHandler を設定することで、「ボタンが押された」を取得することができる。

        if let dpad: GCControllerDirectionPad = self.virtualController.controller?.extendedGamepad?.dpad {
            dpad.valueChangedHandler = { (dpad: GCControllerDirectionPad, xValue: Float, yValue: Float) in
                if dpad.up.isPressed {
                    print("↑")
                }
                if dpad.down.isPressed {
                    print("↓")
                }
                if dpad.left.isPressed {
                    print("←")
                }
                if dpad.right.isPressed {
                    print("→")
                }
            }
        }
        
        if let buttonA: GCControllerButtonInput = self.virtualController.controller?.extendedGamepad?.buttonA {
            buttonA.valueChangedHandler = { (button: GCControllerButtonInput, value: Float, pressed: Bool) in
                if buttonA.isPressed {
                    print("A")
                }
            }
        }
        
        if let buttonB: GCControllerButtonInput = self.virtualController.controller?.extendedGamepad?.buttonB {
            buttonB.valueChangedHandler = { (button: GCControllerButtonInput, value: Float, pressed: Bool) in
                if buttonB.isPressed {
                    print("B")
                }
            }
        }

valueChangedHandler は「ボタンが押された」だけでなく「ボタンが離された」タイミングでも呼ばれるため、どのボタンが離されたかも判断できる。

また valueChangedHandler を使わずに Timer などで周定期で各ボタンの isPressed を確認してボタン状態を得ることもできる。
(たとえば、「Nフレームボタンが押されたら」という処理がしたい場合は、周定期でボタン状態を監視することになる)

ボタンの見た目・位置は変えれるか?

ボタンの要素は ElemntConfiguration で変更できる。

これを見ると

  • ボタンの位置は変えられない。
  • ボタンの位置、大きさはそのままに、中の表示を UIBezierPath で変更することはできる。

ボタンの見た目の変更は、たとえば以下のコードで

        self.virtualController.updateConfiguration(forElement: GCInputButtonA, configuration: { _ in
            let elementConfiguration: GCVirtualController.ElementConfiguration = GCVirtualController.ElementConfiguration()
            
            elementConfiguration.path = UIBezierPath()
            
            let bezierPath: UIBezierPath = UIBezierPath()
            bezierPath.move(to: CGPoint(x: 0, y: 0))
            bezierPath.addLine(to: CGPoint(x: -10, y: -10))
            bezierPath.addLine(to: CGPoint(x: 10, y: -10))
            bezierPath.addLine(to: CGPoint(x: 10, y: 10))
            bezierPath.addLine(to: CGPoint(x: -10, y: 10))
            bezierPath.close()
            
            elementConfiguration.path?.append(bezierPath)

            return elementConfiguration
        })

このように A ボタンの見た目が、パックマンに変わる。

f:id:daisuke-t-jp:20220302205331p:plain

各ボタンは途中で非表示にできるか?

ElementConfiguration の isHidden で非表示にすることができる。

たとえば、 A ボタンを押したら B ボタンを非表示にするコードは以下だ。

        if let buttonA: GCControllerButtonInput = self.virtualController.controller?.extendedGamepad?.buttonA {
            buttonA.valueChangedHandler = { (button: GCControllerButtonInput, value: Float, pressed: Bool) in
                if buttonA.isPressed {
                    self.virtualController.updateConfiguration(forElement: GCInputButtonB, configuration: { _ in
                        let elementConfiguration: GCVirtualController.ElementConfiguration = GCVirtualController.ElementConfiguration()
                        elementConfiguration.isHidden = true
                        return elementConfiguration
                    })
                }
            }
        }

B ボタンが非表示になると見た目がこうなる。

f:id:daisuke-t-jp:20220302205335p:plain

ちなみに最初から B ボタンがない(GCVirtualController の init(configuration:) の時点で B ボタンの要素を含めない)場合は、見た目がこうなる。

f:id:daisuke-t-jp:20220302205342p:plain

上の2つのボタン配置位置が違うことから

  • 最初からボタンがない場合
  • 途中でボタンを非表示にした場合

はそれぞれ配置が異なることがわかるので、注意したい。
(途中でボタンを非表示にしても、その分、配置が詰められる訳ではない)

もし、ボタンを非表示にして、かつボタン配置も最初からボタンがないものと同じにしたい場合は、GCVirtualController init(configuration:) でバーチャルコントローラー自体を作り直せば良い。

おわり

↑のようにいろいろ GCVirtualController を調べて作ったゲームがあったりしますよ。

HARVEST - CATCH THE FRUIT -

HARVEST - CATCH THE FRUIT -

  • Daisuke Tonosaki
  • Games
  • Free

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 しておいて

  1. SwiftUI 側で isPresentStoreProduct を true にする。
  2. UIViewControllerRepresentable で isPresentStoreProduct が true になったら ViewController の AppStore を開くメソッドを実行する。
  3. UIViewController で、AppStore を開いた後、 isPresentStoreProduct を false に戻す。
    • このとき、即時に false にすると UIViewControllerRepresentable 側で連続してイベントが発生してしまうため、 非同期に DispatchQueue.main.async で false にする。

動作させるとこんな感じになる。
ちなみに id 284602850 のアプリは、 Texas Hold’em である。

コードは GitHub にもあります。
https://github.com/daisuke-t-jp/SwiftUIPresentAppStoreProduct

Swift Dictionary の key, value を weak 参照したい

たとえば

UIView を key にして、Datevalue にした Dictionary を使いたいと考えたとき。

ここで問題があるのは Dictionary の key, valuestrong で参照するため、 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. レシートから1枚ランダムに選んで、テーブルに置く
  2. テーブルの上にあるレシートを見る
    1. レシートの合計が3000円以上なら、コップを用意してそこにテーブルの上のレシートを入れる。1. を実行する
    2. レシートの合計が3000円未満なら、1. を実行する
    1. と 2. をレシートを取り尽くすまで実行する。
    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 スポットがあったりするのでまとめたりとか。。

そんな感じで作りましたよ。

青森県 公衆Wi-Fiマップ

青森県 公衆Wi-Fiマップ

  • Daisuke Tonosaki
  • Navigation
  • Free

あ、、青森県が出身地です。


今回 iOS 15 以降がターゲットのアプリにしたのだけど

iOS 15 だと属性付き文字列を NSAttributedString ではなく新しい AttributedString を使って作れるので、以前より直感的に作れる気がする。

AttributedString

たとえばこんな表示をしたければ、AttributedString はこう作る。

f:id:daisuke-t-jp:20211018210051p:plain

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 の記載あったかなぁ・・・

ちなみに、↑とは別に「テストデバイス」設定をする方法でも、デモ広告は表示される。