‫Android Room with a View - Kotlin

המטרה של רכיבי ארכיטקטורה היא לספק הנחיות לגבי ארכיטקטורת אפליקציות, עם ספריות למשימות נפוצות כמו ניהול מחזור חיים והתמדת נתונים. רכיבי ארכיטקטורה עוזרים לכם לבנות את האפליקציה בצורה חזקה, שניתנת לבדיקה ולתחזוקה, עם פחות קוד חוזר. ספריות רכיבי הארכיטקטורה הן חלק מ-Android Jetpack.

זוהי גרסת Kotlin של ה-codelab. הגרסה בשפת התכנות Java זמינה כאן.

אם נתקלתם בבעיות (באגים בקוד, שגיאות דקדוק, ניסוח לא ברור וכו') במהלך העבודה עם ה-codelab הזה, אתם יכולים לדווח על הבעיה באמצעות הקישור דיווח על טעות בפינה הימנית התחתונה של ה-codelab.

דרישות מוקדמות

צריך להכיר את Kotlin, מושגים של עיצוב מונחה-עצמים ויסודות של פיתוח אפליקציות ל-Android, ובמיוחד:

  • RecyclerView ומתאמים
  • מסד נתונים של SQLite ושפת השאילתות של SQLite
  • קורוטינות בסיסיות (אם אתם לא מכירים קורוטינות, אתם יכולים לעיין במאמר Using Kotlin Coroutines in your Android App).

כדאי גם להכיר דפוסי ארכיטקטורה של תוכנה שמפרידים בין הנתונים לבין ממשק המשתמש, כמו MVP או MVC. ב-codelab הזה מטמיעים את הארכיטקטורה שמוגדרת במדריך לארכיטקטורת אפליקציות.

ה-codelab הזה מתמקד ברכיבי ארכיטקטורה של Android. קונספטים וקוד שלא קשורים לנושא מסופקים לכם כדי שתוכלו פשוט להעתיק ולהדביק אותם.

אם אתם לא מכירים את Kotlin, יש גרסה של ה-codelab הזה בשפת התכנות Java כאן.

מה עושים

בשיעור הזה תלמדו איך לעצב ולבנות אפליקציה באמצעות רכיבי הארכיטקטורה Room,‏ ViewModel ו-LiveData, וליצור אפליקציה שמבצעת את הפעולות הבאות:

  • הטמעה של הארכיטקטורה המומלצת שלנו באמצעות רכיבי הארכיטקטורה של Android.
  • האפליקציה פועלת עם מסד נתונים כדי לקבל ולשמור את הנתונים, ומאכלסת מראש את מסד הנתונים במילים מסוימות.
  • כל המילים בRecyclerView מוצגות בMainActivity.
  • נפתח חלון פעילות שני כשהמשתמש מקיש על הלחצן '+'. כשהמשתמש מזין מילה, המילה מתווספת למסד הנתונים ולרשימה.

האפליקציה פשוטה, אבל מורכבת מספיק כדי שאפשר יהיה להשתמש בה כתבנית לבנייה. הנה תצוגה מקדימה:

מה נדרש

  • Android Studio 3.0 ואילך וידע בשימוש בו. חשוב לוודא ש-Android Studio מעודכן, וגם ה-SDK ו-Gradle.
  • מכשיר Android או אמולטור.

ב-codelab הזה מופיע כל הקוד שצריך כדי ליצור את האפליקציה המלאה.

יש הרבה שלבים לשימוש ברכיבי הארכיטקטורה וליישום הארכיטקטורה המומלצת. הדבר הכי חשוב הוא ליצור מודל מנטלי של מה שקורה, להבין איך החלקים משתלבים ואיך הנתונים זורמים. במהלך העבודה עם ה-codelab הזה, אל תעתיקו ותדביקו את הקוד, אלא נסו להבין את מה שקורה מאחורי הקלעים.

כדי להציג את המינוח, הנה מבוא קצר לרכיבי הארכיטקטורה ולאופן הפעולה שלהם יחד. שימו לב: ה-codelab הזה מתמקד בחלק מרכיבי הארכיטקטורה, כלומר LiveData, ‏ ViewModel ו-Room. הסבר על כל רכיב מופיע כשמשתמשים בו.

בתרשים הזה מוצגת צורה בסיסית של הארכיטקטורה:

Entity: מחלקה עם הערות שמתארת טבלת מסד נתונים כשעובדים עם Room.

מסד נתונים של SQLite: אחסון במכשיר. ספריית Room persistence יוצרת ומנהלת את מסד הנתונים הזה בשבילכם.

DAO: אובייקט גישה לנתונים. מיפוי של שאילתות SQL לפונקציות. כשמשתמשים ב-DAO, קוראים לשיטות, ו-Room מטפל בכל השאר.

Room database: מפשט את העבודה עם מסד הנתונים ומשמש כנקודת גישה למסד הנתונים הבסיסי של SQLite (מסתיר את SQLiteOpenHelper). מסד הנתונים של Room משתמש ב-DAO כדי להנפיק שאילתות למסד הנתונים של SQLite.

מאגר: מחלקה שיוצרים ומשמשת בעיקר לניהול של כמה מקורות נתונים.

ViewModel: משמש כמרכז תקשורת בין Repository (נתונים) לבין ממשק המשתמש. ממשק המשתמש לא צריך יותר לדאוג למקור הנתונים. מופעי ViewModel שורדים יצירה מחדש של Activity או Fragment.

LiveData: מחזיק נתונים שאפשר לצפות בו. תמיד מחזיק/מטמין את הגרסה האחרונה של הנתונים, ומודיע לצופים שלו כשהנתונים משתנים. LiveData מודע למחזור החיים. רכיבי ממשק המשתמש רק מתבוננים בנתונים הרלוונטיים, ולא מפסיקים או מחדשים את ההתבוננות. ‫LiveData מנהל את כל זה באופן אוטומטי, כי הוא מודע לשינויים הרלוונטיים בסטטוס מחזור החיים בזמן ההמתנה.

סקירה כללית על הארכיטקטורה של RoomWordSample

בתרשים הבא מוצגים כל החלקים של האפליקציה. כל אחת מהתיבות המקיפות (חוץ ממסד הנתונים של SQLite) מייצגת מחלקה שתיצרו.

  1. פותחים את Android Studio ולוחצים על Start a new Android Studio project (התחלת פרויקט חדש ב-Android Studio).
  2. בחלון Create New Project (יצירת פרויקט חדש), בוחרים באפשרות Empty Activity (פעילות ריקה) ולוחצים על Next (הבא).
  3. במסך הבא, נותנים לאפליקציה את השם RoomWordSample ולוחצים על Finish (סיום).

בשלב הבא, צריך להוסיף את ספריות הרכיבים לקובצי Gradle.

  1. ב-Android Studio, לוחצים על הכרטיסייה Projects (פרויקטים) ומרחיבים את התיקייה Gradle Scripts (תסריטי Gradle).

פותחים את build.gradle (Module: app).

  1. מוסיפים את הפלאגין kapt annotation processor Kotlin אחרי הפלאגינים האחרים שמוגדרים בחלק העליון של הקובץ build.gradle (Module: app).
apply plugin: 'kotlin-kapt'
  1. מוסיפים את הבלוק packagingOptions בתוך הבלוק android כדי להחריג את מודול הפונקציות האטומית מהחבילה ולמנוע אזהרות.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. מוסיפים את הקוד הבא בסוף הבלוק dependencies.
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

// Kotlin components
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  1. בקובץ build.gradle (Project: RoomWordsSample), מוסיפים את מספרי הגרסאות לסוף הקובץ, כמו שמופיע בקוד שלמטה.
ext {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

הנתונים באפליקציה הזו הם מילים, ותצטרכו טבלה פשוטה כדי לאחסן את הערכים האלה:

ב-Room אפשר ליצור טבלאות באמצעות Entity. קדימה, נתחיל עכשיו.

  1. יוצרים קובץ מחלקה חדש של Kotlin בשם Word שמכיל את Word data class. ‫
    This class will describe the Entity (which represents the SQLite table) for your words. כל מאפיין במחלקה מייצג עמודה בטבלה. בסופו של דבר, Room ישתמש במאפיינים האלה כדי ליצור את הטבלה וליצור מופעים של אובייקטים משורות במסד הנתונים.

הנה הקוד:

data class Word(val word: String)

כדי שהכיתה Word תהיה משמעותית למסד נתונים של Room, צריך להוסיף לה הערות. ההערות מסבירות איך כל חלק בכיתה הזו קשור לרשומה במסד הנתונים. המידע הזה משמש את Room ליצירת קוד.

אם מקלידים את ההערות בעצמכם (במקום להדביק), Android Studio ייבא אוטומטית את מחלקות ההערות.

  1. מעדכנים את המחלקה Word באמצעות הערות כמו בקוד הזה:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

בואו נראה מה ההערות האלה עושות:

  • @Entity(tableName = "word_table")
    כל מחלקה @Entity מייצגת טבלת SQLite. מוסיפים הערה להצהרת הכיתה כדי לציין שמדובר בישות. אפשר לציין את שם הטבלה אם רוצים שהוא יהיה שונה משם הכיתה. השם של הטבלה יהיה word_table.
  • @PrimaryKey
    לכל ישות צריך להיות מפתח ראשי. כדי לפשט את העניינים, כל מילה משמשת כמפתח ראשי משלה.
  • @ColumnInfo(name = "word")
    מציין את שם העמודה בטבלה אם רוצים שהוא יהיה שונה משם משתנה החבר. הפעולה הזו נותנת לעמודה את השם word.
  • לכל נכס שמאוחסן במסד הנתונים צריכה להיות חשיפה ציבורית, שהיא ברירת המחדל ב-Kotlin.

רשימה מלאה של ההערות זמינה בחומר העזר בנושא סיכום חבילת החדר.

מה זה DAO?

ב-DAO (אובייקט גישה לנתונים), מציינים שאילתות SQL ומשייכים אותן לקריאות של שיטות. הקומפיילר בודק את ה-SQL ומפיק שאילתות מהערות נוחות לשאילתות נפוצות, כמו @Insert. ‫Room משתמש ב-DAO כדי ליצור API נקי לקוד שלכם.

ה-DAO חייב להיות ממשק או מחלקה מופשטת.

כברירת מחדל, כל השאילתות צריכות לפעול בשרשור נפרד.

‫Room תומך ב-coroutines, כך שאפשר להוסיף הערות לשאילתות עם המאפיין suspend ואז לקרוא להן מ-coroutine או מפונקציית השעיה אחרת.

הטמעת ה-DAO

נכתוב DAO שמספק שאילתות ל:

  • קבלת כל המילים בסדר אלפביתי
  • הוספת מילה
  • מחיקת כל המילים
  1. יוצרים קובץ מחלקה חדש של Kotlin בשם WordDao.
  2. מעתיקים את הקוד הבא ומדביקים אותו ב-WordDao, ומתקנים את ההצהרות על הייבוא לפי הצורך כדי שהקוד יתקמפל.
@Dao
interface WordDao {

    @Query("SELECT * from word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

בואו נעבור על זה:

  • WordDao הוא ממשק; אובייקטים מסוג DAO חייבים להיות ממשקים או מחלקות מופשטות.
  • ההערה @Dao מזהה אותו כסוג DAO עבור Room.
  • suspend fun insert(word: Word) : מכריז על פונקציית השהיה להוספת מילה אחת.
  • ההערה @Insert היא הערה מיוחדת של שיטת DAO שבה לא צריך לספק SQL. (יש גם הערות @Delete ו-@Update למחיקה ולעדכון של שורות, אבל אתם לא משתמשים בהן באפליקציה הזו).
  • onConflict = OnConflictStrategy.IGNORE: האסטרטגיה שנבחרה לטיפול במקרים של התנגשות מתעלמת ממילה חדשה אם היא זהה למילה שכבר נמצאת ברשימה. מידע נוסף על אסטרטגיות לפתרון קונפליקטים זמין במאמרי העזרה.
  • suspend fun deleteAll(): הצהרה על פונקציית השהיה למחיקת כל המילים.
  • אין הערה נוחה למחיקה של כמה ישויות, ולכן היא מסומנת בהערה הכללית @Query.
  • @Query("DELETE FROM word_table"): ‫@Query מחייב לספק שאילתת SQL כפרמטר מחרוזת להערה, וכך מאפשר שאילתות קריאה מורכבות ופעולות אחרות.
  • fun getAlphabetizedWords(): List<Word>: שיטה לקבלת כל המילים והחזרת List של Words.
  • @Query("SELECT * from word_table ORDER BY word ASC"): שאילתה שמחזירה רשימה של מילים ממוינות בסדר עולה.

כשנתונים משתנים, בדרך כלל רוצים לבצע פעולה כלשהי, כמו הצגת הנתונים המעודכנים בממשק המשתמש. כלומר, צריך לעקוב אחרי הנתונים כדי שאפשר יהיה להגיב כשהם משתנים.

התהליך הזה יכול להיות מסובך, בהתאם לאופן שבו הנתונים מאוחסנים. מעקב אחרי שינויים בנתונים בכמה רכיבים של האפליקציה יכול ליצור נתיבי תלות ברורים ונוקשים בין הרכיבים. בין היתר, קשה לבצע בדיקות ולנפות באגים.

LiveData, מחלקה של ספריית מחזור חיים למעקב אחרי נתונים פותרת את הבעיה הזו. משתמשים בערך החזרה מהסוג LiveData בתיאור השיטה, ו-Room יוצר את כל הקוד הדרוש לעדכון LiveData כשהמסד הנתונים מתעדכן.

ב-WordDao, משנים את חתימת השיטה getAlphabetizedWords() כך שהערך List<Word> שמוחזר יהיה עטוף ב-LiveData.

   @Query("SELECT * from word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): LiveData<List<Word>>

בהמשך ה-codelab הזה, תעקבו אחרי שינויים בנתונים באמצעות Observer ב-MainActivity.

מהו מסד נתונים של Room?

  • ‫Room היא שכבת מסד נתונים מעל מסד נתונים של SQLite.
  • החדר מטפל במשימות שגרתיות שבעבר טיפלתם בהן באמצעות SQLiteOpenHelper.
  • האפליקציה Room משתמשת ב-DAO כדי להנפיק שאילתות למסד הנתונים שלה.
  • כדי למנוע ביצועים נמוכים של ממשק המשתמש, כברירת מחדל, Room לא מאפשרת להריץ שאילתות בשרשור הראשי. כששאילתות לגבי חדרים מחזירות LiveData, השאילתות מורצות באופן אוטומטי באופן אסינכרוני בשרשור ברקע.
  • ‫Room מספק בדיקות בזמן ההידור של הצהרות SQLite.

הטמעה של מסד הנתונים Room

הכיתה של מסד הנתונים של Room צריכה להיות מופשטת ולהיות הרחבה של RoomDatabase. בדרך כלל צריך רק מופע אחד של מסד נתונים של Room לכל האפליקציה.

רוצה ליצור אחד עכשיו?

  1. יוצרים קובץ מחלקה של Kotlin בשם WordRoomDatabase ומוסיפים לו את הקוד הבא:
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time. 
        @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
            }
        }
   }
}

בואו נסביר את הקוד:

  • המחלקות של מסד הנתונים ב-Room חייבות להיות abstract ולהרחיב את RoomDatabase
  • מוסיפים הערה ל-class כדי להגדיר אותו כמסד נתונים של Room באמצעות @Database, ומשתמשים בפרמטרים של ההערה כדי להצהיר על הישויות ששייכות למסד הנתונים ולהגדיר את מספר הגרסה. כל ישות תואמת לטבלה שתיצור במסד הנתונים. העברות של מסדי נתונים הן מעבר להיקף של ה-codelab הזה, לכן הגדרנו את exportSchema כ-false כדי להימנע מאזהרה לגבי build. באפליקציה אמיתית, כדאי להגדיר ספרייה לשימוש ב-Room כדי לייצא את הסכימה, וכך תוכלו לבדוק את הסכימה הנוכחית במערכת בקרת הגרסאות.
  • מסד הנתונים חושף את אובייקטי ה-DAO באמצעות שיטת getter מופשטת לכל @Dao.
  • הגדרנו סינגלטון, WordRoomDatabase, כדי למנוע פתיחה של כמה מופעים של מסד הנתונים בו-זמנית.
  • getDatabase מחזירה את הסינגלטון. בפעם הראשונה שתיגשו למסד הנתונים, הוא ייצור אותו באמצעות כלי בניית מסד הנתונים של Room כדי ליצור אובייקט RoomDatabase בהקשר של האפליקציה מהמחלקה WordRoomDatabase, וייתן לו את השם "word_database".

מה זה מאגר?

מחלקת מאגר מפשטת את הגישה למספר מקורות נתונים. המאגר לא נכלל בספריות של רכיבי הארכיטקטורה, אבל הוא שיטה מומלצת להפרדה בין קוד לארכיטקטורה. מחלקת Repository מספקת API נקי לגישה לנתונים לשאר האפליקציה.

למה כדאי להשתמש במאגר?

מאגר מאפשר לנהל שאילתות ולהשתמש בכמה קצה עורפי. בדוגמה הנפוצה ביותר, Repository מטמיע את הלוגיקה להחלטה אם לאחזר נתונים מרשת או להשתמש בתוצאות ששמורות במטמון במסד נתונים מקומי.

הטמעה של מאגר

יוצרים קובץ מחלקה של Kotlin בשם WordRepository ומדביקים בו את הקוד הבא:

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

המסקנות העיקריות:

  • אובייקט ה-DAO מועבר לקונסטרוקטור של המאגר ולא כל מסד הנתונים. הסיבה לכך היא שהיא צריכה גישה רק ל-DAO, כי ה-DAO מכיל את כל שיטות הקריאה והכתיבה של מסד הנתונים. אין צורך לחשוף את כל מסד הנתונים למאגר.
  • רשימת המילים היא מאפיין ציבורי. הוא מאותחל על ידי קבלת רשימת המילים LiveData מ-Room. אנחנו יכולים לעשות את זה כי הגדרנו את השיטה getAlphabetizedWords להחזרת LiveData בשלב 'המחלקות של LiveData'. ספריית Room מריצה את כל השאילתות בשרשור נפרד. לאחר מכן, הפונקציה LiveData תודיע לצופה בשרשור הראשי כשהנתונים ישתנו.
  • המשנה suspend אומר לקומפיילר שצריך לקרוא לפונקציה הזו מתוך קורוטינה או פונקציית השהיה אחרת.

מה זה ViewModel?

התפקיד של ViewModel הוא לספק נתונים לממשק המשתמש ולשמור אותם גם אחרי שינויים בהגדרות. ViewModel משמש כמרכז תקשורת בין המאגר לבין ממשק המשתמש. אפשר גם להשתמש ב-ViewModel כדי לשתף נתונים בין רכיבי Fragment. ה-ViewModel הוא חלק מספריית מחזור החיים.

מדריך מבוא בנושא הזה זמין במאמר ViewModel Overview או בפוסט בבלוג ViewModels: A Simple Example.

למה כדאי להשתמש ב-ViewModel?

ViewModel מכיל את נתוני ממשק המשתמש של האפליקציה באופן שמתחשב במחזור החיים של האפליקציה, כך שהנתונים נשמרים גם אחרי שינויים בהגדרות. הפרדת נתוני ממשק המשתמש של האפליקציה מהמחלקות Activity ו-Fragment מאפשרת לכם לעקוב טוב יותר אחרי עקרון האחריות היחידה: הפעילויות והקטעים אחראים לציור הנתונים במסך, ואילו המחלקה ViewModel יכולה לטפל בהחזקה ובעיבוד של כל הנתונים שנדרשים לממשק המשתמש.

ב-ViewModel, משתמשים ב-LiveData לנתונים שניתנים לשינוי שממשק המשתמש ישתמש בהם או יציג אותם. יש כמה יתרונות לשימוש במאפיין LiveData:

  • אפשר להגדיר מעקב אחרי הנתונים (במקום לבדוק שינויים שוב ושוב) ולעדכן את
    ממשק המשתמש רק כשהנתונים משתנים בפועל.
  • המאגר וממשק המשתמש מופרדים לחלוטין על ידי ViewModel.
  • אין קריאות למסד הנתונים מ-ViewModel (הכול מטופל במאגר), ולכן קל יותר לבדוק את הקוד.

viewModelScope

ב-Kotlin, כל הקורוטינות פועלות בתוך CoroutineScope. היקף קובע את משך החיים של קורוטינות באמצעות המשימה שלהן. כשמבטלים את העבודה של היקף, כל הקורוטינות שהופעלו בהיקף הזה מבוטלות.

ספריית AndroidX lifecycle-viewmodel-ktx מוסיפה את viewModelScope כפונקציית הרחבה של המחלקה ViewModel, ומאפשרת לכם לעבוד עם היקפים.

כדי לקבל מידע נוסף על עבודה עם קורוטינות ב-ViewModel, אפשר לעיין בשלב 5 של ה-codelab בנושא שימוש בקורוטינות של Kotlin באפליקציית Android או בפוסט בבלוג בנושא קורוטינות פשוטות ב-Android: viewModelScope.

הטמעה של ViewModel

יוצרים קובץ מחלקה של Kotlin בשם WordViewModel ומוסיפים לו את הקוד הבא:

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch(Dispatchers.IO) {
        repository.insert(word)
    }
}

