くま's Tech系Blog

基本的には技術で学んだことを書き留めようと思います。雑談もやるかもね!

Xcodeで例外発生箇所を特定する

今回はXcodeで例外発生箇所を特定する方法についてです。(小ネタですが最後まで見てね)

クラッシュしたときに原因となった場所が表示される場合もあれば、次のようにわかりづらい箇所が表示されたり、AppDelegateで止まったりすることがあります

そんなときにクラッシュの原因はどこなのかを突き止めるためにException Breakpointを使います

まずは次のようにBreakpointアイコンのボタンをクリックして、Breakpoint Navigatorを開きます。そして、Exception Breakpointを選択します

すると、Breakpointを設定するツールチップのようなものが表示されます

ExceptionはAll、Objective-CC++を選択できます。今回はデフォルトのAllを設定しています

このようにBreakpointを設定した状態で、アプリを実行すると次のようにクラッシュした場所で止まります

また、Actionにpo $arg1を設定することでクラッシュの原因のログが表示されます

このようにクラッシュ箇所を探すのに時間をかけず、クラッシュ解決のために多くの時間を使えるようにしましょう!!

参照

dev.classmethod.jp

PencilKitを使ってみる

今回はPencilKitについてです

PencilKitとはApple Pencilやユーザーの指からの入力を受け取り、iPadOS、iOSmacOSで表示する画像に変換するiOSアプリケーション用の描画できるSDKです

PencilKitには、線を作成したり、消したり、選択したりするツールが付属しています

簡単な実装で実現できるのでどのように実現させるのかみていきましょう

レイアウト作成

まずはレイアウトを作成します。StoryBoardは次のようにしています

PKCanvasViewを使用して入力をキャプチャします。このオブジェクトは、Apple Pencilや指によるタッチをキャプチャすることができます。

Viewのクラス名をPKCanvasViewに設定するとPKCanvasViewとして認識されます。 また、検索するとPKCanvasViewをStoryBoardで設定せずに、ソースコードで直接PKCanvasViewを生成している記事が多いですが、それでも大丈夫です

imageViewを配置しているのは画像の上で入力できるようにするためです

キャンバスとパレットの設定

レイアウトを作成したら入力できるように設定します

PKCanvasViewはレイアウトの設定を行ったので、入力エリアは設定できています。 あとは、PKToolPickerという色やペンの種類を変更するためのパレットを表示させる設定を行います

class PencilKitViewController: UIViewController {
    @IBOutlet private weak var pencilKit: PKCanvasView!
    
    private let pkToolPicker = PKToolPicker()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // CanvasViewで入力ができるようにする       
        pencilKit.tool = PKInkingTool(.pen, color: .black, width: 30)
        if #available(iOS 14.0, *) {
            pencilKit.drawingPolicy = .anyInput
        }
        
        // CanvasViewの下の画像が表示できるようにCanvasViewを透明にする
        pencilKit.isOpaque = true
        pencilKit.backgroundColor = .clear

        pencilKit.becomeFirstResponder()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        if let pencilKit = pencilKit {
            pkToolPicker.setVisible(true, forFirstResponder: pencilKit)
            pkToolPicker.addObserver(pencilKit)
        }
    }
}

pencilKit.drawingPolicy = .anyInputApple Pencilだけでなく手でも入力できるようにしています。drawingPolicyはiOS14以降で設定できるため注意してください

基本的には上記ソースコードにコメントを記載しています。ただ、「CanvasViewの下の画像が表示できるようにCanvasViewを透明にする」というのは画像が下にあって入力できるようにするために必要なので、 お絵描きをするためだけであればなくてもいい処理になります

画像の上にCanvasViewを表示させる

また、ツールパレットを表示させる処理はviewDidAppearで行わないと表示されないので注意が必要です

入力したデータを変換する

PKCanvasViewで入力したデータをUIImageに変換することができます

次のように、PKDrawing.imageでUIImageを取得します

let image = pencilKit.drawing.image(from: pencilKit.bounds, scale: 1.0)

UIImage として取得したいPKCanvasViewの範囲をfromに指定して、画像の拡大率をscaleに指定します

上記の方法でUIImageを取得できます

最後に

お絵描きアプリなど作成する場合には湯買うことになると思いますが、短いコードで実現できるので導入しやすいと思います

