×

开发第三方库最佳实践

96
天之界线2010
2016.05.31 22:14* 字数 3989

本文会不定期更新,推荐watch下项目。如果喜欢请star,如果觉得有纰漏请提交issue,如果你有更好的点子可以提交pull request。
本文的示例代码主要是基于作者的经验来编写的,若你有其他的技巧和方法可以参与进来一起完善这篇文章。

本文固定连接:https://github.com/tianzhijiexian/Android-Best-Practices

一、需求背景

目前技术圈的人或多或少都开发过库项目,无论是因为要靠它来找工作,还是通过其进行学习交流,亦或是借此来招摇撞骗,总之开发第三方库这件事已经变得越来越流行了。
我个人是很讨厌技术圈娱乐化的,star那么多,issue没人管的现象比比皆是。其实解决issue才能促使实践者更快的学习,仅仅炫耀自己的star数毫无意义。因此,我便写了我开发第三方库的经验,希望本文能帮助到大家。

二、需求

我认为一个好的库作者应该是能满足以下需求的:

  • 避免重复造轮子
  1. 谨慎设计API
  2. 避免引入其他库
  3. 尽量用注解代替枚举
  4. 资源文件加上特殊前缀
  5. 提供可插拔依赖的方案
  6. 将Manifest中的参数变量化
  7. 有多个相关依赖,做聚合依赖
  8. 根据需求考虑是否提供no-op
  9. 仅仅在debug模式中引入代码
  10. 使用JitPack做库的托管仓库
  11. 严格限制库的大小和方法数
  12. 快速解决issue,多和提问者沟通
  13. 不断完善,坚持更新

三、实现

避免重复造轮子

当你产生了一个新奇的想法,想要实现它之前请用一天的时间去分析自己想法的优缺点,然后去Github上搜索有没有类似的库,或者是通过群组来询问相关信息。你必须知道,当你提出一个想法的时候,别人很可能也已经想过了,差别就在于别人是否已经实现。如果实在没有找到和你要做的库类似的东西,那么就开始干吧!

如果有现成的,那么我就不做
如果你搜索到github上有个和你想做的库类似的东西,你完全可以了解其实现后拿来就用,这会节约你很多的时间。我自己写过一个Gradle插件,当我写的差不多的时候我突然意识到github上可能有现成的,所以我立刻停止了开发,进行搜索。果真找到了一个十分类似的库,阅读源码后发现其思路和我几乎完全一致。

如果现成的不够好,我可以让它变的更好
当我们搜索到了一个和自己想法类似的库后,很可能会发现它和自己的想法有些差异,或者是有些bug。这时候千万不要呵呵一声,然后自己开始狂妄的写代码。我更加希望的是通过issue联系到作者,提出问题,如果可能的话给出自己的解决方案和pr。我们的时间很宝贵,为何不花时间来维护同一个东西呢。

material design library

如果现成的太糟,由我来让其脱胎换骨
Github上有很多很多作者,那么自然就产生了社会性。我的提交和留言很可能被作者无视,或者作者早就转行了,他的项目等于死了。遇到这样的情况,我的做法是frok人家的代码,然后自己开始维护。DebugDrawer就是一个例子。他原本的作者已经很久不更新了,而且issue也没人回复,不得已的情况下我只能自己维护了。自己拿来维护可以,但一定要记得fork人家的代码,你要时刻记住,你是踩在前人的肩膀上,不要狂妄。

谨慎设计API

如果你的库是给人用的,那么请在设计api层面多花点时间。因为一旦有人用了你的库,你就有了历史负担。如果你后期随意地改变方法名和参数,使用者会来打你的,所以经验就很重要了。我可以简单给出一个建议:把内部类写到参数靠后的位置,把context放在参数的前部:

public interface Test{
    // context在前,内部类在后
    void test(Context context, View.OnClickListener listener);
}

如果后期实在要改名字和废弃方法,可以采用@Deprecated来做标记,把要变动的东西先标记为废弃,过了几个版本后再删除掉。

避免引入其他库