בשלב הזה:

  • יצרנו מחלקה בשם WordViewModel שמקבלת את Application כפרמטר ומרחיבה את AndroidViewModel.
  • נוסף משתנה חבר פרטי כדי להחזיק הפניה למאגר.
  • נוספה משתנה חבר ציבורי LiveData כדי לשמור במטמון את רשימת המילים.
  • יצרתם בלוק init שמקבל הפניה ל-WordDao מ-WordRoomDatabase.
  • בבלוק init, המערכת בנתה את WordRepository על סמך WordRoomDatabase.
  • בבלוק init, מאתחלים את allWords LiveData באמצעות המאגר.
  • נוצרת שיטת wrapper‏ insert() שקוראת לשיטה insert() של Repository. כך ההטמעה של insert() מוסתרת מממשק המשתמש. אנחנו לא רוצים שהוספה תחסום את השרשור הראשי, לכן אנחנו מפעילים קורוטינה חדשה ומפעילים את הפונקציה insert של המאגר, שהיא פונקציית השהיה. כמו שציינו, ל-ViewModels יש היקף של קורוטינה שמבוסס על מחזור החיים שלהם, שנקרא viewModelScope, ואנחנו משתמשים בו כאן.

בשלב הבא, צריך להוסיף את פריסת ה-XML לרשימה ולפריטים.

