NHK for School API を使った Flutter の習作
Flutter を作って何かアプリを作ってみようと思い、
NHK for School API という API を使って、アプリを作ってみた。
NHK for School API を使うと NHK for School 公開されているコンテンツを API で取得できる。
(すべてが取得できるわけでなく、現在だと小・中学校の理科と社会科のコンテンツだけのようだ)
このアプリ作成を通じて Flutter アプリでの以下の実現方法を学べた。
- HTTP によるデータ取得
- アプリ内データ保存(shared_preferences)
- 各 Widget の使い方(レイアウトの組み方など)
情報提供 NHK
Swift の未使用コードを分析する periphery コマンドのオプションメモ
periphery を使用すると Swift プロジェクトの中で未使用なコードを特定できる。
(未使用なクラス・プロパティ、不要な public
がわかる)
インストールして、コマンドのオプションを調べたのでメモする。
インストール
brew 経由だと
brew install peripher
ヘルプを実行
periphery scan --help
実行結果。オプションが羅列される。
% periphery help scan OVERVIEW: Scan for unused code USAGE: periphery scan [<options>] [<build-arguments> ...] ARGUMENTS: <build-arguments> Arguments following '--' will be passed to the underlying build tool, which is either 'swift build' or 'xcodebuild' depending on your project OPTIONS: --setup Enable guided setup --config <config> Path to configuration file. By default Periphery will look for .periphery.yml in the current directory --workspace <workspace> Path to your project's .xcworkspace. Xcode projects only --project <project> Path to your project's .xcodeproj - supply this option if your project doesn't have an .xcworkspace. Xcode projects only --schemes <schemes> Comma-separated list of schemes that must be built in order to produce the targets passed to the --targets option. Xcode projects only (default: []) --targets <targets> Comma-separated list of target names to scan. Required for Xcode projects. Optional for Swift Package Manager projects, default behavior is to scan all targets defined in Package.swift (default: []) --format <format> Output format (allowed: xcode, csv, json, checkstyle) (default: xcode) --index-exclude <index-exclude> Path glob of source files which should be excluded from indexing. Declarations and references within these files will not be considered during analysis. Multiple globs may be delimited by a pipe (default: []) --report-exclude <report-exclude> Path glob of source files which should be excluded from the results. Note that this option is purely cosmetic, these files will still be indexed. Multiple globs may be delimited by a pipe (default: []) --index-store-path <index-store-path> Path to index store to use. Implies '--skip-build' --retain-public Retain all public declarations - you'll likely want to enable this if you're scanning a framework/library project --disable-redundant-public-analysis Disable identification of redundant public accessibility --retain-assign-only-properties Retain properties that are assigned, but never used --retain-assign-only-property-types <retain-assign-only-property-types> Comma-separated list of property types to retain if the property is assigned, but never read (default: []) --external-encodable-protocols <external-encodable-protocols> Comma-separated list of external protocols that inherit Encodable. Properties of types conforming to these protocols will be retained (default: []) --retain-objc-accessible Retain declarations that are exposed to Objective-C implicitly by inheriting NSObject classes, or explicitly with the @objc and @objcMembers attributes --retain-unused-protocol-func-params Retain unused protocol function parameters, even if the parameter is unused in all conforming functions --clean-build Clean existing build artifacts before building --skip-build Skip the project build step --strict Exit with non-zero status if any unused code is found --disable-update-check Disable checking for updates --verbose Enable verbose logging --quiet Only output results -h, --help Show help information.
オプションのメモ
--setup
対話形式でセットアップして分析する。
セットアップした結果は .periphery.yml
に構成が保存されるので次回以降はセットアップせずに yml から構成を指定して、分析できる。
--config
.periphery.yml
のパスを指定する。
デフォルトはカレントディレクトリ。
--workspace
--project
Xcode プロジェクトへのパス。
ワークスペースがない場合は、こちらを指定する。
--schemes
対象スキーム。
カンマ区切りで複数指定できる。
単体指定も可能。
--targets
対象ターゲット。
カンマ区切りで複数指定できる。
単体指定も可能。
--format
出力フォーマット。以下が指定可能。
デフォルトは xcode
。
Xcode で Build Phase に追加して、Xcode のエディタ上で結果を表示したい時は xcode 指定にする。
表として、出力したい時は csv が便利。
--index-exclude
インデックス作成から除外するパス。
--report-exclude
出力される結果から除外するパス。
インデックス作成自体はされることに注意。
--index-store-path
インデックスストアのパスを指定する。
この場合、プロジェクトのビルドをスキップする。
(--skip-build
と同様に)
--retain-public
public
された宣言はすべて外部から使用される想定にする。
フレームワークやライブラリのプロジェクトで、実際にそのインターフェースを使用していないプロジェクトの場合にこれを指定する。
--disable-redundant-public-analysis
冗長なパブリックアクセシビリティの識別を無効にする。
--retain-assign-only-propertie
割り当てられているが使用されていないプロパティを保持する。
--retain-assign-only-property-types
プロパティが割り当てられているが、読み取られない場合に保持するプロパティタイプのコンマ区切りリスト。
--external-encodable-protocols
Encodableを継承する外部プロトコルのコンマ区切りリスト。 これらに準拠するタイプのプロパティプロトコルは保持されます。
--retain-objc-accessible
NSObjectクラスを継承することによって暗黙的に、または@objc属性と@objcMembers属性を使用して明示的に、Objective-Cに公開される宣言を保持します。
--retain-unused-protocol-func-params
パラメータがすべての準拠関数で使用されていない場合でも、未使用のプロトコル関数パラメータを保持します。
--clean-build
既存のビルドをクリアしてからビルドする。
誤った結果が出ている時は、インデックスストアが破損したり・ソースファイルと同期しなくなった可能性がある。
たとえば、スキャンを強制的に終了(^C
)した場合にこれが発生することがある。
この時は --clean-build
すると良い。
--skip-build
ビルドをスキップする。
--strict
未使用コードが見つかった場合は非ゼロのステータスで終了する。
--disable-update-check
更新のチェックを無効にする。
--verbose
冗長なログを出力。
これを指定すると、
[configuration:begin]
から始まる箇所に、分析の構成が表示されるので、それを .periphery.yml
に貼り付けると、構成を永続化できる。
--quiet
結果のみを出力。
-h, --help
ヘルプを表示。
CI で使用する場合
https://github.com/peripheryapp/periphery#continuous-integration
たとえばテストを実行した後に periphery を実行する時は
--skip-build
でビルドをスキップできる。
インデックスパスが非標準の場所にあるときは
--index-store-path
で指定する。
例
たとえば以下のスキームがあって
- A
- B
- C
それぞれ以下のターゲットがあるとき
- A
- B
- C
これらをそれぞれ分析して、結果を CSV に出力する時は以下のシェルになる。
array=(A B C) for i in "${array[@]}" do periphery scan --project test.xcodeproj --schemes ${i} --targets ${i} --format csv > ~/Desktop/test/periphery/${i}.csv done
iOS 15 から使える GCVirtualController のメモ
GCVirtualController
は iOS 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() } }
これを実行すると、このようにボタンが表示される。
使えるボタンの種類はここに書いてあり、 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 ボタンの見た目が、パックマンに変わる。
各ボタンは途中で非表示にできるか?
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 ボタンが非表示になると見た目がこうなる。
ちなみに最初から B ボタンがない(GCVirtualController の init(configuration:)
の時点で B ボタンの要素を含めない)場合は、見た目がこうなる。
上の2つのボタン配置位置が違うことから
- 最初からボタンがない場合
- 途中でボタンを非表示にした場合
はそれぞれ配置が異なることがわかるので、注意したい。
(途中でボタンを非表示にしても、その分、配置が詰められる訳ではない)
もし、ボタンを非表示にして、かつボタン配置も最初からボタンがないものと同じにしたい場合は、GCVirtualController init(configuration:)
でバーチャルコントローラー自体を作り直せば良い。
おわり
↑のようにいろいろ GCVirtualController
を調べて作ったゲームがあったりしますよ。
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 アクションでも、ビルドステータスのバッチがつけれます。