駆け出しエンジニアぱかぱかの成長記録

引くほど忘れっぽい新卒2年目駆け出しSEぱかぱかの備忘録です。

【AWS SAA】勉強始めてみた

こんにちは!ぱかぱかです!
久しぶりに予定のない休日をエンジョイしています。
予定があるのももちろん嬉しいのですが、やはり自由な週末も大好きです。
部屋の掃除をしたり料理をしたり勉強をしたり、これはこれで充実した時間を過ごしております。

今日は表題にもある通りAWS SAAの取得に向けた勉強を開始したためそのことについて書いておきます。

AWSを学ぶモチベ

2023年の大晦日に書いた記事で2024年は資格を2つくらい取りたいなという話を書きました。
radish-se.hatenablog.com

ここで挙げていたのが以下です。
AWS資格
IPA高度資格

IPAは昨年の秋に応用情報を受けたばかりで一旦距離を置きたいなと言う感じ。笑
ひとまず味変でAWSから手を付けることに。

AWSは今の業務でほとんど触れておらず、以下の記事でUdemyをベースにちまちま触った程度。
個人のアプリ開発にも活用したいのと、今やクラウドは常識という時代なので教養のために勉強することにしました。

radish-se.hatenablog.com

AWS SAAとは

AWSの資格の資格体系は以下のように4つのカテゴリーがあります。
レベルとしては上に行くほど高度です。

・Specialty(専門)
・Professional(プロフェッショナル)
・Associate(アソシエイト)
・Foundational(基礎)

詳しくは公式のサイトを見ていただくが早いかと思います。
aws.amazon.com

AWS SAAは正式には「AWS Certified Solutions Architect - Associate」のことで、カテゴリーとしてはAssociateの資格になります。
Foundationalには「AWS Certified Cloud Practitioner」という資格がありますが、難易度的はその次に位置づけられるのがAWS SAAのようです。
レベル的にちょうど良さそうというのと、先輩で受けていた方がいたのもありこの資格を目指してみることにしました。

学習方法

学習は以下のUdemy講座で進めています。

www.udemy.com

まだセクションが19あるうちのまだ6つ目まで(概要・IAM・VPC・EC2など)しか進んでいませんが、丁寧な概要の説明と実際に手を動かすハンズオンがあり非常にわかりやすいです。

目標

5月のGWに海外旅行に行く予定があるのですが、旅行はスッキリとした気持ちで楽しみたい!
ということで4月末まで合格することが目標です!
業務もなかなか忙しく両立が不安なのですが、それはいつものことなので頑張っていきたいと思います。

ちょっと勉強してみての感想

AWSの知識がついていくというのももちろんなのですが、応用情報で学んだ基礎知識の復習にもなっていい感じです。
クラウド、ネットワーク、セキュリテイ、冗長化など学んだことを今度は実際に使っていくことでより必要性を実感できています。
復習がてらまとめて記事にしていきたいですね。

懸念

また資格取得によって自主アプリ開発が阻害されてしまう…というのは懸念点です。
息抜きがてら開発したいところですが、開発の方が時間の消費量がエグい&未知数なのでなかなか難しい…
時間ってほんと、いくらあっても足りないですよね…
学んだAWSの知識をアプリ開発にも活かしたいというのもモチベのうちなので、受験までは資格取得の優先度を上げて取り組みたいと思います!

ではまた!

【日記】オードリーのオールナイトニッポン in 東京ドームのライブビューイング観て来た

こんにちは!
最近記事の下書きばかりが溜まり、結局アップしないまま休日が過ぎ去ってしまうというのを繰り返しております…
1月2月と週末はほとんど予定が入っており充実していましたが、ブログを書くところまでいつもたどり着けない…
肩肘張りすぎずに簡単な日記でも残していきたいですね。

とりあえず今日は2月18日に行ったオードリーのオールナイトニッポン in 東京ドームのライブビューイングについて簡単に残しておきます!

event.1242.com

なんで行った

自分はリトルトゥースと言うほどではなく、時々気が向いたら聞く程度のにわかリスナーです。
ちょうどこの日はお友達と遊んでいたのですが、新しくできた映画館に行ってみようということで上演を漁っていたらこれを発見!
勢いで飛び込んでみた感じです。
大きなスクリーンを2つ使って上映していたのですが、どちらもほぼ満席!
飛び込みで入れてラッキーでした。

ラジオのリスナーって目に見えづらい

ラジオってテレビやYoutubeよりも視聴者が可視化されづらい気がします。
テレビだったらよく話題にも上がりますし、Youtubeは再生数や登録者数が目に見えるので人気が一目でわかる。
でも、ラジオって各々がそれぞれのタイミングで聞いているみたいな事が多い。
1人で聞くことが多いので、自分とラジオ一対一の世界になっちゃいますよね。
だからこそ、満員の東京ドームや映画館を見た時の「こんなに仲間がいるんだ」っていう一体感がなんか新鮮でした。
リスナーにしかわからない内輪ネタみたいなもので公共の場で一緒に笑い合うっていう経験はなかなか貴重なのではないでしょうか。

