קריאה וכתיבה של נתונים ב-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.

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

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

הוספת קריאה חוזרת בסיום הרכישה

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

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

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

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

השלבים הבאים