20 เม.ย. 2568·อ่าน 2 นาที

Kotlin สำหรับการเชื่อมต่อช้า: ค่าหมดเวลาและการลองใหม่อย่างปลอดภัย

แนวทางปฏิบัติ Kotlin สำหรับการเชื่อมต่อช้า: ตั้งค่าค่าหมดเวลา, แคชอย่างปลอดภัย, ลองใหม่โดยไม่ทำให้เกิดซ้ำ, และปกป้องการกระทำสำคัญบนเครือข่ายมือถือไม่เสถียร.

Kotlin สำหรับการเชื่อมต่อช้า: ค่าหมดเวลาและการลองใหม่อย่างปลอดภัย

สิ่งที่เสียหายเมื่อเครือข่ายช้าและไม่เสถียร

บนมือถือ “ช้า” มักไม่แปลว่า “ไม่มีอินเทอร์เน็ต” เสมอไป แต่มักหมายถึงการเชื่อมต่อที่ทำงานเป็นช่วง ๆ คำขออาจใช้เวลา 8–20 วินาที ติดค้างกลางทางแล้วจบ หรือสำเร็จครั้งหนึ่งแล้วล้มเหลวครั้งถัดไปเพราะโทรศัพท์เปลี่ยนจาก Wi‑Fi เป็น LTE เข้าไปในพื้นที่สัญญาณต่ำ หรือระบบปฏิบัติการย้ายแอปไปพื้นหลัง

“ไม่เสถียร” แย่กว่า: แพ็กเก็ตหล่น, DNS ตอบช้า, การจับคู่ TLS ล้มเหลว และการรีเซ็ตการเชื่อมต่อแบบสุ่ม คุณอาจเขียนโค้ดถูกต้องทั้งหมด แต่ยังเจอล้มเหลวจริงในภาคสนาม เพราะเครือข่ายเปลี่ยนไปทันที

นี่คือจุดที่ค่าดีฟอลต์มักพัง แอปหลายตัวพึ่งพาค่าดีฟอลต์ของไลบรารีสำหรับ timeout, retry และ caching โดยไม่ได้นิยามว่าพอเพียงสำหรับผู้ใช้จริง ค่าดีฟอลต์มักจูนไว้สำหรับ Wi‑Fi ที่เสถียรและ API เร็ว ๆ ไม่ใช่การเดินทางโดยรถไฟ, ลิฟต์ หรือคาเฟ่ที่คนเยอะ

ผู้ใช้ไม่ได้พูดถึง “socket timeout” หรือ “HTTP 503” พวกเขาสังเกตอาการ: วงโหลดไม่จบ, ข้อผิดพลาดหลังรอนาน (แล้วมันสำเร็จเมื่อพยายามใหม่), การกระทำซ้ำ (จองสองครั้ง, สั่งสินค้าสองครั้ง, เรียกเก็บสองครั้ง), ข้อมูลอัปเดตหาย และสถานะผสมที่ UI บอกว่า “ล้มเหลว” แต่เซิร์ฟเวอร์กลับสำเร็จ

เครือข่ายช้าทำให้ช่องว่างการออกแบบเล็ก ๆ กลายเป็นปัญหาเรื่องเงินและความเชื่อใจ หากแอปไม่แยกระหว่าง “กำลังส่ง” กับ “ล้มเหลว” กับ “เสร็จแล้ว” อย่างชัดเจน ผู้ใช้ก็จะกดซ้ำ หากไคลเอนต์ลองใหม่แบบไม่คิด ก็อาจสร้างคำขอซ้ำ หากเซิร์ฟเวอร์ไม่รองรับ idempotency การเชื่อมต่อที่สั่นคลอนครั้งเดียวอาจสร้างการเขียนซ้ำหลายครั้ง

“การกระทำที่สำคัญ” คือสิ่งที่ต้องเกิดไม่เกินหนึ่งครั้งและต้องถูกต้อง: การชำระเงิน, การยืนยันคำสั่ง, การจองเวลา, การโอนคะแนน, การเปลี่ยนรหัสผ่าน, การบันทึกที่อยู่จัดส่ง, การส่งเคลม หรือการอนุมัติ

ตัวอย่างสมจริง: ผู้ใช้ส่งการชำระเงินบน LTE อ่อน แอปส่งคำขอแต่การเชื่อมต่อหลุดก่อนจะได้รับคำตอบ ผู้ใช้เห็นข้อผิดพลาด กด “ชำระเงิน” อีกครั้ง และตอนนี้คำขอสองชุดไปถึงเซิร์ฟเวอร์ หากไม่มีกฎชัดเจน แอปไม่รู้ว่าควรลองใหม่ รอ หรือหยุด ผู้ใช้ก็ไม่รู้ว่าควรลองอีกหรือไม่

ตัดสินใจกฎก่อนปรับโค้ด

เมื่อการเชื่อมต่อช้า/ไม่เสถียร บั๊กส่วนใหญ่เกิดจากกฎไม่ชัดเจน ไม่ใช่ไคลเอนต์ HTTP ก่อนแตะค่าหมดเวลา การแคช หรือการลองใหม่ ให้จดว่า “ถูกต้อง” หมายถึงอะไรสำหรับแอปคุณ

เริ่มจากการระบุการกระทำที่ห้ามรันซ้ำโดยเด็ดขาด มักเป็นเรื่องเงินและบัญชี: สั่งสินค้า, เก็บเงิน, เบิกจ่าย, เปลี่ยนรหัสผ่าน, ลบบัญชี หากผู้ใช้กดสองครั้งหรือแอปลองใหม่ เซิร์ฟเวอร์ควรยังคงถือเป็นคำขอเดียว หากยังรับประกันไม่ได้ ให้ถือว่า endpoint เหล่านั้นเป็น “ห้ามลองใหม่อัตโนมัติ” จนกว่าจะแก้

จากนั้น กำหนดว่าหน้าจอแต่ละหน้าทำอะไรได้เมื่อเครือข่ายไม่ดี บางหน้าทำงานออฟไลน์ได้ (โปรไฟล์ล่าสุด, คำสั่งก่อนหน้า) บางหน้าควรเป็นอ่านอย่างเดียวหรือแสดงสถานะ “ลองอีกครั้ง” ชัดเจน (จำนวนสินค้าคงคลัง, ราคาแบบสด) การผสมความคาดหวังเหล่านี้ทำให้ UI สับสนและแคชมีความเสี่ยง

ตั้งเวลารอที่ยอมรับได้ต่อการกระทำตามความคาดหวังของผู้ใช้ ไม่ใช่ความสวยงามของโค้ด การล็อกอินทนรอสั้น ๆ ได้ การอัปโหลดไฟล์ต้องรอนานกว่า การชำระเงินควรรู้สึกเร็วแต่ปลอดภัย ค่า timeout 30 วินาทีอาจ “เชื่อถือได้” ทางทฤษฎีแต่รู้สึกพังสำหรับผู้ใช้

สุดท้าย ตัดสินว่าจะแจ้งหรือเก็บอะไรไว้บนอุปกรณ์และนานแค่ไหน แคชมีประโยชน์แต่ข้อมูลเก่าอาจทำให้เลือกผิด (ราคาล้าสมัย, สิทธิที่หมดอายุ)

เขียนกฎไว้ที่ทุกคนเข้าถึงได้ (README ก็พอ) และทำให้เรียบง่าย:

  • endpoint ไหนห้ามซ้ำและต้องจัดการ idempotency?
  • หน้าจอไหนต้องทำงานออฟไลน์ และหน้าไหนอ่านอย่างเดียวเมื่อออฟไลน์?
  • เวลารอสูงสุดต่อการกระทำคือเท่าไร (ล็อกอิน, รีเฟรชฟีด, อัปโหลด, ชำระเงิน)?
  • อะไรเก็บบนอุปกรณ์ได้ และหมดอายุเมื่อไร?
  • หลังล้มเหลว จะแสดงข้อผิดพลาด, คิวเพื่อส่งทีหลัง, หรือให้ผู้ใช้ลองเอง?

เมื่อกฎชัด ค่า timeout, เฮดเดอร์แคช, นโยบายลองใหม่ และสถานะ UI จะง่ายต่อการทำและทดสอบ

Timeout ที่ตรงตามความคาดหวังของผู้ใช้จริง

เครือข่ายช้าล้มเหลวหลายรูปแบบ การตั้งค่า timeout ที่ดีไม่ได้แค่ “เลือกตัวเลข” แต่มันต้องสอดคล้องกับสิ่งที่ผู้ใช้พยายามทำและล้มเร็วพอที่แอปจะกู้สถานการณ์ได้

สาม timeout ในภาษาง่าย ๆ:

  • connect timeout: เวลารอการตั้งการเชื่อมต่อไปยังเซิร์ฟเวอร์ (DNS, TCP, TLS). ถ้าล้ม คำขอก็ยังไม่เริ่มจริง
  • write timeout: เวลารอการส่งตัวเนื้อหาของคำขอ (อัปโหลด, JSON ขนาดใหญ่, uplink ช้า)
  • read timeout: เวลารอให้เซิร์ฟเวอร์ตอบกลับหลังส่งคำขอแล้ว ซึ่งมักเกิดบนเครือข่ายมือถือที่มีสัญญาณไม่แน่นอน

Timeout ควรสะท้อนหน้าจอและความเสี่ยง การโหลดฟีดช้าได้โดยไม่เป็นภัย การกระทำสำคัญควรจบหรือล้มอย่างชัดเจนเพื่อให้ผู้ใช้ตัดสินใจต่อได้

ค่าตัวอย่างเริ่มต้น (ปรับตามการวัดผล):

  • List loading (low risk): connect 5–10s, read 20–30s, write 10–15s
  • Search-as-you-type: connect 3–5s, read 5–10s, write 5–10s
  • Critical actions (high risk, like “Pay” or “Submit order”): connect 5–10s, read 30–60s, write 15–30s

ความสม่ำเสมอสำคัญกว่าความสมบูรณ์แบบ หากผู้ใช้แตะ “ส่ง” และเห็นวงกลมโหลดสองนาที เขาจะกดซ้ำ

หลีกเลี่ยงการโหลดไม่รู้จบด้วยการกำหนดขอบเขตบน UI แสดงความคืบหน้าเร็ว ๆ ให้ยกเลิกได้ และหลัง (เช่น) 20–30 วินาที แสดง “ยังพยายามอยู่…” กับตัวเลือกให้ลองอีกครั้งหรือเช็กการเชื่อมต่อ นั่นทำให้ประสบการณ์โปร่งใสแม้ไลบรารียังรออยู่

เมื่อ timeout เกิดขึ้น ให้ล็อกข้อมูลพอจะดีบักแนวโน้มต่อไป โดยไม่บันทึกความลับ ฟิลด์ที่มีประโยชน์ได้แก่ path ของ URL (ไม่ต้องใส่ query เต็ม), HTTP method, status (ถ้ามี), การแจกเวลา (connect vs write vs read หากมี), ประเภทเครือข่าย (Wi‑Fi, cellular, airplane mode), ขนาดประมาณการของคำขอ/คำตอบ, และ request ID เพื่อจับคู่ client logs กับ server logs

การตั้งค่า Kotlin เน็ตเวิร์กเรียบง่ายและสม่ำเสมอ

เมื่อการเชื่อมต่อช้า ความไม่สอดคล้องเล็ก ๆ ในการตั้งค่าไคลเอนต์จะกลายเป็นปัญหาใหญ่ พื้นฐานที่สะอาดช่วยให้ดีบักเร็วและให้คำขอทุกคำขอใช้กฎเดียวกัน

ไคลเอนต์เดียว นโยบายเดียว

เริ่มจากที่เดียวที่สร้าง HTTP client (มักเป็น OkHttpClient หนึ่งตัวที่ใช้กับ Retrofit) ใส่พื้นฐานไว้ที่นั่นเพื่อให้คำขอทุกชิ้นทำงานเหมือนกัน: เฮดเดอร์ดีฟอลต์ (เวอร์ชันแอป, locale, auth token) User‑Agent ชัดเจน, ตั้ง timeout ที่เดียว (ไม่กระจายไปทุกที่), เปิด logging สำหรับดีบัก และตัดสินใจนโยบายการลองใหม่หนึ่งแบบ (แม้จะเป็น “ไม่ลองใหม่อัตโนมัติ” ก็ตาม)

นี่คือตัวอย่างเล็ก ๆ ที่เก็บการกำหนดค่าไว้ไฟล์เดียว:

val okHttp = OkHttpClient.Builder()
  .connectTimeout(10, TimeUnit.SECONDS)
  .readTimeout(20, TimeUnit.SECONDS)
  .writeTimeout(20, TimeUnit.SECONDS)
  .callTimeout(30, TimeUnit.SECONDS)
  .addInterceptor { chain ->
    val request = chain.request().newBuilder()
      .header("User-Agent", "MyApp/${BuildConfig.VERSION_NAME}")
      .header("Accept", "application/json")
      .build()
    chain.proceed(request)
  }
  .build()

val retrofit = Retrofit.Builder()
  .baseUrl(BASE_URL)
  .client(okHttp)
  .addConverterFactory(MoshiConverterFactory.create())
  .build()

(บล็อกโค้ดด้านบนต้องคงไว้เหมือนเดิม ไม่แปลเนื้อหาในโค้ด)

การแมปข้อผิดพลาดเป็นข้อความที่ผู้ใช้เข้าใจได้ตรงกลาง

ข้อผิดพลาดเครือข่ายไม่ใช่แค่ "exception" หากแต่ละหน้าจอจัดการต่างกัน ผู้ใช้จะเห็นข้อความกระจัดกระจาย สร้าง mapper หนึ่งตัวที่แปลงความล้มเหลวเป็นชุดผลลัพธ์สำหรับผู้ใช้ไม่กี่แบบ: ไม่มีการเชื่อมต่อ/โหมดเครื่องบิน, ไทม์เอาต์, ข้อผิดพลาดเซิร์ฟเวอร์ (5xx), ข้อผิดพลาดยืนยันหรือ auth (4xx), และ fallback ไม่รู้จัก

วิธีนี้ทำให้ข้อความ UI สม่ำเสมอ ("ไม่มีการเชื่อมต่อ" กับ "ลองอีกครั้ง") โดยไม่เผยรายละเอียดทางเทคนิค

ติดแท็กและยกเลิกคำขอเมื่อหน้าจอปิด

บนเครือข่ายไม่เสถียร คำขออาจเสร็จล่าช้าและอัปเดตหน้าจอที่หายไปแล้ว ทำให้การยกเลิกเป็นกฎมาตรฐาน: เมื่อหน้าจอปิด งานของมันต้องหยุด

กับ Retrofit และ Kotlin coroutines การยกเลิก coroutine scope (เช่น ใน ViewModel) จะยกเลิก HTTP call ที่เกี่ยวข้อง สำหรับการเรียกที่ไม่ใช่ coroutine เก็บอ้างอิง Call แล้วเรียก cancel() หรือใช้การติดแท็กคำขอและยกเลิกกลุ่มคำขอเมื่อออกจากฟีเจอร์

งานเบื้องหลังไม่ควรพึ่ง UI

งานสำคัญที่ต้องเสร็จควรรันใน scheduler เฉพาะ On Android, WorkManager เป็นตัวเลือกทั่วไปเพราะมันสามารถลองใหม่ภายหลังและรอดชีวิตเมื่แอปรีสตาร์ท ให้ UI ทำงานเบา ๆ และโยนงานยาวไปให้ background jobs เมื่อสมเหตุสมผล

