为了账号安全,请及时绑定邮箱和手机立即绑定

android架构设计之插件化、组件化

标签:
Android

如今移动app市场已经是百花齐放,其中有不乏有很多大型公司,巨型公司都是通过app创业发展起来的;app类型更加丰富,有电子商务,有视频,有社交,有工具等等,基本上涵盖了各行各业每个角落,为了更加具有竞争力app不仅功能上有创性,内容也更加多元化,更加饱满,所以出现了巨大的工程。这些工程代码不停添加如果没有一个好的架构所有代码将会强耦合在一起,功能直接也会有很多依赖,那么就会出现很多问题;例如:

  1. 修改功能困难,牵一发动全身。很多地方如果api写的不好,封装的不优雅,那么就会出现改一个地方需要改很多地方的调用。

  2. 更新迭代工作中冗余废弃代码资源过多造成删除冗余变得很复杂,并且很可能出现很多bug。

大型app有哪些架构解决方案?

在编码结构上有:

  • mvc

  • mvp

  • mvvm

从项目结构上有:

  • 插件化

  • 组件化

这里我们一个个来分析说明:

首先我们来看看编码设计模式,上面的模式都是抽象模式,所以这个具象界定没有官方一致的规定。所以要根据自己的理解和解释都有自己的一套mvc模式,不一定是具象到什么细节这类的,下面讨论的也是会举出例子来说明自己的理解。

代码设计模式

MVC全名是Model View Controller,是模型(model)-视图(view)-控制器(controller)的缩写,一种软件设计典范,用一种业务逻辑,数据,界面显示分离的方法组织代码,将业务逻辑聚集到一个部件里面,在改进和个性化定制界面及用户交互的同时,不需要重新编写业务逻辑。mvc被独特的发展起来用于映射传统的输入,处理和输出功能在一个逻辑的图形化用户界面的结构中。

举个栗子:具有生命周期的activity相当于controller,自己开发封装用于获取数据(网络请求,本地数据,数据处理逻辑等)的api相当于model,xml控件和自定义控制控件显示数据的逻辑相当于view。

mvc模式是非常常见的模式,基本上有基本概念就能按照这个模式进行开发,这里就不过多讨论了。

MVP全称:Model-View-Presenter;MVP是从经典的MVC演变而来,它们的基本思想有相通的地方:Controller/Presenter负责逻辑的处理,model提供数据,view负责显示。

举个栗子:Adapter相当于presenter控制,控制数据与显示的分离,向adapter喂食数据的api获取处理数据相当于model,支持adapter的显示的控件相当于view层。

mvp是从mvc基础上衍生出来的,mvp看上去与mvc好像没有什么差别,但是实际不然,mvc model数据与view层组合是直接组合,难免会产生耦合,这样model复用性有一定缺失。mvp优化这种结构,他抽象出来一个接口规则,那么view需要支持这个规则,而model按照这个规则向里面喂食数据。这样解开耦合model view,这样model与view链接逻辑都是用presenter控制。

MVVM是Model-View-ViewModel的简写。微软的WPF带来了新的技术体验,如Silverlight,音频,视频,3D,动画......,这导致了软件UI层更加细节化,可定制化。同时,在技术层面,WPF也带来了诸如Binding、Dependency Property、Routed Events、Command、、DataTemplate、ControlTemplate等新特性。MVVM(Model-view-ViewModel)框架的由来便是MVP(Model-View-Presenter)模式与WPF结合的应用方式时发展演变过来的一种新型架构框架。它立足于原有MVP框架并且把WPF的新特性糅合进去,以应对客户日益复杂的需求变化。

举个栗子:使用databing可以搭建mvvm架构,获取网络数据封装api相当于model,数据处理后分给databing设置界面绑定数据源和界面上绑定的逻辑相当于viewmodel层,用于最终实现的控件相当于view层。

其实看到上面的似乎有点模糊不清楚,用mvp作为参照,只是p层替换成了vm层,增加xml功能属性,能够利于view层属性方法来扩展功能,将一些与界面相关逻辑处理加入这层,更加细分了抽象层次。

android组件化方案

组件化

android studio改变了项目构建方式,eclipse环境下的工作空间和project变成现在的module项目,这样类别虽然不精确但是这个不是重点,重点他加入项目构建工具gradle使得我们项目构建变得非常简单了。接下来用一个项目组件化方案来体会一下项目组件化。

组件化好处:

  1. 架构清晰业务组件间完成解耦合。

  2. 每个业务组件都可以根据BU需求完成独立app发布。

  3. 开发中使开发者更加轻松,开发中加快功能开发调试的速度。

  4. 业务组件整体删除添加替换变得非常轻松,减少工程中的代码资源等冗余文件。

  5. 业务降级,业务组件在促销高峰期间可以业务为单元关闭,保证核心业务组件的顺利执行。

项目组件化方案

概述:

  1. module library切换。

  2. 组件间跳转uri跳转。

  3. 组件间通讯 binder机制。

首先看看项目中的角色:

从上图可以发现有一根业务总线将所有组件串联起来,其中组件总线相当于主工程(壳工程module),而业务组件相当于工程中(module/library)。可以看出组件化实现可以有自己认定的维度,这里只是使用了最常用的维度按照业务区分组件。

上面是从抽象角度来描述的一张图,下面我们从具体角度来看看工程结构:

从图片可以看出,主要有三个角色:

  1. 主工程(壳工程module):主要负责事情不塞入任何具体业务逻辑,主要用于使用组合业务组件,初始化配置和发布应用配置等操作。

  2. 组件(module/library):主要实现具体业务逻辑,尽可能保证业务独立性,例如现在手淘这样一个大型的app几乎每个bu功能块都能拿出来作为一个独立业务app。但是没有大型也可以按照小一些的业务逻辑来区分组件,例如:购物车组件,收银台组件,用户中心组件等,具体根据自己的项目需要来划分。

  3. 公共库(library):公共使用的工具类,sdk等库,例如eventbus,xutils,rxandroid,自定义工具类等等,这些库可以做成一个公共common sdk,也可以实现抽离很细按照需求依赖使用。

他们之间的关系则是 主工程依赖组件,组件依赖公共库。

组件开发中分为两种模式一种开发测试模式,一种是发布模式:

  1. 开发测试模式:这种模式下面组件应该是独立module模式,module是可以独立运行的,只要保证他对其他业务没有依赖就可以独立开发测试。

  2. 发布模式:这时候组件应该library模式被主工程依赖组合,发布运行,所有业务将组合成完整app发布运行。

上面模式提出了几个问题我们可以一一来解决;

问题一:上面两种模式要求组件一会是module,一会是library这样切换是如何实现的?

问题二:业务之间跳转如何进行跳转?

问题三:虽然业务组件相对独立,但是如果有时候一定需要获取其他组件运行时某些状态下数据,也就是组件数据间的数据互通如何实现?

问题一:业务组件module/library切换解决方法

是用gradle轻松可以解决这个问题;每当我们用AndroidStudio创建一个Android项目后,就会在项目的根目录中生成一个文件 gradle.properties,我们将使用这个文件的一个重要属性:在Android项目中的任何一个build.gradle文件中都可以把gradle.properties中的常量读取出来;那么我们在上面提到解决办法就有了实际行动的方法,首先我们在gradle.properties中定义一个常量值 isPlugin(是否是组件开发模式,true为是,false为否):
isPlugin=false

然后我们在业务组件的build.gradle中读取jsPlugin,但是gradle.properties还有一个重要属性:gradle.properties中的数据类型都是String类型,使用其他数据类型需要自行转换;也就是说我们读到isPlugin是个string类型的值,而我们需要的是boolean值,代码如下:

if (isPlugin.toBoolean()) {apply plugin: 'com.android.application'} else {apply plugin: 'com.android.library'}

这样可以轻松设置isModule就可以变成切换module、library。

接下来要解决的就是包名,要知道library是不需要包名的,那么就可以这样操作:

defaultConfig {if (isPlugin.toBoolean()){applicationId 'com.example.rspluginmodule' }....}

最后还要处理androidManifest.xml问题,因为library,module的主配置文件是有区别的:

可以这样处理首先在main文件夹中创建release文件夹然后拷贝一份androidManifest进入release文件夹,那么发布模式下使用的就是release文件夹下面的androidManifest,而开发模式下用的就是默认的androidManifest,这样就要对release文件夹下面的androidManifest进行修改因为开发模式下release文件夹下面是用来给library使用的。

结构如图:

修改内容release文件夹androidManifest内容为:

<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android"xmlns:tools="http://schemas.android.com/tools"package="com.example.rspluginmodule">.........<application><activity            android:name="com.example.rspluginmodule.RSPluginTestActivity"android:exported="false"android:screenOrientation="portrait"><intent-filter><data                    android:host="sijienet"android:path="/plugin_uri_path"android:scheme="app_schem" /><action android:name="cn.com.bailian.plugin.VIEW_ACTION" /><category android:name="android.intent.category.DEFAULT" /></intent-filter></activity>........</application></manifest>

可以发现上面去掉了application很多module使用的属性。

<?xml version="1.0" encoding="utf-8"?><manifest xmlns:android="http://schemas.android.com/apk/res/android"xmlns:tool="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"package="com.example.rspluginmodule">........<application        android:allowBackup="true"android:label="@string/app_name"android:supportsRtl="true"android:theme="@style/AppTheme"tools:replace="android:allowBackup"><!--测试入口activity 只有在module环境下配置--><activity android:name=".RSMainActivity"><intent-filter><action android:name="android.intent.action.MAIN" /><category android:name="android.intent.category.LAUNCHER" /></intent-filter></activity><activity            android:name="com.example.rspluginmodule.RSPluginTestActivity"android:exported="false"android:screenOrientation="portrait"><intent-filter><data                    android:host="sijienet"android:path="/plugin_uri_path"android:scheme="app_schem" /><action android:name="cn.com.bailian.plugin.VIEW_ACTION" /><category android:name="android.intent.category.DEFAULT" /></intent-filter></activity>..........</application></manifest>

那么androidManifest文件已经建立好了,接下来就要修改gradle配置了;

sourceSets {main {if (isPlugin.toBoolean()){manifest.srcFile 'src/main/AndroidManifest.xml'}else {manifest.srcFile 'src/main/release/AndroidManifest.xml'}}}

这样问题一就全部解决了,我们可以轻松使用isPlugin来切换业务组件的module和library。接下来我们看看业务组件间如何进行跳转问题。

问题二:业务组件间跳转解决方法

不管是开发模式或者是发布模式我们都需要处理界面间跳转问题,在业务运行阶段经常会有跳转到不同业务组件的界面的需求,我们用什么方法是可以解决这个问题,其实andorid本身提供了这种机制,而且在很多地方都在使用。例如:调用系统拍照功能,电话功能等这些功能都是在不同app当中,你也可以理解为不同的module当中,他们之间的调用底层都是进程通讯,实现手段非常简单,就是是使用意图筛选器。

可以看到上面的androidManifest中配置组件activity时候都要配置意图筛选器;

<intent-filter><data                    android:host="sijienet"android:path="/plugin_uri_path"android:scheme="app_schem" /><action android:name="cn.com.bailian.plugin.VIEW_ACTION" /><category android:name="android.intent.category.DEFAULT" /></intent-filter>

我们就可以通过隐式意图方式打开新的其他组件activity;举个例子我要打开RSPluginTestActviity类;就可以调用下面的方法。

public RMRouter jump(Activity activity,String url, String parm, int animId){if (url==null)return this;Log.i(TAG,"jump page=="+url);Log.i(TAG,"jump page parm=="+parm);Intent intent=new Intent(RMConfig.ROUTER_URL_ACTION, Uri.parse(url));intent.addCategory(Intent.CATEGORY_DEFAULT);intent.putExtra(RMConfig.ROUTER_PARM, parm);PackageManager packageManager=activity.getPackageManager();List<ResolveInfo> resolveInfos = packageManager.queryIntentActivities(intent, 0);if (! resolveInfos.isEmpty()){activity.startActivity(intent);selectTranlateAnim(activity, animId);}else {Log.i(TAG,"no page");}return this;}

上面使用的是uri跳转,也可以简单点使用跳转。

这里还有一个就是规范问题:

  1. 组件命名规范,java类名加大些前缀,例如RSPluginTestActivity RS就是前缀,类似ios要求的代码约定,xml,image等资源文件使用对应前缀例如rs_.

  2. 组件内的actviity,service系统组件要遵守rest风格(rest风格把业务对象看作资源用唯一uri标识调用),组件间尽量能够通过uri唯一标识调用,不用过多业务bean传递依赖。

