20200226のiOSに関する記事は5件です。

BitriseでAdHocアプリ配布するまで

ゴール

PRのMerge時に自動でアプリを作成し、SlackでQRコードを共有する。

前提

  • Bitrise のアカウントを持っている。
  • Distribution のP12ファイルを持っている。
  • Ad HocProvisioning Profile が作成されている。

準備

  1. アーカイブ時に使用される Provisioning ProfileAd Hoc のものに設定する。
    説明が面倒なので省略(そのうち追記するかも...)

  2. SlackWebhook の設定を行い、Webhook URLを取得する。
    001.png
    002.png

Bitrise の App を作成

1. プライバシーの設定

003.png

2. リポジトリの選択

004.png

3. リポジトリの設定

特に指定がなければ、左を選択
005.png

4. ブランチの設定

今回はdevelop
006.png

5. 配布方法の設定

今回はad-hoc
007.png

6. 画像を選択

009.png

7. Webhookの設定

どちらでもいいが、今回は設定する
010.png

8. 初回ビルド

011.png

実行する意味がないので、 Abort させる
012.png

013.png

WorkFlow の作成

Appのトップ画面へ移動
014.png

Workflow 画面へ遷移
015.png

primary は必要ないので削除
016.png

特に理由がなければ各 StepVersionAlways latest (常に最新) に変更してしまいましょう
017.png

ScriptXcode Test は今回は使わないので削除
018.png
019.png

QRコード生成の Step を追加

Deploy to Bitrise.ioStep の後ろに QRコード生成の Step を追加
020.png
021.png

SlackStep を追加

Cache:Push の後に SlackStep を追加
022.png
023.png

Webhook URL を設定

事前に準備していた Webhook URL を設定
024.png
025.png

QRコードをSlackのメッセージに含める

先程追加したQRコードをSlackのメッセージに含める
026.png
027.png

保存

028.png

Provisioning Profile証明書 を設定

Code Signing タブを開く
029.png

Provisioning Profile証明書 を設定
030.png

ビルド

Appのトップ画面へ移動し、ビルドを行う
031.png

先程作成した Workflow を選択
032.png

ビルド結果を確認する
033.png

Slackにメッセージが飛んでいればOK
034.png

実機にインストール

インストールページを開く

警告が出ているので、 Click here をタップ
035.PNG

構成プロファイルをダウンロード

036.PNG

037.PNG

構成プロファイルをインストール

設定アプリを開くと「プロファイルがインストールされました」と表示されているので、タップ
038.PNG
「インストール」をタップ
039.PNG

アプリをダウンロード

インストールページを開くとインストールが可能になっているので、「Install」をタップ
040.PNG
「OK」をタップ
041.PNG
「インストール」をタップ
042.PNG
アプリがインストールされました
043.PNG

トリガーを設定

WorkFlow が完成したので、最後にトリガーを設定しましょう。

Triggers タブを開く

044.png

トリガーを設定

ブランチ名とWorkFlowを設定し、「Done」をクリック
045.png

設定を保存

046.png

あとがき

アプリの配布はいくつか種類がありますが、今回紹介したBitriseのものはファイルの追加などがなく、Bitrise上で完結していることがメリットかと思います。
よろしければ使ってみてください。

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

iOSアプリ申請リジェクト Apple Developer Program account under investigation

下記の理由でアプリ申請がリジェクトされる

We are unable to continue this app’s review because your Apple Developer Program account is currently under investigation for not following the App Store Review Guidelines’ Developer Code of Conduct.

googleで翻訳すると、

お使いのApple Developer Programアカウントは、App Storeレビューガイドラインのデベロッパー行動規範に従っていないため、現在調査中のため、このアプリのレビューを続行できません。

ネットで解決方法を調べると

  • 2019年初期から同様の事象が多発している。
  • 個人開発者によく起きる
  • かつ、1つ目のアプリ申請で起きやすい
  • ゲームアプリに起こりやすい
  • Appleの調査中は、開発者は何も出来ない(別のアプリのアップデートもできなくなる)
  • 調査期間は、半年以上と言う人もいる
  • 2020年1月時点では、調査期間が1週間から1ヶ月で推移していらしい