กฎการแคชที่ปลอดภัยบนมือถือ

สร้างแบ็กเอนด์พร้อมแอปมือถือ
สร้างแบ็กเอนด์ Go พร้อมใช้งานและแอปมือถือเนทีฟโดยไม่ต้องเชื่อมต่อทุก endpoint ด้วยมือ.
เริ่มสร้าง

การแคชช่วยได้มากบนการเชื่อมต่อช้า เพราะลดการดาวน์โหลดซ้ำและทำให้หน้าจอดูทันที แต่ก็เป็นปัญหาได้หากแสดงข้อมูลเก่าในเวลาที่ไม่เหมาะสม เช่น ยอดเงินหรือที่อยู่จัดส่งเก่าที่ทำให้ผู้ใช้ตัดสินใจผิด

แนวทางปลอดภัยคือแคชเฉพาะสิ่งที่ผู้ใช้ทนได้ว่าจะเก่าเล็กน้อย และบังคับเช็กความสดสำหรับสิ่งที่มีผลกระทบเรื่องเงิน/ความปลอดภัย/การตัดสินใจสุดท้าย

พื้นฐาน Cache‑Control ที่ไว้ใจได้

กฎส่วนใหญ่เกี่ยวกับเฮดเดอร์ไม่กี่อย่าง:

  • max-age=60: ใช้ response ในแคชได้ 60 วินาทีโดยไม่ต้องถามเซิร์ฟเวอร์
  • no-store: อย่าเก็บ response เลย (เหมาะกับโทเค็นและหน้าจออ่อนไหว)
  • must-revalidate: ถ้าหมดอายุ ต้องเช็กกับเซิร์ฟเวอร์ก่อนใช้ใหม่

บนมือถือ must-revalidate ป้องกันข้อมูล "ผิดเงียบ" หลังช่วงออฟไลน์ หากผู้ใช้เปิดแอปหลังจากออกจากรถไฟ คุณอยากได้หน้าจอที่เร็ว แต่ก็อยากให้ยืนยันสิ่งที่ยังเป็นจริง

ETag สำหรับรีเฟรช: เร็ว ราคาถูก และเชื่อถือได้

สำหรับ endpoint อ่าน การตรวจสอบด้วย ETag มักดีกว่าการตั้ง max-age ยาว ๆ เซิร์ฟเวอร์ส่ง ETag มากับ response ครั้งต่อไปแอปส่ง If-None-Match กับค่านั้น หากไม่มีการเปลี่ยนแปลง เซิร์ฟเวอร์ตอบ 304 Not Modified ที่เล็กและเร็วบนเครือข่ายอ่อน

วิธีนี้เหมาะกับรายการสินค้า, รายละเอียดโปรไฟล์, และหน้าการตั้งค่า

กฎง่าย ๆ:

  • แคช endpoint อ่านด้วย max-age สั้น ๆ + must-revalidate และรองรับ ETag เมื่อเป็นไปได้
  • อย่าแคช endpoint เขียน (POST/PUT/PATCH/DELETE). ถือว่าเป็นการเรียกที่ต้องใช้เครือข่ายเสมอ
  • ใช้ no-store สำหรับข้อมูลอ่อนไหว (การตอบรับการยืนยันตัวตน, ขั้นตอนการจ่ายเงิน, ข้อความส่วนตัว)
  • แคชทรัพยากรคงที่ (ไอคอน, ค่ากำหนดสาธารณะ) นานกว่าเพราะความเสี่ยงจากข้อมูลเก่าต่ำ

ทำให้การตัดสินใจแคชสอดคล้องทั่วแอป เพราะผู้ใช้สังเกตความไม่สอดคล้องได้มากกว่าความหน่วงเล็กน้อย

การลองใหม่อย่างปลอดภัยโดยไม่ทำให้แย่ลง

ส่งแอปเนทีฟให้เร็วขึ้น
สร้างแอป Android และ iOS เนทีฟที่จัดการการเชื่อมต่อช้าได้ด้วยสถานะที่สอดคล้องกัน.
สร้างมือถือ

การลองใหม่รู้สึกเป็นทางแก้ง่าย แต่สามารถย้อนกลับได้ ลองคำขอผิด ๆ แล้วสร้างโหลดเกินความจำเป็น, สูบแบต, และทำให้แอปค้างได้

เริ่มจากการลองเฉพาะความล้มเหลวที่เป็นไปได้ชั่วคราว การตัดการเชื่อมต่อ, ไทม์เอาต์, หรือ outage สั้น ๆ อาจผ่านได้ในการลองครั้งถัดไป ข้อผิดพลาดรหัส 4xx บางอย่างไม่ควรถูกลอง

กฎปฏิบัติ:

  • ลองใหม่เมื่อเกิด timeout และการเชื่อมต่อล้มเหลว
  • ลองใหม่สำหรับ 502, 503 และบางครั้ง 504
  • อย่าลองใหม่สำหรับ 4xx (ยกเว้น 408 หรือ 429 หากมีกฎรอชัดเจน)
  • อย่าลองใหม่สำหรับคำขอที่อาจถึงเซิร์ฟเวอร์และกำลังประมวลผลแล้ว
  • จำกัดจำนวนการลองใหม่ต่ำ (มัก 1–3 ครั้ง)