בשיעור הזה אנחנו מניחים שאתם יודעים איך ליצור פריסות ב-XML, ולכן אנחנו מספקים לכם רק את הקוד.

כדי להגדיר את העיצוב של האפליקציה כעיצוב חומרי, מגדירים את AppTheme parent לערך Theme.MaterialComponents.Light.DarkActionBar. מוסיפים סגנון לפריטים ברשימה ב-values/styles.xml:

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

הוספת פריסת layout/recyclerview_item.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" 
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

ב-layout/activity_main.xml, מחליפים את TextView ב-RecyclerView ומוסיפים כפתור פעולה צף (FAB). עכשיו הפריסה אמורה להיראות כך:

<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"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

המראה של ה-FAB צריך להתאים לפעולה הזמינה, ולכן נרצה להחליף את הסמל בסמל '+'.

קודם צריך להוסיף נכס וקטורי חדש:

  1. בוחרים באפשרות File > New > Vector Asset (קובץ > חדש > נכס וקטורי).
  2. לוחצים על סמל הרובוט של Android בשדה Clip Art: �
  3. מחפשים את הנכס '+' ובוחרים בו. לוחצים על אישור
  4. אחר כך לוחצים על הבא.
  5. מאשרים את נתיב הסמל כ-main > drawable ולוחצים על סיום כדי להוסיף את הנכס.
  6. עדיין ב-layout/activity_main.xml, מעדכנים את כפתור הפעולה הצף כך שיכלול את ה-drawable החדש:
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