先日社員研修を受けまして、「信頼を生むためにコミュニケーションは重要」という話がありました。
これだけ聞くとそりゃそうだ!って感じですが笑
コミュニケーションは希少性の高い内容であるほど信頼度合いが高くなるそうです。
ラジオに限らずイベントってそういうものだと思いますが、内輪ネタをみんなで楽しめる喜びって大きいですよね。
内輪ネタをもっとわかりたくて、またちゃんとラジオ聞こうって思いました笑

芸人ってすごい

芸人に限らずですが、ラジオ番組を持っている人って本当にすごい。
何がすごいって、毎週必ず滑らないトークを準備できるところ。
これは普通に生きてたらできないよなって思ってしまいます。

これをするためには
・まず日々色々な経験をする
・経験から気づきを得る
・それを面白く語る
みたいな力が必要ですよね。

また研修に繋がるのですが、確度の高い行動をするためには仮説力が必要で、そのベースとして知識量も大切だよという話がありました。
深く狭い知識だけでなく、広く浅い知識も重要になります。
知識は本やらネットやら色々ところから得られると思いますが、一番手っ取り早いのは百聞は一見にしかず、経験をすることのような。

芸人さんって普段から話題作りのためもあるのでしょうが、色々な経験をしています。
今回のトークで話していましたが、若林は最近体力づくりの一環でウーバーイーツをやっているらしいです。
相変わらず面白いトークだったのですが、この経験から若林が得た気付きが色々あって、その気付きがあるからこそトークがまた面白くなるんでしょうね…
オードリーではなく一人間W.MASAYASUになった途端にウーバー届けることすらできないとか、目的を持って自転車を漕いだら街がカラフルになったとか笑

最後の面白く語る力については芸人さんに譲りますが、「なるべくたくさんの経験をしてそれを振り返って気づきを得る」っていうのは自分も心がけたいなと思いました。
多分それを人に話すとかブログに書くとか、アウトプットしようと思ったときに気づきの量がさらに増える気がします。
(正直この記事もこんなあれこれ書くつもりは全く無かったのに、書こうとしたら色々出てきた笑)

なんかキモい記事になった

先日研修を受けた内容を記事にまとめたいなと思いつつ、それだけ書いてもつまらない記事にしかならないよなと思い下書きに眠らせていた矢先。
まさかオードリーのイベントが研修内容に繋がるとは思いませんでした。
全くそんなつもりはなかったのですが…笑
すごくためになる研修だったのでこんな感じでちょい出しで振り返りながら、自分の中に落とし込めたらなとは思います。

ではまた!

【iOS】今さらStoryboard開発を改めて学んでみる

こんにちは!ぱかぱかです!
正月休みはテンポよく投稿できていたのですが、仕事が始まった途端に投稿が途絶えてしまいました…
1月は週末にスノボや旅行など色々な予定が入っており、充実した日々を過ごしておりました。
今週末は久しぶりに何も予定がないので書き進めていきたいと思います。

今回はiOSのStoryboard開発についてです。

Storyboard

せっかくMacを買ったのにiOSの開発をまともにやっていなかった私…
だいぶ前ですが、iOS開発について書いた記事が以下です。

radish-se.hatenablog.com
radish-se.hatenablog.com

この本をベースにサラッと触って終了しています…

この時行っていたのはSwiftUIによる開発でした。
SwiftUIは2019年に登場した開発方法で、今はこちらが主流となっています。

一方でStoryboard開発はSwiftUIが登場する以前の開発方法です。
オブジェクトをドラッグ&ドロップで配置してレイアウトを作っていきます。

今はSwiftUIが主流なのになぜ今さらStoryboardを復習しようとしているかというと、業務のアプリでStoryboardを使っているからですね。
画面についてはあまり新規で作成する機会がなかったので、このタイミングで改めて学び直しておこうと思います。

題材

Udemyを見てもSwiftUIの講座ばかりでなかなかStoryboardを扱っているものが見つからず。
色々漁っているとようやくちょっと古めでStoryboardを使っている以下講座に出会いました。

www.udemy.com

今回はこの講座をベースに学習を進めました。

作ったもの

講座の内容に従って簡単なメモアプリを作成しました。
所要時間はなんだかんだ2~3時間?

UITableViewを使ったメモ一覧。
右上の+で新規メモ追加。

スワイプでメモの削除が可能。

左上のアイコンをタップすると、ActionSheetで色を選択しメモのヘッダーの色が変えられる。
設定情報はUserDefaultに保存し、アプリ再起動後も設定内容が反映される。

学び

・Realmを使ったDB管理なんか楽そう

