MVP模式—— 安卓中的Model-View-Presenter模式介绍

这是一篇安卓中MVP模式的详细教程,从最简单的例子到最佳实践。本文还介绍了一个让在安卓中使用MVP模式变得非常简单的library。

它是不是很简单,我们如何才能从中获益?

什么是MVP

.View是指显示数据并且和用户交互的层。在安卓中,它们可以是一个Activity,一个Fragment,一个android.view.View或者是一个Dialog。

.Model 是数据源层。比如数据库接口或者远程服务器的api。

.Presenter是从Model中获取数据并提供给View的层,Presenter还负责处理后台任务。

MVP是一个将后台任务和activities/views/fragment分离的方法,让它们独立于绝大多数跟生命周期相关的事件。这样应用就会变得更简单,整个应用的稳定性提高10倍以上,代码也变得更短,可维护性增强,程序员也不会过劳死了~~。

为什么要在安卓上使用MVP

原因之一: 尽量简单

如果你还没有阅读过这篇文章,阅读它: Kiss原则。- kiss是Keep It Stupid Simple或者Keep It Simple, Stupid的缩写。

 .绝大多数的安卓程序都只使用了View-Model架构。

 .程序员被绞尽了复杂的界面开发中,而不是解决事务逻辑。

在应用中使用Model-View的坏处是“每个东西之间都是相互关联的”如下图:

如果上面的图解看起来还不够复杂,那么想想这些情况:每个view可能在任意的时间出现或者消失,view数据需要保存与恢复,在临时的view上挂载一个后台任务。

而与“每个东西之间都是相互关联的”的相反选择是使用一个万能对象(god object)。注:god object是指一个对象/例程在系统中做了太多的事情,或者说是有太多不怎么相关的事情放在一个对象/例程里面来完成


god object过于复杂,他的不同部分无法重用、测试,无法轻易的debug和重构。


使用MVP

.复杂的任务被分割成简单的任务。

.更小的对象,更少的bug。

.更好测试

MVP的view层变得如此简单,在请求数据的时候甚至不需要使用回调。view的逻辑变得非常直接。

原因之二: 后台任务

当你需要写一个Activity,Fragment或者一个自定义View的时候,你可以将所有和后台任务相关的方法放在一个外部的或者静态的类中。这样你的后台任务就不会再与Activity相关联,不会在泄漏内存同时也不会依赖于Activity的重建。我们称这样的一个类为“Presenter”。注:要理解此话的含义最好先看懂第一个MVP示例的代码。

虽然有一些方法可以解决后台任务的问题,但是没有一种和MVP一样可靠。

为什么这是可行的

下面的图解显示了在configuration改变或者发生out-of-memory事件的情况下应用的不同部分所发生的事情。每一个开发者都应该知道这些数据,但是这些数据并不好发现。

 
 
  1.                                           |    Case 1     |   Case 2     |    Case 3
  2.                                           |A configuration| An activity  |  A process
  3.                                           |   change      |   restart    |   restart
  4.  ---------------------------------------- | ------------- | ------------ | ------------
  5.  Dialog                                   |     reset     |    reset     |    reset
  6.  Activity, View, Fragment                 | save/restore  | save/restore | save/restore
  7.  Fragment with setRetainInstance(true)    |   no change   | save/restore | save/restore
  8.  Static variables and threads             |   no change   |   no change  |    reset

情景1:configuration的改变通常发生在旋转屏幕,修改语言设置,链接外部的模拟器等情况下。要知道更多的configuration change事件请阅读:configChanges

情景2:Activity的重启发生在当用户在开发者选项中选中了“Don't keep activities”(“中文下为 不保留活动”)的复选框,然后另一个Activity在最顶上的时候。

情景3:进程的重启发生在应用运行在后台,但是这个时候内存不够的情况下。

结论

现在你可以发现,一个拥有setRetainInstance(true)的Fragment并没有带来帮助 - 我们还是要保存和/恢复这种fragment的状态。因此我们可以去掉可保持Fragment的情景,把问题简单化。Occam's razor.

 
 
  1.                                           |A configuration|
  2.                                           |   change,     |
  3.                                           | An activity   |  A process
  4.                                           |   restart     |   restart
  5.  ---------------------------------------- | ------------- | -------------
  6.  Activity, View, Fragment, DialogFragment | save/restore  | save/restore
  7.  Static variables and threads             |   no change   |    reset

