Đọc và ghi dữ liệu trên Android

Tài liệu này trình bày các kiến thức cơ bản về cách đọc và ghi dữ liệu Firebase.

Dữ liệu Firebase được ghi vào tệp tham chiếu FirebaseDatabase và truy xuất bằng cách đính kèm một trình nghe không đồng bộ vào tệp tham chiếu đó. Trình nghe được kích hoạt một lần đối với trạng thái ban đầu của dữ liệu và kích hoạt lại bất cứ khi nào dữ liệu thay đổi.

(Không bắt buộc) Tạo nguyên mẫu và kiểm thử bằng Firebase Local Emulator Suite

Trước khi nói về cách ứng dụng đọc và ghi vào Realtime Database, hãy giới thiệu một bộ công cụ mà bạn có thể sử dụng để tạo nguyên mẫu và kiểm thử chức năng Realtime Database: Firebase Local Emulator Suite. Nếu bạn đang thử nghiệm nhiều mô hình dữ liệu, tối ưu hoá các quy tắc bảo mật hoặc tìm cách hiệu quả nhất về chi phí để tương tác với phần phụ trợ, thì việc có thể làm việc cục bộ mà không cần triển khai dịch vụ trực tiếp có thể là một ý tưởng hay.

Trình mô phỏng Realtime Database là một phần của Local Emulator Suite, cho phép ứng dụng của bạn tương tác với nội dung và cấu hình của cơ sở dữ liệu được mô phỏng, cũng như các tài nguyên dự án được mô phỏng (không bắt buộc) (các hàm, cơ sở dữ liệu khác và quy tắc bảo mật).

Việc sử dụng trình mô phỏng Realtime Database chỉ bao gồm vài bước:

  1. Thêm một dòng mã vào cấu hình kiểm thử của ứng dụng để kết nối với trình mô phỏng.
  2. Trên gốc của thư mục dự án cục bộ, chạy firebase emulators:start.
  3. Thực hiện lệnh gọi từ mã nguyên mẫu của ứng dụng bằng SDK nền tảng Realtime Database như bình thường hoặc sử dụng API REST Realtime Database.

Có sẵn hướng dẫn chi tiết liên quan đến Realtime DatabaseCloud Functions. Bạn cũng nên xem giới thiệu về Local Emulator Suite.

Lấy DatabaseReference

Để đọc hoặc ghi dữ liệu từ cơ sở dữ liệu, bạn cần có một thực thể của DatabaseReference:

Kotlin+KTX

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

Java

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

Ghi dữ liệu

Các thao tác ghi cơ bản

Đối với các thao tác ghi cơ bản, bạn có thể sử dụng setValue() để lưu dữ liệu vào một tham chiếu được chỉ định, thay thế mọi dữ liệu hiện có tại đường dẫn đó. Bạn có thể sử dụng phương thức này để:

  • Các loại thẻ tương ứng với các loại JSON hiện có như sau:
    • String
    • Long
    • Double
    • Boolean
    • Map<String, Object>
    • List<Object>
  • Truyền một đối tượng Java tuỳ chỉnh, nếu lớp xác định đối tượng đó có một hàm khởi tạo mặc định không nhận đối số và có phương thức getter công khai cho các thuộc tính được chỉ định.

Nếu bạn sử dụng đối tượng Java, nội dung của đối tượng sẽ tự động được liên kết với các vị trí con theo cách lồng nhau. Việc sử dụng đối tượng Java cũng thường giúp mã của bạn dễ đọc và dễ bảo trì hơn. Ví dụ: nếu bạn có một ứng dụng có hồ sơ người dùng cơ bản, thì đối tượng User của bạn có thể có dạng như sau:

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;
    }

}

Bạn có thể thêm một người dùng có setValue() như sau:

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);
}

Việc sử dụng setValue() theo cách này sẽ ghi đè dữ liệu tại vị trí đã chỉ định, bao gồm cả mọi nút con. Tuy nhiên, bạn vẫn có thể cập nhật một phần tử con mà không cần ghi lại toàn bộ đối tượng. Nếu muốn cho phép người dùng cập nhật hồ sơ của họ, bạn có thể cập nhật tên người dùng như sau:

Kotlin+KTX

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

Java

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

Đọc dữ liệu

Đọc dữ liệu bằng trình nghe ổn định

Để đọc dữ liệu tại một đường dẫn và theo dõi các thay đổi, hãy sử dụng phương thức addValueEventListener() để thêm ValueEventListener vào DatabaseReference.

gửi biểu mẫu Lệnh gọi lại sự kiện Cách sử dụng thông thường
ValueEventListener onDataChange() Đọc và theo dõi các thay đổi đối với toàn bộ nội dung của một đường dẫn.

Bạn có thể sử dụng phương thức onDataChange() để đọc ảnh chụp nhanh tĩnh về nội dung tại một đường dẫn nhất định, vì các nội dung này đã tồn tại tại thời điểm diễn ra sự kiện. Phương thức này được kích hoạt một lần khi trình nghe được đính kèm và lặp lại mỗi khi dữ liệu (bao gồm cả phần tử con) thay đổi. Lệnh gọi lại sự kiện được truyền một ảnh chụp nhanh chứa tất cả dữ liệu tại vị trí đó, bao gồm cả dữ liệu con. Nếu không có dữ liệu, bản tổng quan nhanh sẽ trả về false khi bạn gọi exists()null khi bạn gọi getValue() trên đó.

Ví dụ sau minh hoạ một ứng dụng viết blog trên mạng xã hội truy xuất thông tin chi tiết của một bài đăng từ cơ sở dữ liệu:

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);

Trình nghe nhận một DataSnapshot chứa dữ liệu tại vị trí đã chỉ định trong cơ sở dữ liệu vào thời điểm diễn ra sự kiện. Việc gọi getValue() trên ảnh chụp nhanh sẽ trả về bản trình bày đối tượng Java của dữ liệu. Nếu không có dữ liệu ở vị trí này, lệnh gọi getValue() sẽ trả về null.

Trong ví dụ này, ValueEventListener cũng xác định phương thức onCancelled() được gọi nếu quá trình đọc bị huỷ. Ví dụ: một lượt đọc có thể bị huỷ nếu ứng dụng không có quyền đọc ở vị trí cơ sở dữ liệu Firebase. Phương thức này được truyền một đối tượng DatabaseError cho biết lý do xảy ra lỗi.

Đọc dữ liệu một lần

Đọc một lần bằng cách sử dụng get()

SDK được thiết kế để quản lý các hoạt động tương tác với máy chủ cơ sở dữ liệu cho dù ứng dụng của bạn là trực tuyến hay ngoại tuyến.

Nhìn chung, bạn nên sử dụng các kỹ thuật ValueEventListener được mô tả ở trên để đọc dữ liệu nhằm nhận thông báo về nội dung cập nhật dữ liệu từ phần phụ trợ. Các kỹ thuật trình nghe giúp giảm mức sử dụng và mức thanh toán, đồng thời được tối ưu hoá để mang lại cho người dùng trải nghiệm tốt nhất khi họ truy cập mạng và khi không có mạng.

Nếu chỉ cần dữ liệu một lần, bạn có thể sử dụng get() để lấy thông tin tổng quan nhanh về dữ liệu từ cơ sở dữ liệu. Nếu vì bất kỳ lý do gì mà get() không thể trả về giá trị máy chủ, thì ứng dụng sẽ thăm dò bộ nhớ đệm lưu trữ cục bộ và trả về lỗi nếu vẫn không tìm thấy giá trị đó.

Việc sử dụng get() không cần thiết có thể làm tăng mức sử dụng băng thông và dẫn đến giảm hiệu suất. Bạn có thể ngăn chặn điều này bằng cách sử dụng trình nghe theo thời gian thực như trình bày ở trên.

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()));
        }
    }
});

Đọc một lần bằng trình nghe

Trong một số trường hợp, bạn có thể muốn giá trị từ bộ nhớ đệm cục bộ được trả về ngay lập tức, thay vì kiểm tra giá trị đã cập nhật trên máy chủ. Trong những trường hợp đó, bạn có thể sử dụng addListenerForSingleValueEvent để lấy dữ liệu từ bộ nhớ đệm của ổ đĩa cục bộ ngay lập tức.

Điều này hữu ích cho dữ liệu chỉ cần tải một lần và không dự kiến sẽ thay đổi thường xuyên hoặc yêu cầu phải nghe chủ động. Ví dụ: ứng dụng viết blog trong các ví dụ trước sử dụng phương thức này để tải hồ sơ của người dùng khi họ bắt đầu viết bài đăng mới.

Cập nhật hoặc xoá dữ liệu

Cập nhật các trường cụ thể

Để đồng thời ghi vào các nút con cụ thể của một nút mà không ghi đè các nút con khác, hãy sử dụng phương thức updateChildren().

Khi gọi updateChildren(), bạn có thể cập nhật các giá trị con cấp thấp hơn bằng cách chỉ định một đường dẫn cho khoá. Nếu dữ liệu được lưu trữ ở nhiều vị trí để mở rộng quy mô tốt hơn, bạn có thể cập nhật tất cả các thực thể của dữ liệu đó bằng cách sử dụng tính năng phân phối dữ liệu. Ví dụ: một ứng dụng viết blog xã hội có thể có lớp Post như sau:

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;
    }
}

Để tạo một bài đăng và đồng thời cập nhật bài đăng đó vào nguồn cấp dữ liệu hoạt động gần đây và nguồn cấp dữ liệu hoạt động của người dùng đăng, ứng dụng viết blog sử dụng mã như sau:

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);
}

Ví dụ này sử dụng push() để tạo một bài đăng trong nút chứa bài đăng cho tất cả người dùng tại /posts/$postid và đồng thời truy xuất khoá bằng getKey(). Sau đó, bạn có thể sử dụng khoá này để tạo mục nhập thứ hai trong bài đăng của người dùng tại /user-posts/$userid/$postid.

Khi sử dụng các đường dẫn này, bạn có thể thực hiện đồng thời việc cập nhật nhiều vị trí trong cây JSON bằng một lệnh gọi duy nhất đến updateChildren(), chẳng hạn như cách ví dụ này tạo bài đăng mới ở cả hai vị trí. Các bản cập nhật đồng thời được thực hiện theo cách này là nguyên tử: tất cả các bản cập nhật đều thành công hoặc tất cả các bản cập nhật đều không thành công.

Thêm lệnh gọi lại khi hoàn tất

Nếu muốn biết thời điểm dữ liệu của bạn đã được xác nhận, bạn có thể thêm một trình nghe hoàn thành. Cả setValue()updateChildren() đều có một trình nghe hoàn tất không bắt buộc được gọi khi quá trình ghi đã được thực hiện thành công vào cơ sở dữ liệu. Nếu lệnh gọi không thành công, trình nghe sẽ được truyền một đối tượng lỗi cho biết lý do xảy ra lỗi.

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
                // ...
            }
        });

Xóa dữ liệu

Cách đơn giản nhất để xoá dữ liệu là gọi removeValue() trên một tệp tham chiếu đến vị trí của dữ liệu đó.

Bạn cũng có thể xoá bằng cách chỉ định null làm giá trị cho một thao tác ghi khác, chẳng hạn như setValue() hoặc updateChildren(). Bạn có thể sử dụng kỹ thuật này với updateChildren() để xoá nhiều phần tử con trong một lệnh gọi API.

Tách trình nghe

Bạn có thể xoá lệnh gọi lại bằng cách gọi phương thức removeEventListener() trên tham chiếu cơ sở dữ liệu Firebase.

Nếu một trình nghe đã được thêm nhiều lần vào một vị trí dữ liệu, thì trình nghe đó sẽ được gọi nhiều lần cho mỗi sự kiện và bạn phải tách trình nghe đó ra cùng số lần để xoá hoàn toàn trình nghe đó.

Việc gọi removeEventListener() trên trình nghe mẹ không tự động xoá trình nghe đã đăng ký trên các nút con; bạn cũng phải gọi removeEventListener() trên mọi trình nghe con để xoá lệnh gọi lại.

Lưu dữ liệu dưới dạng giao dịch

