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

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

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

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

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

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

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

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

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

אחזור של DatabaseReference

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

Kotlin+KTX

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+KTX

@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+KTX

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+KTX

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

Java

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

קריאת הנתונים

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

כדי לקרוא נתונים בנתיב ולעקוב אחרי שינויים, משתמשים ב-method‏ addValueEventListener() כדי להוסיף ValueEventListener ל-DatabaseReference.

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

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

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

Kotlin+KTX

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() ב-snapshot מחזירה את ייצוג האובייקט של הנתונים ב-Java. אם לא קיימים נתונים במיקום, קריאה ל-getValue() מחזירה את הערך null.

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

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

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

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

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

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

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

Kotlin+KTX

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+KTX

@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+KTX

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) בסיום הרכישה

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

Kotlin+KTX

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.

ניתוק מאזינים

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

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

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

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

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

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

Kotlin+KTX

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+KTX

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 שומר גרסה פנימית משלו של כל הנתונים שבהם נעשה שימוש במאזינים או של נתונים שסומנו לצורך סנכרון עם השרת. כשקוראים או כותבים נתונים, נעשה שימוש קודם בגרסה המקומית הזו של הנתונים. לאחר מכן לקוח Firebase מסנכרן את הנתונים האלה עם שרתי מסד הנתונים המרוחקים ועם לקוחות אחרים על בסיס התוצאה הטובה ביותר.

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

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

נדבר על ההתנהגות אופליין בקטע מידע נוסף על יכולות אונליין ואופליין.

השלבים הבאים