細かい情報はWWDCなどの映像や公式ドキュメントで確認できるので調べてみるのをおススメします!

参照

developer.apple.com

developer.apple.com

frameとboundsの違いについて

今回はViewの配置やサイズ指定で使うframeboundsについて違いを調べていこうと思います

違いを知らずに表示の不具合を起こす可能性があるので要注意です(まさに自分が体験しました...)

frameについて

公式ドキュメントにあるように親Viewから見た相対的な座標・大きさを返すプロパティです

つまり、親Viewの左上を原点とし、右へx,下へyへ移動した位置であるoriginと、width, heightからなるサイズであるsizeで構成される情報です

実際に見てみましょう。

まずは下記のようなimageViewを表示させる画面があります

そして次のように赤い背景色のViewを追加します。

@IBOutlet private weak var imageView: UIImageView!

override func viewDidLoad() {
       super.viewDidLoad()
        
       let view = UIView()
       view.backgroundColor = .red
       view.frame = imageView.frame
        
       self.view.addSubview(view)
}

追加するViewにimageViewのframeを指定して位置を決めています

このときのframeの情報をログで確認すると(0.0, 74.0, 414.0, 788.0)となっています

右へ0,下へ74(safeArea + 30)へ移動した位置の幅414、高さ788のviewになります

右へ0,下へ74は親Viewの左上を原点としたもので、今回の場合にはViewControllerのViewになります

frameの値は回転すると変わってしまいます

バイスを回転させる場合、ウィンドウは回転せず、ビュー コントローラーのビューが回転します

ウィンドウの視点からは、すべてが実質的にポートレートモードとなります

このため、subViewのframeの値が変わります

次のようにViewを回転させるとframeの値は変わります

@IBOutlet private weak var imageView: UIImageView!

override func viewDidLoad() {
       super.viewDidLoad()
        
       let view = UIView()

       var transRotate = CGAffineTransform()
       let angle = 35 * CGFloat.pi / 180
       transRotate = CGAffineTransform(rotationAngle: CGFloat(angle));
       view.transform = transRotate
       
       view.backgroundColor = .red
       view.frame = imageView.frame
        
       self.view.addSubview(view)
}

回転したときのframeの情報をログで確認すると(-188.55358909013347, 26.52377222547068, 791.1071781802668, 882.9524555490586)となっています。

boundsについて

boundsは自身を左上を原点とし、右へx,下へyへ移動した位置であるoriginと、width, heightからなるサイズであるsizeで構成される情報です。

frameとの違いは、自身を起点としているので、回転してもboundsは変わりません。

次のように設定します

@IBOutlet private weak var imageView: UIImageView!

override func viewDidLoad() {
       super.viewDidLoad()
        
       let view = UIView()
       view.backgroundColor = .red
       view.bounds = imageView.bounds
        
       self.view.addSubview(view)
}

このときのboundsの情報をログで確認すると(0.0, 74.0, 414.0, 788.0)となっています

ここまではframeと同じ結果です。

次に回転させたときを確認します。

@IBOutlet private weak var imageView: UIImageView!

override func viewDidLoad() {
       super.viewDidLoad()
        
       let view = UIView()

       var transRotate = CGAffineTransform()
       let angle = 35 * CGFloat.pi / 180
       transRotate = CGAffineTransform(rotationAngle: CGFloat(angle));
       view.transform = transRotate
       
       view.backgroundColor = .red
       view.bounds = imageView.bounds
        
       self.view.addSubview(view)
}

このときのboundsの情報をログで確認すると(0.0, 74.0, 414.0, 788.0)となっていて、回転させても値は変わりません

結論

ここまでframeとboundsの違いを説明しましたが、結局どちらを使った方がいいのでしょうか?

結果的にorigin(左上の位置)やwidthやheightを使う場合には回転したり、拡大・縮小などで変わってしまう可能性があるので、boundsを使うことを優先的に考えるのがいいと思います

例えば、画像と画像を合成するときに2つの画像の起点を合わせないと変な画像になるなど、想定外のことが発生する可能性は多少なりともあるのでframeを使う場合には気をつけないといけないです

参照

developer.apple.com

developer.apple.com

Swiftのvar、letについての個人的疑問を解消する