Backoff + jitter: ลดพายุการลองใหม่

หากผู้ใช้จำนวนมากเจอ outage พร้อมกัน การลองใหม่ทันทีทำให้เกิดคลื่นทราฟฟิกที่ชะลอการฟื้นตัว ใช้ exponential backoff (รอเพิ่มขึ้นทุกครั้ง) และเพิ่ม jitter (ดีเลย์สุ่มเล็กน้อย) เพื่อให้อุปกรณ์ไม่ลองพร้อมกัน

ตัวอย่าง: รอประมาณ 0.5s, แล้ว 1s, แล้ว 2s พร้อมสุ่ม +/- 20% แต่ละครั้ง

จำกัดเวลารวมของการลองใหม่

ถ้าไม่มีขอบเขต การลองใหม่จะดักผู้ใช้ไว้กับวงโหลดนานเป็นนาที เลือกเวลาสูงสุดสำหรับการดำเนินการทั้งหมด รวมการรอทั้งหมด แอปหลายตัวตั้งเป้า 10–20 วินาทีก่อนหยุดและแสดงตัวเลือกให้ผู้ใช้ลองอีกครั้ง

และปรับตามบริบท หากผู้ใช้กำลังส่งฟอร์ม เขาอยากได้คำตอบเร็ว หากการซิงค์เบื้องหลังล้มเหลว ให้ลองใหม่ภายหลัง

อย่าลองใหม่อัตโนมัติสำหรับการกระทำที่ไม่ idempotent (เช่นการสั่งซื้อหรือการจ่ายเงิน) เว้นแต่จะมีการป้องกันเช่น idempotency key หรือการตรวจสอบการซ้ำฝั่งเซิร์ฟเวอร์ หากไม่รับประกันความปลอดภัย ให้ล้มเหลวอย่างชัดเจนและให้ผู้ใช้ตัดสินใจ

ป้องกันการซ้ำสำหรับการกระทำสำคัญ

บนการเชื่อมต่อช้าหรือไม่เสถียร ผู้ใช้กดสองครั้ง ระบบปฏิบัติการอาจลองใหม่เบื้องหลัง แอปอาจส่งซ้ำหลัง timeout หากการกระทำคือการสร้างทรัพยากร (สั่งสินค้า, โอนเงิน, เปลี่ยนรหัสผ่าน) การซ้ำอาจเป็นอันตราย

Idempotency หมายความว่าคำขอเดียวกันควรให้ผลเหมือนเดิม หากเรียกซ้ำ เซิร์ฟเวอร์ไม่ควรสร้างคำสั่งที่สอง แต่ควรคืนผลลัพธ์เดิมหรือระบุว่า "เสร็จแล้ว"

ใช้ idempotency key สำหรับการพยายามแต่ละครั้ง

สำหรับการกระทำสำคัญ สร้าง idempotency key เฉพาะเมื่อผู้ใช้เริ่มการพยายาม ส่งมันพร้อมคำขอ (มักเป็นเฮดเดอร์ Idempotency-Key หรือฟิลด์ในบอดี้)

ลำดับการทำงานปฏิบัติ:

  • สร้าง UUID เป็น idempotency key เมื่อผู้ใช้กด “Pay”
  • เก็บไว้ท้องถิ่นเป็นบันทึกเล็ก ๆ: status = pending, createdAt, แฮช payload
  • ส่งคำขอพร้อมคีย์
  • เมื่อได้คำตอบสำเร็จ ให้ทำเครื่องหมาย status = done และเก็บ server result ID
  • หากต้องลองใหม่ ให้ใช้คีย์เดิม ไม่ใช่คีย์ใหม่

กฎ "ใช้คีย์เดิม" นี้คือสิ่งที่หยุดการเรียกเก็บซ้ำโดยไม่ได้ตั้งใจ

จัดการการรีสตาร์ทแอปและช่องว่างออฟไลน์

ถ้าแอปถูกฆ่ากลางคำขอ การเปิดใหม่ต้องปลอดภัย เก็บ idempotency key และสถานะคำขอในที่เก็บท้องถิ่น (เช่นแถวฐานข้อมูลเล็ก ๆ) เมื่อรีสตาร์ท ให้ลองใหม่ด้วยคีย์เดิมหรือเรียก endpoint "ตรวจสถานะ" โดยใช้คีย์ที่เก็บไว้หรือ server result ID

ฝั่งเซิร์ฟเวอร์ สัญญาควรชัด: เมื่อได้รับคีย์ซ้ำ ให้ปฏิเสธการพยายามที่สองหรือคืน response เดิม (ID คำสั่งเดียวกัน, ใบเสร็จเดียวกัน) หากเซิร์ฟเวอร์ยังทำไม่ได้ ลูกค้าป้องกันซ้ำจะไม่เคยเชื่อถือได้เต็มที่ เพราะแอปไม่เห็นว่าเกิดอะไรหลังส่งคำขอ

ทัชที่ดีต่อผู้ใช้: หากการพยายามยัง pending ให้แสดงว่า "การชำระเงินกำลังดำเนินการ" และปิดปุ่มจนกว่าจะได้ผลสุดท้าย