DBについて、業務のアプリではAndroid/iOSともにSQLiteを使っています。
今回はRealmを使ってメモ情報の登録・削除・更新を行ったのですが、なんかDBを意識せずできてしまった感じがあります。

ざっくりの流れは以下のような感じ。

①Realmモデルを定義する
Objectクラスを継承してプロパティに@objc dynamicキーワードを付ける。

class MemoDataModel: Object {
    @objc dynamic var id: String = UUID().uuidString
    @objc dynamic var text: String = ""
    @objc dynamic var recordDate: Date = Date()
}

②Realmをインポート

import RealmSwift

③Realmオブジェクトを取得

let realm = try! Realm()

CRUD操作
数行で直感的にかける。

・Create

val newMemo = MemoDataModel()

try! realm.write {
    realm.add(newMemo)
}

・Read

let result = realm.objects(MemoDataModel.self)

・Update

try! realm.write {
    newMemo.text = newText
}

・Delete

try! realm.write {
            realm.delete(targetMemo)
        }

なんか楽ちんでいいな〜と思いました。
AndroidのRoomでもあくまでカラム名とかを意識してDBいじってます感があったので、差を感じました。
複雑な処理はどうやるのかわかりませんが…

UI部品使いこなせたら便利そう

今回はUITableViewとアクションシートとタブバーのボタンくらいしかいじっていませんが、単純な処理をする分には思いの外扱いやすかったです。
業務でView周りに苦しめられている姿をよく見るので、複雑なことをやろうとすると大変なのだと思いますが、今回はそれを味わうことなく終わったのでまだiOS嫌いにはならずにすみました。

今後

もう少し踏み込んでiOSの開発もしたいのですが、それをSwiftUIでやるかStoryboardでやるかが問題です。
グルメノートもAndroidが完成しきっていないのでなんともですが、ちょこちょこiOSに移植して行きたい所。
たぶん今後のことを考えてもSwiftUIでやっちゃうんだろうな…という気がしています。
Storyboardはやはり業務で慣れていきたいと思います。
今回受講したUdemyももう1つ課題のアプリが残っているので、そちらはStoryboardを使って完成させたいと思います。

ではまた!

【Android】RecyclerViewのセルタップイベントを検知する

こんにちは!ぱかぱかです!
お客様都合で今日もお休みを頂いているのですが、これでついに13連休も幕を閉じます…寂しいですね…
正月休みは小・中・高の友達にそれぞれ会ったり美味しいものを食べたりアプリを作ったり実家の猫と戯れたり、なかなか充実した時間になりました。
仕事が始まってもメリハリを付けていい時間の使い方をしていきたいですね。

今日は現在開発を進めている飲食店記録アプリ「グルメノート」で、RecyclerViewのセルをタップした際のイベントを検知する処理についてまとめていきたいと思います。

前回の記事

radish-se.hatenablog.com

内部ストレージに保存した画像をリスト上で表示できるようになった!

今回のゴール

行った店リスト(RecyclerView)のセルをタップした際にタップイベントを検知できるようにする。
次回以降でシングルタップ時に行った店の詳細画面に飛べるようにしたい。
ロングタップ時でメニュー表示も。

リスナーの実装

RecyclerViewのタップ処理実装方法は調べてみると色々なパターンがありそうでした。

実装方法の候補
・SimpleOnItemTouchListener
→単純なタップやロングタップに対応できる

・ItemTouchHelper
→ドラッグやスワイプに対応できる

・自前でリスナーのインターフェースを作りViewHolderに適用
→単純なタップ等

今回はSimpleOnItemTouchListenerを使ってみたいと思います。
今後はスワイプで削除とかもやりたいのでItemTouchHelperも使いたい…(実装した後に気づいた…)
両方併用することも可能そうなので今度試してみます。

SimpleOnItemTouchListenerの継承

SimpleOnItemTouchListenerというRecyclerViewのインターフェースを継承したリスナークラスを作成し、クリック処理を実装していきます。
RecyclerView.SimpleOnItemTouchListener  |  Android Developers

// SimpleOnItemTouchListenerを継承したリスナークラス
class RecyclerItemClickListener() : RecyclerView.SimpleOnItemTouchListener() {

}

onInterceptTouchEventの実装

onInterceptTouchEventはタップイベントを監視するメソッドです。
RecyclerView.SimpleOnItemTouchListener  |  Android Developers

実装することで、MotionEventを検知した際にRecyclerViewのスクロール動作に割り込んで実装したタップイベントを先に処理できるようになります。

// SimpleOnItemTouchListenerを継承したリスナークラス
class RecyclerItemClickListener() : RecyclerView.SimpleOnItemTouchListener() {

    override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean {
       // タップイベント検知時の処理
    }
}

インターフェースの作成

リスナークラス内に実装するインターフェースを準備します。
今回はシングルタップ時とロングタップ時のメソッドを作ることにします。

// SimpleOnItemTouchListenerを継承したリスナークラス
class RecyclerItemClickListener() : RecyclerView.SimpleOnItemTouchListener() {

