服务器之家

服务器之家 > 正文

详解Android中的Toast源码

时间:2019-12-30 14:14     来源/作者:低调小一

Toast源码实现
Toast入口

    我们在应用中使用Toast提示的时候,一般都是一行简单的代码调用,如下所示:
[java] view plaincopyprint?在CODE上查看代码片派生到我的代码片

?
1
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();

    makeText就是Toast的入口,我们从makeText的源码来深入理解Toast的实现。源码如下(frameworks/base/core/java/android/widget/Toast.java):

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Toast makeText(Context context, CharSequence text, int duration) {
  Toast result = new Toast(context);
 
  LayoutInflater inflate = (LayoutInflater)
      context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
  View v = inflate.inflate(com.android.internal.R.layout.transient_notification, null);
  TextView tv = (TextView)v.findViewById(com.android.internal.R.id.message);
  tv.setText(text);
   
  result.mNextView = v;
  result.mDuration = duration;
 
  return result;
}

    从makeText的源码里,我们可以看出Toast的布局文件是transient_notification.xml,位于frameworks/base/core/res/res/layout/transient_notification.xml:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical"
  android:background="?android:attr/toastFrameBackground">
 
  <TextView
    android:id="@android:id/message"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_weight="1"
    android:layout_gravity="center_horizontal"
    android:textAppearance="@style/TextAppearance.Toast"
    android:textColor="@color/bright_foreground_dark"
    android:shadowColor="#BB000000"
    android:shadowRadius="2.75"
    />
 
</LinearLayout>

    系统Toast的布局文件非常简单,就是在垂直布局的LinearLayout里放置了一个TextView。接下来,我们继续跟到show()方法,研究一下布局形成之后的展示代码实现:

  

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void show() {
   if (mNextView == null) {
     throw new RuntimeException("setView must have been called");
   }
  
   INotificationManager service = getService();
   String pkg = mContext.getPackageName();
   TN tn = mTN;
   tn.mNextView = mNextView;
  
   try {
     service.enqueueToast(pkg, tn, mDuration);
   } catch (RemoteException e) {
     // Empty
   }
 }

    show方法中有两点是需要我们注意的。(1)TN是什么东东?(2)INotificationManager服务的作用。带着这两个问题,继续我们Toast源码的探索。
TN源码
    很多问题都能通过阅读源码找到答案,关键在与你是否有与之匹配的耐心和坚持。mTN的实现在Toast的构造函数中,源码如下:

?
1
2
3
4
5
6
7
8
public Toast(Context context) {
  mContext = context;
  mTN = new TN();
  mTN.mY = context.getResources().getDimensionPixelSize(
      com.android.internal.R.dimen.toast_y_offset);
  mTN.mGravity = context.getResources().getInteger(
      com.android.internal.R.integer.config_toastDefaultGravity);
}

    接下来,我们就从TN类的源码出发,探寻TN的作用。TN源码如下:

 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
private static class TN extends ITransientNotification.Stub {
  final Runnable mShow = new Runnable() {
    @Override
    public void run() {
      handleShow();
    }
  };
 
  final Runnable mHide = new Runnable() {
    @Override
    public void run() {
      handleHide();
      // Don't do this in handleHide() because it is also invoked by handleShow()
      mNextView = null;
    }
  };
 
  private final WindowManager.LayoutParams mParams = new WindowManager.LayoutParams();
  final Handler mHandler = new Handler();  
 
  int mGravity;
  int mX, mY;
  float mHorizontalMargin;
  float mVerticalMargin;
 
 
  View mView;
  View mNextView;
 
  WindowManager mWM;
 
  TN() {
    // XXX This should be changed to use a Dialog, with a Theme.Toast
    // defined that sets up the layout params appropriately.
    final WindowManager.LayoutParams params = mParams;
    params.height = WindowManager.LayoutParams.WRAP_CONTENT;
    params.width = WindowManager.LayoutParams.WRAP_CONTENT;
    params.format = PixelFormat.TRANSLUCENT;
    params.windowAnimations = com.android.internal.R.style.Animation_Toast;
    params.type = WindowManager.LayoutParams.TYPE_TOAST;
    params.setTitle("Toast");
    params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
        | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
        | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
    /// M: [ALPS00517576] Support multi-user
    params.privateFlags = WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS;
  }
 
  /**
   * schedule handleShow into the right thread
   */
  @Override
  public void show() {
    if (localLOGV) Log.v(TAG, "SHOW: " + this);
    mHandler.post(mShow);
  }
 
  /**
   * schedule handleHide into the right thread
   */
  @Override
  public void hide() {
    if (localLOGV) Log.v(TAG, "HIDE: " + this);
    mHandler.post(mHide);
  }
 
  public void handleShow() {
    if (localLOGV) Log.v(TAG, "HANDLE SHOW: " + this + " mView=" + mView
        + " mNextView=" + mNextView);
    if (mView != mNextView) {
      // remove the old view if necessary
      handleHide();
      mView = mNextView;
      Context context = mView.getContext().getApplicationContext();
      if (context == null) {
        context = mView.getContext();
      }
      mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
      // We can resolve the Gravity here by using the Locale for getting
      // the layout direction
      final Configuration config = mView.getContext().getResources().getConfiguration();
      final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
      mParams.gravity = gravity;
      if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
        mParams.horizontalWeight = 1.0f;
      }
      if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
        mParams.verticalWeight = 1.0f;
      }
      mParams.x = mX;
      mParams.y = mY;
      mParams.verticalMargin = mVerticalMargin;
      mParams.horizontalMargin = mHorizontalMargin;
      if (mView.getParent() != null) {
        if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
        mWM.removeView(mView);
      }
      if (localLOGV) Log.v(TAG, "ADD! " + mView + " in " + this);
      mWM.addView(mView, mParams);
      trySendAccessibilityEvent();
    }
  }
 
  private void trySendAccessibilityEvent() {
    AccessibilityManager accessibilityManager =
        AccessibilityManager.getInstance(mView.getContext());
    if (!accessibilityManager.isEnabled()) {
      return;
    }
    // treat toasts as notifications since they are used to
    // announce a transient piece of information to the user
    AccessibilityEvent event = AccessibilityEvent.obtain(
        AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
    event.setClassName(getClass().getName());
    event.setPackageName(mView.getContext().getPackageName());
    mView.dispatchPopulateAccessibilityEvent(event);
    accessibilityManager.sendAccessibilityEvent(event);
  }    
 
  public void handleHide() {
    if (localLOGV) Log.v(TAG, "HANDLE HIDE: " + this + " mView=" + mView);
    if (mView != null) {
      // note: checking parent() just to make sure the view has
      // been added... i have seen cases where we get here when
      // the view isn't yet added, so let's try not to crash.
      if (mView.getParent() != null) {
        if (localLOGV) Log.v(TAG, "REMOVE! " + mView + " in " + this);
        mWM.removeView(mView);
      }
 
      mView = null;
    }
  }
}

    通过源码,我们能很明显的看到继承关系,TN类继承自ITransientNotification.Stub,用于进程间通信。这里假设读者都有Android进程间通信的基础(不太熟的建议学习罗升阳关于Binder进程通信的一系列博客)。既然TN是用于进程间通信,那么我们很容易想到TN类的具体作用应该是Toast类的回调对象,其他进程通过调用TN类的具体对象来操作Toast的显示和消失。
    TN类继承自ITransientNotification.Stub,ITransientNotification.aidl位于frameworks/base/core/java/android/app/ITransientNotification.aidl,源码如下:

?
1
2
3
4
5
6
7
package android.app;
 
/** @hide */
oneway interface ITransientNotification {
  void show();
  void hide();
}

    ITransientNotification定义了两个方法show()和hide(),它们的具体实现就在TN类当中。TN类的实现为:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * schedule handleShow into the right thread
 */
@Override
public void show() {
  if (localLOGV) Log.v(TAG, "SHOW: " + this);
  mHandler.post(mShow);
}
 
/**
 * schedule handleHide into the right thread
 */
@Override
public void hide() {
  if (localLOGV) Log.v(TAG, "HIDE: " + this);
  mHandler.post(mHide);
}

    这里我们就能知道,Toast的show和hide方法实现是基于Handler机制。而TN类中的Handler实现是:

?
1
final Handler mHandler = new Handler();  

    而且,我们在TN类中没有发现任何Looper.perpare()和Looper.loop()方法。说明,mHandler调用的是当前所在线程的Looper对象。所以,当我们在主线程(也就是UI线程中)可以随意调用Toast.makeText方法,因为Android系统帮我们实现了主线程的Looper初始化。但是,如果你想在子线程中调用Toast.makeText方法,就必须先进行Looper初始化了,不然就会报出java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare() 。Handler机制的学习可以参考我之前写过的一篇博客:http://blog.csdn.net/wzy_1988/article/details/38346637。
    接下来,继续跟一下mShow和mHide的实现,它俩的类型都是Runnable。

 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final Runnable mShow = new Runnable() {
  @Override
  public void run() {
    handleShow();
  }
};
 