今回はSwiftのvar、letについてです。

たまにvar、letを定義したときに思わぬワーニングが表示されることがあり、個人的に疑問に思う場面があったので疑問を解消する記事にしようと思います

基本的なvar、letの解説は割愛しますが、varは変数でletは定数です。なので、letで初期値を定義すると後から値を変更しようとするとエラーが発生します

また、下記クラスを使うことを前提として進めます

class Test {
    var name: String?
    var tall: Int?
}

疑問点①

まずはクラスをインスタンス化して変数を変えるのにクラスをletで定義するべきというワーニングが発生するという点です

少しわかりにくいと思うので、下記にコードを載せます

// letに変えるべきというワーニングが発生!!
var test = Test()
test.name = "テスト"

個人的感覚かもしれませんが、値を変更するのでvarにするのではと思ってしまいます

これは、letが正しいです。なぜなら、初期化中にtestインスタンスへのポインタが割り当てられ、別の値が割り当てられることはなく定数扱いだからです

すべてのクラスインスタンスが実際にヒープ領域でバックアップされたクラスへのポインターなのです

Testクラスのnameが変わっても割り当てられたポインタが変わらないのでテストインスタンスがnullからnullではない値に変わったりする場合には変数扱いになるかもしれないです

疑問点②

2つ目の疑問は下記コードを見てください

let tests = [Test]()

let test = Test()
test.name = "テスト"
 
// varに変更しないとエラーが発生
tests.append(test)

testsは定数だからletでいい気がするのですが、appendする場合にはvarに変更しないとエラーになります

これはドキュメントに記載がありました

If you create an array, a set, or a dictionary, and assign it to a variable, the collection that’s created will be mutable. This means that you can change (or mutate) the collection after it’s created by adding, removing, or changing items in the collection. If you assign an array, a set, or a dictionary to a constant, that collection is immutable, and its size and contents can’t be changed.

どういうことかというとimmutable(不変)の配列やDictionaryなどはサイズや中身を変更できないのでletで定義しているとappendできないことになります。appendする場合にはvarに変更する必要がありそうです

疑問点を調べてみて

まだまだ学ぶことが多くあるなあと感じました。あと、ワーニングやエラーが発生してボタンを押すと勝手にFixする場合があると思います。そのときに自動で修正されて終わりではなく、どういう原因だったのかなど深掘りしたほうが理解が深まりそうです

参照

www.web-dev-qa-db-ja.com

docs.swift.org

RecyclerViewについて

今回はRecyclerViewについてです

Androidでリスト形式のデータを表示させる場合にはListViewRecyclerViewなどがあると思いますが、複雑なリストが作成できたりする点でRecyclerViewを選択することがほとんどだと思います

ただ、当たり前のように使っていて仕組みをあまり理解していない気がしないので調べたことをまとめようと思います

RecyclerViewについて

RecyclerViewはリストを表示させる際に使います。リサイクルという名前のとおり、個々の要素をリサイクルします。アイテムが画面外にスクロールされても、RecyclerViewはビューを破棄せず、画面上にスクロールされた新しいアイテムのビューを再利用します。再利用をすることによってパフォーマンスの大幅な改善がされるのがListViewとの違いです

RecyclerViewでリストを表示させるために必要なもの

今回はRecyclerViewのライブラリ追加は省きますが、前提としてbuild.gradleにRecyclerViewのバージョン等の設定を追加しないと使えないです

RecyclerViewを使用するためにはまず、AdapterViewHolderが必要になります

Adapter

Adapterについて軽く説明します。AdapterはデザインパターンのAdapterパターンを元に作られています

理解しやすくするために例えられるものでコンセントを例にしたものがあります。電気のコンセントは日本と海外では異なるので、日本のコンセントを海外で使うときは変換アダプターを使いプラグの形状を変えて使います。このように家電自体のコンセントを変えずに、別の場所でコンセントを使えるように変換するのがアダプターの役割です

Adapterパターンも変換アダプターと同じように、異なるクラス同士のインターフェースを変更せずに使用するための役割を担います

RecyclerViewのAdapterは、表示するデータをレイアウトファイルのRecyclerViewに表示させるために使います(1行分のデータを1行分のViewに設定して生成するもの)

ViewHolder

ViewHolderは1行分のView(ウィジェット)の参照を保持するものです。Adapterに指示されてセルを作ったり保持したりします

RecyclerViewやAdapterやViewHolderの関連性を簡易的な図にまとめたものは下記です

次に実際にAdapterクラスを見てみましょう

実際のAdapterクラス

下記にAdapterクラスの一例です

class SimpleRecyclerAdapter(private val titleList: List<String>): RecyclerView.Adapter<SimpleRecyclerAdapter.SimpleListViewHolder>() {
    // セルのレイアウトを読み込んでViewHolderと紐付ける (1セルごとに毎回呼び出される)
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SimpleListViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        // セルのレイアウト
        val view = inflater.inflate(R.layout.simple_list, parent, false)
        return SimpleListViewHolder(view)
    }

    // セルの個数の定義
    override fun getItemCount(): Int {
        return titleList.size
    }

     // 取得したセルデータをViewHolderが参照してきたViewにセットする (1セルごとに毎回呼び出される)
     // positionは現在のセルが何番目かを定義しているもの
    override fun onBindViewHolder(holder: SimpleListViewHolder, position: Int) {
        holder.titleTextView.text = titleList[position]
    }

    class SimpleListViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        var titleTextView: TextView = itemView.findViewById(R.id.simpleListTitleTextView)
    }
}

Adapterクラスを作成する場合には必ず親元のRecyclerView.Adapter<自作ViewHolder>を継承させることと次の3つのメソッドをOverrideする必要があります

  • onCreateViewHolder()
  • onBindViewHolder()
  • getItemCount()

それぞれの処理の概要は上記の一例のコードにコメントとして記載したので確認してください

今回はセルのレイアウトにsimpleListTitleTextViewというidのTextViewを設定しています

ListViewとの大きな違いとして、ListViewではgetItem()内でレイアウトファイルからインフレート・データの取得・Viewに格納を行っていますが、RecyclerViewではそれぞれが独立した処理(メソッドやクラス)として定義する必要があります。

これでAdapterクラスを作成できたのですが、RecyclerViewに渡すために紐付けを行うのとレイアウトを調整する必要があります。

LayoutManager

LayoutManagerはセル1つ分のデータのサイズなどを考慮して、レスポンシブにレイアウトを管理するクラスになります

LayoutManagerは標準で下記3パターンのレイアウトを提供しています

  • LinearLayoutManager→リストで表示する
  • GridLayoutManager→それぞれのセルの高さ(幅)がそろった格子状に表示する
  • StaggeredGridLayoutManager→格子状に表示するが、高さ(幅)がそれぞれのセルで異なる

一般的にLinearLayoutManagerを使用することが多いと思います。リストで表示する場合には縦にスクロールするか横にスクロールするかを設定することができます

実際にLayoutManagerの使い方の一例は下記になります

class SimpleRecyclerViewActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_simple_recycler)

        setupUI()
    }

    private fun setupUI() {
        setupAdapter()
    }

    private fun setupAdapter() {
        val titleList = listOf("title1", "title2", "title3", "title4", "title5", "title6", "title7", "title8")
        val adapter = SimpleRecyclerAdapter(titleList)

        //  RecyclerViewとAdapterの紐付けを行う
        simpleRecyclerView.adapter = adapter
        //  RecyclerViewの表示形式の設定
        simpleRecyclerView.layoutManager = LinearLayoutManager(this)

        // 区切り線を追加する場合には必要
        val separate = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        simpleRecyclerView.addItemDecoration(separate)
    }
}

今回はリストの縦スクロールで表示するパターンなのですが、横スクロールをする場合にはval layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)で可能です

上記のsetupAdapterメソッドの処理をActivityやFragmentなどで行うのが一般的です

区切り線を追加する場合にはDividerItemDecorationを追加する必要があります。今回は標準のクラスを使っていますが、背景色を変更する場合にはカスタマイズしたクラスを作成してRecyclerViewと紐づけることができます

ここまで行うとリストが表示されるはずです

複数のAdapterを使う場合

場合によっては複数のAdapterを使う場合があると思います。例えばセルのレイアウトが異なるリストを作成する場合などです

そんなときはConcatAdapterという機能を使えます。ConcatAdapterはRecyclerviewの1.2以降で使える機能なので古いバージョンを使っている方はバージョンをあげて利用できるようにする必要があります

