【MVP+Retrofit+RxAndroid+dagger2】读易读应用框架笔记(二)网络请求与回调篇

  本文旨在学习MVP+Retrofit+RxAndroid(RxJava)+dagger2框架,已经取得作者授权,github地址为:https://github.com/laotan7237/EasyReader,作者本人的csdn地址为:http://blog.csdn.net/laotan7237/article/details/68946797,感谢作者的支持!

  在读完MVP模式框架之后,我们可以把retrofit2(注解式HTTP请求框架)+rxAndroid(响应式编程框架)加入进来,retrofit2的优势在于可以简单的进行同步和异步请求以及使用@注解,重要的概念有ServiceCall<T>@注解,Service里面存放着一组网络请求,是实际的请求者,为了业务方便我们可以把每一种业务逻辑的网络请求放在单独的一个Service当中,Call<T>是单个网络请求返回的数据,我们知道,通常返回时的数据为JSONString类型,我们以前在使用Volley的时候配合fastJsonalibaba)或GSONGoogle)将其解析为序列化类型的beanT的类型就是这个bean,因为retrofit同样是支持GSON的,@注解最常用的包括{@Path,@Url}{@GET,@Query,@QueryMap}{@POST,@Field,@FieldMap,@UrlEncoded@Body}(这里我用“{}”进行分组,并不是在使用的时候要带上“{}”)。

public interfaceDoubanService{
    StringAPI_DOUBAN="https://api.douban.com/";
    /**
     * 豆瓣热映电影,每日更新
     */
    @GET("v2/movie/in_theaters")
    Observable<HotMovieBean>fetchHotMovie();

    /**
     * 获取电影详情
     *
     * @paramid电影bean里的id
     */
    @GET("v2/movie/subject/{id}")
    Observable<MovieDetailBean>fetchMovieDetail(@Path("id")Stringid);

    /**
     * 获取豆瓣电影top250
     *
     * @paramstart从多少开始,如从"0"开始
     *@paramcount一次请求的数目,如"10"条,最多100
     */
    @GET("v2/movie/top250")
    Observable<HotMovieBean>fetchMovieTop250(@Query("start")intstart,@Query("count")intcount);
}

 

@Path@Url用于对请求的Url地址需要半动态或者动态使用的时候。半动态即请求的Url某一段动态填充(如填充user的名字),占位符/{XXX}配合@Path(“XXX”)可以实现;动态即整个Url都是动态改变的,直接在参数中应用@Url:

 @GET("users/{user}/repos")

  Call<List<Repo>> listRepos(@Path("user") String user);

 @GET

  Call<List<Repo>> listRepos(@Url String user);

 

@Query@QueryMap是针对@GET请求的,@Query用于携带简单参数,@QueryMap用来携带复杂参数,两个@注解都是将内容添加在Url的?之后构成完整的Url地址。我们先看看@Query携带参数的方式,以下面这个@Query注解为例,传入newId=1时,将针对http://102.10.10.132/api/News?newsId=1发起请求;下面这个@QueryMap注解,传入<newsId,1>,<type,类型1>...(以Map的形式)将针对http://102.10.10.132/api/News?newsId=1&type=类型1...发起请求

    @GET("News")

    Call<NewsBean>getItem(@Query("newsId") String newsId);

 

 @GET("News")

    Call<NewsBean>getItem(@QueryMap Map<String, String> map);

 

 

     //无参数

    @GET("users/stven0king/repos")

    Call<List<Repo>> listRepos();

    //少数参数

    @GET("users/stven0king/repos")

    Call<List<Repo>> listRepos(@Query("time")long time);

    //参数较多

    @GET("users/stven0king/repos")

    Call<List<Repo>> listRepos(@QueryMap Map<String, String> params);

@Field,@FieldMap@Body@FromUrlEncoded是针对与@POST请求的,不会将数据携带在Url上,而是放在请求体(body)当中,需要注意一旦@POST需要携带Field或者FieldMap参数时,必须同时携带上@FromUrlEncoded@Field携带一个简单参数,@FiledMap携带复杂参数,和@QueryMap一样使用Map传入,而@Body能够以bean对象的形式传入,需要说明的是@Query@Query同样可以使用@POST请求中,参数将不全在Url的?之后形成完整的Url,下面两个例子针对的Url都是http://102.10.10.132/api/Comments/1?access_token=1234123,只是请求体一个携带reason一个携带了CommentBean类型的对象。

