- 投稿日:2021-02-26T22:51:54+09:00
SF SymbolsのsystemNameでタイポを防ぐために....
環境
- Swift 5.3.2
実装
enum SFSymbol { case rectangleAndPencilAndEllipsis case ... var image: UIImage? { switch self { case .rectangleAndPencilAndEllipsis: return UIImage(systemName: "rectangle.and.pencil.and.ellipsis") case ... } } } let image = SFSymbol.rectangleAndPencilAndEllipsis.imageたったこれだけのことなのに、
よく分からない寄り道をしました。その作業工程で
正規表現を使わずにlowerCamelCaseをsnake_case等に変換するStringのExtension
という副産物ができたので合わせてご覧ください。経緯
SF Symbols
の画像を複数箇所で利用する場合let image = UIImage(systemName: "rectangle.and.pencil.and.ellipsis")毎回文字列指定するのはタイポ起きそうだし長いしなんだかなぁと思っていました。
過ち
その一
早速足を踏み外します。
enum SFSymbol { case rectangleAndPencilAndEllipsis case ... var name: String { switch self { case .rectangleAndPencilAndEllipsis: return "rectangle.and.pencil.and.ellipsis" case ... } } } let image = UIImage(systemName: SFSymbol.rectangleAndPencilAndEllipsis.name)この時点でなんで気づかないんですかね。
その二
微調整が入ります。
enum SFSymbol { case rectangleAndPencilAndEllipsis case ... var name: String { switch self { case .rectangleAndPencilAndEllipsis: return "rectangle.and.pencil.and.ellipsis" case ... } } } extension UIImage { convenience init?(sfSymbol: SFSymbol) { self.init(systemName: sfSymbol.name) } } let image = UIImage(sfSymbol: .rectangleAndPencilAndEllipsis)何がしたかったんですかね。
その三
過去の自分:「nameで返してる文字列も無くしてしまえばいいのでは?」
副産物が出来上がります。
extension String { var splitLowercaseStrings: [String] { guard self.includesUppercaseChar else { return [self] } let (lowercaseString, otherString) = self.dividedLowercaseStringBeforeUppercaseCharAndOther let replacedOtherString = otherString.replacedFirstCharWithLowercaseChar guard replacedOtherString.includesUppercaseChar else { return [lowercaseString, replacedOtherString] } return [[lowercaseString], replacedOtherString.splitLowercaseStrings] .flatMap { $0 } } private var includesUppercaseChar: Bool { filter { $0.isUppercase }.count != 0 } private var dividedLowercaseStringBeforeUppercaseCharAndOther: (prefixString: String, otherString: String) { guard let uppercaseCharIndex = firstIndex(where: { $0.isUppercase }) else { return (self, "") } let targetRange = startIndex..<uppercaseCharIndex let firstLowercaseString = String(self[targetRange]) var otherString = self otherString.removeSubrange(targetRange) return (firstLowercaseString, otherString) } private var replacedFirstCharWithLowercaseChar: String { var string = self let firstUppercaseChar = string.first! let firstLowercaseChar = firstUppercaseChar.lowercased() let secondIndex = string.index(after: string.startIndex) string.insert(contentsOf: firstLowercaseChar, at: secondIndex) return String(string.dropFirst()) } } enum SystemSymbol: String { case rectangleAndPencilAndEllipsis case ... } let systemImageName = SystemSymbol.rectangleAndPencilAndEllipsis.rawValue.splitLowercaseStrings.joined(separator: ".") let image = UIImage(systemName: systemImageName)地獄ですね。
最終的に
そもそも
SF Symbols
を複数箇所で利用していませんでした。余談
副産物について
let ordinalNumbers = "firstSecondThirdFourthFifthSixth" print("dot: \(ordinalNumbers.splitLowercaseStrings.joined(separator: "."))") // dot: first.second.third.fourth.fifth.sixth print("underScore: \(ordinalNumbers.splitLowercaseStrings.joined(separator: "_"))") // underScore: first_second_third_fourth_fifth_sixthlowerCamelCaseの文字列を任意の文字列で連結できます。
(※)先頭の大文字を小文字に差し替える
var replacedFirstCharWithLowercaseChar: String
では、
replacingOccurrences(of:with:)も試しましたが、
後続している大文字始まりの文字列の塊があると、その大文字も差し代わってしまうので
insert(contentsOf: at:) と dropFirst(_:)を用いて処理しています。
- 投稿日:2021-02-26T19:05:25+09:00
xcode 上でios の devicetype を取得する
概要
devicetype を判定するための資料がうまく纏まっているサイトが少なかったので、
どなたか参考になる方がいるのを願って備忘録とします。ここを参照
// Output on a simulator @"i386" on 32-bit Simulator @"x86_64" on 64-bit Simulator // Output on an iPhone @"iPhone1,1" on iPhone @"iPhone1,2" on iPhone 3G @"iPhone2,1" on iPhone 3GS @"iPhone3,1" on iPhone 4 (GSM) @"iPhone3,2" on iPhone 4 (GSM Rev A) @"iPhone3,3" on iPhone 4 (CDMA/Verizon/Sprint) @"iPhone4,1" on iPhone 4S @"iPhone5,1" on iPhone 5 (model A1428, AT&T/Canada) @"iPhone5,2" on iPhone 5 (model A1429, everything else) @"iPhone5,3" on iPhone 5c (model A1456, A1532 | GSM) @"iPhone5,4" on iPhone 5c (model A1507, A1516, A1526 (China), A1529 | Global) @"iPhone6,1" on iPhone 5s (model A1433, A1533 | GSM) @"iPhone6,2" on iPhone 5s (model A1457, A1518, A1528 (China), A1530 | Global) @"iPhone7,1" on iPhone 6 Plus @"iPhone7,2" on iPhone 6 @"iPhone8,1" on iPhone 6S @"iPhone8,2" on iPhone 6S Plus @"iPhone8,4" on iPhone SE @"iPhone9,1" on iPhone 7 (CDMA) @"iPhone9,3" on iPhone 7 (GSM) @"iPhone9,2" on iPhone 7 Plus (CDMA) @"iPhone9,4" on iPhone 7 Plus (GSM) @"iPhone10,1" on iPhone 8 (CDMA) @"iPhone10,4" on iPhone 8 (GSM) @"iPhone10,2" on iPhone 8 Plus (CDMA) @"iPhone10,5" on iPhone 8 Plus (GSM) @"iPhone10,3" on iPhone X (CDMA) @"iPhone10,6" on iPhone X (GSM) @"iPhone11,2" on iPhone XS @"iPhone11,4" on iPhone XS Max @"iPhone11,6" on iPhone XS Max China @"iPhone11,8" on iPhone XR @"iPhone12,1" on iPhone 11 @"iPhone12,3" on iPhone 11 Pro @"iPhone12,5" on iPhone 11 Pro Max @"iPhone12,8" on iPhone SE (2nd Gen) @"iPhone13,1" on iPhone 12 Mini @"iPhone13,2" on iPhone 12 @"iPhone13,3" on iPhone 12 Pro @"iPhone13,4" on iPhone 12 Pro Max //iPad 1 @"iPad1,1" on iPad - Wifi (model A1219) @"iPad1,2" on iPad - Wifi + Cellular (model A1337) //iPad 2 @"iPad2,1" - Wifi (model A1395) @"iPad2,2" - GSM (model A1396) @"iPad2,3" - 3G (model A1397) @"iPad2,4" - Wifi (model A1395) // iPad Mini @"iPad2,5" - Wifi (model A1432) @"iPad2,6" - Wifi + Cellular (model A1454) @"iPad2,7" - Wifi + Cellular (model A1455) //iPad 3 @"iPad3,1" - Wifi (model A1416) @"iPad3,2" - Wifi + Cellular (model A1403) @"iPad3,3" - Wifi + Cellular (model A1430) //iPad 4 @"iPad3,4" - Wifi (model A1458) @"iPad3,5" - Wifi + Cellular (model A1459) @"iPad3,6" - Wifi + Cellular (model A1460) //iPad AIR @"iPad4,1" - Wifi (model A1474) @"iPad4,2" - Wifi + Cellular (model A1475) @"iPad4,3" - Wifi + Cellular (model A1476) // iPad Mini 2 @"iPad4,4" - Wifi (model A1489) @"iPad4,5" - Wifi + Cellular (model A1490) @"iPad4,6" - Wifi + Cellular (model A1491) // iPad Mini 3 @"iPad4,7" - Wifi (model A1599) @"iPad4,8" - Wifi + Cellular (model A1600) @"iPad4,9" - Wifi + Cellular (model A1601) // iPad Mini 4 @"iPad5,1" - Wifi (model A1538) @"iPad5,2" - Wifi + Cellular (model A1550) //iPad AIR 2 @"iPad5,3" - Wifi (model A1566) @"iPad5,4" - Wifi + Cellular (model A1567) // iPad PRO 9.7" @"iPad6,3" - Wifi (model A1673) @"iPad6,4" - Wifi + Cellular (model A1674) @"iPad6,4" - Wifi + Cellular (model A1675) //iPad PRO 12.9" @"iPad6,7" - Wifi (model A1584) @"iPad6,8" - Wifi + Cellular (model A1652) //iPad (5th generation) @"iPad6,11" - Wifi (model A1822) @"iPad6,12" - Wifi + Cellular (model A1823) //iPad PRO 12.9" (2nd Gen) @"iPad7,1" - Wifi (model A1670) @"iPad7,2" - Wifi + Cellular (model A1671) @"iPad7,2" - Wifi + Cellular (model A1821) //iPad PRO 10.5" @"iPad7,3" - Wifi (model A1701) @"iPad7,4" - Wifi + Cellular (model A1709) // iPad (6th Gen) @"iPad7,5" - WiFi @"iPad7,6" - WiFi + Cellular // iPad (7th Gen) @"iPad7,11" - WiFi @"iPad7,12" - WiFi + Cellular //iPad PRO 11" @"iPad8,1" - WiFi @"iPad8,2" - 1TB, WiFi @"iPad8,3" - WiFi + Cellular @"iPad8,4" - 1TB, WiFi + Cellular //iPad PRO 12.9" (3rd Gen) @"iPad8,5" - WiFi @"iPad8,6" - 1TB, WiFi @"iPad8,7" - WiFi + Cellular @"iPad8,8" - 1TB, WiFi + Cellular //iPad PRO 11" (2nd Gen) @"iPad8,9" - WiFi @"iPad8,10" - 1TB, WiFi //iPad PRO 12.9" (4th Gen) @"iPad8,11" - (WiFi) @"iPad8,12" - (WiFi+Cellular) // iPad mini 5th Gen @"iPad11,1" - WiFi @"iPad11,2" - Wifi + Cellular // iPad Air 3rd Gen @"iPad11,3" - Wifi @"iPad11,4" - Wifi + Cellular // iPad (8th Gen) @"iPad11,6" - iPad 8th Gen (WiFi) @"iPad11,7" - iPad 8th Gen (WiFi+Cellular) // iPad Air 4th Gen @"iPad13,1" - iPad air 4th Gen (WiFi) @"iPad13,2" - iPad air 4th Gen (WiFi+Cellular) //iPod Touch @"iPod1,1" on iPod Touch @"iPod2,1" on iPod Touch Second Generation @"iPod3,1" on iPod Touch Third Generation @"iPod4,1" on iPod Touch Fourth Generation @"iPod5,1" on iPod Touch 5th Generation @"iPod7,1" on iPod Touch 6th Generation @"iPod9,1" on iPod Touch 7th Generation // Apple Watch @"Watch1,1" on Apple Watch 38mm case @"Watch1,2" on Apple Watch 38mm case @"Watch2,6" on Apple Watch Series 1 38mm case @"Watch2,7" on Apple Watch Series 1 42mm case @"Watch2,3" on Apple Watch Series 2 38mm case @"Watch2,4" on Apple Watch Series 2 42mm case @"Watch3,1" on Apple Watch Series 3 38mm case (GPS+Cellular) @"Watch3,2" on Apple Watch Series 3 42mm case (GPS+Cellular) @"Watch3,3" on Apple Watch Series 3 38mm case (GPS) @"Watch3,4" on Apple Watch Series 3 42mm case (GPS) @"Watch4,1" on Apple Watch Series 4 40mm case (GPS) @"Watch4,2" on Apple Watch Series 4 44mm case (GPS) @"Watch4,3" on Apple Watch Series 4 40mm case (GPS+Cellular) @"Watch4,4" on Apple Watch Series 4 44mm case (GPS+Cellular) @"Watch5,1" on Apple Watch Series 5 40mm case (GPS) @"Watch5,2" on Apple Watch Series 5 44mm case (GPS) @"Watch5,3" on Apple Watch Series 5 40mm case (GPS+Cellular) @"Watch5,4" on Apple Watch Series 5 44mm case (GPS+Cellular) @"Watch5,9" on Apple Watch SE 40mm case (GPS) @"Watch5,10" on Apple Watch SE 44mm case (GPS) @"Watch5,11" on Apple Watch SE 40mm case (GPS+Cellular) @"Watch5,12" on Apple Watch SE 44mm case (GPS+Cellular) @"Watch6,1" on Apple Watch Series 6 40mm case (GPS) @"Watch6,2" on Apple Watch Series 6 44mm case (GPS) @"Watch6,3" on Apple Watch Series 6 40mm case (GPS+Cellular) @"Watch6,4" on Apple Watch Series 6 44mm case (GPS+Cellular)
- 投稿日:2021-02-26T19:02:40+09:00
Xcodeを使ってipaをiPhoneにインストールする
対象
以下に当てはまる人
- adhoc用などで書き出したipaファイルを配布するまえに実機で確認したい
- ipaインストールのために余計なソフトを導入したくない
- 以前はiTunesでできたらしいが今はできなくなって困っている
手順
Xcodeを開き、メニューバーから
Window > Devices and Simulators
を開く
インストールしたいipaファイルを選択肢して
open
をクリック
インストールが始まります。
後書き
普通に初歩的なことだと思うけど、調べてもこれに関する情報が見つからなかった。
appleの純正ソフトだとApple Configurator2というのを使えばできるらしい。それ以外だと有料ソフトを使うとか、脱獄するとかスマートではない方法ばかりヒットした。
- 投稿日:2021-02-26T18:33:35+09:00
【iOS14】アプリにウィジェット(WidgetKit)を導入してみた
【watnowテックカレンダー2020/2/26】
今日はwatnowのryotaが担当します
よろしくお願いしますはじめに
2020年9月にiOS14がリリースされ、その追加機能の一つとしてウィジェットという機能が利用可能になりました。
Apple公式のWidget Kitについて
Apple Introducing WidgetKitウィジェットはアプリの一部情報をiOSのホーム画面へ表示させられる機能です。この機能を用いればアプリを開かずにちょっとした情報を確認することができます。またウィジェットに任意の画面へのリンクを設定することもできユーザのアプリの利用率も向上させられます。
一方でWidgetKitを作成するにはswiftUIで実装する必要があり、なかなか導入しにくいと思う部分もあると思いますが、やってみると簡単に作成することができたので共有しようと思います。
Widget Kitを導入する
まずWidget Kitを導入するにあったってQ&Aです。(主に僕が思ってたことです)
Swiftでプロジェクトを作ってるけど、SwiftUIのWidget Kitってそもそも共存できるの?
- SwiftUIはSwift言語で使用できるUI構築フレームワークということもあり問題なく共存できます。
ただしWidget KitはiOS14以降のみの対応のため、それより以前のバージョンの端末では使用できない点に注意してください。Swiftしか触ったことないけどSwiftUIでコードを書くのはちょっと..
- Swiftを書いたことのある方にとってSwiftUIはなんとなくであれば実装しやすい言語だと思います。swiftUIではソースコードと、iPhone画面を同時に表示するプレビュー機能(名称はCanvas)があるので簡単にViewを確認できます。Widget Kitを導入する場合でもそこまで複雑な処理やUIを実装しない限り、実装するコードは少なくてすみます。
Widgetにメモリ制限とかはあるの?
- Widget Kitではホーム画面に常時設置されるという特性上、厳しいメモリ制限があるようです。トータルで最大30MB以上のデータはロードできないといった報告があるようです。似たような例としてApp extensionsを実装したこちらの記事によると、使用する機種によって使用できるメモリ量も増減するようです。いずれにせよ複雑なグラフの描画や、解像度の高いデータの取得などは行わない方が良さそうです。
- メモリ制限とかについて
10 Tips on Developing iOS 14 WidgetsもしSwiftUIを初めて使うという方はまずは、公式のチュートリアルから始めるとわかりやすいです。
Appleが掲載している公式のSwiftUIチュートリアル
* Introducing SwiftUI上記のSwiftUIチュートリアルを日本語で解説されているページもあります
* 【第1回】日本語版SwiftUIチュートリアル【基本要素を学ぶ】導入
まずはWidgetKitのデータの取り扱い方についてはこちらの記事が非常にわかりやすくまとまっているので概要を掴むとわかりやすいです。
【iOS14】Widget(WidgetKit) まとめWidget Kitを導入していきます。
プロジェクトを作成したあとに、File=>New=>Target
真ん中らへんにWidget Extensionがあるのでクリックし、Next
Product Nameを適当に決めてFinish
するとProduct Name以下にこのようなファイルが出来上がっていると思います。
それぞれの役割として
- widgetDemo.swift
- =>データやViewとかの処理をまとめて行う。追加時には時刻を表示する場合のサンプルコードが記入されている。もし独自のウェイジェットを実装していくならViewとかでファイルを分けるとわかりやすい。
- widgetDemo.intendefinition
- => メインのアプリとデータを共有したりするときにここで設定する
- Assets.xcassets
- => 静的な画像データとかはここにおく
- info.plist
- => Widgetの設定プロパティ
以下のようにWidgetExtensionのViewが表示されています。
この状態で アプリのビルドを行い、ホーム画面を長押し=>widgetを追加する
これでホーム画面に現在の時間が表示されるウィジェットが追加されます。時計を表示させるだけなら実はこれだけで実装となります。
Simulator Screen Shot - iPod touch (7th generation) - 2021-02-26 at 11.59.45.pngAPIからデータをとってきて表示させる
ここまでで、すでにホーム画面にWidgetを追加して、時間を表示させることができました。
ここをベースに開発していくことでアプリ側からデータを受け取って表示させたりサーバからデータを取得して表示させたりすることができます。
では今回はAPIからデータをとってきて表示させるWidgetを開発しようと思います。完成はこんな感じです。今回はNewsAPIからJSONで技術系のニュースデータを取得してその1つめのデータをWidgetに追加するようにします。
まずはニュースを取得するためにNewsAPIを利用するのでそのAPIKeyを取得します。News系のAPIで探してみるとこれが多く使われてるみたいです。APIKeyを取得したら以下のようにGETするとレスポンスがかえってきます。
http://newsapi.org/v2/top-headlines?sources=techcrunch&apiKey=APIキーWidgetKitがHTTP通信を行うことができるようにinfo.plistを編集してあげる必要があります。HTTPS通信が必須化しているためHTTP通信を行うには許可する必要があります。
今回編集するのはアプリのinfo.plistではなく、Widgetのフォルダに作成されたinfo.plistを編集する必要があることに注意してください。
HTTP通信の許可する方法についてはこちらが参考になります。
iOSアプリのhttp通信を許可する方法まずはCodableなモデルを作成していきます。
JSONからモデルを作成する時は、Convert JSON into gorgeousが便利です。JSONを入力するだけでいい感じにモデルを作ってくれます。import WidgetKit import SwiftUI import Intents import Foundation // MARK: - News struct News: Codable { let status: String? let totalResults: Int? let articles: [Article]? } // MARK: - Article struct Article: Codable { let source: Source? let author, title, articleDescription: String? let url: String? let urlToImage: String? let publishedAt: String? let content: String? enum CodingKeys: String, CodingKey { case source, author, title case articleDescription = "description" case url, urlToImage, publishedAt, content } } // MARK: - Source struct Source: Codable { let id: ID? let name: Name? } enum ID: String, Codable { case techcrunch = "techcrunch" } enum Name: String, Codable { case techCrunch = "TechCrunch" }次はWidgetKitのメインとなる部分です。今回は最初からあるSimpleEntryにnewsDataの項目を追加しています。取得したデータからTimelineEntryを作成し、それをもとにTimelineを構成していくイメージです。
import WidgetKit import SwiftUI import Intents struct Provider: IntentTimelineProvider { @ObservedObject var NewsStore = SessionStore() func placeholder(in context: Context) -> SimpleEntry { #Widgetが読み込み中の時に呼ばれる SimpleEntry(date: Date(), configuration: ConfigurationIntent(), newsData: [] as? News) } func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) { #Widgetを追加しようとするときに例として表示される #ダミーのデータをいれるとよい let entry = SimpleEntry(date: Date(), configuration: configuration, newsData: [] as? News) completion(entry) } func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) { var entries: [SimpleEntry] = [] /// 1時間ごとに更新する let refresh = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() NewsStore.fetch{ newData in entries.append(SimpleEntry(date: Date(), configuration: ConfigurationIntent(), newsData: newData)) let timeline = Timeline(entries: entries, policy: .after(refresh)) completion(timeline) } } } struct SimpleEntry: TimelineEntry { let date: Date let configuration: ConfigurationIntent let newsData:News? } struct newsWidgetEntryView : View { var entry: Provider.Entry var body: some View { VStack{ URLImageView(url: URL(string: entry.newsData?.articles?[0].urlToImage ?? "")!) .aspectRatio(contentMode: .fill) Text(entry.newsData?.articles?[0].title ?? "") .foregroundColor(.black) .font(.headline) .padding(8) } } } @main struct newsWidget: Widget { let kind: String = "newsWidget" var body: some WidgetConfiguration { IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in newsWidgetEntryView(entry: entry) } .configurationDisplayName("News Widget") .description("News Widgetの説明") #大きなwidgetのみ対応する .supportedFamilies([.systemLarge]) } } struct newsWidget_Previews: PreviewProvider { static var previews: some View { newsWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), newsData: [] as? News)) .previewContext(WidgetPreviewContext(family: .systemSmall)) } }データを取得するクラスはこんな感じで実装しています。
import Foundation import Combine final class SessionStore: ObservableObject { @Published var current : News? init(){ self.fetch{ val in print(val) } } } extension SessionStore{ func fetch(completion : @escaping(News)->()){ NewsClient.fetchSummary { self.current = $0 completion($0) } } }最後にURLから画像を読み込む処理についてです。
Swiftで非同期で画像を取得するときはKing Fisherとかが便利なんですが、今回はSwiftUIで実装するということもあり処理を別途書く必要があります。import SwiftUI struct URLImageView: View { let url: URL @ViewBuilder var body: some View { if let data = try? Data(contentsOf: url), let uiImage = UIImage(data: data) { Image(uiImage: uiImage) .resizable() } else { Image(systemName: "photo") .resizable() } } }以上で、Widgetの実装は終わりになります。
これでWidgetを追加すると最新のニュースが表示されます。今回使用したサンプルプロジェクトは以下になります。
https://github.com/ryota2425/widgetExample最後に
WidgetKitはSwiftUIでの実装で難しそうに見えますが、ぜひチャレンジしてみてください。
今回はAPIの情報を表示させるだけでしたが、これを発展させてクリックした場にアプリで記事を表示させるとより実用的になっていくかと思います!
- 投稿日:2021-02-26T16:28:51+09:00
daeファイル作成時に気を付けること
Mayaでdaeファイルをexportすると、ソフトウェアによってはblendShapeが読み込まれない。 exportしたdaeファイルをテキストエディタ等で開いて、「RELATIVE」を「NORMALIZED」にすべて書き換えると使えるようになる。 また、ピボットポイントを変更してもそのままだと反映されないので、修正→ピボットをベイク処理をしておく必要がある
- 投稿日:2021-02-26T14:55:55+09:00
【SwiftUI】NavigationViewの基本的な使い方(前編)
前回に引き続き、アプリに使用した技術をアウトプットしていきます。
解説する私のアプリはこちら。
わかりすと -勉強サポートアプリ-NavigationView
階層的な画面遷移を管理するビュー。
NavigationLink
NavigationView内で、画面遷移を行うトリガーになるビューと遷移先のビューの両方を指定できるビュー。NavigationViewとセットで使う。
実際の動作
※拡大推奨
動画のポイント
- NavigationViewの外側の領域は切り替わらず、遷移先にも表示される。(動画内のヘルプボタン)
- 遷移先の画面の左上には、前の画面にもどるためのボタンが表示される。
- もどるボタンにはタイトルがそのまま書かれる。
コード
今回作成したサンプルアプリのソースコードはこちら
まず、一番上の階層から見ていきましょう。
ContentView.swiftimport SwiftUI struct ContentView: View { let regions = ["カントー地方", "ジョウト地方", "ホウエン地方", "シンオウ地方"] var body: some View { VStack{ // ここから HStack{ Spacer() Button(action: {}, label: { Text("ヘルプ") }) } .padding(.horizontal) // ここまではNavigationViewに含まれない //ここから NavigationView { List(0..<4) { n in NavigationLink(regions[n], destination: Region(id: n, regionName: regions[n])) } .navigationTitle("そらをとぶ") } //ここまでがNavigationViewの領域 } } }基本として、遷移する(切り替わる)のはNavigationViewの{}内の要素だけです。上のソースコードの「ヘルプ」というラベルのボタンはNavigationViewに含まれないため、リンクをタップしても切り替わらずに画面上部に残っています(動画を確認してください)。今回の「ヘルプ」ボタンのように、何らかの理由で遷移先にもずっと表示させたいものがある場合はNavigationViewに含めない方が良いでしょう。
次にNavigationViewの中を見ていきます。
NavigationViewの中にはNavigationLinkが書かれており、Listの繰り返し処理で4つ並ぶようにしています。上で説明したとおり、NavigationLinkは画面遷移のトリガーと遷移先のビューを指定できます。今回は
regions[n]
(配列regionsのn番目の要素)がトリガー、Regionビュー
が遷移先となっています。Regionビューのソースコードはこちら。
Region.swiftimport SwiftUI struct Region: View { let id: Int //地方によって表示を変えるためのプロパティ let regionName: String //地方の名前をタイトルにするためのプロパティ var body: some View { //NavigationViewは書かない List(0..<4) { n in NavigationLink(regionsArray[id][n], destination: Flight(city: regionsArray[id][n])) .navigationTitle(regionName) } } } //各地方ごとのデータ let kanto = ["マサラタウン", "トキワシティ", "ニビシティ", "ハナダシティ"] let joto = ["ワカバタウン", "ヨシノシティ", "キキョウシティ", "ヒワダタウン"] let hoen = ["ミシロタウン", "コトキタウン", "トウカシティ", "カナズミシティ"] let shino = ["フタバタウン", "マサゴタウン", "クロガネシティ", "コトブキシティ"] //idに対応する配列を取り出すための配列 let regionsArray = [kanto, joto, hoen, shino]ここで見てほしいのは、NavigationLinkはあるのにNavigationViewの記述がどこにも書かれていないことです。NavigationViewは一番上の階層となるビューに書くだけで、遷移先にも機能を持たせることができます。
逆に、下の階層にもNavigationViewを書き、次の画面に遷移するとこのようになります。
画面左上に、前の画面にもどるためのボタンが2つ並んでいることがわかると思います。これはNavigationViewの中にNavigationViewを作っていると判断された結果です。このような表示にしたい場合は別ですが、基本は一番上の階層にのみNavigationViewを書くことをおすすめします。
次回はnavigationTitleについて書きます。
参考文献
SwiftUI徹底入門 金田浩明(著)(【注意】Xcode12非対応のものです)
- 投稿日:2021-02-26T14:54:29+09:00
PoCでiOSアーキテクチャにMVPを採用した話
TL;DR
PoC(検証)のための新規iOSアプリ開発で、iOSアプリ開発初心者がアーキテクチャにMVPを採用した理由と、MVPを実装するする上で気づいたことを共有するものです。
結論
- アーキテクチャの検討に当たっては、①開発要件に合うものの中で、②経験があるor経験がなくても最も理解しやすそうな、設計パターンをサクッと決定するのがよさそうです
- MVPのアーキテクチャパターンにおいては、プレゼンテーションロジックの分離が重要で、その際に活用できるのがProtocol
この記事の効能
- 新規のiOSアプリ開発でアーキテクチャを決定する一助になるかもしれません
- MVPの実装において筆者が大切だと思った、「プレゼンテーションロジックの分離」について理解が深められます
MVPの選定
手順
今回はデザイナーさんが作成してくださったワイヤーフレームがあったのでそれを参考にしながら行いました
1. 開発する画面を列挙する
2. 各画面や画面間で管理する状態を書き出す
3. 上記のステップでまとめた開発要件を元に設計パターンを考える(例)画面・状態を書き出している様子- 管理すべき状態 - 画面をまたぐもの - セッション(ログイン)情報 - サインアップ画面 - IDとパスワードが不正の状態(validationに利用) - カート画面 - カート内のアイテム - 合計金額 etc.今回の前提条件
a. ページ間にまたがる状態はログインの有無だけで、状態管理が複雑でない
b. 検証用のiOSアプリで1+α人程度での小規模な開発
c. 開発者はiOSアプリ開発初心者選択肢
MVC, MVP, MVVM, Flux, Redux
上記の全てについて一通り把握し検討を行いましたが、前提条件のaにあるように複雑な状態管理がないことから、MVCとMVPに絞りました。また他の設計パターンでは、導入に当たって外部のライブラリを使用すべきであったことも選定対象から外れた理由となりました。(例:MVVMにおけるRxSwiftなど)
MVCとMVPの2つの中から選定した理由としては、①決済などテストすべき項目を扱うためテストしやすい方がいい、②MVCから発展したものだからMVPの方が使いやすい(はず)になります。アーキテクチャパターン選定のまとめ
結構時間をかけて真剣に設計パターンの検討を行いましたが、後から見返すとやってみないと分からないことも多いなと思います。また、実際に開発を進めて考えていく中でやっと理解が進むものもあります。結論として、アーキテクチャの検討に当たっては、①開発要件に合うものの中で、②経験があるor経験がなくても最も理解しやすそうな、設計パターンをサクッと決定してしまうことが良さそうかなと思っています。(BFFの設計も考えたことがあり、その経験も踏まえて。)
今回に関して言うと、ページをまたがる複雑な状態管理が必要なく、少人数での開発という開発要件で、比較的単純な(だと感じた)MVPを採用しました。MVPにおけるプレゼンテーションロジック分離の重要性
MVPについて
MVPの中にも、データの流れによってPassive ViewとSupervising Controllerという2つの方式があります。
Passive ViewはViewとModelのデータのやり取りにおいて必ずPresenterを経由するもので、データの流れが理解しやすく、全てのプレゼンテーションロジックのテストが可能です。一方でSupervising Controllerは、Model→Viewの流れにおいて、更新の通知により変更を知らせることができ、Presenterを挟まない分冗長なデータの流れを回避することができます。今回は、テストが容易な点と開発の簡潔な点を重視してPassive Viewを採用しました。
それぞれの詳しい図解と解説は以下の記事が分かりやすかったです。(ただ、Supervising Controllerについては以下の記事ではData bindingになっていますが、参考文献のようにオブザーバー同期を使うものもあります)
StackOverFlowの「MVPとMVCの違い」についての回答を読んでみたPassive Viewにおける各コンポーネントの役割
コンポーネント 役割 Model コマンドを受けて自身を更新(Presenterからのみアクセスされ、Viewとは直接の関わりをもちません) View ユーザーの入力を受け、Presenterに伝える(Presenterからの描画指示に従うだけで、完全に受け身な立ち位置になります) Presenter ViewからのコマンドをModelに送る・Modelからの変更を受け取り、Viewを更新(すべてのプレゼンテーションロジックを受けもちます) (参考文献より)
補足:プレゼンテーションロジックとは
「View(ユーザーの入力など)やModel(データの変更など)の変更から、画面の更新・遷移を行うロジックのこと」Protocolの活用によるプレゼンテーションロジックの分離
実装している中で、プレゼンテーションロジックがViewに紛れていることに気付きました。今回はiOSアプリでよく用いられるViewControllerというファイルにViewの役割を持たせたのですが、ViewControllerのファイルの中に直接プレゼンテーションロジックを書き込んでいたのです。これはひとえに、各コンポーネントの役割を完全に理解できていなかったことが原因であると考えます。そこで以下の記事を見つけ、Protocolの活用法に気づくと共に、リファクタを行なってプレゼンテーションロジックをViewControllerから取り除きました。
MVP VS MVVM in iOS using swift実装に当たっては、上記の記事よりもPresenterの役割が分かりやすいコードがあったのでそちらを参考にしました。
以下が参考にしたコードです。(参考文献で扱われているコードです。)
ポイントは、protocolの定義において、ユーザーからの入力(SearchUserPresenterInput)と、画面更新・遷移(SearchUserPresenterOutput)を分けて行うことで、それぞれの役割を明確にしている部分です。
MVPにおいてPresenterを実装するということ
簡単なことではありますが、気づくのに時間がかかったポイントを挙げます。
「全てのプレゼンテーションロジックを受けもつPresenterを実装する」ということは、
- プレゼンテーションロジックは全てPresenterから呼び出されるべき(=Viewが呼び出すことはなく、また当然Viewに直接プレゼンテーションロジックを記述することもない)
- ユーザーからの入力はInputProtocol、画面の描画はOutputProtocolで定義し、OutputProtocolの実装自体はViewControllerで継承して行うのがよい
ということであると理解しました。
(追記)この記事にいただいたご質問・ご意見
MVPは難しくないの?
参考文献では、MVCの責務を再分割したもの(MVCの三要素を60°回転したもの)であると言及されていたため、そこまで複雑なものではないと考えていました。
また以下の記事でも、MVVMやReduxと比べれば、規模を小さく開発できると考えられます。
手を動かしてMVPパターンを体験する自分としては、記事で説明したようにPresenterを正しく理解できないなどのポイントはあるものの、小規模開発にも適したパターンであると考えます。今回、iOSアプリ開発が初めてであったことを考えると、MVC(Cocoa MVC)の習得にも学習コストは存在するので、MVPとそれほど差異はないのではないかと考えています。
MVCとの違いは?
MVCに対するMVPの利点として挙げられるのは、テストの容易性と作業分担のしやすさになります。これはPresenterという役割を作り、プレゼンテーションロジックを取り出したことによるものかと思われます。
特にPassive Viewの場合は、データの流れが単一なので実装に迷うことがないというメリットもあると、実装する中で感じました。AndroidにもMVPはある?
あるようです。
Architecture of Android Appsまた、Androidには公式のアーキテクチャガイドがあるようで、素敵だなと思いました。
アプリ アーキテクチャ ガイド参考
iOSアプリ設計パターン入門
iOSアプリ開発に限らず、設計パターンを学びたい方に損得抜きで強くおすすめです。歴史的経緯も含めた設計パターンの丁寧な解説、さらに各設計パターンのサンプルコードで構成されています。
- 投稿日:2021-02-26T12:57:40+09:00
【NoCode】5年目iOSエンジニアが、アプリのE2Eテスト自動化サービスを作った話
iOSエンジニア歴も早5年目となりました。
その経験を活かして、iOSアプリのテスト自動化サービス「SmartQA」を作りました!
今回の開発では、Firebaseが大活躍してくれました。
SmartQAをリリースする上で、AppiumやFirebaseの技術的な知見をここに残せたらと思います。
概要
ビルドをアップロードするだけで、ブラウザ上で簡単にテストの自動化ができます。
UIが変わってしまった場合でも、要素探索を自動で修復してくれます。
複数端末対応しているので、「iPhone SEやiPadだとクラッシュしてしまった」なんてことも検知できます。
現在はiOSアプリのみ対応しております。
私について
10歳の頃にアセンブリに出会ったのがきっかけで、プログラミングの世界にのめり込みました。
どんなプログラムも足し算・引き算・比較・ジャンプで動いてることに感動したことを今でも覚えています。
京都大学工学部情報学科を卒業して、Levetty株式会社を立ち上げました。
在学中にiOSエンジニアとして働き始めました。
当時京都では学生がエンジニアとして働ける会社がほとんどなく、大学生協のアルバイト募集に稀に出現するエンジニア募集を見つけるしかありませんでした。
大学生協を毎日クロールするスクリプトを組んで、LINEに通知して最速で応募したりしてました。
開発体制
開発期間としては、9月に実現できるのか技術検証を行い、10月から開発を始めました。
基本的に僕1人で開発を進めていたのですが、仲の良いエンジニア3人に手伝ってもらえたのがすごく大きかったです。
設計を綺麗にしてくれたり、インフラ整えてくれたり、なかなか手が回らない部分の実装をしてくれたり、サービスのアイデアをいただけたり。
とても感謝しています。
使用した技術
Appium
iOSやAndroidでシミュレータを操作するのに必要なライブラリです。
iOSで使う場合は、内部でXCUITestを使用しています。
Firebase
- Authentication
- Cloud Firestore
- Cloud Functions
- Cloud PubSub
- Cloud Storage
- Hosting
React.js / Redux / TypeScript
フロントは、SPA(シングルページアプリケーション)で作成しました。
Material-UIが大活躍してくれました。
Express / Node.js / TypeScript
テスト実行サーバー、macOS上で動作する簡易的なサーバーは全てExpressで実装しました。
AWS CDK / Ansible
サーバー側の実装は、Cloud Functionsに寄せていました。
しかし、540秒の実行時間の制限がきつく、テスト実行処理はAWSのEC2で運用することになりました。
手伝ってくれている凄腕エンジニアがAWS CDKとAnsibleを使って構築してくれていました。
macOSを何台も用意する必要があるので、Ansibleを触ったのですが、革命を感じました。
CDKについては、僕は触ってないので分かりません、、。が、めちゃくちゃ良いみたいです。
苦労したこと
大量のMacのセットアップ
iOSのシミュレータを使用するには、macOS環境が必要です。
SmartQAでは、macOSを貸し出してくれるMacStadiumというサービスを利用することにしました。
借りたMacを1つ1つ設定していくのですが、それはもう大変でした。
途中からAnsibleで自動化され楽にはなったものの、それでもXcodeをインストールするのは手動じゃないとダメだったりします。(2ファクタ認証の関係で)
平成時代から続くiOSシミュレータ自体のバグ
5年前に、AppleのDeveloperフォーラムに投稿されている内容です。
Hardware keyboard not working in simulator (toggle in on)Macのキーボードをシミュレータに接続しない設定で起動しても、接続されているバグがあるんですよね。
キーボードが接続されていると、UITextFieldにフォーカスするために、2回タップが必要だったり。
実際とは違う挙動をしてしまうんですよね。
もちろんApple信者としては、仕様として受け入れることにしました
調べた限り対策は1つしかなく、ここを2回クリックしてオンオフするしかないみたいです。
AppleScriptというものをこの時初めて知り、起動直後にConnect Hardware Keyboardを2回クリックするスクリプトを用意しました。
tell application "System Events" set hwKB to value of attribute "AXMenuItemMarkChar" of menu item "Connect Hardware Keyboard" of menu 1 of menu item "Keyboard" of menu 1 of menu bar item "I/O" of menu bar 1 of application process "Simulator" click menu item "Connect Hardware Keyboard" of menu 1 of menu item "Keyboard" of menu 1 of menu bar item "I/O" of menu bar 1 of application process "Simulator" click menu item "Connect Hardware Keyboard" of menu 1 of menu item "Keyboard" of menu 1 of menu bar item "I/O" of menu bar 1 of application process "Simulator" if ((hwKB as string) is equal to "missing value") then do shell script "echo 'hardware keyboard is off'" else click menu item "Connect Hardware Keyboard" of menu 1 of menu item "Keyboard" of menu 1 of menu bar item "I/O" of menu bar 1 of application process "Simulator" end if end telliOSアプリのUI要素のXMLの情報量が極端に少ない!
Appiumでは、XCUITestのdebugDescriptionを利用してWebでいうところのDOMを取得しています。
そのDOMをXML形式で取得できるのですが、classやidなどの情報があるWebに比べて情報量が圧倒的に少ないです。
<XCUIElementTypeStaticText type="XCUIElementTypeStaticText" value="パスワード" name="パスワード" label="パスワード" enabled="true" x="24" y="87" width="75" height="20"/> <XCUIElementTypeSecureTextField type="XCUIElementTypeSecureTextField" value="Abc1234" name="" label="" enabled="true" x="190" y="87" width="161" height="21"/> <XCUIElementTypeTextField type="XCUIElementTypeTextField" value="ピッカー入力" name="" label="" enabled="true" x="24" y="131" width="327" height="22"/> <XCUIElementTypeButton type="XCUIElementTypeButton" name="Login" label="Login" enabled="true" x="165" y="185" width="45" height="34"> <XCUIElementTypeStaticText type="XCUIElementTypeStaticText" value="Login" name="Login" label="Login" enabled="true" x="165" y="191" width="45" height="22"/> </XCUIElementTypeButton>こちらがXMLの一部です。
UIButtonやUITextField、UILabelについて得られる情報は、要素の種類・座標・サイズ・値があります。
<XCUIElementTypeImage type="XCUIElementTypeImage" enabled="true" x="171" y="598" width="45" height="44"/> <XCUIElementTypeOther type="XCUIElementTypeOther" enabled="true" x="235" y="612" width="17" height="16"/>しかし、UIImageViewやUIViewの場合は、座標とサイズだけが頼りになります。
そこで、何かしらの方法を用いて要素の推定を行う必要があります。
今回は、ルールベースとAIを組み合わせて推定することにしました。
ちなみに、ソースコードを変更できるのであれば、Accessibility Identifierを割り当てると、nameが固有の値になるので、追従がしやすくなります。
Appiumの謎のエラーでシミュレータが起動しなくなる
クラウドのmacOSで、いつも通り何度もシミュレータを立ち上げていたところ、Appiumでエラーが出力され、シミュレータが強制終了するようになりました。
Appiumの再インストール、Xcodeの最新版をインストールなど様々な方法を試しても治りませんでした。
ふと、macOS自体を再起動したところ正常に動き始めました。
????
憶測ですが、シミュレータを立ち上げごとにゾンビプロセスが生成されてしまってるのかもしれません。
この日を境にmacOSは、定期的に再起動される運用になりました
よかったこと
Appiumサーバーを複数立てることで並列化が簡単
テストの実行を早く終わらせるために、並列化をしようと試みました。
Appiumサーバーを複数起動することで実現することができました!
(WebDriverAgentとAppiumのポートがかぶらないように気をつけてください)
M1 Mac上で同一シミュレータを複数立ち上げる、人類の夢を達成しました。
— こーや (@koyataroo) February 12, 2021
E2Eテストが一瞬で終わる世界線作る! pic.twitter.com/P3JdxByXhRFirestoreのCollection Groupが強力
SmartQAでは、セキュリティを最大限考慮した設計になっています。
project
配下に情報を置くことで、セキュリティルールで縛りやすくしています。その代わり、横断検索が大変になってしまいます。
2019年にリリースされたCollection Groupで、解決することができました。
/project/{projectID}/collectionA/{documentID}通常、このような階層で
collectionA
に対して検索する場合、それぞれのプロジェクトに対して実行しなければいけません。しかし、CollectionGroupでは、同じコレクション名であれば横断的に検索をすることができます。
どんな階層に存在していても可能です。
インデックスを設定するだけで使用できて、非常に簡単でした。
さいごに
インフラからバックエンド・フロント、更にはmacOSのシミュレータの資源割付まで、幅広い実装でエンジニア人生の集大成ともいえるプロダクトでした。
テスト実行の安定化や、機能追加に励んでいきたいと思います!
長期的には、Android対応などやることがたくさんあります。
全然人手が足らなくて、エンジニアさんやQAさんを絶賛募集しております。
少しでも興味があれば、contact@levetty.co.jpか@koyatarooに連絡お待ちしております!