20200630のiOSに関する記事は8件です。

MacのSafari開発タブにiPhone iPad iPodが表示されない問題

MacのSafari開発タブでiPhone iPad iPodが表示されない

なぜそうなってしまったかわからなくて調べたが自分の場合はESETが問題だった。
ESET以外のウイルス対策ソフトが原因の場合もあるだろうから一度ウイルス対策ソフトをOFFにしてみると良いかも。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WKWebViewを利用した実装でメモリリークしやすいパターン2つ

WKWebViewを利用したアプリを開発していてメモリリークの問題に当たったので、その原因を整理してメモしておく

メモリリークするパターン

以下のように、TabというクラスがWKWebViewのインスタンスを強参照しているとする。

class Tab {
   var webView: WKWebView
}

WKWebViewをインスタンス化してプロダクト専用の処理や設定を構成する過程で、特に注意せずに実装するとWKWebViewインスタンスがTabクラスを強参照することになるパターンがある。
そうなると、WKWebViewインスタンスとTabクラスがお互いを強参照する循環参照の状態となり、Tabを破棄したつもりでもメモリ上に残ったままとなり、メモリリークが発生してしまう。

このようなパターンの具体的な例を、以下に2つ挙げる。

KVO登録

WKWebViewにKVOの仕組みを用いて、特定のプロパティが変化したときにTab内の特定の処理を呼び出すという実装を行うとき、メモリリークが発生しやすい。

ここでは、ページ読み込み完了の割合を取得できるestimatedProgressのKVOを登録する例を示す。

メモリリークするパターン
class Tab {
    var webView: WKWebView
    var webViewKeyValueObservers: NSKeyValueObservation?

    func configure() {
        self.webViewKeyValueObservers = webView.observe(\.estimatedProgress) { (webView, _) in
            // 普通に self と書くと強参照になる
            self.estimatedProgressDidChange(webView)
        }
    }

    func estimatedProgressDidChange(_ webView: WKWebView) {
        // ...
    }
}

上記のパターンでは、KVO登録時のクロージャ内でselfつまりTabクラスを強参照しているため、WKWebViewインスタンスがTabを強参照することになる。そのため、循環参照が発生し、メモリリークが発生する。

解決策としては、以下のようにクロージャ内でselfを弱参照にする実装が挙げられる。

メモリリークしないパターン
class Tab {
    var webView: WKWebView
    var webViewKeyValueObservers: NSKeyValueObservation?

    func configure() {
        self.webViewKeyValueObservers = webView.observe(\.estimatedProgress) { [weak self] (webView, _) in
            // [weak self] を付与することで self が弱参照になる
            self?.estimatedProgressDidChange(webView)
        }
    }

    func estimatedProgressDidChange(_ webView: WKWebView) {
        // ...
    }
}

上記のようにすることで、WKWebViewがTabを強参照しなくなり、循環参照が発生せず、メモリリークが発生しない。

UserContentController登録

WKWebViewからネイティブの処理を実行する仕組みUserContentControllerを使う時、ネイティブの実装としてTabを指定する場合に、メモリリークが発生しやすい。

メモリリークするパターン
class Tab {
    var webView: WKWebView

    func configure() {
        // 普通に self を渡すと強参照になる
        webView.configuration.userContentController.add(self, name: "test")
    }
}

extension Tab: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard message.name == "test" else { return }
        // ...
    }
}

UserContentControllerのaddは引数に指定したインスタンスを強参照する仕様になっており、上記のパターンでは、WKWebViewのインスタンスがselfつまりTabクラスを強参照することになる。そのため、循環参照が発生し、メモリリークが発生してしまう。

解決策としては、このページの流用となるが、以下のようにTabを一旦弱参照にするクラスをブリッジとして入れる実装が挙げられる。

メモリリークしないパターン
class Tab {
    var webView: WKWebView

    func configure() {
        // self を弱参照にして渡す
        webView.configuration.userContentController.add(
            WKScriptMessageHandlerWithWeakReference(to: self), name: "test")
    }
}

extension Tab: WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard message.name == "test" else { return }
        // ...
    }
}

class WKScriptMessageHandlerWithWeakReference: NSObject, WKScriptMessageHandler {
    private weak var delegate: WKScriptMessageHandler?

    init(to delegate: WKScriptMessageHandler) {
        self.delegate = delegate
        super.init()
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        self.delegate?.userContentController(userContentController, didReceive: message)
    }
}

上記のようにすることで、WKWebViewがTabを強参照しなくなり、循環参照が発生せず、メモリリークが発生しない。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutter(Dart)でValidationを実装する

バリデーション

validation_helper.dart
// 英数のみ、6桁のバリデーション

class Validator {
  Validator._();

  static bool isValidHoge(String hoge) {
    const _hogeRegExpString = r'^[0-9a-zA-Z]{6}$';
    return RegExp(_hogeRegExpString, caseSensitive: true).hasMatch(hoge);
  }
}