现在看起来就好多了。我们只需要写两部分代码来实现任意情况下完全恢复应用的状态:

.保存/恢复Activity, View, Fragment, DialogFragment;

.在进程重启的情况下重新开启后台请求。

第一部分我们可以通过常规的Android API方式来实现,第二部分就是Presenter的工作了。Presenter可以记住哪个请求应该被执行,并且在执行期间如果进程重启,Presenter可以重新执行这些请求。

一个简单的例子 (未使用MVP)

这个例子将从远程服务器中加载与显示一些item元素(就是显示在ListView中的意思)。如果遇到错误会显示一个toast提示。

我推荐使用RxJava 来建立presenter,因为这个库可以让数据流的控制更简单。

我还要感谢那个创立了一个简单api的小伙伴,我的例子中用到了它:The Internet Chuck Norris Database 。作者的远程数据就是来自于这个api。貌似是一个提供笑话内容的api。

不使用 MVP 示例 00:

 
 
  1. public class MainActivity extends Activity {
  2.     public static final String DEFAULT_NAME = "Chuck Norris";
  3.  
  4.     private ArrayAdapter<ServerAPI.Item> adapter;
  5.     private Subscription subscription;
  6.  
  7.     @Override
  8.     public void onCreate(Bundle savedInstanceState) {
  9.         super.onCreate(savedInstanceState);
  10.         setContentView(R.layout.activity_main);
  11.         ListView listView = (ListView)findViewById(R.id.listView);
  12.         listView.setAdapter(adapter = new ArrayAdapter<>(this, R.layout.item));
  13.         requestItems(DEFAULT_NAME);
  14.     }
  15.  
  16.     @Override
  17.     protected void onDestroy() {
  18.         super.onDestroy();
  19.         unsubscribe();
  20.     }
  21.  
  22.     public void requestItems(String name) {
  23.         unsubscribe();
  24.         subscription = App.getServerAPI()
  25.             .getItems(name.split("\\s+")[0], name.split("\\s+")[1])
  26.             .delay(1, TimeUnit.SECONDS)
  27.             .observeOn(AndroidSchedulers.mainThread())
  28.             .subscribe(new Action1<ServerAPI.Response>() {
  29.                 @Override
  30.                 public void call(ServerAPI.Response response) {
  31.                     onItemsNext(response.items);
  32.                 }
  33.             }, new Action1<Throwable>() {
  34.                 @Override
  35.                 public void call(Throwable error) {
  36.                     onItemsError(error);
  37.                 }
  38.             });
  39.     }
  40.  
  41.     public void onItemsNext(ServerAPI.Item[] items) {
  42.         adapter.clear();
  43.         adapter.addAll(items);
  44.     }
  45.  
  46.     public void onItemsError(Throwable throwable) {
  47.         Toast.makeText(this, throwable.getMessage(), Toast.LENGTH_LONG).show();
  48.     }
  49.  
  50.     private void unsubscribe() {
  51.         if (subscription != null) {
  52.             subscription.unsubscribe();
  53.             subscription = null;
  54.         }
  55.     }
  56. }

注:别被RxJava吓到,你就当成一般的异步请求就行了。

一个有经验的开发者应该注意到这个简单的例子存在很严重的问题:

.每次用户翻转屏幕的时候都会开始请求 - app做了多余实际需要的请求,并且用户在旋转屏幕之后会观察到一段时间的空白屏幕。

.如果用户翻转屏幕的此时很频繁会导致内存泄漏 - 每次回调都会保存一个对MainActivity的引用,在请求运行的时候这个引用将保存在内存中。这几乎会必然导致应用因为out-of-memory错误或者运行缓慢而崩溃。

译者注:为什么平时我们没有发现这样的问题?因为我们完全不去考虑用户频繁旋转屏幕的情况,我们认为用户这样用手机是找虐,还有,绝大多数的中文应用都禁止屏幕旋转,只有竖屏,因此就避免了这种问题的发生。

使用MVP 示例 01:

 
 
  1. public class MainPresenter {
  2.  
  3.     public static final String DEFAULT_NAME = "Chuck Norris";
  4.  
  5.     private ServerAPI.Item[] items;
  6.     private Throwable error;
  7.  
  8.     private MainActivity view;
  9.  
  10.     public MainPresenter() {
  11.         App.getServerAPI()
  12.             .getItems(DEFAULT_NAME.split("\\s+")[0], DEFAULT_NAME.split("\\s+")[1])
  13.             .delay(1, TimeUnit.SECONDS)
  14.             .observeOn(AndroidSchedulers.mainThread())
  15.             .subscribe(new Action1<ServerAPI.Response>() {
  16.                 @Override
  17.                 public void call(ServerAPI.Response response) {
  18.                     items = response.items;
  19.                     publish();
  20.                 }
  21.             }, new Action1<Throwable>() {
  22.                 @Override
  23.                 public void call(Throwable throwable) {
  24.                     error = throwable;
  25.                     publish();
  26.                 }
  27.             });
  28.     }
  29.  
  30.     public void onTakeView(MainActivity view) {
  31.         this.view = view;
  32.         publish();
  33.     }
  34.  
  35.     private void publish() {
  36.         if (view != null) {
  37.             if (items != null)
  38.                 view.onItemsNext(items);
  39.             else if (error != null)
  40.                 view.onItemsError(error);
  41.         }
  42.     }
  43. }

严格意义上来说MainPresenter有三个事件:onNext, onError, onTakeView(onNext指代view.onItemsNext,同理onError指代view.onItemsError)。这三个事件在publish()方法中结合到了一起。onNext和onError的值被发布给了onTakeView()方法提供的MainActivity的实例。

 
 
  1. public class MainActivity extends Activity {
  2.  
  3.     private ArrayAdapter<ServerAPI.Item> adapter;
  4.  
  5.     private static MainPresenter presenter;
  6.  
  7.     @Override
  8.     public void onCreate(Bundle savedInstanceState) {
  9.         super.onCreate(savedInstanceState);
  10.         setContentView(R.layout.activity_main);
  11.  
  12.         ListView listView = (ListView)findViewById(R.id.listView);
  13.         listView.setAdapter(adapter = new ArrayAdapter<>(this, R.layout.item));
  14.  
  15.         if (presenter == null)
  16.             presenter = new MainPresenter();
  17.         presenter.onTakeView(this);
  18.     }
  19.  
  20.     @Override
  21.     protected void onDestroy() {
  22.         super.onDestroy();
  23.         presenter.onTakeView(null);
  24.         if (isFinishing())
  25.             presenter = null;
  26.     }
  27.  
  28.     public void onItemsNext(ServerAPI.Item[] items) {
  29.         adapter.clear();
  30.         adapter.addAll(items);
  31.     }
  32.  
  33.     public void onItemsError(Throwable throwable) {
  34.         Toast.makeText(this, throwable.getMessage(), Toast.LENGTH_LONG).show();
  35.     }
  36. }

MainActivity创建MainPresenter,并让它在onCreate/onDestroy的周期之外。MainActivity用静态变量来引用MainPresenter,因此每次进程因为out-of-memory事件重启的时候,MainActivity都会检查presenter是否还在,如果必要再新建一个。是的,使用静态变量看起来会觉得让人不舒服,但是稍后我们会告诉你如何好看些:

主要的考虑是:

示例程序不会在每次切换屏幕的时候都开始一个新的请求。

如果进程重启,示例程序会重新加载数据。

在MainActivity销毁(destroyed)的时候MainPresenter不会再持有对MainActivity的引用,因此不会在切换屏幕的时候发生内存泄漏,而且没必要去unsubscribe请求。

Nucleus

Nucleus是我从Mortar库和 Keep It Stupid Simple  这篇文章得到的灵感而建立的库。

下面列出其特点:

1.支持在View、Fragment或者Activity的Bundle中保存与恢复Presenter的状态。Presenter可以将请求参数保存在这个bundle中,在稍后重启请求。

2.只需一行代码就能将请求的结果与错误信息交给view,你不需要写什么!= null之类的检查代码。

3.presenter允许拥有多个View的实例。不过你不能在用Dagger实例化的presenter中这样使用。

4.支持只用一行代码将presenter和view绑定。