final Runnable mHide = new Runnable() {
  @Override
  public void run() {
    handleHide();
    // Don't do this in handleHide() because it is also invoked by handleShow()
    mNextView = null;
  }
};

    可以看到,show和hide的真正实现分别是调用了handleShow()和handleHide()方法。我们先来看handleShow()的具体实现:
   

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void handleShow() {
   if (mView != mNextView) {
     // remove the old view if necessary
     handleHide();
     mView = mNextView;
     Context context = mView.getContext().getApplicationContext();
     if (context == null) {
       context = mView.getContext();
     }
     mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE);
     // We can resolve the Gravity here by using the Locale for getting
     // the layout direction
     final Configuration config = mView.getContext().getResources().getConfiguration();
     final int gravity = Gravity.getAbsoluteGravity(mGravity, config.getLayoutDirection());
     mParams.gravity = gravity;
     if ((gravity & Gravity.HORIZONTAL_GRAVITY_MASK) == Gravity.FILL_HORIZONTAL) {
       mParams.horizontalWeight = 1.0f;
     }
     if ((gravity & Gravity.VERTICAL_GRAVITY_MASK) == Gravity.FILL_VERTICAL) {
       mParams.verticalWeight = 1.0f;
     }
     mParams.x = mX;
     mParams.y = mY;
     mParams.verticalMargin = mVerticalMargin;
     mParams.horizontalMargin = mHorizontalMargin;
     if (mView.getParent() != null) {
       mWM.removeView(mView);
     }
     mWM.addView(mView, mParams);
     trySendAccessibilityEvent();
   }
 }

    从源码中,我们知道Toast是通过WindowManager调用addView加载进来的。因此,hide方法自然是WindowManager调用removeView方法来将Toast视图移除。
    总结一下,通过对TN类的源码分析,我们知道了TN类是回调对象,其他进程调用tn类的show和hide方法来控制这个Toast的显示和消失。
NotificationManagerService
    回到Toast类的show方法中,我们可以看到,这里调用了getService得到INotificationManager服务,源码如下:

?
1
2
3
4
5
6
7
8
9
private static INotificationManager sService;
 
static private INotificationManager getService() {
  if (sService != null) {
    return sService;
  }
  sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
  return sService;
}

    得到INotificationManager服务后,调用了enqueueToast方法将当前的Toast放入到系统的Toast队列中。传的参数分别是pkg、tn和mDuration。也就是说,我们通过Toast.makeText(context, msg, Toast.LENGTH_SHOW).show()去呈现一个Toast,这个Toast并不是立刻显示在当前的window上,而是先进入系统的Toast队列中,然后系统调用回调对象tn的show和hide方法进行Toast的显示和隐藏。
    这里INofiticationManager接口的具体实现类是NotificationManagerService类,位于frameworks/base/services/java/com/android/server/NotificationManagerService.java。
    首先,我们来分析一下Toast入队的函数实现enqueueToast,源码如下:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public void enqueueToast(String pkg, ITransientNotification callback, int duration)
{
  // packageName为null或者tn类为null,直接返回,不进队列
  if (pkg == null || callback == null) {
    return ;
  }
 
  // (1) 判断是否为系统Toast
  final boolean isSystemToast = isCallerSystem() || ("android".equals(pkg));
 
  // 判断当前toast所属的pkg是否为系统不允许发生Toast的pkg.NotificationManagerService有一个HashSet数据结构,存储了不允许发生Toast的包名
  if (ENABLE_BLOCKED_TOASTS && !noteNotificationOp(pkg, Binder.getCallingUid()) && !areNotificationsEnabledForPackageInt(pkg)) {
    if (!isSystemToast) {
      return;
    }
  }
 
  synchronized (mToastQueue) {
    int callingPid = Binder.getCallingPid();
    long callingId = Binder.clearCallingIdentity();
    try {
      ToastRecord record;
      // (2) 查看该Toast是否已经在队列当中
      int index = indexOfToastLocked(pkg, callback);
      // 如果Toast已经在队列中,我们只需要更新显示时间即可
      if (index >= 0) {
        record = mToastQueue.get(index);
        record.update(duration);
      } else {
        // 非系统Toast,每个pkg在当前mToastQueue中Toast有总数限制,不能超过MAX_PACKAGE_NOTIFICATIONS
        if (!isSystemToast) {
          int count = 0;
          final int N = mToastQueue.size();
          for (int i=0; i<N; i++) {
             final ToastRecord r = mToastQueue.get(i);
             if (r.pkg.equals(pkg)) {
               count++;
               if (count >= MAX_PACKAGE_NOTIFICATIONS) {
                 Slog.e(TAG, "Package has already posted " + count
                    + " toasts. Not showing more. Package=" + pkg);
                 return;
               }
             }
          }
        }
 
        // 将Toast封装成ToastRecord对象,放入mToastQueue中
        record = new ToastRecord(callingPid, pkg, callback, duration);
        mToastQueue.add(record);
        index = mToastQueue.size() - 1;
        // (3) 将当前Toast所在的进程设置为前台进程
        keepProcessAliveLocked(callingPid);
      }
      // (4) 如果index为0,说明当前入队的Toast在队头,需要调用showNextToastLocked方法直接显示
      if (index == 0) {
        showNextToastLocked();
      }
    } finally {
      Binder.restoreCallingIdentity(callingId);
    }
  }
}

    可以看到,我对上述代码做了简要的注释。代码相对简单,但是还有4点标注代码需要我们来进一步探讨。
    (1) 判断是否为系统Toast。如果当前Toast所属的进程的包名为“android”,则为系统Toast,否则还可以调用isCallerSystem()方法来判断。该方法的实现源码为:

 

?
1
2
3
4
5
6
7
boolean isUidSystem(int uid) {
  final int appid = UserHandle.getAppId(uid);
  return (appid == Process.SYSTEM_UID || appid == Process.PHONE_UID || uid == 0);
}
boolean isCallerSystem() {
  return isUidSystem(Binder.getCallingUid());
}

    isCallerSystem的源码也比较简单,就是判断当前Toast所属进程的uid是否为SYSTEM_UID、0、PHONE_UID中的一个,如果是,则为系统Toast;如果不是,则不为系统Toast。
    是否为系统Toast,通过下面的源码阅读可知,主要有两点优势:

    系统Toast一定可以进入到系统Toast队列中,不会被黑名单阻止。
    系统Toast在系统Toast队列中没有数量限制,而普通pkg所发送的Toast在系统Toast队列中有数量限制。

    (2) 查看将要入队的Toast是否已经在系统Toast队列中。这是通过比对pkg和callback来实现的,具体源码如下所示:

 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
private int indexOfToastLocked(String pkg, ITransientNotification callback)
{
  IBinder cbak = callback.asBinder();
  ArrayList<ToastRecord> list = mToastQueue;
  int len = list.size();
  for (int i=0; i<len; i++) {
    ToastRecord r = list.get(i);
    if (r.pkg.equals(pkg) && r.callback.asBinder() == cbak) {
      return i;
    }
  }
  return -1;
}

    通过上述代码,我们可以得出一个结论,只要Toast的pkg名称和tn对象是一致的,则系统把这些Toast认为是同一个Toast。
    (3) 将当前Toast所在进程设置为前台进程。源码如下所示:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void keepProcessAliveLocked(int pid)
{
  int toastCount = 0; // toasts from this pid
  ArrayList<ToastRecord> list = mToastQueue;
  int N = list.size();
  for (int i=0; i<N; i++) {
    ToastRecord r = list.get(i);
    if (r.pid == pid) {
      toastCount++;
    }
  }
  try {
    mAm.setProcessForeground(mForegroundToken, pid, toastCount > 0);
  } catch (RemoteException e) {
    // Shouldn't happen.
  }
}

    这里的mAm=ActivityManagerNative.getDefault(),调用了setProcessForeground方法将当前pid的进程置为前台进程,保证不会系统杀死。这也就解释了为什么当我们finish当前Activity时,Toast还可以显示,因为当前进程还在执行。
    (4) index为0时,对队列头的Toast进行显示。源码如下:

 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void showNextToastLocked() {
  // 获取队列头的ToastRecord
  ToastRecord record = mToastQueue.get(0);
  while (record != null) {
    try {
      // 调用Toast的回调对象中的show方法对Toast进行展示
      record.callback.show();
      scheduleTimeoutLocked(record);
      return;
    } catch (RemoteException e) {
      Slog.w(TAG, "Object died trying to show notification " + record.callback
          + " in package " + record.pkg);
      // remove it from the list and let the process die
      int index = mToastQueue.indexOf(record);
      if (index >= 0) {
        mToastQueue.remove(index);
      }
      keepProcessAliveLocked(record.pid);
      if (mToastQueue.size() > 0) {
        record = mToastQueue.get(0);
      } else {
        record = null;
      }
    }
  }
}

    这里Toast的回调对象callback就是tn对象。接下来,我们看一下,为什么系统Toast的显示时间只能是2s或者3.5s,关键在于scheduleTimeoutLocked方法的实现。原理是,调用tn的show方法展示完Toast之后,需要调用scheduleTimeoutLocked方法来将Toast消失。(如果大家有疑问:不是说tn对象的hide方法来将Toast消失,为什么要在这里调用scheduleTimeoutLocked方法将Toast消失呢?是因为tn类的hide方法一执行,Toast立刻就消失了,而平时我们所使用的Toast都会在当前Activity停留几秒。如何实现停留几秒呢?原理就是scheduleTimeoutLocked发送MESSAGE_TIMEOUT消息去调用tn对象的hide方法,但是这个消息会有一个delay延迟,这里也是用了Handler消息机制)。

 

