20191213のSwiftに関する記事は7件です。

「~フォルダ内のファイルにアクセスしようとしています」のダイアログが表示されない場合の解決方法

概要

(↓こやつ)
-w420

解決方法1. セキュリティとプライバシーの設定に情報が登録済みでないかを確認

  • 一度「~フォルダ内のファイルにアクセスしようとしています」のダイアログで回答すると、下記の場所に情報が記録されます。
  • ONにすればアクセスを許可できますが、「~フォルダ内のファイルにアクセスしようとしています」の表示は以後でなくなってしまいます。
  • 再度出したい場合は情報をターミナルからリセットする必要があります。例えば以下の通りです。
  • tccutil reset <サービス名> <バンドルID>
  • 詳しくは、セキュリティとプライバシーの設定値をリセットする(macOS 10.15 Catalina)を参照ください。
tccutil reset SystemPolicyDesktopFolder jp.co.hikeuchi.AccessConfirmSample

-w668

解決方法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-13 15.32.59

  • その他のウィンドウレベルでは問題なく表示されることが確認できる

スクリーンショット 2019-12-13 15.32.42

スクリーンショット 2019-12-13 15.32.50

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

fastlaneでまず始めに使いたいアクション

はじめに

本記事はVolare Advent Calendar 2019の3記事目として書いています。
私は今年の4月から社会人として、主にiOSアプリの開発を行っていますが、今回はVolare卒業1期生(?)として寄稿させていただきます!

fastlaneの導入方法やリリースの自動化などについては、既にたくさんの記事があるので、
今回はアプリの個人開発を行っている方や、今までfastlaneを知らなかった方でも恩恵を受けられそうな、3つのfastlaneのアクションについて、その挙動や実行方法などをまとめたいと思います。

fastlaneとは

fastlaneは、iOS及びAndroidアプリのビルドやリリースを自動化してくれる、ビルドツールです。
無料で使用することができ、OSSでドキュメントも整備されているため、手軽に導入できます。
無料です。

Fastfileにlaneと呼ばれるアクションの組み合わせを記述し、CUI上やCIツールなどから実行することで、様々な処理を自動化することができます。
今回はこのアクションについて、特に便利でよく使いそうなアクションを挙げていきたいと思います。

アクション

gym

gym.png
(引用:https://docs.fastlane.tools/actions/gym/)

:muscle: :muscle: :muscle:

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

pilot.png
(引用: https://docs.fastlane.tools/actions/pilot/)

:rocket: :rocket: :rocket:

pilotアクションは、AppleのTestFlightにアプリをアップロードしたり、テスターに配布したり、テスターの管理などができます。

テスターの追加・削除

テスターを追加したい場合は、CUI上で

fastlane pilot add [追加したいテスターのemailアドレス] 

と打つことで実行可能です。同様に削除したい場合も、

fastlane pilot remove [削除したいテスターのemailアドレス]

のコマンドを実行するだけで、対象のテスターを綺麗サッパリ消すことが可能です。

ベータ版の配布

パラメータでipaファイルを指定し、pilotアクションを実行するだけでTestFlightへのアップロードまで行えます。

pilot(
  ipa: "./MyApp.ipa"
)

deliver

deliver.png

(引用:https://docs.fastlane.tools/actions/deliver/)

:bicyclist: :bicyclist: :bicyclist: ← 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 [:レーンの名前] doendの間に実行したいアクションを書いていきます。

具体例を示すと、

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にはこれらのアクションの他にも、証明書やプロファイルを生成、同期、管理できるmatchpemcertといったアクションや、テストを自動化できるscan、スクリーンショットを自動生成することができるsnapshotなど、まだまだたくさんのアクションが用意されています。

この記事をきっかけにアプリ開発の自動化について興味を持ってくださった方は、ぜひ公式ドキュメントなどを参考に、他のアクションも使ってみてください!

参考

fastlane公式
fastlaneを導入する手順について
fastlaneを使ってみる
これから始めるfastlane

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【プロパティ・ラッパー】基本を理解する

この投稿は何か?

Apple Swift Language GuideにあるProperty Wrappersに基づき、プロパティ・ラッパーの基本を理解します。

実行環境

macOS Catalina 10.15.2
Xcode11.3
Swift5.1

ハンズオン

小さな長方形

コードで、各辺の長さが12以下である小さな長方形を表現します。
2つのプロパティheightwidthを持つ構造体です。

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 }
    }
}

