Android SDK开发艺术探索实践

作者:brucevanfdm

链接:

https://zhuanlan.zhihu.com/p/151406299

这是作者实际生产中的 SDK 经验,非常宝贵的经验,系列一共七篇,我分上下两篇推送给大家。

Android SDK开发艺术探索系列基于实际生产中的业务型SDK开发实践经验,具有一定的实战性与技术性,不仅包含一定业务背景下的经验之谈,还系统性地介绍了一款第三方SDK的开发过程以及相关技术的选型。

在这个系列中,你不仅能了解到如何开发一款第三方SDK,还能学习到通用的Android开发知识、软件工程思想,甚至一些奇奇怪怪的知识。

一、前言

在本章中你可以了解到如何设计一个SDK,定义与第三方交互的数据结构、数据预处理。了解到SDK与APP交互的通讯机制,包括整个流程的交互逻辑设计,规避信任问题。更能从这些设计中了解到对应的通用技术要点与设计思路。

二、SDK的定义与场景

2.1、SDK的定义

SDK全称 Software Development Kit,广义上的SDK 是为特定的软件包、软件框架、硬件平台、操作系统等建立应用程序时所使用的开发工具的集合;狭义上的 SDK 则是基于系统基础组件进行二次开发封装的、独立的、能够完成特定功能并返回相关数据的一组工具的集合。

说人话,SDK一般就是我们常见的jar包、so库、aar包,大到一套JDK,小到只有一个方法的jar包,都可以称之为SDK。SDK的本质是一系列方法、逻辑的集合,对资源与API封装后的产物。

2.2、SDK的场景

SDK的定义我们了解了,那么SDK在行业内的场景有哪些呢?SDK广泛存在于2B产品中(此处禁止联想...)。

如推送SDK,支付SDK,地图SDK、OCR-SDK、人脸识别SDK、游戏SDK等等。标准化的、可复用的SDK产品,广泛提高了中小型公司开发APP的效率。

三、SDK接口设计

设计一个SDK,有两个明确的原则贯穿始终:

一是:最小可用性原则,即用最少的代码,如无必要勿增实体;
二是:最少依赖性原则,即用最低限度的外部依赖,如无必要勿增依赖。

首先我们需要明确一下这个SDK的职责与边界,定义与宿主App的交互参数。即SDK接收什么?输出什么?

举个例子:

关键要点:

  • 出入参都有一个token,用于本次调用的关联与凭证。

  • 前端SDK设计入参时,应当尽量减少前端参数交互,相关参数尽量在获取token时传入后端服务,以此保持SDK接口调用的简洁性与调用的灵活性。


3.1、出入参设计

大多数情况下,出入参需要交互多个字段。那么就产生了数据组装的问题。

因此,必须设计一个便捷、清晰的数据结构。

SDK出入参本质上是交互一组键值对(Key-Value),因此可以考虑的方式有:

3.1.1、传递一个Map

生成一个Map,并设置对应的Key-Value。可行,但缺点是暴露了过多生成参数的细节与内部逻辑,Map的生成与KEY的设置相对不可控,也埋下了数据不一致的隐患。

Tips:采用HashMap实例在activity间传输时,需要使用bundle.putSerializable(KEY_REQ, value);

3.1.2、传递一个String

生成一个JSON格式的字符串,跟Map方案的缺点类似,组装过程中还容易产生JSON格式问题。

3.1.3、传递一个自定义实体类

通过SDK模块内置专用实体类的实例,动态设置相关参数,交互类的实例。该放方案简单易用,屏蔽了生成与设置的细节,直接通过简单的set方法接口为其赋值,并且可以在赋值时进行数据校验、限定。

入参构建示例:

//构建请求实体
final ReqContent req = new ReqContent();
req.setToken("a_valid_token");
req.setSignature("a_valid_signature");

返参获取示例:

//获取返回实体
ResultContent result = getResult(intent);
int code = result.getCode();
String message = result.getMessage();
String token = result.getToken();

Tips1:采用自定义实体类作为出入参在activity间传输时,无法直接传递实体,需要经过序列化。有两种方法可以实现

1、实体类需要实现 JDK下的Serializable接口,通过bundle传输:

bundle.putSerializable(KEY_REQ, req);