אתם הולכים להציג את הנתונים בRecyclerView, שזה קצת יותר נחמד מאשר פשוט להציג את הנתונים בTextView. ב-codelab הזה אנחנו מניחים שאתם יודעים איך RecyclerView,‏ RecyclerView.LayoutManager,‏ RecyclerView.ViewHolder ו-RecyclerView.Adapter פועלים.

שימו לב שהנתונים נשמרים במטמון של המתאם במשתנה words. במשימה הבאה מוסיפים את הקוד שמעדכן את הנתונים באופן אוטומטי.

יוצרים קובץ מחלקה של Kotlin בשם WordListAdapter שמרחיב את RecyclerView.Adapter. הנה הקוד:

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

    inner class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val wordItemView: TextView = itemView.findViewById(R.id.textView)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

מוסיפים את RecyclerView בשיטת onCreate() של MainActivity.

בשיטה onCreate() אחרי setContentView:

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter(this)
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

מפעילים את האפליקציה כדי לוודא שהכול עובד. אין פריטים כי עדיין לא קישרתם את הנתונים.

אין נתונים במסד הנתונים. תוכלו להוסיף נתונים בשתי דרכים: להוסיף נתונים מסוימים כשפותחים את מסד הנתונים, ולהוסיף Activity כדי להוסיף מילים.