参考1
https://forums.developer.apple.com/thread/116331

参考2
https://qiita.com/gureta/items/b21b264dfa95051e67bb

 試したこと

申請したアプリをapp store connectから削除して、新しいappとして再申請(開発者アカウントは同じ)

結果

2回目のWe are unable to continue this app’s review because...

同じ事象でリジェクトされる。ただただ、待つしかなくなる。

 開発環境

  • 個人の開発者アカウント
  • 初めてのアプリ申請
  • firebaseを使ったSNSアプリ
  • ホームページあり

経緯

2月21日 
  • 15:49  アプリ申請提出
  • 18:41  ステータスが In Reviewに更新
  • 20:51  申請のリジェクト通知
2月22日 

ネットでリサーチした結果、リジェクトされたアプリを削除

2月24日

申請内容を見直してみて、再度新規のアプリとして申請し直す。

  • 20:07  申請提出
2月25日
  • 4:32  ステータス In Review
2月26日
  • 2:30  2回目のリジェクト(理由は、1回目と同じApple Developer Program account is currently under investigation)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

XCTest(Unit Test)で明示的に書かれていないいくつかの仕様

単体テストで画面遷移もやっちゃおう、と思って格闘したときの覚書です。

アプリの起動はテスト実行時一回のみ行われる

テスト対象のアプリは、テストの実行時一回のみ行われます。

@testable import app

つまりAppDelegateは初回のテストケースで一度だけ走ります。
各テストケースでは走リません。

デフォルトのテストケースの実行順序はクラス名順 -> テストケースメソッド名順

テストケースの実行順序は名前の順になります。
個人的にはクラスの中で、上から下に舐めていって欲しい気持ちがあったのですが、それは仕様みたいです。
テストケースの実行をランダムにするオプションはあるんですが、デフォルトは名前順。

基本テストケースの実行順序に依存するようなテストコードを書くな、というのはあるんでしょうけど、
何らか制約が生じてしまうときは、名前で縛るしかないのかなあという感じです。
たとえばtest_1_何かの確認test_2_何かの確認みたいな。

あるテストケースでアプリに加えた変更は次のテストケースに引き継がれる

テストケースのたびにアプリの状態はイニシャルしてしまって構わないのですが、
多分パフォーマンスの問題だと思うんですが、イニシャルはされず、次に引き継がれます。
別にModelクラスならそんなに困ることはないんですが、UIが関わるクラスだと結構キツイです。

tearDown()でちゃんと後始末して、常にきれいな状態で次のテストケースに引き継げるように記述できれば良いんですが、
テストのことをあまり考えずにつくったアプリだとキツイときもあって、みんなどうしてんだろうなあって思いました。

Class単位にsetup()/tearDown()ができる

これは明示的に書かれていないということはなく、
テストの本読むとXCTestのライフサイクルとしてちゃんと書かれていますが、
サンプルコートの中になくて、使わないので盲点になっていました。

XCTestCaseのclassメソッドsetup()/tearDown()はテストclassに対して一回。
通常のsetup()/tearDown()はテストケースに対して一回。
何を言っているのかというと、

XCTestCaseを継承したクラス
    class override func setUp() {
        <#code#>
    }

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    class override func tearDown() {
        <#code#>
    }

この一番上と一番下のクラスメソッドはクラス中で一回だけ、
二番目、三番目のメソッドは各テストケースごとに実行されます。

僕は画面遷移する前にアプリが持っている情報をテスト前に退避して、
テストでは仮のデータ突っ込んでめちゃくちゃにして、テスト後は退避したものをきれいに戻す、みたいな書き方をしようとしたんですが、
使ってみたらクラスメソッドだと子クラスの中に書いた変数が使えないので、退避先がなくて実現しませんでした。

本気であまり使い所ないかもしれませんね……

