J2ME优化

 

今天看了一下,j2me 优化技巧

 

 

 

一个程序在运行时候的过程大概是:

        执行 -> 绘制  ->  等待用户输入 -> 重复

问题一:哪里去优化 -- 90/10 规则
在苛求性能的游戏里面,有 90% 的时间是在执行其中 %10 的代码。我们的优化努力就应该针对这 10% 的代码。我们用一个 Profier 来定位这 10%. 要运行 J2ME 无线开发包中的 profier 工具 , 选择 edit 菜单下的 preferences 选项 . 这将会显示 preferences 窗口 . 选择
Monitoring
这一栏 , "Enable Profiling" 悬赏,然后点 ok 按钮。什么也没有出现。这是对的,在 Profier 窗口显示之前,我们需要在模拟器中运行我们的程序然后退出

 

输出数据:

(进入选择程序菜单)

Garbage collecting...

 

Collected 0 bytes of garbage (2097016/2097152 bytes free)

 

Initializing class: 'java/lang/System'

 

Initializing class: 'com/sun/cldc/i18n/Helper'

 

Initializing class: 'com/sun/midp/io/j2me/storage/File'

 

Initializing class: 'java/lang/Math'

 

Initializing class: 'java/lang/Double'

 

Initializing class: 'com/sun/midp/main/Main'

 

Initializing class: 'com/sun/midp/lcdui/Resource'

 

Initializing class: 'com/sun/midp/security/SecurityToken'

 

Initializing class: 'com/sun/midp/security/Permissions'

 

Initializing class: 'javax/microedition/rms/RecordStore'

 

Initializing class: 'com/sun/midp/io/j2me/http/Protocol'

 

Initializing class: 'java/lang/Integer'

 

Initializing class: 'com/sun/midp/io/j2me/https/Protocol'

 

Initializing class: 'com/sun/midp/io/j2me/datagram/Protocol'

 

Initializing class: 'com/sun/midp/io/NetworkConnectionBase'

 

Initializing class: 'com/sun/midp/lcdui/DefaultInputMethodHandler'

 

Initializing class: 'com/sun/midp/midlet/MIDletState'

 

Initializing class: 'com/sun/midp/io/j2me/tcpobex/Protocol'

 

Initializing class: 'com/sun/midp/io/j2me/irdaobex/Protocol'

 

Initializing class: 'com/sun/midp/io/j2me/btgoep/Protocol'

 

Initializing class: 'com/sun/kvem/jsr082/impl/bluetooth/SecurityTokenHandler'

 

Initializing class: 'com/sun/kvem/io/j2me/tcpobex/Protocol'

 

Initializing class: 'com/sun/mmedia/BasicPlayer'

 

Initializing class: 'com/sun/mmedia/JavaMPEG1Player2'

 

Initializing class: 'com/sun/midp/io/j2me/push/PushRegistryImpl'

 

Initializing class: 'com/sun/kvem/jsr082/impl/JSR082PushAdaptor'

 

Initializing class: 'com/sun/midp/io/j2me/mms/Protocol'

 

Initializing class: 'com/sun/midp/io/j2me/jcrmi/Protocol'

 

Initializing class: 'com/sun/kvem/environment/NetMon'

 

Initializing class: 'com/sun/j2me/global/AppResourceManagerFactory'

 

Initializing class: 'com/sun/j2me/global/DevResourceManagerFactory'

 

Initializing class: 'com/sun/j2me/global/AppResourceBundleReader'

 

Initializing class: 'com/sun/amms/control/camera/SnapshotCtrl'

 

Initializing class: 'com/sun/mmedia/protocol/CommonDS'

 

Initializing class: 'com/sun/mmedia/MmapiTuner'

 

Initializing class: 'com/sun/mmedia/Configuration'

 

Initializing class: 'com/sun/mmedia/DefaultConfiguration'

 

Initializing class: 'com/sun/mmedia/WtkQSoundAmmsConfig'

 

Initializing class: 'com/sun/mmedia/QSoundHiddenManager'

 

Initializing class: 'com/sun/mmedia/protocol/FileConnectionSubstitute'

 

Initializing class: 'com/sun/midp/security/SecurityInitializer'

 