下記のようにConcatAdapterにaddAdapterすることでセルのレイアウトが異なる場合でも簡単に表示できるようになります

class SimpleRecyclerViewActivity: AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_simple_recycler)

        setupUI()
    }

    private fun setupUI() {
        setupAdapter()
    }

    private fun setupAdapter() {
        val titleList = listOf("title1", "title2", "title3", "title4", "title5", "title6", "title7", "title8")
        val headerAdapter = HeaderAdapter(titleList)
        val contentsAdapter = ContentsAdapter()

        val concatAdapter = ConcatAdapter().apply {
            addAdapter(headerAdapter)
            addAdapter(contentsAdapter)
        }

        simpleRecyclerView.adapter = concatAdapter
        simpleRecyclerView.layoutManager = LinearLayoutManager(this)
    }
}

スクロール位置の取得・復元

スクロール位置の取得と復元ができます

// 保持
val state: Parcelable = recyclerView.layoutManager?.onSaveInstanceState()

// 復元
recyclerView.layoutManager?.onRestoreInstanceState(state)

リストの先頭や末尾かどうかを調べる

あまり使うことはないかもしれませんが、リストが先頭や末尾にいるかどうかを判定する方法です。 次で判定することができます

// 先頭かどうか
recyclerView.canScrollVertically(-1).not()

// 末尾かどうか
recyclerView.canScrollVertically(1).not()

主に使うのはonScrolledで判定するケースだと思いますが、大体以下のような実装になるかと思います

recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            if (recyclerView.canScrollVertically(-1).not()) {
                // リストの先頭に来た時の処理
            }
            if (recyclerView.canScrollVertically(1).not()) {
                // リストの末尾に来た時の処理
            }
        }
    })

参照

developer.android.com

qiita.com

developer.android.com

qiita.com

build.gradleとは?

今回はbuild.gradleについてです

build.gradleファイルに息をするかのごとくライブラリを追加したり、設定を追加したりすると思いますが、よくよく考えるとbuild.gradleってなんだろうとふと思いまとめてみることにしました

build.gradleとは?

まず、AndroidアプリケーションのビルドにはGradleが採用されています。Google が Gradle用のAndroid プラグインを提供しており、このプラグインを各Android プロジェクトの Gradleスクリプトから利用することでAndroidモジュールのビルドを行えるようになります。build.graldeは「Gradleスクリプト」にあたるものになります

有名なビルドツールにMavenがありますが、GradleはMavenのようにXMLで設定定義するのではなくGroovyという言語を利用して、ビルド定義ができます

Gradle自身もJavaとGroovyで作成されGradleによりビルドされています

また、JVM上で動作するため、基本的にどのような環境でも動作させることができます

build.gradleの種類

Android Studioのプロジェクトを見るとProjectとModuleの2種類のbuild.gradleがあると思います

それぞれどんな役割のために存在しているのでしょうか?

共通の依存関係とか設定とかをProjectのbuild.gradleに記載して、プロジェクト毎の設定はModuleのbuild.gradleに記載します

プロジェクトごとは例えばモジュールを分割した場合などで、分割したモジュールごとにModuleのbuild.gradleが存在してモジュールごとに設定を追加することになります

下記がProjectのbuild.gradleです。最初のコメントにあるようにサブモジュールやサブプロジェクトでも共通で追加する設定を定義します

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext.kotlin_version = '1.3.71'
    repositories {
        google()
        jcenter()
        
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.6.3'
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

build.gradleで正しく設定するために

最後にbuild.gradleで正しく定義できるようにファイルに記載されている内容を理解しましょう

まずはProjectのbuild.gradleです。先程のテンプレートを再度記載します

// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
    ext.kotlin_version = '1.3.71'
    repositories {
        google()
        jcenter()
        
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.6.3'
        classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

buildscriptブロックではビルド環境の設定を行います。 テンプレートでは、Mavenリポジトリとしてメジャーな JCenterを使用することや、ビルド用にどのようなプラグインが必要であるかを指定しています

また、上記テンプレートでは、Android アプリケーションをビルドするために必要な Android プラグイン (for Gradle) を使用することを示しています

また、kotlinのバージョンの設定やgradleのバージョン設定などプロジェクト全てに共通する設定を定義します(モジュールを分割しても適用される設定になります)

dependenciesではコメントにある通りアプリケーションごとの設定はモジュールごとのbuild.gradleに定義することになります(後述します)

allprojectsブロックを利用すると、すべてのプロジェクト(マルチプロジェクト構成の場合)に対して共通で適用する設定を記述しておくことができます

buildscriptブロックとallprojectsブロックの両方にrepositoryが含まれていますが、buildscriptブロックではgradleスクリプトを実行するために必要な依存関係を追加する、allprojectsブロックではプロジェクトを作成するために必要な依存関係を追加するという違いがあります

そして、task cleanは起動したらbuildフォルダを削除するタスクになります。このタスクは、Gradleの設定ファイルを変更した場合、キャッシュされている設定をきれいにする(リセット)ために行います

次にModuleのbuild.gradleのテンプレートを記載します

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.3"

    defaultConfig {
        applicationId "com.example.test"
        minSdkVersion 28
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }

}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.3.0'
    implementation 'androidx.core:core-ktx:1.5.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'androidx.legacy:legacy-support-v4:1.0.0'
    implementation 'androidx.viewpager2:viewpager2:1.0.0'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.navigation:navigation-fragment:2.3.5'
    implementation 'androidx.navigation:navigation-ui:2.3.5'
    implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
    implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
    implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

androidブロックでtargetSdkVersionやapplicationIdなどアプリの設定を行います。モジュールごとに別々の設定になる可能性もあるのでModuleのbuild.gradleで定義します

dependenciesブロックでは使用するライブラリを定義してインポートできるようにします

apply pluginでは対象アプリのビルドするために必要なプラグインを定義しています。一例を挙げると、kotlin-androidはkotlinで書かれたAndroidのモジュールをビルドするために必要です

このようにbuild.gradleが2種類あるのは設定を適用させる対象が明確に分かれているためなのです

最後に

今回説明したものはほんのさわりの部分で細かいカスタマイズをするためにはさらに設定を追加したりタスクを追加したりする必要があると思います。最後の参照に記法や設定に関するドキュメントのリンクを載せているので参照してください

また、setting.gradleなど他にもファイルがあるため別の記事で説明したいと思います

それにしても奥が深い!!

参照

qiita.com

docs.gradle.org

developer.android.com

Androidでプッシュ通知(FCM)を使う

今回はAndroidでプッシュ通知を使う方法です。プッシュ通知はFCM(Firebase Cloud Messaging)を使います。

FCMの設定に関しては今回は割愛しますが大まかにいうとFirebaseのプロジェクトを作成してパッケージ名を設定すると、google-service.jsonがダウンロードできるようになっているのでAndroid Studioのプロジェクトに追加します

ここまでを前提としてこれからプッシュ通知を受け取るための実装や受け取った後の実装について記載しようと思います。

SDKの設定

まずはFirebase Cloud Messagingを利用できるようにライブラリの追加を行います。ルートレベルのbuild.gradleに下記設定を追加します

buildscript {
    dependencies {
        classpath 'com.google.gms:google-services:4.3.10'
    }
}

そしてモジュールレベルのbuild.gradleに下記を追加します(Firebaseのバージョンをbomで管理しています)

dependencies {
    implementation platform('com.google.firebase:firebase-bom:28.3.1')
    implementation 'com.google.firebase:firebase-messaging'
}

サービスクラスの作成

次にFirebaseMessagingServiceを継承したサービスを作成します。これは、バックグラウンド時にアプリで通知を受け取る処理よりも高度なメッセージ処理を行う場合に必要になります。 例えば、フォアグラウンド時のアプリで通知を受け取る、プロジェクトとして独自のペイロードを受信するなどの場合はサービスを継承します。

class PushNotificationService : FirebaseMessagingService() {
    override fun onNewToken(p0: String) {
        super.onNewToken(p0)
    }

    override fun onMessageReceived(p0: RemoteMessage) {
        super.onMessageReceived(p0)
    }
}

サービスクラスの処理について説明は後述します。そして作成したクラスをマニフェストファイルに追加します

<application>
        <service
            android:name=".service.PushNotificationService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT"/>
            </intent-filter>
        </service>
</application>

channelの作成

Android 8.0以降、通知はすべてチャネルに割り当てる必要があります。 チャネルごとに、そのチャネルのすべての通知に適用される表示と音声の動作を設定することができます。 チャネルはユーザーがアプリのどの通知チャネルを実際に表示するか、または不要とするかを決めることができます

class PushNotificationService : FirebaseMessagingService() {
    override fun onNewToken(p0: String) {
        super.onNewToken(p0)
    }

    override fun onMessageReceived(p0: RemoteMessage) {
        super.onMessageReceived(p0)

        sendNotification(p0)
    }

    private fun sendNotification(remoteMessage: RemoteMessage) {
        // 受け取った後の遷移先の設定
        val intent = Intent(this, MainActivity::class.java)
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

        // Android12でクラッシュするのでPendingIntent.FLAG_IMMUTABLEを使う
        val pendingIntent = PendingIntent.getActivity(this, requestCode, intent,
            PendingIntent.FLAG_IMMUTABLE)

        // channelIdを設定(変更可能)
        val channelId = getString(R.string.channel_id)
        // チャネル名を設定(変更可能)
        val channelName = getString(R.string.channel_name)
        val notificationBuilder = NotificationCompat.Builder(this, channelId)
            .setSmallIcon(R.drawable.ic_launcher)
            .setColor(resources.getColor(R.color.appTransparent, null))
            .setContentTitle(remoteMessage.notification?.title)
            .setContentText(remoteMessage.notification?.body)
            .setShowWhen(true)
            .setWhen(System.currentTimeMillis())
            .setAutoCancel(true)
            .setDefaults(NotificationCompat.DEFAULT_ALL)
            .setPriority(NotificationCompat.PRIORITY_MAX)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .setContentIntent(pendingIntent)

        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        // Android8以降設定が必要
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
            channel.setShowBadge(true)
            channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
            notificationManager.createNotificationChannel(channel)
        }

        // 通知処理(PendingIntent.getActivityで指定したリクエストコードを通知に設定する)
        notificationManager.notify(requestCode, notificationBuilder.build())
    }

    companion object {
        val requestCode = 0
    }
}