SmallRectangleAnotherSmallRectangleに大した違いはありませんが、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
}

上記、エラーの原因と修正対応について、みなさまどう思われるでしょうか?
ぜひ、お考えなどをお聞かせください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftGenを使ってみる

初めに

文字列とかを管理するのにSwiftGenというものがあるらしいので使ってみました。

導入

まず最初にcocoaPodsを使ってプロジェクトに読み込みます。
1. pod init
2. pod 'SwiftGen'をpodfileに書く
3. pod install

実際に使ってみる

まずプロジェクト直下にswiftgen.ymlファイルを追加して以下の設定を追加します

swiftgen.yml
strings:
  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.swift
import 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で使ってみると
image.png
こんな感じで文字列を参照できるようになっています。

最後に

他にも色々管理できるみたいなので詳しくは公式ドキュメントをみましょう
https://github.com/SwiftGen/SwiftGen

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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とは...

  1. クラスや構造体, 列挙体の拡張
  2. プロトコル拡張(デフォルト実装)
  3. クラスや構造体, 列挙体をプロトコルに適合させる
Extension+String.swift
extension String {
  // 追加するメソッド等
  func printHoge() {
    print("hoge")
  }
}

// 呼び出し
String().printHoge()

↑例えばこのように拡張することはあるかと思いますが,
もとからあるのもなのか, それとも拡張され追加されたものなのかを判別するのは分からないと言う問題があります.

そこで, 拡張され追加されたものには, プレフィックスを付けます.
そのようにすることによって, 拡張されたものであることが明示的になると同時に, プロパティの衝突も避けることができます.

では, ここから実際にTargetedExtensionを実装していきます!

Extensions実装

今回は例として, FileManagerを拡張してみたいと思います!
ちなみに, このメソッドは指定パスにあるファイルを一覧を表示するものです.

下のものは, 単純にFileManagerを拡張したものになります.

FileManager+Extensions.swift
import 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.swift
import 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部分は, コードの説明を交えて丁寧なものにしたいと思います.

参考

告知

今年もサイバーエージェントの本選考がやってきたみたいです!
興味ある方は, 以下のURLからチェックしてみてください!!
https://www.cyberagent.co.jp/careers/special/engineer2021/

  • 早期選考で内定となった場合, 長期実務インターンをすることができます!
  • #cypitch をつけてTwitterに呟くと, それが会社説明資料になるみたいです!

僕自身がCyberAgentを選んだ理由はたくさんありますが,
その1つとして「人の良さ」です!!
「人の良さ」ってのを言葉で正確に伝えるのは難しいので, Zehi社員さんに会ってみてください!(勿論, 内定者でも大丈夫です!)
きっとこの言葉の意味が感じられるような気がします!

最後まで読んでくださり, ありがとうございました?‍♂️

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】サンプルデータ、どうやってアプリに入れますか

はじめに

Releaseビルドには要らないけど、開発中には使いたいサンプルデータ。このサンプルデータ、どうやってアプリに入れますか?

まず、想定しているサンプルデータの使い方を以下に挙げます。

  1. 主にUIの開発に用いる。UIを作るためにも表示するデータがほしい
  2. 多言語対応したデータを用意したい。AppStoreのスクショにも使う screenshot1.png
  3. 開発中は全消しして入れ直すことは多々あるので楽な方法で入れたい
  4. テスト用のテストデータのようにパターンを網羅したいわけではない

どうやって入れますか?

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

スクリーンショット 2019-12-12 20.21.36.png