    interface OnRecyclerClickListener {
        // シングルタップ用
        fun onItemClick(view: View, position: Int)
        // ロングタップ用
        fun onItemLongClick(view: View, position: Int)
    }

    override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean {
       // タップイベント検知時の処理
    }
}

GestureDetectorでタップイベントの種類を判別

検知したタップイベントをGestureDetectorに渡すことでシングルタップやダブルタップなどを判別できるようにします。
GestureDetector  |  Android Developers

参考までに、GestureDetector.SimpleOnGestureListenerのメソッドには以下のように様々な種類があります。

メソッド名 検知する操作
onContextClick コンテキストクリック(コンテキストメニューを表示するクリック、長押しなど)
onDoubleTap ダブルタップ
onDoubleTapEvent ダブルタップの開始、移動、終了(DOWN, MOVE, UP)
onDown 押下(DOWN)
onFling サッとなぞる
onLongPress ロングタップ
onScroll 押下して画面上を移動
onShowPress 押下して少し待つ
onSingleTapConfirmed シングルタップ
onSingleTapUp 押下して指を離した時(UP)

今回は一旦、シングルタップロングタップを実装したいと思います。
GestureDetectorをインスタンス化し、シングルタップを検知するonSingleTapConfirmedとロングタップを検知するonLongPressを実装します。

class RecyclerItemClickListener(
    context: Context,
    recyclerView: RecyclerView,
    private val listener: OnRecyclerClickListener
) : RecyclerView.SimpleOnItemTouchListener() {

...

// GestureDetectorのインスタンス化
private val gestureDetector =
        GestureDetectorCompat(context, object : GestureDetector.SimpleOnGestureListener() {

            // シングルタップ
            override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
                val childView = recyclerView.findChildViewUnder(e.x, e.y)

                if (childView != null) {
                    listener.onItemClick(
                        childView,
                        recyclerView.getChildAdapterPosition(childView)
                    )
                }
                return true
            }

            // ロングタップ
            override fun onLongPress(e: MotionEvent) {
                val childView = recyclerView.findChildViewUnder(e.x, e.y)

                if (childView != null) {
                    listener.onItemLongClick(
                        childView,
                        recyclerView.getChildAdapterPosition(childView)
                    )
                }
                super.onLongPress(e)
            }
        })
...
}

RecyclerItemClickListenerのコンストラクタ引数にContext, RecyclerView, OnRecyclerClickListenerを追加しています。

各overrideメソッドではクリックされたRecyclerView内の子ビューがある場合はそれを取得し、先ほど作成したOnRecyclerClickListenerインターフェースのメソッドに子ビューとそのポジションを渡します。

返り値がtrueの時、RecyclerViewやその子ビューに定義されているイベントを止めてタップイベントが挿入されます。

ただしonLongPressでは検知した後にRecyclerViewのスクロールができなくなてしまっては困るので、trueを返さないようにしています。

onTouchEventの呼び出し

onInterceptTouchEventの中でGestureDetectorのonTouchEventを呼び出すようにします。

class RecyclerItemClickListener(
    context: Context,
    recyclerView: RecyclerView,
    private val listener: OnRecyclerClickListener
) : RecyclerView.SimpleOnItemTouchListener() {


    interface OnRecyclerClickListener {
...
    }

private val gestureDetector =
        GestureDetectorCompat(context, object : GestureDetector.SimpleOnGestureListener() {
...
        })

    override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
        return gestureDetector.onTouchEvent(e)
    }
}

これでタップイベントが以下の流れで処理されるようになります。

タップイベント発生
onInterceptTouchEventで検知
GestureDetector#onTouchEvent呼び出し
→該当するタップイベントのoverrideメソッド呼び出し
OnRecyclerClickListenerインターフェースのメソッド呼び出し

というわけで最後はOnRecyclerClickListenerインターフェースのメソッドを実装するだけです。

インターフェースの実装

シングルタップ時に行った店の詳細画面に遷移するようにしたいのですが、その実装は次回の記事に回したいと思います。

今回はタップ時にログが出力されることを確かめます。
行った店リストを表示するVisitedRestaurantFragmentでインターフェースRecyclerItemClickListener.OnRecyclerClickListenerを継承し、それぞれログを出力するようメソッドを実装します。

class VisitedRestaurantFragment : Fragment(),
    RecyclerItemClickListener.OnRecyclerClickListener {
...

    // シングルタップ時の処理
    override fun onItemClick(view: View, position: Int) {
        Log.d("VisitedRestaurantFragment", "Single tap position: $position")
    }

    // ロングタップ時の処理
    override fun onItemLongClick(view: View, position: Int) {
        Log.d("VisitedRestaurantFragment", "Long tap position: $position")
    }
}

いざ実践

以下のような状態の行った店リストでアイテムをタップしてみます。