接頭辞rを付することでraw(生)文字列として扱うことができます。(エスケープシーケンス回避のため)

参考記事
1. form用正規表現判定/備忘
2. RegExp constructor

実装例

example_notifier.dart
// freezedを使用した実装例

class Hoge {
  void onChangeHoge(String hoge) {
    state = state.copyWith(hoge: hoge);
    if (Validator.isValidHoge(hoge)) {
      state = state.copyWith(isEnable: true);
    } else {
      state = state.copyWith(isEnable: false);
    }
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Alamofire5.2のAuthenticationInterceptorでトークンリフレッシュを試す

AuthenticationInterceptor

先月Alamofire 5.2がリリースされました。
https://github.com/Alamofire/Alamofire/releases/tag/5.2.0

新機能の1つとして、「AuthenticationInterceptor」があります。
(Release)
https://github.com/Alamofire/Alamofire/releases/tag/5.2.0
(PR)
https://github.com/Alamofire/Alamofire/pull/3164

クレデンシャルを使用したリクエストのAdaptとRetryを容易にするために導入されたとされています。
AuthenticationInterceptorは、AdaptとRetryの処理におけるスレッドやキューイングの管理を意識せずに実装するための仕組みのようです。

経緯

導入の発端?となったのはこのissueのようです。
https://github.com/Alamofire/Alamofire/issues/3086

今までAdvanced Usageに記載されていたOAuthのトークンリフレッシュ処理のexampleが突然削除されたのはなぜか?という趣旨のissueです。

従来Alamofireで行われていたトークンリフレッシュの実装はこんな感じでした。(上記issueより抜粋)

  // MARK: - RequestRetrier

    func should(_ manager: SessionManager, retry request: Request, with error: Error, completion: @escaping RequestRetryCompletion) {
        lock.lock() ; defer { lock.unlock() }

        if let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 {
            requestsToRetry.append(completion)

            if !isRefreshing {
                refreshTokens { [weak self] succeeded, accessToken, refreshToken in
                    guard let strongSelf = self else { return }

                    strongSelf.lock.lock() ; defer { strongSelf.lock.unlock() }

                    if let accessToken = accessToken, let refreshToken = refreshToken {
                        strongSelf.accessToken = accessToken
                        strongSelf.refreshToken = refreshToken
                    }

                    strongSelf.requestsToRetry.forEach { $0(succeeded, 0.0) }
                    strongSelf.requestsToRetry.removeAll()
                }
            }
        } else {
            completion(false, 0.0)
        }
    }

RequestRetrierを用いる方法ですが、上記のようなコードが今まではAlamofireのAdvanced Usageには記載がありました。
それが最近突如削除されたので、既存のサンプルコードが何かまずい動作をしていたのではないかと心配になった開発者の方々がいたようですね。
私もRequestRetrierを実際に利用していましたが、「lock.lock()処理がサンプルに入っているけど、入れても他に影響ないかな?」みたいなことを考えた記憶があります。

Alamofireの開発サイドとしては、どうやら掲載していたサンプルコードは本番で動く想定のものではなかったとのことです。しかしその意図と反して本番プロジェクトにも利用されることが増えてしまったため、Advanced Usageから削除したとコメントされています。実際、ネット上で見るRequestRetrierの実装の紹介の多くが上記のような実装になっていますよね。

こういった状況を踏まえ、スレッドの管理やlock処理について意識した実装をする必要のない仕組みの導入に至ったようです。

使い方

クレデンシャル情報をプロパティに持つstructを定義します。(公式のサンプルから抜粋)

struct OAuthCredential: AuthenticationCredential {
    let accessToken: String
    let refreshToken: String
    let userID: String
    let expiration: Date

    // Require refresh if within 5 minutes of expiration
    var requiresRefresh: Bool { Date(timeIntervalSinceNow: 60 * 5) > expiration }
}

次にAuthenticatorクラスを実装します。
applyメソッドでは、クレデンシャル情報の適用(headerにトークン付与)、refreshメソッドではリフレッシュ処理を行います。

class OAuthAuthenticator: Authenticator {
    func apply(_ credential: OAuthCredential, to urlRequest: inout URLRequest) {
        urlRequest.headers.add(.authorization(bearerToken: credential.accessToken))
    }

    func refresh(_ credential: OAuthCredential,
                 for session: Session,
                 completion: @escaping (Result<OAuthCredential, Error>) -> Void) {

        // トークンリフレッシュ処理
        requestToRefreshToken()
        // 新しいクレデンシャルの作成
        completion(.success(newCredential))
    }

    func didRequest(_ urlRequest: URLRequest,
                    with response: HTTPURLResponse,
                    failDueToAuthenticationError error: Error) -> Bool {
        return response.statusCode == 401
    }

    func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: OAuthCredential) -> Bool {
        let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
        return urlRequest.headers["Authorization"] == bearerToken
    }
}

上記のようにrefreshメソッド内でトークンリフレッシュ処理を行い、新しいクレデンシャル情報をcompletion(.success)に渡してあげると、それ以降の通信は新しいクレデンシャルをapplyメソッドで適用してくれるようになります。

使い方はこんな感じです。 (公式のAdvancedUsageより抜粋)

// Generally load from keychain if it exists
let credential = OAuthCredential(accessToken: "a0",
                                 refreshToken: "r0",
                                 userID: "u0",
                                 expiration: Date(timeIntervalSinceNow: 60 * 60))

// Create the interceptor
let authenticator = OAuthAuthenticator()
let interceptor = AuthenticationInterceptor(authenticator: authenticator,
                                            credential: credential)

// Execute requests with the interceptor
let session = Session()
let urlRequest = URLRequest(url: URL(string: "https://api.example.com/example/user")!)
session.request(urlRequest, interceptor: interceptor)

手軽にトークンリフレッシュ機能を実装することが可能でした。

尚、Authenticatorクラスの「didRequest」「isRequest」メソッドですが、下記のような定義がコメントで書かれているものの、手元でこのメソッドに到達するケースを見つけることができませんでした。

