קריאה וכתיבה של נתונים ב-Android

במאמר הזה מוסבר איך לקרוא נתונים מ-Firebase ולכתוב נתונים ל-Firebase.

הנתונים של Firebase נכתבים בהפניה FirebaseDatabase ומאוחזרים על ידי צירוף מאזין אסינכרוני להפניה. המאזין מופעל פעם אחת עבור המצב הראשוני של הנתונים, ושוב בכל פעם שהנתונים משתנים.

(אופציונלי) יצירת אב טיפוס ובדיקה באמצעות Firebase Local Emulator Suite

לפני שנסביר איך האפליקציה קוראת מ-Realtime Database וכותבת ל-Realtime Database, נציג קבוצה של כלים שבהם אפשר להשתמש כדי ליצור אב טיפוס ולבדוק את הפונקציונליות של Realtime Database: Firebase Local Emulator Suite. אם אתם מנסים מודלים שונים של נתונים, מבצעים אופטימיזציה של כללי האבטחה או מנסים למצוא את הדרך הכי חסכונית ליצור אינטראקציה עם ה-Back-end, כדאי לעבוד באופן מקומי בלי לפרוס שירותים פעילים.

Realtime Database אמולטור הוא חלק מ-Local Emulator Suite, שמאפשר לאפליקציה שלכם ליצור אינטראקציה עם התוכן וההגדרות של מסד הנתונים המדומה, וגם עם משאבי הפרויקט המדומים (פונקציות, מסדי נתונים אחרים וכללי אבטחה).

השימוש באמולטור Realtime Database מתבצע בכמה שלבים פשוטים:

  1. הוספת שורת קוד להגדרת הבדיקה של האפליקציה כדי להתחבר לאמולטור.
  2. מהספרייה הראשית של פרויקט מקומי, מריצים את הפקודה firebase emulators:start.
  3. ביצוע קריאות מקוד האב-טיפוס של האפליקציה באמצעות Realtime Database SDK של פלטפורמהRealtime Database כרגיל, או באמצעות Realtime Database REST API.

כאן אפשר למצוא הסבר מפורט על Realtime Database ועל Cloud Functions. מומלץ גם לעיין Local Emulator Suite במבוא.

קבלת DatabaseReference

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

Kotlin

private lateinit var database: DatabaseReference
// ...
database = Firebase.database.reference

Java

private DatabaseReference mDatabase;
// ...
mDatabase = FirebaseDatabase.getInstance().getReference();

כתיבת נתונים

פעולות כתיבה בסיסיות

בפעולות כתיבה בסיסיות, אפשר להשתמש ב-setValue() כדי לשמור נתונים בהפניה שצוינה, ולהחליף את הנתונים הקיימים בנתיב הזה. אתם יכולים להשתמש בשיטה הזו כדי:

  • סוגי הכרטיסים שתואמים לסוגי ה-JSON הזמינים, באופן הבא:
    • String
    • Long
    • Double
    • Boolean
    • Map<String, Object>
    • List<Object>
  • העברה של אובייקט Java בהתאמה אישית, אם למחלקה שמגדירה אותו יש בנאי ברירת מחדל שלא מקבל ארגומנטים, ויש לה שיטות getter ציבוריות למאפיינים שיוקצו.

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

Kotlin

@IgnoreExtraProperties
data class User(val username: String? = null, val email: String? = null) {
    // Null default values create a no-argument default constructor, which is needed
    // for deserialization from a DataSnapshot.
}

Java

@IgnoreExtraProperties
public class User {

    public String username;
    public String email;

    public User() {
        // Default constructor required for calls to DataSnapshot.getValue(User.class)
    }

    public User(String username, String email) {
        this.username = username;
        this.email = email;
    }

}

כדי להוסיף משתמש עם setValue():

Kotlin

fun writeNewUser(userId: String, name: String, email: String) {
    val user = User(name, email)

    database.child("users").child(userId).setValue(user)
}

Java

public void writeNewUser(String userId, String name, String email) {
    User user = new User(name, email);

    mDatabase.child("users").child(userId).setValue(user);
}

שימוש ב-setValue() באופן הזה מחליף את הנתונים במיקום שצוין, כולל כל צמתי הצאצא. אבל עדיין אפשר לעדכן את הצאצא בלי לשכתב את כל האובייקט. אם רוצים לאפשר למשתמשים לעדכן את הפרופילים שלהם, אפשר לעדכן את שם המשתמש באופן הבא:

Kotlin

database.child("users").child(userId).child("username").setValue(name)

Java

mDatabase.child("users").child(userId).child("username").setValue(name);

קריאת נתונים

קריאת נתונים באמצעות מאזינים מתמידים

כדי לקרוא נתונים בנתיב ולהאזין לשינויים, משתמשים בשיטה addValueEventListener() כדי להוסיף ValueEventListener ל-DatabaseReference.

Listener התקשרות חוזרת לאירוע שימוש אופייני
ValueEventListener onDataChange() קריאה והאזנה לשינויים בכל התוכן של נתיב.

אפשר להשתמש בשיטה onDataChange() כדי לקרוא תמונה סטטית של התוכן בנתיב נתון, כפי שהיה קיים בזמן האירוע. השיטה הזו מופעלת פעם אחת כשהמאזין מצורף, ושוב בכל פעם שהנתונים, כולל הנתונים של ילדים, משתנים. פונקציית הקריאה החוזרת של האירוע מקבלת תמונת מצב שמכילה את כל הנתונים במיקום הזה, כולל נתוני צאצא. אם אין נתונים, הפעולה false תוחזר כשמתקשרים אל exists(), והפעולה null תוחזר כשמתקשרים אל getValue().

בדוגמה הבאה מוצגת אפליקציית בלוגים ברשתות החברתיות ששולפת את פרטי הפוסט ממסד הנתונים:

Kotlin

val postListener = object : ValueEventListener {
    override fun onDataChange(dataSnapshot: DataSnapshot) {
        // Get Post object and use the values to update the UI
        val post = dataSnapshot.getValue<Post>()
        // ...
    }

    override fun onCancelled(databaseError: DatabaseError) {
        // Getting Post failed, log a message
        Log.w(TAG, "loadPost:onCancelled", databaseError.toException())
    }
}
postReference.addValueEventListener(postListener)

Java

ValueEventListener postListener = new ValueEventListener() {
    @Override
    public void onDataChange(DataSnapshot dataSnapshot) {
        // Get Post object and use the values to update the UI
        Post post = dataSnapshot.getValue(Post.class);
        // ..
    }

    @Override
    public void onCancelled(DatabaseError databaseError) {
        // Getting Post failed, log a message
        Log.w(TAG, "loadPost:onCancelled", databaseError.toException());
    }
};
mPostReference.addValueEventListener(postListener);

המאזין מקבל DataSnapshot שמכיל את הנתונים במיקום שצוין במסד הנתונים בזמן האירוע. הפעלת getValue() על תמונת מצב מחזירה את הייצוג של הנתונים כאובייקט Java. אם אין נתונים במיקום, הקריאה ל-getValue() מחזירה null.

בדוגמה הזו, ValueEventListener מגדיר גם את השיטה onCancelled() שמופעלת אם הקריאה מבוטלת. לדוגמה, אפשר לבטל קריאה אם ללקוח אין הרשאה לקרוא ממיקום במסד נתונים של Firebase. השיטה הזו מקבלת אובייקט DatabaseError שמציין למה ההעלאה נכשלה.

קריאת נתונים פעם אחת

קריאה חד-פעמית באמצעות get()

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

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

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

שימוש לא נחוץ ב-get() עלול להגדיל את השימוש ברוחב הפס ולהוביל לירידה בביצועים. כדי למנוע זאת, אפשר להשתמש במאזין בזמן אמת כמו בדוגמה שלמעלה.

Kotlin

mDatabase.child("users").child(userId).get().addOnSuccessListener {
    Log.i("firebase", "Got value ${it.value}")
}.addOnFailureListener{
    Log.e("firebase", "Error getting data", it)
}

Java

mDatabase.child("users").child(userId).get().addOnCompleteListener(new OnCompleteListener<DataSnapshot>() {
    @Override
    public void onComplete(@NonNull Task<DataSnapshot> task) {
        if (!task.isSuccessful()) {
            Log.e("firebase", "Error getting data", task.getException());
        }
        else {
            Log.d("firebase", String.valueOf(task.getResult().getValue()));
        }
    }
});

קריאה חד-פעמית באמצעות מאזין

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

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

עדכון או מחיקה של נתונים

עדכון שדות ספציפיים

כדי לכתוב בו-זמנית לצאצאים ספציפיים של צומת בלי לדרוס צמתי צאצא אחרים, משתמשים בשיטה updateChildren().