(毎回データを消して入れ直しているので、その時私が食べたい物がバレますね…)

1つ目(position: 0)のモスバーガーをシングルタップ、2つ目(position: 1)の丸亀製麺をロングタップしてみます。
(開発者向けオプションでタップ位置を表示した状態で動画にとってみたのですが、シングルタップ・ロングタップが全然伝わらなさそうなので掲載は諦めました…)

するとログに以下のように出力されました。
タップの種類を検知できていますね。

次回はタップ時に行った店詳細画面に遷移したいと思います!
ではまた!

【Android】内部ストレージに画像を保存しリストで表示する②

こんにちは!ぱかぱかです!
今日は現在開発を進めている飲食店記録アプリ「グルメノート」で、画像を内部ストレージに保存した後にそれを取得してリストに表示する実装についてまとめる記事の2つ目です。
長くなりそうと思って記事を分けたのですが、2つ目の方がだいぶ軽めになりそうです。

前回の記事

radish-se.hatenablog.com

行った店登録画面で登録した画像を内部ストレージに保存し、DBにそのパス情報を残せるようにした。

写真選択ツール
保存する写真を選択する。

内部ストレージ
内部ストレージに画像が保存される。

データベース
DBには保存された画像のファイル名が保存される。


今回のゴール

行った店リストでDBのパス情報を元に内部ストレージから画像を取得して表示する。

RecyclerView内で保存した画像を表示する

行った店登録画面で登録した情報は以下のように行った店登録リストに表示される想定です。
今までは何でもNoImage画像を表示するように実装していたので、以下のように表示されています。

ここでリストのNoImage画像の情報を詰めている箇所で、引数にDBに保存した画像のファイル名を渡し以下の処理を呼び出すようにします。

    private fun convertDrawable(fileName: String): Drawable {
        return try {
            if (fileName != "no_image") {
                // ディレクトリ取得
                val directory = ContextWrapper(getApplication()).getDir(
                    "image",
                    Context.MODE_PRIVATE
                )
                // File生成
                val file = File(directory, fileName)
                // Bitmap生成
                val bitmap = BitmapFactory.decodeFile(file.toString())
                // Drawable生成
                return bitmap.toDrawable(getApplication<Application>().resources)
            } else {
                // 画像の登録が無い場合、NoImage画像を登録
                return ContextCompat.getDrawable(getApplication(), R.drawable.no_image)!!
            }
        } catch (e: FileNotFoundException) {
            // FileNotFoundExceptionが発生した場合NoImage画像を登録
            ContextCompat.getDrawable(getApplication(), R.drawable.no_image)!!
        }
    }

画像の登録がない場合はfileNameに"no_image"という文字列を保存しているため、その時はリソース上のNoImage画像を表示します。

fileNameに"no_image"ではない場合、内部ストレージのディレクトリまでのパスとファイル名を渡してFileオブジェクトを生成します。
そのFileオブジェクトを元にBitmapを取得しそれをDrawableに変換して表示します。

いざ実践

4つほど行った店を登録してリストを表示してみます。

DBとストレージは以下のような状態。
大戸屋だけ、画像を登録していません。

4行目が大戸屋のデータですが、visited_photoに"no_image"の文字列が登録されています。

内部ストレージの画像は3枚だけです。
(ドリアとハンバーガーと餃子の写真)


その状態で行った店リストを表示すると、以下のように表示されました。

保存された画像がある場合はそれが表示され、大戸屋は画像がないのでNoImageが表示されていますね!

CoilGlideなどのライブラリを使った画像読み込み方法もあるみたいなので今度試してみたいです。

今日はこの辺で!ではまた!

【Android】内部ストレージに画像を保存しリストで表示する①

こんにちは!ぱかぱかです!
1/4,5は有給を取得しているので10連休以上あるはずなのですが、あっという間に日が過ぎて行って怖いです…
1日中スウェットを着てPCの前で代わり映えのない日々を過ごしているからかもしれませんね…
しかし、このように十分な時間がある時でないとなかなかアプリ開発が前進しないので、チャンスと思って取り組んでいきたいと思います!

今日は現在開発を進めている飲食店記録アプリ「グルメノート」で、画像を内部ストレージに保存した後にそれを取得してリストに表示する実装についてまとめていきたいと思います。
長くなりそうなので記事を2つに分けたいと思います。

前回の記事

radish-se.hatenablog.com

DBのデータをLiveDataに格納しリストに表示できるようになった!
ただし画像はNo Image…

手作りNo Image画像

今回のゴール

・行った店登録画面から登録した画像をアプリの内部ストレージに保存
・DBにストレージのパス情報を保存

次回の記事
・行った店リストでDBのパス情報を元に内部ストレージから画像を取得して表示
radish-se.hatenablog.com

画像を内部ストレージに保存

色々なストレージ

アプリデータの保存方法には以下のような選択肢があります。