    func didRequest(_ urlRequest: URLRequest,
                    with response: HTTPURLResponse,
                    failDueToAuthenticationError error: Error) -> Bool {
        // If authentication server CANNOT invalidate credentials, return `false`
        return false

        // If authentication server CAN invalidate credentials, then inspect the response matching against what the
        // authentication server returns as an authentication failure. This is generally a 401 along with a custom
        // header value.
        // return response.statusCode == 401
    }

    func isRequest(_ urlRequest: URLRequest, authenticatedWith credential: OAuthCredential) -> Bool {
        // If authentication server CANNOT invalidate credentials, return `true`
        return true

        // If authentication server CAN invalidate credentials, then compare the "Authorization" header value in the
        // `URLRequest` against the Bearer token generated with the access token of the `Credential`.
        // let bearerToken = HTTPHeader.authorization(bearerToken: credential.accessToken).value
        // return urlRequest.headers["Authorization"] == bearerToken
    }

issueでも質問をしてみていますが、引き続き調査をしつつ判明したことがあったら追記をしていきます。

以上、AuthenticationInterceptorを使ってみた際のメモでした。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

systemLayoutSizeFittingでViewの高さを計算する

UICollectionViewCellなどで、表示する内容とAutoLayoutの制約から高さを計算したいことは、よくあると思います。
そのような時は以下のようにsystemLayoutSizeFittingSize:を使うだけで、簡単に高さを得ることができます。

class SampleCell: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!

    private static var layoutCell: SampleCell = {
        UINib(nibName: "SampleCell", bundle: nil)
            .instantiate(withOwner: nil, options: nil)[0] as! SampleCell
    }()

    static func fittingSize(from model: String, with containerWidth: CGFloat) -> CGSize {
        layoutCell.label.text = model
        layoutCell.frame.size = CGSize(width: containerWidth, height: .greatestFiniteMagnitude)
        layoutCell.setNeedsLayout()
        layoutCell.layoutIfNeeded()
        return layoutCell.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
    }
}

Screen Shot 2020-06-30 at 15.18.59.png

上記のxibとコードを、Xcode Previewsで次のように確認すると高さの計算がうまくいってることがわかります。

struct SampleCell_Previews: PreviewProvider {
    static var model = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."

    static var previews: some View {
        let cell = UINib(nibName: "SampleCell", bundle: nil)
            .instantiate(withOwner: nil, options: nil)[0] as! SampleCell
        cell.label.text = model
        let view = Wrap(uiView: cell)
        return view
            .previewLayout(
                .fixed(
                    width: 320,
                    height: SampleCell.fittingSize(from: model, with: 320).height
                )
            )
    }
}

struct Wrap<T: UIView>: UIViewRepresentable {
    var uiView: T
    func makeUIView(context: Context) -> T { uiView }
    func updateUIView(_ view: T, context: Context) {}
}

Screen Shot 2020-06-25 at 18.03.43.png

このように多くの場合はsystemLayoutSizeFittingSize:で期待した通りの高さを得ることができます。ただし、Aspect Ratio制約を使っている場合は注意が必要です。
例えば以下のようなViewの場合です。(青いViewは1:1のAspect Ratio制約をかけて正方形にしています)
このUILabelに入る文字列の長さや指定した幅の大きさによっては、小さいサイズに縮小されてしまい、期待する高さを得られないことがあります。

Screen Shot 2020-06-30 at 15.23.15.png

