Bubbles原理解析

官方文档

https://developer.android.com/develop/ui/views/notifications/bubbles#the_bubble_api

气泡使用户可以轻松查看和参与对话。
气泡内置于通知系统中。 它们漂浮在其他应用程序内容之上,无论用户走到哪里都会跟随他们。 气泡可以展开以显示应用程序功能和信息,并且可以在不使用时折叠。
当设备被锁定或始终显示处于活动状态时,气泡会像通常的通知一样出现。
气泡是一种选择退出功能。 当应用程序显示第一个气泡时,会显示一个权限对话框,其中提供两种选择:

  • 阻止您应用中的所有气泡 - 通知不会被阻止,但它们永远不会显示为气泡
  • 允许您应用中的所有气泡 - 所有使用 BubbleMetaData 发送的通知都将显示为气泡

https://developer.android.com/static/images/guide/topics/ui/bubbles-demo.mp4

气泡API

气泡是通过 Notification API 创建的,因此您可以照常发送通知。如果您希望让通知显示为气泡,则需要为其附加一些额外的数据。
气泡的展开视图是根据您选择的 Activity 创建的。此 Activity 需要经过配置才能正确显示为气泡。此 Activity 必须可以调整大小且是嵌入式的。只要 Activity 不满足其中任何一项要求,都会显示为通知。
以下代码演示了如何实现简单的气泡:

<activity
  android:name=".bubbles.BubbleActivity"
  android:theme="@style/AppTheme.NoActionBar"
  android:label="@string/title_activity_bubble"
  android:allowEmbedded="true"
  android:resizeableActivity="true"
  />

如果您的应用需要显示多个相同类型的气泡(例如与不同联系人的多个聊天对话),此 Activity 必须能够启动多个实例。在搭载 Android 10 的设备上,除非您将 documentLaunchMode 明确设置为 “always”,否则通知不会显示为气泡。从 Android 11 开始,您无需明确设置此值,因为系统会自动将所有对话的 documentLaunchMode 设置为 “always”。
如需发送气泡,请按照以下步骤操作:

注意:第一次发送通知以显示气泡时,它必须位于 IMPORTANCE_MIN 或更高级别的通知渠道中。

如果在发送气泡时您的应用程序在前台,重要性将被忽略并且您的气泡将始终显示(除非用户已阻止来自您的应用程序的气泡或通知)。

// Create bubble intent
Intent target = new Intent(mContext, BubbleActivity.class);
PendingIntent bubbleIntent =
    PendingIntent.getActivity(mContext, 0, target, 0 /* flags */);

private val CATEGORY_TEXT_SHARE_TARGET =
    "com.example.category.IMG_SHARE_TARGET"

Person chatPartner = new Person.Builder()
        .setName("Chat partner")
        .setImportant(true)
        .build();

// 创建共享快捷方式
private String shortcutId = generateShortcutId();
ShortcutInfo shortcut =
    new ShortcutInfo.Builder(mContext, shortcutId)
    .setCategories(Collections.singleton(CATEGORY_TEXT_SHARE_TARGET))
    .setIntent(Intent(Intent.ACTION_DEFAULT))
    .setLongLived(true)
    .setShortLabel(chatPartner.getName())
    .build();

// Create bubble metadata
Notification.BubbleMetadata bubbleData =
    new Notification.BubbleMetadata.Builder(bubbleIntent,
                                            Icon.createWithResource(context, R.drawable.icon))
    .setDesiredHeight(600)
    .build();

// Create notification, referencing the sharing shortcut
Notification.Builder builder =
    new Notification.Builder(mContext, CHANNEL_ID)
    .setContentIntent(contentIntent)
    .setSmallIcon(smallIcon)
    .setBubbleMetadata(bubbleData)
    .setShortcutId(shortcutId)
    .addPerson(chatPartner);

创建展开的气泡