@FormUrlEncoded

    @POST("Comments/{newsId}")

    Call<Comment>reportComment(

        @Path("newsId") String commentId,

        @Query("access_token") String access_token,

        @Field("reason") String reason);

   

@POST("Comments/{newsId}")

    Call<Comment>reportComment(

        @Path("newsId") String commentId,

        @Query("access_token") String access_token,

        @Body CommentBean bean);

 

 

//无参数

    @POST("users/stven0king/repos")

    Call<List<Repo>> listRepos();

    //少数参数

    @FormUrlEncoded

    @POST("users/stven0king/repos")

    Call<List<Repo>> listRepos(@Field("time")long time);

    //参数较多

    @FormUrlEncoded

    @POST("users/stven0king/repos")

    Call<List<Repo>> listRepos(@FieldMap Map<String, String> params);

回过头对@注解简单的总结和重新考虑注意事项,@GET@POST指定请求方式,@Path用于Url在?前的补全,@Query用于?后的补全,@QueryMap相当于多个@Query@Field用于Post请求,提交一个数据,@Body相当于多个@Field以对象的形式提交,@FieldMap相当于多个@Field,用Map的形式提交,@Url动态的指定Url,特别注意使用@Field时使用@FromUrlEncoded

我们学习了retrofit当中的核心知识之后,聚焦于怎么使用它,我们看到了Service里这些针对Url的请求和携带的请求体,那么第一如何能构造到Service,第二怎么发起异步请求,第三请求返回的内容怎么获取处理。先看第一点Service的构造分为两步,先构造出Retrofit实例,new Retrofit.Builder()会构造出一个Builder对象,然后链式传入baseUrladdConverterFactory(用于支持GSON的工厂),addCallAdapterFactory(用于提供rxJava响应式编程的工厂),client(传入okHttpclient)等设置后调用build方法得到的就是Retrofit对象,下面的例子是最简单的一个Retrofit构造方式,第二步就是运用这个Retrofit构造出Service,调用retrofitcreate(Service.Class)方法传入Class得到;

Retrofit retrofit =new Retrofit.Builder()

    .baseUrl("https://api.github.com/")

    .build();

 

/**
 * 豆瓣热映电影,每日更新
 */
@GET("v2/movie/in_theaters")
Observable<HotMovieBean> fetchHotMovie();

}

 拿到Service后,在需要请求的地方,我们调用对应的方法(如fetchHotMovie)就可以得到对应的返回值了,具体的运用我们在DoubanMovieDetailPresenterImpl中见到过;返回的值的类型这里被定义为了Observer<T>,这个并不是原生的Call<T>Call<T>是在retrofit2中被引用进来的使用的方式,调用call.excute()call.enqueue()分别完成同步请求和异步请求,而Observer<T>是针对rxJava响应式编程支持的返回值,是观察者模式中的被观察者。这一块我们马上在rxJava中会深入探究。

在这里我们仍有一些问题留待后面解决,除了rxJava之外,还有这里的RetrofitService实例在DoubanMovieDetailPresenterImpl的取得方式,我们留在dagger2中分析。

@Override
public void fetchMovieDetail(Stringid){
    invoke(mDoubanService.fetchMovieDetail(id),newCallback<MovieDetailBean>(){
    });
}

 Call<List<Contributor>> call =

    gitHubService.repoContributors("square", "retrofit");

call.enqueue(new Callback<List<Contributor>>() {

  @Override void onResponse(/* ... */) {

    // ...

  }

 

  @Override void onFailure(Throwable t) {

    // ...

  }});


为了能够了解retrofit2返回的数据如何被处理,我们将继续研究rxAndroidrxJava针对Android的线程优化后的框架)的相关知识,reactivex的核心概念有观察者模式,响应式,流等,后面需要了解一些操作符,这部分最复杂,它的处理都是基于我们对流的理解,我们从最基础的“响应式”来看,现在针对rx的编程几乎成为了当下的最热门,从rxAndroidrxJs核心的理念都是当数据发生变化(包括获取失败,获取成功,数据刷新)时,自主的对展示等反馈。在应用开发中我们经常遇到这样的需求,点击按钮向服务器端请求数据,数据请求过程中失败需要弹出窗口(V层)提示用户,请求成功后需要更新M层(数据库等)然后更新V层(界面),针对这样的需求,响应式框架采用观察者模式进行处理。

观察者模式是设计模式中的一种,在《大话设计模式》一书中,作者以生动的例子描述了这种模式的应用场景和各类之间的关系,当老板离开公司的时候,员工抽空放松,如果老板回来了之后,员工必须提前回到工作状态,这时候就需要使用到观察者模式,被观察者是老板,观察者是员工,假设我们分别定义两个接口ObservableObserverObservable中待实现的方法1是添加Observer到队列中,方法2是通知Observer,通常会调用Observerupdate方法,而Observer中定义一个update方法,具体的Observer会实现这个方法,将Observable状态改变时要进行的更新操作写在方法体里(比如这里的老板来后重新开始工作)。

rxAndroid中有ObservableObserverSubscriberSubscription几个类,其中Subscriber是对Observer的封装,SubscriptionObservable.subscribeSubscriber)后得到的句柄。下面是最常见的使用方式,Observable使用Create的方式构造,这里的call类似于观察者模式中的notify,通过封装,Subscriber有比update更细致的三个方法,能针对onNext(下一步),onComplete(结束操作)和onError(异常),需要注意的是如果进入onError回调之后将结束整个流程,后续的onCompleteonComplete都不会再执行。

        final Observable<String> observable = Observable.create(new Observable.OnSubscribe<String>() {

            @Override

            public void call(Subscriber<?super String> subscriber) {

                subscriber.onNext("");

                subscriber.onCompleted();

            }

        });

 

        Subscriber<String> subscriber = new Subscriber<String>() {

            @Override

            public void onCompleted() {

 

            }

 

            @Override

            public void onError(Throwable e) {

 

            }

 

            @Override

            public void onNext(String s) {

                System.out.println(s);

            }

        };

        Subscription subsription = observable.subscribe(subscriber);

Subscribe方法除了传入一个Subscriber以外,还有只提供onNext的方法,只有onNextonError的方法和直接提供onNextonErroronComplete的方法,其中onNextonError方法的call方法由Action1类提供,onError(Throwable t)要求传入的是异常的类型,下面是将三个回调分别定义的方式。

Action1<Throwable>onError=newAction1<Throwable>(){
    @Override
    public voidcall(Throwablethrowable){

    }
};

Action0 onComplete=newAction0(){
    @Override
    public voidcall(){

    }
};
Observable.just("").map(newFunc1<String,Integer>(){
    @Override
    publicIntegercall(Strings){
        returns.hashCode();
    }
}).subscribe(newAction1<Integer>(){
    @Override
    public voidcall(Integers){
        System.out.println(s);
    }
},onError,onComplete);

 

这里用到的justmaprxJava中的操作符,在rxJava当中,流是一个重要概念,SubscriberObserver)是对“流”的响应,而操作符则是对“流”的修改以达到方便响应的目的,在项目中我们经常遇到收到了异步请求的返回,返回的结果中有一部分需要进行先过滤修改之后再反映到界面上的情况,这里更改界面是对流的响应,而我们习以为常将过滤修改也混淆到“响应”过程中,在rxJava中我们可以将它抽离出来到操作符当中,常见的流变换(操作符)有justmapfromfilter等,比如采用filter选出用户id>10的,使用map将原本是Integer类型的gender(性别)转化成String类型的famalemale,然后再更新界面。

rxJava的探究我们使用流图来理解会容易很多:create是一个操作符,用于构建一个Observable(被观察者)对象,justfromdeferintervalrepeattimer)操作符也是这种,都是用来创建被观察者对象的,我们看到create方法执行的顺序是可以多次执行SubscriberonNext方法,直到最后执行onComplete方法,箭头的方向是流的方向;just方法,执行的顺序是只执行一次onNext方法后直接执行onComplete方法,上面我们写的代码里Observable.just("")这句相当于在call方法中只有Subscriber.onNext(“”),它将某种对象转化为了一个Observable对象(我们这里把String转化为了一个Observable,其实也可以是一个数组,迭代器或者其他类型),just方法起到了普通数据转化为流的作用;from方法也把对象转化为Observable对象,区别于just,它能够把对象依次发送出去,如果传入的是一个数组,那么just是将数组转化为一个整体处理,from是将数组中的每个元素作为一个个体,然后依次处理的;defer操作符与just相似,区别在于使用just的时候创建一个Observable对象,随后再改变just中传入的对象不会改变Observable对象,defer在使用时定义一个Observable,当订阅发生的时候才创建实例,如果在定义与创建过程中改变传入deferjust的值,会使用到最新的对象作为Observable对象的源,需要注意的是defer是与just配合使用的,defer中传入一个Func0对象,Func0中需要实现call()返回一个Observable,这个Observablejust构造。

Create流图

Just流图

From流图

Integer[]integers={1,2,3,4,5};
Observable observable1= Observable.from(integers);

observable1.subscribe(newAction1<Integer>(){
    @Override
    public voidcall(Integerinteger){
        Log.i(TAG,"from array---->>" +integer);
    }
});

defer使用:

a = 12;

 

Observable<String> o2 = Observable.defer(new Func0<Observable<String>>() {

@Override 

public Observable<String> call() {

return Observable.just("defer result: " + a);

}

});

 

a = 20;

o2.subscribe(new Action1<String>() {

 @Override 

public void call(String t) {

System.out.println(t);

}

});

这是from的使用方法,结合上面我们学习到的createjust使用方法,我们知道了最基础的创建Observable的方式,还有上面提到的defertimer等,我们放到后面探究。

第二类操作符是转换操作符,这类操作很多,组合使用能够处理大多数的响应前的变换,这里我们主要看一下mapfiltermap操作符将某种Observable转化成另外一种Observable,比如我们上面提到的genderInteger转化为String类型,使用方法是放在Subscribe之前,这一点正如我们所想的那样,是将转变(map)放在响应(Subscribe)前,而且转换操作可以链式叠加,这也是流作为重要概念的原因,一旦将对象转化成流之后,流又能通过转换变成新的流,观察者(Subscriber或者Observaber)只要响应最后的流。

Map流图

Observable.just("").map(newFunc1<String,Integer>(){
    @Override
    publicIntegercall(Strings){
        returns.hashCode();
    }
}).subscribe(newAction1<Integer>(){
    @Override
    public voidcall(Integers){
        System.out.println(s);
    }
},onError,onComplete);

Map操作符中传入一个Function<T,R>,其中T是转变前的类型,转变后的对象是R类型的,所以要注意被观察着是转变前的T类型,响应式响应的是R类型的。

Filter操作符从方法名上我们可以猜测到它是一个“过滤”类型的方法,在流图中我们看到输入的流中的满足filter条件的元素都保留在了filter之后输出的流当中,用于过滤类型的操作符还有ElementAt(只返回指定位置的数据)、first(取第一个满足要求的数据)、Last(取最后一个满足要求的数据)、Skip(丢弃前半部分指定长度数据)、Take(获取前半部分指定长度数据)等。

