本文系作者原创,转载请附原文地址:http://blog.csdn.net/a774057695/article/details/50619599
也要先致歉一下,行文的时候是在word中编辑的,拉上来的时候也不高兴调格式惹。。。
呵呵哒:
这一篇略显无聊,这么说是有道理的,toast这东西真的没什么好讲的,扒着指头数数也就这么几样东西:
1. 自定义样式
2. 自定义位置
3. 主线程中调用show()方法显示,非主线程需要开启looper
然而还是想拉出来溜一圈,为什么呢,测试总是很疯狂,甚至让你有不想使用toast的想法。然而toast又是那么友好的一个widget,在界面上重写一个提示内容显示区又不是产品设计师要的效果。
关键问题呢,是toast实在是太简单太基础了,以至于忘掉要去看一下它的源码。
说到这里要提一下为什么要写这个的原因了,近期手头上有个项目,测试总是吐槽,我这里点了很多下,提示语怎么不消失……大概隔了一分钟吧,提示语没了。内心满满的崩溃,屏幕和你有仇啊,点几十下!!!
总之就是想让我们改,但是我不想改,因为改了也没什么意思啊,关键是没有明确的预期效果。产品设计的时候确实会有部分时间间隔很小的连续提示(虽然应该去避免这些问题,可咱不想搞产品的活)。
可能有些朋友已经想说重写一个类似toast的widget了,奉劝你不要。因为没有明确的需求。
正文:
这里直接进入正题:从设计的角度来说,为了使得交互更加友好,应当将一些重要的信息反馈给用户,按照信息的重要性(以及牵涉到的后续操作),一般有三种提示形式:
1. toast
2. 视图上安置一块信息显示区
3. dialog形式
这三种形式中,1和2对用户的打扰程度是较小的,dialog的打扰程度很大,一般来说,当你必须要用户来做出决定时才会使用dialog形式。
而1和2相比较:Toast维护较为方便,2需要在布局上进行额外的处理,而2的优点在于,开发者可以根据实际需求来维护提示的显示状态。
一般而言,2使用的并不是很多,尽管他的样式可以和应用的主题很相近,原因在于你需要在布局上额外设计一个显示区,比较费神,往往登录界面(或者说需要验证用户输入)的场景才会使用。
而toast的一个问题就是,系统会将toast放置到队列中然后维护其显示。所以出现了测试人员疯狂的点击(产生同样的toast),这条toast会显示很长时间。所以,我们真正的需求(或者封住测试的嘴巴的方案)就是在显示过程中如果紧跟着出现了相同的toast,就不要显示了(这条toast可能是反复操作导致的,让它滚蛋,而其他内容的toast还是保守一些,让它去排队吧)。
然而这个需求想直接使用toast的代码来实现还是不怎么可行的。
可能有些人会有这样一种思路:取消之前的显示,更新新的显示。首先给个粗糙的结论,体验可能不会太好。他们想的是cancel()方法来简单粗暴的取消一个toast的显示,然而这样会带来还没看完,刷新了的问题,以及遗漏一些提示。
可能您已经对toast的源码感兴趣了,我们先看一下api中大致的内容,再看源码,再看看如何实现我们的目的
Constants
int
Show the view or text notification for a long period of time.
int
Show the view or text notification for a short period of time.
Public Constructors
Construct an empty Toast object.
Public Methods
void
cancel()
Close the view if it's showing, or don't show it if it isn't showing yet.
int
Return the duration.
int
Get the location at which the notification should appear on the screen.
float
Return the horizontal margin.
float
Return the vertical margin.
getView()
Return the view.
int
Return the X offset in pixels to apply to the gravity's location.
int
Return the Y offset in pixels to apply to the gravity's location.
static Toast
makeText(Context context, int resId, int duration)
Make a standard toast that just contains a text view with the text from a resource.
static Toast
makeText(Context context, CharSequence text, int duration)
Make a standard toast that just contains a text view.
void
setDuration(int duration)
Set how long to show the view for.
void
setGravity(int gravity, int xOffset, int yOffset)
Set the location at which the notification should appear on the screen.
void
setMargin(float horizontalMargin, float verticalMargin)
Set the margins of the view.
void
setText(int resId)
Update the text in a Toast that was previously created using one of the makeText() methods.
void
Update the text in a Toast that was previously created using one of the makeText() methods.
void
Set the view to show.
void
show()
Show the view for the specified duration.
内容真的很少,set和get一堆属性,maketext是利用几个重要的参数构建并返回一个toast实例,show()方法和cancel()方法。那我们看源码,分析一下toast的显示和消失。
/*
* Copyright (C) 2007 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.widget;
import android.app.INotificationManager;
import android.app.ITransientNotification;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.PixelFormat;
import android.os.Handler;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowManagerImpl;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
/**
* A toast is a view containing a quick little message for the user. The toast class
* helps you create and show those.
* {@more}
*
* <p>
* When the view is shown to the user, appears as a floating view over the
* application. It will never receive focus. The user will probably be in the
* middle of typing something else. The idea is to be as unobtrusive as
* possible, while still showing the user the information you want them to see.
* Two examples are the volume control, and the brief message saying that your
* settings have been saved.
* <p>
* The easiest way to use this class is to call one of the static methods that constructs
* everything you need and returns a new Toast object.
*
* <div class="special reference">
* <h3>Developer Guides</h3>
* <p>For information about creating Toast notifications, read the
* <a href="{@docRoot}guide/topics/ui/notifiers/toasts.html">Toast Notifications</a> developer
* guide.</p>
* </div>
*/
public class Toast {
static final String TAG = "Toast";
static final boolean localLOGV = false;
/**
* Show the view or text notification for a short period of time. This time
* could be user-definable. This is the default.
* @see #setDuration
*/
public static final int LENGTH_SHORT = 0;
/**
* Show the view or text notification for a long period of time. This time
* could be user-definable.
* @see #setDuration
*/
public static final int LENGTH_LONG = 1;
final Context mContext;
final TN mTN;
int mDuration;
View mNextView;
/**
* Construct an empty Toast object. You must call {@link #setView} before you
* can call {@link #show}.
*
* @param context The context to use. Usually your {@link android.app.Application}
* or {@link android.app.Activity} object.
*/
public Toast(Context context) {
mContext = context;
mTN = new TN();
mTN.mY = context.getResources().getDimensionPixelSize(
com.android.internal.R.dimen.toast_y_offset);
}
/**
* Show the view for the specified duration.
*/
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
}
}
/**
* Close the view if it's showing, or don't show it if it isn't showing yet.
* You do not normally have to call this. Normally view will disappear on its own
* after the appropriate duration.
*/
public void cancel() {
mTN.hide();
// TODO this still needs to cancel the inflight notification if any
}
/**
* Set the view to show.
* @see #getView
*/
public void setView(View view) {
mNextView = view;
}
/**
* Return the view.
* @see #setView
*/
public View getView() {
return mNextView;
}
/**
* Set how long to show the view for.
* @see #LENGTH_SHORT
* @see #LENGTH_LONG
*/
public void setDuration(int duration) {
mDuration = duration;
}
/**
* Return the duration.
* @see #setDuration
*/
public int getDuration() {
return mDuration;
}
/**
* Set the margins of the view.
*
* @param horizontalMargin The horizontal margin, in percentage of the
* container width, between the container's edges and the
* notification
* @param verticalMargin The vertical margin, in percentage of the
* container height, between the container's edges and the
* notification
*/
public void setMargin(float horizontalMargin, float verticalMargin) {
mTN.mHorizontalMargin = horizontalMargin;
mTN.mVerticalMargin = verticalMargin;
}
/**
* Return the horizontal margin.
*/
public float getHorizontalMargin() {
return mTN.mHorizontalMargin;
}
/**
* Return the vertical margin.
*/
public float getVerticalMargin() {
return mTN.mVerticalMargin;
}
/**
* Set the location at which the notification should appear on the screen.
* @see android.view.Gravity
* @see #getGravity
*/
public void setGravity(int gravity, int xOffset, int yOffset) {
mTN.mGravity = gravity;
mTN.mX = xOffset;
mTN.mY = yOffset;
}
/**
* Get the location at which the notification should appear on the screen.
* @see android.view.Gravity
* @see #getGravity
*/
public int getGravity() {
return mTN.mGravity;
}
/**
* Return the X offset in pixels to apply to the gravity's location.
*/
public int getXOffset() {
return mTN.mX;
}
/**
* Return the Y offset in pixels to apply to the gravity's location.
*/
public int getYOffset() {
return mTN.mY;
}
/**
* Make a standard toast that just contains a text view.
*
* @param context The context to use. Usually your {@link android.app.Application}
* or {@link android.app.Activity} object.
* @param text The text to show. Can be formatted text.
* @param duration How long to display the message. Either {@link #LENGTH_SHORT} or
* {@link #LENGTH_LONG}
*
*/
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;
}
/**
* Make a standard toast that just contains a text view with the text from a resource.
*
* @param context The context to use. Usually your {@link android.app.Application}
* or {@link android.app.Activity} object.
* @param resId The resource id of the string resource to use. Can be formatted text.
* @param duration How long to display the message. Either {@link #LENGTH_SHORT} or
* {@link #LENGTH_LONG}
*
* @throws Resources.NotFoundException if the resource can't be found.
*/
public static Toast makeText(Context context, int resId, int duration)
throws Resources.NotFoundException {
return makeText(context, context.getResources().getText(resId), duration);
}
/**
* Update the text in a Toast that was previously created using one of the makeText() methods.
* @param resId The new text for the Toast.
*/
public void setText(int resId) {
setText(mContext.getText(resId));
}
/**
* Update the text in a Toast that was previously created using one of the makeText() methods.
* @param s The new text for the Toast.
*/
public void setText(CharSequence s) {
if (mNextView == null) {
throw new RuntimeException("This Toast was not created with Toast.makeText()");
}
TextView tv = (TextView) mNextView.findViewById(com.android.internal.R.id.message);
if (tv == null) {
throw new RuntimeException("This Toast was not created with Toast.makeText()");
}
tv.setText(s);
}
// =======================================================================================
// All the gunk below is the interaction with the Notification Service, which handles
// the proper ordering of these system-wide.
// =======================================================================================
private static INotificationManager sService;
static private INotificationManager getService() {
if (sService != null) {
return sService;
}
sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
return sService;
}
private static class TN extends ITransientNotification.Stub {
final Runnable mShow = new Runnable() {
public void run() {
handleShow();
}
};
final Runnable mHide = new Runnable() {
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 = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM;
int mX, mY;
float mHorizontalMargin;
float mVerticalMargin;
View mView;
View mNextView;
WindowManagerImpl 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.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
params.format = PixelFormat.TRANSLUCENT;
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
}
/**
* schedule handleShow into the right thread
*/
public void show() {
if (localLOGV) Log.v(TAG, "SHOW: " + this);
mHandler.post(mShow);
}
/**
* schedule handleHide into the right thread
*/
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;
mWM = WindowManagerImpl.getDefault();
final int gravity = mGravity;
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;
}
}
}
}
从show方法和cancel方法,我们发现了有个内部类TN,成员函数中有如下两个:
/**
* schedule handleShow into the right thread
*/
publicvoid show() {
if (localLOGV) Log.v(TAG,"SHOW: " + this);
mHandler.post(mShow);
}
/**
* schedule handleHide into the right thread
*/
publicvoid hide() {
if (localLOGV) Log.v(TAG,"HIDE: " + this);
mHandler.post(mHide);
}
分别对应这一个toast的显示和消失(隐藏),使用了hanler,关键之处在于mShow和mHide两个Runnable接口实例的内容。
final RunnablemShow = new Runnable() {
publicvoid run() {
handleShow();
}
};
final RunnablemHide = new Runnable() {
publicvoid run() {
handleHide();
// Don't do this in handleHide() because it is also invoked by handleShow()
mNextView = null;
}
};
publicvoid 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;
mWM = WindowManagerImpl.getDefault();
finalint gravity = mGravity;
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();
}
}
privatevoid 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);
}
publicvoid 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;
}
}
取出最重要的部分:
mWM = WindowManagerImpl.getDefault();
//显示
mWM.addView(mView,mParams);
//隐藏
if (mView.getParent() !=null) {
if (localLOGV) Log.v(TAG,"REMOVE! " + mView +" in " + this);
mWM.removeView(mView);
}
再把话头转回来,显示的时候,调用了
service.enqueueToast(pkg, tn, mDuration);
这里呢,和大家先道个歉,刚才实在是太在意cancel了,理应先看show的。这个service从哪里来的呢?
INotificationManager service = getService();
//
staticprivate INotificationManager getService() {
if (sService !=null) {
returnsService;
}
sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
returnsService;
}
这样我们就知道了,用AIDL定义了一个接口,
/** @hide */
oneway interface ITransientNotification {
void show();
void hide();
}
而他的实现,没错,就是在TN类。所以说,显示使用了IPC,而且,这么多年的经验不看更深的源码都知道这个toast被放到queue中了。同样,隐藏也是需要用到ipc的,而且应当有回调的。
好了,先不看源码了。我们之前说简单粗暴的cancel掉体验可能不好,我们可以这样尝试:
/**
* @ClassName: MToast
* @Description:TODO
* @date 2016年2月1日下午1:26:43
*
* @author leobert.lan
* @version 1.0
*/
publicclass MToast {
privatestatic String preMsg ="";
privatefinal staticint shortTime = 2000;
privatefinal staticint longTime = 3500;
privatestatic booleanonShowing = false;
privatestatic Object syncLock =new Object();
privatestatic ArrayList<ToastJob> toastList =new ArrayList<ToastJob>();
@SuppressLint("ShowToast")
publicstatic void show(Context c,int sRid, Duration d) {
int duration = 0;
long time = 0;
switch (d) {
casel:
duration = Toast.LENGTH_LONG;
time = longTime;
break;
cases:
duration = Toast.LENGTH_SHORT;
time = shortTime;
break;
default:
break;
}
String msg = c.getResources().getString(sRid);
if (msg.equals(preMsg) &&onShowing)
return;
preMsg = msg;
ToastJob job = new ToastJob();
job.setToast(Toast.makeText(c, msg, duration));
job.setTime(time);
synchronized (syncLock) {
toastList.add(job);
callShow();
}
}
privatestatic synchronizedvoid callShow() {
synchronized (syncLock) {
if (!onShowing &&toastList.size() > 0) {
toastList.get(0).getToast().show();
onShowing =true;
new Handler().postDelayed(new Runnable() {
@Override
publicvoid run() {
onShowing =false;
callShow();
}
}, toastList.get(0).getTime());
toastList.remove(0);
}
}
}
publicenum Duration {
l,s;
}
staticclass ToastJob {
private Toasttoast;
privatelong time;
public Toast getToast() {
returntoast;
}
publicvoid setToast(Toast toast) {
this.toast = toast;
}
publiclong getTime() {
returntime;
}
publicvoid setTime(long time) {
this.time = time;
}
}
}
代码不长,一眼就看明白了,需要注意的是,只有在Toast已经显示,同时当前新增的toast内容和上一条一致时,我们才忽略它。另外,在非UI线程调用的时候注意Looper。
然后呢,就突然衍生了一个自定义显示时长的toast的想法:
import java.util.Timer;
import java.util.TimerTask;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.PixelFormat;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.widget.Toast;
/**
* @ClassName: CustomToast
* @Description:TODO
* @date 2016年2月1日下午4:40:21
*
* @author leobert.lan
* @version 1.0
*/
publicclass CustomToast {
private WindowManagermWM;
/**
* time:自定义的显示时间
*/
privatelong time;
/**
* mView:toast的view
*/
private ViewmView;
/**
* params:toast的布局参数
*/
private WindowManager.LayoutParamsparams;
/**
* timer:计时任务,控制
*/
private Timertimer;
@SuppressLint("ShowToast")
private CustomToast(Context context, String text,long time) {
mWM = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
timer =new Timer();
Toast toast = Toast.makeText(context, text, Toast.LENGTH_SHORT);
mView = toast.getView();
params =new WindowManager.LayoutParams();
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
params.windowAnimations = toast.getView().getAnimation().INFINITE;
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
params.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM;
params.y = 100;
this.time = time;
}
publicstatic CustomToast makeText(Context context, String text,long time) {
CustomToast toastCustom = new CustomToast(context, text, time);
return toastCustom;
}
publicvoid show() {
mWM.addView(mView,params);
timer.schedule(new TimerTask() {
@Override
publicvoid run() {
mWM.removeView(mView);
}
}, time);
}
publicvoid cancel() {
mWM.removeView(mView);
timer.cancel();
}
}
可以发现,基本都是从toast中抄出来的。先写个模板放在这里,根据实际需求,我们还能玩很大花样,(毕竟对toast类我们玩不了什么花样了)
就到这里吧,本文系作者原创,转载请附原文地址:http://blog.csdn.net/a774057695/article/details/50619599
Android Toast 解析以及减少“无意义的”toast
最新推荐文章于 2024-04-07 22:47:53 发布