Kotlin-----Coroutine

Was ist Coroutine?

Für die Coroutine von Kotlin in Android handelt es sich bei der Coroutine um eine Reihe von Thread-Switching-APIs, die offiziell von Kotlin bereitgestellt werden. Sie haben die gleiche Funktion wie der Thread-Pool, ihr Vorteil besteht jedoch darin, dass sie asynchronen Code synchron schreibt. . Da Coroutinen auf JVM ausgeführt werden, verfügt JVM nicht über das Konzept von Coroutinen.

Wenn Kotlins Coroutine im aktuellen Thread ausgeführt wird, entspricht dies dem Posten einer Aufgabe in der Handler-Warteschlange des aktuellen Threads, was ebenfalls zu einer Blockierung führt. Denken Sie daran, dass es auch zu einer Blockierung kommen kann, wenn die Coroutine den Thread nicht wechselt. Ich werde dies später überprüfen .

Okay, jetzt beginnen Sie mit der Verwendung

Wenn Coroutine verwendet wird

In Kotlin sind Coroutinen nicht in der Standardbibliothek enthalten, Sie müssen sie also bei der Verwendung noch einführen:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1"

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1"

Die zweite Bibliothek wird von der Android-Umgebung verwendet.

Zwei Möglichkeiten, eine Coroutine zu starten:

  launch {  }
             
   async {  }

Schauen wir uns diese beiden Methoden an

Start

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

asynchron

public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T> {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyDeferredCoroutine(newContext, block) else
        DeferredCoroutine<T>(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

Es wurde festgestellt, dass diese beiden Methoden Erweiterungsfunktionen der Klasse CoroutineScope (Coroutine Scope) sind, sodass diese beiden Funktionen mit dem Coroutine Scope-Objekt aufgerufen werden müssen.

Der Unterschied besteht darin, dass Lanch das laufende Ergebnis nicht zurückgibt, während Async das Deferred-Objekt überawait() des Deferred-Objekts zurückgibt.

Die Methode kann die Ausführungsergebnisse der Coroutine abrufen.

Es gibt noch einen

withContext(Dispatchers.Main){ 
    
}

Das ist er tatsächlich

async { 

}.await()

Abkürzung, daher wird die Coroutine blockiert, da die Methode „await()“ eine suspendierende Funktion ist

Erklären Sie einige Begriffe:

CoroutineScope: Coroutine-Bereich

public interface CoroutineScope {
    /**
     * The context of this scope.
     * Context is encapsulated by the scope and used for implementation of coroutine builders that are extensions on the scope.
     * Accessing this property in general code is not recommended for any purposes except accessing the [Job] instance for advanced usages.
     *
     * By convention, should contain an instance of a [job][Job] to enforce structured concurrency.
     */
    public val coroutineContext: CoroutineContext
}

Es gibt nur ein Attribut im Coroutine-Bereich, nämlich den Coroutine-Kontext, was bedeutet, dass coroutineScope eine Kapselung von CoroutineContext ist . Es bezieht sich auf den laufenden Bereich (oder Lebenszyklus) einer Coroutine, der von Benutzern verwendet wird, um die Coroutine zu erben und abzubrechen und Vorgänge zu stoppen. Ich kann mich beherrschen

 CoroutineContext:

Unter dem Kontext einer Coroutine versteht man die Konfigurationsinformationen der laufenden Coroutine, die zur Ausführung der Coroutine erforderliche Konfiguration, z. B. den Namen der Coroutine, und den Thread, in dem die Coroutine ausgeführt wird.

Lassen Sie mich einen Blick auf die Klasse coroutineContext werfen. Sie entspricht tatsächlich einem Container, der verschiedene Attribute speichert, ähnlich dem Hash-Speicher.

public interface CoroutineContext {

    //重写了+号运算符
    public operator fun plus(context: CoroutineContext): CoroutineContext =
        if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation
            context.fold(this) { acc, element ->
                val removed = acc.minusKey(element.key)
                if (removed === EmptyCoroutineContext) element else {
                    // make sure interceptor is always last in the context (and thus is fast to get when present)
                    val interceptor = removed[ContinuationInterceptor]
                    if (interceptor == null) CombinedContext(removed, element) else {
                        val left = removed.minusKey(ContinuationInterceptor)
                        if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
                            CombinedContext(CombinedContext(left, element), interceptor)
                    }
                }
            }

    public interface Key<E : Element>

        public val key: Key<*>

        public override operator fun <E : Element> get(key: Key<E>): E? =
            @Suppress("UNCHECKED_CAST")
            if (this.key == key) this as E else null

        public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)

        public override fun minusKey(key: Key<*>): CoroutineContext =
            if (this.key == key) EmptyCoroutineContext else this
    }
}