5.提供一些现成的基类:NucleusView, NucleusFragment, NucleusSupportFragment, NucleusActivity。你可以将他们的代码拷贝出来改造出一个自己的类以利用Nucleus的presenter。

6.支持在进程重启的时候自动重启一个请求,以及在销毁(onDestroy)期间自动取消RxJava的订阅。

7.最后,它非常简单,任何一个开发者都能理解。只有Presenter的驱动只有180行代码,而对于RxJava的支持只有230行代码。

 Nucleus的例子 example 02

 
 
  1. public class MainPresenter extends RxPresenter<MainActivity> {
  2.  
  3.     public static final String DEFAULT_NAME = "Chuck Norris";
  4.  
  5.     @Override
  6.     protected void onCreate(Bundle savedState) {
  7.         super.onCreate(savedState);
  8.  
  9.         App.getServerAPI()
  10.             .getItems(DEFAULT_NAME.split("\\s+")[0], DEFAULT_NAME.split("\\s+")[1])
  11.             .delay(1, TimeUnit.SECONDS)
  12.             .observeOn(AndroidSchedulers.mainThread())
  13.             .compose(this.<ServerAPI.Response>deliverLatestCache())
  14.             .subscribe(new Action1<ServerAPI.Response>() {
  15.                 @Override
  16.                 public void call(ServerAPI.Response response) {
  17.                     getView().onItemsNext(response.items);
  18.                 }
  19.             }, new Action1<Throwable>() {
  20.                 @Override
  21.                 public void call(Throwable throwable) {
  22.                     getView().onItemsError(throwable);
  23.                 }
  24.             });
  25.     }
  26. }
  27.  
  28. @RequiresPresenter(MainPresenter.class)
  29. public class MainActivity extends NucleusActivity<MainPresenter> {
  30.  
  31.     private ArrayAdapter<ServerAPI.Item> adapter;
  32.  
  33.     @Override
  34.     public void onCreate(Bundle savedInstanceState) {
  35.         super.onCreate(savedInstanceState);
  36.         setContentView(R.layout.activity_main);
  37.  
  38.         ListView listView = (ListView)findViewById(R.id.listView);
  39.         listView.setAdapter(adapter = new ArrayAdapter<>(this, R.layout.item));
  40.     }
  41.  
  42.     public void onItemsNext(ServerAPI.Item[] items) {
  43.         adapter.clear();
  44.         adapter.addAll(items);
  45.     }
  46.  
  47.     public void onItemsError(Throwable throwable) {
  48.         Toast.makeText(this, throwable.getMessage(), Toast.LENGTH_LONG).show();
  49.     }
  50. }

就如你看到的那样,这个例子比前面的例子要简短多了。Nucleus可以创建/销毁/保存 presenter,附加或者解除和一个view的关系,并且自动向附加的view发送请求。

MainPresenter的代码变短是因为我们使用了deliverLatestCache()操作将数据源发出的所有数据与错误信息延迟到了view可用之后。它还能将数据缓存到内存中,因此可以在onfiguration change的时候重用。

警告!这里有一个注解!在安卓的世界里,如果你使用了注解,最好检查一下它是否会影响性能。

MainActivity的代码变简单了是因为presenter的创建是由NucleusActivity管理的。你只需要写上@RequiresPresenter(MainPresenter.class) 就能绑定presenter。我在Galaxy S(2010年的设备)上的检测结果显示,注解在这里

只花费了不到0.3ms。只在实例化view的时候才会发生,因此注解在这里对性能的影响可以忽略。

更多示例

带有保持请求参数的拓展示例在这里:Nucleus Example.

带有单元测试的例子:Nucleus Example With Tests

deliverLatestCache() 方法

这个RxPresenter的工具方法有三个变种:

  • deliver() will just delay all onNext, onError and onComplete emissions until a View becomes available. Use it for cases when you're doing a one-time request, like logging in to a web service. Javadoc

  • deliverLatest() will drop the older onNext value if a new onNext value is available. If you have an updatable source of data this will allow you to not accumulate data that is not necessary. Javadoc

  • deliverLatestCache() is the same as deliverLatest() but in addition it will keep the latest result in memory and will re-deliver it when another instance of a view becomes available (i.e. on configuration change). If you don't want to organize save/restore of a request result in your view (in case if a result is big or it can not be easily saved into Bundle) this method will allow you to make user experience better. Javadoc