Khi xử lý dữ liệu có thể bị hỏng do các thay đổi đồng thời, chẳng hạn như bộ đếm gia tăng, bạn có thể sử dụng thao tác giao dịch. Bạn cung cấp cho toán tử này hai đối số: một hàm cập nhật và một lệnh gọi lại hoàn tất không bắt buộc. Hàm cập nhật lấy trạng thái hiện tại của dữ liệu làm đối số và trả về trạng thái mới mà bạn muốn ghi. Nếu một ứng dụng khác ghi vào vị trí đó trước khi viết thành công giá trị mới, thì hàm cập nhật của bạn sẽ được gọi lại bằng giá trị hiện tại mới và thao tác ghi sẽ được thử lại.

Ví dụ: trong ứng dụng blog xã hội mẫu, bạn có thể cho phép người dùng gắn sao và bỏ gắn sao các bài đăng, đồng thời theo dõi số lượng sao mà một bài đăng nhận được như sau:

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);
        }
    });
}

Việc sử dụng giao dịch sẽ giúp số lượng dấu sao không bị sai nếu nhiều người dùng gắn dấu sao cho cùng một bài đăng cùng một lúc hoặc ứng dụng có dữ liệu cũ. Nếu giao dịch bị từ chối, máy chủ sẽ trả về giá trị hiện tại cho máy khách, thao tác này sẽ chạy lại giao dịch với giá trị đã cập nhật. Thao tác này lặp lại cho đến khi chấp nhận giao dịch hoặc bạn đã thử quá nhiều lần.

Tăng nguyên tử phía máy chủ

Trong trường hợp sử dụng ở trên, chúng ta sẽ ghi hai giá trị vào cơ sở dữ liệu: mã nhận dạng của người dùng đã gắn dấu sao/bỏ gắn dấu sao cho bài đăng và số lượng dấu sao đã tăng lên. Nếu đã biết người dùng đang gắn dấu sao cho bài đăng, chúng ta có thể sử dụng thao tác tăng nguyên tử thay vì giao dịch.

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);
}

Mã này không sử dụng thao tác giao dịch, vì vậy, mã này sẽ không tự động chạy lại nếu có bản cập nhật xung đột. Tuy nhiên, vì thao tác tăng giá trị diễn ra trực tiếp trên máy chủ cơ sở dữ liệu, nên không có khả năng xảy ra xung đột.

Nếu muốn phát hiện và từ chối các xung đột dành riêng cho ứng dụng, chẳng hạn như người dùng gắn dấu sao một bài đăng mà họ đã gắn dấu sao trước đó, thì bạn nên viết các quy tắc bảo mật tuỳ chỉnh cho trường hợp sử dụng đó.

Làm việc với dữ liệu khi không có mạng

Nếu ứng dụng mất kết nối mạng, ứng dụng của bạn sẽ tiếp tục hoạt động đúng cách.

Mỗi ứng dụng khách được kết nối với cơ sở dữ liệu Firebase sẽ duy trì phiên bản nội bộ riêng của mọi dữ liệu mà trình nghe đang được sử dụng hoặc được gắn cờ để đồng bộ hoá với máy chủ. Khi dữ liệu được đọc hoặc ghi, phiên bản cục bộ này của dữ liệu sẽ được sử dụng trước. Sau đó, ứng dụng Firebase sẽ đồng bộ hoá dữ liệu đó với các máy chủ cơ sở dữ liệu từ xa và với các ứng dụng khác trên cơ sở "tối đa có thể".

Do đó, tất cả các hoạt động ghi vào cơ sở dữ liệu sẽ kích hoạt các sự kiện cục bộ ngay lập tức, trước khi có bất kỳ hoạt động tương tác nào với máy chủ. Điều này có nghĩa là ứng dụng của bạn vẫn có thể phản hồi bất kể độ trễ hoặc khả năng kết nối mạng.

Sau khi kết nối được thiết lập lại, ứng dụng của bạn sẽ nhận được một tập hợp các sự kiện thích hợp để đồng bộ hoá ứng dụng với trạng thái máy chủ hiện tại mà không cần phải viết mã tuỳ chỉnh nào.

Chúng ta sẽ thảo luận thêm về hành vi ngoại tuyến trong phần Tìm hiểu thêm về các tính năng trực tuyến và ngoại tuyến.

Các bước tiếp theo