Er kann es also so nutzen

Schauen wir uns die Startfunktion an. Wir können einen CoroutineContext übergeben

 Wir können es so verwenden

launch(Dispatchers.Main+CoroutineName("协程名称")) {  }

CoroutineDispatcher   Coroutine-Scheduler

Kotlin stellt uns vier Scheduler zur Verfügung

public actual object Dispatchers {
public actual val Default: CoroutineDispatcher = createDefaultDispatcher()
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined
public val IO: CoroutineDispatcher = DefaultScheduler.IO
}

Standard: Der Standardplaner, ein CPU-intensiver Aufgabenplaner, erledigt normalerweise einige einfache Rechenaufgaben oder Aufgaben mit kurzer Ausführungszeit. Zum Beispiel Datenberechnung
IO: IO-Scheduler, IO-intensiver Aufgabenplaner, geeignet für die Durchführung von IO-bezogenen Vorgängen. Zum Beispiel: Netzwerkanforderungen, Datenbankoperationen, Dateioperationen usw.
Main: UI-Planer, nur auf der UI-Programmierplattform von Bedeutung, wird zum Aktualisieren der Benutzeroberfläche verwendet, z. B. der Hauptthread in Android. Unbegrenzt:
uneingeschränkter Scheduler, derzeit kein Scheduler Coroutinen können in jedem Thread ausgeführt werden

CoroutineStart: Startmodus von Coroutine

public enum class CoroutineStart {
DEFAULT,
LAZY,
ATOMIC,
UNDISPATCHED;
}

Sie können sehen, dass CoroutineStart eine Aufzählungsklasse mit vier Typen ist.
DEFAULT ist der Standard-Startmodus. Die Planung beginnt unmittelbar nach der Erstellung der Coroutine. Beachten Sie, dass sie sofort geplant und nicht sofort ausgeführt wird. Sie kann vor der Ausführung abgebrochen werden.
Der LAZY-Lazy-Startup-Modus hat nach der Erstellung kein Planungsverhalten. Die Planung erfolgt erst, wenn wir sie zur Ausführung benötigen. Die Planung beginnt nur, wenn wir die Start-, Join- oder Wait-Funktionen des Jobs manuell aufrufen müssen.
ATOMIC beginnt unmittelbar nach der Erstellung der Coroutine mit der Planung, unterscheidet sich jedoch vom Standardmodus. In diesem Modus muss die Coroutine nach dem Start bis zum ersten Unterbrechungspunkt ausgeführt werden, bevor sie auf den Abbruchvorgang reagiert.
In diesem Modus beginnt die UNDISPATCHED-Coroutine direkt mit der Ausführung im aktuellen Thread, bis sie den ersten Unterbrechungspunkt erreicht. Es ist ATOMIC sehr ähnlich, aber UNDISPATCHED wird stark vom Scheduler beeinflusst.

Arbeit

Die Methode launch() gibt ein Jobobjekt zurück, das den Lebenszyklus der Coroutine erkennen und die Ausführung der Coroutine abbrechen kann.
public interface Job : CoroutineContext.Element {
  
    public companion object Key : CoroutineContext.Key<Job>

    //判断协程是否是活的
    public val isActive: Boolean

    
   //判断协程是否完成
    public val isCompleted: Boolean

   //判断协程是否取消
    public val isCancelled: Boolean



    public fun getCancellationException(): CancellationException

    //开始协程
    public fun start(): Boolean


    //取消协程
    public fun cancel(cause: CancellationException? = null)


   

Aufgeschoben erbt tatsächlich den Job

Daher gibt es auch Methoden zum Betreiben der Coroutine, die auch eine wichtige Methode, die Methode „await()“, bereitstellt, um den von der Coroutine zurückgegebenen Wert zu erhalten. „await“ ist eine Unterbrechungsfunktion. Wenn sie hier ausgeführt wird, wird die aktuelle Coroutine blockiert . .

public interface Deferred<out T> : Job {

    public suspend fun await(): T

suspend bedeutet auf Chinesisch suspendieren

Die Suspend-Funktion blockiert die aktuelle Coroutine, jedoch nicht den Thread, der die Coroutine gestartet hat.

Wenn vor einer Funktion ein Suspend deklariert wird, wird die Funktion zu einer Suspending-Funktion. Die Suspending-Funktion kann nur in der Suspending-Funktion aufgerufen werden, dies dient jedoch nur als Erinnerung. Was wirklich die Rolle des Suspendings spielt, ist der Code in der Funktion . Wenn Sie also eine Suspend-Funktion deklarieren, diese jedoch keine tatsächliche Suspend-Operation enthält, wird sie vom Compiler ausgegraut, was bedeutet, dass diese Funktion nicht deklariert werden muss. Welchen Nutzen hat diese Erklärung? Es wird vom Ersteller verwendet, um den Anrufer daran zu erinnern, dass es sich bei dieser Funktion um eine hängende Funktion handelt, die möglicherweise zeitaufwändig ist.

Was die Suspend-Funktion bewirkt:

Wenn die Coroutine die angehaltene Funktion erreicht, pausiert sie hier. Sie wechselt, um die angehaltene Funktion auszuführen. Nach dem Ausführen der angehaltenen Funktion wechselt sie zurück und führt sie erneut aus. Tatsächlich handelt es sich um einen Rückruf- und Zustandsmaschinenmechanismus.

 Wenn Sie eine vom System bereitgestellte echte Suspend-Funktion hinzufügen, ist dies in Ordnung.

Die Beziehung zwischen übergeordneter Coroutine und untergeordneter Coroutine

1. Die Coroutine-Kontextinformationen in der untergeordneten Coroutine werden in die übergeordnete Coroutine kopiert, sofern Sie sie nicht selbst ändern.

2. Nachdem die übergeordnete Coroutine beendet ist, werden alle darin enthaltenen untergeordneten Coroutinen beendet.

Beweisen Sie den ersten Punkt:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
         GlobalScope.launch(Dispatchers.Main+CoroutineName("父协程名字")) {
             Log.e("---","协程上下文-1=${this.coroutineContext}")
             launch {
                 Log.e("---","协程上下文-2=${this.coroutineContext}")
             }
             withContext(Dispatchers.IO+CoroutineName("子协程")){
                 Log.e("---","协程上下文-2=${this.coroutineContext}")
             }
         }
    }

Gedruckte Ergebnisse:

E/---: Coroutine context-1=[CoroutineName(parent coroutine name), StandaloneCoroutine{Active}@d2a3906, Dispatchers.Main]
E/---: Coroutine context-3=[CoroutineName(child coroutine ), DispatchedCoroutine{ Active}@8fe4bc7, Dispatchers.IO]
E/---: Coroutine context-2=[CoroutineName (Name der übergeordneten Coroutine), StandaloneCoroutine{Active}@24715f4, Dispatchers.Main]

 Wir haben festgestellt, dass die Konfigurationsinformationen des Coroutine-Kontexts von Coroutine 2 und der übergeordneten Coroutine gleich sind, Coroutine 3 jedoch unterschiedlich ist, was darauf hinweist, dass die untergeordnete Coroutine die Coroutine-Konfigurationsinformationen der übergeordneten Coroutine erbt.

 CoroutineExceptionHandler Coroutine-Ausnahmebehandlung

Wenn wir Code schreiben, werden wir auf jeden Fall auf ungewöhnliche Situationen stoßen. Normalerweise verwenden wir try...catch, um Ausnahmen zu behandeln, aber es ist unvermeidlich, dass Auslassungen auftreten. CoroutineExceptionHandler ist eine Klasse, die auf das Abfangen von Ausnahmen in Coroutinen spezialisiert ist. Ausnahmen, die in Coroutinen auftreten, werden abgefangen und von der handleException-Methode von CoroutineExceptionHandler zur Verarbeitung an uns zurückgegeben.

public interface CoroutineExceptionHandler : CoroutineContext.Element {
    public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>

    public fun handleException(context: CoroutineContext, exception: Throwable)
}

handleException gibt zwei Parameter zurück. Der erste Parameter ist die Coroutine, in der die Ausnahme aufgetreten ist, und der zweite Parameter ist die aufgetretene Ausnahme.
Das Beispiel sieht wie folgt aus: Wir lösen manuell eine NullPointerException-Ausnahme aus, erstellen dann einen CoroutineExceptionHandler und weisen ihn der Coroutine zu.

val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
    Log.e("捕获异常", "${coroutineContext[CoroutineName]} :$throwable")
}

GlobalScope.launch(Dispatchers.Main + CoroutineName("主协程")+exceptionHandler) {
    Log.e("协程的coroutineContext",this.coroutineContext.toString())
    throw NullPointerException()
}
打印结果:
协程的coroutineContext: [CoroutineName(主协程), com.jf.simple.ThirdActivity$onCreate$$inlined$CoroutineExceptionHandler$1@288ff9, StandaloneCoroutine{Active}@b95b3e, Dispatchers.Main]

捕获异常: CoroutineName(主协程) :java.lang.NullPointerException


 

Problemanalyse

1. Wenn die laufende Coroutine im aktuellen Thread ausgeführt wird, der die Coroutine startet, ist dies gleichbedeutend mit dem Posten einer Aufgabe im Handler-Task-Stack des Threads?

Testcode:

    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
         GlobalScope.launch(Dispatchers.Main+CoroutineName("父协程名字")) {
              Log.e("---","111")
         }
        Log.e("---","222")
    }

Drucken Sie das Ergebnis aus:

                com.example.mytest2 E 222
                com.example.mytest2 E 111

Das Druckergebnis 222 steht vor 111 und zeigt an, dass tatsächlich eine Aufgabe gepostet wurde.

So erstellen Sie einen Coroutine-Bereich

Wir wissen, dass es zwei Möglichkeiten gibt, eine Coroutine zu starten. Tatsächlich gibt es auch eine Art runBlocking, aber diese Art von gestarteter Coroutine blockiert den aktuellen Thread. Erst wenn die Coroutine endet, wird der Thread, der die Coroutine gestartet hat, beendet. Dies führt zu einem Speicherverlust. Wird in allgemeinen Projekten nicht verwendet.

Start()

async()

Sie gehören zu den Erweiterungsfunktionen von CoroutineScope und müssen daher über ein CoroutineScope-Objekt verfügen, um sie aufrufen zu können.

Methode 1:

GlobalScope ist ein Singleton des Coroutine-Bereichs und kann daher direkt verwendet werden
@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
    /**
     * Returns [EmptyCoroutineContext].
     */
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}
GlobalScope.launch() { 
}

Methode 2:

class MainActivity : AppCompatActivity() {
    lateinit var coroutine:CoroutineScope

    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
       coroutine=  CoroutineScope(Dispatchers.IO)  //创建一个协程作用域
        coroutine.launch { 
            Log.e("---","开始了协程")
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        coroutine.cancel() //协程取消
    }

}

Hier gibt es jedoch ein Problem: Wenn ich viele Coroutine-Bereiche öffnen muss, muss ich dann viele Abbrechen aufrufen?

Wie folgt:

package com.example.mytest2

import android.annotation.SuppressLint
import android.content.Intent
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.provider.Settings.Global
import android.util.Log
import android.view.View
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
import kotlin.math.log
import kotlin.system.measureTimeMillis
import kotlin.time.measureTimedValue

class MainActivity : AppCompatActivity() {
    lateinit var coroutine:CoroutineScope
    lateinit var coroutine1:CoroutineScope

    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
       coroutine=  CoroutineScope(Dispatchers.IO)
        coroutine.launch {
            Log.e("---","开始了协程")
        }

        coroutine1=  CoroutineScope(Dispatchers.IO)
        coroutine1.launch {
            Log.e("---","开始了协程")
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        coroutine.cancel()
        coroutine1.cancel()
    }

}

Optimierung: Wir wissen, dass Job Coroutinen verwaltet. Wie verwaltet CoroutineScope Coroutinen?

öffentlicher Spaß CoroutineScope.cancel(cause: CancellationException? = null) { 
    val job = coroutineContext[Job] ?: error("Scope kann nicht abgebrochen werden, da es keinen Job hat: $this") 
    job.cancel(cause) 
}

Es stellte sich heraus, dass er tatsächlich den Abbruch des Jobs aufgerufen hatte, also erstellten wir einen Job und ließen ein Jobobjekt den Abbruch mehrerer Coroutinen verwalten.