Presenter的生命周期

Presenter的生命周期要比安卓组建的生命周期简短得多

  • void onCreate(Bundle savedState) - 在Presenter创建的时候调用 Javadoc

  • void onDestroy() - 在用户离开一个view的时候调用 Javadoc

  • void onSave(Bundle state) - 在View的onSaveInstanceState同时也是Presenter的状态保持的时候被调用 Javadoc

  • void onTakeView(ViewType view) -  在Activity或者Fragment的Resume()或者android.view.View#onAttachedToWindow()的时候调用. Javadoc

  • void onDropView() - 在Activity或者Fragment的onPause()或者android.view.View#onDetachedFromWindow()的时候调用. Javadoc

View的生命周期与view栈 

通常来说你的view(比如fragment或者自定义的view)在用户的交互过程中挂载与解挂(attached and detached)都是随机发生的。 这倒是不让presenter在view每次解挂(detached)的时候都销毁的一个启发。你可以在任何时候挂载与解挂view,但是presenter可以在这些行为中幸存下来,继续后台的工作。

关于view的周期有一个问题:fragment会因为configuration change或者从栈中去掉而不知道自己是否被解挂(detached)。

Nucleus view默认:只有在activity结束的时候,在view的onDetachedFromWindow()/onDestroy()期间才会销毁presenter。

因此,如果你要在Activity正常的生命期间销毁一个view,你必须向view发出presenter也必须销毁的信号。通过公共方法NucleusLayout.destroyPresenter()和NucleusFragment.destroyPresenter()来做这个事情。

比如,下面是fragment manager的pop()操作在我的一个项目里是如何工作的:

 
 
  1.     fragment = fragmentManager.findFragmentById(R.id.fragmentStackContainer);
  2.     fragmentManager.popBackStackImmediate();
  3.     if (fragment instanceof NucleusFragment)
  4.         ((NucleusFragment)fragment).destroyPresenter();

同样在fragment的replace操作中也要做相同的事情,在最底部的fragment销毁的时候也要如此。

你可能会决定在每次view从Activity解挂的时候都销毁presenter来避免这样的问题,但是如果这样的话,在view销毁的时候你无法继续后台任务。所以这一节的 "view recycling"完全留你你自己考虑,也许有一天我会找到更好的解决办法,如果你有一个办法,请告诉我。

最佳实践

在Presenter中保存你的请求参数

