Android 性能优化 (十二) UI卡顿优化 秒变大神 内存抖动过渡绘制

     

Android 性能优化 (一)APK高效瘦身 

http://blog.csdn.net/whb20081815/article/details/70140063

Android 性能优化 (二)数据库优化 秒变大神

http://blog.csdn.net/whb20081815/article/details/70142033

  Android 性能优化(三)布局优化 秒变大神 

http://blog.csdn.net/whb20081815/article/details/70147958

Android 性能优化(四)内存优化OOM 秒变大神

 http://blog.csdn.net/whb20081815/article/details/70243105

Android 性能优化(五)ANR 秒变大神

http://blog.csdn.net/whb20081815/article/details/70245594

Android 性能优化(六) RelativeLayout和LinearLayout性能比较

http://blog.csdn.net/whb20081815/article/details/74465870

Android 性能优化<七>自定义view绘制优化 

http://blog.csdn.net/whb20081815/article/details/74474736

Android 性能优化<八> 多线程优化和线程管理

https://blog.csdn.net/WHB20081815/article/details/77775444

Android 性能优化 <九>RecyclerView替代Listview用法

https://blog.csdn.net/WHB20081815/article/details/76221998

 

Android 性能优化 (十) 启动优化 秒变大神 启动优化提升60%

https://blog.csdn.net/WHB20081815/article/details/88595045

 

Android 性能优化 (十一) 电量优化全解析 秒变大神https://blog.csdn.net/WHB20081815/article/details/88595383

 

 

本文从下面4个问题进行分析

1.为什么ui卡顿

2.ui卡顿产生的几种原因

3.用什么工具检测和分析ui卡顿

4.ui卡顿的常见的几种情形和解决办法

 

UI卡顿分析

 

16ms原则:Android系统每隔16ms会发出VSYNC信号重绘我们的界面(Activity)。为什么是16ms, 因为Android设定的刷新率是60FPS(Frame Per Second), 也就是每秒60帧的刷新率, 约合16ms刷新一次。

卡顿:从用户角度说,App操作起来缓慢,响应不及时,列表滑动一顿一顿的,动画刷新不流畅等等一些直观感受。从系统角度来说,屏幕刷新的帧率不稳定,无法保证每秒绘制60帧,也就是说有掉帧的情况发生。

UI卡顿的原理:Android每16ms就会绘制一次Activity,通过上述的结论我们知道,如果由于一些原因导致了我们的逻辑、CPU耗时、GPU耗时大于16ms,UI就无法完成一次绘制,那么就会造成卡顿。简单的一句话就是:卡主线程了。

 

 

二、View本身的卡顿

 

外部因素之--内存抖动的问题引起卡顿分析

https://segmentfault.com/a/1190000006852540

 

Demo的编写

 

为了模拟UI卡顿,我们利用了WebView加载一张GIF图片:

 

WebView webView = (WebView) findViewById(R.id.webview);

webView.getSettings().setUseWideViewPort(true);

webView.getSettings().setLoadWithOverviewMode(true);

webView.loadUrl("file:///android_asset/shiver_me_timbers.gif");

然后在GIF在动的时候,执行我们的业务代码,通过GIF的卡顿情况来模拟UI卡顿。

 

为了模拟内存抖动,我们在GIF动的时候,在主线程执行一下代码:

 

/**

 * 排序后打印二维数组,一行行打印

 */

public void imPrettySureSortingIsFree() {

    int dimension = 300;

    int[][] lotsOfInts = new int[dimension][dimension];

    Random randomGenerator = new Random();

    for(int i = 0; i < lotsOfInts.length; i++) {

        for (int j = 0; j < lotsOfInts[i].length; j++) {

            lotsOfInts[i][j] = randomGenerator.nextInt();

        }

    }

 

    for(int i = 0; i < lotsOfInts.length; i++) {

        String rowAsStr = "";

        //排序

        int[] sorted = lotsOfInts[i].clone();

        Arrays.sort(sorted);

        //拼接打印

        for (int j = 0; j < lotsOfInts[i].length; j++) {

            rowAsStr += sorted[j];

            if(j < (lotsOfInts[i].length - 1)){

                rowAsStr += ", ";

            }

        }

        Log.i("ricky", "Row " + i + ": " + rowAsStr);

    }

}

 