2、实体类需要实现 Android SDK下的Parcelable接口,通过bundle传输:bundle.putParcelable(KEY_REQ, req);

Tips2:需要注意的是:Intent 中的 Bundle 是使用 Binder 机制进行数据传送的,有大小限制,所以务必不要进行大数据传输,建议最大不超过500K,越小越好,避免传输图片。(实测不同版本有不同限制,500K不是一个确定的数目,感兴趣的可以自己测试一下)

3.2、调用封装设计

SDK的调用,需遵循简单、封装的原则。

主要有两种类型,一是单纯的、无界面的、不与用户交互的代码逻辑,如常见的加密、摘要生成SDK等等,直接通过一个最简单的方法调用后直接返回即可。

二是复杂的、有界面、直接接触用户的SDK,此类SDK也是本章重点讨论的类型。

背景:SDK内部有一系列承载不同业务逻辑的Activity,第三方通过启动SDK的入口Activity进行SDK调用与业务分发,那么如何做一个优雅的调用封装?

封装前的调用:

//繁琐的调用代码
Bundle bundle = new Bundle();
//这里要暴露或者约定一个静态KEY
bundle.putParcelable(KEY_REQ, request);
Intent intent = new Intent(context, EntryActivity.class);
//屏蔽转场动画,让画面跳转更和谐
intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
intent.putExtras(bundle);
context.startActivity(intent);

封装后的调用:

//简洁的调用代码
MySDK.launchSdk(context,req);

//将复杂逻辑或细节内置于SDK中,对外提供封装好的静态方法即可
public static void launchSdk(Context context, ReqContent request) {
    Bundle bundle = new Bundle();
    bundle.putParcelable(KEY_REQ, request);
    Intent intent = new Intent(context, EntryActivity.class);
    //屏蔽转场动画,让画面跳转更和谐
    intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
    intent.putExtras(bundle);
    context.startActivity(intent);
}

如此一来,不仅简化了调用,还屏蔽了一系列中间过程的模板代码,何乐而不为呢?SDK多写几行代码,可以方便开发者千万家。这里只是简单提供了一个思路,具体流程具体分析,从这里发散开去即可,但是也要避免过度封装,导致扩展、、兼容性的问题。

3.3、数据返回设计

数据返回是SDK设计中不可缺失的一环。遵循的是“调用即响应”的原则,响应具体可以分为返回码、抛异常。切勿设计出无响应、无回调的SDK逻辑。SDK内部流程处理完毕后必须给调用方一个返回,业务才能继续。具体来讲,处理了if,就必须关注else,处理了case,就必须关注default,这也是良好的java编程习惯的一部分。

业务型SDK的数据返回机制需要怎么设计呢?首先我们看下需求背景:

  • 需要在App与SDK的Activity间进行数据传输

  • 调取SDK后,需要执行一段异步逻辑,结束后需及时关闭;

  • 需要在逻辑代码处理过程中随时随地的构造返回参数并返回;

  • SDK代码需集成于各类第三方App,需要通用的交互机制,避免引起更多兼容问题。

采用startActivityForResult()? 理论可行,但不够灵活,弃用。

采用EventBus ? 避免引入非必须第三方依赖,暂时放弃。

采用BroadcastReceiver?Android 内置的四大组件之一,为组件间交互而生。而且灵活方便,与入参方法呼应,可以从Intent中传输数据。

广播又可以分为全局广播和应用内广播,全局广播可以跨App传递,基于Binder机制,在这个场景下显得过于大材小用。而应用内广播,基于Handler机制,仅限于同一应用内的数据交互,相比于全局广播无需进程间通信,效率更高、也无需担心其他应用接收广播带来的安全性问题。下面来看下应用实例:

发送数据

//SDK内部封装的静态方法,提供给SDK内部模块统一调用
public static void sendResult(String token, int code, String message) {

    ResultContent result = new ResultContent();
    result.setToken(token);
    result.setCode(code);
    result.setMessage(message);

    Bundle bundle = new Bundle();
    bundle.putParcelable(KEY_RESULT, result);

    Intent intent = new Intent();
    intent.setAction(MY_SDK_RECEIVER_ACTION);
    intent.putExtras(bundle);
    LocalBroadcastManager.getInstance(MySDK.getContext()).sendBroadcast(intent);
}

接收数据

//SDK内部封装的注册方法,提供给APP模块统一调用
public static void registerReceiver(BroadcastReceiver receiver) {
    IntentFilter filter = new IntentFilter();
    filter.addAction(MY_SDK_RECEIVER_ACTION);
    LocalBroadcastManager.getInstance(getContext()).registerReceiver(receiver, filter);
}

//SDK内部封装的反注册方法,提供给APP模块统一调用,如果在onCreate()方法中注册,则在onDestroy()方法中反注册。
public static void unregisterReceiver(BroadcastReceiver receiver) {
    LocalBroadcastManager.getInstance(getContext()).unregisterReceiver(receiver);
}


//APP 中定义的广播接收器,用于接收返回参数
public class MyReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        ResultContent result = MySDKHelper.getResult(intent);
        if (result == null) {
            return;
        }
        int code = result.getCode();
        String message = result.getMessage();
        String token = result.getToken();
        String log = String.format(Locale.CHINA,"返回码: %d 返回信息: %s Token: %s", code, message, token);
        Log.d(TAG, log);
    }
}

四、参数过滤设计

参数过滤是指在入参过程中对入参数据进行过滤,避免无意义后续业务流程,及时提供调用反馈。

常见的过滤方式有:非空检测、数据类型检测,数据格式检测。还可以通过自定义注解的方式对参数进行标记,通过编译器的检测就可以及时纠正数据类型。

一个简单的自定义注解示例

public class SourceAnnotationDemo {

    public static final String TYPE_A = "A";
    public static final String TYPE_B = "B";

    private String type;

    @StringDef({TYPE_A, TYPE_B})
    @Retention(RetentionPolicy.SOURCE)
    public @interface MyType {
    }

    public String getType() {
        return type;
    }

    //限定了该方法的参数只能是设置限定的TYPE_A或TYPE_B,否则编译器提示错误
    public void setType(@MyType String type) {
        this.type = type;
    }
}

五、交互流程设计

“前端没有绝对的安全!”

前端没有绝对的安全,因此我们在设计SDK的整体交互逻辑时,必须考虑返回结果的合法性问题。

5.1、错误的返回

在错误的返回中,由于错误的结果理论上并不会对业务造成损失,比如支付失败,业务处理流程上并不会产生异常结果,当做失败处理即可。

5.2、成功的返回

在成功的返回中,由于成功的结果理论上是继续业务流程的依据,比如支付成功后发货行为。因此必须严格判断返回的可靠性。

在前端代码中,容易被破解者通过Hook等非正常手段篡改或者伪造返回,因此不能直接信任前端返回码。一般支付的流程,都是类似的设计。

5.3、参考流程

流程要点:

  • 交互凭证(Token)从后端接口交互中返回,用于对应当次调用,此时若有更多参数需要获取,也应当从该接口中传入,保持前端接口的简洁性与数据交互的灵活性;

  • SDK处理完内部逻辑后,返回前端返回码通知App流程结束,App根据返回码类型来确定是否需要进一步确认。此处也可以精简前端返回,将更多业务字段从后端查询确认接口中返回,保持前端接口的简洁性与数据交互的灵活性。

本章介绍了SDK的接口设计,参数过滤,以及合时信任链下的流程设计。

接下来将继续介绍SDK开发中对于异常与错误返回的讨论, Exception or ErrorCode?It's a question.

一、Exception or ErrorCode

一、前言

本章介绍了Java/Android中的异常以及在SDK开发中是如何根据异常的特性进行融合设计的思考。

注意,本章并不涉及详细地异常介绍以及异常发生后代码执行地顺序问题,而是基于异常的基本特性与职责边界,结合业务开发的实际情况,试图探讨抛异常与错误码的选择。

二、异常的概念

首先,我们来认识一下异常:

异常是指在程序运行期间发生的意外事件,它会中断程序(当前方法或作用域)正常执行的流程。

异常的层次
  • Checked Exception,比如最常见的IOException,这种异常是指需要调用处显式处理的类型,要么用try catch捕获,要么用 throw 再抛出去;

  • Unchecked Exception指的是所有继承自Error(包含自身)或者RuntimeException(包含自身)的类,比如我们常见的NullPointerException就属于这类。这些异常不强制在调用处显式处理,但是也可以通过try catch处理;

  • Throwable是Exception和Error的父类。


2.1、APP开发者看待异常的角度

在APP的开发中,似乎我们总是被动的根据编译器的提示编写异常代码,只是为了让编译器通过。为什么编译器跟我们过不去?因为这些提示都是因为对于的操作抛出了Checked类型的异常,需要手动显式进行处理。

在Java开发中,NullPointerException,有部分人简称NPE,是每个开发者都很熟悉的异常,也是让人深恶痛绝的异常。

Java程序员:要么正在debug NPE,要么就在debug NPE的路上。

2.2、SDK开发者看待异常的角度

在SDK开发中,除了要面临APP开发中那些对异常的处理,还需要充分考虑被异常中断了的那些逻辑对整体交互流程产生的影响。

SDK自身的逻辑与APP密不可分,假如因为SDK内部发生的异常,中断了SDK内部流程,而又未告知APP这显然是不可接受的。因此,我们必须谨慎处理SDK内部发生的异常,原则是不应该把异常内部消化,而应该向外抛出,以同样异常的形式或者自定义的返回码。

2.3、Exception or ErrorCode,这是一个问题

在SDK的逻辑设计中,既可以通过异常告知开发者,也可以通过返回码告知开发者,那么如何选择呢?下面我们通过两个例子来探索处理的方式

  • 场景1:在SDK的入参中,需要APP传入手机号码,并校验参数合法性

  • 场景2: 在SDK内部,需要获取APP ID并进行白名单校验

这两种场景下,都需要在SDK的调用流程中处理并反馈,而反馈可以通过返回码,也可以直接抛异常(系统内置异常或自定义异常)孰好孰坏?

笔者认为,在我们实际的SDK开发中,应该通过对相关错误进行分类,来决定采用异常交互还是返回码交互。

自定义异常是一种比较“重”的告知方式,因为异常意味着中断。返回码是一种比较“轻”的告知方式,也是最不容易引起开发者重视的告知方式。

常见SDK开发者没有清晰的返回码对照表,亦常见APP开发者没有完整地处理全部返回情形。因此,不可逆转的错误建议采用异常处理机制,直接中断程序执行流程让问题彻底暴露出来,避免发生更多预期之外的错误。

具体概括为:

在与业务有关的逻辑且涉及用户(注意,非开发者,下同)可以重试的部分,应该采用返回码告知开发者,方便其进行后续处理。比如,用户输入了不符合手机号码规则的信息,就可以通过我们的自定义检查逻辑来返回对应的返回码,集成SDK的开发者针对处理即可。

在与业务无关的逻辑且一旦发生用户无法自行处理的部分,可以采用抛出自定义异常的处理方式。比如,SDK开发着对APP的包名校验,这是一个面向开发者的校验逻辑,因此不妨直接抛出自定义异常,坦然让APP崩溃,让开发者从错误堆栈信息中找到问题。这种情形,也许就是Unchecked Exception的使用场景吧。

Exception or ErrorCode,这个问题想必大家也有了一个感性认识,也欢迎耐心看至此处的读者发表自己的见解。

二、初始化

一、前言

本章系统介绍SDK的几种初始化方式,以及SDK开发历程中由于“误入歧途”而了解到的Java多重继承、ContentProvider用于SDK初始化的姿势等。通过认识初始化到初始化的几种方式,进而试图探索初始化的本质和最优解。

二、初始化的概念

在日常的开发中,可以看到我们常用的第三方SDK在使用前都需要一个初始化的步骤。那么为什么要初始化?初始化到底做了啥?

笔者认为:初始化的本质是将App的上下文(Context)注入到SDK中,使其能通过这个上下文访问到App的资源与服务。也包括在初始化时调用SDK方法进行相关选项的自定义配置。

三、初始化的几种方式

3.1、惯性思维下的歧途——自定义Application

在App开发中,我们一般会从自定义Application中获取应用的全局上下文,用于相关资源和服务的获取。示例如下:

public class App extends Application {

    @SuppressLint("StaticFieldLeak")
    private static Context sContext;

    @Override
    public void onCreate() {
        super.onCreate();
        sContext = this;
    }

    public static Context getContext() {
        return sContext;
    }
}

SDK在一定程度上是App的子集,通过抽象出App的部分业务与逻辑封装而成。因此,惯性思维下,容易将这段代码直接在SDK内部实现。殊不知,这就是歧途开始的路口...

在自定义Application对象时,需要手动在AndroidManifest.xml的application节点中,通过android:name=".App"指定,否则不会加载这个自定义的Application。

在惯性思维下,SDK开发者很容易想到一个解决方案:让App来指定加载SDK内置自定义Application对象不就行了?那么问题来了,他App已经有了自己的自定义Application了呢?

再次惯性下去:那让App继承SDK的Application不就行了?那么问题又来了,他App已经继承了其他SDK的自定义Application了呢?

继续惯性下去,已经继承了一个?那就再继承一个,来个Application多继承吧?一查,Java没提供多个类继承的直接支持,但是也能曲线实现:

通过接口实现 + 反射 的方式来创建代理Application对象,曲线实现Application的多继承,由于代码较多,这里就不贴源码了。

解决方案:

https://github.com/brucevanfdm/ApplicationProxyDemo

至此,在惯性三连下,成功误入歧途。不是说没解决问题,只是解决的过于粗暴,友善度直线下降...

我们再回头看下,SDK初始化的本质就是为SDK注入一个App的上下文,而不是注入一个自定义Application。路肯定是走歪了的,但是我们也从歪路中学到了一些奇奇怪怪的知识,安慰下自己,也算是有收获吧。换个思路,继续往前走吧!

3.2、普遍采用的静态方法初始化


再复习一下SDK初始化的本质:注入App上下文,用于获取相关资源及服务。那么,为什么要在Application里初始化呢?

我们完全可以自定义一个“伪Application类”,再通过静态方法来注入应用上下文(一般为Application Context)。示例如下:

public class MySDK {

    private static Context sContext;

    private MySDK() {
    }

    public static void initSdk(Context context) {
                //获取ApplicationContext防止内存泄漏
        sContext = context.getApplicationContext();
        initSomething();

    }

    public static Context getContext() {
        return sContext;
    }

    private static void initSomething() {
        //init something
    }

}

如此一来,便可愉快初始化了:

public class App extends Application {

    @Override
    public void onCreate() {
        super.onCreate();

        //初始化SDK
        MySDK.initSdk(this);

    }
}

如此一来,别说获取App的上下文,初始化SDK的相关逻辑也有地方写了,友善度up up up。

3.3、“无侵入”的ContentProvider初始化方案

相信细心的朋友发现了,有些SDK看起来直接引入就行,也没有进行初始化呀?那他们是怎么做的呢?答案正是利用的ContentProvider获取了应用的上下文。看个示例

以上截图来自著名工具类SDK:AndroidUtilCode  ,其实还有更多采用类似方式初始化的SDK,比如利用此方法进行初始化的鼻祖Firebase(貌似是),其他就不再一一举例了,大家平时也可以留意一下。

那么问题来了,为啥在ContentProvider就可以做初始化,并且获取到application context的呢?且看几段源码

ActivityThread-handleBindApplication
ActivityThread-installProvider
ContentProvider-attachInfo

截了三段源码,可以看到App的启动过程中加载了provider,并且传了一个Application实例进去,最终在ContentProvider中调用了onCreate()方法。因此,在自定义的ContentProvider中,通过getContext()方法就可以获取到Application的实例了。

其实从这段源码中,我们也可以看到,ContentProvider中的onCreate()方法是先于Application中的onCreate()方法执行的(注意:此时Application对象已经创建)。

关于App启动耗时的优化思路,是不是又多了一个关注点?

说完了源码再来看下SDK工程中怎么配置吧(其实开头的截图已经有了):

public class MySDKInitProvider extends FileProvider{

    @Override
    public boolean onCreate() {
        //初始化
        MySDK.initSdk(getContext());
        return super.onCreate();
    }
}

在SDK Module下的manifest中注册,编译时将会合并至主工程项目

<provider
    android:name=".MySDKInitProvider"
    android:authorities="${applicationId}.MySDKInitProvider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/my_sdk_provider_paths" />
</provider>