规则很简单:presenter的主要职能是管理请求。因此view不应该去处理或者开始请求。从view的角度来看,后台任务是永不消失的,总是会返回一个结果或者错误信息的,不需要任何回调的。

 
 
  1. public class MainPresenter extends RxPresenter<MainActivity> {
  2.  
  3.     private String name = DEFAULT_NAME;
  4.  
  5.     @Override
  6.     protected void onCreate(Bundle savedState) {
  7.         super.onCreate(savedState);
  8.         if (savedState != null)
  9.             name = savedState.getString(NAME_KEY);
  10.         ...
  11.  
  12.     @Override
  13.     protected void onSave(@NonNull Bundle state) {
  14.         super.onSave(state);
  15.         state.putString(NAME_KEY, name);
  16.     }

我推荐你使用酷爆了的Icepick 库。在不使用运行时注解的前提下,它减少了代码量并且简化了app的逻辑,所有的事情都在编译过程中就完成了,是ButterKnife 的好伴侣。

 
 
  1. public class MainPresenter extends RxPresenter<MainActivity> {
  2.  
  3.     @Icicle String name = DEFAULT_NAME;
  4.  
  5.     @Override
  6.     protected void onCreate(Bundle savedState) {
  7.         super.onCreate(savedState);
  8.         Icepick.restoreInstanceState(this, savedState);
  9.         ...
  10.  
  11.     @Override
  12.     protected void onSave(@NonNull Bundle state) {
  13.         super.onSave(state);
  14.         Icepick.saveInstanceState(this, state);
  15.     }

如果你有多个请求参数,这个库可以帮助你节省不少时间。你可以创建一个BasePresenter,然后将Icepick放到里面,所有的子类将自动保存被@Icicle注释的变量,你再也不需要实现onSave了。这对于Activity和Fragment或者View也同样适用。

Execute instant queries on the main thread in onTakeView Javadoc

有时候我们的数据查询量并不大,比如从数据库中读取少量的数据。虽然使用Nucleus创建一个可重启的请求非常简单,但是你不需要每次都用。如果你在fragment创建的时候初始化一个后台请求,即使只有几毫秒,用户也会看到一会儿的空白屏。因此为了代码的简洁,也为了用户的感受,使用主线程来初始化。

不要让Presenter控制View 

这种情况不好工作 - application的逻辑因为使用了不自然的方式变得非常复杂。

最自然的方式是用户的操作流从view,到presenter到model最后到数据。这样用户才是控制应用的源头。对应用的控制应该来源于用户,而不是应用的内部结构。从view,到presenter到model是很直接的形式,这样的代码也很好写,操作流是这样的user -> view -> presenter -> model -> data;但是像这样的操作流:user -> view -> presenter -> view -> presenter -> model -> data,是违背了KISS原则的。

什么?Fragment?不好意思它是违背了这种自然操作流程的。他们太复杂。这里有一篇关于看待fragment的好文章:不提倡 Android Fragment。但是fragment的替代者 Flow 并没有简化多少东西。

MVC

如果你熟悉MVC(Model-View-Controller)- 别那样做。Model-View-Controller和MVP完全不同,也并没有解决用户界面开发上的任何问题。

什么是MVC?

  • Model stands here for internal application state. It can or can not be connected with a storage.

  • View is the only thing that is partially common with MVP - it is a part of an application that renders  Model to the screen.

  • Controller represents an input device, such as keyboard, mouse or joystick.

MVC在过去以键盘为驱动的应用中(比如游戏),是比较好的模式。没有窗口和图形用户界面的交互-程序接受输入(Controller),维护状态(Model),以及显示输出(View)。数据与操作类似于:controller -> model -> view.但是这种模式在安卓中完全无用。MVC有太多的困扰。人们认为他们在使用MVC,其实使用的的MVP(web开发者)。许多安卓开发者认为Controller应该是控制view的东西,因此他们将view的逻辑从view中分离,创建一个轻量级的被代理Controller控制的view。我个人是没有看出这种架构的好处。


在数据复杂的项目中使用固定的数据结构 

AutoValue 是做这件事的一个优秀的库,在他的描述中有其优点的列表,建议阅读。有安卓的接口AutoParcel。使用固定数据对象的主要原因是你可以四处传递,而不用关心是否在程序的某个地方被修改了。而且它们是线程安全的。

结论

试试mvp吧,并告诉你的朋友。


from:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0425/2782.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
MVC(Model-View-Controller)架构是一种软件设计模式,用于将应用程序的逻辑分离为三个不同的组件:模型(Model)、视图(View)和控制器(Controller)。每个组件都有自己的职责和功能。 1. 模型(Model): 模型代表应用程序的数据和业务逻辑。它负责处理数据的获取、存储、更新和验证。模型通常包括数据结构、数据库操作、网络请求等。模型不依赖于视图或控制器,它独立于用户界面。 2. 视图(View): 视图是用户界面的可视化部分,负责展示数据给用户,并接收用户的输入。它可以是一个界面、一个页面或一个控件。视图从模型获取数据,并将其呈现给用户。视图不负责处理数据的获取或处理逻辑,它只负责展示和接收用户操作。 3. 控制器(Controller): 控制器是模型和视图之间的桥梁,负责协调用户界面和应用程序的交互。它接收用户的输入,并根据输入更新模型或视图。控制器可以处理用户事件、调用模型的方法、更新视图等操作。控制器还可以根据需要调整模型和视图之间的通信。 MVC架构的优势在于它实现了逻辑的分离,使得代码更易于维护、测试和扩展。模型和视图之间的解耦使得可以独立修改其一个组件,而不会影响其他组件。控制器作为介者处理用户交互,并协调模型和视图的更新。这种分离提高了代码的可读性、可维护性和可重用性。 在Android开发,MVC架构通常被扩展为MVPModel-View-Presenter)或MVVM(Model-View-ViewModel模式,以适应Android框架的特点和要求。这些扩展模式在MVC的基础上进一步优化了代码结构和组件之间的交互方式。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值