这样问题二组件跳转问题就解决了。接下来就来解决最后一座大山问题了:

问题三:组件间通讯问题

组件间如果按照规范应该业务逻辑独立,对其他模块没有耦合的情况,但是有时候要发生那么数据交换的话要怎么解决?如果严格按照rest风格业务组件每块只需要通过界面间跳转的方式就可以轻松通过intent将数据传输过去,基本上可以满足组件间数据传递分问题。但是这个只是简单跨界面数据传递,那么如果要是没有界面跨越也想组件间数据传递那么要怎么解决?类似web开发http协议可以通过get post传递数据,那么不跳页的时候数据应该如何通讯,web提供了ajax机制。那么android提供什么机制满足我们需求?

如果按照解耦合的方式开发,我们业务组件间是可以独立存在,并且不需要依赖其他业务组件,如果公共部分就可以提取成公共业务组件工具库library,但是开发需求总是非常多变,如果有时候有这情况时我们就要用到进程通讯aidl。首先要知道组件开发模式下的组件都是独立module,你们每个独立的module都是独立进程;在发布模式下面每个业务组件又是library,那么进程变成了一个aidl解决进程间通讯,系统组件activity,service通讯问题的方案。aidl实际上是android提供生成binder接口的方法而已,实际上底层使用的都是binder机制。

binder机制这里简单介绍一下,他基本上贯通了怎么android系统和应用,首先他是android首选进程通讯机制(IPC),android是建立在linux kernel之上的所以他支持linux的IPC方式,例如:网络链接进程通讯(Internet Process Connection):管道(Pipe),信号(Signal)和跟踪(Trace),插口(Socket),报文队列(Message),共享内存(Share Memory)和信号量(Semaphore)。那么为什么还要出现binder机制那是因为它是针对移动端这种时效性快,资源消耗低而设计出来了,是移动端首选的进程通讯方式。从binder应用的范围就知道他重要性,除了Zygote进程和SystemServer进程之间使用的socket通讯之外,基本上其他进程通讯都是用的binder方式。

首先我们来看一张图:

可以发现每个module都provider属于自己提供出去的action,这样这些可以在提供其他业务组件调用。这时候provider端相当于服务端,提供处理后数据,调用相当于客户端。

下面看看binder机制实现方法:

首先第一步创建进程通讯接口:

CommonProvider.java

/** * 作者: 李一航 * 时间: 18-1-4. */public interface CommonProvider extends IInterface {String getJsonData(String jsonParm) throws RemoteException;}

这里只是创建一个action function例子,可以根据自己需要创建多个action function。

接下来创建service端实现基类:

CommonStub.java

/** * 作者: 李一航 * 时间: 18-1-4. */public abstract class CommonStub extends Binder implements CommonProvider {public static final String DESCRIPTOR="com.ffmpeg.bin.CommonProvider";public static final int ACTION_1 = IBinder.FIRST_CALL_TRANSACTION;public CommonStub() {this.attachInterface(this, DESCRIPTOR);}@Overridepublic IBinder asBinder() {return this;}@Overrideprotected boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {switch (code){case INTERFACE_TRANSACTION:{reply.writeString(DESCRIPTOR);return true;}case ACTION_1:{data.enforceInterface(DESCRIPTOR);String parm = data.readString();String jsonData = getJsonData(parm);reply.writeNoException();reply.writeString(jsonData);return true;}}return super.onTransact(code, data, reply, flags);}}

service端实现了binder机制回调动作:onTransact方法。这里将会调用继承类的接口实现此方法:getJsonData

接下来创建远程代理类:

CommonProxy.java