少し補足します。override fun onNewToken(p0: String)ではデバイス登録トークンが登録されたときに呼ばれます。override fun onMessageReceived(p0: RemoteMessage)はプッシュ通知が届いた時に呼ばれます

チャネルの設定については公式ドキュメントに細かく記載されています

intentは遷移先になるので、ペイロードのデータによって遷移先を変えたい場合には適時変更してください

もし、自分で設定した覚えのない「その他」(Miscellaneous)というChannelに割り振られていて、スマホで追加したChannelのOn/Offを切り替えても通知が届かない場合にはAndroidManifest.xmlに以下のようにmeta-dataを追加して自分でChannelIdを指定してください

また、通知でアイコンが表示されない場合にはStackOverFlowに方法が載っていたので参考にしてみてください

<application>
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="@string/channel_name"/>
</application>

ペイロードを受け取る

最後にペイロードを受け取る処理についてです

ペイロードが含まれている時はoverride fun onMessageReceived(p0: RemoteMessage)の引数のp0に含まれています

例えば下記のようなペイロードがあるとします

{
  "to":"deviceid","priority":"high",
  "notification":{
    "title": "通知タイトル",
    "body": "通知メッセージ"
  },
  "data":{
    "title": "title",
    "body": "message",
    "type": "type1"
  }
}

dataのtypeを取得する場合には下記のようにして取得します

  if (remoteMessage.data.size > 0) { 
    for( key in remoteMessage.data.keys ) {
      // typeのデータを取得
      if( key == "type" ) {
      }
    }
  }

プッシュ通知はほとんどのアプリで使う機能だと思うので、参考にしてみてください!!

※補足

ペイロードを受け取る際にアプリがフォアグラウンド状態か起動していないかで受け取るクラスが異なるため注意してください。

フォアグラウンドでプッシュ通知をタップした場合にはonMessageReceived(p0: RemoteMessage)のp0で受け取ります。 プッシュ通知をタップしてアプリを起動したりバックグラウンドの場合にはAndroidManifest.xml<action android:name="android.intent.action.MAIN" />の設定のあるクラスで intent.getStringExtra("")などのintentの処理でペイロードを取得できます。 プッシュ通知をタップしてアプリを起動する場合にはFirebaseMessagingService()を継承したクラスは通らないためご注意ください!