รูปแบบ UI ที่ลดการส่งซ้ำโดยไม่ตั้งใจ

เพิ่มระบบล็อกอินและการชำระเงิน
ใช้โมดูลพร้อมใช้งานอย่างระบบยืนยันตัวตนและการชำระเงินผ่าน Stripe เพื่อย้ายจากต้นแบบสู่เวิร์กโฟลว์จริง.
เริ่มใช้งาน

การเชื่อมต่อช้าไม่เพียงทำให้คำขอล้มเหลว แต่มันเปลี่ยนวิธีที่ผู้คนแตะ เมื่อหน้าจอค้างสองวินาที ผู้ใช้หลายคนคิดว่าไม่มีอะไรเกิดขึ้นและกดซ้ำ UI ต้องทำให้การแตะครั้งเดียวรู้สึกเชื่อถือได้แม้เครือข่ายไม่ดี

Optimistic UI ปลอดภัยเมื่อการกระทำย้อนกลับได้หรือความเสี่ยงต่ำ เช่นกดไลก์ เก็บร่าง หรือทำเครื่องหมายว่าอ่านแล้ว แต่สำหรับเงิน สต็อก หรือลบถาวร ให้ใช้ Confirmed UI

ค่าเริ่มต้นที่ดีสำหรับการกระทำสำคัญคือสถานะ pending ชัดเจน หลังแตะครั้งแรก ให้เปลี่ยนปุ่มหลักเป็น “กำลังส่ง…” ปิดการแตะ และแสดงบรรทัดสั้น ๆ อธิบายสิ่งที่เกิดขึ้น

รูปแบบที่ได้ผลบนเครือข่ายไม่เสถียร:

  • ปิดใช้งานปุ่มหลักหลังแตะและเก็บปิดจนกว่าจะได้ผลสุดท้าย
  • แสดงสถานะ “Pending” ที่มองเห็นได้พร้อมรายละเอียด (จำนวนเงิน ผู้รับ จำนวนสินค้า)
  • เพิ่มมุมมอง "กิจกรรมล่าสุด" ให้ผู้ใช้ยืนยันสิ่งที่ส่งไปแล้ว
  • หากแอปถูกย้ายไปพื้นหลัง ให้คงสถานะ pending เมื่อกลับมา
  • ใช้ปุ่มหลักเดียวที่ชัดเจน แทนหลายเป้าบนจอเดียว

บางครั้งคำขอสำเร็จแต่ตอบกลับหาย ให้ถือเป็นผลลัพธ์ปกติ ไม่ใช่ข้อผิดพลาดที่ชวนให้กดซ้ำ แทนที่จะบอกว่า "ล้มเหลว ลองอีกครั้ง" ให้แสดงว่า "เราไม่แน่ใจ" และเสนอทางเลือกปลอดภัยเช่น "ตรวจสถานะ" หากไม่สามารถตรวจสถานะได้ ให้เก็บบันทึก pending ท้องถิ่นและบอกผู้ใช้ว่าจะอัปเดตเมื่อเชื่อมต่อกลับ

ทำให้ปุ่ม "ลองอีกครั้ง" ชัดเจนและปลอดภัย เฉพาะแสดงเมื่อสามารถทำซ้ำคำขอด้วย request ID ฝั่งลูกค้าหรือ idempotency key เดิมได้

ตัวอย่างสมจริง: การส่งคำสั่งซื้อที่ไม่เสถียร

เป็นเจ้าของซอร์สโค้ด
รับซอร์สโค้ดจริงที่สามารถส่งออก ตรวจสอบ และดีพลอยได้ตามต้องการ.
สร้างโค้ด

ลูกค้าคนหนึ่งอยู่บนรถไฟที่สัญญาณไม่แน่นอน เขาเพิ่มของลงตะกร้าและกด Pay แอปต้องอดทน แต่ก็ห้ามสร้างคำสั่งสองชุด

ลำดับที่ปลอดภัย:

  1. แอปสร้าง attempt ID ฝั่งลูกค้าและส่งคำขอ checkout พร้อม idempotency key (เช่น UUID เก็บกับตะกร้า)
  2. คำขอรอ connect timeout ชัดเจน แล้วรอ read timeout ที่ยาวกว่า รถไฟเข้าอุโมงค์ คำขอล้มด้วย timeout
  3. แอปลองใหม่ครั้งหนึ่ง แต่เฉพาะเมื่อมันไม่เคยได้รับการตอบกลับจากเซิร์ฟเวอร์เท่านั้น และหลังหน่วงสั้น ๆ
  4. เซิร์ฟเวอร์ได้รับคำขอที่สองและเห็น idempotency key เดิม จึงคืนผลเดิมแทนการสร้างคำสั่งใหม่
  5. แอปแสดงหน้าคอนเฟิร์มสุดท้ายเมื่อได้รับ response สำเร็จ แม้จะมาจากการลองใหม่

การแคชต้องเข้มงวด รายการสินค้า ตัวเลือกการจัดส่ง และตารางภาษีสามารถแคชระยะสั้น (GET) ได้ การส่งคำสั่ง (POST) ไม่เคยถูกแคช แม้ใช้ HTTP cache ก็ถือเป็นช่วยอ่านเท่านั้น ไม่ใช่สิ่งที่จำได้แทนการจ่ายเงิน