?
1
2
3
4
5
6
7
8
9
private static final int LONG_DELAY = 3500; // 3.5 seconds
private static final int SHORT_DELAY = 2000; // 2 seconds
private void scheduleTimeoutLocked(ToastRecord r)
{
  mHandler.removeCallbacksAndMessages(r);
  Message m = Message.obtain(mHandler, MESSAGE_TIMEOUT, r);
  long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;
  mHandler.sendMessageDelayed(m, delay);
}

    首先,我们看到这里并不是直接发送了MESSAGE_TIMEOUT消息,而是有个delay的延迟。而delay的时间从代码中“long delay = r.duration == Toast.LENGTH_LONG ? LONG_DELAY : SHORT_DELAY;”看出只能为2s或者3.5s,这也就解释了为什么系统Toast的呈现时间只能是2s或者3.5s。自己在Toast.makeText方法中随意传入一个duration是无作用的。
    接下来,我们来看一下WorkerHandler中是如何处理MESSAGE_TIMEOUT消息的。mHandler对象的类型为WorkerHandler,源码如下:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
private final class WorkerHandler extends Handler
{
  @Override
  public void handleMessage(Message msg)
  {
    switch (msg.what)
    {
      case MESSAGE_TIMEOUT:
        handleTimeout((ToastRecord)msg.obj);
        break;
    }
  }
}

    可以看到,WorkerHandler对MESSAGE_TIMEOUT类型的消息处理是调用了handlerTimeout方法,那我们继续跟踪handleTimeout源码:

?
1
2
3
4
5
6
7
8
9
private void handleTimeout(ToastRecord record)
{
  synchronized (mToastQueue) {
    int index = indexOfToastLocked(record.pkg, record.callback);
    if (index >= 0) {
      cancelToastLocked(index);
    }
  }
}

    handleTimeout代码中,首先判断当前需要消失的Toast所属ToastRecord对象是否在队列中,如果在队列中,则调用cancelToastLocked(index)方法。真相就要浮现在我们眼前了,继续跟踪源码:

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void cancelToastLocked(int index) {
  ToastRecord record = mToastQueue.get(index);
  try {
    record.callback.hide();
  } catch (RemoteException e) {
    // don't worry about this, we're about to remove it from
    // the list anyway
  }
  mToastQueue.remove(index);
  keepProcessAliveLocked(record.pid);
  if (mToastQueue.size() > 0) {
    // Show the next one. If the callback fails, this will remove
    // it from the list, so don't assume that the list hasn't changed
    // after this point.
    showNextToastLocked();
  }
}

    哈哈,看到这里,我们回调对象的hide方法也被调用了,同时也将该ToastRecord对象从mToastQueue中移除了。到这里,一个Toast的完整显示和消失就讲解结束了。

标签:

相关文章

热门资讯

玄元剑仙肉身有什么用 玄元剑仙肉身境界等级划分
玄元剑仙肉身有什么用 玄元剑仙肉身境界等级划分 2019-06-21
男生常说24816是什么意思?女生说13579是什么意思?
男生常说24816是什么意思?女生说13579是什么意思? 2019-09-17
配置IIS网站web服务器的安全策略配置解决方案
配置IIS网站web服务器的安全策略配置解决方案 2019-05-23
华为nova5pro和p30pro哪个好 华为nova5pro和华为p30pro对比详情
华为nova5pro和p30pro哪个好 华为nova5pro和华为p30pro对比详情 2019-06-22
Nginx服务器究竟是怎么执行PHP项目
Nginx服务器究竟是怎么执行PHP项目 2019-05-24
返回顶部