20190829のiOSに関する記事は8件です。

Github Actions で Xcode プロジェクトをビルドしてみる

これは何

GitHub Actions で Xcode プロジェクトをビルドする方法のメモです。そもそも GitHub Actions って何よ?という方は下記サイトを御覧ください。

GitHub Actionsについて - GitHub ヘルプ

利用申請

GitHub Actions はまだベータ版であり、利用するには下記URLから申し込む必要があります。

https://github.com/features/actions

何日か待つと「使えるようにしたよ〜」といった内容のメールがGitHubから届きます。

とりあえずビルドしてみる

リポジトリのトップページに「Actions」タブが追加されているので「Set up Actions」をクリックしましょう。

スクリーンショット 2019-08-29 21.56.10.png

スタートページが表示されます。ここでサンプルの workflow を動かすこともできますが、今回は右上の「Set up workflow youself」をクリックします。

スクリーンショット 2019-08-29 21.56.54.png

workflow を編集する画面が表示されるので下記の yml を入力してください。

name: CI

on: [push] # git push をトリガーとする

jobs:
  build:

    runs-on: macos-latest

    steps:
    - uses: actions/checkout@v1 # ソースコードをcheckout
    - name: Select Xcode version # Xcode 10.3 を使う
      run: sudo xcode-select -s '/Applications/Xcode_10.3.app/Contents/Developer'
    - name: Show Xcode version
      run: xcodebuild -version
    - name: Build # シミュレーター向けにビルド
      run: xcodebuild -sdk iphonesimulator -configuration Debug build 

せっかくなので上記内容を適当なブランチに push してプルリクエストを作成してみましょう。「Checks」タブで workflow が動いていることが確認できます。

スクリーンショット 2019-08-29 23.24.51.png

これで Xcode プロジェクトをビルドできました!

この要領でテストを実行したり、IPAを書き出したりもできそうですね。xcodebuild については下記サイトで丁寧に解説されています。

CocoaPods や Carthage を使う

GitHub Actions の仮想環境にはすでに CocoaPods や Carthage がインストールされているので

- name: Install CocoaPods
  run: pod install

のように利用できます。

リンク

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

iOSアプリのhttp通信を許可する方法

目的

XcodeでiOSアプリを開発するとき、api通信を挟んでデータを取得する機会がよくあります。しかし、iOS9以降ではhttps通信しかデフォルトで許可されていません。
いざ実際にhttp通信を試みても、下記のようなエラーメッセージが表示され、通信がリジェクトされます。

