Android 组件化之初探

这是一个APP臃肿的时代!所以为了告别APP臃肿的时代,让我们进入一个U盘时代,每个业务模块都是一个具备独立运行的U盘,插在哪里都可以完美运行,这就是推进业务组件化的初衷也是一个美好的愿景。

目前大部分app的单一项目结构原型。大致如下图所示:


app1.png

一眼望去这结构不是挺清晰的么?每个业务都放在单独的包名下,网络库、图片加载库等第三方库与上层业务都完美的剥离了,我们再来看下他们的直接的依赖关系图:

app2.png

虽然上面的依赖关系举例有点太过于极端,但是真实场景中是存在的。各个业务之间代码互相引用,所以这种结构也是架构整改的根本动机,也是当务之急应该考虑的事情。为了更好的满足各个业务的迭代而彼此不受影响,更好的解决上面这种让人头疼的依赖关系,开始着手app架构整改。

从上面的分析我们可以得出适合业务组件化的几种情况:

  • 业务较多、而且复杂
  • 业务之间的依赖需要解耦
  • 团队成员较多,需要各自开发相对独立的业务

业务组件化方向:

APP业务组件化架构整改的方向就是告别结构臃肿的时代,让各个业务变得相对独立。模块工程和类库工程之间遵循向下依赖关系,各个模块之间的遵循平级依赖关系。先看下整改后的各个独立业务模块与类库工程之间的向下依赖关系图:

app3.png

整改的方向由一个项目工程拆分成若干个模块工程,由app壳工程提供统一的入口,每个业务独立的模块module共享项目的依赖库。由壳工程集成需要引入的业务模块,至于各个独立的业务模块之间的调用依赖关系,我们借助一个中间层充当路由功能,这个路由我们放在各个业务模块共同引用的依赖库那一层。由路由统一调度他们之间的依赖关系,路由调度解决平级依赖问题示意图:

app4.png

通过APP壳工程提供的路由功能,各个模块之间调用不再采用传统的显式调用,而是采用隐式调用的方式。从而使各个模块之间不再存在依赖关系。

组件化的实现

  • 子模块单独编译
  • sdk和第三方库的版本一致性
  • 资源重复定义
  • 模块之间页面跳转
  • 模块之间数据传递
  • 模块初始化处理

APP业务组件化架构整改带来的好处:

  • 加快迭代速度,各个业务模块组件更加独立,不再因为业务耦合情况,在发版时候,由于互相等待而迟迟不能发布版本。
  • 稳定的公共模块采用依赖库方式,提供给各个业务线使用,减少重复开发和维护工作量。
  • 迭代频繁的业务模块采用组件方式,各业务线研发可以互不干扰、提升协作效率,并控制产品质量。
  • 为新业务随时集成提供了基础,所有业务可上可下,灵活多变。
  • 降低团队成员熟悉项目的成本,降低项目的维护难度。
  • 加快编译速度,提高开发效率

1、子模块如何单独编译

我们希望在开发模式下,能够单独调试自己的模块,编译成独立的apk。而在主程序发布时,成为一个library
嵌入主工程。
首先在子模块build.gradle中定义常量,来标示模块目前是否处于开发模式def isDebug = true
在子模块的build.gradle中进行模式配置。debug模式下编译成独立app,release模式下编译成library。

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

两种模式下模块AndroidManifest.xml文件是有差别的。作为独立运行的app,有自己的Application,要加Launcher的入口intent,作为library不需要。这个问题很好解决,写两个不同的AndroidManifest.xml
即可,并在gradle中进行配置。

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

2、sdk和第三方库的版本一致性

不同module依赖sdk版本不一致,会因兼容性问题导致编译问题。
不同module引用了同一个第三方库的不同版本,并且这个库没有做到向前兼容,就有可能出现方法找不到、参数不对应等问题。
所以有必要统一整个project的依赖版本。
在最外层build.gradle中定义的常量能被整个projectbuild.gradle文件引用,统一的版本定义可以放在这里。

ext {
    android_compileSdkVersion = 23
    android_buildToolsVersion = '23.0.3'
    android_minSdkVersion = 15
    android_targetSdkVersion = 23

    lib_appcompat = 'com.android.support:appcompat-v7:23.2.1'
    lib_gson = 'com.google.code.gson:gson:2.6.1'
    lib_butterknife = 'com.jakewharton:butterknife:8.4.0'
    lib_butterknife_compiler = 'com.jakewharton:butterknife-compiler:8.4.0'
}