rxJava中还有很多操作符提供给我们使用,通过这些操作符我们几乎可以使用各种类型的对象构造出各种类型的流进行转换,过滤后获取到可以直接用于订阅者响应的流,这里推荐一个很详细的操作符介绍网址(http://mushuichuan.com/2015/12/11/rxjava-operator-1/),这个系列将操作符做了大类分解后进行仔细剖析,很有学习价值。

rxAndroid中针对应用开发中需要解决的线程问题做了优化和改进,加入了线程调度方法,这种方式是Handler的替代。在应用程序编写中,异步返回的数据通过网络请求获取到,而网络请求是耗时操作,我们通常将耗时操作放在单独的线程中去执行(在较新的版本中为了加强用户体验,禁止在主线程中进行网络请求等耗时操作),这意味着异步返回的地方无法更新UI(应用编译中不允许主线程以外的线程更改UI),为了解决这个问题,我们最常使用的方式是使用handler.senMessage()将获取到的数据传递到主线程中更新UI,这种方式是Handler整体较为冗杂,信息的传递使得易读性也较差,相比之下rxAndroid中的线程调度清晰明了。rxAndroid提供给我们两个方法,subscribeOnobserveOn,两个方法分别指定Observable(被观察者)和Subscriber(观察者,响应者)所处的线程,传入的参数也是既定参数,被观察着如果是网络请求异步返回的结果,传入Schedulers.io()表示是一个IO线程,观察者(响应者)一定在UI线程上,传入AndroidSchedulers.mainThread()表示是主线程。只要把这两个方法在生成的Observable订阅观察者前链式调用就能够完成线程的调度配置。

Subscriptionsubscription=observable.subscribeOn(Schedulers.io())
        .observeOn(AndroidSchedulers.mainThread())
        .subscribe(callback);

结合易读应用框架,有三点是我们需要认真研究和学习的,第一LifeSubscription接口的使用,这个接口我们在BasePresenter里有分析过,现在我们回过头思考一下这个接口在哪些地方使用到了,在整个框架中起到的作用是什么,首先在HttpUtils里我们看到,它调用到了bindSubscription方法,这时候我们想要知道这个待实现的接口方法在哪里实现了,在我们所探讨的类里,它在BaseActivity中被实现,实现方式是创建综合订阅并将HttpUtilsinvoke被调用时完成一个订阅后将订阅句柄添加到综合订阅队列,这个综合订阅在Actvity被销毁的时候被关闭,监听被关闭以避免内存泄漏。

除此之外我们考虑BasePresenterattachView中获取T类型的mView时传入的对象类型是LifeSubscription,这个attachView我们使用findUsage查找使用的时机,除了我们之前分析过的LoadingBaseActivity还有BaseFragmentLoadingBaseActivity继承自BaseActivityLoadingBaseActivity(继承自BaseActivity,是一种BaseActivity)和BaseFragment都是LifeSubscription的实现,通过观察我们还可以看到BaseFragment里同样持有继承自BasePresenterT泛型的实例和LoadingPage实例,也实现了Stateful接口,,所以BaseFragment其实和LoadingBaseActivity在框架中担当类似的责任,这里他们都是LIfeSubscription的实现,在BasePresenterattachView中传入参数的时候就可以统一传入LifeSubscription类型,这里我们思考一下,LifeSubscription的意义就是Subscription的生命周期,每一个ViewActivtyFragment)是Subscription的生命周期(Subscription伴着View的销毁而被解绑)。

private CompositeSubscription mCompositeSubscription;//综合订阅

//用于添加rx的监听的在onDestroy中记得关闭不然会内存泄漏。
@Override
public void bindSubscription(Subscriptionsubscription){
    if(this.mCompositeSubscription==null){
        this.mCompositeSubscription=newCompositeSubscription();
    }
    this.mCompositeSubscription.add(subscription);
}

@Override
protected void onDestroy(){
    super.onDestroy();
    synchronized(mActivities){
        mActivities.remove(this);
    }
    if(this.mCompositeSubscription!=null&&mCompositeSubscription.hasSubscriptions()){
        this.mCompositeSubscription.unsubscribe();
    }
}

贯穿框架的接口还有StatefulStateful接口是为了能够给具有stateLoadingPage设置状态,所以看到实现这个接口的类都持有LoadingPage的实例(BaseFragment的子类和LoadingBaseActivity的子类),同时因为只有这些类里才持有继承自BasePresenterT泛型实例,而这些T泛型实现类的实例里都持有继承自LoadingBaseActivityBaseFragmentmView(其实就是这些类本身,使用attachView绑定),因此在使用invoke的时候传入的mView都是既是LifeSubscription实现也是Stateful的实现,在HttpUtilsinvoke方法进行判断类型,并设置给target的时候,所有的lifecycle都同时是两个接口的实现。

/**BasePresenter*/

protected<T>voidinvoke(Observable<T>observable,Callback<T>callback){
    this.callback=callback;
    HttpUtils.invoke((LifeSubscription)mView,observable,callback);
}

 

/**HttpUtils*/

public static<T>voidinvoke(LifeSubscriptionlifecycle,Observable<T>observable,Callback<T>callback){

Statefultarget=null;
if (lifecycleinstanceofStateful){
    target=(Stateful)lifecycle;
    callback.setTarget(target);
}

}

 