保存場所 保存するもの
アプリ固有のストレージ そのアプリのみで使用するファイル
共有ストレージ 他のアプリでも共有可能なファイル
設定 アプリの設定に関する情報(Key-Value
データベース 構造化データ

今回はこの中で共有ストレージから写真選択ツールで画像を取得し、アプリ固有ストレージである内部ストレージに保存します。
そして、保存したパス情報をデータベースに保存します。

写真選択ツールの表示

行った店登録画面の画像登録ボタンを押下した際に、まずは暗黙的インテントを使って写真選択ツールを表示したいと思います。

どうやらAndroid公式を見てみるとAndroid13からいい感じの写真選択ツールが追加されているみたいなのですが、私が動作確認に使っている実機がAndroid12なので今回は使わないことにしました。
写真選択ツール  |  デベロッパー向け Android  |  Android Developers

暗黙的インテント

暗黙的インテントには第一引数(action)に要求する動作、第二引数(uri)に関連付けるデータの詳細を指定します。

今回は以下を指定します。

action:ACTION_PICK
→ データから項目を選択し、選択された内容を返す
Intent  |  Android Developers

uriMediaStore.Images.Media.EXTERNAL_CONTENT_URI
→ 画像の共有ストレージ
MediaStore.Images.Media  |  Android Developers

val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)

Activity Result APIの実装

Activityの結果を取得するためにActivity Result APIregisterForActivityResultを使用します。
今回結果に当たるのは選択した「画像のURI」になります。
行った店登録画面の画像を登録ボタンを押したときの処理として以下のように実装しました。
result.resultCodeがRESULT_OKであればURIを取得し、Bitmapとして一旦保持しておきます。

ちなみに取得されたURIは以下のような感じでcontent://から始まります。
content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Fimages%2Fmedia%2F27/ORIGINAL/NONE/image%2Fjpeg/1357343246

val pickImage =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
                if (result.resultCode == Activity.RESULT_OK) {
                    // 選択された画像のURIを取得
                    val data: Intent? = result.data
                    selectedImageUri = data?.data

                    // 選択した画像のUriからBitmapを取得し保持
                    this.visitedPhotoBitmap = getBitmapFromUri(selectedImageUri)
                    this.visitedPhotoBitmap ?: return@registerForActivityResult
                }
            }

        btImage.setOnClickListener {
            val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
            pickImage.launch(intent)
        }

    private fun getBitmapFromUri(uri: Uri?): Bitmap? {
        uri ?: return null

        val parcelFileDescriptor: ParcelFileDescriptor? =
            requireActivity().contentResolver.openFileDescriptor(uri, "r")
        parcelFileDescriptor ?: return null

        val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor
        val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
        parcelFileDescriptor.close()
        return image
    }

画像保存先のパスをDBに登録

行った店登録ボタンを押したときにの処理を以下のように実装しました。
DBには保存した画像のファイルパスを保存しておきます。
("/"区切りのパスを保存したらうまく行かなかったので、結局ファイル名の部分だけ保存しておいて読み込む時にディレクトリのパスと組み合わせることにしました。)
画像のBitmapが保持されていない場合は画像登録無しとして一旦文字列を保存し、表示時にNoImage画像を出すよう判定します。(なんかここ微妙ですがとりあえず)

ContextWrappergetDirを使うことでアプリの内部ストレージディレクトリにアクセス、また必要に応じて作成することができます。
第一引数(name)にはディレクトリ名、第二引数にはモードを指定します。
今回はディレクトリ名に"image"、モードはMODE_PRIVATEを設定しています。
ContextWrapper  |  Android Developers

// 行った店登録ボタン
val registrationButton = registVisitedRestFragment.findViewById<Button>(R.id.registration_button)

        registrationButton.setOnClickListener {

        // EditTextに入力した情報を諸々ViewModelに突っ込む処理
        ...

            if (this.visitedPhotoBitmap == null) {
                // 画像登録がない場合は文字列を登録し表示時にNoImage画像を表示するよう判定
                _registrationVisitedRestaurantViewModel.visitedPhoto = "no_image"
            } else {
                // 画像登録がある場合はその画像を内部ストレージに保存しファイルパスをDB登録する

                // ディレクトリ名指定
                val directory = ContextWrapper(context).getDir(
                    "image",
                    Context.MODE_PRIVATE
                )

                // ユニークなファイル名を指定する必要があるので日時を利用してファイル名設定
                val fileName = this.getFileName()
                val file = File(directory, fileName)

                // 内部ストレージに保存
                FileOutputStream(file).use { stream ->
                    visitedPhotoBitmap!!.compress(Bitmap.CompressFormat.PNG, 100, stream)
                }

                _registrationVisitedRestaurantViewModel.visitedPhoto = fileName
            }

            lifecycleScope.launch {
                _registrationVisitedRestaurantViewModel.addVisitedRestaurant()
            }
        }

    private fun getFileName(): String {
        val dateFormat = SimpleDateFormat("yyyyMMddHHmmss", Locale.JAPANESE)
        val now = Date()
        val dateStr = dateFormat.format(now)
        // タイムスタンプを利用したファイル名の指定
        return "Photo_${dateStr}.png"
    }