这段代码主要是模拟大量的堆内存分配与释放String对象,频繁触发GC,导致UI卡顿。通过Memory Monitor可以看出:

 

内存方面是发生了抖动,但是CPU的占用几乎不动。

 

为了分析内存的情况,我们结合之前的文章,使用一些工具来分析,因为实际情况是,我们不知道哪里的代码导致UI卡顿。

 

首先我们使用Android Studio自带的Allocation Tracking工具来跟踪内存分配情况。我们在UI卡顿的过程中收集内存分配的信息如下:

 

我主要关心自己的包,可以粗略地根据内存分配次数来断定哪些类的代码存在大量的内存分配。当然,我们也可以通过饼状图来分析,这里不再赘述。

 

解决办法

 

解决办法,这个Demo中,为了解决GC频繁的问题,我们可以利用StringBudiler代替String:

 

/**

 * 打印二维数组,一行行打印

 */

public void imPrettySureSortingIsFree() {

    int dimension = 300;

    int[][] lotsOfInts = new int[dimension][dimension];

    Random randomGenerator = new Random();

    for(int i = 0; i < lotsOfInts.length; i++) {

        for (int j = 0; j < lotsOfInts[i].length; j++) {

            lotsOfInts[i][j] = randomGenerator.nextInt();

        }

    }

 

    // 使用StringBuilder完成输出,我们只需要创建一个字符串即可,不需要浪费过多的内存

    StringBuilder sb = new StringBuilder();

    String rowAsStr = "";

    for(int i = 0; i < lotsOfInts.length; i++) {

        // 清除上一行

        sb.delete(0, rowAsStr.length());

        //排序

        int[] sorted = lotsOfInts[i].clone();

        Arrays.sort(sorted);

        //拼接打印

        for (int j = 0; j < lotsOfInts[i].length; j++) {

            sb.append(sorted[j]);

            if(j < (lotsOfInts[i].length - 1)){

                sb.append(", ");

            }

        }

        rowAsStr = sb.toString();

        Log.i("jason", "Row " + i + ": " + rowAsStr);

    }

}

 

注意,GC是无法避免的,我们要避免的是频繁的GC,因此这里的优化实质上是内存优化。

 

外部因素之--方法耗时(CPU占用)的问题引起卡顿分析

--------------------- 

 

上图中:

 

在第一个16ms内,UI正常地完成了绘制,那么屏幕不会卡顿。

在第二个16ms内,由于某些原因触发了频发的GC,UI无法在16ms内完成绘制,就会卡顿。

UI卡顿外部和内部常见原因

 

下面总结一些常见的UI卡顿原因:

 

一、外部因素引起的(以View为区分)

 

内存抖动的问题

方法太耗时了(CPU占用)

--------------------- 

 

问题检测分析

1.2种代码分析法,通过时间看

2.通过studio的工具查看

 

非常好的检测办法;

https://blog.csdn.net/lmj623565791/article/details/58626355

 

1.利用Choreographer

 

Android系统每隔16ms发出VSYNC信号,触发对UI进行渲染。SDK中包含了一个相关类,以及相关回调。理论上来说两次回调的时间周期应该在16ms,如果超过了16ms我们则认为发生了卡顿,我们主要就是利用两次回调间的时间周期来判断:

 

大致代码如下:

 

public class BlockDetectByChoreographer {

    public static void start() {

        Choreographer.getInstance()

            .postFrameCallback(new Choreographer.FrameCallback() {

                @Override

                public void doFrame(long l) {

                    if (LogMonitor.getInstance().isMonitor()) {

                        LogMonitor.getInstance().removeMonitor();                    

                    } 

                    LogMonitor.getInstance().startMonitor();

                    Choreographer.getInstance().postFrameCallback(this);

                }

        });

    }

}

 

第一次的时候开始检测,如果大于阈值则输出相关堆栈信息,否则则移除。

利用loop()中打印的日志

 

(1)原理

 

大家都知道在Android UI线程中有个Looper,在其loop方法中会不断取出Message,调用其绑定的Handler在UI线程进行执行。

 

大致代码如下:

 

public static void loop() {

    final Looper me = myLooper();

 

    final MessageQueue queue = me.mQueue;

    // ...

    for (;;) {

        Message msg = queue.next(); // might block

        // This must be in a local variable, in case a UI event sets the logger

        Printer logging = me.mLogging;

        if (logging != null) {

            logging.println(">>>>> Dispatching to " + msg.target + " " +

                    msg.callback + ": " + msg.what);

        }

        // focus

        msg.target.dispatchMessage(msg);

 

        if (logging != null) {

            logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);

        }

 

        // ...

        }

        msg.recycleUnchecked();

    }

}

 

所以很多时候,我们只要有办法检测:

 

msg.target.dispatchMessage(msg);

 

此行代码的执行时间,就能够检测到部分UI线程是否有耗时操作了。可以看到在执行此代码前后,如果设置了logging,会分别打印出>>>>> Dispatching to和<<<<< Finished to这样的log。

 

我们可以通过计算两次log之间的时间差值,大致代码如下:

 

public class BlockDetectByPrinter {

 

    public static void start() {

 

        Looper.getMainLooper().setMessageLogging(new Printer() {

 

            private static final String START = ">>>>> Dispatching";

            private static final String END = "<<<<< Finished";

 

            @Override

            public void println(String x) {

                if (x.startsWith(START)) {

                    LogMonitor.getInstance().startMonitor();

                }

                if (x.startsWith(END)) {

                    LogMonitor.getInstance().removeMonitor();

                }

            }

        });

 

    }

}

假设我们的阈值是1000ms,当我在匹配到>>>>> Dispatching时,我会在1000ms毫秒后执行一个任务(打印出UI线程的堆栈信息,会在非UI线程中进行);正常情况下,肯定是低于1000ms执行完成的,所以当我匹配到<<<<< Finished,会移除该任务。

 

大概代码如下:

 

public class LogMonitor {

 

    private static LogMonitor sInstance = new LogMonitor();

    private HandlerThread mLogThread = new HandlerThread("log");

    private Handler mIoHandler;

    private static final long TIME_BLOCK = 1000L;

 

    private LogMonitor() {

        mLogThread.start();

        mIoHandler = new Handler(mLogThread.getLooper());

    }

 

    private static Runnable mLogRunnable = new Runnable() {

        @Override

        public void run() {

            StringBuilder sb = new StringBuilder();

            StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();

            for (StackTraceElement s : stackTrace) {

                sb.append(s.toString() + "\n");

            }

            Log.e("TAG", sb.toString());

        }

    };

 

    public static LogMonitor getInstance() {

        return sInstance;

    }

 

    public boolean isMonitor() {

        return mIoHandler.hasCallbacks(mLogRunnable);

    }

 

    public void startMonitor() {

        mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);

    }

 

    public void removeMonitor() {

        mIoHandler.removeCallbacks(mLogRunnable);

    }

 

}

 

我们利用了HandlerThread这个类,同样利用了Looper机制,只不过在非UI线程中,如果执行耗时达到我们设置的阈值,则会执行mLogRunnable,打印出UI线程当前的堆栈信息;如果你阈值时间之内完成,则会remove掉该runnable。

 

(2)测试

 

用法很简单,在Application的onCreate中调用:

 

BlockDetectByPrinter.start();

1

即可。

 

然后我们在Activity里面,点击一个按钮,让睡眠2s,测试下:

 

findViewById(R.id.id_btn02)

    .setOnClickListener(new View.OnClickListener() {

        @Override

        public void onClick(View v) {

            try {

                Thread.sleep(2000);

            } catch (InterruptedException e) {

            }

        }

    });

 

运行点击时,会打印出log:

 

02-21 00:26:26.408 2999-3014/com.zhy.testlp E/TAG: 

java.lang.VMThread.sleep(Native Method)

   java.lang.Thread.sleep(Thread.java:1013)

   java.lang.Thread.sleep(Thread.java:995)

   com.zhy.testlp.MainActivity$2.onClick(MainActivity.java:70)

   android.view.View.performClick(View.java:4438)

   android.view.View$PerformClick.run(View.java:18422)

   android.os.Handler.handleCallback(Handler.java:733)

   android.os.Handler.dispatchMessage(Handler.java:95)

 

 

当发生掉帧时,需要判断是什么原因导致了UI线程耗时过程或阻塞。这时需要借助一些开发工具来帮助定位。

 

systrace

systrace 官方说明:https://developer.android.com/studio/command-line/systrace.html

systrace.py 是一个命令行工具,位于 …/sdk/platform-tools/systrace目录下。在应用运行时,它可以帮助我们收集和分析所有进程的计时信息,包含了CPU调度、应用线程、磁盘活动等Android内核数据,然后生成一份HTML报告。

 

systace对检测应用UI表现非常有效,因为它可以分析你的代码和帧率来识别出问题区域,然后提出可能的解决方案。示例

 

 

如果其中的Expensive measure/layout 或 Long View#draw() 警告特别多,可能是因为页面层级比较深,导致测量、布局和渲染时间过长,从而引起掉帧。

 

检测布局层级是否太深最有效的工具就是开发者选项中的GPU过度绘制模式了,这个稍后会讲到。

 

systrace对每一种警告类型都做出了解释:

 

Scheduling delay

渲染一帧的工作被推迟了几个毫秒,从而导致了不合格。确保UI线程上的代码不会被其他线程上完成的工作阻塞,并且后台线程(例如,网络或位图加载)在android.os.Process#THREAD_PRIORITY_BACKGROUND中运行或更低,因此它们不太可能中断UI线程。

 

Expensive measure/layout pass

测量/布局花费了很长时间,导致掉帧,要避免在动画过程中触发重新布局。

 

Long View#draw()

记录无效的绘图命令花费了很长时间,在View或Drawable自定义视图时,要避免做耗时操作,尤其是Bitmap的分配和绘制。

 

Expensive Bitmap uploads

修改或新创建Bitmap视图要传送给GPU,如果像素总数很大,这个操作会很耗时。因此在每一帧中要尽量减少Bitmap变更的次数。

 

Inefficient View alpha usage

将alpha设置为半透明值(0<alpha<1)会很耗性能,尤其是对大视图。所以最好短暂地使用alpha属性。

 

traceview

 

TraceView 是 Android SDK 中内置的一个工具,它可以加载 trace 文件,用图形的形式展示代码的执行时间、次数及调用栈,便于我们分析。我们可以在Android Profiler或DDMS中启动它。

 

 

使用这个工具最关键的地方就是要理解各个统计维度的含义:

 

方法执行时间

Incl Cpu Time: 执行方法X及子方法占用Cpu的时间

Excl Cpu Time: 执行方法X占用Cpu时间,不包含子方法

 

Incl Real Time: 执行方法X及子方法总时间

Excl Real Time: 执行方法x总时间

 

Cpu Time/Call: 每次执行方法X占用Cpu时间

Real Time/Call: 每次执行方法X总时间

 

占用CPU比例

 

Incl Cpu Time%

Excl Cpu Time%

Incl Real Time%

Excl Real Time%

 

以上各个时间占Cpu执行耗时的百分比

 

调用次数

 

Calls + Recur Calls/Total: 方法X调用次数和递归调用次数

 

使用时只需要关注 Incl Real Time、Real Time/Call、Calls + Recur Calls/Total这三个指标即可,找出应用包名下的耗时方法调用后加以优化。

 

GPU过度绘制调试模式

 

开发者选项 -> 调试GPU过度绘制

 

 

Overdraw(过度绘制)描述的是屏幕上的某个项目在同一帧内被绘制了多次。在多层次的UI结构里面,如果不可见的UI也在做绘制的操作,这就会导致某些像素区域内绘制了 多次,这就浪费了大量的CPU以及GPU资源。可以开启调试工具,打开开发者设置来显示GPU过度绘制显示情况。一般步骤:设置-> 开发者选项->调试GPU过度绘制->显示GPU过度绘制 (不同的手机操作路径不一致,但是大体差不多)

 

                 

 

               深红: 意味着overdraw 4倍。像素绘制了5次或者更多,这是错误的,需要修复;

 

               淡红: 意味着overdraw 3倍。像素绘制了4次,小范围可以接受。

 

               绿色: 意味着overdraw 2倍。像素绘制了3次。中等大小的绿色区域是可以接受的,但是还是应该优化,减少他们。

 

               蓝色: 意味着overdraw 1倍。像素绘制了2次。大片的啦色还是可以接受的。

 

         优化的目标就是减少红色的Overdraw,看到更多的蓝色区域。

 

请注意,这些颜色是半透明的,因此,您在屏幕上看到的确切颜色取决于您的界面内容。

 

可以通过此功能查看哪些页面的布局层级过深。

 

 

 

常见卡顿原因及解决方案

1.内存抖动

2.view过度绘制

   2.1.1 overdraw优化方案

 

去掉window的默认背景

 

使用Android自带的一些主题时,window会被默认添加一个纯色的背景,这个背景是被DecorView持有的。当我们的自定义布局时又添加了一张背景图或者设置背景色,那么DecorView的background此时对我们来说是无用的,但是它会产生一次Overdraw,带来绘制性能损耗。去掉window的背景可以在onCreate()中setContentView()之后调用getWindow().setBackgroundDrawable(null);或者在theme中添加android:windowbackground=”null”;

 

去掉其他不必要的背景

 

 有时候为了方便会先给Layout设置一个整体的背景,再给子View设置背景,这里也会造成重叠,如果子View宽度mach_parent,可以看到完全覆盖了Layout的一部分,这里就可以通过分别设置背景来减少重绘。再比如如果采用的是selector的背景,将normal状态的color设置为“@android:color/transparent”,也同样可以解决问题。这里只简单举两个例子,我们在开发过程中的一些习惯性思维定式会带来不经意的Overdraw,所以开发过程中我们为某个View或者ViewGroup设置背景的时候,先思考下是否真的有必要,或者思考下这个背景能不能分段设置在子View上,而不是图方便直接设置在根View上。

 

 clipRect的使用

 

 我们可以通过canvas.clipRect()来 帮助系统识别那些可见的区域。这个方法可以指定一块矩形区域,只有在这个区域内才会被绘制,其他的区域会被忽视。这个API可以很好的帮助那些有多组重叠 组件的自定义View来控制显示的区域。同时clipRect方法还可以帮助节约CPU与GPU资源,在clipRect区域之外的绘制指令都不会被执行,那些部分内容在矩形区域内的组件,仍然会得到绘制。

 

 ViewStub

 

 ViewStub称之为“延迟化加载”,在教多数情况下,程序无需显示ViewStub所指向的布局文件,只有在特定的某些较少条件下,此时ViewStub所指向的布局文件才需要被inflate,且此布局文件直接将当前ViewStub替换掉,具体是通过viewStub.infalte()或viewStub.setVisibility(View.VISIBLE)来完成;

 

 Merge标签      

 

 Merge标签可以干掉一个view层级。Merge的作用很明显,但是也有一些使用条件的限制。有两种情况下我们可以使用Merge标签来做容器控件。第一种子视图不需要指定任何针对父视图的布局属性,就是说父容器仅仅是个容器,子视图只需要直接添加到父视图上用于显示就行。另外一种是假如需要在LinearLayout里面嵌入一个布局(或者视图),而恰恰这个布局(或者视图)的根节点也是LinearLayout,这样就多了一层没有用的嵌套,无疑这样只会拖慢程序速度。而这个时候如果我们使用merge根标签就可以避免那样的问题。另外Merge只能作为XML布局的根标签使用,当Inflate以开头的布局文件时,必须指定一个父ViewGroup,并且必须设定attachToRoot为true。  

--------------------- 

 

使用ViewStub标签,延迟加载不必要的视图

使用AsyncLayoutInflater异步解析视图

主线程耗时操作

 

Json数据解析耗时(Cache类)

文件操作(获取所属渠道名称)

Binder通信(获取系统属性(mac地址))

正则匹配(Hybird 通信)

相机操作:初始化、预览、停止预览、释放(反扫)

组件初始化(推送)

循环删除、创建View(更多页面)

WebView首次初始化

处理方案评估:

异步 > 缓存 > 替代方案 > 保持原状

 

对于开发过程,出现卡顿的主要原因是主线程做了一些不该做的事,或者主线程做不了事情了。

 

布局渲染(解析、测量、布局、渲染)

动画执行

Binder通信

界面响应

