明けましておめでとうございます。新しい年ということでアイコンも一新した lovee です。はい、あまりにもビビったのでラノベっぽいタイトルにしてみました。後悔はしていない。
なぜアプリ作ろうと思ったの?
いやーそれなりに長い間 iOS エンジニアやってきたけど、業務ではそれなりにたくさんのアプリを作ってきたけど、完全に個人でアプリを作ったことがまだ全くないんですよね。というわけで去年「今年中に個人アプリを出す」という目標を立てました。結局去年中には出せなかったがとりあえずまだ今年度中ということで開き直ってます
どんなアプリ?
簡単に言うとただ単にユーザが入力した文字列を QR コードに変換するだけのアプリです。それだけです。でも本当の使い方はアプリをいちいち立ち上げて QR コードを作るのではありません。このアプリは iOS ネイティブの共有メニューを対応しているので、別のアプリから共有メニュー呼び出して共有したいものを QR コードに変換するのが本来の使い方というかアイデアです。
言葉では説明しにくいので、こちらの画像をご覧いただければ分かりやすいかもしれません:
はい、こちらは bilibili 動画で周りにいる人に布教したい動画があったときに、サクッとその動画の URL を QR コード化した動画です。これで例えば周りにいる人が AirDrop が使えなかったり、その人の SNS アカウントを探すのが面倒だったり、そもそも繋がってなくてたまたま懇親会とかで初対面の人に何かを布教したいときにこのサクッと作られた QR コードを読み取ってもらえばミッションコンプリートです。とても楽です。もちろん bilibili 動画だけでなく、iOS のネイティブ共有メニューが使えるアプリで共有内容がテキストもしくは URL に落とし込めるものならなんでも使えます。もう一つの例としては周りにいる友達と何処かを目指したいとき、その場所も地図アプリから共有できたりします:
App Store で無料リリースしており、しかも広告もないので是非試してみてください!
ちなみに実はソースコードも公開しており、興味ある方是非覗いてみてください:
どんな技術を使ってる?
QR コード生成
QR コードの生成自体は特に尖った技術はありません、単純に CoreImage の CIFilter.qrCodeGenerator() から作られてるだけです。ただ何か特筆したいことがあるとすれば、その生成コード読めばわかるように、今の CoreImage の Swift 対応がだいぶ様になっているようです。ひと昔はまだ CIFilter(name: String) から作らなくてはならず、そのため作られたインスタンスはただの CIFilter 型なので、それはなんのためのフィルターか、どんなパラメーターが設定できるかなどはドキュメントを読まなくてはならず、全て文字列で Key-Value コーディングする必要がありました。ところが今の作り方では CIFilter & CIQRCodeGenerator 型が作られ、CIFilter はこれまでと同じく、そして CIQRCodeGenerator プロトコルは設定できるパラメーターとかが定義されているので、QR コードで設定できる correctionLevel と message がプロパティーとして直接設定できるようになり非常に使いやすいです。
private extension CIQRCodeGenerator where Self: CIFilter {
func qrCodeImage(for text: String, correctionLevel: String) -> CIImage? {
self.correctionLevel = correctionLevel
self.message = text.data(using: .utf8)!
return outputImage
}
}
QR コード表示
CIFilter で生成された QR コードはサイズが非常に小さく、各ビットは 1 ピクセルで表現されているため、そのまま画面に出すととても小さいです。どれくらい小さいかというとこんな感じの画像になります:
もちろん確かにこの画像では QR コードとして必要なすべての情報が詰まっていますが、小さすぎてカメラからは認識できませんので画面に合わせて大きく表示する必要があります。ちなみにメインアプリでは SwiftUI を使っています。さて次の問題はどうやって小さい画像を SwiftUI で大きく表示させるかの問題です。
まず画面に合わせて大きく表示すること自体は簡単です、Image 要素の後ろに .resizable().scaledToFit() を追加してあげればいいです。前の .resizable() は Image 要素を親要素に合わせてリサイズし、後ろの .scaledToFit() はリサイズしたときアスペクト比を保持して親要素にフィットさせるようにします。UIKit 時代の .aspectFit ですね。
しかしこれだけでは大きな問題点が残っています、それは SwiftUI では(UIKit も同じですが)画像をリサイズするとき、特に拡大するときに補間が働いて、画像がぼやけてしまい、こんなふうに表示されます:
もちろんこれでスキャンするときちゃんと認識はできますが、やはり見た目は非常に残念ですね。ではこれをどうすればいいかというと、Image 要素を .interpolation(.none) で補完を無効化すればいいです。ですので最終的なコードはこんなものになります:
Image(uiImage: generator.qrPicture(for: content).uiImage)
.interpolation(.none)
.resizable()
.scaledToFit()
こうすれば下記のようにキレイに QR コードが表示されます:
SwiftUI
せっかく SwiftUI の話が出てきたので、ここでもう一歩踏み入れた話をしましょう。なぜかというととにかく SwiftUI は今現時点ではすごい罠だらけですので()
まずは入力画面の話をしましょう。入力画面はこんな感じのとてもシンプルな画面です。
とても簡単ですね、画面には左側に文字入力の TextField、そしてその右側に入力確定の Button しかありません。少なくともそんな風に見えます。…本当にそうでしょうか?
まず TextField について、今回はしたに線を引いてあります。UIKit の時代、まあ下線を引くのは決して簡単とは言えませんが、まあ愚直な形でできてました。しかし現段階の SwiftUI では、四角のボーダーや背景色を入れるなら楽に対応できますが下に線を一本引くだけの描画は直接にはできません。そこでいろいろ調べた結果、どうやら Divider() を下に入れればそれっぽく見えることがわかりましたので、こんな感じで対応しました:
extension View {
func underline() -> some View {
return VStack {
self
Divider()
}
}
}
TextField(/* ... */)
.underline()
次は右側のボタンですが、まずこのボタンの動作は Push 遷移になるので、そもそも Button ではなく NavigationLink になります。まあこれはそんなに難しい話ではないですかね。
問題はこのボタンの周りにあるボーダーです。
え?さっきボーダー入れるなら楽って言ってたよね?と思うかもしれません。実際筆者もこの対応をするまで楽だと思ってました。そもそも昔 beta の時代は確かに楽に対応できてた気はします。確か .cornerRadius(10).border(Color.secondary, width: 1) みたいなこと書いてればできた覚えがあります。ところが今現時点(Xcode 11.3)でやってみたら、なんとボーダーが直角のままでした。.cornerRadius と .border の順番入れ替えても直角のボーダーのままで角の線がなくなっただけです。
というわけでいろいろ調べてみたら、どうやら .overlay で設定しなくてはいけないことが判明しましたので、こんな風に作ることになりました:
extension View {
func border(color: Color, cornerRadius: CGFloat, lineWidth: CGFloat) -> some View {
return overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(color, lineWidth: lineWidth)
)
}
}
NavigationLink(/* ... */)
.border(color: .secondary, cornerRadius: 10, lineWidth: 1)
面倒ですね。
面倒と言えば、さらにこのままこれを NavigationView の中に表示すると、機種によってはキーボードを呼び出したとき TextField が隠されちゃうので、さらにこれらを .offset(y: -100) で場所をずらしました。面倒です。よしなにレイアウトやってくれるならキーボード表示時の対応くらい自動的によしなにやって欲しいです。でないとちゃんとしたレイアウトを作りたいとき結局 GeometryReader とか使う羽目になるからとても面倒です。
面倒な話はまだここで終わっていません。Push 遷移の話をちょろっとしましたが、さてこの Push 遷移をするためには、この入力ビューを NavigationView の中に入れなくてはいけません。というわけで SceneDelegate を対応させましょう:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = NavigationView(content: { TextInputView() })
// ...
}
これで手持ちの iPhone X で動作確認してみましょう。よっしちゃんと表示されました。文字列入力して Generate ボタンタップしたらちゃんと QR コードも表示されました。
めでたしめでたし。これで App Store に申請出しましょう。
…
と思うじゃん?また後でテストについてもちょっと話しますが、実はこれ、iPad ではちゃんと表示されません(!?)。アプリ開いたら真っ白な画面が表示されます。
なんでや!?と思っていろいろ画面いじってみたら、どうやら指が左から右スワイプしないとこの入力画面が出てこないことが判明しました。
さらにいうと iPad だけでなく、iPhone でも横画面にするとき横の Size Class が Regular になる iPhone 11 Pro Max とかの機種も同じく真っ白で左から右スワイプしないといけません。
というわけでこの動きについてさらにいろいろ調べてみたら、とりあえず DoubleColumnNavigationViewStyle にすれば表示はできることがわかりました。ただしこれは本来すべき対応というより、現状のバグのような動きに対するワークアラウンドですから本当はこれに依存すべきではないですが、まあトレードオフですかね、多機種/多画面向きの対応を取るのか、SwiftUI がバグらない環境を取るのか。とりあえず私は前者を取りました。そしてもちろんこうすれば QR コードの表示画面は iPad ではフル画面ではなく常に右側の限られた幅で表示されることにはなっちゃいますが。というわけでコードをこんなふうに修正しました:
let contentView = NavigationView(content: { TextInputView() })
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.padding()
しかし、これで万事解決と思ったら、まだまだ罠があります。先ほど言いました iPhone 11 Pro Max の横画面問題ですが、こうしても横画面では結局これまでと同じく右スワイプしないと入力画面出てきません。もう現在これについては SwiftUI のバグということで開き直って放置することにしました。別にアプリが落ちるわけでもないし
こんなにシンプルな画面が 2 つだけでも対応がこんなに大変なので、現時点で SwiftUI で複雑な画面を組むのはやはりやめたほうがいいですね。Swift が 3.0 でようやくまともっぽくなったのと同じように、SwiftUI 3.0 くらいまで気長に待ちましょう。
Share Extension 対応
共有メニューに自分のアプリを表示させるためには Share Extension を対応すればいいです。まずは Target を追加します:
あとはダイアログに合わせてポチポチすれば Share Extension 用の Target が追加されます。簡単です。
ところがこれで共有画面用の ShareViewController が自動的に追加されますが、その VC のクラスは Twitter とかの SNS に投稿するための iOS ネイティブで用意されている SLComposeServiceViewController のサブクラスです;しかしこのアプリではそんな SNS 投稿とかをせず、単純に共有内容を QR コード化して表示するだけの画面が欲しいです。どうすればいいですか?
答えは意外と簡単です:SLComposeServiceViewController の継承を UIViewController に変えるだけです。
- class ShareViewController: SLComposeServiceViewController {
+ class ShareViewController: UIViewController {
// ...
}
こうすることで、簡単に必要な共有画面を組み込めます。
また、共有項目の取得は、ShareViewController の extensionContext?.inputItems から取得できます。ただしこの inputItems は [Any] ですので、中身を NSExtensionItem に落とし込む必要があります。落とし込んだら、さらに attachments プロパティーから forEach して loadItem の非同期処理で共有アイテムを取り出さなくてはなりませんし、そのアイテムの種類も Uniform Type Identifiers で特定しないといけなかったりします。割と結構面倒な処理ですが、愚直なコードにするとこんな感じです:
class ShareViewController: UIViewController {
private typealias SharingItem = (type: String, content: String)
private var sharingItems: [SharingItem] = []
override func viewDidLoad() {
super.viewDidLoad()
guard let inputItem = extensionContext?.inputItems.first as? NSExtensionItem else {
return
}
DispatchQueue.global().async {
for attachment in inputItem.attachments ?? [] {
for identifier in attachment.registeredTypeIdentifiers {
attachment.loadItem(forTypeIdentifier: identifier) { [weak self] (coding, error) in
guard let item = coding else {
assertionFailure("Failed to load item for \(identifier). Error: \(error as Any)")
return
}
switch identifier as CFString {
case kUTTypeURL:
guard let url = item as? URL else { assertionFailure("Failed to load item as URL."); break }
self?.sharingItems.append(("URL", url.absoluteString))
case kUTTypeText, kUTTypePlainText:
guard let text = item as? String else { assertionFailure("Failed to load item as Text."); break }
self?.sharingItems.append(("Text", text))
default:
break
}
}
}
}
}
}
}
見ての通り、非常に長い処理をしなくてはいけないです。ですので流石にアプリではいくつかの細かいメソッドに切り分けていますが。
簡単にちょっと説明を付け加えると、上記の attachments 配列に対してこんなことを行っています:
-
attachmentsを回して、さらに各々のattachmentに対してそれぞれに登録されているregisteredTypeIdentifiers配列を回します。二重ループです。 -
registeredTypeIdentifiersループ内で取得されたidentifierを使って、attachmentのloadItem(forIdentifier:completionHandler:)処理を掛けます。登録されてるはずのidentifierですので、completionHandler内ではcodingが必ず取得できるはずですから、取得できなかった時をアサートします。当然ですがcompletionHandlerは非同期処理ですので注意しましょう。 -
identifierは Uniform Type Identifiers(UTType) ですので、ここを参考に、URL とテキストを指すkUTTypeURL、kUTTypeTextとkUTTypePlainTextのときだけ共有アイテムを取り出して、自身のsharingItemsに追加します。
まあなかなか大変な作業ですね。特に気をつけないといけないのは、kUTTypeURL とかを定義しているのは MobileCoreServices モジュールですので、これも import しないといけないですし、これらはそもそも String ではなく CFString ですので,switch 文回す時もキャストが必要です。さらに、kUTTypeURL の時と、kUTTypeText kUTTypePlainText の時、取り出した coding は違う型で、前者は URL、後者は String になるのでここでもさらにキャストしないといけないです。まあ面倒です。
ちなみにここももちろん生成された小さい QR コード画像を拡大して表示しないといけないですが、これは SwiftUI ではなく UIKit の画面になるので対応が少々違います、UIImageView の layer を弄ることになります:
qrImageView.layer.magnificationFilter = .nearest
さてここまで弄ってミッションコンプリート思ってるあなた、甘いです。まだ画面を閉じる処理が書かれていません。
共有画面を閉じるなんて、画面の上からしたスワイプすれば閉じれるんじゃないの?と思うかもしれませんが、大体の場合はこれでいけます。ところが筆者がいろんなアプリで動作テストしてたときに、なんとなぜか Amazon アプリからの共有画面は閉じれないことが判明しました。ほとんどのアプリでは Modal 遷移するときにカードのような表示になりますが、なぜか Amazon のアプリだけ Fullscreen の表示になります。そのため下スワイプでの画面終了はできないのです。
いやできなくても普通に dismiss すればいいじゃん?と思うかもしれませんが、実際筆者も最初そう思いましたが、やってみたら全然閉じれませんでした。いろいろ調べてみたらなんとまた VC の extensionContext から completeRequest もしくは cancelRequest を呼び出さなくてはいけないことが判明しました。まあ QR コードの生成は終わってるし他にやることもないので complete でいいと思いましたので、こんなふうに素直に閉じるボタンを作って,閉じる処理を書きました:
@IBAction private func dismiss() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
どうやら Share Extension 対応の肝は extensionContext っぽいですね。
さて、ここまで対応したらプログラム上の対応はほぼ終わりましたが、審査に提出するためにはまだもう一つ直すところがあります:Info.plist です。
Share Extension 対応を追加するときに、Xcode は自動的に Share Extension ターゲットの Info.plist に NSExtensionActivationRule を書き込みますが、初期値は開発がしやすいように TRUEPREDICATE になっています。これはどういう意味かというと、アプリから共有メニューを呼び出すときに、共有内容がどんなものでもこのアプリは受け付けられるよという意味です。しかしこれは本当に初期開発がしやすいためだけのものですので、このまま審査に提出するとリジェクトを喰らいます。ですのでこれを直す必要があります。
単純な直し方でしたら、この NSExtensionActivationRule の値タイプを String から Dictionary に変えて、その下にさらに NSExtensionActivationSupportsAttachmentsWithMaxCount や NSExtensionActivationSupportsWebURLWithMaxCount などの項目を追加すればいいです。このアプリも受け付ける共有は文字列と URL だけですので、それっぽく設定してみました。ところがいろんなアプリで試してみたところ、TRUEPREDICATE なら対応できてたマップアプリの場所共有はどう変更しても出てこなくなりました。受付タイプを文字列と URL に限定せずに全てのファイルを受け付けるとかにしてもダメでした。というわけでここで更にいろいろ探してみたら、どうやらこれは Subquery で対応できるらしい。というわけで最終的に NSExtensionActivationRule はこんな風になりました:
<key>NSExtensionActivationRule</key>
<string>SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text").@count >= 1).@count >= 1
OR SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text").@count >= 1).@count >= 1
OR SUBQUERY(extensionItems, $extensionItem, SUBQUERY($extensionItem.attachments, $attachment, SUBQUERY($attachment.registeredTypeIdentifiers, $uti, $uti UTI-CONFORMS-TO "public.url" AND NOT $uti UTI-CONFORMS-TO "public.file-url").@count >= 1).@count >= 1).@count >= 1
</string>
本当はソースコードと同じように kUTTypeURL とかを使いたかったのですが、やはり Info.plist ではそれが使えませんでしたから文字列をそのまま書くしかないです。残念。
初めてリリースした個人アプリどんな感想?
とりあえずまずは審査の速さにビビっていますね。はい。
思い返せば、自分が初めて iPhone アプリ(当時はまだ iPad がない)開発に(一応)携わった時はまだ iPhone 3GS の時代で、当時は Retina ディスプレイとか多種多様な画面サイズとかとは無縁な世界でした。その時にメモオフの iPhone 移植の仕事に携わって、当時は審査を出してから結果が出るのに 1 週間かかってもおかしくない時代でした(コンシューマゲーム機の場合、ソニーやニンテンドーなどの審査は更に時間かかるので特にそれほど違和感がなかったっぽい)。その後インディーズの iOS 開発者が増え、(特に Android の無審査と比べた場合の)審査の待ち時間の不満が高まっていき、徐々に審査が早くなり、1 週間以内、3 ~ 4 日内、2 日以内、そして割と最近でも大体当日か翌日とかのイメージでしたので、まさか 30 分以内に審査が通って App Store に並ばれたなんて思いもしなかったから、とても感慨深いです。
そしてまだ超シンプルな機能しかありませんが、むしろ「とりあえずリリースしようぜ」の精神でリリースしたわけです。でないとまじでずるずるして今年度すら出せないじゃないかと危機感を抱きました
ところが去年の WWDC の二大目玉の一つである SwiftUI ですが、最初はざわざわして、みんな大興奮して、そして時とともになんとなくみんな冷静になり始めて、「あれ?これ罠多くね?」って流れになって、最終的に「やはりプロダクトに使うのはまだ早すぎた」という結論に着地したのが身をもって理解しました。本当にまだ罠が多いしちょっとでもカスタムなレイアウトを組もうとするとすぐ面倒な書き方が強いられます。特に iPhone と iPad 両方、画面向きも横縦両方対応しようとすると本当に面倒です。まあそれでも個人的には Auto Layout よりは将来性があると思う信じ込んでいますけどね。
将来性といえば、そういえば beta の時 SwiftUI のプレビューをポチポチするだけでビューの添削とかカスタマイズとかができたはずですが、いつの間になくなった!?
そして自分でアプリをリリースして気づいたのが、AppStoreConnect のアプリ紹介でスクショやキーワードの追加や修正は新しいバージョンを出さないとできないですね。静止画のスクショしかないとやはりメリットが伝わりにくいと思って、この記事の最初に貼り付けたような共有メニューからの利用を画面録画したから、それを AppStoreConnect に追加しようと思ったらできませんでした。一つ勉強になりました。
どれくらいダウンロードされた?
残念ながら本記事を執筆した時点ではまだダウンロード数とかのレポートがないんですよね…一応おかげさまで評価が 1 件星 5 ついただいておりますがレビューも今のところまだありません。今後に期待です。皆さんのレビューもお待ちしております。
今後のロードマップは?
テストをたくさん書く
今現時点でも一応最低限の Unit Test がありますが、それに加えて UI Test もたくさん追加していきたいと思います。実際現状まだリリースしていない開発ブランチに最初の UI Test を追加しました。やはりテスト大事ですね。実は上にも書いた iPad の NavigationView 問題、これもとりあえず iPad で UI Test 動かした時に発覚した問題です。
UI Test 書いてて思ったのは、やはり結構 API が独特というか普段の開発とは全然違うスタイルですね。手元の iOS テスト全書がとても頼りになりましたが、それでも今 iPhone と iPad でそれぞれ微妙に違いが生じる操作法をどうやって UI Test に落としこむかちょっと悩んでいます。そしてそれとは別にもしかすると XCUITest 用のラッパーフレームワークを作るかもしれません。いろんな API をもう少し分かりやすいようにしたいです。
そしてまだメインアプリの最低限の UI Test しか書いてませんが、何しろ目玉機能は共有メニューからの呼び出しなので、共有メニューからの UI Test も書きたいです。はい。
更にテストと関連するもので、CI/CD 環境も充実にしたいですね。CI 面として現状まだ SwiftLint も入れてないし Danger も入れていません、Bitrise の Workflow でそれらの Step を用意していますがスキップさせてるだけです。早いうちになんとか直したいです。そして CD 面として開発者アカウントの 2FA 問題もあってなぜか AppStoreConnect にログインできなくなっています。2FA 対応のためのセッション同期やアプリパスワード設定を全部マニュアル通りに設定したはずなのに…というわけで今は Bitrise の方に上げられた .xcarchive ファイルを落として手動で上げています。あとは UI Test で撮ったスクショも自動的にアップロードしていきたいですね。毎回毎回自分で手動で撮って上げるのは流石にだるいです。
機能を追加する
とりあえず今考えているのは履歴機能、お気に入り機能と QR コード画像のカスタマイズ機能です。
履歴と気に入りは文字通り、これまで生成した QR コードの履歴と、お気に入りの QR コードをまとめる機能です。QR コードのカスタマイズ機能は、例えば QR コードの色を変えるとか、ユーザが提供した画像を QR コード画像に組み込むとかの機能です。多分こちらのライブラリーを使った機能かな。
また履歴機能とお気に入り機能に関しては共有メニューからの利用も連動させる予定です。つまり共有メニューから生成したものも履歴に残るし、お気に入りに追加できるようにしたいです。
有料アプリにする(?)
はい、上記の機能を全部組み込んだら有料アプリにしようかなと思っています。オープンソースなのに有料アプリ。
まあもちろんそんな高いアプリにするつもりは全然ないですが、やはりお金が入るのと入らないのとではモチベーションが全然違う(と思う)んですね。
じゃあアプリ内購入にするか広告にすればいいのでは?と思うかもしれませんが、まずアプリ内購入はプログラムをかなり複雑化してしまう(気がする)ので気が進まないです;そして広告はそもそも個人的に嫌いです。せっかくシンプルで使いやすい画面を作ったのにそれを広告で汚したくないです。
逆に言うとつまり今のうちダウンロードしておかないと、今後有料アプリになったらお金払わなくてはならなくなるかもしれませんよ?(悪魔の囁き)(でもそもそも Qiita のユーザならソースコードから自分でフォークしてビルドすればええやん)
他に何か言いたいことある?
実はこのアプリを Slack で宣伝したら、@takasek さんにショートカットで似たようなことができるよーと教えられました。手順としてはまず共有メニューからの入力を受け入れて、その入力から「書類」で QR コードを生成して、更に「書類」で生成された QR コードを Quick Look で表示すればいいです。ただ実際自分でやってみたところ、QR コードの表示はできるが、例えばマップアプリとかの共有内容が複数ある時に、どの QR コードがなんの内容に対応したものかというのは~~自力で目パースするか~~実際一回誰かにスキャンしてもらわないとわからないのでちょっと大変です。その点このアプリでは共有内容を UISegmentedControl で切り替えられ、更にその内容も UILabel で下に表示しているから分かりやすいです。あと羅小黒戦記マジ面白いからとりあえず日本語字幕版上映中の映画からでいいからみんな見て
参考リンク
- SwiftUI Tip: Drawing a Border with Rounded Corners for Buttons and Text
- SwiftUI Textfield with underline
- SwiftUI Navigation on iPad - How to show master list
- Share Extension でデータを共有する
- System-Declared Uniform Type Identifiers
- [iOS 8] App Extension #4 – Action Extension
- dismiss share extension custom viewcontroller
- Share extension with map
- 【iOS】SwiftでQRコードを表示して目で読んでみた