你的代码本身就是一个库了,因此我强烈不建议你的代码还引入别人的库。友盟推送的代码就是一个典型的反例,一个推送库引入了okhttp、okio等其他库,臃肿不堪,完全没有让我使用的欲望。
一个第三方库引入其他第三方库有很多坏处,使用者可能会遇到版本冲突的问题(比如:友盟反馈和友盟推送同时使用),方法数还会极速增多。
你可能会说appcompat这个库基本所有第三方库都会引入的,有没有什么好的办法可以避免呢?好在我们有provided关键字。provided可以将你需要的库引入,但是并不会将其打包到aar里面以CommonAdapter为例:

dependencies {
    provided 'com.android.support:recyclerview-v7:23.2.1'
    provided 'com.android.databinding:baseLibrary:1.0'
    
    provided "org.projectlombok:lombok:1.12.6"
}

CommonAdapter依赖了三个库,但是都用了私有依赖的方式来做的。
首先,我能确定使用这个库的人,肯定使用了recyclerView,所以我通过私有依赖的方式将recyclerView的代码剔除,那么recyclerView的最终版本由使用者来定。
其次,如果使用者的项目使用了DataBinding这个库,那么可以采用数据绑定的形式来做界面的更新操作,但我并非强制使用者必须依赖db,所以我也将db的库剔除,并且在代码里做了这样的判断:

public class DataBindingJudgement {

    public static final boolean SUPPORT_DATABINDING;

    static {
        boolean hasDependency;
        try {
            Class.forName("android.databinding.ObservableList");
            hasDependency = true;
        } catch (ClassNotFoundException e) {
            hasDependency = false;
        }
        SUPPORT_DATABINDING = hasDependency;
    }
}
public CommonAdapter(@Nullable List<T> data, int viewTypeCount) {

        if (DataBindingJudgement.SUPPORT_DATABINDING && data instanceof ObservableList) {
            // 判断是否有db的依赖
            ((ObservableList<T>) data).addOnListChangedCallback(new ObservableList.OnListChangedCallback<ObservableList<T>>() {
                @Override
                public void onChanged(ObservableList<T> sender) {
                    notifyDataSetChanged();
                }
            });
        }
        
        //...
    }

上面的代码的意思是如果你用了db,那么commonAdapter就支持了数据绑定,如果没有用到db,也不影响,还可以用传统方式来做。
最后,我为了增加代码可维护性,我引入了lombok。无论使用者是否用了lombok,都和我无关,这种情况采用provided的方式也是最合理的。

尽量用注解代替枚举

通过注解代替枚举可以减少内存开销,并且在AS越来越智能的提示下,编码方式和枚举几乎一致。以ShareLoginLib为例,我在编码的时候会用注解来进行参数的表示,这里一般会使用有限值的int来做。

@Retention(RetentionPolicy.SOURCE)
@IntDef({ContentType.TEXT, ContentType.PIC, ContentType.WEBPAGE, ContentType.MUSIC})
public @interface ContentType {

    int TEXT = 1, PIC = 2, WEBPAGE = 3, MUSIC = 4;
}

但是如果你需要将注解暴露给使用者,那么我推荐采用string的形式来做,因为string的值不会像int那样随着打包而改变,此外string有很高的可读性。在AS目前还没智能识别变量的情况下,我强烈建议用有限值的String来代替枚举。

@Retention(RetentionPolicy.SOURCE)
@StringDef({LoginType.WEIXIN, LoginType.WEIBO, LoginType.QQ})
public @interface LoginType {

    String WEIXIN = "WEIXIN", WEIBO = "WEIBO", QQ = "QQ";
}

给资源文件加上特殊前缀

第三方库的资源会和使用者的项目进行合并,资源的名字需要特殊注意,以DebugDrawer为例,我在layout资源前面都会加特殊的前缀(debugDrawer->dd)

前缀-dd

除了layout文件,color等也应该注意,库作者多注意这些细节点,会给使用者省去很多麻烦,减少不必要的冲突和问题。

在access文件中也应该建立一个子目录:

Paste_Image.png

这样可以防止多个库用了同一个同一个资源,然后产生覆盖的问题。

提供可插拔依赖的方案

我们制作的库很可能会用到回调,我希望给已经使用了rxjava项目的使用者rxjava的回调,给没有使用rxjava的用户提供默认的接口回调。

