20210503のiOSに関する記事は4件です。

【Swift】VIPERとは

はじめに VIPERを勉強したので、基本的なことをまとめていこうと思います。 GitHub VIPERとは クリーンアーキテクチャーをiOS向けにしたシステムアーキテクチャのこと。 View, Interactor, Presenter, Entity, Routerの頭文字からとった。 システムアーキテクチャーとは、今までのGUIアーキテクチャー(MVC, MVVM, MVP)のように、Viewとその他みたいな考え方ではなく、画面遷移やAPI通信、データ保存などを考慮した設計のこと。 それぞれの役割 View: ViewとViewController Interactor: API通信担当 Presenter: 自分以外の中継役 Entity: データそのもの Router: 画面遷移担当 特徴 ・徹底的な疎結合 →Entity以外全てprotocolで繋ぐ ・Presenterは内部で状態をもたない →いつどのような入力に対しても必ず同じ出力になる。(Entityの違いはある) ・PresenterのメソッドはViewで起きたものに依存した名前にする →viewDidLoad, buttonDidTappedなど ・ViewとRouter以外はimport UIKitだめ、絶対 ・Interactorはデータを返すだけに徹する →API通信、端末内保存、メソッドで計算しただけなど関係なく、最後はデータを返すだけ。 他のモジュールからはどのようにデータを返したのかわからなくする。 データの返し方はRxSwift, protocol, closureなど、なんでもいい ・Entityに処理を書かない →純粋にデータを保持した型 処理の流れ 1.Routerdえ画面を生成し、DI(依存性注入)させる 2.生成された画面を表示 3.ViewからイベントをPresenterに知らせる →ライフサイクル、ボタンタップ... 4.PresenterはViewから送られてきたイベントの内容に合わせて以下のような処理をする ・Viewに対して画面の更新依頼する →Viewは依頼された通りに画面を更新する ・Interactorに対してデータの取得依頼をする →Interactorは依頼されたデータの取得が完了したらPresenterに通知する ・Routerに対して画面遷移の依頼をする →Routerは依頼された画面へ遷移する 命名 View ・画面の更新 ラベルの文字変更 UITableViewのreload など ・Presenterへのイベント通知担当 ライフサイクル ボタンのタップ、セルのタップ など GitHubSearchViewController import UIKit protocol GitHubSearchView: AnyObject { func initView() func startLoading() func finishLoading() func reloadTableView(items: [GitHubSearchEntity]) } final class GitHubSearchViewController: UIViewController { @IBOutlet private weak var textField: UITextField! @IBOutlet private weak var searchButton: UIButton! @IBOutlet private weak var indicator: UIActivityIndicatorView! @IBOutlet private weak var tableView: UITableView! // presenterへのアクセスはprotocolを介して行う private var presenter: GitHubSearchPresentation! override func viewDidLoad() { super.viewDidLoad() tableView.delegate = self tableView.dataSource = self tableView.register(GitHubSearchTableViewCell.nib, forCellReuseIdentifier: GitHubSearchTableViewCell.identifier) searchButton.addTarget(self, action: #selector(searchButtonDidTapped), for: .touchUpInside) // presenterにイベントを通知 presenter.viewDidLoad() } func inject(presenter: GitHubSearchPresentation) { self.presenter = presenter } } // MARK: - @objc func @objc private extension GitHubSearchViewController { func searchButtonDidTapped() { // presenterにイベントを通知 presenter.searchButtonDidTapped(word: textField.text) } } // MARK: - GitHubSearchView extension GitHubSearchViewController: GitHubSearchView { func initView() { DispatchQueue.main.async { self.tableView.isHidden = true self.indicator.isHidden = true } } func startLoading() { DispatchQueue.main.async { self.tableView.isHidden = true self.indicator.isHidden = false } } func finishLoading() { DispatchQueue.main.async { self.tableView.isHidden = false self.indicator.isHidden = true } } func reloadTableView(items: [GitHubSearchEntity]) { DispatchQueue.main.async { self.tableView.reloadData() } } } // MARK: - UITableViewDelegate extension GitHubSearchViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) // presenterにイベントを通知 presenter.selectItem(indexPath: indexPath) } } // MARK: - UITableViewDataSource extension GitHubSearchViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { // presenterにイベントを通知 return presenter.getSearchedItems().count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: GitHubSearchTableViewCell.identifier, for: indexPath) as! GitHubSearchTableViewCell // presenterにイベントを通知 let item = presenter.getSearchedItems()[indexPath.row] cell.configure(gitHubSearch: item) return cell } } Interactor ・ビジネスロジック担当(Utility) ・Presenterから依頼されたビジネスロジックを実装し、結果を返す。 delegate, closure, RxSwift... ・import UIKitだめ、絶対 UIを気にしない GitHubSearchInteractor import Foundation protocol GitHubSearchUsecase { func get(parameters: GitHubSearchParameters, handler: ResultHandler<[GitHubSearchEntity]>?) func getSearchedItems() -> [GitHubSearchEntity] } // 他のアーキテクチャーでいうUtilityの役割も持つ final class GitHubSearchInteractor { private var searchedItems: [GitHubSearchEntity] init() { searchedItems = [] } } // MARK: - GitHubSearchUsecase extension GitHubSearchInteractor: GitHubSearchUsecase { func get(parameters: GitHubSearchParameters, handler: ResultHandler<[GitHubSearch]>? = nil) { guard parameters.validation else { handler?(.failure(.error)) return } guard let url = URL(string: "https://api.github.com/search/repositories?\(parameters.queryParameter)") else { handler?(.failure(.invalidUrl)) return } let task = URLSession.shared.dataTask(with: url) { data, _, _ in guard let data = data, let gitHubResponse = try? JSONDecoder().decode(GitHubSearchEntityResponse.self, from: data), let items = gitHubResponse.items else { handler?(.failure(.error)) return } self.searchedItems = items handler?(.success(items)) } task.resume() } func getSearchedItems() -> [GitHubSearchEntity] { return searchedItems } } Presenter ・Viewから受け取ったイベントを元に別クラスに依頼 Viewに対して画面更新を依頼 Interactorに対してデータの取得を依頼 Routerに対して画面遷移を依頼 ・Presenterが提供するメソッド名はViewのメソッド名と同じ viewDidLoad, buttonDidTapped... ・Presenterに状態を持たせない ・import UIKitだめ、絶対 UIを気にしない GitHubSearchPresenter import Foundation protocol GitHubSearchPresentation: AnyObject { func viewDidLoad() func searchButtonDidTapped(word: String?) func selectItem(indexPath: IndexPath) func getSearchedItems() -> [GitHubSearchEntity] } // 他との部品以外はパラメータを持たない // 他との中継役にだけに徹する final class GitHubSearchPresenter { // view, interactor, routerへのアクセスはprotocolを介して行う // 循環参照しないようにviewだけweak private weak var view: GitHubSearchView? private var interactor: GitHubSearchUsecase private var router: GitHubSearchWireframe init(view: GitHubSearchView, interactor: GitHubSearchUsecase, router: GitHubSearchWireframe) { self.view = view self.interactor = interactor self.router = router } } // MARK: - GithubSearchPresentation extension GitHubSearchPresenter: GitHubSearchPresentation { func viewDidLoad() { view?.initView() } func searchButtonDidTapped(word: String?) { let parameters = GitHubSearchParameters(searchWord: word) view?.startLoading() interactor.get(parameters: parameters) { [weak self] result in guard let self = self else { return } self.view?.finishLoading() switch result { case .success(let items): self.view?.reloadTableView(items: items) case .failure(let error): self.router.showAlert(error: error) } } } func selectItem(indexPath: IndexPath) { let gitHubSearchEntity = interactor.getSearchedItems()[indexPath.row] let initParameters: WebUsecaseInitParameters = .init(entity: gitHubSearchEntity) router.showWeb(initParameters: initParameters) } func getSearchedItems() -> [GitHubSearchEntity] { return interactor.getSearchedItems() } } Entity ・データ構造そのもの ・ロジックを持たせない ・import UIKitだめ、絶対 UIを気にしない //対応がわかりやすいように置き換え typealias GitHubSearchEntityResponse = GitHubResponse typealias GitHubSearchEntity = GitHubSearch typealias GitHubSearchntityError = GitHubError import Foundation struct GitHubResponse: Codable { let items: [GitHubSearch]? } struct GitHubSearch: Codable { let id: Int let name: String private let fullName: String var urlString: String { "https://github.com/\(fullName)" } enum CodingKeys: String, CodingKey { case id case name case fullName = "full_name" } } Router ・画面遷移 ・依存性注入(後述) ・VIPERの肝 VIPERでは画面遷移の処理をRouterで行うことにより、Viewの責務を減らせて可読性の向上が望める GitHubSearchRouter import UIKit protocol GitHubSearchWireframe { func showWeb(initParameters: WebUsecaseInitParameters) func showAlert(error: Error) } final class GitHubSearchRouter { private unowned let viewController: UIViewController private init(viewController: UIViewController) { self.viewController = viewController } // Routerが画面遷移を担当しているので、ここに書く static func assembleModules() -> UIViewController { let view = UIStoryboard.gitHubSearch.instantiateInitialViewController() as! GitHubSearchViewController let interactor = GitHubSearchInteractor() let router = GitHubSearchRouter(viewController: view) // presenterが中継役なので、全てと繋げる let presenter = GitHubSearchPresenter(view: view, interactor: interactor, router: router) // viewからpresenterに通知する必要があるため繋ぐ // viewとpresenterは互いが互いを知っている view.inject(presenter: presenter) return view } } // MARK: - GitHubSearchWireframe extension GitHubSearchRouter: GitHubSearchWireframe { func showWeb(initParameters: WebUsecaseInitParameters) { let next = WebRouter.assembleModules(initParameters: initParameters) viewController.show(next: next) } func showAlert(error: Error) { print(error.localizedDescription) } } おわりに その他の処理はGitHubをご覧ください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【エラー対処】【ゆめみiOS研修】Swift Unit Test -> Could not cast value of type 'xxx' to 'xxxTests'

株式会社ゆめみ iOS研修 システムエンジニアとして働いているものコードを書く機会が少ないため、プライベートでコードを書く習慣を身につけたいと思い見つけた新人育成用カリキュラム。 Session8でのテストコード作成時に以下のエラーで躓いたため備忘録として記載します。 エラーログ Could not cast value of type 'yumemi.ViewController' (0x108f463f8) to 'yumemiTests.ViewController' テストコードでUIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController()よりViewControllerを生成しようとしたが、実行時にエラーとなる。 ログの内容的にTargetの呼び出し先が関係していそう。 バージョン ツール バージョン Xcode 12.4 Swift 5 テストコード ViewControllerTests.swift import XCTest @testable import yumemi class ViewControllerTests: XCTestCase { var viewController: ViewController! override func setUpWithError() throws { viewController = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() _ = viewController.view } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } func testPerformanceExample() throws { // This is an example of a performance test case. self.measure { // Put the code you want to measure the time of here. } } } 解決方法 テスト対象のViewControllerを参照するとTarget MembershipにTest Target(今回はyumemiTests)が含まれていたため、チェックを外したらエラーは解消され、テストコードからViewControllerを参照できるようになった。 その他 解決はしたものの理由は分かっていないため、ご存知の方がいればご教授頂けますと嬉しいです。 まだ綺麗にコードを書けていませんが奮闘中です。 yumemi個人演習用リポジトリ
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Facemotion3dにおけるUaaLについて

最近、Facemotion3dというiOSアプリをリリースしました。 これは、iOSでフェイストラッキングが出来るアプリです。 このアプリの中には「UaaL」と呼ばれている技術が使われています。 目次 1.UaaLとは 2.UaaLが使用されているアプリ 3.SceneKitとの併用 4.SceneKitの面倒くささ 5.アプリ起動直後ではない方法でUaaLを使用する 6.UaaL実装においてうまく行かなかった点 7.Facemotion3dでUaaLを実装した理由 1. UaaLとは UaaLとは「Unity as a Library」の略で、その名の通りUnityをライブラリとして使うものです。 大元の記述をSwiftというiOSネイティブの言語で記述し、Unityをライブラリ(framework)として使用することで、UI部分はSwiftを使いつつ、3DCGの描画にはUnityを使うというようなSwiftとUnityの良い所取りが出来ます。 Unityでスクロール画面を作るよりも、Swiftでのスクロール速度の方が早かったりする利点があります。また、ARKitを使用する場合、「純正Unityだと30FPSしか出ないのに対して、Swiftだと60FPSを出せる...」という話をTwitter上で見かけたりしたので、本当かは分かりませんがそういう利点があります。 2. UaaLが使用されているアプリ 偉大な先人としてnoppeさんのvearや、Realityなどで使用されています。 そういった記事を参考にさせて頂いてFacemotion3dにもUaaLを使っています。 以下の記事などを参考にさせて頂きました。 https://qiita.com/noppefoxwolf/items/b43d8554142e69c2ada6 https://qiita.com/tkyaji/items/7dbd56b41b6ac3e72635 https://forum.unity.com/threads/integration-unity-as-a-library-in-native-ios-app.685219/ 3. SceneKitとの併用 Facemotion3dでは、Apple標準のビューポートを使用する「SceneKit」とUnityを使用する「UaaL」の併用ということをやっています。 これはどうしてこうなったかというと開発していく流れの中で偶然こうなったという感じです。元々、SceneKitでアプリを作っていて、SceneKitでも十分見栄えのするモデルをモデラーさんが作ってくれました。途中からUaaLについても調べ始めたので後付けでUaaLを載せました。 正確な比較はしたことないので分かりませんが、恐らくメモリの消費量であったり電池の消費量などの面ではUnityを起動しない方が長時間使用する場合、パフォーマンス的にはApple標準のSceneKitを使う方が良いのではないかという気がします。 4. SceneKitを使う上での面倒くささ SceneKitにも利点はありますが、Apple標準のビューポートは機能が少なく、プログラミングする上でのネット上の情報が少ないので3DCG部分について記述する上では圧倒的にUnity(UaaL)で記述する方が楽です。 また、UnityではVRMなどを使うとプログラマーからしてみると簡単にデータをアプリ内に読み込めます。 しかし、SceneKitを使う場合、MayaなどのDCCツールでデザイナーからデータを受け取り、それを良い感じに原点付近に来るようにデータを配置して、ピボット情報を修正し、スケール情報を整え、オブジェクトの命名規則をちゃんとチェックしたりした上で、DAE_FBXなどという聞きなれない拡張子でデータをエクスポートする必要があります。 それだけでなく、DAE_FBXを使うだけではデータをアプリ内に読み込めないので、DAE_FBXをテキストファイルで開き、内部の記述をちょっと書き換える必要があります。 これだけでも面倒くさいのですが、MacのXCodeにDAE_FBXを読み込んだ後に.scnというファイル拡張子に変換し、全てのテクスチャパスを手動で張り替えるという面倒くさい手順が必要でした。 モデル変更などでデザイナーさんが頻繁にデータを更新するので、この作業を何度か繰り返す必要があります。 ここら辺の作業は、今後、AppleとPixarが共同で開発しているUSDZという拡張子が台頭することで簡略化されるのだと思われます。 5. アプリ起動直後ではない方法でUaaLを使用する 参考にさせて頂いたこちらの記事では、アプリ起動直後にUnityが起動する方法が紹介されています。 Facemotion3dでも、前回アプリ終了時にUnityをロードしたまま終了していればアプリ起動直後にUnityが起動する仕組みになっていますが、基本はUaaLではなくSceneKitが起動するようになっています。 前置きが長くなりましたが、ここから実際にFacemotion3d内に記述されているコードを示します。基本的にはnoppeさんの記事の内容を拡張しながら場当たり的に書いていった上に、Swift歴も浅いので関数名の汚さや、記述の汚さが目立つかもしれません...。「適当に書いてたらなんか動いた...!」という類のコードであり、何ら参考にならないかもしれません。 AppDelegate.swift import UnityFramework @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? var application: UIApplication? var launchOptions: [UIApplication.LaunchOptionsKey : Any]? var firstLaunchUnity = true var isUnityRunning = false func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) // see notes below for the meaning of Atomic / Non-Atomic SwiftyStoreKit.completeTransactions(atomically: true) { purchases in for purchase in purchases { switch purchase.transaction.transactionState { case .purchased, .restored: if purchase.needsFinishTransaction { // Deliver content from server, then: SwiftyStoreKit.finishTransaction(purchase.transaction) } // Unlock content case .failed, .purchasing, .deferred: break // do nothing @unknown default: print("unknown") } } } self.application = application self.launchOptions = launchOptions let storyboard = UIStoryboard(name: "ViewController", bundle: nil) window?.rootViewController = storyboard.instantiateInitialViewController() window?.makeKeyAndVisible() return true } lazy var persistentContainer: NSPersistentContainer = { let container = NSPersistentContainer(name: "FACEMOJO for DAZ") container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") } }) return container }() func saveContext () { let context = persistentContainer.viewContext if context.hasChanges { do { try context.save() } catch { let nserror = error as NSError fatalError("Unresolved error \(nserror), \(nserror.userInfo)") } } } func firstStartUnity() { Unity.shared.secondInit() if let app = self.application { Unity.shared.application(app, didFinishLaunchingWithOptions: launchOptions) window?.makeKeyAndVisible() } } } class Unity: NSObject, UnityFrameworkListener, NativeCallsProtocol { static let shared = Unity() var unityFramework: UnityFramework //グローバル変数 var myVar = GlobalVar.shared override init() { let bundlePath = Bundle.main.bundlePath let frameworkPath = bundlePath + "/Frameworks/UnityFramework.framework" let bundle = Bundle(path: frameworkPath)! if !bundle.isLoaded { bundle.load() } let frameworkClass = bundle.principalClass as! UnityFramework.Type let framework = frameworkClass.getInstance()! if framework.appController() == nil { var header = _mh_execute_header framework.setExecuteHeader(&header) } unityFramework = framework super.init() }   //1度UnityをUnloadした後に読み込む場合に対応 func secondInit() { if !unityIsInitialized() { let bundlePath = Bundle.main.bundlePath let frameworkPath = bundlePath + "/Frameworks/UnityFramework.framework" let bundle = Bundle(path: frameworkPath)! if !bundle.isLoaded { bundle.load() } let frameworkClass = bundle.principalClass as! UnityFramework.Type let framework = frameworkClass.getInstance()! if framework.appController() == nil { var header = _mh_execute_header framework.setExecuteHeader(&header) } unityFramework = framework } } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { unityFramework.register(self) FrameworkLibAPI.registerAPIforNativeCalls(self) unityFramework.setDataBundleId("com.unity3d.framework") unityFramework.runEmbedded(withArgc: CommandLine.argc, argv: CommandLine.unsafeArgv, appLaunchOpts: launchOptions) } private func unityIsInitialized() -> Bool { return ( unityFramework.appController() != nil ) } func unloadUnity() { if unityIsInitialized() { unityFramework.unloadApplication() if let appDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.firstLaunchUnity = true } } } //Unity側からSwiftに対して送られてくる関数-関数名はUaaLサンプルの初期設定のまま使用 func showHostMainWindow( _ color: String!) { //Unityを読み込み終わったことを示すフラグ self.myVar.UnityLoadFlag = true } func sendUnityMessage(objectName:String,functionName:String,message:String) { unityFramework.sendMessageToGO(withName: objectName, functionName: functionName, message: message) } var view: UIView { unityFramework.appController()!.rootView! } } class GlobalVar { private init() {} static let shared = GlobalVar() var UnityLoadFlag = false var unityFirstLoadFlag = true } ViewController.swift //グローバル変数 var myVar = GlobalVar.shared //Unityを起動する関数,適当なところで呼び出す func startUnity() { //GUI周りの表示切り替え DispatchQueue.main.async { if self.myVar.unityFirstLoadFlag == true { //Unityロゴを隠さないようにするための処理 //UaaLを使うとUnityロゴを隠せてしまうが、それを避ける処理 //self.tappedFunction() } //ロード時のくるくる開始 //self.activityIndicator.startAnimating() //self.activityIndicator.isHidden = false } //loadingUnity = true var waitTimeAfterStopUnity = 0.0 if self.myVar.UnityLoadFlag == true { waitTimeAfterStopUnity = 0.5 } stopUnity() DispatchQueue.main.async {        //SceneKitを非表示にする //self.faceView.isHidden = true } DispatchQueue.main.asyncAfter(deadline: .now() + waitTimeAfterStopUnity) { if let appDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.firstStartUnity() DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { DispatchQueue.main.async { let unityView:UIView = Unity.shared.view unityView.isHidden = false self.view?.insertSubview(unityView, at: 0)              //ロード時のくるくる停止 //self.stop_activityIndicator() } /*        //SceneKitのタッチが反応しないようにする(画面回転用) self.faceView.isUserInteractionEnabled = false        //self.viewのタッチが反応するようにする(画面回転用) self.view.isUserInteractionEnabled = true self.view.isMultipleTouchEnabled = true self.unityView?.isUserInteractionEnabled = true self.unityView?.isMultipleTouchEnabled = true */ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { if self.myVar.unityFirstLoadFlag == true { self.myVar.unityFirstLoadFlag = false } } } } } } //Unityをアンロードする関数,適当なところで呼び出す func stopUnity() { if self.myVar.UnityLoadFlag == true { DispatchQueue.main.async { //Bool型をStringで記述するというあほな記述、無視してください;; self.myVar.UnityLoadFlag = false let unityView = Unity.shared.view unityView.removeFromSuperview() Unity.shared.unloadUnity() unityView.isHidden = true      //SceneKitを非表示にする //self.faceView.isHidden = true } } } 6. UaaL実装においてうまく行かなかった点 1.AVAudioSessionやAVAudioRecorderなどを使った録音、再生周りがうまく動作しなくなった SceneKitのままだとAVAudioSessionなどがうまく機能しているのですが、UaaLを起動した途端に録音ができなくなりました。理由は不明です。 Unityの使用中だけでなく、Unityをアンロードした後でも同様に録音できないようです。 2.2本指ドラッグを反応させる方法が分からなかった Unityの画面のズームをさせるために、 self.viewに対してPinchGestureRecognizerを使用しています。 そして、Unity画面の回転のために、以下の記事を参考に1本指の画面ドラッグをself.viewに実装しています。 https://i-app-tec.com/ios/image-drag.html 上記の記事には、画面の2本指ドラッグについても書かれており、SceneKitでは2本指ドラッグが正常に反映されたのですが、UaaLになると2本指ドラッグによるカメラ移動が反応しないという問題が回避できませんでした。原因は不明です。 7. Facemotion3dでUaaLを実装した理由 単に、これは知っておかないとまずいやつだ、という感覚がありました。 また、既にVRMをiOS上に読み込む機能を持つフェイストラッキングアプリがいくつかあるのに、何でUaaLを使ってVRMを読み込む機能をFacemotion3dに実装しようと思ったかの理由を書いておこうと思います。 私がFacemotion3dの前身であるiFacialMocapというアプリを作った時に、1番最初に感じたことは、「周りがVRMを動かすものを作っている中で、自分のアプリだけVRMを動かすためのアプリではない」という点でした。 そんな中でVRMを動かす機能を自分のアプリにも実装してしまうと、アプリ間の差別化が出来ず、色んな方面と衝突してしまうのではないかという懸念が常にありました。しかし、VRMを動かすアプリを作れる人はいつでも非VRMを動かすアプリだって作れるので危機感とかもあったわけです。そんな中で、アプリの差別化をできないにしても、どうすれば「世の中にとって新規性のあるもの」を作れるかということは考えていました。 新規性の案の1つとして、自分のアプリは非VRMを動かせるわけなので、非VRMの動的ロードなども出来るのではないか...と考えたりしていました。実際に、TriLibというUnityの有料アセットを使用すればFBXを動的ロードの実装ができます。 私は、TriLibを動作テストをして、実際に使ってみました。FBXの動的ロードに成功し、ある程度動かせるレベルまで行けました。基本的に、軸情報とボーン等のオブジェクト名の情報さえあればFBXは動かせるわけなので、各ソフトウェアの軸情報をコピーしてUnity用に変換し、読み込むということを試してみたりしていました。FBXデータだと必ずしも原点付近にデータがあるとも限らないため、メッシュのバウンディングボックスを調べて、オブジェクトがカメラ内に収まるように移動させるようなことも試してみたりしました。 しかし、実装したものの、TriLibの完成度の問題なのか、自分の知識レベルの低さの問題なのか、いくつかの点でつまづいたわけです。 まず、TriLibは現時点でFBXを読み込むとメモリが変な食い方するのか何なのか、動きがのっそりしたおかしな挙動になります。また、テクスチャのロードなどがFBXのバージョンによってはうまく行かないなどの問題がありました。TriLibeによるFBXのロード時間は、VRMのロード時間より遥かに長く、10~20秒くらいかかった気がします。(2020年時点) そんなこんなで、FBXの動的ロードは置き去りになり、VRMの読み込み機能だけが残ったという背景があります。VRMに関する機能についてアレコレ主張しようという気は特にありません。私のアプリは後発なので、これ良いなと思う機能があれば、いい感じにコピッてもらえたら良いかと思います。 FBXの動的ロードを実装したところで、将来的にはVRMやFBXではなく、Apple標準のUSDZがシェアを伸ばしてそちらが覇権を握ったりするかもしれません。その辺の考えについては、以下の記事に書いてみました。 ドワンゴが作るプラットフォームや規格について思うこと https://note.com/pekochun/n/n8c5a8f115645 こういうのも書きました。 モーションキャプチャアプリを作っていて色々思うこと https://note.com/pekochun/n/nf16643bd9f68 Amazonで欲しいものリストを公開しているので、もしお金持ちの方がいたら買ってください>< https://www.amazon.jp/hz/wishlist/ls/1S03PCOA5P7IE
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】iOSネイティブプラグイン開発を完全に理解する - ネイティブプラグインの設計について

本編「【Unity】iOSネイティブプラグイン開発を完全に理解する」の付録記事です。 記事中での用語や略称についてはそのまま本編に倣う形で記載していきます。 ここでは実際にネイティブプラグインを実装/保守していく上でのオススメの設計手法について触れていきます。 あくまで自分がよくやる手法の話なので、一例として受け取っていただけると幸いです。 題材自体はiOSネイティブプラグインに限らずに適用できる内容になるかと思います。 TL;DR interfaceを切ってプラットフォームごとに実装を分けることをオススメ 可読性/保守性/拡張性が上がる DIフレームワークとの相性が良くなる かと言って慣習的に必ず切る必要は無い サンプル実装や簡単な機能検証程度ならプリプロセス命令で雑に分岐して直接呼び出してしまうのも有りかも 最小構成のサンプルを振り返ってみる 本編側にある最小構成のサンプルでは以下のようにEditor実行時との挙動はプリプロセス命令で分岐した上でボタン押下時にP/Invokeの処理を直接呼び出していました。 このようなサンプル実装や簡単な機能検証程度ならこの書き方でも良いかと思います。 sealed class Example : MonoBehaviour { [SerializeField] Button _buttonHelloWorld = default; void Start() { _buttonHelloWorld.onClick.AddListener(() => { #if !UNITY_EDITOR && UNITY_IOS // プラグインの呼び出し var ret = PrintHelloWorld(); Debug.Log($"戻り値: {ret}"); #else // それ以外のプラットフォームからの呼び出し (Editor含む) Debug.Log("Hello World (iOS以外からの呼び出し)"); #endif }); } #region P/Invoke [DllImport("__Internal", EntryPoint = "printHelloWorld")] static extern int PrintHelloWorld(); #endregion P/Invoke } マルチプラットフォーム想定の機能だとどうなるか? 例としてネイティブプラグインを活用した「iOS/Android標準のシェアUIからテキスト/画像/動画をシェアする機能」を実装するとしましょう。 もし上述のサンプルと同じくプリプロセス命令の分岐ベースで愚直に実装すると、場合によっては以下のような感じの実装になってくるかと思います。 クラス図 ※ExampleViewからシェア機能(ShareMedia)を呼び出すイメージ 1 コードはこちら ShareMedia.cs (クリックで展開) ShareMedia.cs namespace Examples.View { /// <summary> /// 各プラットフォーム標準のシェアUIからテキスト/画像/動画をシェア /// </summary> /// <remarks> /// NOTE: 全体的に参照透過性が高いのでstatic classとして纏めている /// </remarks> public static class ShareMedia { /// <summary> /// テキストのシェア /// </summary> public static void ShareText(string text) { #if !UNITY_EDITOR || UNITY_IOS ShareTextForIOS(text); #elif !UNITY_EDITOR || UNITY_ANDROID ShareTextForAndroid(text); #else Debug.Log("Editorからの呼び出し"); #endif } /// <summary> /// 画像のシェア /// </summary> public static void ShareImage(byte[] image) { #if !UNITY_EDITOR || UNITY_IOS ShareImageForIOS(image); #elif !UNITY_EDITOR || UNITY_ANDROID ShareImageForAndroid(image); #else Debug.Log("Editorからの呼び出し"); #endif } /// <summary> /// 動画のシェア /// </summary> /// <param name="moviePath">動画のパス</param> public static void ShareMovie(string moviePath) { #if !UNITY_EDITOR || UNITY_IOS ShareMovieForIOS(moviePath); #elif !UNITY_EDITOR || UNITY_ANDROID ShareMovieForAndroid(image); #else Debug.Log("Editorからの呼び出し"); #endif } #region iOSのP/Invoke #if UNITY_IOS // iOSのシェアUIからテキストをシェア [DllImport("__Internal", EntryPoint = "shareText")] static extern void ShareTextForIOS(string text); // iOSのシェアUIから画像をシェア [DllImport("__Internal", EntryPoint = "shareImage")] static extern void ShareImageForIOS(byte[] image); // iOSのシェアUIから動画をシェア [DllImport("__Internal", EntryPoint = "shareMovie")] static extern void ShareMovieForIOS(string moviePath); #endif #endregion iOSのP/Invoke #region AndroidのP/Invoke #if UNITY_ANDROID // AndroidのシェアUIからテキストをシェア static void ShareTextForAndroid(string text) { // ネイティブのシェアUI呼び出し (省略) } // AndroidのシェアUIから画像をシェア static void ShareImageForAndroid(byte[] image) { // ネイティブのシェアUI呼び出し (省略) } // AndroidのシェアUIから動画をシェア static void ShareMovieForAndroid(string moviePath) { // ネイティブのシェアUI呼び出し (省略) } #endif #endregion AndroidのP/Invoke } } ExampleView.cs (クリックで展開) ExampleView.cs namespace Examples.View { /// <summary> /// シェア機能の呼び出しサンプル /// </summary> sealed class ExampleView : MonoBehaviour { [SerializeField] InputField _shareText = default; void Start() { // 入力されたテキストのシェア _shareText.onEndEdit.AddListener(text => { ShareMedia.ShareText(text); }); } } } プリプロセス命令の分岐が増えてくると可読性が悪くなる 注目して欲しいのはShareMedia.csです。 至るところにプリプロセス命令による分岐が挟まってます。 例に示した機能程度であれば「これでも運用できなくはないかな...」と言う意見もあるかもしれませんが、ここから更に「対応プラットフォームにStandaloneとWebGLを追加」「テキスト/画像/動画以外にも、○○をシェアできるようにしたい」と言った要件が増えてくると、その分プリプロセス命令の分岐が増えてきて可読性や保守性が悪くなってくるかと思われます。 更に言うとサンプルコードのpublic methodはP/Invokeのメソッドを呼び出すだけと言った割とシンプルな構成で収まってますが、場合によってはプラットフォームごとに合わせたデータ変換と言ったロジックも挟まる可能性もあり、そうなってくると更にコードの量も増えていく懸念があります。 interfaceを切ってプラットフォームごとに実装を分けていく 「じゃあどう実装していくのが良いのか?」について話していきます。 個人的には表題にある通り、interfaceを切ってプラットフォームごとに実装を分けていく手法をオススメします。 前の章のサンプルを分けるとしたら以下のような形になります。 先にクラス図/コード合わせて載せておきます。 クラス図 コードはこちら IShareMedia.cs (クリックで展開) IShareMedia.cs namespace Examples.View { /// <summary> /// シェアUIからテキスト/画像/動画をシェア /// </summary> interface IShareMedia { /// <summary> /// テキストのシェア /// </summary> void ShareText(string text); /// <summary> /// 画像のシェア /// </summary> void ShareImage(byte[] image); /// <summary> /// 動画のシェア /// </summary> /// <param name="moviePath">動画のパス</param> void ShareMovie(string moviePath); } } ShareMediaForIOS.cs (クリックで展開) ShareMediaForIOS.cs #if UNITY_IOS namespace Examples.View { /// <summary> /// iOS用の`IShareMedia`の実装 /// </summary> public sealed class ShareMediaForIOS : IShareMedia { public void ShareText(string text) => ShareTextForIOS(text); public void ShareImage(byte[] image) => ShareImageForIOS(image); public void ShareMovie(string moviePath) => ShareMovieForIOS(moviePath); #region P/Invoke // iOSのシェアUIからテキストをシェア [DllImport("__Internal", EntryPoint = "shareText")] static extern void ShareTextForIOS(string text); // iOSのシェアUIから画像をシェア [DllImport("__Internal", EntryPoint = "shareImage")] static extern void ShareImageForIOS(byte[] image); // iOSのシェアUIから動画をシェア [DllImport("__Internal", EntryPoint = "shareMovie")] static extern void ShareMovieForIOS(string moviePath); #endregion P/Invoke } } #endif ShareMediaForAndroid.cs (クリックで展開) ShareMediaForAndroid.cs #if UNITY_ANDROID namespace Examples.View { /// <summary> /// Android用の`IShareMedia`の実装 /// </summary> public sealed class ShareMediaForAndroid : IShareMedia { public void ShareText(string text) => ShareTextForAndroid(text); public void ShareImage(byte[] image) => ShareImageForAndroid(image); public void ShareMovie(string moviePath) => ShareMovieForAndroid(moviePath); #region P/Invoke // AndroidのシェアUIからテキストをシェア static void ShareTextForAndroid(string text) { // ネイティブのシェアUI呼び出し (省略) } // AndroidのシェアUIから画像をシェア static void ShareImageForAndroid(byte[] image) { // ネイティブのシェアUI呼び出し (省略) } // AndroidのシェアUIから動画をシェア static void ShareMovieForAndroid(string moviePath) { // ネイティブのシェアUI呼び出し (省略) } #endregion P/Invoke } } #endif ShareMediaForEditor.cs (クリックで展開) ShareMediaForEditor.cs #if UNITY_EDITOR namespace Examples.View { /// <summary> /// Editor用の`IShareMedia`の実装 /// </summary> public sealed class ShareMediaForEditor : IShareMedia { public void ShareText(string text) { Debug.Log("Editorからの呼び出し"); } public void ShareImage(byte[] image) { Debug.Log("Editorからの呼び出し"); } public void ShareMovie(string moviePath) { Debug.Log("Editorからの呼び出し"); } } } #endif ExampleView.cs (クリックで展開) ExampleView.cs namespace Examples.View { /// <summary> /// シェア機能の呼び出しサンプル /// </summary> sealed class ExampleView : MonoBehaviour { [SerializeField] InputField _shareText = default; IShareMedia _shareMedia; void Start() { // プラットフォームに応じて実装を差し替える // NOTE: サンプルなので雑に分岐しているが、実際にやるならDIフレームワーク経由で注入しても良いかもしれない #if UNITY_EDITOR _shareMedia = new ShareMediaForEditor(); #elif UNITY_IOS _shareMedia = new ShareMediaForIOS(); #elif UNITY_ANDROID _shareMedia = new ShareMediaForAndroid(); #else // 非対応プラットフォームなら投げておく // NOTE: 非対応プラットフォームでも動かしたいなら`IShareMedia`を実装したダミークラスを用意して入れておくのも手 throw new NotImplementedException(); #endif // 入力されたテキストのシェア _shareText.onEndEdit.AddListener(text => { _shareMedia.ShareText(text); }); } } } 利点 この形式にすると実装の詳細がプラットフォームごとの実装クラスに委譲されるので、仮に「プラットフォームごとに合わせたデータ変換」と言ったロジックが挟まることになったとしても、処理のスコープを限定できるようになります。 (「iOSで必要な処理はiOSの実装クラス内に」「Androidで必要な処理はAndroidの実装クラス内に」と言った感じにスコープを限定できる) 他にもプリプロセス命令による分岐が初期化タイミングのみとなっているために、全体的に見通しも良くなっているかと思います。 保守性と拡張性 もし対応プラットフォームが増減したとしても既存のコードに対する影響範囲を抑えられます。 (増えた際には実装クラスを追加して初期化時のプリプロセス命令の分岐に追加するだけで済む。減った際にはプリプロセス命令の分岐から消すだけで済む) 他にもIShareMediaを実装したダミークラスを用意することで非対応プラットフォーム実行時/Editor実行時などの振る舞いを分けやすくなったり、ソフトウェアテスト用にMockクラスを実装して挟むと言ったことも対応しやすくなります。 DIフレームワークとの相性 今回の例ではプリプロセス命令でプラットフォームに応じた実装クラスを流し込む形になってますが、実装自体はinterfaceを切っているのでZenjectと言ったDIフレームワークとの親和性も上がります。 仮に他のViewでもシェア機能を呼び出したいとなったときに利便性が上がります。 サンプルプロジェクト ここまでの流れのおさらい用にサンプルプロジェクトを用意しました。 内容としては「端末のバッテリーレベル(容量)を取得して画面に表示する」と言ったものであり、iOS/Android/Editorに対応してます。 (※Editor実行時は常に100%が表示される) サンプルは「design-exanmple-objc++」ブランチにて管理してます。 詳細な実装内容は前の章にて解説した通りなので省きますが、宜しければ一例として御覧ください。 ※備考 ちなみに..こちらのサンプルは説明のために意図的に車輪の再発明をしたものであり、実際にバッテリーレベルを取得するぐらいであれば以下のAPIから取得可能だったりします。 SystemInfo.batteryLevel ここまでのまとめ 大事なポイントとしてはinterfaceを切ってプラットフォームごとに実装を分けることです。 可読性/保守性/拡張性が上がり、DIフレームワークとの相性が良くなっていくるかと思います。 後はinterfaceを切ると言っても慣習的に必ず切る必要は無く、例えば冒頭にも記載したようにサンプル実装や簡単な機能検証程度ならプリプロセス命令で雑に分岐して直接呼び出してしまうのも有りかと思います。 ViewからViewを呼び出すような構成になっているが、このサンプル中ではそこに強い意図は無い。各プロジェクトごとに採用しているアーキテクチャに合わせて呼び出し元をViewなりPresenterなりと変えていけば良いと思う。 ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む