您可以将气泡配置为自动以展开状态显示。我们建议您仅在用户执行会导致显示气泡的操作(例如点按按钮以开始新的聊天)时才使用此功能。在这种情况下,还有必要禁止显示在创建气泡时发送的初始通知。
您可以使用以下方法设置启用这些行为的标志:setAutoExpandBubble()setSuppressNotification()

Notification.BubbleMetadata bubbleData =
    new Notification.BubbleMetadata.Builder()
        .setDesiredHeight(600)
        .setIntent(bubbleIntent)
        .setAutoExpandBubble(true)
        .setSuppressNotification(true)
        .build();
气泡内容生命周期

如果展开气泡,内容 Activity 会完成常规进程生命周期,这会使应用成为前台进程(如果应用尚未在前台运行)。
如果收起或关闭气泡,系统会销毁此 Activity。这可能导致系统缓存此进程,然后将其终止,具体取决于应用是否有任何其他前台组件正在运行。

何时显示气泡

为减少对用户的干扰,气泡仅在特定情况下显示。
如果应用以 Android 11 或更高版本为目标平台,那么除非通知符合对话要求,否则将不会显示为气泡。如果应用以 Android 10 为目标平台,那么仅在满足以下一个或多个条件时,通知才会显示为气泡:

如果上述条件均不满足,系统就会显示通知而不显示气泡。

最佳做法

  • 气泡会占用屏幕空间并遮盖其他应用内容。仅当非常需要显示气泡(例如对于进行中的通信)或用户明确要求为某些内容显示气泡时,才将通知发送为气泡。
  • 请注意,用户可以停用气泡。在这种情况下,气泡通知会显示为一般通知。您应该始终确保您的气泡通知也可以作为一般通知使用。
  • 从气泡启动的进程(例如 activity 和对话框)会显示在气泡容器中。这意味着气泡可以有任务堆栈。如果您的气泡中有很多功能或导航,情况就会变得很复杂。建议您尽量让功能保持具体且简明。
  • 确保在气泡 Activity 中替换 onBackPressed 时调用 super.onBackPressed;否则,气泡可能会无法正常运行。
  • 当气泡在收起后收到更新的消息时,气泡会显示一个标志图标,表示有未读消息。当用户在关联的应用中打开消息时,请按以下步骤操作:

示例应用

People 示例应用是一个使用气泡的简单对话式应用。出于演示目的,此应用使用聊天机器人。在真实的应用中,气泡应仅用于人类发送的消息,而不用于聊天机器人发送的消息。

气泡是一种特殊类型的内容,可以“漂浮”在其他应用程序或系统 UI 之上。
可以展开气泡以显示更多内容。
控制器管理屏幕上气泡的添加、移除和可见状态。

布局分析

在这里插入图片描述
在这里插入图片描述

在根布局BubbleStackView添加时先创建添加BubbleExpandedView并初始化添加BubbleOverflowContainerView

     // 在这里初始化{@link BubbleController}和{@link BubbleStackView},
	// 这个方法必须在view inflate之后调用。
    void initialize(BubbleController controller, BubbleStackView stackView, boolean isOverflow) {
        mController = controller;
        mStackView = stackView;
        mIsOverflow = isOverflow;
        mPositioner = mController.getPositioner();
    	// 首次先添加右侧的BubbleOverflowContainerView相关的
        if (mIsOverflow) {
            mOverflowView = (BubbleOverflowContainerView) LayoutInflater.from(getContext()).inflate(
                    R.layout.bubble_overflow_container, null /* root */);
            mOverflowView.setBubbleController(mController);
            FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT);
            mExpandedViewContainer.addView(mOverflowView, lp);
            mExpandedViewContainer.setLayoutParams(
                    new LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT));
            bringChildToFront(mOverflowView);
            mManageButton.setVisibility(GONE);
        } ......
    }