כשמבצעים קריאה ל-updateChildren(), אפשר לעדכן ערכי צאצא ברמה נמוכה יותר על ידי ציון נתיב למפתח. אם הנתונים מאוחסנים בכמה מיקומים כדי לשפר את יכולת ההתאמה לגודל, אפשר לעדכן את כל המופעים של הנתונים האלה באמצעות פיצול נתונים. לדוגמה, לאפליקציה של בלוג חברתי יכולה להיות מחלקה Post כזו:

Kotlin

@IgnoreExtraProperties
data class Post(
    var uid: String? = "",
    var author: String? = "",
    var title: String? = "",
    var body: String? = "",
    var starCount: Int = 0,
    var stars: MutableMap<String, Boolean> = HashMap(),
) {

    @Exclude
    fun toMap(): Map<String, Any?> {
        return mapOf(
            "uid" to uid,
            "author" to author,
            "title" to title,
            "body" to body,
            "starCount" to starCount,
            "stars" to stars,
        )
    }
}

Java

@IgnoreExtraProperties
public class Post {

    public String uid;
    public String author;
    public String title;
    public String body;
    public int starCount = 0;
    public Map<String, Boolean> stars = new HashMap<>();

    public Post() {
        // Default constructor required for calls to DataSnapshot.getValue(Post.class)
    }

    public Post(String uid, String author, String title, String body) {
        this.uid = uid;
        this.author = author;
        this.title = title;
        this.body = body;
    }

    @Exclude
    public Map<String, Object> toMap() {
        HashMap<String, Object> result = new HashMap<>();
        result.put("uid", uid);
        result.put("author", author);
        result.put("title", title);
        result.put("body", body);
        result.put("starCount", starCount);
        result.put("stars", stars);

        return result;
    }
}

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

Kotlin

private fun writeNewPost(userId: String, username: String, title: String, body: String) {
    // Create new post at /user-posts/$userid/$postid and at
    // /posts/$postid simultaneously
    val key = database.child("posts").push().key
    if (key == null) {
        Log.w(TAG, "Couldn't get push key for posts")
        return
    }

    val post = Post(userId, username, title, body)
    val postValues = post.toMap()

    val childUpdates = hashMapOf<String, Any>(
        "/posts/$key" to postValues,
        "/user-posts/$userId/$key" to postValues,
    )

    database.updateChildren(childUpdates)
}

Java

private void writeNewPost(String userId, String username, String title, String body) {
    // Create new post at /user-posts/$userid/$postid and at
    // /posts/$postid simultaneously
    String key = mDatabase.child("posts").push().getKey();
    Post post = new Post(userId, username, title, body);
    Map<String, Object> postValues = post.toMap();

    Map<String, Object> childUpdates = new HashMap<>();
    childUpdates.put("/posts/" + key, postValues);
    childUpdates.put("/user-posts/" + userId + "/" + key, postValues);

    mDatabase.updateChildren(childUpdates);
}

בדוגמה הזו נעשה שימוש ב-push() כדי ליצור פוסט בצומת שמכיל פוסטים של כל המשתמשים ב-/posts/$postid, ובמקביל לאחזר את המפתח באמצעות getKey(). אחר כך אפשר להשתמש במפתח כדי ליצור רשומה שנייה בפוסטים של המשתמש בכתובת /user-posts/$userid/$postid.

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

הוספת קריאה חוזרת (callback) להשלמה

אם רוצים לדעת מתי הנתונים נשמרו, אפשר להוסיף listener להשלמה. הפונקציות setValue() ו-updateChildren() מקבלות מאזין אופציונלי לסיום, שמופעל כשהכתיבה בוצעה בהצלחה במסד הנתונים. אם הקריאה נכשלה, המאזין מקבל אובייקט שגיאה שמציין למה היא נכשלה.

Kotlin

database.child("users").child(userId).setValue(user)
    .addOnSuccessListener {
        // Write was successful!
        // ...
    }
    .addOnFailureListener {
        // Write failed
        // ...
    }

Java

mDatabase.child("users").child(userId).setValue(user)
        .addOnSuccessListener(new OnSuccessListener<Void>() {
            @Override
            public void onSuccess(Void aVoid) {
                // Write was successful!
                // ...
            }
        })
        .addOnFailureListener(new OnFailureListener() {
            @Override
            public void onFailure(@NonNull Exception e) {
                // Write failed
                // ...
            }
        });