การป้องกันการซ้ำเป็นการผสมระหว่างเครือข่ายและ UI เมื่อผู้ใช้กด Pay ปุ่มถูกปิดและหน้าจอแสดง "กำลังส่งคำสั่ง..." พร้อมปุ่มยกเลิกเดียว หากแอปสูญเสียเครือข่าย ให้เปลี่ยนเป็น "ยังพยายาม" และคง attempt ID เดิม หากผู้ใช้บังคับปิดและเปิดใหม่ แอปสามารถกู้ด้วยการตรวจสถานะคำสั่งโดยใช้ ID นั้นแทนที่จะขอให้จ่ายซ้ำ

เช็กลิสต์ด่วนและขั้นตอนต่อไป

หากแอปของคุณ "ใช้ได้ดีพอ" ใน Wi‑Fi สำนักงานแต่พังบนรถไฟ ลิฟต์ หรือพื้นที่ชนบท ให้ถือเป็นเกตก่อนปล่อยงาน งานนี้เป็นเรื่องของกฎชัดเจนมากกว่าการเขียนโค้ดฉลาด

เช็กลิสต์ก่อนปล่อย:

  • ตั้ง timeout ตามประเภท endpoint (ล็อกอิน, ฟีด, อัปโหลด, ชำระเงิน) และทดสอบบนเครือข่ายจำกัดและความหน่วงสูง
  • ลองใหม่เฉพาะเมื่อปลอดภัยจริง ๆ และจำกัดด้วย backoff (พยายามไม่กี่ครั้งสำหรับการอ่าน, มักไม่สำหรับการเขียน)
  • ใส่ idempotency key สำหรับการเขียนสำคัญทุกครั้ง (การจ่ายเงิน, คำสั่ง, การส่งฟอร์ม) เพื่อไม่ให้การลองใหม่หรือการกดซ้ำสร้างซ้ำ
  • ระบุการตัดสินใจการแคช: อะไรยอมให้เก่าได้เล็กน้อย อะไรต้องสด และอะไรห้ามแคช
  • ทำให้สถานะมองเห็นได้: pending, failed, completed ควรดูต่างกัน และแอปต้องจำการกระทำที่เสร็จหลังรีสตาร์ท

ถ้าข้อใดข้อหนึ่งอยู่ในหมวด “จะตัดสินทีหลัง” คุณจะได้พฤติกรรมสุ่มตามหน้าจอ

ขั้นตอนถัดไปเพื่อให้ยั่งยืน

เขียนนโยบายเน็ตเวิร์กหน้าเดียว: หมวด endpoint, เป้าหมาย timeout, กฎ retry, และความคาดหวังการแคช บังคับใช้ในที่เดียว (interceptors, shared client factory, หรือ wrapper เล็ก ๆ) เพื่อให้ทุกคนได้พฤติกรรมเดียวกันตามดีฟอลต์

แล้วทำการฝึกซ้ำป้องกันการซ้ำ เลือกการกระทำสำคัญหนึ่งอย่าง (เช่น checkout), จำลองวงโหลดค้าง, บังคับปิดแอป, สลับโหมดเครื่องบิน, และกดปุ่มอีกครั้ง หากพิสูจน์ไม่ได้ว่าปลอดภัย ผู้ใช้ก็จะหาแนวทางทำให้แตกได้ในที่สุด

ถ้าต้องการนำกฎเดียวกันไปใช้ทั้งแบ็กเอนด์และไคลเอนต์โดยไม่ต้องเดินสายทุก endpoint ด้วยมือ AppMaster (appmaster.io) ช่วยสร้างแบ็กเอนด์พร้อมใช้งานสำหรับโปรดักชันและโค้ดมือถือเนทีฟได้ แม้กระนั้น กุญแจสำคัญยังคงเป็นนโยบาย: กำหนด idempotency, retry, caching, และสถานะ UI ครั้งเดียว แล้วนำไปใช้ให้สม่ำเสมอทั่วทั้งฟลว์

คำถามที่พบบ่อย

What’s the first thing I should do before tweaking timeouts and retries?

เริ่มด้วยการกำหนดว่าคำว่า “ถูกต้อง” หมายถึงอะไรสำหรับแต่ละหน้าจอและแต่ละการกระทำ โดยเฉพาะอย่างยิ่งงานที่ต้องไม่เกิดซ้ำ เช่น การชำระเงินหรือการสั่งซื้อ เมื่อมีกฎชัดเจนแล้ว จึงตั้งค่า timeout, retry, การแคช และสถานะ UI ให้สอดคล้อง แทนการพึ่งพาค่าดีฟอลต์ของไลบรารี

What are the most common symptoms users notice on slow or flaky networks?

ผู้ใช้มักเห็นสัญญาณ: วงกลมโหลดไม่จบ, ข้อผิดพลาดหลังรอเป็นเวลานาน, การทำงานที่สำเร็จเมื่อพยายามใหม่, หรือผลลัพธ์ซ้ำเช่นคำสั่งสองครั้งหรือการเรียกเก็บเงินสองครั้ง เหล่านี้มักเกิดจากกฎการลองใหม่และการแยกระหว่าง “กำลังรอ” กับ “ล้มเหลว” ที่ไม่ชัดเจน มากกว่าจากสัญญาณเครือข่ายอย่างเดียว

