UIMenuControllerが闇だった 前編

f:id:toshi0383:20200519222043p:plain

こんな感じでカスタム項目(Print this word)を追加するのは簡単なのですが、以下のようなケースを考えます。

  • Copy などの標準項目は隠しつつカスタム項目だけ表示する
  • TextViewごとに表示する UIMenuItem を切り替える

カスタム項目入門

まずは簡単なケースですが、これで良いはずです。

    override func viewDidLoad() {
        super.viewDidLoad()

        UIMenuController.shared.menuItems = [.init(title: "Print this word", action: #selector(ViewController.printThisWord))]
    }

    @objc func printThisWord() {
        guard let range = textView.selectedTextRange, let text = tv.text(in: range),
            !text.isEmpty else {
            return
        }

        print("text: \(text)")
    }

UIMenuController.shared.menuItems = [.init(title: "Print this word", action: #selector(ViewController.printThisWord))] の部分はAppDelegateにあっても動きます。 便利ですねUIResponder

Copy などの標準項目は隠しつつカスタム項目だけ表示する

これはちょっと厄介です。 なぜかというと、 UITextView.canPerformAction(_:withSender:) の標準実装が、以下の3つに対してはtrueを返すからです。

copy: _define _share:
f:id:toshi0383:20200519213418p:plain f:id:toshi0383:20200519213414p:plain f:id:toshi0383:20200519213412p:plain

つまり UITextView のサブクラスを作ればこれらに対してfalseを返せることになります。パッと作るならそれでもいいと思います。

私はもう少しスケールする実装にしたかったので、Method Swizzlingを活用して以下のようなハックを実装してみました。

private var enableMenuItemsKey = 0

extension UITextView {

    enum MenuItem: String, CaseIterable {
        case copy = "copy:"
        case define = "_define:"
        case select = "select:"
        ...(省略)
    }

    var enableMenuItems: [MenuItem]? {
        set {
            objc_setAssociatedObject(self, &enableMenuItemsKey, newValue, .OBJC_ASSOCIATION_RETAIN)
        }
        get {
            return objc_getAssociatedObject(self, &enableMenuItemsKey) as? [MenuItem]
        }
    }

    static func swizzle() {
        do {
            let originalMethod = class_getInstanceMethod(Self.self, #selector(canPerformAction(_:withSender:)))!
            let swizzledMethod = class_getInstanceMethod(Self.self, #selector(swizzle_canPerformAction(_:withSender:)))!

            method_exchangeImplementations(originalMethod, swizzledMethod)
        }
    }

    @objc func swizzle_canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {

        let original = swizzle_canPerformAction(action, withSender: sender)
        guard let enableMenuItems = enableMenuItems else {
            return original
        }

        if enableMenuItems.isEmpty {
            return false // ignore original or result from subclass
        }

        return enableMenuItems.map({ $0.rawValue }).contains(action.description)
            && original
    }

}

なかなかハゲしい感じになりましたが、これで標準項目は隠してカスタム項目のみにするコードがかなりスッキリしました。

f:id:toshi0383:20200519220904p:plain

Copy だけ追加することもできました。

f:id:toshi0383:20200519220803p:plain

しかしまだこの行だけ見ると Print this word どこからきたんだ感がすごいです。。

TextViewごとに表示する UIMenuItem を切り替える

鬼門にやってきました。 異なる画面やUITextViewごとに項目を切り替えたいのですが、 UIMenuController.shared はシングルトンです。 UIMenuController.init() を実装することはできるのですが、ランタイムでエラーになります。

2020-05-19 21:44:44.506837+0900 MenuControllerSample[43094:6685424] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'There can only be one UIMenuController instance.'

これでは、同じ画面に複数のUITextViewがあって、それぞれでカスタム項目を出し分けたい場合の対応に困りそうです。

なおcanPerformActionUIMenuController.shared.menuItems を登録し直すのは事情により禁じ手になります。。 https://github.com/cxa/MenuItemKit/blob/master/MenuItemKit/Swift/Swizzlings.swift#L31 この辺りで無限ループすることがあるので。。まあ第一ちょっと設計として分かりづらいですしね。

まだ色々試している段階なので、今回はここまでにしたいと思います。気になる方は次回の投稿をお待ちください。もしくは既に発明されている便利な車輪があるようでしたらぜひこっそり教えてください。

多分 AppDelegate をEvent Hub的な役割にしてレスポンダチェーンに放り投げるような形がいいのかなーとか考えてます。

それでは!

Macのディスクの容量がいっぱいです

f:id:toshi0383:20200423212627p:plain
Macのストレージがそろそろいっぱい

どんな大容量SSDMacを使っていてもなぜか定期的に遭遇するこの問題。

やはりこのご時世で自宅作業が増えたことでディスク容量が増える速度も以前とは比べ物にならないようです。 今日ビルド中に残り300MBになってすごくハラハラしました。

というわけで個人的な対処方法をまとめておきます。

システム情報から管理画面を開く

最近のmacOSには便利な機能がついて、ストレージタブの「管理...」をクリックするとこういう画面が出てきます。 ここでオススメされたものを消したりします。

f:id:toshi0383:20200423214842p:plain

ファイルサイズが大きいものを表示するビューもあります。

f:id:toshi0383:20200423215037p:plain

ですが、大抵これだけでは不十分なので、以下の通り泥臭い作業が必要になります。

キャッシュ発掘作業

時間はかかりますがターミナルから以下の要領で容量を食っているディレクトリを特定するのが一番近道です。

$ du -d 1 . | sort -nr  | head
99739072    .
66876560    ./Library
8222944 ./.android
7003920 ./.cocoapods
3556704 ./work
3223392 ./.gradle
2604432 ./Documents
1632552 ./.cabal
1435632 ./dev
1427656 ./Software

いうまでもないと思いますが一番左の数字がざっくりとした容量です。 -h をつけるとGB,MBなどの表示になり見やすくなりますがあんまり気にしないと思うのでそのまま行きます。 Library/ が一番重いので、掘り下げていきます。

$ cd Library/
$ du -d 1 . | sort -nr  | head
66908920    .
21692480    ./Android
20229192    ./Developer
10662456    ./Caches
10046112    ./Application Support
2207200 ./Containers
1090392 ./Ethereum
251632  ./Safari
163784  ./MediaStream
135144  ./Group Containers

大抵は Cache とか cache ディレクトリが見つかるので、要領を食っていて消して大丈夫そうなものはどんどん掃除していきます。自分の場合は使わなくなったツールのキャッシュとかよく残っていますね。

コマンドにまとめる

上記作業をするとなんとなく傾向が見えてくると思うので、aliasやコマンドにまとめておくと良いと思います。 自分の場合は以下のような感じで ~/.bashrc にまとめています。 最近はiOSAndroidを触ることが多いのでそれ関連が増えてきてました。

    function removeCaches() {
       rm -rf `brew --cache`
       rm -rf ~/Library/Developer/Xcode/DerivedData/
       rm -rf ~/Library/Developer/CoreSimulator/Caches/*
       rm -rf ~/Library/Developer/Xcode/iOS\ Device\ Logs/*
       rm -rf ~/Library/Caches/org.carthage.CarthageKit/
       rm -rf ~/Library/Caches/CocoaPods/

       rm -rf ~/.gradle/caches/*

       # Simulatorを個別にダウンロードした場合にdmgが溜まっていた
       rm -rf ~/Library/Caches/com.apple.dt.Xcode/Downloads/*
    }

以上の作業をこの記事を書きつつ大体1時間くらい続けて13GB程度は確保することができました。 Xcodeをアップデートする時に空き容量がXcode自体の倍はないと要領不足で失敗するので、これくらいがギリギリ許容範囲だと思います。

f:id:toshi0383:20200423222627p:plain

ご覧の通り2TBのHDDもマウントしているし500GBの外付けSSDも持っているので、もっと活用するべきなのかもしれません。 キャッシュディレクトリの変更機能とかあれば便利なのかもしれないですが、HDDをキャッシュディレクトリにしたら読み出し速度も遅いし、キャッシュとしては本末転倒は気はしますよね..

他の要領節約ハック

筆者は他にも以下のようなハックをしてdisk容量をケチケチ節約しています。ご参考まで!

  • 写真ライブラリはHDD側に
  • iOSシミュレータのランタイムもHDD側に

結論

会社のMacと同じように迷わず256GBのSSDにアップグレードすればよかった!と思ってますが、容量あったらあったでなぜか使い切ってしまうんですよねえ。。不思議なものです。 定期的にクリーンアップして開発中に困らないようにしたいと思います。

Carthageユーザ必携の便利スクリプトを公開しました

これは何

以下のリポジトリのcarthageディレクトリ以下に便利ツールを公開しました。
https://github.com/toshi0383/scripts

cmdshelfでのご利用が一部必須になっています。この機会にどうぞ。
https://github.com/toshi0383/cmdshelf

Carthageのイケてないところ

まずはなぜこういうものが必要だったのかの動機についてです。

手作業多い

CocoaPodsだと自動でやってくれるようなことも、Carthageでは手作業が必要になります。 単純なことしかしないのがいいところなのですが、例えば以下のような作業は結構面倒なものです。

  • ライブラリ追加/削除時にBuild Phaseに carthage copy-frameworks を追加/削除する
  • 更新時に古いbcsymbolmapを探して削除する
  • CodeCoverage設定問題

XcodeGenを使うと1つ目は気にしなくて良くなるのですが、どうしても後の2つは手作業と指差し確認が必要ですね。

追記あり

*1

不要なFetchが走り、しかもキャンセルできない

例えばライブラリ1つだけを更新したくて carthage update CircleProgressButton をしても、 Cartfile にある全部のリポジトリをfetchしてしまいます。
もちろん依存関係がある場合に必要なのはわかるのですが、明らかに依存がないことがわかっている場合は、不要な処理ですよね。私の環境だと特にRxSwiftのfetchが延々終わらないことがあったので、そうなると結構辛いです。
しかも間違ってもControl+Cでキャンセルしてはいけません。NSTaskはchild processをkillすることができないらしく、非同期のfetch&buildのプロセスがゾンビ化します。しかもガンガン標準出力に書いてきます。やめてくれ。。
こうなると、 ps して kill するか、終わるまでおとなしく待つしかなくなります。

*2

解決策

carthage/update

https://github.com/toshi0383/scripts/blob/master/carthage/update

このスクリプトは上で挙げた課題のうち以下の2つに対応しつつ、 carthage update を実行します。

どうしてもbcsymbolmapが見つからない場合もあるので、そういう場合はメッセージを出力してます。

またCodeCoverage問題が見つかった場合自動で直しますが、その場合ライブラリ管理者に連絡するよう促しています。

上記のライブラリはcodeCoverageEnabled = YESになっていました。ライブラリ管理者に連絡するべきかもしれません。

carthage/update-with-hack.sh

https://github.com/toshi0383/scripts/blob/master/carthage/update-with-hack.sh

こちらはまずCartfileとCartfile.resolvedをいじることで指定されたライブラリ以外を見ないようにして、その上で carthage/update を実行します。終わったらちゃんとCartfileとCartfile.resolvedは元に戻して、更新を反映します。

基本的にはどちらも carthage update と書くのと同じ感覚で

cmdshelf run carthage/update
cmdshelf run carthage/update-with-hack.sh CircleProgressButton

とか書いてもらえれば便利に使えます。 tvOSの場合は

PLATFORM=tvOS cmdshelf run carthage/update-with-hack.sh CircleProgressButton

のようにプロセスに環境変数を渡します。

まあどちらのスクリプトも泥臭い処理をベタベタ書いてるだけなので、ここでの解説は省きます。 各スクリプト冒頭にドキュメントがあるので気になる方はご確認ください。

まとめ

仕事で必要に駆られて作ったものですが、公開したほうが色々いいことがあると思うので、公開できない部分は削除した上でとりあえず公開しました。

AbemaTVのiOSチームでは、Carthageライブラリ更新の際は必ず上の cmdshelf run carthage/update を使うことになっています。

内部的に --use-binaries--use-ssh を指定しているので、運用と合わない場合はごめんなさい。

多分みなさんも似たようなスクリプトを書いて運用されているとは思うので、「こうしたほうがいい」や「もっといいツールがある」などあればぜひ教えてください。
ツッコミや質問などブコメTwitterで受け付けますのでお気軽にどうぞ。
リポジトリへのプルリクもお待ちしています。

*1:0.26.0以降のCarthageでは、CodeCoverage設定問題は解決しているようです。 https://github.com/Carthage/Carthage/issues/2056#issuecomment-329971591

*2:今 carthage update --platform ios CircleProgressButton をやってみたら不要なFetchが終わらなくなることはなかったので、もしかしたら最新版では挙動改善したのかもしれません。Carthage 0.28.0で確認しました。

iOSDC Japan 2017でLT登壇して来ました

f:id:toshi0383:20170918182438j:plain

iOSDC Japan 2017、控えめにいって最高でした😂

同じくらいの規模だと以前360iDevというUSのカンファレンスに行ったことがあってエントリも書いたのですが、ちょっと割高な印象だったのですよね。それでもまあ会場の広さは余裕があるし、とてもアットホームで雰囲気が良かったし、でもトークはどれも刺激的で、という感じでした。それと同じか私にとってはそれ以上の楽しい時間を過ごせたので、このチケットの価格はもはや恐ろしいなという感じでした。運営すごすぎ。

ところで5月に子供が産まれまして、最近はなるべく明るいうちにお家に帰って奥さんの手伝いをしています。以前にも増して勉強会に行く頻度が減っていたこともあり、ひさびさに顔を合わせる方が多く、いやーほんと、忘れられてなくて良かったと思いました。話しかけるときドキドキしました。

今回はCfPに応募を出したところLTが通りまして、以下の発表をさせてもらいました。

speakerdeck.com

ただ、15分くらいならそれなりに経験はあるんですけど、そういえばLTって焦っちゃうのでもともと苦手だったのをすっかり忘れてたんですよね。マイペースなんで。。 練習はしてたし内容は伝わったかと思うのですが、最初と最後がマイペースにやりすぎてちょっとグダッとなってしまって、心もとない印象を与えてしまったかも。せっかく選んでもらったのに、悔しいです。これを糧にというか、今後は月1くらいで軽い発表をしていけるようにして、来年リベンジしたいと思ってます。 何より最終日の最後って体力がないと全然もたないので、やはり私にいま一番必要なのは筋肉だと思いました🍖

こういう大きいカンファレンスで発表側に立ったのってもしかしたら初めてだったのですが、他の偉大な発表者の方々とも距離が近くて(気のせい?)、結構いろんな方とお話しできたような気がします。新しい繋がりも私にしては結構できたような気がします。普段TwitterやQiitaでしか関わりがないと忘れがちな、それぞれの人間っぽい部分に触れることができて、やっぱりリアルでのコミュニケーションは大切だなと思いました。 @yimajo さんが実は冒頭「このセッションに来てくれてありがとうございます」のくだりで感極まっていて誤魔化すために必死だったというエピソードがあったりして、ほっこりしました。全然わからなかった笑

という感じで、様々な知見はもちろん、とても前向きなエネルギーをもらいました。 今から来年が楽しみです!

React NativeのAndroid版のJSブリッジはどうやってるの?

本日はおひさしぶりにyidev に参加。
今回も始終和やかな雰囲気で和みました。

その中で@motokiee さんのReact Nativeの発表を受け、React NativeのAndroid版はどうやってJSとブリッジしているのだろう?という疑問が私を含む一部で湧いたので、Android詳しいわけでもないのですが、軽く調査。

メモ程度ですが何かのお役に立てば幸い。

com/facebook/react/bridge

ふむ、この辺に色々ありますな。。

react-native/ReactAndroid/src/main/java/com/facebook/react/bridge at 9ee815f6b52e0c2417c04e5a05e1e31df26daed2 · facebook/react-native · GitHub

com/facebook/jni

お、jniがいるということは。。

react-native/ReactAndroid/src/main/java/com/facebook/jni at 9ee815f6b52e0c2417c04e5a05e1e31df26daed2 · facebook/react-native · GitHub

ReactCommon/cxxreact/JSCExecutor.cpp

いましたーそれっぽいc++のクラス。loadApplicationScriptという関数でJSをロードしているっぽいですね。

react-native/JSCExecutor.cpp at 1c249e4804030b5691adf508751c369bf0036f1c · facebook/react-native · GitHub

loadApplicationScript内のJSCreateCompiledSourceCode 関数

JSCreateCompiledSourceCodeという関数が使われており、名前からしてこいつがJSから実行形態に動的にコンパイルしているっぽい。しかしこの関数、ググっても出てこない。jscというモジュールがそれっぽいのだけど。

react-native/JSCExecutor.cpp at 1c249e4804030b5691adf508751c369bf0036f1c · facebook/react-native · GitHub

com/facebook/react/bridge/JavaJSExecutor.java

で、こいつがJSCExecutor.cppにブリッジしている子っぽいです。アプリ側からはこいつを介してJSオブジェクト取ってきたりJSの関数実行したりとかやるわけですね。

react-native/JavaJSExecutor.java at 9ee815f6b52e0c2417c04e5a05e1e31df26daed2 · facebook/react-native · GitHub

ひとまずここまでですー。詳しい方是非とも教えてください!

SoLoader (別のライブラリ)

ちなみにこの記事の話題とは直接関係ないですが、jni層(.so)のロードにはfacebook/SoLoaderというのが使われているようでした。

GitHub - facebook/SoLoader: Native code loader for Android

READMEを読むと、

SoLoader is a native code loader for Android. It takes care of unpacking your native libraries and recursively loads dependencies on platforms that don’t support that out of the box.

と書いてあり、どうやら再帰的に.soの依存関係を解決してくれるらしいです。へー何だそれどうなってんだ。

そういえばお久しぶりの投稿になりました。今後も気が向いた時にゆるく投稿していければと思います。

Bitriseをオープンソースプロジェクトで利用したい!

最近Travisのワーカーが一部で不足しており、Xcode8.1をターゲットにすると、ビルドが始まるまで1時間待たされるなんてことがありました。 これではちょっと回らないと思ったので、お試しでBitriseに乗り換えてみました。普段仕事では有料契約をして使っているので、問題ないだろう、と。 なおBitriseは、iOSAndroidに特化したCI/Deliverlyサービス(PaaS)です。

しかし、現状オープンソースで使うには以下の問題があることが判明しました。

  • ビルド結果とログはオーナー及びメンバー以外は見れない。フリープランだとオーナーに対して2名しか登録できない。しかもオーナーアカウントに対して2名までで、プロジェクトに対して2名までではない。 => 2017.1.13追記:変更されたかも
  • ビルド自動再実行の機能がない。Travisだと、プルリクClose->Reopenでビルドが再スタートしますよね。
  • 200 builds/month (これもオーナーのアカウント単位)の制限があり、すぐに超過してしまいそう。超過分はFailedになります。

これではコントリビュータが増えてくると使えないので、slackチャンネルで問い合わせてみました。すると、すぐに以下の回答がありました。

viktorbenei [7:50 PM]  
@toshi0383 thanks for the request/infos!
for the first point please vote & comment here: https://bitrise.uservoice.com/forums/235233-general/suggestions/8795035-allow-public-or-publicly-viewable-apps
for the auto rebuild: please create a new feature request
for unlimited builds per month for OSS project: just send us an email and we can do this for you (if you have one)

というわけで

ビルド結果とログの公開

みなさんvoteよろしく!!!

2017-02-10 追記:URLが変更になりました。 discuss.bitrise.io

ビルド自動再実行機能

機能リクエストしてくれとのことなので、頑張っちゃおうかな!!1 => 機能リクエスト投稿しました。voteよろしく!!

completed
I suggest you ...
  • 1 vote
  • 1 comment

`auto rebuild` for a failed build received from non-authorized users

Closing and Reopening the PullRequest triggers a rebuild like Travis is doing.
Arbitrary PullRequest can trigger a build, so Reopening PullRequest should too.
This way PullRequest's author can restart build without creating Bitrise account.

bitrise.uservoice.com

2017-02-10 追記: discuss.bitrise.io にvoteが足りず移行されなかったので、放置することにします。多分、"allow public build" に含まれる想定でいいのだと思います。

OSSプロジェクトのビルド数制限解除

なんと、言えば対応してくれるらしい!!!神!!!

まとめ

以上、速報でした。 ちなみにBitriseのslack/emailのサポートは対応が神なので、もし何かわからないときは気軽に問い合わせてみると良いと思います。

Apple Pay ? In-App Purchase ?

Apple Payが話題になっており、メルカリさんでは既に導入されたとのこと。

あれ、そういえばアプリ外で課金させちゃダメなんじゃなかったっけ。。この辺りのルールがよくわからん!となったので、調べてみました。

結論から言うと、

  • アプリ内で消費するコンテンツに関してはアプリ外課金ダメ。(IAP(アプリ内課金)使ってね)
  • アプリ外で消費するコンテンツに関してはIAP(アプリ内課金)ダメ。(クレジットカードとかApple Pay使ってね。)

ということでした。

ソースはこちら。

アプリ内で消費するコンテンツに関してはアプリ外課金ダメ。

3.1.1 In-App Purchase: If you want to unlock features or functionality within your app, (by way of example: subscriptions, in-game currencies, game levels, access to premium content, or unlocking a full version), you must use in-app purchase.

アプリ外で消費するコンテンツに関してはIAP(アプリ内課金)ダメ。

3.1.5 Physical Goods and Services Outside of the App: If your app enables people to purchase goods or services that will be consumed outside of the app, you must use purchase methods other than IAP to collect those payments, such as Apple Pay or traditional credit card entry. Apps may facilitate transmission of approved virtual currencies (e.g. Bitcoin, DogeCoin) provided that they do so in compliance with all state and federal laws for the territories in which the app functions.

https://developer.apple.com/app-store/review/guidelines/#physical-goods-and-services

と言っても出品されたら取引できちゃうじゃん?と一瞬思いましたが、メルカリの規約にも禁止されている出品物の記述がありました。

  • 物品ではないもの(情報、サービスの提供等)

なるほど。しかし曖昧な。。

  • メルカリ事務局で不適切と判断される商品

ア、ハイなんでもないです。

以上、初歩的でしたがアプリとお金に関する情報でした。