این سری نوشته ها خلاصه کتاب اموزشی به نام Android Programming: The Big Nerd Ranch Guide که سعی خواهم کرد در حال مطالعه کتاب نکات خلاصه و مفید رو بنویسم
ذخیره سازی اطلاعات در برنامه های اندروید بوسیله کامپوننت Room بر پایه دیتابیس sqllite میباشد . این کامپوننت با استفاده از annotation هایی که برای کلاس ها تعریف میکنیم بصورت خودکار کلاس های ارتباط با دیتابیس را میسازد . برای کار با Room باید کلاس های خاصی ساخته شود که در زیر به انها میپردازیم
قبل از هر چیز باید dependency به build.gradle بصورت زیر اضافه شود :
apply plugin: 'kotlin-kapt'
implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version"
استفاده از kapt به اندروید استودیو اجازه میدهد به کلاس های خودکار ساخته شده دسترسی داشته و بتوانیم از انها در برنامه استفاده کنیم . room-runtime شامل کتابخانه هایی برای ساختن کلاس های ارتباط با دیتابیس و room-compiler شامل کتابخانه هایی برای ساخت کلاس ها مورد نیاز از طریق annotation ها بصورت خودکار میباشد . حالا کافی است یکبار پروژه را sync کنید
کلاس های مدل - Entity :
هر یک از table های دیتابیس باید بصورت کلاس هایی با Entity@ تعریف شود ، بطور مثال :
@Entity data class Crime( @PrimaryKey (autoGenerate =true) val id: UUID , var title: String = "", var date: Date = Date(), var isSolved: Boolean = false)
کلاس دیتابیس - Database :
کلاس دیتابیس همانطور که از نامش پیداست معرف دیتابیس مورد استفاده میباشد . این کلاس نیز با Database@ تعریف میشود و دارای پارامتر هایی نیز میباشد ، پارامتر های اولیه شامل کلاس های entity که تعریف شده و ورژن دیتابیس برای شناسایی تغییرات احتمالی بعدی دیتابیس میباشد . بطور مثال :
@Database(entities = [ Crime::class ], version=1) abstract class CrimeDatabase : RoomDatabase() { }
کلاس های تبدیل داده - TypeConverter
اغلب نوع اطلاعاتی که در دیتابیس ذخیره میشود با نوع مورد نیاز برنامه متفاوت است ، مثلا در برنامه به نوع داده Date نیاز داریم ولی دیتابیس از ذخیره سازی این نوع پشتیبانی نمیکند ، راه حل تبدیل نوع میباشد که میتوان به راحتی با متد های با TypeConverter@ تعریف شده و معرفی شده در Database اینکار را در Room انجام میدهیم ، بطور مثال :
class CrimeTypeConverters { @TypeConverter fun fromDate(date: Date?): Long? { return date?.time } @TypeConverter fun toDate(millisSinceEpoch: Long?): Date? { return millisSinceEpoch?.let { Date(it) } }
و به این صورت در دیتابیس ان را اعلام میکنیم :
@Database(entities = [ Crime::class ], version=1) @TypeConverters(CrimeTypeConverters::class) abstract class CrimeDatabase : RoomDatabase() { }
رابط دسترسی به داده - DAO :
برای اینکه بتوان با دیتابیس و table های ان ارتباط برقرار کرد ، اینترفیسی با Dao@ تعریف میکنیم که با استفاده از annotation یا دستورات صریح sqllite با دیتابیس تراکنش خواهد داشت :
@Dao interface CrimeDao { @Query("SELECT * FROM crime fun getCrimes(): List<Crime> @Query("SELECT * FROM crime WHERE id=(:id)") fun getCrime(id: UUID): Crime? }
برای استفاده از dao یک متد abstract که مقدار برگشت ان از نوع dao است در Database میسازیم :
@Database(entities = [ Crime::class ], version=1) @TypeConverters(CrimeTypeConverters::class) abstract class CrimeDatabase : RoomDatabase() { abstract fun crimeDao(): CrimeDao }
کلاس مخزن - Repository
گوگل توصیه میکند برای ارتباط با دیتابیس از الگوی Repository استفاده کنیم ، این الگو دسترسی به دیتابیس را کپسوله کرده و به عنوان لایه بالاتر از دیتابیس میتوان برای گرفتن اطلاعات از وب سرویس یا منابع دیگر نیز استفاده کرد . برای مثال :
class CrimeRepository private constructor(context: Context) { companion object { private var INSTANCE: CrimeRepository? = null fun initialize(context: Context) { if (INSTANCE == null) { INSTANCE = CrimeRepository(context) } } fun get(): CrimeRepository { return INSTANCE ?: throw IllegalStateException("CrimeRepository must be initialized") } } }
همانطور که از کد بالا مشخص است Repository با الگوی singletone پیاده سازی شده ، این الگو تضمین میکند فقط یک مخزن از داده در برنامه وجود داشته و هر بار که از طریق get ساخته میشود همان مخزن میباشد (چون constructor ان بصورت private تعریف شده نمیتوان نمونه جدیدی از روی ان ساخت )
اما اینکار نیازمند ساخته شدن Repository در ابتدای اجرای برنامه توسط اندروید میباشد و اینکار باید در Application انجام شود ، یعنی کلاسی میسازیم که از Application ارث بری کرده و ان را در متد oncreate مربوطه intialaize میکنیم :
class CriminalIntentApplication : Application() { override fun onCreate() { super.onCreate() CrimeRepository.initialize(this) } }
در ضمن باید حتما کلاس تعریف شده را در manifest نیز تعریف کنیم :
<manifest> xmlns:android="http://schemas.android.com/apk/res/android" package="com.bignerdranch.android.criminalintent">
<application android:name=".CriminalIntentApplication" android:allowBackup="true" ... > ... </application>
</manifest>
حال کافی است در Repository دیتابیس را بسازیم و به ان دسترسی داشته باشیم :
private const val DATABASE_NAME = "crime-database" class CrimeRepository private constructor(context: Context) { private val database : CrimeDatabase = Room.databaseBuilder( context.applicationContext, CrimeDatabase::class.java, DATABASE_NAME ).build() private val crimeDao = database.crimeDao()
fun getCrimes(): List<Crime> = crimeDao.getCrimes()
fun getCrime(id: UUID): Crime? = crimeDao.getCrime(id)
همانطور که میبینید برای ساخت دیتابیس به context و کلاس database و نام دیتابیس نیاز میباشد . حال به راحتی به dao دیتابیس دسترسی داشته و با ان ارتباط برقرار میکنیم. و کافی است در Viewmodel از Repository استفاده کنیم :
class CrimeListViewModel : ViewModel() { val crimes = mutableListOf<Crime>() init { for (i in 0 until 100) { val crime = Crime() crime.title = "Crime #$i" crime.isSolved = i % 2 == 0 crimes += crime } } private val crimeRepository = CrimeRepository.get() val crimes = crimeRepository.getCrimes() }
حالا وقتی برنامه را اجرا کنیم با خطای زیر مواجه خواهیم شد :
java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.
این خطا بدلیل اجرای کد Room در ترد اصلی برنامه است ، جایی که UI در ان اجرا میشود و به صورت پیش فرض اندروید اجازه اجرای کارهای زمان بر را در این ترد نمیدهد . راه حل های مختلفی برای رفع این ایرور وجود دارد . یکی از آنها استفاده از LiveData است .LiveData یک نگهدارنده ( holder ) اطلاعات می باشد که به خوبی با Room هماهنگ می باشد و همچنین قابلیت انتقال اطلاعات بین ترد های مختلف را دارد .
قبل از هر چیز باید dependency زیر اضافه شود :
implementation "androidx.lifecycle: lifecycle-extension:$last_version"
زمانی که از طریق DAO اطلاعات دیتابیس بصورت LiveData برگردانده میشود ، اجرای برنامه روی ترد پس زمینه اجرا شده و پس از اتمام اجرا نتایج روی آن انتقال پیدا میکند . برای اینکار DAO را به شکل زیر تغییر میدهیم :
interface CrimeDao { @Query("SELECT * FROM crime") fun getCrimes(): LiveData<List<Crime>> @Query("SELECT * FROM crime WHERE id=(:id)") fun getCrime(id: UUID): LiveData<Crime?> }
و همچنین باید کد Repository را نیز به شکل زیر تغییر دهیم :
class CrimeRepository private constructor(context: Context) { ... private val crimeDao = database.crimeDao() fun getCrimes(): LiveData<List<Crime>> = crimeDao.getCrimes() fun getCrime(id: UUID): LiveData<Crime?> = crimeDao.getCrime(id) ... }
و اما اصل ماجرا . LiveData از الگوی Observer استفاده میکند و باید برای استفاده از نتایج برگدانده توسط آن یک Observer برای آن تعریف کرد تا عملیاتی را که نیاز است بعد از تکمیل نتایج انجام شود در آن انجام داد . بطور معمول پس از دریافت نتایج نیاز به نمایش اطلاعات روی View برنامه می باشد ، برای همین از متد onCreateView در fragment استفاده میکنیم ، به صورت زیر :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) crimeListViewModel.crimeListLiveData.observe( viewLifecycleOwner, Observer { crimes -> crimes?.let { Log.i(TAG, "Got crimes ${crimes.size}") updateUI(crimes) } }) }
متد observe سه پارامتر دریافت میکند . پارامتر اول viewLifecycleOwner همان جزئی از برنامه است که چرخه حیات این LiveData به آن وابسته است ( fragment ) و در صورتی که چرخه حیات این بخش پایان یابد LiveData نیز به کار خود پایان خواهد داد و از بین خواهد رفت . چون اگر قبل از برگرداندن نتایج LiveData فرگمنت مورد نظر از بین برود و LiveData بخواهد اطلاعات را روی View نمایش دهد با ایرور مواجه شده و برنامه از کار می افتد ، برای همین موضوع است که LiveData را یک lifecycle aware یعنی وابسته به چرخه حیات معرفی کرده اند .
دومین پارامتر نیز متدی است که هنگام برگردانده شدن اطلاعات باید اجرا شود و بوسیله ان به راحتی میتوان view برنامه را آپدیت کرد . حال اگر برنامه را اجرا کنیم میبینیم که اطلاعات به راحتی از دیتابیس دریافت شده و در برنامه به نمایش در آمده است .
نکات مهم :
1. دریافت اطلاعات از دیتابیس زمانبر بوده و ب باسته به حجم اطلاعات ممکن است از چند میلی ثانیه تا چند ثانیه طول بکشد .برای همین بسته به طراحی برنامه باید بر نحوه نمایش تغییر اطلاعات به کاربر پس از دریافت اطلاعات حتما پروسه ای تعیین گردد . مثلا استفاده از Loader یا دریافت اطلاعات از قبل در حافظه برنامه نمایش آن پس از دریافت کامل و از این دست کارها . یکی از روش هایی که برای جلوگیری از تغییر وضعیت برخی از المان های صفحه بصورت انیمیشنی اجرا میشود استفاده از () View.jumpDrawablesToCurrentState است که جلو اجرای انیمیشن تغییر وضعیت مثلا CheckBox را پس از دریافت اطلاعات گرفته و مستقیما آن را نمایش میدهد :
checkBox.apply { isChecked = crime.isSolved jumpDrawablesToCurrentState() }
2. اغلب ممکن است دریافت اطلاعات از دیتابیس نیازمند دریافت اطلاعات دیگر قبل از ان از دیتابیس باشد ، مثلا ابتدا اطلاعات یک محصول با استفاده از id از دیتابیس گرفته میشود و سپس نیاز است مقدار فروش ان محصول از دیتابیس دریافت شود . در صورتی از LiveData استفاده شود هر دو نوع برگشت داده شده از دیتابیس از همین نوع است و دریافت نتیجه نیازمند دریافت اولین LiveData میباشد . برای اینکار میتوان از متد های transformation در LiveData استفاده کرد که دو نوع را بهم مرتبط میکند .