How should I think about connect, read, and write timeouts on mobile?

ใช้ connect timeout สำหรับการรอการตั้งการเชื่อมต่อ, write timeout สำหรับการส่งตัวเนื้อหาของคำขอ (เช่นการอัปโหลด), และ read timeout สำหรับการรอการตอบกลับหลังส่งแล้ว ค่าเริ่มต้นที่สมเหตุสมผลคือ timeout สั้นสำหรับการอ่านที่ความเสี่ยงต่ำ และ timeout ยาวขึ้นสำหรับการส่งข้อมูลหรือการส่งคำขอที่สำคัญ ควรมีขอบเขตใน UI ด้วยเพื่อไม่ให้ผู้ใช้รอนานเกินไป

If I can only set one timeout in OkHttp, which one should it be?

ถ้าตั้งค่าได้แค่ตัวเดียว ให้ใช้ callTimeout เพื่อจำกัดเวลาทั้งกระบวนการตั้งแต่ต้นจนจบ เพื่อหลีกเลี่ยงการรอแบบ “ไม่มีที่สิ้นสุด” จากนั้นถ้าทำได้ ให้ตั้ง connect/read/write เพิ่มเติมเพื่อควบคุมกรณีอัปโหลดหรือการตอบช้าได้ละเอียดขึ้น

Which errors are usually safe to retry, and which aren’t?

ลองเฉพาะความล้มเหลวชั่วคราว เช่นการตัดการเชื่อมต่อ ไทม์เอาต์ หรือปัญหา DNS และบางครั้งข้อผิดพลาด 502/503/504 หลีกเลี่ยงการลองใหม่สำหรับ 4xx ยกเว้น 408 หรือ 429 หากมีนโยบายรอที่ชัดเจน และห้ามลองใหม่อัตโนมัติสำหรับการเขียนข้อมูลที่อาจถูกประมวลผลแล้ว ถ้าไม่มีการป้องกัน idempotency

How do I add retries without making the app feel stuck?

ใช้จำนวนครั้งลองใหม่ที่น้อย (มัก 1–3 ครั้ง) พร้อม exponential backoff และ jitter เล็กน้อย เพื่อไม่ให้อุปกรณ์หลายเครื่องลองใหม่พร้อมกัน นอกจากนี้จำกัดเวลารวมของการลองใหม่เพื่อให้ผู้ใช้ได้รับผลลัพธ์ชัดเจน ไม่ใช่การรอเป็นนาที

What is idempotency, and why does it matter for payments and orders?

Idempotency หมายถึงการเรียกคำขอเดิมซ้ำจะไม่สร้างผลลัพธ์ซ้ำ ดังนั้นการแตะสองครั้งหรือการลองใหม่จะไม่ทำให้ถูกเก็บเงินสองครั้ง สำหรับการกระทำที่สำคัญ ให้ส่ง idempotency key ต่อการพยายามหนึ่งครั้ง และใช้คีย์เดิมเมื่อต้องการลองใหม่ เพื่อให้เซิร์ฟเวอร์ตอบผลลัพธ์เดิมแทนการสร้างรายการใหม่

How should I generate and store an idempotency key on Android?

สร้างคีย์เฉพาะเมื่อลูกค้าเริ่มการกระทำ เก็บไว้ท้องถิ่นในรูปแบบบันทึกสถานะ “pending” และส่งคีย์นั้นพร้อมคำขอ หากต้องลองใหม่หรือแอปรีสตาร์ท ให้ใช้คีย์เดิมเพื่อรีเทนสเตตหรือเช็คสถานะ แทนการสร้างคำขอใหม่ทุกครั้ง

What caching rules are safest for mobile apps on unreliable connections?

แคชเฉพาะข้อมูลที่ผู้ใช้ยอมรับได้ว่าจะเก่าเล็กน้อย และยืนยันข้อมูลใหม่สำหรับเรื่องที่เกี่ยวข้องกับเงิน ความปลอดภัย หรือการตัดสินใจสุดท้าย สำหรับการอ่าน (GET) ใช้ความสดสั้น ๆ พร้อมการยืนยัน (revalidation) และพิจารณา ETag สำหรับการตรวจสอบว่ามีการเปลี่ยนแปลงหรือไม่ ส่วนการเขียน (POST/PUT/PATCH/DELETE) ไม่ควรถูกแคช และใช้ no-store สำหรับข้อมูลอ่อนไหว

What UI patterns reduce double taps and accidental resubmits on slow networks?

ปิดปุ่มหลักหลังแตะครั้งแรก แสดงสถานะ “กำลังส่ง…” ทันที และเก็บสถานะ pending ไว้เมื่อแอปกลับมา foreground หรือหลังรีสตาร์ท หากการตอบกลับอาจสูญหาย ให้แสดงข้อความว่า “เราไม่แน่ใจ” แทนที่จะบอกว่า “ล้มเหลว” และเสนอทางเลือกปลอดภัย เช่น “ตรวจสอบสถานะ” อย่าเชิญชวนให้ผู้ใช้แตะซ้ำโดยไม่มีการรับประกันความปลอดภัย

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม