שלבי העבודה ב-Jetpack פיתוח נייטיב

בדומה לרוב ערכות הכלים לבניית ממשק משתמש, התכונה 'כתיבה' מעבדת מסגרת שלבים נפרדים. אם נבחן את מערכת Android View, יש לה שלבים: מדידה, פריסה ושרטוט. הכתיבה דומה מאוד אבל כוללת שלב נוסף וחשוב שנקרא יצירה מוזיקלית בהתחלה.

יצירה מוזיקלית מתוארת בכל מסמכי התיעוד שלנו, כולל חשיבה כתיבה ו-State ו-Jetpack פיתוח נייטיב.

שלושת השלבים של מסגרת

תהליך הכתיבה מורכב משלושה שלבים עיקריים:

  1. יצירה: מה ממשק המשתמש להצגה. פיתוח נייטיב מפעיל פונקציות קומפוזביליות יוצרת תיאור של ממשק המשתמש.
  2. פריסה: איפה למקם את ממשק המשתמש. השלב הזה כולל שני שלבים: מדידה ומיקום. רכיבי הפריסה מודדים וממקמים את עצמם כל רכיבי צאצא בקואורדינטות דו-ממדיות, עבור כל צומת בעץ הפריסה.
  3. שרטוט: איך מתבצע רינדור. רכיבי ממשק המשתמש מופיעים בלוח הציור, בדרך כלל מסך המכשיר.
תמונה של שלושת השלבים שבהם 'פיתוח נייטיב' משנה נתונים לממשק משתמש (לפי הסדר, נתונים, הרכבה, פריסה, שרטוט, ממשק משתמש).
איור 1. שלושת השלבים שבהם התכונה 'כתיבה' ממירה נתונים לממשק משתמש.

בדרך כלל סדר השלבים האלה זהה, ומאפשר זרימה של נתונים באחד כיוונה מקומפוזיציה לפריסה לפריסה ועד לשרטוט כדי ליצור פריים כזרימת נתונים חד-כיוונית). BoxWithConstraints ו- הערכים LazyColumn ו-LazyRow לא בולטים חריגים, שבהם הרכב הצאצאים תלוי בפריסת ההורה שלב אחד.

אפשר להניח בבטחה ששלושת השלבים האלה מתרחשים באופן וירטואלי בכל פריים, אבל לצורך הביצועים, הכלי 'לכתוב' נמנע מביצוע פעולות חוזרות לחשב את אותן התוצאות מאותם קלט בכל השלבים האלה. אימייל חדש מדלג על הפעלה של תוכן קומפוזבילי פונקציה זו אם היא יכולה להשתמש שוב בתוצאה קודמת, וממשק המשתמש של 'פיתוח נייטיב' לא נפרס מחדש או אם אין צורך, משרטטים מחדש את כל העץ. התכונה 'כתיבה' מבצעת רק את נדרשת עבודה מינימלית כדי לעדכן את ממשק המשתמש. אפשר לבצע את האופטימיזציה הזו כי הכלי 'פיתוח נייטיב' מסתמך על קריאות שונות בשלבים השונים.

הבנת השלבים

בקטע הזה מתואר איך מתבצעות שלושת שלבי הבנייה של תכנים קומפוזביליים על שלושת הפיצ'רים האלה.

יצירה מוזיקלית

בשלב ההרכבה, זמן הריצה של 'פיתוח נייטיב' מפעיל פונקציות קומפוזביליות הפונקציה יוצרת מבנה עץ שמייצג את ממשק המשתמש. עץ ממשק המשתמש הזה מורכב פריסת צמתים שמכילים את כל המידע הדרוש לשלבים הבאים, כמו שמוצג בסרטון הבא:

איור 2. העץ שמייצג את ממשק המשתמש שנוצר בהרכבת שלב אחד.

קטע משנה של עץ הקוד ועץ ממשק המשתמש נראה כך:

קטע קוד עם חמש תכנים קומפוזביליים ועץ ממשק המשתמש שמתקבל, עם צומתי צאצא שמסתעפים מצומתי ההורה שלהם.
איור 3. קטע משנה של עץ ממשק משתמש עם הקוד התואם.

בדוגמאות האלה, כל פונקציה קומפוזבילית בקוד ממופה לפריסה אחת בעץ ממשק המשתמש. בדוגמאות מורכבות יותר, תכנים קומפוזביליים יכולים להכיל לוגיקה לשלוט בו, וליצור עץ אחר בהינתן מצבים שונים.

פריסה

בשלב הפריסה, התכונה 'פיתוח נייטיב' משתמשת בעץ ממשק המשתמש שהופק בשלב היצירה בתור קלט. אוסף צמתים הפריסה מכיל את כל המידע הנדרש לקבוע את הגודל והמיקום של כל צומת במרחב הדו-ממדי.

איור 4. המדידה והמיקום של כל צומת של פריסה בעץ ממשק המשתמש במהלך שלב הפריסה.

במהלך שלב הפריסה, עוברים על העץ בשלושת השלבים הבאים אלגוריתם:

  1. מדידת צאצאים: צומת מודד את הצאצאים שלו, אם יש כאלה.
  2. בחירת גודל משלו: על סמך המדידות האלה, צומת מחליט בעצמו גודל.
  3. מיקום צאצאים: כל צומת צאצא ממוקם ביחס לצומת שלו המיקום.

בסוף השלב הזה, כל צומת פריסה מכיל:

  • רוחב וגובה מוקצים
  • קואורדינטת x ו-y שבה יש לשרטט

משחזרים את עץ ממשק המשתמש מהקטע הקודם:

קטע קוד עם חמש תכנים קומפוזביליים ועץ ממשק המשתמש שמתקבל, עם צומתי צאצא שמסתעפים מצומתי ההורה שלהם

עבור העץ הזה, האלגוריתם פועל באופן הבא:

  1. Row מודד את הצאצאים שלו, Image ו-Column.
  2. הערך של Image נמדד. אין פה ילדים, לכן היא מחליטה בנפרד ומדווחת את הגודל חזרה אל Row.
  3. הערך של Column נמדד בשלב הבא. הוא מודד את הצאצאים שלו (שני Text לתוכן קומפוזבילי).
  4. הערך של Text הראשון נמדד. אין בסרטון ילדים, לכן הוא מחליט בגודל שלו ומדווח על גודלו חזרה אל Column.
    1. הערך השני של Text נמדד. אין בסרטון ילדים, לכן הוא מחליט בגודל שלו ומדווח עליו חזרה אל Column.
  5. הפרמטר Column משתמש במדידות הצאצא כדי לקבוע את הגודל שלו. היא משתמשת ב הרוחב המקסימלי של הילד וסכום הגובה של הצאצאים שלו.
  6. הColumn ממקמת את הצאצאים שלו ביחס לעצמו, ומניחה אותם מתחת שתי רשתות אנכיות.
  7. הפרמטר Row משתמש במדידות הצאצא כדי לקבוע את הגודל שלו. היא משתמשת ב גובה הילד המקסימלי וסכום מידות הרוחב של הצאצאים. לאחר מכן היא מציבה את הילדים שלו.

לידיעתך, בכל צומת נעשה ביקור פעם אחת בלבד. בסביבת זמן הריצה של 'פיתוח נייטיב' נדרשת רק פעולה אחת לעבור דרך עץ ממשק המשתמש כדי למדוד ולמקם את כל הצמתים, וכך לשפר או של ביצועים. כשמספר הצמתים בעץ גדל, משך הזמן המעבר אליו עולה באופן ליניארי. לעומת זאת, אם כל צומת ביקרו כמה פעמים, זמן המעבר גדל באופן אקספוננציאלי.

שרטוט

בשלב השרטוט, עוברים שוב על העץ מלמעלה למטה, וכל אחד הצומת מצייר את עצמו על המסך בתורו.

איור 5. שלב הציור משרטט את הפיקסלים על המסך.

בהמשך לדוגמה הקודמת, תוכן העץ מצויר באופן הבא:

  1. Row שורטט את כל התוכן שעשוי להיות בו, כמו צבע רקע.
  2. Image מושך את עצמו.
  3. Column מושך את עצמו.
  4. Text הראשון והשני משרטטים את עצמם, בהתאמה.

איור 6. עץ ממשק משתמש והייצוג שלו.

קריאה לפי מדינה

כשקוראים את הערך של מצב תמונת מצב במהלך בכל השלבים שמפורטים למעלה, 'כתיבה מהירה' עוקבת באופן אוטומטי אחר הפעולות הערך נקרא. מעקב זה מאפשר ל'כתיבה' להפעיל מחדש את הקורא כאשר ערך המצב משתנה, והוא הבסיס לניראות של מצב ב-Compose (כתיבה).

מצב כזה נוצר בדרך כלל באמצעות mutableStateOf() ולאחר מכן ניתן לגשת אליו דרך בשתי דרכים: באמצעות גישה ישירה לנכס value או לחלופין באמצעות נציג מורשה של נכס Kotlin. אפשר לקרוא מידע נוסף עליהם במדינה באזור תכנים קומפוזביליים. למטרות במדריך הזה, "קריאת המדינה" מתייחס לאחת מהאפשרויות האלה שיטות.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

מאחורי הקלעים של הנכס מעניק גישה, 'getter' ו-'setter' משמשות כדי לגשת value. פונקציות getter ו-setter מופעלות רק כשמפנות את הנכס כערך ולא כאשר הוא נוצר, ולכן שתי הדרכים שלמעלה מקבילות.

כל בלוק קוד שאפשר להריץ אותו מחדש כשמצב קריאה משתנה היקף להפעלה מחדש. 'פיתוח נייטיב' עוקב אחר שינויים בערך המצב והפעלה מחדש שונים בשלבים שונים.

קריאות למצב הדרגתי

כפי שצוין למעלה, בטראקים 'כתיבה' ו'כתיבה' יש שלושה שלבים עיקריים איזה מצב נקרא בתוך כל אחד מהם. הפעולה הזו מאפשרת ל'כתיבה' להודיע רק שלבים ספציפיים שישפיעו על כל אחד מהרכיבים המושפעים ממשק משתמש.

נעבור על כל השלבים ונתאר מה קורה כשקוראים ערך של מדינה בתוכו.

שלב 1: יצירה מוזיקלית

קריאות של מדינות בתוך פונקציית @Composable או בלוק lambda, משפיעות על הרכב ואולי גם את השלבים הבאים. כשערך המצב משתנה, מקודד מתזמנים חזרות חוזרות של כל הפונקציות הקומפוזביליות שקוראות ערך במצב. לתשומת ליבכם: יכול להיות שבסביבת זמן הריצה לדלג על חלק או על כולם בפונקציות קומפוזביליות אם ערכי הקלט לא השתנו. בקטע דילוג אם ערכי הקלט לא השתנה לקבלת מידע נוסף.

בהתאם לתוצאת ההרכבה, ממשק המשתמש לכתיבה מפעיל את הפריסה והשרטוט השלבים. ייתכן שהמערכת תדלג על השלבים האלה אם התוכן יישאר ללא שינוי והגודל שלו והפריסה לא תשתנה.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

שלב 2: פריסה

שלב הפריסה מורכב משני שלבים: מדידה ומיקום מודעה. בשלב המדידה מפעיל את ה-lambda, ומעביר את הפונקציה הקומפוזבילית Layout MeasureScope.measure של הממשק LayoutModifier, וכן הלאה. השלב של המיקום מפעיל את בלוק המיקום של הפונקציה layout, של Modifier.offset { … }, וכן הלאה.

קריאות המדינה במהלך כל אחד מהשלבים האלה משפיעות על הפריסה ואולי שלב השרטוט. כשערך המצב משתנה, ממשק המשתמש של 'כתיבה' מתזמנ את הפריסה שלב אחד. הוא גם מפעיל את שלב השרטוט אם הגודל או המיקום השתנה.

כדי לדייק יותר, יש הפרדה בין שלב המדידה לבין השלב של מיקום המודעה להפעיל מחדש היקפי הרשאות, כלומר קריאה של המצב בשלב המיקום לא מופעלת מחדש את שלב המדידה לפני כן. אבל לרוב, שני השלבים האלה אחת משולבות, כך שמצב שנקרא בשלב המיקום יכול להשפיע על הפעלות אחרות ההיקפים שמשויכים לשלב המדידה.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

שלב 3: שרטוט

קריאות מצב במהלך קוד השרטוט משפיעות על שלב השרטוט. דוגמאות נפוצות כוללים את Canvas(), Modifier.drawBehind ו-Modifier.drawWithContent. מתי כאשר ערך המצב משתנה, ממשק המשתמש של הכתיבה מפעיל רק את שלב השרטוט.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

אופטימיזציה של קריאות מצב

מכיוון שהכלי 'כתיבה' מבצע מעקב אחר קריאת מצב מקומי, אנחנו יכולים לצמצם שמבוצעת על ידי קריאת כל מצב בשלב המתאים.

נבחן עכשיו דוגמה. כאן יש לנו Image() שמשתמש בקיזוז כדי לקזז את מיקום הפריסה הסופי שלו, וכתוצאה מכך יתקבל אפקט פרלקס המשתמש גולל.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

הקוד הזה פועל, אבל הביצועים שלו לא אופטימליים. כמו שכתוב, הקוד קורא את הערך של המצב firstVisibleItemScrollOffset ומעביר אותו ה Modifier.offset(offset: Dp) מותאמת אישית. כשהמשתמש יגלול, הערך של firstVisibleItemScrollOffset ישתנה שינוי. כידוע לנו, הכלי 'פיתוח נייטיב' עוקב אחר קריאה של כל מצב שניתן כדי שניתן יהיה להפעיל אותו מחדש (להפעיל מחדש) את קוד הקריאה, שבדוגמה שלנו הוא התוכן Box

זוהי דוגמה למצב שנקרא בשלב היצירה. הדבר לא בהכרח דבר רע, ולמעשה הוא הבסיס של הרכבה מחדש, שמאפשר לשינויים בנתונים לפלוט ממשק משתמש חדש.

בדוגמה הזו היא לא אופטימלית, כי כל אירוע גלילה יוביל בכל התוכן הקומפוזבילי שנבדק מחדש ואז גם נמדד, שנפרס ולבסוף צייר. אנחנו מפעילים את שלב הכתיבה בכל גלילה למרות שמה שאנחנו מציגים לא השתנה, רק איפה הוא מוצג. ניתן לבצע אופטימיזציה לקריאת המצב שלנו כדי להפעיל מחדש את שלב הפריסה.

יש גרסה אחרת של מגביל ההיסט: Modifier.offset(offset: Density.() -> IntOffset)

הגרסה הזו לוקחת פרמטר lambda, שבו ההיסט שנוצר מוחזר על ידי לבלוק lambda. נעדכן את הקוד שלנו כך שישתמש בו:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

אז למה המדד הזה מניב ביצועים טובים יותר? בלוק lambda שאנחנו מספקים למגביל הוא שהופעלו במהלך שלב הפריסה (ספציפית, במהלך שלב הפריסה) מיקום מודעה), כלומר, המצב firstVisibleItemScrollOffset שלנו אינו שנקראים עוד הרבה זמן במהלך ההרכבה. מכיוון שכתיבת רצועות כאשר מצב נקרא, המשמעות של השינוי הזה היא שאם הערך של firstVisibleItemScrollOffset ישתנה, צריך רק להפעיל מחדש את שלבי הפריסה והשרטוט.

הדוגמה הזו מסתמכת על מגבילי ההיסט השונים כדי לבצע אופטימיזציה של שהתקבל, אבל הרעיון הכללי הוא נכון: מנסים להתאים את קריאות המצב לשוק המקומי השלב הנמוך ביותר האפשרי, מה שמאפשר ל'פיתוח נייטיב' לבצע כמות מינימלית של בעבודה.