UIテストだとアプリ自体の起動・終了が制御できる

アプリの状態をイニシャルできないというのは、UIテストだったら、アプリの起動・終了が制御できるので、多少は良いかなと思います。

XCUIApplication().launch()

画面遷移ならむしろUIテストで書くのが筋なんですかね?

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

Swift で UITextView の URL リンクを短縮表示させる。

0.はじめに

Swift で UITextView に表示される URL リンクを短縮表示させたかったので、やってみました。

ついでに、キーワード検索時の強調表示もできる様にしてます。

以下の記事を参考にさせて頂きました。

感謝 ♪♪♪

?‍♂️?‍♂️?‍♂️

1.コード

今回は、UITextView の Extension として、setAttributedText という関数を追加しました。

パラメータの説明は、以下。

  • text: String : [元となるテキスト文字列]
  • shortUrlMaxNum: Int = Int() : [短縮されるURLの最大文字数]
  • keyword: String? = nil : [検索キーワード]
  • keywordBackgroundColor: UIColor = .yellow : [検索キーワードの強調される背景色]
UITextView+Extension.swift
//
//  UITextView+Extension.swift
//

import UIKit

extension UITextView {
    func setAttributedText(text: String, shortUrlMaxNum: Int = Int(), keyword: String? = nil, keywordBackgroundColor: UIColor = .yellow) -> Void {
        var tempText: String = text
        var links: [(url: URL, range: NSRange)] = []
        if shortUrlMaxNum > 0 {
            if let _detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) {
                var truncateCount: Int = 0
                for link in _detector.matches(in: tempText, range: NSMakeRange(0, tempText.utf16.count)) {
                    let startIndex = tempText.utf16.index(tempText.startIndex, offsetBy: (link.range.location - truncateCount))
                    let endIndex = tempText.utf16.index(startIndex, offsetBy: link.range.length)
                    let _tempText = tempText[startIndex..<endIndex]
                    if let _url = link.url, let _ = _url.host, _tempText == _url.absoluteString {
                        var range: NSRange = link.range
                        if _url.absoluteString.count > shortUrlMaxNum {
                            let replacementString: String = _url.absoluteString.prefix(shortUrlMaxNum) + "..."
                            range = NSMakeRange(link.range.location - truncateCount, replacementString.count)
                            tempText.replaceSubrange(
                                Range<String.Index>(NSMakeRange(range.location, link.range.length), in: tempText)!,
                                with: replacementString)
                            truncateCount += (link.range.length - replacementString.count)
                        } else {
                            range = NSMakeRange(link.range.location - truncateCount, _url.absoluteString.count)
                        }
                        links.append((url: _url, range: range))
                    }
                }
            }
        }
        let attrText: NSMutableAttributedString = NSMutableAttributedString(string: tempText, attributes: [
            .font : self.font as Any,
            .foregroundColor : self.textColor as Any,
        ])
        for link in links {
            attrText.addAttribute(.link, value: link.url.absoluteString, range: link.range)
        }
        if let _keyword = keyword {
            if let _regex = try? NSRegularExpression(pattern: _keyword) {
                for match in _regex.matches(in: text, range: NSMakeRange(0, text.count)) as [NSTextCheckingResult] {
                    attrText.addAttribute(.backgroundColor, value: keywordBackgroundColor, range: match.range)
                }
            }
        }
        self.attributedText = attrText
    }
}


で、こんな感じに表示されます。

IMG_0152.png


99.ハマりポイント

  • 短縮URL後のテキスト文字列の編集が、結構面倒臭かったですね…。バグってないかちょっと心配…。

  • あと、tempText.utf16 を使っている箇所がありますが、これは絵文字対策で入れたものです。これも、結構面倒臭かった…。

???

XX.まとめ

もしかしたら、バグあるかもしれないので、実装する場合は、自己責任でお願いします!

?‍♂️?‍♂️?‍♂️

あと、もっと簡単な方法があれば、教えて頂けるとと嬉しいです!

以上、ご参考になれば ♪♪♪

