- 投稿日:2020-04-06T23:56:18+09:00
遷移しよー!with CircleMenu
こんにちは!!!
手元からPCが消え去って1ヶ月。やっっっとパソコンが買えたので投稿。
今回はCircleMenuというオシャレなライブラリがあることを(今更)知ったのでこのアニメーションを利用してふわっと遷移しようという趣旨。
別にCircleMenuのオシャレなアニメーションに乗っかって遷移してるだけのハリボテです。
ブランクあるんや、、リハビリみたいなもんや、、成果物
なんかgifだと残念な感じに円が残るけど実際には普通にスゥーっ!っと消えます!!!(血眼)
コード
import UIKit import CircleMenu class ViewController: UIViewController { let menuButton: CircleMenu = { let view = CircleMenu(frame: CGRect(x: UIScreen.main.bounds.width / 2 - 25, y: UIScreen.main.bounds.height / 2 - 25, width: 50, height: 50), normalIcon: "menu", selectedIcon: "error", buttonsCount: 5, duration: 0.3, distance: 150) view.backgroundColor = .green view.layer.cornerRadius = view.frame.size.width / 2.0 return view }() override func viewDidLoad() { super.viewDidLoad() menuButton.delegate = self self.view.addSubview(menuButton) } } extension ViewController: CircleMenuDelegate { func circleMenu(_ circleMenu: CircleMenu, buttonDidSelected button: UIButton, atIndex: Int) { var vc: UIViewController? switch atIndex { case 0: vc = NextViewController() case 1: vc = NextViewController() case 2: vc = NextViewController() case 3: vc = NextViewController() case 4: vc = NextViewController() default: print("no vc") } guard let VC = vc else { return } VC.modalPresentationStyle = .overCurrentContext self.present(VC, animated: false, completion: nil) } }import UIKit class NextViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = .red let tap = UITapGestureRecognizer(target: self, action: #selector(dismissAction)) self.view.addGestureRecognizer(tap) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.view.alpha = 0 UIView.animate(withDuration: 0.5) { self.view.alpha = 1 } } @objc func dismissAction() { self.dismiss(animated: false, completion: nil) } }解説
import らへん
import CircleMenu今回のメインであるライブラリのインポートですね。githubではbuildの項目がfailingになってましたけど普通に使えますね。なんででしょ。余裕があったら読みたいものですな。
view らへん
let menuButton: CircleMenu = { let view = CircleMenu(frame: CGRect(x: UIScreen.main.bounds.width / 2 - 25, y: UIScreen.main.bounds.height / 2 - 25, width: 50, height: 50), normalIcon: "menu", selectedIcon: "error", buttonsCount: 5, duration: 0.3, distance: 150) view.backgroundColor = .green view.layer.cornerRadius = view.frame.size.width / 2.0 return view }()viewの宣言をクロージャで宣言してるんや。これは宣言してるviewが使われるときに一度だけ初期化されるヨ。
この形をとることでひとまとまりになってるし、viewDidload内が汚れないから見やすいね!
あとはあんまり良くないかもしれないけど、中で宣言するインスタンス名をviewにすることで他のviewを宣言するときコピペがとっても楽になるよ!笑delegate らへん
delegateは移譲って言って、こいつがいるときは処理を他人任せにしてる証拠だ!!(
そして大抵僕に丸投げされてるんだ。これは私情。)もし君が仕事を任されたとき自分なりのやり方でやるよね???(異論ナシ。)
delegateも任された側のやり方で処理することができるから便利なんだ。
delegateと一緒だね!!!!(歓喜)あと、通知の役割も大きいけど今回は使ってないからパス _ (:3 」)_
menuButton.delegate = selfこのコードからは、menuButton先輩が、self、つまり私(ViewController)に仕事をぶん投げるということを高らかに宣言しているのだ。(
やめてくれよ)逆に言えば、このように高らかに宣言してくれないとdelegateできない。つまり仕事を任せられていないので、
「お前これ頼んだよな?(威圧)」
「は?寝ぼけてんのか?(えっ、??いや、頼まれてないですよ、、?)」なんてことが起こる。(特に良く僕のコード内で。うっかり。照)
Extension らへん
swiftに限らず継承はとても便利だ。
ここでは便利なextensionの継承の、書き方について触れておこうと思う。extension ViewController: CircleMenuDelegate { //省略 }プロトコルを継承することで決まった形のクラスや構造体を作れるし、うまくかければ具象への依存かなり減らせてそれだけでとても変更しやすくなる。
しかし、ViewControllerの定義でついつい一度に継承するものを羅列してしまいがちだ。
class ViewController: UIViewController, UITableViewDelegate, CircleMenuDelegate { //省略 }これではどの関数がどのクラス・プロトコルから継承してきたものかかなり分かりづらいし、区切りのないコードが一気に肥大してしまう。
そのため、今回のように一つ一つ優しくextensionしてあげよう。これだけで見通しが良くなるし、「あっ、こいつイラネ!w」となった時もどこを消せばいいかすぐわかる。
追加もしやすい。書き方をちょっと変えるだけでいいことづくめなのだ。
guard らへん
guardとif。ifが便利すぎでわざわざguardを使う意味がわからないという方もいるのではなかろうか。
ペーペーの僕がいうのもなんだが、僕なんかよりずっといいコードを書いている人でもguardを使わずifを使っていることがあるから頭に「?」が浮かぶ。
こんなに良い関数は他にないんじゃないか? それは無いか。
(ちなみに僕的、昨今の推しはmapです。)guard let VC = vc else { return }guardはなんと言っても一眼で役割が分かるのがいい。
- guardを使う場面はreturnされて本土(呼び出し元)に返されるか、fatalErrorでも呼ばれてアプリがクラッシュするかくらいだ。
「いやそれifでもできるやーん?」
そうなんだけど、あえてguardを使うことで自分や他の人がそこを読む時、一瞬で意図がわかる。
素敵だ。(イケボ)
そしてしばしばletとセットでオプショナル回避に用いられる。
let optional: Int? = 3 guard let Nakami = optional else { return } // optional : Optional<3> // Nakami : 3このように中身を簡単かつ安全に、そして中身がない時は本土に返還するスグレモノなのだ。
lifecycle と animation らへん
animationを行うことで今回はそれっぽーく仕上げでいる。
透明度を表すalphaの値を事前に0にして完全に透明にしておく。viewWillAppearが呼ばれたらアニメーションを行って、alphaを1すればふわっと素敵っぽい遷移が実現できた。(急に完了する。)
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.view.alpha = 0 UIView.animate(withDuration: 0.5) { self.view.alpha = 1 } }ここで大事なのでviewWillAppearで呼ぶことだ。
これより前のviewDidloadで呼ぶと、そもそもアニメーションが行われない。
しかもvcを再利用していればviewDidloadは一回しか呼ばれない。この後のviewWillLayoutSubviewsは名前の通りサブビューの描画準備のためにあるので役割として明らかにおかしい。
ここら辺に関してはもっと良い書き方があるんだろうなぁという感覚なので勉強します。
ここまで読んでいただきありがとうございました。
キーボードは配列が前のは海外仕様だったから逆に日本仕様に慣れない、、
書くつもりなかったのにPCきたの嬉しすぎて夢中になって書いてしまった、、
明日仕事なのに。もう寝なきゃ。おやすみなさい
- 投稿日:2020-04-06T22:12:09+09:00
【Flutter】BLoCパターンでアプリを作成してみた。~カウンターアプリ、githubリポジトリ検索アプリ~
BLoCパターンとは
BLoC PatternはFlutterでのアプリケーション開発時に用いる、状態管理手法の1つです。
ビジネスロジックをコンポーネント単位で管理しやすくするためのパターンです。こちらを参考にしてください。
サンプルアプリの紹介
カウンターアプリ
プラスボタン、マイナスボタンを押下することで画面中央の数字がインクリメント、デクリメントされます。
githubリポジトリ検索アプリ
TextFieldに検索キーワードを入力して、検索すると対象のGitHubリポジトリの一覧を表示して、要素をタップするとWebViewで表示します。
ソースコード解説
main.dartimport 'Model/counter_bloc.dart'; import 'Model/search_bloc.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'UI/counter_page.dart'; import 'UI/search_page.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MultiProvider( providers: [ Provider<CounterBloc>( create: (context) => CounterBloc(), dispose: (context, bloc) => bloc.dispose(), ), Provider<SearchBloc>( create: (context) => SearchBloc(), dispose: (context, bloc) => bloc.dispose(), ), ], child: MyHomePage(title: 'Flutter BLoC Sample'), ) ); } } class MyHomePage extends StatelessWidget { MyHomePage({this.title}); final String title; @override Widget build(BuildContext context) { return DefaultTabController( length: 2, child: Scaffold( appBar: AppBar( title: Text(title), bottom: TabBar( tabs: <Widget>[ new Tab( text: "Count", ), new Tab( text: "Search", ), ], ), ), body: TabBarView( children: <Widget> [ CounterPage(), SearchPage(), ], ), // This trailing comma makes auto-formatting nicer for build methods. ), ); } }Providerの利用
Providerを使うことで、childパラメータに指定したWidget以下全てのWidgetで、同じBLoCインスタンスにアクセスすることができます。
複数のProviderを設定する場合は、MultiProviderを設定します。
class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), home: MultiProvider( providers: [ Provider<CounterBloc>( create: (context) => CounterBloc(), dispose: (context, bloc) => bloc.dispose(), ), Provider<SearchBloc>( create: (context) => SearchBloc(), dispose: (context, bloc) => bloc.dispose(), ), ], child: MyHomePage(title: 'Flutter BLoC Sample'), ) ); } }Counter UIの作成
counter_page.dartimport '../Model/counter_bloc.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; class CounterPage extends StatelessWidget { CounterPage(); @override Widget build(BuildContext context) { final counterBloc = Provider.of<CounterBloc>(context); return new Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'You have pushed the button this many times:', ), StreamBuilder( initialData: 0, stream: counterBloc.count, builder: (context, snapshot) { return Text( '${snapshot.data}', style: Theme.of(context).textTheme.display1, ); }, ) ], ), ), floatingActionButton: Column( verticalDirection: VerticalDirection.up, // childrenの先頭を下に配置 mainAxisSize: MainAxisSize.min, children: <Widget>[ FloatingActionButton( onPressed: () { counterBloc.changeCountAction.add(false); }, tooltip: 'Declement', child: Icon(Icons.remove), ), Container( // 余白のためContainerでラップ margin: EdgeInsets.only(bottom: 16.0), child: FloatingActionButton( onPressed: () { counterBloc.changeCountAction.add(true); }, tooltip: 'Increment', backgroundColor: Colors.redAccent, child: Icon(Icons.add), ), ), ], ) ); } }Counter BLoCの生成
counter_bloc.dartimport 'dart:async'; import 'package:rxdart/rxdart.dart'; class CounterBloc { // input final _actionController = BehaviorSubject<bool>(); Sink<void> get changeCountAction => _actionController.sink; //output final _countController = BehaviorSubject<int>(); Stream<int> get count => _countController.stream; int _count = 0; CounterBloc() { _actionController.stream.listen((isPlus) { if (isPlus) { _count++; } else { _count--; } _countController.sink.add(_count); }); } void dispose() { _actionController.close(); _countController.close(); } }BLoCの呼び出し
BLoCは、子Widgetのbuild()メソッドで呼ぶのが定番です。
@override Widget build(BuildContext context) { final counterBloc = Provider.of<CounterBloc>(context); return new Scaffold(
Sink<T>.add()
でBLoCに値を送る例では、
counterBloc.changeCountAction
に対して、プラスならTrue,マイナスならFalseのBooleanを渡しています。child: FloatingActionButton( onPressed: () { counterBloc.changeCountAction.add(true); },
Stream.listen
でinputの値に対して処理を実行
Stream.listen
で流れてきたBooleanを受け取る。ちなみにStreamはこんなイメージを持ってもらえば良いと思う。
用意された川に対して、今回だとBooleanの要素をプラスボタンやマイナスボタンが押される度に流されるイメージ。
引用: https://medium.com/@teivah/reactivewm-a-reactive-framework-for-webmethods-2c91c7de82b3CounterBloc() { _actionController.stream.listen((isPlus) { if (isPlus) { _count++; } else { _count--; } _countController.sink.add(_count); }); }以下のコードで再度、別のStreamに要素(この例ではint)を流している。
Dart
_countController.sink.add(_count);
StreamBuilder
で値の受け取りStreamBuilderを使って、Streamの値を反映します。StreamBuilderを使うことで、build()メソッドを呼ぶことなくStreamの値に応じてこの箇所だけUIを更新することができます。
StreamBuilder( initialData: 0, stream: counterBloc.count, builder: (context, snapshot) { return Text( '${snapshot.data}', style: Theme.of(context).textTheme.display1, ); }, )これで、カウンターアプリは完成です。
続いて、、、githubのリポジトリ検索アプリを紹介
検索画面UIの生成
search_page.dartimport 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../Model/search_bloc.dart'; import 'package:flutter_inappbrowser/flutter_inappbrowser.dart'; class SearchPage extends StatelessWidget { final _formKey = GlobalKey<FormState>(); TextEditingController queryInputController = TextEditingController(text: ''); @override Widget build(BuildContext context) { final searchBloc = Provider.of<SearchBloc>(context); return Scaffold( body: Center( child: Column(children: <Widget>[ Form( key: _formKey, child: Column(children: <Widget>[ Padding( padding: const EdgeInsets.all(16.0), child: Center( child: TextFormField( decoration: InputDecoration( labelText: '検索キーワード', ), controller: queryInputController, ))), RaisedButton( child: const Text('検索'), onPressed: () => searchBloc.changeQuery.add(queryInputController.text)), ])), StreamBuilder( stream: searchBloc.result, builder: (context, snapshot) { if (snapshot.hasError) { print(snapshot.error); // snapshot.error を使ったWidgetを返す // snapshot は AsyncSnapshot<T> で } if (snapshot.data != null) { return Expanded( child: ListView.builder( scrollDirection: Axis.vertical, shrinkWrap: true, itemCount: snapshot.data.length, itemBuilder: (context, int index) { var item = snapshot.data[index]; return Container( decoration: BoxDecoration( border: Border( bottom: BorderSide(color: Colors.black38), ), ), child: ListTile( title: Text(item['full_name']), subtitle: Text('Star: ' + item['stargazers_count'].toString()), onTap: () { this.openBrowser(url: item['html_url']); }, )); }), ); } return Container(); }) ]))); } openBrowser({String url}) { ChromeSafariBrowser browser = ChromeSafariBrowser(); browser.open(url: url, options: ChromeSafariBrowserClassOptions( androidChromeCustomTabsOptions: AndroidChromeCustomTabsOptions( addShareButton: true, toolbarBackgroundColor: "#ff0000", enableUrlBarHiding: true, ), iosSafariOptions: IosSafariOptions( barCollapsingEnabled: true, ) )); } }検索BLoCの生成
search_bloc.dartimport 'dart:async'; import 'dart:convert'; import 'package:flutterbloc/Model/API/api_service.dart'; import 'package:rxdart/rxdart.dart'; import 'API/chopper_client_creater.dart'; import 'package:chopper/chopper.dart'; class SearchBloc { final searchApi = SearchApi(); final searchQueryController = BehaviorSubject<String>(); Stream<String> get query => searchQueryController.stream; StreamSink<String> get changeQuery => searchQueryController.sink; // APIの返り値となるSearchResult型を自作したと仮定 final searchResultController = BehaviorSubject<List<dynamic>>(); Stream<List<dynamic>> get result => searchResultController.stream; StreamSink<List<dynamic>> get changeResult => searchResultController.sink; SearchBloc() { query.listen((v) async { // APIの返り値となるSearchResult型を自作したと仮定 final List<dynamic> searchResults = await searchApi.fetchApi(query: v); print("------"); print(searchResults); print("------"); if (searchResults.isEmpty) { changeResult.addError(searchResults); } else { changeResult.add(searchResults); } }); } void dispose() { searchResultController.close(); searchQueryController.close(); } } class SearchApi { final ApiService service = ApiService.create(ChopperClientCreator.create()); Future<List<dynamic>> fetchApi({String query}) async { final Response response = await service.fetchApi(query: query); if (response.isSuccessful) { return response.body['items']; } else { print(response.error); } } }APIモデルの生成
今回はChopperを利用しました。
こちら参考記事になります。
api_service.dartimport 'package:chopper/chopper.dart'; part 'api_service.chopper.dart'; @ChopperApi(baseUrl: '') abstract class ApiService extends ChopperService { static ApiService create([ChopperClient client]) => _$ApiService(client); @Get(path: "/repositories") Future<Response> fetchApi({ @Query('q') String query, @Query('sort') String sort = 'stars' }); }chopper_client_creater.dartimport 'package:chopper/chopper.dart'; class ChopperClientCreator { static final String baseUrl = "https://api.github.com/search"; static ChopperClient create() { return ChopperClient( baseUrl: ChopperClientCreator.baseUrl, converter: JsonConverter(), ); } }api_service.chopper.dart// GENERATED CODE - DO NOT MODIFY BY HAND part of 'api_service.dart'; // ************************************************************************** // ChopperGenerator // ************************************************************************** // ignore_for_file: always_put_control_body_on_new_line, always_specify_types, prefer_const_declarations class _$ApiService extends ApiService { _$ApiService([ChopperClient client]) { if (client == null) return; this.client = client; } @override final definitionType = ApiService; @override Future<Response<dynamic>> fetchApi({String query, String sort = 'stars'}) { final $url = '/repositories'; final $params = <String, dynamic>{'q': query, 'sort': sort}; final $request = Request('GET', $url, client.baseUrl, parameters: $params); return client.send<dynamic, dynamic>($request); } }参考記事
- 投稿日:2020-04-06T21:08:02+09:00
GitLab で Unity の iOS アプリを自動ビルドするようにしてみた
GitLab で Unity の iOS アプリを自動ビルドするようにしてみた
GitLab で Unity の iOSアプリを自動ビルドするようにしてみました。
ipaファイルを配布するようにしたかったのですが、無料の範囲ではできないようです。
自動ビルドして作成した ipaファイルは Mac の Xcode を使って iPhone へインストールします。環境
- macOS Catalina 10.15.4
- GitLab CE 12.8.6(Windows10上の仮想サーバーで動かしています)
- GitLab Runner 12.9.0(Mac上で動かしています)
- Unity 2018.4.20f1
フォルダ構成
ファイル/フォルダ 内容 TestProject_Unity/ Unityプロジェクトフォルダ TestProject_Unity/.gitignore Git無視リストファイル TestAutoTest/TestProject_Unity/Assets/Editor/AppBuild.cs ビルド実行メソッドC#ファイル .gitlab-ci.yml GitLab CI 設定ファイル build.sh Unityプロジェクトのビルド用シェルスクリプトファイル build_xcode.sh Xcodeプロジェクトのビルド用シェルスクリプトファイル ExportOptions.plist xcodebuildのarchiveエクスポート用plistファイル README.md 説明ファイル 注意点
Unity で Identification の設定をする
Unity の Build Settings -> Player Settings で PlayerSettings を開きます。
iOS の Identification にある項目を設定します。
設定する内容は、Provisioning Profiles などで確認します。
- Bundle Identifier
- Signing Team ID
- Automatically Sign
※Automatically Sign にチェックを入れました。Provisioning Profiles の作成方法
Xcode の Preferences -> Accounts で Manage Certificatesボタンを押します。
右下の +ボタンから iOS Development で Provisioning Profile を作成します。Provisioning Profiles の確認方法
下記のフォルダに配置されている .mobileprovisionファイルを選択してスペースキーを押すと内容が表示されます。
~/Library/MobileDevice/Provisioning Profilesipaファイルの配布方法
- iOS Development の場合(無料の範囲内 ←今回はこっち)
Xcode で ipaファイルをインストール可能です。
Xcode の Window -> Devices and Simulators でウィンドウを開いてドラッグアンドドロップでインストールできます。
- iOS Distribution の場合(課金が必要のようだ)
iTunes で ipaファイルをインストール可能です。
iPhone のインストール時の信頼
iPhone の 一般 -> プロファイルとデバイス管理 -> デベロッパAPP で信頼します。
ExportOptions.plist について
xcodebuildコマンドの -exportOptionsPlistオプションで指定する plistファイルについては、下記のコマンドで確認できます。
xcodebuild -h
今回 plistファイル内で使用した key については下記のとおりです。(Google翻訳つき)
compileBitcode : Bool
For non-App Store exports, should Xcode re-compile the app from bitcode? Defaults to YES.
App Store以外のエクスポートの場合、Xcodeはビットコードからアプリを再コンパイルする必要がありますか?デフォルトはYESです。method : String
Describes how Xcode should export the archive. Available options: app-store, validation, package, ad-hoc, enterprise, development, developer-id, and mac-application. The list of options varies based on the type of archive. Defaults to development.
Xcodeがアーカイブをエクスポートする方法について説明します。利用可能なオプション:app-store、検証、パッケージ、アドホック、エンタープライズ、開発、developer-id、mac-application。オプションのリストは、アーカイブのタイプによって異なります。デフォルトは開発です。provisioningProfiles : Dictionary
For manual signing only. Specify the provisioning profile to use for each executable in your app. Keys in this dictionary are the bundle identifiers of executables; values are the provisioning profile name or UUID to use.
手動署名のみ。アプリの各実行可能ファイルに使用するプロビジョニングプロファイルを指定します。この辞書のキーは、実行可能ファイルのバンドル識別子です。値は、使用するプロビジョニングプロファイル名またはUUIDです。signingCertificate : String
For manual signing only. Provide a certificate name, SHA-1 hash, or automatic selector to use for signing. Automatic selectors allow Xcode to pick the newest installed certificate of a particular type. The available automatic selectors are "Mac App Distribution", "iOS Distribution", "iOS Developer", "Developer ID Application", and "Mac Developer". Defaults to an automatic certificate selector matching the current distribution method.
手動署名のみ。署名に使用する証明書名、SHA-1ハッシュ、または自動セレクターを指定します。自動セレクターにより、Xcodeは特定のタイプの最新のインストール済み証明書を選択できます。利用可能な自動セレクターは、「Mac App Distribution」、「iOS Distribution」、「iOS Developer」、「Developer ID Application」、および「Mac Developer」です。デフォルトは、現在の配布方法に一致する自動証明書セレクターです。signingStyle : String
The signing style to use when re-signing the app for distribution. Options are manual or automatic. Apps that were automatically signed when archived can be signed manually or automatically during distribution, and default to automatic. Apps that were manually signed when archived must be manually signed during distribtion, so the value of signingStyle is ignored.
配布用にアプリに再署名するときに使用する署名スタイル。オプションは手動または自動です。アーカイブ時に自動的に署名されたアプリは、手動または配布時に自動的に署名でき、デフォルトでは自動です。アーカイブ時に手動で署名されたアプリは、配布時に手動で署名する必要があるため、signingStyleの値は無視されます。stripSwiftSymbols : Bool
Should symbols be stripped from Swift libraries in your IPA? Defaults to YES.
IPAのSwiftライブラリからシンボルを削除する必要がありますか?デフォルトはYESです。teamID : String
The Developer Portal team to use for this export. Defaults to the team used to build the archive.
このエクスポートに使用する開発者ポータルチーム。デフォルトでは、アーカイブの作成に使用されたチームになります。thinning : String
For non-App Store exports, should Xcode thin the package for one or more device variants? Available options: (Xcode produces a non-thinned universal app), (Xcode produces a universal app and all available thinned variants), or a model identifier for a specific device (e.g. "iPhone7,1"). Defaults to .
App Store以外のエクスポートの場合、Xcodeは1つ以上のデバイスバリアントのパッケージを薄くする必要がありますか?利用可能なオプション:(Xcodeは非薄型のユニバーサルアプリを生成します)、(Xcodeはユニバーサルアプリと利用可能なすべての薄型のバリアントを生成します)、または特定のデバイスのモデル識別子(例:「iPhone7,1」)。デフォルトはです。ファイルの内容
TestProject_Unity/.gitignore
/[Ll]ibrary/ /[Tt]emp/ /[Oo]bj/ /[Bb]uild/ /[Bb]uilds/ /Assets/AssetStoreTools* # Autogenerated VS/MD solution and project files ExportedObj/ *.csproj *.unityproj *.sln *.suo *.tmp *.user *.userprefs *.pidb *.booproj *.svd # Unity3D generated meta files *.pidb.meta # Unity3D Generated File On Crash Reports sysinfo.txt # Builds *.apk *.unitypackage # Visual Studio .vs Release/ Debug/.gitlab-ci.yml
stages: - build job_build02: stage: build script: - echo "Start build" - chmod a+x ./build.sh - chmod a+x ./build_xcode.sh - ./build.sh iOS 2.3.4.5 - ./build_xcode.sh iOS 2.3.4.5 - echo "Finish build" tags: - mac artifacts: paths: - build_*.log - TestProject_Unity/Build/ expire_in: 1 weekTestAutoTest/TestProject_Unity/Assets/Editor/AppBuild.cs
using UnityEditor; using UnityEngine; using UnityEditor.Build.Reporting; using System; using System.Collections.Generic; public class AppBuild : MonoBehaviour { static string[] GetSceneList() { List<string> sceneList = new List<string>(); EditorBuildSettingsScene[] scenes = EditorBuildSettings.scenes; foreach (EditorBuildSettingsScene scene in scenes) { if (scene.enabled) { sceneList.Add(scene.path); } } return sceneList.ToArray(); } static string GetArgument(string name) { string[] arguments = Environment.GetCommandLineArgs(); for (int i = 0; i < arguments.Length; i++) { if (arguments[i].Contains(name)) { return arguments[i + 1]; } } return null; } public static void Build() { string locationPathName = ""; BuildTarget target = BuildTarget.NoTarget; string bundleVersion = GetArgument("/version");; string platformName = GetArgument("/platform"); switch (platformName.ToLower()) { case "android": { locationPathName = "Build/" + platformName + "_" + bundleVersion + ".apk"; } break; case "ios": { locationPathName = platformName + "Project_" + bundleVersion; } break; default: { locationPathName = "Build/" + platformName + "_" + bundleVersion; } break; } target = (BuildTarget)Enum.Parse(typeof(BuildTarget), platformName); BuildPlayerOptions buildPlayerOptions = new BuildPlayerOptions(); buildPlayerOptions.scenes = GetSceneList(); buildPlayerOptions.locationPathName = locationPathName; buildPlayerOptions.target = target; buildPlayerOptions.options = BuildOptions.Development; PlayerSettings.bundleVersion = bundleVersion; BuildReport report = BuildPipeline.BuildPlayer(buildPlayerOptions); BuildSummary summary = report.summary; if (summary.result == BuildResult.Succeeded) { Debug.Log("Build succeeded: " + summary.totalSize + " bytes"); } if (summary.result == BuildResult.Failed) { Debug.Log("Build failed"); } } }build.sh
#!/bin/bash SHELL_PATH=`pwd` PLATFORM=$1 VERSION=$2 UNITY=/Applications/Unity/Hub/Editor/2018.4.20f1/Unity.app/Contents/MacOS/Unity LOGFILE=build_${PLATFORM}.log PROJECT_PATH=TestProject_Unity EXECUTE_METHOD=AppBuild.Build ${UNITY} -batchmode -quit -logFile ${LOGFILE} -projectPath "${SHELL_PATH}/${PROJECT_PATH}" -executeMethod ${EXECUTE_METHOD} /platform ${PLATFORM} /version ${VERSION}build_xcode.sh
#!/bin/bash SHELL_PATH=pwd PLATFORM=$1 VERSION=$2 SCHEME="Unity-iPhone" UNITY_PROJECT_PATH="TestProject_Unity" XCODE_PROJECT_PATH="${UNITY_PROJECT_PATH}/${PLATFORM}Project_${VERSION}/${SCHEME}.xcodeproj" ARCHIVE_PATH="archive/${SCHEME}.xcarchive" IPA_PATH="TestProject_Unity/Build/ipa" EXPORT_OPTIONS_PLIST="ExportOptions.plist" PROVISIONING_PROFILE="(Provisioning ProfileのUUID)" mkdir -p ${ARCHIVE_PATH} xcodebuild -project ${XCODE_PROJECT_PATH} -scheme ${SCHEME} archive -archivePath ${ARCHIVE_PATH} xcodebuild -exportArchive -archivePath ${ARCHIVE_PATH} -exportPath ${IPA_PATH} -exportOptionsPlist ${EXPORT_OPTIONS_PLIST} PROVISIONING_PROFILE=${PROVISIONING_PROFILE}ExportOptions.plist
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>compileBitcode</key> <false/> <key>method</key> <string>development</string> <key>provisioningProfiles</key> <dict> <key>(Bundle Identifier)</key> <string>(Provisioning ProfileのUUID)</string> </dict> <key>signingCertificate</key> <string>iOS Developer</string> <key>signingStyle</key> <string>automatic</string> <key>stripSwiftSymbols</key> <true/> <key>teamID</key> <string>(Signing Team ID)</string> <key>thinning</key> <string><none></string> </dict> </plist>まとめ
作成した ipaファイルを自由に配布はできないようです。
無料の範囲内では、Xcode を使ってインストールするしかなさそうです。
今回の自動ビルドで ipaファイルの作成までは自動化できるので時間の節約にはなるかと思います。
- 投稿日:2020-04-06T12:18:25+09:00
PokéAPIを利用してMVP+CleanArchitectureのiOSアプリを作ったので解説する
はじめに
最近、勉強会などで、iOSの業務未経験の人たちと話している時に、これからiOSエンジニアとして仕事を得るためにどういったことを学べばいいのか、業務で実際にどういうことを意識して設計やツールを駆使しているかということをよく聞かれるので、それについて説明することができたらなと感じていました。
実際に業務で作っているコードを公開することはできないので、業務で作っているコードにかなり近い形のサンプルのアプリを作成、公開したので、それを参考にしながら私がどういったことを考えてiOSの業務をこなしているかについてを解説していきます。
注意:これはあくまで私が個人的に思う業務における考え方であって、正解というわけではありません。一つの意見として捉えてもらえると幸いです。
最も重要なことは何か
私は、iOSに限らずアプリケーション開発の業務を遂行する上で、最も重要なことは、アプリケーションのリリースサイクルを短くして、ユーザーに最速で最大の価値を提供し続けることだと考えています。
それを達成するためには、以下の様なことを意識するのが効果的と考えて、業務で実践しています。
- 自動化をする
- ミスを減らす
- 開発におけるストレスを減らす
今回はそういったことを意識して、業務で実際に使っている、ミスが起こりにくく、素早い変更や改修、機能追加を容易にするためのノウハウを実践的に詰め込んだアプリを作成しました。
Pokedex
そのノウハウを詰め込んだサンプルのアプリですが、普段業務で考えていることを解説するという、少し退屈なテーマを目的としたものである以上、せめてアプリのテーマだけでも面白いものにしたいと思い、今回はPokéAPIを利用してみました。
ポケモンの一覧と詳細を見ることができる簡単なポケモン図鑑のアプリです。
セットアップ編
では、早速コードを見て解説と言いたいところですが、このアプリを起動するまでには、少しセットアップの作業が必要なので、それをまずはしていきましょう。
Makefile
Makefileに従ってセットアップを行えば、ツールやライブラリのインストールなどの面倒な作業をかなり簡単にできるのでオススメです。
実際の業務では、新たにチームメンバーが参加した時などに役立つでしょう。
README.mdに書いてあるとおり、ターミナルでプロジェクトのルートを開き、
make bootstrap
とmake project
のコマンドを実行します。今回インストールしたツールはiOS開発を行う上で、効率を上げてくれるので詳細を説明するべきなのですが、全部を細かく紹介すると、かなり長くなってしまうので、それぞれについてさらっと紹介して、参考になる記事を貼り付けておきます。
Mint
Swift製のコマンドラインツールを管理することができるツールです。
今回はこれを利用して、XcodeGenなどの開発する際に便利なツールを管理します。
Mint で Swift 製のコマンドラインツールを管理する
Swift製コマンドラインツールのパッケージ管理ツール「Mint」のセットアップ&操作方法
Carthage
言わずとしれたライブラリ管理ツールです。
CocoaPodsに比べて、ライブラリのインストール時間が長いですが、その分コンパイル時間が短縮できるので、オススメです。
XcodeGen
チームで開発しているとよくあるのですが、ファイル追加による.xcodeprojのコンフリクトに悩まされます。
ターミナルで
xcodegen
のコマンドを実行すれば、project.ymlファイルに記載されている内容から.xcodeprojを生成してくれるので、コンフリクトのストレスから解放されます。Pokedexは一人で開発したので、コンフリクトの問題は元々起こりにくい環境ではあるのですが、マルチモジュールで開発しているため、その辺りの管理もやりやすいことから導入するメリットがあるなと思ってます。
SwiftGen
画像や色などのリソースを取得するためのファイルを自動生成してくれるツールです。
画像の呼び出しなどをする際の文字列の指定をtypoしてしまい取得できないといった、ミスを無くすことができます。
R.swiftとSwiftGenの導入方法とどちらを採用した方がいいのか
SwiftLint
.swiftlint.ymlファイルに記載されたコードのルールに応じて、ビルド時に警告を
出したりコンパイルエラーにさせることができるツールです。コードにルールを設けることで書き方が統一されるので、コードの読みやすさが向上します。
簡単なルールなら、自動でコードの修正をしてくれる機能もあるので、そういった点でも入れておくとかなり楽になります。
Swiftの静的解析ツール「SwiftLint」のセットアップ方法
設計とマルチモジュール編
セットアップができたところで、Pokedexのアプリの全体の設計についてを先に見ていきましょう。
Pokedexでは、アーキテクチャにMVP + CleanArchitectureを採用しています。
設計(アーキテクチャ)について
業務での開発において私が最も重要視しているのはこれと言っても過言ではありません。
設計がしっかりしたソースコードは責務分けがしっかりされているので、特定の改修を加える際に楽になります。
その上、テストを書きやすくなるので、特定の改修を行った際にデグレが起きてないかのチェックを簡単にできるというメリットもあります。業務であれば、新規開発する期間よりもアプリをリリースしてから運用して改修する期間の方が長いことがほとんどなので、改修をより早く容易にできる様にアプリの設計を採用すべきと考えています。
例えば、PokedexでAPI通信する際のアクセスする先のURLを変更したいという要望があった場合に、DataStoreのモジュールにある、アクセス先のURLを指定している部分を変更すればいいということが何となくつかめるので、すぐに改修でき、テストも書いてあれば問題がないかどうかもすぐに分かり、安心してアップデートすることができます。
もし設計が何もない状態で全てのロジックがUIViewController上に書かれたソースコードだと、まずはそのUIViewControllerのクラスを開き、そこから特定のソースコードを探して改修することになります。
それが何の責務わけもされていない、数千行のUIViewControllerだった場合、簡単な変更をするだけでも他の部分に影響がないか調査するだけでも一苦労です。こういう状況で、素早いリリースサイクルをこなすということは、非常に難しくなるでしょう。
今回私はMVP + CleanArchitectureを採用しましたが、それでないとダメというわけではありません。
MVC、MVVM、VIPERなどの数々のアーキテクチャがありますが、これを使えば全部において最強!みたいなのは存在せず、リリースサイクルを短くするのに、最も都合がいい物を都度選択すればいいと私は考えています。
設計はあくまで目的を実現するのに手段に過ぎないので、作りたいアプリのサイズ感や性質などに応じて適切なものを選択できる様になるといいでしょう。
アーキテクチャに関しては様々な記事が出ているので、もし詳細に知りたい場合は以下の記事を参考にするのをお勧めします。
まだMVC,MVP,MVVMで消耗してるの? iOS Clean Architectureについて
マルチモジュール化
最大の特徴はマルチモジュールで作成されているということです。
アプリのターゲットに加えて、
DataStore
、Domain
、Presentation
の三つのEmbeddef Frameworkが追加されています。これは、先ほども上げた、アプリの構成図の黄色い四角で囲われた部分ごとに切り分けているものです。
特にこのEmbedded Frameworkに切り分けて実装せずにアプリのターゲットのみで開発することも可能なのですが、Clean Architectureの様な細かく責務が分けられた設計の場合、使うことのメリットが大きいのでこの方法を採用しました。
ビルドパフォーマンスの向上
Xcodeの差分コンパイルのが各フレームワークごとに効く様になるので、スコープが小さくなり、ビルド時間の短縮に繋がります。
ただ、Pokedexの場合はアプリ自体が大規模ではないので、そこまで恩恵は受けられてません。不要な参照を制御
1つのターゲットに全てのファイルが入っていると、例えば、元来Presentationでは、DataStoreの処理を直接呼び出す必要がないにも関わらず、何の制限もなく呼び出すことができてしまいます。これでは、せっかく責務を切り分けたのにも関わらず、参照のルールが崩れてしまうので、図の通りにならなくなってしまいます。
自分一人だけで開発していれば、そうしない様に気を付けるだけで済みますが、複数人で開発している場合や設計に慣れていない人と一緒に開発する場合はそういうわけにはいきません。
そこをEmbedded Frameworkで切り分けることで、importしなければ、参照ができなくなるという仕様が活きてきます。
例えばPresenterでは、UseCaseの処理を呼び出すために、
import Domain
をしなければ、UseCaseを呼び出すことができなくなります。
最初の例で言うならば、以下の画像の様なコードをレビューで発見した場合、怪しいコードとして心してかからなければいけないということです。
コードレビューをした際に、こういった設計にしたがっていないコードを見落としてしまう可能性がありますが、Frameworkのターゲットを切り分けておくと、importを確認すれば、まずは参照のルールが守られているコードであるかどうかがわかるので、レビューの負担が少し減るというわけです。
あとは、少し副産物的なメリットですが、変換候補を減らすことができるという点もメリットになるでしょう。
Clean Architecutureを使用することで、ファイルや定義が非常に多くなってしまいます。(Pokedexの場合、PokemonDetail
という名前を含んだ命名が32個も存在します。)必要ない定義がFrameworkの切り分けによって少なくなるだけで、コーディングしている際のストレスがなくなります。
その32個の定義が気になる物好きの人はこちら
PokemonDetailResponse PokemonDetailRequest PokemonDetailAPIGatewayProvider PokemonDetailAPIGateway PokemonDetailAPIGatewayImpl PokemonDetailRepositoryProvider PokemonDetailRepository PokemonDetailRepositoryImpl PokemonDetailUseCaseProvider PokemonDetailUseCase PokemonDetailUseCaseImpl PokemonDetailTranslatorProvider PokemonDetailTranslator PokemonDetailTranslatorImpl PokemonDetailModel PokemonDetailBuilder PokemonDetailView PokemonDetailViewController PokemonDetailFavoriteButton PokemonDetailSingleImageCell PokemonDetailDualImageCell PokemonDetailHeightCell PokemonDetailWeightCell PokemonDetailPokemonTypeCell PokemonDetailPokemonTypeItemCell PokemonDetailStatusCell PokemonDetailPresenter PokemonDetailPresenterImpl PokemonDetailWireframe PokemonDetailWireframeImpl TransitToPokemonDetailWireframe(これが変換に一気に出てくると思うとゾッとしますねw)
ライブラリの依存を明確にできる
あとは、ライブラリの依存を明確にできるということもメリットになります。
どういうことかというと、PokedexではAPI通信をする際にはAlamofireというライブラリを使用していますが、このライブラリはDataStoreの中でしか使用しません。そういった場合に、この様に、DataStoreに対してCarthageでインストールしたライブラリをリンクしています。
そうすると、使用する必要がないPresentationやDomainからライブラリの呼び出しを制限することができるので、ライブラリへの依存が明確になります。
コーディング編
最後に実際のコーディングに関して、最低限これはしておいて欲しいというものを紹介します。
アクセス修飾子
アクセス修飾子を正しく使うことで、より堅牢にコードを保持することができます。
全て知っておく必要がありますが、Pokedexにおいて主に使っているのはprivate
、internal
、public
の三つです。他のも知っておきたい方は以下の記事を参考にしてください。
知っているようで知らないSwift5のアクセス修飾子private
外部から参照されない(もしくはされたくない)クラスや変数には必ず
private
をつける様にしましょう。import UIKit final class PokemonListCell: UITableViewCell { @IBOutlet private weak var spriteImageView: UIImageView! @IBOutlet private weak var numberLabel: UILabel! @IBOutlet private weak var nameLabel: UILabel! func setData(_ data: PokemonListModel.Pokemon) { self.spriteImageView.loadImage(with: data.imageUrl, placeholder: Asset.mosnterball.image) self.numberLabel.text = "No.\(data.number)" self.nameLabel.text = data.name } }
@IBOutlet
で紐づけられたpropertyはprivate付け忘れがちですが、継承して使うとかが無い限りはprivateを付けておきましょう。import Foundation public enum PokemonListAPIGatewayProvider { public static func provide() -> PokemonListAPIGateway { return PokemonListAPIGatewayImpl(dataStore: PokeAPIDataStoreProvider.provide()) } } public protocol PokemonListAPIGateway { func get(completion: @escaping ((Result<PokemonListResponse, Error>) -> Void)) } private struct PokemonListAPIGatewayImpl: PokemonListAPIGateway { let dataStore: PokeAPIDataStore func get(completion: @escaping ((Result<PokemonListResponse, Error>) -> Void)) { self.dataStore.request(PokemonListRequest(), completion: completion) } }PokemonListAPIGatewayImplはPokemonListAPIGatewayProviderの
provide()
の中でしか呼び出す必要が無いので、privateをつけておくと、他のファイルから参照できない様になります。public
Pokedexの場合、Embedded Frameworkを利用しているので、フレームワーク外から参照されるクラスに関しては
public
を指定する必要があります。import DataStore import Foundation public enum PokemonListUseCaseProvider { public static func provide() -> PokemonListUseCase { return PokemonListUseCaseImpl( repository: PokemonListRepositoryProvider.provide(), translator: PokemonListTranslatorProvider.provide() ) } } public protocol PokemonListUseCase { func get(completion: @escaping ((Result<PokemonListModel, Error>) -> Void)) } private struct PokemonListUseCaseImpl: PokemonListUseCase { let repository: PokemonListRepository let translator: PokemonListTranslator func get(completion: @escaping ((Result<PokemonListModel, Error>) -> Void)) { self.repository.get { result in switch result { case .success(let response): completion(.success(self.translator.convert(from: response))) case .failure(let error): completion(.failure(error)) } } } }Presentationの
PokemonListPresenterImpl
内で、PokemonListUseCase
が参照されているため、PokemonListUseCase
にはpublicを付ける必要があります。
PokemonListUseCaseProvider
とその中のprovide()
メソッドもPokemonListBuilder
で呼び出されるのでpublicをつけましょう。final修飾子
継承されないclassには必ず
final
を指定する様にしましょう。import UIKit final class PokemonDetailViewController: UIViewController { }これを付けることによって、継承の必要性が無いということを明示できるのでソースコードの理解に役立つ上に、実行時のパフォーマンス向上にもつながります。
Decodable
PokeAPIはレスポンスがJsonなので、Codableという機能でレスポンスをパースすることができます。
その時に、
Codable
は、Encodable
(オブジェクト -> Data型に変換する)とDecodable
(Data型 -> オブジェクトに変換する)の両方に準拠してしまうので、今回の様なEncodable
が必要が無い場合には、Decodable
のみに準拠させると目的がはっきりするのでわかり易くなります。import Foundation public struct PokemonListResponse: Decodable { public let count: Int public let previous: String? public let next: String? public let results: [Result] } extension PokemonListResponse { public struct Result: Decodable { public let name: String public let url: String } }おわりに
最初、Pokedexを公開した時に誰も見てくれないだろうと思ってtwitterで呟いたら、予想以上の人に見てもらえたので、急いで解説記事を書きました?
ポケモンAPIでMVP+CleanArchitectureのサンプルアプリ作ってみたから誰か見て・・・(何ならスターください)https://t.co/h9W9yoym8u
— Tomosuke Okada (@fr0g_fr0g) April 1, 2020至らぬところは多々ある上に、少し長く難しい内容になってしまったかもしれませんが、おそらくこの記事に書いている内容を実践して身につけていけば、業務未経験の人でもiOSエンジニアとして仕事を得るのに役立つ内容であると思います。
まだ少し書きたい事柄もあり、適度に加筆修正する予定なので、この記事についての質問も歓迎ですし、さらにはPokedexへのissue、pull requestもお待ちしております!
- 投稿日:2020-04-06T03:22:44+09:00
iOS app の習得日記 (2)
(https://qiita.com/gomi_ningen/items/4e0e5bd98f08c4bcf93d)
続き
4.1 todoリスト作成までいった
```
import UIKit
import Foundationclass TODOMainViewController: UIViewController {
@IBOutlet weak var dismissButton: UIBarButtonItem! override func viewDidLoad() { super.viewDidLoad() dismissButton.target = self dismissButton.action = #selector(TODOMainViewController.dismissButtonTapped(_:)) } func dismissButtonTapped(_ sender: Any) { dismiss(animated: true, completion: nil) }}
```新しくstoryboard と viewcontrollerを作り、
dismissButtonを接続しようとするも、"Could not insert new outlet connection".と出る。
(https://dev.classmethod.jp/articles/remove-xcode-derived-data/)
こんなのがでてきた
キャッシュを消せと
これでいいのか?明日やってみよう