首先,通过provided来依赖rxjava:

provided 'io.reactivex:rxjava:1.1.3'

然后在代码中提供使用rxjava和传统的两种方法:


rxjava

这样使用者就可以选择性的使用不同的方法来接收回调了。

将Manifest中的参数变量化

在制作第三方登录、分享的SDK时,我发现需要在manifest中定义一些key,但是我不希望写死,而是交由使用者进行填写。因此我将key变量化:

key

使用的时候只需要在gradle中进行如下配置即可:

android {
    compileSdkVersion 23
    buildToolsVersion '23.0.2'

    defaultConfig {
        applicationId "com.liulishuo.engzo"
        minSdkVersion 15
        targetSdkVersion 23

        manifestPlaceholders = [
                // 这里需要换成:tencent+你的AppId
                "tencentAuthId": "tencent123456",
        ]
    }
}

最终打包生成的manifest就会自动合并成下图:


merge

值得注意的是${applicationId}是一个默认的变量,随着实际项目中的参数而定,所以在需要在manifest中指定具体包名的时候可以采取如下方式:

${applicationId}

实际项目中强烈建议把这个值定写成包名:

applicationId

有多个相关依赖,做聚合依赖

DebugDrawer有多个用来debug的库,使用者可以根据选择进行依赖。但是这些库都是和DebugDrawer密切相关的,所以开发者应该建议使用者将他们应该一并依赖进来,这样以后删除库的时候也很方便。

 debugCompile(["com.github.tianzhijiexian:DebugDrawer:1.0.0",
                  "jp.wasabeef:takt:1.0.1",
                  "com.jakewharton.scalpel:scalpel:1.4.6"
    ])

根据需求考虑是否提供no-op

如果你开发的库可能只需要在debug时才用到,但库提供的类或方法需要写入现有的代码中,那么就可以采用no-op的方案。所谓no-op就是希望某些代码仅仅存在于debug环境中,在release版本中,可能就是保留了了一些代码接口,但是并不提供实现。
leakcanary为例,它的文档中就给出了no-op的依赖。

dependencies {
   debugCompile 'com.squareup.leakcanary:leakcanary-android:1.4-beta2'
   releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta2'
   testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta2'
 }

我举一个no-op的例子:

// debug版依赖的代码
public void doSomeThing(){
    int i = 1,sum;
    for(int i =0;i<100000;i++){
        sum += i;
    }
    // 省略一千行代码
}

// release版依赖的代码
public void doSomeThing(){
    // no-op
}

no-op的库仅仅提供了一个方法壳,让不需要出现再release包中的代码消失。

仅仅在debug模式中引入代码

如果你的库代码仅仅需要出现在debug模式中,并且对于使用者现有的代码没任何影响,那么你可以建议使用你的库的人通过debugCompile进行依赖。

stetho为例,我先将其用debug模式进行依赖。

debugCompile "com.facebook.stetho:stetho:1.3.1"

然后在src下建立debug/java的目录,接着建立一个DebugApplication的类:


Paste_Image.png
public class DebugApplication extends ReleaseApplication {

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

        Stetho.initialize(
                Stetho.newInitializerBuilder(this)
                        .enableDumpapp(Stetho.defaultDumperPluginsProvider(this))
                        .enableWebKitInspector(
                                Stetho.defaultInspectorModulesProvider(this)).build());
        }
}

最后,在manifest文件中进行application的替换:

<?xml version="1.0" encoding="utf-8"?>
<manifest 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    >

    <application
        android:name=".DebugApplication"
        android:allowBackup="true"
        android:icon="@drawable/debug_icon"
        tools:replace="android:name,android:icon"
        />

</manifest>

这样我们就会在debug时自动用debugApplication作为application对象,并且使用里面stetho的代码,在release版本中还是采用ReleaseApplication,以此来减少无用代码的引入。

使用JitPack做库的托管仓库

我们的代码大多都是存在github上面,jitpack可以快速将你的github项目变成可以被使用者进行依赖的库。
这是我的一个库的例子:https://jitpack.io/#tianzhijiexian/AppBar

appbar

我们可以通过tag和commit进行库版本的选择,选择完毕后就可以看到依赖的方式:

compile

jitpack让我们提交库变得简单快速,但需要注意它并不能支持多个module的库,这是一个劣势。

jitpack还提供了java文档的在线浏览,如果你的库需要提供文档支持,那么它绝对是一个很好的选择。


jitpack doc.png

配置的方式是在lib的build.gradle中添加如下代码:

// build a jar with source files
task sourcesJar(type: Jar) {
    from android.sourceSets.main.java.srcDirs
    classifier = 'sources'
}

task javadoc(type: Javadoc) {
    failOnError  false
    source = android.sourceSets.main.java.sourceFiles
    classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
    classpath += configurations.compile
}

javadoc {
    options {
        encoding "UTF-8"
        charSet 'UTF-8'
        author true
        version true
        links "http://docs.oracle.com/javase/7/docs/api"
    }
}

// build a jar with javadoc
task javadocJar(type: Jar, dependsOn: javadoc) {
    classifier = 'javadoc'
    from javadoc.destinationDir
}

artifacts {
    archives sourcesJar
    archives javadocJar
}

接着把代码push到github上后我们就可以在线浏览文档了。

doc.png

如果你想详细了解jitpack,jitpack的官方doc写得很清楚,查看即可。

Tips

下面说个小的tips,我们希望使用者可以明确知道当前工程最新的版本是多少,但是每次手动改readme很麻烦,jitpack可以通过插入link的方式来自动获得jitpack上的最新版本。进入https://jitpack.io/#tianzhijiexian/UIBlock/58d865ecbd 选择完版本后就可以看到最下方的提示了。

link

最后将svg粘贴到readme开头就可以了,这个标签还可以让我们快速从工程跳转到jitpack,十分实用。

readme

严格限制库的大小和方法数

以我自身的经验,一个小型库的方法数不应该超过300,所以需要时刻留意自己是否在做一个单一功能的库。这个300自然不是权威指标,我只是希望库开发者应该尽可能让自己的库轻量干净,减少使用者引入库的负担。一个第三方库的方法数和大小都是使用者会考虑的点,所以我推荐使用:MethodsCount来进行库方法数目的检测:

gson

我们还可以通过图表来量化自己库的方法数和大小,下面就是ShareLoginLib的走势图:

chat.png

MethodsCount还提供了as插件以便于我们了解自己依赖的库大小,安装后的效果如下:


plugin

也可以采用谷歌推荐的方式进行依赖关系的检测(不常用):

dependencies

支持SourceGraph,让使用者可以快速浏览项目代码

Github一个不好的地方就是代码是不能相互跳转的,所以阅读起来很累,如果我要引入一个库,那么就必须clone下来然后通过idea打开才行。这样的流程对于库的前期调研来说成本很高,所以我希望利用SourceGraph让在线阅读代码的体验提升一个量级。
一个简单的演示:

souce.gif

你在安装完SourceGraph的Chrome插件后,就会发现支持SourceGraph的代码上方就会显示一个icon。

icon.png

现在,你就可以利用sourcegraph进行跳转和插件文档了。

Paste_Image.png

想要体验更多,可以浏览:
https://github.com/tianzhijiexian/CommonAdapter/blob/master/adapter/src/main/java/kale/adapter/BasePagerAdapter.java

快速解决issue,多和提问者沟通

一个优秀的开源库自然要经历很多issue,作为库开发者需要对issue有一定的敏感度,不要因为自己太忙而放任不管。

issue

我分享下我的做法:

  • 如果提问者是理解上的问题,可以在解答后更新到ReadMe中,以防止别人有同样的疑问。
  • 如果是小的bug,那么最好快速修复,并且由提交者验证问题,验证后由提交者关闭issue。
  • 如果是难以解决的bug,或最近自己没有时间,那么应快速告知提问者,说明情况。

不断完善,坚持更新

完善和维护一个库确实需要很大的精力,如果你的库是真的希望给别人用的,那么就应该能有付出时间和精力的准备。因为你既然做了这件事,那么就需要为此负责。我平时也非常忙,但是我还是努力地做着这些事情,所以我相信你也可以的!

developer_kale@foxmail.com
日记本
Web note ad 1