class MainActivity : AppCompatActivity() {
    val job=Job()
    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
      
        CoroutineScope(Dispatchers.IO+job).launch {
            Log.e("---","开始了协程")
        }

         CoroutineScope(Dispatchers.IO+job).launch {
            Log.e("---","开始了协程")
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }

}

 Beachten Sie das oben Gesagte

CoroutineScope(Dispatchers.IO+job) ist eine Methode. Es erstellt kein Objekt, aber eine Methode gibt ein Objekt zurück.
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())

Methode 3

Implementieren Sie die CoroutineScope- Schnittstelle. Da die CoroutineScope- Schnittstelle eine nicht implementierte Eigenschaft enthält , muss der Erbe diese Eigenschaft initialisieren. Wir können sie mithilfe einer Delegate-Klasse implementieren.

public interface CoroutineScope {

    public val coroutineContext: CoroutineContext
}
实现CoroutineScope 的接口,协程的上下文让MainScope()方法创建的类实现实现
class MainActivity : AppCompatActivity() ,CoroutineScope by MainScope() {
    @SuppressLint("MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        launch {   //这个协程是运行在主线程的,因为mainScope创建的协程上下文中的线程是主线程
            
        }
    }
    override fun onDestroy() {
        super.onDestroy()
        cancel()
    }
}

Werfen wir einen Blick auf die MainScope()-Methode.

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
注意这个方法创建的协程上下文的指定线程是主线程,所以说如果子协程不指定线程的话,子协程也是在主线程中运行

Weg 4:

Wenn wir die Coroutine selbst verwalten, ist dies problematisch und manchmal vergessen wir, sie abzubrechen, was leicht zu Speicherverlusten führen kann. Daher stellt uns Android einige Bibliotheken zur Verfügung.

//第四种: android提供的和lifecycle相关的库
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
lifecycleScope.launch {  } 用在activity和fragment中

viewModelScope.launch{} 用在viewModel中
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
Offizielle Website-Adresse

Analyse der Prinzipien von Coroutinen

Das nicht blockierende Prinzip von Coroutinen wird tatsächlich mithilfe des Callback + State Machine-Mechanismus implementiert.

Referenzartikel:

Weiterentwicklung der Kotlin-Syntax - Coroutine (2) Prinzip der Suspendierungsfunktion_Fliegen Sie zu diesem Zeitpunkt über den Blog der Stadt - CSDN-Blog

suspend: bedeutet suspendieren
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_coroutine)
        val launch = GlobalScope.launch(Dispatchers.Main) {   //实质上相当于往主线程中post了一个新任务,这个任务就是{}闭包的内容

            println("thread name= ${Thread.currentThread().name}")
            println("======12")
            delay(500)
            println("======23")
        }
        println("--end--")
    }


Werfen wir einen Blick auf die Startfunktion:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit   //执行的也是也是我们传入好的闭包,只是给它改成了挂起函数
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

Analysieren Sie diesen Code:

class CoroutineActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_coroutine)

        
        运行到挂起函数的时候,相当于线程执行到这里切断了,一部分去执行挂起函数,一部分去按顺序去执行
        val launch = GlobalScope.launch(Dispatchers.Main) {  //挂起函数
            println("thread name= ${Thread.currentThread().name}")
            println("======12")
            delay(500)
            println("======23")
        }
        println("--end--")  //接下来的内容
    }
}

Es ist zu beachten, dass das Schlüsselwort suspend nur eine Eingabeaufforderung ist und keine suspendierende Rolle spielen kann. Zu den suspendierenden Methoden gehören withcontent (), launch usw.

[Mashang School Starts] Die Aussetzung der Kotlin-Coroutinen ist so magisch und schwer zu verstehen? Heute habe ich den Code „skin_bilibili_bilibili“ abgezogen und beginnt mit der Schule (kaixue.io): Kotlin-Coroutine-Ausgabe 2. Wenn Sie nach dem Lesen noch irgendwelche Gedanken haben, hinterlassen Sie bitte eine Nachricht zur Diskussion! https://www.bilibili.com/video/BV1KJ41137E9?spm_id_from=333.999.0.0

Ich denke du magst

Origin blog.csdn.net/xueyoubangbang/article/details/123095882
Empfohlen
Rangfolge