class SampleCell2: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!

    private static var layoutCell: SampleCell2 = {
        UINib(nibName: "SampleCell2", bundle: nil)
            .instantiate(withOwner: nil, options: nil)[0] as! SampleCell2
    }()

    static func fittingSize(from model: String, with containerWidth: CGFloat) -> CGSize {
        layoutCell.label.text = model
        layoutCell.frame.size = CGSize(width: containerWidth, height: .greatestFiniteMagnitude)
        layoutCell.setNeedsLayout()
        layoutCell.layoutIfNeeded()
        return layoutCell.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
    }
}

struct SampleCell2_Previews: PreviewProvider {
    static var model = "Lorem ipsum"

    static var previews: some View {
        let cell = UINib(nibName: "SampleCell2", bundle: nil)
            .instantiate(withOwner: nil, options: nil)[0] as! SampleCell2
        cell.label.text = model
        let view = Wrap(uiView: cell)
        return view
            .previewLayout(
                .fixed(
                    width: 320,
                    height: SampleCell2.fittingSize(from: model, with: 320).height
                )
            )
    }
}

Screen Shot 2020-06-30 at 15.30.01.png

そのような場合はsystemLayoutSizeFittingSize:withHorizontalFittingPriority:verticalFittingPriority:を使い、HorizontalFittingPriorityをrequiredにすると指定した幅が優先されます。これでUILabelに入る文字列の長さや指定した幅に依存せず、期待した高さを得ることができます。

    static func fittingSize(from model: String, with containerWidth: CGFloat) -> CGSize {
        layoutCell.label.text = model
        layoutCell.frame.size = CGSize(width: containerWidth, height: .greatestFiniteMagnitude)
        layoutCell.setNeedsLayout()
        layoutCell.layoutIfNeeded()
//        return layoutCell.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
        return layoutCell.systemLayoutSizeFitting(
            UIView.layoutFittingCompressedSize,
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel
        )
    }

Screen Shot 2020-06-30 at 15.33.56.png

こちらにこれらのコードを含んだサンプルプロジェクトを公開しました。気になる方は、ぜひ確認してみてください。
https://github.com/maoyama/SystemLayoutSizeFittingSample

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS12でもXcode Previewsを使いたい!

はじめに

ある日、下記のような条件によって表示する項目が変わるカスタム View を修正することになりました。(実際はもっと項目が多い)

final class PiyoView: UIStackView {

    struct Config {
        let isAlertShown: Bool
        let alert: String?
        let isInfoShown: Bool
        let info: String?
        let message: String
    }

    private var alertBackgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor(named: "alert_red_bg")
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    private var alertContainerView: UIStackView = {
        let stack = UIStackView()
        stack.axis = .horizontal
        stack.spacing = 8
        stack.alignment = .center
        stack.translatesAutoresizingMaskIntoConstraints = false
        return stack
    }()