???

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

【ARKit】3Dアニメーションの切り替え

はじめに

状況に応じてアニメーションを切り替えたかったので、そのメモ。

実行環境

やりたいこと

  • iPhoneのミュージックなど、他のアプリでの音楽が再生/停止の検知。
  • 再生中はキャラクタが踊り、停止すると踊りを止めるようにアニメーションを切り替える。 (昔あった玩具のフラワーロック的なイメージ)

Mixamoで作るアニメーションは、1つの3Dモデルに1つのアニメーションを割り当てるので、座ってる状態(sitting.scn)と踊っている状態(swing.scn)の二つのシーンからnodeを取りだして、差し替えることでアニメーションを切り替えることにしました。

【座ってる状態(sitting.scn)】
スクリーンショット 2020-02-25 0.46.45.png

【踊っている状態(swing.scn)】
スクリーンショット 2020-02-25 0.46.58.png

他のアプリでのAVAudioSessionの状態を検知する

他のアプリでの音楽が再生/停止されたかは、AVAudioSession.silenceSecondaryAudioHintNotificationで 検知できるようなので、通知されたときに呼び出される関数(handleSecondaryAudio)をNotificationCenterに登録します。

silenceSecondaryAudioHintNotificationの登録処理
    func setupNotifications() {
        // AVAudioSession:初期化
        try! AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
        try! AVAudioSession.sharedInstance().setActive(true)

        // 他アプリでのサウンド再生/停止を監視
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(self.handleSecondaryAudio), name: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil)
    }

setupNotificationsは、viewDidLoadなどで呼び出してておきます。また、AVAudioSession.silenceSecondaryAudioHintNotification は、再生/停止が変化したの通知のため、初回のみ AVAudioSession.sharedInstance().isOtherAudioPlaying でアプリ起動時の再生/停止状態を判断して初期化します。

再生/停止検知の初期化
    let val_idle:String? = "sitting"
    let val_swing:String? = "swing"

    var selectedItem: String?

    override func viewDidLoad() {
        super.viewDidLoad()

        //(中略)

        setupNotifications()

        if AVAudioSession.sharedInstance().isOtherAudioPlaying {
            // 再生中!!
            print("---再生中")
            selectedItem = val_swing
        } else {
            print("---停止中")
            selectedItem = val_idle
        }
    }

他のアプリでの再生/停止検知時の処理

notification.userInfoの AVAudioSession.SilenceSecondaryAudioHintType を取りだして、その値で再生(.begin)、停止(.end)を判断します。

そして切り替えるシーン名を変更して(selectedItem)、キャラクタのnodeを差し替える処理(changeAllNodes())を呼び出します。

再生/停止検知時の処理
    /// 他アプリでのサウンド再生/停止を監視
    @objc func handleSecondaryAudio(notification: Notification) {
        // Determine hint type
        print("---check audio")

        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
            let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
                return
        }

        if type == .begin {
            // Other app audio started playing - mute secondary audio.
            print("---audio:begin")

            // キャラクタのnodeを差し替える
            selectedItem = val_swing
            changeAllNodes()
        } else if type == .end {
                // Other app audio started playing - mute secondary audio.
            print("---audio:stop")

            // キャラクタのnodeを差し替える
            selectedItem = val_idle
            changeAllNodes()
        } else {
            // Other app audio stopped playing - restart secondary audio.
        }
    }

キャラクタのnodeを差し替える

self.mainSceneView から1つずつノードを取りだして、現在表示されているノード(prevItem)を新しいノード(selectedItem)に差し替えます。

