- 投稿日:2019-12-13T18:58:29+09:00
「~フォルダ内のファイルにアクセスしようとしています」のダイアログが表示されない場合の解決方法
概要
- macOS10.15でファイルやOSの機能にアクセスする際のセキュリティが厳しくなりました。
- 例えば、デスクトップ上のフォルダにアクセスしようとすると、下記のような許可を求めるダイアログが表示されます。
- しかしプログラムを書いている中で、許可を求めるダイアログがでないケースが2つあったので、以下で紹介します。
解決方法1. セキュリティとプライバシーの設定に情報が登録済みでないかを確認
- 一度「~フォルダ内のファイルにアクセスしようとしています」のダイアログで回答すると、下記の場所に情報が記録されます。
- ONにすればアクセスを許可できますが、「~フォルダ内のファイルにアクセスしようとしています」の表示は以後でなくなってしまいます。
- 再度出したい場合は情報をターミナルからリセットする必要があります。例えば以下の通りです。
tccutil reset <サービス名> <バンドルID>
- 詳しくは、セキュリティとプライバシーの設定値をリセットする(macOS 10.15 Catalina)を参照ください。
tccutil reset SystemPolicyDesktopFolder jp.co.hikeuchi.AccessConfirmSample解決方法2. スクリーンセーバーレベルのウィンドウが悪さをしている場合
- WindowLevelが
kCGScreenSaverWindowLevel
のウィンドウがあるとき、アクセス許可画面は表示されないので、WindowLevelを変更してやる必要があります。参考
GitHub
Code
- 簡単に下記を実装
- 選択したウィンドウのレベルに変える
- デスクトップファイルの読み込み
@IBAction func windowLevelChanged(_ sender: NSPopUpButton) { switch sender.selectedTag() { case 0: window.level = NSWindow.Level(rawValue: NSWindow.Level.RawValue(kCGNormalWindowLevel)) case 1: window.level = NSWindow.Level(rawValue: NSWindow.Level.RawValue(kCGModalPanelWindowLevel)) case 2: window.level = NSWindow.Level(rawValue: NSWindow.Level.RawValue(kCGScreenSaverWindowLevel)) default: window.level = NSWindow.Level(rawValue: NSWindow.Level.RawValue(kCGNormalWindowLevel)) } } @IBAction func openButtonClicked(_ sender: Any) { let fileData: Data? do { let fileUrl = URL(fileURLWithPath: "/Users/ikeh/Desktop/sample.txt") fileData = try Data(contentsOf:fileUrl) } catch { // ファイルデータの取得でエラーの場合 fileData = nil } print("\(String(describing: fileData))") }動作確認
- WindowLevelが
kCGScreenSaverWindowLevel
のウィンドウがあるとき、アクセス許可画面は表示されません- スクリーンセーバーのレベルのウィンドウが、アクセス許可画面の表示を妨げているような動作が見られます。
- その他のウィンドウレベルでは問題なく表示されることが確認できる
- 投稿日:2019-12-13T18:38:05+09:00
fastlaneでまず始めに使いたいアクション
はじめに
本記事はVolare Advent Calendar 2019の3記事目として書いています。
私は今年の4月から社会人として、主にiOSアプリの開発を行っていますが、今回はVolare卒業1期生(?)として寄稿させていただきます!fastlaneの導入方法やリリースの自動化などについては、既にたくさんの記事があるので、
今回はアプリの個人開発を行っている方や、今までfastlaneを知らなかった方でも恩恵を受けられそうな、3つのfastlaneのアクションについて、その挙動や実行方法などをまとめたいと思います。fastlaneとは
fastlaneは、iOS及びAndroidアプリのビルドやリリースを自動化してくれる、ビルドツールです。
無料で使用することができ、OSSでドキュメントも整備されているため、手軽に導入できます。
無料です。Fastfileにlaneと呼ばれるアクションの組み合わせを記述し、CUI上やCIツールなどから実行することで、様々な処理を自動化することができます。
今回はこのアクションについて、特に便利でよく使いそうなアクションを挙げていきたいと思います。アクション
gym
(引用:https://docs.fastlane.tools/actions/gym/)
![]()
![]()
![]()
gymアクションは、アプリのビルドと、ストア申請やTestFlightへのアップロードのために必要な、ipaファイルの作成を行えます。
これ、XCodeからやるとめちゃくちゃ時間かかりますよね…パラメータ
gymアクションには公式ドキュメントにあるように、様々なパラメータを渡すことができます。
私は主に、
キー名 役割 設定値 scheme アプリのスキームを指定する "[アプリ名]" configuration アプリをビルドする際の構成を指定する "Release" or "Debug" outpu-name ipaファイルの書き出し名を指定する "[アプリ名].ipa" の3つを指定しています。
gym( scheme: "MyApp", configuration: "Release", output_name: "MyApp.ipa" )このような感じですね。
pilot
(引用: https://docs.fastlane.tools/actions/pilot/)
![]()
![]()
![]()
pilotアクションは、AppleのTestFlightにアプリをアップロードしたり、テスターに配布したり、テスターの管理などができます。
テスターの追加・削除
テスターを追加したい場合は、CUI上で
fastlane pilot add [追加したいテスターのemailアドレス]と打つことで実行可能です。同様に削除したい場合も、
fastlane pilot remove [削除したいテスターのemailアドレス]のコマンドを実行するだけで、対象のテスターを綺麗サッパリ消すことが可能です。
ベータ版の配布
パラメータでipaファイルを指定し、pilotアクションを実行するだけでTestFlightへのアップロードまで行えます。
pilot( ipa: "./MyApp.ipa" )deliver
(引用:https://docs.fastlane.tools/actions/deliver/)
![]()
![]()
← UberEats
deliverアクションはアプリのリリースを自動化してくれるアクションです。XCodeを使わずに、新しい.ipaファイルをApp Store Connectにアップロードすることが可能です。
その際にprefetch機能が実行されることで、アプリがリジェクトされる危険性を含んでいないかを、事前にある程度確認もしてくれます。関連ファイル
App Storeへのアプリの提出には、スクリーンショットやリリースノート、アプリの説明文など様々な要素が必要となります。
そしてfastlaneは、これらのファイルもまとめて管理することができます。
具体的には、
./fastlane/screenshots
以下でスクリーンショットを
./fastlane/metadata/ja/release_notes.txt
でリリースノートを
./fastlane/metadata/ja/description.txt
でアプリ概要の文章をそれぞれ変更、修正できます。パラメータ
他のアクションと同様に、deliverアクションにも様々なパラメータを渡すことができます。
特に使用頻度の高そうなものとしては、
キー名 役割 設定値 force アプリ申請時のHTMLレポートファイルの検証をスキップ true submit_for_review アプリのアップロード後、審査用に新しい新しいバージョンを送信 true automatic_release アプリの審査通過後、自動的にリリースする true 等が挙げられます。実際に書くと、
deliver( force: true, submit_for_review: true, automatic_release: true )のような形ですね。
いざ実行
実際にこれらのアクションを使用する場合、CUI上で1つずつコマンドを叩いてもよいのですが、多くの場合Fastfileにレーンを作って、実行します。
レーンとは、複数のアクションを組み合わせたものです。
lane [:レーンの名前] do
とend
の間に実行したいアクションを書いていきます。具体例を示すと、
Fastfile.lane :testFlight do # ビルド gym( scheme: "MyApp", configuration: "Release", output_name: "MyApp.ipa", ) # TestFlightへアップロード pilot( ipa: "./MyApp.ipa" ) end lane :release do # ビルド gym( scheme: "MyApp", configuration: "Release", output_name: "MyApp.ipa", ) # App Store Connectへアプリ提出, リリース deliver( force: true, submit_for_review: true, automatic_release: true ) endのような形になります。
あとは、ベータ版の配布を行う場合は
bundle exec fastlane testFlight
アプリのリリースを行う場合は
bundle exec fastlane release
のように、作成したレーンを指定したコマンドをCUI上で実行すれば、自動化が行なえます。おわりに
今回はfastlaneでまず始めに使いたいアクションとして、
- gym
- pilot
- deliver
の3つを挙げ、それぞれのアクションの動作と実行方法を軽くまとめてみました。fastlaneにはこれらのアクションの他にも、証明書やプロファイルを生成、同期、管理できるmatch、pem、certといったアクションや、テストを自動化できるscan、スクリーンショットを自動生成することができるsnapshotなど、まだまだたくさんのアクションが用意されています。
この記事をきっかけにアプリ開発の自動化について興味を持ってくださった方は、ぜひ公式ドキュメントなどを参考に、他のアクションも使ってみてください!
参考
fastlane公式
fastlaneを導入する手順について
fastlaneを使ってみる
これから始めるfastlane
- 投稿日:2019-12-13T12:32:47+09:00
【プロパティ・ラッパー】基本を理解する
この投稿は何か?
Apple Swift Language GuideにあるProperty Wrappersに基づき、プロパティ・ラッパーの基本を理解します。
実行環境
macOS Catalina 10.15.2
Xcode11.3
Swift5.1ハンズオン
小さな長方形
コードで、各辺の長さが
12
以下である小さな長方形を表現します。
2つのプロパティheight
とwidth
を持つ構造体です。struct SmallRectangle { private var _height = 0 private var _width = 0 var height: Int { get { return _height } set { _height = min(newValue, 12) } } var width: Int { get { return _width } set { _width = min(newValue, 12) } } }高さも幅も、計算プロパティを利用して、プロパティに
12
より大きい値が割り当てられた場合だけ、12
を返すようにしています。
実際に、インスタンスを生成してみます。小さな長方形インスタンスvar rectangle = SmallRectangle() rectangle.height = 9 rectangle.height // height is 9.長方形の高さは
9
になりました。
次に、長方形の高さを15
に変更してみます。rectangle.height = 15 rectangle.height // height was assigned 15, but now is 12.割り当てようとした高さが大きすぎたので、計算プロパティによって
12
が割り当てられました。コードを分離する
では、次に「小さな円」や「小さな三角形」の構造体を定義する必要があった場合はどうでしょう?
いずれも単純な構造体ですが、SmallRectangle
構造体と同じような記述を繰り返す必要があります。
繰り返している部分を抽出して、別の構造体に定義します。12以下の数値を返す構造体struct TwelveOrLess { private var number = 0 var wrappedValue: Int { get { return number } set { number = min(newValue, 12) } } }
TwelveOrLess
構造体は割り当てた数値が12
より大きかった場合、12
を返します。
このTwelveOrLess
構造体を利用して、各辺のサイズが12
以下の長方形を表現する構造体を定義します。小さな長方形を表現する構造体struct AnotherSmallRectangle { private var _height = TwelveOrLess() private var _width = TwelveOrLess() var height: Int { get { return _height.wrappedValue } set { _height.wrappedValue = newValue } } var width: Int { get { return _width.wrappedValue } set { _width.wrappedValue = newValue } } }
SmallRectangle
とAnotherSmallRectangle
に大した違いはありませんが、12以下を返す部分のコードを再利用できるようになりました。
「直径または半径が12
以下の円」や「高さが12
以下の三角形」の構造体を定義する際に、TwelveOrLess
構造体を再利用できます。@propertyWrapper
ここで、プロパティ・ラッパーを使用すると、コードを簡略化できます。
プロパティ・ラッパーを使用するには、TwelveOrLess
構造体の定義直前に@propertyWrapper
キーワードを記述します。@propertyWrapper // add keyword struct TwelveOrLess { private var number = 0 var wrappedValue: Int { get { return number } set { number = min(newValue, 12) } } }プロパティがラップされた長方形の構造体は、次のように定義されます。
プロパティの直前に@TwelveOrLess
属性を指定します。プロパティをラップした構造体struct SmallRectangleWithPropertyWrapper { @TwelveOrLess var height: Int @TwelveOrLess var width: Int }すごくシンプルに記述できるようになりました。
同じように動作します。var simpleRectangle = SmallRectangleWithPropertyWrapper() simpleRectangle.height = 7 simpleRectangle.height // height is 7 simpleRectangle.height = 17 simpleRectangle.height // height is 12ただ、Playgroundで実行している限りですが、動作が不安定で、以下のようなエラーが発生することがあります。
error: MyPlayground.playground:58:17: error: 'height' is inaccessible due to 'internal' protection level
原因が思い当たらないのですが、次のように修正すると確実に動作するようになります。
修正struct SmallRectangleWithPropertyWrapper { @TwelveOrLess public var height: Int // add public keyword @TwelveOrLess public var width: Int // add public keyword }上記、エラーの原因と修正対応について、みなさまどう思われるでしょうか?
ぜひ、お考えなどをお聞かせください。
- 投稿日:2019-12-13T12:04:02+09:00
SwiftGenを使ってみる
初めに
文字列とかを管理するのにSwiftGenというものがあるらしいので使ってみました。
導入
まず最初にcocoaPodsを使ってプロジェクトに読み込みます。
1. pod init
2. pod 'SwiftGen'をpodfileに書く
3. pod install実際に使ってみる
まずプロジェクト直下にswiftgen.ymlファイルを追加して以下の設定を追加します
swiftgen.ymlstrings: inputs: Localizable.strings outputs: templateName: structured-swift4 output: Project名/strings.swift上の設定ではLocalizable.stringsからProject/strings.swiftを生成するというものです。
今回は文字列を管理するのでstrings:をしています。
次にLocalizable.stringsを作ります。
xcodeのFile -> New -> Fileの順に選んでstringsファイルを作ります。
作れたらとりあえず中身を記述します。Localizable.strings"name"="defaultname"; "labelText"="ウヒョーーーー";こんな感じです。
それでは最後にTerminalでPods/SwiftGen/bin/swiftgenを実行すると
strings.swiftというファイルができていると思います。strings.swiftimport Foundation // swiftlint:disable superfluous_disable_command // swiftlint:disable file_length // MARK: - Strings // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name internal enum L10n { /// ウヒョーーーー internal static let labelText = L10n.tr("Localizable", "labelText") /// defaultname internal static let name = L10n.tr("Localizable", "name") } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name // MARK: - Implementation Details extension L10n { private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { // swiftlint:disable:next nslocalizedstring_key let format = NSLocalizedString(key, tableName: table, bundle: Bundle(for: BundleToken.self), comment: "") return String(format: format, locale: Locale.current, arguments: args) } } private final class BundleToken {}これができているともう文字列が使えるようになっています。
試しにViewControllerで使ってみると
こんな感じで文字列を参照できるようになっています。最後に
他にも色々管理できるみたいなので詳しくは公式ドキュメントをみましょう
https://github.com/SwiftGen/SwiftGen
- 投稿日:2019-12-13T07:47:26+09:00
SwiftのExtensionをより分かりやすく -TargetedExtension-
はじめに
本記事はCyberAgent 20新卒 Advent Calendar 2019の13日目の記事です!
もうすこしでクリスマスですね??// TODO: 追記します!
- 昨日の投稿は, Ayumuくんの「Lottieを使ったAndroidのアニメーション実装」でした!
- そして, 14日目の投稿はdragon_taroくんの「」です!
ぜひこちらもご覧ください!!本日(アドベントカレンダー13日目)の記事は, よだだよ!or ヨーダが担当します!
普段から触っている技術はiOSで, 最近は研究や受託開発等でARKitを使うことが多いです.しかし,
今回はSwiftExtensionの記事を書こうと思います!※実は, 「ARKitを使って, クリスマスぽいお絵かきARアプリを作ってみよう!??」というタイトルで書こうと思ってましたが, 間に合わなそうなのでテーマを急遽変更しました!
時間のあるときにでも, お絵かきAR作ってみる記事は書こうと思います.SwiftのExtensionとは...
- クラスや構造体, 列挙体の拡張
- プロトコル拡張(デフォルト実装)
- クラスや構造体, 列挙体をプロトコルに適合させる
Extension+String.swiftextension String { // 追加するメソッド等 func printHoge() { print("hoge") } } // 呼び出し String().printHoge()↑例えばこのように拡張することはあるかと思いますが,
もとからあるのもなのか, それとも拡張され追加されたものなのかを判別するのは分からないと言う問題があります.そこで, 拡張され追加されたものには, プレフィックスを付けます.
そのようにすることによって, 拡張されたものであることが明示的になると同時に, プロパティの衝突も避けることができます.では, ここから実際にTargetedExtensionを実装していきます!
Extensions実装
今回は例として, FileManagerを拡張してみたいと思います!
ちなみに, このメソッドは指定パスにあるファイルを一覧を表示するものです.下のものは, 単純にFileManagerを拡張したものになります.
FileManager+Extensions.swiftimport Foundation public extension FileManager { /// List local files (specified path) func listupLocalFiles(path: String) { do { // Get all file name strings in path let files = try FileManager().contentsOfDirectory(atPath: path) // Get file name as String array print(files) } catch let error { // Error : If path doesn't exist print(error) } } } // Usage: Temp Directory let fm = FileManager() fm.listupLocalFiles(path: NSTemporaryDirectory())普通に拡張すると,
fm.listupLocalFiles(path: NSTemporaryDirectory())
になり,
最初に挙げたように, 元からあるものなのか, 追加されたものなのかが分かりません.Targeted Extensionsで実装
FileManager+Extensions.swiftimport Foundation public protocol FileManagerComapatible { associatedtype CompatibleType var ex: CompatibleType { get } } public final class FileManagerExtension<Base> { private let base: Base public init(_ base: Base) { self.base = base } } public extension FileManagerComapatible { var ex: FileManagerExtension<Self> { return FileManagerExtension(self) } } extension FileManager: FileManagerComapatible { } extension FileManagerExtension where Base == FileManager { /// ローカルファイル(指定パス)のリストアップ func listupLocalFiles(path: String) { do { // Get all file name strings in path let files = try FileManager().contentsOfDirectory(atPath: path) // Get file name as String array print(files) } catch let error { // Error : If path doesn't exist print(error) } } } // Usage: // let fm = FileManager() // fm.ex.listupLocalFiles(path: NSTemporaryDirectory())TargetedExtension呼ばれる形で実装することで,
fm.ex.listupLocalFiles(path: NSTemporaryDirectory())
といった形にすることができます.メソッドの前に,
ex
を挟むことで, 「これは追加されたもの」なんだと理解することができます!// TODO: コードの説明の追加
まとめ
- TargetedExtension呼ばれるにすることで, Extensionであることが明示的になる
- また, 名前の衝突も避けることができるというメリットが生まれます.
※後で, #TargetedExtension部分は, コードの説明を交えて丁寧なものにしたいと思います.
参考
- モダンなSwiftのExtensionについて - Targeted Extensions
- モダンなSwiftのExtension(TargetedExtensions)を実装するときハマったところ
- Swiftのextensionは3パターンだけ〜そして条件付き適合へ・・・〜
告知
今年もサイバーエージェントの本選考がやってきたみたいです!
興味ある方は, 以下のURLからチェックしてみてください!!
https://www.cyberagent.co.jp/careers/special/engineer2021/
- 早期選考で内定となった場合, 長期実務インターンをすることができます!
- #cypitch をつけてTwitterに呟くと, それが会社説明資料になるみたいです!
僕自身がCyberAgentを選んだ理由はたくさんありますが,
その1つとして「人の良さ」です!!
「人の良さ」ってのを言葉で正確に伝えるのは難しいので, Zehi社員さんに会ってみてください!(勿論, 内定者でも大丈夫です!)
きっとこの言葉の意味が感じられるような気がします!最後まで読んでくださり, ありがとうございました?♂️
- 投稿日:2019-12-13T07:35:57+09:00
【iOS】サンプルデータ、どうやってアプリに入れますか
はじめに
Releaseビルドには要らないけど、開発中には使いたいサンプルデータ。このサンプルデータ、どうやってアプリに入れますか?
まず、想定しているサンプルデータの使い方を以下に挙げます。
- 主にUIの開発に用いる。UIを作るためにも表示するデータがほしい
- 多言語対応したデータを用意したい。AppStoreのスクショにも使う
![]()
- 開発中は全消しして入れ直すことは多々あるので楽な方法で入れたい
- テスト用のテストデータのようにパターンを網羅したいわけではない
どうやって入れますか?
1. 手打ちする
入力UIがあるアプリなら都度手打ちして入れるのもありだと思います。少量データならこれで事足りることも。ただ、データ量が多くなるとやはり辛いです。
2. ハードコードする
サーバができていない、仕様もFixしていない、主機能の開発に集中したいなどの理由により、少なくとも開発初期段階はこれで進めることが多いと思います。
// ハードコード! let user = User(name: "Taro Yamada", // 名前 country: "Japan", // 国名 age: 30) // 年齢開発が進んだ段階では、もう少し何とかしたくなります。ソースコード中にゴミが書かれているのは嫌ですよね?
また、アプリが成長してくると、ビルド時間が長くなってくることがあります。サンプルデータをちょっと修正して再ビルドはストレスに感じるかもしれません。3. フェイクデータ生成ライブラリを使う
ランダムなデータで良ければ、Fakeryのようなフェイクデータ生成ライブラリを使うのもありです。ゴミデータがソースコード中に直に書かれているということは避けれますし、実行時にデータが生成されるため、再ビルドの必要もありません。
// Fakeryの使用例 let faker = Faker() let user = User(name: faker.name.name(), // 名前 country: faker.address.country(), // 国名 age: faker.number.randomInt(min: 20, max: 80)) // 年齢4. サーバからダウンロードする
Debugビルド時は開発サーバからデータをダウンロードする感じです。サーバを用意したり、ダウンローダーを書いたりとやることが多いですが、Release版に近い形で作れます。サーバ連携するアプリならきっと通る道です。
ただ、作業分担して開発しているとき、自分でサーバを触れるならば良いですが、そうでない場合はサーバ担当者にお願いする必要があり少々面倒です。
あと当たり前ですが、オンラインじゃないと使えません。
経験談ですが、客先でデモするとき、通信環境が必ずしも良好とは限りません。ロードに時間がかかっていると、けっこう気不味い空気になります…。デモ時はオフラインでもサクサク動かせるようにしておきたいですね。5. Mac・iOS間のファイル共有を利用する
Mac PCとiOS端末を直結してアプリにサンプルデータファイルを直接入れます。アプリはファイル読み込みしてデータをよしなに使えるように実装します。
最近、僕はこの方法をよく使うので紹介します。
実装方法
Info.plistに以下のプロパティを追加します。
Key Type Value Application supports iTunes file sharing Boolean YES このプロパティをYESをに設定したアプリは、Mac PCとiOS端末をケーブルで接続すると、FinderからアプリのDocumentsディレクトリが見れるようになります。(macOS 10.15より前はiTunes経由で見れましたが、10.15からはより直感的になりました)
ドラッグ&ドロップでファイルの転送ができるため、あとはアプリ側でDocumentsディレクトリ内の指定ファイルを読み込んでゴニョゴニョすればOKです。なんとでも書けてしまうわけですが、僕はサンプルデータ読込み用のクラスを作っています。ソースコードはこちら↓
https://github.com/HituziANDO/SampleData/blob/master/SampleDataApp/Util/SampleData.swiftこのクラスはDocumentsディレクトリ直下にsampledataディレクトリを配置し、その専用ディレクトリ内のファイルを読み込みます。ファイル形式はCodable+JSONDecoderの使い勝手が良いためJSONを扱います。
例えば、以下のようなuser.jsonファイルをsampledataディレクトリ直下に投入します。なお、シミュレータの場合は~/Library/Developer/CoreSimulator/Devices/{UUID}/data/Containers/Data/Application/{UUID}/Documents/sampledata
ディレクトリ配下にファイルを置いてください。(macOS 10.15.1, Xcode 11.2.1で確認)user.json{ "name": "山田 太郎", "country": "日本", "age": 30 }Userモデルは以下の通りです。
User.swiftstruct User: Codable { var name = "" var country = "" var age = 0 }読み込み方は以下の通りです。ファイル名と型を指定します。他にも重複して読み込まないようにするロックオプションなどがありますが、詳しくはソースコードを読んで頂ければと思います。
if let user = SampleData.default.import(dataOfFile: "user.json", ofType: User.self) { textView.text = """ User: name: \(user.name) country: \(user.country) age: \(user.age) """ }もし英語版のサンプルデータを入れたい時はJSONファイルを差し替えてアプリを起動すればOKです。ビルドにサンプルデータを含めているわけではないため、差し替え時に再ビルドの必要はありません。
user.json(英語版){ "name": "Brad Pitt", "country": "USA", "age": 55 }注意点
Documentsディレクトリがユーザに見える状態になるため、
- Releaseビルド時は
Application supports iTunes file sharing
をNOに設定する- ユーザに見られたくないファイルはLibraryディレクトリ配下に保存する
などセキュリティホールにならないようにしましょう。
サンプルアプリをGitHubにアップしておきましたので、ご参考にどうぞ。
https://github.com/HituziANDO/SampleDataまとめ
サンプルデータの入れ方について考察してみました。以下にメリット・デメリットをまとめておきます。
方法 メリット デメリット 手打ち 簡単 ・入力頻度、入力データが多いと辛い
・そもそも入力UIが必要ハードコード 簡単 ・ソースコードが汚くなる
・サンプルデータの差し替え時は再ビルドが必要フェイクデータ生成ライブラリ ・サンプルデータの生成が容易
・実行時にデータ生成するため、再ビルドが不要固定データがほしいときは使えない サーバからダウンロード ・サーバ連携するアプリならRelease版に近い形で作れる
・サーバ上でサンプルデータの差し替えが可能なため、アプリは再ビルドが不要・コストが高い
・オフラインでは使えないMac・iOS間のファイル共有 ・仕組みを実装した後はお手軽
・オフラインでファイルの差し替えが可能なため、再ビルドが不要Documentsディレクトリがユーザに見える状態になるので注意が必要
- 投稿日:2019-12-13T02:04:40+09:00
僕のワンダフルライフ 〜ARのAI犬が友達〜
この記事は NewsPicks Advent Calendar 2019 の13日目の記事です。
はじめに
いらっしゃいませ、NewsPicksでエンジニアをしているkohei1218です。
突然ですが僕のワンダフルライフという映画をご存知でしょうか?
最近では続編の僕のワンダフル・ジャーニーも有名でした。
犬と飼い主の絆の物語なのですが、これがまあ泣ける映画で試写会では満足度100%という驚異的な数値を叩き出しました。
劇中では犬の健気な飼い主を愛する気持ちがセリフとなり、伝わってきます。犬好きな自分はよりこう思うわけです、犬飼いたいと。犬と話したいと。
しかし、私が住む築20年のアパートはそんなことを許してくれるはずがありません。だったらARで犬を表示して、さらに会話をできるようにしましょう。要件
ARで犬を表示する
→ARKitでいけそう音声入力で犬に話しかける
→SFSpeechRecognizerでいけそうAIで返事をする
→chat系のAPIがあればできそうその返事を読み上げる
→AVSpeechSynthesizerでいけそうあれ、これ1日でいける!?
ということで早速実装していきます。
ARで犬を表示する
まず3Dデータを準備します。
.obj
か.dae
のファイルをXcodeで.scn
に変えてあげる必要があります。Free 3D
こちらのサイトにフリーのいい感じの3Dファイルがたくさんあるので好きなものを選びましょう。今回はもちろん犬です。準備ができたら
New File
からSceneKit Catalog
を選択します。
この中にさきほどダウンロードしてきた
.obj
ファイルたちを入れてあげます。
そうしたら
obj
にカーソルを合わせてEditor
からConvert to SceneKit file format
を選択して.scn
ファイルに変換しましょう。
すると
.scn
ファイルが作られます。中身をみていくと
とんでもない向きになってますね。可愛いうちのわんちゃんを地面にめり込ませるわけにはいかないので向きを変更しましょう。3Dのオブジェクトを選択して
Node Inspector
のtransform
で向きを調整します。
うん、可愛い。名前付けたい
ニンテンドッグスとかにはまっちゃう私としてはたまりません。
早速このわんちゃんをARで表示していきます。ARを表示するための
ARSCNView
をviewにおき、シーンのセットとデバッグ情報を表示できるようにします。ViewController.swift@IBOutlet weak var sceneView: ARSCNView! override func viewDidLoad() { super.viewDidLoad() let scene = SCNScene() sceneView.scene = scene sceneView.delegate = self sceneView.showsStatistics = true sceneView.debugOptions = ARSCNDebugOptions.showFeaturePoints }次にviewが表示されたらconfigurationを設定します。今回は水平な面にわんちゃんをおきたいので水平を検知する設定をいれます。
ViewController.swiftoverride func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let configuration = ARWorldTrackingConfiguration() configuration.planeDetection = .horizontal sceneView.session.run(configuration) }これで水平な面が検知されたらdelegateで受け取ることができるのでそこでわんちゃんを置いていきましょう。
ViewController.swiftextension ViewController: ARSCNViewDelegate { func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { guard let planeAnchor = anchor as? ARPlaneAnchor else { fatalError() } guard let scene = SCNScene(named: "dog.scn", inDirectory: "art.scnassets/dog") else { fatalError() } guard let catNode = scene.rootNode.childNode(withName: "Dog", recursively: true) else { fatalError() } let magnification = 0.005 catNode.scale = SCNVector3(magnification, magnification, magnification) catNode.position = SCNVector3(planeAnchor.center.x, 0, planeAnchor.center.z) DispatchQueue.main.async(execute: { node.addChildNode(catNode) }) } }かんわぃぃぃぃ
ペット禁止の我が家についにわんちゃんが現れました。
可愛すぎてさわれないのが惜しいですがこのままやっていきましょう。
次はこのわんちゃんに話しかけられるようにします。音声入力で犬に話しかけたい
次に音声認識で声から文字列を取得していきます。
SFSpeechRecognizerを使用します。ViewController.swiftprivate let speechRecognizer = SFSpeechRecognizer(locale: Locale(identifier: "ja-JP"))! private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? private let audioEngine = AVAudioEngine() override func viewDidLoad() { super.viewDidLoad() speechRecognizer.delegate = self } @IBAction func tapeedRecording(_ sender: UILongPressGestureRecognizer) { switch sender.state{ case .began: try! start() recordingLabel.text = "認識中..." isRecording = true case .ended: recordImageView.isUserInteractionEnabled = false audioEngine.stop() recognitionRequest?.endAudio() audioEngine.inputNode.removeTap(onBus: 0) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.recordImageView.isUserInteractionEnabled = true } default: break } } private func start() throws { guard !isRecording else { return } if let recognitionTask = recognitionTask { recognitionTask.cancel() self.recognitionTask = nil } let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.record, mode: .measurement, options: []) try audioSession.setActive(true, options: .notifyOthersOnDeactivation) let recognitionRequest = SFSpeechAudioBufferRecognitionRequest() self.recognitionRequest = recognitionRequest recognitionRequest.shouldReportPartialResults = true recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { [weak self] (result, error) in guard let `self` = self else { return } guard self.isRecording else { return } var isFinal = false if let result = result { isFinal = result.isFinal self.recordingLabel.text = result.bestTranscription.formattedString } if error != nil || isFinal { self.audioEngine.stop() self.audioEngine.inputNode.removeTap(onBus: 0) self.recognitionRequest = nil self.recognitionTask = nil } } let recordingFormat = audioEngine.inputNode.outputFormat(forBus: 0) audioEngine.inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in self.recognitionRequest?.append(buffer) } audioEngine.prepare() try? audioEngine.start() }これで音声入力で文字列を取得できるようになります。
次はここで取得したテキストをChatのAPIに投げていきましょう。
AIで返事をする
今回はユーザーローカルさんのSupportChatbotのAPIを使っていきましょう。
個人利用の場合は申請フォームから申し込むだけですぐにAPIのKeyが発行され、それと元に叩くだけで利用できます。
早速サンプルで投げてみましょう。https://chatbot-api.userlocal.jp/api/chat?message=こんにちは&key=APIKeyレスポンスがこちら
{ "status": "success", "result": "なーにー?" }めちゃくちゃ簡単。今回はこのmessageに音声認識で取得した文字列をセットして投げていきます。
ChatStruct.swiftstruct ChatStruct: Codable { var status: String var result: String }ChatApi.swiftclass ChatApi { static func getMessage(message: String, completion: @escaping (ChatStruct) -> Swift.Void) { let url = URL(string: "https://chatbot-api.userlocal.jp/api/chat?message=\(message)&key=your_api_key".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!) let task = URLSession.shared.dataTask(with: url!) { data, response, error in guard let jsonData = data else { return } do { let chat = try JSONDecoder().decode(ChatStruct.self, from: jsonData) completion(chat) } catch { print(error.localizedDescription) } } task.resume() } }こんな感じでStructとModelを作成して音声認識が終わったらAPIを叩くようにして、結果を出力してみましょう。
ViewController.swift@IBAction func tapeedRecording(_ sender: UILongPressGestureRecognizer) { switch sender.state{ case .began: try! start() recordingLabel.text = "認識中..." isRecording = true case .ended: recordImageView.isUserInteractionEnabled = false audioEngine.stop() recognitionRequest?.endAudio() audioEngine.inputNode.removeTap(onBus: 0) loadingView.isHidden = false // 追加 ChatApi.getMessage(message: inputMessage) { [unowned self] (chat) in self.chat = chat DispatchQueue.main.async { self.loadingView.isHidden = true self.recordingLabel.text = nil // 出力してみる print("result:", chat.result) } self.isRecording = false } DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.recordImageView.isUserInteractionEnabled = true } default: break } }
こんにちは
と話しかけた結果がこちらresult: すごーっ絶妙に会話にはなっていないですがまあいいでしょう。最後にこの結果を読み上げてもらいましょう。
その返事を読み上げる
AVSpeechSynthesizerを使用して文字列を読み上げてもらいます。
ViewController.swiftprivate let speechSynthesizer = AVSpeechSynthesizer() private func speak(message: String) { defer { disableAVSession() } do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker) try AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation) } catch { print("audioSession properties weren't set because of an error.") } let utterance = AVSpeechUtterance(string: message) utterance.voice = AVSpeechSynthesisVoice(language: "ja-JP") utterance.pitchMultiplier = 1 self.speechSynthesizer.speak(utterance) } private func disableAVSession() { do { try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } catch { print("audioSession properties weren't disable.") } }これで一通りの機能は完成したので早速試してみましょう。
声が機械的すぎてあれだけどちゃんと慰めてくれてますね。Qiitaに動画載せられなかったので音声は伝わらないのですが。。
結果
これで僕にも友達ができました。僕のワンダフルライフはここから始まります。
gitにもあげたので全体のソースはそこで確認してください。https://github.com/kohei1218/LoveMinus
明日は弊社のAndroidの神こと@kozmatsの記事です、お楽しみに!!
参考
ARKitで簡単ARやってみた
【ARKit】今日からはじめる AR プログラミング Part.1「現実空間をトラッキングする」
Swiftでリアルタイム音声認識するための最小コード
【Speech Framework】音声認識してテキストを入力する
[Swift] AVSpeechSynthesizerで読み上げ機能を使ってみる上記とてもわかりやすくて参考にしました、ありがとうございますm(_ _)m