خواندن و نوشتن داده‌ها در 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. برقراری تماس از کد نمونه اولیه برنامه بااستفاده از کیت توسعه نرم‌افزار پلاتفرم Realtime Database طبق معمول، یا بااستفاده از REST API‏ Realtime Database.

راهنمای گام‌به‌گام دقیقی شامل 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 سفارشی را تعریف می‌کند سازنده پیش‌فرضی داشته باشد که هیچ آرگومانی را نمی‌پذیرد و برای ویژگی‌هایی که باید اختصاص داده شوند گیرنده عمومی داشته باشد، شیء Java سفارشی را ارسال کنید.

اگر از شیء 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 استفاده کنید.

شنونده پاسخ تماس رویداد استفاده معمول
ValueEventListener onDataChange() خواندن و گوش دادن به تغییرات در کل محتوای یک مسیر.

می‌توانید از روش onDataChange() برای خواندن عکس آنی ایستا از محتوا در مسیر مشخص‌شده، همان‌طور که در زمان رویداد وجود داشته است، استفاده کنید. این روش یک‌بار هنگام پیوست شدن شنونده و بار دیگر هربار که داده‌ها، ازجمله فرزندان، تغییر می‌کنند راه‌اندازی می‌شود. فراخوانی برگشتی رویداد به عکس آنی حاوی همه داده‌های آن مکان، ازجمله داده‌های فرزند، منتقل می‌شود. اگر داده‌ای وجود نداشته باشد، عکس آنی هنگام فراخوانی exists()، false را برمی‌گرداند و هنگام فراخوانی getValue()، null را برمی‌گرداند.

مثال زیر نشان می‌دهد که چگونه یک برنامه وبلاگ‌نویسی اجتماعی جزئیات یک پست را از پایگاه داده بازیابی می‌کند:

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

این کیت توسعه نرم‌افزار برای مدیریت تعاملات با سرورهای پایگاه داده طراحی شده است، چه برنامه شما آنلاین باشد و چه آفلاین.

به‌طور کلی، باید از روش‌های ValueEventListener شرح‌داده‌شده در بالا برای خواندن داده‌ها استفاده کنید تا از به‌روزرسانی‌های داده‌ها از زیرینه مطلع شوید. فن‌های شنونده استفاده و صورت‌حساب شما را کاهش می‌دهند و بهینه‌سازی شده‌اند تا بهترین تجربه را به کاربران شما هنگام آنلاین و آفلاین شدن ارائه دهند.

اگر فقط یک‌بار به داده‌ها نیاز دارید، می‌توانید از 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() انجام دهید، مانند اینکه چگونه این مثال پست جدید را در هر دو مکان ایجاد می‌کند. به‌روزرسانی‌های هم‌زمان که به این روش انجام می‌شوند اتمی هستند: یا همه به‌روزرسانی‌ها موفق می‌شوند یا همه به‌روزرسانی‌ها ناموفق می‌شوند.

افزودن فراخوان تکمیل

اگر می‌خواهید بدانید داده‌هایتان چه زمانی ثبت شده است، می‌توانید شنونده تکمیل اضافه کنید. ‫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() برای حذف چند فرزند در یک فراخوانی میانای برنامه‌سازی کاربردی استفاده کنید.

جدا کردن شنونده‌ها

فراخوانی‌های برگشتی با فراخوانی روش 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 نسخه داخلی خودش را از هر داده‌ای که شنوندگان از آن استفاده می‌کنند یا برای همگام‌سازی با سرور پرچم‌گذاری شده است حفظ می‌کند. وقتی داده‌ها خوانده یا نوشته می‌شوند، ابتدا از این نسخه محلی داده‌ها استفاده می‌شود. سپس کارخواه Firebase آن داده‌ها را با سرورهای پایگاه داده راه دور و با دیگر کارخواه‌ها براساس «بهترین تلاش» همگام‌سازی می‌کند.

درنتیجه، همه نوشتن‌ها در پایگاه داده بلافاصله رویدادهای محلی را راه‌اندازی می‌کنند، قبل‌از هرگونه تعامل با سرور. این یعنی برنامه‌تان بدون توجه به تأخیر شبکه یا اتصال، همچنان پاسخگو می‌ماند.

پس‌از برقراری مجدد اتصال، برنامه شما مجموعه مناسبی از رویدادها را دریافت می‌کند تا سرویس‌گیرنده با وضعیت فعلی سرور همگام‌سازی شود، بدون اینکه نیازی به نوشتن کد سفارشی باشد.

در درباره قابلیت‌های آنلاین و آفلاین بیشتر بدانید، درباره رفتار آفلاین بیشتر صحبت خواهیم کرد.

مراحل بعدی