主线程主要是做以上四个方面的工作,如果在主线程做一些耗时操作(网络请求、IO读写等),或者被其他线程挂起(GC),那么页面刷新无法在16ms内完成,就会出现掉帧的情况。

 

异步:

 

登录、退出登录后的数据处理

相机操作

组件初始化

示例:

 

    //异步启动消息推送服务

    private void startPushAsync(Context context) {

        Subscription startPushSub = Observable.unsafeCreate(subscriber -> {

            MyPushManager.getInstance().startPush(context);

            MyPushManager.getInstance().connectHwPushAgent(mInteraction.getActivity());

        }).compose(executorTransformer.transformer())

                .subscribe(new DefaultSubscriber<Object>(context) {

                    @Override

                    protected void onFinally() {

                        super.onFinally();

                    }

                });

        addSubscription(startPushSub);

    }

 

缓存:

Cache类

系统属性

示例:

//获取应用渠道标识

public static String getChannel(Context context) {

        if (!TextUtils.isEmpty(APP_CHANNEL)) {

            return APP_CHANNEL;

 

         zipfile = new ZipFile(sourceDir);

            Enumeration<?> entries = zipfile.entries();

            while (entries.hasMoreElements()) {

                ZipEntry entry = ((ZipEntry) entries.nextElement());

                String entryName = entry.getName();

                if (entryName.contains(start_flag)) {

                    channel = entryName.replace(start_flag, "");

                    break;

                }

            }

}

 

替代方案:

 

Hybird通信中的一处正则匹配

更多页面采用RecycleView嵌套

示例:

 

 

    private void dispatchMessage(WVJBMessage message) {

        String messageJSON = message2JSONObject(message).toString();

        //使用JSONObject的quote方法,代替正则替换,效率更高

        messageJSON = JSONObject.quote(messageJSON);

        messageJSON = messageJSON.substring(1, messageJSON.length() - 1);

 

        log("SEND", messageJSON);

        executeJavascript("WebViewJavascriptBridge._handleMessageFromObjC('"

                + messageJSON + "');");

    }

    

    旧版本

    String messageJSON = message2JSONObject(message).toString()

                .replaceAll("\\\\", "\\\\\\\\").replaceAll("\"", "\\\\\"")

                .replaceAll("\'", "\\\\\'").replaceAll("\n", "\\\\\n")

                .replaceAll("\r", "\\\\\r").replaceAll("\f", "\\\\\f");

 

保持原状:

 

WebView首次初始化耗时

提前加载一个WebView窗口 (没必要)

异步初始化WebView (不支持)

 

//将主题的背景去掉

getWindow().setBackgroundDrawable(null);

又例如我们的根布局经常会设置重复的背景,那么这时候就应该去掉一些不必要的背景。

 

还有的就是,我们在写列表控件的时候,如果Item在没有图片的时候需要一个背景色的时候,那么我们这时候就需要灵活地利用透明色来防止过度绘制:

 

if (chat.getAuthor().getAvatarId() == 0) {

    //没有头像的时候,需要把Drawable设置为透明,防止过度绘制(每次都要设置,因为Item会复用)

    Picasso.with(getContext()).load(android.R.color.transparent).into(chat_author_avatar);

    //没有头像的时候,需要设置默认的背景色

    chat_author_avatar.setBackgroundColor(chat.getAuthor().getColor());

} else {

    //有头像的时候,直接设置头像,并且把背景色设置为透明,同样也是防止过度绘制

    Picasso.with(getContext()).load(chat.getAuthor().getAvatarId()).into(

            chat_author_avatar);

    chat_author_avatar.setBackgroundColor(Color.TRANSPARENT);

}

二、自定义控件处理过度绘制。

如果我们的自定义控件存在一些被遮挡的不需要显示的区域,可以通过画布的裁剪来处理。例如下面的伪代码:

 

private void drawSomething(Canvas canvas , ...) {

    //画布的保存

    canvas.save();

    //裁剪画布

    canvas.clipRect(...);

    //绘制

    canvas.draw(...);

    //画布还原,下次继续使用

    canvas.restore();

 

参考博客:

  https://www.jianshu.com/p/8b81f5588afb

https://blog.csdn.net/joye123/article/details/79425398

 

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值