このプロパティをYESをに設定したアプリは、Mac PCとiOS端末をケーブルで接続すると、FinderからアプリのDocumentsディレクトリが見れるようになります。(macOS 10.15より前はiTunes経由で見れましたが、10.15からはより直感的になりました)

スクリーンショット 2019-12-12 23.20.52.png

ドラッグ&ドロップでファイルの転送ができるため、あとはアプリ側で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.swift
struct 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ディレクトリがユーザに見える状態になるので注意が必要
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

僕のワンダフルライフ 〜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を選択します。
スクリーンショット 2019-12-12 10.43.15.png

この中にさきほどダウンロードしてきた.objファイルたちを入れてあげます。
スクリーンショット 2019-12-12 10.43.51.png

そうしたらobjにカーソルを合わせてEditorからConvert to SceneKit file formatを選択して.scnファイルに変換しましょう。
スクリーンショット 2019-12-12 10.47.25.png

すると.scnファイルが作られます。中身をみていくと
スクリーンショット 2019-12-12 10.48.00.png
とんでもない向きになってますね。可愛いうちのわんちゃんを地面にめり込ませるわけにはいかないので向きを変更しましょう。

3Dのオブジェクトを選択してNode Inspectortransformで向きを調整します。
スクリーンショット 2019-12-12 10.50.03.png

うん、可愛い。名前付けたい
スクリーンショット 2019-12-12 10.51.39.png
ニンテンドッグスとかにはまっちゃう私としてはたまりません。
早速このわんちゃんを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.swift
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = .horizontal
        sceneView.session.run(configuration)
    }

これで水平な面が検知されたらdelegateで受け取ることができるのでそこでわんちゃんを置いていきましょう。

ViewController.swift
extension 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)
        })

    }
}

IMG_0340.jpg

かんわぃぃぃぃ
ペット禁止の我が家についにわんちゃんが現れました。
可愛すぎてさわれないのが惜しいですがこのままやっていきましょう。
次はこのわんちゃんに話しかけられるようにします。

音声入力で犬に話しかけたい

次に音声認識で声から文字列を取得していきます。
SFSpeechRecognizerを使用します。

ViewController.swift
    private 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()
    }

これで音声入力で文字列を取得できるようになります。

IMG_0349.jpg

IMG_0350.jpg

次はここで取得したテキストをChatのAPIに投げていきましょう。

AIで返事をする

今回はユーザーローカルさんのSupportChatbotのAPIを使っていきましょう。
個人利用の場合は申請フォームから申し込むだけですぐにAPIのKeyが発行され、それと元に叩くだけで利用できます。
早速サンプルで投げてみましょう。

https://chatbot-api.userlocal.jp/api/chat?message=こんにちは&key=APIKey

レスポンスがこちら

{
    "status": "success",
    "result": "なーにー?"
}

めちゃくちゃ簡単。今回はこのmessageに音声認識で取得した文字列をセットして投げていきます。

ChatStruct.swift
struct ChatStruct: Codable {
    var status: String
    var result: String
}
ChatApi.swift
class 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.swift
    private 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.")
        }
    }

これで一通りの機能は完成したので早速試してみましょう。

hachi.gif

声が機械的すぎてあれだけどちゃんと慰めてくれてますね。Qiitaに動画載せられなかったので音声は伝わらないのですが。。

結果

これで僕にも友達ができました。僕のワンダフルライフはここから始まります。
gitにもあげたので全体のソースはそこで確認してください。

https://github.com/kohei1218/LoveMinus

明日は弊社のAndroidの神こと@kozmatsの記事です、お楽しみに!!

参考

ARKitで簡単ARやってみた
【ARKit】今日からはじめる AR プログラミング Part.1「現実空間をトラッキングする」
Swiftでリアルタイム音声認識するための最小コード
【Speech Framework】音声認識してテキストを入力する
[Swift] AVSpeechSynthesizerで読み上げ機能を使ってみる

上記とてもわかりやすくて参考にしました、ありがとうございますm(_ _)m

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む