欢迎大家前往云+社区,获取更多腾讯海量技术实践干货哦~

作者: QQ音乐技术团队

题记

Toast 作为 Android 系统中最常用的类之一,由于其方便的api设计和简洁的交互体验,被我们所广泛采用。但是,伴随着我们开发的深入,Toast 的问题也逐渐暴露出来。 本系列文章将分成两篇: 第一篇,我们将分析 Toast 所带来的问题 第二篇,将提供解决 Toast 问题的解决方案 (注:本文源码基于Android 7.0)

1.回顾

上一篇 [[Android] Toast问题深度剖析(一)] 笔者解释了:

  1. Toast 系统如何构建窗口(通过系统服务NotificationManager来生成系统窗口)
  2. Toast 异常出现的原因(系统调用 Toast的时序紊乱)

而本篇的重点,在于解决我们第一章所说的 Toast 问题。

2.解决思路

基于第一篇的知识,我们知道,Toast 的窗口属于系统窗口,它的生成和生命周期依赖于系统服务 NotificationManager。一旦 NotificationManager 所管理的窗口生命周期跟我们本地的进程不一致,就会发生异常。那么,我们能不能不使用系统的窗口,而使用自己的窗口,并且由我们自己控制生命周期呢?事实上, SnackBar 就是这样的方案。不过,如果不使用系统类型的窗口,就意味着你的Toast 界面,无法在其他应用之上显示。(比如,我们经常看到的一个场景就是你在你的应用出调用了多次 Toast.show函数,然后退回到桌面,结果发现桌面也会弹出 Toast,就是因为系统的 Toast 使用了系统窗口,具有高的层级)不过在某些版本的手机上,你的应用可以申请权限,往系统中添加 TYPE_SYSTEM_ALERT 窗口,这也是一种系统窗口,经常用来作为浮层显示在所有应用程序之上。不过,这种方式需要申请权限,并不能做到让所有版本的系统都能正常使用。 如果我们从体验的角度来看,当用户离开了该进程,就不应该弹出另外一个进程的 Toast 提示去干扰用户的。Android 系统似乎也意识到了这一点,在新版本的系统更新中,限制了很多在桌面提示窗口相关的权限。所以,从体验上考虑,这个情况并不属于问题。

“那么我们可以选择哪些窗口的类型呢?”

  1. 使用子窗口: 在 Android 进程内,我们可以直接使用类型为子窗口类型的窗口。在 Android 代码中的直接应用是 PopupWindow 或者是 Dialog 。这当然可以,不过这种窗口依赖于它的宿主窗口,它可用的条件是你的宿主窗口可用
  2. 采用 View 系统: 使用 View 系统去模拟一个 Toast 窗口行为,做起来不仅方便,而且能更加快速的实现动画效果,我们的 SnackBar 就是采用这套方案。这也是我们今天重点讲的方案

“如果采用 View 系统方案,那么我要往哪个控件中添加我的 Toast 控件呢?”

Android进程中,我们所有的可视操作都依赖于一个 ActivityActivity 提供上下文(Context)和视图窗口(Window) 对象。我们通过 Activity.setContentView 方法所传递的任何 View对象 都将被视图窗口( Window) 中的 DecorView 所装饰。而在 DecorView 的子节点中,有一个 idandroid.R.id.contentFrameLayout 节点(后面简称 content 节点) 是用来容纳我们所传递进去的 View 对象。一般情况下,这个节点占据了除了通知栏的所有区域。这就特别适合用来作为 Toast 的父控件节点。

“我什么时机往这个content节点中添加合适呢?这个 content 节点什么时候被初始化呢?”

根据不同的需求,你可能会关注以下两个时机:

  1. Content 节点生成
  2. Content 内容显示

实际我们只需要将我们的 Toast 添加到 Content 节点中,只要满足第一条即可。如果你是为了完成性能检测,测量或者其他目的,那么你可能更关心第二条。 那么什么情况下 Content 节点生成呢?刚才我们说了,Content 节点包含在我们的 DecorView 控件中,而 DecorView 是由 ActivityWindow对象所持有的控件。WindowAndroid 中的实现类是 PhoneWindow,(这部分代码有兴趣可以自行阅读) 我们来看下源码:

//code PhoneWindow.java
@Overridepublic void setContentView(int layoutResID) {if (mContentParent == null) { //mContentParent就是我们的 content 节点installDecor();//生成一个DecorView} else {mContentParent.removeAllViews();}mLayoutInflater.inflate(layoutResID, mContentParent);final Callback cb = getCallback();if (cb != null && !isDestroyed()) {cb.onContentChanged();}}

PhoneWindow 对象通过 installDecor 函数生成 DecorView 和 我们所需要的 content 节点(最终会存到 mContentParent) 变量中去。但是, setContentView 函数需要我们主动调用,如果我并没有调用这个 setContentView 函数,installDecor 方法将不被调用。那么,有没有某个时刻,content 节点是必然生成的呢?当然有,除了在 setContentView 函数中调用installDecor外,还有一个函数也调用到了这个,那就是:

//code PhoneWindow.java
@Overridepublic final View getDecorView() {if (mDecor == null) {installDecor();}return mDecor;
}

而这个函数,将在 Activity.findViewById 的时候调用:

//code Activity.java
public View findViewById(@IdRes int id) {return getWindow().findViewById(id);
}
//code Window.java
public View findViewById(@IdRes int id) {return getDecorView().findViewById(id);
}

因此,只要我们只要调用了 findViewById 函数,一样可以保证 content 被正常初始化。这样我们解释了第一个”就绪”(Content 节点生成)。我们再来看下第二个”就绪”,也就是 Android 界面什么时候显示呢?相信你可能迫不及待的回答不是 onResume 回调的时候么?实际上,在 onResume 的时候,根本还没处理跟界面相关的事情。我们来看下 Android 进程是如何处理 resume 消息的: (注: AcitivityThreadAndroid 进程的入口类, Android 进程处理 resume 相关消息将会调用到 AcitivityThread.handleResumeActivity 函数)

//code AcitivityThread.java
void handleResumeActivity(...) {...ActivityClientRecord r = performResumeActivity(token, clearHide);// 之后会调用call onResume
    ...View decor = r.window.getDecorView();//调用getDecorView 生成 content节点
    decor.setVisibility(View.INVISIBLE);....if (r.activity.mVisibleFromClient) {r.activity.makeVisible();//add to WM 管理
    }...
}
//code Activity.javavoid makeVisible() {if (!mWindowAdded) {ViewManager wm = getWindowManager();wm.addView(mDecor, getWindow().getAttributes());mWindowAdded = true;}mDecor.setVisibility(View.VISIBLE);}

Android 进程在处理 resume 消息的时候,将走以下的流程:

  1. 调用 performResumeActivity 回调 ActivityonResume 函数
  2. 调用 WindowgetDecorView 生成 DecorView 对象和 content 节点
  3. DecorView纳入 WindowManager (进程内服务)的管理
  4. 调用 Activity.makeVisible 显示当前 Activity

按照上述的流程,在 Activity.onResume 回调之后,才将控件纳入本地服务 WindowManager 的管理中。也就是说, Activity.onResume 根本没有显示任何东西。我们不妨写个代码验证一下:

//code DemoActivity.javapublic DemoActivity extends Activity {private View view ;@Overrideprotected void onCreate( Bundle savedInstanceState) {super.onCreate(savedInstanceState);view = new View(this);this.setContentView(view);}@Overrideprotected void onResume() {super.onResume();Log.d("cdw","onResume :" +view.getHeight());// 有高度是显示的必要条件
    }
}

这里,我们通过在 onResume 中获取高度的方式验证界面是否被绘制,最终我们将输出日志:

D cdw     : onResume :0

那么,界面又是在什么时候完成的绘制呢?是不是在 WindowManager.addView 之后呢?我们在 onResume之后会调用Activity.makeVisible,里面会调用 WindowManager.addView。因此我们在onResumepost一个消息就可以检测WindowManager.addView 之后的情况:

@Overrideprotected void onResume() {super.onResume();this.runOnUiThread(new Runnable() {@Overridepublic void run() {Log.d("cdw","onResume :" +view.getHeight());}});
}//控制台输出:
01-02 21:30:27.445  2562  2562 D cdw     : onResume :0

从结果上看,我们在 WindowManager.addView 之后,也并没有绘制界面。那么,Android的绘制是什么时候开始的?又是到什么时候结束?