参照

firebase.google.com

developers.goalist.co.jp

qiita.com

trueman-developer.blogspot.com

UIのインスタンス化について

Xcodeには、Interface Builderでコードを書かずにデザインを構築することが多いと思います

Auto Layoutも比較的付けやすいので、使ったことがない人はいないくらい使われると思っています

今回は、Interface Builderソースコードを紐付ける方法をまとめます。(主に初期化などです)

ViewController

まずはViewControllerについてです。

ViewControllerの作り方についてですが、 New File... から新しいViewControllerのクラスを作ります。

次に、利用するStoryboardに新しいViewControllerを置き、 Storyboard IDに適当な文字列を指定します。

また、ソースコードでViewControllerを作った際にはClassに対応するクラスを指定しましょう。

インスタンス化する場合には下記のように行います。

let vc = storyboard?.instantiateViewController(identifier: "( Storyboard IDで指定した名前)") as? ViewController(該当のViewController)

今まで説明したものは1つのStroryBoardで管理する場合の方法ですが、1つのViewControllerを1つのStroryBoardで管理する場合には下記のようの行います。

let sb = UIStoryboard(name: "( Storyboardの名前)", bundle: nil)
let vc = storyboard?.instantiateViewController() as? ViewController(該当のViewController)

UIStoryboardのイニシャライザの引数について、 name には.storyboardファイルの拡張子を除いた部分の文字列を、 bundle には指定した.storyboardファイルとそれに関連するリソースが含まれているbundleを渡します。

上のコードのようにbundleにnilを渡すと、このメソッドはmain bundleを見にいきます。

XibでのカスタムView

ViewControllerをXibで定義するパターンもありますが、ここではカスタムViewをxibで作成するパターンについて説明します

カスタムViewを作成(初期化)する方法は大まかに2種類あるので、それぞれ説明します

①File’s Ownerで設定する場合

下記のようにカスタムViewをXibで作成する場合にFile’s Ownerでクラス指定する場合です。

生成方法は、イニシャライザでxibファイルを読み込み、自身に addSubview() します。

CustomViewクラスを継承したRoot viewの上に通常のUIViewを一枚挟んでから、Interfce Builder上で配置した部品が載ることになるのでパフォーマンスに影響を与える可能性があります(微々たるものではないので影響はほぼなさそう)

class LoginForm: UIView {
    @IBOutlet private weak var loginButton: UIButton!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        loadNib()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        loadNib()
    }
    
    private func loadNib() {
        let view = UINib(nibName: "LoginForm", bundle: nil).instantiate(withOwner: self, options: nil).first as! UIView
        view.frame = self.bounds
        addSubview(view)
    }
}

②Custom Classで設定する場合

File’s Ownerとは別の方法でCustom Classで設定する方法もあります。

Custom Classで設定する場合には、File’s Ownerで設定する場合とは違い、無駄なUIViewを挟む必要はありません。 しかし、コードでinit(frame:)が呼ばれてしまうと、その時点でCustomViewがインスタンス化されてしまうため、xibと結びつけることができません。 コードから呼び出す場合には別途 static関数などを用意し、そちらを利用する必要があります。 仮にload() の処理をrequired init?(coder: NSCoder)で実行した場合には初期化されているのにinstantiateでサイド初期化しようとして無限ループに陥ります。

class LoginForm: UIView {
    @IBOutlet private weak var loginButton: UIButton!
    
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
    }

    // パラメータを渡す場合にもここで引数を設定してViewController側で呼び出す
    static func load() {
        let view = UINib(nibName: "LoginForm", bundle: nil).instantiate(withOwner: nil, options: nil).first as! Self
      
        addSubview(view)
    }
}

File’s OwnerとCustom Classで設定する方法がありますが、File’s Ownerで設定する方がUIViewを扱う時と同じ方法で使えるのでおすすめです。

まとめ

ここまでUIのインスタンス化について記載しました。 どのパターンを使うのかは実装方法やインスタンス化のコードを踏まえ、自分のプロジェクトにあったものを採用するのがいいでしょう。

参照

qiita.com

developer.apple.com

medium.com

shiba1014.medium.com