キャラクタのnodeを差し替える
    // キャラクタのnodeを差し替える
    func changeAllNodes() -> Void {
        var prevItem:String?

        if (selectedItem == val_swing){
            prevItem = val_idle
        } else {
            prevItem = val_swing
        }

        // 追加されている全ての子ノードを1つずつ取り出す
        for Node : AnyObject in self.mainSceneView.scene.rootNode.childNodes{
            if (Node as! SCNNode).name == nil  {
                print("name is nil")
            } else {
                if Node.name == prevItem{

                    if let selectedItem = self.selectedItem {

                        // .scnファイルから新しい3Dモデルのノードを作成
                        let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
                        let newNode = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

                        // 3Dモデルの配置(現在と同じ位置に)
                        newNode.position = Node.position

                        // 3Dモデルのサイズを変更(現在と同じサイズに)
                        newNode.scale = Node.scale

                        // 3Dモデルに名前をつける(削除用)
                        newNode.name = selectedItem

                        // 新しい3Dモデルのノードに差し替える
                        self.mainSceneView.scene.rootNode.replaceChildNode(Node as! SCNNode, with: newNode)

//                        // シーンに追加
//                        self.mainSceneView.scene.rootNode.addChildNode(newNode)
//
//                        // 古い3Dモデルを削除
//                        Node.removeFromParentNode();
                }
            }

        }

    }

※最初は、新しいノードを追加>古いノードを削除としていましたが、replaceChildNode(_:with:)で差し替えできるとがわかったので、書き換えました。

完成

できました!

↓音楽停止中は座り込み...

↓音楽再生中は踊る!

音楽の再生/停止をコントロールセンターで行うと、切り替え時にタイムラグがあるので、リモコン付きのイヤフォンで再生/停止を行うと挙動がわかりやすいです。

最後にスクリプト全体を載せておきます。

ViewController.swift
import UIKit
import ARKit
import AVFoundation

class ViewController: UIViewController{

    @IBOutlet weak var mainSceneView: ARSCNView!

    let configuration = ARWorldTrackingConfiguration()

//    let val_idle:String? = "idle"
    let val_idle:String? = "sitting"
    let val_swing:String? = "swing"

    var selectedItem: String?

    override func viewDidLoad() {
        super.viewDidLoad()

        initialize()
        registerGestureRecognizers()

        setupNotifications()

        if AVAudioSession.sharedInstance().isOtherAudioPlaying {
            // 再生中!!
            print("---再生中")
            selectedItem = val_swing
        } else {
            print("---停止中")
            selectedItem = val_idle
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }

    func setupNotifications() {
        // AVAudioSession:初期化
        try! AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
        try! AVAudioSession.sharedInstance().setActive(true)

        // 他アプリでのサウンド再生/停止を監視
        let nc = NotificationCenter.default
        nc.addObserver(self, selector: #selector(self.handleSecondaryAudio), name: AVAudioSession.silenceSecondaryAudioHintNotification, object: nil)


    }
    /// ARSCNiew初期化設定
    func initialize (){

        self.mainSceneView.debugOptions = [ARSCNDebugOptions.showWorldOrigin, ARSCNDebugOptions.showFeaturePoints]
        self.configuration.planeDetection = .horizontal
        self.mainSceneView.session.run(configuration)
        self.mainSceneView.autoenablesDefaultLighting = true
    }

    /// メインのビューのタップを検知するように設定する
    func registerGestureRecognizers() {

        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped))
        self.mainSceneView.addGestureRecognizer(tapGestureRecognizer)

        // ロングプレスイベントハンドラの登録
        let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(longPressView))
        self.mainSceneView.addGestureRecognizer(longPressGesture)
    }

    @objc func tapped(sender: UITapGestureRecognizer) {
        // タップされた位置を取得する
        let sceneView = sender.view as! ARSCNView
        let tapLocation = sender.location(in: sceneView)

        // タップされた位置のARアンカーを探す
        let hitTest = sceneView.hitTest(tapLocation, types: .existingPlaneUsingExtent)
        if !hitTest.isEmpty {
            // タップした箇所が取得できていればitemを追加
            self.addItem(hitTestResult: hitTest.first!)
        }
    }

    // 長押しでキャラクタを削除する
    @objc func longPressView(sender: UILongPressGestureRecognizer) {
        print("----長押し!")

        if sender.state == .began {
            let location = sender.location(in: self.mainSceneView)
            let hitTest  = self.mainSceneView.hitTest(location)

            print("---hitTest:%d",hitTest)

            if let result = hitTest.first  {

                if ((result.node.name) != nil){
                    print("--.name:%d",result.node.name!)
                }
                if ((result.node.parent!.name) != nil){
                    print("--parent.name:%d",result.node.parent!.name!)
                }

                // 3Dアニメーションモデルは、複数パーツで構成されるため、親ノードの名前で判定・削除する
                if result.node.parent!.name == selectedItem
                {
                    result.node.parent!.removeFromParentNode();
                }
            }
        }
    }

    /// アイテム配置メソッド
    func addItem(hitTestResult: ARHitTestResult) {
        if let selectedItem = self.selectedItem {

            // .scnファイルから新しい3Dモデルのノードを作成
            let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
            let node = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

            // 現実世界の座標を取得
            let transform = hitTestResult.worldTransform
            let thirdColumn = transform.columns.3

            // 3Dモデルの配置
            node.position = SCNVector3(thirdColumn.x, thirdColumn.y, thirdColumn.z)

            // 3Dモデルのサイズを変更
            node.scale = SCNVector3(0.05, 0.05, 0.05)

            // 3Dモデルに名前をつける
            node.name = selectedItem

            // シーンに追加
            self.mainSceneView.scene.rootNode.addChildNode(node)
        }
    }

    /// 他アプリでのサウンド再生/停止を監視
    @objc func handleSecondaryAudio(notification: Notification) {
        // Determine hint type
        print("---check audio")

        guard let userInfo = notification.userInfo,
            let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
            let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
                return
        }

        if type == .begin {
            // Other app audio started playing - mute secondary audio.
            print("---audio:begin")

            // キャラクタのnodeを差し替える
            selectedItem = val_swing
            changeAllNodes()
        } else if type == .end {
                // Other app audio started playing - mute secondary audio.
            print("---audio:stop")

            // キャラクタのnodeを差し替える
            selectedItem = val_idle
            changeAllNodes()
        } else {
            // Other app audio stopped playing - restart secondary audio.
        }
    }

    // キャラクタのnodeを差し替える
    func changeAllNodes() -> Void {
        var prevItem:String?

        if (selectedItem == val_swing){
            prevItem = val_idle
        } else {
            prevItem = val_swing
        }

        // 追加されている全ての子ノードを1つずつ取り出す
        for Node : AnyObject in self.mainSceneView.scene.rootNode.childNodes{
            if (Node as! SCNNode).name == nil  {
                print("name is nil")
            } else {
                if Node.name == prevItem{

                    if let selectedItem = self.selectedItem {

                        // .scnファイルから新しい3Dモデルのノードを作成
                        let scene = SCNScene(named: "art.scnassets/\(selectedItem).scn")
                        let newNode = (scene?.rootNode.childNode(withName: selectedItem, recursively: false))!

                        // 3Dモデルの配置(現在と同じ位置に)
                        newNode.position = Node.position

                        // 3Dモデルのサイズを変更(現在と同じサイズに)
                        newNode.scale = Node.scale

                        // 3Dモデルに名前をつける(削除用)
                        newNode.name = selectedItem

                        // 新しい3Dモデルのノードに差し替える
                        self.mainSceneView.scene.rootNode.replaceChildNode(Node as! SCNNode, with: newNode)
//                        // シーンに追加
//                        self.mainSceneView.scene.rootNode.addChildNode(newNode)
//
//                        // 古い3Dモデルを削除
//                        Node.removeFromParentNode();
                }
            }

        }

    }
}

}

まとめ

Mixamoで作るモデルが、1つの3Dモデルに1つのアニメーションなので、このような方法を取りましたが、別の3Dアプリなどで複数のアニメーションを1つのオブジェクトに内包させることができたりするんですかね?(Unityのようにアニメーション設定そのものを別ファイルとして、モデルに適用するような)

もし他にいい方法があるようなら、教えてください。

以下の記事が参考になりました。ありがとうございます。

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