    private var alertImageView: UIImageView = {
        let image = UIImageView()
        image.tintColor = UIColor(named: "alert_red")
        if #available(iOS 13.0, *) {
            image.image = UIImage(systemName: "exclamationmark.shield.fill")
            image.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                image.heightAnchor.constraint(equalToConstant: 25),
                image.widthAnchor.constraint(equalToConstant: 25)
            ])
        } else {
            // Fallback on earlier versions
        }
        return image
    }()

    private var alertLabel: UILabel = {
        let label = UILabel()
        label.textColor = UIColor(named: "alert_red")
        label.font = .systemFont(ofSize: 16)
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        label.setContentHuggingPriority(.init(750), for: .vertical)
        return label
    }()

    private var infoBackgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor(named: "info_blue_bg")
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    private var infoContainerView: UIStackView = {
        let stack = UIStackView()
        stack.axis = .horizontal
        stack.spacing = 8
        stack.alignment = .center
        stack.translatesAutoresizingMaskIntoConstraints = false
        return stack
    }()

    private var infoImageView: UIImageView = {
        let image = UIImageView()
        image.tintColor = UIColor(named: "info_blue")
        if #available(iOS 13.0, *) {
            image.image = UIImage(systemName: "info.circle.fill")
            image.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                image.heightAnchor.constraint(equalToConstant: 25),
                image.widthAnchor.constraint(equalToConstant: 25)
            ])
        } else {
            // Fallback on earlier versions
        }
        return image
    }()

    private var infoLabel: UILabel = {
        let label = UILabel()
        label.textColor = UIColor(named: "info_blue")
        label.font = .systemFont(ofSize: 16)
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        label.setContentHuggingPriority(.init(750), for: .vertical)
        return label
    }()

    private var messageBackgroundView: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    private var messageContainerView: UIStackView = {
        let stack = UIStackView()
        stack.axis = .horizontal
        stack.spacing = 8
        stack.alignment = .center
        stack.translatesAutoresizingMaskIntoConstraints = false
        return stack
    }()

    private var messageLabel: UILabel = {
        let label = UILabel()
        label.font = .systemFont(ofSize: 12)
        label.numberOfLines = 0
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: .zero)
        commonInit()
    }

    required init(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }

    private func commonInit() {
        axis = .vertical
        spacing = 0

        alertContainerView.addArrangedSubview(alertImageView)
        alertContainerView.addArrangedSubview(alertLabel)
        alertBackgroundView.addSubview(alertContainerView)
        addArrangedSubview(alertBackgroundView)
        NSLayoutConstraint.activate([
            alertContainerView.topAnchor.constraint(equalTo: alertBackgroundView.topAnchor, constant: 8),
            alertContainerView.bottomAnchor.constraint(equalTo: alertBackgroundView.bottomAnchor, constant: -8),
            alertContainerView.leadingAnchor.constraint(equalTo: alertBackgroundView.leadingAnchor, constant: 16),
            alertContainerView.trailingAnchor.constraint(equalTo: alertBackgroundView.trailingAnchor, constant: -16)
        ])

        infoContainerView.addArrangedSubview(infoImageView)
        infoContainerView.addArrangedSubview(infoLabel)
        infoBackgroundView.addSubview(infoContainerView)
        addArrangedSubview(infoBackgroundView)
        NSLayoutConstraint.activate([
            infoContainerView.topAnchor.constraint(equalTo: infoBackgroundView.topAnchor, constant: 8),
            infoContainerView.bottomAnchor.constraint(equalTo: infoBackgroundView.bottomAnchor, constant: -8),
            infoContainerView.leadingAnchor.constraint(equalTo: infoBackgroundView.leadingAnchor, constant: 16),
            infoContainerView.trailingAnchor.constraint(equalTo: infoBackgroundView.trailingAnchor, constant: -16)
        ])

        messageContainerView.addArrangedSubview(messageLabel)
        messageBackgroundView.addSubview(messageContainerView)
        addArrangedSubview(messageBackgroundView)
        NSLayoutConstraint.activate([
            messageContainerView.topAnchor.constraint(equalTo: messageBackgroundView.topAnchor, constant: 8),
            messageContainerView.bottomAnchor.constraint(equalTo: messageBackgroundView.bottomAnchor, constant: -8),
            messageContainerView.leadingAnchor.constraint(equalTo: messageBackgroundView.leadingAnchor, constant: 16),
            messageContainerView.trailingAnchor.constraint(equalTo: messageBackgroundView.trailingAnchor, constant: -16)
        ])
    }

    func configure(_ config: Config) {
        alertBackgroundView.isHidden = !config.isAlertShown
        alertLabel.text = config.alert
        infoBackgroundView.isHidden = !config.isInfoShown
        infoLabel.text = config.info
        messageLabel.text = config.message
    }
}

いや!xib ねーし表示パターン多過ぎてわかんねぇーよ!!ってなりました:confused:
チェックめんどくさいなぁと思ってたらそういえば以前レイアウトチェックに Xcode Previews 使ってるっていうのを聞いたので今回導入してみました。

問題

Xcode Previews を導入するには import SwiftUI する必要があるのですが該当プロジェクトの Deployment Target が iOS 12 。。。

解決策

こちら Xcode Previewsを用いたUIKitベースのプロジェクトの開発効率化 を参考に Preview 用ターゲットを追加することで実現できました:tada:

導入方法

  1. Duplicate でターゲットを複製
  2. ターゲット名を変更(ex. ~Preview とか)
    target
  3. Deployment Target を iOS 13.0 にする
    deployment
  4. Preview 用ディレクトリ作成
  5. 上記ディレクトリに Info.plist 移動
  6. 上記ディレクトリに PiyoViewPreviews.swift 追加(Target Membership は Preview 用のみにする)
    directory membership

PiyoViewPreviews.swift はこんな感じ。

import SwiftUI

struct PiyoViewWrapper: UIViewRepresentable {

    let config: PiyoView.Config
    init(config: PiyoView.Config) {
        self.config = config
    }
    func makeUIView(context: UIViewRepresentableContext<PiyoViewWrapper>) -> PiyoView {
        return PiyoView()
    }
    func updateUIView(_ uiView: PiyoView, context: UIViewRepresentableContext<PiyoViewWrapper>) {
        uiView.configure(config)
    }
}

struct PiyoViewWrapper_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            PiyoViewWrapper(config: .init(isAlertShown: true, alert: "アラート", isInfoShown: true, info: "インフォメーション", message: "メッセージ")).previewDisplayName("全表示")

            PiyoViewWrapper(config: .init(isAlertShown: false, alert: nil, isInfoShown: true, info: "インフォメーション", message: "メッセージ")).previewDisplayName("アラート非表示")

            PiyoViewWrapper(config: .init(isAlertShown: true, alert: "アラート", isInfoShown: false, info: nil, message: "メッセージ")).previewDisplayName("インフォ非表示")

            PiyoViewWrapper(config: .init(isAlertShown: false, alert: nil, isInfoShown: false, info: nil, message: "メッセージ")).previewDisplayName("メッセージのみ表示")

            PiyoViewWrapper(config: .init(isAlertShown: true, alert: "アラート\nアラート", isInfoShown: true, info: "インフォメーション\nインフォメーション", message: "メッセージ\nメッセージ")).previewDisplayName("複数行表示")

        }.previewLayout(.fixed(width: 375, height: 160))
    }
}