מחיקת נתונים

הדרך הפשוטה ביותר למחוק נתונים היא לקרוא ל-removeValue() בהפניה למיקום של הנתונים האלה.

אפשר גם למחוק על ידי ציון null כערך לפעולת כתיבה אחרת, כמו setValue() או updateChildren(). אפשר להשתמש בטכניקה הזו עם updateChildren() כדי למחוק כמה ילדים בקריאת API אחת.

ניתוק של listeners

כדי להסיר קריאות חוזרות, קוראים לשיטה removeEventListener() בהפניה למסד הנתונים של Firebase.

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

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

שמירת נתונים כעסקאות

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

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

Kotlin

private fun onStarClicked(postRef: DatabaseReference) {
    // ...
    postRef.runTransaction(object : Transaction.Handler {
        override fun doTransaction(mutableData: MutableData): Transaction.Result {
            val p = mutableData.getValue(Post::class.java)
                ?: return Transaction.success(mutableData)

            if (p.stars.containsKey(uid)) {
                // Unstar the post and remove self from stars
                p.starCount = p.starCount - 1
                p.stars.remove(uid)
            } else {
                // Star the post and add self to stars
                p.starCount = p.starCount + 1
                p.stars[uid] = true
            }

            // Set value and report transaction success
            mutableData.value = p
            return Transaction.success(mutableData)
        }

        override fun onComplete(
            databaseError: DatabaseError?,
            committed: Boolean,
            currentData: DataSnapshot?,
        ) {
            // Transaction completed
            Log.d(TAG, "postTransaction:onComplete:" + databaseError!!)
        }
    })
}

Java

private void onStarClicked(DatabaseReference postRef) {
    postRef.runTransaction(new Transaction.Handler() {
        @NonNull
        @Override
        public Transaction.Result doTransaction(@NonNull MutableData mutableData) {
            Post p = mutableData.getValue(Post.class);
            if (p == null) {
                return Transaction.success(mutableData);
            }

            if (p.stars.containsKey(getUid())) {
                // Unstar the post and remove self from stars
                p.starCount = p.starCount - 1;
                p.stars.remove(getUid());
            } else {
                // Star the post and add self to stars
                p.starCount = p.starCount + 1;
                p.stars.put(getUid(), true);
            }

            // Set value and report transaction success
            mutableData.setValue(p);
            return Transaction.success(mutableData);
        }

        @Override
        public void onComplete(DatabaseError databaseError, boolean committed,
                               DataSnapshot currentData) {
            // Transaction completed
            Log.d(TAG, "postTransaction:onComplete:" + databaseError);
        }
    });
}

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

הגדלות אטומיות בצד השרת

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

Kotlin

private fun onStarClicked(uid: String, key: String) {
    val updates: MutableMap<String, Any> = hashMapOf(
        "posts/$key/stars/$uid" to true,
        "posts/$key/starCount" to ServerValue.increment(1),
        "user-posts/$uid/$key/stars/$uid" to true,
        "user-posts/$uid/$key/starCount" to ServerValue.increment(1),
    )
    database.updateChildren(updates)
}

Java

private void onStarClicked(String uid, String key) {
    Map<String, Object> updates = new HashMap<>();
    updates.put("posts/"+key+"/stars/"+uid, true);
    updates.put("posts/"+key+"/starCount", ServerValue.increment(1));
    updates.put("user-posts/"+uid+"/"+key+"/stars/"+uid, true);
    updates.put("user-posts/"+uid+"/"+key+"/starCount", ServerValue.increment(1));
    mDatabase.updateChildren(updates);
}

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

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

עבודה עם נתונים במצב אופליין

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

כל לקוח שמחובר למסד נתונים של Firebase שומר גרסה פנימית משלו של כל הנתונים שנעשה בהם שימוש ב-listeners או שסומנו לשמירה בסנכרון עם השרת. כשקוראים נתונים או כותבים נתונים, המערכת משתמשת קודם בגרסה המקומית הזו של הנתונים. לאחר מכן, לקוח Firebase מסנכרן את הנתונים האלה עם שרתי מסד הנתונים המרוחקים ועם לקוחות אחרים על בסיס 'המאמץ הטוב ביותר'.

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

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

מידע נוסף על יכולות אונליין ואופליין

השלבים הבאים