App Transport Security has blocked a cleartext HTTP (http://) resource load since it is insecure. Temporary exceptions can be configured via your app’s Info.plist file.

設定方法

info.plistの編集

スクリーンショット 2019-08-29 22.43.59.png
1 まず最初にプロジェクト作成時に自動で作成されるinfo.plistを開きます。そして、上記画像のinformation Property Listの右部の+マークをクリックします。
スクリーンショット 2019-08-29 22.50.15.png
2 次に+ボタンを押したら表示される欄の中から、App Transport Security Settingsを選択します。
スクリーンショット 2019-08-29 22.54.00.png
3次に先ほど選択したApp Transport Security Settingsの左にある、▶︎を上記画像のように▼にクリックして下向きにしてください。その後App Transport Securityの右にある+を押し、Allow Arbitary Loadsを選択してください。
スクリーンショット 2019-08-29 22.58.45.png
4最後に上記画像のように、Allow Arbitary LoadsのValue部分をデフォルトのNOから、YESに変更したら設定完了です。

最後に

今回はHTTP通信を可能にする方法を紹介しましたが、iOS9移行で通信が暗号化されるHTTPS通信が推奨されており、セキュリティー面なども考慮するともちろんHTTPS通信を使用することが良いと思います。
そのため、できればapiを用意する場合にはhttpsに対応させることが勧められます。

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

MicroViewControllerのInjectableとInteractableプロトコルを使ってみたら良かった話

最近少し入力が多くて画面が多く複雑な画面を実装することになったので、iOSDC2018で発表されてたMicroViewControllerを思い出して、ViewController間のやりとりを入力と出力のプロトコルで縛る方式で行うようにした。

「MicroViewControllerのやり方良いよね」というと、正しく相手に通じないかもしれないので何が良いかというのを書いておこうと思います。別にButtonとかCellとかをViewControllerにしたいわけじゃない。単に入力と出力のプロトコルが良いというのが結論です。

入力

https://github.com/mercari/Mew/blob/master/Sources/Mew/Protocols/Injectable.swift

public protocol Injectable {
    associatedtype Input
    func input(_ input: Input)
}

具体的にはViewController AからViewController Bに情報を渡すような際にも使える。

class ViewControllerB: UIViewController {
    struct Input {
        let userId: String
    }

    ... 
}

extension ViewControllerB: Injectable {
    func input(_ input: Input) {
        // self.input = input として使い回すときもあるけどしなかったりもする
        if isViewLoaded {
            label.text = input.userId
        }
    }
}

func input(_ input: Input)が単なるメソッドなのが良い。これをvar input: Inputにしてしまうと入力をgetできてしまう。getして誰かが困るかっつうと困らない。困らないんだけど入力をgetする意味がないのでできない方が良い。

出力

https://github.com/mercari/Mew/blob/master/Sources/Mew/Protocols/Interactable.swift

public protocol Interactable {
    associatedtype Output
    func output(_ handler: ((Output) -> Void)?)
}

具体的には、入力によって出力が取得できる場合に使える

class ViewControllerB: UIViewController {
    struct Input {
        let userId: String
    }

    typealias Output = User

    private var outputHandler: ((Output) -> Void)?
    ... 

    func 何かやる() {
        取得しに行くやつ(input.userId).fetch { [weak self] user in
            self?.outputHandler?(user)
        }
    }
}

extension ViewControllerB: Interactable {
    func output(_ handler: ((Output) -> Void)?) {
        outputHandler = handler
    }
}

これも入力と同じで出力がメソッドなのがいい。

その他の良い点

その他の良い点も書いておく。

例えばViewController AViewController BViewController Cと3つあったり、コンテナであったりそういう場面でも使えるし、そしてViewControllerに限定せずViewでも使えるのがいい。

これによって複数人でも入力と出力だけやり方を揃えてぶらさず、そこを重点的にチェックすれば良くなるはず。

また、入力と出力さえ公開されていればよくて他はすべてprivateでいい。人のコードをチェックする際に考えることが減りそうなのもいい。

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

AVFoundationで動画に音声を追加する

[Swift]AVFoundationで動画に音声を追加する

関わっているアプリ開発のプロジェクトで無音動画に音声を結合する必要がありました。AVFoundationまわりの情報が少なく特にSwiftのサンプルコードなどがあまりないなと思い備忘録をかねてまとめます。
サンプルコードでは無音声動画と音声の結合をしていますが、音声ありでも処理は変わりません。その場合は差し替えの処理も可能です。

前提としてAVAsset/AVMutableCompositionをある程度理解しておく必要はあります。
AVFoundation Programming Guide

以下サンプルコードです。(必要箇所だけ抜き出しているのでコンパイルが通るかは?)

MovieMakerViewController.swift

import UIKit
import Foundation
import AVFoundation
import AVKit


class MovieMakerViewController: UIViewController {


    let dispatchQueue = DispatchQueue(label: "queue")

    let documentPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
    let videoUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!).appendingPathComponent("videotmp.mp4")
    let soundUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!).appendingPathComponent("soundtmp.caf")
    let movieUrl = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!).appendingPathComponent("sample.mp4")//output


    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.


        let composition = AVMutableComposition.init()


        //--------------------        
        //source video
        let asset = AVURLAsset.init(url: self.videoUrl)
        let range = CMTimeRangeMake(start: CMTime.zero, duration: asset.duration)
        let videoTrack = asset.tracks(withMediaType: .video).first
        //        let audioTrack = asset.tracks(withMediaType: .audio).first//無音性動画の場合エラーになる

        let compositionVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
        //        let compositionAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)


        try? compositionVideoTrack?.insertTimeRange(range, of: videoTrack!, at: CMTime.zero)
        //        try? compositionAudioTrack?.insertTimeRange(range, of: audioTrack!, at: CMTime.zero)
        let instruction = AVMutableVideoCompositionInstruction.init()
        instruction.timeRange = range
        let layerInstruction = AVMutableVideoCompositionLayerInstruction.init(assetTrack: compositionVideoTrack!)


        //-------------------- 
        //source sound
        let soundAsset = AVURLAsset.init(url: self.soundUrl)
        let soundTrack = soundAsset.tracks(withMediaType: .audio).first
        let compositionSoundTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
        try? compositionSoundTrack?.insertTimeRange(range, of: soundTrack!, at: CMTime.zero)


        //-------------------- 
        //composite
        let transform = videoTrack!.preferredTransform

        layerInstruction.setTransform(transform, at: CMTime.zero)
        instruction.layerInstructions = [layerInstruction]

        let videoComposition = AVMutableVideoComposition.init()
        videoComposition.renderSize = videoTrack!.naturalSize
        videoComposition.instructions = [instruction]
        videoComposition.frameDuration = CMTime.init(value: 1, timescale: 60)


        if FileManager.default.fileExists(atPath: self.movieUrl.path) {
            try? FileManager.default.removeItem(at: self.movieUrl)
        }

        let session = AVAssetExportSession.init(asset: composition, presetName: AVAssetExportPresetHighestQuality)
        session?.outputURL = self.movieUrl
        session?.outputFileType = .mp4
        session?.videoComposition = videoComposition

        session?.exportAsynchronously(completionHandler: {
            if session?.status == AVAssetExportSession.Status.completed {
                DispatchQueue.global(qos: .default).async {
                    DispatchQueue.main.async {
                        print("finished")
                    }
                }
            }
        })

        DispatchQueue.global(qos: .default).async {
            DispatchQueue.main.async {
                Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (timer: Timer) in
                    print("\(Int((session?.progress ?? 0) * 100.0))%")
                    //完了したらtimer.invalidate()を実行
                })
            }
        }
    }

}