这里需要注意的是Provider的authorities千万别写死,否则两个引入同样SDK的App就无法共存了,这大概是SDK最不该犯的错误之一吧!

3.4、初始化新姿势-App Startup

关注Jetpack的同学可能有留意到,不久前谷歌新增了一个组件—— App Startup 。

App Startup提供了一种在应用程序启动时高效、直接初始化组件的方法。SDK开发人员和APP开发人员都可以使用App Startup简化启动顺序并显式设置初始化顺序。App Startup还允许通过定义共享的ContentProvider统一组件的初始化,大大缩短应用启动时间。

该组件的发布,也可以看到谷歌试图为混乱的ContentProvider初始化填坑,为更加可控的初始化填坑。虽然将Provider用于初始化方便了调用人员,看似“无侵入”,实则“强侵入”。

试想一下,在追求App性能与启动速度的场景中,多个SDK同时利用各自定义的ContentProvider实现“自启动”,还要在各种有先后顺序与依赖的SDK初始化下做优化,那滋味恐怕也不好受吧?

由于该组件还处于alpha的阶段,就不太建议用于生产环境中了。

三、个性化配置

一、SDK自定义配置

1.1、什么是自定义配置

在SDK开发中,常见的需求是提供一系列配置方法,用于自定义SDK行为。比如切换调试/正式模式,启动/关闭某些功能。

某推送SDK自定义配置方法示例


1.2、设计一个配置方法

前面我们介绍了自定义配置的概念,并且参考了某推送SDK实现的自定义配置方法。相信大家也能据此思想实现自己SDK的配置了吧!

但是,这种方式未免不太过瘾,我们平时开发的时候也可以看到Java代码中有一种很爽的调用方式,随便举个例子:

StringBuilder builder = new StringBuilder();
builder.append("one").append("two").append("three").length();

可以看到,连续的流式调用API很方便也很简洁。这种API实现方式又称为流式接口(fluent interface)是软件工程中面向对象API的一种实现方式。那么问题来了,这么好的API设计思想,为什么不用到我们的SDK中来,让开发者爽一把呢?

先来回顾一下SDK配置的本质:SDK配置方法的本质是为SDK相关功能提供默认配置,并且接收开发者的自定义配置,用于修改默认逻辑。

所以我们的方法中,不仅包含默认选项,还要包含修改方方法。话不多说,先上一份模板实例:

配置方法

public class MySDKConfig {

    //默认配置
    private static boolean sDebug = false;
    private static long sTimeout = 8000L;

    private static final MySDKConfig.Config CONFIG = new MySDKConfig.Config();

    public static class Config {
        private Config() {
        }

        /**
         * 设置调试模式
         *
         * @param isDebug 模式
         * @return Config
         */
        public MySDKConfig.Config setDebug(final boolean isDebug) {
            sDebug = isDebug;
            return this;
        }

        /**
         * 设置超时时间
         *
         * @param timeout 超时时间
         * @return Config
         */
        public MySDKConfig.Config setTimeout(final long timeout) {
            //此处演示了边界值的处理方式
            long minTimeout = 3000L;
            if (timeout < minTimeout) {
                sTimeout = minTimeout;
            } else {
                sTimeout = timeout;
            }
            return this;
        }
    }

    public static boolean isDebug() {
        return sDebug;
    }

    public static long getTimeout() {
        return sTimeout;
    }

    public static MySDKConfig.Config getConfig() {
        return CONFIG;
    }

}

调用示例

//一行代码,流式调用
MySDKConfig.getConfig().setDebug(true).setTimeout(8000L);

从源码实例可以看到,我们提供了一些默认配置。并通过静态内部类来实现自定义配置,并且在外层提供了getter方法,将配置提供给SDK其他模块调用。

其中实现流式调用的关键就是每个setter方法中都返回了this对象本身,就这样实现了流式API接口。

在SDK开发的场景中,由于需要配置的内容多,还涉及到默认配置,特别适合采用流式API配置方法构建自定义配置。其实回头一想,这种设计思想其实不就是简化版建造者(Builder)模式的使用场景吗?

ok,到这里我们已经介绍了 SDK 开发的大部分知识,下一篇,会介绍:

  • 安全与校验

  • 压缩与优化

  • 依赖原则与打包方法

关注我获取更多知识或者投稿

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值