xyk blog

最近は iOS 開発の記事が多めです。

macOS で BlackHole を使って音声付きで画面収録する

環境:macOS Sonama 14.2.1

macOS 環境で QuickTime Player を使って画面収録(ショートカットはCommand(⌘) + Shift(⇧) + 5)を行う場合、masOS上で再生されている音声は収録されない。
音声も同時に収録できるようにするため「Soundflower」という昔からある仮想オーディオデバイスを作成するソフトをインストールしようとしたが自分の使っている Apple シリコンの mac にはインストールできなかった。
調べたところ、同様な機能をもつ「Blackhole」というソフトがあることを知り、こちらを試したところ問題なくインストールできた。

BlackHole インストール

公式サイトからソフトをダウンロードできるがメアド登録が必要だったので、今回は Homebrew からインストールした。

Homebrew でインストール

$ brew install blackhole-2ch

今回は 2ch 版をインストールしたが、その他に 16ch 版や 64ch 版もあった。

「複数出力装置」を作成

インストールしたら、仮想オーディオの設定を行う。
まず「Audio MIDI 設定」というmac の標準アプリを起動する。
左下+ボタンを押して「複数出力装置を作成」を選択。

作成した「複数出力装置」を選択、「BlackHole 2ch」の使用にチェックを入れる。また同時に Macbook のスピーカーでも聞けるように「Macbook Air のスピーカー」にもチェックを入れる。
チェックを入れる順番は入れ替えて試してみたがどちらが先でも特に問題はなかった。

サウンド出力を「複数出力装置」に変更

次に「システム設定」> 「サウンド」> 「出力」に先ほど追加した「複数出力装置」を選択。

QuickTime Player で画面収録

Command(⌘) + Shift(⇧) + 5QuickTime Player を起動する。
「オプション」ボタンを押し、マイクに「BlackHole 2ch」を選択。
そして「収録」ボタンを押して開始すればOK。

ちなみに「複数出力装置」を作らずともサウンド出力で直接「BlackHole 2ch」を選択しても音声付きで画面収録できるが収録中の音声をスピーカーで聴くことができなくなってしまう。

macOS で右クリックメニュー(コンテキストメニュー)をキーボードショートカットで実行する

検証環境:
macOS Monterey 12.5

macOS で右クリックメニュー(コンテキストメニュー)をキーボードショートカットで実行する方法について。
実現する方法はいろいろあると思うが、今回は BetterTouchTool を使うことで簡単にできた。

今回自分がやりたかったことは Chrome ブラウザで閲覧中サイトの日本語翻訳をキーボードショートカットで実行すること。
Chrome 自体には翻訳用のキーボードショートカットは用意されていないので、サイト表示時に右上に表示されるポップアップからクリックするか、右クリックメニューの「日本語に翻訳」をクリックする必要がある。

Chrome 上での右クリックメニュー

BetterTouchTool 設定手順

1) 画面左下の+ボタンからアプリ追加で Chrome を追加する。
2) 画面上部のプルダウンメニューから「キーボードショートカット」を選択する。
3) 画面中央の+ボタンを押す。

4) 画面右上の「ショートカットを記録するには、下記をクリック:」をクリックした後、実際のキーを押して登録する。
今回自分はControl(^) + Aを登録した。
5) 次に「選択したトリガーに最初の操作を割り当てる」+ボタンをクリックする。

6) 画面右上のアクション設定プルダウンメニューから「その他のアプリケーションの制御」->「コンテキストメニュー項目をトリガー」を選択する。

7) 「コンテキストメニュー項目へのコマンドパスを;で区切って入力します。」と書いてある下の入力欄に実行したいメニュー名の文字列を入力する。今回であれば 日本語に翻訳 と入力すればOK。

ここには(1)のように数値によるインデックス指定も可能。さらにネストしたメニューもセミコロン区切りで(1);(2)のように指定可能。
ちなみにコンテキストメニュー数は状況によって変動するのでインデックス指定より文字列指定のほうが確実だろう。
また状況によって表記揺れが発生する場合でもワイルドカード*を使うことで柔軟にマッチさせることもできるようだ。

これでキーボードショートカット登録完了。
Chrome でサイト表示後にControl(^) + Aを押せば翻訳が発動するようになった。

Swift 画像表示ライブラリ Nuke で画像取得失敗時のレスポンスステータスコードを知りたい

検証環境:
Xcode 13.4
Swift 5.6.1
Nuke 11.0.1

