Apps foundation

ปรับขนาดแอปสำหรับทุกหน้าจอด้วย Compose

ลำโพง

เกี่ยวกับ

เกี่ยวกับการเสวนา

ระบบนิเวศของ Android มีขนาดใหญ่มาก โดยผู้ใช้สามารถสลับไปมาระหว่างโทรศัพท์ แท็บเล็ต อุปกรณ์แบบพับได้ และสภาพแวดล้อมเดสก์ท็อปได้อย่างราบรื่น ตอนนี้เราใช้ Compose เป็นหลักแล้ว เซสชันนี้จะสำรวจวิธีออกแบบสถาปัตยกรรมแอปสำหรับหน้าต่างที่ปรับขนาดได้และสัดส่วนภาพที่หลากหลาย ซึ่งสมมติฐานเกี่ยวกับเลย์เอาต์แบบเดิมๆ จะใช้ไม่ได้อีกต่อไป ส่งผลให้ UI ยืดออกและสถานะสูญหาย

สำรวจว่าความสามารถล่าสุดใน Jetpack Compose, Navigation 3 และ WindowManager จะช่วยคุณสร้างเลย์เอาต์แบบไดนามิกที่ปรับขนาดได้อย่างเป็นธรรมชาติจากหน้าจอขนาดกะทัดรัดไปจนถึงการแสดงผลแบบแยกหน้าจอได้อย่างไร รับกลยุทธ์เพื่อเร่งเวิร์กโฟลว์การพัฒนาโดยใช้ Gemini และทักษะของเรา เช่น ทักษะการปรับเปลี่ยน เพื่อเปลี่ยนโครงสร้างภายในฐานของโค้ดเดิมโดยอัตโนมัติสำหรับหน้าจอขนาดใหญ่ขึ้น

สรุป

  • แพลตฟอร์ม Android ต้องใช้แอปที่ปรับเปลี่ยนได้เนื่องจากระบบจะไม่สนใจการล็อกการวางแนวและสัดส่วนภาพบนหน้าจอขนาดใหญ่ตั้งแต่ Android 16 เป็นต้นไป และจะนำการเลือกไม่ใช้ไฟล์ Manifest ออกใน Android 17
  • MediaQuery API เวอร์ชันทดลองจะติดตามความสามารถของ UI ที่ใช้งานอยู่ เช่น ขนาดหน้าต่างและท่าทางการพับในลักษณะที่ประกาศ โดยจะแทนที่การเชื่อมต่อเครื่องมือจัดการแพลตฟอร์มเดิมหลายรายการเข้ากับสถานะ Compose
  • Grid และ FlexBox แบบพื้นฐานที่เสถียรมีองค์ประกอบที่ใช้สร้างสรรค์เลย์เอาต์แบบปรับเปลี่ยนได้ Grid ช่วยให้จัดเรียงหลายมิติแบบกำหนดพารามิเตอร์ได้ภายใน Composable เดียว ส่วน FlexBox จะช่วยกระจายและตัดพื้นที่แบบมิติเดียว
  • Jetpack Navigation 3 จะจัดโครงสร้างโฟลว์แบบหลายแผงในระดับแอปตามสถานะและฉาก กลยุทธ์ฉากที่กำหนดเองจะประเมินความสามารถของ UI และคลาสขนาดหน้าต่างเพื่อสลับระหว่างการนำเสนอแบบแผงเดียวและหลายแผงแบบไดนามิก
  • ทักษะ Compose Adaptive สำหรับ Gemini จะย้ายข้อมูลแอปไปยังคอมโพเนนต์ที่ปรับเปลี่ยนตามอุปกรณ์ที่ทันสมัยโดยอัตโนมัติ ทักษะที่อัปเดตจะเปลี่ยนโครงสร้างเลย์เอาต์ แก้ไขข้อบกพร่องของการสูญเสียสถานะ และกำหนดค่าอินพุตบนเดสก์ท็อป เช่น แป้นพิมพ์ลัดและเคอร์เซอร์

แหล่งข้อมูล

Gemini Notebook

หัวข้อ

  • jetpack-compose
  • adaptive-layouts
  • mediaquery
  • grid-layout
  • Flexbox
  • navigation-3
  • android-skills

ข้อความถอดเสียง

ข้อมูลเบื้องต้นเกี่ยวกับการปรับขนาดแอปในหน้าจอต่างๆ

  • Francesco Romano

    สวัสดีทุกคน

  • วันนี้เราจะมาเจาะลึกด้านเทคนิคเกี่ยวกับวิธีปรับขนาดแอปให้เหมาะกับทุกหน้าจอด้วย Jetpack Compose

  • เหตุใดจึงต้องปรับขนาดให้เหมาะกับทุกหน้าจอ

  • เนื่องจากระบบนิเวศของ Android ได้ขยายไปยังอุปกรณ์รูปแบบต่างๆ มากมาย

  • APK ของคุณสามารถทำงานบนโทรศัพท์, อุปกรณ์แบบพับได้, แท็บเล็ต, Chromebook และ Googlebook, ทีวี, ระบบสาระบันเทิงในรถยนต์, อุปกรณ์ XR และอื่นๆ

  • สิ่งแรกคือการเข้าถึงผู้ใช้ในที่ที่พวกเขาอยู่

  • ประการที่ 2 การเปลี่ยนแพลตฟอร์ม Android ไปใช้แอปที่ปรับเปลี่ยนตามอุปกรณ์ไม่ใช่เรื่องชั่วคราว

  • เราไม่ได้จะย้อนกลับไปสู่โลกที่มีแต่โทรศัพท์ แต่เทคโนโลยีนี้จะอยู่กับเราตลอดไป

  • และฉันมีไทม์ไลน์คร่าวๆ ดังนี้

  • ในปี 2022 เราได้เริ่มวางรากฐาน

  • เรามี Android 12L ซึ่งเป็นรุ่นแรกของ Jetpack WindowManager เราได้มอบ API ต่างๆ เช่น คลาสขนาดหน้าต่าง และเราได้เผยแพร่หลักเกณฑ์ด้านคุณภาพของแอปที่ปรับเปลี่ยนตามอุปกรณ์เวอร์ชันแรก

  • นอกจากนี้ เรายังอัปเดต Google Play เพื่อให้คะแนนแยกตามประเภทอุปกรณ์ และเริ่มรวมหลักเกณฑ์ด้านคุณภาพของแอปที่ปรับเปลี่ยนได้ไว้ในการตัดสินใจจัดอันดับ

  • ใน Android 15 เรายังมีหน้าต่างเดสก์ท็อปที่เปิดตัวใน AOSP และ Compose Material 3 Adaptive ที่เสถียรแล้ว

  • และตั้งแต่ Android 16, SDK 36 เป็นต้นไป ระบบจะไม่สนใจการล็อกการวางแนว การล็อกสัดส่วนภาพ และแฟล็กความสามารถในการปรับขนาดบนหน้าจอขนาดใหญ่

  • เมื่อเปิดตัวจอแสดงผลที่เชื่อมต่อ ตอนนี้ APK ของคุณจึงทำงานได้ในหน้าต่างเกือบทุกขนาด

  • และ SDK 36 มี Flag ไฟล์ Manifest ชั่วคราว 1 รายการให้คุณเลือกไม่ใช้การลบล้างการวางแนวและสัดส่วนภาพ

  • ใน Android 17 ตัวเลือกการเลือกไม่ใช้นั้นจะหายไป

  • ดังนั้นการสร้างแอปที่ปรับเปลี่ยนได้จึงเป็นข้อกำหนดพื้นฐานของแพลตฟอร์ม

  • ในช่วงทศวรรษที่ผ่านมา เราได้ทำงานอย่างใกล้ชิดกับทีมต่างๆ ในระบบนิเวศ และได้เรียนรู้โดยตรงว่าทำไมการสร้างแอปที่ปรับเปลี่ยนตามอุปกรณ์จึงเป็นเรื่องยาก

  • คุณอาจพบปัญหาการสูญเสียสถานะ, API ของหน้าต่างที่กระจัดกระจาย, สัญญาณที่ไม่ชัดเจนว่าควรเปลี่ยนเลย์เอาต์เมื่อใด และสมมติฐานเก่าๆ บางอย่างที่ฝังลึกอยู่ในฐานของโค้ด

  • เราจึงนำทุกสิ่งที่ได้เรียนรู้จากความท้าทายของนักพัฒนาซอฟต์แวร์ตลอดทศวรรษนี้มาสร้างไว้ใน 2 แห่งโดยตรง ได้แก่ ชุดเครื่องมือ UI เอง

  • วันนี้เราจะมาดูความสามารถใหม่ๆ ที่สำคัญใน Jetpack Compose ซึ่งได้แก่ Media Query, Grid, FlexBox และ Navigation 3

  • แต่ยังรวมถึงเครื่องมือ Agentic ที่เขียนโค้ดร่วมกับคุณผ่านทักษะ Android อย่างเป็นทางการสำหรับ Gemini ใน Android Studio และ Android CLI ด้วย

  • ตัวอย่างเช่น เราจะเห็นทักษะแบบปรับอัตโนมัติของ Compose ที่เปิดตัวในงาน Google I/O เมื่อต้นปีนี้

  • ซึ่งสามารถย้ายข้อมูลเลย์เอาต์โทรศัพท์ไปยังหน้าต่างขนาดใหญ่ขึ้นได้อยู่แล้ว

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

  • เรามาเริ่มที่ Jetpack Compose ซึ่งเป็นรากฐานของทั้งหมดนี้กันก่อน

  • Jetpack Compose มีความเสถียรในปี 2021 ซึ่งถือเป็นวันเกิดปีที่ 5 ของ Jetpack Compose

  • และตลอดระยะเวลา 5 ปีที่ผ่านมา เราได้เห็นแอปเวอร์ชันที่ใช้งานจริงหลายพันแอปที่แสดงให้เห็นว่าการสร้างแอปที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Compose นั้นง่ายและเชื่อถือได้มากขึ้นเพียงใด

  • ตอนนี้การพัฒนาแอป Android ใช้ Compose เป็นหลักอย่างเป็นทางการแล้ว

  • ซึ่งหมายความว่าเลย์เอาต์แบบปรับเปลี่ยนได้ สไตล์ และคอมโพเนนต์การนำทางที่ทันสมัยใหม่ทั้งหมดสร้างขึ้นสำหรับ Compose

  • และจากมุมมองสถาปัตยกรรมระบบ ข้อดีที่สำคัญที่สุดของ Compose สำหรับแอปที่ปรับเปลี่ยนได้เมื่อเทียบกับระบบมุมมองเดิมคือวิธีที่สถานะและ UI โต้ตอบกัน

  • และเพื่ออธิบายเรื่องนี้ ฉันขอเปรียบเทียบกับระบบมุมมองอย่างรวดเร็ว

  • ลองนึกถึงวิธีการทำงานของระบบมุมมอง

  • UI ของคุณเป็นโครงสร้างแบบต้นไม้ของออบเจ็กต์ที่เปลี่ยนแปลงได้ซึ่งคุณขยายจากไฟล์ XML แบบคงที่เพียงครั้งเดียว

  • คุณมีตัวระบุทรัพยากรที่แตกต่างกัน และเรามาเริ่มกันที่ปัญหาหลักที่นี่ นั่นคือการทำซ้ำในตัวระบุทรัพยากรที่แตกต่างกัน

  • เมื่อสร้าง UI แบบปรับอัตโนมัติใน XML คุณจะไม่มีตรรกะแบบมีเงื่อนไขภายในไฟล์เลย์เอาต์

  • แต่คุณเพียงแค่ทำซ้ำลำดับชั้นทั้งหมดในโฟลเดอร์ทรัพยากร เช่น เลย์เอาต์ เลย์เอาต์หน้าต่างที่เล็กที่สุด 600 dp เลย์เอาต์แนวนอน และอื่นๆ

  • มาดูตัวอย่าง EditText ทางด้านขวากัน

    <LinearLayout android:orientation="vertical">
      <EditText
          android:id="@+id/text_msg"
          android:inputType="textCapCharacters"
          android:maxLength="12" />
    </LinearLayout>
    <ConstraintLayout>
      <TextInputEditText
        android:id="@+id/text_input"
        android:inputType="text" />
    </ConstraintLayout>
  • ซึ่งเป็นคอมโพเนนต์เชิงตรรกะเดียวกันกับที่จัดการการป้อนข้อความ และกระจายอยู่ในไฟล์ XML ต่างๆ

  • หากคุณอัปเดตแอตทริบิวต์ เช่น inputType หรือ maxLength ในเลย์เอาต์โทรศัพท์ คอมไพเลอร์จะไม่บังคับให้คุณอัปเดตแอตทริบิวต์ดังกล่าวในเลย์เอาต์แท็บเล็ตด้วย

  • และเนื่องจากคอมโพเนนต์แยกออกจากกันในไฟล์ต่างๆ จึงแทบจะรับประกันได้ว่าโครงสร้างจะเปลี่ยนไปเมื่อเวลาผ่านไป

  • และการทำซ้ำนั้นยังเชื่อมโยงกับปัญหาอื่นด้วย ซึ่งก็คือการสูญเสียสถานะระหว่างการเปลี่ยนแปลงการกำหนดค่า

  • โปรดทราบว่าในระบบ View คอมโพเนนต์ UI จะเก็บสถานะไว้

  • EditText เป็นเจ้าของบัฟเฟอร์ที่แก้ไขได้ภายใน ตำแหน่งเคอร์เซอร์ที่กำหนดเอง สถานะโฟกัส และสถานะบางอย่าง

  • ดังนั้นเมื่อผู้ใช้กางอุปกรณ์ออกหรือปรับขนาดหน้าต่างข้ามเกณฑ์ที่กำหนด Android จะทำลายลำดับชั้นการแสดงผลทั้งหมดและสร้างไฟล์ XML ใหม่ตั้งแต่ต้น

  • หากต้องการบันทึกข้อมูลที่ผู้ใช้ป้อน เฟรมเวิร์กจะอาศัย onSaveInstanceState ซึ่งจะรวมสถานะที่ลิงก์กับรหัสของมุมมองนั้น

  • แต่การทำเช่นนั้นอาจทำให้เกิดความล้มเหลวได้ 3 รูปแบบ

  • ประการแรก หากมีคนตั้งชื่อรหัสเป็น text_input แทนที่จะเป็น text_msg โดยไม่ตั้งใจใน XML อื่น การกู้คืนสถานะจะทิ้งข้อความที่ผู้ใช้ป้อนโดยไม่มีคำเตือนใดๆ

  • ประการที่ 2 onRestoreInstanceState จะทำงานหลังจาก onViewCreated ซึ่งมักจะขัดแย้งกับ ViewModel Observer และอาจทำให้เกิดลูป Callback

  • และข้อที่ 3 หากคุณสลับประเภทวิดเจ็ตในตัวระบุคุณสมบัติ เช่น ในหน้าจอขนาดกะทัดรัด คุณอาจต้องการ Spinner และในหน้าจอที่กว้างขึ้น คุณต้องการ RadioGroup หากวิดเจ็ตทั้ง 2 มีรหัสเดียวกัน วิดเจ็ตจะขัดข้องที่รันไทม์ด้วย ClassCastException

  • ใน Jetpack Compose นั้น UI ไม่ใช่ออบเจ็กต์ที่คุณสร้างขึ้นเพียงครั้งเดียว

  • UI ของคุณเป็นฟังก์ชันสถานะล้วนๆ

  • ซึ่งจะเปลี่ยนความหมายของการเปลี่ยนแปลงการกำหนดค่าไปโดยสิ้นเชิง

  • การเปลี่ยนแปลงการกำหนดค่าใน Compose คืออะไร

  • ฉันมีคำใบ้

การจัดการการเปลี่ยนแปลงการกำหนดค่าและความสามารถของ UI

  • Francesco Romano

    ใช่

  • ใน Compose การเปลี่ยนแปลงการกำหนดค่าเป็นเพียงการจัดองค์ประกอบใหม่ และการจัดองค์ประกอบใหม่จะเกิดขึ้นตลอดเวลา

  • เมื่อหน้าต่างกว้างขึ้น อาจเป็นเพราะผู้ใช้กางอุปกรณ์ออก Composable จะอ่านขนาดใหม่ และคอมโพเนนต์ที่เอาต์พุตขึ้นอยู่กับขนาดนั้นจะทำงานอีกครั้ง

  • นั่นคือทั้งหมดของกิจกรรม

  • แต่ตอนนี้ มาดูกันว่าการเขียนแอปที่ปรับเปลี่ยนตามอุปกรณ์ที่ทันสมัยโดยใช้เครื่องมือและคอมโพเนนต์ Compose ล่าสุดเป็นอย่างไร

  • ดังนั้น โลกของ XML จึงทำให้คุณต้องถามคำถามต่างๆ เช่น "ฉันกำลังใช้งานบนโทรศัพท์ แท็บเล็ต หรือเดสก์ท็อป" แล้วจึงแยก UI ตามหมวดหมู่อุปกรณ์

  • แต่มีเหตุผลสำคัญที่ควรหลีกเลี่ยงการทำเช่นนั้น

  • แม้ว่าจะทำงานบนอุปกรณ์จริงเครื่องเดียวกัน แต่บริบทของแอปพลิเคชันและความสามารถของ UI อาจเปลี่ยนแปลงแบบไดนามิกขณะรันไทม์

  • ดังนั้น ข้อความที่ฉันต้องการฝากไว้คือการตัดสินใจเกี่ยวกับ UI ควรขับเคลื่อนด้วยความสามารถที่ใช้งานอยู่ในปัจจุบัน เช่น ขนาดหน้าต่างปัจจุบัน ท่าทาง และลักษณะฮาร์ดแวร์บางอย่าง

  • มาดูตัวอย่างคลาสสิกกัน ซึ่งก็คือหน้าต่างเดสก์ท็อปบนจอภาพภายนอก

  • หากคุณนำโทรศัพท์ Pixel ไปเสียบเข้ากับจอภาพภายนอกบนโต๊ะทำงาน กระบวนการเดียวกันของแอปที่เพิ่งทำงานบนหน้าจอแนวตั้งขนาด 6 นิ้วจะทำงานบนจอภาพขนาด 32 นิ้วที่มีแป้นพิมพ์และเมาส์ต่ออยู่

  • ดังนั้น การตรวจสอบประเภทอุปกรณ์ที่แอปทำงานอยู่จึงไม่รับประกันว่าหน้าจอที่แสดงอยู่จริงหรือรูปแบบการป้อนข้อมูลจะเป็นอย่างไร

  • นอกจากนี้ Android ยังรองรับหลายหน้าต่างด้วย หน้าต่างจึงปรับขนาดได้

  • แม้ว่าหน้าจอของ Googlebook จะใหญ่กว่าโทรศัพท์ แต่แอปของคุณอาจทำงานในหน้าต่างกว้าง 400 dp ร่วมกับแอปอื่นๆ

  • ดังนั้น การค้นหาเมตริกการแสดงผลทางกายภาพแบบคงที่จึงอาจไม่มีความหมายเช่นกัน

  • ดังนั้น หากต้องการออกแบบแอปสำหรับ Compose ก่อน เราต้องเข้าใจวิธีค้นหาสภาพแวดล้อม

  • และฉันอยากเริ่มด้วยการพยายามกำหนดแนวคิดเกี่ยวกับความสามารถของ UI อย่างเป็นทางการ

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

  • เราพบว่าผู้ใช้ในสภาพแวดล้อมเดสก์ท็อปไม่ได้ใช้หน้าจอสัมผัสเสมอไป

  • ผู้ใช้สามารถใช้แป้นพิมพ์และเมาส์ได้

  • ในระบบยานยนต์ รถบางคันมีปุ่มหมุนหรืออินพุตที่แตกต่างกัน วิธีป้อนสัญญาณที่แตกต่างกัน

  • นอกจากขนาดหน้าจอและอินพุตแล้ว ยังรวมถึงระยะห่างระหว่างผู้ใช้กับจอแสดงผล เช่น ระยะการมองเห็น หรือกล้องหรือไมโครโฟนของฮาร์ดแวร์เชื่อมต่ออยู่หรือไม่

  • ทั้งหมดนี้คือความสามารถของ UI

การตรวจจับท่าทางด้วย mediaQuery API

  • Francesco Romano

    ตอนนี้ หากต้องการดึงข้อมูลสัญญาณทั้งหมด คุณต้องเชื่อมต่อ API ที่แตกต่างกัน 5-6 รายการจากแหล่งที่มาต่างๆ เข้าด้วยกัน

  • คุณต้องมีออบเจ็กต์การกำหนดค่า, WindowMetricsCalculator, WindowInfoTracker, InputManager, PackageManager แล้วแปลงแต่ละรายการเป็นสถานะ Compose ด้วยตนเอง

  • คุณชอบทำแบบนั้นไหม

  • ฉันไม่ได้ทำ

  • เราจึงเปิดตัว MediaQuery API ใน androidx.compose.ui เพื่อแก้ปัญหานี้

    mediaQuery { ... }
    derivedMediaQuery { ... }
  • ซึ่งได้รับแรงบันดาลใจจาก CSS Media Queries

  • และคำว่าสื่อไม่ได้หมายถึงการเล่นเสียงหรือวิดีโอ

  • ซึ่งหมายถึงสื่อหรือสภาพแวดล้อมที่แสดงผล UI

  • และ MediaQuery จะช่วยให้คุณค้นหาความสามารถของ UI ได้ด้วยวิธีประกาศแบบเนทีฟของ Compose เพียงวิธีเดียว

  • มาดูพร็อพเพอร์ตี้ที่แสดงใน UiMediaScope กัน

    sealed interface UiMediaScope {
        @get:FrequentlyChangingValue val windowWidth: Dp
        @get:FrequentlyChangingValue val windowHeight: Dp
    
        val windowPosture: Posture // Flat, Tabletop, Book
        val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
        val keyboardKind: KeyboardKind // Physical, Virtual, None
    
        val hasCamera: Boolean
        val hasMicrophone: Boolean
        val viewingDistance: ViewingDistance // Near, Medium, Far
    }
  • windowWidth และ windowHeight ในหน่วย dp

  • windowPosture ซึ่งมีไว้สำหรับอุปกรณ์แบบพับได้ในขณะนี้ และอาจเป็นแบบแบน แบบตั้งโต๊ะ หรือแบบหนังสือ

  • pointerPrecision จะบอกความแม่นยำของอุปกรณ์ชี้ตำแหน่งหลัก โดย Fine สำหรับเมาส์, สไตลัส หรือแทร็กแพด, Coarse สำหรับการสัมผัส, Blunt สำหรับจอยสติ๊ก, คอนโทรลเลอร์ หรือ None

  • keyboardKind อาจเป็นแบบจริง แบบเสมือน หรือไม่มีก็ได้

  • hasCamera และ hasMicrophone จะติดตามแบบไดนามิกว่าฮาร์ดแวร์นี้พร้อมใช้งานหรือไม่

  • ลองนึกถึงกล้องภายนอกที่เสียบปลั๊กได้

  • และ viewingDistance จะรายงานว่าใกล้ ปานกลาง หรือไกล

  • แต่เราคิดว่าการดูตัวอย่างจริงจะเข้าใจได้ง่ายกว่า ซึ่งก็คือการตรวจจับว่าอุปกรณ์อยู่ในโหมดตั้งโต๊ะหรือไม่

  • โหมดตั้งโต๊ะเป็นท่าทางที่ช่วยให้ผู้ใช้พับอุปกรณ์ครึ่งหนึ่งและมักจะวางไว้บนพื้นผิว

  • และยังเหมาะสำหรับการเล่นวิดีโอเป็นเวลานาน วิดีโอคอล และการถ่ายภาพไทม์แลปส์

  • นอกจากนี้ยังเป็นประโยชน์อย่างมากในการทำตามสูตรอาหารหรือดูภาพสไลด์ด้วย

  • ก่อนหน้านี้ หากต้องการตรวจจับท่าทางแบบตั้งโต๊ะก่อน MediaQuery คุณต้องเขียนโค้ดทั้งหมดนี้

    @Composable
    fun currentVideoPlayerLayout(): VideoPlayerLayout {
       val context = LocalContext.current
       val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
           .collectAsStateWithLifecycle(null)
       val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
           displayFeature is FoldingFeature &&
                   displayFeature.state == FoldingFeature.State.HALF_OPENED &&
                   displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
       } ?: false
       return if (isTabletopPosture) {
           VideoPlayerLayout.Tabletop
       } else {
           VideoPlayerLayout.Default
       }
    }
  • คุณต้องรับบริบท รับอินสแตนซ์ของ WindowInfoTracker, รวบรวม windowLayoutInfo ซึ่งเป็นโฟลว์ สถานะ Compose ที่รับรู้ถึงวงจร แล้วทำซ้ำผ่าน displayFeatures เพื่อตรวจสอบว่ามี FoldingFeature ที่เปิดอยู่ครึ่งหนึ่งและอยู่ในแนวนอนหรือไม่

  • และนั่นเป็นเพียงการตรวจจับท่าทางเท่านั้น

  • ซึ่งเป็นโค้ดสำเร็จรูปจำนวนมาก

  • เมื่อใช้ MediaQuery API ใหม่ โค้ดทั้งหมดจะลดลงเหลือเพียงไม่กี่บรรทัด

    @Composable
    fun currentVideoPlayerLayout(): VideoPlayerLayout {
       return mediaQuery {
           when(windowPosture) {
               UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
               else -> VideoPlayerLayout.Default
           }
       }
    }
  • ฟังก์ชัน mediaQuery จะใช้พารามิเตอร์ Lambda รายการเดียว และภายในขอบเขตตัวรับของ Lambda นั้น ฟังก์ชันจะแสดงชุดพร็อพเพอร์ตี้ที่เราเพิ่งเห็น

  • ในตัวอย่างนี้ เพียงแค่ตรวจสอบ windowPosture ในนิพจน์ when เราก็สามารถแสดงผล Composable ที่ถูกต้องได้ทันที

  • และนี่คืออีก 2 ตัวอย่างวิธีใช้ API

    @Composable
    fun AdaptiveChatControls(
        onStartVideoCall: () -> Unit,
        onSendMessage: () -> Unit,
    ) {
        // 1. Return a Dp value directly based on pointer precision
        val chipPadding = mediaQuery {
            if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
        }
    
        // 2. Return a Boolean combining hardware capabilities
        val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
        val hasPhysicalKeyboard = mediaQuery {
            keyboardKind == KeyboardKind.Physical
        }
    
        Row(modifier = Modifier.padding(chipPadding)) {
            if (canVideoCall) {
                IconButton(onClick = onStartVideoCall) {
                    Icon(Icons.Default.Videocam, contentDescription = "Video call")
                }
            }
            Button(onClick = onSendMessage) {
                Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
            }
        }
    }
  • อย่างแรกคือความหนาแน่นของข้อมูล

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

  • รวมถึงการดำเนินการที่ขับเคลื่อนด้วยความสามารถ

  • สมมติว่าเรามีแถบป้อนข้อมูลแชท เราสามารถตรวจสอบ hasCamera และ hasMicrophone เพื่อแสดงหรือซ่อนปุ่มดำเนินการวิดีโอคอลแบบไดนามิก หรือเราสามารถรวมประเภทแป้นพิมพ์เป็นแป้นพิมพ์จริงเพื่อแสดงหรือไม่แสดงคำแนะนำแป้นพิมพ์ลัดบางอย่าง เช่น กด Ctrl+Enter เพื่อส่ง

  • ระบบจะดึงข้อมูลทั้งหมดดังกล่าวในรันไทม์

  • นอกจากนี้ MediaQuery ยังเหมาะสำหรับการทดสอบและการสร้างตัวอย่างด้วย

    @Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
    @Composable
    fun TabletopPlayerPreview() {
        val simulatedMediaScope = object : UiMediaScope {
            override val windowWidth = 840.dp
            override val windowHeight = 700.dp
            override val windowPosture = UiMediaScope.Posture.Tabletop
            override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
            override val keyboardKind = UiMediaScope.KeyboardKind.Physical
            override val hasCamera = true
            override val hasMicrophone = true
            override val viewingDistance = UiMediaScope.ViewingDistance.Medium
        }
    
        CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
            VideoPlayerScreen()
        }
    }
  • โดยพื้นฐานแล้ว MediaQuery จะแก้ไข Receiver ผ่าน Composition Local ที่ชื่อว่า LocalUiMediaScope

  • ดังนั้น คุณจึงสามารถระบุ UiMediaScope ปลอมผ่าน CompositionLocalProvider ในตัวอย่างได้ และไม่จำเป็นต้องหมุนอุปกรณ์แบบพับได้และวางที่ 90° หรือต่อเมาส์เพียงเพื่อทำการทดสอบ Compose UI

เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox

  • Francesco Romano

    ตอนนี้เราทราบวิธีค้นหาความสามารถของ UI แล้ว

  • คราวนี้มาดูวิธีสร้างเลย์เอาต์จริงกัน

  • ด้วยเหตุนี้ เราจึงขอแนะนำ Grid ซึ่งเป็นเลย์เอาต์พื้นฐานใหม่ใน androidx.compose.foundation.layout ที่ตอนนี้เสถียรแล้ว

    Grid(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        config = {
            val maxWidthDp = constraints.maxWidth.toDp()
            val columnCount = if (maxWidthDp < 600.dp) 2 else 4
    
            repeat(columnCount) {
                column(1.fr)
            }
            row(GridTrackSize.Auto)
            row(1.fr)
    
            gap(row = 16.dp, column = 8.dp)
            flow = GridFlow.Row
        }
    ) {
        // GridScope children...
    }
  • ตารางกริดช่วยให้คุณใช้เลย์เอาต์ 2 มิติได้โดยการแสดงรายการในคอลัมน์และแถวที่ปรับให้เข้ากับขนาดคอนเทนเนอร์ที่ใช้ได้

  • มาดูสิ่งที่คุณทำได้ในบล็อกการกำหนดค่ากัน

  • คุณสามารถเพิ่มองค์ประกอบหรือแทร็กใหม่ได้โดยเรียกใช้คอลัมน์หรือแถว และใช้ลูป Kotlin มาตรฐาน เช่น repeat

  • คุณสามารถตั้งค่ารางระหว่างแทร็กได้

  • คุณควบคุมทิศทางการจัดวางอัตโนมัติได้ผ่านโฟลว์ GridFlow.Row หรือ GridFlow.Column

  • และนี่คือรายละเอียดสถาปัตยกรรมที่สำคัญ

  • บล็อกการกำหนดค่าจะทำงานระหว่างการส่งผ่านการวัด และขอบเขตการกำหนดค่าตารางกริดจะใช้ความหนาแน่นและแสดงข้อจำกัด

  • ซึ่งหมายความว่าคุณสามารถตรวจสอบ constraints.maxWidth.toDp() ได้โดยตรงภายในการกำหนดค่าเพื่อคำนวณแทร็กโดยไม่ต้องผ่านการจัดองค์ประกอบใหม่เพิ่มเติม

  • ใน Grid เมื่อคุณเพิ่มองค์ประกอบย่อยโดยไม่มีตัวแก้ไข องค์ประกอบนั้นจะไหลไปยังเซลล์ถัดไปโดยอัตโนมัติ

    Grid(
        config = {
            repeat(3) { column(1.fr) }
            repeat(3) { row(100.dp) }
            gap(8.dp)
        }
    ) {
        // Spans 2x2 area (rows 1..2, columns 1..2)
        HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))
    
        // Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
        LiveBadge(
            modifier = Modifier.gridItem(
                row = 1,
                column = 1,
                alignment = Alignment.TopStart
            )
        )
    
        // Negative index: placed in last row (-1), spanning all 3 columns
        FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
    }
  • เมื่อต้องการควบคุมอย่างชัดเจน คุณสามารถใช้ Modifier.gridItem และคุณจะมีพิกัดแบบอิงตาม 1

  • ดังนั้น แถว 1 คอลัมน์ 1 คือเซลล์ซ้ายบน

  • โดยรองรับการจัดทำดัชนีเชิงลบ ดังนั้นแถว -1 จึงเป็นแถวสุดท้าย และอื่นๆ

  • คุณสามารถใช้ rowSpan เพื่อขยายรายการในพื้นที่ 2 มิติ และยังมีการซ้อนทับกันด้วย

  • ดังนั้น หากวางรายการ 2 รายการไว้ในเซลล์ที่ทับซ้อนกัน Grid จะวางซ้อนกันไว้ด้านบนของอีกรายการหนึ่งภายในขอบเขตเหล่านั้น และลำดับ Z จะกำหนดโดยลำดับการจัดองค์ประกอบ

  • ดังนั้นรายการที่อยู่ด้านหลังจะวาดทับรายการอื่นๆ เช่นเดียวกับ Box

  • ตอนนี้เราทราบข้อมูลพื้นฐานของ Grid แล้ว

  • มาดูตัวอย่างวิดีโอเพลเยอร์กัน

  • เราต้องสลับระหว่างเลย์เอาต์เริ่มต้นหรือเลย์เอาต์แบบตั้งโต๊ะ โดยขึ้นอยู่กับว่าอุปกรณ์แบนราบหรือเปิดอยู่ครึ่งหนึ่ง

  • ก่อนที่จะมี Grid เราต้องสร้าง Composable 2 รายการที่แตกต่างกัน ได้แก่ DefaultLayout และ TabletopLayout จากนั้นจึงแยกโค้ดในนิพจน์ when

    @Composable
    fun VideoPlayerLayout(
       player: @Composable () -> Unit,
       controller: @Composable () -> Unit,
       modifier: Modifier = Modifier
    ) {
       when(currentVideoPlayerLayout()) {
           VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
           VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
       }
    }
  • คุณอาจคิดว่า "แล้วมันไม่ดียังไง"

  • หากดูเลย์เอาต์เหล่านั้น คุณจะเห็นว่าเนื้อหาเหมือนกัน

  • ทั้ง 2 หน้าจอจะแสดงเพลเยอร์และตัวควบคุมเดียวกันทุกประการ

  • ความแตกต่างเพียงอย่างเดียวระหว่างทั้ง 2 รูปแบบคือวิธีจัดเรียงองค์ประกอบทั้ง 2 อย่างนี้บนหน้าจอ

  • ก่อนหน้านี้ เนื่องจากเราไม่มีวิธีส่งการจัดเรียงเลย์เอาต์เป็นพารามิเตอร์ เราจึงต้องกำหนด Composable 2 รายการแยกกัน

  • ตอนนี้เราสามารถกำหนดพารามิเตอร์ให้กับการกำหนดค่าเลย์เอาต์และจัดการทุกอย่างภายในฟังก์ชันที่ประกอบกันได้ฟังก์ชันเดียว

  • และเราจะมาดูวิธีการกัน

  • ดังนั้นเมื่อใช้ Grid API เราจึงกำหนดพื้นที่ที่มีชื่อเป็น Enum ได้

    enum class VideoPlayerArea { Player, Controller }
    
    Grid(
        config = { ... },
        modifier = modifier
    ) {
        Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
            player()
        }
        Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
           controller()
       }
    }
  • จากนั้นเราสามารถระบุ Composable ย่อยอย่าง Player และ Controller เพื่อวางไว้ในตารางกริด และเราสามารถเชื่อมโยงองค์ประกอบย่อยแต่ละรายการกับพื้นที่ที่มีชื่อซึ่งเรียกใช้ Modifier.gridItem และเราจะส่ง areaId

  • และในส่วนนี้ เราจะบอกให้ Grid วางเพลเยอร์ลงในพื้นที่ชื่อ VideoPlayerArea.Player และวางตัวควบคุมลงใน VideoPlayerArea.Controller

  • และที่นี่เราจะกำหนด Lambda การกำหนดค่า 2 รายการใน VideoPlayerGridConfiguration

    object VideoPlayerGridConfiguration {
       val Default: GridConfigurationScope.() -> Unit = {
           row(1f)
           column(1f)
           area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
           area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
       }
    
       val Tabletop: GridConfigurationScope.() -> Unit = {
           row(0.5f)
           row(0.5f)
           column(1f)
           area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
           area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
       }
    }
  • สำหรับเลย์เอาต์วิดีโอเพลเยอร์เริ่มต้น เราจะกำหนดค่าตารางกริดที่มี 1 แถวและ 1 คอลัมน์ แต่โปรดทราบว่าแม้ว่าจะมีเพียงเซลล์เดียว เราก็ยังคงกำหนดชื่อพื้นที่ 2 ชื่อให้กับเซลล์เดียวกันได้

  • ในกรณีนี้ อย่างที่เราเคยเห็นมาก่อน ตัวควบคุมจะวางซ้อนอยู่ด้านบนของเพลเยอร์

  • สำหรับโหมดตั้งโต๊ะ เราแค่กำหนด 2 แถวและ 1 คอลัมน์ โดยวางเพลเยอร์ไว้ที่ครึ่งบนและตัวควบคุมไว้ที่ครึ่งล่าง โดยพื้นฐานแล้วจะเป็นแบบนี้

  • เมื่อกำหนด Lambda ของการกำหนดค่าเหล่านั้นแล้ว เราจะใช้ MediaQuery ในการกำหนดค่าของตารางกริดได้โดยตรง

    @Composable
    fun VideoPlayerLayout(
       player: @Composable () -> Unit,
       controller: @Composable () -> Unit,
       modifier: Modifier = Modifier
    ) {
       Grid(
           config = { mediaQuery {
           if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
     else { VideoPlayerGridConfiguration.Default }
    }
       }
           modifier = modifier
       ) {
           Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
               player()
           }
           Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
               controller()
           }
       }
    }
  • ดังนั้น ตอนนี้เราจึงมีเลย์เอาต์วิดีโอเพลเยอร์แบบ Composable เดียวที่มี UI ซึ่งสลับระหว่างการกำหนดค่า 2 แบบได้แบบไดนามิก

  • องค์ประกอบพื้นฐานถัดไปที่ฉันอยากพูดถึงคือ FlexBox

    @Composable
    fun AdaptiveActionHeader(tags: List<String>) {
            FlexBox(
            config = {
                direction( if (isCompact)
    FlexDirection.Column else FlexDirection.Row )
                wrap(FlexWrap.Wrap)
                alignItems(FlexAlignItems.Center)
                gap(row = 8.dp, column = 12.dp)
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text(
                text = "Home",
                modifier = Modifier.flex {
                    basis(FlexBasis.Auto)
                    grow(1f)
                    shrink(1f)
                }
            )
            tags.forEach { tag ->
                FilterChipItem(
                    label = tag,
                    modifier = Modifier.flex { shrink(0f) }
                )
            }
        }
    }
  • ดังนั้น แม้ว่า Grid จะจัดการเลย์เอาต์แถวและคอลัมน์แบบ 2 มิติ แต่ FlexBox ซึ่งตอนนี้ก็เสถียรแล้วเช่นกัน จะจัดการเลย์เอาต์แบบ 1 มิติที่ยังคงต้องมีการกระจายพื้นที่ การย่อขนาด และการตัดบรรทัดหลายบรรทัดอย่างชาญฉลาด

  • และเป็นเลย์เอาต์ที่ปรับเปลี่ยนตามอุปกรณ์และใช้งานได้สะดวก ซึ่งคุณสามารถใช้แทน Composable 2 รายการที่แยกกัน เช่น FlowRow และ FlowColumn เนื่องจากเป็นคอนเทนเนอร์เดียวที่สามารถเปลี่ยนทิศทางแบบไดนามิกภายในบล็อกการกำหนดค่าโดยไม่ต้องสร้าง Composable ย่อยขึ้นมาใหม่

  • และยังใช้อัลกอริทึม CSS FlexBox แบบเต็มรูปแบบ คุณจึงสามารถใช้ wrap, justify-content, align-items, gap ได้

  • และในแต่ละรายการย่อย คุณจะมี Modifier.flex ที่ให้พื้นฐาน, การเติบโตแบบสัมพัทธ์, ปัจจัยการหดตัว, alignSelf และอื่นๆ

สถาปัตยกรรมเลย์เอาต์ระดับแอปและการนำทาง 3

  • Francesco Romano

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

  • แม้ว่า MediaQuery จะให้ความกว้างและความสูงของหน้าต่างที่แน่นอนในหน่วย Dp แต่สำหรับสถาปัตยกรรมระดับแอป คุณต้องการเบรกพอยต์วิวพอร์ตที่ได้มาตรฐานและมีความเห็น ซึ่งก็คือคลาสขนาดหน้าต่าง

  • โดยพื้นฐานแล้ว คุณต้องการให้ทุกหน้าจอปรับเปลี่ยนตามอุปกรณ์ภายในแต่ละกลุ่มเหล่านี้ ในขณะที่คุณสามารถทำการเปลี่ยนแปลงและอัปเดตโครงสร้าง รวมถึงความแตกต่างระหว่างกลุ่มต่างๆ ได้มากขึ้น

  • เช่น โดยการแสดงเฉพาะรายการในหน้าจอขนาดกะทัดรัด หรือแสดงทั้งรายการและรายละเอียดในหน้าต่างที่ขยาย

  • คุณสามารถสร้างคลาสขนาดหน้าต่างที่ด้านบนของ UiMediaScope ได้โดยตรงด้วยวิธีที่ชัดเจน ซึ่งเป็นการกำหนดพร็อพเพอร์ตี้ส่วนขยายใน UiMediaScope

    val UiMediaScope.windowSizeClass: WindowSizeClass
        @Composable get() = derivedMediaQuery {
            WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
                widthDp = windowWidth.value,
                heightDp = windowHeight.value
            )
        }
    
    @Composable
    fun AppRoot() {
        val sizeClass = mediaQuery { windowSizeClass }
        val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
            WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
        )
        // Drive app-level navigation and scene strategies...
    }
  • สิ่งหนึ่งที่ควรสังเกตคือโค้ดนี้ใช้ derivedMediaQuery ไม่ใช่ MediaQuery มาตรฐาน

  • ทำไมจึงเป็นเช่นนั้น

  • เนื่องจากความกว้างและความสูงของหน้าต่างเป็นค่าที่มีการเปลี่ยนแปลงบ่อยครั้ง

  • เมื่อผู้ใช้ปรับขนาดแอป เช่น บนเดสก์ท็อป ค่าเหล่านี้อาจเปลี่ยนแปลงทุกเฟรม

  • และคุณอาจไม่ต้องการจัดองค์ประกอบสแคฟโฟลด์ระดับแอปใหม่ในทุกเฟรม แต่จะจัดองค์ประกอบใหม่เฉพาะเมื่อหน้าต่างข้ามเบรกพอยต์จริงๆ เท่านั้น

  • และเมื่อเรามีคลาสขนาดหน้าต่างแล้ว เราจะเปลี่ยนแอปจากโฟลว์แผงเดียวเป็นเลย์เอาต์หลายแผงเมื่อมีพื้นที่มากขึ้นได้อย่างไร

  • นี่คือจุดที่ Jetpack Navigation 3 โดดเด่น เนื่องจาก Nav 3 ได้รับการออกแบบตั้งแต่ต้นโดยอิงตามสถานะและฉากของ Compose

  • และอย่างแรก คุณเป็นเจ้าของ Back Stack ซึ่งเป็นเพียงรายการ NavKey จึงมีความยืดหยุ่นสูงและบันทึกเป็นสถานะได้ง่าย

  • คีย์แต่ละรายการในสแต็กจะแปลงเป็น NavEntry ที่มี Composable และข้อมูลเมตา

  • และ NavDisplay จะไม่ฮาร์ดโค้ดวิธีวาดรายการ แต่จะประเมินรายการกลยุทธ์ฉากแทน

  • ภายในกลยุทธ์ คุณสามารถตรวจสอบความสามารถของ UI เช่น คลาสขนาดหน้าต่าง

    if (isExpandedWidth) {
        /* return a multi-pane scene */
    }
    else {
        /* return a single pane scene */
    }
  • คุณสามารถตรวจสอบ Back Stack แล้วตัดสินใจว่าจะแสดงผล NavEntry 1 รายการในแผงเดียว หรือหลายรายการพร้อมกัน หรือแสดงผลซ้อนกัน

  • ซึ่งมีความยืดหยุ่นมาก

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

  • ตัวอย่างเช่น ในความกว้างแบบกะทัดรัด คุณอาจต้องการแถบการไปยังส่วนต่างๆ ด้านล่าง ในความกว้างปานกลาง คุณอาจต้องการแถบนำทาง และในความกว้างแบบขยาย คุณอาจต้องการลิ้นชักการนำทางแบบเต็ม

  • เมื่อรวม Nav3 สำหรับการตัดสินใจเกี่ยวกับเลย์เอาต์ระดับแอปและคอมโพเนนต์ที่ปรับเปลี่ยนตามอุปกรณ์สำหรับการกำหนดระดับหน้าจอ คุณจะได้แอปที่มีลักษณะเช่นนี้และโดดเด่นในหน้าต่างเกือบทุกขนาดและทุกสภาพแวดล้อม

  • สำหรับ Nav 3 โดยเฉพาะ หากต้องการดูข้อมูลเพิ่มเติมเกี่ยวกับการอัปเดตล่าสุด รวมถึงการรองรับ Deep Link โปรดดูการบรรยายเรื่องการสร้าง Deep Link ที่ปลอดภัยต่อประเภทด้วย Jetpack Navigation 3

การสาธิตการพัฒนาและการทำงานอัตโนมัติของ Agentic AI

  • Francesco Romano

    เอาล่ะ วันนี้เราได้พูดถึง API ใหม่ๆ ไปหลายตัวแล้ว ได้แก่ MediaQuery, Grid, FlexBox, Nav3

  • และนั่นคือวิธีที่ Jetpack Compose ช่วยคุณสร้างแอปแบบปรับอัตโนมัติ

  • แต่ในยุคของการพัฒนาแบบ Agentic การใช้ Jetpack Compose มีข้อดีอีกอย่างที่สำคัญอย่างยิ่ง นั่นคือมีประสิทธิภาพมากกว่ามาก

  • คุณอาจได้ยินในระหว่างการกล่าวสุนทรพจน์ว่าเราพบว่าแอปใช้โทเค็นน้อยลงกว่า 30% หลังจากย้ายข้อมูลไปยัง Compose และเรากำลังทำงานเพื่อเพิ่มประสิทธิภาพเวิร์กโฟลว์แบบ Agentic สำหรับแอป Compose ให้ดียิ่งขึ้น

  • วันนี้เราอยากจะไฮไลต์การอัปเดตหลักเกี่ยวกับทักษะการเขียนแบบปรับเปลี่ยนได้

    android skills list
    android skills add adaptive
  • อย่างที่บอกไปว่านอกจากการย้ายข้อมูลเลย์เอาต์ไปยัง Grid, Flexbox และ MediaQuery แล้ว ตอนนี้ยังจัดการการจัดการสถานะแบบครบวงจรทั่วทั้งแอปอย่างชัดเจน และเราทดสอบทักษะนี้กับข้อบกพร่องของการสูญเสียสถานะในชีวิตจริง เช่น การคงการนำทาง ตำแหน่งการเลื่อน การเล่นสื่อที่ใช้งานอยู่ การป้อนข้อความที่พิมพ์ไปครึ่งหนึ่ง และอื่นๆ

  • ตอนนี้ทักษะนี้ยังรวมถึงการเพิ่มประสิทธิภาพท่าทางของอุปกรณ์แบบพับได้ เช่น การเปลี่ยนโครงสร้างภายในโค้ดของหน้าจอสื่อและกล้องให้เป็นโหมดตั้งโต๊ะโดยอัตโนมัติ แต่เมื่อแอปทำงานบน Googlebook หรือจอภาพภายนอกที่มีเมาส์และแป้นพิมพ์ เลย์เอาต์ก็เป็นเพียงส่วนหนึ่งเท่านั้น

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

  • มาดูการทำงานจริงกันเลย

  • ฉันบันทึกวิดีโอสาธิตเรื่องนี้ไว้

  • เราจึงมีแอปของเราเอง

  • เราดูสัตว์เลี้ยงของเราได้

  • เราสามารถส่งข้อความถึงบุคคลเหล่านั้นได้

  • เราดูวิดีโอของพวกเขาได้

  • ใช่ เราดูสัตว์เลี้ยงยอดนิยมได้

  • แต่คุณก็เห็นแล้วว่ามันไม่ดี

  • ทีนี้มาดูกัน ฉันแค่อยากดูรูปที่มีความดราม่าและมีแมวอยู่ด้วย

  • โอเค ฉันอยากดูแบบใหญ่ขึ้น

  • Dah คือกลับไปที่หน้าแรก

  • โอเค เดี๋ยวฉันบอกเพื่อนให้

  • ใช่ "บัดดี้" อืม

  • แอปไม่ค่อยดี

  • และกว้างเกินไป

  • คุณจะเห็นว่ากว้างเกินไป

  • มาลองปรับขนาดกัน

  • อะไร

  • อืม ใช่ โอเค

  • และข้อความที่ฉันพิมพ์ไว้หายไป

  • เอาล่ะ ได้เวลาขอความช่วยเหลือจาก Gemini เพื่อแก้ไขเรื่องนี้แล้ว

  • ดังนั้น ด้วยทักษะ Adaptive พรอมต์จึงอาจเป็นเพียง "ทำให้แอปปรับเปลี่ยนได้"

  • อย่างที่คุณเห็น ฉันใช้ Gemini 3.0 Flash ซึ่งเมื่อใช้ร่วมกับทักษะนี้จะสามารถทำได้

  • ระบบรับทักษะดังกล่าวแล้ว และตอนนี้กำลังเริ่มดำเนินการเปลี่ยนแปลง

  • โดยจะถามคำถาม เช่น "โอ้ MediaQuery อยู่ในขั้นทดลอง

  • คุณต้องการใช้จริงๆ ใช่ไหม"

  • และทำต่อไป

  • ผมต้องเร่งความเร็วเพื่อตัดสิ่งต่างๆ แต่ถ้าคุณมาที่พื้นที่สาธิต คุณจะลองใช้ทักษะด้วยตัวเองได้

  • และนี่เป็นเพียงหนึ่งในทักษะที่เราเปิดตัว

  • ตัวอย่างเช่น เรายังมีทักษะใหม่ที่จะช่วยคุณสร้างประสบการณ์การใช้งานสไตลัสที่น่าทึ่ง

  • ดังนั้น โปรดมาที่พื้นที่ดังกล่าวเพื่อลองใช้

  • เท่านี้ก็เสร็จเรียบร้อย

  • โดยมีการแก้ไขไฟล์ 11 รายการ

  • มาดูการทำงานจริงกัน

  • ตกลง

  • Grid กำลังทำงานอยู่แล้ว

  • การนำทางย้ายไปที่ด้านข้างแล้ว

  • มาลองใช้ฟิลเตอร์กัน

  • ฉันคิดว่าตอนนี้จะใช้งานได้แล้ว

  • ใช่ มี

  • โอเค มาส่งข้อความหาเพื่อนและบอกว่าตอนนี้แอปใช้งานได้ดีแล้วกัน

  • มาดูกันว่าเมื่อมีพื้นที่มากขึ้นแล้วจะเป็นอย่างไร

  • ใช่ เราย้ายข้อมูลไปยังเลย์เอาต์แบบรายละเอียดรายการแล้ว

  • การรันครั้งนี้ใช้เวลาประมาณ 20 นาทีและใช้โทเค็นประมาณ 200,000 รายการ

  • ตกลง

ROI ของแอปที่ปรับเปลี่ยนได้และประเด็นสำคัญ

  • Francesco Romano

    โดยปกติแล้ว ฉันมักจะเริ่มพูดคุยด้วยเมตริกทางธุรกิจและกรณีที่ประสบความสำเร็จ แต่สำหรับ ADS ฉันต้องการเจาะลึกโค้ดโดยตรง

  • อย่างไรก็ตาม เราอยากใช้เวลาสักครู่เพื่อแชร์ผลตอบแทนจากการลงทุนด้านวิศวกรรม

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

  • มาดูกรณีศึกษาที่เป็นรูปธรรม 2 กรณีที่พิสูจน์ให้เห็นถึงสิ่งที่จะเกิดขึ้นเมื่อคุณจัดส่งบิลด์แบบปรับเปลี่ยนได้

  • แอปแรกคือ Gratitude ซึ่งเป็นแอปดูแลตนเองและแอปบันทึกประจำวันที่ได้รับความนิยม

  • การใช้การออกแบบหน้าจอที่ปรับเปลี่ยนได้ทำให้ Gratitude สามารถใช้แท็บเล็ตและอุปกรณ์แบบพับได้เป็นช่องทางการได้ผู้ใช้ใหม่ที่มีการเติบโตสูง

  • อันดับแรก ทีมได้เพิ่มความเร็วในการได้ผู้ใช้ใหม่บนหน้าจอขนาดใหญ่เป็น 2 เท่า

  • การติดตั้งรายวันที่มาจากแท็บเล็ตและอุปกรณ์แบบพับได้โดยเฉพาะเพิ่มขึ้น 100%

  • โดยสามารถขยายสัดส่วนหน้าจอขนาดใหญ่ของฐานผู้ใช้โดยรวมจาก 7% เป็น 10% ของการติดตั้งทั้งหมด

  • แอปที่ 2 คือ BoxBoxClub ซึ่งเป็นแอปที่ใช้ร่วมกันสำหรับ Formula 1 และมอเตอร์สปอร์ต หลังจากอัปเดตบิลด์สำหรับอุปกรณ์แบบพับได้และหน้าจอขนาดใหญ่ขึ้น ข้อมูลทางไกลของแอปแสดงให้เห็นถึงการเติบโตอย่างมาก โดยมีผู้ใช้ที่ใช้งานอยู่โดยรวมเพิ่มขึ้น 7.6% และเวลาในการมีส่วนร่วมเพิ่มขึ้น 54%

  • ตกลง

  • เรามาถึงช่วงท้ายของการพูดคุยแล้ว และเราอยากฝากประเด็นสำคัญ 3 ข้อไว้ให้คุณ

  • การสร้างแอปที่ปรับเปลี่ยนได้ช่วยให้แอปของคุณรองรับอุปกรณ์และผู้ใช้ได้มากขึ้น พร้อมทั้งทำให้แอปรองรับการใช้งานในอนาคตสำหรับระบบนิเวศที่กำลังขยายตัวนี้

  • การพัฒนาแอป Android โดยใช้ Compose เป็นหลัก

  • เครื่องมือ ไลบรารี และทักษะใหม่ๆ ทั้งหมดสร้างขึ้นสำหรับ Compose

  • Compose เหมาะอย่างยิ่งสำหรับเลย์เอาต์แบบปรับเปลี่ยนตามอุปกรณ์และการพัฒนา Agent

  • ฐานของโค้ดมีขนาดเล็กลงและเข้าใจได้ง่ายขึ้น โดย Agent จะใช้โทเค็นน้อยลงเมื่อจัดการกับโค้ด Compose

  • ลองใช้เครื่องมือ ทักษะ และ API ใหม่ๆ ทั้งหมด แล้วสนุกไปกับมันได้เลย

  • ขอขอบคุณ

รหัส

ข้อมูลโค้ด

ข้อมูลโค้ด XML ที่ 05:17 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการปรับขนาดแอปในหน้าจอต่างๆ"
<LinearLayout android:orientation="vertical">
  <EditText
      android:id="@+id/text_msg"
      android:inputType="textCapCharacters"
      android:maxLength="12" />
</LinearLayout>
ข้อมูลโค้ด XML ที่เวลา 05:17 ใน "ข้อมูลเบื้องต้นเกี่ยวกับการปรับขนาดแอปในหน้าจอต่างๆ"
<ConstraintLayout>
  <TextInputEditText
    android:id="@+id/text_input"
    android:inputType="text" />
</ConstraintLayout>
ข้อมูลโค้ด Kotlin ที่ 11:31 ใน "การตรวจจับท่าทางด้วย mediaQuery API"
mediaQuery { ... }
derivedMediaQuery { ... }
ข้อมูลโค้ด Kotlin ที่ 11:57 ใน "การตรวจจับท่าทางด้วย mediaQuery API"
sealed interface UiMediaScope {
    @get:FrequentlyChangingValue val windowWidth: Dp
    @get:FrequentlyChangingValue val windowHeight: Dp

    val windowPosture: Posture // Flat, Tabletop, Book
    val pointerPrecision: PointerPrecision // Fine, Coarse, Blunt, None
    val keyboardKind: KeyboardKind // Physical, Virtual, None

    val hasCamera: Boolean
    val hasMicrophone: Boolean
    val viewingDistance: ViewingDistance // Near, Medium, Far
}
ข้อมูลโค้ด Kotlin ที่ 13:11 ใน "การตรวจจับท่าทางด้วย mediaQuery API"
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
   val context = LocalContext.current
   val windowLayoutInfo by WindowInfoTracker.getOrCreate(context).windowLayoutInfo(context)
       .collectAsStateWithLifecycle(null)
   val isTabletopPosture = windowLayoutInfo?.displayFeatures?.any { displayFeature ->
       displayFeature is FoldingFeature &&
               displayFeature.state == FoldingFeature.State.HALF_OPENED &&
               displayFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
   } ?: false
   return if (isTabletopPosture) {
       VideoPlayerLayout.Tabletop
   } else {
       VideoPlayerLayout.Default
   }
}
ข้อมูลโค้ด Kotlin ที่ 13:42 ใน "การตรวจจับท่าทางด้วย mediaQuery API"
@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
   return mediaQuery {
       when(windowPosture) {
           UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
           else -> VideoPlayerLayout.Default
       }
   }
}
ข้อมูลโค้ด Kotlin ที่นาที 14:07 ใน "การตรวจจับท่าทางด้วย mediaQuery API"
@Composable
fun AdaptiveChatControls(
    onStartVideoCall: () -> Unit,
    onSendMessage: () -> Unit,
) {
    // 1. Return a Dp value directly based on pointer precision
    val chipPadding = mediaQuery {
        if (pointerPrecision == PointerPrecision.Fine) 2.dp else 8.dp
    }

    // 2. Return a Boolean combining hardware capabilities
    val canVideoCall = mediaQuery { hasCamera && hasMicrophone }
    val hasPhysicalKeyboard = mediaQuery {
        keyboardKind == KeyboardKind.Physical
    }

    Row(modifier = Modifier.padding(chipPadding)) {
        if (canVideoCall) {
            IconButton(onClick = onStartVideoCall) {
                Icon(Icons.Default.Videocam, contentDescription = "Video call")
            }
        }
        Button(onClick = onSendMessage) {
            Text(if (hasPhysicalKeyboard) "Send (Ctrl+Enter)" else "Send")
        }
    }
}
ข้อมูลโค้ด Kotlin ที่ 14:59 ใน "การตรวจจับท่าทางด้วย mediaQuery API"
@Preview(name = "Tabletop + Fine Pointer", widthDp = 840, heightDp = 700)
@Composable
fun TabletopPlayerPreview() {
    val simulatedMediaScope = object : UiMediaScope {
        override val windowWidth = 840.dp
        override val windowHeight = 700.dp
        override val windowPosture = UiMediaScope.Posture.Tabletop
        override val pointerPrecision = UiMediaScope.PointerPrecision.Fine
        override val keyboardKind = UiMediaScope.KeyboardKind.Physical
        override val hasCamera = true
        override val hasMicrophone = true
        override val viewingDistance = UiMediaScope.ViewingDistance.Medium
    }

    CompositionLocalProvider(LocalUiMediaScope provides simulatedMediaScope) {
        VideoPlayerScreen()
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 15:40 ใน "เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox"
Grid(
    modifier = Modifier.fillMaxSize().padding(16.dp),
    config = {
        val maxWidthDp = constraints.maxWidth.toDp()
        val columnCount = if (maxWidthDp < 600.dp) 2 else 4

        repeat(columnCount) {
            column(1.fr)
        }
        row(GridTrackSize.Auto)
        row(1.fr)

        gap(row = 16.dp, column = 8.dp)
        flow = GridFlow.Row
    }
) {
    // GridScope children...
}
ข้อมูลโค้ด Kotlin ที่นาที 16:50 ใน "เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox"
Grid(
    config = {
        repeat(3) { column(1.fr) }
        repeat(3) { row(100.dp) }
        gap(8.dp)
    }
) {
    // Spans 2x2 area (rows 1..2, columns 1..2)
    HeroCard(modifier = Modifier.gridItem(rows = 1..2, columns = 1..2))

    // Overlaps on top of HeroCard in cell (1, 1) aligned to TopStart!
    LiveBadge(
        modifier = Modifier.gridItem(
            row = 1,
            column = 1,
            alignment = Alignment.TopStart
        )
    )

    // Negative index: placed in last row (-1), spanning all 3 columns
    FooterBar(modifier = Modifier.gridItem(row = -1, column = 1, columnSpan = 3))
}
ข้อมูลโค้ด Kotlin ที่นาที 17:59 ใน "เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox"
@Composable
fun VideoPlayerLayout(
   player: @Composable () -> Unit,
   controller: @Composable () -> Unit,
   modifier: Modifier = Modifier
) {
   when(currentVideoPlayerLayout()) {
       VideoPlayerLayout.Default -> DefaultLayout(player, controller, modifier)
       VideoPlayerLayout.Tabletop -> TabletopLayout(player, controller, modifier)
   }
}
ข้อมูลโค้ด Kotlin ที่นาที 18:51 ใน "เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox"
enum class VideoPlayerArea { Player, Controller }

Grid(
    config = { ... },
    modifier = modifier
) {
    Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
        player()
    }
    Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
       controller()
   }
}
Kotlin snippet at 19:20 in "Responsive screen layouts with Grid and FlexBox"
object VideoPlayerGridConfiguration {
   val Default: GridConfigurationScope.() -> Unit = {
       row(1f)
       column(1f)
       area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
       area(areaId = VideoPlayerArea.Controller, row = 1, column = 1)
   }

   val Tabletop: GridConfigurationScope.() -> Unit = {
       row(0.5f)
       row(0.5f)
       column(1f)
       area(areaId = VideoPlayerArea.Player, row = 1, column = 1)
       area(areaId = VideoPlayerArea.Controller, row = 2, column = 1)
   }
}
ข้อมูลโค้ด Kotlin ที่นาที 20:00 ใน "เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox"
@Composable
fun VideoPlayerLayout(
   player: @Composable () -> Unit,
   controller: @Composable () -> Unit,
   modifier: Modifier = Modifier
) {
   Grid(
       config = { mediaQuery {
       if (isTabletopPosture) { VideoPlayerGridConfiguration.Tabletop }
 else { VideoPlayerGridConfiguration.Default }
}
   }
       modifier = modifier
   ) {
       Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Player)) {
           player()
       }
       Box(modifier = Modifier.gridItem(areaId = VideoPlayerArea.Controller)) {
           controller()
       }
   }
}
ข้อมูลโค้ด Kotlin ที่นาที 20:16 ใน "เลย์เอาต์หน้าจอที่ปรับเปลี่ยนตามอุปกรณ์ด้วย Grid และ FlexBox"
@Composable
fun AdaptiveActionHeader(tags: List<String>) {
        FlexBox(
        config = {
            direction( if (isCompact)
FlexDirection.Column else FlexDirection.Row )
            wrap(FlexWrap.Wrap)
            alignItems(FlexAlignItems.Center)
            gap(row = 8.dp, column = 12.dp)
        },
        modifier = Modifier.fillMaxWidth()
    ) {
        Text(
            text = "Home",
            modifier = Modifier.flex {
                basis(FlexBasis.Auto)
                grow(1f)
                shrink(1f)
            }
        )
        tags.forEach { tag ->
            FilterChipItem(
                label = tag,
                modifier = Modifier.flex { shrink(0f) }
            )
        }
    }
}
ข้อมูลโค้ด Kotlin ที่นาที 22:09 ใน "สถาปัตยกรรมเลย์เอาต์ระดับแอปและการนำทาง 3"
val UiMediaScope.windowSizeClass: WindowSizeClass
    @Composable get() = derivedMediaQuery {
        WindowSizeClass.BREAKPOINTS_V2.computeWindowSizeClass(
            widthDp = windowWidth.value,
            heightDp = windowHeight.value
        )
    }

@Composable
fun AppRoot() {
    val sizeClass = mediaQuery { windowSizeClass }
    val isExpandedWidth = sizeClass.isWidthAtLeastBreakpoint(
        WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND // 600dp
    )
    // Drive app-level navigation and scene strategies...
}
ข้อมูลโค้ด Kotlin ที่นาที 23:33 ใน "สถาปัตยกรรมเลย์เอาต์ระดับแอปและ Navigation 3"
if (isExpandedWidth) {
    /* return a multi-pane scene */
}
else {
    /* return a single pane scene */
}
ข้อมูลโค้ด Shell ที่ 25:18 ใน "การสาธิตการพัฒนาและการทำงานอัตโนมัติแบบ Agentic"
android skills list
android skills add adaptive