ระบบนิเวศของ Android มีขนาดใหญ่มาก โดยผู้ใช้สามารถสลับไปมาระหว่างโทรศัพท์ แท็บเล็ต อุปกรณ์แบบพับได้ และสภาพแวดล้อมเดสก์ท็อปได้อย่างราบรื่น ตอนนี้เราใช้ Compose เป็นหลักแล้ว เซสชันนี้จะสำรวจวิธีออกแบบสถาปัตยกรรมแอปสำหรับหน้าต่างที่ปรับขนาดได้และสัดส่วนภาพที่หลากหลาย ซึ่งสมมติฐานเกี่ยวกับเลย์เอาต์แบบเดิมๆ จะใช้ไม่ได้อีกต่อไป ส่งผลให้ UI ยืดออกและสถานะสูญหาย
สำรวจว่าความสามารถล่าสุดใน Jetpack Compose, Navigation 3 และ WindowManager จะช่วยคุณสร้างเลย์เอาต์แบบไดนามิกที่ปรับขนาดได้อย่างเป็นธรรมชาติจากหน้าจอขนาดกะทัดรัดไปจนถึงการแสดงผลแบบแยกหน้าจอได้อย่างไร รับกลยุทธ์เพื่อเร่งเวิร์กโฟลว์การพัฒนาโดยใช้ Gemini และทักษะของเรา เช่น ทักษะการปรับเปลี่ยน เพื่อเปลี่ยนโครงสร้างภายในฐานของโค้ดเดิมโดยอัตโนมัติสำหรับหน้าจอขนาดใหญ่ขึ้น
MediaQuery API เวอร์ชันทดลองจะติดตามความสามารถของ UI ที่ใช้งานอยู่ เช่น ขนาดหน้าต่างและท่าทางการพับในลักษณะที่ประกาศ โดยจะแทนที่การเชื่อมต่อเครื่องมือจัดการแพลตฟอร์มเดิมหลายรายการเข้ากับสถานะ ComposeGrid และ FlexBox แบบพื้นฐานที่เสถียรมีองค์ประกอบที่ใช้สร้างสรรค์เลย์เอาต์แบบปรับเปลี่ยนได้ Grid ช่วยให้จัดเรียงหลายมิติแบบกำหนดพารามิเตอร์ได้ภายใน Composable เดียว ส่วน FlexBox จะช่วยกระจายและตัดพื้นที่แบบมิติเดียวข้อความถอดเสียง
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 คืออะไร
ฉันมีคำใบ้
Francesco Romano
ใช่
ใน Compose การเปลี่ยนแปลงการกำหนดค่าเป็นเพียงการจัดองค์ประกอบใหม่ และการจัดองค์ประกอบใหม่จะเกิดขึ้นตลอดเวลา
เมื่อหน้าต่างกว้างขึ้น อาจเป็นเพราะผู้ใช้กางอุปกรณ์ออก Composable จะอ่านขนาดใหม่ และคอมโพเนนต์ที่เอาต์พุตขึ้นอยู่กับขนาดนั้นจะทำงานอีกครั้ง
นั่นคือทั้งหมดของกิจกรรม
แต่ตอนนี้ มาดูกันว่าการเขียนแอปที่ปรับเปลี่ยนตามอุปกรณ์ที่ทันสมัยโดยใช้เครื่องมือและคอมโพเนนต์ Compose ล่าสุดเป็นอย่างไร
ดังนั้น โลกของ XML จึงทำให้คุณต้องถามคำถามต่างๆ เช่น "ฉันกำลังใช้งานบนโทรศัพท์ แท็บเล็ต หรือเดสก์ท็อป" แล้วจึงแยก UI ตามหมวดหมู่อุปกรณ์
แต่มีเหตุผลสำคัญที่ควรหลีกเลี่ยงการทำเช่นนั้น
แม้ว่าจะทำงานบนอุปกรณ์จริงเครื่องเดียวกัน แต่บริบทของแอปพลิเคชันและความสามารถของ UI อาจเปลี่ยนแปลงแบบไดนามิกขณะรันไทม์
ดังนั้น ข้อความที่ฉันต้องการฝากไว้คือการตัดสินใจเกี่ยวกับ UI ควรขับเคลื่อนด้วยความสามารถที่ใช้งานอยู่ในปัจจุบัน เช่น ขนาดหน้าต่างปัจจุบัน ท่าทาง และลักษณะฮาร์ดแวร์บางอย่าง
มาดูตัวอย่างคลาสสิกกัน ซึ่งก็คือหน้าต่างเดสก์ท็อปบนจอภาพภายนอก
หากคุณนำโทรศัพท์ Pixel ไปเสียบเข้ากับจอภาพภายนอกบนโต๊ะทำงาน กระบวนการเดียวกันของแอปที่เพิ่งทำงานบนหน้าจอแนวตั้งขนาด 6 นิ้วจะทำงานบนจอภาพขนาด 32 นิ้วที่มีแป้นพิมพ์และเมาส์ต่ออยู่
ดังนั้น การตรวจสอบประเภทอุปกรณ์ที่แอปทำงานอยู่จึงไม่รับประกันว่าหน้าจอที่แสดงอยู่จริงหรือรูปแบบการป้อนข้อมูลจะเป็นอย่างไร
นอกจากนี้ Android ยังรองรับหลายหน้าต่างด้วย หน้าต่างจึงปรับขนาดได้
แม้ว่าหน้าจอของ Googlebook จะใหญ่กว่าโทรศัพท์ แต่แอปของคุณอาจทำงานในหน้าต่างกว้าง 400 dp ร่วมกับแอปอื่นๆ
ดังนั้น การค้นหาเมตริกการแสดงผลทางกายภาพแบบคงที่จึงอาจไม่มีความหมายเช่นกัน
ดังนั้น หากต้องการออกแบบแอปสำหรับ Compose ก่อน เราต้องเข้าใจวิธีค้นหาสภาพแวดล้อม
และฉันอยากเริ่มด้วยการพยายามกำหนดแนวคิดเกี่ยวกับความสามารถของ UI อย่างเป็นทางการ
ความสามารถของ UI ไม่เพียงแต่รวมถึงความกว้างและความสูงของหน้าต่างหรือท่าทางของอุปกรณ์แบบพับได้เท่านั้น แต่ยังรวมถึงรูปแบบอินพุตและเอาต์พุตที่ใช้งานอยู่ในปัจจุบันด้วย
เราพบว่าผู้ใช้ในสภาพแวดล้อมเดสก์ท็อปไม่ได้ใช้หน้าจอสัมผัสเสมอไป
ผู้ใช้สามารถใช้แป้นพิมพ์และเมาส์ได้
ในระบบยานยนต์ รถบางคันมีปุ่มหมุนหรืออินพุตที่แตกต่างกัน วิธีป้อนสัญญาณที่แตกต่างกัน
นอกจากขนาดหน้าจอและอินพุตแล้ว ยังรวมถึงระยะห่างระหว่างผู้ใช้กับจอแสดงผล เช่น ระยะการมองเห็น หรือกล้องหรือไมโครโฟนของฮาร์ดแวร์เชื่อมต่ออยู่หรือไม่
ทั้งหมดนี้คือความสามารถของ UI
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
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 และอื่นๆ
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
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 รายการ
ตกลง
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 ใหม่ๆ ทั้งหมด แล้วสนุกไปกับมันได้เลย
ขอขอบคุณ
<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>mediaQuery { ... }
derivedMediaQuery { ... }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
}@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
}
}@Composable
fun currentVideoPlayerLayout(): VideoPlayerLayout {
return mediaQuery {
when(windowPosture) {
UiMediaScope.Posture.Tabletop -> VideoPlayerLayout.Tabletop
else -> VideoPlayerLayout.Default
}
}
}@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")
}
}
}@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()
}
}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...
}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))
}@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)
}
}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()
}
}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)
}
}@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
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) }
)
}
}
}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...
}if (isExpandedWidth) {
/* return a multi-pane scene */
}
else {
/* return a single pane scene */
}android skills list
android skills add adaptive