- 投稿日:2021-03-21T20:58:14+09:00
ナビゲーションバーのカスタマイズ一覧(iOS)
はじめに
ナビゲーションバーをカスタマイズする方法を毎回忘れて実装に時間がかかるため、まとめました。
環境
- OS:macOS Big Sur 11.1
- Xcode:12.4 (12D4e)
- Swift:5.3.2
- iOS:14.4
ナビゲーションバーのカスタマイズ一覧
ナビゲーションバーのタイトルに画像をセットする
UINavigationItem.titleView
に画像をセットすることで、ナビゲーションバーのタイトルを画像にできます。MonsterIndexListViewController.swiftfinal class MonsterIndexListViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let imageView = UIImageView(image: UIImage(named: "navi_zukan")) imageView.contentMode = .scaleAspectFit self.navigationItem.titleView = imageView } }
テキスト 画像
UIImageView.contentMode
を.scaleAspectFit
にすると、画像の縦横比を変えずに表示できます。ナビゲーションバーの戻るボタンの画像を変更する
UINavigationBar
のbackIndicatorImage
とbackIndicatorTransitionMaskImage
を変更することで、戻るボタンの画像を変更できます。MonsterIndexListViewController.swiftfinal class MonsterIndexListViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) let image = UIImage(named: "index_icon_back") self.navigationController?.navigationBar.backIndicatorImage = image self.navigationController?.navigationBar.backIndicatorTransitionMaskImage = image } }
画像未変更 画像変更 セットした画像は以下であり、
tintColor
が反映されてしまいます。
tintColor
を反映させない方法がわからなかったので、ご存じの方がいたら教えていただけると嬉しいです。2021/03/23, 追記
コメント で教えていただきました。対象画像のRender Asを
Default
からOriginal Image
に変えるとtintColor
が反映されなくなります。
ただ画像の位置が上に寄るのは直りません。
画像のサイズを調整すればかんたんに対応できます。
参考: https://sarunw.com/posts/how-to-change-back-button-image/#position
UIImage
の位置を変える方法もありますが、tintColor
が反映されるのと、UIImageView
の位置は変わらないので見切れることがあるため、ベターな方法ではなさそうです。
参考: https://stackoverflow.com/questions/29445644/vertically-center-backindicatorimage-in-swiftナビゲーションバーの戻るボタンのテキストを非表示にする
iOS 14以上の場合、 遷移元のビューコントローラー の
UINavigationItem.backButtonDisplayMode
を.minimal
にすることで、戻るボタンのテキストを非表示にできます。MainViewController.swiftfinal class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.navigationItem.backButtonDisplayMode = .minimal } }
表示 非表示 戻るボタンの長押し時にビューコントローラーの
title
が表示されるので、戻るボタンを非表示にする場合でもセットするのが望ましいです。MainViewController.swiftfinal class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + self.title = "メイン" self.navigationItem.backButtonDisplayMode = .minimal } }
タイトル未セット タイトルセット 戻るボタンのタイトルに半角スペースをセットする方法もよく見ますが、iOS 14以降では戻るボタン長押し時のメニューにタイトルが表示されなくなるので望ましくありません。
ナビゲーションバーの戻るボタンの色を変更する
UINavigationBar
のtintColor
を変更することで、戻るボタンの色を変更できます。MonsterIndexListViewController.swiftfinal class MonsterIndexListViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.navigationBar.tintColor = UIColor(named: "tekimon_khaki") } }
色未変更 色変更 一部のビューコントローラーのみナビゲーションバーの戻るボタンの色を変更する場合、戻し忘れに気をつけてください。
戻すには
nil
をセットします。MainController.swiftfinal class MainViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.navigationBar.tintColor = nil } }ナビゲーションバーの背景色を変更する
UINavigationBar
のbarTintColor
を変更することで、ナビゲーションバーの背景色を変更できます。MonsterIndexListViewController.swiftfinal class MonsterIndexListViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.navigationBar.barTintColor = UIColor(named: "tekimon_khaki") } }
背景色未変更 背景色変更 一部のビューコントローラーのみナビゲーションバーの背景色を変更する場合、戻し忘れに気をつけてください。
戻すには
nil
をセットします。MainController.swiftfinal class MainViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.navigationBar.barTintColor = nil } }ナビゲーションバーを透過する
UINavigationBar
のbackgroundImage
とshadowImage
を初期化することで、ナビゲーションバーを透過できます。MonsterIndexListViewController.swiftfinal class MonsterIndexListViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.navigationBar.setBackgroundImage(UIImage(), for: .default) self.navigationController?.navigationBar.shadowImage = UIImage() } }
非透過 透過 一部のビューコントローラーのみナビゲーションバーを透過する場合、戻し忘れに気をつけてください。
非透過にするには
nil
をセットします。MainViewController.swiftfinal class MainViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.navigationBar.setBackgroundImage(nil, for: .default) self.navigationController?.navigationBar.shadowImage = nil } }私が参考にしたサイトでは、
UINavigationBar
に以下のような拡張メソッドを実装していました。
意図が明確になっていいと思います。UINavigationBar+Transparency.swiftextension UINavigationBar { func enableTransparency() { setBackgroundImage(UIImage(), for: .default) self.shadowImage = UIImage() } func disableTransparency() { setBackgroundImage(nil, for: .default) self.shadowImage = nil } }ナビゲーションバーを非表示にする
UINavigationController.setNavigationBarHidden(_:animated:)
メソッドを呼び出すことで、ナビゲーションバーの表示/非表示を切り替えられます。MainViewController.swiftfinal class MainViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.navigationController?.setNavigationBarHidden(true, animated: false) } }
表示 非表示 ナビゲーションバーの表示/非表示が混在しているアプリの場合、画面を遷移するたびにナビゲーションバーの表示も切り替える必要があるので、
viewWillAppear(_:)
メソッド内で実行すべきです。ナビゲーションバーを表示 or 隠し忘れないように気をつけてください。
おまけ: スクリーンショットのアプリ
スクリーンショットに使ったアプリは、先日行われたHack Day 2021で私たちのチームが開発しました!
詳細は @hcrane14 さんがまとめているので、よかったら見てください。
https://note.com/hcrane/n/n98a8f1157390おわりに
これでナビゲーションバーをカスタマイズする案件が来ても安心です
![]()
self.navigationController
を変更すると他のビューコントローラーにも影響するのでviewWillAppear(_:)
で行って戻すのを忘れない、self.navigationItem
は変更しても他のビューコントローラーに影響しないのでviewDidLoad()
で行う、と覚えておくといいです(間違っていたらすみません)。参考リンク
- 投稿日:2021-03-21T16:58:10+09:00
github actionsでunityビルドしてiOSとAndroidのストアに自動的に提出する
こんにちは。virapture株式会社のもぐめっとです。
この記事を書いてるときは冬なんですけど、もう自分の写真がなさすぎて夏までさかのぼってしまいました。
本日はCI/CD with Unity, GitHub Actions, and Fastlaneという記事を参考にUnityでの自動ビルドとストア提出を作ってみたのでそのメモ書きを残しておきます。
概要
UnityにはUnity Cloud Buildという公式のCIがあるのですが、いかんせんfastlaneとの連携ができないので、証明書を引っ張ってきたり、ストアにアップロードというのができません。
そこで探してみたところ、GameCIが出しているunity-builderというgithub actionsを使ってビルドを行い、デプロイにはfastlaneを使用することでうまくできました。
このunity-builderが結構優秀で、ライセンスの認証とかもやってくれる。そして、ビルドが終わった後はライセンスをリターンしてくれるという優れもの。
もはやUnity Cloud Build使わなくてもCIができちゃいます。構築手順
github actions workflowの設置
記事の人のリポジトリのファイルをベースにしながら作りました。
ios,android以外にもmac, windowsなどもあったのですが、邪魔だったのでそのへんは抹消しました。
こんなワークフローになってます。
iOSのビルド長すぎぃぃ。。
お待ちかね実体ファイルはこんな感じになってます。name: Test, Build, and Release CGS on: # push: { branches: [master] } #masterにpushされたときにやりたい人はこちらをお使いください release: { types: [published] } env: UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} BUILD_NUMBER: ${{ github.run_number }} jobs: tests: name: Test Code Quality runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout Repository uses: actions/checkout@v2 with: fetch-depth: 0 - name: Cache Library uses: actions/cache@v2 with: path: Library key: Library - name: Run EditMode and PlayMode Tests uses: game-ci/unity-test-runner@main - name: Publish Test Results if: ${{ always() }} # Avoid skipping on failed tests uses: davidmfinol/unity-test-publisher@main with: githubToken: ${{ secrets.GITHUB_TOKEN }} buildWithLinux: name: Build for ${{ matrix.targetPlatform }} by Unity runs-on: ubuntu-latest timeout-minutes: 90 needs: tests strategy: fail-fast: false matrix: targetPlatform: - Android - iOS steps: - name: Checkout Repository uses: actions/checkout@v2 with: fetch-depth: 0 lfs: true - name: Cache Library uses: actions/cache@v2 with: path: | Library build/${{ matrix.targetPlatform }} key: Library-${{ matrix.targetPlatform }}- restore-keys: Library- - name: Free Disk Space for Android if: matrix.targetPlatform == 'Android' run: | sudo swapoff -a sudo rm -f /swapfile sudo apt clean docker rmi $(docker image ls -aq) df -h - name: Build Unity Project uses: game-ci/unity-builder@main with: customParameters: -buildNumber ${{ github.run_number }} targetPlatform: ${{ matrix.targetPlatform }} buildMethod: Cgs.Editor.BuildCgs.BuildOptions androidAppBundle: true androidKeystoreName: keystore.keystore androidKeystoreBase64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} androidKeystorePass: ${{ secrets.ANDROID_KEYSTORE_PASS }} androidKeyaliasName: ${{ secrets.ANDROID_KEYALIAS_NAME }} androidKeyaliasPass: ${{ secrets.ANDROID_KEYALIAS_PASS }} - name: Upload Build uses: actions/upload-artifact@v2 if: github.event.ref != 'refs/heads/develop' with: name: cgs-${{ matrix.targetPlatform }} path: build/${{ matrix.targetPlatform }} releaseToGooglePlay: name: Release to the Google Play Store runs-on: ubuntu-latest timeout-minutes: 60 needs: buildWithLinux if: github.event.action == 'published' env: GOOGLE_PLAY_KEY_FILE: ${{ secrets.GOOGLE_PLAY_KEY_FILE }} GOOGLE_PLAY_KEY_FILE_PATH: ${{ format('{0}/fastlane/api-finoldigital.json', github.workspace) }} ANDROID_BUILD_FILE_PATH: ${{ format('{0}/build/Android/Android.aab', github.workspace) }} ANDROID_PACKAGE_NAME: com.sample.app # 自分のアプリのパッケージ名に合わせよう! RELEASE_NOTES: ${{ github.event.release.body }} steps: - name: Checkout Repository uses: actions/checkout@v2 - name: Download Android Artifact uses: actions/download-artifact@v2 with: name: cgs-Android path: build/Android - name: Prepare for Upload run: | echo "$GOOGLE_PLAY_KEY_FILE" > $GOOGLE_PLAY_KEY_FILE_PATH echo "$RELEASE_NOTES" > fastlane/metadata/android/en-US/changelogs/default.txt - name: Upload to Google Play uses: maierj/fastlane-action@v1.4.0 with: lane: 'android playstore' buildIOS: name: Build Archive for iOS runs-on: macos-latest timeout-minutes: 60 needs: buildWithLinux if: github.event.action == 'published' env: APPLE_CONNECT_EMAIL: ${{ secrets.APPLE_CONNECT_EMAIL }} APPLE_DEVELOPER_EMAIL: ${{ secrets.APPLE_DEVELOPER_EMAIL }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_NAME: ${{ secrets.APPLE_TEAM_NAME }} APPLE_ITC_TEAM_ID: ${{ secrets.APPLE_ITC_TEAM_ID }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_PERSONAL_ACCESS_TOKEN: ${{ secrets.MATCH_PERSONAL_ACCESS_TOKEN }} IOS_APP_ID: com.sample.app # bundle idを指定します IOS_BUILD_PATH: ${{ format('{0}/build/iOS', github.workspace) }} PROJECT_NAME: onenitejinro RELEASE_NOTES: ${{ github.event.release.body }} MATCH_REPOSITORY_ACCOUNT: ${{ secrets.MATCH_REPOSITORY_ACCOUNT }} USYM_UPLOAD_AUTH_TOKEN: 'fake' # ビルドが途中でこけるのでfake用に環境変数を追加。 steps: - name: Checkout Repository uses: actions/checkout@v2 - name: Cache restore for debug uses: actions/cache@v2 with: path: | Library build/iOS key: Library-iOS- restore-keys: Library- - name: Download iOS Artifact uses: actions/download-artifact@v2 with: name: cgs-iOS path: build/iOS - name: Cache restore cocoapods # firebase使ってるとcocoapodsを使うのですが、cocoapodsのキャッシュとらないと毎回時間かかるのでキャッシュしておきます。 uses: actions/cache@v2 if: ${{ always() }} with: path: | build/iOS/iOS/Pods ~/.cocoapods/repos key: Pods-${{ hashFiles('**/Podfile') }} restore-keys: Pods- - uses: actions/setup-ruby@v1 with: ruby-version: '2.7.2' - name: Prepare for fastlane # GateKeeper対策 run: | sudo spctl --master-disable - name: Archive iOS uses: maierj/fastlane-action@v2.0.1 with: lane: 'ios build' - name: run if fail_step failed # ビルドがコケた原因がわかるようにcatしておきます if: failure() run: cat /Users/runner/Library/Logs/gym/*Unity-iPhone.log - name: Upload Build uses: actions/upload-artifact@v2 if: github.event.ref != 'refs/heads/develop' with: name: ipa path: | ${{ github.workspace }}/*.ipa ${{ github.workspace }}/*.dSYM.zip releaseToAppStore: name: Release to the App Store runs-on: macos-latest timeout-minutes: 60 needs: buildIOS if: github.event.action == 'published' env: APPLE_CONNECT_EMAIL: ${{ secrets.APPLE_CONNECT_EMAIL }} APPLE_DEVELOPER_EMAIL: ${{ secrets.APPLE_DEVELOPER_EMAIL }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_NAME: ${{ secrets.APPLE_TEAM_NAME }} APPLE_ITC_TEAM_ID: ${{ secrets.APPLE_ITC_TEAM_ID }} FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} MATCH_PERSONAL_ACCESS_TOKEN: ${{ secrets.MATCH_PERSONAL_ACCESS_TOKEN }} ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} ASC_KEY_CONTENT: ${{ secrets.ASC_KEY_CONTENT }} IOS_APP_ID: com.sample.app # bundle idを指定します IOS_BUILD_PATH: ${{ format('{0}/build/iOS', github.workspace) }} PROJECT_NAME: onenitejinro RELEASE_NOTES: ${{ github.event.release.body }} MATCH_REPOSITORY_ACCOUNT: ${{ secrets.MATCH_REPOSITORY_ACCOUNT }} steps: - name: Checkout Repository uses: actions/checkout@v2 - name: Download iOS Artifact uses: actions/download-artifact@v2 with: name: ipa path: | ${{ github.workspace }}/*.ipa ${{ github.workspace }}/*.dSYM.zip - name: Upload to the App Store uses: maierj/fastlane-action@v1.4.0 with: lane: 'ios release'このファイルのポイントを話しておくと、linuxでビルドが終わった後、fastlaneを使ってandroid/iosともにビルドもしくはアップロードを行っています。
iosは結構曲者で、UnityでCLIビルドが結構大変なので随所随所にビルドできるような仕組みを置いてます。
fastlaneの設置
fastlaneで使うファイルを参考ファイルをベースにカスタマイズした下記を設置しています。
keychain_name = "temporary_keychain" keychain_password = SecureRandom.base64 platform :android do desc "Upload a new Android version to the Google Play Store" lane :playstore do upload_to_play_store( aab: "#{ENV['ANDROID_BUILD_FILE_PATH']}", track: 'internal', skip_upload_screenshots: true, skip_upload_images: true ) end end platform :ios do desc "Push a new release build to the App Store" lane :release do api_key = app_store_connect_api_key( key_id: ENV['ASC_KEY_ID'], # your key id issuer_id: ENV['ASC_ISSUER_ID'], # your issuer id key_content: ENV['ASC_KEY_CONTENT'], # your secret key body # ex) key_content: '-----BEGIN PRIVATE KEY-----\nfoobar\n-----END PRIVATE KEY-----' ) upload_to_app_store( api_key: api_key, # pass api_key force: true, skip_screenshots: true, skip_metadata: true ) end desc "Submit a new Beta Build to Apple TestFlight" lane :beta do api_key = app_store_connect_api_key( key_id: ENV['ASC_KEY_ID'], # your key id issuer_id: ENV['ASC_ISSUER_ID'], # your issuer id key_content: ENV['ASC_KEY_CONTENT'], # your secret key body # ex) key_content: '-----BEGIN PRIVATE KEY-----\nfoobar\n-----END PRIVATE KEY-----' ) upload_to_testflight( api_key: api_key, # pass api_key skip_waiting_for_build_processing: true ) end desc "Create .ipa" lane :build do cocoapods(podfile: "#{ENV['IOS_BUILD_PATH']}/iOS/Podfile") disable_automatic_code_signing(path: "#{ENV['IOS_BUILD_PATH']}/iOS/Unity-iPhone.xcodeproj") certificates update_project_provisioning( xcodeproj: "#{ENV['IOS_BUILD_PATH']}/iOS/Unity-iPhone.xcodeproj", target_filter: "Unity-iPhone", profile: ENV["sigh_#{ENV['IOS_APP_ID']}_appstore_profile-path"], # より動的にみるようにしています code_signing_identity: "Apple Distribution: #{ENV['APPLE_TEAM_NAME']} (#{ENV['APPLE_TEAM_ID']})" ) gym( workspace: "#{ENV['IOS_BUILD_PATH']}/iOS/Unity-iPhone.xcworkspace", scheme: "Unity-iPhone", clean: true, #clean: false, skip_profile_detection: true, codesigning_identity: "Apple Distribution: #{ENV['APPLE_TEAM_NAME']} (#{ENV['APPLE_TEAM_ID']})", export_method: "app-store", export_options: { method: "app-store", provisioningProfiles: { ENV["IOS_APP_ID"] => "match AppStore #{ENV['IOS_APP_ID']}" } } ) end desc "Synchronize certificates" lane :certificates do cleanup_keychain create_keychain( name: keychain_name, password: keychain_password, default_keychain: true, lock_when_sleeps: true, timeout: 3600, unlock: true ) match( type: "appstore", readonly: true, keychain_name: keychain_name, keychain_password: keychain_password ) end lane :cleanup_keychain do if File.exist?(File.expand_path("~/Library/Keychains/#{keychain_name}-db")) delete_keychain(name: keychain_name) end end after_all do if File.exist?(File.expand_path("~/Library/Keychains/#{keychain_name}-db")) delete_keychain(name: keychain_name) end end end元ファイルからcocoapodsのインストールや、api keyまわりの設定、provisioning profileの指定方法などが若干違います。
また、問題を切り分けやすくするためにビルドとapp storeにリリースする処理はわけたりしています。
参考元ファイルでは現在、apikey周りは証明書をおいていい感じにやるようにしているみたいです。
他にfastlaneに付随するファイルも置いていきます
- Appfile
for_platform :android do package_name(ENV["ANDROID_PACKAGE_NAME"]) json_key_file(ENV["GOOGLE_PLAY_KEY_FILE_PATH"]) end for_platform :ios do app_identifier(ENV["IOS_APP_ID"]) apple_dev_portal_id(ENV["APPLE_DEVELOPER_EMAIL"]) # Apple Developer Account itunes_connect_id(ENV["APPLE_CONNECT_EMAIL"]) # App Store Connect Account team_id(ENV["APPLE_TEAM_ID"]) # Developer Portal Team ID itc_team_id(ENV["APPLE_ITC_TEAM_ID"]) # App Store Connect Team ID end
- Deliverfile
submit_for_review false automatic_release true force true skip_screenshots true release_notes({ 'default' => ENV["RELEASE_NOTES"], 'en-US' => ENV["RELEASE_NOTES"] }) run_precheck_before_submit false submission_information({ add_id_info_uses_idfa: false, export_compliance_compliance_required: false, export_compliance_encryption_updated: false, export_compliance_app_type: nil, export_compliance_uses_encryption: false, export_compliance_is_exempt: false, export_compliance_contains_third_party_cryptography: false, export_compliance_contains_proprietary_cryptography: false, export_compliance_available_on_french_store: false });
- Matchfile
git_url("https://github.com/iosのcertificateおいてるgitリポジトリ") # sshのurlではなく、httpsで指定してます。 git_basic_authorization(Base64.strict_encode64("#{ENV['MATCH_REPOSITORY_ACCOUNT']}:#{ENV['MATCH_PERSONAL_ACCESS_TOKEN']}")) storage_mode("git") type("appstore") # The default type, can be: appstore, adhoc, enterprise or development app_identifier(["com.sample.app"]) # bundleIDを指定しよう username("apple@example.com") # Your Apple Developer Portal usernameprojectの準備
BuildCgs.csをAssets/Scripts/Cgs/Editorディレクトリに置いておく。
このファイルがビルド番号の設定などをしてくれている。もぐめっとの場合、CIのビルド番号とアプリのビルド番号を紐付けしたかったので冒頭部分だけbuildNumberを参照するように少し修正しました。
修正後BuildCgs.csPlayerSettings.macOS.buildNumber = options["buildNumber"]; PlayerSettings.iOS.buildNumber = options["buildNumber"]; PlayerSettings.Android.bundleVersionCode = int.Parse(options["buildNumber"]); PlayerSettings.WSA.packageVersion = new Version(options["buildVersion"]);metadataの準備
metadataがないとfastlane途中でこけるため準備しておく。
androidの準備
fastlane run download_from_play_store json_key:<json path>jsonファイルはストアにアクセスするために必要なものになるが、こちらの記事に解説は委ねます。
iosの準備
fastlane deliver download_metadata上記で生成されたファイルをコミットしておく
githubのsecrets設定
先術したymlやfastfileなどを見てもらったとおり、環境変数をふんだんに使うので、その準備をしていきます。
game-ci/unity-builder周りで使う環境変数
細かいことはGame CIのドキュメントをご参照ください
Unityのアカウント周りの情報は下記の変数になります
- UNITY_EMAIL
- UNITY_PASSWORD
- UNITY_SERIAL
unityのserialについてはunityのwebサイトにログインして確認します。
Androidの公開設定周りはこちらの変数になります
- ANDROID_KEYALIAS_NAME
- ANDROID_KEYALIAS_PASS
- ANDROID_KEYSTORE_BASE64
- ANDROID_KEYSTORE_PASS
上記四点はandroidの公開設定でも使うこの辺の情報ですね。
keystoreの作り方は公式に委ねます
fastlaneのアップロード周りで使ってる環境変数
アップロードするときなどに使われるfastlaneのDeliverで使われるAppfileで定義して使ってる一覧です。
- APPLE_CONNECT_EMAIL
App Store Connect Accountにアクセスできるアカウントを指定します
- APPLE_DEVELOPER_EMAIL
Apple Developer Accountにアクセスできるアカウントを指定します。
- APPLE_TEAM_ID
- APPLE_TEAM_NAME
上記2点はDeveloper Portalのサイトでアクセスできる項目を確認します
- APPLE_ITC_TEAM_ID
APPLE_TEAM_IDではなくて、app store connectで使うアカウントのほうのIDになるらしい。とりあえずfastlane使えるならspaceship使えば取得できます。
- ASC_ISSUER_ID
- ASC_KEY_CONTENT
- ASC_KEY_ID
Appleの2段階認証を突破してアップロードできるようにする用の変数です。解説は下記記事に委ねます。
- FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD
- FASTLANE_PASSWORD
先程のAppStoreConnect API Keyを使った方法でできると思うので、おそらくこの2点は設定しなくてもいいと思いますが、念の為設定しておきます。
設定方法はこちらに。
- GOOGLE_PLAY_KEY_FILE
metadataをダウンロードする時お話した、jsonファイルの中身をはっつけます。
再掲)
fastlane match周りの環境変数
fastlane matchについてはこちらをご参照ください。証明書を管理するやつです。
- MATCH_PASSWORD
matchに設定してるパスワードを指定します。
- MATCH_PERSONAL_ACCESS_TOKEN
- MATCH_REPOSITORY_ACCOUNT
上記2つはMatchfileで指定したgit_basic_authorizationに使うものなのですが、repositoryのcloneがgithub action上でできるようにするために使っています。
MATCH_PERSONAL_ACCESS_TOKENはgithubのaccess tokenを指定します。
MATCH_REPOSITORY_ACCOUNTはMatchのリポジトリにアクセスできるアカウントを指定します。(access tokenを発行したアカウント名ですね)
使い方
ようやく設定がおわりました。
このworkflowはreleaseを切ったときに初めて発火されます。
Draft a new releaseボタンからリリースを作りましょう!
成功すればreleaseで書いた内容でappstoreとplay storeにリリースされるはずです!
まとめ
というわけでgame ciさんのunity-builderを使うことでgithub actionsでunityのCIを回すことができるようになりました!
unityはビルドに時間かかるので結構めんどくさかったのですがこの辺が省略できるようになるのはとても楽になりました。みなさんの開発にあてる時間がこれで増えたら幸いです!
最後に、ワンナイト人狼オンラインというゲームを作ってます!よかったら遊んでね!
他にもcameconやoffchaといったサービスも作ってるのでよかったら使ってね!
- 投稿日:2021-03-21T11:09:11+09:00
TCAでもUseCase/Repository/DataSourceが使いたい
はじめに
この内容は『iOSアプリ開発のためのFunctional Architecture情報共有会4』のための資料です。
導入
The Composable Architecture (TCA)では副作用実行にグローバルな関数1やClient、またはManagerというのが使われる。公式サンプルだけでなくpointfreeのゲームアプリのコードでもClientがよく出てくる。これを普段よく使われるUseCase/Repository/DataSourceを使いたいというのが発表の主旨。
TCAの副作用実行のための関数やClientについてのおさらい
(グローバルな)関数?
サンプルではグローバルな関数を使っていた。
次のコードはWeb API呼び出しで任意の数字からその数字にまつわるトリビアを返す非同期な処理。
func liveNumberFact(for n: Int) -> Effect<String, NumbersApiError> { return URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(n)/trivia")!) .map { data, _ in String(decoding: data, as: UTF8.self) } .catch { _ in // Sometimes numbersapi.com can be flakey, so if it ever fails we will just // default to a mock response. Just("\(n) is a good number Brent") .delay(for: 1, scheduler: DispatchQueue.main) } .setFailureType(to: NumbersApiError.self) .eraseToEffect() }
関数利用側でEnvironmentによってDIする場合。Swiftにおいても、関数も変数として扱えるのでDIするときは
(Parameter) -> Effect
クロージャさえ合えばいい。struct EffectsBasicsEnvironment { var numberFact: (Int) -> Effect<String, NumbersApiError> }これで結果を置き換えられるのでXcodeプレビューでも楽だし、テストコードも楽に書ける。
ただ、グローバルな関数として用意するのは複数人でアプリを作るときはあまりやりたくない。サンプルだから良いけども。
Client/Managerとは?
公式のサンプルではClient/Managerは次の通り。
- Client系
- WebSocketClient
- SpeechClient
- DownloadClient
- AudioPlayerClient
- WeatherClient
- AudioRecorderClient
- Manager系
- LocationManager(これはサンプルというよりTCA公式のLocationラッパーかも)
- MotionManager(これはサンプルというよりTCA公式のMotionラッパーかも)
ちなみにClientってどんなものなんだろう?
Clientには
var
クロージャが処理を返しロジックが入れ替えられるようになってる。struct WeatherClient { var searchLocation: (String) -> Effect<[Location], Failure> var weather: (Int) -> Effect<LocationWeather, Failure> struct Failure: Error, Equatable {} }struct SearchEnvironment { var weatherClient: WeatherClient var mainQueue: AnySchedulerOf<DispatchQueue> }クロージャを変数で入れる場合にEnvironmentに直接入れるのではなく、型としてまとめている。
プロダクション用の実処理自体はextensionに
.live
定数で用意extension WeatherClient { static let live = WeatherClient( // 初期化してクロージャに処理を用意してそれを定数 .live として返す searchLocation: { query in var components = URLComponents(string: "https://www.metaweather.com/api/location/search")! components.queryItems = [URLQueryItem(name: "query", value: query)] return URLSession.shared.dataTaskPublisher(for: components.url!) // URLSessionをDIしてない(する気がない .map { data, _ in data } .decode(type: [Location].self, decoder: jsonDecoder) .mapError { _ in Failure() } .eraseToEffect() }, ...
短所
- 処理がstaticな定数だから変数を参照することはできない
- URLSessionをDIすることができない
- WeatherClient自体を置き換えやすいが、WeatherClient自体をテストする気がない
- 例えばDBで特定の要素が上書きされるロジックのテストとかはやる気がない
おそらくReducerの仕様さえテストできてれば良くて、そこから奥にあるミスっていうのはReducerの仕様に関するテストコードで気付こうという感じなのではないか。
短所を解決するには?
呼び出し時に引数で置き換えたいものを渡す
// Reducer内 case let .locationTapped(location): struct SearchWeatherId: Hashable {} state.locationWeatherRequestInFlight = location return environment.weatherClient .weather(location.id, /* ここでURLSessionを渡す */)もしくは定数
.live
のなかでFactory.getURLSession()
みたいなのを用意してプリプロセッサ的なマクロで切り替えて取り出してもいい(が、その仕組みを自動化せず作るのは自分はあんまり...)。
ここまでのまとめ
- pointfreeの人たちは細かな副作用の単体テストをReducerのテストでカバーしてる
- 副作用での細かな処理のテストをやるには引数で渡すとか、Factory的なのを内部で切り替える
おまけ: 最近公開されたpointfreeのゲームのコードもClient
- DictionaryClient
- FeedbackGeneratorClient
- FileClient
- LocalDatabaseClient
- LowPowerModeClient
- RemoteNotificationsClient
- ServerConfigClient
- UIApplicationClient
- UserDefaultsClient
Managerはない。
UseCase/Repository/DataSourceで副作用を呼び出す
Google I/O 2019 - 2021 Android Appから
層の解説
- ドメイン層(いわくlightweight domain layer)
- データレイヤーとプレゼンテーションレイヤーの間に位置
- UIスレッドから離れた場所でビジネスロジックの個別部分を処理
- UseCaseクラスを中心に構成されてる
- コールバック地獄を避けるために、LiveData を使用してユースケースの結果を公開
- 2020あたりではコルーチンも使われはじめる
- 2018あたりまではexecuteNowメソッドで同期的なものは直接返したり
- そもそもAndroid Blue Printという設計のお手本リポジトリがあった
- データ層
- リポジトリモジュールは、すべてのデータ操作を処理し、アプリの他の部分からデータソースを抽象化する役割を担う
データ層についての引用
私たちはFirestoreを気に入って使用していましたが、将来的に別のデータソースに交換したくなった場合、このアーキテクチャによってすっきりとした方法で交換することができます)
Google I/O 2019 - 2021 Android AppにおけるUseCase実装
... /** * A [UseCase] that returns the [UserSession]s for a user. */ class LoadUserSessionOneShotUseCase @Inject constructor( private val userEventRepository: DefaultSessionAndUserEventRepository, @IoDispatcher dispatcher: CoroutineDispatcher ) : UseCase<Pair<String, SessionId>, UserSession>(dispatcher) { // suspendさせてコルーチンなメソッドだが override suspend fun execute(parameters: Pair<String, SessionId>): UserSession { val (userId, eventId) = parameters // 実処理は非同期ではなく同期処理 return userEventRepository.getUserSession(userId, eventId) } }
- セッション情報を取得するUseCase
- コルーチンで結果
UserSession
を返してる- 処理は
userEventRepository
のgetUserSession
メソッド
- 同期も非同期もインタフェース揃えるため
suspend fun
でコルーチンだけど、同期的に処理2
SwiftでUseCaseを作る場合を考える
- 同期と非同期二つにUseCaseを分ける(まだコルーチンないし)
- Swiftではassociated typeを持つprotocolを作る
- これを解決する型消去のやり方は複数ある
同期処理するSyncUseCaseの例
public protocol SyncUseCase where Failure: Error { associatedtype Parameters associatedtype Success associatedtype Failure func perform(_ parameters: Parameters) -> Result<Success, Failure> }このSyncUseCaseを直接利用する型でそのまま準拠してもそのまま使うことはできないので型消去するがその話は別に書いてる。
ProfileをSaveするUseCaseを例に
public class SaveProfileUseCase: SyncUseCase { public typealias Parameters = Profile public typealias Success = () public typealias Failure = Error private let dataSource: ProfileLocalDataSource init(dataSource: ProfileLocalDataSource) { self.dataSource = dataSource // initでdataSourceを入れ替えられるように } public func perform(_ parameters: Parameters) -> Result<Success, Failure> { dataSource.save(Profiles: parameters) // 実処理はdataSourceのCore Dataとかでやってる } } import ComposableArchitecture extension SaveProfileListUseCase { static func executeEffect(Profile: Parameters) -> Effect<Success, Failure> { .result { perform(profile) } // Effectにして結果を返す } }
UseCase型の長所と短所
- 長所
- initでDataSourceを入れるので実行時に引数で用意するわけじゃない
- 従来のプログラミングスタイルと同じ感じでできる
- UseCase自体のテストコードを書ける(依存してるDataSourceを置き換えられるし)
- 短所
- 関数型のやり方に従来のオブジェクト指向型のプログラミングスタイルが混ざることの心理的抵抗感
- UseCase型がめちゃくちゃ増える
UseCaseは使わないがRepository/DataSourceを使う
UseCase型を使わずに、しかしReducer以外の副作用の単体テストしたいのでRepository/DataSourceは使う場合を考える。
// プロフィールを変更して上書きする的なState, Action Environmentの集まり enum ProfileSettingCore { enum State { ... } struct Action { ... } struct Environment { // DIできるようにする var save: (Profile, ProfileDataSource) -> Result<(), Error> } static let reducer = ... }
ProfileをSaveする例
// プロフィールを変更して上書きする的なState, Action Environmentの集まり enum ProfileSettingCore { enum State { ... } struct Action { ... } struct Environment { // DIできるようにする var save: (Profile, ProfileDataSource) -> Result<(), Error> } static let reducer = ... } extension ProfileSettingCore { enum SideEffect { static func save(profile: Profile, dataSource: ProfileDataSource) -> Result<(), Error> { dataSource.update(profile) // 実質的なUseCaseの処理 } } }
UseCaseを使わないSideEffectでグルーピングされたstatic関数の長所と短所
- 長所
- シンプル(UseCase型の面倒な型消去なんて考えなくていい)
- SideEffect自体のテストコードを書ける(依存してるDataSourceを置き換えられるし)
- 短所
- 副作用実行の引数で依存するRepository/DataSourceを入れるのはかったるい
まとめ
- 何がベストなのかはチームにあってるやり方を採用したらいい
- pointfree自体のやり方でClientをつくるってのもそれがチームに合っていればそれでいい
- 投稿日:2021-03-21T07:59:55+09:00
Swift初心者がアプリを作れるようになるまで①
開発環境
MacBook Air (2020)
macOS Big Sur Ver11.2.2
Xcode Ver12.4(12D4e)Swiftってなに?
Swiftはappleが開発したオープンソースの言語で、iOS、Mac、Apple TV、Apple Watch向けアプリの開発に用います。
Swift。誰もが圧倒的に優れたアプリを作れる、パワフルなオープンソースの言語です。
iOS、Mac、Apple TV、Apple Watch向けのアプリを開発するためにAppleが作った、
強固で直感的なプログラミング言語。それがSwiftです。デベロッパのみなさんに、かつてないほどの
自由を届けられるように設計されています。Swiftは簡単に使えて、しかもオープンソースなので、
アイデアがある人なら誰でも、画期的なアプリを作ることができます。
(apple公式HPより引用)おおまかに言えばiPhoneのアプリ開発ができる言語ということですね。
iPhoneアプリの開発はかつてはObject-Cがメインでしたが、Swift及びXcode(Swiftを扱えるエディタ)がリリースされてからはSwiftがメインとなっています。
理由としてはSwiftで作ったアプリは、一般的な検索アルゴリズムがこれまでよりも大幅に速く実行できるからなんです。その速さはなんとObject-Cの最大2.6倍、Python2.7の最大8.4倍と驚きの速度。(apple公式HPより引用)
ちなみにSwiftに日本語版は無いので、英語が苦手な方は翻訳を駆使して頑張ってください。
Swiftを扱うのに必要なもの
Swiftでアプリを制作するには、以下の環境が必要となってきます。
・Mac
・Xcode
・AppleID
そう、これだけです。厳密にはリリースに際してAppleディベロッパプログラムへの登録などが必要になるのですが、今回のテーマは「Swift初心者がアプリを作れるようになるまで」ということで、リリース方法はまた別で紹介しようと思います。ひとまず作るだけならMacとXcodeがあればOKです。(Xcodeが無い方はこちらからインストールしてください)Windowsで開発できないの?
気になりますよね、これ。結論から言うと...可能です。ですが2021/3/6現在Swiftパッケージマネージャと、REPLやデバッグエクスペリエンスに使用するlldbの開発が完了しておらず、とりあえずはなにも考えずにMacでの開発をおすすめします。
どんどん情報は更新されているので、気になる方は「Swift Windows」で検索してみてください。Xcodeの設定を知ろう
Xcodeを開くとこのような画面が出てきます。それぞれの説明をすると、
項目 説明 Create a new Xcode project 新規プロジェクト作成 Clone an existing project 既存プロジェクトのクローン Open a project or file 既存プロジェクトのオープン ...そのまんまですね。
ちなみに下のチェックボックス「Show this window when Xcode launches」のチェックを外すと次回からこのメニュー画面が表示されなくなるので、ひとまず触らなくてOkです。今回は新規作成なのでもちろん「Create a new Xcode project」(赤枠)を選択します。
するとこちらの画面になるはずなので、iOSタブの「App」を選択します。
他にもたくさんアイコンが並んでいると思いますが、これらは最初に組まれる雛形が違うだけでいくらでもカスタマイズができるので、あまり難しく考えずに自身が作りたいものに最も近いものを選べばいいと思います。
今回はひとまず「App」で進めていきます。
すると次はこちらの画面になるので、「Product Name」に今回のプロダクト名を入力します。今回はとりあえず「Sample」としておきます。
以下、他の項目については以下のような感じです。
項目 説明 Product Name プロダクト名、アプリ名 Team プロダクトを作成する団体名(もしくは個人名) Organization Identifier プロダクトを作成する団体のID(もしくは個人) Bundle Identifier 今回のID(Organization Identifier + . + Product Name) Interface 実装方法の設定(SwiftUI, StoryBoardから選択) Life Cycle ライフサイクルの選択 Languages 使用するプログラミング言語(Swift、 Objective-Cから選択) 上半分に関しては自身で設定すれば良いのですが、下部に関しては少し知識が必要かもしれません。
InterFaceについて
SwiftのインターフェイスはSwiftUI, StoryBoardの2つとなっていますが、これらの違いはコードでUIを実装するか、GUIでUIを実装するか。といったところです。
以前まではStoryBoard, Xib, UIKitの3つが存在していたのですが、iOS13にてSwiftUIが追加されました。
どれを選択するかは一概には言えず、アプリの特徴や開発者の慣れなどで考えるのがいいと思います。
ひとまず今回はサンプルを動かすだけなので、何も考えずにSwiftUIを選択します。Life Cycleについて
Life Cycle(ライフサイクル)ってなんだ...わかります。その気持ち。知ってた方は飛ばしてちゃってください。
私もSwift学習を開始してから知ったので、知らなくても全く問題ないです。
ライフサイクルはアプリ内の状態遷移を表す言葉です。...どゆこと。
実際にiPhoneでアプリを使用するときを想像してみてください。
1.アイコンをタップする
2.画面が起動画面に切り替わる
3.画面がアプリ画面に切り替わる
4.画面のみ閉じるとアプリがバックグラウンドへ
5.再度起動すると前回使用画面から起動
6.タスクキルでアプリ終了
こういった一連の流れでアプリ側が保持している状態をライフサイクルといいます。
だいぶ説明を省いているので詳しく知りたい方は「アプリ ライフサイクル」で検索してみてください。今回は「SwiftUI App」でOKです。
Languageについて
SwiftとObjectiv-Cからの選択です。(上記InterFaceでSwiftUIを選択している場合Swiftのみ選択可能)
こちらは使用言語についてです。
SwiftとObjective-Cの選択に関しては、一概には言えませんがSwiftの方が新しいことや、Swiftの方がAppleがSwiftを推奨していることからざっくりとSwiftの方が良いとされています。
ちゃんとした理由を知りたい方はご自身で調べることをおすすめします。下部の選択項目について
上記の項目の下に写真のような選択項目があるのですが、これらに関しては一応軽い説明を載せますが、今回は全て選択しなくて大丈夫です。
Use Core Data / Host in CloudKit
「Use Core Data」には名前の通りCore Dataというものを使用するかどうかの選択です。
Core DataというのはざっくりXcode上からデータベース構造の設定を行えたり、使用するデータの保存、削除、更新を行うプログラムを書くための仕組みのことです。
こちらに関しても詳細はご自身で調べてください。(ちゃんと説明するとそれだけで1記事分になってしまうので...)
「Host in CloudKit」に関してですが、これはCloudKitを理解することから始まります。
簡単に言えばiCloudにデータを保存できるサービスです。AppleサポートのCloudKitのページを見てみると、CloudKitを使用するとキー値データ、構造化データ、および各種アセットをiCloudに保存できるようです。そして公開データベースと非公開データベースの両方に対応しているので、プライベートなデータ、パブリックなデータ両方扱えることになります。例えばAppleIDを使用してプライベートなデータを管理することも可能です。これはiOS標準アプリの「メモ」などにも使用されており、iPhoneでメモに何か保存すると、AppleIDで同期されているmacなどのメモにも表示されるようになります。これはCloudKitによって管理されていたんです。Include Tests
続いて「Include Tests」についてです。これに関しては単純で、チェックを入れることでテスト用のターゲットとテスト用のコードが生成されます。なのでとりあえずはチェックを入れないで大丈夫です。
これですべての設定を終えたので、右下のNextボタンを押して次に進みます。
まとめ
ひとまず今回はここまでです。
xCodeは日本語版が無いということで設定内容を理解することだけで一苦労ですね...。
次回からはサンプルコードを使用してエディター内の項目解説に移るので、更新しましたらぜひ「Swift初心者がアプリを作れるようになるまで②」もご覧ください。最後までお読みいただきありがとうございました!
- 投稿日:2021-03-21T01:42:47+09:00
SwiftUIでフルスクリーンのモーダルを表示する(iOS13)
SwiftUI(iOS13)でフルスクリーンモーダルを表示する
久しぶりの記事になります。おはこんばんにちは和尚です!
今日は一昨年Appleから発表されて話題となっているSwiftUIについての記事を書いていきたいと思いますSwiftUI 2はiOS13では使用できない件
現在(2021.3月時点)ではSwiftUIはver.2までリリースされておりますが、残念なことにSwiftUI 2でリリースされた機能はiOS13で使用することができません。SwiftUI 1では一般的なアプリに必要な機能が全然揃っておらず、2以上にライブラリやUIKitに頼ることになります。
必須級ライブラリ
SwiftUIX
↑ SwiftUIにまだ実装されていないものを補ってくれます。説明書がないものがチラホラあるのが残念...さて、本題であるフルスクリーンのモーダルですが、公式からは「.fullScreenCover」というものが用意されていますがこちらはSwiftUI 2から登場したものとなっており残念ながらiOS13では使用することができません。
ので、今回iOS13でも使えるフルスクリーンのモーダルを作っていきましょう!※ちなみに筆者、初めてのiOSアプリ実装がSwiftUIとなっておりUIKitも現在絶賛勉強中ですのでお手柔らかに(笑)
フルスクリーンのモーダル実装
1. UIApplicationの拡張
UIApplicationを拡張して一番上のコントローラーを取得するメソッドと、フルスクリーンのモーダルを閉じるメソッドを作成していきます。
UIApplication+Extension.swiftextension UIApplication { /// 一番上にあるコントローラーを取得する public func getTopViewController() -> UIViewController? { guard let window = UIApplication.shared .connectedScenes .filter({$0.activationState == .foregroundActive}) .map({$0 as? UIWindowScene}) .compactMap({$0}) .first?.windows.first else { return nil } window.makeKeyAndVisible() guard let rootViewController = window.rootViewController else { return nil } var topController = rootViewController while let newTopController = topController.presentedViewController { topController = newTopController } return topController } /// フルスクリーンのモーダルを閉じる public func closeModalView() { UIApplication.shared.getTopViewController()?.dismiss(animated: true, completion: nil) } }2. Viewの拡張
次はSwiftUIのViewでSheetViewやFullCreenCoverのようにフルスクリーンのモーダルが使用できるようにViewを拡張していきます。
View+Extension.swiftextension View { public func fullScreenView<Content>( isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> Content ) -> some View where Content: View { if isPresented.wrappedValue { let window = UIApplication.shared.windows.last window?.isHidden = true let view = content() let viewController = UIHostingController(rootView: view) viewController.modalPresentationStyle = .fullScreen DispatchQueue.main.async { guard let tvc = UIApplication.shared.getTopViewController() else { return } tvc.present(viewController, animated: true, completion: nil) isPresented.wrappedValue = false } } return self } }実際に使ってみよう!
実際にViewで使用してみましょう!今回適当なサンプルViewを用意してみました!
こちら参考にみなさん是非使ってみてくださいContentView.swiftstruct ContentView: View { @State private var showModal: Bool = false var body: some View { VStack { Spacer() Button(action: { showModal.toggle() }, label: { Text("フルスクリーンのモーダルを表示する") }) Spacer() } .fullScreenView(isPresented: $showModal) { ModalView() } } } struct ModalView: View { var body: some View { ZStack { Color.green.edgesIgnoringSafeArea(.all) Button(action: { UIApplication.shared.closeModalView() }, label: { Text("閉じる") }) } } }最後に
iOS13でSwiftUIとCombineを使用する際は、特にiOS13.0からiOS13.2までは気をつけましょう。バグがかなり多いです...
しかも、たちが悪いことにシュミレーションでは再現せず実機のみで再現するバグもいくつかあります。実機がない場合は、SwiftUIをiOS13で使用することをおすすめしません
それでもiOS13を含めたい場合は最低でもiOS13.3以上にしましょう。Let's Enjoy SwiftUI!!