การแบ่งหน้าเป็นเทคนิคการโหลดข้อมูลทีละหน้า ใช้ในแอปพลิเคชันมือถือและบริการเว็บเพื่อทำงานกับชุดเรกคอร์ดขนาดใหญ่ ตาม เอกสาร Android Developers (2025) การใช้งานการแบ่งหน้าที่ถูกต้องช่วยลดภาระของ API ประหยัดแบนด์วิดท์ และปรับปรุงประสบการณ์ผู้ใช้ การโหลดทีละหน้า ช่วยให้แอปพลิเคชันแสดงเนื้อหาได้ทีละน้อยโดยไม่ต้องรอให้โหลดข้อมูลทั้งหมด
ประเด็นสำคัญ
การแบ่งหน้า (จากภาษาอังกฤษ pagination — การแบ่งหน้า) เป็นเทคนิคการแบ่งชุดข้อมูลขนาดใหญ่ออกเป็นส่วนต่อเนื่อง (หน้า) ในแอปพลิเคชันมือถือ การแบ่งหน้าถูกใช้เมื่อโหลดรายการข้อความ ฟีดข่าว แคตตาล็อกสินค้า ประวัติคำสั่งซื้อ และคอลเลกชันอื่นๆ ที่มีจำนวนเรกคอร์ดที่ไม่จำกัด
หากไม่มีการแบ่งหน้า แอปพลิเคชันจะถูกบังคับให้โหลดข้อมูลทั้งหมดพร้อมกัน ซึ่งนำไปสู่การรอคอยนาน การใช้แบนด์วิดท์สูง และประสิทธิภาพที่ไม่เสถียรบนอุปกรณ์ที่อ่อนแอ คำขอ API ที่มีการแบ่งหน้า จะส่งคืนข้อมูลเพียงส่วนเดียวและข้อมูลเมตาเพื่อโหลดส่วนถัดไป ดังนั้นแอปพลิเคชันจึงควบคุมปริมาณข้อมูลที่ได้รับ
ตัวชี้วัดหลักของการแบ่งหน้า: ขนาดหน้า (page size) — จำนวนเรกคอร์ดต่อหน้า (ปกติ 10–50) และ หมายเลขหน้าหรือเคอร์เซอร์ — ตัวชี้ไปยังตำแหน่งปัจจุบันในชุดข้อมูล การเลือกขนาดหน้าขึ้นอยู่กับประเภทข้อมูล: สำหรับองค์ประกอบขนาดเล็ก (ชื่อ) 20–30 ก็เพียงพอ สำหรับการ์ดที่มีรูปภาพ — 10–15
อุปกรณ์มือถือมีทรัพยากรจำกัด: แรม ความเร็วโปรเซสเซอร์ และขีดจำกัดแบนด์วิดท์ การแบ่งหน้าแก้ปัญหาสำคัญสามประการ: ลดการใช้หน่วยความจำ (เก็บเฉพาะองค์ประกอบที่มองเห็นในหน่วยความจำ) เร่งการแสดงผลครั้งแรก (ส่วนแรกโหลดเร็วกว่าชุดข้อมูลทั้งหมด) และ ประหยัดแบนด์วิดท์ (ข้อมูลจะถูกโหลดเมื่อผู้ใช้เลื่อนรายการเท่านั้น)
การแบ่งหน้ามีสี่ประเภทหลัก แต่ละประเภทแก้ปัญหาเฉพาะ การเลือกวิธีการขึ้นอยู่กับข้อกำหนด ความสอดคล้องของข้อมูล สถาปัตยกรรม API ประเภทพื้นที่จัดเก็บ และความซับซ้อนที่ยอมรับได้ในการดำเนินงานบนไคลเอนต์และเซิร์ฟเวอร์
| ประเภท | หลักการทำงาน | ความเสถียร | ความเร็วในปริมาณมาก |
|---|---|---|---|
| Offset | LIMIT + OFFSET ใน SQL | ต่ำ | ลดลงเมื่อ OFFSET เพิ่มขึ้น |
| Cursor | WHERE id > last_id | สูง | เสถียร (O(log n)) |
| Keyset | WHERE key > last_key | สูง | เสถียร (O(log n)) |
| Time-based | WHERE created_at < last_time | ปานกลาง | เสถียรเมื่อมีดัชนี |
การแบ่งหน้าแบบ Offset เหมาะสำหรับชุดข้อมูลคงที่หรือที่อัปเดตไม่บ่อย เมื่อการดำเนินการที่เรียบง่ายเป็นสิ่งสำคัญ Cursor และ Keyset เหมาะสำหรับข้อมูลไดนามิกที่มีการแทรกบ่อยครั้ง Time-based เหมาะสำหรับฟีดตามลำดับเวลาที่เรกคอร์ดถูกจัดเรียงตามเวลาที่สร้าง มาตรฐาน GraphQL Relay ใช้การแบ่งหน้าแบบเคอร์เซอร์เป็นวิธีเดียวที่แนะนำ
การแบ่งหน้าแบบ Offset เป็นประเภทการโหลดแบบหน้าที่ง่ายที่สุด ไคลเอนต์ส่งพารามิเตอร์ page และ limit (หรือ offset และ limit) และเซิร์ฟเวอร์ใช้ SQL OFFSET และ LIMIT ตัวอย่างเช่น page=2, limit=20 จะส่งคืนเรกคอร์ดที่ 21 ถึง 40 วิธีนี้เป็นธรรมชาติและใช้งานได้ง่ายบนสแต็กใดๆ
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/items")
async def get_items(
page: int = Query(default=1, ge=1),
limit: int = Query(default=20, le=100)
):
offset = (page - 1) * limit
items = await fetch_items(offset, limit)
total = await count_items()
return {
"items": items,
"total": total,
"page": page,
"pages": (total + limit - 1) // limit
}
ข้อเสียหลักของการแบ่งหน้าแบบ Offset คือ ปัญหาเรกคอร์ดที่หายไปและซ้ำกัน หากมีการเพิ่มเรกคอร์ดใหม่ลงในตารางระหว่างคำขอสองครั้ง OFFSET จะเลื่อนไป ผู้ใช้อาจเห็นเรกคอร์ดเดียวกันสองครั้งหรือพลาดเรกคอร์ดใหม่ ซึ่งเป็นเรื่องสำคัญสำหรับฟีดข่าวและแชทที่ความสอดคล้องมีความสำคัญ
อีกปัญหาหนึ่งคือประสิทธิภาพที่ลดลงเมื่อ OFFSET มีค่ามาก ฐานข้อมูลต้องสแกนและข้ามเรกคอร์ด offset แรกก่อนที่จะส่งคืนผลลัพธ์ ที่ offset=100000 แม้จะมี LIMIT 20 เซิร์ฟเวอร์จะใช้เวลาในการสแกนอย่างมาก PostgreSQL และ MySQL แสดงความเร็วที่ลดลงเป็นเส้นตรงเมื่อ OFFSET เพิ่มขึ้น
การแบ่งหน้าแบบ Offset ยังคงเป็นตัวเลือกที่ดีที่สุดสำหรับ: แผงผู้ดูแลระบบ (ข้อมูลเปลี่ยนแปลงน้อยครั้ง ต้องการการนำทางระหว่างหน้า) รายงานและบันทึกประวัติ (ภาพรวมข้อมูลที่แน่นอน) แคตตาล็อกที่มีการกรอง (สามารถไปยังหน้าใดก็ได้) Offset ยังใช้งานบนไคลเอนต์ได้ง่ายที่สุด — RecyclerView ที่มี Paging 3 รองรับทันที
การแบ่งหน้าแบบ Keyset ใช้คีย์เฉพาะ (โดยปกติคือคีย์หลัก) เพื่อกรองเรกคอร์ด แทนที่จะใช้ OFFSET คำสั่งใช้ WHERE id > last_seen_id ซึ่งรับประกันประสิทธิภาพที่เสถียรโดยไม่ขึ้นกับจำนวนเรกคอร์ดและไม่มีการซ้ำเมื่อแทรก เนื่องจาก เรกคอร์ดใหม่จะมี id ที่ใหญ่กว่าเสมอ
-- การแบ่งหน้าด้วย Offset (มีปัญหา)
SELECT * FROM posts
ORDER BY id
LIMIT 20 OFFSET 100;
-- การแบ่งหน้าด้วย Keyset (เสถียร)
SELECT * FROM posts
WHERE id > 100
ORDER BY id
LIMIT 20;
การแบ่งหน้าแบบ Time-based (หรือเคอร์เซอร์ตามเวลา) ใช้การประทับเวลา created_at เพื่อนำทาง ไคลเอนต์ส่งการประทับเวลาของเรกคอร์ดสุดท้ายที่โหลด และเซิร์ฟเวอร์ส่งคืนเรกคอร์ดที่สร้างก่อนหรือหลังการประทับเวลานั้น วิธีนี้เป็นที่นิยมใน เครือข่ายสังคมและฟีดข่าว ที่ลำดับของเรกคอร์ดถูกกำหนดโดยเวลาเผยแพร่
ลักษณะเฉพาะของการแบ่งหน้าแบบ Time-based คือความเป็นไปได้ที่จะมีการซ้ำกันหากเรกคอร์ดสองรายการถูกสร้างขึ้นในมิลลิวินาทีเดียวกัน เพื่อกำจัดปัญหานี้ ให้รวม คีย์ตามเวลากับ id เฉพาะ: WHERE (created_at, id) < (last_time, last_id) เคอร์เซอร์แบบผสมดังกล่าวรับประกันความเป็นเอกลักษณ์ของแต่ละเรกคอร์ดและลำดับที่แม่นยำ
การแบ่งหน้าแบบ Keyset ต้องการคอลัมน์ที่มีค่าเฉพาะและเพิ่มขึ้นอย่างสม่ำเสมอ (id แบบเพิ่มอัตโนมัติ, UUID v7) Time-based เหมาะสำหรับตารางใดๆ ที่มี created_at แต่ต้องการการจัดการเพิ่มเติมสำหรับการซ้ำกัน ความแตกต่างหลัก: Keyset ทำงานได้เสถียรภายใต้การดำเนินการแทรกใดๆ ในขณะที่ Time-based มีความไวต่อการประทับเวลาที่เหมือนกัน
การเลือกประเภทการแบ่งหน้าขึ้นอยู่กับลักษณะของข้อมูลและข้อกำหนดประสบการณ์ผู้ใช้ ด้านล่างนี้เป็นคำแนะนำสำหรับสถานการณ์ทั่วไปในการพัฒนาแอปมือถือ ไม่มีวิธีแก้ไขที่เป็นสากล — แต่ละวิธีมีขอบเขตที่เหมาะสมที่สุด
ไลบรารี Android Paging 3 รองรับการแบ่งหน้าทุกประเภทผ่าน PagingSource สำหรับ Offset — PagingSource ที่มีคีย์ Int (page) สำหรับ Cursor — ที่มีคีย์ String หรือ Long (cursor) PagingSource จัดการการโหลด การแคช และการลองใหม่เมื่อเกิดข้อผิดพลาดโดยอัตโนมัติ
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> {
val cursor = params.key ?: Long.MAX_VALUE
return try {
val response = api.getPosts(cursor, params.loadSize)
LoadResult.Page(
data = response.items,
prevKey = null,
nextKey = response.items.lastOrNull()?.id
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Long, Post>): Long? {
return state.anchorPosition?.let {
state.closestItemToPosition(it)?.id
}
}
}
ขนาดหน้าส่งผลต่อความเร็วในการโหลดและการรับรู้ประสิทธิภาพ สำหรับแอปพลิเคชันมือถือ ช่วงที่เหมาะสมคือ 10–25 รายการต่อหน้า น้อยกว่า 10 — คำขอไปยัง API มากเกินไปและการเลื่อนที่กระตุก มากกว่า 25 — การโหลดส่วนแรกช้าบนเครือข่ายที่ช้า
สำหรับรูปภาพและวิดีโอ ให้ลดขนาดหน้าเป็น 5–10 เนื่องจากแต่ละองค์ประกอบต้องใช้เวลาเพิ่มเติมในการโหลดสื่อ สำหรับรายการที่เป็นข้อความ (ความคิดเห็น, บันทึก) สามารถเพิ่มขนาดเป็น 30–50 เรกคอร์ด ขอแนะนำให้ทำให้ขนาดหน้าสามารถกำหนดค่าได้ผ่าน API เพื่อให้ไคลเอนต์สามารถปรับให้เข้ากับสภาพเครือข่ายที่แตกต่างกัน
คำถามที่พบบ่อย
การแบ่งหน้าคือการโหลดข้อมูลเป็นส่วนๆ ไม่ใช่ทั้งหมดพร้อมกัน เหมือนหนังสือ: คุณอ่านหน้าหนึ่งแล้วพลิกไปหน้าถัดไป ในแอป หมายความว่าเมื่อเลื่อนรายการ ชุดข้อมูลถัดไป จะถูกโหลด ไม่ใช่ทั้งรายการ ช่วยประหยัดแบนด์วิดท์และหน่วยความจำ
Offset จะนับเรกคอร์ด: “ข้าม 20 ส่งคืน 10 ถัดไป” หากมีการเพิ่มเรกคอร์ดใหม่ระหว่างการโหลด หมายเลขจะคลาดเคลื่อน Cursor ใช้ ตัวระบุเฉพาะของเรกคอร์ดสุดท้าย: “ส่งคืน 10 เรกคอร์ดหลังจาก ID = 100” เรกคอร์ดใหม่ไม่ส่งผลต่อตำแหน่ง
สำหรับแอปพลิเคชันมือถือ 10–25 รายการ เหมาะสมที่สุด สำหรับรายการที่มีรูปภาพ — 5–10 สำหรับฟีดข้อความ — 20–30 ขนาดขึ้นอยู่กับขนาดเฉลี่ยของแต่ละองค์ประกอบ ยิ่งองค์ประกอบหนักเท่าใด หน้าก็ยิ่งควรเล็กลงเพื่อการแสดงผลที่รวดเร็ว
ใช้ไลบรารี Paging 3 จาก Android Jetpack โดยมี PagingSource สำหรับการโหลด PagingData สำหรับสตรีมแบบรีแอกทีฟ และ PagingDataAdapter สำหรับการโหลดอัตโนมัติเมื่อเลื่อน ไลบรารีรองรับการแบ่งหน้าแบบ Offset, Cursor และ Keyset ผ่าน PagingSource ที่กำหนดเอง
การเลื่อนแบบไม่สิ้นสุดเป็นรูปแบบ UI ที่ชุดข้อมูลใหม่จะถูกโหลดโดยอัตโนมัติเมื่อเข้าใกล้จุดสิ้นสุดของรายการ การแบ่งหน้าคือกลไก การโหลดข้อมูลเป็นชุดๆ ในขณะที่การเลื่อนแบบไม่สิ้นสุดเป็นวิธีการแสดงผล ทางเลือกอื่นคือปุ่ม “โหลดเพิ่มเติม”
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม