Sept questions d'entrevue Android, commençons par un simple échauffement

Commencez le voyage de croissance des pépites ! C'est le 3ème jour de ma participation au "Nuggets Daily New Project · February Update Challenge", cliquez pour voir les détails de l'événement

Ce sera la haute saison du recrutement (tiao) et de l'embauche (cao) or 3 argent 4. Groupe après groupe d'élites sociales à la recherche de leur prochaine famille et en même temps, ils commencent à se préparer pour l'entretien. ces années, j'ai aussi vécu de nombreuses interviews, petites et grandes, certains ont été interviewés, et certains ont été intervieweurs. J'ai aussi résumé deux points de vue. L'un est que ce n'est vraiment pas bon si vous ne dépensez pas un certain temps à mémoriser des dissertations stéréotypées. Écoutez, ils font tous du mal aux gens. L'autre passe des entretiens pour construire des fusées. Après tout, il n'y a que quelques personnes qui se vissent en entrant dans un emploi. Les choses qui sont vraiment demandées dans un entretien qualifié sont ceux qui seront rencontrés au cours du processus de développement proprement dit. J'en dirai quelques-uns ci-dessous. Questions d'entretien que j'ai rencontrées

Pourquoi ArrayMap est plus adapté au développement Android que HashMap

Nous sommes généralement habitués à utiliser HashMap pour stocker des données telles que des équipes clé-valeur dans des projets, donc HashMap est souvent une partie incontournable des entretiens Android, mais je me souviens qu'on m'a demandé dans une interview si j'avais déjà eu un ArrayMap. dis seulement que j'ai une impression , Après tout, HashMap est le plus utilisé, puis l'intervieweur m'a demandé si ArrayMap ou HashMap est plus adapté pour Android. Compris, maintenant il suffit de comparer les caractéristiques de ArrayMap et HashMap

HashMap

  • La structure de données de HashMap est la structure d'un tableau plus une liste chaînée. Après jdk1.8, elle est remplacée par la structure d'un tableau plus une liste chaînée plus un arbre rouge-noir.
  • Lors de la mise, il calculera d'abord le hashcode de la clé, puis ira dans le tableau pour trouver l'indice du hashcode. Si les données sont vides, redimensionnez-les d'abord, puis vérifiez la valeur d'indice correspondante (subscript value=(array longueur-1)&hashcode) Qu'il soit vide, s'il est vide, une entrée sera générée pour l'insertion, sinon, il sera jugé si les valeurs de hascode et de clé sont égales, si elles sont égales, ce sera écrasé, sinon, un conflit de hachage se produira et une nouvelle entrée sera générée et insérée derrière la liste chaînée, si à ce moment Si la longueur de la liste chaînée est supérieure à 8 et la longueur du tableau est supérieure à 64 , convertissez-le d'abord en arbre et ajoutez l'entrée à l'arbre
  • Lors de l'obtention, il vérifie également d'abord si la valeur d'indice correspondant au tableau est vide. S'il n'est pas vide et que la clé et le hascode sont égaux, la valeur est renvoyée directement. Sinon, il est jugé si le nœud est un nœud d'arbre , et elle est renvoyée dans l'arborescence. Correspondant à l'entrée, sinon, parcourez toute la liste chaînée pour trouver l'entrée avec la même valeur de clé et retournez

ArrayMap

  • Maintenir en interne deux tableaux, l'un est un tableau de type int (mHashes) pour enregistrer le hashcode de la clé, et l'autre est un tableau d'objets (mArray), qui est utilisé pour enregistrer la valeur-clé correspondant aux mHashes
  • Lorsque vous placez des données, utilisez d'abord la méthode de recherche binaire pour trouver l'indice d'indice dans mHashes pour stocker le hashcode, et stockez la clé et la valeur dans les positions correspondant à l'indice d'indice<<1 et (index<<1)+1 dans mArray
  • Lors de l'obtention de données, la méthode de recherche binaire est également utilisée pour trouver l'indice d'indice correspondant à la valeur de la clé, puis la valeur est extraite de la position (index<<1)+1 de mArray

Par rapport

  • Lorsque HashMap stocke des données, quelle que soit la quantité qu'il stocke, il génère d'abord un objet Entry, ce qui est un gaspillage d'espace mémoire, tandis qu'ArrayMap insère simplement des données dans le tableau sans générer de nouveaux objets.
  • Lors du stockage d'une grande quantité de données, les performances d'ArrayMap ne sont pas aussi bonnes que celles de HashMap, car ArrayMap utilise la méthode de recherche binaire pour trouver des indices. Lorsqu'il y a trop de valeurs d'indices dans les données, cela prendra beaucoup de temps. temps pour les trouver. De plus, toutes les données doivent être reculées Ensuite, insérez les données, et HashMap n'a besoin d'être inséré que derrière la liste ou l'arborescence chaînée

C'est pourquoi, en l'absence d'une telle quantité de données requises, Android est plus adapté à ArrayMap du point de vue des performances

Pourquoi ajouter des données dans Arrays.asList signalera une erreur

J'ai posé cette question à beaucoup de gens au début, et il ne manque pas de gros bonnets expérimentés, mais ils ne la comprennent fondamentalement pas. Elle est souvent ignorée par les gens, et si certains problèmes sont ignorés, des problèmes imprévisibles surgiront souvent. Par exemple, certaines personnes aiment utiliser Arrays.asList pour générer une liste

val dataList = Arrays.asList(1,2,3)
dataList.add(4)
复制代码

但是当我们往这个List里面add数据的时候,我们会发现,crash了,看到的日志是

image.png 不被支持的操作,这让首次遇到这样问题的人肯定是一脸懵,List不让添加数据了吗?之前明明可以的啊,但是之前我们创建一个List是这样创建的

image.png 它所在的包是java.util.ArrayList里面,我们看下里面的代码

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
public void add(int index, E element) {
    if (index > size || index < 0)
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));

    ensureCapacityInternal(size + 1);  // Increments modCount!!
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    elementData[index] = element;
    size++;
}
复制代码

是存在add方法的,我们再回头再去看看asList生成的List

image.png 是在java.util.Arrays包里面的,而这里面的ArrayList我们看到了,并没有去实现List接口,所以也就没有add,get等方法,另外在kotlin里面,我们会看到一个细节,当你敲完Arrays.asList的时候,编译器会提示你,可以转换成listof函数,而这个还是我们知道生成的list都是只能读取,不能往里写数据

Thread.sleep(0)到底“睡没睡”

记得在上上家公司,接手的第一个需求就是做一个动画,这个动画需要一个延迟启动的功能,我那个时候想都没想加了个Thread.sleep(3000),后来被领导批了,不可以用Thread.sleep实现延迟功能,那会还不太明白,后来知道了,Thread.sleep(3000)不一定真的暂停三秒,我们来举个例子

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
    Thread.sleep(3000)
    println("end:${System.currentTimeMillis()}")
}).start()
复制代码

我们在主线程先打印一条数据展示时间,然后开启一个子线程,在里面sleep三秒以后在打印一下时间,我们看下结果如何

start:1675665421590
end:1675665424591
复制代码

好像对了又好像没对,为什么是过了3001毫秒才打印出来呢?有的人会说,1毫秒而已,忽略嘛,那我们把上面的代码改下再试试

println("start:${System.currentTimeMillis()}")
Thread(Runnable {
    Thread.sleep(0)
    println("end:${System.currentTimeMillis()}")
}).start()
复制代码

现在sleep了0毫秒,那是不是两条打印日志应该是一样的呢,我们看看结果

start:1675666764475
end:1675666764477
复制代码

这下子给整不会了,明明sleep0毫秒,那么多出来的2毫秒是怎么回事呢?其实在Android操作系统中,每个线程使用cpu资源都是有优先级的,优先级高的才有资格使用,而操作系统则是在一个线程释放cpu资源以后,重新计算所有线程的优先级来重新分配cpu资源,所以sleep真正的意义不是暂停,而是在接下去的时间内不参与cpu的竞争,等到cpu重新分配完资源以后,如果优先级没变,那么继续执行,所以sleep(0)秒的真正含义是触发cpu资源重新分配

View.post为什么可以获取控件的宽高

我们都知道在onCreate里面想要获取一个控件的宽高,如果直接获取是拿不到的

val mWith = bindingView.mainButton.width
val mHeight = bindingView.mainButton.height
println("按钮宽:$mWith,高:$mHeight")
......
按钮宽:0,高:0
复制代码

而如果想要获取宽高,则必须调用View.post的方法

bindingView.mainButton.post {
    val mWith = bindingView.mainButton.width
    val mHeight = bindingView.mainButton.height
    println("按钮宽:$mWith,高:$mHeight")
}
......
按钮宽:979,高:187
复制代码

很神奇,加个post就可以在同样的地方获取控件宽高了,至于为什么呢?我们来分析一下

简单的来说

Activity生命周期,onCreate方法里面视图还在绘制过程中,所以没法直接获取宽高,而在post方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成的时候放在一个等待队列里面,等到视图绘制执行完毕以后再去执行队列里面的线程,所以在post里面也可以获取宽高

复杂的来说

我们首先从View.post方法里面开始看

image.png

这个代码里面的两个框子,说明了post方法做了两件事情,当mAttachInfo不为空的时候,直接让mHandler去执行线程action,当mAttachInfo为空的时候,将线程放在了一个队列里面,从注释里面的第一个单词Postpone就可以知道,这个action是要推迟进行,什么时候进行呢,我们在慢慢看,既然是判断当mAttachInfo不为空才去执行线程,那我们找找什么时候对mAttachInfo赋值,整个View的源码里面只有一处是对mAttachInfo赋值的,那就是在dispatchAttachedToWindow 这个方法里面,我们看下

void dispatchAttachedToWindow(AttachInfo info, int visibility) {
    mAttachInfo = info;
    ...省略部分源码...
  
    // Transfer all pending runnables.
    if (mRunQueue != null) {
        mRunQueue.executeActions(info.mHandler);
        mRunQueue = null;
    }

}
复制代码

当走到dispatchAttachedToWindow这个方法的时候,mAttachInfo才不为空,也就是从这里开始,我们就可以获取控件的宽高等信息了,另外我们顺着这个方法往下看,可以发现,之前的那个队列在这里开始执行了,现在就关键在于,什么时候执行dispatchAttachedToWindow这个方法,这个时候就要去ViewRootIml类里面查看,发现只有一处调用了这个方法,那就是在performTraversals这个方法里面

private void performTraversals() {
        ...省略部分源码...
    host.dispatchAttachedToWindow(mAttachInfo, 0);
        ...省略部分源码...
    // Ask host how big it wants to be
    performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
        ...省略部分源码...
    performLayout(lp, mWidth, mHeight);
        ...省略部分源码...
    performDraw();
}
复制代码

performTraversals这个方法我们就很熟悉了,整个View的绘制流程都在里面,所以只有当mAttachInfo在这个环节赋值了,才可以得到视图的信息

IdleHandler到底有啥用

Handler是面试的时候必问的环节,除了问一下那四大组件之外,有的面试官还会问一下IdleHandler,那IdleHandler到底是什么呢,它是干什么用的呢,我们来看看

Message next() {
...省略部分代码...
    synchronized (this) {
        // If first time idle, then get the number of idlers to run.
        // Idle handles only run if the queue is empty or if the first message
        // in the queue (possibly a barrier) is due to be handled in the future.
        if (pendingIdleHandlerCount < 0
                && (mMessages == null || now < mMessages.when)) {
            pendingIdleHandlerCount = mIdleHandlers.size();
        }
        if (pendingIdleHandlerCount <= 0) {
            // No idle handlers to run.  Loop and wait some more.
            mBlocked = true;
            continue;
        }

        if (mPendingIdleHandlers == null) {
            mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
        }
        mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
    }

    // Run the idle handlers.
    // We only ever reach this code block during the first iteration.
    for (int i = 0; i < pendingIdleHandlerCount; i++) {
        final IdleHandler idler = mPendingIdleHandlers[i];
        mPendingIdleHandlers[i] = null; // release the reference to the handler

        boolean keep = false;
        try {
            keep = idler.queueIdle();
        } catch (Throwable t) {
            Log.wtf(TAG, "IdleHandler threw exception", t);
        }

        if (!keep) {
            synchronized (this) {
                mIdleHandlers.remove(idler);
            }
        }
    }

}
复制代码

只有在MessageQueue中的next方法里面出现了IdleHandler,作用也很明显,当消息队列在遍历队列中的消息的时候,当消息已经处理完了,或者只存在延迟消息的时候,就会去处理mPendingIdleHandlers里面每一个idleHandler的事件,而这些事件都是通过方法addIdleHandler注册进去的

Looper.myQueue().addIdleHandler {
    false
}
复制代码

addIdlehandler接受的参数是一个返回值为布尔类型的函数类型参数,至于这个返回值是true还是false,我们从next()方法中就能了解到,当为false的时候,事件处理完以后,这个IdleHandler就会从数组中删除,下次再去遍历执行这个idleHandler数组的时候,该事件就没有了,如果为true的话,该事件不会被删除,下次依然会被执行,所以我们按需设置。现在我们可以利用idlehandler去解决上面讲到的在onCreate里面获取控件宽高的问题

Looper.myQueue().addIdleHandler {
    val mWith = bindingView.mainButton.width
    val mHeight = bindingView.mainButton.height
    println("按钮宽:$mWith,高:$mHeight")
    false
}
复制代码

当MessageQueue中的消息处理完的时候,我们的视图绘制也完成了,所以这个时候肯定也能获取控件的宽高,我们在IdleHandler里面执行了同样的代码之后,运行后的结果如下

按钮宽:979,高:187
复制代码

除此之外,我们还可以做点别的事情,比如我们常说的不要在主线程里面做一些耗时的工作,这样会降低页面启动速度,严重的还会出现ANR,这样的场景除了开辟子线程去处理耗时操作之外,我们现在还可以用IdleHandler,这里举个例子,我们在主线程中给sp塞入一些数据,然后在把这些数据读取出来,看看耗时多久

println(System.currentTimeMillis())
val testData = "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhas" +
        "jkhdaabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
        "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
        "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
        "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd" +
        "aabbbbakjsdhjkahsjkasdjasdhjakshdjkahsdjkhasjdkhjaskhdjkashdjkhasjkhasjkhd"
sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
for (i in 1..5000) {
    sharePreference.edit().putString("test$i", testData).commit()
}
for (i in 1..5000){
    sharePreference.getString("test$i","")
}
println(System.currentTimeMillis())

......运行结果
1676260921617
1676260942770
复制代码

我们看到在塞入5000次数据,再读取5000次数据之后,一共耗时大概20秒,同时也阻塞了主线程,导致的现象是页面一片空白,只有等读写操作结束了,页面才展示出来,我们接着把读写操作的代码用IdleHandler执行一下看看

Looper.myQueue().addIdleHandler {
    sharePreference = getSharedPreferences(packageName, MODE_PRIVATE)
    val editor = sharePreference.edit()
    for (i in 1..5000) {
        editor.putString("test$i", testData).commit()
    }
    for (i in 1..5000){
        sharePreference.getString("test$i","")
    }
    println(System.currentTimeMillis())
    false
}
......运行结果
1676264286760
1676264308294
复制代码

运行结果依然耗时二十秒左右,但区别在于这个时候页面不会受到读写操作的阻塞,很快就展示出来了,说明读写操作的确是等到页面渲染完才开始工作,上面过程没有放效果图主要是因为时间太长了,会影响gif的体验,有兴趣的可以自己试一下

如何让指定视图不被软键盘遮挡

我们通常使用android:windowSoftInputMode属性来控制软键盘弹出之后移动界面,让输入框不被遮挡,但是有些场景下,键盘永远都会挡住一些我们使用频次比较高的控件,比如现在我们有个登录页面,大概的样子长这样

image.png

它的布局文件是这样

<RelativeLayout
    android:id="@+id/mainroot"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="100dp"
        android:src="@mipmap/ic_launcher_round" />

    <androidx.appcompat.widget.LinearLayoutCompat
        android:id="@+id/ll_view1"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_marginBottom="120dp"
        android:gravity="center"
        android:orientation="vertical">

        <EditText
            android:id="@+id/main_edit"
            android:layout_width="match_parent"
            android:layout_height="40dp"
            android:hint="请输入用户名"
            android:textColor="@color/black"
            android:textSize="15sp" />

        <EditText
            android:id="@+id/main_edit2"
            android:layout_width="match_parent"
            android:layout_height="40dp"
            android:layout_marginTop="30dp"
            android:hint="请输入密码"
            android:textColor="@color/black"
            android:textSize="15sp" />

        <Button
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:layout_marginHorizontal="10dp"
            android:layout_marginTop="20dp"
            android:text="登录" />

    </androidx.appcompat.widget.LinearLayoutCompat>

</RelativeLayout>
复制代码

在这样一个页面里面,由于输入框与登录按钮都比较靠页面下方,导致当输入完内容想要点击登录按钮时候,必须再一次关闭键盘才行,这样的操作在体验上就比较大打折扣了

aaaa.gif

现在希望可以键盘弹出之后,按钮也展示在键盘上面,这样就不用收起弹框以后才能点击按钮了,这样一来,windowSoftInputMode这一个属性已经不够用了,我们要想一下其他方案

  • 首先,需要让按钮也展示在键盘上方,那只能让布局整体上移把按钮露出来,在这里我们可以改变LayoutParam的bottomMargin参数来实现
  • 其次,需要知道键盘什么时候弹出,我们都知道android里面并没有提供任何监听事件来告诉我们键盘什么时候弹出,我们只能从其他角度入手,那就是监听根布局可视区域大小的变化

ViewTreeObserver

我们先获取视图树的观察者,使用addOnGlobalLayoutListener去监听全局视图的变化

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {

}
复制代码

接下去就是要获取根视图的可视化区域了,如何来获取呢?View里面有这么一个方法,那就是getWindowVisibleDisplayFrame,我们看下源码注释就知道它是干什么的了

image.png

一大堆英文没必要都去看,只需要看最后一句就好了,大概意思就是获取能够展示给用户的可用区域,所以我们在监听器里面加上这个方法

bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
    val rect = Rect()
    bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
}
复制代码

当键盘弹出或者收起的时候,rect的高度就会跟着变化,我们就可以用这个作为条件来改变bottomMargin的值,现在我们增加一个变量oldDelta来保存前一个rect变化的高度值,用来做比较,完整的代码如下

var oldDelta = 0
val params:RelativeLayout.LayoutParams = bindingView.llView1.layoutParams as RelativeLayout.LayoutParams
val originBottom = params.bottomMargin
bindingView.mainroot.viewTreeObserver.addOnGlobalLayoutListener {
    val rect = Rect()
    bindingView.mainroot.getWindowVisibleDisplayFrame(rect)
    val deltaHeight = r.height()
    if (oldDelta != deltaHeight) {
        if (oldDelta != 0) {
            if (oldDelta > deltaHeight) {
                params.bottomMargin = oldDelta - deltaHeight
            } else if (oldDelta < deltaHeight) {
                params.bottomMargin = originBottom
            }
            bindingView.llView1.layoutParams = params
        }
        oldDelta = deltaHeight
    }
}
复制代码

最终效果如下

aaaa2.gif

