خواندن و نوشتن داده ها در اندروید

این سند اصول اولیه خواندن و نوشتن داده‌های Firebase را پوشش می‌دهد.

داده‌های Firebase در یک مرجع FirebaseDatabase نوشته می‌شوند و با اتصال یک شنونده‌ی ناهمزمان به مرجع، بازیابی می‌شوند. شنونده یک بار برای وضعیت اولیه‌ی داده‌ها و بار دیگر هر زمان که داده‌ها تغییر کنند، فعال می‌شود.

(اختیاری) نمونه اولیه و تست با Firebase Local Emulator Suite

قبل از صحبت در مورد نحوه خواندن و نوشتن برنامه شما از Realtime Database ، بیایید مجموعه‌ای از ابزارهایی را که می‌توانید برای نمونه‌سازی اولیه و آزمایش عملکرد Realtime Database استفاده کنید، معرفی کنیم: Firebase Local Emulator Suite . اگر در حال آزمایش مدل‌های داده مختلف، بهینه‌سازی قوانین امنیتی خود یا تلاش برای یافتن مقرون‌به‌صرفه‌ترین راه برای تعامل با back-end هستید، امکان کار به صورت محلی بدون استقرار سرویس‌های زنده می‌تواند ایده خوبی باشد.

یک شبیه‌ساز 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 نیاز دارید:

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

اگر از یک شیء جاوا استفاده می‌کنید، محتویات شیء شما به طور خودکار به مکان‌های فرزند به صورت تو در تو نگاشت می‌شوند. استفاده از یک شیء جاوا همچنین معمولاً کد شما را خواناتر و نگهداری آن را آسان‌تر می‌کند. به عنوان مثال، اگر برنامه‌ای با یک پروفایل کاربری ساده دارید، شیء 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() برای خواندن یک snapshot ایستا از محتویات یک مسیر مشخص، همانطور که در زمان رویداد وجود داشته‌اند، استفاده کنید. این متد یک بار زمانی که listener متصل می‌شود و بار دیگر هر بار که داده‌ها، از جمله داده‌های فرزند، تغییر می‌کنند، فعال می‌شود. فراخوانی رویداد، یک snapshot حاوی تمام داده‌های موجود در آن مکان، از جمله داده‌های فرزند، ارسال می‌کند. اگر داده‌ای وجود نداشته باشد، snapshot هنگام فراخوانی 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() روی یک snapshot، نمایش شیء جاوا از داده‌ها را برمی‌گرداند. اگر هیچ داده‌ای در آن مکان وجود نداشته باشد، فراخوانی getValue() null را برمی‌گرداند.

در این مثال، ValueEventListener همچنین متد onCancelled() را تعریف می‌کند که در صورت لغو خواندن فراخوانی می‌شود. برای مثال، اگر کلاینت مجوز خواندن از یک مکان پایگاه داده Firebase را نداشته باشد، می‌توان خواندن را لغو کرد. به این متد یک شیء DatabaseError ارسال می‌شود که نشان می‌دهد چرا خطا رخ داده است.

یک بار خواندن داده‌ها

یک بار خواندن با استفاده از get()

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

به طور کلی، شما باید از تکنیک‌های ValueEventListener که در بالا توضیح داده شد برای خواندن داده‌ها استفاده کنید تا از به‌روزرسانی‌های داده‌ها از backend مطلع شوید. تکنیک‌های 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() ، می‌توانید مقادیر فرزند سطح پایین‌تر را با مشخص کردن مسیری برای کلید به‌روزرسانی کنید. اگر داده‌ها برای مقیاس‌پذیری بهتر در چندین مکان ذخیره می‌شوند، می‌توانید تمام نمونه‌های آن داده‌ها را با استفاده از تابع data-fan-out به‌روزرسانی کنید. به عنوان مثال، یک برنامه وبلاگ‌نویسی اجتماعی ممکن است کلاس 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() برای حذف چندین فرزند در یک فراخوانی API واحد استفاده کنید.

جدا کردن شنوندگان

فراخوانی‌های برگشتی با فراخوانی متد 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 آن داده‌ها را با سرورهای پایگاه داده راه دور و با سایر کلاینت‌ها بر اساس "بهترین تلاش" همگام‌سازی می‌کند.

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

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

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

مراحل بعدی