Swift の画像表示ライブラリである Nuke (https://github.com/kean/Nuke) で、画像取得に失敗した時のレスポンスステータスコードを知りたい状況があったのだが、Error 情報から取り出す方法がちょっと面倒だったのでメモしておく。

ImagePipeline.shared.loadImage(with: url) { result in

    if case .failure(let pipelineError) = result,
        case ImagePipeline.Error.dataLoadingFailed(let dataLoaderError) = pipelineError,
        case DataLoader.Error.statusCodeUnacceptable(let responseCode) = dataLoaderError {
        print("responseCode: \(responseCode)")
    }
}

zsh で実行に失敗したコマンドを履歴に残さない

zsh で実行に失敗したコマンドは履歴(.zsh_history)に残さないようにしたい。
以下を.zshrc に追加しておく。
precmdのタイミングでフックして、zsh の fc コマンドを利用して履歴から削除している。

autoload -Uz add-zsh-hook

remove_last_history_if_not_needed () {
  local last_status="$?"
  local HISTFILE=~/.zsh_history
  if [[ ${last_status} -ne 0 ]]; then
    fc -W
    ed -s ${HISTFILE} <<EOF >/dev/null
d
w
q
EOF
    fc -R
  fi
}

add-zsh-hook precmd remove_last_history_if_not_needed

UIViewController で画面表示時に1度のみ処理を実行する

検証環境:
Version 13.2 (13C90)
Swift 5.5.2

UIViewController で画面表示時に何か処理を1度のみ実行したい時に lazy stored property を使って簡潔に書くやり方。

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    _ = viewDidAppearOnce
}

private lazy var viewDidAppearOnce: Void = {
    // doSomething
}()

RxSwift を使っているなら

private let disposeBag = DisposeBag()

override func viewDidLoad() {
    super.viewDidLoad()

    rx.sentMessage(#selector(viewDidAppear))
        .take(1)
        .subscribe(onNext: { _ in
            // doSomething
        }).disposed(by: disposeBag)
}

Swift で月初・月末を取得する

検証環境:
Xcode Version 12.5 (12E262)
Swift 5.4

Swift で、ある月の月初・月末を取得する方法。
月初の取得は1日固定で取得するだけだが、月末の取得は月初から1ヶ月進めて1日戻すことで算出できる。

例1:2020年2月の月初と月末を取得する

let calendar = Calendar(identifier: .gregorian) // 西暦を指定
let firstDay = calendar.date(from: DateComponents(year: 2020, month: 2))! // day: 1 を指定してもよいが省略しても月初となる

/* こう書いても同じ
var comps = calendar.dateComponents([.year, .month], from: Date())
comps.year = 2020
comps.month = 2
let firstDay = calendar.date(from: comps)!
*/

let add = DateComponents(month: 1, day: -1) // 月初から1ヶ月進めて1日戻す
let lastDay = calendar.date(byAdding: add, to: firstDay)!

print("\(firstDay)") // 2020-01-31 15:00:00 +0000
print("\(lastDay)") // 2020-02-28 15:00:00 +0000

print で文字列出力すると GMT(UTC) でわかりづらいので、フォーマッタを用意して JST で文字列出力する。

let dateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.calendar = Calendar(identifier: .gregorian) // 西暦を指定
    // formatter.timeZone = TimeZone(identifier: "Asia/Tokyo") // システムのタイムゾーンが Asia/Tokyo でない場合は指定が必要
    formatter.dateFormat = "yyyy-MM-dd"
    return formatter
}()

print(dateFormatter.string(from: firstDay)) // 2020-02-01
print(dateFormatter.string(from: lastDay)) // 2020-02-29

例2:今月の月初と月末を取得する

let calendar = Calendar(identifier: .gregorian) // 西暦を指定
let comps = calendar.dateComponents([.year, .month], from: Date())
let firstDay = calendar.date(from: comps)!

let add = DateComponents(month: 1, day: -1)
let lastDay = calendar.date(byAdding: add, to: firstDay)!

print(dateFormatter.string(from: firstDay)) // 2021-07-01
print(dateFormatter.string(from: lastDay)) // 2021-07-31

Git でブランチの派生元を間違えたときに git rebase --onto で修正する

やるたびに調べているのでメモ。

$ git rebase --onto (新しい派生元ブランチ名) (現在の派生元ブランチ名) (ブランチ名)

派生元がブランチでない場合はコミットID指定でもOK。

例: 間違えて develop から作成した feature/hoge ブランチの派生元を master に変更する

$ git rebase --onto master develop feature/hoge

おまけで --onto で指定先をミスってコミットが見えなくなってしまった場合は、reflog で消えたコミット群を探し、その末端のコミットに対して再度ブランチ化すればよい。

$ git reflog
$ git checkout (コミットID)  # 消えているコミットの末端へ移動する
$ git branch (ブランチ名)  # ブランチ化する

参考:

git でコミットが消えた場合に簡単に復帰する方法 - Qiita

表示中のマップ(MKMapView)領域内に含まれるアノテーション(MKAnnotation)を調べる

検証環境:
Xcode Version 12.5 (12E262)
Swift 5.4
iOS Deployment Target 11.0

表示中のマップ(MKMapView)領域内に配置されているアノテーション(MKAnnotation)を調べる方法について。
MKMapView.visibleMapRectで現在表示中の領域を取得し、MKMapView.annotations(in:)に表示領域の mapRect を渡すことで表示領域内にあるアノテーション配列を取得できる。
以下ではそれらを使い Extension として実装した。

extension MKMapView {
    func visibleAnnotations() -> [MKAnnotation] {
        return annotations(in: visibleMapRect).compactMap { $0 as? MKAnnotation }
    }
}

自身の現在位置アノテーション(MKUserLocation)は除外する場合

extension MKMapView {
    func visibleAnnotations() -> [MKAnnotation] {
        return annotations(in: visibleMapRect).compactMap { $0 as? MKAnnotation }.filter { !($0 is MKUserLocation) }
    }
}

使用例としてスワイプやピンチイン・ピンチアウトでマップを動かし表示表域内にアノテーションが無くなったらアノテーションの再検索を実行させたい場合。

extension ViewController: MKMapViewDelegate {

    func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
        if mapView.visibleAnnotations().isEmpty {
            // 検索処理を実行
        }
    }

}

参考:

visibleMapRect
https://developer.apple.com/documentation/mapkit/mkmapview/1452732-visiblemaprect

annotations(in:)
https://developer.apple.com/documentation/mapkit/mkmapview/1452279-annotations

関連:

xyk.hatenablog.com

Swift 5.3 からの Multiple Trailing Closures

Swift 5.3(SE-0279SE-0286)から追加された Multiple Trailing Closures について。

例えば、UIView.animate メソッドのような引数に複数の Closure を持つ場合の Trailing Closure は Swift5.2 までは次のように書いていた。

UIView.animate(withDuration: 0.3, delay: 0, options: [], animations: {
    // ...
}) { _ in
    // ...
}

この従来の書き方は現行のバージョンでもコンパイルエラーにはならず使える。

Swift 5.3 からの新しいルールでは Trailing Closure を次のように書ける。

UIView.animate(withDuration: 0.3, delay: 0, options: []) {
    // ...
} completion: { _ in
    // ...
}

最初の Closure は引数ラベルを省略し、2つ目以降の Closure には引数ラベルを付ける。

ちなみに最初の Closure のラベルを省略せず書くとコンパイルエラーになる。

// compile error
UIView.animate(withDuration: 0.3, delay: 0, options: []) animations: {
    // ...
} completion: { _ in
    // ...
}

参考:
Closures — The Swift Programming Language (Swift 5.4)

iOS アプリがユニバーサルリンクから起動されたかを判定する

検証環境:
Xcode Version 12.5 (12E262)
Swift 5.4

ユニバーサルリンク(Universal Link)から iOS アプリが呼び出された場合にはAppDelegateapplication(_:continue:restorationHandler:)メソッドが呼び出される。
これはアプリが

  • 未起動状態からアプリ起動
  • バックグラウンド状態 からフォアグラウンドになる

のどちらでも呼び出される。

func application(_ application: UIApplication,
                  continue userActivity: NSUserActivity,
                  restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    
    if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
        let webpageURL = userActivity.webpageURL {
        print("webpageURL: \(webpageURL)")
    }
    
    return true
}

アプリが未起動状態でユニバーサルリンクからアプリが起動した場合は、上記に書いたAppDelegateapplication(_:continue:restorationHandler:)メソッドの呼び出しに加えてAppDelegateapplication(_:didFinishLaunchingWithOptions:)メソッドの引数 launchOptions にも NSUserActivity パラメータが入っているので、それをチェックすることでアプリがユニバーサルリンクから起動されたかを判定することができる。

func application(_ application: UIApplication, 
                  didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    if let userActivityDict = launchOptions?[.userActivityDictionary] as? [String: Any] {
        if let userActivity = userActivityDict["UIApplicationLaunchOptionsUserActivityKey"] as? NSUserActivity {
            if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
                let webpageURL = userActivity.webpageURL {
                NSLog("webpageURL: \(webpageURL)")
            }
        }
    }
    
    return true
}

呼び出し順は

  1. application(_:didFinishLaunchingWithOptions:)
  2. application(_:continue:restorationHandler:)

になる。

ちなみに NSUserActivity はユニバーサルリンク以外にも、Siriショートカットや、Spotlight検索でも使われるが、上記と同様な方法で判定できる。