注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

环信FAQ

环信FAQ

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

在Android中实现python的功能

起因:为什么想写这样一篇文章呢,最开始是我的一个朋友和我说想换一个QQ头像但是苦于里面一个常用的模块被下架了在网页中找无关信息太多也显得杂乱,我就萌生了这样一个想法,我是一个程序员这种事能不能通过技术手段实现或者说简化一下说干咱就干(PS:目前只通过Java实...
继续阅读 »

起因:

为什么想写这样一篇文章呢,最开始是我的一个朋友和我说想换一个QQ头像但是苦于里面一个常用的模块被下架了在网页中找无关信息太多也显得杂乱,我就萌生了这样一个想法,我是一个程序员这种事能不能通过技术手段实现或者说简化一下说干咱就干

(PS:目前只通过Java实现了爬虫的功能就不多赘述了具体的可以自行百度,python的部分并未能全部实现故只介绍前期的准备流程及部分结果)

需要准备的工具:

Android Studio,adaconda

接下来让我们开始吧!

  1. 首先为了能在as中创建python文件我们需要先下载一个插件。在Plugins中搜索Python Community Edition插件下载,安装重启as后就可以在as中创建python文件了,因为Chaquopy没有与这个插件集成,所以.py文件中的代码会报错这是正常现象可以忽略,实际错误请以logcat为准
  2. 打开根目录下build.gradle文件引入chaquo模块
buildscript {
repositories {
xxx
maven { url 'https://jitpack.io' }
//引入chaquo模块
maven { url "https://chaquo.com/maven" }

}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.3'
//如果该模块的版本引入不对会引起编译失败
//如果这里使用的版本是12.0.0及更早的版本会在模块启动时弹出吐司及通知栏显示许可证警告,并且一次只能运行五分钟
//想要删除限制需要在local.properties文件中引入chaquopy.license = free12.0.1及之后的版本则为开源的无需额外配置
classpath "com.chaquo.python:gradle:12.0.1"
}
}

local.properties文件中内容如下

#使用闭源 Chaquopy 版本(12.0.0 及更早版本)将在启动时显示许可证警告,并且一次只能运行 5 分钟。要删除这些限制,请将以下内容添加到您的项目.
#chaquopy.license=free
#如果使用闭源代码的Chapuopy版本来构建AAR,还需要增添如下标识将AAR内置到应用程序中
#chaquopy.applicationId=your.applicationId

3.接下来让我们打开app目录下的build.gradle文件加入以下引用

plugins {
//应用模块
id 'com.android.application'
id 'com.chaquo.python'
}
android {
ndk {
//引入python模块后不支持架构为armeabicpu类型
abiFilters 'armeabi-v7a', 'arm64-v8a', "x86", "x86_64"
// 还可以添加 'x86', 'x86_64', 'mips', 'mips64'
}

python {
//adaconda中的python编译器,目的引入虚拟环境让python文件在安卓应用中运行,buildPython中的路径需要替换为你自己的安装地址
buildPython "D:\\ana_2\\python.exe"
pip {
//指定库的镜像下载地址:阿里云,清华等
//options "--index-url", "https://mirrors.aliyun.com/pypi/simple/"
options "--extra-index-url", "https://pypi.tuna.tsinghua.edu.cn/simple/"
//install "opencv-python"
//下载的库,需要什么模块就自行下载下载什么模块,另有些模块不支持引入详情请参阅https://chaquo.com/chaquopy/doc/current/android.html#stdlib-unsupported
install "requests"
}
}
}

4.完成以上配置后就可以开始真正的旅程了

//初始化python模块的相关文件
void initPython() {
if (!Python.isStarted()) {
Python.start(new AndroidPlatform(this));
}
}

//调用python中的内容
void callPythonCode() {
Python py = Python.getInstance();
//getModule:py文件名,不用加.py的后缀;callAttr:方法名;如果方法有返回值那pyObject就是返回值
PyObject pyObject = py.getModule("SearchHeadImg").callAttr("sjs");
String a = String.valueOf(pyObject);
Log.e(".py返回值", a);
}

这样我们就可以在app中调用python的相关功能了!

这些内容虽说不多但也是我花了很长时间踩坑一步一步总结出来的,如果有问题或者缺失的内容欢迎大佬指正补充。

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)2.3 内部通讯协议完善当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在...
继续阅读 »

接:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

2.3 内部通讯协议完善

当客户端发起请求,想要执行某个方法的时候,首先服务端会先向Registery中查询注册的服务,从而找到这个要执行的方法,这个流程是在内部完成。

override fun send(requestRequest?): Response? {
   //获取服务对象id
   val serviceId = request?.serviceId
   val methodName = request?.methodName
   val params = request?.params
   // 反序列化拿到具体的参数类型
   val neededParams = parseParameters(params)
   val method = Registry.instance.findMethod(serviceIdmethodNameneededParams)
   Log.e("TAG""method $method")
   Log.e("TAG""neededParams $neededParams")
   when (request?.type) {

       REQUEST_TYPE.GET_INSTANCE.ordinal -> {
           //==========执行静态方法
           try {
               var instanceAny? = null
               instance = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(null)
              } else {
                   method?.invoke(nullneededParams)
              }
               if (instance == null) {
                   return Response("instance == null"-101)
              }
               //存储实例对象
               Registry.instance.setServiceInstance(serviceId ?""instance)
               return Response(null200)
          } catch (eException) {
               return Response("${e.message}"-102)
          }
      }
       REQUEST_TYPE.INVOKE_METHOD.ordinal -> {
           //==============执行普通方法
           val instance = Registry.instance.getServiceInstance(serviceId)
           if (instance == null) {
               return Response("instance == null "-103)
          }
           //方法执行返回的结果
           return try {

               val result = if (neededParams == null || neededParams.isEmpty()) {
                   method?.invoke(instance)
              } else {
                   method?.invoke(instanceneededParams)
              }
               Response(gson.toJson(result), 200)
          } catch (eException) {
               Response("${e.message}"-104)
          }

      }
  }

   return null
}

当客户端发起请求时,会将请求的参数封装到Request中,在服务端接收到请求后,就会解析这些参数,变成Method执行时需要传入的参数。

private fun parseParameters(paramsArray<Parameters>?): Array<Any?>? {
   if (params == null || params.isEmpty()) {
       return null
  }
   val objects = arrayOfNulls<Any>(params.size)
   params.forEachIndexed { indexparameters ->
       objects[index=
           gson.fromJson(parameters.valueClass.forName(parameters.className))
  }
   return objects
}

例如用户中心调用setUserInfo方法时,需要传入一个User实体类,如下所示:

UserManager().setUserInfo(User("ming",25))

那么在调用这个方法的时候,首先会把这个实体类转成一个JSON字符串,例如:

{
  "name":"ming",
  "age":25
}

为啥要”多此一举“呢?其实这种处理方式是最快速直接的,转成json字符串之后,能够最大限度地降低数据传输的大小,等到服务端处理这个方法的时候,再把Request中的params反json转成User对象即可。

fun findMethod(serviceIdString?methodNameString?neededParamsArray<Any?>?): Method? {
   //获取服务
   val serviceClazz = serviceMaps[serviceId?return null
   //获取方法集合
   val methods = methodsMap[serviceClazz?return null
   return methods[rebuildParamsFunc(methodNameneededParams)]
}

private fun rebuildParamsFunc(methodNameString?paramsArray<Any?>?): String {

   val stringBuffer = StringBuffer()
   stringBuffer.append(methodName).append("(")

   if (params == null || params.isEmpty()) {
       stringBuffer.append(")")
       return stringBuffer.toString()
  }
   stringBuffer.append(params[0]?.javaClass?.name)
   for (index in 1 until params.size) {
       stringBuffer.append(",").append(params[index]?.javaClass?.name)
  }
   stringBuffer.append(")")
   return stringBuffer.toString()
}

那么在查找注册方法的时候就简单多了,直接抽丝剥茧一层一层取到最终的Method。在拿到Method之后,这里是有2种处理方式,一种是通过静态单例的形式拿到实例对象,并保存在服务端;另一种就是执行普通方法,因为在反射的时候需要拿到类的实例对象才能调用,所以才在GET_INSTANCE的时候存一遍

3 客户端 - connect

在第二节中,我们已经完成了通讯协议的建设,最终一步就是客户端通过绑定服务,向服务端发起通信了。

3.1 bindService

/**
* 绑定服务
*
*/
fun connect(
   contextContext,
   pkgNameString,
   actionString = "",
   serviceClass<out IPCService>
) {
   val intent = Intent()
   if (pkgName.isEmpty()) {
       //同app内的不同进程
       intent.setClass(contextservice)
  } else {
       //不同APP之间进行通信
       intent.setPackage(pkgName)
       intent.setAction(action)
  }
   //绑定服务
   context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
}

inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

   override fun onServiceConnected(nameComponentName?serviceIBinder?) {
       val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
       binders[simpleService= mService
  }

   override fun onServiceDisconnected(nameComponentName?) {
       //断连之后,直接移除即可
       binders.remove(simpleService)
  }
}

对于绑定服务这块,相信伙伴们也很熟悉了,这个需要说一点的就是,在Android 5.0以后,启动服务不能只依赖action启动,还需要指定应用包名,否则就会报错。

在服务连接成功之后,即回调onServiceConnected方法的时候,需要拿到服务端的一个代理对象,即IIPCServiceInterface的实例对象,然后存储在binders集合中,key为绑定的服务类class对象,value就是对应的服务端的代理对象。

fun send(
   typeInt,
   serviceClass<out IPCService>,
   serviceIdString,
   methodNameString,
   paramsArray<Parameters>
): Response? {
   //创建请求
   val request = Request(typeserviceIdmethodNameparams)
   //发起请求
   return try {
       binders[service]?.send(request)
  } catch (eException) {
       null
  }
}

当拿到服务端的代理对象之后,就可以在客户端调用send方法向服务端发送消息。

class Channel {

   //====================================
   /**每个服务对应的Binder对象*/
   private val bindersConcurrentHashMap<Class<out IPCService>IIPCServiceInterface> by lazy {
       ConcurrentHashMap()
  }

   //====================================

   /**
    * 绑定服务
    *
    */
   fun connect(
       contextContext,
       pkgNameString,
       actionString = "",
       serviceClass<out IPCService>
  ) {
       val intent = Intent()
       if (pkgName.isEmpty()) {
           intent.setClass(contextservice)
      } else {
           intent.setPackage(pkgName)
           intent.setAction(action)
           intent.setClass(contextservice)
      }
       //绑定服务
       context.bindService(intentIpcServiceConnection(service), Context.BIND_AUTO_CREATE)
  }

   inner class IpcServiceConnection(val simpleServiceClass<out IPCService>) : ServiceConnection {

       override fun onServiceConnected(nameComponentName?serviceIBinder?) {
           val mService = IIPCServiceInterface.Stub.asInterface(serviceas IIPCServiceInterface
           binders[simpleService= mService
      }

       override fun onServiceDisconnected(nameComponentName?) {
           //断连之后,直接移除即可
           binders.remove(simpleService)
      }
  }


   fun send(
       typeInt,
       serviceClass<out IPCService>,
       serviceIdString,
       methodNameString,
       paramsArray<Parameters>
  ): Response? {
       //创建请求
       val request = Request(typeserviceIdmethodNameparams)
       //发起请求
       return try {
           binders[service]?.send(request)
      } catch (eException) {
           null
      }
  }


   companion object {
       private val instance by lazy {
           Channel()
      }

       /**
        * 获取单例对象
        */
       fun getDefault(): Channel {
           return instance
      }
  }
}

3.2 动态代理获取接口实例

回到1.2小节中,我们定义了一个IUserManager接口,通过前面我们定义的通信协议,只要我们获取了IUserManager的实例对象,那么就能够调用其中的任意普通方法,所以在客户端需要设置一个获取接口实例对象的方法。

fun <T> getInstanceWithName(
   serviceClass<out IPCService>,
   classTypeClass<T>,
   clazzClass<*>,
   methodNameString,
   paramsArray<Parameters>
): T? {
   
   //获取serviceId
   val serviceId = clazz.getAnnotation(ServiceId::class.java)

   val response = Channel.getDefault()
      .send(REQUEST.GET_INSTANCE.ordinalserviceserviceId.namemethodNameparams)
   Log.e("TAG""response $response")
   if (response != null && response.result) {
       //请求成功,返回接口实例对象
       return Proxy.newProxyInstance(
           classType.classLoader,
           arrayOf(classType),
           IPCInvocationHandler()
      ) as T
  }

   return null
}

当我们通过客户端发送一个获取单例的请求后,如果成功了,那么就直接返回这个接口的单例对象,这里直接使用动态代理的方式返回一个接口实例对象,那么后续执行这个接口的方法时,会直接走到IPCInvocationHandler的invoke方法中。

class IPCInvocationHandler(
   val serviceClass<out IPCService>,
   val serviceIdString?
) : InvocationHandler {

   private val gson = Gson()

   override fun invoke(proxyAny?methodMethod?argsArray<out Any>?): Any? {

       //执行客户端发送方法请求
       val response = Channel.getDefault()
          .send(
               REQUEST.INVOKE_METHOD.ordinal,
               service,
               serviceId,
               method?.name ?"",
               args
          )
       //拿到服务端返回的结果
       if (response != null && response.result) {
           //反序列化得到结果
           return gson.fromJson(response.valuemethod?.returnType)
      }


       return null
  }

}

因为服务端在拿到Method的返回结果时,将javabean转换为了json字符串,因此在IPCInvocationHandler中,当调用接口中方法获取结果之后,用Gson将json转换为javabean对象,那么就直接获取到了结果。

3.3 框架使用

服务端:

UserManager2.getDefault().setUserInfo(User("ming"25))
IPC.register(UserManager2::class.java)

同时在服务端需要注册一个IPCService的实例,这里用的是IPCService01

<service
   android:name=".UserService"
   android:enabled="true"
   android:exported="true" />
<service
   android:name="com.lay.ipc.service.IPCService01"
   android:enabled="true"
   android:exported="true">
   <intent-filter>
       <action android:name="android.intent.action.GET_USER_INFO" />
   </intent-filter>
</service>

客户端:

调用connect方法,需要绑定服务端的服务,传入包名和action

IPC.connect(
   this,
   "com.lay.learn.asm",
   "android.intent.action.GET_USER_INFO",
   IPCService01::class.java
)

首先获取IUserManager的实例,注意这里要和服务端注册的UserManager2是同一个ServiceId,而且接口、javabean需要存放在与服务端一样的文件夹下

val userManager = IPC.getInstanceWithName(
   IPCService01::class.java,
   IUserManager::class.java,
   "getDefault",
   null
)
val info = userManager?.getUserInfo()

通过动态代理拿到接口的实例对象,只要调用接口中的方法,就会进入到InvocationHandler中的invoke方法,在这个方法中,通过查找服务端注册的方法名从而找到对应的Method,通过反射调用拿到UserManager中的方法返回值。

这样其实就通过5-6行代码,就完成了进程间通信,是不是比我们在使用AIDL的时候要方便地许多。

4 总结

如果我们面对下面这个类,如果这个类是个私有类,外部没法调用,想通过反射的方式调用其中某个方法。

@ServiceId(name = "UserManagerService")
public class UserManager2 implements IUserManager {

   private static UserManager2 userManager2 = new UserManager2();

   public static UserManager2 getDefault() {
       return userManager2;
  }

   private User user;

   @Nullable
   @Override
   public User getUserInfo() {
       return user;
  }

   @Override
   public void setUserInfo(@NonNull User user) {
       this.user = user;
  }

   @Override
   public int getUserId() {
       return 0;
  }

   @Override
   public void setUserId(int id) {

  }
}

那么我们可以这样做:

val method = UserManager2::class.java.getDeclaredMethod("getUserInfo")
method.isAccessible = true
method.invoke(this,params)

其实这个框架的原理就是上面这几行代码所能够完成的事;通过服务端注册的形式,将UserManager2中所有的方法Method收集起来;当另一个进程,也就是客户端想要调用其中某个方法的时候,通过方法名来获取到对应的Method,调用这个方法得到最终的返回值

作者:layz4android
来源:juejin.cn/post/7192465342159912997

收起阅读 »

Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(上)

如果在Android中想要实现进程间通信,有哪些方式呢?(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;那么本篇文章并不是说完全丢弃掉AIDL,它依然不失...
继续阅读 »

对于进程间通信,很多项目中可能根本没有涉及到多进程,很多公司的app可能就一个主进程,但是对于进程间通信,我们也是必须要了解的。

如果在Android中想要实现进程间通信,有哪些方式呢?

(1)发广播(sendBroadcast):e.g. 两个app之间需要通信,那么可以通过发送广播的形式进行通信,如果只想单点通信,可以指定包名。但是这种方式存在的弊端在于发送方无法判断接收方是否接收到了广播,类似于UDP的通信形式,而且存在丢数据的形式;

(2)Socket通信:这种属于Linux层面的进程间通信了,除此之外,还包括管道、信号量等,像传统的IPC进程间通信需要数据二次拷贝,这种效率是最低的;

(3)AIDL通信:这种算是Android当中主流的进程间通信方案,通过Service + Binder的形式进行通信,具备实时性而且能够通过回调得知接收方是否收到数据,弊端在于需要管理维护aidl接口,如果不同业务方需要使用不同的aidl接口,维护的成本会越来越高。

那么本篇文章并不是说完全丢弃掉AIDL,它依然不失为一个很好的进程间通信的手段,只是我会封装一个适用于任意业务场景的IPC进程间通讯框架,这个也是我在自己的项目中使用到的,不需要维护很多的AIDL接口文件。

有需要源码的伙伴,可以去我的github首页获取 FastIPC源码地址分支:feature/v0.0.1-snapshot有帮助的话麻烦给点个star⭐️⭐️⭐️

1 服务端 - register

首先这里先说明一下,就是对于传统的AIDL使用方式,这里就不再过多介绍了,这部分还是比较简单的,有兴趣的伙伴们可以去前面的文章中查看,本文将着重介绍框架层面的逻辑。

那么IPC进程间通信,需要两个端:客户端和服务端。服务端会提供一个注册方法,例如客户端定义的一些服务,通过向服务端注册来做一个备份,当客户端调用服务端某个方法的时候来返回值。

object IPC {

   //==========================================

   /**
    * 服务端暴露的接口,用于注册服务使用
    */
   fun register(serviceClass<*>) {
       Registry.instance.register(service)
  }

}

其实在注册的时候,我们的目的肯定是能够方便地拿到某个服务,并且能够调用这个服务提供的方法,拿到我想要的值;所以在定义服务的时候,需要注意以下两点:

(1)需要定义一个与当前服务一一对应的serviceId,通过serviceId来获取服务的实例;

(2)每个服务当中定义的方法同样需要对应起来,以便拿到服务对象之后,通过反射调用其中的方法。

所以在注册的时候,需要从这两点入手。

1.1 定义服务唯一标识serviceId

@Target(AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
annotation class ServiceId(
   val nameString
)

一般来说,如果涉及到反射,最常用的就是通过注解给Class做标记,因为通过反射能够拿到类上标记的注解,就能够拿到对应的serviceId。

class Registry {

   //=======================================
   /**用于存储 serviceId 对应的服务 class对象*/
   private val serviceMapsConcurrentHashMap<StringClass<*>> by lazy {
       ConcurrentHashMap()
  }

   /**用于存储 服务中全部的方法*/
   private val methodsMapConcurrentHashMap<Class<*>ConcurrentHashMap<StringMethod>> by lazy {
       ConcurrentHashMap()
  }


   //=======================================

   /**
    * 服务端注册方法
    * @param service 服务class对象
    */
   fun register(serviceClass<*>) {

       // 获取serviceId与服务一一对应
       val serviceIdAnnotation = service.getAnnotation(ServiceId::class.java)
           ?throw IllegalArgumentException("只有标记@ServiceId的服务才能够被注册")
       //获取serviceId
       val name = serviceIdAnnotation.name
       serviceMaps[name= service
       //temp array
       val methodsConcurrentHashMap<StringMethod> = ConcurrentHashMap()
       // 获取服务当中的全部方法
       for (method in service.declaredMethods) {

           //这里需要注意,因为方法中存在重载方法,所以不能把方法名当做key,需要加上参数
           val buffer = StringBuffer()
           buffer.append(method.name).append("(")
           val params = method.parameterTypes
           if (params.size > 0) {
               buffer.append(params[0].name)
          }
           for (index in 1 until params.size) {
               buffer.append(",").append(params[index].name)
          }
           buffer.append(")")
           //保存
           methods[buffer.toString()] = method
      }
       //存入方法表
       methodsMap[service= methods
  }

   companion object {
       val instance by lazy { Registry() }
  }
}

通过上面的register方法,当传入定义的服务class对象的时候,首先获取到服务上标记的@ServiceId注解,注意这里如果要注册必须标记,否则直接抛异常;拿到serviceId之后,存入到serviceMaps中。

然后需要获取服务中的全部方法,因为考虑到重载方法的存在,所以不能单单以方法名作为key,而是需要把参数也加上,因此这里做了一个逻辑就是将方法名与参数名组合一个key,存入到方法表中。

这样注册任务就完成了,其实还是比较简单的,关键在于完成2个表:服务表和方法表的初始化以及数据存储功能

1.2 使用方式

@ServiceId("UserManagerService")
interface IUserManager {

   fun getUserInfo()User?
   fun setUserInfo(userUser)
   fun getUserId()Int
   fun setUserId(idInt)
}

假设项目中有一个用户信息管理的服务,这个服务用于给所有的App提供用户信息查询。

@ServiceId("UserManagerService")
class UserManager : IUserManager {

   private var userUser? = null
   private var userIdInt = 0

   override fun getUserInfo(): User? {
       return user
  }

   override fun setUserInfo(userUser) {
       this.user = user
  }

   override fun getUserId()Int {
       return userId
  }

   override fun setUserId(idInt) {
       this.userId = id
  }

}

用户中心可以注册这个服务,并且调用setUserInfo方法保存用户信息,那么其他App(客户端)连接这个服务之后,就可以调用getUserInfo这个方法,获取用户信息,从而完成进程间通信。

2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: entrySet key class com.lay.learn.asm.binder.UserManager
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserInfo(com.lay.learn.asm.binder.User)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public com.lay.learn.asm.binder.User com.lay.learn.asm.binder.UserManager.getUserInfo()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public int com.lay.learn.asm.binder.UserManager.getUserId()
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue key setUserId(int)
2023-01-23 22:15:54.729 13361-13361/com.lay.learn.asm E/TAG: mapValue value public void com.lay.learn.asm.binder.UserManager.setUserId(int)

我们看调用register方法之后,每个方法的key值都是跟参数绑定在一起,这样服务端注册就完成了。

2 客户端与服务端的通信协议

对于客户端的连接,其实就是绑定服务,那么这里就会使用到AIDL通信,但是跟传统的相比,我们是将AIDL封装到框架层内部,对于用户来说是无感知的。

2.1 创建IPCService

这个服务就是用来完成进程间通信的,客户端需要与这个服务建立连接,通过服务端分发消息,或者接收客户端发送来的消息。

abstract class IPCService : Service() {
   override fun onBind(intentIntent?)IBinder? {
       return null
  }
}

这里我定义了一个抽象的Service基类,为啥要这么做,前面我们提到过是因为整个项目中不可能只有一个服务,因为业务众多,为了保证单一职责,需要划分不同的类型,所以在框架中会衍生多个实现类,不同业务方可以注册这些服务,当然也可以自定义服务继承IPCService。

class IPCService01 : IPCService() {
}

在IPCService的onBind需要返回一个Binder对象,因此需要创建aidl文件。

2.2 定义通讯协议

像我们在请求接口的时候,通常也是向服务端发起一个请求(Request),然后得到服务端的一个响应(Response),因此在IPC通信的的时候,也可以根据这种方式建立通信协议。

data class Request(
   val type: Int,
   val serviceId: String?,
   val methodName: String?,
   val params: Array<Parameters>?
) : Parcelable {
   //=====================================
   /**请求类型*/
   //获取实例的对象
   val GET_INSTANCE = "getInstance"
   //执行方法
   val INVOKE_METHOD = "invokeMethod"
   
   //=======================================

   constructor(parcel: Parcel) : this(
       parcel.readInt(),
       parcel.readString(),
       parcel.readString(),
       parcel.createTypedArray(Parameters.CREATOR)
  )

   override fun writeToParcel(parcel: Parcel, flags: Int) {
       parcel.writeInt(type)
       parcel.writeString(serviceId)
       parcel.writeString(methodName)
  }

   override fun describeContents(): Int {
       return 0
  }

   override fun equals(other: Any?): Boolean {
       if (this === other) return true
       if (javaClass != other?.javaClass) return false

       other as Request

       if (type != other.type) return false
       if (serviceId != other.serviceId) return false
       if (methodName != other.methodName) return false
       if (params != null) {
           if (other.params == null) return false
           if (!params.contentEquals(other.params)) return false
      } else if (other.params != null) return false

       return true
  }

   override fun hashCode(): Int {
       var result = type
       result = 31 * result + (serviceId?.hashCode() ?: 0)
       result = 31 * result + (methodName?.hashCode() ?: 0)
       result = 31 * result + (params?.contentHashCode() ?: 0)
       return result
  }

   companion object CREATOR : Parcelable.Creator<Request> {
       override fun createFromParcel(parcel: Parcel): Request {
           return Request(parcel)
      }

       override fun newArray(size: Int): Array<Request?> {
           return arrayOfNulls(size)
      }
  }

}

对于客户端来说,致力于发起请求,请求实体类Request参数介绍如下:

type表示请求的类型,包括两种分别是:执行静态方法和执行普通方法(考虑到反射传参);

serviceId表示请求的服务id,要请求哪个服务,便可以获取到这个服务的实例对象,调用服务中提供的方法;

methodName表示要请求的方法名,也是在serviceId服务中定义的方法;

params表示请求的方法参数集合,我们在服务端注册的时候,方法名 + 参数名 作为key,因此需要知道请求的方法参数,以便获取到Method对象。

data class Response(
   val value:String?,
   val result:Boolean
):Parcelable {
   @SuppressLint("NewApi")
   constructor(parcelParcel) : this(
       parcel.readString(),
       parcel.readBoolean()
  )

   override fun writeToParcel(parcelParcelflagsInt) {
       parcel.writeString(value)
       parcel.writeByte(if (result1 else 0)
  }

   override fun describeContents()Int {
       return 0
  }

   companion object CREATOR : Parcelable.Creator<Response> {
       override fun createFromParcel(parcelParcel)Response {
           return Response(parcel)
      }

       override fun newArray(sizeInt)Array<Response?> {
           return arrayOfNulls(size)
      }
  }
}

对于服务端来说,在接收到请求之后,需要针对具体的请求返回相应的结果,Response实体类参数介绍:

result表示请求成功或者失败;

value表示服务端返回的结果,是一个json字符串。

因此定义aidl接口文件如下,输入一个请求之后,返回一个服务端的响应。

interface IIPCServiceInterface {
   Response send(in Request request);
}

这样IPCService就可以将aidl生成的Stub类作为Binder对象返回。

abstract class IPCService : Service() {
   
   override fun onBind(intentIntent?)IBinder? {
       return BINDERS
  }

   companion object BINDERS : IIPCServiceInterface.Stub() {
       override fun send(requestRequest?)Response? {

           when(request?.type){
               
               REQUEST.GET_INSTANCE.ordinal->{
                   
              }
               REQUEST.INVOKE_METHOD.ordinal->{
                   
              }
          }
           
           return null
      }
  }
}

续:Android进阶宝典 -- 告别繁琐的AIDL吧,手写IPC通信框架,5行代码实现进程间通信(下)

作者:layz4android

来源:juejin.cn/post/7192465342159912997

收起阅读 »

高仿B站自定义表情

在之前的文章给你的 Android App 添加自定义表情中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:自定义表情的大小当我们写死表情的大小时,文字的 textSize 变大变小时都会有一...
继续阅读 »

在之前的文章给你的 Android App 添加自定义表情中我们介绍了自定义表情的原理,没看过的建议看一下。这一篇文章将介绍它的应用,这里以B站的自定义表情面板为例,效果如下:


自定义表情的大小

当我们写死表情的大小时,文字的 textSize 变大变小时都会有一点问题。

文字大于图片大小时,在多行的情况下,只有表情的行间距明显小于其他行的间距。如图:


为什么会出现这种情况呢?如下图所示,我在top, ascent, baseline, descent, bottom的位置标注了辅助线。


可以很清晰的看到,在只有表情的情况下,top, ascent, descent, bottom的位置有明显的问题。原因是 DynamicDrawableSpangetSize 方法里面对 FontMetricsInt 进行了修改。解决的方式很简单,就是注释掉修改代码就行,代码如下。修改后,效果如下图所示。

@Override
   public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
         Drawable d = getDrawable();
         Rect rect = d.getBounds();
//
//       if (fm != null) {
//           fm.ascent = -rect.bottom;
//           fm.descent = 0;
//
//           fm.top = fm.ascent;
//           fm.bottom = 0;
//       }

       return rect.right;
  }


不知道你还记不记得,我们说过getSize 的返回值是表情的宽度。上面的注释代码其实是设置了表情的高度,如果文本的大小少于表情时,就会显示不全,如下图所示:


那这种情况下,应该怎么办?这里不卖关子了,最终代码如下。解决方式非常简单就是分情况来判断。当文本的高度小于表情的高度时,设置 fmtop, ascent, descent, bottom的值,让行的高度变大的同时让大的 emoji 图片居中。

 @Override
  public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
      Drawable d = getDrawable();
      Rect rect = d.getBounds();

      float drawableHeight = rect.height();
      Paint.FontMetrics paintFm = paint.getFontMetrics();

      if (fm != null) {
          int textHeight = fm.bottom - fm.top;
          if(textHeight <= drawableHeight) {//当文本的高度小于表情的高度时
              //解决文字的大小小于图片大小的情况
              float textCenter = (paintFm.descent + paintFm.ascent) / 2;
              fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
              fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
          }
      }
  return rect.right;
}

当然,你可能发现了,B站的 emoji 表情好像不是居中的。如下图所示,B站对 emoji 表情的处理类似基于 baseline 对齐。


上面最难理解的居中已经介绍,对于其他方式比如 baseline 和 bottom 就简单了。完整代码如下:

@Override
  public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable FontMetricsInt fm) {
      Drawable d = getDrawable();
      if(d == null) {
          return 48;
      }
      Rect rect = d.getBounds();

      float drawableHeight = rect.height();
      Paint.FontMetrics paintFm = paint.getFontMetrics();

      if (fm != null) {
          if (mVerticalAlignment == ALIGN_BASELINE) {
              fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
              fm.bottom = (int) (paintFm.bottom);
              fm.descent = (int) paintFm.descent;
          } else if(mVerticalAlignment == ALIGN_BOTTOM) {
              fm.ascent = fm.top = (int) (paintFm.bottom - drawableHeight);
              fm.bottom = (int) (paintFm.bottom);
              fm.descent = (int) paintFm.descent;
          } else if (mVerticalAlignment == ALIGN_CENTER) {
              int textHeight = fm.bottom - fm.top;
              if(textHeight <= rect.height()) {
                  float textCenter = (paintFm.descent + paintFm.ascent) / 2;
                  fm.ascent = fm.top = (int) (textCenter - drawableHeight / 2);
                  fm.descent = fm.bottom = (int) (textCenter + drawableHeight / 2);
              }
          }
      }

      return rect.right;
  }

动态表情

动态表情实际上就是 gif 图。我们可以使用 android-gif-drawable 来实现。在 build.gradle 中增加依赖:

dependencies {
  ...
  implementation 'pl.droidsonroids.gif:android-gif-drawable:1.2.25'
}

然后在我们创建自定义 ImageSpan 的时候传入参数就可以了:

val size = 192
val gifFromResource = GifDrawable(getResources(), gifData.drawableResource)
gifFromResource.stop()
gifFromResource.setBounds(0,0, size, size)
val content = mBinding.editContent.text as SpannableStringBuilder
val stringBuilder = SpannableStringBuilder(gifData.text)
stringBuilder.setSpan(BilibiliEmojiSpan(gifFromResource, ALIGN_BASELINE),
  0, stringBuilder.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

关于 android-gif-drawable 更具体用法可以看 Android加载Gif动画android-gif-drawable的使用

作者:小墙程序员

来源:juejin.cn/post/7196592276159823931

收起阅读 »

首页弹框太多?Flow帮你“链”起来

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于如何去判断它们什么时候出来它...
继续阅读 »

很多App一打开,首页都会有各种各样的交互,比如权限授权,版本更新,阅读协议,活动介绍,用户权限变更等,这些交互大多数都是以弹框为主,也会有少数几个是以页面或者别的形式出现,但是无论是弹框还是页面,这些只是表现形式,这种交互难点在于

  1. 如何去判断它们什么时候出来

  2. 它们出来的先后次序是什么

  3. 中途需求如果增加或者删除一个弹框或者页面,我们应该改动哪些逻辑

常见的做法

可能这种需求刚开始由于弹框少,交互还简单,所以大多数的做法就是直接在首页用if-else去完成了

if(条件1){
  //弹框1
}else if(条件2){
   //弹框2
}

但是当需求慢慢迭代下去,首页弹框越来越多,判断的逻辑也越来越复杂,判断条件之间还存在依赖关系的时候,我们的代码就变得很可怕了

if(条件1 && 条件2 && 条件3){
  //弹框1
}else if(条件1 && (条件2 || 条件3)){
   //弹框2
}else if(条件2 && 条件3){
   //弹框3
}else if(....){
  ....
}

这种情况下,这些代码就变的越来越难维护,长久下来,造成的问题也越来越多,比如

  1. 代码可读性变差,不是熟悉业务的人无法去理解这些逻辑代码

  2. 增加或者减少弹框或者条件需要更改中间的逻辑,容易产生bug

  3. 每个分支的弹框结束后,需要重新从第一个if再执行一遍判断下一个弹框是哪一个,如果条件里面牵扯到io操作,也会产生一定的性能问题

设计思路

能否让每个弹框作为一个单独的任务,生成一条任务链,链上的节点为单个任务,节点维护任务执行的条件以及任务本身逻辑,节点之间无任何依赖关系,具体执行由任务链去管理,这样的话如果增加或者删除某一个任务,我们只需要插拔任务节点就可以


定义任务

首先我们先简单定一个任务,以及需要执行的操作

interface SingleJob {
   fun handle(): Boolean
   fun launch(context: Context, callback: () -> Unit)
}
  • handle():判断任务是否应该执行的条件

  • launch():执行任务,并在任务结束后通过callback通知任务链执行下一条任务

实现任务

定义一个TaskJobOne,让它去实现SingleJob

class TaskJobOne : SingleJob {
   override fun handle(): Boolean {
       println("start handle job one")
       return true
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       AlertDialog.Builder(context).setMessage("这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
}

这个任务里面,我们先默认handle的执行条件是true,一定会执行,实际开发过程中可以根据需要来定义条件,比如判断登录态等,lanuch里面我们简单的弹一个框,然后在弹框的关闭的时候,callback给任务链,为了调试的时候看的清楚一些,在这两个函数入口分别打印了日志,同样的任务我们再创建一个TaskJobTwo,TaskJobThree,具体实现差不多,就不贴代码了

任务链

首先思考下如何存放任务,由于任务之间需要体现出优先级关系,所以这里决定使用一个LinkedHashMap,K表示优先级,V表示任务

object JobTaskManager {
   val jobMap = linkedMapOf(
       1 to TaskJobOne(),
       2 to TaskJobTwo(),
       3 to TaskJobThree()
  )
}

接着就是思考如何设计整条任务链的执行任务,因为这个是对jobMap里面的任务逐个拿出来执行的过程,所以我们很容易就想到可以用Flow去控制这些任务,但是有两个问题需要去考虑下

  1. 如果直接将jobMap转成Flow去执行,那么出现的问题就是所有任务全部都一次性执行完,显然不符合设计初衷

  2. 我们都知道Flow是由上游发送数据,下游接收并处理数据的一个自上而下的过程,但是这里我们需要一个job执行完以后,通过callback通知任务链去执行下一个任务,任务的发送是由前一个任务控制的,所以必须设计出一个环形的过程

首先我们需要一个变量去保存当前需要执行的任务优先级,我们定义它为curLevel,并设置初始值为1,表示第一个执行的是优先级为1的任务

var curLevel = 1

这个变量将会在任务执行完以后,通过callback回调以后再自增,至于自增之后如何再去执行下一条任务,这个通知的事情我们交给StateFlow

val stateFlow = MutableStateFlow(curLevel)
fun doJob(context: Context, job: SingleJob) {
   if (job.handle()) {
       job.launch(context) {
           curLevel++
           if (curLevel <= jobMap.size)
               stateFlow.value = curLevel
      }
  } else {
       curLevel++
       if (curLevel <= jobMap.size)
           stateFlow.value = curLevel
  }
}

stateFlow初始值是curlevel,当上层开始订阅的时候,不给stateFlow设置value,那么stateFlow初始值1就会发送出去,开始执行优先级为1的任务,在doJob里面,当任务的执行条件不满足或者任务已经执行完成,就自增curLevel,再给stateFlow赋值,从而执行下一个任务,这样一个环形过程就有了,下面是在上层如何执行任务链

MainScope().launch {
   JobTaskManager.apply {
       stateFlow.collect {
           flow {
               emit(jobMap[it])
          }.collect {
               doJob(this@MainActivity, it!!)
          }
      }
  }
}

我们的任务链就完成了,看下效果


通过日志我们可以看到,的确是每次关闭一个弹框,才开始执行下一条任务,这样一来,如果某个任务的条件不满足,或者不想让它执行了,只需要改变对应job的handle条件就可以,比如现在把TaskJobOne的handel设置为false,在看下效果

class TaskJobOne : SingleJob {
   override fun handle(): Boolean {
       println("start handle job one")
       return false
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       AlertDialog.Builder(context).setMessage("这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
}


可以看到经过第一个task的时候,由于已经把handle条件设置成false了,所以直接跳过,执行下一个任务了

依赖于外界因素

上面只是简单的模拟了一个任务链的工作流程,实际开发过程中,我们有的任务会依赖于其他因素,最常见的就是必须等到某个接口返回数据以后才去执行,所以这个时候,执行你的任务需要判断的东西就更多了

  • 是否优先级已经轮到它了

  • 是否依赖于某个接口

  • 这个接口是否已经成功返回数据了

  • 接口数据是否需要传递给这个任务 鉴于这些,我们就要重新设计我们的任务与任务链,首先要定义几个状态值,分别代表任务的不同状态

const val JOB_NOT_AVAILABLE = 100
const val JOB_AVAILABLE = 101
const val JOB_COMBINED_BY_NOTHING = 102
const val JOB_CANCELED = 103
  • JOB_NOT_AVAILABLE:该任务还没有达到执行条件

  • JOB_AVAILABLE:该任务达到了执行任务的条件

  • JOB_COMBINED_BY_NOTHING:该任务不关联任务条件,可直接执行

  • JOB_CANCELED:该任务不能执行

接着需要去扩展一下SingleJob的功能,让它可以设置状态,获取状态,并且可以传入数据

interface SingleJob {
  ......
   /**
    * 获取执行状态
    */
   fun status():Int

   /**
    * 设置执行状态
    */
   fun setStatus(level:Int)

   /**
    * 设置数据
    */
   fun setBundle(bundle: Bundle)
}

更改一下任务的实现

class TaskJobOne : SingleJob {
   var flag = JOB_NOT_AVAILABLE
   var data: Bundle? = null
   override fun handle(): Boolean {
       println("start handle job one")
       return  flag != JOB_CANCELED
  }
   override fun launch(context: Context, callback: () -> Unit) {
       println("start launch job one")
       val type = data?.getString("dialog_type")
       AlertDialog.Builder(context).setMessage(if(type != null)"这是第一个${type}弹框" else "这是第一个弹框")
          .setPositiveButton("ok") {x,y->
               callback()
          }.show()
  }
   override fun setStatus(level: Int) {
       if(flag != JOB_COMBINED_BY_NOTHING)
           this.flag = level
  }
   override fun status(): Int = flag

   override fun setBundle(bundle: Bundle) {
       this.data = bundle
  }
}

现在的任务执行条件已经变了,变成了状态不是JOB_CANCELED的任务才可以执行,增加了一个变量flag表示这个任务的当前状态,如果是JOB_COMBINED_BY_NOTHING表示不依赖外界因素,外界也不能改变它的状态,其余状态则通过setStatus函数来改变,增加了setBundle函数允许外界向任务传入数据,并且在launch函数里面接收数据并展示在弹框上,我们在任务链里面增加一个函数,用来给对应优先级的任务设置状态与数据

fun setTaskFlag(level: Int, flag: Int, bundle: Bundle = Bundle()) {
       if (level > jobMap.size) {
           return
      }
       jobMap[level]?.apply {
           setStatus(flag)
           setBundle(bundle)
      }
  }

我们现在可以把任务链同接口一起关联起来了,首先我们先创建个viewmodel,在里面创建三个flow,分别模拟三个不同接口,并且在flow里面向下游发送数据

class MainViewModel : ViewModel(){
   val firstApi = flow {
       kotlinx.coroutines.delay(1000)
       emit("元宵节活动")
  }
   val secondApi = flow {
       kotlinx.coroutines.delay(2000)
       emit("端午节活动")
  }
   val thirdApi = flow {
       kotlinx.coroutines.delay(3000)
       emit("中秋节活动")
  }
}

接着我们如果想要去执行任务链,就必须等到所有接口执行完毕才可以,刚好flow里面的zip操作符就可以满足这一点,它可以让异步任务同步执行,等到都执行完任务之后,才将数据传递给下游,代码实现如下

val mainViewModel: MainViewModel by lazy {
   ViewModelProvider(this)[MainViewModel::class.java]
}

MainScope().launch {
   JobTaskManager.apply {
       mainViewModel.firstApi
           .zip(mainViewModel.secondApi) { a, b ->
               setTaskFlag( 1, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", a)
              })
               setTaskFlag( 2, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", b)
              })
          }.zip(mainViewModel.thirdApi) { _, c ->
               setTaskFlag( 3, JOB_AVAILABLE, Bundle().apply {
                   putString("dialog_type", c)
              })
          }.collect {
               stateFlow.collect {
                   flow {
                       emit(jobMap[it])
                  }.collect {
                       doJob(this@MainActivity, it!!)
                  }
              }
          }
  }
}

运行一下,效果如下


我们看到启动后第一个任务并没有立刻执行,而是等了一会才去执行,那是因为zip操作符是等所有flow里面的同步任务都执行完毕以后才发送给下游,flow里面已经执行完毕的会去等待还没有执行完毕的任务,所以才会出现刚刚页面有一段等待的时间,这样的设计一般情况下已经可以满足需求了,毕竟正常情况一个接口的响应时间都是毫秒级别的,但是难防万一出现一些极端情况,某一个接口响应忽然变慢了,就会出现我们的任务链迟迟得不到执行,产品体验方面就大打折扣了,所以需要想个方案解决一下这个问题

优化

首先我们需要当应用启动以后就立马执行任务链,判断当前需要执行任务的优先级与curLevel是否一致,另外,该任务的状态是可执行状态

/**
* 应用启动就执行任务链
*/
fun loadTask(context: Context) {
   judgeJob(context, curLevel)
}

/**
* 判断当前需要执行任务的优先级是否与curLevel一致,并且任务可执行
*/
private fun judgeJob(context: Context, cur: Int) {
   val job = jobMap[cur]
   if(curLevel == cur && job?.status() != JOB_NOT_AVAILABLE){
       MainScope().launch {
           doJob(context, cur)
      }
  }
}

我们更改一下doJob函数,让它成为一个挂起函数,并且在里面执行完任务以后,直接去判断它的下一级任务应不应该执行

private suspend fun doJob(context: Context, index: Int) {
   if (index > jobMap.size) return
   val singleJOb = jobMap[index]
   callbackFlow {
       if (singleJOb?.handle() == true) {
           singleJOb.launch(context) {
               trySend(index + 1)
          }
      } else {
           trySend(index + 1)
      }
       awaitClose { }
  }.collect {
       curLevel = it
       judgeJob(context,it)
  }
}

流程到了这里,如果所有任务都不依赖接口,那么这个任务链就能一直执行下去,如果遇到JOB_NOT_AVAILABLE的任务,需要等到接口响应的,那么任务链停止运行,那什么时候重新开始呢?就在我们接口成功回调之后给任务更改状态的时候,也就是setTaskFlag

fun setTaskFlag(context:Context,level: Int, flag: Int, bundle: Bundle = Bundle()) {
   if (level > jobMap.size) {
       return
  }
   jobMap[level]?.apply {
       setStatus(flag)
       setBundle(bundle)
  }
   judgeJob(context,level)
}

这样子,当任务链走到一个JOB_NOT_AVAILABLE的任务的时候,任务链暂停,当这个任务依赖的接口成功回调完成对这个任务状态的设置之后,再重新通过judgeJob继续走这条任务链,而一些优先级比较低的任务依赖的接口先完成了回调,那也只是完成对这个任务的状态更改,并不会执行它,因为curLevel还没到这个任务的优先级,现在可以试一下效果如何,我们把threeApi这个接口响应时间改的长一点

val thirdApi = flow {
   kotlinx.coroutines.delay(5000)
   emit("中秋节活动")
}

上层执行任务链的地方也改一下

MainScope().launch {
   JobTaskManager.apply {
       loadTask(this@MainActivity)
       mainViewModel.firstApi.collect{
           setTaskFlag(this@MainActivity, 1, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
       mainViewModel.secondApi.collect{
           setTaskFlag(this@MainActivity, 2, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
       mainViewModel.thirdApi.collect{
           setTaskFlag(this@MainActivity, 3, JOB_AVAILABLE, Bundle().apply {
               putString("dialog_type", it)
          })
      }
  }
}

应用启动就loadTask,然后三个接口已经从同步又变成异步操作了,运行一下看看效果


总结

大致的一个效果算是完成了,这只是一个demo,实际需求当中可能更复杂,弹框,页面,小气泡来回交互的情况都有可能,这里也只是想给一些正在优化项目的的同学提供一个思路,或者接手新需求的时候,鼓励多思考一下有没有更好的设计方案

作者:Coffeeee
来源:juejin.cn/post/7195336320435601467

收起阅读 »

Android通知栏增加快捷开关的技术实现

我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileServ...
继续阅读 »


我们通常可以在通知栏上看到“飞行模式”、“移动数据”、“屏幕录制”等开关按钮,这些按钮都属于通知栏上的快捷开关,点击快捷开关可以轻易调用某种系统能力或打开某个应用程序的特定页面。那是否可以在通知栏上自定义一个快捷开关呢?答案是可以的,具体是通过TileService的方案实现。

TileService继承自Service,所以它也是Android的四大组件之一,不过它是一个特殊的组件,开发者不需要手动开启调用,系统可以自动识别并完成调用,系统会通过绑定服务(bindService)的方式调用。

创建使用:

快捷开关是Android 7(target 24)的新能力,因此在使用该能力前必须先判断版本大小(大于等于target 24)。

1、自定义一个TileService类。

class MyQSTileService: TileService() {
 override fun onTileAdded() {    
     super.onTileAdded()  
}
 
 override fun onStartListening() {    
     super.onStartListening()  
}
 
 override fun onStopListening() {    
     super.onStopListening()  
}
 
 override fun onClick() {    
     super.onClick()  
}
 
 override fun onTileRemoved() {    
     super.onTileRemoved()  
}
}

TileService是通过绑定服务(bindService)的方式被调用的,因此,绑定服务生命周期包含的四种典型的回调方法(onCreate()、onBind()、onUnbind()和 onDestroy())都会被调用。但是,TileService也包含了以下特殊的生命周期回调方法:

  • onTileAdded():当用户从编辑栏添加快捷开关到通知栏的快速设置中会调用。

  • onTileRemoved():当用户从通知栏的快速设置移除快捷开关时调用。

  • onClick():当用户点击快捷开关时调用。

  • onStartListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileAdded添加之后会调用一次。

  • onStopListening():当用户打开通知栏的快速设置时调用。当快捷开关并没有从编辑栏拖到设置栏中不会调用。在TileRemoved移除之前会调用一次。

2、在应用程序的清单文件中声明TileService

<service
    android:name=".MyQSTileService"
    android:label="@string/my_default_tile_label"  
    android:icon="@drawable/my_default_icon_label"
    android:exported="true"
    android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
    <intent-filter>
        <action android:name="android.service.quicksettings.action.QS_TILE" />
    </intent-filter>
</service>
  • name:自定义的TileService的类名。

  • label:快捷开关在通知栏上显示的名称。

  • icon:快捷开关在通知栏上显示的图标。

  • exported:该服务能否被外部应用调用。该属性必须为true。如果为false,那么快捷开关的功能将失效,原因是exported="false"时,TileService将不支持外部应用调起,手机系统自然不能再和该快捷开关交互。必须配置。

  • permission:需要给service配置的权限,BIND_QUICK_SETTINGS_TILE即允许应用程序绑定到第三方快速设置。必须配置。

  • intent-filter:意图过滤器,只有匹配内部的action,才能调起该service。必须配置。

监听模式

TileService的监听模式(或理解为启动模式)有两种,一种是主动模式,另一种是标准模式。

  • 主动模式

在主动模式下,TileService被请求时该服务会被绑定,并且TileService的onStartListening也会被调用。该模式需要在AndroidManifeast清单文件中声明:

<service ...>
  <meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
        android:value="true" />
  ...
</service>

通过TileService.requestListeningState()这一静态方法,就可以实现对TileService的请求,示例如下:

      TileService.requestListeningState(
           applicationContext, ComponentName(
               BuildConfig.APPLICATION_ID,
               MyQSTileService::class.java.name
          )
      )

主动模式下值得注意的是:

  • 用户在通知栏快速设置的地方点击快捷开关时,TileService会自动完成绑定、TileService的onStartListening会被调用。

  • TileService无论是通过点击被绑定还是通过requestListeningState请求被绑定,TileService所在的进程都会被调起。

标准模式

在标准模式下,TileService可见时(即用户下拉通知栏看见快捷开关)该服务会被绑定,并且TileService的onStartListening也会被调用。标准模式不需要在AndroidManifeast清单文件中进行额外的声明,默认就是标准模式。

标准模式下值得注意的是:

  • 和主动模式相同,TileService被绑定时,TileService所在的进程就会被调起。

  • 而和主动模式不同的是,标准模式绑定TileService是通过用户下拉通知栏实现的,这意味着TileService所在的进程会被多次调起。因此为了避免主进程被频繁调起、避免DAU等数据统计受到影响,我们还需要为TileService指定一个特定的子进程,在Androidmanifest清单文件中设置:

      <service
          ......
          android:process="自定义子进程的名称">
          ......
      </service>

更新快捷开关

如果需要对快捷开关的数据进行更新,可以通过getQsTile()获取快捷开关的对象,然后通过setIcon(更新icon)、setLable(更新名称)、setState(更新状态,包括STATE_ACTIVE——表示开启或启用状态、STATE_INACTIVE——表示关闭或暂停状态、STATE_UNAVAILABLE:表示暂时不可用状态,在此状态下,用户无法与您的磁贴交互)等方法设置快捷开关新的数据,最后调用updateTile()方法实现。

  override fun onStartListening() {
  super.onStartListening()
  if (qsTile.state === Tile.STATE_ACTIVE) {
      qsTile.label = "inactive"
      qsTile.icon = Icon.createWithResource(context, R.drawable.inactive)
      qsTile.state = Tile.STATE_INACTIVE
  } else {
      qsTile.label = "active"
      qsTile.icon = Icon.createWithResource(context, R.drawable.active)
      qsTile.state = Tile.STATE_ACTIVE
  }
  qsTile.updateTile()
}

操作快捷开关

  • 如果想要实现点击快捷开关时、关闭通知栏并跳转到某个页面,可以调用以下方法:

startActivityAndCollapse(Intent intent)
  • 如果想要在点击快捷开关时弹出对话框进行交互,可以调用以下方法:

override fun onClick() {
   super.onClick()
   if(!isLocked()) {
       showDialog()
  }
}

因为快捷开关有可能在用户锁屏时出现,所以必须加上isLocked()的判断。只有非锁屏的情况下,对话框才会出现。

  • 如果快捷开关含有敏感信息,需要使用isSecure()进行设备安全性判断,当设备安全时,才能执行快捷开关相关的逻辑(如点击的逻辑)。当设备不安全时(手机处于锁屏状态时),可调用unlockAndRun(Runnable runnable),提示用户解锁屏幕并执行自定义的runnable操作。

以上是通知栏增加快捷开关的全部介绍。欢迎关注公众号度熊君,一起分享交流。

作者:度熊君
来源:juejin.cn/post/7190663063631036473

收起阅读 »

Android动态加载so!这一篇就够了!

背景对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,...
继续阅读 »

背景

对于一个普通的android应用来说,so库的占比通常都是巨高不下的,因为我们无可避免的在开发中遇到各种各样需要用到native的需求,所以so库的动态化可以减少极大的包体积,自从2020腾讯的bugly团队发部关于动态化so的相关文章后,已经过去两年了,相关文章,经过两年的考验,实际上so动态加载也是非常成熟的一项技术了,但是很遗憾,许多公司都还没有这方面的涉略又或者说不知道从哪里开始进行,因为so动态其实涉及到下载,so版本管理,动态加载实现等多方面,我们不妨抛开这些额外的东西,从最本质的so动态加载出发吧!这里是本次的例子,我把它命名为sillyboy,欢迎pr还有后续点赞呀!

so动态加载介绍

动态加载,其实就是把我们的so库在打包成apk的时候剔除,在合适的时候通过网络包下载的方式,通过一些手段,在运行的时候进行分离加载的过程。这里涉及到下载器,还有下载后的版本管理等等确保一个so库被正确的加载等过程,在这里,我们不讨论这些辅助的流程,我们看下怎么实现一个最简单的加载流程。


从一个例子出发

我们构建一个native工程,然后在里面编入如下内容,下面是cmake

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.18.1)

# Declares and names the project.

project("nativecpp")

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
      nativecpp

      # Sets the library as a shared library.
      SHARED

      # Provides a relative path to your source file(s).
      native-lib.cpp)

add_library(
      nativecpptwo
      SHARED
      test.cpp

)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
      log-lib

      # Specifies the name of the NDK library that
      # you want CMake to locate.
      log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
      nativecpp

      # Links the target library to the log library
      # included in the NDK.
      ${log-lib})


target_link_libraries( # Specifies the target library.
      nativecpptwo

      # Links the target library to the log library
      # included in the NDK.
      nativecpp
      ${log-lib})

可以看到,我们生成了两个so库一个是nativecpp,还有一个是nativecpptwo(为什么要两个呢?我们可以继续看下文) 这里也给出最关键的test.cpp代码

#include <jni.h>
#include <string>
#include<android/log.h>


extern "C"
JNIEXPORT void JNICALL
Java_com_example_nativecpp_MainActivity_clickTest(JNIEnv *env, jobject thiz) {
  // 在这里打印一句话
  __android_log_print(ANDROID_LOG_INFO,"hello"," native 层方法");

}

很简单,就一个native方法,打印一个log即可,我们就可以在java/kotin层进行方法调用了,即

public native void clickTest();

so库检索与删除

要实现so的动态加载,那最起码是要知道本项目过程中涉及到哪些so吧!不用担心,我们gradle构建的时候,就已经提供了相应的构建过程,即构建的task【 mergeDebugNativeLibs】,在这个过程中,会把一个project里面的所有native库进行一个收集的过程,紧接着task【stripDebugDebugSymbols】是一个符号表清除过程,如果了解native开发的朋友很容易就知道,这就是一个减少so体积的一个过程,我们不在这里详述。所以我们很容易想到,我们只要在这两个task中插入一个自定义的task,用于遍历和删除就可以实现so的删除化了,所以就很容易写出这样的代码

ext {
   deleteSoName = ["libnativecpptwo.so","libnativecpp.so"]
}
// 这个是初始化 -配置 -执行阶段中,配置阶段执行的任务之一,完成afterEvaluate就可以得到所有的tasks,从而可以在里面插入我们定制化的数据
task(dynamicSo) {
}.doLast {
   println("dynamicSo insert!!!! ")
   //projectDir 在哪个project下面,projectDir就是哪个路径
   print(getRootProject().findAll())

   def file = new File("${projectDir}/build/intermediates/merged_native_libs/debug/out/lib")
   //默认删除所有的so库
   if (file.exists()) {
       file.listFiles().each {
           if (it.isDirectory()) {
               it.listFiles().each {
                   target ->
                       print("file ${target.name}")
                       def compareName = target.name
                       deleteSoName.each {
                           if (compareName.contains(it)) {
                               target.delete()
                          }
                      }
              }
          }
      }
  } else {
       print("nil")
  }
}
afterEvaluate {
   print("dynamicSo task start")
   def customer = tasks.findByName("dynamicSo")
   def merge = tasks.findByName("mergeDebugNativeLibs")
   def strip = tasks.findByName("stripDebugDebugSymbols")
   if (merge != null || strip != null) {
       customer.mustRunAfter(merge)
       strip.dependsOn(customer)
  }

}

可以看到,我们定义了一个自定义task dynamicSo,它的执行是在afterEvaluate中定义的,并且依赖于mergeDebugNativeLibs,而stripDebugDebugSymbols就依赖于我们生成的dynamicSo,达到了一个插入操作。那么为什么要在afterEvaluate中执行呢?那是因为android插件是在配置阶段中才生成的mergeDebugNativeLibs等任务,原本的gradle构建是不存在这样一个任务的,所以我们才需要在配置完所有task之后,才进行的插入,我们可以看一下gradle的生命周期


通过对条件检索,我们就删除掉了我们想要的so,即ibnativecpptwo.so与libnativecpp.so。

动态加载so

根据上文检索出来的两个so,我们就可以在项目中上传到自己的后端中,然后通过网络下载到用户的手机上,这里我们就演示一下即可,我们就直接放在data目录下面吧


真实的项目过程中,应该要有校验操作,比如md5校验或者可以解压等等操作,这里不是重点,我们就直接略过啦!

那么,怎么把一个so库加载到我们本来的apk中呢?这里是so原本的加载过程,可以看到,系统是通过classloader检索native目录是否存在so库进行加载的,那我们反射一下,把我们自定义的path加入进行不就可以了吗?这里采用tinker一样的思路,在我们的classloader中加入so的检索路径即可,比如

private static final class V25 {
  private static void install(ClassLoader classLoader, File folder) throws Throwable {
      final Field pathListField = ShareReflectUtil.findField(classLoader, "pathList");
      final Object dexPathList = pathListField.get(classLoader);

      final Field nativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

      List<File> origLibDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
      if (origLibDirs == null) {
          origLibDirs = new ArrayList<>(2);
      }
      final Iterator<File> libDirIt = origLibDirs.iterator();
      while (libDirIt.hasNext()) {
          final File libDir = libDirIt.next();
          if (folder.equals(libDir)) {
              libDirIt.remove();
              break;
          }
      }
      origLibDirs.add(0, folder);

      final Field systemNativeLibraryDirectories = ShareReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
      List<File> origSystemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);
      if (origSystemLibDirs == null) {
          origSystemLibDirs = new ArrayList<>(2);
      }

      final List<File> newLibDirs = new ArrayList<>(origLibDirs.size() + origSystemLibDirs.size() + 1);
      newLibDirs.addAll(origLibDirs);
      newLibDirs.addAll(origSystemLibDirs);

      final Method makeElements = ShareReflectUtil.findMethod(dexPathList, "makePathElements", List.class);

      final Object[] elements = (Object[]) makeElements.invoke(dexPathList, newLibDirs);

      final Field nativeLibraryPathElements = ShareReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
      nativeLibraryPathElements.set(dexPathList, elements);
  }
}

我们在原本的检索路径中,在最前面,即数组为0的位置加入了我们的检索路径,这样一来classloader在查找我们已经动态化的so库的时候,就能够找到!

结束了吗?

一般的so库,比如不依赖其他的so的时候,直接这样加载就没问题了,但是如果存在着依赖的so库的话,就不行了!相信大家在看其他的博客的时候就能看到,是因为Namespace的问题。具体是我们动态库加载的过程中,如果需要依赖其他的动态库,那么就需要一个链接的过程对吧!这里的实现就是Linker,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libxxx.so 文件(当前的so)能找到,而依赖的so 找不到的情况。bugly文章

很多实现都采用了Tinker的实现,既然我们系统的classloader是这样,那么我们在合适的时候把这个替换掉不就可以了嘛!当然bugly团队就是这样做的,但是笔者认为,替换一个classloader显然对于一个普通应用来说,成本还是太大了,而且兼容性风险也挺高的,当然,还有很多方式,比如采用Relinker这个库自定义我们加载的逻辑。

为了不冷饭热炒,嘿嘿,虽然我也喜欢吃炒饭(手动狗头),这里我们就不采用替换classloader的方式,而是采用跟relinker的思想,去进行加载!具体的可以看到sillyboy的实现,其实就不依赖relinker跟tinker,因为我把关键的拷贝过来了,哈哈哈,好啦,我们看下怎么实现吧!不过在此这前,我们需要了解一些前置知识

ELF文件

我们的so库,本质就是一个elf文件,那么so库也符合elf文件的格式,ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。


那么我们so中,如果依赖于其他的so,那么这个信息存在哪里呢!?没错,它其实也存在elf文件中,不然链接器怎么找嘛,它其实就存在.dynamic段中,所以我们只要找打dynamic段的偏移,就能到dynamic中,而被依赖的so的信息,其实就存在里面啦 我们可以用readelf(ndk中就有toolchains目录后) 查看,readelf -d nativecpptwo.so 这里的 -d 就是查看dynamic段的意思


这里面涉及到动态加载so的知识,可以推荐大家一本书,叫做程序员的自我修养-链接装载与库这里就画个初略图


我们再看下本质,dynamic结构体如下,定义在elf.h中

typedef struct{
Elf32_Sword d_tag;
union{
Elf32_Addr d_ptr;
....
}
}

当d_tag的数值为DT_NEEDED的时候,就代表着依赖的共享对象文件,d_ptr表示所依赖的共享对象的文件名。看到这里读者们已经知道了,如果我们知道了文件名,不就可以再用System.loadLibrary去加载这个文件名确定的so了嘛!不用替换classloader就能够保证被依赖的库先加载!我们可以再总结一下这个方案的原理,如图


比如我们要加载so3,我们就需要先加载so2,如果so2存在依赖,那我们就调用System.loadLibrary先加载so1,这个时候so1就不存在依赖项了,就不需要再调用Linker去查找其他so库了。我们最终方案就是,只要能够解析对应的elf文件,然后找偏移,找到需要的目标项(DT_NEED)所对应的数值(即被依赖的so文件名)就可以了

public List<String> parseNeededDependencies() throws IOException {
  channel.position(0);
  final List<String> dependencies = new ArrayList<String>();
  final Header header = parseHeader();
  final ByteBuffer buffer = ByteBuffer.allocate(8);
  buffer.order(header.bigEndian ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN);

  long numProgramHeaderEntries = header.phnum;
  if (numProgramHeaderEntries == 0xFFFF) {
      /**
        * Extended Numbering
        *
        * If the real number of program header table entries is larger than
        * or equal to PN_XNUM(0xffff), it is set to sh_info field of the
        * section header at index 0, and PN_XNUM is set to e_phnum
        * field. Otherwise, the section header at index 0 is zero
        * initialized, if it exists.
        **/
      final SectionHeader sectionHeader = header.getSectionHeader(0);
      numProgramHeaderEntries = sectionHeader.info;
  }

  long dynamicSectionOff = 0;
  for (long i = 0; i < numProgramHeaderEntries; ++i) {
      final ProgramHeader programHeader = header.getProgramHeader(i);
      if (programHeader.type == ProgramHeader.PT_DYNAMIC) {
          dynamicSectionOff = programHeader.offset;
          break;
      }
  }

  if (dynamicSectionOff == 0) {
      // No dynamic linking info, nothing to load
      return Collections.unmodifiableList(dependencies);
  }

  int i = 0;
  final List<Long> neededOffsets = new ArrayList<Long>();
  long vStringTableOff = 0;
  DynamicStructure dynStructure;
  do {
      dynStructure = header.getDynamicStructure(dynamicSectionOff, i);
      if (dynStructure.tag == DynamicStructure.DT_NEEDED) {
          neededOffsets.add(dynStructure.val);
      } else if (dynStructure.tag == DynamicStructure.DT_STRTAB) {
          vStringTableOff = dynStructure.val; // d_ptr union
      }
      ++i;
  } while (dynStructure.tag != DynamicStructure.DT_NULL);

  if (vStringTableOff == 0) {
      throw new IllegalStateException("String table offset not found!");
  }

  // Map to file offset
  final long stringTableOff = offsetFromVma(header, numProgramHeaderEntries, vStringTableOff);
  for (final Long strOff : neededOffsets) {
      dependencies.add(readString(buffer, stringTableOff + strOff));
  }

  return dependencies;
}

扩展

我们到这里,就能够解决so库的动态加载的相关问题了,那么还有人可能会问,项目中是会存在多处System.load方式的,如果加载的so还不存在怎么办?比如还在下载当中,其实很简单,这个时候我们字节码插桩就派上用场了,只要我们把System.load替换为我们自定义的加载so逻辑,进行一定的逻辑处理就可以了,嘿嘿,因为笔者之前就有写一个字节码插桩的库的介绍,所以在本次就不重复了,可以看Sipder,同时也可以用其他的字节码插桩框架实现,相信这不是一个问题。

总结

看到这里的读者,相信也能够明白动态加载so的步骤了,最后源代码可以在SillyBoy,当然也希望各位点赞呀!当然,有更好的实现也欢迎评论!!

作者:Pika
来源:juejin.cn/post/7107958280097366030

收起阅读 »

基于 Android 系统方案适配 Night Mode 后,老板要再加一套皮肤?

背景说明原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。其实我们之...
继续阅读 »

背景说明

原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。

其实我们之前两个 App 的换肤方案都是使用 Android-skin-support 来做的,在此基础上再加套皮肤也不是难事。但在新的 App 实现多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需要实现跟随系统,为了更好的体验和较少的代码实现,就采用了系统方案进行适配暗黑模式。

Android-skin-support 和系统两种方案适配经验来看,系统方案适配改动的代码更少,所花费的时间当然也就更少了。所以在需要新添一套皮肤的时候,也不可能再去切方案了。那么在使用系统方案的情况下,如何再加一套皮肤呢?来,先看源码吧。

源码分析

以下源码基于 android-31

首先,在代码中获取资源一般通过 Context 对象的一些方法,例如:

// Context.java

@ColorInt
public final int getColor(@ColorRes int id) {
   return getResources().getColor(id, getTheme());
}

@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
   return getResources().getDrawable(id, getTheme());
}

可以看到 Context 是通过 Resources 对象再去获取的,继续看 Resources

// Resources.java

@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValue(id, value, true);
       if (value.type >= TypedValue.TYPE_FIRST_INT
           && value.type <= TypedValue.TYPE_LAST_INT) {
           return value.data;
      } else if (value.type != TypedValue.TYPE_STRING) {
           throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id)                                 + " type #0x" + Integer.toHexString(value.type) + " is not valid");
      }
       // 这里调用 ResourcesImpl#loadColorStateList 方法获取颜色
       final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
       return csl.getDefaultColor();
  } finally {
    releaseTempTypedValue(value);
  }
}

public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
       throws NotFoundException {
   return getDrawableForDensity(id, 0, theme);
}

@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
   final TypedValue value = obtainTempTypedValue();
   try {
       final ResourcesImpl impl = mResourcesImpl;
       impl.getValueForDensity(id, density, value, true);
    // 看到这里
       return loadDrawable(value, id, density, theme);
  } finally {
    releaseTempTypedValue(value);
  }
}

@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
       throws NotFoundException {
   // 这里调用 ResourcesImpl#loadDrawable 方法获取 drawable 资源
   return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}

到这里我们知道在代码中获取资源时,是通过 Context -> Resources -> ResourcesImpl 调用链实现的。

先看 ResourcesImpl.java

/**
* The implementation of Resource access. This class contains the AssetManager and all caches
* associated with it.
*
* {@link Resources} is just a thing wrapper around this class. When a configuration change
* occurs, clients can retain the same {@link Resources} reference because the underlying
* {@link ResourcesImpl} object will be updated or re-created.
*
* @hide
*/
public class ResourcesImpl {
  ...
}

虽然是 public 的类,但是被 @hide 标记了,意味着想通过继承后重写相关方法这条路行不通了,pass。

再看 Resources.java,同样是 public 类,但没被 @hide 标记。我们就可以通过继承 Resources 类,然后重写 Resources#getColorResources#getDrawableForDensity 等方法来改造获取资源的逻辑。

先看相关代码:

// SkinResources.kt

class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {

   val contextRef: WeakReference<Context> = WeakReference(context)

   override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
       return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
  }

   override fun getColor(id: Int, theme: Theme?): Int {
       return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
  }
 
   private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
       // 非暗黑蓝无需替换资源 ID
       if (context == null || !UIUtil.isNightBlue(context)) return resId

       var newResId = resId
       val res = context.resources
       try {
           val resPkg = res.getResourcePackageName(resId)
           // 非本包资源无需替换
           if (context.packageName != resPkg) return newResId

           val resName = res.getResourceEntryName(resId)
           val resType = res.getResourceTypeName(resId)
        // 获取对应暗蓝皮肤的资源 id
           val id = res.getIdentifier("${resName}_blue", resType, resPkg)
           if (id != 0) newResId = id
      } finally {
           return newResId
      }
  }

}

主要原理与逻辑:

  • 所有资源都会在 R.java 文件中生成对应的资源 id,而我们正是通过资源 id 来获取对应资源的。

  • Resources 类提供了 getResourcePackageName/getResourceEntryName/getResourceTypeName 方法,可通过资源 id 获取对应的资源包名/资源名称/资源类型。

  • 过滤掉无需替换资源的场景。

  • Resources 还提供了 getIdentifier 方法来获取对应资源 id。

  • 需要适配暗蓝皮肤的资源,统一在原资源名称的基础上加上 _blue 后缀。

  • 通过 Resources#getIdentifier 方法获取对应暗蓝皮肤的资源 id。如果没找到,改方法会返回 0

现在就可以通过 SkinResources 来获取适配多皮肤的资源了。但是,之前的代码都是通过 Context 直接获取的,如果全部替换成 SkinResources 来获取,那代码改动量就大了。

我们回到前面 Context.java 的源码,可以发现它获取资源时,都是通过 Context#getResources 方法先得到 Resources 对象,再通过其去获取资源的。而 Context#getResources 方法也是可以重写的,这意味着我们可以维护一个自己的 Resources 对象。ApplicationActivity 也都是继承自 Context 的,所以我们在其子类中重写 getResources 方法即可:

// BaseActivity.java/BaseApplication.java

private Resources mSkinResources;

@Override
public Resources getResources() {
   if (mSkinResources == null) {
    mSkinResources = new SkinResources(this, super.getResources());
  }
   return mSkinResources;
}

到此,基本逻辑就写完了,马上 build 跑起来。

咦,好像有点不太对劲,有些 colordrawable 没有适配成功。

经过一番对比,发现 xml 布局中的资源都没有替换成功。

那么问题在哪呢?还是先从源码着手,先来看看 View 是如何从 xml 中获取并设置 background 属性的:

// View.java

public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);

   // AttributeSet 是 xml 中所有属性的集合
   // TypeArray 则是经过处理过的集合,将原始的 xml 属性值("@color/colorBg")转换为所需的类型,并应用主题和样式
   final TypedArray a = context.obtainStyledAttributes(
           attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);

  ...
   
   Drawable background = null;

  ...
   
   final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
    int attr = a.getIndex(i);
    switch (attr) {
           case com.android.internal.R.styleable.View_background:
               // TypedArray 提供一些直接获取资源的方法
            background = a.getDrawable(attr);
            break;
          ...
      }
  }
 
  ...
   
   if (background != null) {
    setBackground(background);
  }
 
  ...
}

再接着看 TypedArray 是如何获取资源的:

// TypedArray.java

@Nullable
public Drawable getDrawable(@StyleableRes int index) {
   return getDrawableForDensity(index, 0);
}

@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
   if (mRecycled) {
    throw new RuntimeException("Cannot make calls to a recycled instance!");
  }

   final TypedValue value = mValue;
   if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
       if (value.type == TypedValue.TYPE_ATTRIBUTE) {
           throw new UnsupportedOperationException(
               "Failed to resolve attribute at index " + index + ": " + value);
      }

       if (density > 0) {
           // If the density is overridden, the value in the TypedArray will not reflect this.
           // Do a separate lookup of the resourceId with the density override.
           mResources.getValueForDensity(value.resourceId, density, value, true);
      }
    // 看到这里
       return mResources.loadDrawable(value, value.resourceId, density, mTheme);
  }
   return null;
}

TypedArray 是通过 Resources#loadDrawable 方法来加载资源的,而我们之前写 SkinResources 的时候并没有重写该方法,为什么呢?那是因为该方法是被 @UnsupportedAppUsage 标记的。所以,这就是 xml 布局中的资源替换不成功的原因。

这个问题又怎么解决呢?

之前采用 Android-skin-support 方案做换肤时,了解到它的原理,其会替换成自己的实现的 LayoutInflater.Factory2,并在创建 View 时替换生成对应适配了换肤功能的 View 对象。例如:将 View 替换成 SkinView,而 SkinView 初始化时再重新处理 background 属性,即可完成换肤。

AppCompat 也是同样的逻辑,通过 AppCompatViewInflater 将普通的 View 替换成带 AppCompat- 前缀的 View。

其实我们只需能操作生成后的 View,并且知道 xml 中写了哪些属性值即可。那么我们完全照搬 AppCompat 这套逻辑即可:

  • 定义类继承 LayoutInflater.Factory2,并实现 onCreateView 方法。

  • onCreateView 主要是创建 View 的逻辑,而这部分逻辑完全 copy AppCompatViewInflater 类即可。

  • onCreateView 中创建 View 之后,返回 View 之前,实现我们自己的逻辑。

  • 通过 LayoutInflaterCompat#setFactory2 方法,设置我们自己的 Factory2。

相关代码片段:

public class SkinViewInflater implements LayoutInflater.Factory2 {
   @Nullable
   @Override
   public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
    // createView 方法就是 AppCompatViewInflater 中的逻辑
       View view = createView(parent, name, context, attrs, false, false, true, false);
       onViewCreated(context, view, attrs);
       return view;
  }

   @Nullable
   @Override
   public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
       return onCreateView(null, name, context, attrs);
  }

   private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
    if (view == null) return;
       resetViewAttrsIfNeed(context, view, attrs);
  }
 
   private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
    if (!UIUtil.isNightBlue(context)) return;
     
    String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
    String BACKGROUND = "background";
     
    // 获取 background 属性值的资源 id,未找到时返回 0
    int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
    if (backgroundId != 0) {
           view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
      }
  }
}
// BaseActivity.java

@Override
public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);
   SkinViewInflater inflater = new SkinViewInflater();
   LayoutInflater layoutInflater = LayoutInflater.from(this);
   // 生成 View 的逻辑替换成我们自己的
   LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}

至此,这套方案已经可以解决目前的换肤需求了,剩下的就是进行细节适配了。

其他说明

自定义控件与第三方控件适配

上面只对 background 属性进行了处理,其他需要进行换肤的属性也是同样的处理逻辑。如果是自定义的控件,可以在初始化时调用 TypedArray#getResourceId 方法先获取资源 id,再通过 context 去获取对应资源,而不是使用 TypedArray#getDrawable 类似方法直接获取资源对象,这样可以确保换肤成功。而第三方控件也可通过 background 属性同样的处理逻辑进行适配。

XML <shape> 的处理

<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background" />
</shape>

上面的 bg.xml 文件内的 color 并不会完成资源替换,根据上面的逻辑,需要新增以下内容:

<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background_blue" />
</shape>

如此,资源替换才会成功。

设计的配合

这次对第三款皮肤的适配还是蛮轻松的,主要是有以下基础:

  • 在适配暗黑主题的时候,设计有出设计规范,后续开发按照设计规范来。

  • 暗黑和暗蓝共用一套图片资源,大大减少适配工作量。

  • 暗黑和暗蓝部份共用颜色值含透明度,同样减少了工作量,仅少量颜色需要新增。

这次适配的主要工作量还是来自 <shape> 的替换。

暗蓝皮肤资源文件的归处

我知道很多换肤方案都会将皮肤资源制作成皮肤包,但是这个方案没有这么做。一是没有那么多需要替换的资源,二是为了减少相应的工作量。

我新建了一个资源文件夹,与 res 同级,取名 res-blue。并在 gradle 配置文件中配置它。编译后系统会自动将它们合并,同时也能与常规资源文件隔离开来。

// build.gradle
sourceSets {
main {
java {
srcDir 'src/main/java'
}
res.srcDirs += 'src/main/res'
res.srcDirs += 'src/main/res-blue'
}
}

有哪些坑?

WebView 资源缺失导致闪退

版本上线后,发现有 android.content.res.Resources$NotFoundException 异常上报,具体异常堆栈信息:

android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)

经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该异常导致 App 闪退。

这是其他插件化方案也踩过的坑,我们只需在创建 SkinResources 之前将外部 WebView 的资源路径添加进来即可。

@Override
public Resources getResources() {
if (mSkinResources == null) {
WebViewResourceHelper.addChromeResourceIfNeeded(this);
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}

RePlugin/WebViewResourceHelper.java 源码文件

具体问题分析可参考

Fix ResourceNotFoundException in Android 7.0 (or above)

最终效果图

skin_demo.gif

总结

这个方案在原本使用系统方式适配暗黑主题的基础上,通过拦截 Resources 相关获取资源的方法,替换换肤后的资源 id,以达到换肤的效果。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater 创建 View 的代码逻辑,并在 View 创建成功后重新设置需要进行换肤的相关 XML 属性。同一皮肤资源使用单独的资源文件夹独立存放,可以与正常资源进行隔离,也避免了制作皮肤包而增加工作量。

目前来说这套方案是改造成本最小,侵入性最小的选择。选择适合自身需求的才是最好的。

作者:ONEW
来源:https://juejin.cn/post/7187282270360141879

收起阅读 »

自定义View模仿即刻点赞数字切换效果

即刻点赞展示点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。自己如何实现这种数字切换呢?下面用一张图来展示我的思路:现在只需要根据这张图,写出对应的动画即可。 分为2种场景:数字+1:差异化的数字从3号区域由渐变动画(透明度 0...
继续阅读 »

即刻点赞展示


点赞的数字增加和减少并不是整个替换,而是差异化替换。再加上动画效果就看的很舒服。

自己如何实现这种数字切换呢?

下面用一张图来展示我的思路:


现在只需要根据这张图,写出对应的动画即可。 分为2种场景:

  • 数字+1:

    • 差异化的数字从3号区域由渐变动画(透明度 0- 255) + 偏移动画 (3号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,1号区域绘制文字的基线),将数字移动到1号位置处

  • 数字-1

    • 差异化的数字从1号区域由渐变动画(透明度 0- 255) + 偏移动画 (1号区域绘制文字的基线,2号区域绘制文字的基线),将数字移动到2号位置处

    • 差异化的数字从2号区域由渐变动画(透明度 255- 0) + 偏移动画(2号区域绘制文字的基线,3号区域绘制文字的基线),将数字移动到3号位置处

公共部分就是: 不变的文字不需要做任何处理,绘制在2号区域就行。绘制差异化文字时,需要加上不变的文字的宽度就行。

效果展示


源码

class LikeView @JvmOverloads constructor(
  context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

  private val paint = Paint().also {
      it.isAntiAlias = true
      it.textSize = 200f
  }

  private val textRect0 = Rect(300, 100, 800, 300)
  private val textRect1 = Rect(300, 300, 800, 500)
  private val textRect2 = Rect(300, 500, 800, 700)

  private var nextNumberAlpha: Int = 0
      set(value) {
          field = value
          invalidate()
      }

  private var currentNumberAlpha: Int = 255
      set(value) {
          field = value
          invalidate()
      }

  private var offsetPercent = 0f
      set(value) {
          field = value
          invalidate()
      }

  private val fontMetrics: FontMetrics = paint.fontMetrics
  private var currentNumber = 99
  private var nextNumber = 0
  private var motionLess = currentNumber.toString()
  private var currentMotion = ""
  private var nextMotion = ""

  private val animator: ObjectAnimator by lazy {
      val nextNumberAlphaAnimator = PropertyValuesHolder.ofInt("nextNumberAlpha", 0, 255)
      val offsetPercentAnimator = PropertyValuesHolder.ofFloat("offsetPercent", 0f, 1f)
      val currentNumberAlphaAnimator = PropertyValuesHolder.ofInt("currentNumberAlpha", 255, 0)
      val animator = ObjectAnimator.ofPropertyValuesHolder(
          this,
          nextNumberAlphaAnimator,
          offsetPercentAnimator,
          currentNumberAlphaAnimator
      )
      animator.duration = 200
      animator.interpolator = DecelerateInterpolator()
      animator.addListener(
          onEnd = {
              currentNumber = nextNumber
          }
      )
      animator
  }

  override fun onDraw(canvas: Canvas) {
      paint.alpha = 255

      paint.color = Color.LTGRAY
      canvas.drawRect(textRect0, paint)

      paint.color = Color.RED
      canvas.drawRect(textRect1, paint)

      paint.color = Color.GREEN
      canvas.drawRect(textRect2, paint)

      paint.color = Color.BLACK
      if (motionLess.isNotEmpty()) {
          drawText(canvas, motionLess, textRect1, 0f)
      }

      if (nextMotion.isEmpty() || currentMotion.isEmpty()) {
          return
      }

      val textHorizontalOffset =
          if (motionLess.isNotEmpty()) paint.measureText(motionLess) else 0f
      if (nextNumber > currentNumber) {
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, -offsetPercent)
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect2, textHorizontalOffset, -offsetPercent)
      } else {
          paint.alpha = nextNumberAlpha
          drawText(canvas, nextMotion, textRect0, textHorizontalOffset, offsetPercent)
          paint.alpha = currentNumberAlpha
          drawText(canvas, currentMotion, textRect1, textHorizontalOffset, offsetPercent)
      }
  }

  private fun drawText(
      canvas: Canvas,
      text: String,
      rect: Rect,
      textHorizontalOffset: Float = 0f,
      offsetPercent: Float = 0f
  ) {
      canvas.drawText(
          text,
          rect.left.toFloat() + textHorizontalOffset,
          rect.top + (rect.bottom - rect.top) / 2f - (fontMetrics.bottom + fontMetrics.top) / 2f + offsetPercent * 200,
          paint
      )
  }


  override fun onDetachedFromWindow() {
      super.onDetachedFromWindow()
      animator.end()
  }

  fun plus() {
      if (currentNumber == Int.MAX_VALUE) {
          return
      }
      nextNumber = currentNumber + 1

      processText(findEqualsStringIndex())

      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  fun minus() {
      if (currentNumber == 0) {
          return
      }
      nextNumber = currentNumber - 1
      processText(findEqualsStringIndex())
      if (animator.isRunning) {
          return
      }
      animator.start()
  }

  private fun findEqualsStringIndex(): Int {
      var equalIndex = -1
      val nextNumberStr = nextNumber.toString()
      val currentNumberStr = currentNumber.toString()

      val endIndex = min(currentNumberStr.length, nextNumberStr.length) - 1

      for (index in 0..endIndex) {
          if (nextNumberStr[index] != currentNumberStr[index]) {
              break
          }
          equalIndex = index
      }
      return equalIndex
  }

  private fun processText(index: Int) {
      val currentNumberStr = currentNumber.toString()
      val nextNumberStr = nextNumber.toString()
      if (index == -1) {
          motionLess = ""
          currentMotion = currentNumberStr
          nextMotion = nextNumberStr
      } else {
          motionLess = currentNumberStr.substring(0, index + 1)
          currentMotion = currentNumberStr.substring(index + 1)
          nextMotion = nextNumberStr.substring(index + 1)
      }
  }
}

作者:timer
来源:juejin.cn/post/7179181214530551867

收起阅读 »

kotlin快速实现一款小游戏,糖果雨来啦

前言回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。效果展示开始引导页面糖果收集页面收集结束页面实现细节具体...
继续阅读 »

前言

回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。

效果展示

开始引导页面糖果收集页面收集结束页面



实现细节

具体实现其实也很简单,主要分为3块内容:

  1. 开始引导页面:提供开始按钮来告诉用户如何开始,3秒倒计时动画,让用户做好准备。

  2. 糖果收集页面:自动生成糖果并从上往下掉落,用户点击糖果完成收集(糖果消失 & 糖果收集总数加一)。

  3. 收集结束页面:告诉用户一共收集了多少糖果,提供再玩一次按钮入口。

引导动画

如果单单是一个静态页面,提供文字来提醒用户如何开始游戏,会略显单调,所以我加了一些自定义View动画,模拟点击动作,来达到提醒用户作用。

利用三个动画组合在一起同时执行,从达到该效果,分别是:

  1. 手指移动去点击动画。

  2. 点击后的水波纹动画。

  3. 点击后糖果+1动画。

这里我们以 点击后糖果+1动画 举例。

我们先建一个res/anim/candy_add_anim.xml文件,如下:

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

   <alpha
       android:duration="3000"
       android:fromAlpha="0.0"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toAlpha="1.0" />

   <translate
       android:duration="3000"
       android:fromYDelta="0%"
       android:interpolator="@android:anim/accelerate_interpolator"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toYDelta="-10%p" />

   <scale
       android:duration="3000"
       android:fromXScale="0"
       android:fromYScale="0"
       android:pivotX="50%"
       android:pivotY="50%"
       android:repeatCount="-1"
       android:repeatMode="restart"
       android:toXScale="1"
       android:toYScale="1" />

</set>

然后在指定的View中执行该动画,如下:

binding.candyAddOneTv.apply {
   val animation = AnimationUtils.loadAnimation(context, R.anim.candy_add_anim)
   startAnimation(animation)
}

糖果的生成

从效果展示图中也可以看出,糖果的样式是各式各样的且其位置坐标是随机的。

我通过代码动态生成一个大小固定的TextView,然后通过设置layoutParams.setMargins来确定其坐标,通过setBackground(drawable)来设置糖果背景(为了使生成的糖果是各式各样的,所以我找了一些糖果的SVG图来作为背景),然后加入到View.root

具体代码如下:

//随机生成X坐标
val leftMargin = (0..(getScreenWidth() - 140)).random()
TextView(this).apply {
   layoutParams = FrameLayout.LayoutParams(140, 140).apply {
       setMargins(leftMargin, -140, 0, 0)
  }
   background = ContextCompat.getDrawable(this@MainActivity, generateRandomCandy())
   binding.root.addView(this)
}

并且通过协程delay(250),来达到一秒钟生成4颗糖果。

fun generatePointViewOnTime() {
   viewModelScope.launch {
       for (i in 1..60) {
           Log.e(TAG, "generatePointViewOnTime: i = $i")
           pointViewLiveData.value = i
           if (i % 4 == 0) {
               countDownTimeLiveData.postValue(i / 4)
          }
           delay(250)
      }
  }

}

糖果的掉落

介绍完了糖果的生成,接着就是糖果的掉落效果实现。

这里我们同样使用View动画即可完成,通过translationY(getScreenHeight().toFloat() + 200)来让糖果从最上方平移出屏幕最下方,同时为其设置加速插值器,达到掉落速度越来越快的效果。

整个平移时间设置为3s,具体代码如下:

private fun startMoving(view: View) {
   view.apply {
       animate().apply {
           interpolator = AccelerateInterpolator()
           duration = 3000
           translationY(getScreenHeight().toFloat() + 200)
           start()
      }
  }
}

糖果的收集

点击糖果,糖果消失,糖果收集总数+1。所以我们只需为其设置点击监听器,在用户点击时,为TextView设置visibility以及catchNumber++即可。

TextView(this).apply {
   ···略···

   setOnClickListener {
       this.visibility = View.GONE
       Log.e(TAG, "onCreate: tag = ${it.tag}, id = ${it.id}")
       catchNumber++
       binding.catchNumberTv.text = getString(R.string.catch_number, catchNumber)
       doVibratorEffect()
  }
}

点击反馈

为了更好的用户体验,为点击设置震动反馈效果。

private fun doVibratorEffect() {
   val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
       val vibratorManager =
           getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
       vibratorManager.defaultVibrator
  } else {
       getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
  }

   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
       vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
  } else {
       vibrator.vibrate(30)
  }
}

结束弹窗

当糖果收集结束后,弹出一个结束弹窗来告诉用户糖果收集情况,这里采用属性动画,让弹窗弹出的效果更加的生动。

private fun showAnimation(view: View) {
   view.scaleX = 0F
   view.scaleY = 0F

   //zoom in 放大;zoom out 缩小;normal 恢复正常
   val zoomInHolderX = PropertyValuesHolder.ofFloat("scaleX", 1.05F)
   val zoomInHolderY = PropertyValuesHolder.ofFloat("scaleY", 1.05F)
   val zoomOutHolderX = PropertyValuesHolder.ofFloat("scaleX", 0.8F)
   val zoomOutHolderY = PropertyValuesHolder.ofFloat("scaleY", 0.8F)
   val normalHolderX = PropertyValuesHolder.ofFloat("scaleX", 1F)
   val normalHolderY = PropertyValuesHolder.ofFloat("scaleY", 1F)
   val zoomIn = ObjectAnimator.ofPropertyValuesHolder(
       view,
       zoomInHolderX,
       zoomInHolderY
  )

   val zoomOut = ObjectAnimator.ofPropertyValuesHolder(
       view,
       zoomOutHolderX,
       zoomOutHolderY
  )
   zoomOut.duration = 400

   val normal = ObjectAnimator.ofPropertyValuesHolder(
       view,
       normalHolderX,
       normalHolderY
  )
   normal.duration = 500

   val animatorSet = AnimatorSet()
   animatorSet.playSequentially(zoomIn, zoomOut, normal)
   animatorSet.start()
}

总结

如果你对该小游戏有兴趣,想进一步了解一下代码,可以参考Github Candy-Catch,欢迎你给我点个小星星。

相信很多人都有这样的感受,随着年龄的增加,越来越觉得这年味越来越淡了,随之而来对过年的期盼度也是逐年下降。在这里,我愿大家童心未泯,归来仍是少年!

最后,给大家拜个早年,祝大家新春快乐

其实分享文章的最大目的正是等待着有人指出我的错误,如果你发现哪里有错误,请毫无保留的指出即可,虚心请教。

另外,如果你觉得文章不错,对你有所帮助,请给我点个赞,就当鼓励,谢谢~Peace~!

作者:Jere_Chen
来源:juejin.cn/post/7054194708410531876

收起阅读 »

运行环信Android Demo常见问题以及语音消息播放声音小的解决方法

运行Demo为什么会下载不下来aar 导致demo的项目无法正常运行打开到 buildgradle , 将MavenCental()至前,在maven库 阿里云和华为里 添加allowInsecureProtocol = true 添加后编译一下 可以在远...
继续阅读 »
运行Demo为什么会下载不下来aar 导致demo的项目无法正常运行
打开到 buildgradle , 将MavenCental()至前,在maven库 阿里云和华为里 添加
allowInsecureProtocol = true
添加后编译一下 可以在远程包里查看下是否下载成功 会 一般是4个aar  
1.easeimkit aar 2.easecallkit 3.easechat 4.rtc 3.6.2aar
1.环信3.9.3 sdk登录慢的问题
初始化打开 options.setFpaEnable(true)(全球加速)
2.播放语音时 语音声音小

1.首先要打开扬声器 如果觉得声音还是比较小
2.将ui库中调用的原声音量模式修改为媒体音量模式

3.发送语音、视频、文件体积超过10MB
相机是直接调用的系统的,跟随的是系统的大小,我拍摄15s视频大概18m左右。环信系统默认的是只能发送10M的视频文件,可以联系商务经理开通上调发送体积
4.关于百度地图切换至高德地图
demo中百度地图的so库是放在项目层的

1.因为百度地图将easeimkit中关于百度地图的集成去掉,改成高德地图;2.在chatfragment中重写位置的点击事件方法startMapLocation或者是直接在EaseChatFragment中直接修改点击事件startMapLocation跳转到高德地图;3.在调用环信api去发送地理位置消息时,传入高德获取到的经纬度
2.点击位置的点击事件更换 ,demo中的点击事件是在EaseChatFragment下的onExtendMenuItemClick里面官方提供了EaseBaiduMapActivity 这个定位页面。2.修改为高德其实非常简单只需要在ChatFragment操作就可以了2.1修改点击事件在ChatFragment的onExtendMenuItemClick方法中添加2.2 在自己实现高德地图的页面返回定位信息 参数名称不要修改 不然其它地方也要修改2.3接下来在ChatFragment中的onActivityResult中接收定位信息并发送消息走到这里从高德获取的位置消息已经成功发送给好友了 接下来是获取查看好友位置消息2.4 查看位置消息还是在ChatFragment里 通过getCustomChatRow方法LoccationAdapter 继承位置消息展示 重写了点击事件即可。
5.语音消息amr格式转为MP3格式
需要本地库倒入easeimkit

收起阅读 »

货拉拉SSL证书踩坑之旅

一、背景简介1、遇到的问题2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访...
继续阅读 »

img

一、背景简介

1、遇到的问题

2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访问异常的问题

2、本文内容

本文主要介绍解决货拉拉Android客户端SSL证书到期的解决方案及Android端SSL证书相关知识

二、SSL证书简介

1、SSL证书诞生背景

1994年,Netscape公司首先使用了SSL协议,SSL协议全称为:安全套接层协议(Secure Sockets Layer),它指定了在应用程序协议(如HTTP、Telnet、FTP)和TCP/IP之间提供数据安全性分层的机制,它是在传输通信协议(TCP/IP)上实现的一种安全协议,采用公开密钥技术,它为TCP/IP连接提供数据加密、服务器认证、消息完整性以及可选的客户端认证。由于SSL协议很好地解决了互联网明文传输的不安全问题,很快得到了业界的支持,并已经成为国际标准

HyperText Transfer Protocol over Secure Socket Layer。在HTTPS中,使用传输层安全性(TLS)或安全套接字层(SSL)对通信协议进行加密。也就是HTTP+SSL(TLS)=HTTPS

img

2、SSL证书简介

按类型划分,SSL证书包括CA证书、用户证书两种

(1)CA证书(Certification Authority证书颁发机构)

证书的签发机构(CA)颁发的电子证书,包含根证书和中间证书两种

[i]根证书

属于根证书颁发机构(CA)的公钥证书,是在公开密钥基础建设中,信任链的起点

一般客户端会内置

[ii]中间证书

因为根证书太宝贵了,直接颁发风险太大了。因此,为了保护根证书,CAs通常会颁发所谓的中间证书。CA使用它的私钥对中间证书签名,使它受到信任。然后CA使用中间证书的私钥签署和颁发终端用户SSL证书。这个过程可以执行多次,其中一个中间根对另一个中间根进行签名

(2)用户证书

用户证书是由CA中间证书签发给用户的证书,包含服务器证书、客户端证书

[i]服务器证书

组成Web服务器的SSL安全功能的唯一的数字标识。 通过CA签发,并为用户提供验证您Web站点身份的手段。

服务器证书包含详细的身份验证信息,如服务器内容附属的组织、颁发证书的组织以及称为公开密钥的唯一的身份验证文件

[ii]客户端证书

在双向https验证中,就必须有客户端证书,生成方式同服务器证书一样;

单向证书则不用生成

3、SSL证书链

SSL证书链是从用户证书、生成用户证书的CA中间证书、生成CA中间证书的CA中间证书...一直到CA根证书;其中根证书只能有一个,但是CA中间证书可以有多个

(1)以baidu的证书为例

img

(2)证书链

客户端(比如浏览器或者Android手机)验证我们SSL证书的有效性的时候,会一层层的去寻找颁发者的证书,直到自签名的根证书,然后通过相应的公钥再反过来验证下一级的数字签名的正确性

任何数字证书都必须要有根证书做支持,有了根证书的支持才说明这个数字证书是有效的是被信任的

img

4、SSL证书文件的后缀

证书的后缀主要有.key、.csr、.crt、.pem等

(1).key文件:密钥文件,SSL证书的私钥就包含在其中

(2).csr文件:这个文件里面包含着证书的公钥和其他一些公司信息,通过请求签名之后就可以直接生出证书

(3).crt文件:该文件中也包含了证书的公钥、签名信息以及根据不同类型证书携带不同的认证信息,如IP等(该文件在有些机构、系统中也可能表现为.cert后缀)

(4).pem文件:该文件相对比较少见,里面包含着证书的私钥以及部分证书信息

5、SSL用户证书类型

SSL用户证书主要分为(1)DV SSL证书 (2)OV SSL证书 (3)EV SSL证书

(1)DV SSL证书(域名验证型):只需验证域名所有权,无需人工验证申请单位真实身份,几分钟就可颁发的SSL证书。价格一般在百元至千元左右,适用于个人或者小型网站

(2)OV SSL证书(企业验证型):需要验证域名所有权以及企业身份信息,证明申请单位是一个合法存在的真实实体,一般在1~5个工作日颁发。价格一般在百元至几千元左右,适用于企业型用户申请

(3)EV SSL证书(扩展验证型):除了需要验证域名所有权以及企业身份信息之外,还需要提交一下扩展型验证,通常CA机构还会进行电话回访,一般在2~7个工作日颁发证书。价格一般在千元至万元左右,适用于在线交易网站、企业型网站

6、SSL证书结构

img

7、SSL证书查看

以Chorme上的baidu为例:

第1步

img

第2步

img

第3步

img

三、客户端SSL证书校验流程

1、客户端SSL证书校验主要是在网络连接的SSL/TLS握手环节校验

SSL/TLS握手(用非对称加密的手段传递密钥,然后用密钥进行对称加密传递数据)

img

校验流程主要在上述过程的第三步和第六步

第三步:Certificate

Server——>Client 服务端下发公钥证书

第六步:证书合法性校验

Client 对 Server下发的公钥证书进行合法性校验

2、客户端证书校验过程

img

(1)校验证书是否是受信任的CA根证书颁发机构颁发

客户端通过服务器证书 中签发机构信息,获取到中间证书公钥;利用中间证书公钥进行服务器证书的签名验证

a、中间证书公钥解密 服务器签名,得到证书摘要信息;

b、摘要算法计算 服务器证书 摘要信息;

c、然后对比两个摘要信息。

客户端通过中间证书中签发机构信息,客户端本地查找到根证书公钥;利用根证书公钥进行中间证书的签名验证

(2)客户端校验服务端证书公钥及摘要信息

客户端获取到服务端的公钥:Https请求 TLS握手过程中,服务器公钥会下发到请求的客户端。

客户端用存储在本地的CA机构的公钥,对服务端公钥中对应的摘要信息进行解密,获取到服务端公钥的摘要信息A;

客户端根据对服务端公钥进行摘要计算,得到摘要信息B;

对比摘要信息A与B,相同则证书验证通过

(3)校验证书是否在上级证书的吊销列表

若证书的申请主体出现:私钥丢失、申请证书无效等情况,CA机构需要废弃该证书

(详细策略见《四、Android端证书吊销校验策略》)

(4)校验证书是否过期

校验证书的有效期是否已经过期:主要判断证书中Validity period字段是否过期(ps:Android系统默认不校验证书有效期,但浏览器和ios系统默认会校验证书有效期)

(5)校验证书域名是否一致

校验证书域名是否一致:核查 证书域名*是否与当前的*访问域名 匹配

比如:我们请求的域名 http://www.huolala.cn 是否与证书文件DNS标签下所列的域名匹配

img

四、Android端证书吊销校验策略

1、证书吊销校验主要存在两类机制:CRL 与 OCSP

(1)证书吊销列表校验:CRL(Certificate Revocation List)

证书吊销列表:是一个单独的文件,该文件包含了 CA机构 已经吊销的证书序列号与吊销日期;

证书中一般会包含一个 URL 地址 CRL Distribution Point,通知使用者去哪里下载对应的 CRL 以校验证书是否吊销。

该吊销方式的优点是不需要频繁更新,但是不能及时吊销证书,这期间可能已经造成了极大损失

(2)证书状态在线查询:OCSP(Online Certificate Status Protocol)

证书状态在线查询协议:一个实时查询证书是否吊销的方式。

请求者发送证书的信息并请求查询,服务器返回正常、吊销或未知中的任何一个状态。

证书中一般也会包含一个 OCSP 的 URL 地址,要求查询服务器具有良好的性能。

部分 CA 或大部分的自签 CA (根证书)都是未提供 CRL 或 OCSP 地址的,对于吊销证书会是一件非常麻烦的事情

2、Android系统默认使用CRL方式来校验证书是否被吊销

核心实现类是CertBlocklistImpl(维护了本地黑名单列表),部分源码逻辑如下:

(1)TrustManagerImpl(证书校验核心类)

第1步循环校验信任证书

img

第2步检查该证书是否在黑名单列表里面

img

(2)CertBlocklistImpl(证书黑名单列表维护类)

黑名单校验逻辑:主要检查是否在黑名单列表里面

img

黑名单本地存储位置

img

可以看到黑名单文件储存在环境变量“ANDROID_DATA”/misc/keychain/pubkey_blacklist.txt;

可以通过adb shell--export--echo $ANDROID_DATA,拿到环境变量位置,一般在/data目录下

3、Android端自定义证书吊销校验逻辑

核心类在TrustManagerFactory、CertPathTrustManagerParameters、PKIXRevocationChecker

(1)TrustManagerFactory工厂模式的证书管理类

有两种init方式

[i]init(KeyStore ks) 默认使用

传递私钥,一般传递系统默认或者传空

以okhttp为例(默认传空)

img

[ii]init(ManagerFactoryParameters spec) 自定义方式

下面介绍下通过自定义方式来实现OCSP方式校验证书是否吊销

4、基于PKIXRevocationChecker方式自定义OCSP方式

(1)自定义TrustManagerFactory.init(ManagerFactoryParameters spec)

init方法传入基于CertPath的TrustManagerCertPathTrustManagerParameters,包装策略PKIXRevocationChecker

img

(2)PKIXRevocationChecker(用于检查PKIX算法的证书撤销状态)

默认使用OCSP方式校验,可以自定义使用OCSP策略还是CLR策略

参考谷歌开发者文档:developers.google.cn/j2objc/java…

img

五、Android端证书校验方式

主要有四种校验方式:

客户端单向认证服务端---证书锁定

客户端单向认证服务端---公钥锁定

客户端服务端双向认证

客户端信任所有证书

1、客户端单向认证服务端---证书锁定

(1)校验过程

校验服务端证书的subject信息和publickey信息是否与客户端内置证书一致,如果不一致会报错:

“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”

(2)实现方式

[i]network-security-config配置方式

(生效范围:app全局,包含webview请求)

(只支持android7.0及以上)

img

[ii]代码配置方式(生效范围:配置了该SSLParams的实例)

img

(3)优点

校验了subject信息和publickey信息,防信息篡改的安全等级高一点

(4)缺点

[i]因为一般网络证书的有效期是1-2年,所以面临过期之后可能校验异常的问题(ps:本次货拉拉客户端遇到的就是这种内置的网络证书快到期的case)

[ii]内置在app里面,证书容易泄漏

2、客户端单向认证服务端---公钥锁定

(1)校验过程

校验服务端证书的公钥信息是否与客户端内置证书的一致

(2)实现方式

[i]network-security-config配置方式

(生效范围:app全局,包含webview请求)

(只支持android7.0及以上)

img

[ii]代码配置方式(生效范围:配置了该参数的实例)

img

(3)优点

只要服务端的公钥保持不变,更换证书也能通过校验

(4)缺点

只校验了公钥,防信息篡改的安全等级低一点

3、客户端和服务端双向认证

(1)实现方式

自定义的SSLSocketFactory实现客户端和服务端双向认证

public class SSLHelper {

  /** * 存储客户端自己的密钥 */ private final static String CLIENT_PRI_KEY = "client.bks";

  /** * 存储服务器的公钥 */ private final static String TRUSTSTORE_PUB_KEY = "publickey.bks";

  /** * 读取密码 */ private final static String CLIENT_BKS_PASSWORD = "123321";

  /** * 读取密码 */ private final static String PUCBLICKEY_BKS_PASSWORD = "123321";

  private final static String KEYSTORE_TYPE = "BKS";

  private final static String PROTOCOL_TYPE = "TLS";

  private final static String CERTIFICATE_STANDARD = "X509";

  public static SSLSocketFactory getSSLCertifcation(Context context) {

      SSLSocketFactory sslSocketFactory = null;

      try {
          // 服务器端需要验证的客户端证书,其实就是客户端的keystore
          KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
          // 客户端信任的服务器端证书
          KeyStore trustStore = KeyStore.getInstance(KEYSTORE_TYPE);

          //读取证书
          InputStream ksIn = context.getAssets().open(CLIENT_PRI_KEY);
          InputStream tsIn = context.getAssets().open(TRUSTSTORE_PUB_KEY);

          //加载证书
          keyStore.load(ksIn, CLIENT_BKS_PASSWORD.toCharArray());
          trustStore.load(tsIn, PUCBLICKEY_BKS_PASSWORD.toCharArray());

          //关闭流
          ksIn.close();
          tsIn.close();

          //初始化SSLContext
          SSLContext sslContext = SSLContext.getInstance(PROTOCOL_TYPE);
          TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(CERTIFICATE_STANDARD);
          KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(CERTIFICATE_STANDARD);

          trustManagerFactory.init(trustStore);
          keyManagerFactory.init(keyStore, CLIENT_BKS_PASSWORD.toCharArray());

          sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
          sslSocketFactory = sslContext.getSocketFactory();
      } catch (KeyStoreException e) {
          e.printStackTrace();
      } catch (IOException e) {
          e.printStackTrace();
      } catch (CertificateException e) {
          e.printStackTrace();
      } catch (NoSuchAlgorithmException e) {
          e.printStackTrace();
      } catch (UnrecoverableKeyException e) {
          e.printStackTrace();
      } catch (KeyManagementException e) {
          e.printStackTrace();
      }
      return sslSocketFactory;
  }

}

(2)优点

双向校验更安全

(3)缺点

需要服务端支持,TLS/SSL握手耗时增长

4、客户端信任所有证书

不检验任何证书,下面列两种常见的实现方式

(1)OkHttp版本

img

(2)HttpURLConnection版本

img

六、Android端一种源码调试的方式

背景:由于证书校验相关源码不在Android.jar中,为了方便调试证书校验的流程,这里简单介绍一种非android.jar包中的Android源码调试的方式

1、下载源码

(1)源码地址:android.googlesource.com/

android官方提供了各个模块的git仓库地址

img

(2)以SSL证书调试为例

我们只需要conscrypt部分的源码:android.googlesource.com/platform/ex…

注意点:选择的分支要和被调试的手机版本一致(因为不同系统版本下源码有点区别)

如果测试及时Android10.0系统,我们可以选择android10-release分支

img

2、源码导入

新建一个module 把刚才的系统源码复制进来,不需要依赖,只需要在setting.gradle中include,这样做隔离性好,方便移除

img

3、源码编译

导入源码之后,可能会有部分编译问题,可以解决的可以先解决,如果解决不了可以先注释;

需要注意点:

(1)不能修改行号,否则调试的时候走不到

(2)不能新增代码,新增的代码不会执行

4、断点调试

打好断点就可以发车了

可以看到app发起网络请求之后会走到TrustManagerImpl里面的checkServerTrusted校验服务端证书

img

七、Android端证书校验源码解析

1、证书校验主要分3步

(1)握手过程中验证证书

验证证书合法性,判断是否由合法的CA签发,由上面的Android系统根证书库来判断

img

(2)验证域名

判断服务端证书是否为特定域名签发,验证网站身份,这里如果出错就会抛出

SSLPeerUnverifiedException的异常

img

(3)验证证书绑定

img

2、Android根证书相关源码

Android会内置常用的根证书,系统根证书存放在/system/etc/security/cacerts 目录下,文件均为 PEM 格式的 X509 证书格式,包含明文base64编码公钥,证书信息,哈希等

Android系统的根证书管理类

位于/frameworks/base/core/java/android/security/net/config 目录下

以下是根证书管理类的类关系图

img

(1)CertificateSource

接口类,定义了对根证书可执行的获取和查询操作

img

有三个实现类,分别是KeyStoreCertificateSource、ResourceCertificateSource、DirectoryCertificateSource

(2)KeyStoreCertificateSource

从 KeyStore 中获取证书

img

(3)ResourceCertificateSource

基于 ResourceId 从资源目录读取文件并构造证书

img

(4)DirectoryCertificateSource(抽象类)

遍历指定的目录 mDir 读取证书;还提供了一个抽象方法 isCertMarkedAsRemoved() 用于判断证书是否被移除

img

SystemCertificateSourceUserCertificateSource 继承了DirectoryCertificateSource并且分别定义了系统和用户根证书库的路径,并实现抽象方法

[i]SystemCertificateSource

定义了系统证书查询路径,并且还指定了被移除的证书文件的目录

img

判断证书是否移除就是直接判断证书文件是否存在于指定的目录

img

[ii]UserCertificateSource

定义了用户证书指定查询路径,证书是否移除永远为false

img

3、Android证书校验源码

(以证书锁定方式的单向校验服务端证书为例)

核心类TrustManagerImpl、TrustedCertificateIndex、X500Principal

(1)第一步checkServerTrusted()

img

(2)第二步checkTrusted()

img

(3)第三步TrustedCertificateIndex类匹配证书issuer和signature信息

private final Map<X500Principal, List> subjectToTrustAnchors

= new HashMap<X500Principal, List>();

可以看到获取TrustAnchor是通过HashMap的key X500Principal匹配获取的,

img

(4)X500Principal

private transient X500Name thisX500Name;

查看X500Principal的源码可以看到它覆写了equals()方法,对比的是属性中的thisX500Name

调试下来发现我们客户端证书的 thisX500Name 的值为

“CN=*. huolala.cn , OU=IT, O=深圳货拉拉科技有限公司, L=深圳市, ST=广东省, C=CN”

(ps:后面会提到,货拉拉客户端证书异常主要因为新证书缺少了OU字段)

img

(5)subject和issue信息

img

八、货拉拉SSL证书踩坑流程

1、背景简介

2020年7月份的时候,货拉拉出现了因为网络证书过期导致的异常,所以运维的同事拉了客户端的同事一起对齐了方案,使用上述《客户端单向认证服务端---公钥锁定》的方式

由于历史原因:

货拉拉用户端使用了上述(三、1(2)客户端单向认证服务端---证书锁定,代码配置方式)

货拉拉司机端使用了上述(三、1(1)客户端单向认证服务端---证书锁定,network-security-config配置方式)

2021年7月份的时候,运维同事更新了服务端的证书,因为更换过程中没有出现异常,所以运维的同事以为android端都是按照之前约定的《客户端单向认证服务端---公钥锁定》方式

(但实际原因是用户和司机端提前内置了2022-8-19过期的证书)

2、线上出现异常

2022-8-1的时候,运维同事开始操作更新服务端2023年的证书,在更新了H5部分域名的证书之后,司机Android端出现部分网页白屏的问题

排查之后发现服务端更新了证书导致客户端证书校验证书非法导致异常

2022-8-2的时候开始排查用户端的逻辑,发现是《客户端单向认证服务端---证书锁定,代码配置方式》,测试之后发现

(1)删除app内置2022年的证书,只保留2020年的证书之后,native请求异常,无法进入app

(2)手动调整手机设备时间,发现native请求正常,webview白屏和图片加载失败

意味着在服务端更换的证书2022-8-19到期之后,客户端将面临全网访问异常的问题

3、第一次尝试解决

测试的时候发现,android端在证书过期时仍然可以访问服务端(客户端和服务端都保持一致的2022年的证书);

所以想的第1个解决方案是服务端仍然使用2022-8-19的证书,直到大部分用户升级上来之后再更换新证书;

但是ios和web发现如果服务端使用过期证书的情况,系统底层会拦截这个过期证书直接报错;

所以无法兼容所有客户端

4、第二次尝试解决

在查看源码TrustManagerImpl类源码的时候发现,TrustManagerImpl的服务端检验只是校验了publickey(公钥),所以如果2022年的旧证书和2023年的新证书如果公钥一致的话,可能可以校验通过;

所以想的第2个解决方案是服务端使用的新证书保持和2022-8-19的证书的公钥一致就可以;

但是测试的时候发现native请求还是会报错

“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”

5、第三次尝试解决

开发发现按照证书链的校验过程,如下:

img

如果有中间证书,那么这个中间证书机构颁发的任何服务器证书都可以都校验通过;

所以想出的第3个解决方案是服务器证书内置中间证书组成证书链;

但是排查之后发现服务器证书和客户端内置的证书里面都已经包含了中间证书,所以依然行不通

(ps:如果客户端内置的证书里面删除用户证书信息,只保留中间证书信息,那么只要是这家中间证书颁发的所有的服务器证书都是可以校验通过的,而且一般中间证书的有效期是10年,这也可以作为一个备选项,不过缺点是不安全)

6、第四次尝试解决

(1)测试同学在网上找到一篇《那些年踩过HTTPS的坑(二)——APP证书链mp.weixin.qq.com/s/yv_XcMLvr…

所以想到的解决方案是重新申请一个带OU字段的新服务器证书

(2)但是运维同事咨询了两家之前的中间商之后对方的回复都是新的证书已经不再提供OU字段,理由是

img

img

(3)最后历经一言难尽的各种插曲最后找UniTrust颁发了带OU字段的新证书

(ps:还在使用证书锁定方式校验的可以留意下证书里面的OU字段,后续证书都不会再提供)

九、Android端证书校验的解决方案

1、认证方式

按照安全等级划分,从高到低依次为:

(1)客户端和服务端双向认证,参考上述《五、Android端证书校验方式-3、客户端和服务端双向认证》

(2)客户端单向认证服务端---证书锁定,参考上述《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》

(3)客户端单向认证服务端---公钥锁定,参考上述《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》

可以根据各自的安全需求选择合适的认证方式

2、校验方式

(1)证书校验

具体方式参考《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》;

为了增强安全性,app可以内置加密后的证书,将解密信息存放在加固后的c++端,增强安全性

(2)公钥校验

具体方式参考《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》;

为了增强安全性,app可以内置加密后的公钥,将解密信息存放在加固后的c++端,增强安全性

3、配置降级

为了在出现异常情况时不影响app访问,可以添加动态配置和动态降级能力

(1)动态配置

动态下发公钥和证书信息,需要留意下发的时机要尽量早一点,避免证书异常时走不到下发的请求

(2)动态降级

动态降级证书校验功能,在客户端证书或者服务端证书出现异常时,支持动态关闭所有的证书校验的功能

十、总结

最后,总结一下整体的思路:

1、SSL证书分为CA证书和用户证书

2、客户端SSL证书校验是在网络连接的SSL/TLS握手环节进行校验

3、SSL证书的认证方式分为(1)单向认证(2)双向认证

4、SSL证书的校验方式分为(1)证书校验(2)公钥校验

5、SSL证书的校验流程主要是校验证书是否是由受信任的CA机构签发的合法证书

6、SSL证书的吊销校验策略分为(1)CRL本地校验证书吊销列表(2)OCSP证书状态在线查询

7、纵观本次踩坑之旅,也暴露出一个比较深刻的问题:大部分的客户端开发的认知还是停留在app上层,缺少对底层技术的认识和探索,导致一个很小的配置问题差点酿成大的事故;这也为想在客户端领域进一步提升提供了一个思路:多学习客户端的底层技术,包含网络底层实现、安全、系统底层源码等等

8、最后,解决技术类问题最核心的点还是学习和熟悉源代码;解决证书配置问题的过程中,走了不少弯路,本质上是最开始没有彻底掌握证书校验相关的系统源代码的逻辑,客观上是由于缺少非android.jar源码的调试手段导致阅读源码遗漏了部分校验逻辑,所以本次特意补上(六、Android端一种源码调试的方式),希望后续遇到系统级的疑难杂症可以用的上

参考:

http://www.cnblogs.com/xiaxveliang…

blog.csdn.net/weixin_3501…

作者:货拉拉技术
来源:https://juejin.cn/post/7186837003026038843

收起阅读 »

Android 中关于枚举的优化

概述Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。参考ProGuard 的优化列表:http://www.guard...
继续阅读 »

概述

Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。

参考

枚举的开销

详情描述

eg: 使用 enum 定义枚举类 ClazzEnum.

public enum ClazzEnum {
   ONE, TWO
}

enum 标识符声明的枚举类 ClazzEnum 默认继承自 java.lang.Enum, 每个枚举类成员默认都是 public static final 修饰,每个枚举常量都相当于是一个 ClazzEnum 对象,而 Enum 默认实现已经声明了一些枚举属性,所以枚举通常会比静态常量多两倍以上的内存占用,所以在过去 Android 中不推荐使用枚举。

解决办法

  1. 启用 R8 编译优化;

  2. 使用静态常量或TypeDef注解替换枚举;

R8 编译优化

R8 编译优化枚举,解决枚举造成的额外开销;

Android Studio 3.4.0+ 以后,在 build.gradle 编译配置中通过 minifyEnabled=true 开启 R8 编译优化,R8 会直接调用枚举的序数值(ordinal),在编译的时候将琐碎的枚举优化为整型,避免枚举造成的额外开销。

kotlin/java 代码的编译

为了更好的理解 R8 对枚举的优化,我们简单了解下kotlin/java 代码的编译流程。

在 Android 应用中,kotlin/java 代码的编译流程:

  1. kotlin/javac 编译器编译源代码文件为 java 字节码

    kotlin/javac 编译器会将代码转换为 java 字节码,Android 设备并不直接运行 Java 字节码,而是运行名为 DEX 的 Dalvik 可执行文件;

  2. D8 编译器将 java字节码转为 DEX 代码

  3. R8 (可选项,推荐 release 使用)优化:

    R8 在 build.gradle 中通将 minifyEnabled 设为 true 来开启,它将在所有其他编译工作后执行,来保证您获得的是一个缩减和优化过的应用。

Kotlin: 枚举+when

问题描述

在 Kotlin 中使用枚举时,也仅仅是将其转换为 Java 编程语言中的枚举而已,本身并不包含任何隐藏开销。但当 枚举+when 配合使用时,就会引入额外的开销。

我们举个例子:

package enums

fun main() {
   val age: Int = getAge(People.CHILD);
   println("ret: ${age}")
}

fun getAge(p: People): Int {
   return when (p) {
       People.ADULT -> 30
       People.CHILD -> 18
  }
}

enum class People {
   ADULT,
   CHILD
}

查看上述代码编译后的字节码:

# 查看字节码
# 方式一:IDEA(可能有些地方编译失败)
IDEA/AndroidStudio -> Tools -> Kotlin -> Show Kotlin Bytecode -> Decompile
# 方式二:kotlinc + JD-GUI
$ kotlinc test.kt -include-runtime -d ret.jar
// 编译后的字节码
public final class TestKt$WhenMappings {
  public static final int[] $EnumSwitchMapping$0 = new int[People.values().length];

  static {
     $EnumSwitchMapping$0[People.ADULT.ordinal()] = 1;
     $EnumSwitchMapping$0[People.CHILD.ordinal()] = 2;
  }
}

@Metadata(...)
public final class TestKt {
public static final void main() {
  int age = getAge(People.CHILD);
  String str = Intrinsics.stringPlus("ret: ", Integer.valueOf(age));
  boolean bool = false;
  System.out.println(str);
}
 
public static final int getAge(@NotNull People p) {
  Intrinsics.checkNotNullParameter(p, "p");
  People people = p;
  int i = WhenMappings.$EnumSwitchMapping$0[people.ordinal()];
  switch (i) {
    case 1:
     
    case 2:
     
  }
  throw new NoWhenBranchMatchedException();
}
}

在上述编译后的代码中可以发现,当使用 when 语句接受枚举作为参数时,编译后 when 转换成的 switch 并没有让 switch 语句直接接受枚举,而是接受了 p 枚举对应 ordinal 作为索引对应 TestKt$WhenMappings 数组中的元素值作为参数。

可以发现使用 when 语句时,编译后产物中会生成 TestKt$WhenMappings类,这个类里面有一个存储映射信息的数组 $EnumSwitchMapping$0,接下来则是一些执行映射操作的静态代码。

示例中是只有一个 when 语句时的情况,如果我们写了更多的 when 语句,那么每个 when 语句都会在 TestKt$WhenMappings类中生成一个对应的数组,即使这些 when 语句都在使用同一个枚举也一样。所以这就意味着,在您不知情的时候,会生成一个类,而且其中还包含了一些数组,这些都会让类加载和实例化消耗更多的时间。

解决办法

  1. Kotlin 中枚举可以用 Sealed Class 密封类替代;

  2. 启用 Android R8 编译会自动优化,避免生成类和映射数组,而且只会创建了您所需的最佳代码;

    // 启用 R8 编译优化后,会直接把 when 转为 switch, 并接收 Enum#ordinal 作为参数;
    public static final int getAge(@NotNull People p) {
       switch (p.ordinal()) {
           case 0:
           // ...
      }
    }

作者:呛呛cei
来源:juejin.cn/post/7070074670036287496

收起阅读 »

Android FCM接入

消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud ...
继续阅读 »

消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud Message(FCM)。

下面介绍下如何接入FCM与发送通知。

发送通知

FCM的SDK不包含创建和发送通知的功能,这部分需要我们自己实现。

在 Android 13+ 上请求运行时通知权限

Android 13 引入了用于显示通知的新运行时权限。这会影响在 Android 13 或更高版本上运行的所有使用 FCM 通知的应用。需要动态申请POST_NOTIFICATIONS权限后才能推送通知,代码如下:

class ExampleActivity : AppCompatActivity() {

   private val requestPermissionCode = this.hashCode()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
           if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
               ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
               // 申请通知权限
               ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), requestPermissionCode)
          }
  }

   override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
       super.onRequestPermissionsResult(requestCode, permissions, grantResults)
           if (requestCode == requestPermissionCode) {
               // 处理回调结果
          }
  }
}

创建通知渠道

从 Android 8.0(API 级别 26)开始,必须为所有通知分配渠道,否则通知将不会显示。通过将通知归类到不同的渠道中,用户可以停用您应用的特定通知渠道(而非停用您的所有通知),还可以控制每个渠道的视觉和听觉选项。

创建通知渠道代码如下:

class ExampleActivity : AppCompatActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       val notificationManager = NotificationManagerCompat.from(this)
       if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
           val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
               packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
          } else {
               packageManager.getApplicationInfo(packageName, 0)
          }
           val appLabel = getText(applicationInfo.labelRes)
           val exampleNotificationChannel = NotificationChannel("example_notification_channel", "$appLabel Notification Channel", NotificationManager.IMPORTANCE_DEFAULT).apply {
               description = "The description of this notification channel"
          }
           notificationManager.createNotificationChannel(minigameChannel)
      }
  }
}

创建并发送通知

创建与发送通知,代码如下:

class ExampleActivity : AppCompatActivity() {

   private var notificationId = 0

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate()
       val notificationManager = NotificationManagerCompat.from(this)
      ...
       if (notificationManager.areNotificationsEnabled()) {
           val notification = NotificationCompat.Builder(this, "example_notification_channel")
               //设置小图标
              .setSmallIcon(R.drawable.notification)
               // 设置通知标题
              .setContentTitle("title")
               // 设置通知内容
              .setContentText("content")
               // 设置是否自动取消
              .setAutoCancel(true)
               // 设置通知声音
              .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
               // 设置点击的事件
              .setContentIntent(PendingIntent.getActivity(this, requestCode, packageManager.getLaunchIntentForPackage(packageName)?.apply { putExtra("routes", "From notification") }, PendingIntent.FLAG_IMMUTABLE))
              .build()
           // notificationId可以记录下来
           // 可以通过notificationId对通知进行相应的操作
           notificationManager.notify(notificationId, notification)
      }
  }
}

注意,smallIcon必须设置,否则会导致崩溃。***

FCM

Firebase Cloud Message (FCM) 是一种跨平台消息传递解决方案,可让您免费可靠地发送消息。

官方接入文档

集成FCM

在项目下的build.gradle中添加如下代码:

buildscript {

   repositories {
       google()
       mavenCentral()
  }

   dependencies {
      ...
       classpath("com.google.gms:google-services:4.3.14")
  }
}

在app module下的build.gradle中添加代码,如下:

dependencies {
   // 使用Firebase Andorid bom(官方推荐)
   implementation platform('com.google.firebase:firebase-bom:31.1.0')
   implementation 'com.google.firebase:firebase-messaging'
   
   // 不使用bom
   implementation 'com.google.firebase:firebase-messaging:23.1.1'
}

在Firebase后台获取项目的google-services.json文件,放到app目录下


要接收FCM的消息推送,需要自定义一个Service继承FirebaseMessagingService,如下:

class ExampleFCMService : FirebaseMessagingService() {

   override fun onNewToken(token: String) {
       super.onNewToken(token)
       // FCM生成的令牌,可以用于标识用户的身份
  }

   override fun onMessageReceived(message: RemoteMessage) {
       super.onMessageReceived(message)
       // 接收到推送消息时回调此方法
  }

在AndroidManifest中注册Service,如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <service
           android:name="com.minigame.fcmnotificationsdk.MinigameFCMService"
           android:exported="false">
           <intent-filter>
               <action android:name="com.google.firebase.MESSAGING_EVENT" />
           </intent-filter>
       </service>
   </application>
</manifest>

通知图标的样式

当App处于不活跃状态时,如果收到通知,FCM会使用默认的图标与颜色来展示通知,如果需要更改的话,可以在AndroidManifest中通过meta-data进行配置,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <!--修改默认图标-->
       <meta-data
           android:name="com.google.firebase.messaging.default_notification_icon"
           android:resource="@drawable/notification" />

       <!--修改默认颜色-->
       <meta-data
           android:name="com.google.firebase.messaging.default_notification_color"
           android:resource="@color/color_blue_0083ff" />
   </application>
</manifest>

修改前:


修改后:


避免自动初始化

如果有特殊的需求,不希望FCM自动初始化,可以通过在AndroidManifest中配置meta-data来实现,代码如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
   <application>
       <meta-data
           android:name="firebase_messaging_auto_init_enabled"
           android:value="false" />
       
       <!--如果同时引入了谷歌分析,需要配置此参数-->
       <meta-data
           android:name="firebase_analytics_collection_enabled"
           android:value="false" />
   </application>
</manifest>

需要重新启动FCM自动初始化时,更改FirebaseMessagingisAutoInitEnabled的属性,代码如下:

FirebaseMessaging.getInstance().isAutoInitEnabled = true
// 如果同时禁止了Google Analytics,需要配置如下代码
FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(true)

调用此代码后,下次App启动时FCM会自动初始化。

测试消息推送

在Firebase后台中,选择Messageing,并点击制作首个宣传活动,如图:


选择Firebase 通知消息,如图:


输入标题和内容后,点击发送测试消息,如图:


输入在FirebaseMessagingService的onNewToken方法中获取到的token,并点击测试,如图:


示例

已整合到demo中。

ExampleDemo github

ExampleDemo gitee

效果如图:


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

收起阅读 »

Android开发中那些与代码无关的技巧

1.如何找到代码作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?(1)无敌搜索大法双击shift键,页面上有什么...
继续阅读 »
1.如何找到代码

作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?

(1)无敌搜索大法

双击shift键,页面上有什么就在代码中全局搜索什么,比如标题,按钮名字~找到资源文件布局文件,再进一步搜索用到这些文件的代码位置。

(2)log输出大法

在不方便debug的时候,可以输出一些log,通过查看log的输出,可以明确的看出程序运行时的运行逻辑和变量值。

(3)profiler查看大法

我们要善于利用AndroidStudio提供的工具,比如profiler。在profiler中可以看到手机中正在运行的Activity的名字,甚至能看到网络请求的详情等等,功能很强大!

(4)万能法找到页面

在你的Application中注册一个Activity的生命周期监听,

ActivityLifeCycle lifecycleCallbacks = new Application.ActivityLifecycleCallbacks();
registerActivityLifecycleCallbacks(lifecycleCallbacks);

在进入到页面的时候,直接输出页面路径~

@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
   Log.e(TAG, "onActivityCreated :" + getActivityName(activity));
}
2.如何解决bug

这里讨论的是那些第一时间没有思路不知道如何解决的bug。这些bug有的是因为开发过程中粗心写错变量名,变量值,使用了错误的方法,少执行了方法,之前修改bug时某些地方被遗漏了,或者不小心把不应该改动的地方做了改动。也可能是因为使用的第三方库存在缺陷,也可能是数据问题,接口返回的数据不正确,用户做了意料之外的操作没有被程序正确处理等等。

解决棘手的bug之前,首先要稳定自己的心态。记住,心态很重要。无论这个bug已经造成了线上多么大的影响,你的boss多么着急的催着你解决bug,要有一个平稳的心态才能解决问题,否者,慌慌忙忙紧紧张张的状态下去解决bug,很可能会造成更多的bug!

(1)先看再想最后动手

解决bug的第一步,当然是稳定的复现bug。根据我的经验,如果一个bug可以被稳定的复现,至少它就被解决了70%。

通过观察bug的现象,就可以对bug做个大致的归类或者定位了。是因为数据问题?还是第三方库的问题?还或者是代码的问题?

接着就是debug,看日志等常规操作了~

如果经过上面的操作,你还是一筹莫展,那么请往下看。

(2)改变现状

如果你真的是一点思路也没有,很可能某些可能造成bug的代码也看不太懂。我建议你做一些改变现状的操作,比如:注掉某些代码,尝试其他的输入数据或者操作。总而言之,就是让bug的现象出现改变! 那么你做的这些操作肯定是对这个bug是有影响的!!!然后再逐步恢复之前注掉的代码,直到恢复某些注掉代码之后,bug的现象恢复了。很有可能这里就是造成bug的位置。bug定位了之后,再去思考解决办法。

(3)是技术问题还是业务问题

在实际的开发过程中,很多问题是通过技术手段解决不了的。可能是业务逻辑就出现了矛盾,也有可能是是因为一些奇奇怪怪的王八的屁股。这类问题要早点发现,早点提出,才能早点解决。有些可能踩红线的问题,作为开发,不要试图通过技术去解决!!!否则可能要去踩缝纫机了~~~

(4)张张嘴远胜于动动手

我一直坚信,世界上有更多能力比我强的人。我现在面对的bug也肯定不是只有我面对了。张张嘴问问周围的同事,问问网站上的大神,现在网络这么发达,只要别人解决过的问题,就不是问题。

很多时候的bug可能只是因为你对某些领域不熟悉,去请教那些对这个领域熟悉的人,你的问题对他们来说可能不是问题。

(5)bug解决不了,那就解决提出bug的人

有的时候的bug可能不是bug。提出bug的人可能只是对某些操作或者现象不理解,或者没有达到他们的预期。他们就会提出来,他们觉得现在的程序是有问题的……这个时候可以去尝试解决这个提出bug的人!让他们觉得这不是一个bug。当然你没有这种“解决人”的能力的话,就还是老老实实去解决bug吧~

(6)解决了bug之后

人的成长在于,遇到了问题,敢于直面问题,解决问题,并让自己今后避免再出现类似的问题!

解决了bug,无论这个bug是自己造成的还是别人造成的。要善于总结,避免日后自己再写出类似的问题。

3.如何实现不会的功能
(1)不要急着拒绝

遇到如何实现不会的功能,内心首先不要着急抗拒。

人总要成长,开发的技能如何成长?总不是像流水线工人那样做些一些“熟练”操作吧?总要走出自己的舒适圈,尝试解决一些问题,突破自己的上限吧~

你要知道,在Android开发这个领域,其实没有什么逾越不了技术壁垒!只要别人家有的,你就可能有!别人家做出来的东西,你就能做出来。这种信心,至少要有的~

(2)大事化小小事化了

一个复杂的功能,通常可以分解成一些简单功能,简单的功能就可以攻克!

那么当你在面对要实现一个复杂功能或者没有接触过的功能开发的时候,你所要做的其实就是分解这个功能,然后处理分解后的小功能,最后再把这些小功能组合回去!

心态要稳,天塌了有个高的顶着

遇到问题,尝试解决,实在不行,就要及时向上级反馈。作为你的上级,他们有责任也有能力帮你解决问题,或者至少给你提供解决问题的一种思路。心态要稳,天塌了有个高的顶着。

工作不是生活的全部,工作只是为了更好的生活!不要让那些无聊的代码影响你的心情影响你的生活!

作者:我是绿色大米呀
来源:juejin.cn/post/7182379138752675898

收起阅读 »

徒手撸一个注解框架

运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。1.运行时注解1.1定义注解首先定义两个运行时注解,其中Rete...
继续阅读 »

运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。

1.运行时注解

1.1定义注解

首先定义两个运行时注解,其中Retention标明此注解在运行时生效,Target标明此注解的程序元范围,下面两个示例RuntimeBindView用于描述成员变量和类,成员变量绑定view,类绑定layout;RuntimeBindClick用于描述方法,让指定的view绑定click事件。

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface RuntimeBindView {
   int value() default View.NO_ID;
}

@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//描述方法
public @interface RuntimeBindClick {
   int[] value();
}

1.2反射实现

以下代码是用反射实现的注解功能,其中ClassInfo是一个能解析处类的各种成员和方法的工具类, 源码见github.com/huangbei199… 其实逻辑很简单,就是从Activity里面取出指定的注解,然后再调用相应的方法,如取出RuntimeBindView描述类的注解,然后得到这个注解的返回值,接着调用activity的setContentView将layout的id设置进去就可以了。

public static void bindId(Activity obj){
   ClassInfo clsInfo = new ClassInfo(obj.getClass());
   //处理类
   if(obj.getClass().isAnnotationPresent(RuntimeBindView.class)) {
       RuntimeBindView bindView = (RuntimeBindView)clsInfo.getClassAnnotation(RuntimeBindView.class);
       int id = bindView.value();
       clsInfo.executeMethod(clsInfo.getMethod("setContentView",int.class),obj,id);
  }

   //处理类成员
   for(Field field : clsInfo.getFields()){
       if(field.isAnnotationPresent(RuntimeBindView.class)){
           RuntimeBindView bindView = field.getAnnotation(RuntimeBindView.class);
           int id = bindView.value();
           Object view = clsInfo.executeMethod(clsInfo.getMethod("findViewById",int.class),obj,id);
           clsInfo.setField(field,obj,view);
      }
  }

   //处理点击事件
   for (Method method : clsInfo.getMethods()) {
       if (method.isAnnotationPresent(RuntimeBindClick.class)) {
           int[] values = method.getAnnotation(RuntimeBindClick.class).value();
           for (int id : values) {
               View view = (View) clsInfo.executeMethod(clsInfo.getMethod("findViewById", int.class), obj, id);
               view.setOnClickListener(v -> {
                   try {
                       method.invoke(obj, v);
                  } catch (Exception e) {
                       e.printStackTrace();
                  }
              });
          }
      }
  }
}

1.3使用

如下所示,将我们定义好的注解写到相应的位置,然后调用BindApi的bind函数,就可以了。很简单吧

@RuntimeBindView(R.layout.first)//类
public class MainActivity extends AppCompatActivity {

   @RuntimeBindView(R.id.jump)//成员
   public Button jump;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       BindApi.bindId(this);//调用反射
  }

   @RuntimeBindClick({R.id.jump,R.id.jump2})//方法
   public void onClick(View view){
       Intent intent = new Intent(this,SecondActivity.class);
       startActivity(intent);
  }
}

2.编译时注解

编译时注解就是在编译期间帮你自动生成代码,其实原理也不难。

2.1定义注解

我们可以看到,编译时注解定义的时候Retention的值和运行时注解不同。

@Retention(RetentionPolicy.CLASS)//编译时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface CompilerBindView {
   int value() default -1;
}

@Retention(RetentionPolicy.CLASS)//编译时生效
@Target(ElementType.METHOD)//描述方法
public @interface CompilerBindClick {
   int[] value();
}

2.2根据注解生成代码

1)准备工作

首先我们要新建一个java的lib库,因为接下需要继承AbstractProcessor类,这个类Android里面没有。


然后我们需要引入两个包,javapoet是帮助我们生成代码的包,auto-service是帮助我们自动生成META-INF等信息,这样我们编译的时候就可以执行我们自定义的processor了。

apply plugin: 'java-library'

dependencies {
   implementation fileTree(dir: 'libs', include: ['*.jar'])
   api 'com.squareup:javapoet:1.9.0'
   api 'com.google.auto.service:auto-service:1.0-rc2'
}


sourceCompatibility = "1.8"
targetCompatibility = "1.8"

2)继承AbstractProcessor

如下所示,我们需要自定义一个类继承子AbstractProcessor并复写他的方法,并加上AutoService的注解。 ClassElementsInfo是用来存储类信息的类,这一步先暂时不用管,下一步会详细说明。 其实从函数的名称就可以看出是什么意思,init初始化,getSupportedSourceVersion限定所支持的jdk版本,getSupportedAnnotationTypes需要处理的注解,process我们可以在这个函数里面拿到拥有我们需要处理注解的类,并生成相应的代码。


3)搜集注解

首先我们看下ClassElementsInfo这个类,也就是我们需要搜集的信息。 TypeElement为类元素,VariableElement为成员元素,ExecutableElement为方法元素,从中我们可以获取到各种注解信息。 classSuffix为前缀,例如原始类为MainActivity,注解生成的类名就为MainActivity+classSuffix

public class ClassElementsInfo {

   //类
   public TypeElement mTypeElement;
   public int value;
   public String packageName;

   //成员,key为id
   public Map<Integer,VariableElement> mVariableElements = new HashMap<>();

   //方法,key为id
   public Map<Integer,ExecutableElement> mExecutableElements = new HashMap<>();

   //后缀
   public static final String classSuffix = "proxy";

   public String getProxyClassFullName() {
       return mTypeElement.getQualifiedName().toString() + classSuffix;
  }
   public String getClassName() {
       return mTypeElement.getSimpleName().toString() + classSuffix;
  }
  ......
}

然后我们就可以开始搜集注解信息了, 如下所示,按照注解类型一个一个的搜集,可以通过roundEnvironment.getElementsAnnotatedWith函数拿到注解元素,拿到之后再根据注解元素的类型分别填充到ClassElementsInfo当中。 其中ClassElementsInfo是存储在Map当中,key是String是classPath。

private void collection(RoundEnvironment roundEnvironment){
   //1.搜集compileBindView注解
   Set<? extends Element> set = roundEnvironment.getElementsAnnotatedWith(CompilerBindView.class);
   for(Element element : set){
       //1.1搜集类的注解
       if(element.getKind() == ElementKind.CLASS){
           TypeElement typeElement = (TypeElement)element;
           String classPath = typeElement.getQualifiedName().toString();
           String className = typeElement.getSimpleName().toString();
           String packageName = mElementUtils.getPackageOf(typeElement).getQualifiedName().toString();
           CompilerBindView bindView = element.getAnnotation(CompilerBindView.class);
           if(bindView != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               info.packageName = packageName;
               info.value = bindView.value();
               info.mTypeElement = typeElement;
          }
      }
       //1.2搜集成员的注解
       else if(element.getKind() == ElementKind.FIELD){
           VariableElement variableElement = (VariableElement) element;
           String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
           CompilerBindView bindView = variableElement.getAnnotation(CompilerBindView.class);
           if(bindView != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               info.mVariableElements.put(bindView.value(),variableElement);
          }
      }
  }

   //2.搜集compileBindClick注解
   Set<? extends Element> set1 = roundEnvironment.getElementsAnnotatedWith(CompilerBindClick.class);
   for(Element element : set1){
       if(element.getKind() == ElementKind.METHOD){
           ExecutableElement executableElement = (ExecutableElement) element;
           String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
           CompilerBindClick bindClick = executableElement.getAnnotation(CompilerBindClick.class);
           if(bindClick != null){
               ClassElementsInfo info = classElementsInfoMap.get(classPath);
               if(info == null){
                   info = new ClassElementsInfo();
                   classElementsInfoMap.put(classPath,info);
              }
               int[] values = bindClick.value();
               for(int value : values) {
                   info.mExecutableElements.put(value,executableElement);
              }
          }
      }
  }
}

4)生成代码

如下所示使用javapoet生成代码,使用起来并不复杂。

public class ClassElementsInfo {
  ......
   public String generateJavaCode() {
       ClassName viewClass = ClassName.get("android.view","View");
       ClassName clickClass = ClassName.get("android.view","View.OnClickListener");
       ClassName keepClass = ClassName.get("android.support.annotation","Keep");
       ClassName typeClass = ClassName.get(mTypeElement.getQualifiedName().toString().replace("."+mTypeElement.getSimpleName().toString(),""),mTypeElement.getSimpleName().toString());

       //构造方法
       MethodSpec.Builder builder = MethodSpec.constructorBuilder()
              .addModifiers(Modifier.PUBLIC)
              .addParameter(typeClass,"host",Modifier.FINAL);
       if(value > 0){
           builder.addStatement("host.setContentView($L)",value);
      }

       //成员
       Iterator<Map.Entry<Integer,VariableElement>> iterator = mVariableElements.entrySet().iterator();
       while(iterator.hasNext()){
           Map.Entry<Integer,VariableElement> entry = iterator.next();
           Integer key = entry.getKey();
           VariableElement value = entry.getValue();
           String name = value.getSimpleName().toString();
           String type = value.asType().toString();
           builder.addStatement("host.$L=($L)host.findViewById($L)",name,type,key);
      }

       //方法
       Iterator<Map.Entry<Integer,ExecutableElement>> iterator1 = mExecutableElements.entrySet().iterator();
       while(iterator1.hasNext()){
           Map.Entry<Integer,ExecutableElement> entry = iterator1.next();
           Integer key = entry.getKey();
           ExecutableElement value = entry.getValue();
           String name = value.getSimpleName().toString();
           MethodSpec onClick = MethodSpec.methodBuilder("onClick")
                  .addAnnotation(Override.class)
                  .addModifiers(Modifier.PUBLIC)
                  .addParameter(viewClass,"view")
                  .addStatement("host.$L(host.findViewById($L))",value.getSimpleName().toString(),key)
                  .returns(void.class)
                  .build();
           //构造匿名内部类
           TypeSpec clickListener = TypeSpec.anonymousClassBuilder("")
                  .addSuperinterface(clickClass)
                  .addMethod(onClick)
                  .build();
           builder.addStatement("host.findViewById($L).setOnClickListener($L)",key,clickListener);
      }

       TypeSpec typeSpec = TypeSpec.classBuilder(getClassName())
              .addModifiers(Modifier.PUBLIC)
              .addAnnotation(keepClass)
              .addMethod(builder.build())
              .build();
       JavaFile javaFile = JavaFile.builder(packageName,typeSpec).build();
       return javaFile.toString();
  }
}

最终使用了注解之后生成的代码如下

package com.android.hdemo;

import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;

@Keep
public class MainActivityproxy {
 public MainActivityproxy(final MainActivity host) {
   host.setContentView(2131296284);
   host.jump=(android.widget.Button)host.findViewById(2131165257);
   host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165258));
    }
  });
   host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165257));
    }
  });
}
}

5)让注解生效

我们生成了代码之后,还需要让原始的类去调用我们生成的代码

public class BindHelper {

   static final Map<Class<?>,Constructor<?>> Bindings = new HashMap<>();

   public static void inject(Activity activity){
       String classFullName = activity.getClass().getName() + ClassElementsInfo.classSuffix;
       try{
           Constructor constructor = Bindings.get(activity.getClass());
           if(constructor == null){
               Class proxy = Class.forName(classFullName);
               constructor = proxy.getDeclaredConstructor(activity.getClass());
               Bindings.put(activity.getClass(),constructor);
          }
           constructor.setAccessible(true);
           constructor.newInstance(activity);
      }catch (Exception e){
           e.printStackTrace();
      }
  }
}

2.3调试

首先在gradle.properties里面加入如下的代码

android.enableSeparateAnnotationProcessing = true
org.gradle.daemon=true
org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888

然后点击Edit Configurations


新建一个remote


然后填写相关的参数,127.0.0.1表示本机,port与刚才gradle.properties里面填写的保持一致,然后点击ok


然后将Select Run/Debug Configuration选项调整到刚才新建的Configuration上,然后点击Build--Rebuild Project,就可以开始调试了。


2.4使用

如下所示为原始的类

@CompilerBindView(R.layout.first)
public class MainActivity extends AppCompatActivity {

   @CompilerBindView(R.id.jump)
   public Button jump;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       BindHelper.inject(this);
  }

   @CompilerBindClick({R.id.jump,R.id.jump2})
   public void onClick(View view){
       Intent intent = new Intent(this,SecondActivity.class);
       startActivity(intent);
  }
}

以下为生成的类

package com.android.hdemo;

import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;

@Keep
public class MainActivityproxy {
 public MainActivityproxy(final MainActivity host) {
   host.setContentView(2131296284);
   host.jump=(android.widget.Button)host.findViewById(2131165257);
   host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165258));
    }
  });
   host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
     @Override
     public void onClick(View view) {
       host.onClick(host.findViewById(2131165257));
    }
  });
}
}

3.总结

注解框架看起来很高大上,其实弄懂之后也不难,都是一个套路。

作者:我是黄大仙
来源:juejin.cn/post/7180166142093656120

收起阅读 »

移动端防抓包实践

01.整体概述介绍1.1 项目背景通讯安全是App安全检测过程中非常重要的一项针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。保证数据安全通过charles等工具可以对app的网络请求进行...
继续阅读 »

01.整体概述介绍

1.1 项目背景

  • 通讯安全是App安全检测过程中非常重要的一项

    • 针对该项的主要检测手段就是使用中间人代理机制对网络传输数据进行抓包、拦截和篡改,以检验App在核心链路上是否有安全漏洞。

  • 保证数据安全

    • 通过charles等工具可以对app的网络请求进行抓包,这样这些信息就会被清除的提取出来,会被不法分子进行利用。

  • 不想被竞争对手逆向抓包

    • 不想自身App的数据被别人轻而易举地抓包获取到,从而进行类似业务或数据分析、爬虫或网络攻击等破坏性行为。

1.2 思考问题

  • 开发项目的时候,都需要抓包,很多情况下即使是Https也能正常抓包正常。那么问题来了:

    • 抓包的原理是?任何Https的 app 都能抓的到吗?如果不能,哪些情况下可以抓取,哪些情况下抓取不到?

  • 什么叫做中间人攻击?

    • 使用HTTPS协议进行通信时,客户端需要对服务器身份进行完整性校验,以确认服务器是真实合法的目标服务器。

    • 如果没有校验,客户端可能与仿冒的服务器建立通信链接,即“中间人攻击”。

1.3 设计目标

  • 防止App被各种方式抓包

    • 做好各种防抓包安全措施,避免各种黑科技抓包。

  • 沉淀为技术库复用

    • 目前只是针对App端有需要做防抓包措施,后期其他业务线可能也有这个需要。因此下沉为工具库,傻瓜式调用很有必要。

  • 该库终极设计目标如下所示

    • 第一点:必须是低入侵性,对原有代码改动少,最简单的加入是一行代码设置即可。完全解耦合。

    • 第二点:可以动态灵活配置,支持配置禁止代理,支持配置是否证书校验,支持配置域名合法性过滤,支持拦截器加解密数据。

    • 第三点:可以检测App是否在双开,挂载,Xposed攻击环境

    • 第四点:可以灵活设置加解密的key,可以灵活替换加解密方式,比如目前采用RC4,另一个项目想用DES,可以灵活更换。

1.4 收益分析

  • 抓包库收益

    • 提高产品App的数据安全,必须对数据传输做好安全保护措施和完整性校验,以防止自身数据在网络传输中裸奔,甚至是被三方恶意利用或攻击。

  • 技能的收益

    • 下沉为功能基础库,可以方便各个产品线使用,提高开发的效率。避免跟业务解耦合。傻瓜式调用,低成本接入!

02.市面抓包的分析

2.1 Https三要素

  • 要清楚HTTPS抓包的原理,首先需要先说清楚 HTTPS 实现数据安全传输的工作原理,主要分为三要素和三阶段。

  • Http传输数据目前存在的问题

    • 1.通信使用明文,内容可能被窃听;2.不验证通信方的身份,因此可能遭遇伪装;3.无法证明报文的完整性,所以有可能遭到篡改。


  • Https三要素分别是:

    • 1.加密:通过对称加密算法实现。

    • 2.认证:通过数字签名实现。(因为私钥只有 “合法的发送方” 持有,其他人伪造的数字签名无法通过验证)

    • 3.报文完整性:通过数字签名实现。(因为数字签名中使用了消息摘要,其他人篡改的消息无法通过验证)

  • Https三阶段分别是:

    • 1.CA 证书校验:CA 证书校验发生在 TLS 的前两次握手,客户端和服务端通过报文获得服务端 CA 证书,客户端验证 CA 证书合法性,从而确认 CA 证书中的公钥合法性(大多数场景不会做双向认证,即服务端不会认证客户端合法性,这里先不考虑)。

    • 2.密钥协商:密钥协商发生在 TLS 的后两次握手,客户端和服务端分别基于公钥和私钥进行非对称加密通信,协商获得 Master Secret 对称加密私钥(不同算法的协商过程细节略有不同)。

    • 3.数据传输:数据传输发生在 TLS 握手之后,客户端和服务端基于协商的对称密钥进行对称加密通信。

  • Https流程图如下


2.2 抓包核心原理

  • HTTPS抓包原理

    • Fiddler、Charles等抓包工具,其实都是采用了中间人攻击的方案: 将客户端的网络流量代理到MITM(中间人)主机,再通过一系列的面板或工具将网络请求结构化地呈现出来。

  • 抓包Https有两个突破点

    • CA证书校验是否合法;数据传递过程中的加密和解密。如果是要抓包,则需要突破这两点的技术,无非就是MITM(中间人)伪造证书和使用自己的加解密方式。

  • 抓包的工作流程如下

    • 中间人截获客户端向发起的HTTPS请求,佯装客户端,向真实的服务器发起请求;

    • 中间人截获真实服务器的返回,佯装真实服务器,向客户端发送数据;

    • 中间人获取了用来加密服务器公钥的非对称秘钥和用来加密数据的对称秘钥,处理数据加解密。

2.3 搞定CA证书

  • Https抓包核心CA证书

    • HTTPS抓包的原理还是挺简单的,简单来说,就是Charles作为“中间人代理”,拿到了服务器证书公钥和HTTPS连接的对称密钥。

    • 前提是客户端选择信任并安装Charles的CA证书,否则客户端就会“报警”并中止连接。这样看来,HTTPS还是很安全的。

  • 安装CA证书到手机中必须洗白

    • 抓包应用内置的 CA 证书要洗白,必须安装到系统中。而 Android 系统将 CA 证书又分为两种:用户 CA 证书和系统 CA 证书(必要Root权限)。

  • Android从7.0开始限制CA证书

    • 只有系统(system)证书才会被信任。用户(user)导入的Charles根证书是不被信任的。相当于可以理解Android系统增加了安全校验!

  • 如何绕过CA证书这种限制呢?已知有以下四种方式

    • 第一种方式:AndroidManifest 中配置 networkSecurityConfig,App 信任用户 CA 证书,让系统对用户 CA 证书的校验给予通过。

    • 第二种方式:调低 targetSdkVersion < 24,不过这种方式谷歌市场有限制,意味着抓 HTTPS 的包越来越难操作。

    • 第三种方式:挂载App抓包,VirtualApp 这种多开应用可以作为宿主系统来运行其它应用,利用xposed避开CA证书校验。

    • 第四种方式:Root手机,把 CA 证书安装到系统 CA 证书目录中,那这个假 CA 证书就是真正洗白了,难度较大。

2.4 突破CA证书校验

  • App版本如何让证书校验安全

    • 1.设置targetSdkVersion大于24,去掉清单文件中networkSecurityConfig文件中的system和user配置,设置不信任用户证书。

    • 2.公钥证书固定。指 Client 端内置 Server 端真正的公钥证书。在 HTTPS 请求时,Server 端发给客户端的公钥证书必须与 Client 端内置的公钥证书一致,请求才会成功。

      • 证书固定的一般做法是,将公钥证书(.crt 或者 .cer 等格式)内置到 App 中,然后创建 TrustManager 时将公钥证书加进去。

  • 那么如何突破CA证书校验

    • 第一种:JustTrustMe 破解证书固定。Xposed 和 Magisk 都有相应的模块,用来破解证书固定,实现正常抓包。破解的原理大致是,Hook 创建 SSLContext 等涉及 TrustManager 相关的方法,将固定的证书移除。

    • 第二种:基于 VirtualApp 的 Hook 机制破解证书固定。在 VirtualApp 中加入 Hook 代码,然后利用 VirtualApp 打开目标应用进行抓包。具体看:VirtualHook

2.5 如何搞定加解密

  • 目前使用对称加密和解密请求和响应数据

    • 加密和解密都是用相同密钥。只有一把密钥,如果密钥暴露,内容就会暴露。但是这一块逆向破解有些难度。而破解解密方式就是用密钥逆向解密,或者中间人冒充使用自己的加解密方式!

  • 加密后数据镇兼顾了安全性吗

    • 不一定安全。中间人伪造自己的公钥和私钥,然后拦截信息,进行篡改。

2.6 Charles原理

  • Charles类似代理服务器

    • Charles 通过将软件本身设置成系统的网络访问代理服务器,使得所有的网络请求都会走一遍 Charles 代理,从而 Charles 可以截取经过它的请求,然后我们就可以对其进行网络包的分析。

  • 截取设备网络封包数据

    • Charles对应设置:将代理功能打开,并设置一个固定的端口。默认情况下,端口号为:8888 。

    • 移动设备设置:在手机上设置 WIFI 的 HTTP 代理。注意这里的前提是,Phone 和 Charles 代理设备链接的是同一网络(同一个ip地址和端口号)。

  • 截取Https的网络封包

    • 正常情况下,Charles 是不能截取Https的网络包的,这涉及到 Https 的证书问题。

2.7 抓包原理图

  • Charles抓包原理图


  • Android上的网络抓包原来是这样工作的

    • Charles抓包

2.8 抓包核心流程

  • 抓包核心流程关键节点

    • 第一步,客户端向服务器发起HTTPS请求,charles截获客户端发送给服务器的HTTPS请求,charles伪装成客户端向服务器发送请求进行握手 。

    • 第二步,服务器发回相应,charles获取到服务器的CA证书,用根证书(这里的根证书是CA认证中心给自己颁发的证书)公钥进行解密,验证服务器数据签名,获取到服务器CA证书公钥。然后charles伪造自己的CA证书(这里的CA证书,也是根证书,只不过是charles伪造的根证书),冒充服务器证书传递给客户端浏览器。

    • 第三步,与普通过程中客户端的操作相同,客户端根据返回的数据进行证书校验、生成密码Pre_master、用charles伪造的证书公钥加密,并生成HTTPS通信用的对称密钥enc_key。

    • 第四步,客户端将重要信息传递给服务器,又被charles截获。charles将截获的密文用自己伪造证书的私钥解开,获得并计算得到HTTPS通信用的对称密钥enc_key。charles将对称密钥用服务器证书公钥加密传递给服务器。

    • 第五步,与普通过程中服务器端的操作相同,服务器用私钥解开后建立信任,然后再发送加密的握手消息给客户端。

    • 第六步,charles截获服务器发送的密文,用对称密钥解开,再用自己伪造证书的私钥加密传给客户端。

    • 第七步,客户端拿到加密信息后,用公钥解开,验证HASH。握手过程正式完成,客户端与服务器端就这样建立了”信任“。

  • 在之后的正常加密通信过程中,charles如何在服务器与客户端之间充当第三者呢?

    • 服务器—>客户端:charles接收到服务器发送的密文,用对称密钥解开,获得服务器发送的明文。再次加密, 发送给客户端。

    • 客户端—>服务端:客户端用对称密钥加密,被charles截获后,解密获得明文。再次加密,发送给服务器端。由于charles一直拥有通信用对称密钥enc_key,所以在整个HTTPS通信过程中信息对其透明。

03.防止抓包思路

3.1 先看如何抓包

  • 使用Charles需要做哪些操作

    • 1.电脑上需要安装证书。这个主要是让Charles充当中间人,颁布自己的CA证书。

    • 2.手机上需要安装证书。这个是访问Charles获取手机证书,然后安装即可。

    • 3.Android项目代码设置兼容。Google 推出更加严格的安全机制,应用默认不信任用户证书(手机里自己安装证书),自己的app可以通过配置解决,相当于信任证书的一种操作!

  • 尤其可知抓包的突破口集中以下几点

    • 第一点:必须链接代理,且跟Charles要具有相同ip。思路:客户端是否可以判断网络是否被代理了

    • 第二点:CA证书,这一块避免使用黑科技hook证书校验代码,或者拥有修改CA证书权限。思路:集中在可以判断是否挂载

    • 第三点:冒充中间人CA证书,在客户端client和服务端server之间篡改拦截数据。思路:可以做CA证书校验

    • 第四点:为了可以在7.0上抓包,App往往配置清单文件networkSecurityConfig。思路:线上环境去掉该配置

3.2 设置配置文件

  • 一个是CA证书配置文件

    • debug包为了能够抓包,需要配置networkSecurityConfig清单文件的system和user权限,只有这样才会信任用户证书。

  • 一个是检验证书配置

    • 不论是权威机构颁发的证书还是自签名的,打包一份到 app 内部,比如存放在 asset 里。然后用这个KeyStore去引导生成的TrustManager来提供证书验证。

  • 一个是检验域名合法性

    • Android允许开发者重定义证书验证方法,使用HostnameVerifier类检查证书中的主机名与使用该证书的服务器的主机名是否一致。

    • 如果重写的HostnameVerifier不对服务器的主机名进行验证,即验证失败时也继续与服务器建立通信链接,存在发生“中间人攻击”的风险。

  • 如何查看CA证书的数据

    • 证书验证网站 ;SSL配置检查网站

3.3 数据加密处理

  • 网络数据加密的需求

    • 为了项目数据安全性,对请求体和响应体加密,那肯定要知道请求体或响应体在哪里,然后才能加密,其实都一样不论是加密url里面的query内容还是加密body体里面的都一样。

  • 对数据哪里进行加密和解密

    • 目前对数据返回的data进行加解密。那么如何做数据加密呢?目前项目中采用RC4加密和解密数据。

  • 抓取到的内容为乱码

    • 有的APP为了防止抓取,在返回的内容上做了层加密,所以从Charles上看到的内容是乱码。这种情况下也只能反编译APP,研究其加密解密算法进行解密。难度极大!

3.4 避免黑科技抓包

  • 基于Xposed(或者)黑科技破解证书校验

    • 这种方式可以检查是否有Xposed环境,大概的思路是使用ClassLoader去加载固定包名的xp类,或者手动抛出异常然后捕获去判断是否包含Xposed环境。

  • 基于VirtualApp挂载App突破证书访问权限

    • 这个VirtualApp相当于是一个宿主App(可以把它想像成桌面级App),它突破证书校验。然后再实现挂载App的抓包。判断是否是双开环境!

04.防抓包实践开发

4.1 App安全配置

  • 添加配置文件

    • android:networkSecurityConfig="@xml/network_security_config"

  • 配置networkSecurityConfig抓包说明

    • 中间人代理之所有能够获取到加密密钥就是因为我们手机上安装并信任了其代理证书,这类证书安装后都会被归结到用户证书一类,而不是系统证书。

    • 那我们可以选择只信任系统内置的系统证书,而屏蔽掉用户证书(Android7.0以后就默认是只信任系统证书了),就可以防止数据被解密了。

  • 实现App防抓包安全配置方式有两种:

    • 一种是Android官方提供的网络安全配置;另一种也可以通过设置网络框架实现(以okhttp为例)。

    • 第一种:具体可以看清单配置文件,相当于base-config标签下去掉 这组标签。

    • 第二种:需要给okhttpClient配置 X509TrustManager 来监听校验服务端证书有效性。遍历设备上信任的证书,通过证书别名将用户证书(别名中含有user字段)过滤掉,只将系统证书添加到验证列表中。

  • 该方案优点和缺点分析说明

    • 优点:network_security_config配置简单,对整个app网络生效,无需修改代码;代码实现对通过该网络框架请求的生效,能兼容7.0以前系统。

    • 缺陷:network_security_config配置方式,7.0以前的系统配置不生效,依然可以通过代理工具进行抓包。okhttp配置的方式只能对使用该网络框架进行数据传输的接口生效,并不能对整个app生效。

    • 破解:将手机进行root,然后将代理证书放置到系统证书列表内,就可以绕过代码或配置检查了。

4.2 关闭代理

  • charles 和 fiddler 都使用代理来进行抓包,对网络客户端使用无代理模式即可防止抓包,如

    OkHttpClient.Builder()
       .proxy(Proxy.NO_PROXY)
       .build()
  • no_proxy实际上就是type属性为direct的一个proxy对象,这个type有三种

    • direct,http,socks。这样因为是直连,所以不走代理。所以charles等工具就抓不到包了,这样一定程度上保证了数据的安全,这种方式只是通过代理抓不到包。

  • 通常情况下上述的办法有用,但是无法防住使用 VPN 导流进行的抓包

    • 使用VPN抓包的原理是,先将手机请求导到VPN,再对VPN的网络进行Charles的代理,绕过了对App的代理。

  • 该方案优点和缺点分析说明

    • 优点:实现简单方便,无系统版本兼容问题。

    • 缺陷:该方案比较粗暴,将一切代理都切断了,对于有合理诉求需要使用网络代理的场景无法满足。

    • 破解:使用ProxyDroid全局代理工具通过iptables对请求进行强制转发,可以有效绕过代理检测。

4.3 证书校验(单向认证)

  • 下载服务器端公钥证书

    • 为了防止上面方案可能导致的“中间人攻击”,可以下载服务器端公钥证书,然后将公钥证书编译到Android应用中一般在assets文件夹保存,由应用在交互过程中去验证证书的合法性。

  • 如何设置证书校验

    • 通过OkHttp的API方法 sslSocketFactory(sslSocketFactory,trustManager) 设置SSL证书校验。

  • 如何设置域名合法性校验

    • 通过OkHttp的API方法 hostnameVerifier(hostnameVerifier) 设置域名合法性校验。

  • 证书校验的原理分析

    • 按CA证书去验证的,若不是CA可信任的证书,则无法通过验证。

  • 单向认证流程图


  • 该方案优点和缺点分析说明

    • 优点:安全性比较高,单向认证校验证书在代码中是方便的,安全性相对较高。

    • 缺陷:CA证书存在过期的问题,证书升级。

    • 破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效。

4.4 双向认证

  • 什么叫做双向认证

    • SSL/TLS 协议提供了双向认证的功能,即除了 Client 需要校验 Server 的真实性,Server 也需要校验 Client 的真实性。

  • 双向认证的原理

    • 双向认证需要 Server 支持,Client 必须内置一套公钥证书 + 私钥。在 SSL/TLS 握手过程中,Server 端会向 Client 端请求证书,Client 端必须将内置的公钥证书发给 Server,Server 验证公钥证书的真实性。

    • 用于双向认证的公钥证书和私钥代表了 Client 端身份,所以其是隐秘的,一般都是用 .p12 或者 .bks 文件 + 密钥进行存放。

  • 代码层面如何做双向认证

    • 双向校验就是自定义生成客户端证书,保存在服务端和客户端,当客户端发起请求时在服务端也校验客户端的证书合法性,如果不是可信任的客户端发送的请求,则拒绝响应。

    • 服务端根据自身使用语言和网络框架配置相应证书校验机制即可。

  • 双向认证流程图


  • 该方案优点和缺点分析说明

    • 优点:安全性非常高,使用三方工具不易破解。

    • 缺陷:服务端需要存储客户端证书,一般服务端会对应多个客户端,就需要分别存储和校验客户端证书,增加校验成本,降低响应速度。该方案比较适合对安全等级要求比较高的业务(如金融类业务)。

    • 破解:由于在服务端也做校验,在服务端安全的情况下很难被攻破。

4.5 防止挂载抓包

  • Xposed是一个牛逼的黑科技

    • Xposed + JustTrustMe 可以破解绕过校验CA证书。那么这样CA证书的校验就形同虚设了,对App的危险性也很大。

  • App多开运行在多个环境上

    • 多开App的原理类似,都是以新进程运行被多开的App,并hook各类系统函数,使被多开的App认为自己是一个正常的App在运行。

    • 一种是从多开App中直接加载被多开的App,如平行空间、VirtualApp等,另一种是让用户新安装一个App,但这个App本质上就是一个壳,用来加载被多开的App。

  • VirtualApp是一个牛逼的黑科技

    • 它破坏了Android 系统本身的隔离措施,可以进行免root hook和其他黑科技操作,你可以用这个做很多在原来APP里做不到事情,于此同时Virtual App的安全威胁也不言而喻。

  • 如何判断是否具有Xposed环境

    • 第一种方式:获取当前设备所有运行的APP,根据安装包名对应用进行检测判断是否有Xposed环境。

    • 第二种方式:通过自造异常来检测堆栈信息,判断异常堆栈中是否包含Xposed等字符串。

    • 第三种方式:通过ClassLoader检查是否已经加载了XposedBridge类和XposedHelpers类来检测。

    • 第四种方式:获取DEX加载列表,判断其中是否包含XposedBridge.jar等字符串。

    • 第五种方式:检测Xposed相关文件,通过读取/proc/self/maps文件,查找Xposed相关jar或者so文件来检测。

  • 如何判断是否是双开环境

    • 第一种方式:通过检测app私有目录,多开后的应用路径会包含多开软件的包名。还有一种思路遍历应用列表如果出现同样的包名,则被认为双开了。

    • 第二种方式:如果同一uid下有两个进程对应的包名,在"/data/data"下有两个私有目录,则该应用被多开了。

  • 判断了具有xposed或者多开环境怎么处理App

    • 目前使用VirtualApp挂载,或者Xposed黑科技去hook,前期可以先用埋点统计。测试学而思App发现挂载在VA上是推出App。

4.5 数据加解密

  • 针对数据加解密入口

    • 目前在网络请求类里添加拦截器,然后在拦截器中处理request请求和response响应数据的加密和解密操作。

  • 主要是加密什么数据

    • 在request请求数据阶段,如果是get请求加密url数据,如果是post请求则加密url数据和requestBody数据。

    • 在response响应数据阶段,

  • 如何进行加密:发起请求(加密)

    • 第一步:获取请求的数据。主要是获取请求url和requestBody,这一块需要对数据一块处理。

    • 第二步:对请求数据进行加密。采用RC4加密数据

    • 第三步:根据不同的请求方式构造新的request。使用 key 和 result 生成新的 RequestBody 发起网络请求

  • 如何进行解密:接收返回(解密)

    • 第一步:常规解析得到 result ,然后使用RC4工具,传入key去解密数据得到解密后的字符串

    • 第二步:将解密的字符串组装成ResponseBody数据传入到body对象中

    • 第三步:利用response对象去构造新的response,然后最后返回给App

4.7 证书锁定

  • 证书锁定是Google官方比较推荐的一种校验方式

    • 原理是在客户端中预先设置好证书信息,握手时与服务端返回的证书进行比较,以确保证书的真实性和有效性。

  • 如何实现证书锁定

    • 有两种实现方式:一种通过network_security_config.xml配置,另一种通过代码设置;

      //第一种方式:配置文件 api.zuoyebang.cn 38JpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhK90= 9k1a0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM90K=

      //第二种方式:代码设置 fun sslPinning(): OkHttpClient { val builder = OkHttpClient.Builder() val pinners = CertificatePinner.Builder() .add("api.zuoyebang.cn", "sha256//89KpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRh00L=") .add("api.zuoyebang.com", "sha256//a8za0LRMXouZHRC8Ei+4PyuldPDcf3UKgO/04cDM1o=09") .build() builder.apply { certificatePinner(pinners) } return builder.build() }

  • 该方案优点和缺点分析说明

    • 优点:安全性高,配置方式也比较简单,并能实现动态更新配置。

    • 缺陷:网络安全配置无法实现证书证书的动态更新,另外该配置也受Android系统影响,对7.0以前的系统不支持。代码配置相对灵活些。

    • 破解:证书锁定破解比较复杂,比如老牌的JustTrustMe插件,通过hook各网络框架的证书校验方法,替换原有逻辑,使校验失效

4.8 Sign签名

  • 先说一下背景和问题

    • api.test.com/getbanner?k…

    • 这种方式简单粗暴,通过调用getbanner方法即可获取轮播图列表信息,但是这样的方式会存在很严重的安全性问题,没有进行任何的验证,大家都可以通过这个方法获取到数据,导致产品信息泄露。

  • 在写开放的API接口时是如何保证数据的安全性的?

    • 请求来源(身份)是否合法?请求参数被篡改?请求的唯一性(不可复制)?

  • 问题的解决方案设想

    • 解决方案:为了保证数据在通信时的安全性,我们可以采用参数签名的方式来进行相关验证。

  • 最终决定的解决方案

    • 调用接口之前需要验证签名和有效时间,要生成一个sign签名。先拼接-后转码-再加密-再发请求!

  • sign签名校验实践

    • 需要对请求参数进行签名验证,签名方式如下:key1=value1&key2=value2&key3=value3&secret=yc 。对这个字符串进行md5一下。

    • 然后被sign后的接口就变成了:api.test.com/getbanner?k…

    • 为什么在获取sign的时候建议使用secret参数?secret仅作加密使用,添加在参数中主要是md5,为了保证数据安全请不要在请求参数中使用。

  • 服务端对sign校验

    • 这样请求的时候就需要合法正确签名sign才可以获取数据。这样就解决了身份验证和防止参数篡改问题,如果请求参数被人拿走,没事,他们永远也拿不到secret,因为secret是不传递的。再也无法伪造合法的请求。

  • 如何保证请求的唯一性

    • api.test.com/getbanner?k…

    • 通过stamp时间戳用来验证请求是否过期。这样就算被人拿走完整的请求链接也是无效的。

  • Sign签名安全性分析:

    • 通过上面的案例,安全的关键在于参与签名的secret,整个过程中secret是不参与通信的,所以只要保证secret不泄露,请求就不会被伪造。

05.架构设计说明

5.1 整体架构设计

  • 如下所示


5.2 关键流程图

5.3 稳定性设计

  • 对于请求和响应的数据加解密要注意

    • 在网络上交换数据(网络请求数据)时,可能会遇到不可见字符,不同的设备对字符的处理方式有一些不同。

    • Base64对数据内容进行编码来适合传输。准确说是把一些二进制数转成普通字符用于网络传输。统统变成可见字符,这样出错的可能性就大降低了。

5.4 降级设计

  • 可以一键配置AB测试开关

    .setMonitorToggle(object : IMonitorToggle {
       override fun isOpen(): Boolean {
           //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
          return false
      }
    })

5.5 异常设计说明

  • base64加密和解密导致错误问题

    • Android 有自带的Base64实现,flag要选Base64.NO_WRAP,不然末尾会有换行影响服务端解码。导致解码失败。

5.6 Api文档

  • 关于初始化配置

    NotCaptureHelper.getInstance().config = CaptureConfig.builder()
           //设置debug模式
      .setDebug(true)
           //设置是否禁用代理
      .setProxy(false)
           //设置是否进行数据加密和解密,
      .setEncrypt(true)
           //设置cer证书路径
      .setCerPath("")
           //设置是否进行CA证书校验
      .setCaVerify(false)
           //设置加密和解密key
      .setEncryptKey(key)
           //设置参数
      .setReservedQueryParam(OkHttpBuilder.RESERVED_QUERY_PARAM_NAMES)
      .setMonitorToggle(object : IMonitorToggle {
           override fun isOpen(): Boolean {
               //todo 是否降级,如果降级,则不使用该功能。留给AB测试开关
              return false
          }
      })
      .build()
  • 设置okHttp配置

    NotCaptureHelper.getInstance().setOkHttp(app,okHttpBuilder)
  • 如何设置自己的加解密方式

    NotCaptureHelper.getInstance().encryptDecryptListener = object : EncryptDecryptListener {
       /**
        * 外部实现自定义加密数据
        */
       override fun encryptData(key: String, data: String): String {
           LoggerReporter.report("NotCaptureHelper", "encryptData data : $data")
           val str = data.encryptWithRC4(key) ?: ""
           LoggerReporter.report("NotCaptureHelper", "encryptData str : $str")
           return str
      }
       /**
        * 外部实现自定义解密数据
        */
       override fun decryptData(key: String, data: String): String {
           LoggerReporter.report("NotCaptureHelper", "decryptData data : $data")
           val str = data.decryptWithRC4(key) ?: ""
           LoggerReporter.report("NotCaptureHelper", "decryptData str : $str")
           return str
      }
    }

5.7 防抓包功能自测

  • 网络请求测试

    • 正常请求,测试网络功能是否正常

  • 抓包测试

    • 配置fiddler,charles等工具

    • 手机上设置代理

    • 手机上安装证书

    • 单向认证测试:进行网络请求,会提示SSLHandshakeException即ssl握手失败的错误提示,即表示app端的单向认证成功。

    • 数据加解密:进行网络请求,看一下请求参数和响应body数据是否加密,如果看不到实际json实体则表示加密成功。

防抓包库:github.com/yangchong21…

综合库:github.com/yangchong21…

视频播放器:github.com/yangchong21…

作者:杨充
来源:juejin.cn/post/7175325220109025339

收起阅读 »

研究良久,终于发现了他代码写的快且bug少的原因

前言读者诸君,今日我们适当放松一下,不钻研枯燥的知识和源码,分享一套高效的摸鱼绝活。我有一位程序员朋友,当时在一个团队中开发Android应用,历经多次考核后发现:在组内以及与iOS团队的对比中:他的任务量略多但他的bug数量和严重度均低但他加班的时间又少于其...
继续阅读 »

前言

读者诸君,今日我们适当放松一下,不钻研枯燥的知识和源码,分享一套高效的摸鱼绝活。

我有一位程序员朋友,当时在一个团队中开发Android应用,历经多次考核后发现:

在组内以及与iOS团队的对比中:

  • 他的任务量略多

  • 但他的bug数量和严重度均低

  • 但他加班的时间又少于其他人

不禁令人产生好奇,他是如何做到代码别的又快,质量又高的

经过多次研究我终于发现了奥秘。

为了行文方便我用"老L"来代指这位朋友。

最常见的客户端bug

"老L,听说昨晚上线,你又坐那摸鱼看测试薅别人,有什么秘诀吗?"

老L:"秘诀?倒也谈不上,你这么说,我倒是有个问题,你觉得平日里最常见的bug有哪些?"

"emm,编码上不健壮的地方,例如NPE,IndexOutOfBoundsException,UI上的可就海了去了,文本长度不一导致显示不下,间距问题,乱七八糟的一大堆"

老L:"哈哈,都是些看起来很幼稚、愚蠢的问题吧?是不是测试挂嘴边的那句:' 你就不能跑一跑吗,你又不瞎,跑两下不就看到了,这么明显!!!' "

我突然来了兴致,"你是说我们有必要上 TDD(test-driven-develop),按照DevOps思想,在CI(Continuous Integration)的时候,顺带跑自动化测试用例发现问题?"

老L突然打断了我:"不要拽你那些词了,记住了,事情是要人干的,机器只能替代可重复劳动,现在还不能替代人的主观能动性,拽词并不能解决问题。我们已经找到了第一个问题的答案,现在换个角度"


平日里最常见的bug有哪些?

  • 编码不健壮, 例如NPE,IndexOutOfBoundsException

  • UI细节问题, 例如文本长度不一导致显示不下,间距,等

为什么很浅显的问题没有被发现


老L:"那么问题来了,为什么这些浅显的问题,在交测前没有被发现呢?"

我陷入了思考...

是开发们都很懒吗?也不至于啊!

是时间很紧来不及吗?确实节奏紧张,但也不至于不给调试就拿去测了!

"emm, 可能是迭代的节奏的太频繁,压力较大,并没有整块的时间用来自测联调"


老L接过话茬,"假定你说的是正确的,那么就有两种可能。"

"第一种,自测与联调要比开发还要耗费心思的一件事情。但实际上,你我都知道,这一点并站不住脚!"

"而第二种,就是在开发阶段无法及时测试,拖到开发完,简单测测甚至被催促着就交差了"

仔细的思考后

  • 业务逐步展开,无法在任意时间自由地进行有效的集成测试

  • 后端节奏并不比前端快多少,在前端的开发阶段,难以借助后端接口测试,也许接口也有问题

"确实,这是一个挺麻烦的问题,听你一说,我感觉除了多给几天,开发完集中自测一波才行" 我如是说到。


"NO NO NO",老L又打断了我:"你想的过多了,你想借助一个可靠的、已经完备的后端系统来进行自测。对于你的需求来说,这个要求过高了,你这是准备干QA的活"

"我帮你列举一下情况"

  1. 一些数据处理的算法,这种没有办法,老老实实写单元测试,在开发阶段就可以做好,保障可靠性

  2. UI呢,我们现在写的代码,基本都做到了UI与逻辑分层,只要能模拟数据,就能跑起来看页面

  3. 业务层,后端逻辑我们无法控制,但 Web-API 调用的情况可以分析下并做一下测试,而对于返回数据的JSON结构校验、约束性校验也可以考虑做一下测试

总而言之,我们只需要先排除掉浅显的错误。而这些浅显的错误,属于情况2、3

老L接着说道:"你先歇歇吧,我来说,你再插嘴这文章就太长了!"

接下来就可以实现矛盾转移:"如何模拟数据进行测试",准确的说,问题分成两个子问题:

  • 如何生成模拟数据

  • 如何从接缝中塞入数据,让系统得以使用

可能存在的接缝


先看问题2:"如何从接缝中塞入数据,让系统得以使用"

脑暴一下,可以得出结论:

  • 应用内部

    • 替换调用web-api的业务模块,使用假数据调用业务链,一般替换Presenter、Controller实例

    • 替换Model层,不调用web-api,返回假数据或用假数据调用回调链

    • 侵入网络层实现,不进行实际网络层交互,直接使用假数据

    • 遵循切面,向缓存等机制模块中植入假数据

  • 应用外部

    • 使用代理,返回假数据

    • 假数据服务器

简单分析:

  • "假数据服务器" ,并且使用逻辑编造假数据的代价太大,过。

  • "使用代理,返回假数据",可以用于特定问题的调试,不适用广泛情况,过。

  • "替换调用web-api的业务模块",成本过大,过。

  • "替换Model层",对项目的依赖注入管理具有较大挑战,备选,可能带来很多冗余代码。

  • "侵入网络层实现",优选。

  • "向缓存等机制模块中植入假数据",操作真实的缓存较复杂,但可以考虑增加一个 Mock缓存实现模块,基于SPI等机制,可以解决冗余代码问题,备选。

得出结论:

  • 方案1:"侵入网络层实现",优选

  • 方案2:"替换Model层",(项目的依赖注入做得很好时)作为备选,可能带来冗余代码

  • 方案3:"向缓存等机制模块中植入假数据",增加一个 Mock缓存实现模块,备选。(基于SPI等机制,可以解决冗余代码问题)

再仔细分析: 方案1和方案3可以合并,形成一个完整的方案,但未必需要限定在缓存机制中


OK 我们先搁置一下这个问题,看前一个问题。


创造假数据

简单脑暴一下,无非三种:

  • 人工介入,手动编写 -- 成本过大

    • 可能在前期准备好,基本是纯文本

    • 可能使用一个交互工具,在需要数据时介入,通过图形化操作和输入产生数据

  • 人工介入,逻辑编码

  • 基于反射等自省机制,并完全随机或者基于限制生成数据

"第一种代价过大,暂且抛弃"

"第二种可以采用,但是人力成本不容忽视! 一个可以说服我使用它的理由是:"可以精心设计单测数据,针对性的发现问题"

"第三种很轻松,例如使用Mockito,但生成合适的数据需要花费一定的精力"

我们来扒一扒第三种方式,其核心思想为:

  1. 获取类信息,得到属性集

  2. 遍历属性填充 >

  1. 基础类型、箱体类型,枚举,确定取值范围,使用Random取值,赋值

2. 普通类、泛型类,创建实例,回归步骤1
3. 集合、数组等,创建实例,回归步骤1,收集填充

不难得出结论,这一方法虽然很强大,但 创建高度定制化的数据 是一件有挑战的事情。

举个例子,模拟字符串时,一般会使用语料集作为枚举,进行取值。要得到“地址”、“邮箱”等特定风格的数据,需要结合框架做配置,客观上存在较高地学习、使用门槛。

你也知道,前几年我图好玩,写了个 mock库

必须强调的一点:“我并不认为我写的库比Mockito等库强大,仅仅是在我们开发人员够用的基础上,做到尽可能简单!”

你也知道,Google 在Androidx(前身为support)中提供了一套注解包: annotations。但Google并未提供bean validation 实现 ,我之前也基于此做过一套JSR303实现,有一次突发灵感,这套注解的含义同样适用于 声明假数据取值范围 !!!

所以,我能使用它便捷的生成合适的假数据,在开发阶段及时的进行 “伪集成”


此刻,我再也忍不住要发言了:“且慢,老L,你这个做法有一定的侵入性吧。而且,如果数据类在不同业务下复用的话,是否存在问题呢?”

老L顿了顿,“确实,google的annotations是源码级注解,并不是运行时,我为了保持简单,使用了运行时反射而非代码生成。所以确实存在一定的代码侵入性”。

但是,我们可以基于此建立一套简单的MOCK-API,这样就不存在代码侵入了。

另外,也可以增加一套Annotation-Processor 实现方案,这样就可以适当沿用项目中的注解约束了,但我个人认为华而不实。


看你的第二个问题,Mocker一开始确实存在这个问题,有一次从Spring的JSR380中得到灵感,我优化了注解规则,这个问题已经被解决了。得空你可以顺着这个图看看:


或者去看看代码和使用说明:github.com/leobert-lan…

再次审视如何处理接缝

此时我已经有点云里雾里,虽然听起来很牛,如何用起来呢?我还是很茫然,简直人麻了!不得不再次请教。

老L笑着说:“你问的是一个实践方案的问题,而这类问题没有银弹.不同的项目、不同的习惯都有最适宜的方法,我只能分享一下我的想法和做法,仅做参考”

在之前的项目中,我自己建了一个Mock-API,利用我的Mocker库,写一个假数据接口就是分分钟的事情。

测试机挂上charles代理,有需要的接口直接进行mapping,所以在客户端代码中,你看不到我做了啥。

当然,这个做法是在软件外部。

如果要在软件内部做,我个人认为这也是一个华而不实的事情。不过不得不承认是一件好玩的事情,那就提一些思路。

基于Retrofit的CallAdapter

public interface CallAdapter<R, T> {
   Type responseType();

   T adapt(Call<R> call);

   abstract class Factory {
       public abstract @Nullable
       CallAdapter<?, ?> get(Type returnType, Annotation[] annotations,
                             Retrofit retrofit);

       protected static Type getParameterUpperBound(int index,
                                                    ParameterizedType type) {
           return Utils.getParameterUpperBound(index, type);
      }

       protected static Class<?> getRawType(Type type) {
           return Utils.getRawType(type);
      }
  }
}

很明显,我们可以追加注解,用以区分是否需要考虑mock;

可选:对于有可能需要mock的接口,可以继续追加切面,实现在软件外部控制使用 mock数据真实数据

而Retrofit已经使用反射确定了方法的 return Type ,在Mocker中也有适应的API直接生成假数据

基于Retrofit的Interceptor

相比于上一种,拦截器已经在Retrofit处理流程中靠后,此时在 Chain 中能够得到的内容已经属于Okhttp库的范畴。

所以需要一定的前置措施用于确定 "return Type"、"是否需要Mock" 等信息。可以借助Tag机制:

@Documented
@Target(PARAMETER)
@Retention(RUNTIME)
public @interface Tag {
}

@GET("/")
Call<ResponseBody> foo(@Tag String tag);

最终从 Request#tag(type: Class<out T>): T? 方式获取,并接入mock,并生成 Response

其他针对Okhttp的封装

思路基本类似,不再展开。

写在最后

听完老L的思路,我若有所思,若有所悟。他的方案似乎很有效,而且直觉告诉我,这些方案中还有很多留白空间,例如:

  • 借用SPI等技术思路,可以轻易的解决 "Mock 模块集成与移除" 的问题

  • 提前外部控制是否Mock的接缝,可以在加一个工具APP、或者Socket+网页端工具 用以实现控制

但我似乎遗漏了问题的开始


是否原意做 用于约束假数据生成规则的基础建设工作呢??? 例如维护注解

事情终究是人干的,人原意做,办法总比困难多。

最后一个小问题:

作者:leobert-lan
来源:juejin.cn/post/7175772997582585917

收起阅读 »

做一个具有高可用性的网络库(下)

续 做一个具有高可用性的网络库(上)网速检测如果可以获取到当前手机的网速,就可以做很多额外的操作。 比如在图片场景中,可以基于当前的实时网速进行图片的质量的变换,在网速快的场景下,加载高质量的图片,在网速慢的场景下,加载低质量的图片。 我们如何去计算...
继续阅读 »

续 做一个具有高可用性的网络库(上)

网速检测

如果可以获取到当前手机的网速,就可以做很多额外的操作。 比如在图片场景中,可以基于当前的实时网速进行图片的质量的变换,在网速快的场景下,加载高质量的图片,在网速慢的场景下,加载低质量的图片。 我们如何去计算一个比较准确的网速呢,比如下面列举的几个场景

  • 当前app没有发起网络请求,但是存在其他进程在使用网络,占用网速

  • 当前app发起了一个网络请求,计算当前网络请求的速度

  • 当前app并发多个网络请求,导致每个网络请求的速度都比较慢

可能还会存在一些其他的场景,那么在这么复杂的场景,我们通过两种不同的计算方式进行合并计算

  1. 基于当前网络接口的response读取的速度,进行网速的动态计算

  2. 基于流量和时间计算出网速

通过计算出来的两者,取最大值的网速作为当前的网速值。

基于当前接口动态计算

基于前面网络请求的全流程监控,我们可以在全局添加所有网络接口的监听,在ResponseBody这个周期内,基于response的byte数和时间,可以计算每一个网络body读取速度。之所以要选取body读取的时间来计算网速,主要是为了防止把网络建连的耗时影响了最终的网速计算。 不过接口网速的动态计算需要针对不同场景去做不同的计算。

  • 当前只有一个网络请求 在当前只有一个网络请求的场景下, 当前body计算出来请求速度就是当前的网速。

  • 当前同时存在多个网络请求发起时 每一个请求都会瓜分网速,所以在这个场景下,每个网络请求的网速都有其对应的网速占比。比如当前有6个网络请求,每个网络请求的网速近似为1/6。

当然,为了防止网速的短时间的波动,每个网络请求对于当前的网速的影响是有固定的占比的, 比如我们可以设置的占比为5%。

currentSpeed = (requestSpeed * concurrentRequestCount - preSpeed) * ratePercent + preSpeed

其中

  • requestSpeed:表示为当前网络请求计算出来的网速。

  • concurrentRequestCount:表示当前网络请求的总数

  • preSpeed:表示先前计算出来的网速

  • ratePercent:表示当前计算出来网速对于真正的网速影响占比

为了防止body过小导致的计算出来网速不对的场景,我们选取当前body大小超过20K的请求参与进行计算。

基于流量动态计算

基于流量的计算,可以参照TrafficState进行计算。可以参照facebook的network-connection-class 。可以通过每秒获取系统当前进程的流量变化,网速 = 流量总量 / 计算时间。 它内部也有一个计算公式:

public void addMeasurement(double measurement) {
  double keepConstant = 1 - mDecayConstant;
  if (mCount > mCutover) {
    mValue = Math.exp(keepConstant * Math.log(mValue) + mDecayConstant * Math.log(measurement));
  } else if (mCount > 0) {
    double retained = keepConstant * mCount / (mCount + 1.0);
    double newcomer = 1.0 - retained;
    mValue = Math.exp(retained * Math.log(mValue) + newcomer * Math.log(measurement));
  } else {
    mValue = measurement;
  }
  mCount++;
}

自定义注解处理

假如我们现在有一个需求,在我们的网络库中有一套内置的接口加密算法,现在我们期望针对某几个网络请求做单独的配置,我们有什么样的解决方案呢? 比较容易能够想到的方案是添加给网络添加一个全局拦截器,在拦截器中进行接口加密,然后在拦截器中对符合要求的请求URL的进行加密。但是这个拦截器可能是一个网络库内部的拦截器,在这里面去过滤不同的url可能不太合适。 那么,有什么方式可以让这个配置通用并且简洁呢? 其中一种方式是通过接口配置的地方,添加一个Header,然后在网络库内部拦截器中获取Header中有没有这个key,但是这个这个使用起来并且没有那么方便。首先业务方并不知道header里面key的值是什么,其次在添加到header之后,内部还需要在拦截器中把这个header的key给移除掉。

最后我们决定对于网络库给单接口提供的能力都通过注解来提供。 就拿接口加密为例子,我们期望加密的配置方式如下所示

@Encryption
@POST("xxUrl)
fun testRequest(@Field("xxUrl"nicknameString)

这个注解是如何能够透传到网络库中的内部拦截器呢。首先需要把在Interface中配置的注解获取出来。CallAdapter.Factory可以拿到网络请求中配置的注解。

override fun get(returnTypeTypeannotationsArray<Annotation>retrofitRetrofit): CallAdapter<**> {}

我们可以在这里讲注解和同一个url的request的关联起来。 然后在拦截器中获取是否有对应的注解。

override fun intercept(chainInterceptor.Chain): Response {
       val request = chain.request()
       if (!NetAnnotationUtil.isAnntationExsit(requestEncryption::class)) {
           return chain.proceed(request)
      }
       //do encrypt we want
      ...
}

调试工具

对于网络能力,我们经常会去针对网络接口进行调试。最简单的方式是通过charles抓包。 通过charles抓包我们可以做到哪些调试呢?

  1. 查看请求参数、查看网络返回值

  2. mock网络数据 看着上面2个能力似乎已经满足我们了日常调试了,但是它还是有一些缺陷的:

  3. 必须要借助PC

  4. 在App关闭了可抓包能力之后,就不能再抓包了

  5. 无法针对于post请求参数区分

所以,我们需要有一个强大的网络调试能力,既满足了charles的能力, 也可以不借助PC,并且不论apk是否开启了抓包能力也能够允许抓包。 可以添加一个专门Debug网络拦截器,在拦截器中实现这个能力。

  1. 把网络的debug文件配置在本地Sdcard下(也可以配置在远端统一的地址中)

  2. 通过拦截器,进行url、参数匹配,如果命中,将本地json返回。否则,正常走网络请求。

data class GlobalDebugConfig(
    @SeerializedName("printToConsole"var printDataBoolean = false,
   @SeerializedName("printToPage"var printDataBoolean = false
)
data class NetDebugInfo(
       @SerializedName("filter"var debugFilterInfoNetDebugFilterInfo?,
       @SerializedName("response"var responseStringAny?,
       @SerializedName("code"var httpCodeInt,
       @SerializedName("message"var httpMessageString? = null,
       @SeerializedName("printToConsole"var printDataBoolean = true,
       @SeerializedName("printToPage"var printDataBoolean = true)

data class NetDebugFilterInfo(
       @SerializedName("host"var hostString? = null,
       @SerializedName("path"var pathString? = null,
       @SerializedName("parameter"var paramMapMap<StringString>? = null)

首先日志输出有个全局配置和单个接口的配置,单接口配置优于全局配置。

  • printToConsole表示输出到控制台

  • printToPage表示将接口记录到本地中,可以在本地页面查看请求数据

其次filterInfo就是我们针对接口请求的匹配规则。

  • host表示域名

  • path表示接口请求地址

  • parameter表示请求参数的值,如果是post请求,会自动匹配post请求的body参数。如果是get请求,会自动匹配get请求的query参数。

       val host = netDebugInfo.debugFilterInfo?.host
       if (!TextUtils.isEmpty(host) && UriUtils.getHost(request.url().toString()) !host) {
           return chain.proceed(request)
      }
       val filterPath = netDebugInfo.debugFilterInfo?.path
       if (!TextUtils.isEmpty(filterPath) && path !filterPath) {
           return chain.proceed(request)
      }
       val filterRequestFilterInfo = netDebugInfo.debugFilterInfo?.paramMap
       if (!filterRequestFilterInfo.isNullOrEmpty() && !checkParam(filterRequestFilterInforequest)) {
           return chain.proceed(request)
      }
       val resultResponseJsonObj = netDebugInfo.responseString
       if (resultResponseJsonObj == null) {
           return chain.proceed(request)
      }
       return Response.Builder()
               .code(200)
               .message("ok")
               .protocol(Protocol.HTTP_2)
               .request(request)
               .body(NetResponseHelper.createResponseBody(GsonUtil.toJson(resultResponseJsonObj)))
               .build()

对于配置文件,最好能够共同维护mock数据。 本地可以提供mock数据展示列表。

组件化上网络库的能力支持

在组件化中,各个组件都需要使用网络请求。 但是在一个App内,都会有一套统一的网络请求的Header,例如AppInfo,UA,Cookie等参数。。在组件化中,针对这几个参数的配置有下面几个比较容易想到的解决方案:

  1. 在各个组件单独配置这几个Header

  • 每个组件都需要但单独配置Header,会存在很多重复代码

  • 通用信息很大概率在各个组件中获取不到

  1. 由主工程实现代理发起网络请求 这种实现方式也有下面几个缺陷

  • 主工程需要关注所有组件,随着集成的组件越来越多,主工程需要初始化网络代理接口会越来越多

  • 由于主工程并不知道组件什么时候会启动,只能App启动就初始化网络代理,导致组件初始化提前

  • 所有直接和间接依赖的模块都需要由主工程来实现代理,很容易遗漏

通用信息拦截器自动注入

正因为上面两个实现方式或多或少都有问题,所以需要从网络库这一层来解决这个问题。 我们可以在网络层通过服务发现的能力,给外部提供一个通用网络信息拦截器注解, 一般由主工程实现, 完成默认信息的Header修改。创建网络Client实例时,自动查找app中被通用网络信息拦截器注解标注的拦截器。

线程池、连接池复用

各个组件都会有自己的网络Client实例,导致在同一个进程中,创建出来网络Client实例过多,同时线程池、连接池并没有复用。所以在网络库中,各个组件创建的网络Client默认会共享网络连接池和线程池,有特殊需要的模块,可以强制使用独立线程池和连接池。

作者:谢谢谢_xie
来源:juejin.cn/post/7074493841956405278

收起阅读 »

做一个具有高可用性的网络库(上)

Retrofit本身就是对于OkHttp库的封装,它的优点很很多,比如注解来实现的,配置简单,使用方便等。那为什么我们要做二次封装呢?最根本的原因还是我们现有的业务过于复杂,我们期望有更多的自定义的能力,有更好用的使用方式等。就好比下面这些自定义的能力这些目前...
继续阅读 »

在android中,网络模块是一个不可或缺的模块,相信很多公司都会有自建的网络库。目前市面上主流的网络请求框架都是基于okHttp做的延伸和扩展,并且android底层的网络库实现也使用OkHttp了,可见okHttp应用的广泛性。

Retrofit本身就是对于OkHttp库的封装,它的优点很很多,比如注解来实现的,配置简单,使用方便等。那为什么我们要做二次封装呢?最根本的原因还是我们现有的业务过于复杂,我们期望有更多的自定义的能力,有更好用的使用方式等。就好比下面这些自定义的能力

  1. 屏蔽底层的网络库实现

  2. 网络层统一处理code码和线程回调问题

  3. 网络请求绑定生命周期

  4. 网络层的全局监控

  5. 网络的调试能力

  6. 网络层对于组件化的通用能力支持

这些目前能力目前如果直接使用Retrofit,基本都是满足不了的。 本文是基于Retrofit + OkHttp提供的基础能力上,做的网络库的二次封装。主要介绍下如何在retrofit和Okhhtp的基础上,提供上述几个通用的能力。 本文需要有部分okHttp和retrofit源码的了解。 有兴趣的可以先查看官方文档,传送门:

屏蔽底层的网络库实现

虽然Retrofit是一个非常强大的封装框架,但是它并没有完全把网路库底层的实现的屏蔽掉。 默认的内部网络请求使用的okHttp,在我们创建Retrofit实例的时候,如果需要配置拦截器,就会直接依赖到底层的OkHttp,导致上层业务直接访问到了网络库的底层实现。这个对于后续的网络库底层的替换会是一个不小的成本。 因此,我们希望能够封装一层网络层,让业务的使用仅仅依赖到网络库的封装层,而不会使用到网络库的底层实现。 首先,我们需要先知道业务层当前使用到了哪些网络库底层的API, 其实最主要的还是拦截器这一层的封装。 拦截器这一层,主要涉及到几个类:

  1. Request

  2. Response

  3. Chain和Intercept 我们可以针对这几个类进行封装,定义对象接口,IRequest、IResponse、IChain和INetIntercept,这套接口不带任何具体实现。 然后在真正需要访问到具体的实例的时候,转化成具体的Request和Response等。我们可以看看在自己定义了一套拦截器之后,如何添加到之前OkHttp的流程中。 先看看IChain和INetIntercept的定义。

interface IChain {

   fun getRequestInfo(): IRequest

   @Throws(IOException::class)
   fun proceed(request: IRequest): IResponse?

}

interface INetInterceptor {
   @Throws(IOException::class)
   fun intercept(chain: IChain): IResponse?
}

在构造Retrofit的实例时,内部会尝试创建OkHttpClient,在此时把外部传入的INetInterceptor合并组装成一个OkHttp的拦截器,添加到OkHttpClient中。

 fun swicherToIntercept(list: MutableList<INetInterceptor>): Interceptor {
           return object: Interceptor {
               override fun intercept(chain: Interceptor.Chain): Response? {
                   val netRequest = IRequest(chain.request())
                   val realChain = IRealChain(0, netRequest, list as MutableList<IInterceptor>, chain, this)
                   val response: Response?
                   return (realChain.proceed(netRequest) as? IResponse)?.response
              }
          }
      }

整体修改后的拦截器的调用链如下所示:


上面举的只是在构建拦截器中的隔离,如果你们项目还有访问到其他内部的OkHttp的能力,也可以参照上面的封装流程,定义接口,在需要使用的地方转换为具体实现。

Retrofit的Call自定义

对于Retrofit,我们在接口中定义的方法就是每一个请求的配置,每一个请求都会被包装成Call。我们想要的请求做一些通用的逻辑处理和自定义,就比如在请求前做一些逻辑处理,请求后做一些逻辑处理,最后才返回给上层,就需要hook这个请求流程,可以做Retrofit的二次动态代理。 如果希望做一些更精细化的处理,hook能力就满足不了了。这种时候,可以选择使用自定义Call对象。如果整个Call对象都是我们提供的,我们当然可以在里面实现任何我们期望的逻辑。接下来简单介绍下如何自定义Retrofit的Call对象。

定义Call类型

class TestCall<T>(internal var call: Call<T>) {}

自定义CallAdapter

自定义CallAdapter时,需要使用我们前面自定义的返回值类型,并将call对象转化为我们我们自定义的返回值类型。

 class NetCallAdapter<R>(repsoneType: Type): CallAdapter<R, TestCall<R>> {
     override fun adapt(call: Call<R>): TestCall<R> {
       return TestCall(call)
  }
     override fun responseType(): Type {
       return responseType
  }
}
  1. 首先需要在class的继承关系上,显式的标明CallAdapter的第二个泛型参数是我们自定义的Call类型。

  2. 在adapt适配方法中,通过原始的call,转化为我们期望的TestCall。

自定义Factory

class NetCallAdapterFactory: CallAdapter.Factory() {
       override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*, *>? {
       val rawType = getRawType(returnType)
       if (rawType == TestCall::class.java && returnType is ParameterizedType) {
           val callReturnType = getParameterUpperBound(0, returnType)
           return NetCallAdapter<ParameterizedType>(callReturnType)
      }
       return null
  }
}

在自定义的Factory中,根据从接口定义中获取到的网络返回值,匹配TestCall类型,如果匹配上,就返回我们定义的CallAdapter。

注册Factory

val builder = Retrofit.Builder()
   .baseUrl(retrofitBuilder.baseUrl!!)
   .client(client)
   .addCallAdapterFactory(NetCallAdapterFactory())

网络层统一处理code码和线程回调问题

code码统一处理

相信每一个产品都会定义业务错误码,每一个业务都可能有自己的一套错误码,有一些错误码可能是全局的,比如说登录过期、被封禁等,这种错误码可能跟特定的接口无关,而是一个全局的业务错误码,在收到这些错误码时,会有统一的逻辑处理。 我们可以先定义code码解析的接口

interface ICodehandler {
   fun handle(context: Context?, code: Int, message: String?, isBackGround: Boolean): Boolean
}

code码处理器的注册。

code码处理器的注册方式有两种,一种是全局的code码处理器。 在创建Retrofit实例的传入。

NetWorkClientBuilder()
       .addNetCodeHandler(SocialCodeHandler())
       .build()

另一种是在具体的网络请求时,传入错误码处理器,

TestInterface.inst.testCall().backGround(true)
       .withInterceptor(new CodeRespHandler() {
           @Override
           public boolean handle(int code, @Nullable String message) {
                ....
          }
      })
       .enqueue(null)

code码处理的调用

因为Call是我们自定义的,我们可以在网络成功的返回时,优先执行错误码处理器,如果命中业务错误码,那么对外返回失败。否则正常返回成功。

线程回调

OkHttp的callback线程回调默认是在子线程,retrofit的回调线程取决于创建实例时的配置,可以配置callbackExecutor,这个是对整个实例生效的,在这个实例内,所有的网络返回都会通过callbackExecutor。我们希望能够针对每一个接口单独配置回调的线程,所以同样基于自定义call的前提下,我们自定义Callback和UiCallback。

  • Callback: 表示当前回调线程无需主线程

  • UICallback: 表示当前回调线程需要在主线程

通用业务传入的接口类型就标识了当前回调的线程.

网络请求绑定生命周期

大部分网络请求都是异步发起的。所以可能会导致下面两个问题:

  • 内存泄漏问题

  • 空指针问题

先看一个比较常见的内存泄漏的场景

class XXXFragment {

   var unBinder: Unbinder? = null
   
   @BindView(R.id.xxxx)
   val view: AView;
   
    @Override
   public void onDestroyView() {
       unBinder?.unbind();
  }
   
   override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
     val view= super.onCreateView(inflater, container, savedInstanceState)
     unBinder = ButterKnife.bind(this, view)
     loadDataOfPay(1, 20)
     return view
  }
   
   private void testFun() {
       TestInterface.getInst().getTestFun()
              .enqueue(new UICallback<TestResponse>() {
                   @Override
                   public void onSuccessful(TestResponse test) {
                       view.xxxx = test.xxx
                  }

                   @Override
                   public void onFailure(@NotNull NetException e) {
                      ....
                  }
              });
  }
}

在上面的例子中,在testFun方法中编译后会创建匿名内部类,并且显式的调用了外部Fragment的View,一旦这个网络请求阻塞了,或者晚于这个Fragment的销毁时机回调,就会导致这个Fragment出现内存泄漏,直至这个请求正常结束返回。

更严重的是,这个被操作的view是通过ButterKnife绑定的,在Fragment走到onDestory之后就进行了解绑,会将这个View的值设置为null,导致在这个callback的回调时候,可能出现view为null的情况,导致空指针。 对于空指针的问题,我们可以看到有很多网络请求的回调都可能会出现类似下面的代码段。

 TestInterface.getInst().getTestFun()
               .enqueue(new UICallback<TestResponse>() {
                   @Override
                   public void onSuccessful(TestResponse test) {
                     if(!isFinishing() && view != null) {
                         view.xxxx = test.xxx
                    }  
                  }});

在匿名内部类回调时,通过判断页面是否已经销毁,以及view是否为空,再进行对应的UI操作。 我们通过动态代理来解决了这个空指针和内存泄漏的问题。 详细的方案可以阅读下这个文章匿名内部类导致内存泄漏的解决方案 因为我们把Activity、Fragment抽象为UIContext。在网络接口调用时,传入对应的UIContext,会将网络请求的Callabck通过动态代理,将Callback和UIContext进行关联,在页面销毁时,不进行回调。

自动Cancel无用请求

很多的业务场景中,在页面一进去就会触发很多网络请求,这个请求可能有一部分处于网络库的请求等待队列中,一部分处于进行中。当我们退出了这个页面之后,这些网络请求其实都已经没有了存在的意义。 所以我们可以在页面销毁时,取消还未发起和进行中的网络请求。 我们可以通过上面提过的UIContext,将网络请求跟页面进行关联。监听页面的生命周期,在页面关闭时,cancel掉对应的网络请求。

页面关联

在网络请求发起前,把当前的网络请求关联上对应的页面。

class TestCall {
   fun  enqueue(uiCallBack: Callback, uiContext: UIContext?) {
         LifeCycleRequestManager.registerCall(this, uiContext)
    ....
  }
   
}

internal object LifeCycleRequestManager {

   init {
       registerApplicationLifecycle()
  }
   private val registerCallMap = ConcurrentHashMap<Int, MutableList<BaseNetCall>>()

  }

ConcurrentHashMap的key为页面的HashCode,value的请求list。每一个页面都会关联一个请求List。

cancel请求

通过Application监听Activity、Fragment的生命周期。在页面销毁时,调用cancel取消对应的网络请求。

  private fun registerActivityLifecycle(app: Application) {
       app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
           override fun onActivityDestroyed(activity: Activity?) {
               registerCallMap.remove(activity.hashCode())
          }})
  }

这个是针对Activity的生命周期的监听。对于Fragment的生命周期的监听其实和Activity类似。

    private fun registerActivityLifecycle(app: Application) {
       app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
           override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {
              (activity as? FragmentActivity)?.supportFragmentManager
                       ?.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, true)
          }})
  }

网络监听

网络模使用的场景非常多,当前出现问题的概率也更好,网络相关的问题非常多,比如网络异常、DNS解析失败、连接超时等。所以一套完善网络流程监控是非常有必要的,可以帮助我们在很多问题中快速分析出问题,提高我们的排查问题的效率

网络流程监控

根据OkHttp官方的EventListener提供的回调:OkHttpEvent事件,我们定义了以下几个一个网络请求中可能 触发的Action事件。

enum class NetEventType {
   EN_QUEUE, //入队
   NET_START, //网络请求真正开始执行
   DNS_START, //开始DNS解析
   DNS_END, //DNS解析结束
   CONNECT_START, //开始建立连接
   TLS_START, // TLS握手开始
   TLS_END, //TLS握手结束
   CONNECT_END, //建立连接结束
   RETRY, //尝试重新连接
   REUSE, //连接重用,从连接池中获取到连接
   CONNECTION_ACQUIRE, //获取到链接(可能不走连接建立,直接从连接池中获取)
   CONNECT_FAILED, // 连接失败
   REQUEST_HEADER_START, // request写Header开始
   REQUEST_HEADER_END, // request写Header结束
   REQUEST_BODY_START, // request写Body开始
   REQUEST_BODY_END, // request写Body结束
   RESPONSE_HEADER_START, // response写Header开始
   RESPONSE_HEADER_END, // response写Header结束
   RESPONSE_BODY_START, // response写Body开始
   RESPONSE_BODY_END, // response写Body结束
   FOLLOW_UP, // 是否发生重定向
   CALL_END, //请求正常结束
   CONNECTION_RELEASE, // 连接释放
   CALL_FAILED, // 请求失败
   NET_END, // 网络请求结束(包括正常结束和失败)

}

可以看到,除了okHttp原有的几个Event,还额外多了一个ENQUEUE事件。这个时机最主要的作用是计算出请求从调用到真正发起接口请求的等待时间。 当我们调用了RealCall.enqueue方法时,实际上这个接口请求并不是都会立即执行,OkHttp对于同一个时刻的请求数有限制。

  • 同一个Dispatcher,同一时刻并发数不能超过64

  • 同一个Host,同一时刻并发数不能超过5

 private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();
private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>();

  synchronized void enqueue(AsyncCall call) {
   if (runningAsyncCalls.size() < maxRequests && runningCallsForHost(call) < maxRequestsPerHost) {
     runningAsyncCalls.add(call);
     executorService().execute(call);
  } else {
     readyAsyncCalls.add(call);
  }
}

所以一旦超过对应的阈值,当次请求就会被添加到readyAsyncCalls中,等待被执行。

根据这几个Action,我们可以将统计的时间分为下面几个阶段

enum class NetRecordItemType {
   WAIT, // 等待时间,入队到真正开始执行耗时
   DNS, // DNS耗时
   TLS, // TLS耗时
   RequestHeader, // request写入Header耗时
   RequestBody, // request写入Body耗时
   Request, // request写入header和body总耗时
   NetworkLatency, // 网络请求延时
   ResponseHeader, // response写入Header耗时
   ResponseBody, // response写入Body耗时
   Response, // response写入header和body总耗时
   Connect, // 连接建立总耗时
   RequestAndResponse, // 数据传输耗时
   CallTime, // 单次网络请求总耗时(包含排队时间)
   UNKNOWN
}

唯一ID

我们不仅仅想对整个网络的大盘进行监控,我们还希望能够精细化到每一个独立的网络请求进行监控。针对单个网络请求进行的监控的难点是我们如何去标志出来每一个网络请求,因为EventListener回调只会返回对应的call。

public abstract class EventListener {
   public void callStart(Call call) {}
   
   public void callEnd(Call call) {}
}

而这个Call没有办法与单个监控的请求进行关联。 并且在网络请求发起的阶段就需要标识出来,所以需要在Request创建的最前头就生成这个唯一ID。通过阅读源码,我们发现可以生成唯一id最早时机是在OkHttp的RealCall创建的最前头。

  RealCall(OkHttpClient client, Request originalRequest, boolean forWebSocket) {
  final EventListener.Factory eventListenerFactory = client.eventListenerFactory();

  this.client = client;
  this.originalRequest = originalRequest;
  this.forWebSocket = forWebSocket;
  this.retryAndFollowUpInterceptor = new RetryAndFollowUpInterceptor(client, forWebSocket);

  this.eventListener = eventListenerFactory.create(this);
}

其中,eventListenerFactory是由外部传递到Okhttp中的。

 public Builder eventListenerFactory(EventListener.Factory eventListenerFactory) {
     if (eventListenerFactory == null) {
       throw new NullPointerException("eventListenerFactory == null");
    }
     this.eventListenerFactory = eventListenerFactory;
     return this;
  }

因此,我们可以在EventListener.Factory中生成标记request的唯一Id。

internal class CallEventFactory(var configuration: CallEventConfiguration?) : EventListener.Factory {
   companion object {
       private val nextCallId = AtomicLong(1L)
  }

   override fun create(call: Call): EventListener {
        val callId = nextCallId.getAndIncrement()
  }
}

那生成的callId如何与request进行关联呢?最直接的是给Request添加一个Header的key。Request本身没有提供Api去修改Header。 所以这个时候就需要通过反射来设置, 先获取当前的Header,然后给header新增这个CallId,最后通过反射设置到request的header字段上。

fun appendToHeader(request: Request?, key: String?, value: String?) {
   key ?: return
   request ?: return
   value ?: return
   val headerBuilder = request.headers().newBuilder().add(key, value)
   ReflectUtils.setFieldValue(Request::class.java, request, NetCallAdapter.HEADER_NAME, headerBuilder.build())
  }

需要注意的是,因为使用了反射,所以需要在proguard文件中keep住request。 当然这个key最好能够不带到服务端,所以需要新增一个拦截器,添加到所有拦截器最后,这个这个唯一id的key就不会被添加到真正的请求上了。

class NetLastInterceptor: Interceptor {
   companion object {
       const val TAG = "NetLastInterceptor"

  }
   override fun intercept(chain: Interceptor.Chain): Response {
       val request = chain.request()
       val requestBuilder = request
              .newBuilder()
              .removeHeader(NetConstants.CALL_ID)
     
       return chain.proceed(requestBuilder.build())
  }
}

监控

在生成完唯一Id之后,我们再来看看如何外部是如何添加期望的网络监控的。

基于Client的监控

networkClient = NetWorkClientBuilder()
  .addLifecycleListener("*", object : INetLifecycleListener {
       override fun onLifecycle(info: INetLifecycleInfo) { }})
  .registerEventListener("xxxUrl", NetEventType.CALL_END, object : INetEventListener {
       override fun onEvent(event: NetEventType, request: NetRequest) { }})
      .build()

基于单个请求的监控

   TestInterface.inst.testFun()
          .addLifeCycleListener(object : INetLifecycleListener {
               override fun onLifecycle(info: INetLifecycleInfo) {} })
          .registerEventListener(mutableListOf(NetEventType.CALL_END, NetEventType.NET_START), object : INetEventListener {
               override fun onEvent(event: NetEventType, request: NetRequest) {} })
          .enqueue(null)

在创建EventListener时,按照下面的规则添加。

  1. 添加网络库系统的内部监听

  2. 添加OkHttpClient初始化配置的监听

  3. 添加单个请求配置的监听

基于单个请求的网络监控,需要提前把这个Request和网络监听的listener的关联关系存起来,因为EventListener的设置是针对整个OkHttpClient的生效的,所以需要在EventListener处理的过程中,获取当前的Request设置进去的listener。


续 做一个具有高可用性的网络库(下) 

作者:谢谢谢_xie
来源:juejin.cn/post/7074493841956405278

收起阅读 »

百度 Android 直播秒开体验优化

导读网络直播功能作为一项互联网基本能力已经越来越重要,手机中的直播功能也越来越完善,电商直播、新闻直播、娱乐直播等多种直播类型为用户提供了丰富的直播内容。随着直播的普及,为用户提供极速、流畅的直播观看体验也越来越重要。全文6657字,预计阅读时间17分钟。01...
继续阅读 »

导读

网络直播功能作为一项互联网基本能力已经越来越重要,手机中的直播功能也越来越完善,电商直播、新闻直播、娱乐直播等多种直播类型为用户提供了丰富的直播内容。随着直播的普及,为用户提供极速、流畅的直播观看体验也越来越重要。

全文6657字,预计阅读时间17分钟。

01 背景

百度 APP 作为百度的航母级应用为用户提供了完善的移动端服务,直播也作为其中一个必要功能为用户提供内容。随着直播间架构、业务能力逐渐成熟,直播间播放指标优化也越来越重要。用户点击直播资源时,可以快速的看到直播画面是其中一个核心体验,起播速度也就成了直播间优化中的一个关键指标。

02 现状

由于包体积等原因,百度 APP 的 Android 版中直播功能使用插件方式接入,在用户真正使用直播功能时才会将直播模块加载。为解决用户点击直播功能时需要等待插件下载、安装、加载等阶段及兼容插件下载失败的情况,直播团队将播放、IM 等核心能力抽到了一个独立的体积较小的一级插件并内置在百度 APP 中,直播间的挂件、礼物、关注、点赞等业务能力在另外一个体积较大的二级插件中。特殊的插件逻辑和复杂的业务场景使得 Android 版整体起播时长指标表现的不尽人意。

2022 年 Q1 直播间整体起播时长指标 80 分位在 3s 左右,其中二跳(直播间内上下滑)场景在 1s 左右,插件拆分上线后通过观察起播数据发现随着版本收敛,一跳进入直播间携带流地址(页面启动后会使用该地址预起播,与直播列表加载同步执行)场景起播时有明显的增长,从发版本初期 1.5s 左右,随版本收敛两周内会逐步增长到 2.5s+。也就是线上在直播间外点击直播资源进直播间时有很大一部分用户在点击后还需要等待 3s 甚至更长时间才能真正看到直播画面。这个时长对用户使用直播功能有非常大的负向影响,起播时长指标急需优化。

03 目标

△起播链路

起播过程简单描述就是用户点击直播资源,打开直播页面,请求起播地址,调用内核起播,内核起播完成,内核通知业务,业务起播完成打点。从对内核起播时长监控来看,直播资源的在内核中起播耗时大约为 600-700ms,考虑链路中其他阶段损耗以及二跳(直播间内上下滑)场景可以在滑动时提前起播,整体起播时长目标定位为1.5 秒;考虑到有些进入直播间的位置已经有了起播流地址,可以在某些场景省去 “请求起播地址” 这一个阶段,在这种直播间外已经获取到起播地址场景,起播时长目标定为 1.1 秒。

04 难点

特殊的插件逻辑和复杂的业务场景使得 Android 版每一次进入直播的起播链路都不会完全一样。只有一级插件且二级插件还未就绪时在一级插件中请求直播数据并起播,一二级插件都已加载时使用二级插件请求直播数据并处理起播,进直播间携带流地址时为实现秒开在 Activity 启动后就创建播放器使用直播间外携带的流地址起播。除了这几种链路,还有一些其他情况。复杂的起播链路就导致了,虽然在起播过程中主要节点间都有时间戳打点,也有天级别相邻两个节点耗时 80 分位报表,但线上不同场景上报的起播链路无法穷举,使用现有报表无法分析直播大盘起播链路中真正耗时位置。需要建立新的监控方案,找到耗时点,才能设计针对性方案将各个耗时位置进行优化。

05 解决方案

5.1 设计新报表,定位耗时点

△一跳有起播地址时起播链路简图

由于现有报表无法满足起播链路耗时阶段定位,需要设计新的监控方案。观察在打开直播间时有流地址场景的流程图(上图),进入直播间后就会同步创建直播间列表及创建播放器预起播,当直播间列表创建完毕且播放器收到首帧通知时起播流程结束。虽然用户点击到页面 Activity 的 onCreate 中可能有多个节点(一级插件安装、加载等),页面 onCreate 调用播放器预起播中可能多个节点,内核完成到直播业务收到通知中有多个节点,导致整个起播链路无法穷举。但是我们可以发现,从用户点击到 onCreate 这个路径是肯定会有的,onCreate 到创建播放器路径也是肯定有的。这样就说明虽然两个关键节点间的节点数量和链路无法确定,但是两个关键节点的先后顺序是一定的,也是必定会有的。由此,我们可以设计一个自定义链路起点和自定义链路终点的查询报表,通过终点和起点时间戳求差得到两个任意节点间耗时,将线上这两个节点所有差值求 80 分位,就可以得到线上起播耗时中这两个节点间耗时。将起播链路中所有核心关键节点计算耗时,就可以找到整个起播链路中有异常耗时的分段。

按照上面的思路开发新报表后,上面的链路各阶段耗时也就比较清晰了,见下图,这样我们就可以针对不同阶段逐个击破。

△关键节点间耗时

5.2 一跳使用一级插件起播

使用新报表统计的重点节点间耗时观察到,直播间列表创建(模版组件创建)到真正调用起播(业务视图就绪)中间耗时较长,且这个耗时随着版本收敛会逐步增加,两周内大约增加 1000ms,首先我们解决这两个节点间耗时增加问题。

经过起播链路观察和分析后,发现随版本收敛,这部分起播链路有较大变化,主要是因为随版本收敛,在二级插件中触发 “业务调用起播” 这个节点的占比增加。版本收敛期,进入直播间时大概率二级插件还未下载就绪或未安装,此时一级插件中可以很快的进行列表创建并创建业务视图,一级插件中在 RecyclerView 的 item attach 到视图树时就会触发起播,这个链路主要是等待内核完成首帧数据的拉取和解析。当二级插件逐渐收敛,进入直播间后一级插件就不再创建业务视图,而是有二级插件创建业务视图。由于二级插件中业务组件较多逐个加载需要耗时还有一级到二级中逐层调用或事件分发也存在一定耗时,这样二级插件起播场景就大大增加了直播间列表创建(模版组件创建)到真正调用起播(业务视图就绪)中间耗时。

5.2.1 一跳全部使用一级插件起播

基于上面的问题分析,我们修改了一跳场景起播逻辑,一跳全部使用一级插件起播。一级插件和二级插件创建的播放器父容器 id 是相同的,这样在一级插件中初始化播放器父容器后,当内核首帧回调时起播过程就可以结束了。二级插件中在初始化播放器父容器时也会通过 id 判断是否已经添加到视图树,只有在未添加的情况(二跳场景或一跳时出现异常)才会在二级中进行兜底处理。在一级插件中处理时速度可以更快,一级优先二级兜底逻辑保证了进入直播间后一定可以顺利初始化视图。

5.2.2 提前请求接口

使用由一起插件处理起播优化了二级插件链路层级较多问题,还有一个耗时点就是进直播间时只传入了房间 room_id 未携带流地址场景,此时需要通过接口请求获取起播数据后才能创建播放器和起播。为优化这部分耗时,我们设计了一个直播间数据请求管理器,提供了缓存数据和超时清理逻辑。在页面 onCreate 时就会触发管理器进行接口请求,直播间模版创建完成后会通过管理器获取已经请求到的直播数据,如果管理器接口请求还未结束,则会复用进行中请求,待请求结束后立刻返回数据。这样在进直播间未携带流数据时我们可以充分利用图中这 300ms 时间做更多必要的逻辑。


5.3 播放器Activity外预起播

通过进直播间播放器预创建、预起播、一跳使用一级插件起播等方案来优化进入直播间业务链路耗时后,业务链路耗时逐渐低于内核部分耗时,播放器内核耗时逐渐成为一跳起播耗时优化瓶颈。除了在内核内部探索优化方案,继续优化业务整个起播链路也是一个重要方向。通过节点间耗时可以发现,用户点击到 Activity 页面 onCrete 中间也是有 300ms 左右耗时的。当无法将这部分耗时缩到更短时,我们可以尝试在这段时间并行处理一些事情,减少页面启动后的部分逻辑。

一级插件在百度 APP 中内置后,设计并上线了插件预加载功能,上线后用户通过点击直播资源进入直播间的场景中,有 99%+ 占比都是直播一级插件已加载情况,一级插件加载这里就没有了更多可以的操作空间。但将预起播时机提前到用户点击处,可以将内核数据加载和直播间启动更大程度并行,这样来降低内核耗时对整个起播耗时影响。
△播放器在直播间外起播示意图

如上图,新增一个提前起播模块,在用户点击后与页面启动并行创建播放器起播并缓存,页面启动后创建播放器时会先从提前起播模块的缓存中尝试取已起播播放器,如果未获取到则走正常播放器创建起播逻辑,如果获取到缓存的播放器且播放器未发生错误,则只需要等待内核首帧即可。

播放器提前起播后首帧事件大概率在 Activity 启动后到达,但仍有几率会早于直播业务中设置首帧监听前到达,所以在直播间中使用复用内核的播放器时需要判断是否起播成功,如果已经起播成功需要马上分发已起播成功事件(含义区别于首帧事件,防止与首帧事件混淆)。

提前起播模块中还设计了超时回收逻辑,如果提前起播失败或 5s (暂定)内没有被业务复用(Activity 启动异常或其他业务异常),则主动回收缓存的播放器,防止直播间没有复用成功时提前创建的播放器占用较多内存及避免泄漏;超时时间是根据线上大盘起播时间决定,使用一个较大盘起播时间 80 分位稍高的值,防止起播还未完成时被回收,但也不能设置较长,防止不会被复用时内存占用较多。

通过提前起播功能,实验期命中提前起播逻辑较不进行提前起播逻辑,整体起播耗时 80 分位优化均值:450ms+。

5.4直播间任务打散

△内核首帧分发耗时

业务链路和内核链路耗时都有一定优化后,我们继续拆解重点节点间耗时。内核内部标记首帧通知到直播业务真正收到首帧通知之间耗时较长,如上图,线上内核首帧分发耗时 80 分位均值超过 1s,该分段对整体起播耗时优化影响较大。内核首帧是在子线程进行标记,通知业务时会通过主线程 Handler 分发消息,通过系统的消息分发机制将事件转到主线程。

通过排查内核标记首帧时间点到业务收到首帧通知事件时间点之间所有主线程任务,发现在首帧分发任务开始排队时,主线程任务队列中已有较多其他任务,其他事件处理时间较长,导致首帧分发排队时间较久,分发任务整体耗时也就较长。直播业务复杂度较高,如果内核首帧分发任务排队时直播间其他任务已在队列中或正在执行,首帧分发任务需要等直播任务执行完成后才能执行。

通过将直播间启动过程中所有主线程任务进行筛查,发现二级插件的中业务功能较多,整体加载任务执行时间较长,为验证线上也是由于二级业务任务阻塞了首帧分发任务,我们设计了一个二级组件加载需要等待内核首帧后才能进行的实验,通过实验组与对照组数据对比,在命中实验时首帧分发耗时和起播整体耗时全部都有明显下降,整体耗时有 500ms 左右优化。

通过实验验证及本地对起播阶段业务逻辑分析,定位到直播间各业务组件及对应视图的预加载数量较多且耗时比较明显,这个功能是二级插件为充分利用直播间接口数据返回前时间,二级插件加载后会与接口请求并行提前创建业务视图,提起初始化组件及视图为接口完成后组件渲染节省时间。如果不预创建,接口数据回来后初始化业务组件也会主动创建后设置数据。但将所有预创建任务全部串行执行耗时较长,会阻塞主线程,页面一帧中执行太多任务,也会造成页面明显卡顿。

发现这个阻塞问题后,我们设计了将预创建视图任务进行拆分打散,将一起执行的大任务拆分成多个小任务,每个组件的初始化都作为一个单独任务在主线程任务队列中进行排队等待执行。避免了一个大任务耗时特别长的问题。该功能上线后,整个二级插件中的组件加载大任务耗时降低了 40%+。

5.5 内核子线程分发首帧

由于主线程消息队列中任务是排队执行的,将阻塞首帧分发事件的大任务拆分成较多小任务后,还是无法解决首帧事件开始排队时这些小任务已经在主线程任务队列中排队问题。除了降低直播业务影响,还可以通过加快内核任务分发速度,使首帧分发耗时降低。需要设计一个在不影响内核稳定性与业务逻辑情况下内核首帧事件如何避免主线程排队或快速排队后被执行的方案。

为解决上面的问题, 我们推动内核,单独增加了一个子线程通知业务首帧事件能力。业务收到子线程中首帧回调后通过 Handler 的 postAtFrontOfQueue() 方法将一个新任务插到主线程任务队列最前面,这样主线程处理完当前任务后就可以马上处理我们新建的这个任务,在这个新任务中可以马上处理播放器上屏逻辑。无需等待播放内核原本的主线程消息。

主线程任务前插无法打断新任务排队时主线程中已经开始执行的任务,需要正在执行任务结束后才会被执行。为优化这个场景,内核通过子线程通知首帧后,播放器中需要记录这个状态,在一级插件及二级插件中的直播间业务任务执行开始前后,增加判断播放器中是否已经收到首帧逻辑,如果已经收到,就可以先处理上屏后再继续当前任务。

通过直播内核首帧消息在主线程任务队列前插和业务关键节点增加是否可上屏判断,就可以较快处理首帧通知,降低首帧分发对起播时长影响。

5.6 起播与完载指标平衡

直播间起播优化过程中,完载时长指标(完载时长:用户点击到直播间核心功能全部出现的时间,其中经历页面启动,直播间列表创建,二级插件下载、安装、加载,直播间接口数据请求,初始化直播间功能组件视图及渲染数据,核心业务组件显示等阶段)的优化也在持续进行。直播间二级插件是在使用二级插件中的功能时才会触发下载安装及加载逻辑,完载链路中也注意到了用户点击到页面 onCreate 这段耗时,见下图。

△页面启动耗时示意图

为优化直播间完载指标,直播团队考虑如果将插件加载与页面启动并行,那么完载耗时也会有一定的优化。直播团队继续设计了二级插件预加载方案,将二级插件加载位置提前到了用户点击的时候(该功能上线在 5.4、5.5 章节对应功能前)。该功能上线后试验组与对照组数据显示,实验组完载耗时较对照组确实有 300ms+ 优化。但起播耗时却出现了异常,实验组的起播耗时明显比对照组增长了 500ms+,且随版本收敛这个起播劣化还在增加。我们马上很快发现了这个异常,并通过数据分析确定了这个数据是正确的。完载的优化时如何引起起播变化的?

经过数据分析,我们发现起播受影响的主要位置还是内核首帧消息分发到主线程这个分段引起,也就是二级插件加载越早,内核首帧分发与二级组件加载时的耗时任务冲突可能性越大。确认问题原因后,我们做了 5.4、5.5 章节的功能来降低二级组件加载任务对起播影响。由于二级插件中的耗时任务完全拆分打散来缓解二级插件预下载带来的起播劣化方案复杂度较高,对直播间逻辑侵入太大,二级插件提前加载没有完全上线,完载的优化我们设计了其他方案来实现目标。

虽然不能在进入直播间时直接加载二级插件,但我们可以在进入直播间前尽量将二级插件下载下来,使用时直接加载即可,这个耗时相对下载耗时是非常小的。我们优化了插件预下载模块,在直播间外展示直播资源时触发该模块预下载插件。该模块会通过对当前设备网络、带宽、下载频次等条件综合判断,在合适的时机将匹配的二级插件进行下载,插件提前下载后对完载指标有较大优化。除了插件预下载,直播间内通过 5.4 章节直播间二级组件初始化拆分,也将全部组件初始化对主线程阻塞进行了优化,这样接口数据请求成功后可以优先处理影响完载统计的组件,其他组件可以在完载结束后再进行初始化,这个方案也对直播完载指标有明显优化。

除了以上两个优化方案,直播团队还在其他多个方向对完载指标进行了优化,同时也处理了完载时长与起播时长的指标平衡,没有因为一个指标优化而对其他指标造成劣化影响。最终实现了起播、完载指标全部达到目标。

06 收益

△2022 Android 端起播耗时走势

经过以上多个优化方案逐步迭代,目前 Android 端最新版本数据,大盘起播时间已经由 3s+ 降到 1.3s 左右;一跳带流地址时起播时长由 2.5s+ 左右降低到 1s 以内;二跳起播时长由 1s+ 降低到 700ms 以内,成功完成了预定目标。

07 展望

起播时长作为直播功能一个核心指标,还需要不断打磨和优化。除了业务架构上的优化,还有优化拉流协议、优化缓冲配置、自适应网速起播、优化 gop 配置、边缘节点加速等多个方向可以探索。百度直播团队也会持续深耕直播技术,为用户带来越来越好的直播体验。

作者:任雪龙
来源:百度Geek说 juejin.cn/post/7174596046641692709

收起阅读 »

Android Jetpack:利用Palette进行图片取色

与产品MM那些事新来一个产品MM,因为比较平,我们就叫她A妹吧。A妹来第一天就指出:页面顶部的Banner广告位的背景是白色的,太单调啦,人家不喜欢啦,需要根据广告图片的内容自动切换背景颜色,颜色要与广告图主色调一致。作为一名合格的码农我直接回绝了,我说咱们的...
继续阅读 »

与产品MM那些事

新来一个产品MM,因为比较平,我们就叫她A妹吧。A妹来第一天就指出:页面顶部的Banner广告位的背景是白色的,太单调啦,人家不喜欢啦,需要根据广告图片的内容自动切换背景颜色,颜色要与广告图主色调一致。作为一名合格的码农我直接回绝了,我说咱们的应用主打简洁,整这花里胡哨的干嘛,劳民伤财。A妹也没放弃,与我深入交流了一夜成功说服了我。

其实要实现这个需求也不难,Google已经为我们提供了一个方便的工具————Palette。

前言

Palette即调色板这个功能其实很早就发布了,Jetpack同样将这个功能也纳入其中,想要使用这个功能,需要先依赖库

implementation 'androidx.palette:palette:1.0.0'

本篇文章就来讲解一下如何使用Palette在图片中提取颜色。

创建Palette

创建Palette其实很简单,如下

var builder = Palette.from(bitmap)
var palette = builder.generate()

这样,我们就通过一个Bitmap创建一个Pallete对象。

注意:直接使用Palette.generate(bitmap)也可以,但是这个方法已经不推荐使用了,网上很多老文章中依然使用这种方式。建议还是使用Palette.Builder这种方式。

generate()这个函数是同步的,当然考虑图片处理可能比较耗时,Android同时提供了异步函数

public AsyncTask<BitmapVoidPalette> generate(
       @NonNull final PaletteAsyncListener listener) {

通过一个PaletteAsyncListener来获取Palette实例,这个接口如下:

public interface PaletteAsyncListener {
   /**
    * Called when the {@link Palette} has been generated. {@code null} will be passed when an
    * error occurred during generation.
    */
   void onGenerated(@Nullable Palette palette);
}

提取颜色

有了Palette实例,就可以通过Palette对象的相应函数就可以获取图片中的颜色,而且不只一种颜色,下面一一列举:

  • getDominantColor:获取图片中的主色调

  • getMutedColor:获取图片中柔和的颜色

  • getDarkMutedColor:获取图片中柔和的暗色

  • getLightMutedColor:获取图片中柔和的亮色

  • getVibrantColor:获取图片中有活力的颜色

  • getDarkVibrantColor:获取图片中有活力的暗色

  • getLightVibrantColor:获取图片中有活力的亮色

这些函数都需要提供一个默认颜色,如果这个颜色Swatch无效则使用这个默认颜色。光这么说不直观,我们来测试一下,代码如下:

var bitmap = BitmapFactory.decodeResource(resourcesR.mipmap.a)
var builder = Palette.from(bitmap)
var palette = builder.generate()
color0.setBackgroundColor(palette.getDominantColor(Color.WHITE))
color1.setBackgroundColor(palette.getMutedColor(Color.WHITE))
color2.setBackgroundColor(palette.getDarkMutedColor(Color.WHITE))
color3.setBackgroundColor(palette.getLightMutedColor(Color.WHITE))
color4.setBackgroundColor(palette.getVibrantColor(Color.WHITE))
color5.setBackgroundColor(palette.getDarkVibrantColor(Color.WHITE))
color6.setBackgroundColor(palette.getLightVibrantColor(Color.WHITE))

运行后结果如下:


这样各个颜色的差别就一目了然。除了上面的函数,还可以使用getColorForTarget这个函数,如下:

@ColorInt
public int getColorForTarget(@NonNull final Target target@ColorInt final int defaultColor) {

这个函数需要一个Target,提供了6个静态字段,如下:

/**
* A target which has the characteristics of a vibrant color which is light in luminance.
*/
public static final Target LIGHT_VIBRANT;

/**
* A target which has the characteristics of a vibrant color which is neither light or dark.
*/
public static final Target VIBRANT;

/**
* A target which has the characteristics of a vibrant color which is dark in luminance.
*/
public static final Target DARK_VIBRANT;

/**
* A target which has the characteristics of a muted color which is light in luminance.
*/
public static final Target LIGHT_MUTED;

/**
* A target which has the characteristics of a muted color which is neither light or dark.
*/
public static final Target MUTED;

/**
* A target which has the characteristics of a muted color which is dark in luminance.
*/
public static final Target DARK_MUTED;

其实就是对应着上面除了主色调之外的六种颜色。

文字颜色自动适配

在上面的运行结果中可以看到,每个颜色上面的文字都很清楚的显示,而且它们并不是同一种颜色。其实这也是Palette提供的功能。

通过下面的函数,我们可以得到各种色调所对应的Swatch对象:

  • getDominantSwatch

  • getMutedSwatch

  • getDarkMutedSwatch

  • getLightMutedSwatch

  • getVibrantSwatch

  • getDarkVibrantSwatch

  • getLightVibrantSwatch

注意:同上面一样,也可以通过getSwatchForTarget(@NonNull final Target target)来获取

Swatch类提供了以下函数:

  • getPopulation(): 样本中的像素数量

  • getRgb(): 颜色的RBG值

  • getHsl(): 颜色的HSL值

  • getBodyTextColor(): 能都适配这个Swatch的主体文字的颜色值

  • getTitleTextColor(): 能都适配这个Swatch的标题文字的颜色值

所以我们通过getBodyTextColor()getTitleTextColor()可以很容易得到在这个颜色上可以很好现实的标题和主体文本颜色。所以上面的测试代码完整如下:

var bitmap = BitmapFactory.decodeResource(resourcesR.mipmap.a)
var builder = Palette.from(bitmap)
var palette = builder.generate()

color0.setBackgroundColor(palette.getDominantColor(Color.WHITE))
color0.setTextColor(palette.dominantSwatch?.bodyTextColor ?Color.WHITE)

color1.setBackgroundColor(palette.getMutedColor(Color.WHITE))
color1.setTextColor(palette.mutedSwatch?.bodyTextColor ?Color.WHITE)

color2.setBackgroundColor(palette.getDarkMutedColor(Color.WHITE))
color2.setTextColor(palette.darkMutedSwatch?.bodyTextColor ?Color.WHITE)

color3.setBackgroundColor(palette.getLightMutedColor(Color.WHITE))
color3.setTextColor(palette.lightMutedSwatch?.bodyTextColor ?Color.WHITE)

color4.setBackgroundColor(palette.getVibrantColor(Color.WHITE))
color4.setTextColor(palette.vibrantSwatch?.bodyTextColor ?Color.WHITE)

color5.setBackgroundColor(palette.getDarkVibrantColor(Color.WHITE))
color5.setTextColor(palette.darkVibrantSwatch?.bodyTextColor ?Color.WHITE)

color6.setBackgroundColor(palette.getLightVibrantColor(Color.WHITE))
color6.setTextColor(palette.lightVibrantSwatch?.bodyTextColor ?Color.WHITE)

这样每个颜色上的文字都可以清晰的显示。

那么这个标题和主体文本颜色有什么差别,他们又是如何的到的?我们来看看源码:

/**
* Returns an appropriate color to use for any 'title' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getTitleTextColor() {
   ensureTextColorsGenerated();
   return mTitleTextColor;
}

/**
* Returns an appropriate color to use for any 'body' text which is displayed over this
* {@link Swatch}'s color. This color is guaranteed to have sufficient contrast.
*/
@ColorInt
public int getBodyTextColor() {
   ensureTextColorsGenerated();
   return mBodyTextColor;
}

可以看到都会先执行ensureTextColorsGenerated(),它的源码如下:

private void ensureTextColorsGenerated() {
   if (!mGeneratedTextColors) {
       // First check white, as most colors will be dark
       final int lightBodyAlpha = ColorUtils.calculateMinimumAlpha(
               Color.WHITEmRgbMIN_CONTRAST_BODY_TEXT);
       final int lightTitleAlpha = ColorUtils.calculateMinimumAlpha(
               Color.WHITEmRgbMIN_CONTRAST_TITLE_TEXT);

       if (lightBodyAlpha != -1 && lightTitleAlpha != -1) {
           // If we found valid light values, use them and return
           mBodyTextColor = ColorUtils.setAlphaComponent(Color.WHITElightBodyAlpha);
           mTitleTextColor = ColorUtils.setAlphaComponent(Color.WHITElightTitleAlpha);
           mGeneratedTextColors = true;
           return;
      }

       final int darkBodyAlpha = ColorUtils.calculateMinimumAlpha(
               Color.BLACKmRgbMIN_CONTRAST_BODY_TEXT);
       final int darkTitleAlpha = ColorUtils.calculateMinimumAlpha(
               Color.BLACKmRgbMIN_CONTRAST_TITLE_TEXT);

       if (darkBodyAlpha != -1 && darkTitleAlpha != -1) {
           // If we found valid dark values, use them and return
           mBodyTextColor = ColorUtils.setAlphaComponent(Color.BLACKdarkBodyAlpha);
           mTitleTextColor = ColorUtils.setAlphaComponent(Color.BLACKdarkTitleAlpha);
           mGeneratedTextColors = true;
           return;
      }

       // If we reach here then we can not find title and body values which use the same
       // lightness, we need to use mismatched values
       mBodyTextColor = lightBodyAlpha != -1
               ? ColorUtils.setAlphaComponent(Color.WHITElightBodyAlpha)
              : ColorUtils.setAlphaComponent(Color.BLACKdarkBodyAlpha);
       mTitleTextColor = lightTitleAlpha != -1
               ? ColorUtils.setAlphaComponent(Color.WHITElightTitleAlpha)
              : ColorUtils.setAlphaComponent(Color.BLACKdarkTitleAlpha);
       mGeneratedTextColors = true;
  }
}

通过代码可以看到,这两种文本颜色实际上要么是白色要么是黑色,只是透明度Alpha不同。

这里面有一个关键函数,即ColorUtils.calculateMinimumAlpha()

public static int calculateMinimumAlpha(@ColorInt int foreground@ColorInt int background,
       float minContrastRatio) {
   if (Color.alpha(background!= 255) {
       throw new IllegalArgumentException("background can not be translucent: #"
               + Integer.toHexString(background));
  }

   // First lets check that a fully opaque foreground has sufficient contrast
   int testForeground = setAlphaComponent(foreground255);
   double testRatio = calculateContrast(testForegroundbackground);
   if (testRatio < minContrastRatio) {
       // Fully opaque foreground does not have sufficient contrast, return error
       return -1;
  }

   // Binary search to find a value with the minimum value which provides sufficient contrast
   int numIterations = 0;
   int minAlpha = 0;
   int maxAlpha = 255;

   while (numIterations <= MIN_ALPHA_SEARCH_MAX_ITERATIONS &&
          (maxAlpha - minAlpha> MIN_ALPHA_SEARCH_PRECISION) {
       final int testAlpha = (minAlpha + maxAlpha/ 2;

       testForeground = setAlphaComponent(foregroundtestAlpha);
       testRatio = calculateContrast(testForegroundbackground);

       if (testRatio < minContrastRatio) {
           minAlpha = testAlpha;
      } else {
           maxAlpha = testAlpha;
      }

       numIterations++;
  }

   // Conservatively return the max of the range of possible alphas, which is known to pass.
   return maxAlpha;
}

它根据背景色和前景色计算前景色最合适的Alpha。这期间如果小于minContrastRatio则返回-1,说明这个前景色不合适。而标题和主体文本的差别就是这个minContrastRatio不同而已。

回到ensureTextColorsGenerated代码可以看到,先根据当前色调,计算出白色前景色的Alpha,如果两个Alpha都不是-1,就返回对应颜色;否则计算黑色前景色的Alpha,如果都不是-1,返回对应颜色;否则标题和主体文本一个用白色一个用黑色,返回对应颜色即可。

更多功能

上面我们创建Palette时先通过Palette.from(bitmap)的到了一个Palette.Builder对象,通过这个builder可以实现更多功能,比如:

  • addFilter:增加一个过滤器

  • setRegion:设置图片上的提取区域

  • maximumColorCount:调色板的最大颜色数 等等

总结

通过上面我们看到,Palette的功能很强大,但是它使用起来非常简单,可以让我们很方便的提取图片中的颜色,并且适配合适的文字颜色。同时注意因为ColorUtils是public的,所以当我们需要文字自动适配颜色的情况时,也可以通过ColorUtils的几个函数自己实现计算动态颜色的方案。

作者:BennuCTech
来源:juejin.cn/post/7077380907333582879

收起阅读 »

炸裂的点赞动画

前言之前偶然间看到某APP点赞有个炸裂的效果,觉得有点意思,就尝试了下,轻微还原,效果图如下封装粒子从动画效果中我们可以看到,当动画开始的时候,会有一组粒子从四面八方散射出去,然后逐渐消失,于是可以定义一个粒子类包含以下属性public class Parti...
继续阅读 »

前言

之前偶然间看到某APP点赞有个炸裂的效果,觉得有点意思,就尝试了下,轻微还原,效果图如下


封装粒子

从动画效果中我们可以看到,当动画开始的时候,会有一组粒子从四面八方散射出去,然后逐渐消失,于是可以定义一个粒子类包含以下属性

public class Particle {
  public float x, y;
  public float startXV;
  public float startYV;
  public float angle;
  public float alpha;
  public Bitmap bitmap;
  public int width, height;
}
  • x,y是粒子的位置信息

  • startXV,startYV是X方向和Y方向的速度

  • angle是发散出去的角度

  • alpha是粒子的透明度

  • bitmap, width, height即粒子图片信息 我们在构造函数中初始化这些信息,给定一些默认值

public Particle(Bitmap originalBitmap) {
  alpha = 1;
  float scale = (float) Math.random() * 0.3f + 0.7f;
  width = (int) (originalBitmap.getWidth() * scale);
  height = (int) (originalBitmap.getHeight() * scale);
  bitmap = Bitmap.createScaledBitmap(originalBitmap, width, height, true);

  startXV = new Random().nextInt(150) * (new Random().nextBoolean() ? 1 : -1);
  startYV = new Random().nextInt(170) * (new Random().nextBoolean() ? 1 : -1);
  int i = new Random().nextInt(360);
  angle = (float) (i * Math.PI / 180);

  float rotate = (float) Math.random() * 180 - 90;
  Matrix matrix = new Matrix();
  matrix.setRotate(rotate);
  bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
  originalBitmap.recycle();
}

仔细看效果动画,会发现同一个图片每次出来的旋转角度会有不同,于是,在创建bitmap的时候我们随机旋转下图片。

绘制粒子

有了粒子之后,我们需要将其绘制在View上,定义一个ParticleView,重写onDraw()方法,完成绘制

public class ParticleView extends View {
   Paint paint;
   List<Particle> particles = new ArrayList<>();
   //.....省略构造函数
   @Override
   protected void onDraw(Canvas canvas) {
       super.onDraw(canvas);
       for (Particle particle : particles) {
           paint.setAlpha((int) (particle.alpha * 255));
           canvas.drawBitmap(particle.bitmap, particle.x - particle.width / 2, particle.y - particle.height / 2, paint);
      }
  }
   public void setParticles(List<Particle> particles) {
       this.particles = particles;
  }
}

管理粒子

绘制的时候我们发现需要不断改变粒子的x,y值,才能使它动起来,所以我们需要一个ValueAnimator,然后通过监听动画执行情况,不断绘制粒子。

private void startAnimator(View emiter) {
  ValueAnimator valueAnimator = ObjectAnimator.ofInt(0, 1).setDuration(1000);
  valueAnimator.addUpdateListener(animation -> {
      for (Particle particle : particles) {
          particle.alpha = 1 - animation.getAnimatedFraction();
          float time = animation.getAnimatedFraction();
          time *= 10;
          particle.x = startX - (float) (particle.startXV * time * Math.cos(particle.angle));
          particle.y = startY - (float) (particle.startYV * time * Math.sin(particle.angle) - 9.8 * time * time / 2);
      }
      particleView.invalidate();
  });
  valueAnimator.start();
}

由于我们的点赞按钮经常出现在RecyclerView的item里面,而点赞动画又是全屏的,所以不可能写在item的xml文件里面,而且我们需要做到0侵入,在不改变原来的逻辑下添加动画效果。

我们可以通过activity.findViewById(android.R.id.content)获取FrameLayout然后向他添加子View

public ParticleManager(Activity activity, int[] drawableIds) {
  particles = new ArrayList<>();
  for (int drawableId : drawableIds) {
      particles.add(new Particle(BitmapFactory.decodeResource(activity.getResources(), drawableId)));
  }
  topView = activity.findViewById(android.R.id.content);
  topView.getLocationInWindow(parentLocation);
}

首先我们通过构造函数传入当前Activity以及我们需要的图片资源,然后将图片资源都解析成Particle对象,保存在particles中,然后获取topView以及他的位置信息。

然后需要知道动画从什么位置开始,传入一个view作为锚点

public void start(View emiter) {
  int[] location = new int[2];
  emiter.getLocationInWindow(location);
  startX = location[0] + emiter.getWidth() / 2 - parentLocation[0];
  startY = location[1] - parentLocation[1];
  particleView = new ParticleView(topView.getContext());
  topView.addView(particleView);
  particleView.setParticles(particles);
  startAnimator(emiter);
}

通过传入一个emiter,计算出起始位置信息并初始化particleView中的粒子信息,最后开启动画。

使用

val ids = ArrayList<Int>()
for (index in 1..18) {
   val id = resources.getIdentifier("img_like_$index", "mipmap", packageName);
   ids.add(id)
}
collectImage.setOnClickListener {
   ParticleManager(this, ids.toIntArray())
      .start(collectImage)
}

运行之后会发现基本和效果图一致,但是其实有个潜在的问题,我们只是向topView添加了view,并没有移除,虽然界面上看不到,其实只是因为我们的粒子在最后透明度都是0了,将粒子透明度最小值设置为0.1后运行会发现,动画结束之后粒子没有消失,会越堆积越多,所以我们还需要移除view。

valueAnimator.addListener(new AnimatorListenerAdapter() {
   @Override
   public void onAnimationStart(Animator animation, boolean isReverse) {
  }
   @Override
   public void onAnimationEnd(Animator animation) {
       topView.removeView(particleView);
       topView.postInvalidate();
  }
   @Override
   public void onAnimationCancel(Animator animation) {
       topView.removeView(particleView);
       topView.postInvalidate();
  }
});

移除的时机放在动画执行完成,所以继续使用之前的valueAnimator,监听他的完成事件,移除view,当然,如果动画取消了也需要移除。

作者:晚来天欲雪_
来源:juejin.cn/post/7086471790502871054

收起阅读 »

你需要了解的android注入技术

背景在android系统中,进程之间是相互隔离的,两个进程之间是没办法直接跨进程访问其他进程的空间信息的。那么在android平台中要对某个app进程进行内存操作,并获取目标进程的地址空间内信息或者修改目标进程的地址空间内的私有信息,就需要涉及到注入技术。通过...
继续阅读 »

背景

在android系统中,进程之间是相互隔离的,两个进程之间是没办法直接跨进程访问其他进程的空间信息的。那么在android平台中要对某个app进程进行内存操作,并获取目标进程的地址空间内信息或者修改目标进程的地址空间内的私有信息,就需要涉及到注入技术。

通过注入技术可以将指定so模块或代码注入到目标进程中,只要注入成功后,就可以进行访问和篡改目标进程空间内的信息,包括数据和代码。

Android的注入技术的应用场景主要是进行一些非法的操作和实现如游戏辅助功能软件、恶意功能软件。

zygote注入

zygote是一个在android系统中是非常重要的一个进程,因为在android中绝大部分的应用程序进程都是由它孵化(fork)出来的,fork是一种进程复用技术。也就是说在android系统中普通应用APP进程的父亲都是zygote进程。

zygote注入目的就是将指定的so模块注入到指定的APP进程中,这个注入过程不是直接向指定进程进程注入so模块,而是先将so模块注入到zygote进程。

在so模块注入到zygote进程后,在点击操作android系统中启动的应用程序APP进程,启动的App进程中包括需要注入到指定进程的so模块,太都是由zygote进程fork生成,因而在新创建的进程中都会包含已注入zygote进程的so模块。

这种的注入是通过间接注入方式完成的,也是一种相对安全的注入so模块方式。目前xposed框架就是基于zygote注入。

1.通过注入器将要注入的so模块注入到zygote进程;

2.手动启动要注入so模块的APP进程,由于APP进程是通过zygote进程fork出来的,所以启动的APP进程都包含zygote进程中所有模块;

3.注入的so模块劫持被注入APP进程的控制权,执行注入so模块的代码;

4.注入so模块归还APP进程的控制权,被注入进程正常运行。

(注入器主要是基于ptrace注入shellcode方式的进程注入)

通过ptrace进行附加到zygote进程。

调用mmap申请目标进程空间,用于保存注入的shellcode汇编代码。

执行注入shellcode代码(shellcode代码是注入目标进程中并执行的汇编代码)。

调用munmap函数释放申请的内存。

通过ptrace进行剥离zygote进程。

下面是关键的zygote代码注入实现




ptrace注入

ptrace注入实现上分类:

通过利用ptrace函数将shellcode注入远程进程的内存空间中,然后通过执行shellcode加载远程进程so模块。

通过直接远程调用dlopen、dlsym、dlclose等函数加载被注入so模块,并执行指定的代码。

ptrace直接调用函数注入流程:

通过利用ptrace进行附加到要注入的进程;

保存寄存环境;

远程调用mmap函数分配内存空间;

向远程进程内存空间写入加载模块名称和函数名称;

远程调用dlopen函数打开注入模块;

远程调用dlsym函数或需要调用的函数地址;

远程调用被注入模块的函数;

恢复寄存器环境;

利用ptrace从远程进程剥离。

关键的ptrace直接调用系统函数实现



shellcode注入就是通过将dlopen/dlsym库函数的操作放在shellcode代码中,注入函数只是通过对远程APP进程进行内存空间申请,接着修改shellcode 代码中有关dlopen、dlsymdlclose等函数使用到的参数信息,然后将shellcode代码注入到远程APP进程申请的空间中,最后通过修改PC寄存器的方式来执行shellcode 的代码。

关键 的ptrace注入shellcode代码实现



修改ELF文件注入

在android平台Native层的可执行文件SO文件,它是属于ELF文件格式,通过修改ELF文件格式可以实现对so文件的注入。

通过修改ELF二进制的可执行文件,并在ELF文件中添加自己的代码,使得可执行文件在运行时会先执行自定义添加的代码,最后在执行ELF文件的原始逻辑。

修改二进制ELF文件需要关注两个重要的结构体:

其中ELF Header 它是ELF文件中唯一的,一个固定位置的文件结构,它保存着Program Header Table和Section Header Table的位置和大小信息。

修改ELF文件实现so文件注入实现原理为:通过修改 Program Header Table中的依赖库信息,添加自定义的so文件信息,APP进程运行加载被该修改过的ELF文件,它也同时会加载并运行自定义的so文件。

Program Header Table表项结构


程序头表项中的类型选项有如下


当程序头表项结构中的类型为PT_DYNAMIC也就是动态链接信息的时候,它是由程序头表项的偏移(p_offset)和p_filesz(大小)指定的数据块指向.dynamic段。这个.dynamic段包含程序链接和加载时的依赖库信息。

关键ELF文件修改代码实现



作者:小道安全
来源:juejin.cn/post/7077940770941960223

收起阅读 »

开发一个APP多少钱?

开发一个APP多少钱?开发一个APP要多少钱?相信不光是客户有这个疑问,就算是一般的程序员也想知道答案。很多程序员想在业余时间接外包挣外快,但是他们常常不知道该如何定价,如何有说服力的要价。这是因为没有一套好的计算APP开发成本的方法。由于国内没有公开的数据,...
继续阅读 »

开发一个APP多少钱?

开发一个APP要多少钱?相信不光是客户有这个疑问,就算是一般的程序员也想知道答案。很多程序员想在业余时间接外包挣外快,但是他们常常不知道该如何定价,如何有说服力的要价。这是因为没有一套好的计算APP开发成本的方法。由于国内没有公开的数据,而且大家对于报价都喜欢藏着掖着,这里我们就整理了国外一些软件外包平台的资料,帮助大家对Flutter APP开发成本有一个直观而立体的认识。(注意,这里是以美元单位计算,请不要直接转换为RMB,应当根据消费力水平来衡量)

跨平台项目正在慢慢取代原生应用程序的开发。跨平台的方法更省时,也更节省成本。最近,原生应用程序的主要优势是其性能。但随着新的跨平台框架给开发者带来更多的力量,这不再是它们的强项。

Flutter就是其中之一。这个框架在2017年发布,并成为跨平台社区中最受推崇的框架之一。Statista称,Flutter是2021年十大最受欢迎的框架之一,并在最受欢迎的跨平台框架中排名第一。对于这样一项新技术来说,这是一个相当不错的结果。它的高需求使我们可以定义软件建设的大致成本。

Flutter应用程序的开发成本根据项目定义的工作范围而变化:

  • 简单的 Flutter 应用程序:$40,000 - $60,000

  • 中等复杂度应用程序:$60,000 – $120,000

  • 高度复杂的 Flutter 应用程序:$120,000 – $200,000+

有一些决定性的因素来回答Flutter应用开发的成本是多少。

在这篇文章中,我们将讨论不同行业的Flutter应用开发成本,找出如何计算精确的价格,以及如何利用这个框架削减项目开支。

Flutter应用的平均开发成本

应用程序的开发成本是一个复杂的数字,取决于各种因素 ——功能的复杂性,开发人员的位置,支持的平台,等等。如果不进行研究和了解所有的要求,就不可能得出项目的价格。

不过,你还是可以看看按项目复杂程度分类的估算:

  • 一个具有简单功能的软件,如带有锻炼建议、膳食计划、个人档案和体重日记的健身应用,其成本从26,000美元到34,800美元

  • 一个中等复杂度的软件,如带有语音通话、消息通信,Flutter应用的开发成本将从34,950美元到48,850美元不等

  • 开发一个像 Instagram 这样具有复杂功能的应用程序的成本将从41,500美元到55,000美元不等

影响价格的因素

为了明确 Flutter 应用开发成本的所有组成部分,我们将挑选出每个因素并分析其对价格的影响。

原生应用开发 vs. Flutter

当我们估算一个原生项目时,我们要考虑到两个平台的开发时间。Flutter是一个跨平台的框架,可以让开发者为Android和iOS编写同一个代码库。这一特点使开发时间减半,使Flutter应用程序的开发成本比原生的低

Flutter 的非凡之处在于它优化了代码并且没有性能问题。Flutter在所有设备上都能提供稳定的接近 60 FPS,如果设备支持,甚至可以提供120 FPS。

然而,Flutter也有一些缺点。如果你的项目需要Wear OS版本或智能电视应用,就会面临一些麻烦。从技术上讲,你可以为这些平台建立一个Flutter应用程序。但是,Flutter的很多开发功能并不被Wear OS所支持。在安卓电视的情况下,必须从头开始建立控制逻辑。原因是安卓电视只读取遥控器的输入,而Flutter则适用于触摸屏和鼠标移动。这一事实会减慢开发进程,给开发者带来麻烦,并增加Flutter应用的开发成本。

这就是为什么如果你的目标是特定的平台,最好去做原生开发。

功能的复杂性

功能是应用程序的主要组成部分。也是影响Flutter应用程序开发成本的主要因素。简单的功能(如登录)需要最少的工作量,而视频通话的集成可能需要长达 2-3 周的开发时间。

让我们想象一下,要建立一个类似 Instagram 的应用程序。照片上传功能需要大约13小时的开发时间。以每小时50美元的平均费率计算,这将花费650美元。然而,要建立用于照片编辑的过滤器,开发团队将不得不花费30至120小时,这取决于它们的类型和数量。一家软件开发公司将为这个功能收取1500-6000美元。

Flutter应用开发中最昂贵的功能

功能描述大约时间(小时)大约成本($50/h)
导航位置地图开发194$9,700
聊天视频、音频、文字聊天188$9,400
支付集成与 PayPal 集成,添加信用卡支付70$3,500

开发商的位置和所选择的雇用方式

影响总成本的另一个方面是你在雇用项目专家时选择的就业方式:

自由职业者

由于有机会减少开发费用,这种选择被广泛采用。然而,就Flutter应用的开发而言,无法保证自由职业者的能力和质量。此外,在支持、维护和更新服务方面,这样的专家也没有优势,因为他们可能会转到另一个项目,从而无法建立长期的合作伙伴关系。

内部团队

在这种情况下,你要负责项目开发管理,以及搜索和检查潜在雇主的经验和知识。此外,内部团队的聚集需要一排额外的费用,如购买硬件,租用办公室,病假,工资,等等。因此,这些条件大大增加了总成本。

外包公司

项目外包指的是已经组建的专家团队,具有成熟深入的资质,接手所有的创作过程。这种选择是一种节省开发投资和避免影响产品质量的好方法。除了这个事实之外,这里还有一些你将通过外包获得的好处。

  • 成本的灵活性。全球市场提供了很多准备以合理价格提供服务的外包软件开发公司。中欧已经成为实现这一目标的顶级地区,许多企业已经从来自该地的优秀开发人员的一流表现中受益。

  • 可扩展性。可以根据您的要求调整开发流程:此类公司的团队包括所有类型的开发人员,将在需要他们的能力时参与创建过程。此外,如果有必要的话,这也是加快项目完成的绝佳方式。外包提供了多种合作模式。 从专门的团队到工作人员的增援

  • 更快的产品交付。有了外包,就不需要在招聘上花费时间。你可以调整项目创建速度,例如,让更多的专家参与进来。因此,进入市场的时间缩短了,支出也减少了。只为已完成的工作付费。

  • 庞大的人才库。IT外包包括大量具有丰富专业知识和经验的技术专家。外包商为企业提供灵活的招聘机会。你可以在全球范围大量的的软件架构师中选择。

  • 可应用的技术非常多样化。根据你的项目要求,你可以从这些公司中选择一个具有相关专业知识的专家。

除了雇佣选择,开发团队的位置可能会对Flutter应用程序的开发成本产生很大的影响。在不同地区,开发人员有不同的价格。在美国,开发人员的平均费率是60美元/小时,而在爱沙尼亚,只有37美元/小时。

在下面的表格中,可以找到开发人员的每小时费率,并将它们进行比较。

Flutter开发人员在不同地区的费率:

地区每小时费率 ($)
北美$75 - $120
拉丁美洲$30 - $50
西欧$70 - $90
爱沙尼亚$30 - $50
印度$25 - $40
澳大利亚$41 - $70
非洲$20 - $49

如何计算 Flutter 应用开发成本

正如前面提到的,功能对Flutter应用开发成本的影响最大。Flutter 适用于不包含原生功能的项目。但是当涉及到地图、流媒体、AR和后台进程时,开发人员必须为iOS和Android单独构建这些功能,然后再与Flutter结合。

让我们回到例子上。如果是原生开发,你将需要大约60-130个小时在你的应用程序中实现AR过滤器。Flutter开发将需要约80-150小时,因为AR是一个原生功能。考虑到50美元/小时的费率,我们应该把它乘以开发时间。这个公式可以用来计算出最终的Flutter应用开发成本。

除了这个公式外,还有一件事在初始阶段很重要。

发现阶段

一个糟糕的发现阶段可能导致整个项目的崩溃。但为什么这个阶段如此重要?在发现阶段,业务分析人员和项目经理与你举行会议,找出可能的风险,并提出消除这些风险的解决方案

粗略估算

粗略估算的精确度从75%到25%不等。这个评估包括在客户和软件团队合作的初级阶段。它也有助于双方决定是否成为合作伙伴。粗略估算的主要目的是计算完成项目所需的最短和最长时间以及大致的总成本,以便客户知道在开发流程中需要多少投资。此外,这个估算包括整个创建过程,分为几个阶段。这个文件不应该被认为是有固定条款和条件的文件。它是为客户准备的,只是为了通知他们。

一个粗略的估算包括:

  • 主要部分包含准备工作。它们在不同的项目中都是一样的,包括产品描述、数据库设置、REST架构。该部分所指出的项目不一定一次就能完成。有些工作是在整个项目中完成的。

  • 开发与加密过程有关。这部分包括要实现的功能、屏幕和特性。开发部分包括 "业务逻辑 "和 "UI/UX "要求,以及某部分工作的小时数。

  • 为了更有效地实现功能,需要整合框架和库,并相应减少开发时间和相应的花费。

  • 非开发工作主要与技术写作有关。专家们准备详细的代码文档和准备有关产品创建的其他数据。

  • 建议部分包含了各种改进建议。

当所有的问题都解决后,会进入发现阶段并创建一个项目规范。客户必须积极参与,因为会根据客户提供的数据来建立项目规范。在下一个阶段,客户应当创建他们的应用程序草稿图。这是一个用户界面元素在屏幕上的位置示意图。

然后,开发人员和业务分析师会对客户的Flutter应用开发成本进行详细的估算。有了准确的预算、项目要求和草稿图,就可以签署合同并开始开发阶段。

如你所见,发现阶段是任何项目的关键部分。没有这个阶段,你就无法知道开发所需的价格和时间,因为会有太多的变数。如果在任何阶段出了问题,整个项目的计划就会出问题。这就是为什么客户必须与软件开发公司合作,使他们能够建立客户需要的项目。

额外费用

就像任何其他产品一样,客户的应用程序需要维护和更新,以便在市场上保持成功。这导致了影响Flutter应用程序开发成本的额外费用。

服务器

如果要处理和存储用户产生的数据,就必须考虑到服务器的问题。脆弱的服务器会导致用户方面的低性能和高响应时间。此外,不可靠的服务器和脆弱的保护系统会导致你的用户的个人数据泄露。为了减少风险,团队只信任可靠的供应商,如亚马逊EC2。根据AWS价格计算器,一台8核CPU和32G内存的工作服务器将花费大约1650美元/年。在计算整个Flutter应用程序的开发成本时,请牢记这笔费用。

UI/UX设计

移动应用的导航、排版和配色是UI/UX设计师应该注意的主要问题。他们还应该向你提供你的应用程序的原型。根据你的应用程序的复杂性,设计可能需要40到90多个小时。这一行的费用将使Flutter应用的开发成本提高到2000-4500美元

发布到应用商店

当你已经有了一个成品,你必须在某个地方发布它。Google Play和App Store是应用程序分发的主要平台。然而,这些平台在应用发布前会收取费用:

  • Google Play 帐号一次收取25美元,可以永久使用

  • 而Apple Store 收取99美元的年费,只要你的APP还想待在应用商店,每年都得花费这笔钱

除此之外,这两个平台对每次产生的应用内购买行为都有30%的分成。如果你通过订阅模式发布你的应用,那你只能得到70%收益。然而,最近Google Play和App Store已经软化了他们的政策。目前,他们对每一个购买了十二个月订阅的账户只收取15%的分成。

应用维护和更新

应用商店排行榜的应用能保持其地位是有原因的。他们通过不断的升级和全新的功能吸引客户。即使你的应用是完美的,但没有更新将导致停滞,用户可能卸载你的应用程序。在完美的构想里,你应该雇用一家开发应用程序的公司。他们从一开始就为你的项目工作。注意,应用程序的维护费用在应用程序的生命周期内会上升。公司通常将Flutter应用开发成本的15-20%纳入应用维护的预算。然而,你的应用程序拥有稳定受众的时间越长,需要投入的更新资金就越多。在一定时间内,你花在更新上的钱比花在实际开发上的钱多,这并不奇怪。尽管如此,但是你的应用产生的收入多于损失,所以这是一项值得的投资。不幸的是,随着新的功能发布可能出现新的错误和漏洞。你不能对这个问题视而不见,因为它使用户体验变差,并为欺诈者提供了新的漏洞。有一些软件开发公司会提供发布后的支持,包括开发新功能、测试和修复错误。

按类型划分的开发成本

由于你已经知道影响价格的主要和次要因素,现在是时候对不同应用程序的Flutter开发成本进行概述了。这里估算了来自不同行业和不同复杂程度的几个现有应用程序的开发成本。

分别是:

  • 交通运输

  • 流媒体

  • 社交媒体

Flutter 应用程序开发成本:交通运输

示例:BlaBlaCar

功能实现的大概时间:438 小时

大概费用:21,900 美元

运输应用程序需要用户档案、司机和乘客的角色、支付网关和GPS支持。请注意,如果你使用Flutter来构建地理定位等本地功能,整个项目的开发时间可能会增加。

请注意,下面的估算不包括代码文档、框架集成、项目管理等方面的时间。

下面是一个类似BlaBlaCar的应用程序的基本功能的粗略估计,基于Flutter的交通应用开发成本:

功能开发时间(小时)大概费用(美元)
注册28$1400
登录(通过电邮和 Facebook)22$1350
推送通知20$1000
用户资料77$3850
支付系统40$2000
乘车预订80$4000
乘车支付+优惠券42$2100
地理定位26$1300
司机端103$5150

Flutter应用程序开发成本:流媒体

例子: Twitch, Periscope, YouTube Live

功能实现的大概时间: 600小时

大概的成本: $30,000

流媒体应用程序是一个复杂的软件。它要求开发团队使用流媒体协议(这不是Flutter的强项),开发与观众沟通的文本聊天,推送通知,使用智能手机的摄像头,等等。其中一些有捐赠系统,与第三方的多种集成,甚至还有付费的表情符号。以下是一个类似Twitch的应用程序的基本功能的粗略估计。

基于Flutter的流媒体应用开发成本:

功能开发时间(小时)大概费用(美元)
注册20$1000
登录(通过电邮和 Facebook)23$1150
个人资料43$2150
搜索系统36$1800
流媒体协议20$1000
播放器集成33$1650
流管理(启动/关闭,设置比特率)120$6000
聊天146$7300
捐赠系统35$1750
支付网关64$3200
频道管理40$2000
推送通知20$1000

Flutter应用程序开发成本:消息通信

例子: Facebook Messenger, WhatsApp, Telegram

功能实现的大概时间: 589小时

估计成本: $29,450

消息通信工具的功能乍一看很简单,但详细的分析证明情况恰恰相反。整合各种状态的聊天(打字,在线/离线,阅读),文件传输,语音信息需要大量的时间。如果再加上语音通话和群组聊天,事情会变得更加复杂。

让我们单独列出每个功能及其成本,基于Flutter的消息通信应用开发成本:

功能开发时间(小时)大概费用(美元)
注册45$2250
登录27$1350
聊天156$7800
发送媒体文件40$2000
语音消息35$1750
群聊57$2850
语音电话100$5000
通知15$750
设置76$3800
搜索38$1900

作者:编程之路从0到1
来源:juejin.cn/post/7170168967690977293

收起阅读 »

Android性能优化方法论

作为一名开发,性能优化是永远绕不过去的话题,在日常的开发中,我们可肯定都会接触过。Android 的性能优化其实是非常成熟的了,成熟的套路,成熟的方法论,成熟的开源框架等等。对于接触性能优化经验较少的开发者来说,可能很少有机会能去总结或者学到这些成熟的套路,方...
继续阅读 »

作为一名开发,性能优化是永远绕不过去的话题,在日常的开发中,我们可肯定都会接触过。Android 的性能优化其实是非常成熟的了,成熟的套路,成熟的方法论,成熟的开源框架等等。

对于接触性能优化经验较少的开发者来说,可能很少有机会能去总结或者学到这些成熟的套路,方法论,或者框架。所以作为一位多年长期做性能优化的开发者,在这篇文章中对性能优化的方法论做一些总结,以供大家借鉴。


性能优化的本质

首先,我先介绍一下性能优化的本质。我对其本质的认知是这样的:性能优化的本质是合理且充分的使用硬件资源,让程序的表现更好,并且程序表现更好的目的则是为了获取更多来自客户的留存,使用时长,口碑、利润等收益。

所以基于本质来思考,性能优化最重要的两件事情:

  1. 合理且充分的使用硬件资源

  1. 让程序表现更好,并取得收益

下面讲一下这两件事情。

合理且充分的使用硬件资源

充分表示能将硬件的资源充分发挥出来,但充分不一定是合理的,比如我们一下子打了几百个线程,cpu 被充分发挥了,但是并不合理,所以合理表示所发挥出来的硬件资源能给程序表现有正向的作用。

硬件资源包括:CPU,内存,硬盘,电量,流量(不属于硬件资源,不过也归于需要合理使用的资源之一)等等。

下面举几个合理且充分的使用硬件资源的例子:

  1. CPU 资源的使用率高,但并不是过载的状态,并且 cpu 资源主要为当前场景所使用,而不是被全业务所分散消耗。比如我们优化页面打开速度,速度和 cpu 有很大的关系,那么我们首先要确保 cpu 被充分发挥出来了,我们可以使用多线程、页面打开前提前预加载等策略,来发挥手机的 cpu。但是在打开页面的时候,我们要合理的确保 cpu 资源主要被打开页面相关的逻辑所使用,比如组件创建,数据获取,页面渲染等等,至于其他和当前打开页面场景联系较少的逻辑,比如周期任务,监控,或者一些预加载等等都可以关闭或者延迟,以此减少非相关任务对 cpu 的消耗,

  1. 内存资源缓使用充分,并且又能将 OOM 等异常控制在合理范围内。比如我们做内存优化,内存优化并不是越少越好,相反内存占用多可能让程序更快,但是内存占用也不能太高,所以我们可以根据不同档次机型的 OOM 率,将内存的占用控制在充分使用并且合理的状态,低端机上,通过功能降级等优化,减少内存的使用,高端机上,则可以适当提升内存的占用,让程序表现的更好。

  1. ……

让程序表现更好,并取得收益

我们有很多直接的指标来度量我性能优化取得的收益,比如做内存优化可以用 pss,java 内存占用,native 内存占用等等;做速度优化,可以用启动速度,页面打开速度;做卡顿优化,这用帧率等等。掌握这些指标很重要,我们需要知道如何能正确并且低开销的监控这些指标数据。

除了上面的直接指标外,我们还需要了解性能优化的最终体现指标,用户留存率,使用时长,转换率,好评率等指标。有时候,这些指标才是最终度量我们性能优化成果的数据,比如我们做内存优化,pss 降低了 100 M,但仅仅只是内存占用少了 100M 并没有太大的收益,如果这个 100M 体现在对应用的存活时间,转化率的提升上,那这 100 M 的优化就是值得的,我们再向上报告我们产出时,也更容易获得认可。

如何做好性能优化

讲完了性能优化的本质,我再讲讲如何做好性能优化。我主要从下面这三个方面来讲解

  1. 知识储备

  1. 思考的角度和方式

  1. 形成完整的闭环

知识储备

想要做好性能优化,特别是原创性、或者完善并且体系的、或者效果很好的优化,不是我们从网上看一些文章然后模仿一下就能进行,需要我们有比较扎实的知识储备,然后基于这些知识储备,通过深入思考,去分析我们的应用,寻找优化点。我依然举一些例子,来说明硬件层面,系统层面和软件层面的知识对我们做好性能优化的帮助。

硬件层面

在硬件层面,我们需要处理器的体系结构,存储器的层次结构有一定的了解。如果我们如果不知道 cpu 由几个核组成,哪些是大核,哪些是小核,我们就不会想到将核心线程绑定大核来提升性能的优化方案;如果我们不了解存储结构中寄存器,高速缓存,主存的设计,我们就没法针对这一特效来提升性能,比如将核心数据尽量放在高速缓存中就能提升不少速度相关的性能。

系统层面

对操作系统的熟悉和了解,也是帮助我们做好性能优化不可缺少的知识。我在这里列一下系统层面需要掌握的知识,但不是全的,Linux的知识包括进行管理和调度,内存管理,虚拟内存,锁,IPC通信等。Android系统的知识包括虚拟机,核心服务如ams,wms等等,渲染,以及一些核心流程,如启动,打开activity,安装等等。

如果我们不了解Linux系统的进程调度系统,我们就没法充分利用进程优先来帮助我们提升性能;如果我们不熟悉 Android 的虚拟机,那么围绕这虚拟机一些相关的优化,比如 oom 优化,或者是 gc 优化等等都无法很好的开展。

软件层面

软件层面就是我们自己所开发的 App,在性能优化中,我们需要对自己所开发的应用尽可能得熟悉。比如我们需要知道自己所开发的 App 有哪些线程,都是干嘛的,这些线程的 cpu 消耗情况,内存占用多少,都是哪些业务占用的,缓存命中率多少等等。我们需要知道自己所开发的 App 有哪些业务,这些使用都是干嘛的,使用率多少,对资源的消耗情况等等。

除了上面提到的三个层面的知识,想要深入做好性能优化,还需要掌握更多的知识,比如汇编,编译器、编程语言、逆向等等知识。比如用c++ 写代码就比用java写代码运行更快,我们可以通过将一些业务替换成 c++ 来提高性能;比如编译期间的内联,无用代码消除等优化能减少包体积;逆向在性能优化上的用处也非常大,通过逆向我们可以修改系统的逻辑,让程序表现的更好。

可以看到,想要做好性能优化,需要庞大的知识储备,所以性能优化是很能体现开发者技术深度和广度的,这也是面试时,一定会问性能优化相关的知识的原因。这是知识储备不是一下就能形成的,需要我们慢慢的进行学习和积累。


思考的角度及方式

讲完了知识储备,再讲讲思考的角度和方式。需要注意它和知识储备没有先后关系,并不是说要有了足够的技术知识后才能开始考虑如何思考。思考的角度和方式体现在我们开发的所有生命周期中,即使是新入门的开发,也可以锻炼自己从不同的角度和方式去进行思考。下面就聊一聊我在做性能优化的过程中,在思考的角度和方式上的一些认知。为了让大家能更形象的理解,我就都以启动优化来讲解。

思考角度

我这里主要通过应用层,系统词,硬件层这三个角度来介绍我对启动速度优化的思考。

应用层

做启动速度优化时,如果从应用层来考虑,我会基于业务的维度考虑所加载的业务的使用率,必要性等等,然后制定优先级,在启动的时候只加载首屏使用,或者使用率高的业务。所以接着我就可以设计启动框架用来管理任务,启动框架要设计好优先级,并且能对这些初始化的任务有使用率或者其他性能方面的统计,比如这些任务初始化后,被使用率的概率是多少,又或者初始化之后,对业务的表现提升提现在哪,帮助有多大。

从应用层的思考主要是基于对业务的管控或者对业务进行优化来提升性能。

系统层

以及系统层来考虑启动优化也有很多点,比如线程和线程优先级维度,在启动过程中,如何控制好线程数量,如何提高主线程的优先级,如何减少启动过程中不相关的线程,比如 gc 线程等等。

硬件层

从硬件层来考虑启动优化,我们可以从 cpu 的利用率,高速缓存cache的命中率等维度来考虑优化。

除了上面提到的这几个角度,我们还可以有更多角度。比如跳出本设备之外来思考,是否可以用其他的设备帮助我们加速启动。google play 就有类似的优化,gp会上传一些其他机器已经编译好的机器码,然后相同的设备下载这个应用时,也会带着这些编译好的机器码一起下载。还有很常用的服务端渲染技术,也是让服务端线渲染好界面,然后直接暂时静态模块来提升页面打开速度;又或者站在用户的角度去思考,想一想到底什么样的优化对用户感知上是有好处的,比如有时候我们再做启动或者页面打开速度优化,会给用户一个假的静态页面让用户感知已经打开了,然后再去绑定真实的数据。

做性能优化时,考虑的角度多一些,全面一些,能帮助我们想出更多的优化方案。

思考方式

除了锻炼我们站在不同的角度思考问题,我们还可以锻炼自己思考问题的方式,这里介绍自上而下和自下而上两种思考方式。

自上而下

我们做启动优化,自上而下的优化思路可能是直接从启动出发,然后分析启动过程中的链路,然后寻找耗时函数,将耗时函数放子线程或者懒加载处理,但是这种方式会导致优化做的不全面。比如将耗时的任务都放在子线程,我们再高端机上速度确实变快了,但是在低端机上,可能会降低了启动速度,因为低端机的 cpu 很差,线程一多,导致 cpu 满载,主线程反而获取不到运行时间。其次,如果从上层来看,一个函数执行耗时久可能并不是这个函数的问题,也可能是因为该函数长时间没有获取到 cpu 时间。

自上而下的思考很容易让我们忽略本质,导致优化的效果不明显或者不完整。

自下而上

自下而上思考就是从底层开始思考,还是以启动优化为例子,自下而上的思考就不是直接分析启动链路,寻找慢函数,而是直接想着如何在启动过程中合理且充分的使用 cpu 资源,这个时候我们的方案就很多了,比如我们可能会想到不同的机型 cpu 能力是不一样的,所以我们会针对高端机和低端机来分别优化,高端机上,我们想办法让cpu利用率更高,低端机上想办法避免 cpu 的超载,同时配合慢函数,线程,锁等知识进行优化,就能制定一套体系并且完整的启动优化方案。


完整的闭环

上面讲的都是如何进行优化,优化很重要,但并不是全部,在实际的性能优化中,我们需要做的有监控,优化,防劣化,数据收益收集等等,这些部分都做好才能形成一个完整的闭环。我一一讲一下这几个部分:

  • 监控:完整的监控应用中各项性能的指标,仅仅有指标监控是不够的,我们还需要尽量做归因的监控。比如内存监控,我们不仅仅要监控我们应用的内存指标,还可以还要能监控到各个业务的内存使用占比,大集合,大图片,大对象等等归因项。并且我们的监控同样要基于性能考虑去设计。完整的监控能让我们更高效的发现和解决异常。

  • 优化:优化就是前面提到的,合理且充分的使用硬件资源,让程序的表现更好。

  • 防劣化:防劣化也是有很多事情可以做的,包括建立完善的线下性能测试,线上监控的报警等。比如内存,我们可以在线下每天通过monkey跑内存泄露并提前治理,这就是防劣化。

  • 数据收益收集。学会用好A/B测试,学会关注核心价值的指标。比如我们做内存优化,一味的追求降低应用内存的占用并不是最优,内存占用的多,可能会让我们的程序运行更快,用户体验更好,所以我们需要结合崩溃率,留存等等这种体验核心价值的指标,来确定内存到底要不要继续进行优化或者优化到多少。

小结

上面就是我在多年的性能优化经验中总结出来的认知及方法论。只有了解了这些方法论,我们才能在进行性能优化时,如鱼得水,游刃有余。

这篇文章也没有介绍具体的优化方案,因为性能优化的方案通过一篇文章是介绍不完的,大家有兴趣可以看看我写的掘金小册《Android 性能优化》,可以体系的学一学如何进行优化,上面讲解的方法论,也都会在这本小册中体现出来。

作者:helson赵子健
来源:juejin.cn/post/7169486107866824717

收起阅读 »

Android依赖冲突解决

一、背景工程中引用不同的库(库A和B),当不同的库又同时依赖了某个库的不同版本(如A依赖C的1.1版本,B依赖C2.2版本),这时就出现了依赖冲突。二、问题解决步骤查看依赖树运行android studio的中如下task任务即可生成依赖关系,查看冲突是由哪哪...
继续阅读 »

一、背景

工程中引用不同的库(库A和B),当不同的库又同时依赖了某个库的不同版本(如A依赖C的1.1版本,B依赖C2.2版本),这时就出现了依赖冲突。

二、问题解决步骤

查看依赖树

运行android studio的中如下task任务即可生成依赖关系,查看冲突是由哪哪些库引入的(即找到库A和库B)。


排除依赖

使用 exclude group:'group_name',module:'module_name'

//剔除rxpermissions这依赖中所有com.android.support相关的依赖,避免和我们自己的冲突
implementation 'com.github.tbruyelle:rxpermissions:0.10.2', {
exclude group: 'com.android.support'
}

注意:下图中红框处表示依赖的版本由1.0.0被提升到了1.1.0。如果对1.0.0的库中的group或module进行exclude时,当库的版本被提升时,exclude将会失效,解决办法时工程中修改库的依赖版本为被提升后的版本。

使用强制版本

冲突的库包含了多个版本,这时可直接使用强制版本。在项目的主module的build.gradle的dependencies节点里添加configurations.all {},{}中的前缀是 resolutionStrategy.force ,后面是指定各module强制依赖的包,如下图所示,强制依赖com.android.tools:sdklib包的30.0.0:


作者:Android_Developer
来源:juejin.cn/post/7042951122872434696

收起阅读 »

六年安卓开发的技术回顾和展望

本文字数:7190 字,阅读完需:约 5 分钟大家好,我是 shixin。一转眼,我从事安卓开发工作已经六年有余,对安卓开发甚至软件开发的价值,每年都有更进一步的认识。对未来的方向,也从刚入行的迷茫到现在逐渐清晰。我想是时候做一个回顾和展望了。这篇文章会先回顾...
继续阅读 »

本文字数:7190 字,阅读完需:约 5 分钟

大家好,我是 shixin。

一转眼,我从事安卓开发工作已经六年有余,对安卓开发甚至软件开发的价值,每年都有更进一步的认识。对未来的方向,也从刚入行的迷茫到现在逐渐清晰。我想是时候做一个回顾和展望了。

这篇文章会先回顾我从入行至今的一些关键点,然后讲一下经过这些年,我对软件开发的认知变化,最后分享一下后面的规划。

回顾

人太容易在琐碎生活中迷失,我们总是需要记住自己从哪里来,才能清楚要到哪里去。

入行至今的一些关键节点

2014~2015:开始安卓开发之旅

说起为什么做安卓开发,我很有感慨,差一点就“误入歧途”😄。

当初在大学时,加入了西电金山俱乐部,俱乐部里有很多方向:后端、前端、安卓、Windows Phone 等。


由于我当时使用的是三星 i917,WindowsPhone,所以就选了 WinPhone 方向。

当时还是 iOS、安卓、WinPhone、塞班四足鼎立的时代,WinPhone 的磁贴式设计我非常喜欢,加上设备的流畅性、像素高,一度让我觉得它可能会统治移动市场。

结果在学习不到 2 个月以后,我的 WinPhone 意外进水了!我当时非常难过,一方面是对手机坏了的伤痛,另一方面也是对无法继续做 WinPhone 开发很遗憾。对于当时的我来说,再换一台 WinPhone 过于昂贵,只好换一台更加便宜的安卓机,因此也就转向学习安卓开发。

后面的故事大家都知道了,因为 WindowsPhone 缺乏良好的开发生态,支持应用很少,所以用户也少,用户少导致开发者更少,恶性循环,如今市场份额已经少的可怜。

现在回想起来,对于这件事还很有感慨,有些事当时觉得是坏事,拉长时间线去看,未必是这样。

当时还有一件目前看来非常重要的决定:开始写博客,记录自己的所学所得。

在开发项目时,我经常需要去网上搜索解决方案,后来搜索的多了,觉得总不能一直都是索取,我也可以尝试去写一下。于是在 CSDN 注册了账号,并于 2014 年 10 月发布了我的第一篇原创文章

后来工作学习里新学到什么知识,我都会尽可能地把它转换成别人看得懂的方式,写到播客里。这个不起眼的开始,让我逐渐有了解决问题后及时沉淀、分享的习惯,受益匪浅。

2015~2017:明白项目迭代的全流程

在学习安卓开发时,我先看了一本明日科技的《Android 从入门到精通》,然后看了些校内网的视频,逐渐可以做一些简单的应用。安卓开发所见即所得的特点,让我很快就可以得到正反馈。后来又去参加一些地方性的比赛,获得一些名次,让我逐渐加强了从事这个行业的信心。


在 2015 年时,偶然参加了一家公司的招聘会,在面试时,面试官问了一些简单的 Java 、安卓和算法问题。其中印象最深的就是会不会使用四大组件和 ListView。在当时移动互联网市场飞速发展时,招聘要求就是这么低。以至于现在很多老安卓回忆起当初,都很有感慨:“当初会个 ListView 就能找工作了,现在都是八股文” 哈哈。

到公司实习后,我感触很多,之前都是自己拍脑袋写一些简单的功能,没有开发规范、发布规范,也没有工程结构设计、系统设计,更没有考虑性能是否有问题。真正的去开发一个商业项目,让我发现自己不足的太多了。


因此在完成工作的同时,我观察并记录了项目迭代的各个流程,同时对自己的技术点做查漏补缺,输出了一些 Java 源码分析、Android 进阶、设计模式文章,也是从那个时候开始,养成了定期复盘的习惯,每次我想回顾下过去,都会看看我的成长专栏

2017~2020:提升复杂项目的架构能力和做事意识

第一个项目中我基本掌握了从 0 到 1 开发一个安卓应用的流程,但对安卓项目架构还只停留在表面,没有足够实践。

在 2017 年,我开始做喜马拉雅直播项目,由于喜马拉雅在当时已经有比较多年的技术积累,加上业务比较复杂,在架构设计、编译加速、快速迭代相关都做了比较多的工作,让我大饱眼福。

同时直播业务本身也是比较复杂的,在一个页面里会集成 IM、推拉流等功能,同时还有大量的消息驱动 UI 刷新操作,要保证业务快速迭代,同时用户体验较好,需要下不少功夫。

为了能够提升自己的技术,在这期间我学习了公司内外很多框架的源码,通过分析这些框架的优缺点、核心机制、架构层级、设计模式,对如何开发一个框架算是有了基本的认识,也输出了一些文章,比如 《Android 进阶之路:深入理解常用框架实现原理》


有了这些知识,再去做复杂业务需求、基础框架抽取、内部 SDK 和优化,就容易多了。

在开发一些需求或者遇到复杂的问题时,我会先想想,之前看的这些三方框架或者系统源码里有没有类似的问题,它们是怎么解决的? 比如开发 PK 功能,这个需求的复杂性在于业务流程很多,分很多状态,咋一看好像很复杂,但如果了解了状态机模式,就会发现很简单。借用其他库的设计思路帮我解决了很多问题,这让我确信了学习优秀框架源码的价值

除了技术上的提升,在这几年里,我的项目全局思考能力也提升很多。

由于我性格外向,和各个职能的同学沟通交流比较顺畅,领导让我去做一个十人小组的敏捷组长,负责跟进需求的提出、开发、测试、上线、运营各个环节,保证项目及时交付并快速迭代。

一开始我还有些不习惯,写代码时总是被不同的人打断,比如产品需求评审、测试 bug 反馈、运营反馈线上数据有问题等等,经常刚想清楚代码怎么写,正准备动手,就被叫去开会,回来后重新寻找思路。

后来在和领导沟通、看一些书和分享后,逐渐对写代码和做事,有了不同的认识。代码只是中间产物,最终我们还是要拿到对用户有价值、给公司能带来收入的产品,要做到这个,眼里除了代码,还需要关注很多。

2020~至今:深入底层技术

在进入字节做基础技术后,我的眼界再一次被打开。

字节有多款亿级用户的产品,复杂的业务常常会遇到各种意想不到的问题,这些问题需要深入底层,对安卓系统的整个架构都比较熟悉,才能够解决。


上图是安卓系统架构图,之前我始终停留在一二层,在这一时期,终于有了纵深的实践经验。

比如帮业务方解决一个内存问题,除了要了解内存指标监控方式,还要知道分析不同类型内存使用的工具及基本原理,最后知道是哪里出了问题后,还要想如何进行体系化的工具,降低学习成本,提升排查效率。

问题驱动是非常好的学习方式。每次帮助业务解决一个新问题,我的知识库都会多一个点,这让我非常兴奋。之前不知道学来干什么的 Linux 编程、Android 虚拟机,终于在实际问题中明白了使用场景,学起来效率也高了很多。

对软件开发的认识

前面讲了个人的一些经历,包括我怎么入的行,做了什么项目,过程中有什么比较好的实践。下面讲一下我从这些具体的事里面,沉淀出哪些东西有价值的结论。

主要聊下对这两点的认识:

  • 职业发展的不同阶段

  • 技术的价值

职业发展的不同阶段

第一点是对职业发展的认识。我们在工作时,要对自己做的事有一个清晰的认识,它大概属于哪一个阶段,怎样做可以更好。

结合我这些年的工作内容、业内大佬所做的事情,我把软件开发者的职业发展分这几个阶段:

  1. 使用某个技术方向的一个点开发简单项目

  2. 使用某个技术方向的多个点及某条线,开发一个较为复杂的业务或系统

  3. 掌握某个方向的通用知识,有多个线的实践,可以从整体上认识和规划

  4. 不限于该方向,能从产品指标方面出发,提供全方位的技术支持业务角度,端到端关注指标

第一个阶段就是使用某个技术方向的一个点完成业务需求。拿安卓开发者来说,比如使用 Android SDK 自定义布局,完成产品要求的界面功能。这个阶段比较简单,只要能够仔细学习官方文档或者看一些书即可胜任。拿后端来说,比如刚接手一个小项目,日常工作就是使用 Spring 等库开发简单的接口,不涉及到上下游通信、数据库优化等。

第二个阶段,你做的项目更加复杂了,会涉及到一个技术方向的多个点,这时你需要能把这些点连起来,给出一个更体系化的解决方案。

拿安卓开发者来说,比如在自定义布局时,发现界面很卡顿,要解决这个问题的话,你就要去了解这个自定义 View 的哪些代码流程影响了这个页面的刷新速度。这就相当于是从一个点到另一个点。怎么连起来呢?你需要去研究渲染的基本原理,分析卡顿的工具,找到导致卡顿的原因,进行优化。这个过程会对流畅性有整体的认识,能够对相关问题有比较全面的分析思路、解决手段,从而可以开发相关的分析工具或优化库。 如果能达到这个程度,基本就算是一个高级工程师了,不只是做一个模块,还能够负责一个具体细分方向的工作。

第三个阶段,掌握某个技术方向的通用知识,有多个线的实践,能够连线为面,同时给工作做中长期的技术规划。

拿安卓开发来说,刚才提到你通过解决卡顿问题,在流畅性这方面有了比较多的实践;然后你又发现内存有问题,去了解了内存分配、回收原理,做出内存分析优化工具,这样就也有了内存的一个体系化的实践。再加一些其他的优化经验,比如启动速度、包大小等。把这些线连起来,就得到了一个性能监控平台,这就是有把多条线连成一个面。

还有比如说你发现项目打包和发布过程中的一些痛点,并且能够做一些实践解决,最后如果能够把这些优化项连起来做一个统一的系统,给出完整的 DevOps 方案,提升开发、发布、运维的效率。能够把这个系统搭建起来,有比较深入的经验,那就可以成为“技术专家”了。

再往上走就不只是做技术,而要更多思考业务。技术最终都是要为业务服务。职业发展的第四个阶段,就是不局限于某个技术方向,能够从产品的业务规划、业务指标出发,给产品提供技术支持。

你首先要明白公司业务的核心指标是什么,比如说拿一个短视频应用来说,它核心指标除了常规的日活、用户量,还更关注视频的播放率、停留时长、页面渗透率等。了解这些指标以后,你要思考做什么可以有助于公司提升这些指标。结合业务指标反思当前的项目哪里存在优化空间。

有了这个思路并且知道可以做什么以后,你可以做一个较为全面的规划,然后拉领导去讨论可行性。这时你不能再局限于某一端,不能说我只是个安卓开发,其他部分都找别人做。一般在项目的价值没有得到验证之前,领导不会轻易给你资源,因此第一个版本迭代肯定是要靠你自己,从前到后独立完成,做一个 MVP 版本,然后让领导认可了这个系统的价值,才有可能会分给你更多的资源做这件事。

总结一下对职业发展的认识:第一阶段只做一些具体的点;第二阶段做多个点,需要能够连点成线;第三个阶段需要围绕这些线提炼出通用的知识,然后做到对业务/技术项目有整体的认识;第四阶段能够从业务指标出发,做出有价值的系统/平台。

技术的价值

说完职业发展的不同阶段,接下来聊下技术对业务的价值。

技术是为业务服务的。根据业务的不同阶段,技术的价值也有所不同:

  1. 业务从 0 到 1 时,帮助业务快速确定模式

  2. 业务从 1 到 100 时,帮助业务快速扩大规模

  3. 最卓越的,用技术创新带动业务有新的发展 (Google、AWS、阿里云)

业务从 0 到 1 时

我一开始做的工作,业务就是处于确定模式期间。业务上反复试错,项目常常推倒重来,会让程序员觉得很有挫败感。

这个阶段很多程序员都会发挥复制粘贴大法,产品经理说要新增一个功能,就复制一份代码稍微改一改。

如果说目前就是在这种业务中,该怎么做呢?如果我回到当时那个情景,我可以做什么让公司业务变得更好呢?

我总结了两点:在高效高质量完成业务的同时,思考如何让业务试错成本更低。

如何让业务试错成本更低呢?大概可以有这些方式:

  • 提供可复用的框架

  • 提供便捷的数据反馈机制

  • 多了解一些竞品业务,在产品不确定的时候,给一些建议

第一点:尽可能的抽象相似点,减少重复成本。

如果产品每次都给你类似的需求,你可以考虑如何把这些重复需求抽象成一些可以复用的逻辑,做一个基本的框架,然后在下次开发的时候能够去直接用框架,而不是每次都从头开始。我平时工作也常常问自己“我现在做的事有哪些是重复的,哪些是可以下沉的”。

就安卓开发来说,这个阶段,可以做好基础建设,提供插件化、热修复、动态化框架,帮助业务快速发版,自研还是第三方看公司财力。

如果你说这些太复杂了我做不来,那就从更小的层面做起,比如某个功能原本需要多个接口多个界面,看能不能改成接口参数可配置,界面根据参数动态生成(也就是 DSL)。

第二点:提供便捷的数据反馈机制

在产品提需求时,你可以问问产品这个需求出于什么考虑,有没有数据支撑?比如说产品需求是某个按钮换个位置,那你要搞清楚,为什么要换,换完之后会导致页面打开率提升吗?要有这种数据驱动的理念。

如果公司做决策时缺乏相应的数据,你可以主动地去提供这种数据反馈机制。比如说开发一个埋点平台、数据监控平台。尽可能地让业务有数据可看,能够数据驱动,而不是像无头苍蝇一样盲目尝试。

如果无法做一个这么大的系统,那可以先从力所能及的做起,比如说战略上重视数据;做好数据埋点;思考做的功能,目前有哪些数据是核心的,这些数据有没有上报,不同版本的数据是升还是降等。

好,这是第一个阶段,技术对业务价值就是帮助业务快速确定模式。第二个阶段就是业务快速扩大规模时,技术的核心价值是什么呢?

业务从 1 到 100 时

业务正在快速扩大规模时,需要把当前跑通的业务模式复制到更多的地方,同时能够服务更多的用户。这个阶段,技术能够提供的价值主要是两点。

  1. 快速迭代(这一点其实无论什么阶段)

  2. 提升质量(用户规模日活上亿和日活一万,需要面对的挑战差异也是这个数量级)

第一点:快速迭代

虽然快速迭代是业务各个阶段都需要做到,但和从 0 到 1 相比,从 1 到 100 的阶段会有更多的挑战,除了个人速度,更要关注团队的速度。

团队的速度如何提升?可以参考后端的单体到微服务、前端的单仓到多仓的演变过程及原因。

这个阶段主要有这几点问题:

  1. 多人协作代码冲突

  2. 发布速度慢

  3. 出问题影响大,不好定位

具体到安卓项目,几百人开发和三两个人开发的,复杂度也是几百倍。我们可以做的是:

  1. 下沉基础组件,定义组件规范,收敛核心流程

  2. 拆分业务模块,设计业务模板,单独维护迭代

  3. 探索适合业务的新方式:跨端(RN Flutter KotlinMultiplatform)、动态化、多端逻辑一致(C/C++ Rust)

第二点:提升质量

和日活几万的项目相比,日活千万甚至上亿的产品,需要应对的质量问题更加显著。在这个阶段,我们不仅要满足于实现功能,还要能够写的好,更要能够了解底层原理,才能应对这样大的业务量。

有了大规模的用户后,你会遇到很多奇怪的问题,不能疲于每天去解决一样重复的问题,那你就需要从这些问题中找到一些共通的点,然后提炼出来,输出工具、解决方案甚至平台。

这就需要你从问题中磨练本领,站在更高的层面思考自己该具体的能力、思路和工具。

在解决问题的时候,除了当下这个问题,更需要做的是把这个问题解构、归类,抽象出不同问题的相似和差异,得出问题分析流程图。

同样是分析内存泄漏,有的人可能只知道使用 Leakcanary,但你还可以思考的更深入,比如:

  • 先定义问题。什么是泄露?

  • 泄露是申请了没有释放或者创建了没有回收

  • 内存泄露怎么分析?

  • 找到创建和销毁的点

  • 在创建的时候保存记录,销毁的时候删除这个记录,最终剩下来的就是泄露的

有了基础的逻辑,就可以把它套用到各种问题上:

  • Native 内存泄漏:在 Native 内存分配和释放 API,做记录

  • 图片使用不当:在图片创建、释放的 API 里做记录

  • 线程过多:在线程创建、释放的 API 里做记录

在遇到一个新问题时,发现和之前解决过的有点像,但又不知道哪里像。怎么办?回头去思考新旧的两个问题,它们的本质是什么?有什么相似的分析思路?

这个思考训练的目的,就是提升举一反三的能力。大规模应用可能各种问题,需要你一方面提升技术,另一方面分析问题的思路和能力上也要提升,不能看着一个问题就是一个问题,要做到看到一个问题,想到一类问题。

展望(后面的规划)

技术上达到一专多能,软实力上持续提升。

硬实力

专业

如果你是安卓开发,最好在某个有细分领域很擅长,比如音视频、跨端、动态化、性能优化。

我目前主要是做优化,后面需要继续补充的知识:

  • Linux 内核原理

  • Android 虚拟机原理

  • 项目从开发、编译、发布、数据分析各个流程的效率提升方式

多能

前面提到职业发展的第四个阶段:

不限于该方向,能从产品指标方面出发,提供全方位的技术支持

我希望可以具备独立完成一个系统从前到后的能力。

目前已有的经验:

  • 使用 TypeScript + React + Electron 开发桌面端软件

  • 使用 SpringMVC 开发简单的内部系统

后面需要加强的点:

  • 熟练掌握前端的 JS、打包、优化等知识

  • 后端技术达到中级

还有这些点需要长期关注:

  • Flutter 更新频繁,有一些尝试效果还不错,一套代码多端运行,节省开发成本

  • 掌握 DevOps 理念及实践

最终目的:

  • 具备独立完成一个有价值的系统的能力

  • 具备对研发整个流程的完善、优化能力

软实力

除了技术规划,我也有很多软实力需要继续提升,今年主要想提升的就是同频对话的能力。

什么是同频对话?

同频对话就是根据听众的角色和他的思考角度去转换你的表达内容。

比如说我们在和领导汇报的时候,你要去讲你做的一个系统,你就要从他角度去表达。他可能关注的是整体流程、系统的难点、瓶颈在哪里,带来的收益是什么。那你就不能只讲某个模块的细节,而要从更高的层面去思考和表达。

为什么要提升呢?

随着工作年限的增加,市场对我们的要求是越来越高的,除了写代码,对表达能力的要求也是越来越高的。

一开始刚入行,你就是做一个执行者,只要多动耳朵、眼睛、手,实现别人要求你做的功能。

后来你的能力逐渐提升以后,有机会设计一个模块的时候,你就需要多动脑力去思考,去复设计这个系统的输入输出、内部数据流转等。

再往后走的话,你可能会有一些资源,那就需要能把你的想法完整地表达出来,让别人帮你去贯彻落地。这其实是一种比较难得的能力。我今年计划通过多分享、多与不同的人交流等方式,提升自己的这种能力,争取做到满意的程度。

结束语

好了,这篇文章就到这里了,这就是我这六年的技术回顾和展望,感谢你的阅读❤️。

人生的多重境界:看山是山、看水是水;看山不是山、看水不是水;看山还是山、看水还是水。

我想,我对软件开发,还没有达到第三层,相信用不了多久,就会有不同的观点冒出来。

但,怕什么真理无穷,进一寸有一寸的欢喜!

作者:张拭心
来源:juejin.cn/post/7064960413280141348

收起阅读 »

Android 无所不能的 hook,让应用不再崩溃

之前推送了很多大厂分享,很多同学看完就觉得,大厂输出的理论知识居多,缺乏实践。那这篇文章,我们将介绍一个大厂的库,这个库能够实打实的帮助大家解决一些问题。今天的主角:初学者小张,资深研发老羊。三方库中的 bug这天 QA 上线前给小张反馈了一个 bug,应用启...
继续阅读 »

之前推送了很多大厂分享,很多同学看完就觉得,大厂输出的理论知识居多,缺乏实践。

那这篇文章,我们将介绍一个大厂的库,这个库能够实打实的帮助大家解决一些问题。

今天的主角:初学者小张,资深研发老羊。

三方库中的 bug

这天 QA 上线前给小张反馈了一个 bug,应用启动就崩溃,小张一点不慌,插入 USB,触发,一看日志,原来是个空指针。

想了想,空指针比较好修复,大不了判空防御一下,于是回答:这个问题交给我,马上修复。

根据堆栈,找到了空指针的元凶。

忽然间,小张愣住了,这个空指针是个三方库在初始化的时候获取用户剪切板出错了。

这可怎么解决呢?

本来以为判个空防御一下完事,这会遇到硬茬了。

毕竟是自己装的逼,含着泪也要修复了,我们模拟下现场。

/**
* 这是三方库中的调用
*/
public class Tools {
   
  public static String getClipBoardStr(Context context) {
      ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
      ClipData primaryClip = clipboardManager.getPrimaryClip();
      // NPE
      ClipData.Item itemAt = primaryClip.getItemAt(0);
      if (itemAt == null) {
          return "";
      }
      CharSequence text = itemAt.getText();
      if (text == null) {
          return "";
      }
      return text.toString();
  }
}

我们写个按钮来触发一下:


果然发生了崩溃,空指针发生在clipboardManager.getPrimaryClip(),当手机上没有过复制内容时,getPrimaryClip返回的就是 null。

马上就要上线了,但是这个问题,也不是修复不了,根据自己的经验,大多数系统服务都可以被 hook,hook 掉 ClipboradManager 的相关方法,保证返回的 getPrimaryClip 的不为 null 即可。

于是看了几个点:

public @Nullable ClipData getPrimaryClip() {
   try {
       return mService.getPrimaryClip(mContext.getOpPackageName(), mContext.getUserId());
  } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
  }
}

这个 mService 的初始化为:

mService = IClipboard.Stub.asInterface(
              ServiceManager.getServiceOrThrow(Context.CLIPBOARD_SERVICE));

这么看,已经八成可以 hook了,再看下我们自己能构造 ClipData 吗?

public ClipData(CharSequence label, String[] mimeTypes, Item item) {}

恩,hook 的思路基本可行。

小张内心暗喜,多亏是遇到了我呀,还好我实力扎实。

这时候,资深研发老羊过来问了句,马上就要上线了,你这干啥呢?

小张滔滔不绝的描述了一下当前遇到了问题,和自己的解决思路,本以为老羊这次会拍拍自己的肩膀「还好是你遇到了呀」来表示对自己的认可。

老羊开口说道:

getPrimaryClip返回 null 造成的空指针,那你在之前调用一个setPrimaryClip不就行了?

恩?卧槽...看一眼源码:

#ClipboardManager
public void setPrimaryClip(@NonNull ClipData clip) {
   try {
       Preconditions.checkNotNull(clip);
       clip.prepareToLeaveProcess(true);
       mService.setPrimaryClip(clip, mContext.getOpPackageName(), mContext.getUserId());
  } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
  }
}

还真有这个方法...

那试试吧。

添加了一行:

ClipboardManager clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClip(new ClipData("bugfix", new String[]{"text/plain"}, new ClipData.Item("")));

果然不在崩溃了。

这时候老羊说了句:

你也想想,假设三方库里面真有个致命的 bug,然后你没找到合适的 hook 点你怎么处理?想好了过来告诉我。

致命 bug,没找到合适的 hook 点?

模拟下代码:

public class Tools {

  public static void evilCode() {
      int a = 1 / 0;
  }

  public static String getClipBoardStr(Context context) {
      evilCode();
      ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
      ClipData primaryClip = clipboardManager.getPrimaryClip();
      ClipData.Item itemAt = primaryClip.getItemAt(0);
      if (itemAt == null) {
          return "";
      }
      CharSequence text = itemAt.getText();
      if (text == null) {
          return "";
      }
      return text.toString();
  }


}

假设 getClipBoardStr 内部调用了一行 evilCode,执行到就crash。

一眼望去这个 evilCode 方法,简单是简单,但是在三方库里面怎么解决呢?

小张百思不得其解,忽然灵光一闪:

是不是老羊想考察我的推动能力,让我没事别瞎 hook 人家代码,这种问题当然找三方库那边修复,然后给个新版本咯。

于是跑过去,告诉老羊,我想到了,这种问题,我们应该及时推动三方库那边解决,然后我们升级版本即可。

老羊听了后,恩,确实要找他们,但是如果是上线前遇到,推动肯定是来不及了,就是人家立马给你个新版本,直接升级风险也是比较大的。

然后老羊说道:

我看你对于反射找 hook 点已经比较熟悉了,其实还有一类 hook 更加好用,也更加稳定。

叫做字节码 hook。

怎么说?

我们的代码在打包过程中,会经过如下步骤:

.java -> .class -> dex -> apk

上面那个类的 evil 方法,从 class 文件的角度来看,其实都是字节码。

假设我们在编译过程中,这么做:

.java -> .class -> 拿到 Tools.class,修正里面的方法 evil 方法 -> dex -> apk

这个时机,其实构建过程中也给我们提供了,也就是传说的 Transform 阶段(这里不讨论 AGP 7 之后的变化,还是有对应时机的)。

小张又问,这个时机我知道,Tools.class 文件怎么修改呢?

老羊说,这个你去看看我的博客:

Android 进阶之路:ASM 修改字节码,这样学就对了!

不过话说回来,既然你会遇到这样的痛点,那么别的开发者肯定也会遇到。

这个时候应该怎么想?

小张:肯定有人造了好用的轮子。

老羊:恩,99%的情况,轮子肯定都造好了,剩下 1%,那就是你的机会了。

轻量级 aop 框架 lancet 出现

饿了么,很早的时候就开源了一个框架,叫 lancet。

github.com/eleme/lance…

这个框架可以支持你,在不懂字节码的情况下,也能够完成对对应方法字节码的修改。

代入到我们刚才的思路:

.java -> .class -> lancet 拿到 Tools.class,修正里面的方法 evilCode 方法 -> dex -> apk

小张:怎么使用 lancet 来修改我们的 evilCode 方法呢?

引入框架

在项目的根目录添加:

classpath 'me.ele:lancet-plugin:1.0.6'

在 module 的build.gradle 添加依赖和 apply plugin:

apply plugin: 'me.ele.lancet'

dependencies {
  implementation 'me.ele:lancet-base:1.0.6' // 最好查一下,用最新版本
}

开始使用

然后,我们做一件事情,把Tools 里面的 evilCode方法:

public static void evilCode() {
   int a = 1 / 0;
}

里面的这个代码给去掉,让它变成空方法。

我们编写代码:

package com.imooc.blogdemo.blog04;

import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class ToolsLancet {

   @TargetClass("com.imooc.blogdemo.blog04.Tools")
   @Insert("evilCode")
   public static void evilCode() {

  }

}

我们编写一个新的方法,保证其是个空方法,这样就完成让原有的 evilCode 中调用没有了。

其中:

  • TargetClass 注解:标识你要修改的类名;

  • Insert注解:表示你要往 evilCode 这个方法里面注入下面的代码

  • 下面的方法声明需要和原方法保持一致,如果有参数,参数也要保持一致(方法名、参数名不需要一致)

然后我们打包,看看背后发生了什么神奇的事情。

在打包完成后,我们反编译,看看 Tools.class

public class Tools {    
  //...
   public static void evilCode() {
       Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_evilCode();
  }

   private static void evilCode$___twin___() {
       int a = 1 / 0;
  }

   private static class _lancet {
       private _lancet() {
      }

       @TargetClass("com.imooc.blogdemo.blog04.Tools")
       @Insert("evilCode")
       static void com_imooc_blogdemo_blog04_ToolsLancet_evilCode() {
      }
  }
}

可以看到,原本的evilCode方法中的校验,被换成了一个生成的方法调用,而这个生成的方法和我们编写的非常类似,并且其为空方法。

而原来的 evilCode 逻辑,放在一个evilCode$___twin___()方法中,可惜这个方法没地方调用。

这样原有的 evilCode 逻辑就变成了一个空方法了。

我们可以大致梳理下原理:

lancet 会将我们注明需要修改的方法调用中转到一个临时方法中,这个临时方法你可以理解为和我们编写的方法逻辑基本保持一致。

然后将该方法的原逻辑也提取到一个新方法中,以备使用。

小张:确实很神奇,那这个原方法我们什么时候会使用呢?

老羊:很多时候,可能原有逻辑只是个概率很低的问题,比如发送请求,只有在超时等情况才发生错误,你不能粗暴的把人家逻辑移除了,你可能更想加个 try-catch 然后给个提示什么的。

这个时候你可以这么改:

package com.imooc.blogdemo.blog04;

import me.ele.lancet.base.Origin;
import me.ele.lancet.base.annotations.Insert;
import me.ele.lancet.base.annotations.TargetClass;

public class ToolsLancet {

   @TargetClass("com.imooc.blogdemo.blog04.Tools")
   @Insert("evilCode")
   public static void evilCode() {
       try {
           Origin.callVoid();
      } catch (Exception e) {
           e.printStackTrace();
      }
  }

}

我们再来看下反编译代码:

public class Tools {

   public static void evilCode() {
       Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_evilCode();
  }

   private static void evilCode$___twin___() {
       int a = 1 / 0;
  }

   private static class _lancet {
       @TargetClass("com.imooc.blogdemo.blog04.Tools")
       @Insert("evilCode")
       static void com_imooc_blogdemo_blog04_ToolsLancet_evilCode() {
           try {
               Tools.evilCode$___twin___();
          } catch (Exception var1) {
               var1.printStackTrace();
          }

      }
  }
}

看到没,不出所料中转方法内部调用了原有方法,然后外层包了个 try-catch。

是不是很强大,而且相对于运行时反射相关的 hook 更加稳定,其实他就像你写的代码,只不过是直接改的 class。

小张:所以我早上遇到的剪切板崩溃问题,其实也可以利用 lancet 加一个 try-catch。

老羊:是的,挺会举一反三的,当然也从侧面反映出来字节码 hook 的强大之处,几乎不需要找什么 hook 点,只要你有方法,就能干涉。

另外,我给你介绍的都是最基础的 api,你下去好好看看 lancet 的其他用法。

小张:好嘞,又学到了。

新的问题又来了

过了几日,忽然项目又遇到一个问题:

用户未授权读取剪切板之前,不允许有读取剪切板的行为,否则认定为不合规

小张听到这个任务,大脑快速运转:

这个读取剪切板行为的 API 是:

clipboardManager.getPrimaryClip();

搜索下项目中的调用,然后逐一修改。

先不说能不能搜索完整,这三方库里面肯定有,此外后续新增的代码如何控制呢?

另外之前学习 lancet,可以修改三方库代码,但是我也不能把包含clipboardManager.getPrimaryClip的方法全部列出来,一个个字节码修改?

还是解决不了后续新增,已经能保证全部搜出来呀。

最终心里嘀咕:别让我干,别让我干,八成是个坑。

这时候老羊来了句:这个简单,小张熟悉,他搞就行了。

小张:我...

重新思考一下,反正搜索出来,一一修改是不可能了。

那就从源头上解决:

系统肯定是通过framework,system 进程那边去判断是否读取剪切板的。

那么我们只要把:

clipboardManager.getPrimaryClip
IClipboard.getPrimaryClip(mContext.getOpPackageName(), mContext.getUserId());

内部的逻辑hook 掉,换掉IClipBoard 的实现,然后切到我们自己的逻辑即可。

懂了,这就是我之前想的系统服务的 hook 而已,难怪老羊安排给我,我给他说过这个。

于是乎...我开启了一顿写模式...

此处代码略。(确实可以,不过非本文主要内容)

正完成了 Android 10.0的测试,准备翻翻各个版本有没有源码修改,好适配适配,老羊走了过来。

说了句:这都两个小时过去了,你还没搞完?

小张:两个小时搞完?你来。

老羊:我让你自己看看 lancet 其他 api你没看?

这个用 lancet 就是送分题你知道吗?看好:

public class ToolsLancet {

   // 模拟用户同意后的状态
   public static boolean isAuth = true;

   @TargetClass("android.content.ClipboardManager")
   @Proxy("getPrimaryClip")
   public ClipData getPrimaryClip() {
       if (isAuth) {
           return (ClipData) Origin.call();
      }
       // 这里也可以 return null,毕竟系统也 return null
       return new ClipData("未授权呢", new String[]{"text/plain"}, new ClipData.Item(""));
  }
}

小张:这个不行呀,android.content.ClipboardManager类是系统的,不是我们写的,在打包阶段没有这个 class。

老羊:我当然知道,你仔细看,这次用的注解和上次有什么不同。

这次用的是:

  • @Proxy:意思就是代理,会代理ClipboardManager. getPrimaryClip到我们这个方法中来。

我们反编译看看:

原来的调用:

public static String getClipBoardStr(Context context) {
  ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
  ClipData primaryClip = clipboardManager.getPrimaryClip();
  ClipData.Item itemAt = primaryClip.getItemAt(0);
  if (itemAt == null) {
      return "";
  }
  CharSequence text = itemAt.getText();
  if (text == null) {
      return "";
  }
  return text.toString();
}

反编译的调用:

public class Tools {

   public static String getClipBoardStr(Context context) {
       ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService("clipboard");
       ClipData primaryClip = Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip(clipboardManager);
       Item itemAt = primaryClip.getItemAt(0);
       if (itemAt == null) {
           return "";
      } else {
           CharSequence text = itemAt.getText();
           return text == null ? "" : text.toString();
      }
  }

   private static class _lancet {
   
       @TargetClass("android.content.ClipboardManager")
       @Proxy("getPrimaryClip")
       static ClipData com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip(ClipboardManager var0) {
           return ToolsLancet.isAuth ? var0.getPrimaryClip() : new ClipData("未授权呢", new String[]{"text/plain"}, new Item(""));
      }
  }
}

看到没有,clipboardManager.getPrimaryClip()方法变成了Tools._lancet.com_imooc_blogdemo_blog04_ToolsLancet_getPrimaryClip,中转到了我们的hook 实现。

这次明白了吧:

  1. lancet 对于我们自己的类中方法,可以使用@Insert 指令;

  2. 遇到系统的调用,我们可以针对调用函数使用@Proxy 指令将其中转到中转函数;

好了,lancet 还有一些 api,你再下去好好看看。

完结

终于结束了,大家退出小张和老羊的对话场景。

其实字节码 hook 在 Android 开发过程中更为强大,比我们传统的找 Hook 点(单例,静态变量),然后反射的方式方便太多了,还有个最大的优势就是稳定。

当然lancet hook 有个前提就是要明确知道方法调用,如果你想 hook 一个类的所有调用,那么写起来就有点费劲了,可能并不如动态代理那么方便。

好了,话说回来:

之前有个小伙去面试,被问到:

如何收敛三方库里面线程池的创建?

你有想法了吗?

作者:鸿洋
来源:juejin.cn/post/7034178205728636941

收起阅读 »

Android 实现卡片堆叠,钱包管理效果(带动画)

先上效果图源码 github.com/woshiwzy/Ca…实现原理:1.继承LinearLayout 2.重写onLayout,onMeasure 方法 3.利用ValueAnimator 实施动画 4.在动画回调中requestLayout 实现动画效果...
继续阅读 »


先上效果图


源码 github.com/woshiwzy/Ca…

实现原理:

1.继承LinearLayout
2.重写onLayout,onMeasure 方法
3.利用ValueAnimator 实施动画
4.在动画回调中requestLayout 实现动画效果

思路:

1.用Bounds 对象记录每一个CardView 对象的初始位置,当前位置,运动目标位置

2.点击时计算出对应的view以及可能会产生关联运动的View的运动的目标位置,从当前位置运动到目标位置,然后以这2个位置作为动画参数实施ValueAnimator动画,在动画回调中触发onLayout,达到动画的效果。

重写adView 方法,确保新添加的在这里确保所有的子view 都有一个初始化的bounds位置

   @Override
   public void addView(View child, ViewGroup.LayoutParams params) {
       super.addView(child, params);
       Bounds bounds = getBunds(getChildCount());
  }

确保每个子View的测量属性宽度填满父组件

    boolean mesured = false;
   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
       if (mesured == true) {//只需要测量一次
           return;
      }
       mesured = true;
       int childCount = getChildCount();
       int rootWidth = getWidth();
       int rootHeight = getHeight();
       if (childCount > 0) {
           View child0 = getChildAt(0);
           int modeWidth = MeasureSpec.getMode(child0.getMeasuredWidth());
           int sizeWidth = MeasureSpec.getSize(child0.getMeasuredWidth());

           int modeHeight = MeasureSpec.getMode(child0.getMeasuredHeight());
           int sizeHeight = MeasureSpec.getSize(child0.getMeasuredHeight());

           if (childCount > 0) {
               for (int i = 0; i < childCount; i++) {
                   View childView = getChildAt(i);
                   childView.measure(MeasureSpec.makeMeasureSpec(sizeWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(sizeHeight, MeasureSpec.EXACTLY));
                   int top = (int) (i * (sizeHeight * carEvPercnet));
                   getBunds(i).setTop(top);
                   getBunds(i).setCurrentTop(top);
                   getBunds(i).setLastCurrentTop(top);
                   getBunds(i).setHeight(sizeHeight);
              }

          }

      }
  }

重写onLayout 方法是关键,是动画触发的主要目的,这里layout参数并不是写死的,而是计算出来的(通过ValueAnimator 计算出来的)

@Override
   protected void onLayout(boolean changed, int sl, int st, int sr, int sb) {
       int childCount = getChildCount();
       if (childCount > 0) {
           for (int i = 0; i < childCount; i++) {
               View view = getChildAt(i);
               int mWidth = view.getMeasuredWidth();
               int mw = MeasureSpec.getSize(mWidth);
               int l = 0, r = l + mw;
               view.layout(l, getBunds(i).getCurrentTop(), r, getBunds(i).getCurrentTop() + getBunds(i).getHeight());
          }
      }
  }

源码

github: github.com/woshiwzy/Ca…

作者:Sand
来源:juejin.cn/post/7073371960150851615

收起阅读 »

Android 13这些权限废弃,你的应用受影响了吗?

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。Android 13 已被废弃的权限许多用户告诉我们,文件和媒体权限让他们很...
继续阅读 »

无论是更改个人头像、分享照片、还是在电子邮件中添加附件,选择和分享媒体文件是用户最常见的操作之一。在听取了 Android 用户反馈之后,我们对应用程序访问媒体文件的方式做了一些改变。

Android 13 已被废弃的权限

许多用户告诉我们,文件和媒体权限让他们很困扰,因为他们不知道应用程序想要访问哪些文件。

在 Android 13 上废弃了 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,用更好的文件访问方式代替这些废弃的 API。

从 Android 10 开始向共享存储中添加文件不需要任何权限。因此,如果你的 App 只在共享存储中添加文件,你可以停止在 Android 10+ 上申请任何权限。

在之前的系统版本中 App 需要申请 READ_EXTERNAL_STORAGE 权限访问设备的文件和媒体,然后选择自己的媒体选择器,这为开发者增加了开发和维护成本,另外 App 依赖于通过 ACTION_GET_CONTENT 或者 ACTION_OPEN_CONTENT 的系统文件选择器,但是我们从开发者那里了解到,它感觉没有很好地集成到他们的 App 中。


图片选择器

在 Android 13 中,我们引入了一个新的媒体工具 Android 照片选择器。该工具为用户提供了一种选择媒体文件的方法,而不需要授予对其整个媒体库的访问权限。

它提供了一个简洁界面,展示照片和视频,按照日期排序。另外在 "Albums" 页面,用户可以按照屏幕截图或下载等等分类浏览,通过指定一些用户是否仅看到照片或视频,也可以设置选择最大文件数量,也可以根据自己的需求定制照片选择器。简而言之,这个照片选择器是为私人设计的,具有干净和简洁的 UI 易于实现。


我们还通过谷歌 Play 系统更新 (2022 年 5 月 1 日发布),将照片选择器反向移植到 Android 11 和 12 上,以将其带给更多的 Android 用户。

开发一个照片选择器是一个复杂的项目,新的照片选择器不需要团队进行任何维护。我们已经在 ActivityX 1.6.0 版本中为它创建了一个 ActivityResultContract。如果照片选择器在你的系统上可用,将会优先使用照片选择器。

// Registering Photo Picker activity launcher with a max limit of 5 items
val pickMultipleVisualMedia = registerForActivityResult(PickMultipleVisualMedia(5)) { uris ->
   // TODO: process URIs
}
// Launching the photo picker (photos & video included)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
复制代码

如果希望添加类型进行筛选,可以采用这种方式。

// Launching the photo picker (photos only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))
// Launching the photo picker (video only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.VideoOnly))
// Launching the photo picker (GIF only)
pickMultipleVisualMedia.launch(PickVisualMediaRequest(PickVisualMedia.SingleMimeType("image/gif")))
复制代码

可以调用 isPhotoPickerAvailable 方法来验证在当前设备上照片选择器是否可用。

ACTION_GET_CONTENT 将会发生改变

正如你所见,使用新的照片选择器只需要几行代码。虽然我们希望所有的 Apps 都使用它,但在 App 中迁移可能需要一些时间。

这就是为什么我们使用 ACTION_GET_CONTENT 将系统文件选择器转换为照片选择器,而不需要进行任何代码更改,从而将新的照片选择器引入到现有的 App 中。


针对特定场景的新权限

虽然我们强烈建议您使用新的照片选择器,而不是访问所有媒体文件,但是您的 App 可能有一个场景,需要访问所有媒体文件(例如图库照片备份)。对于这些特定的场景,我们将引入新的权限,以提供对特定类型的媒体文件的访问,包括图像、视频或音频。您可以在文档中阅读更多关于它们的内容。

如果用户之前授予你的应用程序 READ_EXTERNAL_STORAGE 权限,系统会自动授予你的 App 访问权限。否则,当你的 App 请求任何新的权限时,系统会显示一个面向用户的对话框。

所以您必须始终检查是否仍然授予了权限,而不是存储它们的授予状态。


下面的决策树可以帮助您更好的浏览这些更改。


我们承诺在保护用户隐私的同时,继续改进照片选择器和整体存储开发者体验,以创建一个安全透明的 Android 生态系统。

新的照片选择器被反向移植到所有 Android 11 和 12 设备,不包括 Android Go 和非 gms 设备。


原文: medium.com/androiddeve…
译者:程序员 DHL
来源:
juejin.cn/post/7161230716838084616



收起阅读 »

安卓之如何优雅的处理Activity回收突发事件

情景与原因前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此...
继续阅读 »

情景与原因

前面的文章说过,我的一个业务要从页面A进入页面B,也就意味着我的应用出现了在ActivityA的基础上启动了ActivityB的情景,这个时候ActivityA就进入了停止状态,但这个时候如果出现系统内存不足的情况,就会把ActivityA回收掉,此时用户按下Back键返回A,仍然会正常显示A,但此时的A是执行onCreate()方法加载的,而不是执行onRestart()方法,也就是说我们的ActivityA页面是被重新创建出来的。

那有什么区别呢,这就是我们平时网上填表之类的最讨厌的情景会浮现了,比如你好不容易把信息填好,点击下一步,然后发现想修改上个页面信息,哎,这时候你会发现由于系统内存不足回收掉了A,于是你辛辛苦苦填好的信息都没了,这太痛了。这一情景的出现就说明回收了A再重新创建会导致我们在A当中存的一些临时数据与状态都可能被丢失。

这就是我们今天要解决的问题。

解决方法

虽然出现这个问题是是否影响用户体验的,但解决方法是非常简单的,因为我们的Activity中还提供了一个onSaveInstanceState()回调方法,它就可以保证在Activity被回收之前一定被调用,而我们就可以利用这一特性去解决上述问题。

方法介绍

onSaveInstanceState()方法中会携带一个Bundle类型的参数,而Bundle提供了一系列方法用于保存我们想要的数据,其中如putString()就是用于保存字符串的,而putInt()方法显而易见也是保存整型数据的,而每个保存方法都需要传入两个参数,其中第一个参数我们称之为键,是用于在Bundle中取值的特定标记,第二个参数是我们要保存的内容,属于键值对类型,学过数据库的一般都知道。

写法

如下,我们可以这么去写:

override fun onSaveInstanceState(outState: Bundle) {
   super.onSaveInstanceState(outState)
   val tempData = "your temp data"
   outState.putString("data_key", tempData)
}
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContentView(R.layout.activity_main)
  if (savedInstanceState != null) {
      val tempData = savedInstanceState.getString("data_key")
  }
  ...
}

在onSaveInstanceState()中保存你想要的数据,在onCreate()中给取出来,这样就不用害怕再创建的时候数据丢失了,不过如果是横竖屏切换造成数据丢失还是依照前文ViewModel的写法更好。

结语

其实基础往往是最容易忽视的,我们之前光在那说高楼大厦如何建成,但地基并不牢靠,所以还是需要落实到基础上最好。

作者:ObliviateOnline
来源:juejin.cn/post/7158096746583687205

收起阅读 »

雪球 Android App 秒开实践

一、背景启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的...
继续阅读 »

一、背景

启动速度可以说是一个 APP 的门面,对用户体验至关重要。随着业务不断增加,需要初始化的任务也越来越多,如果放任不管,启动时长会逐步增加,为此雪球客户端针对应用启动时长做了大量优化工作。本文从应用启动基本原理出发,总结了雪球客户端启动优化的思路和遇到的问题。主要包括启动原理介绍、优化方案和线上验证等三方面内容。

二、启动原理

根据 Google 官方文档,应用启动分为以下三种类型:

  • 冷启动

  • 热启动

  • 温启动

冷启动

冷启动是指 APP 进程被杀死(系统回收、用户手动关闭等),启动 APP 需要系统重新创建应用进程,从用户点击应用桌面图标到第一个页面加载完成的全部过程。冷启动是启动类型中耗时最长的一种,也是启动优化最关键的优化点,下面我们来看一下冷启动的启动过程。


从上图可以看出 APP 冷启动可以分为以下三个过程:

  • 用户点击桌面 APP 图标,调用 Launcher.startActivity ,由 Binder 通知 system_server 进程,system_server 内部使用 ActivityManagerService 通知 zygote 创建应用子进程

  • 应用进程创建完成后,会加载 ActivityThread 并调用 ActivityThread.main 方法,用来实例化 ApplicationThread 、Lopper 和 Handler

  • ActivityThread 内部调用 attach 方法进行 Binder 通信回到 system_server 进程,执行 ActivityManagerService.attachApplication 完成 Application 的创建,同时启动第一个 Activity

我们可以换一种通俗易懂的描述:

想象一下把 Launcher 比做手机桌面,桌面里面很多 APP 可以理解成 Launcher 的孩子,zygote 就是一个进程,system_server 好比服务大管家,ActivityThread 好比每个 APP 进程自己的管家。

启动 APP 首先要通知服务大管家 (system_server),服务大管家 (system_server)收到通知后,会跟它的第一对接人 zygote 进程联系,请求 zygote 创建一个属于孩子的家,也就是 APP 自己的进程,进程创建完成后,接下来是属于孩子自己的工作,它开始使用自己的管家 ActivityThread 布置自己的家,可以简单把 Application 比做是大门,把 Activity 比作是卧室,AMS 是装修团队,ActivityThread 会不断和 AMS 交互,直到 Application 和 Activity 创建完毕,至此一个 APP 就启动完成了。

热启动

热启动是指应用程序从后台被唤起,此时应用进程仍然存在,应用启动无需创建子进程,但是可能会重新执行 Activity 的生命周期,在热启动中,系统的所有工作就是将您的 Activity 带到前台,只要应用的所有 Activity 仍驻留在内存中,应用就不必重复执行对象初始化、布局和绘制,例如用户按下 back 或者 home 键回到后台。

温启动

温启动包含了在冷启动期间发生的部分操作,同时它的开销要比热启动高,例如用户在退出应用后又重新启动应用,此时应用进程仍然存在,但应用必须通过调用 onCreate() 从头开始重新创建 Activity

冷启动是三种启动状态中最耗时的一种,启动优化也是在冷启动的基础上进行优化,热启动和温启动相对耗时较少,暂不考虑优化。

三、问题归因

工欲善其事必先利其器,要想优化启动时长,首先必须知道应用启动过程中发生了什么,以及耗时方法是哪些,下图列举了一些 APP 常用的性能检测工具:


adb shell

获取应用启动总时长 adb 命令:adb shell am start -W [packageName]/[packageName.xActivity]

详细使用可参考文档:developer.android.google.cn/studio/comm…


参数说明:

Activity:应用启动的第一个Activity

TotalTime:应用启动总时长,包括应用进程创建、Application 创建和第一个 Activity 创建并绘制完成到显示的所有过程,冷启动的情况下我们只需要关注 TotalTime 即可

Displayed

displayed 使用比较简单,我们只需要在 Logcat 中过滤关键字 displayed 即可看到应用启动的总时长,如下图所示,displayed 打印的时长跟 adb shell 几乎相同,也就是一次冷启动的总时长。


adb shell 和 displayed 都可以帮助我们快速获取应用启动时长,但是无法获取具体耗时方法的堆栈信息,应用启动的具体信息我们可以使用 Systrace 和 Traceview 来获取。

Systrace

Systrace 是 Android 平台自带的命令行工具,可记录短时间内的设备活动,并保存在压缩的文本文件中,该工具会生成一份报告,其中汇总了 Android 内核中的数据,例如 CPU 调度程序、磁盘活动和应用线程。

Systrace 工具默认在 Android SDK 里面,路径一般为 Android/sdk/platform-tools/systrace

使用 systrace 生成应用冷启动具体信息

  • 如果没有配置环境变量,先切到 systrace 目录下 cd ~/Library/Android/sdk/platform-tools/systrace

  • 执行 systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund

或者直接用绝对路径执行 systrace

详细使用可参考文档:developer.android.google.cn/topic/perfo…

python ~/Library/Android/sdk/platform-tools/systrace/systrace.py -t 10 -o /Users/liuyakui/trace.html -a com.xueqiu.fund

systrace 报告如下图所示,这里仅摘取了启动优化所需要的主要信息:


  • 区域1代表 CPU 使用率,柱子越高,越密集代表 CPU 使用率越高

  • 区域2代表 CPU 编号,该设备是8核处理器,编号0-7,点击 CPU 编号区域,可以查看当前正在运行的任务

  • 区域3代表所有线程和方法具体耗时情况,可以帮助我们定位具体耗时方法

从上图可以看出在0-3秒内,CPU 平均利用率较低,特别是1-3秒这段时间,CPU 几乎处于闲置状态,提高 CPU 利用率,充分发挥 CPU 的性能,是我们主要的优化方向。

上述三部分区域所提供的信息,基本上可以帮助我们定位启动耗时问题,它提供了 CPU 使用情况以及每个线程工作情况,但它不能告诉我们具体的问题代码在哪里,我们要确定具体的耗时代码,可以使用 Traceview 工具。

Traceview

Traceview 能够以图形化的形式展示线程的工作状态,以及方法的调用堆栈和调用链,我们以 application onCreate 为例,统计 onCreate() 内部详细的方法调用,并生成 trace 报表。

详细使用可参考文档:developer.android.google.cn/studio/prof…

@Override
public void onCreate() {
   super.onCreate();
   Debug.startMethodTracing("app_trace");

   //初始化代码...

   //...

   Debug.stopMethodTracing();
}

应用启动完成后,会在 /sdcard/Android/data/com.xueqiu.fund/files 路径下生成一个 app_trace.trace 文件,直接用 AndroidStudio 打开即可,如下图所示:


trace 文件详细展示了每个线程的工作情况,以及每个线程内部具体的方法调用情况,下面简单介绍一下trace 报表中最重要的三块区域:

  • 区域1代表 CPU 使用情况,可以拖拽选择时间段

  • 区域2代表当前线程工作信息,截图所示为当前主线程在0-5s内所有的方法调用情况

  • 区域3代表当前线程内部的方法调用堆栈,以及方法耗时等信息,使用 Top Down 和 Bottom Up 可以对方法正反排序

trace 报表清晰的展示了每个线程对应的所有方法的调用链和耗时情况,很好的帮助我们定位启动过程中具体问题所在,为优化方案提供了重要的参考依据。

四、优化方案

经过上述分析,APP 启动问题主要集中在以下两个阶段:

  • Application 创建

  • 闪屏页绘制

因此下面主要是针对这两方面进行优化

Application 创建优化

从上述 Traceview 报表可以看出,影响 Application 创建的代码主要集中在 initThirdLibs 内部,我们来看一下 initThirdLibs 内部初始化代码执行流程。


initThirdLibs 内部包含了雪球客户端所有的初始化项,这些初始化任务不分主次和优先级都在主线程顺序执行,中间任意一个任务阻塞,都会影响 Application 的创建,而且随着业务不断迭代,初始化任务越来越多,Application 的创建时长也会继续累加。

因此梳理 initThirdLibs 内部任务的优先级,通过合理的方式统一调度,并对各个任务进行延迟初始化是优化 Application 创建的重要内容,延迟初始化主要实现的目标分为以下三点:

  • 提高 CPU 利用率,充分发挥 CPU 性能

  • 初始化任务 Task 处理,降低维护成本和提高任务调度的灵活性

  • 多线程处理,梳理各个 Task 的优先级,形成一个有向无环图

Task 任务流程图如下:


关于启动器实现核心逻辑为,自定义线程池,根据设备 CPU 情况动态计算线程数量,保证所有 Task 任务并发执行,并且相互独立,所有 Task 执行完毕后会最后执行 Final Task 用来做一些收尾的工作,或者有强依赖的任务,也可以放到 Final Task 中进行,这里推荐以下两种实现方式:

  • CountDownLatch

  • 自定义线程池

启动器伪代码如下:

//这里只是一段伪代码,帮助大家理解启动器的基本实现原理

TaskManager manager = new TaskManager();
ExecutorService service = createThreadPool();
final Task1 task1 = new Task1(1);
final Task2 task2 = new Task2(2);
final Task3 task3 = new Task3(3);
final Task4 task4 = new Task4(4);
for (int i = 0i < ni++) {
   Runnable runnable = new Runnable() {
       @Override
       public void run() {
           manager.get(i).start();
      }
  };
   service.execute(runnable);
}

Task 调度完成后,将不依赖主线程的初始化任务,移动到并发 Task 中进行延迟初始化,进行统一管理并且避免阻塞主线程,提高 CPU 利用率。

闪屏页绘制优化

目前闪屏页主要承载的是业务广告,通过优化广告加载的逻辑可以间接调整页面的布局结构。

布局结构

闪屏页会预加载广告数据存到本地,每次应用启动从本地读取广告数据,这里我们可以优化无广告页面展示的逻辑,目前闪屏页无广告的时候仍然会加载布局文件,并设置默认的页面停留时长,理论上如果页面无广告,闪屏页创建完成后可以直接进入首页,不用加载页面的布局文件从而减少页面绘制时间,调整后页面广告加载逻辑核心代码如下:

private void prepareSplashAd() {
   //读取广告数据
   String jsonString = PublicSetting.getInstance().getSplashAd();
   if (TextUtils.isEmpty(jsonString)) {
       //无广告,关闭页面,进入首页
       exitDelay();
       return;
  }

   //加载布局文件
   View parentView = inflateView();
   setContentView(parentView);
   //显示广告
   AD todayAd = ads.get(0);
   showSplashAd(todayAd.imgUrltodayAd.linkUrl);
}

优化结果

经过多个版本的线上数据采样,启动时长明显下降,以华为 Mate 30E Pro 为例,效果对比如下:

优化前


优化后


从上面对比中可以看到,在5年以内的旗舰机型上,启动时长从原来的 1.9s - 2.5s 降低到 0.75s - 1.2s ,整体降低60%左右,可以达到秒开的效果!CPU 活动转为密集型,充分发挥 CPU 的性能,提高了 CPU 的利用率。

五、总结

本文先介绍了应用启动的基本原理,以及如何通过各种检测工具定位影响启动速度的原因,最后重点阐述 Application 创建和闪屏页绘制两个阶段的优化方案。同时它也代表一组最佳实践,在后续的性能优化中,都是不错的选择。

其实启动优化的方案还有很多,但我们除了要关注启动优化本身,更需要制定长远的规划,设计适合自己的方案,为后续业务迭代做好铺垫,避免再次出现启动时长逐步增加的问题。

作者:雪球工程师团队
来源:juejin.cn/post/7081606242212413447

收起阅读 »

我与 Groovy 不共戴天

来到新公司后,小灵通开始接手了核心技术-快编插件,看到传说中的核心技术,小灵通傻眼了,啊这,groovy 写的插件,groovy 认真的嘛,2202 年了,插件咋还用 groovy 写呢,我新手写插件也换 kotlin 了,张嘴就是 这辈子都不可能写 groo...
继续阅读 »


来到新公司后,小灵通开始接手了核心技术-快编插件,看到传说中的核心技术,小灵通傻眼了,啊这,groovy 写的插件,groovy 认真的嘛,2202 年了,插件咋还用 groovy 写呢,我新手写插件也换 kotlin 了,张嘴就是 这辈子都不可能写 groovy,甭想了。 但是嘛,工作不寒碜,学学呗。

一开始和组里几个大佬聊下来,磨刀霍霍准备对历史代码动刀,全迁移到 kotlin 上爽一发,但发现。。。咦,代码好像看不懂诶,我不知道 kt 对应的写法是啥样的。文章结束,小灵通因此被辞退。

开个玩笑,我现在还是在岗状态。工作还是要继续的。既然能力有限我全部迁不过去,那我可以做到新需求用 kotlin 来写嘛,咦,这就有意思了。

Groovy 和 java 以及 kotlin 如何混编

怎么实现混编

我不会嘛,看看官方怎么写的。gradle 源码有这么段代码来阐释了是怎么优先 groovy 编译 而非 java 编译.

// tag::compile-task-classpath[]
tasks.named('compileGroovy') {
   // Groovy only needs the declared dependencies
   // (and not longer the output of compileJava)
   classpath = sourceSets.main.compileClasspath
}
tasks.named('compileJava') {
   // Java also depends on the result of Groovy compilation
   // (which automatically makes it depend of compileGroovy)
   classpath += files(sourceSets.main.groovy.classesDirectory)
}
// end::compile-task-classpath[]

噢,可以这么写啊,那我是不是抄下就可以了,把名字改改。我就可以写 kotlin 了,欧耶!

compileKotlin {
   classpath = sourceSets.main.compileClasspath
}
compileGroovy {
   classpath += files(sourceSets.main.kotlin.classesDirectory)
}

跑一发,没有意外的话,你会看到这个报错。


诶,为啥我照着抄就跑不起来呢?我怀疑是 kotlin classesDiretory 有问题,断点看一波 compileGroovy 这个 task 的 sourceSets.main.kotlin.classesDirectory 是个啥。大概长这样, 是个 DefaultDirectoryVar 类。


诶,这是个啥,一开始我也看不太懂,觉得这里的 value 是 undefined 怪怪的,也不确定,那我看看其他正常的 classesDirectory 是啥


其实到这里可以确定应该是 kotlin 的 classDirectory 在此时是不可用的状态,印证下自己猜想,尝试添加 catch 的断点,确实是这样

具体为啥此时还不可用,我没有更详细的深入了,有大佬知道的,可以不吝赐教下。

SO 搜了一波解答,看到一篇靠谱的回复 compile-groovy-and-kotlin.

compileGroovy.dependsOn compileKotlin
compileGroovy.classpath += files(compileKotlin.destinationDir)
复制代码

试了一下确实是可以的,但为啥这样可以了呢?以及最上面官方的代码是啥意思呢?还有一些奇奇怪怪的名词是啥,下面吹一下

关于 souceset

我们入门写 android 时,都看到 / 写过类似这样的代码

sourceSets {
   main.java.srcDirs = ['src/java']
}

我对他的理解是指定 main sourceset 下的 java 的源码目录。 SourceSets 是一个 Sourset 的容器用来创建一个个的 SourceSet, 比如 main, test. 而 main 下的 java, groovy, kotlin 目录是一个编译目录(SourceDirectorySet),编译实质是找到一个个的编译目录,然后将他们变成 .class 文件放在 build/classes/sourceDirectorySet 下面, 也就是 destinationDirectory。

像 main 对应的是 SourceSet 接口,其实现是 DefaultSourceSet。而 main 下面的 groovy, java, kotlin 是 SourceDirectorySet 接口,其实现是 DefaultSourceDirectorySet。

官方 gradle 对于 sourceset 的定义是:

  • the source files and where they’re located 定位源码的位置

  • the compilation classpath, including any required dependencies (via Gradle configurations) 编译时的 class path

  • where the compiled class files are placed 编译出的 class 放在哪

输入文件 + 编译时 classpath 经过 AbstractCompile Task 得到 输出的 class 目录


第二个 编译时的 classpath,在项目里也见过,sourceSetImplementation 声明 sourceSet 的依赖。第三个我很少见到,印象不深,SourceDirectorySet#destinationDirectory 用来指定 compile task 的输出目录。而 SourceDirectorySet#classesDirectory 和这个值是一致的。再重申一遍这里的 SourceDirectorySet 想成是 DSL 里写的 java, groovy,kt 就好了。

官方文档对于 classesDirectory 的描述是

The directory property that is bound to the task that produces the output via SourceDirectorySet.compiledBy(org.gradle.api.tasks.TaskProvider, java.util.function.Function). Use this as part of a classpath or input to another task to ensure that the output is created before it is used. Note: To define the path of the output folder use SourceDirectorySet.getDestinationDirectory()

大意是 classesDirectory 与这个 compile task 的输出是相关联的,具体是通过 SourceDirectorySet.compiledBy() 方法,这个字段由 destinationDirectory 字段决定。查看 DefaultSourceDirectorySet#compiledBy 方法

    public <T extends Task> void compiledBy(TaskProvider<T> taskProvider, Function<T, DirectoryProperty> mapping) {
       this.compileTaskProvider = taskProvider;
       taskProvider.configure(task -> {
           if (taskProvider == this.compileTaskProvider) {
               mapping.apply(task).set(destinationDirectory);
          }
      });
       classesDirectory.set(taskProvider.flatMap(mapping::apply));
  }

雀食语义上 classesDirectory == destinationDirectory。

现在我们可以去理解下 官方的 demo 了,官方的 demo 简单说就是优先执行 Compile Groovy task, 再去执行 Compile Java task.

tasks.named('compileGroovy') {
   classpath = sourceSets.main.compileClasspath // 1
}
tasks.named('compileJava') {
   classpath += files(sourceSets.main.groovy.classesDirectory) // 2
}

可能看不懂的地方是 1,2 注释处做了啥, 1 处我问了我们组大佬,这是重置了 compileGroovy task 的 classpath 使其不依赖 compile java classpath,在 GroovyPlugin 源码中有那么一句代码

        classpath.from((Callable<Object>) () -> sourceSet.getCompileClasspath().plus(target.files(sourceSet.getJava().getClassesDirectory())));

可以看到 GroovyPlugin 其实是依赖于 java 的 classpath 的。这里我们需要改变 groovy 和 java 的编译时序需要把这层依赖断开。

2呢,使 compileJava 依赖上 compileGroovy 的 output property,间接使 compileJava dependson compileGroovy 任务。

具体为啥 Kotlin 的不行,俺还没搞清楚,知道的大佬可以指教下。

而 SO 上的这个答复其实也是类似的,而且更直接

compileGroovy.dependsOn compileKotlin
compileGroovy.classpath += files(compileKotlin.destinationDir)

使 compileGroovy 依赖于 compileKotlin 任务,再让 compileGroovy 的 classPath 添加上 compileKotlin 的 output. 既然任务的 classPath 添加 另一个任务的 output 会自动依赖上另一个 task。那其实这么写也是可以的

compileGroovy.classpath += files(compileKotlin.destinationDir)

实验了下雀食是可以跑的. 那既然 Groovy 和 Java 都包含 main 的 classpath,是不是 compileKotlin 的 classpath 置为 main,那 compileGroovy 会自动依赖上 compileKotlin。试试呗

compileKotlin.classpath = sourceSets.main.compileClasspath

可以看到 kotlin 的执行顺序雀食跑到了最前面。

在项目实操中,我发现 Kotlin 跑在了 compile 的最前面,那其实 kotlin 的类里面是不能依赖 java 或者 groovy 的任何依赖的。这也符合预期,不然就会出现依赖成环,报 Circular dependsOn hierarchy found in the Kotlin source sets 错误。我个人观点这是一种对历史代码改造的折衷,在新需求上使用 kotlin 进行开发,一些功能相同的工具类能翻译成 kt 就翻译,不能就重写一套。

小结

  • 在这节讲了两种实现混编的方案。写法不同,本质都是使一个任务依赖另一个任务的 output

// 1
compileGroovy.classpath += files(compileKotlin.destinationDir)
// 2
compileKotlin.classpath = sourceSets.main.compileClasspath
  • 我对于 SourceSet 和 SourceDirectorySet 的理解

  • 项目中实践混编方案的现状

Groovy 有趣的语法糖

在写 Groovy 的过程中,我遇到一个头大的问题,代码看不懂,里面有一些奇奇怪怪没见过的语法糖,乍一看就懵了,你要不一起瞅瞅。

includes*.tasks

我司的仓库是大仓的结构,仓库和子仓之间是通过 Composite build 构建联系的。那么怎么使主仓的 task 触发 includeBuild 的仓库执行对应仓库呢?是通过这行代码实现的

tasks.register('publishDeps') {
dependsOn gradle.includedBuilds*.task(':publishIvyPublicationToIvyRepository')
}

这里的 includeBuilds*.task 后面的 *.task 是啥?includeBuilds 看源码发现是个 List。我不懂 groovy,但好歹我能看懂 kotlin, 我看看官方文档右边对应的 kt 写法是啥?

tasks.register("publishDeps") {
dependsOn(gradle.includedBuilds.map { it.task(":publishMavenPublicationToMavenRepository") })
}

咦嘿,原来是个 List 的 map 操作,骚里骚气的。翻了翻原来是个 groovy 的语法糖,写个代码试试看看他编译到 class 是啥样子

def list = ["1", "22", "333"]
def lengths = list*.size()
lengths.forEach{
println it
}

编译成 class

        Object list = ScriptBytecodeAdapter.createList(new Object[]{"1", "22", "333"});
Object lengths = ScriptBytecodeAdapter.invokeMethod0SpreadSafe(Groovy.class, list, (String)"size");
var1[0].call(lengths, new Groovy._closure1(this, this));

在 ScriptBytecodeAdapter.invokeMethod0SpreadSafe 实现内部其实还是新建了一个 List 再逐个对 List 中元素进行 map.

String.execute

这是执行一个 shell 指令,比如 "ls -al".execute(), 刚看到这个的时候认为这个东西类似 kotlin 的扩展函数,点进去看实现发现不一样

public static Process execute(final String self) throws IOException {
return Runtime.getRuntime().exec(self);
}

可以看到 receiver 是他的第一个参数,莫非这是通用的语法糖,我试试写了个

public static String deco(final String self) throws IOException {
return self + "deco"
}
// println "".deco()

运行下,哦吼,跑不了,报了 MissingMethodException。看样子是不通用的。翻了翻 groovy 文档,找到了这个文档

Static methods are used with the first parameter being the destination class, i.e. public static String reverse(String self) provides a reverse() method for String.

看样子这个语法糖是 groovy 内部定制的,我不清楚有没有支持开发定制的方式,知道的大佬可以评论区留言下。

Range 怎么写

groovy 也有类似 kotlin 的 Range 的概念,包含的 Range 是 .. , 不包含右边界(until)的是 ..<

Try with resources

我遇到过一个 OKHttp 连接泄露的问题,代码原型大概是这样

if (xxx) {
response.close()
} else {
// behavior
}

定位到是 Response 没有在 else 的分支上进行 close,当然可以简单在 else 分支上进行 close, 并在外层补上 try, catch 兜底,但在 Effective Java 一书提及针对资源关闭 try-with-resource 优于 try cactch。但我尝试像 java 一样写 try-with-resource,发现嗝屁了,直接报红,我去 SO 上搜了一波 groovy 的 try-with-resource. Groovy 是通过 withCloseable 扩展来实现,看这个方法的声明与 Process#execute 语法糖类似—public static def withCloseable(Closeable self, Closure action) . 最终改造后的代码是这样的

Response.withCloseable { reponse ->
if (xxx) {

} else {

}
}
<<

这个是 groovy 中的左移运算符也是可以重载的,而 kotlin 是不支持的。他运用比较多的场景。起初我印象中 Task 的 是覆写了这个运算符作为 doLast 简易写法,现在 gradle7.X 的版本上是没有了。其它常见的是文件写入操作, 列表添加元素。

def file = new File("xxx")
file << "text"
def list = []
list << "aaa"

Groovy 的一家之言

如果 kotlin 是 better java, 那么 groovy 应该是 more than java,它的定位更加偏向脚本一些,更加动态化(从它反编译的字节码可见一斑),上手曲线较高,但一个人精通这个语言,并且独立维护一个项目,其实 groovy 的开发效率并不会比 kotlin 和 java 差,感受比较深切的是 maven publish 的例子,看看插件中 groovy 和 kotlin 的写法上的不同。

// Groovy
def mavenSettings = {
           groupId 'org.gradle.sample'
           artifactId 'library'
           version '1.1'
      }
def repSettings = {
           repositories {
               maven {
                   url = mavenUrl
              }
          }
      }

afterEvaluate {
 publishing {
     publications {
         maven(MavenPublication) {
             ConfigureUtil.configure(mavenSettings, it)
             from components.java
        }
    }
    ConfigureUtil.configure(repoSettings, it)
}
 def publication = publishing.publications.'maven' as MavenPublication
 publication.pom.withXml {
    // inject msg
}
}
// Kotlin
// Codes are borrowed from (sonatype-publish-plugin)[https://github.com/johnsonlee/sonatype-publish-plugin/]
fun Project.publishing(
       config: PublishingExtension.() -> Unit
) = extensions.configure(PublishingExtension::class.java, config)
val Project.publishing: PublishingExtension
   get() = extensions.getByType(PublishingExtension::class.java)

val mavenClosure = closureOf<MavenPublication> {
  groupId = "org.gradle.sample"
  artifactId = "library"
  version = "1.1"
}
val repClosure = closureOf<PublishingExtension> {
   repositories {
       maven {
           url = mavenUrl
      }
  }
}
afterEvaluate {
   publishing {
       publications {
           create<MavenPublication>("maven") {
              ConfigureUtil.configure(mavenClosure, this)
               from(components["java"])
          }
      }
      ConfigureUtil.configure(repoClosure, this)
  }
 
   val publication = publishing.publications["maven"] as MavenPublication
   publication.pom.withXml {
            // inject msg
  }
}

我觉得吧,如果像我们大佬擅长 groovy 的话,而且是一个人开发的商业项目,插件里的确写 groovy 会更快,更简洁,那为什么不呢?这对他来说是种善,语言没有优劣,动态性和静态语言优劣我不想较高下,这因人而异。


我选择 kotlin 是俺不擅长写 groovy 啊,我写了几个月 groovy 每次改动插件发布后再应用第一次都会有语法错误,调试的头皮发麻,所以最后搞了个折衷方案,新代码用 kotlin, 旧代码用 groovy 继续写。而且参考了 KOGE@2BAB 文档,发现咦,gradle 正面回应过 groovy 与 kotlin 之争. "Prefer using a statically-typed language to implement a plugin"@Gradle。嗯, 我还是继续写 Kotlin 吧。

作者:小灵通

来源:juejin.cn/post/7084949825866694686

收起阅读 »

浅谈2022Android端技术趋势,什么值得学?

引言回头去看 2021,过的似乎那么快,不敢相信我已经从事 Android 开发两年了,不免生出一些感叹。那么 2022 ,Android 端会有什么技术趋势吗?或者什么 [新] 技术值得去学? 又或者对我来说,现在什么 [值得] 去学?本文将通过我个人的技术...
继续阅读 »

引言

回头去看 2021,过的似乎那么快,不敢相信我已经从事 Android 开发两年了,不免生出一些感叹。

那么 2022Android 端会有什么技术趋势吗?或者什么 [新] 技术值得去学? 又或者对我来说,现在什么 [值得] 去学?

本文将通过我个人的技术学习经历去分析我们应该怎么选用某个技术,希望对大家能有所帮助。

回头看

让我们把时间切回过去,最近几年我都给自己加了哪些技术点?

2019-2020

  • Kotlin,协程

  • MVPHiltMVVM ,JetPack 相关

  • 热修复

  • Flutter 浅试

  • 自动化、持续集成相关

2021-2022

  • JetPack Compose

  • Epoxy+Mvrx , MVI

看完这个表,是不是惊叹于,我靠,你小子 2021 等于啥都没学啊?

说来尴尬,当我自己从博客记录从翻阅时,也有这份感叹,对于 [新技术] ,真的好像没怎么看了,所以在年终总结上,我发出了这样一句感叹,今年用的组件都是以前学过的,只不过忘了,又翻出笔记看了一眼

但仔细又想了下,2021新技术真的好像没有多少,对于 Android 端而言,Compose 算一击重拳,而 MVI 又是最近因为 Compose 被正式启用为 Google 推荐 [新] 架构标准,其他的好像真的没有。

Google今年推过的技术

如果我们只将技术定义为 [技术组件] ,那就可能太狭义,所以那就细细列举一下今年 Google 推过的那些技术文章:

如何找到呢,那么 Android开发者 公众号就是最优先的。所以我们就通过其发布过的文章,大致统计一下,Android 官方给我们的建议及 [开发指南] ,我排了一个表,大致如下:

  1. JetPack

    • NavigationHiltWorkManagerActivityResult

    • ComposeWear OS-ComposeWear Os-卡片库

    • WindowsManagerRoomPaging3.0Glance - Alpha

  2. 折叠屏,大屏适配

    推荐了很多次,Android12 上也推了几次

  3. Kotlin

    Flow、协程

  4. Android12

    行为变更、隐私安全更新、新的 小组件widget

  5. 安全方面

    数据加密与生物特征、App 合规

  6. Android 启动相关

    App Startup、延迟初始化

  7. CameraX

  8. Material Desgin

按照推荐频率,我将它们分别列在了上面,总共上面这几大类。不难发现,JetPack 仍然是 Android 官方 首推 ,然后就是 折叠屏以及不同屏幕 的适配,接着就是 KotlinAndroid12 ,当然今年因为 合规 方面的各种问题,Android团队 对于安全方面也提到了,最后就是和性能以及 UI 相关的一些推荐。

趋势预测

折叠屏与大屏适配

严格上这个其实不算是一项技术,而是一项适配工作。但是一直以来,对于大屏的适配,Android 上基本做的很少。自从三星推出第一个折叠屏之后,这个适配就开始被重视起来了。

厂商方面,目前 oppo,华为小米 也都纷纷推出自己的折叠屏手机,以满足先行市场。

官方支持度 方面,如果看过今年的 IO 大会,就会发现,折叠屏适配已经被专门放到了一个栏目,而且专门讲解,官方公众号也已经推了多次。

所以我们姑且可以认为,折叠屏适配应该是2022的一个趋势,但目前对于折叠屏的适配的主流App其实还没有多少,更多的也都是厂商做了相关适配,app开发方面专门针对改动做的其实并不多。

所以可见在2022随着折叠屏手机机型的愈来愈多,某些关键业务的全面适配工作也将随之展开,而不是现在仅仅只是在折叠的时候,同时存在两个APP,或者某个页面展示在另一个屏幕。

技术支持方面,Android团队 为此专门准备了一个新的 JetPack 组件,JetPack WindowManager,其主要功能就是监听屏幕的折叠状态,以及当前相应的屏幕信息,目前主要以可折叠设备为目标,不过未来将支持更多屏幕类型及窗口功能,现在处于 rc 版本,当然今年也肯定会推出稳定版。

JetPack Compose

Compose 自从发布第一个稳定版本后,在今年的 IO 大会上也有专门的分区去讲。

其是用于构建 原生Android 的一个 工具包 ,以 声明式 写法,搭配 Kotlin,可大大简化并加快原生的 UI 开发工作。

目前 Compose 已经对如下几个方面做了支持:

  • Android UI 支持

  • Wear 可穿戴设备支持

  • Android Widget 小组件支持

非官方方面,jetbrains 也对桌面版,以及网页做了相关支持,具体见:Compose Multiplatform

桌面版 目前已经发布了正式版本1.0.1

得益于 Compose 的声明式开发,使得其做折叠屏适配也是较为简单。在与原生 View 的交互上,Compose 支持的也非常不错。

所以我们可以认为,2022,如果从事原生开发,那么 Compose 势必是一个比较适合你学习的新技术,它的上手难度并不大,只要你熟悉Kotlin ,也就能很快上手,只不过目前其在ide上的 预览 功能比较慢,还有待后续优化。

Kotlin

协程

协程其实在前几年已经被广泛使用,我第一次使用协程是在2020年,也见证了其逐渐替代 AsyncTask 及相关线程池工具的过程。

Flow

Flow 今年来被 Android团队 推荐了多次,其主要以协程为基础构建,某种意义上而言,我个人觉得其似乎有点替代 RxJava 的意思。得益于 Kotlin 的强大与简洁,Flow 今年出现最多的场景反而是 Android团队 推荐其用于替代 LiveData ,以增强部分情况下的使用。

当然 Flow 不止于此,如果你正在使用 Kotlin ,并且协程用的也比较多,那么 Flow 肯定是绕不开的一个话题。

所以我们可以预估,在2022,协程Flow 依然值得学习,并且也是能很快感受到效益的组件。

但是相比协程,Flow 其实还有很长一段时间要走,毕竟常见开发场景里,LiveData 就可以满足,而 Flow 却显得不是那么必需。

ASM

这项技术其实并不新奇,但是因为其本身需要的前备知识挺多,比如 Android打包流程APK打包流程字节码自定义 Gradle 插件Transform API ,导致细分为了好多领域,大佬们依然在热追,而像我这样的菜鸟其实还是一脸吃瓜。

那为什么我认为其是一个技术趋势呢?

主要是 合规 带来的影响,大的环境下,可能以后打包时,我们都会监测相应的权限声明与隐私调用,否则如何确保后续的改动不会导致违规呢?但如何确定某个 sdk 没有调用?而且我们也不可能每次都让相关第三方去检测。

所以,维护一个相应的监测组件,是大环境下的必需。而实现上述插件最好的方式就是 Hook 或者 ASM ,所以如果你目前阶段比较高,ASM 依然是你避不开的技术选题。

什么[值得]你去学?

这个副标题其实有一点夸张,但仔细想想,其实就是这样,我们应该明白,到底什么是更适合自己当下学习的。

以我个人为例,大家可以从中体会一下,自己应该关注哪些技术,当然,我个人的仅只能作为和我一样的同学做参考:

就像最开始说的,其实这些新组件,很多我都已经用过或者记录过,在最开始的两年,我一直在追寻组件越新越好的道路上,所以每当新出一个组件,总会在项目中进行实践,去尝试。

但是我也逐渐发现了一些问题,当经历了[使用工具]的这个阶段,当我要去解决某些特定情况下问题时,突然发现,自己似乎什么都不会,或者就只会基础,比如:

  1. 在集成某些 gradle 插件时,如果要满足 CI 下的一些便捷,要去写一些 Task 去满足动态集成,而自己对 Gradle 仅仅处于Android常见使用阶段,这时候就需要去学相关;

  2. 我自己也会维护一些组件库,当使用的同学逐渐增多,大家提到的问题也越来越多,那如何解决这些问题,如何优雅的兼容,组件的组合方式,如何运用合适的设计模式去优化,这些又是我需要考虑的问题;

  3. 当我们开始对音视频组件进行相关优化时,此时又出现了很多方向,最终的方案选型也是需要你再次进入一个未知领域,从0到0.1;

新技术会让我当前编码变得开心,能节省我很多事,但其不能解决一些非编码或者复杂问题,而这些问题,是每个同学前进道路上也都会遇到的,所以我们常常会看到,做 Android 真难,啥都要会。

总体对我而言,今年会主要将一些精力放在如下几个方面:

  • Gradle 相关

  • 设计模式在三方库中的运用

  • Android 相关 源码 理解

总结

技术在不断变化与迭代,有些技术我们会发现为什么好几年了,今年似乎特别受人关注,其实也是因为在某种环境下,其的作用逐渐显现。而这些技术正是成为一名优秀的 Android工程师 所必须具备的基础技能。

我们在追寻 [新] 技术的,享受快捷的同时,也别忘了 [停] 下来看看身边风景。

作者:Petterp
来源:juejin.cn/post/7053831595576426504

收起阅读 »

Android 系统 Bar 沉浸式完美兼容方案(下)

续 Android 系统 Bar 沉浸式完美兼容方案(上)完整代码@file:Suppress("DEPRECATION")package com.bytedance.heycan.systembar.activityimport ...
继续阅读 »

续 Android 系统 Bar 沉浸式完美兼容方案(上)

完整代码

@file:Suppress("DEPRECATION")

package com.bytedance.heycan.systembar.activity

import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.util.Size
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.bytedance.heycan.systembar.R

/**
* Created by dengchunguo on 2021/4/25
*/
fun Activity.setLightStatusBar(isLightingColorBoolean) {
   val window = this.window
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
       if (isLightingColor) {
           window.decorView.systemUiVisibility =
               View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
      } else {
           window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
      }
  }
}

fun Activity.setLightNavigationBar(isLightingColorBoolean) {
   val window = this.window
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isLightingColor) {
       window.decorView.systemUiVisibility =
           window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.OView.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0
  }
}

/**
* 必须在Activity的onCreate时调用
*/
fun Activity.immersiveStatusBar() {
   val view = (window.decorView as ViewGroup).getChildAt(0)
   view.addOnLayoutChangeListener { v________ ->
       val lp = view.layoutParams as FrameLayout.LayoutParams
       if (lp.topMargin > 0) {
           lp.topMargin = 0
           v.layoutParams = lp
      }
       if (view.paddingTop > 0) {
           view.setPadding(000view.paddingBottom)
           val content = findViewById<View>(android.R.id.content)
           content.requestLayout()
      }
  }

   val content = findViewById<View>(android.R.id.content)
   content.setPadding(000content.paddingBottom)

   window.decorView.findViewById(R.id.status_bar_view?View(window.context).apply {
       id = R.id.status_bar_view
       val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTstatusHeight)
       params.gravity = Gravity.TOP
       layoutParams = params
      (window.decorView as ViewGroup).addView(this)

      (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
           override fun onChildViewAdded(parentView?childView?) {
               if (child?.id == android.R.id.statusBarBackground) {
                   child.scaleX = 0f
              }
          }

           override fun onChildViewRemoved(parentView?childView?) {
          }
      })
  }
   setStatusBarColor(Color.TRANSPARENT)
}

/**
* 必须在Activity的onCreate时调用
*/
fun Activity.immersiveNavigationBar(callback: (() -> Unit)? = null) {
   val view = (window.decorView as ViewGroup).getChildAt(0)
   view.addOnLayoutChangeListener { v________ ->
       val lp = view.layoutParams as FrameLayout.LayoutParams
       if (lp.bottomMargin > 0) {
           lp.bottomMargin = 0
           v.layoutParams = lp
      }
       if (view.paddingBottom > 0) {
           view.setPadding(0view.paddingTop00)
           val content = findViewById<View>(android.R.id.content)
           content.requestLayout()
      }
  }

   val content = findViewById<View>(android.R.id.content)
   content.setPadding(0content.paddingTop0-1)

   val heightLiveData = MutableLiveData<Int>()
   heightLiveData.value = 0
   window.decorView.setTag(R.id.navigation_height_live_dataheightLiveData)
   callback?.invoke()

   window.decorView.findViewById(R.id.navigation_bar_view?View(window.context).apply {
       id = R.id.navigation_bar_view
       val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTheightLiveData.value ?0)
       params.gravity = Gravity.BOTTOM
       layoutParams = params
      (window.decorView as ViewGroup).addView(this)

       if (this@immersiveNavigationBar is FragmentActivity) {
           heightLiveData.observe(this@immersiveNavigationBar) {
               val lp = layoutParams
               lp.height = heightLiveData.value ?0
               layoutParams = lp
          }
      }

      (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
           override fun onChildViewAdded(parentView?childView?) {
               if (child?.id == android.R.id.navigationBarBackground) {
                   child.scaleX = 0f
                   bringToFront()

                   child.addOnLayoutChangeListener { __top_bottom____ ->
                       heightLiveData.value = bottom - top
                  }
              } else if (child?.id == android.R.id.statusBarBackground) {
                   child.scaleX = 0f
              }
          }

           override fun onChildViewRemoved(parentView?childView?) {
          }
      })
  }
   setNavigationBarColor(Color.TRANSPARENT)
}

/**
* 当设置了immersiveStatusBar时,如需使用状态栏,可调佣该函数
*/
fun Activity.fitStatusBar(fitBoolean) {
   val content = findViewById<View>(android.R.id.content)
   if (fit) {
       content.setPadding(0statusHeight0content.paddingBottom)
  } else {
       content.setPadding(000content.paddingBottom)
  }
}

fun Activity.fitNavigationBar(fitBoolean) {
   val content = findViewById<View>(android.R.id.content)
   if (fit) {
       content.setPadding(0content.paddingTop0navigationBarHeightLiveData.value ?0)
  } else {
       content.setPadding(0content.paddingTop0-1)
  }
   if (this is FragmentActivity) {
       navigationBarHeightLiveData.observe(this) {
           if (content.paddingBottom != -1) {
               content.setPadding(0content.paddingTop0it)
          }
      }
  }
}

val Activity.isImmersiveNavigationBarBoolean
   get() = window.attributes.flags and WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION != 0

val Activity.statusHeightInt
   get() {
       val resourceId =
           resources.getIdentifier("status_bar_height""dimen""android")
       if (resourceId > 0) {
           return resources.getDimensionPixelSize(resourceId)
      }
       return 0
  }

val Activity.navigationHeightInt
   get() {
       return navigationBarHeightLiveData.value ?0
  }

val Activity.screenSizeSize
   get() {
       return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
           Size(windowManager.currentWindowMetrics.bounds.width(), windowManager.currentWindowMetrics.bounds.height())
      } else {
           Size(windowManager.defaultDisplay.widthwindowManager.defaultDisplay.height)
      }
  }

fun Activity.setStatusBarColor(colorInt) {
   val statusBarView = window.decorView.findViewById<View?>(R.id.status_bar_view)
   if (color == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
       statusBarView?.setBackgroundColor(STATUS_BAR_MASK_COLOR)
  } else {
       statusBarView?.setBackgroundColor(color)
  }
}

fun Activity.setNavigationBarColor(colorInt) {
   val navigationBarView = window.decorView.findViewById<View?>(R.id.navigation_bar_view)
   if (color == 0 && Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
       navigationBarView?.setBackgroundColor(STATUS_BAR_MASK_COLOR)
  } else {
       navigationBarView?.setBackgroundColor(color)
  }
}

@Suppress("UNCHECKED_CAST")
val Activity.navigationBarHeightLiveDataLiveData<Int>
   get() {
       var liveData = window.decorView.getTag(R.id.navigation_height_live_dataas? LiveData<Int>
       if (liveData == null) {
           liveData = MutableLiveData()
           window.decorView.setTag(R.id.navigation_height_live_dataliveData)
      }
       return liveData
  }

val Activity.screenWidthInt get() = screenSize.width

val Activity.screenHeightInt get() = screenSize.height

private const val STATUS_BAR_MASK_COLOR = 0x7F000000

扩展

对话框适配

有时候需要通过 Dialog 来显示一个提示对话框、loading 对话框等,当显示一个对话框时,即使设置了 activity 为深色状态栏和导航栏文字颜色,这时候状态栏和导航栏的文字颜色又变成白色,如下所示:


这是因为对 activity 设置的状态栏和导航栏颜色是作用 于 activity 的 window,而 dialog 和 activity 不是同一个 window,因此 dialog 也需要单独设置。

完整代码

@file:SuppressDEPRECATION )

package com.bytedance.heycan.systembar.dialog

import android.app.Dialog
import android.os.Build
import android.view.View
import android.view.ViewGroup

/**
* Created by dengchunguo on 2021/4/25
*/
fun Dialog.setLightStatusBar(isLightingColorBoolean) {
   val window = this.window ?return
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
       if (isLightingColor) {
           window.decorView.systemUiVisibility =
               View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
      } else {
           window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
      }
  }
}

fun Dialog.setLightNavigationBar(isLightingColorBoolean) {
   val window = this.window ?return
   if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isLightingColor) {
       window.decorView.systemUiVisibility =
           window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.OView.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0
  }
}

fun Dialog.immersiveStatusBar() {
   val window = this.window ?return
  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

fun Dialog.immersiveNavigationBar() {
   val window = this.window ?return
  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.navigationBarBackground) {
               child.scaleX = 0f
          } else if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

效果如下:


快速使用

Activity 沉浸式

immersiveStatusBar() // 沉浸式状态栏
immersiveNavigationBar() // 沉浸式导航栏

setLightStatusBar(true// 设置浅色状态栏背景(文字为深色)
setLightNavigationBar(true// 设置浅色导航栏背景(文字为深色)

setStatusBarColor(color// 设置状态栏背景色
setNavigationBarColor(color// 设置导航栏背景色

navigationBarHeightLiveData.observe(this) {
// 监听导航栏高度变化
}

Dialog 沉浸式

val dialog = Dialog(thisR.style.Heycan_SampleDialog)
dialog.setContentView(R.layout.dialog_loading)
dialog.immersiveStatusBar()
dialog.immersiveNavigationBar()
dialog.setLightStatusBar(true)
dialog.setLightNavigationBar(true)
dialog.show()

Demo 效果


可实现与 iOS 类似的页面沉浸式导航条效果:


作者:字节跳动技术团队
来源:juejin.cn/post/7075578574362640421

收起阅读 »

Android 系统 Bar 沉浸式完美兼容方案(上)

自 Android 5.0 版本,Android 带来了沉浸式系统 bar(状态栏和导航栏),Android 的视觉效果进一步提高,各大 app 厂商也在大多数场景上使用沉浸式效果。但由于 Android 碎片化比较严重,每个版本的系统 bar 效果可能会有所...
继续阅读 »

引言

自 Android 5.0 版本,Android 带来了沉浸式系统 bar(状态栏和导航栏),Android 的视觉效果进一步提高,各大 app 厂商也在大多数场景上使用沉浸式效果。但由于 Android 碎片化比较严重,每个版本的系统 bar 效果可能会有所差异,导致开发者往往需要进行兼容适配。为了简化系统 bar 沉浸式的使用,以及统一机型、版本差异所造成的效果差异,本文将介绍系统 bar 的组成以及沉浸式适配方案。

背景

问题一:沉浸式下无法设置背景色

对于大于等于 Android 5.0 版本的系统,在 Activity 的 onCreate 时,通过给 window 设置属性:

window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)

即可开启沉浸式系统 bar,效果如下:


Android 5.0 沉浸式状态栏


Android 5.0 沉浸式导航栏

但是设置沉浸式之后,原来通过 window.statusBarColorwindow.statusBarColor 设置的颜色也不可用,也就是说不支持自定义半透明系统 bar 的颜色。

问题二:无法全透明导航栏

系统默认的状态栏和导航栏都有一个半透明的蒙层,虽然不支持设置颜色,但通过设置以下代码,可让状态栏变为全透明:

window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
      or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.statusBarColor = Color.TRANSPARENT

效果如下:


Android 10.0 沉浸式全透明状态栏

通过类似的方式尝试将导航栏设置为全透明:

window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
      or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.navigationBarColor = Color.TRANSPARENT

但发现导航栏半透明背景依然无法去掉:


问题三:亮色系统 bar 版本差异

对于大于等于 Android 6.0 版本的系统,如果背景是浅色的,可通过设置状态栏和导航栏文字颜色为深色,也就是导航栏和状态栏为浅色(只有 Android 8.0 及以上才支持导航栏文字颜色修改):

window.decorView.systemUiVisibility =
  View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR

window.decorView.systemUiVisibility =
  window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0

效果如下:


Android 8.0 亮色状态栏


Android 8.0 亮色导航栏

但是在亮色系统 bar 基础上开启沉浸式后,在 8.0 至 9.0 系统中,导航栏深色导航 icon 不生效,而 10.0 以上版本能显示深色导航 icon:


Android 8.0 亮色沉浸式亮色导航栏


Android 10.0 亮色沉浸式亮色导航栏

问题分析

问题一:沉浸式下无法设置背景色

查看源码发现设置状态栏和导航栏背景颜色时,是不能为沉浸式的:


问题二:无法全透明导航栏

当设置导航栏为透明色(Color.TRANSPARENT)时,导航栏会变成半透明,当设置其他颜色,则是正常的,例如设置颜色为 0x700F7FFF,显示效果如下:


Android 10.0 沉浸式导航栏

为什么会出现这个情况呢,通过调试进入源码,发现 activity 的 onApplyThemeResource 方法中有一个逻辑:

// Get the primary color and update the TaskDescription for this activity
TypedArray a = theme.obtainStyledAttributes(
      com.android.internal.R.styleable.ActivityTaskDescription);
if (mTaskDescription.getPrimaryColor() == 0) {
  int colorPrimary = a.getColor(
          com.android.internal.R.styleable.ActivityTaskDescription_colorPrimary, 0);
  if (colorPrimary != 0 && Color.alpha(colorPrimary) == 0xFF) {
      mTaskDescription.setPrimaryColor(colorPrimary);
  }
}

也就是说如果设置的导航栏颜色为 0(纯透明)时,将会为其修改为内置的颜色:ActivityTaskDescription_colorPrimary,因此就会出现灰色蒙层效果。

问题三:亮色系统 bar 版本差异

通过查看源码发现,与设置状态栏和导航栏背景颜色类似,设置导航栏 icon 颜色也是不能为沉浸式:


解决沉浸式兼容性问题

对于问题二无法全透明导航栏,由上述问题分析中的代码可以看出,当且仅当设置的导航栏颜色为纯透明时(0),才会置换为半透明的蒙层。那么,我们可以将纯透明这种情况修改颜色为 0x01000000,这样也能达到接近纯透明的效果:


对于问题一,难以通过常规方式进行沉浸式下的系统 bar 背景颜色设置。而对于问题三,通过常规方式需要分别对各个版本进行适配,对于国内手机来说,适配难度更大。

为了解决兼容性问题,以及更好的管理状态栏和导航栏,我们是否能自己实现状态栏和导航栏的背景 View 呢?

通过 Layout Inspector 可以看出,导航栏和状态栏本质上也是一个 view:


在 activity 创建的时候,会创建两个 view(navigationBarBackground 和 statusBarBackground),将其加到 decorView 中,从而可以控制状态栏的颜色。那么,是否能把系统的这两个 view 隐藏起来,替换成自定义的 view 呢?

因此,为了提高兼容性,以及更好的管理状态栏和导航栏,我们可以将系统的 navigationBarBackground 和 statusBarBackground 隐藏起来,替换成自定义的 view,而不再通过 FLAG_TRANSLUCENT_STATUSFLAG_TRANSLUCENT_NAVIGATION 来设置。

实现沉浸式状态栏

  1. 添加自定义的状态栏。通过创建一个 view ,让其高度等于状态栏的高度,并将其添加到 decorView 中:

View(window.context).apply {
id = R.id.status_bar_view
  val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, statusHeight)
  params.gravity = Gravity.TOP
  layoutParams = params
  (window.decorView as ViewGroup).addView(this)
}
  1. 隐藏系统的状态栏。由于 activity 在 onCreate 时,并没有创建状态栏的 view(statusBarBackground),因此无法直接将其隐藏。这里可以通过对 decorView 添加 OnHierarchyChangeListener 监听来捕获到 statusBarBackground:

(window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
   override fun onChildViewAdded(parentView?childView?) {
       if (child?.id == android.R.id.statusBarBackground) {
           child.scaleX = 0f
      }
  }

   override fun onChildViewRemoved(parentView?childView?) {
  }
})

注意:这里将 child 的 scaleX 设为 0 即可将其隐藏起来,那么为什么不能设置 visibilityGONE 呢?这是因为后续在应用主题时(onApplyThemeResource),系统会将 visibility 又重新设置为 VISIBLE

隐藏之后,半透明的状态栏不显示,但是顶部会出现空白:


通过 Layout Inspector 发现,decorView 的第一个元素(内容 view )会存在一个 padding:


因此,可以通过设置 paddingTop 为 0 将其去除:

val view = (window.decorView as ViewGroup).getChildAt(0)
view.addOnLayoutChangeListener { v________ ->
   if (view.paddingTop > 0) {
       view.setPadding(000view.paddingBottom)
       val content = findViewById<View>(android.R.id.content)
       content.requestLayout()
  }
}

注意:这里需要监听 view 的 layout 变化,否则只有一开始设置则后面又被修改了。

实现沉浸式导航栏

导航栏的自定义与状态栏类似,不过会存在一些差异。先创建一个自定义 view 将其添加到 decorView 中,然后把原来系统的 navigationBarBackground 隐藏:

window.decorView.findViewById(R.id.navigation_bar_view?View(window.context).apply {
id = R.id.navigation_bar_view
   val resourceId = resources.getIdentifiernavigation_bar_height ,  dimen ,  android )
   val navigationBarHeight = if (resourceId > 0resources.getDimensionPixelSize(resourceIdelse 0
   val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTnavigationBarHeight)
   params.gravity = Gravity.BOTTOM
   layoutParams = params
  (window.decorView as ViewGroup).addView(this)

  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.navigationBarBackground) {
               child.scaleX = 0f
          } else if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

注意:这里 onChildViewAdded 方法中,因为只能设置一次 OnHierarchyChangeListener ,需要同时考虑状态栏和导航栏。

通过这个方式,能将导航栏替换为自定义的 view ,但是存在一个问题,由于 navigationBarHeight 是固定的,如果用户切换了导航栏的样式,再回到 app 时,导航栏的高度不会重新调整。为了让导航栏看的清楚,设置其颜色为 0x7F00FF7F:


从图中可以看出,导航栏切换之后高度没有发生变化。为了解决这个问题,需要通过对 navigationBarBackground 设置 OnLayoutChangeListener 来监听导航栏高度的变化,并通过 liveData 关联到 view 中,代码实现如下:

val heightLiveData = MutableLiveData<Int>()
heightLiveData.value = 0
window.decorView.setTag(R.id.navigation_height_live_dataheightLiveData)

val navigationBarView = window.decorView.findViewById(R.id.navigation_bar_view?View(window.context).apply {
   id = R.id.navigation_bar_view
   val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENTheightLiveData.value ?0)
   params.gravity = Gravity.BOTTOM
   layoutParams = params
  (window.decorView as ViewGroup).addView(this)

   if (this@immersiveNavigationBar is FragmentActivity) {
       heightLiveData.observe(this@immersiveNavigationBar) {
           val lp = layoutParams
           lp.height = heightLiveData.value ?0
           layoutParams = lp
      }
  }

  (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {
       override fun onChildViewAdded(parentView?childView?) {
           if (child?.id == android.R.id.navigationBarBackground) {
               child.scaleX = 0f

               child.addOnLayoutChangeListener { __top_bottom____ ->
                   heightLiveData.value = bottom - top
              }
          } else if (child?.id == android.R.id.statusBarBackground) {
               child.scaleX = 0f
          }
      }

       override fun onChildViewRemoved(parentView?childView?) {
      }
  })
}

通过上面方式,可以解决切换导航栏样式后自定义的导航栏高度问题:


作者:字节跳动技术团队
来源:juejin.cn/post/7075578574362640421

接 Android系统Bar沉浸式完美兼容方案(下)

收起阅读 »

简易的Android网络图片加载器

在项目开发中,我们加载图片一般使用的是第三方库,比如Glide,在闲暇之余突发奇想,自己动手实现一个简单的Android网络图片加载器。首先定义API,API的定义应该简单易用,比如imageLoader.displayImage(imageView,imag...
继续阅读 »

在项目开发中,我们加载图片一般使用的是第三方库,比如Glide,在闲暇之余突发奇想,自己动手实现一个简单的Android网络图片加载器。

首先定义API,API的定义应该简单易用,比如

imageLoader.displayImage(imageView,imagePath);

其次应该支持缓存。缓存一般是指三级缓存,先定义一个入口类ImageLoader

public ImageLoader(Activity activity) {
   this.activity = activity;
   memoryCache = new MemoryCache();
   diskCache = new DiskCache(activity);
   netCache = new NetCache(activity,memoryCache,diskCache);
}

在初始化的时候就初始化内存缓存,磁盘缓存,网络缓存三个变量,然后定义加载方法:

public void displayImage(final ImageView imageView, String url,int placeholder){
   imageView.setTag(url);
   imageView.setImageResource(placeholder);
   Bitmap bitmap;
   bitmap = memoryCache.getBitmap(url);
   if(bitmap != null){
       imageView.setImageBitmap(bitmap);
       Log.i(TAG, "从内存中获取图片");
       return;
  }
   bitmap = diskCache.getBitmap(url);
   if(bitmap != null){
       imageView.setImageBitmap(bitmap);
       memoryCache.setBitmap(url,bitmap);
       Log.i(TAG, "从磁盘中获取图片");
       return;
  }
   netCache.getBitmap(imageView,url);
}

首先将图片地址设置给ImageView的tag,防止因为ImageView复用导致图片错乱的问题。然后设置一个占位图防止图片加载过慢ImageVIew显示白板

三级缓存中从内存中加载缓存信息是最快的,所以第一步从内存缓存中查找,如果找到了就直接设置给ImageView,否则继续从磁盘缓存中查找,找到了就显示,最后实在找不到就从网络下载图片

内存缓存

public class MemoryCache {
   private LruCache<String,Bitmap> lruCache;
   public MemoryCache() {
       long maxMemory  = Runtime.getRuntime().maxMemory() / 8;
       lruCache = new LruCache<String,Bitmap>((int) maxMemory){
           @Override
           protected int sizeOf(String key, Bitmap value) {
               return value.getByteCount();
          }
      };
  }
   public Bitmap getBitmap(String url) {
       return lruCache.get(url);
  }
   public void setBitmap(String url,Bitmap bitmap) {
       lruCache.put(url,bitmap);
  }
}

内存缓存比较简单,只需要将加载过的图片放入内存,然后下次加载直接获取,由于内存大小有限制,所以这里使用了LruCache算法保证缓存不会无限制增长。

磁盘缓存

对于已经缓存在磁盘上的文件,就不需要在从网络下载了,直接从磁盘读取。

public Bitmap getBitmap(String url) {
   FileInputStream is;
   String cacheUrl = Md5Utils.md5(url);
   File parentFile = new File(Values.PATH_CACHE);
   File file = new File(parentFile,cacheUrl);
   if(file.exists()){
       try {
           is = new FileInputStream(file);
           Bitmap bitmap =  decodeSampledBitmapFromFile(file.getAbsolutePath());
           is.close();
           return bitmap;
      } catch (Exception e) {
           e.printStackTrace();
      }
  }
   return null;
}

考虑到多图加载的时候,如果图片太大容易OOM,所以需要对加载的图片稍作处理

public  Bitmap decodeSampledBitmapFromFile(String pathName){
   BitmapFactory.Options options = new BitmapFactory.Options();
   options.inJustDecodeBounds = true;
   BitmapFactory.decodeFile(pathName,options);
   options.inSampleSize = calculateInSampleSize(options)*2;
   options.inJustDecodeBounds = false;
   options.inPreferredConfig = Bitmap.Config.RGB_565;
   return BitmapFactory.decodeFile(pathName,options);
}

这里降低了图片的采样,对图片的质量进行了压缩

对于本地没有缓存的图片,需要从网络下载,当获取到图片流之后,保存在本地

public void saveBitmap(InputStream inputStream, String url) throws IOException {
   String cacheUrl = Md5Utils.md5(url);
   File parentFile = new File(Values.PATH_CACHE);
   if(!parentFile.exists()){
       parentFile.mkdirs();
  }
   FileOutputStream fos = new FileOutputStream(new File(parentFile,cacheUrl));
   byte[] bytes = new byte[1024];
   int index = 0;
   while ((index = inputStream.read(bytes))!=-1){
       fos.write(bytes,0,index);
       fos.flush();
  }
   inputStream.close();
   fos.close();
}

为了防止图片url带一些非法字符导致创建文件失败,所以对url进行了md5处理

网络缓存

这里比较简单,直接从服务器加载图片信息就可以了,访问网络使用了OkHttp

public void getBitmap(final ImageView imageView, final String url) {
   OkHttpClient client = new OkHttpClient();
   Request request = new Request.Builder() .get().url(url).build();
   client.newCall(request).enqueue(new Callback() {
       @Override
       public void onFailure(Call call, IOException e) {
           imageView.setImageResource(R.mipmap.ic_error);
      }
       @Override
       public void onResponse(Call call, Response response) throws IOException {
           InputStream inputStream = response.body().byteStream();
           diskCache.saveBitmap(inputStream, url);
           activity.runOnUiThread(new Runnable() {
               @Override
               public void run() {
                   if (url != null && url.equals(imageView.getTag())) {
                       Bitmap bitmap = diskCache.getBitmap(url);
                       memoryCache.setBitmap(url, bitmap);
                       if (bitmap != null) {
                           imageView.setImageBitmap(bitmap);
                      } else {
                           imageView.setImageResource(R.mipmap.ic_error);
                      }
                  } else {
                       imageView.setImageResource(R.mipmap.ic_place);
                  }
              }
          });
      }
  });
}

当获取到图片后,分别放入磁盘和内存缓存起来

使用

最后直接在需要加载图片的地方调用

new ImageLoader(activity).displayImage(imageView,path)


作者:晚来天欲雪_
来源:juejin.cn/post/7088693420109070373

收起阅读 »

细说Android apk四代签名:APK v1、APK v2、APK v3、APK v4

简介大部分开发者对apk签名还停留在APK v2,对APK v3和APK v4了解很少,而且网上大部分文章讲解的含糊不清,所以根据官网文档重新整理一份。apk签名从APK v1到APK v2改动很大,是颠覆性的,而APK v3只是对APK v2的一次升级,AP...
继续阅读 »

简介

大部分开发者对apk签名还停留在APK v2,对APK v3和APK v4了解很少,而且网上大部分文章讲解的含糊不清,所以根据官网文档重新整理一份。

apk签名从APK v1到APK v2改动很大,是颠覆性的,而APK v3只是对APK v2的一次升级,APK v4则是一个补充。

本篇文章主要参考Android各版本改动: developer.android.google.cn/about/versi…

APK v1

就是jar签名,apk最初的签名方式,大家都很熟悉了,签名完之后是META-INF 目录下的三个文件:MANIFEST.MF、CERT.SF、CERT.RSA。

MANIFEST.MF

MANIFEST.MF中是apk种每个文件名称和摘要SHA1(或者 SHA256),如果是目录则只有名称

CERT.SF

CERT.SF则是对MANIFEST.MF的摘要,包括三个部分:

  • SHA1-Digest-Manifest-Main-Attributes:对 MANIFEST.MF 头部的块做 SHA1(或者SHA256)后再用 Base64 编码

  • SHA1-Digest-Manifest:对整个 MANIFEST.MF 文件做 SHA1(或者 SHA256)后再用 Base64 编码

  • SHA1-Digest:对 MANIFEST.MF 的各个条目做 SHA1(或者 SHA256)后再用 Base64 编码

CERT.RSA

CERT.RSA是将CERT.SF通过私钥签名,然后将签名以及包含公钥信息的数字证书一同写入 CERT.RSA 中保存

通过这三层校验来确保apk中的每个文件都不被改动。

APK v2

官方说明:source.android.google.cn/security/ap…

APK 签名方案 v2 是在 Android 7.0 (Nougat) 中引入的。为了使 APK 可在 Android 6.0 (Marshmallow) 及更低版本的设备上安装,应先使用 JAR 签名功能对 APK 进行签名,然后再使用 v2 方案对其进行签名。

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

APK 签名方案 v2 是一种全文件签名方案,该方案能够发现对 APK 的受保护部分进行的所有更改,从而有助于加快验证速度并增强完整性保证。

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


通俗点说就是签名信息不再以文件的形式存储,而是将其转成二进制数据直接写在apk文件中,这样就避免了APK v1的META-INF目录的问题。

在 Android 7.0 及更高版本中,可以根据 APK 签名方案 v2+ 或 JAR 签名(v1 方案)验证 APK。更低版本的平台会忽略 v2 签名,仅验证 v1 签名。


APK v3

官方说明:source.android.google.cn/security/ap…

APK 签名方案 v3 是在 Android 9 中引入的。

Android 9 支持 APK 密钥轮替,这使应用能够在 APK 更新过程中更改其签名密钥。为了实现轮替,APK 必须指示新旧签名密钥之间的信任级别。为了支持密钥轮替,我们将 APK 签名方案从 v2 更新为 v3,以允许使用新旧密钥。v3 在 APK 签名分块中添加了有关受支持的 SDK 版本和 proof-of-rotation 结构的信息。

简单来说APK v3就是为了Andorid9的APK 密钥轮替功能而出现的,就是在v2的基础上增加两个数据块来存储APK 密钥轮替所需要的一些信息,所以可以看成是v2的升级。具体结构见官网说明即可。

APK 密钥轮替功能可以参考:developer.android.google.cn/about/versi…

具有密钥轮转的 APK 签名方案

Android 9 新增了对 APK Signature Scheme v3 的支持。该架构提供的选择可以在其签名块中为每个签名证书加入一条轮转证据记录。利用此功能,应用可以通过将 APK 文件过去的签名证书链接到现在签署应用时使用的证书,从而使用新签名证书来签署应用。

developer.android.google.cn/about/versi…

注:运行 Android 8.1(API 级别 27)或更低版本的设备不支持更改签名证书。如果应用的 minSdkVersion 为 27 或更低,除了新签名之外,可使用旧签名证书来签署应用。

详细了解如何使用 apksigner 轮转密钥参考:developer.android.google.cn/studio/comm…

在 Android 9 及更高版本中,可以根据 APK 签名方案 v3、v2 或 v1 验证 APK。较旧的平台会忽略 v3 签名而尝试验证 v2 签名,然后尝试验证 v1 签名。


APK v4

官方说明:source.android.google.cn/security/ap…

APK 签名方案 v4 是在 Android 11 中引入的。

Android 11 通过 APK 签名方案 v4 支持与流式传输兼容的签名方案。v4 签名基于根据 APK 的所有字节计算得出的 Merkle 哈希树。它完全遵循 fs-verity 哈希树的结构(例如,对salt进行零填充,以及对最后一个分块进行零填充。)Android 11 将签名存储在单独的 .apk.idsig 文件中。v4 签名需要 v2 或 v3 签名作为补充。

APK v4同样是为了新功能而出现的,这个新功能就是ADB 增量 APK 安装,可以参考Android11 功能和 API 概览: developer.android.google.cn/about/versi…

ADB 增量 APK 安装

在设备上安装大型(2GB 以上)APK 可能需要很长的时间,即使应用只是稍作更改也是如此。ADB(Android 调试桥)增量 APK 安装可以安装足够的 APK 以启动应用,同时在后台流式传输剩余数据,从而加速这一过程。如果设备支持该功能,并且您安装了最新的 SDK 平台工具,adb install 将自动使用此功能。如果不支持,系统会自动使用默认安装方法。

developer.android.google.cn/about/versi…

运行以下 adb 命令以使用该功能。如果设备不支持增量安装,该命令将会失败并输出详细的解释。

adb install --incremental

在运行 ADB 增量 APK 安装之前,您必须先为 APK 签名并创建一个 APK 签名方案 v4 文件。必须将 v4 签名文件放在 APK 旁边,才能使此功能正常运行。

developer.android.google.cn/about/versi…

因为需要流式传输,所以需要将文件分块,对每一块进行签名以便校验,使用的方式就是Merkle 哈希树(http://www.kernel.org/doc/html/la… v4就是做这部分功能的。所以APK v4与APK v2或APK v3可以算是并行的,所以APK v4签名后还需要 v2 或 v3 签名作为补充。

运行 adb install --incremental 命令时,adb 会要求 .apk.idsig 文件存在于 .apk 旁边(所以APK v4的签名文件.apk.idsig并不会打包进apk文件中

默认情况下,它还会使用 .idsig 文件尝试进行增量安装;如果此文件缺失或无效,该命令会回退到常规安装。


总结

综上,可以看到APK v4是面向ADB即开发调试的,而如果我们没有签名变动的需求也可以不考虑APK v3,所以目前国内大部分还停留在APK v2。


作者:BennuCTech
来源:juejin.cn/post/7068079232290652197

收起阅读 »

App如何防止抓包

前言App安全非常重要,尤其是数据安全。但是我们知道通过Charles等工具可以对App的网络请求进行抓包,如果我们的数据没有进行加密,这样这些信息就会被清楚的提取出来,会被不法分子进行利用。保证数据安全有很多种方法,今天简单聊一聊如何通过简单几步防止抓包。正...
继续阅读 »

前言

App安全非常重要,尤其是数据安全。但是我们知道通过Charles等工具可以对App的网络请求进行抓包,如果我们的数据没有进行加密,这样这些信息就会被清楚的提取出来,会被不法分子进行利用。保证数据安全有很多种方法,今天简单聊一聊如何通过简单几步防止抓包。

正文

当我们进行网络请求的时候,一般通过URL的openConnection来建立连接,代码如下:

URLConnection conn = url.openConnection()

其实openConnection这个函数还有一个版本,可以传入一个proxy对象,代码如下:

public URLConnection openConnection(Proxy proxy)
   throws java.io.IOException

这样我们通过这个函数建立连接时传入一个Proxy.NO_PROXY,即可达到防止抓包的效果,如Charles等抓包工具就无法看到我们的链接信息了,代码如下

URLConnection conn = url.openConnection(Proxy.NO_PROXY)

官方对于Proxy.NO_PROXY描述如下:

/**
* A proxy setting that represents a {@code DIRECT} connection,
* basically telling the protocol handler not to use any proxying.
* Used, for instance, to create sockets bypassing any other global
* proxy settings (like SOCKS):
* <P>
* {@code Socket s = new Socket(Proxy.NO_PROXY);}
*
*/
public final static Proxy NO_PROXY = new Proxy();

// Creates the proxy that represents a {@code DIRECT} connection.
private Proxy() {
   type = Type.DIRECT;
   sa = null;
}

我么可以看到NO_PROXY实际上就是type属性为DIRECT的一个Proxy对象,这个type有三种:

  • DIRECT

  • HTTP

  • SOCKS

官方描述如下:

public enum Type {
   /**
    * Represents a direct connection, or the absence of a proxy.
    */
   DIRECT,
   /**
    * Represents proxy for high level protocols such as HTTP or http://FTP.
    */
   HTTP,
   /**
    * Represents a SOCKS (V4 or V5) proxy.
    */
   SOCKS
};

这样因为是直连,所以不走代理。所以Charles等工具就抓不到包了,这样一定程度上保证了数据的安全。

当然这种方式只是通过代理抓不到包,如果直接通过路由还是可以抓包的。


作者:BennuCTech
来源:juejin.cn/post/7078077090506997767

收起阅读 »

情绪宣泄App:十年前IT男编程撩妹纪实

阅读本文,你将收获以下内容:1、通过观察11年前的Android代码,了解安卓开发生态近十年间的演进。2、通过了解这款创意App的功能,对IT男该如何运用技术做出反思。3、不幸看到作者大学时期的照片,形象极其猥琐、狼狈、不堪……够了,谁在动键盘?!前言因为在掘...
继续阅读 »

阅读本文,你将收获以下内容:

1、通过观察11年前的Android代码,了解安卓开发生态近十年间的演进。

2、通过了解这款创意App的功能,对IT男该如何运用技术做出反思。

3、不幸看到作者大学时期的照片,形象极其猥琐、狼狈、不堪……够了,谁在动键盘?!

前言

因为在掘金的创作者群里比嘻哈,有人觉得我经常信口开河,尤其我写了那篇《我裁完兄弟们辞职了,转行做了一名小职员》后,有掘友评论:“这篇文章艺术成分很高”、“感觉是编故事”等等。


其中,我文章里提到过一句:我大学期间搞过Android的APP去运作。


今天,就说说这件事,一来展现下创意作品,二来给自己挽回一些微信……违心……维新……不要了。

女朋友

回到2010年,那时我上大二,谈了一个女朋友,她现在是我孩子的妈妈。

女朋友哪里都好,漂亮、温柔、大方,唯一一点就是脾气太大。

因为我不会制造浪漫,所以女朋友经常对我发脾气,一发脾气就不理我。

我一想,如果她不理我的时间设为N,如果N值无限大,那就相当于分手了,这感情不就黄了吗?

情侣在吵架冷战期间,如何才能不见面也能如同见面一般宣泄情绪,从而刷存在感呢?

可以做一款App来解决这个问题。

冷战红娘App

这款App我取名叫“冷战红娘”,意思是情侣在冷战期间调和关系的红娘媒介,并且我还亲自设计了LOGO和启动页。


一个安卓小机器人,手里拿着玫瑰花,表示示好,头顶两个触角间发出电波,表示科技和智能。

那一年,我才19岁。不行了,我膨胀得快要爆掉了。

功能简介

本软件主要有三大功能:涂鸦对方照片、写情绪日记、告知情绪结果。

下面是首页的效果:


下面是实现代码的目录结构:


女朋友名字中带“冰”,我名字中带“磊”,因此项目名是LoveBing,包名是com.lei.lovebing01

有安卓小伙伴看到目录结构可能会发现少文件,说我在糊弄你,起码你的build.gradle得有吧。

朋友,这是2010年的安卓项目,那时的版本号是SdkVersion="8",也就是Android 2.2,现在最新版本已经到了API 32, Android 12了。从互联网时代来看,就好像是现在和清朝的区别。

那时还没有动态权限请求,存取文件也不用FileProvider,你可以随意读取其他程序的内部数据,应用层就可以静默发送短信和获取定位,开发者可以更好地实现自己的想法,不必受到很多限制。当然,这在现在看来是不安全的。所以,任何事物的成熟都是有周期的。

那时候也没有现在这么多的第三方框架,基本都是调用Android原生的API,操作数据库需要直接使用android.database.sqlite.SQLiteDatabase,SQL语句要自己写,操作异常要自己处理。

下面,就让我们跟随功能,结合代码,一起去剖析一下这款App吧。

涂鸦

女朋友生气不理我了,短信不回,电话不接,女生宿舍我又进不去。但是,她又有怨气没地方宣泄。这时,她就会打开这个功能,可以把我的头像摆出来,然后进行各种攻击,支持花色涂抹,支持往我的照片上放小虫子、扔臭鸡蛋、使用炸弹爆破效果等等。天啊,我这是怎么了……不但有这种想法,而且还开发出了功能。


其实,要实现这个功能,非常简单。

首先,整个页面的数据,是通过配置完成的,各种颜色,工具,以及图标,需要事先声明好。

//信手涂鸦里的数据=====================================================
//工具的名字
public final static String[] colortext={"红色", "黄色","绿色",
"粉色", "黑色", "紫色", "蓝色", "浅绿", "棕色"};
//工具的图片
public final static int[] colorpic = {
R.drawable.color1, R.drawable.color2,
R.drawable.color3, R.drawable.color4,
R.drawable.color5, R.drawable.color6,
R.drawable.color7, R.drawable.color8,
R.drawable.color9 };

//信手涂鸦颜色选择
public  static final int red = 0;
public  static final int yellow = 1;
public  static final int green = 2;
public  static final int pink = 3;
public  static final int black = 4;
public  static final int purple = 5;
public  static final int blackblue = 6;
public  static final int lightgreen = 7;
public  static final int orange = 8;

//使用工具里的数据=====================================================
public final static String[] toolstext={"鸡蛋", "炸弹","生物","喷溅"};//工具的名字
public final static int[] toolspic = {//工具的图片
R.drawable.dao, R.drawable.zhadan01,R.drawable.tool,R.drawable.penjian
};

public final static int[][] toolspic_01 = {//工具的图片使用后
{R.drawable.dao_01, R.drawable.dao_02, R.drawable.dao_03,R.drawable.dao_01}
,{ R.drawable.baozha01, R.drawable.baozha02, R.drawable.baozha03, R.drawable.baozha04}
,{R.drawable.tools_01, R.drawable.tools_02, R.drawable.tools_03, R.drawable.tools_04}
,{R.drawable.penjian01, R.drawable.penjian02, R.drawable.penjian03, R.drawable.penjian04}
};

通过配置的方式,有一个极大的好处,那就是以后你增加新的工具,不用修改代码,直接修改配置文件即可。

下面一步就是使用一个Canvas画板,把上面的配置画到画布上,并响应用户的交互,为此我新建了一个CanvasView,它继承了View

public class CanvasView extends View{

   public Bitmap[][] bitmapArray;
   private Canvas  mCanvas;

   public CanvasView(Context context) {
       super(context);
       bitmapArray=new Bitmap[MyData.toolspic.length][4];//实例化工具数组
       //载入工具需要的图像
       InputStream bitmapis;
       for(int i=0; i<MyData.toolspic_01.length; i++){
           for(int j=0; j<MyData.toolspic_01[i].length; j++){
               bitmapis = getResources().openRawResource(MyData.toolspic_01[i][j]);
               bitmapArray[i][j] = BitmapFactory.decodeStream(bitmapis);
          }
      }
       // 使用mBitmap创建一个画布
       mCanvas = new Canvas(mBitmap);
       mCanvas.drawColor(0xFFFFFFFF);//背景为白色
  }
     
   //在用户点击的地方画使用的工具
   public void drawTools(int bitmapID){
       Random rand = new Random();
       int myrand = rand.nextInt(bitmapArray[bitmapID].length);
       mCanvas.drawBitmap(bitmapArray[bitmapID][myrand], mX-bitmapArray[bitmapID]
          [myrand].getWidth()/2, mY-juli-bitmapArray[bitmapID][myrand].getHeight()/2, null);
  }
   ……
}

上面只是部分关键代码,主要展示了如何加载图片,以及如何响应用户的操作,基本无难点。

女朋友毕竟花费了一番功夫,作品肯定要给她保留,因为她可能要展示给我看:你看,昨天你惹我多严重,我把你画成这样!


涂鸦完成之后,文件保存到SD卡目录即可。权限管理方面,在AndroidManifest.xml中注册一下就行,除此之外,再无其他操作。

<!--  向SD卡写入数据的权限  -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

当然,现在不可以了,Android 6.0以后,你得动态申请权限了。

值得一说的是,上面的图片浏览控件叫ImageSwitcher,这是个老控件了,很简单就可以实现幻灯片浏览的效果。

日记

如果涂鸦无法完全解气,为了能让女朋友把生气的原因表述明白,我特意增加了这个生气日记的功能。效果等同于她面对面骂我,期望她写完了气也就消了。


你觉得数据应该保存到哪里?我首选的是Android内嵌的Sqlite3数据库。

Android中最原始的sqlite数据库操作是这样的,先利用官方的SQLiteOpenHelper创建数据库和数据表。

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;

public class DBOpenHelper extends SQLiteOpenHelper {

private static final String DATABASENAME = "angrydiary.db"; //数据库名称
private static final int DATABASEVERSION = 1;//数据库版本

public DBOpenHelper(Context context) {
super(context, DATABASENAME, null, DATABASEVERSION);
}

public void onCreate(SQLiteDatabase db) {
//建数据表 message 日记
db.execSQL("CREATE TABLE message (message_id integer primary key autoincrement,
                   message_title varchar(50), message_text varchar(500), message_state integer)");
}

public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS message");
onCreate(db);
}
}

然后再写上操作数据表数据的方法。就拿生气日记信息的数据处理举例子。

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;

public class MessageServiceDB {

private DBOpenHelper dbOpenHelper;
//构造函数
public MessageServiceDb(Context context) {
this.dbOpenHelper = new DBOpenHelper(context);
}
//保存数据
public void save(Message message){
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("message_title", message.getTitle());
values.put("message_text", message.getText());
db.insert("message", null, values);
db.close();
}
//获得数据
public List<Message> getScrollData(Integer offset, Integer maxResult){
List<Message> messageList = new ArrayList<Message>();
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor = db.query("message", null, null, null, null, null, "message_id desc", offset+","+ maxResult);
while(cursor.moveToNext()){
Integer message_id = cursor.getInt(cursor.getColumnIndex("message_id"));
String message_title = cursor.getString(cursor.getColumnIndex("message_title"));
String message_text = cursor.getString(cursor.getColumnIndex("message_text"));
Message message = new TvMessage(message_id,message_title, message_text);
messageList.add(message);
}
cursor.close();
db.close();
return messageList;
}
//删除一项数据
public void delete(Integer id){
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
db.delete("message", "message_id=?", new String[]{id.toString()});
db.close();
}  
}

利用之前构造好的数据库帮助类dbOpenHelper,然后调用增删改查进行数据处理。这里面的增删改查,有两种方式实现,一种直接写Sql语句,另一种支持对象操作。我基本上都是用的对象操作,比如删除直接就是db.delete(xx)

日志列表我也是煞费苦心,为了便于了解女朋友还对哪些事情生气,我特意开发了生气事件黑白名单功能。一个事件可以反复标记是否原谅我了。这样可以了解女朋友心中还有哪些心结,可以让我逐个攻破。


此处调用一个修改方法就可以了,其实就是取出message对象后重新setState一个新值就可以了。

//修改信息状态
public void update(Message message){
   SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
   ContentValues values = new ContentValues();
   values.put("message_state", message.getState());
   db.update("message", values, "message_id=?", new String[]{message.getId().toString()});
   db.close();
}

需要注意的是,对于每次打开数据库或者Cursor,都要记得关闭,不关闭不会影响功能,但是会带来风险。现在你使用各种框架的话,完全不用考虑这些操作,因为他们都帮你做了。

安卓开发,以前和现在,用SDK和用第三方框架,就像是汽车的手动挡和自动挡,其中的优劣,自己体会。虽然我是从老手动挡过来的,但是我站自动挡这边,因为我不会傻到逆着发展趋势去行走。

反馈

女朋友也涂鸦了,也写了生气日记,最后应该也累了。为了缓解她的疲劳,我特意开发了一个看图发愣的功能。只需要点开看图发愣,就会随机出现一个唯美的动态图,并且伴随着唰唰的雨声,可以让她看上几个小时,仔细思考人生,思考我这个男朋友值不值得交往。


对于如何展示gif动态图,前端可能要骂人了,因为gif还需要动代码吗?浏览器不全给解释了。但是,当时我需要自己去解析,安卓原生的图片控件是无法展示动图的。所以,你看老一辈的程序员面临多少困难,新一代的程序员完全不用考虑这些。所以,你们应该把更多精力放在更高级的研究上,因为我相信你们也有你们的困难点。

这个图很美,我单独拿出来了,朋友们可以保存下来,自己看。或者,你试试自己去解析一下。


好了,现在总该不生气了吧。针对于此时的场景,我又开发了一个快捷短信功能,女朋友可以选择短信模板,快速给我发送短信息,给我一个台阶,让我及时去哄她。她可以说不是她发的,是我开发的软件发的,这样可以避免“她先联系的我”这类不利立场的产生。


我一贯执行配置策略,短信模板也是写到value文件夹下的xml文件中的。

<string-array name="message_ex"> 
   <item>不要生气了,我错了!</item>  
   <item>我不生气了,你快点陪我逛街!</item>  
   <item>讨厌,还不给我打电话!</item>  
   <item>我错了,我不该对你发火的!</item>  
   <item>三个小时内给我打电话!</item>  
   <item>快给我给我买爆米花!</item>    
</string-array>

关于发送短信,这里面有两点细节。

SmsManager smsManager = SmsManager.getDefault();
PendingIntent sentIntent = PendingIntent.getBroadcast(DuanxinActivity.this, 0, new Intent(), 0);
//如果字数超过70,需拆分成多条短信发送
if (text.length() > 70) {
   List<String> msgs = smsManager.divideMessage(text);
   for (String msg : msgs) {
   smsManager.sendTextMessage(phone, null, msg, sentIntent, null);                        
  }
} else {
   smsManager.sendTextMessage(phone, null, text, sentIntent, null);
}

第一,为什么不调用Intent(Intent.ACTION_SEND)让系统去发送短信?一开始确实是这样的。但是,后来改良为调用代码应用内发送了。因为我不想让女朋友离开我的软件,跳到第三方系统应用,避免用户跳出场景,因为有时候女朋友会夺命连环发短信(告知对方问题很严重,提高优先级),需要来回切程序,这样用着不爽,就更生气了。而且自己发送我还能捕获到发送结果,给出“发送成功”的温馨提示,但是交给第三方应用发送你是获取不到的。

第二,关于字数超过70,需要拆成多条短信,这也是经过实践得来的。满满的都是痛。

有同学不明白为什么要发短信,因为那时候还没有微信,微信是2011年才出来的。

后记

后来,经过不断反馈和改良,这款App越来越完善。

最后,女朋友看我这么用心地对待这份感情,尤其对于她反馈的软件问题,我询问地非常仔细(复现场景、发生时间、前后操作流程),修改地也非常及时(改完了就让她再试试看),她感觉我是一个靠谱和细心的人,于是她也慢慢地不再那么容易生气了。

再后来,有一个全国高校的大学生IT技能比赛,我的老师就让我拿这个作品参赛了,最后去了北京大学进行了决赛。

虽然这款App技术含量不高,但它是一款身经百战的App,它经过了多次迭代,因为用户体验和创意比较好,我最终获得全国第七名的成绩,荣获二等奖。

下面是证书,教育部的证书我是不敢造假的,我用Photoshop(是的,我也会做UI设计)做了简单的遮挡和放大,主要想让大家看一下日期确实是2011年,和我文章里描述的一样(我没有编故事)。


这款App真的没有任何技术含量,无外乎控件的罗列、画板的绘制、数据的存储。我想现在的每一个大学生都能做的到。但是不夸张地讲,它看起来却是很强大的样子。而究其根源,我想应该就是它运用技术手段尝试去解决生活中的问题,让效果得到了放大,使它具备了生命力。

直至今天,我依然在做类似(用技术解决生活中的问题)的事情。

最后,我想借助掘金App的标语结束本文,这也是我每天都会看到的一句话:

相信技术,传递价值。

作者:TF男孩
来源:juejin.cn/post/7123985353878274056

收起阅读 »

Android 三行代码实现高斯模糊

设计:有了毛玻璃效果,产品的逼格直接拉满了呀我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧设计:GayHub ???寻找可行的方案要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单...
继续阅读 »

设计:有了毛玻璃效果,产品的逼格直接拉满了呀

我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧

设计:GayHub ???

寻找可行的方案

要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单直接效率最高的方式,还得是上 Github。

搜索的关键词为 android blur,可以看到有两个库是比较合适的, BlurryBlurView。 这两个库 Star 数比较高,并且也还在维护着。

于是,便尝试了一番,发现 BlurView 比 Blurry 更好用,十分推荐上手 BlurView


Blurry

  • 优点:API 使用非常简洁,效果也不错,提供同步和异步加载的解决方案

  • 缺点:奇奇怪怪的 Bug 非常多,并且只能作用于 ImageView

    • 使用时,基本会遇到这两个 Bug:issue1issue2

    • issue1(NullPointerException) 已经有现成的解决方案

    • issue2(Canvas: trying to use a recycled bitmap) 则从 17 年至今毫无进展,并且复现概率还比较高

BlurView(推荐)

  • 优点:使用的过程中几乎没有遇到 bug,实现时调用的代码较少。并且,可以实现复杂的模糊 View

  • 缺点:需要在 xml 中配置,并且需要花几秒钟的时间理解一下 rootView 的概念

  • 使用方式:

    XML:

    <androidx.constraintlayout.widget.ConstraintLayout
    ...
    android:id="@+id/rootView"
    android:background="@color/purple_200" >
     
    <ImageView
      ...
      android:id="@+id/imageView" />
     
    <eightbitlab.com.blurview.BlurView
      ...
      android:id="@+id/blurView" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    MainActivity#onCreate:

    // 这里的 rootView,只要是 blurView 的任意一个父 View 即可
    val rootView = findViewById<ConstraintLayout>(R.id.rootView)
    val blurView = findViewById<BlurView>(R.id.blurView)
    blurView.setupWith(rootView, RenderScriptBlur(this))
  • 实现的效果

    使用前:

    使用后:

  • Tips :

    • 在 BlurView 以下的 View 都会有高斯模糊的效果

    • rootView 可以选择离 BlurView 最近的 ViewGroup

    • .setBlurRadius() 可以用来设置卷积核的大小,默认是 16F

    • .setOverlayColor() 可以用来设置高斯模糊覆盖的颜色值

    • 例如如下参数配置时可以达到这样的效果:

      blurView.setupWith(rootView, RenderScriptBlur(this))
                .setBlurRadius(5F)
                .setOverlayColor(Color.parseColor("#77000000"))

    • 最后,再补充一下滑动时的效果:



作者:很好奇
来源:juejin.cn/post/7144663860027326494

收起阅读 »

糟糕的 Kotlin 语法糖

这几天在 review 同事的代码的时候,发现一块有意思的代码,我将其写成对应的伪代码如下:class UserViewModel(val userUsecase: UserUsecase) {    // 根据 userId 获取 use...
继续阅读 »

这几天在 review 同事的代码的时候,发现一块有意思的代码,我将其写成对应的伪代码如下:

class UserViewModel(val userUsecase: UserUsecase) {

   // 根据 userId 获取 userName
   fun getUser(userId:Int) {
       val name = userUsecase(userId).name
  }
   
}

class User(val name: String, val age: Int) {}

起初在看到这段代码的时候,觉得十分反人类,在 Kotlin 中,对象的初始化可以省略 new 操作符,也即类后面再配个 () 即可,为啥一个初始化的对象还能继续用 (),在直观的感受下,我以为是初始化了一个对象,唯一让我觉得不像是初始化的就是 userUsecase 开头并不是大写,这才打消我认为他是初始化对象的疑虑。

在我想点进去看下根据 userId 获取 User 的过程,我无论追踪代码,都无法跳转到真正的逻辑代码调用处,点击 userUsecase 会直接跳转到 UserViewModel 的构造方法,点击 name 会跳转到 User 对象,这让我很苦恼。

我不得不点击 UserUsecase 类去看下里面的代码,这对于 review 人来说简直是灾难,但为了解决问题,先妥协,再一探究竟。

进入 UserUsecase 类,伪代码如下:

class UserUsecase {
   operator fun invoke(userId: Int): User {
       // 从数据库中根据 id 获取 User 数据
       // 返回 User 数据
       return User("lisi", 30)
  }
}

看到了奇怪的 invoke 函数,并且是用了 operator 操作重载符,为了了解这种语法,我在 Kotlin 中文网查了下该语法的使用,在调用操作符章节中有所说明:


对象() 等价于 对象.invoke()()内为函数的参数,也即我们上面的那段代码,可以翻译一下:

class UserViewModel(val userUsecase: UserUsecase) {
   fun getUser() {
       val name = userUsecase(1001).name
       // 等价于
       val name2 = userUsecase.invoke(1001).name
  }
}

也可以用 Kotlin Decompile 看下结果:


需要说明的是,对象() 这种写法是有条件的:

  • 必须用 operator 修饰方法

  • 方法名称必须是 invoke

  • invoke 参数可以多个,不做限制

由于 invoke 函数参数不加限制,这又带来了一个问题,如果重载了多个 invoke 函数,就更不知道业务方在调用的时候是做了什么事情,依然不得不进入代码才能知道逻辑。

上面的示例给的已足够简单,但实际在我们的业务中,比这还复杂,invoke 函数被封装到了父类,当我点进去的时候根本找不到 invoke 函数,只能往上查看父类有没有,在找到 invoke 函数时才发现,他最终调用了个抽象方法,该抽象方法由子类实现,我又不得不返回到子类查看这个方法,最终才敲定这个方法做了什么逻辑。

总结:

虽然 operator invoke 可以省略调用方写函数名这个过程,但需要注意的是,代码无论是类名还是方法名还是变量名,一定要做到见名识意,显然,他已经破坏了这个规则,让 review 人很抓狂。

我也很理解大家对 Jetpack 的热爱,这种写法在官方也有出现,可以参考 Domain Layer 这章。但我想说的是,省略方法名这个过程真的有必要吗?写代码到底是为了炫技还是为了让别人能看懂自己的代码呢?


作者:codelang
来源:juejin.cn/post/7081112122179977224

收起阅读 »

深入理解MMAP原理,大厂爱不释手的技术手段

为什么大厂爱不释手如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:跨平台,C++编写,可以支持多平台跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信高性能,实现用户...
继续阅读 »

为什么大厂爱不释手

如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:

  • 跨平台,C++编写,可以支持多平台

  • 跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信

  • 高性能,实现用户空间和内核空间的零拷贝,速度快且节约内存等

  • 高稳定,页中断保护神,由操作系统实现的,稳定性可想而知

函数介绍

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
复制代码
  • addr 代表映射的虚拟内存起始地址;

  • length 代表该映射长度;

  • prot 描述了这块新的内存区域的访问权限;

  • flags 描述了该映射的类型;

  • fd 代表文件描述符;

  • offset 代表文件内的偏移值。

mmap的强大之处在于,它可以根据参数配置,用于创建共享内存,从而提高文件映射区域的IO效率,实现IO零拷贝,后面讲下零拷贝的技术,对比下,决定这些功能的主要就是三个参数,下面一一解释

prot

四种情况如下:

  • PROT_EXEC,代表该内存映射有可执行权限,可以看成是代码段,通常存储CPU可执行机器码

  • PROT_READ,代表该内存映射可读

  • PROT_WRITE,代表该内存映射可写

  • PROT_NONE,代表该内存映射不能被访问

flags

比较有代表性的如下:

  • MAP_SHARED,创建一个共享映射区域

  • MAP_PRIVATE,创建一个私有映射区域

  • MAP_ANONYMOUS,创建一个匿名映射区域,该情况只需要传入-1即可

  • MAP_FIXED,当操作系统以addr为起始地址进行内存映射时,如果发现不能满足长度或者权限要求时,将映射失败,如果非MAP_FIXED,则系统就会再找其他合适的区域进行映射

fd

当参数fd不等于0时,内存映射将与文件进行关联,如果等于0,就会变成匿名映射,此时flags必为MAP_ANONYMOUS

应用场景


一个mmap竟有如此丰富的功能,从申请分配内存到加载动态库,再到进程间通信,真的是无所不能,强大到让人五体投地。下面就着四种情况,拿一个我最关心的父子进程通信来举例看下,实现一个简单的父子进程通信逻辑,毕竟我们学习的目的就是为了应用,光有理论怎么能称之为合格的博客呢?

父子进程共享内存

#include <iostream>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/mman.h>

int main() {
  pid_t c_pid = fork();

  char* shm = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

  if (c_pid == -1) {
      perror("fork");
      exit(EXIT_FAILURE);
  } else if (c_pid > 0) {
      printf("parent process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, my child");
      printf("parent process got a message: %s\n", shm);
      wait(nullptr);
  } else {
      printf("child process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, father.");
      printf("child process got a message: %s\n", shm);
      exit(EXIT_SUCCESS);
  }

  return EXIT_SUCCESS;
}

运行后打印如下

parent process pid: 87799
parent process got a message: hello, my child
child process pid: 87800
child process got a message: hello, father.

Process finished with exit code 0

用mmap创建了一块匿名共享内存区域,fd传入-1MAP_ANONYMOUS配置实现匿名映射,使用MAP_SHARED创建共享区域,使用fork函数创建子进程,这样来实现子进程通信,通过sprintf将格式化后的数据写入到共享内存中。

通过简单的几行代码就实现了跨进程通信,如此简单,这么强大的东西,背后有什么支撑么?带着问题我们接着一探究竟。

MMAP背后的保护神

说到MMAP的保护神,首页了解下内存页:在页式虚拟存储器中,会在虚拟存储空间和物理主存空间都分割为一个个固定大小的页,为线程分配内存是也是以页为单位。比如:页的大小为 4K,那么 4GB 存储空间就需要4GB/4KB=1M 条记录,即有 100 多万个 4KB 的页,内存页中,当用户发生文件读写时,内核会申请一个内存页与文件进行读写操作,如图


这时如果内存页中没有数据,就会发生一种中断机制,它就叫缺页中断,此中断就是MMAP的保护神,为什么这么说呢?我们知道mmap函数调用后,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存,当访问这些没有建立映射关系的虚拟内存时,CPU加载指令发现代码段是缺失的,就触发了缺页中断,中断后,内核通过检查虚拟地址的所在区域,发现存在内存映射,就可以通过虚拟内存地址计算文件偏移,定位到内存所缺的页对应的文件的页,由内核启动磁盘IO,将对应的页从磁盘加载到内存中。最终保护mmap能顺利进行,无私奉献。了解完缺页中断,我们再来细聊下mmap四种场景下的内存分配原理

四种场景分配原理


上面是一个简单的原理总结,并没有详细的展开,感兴趣可以自己查查资料哈。

总结

本次分享,主要介绍了mmap的四种应用场景,通过一个实例验证了父子进程间的通信,并深入mmap找到它的保护神,且深入了解到mmap在四种场景下,操作系统是如何组织分配,通过对这些的了解,在你之后的mmap实战应用有了更好的理论基础,可以根据不同的需求,不同的性能要求等,选择最合适的实现。


作者:i校长
来源:juejin.cn/post/7119116943256190990

收起阅读 »

以往项目中的压缩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

收起阅读 »