弹出后页面有个抖动是因为本身有个页面平移的效果,然后再去计算layoutparam,如果不想抖动可以在布局外层套个scrollView,用smoothScrollTo把页面滑上去就可以了,有兴趣的可以业余时间试一下

为什么LiveData的postValue会丢失数据

LiveData已经问世好多年了,大家都很喜欢用,因为它上手方便,一般知道塞数据用setValue和postValue,监听数据使用observer就可以了,然而实际开发中我遇到过好多人,一会这里用setValue一会那里用postValue,或者交替着用,这种做法也不能严格意义上说错,毕竟运行起来的确没问题,但是这种做法确实是存在风险隐患,那就是连续postValue会丢数据,我们来做个实验,连续setValue十个数据和连续postValue十个数据,收到的结果都分别是什么

var testData = MutableLiveData<Int>()
fun play(){
    for (i in 1..10) {
        testData.value = i
    }
}

mainViewModel.testData.observe(this) {
    println("收到:$it")
}

//执行结果
收到:1
收到:2
收到:3
收到:4
收到:5
收到:6
收到:7
收到:8
收到:9
收到:10
复制代码

setValue十次数据都可以收到,现在把setValue改成postValue再来试试

var testData = MutableLiveData<Int>()
fun play(){
    for (i in 1..10) {
        testData.postValue(i)
    }
}
复制代码

得到的结果是

收到:10
复制代码

Seule la dernière donnée 10 a été reçue, pourquoi ? Allons dans postValue et regardons le code source à l'intérieur.

image.pngRegardez principalement à l'intérieur de la boîte rouge, il y a un verrou synchrone synchronisé qui verrouille un bloc de code, nous l'appelons bloc de code 1, l'objet du verrou est mDataLock, ce que fait le bloc de code 1 est d'abord d'attribuer une valeur booléenne à postTask, puis passer le entrant La valeur est affectée à mPendingData, alors nous savons que la valeur de postTask est vraie sauf lorsque la première est exécutée, et elle sera fausse après que mPendingData aura une valeur. La prémisse est que mPendingData n'a pas été réinitialisé à NOT_SET , puis nous suivons En regardant le code, vous verrez que le code ira ensuite à un thread mPostValueRunnable, jetons un coup d'œil à ce thread

image.png

Trouvé le même verrou et verrouillé un autre bloc de code, que nous appelons bloc de code 2. Dans ce bloc de code, après avoir attribué la valeur de mPendingData à newValue, il est réinitialisé à NOT_SET. De cette façon, postValue peut accepter une nouvelle valeur, donc cela est aussi la raison pour laquelle chaque postValue peut recevoir une valeur dans des circonstances normales, mais pensons à la scène de la postValue continue, nous savons que si synchronized modifie un bloc de code, alors lorsque ce bloc de code acquiert un verrou, il a la priorité, et le le verrou ne sera libéré qu'une fois toutes les exécutions terminées. Par conséquent, lorsque le bloc de code 1 est accédé en continu, le bloc de code 2 ne sera pas exécuté. Ce n'est que lorsque le bloc de code 1 est exécuté et que le verrou est libéré que le bloc de code 2 sera exécuté, et à ce moment, mPendingData est déjà la dernière valeur, et les valeurs précédentes ont toutes été écrasées, nous avons donc dit que postValue perdrait des données. En fait, c'est faux. Il faudrait que postValue n'enverra que les dernières données .

Résumer

Les questions d'entretien mentionnées dans cet article ne se rencontrent que depuis quelques années. On estime qu'en plus de quelques questions de routine, la proportion de l'entretien sera plus encline aux points de connaissance de Kotlin, Compose et Flutter, donc seulement en accumulant au fil du temps, laissez-vous tenter Ce n'est qu'avec un point de connaissance plus complet que l'on peut remonter en amont sous la féroce tendance actuelle du marché et ne pas être claqué sur la plage

Guess you like

Origin juejin.im/post/7199537072302374969