20210109のiOSに関する記事は5件です。

[Xcode]README.mdファイルの追加方法

表題の通りXcodeにREADME.mdファイルを追加する方法について投稿します。

検索をかけたところ最近の記事にありつけなかったので、ぜひ2021年最新版として参考にしていただければと思います!

追加手順(シンプル)

Xcodeナビゲーションバー(Macディスプレイ最上部のメニューバー)ののFileを選択

Newを選択

Fileを選択

すると画像のようにファイルを選択するメニューが出てくるので、Markdown Fileを選択してNext
image.png

そのままページが切り替わるのでCreateを選択

これでGitにコミットすればGithubのリポジトリでREADMEに加えた編集内容を見ることができます。

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

UITextViewの一部のテキストに背景色を角丸でつける

UITextViewの一部のテキストに背景色を角丸でつける方法です。

背景色をつける

UITextView のテキストの一部に背景色をつけたい場合は、NSAttributedString + NSAttributedString.Key.backgroungColor 属性を使います。

let text = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、..."
let attributedText = NSMutableAttributedString(string: text)
attributedText.addAttributes([.backgroundColor: UIColor.cyan], range: NSRange(location: 2, length: 7))

let textView = UITextView()
textView.attributedText = attributedText

スクリーンショット 2021-01-09 22.18.13.png

背景色を角丸でつける

この背景色の部分に角丸をつけたい場合は、NSLayoutManagerfillBackgroundRectArray メソッドをオーバーライドして背景を角丸で描画するカスタムクラスを作り、UITextViewに適用します。

TextLayout.swift
import UIKit

class LayoutManager: NSLayoutManager {

    var backgroundCornerRadius: CGFloat = 0

    override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) {
        guard let context = UIGraphicsGetCurrentContext() else {
            super.fillBackgroundRectArray(rectArray, count: rectCount, forCharacterRange: charRange, color: color)
            return
        }
        context.saveGState()
        defer { context.restoreGState() }
        context.setFillColor(color.cgColor)

        for rectIndex in 0..<rectCount {
            let rect = rectArray.advanced(by: rectIndex).pointee
            let path = UIBezierPath(roundedRect: rect, cornerRadius: backgroundCornerRadius)
            context.addPath(path.cgPath)
            context.fillPath()
        }
    }
}
let layoutManager = LayoutManager()
layoutManager.backgroundCornerRadius = 5

let textView = UITextView()
textView.textContainer.replaceLayoutManager(layoutManager)

スクリーンショット 2021-01-09 21.33.21.png

背景色を角丸でつける(改行も考慮する)

上記の方法の場合、背景色のつくテキストが改行されると次のように折り返しの部分も角丸になってしまいます。

スクリーンショット 2021-01-09 21.44.24.png

そこで、改行も考慮して fillBackgroundRectArray メソッドを実装してみます。実装のポイントは、

  • fillBackgroundRectArray メソッドはテキスト内の backgroundColor 属性ごとに呼び出される
  • 一つの backgroundColor 属性に対して、テキストが改行される場合は、入力の矩形 rectArray, rectCount が複数になる
  • 複数の矩形がある場合は、最初の矩形は左側のみ角丸をつけ、最後の矩形は右側のみ角丸をつけ、それ以外は角丸をつけない

という感じです。

TextLayout.swift
class LayoutManager: NSLayoutManager {

    var backgroundCornerRadius: CGFloat = 0

