当前位置:网站首页>How to optimize the performance of compose? Find the answer through "underlying principles" | developers say · dtalk

How to optimize the performance of compose? Find the answer through "underlying principles" | developers say · dtalk

2022-06-11 16:03:00 Android Developer

9bd6ad698275deeaaa6036bdaa0cbeb5.png

The original author of this article : Zhu Tao , original text Hair Cloth in : Zhu Tao's study room ‍

This year's  Google I/O At the conference ,Android The official target is Jetpack Compose A series of performance optimization suggestions are given , Documents and videos have been released . in general , The official content is very good , After reading it, I still have some unfinished business , I recommend you to have a look .

however , If you are right about Compose Of Underlying principle Not particularly familiar words , that , After watching Android Official document 、 After the video , Your mind may still be full of questions , understand . After all , The official response to 「Compose performance optimization 」 The definition of a topic is 「Intermediate Middle stage 」. For a new technology that has just been launched less than a year ,「 Middle stage 」 It is already a relatively high requirement .

6466c3c0dd543d8bf6bc88215f4b0680.png

Of course , Don't worry , The purpose of my writing this article , Is to make more Compose Developers can understand the official Compose Performance optimization suggestions .

let me put it another way , We just need to make up 「 Billion points 」Compose Of 「 Underlying principle 」 that will do .2d0904379f7d4ef721ea657821a39ff0.png

55fdb8d047486effcdf1d5aa380b36ed.gif

1c9fcd743dee1df5b0c49e449a6bdcc7.png

Composable The essence of

We all know ,Jetpack Compose The most magical place is :  It can be used Kotlin Write UI Interface ( There is no need to XML). and , With the help of Kotlin The properties of higher order functions of ,Compose UI The interface is also very intuitive .

//  Code segment 1


@Composable
fun Greeting() { // 1
    Column { // 2
        Text(text = "Hello")
        Text(text = "Jetpack Compose!")
    }
}

The above code , Even if you don't have any Compose Basics , It should also be easy to understand .Column amount to Android Middle vertical linear layout LinearLayout, In this layout , We put two Text Control .

The final UI Interface display , As shown in the figure below .

806989ead9b1e703d4d32faa28b26fc1.png

Although the example is simple , But in the code above , There are still two details that need our attention , I've marked it with comments : 

notes 1: Greeting()  It's a Kotlin Function of , If you put aside its @Composable If we don't talk about notes , that , Its function type should be () -> Unit. however , because @Composable Is a very special annotation ,Compose The compiler plug-in for will see it as an impact Function type One of the factors . therefore ,Greeting() Its function type should be  @Composable () -> Unit.( By the way , The other two common function type influence factors are : suspend、 Receiver of function type .)

notes 2: Column {}, Please pay attention to its {}, The reason why we can write code like this , This is actually Kotlin Higher order functions provided Abbreviation . Its complete writing should be like this : 

//  Code segment 2


Column(content =  {
    log(2)
    Text(text = "Hello")
    log(3)
    Text(text = "Jetpack Compose!")
})

thus it can be seen ,Compose The grammar of , It's really just through Kotlin The higher-order function of .Column()、Text() Looks like it's calling UI Constructor for control , But it's actually just a normal top-level function , So , It's just a kind of DSL Of " Smoke screen " nothing more .

that , Come here , We can actually make a phased summary : Composable The essence of , Is the function . This conclusion seems simple , But it can lay the foundation for the later principle research .

Next , Let's talk about Composable The characteristics of .

8978fa9ba19f29a2534de59e36bf1997.png

Composable The characteristics of

As we have said before ,Composable It's essentially a function . that , Its characteristics , In fact, it is very close to ordinary functions . This remark looks like nonsense , Let me give you an example .

Based on the previous code , Let's add some log: 

//  Code segment 3


@Composable
fun Greeting() {
    log(1)
    Column {
        log(2)
        Text(text = "Hello")
        log(3)
        Text(text = "Jetpack Compose!")
    }
    log(4)
}


private fun log(any: Any) {
    Log.d("MainActivity", any.toString())
}

Excuse me, , What is the output of the above code ? If you have seen my tutorial on collaboration , Then I must feel a little " virtual ", Right ? however , The output of the above code is very intuitive .

//  Output results 
//  Be careful :  At present Compose Version is 1.2.0-beta
//  In future versions ,Compose The bottom layer is possible to optimize , And change this behavior pattern .


com.boycoder.testcompose D/MainActivity: 1
com.boycoder.testcompose D/MainActivity: 2
com.boycoder.testcompose D/MainActivity: 3
com.boycoder.testcompose D/MainActivity: 4

Look at it. ,Composable It is not only a common function from the perspective of source code , Its behavior pattern at runtime , It is similar to ordinary functions . We wrote it out Composable function , They are nested in each other , Eventually a tree structure will be formed , To be exact, it's a N Fork tree . and Composable The order in which functions are executed , It's actually the right one N Fork tree DFS Traverse .

thus , We wrote it out Compose UI It's almost : " What you see is what you get ".

97caec3e8594461f56037687a99a8f93.png

Maybe , You will feel , The above example , It's nothing , After all ,XML You can do something similar . that , Let's look at another example .

//  Code segment 4


@Composable
fun Greeting() {
    log("start")
    Column {
        repeat(4) {
            log("repeat $it")
            Text(text = "Hello $it")
        }
    }
    log("end")
}


//  Output results : 
com.boycoder.testcompose D/MainActivity: start
com.boycoder.testcompose D/MainActivity: repeat 0
com.boycoder.testcompose D/MainActivity: repeat 1
com.boycoder.testcompose D/MainActivity: repeat 2
com.boycoder.testcompose D/MainActivity: repeat 3
com.boycoder.testcompose D/MainActivity: end

We use repeat{} Repeated calls to 4 Time Text(), We have successfully created on the screen 4 individual Text Control , The key is , They can also be used in Column{} Normal vertical arrangement in the middle . Such a code pattern , In the past XML Times are unimaginable .

26f6d903e3c8f0a5400c3efb3623973b.png

Come back , Precisely because Composable The essence of is a function , It has some of the characteristics of ordinary functions , thus , It also allows us to write just like normal code , Use logical statements to describe UI Layout .

Okay , Now we know Composable The essence of is function , But , The ones on our mobile screen UI How did the control appear ? Next , We need to learn again 「 A little bit 」Compose Knowledge of compiler plug-ins .PS: This time , I promise it's really 「 A little bit 」.e5abf431257904a21209e12bd59ce751.png

51e49663d66ad77721f276228ce362a0.png

Compose  Compiler plug-ins

although Compose Compiler Plugin It looks like a very tall thing , But from a macro perspective , What it does is simple .

It's co-operative suspend keyword , It can change the type of function ,Compose Annotations  @Composable  It's the same thing . in general , The corresponding relationship between them is as follows : 

4562e5264e1e30526b2924ecafb721bc.png

say concretely , We are Kotlin It says Composable function 、 Suspend function , After compiler conversion , Will be injected with additional parameters . For suspended functions , There will be one more parameter list Continuation Parameters of type ; about Composable function , There will be one more parameter list Composer Parameters of type .

Why can't ordinary functions call 「 Suspend function 」 and 「Composable function 」, The underlying reason is : Ordinary functions cannot be passed in at all ContinuationComposer As a parameter of the call .

Be careful : What needs special explanation is , In many scenarios ,Composable The function passes through Compose Compiler Plugin After the transformation , In fact, other parameters may be added . A more complex situation , We'll leave it to the following articles for further analysis .

in addition , because Compose It doesn't belong to Kotlin The category of , In order to achieve Composable Function conversion ,Compose The team is through 「Kotlin Compiler plug-ins 」 In the form of . We wrote Kotlin The code will first be converted to IR, and Compose Compiler Plugin At this stage, it directly changed its structure , This changes the final output Java Bytecode and Dex. This process , That is, the behavior described in the action chart I put at the beginning of the article .

I won't paste the moving pictures again , The following is a static flow chart .

4b1ed5b390659d75c8547287b102ea0b.png

however ,Compose Compiler Not just change 「 Function signature 」 So simple , If you will Composable Function decompiles to Java Code , You will find that its function body will also change .

Let's take a concrete example , To explore Compose Of 「 restructuring 」(Recompose) Implementation principle of .

f40ae3b92a24fd90294a9db036f3e612.png

Recompose Principle

//  Code segment 5


class MainActivity : ComponentActivity() {
    //  Omit 


    @Composable
    fun Greeting(msg: String) {
        Text(text = "Hello $msg!")
    }
}

The code above is very simple ,Greeting() The logic of is very simple , But when it is decompiled into Java after , Its actual logic will become much more complicated .

//  Code segment 6


public static final void Greeting(final String msg, Composer $composer,
 final int $changed) { //  Extra changed Let's analyze later 


  //                        1, Start 
  //                          ↓
  $composer = $composer.startRestartGroup(-1948405856);


  int $dirty = $changed;
  if (($changed & 14) == 0) {
     $dirty = $changed | ($composer.changed(msg) ? 4 : 2);
  }


  if (($dirty & 11) == 2 && $composer.getSkipping()) {
     $composer.skipToGroupEnd();
  } else {
     TextKt.Text-fLXpl1I(msg, $composer, 0, 0, 65534);
  }


  //                                  2, end 
  //                                     ↓
  ScopeUpdateScope var10000 = $composer.endRestartGroup();


  if (var10000 != null) {
     var10000.updateScope((Function2)(new Function2() {
        public final void invoke(@Nullable Composer $composer, int $force) {
           //              3, Recursively call itself 
           //                ↓
           MainActivityKt.Greeting(msg, $composer, $changed | 1);
        }
     }));
  }
}

without doubt ,Greeting() After decompiling , The reason why it has become so complicated , The reasons behind it are all because  Compose Compiler Plugin. There are too many details worth digging into in the above code , In order not to deviate from the theme , For the time being, we will only focus on 3 A note , Let's see one by one .

  • notes 1: composer.startRestartGroup, This is a Compose The compiler plug-in is Composable An auxiliary code for function insertion . Its function is to create a repeatable in memory  Group, It often represents a Composable The function begins to execute ; meanwhile , It also creates a corresponding  ScopeUpdateScope, And this ScopeUpdateScope It will be in the comments 2 Used at .

  • notes 2: composer.endRestartGroup(), It often represents a Composable End of function execution . And this Group, To a certain extent , It also describes UI The structure and hierarchy of . in addition , It also returns a ScopeUpdateScope, And it triggers 「Recompose」 The key to . For the specific logic, let's look at the notes 3.

  • notes 3: We went to ScopeUpdateScope.updateScope()  Registered a listener , When our Greeting() When a function needs to be reorganized , This monitoring will be triggered , So as to recursively call itself . At this time you will find , aforementioned RestartGroup It also implies 「 restructuring 」 The mean of .

thus it can be seen ,Compose Among them, it looks very tall 「Recomposition」, In fact, that is : " Call the function again "  nothing more .

that ,Greeting() Under what circumstances will it trigger 「 restructuring 」 Well ? Let's take a more complete example .

//  Code segment 7


class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}


@Composable
fun MainScreen() {
    log("MainScreen start")
    val state = remember { mutableStateOf("Init") }
    // 1
    LaunchedEffect(key1 = Unit) {
        delay(1000L)
        state.value = "Modified"
    }
    Greeting(state.value)
    log("MainScreen end")
}


private fun log(any: Any) {
    Log.d("MainActivity", any.toString())
}


@Composable
fun Greeting(msg: String) {
    log("Greeting start $msg")
    Text(text = "Hello $msg!")
    log("Greeting end $msg")
}


/*  Output results 
MainActivity: MainScreen start
MainActivity: Greeting start Init
MainActivity: Greeting end Init
MainActivity: MainScreen end
 wait for  1 second 
MainActivity: MainScreen start        //  restructuring 
MainActivity: Greeting start Modified //  restructuring 
MainActivity: Greeting end Modified   //  restructuring 
MainActivity: MainScreen end          //  restructuring 
*/

The above code logic is still very simple ,setContent {} Called MainScreen();MainScreen() Called Greeting(). The only thing that needs attention , It's a comment 1 Situated LaunchedEffect{}, Its function is to start a process , Delay 1 second , Also on state Assign a value .

Output from the code log , We can see , front 4 Log output , yes Compose Triggered by initial execution ; Back 4 Log output , It is from state Change leads to 「 restructuring 」. look ,Compose By some mechanism , Caught state Change of state , And then notified MainScreen() Has been restructured .

If you are careful enough , You will find ,state In fact, only Greeting() Yes , and state Changes , Has led to MainScreen()、Greeting() It all happened 「 restructuring 」,MainScreen() Of 「 restructuring 」 It seems superfluous . In fact, there is something hidden here Compose A key point of performance optimization .

Be careful : Similar to the above ,Compose Compiler In fact, enough optimizations have been made ,MainScreen() Of 「 restructuring 」 It seems superfluous , But it doesn't really have much impact on performance , We give this example just to make it clear 「 restructuring 」 Principle , Lead to optimization ideas .Compose Compiler Specific optimization ideas , We'll leave it for later analysis .

Let's change the code above : 

//  Code segment 8


class MainActivity : ComponentActivity() {
    //  unchanged 
}


@Composable
fun MainScreen() {
    log("MainScreen start")
    val state = remember { mutableStateOf("Init") }
    LaunchedEffect(key1 = Unit) {
        delay(1000L)
        state.value = "Modified"
    }
    Greeting { state.value } // 1, The change is here 
    log("MainScreen end")
}


private fun log(any: Any) {
    Log.d("MainActivity", any.toString())
}


@Composable   // 2, The change is here  ↓
fun Greeting(msgProvider: () -> String) {
    log("Greeting start ${msgProvider()}") // 3, change 
    Text(text = "Hello ${msgProvider()}!") // 3, change 
    log("Greeting end ${msgProvider()}")   // 3, change 
}


/*
MainActivity: MainScreen start
MainActivity: Greeting start Init
MainActivity: Greeting end Init
MainActivity: MainScreen end
 wait for  1 second 
MainActivity: Greeting start Modified  //  restructuring 
MainActivity: Greeting end Modified    //  restructuring 
*/

I marked the code changes with comments , The main changes are :  notes 2, We put the original String Change the parameter of type to function type : () -> String. notes 1、3 There are two changes , All follow the notes 2 Of .

Note the log output of the code , This time, ,「 restructuring 」 The scope of has changed ,MainScreen() No restructuring has occurred ! Why is that ? There are two knowledge points involved here :  One is Kotlin In functional programming 「Laziness」; The other is Compose Restructured 「 Scope 」. Let's see one by one .

Laziness 

Laziness  It's a big topic in functional programming , To make this concept clear , I have to write several articles , Here I'll briefly explain , We'll have a further discussion later .

understand Laziness The most intuitive way , Is to write a piece of code for this comparison : 

//  Code segment 9


fun main() {
    val value = 1 + 2
    val lambda: () -> Int = { 1 + 2 }
    println(value)
    println(lambda)
    println(lambda())
}

Actually , If you are right about Kotlin Higher order function 、Lambda Well understood words , You can immediately understand the code snippet 8 In the middle of Laziness What do you mean .

The output of the above code is as follows : 

3
Function0<java.lang.Integer>
3

This output is also well understood .1 + 2 It's an expression , When we use it {}  After wrapping , To a certain extent, it achieves Laziness, We visit lambda When It does not trigger the actual calculation behavior . Only a call lambda() When , Will trigger the actual calculation behavior .

Laziness Make it clear , Let's see Compose The reorganization of 「 Scope 」.

restructuring 「 Scope 」

Actually , In the previous code snippet 6 It's about , We have already touched it , That is to say ScopeUpdateScope. Through the previous analysis , Each of us Composable function , In fact, they all correspond to one ScopeUpdateScope,Compiler The bottom layer is monitoring through injection , To achieve 「 restructuring 」 Of .

actually ,Compose The bottom layer also provides a :  State snapshot system  (SnapShot).Compose The underlying principle of the snapshot system is still complex , We'll have a chance to discuss it further in the future .

in general ,SnapShot Can monitor Compose among State The reading of 、 Writing behavior .

//  Code segment 10


@Stable
interface MutableState<T> : State<T> {
    override var value: T
}


internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {


    override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent {
            if (!policy.equivalent(it.value, value)) {
                next.overwritable(this, it) { this.value = value }
            }
        }
}

Essentially , It is actually through customization Getter、Setter To achieve . When we define state Variable , Its value is from "Init" Turn into "Modified" When ,Compose Can be customized by Setter Capture this behavior , To call ScopeUpdateScope Listening in the middle , Trigger 「 restructuring 」.

that , Code segment 7、 Code segment 8, What is the difference between them ? The key lies in ScopeUpdateScope Different .

The connection , In fact, it can be summed up in one sentence :  Where does the state read take place Scope, When the status is updated , Which one? Scope A reorganization occurs .

It doesn't matter if you don't understand this sentence , I drew a picture , Describes the code snippet 7、 Code segment 8 Differences between :

37a7bf5e714354c01b8ee758eb640ba1.png

For code snippets 7, When state The read of occurs at MainScreen() Of ScopeUpdateScope, that , When state When things change , It will trigger MainScreen() Of Scope Conduct 「 restructuring 」.

Code segment 8 It's the same thing : 

672ac6f4b36efe0c7c4587e163425fa1.png

Now? , Looking back at this sentence , I believe you can understand :  Where does the state read take place Scope, When the status is updated , Which one? Scope A reorganization occurs .

good , After all the foreshadowing , We can easily understand Android Three of the official performance optimization suggestions are .

1. Defer reads as long as possible
2. Use derivedStateOf to limit recompositions
3. Avoid backwards writes

Above this 3 Piece of advice , In essence, it is to avoid 「 restructuring 」, Or shrink 「 Restructuring scope 」. Due to space limitation , Let's pick the first one to explain in detail ~

14a80bfb90461e585d591c83ad3d0db6.png

Delay as much as possible State The act of reading

Actually , For our code snippet 7、 Code segment 8 Such a change ,Compose The performance improvement is not obvious , because Compiler The bottom layer has been optimized enough , One more level of function calls , There will be no obvious difference .Android Officials even suggest that we delay the reading and writing of certain states until Layout、Draw Stage .

It's like Compose The whole execution 、 Rendering process related . in general , For one Compose Page for , It will experience the following 4 A step : 

  • First step ,Composition, This actually represents our Composable Function execution process .

  • The second step ,Layout, It's not about us View Systematic Layout similar , But there are some differences in the overall distribution process .

  • The third step ,Draw, That is to draw ,Compose Of UI The element will eventually be drawn in Android Of Canvas On . thus it can be seen ,Jetpack Compose Although it's brand new UI frame , But its bottom layer is not separated Android The category of .

  • Step four ,Recomposition, restructuring , And repeat 1、2、3 step .

The overall process is shown in the figure below :

bd88a6e5609a7ad63d04843293e4b217.png

Android The official recommendation is that we delay the status reading as much as possible , In fact, I hope we can skip directly in some scenarios Recomposition The stage of 、 even to the extent that Layout The stage of , Only affect Draw.

And the means to achieve this goal , In fact, it is what we mentioned earlier 「Laziness」 thought . Let's take the official code as an example : 

27fd247cc7fc91ec4d4ef24e1ceeaa2f.png

First , What I want to say is ,Android The comments in the official documents actually have a Small flaws Of . It's friendly to beginners , But it is easy to trouble those of us who go deep into the bottom . As described in the code above Recomposition Scope Inaccurate , It's really Recomposition Scope, It should be the whole SnackDetail(), instead of Box(). Regarding this , I am already in Twitter Related to Google The engineer gave feedback , The other party also replied to me , This is a " intentionally "  Of , Because it's easier to understand .

good , Let's get back to the point , Analyze this case in detail : 

//  Code segment 11


@Composable
fun SnackDetail() {
    // Recomposition Scope
    // ...


    Box(Modifier.fillMaxSize()) {  Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value) // 1, State read 
        // ...
    }
// Recomposition Scope End
}


@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }


    Column(
        modifier = Modifier
            .offset(y = offset) // 2, State use 
    ) {
        // ...
    }
}

The above code has two comments , notes 1, Represents the reading of the state ; notes 2, Represents the use of states . such " The state reading is inconsistent with the use position "  The phenomenon of , In fact, for Compose Provides space for performance optimization .

