▌从 0 到 1 开发
一般从 0 设计一款 SDK,总体上可以分为 5 个步骤:基础架构的设计、开放 API 接口设计、业务功能框架设计与开发、基础核心库设计与开发、打包与发布。
1. 第一步是基础架构设计,一个好的架构可主要从可读性、可扩展性、可维护性三个方面进行考虑,即功能模块间相互独立,降低耦合度,保证结构清晰易于维护。通常我们可以把 SDK 架构简单分为两个层次:业务层和通用功能层。业务层可以独立成业务模块,包括开放 API 接口和业务功能实现,能用功能层可以分为两个模块,包手通用功能模块和基础工具模块。
2. 第二步是开放 API 接口设计,是直接开放给开发者调用的,所以经常要转化角色。针对如何调用这个接口,如何用起来更方便快捷,并且不用去考虑任何场景和问题,总结了以下几个要点(参考下文)。
3. 第三步是业务功能框架设计,不要过度设计,根据具体的业务需求来设计即可,不要为了一些未来很小概率发生的需求变化提前设计。
4. 第四步是基础核心库设计与开发,在核心库提炼过程中需要保证功能间互相独立,降低耦合度。
5. 最后是打包与发布,可以通过 jenkins 自动获取代码和执行编译打包,最终发布到 maven 上,可以大大降低人为本地打包出错的风险。
▌SDK 主要问题及解决途径
SDK 开发主要面临以下问题:SDK 包体大小、兼容适配、四方依赖问题、隐私合规问题。
对于 SDK 包体大小,首先是代码一定要精简,不过度设计,代码撰写需尽量最优雅最简洁的,其次混淆,理论上除了四大组件以及开放 API 接口外,其它的都需要进行混淆。兼容适配主要有 4 个层面的兼容问题:系统版本兼容、厂商兼容、屏幕兼容、以及新老版本兼容。系统版本兼容我们需要适配 android 4.0 以上的所有版本,一般官方每年 Q3 会发布一个新的版本,在新系统正式发布前我们需要做好适配,一般我们会在 Q1-Q2 研究并完成新系统的适配,做法先是先整体研究新系统的变更,并圈出与 SDK 相关的重点变更,最后根据官方指南,进行相关适配修改与测试;厂商兼容,这块主要通过机型兼容覆盖测试来进行功能适配,一般会覆盖 5 大厂商的主流机型;屏幕兼容,主要中涉及界面相关的适配和测试;新老版本兼容,需要确保所有 SDK 版本都是向下兼容的,保证 SDK 发新版本后,开发者可以不用修改一行代码就能直接更新到新版本 SDK。
四方依赖指的是依赖的开源框架(如 supportv4 包、OkHttp 等)、开放平台(如 QQ 分享、微信分享)、开发平台(如 Flutter、Unity、Cocos 等)。首先需要去开源依赖,不依赖任何三方工具包,完全基于原生的 android.jar 进行开发。对于依赖的四方开放平台和支持的开发平台,我们需要进行更新监控,以及定期更新,保证提供的基础功能能正常运行。隐私合规主要关注官方隐私调整、应用市场隐私政策、以及国家网络信息安全政策。通常我们可以走第三方安全检测机构进行权威检测,以及相关适配修改。
▌SDK 其他内容
1.配置中心以及灰度测试
app必备工具之一,配置中心主要负责的就是动态化的配置,比如文本展示类似这些的。sdk提供方需要负责的是提供动态更新能力,这里有个差异化更新,只更新dif部分,还有就是流量优化等等需要开发同学考虑的。然后可以考虑下存储性能方面的提升等。
而abtest也是app必备工具之一了,动态的下发实验策略,之后开发同学可以切换实验的页面。另外主要需要考虑灰度结果计算,分桶以及版本过滤白名单等等。这里只是一个简单的介绍不展开,因为我只是一个使用方。
2.调试组件
Debug日志工具
3.埋点框架
其实这个应该要放在更前面一点的,数据上报数据分析啥的其实都还是蛮重要的。
这部分因为我完全没写过哦,所以我压根不咋会,但是如果你会的话,面试的时候展开说说,可以帮助你不少。
另外还需要有线上的异常用户数据回捞系统,方便开发同学主动去把线上有异常的用户的日志给收集回来。
但是有些刁钻的页面曝光监控啦,自动化埋点啥的其实还是写过一点的,有兴趣的可以翻翻历史,还有github 上还有demo。
AndroidAutoTrack demo工程
4.启动相关
通过DAG(有向无环图)
的方式将sdk的初始化拆解成一个个task,之后理顺依赖关系,让他们能按照固定的顺序向下执行。
核心需要处理的是依赖关系,比如说其实埋点库依赖于网络库初始化,然后APM相关的则依赖于埋点库和配置中心abtest等等,这样的依赖关系需要开发同学去理顺的。
另外就是把sdk的粒度打的细碎一点,更容易观察每个sdk任务的耗时情况,之后增加task阈值告警,超过某个加载速度就通知到相应的同学改一下。
多线程是能优化掉一部分,但是也需要避免频繁线程调度。还有就是我个人觉得这些启动相关的东西因为都无法使用sdk级别的灰度,所以改动最好慎重一点。出发点始终都是好的,但是还是结果导向吧。
启动优化的核心,我个人始终坚持的就是延迟才能优化。开发人员很难做到优化代码执行的复杂度,执行时间之类的。尽人事听天命,玄学代码。
5.中间件(图片 日志 存储 基础信息)
这部分没啥,最好是对第三方库有一层隔离的思维,但是这个隔离也需要对应的同学对于程序设计方面有很好的思维,说起来简单,其实也蛮复杂的。
这里就不展开了,感觉面试也很少会问的很细。
6.第三方sdk大杂烩(偏中台方向)
基本一个app现在都有啥分享啦,推送啦,支付啦,账号体系啦,webview,jsbridge等等服务于应用内的一些sdk,这些东西就比较偏向于业务。
有兴趣的可以看看之前写的两篇关于sdk设计相关的。
活学活用责任链 SDK开发的一点点心得 Android厂商推送Plugin化
7.性能监控框架
这部分有几个不同的方面,首先是异常崩溃方面的,另外则是性能监控方面的,但是他们整体是划分在一起的,都属于线上性能监控体系的。
Crash相关的,可以接入Firebase;
而线上的性能监控框架可以从腾讯的Matrix学起,以前有两篇文章介绍的内容也都是和Matrix
相关的, Matrix
首页上也有介绍,比如fps,卡顿,IO,电池,内存等等方面的监控。其中卡顿监控涉及到的就是方法前后插桩,同时要有函数的mapping表,插桩部分整体来说比较简单感觉。
另外关于线上内存相关的,推荐各位可以学习下快手的koom, 对于hprof
的压缩比例听说能达到70%,也能完成线上的数据回捞以及监控等等,是一个非常屌的框架。
主进程发现内存到达阈值的时候,用leakcanary的方案,通过dump进程内存,之后生成hrop。由于hrop文件相对较大,所以我们需要对于我们所要分析的内容进行筛选,之后对hrop的写入操作进行hook,当发现写入内容的类型符合我们的需要的情况下才进行写入。
而当我们要做线上日志回捞的情况,需要对hprof 进行压缩,具体算法可以参考XMars库,有提供对应的压缩算法。
最后线上回捞机制就是基于一个指令,回捞线上符合标准的用户的文件操作,这个自行设计。
8.基础网络组件
虽然核心可能还是三方网络库,但是因为基本所有公司都对网络方面有调整和改动,以及解析器等方面的优化,其实可以挖的东西也还是蛮多的。
应付面试的同学可以看看Android网络优化方案。当然还是要具体问题具体分析,毕竟头疼医头,脚疼医脚对吧。
之前和另外一个朋友聊了下,其实很多厂对json解析这部分有优化调整,通过apt注解处理之后更换原生成原生的解析方式,加快反序列化速度的都是可以考虑考虑的。
9.其他方面
大公司可能都会有些动态化方案的考虑,比如插件化啊动态化之类的。这部分在下确实不行,我就不展开了啊。
关于第二步API接口设计:
首先回顾一下 API 方法的组成模块:
- API 注释
- 访问修饰符
- 返回值
- 方法名称
- 参数列表
- 异常列表
- 方法主体
针对 API 方法的组成模块,将提出几点小意见;可简单归纳为:"一个原则,三点建议,两个思考,三要五不要"。
一原则
最小知识原则(Least Knowledge Principle)
最小知识原则,或称迪米特法则;是一种面向对象程序设计的指导原则,它描述了一种保持代码松耦合的策略。
它描述的是一个软件实体应尽可能少地与其他实体发生相互作用;这里的软件实体是一个广义的概念,可指代系统、类、模块、对象、函数、变量等。
用更加通俗的语言来描述就是:“不应该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口”。(“软件实体”替换成“类”)
用一个例子来描述,DatabaseConfig
类为数据库实体类,用以描述数据源信息;JdbcUtils
类用以封装一些基础的JDBC操作。
/*** 数据库实体对象* @author coder小奇* @date 2020/9/6**/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DatabaseConfig {private long id;private int clusterId;private String host;private String port;private String dbName;private String dbType;private String jdbcUrl;private String username;private String password;private String dbOwner;private String createUser;private String updateUser;private String createTime;private String updateTime;}/*** jdbc底层操作* 执行SQL 包装数据等* @author 小奇* @date 2020/9/6**/
public class JdbcUtils {// 获取jdbc Connectionpublic static Connection getConnection(@NonNull DatabaseConfig databaseConfig) throws ClassNotFoundException, SQLException {DbType dbType = DbType.valueOf(databaseConfig.getDbType());Class.forName(dbType.getDriver());return DriverManager.getConnection(databaseConfig.getJdbcUrl(), databaseConfig.getUsername(), databaseConfig.getPassword());}
}
这段代码虽然能满足业务需求,但有些地方可以做到更好。JdbcUtils作为一个底层的基础服务类,希望做到尽可能的通用,而不只是支持DatabaseConfig
数据源;其次从另外一个角度来看,DatabaseConfig
实体中有太多的属性字段,getConnection
API到底依赖哪个字段难以确认;所以getConnection
API的设计一定程度上违背了 最小知识原则
,依赖了不该有的直接依赖关系的DatasourceConfig
类。
我们可对JdbcUtils
的getConnection
方法作以改造,使其满足最小知识原则。我们应该只提供getConnection
需要的信息。
public static Connection getConnection(@NonNull String driver, @NonNull String jdbcUrl, @NonNull String username,@NonNull String password)throws ClassNotFoundException, SQLException {Class.forName(driver);return DriverManager.getConnection(jdbcUrl, username, password);}
最小知识原则希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统中其他部分,这样一旦其他部分发生变化,自身就不会受到影响,避免了“城门失火,殃及池鱼”的发生。
三建议
1. 建议优先使用接口而不是具体实现类
优先使用接口类型作为API的返回值类型或参数类型,有利于提升API的可扩展性。这同时也是设计原则——依赖倒置原则所提倡的。
例如,上面例子中的DataSourceConfig
数据源需要加密,应该使用Encryptor
加密器接口,而不是具体的KeyCenterEncryptor
加密器类;因为随着业务的发展,很有可能出现新的加密类型,使用KeyCenterEncryptor
加密器类对会使得难以扩展新的加密类型。
/*** 加密器* @author coder小奇* @date 2020/9/6**/
public interface Encryptor {/*** 加密* @param str 待加密字符串* @return String 加密后的字符串*/public String encrypt(String str);}/*** keyCenter加密器* @author coder小奇* @date 2020/9/6**/
public class KeyCenterEncryptor implements Encryptor {@Overridepublic String encrypt(String str) {// ...// 执行keyCenter加密 & return}
}
2. 善于利用枚举类型
实际的开发场景中,需求在不断的变更迭代,善于利用枚举类型有利于"留有余地"的处理多样化的需求。并且枚举类型有着简洁、易读、可扩展的优点。
例如,上面所提到的加密类型,可以提供一个枚举类型用于描述。
/*** 加密类型枚举类** @author codeer小奇* @date 2020/9/6*/
@Getter
@AllArgsConstructor
public enum EncryptTypeEnum {/*** 加密类型*/KEY_CENTER(0, "keyCenter加密"),MD5(1, "MD5加密")// 后续扩展新的加密类型....;private final int code;private final String desc;}
3. 统一API命名规则
API的命名应该遵循标准的命名规则,应该选择易于理解,并与同一个包中其他命名风格一致的名称,避免使用长的方法名称;具体可参考《阿里巴巴开发手册》的命名规范。 例如:对于具有查询含义的API,可以以queryXXX
来命名。
三要
1. 参数有效性检查
大多数的API方法对于传递接收的参数都有限制,例如索引值不能为负数、对象引用不能为null等;因此在注释文档中标注这些限制并且在方法体的开始进行参数有效性的检查是非常有必要的,这能够让我们在发生错误之后尽快检测到错误的来源,避免错误向下扩散。
对于公有的方法,要用javadoc的@throw标签在文档中说明违反参数值限制时会抛出的异常。这样的异常通常为 IllegalArgumentException
,IndexOutOfBoundsException
或 NullPointerException
。
/*** id查询用户** @param id 自增主键id* @return User* @throws IllegalArgumentException if id is less than or equal 0*/
public User queryById(Integer id) {if (id <= 0) {throw new IllegalArgumentException("id <= 0 :" + id);}// do something & return...
}
Tips
:很多时候我们的参数对象引用会要求不能为空,如果在每个方法javadoc注释上都单独标记这一约束会显得十分的冗余;这个时候我们可以使用类级注释,类级注释适用于类的所有公共方法中的所有参数。
如果没有做参数有效性检查,有可能会发生以下这两种情况:
-
方法在处理的过程中失败,产生了令人难以理解的异常(参数向下延申扩展),客户端不得不根据完整异常栈信息进行逐步排查。
-
方法计算异常但是正常返回,返回了计算出错的结果(脏数据的来源之一)。
无论是哪种情况,都会对客户端造成不必要的困扰,这并不是我们所希望看到的。
这是否意味着对于所有的参数都需要进行有效性检查呢?答案是否定的。有些情况下的参数检查的成本是十分昂贵且不切实际。比如:考虑一个为对象列表排序的方法:Collections.sort(list);列表中的所有对象都必须是可以相互比较的。这个时候如果我们提前对集合list做每个元素是否可比较的检查,其实没有什么实际意义;因为sort方法会进行相关的检查。
这种由计算行为进行的检查称为隐式有效性检查,如果检查不成功会抛出错误的异常(有可能和我们javadoc标注的异常类型不一致);这时我们应该对异常进行兜底转换,转换成我们申明的异常类型。
总而言之,在编写API方法的时候,我们需要考虑参数有哪些限制,在文档中声明这些限制并且在方法体的开始处,显式的检验这些限制。
2. 返回长度为零的数组或集合,而非null
当API方法的返回值类型为数组或者集合的时候,遇到无法返回的情况,我们应该返回对应的空数组或空集合,而不是返回null。
对于返回null的API,客户端在使用的时候每次都需要做与业务逻辑无关非空判断以增强自身代码的健壮性,对于"不那么严谨"的程序员来说,可能会因为忘记做非空判断来处理null返回值,以至于在未来的某一天因此发生一些"匪夷所思"的错误。
常规通用版——返回长度为零的空集合。
/*** 获取拥有这张表权限的所有人** @param dataId* @return List<String>*/
@Override
public List<String> getOdsTableUsers(Integer dataId) {if (dataId == null) {return new ArrayList<>(0);}try {// 执行查询 获取拥有该dataId对应数据表 有权限用户集userlistreturn new ArrayList<>(userlist);} catch (Exception e) {log.error("获取拥有数据表Id:{} 权限用户集失败", dataId, e);return new ArrayList<>(0);}
}
优化慎用版——返回共有的不可变空集合,以避免分配空间。
public List<String> getOdsTableUsers(Integer dataId) {if (dataId == null) {return Collections.emptyList();}// do something & return ....
}
这么做能避免多次分配空间,理论上在性能上有一定的优化。但实际开发中不建议这么使用,假设有这么一个场景,客户端调用该方法后发现返回的为空集合,转而有其他的操作,那很可能会发生意想不到的错误。
/*** 模拟API接口 返回公有的不可变空集合* @param dataId 数据表id* @return List<String>拥有数据表访问权限的用户集合*/
public static List<String> queryUserList(Integer dataId) {// 测试样例直接返回公有的不可变空集合return Collections.emptyList();
}/*** 模拟客户端* @param dataId 数据表id*/
public static void doFunnyThing(Integer dataId) {List<String> userList = queryUserList(dataId);if (userList.isEmpty()) {// 当发现userList为空的时候,希望做一些其他的操作 这时候产生意想不到的异常。String user = "i am user";userList.add(user);}// do some funny thing
}public static void main(String[] args) {Integer dataId = 1;doFunnyThing(dataId);
}// 运行结果:
Exception in thread "main" java.lang.UnsupportedOperationExceptionat java.util.AbstractList.add(AbstractList.java:148)at java.util.AbstractList.add(AbstractList.java:108)at com.kylin.mhr.controller.TemporaryController.doFunnyThing(TemporaryController.java:24)at com.kylin.mhr.controller.TemporaryController.main(TemporaryController.java:30)
总之,永远不要返回null来代替返回空集合或空数据,这会让API更加的难以使用,容易出错,且没有性能上的优势。
3. 规范文档注释
如果要想一个API真正可用,就必须为其编写文档。API的文档注释应该简洁的描述它和客户端之间的约定,这个约定是指:做了什么,而非怎么做的。
API文档注释应包含:
-
所有的前提条件——客户端调用它的必要条件(例如:客户端传递的参数)
-
后置条件——API调用成功后发生的事情(例如:返回数据)
-
异常描述——前提条件是由@throw 标签针对未受检异常的隐含描述,每个未受检异常都对应一个违背前提条件的例子。
为了完整地描述方法的约定,文档注释应该为每个参数都使用一个@Param
标记,方法使用@return
标记返回类型(除非方法的返回类型是 void
),以及对于该方法抛出的每个异常,无论是受检的还是未受检的,都有一个@throws
标签。
随意截取了Java API中LocalDateTime.class
中的注释,供大家瞅瞅。
/*** Returns a copy of this {@code LocalDateTime} with the specified number of hours added.* <p>* This instance is immutable and unaffected by this method call.** @param hours the hours to add, may be negative* @return a {@code LocalDateTime} based on this date-time with the hours added, not null* @throws DateTimeException if the result exceeds the supported date range*/
public LocalDateTime plusHours(long hours) {return plusWithOverflow(date, hours, 0, 0, 0, 1);
}
总而言之,规范文档注释十分的有必要。在实际的开发中,我们可以使用类似SonarLint等插件用以检查自己编写的API文档是否完备。
五不要
1. 避免过长的参数列表
避免过长的参数列表,参数不应该超过4个;参数过多会导致API不利于使用,调用方需要不断的阅读文档来理解。更应该避免相同类型的长参数,相同类型的长参数非常容易引发"不可预知的风险"——调用方弄错了参数的顺序,但是程序还能正常的编译运行,导致与预期不符的错误结果。
在实际的开发中,很有可能会出现需要的参数超过4个的情况,这个时候我们可以采取一些方法用以缩短参数列表。
- 方法拆解
将参数列表过长的API方法进行细化拆解,每个方法的参数列表只需要原有参数的子集。这样一定程度上会导致方法过多,但可通过方法间的正交性;去除部分方法。
- 创建参数辅助类
创建辅助类用以保存参数的分组。比如实时同步的注册topic操作,随着同步服务的升级,使用方在注册的时候新增org相关参数。这时候可以将所有的注册参数抽离封装成一个注册参数实体RegisterParamEntity。
总而言之,简短的参数列表对客户端更加的友好。
2. 避免可变参数
可变参数可接受零个或多个指定类型的参数,可变参数机制通过先创建一个数组,数组的大小等于调用时所传递的参数数量,然后将参数值传递到数组中,最后将数组传递给方法。
小声BB:在我有限的工作时间内,我倒是没见过含有可变参数的API,所以还是不用这东西吧~~
3. 避免相同参数数量的重载方法
重载方法(overloaded method)的调用是在编译时所决定的,是静态的;重写方法(overridden method)的调用是在运行时决定,是动态的。可能会因为记忆或理解上的偏差,而产生错误的使用方式。因此,安全而保守的策略是:避免导出两个相同参数数量的重载方法,因为我们始终可以给方法起不同的名,而非使用重载机制。
我们可以从JAVA的API中看出这一思想,ObjectOutputStream.class 中的write方法,并没有选择相同参数数量的重载机制,而是选择命名上做区分。
public void writeInt(int val) throws IOException {bout.writeInt(val);
}public void writeLong(long val) throws IOException {bout.writeLong(val);
}public void writeFloat(float val) throws IOException {bout.writeFloat(val);
}
4. 避免过度追求提供便利的方法
每个API方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护,应当尽量避免一些临时性质的API方法。
对于接口而言,方法太多会使接口实现者和接口使用者的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到的时候,才考虑为它提供快捷方式(shorthand)。如果不能确定,还是不要提供快捷方式为好。