    override func fillBackgroundRectArray(_ rectArray: UnsafePointer<CGRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: UIColor) {
        guard let context = UIGraphicsGetCurrentContext() else {
            super.fillBackgroundRectArray(rectArray, count: rectCount, forCharacterRange: charRange, color: color)
            return
        }
        context.saveGState()
        defer { context.restoreGState() }
        context.setFillColor(color.cgColor)

        let cornerRadii = CGSize(width: backgroundCornerRadius, height: backgroundCornerRadius)
        for rectIndex in 0..<rectCount {
            let rect = rectArray.advanced(by: rectIndex).pointee

            let corners: UIRectCorner
            if rectCount == 1 {
                corners = .allCorners
            } else if rectIndex == 0 {
                corners = [.topLeft, .bottomLeft]
            } else if rectIndex == rectCount - 1 {
                corners = [.topRight, .bottomRight]
            } else {
                corners = []
            }

            let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: cornerRadii)
            context.addPath(path.cgPath)
            context.fillPath()
        }
    }
}

スクリーンショット 2021-01-09 21.51.34.png

いい感じになりました :tada:

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

flutter podfile関連 環境構築メモ

cache消しとく

sudo rm -rf cache

そしたらいつも通りする

flutter pub upgrade
flutter pub get
flutter run

きちんとpod installする

出てくるエラー

Error output from CocoaPods:
↳

[!] Automatically assigning platform `iOS` with version `8.0` on target `Runner` because no platform was specified. Please specify a platform for this target in your Podfile. See `https://guides.cocoapods.org/syntax/podfile.html#platform`.

Error: CocoaPods's specs repository is too out-of-date to satisfy dependencies.
To update the CocoaPods specs, run:
  pod repo update

対処①

1.Go to /ios folder inside your Project.
2.Delete Podfile.lock
3.Run pod install --repo-update
4.Run flutter clean
5.Once complete, rebuild your Flutter application: flutter run

https://stackoverflow.com/questions/64443888/flutter-cocoapodss-specs-repository-is-too-out-of-date-to-satisfy-dependencies

出てくるエラー

[!] CocoaPods could not find compatible versions for pod "sqflite":
  In Podfile:
    sqflite (from `.symlinks/plugins/sqflite/ios`)

Specs satisfying the `sqflite (from `.symlinks/plugins/sqflite/ios`)` dependency were found, but they required a higher minimum deployment target.