כמובן, הרבה פעמים מאוד צריך לקרוא מצבים בהרכבה שלב אחד. למרות זאת, יש מקרים שבהם אנחנו יכולים לצמצם את מספר הרכבים מחדש על ידי סינון שינויים במצב. למידע נוסף בנושא, ראה derivedStateOf: המרת אובייקט מצב אחד או יותר לאובייקט אחר .

לולאת הרכבה (תלות בין שלב מחזורית)

קודם הזכרנו ששלבי הכתיבה מופעלים תמיד באותם ושאין דרך לחזור אחורה באותה פריים. עם זאת, ההגדרה הזו לא אוסרת על אפליקציות ליצור לולאות של יצירה במסגרות שונות. נבחן את הדוגמה הזו:

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

כאן הטמענו (רע) עמודה אנכית, עם התמונה בחלק העליון של הדף, ולאחר מכן הטקסט מתחתיו. אנחנו משתמשים ב-Modifier.onSizeChanged() כדי לדעת ואנחנו משתמשים ב-Modifier.padding() בטקסט כדי להזיז אותו למטה. ההמרה הלא טבעית מPx חזרה לDp כבר מציין שהקוד מכיל בעיה.

הבעיה בדוגמה הזו היא שאנחנו לא מגיעים לשלב 'הגמר' פריסה בתוך פריים אחד. הקוד מסתמך על כמה פריימים שמתרחשים, עבודה מיותרת, שמובילה לממשק המשתמש שהופיע במסך עבור המשתמש.

בואו נעבור על כל פריים כדי לראות מה קורה:

בשלב ההרכבה של הפריים הראשון, הערך של imageHeightPx הוא 0, והטקסט סופק עם Modifier.padding(top = 0). לאחר מכן, הפריסה לאחר מכן, והקריאה החוזרת (callback) של מקש הצירוף onSizeChanged מתבצעת. מתבצע עדכון של imageHeightPx לגובה בפועל של התמונה. כתיבת לוחות זמנים מחדש למסגרת הבאה. בשלב השרטוט, הטקסט מעובד עם המרווח הפנימי של 0 כי השינוי בערך לא משתקף. עדיין.

לאחר מכן, 'כתיבה' תתחיל את הפריים השני שתוזמן על ידי שינוי הערך של imageHeightPx המצב נקרא בבלוק התוכן של Box, והוא מופעל בשלב היצירה. הפעם, הטקסט כולל מרווח פנימי תואם לגובה התמונה. בשלב הפריסה, הקוד קובע את הערך imageHeightPx שוב, אבל לא תתוזמן הרכב מחדש מאז הערך נשאר ללא שינוי.

בסוף, אנחנו מקבלים את המרווח הפנימי הרצוי בטקסט, אבל הוא לא אופטימלי הוצאות עוד פריים כדי להעביר את ערך המרווח הפנימי בחזרה לשלב אחר, התוצאה תהיה יצירת פריים עם תוכן חופף.

הדוגמה הזו עשויה להיראות מוזרה, אבל חשוב להיזהר מהדפוס הכללי הבא:

  • Modifier.onSizeChanged(), onGloballyPositioned() או פריסה אחרת פעולות
  • עדכון מצבים מסוימים
  • להשתמש במצב הזה כקלט לשינוי פריסה (padding(),height(), או דומה)
  • עשוי להיות חוזר

הפתרון לדוגמה שלמעלה הוא להשתמש ברכיבי הפריסה המתאימים. הדוגמה שלמעלה אפשר להטמיע בעזרת Column() פשוט, אבל יכול להיות שיהיו לכם הוא דוגמה מורכבת שמחייבת משהו מותאם אישית, שמחייב כתיבת פריסה מותאמת אישית. לעיון במדריך פריסות בהתאמה אישית אפשר לקבל מידע נוסף.

העיקרון הכללי כאן הוא כשיש מקור מהימן אחד בכמה ממשקי משתמש. שצריך למדוד ולהציב זה ביחס לזה. באמצעות פריסה מתאימה או יצירה של פריסה מותאמת אישית, מספקים שההורה המשותף משמש כמקור האמת שיכול לתאם את הקשר בין מספר אלמנטים. הוספה של מצב דינמי משנה את העיקרון הזה.