最后一个值得研究的是CallBack,可以看到rxAndroidRetrofit产生的Service返回的Observer<T>绑定在了CallBack<T>上,我们可以猜测CallBackSubscriber类的子类,并且将异步返回的结果用于更新View,我们跟到CallBack类实现中去看一看,我们看到了setTarget方法,这里就是上面我们我们分析的invoke中讲两个接口实现类(继承自BaseFragment或者LoadingBaseActivityView)设置为target的方法,这个targetdetach时被指向空引用,我们通过findUsage找到CallBackdetach的地方是BasePresenterViewdetach的时候,这与我们想到的逻辑相吻合。除此之外CallBack类实现了Subscriber的方法,onNextonCompleteonError,为了更加灵活的处理,我们又将onNext中定义了onResponse,在onError中定义了onFail方法,而两个方法被调用的时候需要将targetstate置为相应的状态,onResponse(其实就是onNext)中将target全部抽象转化为了BaseView(最父类)调用了其refreshView方法,这个refreshView真正的实现其实在target里。

仍然以DoubanMovieDetailActivity中为例,他实现了DoubanMovieDetailPresenter.View接口,而这个接口为BaseView并制定了TMoviewDetail,并用attachView使得DoubanMovieDetailActivity与泛型TMovieDetaiPresenterImplBasePresenter绑定,而MovieDetailPresenterIml中的invoke请求获得Observable,泛型也为MoviewDetail,并将它和泛型为MoviewDetailCallBack绑定,收到onNext之后,会调用持有的ViewDoubanMovieDetailActivity)中实现的具体的refreshView,这时候就能使得View更新界面了。

@Override
public void refreshView(MovieDetailBeandata){
    mMoreUrl=data.getAlt();
    mMovieName=data.getTitle();
    tvFormerly.setText("原名:"+data.getOriginal_title());
    tvOneRatingNumber.setText(data.getRatings_count()+"人评分");
    tvOneCity.setText("制作国家/地区:"+data.getCountries()+"");

    List<PersonBean>castsList=data.getCasts();
    for(finalPersonBeanpersonBean:castsList){
        ImageViewimageView=newImageView(this);
        imageView.setLayoutParams(newViewGroup.LayoutParams(ConvertUtils.dp2px(120),ConvertUtils.dp2px(200)));
        imageView.setScaleType(ImageView.ScaleType.FIT_XY);
        GlideUtils.loadMovieTopImg(imageView,personBean.getAvatars().getLarge());
        hsFilm.addView(imageView);
        imageView.setOnClickListener(newView.OnClickListener(){
            @Override
            public voidonClick(Viewv){
                WebViewActivity.loadUrl(MovieTopDetailActivity.this,personBean.getAlt(),"加载中。。。");
            }
        });
    }
    tvMovieTopDetail.setText(data.getSummary());
}

 

 

public classCallback<T>extendsSubscriber<T>{
    privateStatefultarget;

    public voidsetTarget(Statefultarget){
        this.target=target;
    }

    public voiddetachView(){
        if(target!=null){
            target=null;
        }
    }

    @Override
    public voidonCompleted(){

    }

    @Override
    public voidonError(Throwablee){
        e.printStackTrace();
        onfail();
    }

    @Override
    public voidonNext(Tdata){
        TODO: 2017/3/22这边网络请求成功返回都不一样所以不能在这里统一写了(如果是自己公司需要规定一套返回方案)
        
///TODO: 2017/3/22这里先统一处理为成功   我们要是想检查返回结果的集合是否是空,只能去子类回掉中完成了。
        
target.setState(AppConstants.STATE_SUCCESS);
        onResponse();
        onResponse(data);
    }

    public voidonResponse(Tdata){
        /**
         * 如果喜欢统一处理成功回掉也是可以的。
         *不过获取到的数据都是不规则的,理论上来说需要判断该数据是否为null或者list.size()是否为0
         * 只有不成立的情况下,才能调用成功方法refreshView/()。如果统一处理就放在每个refreshView中处理。
         */
        ((BaseView)target).refreshView(data);
    }

    public voidonResponse(){
    }

    public voidonfail(){
        if(!NetworkUtils.isAvailableByPing()){
            ToastUtils.showShortToast("你连接的网络有问题,请检查路由器");
            if(target!=null){
                target.setState(AppConstants.STATE_ERROR);
            }
            return;
        }
        ToastUtils.showShortToast("程序员哥哥偷懒去了,快去举报他");
        if(target!=null){
            target.setState(AppConstants.STATE_EMPTY);
        }
    }
}

最后一讲将讲一下关于dagger2的应用知识,了解如何使用注解式编程将整个框架串联起来。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值