いざ実践

店登録画面で画像登録ボタンを押すと

写真選択ツールが表示されるので、そこからフォルダを開いて画像を選択します。


DB確認

その後行った店登録ボタンを押すとrestaurantsテーブルとvisited_recordsテーブルに情報が登録されますが…

visited_recordsテーブルのvisited_photoにちゃんと画像のファイル名が保存されていました。


内部ストレージ確認

内部ストレージの証跡を載せるのを忘れていたので後日追記しています。
Device File Explolerを見るとラーメンの写真が以下パスに格納されていました。
/data/data/com.example.gourmetnote/app_image/Photo_20240106152703.png
ディレクトリ名として「image」を指定すると「app_image」というディレクトリ名が作られるようです。
(ちょっとこの原理はよくわかっていません)

おいしそう…

そういえば今更なんですけどアプリのドメインexample.comってダサすぎ…?
まあいいか…

次回はこのパスを元に画像を内部ストレージから取得し表示したいと思います。
ではまた!

【Android】RecyclerView + ListAdapter + DataBinding + LiveData + ViewModelで行った店リスト実装?

こんにちは!ぱかぱかです!
新年早々の大地震、被災された方へお見舞い申し上げます。
一昨日からついつい地震のニュースばかり見てしまい心が落ち着かず…
でも私からできることはほとんどありませんので、微力ながら寄付をさせていただきました。

donation.yahoo.co.jp

昔作ったけど全く使っていなかったyahooアカウントに久しぶりにログイン。
クレジットだけで簡単に募金できました。
よろしければ上記リンクご活用ください。

こんな状況の中ですが、日常は回り続けますね。
今日は箱根駅伝を横目に見ながらPCをカタカタしていました。
現在開発を進めている飲食店記録アプリ「グルメノート」で、LiveDataを使った実装をしてみたのでまとめていきたいと思います。

色々と苦戦してあれこれ調べながら実装したのですが、なんか間違っているような気もするのでご指摘あればよろしくお願いいたします!

前回の記事

radish-se.hatenablog.com

ViewbindingとDataBindingを使ってデータをRecyclerViewに表示できるようになった!
ただし表示するデータはコード上にベタ打ち…

今回のゴール

DBに登録されている情報をRoomを使って取得し、それをLiveDataとして管理。
取得完了後にRecyclerViewが更新されるようにする。

DBから登録したデータを取得する

1ヶ月以上前になってしまいましたが、以下の記事で行った店をDBに登録できるようにしました。
radish-se.hatenablog.com

以下のように行った店登録画面で情報を入力して登録ボタンを押すと

軽くディスっている…

2つのテーブルにデータが挿入されます。

restaurantsテーブル
visited_recordsテーブル

今度はこのDBからViewに表示するデータを取得したいと思います。

DAOクラスにデータ取得メソッド追加

RestaurantEntityとVisitedRecordsEntityに対して操作を行うRestaurantVisitedRecordsEntityDAOを作ります。
そして以下のように検索メソッドselectRestaurantWithVisitedRecordとデータクラスVisitedRestaurantDataを作成します。
Roomではこんな感じでクエリを書いて実行できるんですね。
クエリ内でrestaurants.restaurant_name AS restaurantNameのように書くと、データクラスのプロパティに対応してデータが格納されるようです…ほほう…

@Dao
interface RestaurantVisitedRecordsEntityDAO {

    @Query(
        """
            SELECT
            restaurants.restaurant_name AS restaurantName,
            visited_records.evaluation AS restaurantEvaluation,
            restaurants.place AS restaurantPlace,
            restaurants.genre AS restaurantGenre,
            restaurants.created_at AS restaurantRegistrationDate,
            visited_records.visited_note AS restaurantMemo
            FROM restaurants
            INNER JOIN visited_records ON visited_records.restaurant_id = restaurants.restaurant_id;
        """
    )
    suspend fun selectRestaurantWithVisitedRecord(): MutableList<VisitedRestaurantData>

    data class VisitedRestaurantData(
        val restaurantName: String,
        val restaurantEvaluation: Int,
        val restaurantPlace: String,
        val restaurantGenre: String,
        val restaurantRegistrationDate: String,
        val restaurantMemo: String
    )
}

今度はそれをRestaurantVisitedRecordsRepositoryから呼び出してあげます。

class RestaurantVisitedRecordsRepository(application: Application) {
    private val _db: AppDatabase

    init {
        _db = AppDatabase.getDatabase(application)
    }

    ...