3、资源的重复定义

说到资源的重复定义,笔者趟过坑,如果主工程和子模块中重复定义了同名的资源。
主工程中
<string name="daddy">爸爸</string>
子工程中
<string name="daddy">干爹</string>
虽然编译不会出错,但是最后子模块中用到daddy的地方都会显示爸爸。
编译时子模块的资源会和主工程合并到同一个类中,所以资源重名会有问题。
但是资源也要模块化呀,总不能在底层找个统一的地方都扔在里面,gradle提供了一个解决方案来避免重复定义的问题。
resourcePrefix "a_"强制模块中的资源名称带有a_前缀,否则编译不过。
聊到这里,我们知道了如何使用gradle独立编译子模块,以及如何处理分模块导致的一些问题。但是除了主工程统一调度外,模块与模块之间也需要互相调起和访问,所以需要协议去统一,这个协议是模块间共同定义与使用的,所以写在底层。

app5.png

4、模块之间页面跳转

首先想到的就是配置uri去匹配模块AndroidManifest.xml中的intentFilter来启动相应Activity,这种方式是解耦的,但有缺点,要跳转其它模块,得先去看别的模块的AndroidManifest.xml进行入口适配,还得研究具体Activity中的传参设置,虽然代码依赖上解耦了,但是实现逻辑上没有解耦,忍不了。需要在底层创建一个路由协议,让使用者通过协议方便地调用。
用注解把需要的参数写在路由协议的接口中。

public interface IRouterUri {
    @RouterUri("test://host_liujc")
    public Intent getIntentActivityA(@RounterParam("name") String name, @RounterParam("age") int age);

    @RouterUri("test://host_b")
    public Intent getIntentActivityB();
}

其中@RouterUri表示跳转改页面需要匹配的uri,这个uri最终会拿去和moduleA中的AndroidManifest.xml
中对应activity的intentFilter去匹配。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RouterUri {
  String value() default "";
}

@RounterParam用来表示目标activity需要的参数,最终会在目标activity中进行解析。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface RounterParam {
  String value() default "";
}

为什么用注解的方式写接口而不是直接定义跳转方法呢?
用注解的方式,可以把参数更直观地展现在最醒目的方法声明中。而写成实现的方法,参数会被写在方法内部,定义起来不方便,而且要带上少量逻辑,不够简洁。参考retrofit
框架,也是用注解方式去实现,简洁、方便。
为什么接口返回的是Intent,而不是直接进行页面跳转呢?
因为我们的项目中,实现这个跳转可能是activity
,可能是fragment
,也可能startActivityForResult
需要带入一个自定义的requestCode
。所以为了灵活性,直接返回Intent

写好了接口,还需要将接口中的参数组装成一个可进行跳转的Intent
。使用Proxy
生成类动态代理这个接口。

public class RounterBus {
  //静态map存储代理接口的实例
  private static HashMap<Class, Object> sRounterMap = new HashMap<Class, Object>();

  /**
   * 得到动态代理路由接口的实例
   *
   * @param c 接口类
   * @return
   */
  public static IRouterUri getRounter(Class<?> c) {
    IRouterUri rounter = (IRouterUri) sRounterMap.get(c);
    if (rounter == null) {
      rounter = (IRouterUri) Proxy.newProxyInstance(c.getClassLoader(), new Class[] { c }, new InvocationHandler() {
        @Override public Object invoke(Object proxy, Method method, Object[] args)
            throws Throwable {
          //从方法注解的获取uri
          RouterUri routerUri = method.getAnnotation(RouterUri.class);
          if (routerUri == null || TextUtils.isEmpty(routerUri.value())) {
            throw new IllegalArgumentException(
                "invoke a rounter method, bug not assign a rounterUri");
          }
          Uri.Builder uriBuilder = Uri.parse(routerUri.value()).buildUpon();

          //从参数值和参数注解,获取信息,拼入uri的query
          Annotation[][] annotations = method.getParameterAnnotations();
          if (annotations != null && annotations.length > 0) {
            for (int i = 0, n = annotations.length; i < n; i++) {
              Annotation[] typeAnnotation = annotations[i];
              if (typeAnnotation == null || typeAnnotation.length == 0) {
                throw new IllegalArgumentException("method " + method.getName() + ", args at " + i + " lack of annotion RouterUri");
              }
              boolean findAnnotaion = false;
              for (Annotation a : typeAnnotation) {
                if (a != null && (a.annotationType() == RounterParam.class)) {
                  uriBuilder.appendQueryParameter(((RounterParam) a).value(), GsonInstance.getInstance().toJson(args[i]));
                  findAnnotaion = true;
                  break;
                }
              }
              if (!findAnnotaion) {
                throw new IllegalArgumentException("method " + method.getName() + " args at " + i + ", lack of annotion RouterUri");
              }
            }
          }
          return getIntentByRouterUri(uriBuilder.build());
        }
      });
      sRounterMap.put(c, rounter);
    }
    return rounter;
  }

  private static Intent getIntentByRouterUri(Uri uriBuilder) {
    Context context = AppContext.get();
    PackageManager pm = context.getPackageManager();
    Uri uri = uriBuilder;
    Intent intent = new Intent(Intent.ACTION_VIEW, uri);
    //查询这个intent是否能被接收用来进行跳转
    List<ResolveInfo> activities = pm.queryIntentActivities(intent, 0);
    if (activities != null && !activities.isEmpty()) {
      return intent;
    } else {
      if (BuildConfig.IS_DEBUG) {
        Toast.makeText(context, "子模块作为独立程序启动时,跳不到其他模块", Toast.LENGTH_SHORT).show();
      } else {
        throw new IllegalArgumentException("can't resolve uri with " + uri.toString());
      }
    }
    return null;
  }

}

上面代码包装了一个路由总线,来获取并缓存路由接口的实例。
例如:需要调起moduleA中的ActivityA

     Intent intent = RounterBus.getRounter(IRouterUri.class).getIntentActivityA("heihei", 23);
     if (intent != null) {
           MainActivity.this.startActivity(intent);
     }

5、模块之间的数据传递

ri uri = getIntent().getData();
    if (uri != null) {
      // 完整的url信息
      String url = uri.toString();
      Log.e(TAG, "url: " + uri);
      // scheme部分
      String scheme = uri.getScheme();
      Log.e(TAG, "scheme: " + scheme);
      // host部分
      String host = uri.getHost();
      Log.e(TAG, "host: " + host);
//      //port部分
//      int port = uri.getPort();
//      Log.e(TAG, "host: " + port);
//      // 访问路径
//      String path = uri.getPath();
//      Log.e(TAG, "path: " + path);
      List<String> pathSegments = uri.getPathSegments();
      // Query部分
      String query = uri.getQuery();
      Log.e(TAG, "query: " + query);
      //获取指定参数值
      String name= uri.getQueryParameter("name");
      Log.e(TAG, "name: " + goodsId);

6、application初始化
子模块作为application时,有一些初始化的工作需要在Application.onCreate时进行。而作为library时,调不到这个onCreate。所以自己写一个静态方法,供主工程的Application调用。

public class ApplicationA extends BaseChildApplication {

  @Override public void onCreate() {
    super.onCreate();
    //给底层library设置context
    AppContext.init(getApplicationContext());
  }

  /**
   * 作为library时需要初始化的内容
   */
  @Override public void onCreateAsLibrary(Application application) {
    super.onCreateAsLibrary(application);
  }
}

主工程的Application.onCreate时记得初始化子模块。

public class MainApplication extends Application {

  @Override public void onCreate() {
    super.onCreate();
    AppContext.init(getApplicationContext());
    ApplicationA.onCreateAsLibrary();
    ApplicationB.onCreateAsLibrary();
  }
}

想调试A模块,but某些功能需要依赖B这时只需要把B模块作为library引入A。并且记得在B模块Application.onCreate时初始化一下A模块。是不是很轻量级?常用的话在gradle中设置一个开关就更方便了。

def isDebugWithB = true
if (isDebugWithB.toBoolean()) {
  compile project(':moduleB')
}
ApplicationB.onCreateAsLibrary();
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,382评论 25 707
  • 不怕跌倒,所以飞翔 组件化开发 参考资源 Android组件化方案 为什么要组件化开发 解决问题 实际业务变化非常...
    笔墨Android阅读 2,966评论 0 0
  • 今天来回味下组件化和模块化,这2种说法时一回事,当然还是有区别的,下面再详细说,其实很简单,只是设计范围的不同,也...
    前行的乌龟阅读 48,436评论 6 94
  • 明月_2cce阅读 548评论 0 0
  • 缺点就是优点,有颗会观察人的心足以。 有人说我懒,懒是缺点吗,在很多人眼里是,懒人都聪明,不聪明你懒得起来吗?把你...
    关中人阅读 1,174评论 0 0