Android 系统中,每一次的绘制都是通过一个 16ms 左右的 VSYNC 信号控制的,这种信号可能来自于硬件也可能来自于软件模拟。每一次非动画的绘制,都包含:测量,布局,绘制三个函数。而一般触发这一事件的的动作有:

  1. View 的某些属性的变更
  2. View 重新布局Layout
  3. 增删 View 节点

当调用 WindowManager.addView 将空间添加到 WM 服务管理的时候,会调用一次Layout请求,这就触发了一次 VSYNC 绘制。因此,我们只需要在 onResumepost 一个帧回调就可以检测绘制开始的时间:

 @Overrideprotected void onResume() {super.onResume();Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {@Overridepublic void doFrame(long frameTimeNanos) {//TODO 绘制开始
            }});}

我们先来看下 View.requestLayout 是怎么触发界面重新绘制的:

//code View.java
public void requestLayout() {....if (mParent != null) {...if (!mParent.isLayoutRequested()) {mParent.requestLayout();}}}

View 对象调用 requestLayout 的时候会委托给自己的父节点处理,这里之所以不称为父控件而是父节点,是因为除了控件外,还有 ViewRootImpl 这个非控件类型作为父节点,而这个父节点会作为整个控件树的根节点。按照我们上面说的委托的机制,requestLayout 最终将会调用到 ViewRootImpl.requestLayout

//code ViewRootImpl.java
@Overridepublic void requestLayout() {if (!mHandlingLayoutInLayoutRequest) {checkThread();mLayoutRequested = true;scheduleTraversals();//申请绘制请求
        }}void scheduleTraversals() {if (!mTraversalScheduled) {mTraversalScheduled = true;....mChoreographer.postCallback(Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);//申请绘制
            ....}}

ViewRootImpl 最终会将 mTraversalRunnable 处理命令放到 CALLBACK_TRAVERSAL 绘制队列中去:

final class TraversalRunnable implements Runnable {@Overridepublic void run() {doTraversal();//执行布局和绘制
        }
}void doTraversal() {if (mTraversalScheduled) {mTraversalScheduled = false;...performTraversals();...}}

mTraversalRunnable 命令最终会调用到 performTraversals() 函数:

private void performTraversals() {final View host = mView;...host.dispatchAttachedToWindow(mAttachInfo, 0);//attachWindow
    ...getRunQueue().executeActions(attachInfo.mHandler);//执行某个指令
    ...childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);host.measure(childWidthMeasureSpec, childHeightMeasureSpec);//测量
    ....host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());//布局
    ...draw(fullRedrawNeeded);//绘制
    ...
}

performTraversals 函数实现了以下流程:

  1. 调用 dispatchAttachedToWindow 通知子控件树当前控件被 attach 到窗口中
  2. 执行一个命令队列 getRunQueue
  3. 执行 meausre 测量指令
  4. 执行 layout 布局函数
  5. 执行绘制 draw

这里我们看到一句方法调用:

getRunQueue().executeActions(attachInfo.mHandler);

这个函数将执行一个延时的命令队列,在 View 对象被 attachView树之前,通过调用 View.post 函数,可以将执行消息命令加入到延时执行队列中去:

//code View.java
public boolean post(Runnable action) {Handler handler;AttachInfo attachInfo = mAttachInfo;if (attachInfo != null) {handler = attachInfo.mHandler;} else {// Assume that post will succeed later
            ViewRootImpl.getRunQueue().post(action);return true;}return handler.post(action);
}

getRunQueue().executeActions 函数执行的时候,会将该命令消息延后一个UI线程消息执行,这就保证了执行的这个命令消息发生在我们的绘制之后:

//code RunQueue.javavoid executeActions(Handler handler) {synchronized (mActions) {...for (int i = 0; i < count; i++) {final HandlerAction handlerAction = actions.get(i);handler.postDelayed(handlerAction.action, handlerAction.delay);//推迟一个消息
                }}}

所以,我们只需要在视图被 attach 之前通过一个 View 来抛出一个命令消息,就可以检测视图绘制结束的时间点:

//code DemoActivity.java
 @Overrideprotected void onResume() {super.onResume();Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {@Overridepublic void doFrame(long frameTimeNanos) {start = SystemClock.uptimeMillis();log("绘制开始:height = "+view.getHeight());}});}@Overrideprotected void onCreate( Bundle savedInstanceState) {super.onCreate(savedInstanceState);view = new View(this);view.post(new Runnable() {@Overridepublic void run() {log("绘制耗时:"+(SystemClock.uptimeMillis()-start)+"ms");log("绘制结束后:height = "+view.getHeight());}});this.setContentView(view);}
//控制台输出:
01-03 23:39:27.251 27069 27069 D cdw     : --->绘制开始:height = 0
01-03 23:39:27.295 27069 27069 D cdw     : --->绘制耗时:44ms
01-03 23:39:27.295 27069 27069 D cdw     : --->绘制结束后:height = 1232

我们带着我们上面的知识储备,来看下SnackBar是如何做的呢:

3.Snackbar

SnackBar 系统主要依赖于两个类:

  1. SnackBar 作为门面,与业务程序交互
  2. SnackBarManager 作为时序管理器, SnackBarSnackBarManager 的交互,通过 Callback 回调对象进行

SnackBarManager 的时序管理跟 NotifycationManager 的很类似不再赘述

SnackBar 通过静态方法 make 静态构造一个 SnackBar:

public static Snackbar make(@NonNull View view, @NonNull CharSequence text,@Duration int duration) {Snackbar snackbar = new Snackbar(findSuitableParent(view));snackbar.setText(text);snackbar.setDuration(duration);return snackbar;}

这里有一个关键函数 findSuitableParent ,这个函数的目的就相当于我们上面的 findViewById(R.id.content) 一样,给 SnackBar 所定义的 Toast 控件找一个合适的容器:

private static ViewGroup findSuitableParent(View view) {ViewGroup fallback = null;do {if (view instanceof CoordinatorLayout) {return (ViewGroup) view;} else if (view instanceof FrameLayout) {if (view.getId() == android.R.id.content) {//把 `Content` 节点作为容器
                    ...return (ViewGroup) view;} else {// It's not the content view but we'll use it as our fallbackfallback = (ViewGroup) view;}}...} while (view != null);// If we reach here then we didn't find a CoL or a suitable content view so we'll fallbackreturn fallback;}

我们发现,除了包含 CoordinatorLayout 控件的情况, 默认情况下, SnackBar 也是找的 Content 节点。找到的这个父节点,作为 Snackbar 构造器的形参:

private Snackbar(ViewGroup parent) {mTargetParent = parent;mContext = parent.getContext();...LayoutInflater inflater = LayoutInflater.from(mContext);mView = (SnackbarLayout) inflater.inflate(R.layout.design_layout_snackbar, mTargetParent, false);...}

Snackbar 将生成一个 SnackbarLayout 控件作为 Toast 控件。最后当时序控制器 SnackBarManager 回调返回的时候,通知 SnackBar 显示,即将 SnackBar.mView 增加到 mTargetParent 控件中去。

这里有人或许会有疑问,这里使用强引用,会不会造成一段时间内的内存泄漏呢? 假如你现在弹了 10Toast ,每个 Toast 的显示时间是 2s 。也就是说你的最后一个 SnackBar 将被 SnackBarManager 持有至少 20s。而 SnackBar 中又存在有父控件 mTargetParent 的强引用。相当于在这20s内, 你的mTargetParent 和它所持有的 Context (一般是 Activity)无法释放

这个其实是不会的,原因在于 SnackBarManager 在管理这种回调 callback 的时候,采用了弱引用。

private static class SnackbarRecord {final WeakReference<Callback> callback;....
}

但是,我们从 SnackBar 的设计可以看出,SnackBar无法定制具体的样式: SnackBar 只能生成 SnackBarLayout 这种控件和布局,可能并不满足你的业务需求。当然你也可以变更 SnackBarLayout 也能达到目的。不过,有了上面的知识储备,我们完全可以写一个自己的 Snackbar

4.基于Toast的改法

从第一篇文章我们知道,我们直接在 Toast.show 函数外增加 try-catch 是没有意义的。因为 Toast.show 实际上只是发了一条命令给 NotificationManager 服务。真正的显示需要等 NotificationManager 通知我们的 TN 对象 show 的时候才能触发。NotificationManager 通知给 TN 对象的消息,都会被 TN.mHandler 这个内部对象进行处理

//code Toast.java
private static class TN {final Runnable mHide = new Runnable() {// 通过 mHandler.post(mHide) 执行
            @Overridepublic void run() {handleHide();mNextView = null;}};final Handler mHandler = new Handler() {@Overridepublic void handleMessage(Message msg) {IBinder token = (IBinder) msg.obj;handleShow(token);// 处理 show 消息
                }};
}

 

NotificationManager 通知给 TN 对象显示的时候,TN 对象将给 mHandler 对象发送一条消息,并在 mHandlerhandleMessage 函数中执行。 当NotificationManager 通知 TN 对象隐藏的时候,将通过 mHandler.post(mHide) 方法,发送隐藏指令。不论采用哪种方式发送的指令,都将执行 HandlerdispatchMessage(Message msg) 函数:

//code Handler.java
public void dispatchMessage(Message msg) {if (msg.callback != null) {handleCallback(msg);// 执行 post(Runnable)形式的消息} else {...handleMessage(msg);// 执行 sendMessage形式的消息
        }}

因此,我们只需要在 dispatchMessage 方法体内加入 try-catch 就可以避免 Toast 崩溃对应用程序的影响:

public void dispatchMessage(Message msg) {try {super.dispatchMessage(msg);} catch(Exception e) {}
}

因此,我们可以定义一个安全的 Handler 装饰器:

private static class SafelyHandlerWarpper extends Handler {private Handler impl;public SafelyHandlerWarpper(Handler impl) {this.impl = impl;}@Overridepublic void dispatchMessage(Message msg) {try {super.dispatchMessage(msg);} catch (Exception e) {}}@Overridepublic void handleMessage(Message msg) {impl.handleMessage(msg);//需要委托给原Handler执行
        }
}

由于 TN.mHandler 对象复写了 handleMessage 方法,因此,在 Handler 装饰器里,需要将 handleMessage 方法委托给 TN.mHandler 执行。定义完装饰器之后,我们就可以通过反射往我们的 Toast 对象中注入了:

public class ToastUtils {private static Field sField_TN ;private static Field sField_TN_Handler ;static {try {sField_TN = Toast.class.getDeclaredField("mTN");sField_TN.setAccessible(true);sField_TN_Handler = sField_TN.getType().getDeclaredField("mHandler");sField_TN_Handler.setAccessible(true);} catch (Exception e) {}}private static void hook(Toast toast) {try {Object tn = sField_TN.get(toast);Handler preHandler = (Handler)sField_TN_Handler.get(tn);sField_TN_Handler.set(tn,new SafelyHandlerWarpper(preHandler));} catch (Exception e) {}}public static void showToast(Context context,CharSequence cs, int length) {Toast toast = Toast.makeText(context,cs,length);hook(toast);toast.show();}
}

我们再用第一章中的代码测试一下:

public void showToast(View view) {ToastUtils.showToast(this,"hello", Toast.LENGTH_LONG);try {Thread.sleep(10000);} catch (InterruptedException e) {}
}

等 10s 之后,进程正常运行,不会因为 Toast 的问题而崩溃。

相关阅读

[Android] Toast问题深度剖析(一)

Android基础:Fragment,看这篇就够了

Android图像处理 - 高斯模糊的原理及实现


此文已由作者授权云加社区发布,转载请注明文章出处

[Android] Toast问题深度剖析(二)相关推荐

  1. Android对话框Dialog深度剖析

    对话框 对话框是提示用户作出决定或输入额外信息的小窗口. 对话框不会填充屏幕,通常用于需要用户采取行动才能继续执行的模式事件. 对话框设计 Dialog 类是对话框的基类,但您应该避免直接实例化 Di ...

  2. Android实训案例(四)——关于Game,2048方块的设计,逻辑,实现,编写,加上色彩,分数等深度剖析开发过程!...

    Android实训案例(四)--关于Game,2048方块的设计,逻辑,实现,编写.加上色彩.分数等深度剖析开发过程! 关于2048,我看到非常多大神,比方医生.郭神.所以我也研究了一段时间.还好是研 ...

  3. Android应用程序开发以及背后的设计思想深度剖析(4)

    特别声明:本系列文章LiAnLab.org著作权所有,转载请注明出处.作者系LiAnLab.org资深Android技术顾问吴赫老师.本系列文章交流与讨论:@宋宝华Barry 紧接连载三,我们接下从性 ...

  4. Android实训案例(四)——关于Game,2048方块的设计,逻辑,实现,编写,加上色彩,分数等深度剖析开发过程!

    Android实训案例(四)--关于Game,2048方块的设计,逻辑,实现,编写,加上色彩,分数等深度剖析开发过程! 关于2048,我看到很多大神,比如医生,郭神,所以我也研究了一段时间,还好是研究 ...

  5. libevent源码深度剖析十二

    libevent源码深度剖析十二 --让libevent支持多线程 张亮 Libevent本身不是多线程安全的,在多核的时代,如何能充分利用CPU的能力呢,这一节来说说如何在多线程环境中使用libev ...

  6. 关键字深度剖析,集齐所有关键字可召唤神龙?【二】

    关键字深度剖析,集齐所有关键字可召唤神龙?[二] 1. if.else 组合 1.1 if 和 else 1.1.1 结论1 1.1.2 结论2 1.1.3 结论3 1.2 bool 变量与" ...

  7. 深度剖析E680G开发二.交叉编译BusyBox与图形化程序

    深度剖析E680G开发二.交叉编译BusyBox,图形化程序 草木瓜 20060917 一.前言         很多人看到这个剖析系列,觉得难了,其中不乏热衷于刷机的人.刚刚接触 E680g的普通人 ...

  8. 【学习笔记】Part1·JavaScript·深度剖析-函数式编程与 JS 异步编程、手写 Promise(二、JavaScript 异步编程)

    [学习笔记]Part1·JavaScript·深度剖析-函数式编程与 JS 异步编程.手写 Promise(课前准备) [学习笔记]Part1·JavaScript·深度剖析-函数式编程与 JS 异步 ...

  9. 常见损失函数深度剖析之二分类

    常见损失函数深度剖析之二分类 ###1.二值交叉熵 二值交叉熵是二分类问题的默认损失函数,用于目标变量满足{0,1}二值分布的二分类模型中. 交叉熵回顾--交叉熵是信息理论学里面,用来衡量两个概率分布 ...

最新文章

  1. web服务器的文档的部分路径是如何工作的,FinderWeb - 文档中心
  2. Swift 4正式发布,新功能概览
  3. 韵乐x5最佳参数手动_4种超参数调整技术及其局限性
  4. java自定义equals函数和hashCode函数
  5. Python版双链表结构与有关操作
  6. java基本数据类型自动转包装类_Java基础教程之基本类型数据类型、包装类及自动拆装箱...
  7. 《C++游戏开发》笔记十三 平滑过渡的战争迷雾(一) 原理:Warcraft3地形拼接算法...
  8. Docker之使用Dockerfile创建定制化镜像(四)--技术流ken
  9. 2021全国研究生数学建模竞赛D题思路
  10. 寒门难出贵子,我当程序员让爸妈在老家长脸了
  11. Windows电脑如何查看内存条配置
  12. android 有哪些设备id,Android之设备ID(Device ID)
  13. 基于Sketch Up软件校园建模案例分享
  14. 中兴通讯2013校招软件笔试题
  15. 基于Logistic混沌序列和DNA编码的图像加解密算法仿真
  16. 魔兽服务器排队微信,服务器排队严重:《魔兽世界》经典怀旧服执行47服免费角色转移计划...
  17. 那些在国内还能使用的谷歌产品
  18. 从高考到程序员,做自己就好
  19. 关于C语言的return返回值返回到哪里
  20. 3CDB:基于3C技术的染色质互作信息数据库

热门文章

  1. python opencv 彩色图非局部平均去噪
  2. linux 限制用户端口,linux – Centos 6.4只允许用户绑定到某个端口?
  3. php js 比较大小写,jQuery-JavaScript比较字符串而不区分大小写
  4. o oia ospf 路由优先_OSPF基本配置及OSPF特殊区域配置
  5. leetcode--笔记——120. 三角形最小路径和
  6. php广告轮播效果,使用swiper组件实现轮播广告效果
  7. android studio api30 模拟器运行不了_iOS模拟器来了?但我们劝你不要轻易尝试
  8. k8s 使用Nginx Ingress实现灰度发布和蓝绿发布
  9. openssl、x509、crt、cer、key、csr、ssl、tls
  10. mac下软件包管理brew