これで Previews を起動するとこんな感じ

previews

パラメータを変更すればすぐに反映されるしサイズを変えたりして (.previewLayout(.fixed(width: 375, height: 160)) <- ここ)すぐに SE 用とかも確認できる:confetti_ball:
元のソースはいじらなくていいしすばらしい:clap:

その他Tips(2020/07/02追記)

ダークモード表示

struct PiyoViewWrapper_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            PiyoViewWrapper(config: .init(isAlertShown: true, alert: "アラート", isInfoShown: true, info: "インフォーメーション", message: "メッセージ"))
                .environment(\.colorScheme, .dark)
                .previewDisplayName("全表示・ダークモード")

            PiyoViewWrapper(config: .init(isAlertShown: true, alert: "アラート", isInfoShown: true, info: "インフォーメーション", message: "メッセージ"))
                .environment(\.colorScheme, .light)
                .previewDisplayName("全表示・ライトモード")
        }.previewLayout(.fixed(width: 375, height: 160))
    }
}

上記のように .environment(\.colorScheme, .dark) でダークモード表示もできる。
こんな感じ
dark

ローカライズ表示

残念ながらローカライズは LocalizedStringKey を使わないといけないみたいなのであきらめました:disappointed_relieved:
こんなんやってみたけど無理だった...

extension String {
    func localized() -> String {
        return NSLocalizedString(self, comment: "")
    }
}

struct PiyoViewWrapper_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            PiyoViewWrapper(config: .init(isAlertShown: true, alert: "alert".localized(), isInfoShown: true, info: "info".localized(), message: "message".localized()))
                .environment(\.locale, .init(identifier: "ja"))
                .previewDisplayName("全表示")
        }.previewLayout(.fixed(width: 375, height: 160))
    }
}

どうしても Previews でみたければ Edit Scheme... -> Run -> Options -> Application Language を変えてみるしかないと思います。
language

Segmentation fault 11

Segmentation fault 11...ビルドはできるけど Xcode Previews はこんなエラーでむり...っていうことがありました。きっと運がよければ Derived Data 削除とかクリーンでいけるかもしれませんが私はむりだったので Compile Sources からファイルを全部削除して Previews に必要なものだけ追加するようにするととりあえず動きました。(Segmentation fault 11 <- こいつはほんとにわからない。。。)
compile_sources

Xcode Previewsが消えた場合

Xcode Previews が消えた場合は右上の Adjust Editor Options -> Canvas で表示できます。
canvas

おわりに

いちいちシミュレータ起動しなくていいし快適!今まで SwiftUI じゃねぇし Previews とかしらねぇわと思ってましたがわりといいんじゃないかと思います:sunglasses:

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Ionic & golangでリアルタイム大喜利アプリを作りました

アプリのリリースまでなんとか漕ぎ着けたので、宣伝も兼ねて使ったフレームワークについて紹介します。

どんなアプリか

写真で一言アプリです。
特徴的なのはリアルタイムで集まった人たちが、お互いに投稿し合ったボケを評価し合う部分です。

マッチングした最大10名が、制限時間内に一つのお題写真に対してボケを投稿します。
投稿はお互いに評価することができ、一定数イイネを集めると一本獲得になります。

こちらからダウンロード可能です。
よかったら遊んでみてください。

android
https://play.google.com/store/apps/details?id=jp.co.popbits.funnyapp

ios
https://apps.apple.com/jp/app/funny-one/id1515018792

アプリ側

ionic

アプリのベースはIonic Frameworkで作成しました。

https://ionicframework.com/

ざっくりいうと、
webview + Angular
にネイティブっぽいリッチな見た目のコンポーネントと、共通化されたプラグインが利用できます。
プラグインの例としてはSocial Sharingやdeeplink等があります。

Angularではなく、vueやreactで記載することも可能なようです。
※reactは最近入ったらしい。


android, ios同一のコードで記載できるので工数削減になりました。
環境構築やビルドなどもかなりスムーズにできました。
(多少のつまづきはありますが、ネイティブアプリの環境構築にはつきものです)

懸念点として、webviewベースであるためパフォーマンスがあまり良くないのではないか気になりましたが、
端末の性能も上がってきているからか、思ったよりサクサク動いてくれています。

createjs

大喜利バトルの部分はcreatejsで作成しました。
https://createjs.com/

Adobe Animate
https://www.adobe.com/jp/products/animate.html
でcreatejs用にアニメーションを書き出しています。

アプリへのつなぎ込みには色々と試行錯誤が必要でしたが、細かい話になるので割愛します。