that , How should we optimize it ? It's very simple , With the help of our previous Laziness Thought , Give Way : " The state reading is consistent with the use position ".

//  Code segment 12


@Composable
fun SnackDetail() {
    // Recomposition Scope
    // ...


    Box(Modifier.fillMaxSize()) { Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value } // 1,Laziness
        // ...
    }
    // Recomposition Scope End
}


@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset) // 2, State read + Use 
    ) {
    // ...
    }
}

Please pay attention. notes 1  The changes here , Because we will scroll.value Turned into Lambda, therefore , It will not be in composition State read behavior generated during , such , When scroll.value When it changes , It won't trigger 「 restructuring 」, This is it. 「Laziness」 The meaning of .

Code segment 11、 Code segment 12 The difference is huge :

e38099a8cff48b964174c4d360d0e294.png

The former is triggered frequently during page sliding : 「 restructuring 」+「Layout」+「Draw」, The latter is completely bypassed 「 restructuring 」, Only 「Layout」+「Draw」, thus it can be seen , Its performance improvement is also very significant .

2632fa332f683fd95c68f0f75b74cbb6.png

ending

OK, Come here , This article should be over . Let's summarize briefly : 

  • First of all ,Composable The essence of , It's actually a function . Multiple Composable After functions are nested with each other , It naturally forms a UI Trees .Composable Function execution process , It's really just a DFS Ergodic process .

  • second ,@Composable Modified function , Will eventually be Compose Compiler plug-in modification , Not only does its function signature change , The logic of its function body will also change dramatically . Changes in function signature , As a result, ordinary functions cannot be called directly Composable function ; Changes in the body of a function , To better describe   Compose Of UI structure , And realize 「 restructuring 」.

  • Third , restructuring , In essence, it means to be Compose When the state changes ,Runtime Yes Composable Functional Repeated calls to . This involves Compose Snapshot system of , also ScopeUpdateScope.

  • Fourth , because ScopeUpdateScope It's up to us to State Read location of , therefore , This determines that we can use Kotlin In functional programming Laziness thought , Yes Compose Conduct 「 performance optimization 」. Also is to let :  The state reading is consistent with the use position , Minimize as much as possible 「 Reorganize scope 」, Try to avoid 「 restructuring 」 happen .

  • The fifth , This year's Google I/O At the conference ,Android The official team proposed : 5 Best practices for performance optimization , among 3 The essence of this recommendation , They are all practicing :  The state reading is consistent with the use position Principles .

  • The sixth , We analyzed one of the suggestions in detail 「 Delay as much as possible State The act of reading 」. because Compose The implementation process of is divided into :「Composition」、「Layout」、「Draw」, adopt Laziness, We can get Compose skip 「 restructuring 」 The stage of , Greatly enhance Compose Performance of .

6396a7fa7fbc133804b9e33742227b6f.png

Conclusion

Actually ,Compose The principle of is quite complicated . In addition to UI Layer heel Android There is a strong correlation , Other parts Compiler、Runtime、Snapshot Can be independent of Android Exist outside . That's why JetBrains Can be based on Jetpack Compose build Compose-jb Why .

b4c70b87f08c4b767fb799e3120a43c1.png


Long press the QR code on the right

See more developers share

3154f83a8bb17c954291b8100fded88a.png

" The developer said ·DTalk" oriented 24a8cf2f38ca8de5180f1de5ed731d60.png Chinese developers solicit Google Mobile application (apps & games)  Related products / Technical content . Welcome to share your insights or insights on the mobile application industry 、 Experience or new discovery in the process of mobile development 、 As well as the application of the sea experience summary and the use of related products feedback . We sincerely hope to provide these outstanding Chinese developers with better opportunities to show themselves 、 A platform to give full play to one's strong points . We will focus on selecting excellent cases for Google development through your technical content (GDE)  The recommendation of .

4038e1d1a0a52c85fdb6ed1d221669eb.gif  Click at the end of the screen  |  Read the original  |  Sign up now  " The developer said ·DTalk" 


f20a623dd6600847058157567bc1e729.png

原网站

版权声明
本文为[Android Developer]所创,转载请带上原文链接,感谢
https://yzsam.com/2022/162/202206111546491042.html