もともとの無音声動画と音声は同じdurationという前提なので再生時間はそのまま抜き出しています。

AVの時間情報

MovieMakerViewController.swift
let asset = AVURLAsset.init(url: self.videoUrl)
let range = CMTimeRangeMake(start: CMTime.zero, duration: asset.duration)

AVAssetで動画でも音声でも時間情報を取り出すことができます。CMTimeを操作すること自由に再生時間を制御できますが、動画と音声で考え方が違います。CMTimeは動画であればフレームレート、音声であればサンプルレートを時間基準を算定します。(このあたりは機会があれば)

またこのサンプルではViewがLoadされてからメインキュー内で動画が作成されていますが、本来はメインとは別で行われるべきです。実際には自分でキューを作ってその中で処理をしていますが、サンプルコードでは省略しています。以下がその名残です。
またその場合、ユーザーに処理状況をお知らせする必要があります。UIまわりはメインキューでないと反映されないのでDispatchQueue.main.async内で処理状況を知らせるUI要素をするとよいです。進行状況はsession.progressで取得できます。

MovieMakerViewController.swift
    let dispatchQueue = DispatchQueue(label: "queue")
    (

    DispatchQueue.global(qos: .default).async {
            DispatchQueue.main.async {
                Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { (timer: Timer) in
                    print("\(Int((session?.progress ?? 0) * 100.0))%")
                    //完了したらtimer.invalidate()を実行
                })
            }
        }

手順としては

  • AVAssetで再生時間に関する情報を抜き出す
  • AVMutableCompositionにVideoTrackとAudioTrackを追加する
  • AVAssetExportSessionに合成(composition)のための情報を渡してエクスポートする

これを応用すれば音声を差し替えたり、動画同士を連結することもできるようになります。それはまたの機会に。

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

XCTestで「待つ」ためのサンプルコード集

任意のタイミングまで待つ

任意のタイミングでexpectation.fulfill()する。

    let expectation = XCTestExpectation(description: "view hidden")
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        XCTAssertEqual(view.isHidden, true)
        expectation.fulfill()
    }
    XCTWaiter().wait(for: [expectation], timeout: 10)

NSPredicateを使って待つ

    let predicate = NSPredicate(format: "isHidden == true")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: view)
    let result = XCTWaiter().wait(for: [expectation], timeout: 10)
    XCTAssertEqual(result, .completed)

KVOを使って待つ

    let expectation = XCTKVOExpectation(keyPath: "isHidden", object: view, expectedValue: true)
    let result = XCTWaiter().wait(for: [expectation], timeout: 10)
    XCTAssertEqual(result, .completed)

Notificationを待つ

    let expectation = XCTNSNotificationExpectation(name: notificationName)
    let result = XCTWaiter().wait(for: [expectation], timeout: 10)
    XCTAssertEqual(result, .completed)

環境

Swift 5
Xcode 10.2

参考

http://masilotti.com/xctest-waiting/

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

XCTestで非同期処理を待つためのサンプルコード集

任意のタイミングまで待つ

任意のタイミングでexpectation.fulfill()する。

    let expectation = XCTestExpectation(description: "view hidden")
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        XCTAssertEqual(view.isHidden, true)
        expectation.fulfill()
    }
    XCTWaiter().wait(for: [expectation], timeout: 10)

NSPredicateを使って待つ

    let predicate = NSPredicate(format: "isHidden == true")
    let expectation = XCTNSPredicateExpectation(predicate: predicate, object: view)
    let result = XCTWaiter().wait(for: [expectation], timeout: 10)
    XCTAssertEqual(result, .completed)

KVOを使って待つ

    let expectation = XCTKVOExpectation(keyPath: "isHidden", object: view, expectedValue: true)
    let result = XCTWaiter().wait(for: [expectation], timeout: 10)
    XCTAssertEqual(result, .completed)

Notificationを待つ

    let expectation = XCTNSNotificationExpectation(name: notificationName)
    let result = XCTWaiter().wait(for: [expectation], timeout: 10)
    XCTAssertEqual(result, .completed)

環境

Swift 5
Xcode 10.2

参考

http://masilotti.com/xctest-waiting/

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

iPhone で FeliCa を読み取るライブラリを作りました

この記事は potatotips #64 で発表した内容をテキスト化したものです。

TL;DR

サクッと FeliCa の IC カードを読み取れるライブラリを作ったよ。
今のところ下記のカードに対応してるよ。

  • Suica, Pasmo, Kitaca, ICOCA, TOICA、manaca、PiTaPa、nimoca、SUGOCA、はやかけん
  • nanaco、Edy、WAON
  • カスタムタグ

https://github.com/tattn/NFCReader

iOS 13 から FeliCa の読み書きができるように

WWDC 19 で CoreNFC で FeliCa が読み書きできるようになったことが発表されました。
FeliCa は Suica、Pasmo などの交通系 IC や nanaco、WAON などの電子マネーが採用している非接触 IC カードの技術方式です。

https://developer.apple.com/videos/play/wwdc2019/715/

Suica を例に読み取り方を紹介

例として Suica の乗降履歴の読み取り手順を紹介します。

CoreNFC で FeliCa を読み込むときのフロー

image.png

https://www.sony.co.jp/Products/felica/business/tech-support/index.html
https://developer.apple.com/documentation/corenfc

プロジェクト設定

Info.plist に Privacy 設定と System Code の追加が必要です。

image.png

System Code はシステムごとに割り当てられた 2 バイトのコードです。 plist に追加していないカードは読み取ることができません。
Suica の場合は 0003 になります。

また、Capabilities に 
Near Field Communication Tag Reading を追加する必要があります。

Session の作成 & ポーリングの開始

let session = NFCTagReaderSession(
    pollingOption: NFCTagReaderSession.PollingOption.iso18092, 
    delegate: self
)
session.alertMessage = "iPhoneをSuicaに近づけてください"
session.begin()

FeliCa を読み込む時は ISO18092 を指定します。これは PollingOption のドキュメントコメントにも記載されています。

alertMessage を指定すると、読み込みが始まったタイミングで表示される View にそのテキストが表示されます。
begin を呼び出すと、タグの読み取り (ポーリング) が開始されます。

タグとの接続

public func tagReaderSession(_ session: NFCTagReaderSession, 
                             didDetect tags: [NFCTag]) {

    guard case .feliCa(let tag) = tags.first else { return }

    session.connect(to: tag) { error in
        guard error == nil else { return }

        // Tagの読み込み (次のセクションに続く)
    }
}

タグが検出されると、delegate (NFCTagReaderSessionDelegate) が呼ばれます。
タグは同時に複数検出される場合がありますが、今回はその時のハンドリングは省略します。

NFCFeliCaTag が取得できたら、connect メソッドを呼び出して、タグに対してコマンドを送れるようにします。

Suica のブロックデータの読み込み

let serviceCodeList = [Data([0x0f, 0x09])]               // サービス(データ)を特定するコード
let blockList = (0..<UInt8(10)).map { Data([0x80, $0]) } // データの取得方法/位置を決める

tag.requestService(nodeCodeList: serviceCodeList) { nodes, error in
    guard error == nil, nodes.first == Data([0xff, 0xff]) else {
        return
    }

    tag.readWithoutEncryption(
        serviceCodeList: serviceCodeList,
        blockList: blockList) { status1, status2, dataList, error in
            guard error == nil, status1 == 0, status2 == 0 else {
                return
            }

            // dataの読み込み (次のセクションに続く)
    }
}

まずは、requestService を呼び出して、そのタグに読み取ろうとしているデータ (サービス) があるかどうかを確認します。
サービスが存在しない場合は 0xFF, 0xFF が返ります。 (FeliCa の仕様に基づく)

サービスが存在した場合は、readWithoutEncryption を呼び出して、データの取得をします。 (今回読み取るデータが認証不要なサービスのため、readWithoutEncryption になります)

FeliCa の仕様で、ステータスフラグがともに 0 のときのみ、正しいデータが取得可能なため、念の為チェックしておくと良さそうです。

Suica データのデコード

for data in dataList {
    let year = data[4] >> 1
    let month = UInt16(bytes: data[4...5]) >> 5 & 0b1111
    let day = data[5] & 0b11111
    print("利用日: \(year)/\(month)/\(day)") // 19/8/27

    let entrance = UInt16(bytes: data[6...7])
    let exit = UInt16(bytes: data[8...9])
    print("入場駅: \(entrance), 出場駅: \(exit)")

    let balance = UInt16(bytes: data[10...11].reversed())
    print("残高: ", balance)
}

下記のページを参考にバイナリデータから必要なデータを読み取ります。
https://www.wdic.org/w/RAIL/サイバネ規格%20(ICカード)

(year が 2000 年基準なのが面白いです)

Swift でバイナリデータを数値型に変換するのは少し手間なので上記では以下のようなエクステンションを利用しています。

extension FixedWidthInteger {
    init(bytes: UInt8...) {
        self.init(bytes: bytes)
    }

    init<T: DataProtocol>(bytes: T) {
        let count = bytes.count - 1
        self = bytes.enumerated().reduce(into: 0) { (result, item) in
            result += Self(item.element) << (8 * (count - item.offset))
        }
    }
}

↓のような感じで使えるので便利です。

XCTAssertEqual(UInt16(bytes: 0x35, 0x0B), 13579)
XCTAssertEqual(Int(bytes: 0x07, 0x5B, 0xCD, 0x15), 123456789)

便利なライブラリを作りました

前述のように読み取りには結構実装が必要で、面倒です。(エラーハンドリングなどを含めるとより手間)
そこで、サクッと使えるライブラリにしてみました。

https://github.com/tattn/NFCReader

let reader = Reader<Suica>()

reader.read(didBecomeActive: { _ in
    print("読み込み開始")
}, didDetect: { reader, result in
    switch result {
    case .success(let suica):
        let balance = suica.boardingHistories.first?.balance ?? 0
        reader.setMessage(balance)
    case .failure(let error):
        reader.setMessage("読み込みに失敗しました")
    }
})

このような感じで Reader の型パラメータに読み取りたい NFC タグを指定するだけで struct にマッピングされたデータを取得できます。

Suica の他にも以下のようなタグを読み取れます。

image.png

ぜひ使ってみてください。
NFC タグの追加プルリクエストなども大募集中です。

まとめ・所感

CoreNFC を使って FeliCa (Suica) を読み込む方法と作ったライブラリを紹介しました。

Suica の残高領域は 2 bytes しか用意されてないので
入金できる上限を簡単には増やせなさそうだなという発見もありました。

FeliCa の仕様はソニーが日本語で
丁寧に書いているのでとてもわかりやすかったです。
一方 Apple のドキュメントには現時点では全然情報がなく、FeliCa の仕様を知っている人でないと読めない感じでした。

参考文献

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

Swift ナビゲーションバーにUITextVIewの文字数をリアルタイムにカウント

完成形

ナビゲーションバーのタイトル下にTextViewに入力された文字数をリアルタイムにカウント

count.gif

実装の流れ

1. UITextViewDelegateを継承

ViewController: UIViewController, UITextViewDelegate {

2. 文字数をカウントしたいUITextViewにデリゲート設定

MemoTextView.delegate = self

3. ナビゲーションバーのタイトル・カウントの作成

イメージは下記のような形
青い四角:全体のUIView
黄色の四角:タイトルラベル
緑の四角:カウントラベル

qiita_material_count1.png

        //青い四角 UIView
        let titleView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 40))

        //黄色の四角 タイトルラベル
        let titleLabel = UILabel()
        titleLabel.text = "メモ"
        titleLabel.font = UIFont.boldSystemFont(ofSize: 17)
        titleLabel.frame = CGRect(x: 30, y: 0, width: 50, height: 20)

        //緑の四角 カウントラベル
        countLabel.text = "0"
        countLabel.font = UIFont.systemFont(ofSize: 14)
        countLabel.frame = CGRect(x: 40, y: 20, width: 50, height: 20)

        //UIViewに追加
        titleView.addSubview(countLabel)
        titleView.addSubview(titleLabel)
     
        //ナビゲーションに青い四角のUIViewを追加
        navigationItem.titleView = titleView

4. TextViewの文字数をカウントラベルに挿入

デリゲートメソッドであるtextViewDidChangeを用意し、下記のように文字列にキャストして、カウントラベルに値を入れます。

    func textViewDidChange(_ textView: UITextView) {
        let MemoCount = MemoTextView.text.count
        countLabel.text = String(MemoCount)
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む