大喜利バトルでは後述するgolangサーバとwebsocketで接続し、イベント毎にアニメーションを変化させます。


サーバ側

golang

リアルタイムや並列処理に強そうなので採用しました。
pythonもよく書くのですが、golangは型があるのでバグが起きづらい、
パフォーマンスが良いなどのメリットがあります。

コードは誰が書いても同じ書き方になるよう矯正され、必然的に可読性が高くなります。

一方、リアルタイム部分(websoket接続)はgoroutine と channelを駆使して作るのですが、
正直自分以外理解できないコードになってしまったと思います。

並列処理そのものが難しいのもあると思います。
通常の処理とは違いコードの上から追っていくだけでは読み解くことができず、
どこからchannnelにアクセスが有るなど把握していないといけません。

並列処理は色々な書き方ができるので正解がない感じです。

gin

https://github.com/gin-gonic/gin

デファクトスタンダードっぽいので採用しました。
良さげです。

melody

https://github.com/olahol/melody

websocketを扱う際にいい感じの機能を提供してくれます。

sqlx + squirrel

https://github.com/jmoiron/sqlx
クエリ結果をstructsにマッピングしてくれます。

https://github.com/Masterminds/squirrel
SQLクエリビルダーです。
where句の検索条件をqueryオブジェクトに対して注入していく、
みたいなことがやりたい場合はこれでSQLを作ります。

最初はgormを使っていたのですが、最終的に上の構成に書き換えることにしました。
gormは人気があるようなので使っていたのですが、挙動が直感的でなかったのと、
思いも寄らない部分で大量のSQLを発行されていたことがあったりで、嫌になってやめました。

まとめ

色々紹介しましたが、ionicはかなり気軽にアプリが作成できるので本当におすすめです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

App Clipの設計と構築方法

WWDC 2020のExplore app clipsを見たのでメモです。

※ここに出てくるスクリーンショットは、全て上記の動画のものです。
※App ClipのHIG概要はこちらから。
https://twitter.com/akatsuki174/status/1276481990313734144

概要

App Clipの設計と構築方法、App Clipと普通のアプリとの違いについて。

App Clipとは何なのか

3つの概念を理解する必要がある。
スクリーンショット 2020-06-29 8.35.22.png

アプリ

App Clipはあくまでもアプリの追加機能であり、App Clipを作るにはアプリの存在が必要になる。

App Clip Experience

iOS 14で開いたときにWebブラウザの代わりにApp Clipで処理されるURLのことで、アプリの新しいエントリーポイントになる。ユニバーサルリンク対応をしたことがある人にとってはプロセスが似ていることに気づくはず。違いとしてはApp Clip URLはapp site associationsファイルで定義されているのではなく、App Store Connectを使用して登録されているということ。experienceの作成とカスタマイズについては「Configure and Link your App Clips」にて。

App Clip Experienceを登録してApp Clipがストアで公開されると、そのURLが開かれたときにApp Clipが表示されるようになる。App ClipはQRコード、NFCタグ、Safariやメッセージのリンク、地図アプリ、Siriからの提案などの様々なところから開くことができる。

App Clipを見つける最良の方法は、今年後半に登場する、Appleがデザインした新しいApp Clipコードを使うこと。これならApp Clipが使えるということがわかりやすい。App ClipコードはNFCと視覚コードの使いやすさを備えており、タップしたりスキャンしたりできる。
スクリーンショット 2020-06-29 8.52.56.png

App Clip

アプリをインストールしなくても一部アプリの体験ができる。

App Clipを提供するには、Xcodeで2つめのアプリケーションターゲットを作成する必要がある。このターゲットにはApp Clipを動かす上で必要なコードとアセットを含める。App Clipターゲットはアプリのようにビルドされるが、大元のアプリは必要になるし、アプリとApp Clipを別々にアップデートすることはできない。ただし、App Storeで公開されると互いに排他的なものになる。

端末はApp Clip Experienceに遭遇すると、そのアプリをインストールしていない場合はApp Clipがダウンロードされる。インストールされている場合はアプリが開かれる。

より良いApp Clipのために

App Clipは素早く体験できるところが重要なので、ダウンロード後すぐ起動して使えるように、できるだけ小さいサイズにする必要がある。圧縮後10MB以下である必要がある。

以下の図の四角は画面、線はユーザフローを表している。左側はタブバーなどを表し、右に行くにつれてナビゲーションスタックを使ってゴールに向かうイメージ。アプリはこれでも良いが、App Clipの場合はフローを再考する方が良い。
スクリーンショット 2020-06-29 15.48.29.png
App Clipの強みを活かせる部分だけを考える。プロフィール画面のような定番の機能はもはや意味をなさないかもしれない。
スクリーンショット 2020-06-29 15.54.35.png