כדי למחוק את כל התוכן ולאכלס מחדש את מסד הנתונים בכל הפעלה של האפליקציה, יוצרים RoomDatabase.Callback ומבטלים את onOpen(). מכיוון שאי אפשר לבצע פעולות במסד נתונים של Room בשרשור של ממשק המשתמש, onOpen() מפעיל קורוטינה ב-IO Dispatcher.

כדי להפעיל קורוטינה, צריך CoroutineScope. מעדכנים את שיטת getDatabase של המחלקה WordRoomDatabase, כדי לקבל גם היקף של קורוטינה כפרמטר:

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

מעדכנים את מאתחל השליפה של מסד הנתונים בבלוק init של WordViewModel כדי להעביר גם את ההיקף:

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

בדוגמה WordRoomDatabase, אנחנו יוצרים הטמעה מותאמת אישית של RoomDatabase.Callback(), שמקבלת גם CoroutineScope כפרמטר של בנאי. לאחר מכן, אנחנו מבטלים את השיטה onOpen כדי לאכלס את מסד הנתונים.

זה הקוד ליצירת פונקציית הקריאה החוזרת בתוך המחלקה WordRoomDatabase:

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

לבסוף, מוסיפים את הקריאה החוזרת לרצף בניית מסד הנתונים ממש לפני הקריאה ל-.build() ב-Room.databaseBuilder():