左侧页面的两个BadgedImageView加载和BubbleExpandedView添加

        @VisibleForTesting
        @Nullable
        public static BubbleViewInfo populate(Context c, BubbleController controller,
                BubbleStackView stackView, BubbleIconFactory iconFactory,
                BubbleBadgeIconFactory badgeIconFactory, Bubble b,
                boolean skipInflation) {
            BubbleViewInfo info = new BubbleViewInfo();

            // View inflation: only should do this once per bubble
            if (!skipInflation && !b.isInflated()) {
                LayoutInflater inflater = LayoutInflater.from(c);
                info.imageView = (BadgedImageView) inflater.inflate(
                        R.layout.bubble_view, stackView, false /* attachToRoot */);
                info.imageView.initialize(controller.getPositioner());

                info.expandedView = (BubbleExpandedView) inflater.inflate(
                        R.layout.bubble_expanded_view, stackView, false /* attachToRoot */);
                info.expandedView.initialize(controller, stackView, false /* isOverflow */);
            }

添加TaskView

     // 在这里初始化{@link BubbleController}和{@link BubbleStackView},
	// 这个方法必须在view inflate之后调用。
    void initialize(BubbleController controller, BubbleStackView stackView, boolean isOverflow) {
        mController = controller;
        mStackView = stackView;
        mIsOverflow = isOverflow;
        mPositioner = mController.getPositioner();
    	// 首次先添加右侧的BubbleOverflowContainerView相关的
        if (mIsOverflow) {
        	......
        } else {
            // 添加TaskView
            mTaskView = new TaskView(mContext, mController.getTaskOrganizer(),
                    mController.getTaskViewTransitions(), mController.getSyncTransactionQueue());
            mTaskView.setListener(mController.getMainExecutor(), mTaskViewListener);
            mExpandedViewContainer.addView(mTaskView);
            bringChildToFront(mTaskView);
        }
    }

代码分析

窗口类型:应用程序覆盖窗口显示在所有活动窗口上方({@link #FIRST_APPLICATION_WINDOW} 和 {@link #LAST_APPLICATION_WINDOW} 之间的类型)但在状态栏或 IME 等关键系统窗口下方。
系统可以随时更改这些窗口的位置、大小或可见性,以减少用户的视觉混乱并管理资源。
需要 {@link android.Manifest.permission#SYSTEM_ALERT_WINDOW} 权限。
系统将调整具有这种窗口类型的进程的重要性,以减少低内存杀手杀死它们的机会。
在多用户系统中,仅在拥有用户的屏幕上显示。

public static final int TYPE_APPLICATION_OVERLAY = FIRST_SYSTEM_WINDOW + 38;

窗口标志:此窗口永远不会获得按键输入焦点,因此用户无法向其发送按键或其他按钮事件。 那些将转到它后面的任何可聚焦窗口。 此标志还将启用 {@link #FLAG_NOT_TOUCH_MODAL},无论是否明确设置。
设置此标志还意味着窗口不需要与软输入法交互,因此它将独立于任何活动输入法进行 Z 排序和定位(通常这意味着它在输入法之上获得 Z 排序, 因此它可以使用全屏显示内容并在需要时覆盖输入法。您可以使用 {@link #FLAG_ALT_FOCUSABLE_IM} 修改此行为。

public static final int FLAG_NOT_FOCUSABLE      = 0x00000008;

窗口标志:即使此窗口可聚焦(其 {@link #FLAG_NOT_FOCUSABLE} 未设置),也允许将窗口外的任何指针事件发送到其后面的窗口。 否则它将自己消耗所有指针事件,无论它们是否在窗口内。

public static final int FLAG_NOT_TOUCH_MODAL    = 0x00000020;

指定窗口应被视为受信任的系统覆盖。 在考虑输入调度期间窗口是否被遮挡时,可信系统覆盖将被忽略。 需要 {@link android.Manifest.permission#INTERNAL_SYSTEM_WINDOW} 权限。
{@see android.view.MotionEvent#FLAG_WINDOW_IS_OBSCURED}
{@see android.view.MotionEvent#FLAG_WINDOW_IS_PARTIALLY_OBSCURED}

public void setTrustedOverlay() {
    privateFlags |= PRIVATE_FLAG_TRUSTED_OVERLAY;
}

指定此窗口在布局期间应避免重叠的插图类型。
@param 类型{@link WindowInsets.Type} 此窗口应避免的插入。
该对象的初始值包括所有系统栏。

public void setFitInsetsTypes(@InsetsType int types) {
    mFitInsetsTypes = types;
    privateFlags |= PRIVATE_FLAG_FIT_INSETS_CONTROLLED;
}

{@link #softInputMode} 的调整选项:设置为允许在显示输入法时调整窗口大小,使其内容不被输入法覆盖。 这不能与 {@link #SOFT_INPUT_ADJUST_PAN} 结合使用; 如果这些都没有设置,那么系统将根据窗口的内容尝试选择一个或另一个。 如果窗口的布局参数标志包括 {@link #FLAG_FULLSCREEN},{@link #softInputMode} 的这个值将被忽略; 窗口不会调整大小,但会保持全屏。
@deprecated 使用 {@code false} 调用 {@link Window#setDecorFitsSystemWindows(boolean)} 并在适合类型 {@link Type#ime()} 的插入的根内容视图上安装 {@link OnApplyWindowInsetsListener}。

@Deprecated
public static final int SOFT_INPUT_ADJUST_RESIZE = 0x10;

始终允许窗口延伸到屏幕所有边缘的 {@link DisplayCutout} 区域。
窗口必须确保没有重要内容与 {@link DisplayCutout} 重叠。
在此模式下,无论窗口是否隐藏系统栏,窗口都会在纵向和横向显示的所有边缘上的切口下延伸。

public static final int LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS = 3;
添加根布局BubbleStackView

添加全屏的气泡窗口到屏幕上,mStackView是一个FrameLayout。

BubbleStackView 是在第一次添加 Bubble 时通过此方法延迟创建的。 此方法初始化堆栈视图并将其添加到窗口管理器。

    private void ensureStackViewCreated() {
        if (mStackView == null) {
            // 创建bubble根布局
            mStackView = new BubbleStackView(
                    mContext, this, mBubbleData, mSurfaceSynchronizer, mFloatingContentCoordinator,
                    mMainExecutor);
            mStackView.onOrientationChanged();
            if (mExpandListener != null) {
                mStackView.setExpandListener(mExpandListener);
            }
            mStackView.setUnbubbleConversationCallback(mSysuiProxy::onUnbubbleConversation);
        }
        addToWindowManagerMaybe();
    }
    /** Adds the BubbleStackView to the WindowManager if it's not already there. */
    private void addToWindowManagerMaybe() {
        // If the stack is null, or already added, don't add it.
        if (mStackView == null || mAddedToWindowManager) {
            return;
        }

        mWmLayoutParams = new WindowManager.LayoutParams(
            	// 填满屏幕,以便我们可以使用平移动画来定位气泡堆栈。 
                // 我们将使用可触摸区域来忽略不在气泡本身上的触摸。
                ViewGroup.LayoutParams.MATCH_PARENT,
                ViewGroup.LayoutParams.MATCH_PARENT,
                WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                        | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
                        | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
                PixelFormat.TRANSLUCENT);

        mWmLayoutParams.setTrustedOverlay();
        mWmLayoutParams.setFitInsetsTypes(0);
        mWmLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
        mWmLayoutParams.token = new Binder();
        mWmLayoutParams.setTitle("Bubbles!");
        mWmLayoutParams.packageName = mContext.getPackageName();
        mWmLayoutParams.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
        mWmLayoutParams.privateFlags |= WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS;

        try {
            mAddedToWindowManager = true;
            registerBroadcastReceiver();
            mBubbleData.getOverflow().initialize(this);
            mWindowManager.addView(mStackView, mWmLayoutParams);
            mStackView.setOnApplyWindowInsetsListener((view, windowInsets) -> {
                if (!windowInsets.equals(mWindowInsets)) {
                    mWindowInsets = windowInsets;
                    mBubblePositioner.update();
                    mStackView.onDisplaySizeChanged();
                }
                return windowInsets;
            });
        } catch (IllegalStateException e) {
            // This means the stack has already been added. This shouldn't happen...
            e.printStackTrace();
        }
    }
添加并显示Task

View that can display a task.

  1. 已完成初始化,直接将Task对应的surfacecontrol挂在TaskView的surfacecontrol下面并显示
  2. 否则,先启动对应的activity完成初始化,等Task创建完成在onTaskAppeared方法中完成1中操作
    @Override
    public void surfaceCreated(SurfaceHolder holder) {
        mSurfaceCreated = true;
        if (mListener != null && !mIsInitialized) {
            mIsInitialized = true;
            // 未初始化,先启动activity
            mListenerExecutor.execute(() -> {
                mListener.onInitialized();
            });
        }
        mShellExecutor.execute(() -> {
            if (mTaskToken == null) {
                // Nothing to update, task is not yet available
                return;
            }
            if (isUsingShellTransitions()) {
                mTaskViewTransitions.setTaskViewVisible(this, true /* visible */);
                return;
            }
            // 将Task的surfacecontrol从先有层级剥离,挂在TaskView(SurfaceView的子类)下面
            // Reparent the task when this surface is created
            mTransaction.reparent(mTaskLeash, getSurfaceControl())
                    .show(mTaskLeash)
                    .apply();
            updateTaskVisibility();
        });
    }

展开气泡视图的容器,处理呈现插入符号和设置图标。

 @Override
public void onInitialized() {

    if (mDestroyed || mInitialized) {
        return;
    }

    // 自定义选项,因此没有activity过渡动画
    ActivityOptions options = ActivityOptions.makeCustomAnimation(getContext(),0 /* enterResId */, 0 /* exitResId */);

    Rect launchBounds = new Rect();
    mTaskView.getBoundsOnScreen(launchBounds);

    // TODO: I notice inconsistencies in lifecycle
    // Post to keep the lifecycle normal
    post(() -> {
        try {
            options.setTaskAlwaysOnTop(true);
            options.setLaunchedFromBubble(true);
            if (!mIsOverflow && mBubble.hasMetadataShortcutId()) {
                options.setApplyActivityFlagsForBubbles(true);
                mTaskView.startShortcutActivity(mBubble.getShortcutInfo(),
                        options, launchBounds);
            } else {
                Intent fillInIntent = new Intent();
                // Apply flags to make behaviour match documentLaunchMode=always.
                fillInIntent.addFlags(FLAG_ACTIVITY_NEW_DOCUMENT);
                fillInIntent.addFlags(FLAG_ACTIVITY_MULTIPLE_TASK);
                if (mBubble != null) {
                    mBubble.setIntentActive();
                }
                mTaskView.startActivity(mPendingIntent, fillInIntent, options,
                        launchBounds);
            }
        } catch (RuntimeException e) {
            Log.w(TAG, "Exception while displaying bubble: " + getBubbleKey()
                    + ", " + e.getMessage() + "; removing bubble");
            mController.removeBubble(getBubbleKey(), Bubbles.DISMISS_INVALID_INTENT);
        }
    });
    mInitialized = true;
}

在这里插入图片描述

    @Override
    public void onTaskAppeared(ActivityManager.RunningTaskInfo taskInfo,
            SurfaceControl leash) {
        if (isUsingShellTransitions()) {
            // Everything else handled by enter transition.
            return;
        }
        mTaskInfo = taskInfo;
        mTaskToken = taskInfo.token;
        mTaskLeash = leash;

        if (mSurfaceCreated) {
            // Surface is ready, so just reparent the task to this surface control
            mTransaction.reparent(mTaskLeash, getSurfaceControl())
                    .show(mTaskLeash)
                    .apply();
        } else {
            // The surface has already been destroyed before the task has appeared,
            // so go ahead and hide the task entirely
            updateTaskVisibility();
        }

onTaskAppeared回调的时序图:
在这里插入图片描述
在这里插入图片描述

移除Task
    @Override
    public void surfaceDestroyed(SurfaceHolder holder) {
        mSurfaceCreated = false;
        mShellExecutor.execute(() -> {
            if (mTaskToken == null) {
                // Nothing to update, task is not yet available
                return;
            }

            if (isUsingShellTransitions()) {
                mTaskViewTransitions.setTaskViewVisible(this, false /* visible */);
                return;
            }

            // Unparent the task when this surface is destroyed
            mTransaction.reparent(mTaskLeash, null).apply();
            updateTaskVisibility();
        });
    }
    @Override
    public void onTaskVanished(ActivityManager.RunningTaskInfo taskInfo) {
    	// 与出现时不同,我们还不能保证消失会在我们知道的转换中发生——所以即使启用了 shell 转换,也请将清理留在这里。
        if (mTaskToken == null || !mTaskToken.equals(taskInfo.token)) return;

        if (mListener != null) {
            final int taskId = taskInfo.taskId;
            mListenerExecutor.execute(() -> {
                mListener.onTaskRemovalStarted(taskId);
            });
        }
        mTaskOrganizer.setInterceptBackPressedOnTaskRoot(mTaskToken, false);

        // Unparent the task when this surface is destroyed
        mTransaction.reparent(mTaskLeash, null).apply();
        resetTaskInfo();
    }
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,以下是实现06-bubbles-1.html的代码,已经加上了注释: ```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Bubbles</title> <style> body { margin: 0; padding: 0; background-color: #000; overflow: hidden; /* 隐藏溢出部分 */ cursor: none; /* 隐藏鼠标 */ } canvas { display: block; /* 去除默认margin和padding */ position: absolute; /* 绝对定位 */ top: 0; left: 0; z-index: -1; /* 在最底层 */ } </style> </head> <body> <canvas></canvas> <script> const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); // 设置画布的宽度和高度为窗口的宽度和高度 canvas.width = window.innerWidth; canvas.height = window.innerHeight; // 定义 Bubble 类 class Bubble { constructor() { this.radius = Math.random() * 50 + 10; // 半径在 10 ~ 60 之间随机 this.x = Math.random() * (canvas.width - this.radius * 2) + this.radius; // x 坐标在半径到画布宽度减去半径之间随机 this.y = canvas.height + this.radius; // y 坐标在画布下方 this.speed = Math.random() * 5 + 1; // 速度在 1 ~ 6 之间随机 this.color = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.5)`; // 随机颜色 } update() { this.y -= this.speed; // 更新 y 坐标 } draw() { ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); // 绘制圆形 ctx.fillStyle = this.color; ctx.fill(); } } const bubbles = []; function createBubble() { bubbles.push(new Bubble()); // 在 bubbles 数组中增加一个新的 Bubble 实例 setTimeout(createBubble, Math.random() * 1000); // 每隔 0 ~ 1 秒随机生成一个新的 Bubble } function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除画布 bubbles.forEach((bubble) => { bubble.update(); // 更新 Bubble 实例的位置 bubble.draw(); // 绘制 Bubble 实例 }); requestAnimationFrame(animate); // 循环调用 animate 函数 } createBubble(); // 开始生成 Bubble animate(); // 开始动画 </script> </body> </html> ``` 关键技术点注释如下: - canvas元素:HTML5新增的元素,可以通过JavaScript在其中绘制图形。 - getContext('2d')方法:获取canvas元素的绘图上下文,参数'2d'表示绘制二维图形。 - canvas.width和canvas.height属性:设置canvas元素的宽度和高度,单位为像素。 - Bubble类:定义了气泡的属性和方法。 - Math.random()方法:返回0到1之间的随机数。 - ctx.beginPath()方法:开始绘制路径,路径上的所有绘制操作都会按照顺序被记录下来。 - ctx.arc()方法:绘制圆形路径。 - ctx.fillStyle属性:设置填充颜色。 - ctx.fill()方法:填充路径。 - requestAnimationFrame()方法:浏览器调用下一帧动画之前会执行该方法中的回调函数,可以实现平滑的动画效果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值