Initializing class: 'javax/microedition/content/Registry'

 

Initializing class: 'com/sun/midp/content/RegistryImpl'

 

Initializing class: 'com/sun/midp/content/AppProxy'

 

Initializing class: 'com/sun/midp/content/InvocationImpl'

 

Initializing class: 'com/sun/j2me/global/NormalizationTableImpl'

 

Initializing class: 'com/sun/j2me/global/CollationElementTableImpl'

 

Initializing class: 'com/sun/j2me/payment/PaymentModule'

 

Initializing class: 'com/sun/kvem/payment/KvemPaymentModule'

 

Initializing class: 'com/sun/kvem/payment/CreditCardAdapter'

 

Initializing class: 'com/sun/perseus/platform/ResourceHandler'

 

Initializing class: 'com/sun/perseus/builder/DefaultFontFace'

 

Initializing class: 'com/sun/midp/io/j2me/file/Protocol'

 

Initializing class: 'com/sun/kvem/jsr082/impl/bluetooth/SDDBStorageImpl'

 

Initializing class: 'com/sun/midp/wma/WMASecurityInitializer'

 

Initializing class: 'com/sun/midp/io/j2me/sms/Protocol'

 

Initializing class: 'com/sun/mmedia/WavPlayer'

 

Initializing class: 'com/sun/midp/io/Properties'

 

Initializing class: 'javax/microedition/lcdui/Display'

 

Initializing class: 'com/sun/midp/lcdui/Text'

 

Initializing class: 'javax/microedition/lcdui/Item'

 

Initializing class: 'javax/microedition/lcdui/Displayable'

 

Initializing class: 'javax/microedition/lcdui/Font'

 

Initializing class: 'javax/microedition/lcdui/Screen'

 

Initializing class: 'javax/microedition/lcdui/ImageItem'

 

Initializing class: 'com/sun/midp/lcdui/DisplayDeviceAccess'

 

Initializing class: 'com/sun/midp/content/InvocationStore'

 

Initializing class: 'javax/microedition/midlet/MIDletProxy'

 

Initializing class: 'javax/microedition/lcdui/List'

 

Initializing class: 'javax/microedition/lcdui/ChoiceGroup'

 

(选择运行我们的程序)

Loading class 'OptimizeMe' –> 动态连接

 

Garbage collecting...

 

Collected 458620 bytes of garbage (2024924/2097152 bytes free)

 

Garbage collecting...

 

Collected 936 bytes of garbage (2023812/2097152 bytes free)

 

Class loaded ok

 

Linking class: 'OptimizeMe'

 

Class linked ok

 

Loading class 'OCanvas'

 

Garbage collecting...

 

Collected 4480 bytes of garbage (2021672/2097152 bytes free)

 

Class loaded ok

 

Linking class: 'OCanvas'

 

Class linked ok

 

Initializing class: 'javax/microedition/lcdui/Canvas'

 

Initializing class: 'javax/microedition/lcdui/MMHelperImpl'

 

Initializing class: 'com/sun/mmedia/MIDPRendererCanvasBuddy'

 

exitCanvas - status = 2

 

 

当你退出这个程序时, profiler 窗口就会出现,然后你会看见一个文件夹浏览器中有一些东西,在左边的面板上会有一个熟悉的树形部件。方法间的联系会在这个结构列表中显示。每一个文件夹是一个方法,打开一个文件夹会显示它所调用过的方法。在该树中选择一个方法会显示那个方法的 profiling 信息并在右边的面板显示所有被它调用过的方法。注意在每一个元素旁边显示了一个百分数。这就是该方法在整个执行过程中所占的执行时间的百分比。我们必须翻遍这棵树,来寻找时间都到哪里去了,并对占用百分比最高的方法进行优化,如果可能的话。

 

 

Profile 输出图

 

对这个 profiler ,有几点需要说明。首先你的百分比多半会和我的不一样,但是他们的比例会比较相似 -- 总是在最大的数之后。我的数据在被次运行的时候都会改变。为了保持情况一致,你可能希望关掉所有的后台程序,像 Email 客户端,并在你测试的时候保持你正在进行的任务最少。还有,不要在用 profiler 之前混淆( obfuscate )你的代码,不然你的方法会被神秘的标示为 b 或者 a 或者 ff 。最后 profiler 不会因为你运行模拟器的设备的差别而改变,它和硬件是完全独立的。

进一步分析:

在我们的例子程序中的百分比的划分在真实的环境中并不是完全的没有特性 . 你多半会在一个真实的环境中发现这个大的执行时间的比例是在 paint() 方法中 . 相比于非图形化程序 , 图形化程序总是要花很长的时间 . 不幸的是 , 我们的图形程序已经被写在了 J2ME API 这一层下 , 对于改善它们的性能 , 我们没有多少可以做的 . 我们可以做的是在用哪个和如何用它们之间做出聪明的决定 .

 

高级 vs 低级优化

我们在该文章随后的地方会看到一些低级代码优化的技术 . 你会看见它们很容易被嵌入到现有代码中 , 并且在改善性能的同时相应的降低其可读性 . 在我们使用那些技术之前 , 最好还是继续在我们的代码和算法的设计上下功夫 . 这是高级优化 .Michael Abrash,"Quake" 的一位开发者 , 一次写道 ,"the best optimizer is between your ears"( 最好的游戏器就在你的两耳之间 ). 这有不只一种方法而且如果如果实现花更多的时间来思考正确的做事的方式 , 你会得到极大的回报 . 使用正确的算法所带来的性能提升 , 会比用低级优化技术在普通算法上作优化得到的提升大很多 . 你用低级技术可能会得到几点百分比的提升 , 但是请首先从最上层开始并且使用你的大脑 ( 你可以在你的两耳之间找到它 ).

 

现在让我们来看一看我们在 paint() 方法中作了什么 . 每次在屏幕上打印消息 "n ms per frame" , 我们调用了 Graphics.drawString() 16 . 我们不知道 drawString 的任何内部作业 , 但是我们知道它用掉了大量时间 , 所以让我们试试其它的方式 . 让我们直接将这个字符串画到一个图片实例上 , 然后再画 16 次这个图片 .

drawImage 要比 drawString 快很多 ~~ !!!!!!!!!

 

循环之外?

循环多少次,在 for() 内部的代码就会执行多少次。要改善性能,那么,我们想要尽可能的把循环中的代码移动到循环外。我们可以在 profiler 中看到 paint() 被调用了 101 次,并且在它之中的循环又循环了 16 次。在这两个循环中有哪些我们可以移出来呢?让我们从他们的定义说明开始,每当调用 paint() , 我们声明了一个字体,一个字符串,一个图片对象和一个图形对象 . 我们将要把它们移出到该

 

类的最前面 .

public static final Font font =

  Font.getFont( Font.FACE_PROPORTIONAL,

                Font.STYLE_BOLD | Font.STYLE_ITALIC,

                Font.SIZE_SMALL);

public static final int graphicAnchor =

                   Graphics.VCENTER | Graphics.HCENTER;

public static final int textAnchor =

      Graphics.TOP | Graphics.LEFT;

private static final String MESSAGE = " ms per frame";

private String msMessage = "000" + MESSAGE;

private Image stringImage;

private Graphics imageGraphics;

private long oldFrameTime;

 

你会发现 , 我把 Font 对象变成了一个公共的常量 . 这一点在你的程序中通常是有用的 , 你可以把你所要用到的字体声明都集中到一个地方 . 我发现 anchor 也一样 , 所以我也把文本和图像坐标放到了一起 . 对这些的预处理 , 保持了这些运算 -- 虽然不怎么重要 -- 在循环之外了 . 我把 MESSAGE 也变成了一个常量 . 那是因为 Java 喜欢到处创建字符串对象 . 字符串如果没有被控制 , 它们可能导致大量的内存消耗 . 不要把它们留给自动回收 , 否则你很可能会遇到内存泄露 , 那最终会影响你的程序性能 , 特别是当垃圾回收器被调用得过于频繁时 . 字符串创造垃圾 , 而垃圾不好 . 用一个字符串常量减少了这类问题 . 稍后我们会看到如何运用一个 StringBuffer 来完全的阻止字符串滥用带来的内存流失 .

 

下一步!

让我们从一些简单的开始 . 让我们调用那些函数一次并且把结果暂存在循环之外 , 而不是每次都调用 getHeight() getWidth(). 下一步 , 我们将停止使用字符串并手动使用 StringBuffer 来做所有事 . 依靠在 Graphics.setClip() 的调用中限制绘画区域 , 我们将剃掉一些对 drawImage() 的调用 . 最后 , 我们将避免在循环中对 java.util.Random.nextInt() 的调用 .

 

低级优化:

让我们来看一看

这个方法类型的列表:
*synchronized
该方法是最慢的,因为需要获取一个对象锁
*interface
该方法是次慢的
*instance
这个方法居中
*final
该方法比较快
*static
该方法是最快的


另一个影响方法调用性能的因素是,传递给该方法的参数的个数。如果你不能把所有的方法去掉,试着减少你传递的参数的个数。参数越多,开销就越大。

 

 

降低强度( strength reduction )和解开循环。

降低强度就是将一个慢一点的操作用一个相对快一点的完成同样的工作的去替换。最普通的就是使用位移运算符,它和对 2 的乘除运算

 

是相等的。比如说, x>>2 x/4 是相等的( 2 2 次幂), x<<10 x*1024 是相等的( 2 10 次幂)。一个令人惊讶的巧合,我们的除数

 

总是 2 的幂方(是不是很幸运!),所以我们可以用位移来替换那些除法。

解开循环减少了代码控制流的开销,但在循环中做更多的操作,少执行几次循环,或者完全把循环去掉。由于我们的 DIVISOR ——

 

COUNT 只是 8 ,解开我们的循环变得简单起来。

public final static int work( int[] n ) {

  r = 0;

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  n[i] * n[i]  + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 1) + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 2) + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 3) + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 4) + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 5) + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 6) + n[i]; }

  for ( int i = 0 ; i < n.length ; i++ ) { r +=  (n[i] * n[i] >> 7) + n[i]; }

  return r;

}

有两个重点,第一,你会发现解开我们的循环需要我们复制一些代码。这是你在 J2ME 中想要的最后一件事,程序员们总是在与 JAR 作战,但要记得 JARing (打包)过程包括了压缩,而压缩工作对重复的代码最有效,所以上面的代码可能不会像你所想的那样对你的 jar 文件大小产生大的影响。再者,这都是代价交换。你的代码可以很小,很快,易读,任意选择其中的两个。第二点是位移操作符合乘除运算的优先级不一样,所以你常常需要在表达式周围放置括号,而乘除运算符则不需要。

解开循环和使用位移操作符提升了 1% 多一点,不算坏。现在让我们把注意力集中到数组访问上。数组在 C 中是快速的数据结构,但因为那个原因他们也很危险 --- 如果你的代码访问了超过数组尾部的地址,那么你就重写了你不应该访问的内存区域,而且结果通常都是可怕的。相比之下, java 是一个很安全的语言 --- 像那样执行到数组尾部以外会简单地抛出一个 ArrayIndexOutOfBoundsException( 一个数组地址越界异常 ) 。每次访问数组的时候系统都检查数组的下标是否有效,这使得数组的访问比 C 中要慢。再者,对于 java 内部对于数组的处理我们没有什么可以做的,但是我们可以在它周围作一些聪明的决定。在上面的代码中,举例来说,我们访问了 n[i]24 次。我们可以通过把 n[i] 的值存储于一个变量来省略掉很多那样的数组访问。稍微高级一点的想法同样揭示了我们可以用聪明的多的方式重新安排他们,像这样 ...

r = 0;
    for ( int i = 0 ; i < n.length ; i++ )  {
      ni = n[i];
      r +=  ni * ni + ni;
      r +=  (ni * ni >> 1) + ni;
      r +=  (ni * ni >> 2) + ni;
      r +=  (ni * ni >> 3) + ni;
      r +=  (ni * ni >> 4) + ni;
      r +=  (ni * ni >> 5) + ni;
      r +=  (ni * ni >> 6) + ni;
      r +=  (ni * ni >> 7) + ni;
    }
    return r;

 

 

包大小:

修改前:

修改后:

 