.addCallback(WordDatabaseCallback(scope))

כך צריך להיראות הקוד הסופי:

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

   companion object {
       @Volatile
       private var INSTANCE: WordRoomDatabase? = null

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

מוסיפים את משאבי המחרוזות האלה ב-values/strings.xml:

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>

מוסיפים את משאב הצבע הזה ב-value/colors.xml:

<color name="buttonLabel">#FFFFFF</color>

יוצרים קובץ חדש של משאבי מאפיינים:

  1. לוחצים על מודול האפליקציה בחלון Project (פרויקט).
  2. בוחרים באפשרות File > New > Android Resource File (קובץ > חדש > קובץ משאבים של Android).
  3. בקטע 'מסננים זמינים', בוחרים באפשרות מאפיין .
  4. מגדירים את שם הקובץ: dimens

מוסיפים את משאבי המימדים האלה ב-values/dimens.xml:

<dimen name="small_padding">8dp</dimen>
<dimen name="big_padding">16dp</dimen>

יוצרים פרויקט Android ריק חדש Activity באמצעות התבנית Empty Activity (פעילות ריקה):

  1. בוחרים באפשרות קובץ > חדש > פעילות > פעילות ריקה.
  2. מזינים NewWordActivity בשדה 'שם הפעילות'.
  3. מוודאים שהפעילות החדשה נוספה ל-Android Manifest.
<activity android:name=".NewWordActivity"></activity>

מעדכנים את הקובץ activity_new_word.xml בתיקיית הפריסה באמצעות הקוד הבא:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

מעדכנים את הקוד של הפעילות:

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

השלב האחרון הוא לחבר את ממשק המשתמש למסד הנתונים על ידי שמירת מילים חדשות שהמשתמש מזין והצגת התוכן הנוכחי של מסד הנתונים של המילים ב-RecyclerView.

כדי להציג את התוכן הנוכחי של מסד הנתונים, מוסיפים משקיף שמשקיף על LiveData ב-ViewModel.

בכל פעם שהנתונים משתנים, מופעלת קריאה חוזרת (callback) של onChanged(), שמפעילה את השיטה setWords() של המתאם כדי לעדכן את הנתונים ששמורים במטמון של המתאם ולרענן את הרשימה שמוצגת.

ב-MainActivity, יוצרים משתנה חבר עבור ViewModel:

private lateinit var wordViewModel: WordViewModel

אפשר להשתמש ב-ViewModelProvider כדי לשייך את ViewModel ל-Activity.

כשActivity מתחיל לפעול בפעם הראשונה, ViewModelProviders יוצר את ViewModel. כשפעילות נהרסת, למשל בגלל שינוי בהגדרות, ViewModel נשמר. כשיוצרים מחדש את הפעילות, הפונקציה ViewModelProviders מחזירה את הערך הקיים ViewModel. מידע נוסף זמין במאמר ViewModel.

ב-onCreate() מתחת לבלוק הקוד RecyclerView, מקבלים ViewModel מ-ViewModelProvider:

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

בנוסף, ב-onCreate(), מוסיפים משקיף למאפיין allWords LiveData מ-WordViewModel.

השיטה onChanged() (שיטת ברירת המחדל של Lambda שלנו) מופעלת כשהנתונים שנצפו משתנים והפעילות היא בחזית:

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.setWords(it) }
})