    suspend fun selectRestaurantWithVisitedRecord(): MutableList<RestaurantVisitedRecordsEntityDAO.VisitedRestaurantData> {
        val restaurantVisitedRecordsEntityDAO = _db.createRestaurantVisitedRecordsEntityDAO()
        return restaurantVisitedRecordsEntityDAO.selectRestaurantWithVisitedRecord()
    }
}

これでDBからデータを取得する処理の準備は完了です。

次にこれをViewModelから呼び出しLiveDataで管理してあげます。

LiveData

LiveDataはデータのライフサイクル(作成・更新・削除など)を監視できるデータクラスです。
データのライフサイクルを検知して、UIに反映することができます。
公式でもLiveDataはViewModelで管理するよう記載があるので、ViewModelで持っておきましょう。

LiveData の概要  |  Android デベロッパー  |  Android Developers

注: 以下の理由により、UI を更新する LiveData オブジェクトは、アクティビティやフラグメントとは異なり、ViewModel オブジェクトに保存してください。
・アクティビティやフラグメントの肥大化を避けるため。現在、これらの UI コントローラでは、データの表示は行っていますが、データの状態の保持は行っていません。
・LiveData インスタンスを特定のアクティビティやフラグメントのインスタンスから切り離して、LiveData オブジェクトが設定の変更後にも存在できるようにするため。

ViewModelの実装

前回まではViewModelにデータをベタ打ちしていたのですが、DBから取得するように修正していきます。
行った店のリストであるvisitedRestaurantListをMutableLiveDataで保持するようにします。
取得したデータはVisitedRestaurantListItemに詰め直してMutableLiveDataに格納した後、postValue()を実行します。

postValue()を呼び出すことでObserverがトリガーされてUIが更新されるとのこと。
後で動きを見てみましょう。

class VisitedRestaurantViewModel(application: Application) : AndroidViewModel(application) {
    lateinit var visitedRestaurantListAdapter: VisitedRestaurantListAdapter
    private var _restaurantVisitedRecordsRepository =
        RestaurantVisitedRecordsRepository(this.getApplication())
    
    // LiveDataで保持
    val visitedRestaurantList = MutableLiveData<List<VisitedRestaurantListItem>>()

    @RequiresApi(Build.VERSION_CODES.O)
    suspend fun fetchData() {
        viewModelScope.launch {
            // DBからデータを取得
            _restaurantVisitedRecordsRepository =
                RestaurantVisitedRecordsRepository(getApplication())
            val visitedRestaurantDataList =
                _restaurantVisitedRecordsRepository.selectRestaurantWithVisitedRecord()
            val tmpVisitedRestaurantList = mutableListOf<VisitedRestaurantListItem>()

            // 取得したデータをVisitedRestaurantListItemに格納
            visitedRestaurantDataList.forEach { data ->
                val item = VisitedRestaurantListItem(
                    data.restaurantName,
                    Evaluation.getEvaluationByNumber(data.restaurantEvaluation),
                    data.restaurantPlace,
                    data.restaurantGenre,
                    data.restaurantRegistrationDate,
                    data.restaurantMemo,
                    ContextCompat.getDrawable(getApplication(), R.drawable.no_image),
                    true
                )
                tmpVisitedRestaurantList.add(item)
            }

            // postValue実行
            visitedRestaurantList.postValue(tmpVisitedRestaurantList)
        }
    }
}

Fragmentの実装

ViewModelのvisitedRestaurantListに変更があったときに検知できるようobserve()で監視します。
変更を検知したらsubmitList()でリストを更新します。

class VisitedRestaurantFragment: Fragment()  {
    lateinit var binding: FragmentVisitedRestaurantBinding
    private val visitedRestaurantViewModel: VisitedRestaurantViewModel by viewModels()

    @RequiresApi(Build.VERSION_CODES.O)
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentVisitedRestaurantBinding.inflate(inflater, container, false)
        binding.recyclerView.layoutManager = LinearLayoutManager(context)
        val adapter = VisitedRestaurantListAdapter(visitedRestaurantViewModel)
        visitedRestaurantViewModel.visitedRestaurantListAdapter = adapter
        binding.recyclerView.adapter = adapter

        lifecycleScope.launch {
            visitedRestaurantViewModel.fetchData()
        }

        // observe()でライフサイクルの通知を検知
        visitedRestaurantViewModel.visitedRestaurantList.observe(viewLifecycleOwner){
            adapter.submitList(visitedRestaurantViewModel.visitedRestaurantList.value)
        }

        return binding.root
    }
}

いざ実践

observeメソッドにブレークを貼って実行してみます。
行った店タブを押したとき、ViewModelでDBからのデータ取得が非同期で走ります。
まだデータ取得が完了していない間はリストが空なのでまっさらな画面が表示されています。

その後、DBからのデータ取得が完了するとpostValue()が呼び出されるためFragmentのobserve()が動きsubmitList()が実行されます。
すると以下のようにリストの中身が表示されました。

これでようやくDBに基づいた情報が表示されるようになりました。
長くなってしまいましたが今日はこの辺で!