性能:

      修改前:

              修改后:

      再修改后:

 

 

 

在我们继续之前,让我多说一点关于数组的事。一个稍微高级的优化(也就是

thought ”)可能揭示了数组可能不是这里必要的正确的数据结构。想想一个链表或者一些其他的结构,如果他们将提升性能。第二点,如果你将要使用一个数组而且也需要复制它的内容到另一个数组,请总是使用 System.arraycopy() 。完成同样的工作,它会比自己写的函数要快一点。最后,数组的性能比 java.util.Vector 对象的性能要好一点。如果你需要一种 Vectors 提供的功能,想想自己写代码并测试一下,确保你的代码要快一点。

 

你可能在想为什么那些变量在方法体的声明之外就被声明了。他们在循环外是因为每次定义一个整形数都有一点开销,而且我们需要保持他们在循环外也有效,对么?错!

什么都不假设,我们确实为整形变量的声明节约了时间,但是如果那些变量在方法内部的定义为局部的,代码实际上可能会更慢。这是因为局部变量表现得更好,因为 JVM 解释一个在方法外声明的变量会花更长的时间。所以让我们把他们变为局部变量。最后,我们可以微微改变一下 for() 循环。计算机处理和零比较比处理和其他的非零数比较要快。那意味着我们可以改变我们的循环的顺序并像这样重写方法,我们就可以和零比较:

修改前:

修改后:

 

记住我们所学的关于局部变量的么?如果你被迫要用一个实例变量,而且你在一个方法中引用了那个变量多次,它可能值得你创建一个局部变量来让 JVM 只处理那个引用一次。你将引入一个声明和一个赋值,这会让程序变慢,但根据经验,如果一个变量被引用了超过两次,我们将会使用这个技术。

 

这个代码还有最后一点需要改变。我把它放到最后是因为它没有书写得特别好,并且我的经验是它的表现在不同的实现间不一样。看着

 

OCanvas 中的 start() run() 方法,你可以看到我已经用了一个单独的动画线程。这是 Java 中处理动画的传统方法。在游戏中用这个技术的一个问题是,每当重复循环时,我们被迫等待系统事件,比如说按键或者一个命令被传输了。毕竟我们在一个同步块中调用 wait() 方法等待。这是艰辛的优化代码。毕竟我们的困难工作优化了其他所有事情,但我们在最激烈的时候实际上什么正确的事情也没能做。更坏的是,为 WAIT_TIME 得到一个好的数据并不简单。如果我们 wait() 太长,游戏就变慢了。如果我们没有 wait() 足够的时间 , 按键可能被错过然后游戏停止了对用户输入的响应。

J2ME 提供了一个这个问题的解决方案,用 Display.callSerially() 方法。 API 声明 callSerially (Runnable r)" 导致了在 repaint 周期完成不久之后,为了和事件流同步, Runnable 对象 r 让其 run() 被推迟调用“ [ 原文是: "causes the Runnable object r tohave its run() method called later, serialized with the event stream, soon after completion of the repaintcycle"] 。通过使用 callSerially() ,我们可以完全的取消对 wait() 的调用。系统会保证我们的 work() paint() 方法和用户输入程序同步地被调用,那样游戏就会保持可响应性。

 

 

 

其他的技术

一个我不能在我的示例程序中包含的技术是,最佳的使用 switch() Switch 非常普遍的用于实现有限状态自动机( Finite StateMachines ),在为非玩家角色的行为控制做人工智能的代码时。在你使用 switch 的时候,像这样写代码是一个好的编程习惯:

  public static final int STATE_RUNNING = 1000;

  public static final int STATE_JUMPING = 2000;

  public static final int STATE_SHOOTING = 3000;

  switch ( n ) {

    case STATE_RUNNING:

      doRun();

    case STATE_JUMPING:

      doJump();

    case STATE_SHOOTING:

      doShoot();

  }

这没有什么不对的,这些变量很不错而且离得很远,万一我们想要加一个在 RUNNING JUMPING 之间的变量,像 STATE_DUCKING =2500 。但是显然 switch 选项可以被编译为一个两字节的代码,如果所用的整数紧靠在一起那么这个两字节的代码会更快,所以这会更好:

  public static final int STATE_RUNNING = 1;

  public static final int STATE_JUMPING = 2;

  public static final int STATE_SHOOTING = 3;

在使用定点数学库( Fixed Point math library )的时候,有一些优化你可以做。首先,如果你除了一个相同的数很多次,你应该计算出那个数的倒数然后把运算改变为执行一个乘法。乘法要比除法快一点。所以不是 ...

  int fpP = FP.Div( fpX, fpD );

  int fpQ = FP.Div( fpY, fpD );

  int fpR = FP.Div( fpZ, fpD );

... 你应该把它重新写成这样:

  int fpID = FP.Div( 1, fpD );

  int fpP = FP.Mul( fpX, fpID );

  int fpQ = FP.Mul( fpY, fpID );

  int fpR = FP.Mul( fpZ, fpID );

如果你在每一帧要做数百次的除法,这会有所帮助。第二点,不要默认你的 FP 数学函数库不错。 don't take your FP math library for granted.  如果你有它的源代码,打开它然后看一下里面发生了什么。保证所有的方法都被声明为 final static 并看看有没有机会优化它的代码。比如,你可能发现这个乘法方法需要把 int 强制转换为 long 然后再转换回来:

public static final int Mul (int x, int y) {

  long z = (long) x * (long) y;

  return ((int) (z >> 16));

}

那些转换要花时间。冲突检测使用边界圆球或者半球( bounding circles or spheres )包括将 int 的平方相加。那会产生一些大的

 

可能会溢出你的 int 数据类型的最大值的数字。要避免这个,你可以写下自己的返回一个 long 型数的平方函数:

    public static final long Sqr (int x) {

      long z = (long) x;

      z *= z;

      return (z >> 16);

    }

这个优化的方法避免了两个转换。如果你要做大量的定点计算,你可能要考虑把所有的主游戏循环中的调用替换为 long 型的。那会节约大量的方法调用和参数传递。你可能也发现当这个计算被手动写出的时候,你可以减少类型转换所需要的次数。如果你嵌套一些对你的库的调用,这是尤其正确的。比如:

    int fpA = FP.Mul( FP.toInt(5),

                    FP.Mul( FP.Div( 1 / fpB ),

                    FP.Mul( FP.Div( fpC, fpD ),

                    FP.toInt( 13 ) ) ) );

花一些时间 Take the time 来拆开这些像这样的嵌套调用,然后看你是否能减少类型转换的次数。另一个方式是避免到 long 类型的转换,如果你知道涉及到的数字足够小以至于他们肯定不会导致溢出。

在高级优化上,你应该看一些游戏设计上的文章。大量的已知的游戏设计中问题,比如 3D 几何和碰撞检测已经被非常优雅和有效地解

 

 

 

优化真言:

* 只优化需要的代码
*
只在有价值的地方优化
*
profiler 来找要优化的地方
*
在具体的设备上 profiler 无能为力,在硬件上使用 System timer
*
在于用低级技术之前,总是先研究你的代码并且试着改进算法
*
绘图是慢的,所以尽量节俭地使用图形调用
*
在可以减少绘制区域的地方使用 setClip()
*
尽可能的把东西放到循环之外
*
拼命地预先计算和暂存
*
字符串带来垃圾,垃圾不好,所以使用 StringBuffers 来代替
*
什么都不假设
*
可能就使用 static final 方法,避免 synchronized 修饰符
*
传递尽可能少的参数到经常调用的方法
*
如果可能,完全地去掉函数调用
*
解开循环
*
2 的幂的乘除运算用位移运算代替
*
你可以使用位运算符代替取模运算来实现循环
*
试着用零来代替和其他数的比较
*
数组访问比 C 要慢,所以暂存数组元素
*
消去公共的子表达式
*
局部变量要比引用变量快
*
如果可以 callSerially() 就不要 wait()
*
switch() 中使用小的变量作选项
*
检查定点数学库并且优化它
*
拆开嵌套的 FP 调用来减少类型转换
*
除法比乘法慢,所以用乘于倒数来代替除法
*
用使用过和测试过的算法
*
为了保护可移植性,小心地使用私有高性能 API

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值