GW:全力でTODOアプリを作ってみる(3日目)
背景
- GWなのでアウトプットに専念してみる
- 一旦基本のTODOアプリを作ったらどんな風になるのか試してみる
- 子供が寝た隙に毎日1時間程度の稼働でどこまでいけるか
- 子供が高熱を出して早速3日ストップした。。。
Repositoryを用意する
直接Daoを操作するのは忍びないというか、今後サーバ連携を考えるとRepositoryを経由させておきたい。
class TodoRepository(private val todoDao: TodoDao) { fun fetch() : LiveData<List<TodoEntity>>{ return todoDao.getAllWords() } fun add(todoEntity: TodoEntity) { todoDao.insert(todoEntity) } }
現状Entityをそのまま返却しているが、できればドメインモデルに変換して返したい。これはあとで対応する。
ViewModelにつなぐ
AACのViewModelを作成して、Repositoryを持たせる。
class TodoListViewModel(repository: TodoRepository) : ViewModel() { val todoList = repository.fetch() class Factory(private val todoRepository: TodoRepository) : ViewModelProvider.NewInstanceFactory() { override fun <T : ViewModel> create(modelClass: Class<T>): T { return TodoListViewModel(todoRepository) as T } } }
一応RepositoryはFragmentからDIするようにした。
class TodoListFragment : Fragment() { lateinit var todoListViewModel: TodoListViewModel override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.fragment_todolist, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) todoListViewModel = ViewModelProviders.of( this, TodoListViewModel.Factory(TodoRepository(TodoDatabase.getDatabase(activity!!.application).todoDao())) ) .get(TodoListViewModel::class.java) todoListViewModel.todoList.observe(this, Observer<List<TodoEntity>> { // TODO : ここでRecyclerViewに表示する?Bindしても良いかも。 }) } }
ドメインモデルを作成する
やっぱりモデルを先に作っておこうと思った。
data class Task(val title: String, val detail: String, val expireTime: String)
ViewModelにクリック時の処理を追加
coroutineを利用して非同期処理実施。 viewmodel-ktx 2.1.0で追加されたviewModelScopeを利用すればjobを作らなくて済む。 (現時点でalpha04)
class AddTodoViewModel(private val todoRepository: TodoRepository) : ViewModel() { fun onClickSaveButton(title: String, detail: String, date: String) = runBlocking { viewModelScope.launch { val task = Task(title, detail, date) todoRepository.add(task) } } class Factory(private val todoRepository: TodoRepository) : ViewModelProvider.NewInstanceFactory() { override fun <T : ViewModel> create(modelClass: Class<T>): T { return AddTodoViewModel(todoRepository) as T } } }
今日はここまで。
GW:全力でTODOアプリを作ってみる(2日目)
背景
- GWなのでアウトプットに専念してみる
- 一旦基本のTODOアプリを作ったらどんな風になるのか試してみる
- 子供が寝た隙に毎日1時間程度の稼働でどこまでいけるか
- 子供が高熱を出して早速3日ストップした。。。
Navigationを用意する
まずはres/navigation フォルダを作成してnavigation.xmlを作成する。
<?xml version="1.0" encoding="utf-8"?> <navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/navigation" app:startDestination="@id/todolist"> <fragment android:id="@+id/todolist" android:name="todo.view.TodoListFragment" tools:layout="@layout/fragment_todolist" /> </navigation>
あとはActivityのlayoutファイルにnavigationを設定する。
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context=".MainActivity" tools:showIn="@layout/activity_main"> <fragment android:id="@+id/nav_host_fragment" android:name="androidx.navigation.fragment.NavHostFragment" android:layout_width="match_parent" android:layout_height="match_parent" app:defaultNavHost="true" app:navGraph="@navigation/navigation" /> </androidx.constraintlayout.widget.ConstraintLayout>
fragmentのlayoutファイルにはRecyclerViewを設定しておく。 tools:listitemをRecyclerViewに設定すると、Designタブで見れるので良い。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/list" android:layout_width="match_parent" android:layout_height="match_parent" tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:spanCount="1" tools:itemCount="30" tools:listitem="@layout/item_todo" tools:orientation="vertical" /> </LinearLayout>
RecyclerViewのtoolsについては以下のQiita記事にまとめた。
感想
- なかなか時間が取れずやきもきする。。
GW:全力でTODOアプリを作ってみる(1日目)
背景
- GWなのでアウトプットに専念してみる
- 一旦基本のTODOアプリを作ったらどんな風になるのか試してみる
- 子供が寝た隙に毎日1時間程度の稼働でどこまでいけるか
Roomを使ってDB作成
@Dao interface TodoDao { @Query("SELECT * from todo") fun getAllWords(): List<TodoEntity> @Insert fun insert(todoEntity: TodoEntity) }
@Entity(tableName = "todo") data class TodoEntity( @PrimaryKey val taskNo: Int, val title: String, val detail: String, val expireTime: String? )
@Database(entities = [TodoEntity::class], version = 1) abstract class TodoDatabase : RoomDatabase() { abstract fun todoDao(): TodoDao companion object { @Volatile private var INSTANCE: TodoDatabase? = null fun getDatabase(context: Context): TodoDatabase { val tempInstance = INSTANCE if (tempInstance != null) { return tempInstance } synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, TodoDatabase::class.java, "todo" // テーブル名 ).build() INSTANCE = instance return instance } } } }
一旦ここまでは最近もやったので比較的スムーズ。
buildsrcを用いたdependencies管理
Navigation導入しようと思ったけど先にdependenciesを整理しておく。 前やったしスムーズでしょ、と思ったらどハマりして1時間かかった。。。
buildSrcフォルダにすべきところをbuildsrcにしていた。
ひどいハマり方をしてしまった。。でも最初のパッケージ名入力大文字指定できなかったよね。。?
build.gradle.ktsの中身にrepository指定が必要
バージョンが変わったからなのかリポジトリ指定が必要になっていた。
plugins { `kotlin-dsl` } repositories { jcenter() google() }
Gradle sync failed: No such propertyが発生していた
Depファイル(dependenciesを書いているobjectクラス)にパッケージ名が記載されていなかった。。。 パッケージ名を設定して、appのbuild.gradleにimportしたら動作した。
import dependencies.Dep
今日はここまで。
明日やること。
Navigationを使って画面を作る
Android Architecture Components: Room の再復習
背景
- 急遽RoomとPagingの再復習をする必要に駆られたので頑張る
- まずはRoomから
Room
RoomはQLiteをラップして利用できるコンポーネント。
以下のCodeLabsを行う。 codelabs.developers.google.com
Gradleの設定
以下の設定をbuild.gradleに記載する
// Room components implementation "android.arch.persistence.room:runtime:$rootProject.roomVersion" kapt "android.arch.persistence.room:compiler:$rootProject.roomVersion" androidTestImplementation "android.arch.persistence.room:testing:$rootProject.roomVersion" // Lifecycle components implementation "android.arch.lifecycle:extensions:$rootProject.archLifecycleVersion" kapt "android.arch.lifecycle:compiler:$rootProject.archLifecycleVersion" // Coroutines api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines" api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"
build.gradleに記載するimplementationについて復習しておく。
compile : 依存関係を追加する。ただしBプロジェクトをcompileで指定したAプロジェクトは、BがCライブラリに依存していた場合、AもCライブラリに依存することになる。deplicated。 implementation : 依存関係を追加する。ただしBプロジェクトをcompileで指定したAプロジェクトは、BがCライブラリに依存していた場合でもAはCライブラリに依存しない(AはCライブラリを利用できない) api : compileと同じ動作をする。
なぜcoroutinesのdependenciesをapiにしているのかは謎だ。。 あとCodeLabsで利用しているバージョンは0.29.1と古いものだったので最新の1.0.1にしておく。
Entityを作る
ここでいうEntityはSQLの文脈。Entityはテーブルの行を表しており、以下のようなクラスにAnnotation Processingで表すことができる。
@Entity(tableName = "word_table") data class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)
各要素について記載します。
@Entity
- テーブル内の行を指定するAnnotation。クラスの名前と別にしたい場合はtableNameを指定する
@PrimaryKey
- すべてのエンティティに主キーを設定する必要がある
@ColumnInfo
- メンバー変数の名前と異なる名前にしたい場合はnameで指定する(なくても良い)
注意事項として、全てのフィールドはpublicもしくはgetterを設定する必要があるようです。
Daoを作る
Data Access Objectを作成する。要するにテーブルに対してアクセスするオブジェクト。
@Dao interface WordDao { @Query("SELECT * from word_table ORDER BY word ASC") fun getAllWords(): List<Word> @Insert fun insert(word: Word) @Query("DELETE FROM word_table") fun deleteAll() }
各要素について記載します。
- @Dao
- Data Access Objectであることを指定する。interfaceに対して指定すること。(このinterfaceから自動的にDataAccessObjectを作ってくれる)
- @Query
- このAnnotationを設定したメソッドを実行すると指定したSQLが実行される
- @Insert
- メソッドの引数に指定したEntityを挿入する。競合した場合の振る舞いをonConflictで指定できる。
- 詳細は次のURLを参照: developer.android.com
LiveDataと連携する
データベース変更された際にUIに通知したいよね。RoomはLiveDataを戻り値に設定してUIに通知できるんです。
@Query("SELECT * from word_table ORDER BY word ASC") fun getAllWords(): LiveData<List<Word>> // LiveDataを設定する
RoomDatabaseを作る
RoomはSQLiteデータベースをラッピングする仕組みです。SQLiteOpenHelperのようなSQLiteを利用する時に必要だったタスクを肩代わりしてくれるようです。 Daoを利用してSQLiteにクエリを発行します。またSQLiteステートメントのコンパイル時チェックをしてくれるようです。素敵!
@Database(entities = [Word::class], version = 1) abstract class WordRoomDatabase : RoomDatabase() { abstract fun wordDao(): WordDao companion object { @Volatile private var INSTANCE: WordRoomDatabase? = null fun getDatabase(context: Context): WordRoomDatabase { val tempInstance = INSTANCE if (tempInstance != null) { return tempInstance } synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, WordRoomDatabase::class.java, "Word_database" // テーブル名 ).build() INSTANCE = instance return instance } } } }
各要素について記載します。
- @Database(entities = [Word::class], version = 1)
- 対象のクラスがDatabaseであることを指定する。
- entitiesにこのDatabaseのテーブルを指定する.
- versionも指定できます。マイグレーション時に必要。
- Databaseに指定するクラスはabstractを指定すること。
- また、このデータベースにアクセスするDaoもabstractメソッドで指定すること。
- @Volatile
- Databaseは1アプリにつき1インスタンスなのでシングルトンとします。
- インスタンスを生成する時はRoom.databaseBuilder()を利用します。
リポジトリを作成する
リポジトリパターンのためにリポジトリを作成します。 基本的にはこの層でAPIから取ってくるかDatabaseから取ってくるかを決める感じです。
class WordRepository(private val wordDao: WordDao) { val allWords: LiveData<List<Word>> = wordDao.getAllWords() @WorkerThread suspend fun insert(word: Word) { wordDao.insert(word) } }
@WorkerThreadは「このメソッドは非同期通信する必要があるよ」ということを明示的に示すためのAnnotaionです。 Roomは非同期(UIThread以外)で利用する必要があるので、記載します。
ViewModel を作る
class WordViewModel(application: Application) : AndroidViewModel(application) { private var parentJob = Job() private val coroutineContext: CoroutineContext get() = parentJob + Dispatchers.Main private val scope = CoroutineScope(coroutineContext) private val repository: WordRepository val allWords: LiveData<List<Word>> init { val wordsDao = WordRoomDatabase.getDatabase(application, scope).wordDao() repository = WordRepository(wordsDao) allWords = repository.allWords } fun insert(word: Word) = scope.launch(Dispatchers.IO) { repository.insert(word) } override fun onCleared() { super.onCleared() parentJob.cancel() } }
ここはViewModelなので省きます。allWordsにLiveDataを設定し、initブロックでRoomと接続してますね。
Databaseにオープン時のCallbackを設定する
Databaseのイベントに対してCallbackを設定できるようです。 ここではオープン時に今までのデータを全て消して二つの単語を登録しています。
@Database(entities = [Word::class], version = 1) abstract class WordRoomDatabase : RoomDatabase() { abstract fun wordDao(): WordDao companion object { @Volatile private var INSTANCE: WordRoomDatabase? = null fun getDatabase( context: Context, scope: CoroutineScope ): WordRoomDatabase { val tempInstance = INSTANCE if (tempInstance != null) { return tempInstance } synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, WordRoomDatabase::class.java, "Word_database" ).addCallback(WordDatabaseCallback(scope)).build() // オープン時のコールバックを設定 INSTANCE = instance return instance } } private class WordDatabaseCallback( private val scope: CoroutineScope ) : RoomDatabase.Callback() { override fun onOpen(db: SupportSQLiteDatabase) { // オープンイベント super.onOpen(db) INSTANCE?.let { database -> scope.launch(Dispatchers.IO) { populateDatabase(database.wordDao()) } } } // オープン時の処理 fun populateDatabase(wordDao: WordDao) { wordDao.deleteAll() var word = Word("Hello") wordDao.insert(word) word = Word("World!") wordDao.insert(word) } } } }
あとはinsertしたり、Observeしたり。
あとはViewModelのLiveDataをObserveして、変更が発生した場合の処理書いたり、
wordViewModel.allWords.observe(this, Observer { words -> // Update the cached copy of the words in the adapter. words?.let { adapter.setWords(it) } })
Roomにデータを追加したい時はinsertしたりできます。
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) { data?.let { val word = Word(it.getStringExtra(NewWordActivity.EXTRA_REPLY)) wordViewModel.insert(word) } } else { Toast.makeText( applicationContext, R.string.empty_not_saved, Toast.LENGTH_LONG).show() } }
まとめ
Type parameter bound for T in XXX is not satisfied が発生した場合の対処方法
背景
- 新規プロジェクトでViewModelの動きを試そうとしたら「Type parameter bound for T in XXX is not satisfied」が発生した
ソースコード
MainActivity.kt
import android.os.Bundle import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProviders class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val viewModel = ViewModelProviders.of(this@MainActivity).get(MainViewModel::class.java) // <-- Type parameter bound for T in XXX is not satisfied } }
MainViewModel.kt
import android.arch.lifecycle.ViewModel import com.jakewharton.rxrelay2.BehaviorRelay class MainViewModel : ViewModel() { public val relay = BehaviorRelay.create<String>() fun getStringData() { relay.accept("test") } }
内容
Type parameter bound for T in XXX is not satisfiedは「Tにバインドされているクラスの型が満たされていないよ」という意味。
つまりViewModelProviders.of().get()に渡している型が満たされていないらしい。いやクラス設定してるじゃん。。と思ったら、Activity側はandroidxを利用していて、ViewModelはandroid.archを利用していた。それは一致しない。。
以下のように修正したら直った。
MainViewModel.kt
import androidx.lifecycle.ViewModel // <-- fix. import com.jakewharton.rxrelay2.BehaviorRelay class MainViewModel : ViewModel() { public val relay = BehaviorRelay.create<String>() fun getStringData() { relay.accept("test") } }
まとめ
androidのライブラリこういうの多いのでつらい
'lateinit' modifier is not allowed on properties of primitive type はなぜ起こるのか。
背景
- なんとなくnullableにしたくなくて次のように記載したら'lateinit' modifier is not allowed on properties of primitive type と警告された。
/** * Constructs an [ClientException] with no detail message. */ constructor() : super() /** * Constructs an [ClientException] with the specified detail message. * @param message the detail message. */ constructor(message: kotlin.String, statusCode: Int) : super(message) { status = statusCode } private lateinit var status: Int
実際この場合ではどうしてもnullが発生しうるパターンだったので、lateinit使わずに以下のように表現すべきだった。
private var status: Int?
けどもなんでプリミティブ型はlateInitできないんだっけ?と思ったので調べてみた。
リファレンスを読んでみる
公式リファレンスを参照
lateInitが用意された理由は「そもそもlateInitないと、例えばテストコードのようなコンストラクタでないところで初期化せざるを得ないとき、毎回nullチェックするのだるい」ってことらしい
public class MyTest { lateinit var subject: TestSubject @SetUp fun setup() { subject = TestSubject() } @Test fun test() { subject.method() // dereference directly } }
しかし、「プリミティブ型にはlateInit使えないよ」とは書いてあったものの、なぜ使えないのかについては記載がなかった。
おんなじ疑問を持った人がいた。
ここの回答に以下のような回答があった。
kotlinはnullを利用してlateinitプロパティが初期化されていないことをマークし、そのプロパティにアクセスした時に適切な例外をスローする。 プリミティブ型の場合そのような値がないためlateInitが判断できない。
え、Int型ってオブジェクトだからnull持てるのになんでって思った。
ソースコードを見てみる
そもそもInt型のようなプリミティブ型ラッパークラスについてあんまりよく理解していなかったのでInt型クラスを開いてみる
/** * Represents a 32-bit signed integer. * On the JVM, non-nullable values of this type are represented as values of the primitive type `int`. */ public class Int private constructor() : Number(), Comparable<Int> {
このクラスはJVM上ではintとして表現されるよって書いてある。そうだよね。JVM上で動作するんだからkotlinでいくらオブジェクト型だろうとintになったらnull持てないよね。。 なのでStringにはlateInit使えるけどIntやLongでは使えないのか。
まとめ
- kotlinのInt型はJVM上でintとして扱われる
- lateinitはプロパティが初期化されていないことをnullを用いて判断している
- なのでJVM上nullを持てない型にはlateinitが利用できない
調べている場合ではない時ほどこういうの気になってしまうのはよくないと思いました。
Firebaseでチャットアプリを作る(Cloud Firestore)
背景
- なんとなくFirebase使ったことないのまずいかなぁと思ってFirebaseに手を出してみる。
- 今まではPush通知くらいしか使ってなかった。
- 今回はCloud Firestoreを利用してチャットアプリを作ってみる
Cloud Firestore
NoSQLドキュメント指向データベースらしい。
ちなみに NoSQLっていうのはNot Only SQLの略で、SQL言語を利用しないDBのこと。Realmとかそうですね。 ドキュメント指向というのはSQLにおけるテーブルとかカラムみたいな管理ではなく、自由なデータ構造を持つドキュメントを一つの単位として持ちます。 そのためSQLと違い事前にデータ構造を決めておく必要がないみたいですね。すごい。
サンプルを組んでみる
codeLabあった!やった!
codelabs.developers.google.com
build.gradleに依存関係を追加
// Firestore implementation 'com.google.firebase:firebase-firestore:17.1.5'
とりあえずcollectionに追加してみる。
FireBaseFireStoreのインスタンスを取得して、対象のcollectionにaddするだけ。簡単。
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // FireBaseFireStore: すべてのFireBaseデータベースのエントリポイント。 val firebase = FirebaseFirestore.getInstance() // コレクションに追加するオブジェクト(ドキュメント) val user = User("testiD") // collection:users にuserオブジェクト(ドキュメント)を追加 firebase.collection("users").add(user) } }
なんて簡単なんや。。
FireStoreのデータを監視する。
これも簡単。
- 対象のCollectionのQueryを取得する。
- EventListener
をimplement、onEventを実装する。 - QueryにaddSnapshotListenerを設定する。
とりあえず適当にActivityに書いてみる。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) // FireBaseFireStore: すべてのFireBaseデータベースのエントリポイント。 val firebase = FirebaseFirestore.getInstance() findViewById<Button>(R.id.button).setOnClickListener { // コレクションに追加するオブジェクト(ドキュメント) val user = User("testiDBbcb") // collection:users にuserオブジェクト(ドキュメント)を追加 firebase.collection("testUsers").add(user) } // Queryを作成する。collectionはcollectionReferenceを返しているんだけど、 // collectionReferenceはQueryを継承しているクラスなので、Queryのメソッドが利用できる。 val query = firebase.collection("testUsers") // Snapshotのリスナーを設定する。 query.addSnapshotListener(this) } override fun onEvent(querySnapshot: QuerySnapshot?, firebaseFirestoreExeption: FirebaseFirestoreException?) { // nullableなので念の為。 if (querySnapshot == null) { return } querySnapshot.documentChanges .forEach { // typeは3つ。追加か変更か削除 when (it.type) { DocumentChange.Type.ADDED -> { val data = it.document.data findViewById<TextView>(R.id.textView).text = data["userId"].toString().plus(" ADDED") } DocumentChange.Type.MODIFIED -> { val data = it.document.data findViewById<TextView>(R.id.textView).text = data["userId"].toString().plus(" MODIFERD") } DocumentChange.Type.REMOVED -> { findViewById<TextView>(R.id.textView).text = "Remove" } else -> { findViewById<TextView>(R.id.textView).text = "else" } } } } }
ちょっとFluxベースで整理してみる。
3日くらいかかった。。。
サンプルコードあげるつもりだったけど、Google API Keyあるし載せられなかった。辛い。
感想
- そこらへんのBaasよりすごい使いやすかった。
- ただなんかFirebase側をみても更新されていない時があって、何でだろうってなった。タイミングかな。。