[!] Automatically assigning platform `iOS` with version `8.0` on target `Runner` because no platform was specified. Please specify a platform for this target in your Podfile. See `https://guides.cocoapods.org/syn

対処②

platform : ios, '13.0'にしてpod install 実行

https://github.com/fluttercommunity/flutter_workmanager/issues/106

podfile
# Uncomment this line to define a global platform for your project
platform :ios, '13.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

対処③

ios>Flutter>Flutter.framework>Release.xcconfigで以下の2行を追加

#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"
Release.xcconfig
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"

https://github.com/flutter/flutter/issues/66222

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

iOSアプリで位置情報を取得するときに配慮する点をまとめてみた②

前回の続き↓
iOSアプリで位置情報を取得するときに配慮する点をまとめてみた①

はじめに

前回の記事を読んでない方に、簡単に説明するとiOSアプリで位置情報を取得するときに
配慮する点をまとめた内容となっています。

前回までのあらすじ

前回、端末の位置情報サービスの有無のチェックは
アプリをインストール後の初回起動のみしかされないと書きました。

初回起動時に位置情報サービスがオフになっていたらこんなアラートを表示します↓

このチェックは初回起動のみなので、2回目以降の起動時はアラートを表示するように改修しましたね↓

今回は、バックグラウンド状態からフォアグラウンド状態にアプリを切り替えた時にも
端末の位置情報サービスの有無をチェックするようにしていきましょう。

バックグラウンド状態から戻った時にチェックする(端末)

アプリが、バックグラウンド状態からフォアグラウンド状態に切り替わった時に
端末の位置情報サービスの有無をチェックするように処理するには

AppDelegateapplicationWillEnterForeground(_ application: UIApplication)にて行います。

AppDelegateの呼ばれる順番は、こちらの記事が非常に分かりやすかったので載せておきます↓
iOSアプリのライフサイクル

AppDelegate.swift
// アプリがフォアグラウンド状態に入ろうとしている時に呼ばれるメソッド
func applicationWillEnterForeground(_ application: UIApplication)

ですが、アラートを表示したいのでViewControllerのクラス内で処理を行いたいですね。

アプリの状態が変わったことをAppDelegate以外で感知して
ある処理を行いたい場合は、NotificationCenterを使用します。

※こちらの記事を参考にして実装しました↓
[Swift3.0] NotificationCenter を使ってアプリの状態に応じた処理を行う

ViewController
// ビューが表示される直前に呼ばれるメソッド
// 初回表示以外にもバックグラウンド復帰、タブ切り替えなどにも呼ばれる
// まだビューが表示されていないため、計算コストの高い処理は避ける
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

@objc private func willEnterForeground() {
    // バックグラウンド状態から戻ってきた時に端末の位置情報サービスがオフの場合
    if !CLLocationManager.locationServicesEnabled() {
         // アラート表示
         Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
   }
}

ビルドしてみると、バックグラウンド状態から戻った時にもアラートが表示されるようになりました↓
ezgif.com-gif-maker.gif
これで、端末の位置情報サービスの有無を起動時バックグラウンド状態から戻った時
チェックするようになりましたね。

後、チェックしなければいけないのはアプリの位置情報サービスの有無です。

アプリの位置情報サービスの有無をチェックする

位置情報を取得する際には、ユーザーに許可を貰わなければ取得出来ません。
なので、アプリはこのようなアラートを表示してユーザーに許可をリクエストします↓

ですが、もし、ユーザーが許可しないを選択したらどうでしょうか?
位置情報が必要なアプリの場合、許可を貰わなければ困りますよね。

なのでユーザーが、許可しないを選択した場合にアラートなどを表示したりして
アプリの位置情報取得の許可を促さなければいけません。

前回のソースコードを例に説明します。
※ちょっと長いので一部抜粋

今回も、アラートの表示はこちらの方法で表示します↓
UIAlertControllerをファイルを分けて実装してみる

ViewController
import UIKit
import CoreLocation

override func viewDidLoad() {
   super.viewDidLoad()
   locationManager.requestWhenInUseAuthorization()
   locationManager.delegate = self
  }

extension ViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {

        let status = manager.authorizationStatus

        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            manager.startUpdatingLocation()

        case .notDetermined:
            manager.requestWhenInUseAuthorization()

        // 許可しない場合
        case .denied:
            // アラートを表示して、アプリの位置情報サービスをオンにするように促す
            Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "")

        case .restricted:
            break

        default:
            break
        }
    }
}

許可しないを選択した場合、アラートがちゃんと表示されました。

これでも、ちゃんとユーザーに対してアプリの位置情報取得の許可を促していますが
ここで、もう少し配慮してアラートのOKを選択したら設定アプリに画面遷移するようにしましょう。

設定アプリに画面遷移する

設定アプリに画面遷移するメリットとしてアプリの位置情報取得の許可がスムーズになり
ユーザーの手間が省かれることです。

画面遷移する方法は、こちらの記事が分かりやすかったので載せておきます↓
Swift5を使ってURLスキームで設定画面に遷移する方法

この方法を用いて、先ほどのソースコードに追加していきます。

ViewController
import UIKit
import CoreLocation

override func viewDidLoad() {
   super.viewDidLoad()
   locationManager.requestWhenInUseAuthorization()
   locationManager.delegate = self
  }

extension ViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {

        let status = manager.authorizationStatus

        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            manager.startUpdatingLocation()

        case .notDetermined:
            manager.requestWhenInUseAuthorization()

        // 許可しない場合
        case .denied:
            // アラートを表示して、アプリの位置情報サービスをオンにするように促す
            // ユーザーに対して分かりやすようにmessageで、OKを選択すると設定アプリに画面遷移することを伝える
            Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
               // OKを選択した後、設定アプリに画面遷移する
               UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
            }

        case .restricted:
            break

        default:
            break
        }
    }
}

こちらをビルドしてみると、ちゃんと設定アプリに画面遷移しました。
ですが、ここでも落とし穴があります。
ezgif.com-gif-maker2.gif

この方法で、設定アプリに移動したのはいいけど
アプリの位置情報取得の許可をしないまま戻ったとしましょう。

このままだと、アプリは何もチェックしません。

この場面でも、アプリがバックグラウンド状態から戻った時に
アプリの位置情報取得の有無をチェックしなければいけません。

またまた記事が長くなりそうなので、今回はここら辺で終わります。
ここ間違っているよー!というのがありましたら、気軽にコメントして下さい。

最後まで読んで下さって、ありがとうございます!
ここまでのソースコードを下に載せておきます↓

ソースコード

ViewController
import UIKit
import CoreLocation

class ViewController: UIViewController {

    var locationManager: CLLocationManager = {
       var locationManager = CLLocationManager()
       locationManager.distanceFilter = 5
       locationManager.distanceFilter = 5
       return locationManager
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        locationManager.requestWhenInUseAuthorization()
        locationManager.delegate = self
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
    }

    @objc private func willEnterForeground() {
        if !CLLocationManager.locationServicesEnabled() {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if !CLLocationManager.locationServicesEnabled() {
            Alert.okAlert(vc: self, title: "位置情報サービスを\nオンにして下さい", message: "「設定」アプリ ⇒「プライバシー」⇒「位置情報サービス」からオンにできます")
        }
    }
}


extension ViewController: CLLocationManagerDelegate {
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {

        let status = manager.authorizationStatus

        switch status {
        case .authorizedAlways, .authorizedWhenInUse:
            manager.startUpdatingLocation()

        case .notDetermined:
            manager.requestWhenInUseAuthorization()

        case .denied:
            Alert.okAlert(vc: self, title: "アプリの位置情報サービスを\nオンにして下さい", message: "OKをタップすると設定アプリに移動します") { (_) in
                UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
            }

        case .restricted:
            break           

        default:
            break
        }
    }
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        guard let gps = manager.location?.coordinate else {
            return
        }
        manager.stopUpdatingLocation()
        let lat = gps.latitude
        let lng = gps.longitude
        print("経度:\(String(describing: lat)), 緯度:\(String(describing: lng))")
    }
}
Alert
import UIKit

final class Alert {
    static func okAlert(vc: UIViewController,title: String, message: String, handler: ((UIAlertAction) -> Void)? = nil) {
        let okAlertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        okAlertVC.addAction(UIAlertAction(title: "OK", style: .default, handler: handler))
        vc.present(okAlertVC, animated: true, completion: nil)
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutter】 無駄なリビルドを防ぐたった1つの方法

Flutter では StatelessWidget や StatefulWidget(以下、Widget) の build() が頻繁に呼ばれる(リビルドされる)ことを、 【Flutter】build() でやってはいけない 3 つのこと では説明しました。

build() が頻繁に呼ばれること自体は Flutter フレームワークとして想定通りの動作ですので、ここに「変な」処理を書かない限り リビルドが原因でパフォーマンスが低下するようなことは通常ありませんが、アプリの規模が大きくなり Widget の入れ子(以下「Widget ツリー」と呼びます)が深くなってくると少しずつ「リビルド範囲が大きすぎる」ためにパフォーマンスが落ちてくる場合があるのも事実です。

この記事では、 UI の変化が必要ない Widget の無駄なリビルドを防いでパフォーマンスを改善する にはどうすれば良いのかを理解するために、 Flutter フレームワークがリビルドを行わない条件を確認しつつ、その条件を満たすための実装について考えていきたいと思います。

インスタンスが変わらなければリビルドは発生しない

まず Flutter フレームワークが Widget をリビルドしない条件ですが、それは リビルド対象の Widget のインスタンスがリビルド前後で同一である 場合です。

通常以下のように SomeWidgetbuild() の中で OtherWidget を生成している場合、

class SomeWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return OtherWidget();
  }
}

_SomeWidgetStatebuild() が呼び出されると OtherWidgetbuild() もそれに続けて呼び出されます。 もし OtherWidgetbuild() の中でさらに別の Widget が生成されている場合はその Widget の build() も呼び出され、以下同様に Widget ツリーの末端に到達するまで build() が連続で呼ばれます。これを リビルドの伝播 と呼んだりします。

何も考えずに上記のようにコーディングすると、 OtherWidget のインスタンスは _SomeWidgetStatebuild() が呼び出されるたびに新しく生成されるため、前回の build() で生成したインスタンスとは別のものになってしまいます。

しかし、ここにひと工夫入れて 何度 build() が呼ばれたとしても同じ OtherWidget インスタンスを使い回す ことで、 Flutter フレームワークに対してそのインスタンスをリビルドする必要がない、と判断させることができます。1

Widget のインスタンスを使い回すための 3 つのテクニック

では、インスタンスが変わらなければリビルドが発生しないことを、以下のサンプルアプリで確かめていきたいと思います。

count_app.gif

このアプリでは、 + アイコンをタップすると横の数字がカウントアップします。その上には固定の文言が表示されています。

ソースコードは以下のようになっています。(レイアウトを整えるためのコードは省略しています)

labeled_counter.dart
/// カウントアップでリビルドが発生する StatefulWidget
class LabeledCounter extends StatefulWidget {
  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SomeFixedWidget(), // _LabeledCounterState の build() が呼ばれてもリビルドさせないようにしたい
        Row(
          children: [
            Text('$_counter', style: TextStyle(fontSize: 32)),
            IconButton(
              icon: Icon(Icons.add),
              onPressed: () => setState(() {
                _counter++; 
              }),
            ),
          ],
        ),
      ],
    );
  }
}

/// リビルドを発生させたくない Widget
class SomeFixedWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('I don\'t want to rebuild this widget.');
  }
}

通常このように記述すると、先ほど説明した通り _LabeledCounterStatebuild() が呼び出されたタイミングで SomeFixedWidget のインスタンスも新しく生成され、 SomeFixedWidgetbuild() が続けて呼び出されてしまいます。

しかし今回のアプリでは、 SomeFixedWidget の内容は状態(_counter の値)によって変化しないため、無駄なリビルドが発生しないように修正していきたいと思います。

SomeFixedWidget のインスタンスが _LabeledCounterState のリビルドごとに変わらなければ SomeFixedWidget のリビルドが発生しないのは先述した通りですが、ここからはそれを実現するための具体的な方法として以下の 3 つを見ていきたいと思います。

const を使う

まず一番取り入れやすいのが const をつけてインスタンスを生成する方法です。

const は Dart の文法の1つで、 コンパイル時に 1 つだけインスタンスを生成し、何度同じ処理が実行されても 1 つのインスタンスを使い回す という仕組みです。

つまり、 const を付けなかった場合、サンプルアプリの SomeFixedWidget() はこの処理が呼び出されるたびに毎回違うインスタンスを生成しますが、 const をつけた場合、 const SomeFixedWidget() は何度この処理が呼び出されたとしてもコンパイル時に生成された SomeFixedWidget インスタンスを再利用します2

const をつけるためには、インスタンスを生成される SomeFixedWidget クラスに const コンストラクタを用意しておく必要があります。

some_fixed_widget.dart
/// リビルドを発生させたくない Widget
class SomeFixedWidget extends StatelessWidget {
  // const コンストラクタを用意
  const SomeFixedWidget();

  ..以下略..

const コンストラクタが用意できたら、あとは SomeFixedWidget のインスタンスを生成するコードに const をつけるだけです。 先ほどのサンプルアプリのコードは以下のように修正できます。

/// const を使ってリビルドを回避するパターンのサンプル
class UseConstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('const を使う例')),
      body: LabeledCounter(),
    );
  }
}

/// リビルドを発生させたくない Widget
class SomeFixedWidget extends StatelessWidget {
  // const コンストラクタを用意する
  const SomeFixedWidget();

  @override
  Widget build(BuildContext context) {
    return Text('I don\'t want to rebuild this widget.');
  }
}

/// カウントアップでリビルドが発生する StatefulWidget
class LabeledCounter extends StatefulWidget {
  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const SomeFixedWidget(), // const をつけてインスタンスを生成する
        Row(
          ..省略..
        ),
      ],
    );
  }
}

この対策は TextPadding など、 Flutter が用意する Widget でも、その Widget に const コンストラクタが定義されている限り同様に使えます。

Lint でコードをチェックすると「const がついていない」旨の警告が出る場合がありますが、それはこの例のように const コンストラクタを利用するだけで無駄なリビルドを防げる ためです。(単純にインスタンスの生成コストを抑えるのが理由の場合もあります)

State にキャッシュする

別の方法として、 生成した Widget インスタンスを State にキャッシュする という方法も考えられます。

これは StatefulWidget のドキュメントにも記載されているパフォーマンス改善の工夫で、以下のように記載されています。

If a subtree does not change, cache the widget that represents that subtree and re-use it each time it can be used. It is massively more efficient for a widget to be re-used than for a new (but identically-configured) widget to be created.
訳) サブツリーが変化しない場合、そのサブツリーを表す Widget をキャッシュして使い回してください。 Widget を使い回すのは新しい(でも内容は同一の) Widget を再生成するよりも効率的です。

https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html

やり方は単純で、 State クラスのフィールドに Widget を保持する変数を宣言するのと同時にインスタンスを生成してしまうだけです。

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  // State のフィールドで Widget のインスタンスを生成しておく
  final _widgetCache = SomeFixedWidget();

  ..以下略..

ポイントは State のフィールド でこれをやるということです。何度も説明している通り、 Widget は Flutter フレームワークによって何度もリビルドされ、インスタンスが再生成されます。つまり、 Widget のフィールドにキャッシュを置いたとしても、その Widget 自体が破棄&再生成がされてしまうため、キャッシュの意味がなくなってしまうのです。

一方で State は Widget ツリーの構造に変化がない限り破棄されることはありませんので、 State にキャッシュしておくことでより長い間同じインスタンスを使い回せる、というわけです。これは StatelessWidget にはできない、 StatefulWidget だからこそ可能な工夫といえます。

この工夫を取り入れたサンプルアプリのコードは以下のようになります。

use_cache_page.dart
/// State でのキャッシュでリビルドを回避するパターンのサンプル
class UseCachePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('State でキャッシュする例')),
      body: LabeledCounter(),
    );
  }
}

/// カウントアップでリビルドが発生する [StatefulWidget]
class LabeledCounter extends StatefulWidget {
  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  // State のフィールドで Widget のインスタンスを生成しておく
  final _widgetCache = SomeFixedWidget();

  @override
  Widget build(BuildContext context) {
    // レイアウト関連の記述は省略しています
    return Column(
      children: [
        _widgetCache, // _widgetCache を使い回す
        Row(
          ..省略..
        ),
      ],
    );
  }
}

もちろん、 const コンストラクタが使える場合は const を優先して使った方が、たとえ State が破棄されたとしても同じインスタンスが使いまわされるため効率的です。記述も少なく不具合も起こりづらいです。

何らかの理由で const が使えない場合(たとえば状況に応じてキャッシュする Widget の引数を変えたい場合など)はこの方法を検討してみてください。

外から渡す

3 つめに説明するのが、「外から渡す」という方法です。今まではサンプルアプリの LabeledCounter 内で完結する方法でしたが、この方法では LabeledCounter を使う クラスにも手を入れます。

つまり、 LabeledCounter クラスのコンストラクタに「使いまわしたい Widget」を受け取るための引数を用意し、それをフィールドに保持して使い回す、という方法です。

この方法が他の 2 つと違う点は、 LabeledCounter を使う側が自由に固定表示部分の Widget を指定できる、という点です。

例えば今回のサンプルアプリの場合、 LabeledCounter クラスを以下のように修正します。

class LabeledCounter extends StatefulWidget {
  // リビルドさせたくない Widget を外から受け取る
  final Widget label;

  const LabeledCounter({Key key, this.label}) : super(key: key);

  ..以下略..

そして、この LabeledCounter クラスを使う側で、以下のように SomeFixedWidget を生成して引数に渡します。

/// LabeledCounter を利用する Widget
class UseInjectionPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('外からインスタンスを渡す例')),
      body: LabeledCounter(
        label: SomeFixedWidget(), // ここで SomeFixedWidget インスタンスを生成して渡す
      ),
    );
  }
}

こうすることで、 LabeledCounter を利用する UseInjectionPage がリビルドされない限りは(つまり + ボタンがタップされただけの場合は) SomeFixedWidget インスタンスが再生成されることはないため、何度 _LabeledCounterStatebuild() が呼ばれたとしてもインスタンスが変わることはなく、 SomeFixedWidget のリビルドも発生しない、というわけです。

この方法を取り入れると、サンプルアプリは以下のように修正できます。

use_injection_page.dart
/// 外から Widget のインスタンスを渡してリビルドを回避するパターンのサンプル
class UseInjectionPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('外からインスタンスを渡す例')),
      body: LabeledCounter(
        label: SomeFixedWidget(), // ここで [SomeFixedWidget] インスタンスを生成して渡す
      ),
    );
  }
}

/// カウントアップでリビルドが発生する [StatefulWidget]
class LabeledCounter extends StatefulWidget {
  // リビルドさせたくない Widget を外から受け取る
  final Widget label;

  const LabeledCounter({Key key, this.label}) : super(key: key);

  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        widget.label, // 外から受け取った Widget を使い回す
        Row(
          ..省略..
        ),
      ],
    );
  }
}

まとめ

リビルドが発生した(つまり build() が呼ばれた) Widget の中に StatelessWidget / StatefulWidget があるとき、それらも続けてリビルドされるかどうかは インスタンスが同一かどうか で決定します。

そのため、親の Widget の状態の変化に応じて自身の UI を変化させる必要がないのであれば、この記事で説明したような方法を使って同一のインスタンスを使い回すことで、無駄なリビルドを防ぐことが可能です。

とはいえ、無駄なリビルドを防ぐためにこれらの工夫を「入れなければならない」かどうかは状況次第です。 Flutter はビルドを大量に行ってもパフォーマンスが落ちないように工夫されているフレームワークですので、わざわざこれらの工夫を入れて(記述を増やして)リビルド範囲を狭めようと努力するのは、アプリの規模が大きくなり Widget ツリーが深くなってリビルドごとに画面の更新がカクつくようになった時に検討する程度で良いと思います。(1つ目の const をつける方法はほぼデメリットなくさっと行えるので習慣化すると良いと思いますが)

今回のサンプルアプリは以下のリポジトリに PUSH してあります。

chooyan-eng/prevent_rebuild_sample | GitHub

Flutter 自体のソースコードが読めるようになるとさらに詳しくこのあたりの仕組みがわかるかと思いますが、まずはとりあえずできることとして今回紹介したサンプルプロジェクトを上記の GitHub から落とすなり自分で書いてみるなりして実行し、手元でブレークポイントを張りながら動作確認してみると良いでしょう。この記事には書ききれなかった発見があると思います。


  1. なぜそのような挙動になるのか、興味のある方はまず Element について理解した上で、 Flutter のソースコードの Element.updateChildren あたり の処理を追ってみると良いでしょう。 

  2. 当然、引数を変えてインスタンスを生成するようなことはできません。詳しくは Language tour | Dart を確認してください。 

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