אנחנו רוצים לפתוח את NewWordActivity כשמקישים על הלחצן הראשי לפעולה, וכשחוזרים ל-MainActivity, להוסיף את המילה החדשה למסד הנתונים או להציג Toast. כדי לעשות את זה, נתחיל בהגדרת קוד בקשה:

private val newWordActivityRequestCode = 1

ב-MainActivity, מוסיפים את הקוד onActivityResult() של NewWordActivity.

אם הפעילות מחזירה את הערך RESULT_OK, מוסיפים את המילה שהוחזרה למסד הנתונים על ידי קריאה ל-method‏ insert() של WordViewModel:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

ב-MainActivity,start NewWordActivity כשמשתמש מקיש על הלחצן הצף. ב-MainActivity onCreate, מחפשים את ה-FAB ומוסיפים onClickListener עם הקוד הבא:

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

הקוד הסופי אמור להיראות כך:

class MainActivity : AppCompatActivity() {

   private const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)
       setSupportActionBar(toolbar)

       val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
       val adapter = WordListAdapter(this)
       recyclerView.adapter = adapter
       recyclerView.layoutManager = LinearLayoutManager(this)

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(it) }
       })

       val fab = findViewById<FloatingActionButton>(R.id.fab)
       fab.setOnClickListener {
           val intent = Intent(this@MainActivity, NewWordActivity::class.java)
           startActivityForResult(intent, newWordActivityRequestCode)
       }
   }

   override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
       super.onActivityResult(requestCode, resultCode, data)

       if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
           data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
               val word = Word(it)
               wordViewModel.insert(word)
           }
       } else {
           Toast.makeText(
               applicationContext,
               R.string.empty_not_saved,
               Toast.LENGTH_LONG).show()
       }
   }
}

עכשיו מפעילים את האפליקציה. כשמוסיפים מילה למסד הנתונים ב-NewWordActivity, ממשק המשתמש מתעדכן באופן אוטומטי.

עכשיו, כשיש לך אפליקציה עובדת, נסכם את מה שבנית. הנה שוב מבנה האפליקציה:

הרכיבים של האפליקציה הם:

  • MainActivity: הצגת מילים ברשימה באמצעות התג RecyclerView והתג WordListAdapter. ב-MainActivity יש Observer שעוקב אחרי המילים LiveData במסד הנתונים ומקבל התראה כשהן משתנות.
  • NewWordActivity: מוסיף מילה חדשה לרשימה.
  • WordViewModel: מספק שיטות לגישה לשכבת הנתונים, ומחזיר LiveData כדי ש-MainActivity יוכל להגדיר את יחסי הצופה.*
  • LiveData<List<Word>>: מאפשר עדכונים אוטומטיים ברכיבי ממשק המשתמש. ב-MainActivity, יש Observer שעוקב אחרי המילים LiveData במסד הנתונים ומקבל התראה כשהן משתנות.
  • Repository: מנהל מקור נתונים אחד או יותר. ה-Repository חושף שיטות שמאפשרות ל-ViewModel ליצור אינטראקציה עם ספק הנתונים הבסיסי. באפליקציה הזו, הקצה העורפי הוא מסד נתונים של Room.
  • Room: הוא wrapper סביב מסד נתונים של SQLite ומיישם אותו. ב-Room, הרבה פעולות שפעם הייתם צריכים לעשות בעצמכם מתבצעות אוטומטית.
  • ‫DAO: ממפה קריאות לשיטות לשאילתות במסד נתונים, כך שכאשר מאגר קורא לשיטה כמו getAlphabetizedWords(), ‏ Room יכול להריץ את SELECT * from word_table ORDER BY word ASC.
  • Word: היא מחלקת הישות שמכילה מילה אחת.

‫* Views ו-Activities (וגם Fragments) מקיימים אינטראקציה עם הנתונים רק דרך ViewModel. לכן, לא משנה מאיפה הנתונים מגיעים.

זרימת הנתונים לעדכונים אוטומטיים של ממשק המשתמש (ממשק משתמש תגובתי)

העדכון האוטומטי אפשרי כי אנחנו משתמשים ב-LiveData. ב-MainActivity, יש Observer שעוקב אחרי המילים LiveData במסד הנתונים ומקבל התראה כשהן משתנות. כשמתרחש שינוי, השיטה onChange() של האובייקט המתבונן מופעלת והעדכון mWords מתבצע ב-WordListAdapter.

אפשר לראות את הנתונים כי הם LiveData. הערך שנצפה הוא LiveData<List<Word>> שמוחזר על ידי המאפיין WordViewModel allWords.

ה-WordViewModel מסתיר את כל מה שקשור לחלק האחורי משכבת ממשק המשתמש. הוא מספק שיטות לגישה לשכבת הנתונים, ומחזיר LiveData כדי ש-MainActivity יוכל להגדיר את יחסי הצופה. ‫Views ו-Activities (וגם Fragments) יכולים ליצור אינטראקציה עם הנתונים רק דרך ViewModel. לכן, לא משנה מאיפה הנתונים מגיעים.

במקרה הזה, הנתונים מגיעים מRepository. ViewModel לא צריך לדעת עם מה המאגר הזה מתקשר. היא רק צריכה לדעת איך ליצור אינטראקציה עם Repository, וזה נעשה באמצעות השיטות שמוצגות על ידי Repository.

מאגר המידע מנהל מקור נתונים אחד או יותר. באפליקציית WordListSample, ה-backend הזה הוא מסד נתונים של Room. ‫Room הוא wrapper שמקיף מסד נתונים של SQLite ומיישם אותו. ב-Room, הרבה פעולות שפעם הייתם צריכים לעשות בעצמכם מתבצעות אוטומטית. לדוגמה, ב-Room אפשר לעשות את כל מה שאפשר היה לעשות בכיתה ב-SQLiteOpenHelper.

ה-DAO ממפה קריאות לשיטות לשאילתות במסד הנתונים, כך שכשהמאגר קורא לשיטה כמו getAllWords(), ‏ Room יכול להריץ את SELECT * from word_table ORDER BY word ASC.

מכיוון שהתוצאה שמוחזרת מהשאילתה היא LiveData, בכל פעם שהנתונים ב-Room משתנים, השיטה onChanged() של הממשק Observer מופעלת וממשק המשתמש מתעדכן.

[אופציונלי] הורדת קוד הפתרון

אם עדיין לא עשיתם את זה, אתם יכולים לעיין בקוד הפתרון של ה-codelab. אפשר לעיין במאגר GitHub או להוריד את הקוד כאן:

הורדת קוד המקור

מחלצים את קובץ ה-ZIP שהורד. הפעולה הזו תגרום לפריקת תיקיית בסיס, android-room-with-a-view-kotlin, שמכילה את האפליקציה המלאה.