/** * 作者: 李一航 * 时间: 18-1-4. */public class CommonProxy implements CommonProvider {private IBinder binder;public CommonProxy(IBinder binder) {this.binder = binder;}public static CommonProvider asInterface(IBinder iBinder){if (iBinder==null)return null;IInterface iInterface = iBinder.queryLocalInterface(CommonStub.DESCRIPTOR);if (iInterface!=null && iInterface instanceof CommonProvider)return (CommonProvider)iInterface;return new CommonProxy(iBinder);}@Overridepublic String getJsonData(String jsonParm) throws RemoteException {Parcel data = Parcel.obtain();Parcel reply = Parcel.obtain();String result=null;try {data.writeInterfaceToken(CommonStub.DESCRIPTOR);data.writeString(jsonParm);binder.transact(CommonStub.ACTION_1, data, reply, 0);reply.readException();result=reply.readString();}catch (Exception e) {e.printStackTrace();}finally {data.recycle();reply.recycle();}return result;}@Overridepublic IBinder asBinder() {return binder;}}

使用代理可以拿到接口代理对象完成action操作。

三个binder使用的类,接口可以作为基础类库使用。接下来可以完成远程调用例子:

创建service业务实现类: CommonProviderImp.java

/** * 作者: 李一航 * 时间: 18-1-4. */public class CommonProviderImp extends CommonStub {@Overridepublic String getJsonData(String jsonParm) throws RemoteException {// TODO: 18-1-4 provider action logicreturn "result data string+parm:"+jsonParm;}}

这里只完成了简单的输入输出,方便理解测试;

创建调用service类:

PluginProviderService.java

/** * 作者: 李一航 * 时间: 18-1-4. */public class PluginProviderService extends Service {@Nullable@Overridepublic IBinder onBind(Intent intent) {return new CommonProviderImp();}}

在androidManifest.xml配置service信息:

<service android:name="com.ffmpeg.bin.PluginProviderService"><intent-filter><action android:name="android.intent.action.PROVIDER_MAIN_ACTION"/><category android:name="android.intent.category.DEFAULT"/></intent-filter></service>

上面provider暴露的action function完成了,接下来就在其他业务组件中完成调用测试。

创建调用测试actviity调用:

ClientActivity.java

/** * 作者: 李一航 * 时间: 18-1-4. */public class ClientActivity extends AppCompatActivity implements ServiceConnection {private CommonProvider provider;@Overrideprotected void onCreate(@Nullable Bundle savedInstanceState) {super.onCreate(savedInstanceState);Intent intent=new Intent();intent.setComponent(new ComponentName(getPackageName(), PluginProviderService.class.getName()));bindService(intent,this, BIND_AUTO_CREATE);}@Overrideprotected void onDestroy() {super.onDestroy();unbindService(this);}@Overridepublic void onServiceConnected(ComponentName componentName, IBinder iBinder) {provider = CommonProxy.asInterface(iBinder);try {Log.i(ClientActivity.class.getSimpleName(), provider.getJsonData("input"));} catch (RemoteException e) {e.printStackTrace();}}@Overridepublic void onServiceDisconnected(ComponentName componentName) {provider=null;}}

组件化通讯就这样完成了,这里为了好理解代码都是使用了最简单样子,可以在项目进行封装优化来适合项目复用。

组件化总结

组件化是大型app开发中非常重要架构设计,其实上面贡献的只是组件化方案的核心部分,要是一套完整组件化还需要处理很多细节问题,在开发中还要不断封装,优化,复用等才能够使得架构清晰健壮。上面组件化方案中主要涉及到的知识点并不复杂,概括一下有gradle项目配置,router uri,binder进程间通讯等。组件化重要的思想,现在很多文章有各种各样的方案,理清思路选择适合自己项目的架构才是最重要的。

android插件化方案

有了上面组件化方案理解之后,插件化理解也不是难事,首先我们还是用业务为界限来划分组件内容;开发模式下面module本来就是一个独立app,只是发布模式下变成library,那么插件化就是不存在发布模式开发模式,每个组件业务就是一个独立apk开发,然后通过主工程app动态加载部署业务组件apk。

插件化带来的好处:

  1. 业务组件解耦和,能够实现业务组件热拔插。

  2. 更改了公司开发的迭代模式,从以前以整个app发版流程更改为app发版和业务插件发版流程。

  3. 实现了用户不需要更新App版本完成bug修复和业务组件升级等热修复功能。

  4. 组件化有的好处插件化都有,因为插件化建立在组件化架构之上。

那么插件化带来的都是好处,有没有缺点呢?现在很多各式各样的开源插件框架都有各自优点和缺点;接下来我们还是以问题的形式来解决这样问题。

首先我们来了解一下插件化实现的原理,由于插件化原理涵盖内容太多这里只是介绍一下核心内容;我们了解一下app打包过程。请看下图:

上面是android打包形成apk的一个过程,可以发现android开发主要的部分是整合编译代码,整合编译资源,然后就是安全签名保证apk完整性。我们再看一张图:

上面是一个apk解压之后的文件,可以看出,里面几个比较重要的部分:

  1. dex文件java编译之后的代码文件。

  2. app中使用资源文件。

  3. so文件动态链接库。

而插件化动态加载部署这些内容。加载上面的内容就产生几个技术问题:dex文件加载,资源文件加载,so文件加载部署,activity,service等android组件的动态注册。

首先是dex文件加载:

public static DexClassLoader readDexFile(Context context, String apkPath, String outDexPath){DexClassLoader dexClassLoader=null;try {dexClassLoader=new DexClassLoader(apkPath, getOutDexpaPath(context, outDexPath), context.getApplicationInfo().nativeLibraryDir, context.getClassLoader());} catch (Exception e) {e.printStackTrace();Log.i(tag,""+e.getMessage());}return dexClassLoader;}

夹在apk中dex就是通过dexclassloader api来加载的,通过dexclassloader就可以调用apk中java代码,接下来就是通过反射机制去替换app的classloader,这一步步骤对android framework源码依赖非常大,然后通过ClassLoader的双亲机制将主工程app的ClassLoader设置成父级classloader,这样加载dex步骤就完成了,具体实现技术篇幅太大以后有空会专门出一篇文章。

然后是加载apk中资源文件:

public static Resources readApkRes(Context context, String apkPath){Resources resources1=null;try {AssetManager assetManager=AssetManager.class.newInstance();Method addAssetPath = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);addAssetPath.invoke(assetManager, apkPath);Resources resources = context.getResources();resources1 = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());} catch (Exception e) {e.printStackTrace();Log.i(tag,""+e.getMessage());}return resources1;}

通过上面的方法可以加载出资源内容,接下来也是通过反射替换app默认加载的mResources对象,这样资源加载完成。

上面比较核心的功能思路,由于篇幅还有很多细节这里不能够全部列出,比如so文件加载部署,activity,service等android组件的动态注册都是非常依赖源码的操作,都是要使用大量的反射来修改系统的成员变量等,所以其实插件化实施起来最大的困难就是适配机型的源码,所有成本就在这里。那么我们该不该用插件化?首先根据公司项目实际情况认定,需不需要插件化提供的热更新功能,如果需要可以选择插件化。接下来我们来看看市面上插件化框架对比!

每一款框架都是自己优点和缺点,但是似乎都不能满足所有功能,这里我总结一下什么时候应该用到插件化,首先不是所有地方都是插件化而是局部使用,符合一下条件可以考虑使用:

  1. 项目一些偏向ui界面具有更新要求快的模块可以使用,例如一些出销页面更新较快的地方。

  2. activity为主,大部分框架对activity支持较好。

  3. 对so等第三方库依赖较少,so对插件化兼容性稳定考验比较大。

  4. 没有使用sop切面编程的代码。

speed-tools插件化框架使用

这里自己写了一个对源码侵入性小的插件化框架speed tools。

github: speed-tools 可以的话可以 star 一下 ^-^

首先看看项目结构:

libspeedtools: 插件化核心功能library modulehostmain:宿主工程主工程,负责加载部署apk moduleclientone:测试业务apk 1 moduleclienttwo:测试业务apk 2 libimgutils:测试imageloader图片框架

注意:需要使用speed tools 只需要依赖libspeedtools即可,然后开始配置插件化步骤:

首先在moduleclientone中创建业务逻辑类:TestClass.java

总结:

1、插件化在项目中还是加入很多机制,如果主工程下载更新机制,配合后台区分用户版本发布能够支持的业务插件等,这些东西加起来是个庞大的工程;

2、现在市面上很多成熟的插件框架都android framework依赖还是很重,但也有侵入性小的框架,例如speed tools。

3、插件架构不是全局使用就好,而是根据自己的需要结合实际需求来使用,常更新有不满足html5提供用户体验的情况比较合适。

原文链接:http://www.apkbus.com/blog-822415-78260.html

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消