異なる体験には別々のURLを使用する。例えば商品を注文する場合、注文とチェックアウトのexperienceを分けるなど。また、複数の店舗を持っている場合はURLで店舗を特定すれば、店舗の選択画面を表示することなくコンテンツに直行できる。

デモ

使用するアプリ

Frutaを例にApp Clipを説明。
Frutaはスムージーメニューの閲覧や注文、ポイント獲得、レシピ検索ができるアプリ。App Clipでは注文体験に重点を置きたいので、メニュータブの機能を搭載させる。

実装方法

XcodeでApp Clip用の新しいターゲットを作成する。
スクリーンショット 2020-06-29 21.34.45.png
FrutaアプリのEmbed in Applicationとして埋め込む。
スクリーンショット 2020-06-29 21.37.42.png
App Clipの名前とBundle Identifierは自動で入力される。

App Clipをビルドしてみると、空っぽのボイラーテンプレートが表示されるだけなので、コード、リソース、必要なdependencyを追加していく。新しくSharedAssetsという名前のアセットカタログを作り、App Clipにも必要なリソースを追加してApp Clipターゲットに入れる。

必要に応じてAPPCLIPという新しいカスタムフラグを定義して使用する。
スクリーンショット 2020-06-29 21.57.23.png
最後に、スムージーメニューを表示できるように新しいNavigationViewを作成する。

技術的な補足説明

スクリーンショット 2020-06-29 22.24.06.png
App Clipが複数のApp Clip Experienceをサポートしている場合は、NSUserActivityのwebpageURLを通じてどのexperienceを処理すべきか判断することができる。

App Clipはextensionと異なり、iOS SDKの任意のAPIを使うことができるため、アプリとApp Clip間のコード共有が簡単。ただし、App Clipからはセンシティブなユーザデータへのアクセスは制限されている。データにアクセスできるかどうかはリクエスト前に確認する。例えばApp Clipの場合HealthKitフレームワークを使用するソースコードは共有することができるが、HKHealthStoreのisHealthDataAvailableは常にfalseを返す。

その他ヒントは「Streamline Your App Clip」セッションを見ると良い。

App Clipからアプリにアップグレードした場合にデータ移行できるAPIも用意している。これは共有データコンテナを使って行われる。

App Clip開発時の注意点

App Clipは一定期間使われないとApp Clipとそのデータが削除される。App ClipのデータはiOSのバックアップには含まれない。App Clipはプライバシーを重視し、フィットネスなどの機密データへのアクセスを制限している。アプリ側がこのようなタイプのデータを使用している場合、より多くの機能を利用してもらうためにインストールを促すことができる。

App ClipはカスタムURLスキーム、ユニバーサルリンク等に登録することはできない。もしカスタムURLスキームをfederatedサインインサービスのコールバックとして使用している場合は、ASWebAuthenticationSessionを使用する必要がある。

App Clipはコンテンツブロッカーのような拡張機能を含めることはできない。

App Clipとアプリ

App ClipのQRコードを見つけてスキャンしたとする。アプリがインストールされていない場合はiOS側が自動でApp Clipをダウンロードして起動してくれる。一定期間App Clipが使用されなかったら、iOSはデータコンテナ、及びキーチェーンデータを削除する。ユーザが再度このexperienceを始めた時には、以前と同じように全プロセスを繰り返す。App Clipはデバイスにデータを保存することはできるが、システム判断で削除される可能性があるため、一時的なキャッシュだと思って扱うのが良い。

App Clipユーザがアプリがインストールされたら、iOSはカメラ、マイク、Bluetoothアクセスの認証情報を自動で移行してくれる。他にも移行したいデータがあれば、App Clipの標準コンテナではなく共有データコンテナに保存してデータ移行する。

優れたApp Clipを作る優れた技術

  • Apple Pay
    • クレジットカードを探すことなく素早く買い物をすることができる
  • プッシュ通知
    • スムージーが買えるようになったとき、パーキングメーターの期限が切れたときに通知できる
  • SwiftUI
    • 小型で再利用可能なコンポーネントに焦点を当てているため相性が良い
  • SKOverlay
    • App Clipを使った後にアプリに誘導するためにオーバーレイ表示をする(詳しくは「What's New in In-App Purchase」セッションにて)
  • ASAuthorizationController
    • 購入時のリワードなどのためにユーザをアカウントに紐付けるメリットがある場合に使うと、パスワードベースのアカウントやSign in with Appleベースのアカウントにサインインできる(詳しくは「What's New in Authentication」にて)

まとめ

  • App Clipはアプリをインストールしなくても必要に応じて呼び出すことができる
  • アプリがインストールされたらデータの引き継ぎができる
  • App Clipはアプリと同じ技術を使って構築されているので、アプリの知識を使うことができる
  • App Clipは特定のタスク達成に焦点を当てた直線的な機能
  • リワードなどの機能は目標を達成した後の任意のステップとして提示されるべき
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む