Komponen Arsitektur Android: Library Room Persistence
Indonesian (Bahasa Indonesia) translation by Ilham Saputra (you can also view the original English article)
Dalam artikel terakhir seri Komponen Arsitektur Android ini, kita akan menjelajahi library Room persistence, sumber daya baru yang sangat baik yang membuatnya jauh lebih mudah untuk bekerja dengan basis data di Android. Ini menyediakan lapisan abstraksi atas SQLite, query SQL waktu kompilasi-waktu, dan juga query asynchronous dan observable. Ruang mengambil operasi basis data di Android ke level lain.
Karena ini adalah bagian keempat dari seri ini, saya akan berasumsi bahwa Anda mengenal konsep dan komponen paket Arsitektur, seperti LiveData dan LiveModel. Namun, jika Anda tidak membaca satu pun dari tiga artikel terakhir, Anda masih bisa mengikuti. Namun, jika Anda tidak tahu banyak tentang komponen tersebut, luangkan waktu untuk membaca rangkaiannya—Anda dapat menikmatinya.
1. Komponen Room
Seperti yang disebutkan, Room bukanlah sistem basis data baru. Ini adalah lapisan abstrak yang membungkus database SQLite standar yang diadopsi oleh Android. Namun, Room menambahkan begitu banyak fitur ke SQLite yang hampir tidak mungkin dikenali. Room menyederhanakan semua operasi yang berhubungan dengan database dan juga membuat mereka jauh lebih kuat karena memungkinkan kemungkinan pengembalian yang dapat diamati dan kueri SQL waktu kompilasi-waktu.
Kamar terdiri dari tiga komponen utama: Database, DAO (Objek Akses Data), dan Entity. Setiap komponen memiliki tanggung jawabnya, dan semuanya harus diimplementasikan agar sistem berfungsi. Untungnya, penerapannya cukup sederhana. Berkat anotasi dan kelas abstrak yang disediakan, boiler untuk menerapkan Room dijaga seminimal mungkin.
- Entity adalah kelas yang disimpan di Database. Tabel database eksklusif dibuat untuk setiap kelas yang diberi catatan
@Entity
. -
DAO adalah antarmuka yang dianotasikan dengan
@Dao
yang memediasi akses ke objek dalam database dan tabelnya. Ada empat anotasi khusus untuk operasi dasar DAO:@Insert
,@Update
,@Delete
, dan@Query
. - Komponen Database adalah kelas abstrak yang dianotasikan
@Database
, yang memperluasRoomDatabase
. Kelas mendefinisikan daftar Entitas dan DAO-nya.
2. Menyiapkan Lingkungan
Untuk menggunakan Room, tambahkan dependensi berikut ke modul aplikasi di Gradle:
compile "android.arch.persistence.room:runtime:1.0.0" annotationProcessor "android.arch.persistence.room:compiler:1.0.0"
Jika Anda menggunakan Kotlin, Anda perlu menerapkan plugin kapt
dan menambahkan dependensi lain.
apply plugin: 'kotlin-kapt' // … dependencies { // … kapt "android.arch.persistence.room:compiler:1.0.0" }
3. Entity, Tabel Database
Entity mewakili objek yang disimpan dalam database. Setiap kelas Entity
membuat tabel database baru, dengan masing-masing bidang mewakili kolom. Anotasi digunakan untuk mengonfigurasi entitas, dan proses pembuatannya sangat sederhana. Perhatikan betapa sederhananya mengatur Entity
menggunakan kelas data Kotlin.
@Entity data class Note( @PrimaryKey( autoGenerate = true ) var id: Long?, var text: String?, var date: Long? )
Setelah kelas dianotasi @Entity
, perpustakaan Room secara otomatis akan membuat tabel menggunakan kolom kelas sebagai kolom. Jika Anda perlu mengabaikan bidang, cukup beri anotasi dengan @Ignore
. Setiap Entity
juga harus menentukan @PrimaryKey
.
Tabel dan Kolom
Ruangan akan menggunakan kelas dan nama bidangnya untuk membuat tabel secara otomatis; namun, Anda dapat mempersonalisasi tabel yang dihasilkan. Untuk menentukan nama untuk tabel, gunakan opsi tableName
pada Anotasi @Entity
, dan untuk mengedit nama kolom, tambahkan a @ColumnInfo
anotasi dengan opsi nama di lapangan. Penting untuk diingat bahwa nama tabel dan kolom adalah case sensitive.
@Entity( tableName = “tb_notes” ) data class Note( @PrimaryKey( autoGenerate = true ) @ColumnInfo( name = “_id” ) var id: Long?, //... )
Indeks dan Kendala Keunikan
Ada beberapa kendala SQLite yang berguna bahwa Room memungkinkan kita untuk dengan mudah menerapkan pada entitas kita. Untuk mempercepat permintaan pencarian, Anda dapat membuat indices SQLite di bidang yang lebih relevan untuk kueri tersebut. Indeks akan membuat pencarian lebih cepat. namun, mereka juga akan menyisipkan, menghapus, dan memperbarui kueri lebih lambat, jadi Anda harus menggunakannya dengan hati-hati. Lihatlah dokumentasi SQLite untuk memahaminya lebih baik.
Ada dua cara berbeda untuk membuat indeks di Room. Anda cukup mengatur properti ColumnInfo
, index
, ke true
, membiarkan Room mengatur indeks untuk Anda.
@ColumnInfo(name = "date", index = true) var date: Long
Atau, jika Anda membutuhkan lebih banyak kontrol, gunakan properti indices
dari penjelasan @Entity
, daftar nama-nama bidang yang harus menyusun indeks dalam properti value
. Perhatikan bahwa urutan item dalam value
penting karena mendefinisikan penyortiran tabel indeks.
@Entity( tableName = "tb_notes", indices = arrayOf( Index( value = *arrayOf("date","title"), name = "idx_date_title" ) ) )
Kendala SQLite lain yang berguna adalah unique
, yang melarang bidang yang ditandai untuk memiliki nilai duplikat. Sayangnya, dalam versi 1.0.0, Kamar tidak menyediakan properti ini sebagaimana mestinya, langsung di bidang entitas. Tetapi Anda dapat membuat indeks dan membuatnya unik, mencapai hasil yang serupa.
@Entity( tableName = "tb_users", indices = arrayOf( Index( value = “username”, name = "idx_username", unique = true ) ) )
Kendala lain seperti NOT NULL
, DEFAULT
, dan CHECK
tidak ada di Room (setidaknya hingga sekarang, dalam versi 1.0.0), tetapi Anda dapat membuat logika Anda sendiri di Entitas untuk mencapai hasil yang serupa. Untuk menghindari nilai nol pada entitas Kotlin, cukup hapus ?
di akhir tipe variabel atau, di Java, tambahkan anotasi @NonNull
.
Hubungan Antar Objek
Tidak seperti kebanyakan perpustakaan pemetaan objek-relasional, Room tidak mengizinkan entitas untuk merujuk langsung ke yang lain. Ini berarti bahwa jika Anda memiliki entitas bernama NotePad
dan yang disebut Note
, Anda tidak dapat membuat Collection
of Note
di dalam NotePad
seperti yang akan Anda lakukan dengan banyak pustaka serupa. Pada awalnya, batasan ini mungkin tampak menjengkelkan, tetapi itu adalah keputusan desain untuk menyesuaikan pustaka Room dengan batasan arsitektur Android. Untuk memahami keputusan ini dengan lebih baik, lihat penjelasan Android untuk pendekatan mereka.
Meskipun hubungan objek Room terbatas, masih ada. Dengan menggunakan kunci asing, adalah mungkin untuk mereferensikan parent dan objek child dan cascade modifikasi mereka. Perhatikan bahwa itu juga disarankan untuk membuat indeks pada objek child untuk menghindari pemindaian tabel penuh ketika parent diubah.
@Entity( tableName = "tb_notes", indices = arrayOf( Index( value = *arrayOf("note_date","note_title"), name = "idx_date_title" ), Index( value = *arrayOf("note_pad_id"), name = "idx_pad_note" ) ), foreignKeys = arrayOf( ForeignKey( entity = NotePad::class, parentColumns = arrayOf("pad_id"), childColumns = arrayOf("note_pad_id"), onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE ) ) ) data class Note( @PrimaryKey( autoGenerate = true ) @ColumnInfo( name = "note_id" ) var id: Long, @ColumnInfo( name = "note_title" ) var title: String?, @ColumnInfo( name = "note_text" ) var text: String, @ColumnInfo( name = "note_date" ) var date: Long, @ColumnInfo( name = "note_pad_id") var padId: Long )
Menyematkan Objek
Adalah mungkin untuk menanamkan objek di dalam entitas menggunakan penjelasan @Embedded
. Setelah objek disematkan, semua bidangnya akan ditambahkan sebagai kolom di tabel entitas, menggunakan nama bidang objek yang disematkan sebagai nama kolom. Pertimbangkan kode berikut.
data class Location( var lat: Float, var lon: Float ) @Entity(tableName = "tb_notes") data class Note( @PrimaryKey( autoGenerate = true ) @ColumnInfo( name = "note_id" ) var id: Long, @Embedded( prefix = "note_location_" ) var location: Location? )
Dalam kode di atas, kelas Location
disematkan dalam entitas Note
. Tabel entitas akan memiliki dua kolom tambahan, sesuai dengan bidang objek yang disematkan. Karena kami menggunakan properti awalan pada anotasi @Embedded
, nama kolom akan menjadi ‘note_location_lat
’ dan ‘note_location_lon
’, dan akan dimungkinkan untuk mereferensikan kolom tersebut dalam kueri.
4. Objek Akses Data
Untuk mengakses Dataran Ruang, diperlukan objek DAO. DAO dapat didefinisikan sebagai antarmuka atau kelas abstrak. Untuk menerapkannya, beri anotasi kelas atau antarmuka dengan @Dao
dan Anda bagus untuk mengakses data. Meskipun dimungkinkan untuk mengakses lebih dari satu tabel dari DAO, dianjurkan, atas nama arsitektur yang baik, untuk mempertahankan prinsip Pemisahan Kepentingan dan membuat DAO yang bertanggung jawab untuk mengakses setiap entitas.
@Dao interface NoteDAO{}
Insert, Update, dan Delete
Room menyediakan serangkaian kemudahan anotasi untuk operasi CRUD di DAO: @Insert
, @Update
, @Delete
, dan @Query
. Operasi @Insert
mungkin menerima satu entitas, array
, atau List
entitas sebagai parameter. Untuk satu entitas, mungkin akan mengembalikan long
, mewakili baris penyisipan. Untuk beberapa entitas sebagai parameter, mungkin akan mengembalikan long[]
atau List<Long>
sebagai gantinya.
@Insert( onConflict = OnConflictStrategy.REPLACE ) fun insertNote(note: Note): Long @Insert( onConflict = OnConflictStrategy.ABORT ) fun insertNotes(notes: List<Note>): List<Long>
Seperti yang Anda lihat, ada properti lain untuk dibicarakan: onConflict
. Ini mendefinisikan strategi yang harus diikuti jika terjadi konflik menggunakan konstanta OnConflictStrategy
. Pilihannya hampir jelas, dengan ABORT
, FAIL
, dan REPLACE
menjadi kemungkinan lebih signifikan.
Untuk memperbarui entitas, gunakan penjelasan @Update
. Ini mengikuti prinsip yang sama dengan @Insert
, menerima entitas tunggal atau banyak entitas sebagai argumen. Room akan menggunakan entitas penerima untuk memperbarui nilainya, menggunakan entitas PrimaryKey
sebagai referensi. Namun, @Update
hanya dapat mengembalikan int
yang mewakili total baris tabel yang diperbarui.
@Update() fun updateNote(note: Note): Int
Sekali lagi, mengikuti prinsip yang sama, penjelasan @Delete
mungkin menerima entitas tunggal atau banyak dan mengembalikan int
dengan total baris tabel yang diperbarui. Ini juga menggunakan PrimaryKey
entitas untuk menemukan dan menghapus daftar di tabel database.
@Delete fun deleteNote(note: Note): Int
Membuat Queries
Akhirnya, penjelasan @Query
membuat konsultasi dalam database. Query dibangun dengan cara yang mirip dengan query SQLite, dengan perbedaan terbesar adalah kemungkinan untuk menerima argumen langsung dari metode. Tetapi karakteristik yang paling penting adalah bahwa query diverifikasi pada waktu kompilasi, yang berarti bahwa kompilator akan menemukan kesalahan segera setelah Anda membangun proyek.
Untuk membuat kueri, beri anotasi metode dengan @Query
dan tuliskan kueri SQLite sebagai nilai. Kami tidak akan terlalu memperhatikan cara menulis kueri karena mereka menggunakan SQLite standar. Tetapi umumnya, Anda akan menggunakan kueri untuk mengambil data dari database menggunakan perintah SELECT
. Pilihan dapat mengembalikan nilai tunggal atau koleksi.
@Query("SELECT * FROM tb_notes") fun findAllNotes(): List<Note>
Sangat mudah untuk mengirimkan parameter ke pertanyaan. Kamar akan menyimpulkan nama parameter, menggunakan nama argumen metode. Untuk mengaksesnya, gunakan :
, diikuti oleh nama.
@Query("SELECT * FROM tb_notes WHERE note_id = :id") fun findNoteById(id: Long): Note @Query(“SELECT * FROM tb_noted WHERE note_date BETWEEN :early AND :late”) fun findNoteByDate(early: Date, late: Date): List<Note>
LiveData Queries
Kamar dirancang untuk bekerja dengan anggun dengan LiveData
. Untuk @Query
mengembalikan LiveData
, cukup selesaikan pengembalian standar dengan LiveData
<?>
Dan Anda siap untuk pergi.
@Query("SELECT * FROM tb_notes WHERE note_id = :id") fun findNoteById(id: Long): LiveData<Note>
Setelah itu, dimungkinkan untuk mengamati hasil kueri dan mendapatkan hasil asinkron dengan mudah. Jika Anda tidak mengetahui kekuatan LiveData, luangkan waktu untuk membaca tutorial kami tentang komponen tersebut.
5. Membuat Database
Basis data dibuat oleh kelas abstrak, dianotasikan dengan @Database
dan memperluas kelas RoomDatabase
. Juga, entitas yang akan dikelola oleh database harus dilewatkan dalam array di properti entities
dalam penjelasan @Database
.
@Database( entities = arrayOf( NotePad::class, Note::class ) ) abstract class Database : RoomDatabase() { abstract fun padDAO(): PadDAO abstract fun noteDAO(): NoteDAO }
Setelah kelas database diimplementasikan, sekarang saatnya untuk membangun. Penting untuk menekankan bahwa instance basis data idealnya dibangun hanya sekali per sesi, dan cara terbaik untuk mencapai hal ini adalah dengan menggunakan sistem injeksi dependensi, seperti Dagger. Namun, kami tidak akan terjun ke DI sekarang, karena itu berada di luar lingkup tutorial ini.
fun providesAppDatabase() : Database { return Room.databaseBuilder( context, Database::class.java, "database") .build() }
Biasanya, operasi pada database Room tidak dapat dibuat dari UI Thread, karena mereka memblokir dan mungkin akan membuat masalah untuk sistem. Namun, jika Anda ingin memaksakan eksekusi pada UI Thread, tambahkan allowMainThreadQueries
ke opsi build. Bahkan, ada banyak opsi menarik untuk bagaimana membangun database, dan saya menyarankan Anda untuk membaca dokumentasi RoomDatabase.Builder
untuk memahami kemungkinannya.
6. Datatype dan Konversi Data
Kolom Datatype secara otomatis ditentukan oleh Room. Sistem akan menyimpulkan dari jenis bidang mana jenis SQLite Datatype lebih memadai. Perlu diingat bahwa sebagian besar POJO Java akan dikonversi dari kotak; Namun, perlu untuk membuat konverter data untuk menangani objek yang lebih kompleks yang tidak dikenal oleh Room secara otomatis, seperti Date
dan Enum
.
Untuk Ruang untuk memahami konversi data, diperlukan untuk menyediakan TypeConverters
dan mendaftarkan konverter tersebut di Ruangan. Dimungkinkan untuk membuat pendaftaran ini dengan mempertimbangkan konteks khusus—misalnya, jika Anda mendaftarkan TypeConverter
dalam Database
, semua entitas dari basis data akan menggunakan konverter. Jika Anda mendaftar pada suatu entitas, hanya properti entitas yang dapat menggunakannya, dan seterusnya.
Untuk mengonversi objek Date
secara langsung ke selama Room mengoperasi penghematan Long
dan kemudian mengonversi Long
ke sebuah Date
ketika berkonsultasi dengan database, pertama-tama deklarasikan TypeConverter
.
class DataConverters { @TypeConverter fun fromTimestamp(mills: Long?): Date? { return if (mills == null) null else Date(mills) } @TypeConverter fun fromDate(date: Date?): Long? = date?.time }
Kemudian, daftarkan TypeConverter
di Database
, atau dalam konteks yang lebih spesifik jika Anda mau.
@Database( entities = arrayOf( NotePad::class, Note::class ), version = 1 ) @TypeConverters(DataConverters::class) abstract class Database : RoomDatabase()
7. Menggunakan Room di Aplikasi
Aplikasi yang kami kembangkan selama seri ini menggunakan SharedPreferences
untuk menyimpan data cuaca. Sekarang setelah kami tahu cara menggunakan Room, kami akan menggunakannya untuk membuat cache yang lebih canggih yang memungkinkan kami untuk mendapatkan data cache oleh kota, dan juga mempertimbangkan tanggal cuaca selama pengambilan data.
Pertama, mari buat entitas kita. Kami akan menyimpan semua data kami hanya menggunakan kelas WeatherMain
. Kami hanya perlu menambahkan beberapa anotasi ke kelas, dan selesai.
@Entity( tableName = "weather" ) data class WeatherMain( @ColumnInfo( name = "date" ) var dt: Long?, @ColumnInfo( name = "city" ) var name: String?, @ColumnInfo(name = "temp_min" ) var tempMin: Double?, @ColumnInfo(name = "temp_max" ) var tempMax: Double?, @ColumnInfo( name = "main" ) var main: String?, @ColumnInfo( name = "description" ) var description: String?, @ColumnInfo( name = "icon" ) var icon: String? ) { @ColumnInfo(name = "id") @PrimaryKey(autoGenerate = true) var id: Long = 0 // ...
Kami juga membutuhkan DAO. The WeatherDAO
akan mengelola operasi CRUD di entitas kami. Perhatikan bahwa semua pertanyaan mengembalikan LiveData
.
@Dao interface WeatherDAO { @Insert( onConflict = OnConflictStrategy.REPLACE ) fun insert( w: WeatherMain ) @Delete fun remove( w: WeatherMain ) @Query( "SELECT * FROM weather " + "ORDER BY id DESC LIMIT 1" ) fun findLast(): LiveData<WeatherMain> @Query("SELECT * FROM weather " + "WHERE city LIKE :city " + "ORDER BY date DESC LIMIT 1") fun findByCity(city: String ): LiveData<WeatherMain> @Query("SELECT * FROM weather " + "WHERE date < :date " + "ORDER BY date ASC LIMIT 1" ) fun findByDate( date: Long ): List<WeatherMain> }
Akhirnya, inilah saatnya untuk membuat Database
.
@Database( entities = arrayOf(WeatherMain::class), version = 2 ) abstract class Database : RoomDatabase() { abstract fun weatherDAO(): WeatherDAO }
Oke, sekarang kami sudah mengkonfigurasi database Room. Yang perlu dilakukan hanyalah menghubungkannya dengan Dagger
dan mulai menggunakannya. Di DataModule
, mari berikan Database
dan WeatherDAO
.
@Module class DataModule( val context: Context ) { // ... @Provides @Singleton fun providesAppDatabase() : Database { return Room.databaseBuilder( context, Database::class.java, "database") .allowMainThreadQueries() .fallbackToDestructiveMigration() .build() } @Provides @Singleton fun providesWeatherDAO(database: Database) : WeatherDAO { return database.weatherDAO() } }
Seperti yang Anda harus ingat, kami memiliki repositori yang bertanggung jawab untuk menangani semua operasi data. Mari terus gunakan kelas ini untuk permintaan data Room aplikasi. Tetapi pertama-tama, kita perlu mengedit metode providesMainRepository
dari DataModule
, untuk menyertakan WeatherDAO
selama konstruksi kelas.
@Module class DataModule( val context: Context ) { //... @Provides @Singleton fun providesMainRepository( openWeatherService: OpenWeatherService, prefsDAO: PrefsDAO, weatherDAO: WeatherDAO, locationLiveData: LocationLiveData ) : MainRepository { return MainRepository( openWeatherService, prefsDAO, weatherDAO, locationLiveData ) } /… }
Sebagian besar metode yang akan kami tambahkan ke MainRepository
cukup mudah. Layak mencari lebih dekat pada clearOldData()
, meskipun. Ini membersihkan semua data yang lebih lama dari satu hari, hanya menyimpan data cuaca yang relevan yang disimpan dalam database.
class MainRepository @Inject constructor( private val openWeatherService: OpenWeatherService, private val prefsDAO: PrefsDAO, private val weatherDAO: WeatherDAO, private val location: LocationLiveData ) : AnkoLogger { fun getWeatherByCity( city: String ) : LiveData<ApiResponse<WeatherResponse>> { info("getWeatherByCity: $city") return openWeatherService.getWeatherByCity(city) } fun saveOnDb( weatherMain: WeatherMain ) { info("saveOnDb:\n$weatherMain") weatherDAO.insert( weatherMain ) } fun getRecentWeather(): LiveData<WeatherMain> { info("getRecentWeather") return weatherDAO.findLast() } fun getRecentWeatherForLocation(location: String): LiveData<WeatherMain> { info("getWeatherByDateAndLocation") return weatherDAO.findByCity(location) } fun clearOldData(){ info("clearOldData") val c = Calendar.getInstance() c.add(Calendar.DATE, -1) // get weather data from 2 days ago val oldData = weatherDAO.findByDate(c.timeInMillis) oldData.forEach{ w -> info("Removing data for '${w.name}':${w.dt}") weatherDAO.remove(w) } } // ... }
MainViewModel
bertanggung jawab untuk melakukan konsultasi ke repositori kami. Mari tambahkan beberapa logika untuk mengatasi operasi kami ke database Room. Pertama, kami menambahkan MutableLiveData
, weatherDB
, yang bertanggung jawab untuk konsultasi MainRepository
. Kemudian, kami menghapus referensi ke SharedPreferences
, membuat cache kami hanya bergantung pada database Room.
class MainViewModel @Inject constructor( private val repository: MainRepository ) : ViewModel(), AnkoLogger { // … // Weather saved on database private var weatherDB: LiveData<WeatherMain> = MutableLiveData() // … // We remove the consultation to SharedPreferences // making the cache exclusive to Room private fun getWeatherCached() { info("getWeatherCached") weatherDB = repository.getRecentWeather() weather.addSource( weatherDB, { w -> info("weatherDB: DB: \n$w") weather.postValue(ApiResponse(data = w)) weather.removeSource(weatherDBSaved) } ) }
Agar cache kami relevan, kami akan menghapus data lama setiap kali konsultasi cuaca baru dibuat.
private var weatherByLocationResponse: LiveData<ApiResponse<WeatherResponse>> = Transformations.switchMap( location, { l -> info("weatherByLocation: \nlocation: $l") doAsync { repository.clearOldData() } [email protected] repository.getWeatherByLocation(l) } ) private var weatherByCityResponse: LiveData<ApiResponse<WeatherResponse>> = Transformations.switchMap( cityName, { city -> info("weatherByCityResponse: city: $city") doAsync { repository.clearOldData() } [email protected] repository.getWeatherByCity(city) } )
Terakhir, kami akan menyimpan data ke database Kamar setiap kali cuaca baru diterima.
// Receives updated weather response, // send it to UI and also save it private fun updateWeather(w: WeatherResponse){ info("updateWeather") // getting weather from today val weatherMain = WeatherMain.factory(w) // save on shared preferences repository.saveWeatherMainOnPrefs(weatherMain) // save on db repository.saveOnDb(weatherMain) // update weather value weather.postValue(ApiResponse(data = weatherMain)) }
Anda dapat melihat kode lengkap di repo GitHub untuk postingan ini.
Kesimpulan
Akhirnya, kami berada di akhir seri Komponen Arsitektur Android. Tool ini akan menjadi teman yang sangat baik dalam perjalanan pengembangan Android Anda. Saya menyarankan Anda untuk terus mengeksplorasi komponen. Cobalah luangkan waktu untuk membaca dokumentasi.
Dan periksa beberapa postingan kami yang lain di pengembangan aplikasi Android di sini di Envato Tuts+!
- Android SDKSederhanakan Pengembangan Aplikasi Android Dengan AnkoAshraff Hathibelagal
- Android SDKCara Mengolah Pemrosesan Bahasa Alami di Android dengan IBM WatsonAshraff Hathibelagal
- KotlinKotlin From Scratch: Lebih Menyenangkan Dengan FungsiChike Mgbemena
- Android SDKConcurrency dan Coroutines di KotlinAshraff Hathibelagal
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Update me weekly