注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

tinaJs 源码分析

是什么为了避免混淆 tina 和原生的一些概念,这里先说明一下一些词的含义开局先来预览一下 Page.define 的流程// tina/class/page.jsclass Page extends Basic {  static mixins =...
继续阅读 »

目前公司团队小程序框架使用的是 tinaJs,这篇文章将讲解这个框架的源码。阅读文章时可以对照着这个小工程阅读源码,这个小工程主要是对 tina 加了更多的注释及示例。

是什么

tinaJs 是一款轻巧的渐进式微信小程序框架,不仅能充分利用原生小程序的能力,还易于调试。
这个框架主要是对 Component、Page 两个全局方法进行了封装,本文主要介绍 tinaJS 1.0.0 的 Paeg.define 内部做了些什么。Component.definePaeg.define相似,理解 Paeg.define 之后自然也就理解 Component.define。为什么是讲解 1.0.0 ?因为第一个版本的代码相对于最新版本主干内容更更清晰更容易上手。


概览

为了避免混淆 tina 和原生的一些概念,这里先说明一下一些词的含义

  • wx-Page - 原生 Page 对象

  • tina-Page - tina/class/page 这个类

  • wxPageOptions - 构建原生 Page 实例的 options

  • tinaPageOptions - 构建原生 tina-Page 实例的 options

开局先来预览一下 Page.define 的流程

// tina/class/page.js
class Page extends Basic {
 static mixins = []
 static define(tinaPageOptions = {}) {
   // 选项合并
   tinaPageOptions = this.mix(/*....*/)
   
   // 构建原生 options 对象
   let wxPageOptions = {/*.....*/}
   
   // 在原生 onLoad 时做拦截,关联 wx-Page 对象和 tina-Page 对象
   wxPageOptions = prependHooks(wxPageOptions, {
     onLoad() {
       // this 是小程序 wx-Page 实例
       // instance 是这个 tina-Page 实例
       let instance = new Page({ tinaPageOptions })
       // 建立关联
       this.__tina_instance__ = instance
       instance.$source = this
    }
  })
   
   // 构造 wx-Page 对象
   new globals.Page({
      // ...
      ...wxPageOptions,
    })
}
 constructor({ tinaPageOptions = {} }) {
   super()
   //.......
}
 get data() {
  return this.$source.data
}
}

下面针对每个小流程做讲解

mix

tina 的 mixin 是靠 js 对对象做合并实现的,并没有使用原生的 behaviors

tinaPageOptions = this.mix(PAGE_INITIAL_OPTIONS, [...BUILTIN_MIXINS, ...this.mixins, ...(tinaPageOptions.mixins || []), tinaPageOptions])

tinaJs 1.0.0 只支持一种合并策略,跟 Vue 的默认合并策略一样

  • 对于 methods 就是后面的覆盖前面的

  • 对于生命周期勾子和特殊勾子(onPullDownRefresh 等),就是变成一个数组,还是后面的先执行

  • 也就是 tinaPageOptions.mixins > Page.mixins(全局 mixin) > BUILTIN_MIXINS

合并后可以得到这样一个对象

{
// 页面
beforeLoad: [$log.beforeLoad, options.beforeLoad],
onLoad: [$initial.onLoad, options.onLoad],
onHide: [],
onPageScroll: [],
onPullDownRefresh: [],
onReachBottom: [],
onReady: [],
onShareAppMessage: [],
onShow: [],
onUnload: [],
// 组件
attached: Function,
compute: Function,
created: $log.created,
// 页面、组件共用
data: tinaPageOptions.data,
methods: tinaPageOptions.methods,
mixins: [],
}

合并后是创建 wx-Page 对象,至于创建 wx-Page 对象过程做了什么,为了方便理解整个流程,在这里暂时先跳过讲解,放在后面 改变执行上下文 小节再讲解。

关联 wx-Page、tina-Page

为了绑定 wx-Page 对象,tina 在 wx-onLoad 中追加了一些操作。
prependHooks 是作用是在 wxPageOptions[hookName] 执行时追加 handlers[hookName] 操作,并保证 wxPageOptions[hookName]handlers[hookName] 的执行上下文是原生运行时的 this

// tina/class/page
wxPageOptions = prependHooks(wxPageOptions, {
 onLoad() {
   // this 是 wxPageOptions
   // instance 是 tina-Page 实例
   let instance = new Page({ tinaPageOptions })
   // 建立关联
   this.__tina_instance__ = instance
   instance.$source = this
}
})


// tina/utils/helpers.js

/**
* 在 wx-page 生命周期勾子前追加勾子
* @param {Object} context
* @param {Array} handlers
* @return {Object}
*/
export const prependHooks = (context, handlers) =>
addHooks(context, handlers, true)

function addHooks (context, handlers, isPrepend = false) {
 let result = {}
 for (let name in handlers) {
   // 改写 hook 方法
   result[name] = function handler (...args) {
     // 小程序运行时, this 是 wxPageOptions
     if (isPrepend) {
       // 执行 tina 追加的 onLoad
       handlers[name].apply(this, args)
    }
     if (typeof context[name] === 'function') {
       // 执行真正的 onLoad
       context[name].apply(this, args)
    }
     // ...
  }
}
 return {
   ...context,
   ...result,
}
}

构建 tina-Page

接下来再来看看 new Page 做了什么

  constructor({ tinaPageOptions = {} }) {
   super()
   // 创建 wx-page options
   let members = {
     // compute 是 tina 添加的方法
     compute: tinaPageOptions.compute || function () {
       return {}
    },
     ...tinaPageOptions.methods,
     // 用于代理所有生命周期(包括 tina 追加的 beforeLoad)
     ...mapObject(pick(tinaPageOptions, PAGE_HOOKS), (handlers) => {
       return function (...args) {
         // 因为做过 mixin 处理,一个生命周期会有多个处理方法
         return handlers.reduce((memory, handler) => {
           const result = handler.apply(this, args.concat(memory))
           return result
        }, void 0)
      }
    }),
     // 以 beforeLoad、onLoad 为例,以上 mapObject 后追加的生命周期处理方法实际执行时是这样的
     // beforeLoad(...args) {
     // return [onLoad1、onLoad2、.....].reduce((memory, handler) => {
     //   return handler.apply(this, args.concat(memory))
     // }, void 0)
     //},
     // onLoad(...args) {
     //   return [onShow1、onShow2、.....].reduce((memory, handler) => {
     //     return handler.apply(this, args.concat(memory))
     //   }, void 0)
     // },
  }

   // tina-page 代理所有属性
   for (let name in members) {
     this[name] = members[name]
  }

   return this
}

首先是将 tinaPageOptions 变成跟 wxPageOptions 一样的结构,因为 wxPageOptions 的 methodshooks 都是在 options 的第一层的,所以需要将将 methods 和 hooks 铺平。
又因为 hooks 经过 mixins 处理已经变成了数组,所以需要遍历执行,每个 hooks 的第二个参数都是之前累积的结果。然后通过简单的属性拷贝将所有方法拷贝到 tina-Page 实例。

改变执行上下文

上面提到构建一个属性跟 wx-Page 一模一样的 tina-Page 对象,那么为什么要这样呢?一个框架的作用是什么?我认为是在原生能力之上建立一个能够提高开发效率的抽象层。现在 tina 就是这个抽象层,
举个例子来说就是我们希望 methods.foo 被原生调用时,tina 能在 methods.foo 里做更多的事情。所以 tina 需要与原生关联使得所有本来由原生处理的东西转交到 tina 这个抽象层处理。
那 tina 是如何处理的呢。我们先来看看创建 wxPageOptions 的源码

// tina/class/page.js
let wxPageOptions = {
 ...wxOptionsGenerator.methods(tinaPageOptions.methods),
 ...wxOptionsGenerator.lifecycles(
   inUseOptionsHooks,
  (name) => ADDON_BEFORE_HOOKS[name]
),
}


// tina/class/page.js
/**
* wxPageOptions.methods 中的改变执行上下文为 tina.Page 对象
* @param {Object} object
* @return {Object}
*/
export function methods(object) {
 return mapObject(object || {}, (method, name) => function handler(...args) {
   let context = this.__tina_instance__
   return context[name].apply(context, args)
})
}

答案就在 wxOptionsGenerator.methods。上面说过在 onLoad 的时候会绑定 __tina_instance__ 到 wx-Page,同时 wx-Page 与 tina-Page 的属性都是一模一样的,所以调用会被转发到 tina 对应的方法。这就相当于 tina 在 wx 之上做了一个抽象层。所有的被动调用都会被 tina 处理。而且因为上下文是 __tina_instance__ 的缘故,
所有主动调用都先经过 tina 再到 wx。结合下面两个小节会有更好的理解。


追加生命周期勾子

上面创建 wxPageOptions 时有这么一句 wxOptionsGenerator.lifecycles 代码,这是 tina 用于在 onLoad 之前加多一个 beforeLoad 生命周期勾子,这个功能是怎么做的呢,我们来看看源码

// tina/utils/wx-options-generator

/**
* options.methods 中的改变执行上下文为 tina.Page 对象
* @param {Array} hooks
* @param {Function} getBeforeHookName
* @return {Object}
*/
export function lifecycles(hooks, getBeforeHookName) {
 return fromPairs(hooks.map((origin) => {
   let before = getBeforeHookName(origin) // 例如 'beforeLoad'
   return [
     origin, // 例如 'load'
     function wxHook() {
       let context = this.__tina_instance__
       // 调用 tina-page 的方法,例如 beforeLoad
       if (before && context[before]) {
         context[before].apply(context, arguments)
      }
       if (context[origin]) {
         return context[origin].apply(context, arguments)
      }
    }
  ]
}))
}

其实就是改写 onLoad ,在调用 tina-Page.onLoad 前先调用 tina-Page.beforeLoad。可能有的人会有疑问,为什么要加个 beforeLoad 勾子,这跟直接 onLoad 里不都一样的么。
举个例子,很多时候我们在 onLoad 拿到 query 之后是不是都要手动去 decode,利用全局 mixinsbeforeLoad,可以一次性把这个事情做了。

Page.mixins = [{
 beforeLoad(query) {
   // 对 query 进行 decode
   // 对 this.$options 进行 decode
}
}]

还有一点需要注意的是,tina 源码中了多次对 onLoad 拦截,执行顺序如下

prependHooks.addHooks.handler -> wx-Page.onLoad,关联 wx-PagetinaPage -> 回到 prependHooks.addHooks.handler -> lifecycles.wxHook -> tina-Page.beforeLoad -> tina-Page.onLoad

如下图所示


compute 实现原理

因为运行时的上下文都被 tina 改为 tina-Page,所以开发者调用的 this.setData, 实际上的 tina-Page 的 setData 方法,又因为 tina-Page 继承自 Basic,也就调用 Basic 的 setData 方法。下面看看 setData 的源码

setData(newer, callback = () => {}) {
 let next = { ...this.data, ...newer }
 if (typeof this.compute === 'function') {
   next = {
     ...next,
     ...this.compute(next),
  }
}
 next = diff(next, this.data)
 this.constructor.log('setData', next)
 if (isEmpty(next)) {
   return callback()
}
 this.$source.setData(next, callback)
}

从源码可以看到就是每次 setData 的时候调用一下 compute 更新数据,这是 compute 的原理,很容易理解吧。

前面 mix 小节提到,tina 会合并一些内置选项,可以看到在 onLoad 时会调用this.setData,为了初始化 compute 属性。

// mixins/index.js

function initial() {
 // 为了初始化 compute 属性
 this.setData()
 this.$log('Initial Mixin', 'Ready')
}

export const $initial = {
 // ...
 onLoad: initial,// 页面加载完成勾子
}

小结

到此基本上把 Page.define 主干流程讲完,如有疑问欢迎留言

参考

来源:segmentfault.com/a/1190000021949561

收起阅读 »

GitHub:全国各省市烂尾楼停贷汇总

最近频繁收到关于“停贷”的新闻推送,疫情这几年对经济影响确实大。年轻人前有老板压榨,后有房贷鞭挞。气愤前同事弃坑跑路,却又不得不接手。面对“屎山”代码,心里很排斥却还担心被人抢走,因为身边的同事一批批的毕业,但你不想毕业,自从有了妻子、有了孩子、有了房贷,你变...
继续阅读 »

最近频繁收到关于“停贷”的新闻推送,疫情这几年对经济影响确实大。

年轻人前有老板压榨,后有房贷鞭挞。

气愤前同事弃坑跑路,却又不得不接手。

面对“屎山”代码,心里很排斥却还担心被人抢走,因为身边的同事一批批的毕业,

但你不想毕业,

自从有了妻子、有了孩子、有了房贷,

你变得更有责任心了。

你不会再因为一时冲动离职。

你变得脾气好了,

更能适应领导的加班安排、更能接受遇到的不公平。

可是最后,

你还是毕业了……

你不停的找朋友内推,

又计算着自己可以维持多久的房贷。

直到业主群里炸锅:楼盘烂尾、房开跑路了!

你没有生气,

反而异常平静。

扔掉了房贷计算的稿纸,

习惯性的打开GitHub,

鬼使神差的输入“烂尾楼”

竟然发现一个项目:全国各省市烂尾楼停贷通知汇总(微信打不开要用浏览器https://github.com/WeNeedHome/SummaryOfLoanSuspension)


 一天更新40+,快去看看有没有你家附近的吧!


不知道这个项目会不会像996ICU那样受关注。目前star已经13k了,太疯狂了,我辛辛苦苦写个开源项目,一年下来才二百来star。虽然技术无关,但也算技术圈的网红了。

逛了一圈,很满足,仿佛我又是一个纯粹的技术人。

看着窗外远远的星星,一颗、两颗、无数颗,却没有一颗属于我,正如这灯火通明的城市,没有一处灯是属于我的,我头上的灯是房东的。

我想我买的小区此刻肯定漆黑一片,因为都没建好,都烂尾了,开发商都跑路了。

我如梦初醒,我他妈工作没了,房子没了,还有心情在这逛GitHub,

我真是一个失败的码农,逛GitHub还分心!

收起阅读 »

慢 SQL 分析与优化

背景介绍从系统设计角度看,一个系统从设计搭建到数据逐步增长,SQL 执行效率可能会出现劣化,为继续支撑业务发展,我们需要对慢 SQL 进行分析和优化,严峻的情况下甚至需要对整个系统进行重构。所以我们往往需要在系统设计前对业务进行充分调研、遵守系统设计规范,在系...
继续阅读 »

背景介绍

从系统设计角度看,一个系统从设计搭建到数据逐步增长,SQL 执行效率可能会出现劣化,为继续支撑业务发展,我们需要对慢 SQL 进行分析和优化,严峻的情况下甚至需要对整个系统进行重构。所以我们往往需要在系统设计前对业务进行充分调研、遵守系统设计规范,在系统运行时定期结合当前业务发展情况进行系统瓶颈的分析。

从数据库角度看,每个 SQL 执行都需要消耗一定 I/O 资源,SQL 执行的快慢,决定了资源被占用时间的长短。假如有一条慢 SQL 占用了 30%的资源共计 1 分钟。那么在这 1 分钟时间内,其他 SQL 能够分配的资源总量就是 70%,如此循环,当资源分配完的时候,所有新的 SQL 执行将会排队等待。所以往往一条慢 SQL 会影响到整个业务。

本文仅讨论 MySQL-InnoDB 的情况。

优化方式

SQL 语句执行效率的主要因素

  • 数据量

    • SQL 执行后返回给客户端的数据量的大小;

    • 数据量越大需要扫描的 I/O 次数越多,数据库服务器的 IO 更容易成为瓶颈。

  • 取数据的方式

    • 数据在缓存中还是在磁盘上;

    • 是否能够通过全局索引快速寻址;

    • 是否结合谓词条件命中全局索引加速扫描。

  • 数据加工的方式

    • 排序、子查询、聚合、关联等,一般需要先把数据取到临时表中,再对数据进行加工;

    • 对于数据量比较多的计算,会消耗大量计算节点的 CPU 资源,让数据加工变得更加缓慢;

    • 是否选择了合适的 join 方式

优化思路

  • 减少数据扫描(减少磁盘访问)

    • 尽量在查询中加入一些可以提前过滤数据的谓词条件,比如按照时间过滤数据等,可以减少数据的扫描量,对查询更友好;

    • 在扫描大表数据时是否可以命中索引,减少回表代价,避免全表扫描。

  • 返回更少数据(减少网络传输或磁盘访问)

  • 减少交互次数(减少网络传输)

    • 将数据存放在更快的地方

    • 某条查询涉及到大表,无法进一步优化,如果返回的数据量不大且变化频率不高但访问频率很高,此时应该考虑将返回的数据放在应用端的缓存当中或者 Redis 这样的缓存当中,以提高存取速度。

  • 减少服务器 CPU 开销(减少 CPU 及内存开销)

  • 避免大事务操作

  • 利用更多资源(增加资源)

优化案例

数据分页优化

sele ct * from table_demo where type = ? limit ?,?;

优化方式一:偏移 id

lastId = 0 or min(id)
do {
sele ct * from table_demo where type = ? and id >{#lastId} limit ?;
lastId = max(id)
} while (isNotEmpty)

优化方式二:分段查询

该方式较方式一的优点在于可并行查询,每个分段查询互不依赖;较方式一的缺点在于较依赖数据的连续性,若数据过于分散,代价较高。

minId = min(id) maxId = max(id)
for(int i = minId; i<= maxId; i+=pageSize){
sele ct * from table_demo where type = ? and id between i and i+ pageSize;
}

优化 GROU P BY

提高 GROU P BY 语句的效率, 可以通过将不需要的记录在 GROU P BY 之前过滤掉.下面两个查询返回相同结果但第二个明显就快了许多。

低效:

sele ct job , avg(sal) from table_demo grou p by job having job = ‘manager'

高效:

sele ct job , avg(sal) from table_demo where job = ‘manager' grou p by job

范围查询

联合索引中如果有某个列存在范围(大于小于)查询,其右边的列是否还有意义?

expla in sele ct count(1) from statement where org_code='1012' and trade_date_time >= '2019-05-01 00:00:00' and trade_date_time<='2020-05-01 00:00:00'
expla in sele ct * from statement where org_code='1012' and trade_date_time >= '2019-05-01 00:00:00' and trade_date_time<='2020-05-01 00:00:00' limit 0, 100
expla in sele ct * from statement where org_code='1012' and trade_date_time >= '2019-05-01 00:00:00' and trade_date_time<='2020-05-01 00:00:00'
  • 使用单键索引 trade_date_time 的情况下

    • 从索引里找到所有 trade_date_time 在'2019-05-01' 到'2020-05-01' 区间的主键 id。假设有 100 万个。

    • 对这些 id 进行排序(为的是在下面一步回表操作中优化 I/O 操作,因为很多挨得近的主键可能一次磁盘 I/O 就都取到了)

    • 回表,查出 100 万行记录,然后逐个扫描,筛选出 org_code='1020'的行记录

  • 使用联合索引 trade_date_time, org_code -联合索引 trade_date_time, org_code 底层结构推导如下:


以查找 trade_date_time >='2019-05-01' and trade_date_time <='2020-05-01' and org_code='1020'为例:

  1. 在范围查找的时候,直接找到最大,最小的值,然后进行链表遍历,故仅能用到 trade_date_time 的索引,无法使用到 org_code 索引

  2. 基于 MySQL5.6+的索引下推特性,虽然 org_code 字段无法使用到索引树,但是可以用于过滤回表的主键 id 数。

小结:对于该 case, 索引效果[org_code,trade_date_time] > [trade_date_time, org_code]>[trade_date_time]。实际业务场景中,检索条件中 trade_date_time 基本上肯定会出现,但 org_code 却不一定,故索引的设计还需要结合实际业务需求。

优化 Order by

索引:

KEY `idx_account_trade_date_time` (`account_number`,`trade_date_time`),
KEY `idx_trade_date_times` (`trade_date_time`)
KEY `idx_createtime` (`create_time`),

慢 SQL:

SELE CT id,....,creator,modifier,create_time,update_time FROM statement
WHERE (account_number = 'XXX' AND create_time >= '2022-04-24 06:03:44' AND create_time <= '2022-04-24 08:03:44' AND dc_flag = 'C') ORDER BY trade_date_time DESC,id DESC LIMIT 0,1000;

优化前:SQL 执行超时被 kill 了

SELE CT id,....,creator,modifier,create_time,upda te_time FROM statement
WHERE (account_number = 'XXX' AND create_time >= '2022-04-24 06:03:44' AND create_time <= '2022-04-24 08:03:44' AND dc_flag = 'C') ORDER BY create_time DESC,id DESC LIMIT 0,1000;

优化后:执行总行数为:6 行,耗时 34ms。

MySQL使不使用索引与所查列无关,只与索引本身,where条件,order by 字段,grou p by 字段有关。索引的作用一个是查找,一个是排序。

业务拆分

sele ct * from order where status='S' and update_time < now-5min limit 500

拆分优化:

随着业务数据的增长 status='S'的数据基本占据数据的 90%以上,此时该条件无法走索引。我们可以结合业务特征,对数据获取按日期进行拆分。

date = now; minDate = now - 10 days
while(date > minDate) {
sele ct * from order where order_date={#date} and status='S' and upda te_time < now-5min limit 500
date = data + 1
}

数据库结构优化

  1. 范式优化:表的设计合理化(符合 3NF),比如消除冗余(节省空间);

  2. 反范式优化:比如适当加冗余等(减少 join)

  3. 拆分表:分区将数据在物理上分隔开,不同分区的数据可以制定保存在处于不同磁盘上的数据文件里。这样,当对这个表进行查询时,只需要在表分区中进行扫描,而不必进行全表扫描,明显缩短了查询时间,另外处于不同磁盘的分区也将对这个表的数据传输分散在不同的磁盘 I/O,一个精心设置的分区可以将数据传输对磁盘 I/O 竞争均匀地分散开。对数据量大的表可采取此方法,可按月建表分区。

SQL 语句优化

SQL 检查状态及分数计算逻辑

  1. 尽量避免使用子查询

  2. 用 IN 来替换 OR

  3. 读取适当的记录 LIMIT M,N,而不要读多余的记录

  4. 禁止不必要的 Order By 排序

  5. 总和查询可以禁止排重用 union all

  6. 避免随机取记录

  7. 将多次插入换成批量 Insert 插入

  8. 只返回必要的列,用具体的字段列表代替 sele ct * 语句

  9. 区分 in 和 exists

  10. 优化 Grou p By 语句

  11. 尽量使用数字型字段

  12. 优化 Join 语句

大表优化

  • 分库分表(水平、垂直)

  • 读写分离

  • 数据定期归档

原理剖析

MySQL 逻辑架构图:


索引的优缺点

优点

  • 提高查询语句的执行效率,减少 IO 操作的次数

  • 创建唯一性索引,可以保证数据库表中每一行数据的唯一性

  • 加了索引的列会进行排序,在使用分组和排序子句进行查询时,可以显著减少查询中分组和排序的时间

缺点

  • 索引需要占物理空间

  • 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加

  • 当对表中的数据进行增删改查时,索引也要动态的维护,这样就降低了数据的更新效率

索引的数据结构

主键索引


普通索引


组合索引


索引页结构


索引页由七部分组成,其中 Infimum 和 Supremum 也属于记录,只不过是虚拟记录,这里为了与用户记录区分开,还是决定将两者拆开。


数据行格式:

MySQL 有 4 种存储格式:

  1. Compact

  2. Redundant (5.0 版本以前用,已废弃)

  3. Dynamic (MySQL5.7 默认格式)

  4. Compressed


Dynamic 行存储格式下,对于处理行溢出(当一个字段存储长度过大时,会发生行溢出)时,仅存放溢出页内存地址。

索引的设计原则

哪些情况适合建索引

  • 数据又数值有唯一性的限制

  • 频繁作为 where 条件的字段

  • 经常使用 grou p by 和 order by 的字段,既有 gro up by 又有 order by 的字段时,建议建联合索引

  • 经常作为 upda te 或 dele te 条件的字段

  • 经常需要 distinct 的字段

  • 多表连接时的字段建议创建索引,也有注意事项

    • 连接表数量最好不要超过 3 张,每增加一张表就相当于增加了一次嵌套循环,数量级增长会非常快

    • 对多表查询时的 where 条件创建索引

    • 对连接字段创建索引,并且数据类型保持一致

  • 在确定数据范围的情况下尽量使用数据类型较小的,因为索引会也会占用空间

  • 对字符串创建索引时建议使用字符串的前缀作为索引

  • 这样做的好处是:

    • 能节省索引的空间,

    • 虽然不能精确定位,但是能够定位到相同的前缀,然后通过主键查询完整的字符串,这样既能节省空间,又减少了字符串的比较时间,还能解决排序问题。

  • 区分度高(散列性高)的字段适合作为索引。

  • 在多个字段需要创建索引的情况下,联合索引优先于单值索引。使用最频繁的列作为索引的最左侧 。

哪些情况下不需要使用索引

  • 在 where 条件中用不到的字段不需要。

  • 数据量小的不需要建索引,比如数据少于 1000 条。

  • 由大量重复数据的列上不要建索引,比如性别字段中只有男和女时。

  • 避免在经常更新的表或字段中创建过多的索引。

  • 不建议主键使用无序的值作为索引,比如 uuid。

  • 不要定义冗余或重复的索引

  • 例如:已经创建了联合索引 key(id,name)后就不需要再单独建一个 key(id)的索引

索引优化之 MRR

例如有一张表 user,主键 id,普通字段 age,为 age 创建非聚集索引,有一条查询语句 sele ct* user from table where age > 18;(注意查询语句中的结果是*)

在 MySQL5.5 以及之前的版本中如何查询呢?先通过非聚集索引查询到 age>18 的第一条数据,获取到了主键 id;然后根据非聚集索引中的叶子节点存储的主键 id 去聚集索引中查询行数据;根据 age>18 的数据条数每次查询聚集索引,这个过程叫做回表。

上述的步骤有什么缺点呢?如何 age>18 的数据非常多,那么每次回表都需要经过 3 次 IO(假设 B+树的高度是 3),那么会导致查询效率过低。

在 MySQL5.6 时针对上述问题进行了优化,优化器先查询到 age>3 的所有数据的主键 id,对所有主键的 id 进行排序,排序的结果缓存到 read_rnd_buffer,然后通过排好序的主键在聚簇索引中进行查询。

如果两个主键的范围相近,在同一个数据页中就可以之间按照顺序获取,那么磁盘 io 的过程将会大大降低。这个优化的过程就叫做 Multi Range Read(MRR) 多返回查询。

索引下推

假设有索引(name, age), 执行 SQL: sele ct * from tuser where name like '张%' and age=10;


MySQL 5.6 以后, 存储引擎根据(name,age)联合索引,找到,由于联合索引中包含列,所以存储引擎直接在联合索引里按照age=10过滤。按照过滤后的数据再一一进行回表扫描。


索引下推使用条件

  • 只能用于rangerefeq_refref_or_null访问方法;

  • 只能用于InnoDBMyISAM存储引擎及其分区表;

  • 对存储引擎来说,索引下推只适用于二级索引(也叫辅助索引);

索引下推的目的是为了减少回表次数,也就是要减少 IO 操作。对于的聚簇索引来说,数据和索引是在一起的,不存在回表这一说。

  • 引用了子查询的条件不能下推;

  • 引用了存储函数的条件不能下推,因为存储引擎无法调用存储函数。

思考:

  1. MySQL 一张表到底能存多少数据?

  2. 为什么要控制单行数据大小?

  3. 优化案例 4 中优化前的 SQL 为什么走不到索引?

总结

抛开数据库硬件层面,数据库表设计、索引设计、业务代码逻辑、分库分表策略、数据归档策略都对 SQL 执行效率有影响,我们只有在整个设计、开发、运维阶段保持高度敏感、追求极致,才能让我们系统的可用性、伸缩性不会随着业务增长而劣化。

参考资料

  1. https://help.aliyun.com/document_detail/311122.html

  2. https://blog.csdn.net/qq_32099833/article/details/123150701

  3. https://www.cnblogs.com/tufujie/p/9413852.html

来源:字节跳动技术团队

收起阅读 »

面试官:应用上线后Cpu使用率飙升如何排查?

大家好,我是飘渺。 上次面试官问了个问题:应用上线后Cpu使用率飙升如何排查? 其实这是个很常见的问题,也非常简单,那既然如此我为什么还要写呢?因为上次回答的时候我忘记将线程PID转换成16进制的命令了。 所以我决定再重温一遍这个问题,当然贴心的我还给大家准备...
继续阅读 »

大家好,我是飘渺。


上次面试官问了个问题:应用上线后Cpu使用率飙升如何排查?


其实这是个很常见的问题,也非常简单,那既然如此我为什么还要写呢?因为上次回答的时候我忘记将线程PID转换成16进制的命令了。


所以我决定再重温一遍这个问题,当然贴心的我还给大家准备好了测试代码,大家可以实际操作一下,这样下次就不会忘记了。


模拟一个高CPU场景


public class HighCpuTest {
public static void main(String[] args) {
List<HignCpu> cpus = new ArrayList<>();

Thread highCpuThread = new Thread(()->{
int i = 0;
while (true){
HignCpu cpu = new HignCpu("Java日知录",i);

cpus.add(cpu);
System.out.println("high cpu size:" + cpus.size());
i ++;
}
});
highCpuThread.setName("HignCpu");
highCpuThread.start();
}
}

在main方法中开启了一个线程,无限构建HighCpu对象。


@Data
@AllArgsConstructor
public class HignCpu {
private String name;
private int age;
}

准备好上面的代码,运行HighCpuTest,然后就可以开始一些列的操作来发现问题原因了。


排查步骤


第一步,使用 top 找到占用 CPU 最高的 Java 进程


1. 监控cpu运行状,显示进程运行信息列表
top -c

2. 按CPU使用率排序,键入大写的P
P

image-20220627165915946


第二步,用 top -Hp 命令查看占用 CPU 最高的线程


上一步用 top命令找到了那个 Java 进程。那一个进程中有那么多线程,不可能所有线程都一直占着 CPU 不放,这一步要做的就是揪出这个罪魁祸首,当然有可能不止一个。


执行top -Hp pid命令,pid 就是前面的 Java 进程,我这个例子中就是 16738 ,完整命令为:


top -Hp 16738,然后键入P (大写p),线程按照CPU使用率排序


执行之后的效果如下


image-20220627165953456


查到占用CPU最高的那个线程 PID 为 16756


第三步,查看堆栈信息,定位对应代码


通过printf命令将其转化成16进制,之所以需要转化为16进制,是因为堆栈里,线程id是用16进制表示的。(我当时就是忘记这个命令了~)


[root@review-dev ~]# printf "%x\n" 16756
4174

得到16进制的线程ID为4174。


通过jstack命令查看堆栈信息


jstack 16738 | grep '0x4174' -C10 --color

image-20220627170218909


如上图,找到了耗CPU高的线程对应的线程名称“HighCpu”,以及看到了该线程正在执行代码的堆栈。


最后,根据堆栈里的信息,定位到对应死循环代码,搞定。


小结


cpu使用率飙升后如何排查这个问题不仅面试中经常会问,而且在实际工作中也非常有用,大家最好根据上述步骤实际操作一下,这样才能记得住记得牢。


我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿


作者:飘渺Jam
链接:https://juejin.cn/post/7119116752939646984
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

基于环信IM iOS Demo 重构messageCell方案

本文章相关的视频教程:https://www.imgeek.org/video/108Demo下载地址:https://gitee.com/huanxin666/EMDemo-oc----------------------------------------...
继续阅读 »

本文章相关的视频教程:

https://www.imgeek.org/video/108

Demo下载地址:

https://gitee.com/huanxin666/EMDemo-oc

-----------------------------------------------------------


1.messageCell是指哪一块儿?

messageCell是用来展示消息内容的item.
界面效果:

1.单聊


2.聊天室


代码:
原版采用构造对应气泡来实现 (EaseMessageCell.m)




改造后每种消息都将会使用新的cell单元格



2.为什么要重构messageCell?

当前实现方案:控制气泡显示内容,如此不利于我们对cell进行界面显示调整,并且当前使用的cell高度为自动计算高度,如此不方便我们计算当前滚动视图的高度,无法实现下拉刷新.
总结为两点:
a.更加方便对其显示效果做定制化需求.
b.解决下拉加载更多消息会直接顶到顶部的问题.(已录制视频)

3.我们应该怎么进行重构?

当前展示数据的逻辑:
拿到一组消息
将消息转为更加便于展示在界面上的模型
将模型给到item展示出来

messageList -> messageCellModelList -> UITableView展示

我们依然采用此逻辑,仅做界面调整,以及增加计算cell高度.

我们需要做两件事:
messageCellModel进行改造
这里,我直接创建了一个viewModel继承自EaseMessageModel




内部的核心两点:
1 构建消息时,将item的cellname做下存储,用cellname来判定我们将使用哪一个cell (identifier)
2 cell的高度计算(其中包含文字/边距等所有占用高度相加)
为了统一边距等值,我们可以将这些值做下整理:





我们也可以加入展示与隐藏昵称头像功能,使展示效果更加灵活.




另补充:
在这里,还进行了部分优化,例如:原版的messageModel数组理应存所有messageModel,而不应该存字符串(这么做的原因是加入时间显示)
优化之后将不再使用字符串,也使用model来做表示.代码对比如下:




对messageCell进行重构

布局,这里使用的Masonry布局
布局需要注意:当前使用Masonry布局,但不会使用自动计算高度方式,所以不能将纵向高度全给上,只需要其中一个高度不给即可.



其他方面:
交互对接



另附:
我这边采用的布局以及继承关系
布局方面:

聊天界面所有的cell顶级父类:
EMsgBaseCell : UITableViewCell


用户消息cell的父类
EMsgUserBaseCell : EMsgBaseCell

其他展示用户消息的cell,例如展示文字:
EMsgUserTextCell : EMsgUserBaseCell

特殊cell (非用户消息展示)
展示时间,直接继承于顶级父类
EMsgTimeMarkerCell : EMsgBaseCell

展示系统提醒,直接继承于顶级父类
EMsgSystemRemindCell : EMsgBaseCell

类继承关系图:



布局方面:



项目中视图体现如⬇️⬇️两张图:




收起阅读 »

Android使用Intent传递大数据

数据传输 在Android开发过程中,我们常常通过Intent在各个组件之间传递数据。例如在使用startActivity(android.content.Intent)方法启动新的 Activity 时,我们就可以通过创建Intent对象然后调用putExt...
继续阅读 »

数据传输


在Android开发过程中,我们常常通过Intent在各个组件之间传递数据。例如在使用startActivity(android.content.Intent)方法启动新的 Activity 时,我们就可以通过创建Intent对象然后调用putExtra() 方法传输参数。


val intent = Intent(this, TestActivity::class.java)
intent.putExtra("name","name")
startActivity(intent)

启动完新的Activity之后,我们可以在新的Activity获取传输的数据。


val name = getIntent().getStringExtra("name")

一般情况下,我们传递的数据都是很小的数据,但是有时候我们想传输一个大对象,比如bitmap,就有可能出现问题。


val intent = Intent(this, TestActivity::class.java)
val data= ByteArray( 1024 * 1024)
intent.putExtra("param",data)
startActivity(intent)

当调用该方法启动新的Activity的时候就会抛出异常。


android.os.TransactionTooLargeException: data parcel size 1048920 bytes

很明显,出错的原因是我们传输的数据量太大了。在官方文档中有这样的描述:



The Binder transaction buffer has a limited fixed size, currently 1Mb, which is shared by all transactions in progress for the process. Consequently this exception can be thrown when there are many transactions in progress even when most of the individual transactions are of moderate size。



即缓冲区最大1MB,并且这是该进程中所有正在进行中的传输对象所公用的。所以我们能传输的数据大小实际上应该比1M要小。


替代方案



  1. 我们可以通过静态变量来共享数据

  2. 使用bundle.putBinder()方法完成大数据传递。
    由于我们要将数据存放在Binder里面,所以先创建一个类继承自Binder。data就是我们传递的数据对象。


class BigBinder(val data:ByteArray):Binder()

然后传递


val intent = Intent(this, TestActivity::class.java)
val data= ByteArray( 1024 * 1024)
val bundle = Bundle()
val bigData = BigBinder(data)
bundle.putBinder("bigData",bigData)
intent.putExtra("bundle",bundle)
startActivity(intent)

然后正常启动新界面,发现可以跳转过去,而且新界面也可以接收到我们传递的数据。


为什么通过这种方式就可以绕过1M的缓冲区限制呢,这是因为直接通过Intent传递的时候,系统采用的是拷贝到缓冲区的方式,而通过putBinder的方式则是利用共享内存,而共享内存的限制远远大于1M,所以不会出现异常。


作者:晚来天欲雪_
链接:https://juejin.cn/post/7109862936604049415
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

货拉拉 Android H5离线包原理与实践

背景 在实际业务中,app中的H5页面使用的场景越来越多,在货拉拉app中也存在大量的H5页面,比如金秋拉货节、余额、车型介绍页等,加载速度成为了困扰用户的一个痛点。为此我们决定引入离线包方案,另外还需要解决传统离线包方案不灵活,体积大,不易管理,不易降级...
继续阅读 »
  1. 背景




在实际业务中,app中的H5页面使用的场景越来越多,在货拉拉app中也存在大量的H5页面,比如金秋拉货节、余额、车型介绍页等,加载速度成为了困扰用户的一个痛点。为此我们决定引入离线包方案,另外还需要解决传统离线包方案不灵活,体积大,不易管理,不易降级等问题,我们设计和开发一套H5离线包系统,经过几个sdk版本的迭代,目前货拉拉H5离线包sdk,已在多个业务中落地,接受了大量用户检验。车型介绍页面使用离线包前后打开效果:






  1. 行业方案




目前H5离线包方案,通常是将离线包置入assets目录中,打包在apk内部,用户使用过程中再按需加载。所以大部分情况下可能存在以下问题:



  1. 由于离线包内容固定导致更新不及时

  2. 当离线包内容较多或者离线包个数较多时,会严重影响App包体积

  3. 由于离线包内部的逻辑固定,当出现问题时无法降级,无法禁用

  4. 上线没有数据对比无法知道上线效果


针对以上痛点,我们团队对离线包进行设计优化,应用于团队内的多个应用,多个业务场景中。




  1. 技术实现




H5离线包的基本原理是将html、js、css、图片等静态资源打包到成压缩文件,然后下载到客户端,H5加载时静态资源直接从本地取文件,减少网络请求,提高速度。加载本地文件路径存在的问题和解决:



























存在问题解决方法
cgi请求跨域跨域请求头增加null支持
cookie跨域问题目前静态js中无cookie操作,没有cookie跨域问题
localstorage跨域问题暂时不涉及域名隔离问题,如果有需要,采取调用原生的方式解决
前端使用绝对路径问题相对路径

4.1 总体结构


H5发布基本流程


image.png


App端流程图


image.png


前端的打包平台,支持发布为线上页面,也支持发布为离线包。离线包模式时,客户端会先查询是否有离线包需要更新,有则更新,同时支持离线包降级为线上网页。


H5离线包和线上H5一样也能进行更新和升级,有三个更新时机:


1)WebView容器打开时更新。在需要开启离线包功能的H5页面打开时,会去后端检查对应的离线包页面是否有更新。如果有更新,则下载离线包到本地,绝大部分场景是下次打开时生效。


2)启动查询离线包更新。对于实时性要求比较高的页面,可配置在启动时检查更新。


3)通过长连接推送的方式通知客户端下载最新的离线包。(需要接入方自己实现长链接,调用SDK更新方法)


4.2 性能优化


1)多业务并行化,单业务串行


离线包检查更新时,存在同时查询多个业务的离线包是否有更新的情况,为了提高查询效率,多个业务离线包检查的请求采取并行请求的方式。考虑到后端改造成本问题,目前还不支持聚合查询,计划在后续版本中完善。另外,考虑业务流程的更新流程取消可能导致不稳定,单业务只做串行,避免过程中文件损坏,下载不全,线程并发的问题。


image.png


2)启动预下载


大部分离线包查询和下载的时机为打开H5页面时,由于离线包查询、下载、解压总体耗时较长,导致首次打开无法命中离线包。所以货拉拉离线包支持配置部分离线包在启动时检查和下载离线包。配置为:


OfflineConfig offlineConfig = new OfflineConfig.Builder(true)

.addPreDownload("offline-pkg-name")//预加载业务名称

.build();,

4.3 可靠性设计


1)解压操作可靠性设计


文件解压耗时较长(大约30ms),如果中间程序退出可能会导致只解压了其中一半文件,影响后续离线包逻辑。所以解压到文件夹操作采取先解压,然后重命名,保证最后的文件夹的里的文件是完整的。同时当离线包正在使用时,一般情况下采取先解压,下次生效的策略,极端情况下可以立刻生效,但会导致页面强刷,影响用户体验。操作过程采取了temp、new、cur三个文件夹,解压细节如下


image.png


2)三重降级策略


a.客户端自动降级。


本地没有离线包时,客户端会自动将启用了离线包的H5页面降级为线上H5页面。


b.客户端远程配置降级。


可以设置局部降级,即临时将某个使用离线包的H5页面降级为线上,也可设置全局降级,关闭所有页面的离线包功能。接入方可以自行根据自己服务端下发参数进行配置:


OfflineConfig offlineConfig = new OfflineConfig.Builder(true)//总开关

.addDisable("disable-offline-pkg-name")//禁用业务名称

.addPreDownload("offline-pkg-name")//预加载业务名称

.build();

c.服务端接口降级。


服务端提供的离线包查询接口也可以设置将某个页面降级为线上H5,也可以支持让客户端更新离线包后强制刷新。目前,强制刷新为空实现,需要接入方自己实现,例如重启当前页面,关闭当前页面等。


降级策略流程图如下:


image.png


3)性能监控


货拉拉对webview的加载成功率,错误码、耗时进行了统计上报,通过监控面板查看。



此外离线包sdk还有离线包下载,请求,解压的耗时、结果数据上报。监控和上报采取的接口扩展方式,接入方根据业务特点选用具体的数据上报sdk。


4.4 效能优化


离线包和URL映射配置化


image.png


配置格式如下:主要通过url中的host、path、Fragment配置命中规则。根据接入方是否需要传入,不需要可以不传递。


//匹配规则相关 可选

ArrayList<String> host = new ArrayList<>();

ArrayList<String> path = new ArrayList<>();

ArrayList<String> fragment = new ArrayList<>();

host.add("www.xxxx.cn");

path.add("/aaa");

fragment.add("/ccc=ddd");



OfflineRuleConfig offlineRuleConfig = new OfflineRuleConfig();

offlineRuleConfig.addRule(new OfflineRuleConfig.RulesInfo("offline-pkg-name",host,path,fragment));


new OfflineParams()

.addRule("offline-pkg-name",host,path,fragment)//自定义配置的形式

.setRule(Constants.RULE_CONFIG)//json形式的规则

.setRule(offlineRuleConfig)//实体类形式

{
"rules": [{
"host": ["test1.xxx.cn", "test2.xxx.cn"],
"path": ["/pathA"],
"offweb": "offline-pkg-name-a"
},
{
"host": ["www.aaa.cn", "aaa.xxxx.cn"],
"path": ["aaa/path", "bbb/path"],
"offweb": "offline-pkg-name-b"
}
]
}



  1. 总结




离线包上线后,收益明显,平均加载速度从2秒提升到1秒,同时H5页面加载成功率也有提升。页面主框架(不考虑动态数据)加载成功率从96%提升到100%。






  1. 后期工作与展望




扩大开源范围。比如支持断点续传的下载SDK,后续会考虑开源。离线包依赖的后端服务暂时未开源,目前采取是通过HttpServer搭建一个简单的本地Web Server,可保证离线包示例在本地正常运行。


具体使用方法参考开源代码中介绍(github.com/HuolalaTech…




  1. 参考资料




zhuanlan.zhihu.com/p/34125968


juejin.cn/post/684490…




  1. 作者介绍




货拉拉移动端技术团队


作者:货拉拉移动技术团队
链接:https://juejin.cn/post/7119662876578545678
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter 绘制探索 | 来一起画箭头吧

0. 前言 可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 U...
继续阅读 »
0. 前言

可能有人会觉得,画箭头有什么好说的,不就一根线加两个头吗?其实箭头的绘制还是比较复杂的,其中也蕴含着很多绘制的小技巧。箭头本身有着很强的 示意功能 ,通常用于指示、标注、连接。各种不同的箭头端,再加上线型的不同,可以组合成一些固定连接语法,比如 UML 中的类图。





一个箭头,其核心数据是两个点的坐标,由 左右端点线型 构成。这篇文章就来探索一下,如何绘制一个支持各种样式,而且容易拓展的箭头。





1. 箭头部位的划分

首先要说一点,我希望获取的是箭头的 路径 ,而非单纯的绘制箭头。因为有了路径,可以做更多的事,比如根据路径裁剪、沿路径运动、多个路径间的合并操作等。当然,路径形成之后,绘制自然是非常简单的。所以在绘制技巧中,路径一个非常重要的话题。

如下所示,我们先来生成三个部分的路径,并进行绘制,两端暂时是圆形路径:



代码实现如下,测试使用的起始点分别是 (40,40)(200,40),圆形路径以起始点为中心,宽高为 10。可以看出虽然实现了需求,但是都写在一块,代码看起来比较乱。当要涉及生成各种样式箭头时,在这里修改代码也是非常麻烦的,接下来要做的就是对箭头的路径形成过程进行抽象。


final Paint arrowPainter = Paint();

Offset p0 = Offset(40, 40);
Offset p1 = Offset(200, 40);
double width = 10;
double height = 10;

Rect startZone = Rect.fromCenter(center: p0, width: width, height: height);
Path startPath = Path()..addOval(startZone);
Rect endZone = Rect.fromCenter(center: p1, width: width, height: height);
Path endPath = Path()..addOval(endZone);

Path linePath = Path()..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy);

arrowPainter
..style = PaintingStyle.stroke..strokeWidth = 1
..color = Colors.red;

canvas.drawPath(startPath, arrowPainter);
canvas.drawPath(endPath, arrowPainter);
canvas.drawPath(linePath, arrowPainter);



如下,定义抽象类 AbstractPathformPath 抽象出来,交由子类实现。端点的路径衍生出 PortPath 进行实现,这就可以将一些重复的逻辑进行封装,也有利于维护和拓展。整体路径的生成由 ArrowPath 类负责:


abstract class AbstractPath{
Path formPath();
}

class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
path.addOval(zone);
return path;
}
}

class ArrowPath extends AbstractPath{
final PortPath head;
final PortPath tail;

ArrowPath({required this.head,required this.tail});

@override
Path formPath() {
Offset line = (tail.position - head.position);
Offset center = head.position+line/2;
double length = line.distance;
Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
Path linePath = Path()..addRect(lineZone);
Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
return Path.combine(PathOperation.union, temp, tail.formPath());
}
}



这样,矩形域的确定和路径的生成,交由具体的类进行实现,在使用时就会方便很多:


double width =10;
double height =10;
Size portSize = Size(width, height);
ArrowPath arrow = ArrowPath(
head: PortPath(p0, portSize),
tail: PortPath(p1, portSize),
);
canvas.drawPath(arrow.formPath(), arrowPainter);




2. 关于路径的变换

上面我们的直线其实是矩形路径,这样就会出现一些问题,比如当箭头不是水平线,会出现如下问题:



解决方案也很简单,只要让矩形直线的路径沿两点的中心进行旋转即可,旋转的角度就是两点与水平线的夹角。这就涉及了绘制中非常重要的技巧:矩阵变换 。如下代码添加的四行 Matrix4 的操作,就可以通过矩阵变换,让 linePathcenter 为中心旋转两点间角度。这里注意一下,tag1 处的平移是为了将变换中心变为 center、而tag2 处的反向平移是为了抵消 tag1 平移的影响。这样在两者之间的变换,就是以 center 为中心的变换:


class ArrowPath extends AbstractPath{
final PortPath head;
final PortPath tail;

ArrowPath({required this.head,required this.tail});

@override
Path formPath() {
Offset line = (tail.position - head.position);
Offset center = head.position+line/2;
double length = line.distance;
Rect lineZone = Rect.fromCenter(center:center,width:length,height:2);
Path linePath = Path()..addRect(lineZone);

// 通过矩阵变换,让 linePath 以 center 为中心旋转 两点间角度
Matrix4 lineM4 = Matrix4.translationValues(center.dx, center.dy, 0); // tag1
lineM4.multiply(Matrix4.rotationZ(line.direction));
lineM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0)); // tag2
linePath = linePath.transform(lineM4.storage);

Path temp = Path.combine(PathOperation.union, linePath, head.formPath());
return Path.combine(PathOperation.union, temp, tail.formPath());
}
}

这样就一切正常了,可能有人会疑惑,为什么不直接用两点形成路径呢?这样就不需要旋转了:



前面说了,这里希望获得的是一个 箭头路径 ,使用线型模式就可以看处用矩形的妙处。如果单纯用路径的移动来处理,需要计算点位,比较复杂。而用矩形加旋转,就方便很多:





3.尺寸的矫正

可以看出,目前是以起止点为圆心的矩形区域,但实际我们需要让箭头的两端顶点在两点上。有两种解决方案:其一,在 PortPath 生成路径时,对矩形区域中心进行校正;其二,在合成路径前通过偏移对首位断点进行校正。



我更倾向于后者,因为我希望 PortPath 只负责断点路径的生成,不需要管其他的事。另外 PortPath 本身也不知道端点是起点还是终点,因为起点需要沿线的方向偏移,终点需要沿反方向偏移。处理后效果如下:



---->[ArrowPath#formPath]----
Path headPath = head.formPath();
Matrix4 headM4 = Matrix4.translationValues(head.size.width/2, 0, 0);
headPath = headPath.transform(headM4.storage);

Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-head.size.width/2, 0, 0);
tailPath = tailPath.transform(tailM4.storage);



虽然表面上看起来和顶点对齐了,但换个不水平的线就会看出端倪。我们需要 沿线的方向 进行平移,也就是说,要保证该直线过矩形区域圆心:



如下所示,我们在对断点进行平移时,需要根据线的角度来计算偏移量:



 Path headPath = head.formPath();
double fixDx = head.size.width/2*cos(line.direction);
double fixDy = head.size.height/2*sin(line.direction);

Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
headPath = headPath.transform(headM4.storage);
Path tailPath = tail.formPath();
Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
tailPath = tailPath.transform(tailM4.storage);



4.箭头的绘制

每个 PortPath 都有一个矩形区域,接下来只要专注于在该区域内绘制箭头即可。比如下面的 p0p1p2 可以形成一个三角形:



对应代码如下:


class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}

由于在 PortPath 中无法感知到子级是头还是尾,所以下面可以看出两个箭头都是向左的。处理方式也很简单,只要转转 180° 就行了。





另外,这样虽然看起来挺好,但也有和上面类似的问题,当改变坐标时,就会出现不和谐的情景。解决方案和前面一样,为断点的箭头根据线的倾角添加旋转变换即可。





如下进行旋转,即可得到期望的箭头,tag3 处可以顺便旋转 180° 把尾点调正。这样任意指定两点的坐标,就可以得到一个箭头。



Matrix4 headM4 = Matrix4.translationValues(fixDx, fixDy, 0);
center = head.position;
headM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
headM4.multiply(Matrix4.rotationZ(line.direction));
headM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
headPath = headPath.transform(headM4.storage);

Matrix4 tailM4 = Matrix4.translationValues(-fixDx, -fixDy, 0);
center = tail.position;
tailM4.multiply(Matrix4.translationValues(center.dx, center.dy, 0));
tailM4.multiply(Matrix4.rotationZ(line.direction-pi)); // tag3
tailM4.multiply(Matrix4.translationValues(-center.dx, -center.dy, 0));
tailPath = tailPath.transform(tailM4.storage);



5.箭头的拓展

从上面可以看出,这个箭头断点的拓展能力是很强的,只要在矩形区域内形成相应的路径即可。比如下面带两个尖角的箭头形式,路径生成代码如下:



class PortPath extends AbstractPath{
final Offset position;
final Size size;

PortPath(this.position, this.size);

@override
Path formPath() {
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
return pathBuilder(zone);
}

Path pathBuilder(Rect zone){
Path path = Path();
Rect zone = Rect.fromCenter(center: position, width: size.width, height: size.height);
final double rate = 0.8;
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
Offset p3 = p0.translate(rate*zone.width, 0);
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)
..lineTo(p3.dx, p3.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}

这样如下所示,只要更改 pathBuilder 中的路径构建逻辑,就可以得到不同的箭头样式。而且你只需要在矩形区域创建正着的路径即可,箭头跟随直线的旋转已经被封装在了 ArrowPath 中。这就是 屏蔽细节 ,简化使用流程。不然创建路径时还有进行角度偏转计算,岂不麻烦死了。





到这里,多样式的箭头设置方案应该就呼之欲出了。就像是 Flutter 动画中的各种 Curve 一样,通过抽象进行衍生,实现不同类型的数值转变。这里我们也可以对路径构建的行为进行抽象,来衍生出各种路径类。这样的好处在于:在实现类中,可以定义额外的参数,对绘制的细节进行控制。

如下,抽象出 PortPathBuilder ,通过 fromPathByRect 方法,根据矩形区域生成路径。在 PortPath 中就可以依赖 抽象 来完成任务:


abstract class PortPathBuilder{
const PortPathBuilder();
Path fromPathByRect(Rect zone);
}

class PortPath extends AbstractPath {
final Offset position;
final Size size;
PortPathBuilder portPath;

PortPath(
this.position,
this.size, {
this.portPath = const CustomPortPath(),
});

@override
Path formPath() {
Rect zone = Rect.fromCenter(
center: position, width: size.width, height: size.height);
return portPath.fromPathByRect(zone);
}
}



在使用时,可以通过指定 PortPathBuilder 的实现类,来配置不同的端点样式,比如实现一开始那个常规的 CustomPortPath :


class CustomPortPath extends PortPathBuilder{
const CustomPortPath();

@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
path..moveTo(p0.dx, p0.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
return path;
}
}



以及三个箭头的 ThreeAnglePortPath ,我们可以将 rate 提取出来,作为构造入参,这样就可以让箭头拥有更多的特性,比如下面是 0.50.8 的对比:



class ThreeAnglePortPath extends PortPathBuilder{
final double rate;

ThreeAnglePortPath({this.rate = 0.8});

@override
Path fromPathByRect(Rect zone) {
Path path = Path();
Offset p0 = zone.centerLeft;
Offset p1 = zone.bottomRight;
Offset p2 = zone.topRight;
Offset p3 = p0.translate(rate * zone.width, 0);
path
..moveTo(p0.dx, p0.dy)
..lineTo(p1.dx, p1.dy)
..lineTo(p3.dx, p3.dy)
..lineTo(p2.dx, p2.dy)
..close();
return path;
}
}



想要实现箭头不同的端点类型,只有在构造 PortPath 时,指定对应的 portPath 即可。如下红色箭头的两端分别使用 ThreeAnglePortPathCirclePortPath



ArrowPath arrow = ArrowPath(
head: PortPath(
p0.translate(40, 0),
const Size(10, 10),
portPath: const ThreeAnglePortPath(rate: 0.8),
),
tail: PortPath(
p1.translate(40, 0),
const Size(8, 8),
portPath: const CirclePortPath(),
),
);

这样一个使用者可以自由拓展的箭头绘制小体系就已经能够完美运转了。大家可以基于此体会一下其中 抽象 的意义,以及 多态 的体现。本篇中有很多旋转变换的绘制小技巧,下一篇,我们来一起绘制各种各样的 PortPathBuilder 实现类,以此丰富箭头绘制,打造一个小巧但强大的箭头绘制库。


作者:张风捷特烈
链接:https://juejin.cn/post/7120010916602576926
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

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

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

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

写在前面

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


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

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

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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

凑单核心处理流程设计

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

  2. 支持提前阻断

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

处理类类图如下


【ChainBaseHandler】:核心处理类

【CartHandler】:加购域处理类

【ItemSupplementHandler】:商品域处理类

【RankingHandler】:榜单域处理类

【RequestHanlder】:参数域处理类

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

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

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

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

具体执行如下:

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

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

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

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


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

public class MuchContext<D extends ContextData> implements Context {

  protected List<D> data;

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

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

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

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

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

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

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


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

  1. SingleCommand减少重复循环代码

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

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

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

收起阅读 »

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

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

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

下方是一个使用例子:

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

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

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


多算法分流设计

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


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


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

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

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

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

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

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

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



取巧的功能设计

凑单购物车部分

  • 设计的背景

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

  • 基本框架结构设计

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


  • 分页排序设计

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

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

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

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

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

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

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

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

难点分析

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

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

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

  4. 支持分页

技术方案

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

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

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

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

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

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

具体方案

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

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

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

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

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

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

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

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

方案时序图如下:


  • 商品勾选设计

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

  1. 勾选、反勾选、全选

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

  3. 勾选的商品数量变化

效果图如下:


难点

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

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

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

设计方案

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

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

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

整体逻辑如下:


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

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

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

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

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

营销商品引擎key设计

  • 设计的背景

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


  • 详细索引设计

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

字段示例:mkt_fn_t_60_08200000_60

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

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



最后的总结

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

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

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

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

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

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

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

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

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

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

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

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

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

收起阅读 »

通过拦截 Activity的创建 实现APP的隐私政策改造

序言 最近因为政策收紧,现在要求APP必须在用户同意的情况下才能获取隐私信息。但是很多隐私信息的获取是第三方SDK获取的。而SDK的初始化一般都在application中。由于维护的项目多,如果贸然改动很有可能造成潜在的问题。所以想研究一个低侵入性的方案。在不...
继续阅读 »

序言


最近因为政策收紧,现在要求APP必须在用户同意的情况下才能获取隐私信息。但是很多隐私信息的获取是第三方SDK获取的。而SDK的初始化一般都在application中。由于维护的项目多,如果贸然改动很有可能造成潜在的问题。所以想研究一个低侵入性的方案。在不影响原有APP流程的基础上完成隐私改造。


方案


研究了几个方案,简单的说一下


方案1


通过给APP在设置一个入口,将原有入口的activity的enable设置为false。让客户端先进入到隐私确认界面
。确认完成,再用代码使这个activity的enable设置为false。将原来的入口设置为true。
需要的技术来自这篇文章
(技术)Android修改桌面图标


效果


这种方案基本能满足要求。但是存在两个问题。



  1. 将activity设置为false的时候会让应用崩溃。上一篇文章提到使用别名的方案也不行。

  2. 修改了activity以后,Android Studio启动的时候无法找到在清单文件中声明的activity。


方案2


直接Hook Activity的创建过程,如果用户没有通过协议,就将activity 变为我们的询问界面。
参考文献:
Android Hook Activity 的几种姿势


Android应用进程的创建 — Activity的启动流程


需要注意的是,我们只需要Hook ActivityThread 的mInstrumentation 即可。需要hook的方法是newActivity方法。


public class ApplicationInstrumentation extends Instrumentation {

private static final String TAG = "ApplicationInstrumentation";

// ActivityThread中原始的对象, 保存起来
Instrumentation mBase;

public ApplicationInstrumentation(Instrumentation base) {
mBase = base;
}

public Activity newActivity(ClassLoader cl, String className,
Intent intent)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
className = CheckApp.getApp().getActivityName(className);
return mBase.newActivity(cl, className, intent);
}
}

使用


最终使用了方案2。通过一个CheckApp类来实现管理。
使用很简单,将你的Application类继承自CheckApp 将sdk的初始化放置到 initSDK方法中
为了避免出错,在CheckApp中我已经将onCreate设置为final了


public class MyApp extends CheckApp {


public DatabaseHelper dbHelper;
protected void initSDK() {
RxJava1Util.setErrorNotImplementedHandler();
mInstance = this;
initUtils();
}

private void initUtils() {
}
}

在清单文件中只需要注册你需要让用户确认隐私协议的activity。


<application>
...
<meta-data
android:name="com.trs.library.check.activity"
android:value=".activity.splash.GuideActivity" />

</application>

如果要在应用每次升级以后都判断用户协议,只需要覆盖CheckApp中的这个方法。(默认开启该功能)


/**
* 是否每个版本都检查是否拥有用户隐私权限
* @return
*/
protected boolean checkForEachVersion() {
return true;
}

判断用户是否同意用这个方法


CheckApp.getApp().isUserAgree();

用户同意以后的回调,第二个false表示不自动跳转到被拦截的Activity


    /**
* 第二个false表示不自动跳转到被拦截的Activity
* CheckApp 记录了被拦截的Activity的类名。
*/
CheckApp.getApp().agree(this,false,getIntent().getExtras());

源码


一共只有3个类
在这里插入图片描述


ApplicationInstrumentation


import android.app.Activity;
import android.app.Instrumentation;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;

import java.lang.reflect.Method;

/**
* Created by zhuguohui
* Date: 2021/7/30
* Time: 13:46
* Desc:
*/
public class ApplicationInstrumentation extends Instrumentation {

private static final String TAG = "ApplicationInstrumentation";

// ActivityThread中原始的对象, 保存起来
Instrumentation mBase;

public ApplicationInstrumentation(Instrumentation base) {
mBase = base;
}

public Activity newActivity(ClassLoader cl, String className,
Intent intent)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
className = CheckApp.getApp().getActivityName(className);
return mBase.newActivity(cl, className, intent);
}


}

CheckApp




import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.support.multidex.MultiDexApplication;

import com.trs.library.util.SpUtil;

import java.util.List;

/**
* Created by zhuguohui
* Date: 2021/7/30
* Time: 10:01
* Desc:检查用户是否给与权限的application
*/
public abstract class CheckApp extends MultiDexApplication {

/**
* 用户是否同意隐私协议
*/
private static final String KEY_USER_AGREE = CheckApp.class.getName() + "_key_user_agree";
private static final String KEY_CHECK_ACTIVITY = "com.trs.library.check.activity";

private boolean userAgree;

private static CheckApp app;


@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
userAgree = SpUtil.getBoolean(this, getUserAgreeKey(base), false);
getCheckActivityName(base);
if (!userAgree) {
//只有在用户不同意的情况下才hook ,避免性能损失
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
}
}


protected String getUserAgreeKey(Context base) {
if (checkForEachVersion()) {
try {
long longVersionCode = base.getPackageManager().getPackageInfo(base.getPackageName(), 0).versionCode;
return KEY_USER_AGREE + "_version_" + longVersionCode;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
return KEY_USER_AGREE;

}

/**
* 是否每个版本都检查是否拥有用户隐私权限
* @return
*/
protected boolean checkForEachVersion() {
return true;
}

private static boolean initSDK = false;//是否已经初始化了SDK

String checkActivityName = null;

private void getCheckActivityName(Context base) {
mPackageManager = base.getPackageManager();
try {
ApplicationInfo appInfo = mPackageManager.getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
checkActivityName = appInfo.metaData.getString(KEY_CHECK_ACTIVITY);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
checkActivityName = checkName(checkActivityName);

}

public String getActivityName(String name) {
if (isUserAgree()) {
return name;
} else {
setRealFirstActivityName(name);
return checkActivityName;
}
}

private String checkName(String name) {
String newName = name;
if (!newName.startsWith(".")) {
newName = "." + newName;
}
if (!name.startsWith(getPackageName())) {
newName = getPackageName() + newName;
}

return newName;

}


@Override
public final void onCreate() {
super.onCreate();
if (!isRunOnMainProcess()) {
return;
}
app = this;
initSafeSDK();

//初始化那些和隐私无关的SDK
if (userAgree && !initSDK) {
initSDK = true;
initSDK();
}

}


public static CheckApp getApp() {
return app;
}


/**
* 初始化那些和用户隐私无关的SDK
* 如果无法区分,建议只使用initSDK一个方法
*/
protected void initSafeSDK() {

}


/**
* 判断用户是否同意
*
* @return
*/
public boolean isUserAgree() {
return userAgree;
}


static PackageManager mPackageManager;


private static String realFirstActivityName = null;

public static void setRealFirstActivityName(String realFirstActivityName) {
CheckApp.realFirstActivityName = realFirstActivityName;
}

public void agree(Activity activity, boolean gotoFirstActivity, Bundle extras) {

SpUtil.putBoolean(this, getUserAgreeKey(this), true);
userAgree = true;

if (!initSDK) {
initSDK = true;
initSDK();
}

//启动真正的启动页
if (!gotoFirstActivity) {
//已经是同一个界面了,不需要自动打开
return;
}
try {
Intent intent = new Intent(activity, Class.forName(realFirstActivityName));
if (extras != null) {
intent.putExtras(extras);//也许是从网页中调起app,这时候extras中含有打开特定新闻的参数。需要传递给真正的启动页
}
activity.startActivity(intent);
activity.finish();//关闭当前页面
} catch (ClassNotFoundException e) {
e.printStackTrace();
}

}


/**
* 子类重写用于初始化SDK等相关工作
*/
abstract protected void initSDK();

/**
* 判断是否在主进程中,一些SDK中的PushServer可能运行在其他进程中。
* 也就会造成Application初始化两次,而只有在主进程中才需要初始化。
* * @return
*/
public boolean isRunOnMainProcess() {
ActivityManager am = ((ActivityManager) this.getSystemService(Context.ACTIVITY_SERVICE));
List<ActivityManager.RunningAppProcessInfo> processInfos = am.getRunningAppProcesses();
String mainProcessName = this.getPackageName();
int myPid = android.os.Process.myPid();
for (ActivityManager.RunningAppProcessInfo info : processInfos) {
if (info.pid == myPid && mainProcessName.equals(info.processName)) {
return true;
}
}
return false;
}
}

HookUtil



import android.app.Instrumentation;
import android.util.Log;

import java.lang.reflect.Field;
import java.lang.reflect.Method;

/**
* Created by zhuguohui
* Date: 2021/7/30
* Time: 13:20
* Desc:
*/
public class HookUtil {



public static void attachContext() throws Exception {
Log.i("zzz", "attachContext: ");
// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//currentActivityThread是一个static函数所以可以直接invoke,不需要带实例参数
Object currentActivityThread = currentActivityThreadMethod.invoke(null);

// 拿到原始的 mInstrumentation字段
Field mInstrumentationField = activityThreadClass.getDeclaredField("mInstrumentation");
mInstrumentationField.setAccessible(true);
Instrumentation mInstrumentation = (Instrumentation) mInstrumentationField.get(currentActivityThread);
// 创建代理对象
Instrumentation evilInstrumentation = new ApplicationInstrumentation(mInstrumentation);
// 偷梁换柱
mInstrumentationField.set(currentActivityThread, evilInstrumentation);
}


}

作者:solo_99
链接:https://juejin.cn/post/6990643611130363917
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

给灭霸点颜色看看

前言 继续我们 Flutter 绘图相关的介绍,本篇我们引入一位重量级主角 —— 灭霸。通过绘图的颜色过滤器,我们要给灭霸点颜色看看。通过本篇,你会了解到如下内容: ColorFilter 颜色过滤器的介绍; 彩色图片转换为灰度图; 通过矩阵运算构建自定义的...
继续阅读 »

前言


继续我们 Flutter 绘图相关的介绍,本篇我们引入一位重量级主角 —— 灭霸。通过绘图的颜色过滤器,我们要给灭霸点颜色看看。通过本篇,你会了解到如下内容:



  • ColorFilter 颜色过滤器的介绍;

  • 彩色图片转换为灰度图;

  • 通过矩阵运算构建自定义的颜色过滤器。


ColorFilter 颜色过滤器


其实我们之前在给小姐姐的照片调个颜色滤镜有介绍过颜色滤镜,在 Flutter 中提供了一个 ColorFiltered 的组件,可以将颜色过滤器应用到其子组件上。实际上,颜色过滤器就是对一个图层的每个像素的颜色(包括透明度)进行数学运算,改变像素的颜色来实现特定的效果。数学公式如下:


颜色变换矩阵


在 Flutter 中,ColorFilter 类的继承自 ImageFilter,像 ImageFilter 一样,也只提供了命名构造函数,一共有四个命名构造函数,分别如下:



  • ColorFilter.mode(Color color, BlendMode mode):按制定的混合模式(blend mode),将颜色混入到绘制的目标中。可以理解为图像的色值调整,我们可以用一个指定的颜色调整原图,调整的模式有很多种,具体可以查看 BlendMode 枚举。

  • ColorFilter.linearToSrgbGamma():将一个 SRGB 的 gamma 曲线应用到 RGB 颜色通道中。

  • ColorFilter.srgbToLinearGamma()ColorFilter.linearToSrgbGamma()的反向过程。

  • ColorFilter.matrix(List<double> matrix):应用一个矩阵做颜色变换,也就是我们上面说的矩阵,这是最通用的版本,要什么效果可以自己构建对应的矩阵。


这里说一下 SRGB 的 gamma 曲线的用途。我们人眼在显示屏中对图片进行调色等操作时,是按照线性空间的角度进行的,但显示器是在gamma空间中的,那么图像在计算机中的存储一般都应该是在 gamma 空间下了。也就是计算机存储的是非线性的,但是给我们展示的时候要转为线性的。因此,对于一张图像,可能是线性的也可能是 gamma 空间的,这个时候为了统一可能就需要进行转换,那就会用到linearToSrgbGammasrgbToLinearGamma两个颜色过滤器。


彩色图片转成灰色图片


彩色图片转变为灰色图片有很2种方法,最简单的方法是使用ColorFilter.mode,第一个参数颜色选择灰色或黑色,然后 第二个参数选择 BlendMode.color 或者接近的效果(比如 huesaturation)。BlendMode.color 是取源图的色调和饱和度,然后取目标(即要改变的图片)的亮度。因此,如果我们想更改一张图片的色调,用这种方式最好了。下面是对应的实现代码和变换前后的对比图。


var paint = Paint();
paint.colorFilter = ColorFilter.mode(Colors.grey, BlendMode.color);

canvas.drawImageRect(
bgImage,
Rect.fromLTRB(0, 0, bgImage.width.toDouble(), bgImage.height.toDouble()),
Offset.zero & size,
paint,
);

灰度图.jpg
使用ColorFilter.mode另一个用途就是简单的“修图”了,比如我们可以将一张蓝天白云图修成夕阳西下的效果。


夕阳效果.jpg


当然,转换为灰度图我们也可以通过矩阵实现。


矩阵运算改变颜色


如果要想任意调换颜色,那么使用矩阵运算更合适。在 Flutter 中,ColorFilter.matrix 多增加了一行,这一行主要是在构建一些特殊的矩阵运算更方便,比如反转色的时候。


Matrix 构建公式


比如我们要让变换后的图像实现反转:



  • 红色色值=255-原红色色值

  • 绿色色值=255-原绿色色值

  • 蓝色色值=255-原蓝色色值


那么构建如下矩阵就可以了。


反转色变换矩阵


由于最后一行数值对实际变化没影响,因此实际构建 ColorFilter.matrix 的时候,只需要传入20个参数就可以了。下面是应用了反转效果后的灭霸图,灭霸看起来像一个雕塑了。



下面我们先来看一下使用矩阵实现彩色图变灰度图,用下面的矩阵就能实现,最终得到变换后的 R、G、B值是相等的,而且三个色值的系数相加等于1(保证数值不会超出255)。这个矩阵是官方提供的,实际上也是经过图像学研究推导得到的。


灰度变换公式


对应灰度变换的 ColorFilter 的构造代码如下:


const greyScale = ColorFilter.matrix(<double>[
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0.2126, 0.7152, 0.0722, 0, 0,
0, 0, 0, 1, 0,
]);

最后,我们来看看颜色循环变换的效果,颜色循环变换就是红色部分变为原先像素的绿色值,绿色部分变到原先像素的蓝色值,然后蓝色部分变到原先像素的红色值,对应的 ColorFilter 构造代码如下:


var colorRotation = ColorFilter.matrix(<double>[
0, 1, 0, 0, 0,
0, 0, 1, 0, 0,
1, 0, 0, 0, 0,
0, 0, 0, 1, 0
]);

有了这个我们其实就可以做一些动效了,比如我们把变化过程由动画值控制,得到下面的矩阵。


var colorRotation = ColorFilter.matrix(<double>[
animationValue, 1-animationValue, 0, 0, 0,
0, animationValue, 1-animationValue, 0, 0,
1-animationValue, 0, animationValue, 0, 0,
0, 0, 0, 1, 0
]);

我们看看灭霸图片颜色变化的动画效果,整个画面的色调在不断的变化,感觉像灭霸要开始“打响指”了。


颜色变化动画.gif


ColorFilter 的应用


ColorFilter 的最佳应用场景应该是图片滤镜,我们在图片类应用经常会看到各种滤镜效果(取得名字都很好听,比如什么“清纯”、“蓝调”,“怀旧”等等),实际上这种效果就是将一个颜色预置的变换矩阵应用到图片上。


总结


本篇介绍了颜色过滤器 ColorFilter 的应用以及原理,我们绘图的时候可以使用 ColorFilter 处理图片,实现类似滤镜的效果。如果考虑简单使用,也可以直接使用 ColorFiltered 组件。




本篇源码已上传至:绘图相关源码,文件名为:color_filter_demo.dart


作者:岛上码农
链接:https://juejin.cn/post/7118320708786061325
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android抓包从未如此简单


·  阅读 407

一、情景再现:

有一天你正在王者团战里杀的热火朝天,忽然公司测试人员打你电话问为什么某个功能数据展示不出来了,昨天还好好的纳,是不是你又偷偷写bug了。。。WTF!,你会说你把手机给我,我连上电脑看看打印的请求日志是不是接口有问题,然后吭哧吭哧搞半天看到接口数据返回的格式确实不会,然后去群里丢了几句服务端人员看一下这个接口,数据有问题。然后有回去打游戏,可惜游戏早已结束,以失败告终,自己还没无情的举报禁赛了。。。人生最痛苦的事莫过于如此。假如你的项目已经继承了抓包助手,并且也给其他人员较少过如何使用,那么像这类问题根本就不需要你再来处理了,遇到数据问题他们第一时间会自己看请求数据,而你就可以安心上王者了。

二、Android抓包现状

目前常见的抓包工具有Charles、Fiddler、Wireshark等,这些或多或少都需要一些配置,略显麻烦,只适合开发及测试人员玩,如果产品也想看数据怎么办纳,别急,本文的主角登场了,你可以在项目中集成AndroidMonitor,只需两步简单配置即可实现抓包数据可视化功能,随时随地,人人都可以方便快捷的查看。

三、效果展示

俗话说无图无真相

111.jpg

222.jpg

333.jpg

抓包pc.png

四、如何使用

抓包工具有两个依赖需要添加:monito和monitor-plugin

Demo下载体验

1、monitor接入

添加依赖

   debugImplementation 'io.github.lygttpod:monitor:0.0.4'
复制代码

-备注: 使用debugImplementation是为了只在测试环境中引入

2、monitor-plugin接入

  1. 根目录build.gradle下添加如下依赖
    buildscript {
dependencies {
......
//monitor-plugin需要
classpath 'io.github.lygttpod:monitor-plugin:0.0.1'
}
}

复制代码

2.添加插件

    在APP的build.gradle中添加:

//插件内部会自动判断debug模式下hook到okhttp
apply plugin: 'monitor-plugin'

复制代码

原则上完成以上两步你的APP就成功集成了抓包工具,很简单有没有,如需定制化服务请看下边的个性化配置

3、 个性化配置

1、修改桌面抓包工具入口名字:在主项目string.xml中添加 monitor_app_name即可,例如:
```
<string name="monitor_app_name">XXX-抓包</string>
```
2、定制抓包入口logo图标:
```
添加 monitor_logo.png 即可
```
3、单个项目使用的话,添加依赖后可直接使用,无需初始化,库里会通过ContentProvider方式自动初始化

默认端口8080(端口号要唯一)

4、多个项目都集成抓包工具,需要对不同项目设置不同的端口和数据库名字,用来做区分

在主项目assets目录下新建 monitor.properties 文件,文件内如如下:对需要变更的参数修改即可
```
# 抓包助手参数配置
# Default port = 8080
# Default dbName = monitor_db
# ContentTypes白名单,默认application/json,application/xml,text/html,text/plain,text/xml
# Default whiteContentTypes = application/json,application/xml,text/html,text/plain,text/xml
# Host白名单,默认全部是白名单
# Default whiteHosts =
# Host黑名单,默认没有黑名单
# Default blackHosts =
# 如何多个项目都集成抓包工具,可以设置不同的端口进行访问
monitor.port=8080
monitor.dbName=app_name_monitor_db
```
复制代码

4、 proguard(默认已经添加混淆,如遇到问题可以添加如下混淆代码)

```
# monitor
-keep class com.lygttpod.monitor.** { *; }
```
复制代码

5、 温馨提示

    虽然monitor-plugin只会在debug环境hook代码,
但是release版编译的时候还是会走一遍Transform操作(空操作),
为了保险起见建议生产包禁掉此插件。

在jenkins打包机器的《生产环境》的local.properties中添加monitor.enablePlugin=false,全面禁用monitor插件
复制代码

6、如何使用

  • 集成之后编译运行项目即可在手机上自动生成一个抓包入口的图标,点击即可打开可视化页面查看网络请求数据,这样就可以随时随地的查看我们的请求数据了。
  • 虽然可以很方便的查看请求数据了但是手机屏幕太小,看起来不方便怎么办呐,那就去寻找在PC上展示的方法,首先想到的是能不能直接在浏览器里边直接看呐,这样不用安装任何程序在浏览输入一个地址就可以直接查看数据
  • PC和手机在同一局域网的前提下:直接在任意浏览器输入 手机ip地址+抓包工具设置的端口号即可(地址可以在抓包app首页TitleBar上可以看到)

7、关键原理说明

  • 拦截APP的OKHTTP请求(添加拦截器处理抓包请求,使用ASM字节码插装技术实现)
  • 数据保存到本地数据库(room)
  • APP本地开启一个socket服务AndroidLocalService
  • 与本地socket服务通信
  • UI展示数据(手机端和PC端)

浏览器检测之趣事

web
1 那段历史在开发过程中,我们通常用用户代理字符串—浏览器端 window.navigator.userAgent或者服务器端header携带的user-agent —来用于检测当前浏览器是否为移动端, 比如:if(isMobile()) { // 移动端逻...
继续阅读 »

1 那段历史

在开发过程中,我们通常用用户代理字符串—浏览器端 window.navigator.userAgent或者服务器端header携带的user-agent —来用于检测当前浏览器是否为移动端, 比如:

if(isMobile()) {
// 移动端逻辑...
}

function isMobile () {
  const versions = (function () {
      const u = window.navigator.userAgent // 服务器端:req.header('user-agent')
      return {
        trident: u.indexOf('Trident') > -1, // IE内核
        presto: u.indexOf('Presto') > -1, // opera内核
        webKit: u.indexOf('AppleWebKit') > -1, // 苹果、谷歌内核
        gecko: u.indexOf('Gecko') > -1 && u.indexOf('KHTML') === -1, // 火狐内核
        mobile: !!u.match(/AppleWebKit.*Mobile.*/), // 是否为移动终端
        ios: !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/), // ios终端
        android: u.indexOf('Android') > -1 || u.indexOf('Linux') > -1, // android终端或者uc浏览器
        iPhone: u.indexOf('iPhone') > -1, // 是否为iPhone或者QQHD浏览器
        iPad: u.indexOf('iPad') > -1, // 是否iPad
        webApp: u.indexOf('Safari') === -1
      }
  }())
  return versions.mobile || versions.ios || versions.android || versions.iPhone || versions.iPad
}

我在使用时心里一直有疑问,一个移动端,为什么要做那么多判断呢?

目前我的 Chrome 浏览器:


看到这么一长串字符串,我表示更懵逼, Mozilla不是firefox的厂商么?这是 Chrome 浏览器,又怎么会有 “Safari” 的关键字?那个 “like Gecko” 又是什么鬼?

于是抱着这些疑问, 我打算好好深入了解一下浏览器检测这部分,没想到在学习过程中发现了挺有意思的事情,待我慢慢道来,大家也听个乐呵。

首先始于客户端与服务器端通信,要求携带名称与版本信息,于是服务器端与客户端协定好在每个HTTP请求的头部加上用户代理字符串(userAgent),方便服务器端进行检测,检测通过之后再进行后续操作。

早期的用户代理字符串(userAgent)很简单, 就 "产品名称/产品版本号",比如:"Mosaic/0.9"。93年之后,网景公司发布的Netscape Navigator 系列浏览器渐渐成为了当时最受欢迎的浏览器,于是它拥有了规则制定权,说从此以后我的用户代理字符串就为:


这时肯定有人会问,"Mozilla" 是网景公司为 Netscape 浏览器定义的代号,既然站在“食物链”顶端,那当然得用自己的命名,这能理解。可为啥直到现在,大部分主流浏览器的用户代理字符串(userAgent),第一个名称也是 “Mozilla” 呢?

这就是我即将要讲的, 第一根搅屎棍——微软。

96年,微软推出了 IE3, 而当时 Netscape Navigator3 的市场占有率太高,微软说,为了兼容 Netscape Navigator3, IE的用户代理字符串从此就为:


看到没有, 第一个名称还是 “Mozilla”,这个误导信息可以直接骗过服务器检测,而真正的 IE 版本放到后面去了。

大概意思就是初出茅庐的IE小同学怕自己知名度太低,万一服务端检测不到自己,用户流失了怎么办?隔壁老大哥家大业大,那就干脆去蹭波流量吧。关键是蹭流量就蹭流量吧,还嘴硬说我这可是Mozilla/2.0哦,不是Mozilla/3.0哦,跟那个Netscape Navigator3 不能说没有关系,只能说毫不相干。于是,IE成功地将自己伪装成了 Netscape Navigator。

这在当时来说是有争议,但不得不说, 微软这波操作相当精准。精准到直到97年 IE4 发布时,IE 的市场份额大幅增加,有了点话语权,也不藏着掖着了, 就跟 Netscape 同时将版本升级到了 Mozilla/4.0, 之后就一直保持同步了。

看到 IE 这波操作,场外观众有点坐不住了,更多的浏览器厂商沿着IE的老路,蹭着 Netscape 的流量,在此基础上依葫芦画瓢地设定自己的用户代理字符串(userAgent)。直到 Gecko 渲染引擎 (firefox的核心) 开始大流行,用户代理字符串(userAgent)基本已经形成了一个比较标准格式,服务端检测也能识别到 “Mozilla”、“Geoko” 等关键字,与之前字符串相比, 还增加了引擎、语言信息等等。


接下来我要说第二根搅屎棍——苹果。

2003年,苹果发布了 Safari, 它说,我的浏览器用户代理字符串是这样的:


Safari 用的渲染引擎是WebKit, 不是Gecko,它的核心是在渲染引擎KHTML基础上进行开发的,但是当时大部分浏览器的用户代理字符串(userAgent)都包含了 “Mozilla”、“Gecko”等关键字供服务器端检测。

苹果昂着脸,维持着表面的高傲,表示我的 WebKit 天下无敌、傲视群雄, 心里却颤颤发抖,小心翼翼地在用户代理字符串里加了个“like Gecko”,假装我是Gecko ?!

这波操作可谓是又当又立的典范!

我想可能心理阴影最大的要属 Netscape 了,本来 IE 来白嫖一波也就算了,你Safari 也要来,而且本身苹果的影响力就不容小觑,你再进来插一脚,让我以后怎么生存?但苹果说:“Safari 与 Mozilla 兼容,不能让网站以为用户使用了不受支持的浏览器而把 Safari 排斥在外。”大概意思是,我就是要白嫖, 怎么样?可以说是相当不要脸了。

不过至少苹果还有点藏着掖着, 而 Chrome 就有点不讲武德,它说,成年人的世界不做选择, 我想要的我都要:


Chrome 的渲染引擎是 Blink , Javascript引擎是 V8, 但它的用户代理字符串(userAgent)中, 不仅包含了“Mozilla”、“like Gecko”,还包含了 “WebKit” 的引擎信息, 几乎把能嫖的都嫖了, 只多了一个 “Chrome” 名称和版本号,甚至都没有一个 “Blink” 关键字,节操碎了一地,简直触目惊心,令人叹为观止。

到这里就不得不提一嘴高冷的Opera,直到Opera 8,用户代理字符串(userAgent)一直都是 “Opera/Version (OS-or-CPU; Encryption [; language])”

Opera 一直给人一种世人皆醉我独清、出淤泥而不染的气概。到直到 Opera9 画风突然变了, 估计也是看到几个大厂商各种骚操作,有点绷不住了,也跑去蹭流量。心态虽然崩但高冷人设不能崩,我就是不走寻常路,于是秀了一波玄学操作,它搞了两套用户代理字符串(userAgent):


场外观众表示有点看不懂, 蹭完 Firefox 又去蹭 IE,还得分开蹭,这哪是秀操作, 这可是秀智商啊!纵观浏览器发展的这几十年,大概就是长江后浪推前浪,后浪还没把前浪踩死在沙滩上,后后浪又踩过来的一段历史吧。就在这历史的溪流中,用户代理字符串(userAgent)也已经形成了一个比较标准的格式。

目前,各个浏览器的用户代理字符串(userAgent)始终包含着以下信息:


至于后来移动端的 IOS 和 Andriod 基本的格式就成了:


这里的Mobile可能是 “iphone”、“ipad”、“BlackBerry”等等,Andriod设备的OS-or-CPU通常都是“Andriod”或“Linux”。所以,回到开头的isMobile检测函数内部,一大堆的检测判断条件, 简直就是一粒粒历史尘埃的堆叠。

同时,本地Chrome浏览器输出:


我也可以翻译一下,大概意思就是,白嫖的Mozilla/5.0 + Macintosh平台 + Mac OS操作系统 × 10_15_7版本白嫖的AppleWebKit引擎/537.36引擎版本号 (KHTML内核, like Gecko 假装我是Gecko) Chrome浏览器/浏览器版本号99.0.4844.84 白嫖的Safari/Sarari版本号537.36。

本人表示很精彩, 一个用户代理字符串犹如看了一场轰轰烈烈(巨不要脸)、你挣我夺(你蹭我蹭)的大戏!

2 第三方插件

接下来, 为懒人推荐几款用于浏览器检测的省事的第三方插件。

1、如果只是检测设备是否为手机端, 可以用 isMobile ,它支持在node端或浏览器端使用。

地址:https://github.com/kaimallea/isMobile

2、如果要检测设备的类型、版本、CPU等信息,可以用 UAParser ,它支持在node端或浏览器端使用。

地址:https://github.com/faisalman/ua-parser-js

3、vue插件,vue-browser-detect-plugin

地址:https://github.com/ICJIA/vue-browser-detect-plugin

4、react插件,react-device-detect

地址:https://github.com/duskload/react-device-detect

5、在不同平台,要在Html中设置对应平台的CSS,可以用 current-device

地址:https://github.com/matthewhudson/current-device

需要注意的是, 第三方插件虽好用, 但也要注意安全问题哦,之前 UAParser 就被曝出被遭遇恶意投毒,所以只是简单的检测尽量手写。

3 移动端与PC端分流

移动端与PC端分流,可以用 nginx 来操作, nginx 可以通过 $http_user_agent 直接拿到用户代理信息:

http { 
server {
    listen 80;
      server_name localhost;
      location / {
          root /usr/share/nginx/pc; #pc端代码目录
          if ($http_user_agent ~* '(Android|webOS|iPhone|iPod|BlackBerry)') {
          root /usr/share/nginx/mobile; #移动端代码目录
          }
      index index.html;
      }
}
}

来源:八戒技术团队

收起阅读 »

B站:2021.07.13 我们是这样崩的

至暗时刻2021年7月13日22:52,SRE收到大量服务和域名的接入层不可用报警,客服侧开始收到大量用户反馈B站无法使用,同时内部同学也反馈B站无法打开,甚至APP首页也无法打开。基于报警内容,SRE第一时间怀疑机房、网络、四层LB、七层SLB等基础设施出现...
继续阅读 »

至暗时刻

2021年7月13日22:52,SRE收到大量服务和域名的接入层不可用报警,客服侧开始收到大量用户反馈B站无法使用,同时内部同学也反馈B站无法打开,甚至APP首页也无法打开。基于报警内容,SRE第一时间怀疑机房、网络、四层LB、七层SLB等基础设施出现问题,紧急发起语音会议,拉各团队相关人员开始紧急处理(为了方便理解,下述事故处理过程做了部分简化)。

初因定位

22:55 远程在家的相关同学登陆VPN后,无法登陆内网鉴权系统(B站内部系统有统一鉴权,需要先获取登录态后才可登陆其他内部系统),导致无法打开内部系统,无法及时查看监控、日志来定位问题。

22:57 在公司Oncall的SRE同学(无需VPN和再次登录内网鉴权系统)发现在线业务主机房七层SLB(基于OpenResty构建) CPU 100%,无法处理用户请求,其他基础设施反馈未出问题,此时已确认是接入层七层SLB故障,排除SLB以下的业务层问题。

23:07 远程在家的同学紧急联系负责VPN和内网鉴权系统的同学后,了解可通过绿色通道登录到内网系统。

23:17 相关同学通过绿色通道陆续登录到内网系统,开始协助处理问题,此时处理事故的核心同学(七层SLB、四层LB、CDN)全部到位。

故障止损

23:20 SLB运维分析发现在故障时流量有突发,怀疑SLB因流量过载不可用。因主机房SLB承载全部在线业务,先Reload SLB未恢复后尝试拒绝用户流量冷重启SLB,冷重启后CPU依然100%,未恢复。

23:22 从用户反馈来看,多活机房服务也不可用。SLB运维分析发现多活机房SLB请求大量超时,但CPU未过载,准备重启多活机房SLB先尝试止损。

23:23 此时内部群里同学反馈主站服务已恢复,观察多活机房SLB监控,请求超时数量大大降低,业务成功率恢复到50%以上。此时做了多活的业务核心功能基本恢复正常,如APP推荐、APP播放、评论&弹幕拉取、动态、追番、影视等。非多活服务暂未恢复。

23:25 - 23:55 未恢复的业务暂无其他立即有效的止损预案,此时尝试恢复主机房的SLB。

  • 我们通过Perf发现SLB CPU热点集中在Lua函数上,怀疑跟最近上线的Lua代码有关,开始尝试回滚最近上线的Lua代码。

  • 近期SLB配合安全同学上线了自研Lua版本的WAF,怀疑CPU热点跟此有关,尝试去掉WAF后重启SLB,SLB未恢复。

  • SLB两周前优化了Nginx在balance_by_lua阶段的重试逻辑,避免请求重试时请求到上一次的不可用节点,此处有一个最多10次的循环逻辑,怀疑此处有性能热点,尝试回滚后重启SLB,未恢复。

  • SLB一周前上线灰度了对 HTTP2 协议的支持,尝试去掉 H2 协议相关的配置并重启SLB,未恢复。

新建源站SLB

00:00 SLB运维尝试回滚相关配置依旧无法恢复SLB后,决定重建一组全新的SLB集群,让CDN把故障业务公网流量调度过来,通过流量隔离观察业务能否恢复。

00:20 SLB新集群初始化完成,开始配置四层LB和公网IP。

01:00 SLB新集群初始化和测试全部完成,CDN开始切量。SLB运维继续排查CPU 100%的问题,切量由业务SRE同学协助。

01:18 直播业务流量切换到SLB新集群,直播业务恢复正常。

01:40 主站、电商、漫画、支付等核心业务陆续切换到SLB新集群,业务恢复。

01:50 此时在线业务基本全部恢复。

恢复SLB

01:00 SLB新集群搭建完成后,在给业务切量止损的同时,SLB运维开始继续分析CPU 100%的原因。

01:10 - 01:27 使用Lua 程序分析工具跑出一份详细的火焰图数据并加以分析,发现 CPU 热点明显集中在对 lua-resty-balancer 模块的调用中,从 SLB 流量入口逻辑一直分析到底层模块调用,发现该模块内有多个函数可能存在热点。

01:28 - 01:38 选择一台SLB节点,在可能存在热点的函数内添加 debug 日志,并重启观察这些热点函数的执行结果。

01:39 - 01:58 在分析 debug 日志后,发现 lua-resty-balancer模块中的 _gcd 函数在某次执行后返回了一个预期外的值:nan,同时发现了触发诱因的条件:某个容器IP的weight=0。

01:59 - 02:06 怀疑是该 _gcd 函数触发了 jit 编译器的某个 bug,运行出错陷入死循环导致SLB CPU 100%,临时解决方案:全局关闭 jit 编译。

02:07 SLB运维修改SLB 集群的配置,关闭 jit 编译并分批重启进程,SLB CPU 全部恢复正常,可正常处理请求。同时保留了一份异常现场下的进程core文件,留作后续分析使用。

02:31 - 03:50 SLB运维修改其他SLB集群的配置,临时关闭 jit 编译,规避风险。

根因定位

11:40 在线下环境成功复现出该 bug,同时发现SLB 即使关闭 jit 编译也仍然存在该问题。此时我们也进一步定位到此问题发生的诱因:在服务的某种特殊发布模式中,会出现容器实例权重为0的情况。

12:30 经过内部讨论,我们认为该问题并未彻底解决,SLB 仍然存在极大风险,为了避免问题的再次产生,最终决定:平台禁止此发布模式;SLB 先忽略注册中心返回的权重,强制指定权重。

13:24 发布平台禁止此发布模式。

14:06 SLB 修改Lua代码忽略注册中心返回的权重。

14:30 SLB 在UAT环境发版升级,并多次验证节点权重符合预期,此问题不再产生。

15:00 - 20:00 生产所有 SLB 集群逐渐灰度并全量升级完成。

原因说明

背景

B站在19年9月份从Tengine迁移到了OpenResty,基于其丰富的Lua能力开发了一个服务发现模块,从我们自研的注册中心同步服务注册信息到Nginx共享内存中,SLB在请求转发时,通过Lua从共享内存中选择节点处理请求,用到了OpenResty的lua-resty-balancer模块。到发生故障时已稳定运行快两年时间。

在故障发生的前两个月,有业务提出想通过服务在注册中心的权重变更来实现SLB的动态调权,从而实现更精细的灰度能力。SLB团队评估了此需求后认为可以支持,开发完成后灰度上线。

诱因

  • 在某种发布模式中,应用的实例权重会短暂的调整为0,此时注册中心返回给SLB的权重是字符串类型的"0"。此发布模式只有生产环境会用到,同时使用的频率极低,在SLB前期灰度过程中未触发此问题。

  • SLB 在balance_by_lua阶段,会将共享内存中保存的服务IP、Port、Weight 作为参数传给lua-resty-balancer模块用于选择upstream server,在节点 weight = "0" 时,balancer 模块中的 _gcd 函数收到的入参 b 可能为 "0"。

根因


  • Lua 是动态类型语言,常用习惯里变量不需要定义类型,只需要为变量赋值即可。

  • Lua在对一个数字字符串进行算术操作时,会尝试将这个数字字符串转成一个数字。

  • 在 Lua 语言中,如果执行数学运算 n % 0,则结果会变为 nan(Not A Number)。

  • _gcd函数对入参没有做类型校验,允许参数b传入:"0"。同时因为"0" != 0,所以此函数第一次执行后返回是 _gcd("0",nan)。如果传入的是int 0,则会触发[ if b == 0 ]分支逻辑判断,不会死循环。

  • _gcd("0",nan)函数再次执行时返回值是 _gcd(nan,nan),然后Nginx worker开始陷入死循环,进程 CPU 100%。

问题分析

\1. 为何故障刚发生时无法登陆内网后台?

事后复盘发现,用户在登录内网鉴权系统时,鉴权系统会跳转到多个域名下种登录的Cookie,其中一个域名是由故障的SLB代理的,受SLB故障影响当时此域名无法处理请求,导致用户登录失败。流程如下:


事后我们梳理了办公网系统的访问链路,跟用户链路隔离开,办公网链路不再依赖用户访问链路。

\2. 为何多活SLB在故障开始阶段也不可用?

多活SLB在故障时因CDN流量回源重试和用户重试,流量突增4倍以上,连接数突增100倍到1000W级别,导致这组SLB过载。后因流量下降和重启,逐渐恢复。此SLB集群日常晚高峰CPU使用率30%左右,剩余Buffer不足两倍。如果多活SLB容量充足,理论上可承载住突发流量, 多活业务可立即恢复正常。此处也可以看到,在发生机房级别故障时,多活是业务容灾止损最快的方案,这也是故障后我们重点投入治理的一个方向。


\3. 为何在回滚SLB变更无效后才选择新建源站切量,而不是并行?

我们的SLB团队规模较小,当时只有一位平台开发和一位组件运维。在出现故障时,虽有其他同学协助,但SLB组件的核心变更需要组件运维同学执行或review,所以无法并行。

\4. 为何新建源站切流耗时这么久?

我们的公网架构如下:


此处涉及三个团队:

  • SLB团队:选择SLB机器、SLB机器初始化、SLB配置初始化

  • 四层LB团队:SLB四层LB公网IP配置

  • CDN团队:CDN更新回源公网IP、CDN切量

SLB的预案中只演练过SLB机器初始化、配置初始化,但和四层LB公网IP配置、CDN之间的协作并没有做过全链路演练,元信息在平台之间也没有联动,比如四层LB的Real Server信息提供、公网运营商线路、CDN回源IP的更新等。所以一次完整的新建源站耗时非常久。在事故后这一块的联动和自动化也是我们的重点优化方向,目前一次新集群创建、初始化、四层LB公网IP配置已经能优化到5分钟以内。

\5. 后续根因定位后证明关闭jit编译并没有解决问题,那当晚故障的SLB是如何恢复的?

当晚已定位到诱因是某个容器IP的weight="0"。此应用在1:45时发布完成,weight="0"的诱因已消除。所以后续关闭jit虽然无效,但因为诱因消失,所以重启SLB后恢复正常。

如果当时诱因未消失,SLB关闭jit编译后未恢复,基于定位到的诱因信息:某个容器IP的weight=0,也能定位到此服务和其发布模式,快速定位根因。

优化改进

此事故不管是技术侧还是管理侧都有很多优化改进。此处我们只列举当时制定的技术侧核心优化改进方向。

1. 多活建设

在23:23时,做了多活的业务核心功能基本恢复正常,如APP推荐、APP播放、评论&弹幕拉取、动态、追番、影视等。故障时直播业务也做了多活,但当晚没及时恢复的原因是:直播移动端首页接口虽然实现了多活,但没配置多机房调度。导致在主机房SLB不可用时直播APP首页一直打不开,非常可惜。通过这次事故,我们发现了多活架构存在的一些严重问题:

多活基架能力不足

  • 机房与业务多活定位关系混乱。

  • CDN多机房流量调度不支持用户属性固定路由和分片。

  • 业务多活架构不支持写,写功能当时未恢复。

  • 部分存储组件多活同步和切换能力不足,无法实现多活。

业务多活元信息缺乏平台管理

  • 哪个业务做了多活?

  • 业务是什么类型的多活,同城双活还是异地单元化?

  • 业务哪些URL规则支持多活,目前多活流量调度策略是什么?

  • 上述信息当时只能用文档临时维护,没有平台统一管理和编排。

多活切量容灾能力薄弱

  • 多活切量依赖CDN同学执行,其他人员无权限,效率低。

  • 无切量管理平台,整个切量过程不可视。

  • 接入层、存储层切量分离,切量不可编排。

  • 无业务多活元信息,切量准确率和容灾效果差。

我们之前的多活切量经常是这么一个场景:业务A故障了,要切量到多活机房。SRE跟研发沟通后确认要切域名A+URL A,告知CDN运维。CDN运维切量后研发发现还有个URL没切,再重复一遍上面的流程,所以导致效率极低,容灾效果也很差。

所以我们多活建设的主要方向:

多活基架能力建设

  • 优化多活基础组件的支持能力,如数据层同步组件优化、接入层支持基于用户分片,让业务的多活接入成本更低。

  • 重新梳理各机房在多活架构下的定位,梳理Czone、Gzone、Rzone业务域。

  • 推动不支持多活的核心业务和已实现多活但架构不规范的业务改造优化。

多活管控能力提升

  • 统一管控所有多活业务的元信息、路由规则,联动其他平台,成为多活的元数据中心。

  • 支持多活接入层规则编排、数据层编排、预案编排、流量编排等,接入流程实现自动化和可视化。

  • 抽象多活切量能力,对接CDN、存储等组件,实现一键全链路切量,提升效率和准确率。

  • 支持多活切量时的前置能力预检,切量中风险巡检和核心指标的可观测。

2. SLB治理

架构治理

  • 故障前一个机房内一套SLB统一对外提供代理服务,导致故障域无法隔离。后续SLB需按业务部门拆分集群,核心业务部门独立SLB集群和公网IP。

  • 跟CDN团队、四层LB&网络团队一起讨论确定SLB集群和公网IP隔离的管理方案。

  • 明确SLB能力边界,非SLB必备能力,统一下沉到API Gateway,SLB组件和平台均不再支持,如动态权重的灰度能力。

运维能力

  • SLB管理平台实现Lua代码版本化管理,平台支持版本升级和快速回滚。

  • SLB节点的环境和配置初始化托管到平台,联动四层LB的API,在SLB平台上实现四层LB申请、公网IP申请、节点上线等操作,做到全流程初始化5分钟以内。

  • SLB作为核心服务中的核心,在目前没有弹性扩容的能力下,30%的使用率较高,需要扩容把CPU降低到15%左右。

  • 优化CDN回源超时时间,降低SLB在极端故障场景下连接数。同时对连接数做极限性能压测。

自研能力

  • 运维团队做项目有个弊端,开发完成自测没问题后就开始灰度上线,没有专业的测试团队介入。此组件太过核心,需要引入基础组件测试团队,对SLB输入参数做完整的异常测试。

  • 跟社区一起,Review使用到的OpenResty核心开源库源代码,消除其他风险。基于Lua已有特性和缺陷,提升我们Lua代码的鲁棒性,比如变量类型判断、强制转换等。

  • 招专业做LB的人。我们选择基于Lua开发是因为Lua简单易上手,社区有类似成功案例。团队并没有资深做Nginx组件开发的同学,也没有做C/C++开发的同学。

3. 故障演练

本次事故中,业务多活流量调度、新建源站速度、CDN切量速度&回源超时机制均不符合预期。所以后续要探索机房级别的故障演练方案:

  • 模拟CDN回源单机房故障,跟业务研发和测试一起,通过双端上的业务真实表现来验收多活业务的容灾效果,提前优化业务多活不符合预期的隐患。

  • 灰度特定用户流量到演练的CDN节点,在CDN节点模拟源站故障,观察CDN和源站的容灾效果。

  • 模拟单机房故障,通过多活管控平台,演练业务的多活切量止损预案。

4. 应急响应

B站一直没有NOC/技术支持团队,在出现紧急事故时,故障响应、故障通报、故障协同都是由负责故障处理的SRE同学来承担。如果是普通事故还好,如果是重大事故,信息同步根本来不及。所以事故的应急响应机制必须优化:

  • 优化故障响应制度,明确故障中故障指挥官、故障处理人的职责,分担故障处理人的压力。

  • 事故发生时,故障处理人第一时间找backup作为故障指挥官,负责故障通报和故障协同。在团队里强制执行,让大家养成习惯。

  • 建设易用的故障通告平台,负责故障摘要信息录入和故障中进展同步。

本次故障的诱因是某个服务使用了一种特殊的发布模式触发。我们的事件分析平台目前只提供了面向应用的事件查询能力,缺少面向用户、面向平台、面向组件的事件分析能力:

  • 跟监控团队协作,建设平台控制面事件上报能力,推动更多核心平台接入。

  • SLB建设面向底层引擎的数据面事件变更上报和查询能力,比如服务注册信息变更时某个应用的IP更新、weight变化事件可在平台查询。

  • 扩展事件查询分析能力,除面向应用外,建设面向不同用户、不同团队、不同平台的事件查询分析能力,协助快速定位故障诱因。

总结

此次事故发生时,B站挂了迅速登上全网热搜,作为技术人员,身上的压力可想而知。事故已经发生,我们能做的就是深刻反思,吸取教训,总结经验,砥砺前行。

此篇作为“713事故”系列之第一篇,向大家简要介绍了故障产生的诱因、根因、处理过程、优化改进。后续文章会详细介绍“713事故”后我们是如何执行优化落地的,敬请期待。

最后,想说一句:多活的高可用容灾架构确实生效了。

来源:哔哩哔哩技术

收起阅读 »

来了!解放你 Flutter Assets 的双手

以下是正文 Flutter 中加载本地资源最原始的方式是手动添加,然后硬编码路径,这种方式使用起来极其麻烦,也是我们开发者的痛点。这篇文章来介绍怎么用自动生成的方式来解放大家的双手,远离这个小痛点😉。 下面,我们来看怎么在 App 中使用资源,这些资源可以是...
继续阅读 »

以下是正文


Flutter 中加载本地资源最原始的方式是手动添加,然后硬编码路径,这种方式使用起来极其麻烦,也是我们开发者的痛点。这篇文章来介绍怎么用自动生成的方式来解放大家的双手,远离这个小痛点😉。


image.png


下面,我们来看怎么在 App 中使用资源,这些资源可以是图片,也可是字体。



· · ·

方式 1 : 手动添加


这是我们最原始的方式,也是带给我们痛苦的方式 😂,我们刚开始 Flutter 的时候基本就是这样的~


我们看一下这种方式麻烦在什么地方!怎么给我们自己制造麻烦的!


Step 1: 文件夹中添加图片


1_8MSLeRTWJJ9cNdRzWHymvg.png


Step 2: 添加图片到 pubspec.yaml 文件中


image.png


注意一点🤏:assets/ 会添加 assets/ 文件下所有可用的图片。


Step 3: 直接在代码中使用


import 'package:asset_generation/page2.dart';
import 'package:flutter/material.dart';

class Page1 extends StatelessWidget {
const Page1({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.next_plan),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const Page2(),
),
),
),
body: Center(
child: Image.asset('assets/dash.png'),
),
);
}
}

我们再创建一个 Page2 页面,并且添加相同的代码。



import 'package:flutter/material.dart';

class Page2 extends StatelessWidget {
const Page2({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
),
body: Center(
child: Image.asset('assets/dash.png'),
),
);
}
}

效果如下:


1_6nRtHc2RD8eU8i1VprJ_mA.gif


现在,假如我们想要修改文件的名字。只要我们改变了文件的名字,我们必须在代码中每一个使用到文件的地方修改一遍字符串。这就是痛苦且麻烦的地方!!!


在这里例子中,我们仅仅有两个页面,修改的时候貌似简单。但是我们维护的是一个大型 APP,开发者还修改了文件名,想想这个代码中重命名的任务就恶心🤢。



· · ·

方式 2 : 为资源变量创建一个常量文件


现在我们稍微进步一点点🤏来减缓我们的痛苦。我们创建一个常量来保持文件的路径,然后在代码中使用常量文件!


Step 1: 创建 constants.dart 文件


class Constants {
static String dashImage = 'assets/dash.png';
}

Step 2: 在Page1 和 Page2 中使用常量:


Center(
child: Image.asset(Constants.dashImage),
),

在这个例子里面,如果开发者想要修改文件名字,仅仅改变常量的内容就可以了,只在 Constants 类中一处而已。


Step 3: 自动创建常量文件


接下来就是魔法的地方~


Step 1: 在 pubspec.yaml 添加 flutter_gen 依赖


在 dependencies 下面添加 flutter_gen 依赖,然后在 dev_dependencies 添加 flutter_gen_runnerbuild_runner 依赖。


Step 2: 生成 assets


添加依赖之后,执行 flutter pub get,然后运行下面的命令:


flutter packages pub run build_runner build

这里命令之后,会创建一个 lib/gen 文件夹,在文件夹里面,会存在一个 assets.gen.dart 文件,这个文件会保存所有的资源信息!


Step 3: 在代码中使用


现在,使用生成的资源,开发者可以访问资源文件:


Center(
child: Image.asset(Assets.dash.path),
),

现在,加入开发者想要重命名文件,仅仅需要在运行一遍命令就可以了,我们什么也不用做了!



· · ·

希望大家喜欢文章~


如果文章对大家有帮助,并且想要在自己的 APP 中使用,可以在这个仓库中看 👉GitHub Repository


作者:一条上岸小咸鱼
链接:https://juejin.cn/post/7071077124982964260
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

vue hash和history路由的区别

vue
在了解路由模式前,我们先看下 什么是单页面应用,vue-router  的实现原理是怎样的,这样更容易理解路由。 SPA与前端路由 SPA(单页面应用,全程为:Single-page Web applications)指的是只有一张Web页...
继续阅读 »


在了解路由模式前,我们先看下 什么是单页面应用,vue-router  的实现原理是怎样的,这样更容易理解路由。


SPA与前端路由


  • SPA(单页面应用,全程为:Single-page Web applications)指的是只有一张Web页面的应用,是加载单个HTML 页面并在用户与应用程序交互时动态更新该页面的Web应用程序,简单通俗点就是在一个项目中只有一个html页面,它在第一次加载页面时,将唯一完成的html页面和所有其余页面组件一起下载下来,所有的组件的展示与切换都在这唯一的页面中完成,这样切换页面时,不会重新加载整个页面,而是通过路由来实现不同组件之间的切换。
  • 单页面应用(SPA)的核心之一是:更新视图而不重新请求页面。

优点:


  • 具有桌面应用的即时性、网站的可移植性和可访问性
  • 用户体验好、快,内容的改变不需要重新加载整个页面
  • 良好的前后端分离,分工更明确

缺点:


  • 不利于搜索引擎的抓取
  • 首次渲染速度相对较慢

vue Router实现原理


vue-router  在实现单页面路由时,提供了两种方式:Hash  模式和  History  模式;vue2是 根据  mode  参数来决定采用哪种方式,默认是  Hash  模式,手动设置为  History  模式。更新视图但不重新请求页面”是前端路由原理的核心之一,目前在浏览器环境中这一功能的实现主要有以下两种方式:


image.png


Hash


简述


  • vue-router   默认为 hash 模式,使用 URL 的  hash  来模拟一个完整的 URL,当 URL 改变时,页面不会重新加载;#  就是  hash符号,中文名为哈希符或者锚点,在  hash  符号后的值称为  hash  值。
  • 路由的  hash  模式是利用了  window 可以监听 onhashchange 事件来实现的,也就是说  hash  值是用来指导浏览器动作的,对服务器没有影响,HTTP 请求中也不会包括  hash  值,同时每一次改变  hash  值,都会在浏览器的访问历史中增加一个记录,使用“后退”按钮,就可以回到上一个位置。所以,hash 模式 是根据  hash 值来发生改变,根据不同的值,渲染指定DOM位置的不同数据。

参考:Vue 前端路由工作原理,hash与history之间的区别


image.png


 特点


  • url中带一个   #   号
  • 可以改变URL,但不会触发页面重新加载(hash的改变会记录在  window.hisotry  中)因此并不算是一次 HTTP 请求,所以这种模式不利于 SEO 优化
  • 只能修改  #  后面的部分,因此只能跳转与当前 URL 同文档的 URL
  • 只能通过字符串改变 URL
  • 通过  window.onhashchange  监听  hash  的改变,借此实现无刷新跳转的功能。
  • 每改变一次  hash ( window.location.hash),都会在浏览器的访问历史中增加一个记录。
  • 路径中从  #  开始,后面的所有路径都叫做路由的  哈希值 并且哈希值它不会作为路径的一部分随着 http 请求,发给服务器

参考:在SPA项目的路由中,注意hash与history的区别


 History


简述


  • history  是路由的另一种模式,在相应的  router  配置时将  mode  设置为  history  即可。
  • history  模式是通过调用  window.history  对象上的一系列方法来实现页面的无刷新跳转。
  • 利用了 HTML5 History Interface  中新增的   pushState()  和  replaceState()  方法。
  • 这两个方法应用于浏览器的历史记录栈,在当前已有的  back、forward、go  的基础之上,它们提供了对历史记录进行修改的功能。只是当它们执行修改时,虽然改变了当前的 URL,但浏览器不会向后端发送请求。

 参考:深入了解前端路由 hash 与 history 差异



特点


  • 新的URL可以是与当前URL同源的任意 URL,也可以与当前URL一样,但是这样会把重复的一次操作记录到栈中。
  • 通过参数stateObject可以添加任意类型的数据到记录中。
  • 可额外设置title属性供后续使用。
  • 通过pushState、replaceState实现无刷新跳转的功能。
  • 路径直接拼接在端口号后面,后面的路径也会随着http请求发给服务器,因此前端的URL必须和向发送请求后端URL保持一致,否则会报404错误。
  • 由于History API的缘故,低版本浏览器有兼容行问题。

参考:在SPA项目的路由中,注意hash与history的区别前端框架路由实现的Hash和History两种模式的区别


 生产环境存在问题


       因为  history  模式的时候路径会随着  http 请求发送给服务器,项目打包部署时,需要后端配置 nginx,当应用通过  vue-router  跳转到某个页面后,因为此时是前端路由控制页面跳转,虽然url改变,但是页面只是内容改变,并没有重新请求,所以这套流程没有任何问题。但是,如果在当前的页面刷新一下,此时会重新发起请求,如果  nginx  没有匹配到当前url,就会出现404的页面。


那为什么hash模式不会出现这个问题呢?


     上文已讲,hash 虽然可以改变URL,但不会被包括在  HTTP  请求中。它被用来指导浏览器动作,并不影响服务器端,因此,改变  hash  并没有改变URL,所以页面路径还是之前的路径,nginx  不会拦截。 因此,切记在使用  history  模式时,需要服务端允许地址可访问,否则就会出现404的尴尬场景。


那为什么开发环境时就不会出现404呢?


因为在 vue-cli  中  webpack  帮我们做了处理


 


 解决问题


生产环境 刷新 404 的解决办法可以在 nginx  做代理转发,在  nginx 中配置按顺序检查参数中的资源是否存在,如果都没有找到,让   nginx  内部重定向到项目首页。



作者:前端吃货媛
链接:https://juejin.cn/post/7116336664540086286
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

如何让 x == 1 && x == 2 && x == 3 等式成立

如何让 x == 1 && x == 2 && x == 3 等式成立 某次面试,面试官突然问道:“如何让 x 等于 1 且让 x 等于 2 且让 x 等于 3 的等式成立?” 话音刚落,笔者立马失去意识,双眼一黑,两腿一蹬,心...
继续阅读 »

如何让 x == 1 && x == 2 && x == 3 等式成立


某次面试,面试官突然问道:“如何让 x 等于 1 且让 x 等于 2 且让 x 等于 3 的等式成立?


话音刚落,笔者立马失去意识,双眼一黑,两腿一蹬,心里暗骂:什么玩意儿!



虽然当时没回答上来,但觉得这题非常有意思,便在这为大家分享下后续的解题思路:


宽松相等 == 和严格相等 === 都能用来判断两个值是否“相等”,首先,我们要明确上文提到的等于指的是哪一种,我们先看下二者的区别:


(1) 对于基础类型之间的比较,== 和 === 是有区别的:


(1.1) 不同类型间比较,== 比较“转化成同一类型后的值”看“值”是否相等,=== 如果类型不同,其结果就是不等

(1.2) 同类型比较,直接进行“值”比较,两者结果一样

(2) 对于引用类型之间的比较,== 和 === 是没有区别的,都进行“指针地址”比较 


(3) 基础类型与引用类型之间的比较,== 和 === 是有区别的:


(3.1) 对于 ==,将引用类型转化为基础类型,进行“值”比较

(3.2) 因为类型不同,=== 结果为 false

“== 允许在相等比较中进行强制类型转换,而 === 不允许。”


由此可见,上文提到的等于指的宽松相等 ==,题目变为 “x == 1 && x == 2 && x == 3”。


那多种数据类型之间的相等比较又有哪些呢?笔者查阅了相关资料,如下所示:


同类型数据之间的相等比较


如果 Type(x) 等于 Type(y) ES5 规范 11.9.3.1 这样定义:



  1. 如果 Type(x)Undefined,返回 true



  2. 如果 Type(x)Null,返回 true



  3. 如果 Type(x)Number ,则


    • 如果 xNaN,返回 false
    • 如果 yNaN,返回 false
    • 如果 xy 的数字值相同,返回 true
    • 如果 x+0y-0,返回 true
    • 如果 x-0y+0,返回 true


  4. 如果 Type(x)String,则如果 xy 是字符的序列完全相同(相同的长度和相同位置相同的字符),则返回 true。否则,返回 false



  5. 如果 Type(x)Boolean,则如果 xy 都为 true 或都为 false,则返回 true。否则,返回 false



  6. 如果 xy 指向同一对象,则返回 true。否则,返回 false



null 和 undefined 之间的相等比较


nullundefined 之间的 == 也涉及隐式强制类型转换。ES5 规范 11.9.3.2-3 这样定义:


  1. 如果 xnullyundefined,则结果为 true
  2. 如果 xundefinedynull,则结果为 true

在 == 中,nullundefined 相等(它们也与其自身相等),除此之外其他值都不和它们两个相等。


这也就是说, 在 == 中nullundefined 是一回事。


var a = null;
var b;
a == b; // true
a == null; // true
b == null; // true
a == false; // false
b == false; // false
a == ""; // false
b == ""; // false
a == 0; // false
b == 0; // false

字符串和数字之间的相等比较


ES5 规范 11.9.3.4-5 这样定义:


  1. 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。
  2. 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果。

var a = 42;

var b = "42";

a === b; // false

a == b; // true

因为没有强制类型转换,所以 a === bfalse,42 和 "42" 不相等。


根据规范,"42" 应该被强制类型转换为数字以便进行相等比较。


其他类型和布尔类型之间的相等比较


ES5 规范 11.9.3.6-7 这样定义:


  1. 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果;
  2. 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。

仔细分析例子,首先:


var x = true;

var y = "42";

x == y; // false

Type(x) 是布尔值,所以 ToNumber(x)true 强制类型转换为 1,变成 1 == "42",二者的类型仍然不同,"42" 根据规则被强制类型转换为 42,最后变成 1 == 42,结果为 false


对象和非对象之间的相等比较


关于对象(对象 / 函数 / 数组)和标量基本类型(字符串 / 数字 / 布尔值)之间的相等比较,ES5 规范 11.9.3.8-9 做如下规定:


  1. 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果;
  2. 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPromitive(x) == y 的结果。

什么是 toPrimitive() 函数?


**应用场景:**在 JavaScript 中,如果想要将对象转换成基本类型时,再从基本类型转换为对应的 String 或者 Number,实质就是调用 valueOftoString 方法,也就是所谓的拆箱转换。


**函数结构:**toPrimitive(input, preferedType?)


参数解释:


input 是输入的值,即要转换的对象,必选;


preferedType 是期望转换的基本类型,他可以是字符串,也可以是数字。选填,默认为 number


执行过程:


如果转换的类型是 number,会执行以下步骤:


  1. 如果 input 是原始值,直接返回这个值;
  2. 否则,如果 input 是对象,调用 input.valueOf(),如果结果是原始值,返回结果;
  3. 否则,调用input.toString()。如果结果是原始值,返回结果;
  4. 否则,抛出错误。

如果转换的类型是 string,2和3会交换执行,即先执行 toString() 方法。


valueOf 和 toString 的优先级:


  1. 进行对象转换时 (alert(对象)),优先调用 toString 方法,如没有重写 toString 将调用 valueOf 方法,如果两方法都不没有重写,但按 ObjecttoString 输出。
  2. 进行强转字符串类型时将优先调用 toString 方法,强转为数字时优先调用 valueOf
  3. 在有运算操作符的情况下,valueOf 的优先级高于 toString

由此可知,若 x 为对象时,我们改写 x 的 valueOf 或 toString 方法可以让标题的等式成立:


const x = {
val: 0,
valueOf: () => {
x.val++
return x.val
},
}

或者:


const x = {
val: 0,
toString: () => {
x.val++
return x.val
},
}

给对象 x 设置一个属性 val 并赋值为 0,并修改其 valueOf、toString 方法,在 “x == 1 && x == 2 && x == 3”判断执行时,每次等式比较都会触发 valueOf、toString 方法,都会执行 val++ ,同时把最新的 val 值用于等式比较,三次等式判断时 val 值分别为 1、2、3 与等式右侧的 1、2、3 相同,从而使等式成立。



作者:政采云前端团队
链接:https://juejin.cn/post/7111848825232293918
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

大家好啊,世界您好啊,请多关照哈

大家好啊,世界您好啊,请多关照哈,,,,,,,,,,,

大家好啊,世界您好啊,请多关照哈,,,,,,,,,,,

Flutter: 卡顿检测,实用小工具推荐

前言 对于任何一款应用来说,页面的流畅度是用户体验最重要的几个指标之一。我们需要用数据的形式标识出页面的流畅程度。 如何了解页面流畅度 对于大部分人而言,当每秒的画面达到60,也就是俗称60FPS的时候,整个过程就是流畅的。一秒 60 帧,也就意味着平均两帧之...
继续阅读 »

前言


对于任何一款应用来说,页面的流畅度是用户体验最重要的几个指标之一。我们需要用数据的形式标识出页面的流畅程度。


如何了解页面流畅度


对于大部分人而言,当每秒的画面达到60,也就是俗称60FPS的时候,整个过程就是流畅的。一秒 60 帧,也就意味着平均两帧之间的间隔为 16.7ms。但并不意味着一秒低于60帧,人眼就会感觉到卡顿。小轰将查阅到的资料列出如下:



  • 流畅:FPS大于55,即一帧耗时低于 18ms

  • 良好:FPS在30-55之间,即一帧耗时在 18ms-33ms 之间

  • 轻微卡顿:FPS在15-30之间,即一帧耗时在 33ms-67ms 之间

  • 卡顿:FPS低于15,即一帧耗时大于 66.7ms


两款帧率检测工具


1. PerformanceOverLay


官方SDK为开发者提供的帧率检测工具,使用非常简单,在MaterialApp下添加属性showPerformanceOverlay:true


MaterialApp(
showPerformanceOverlay: true,
home: ...,
)

image.png
如图,PerformanceOverLay 会分别为我们展示了构建(UI)耗时和渲染(Raster)耗时。



注意:我们在判断流畅度的时候,要看一帧的总耗时(UI耗时+Raster耗时)。



2. fps_monitor


一款pub上的开源工具,链接地址:fps_monitor


集成步骤



  1. 添加引用 fps_monitor: ^2.0.0

  2. 根布局添加包裹组件


Widget build(BuildContext context) {
GlobalKey<NavigatorState> globalKey = GlobalKey();
WidgetsBinding.instance.addPostFrameCallback((t) {
//overlayState 为 fps_monitor 内提供变量,用于overlay.insert
overlayState = globalKey.currentState.overlay;
});
return MaterialApp(
showPerformanceOverlay: false,
navigatorKey: globalKey,
builder: (ctx, child) => CustomWidgetInspector(
child: child,
),
home: MyApp(),
);
}
复制代码

参数说明


  • navigatorKey : MaterialApp指定GlobalKey

  • overlayState 赋值: 指定overLayState ,因为需要弹出一个Fps的统计页面,所以当前指定overLayState。

  • CustomWidgetInspector: 在build属性中包裹组件


image.png



与 PerformanceOverLay 不同,fps_monitor在使用上更加直观,省略了两组数据的相加。



原理分析:



  • Flutter 会在每帧完成绘制后,将耗时进行回调List<FrameTiming> 。[构建时间;绘制时间;总时间]。WidgetsBinding.instance.addTimingsCallback(Function(List<FrameTiming> timings));

  • 每一帧的耗时 duration = frameTiming.totalSpan.inMilliseconds.toDouble()

  • 根据每一帧的耗时,依照规则进行流畅度匹配,完成widget的绘制。然后通过 overlay.insert(),作为浮窗展示给开发者

作者:刘小轰
链接:https://juejin.cn/post/7016602675538034719
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Flutter之GetX依赖注入使用详解

put 为了验证依赖注入的功能,首先创建两个测试页面:PageA 和 PageB ,PageA 添加两个按钮 toB 和 find ,分别为跳转 PageB 和获取依赖;在 PageB 中通过 put 方法注入依赖对象,然后调用按钮触发 find 获取依赖。关...
继续阅读 »

put


为了验证依赖注入的功能,首先创建两个测试页面:PageA 和 PageB ,PageA 添加两个按钮 toBfind ,分别为跳转 PageB 和获取依赖;在 PageB 中通过 put 方法注入依赖对象,然后调用按钮触发 find 获取依赖。关键源码如下:


PageA


TextButton(
child: const Text("toB"),
onPressed: (){
/// Navigator.push(context, MaterialPageRoute(builder: (context) => const PageB()));
/// Get.to(const PageB());
},
),

TextButton(
child: const Text("find"),
onPressed: () async {
User user = Get.find();
print("page a username : ${user.name} id: ${user.id}");
})

PageB:


Get.put(User.create("张三", DateTime.now().millisecondsSinceEpoch)));

User user = Get.find();

TextButton(
child: const Text("find"),
onPressed: (){
User user = Get.find();
print("${DateTime.now()} page b username : ${user.name} id: ${user.id}");
})

其中 User 为自定义对象,用于测试注入,源码如下:


User:


class User{
final String? name;
final int? id;

factory User.create(String name, int id){
print("${DateTime.now()} create User");
return User(name, id);
}
}

Navigator 路由跳转


首先使用 Flutter 自带的路由管理从 PageA 跳转 PageB, 然后返回 PageA 再点击 find 按钮获取 User 依赖:


Navigator.push(context, MaterialPageRoute(builder: (context) => const PageB()));

流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


/// put
I/flutter (31878): 2022-01-27 19:18:20.851800 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 19:18:22.170133 page b username : 张三 id: 1643282300139

/// page a find
I/flutter (31878): 2022-01-27 19:18:25.554667 page a username : 张三 id: 1643282300139

通过输出结果发现,在 PageB 注入的依赖 User,在返回 PageA 后通过 find 依然能获取,并且是同一个对象。通过 Flutter 通过源码一步一步剖析 Getx 依赖管理的实现 这篇文章知道,在页面销毁的时候会回收依赖,但是这里为什么返回 PageA 后还能获取到依赖对象呢?是因为在页面销毁时回收有个前提是使用 GetX 的路由管理页面,使用官方的 Navigator 进行路由跳转时页面销毁不会触发回收依赖。


GetX 路由跳转


接下来换成使用 GetX 进行路由跳转进行同样的操作,再看看输出结果:


Get.to(const PageB());

流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB

/// put
I/flutter (31878): 2022-01-27 19:16:32.014530 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 19:16:34.043144 page b username : 张三 id: 1643282192014
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" deleted from memory

/// page a find error
E/flutter (31878): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: "User" not found. You need to call "Get.put(User())" or "Get.lazyPut(()=>User())"

发现在 PageB 中获取是正常,关闭 PageB 时输出了一句 "User" deleted from memory 即在 PageB 注入的 User 被删除了,此时在 PageA 再通过 find 获取 User 就报错了,提示需要先调用 put 或者 lazyPut 先注入依赖对象。这就验证了使用 GetX 路由跳转时,使用 put 默认注入依赖时,当页面销毁依赖也会被回收。


permanent


put 还有一个 permanent 参数,在 Flutter应用框架搭建(一)GetX集成及使用详解 这篇文章里介绍过,permanent 的作用是永久保留,默认为 false,接下来在 put 时设置 permanent 为 true,并同样使用 GetX 的路由跳转重复上面的流程。


关键代码:


Get.put(User.create("张三", DateTime.now().millisecondsSinceEpoch), permanent: true);

流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB
/// put
I/flutter (31878): 2022-01-27 19:15:16.110403 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 19:15:18.667360 page b username : 张三 id: 1643282116109
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" has been marked as permanent, SmartManagement is not authorized to delete it.

/// page a find success
I/flutter (31878): page a username : 张三 id: 1643282116109

设置 permanent 为 true 后,返回 PageA 同样能获取到依赖对象,说明依赖并没有因为页面销毁而回收,GetX 的日志输出也说明了 User 被标记为 permanent 而不会被删除:"User" has been marked as permanent, SmartManagement is not authorized to delete it.


lazyPut


lazyPut 为延迟初始化依赖对象 :


Get.lazyPut(() => User.create("张三", DateTime.now().millisecondsSinceEpoch));

TextButton(
child: const Text("find"),
onPressed: () async {
User user = Get.find();
print("${DateTime.now()} page b username : ${user.name} id: ${user.id}");
})

流程:PageA -> PageB -> put -> find -> find -> PageA -> find, 从 PageA 跳转 PageB,先通过 lazyPut 注入依赖,然后点击 find 获取依赖,过 3 秒再点击一次,然后返回 PageA 点击 find 获取一次。


输出结果:


[GETX] GOING TO ROUTE /PageB
/// lazyPut

/// page b find 1
I/flutter (31878): 2022-01-27 17:38:49.590295 create User
[GETX] Instance "User" has been created
I/flutter (31878): 2022-01-27 17:38:49.603063 page b username : 张三 id: 1643276329589

/// page b find 2
I/flutter (31878): 2022-01-27 17:38:52.297049 page b username : 张三 id: 1643276329589
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" deleted from memory

/// page a find error
E/flutter (31878): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: "User" not found. You need to call "Get.put(User())" or "Get.lazyPut(()=>User())"

通过日志发现 User 对象是在第一次调用 find 时进行初始化话的,第二次 find 时不会再次初始化 User;同样的 PageB 销毁时依赖也会被回收,导致在 PageA 中获取会报错。


fenix


lazyPut 还有一个 fenix 参数默认为 false,作用是当销毁时,会将依赖移除,但是下次 find 时又会重新创建依赖对象。


lazyPut 添加 fenix 参数 :


 Get.lazyPut(() => User.create("张三", DateTime.now().millisecondsSinceEpoch), fenix: true);

流程:PageA -> PageB -> put -> find -> find -> PageA -> find,与上面流程一致。


输出结果:


[GETX] GOING TO ROUTE /PageB
/// lazyPut

/// page b find 1
[GETX] Instance "User" has been created
I/flutter (31878): 2022-01-27 17:58:58.321564 create User
I/flutter (31878): 2022-01-27 17:58:58.333369 page b username : 张三 id: 1643277538321

/// page b find 2
I/flutter (31878): 2022-01-27 17:59:01.647629 page b username : 张三 id: 1643277538321
[GETX] CLOSE TO ROUTE /PageB

/// page a find success
I/flutter (31878): 2022-01-27 17:59:07.666929 create User
[GETX] Instance "User" has been created
I/flutter (31878): page a username : 张三 id: 1643277547666

通过输出日志分析,在 PageB 中的表现与不加 fenix 表现一致,但是返回 PageA 后获取依赖并没有报错,而是重新创建了依赖对象。这就是 fenix 的作用。


putAsync


putAsyncput 基本一致,不同的是传入依赖可以异步初始化。测试代码修改如下:


print("${DateTime.now()} : page b putAsync User");
Get.putAsync(() async {
await Future.delayed(const Duration(seconds: 3));
return User.create("张三", DateTime.now().millisecondsSinceEpoch);
});

使用 Future.delayed 模拟耗时操作。


流程:PageA -> PageB -> put -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB

/// putAsync
I/flutter (31878): 2022-01-27 18:48:34.280337 : page b putAsync User

/// create user
I/flutter (31878): 2022-01-27 18:48:37.306073 create User
[GETX] Instance "User" has been created

/// page b find
I/flutter (31878): 2022-01-27 18:48:40.264854 page b username : 张三 id: 1643280517305
[GETX] CLOSE TO ROUTE /PageB
[GETX] "User" deleted from memory

/// page a find error
E/flutter (31878): [ERROR:flutter/lib/ui/ui_dart_state.cc(209)] Unhandled Exception: "User" not found. You need to call "Get.put(User())" or "Get.lazyPut(()=>User())"

通过日志发现,put 后确实是过了 3s 才创建 User。


create


createpermanent 参数默认为 true,即永久保留,但是通过 Flutter应用框架搭建(一)GetX集成及使用详解 这篇源码分析知道,create 内部调用时 isSingleton 设置为 false,即每次 find 时都会重新创建依赖对象。


Get.create(() => User.create("张三", DateTime.now().millisecondsSinceEpoch));

流程:PageA -> PageB -> put -> find -> find -> PageA -> find


输出结果:


[GETX] GOING TO ROUTE /PageB
/// create

/// page b find 1
I/flutter (31878): 2022-01-27 18:56:10.520961 create User
I/flutter (31878): 2022-01-27 18:56:10.532465 page b username : 张三 id: 1643280970520

/// page b find 2
I/flutter (31878): 2022-01-27 18:56:18.933750 create User
I/flutter (31878): 2022-01-27 18:56:18.934188 page b username : 张三 id: 1643280978933

[GETX] CLOSE TO ROUTE /PageB

/// page a find success
I/flutter (31878): 2022-01-27 18:56:25.319224 create User
I/flutter (31878): page a username : 张三 id: 1643280985319

通过日志发现,确实是每次 find 时都会重新创建 User 对象,并且退出 PageB 后还能通过 find 获取依赖对象。


总结


通过代码调用不同的注入方法,设置不同的参数,分析输出日志,详细的介绍了 putlazyPutputAsynccreate 以及 permanentfenix 参数的具体作用,开发中可根据实际业务场景灵活使用不同注入方式。关于注入的 tag 参数将在后续文章中详细介绍。


作者:loongwind
链接:https://juejin.cn/post/7057851021380288549
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

原生Android工程接入Flutter aar

一、环境搭建 首先,需要开发者按照原生Android、iOS的搭建流程搭建好开发环境。然后,去Flutter官网下载最新的SDK,下载完毕后解压到自定义目录即可。如果出现下载问题,可以使用Flutter官方为中国开发者搭建的临时镜像。 export PUB_H...
继续阅读 »

一、环境搭建


首先,需要开发者按照原生Android、iOS的搭建流程搭建好开发环境。然后,去Flutter官网下载最新的SDK,下载完毕后解压到自定义目录即可。如果出现下载问题,可以使用Flutter官方为中国开发者搭建的临时镜像。


export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

为了方便使用命令行,还需要额外配置下环境变量。首先,使用vim命令打开终端。


vim ~/.bash_profile  

然后,将如下代码添加到.bash_profile文件中,并使用source ~/.bash_profile命令使文件更改生效。


export PATH=/Users/mac/Flutter/flutter/bin:$PATH
//刷新.bash_profile
source ~/.bash_profile

完成上述操作之后,接下来使用flutter doctor命令检查环境是否正确,成功会输出如下信息。
在这里插入图片描述


二、创建Flutter aar包


原生Android集成Flutter主要有两种方式,一种是创建flutter module,然后以原生module那样依赖;另一种方式是将flutter module打包成aar,然后在原生工程中依赖aar包,官方推荐aar的方式接入。


创建flutter aar有两种方式,一种是使用Android Studio进行生成,另一种是直接使用命令行。使用命令行创建flutter module如下:


flutter create -t module flutter_module

然后,进入到flutter_module,执行flutter build aar命令生成aar包,如果没有任何出错,会在/flutter_module/.android/Flutter/build/outputs目录下生成对应的aar包,如下图。


在这里插入图片描述


build/host/outputs/repo
└── com
└── example
└── my_flutter
├── flutter_release
│ ├── 1.0
│ │ ├── flutter_release-1.0.aar
│ │ ├── flutter_release-1.0.aar.md5
│ │ ├── flutter_release-1.0.aar.sha1
│ │ ├── flutter_release-1.0.pom
│ │ ├── flutter_release-1.0.pom.md5
│ │ └── flutter_release-1.0.pom.sha1
│ ├── maven-metadata.xml
│ ├── maven-metadata.xml.md5
│ └── maven-metadata.xml.sha1
├── flutter_profile
│ ├── ...
└── flutter_debug
└── ...


当然,我们也可以使用Android Studio来生成aar包。依次选择File -> New -> New Flutter Project -> Flutter Module生成Flutter module工程。
在这里插入图片描述


然后我们依次选择build ->Flutter ->Build AAR即可生成aar包。


在这里插入图片描述
接下来,就是在原生Android工程中集成aar即可。


三、添加Flutter依赖


3.1 添加aar依赖


官方推荐方式


集成aar包的方式和集成普通的aar包的方式是一样大的。首先,在app的目录下新建libs文件夹 并在build.gradle中添加如下配置。


android {
...

buildTypes {
profile {
initWith debug
}
}

String storageUrl = System.env.FLUTTER_STORAGE_BASE_URL ?:
"https://storage.googleapis.com"
repositories {
maven {
url '/Users/mac/Flutter/module_flutter/build/host/outputs/repo'
}
maven {
url "$storageUrl/download.flutter.io"
}
}

}

dependencies {
debugImplementation 'com.xzh.module_flutter:flutter_debug:1.0'
profileImplementation 'com.xzh.module_flutter:flutter_profile:1.0'
releaseImplementation 'com.xzh.module_flutter:flutter_release:1.0'
}

本地Libs方式


当然,我们也可以把生成的aar包拷贝到本地libs中,然后打开app/build.grade添加本地依赖,如下所示。


repositories {
flatDir {
dirs 'libs'
}
}

dependencies {
...
//添加本地依赖
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation(name: 'flutter_debug-1.0', ext: 'aar')
implementation 'io.flutter:flutter_embedding_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
implementation 'io.flutter:armeabi_v7a_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
implementation 'io.flutter:arm64_v8a_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
implementation 'io.flutter:x86_64_debug:1.0.0-f0826da7ef2d301eb8f4ead91aaf026aa2b52881'
}


io.flutter:flutter_embedding_debug来自哪里呢,其实是build/host/outputs/repo生成的时候flutter_release-1.0.pom文件中,
在这里插入图片描述


  <groupId>com.example.flutter_library</groupId>
<artifactId>flutter_release</artifactId>
<version>1.0</version>
<packaging>aar</packaging>
<dependencies>
<dependency>
<groupId>io.flutter.plugins.sharedpreferences</groupId>
<artifactId>shared_preferences_release</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.flutter</groupId>
<artifactId>flutter_embedding_release</artifactId>
<version>1.0.0-626244a72c5d53cc6d00c840987f9059faed511a</version>
<scope>compile</scope>
</dependency>

在拷贝的时候,注意我们本地aar包的环境,它们是一一对应的。接下来,为了能够正确依赖,还需要在外层的build.gradle中添加如下依赖。


buildscript {
repositories {
google()
jcenter()
maven {
url "http://download.flutter.io" //flutter依赖
}
}
dependencies {
classpath 'com.android.tools.build:gradle:4.0.0'
}
}

如果,原生Android工程使用的是组件化开发思路,通常是在某个module/lib下依赖,比如module_flutter进行添加。


 在module_flutter build.gradle下配置
repositories {
flatDir {
dirs 'libs' // aar目录
}
}

在主App 下配置
repositories {
// 详细路径
flatDir {
dirs 'libs', '../module_flutter/libs'
}
}

3.2 源码依赖


除了使用aar方式外, 我们还可以使用flutter模块源码的方式进行依赖。首先,我们在原生Android工程中创建一个module,如下图。
在这里插入图片描述
添加成功后,系统会默认在settings.gradle文件中生成如下代码。


 
include ':app'
setBinding(new Binding([gradle: this]))
evaluate(new File(
settingsDir.parentFile,
'my_flutter/.android/include_flutter.groovy'
))

然后,在app/build.gradle文件中添加源码依赖。


dependencies {
implementation project(':flutter')
}

3.3 使用 fat-aar 编译 aar


如果flutter 中引入了第三方的一些库,那么多个项目在使用flutter的时候就需要使用 fat-aar。首先,在 .android/build.gradle 中添加fat-aar 依赖。


 dependencies {
...
com.github.kezong:fat-aar:1.3.6
}


然后,在 .android/Flutter/build.gradle 中添加如下 plugin 和依赖。


dependencies {
testImplementation 'junit:junit:4.12'

// 添加 flutter_embedding.jar debug
embed "io.flutter:flutter_embedding_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
// 添加 flutter_embedding.jar release
embed "io.flutter:flutter_embedding_release:1.0.0-e1e6ced81d029258d449bdec2ba3cddca9c2ca0c"
// 添加各个 cpu 版本 flutter.so
embed "io.flutter:arm64_v8a_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
embed "io.flutter:armeabi_v7a_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
embed "io.flutter:x86_64_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"
embed "io.flutter:x86_debug:1.0.0-eed171ff3538aa44f061f3768eec3a5908e8e852"

此时,如果我们运行项目,可能会报一个Cannot fit requested classes in a single dex file的错误。这是一个很古老的分包问题,意思是dex超过65k方法一个dex已经装不下了需要个多个dex。解决的方法是,只需要在 app/build.gradle 添加multidex即可。


android {
defaultConfig {
···
multiDexEnabled true
}
}

dependencies {
//androidx支持库的multidex库
implementation 'androidx.multidex:multidex:2.0.1'
}

五、跳转Flutter


5.1 启动FlutterActivity


集成Flutter之后,接下来我们在AndroidManifest.xml中注册FlutterActivity实现一个简单的跳转。


<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"
android:exported="true" />

然后在任何页面添加一个跳转代码,比如。


myButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
startActivity(
FlutterActivity.createDefaultIntent(this)
);
}
});

不过当我运行项目,执行跳转的时候还是报错了,错误的信息如下。


   java.lang.RuntimeException: Unable to start activity ComponentInfo{com.snbc.honey_app/io.flutter.embedding.android.FlutterActivity}: java.lang.IllegalStateException: ensureInitializationComplete must be called after startInitialization
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2946)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3081)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:78)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:108)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:68)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)

看报错应该是初始化的问题,但是官方文档没有提到任何初始化步骤相关的代码,查查Flutter 官方的issue,表示要加一行初始化代码:


public class MyApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
FlutterMain.startInitialization(this);
}
}

然后,我再次运行,发现报了如下错误。


java.lang.NoClassDefFoundError: Failed resolution of: Landroid/arch/lifecycle/DefaultLifecycleObserver;
at io.flutter.embedding.engine.FlutterEngine.<init>(FlutterEngine.java:152)
at io.flutter.embedding.android.FlutterActivityAndFragmentDelegate.setupFlutterEngine(FlutterActivityAndFragmentDelegate.java:221)
at io.flutter.embedding.android.FlutterActivityAndFragmentDelegate.onAttach(FlutterActivityAndFragmentDelegate.java:145)
at io.flutter.embedding.android.FlutterActivity.onCreate(FlutterActivity.java:399)
at android.app.Activity.performCreate(Activity.java:7224)
at android.app.Activity.performCreate(Activity.java:7213)
at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1272)
at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:2926)
at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3081)
at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:78)
at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:108)
at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:68)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1831)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:201)
at android.app.ActivityThread.main(ActivityThread.java:6806)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:547)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:873)
Caused by: java.lang.ClassNotFoundException: Didn't find class "android.arch.lifecycle.DefaultLifecycleObserver" on path: DexPathList[[zip file "/data/app/com.example.myapplication-kZH0dnJ-qI1ow1NqGOB2ug==/base.apk"],nativeLibraryDirectories=[/data/app/com.example.myapplication-kZH0dnJ-qI1ow1NqGOB2ug==/lib/arm64, /data/app/com.example.myapplication-kZH0dnJ-qI1ow1NqGOB2ug==/base.apk!/lib/arm64-v8a, /system/lib64, /vendor/lib64]]

最后的日志给出的提示是lifecycle缺失,所以添加lifecycle的依赖即可,如下。


   implementation 'android.arch.lifecycle:common-java8:1.1.0'

然后再次运行就没啥问题了。
在这里插入图片描述


5.2 使用FlutterEngine启动


默认情况下,每个FlutterActivity被创建时都会创建一个FlutterEngine,每个FlutterEngine都有一个初始化操作。这意味着在启动一个标准的FlutterActivity时会有一定的延迟。为了减少此延迟,我们可以在启动FlutterActivity之前预先创建一个FlutterEngine,然后在跳转FlutterActivity时使用FlutterEngine即可。最常见的做法是在Application中先初始化FlutterEngine,比如。


class MyApplication : Application() {

lateinit var flutterEngine : FlutterEngine

override fun onCreate() {
super.onCreate()
flutterEngine = FlutterEngine(this)
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine)
}
}

然后,我们在跳转FlutterActivity时使用这个缓冲的FlutterEngine即可,由于FlutterEngine初始化的时候已经添加了engine_id,所以启动的时候需要使用这个engine_id进行启动。


myButton.setOnClickListener {
startActivity(
FlutterActivity
.withCachedEngine("my_engine_id")
.build(this)
)
}

当然,在启动的时候,我们也可以跳转一个默认的路由,只需要在启动的时候调用setInitialRoute方法即可。


class MyApplication : Application() {
lateinit var flutterEngine : FlutterEngine
override fun onCreate() {
super.onCreate()
// Instantiate a FlutterEngine.
flutterEngine = FlutterEngine(this)
// Configure an initial route.
flutterEngine.navigationChannel.setInitialRoute("your/route/here");
// Start executing Dart code to pre-warm the FlutterEngine.
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)
// Cache the FlutterEngine to be used by FlutterActivity or FlutterFragment.
FlutterEngineCache
.getInstance()
.put("my_engine_id", flutterEngine)
}
}

六、与Flutter通信


经过上面的操作,我们已经能够完成原生Android 跳转Flutter,那如何实现Flutter跳转原生Activity或者Flutter如何销毁自己返回原生页面呢?此时就用到了Flutter和原生Android的通迅机制,即Channel,分别是MethodChannel、EventChannel和BasicMessageChannel。



  • MethodChannel:用于传递方法调用,是比较常用的PlatformChannel。

  • EventChannel: 用于传递事件。

  • BasicMessageChannel:用于传递数据。


对于这种简单的跳转操作,直接使用MethodChannel即可完成。首先,我们在flutter_module中新建一个PluginManager的类,然后添加如下代码。


import 'package:flutter/services.dart';

class PluginManager {
static const MethodChannel _channel = MethodChannel('plugin_demo');

static Future<String> pushFirstActivity(Map params) async {
String resultStr = await _channel.invokeMethod('jumpToMain', params);
return resultStr;
}

}

然后,当我们点击Flutter入口页面的返回按钮时,添加一个返回的方法,主要是调用PluginManager发送消息,如下。


Future<void> backToNative() async {
String result;
try {
result = await PluginManager.pushFirstActivity({'key': 'value'});
} on PlatformException {
result = '失败';
}
print('backToNative: '+result);
}

接下来,重新使用flutter build aar重新编译aar包,并在原生Android的Flutter入口页面的configureFlutterEngine方法中添加如下代码。


class FlutterContainerActivity : FlutterActivity() {

private val CHANNEL = "plugin_demo"

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

}


override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine)
MethodChannel(flutterEngine.dartExecutor, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "jumpToMain") {
val params = call.argument<String>("key")
Toast.makeText(this,"返回原生页面",Toast.LENGTH_SHORT).show()
finish()
result.success(params)
} else {
result.notImplemented()
}
}
}

}

重新运行原生项目时,点击Flutter左上角的返回按钮就可以返回到原生页面,其他的混合跳转也可以使用这种方式进行解决。


在这里插入图片描述


关于混合开发中混合路由和FlutterEngine多实例的问题,可以参考FlutterBoost


作者:xiangzhihong
链接:https://juejin.cn/post/7044097107506626597
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

前端 PDF 水印方案

web
场景:前端下载 pdf 文件的时候,需要加上水印,再反给用户下载 用到的库:pdf-lib (文档) @pdf-lib/fontkit 字体:github 方案目标:logo图 + 中文 + 英文 + 数字 => 透明水印首先安装 pdf-lib: 它是...
继续阅读 »

场景:前端下载 pdf 文件的时候,需要加上水印,再反给用户下载
用到的库pdf-lib (文档) @pdf-lib/fontkit
字体github
方案目标:logo图 + 中文 + 英文 + 数字 => 透明水印


首先安装 pdf-lib: 它是前端创建和修改 PDF 文档的一个工具(默认不支持中文,需要加载自定义字体文件)

npm install --save pdf-lib

安装 @pdf-lib/fontkit:为 pdf-lib 加载自定义字体的工具

npm install --save @pdf-lib/fontkit

没有使用pdf.js的原因是因为:

  1. 会将 PDF 转成图片,无法选中

  2. 操作后 PDF 会变模糊

  3. 文档体积会变得异常大


实现:

首先我们的目标是在 PDF 文档中,加上一个带 logo 的,同时包含中文、英文、数字字符的透明水印,所以我们先来尝试着从本地加载一个文件,一步步搭建。

1. 获取 PDF 文件

本地:

// <input type="file" name="pdf" id="pdf-input">

let input = document.querySelector('#pdf-input');
input.onchange = onFileUpload;

// 上传文件
function onFileUpload(e) {
let event = window.event || e;

let file = event.target.files[0];
}

除了本地上传文件之外,我们也可以通过网络请求一个 PDF 回来,注意响应格式为 blob
网络:

var x = new XMLHttpRequest();
x.open("GET", url, true);
x.responseType = 'blob';
x.onload = function (e) {
let file = x.response;
}
x.send();

// 获取直接转成 pdf-lib 需要的 arrayBuffer
// const fileBytes = await fetch(url).then(res => res.arrayBuffer())

2. 文字水印

在获取到 PDF 文件数据之后,我们通过 pdf-lib 提供的接口来对文档做修改。

// 修改文档
async function modifyPdf(file) {
const pdfDoc = await PDFDocument.load(await file.arrayBuffer());

// 加载内置字体
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Courier);

// 获取文档所有页
const pages = pdfDoc.getPages();

// 文字渲染配置
const drawTextParams = {
  lineHeight: 50,
  font: helveticaFont,
  size: 12,
  color: rgb(0.08, 0.08, 0.2),
  rotate: degrees(15),
  opacity: 0.5,
};

for (let i = 0; i < pages.length; i++) {
  const page = pages[i];

  // 获取当前页宽高
  const { width, height } = page.getSize();

  // 要渲染的文字内容
  let text = "water 121314";

  for (let ix = 1; ix < width; ix += 230) { // 水印横向间隔
    let lineNum = 0;
    for (let iy = 50; iy <= height; iy += 110) { // 水印纵向间隔
      lineNum++;
       
      page.drawText(text, {
        x: lineNum & 1 ? ix : ix + 70,
        y: iy,
        ...drawTextParams,
      });
    }
  }
}

来看一下现在的效果

3. 加载本地 logo

在加载图片这块,我们最终想要的其实是图片的 Blob 数据,获取网图的话,这里就不做介绍了,下边主要着重介绍一下,如何通过 js 从本地加载一张图。
先贴上代码:

//  加载 logo blob 数据
~(function loadImg() {
let img = new Image();
img.src = "./water-logo.png";

let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");

img.crossOrigin = "";
img.onload = function () {
  canvas.width = this.width;
  canvas.height = this.height;

  ctx.fillStyle = "rgba(255, 255, 255, 1)";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.drawImage(this, 0, 0, this.width, this.height);
  canvas.toBlob(
    function (blob) {
      imgBytes = blob; // 保存数据到 imgBytes 中
    },
    "image/jpeg",
    1
  ); // 参数为输出质量
};
})();

首先通过一个自执行函数,在初期就自动加载 logo 数据,当然我们也可以根据实际情况做相应的优化。
整体的思路就是,首先通过 image 元素来加载本地资源,再将 img 渲染到 canvas 中,再通过 canvas 的 toBlob 来得到我们想要的数据。

在这块我们需要注意两行代码:

ctx.fillStyle = "rgba(255, 255, 255, 1)"; 
ctx.fillRect(0, 0, canvas.width, canvas.height);

如果我们不加这两行代码的话,同时本地图片还是透明图,最后我们得到的数据将会是一个黑色的方块。所以我们需要在 drawImage 之前,用白色填充一下 canvas 。

4. 渲染 logo

在渲染 logo 图片到 PDF 文档上之前,我们还需要和加载字体类似的,把图片数据也挂载到 pdf-lib 创建的文档对象上(pdfDoc),其中 imgBytes 是我们已经加载好的图片数据。

let _img = await pdfDoc.embedJpg(await imgBytes.arrayBuffer());

挂载完之后,做一些个性化的配置

page.drawImage(_img, {
x: lineNum & 1 ? ix - 18 : ix + 70 - 18, // 奇偶行的坐标
y: iy - 8,
width: 15,
height: 15,
opacity: 0.5,
});

5. 查看文档

这一步的思路就是先通过 pdf-lib 提供的 save 方法,得到最后的文档数据,将数据转成 Blob,最后通过 a 标签打开查看。

// 保存文档 Serialize the PDFDocument to bytes (a Uint8Array)
const pdfBytes = await pdfDoc.save();

let blobData = new Blob([pdfBytes], { type: "application/pdf;Base64" });

// 新标签页预览
let a = document.createElement("a");
a.target = "_blank";
a.href = window.URL.createObjectURL(blobData);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

到目前的效果

6. 中文字体

由于默认的 pdf-lib 是不支持渲染中文的
Uncaught (in promise) Error: WinAnsi cannot encode "水" (0x6c34)

所以我们需要加载自定义字体,但是常规的字体文件都会很大,为了使用,需要将字体文件压缩一下,压缩好的字体在文档头部,包含空格和基础的3500字符。
压缩字体用到的是 gulp-fontmin 命令行工具,不是客户端。具体压缩方法,可自行搜索。

在拿到字体之后(ttf文件),将字体文件上传到网上,再拿到其 arrayBuffer 数据。之后再结合 pdf-lib 的文档对象,对字体进行注册和挂载。同时记得将文字渲染的字体配置改过来。

// 加载自定义字体
const url = 'https://xxx.xxx/xxxx';
const fontBytes = await fetch(url).then((res) => res.arrayBuffer());

// 自定义字体挂载
pdfDoc.registerFontkit(fontkit)
const customFont = await pdfDoc.embedFont(fontBytes)

// 文字渲染配置
const drawTextParams = {
  lineHeight: 50,
  font: customFont, // 改字体配置
  size: 12,
  color: rgb(0.08, 0.08, 0.2),
  rotate: degrees(15),
  opacity: 0.5,
};

所以到现在的效果

7. 完整代码

import { PDFDocument, StandardFonts, rgb, degrees } from "pdf-lib";
import fontkit from "@pdf-lib/fontkit";

let input = document.querySelector("#pdf-input");
let imgBytes;

input.onchange = onFileUpload;

// 上传文件
function onFileUpload(e) {
let event = window.event || e;

let file = event.target.files[0];
console.log(file);
if (file.size) {
  modifyPdf(file);
}
}

// 修改文档
async function modifyPdf(file) {
const pdfDoc = await PDFDocument.load(await file.arrayBuffer());

// 加载内置字体
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Courier);

// 加载自定义字体
const url = 'pttps://xxx.xxx/xxx';
const fontBytes = await fetch(url).then((res) => res.arrayBuffer());

// 自定义字体挂载
pdfDoc.registerFontkit(fontkit)
const customFont = await pdfDoc.embedFont(fontBytes)

// 获取文档所有页
const pages = pdfDoc.getPages();

// 文字渲染配置
const drawTextParams = {
  lineHeight: 50,
  font: customFont,
  size: 12,
  color: rgb(0.08, 0.08, 0.2),
  rotate: degrees(15),
  opacity: 0.5,
};

let _img = await pdfDoc.embedJpg(await imgBytes.arrayBuffer());

for (let i = 0; i < pages.length; i++) {
  const page = pages[i];

  // 获取当前页宽高
  const { width, height } = page.getSize();

  // 要渲染的文字内容
  let text = "水印 water 121314";

  for (let ix = 1; ix < width; ix += 230) { // 水印横向间隔
    let lineNum = 0;
    for (let iy = 50; iy <= height; iy += 110) { // 水印纵向间隔
      lineNum++;
      page.drawImage(_img, {
        x: lineNum & 1 ? ix - 18 : ix + 70 - 18,
        y: iy - 8,
        width: 15,
        height: 15,
        opacity: 0.7,
      });
      page.drawText(text, {
        x: lineNum & 1 ? ix : ix + 70,
        y: iy,
        ...drawTextParams,
      });
    }
  }
}

// 保存文档 Serialize the PDFDocument to bytes (a Uint8Array)
const pdfBytes = await pdfDoc.save();

let blobData = new Blob([pdfBytes], { type: "application/pdf;Base64" });

// 新标签页预览
let a = document.createElement("a");
a.target = "_blank";
a.href = window.URL.createObjectURL(blobData);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}

// 加载 logo blob 数据
~(function loadImg() {
let img = new Image();
img.src = "./water-logo.png";

let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");

img.crossOrigin = "";
img.onload = function () {
  canvas.width = this.width;
  canvas.height = this.height;

  ctx.fillStyle = "rgba(255, 255, 255, 1)";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  ctx.drawImage(this, 0, 0, this.width, this.height);
  canvas.toBlob(
    function (blob) {
      imgBytes = blob;
    },
    "image/jpeg",
    1
  ); // 参数为输出质量
};
})();

8. 不完美的地方

当前方案虽然可以实现在前端为 PDF 加水印,但是由于时间关系,有些瑕疵还需要再进一步探索解决 💪:

  1. 水印是浮在原文本之上的,可以被选中

  2. logo 的背景虽然不注意看不到,但是实际上还未完全透明 🤔

来源:http://www.cnblogs.com/iamzhiyudong/p/14990528.html

收起阅读 »

Logstash:如何在 Elasticsearch 中查找和删除重复文档

许多将数据驱动到 Elasticsearch 中的系统将利用 Elasticsearch 为新插入的文档自动生成的 id 值。 但是,如果数据源意外地将同一文档多次发送到Elasticsearch,并且如果将这种自动生成的 id 值用于 Elasticsear...
继续阅读 »

许多将数据驱动到 Elasticsearch 中的系统将利用 Elasticsearch 为新插入的文档自动生成的 id 值。 但是,如果数据源意外地将同一文档多次发送到Elasticsearch,并且如果将这种自动生成的 id 值用于 Elasticsearch 插入的每个文档,则该同一文档将使用不同的id值多次存储在 Elasticsearch 中。 如果发生这种情况,那么可能有必要找到并删除此类重复项。 因此,在此博客文章中,我们介绍如何通过

  • 使用 Logstash

  • 使用 Python 编写的自定义代码从 Elasticsearch 中检测和删除重复文档

示例文档结构

就本博客而言,我们假设 Elasticsearch 集群中的文档具有以下结构。 这对应于包含代表股票市场交易的文档的数据集。

{
   "_index": "stocks",
   "_type": "doc",
   "_id": "6fo3tmMB_ieLOlkwYclP",
   "_version": 1,
   "found": true,
   "_source": {
       "CAC": 1854.6,
       "host": "Alexanders-MBP",
       "SMI": 2061.7,
       "@timestamp": "2017-01-09T02:30:00.000Z",
       "FTSE": 2827.5,
       "DAX": 1527.06,
       "time": "1483929000",
       "message": "1483929000,1527.06,2061.7,1854.6,2827.5\r",
       "@version": "1"
   }
}

给定该示例文档结构,出于本博客的目的,我们任意假设如果多个文档的 [“CAC”,“FTSE”,“SMI”] 字段具有相同的值,则它们是彼此重复的。

使用 Logstash 对 Elasticsearch 文档进行重复数据删除

这种方法已经在之前的文章 “Logstash:处理重复的文档” 已经描述过了。Logstash 可用于检测和删除 Elasticsearch 索引中的重复文档。 在那个文章中,我们已经对这个方法进行了详述,也做了展示。我们也无妨做一个更进一步的描述。

在下面的示例中,我编写了一个简单的 Logstash 配置,该配置从 Elasticsearch 集群上的索引读取文档,然后使用指纹过滤器根据 ["CAC", "FTSE", "SMI"] 字段的哈希值为每个文档计算唯一的 _id 值,最后将每个文档写回到同一 Elasticsearch 集群上的新索引,这样重复的文档将被写入相同的 _id 并因此被消除。

此外,通过少量修改,相同的 Logstash 过滤器也可以应用于写入新创建的索引的将来文档,以确保几乎实时删除重复项。这可以通过更改以下示例中的输入部分以接受来自实时输入源的文档,而不是从现有索引中提取文档来实现。

请注意,使用自定义 id 值(即不是由 Elasticsearch 生成的 _id)将对索引操作的[写入性能产生一些影响](https://www.elastic.co/guide/en/elasticsearch/reference/master/tune-for-indexing-speed.html#use_auto_generated_ids)。

另外,值得注意的是,根据所使用的哈希算法,此方法理论上可能会导致 id 值的[哈希冲突数](https://en.wikipedia.org/wiki/Collision(computer_science))不为零,这在理论上可能导致两个不相同的文档映射到相同的_id,因此导致这些文档之一丢失。对于大多数实际情况,哈希冲突的可能性可能非常低。对不同哈希函数的详细分析不在本博客的讨论范围之内,但是应仔细考虑指纹过滤器中使用的哈希函数,因为它将影响提取性能和哈希冲突次数。

下面给出了使用指纹过滤器对现有索引进行重复数据删除的简单 Logstash 配置。

input {
# Read all documents from Elasticsearch
elasticsearch {
  hosts => "localhost"
  index => "stocks"
  query => '{ "sort": [ "_doc" ] }'
}
}
# This filter has been updated on February 18, 2019
filter {
  fingerprint {
      key => "1234ABCD"
      method => "SHA256"
      source => ["CAC", "FTSE", "SMI"]
      target => "[@metadata][generated_id]"
      concatenate_sources => true # <-- New line added since original post date
  }
}
output {
  stdout { codec => dots }
  elasticsearch {
      index => "stocks_after_fingerprint"
      document_id => "%{[@metadata][generated_id]}"
  }
}

用于 Elasticsearch 文档重复数据删除的自定义 Python 脚本

内存有效的方法

如果不使用 Logstash,则可以使用自定义 python 脚本有效地完成重复数据删除。 对于这种方法,我们计算定义为唯一标识文档的["CAC","FTSE","SMI"] 字段的哈希值 (Hash)。 然后,我们将此哈希用作 python 字典中的键,其中每个字典条目的关联值将是映射到同一哈希的文档 _id 的数组。

如果多个文档具有相同的哈希,则可以删除映射到相同哈希的重复文档。 另外,如果你担心哈希值冲突的可能性,则可以检查映射到同一散列的文档的内容,以查看文档是否确实相同,如果是,则可以消除重复项。

检测算法分析

对于 50GB 的索引,如果我们假设索引包含平均大小为 0.4 kB 的文档,则索引中将有1.25亿个文档。 在这种情况下,使用128位 md5 哈希将重复数据删除数据结构存储在内存中所需的内存量约为128位x 125百万= 2GB 内存,再加上160位_id将需要另外160位x 125百万= 2.5 GB 的内存。 因此,此算法将需要4.5GB 的 RAM 数量级,以将所有相关的数据结构保留在内存中。 如果可以应用下一节中讨论的方法,则可以大大减少内存占用。

算法增强

在本节中,我们对算法进行了增强,以减少内存使用以及连续删除新的重复文档。

如果你要存储时间序列数据,并且知道重复的文档只会在彼此之间的一小段时间内出现,那么您可以通过在文档的子集上重复执行该算法来改善该算法的内存占用量在索引中,每个子集对应一个不同的时间窗口。例如,如果您有多年的数据,则可以在datetime字段(在过滤器上下文中以获得最佳性能)上使用范围查询,一次仅一周查看一次数据集。这将要求算法执行52次(每周一次)-在这种情况下,这种方法将使最坏情况下的内存占用减少52倍。

在上面的示例中,你可能会担心没有检测到跨星期的重复文档。假设你知道重复的文档间隔不能超过2小时。然后,您需要确保算法的每次执行都包含与之前算法执行过的最后一组文档重叠2小时的文档。对于每周示例,因此,您需要查询170小时(1周+ 2小时)的时间序列文档,以确保不会丢失任何重复项。

如果你希望持续定期从索引中清除重复的文档,则可以对最近收到的文档执行此算法。与上述逻辑相同-确保分析中包括最近收到的文档以及与稍旧的文档的足够重叠,以确保不会无意中遗漏重复项。

用于检测重复文档的 Python 代码

以下代码演示了如何可以有效地评估文档以查看它们是否相同,然后根据需要将其删除。 但是,为了防止意外删除文档,在本示例中,我们实际上并未执行删除操作。 这样的功能的实现将是非常直接的。

可以在 github 上找到用于从 Elasticsearch 中删除文档重复数据的代码。

#!/usr/local/bin/python3
import hashlib
from elasticsearch import Elasticsearch
es = Elasticsearch(["localhost:9200"])
dict_of_duplicate_docs = {}
# The following line defines the fields that will be
# used to determine if a document is a duplicate
keys_to_include_in_hash = ["CAC", "FTSE", "SMI"]
# Process documents returned by the current search/scroll
def populate_dict_of_duplicate_docs(hits):
   for item in hits:
       combined_key = ""
       for mykey in keys_to_include_in_hash:
           combined_key += str(item['_source'][mykey])
       _id = item["_id"]
       hashval = hashlib.md5(combined_key.encode('utf-8')).digest()
       # If the hashval is new, then we will create a new key
       # in the dict_of_duplicate_docs, which will be
       # assigned a value of an empty array.
       # We then immediately push the _id onto the array.
       # If hashval already exists, then
       # we will just push the new _id onto the existing array
       dict_of_duplicate_docs.setdefault(hashval, []).append(_id)
# Loop over all documents in the index, and populate the
# dict_of_duplicate_docs data structure.
def scroll_over_all_docs():
   data = es.search(index="stocks", scroll='1m',  body={"query": {"match_all": {}}})
   # Get the scroll ID
   sid = data['_scroll_id']
   scroll_size = len(data['hits']['hits'])
   # Before scroll, process current batch of hits
   populate_dict_of_duplicate_docs(data['hits']['hits'])
   while scroll_size > 0:
       data = es.scroll(scroll_id=sid, scroll='2m')
       # Process current batch of hits
       populate_dict_of_duplicate_docs(data['hits']['hits'])
       # Update the scroll ID
       sid = data['_scroll_id']
       # Get the number of results that returned in the last scroll
       scroll_size = len(data['hits']['hits'])
def loop_over_hashes_and_remove_duplicates():
   # Search through the hash of doc values to see if any
   # duplicate hashes have been found
   for hashval, array_of_ids in dict_of_duplicate_docs.items():
     if len(array_of_ids) > 1:
       print("********** Duplicate docs hash=%s **********" % hashval)
       # Get the documents that have mapped to the current hashval
       matching_docs = es.mget(index="stocks", doc_type="doc", body={"ids": array_of_ids})
       for doc in matching_docs['docs']:
           # In this example, we just print the duplicate docs.
           # This code could be easily modified to delete duplicates
           # here instead of printing them
           print("doc=%s\n" % doc)
def main():
   scroll_over_all_docs()
   loop_over_hashes_and_remove_duplicates()
main()

结论

在此博客文章中,我们展示了两种在 Elasticsearch 中对文档进行重复数据删除的方法。 第一种方法使用 Logstash 删除重复的文档,第二种方法使用自定义的 Python 脚本查找和删除重复的文档。

来源:https://blog.csdn.net/UbuntuTouch/article/details/106643400

原文: How to Find and Remove Duplicate Documents in Elasticsearch | Elastic Blog

收起阅读 »

盘点程序员写过的惊天Bug:亏损30亿、致6人死亡,甚至差点毁灭世界

一个Bug就地蒸发5亿美元;软件设计层面出Bug致6人死亡;DeBug不成功直接世界毁灭。你职业生涯中写过最大的Bug是什么?在这个问题上,勇敢的码农们,总是能不断地创造奇迹。这不禁让路过的一位普通市民感叹:感觉有你们,我们还活在这个世界就像死神来了Bug很大...
继续阅读 »

一个Bug就地蒸发5亿美元;

软件设计层面出Bug致6人死亡;

DeBug不成功直接世界毁灭。

你职业生涯中写过最大的Bug是什么?

在这个问题上,勇敢的码农们,总是能不断地创造奇迹。

这不禁让路过的一位普通市民感叹:

感觉有你们,我们还活在这个世界就像死神来了

Bug很大,你忍一下

一个Bug到底能有多大?

几个历史数据转储逻辑Bug或发货逻辑Bug,就能让几十万轻松蒸发:


你们这亏钱的Bug都洒洒水啦,写Bug差点进去的见过没?

马上就有码农站出来表示不服,并表示自己参与开发的一款发薪软件曾出现Bug,会导致发放的薪资变成双倍,总共能多发2000多万

当时查出Bug的时候发薪单已经生成,就差批量任务向银行发起请求了!


奇怪的胜负心就这么燃起来了。

一时间,什么水闸关不住、高铁追尾、甚至差点导致非洲国家内战的Bug都来了。


如果再放眼全球,你就会发现——Bug没有最大,只有更大。

2016年时,Excel就出过一个致使上万份遗传基因学论文出错的Bug。

很多长得像日期表达的长基因名的缩写(比如SEPT2、MARCH1),会在这一Bug的作用下被Excel自动转化成日期格式:


学术领域之外的Bug那就更牛逼了。

比如在1996年,欧洲运载火箭Ariane 5在发射37秒后当场爆炸。

一瞬间,70亿美元的开发费用全部木大,5亿美元的设备原地蒸发。

这一切都由一个整数溢出(Integer Overflow)的Bug引起。


而如果翻开维基百科上的这份专门统计历史上造成严重后果的Bug清单,沿着12个类别一个一个找下去,就会发现——

几乎每一条Bug的背后都存在着千万上亿的金钱损失。


有时,甚至会带来意外死亡。

1985年到1987年间,由加拿大AECL公司开发的Therac-25放射线疗法机器在软件互锁机制上出现了Bug,从而使辐射能量变成了正常剂量的100倍

最终,至少有6名来自美国和加拿大地区的患者由于遭受过量辐射而意外死亡。


还有差点引发全球核战争的Bug:1983年苏联核警报误报事件


苏联军官Stanislav Yevgrafovich Petrov

在那一年的9月26日,苏联的雷达监测到了5枚自美军基地发射而来的导弹。

而上图的这位苏联军官权衡再三,最终将这一导弹攻击警告判断为误报,并没有按照规定向上级汇报并申请反击。

事实证明,这次DeBug成功避免了地球Online在1983年就发生重启。

“不是Bug是特性”

看完了上面那些惊天大活儿,瞬间觉得邮件/短信连环CALL这种Bug都温柔了许多。

像这种由于抽奖程序Bug导致的社死,好像也不是个事儿了:


而影响力又大,又没有造成严重损失,甚至让用户拍手叫好的Bug也不是没有。

比如一到游戏圈,Bug就会自动改名为特性


原神鱼竿Bug

某些知名游戏大厂甚至还会联名发布Bug马克杯,玩梗玩得飞起。


还有玩家真情实感地表示:Bug正是游戏复杂规则和交互的体现,我游YYDS!


《矮人要塞》猫咪离奇死亡事件

甚至在游戏行业之外,还有用户在Bug被修复后愤怒投诉:


图源知乎答主三和四保

最后,再回到“你的程序员生涯中写过的最大Bug”这一问题上来。

有回答选择直接结束比赛:

你们的程序员生涯中写过的最大Bug是什么?——当初选择了做程序员。


软件Bug清单:
https://en.wikipedia.org/wiki/List_of_software_Bugs

参考链接:
https://www.zhihu.com/question/482967292

来源:量子位

收起阅读 »

【集成教程】环信Android UI库导入并实现一些基础功能

EaseIMKit 是什么?

EaseIMKit 是基于环信 IM SDK 的一款 UI 组件库,它提供了一些通用的 UI 组件,例如‘会话列表’、‘聊天界面’和‘联系人列表’等,开发者可根据实际业务需求通过该组件库快速地搭建自定义 IM 应用。EaseIMKit 中的组件在实现 UI 功能的同时,调用 IM SDK 相应的接口实现 IM 相关逻辑和数据的处理,因而开发者在使用EaseIMKit 时只需关注自身业务或个性化扩展即可。

下面详细教大家如何导入环信UI库并实现以下基础功能。

一、 如何修改会话列表(ConversationListFragment)的整体样式
二、如何修改会话条目大标题颜色和小标题颜色
三、如何去掉发送语音时未读的红色圆点
四、如何修改emoji图片
五、如何修改名片消息ui布局
六、发送视频更改ui布局
七、如何修改会话条目分割线宽高
八、如何修改气泡颜色

导入

如果是刚开始集成的小伙伴,建议sdk的版本号和ui库的版本号保持一致

1.首先我们打开环信Android端文档,点击Easeimkit使用指南



2.在简介下方有EaseIMKit源码地址



3.Github地址上点击tags,我们来找自己对应的版本号




4.点击我们sdk对应的版本号进行下载



5.以moudel的形式将easeimkit ui库导入



6.修改build.gradle中的远程库,红色圈中正常我们不引入ui库的话就是需要将注释的依赖正常打开,如果我们导入ui库格式应
api (project(path: ':ease-im-kit'))
黄色方圈中为Easemob的SDK的依赖




7.将settings.gradle 的
include ':ease-im-kit'
设置上去




8.导入成功我们就可以看到这个就大功告成了



实现基础功能

一、 如何修改会话列表(ConversationListFragment)的整体样式
按照个人需求自定义添加背景即可
(ease_conversation_list)




二、如何修改会话条目大标题颜色和小标题颜色(EaseConversationListLayout)
此标题控制的是会话条目上面的大标题和小的文本内容,上方包含用户的昵称下方包含用户聊天内容,具体参考注释处自定义更改



三、如何去掉发送语音时未读的红色圆点里面也同时包含了发送语音的背景颜色以及样式可以个性化的进行修改
接收方为(ease_row_received_voice) 发送方(ease_rwo_sent_voice)



四、如何修改emoji图片

EaseDefaultEmojiconDatas



五、如何修改名片消息ui布局
demo_activity_send_user_card为发送方



六、发送视频更改ui布局
发送方:ease_row_sent_video 接收方:ease_row_received_video



七、如何修改会话条目分割线宽高
ease_item_row_chat_history



八、如何修改气泡颜色
接收方ease_row_received_message ,发送方ease_row_sent_message



美团动态线程池实践思路,开源了

写在前面 稍微有些Java编程经验的小伙伴都知道,Java的精髓在juc包,这是大名鼎鼎的Doug Lea老爷 子的杰作,评价一个程序员Java水平怎么样,一定程度上看他对juc包下的一些技术掌握的怎么样,这也是面试中的基本上必问的一些技术点之一。 juc包主...
继续阅读 »

写在前面


稍微有些Java编程经验的小伙伴都知道,Java的精髓在juc包,这是大名鼎鼎的Doug Lea老爷
子的杰作,评价一个程序员Java水平怎么样,一定程度上看他对juc包下的一些技术掌握的怎么样,这也是面试中的基本上必问的一些技术点之一。


juc包主要包括:



1.原子类(AtomicXXX)


2.锁类(XXXLock)


3.线程同步类(AQS、CountDownLatch、CyclicBarrier、Semaphore、Exchanger)


4.任务执行器类(Executor体系类,包括今天的主角ThreadPoolExecutor)


5.并发集合类(ConcurrentXXX、CopyOnWriteXXX)相关集合类


6.阻塞队列类(BlockingQueue继承体系类)


7.Future相关类


8.其他一些辅助工具类



多线程编程场景下,这些类都是必备技能,会这些可以帮助我们写出高质量、高性能、少bug的代码,同时这些也是Java中比较难啃的一些技术,需要持之以恒,学以致用,在使用中感受他们带来的奥妙。


上边简单罗列了下juc包下功能分类,这篇文章我们主要来介绍动态可监控线程池的,所以具体内容也就不展开讲了,以后有时间单独来聊吧。看这篇文章前,希望读者最好有一定的线程池ThreadPoolExecutor使用经验,不然看起来会有点懵。


如果你对ThreadPoolExecutor不是很熟悉,推荐阅读下面两篇文章


javadoop: http://www.javadoop.com/post/java-t…


美团技术博客: tech.meituan.com/2020/04/02/…




背景


使用ThreadPoolExecutor过程中你是否有以下痛点呢?



1.代码中创建了一个ThreadPoolExecutor,但是不知道那几个核心参数设置多少比较合适


2.凭经验设置参数值,上线后发现需要调整,改代码重启服务,非常麻烦


3.线程池相对开发人员来说是个黑盒,运行情况不能感知到,直到出现问题



如果你有以上痛点,这篇文章要介绍的动态可监控线程池(DynamicTp)或许能帮助到你。


如果看过ThreadPoolExecutor的源码,大概可以知道其实它有提供一些set方法,可以在运行时动态去修改相应的值,这些方法有:


public void setCorePoolSize(int corePoolSize);
public void setMaximumPoolSize(int maximumPoolSize);
public void setKeepAliveTime(long time, TimeUnit unit);
public void setThreadFactory(ThreadFactory threadFactory);
public void setRejectedExecutionHandler(RejectedExecutionHandler handler);

现在大多数的互联网项目其实都会微服务化部署,有一套自己的服务治理体系,微服务组件中的分布式配置中心扮演的就是动态修改配置,实时生效的角色。那么我们是否可以结合配置中心来做运行时线程池参数的动态调整呢?答案是肯定的,而且配置中心相对都是高可用的,使用它也不用过于担心配置推送出现问题这类事儿,而且也能减少研发动态线程池组件的难度和工作量。


综上,我们总结出以下的背景



  • 广泛性:在Java开发中,想要提高系统性能,线程池已经是一个90%以上的人都会选择使用的基础工具

  • 不确定性:项目中可能会创建很多线程池,既有IO密集型的,也有CPU密集型的,但线程池的参数并不好确定;需要有套机制在运行过程中动态去调整参数

  • 无感知性,线程池运行过程中的各项指标一般感知不到;需要有套监控报警机制在事前、事中就能让开发人员感知到线程池的运行状况,及时处理

  • 高可用性,配置变更需要及时推送到客户端;需要有高可用的配置管理推送服务,配置中心是现在大多数互联网系统都会使用的组件,与之结合可以大幅度减少开发量及接入难度




简介


我们基于配置中心对线程池ThreadPoolExecutor做一些扩展,实现对运行中线程池参数的动态修改,实时生效;以及实时监控线程池的运行状态,触发设置的报警策略时报警,报警信息会推送办公平台(钉钉、企微等)。报警维度包括(队列容量、线程池活性、拒绝触发等);同时也会定时采集线程池指标数据供监控平台可视化使用。使我们能时刻感知到线程池的负载,根据情况及时调整,避免出现问题影响线上业务。


    |  __ \                            (_) |__   __|
| | | |_ _ _ __ __ _ _ __ ___ _ ___| |_ __
| | | | | | | '_ \ / _` | '_ ` _ | |/ __| | '_ \
| |__| | |_| | | | | (_| | | | | | | | (__| | |_) |
|_____/ __, |_| |_|__,_|_| |_| |_|_|___|_| .__/
__/ | | |
|___/ |_|
:: Dynamic Thread Pool ::

特性



  • 参考美团线程池实践 ,对线程池参数动态化管理,增加监控、报警功能

  • 基于Spring框架,现只支持SpringBoot项目使用,轻量级,引入starter即可食用

  • 基于配置中心实现线程池参数动态调整,实时生效;集成主流配置中心,默认支持Nacos、Apollo,同时也提供SPI接口可自定义扩展实现

  • 内置通知报警功能,提供多种报警维度(配置变更通知、活性报警、容量阈值报警、拒绝策略触发报警),默认支持企业微信、钉钉报警,同时提供SPI接口可自定义扩展实现

  • 内置线程池指标采集功能,支持通过MicroMeter、JsonLog日志输出、Endpoint三种方式,可通过SPI接口自定义扩展实现

  • 集成管理常用第三方组件的线程池,已集成SpringBoot内置WebServer(Tomcat、Undertow、Jetty)的线程池管理




架构设计


主要分四大模块




  • 配置变更监听模块:


    1.监听特定配置中心的指定配置文件(默认实现Nacos、Apollo),可通过内部提供的SPI接口扩展其他实现


    2.解析配置文件内容,内置实现yml、properties配置文件的解析,可通过内部提供的SPI接口扩展其他实现


    3.通知线程池管理模块实现刷新




  • 线程池管理模块:


    1.服务启动时从配置中心拉取配置信息,生成线程池实例注册到内部线程池注册中心中


    2.监听模块监听到配置变更时,将变更信息传递给管理模块,实现线程池参数的刷新


    3.代码中通过getExecutor()方法根据线程池名称来获取线程池对象实例




  • 监控模块:


    实现监控指标采集以及输出,默认提供以下三种方式,也可通过内部提供的SPI接口扩展其他实现


    1.默认实现Json log输出到磁盘


    2.MicroMeter采集,引入MicroMeter相关依赖


    3.暴雷Endpoint端点,可通过http方式访问




  • 通知告警模块:


    对接办公平台,实现通告告警功能,默认实现钉钉、企微,可通过内部提供的SPI接口扩展其他实现,通知告警类型如下


    1.线程池参数变更通知


    2.阻塞队列容量达到设置阈值告警


    3.线程池活性达到设置阈值告警


    4.触发拒绝策略告警







使用



  • maven依赖



  1. apollo应用用接入用此依赖
        <dependency>
    <groupId>io.github.lyh200</groupId>
    <artifactId>dynamic-tp-spring-boot-starter-apollo</artifactId>
    <version>1.0.0</version>
    </dependency>


  2. spring-cloud场景下的nacos应用接入用此依赖
        <dependency>
    <groupId>io.github.lyh200</groupId>
    <artifactId>dynamic-tp-spring-cloud-starter-nacos</artifactId>
    <version>1.0.0</version>
    </dependency>


  3. 非spring-cloud场景下的nacos应用接入用此依赖
        <dependency>
    <groupId>io.github.lyh200</groupId>
    <artifactId>dynamic-tp-spring-boot-starter-nacos</artifactId>
    <version>1.0.0</version>
    </dependency>





  • 线程池配置


    spring:
    dynamic:
    tp:
    enabled: true
    enabledBanner: true # 是否开启banner打印,默认true
    enabledCollect: false # 是否开启监控指标采集,默认false
    collectorType: logging # 监控数据采集器类型(JsonLog | MicroMeter),默认logging
    logPath: /home/logs # 监控日志数据路径,默认${user.home}/logs
    monitorInterval: 5 # 监控时间间隔(报警判断、指标采集),默认5s
    nacos: # nacos配置,不配置有默认值(规则name-dev.yml这样)
    dataId: dynamic-tp-demo-dev.yml
    group: DEFAULT_GROUP
    apollo: # apollo配置,不配置默认拿apollo配置第一个namespace
    namespace: dynamic-tp-demo-dev.yml
    configType: yml # 配置文件类型
    platforms: # 通知报警平台配置
    - platform: wechat
    urlKey: 3a7500-1287-4bd-a798-c5c3d8b69c # 替换
    receivers: test1,test2 # 接受人企微名称
    - platform: ding
    urlKey: f80dad441fcd655438f4a08dcd6a # 替换
    secret: SECb5441fa6f375d5b9d21 # 替换,非sign模式可以没有此值
    receivers: 15810119805 # 钉钉账号手机号
    tomcatTp: # tomcat web server线程池配置
    minSpare: 100
    max: 400
    jettyTp: # jetty web server线程池配置
    min: 100
    max: 400
    undertowTp: # undertow web server线程池配置
    ioThreads: 100
    workerThreads: 400
    executors: # 动态线程池配置
    - threadPoolName: dynamic-tp-test-1
    corePoolSize: 6
    maximumPoolSize: 8
    queueCapacity: 200
    queueType: VariableLinkedBlockingQueue # 任务队列,查看源码QueueTypeEnum枚举类
    rejectedHandlerType: CallerRunsPolicy # 拒绝策略,查看RejectedTypeEnum枚举类
    keepAliveTime: 50
    allowCoreThreadTimeOut: false
    threadNamePrefix: test # 线程名前缀
    notifyItems: # 报警项,不配置自动会配置(变更通知、容量报警、活性报警、拒绝报警)
    - type: capacity # 报警项类型,查看源码 NotifyTypeEnum枚举类
    enabled: true
    threshold: 80 # 报警阈值
    platforms: [ding,wechat] # 可选配置,不配置默认拿上层platforms配置的所以平台
    interval: 120 # 报警间隔(单位:s)
    - type: change
    enabled: true
    - type: liveness
    enabled: true
    threshold: 80
    - type: reject
    enabled: true
    threshold: 1



  • 代码方式生成,服务启动会自动注册


    @Configuration
    public class DtpConfig {

    @Bean
    public DtpExecutor demo1Executor() {
    return DtpCreator.createDynamicFast("demo1-executor");
    }

    @Bean
    public ThreadPoolExecutor demo2Executor() {
    return ThreadPoolBuilder.newBuilder()
    .threadPoolName("demo2-executor")
    .corePoolSize(8)
    .maximumPoolSize(16)
    .keepAliveTime(50)
    .allowCoreThreadTimeOut(true)
    .workQueue(QueueTypeEnum.SYNCHRONOUS_QUEUE.getName(), null, false)
    .rejectedExecutionHandler(RejectedTypeEnum.CALLER_RUNS_POLICY.getName())
    .buildDynamic();
    }
    }



  • 代码调用,根据线程池名称获取


    public static void main(String[] args) {
    DtpExecutor dtpExecutor = DtpRegistry.getExecutor("dynamic-tp-test-1");
    dtpExecutor.execute(() -> System.out.println("test"));
    }





注意事项




  1. 配置文件配置的参数会覆盖通过代码生成方式配置的参数




  2. 阻塞队列只有VariableLinkedBlockingQueue类型可以修改capacity,该类型功能和LinkedBlockingQueue相似,只是capacity不是final类型,可以修改,




VariableLinkedBlockingQueue参考RabbitMq的实现




  1. 启动看到如下日志输出证明接入成功



    | __ \ (_) |__ __|
    | | | |_ _ _ __ __ _ _ __ ___ _ ___| |_ __
    | | | | | | | '_ \ / _` | '_ ` _ | |/ __| | '_ \
    | |__| | |_| | | | | (_| | | | | | | | (__| | |_) |
    |_____/ __, |_| |_|__,_|_| |_| |_|_|___|_| .__/
    __/ | | |
    |___/ |_|
    :: Dynamic Thread Pool ::

    DynamicTp register, executor: DtpMainPropWrapper(dtpName=dynamic-tp-test-1, corePoolSize=6, maxPoolSize=8, keepAliveTime=50, queueType=VariableLinkedBlockingQueue, queueCapacity=200, rejectType=RejectedCountableCallerRunsPolicy, allowCoreThreadTimeOut=false)



  2. 配置变更会推送通知消息,且会高亮变更的字段



    DynamicTp [dynamic-tp-test-2] refresh end, changed keys: [corePoolSize, queueCapacity], corePoolSize: [6 => 4], maxPoolSize: [8 => 8], queueType: [VariableLinkedBlockingQueue => VariableLinkedBlockingQueue], queueCapacity: [200 => 2000], keepAliveTime: [50s => 50s], rejectedType: [CallerRunsPolicy => CallerRunsPolicy], allowsCoreThreadTimeOut: [false => false]





通知报警


触发报警阈值会推送相应报警消息,且会高亮显示相关字段,活性告警、容量告警、拒绝告警



配置变更会推送通知消息,且会高亮变更的字段





监控日志


通过主配置文件collectType属性配置指标采集类型,默认值:logging



  • micrometer方式:通过引入micrometer相关依赖采集到相应的平台


(如Prometheus,InfluxDb...)




  • logging:指标数据以json格式输出日志到磁盘,地址logPath/dynamictp/{logPath}/ dynamictp/{appName}.monitor.log


    2022-01-16 15:25:20.599 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":100,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":10,"taskCount":120,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":1078,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:25.603 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":120,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":20,"taskCount":140,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":1459,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:30.609 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":140,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":89,"taskCount":180,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":1890,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:35.613 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":160,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":99,"taskCount":230,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":2780,"dtpName":"remoting-call","maximumPoolSize":8}
    2022-01-16 15:25:40.616 INFO [dtp-monitor-thread-1:d.m.log] {"activeCount":2,"queueSize":230,"largestPoolSize":4,"poolSize":2,"rejectHandlerName":"CallerRunsPolicy","queueCapacity":1024,"fair":false,"rejectCount":0,"waitTaskCount":0,"taskCount":300,"queueRemainingCapacity":1024,"corePoolSize":6,"queueType":"VariableLinkedBlockingQueue","completedTaskCount":4030,"dtpName":"remoting-call","maximumPoolSize":8}



  • 暴露EndPoint端点(dynamic-tp),可以通过http方式请求


    [
    {
    "dtp_name": "remoting-call",
    "core_pool_size": 8,
    "maximum_pool_size": 16,
    "queue_type": "SynchronousQueue",
    "queue_capacity": 0,
    "queue_size": 0,
    "fair": false,
    "queue_remaining_capacity": 0,
    "active_count": 2,
    "task_count": 2760,
    "completed_task_count": 2760,
    "largest_pool_size": 16,
    "pool_size": 8,
    "wait_task_count": 0,
    "reject_count": 12462,
    "reject_handler_name": "CallerRunsPolicy"
    }
    ]




作者:CodeFox
链接:https://juejin.cn/post/7063408526894301192
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

ARouter原理解析分享

前言 炎炎夏日,不知道大家的周末会是如何安排。本文将给大家带来的分享是ARouter的的原理介绍,通过了解它的原理,我们可以知道它是如何支持组件化和不互相依赖的模块间进行调用或者页面的跳转。 正文 1.ARouter介绍 ARouter是阿里开源的一个用于进行...
继续阅读 »

前言


炎炎夏日,不知道大家的周末会是如何安排。本文将给大家带来的分享是ARouter的的原理介绍,通过了解它的原理,我们可以知道它是如何支持组件化和不互相依赖的模块间进行调用或者页面的跳转。


正文


1.ARouter介绍


ARouter是阿里开源的一个用于进行组件化的路由框架,它可以帮助互不依赖的组件间进行页面跳转和服务调用。


2.ARouter使用


添加依赖:


android {
//...
defaultConfig {
kapt {
arguments {
arg("AROUTER_MODULE_NAME", project.getName())
}
}
}
//...
}

dependencies {
api 'com.alibaba:arouter-api:1.5.0'
kapt 'com.alibaba:arouter-compiler:1.2.2'
}

定义跳转Activity的path:


@Route(path = "/test/router_activity")
class RouterActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_router)
}
}

初始化Router框架:


class RouterDemoApp : Application() {
override fun onCreate() {
super.onCreate()
//初始化、注入
ARouter.init(this)
}
}

调用跳转:


ARouter.getInstance().build("/test/router_activity").navigation()

3.生成的代码(生成的路由表)


当我们给Activity或者服务等加上Route注解后,build一下,ARouter框架便会按照模版帮我们生成java文件,并且是在运行的时候可以访问的。其中使用的技术是apt技术。下面我们一起看看上边的示例生成的代码:


public class ARouter$$Root$$app implements IRouteRoot {
@Override
public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
routes.put("test", ARouter$$Group$$test.class);
}
}

public class ARouter$$Group$$test implements IRouteGroup {
@Override
public void loadInto(Map<String, RouteMeta> atlas) {
atlas.put("/test/router_activity", RouteMeta.build(RouteType.ACTIVITY, RouterActivity.class, "/test/router_activity", "test", null, -1, -2147483648));
}
}

根据上边生成的代码可以看出,生成的代码就是一张路由表,先将群组跟群组的class对应起来,每个群组里边是该群组下的路由表。


4.初始化init()(加载路由表的群组)


接下来我们看看初始化时,路由框架里边做了哪些事情:


//#ARouter
public static void init(Application application) {
if (!hasInit) {
//...省略部分代码
hasInit = _ARouter.init(application);
//...省略部分代码
}
}

//#_ARouter
protected static synchronized boolean init(Application application) {
mContext = application;
LogisticsCenter.init(mContext, executor);
logger.info(Consts.TAG, "ARouter init success!");
hasInit = true;
mHandler = new Handler(Looper.getMainLooper());
return true;
}

初始化的核心代码看起来就在LogisticsCenter中:


public synchronized static void init(Context context, ThreadPoolExecutor tpe) throws HandlerException {
mContext = context;
executor = tpe;

try {
//...省略代码
if (registerByPlugin) {
//...省略代码
} else {
Set<String> routerMap;

// 如果是debug包或者更新版本
if (ARouter.debuggable() || PackageUtils.isNewVersion(context)) {
//获取在com.alibaba.android.arouter.routes下的所以class类名
routerMap = ClassUtils.getFileNameByPackageName(mContext, ROUTE_ROOT_PAKCAGE);
//更新到sp中
if (!routerMap.isEmpty()) {
context.getSharedPreferences(AROUTER_SP_CACHE_KEY, Context.MODE_PRIVATE).edit().putStringSet(AROUTER_SP_KEY_MAP, routerMap).apply();
}
//更新版本
PackageUtils.updateVersion(context);
} else {
//直接从缓存拿出之前存放的class
routerMap = new HashSet<>(context.getSharedPreferences(AROUTER_SP_CACHE_KEY, Context.MODE_PRIVATE).getStringSet(AROUTER_SP_KEY_MAP, new HashSet<String>()));
}

//遍历routerMap,将group的类加载到缓存中
for (String className : routerMap) {
if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_ROOT)) {
//生成的Root、比如我们上面示例的ARouter$$Root$$app,调用loadInto相当于加载了routes.put("test", ARouter$$Group$$test.class)
((IRouteRoot) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.groupsIndex);
} else if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_INTERCEPTORS)) {
//加载拦截器,例如生成的ARouter$$Interceptors$$app
((IInterceptorGroup) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.interceptorsIndex);
} else if (className.startsWith(ROUTE_ROOT_PAKCAGE + DOT + SDK_NAME + SEPARATOR + SUFFIX_PROVIDERS)) {
// 加载Provider,例如生成的ARouter$$Providers$$app
((IProviderGroup) (Class.forName(className).getConstructor().newInstance())).loadInto(Warehouse.providersIndex);
}
}
}
//...省略代码
} catch (Exception e) {
throw new HandlerException(TAG + "ARouter init logistics center exception! [" + e.getMessage() + "]");
}
}

上边的核心逻辑就是如果是debug包或者更新版本,那么就去获取com.alibaba.android.arouter.routes下的所以class类名,然后更新到sp中,并且更新版本号。然后通过反射加载IRouteRoot,去加载群组及对应的class对象,除此还会加载拦截器,Provider。


这里我们重点看一下获取class文件路径的方法getFileNameByPackageName:


public static Set<String> getFileNameByPackageName(Context context, final String packageName) throws NameNotFoundException, IOException, InterruptedException {
final Set<String> classNames = new HashSet();
//获取到dex文件路径
List<String> paths = getSourcePaths(context);
final CountDownLatch parserCtl = new CountDownLatch(paths.size());
Iterator var5 = paths.iterator();

while(var5.hasNext()) {
final String path = (String)var5.next();
DefaultPoolExecutor.getInstance().execute(new Runnable() {
public void run() {
DexFile dexfile = null;
try {
//加载出dexfile文件
if (path.endsWith(".zip")) {
dexfile = DexFile.loadDex(path, path + ".tmp", 0);
} else {
dexfile = new DexFile(path);
}

Enumeration dexEntries = dexfile.entries();
// 遍历dexFile里边的元素,加载出.class文件
while(dexEntries.hasMoreElements()) {
String className = (String)dexEntries.nextElement();
//开头"com.alibaba.android.arouter.routes"
if (className.startsWith(packageName)) {
classNames.add(className);
}
}
} catch (Throwable var12) {
Log.e("ARouter", "Scan map file in dex files made error.", var12);
} finally {
//...省略代码
parserCtl.countDown();
}
}
});
}

parserCtl.await();
//。。。省略代码
return classNames;
}

此方法里边的核心逻辑就是加载出dex文件的路径,然后通过路径构建出DexFile,构建后遍历它里边的元素,如果是com.alibaba.android.arouter.routes开头的class文件,则保存到列表里等待返回。


getSourcePaths:


public static List<String> getSourcePaths(Context context) throws NameNotFoundException, IOException {
ApplicationInfo applicationInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
File sourceApk = new File(applicationInfo.sourceDir);
List<String> sourcePaths = new ArrayList();
sourcePaths.add(applicationInfo.sourceDir);
String extractedFilePrefix = sourceApk.getName() + ".classes";
//是否开启了multidex,如果开启的话,则需获取每个dex路径
if (!isVMMultidexCapable()) {
int totalDexNumber = getMultiDexPreferences(context).getInt("dex.number", 1);
File dexDir = new File(applicationInfo.dataDir, SECONDARY_FOLDER_NAME);
//遍历每一个dex文件
for(int secondaryNumber = 2; secondaryNumber <= totalDexNumber; ++secondaryNumber) {
//app.classes2.zip、app.classes3.zip ...
String fileName = extractedFilePrefix + secondaryNumber + ".zip";
File extractedFile = new File(dexDir, fileName);
if (!extractedFile.isFile()) {
throw new IOException("Missing extracted secondary dex file '" + extractedFile.getPath() + "'");
}
sourcePaths.add(extractedFile.getAbsolutePath());
}
}

if (ARouter.debuggable()) {
sourcePaths.addAll(tryLoadInstantRunDexFile(applicationInfo));
}

return sourcePaths;
}

getSourcePaths的功能就是获取app的所有dex文件的路径,为后面转成class文件从而获取class文件路径提供数据。


小结:



  • ARouter.init(this)调用交给了内部的_ARouter.init(application),然后真正做事的是LogisticsCenter.init(mContext, executor)

  • 如果是debug包或者升级版本,则去加载出com.alibaba.android.arouter.routes包下的dex文件的路径,并且更新到缓存里边

  • 通过这些dex去获取对应的所有class文件的路径

  • 最后根据类名的前缀加载到Warehouse中对应的map里,其中就有group、interceptor和provider


5.调用及处理


ARouter.getInstance().build("/test/router_activity").navigation()

build会构建一个Postcard对象出来:


//#Router
public Postcard build(String path) {
return _ARouter.getInstance().build(path);
}

//#_ARouter
protected Postcard build(String path) {
if (TextUtils.isEmpty(path)) {
throw new HandlerException(Consts.TAG + "Parameter is invalid!");
} else {
PathReplaceService pService = ARouter.getInstance().navigation(PathReplaceService.class);
if (null != pService) {
path = pService.forString(path);
}
//extractGroup方法就是从path中提取出group,比如"/test/router_activity",test便是提取出来的group
return build(path, extractGroup(path));
}
}

build(path, group)方法最终会构建一个Postcard对象出来。


构建好PostCard之后,调用它的navigation方法便可以实现我们的跳转或者获取对应的实体。navigation方法最后会调用到_ARouter的navigation方法:


protected Object navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
//...省略代码
try {
//1.根据postCard的group加载路由表,并且补全postCard的信息
LogisticsCenter.completion(postcard);
} catch (NoRouteFoundException ex) {
//...异常处理
return null;
}
if (null != callback) {
callback.onFound(postcard);
}

//如果不是绿色通道的话,需要走拦截器的逻辑,否则会跳过拦截器
if (!postcard.isGreenChannel()) {
interceptorService.doInterceptions(postcard, new InterceptorCallback() {
@Override
public void onContinue(Postcard postcard) {
//2.真正实现动作处理
_navigation(context, postcard, requestCode, callback);
}
@Override
public void onInterrupt(Throwable exception) {
if (null != callback) {
callback.onInterrupt(postcard);
}
//...省略代码
}
});
} else {
//2.真正实现动作处理
return _navigation(context, postcard, requestCode, callback);
}
return null;
}

navigation方法的核心逻辑为:加载路由表,并且补全postCard的信息,然后真正处理跳转或者请求逻辑。


LogisticsCenter.completion(postcard)的核心源码如下:


public synchronized static void completion(Postcard postcard) {
if (null == postcard) {
throw new NoRouteFoundException(TAG + "No postcard!");
}

RouteMeta routeMeta = Warehouse.routes.get(postcard.getPath());
if (null == routeMeta) {
//groupsIndex在init的时候已经加载好了,这里就可以通过group获取到对应group的class对象
Class<? extends IRouteGroup> groupMeta = Warehouse.groupsIndex.get(postcard.getGroup()); // Load route meta.
if (null == groupMeta) {
throw new NoRouteFoundException(TAG + "There is no route match the path [" + postcard.getPath() + "], in group [" + postcard.getGroup() + "]");
} else {
// Load route and cache it into memory, then delete from metas.
try {
//...省略代码
IRouteGroup iGroupInstance = groupMeta.getConstructor().newInstance();
//将group里边的路由表加载进内存,我们最开始的例子想当于执行:atlas.put("/test/router_activity", RouteMeta.build(RouteType.ACTIVITY, RouterActivity.class, "/test/router_activity", "test", null, -1, -2147483648));
iGroupInstance.loadInto(Warehouse.routes);
//因为加载路由表了,所以可以将group从内存中移除,节省内存
Warehouse.groupsIndex.remove(postcard.getGroup());
//...省略代码
} catch (Exception e) {
throw new HandlerException(TAG + "Fatal exception when loading group meta. [" + e.getMessage() + "]");
}
//已经将group里的路由表加载出来了,再执行一遍函数。
completion(postcard); // Reload
}
} else {
// 第二次时,给postCard填补信息
postcard.setDestination(routeMeta.getDestination());
postcard.setType(routeMeta.getType());
postcard.setPriority(routeMeta.getPriority());
postcard.setExtra(routeMeta.getExtra());
//...省略代码,主要是解析uri然后参数的赋值

//根据路由获取的不同的类型,继续补充一些信息给postCard
switch (routeMeta.getType()) {
case PROVIDER:
//...省略代码,主要是补充一些其他参数
postcard.greenChannel(); // Provider should skip all of interceptors
break;
case FRAGMENT:
postcard.greenChannel(); // Fragment needn't interceptors
default:
break;
}
}
}

补充完postCard信息之后,接下来我们看看_navigation方法:


private Object _navigation(final Context context, final Postcard postcard, final int requestCode, final NavigationCallback callback) {
final Context currentContext = null == context ? mContext : context;

switch (postcard.getType()) {
case ACTIVITY:
//构造Intent,然后切换到主线程,并且跳转到指定的Activity
break;
case PROVIDER:
return postcard.getProvider();
case BOARDCAST:
case CONTENT_PROVIDER:
case FRAGMENT:
//反射构造出实例并返回
case METHOD:
default:
return null;
}

return null;
}

可以看到,最终会根据不同的type,去做出不同的响应,例如ACTIVITY的话,会进行activity的跳转,其他的会通过反射构造出实例返回等操作。


小结:



  • 调用的最开始,会构建一个PostCard对象,初始化path和group

  • navigation方法最终会调用到_ARouter的navigation方法,然后通过LogisticsCenter.completion(postcard)去加载group里边的路由表,并且补全postcard信息。

  • 如果是有绿色通道的话,则不执行拦截器,直接跳过,否则需要执行拦截器。

  • 最后便是通过不同类型执行对应的操作。


结语


本文的分享到这里就结束了,相信看完后,能够对ARouter的原理有了一定的理解,以便我们后面如果有使用到它的时候,能够更好的地使用,或者为项目定制出路由框架提供了很好的思路参考。同时,这么优秀的框架也值得我们去学习它的一些设计思路。


作者:敖森迪
链接:https://juejin.cn/post/7118385586850758686
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

为什么说获取堆栈从来就不是一件简单的事情

碎碎谈 为了不让文章看上去过于枯燥,笔者考虑了一下,特意增加了碎碎谈环节!自从上次这篇文章发出去后 黑科技!让Native Crash 与ANR无处发泄!,就挺受读者欢迎的呀,收藏数大于点赞数是什么鬼,嘿嘿!从我的角度出发,本来Signal出发的目的就是想建...
继续阅读 »

碎碎谈


为了不让文章看上去过于枯燥,笔者考虑了一下,特意增加了碎碎谈环节!自从上次这篇文章发出去后 黑科技!让Native Crash 与ANR无处发泄!,就挺受读者欢迎的呀,收藏数大于点赞数是什么鬼,嘿嘿!从我的角度出发,本来Signal出发的目的就是想建造一个类似于安全气囊的装置,保证crash后第一时间重启恢复,达到一个应用稳定的目的,但是慢慢写着写着,发现很多crash监控平台的也是用了相同的核心原理(大部分还没开源噢),只是作用的目标不同,那么为什么不把Signal打造成一个通用的基础件呢!无论是安全气囊还是监控,其实都是上层的应用不同罢了!嗯!有了这个想法之后,给Signal补充一些日志监控逻辑,就更加完善了!所以就有了本篇文章!算是一个补充文!如果没看过黑科技!让Native Crash 与ANR无处发泄!这篇文章的新朋友,请先阅读!(如果没有ndk开发经验也没关系,里面也不涉及很复杂的c知识)


获取堆栈


获取堆栈!可能很多新朋友看到这个就会想,这有什么难的嘛!直接new 一个Throwable获取不就可以了嘛,或者Thread.currentThread().stackTrace(kotlin)等等也可以呀!嗯!是的!我们在java层通常会有很固定的获取堆栈方式,这得益于java虚拟机的设计,也得益于java语言的设计,因为屏蔽了多平台底层的差异,我们就可以用相对统一的api去获取当前的堆栈。这个堆栈也特指java虚拟机堆栈!


但是对于native的堆栈,问题就来了!我们知道native层通常跟很多因素有关,比如链接器,编译器,还有各种库的版本,各种abi等等影响,获取一个堆栈消息,可没有那么简单,因为太多因素干扰了,这也是历史的包袱!还有对于我们android来说,android官方在对堆栈获取的方式,也是有历史变化的


4.1.1以上,5.0以下,android native使用系统自带的libcorkscrew.so,5.0开始,系统中没有了libcorkscrew.so 高版本的安卓源码中就使用了他的优化版替换libunwind。同时对于ndk来说,编译器的版本也在不断变化,从默认的gcc变成clang(ndk >=13),可以看到,我们会在众多版本,众多因素下,找一个统一的方式,还真的不简单!不过呀!在2022的今天,google早已推出了一个计划统一库 breakpad ,嗯!虽然能不能成为标准还未定,但是也是一个生态的进步


Signal的选择


前面介绍了这么多方案,breakpad是不是Signal的首选呢!虽然breakpad不错,但是里面覆盖了太多其他系统的编译,比如mac,window等等标准,还有就是作为一个开源库,还是希望减少这些库的导入,所以跟大多数主流方案一直,我们选择用unwind.h去实现堆栈打印,因为这个就直接内置在我们的默认编译中了,而且这个在在android也能用!下面我们来看一下实现!即Signal项目的unwind-utils的实现。那么我们要考虑一些什么呢!


堆栈大小


日志当然需要设定追溯的堆栈大小,内容太多不好(过于臃肿,排查困难),内容太少也不好(很有可能漏掉关键crash堆栈),所以Signal默认设置30条,可以根据实际项目修改


std::string backtraceToLogcat() {
默认30个
const size_t max = 30;
void *buffer[max];
//ostringstream方便输出string
std::ostringstream oss;
dumpBacktrace(oss, buffer, captureBacktrace(buffer, max));
return oss.str();
}

_Unwind_Backtrace


_Unwind_Backtrace是unwind提供给我们堆栈回溯函数


_Unwind_Reason_Code _Unwind_Backtrace(_Unwind_Trace_Fn, void *);

那么这个_Unwind_Trace_Fn是个啥,其实点进去看


typedef _Unwind_Reason_Code (*_Unwind_Trace_Fn)(struct _Unwind_Context *,
void *);

其实这就代表一个函数,对于我们常年写java的朋友有点不友好对吧,以java的方式,其实意思就是传xxx(随便函数名)( _Unwind_Context *,void *)这样的结构的函数即可,这里的意思就是一个callback函数,当我们获取到地址信息就会回调该参数,第二个就是需要传递给参数一的参数,这里有点绕对吧,我们怎么理解呢!参数一其实就是一个函数的引用,那么这个函数需要参数怎么办,就通过第二个参数传递!


我们看个例子:这个在Signal也有


static _Unwind_Reason_Code unwindCallback(struct _Unwind_Context *context, void *args) {
BacktraceState *state = static_cast<BacktraceState *>(args);
uintptr_t pc = _Unwind_GetIP(context);
if (pc) {
if (state->current == state->end) {
return _URC_END_OF_STACK;
} else {
*state->current++ = reinterpret_cast<void *>(pc);
}
}
return _URC_NO_REASON;
}


size_t captureBacktrace(void **buffer, size_t max) {
BacktraceState state = {buffer, buffer + max};
_Unwind_Backtrace(unwindCallback, &state);
// 获取大小
return state.current - buffer;
}

struct BacktraceState {
void **current;
void **end;
};

我们定义了一个结构体BacktraceState,其实是为了后面记录函数地址而用,这里有两个作用,end代表日志限定的大小,current表示实际日志条数大小(因为堆栈条数可能小于end)


_Unwind_GetIP


我们在unwindCallback这里拿到了系统回调给我们的参数,关键就是这个了 _Unwind_Context这个结构体参数了,这个参数的作用就是传递给_Unwind_GetIP这个函数,获取我们当前的执行地址,即pc值!那么这个pc值又有什么用呢!这个就是我们获取堆栈的关键!native堆栈的获取需要地址去解析!(不同于java)我们先有这个概念,后面会继续讲解


dladdr


经过了_Unwind_GetIP我们获取了pc值,这个时候就用上dladdr函数去解析了,这个是linux内核函数,专门用于地址符号解析


The function dladdr() determines whether the address specified in
addr is located in one of the shared objects loaded by the
calling application. If it is, then dladdr() returns information
about the shared object and symbol that overlaps addr. This
information is returned in a Dl_info structure:

typedef struct {
const char *dli_fname; /* Pathname of shared object that
contains address */
void *dli_fbase; /* Base address at which shared
object is loaded */
const char *dli_sname; /* Name of symbol whose definition
overlaps addr */
void *dli_saddr; /* Exact address of symbol named
in dli_sname */
} Dl_info;

If no symbol matching addr could be found, then dli_sname and
dli_saddr are set to NULL.

可以看到,每个地址会的解析信息会保存在Dl_info中,如果有运行符号满足,dli_sname和dli_saddr就会被设定为相应的so名称跟地址,dli_fbase是基址信息,因为我们的so库被加载到程序的位置是不固定的!所以一般采用地址偏移的方式去在运行时寻找真正的so库,所以就有这个dli_fbase信息。


Dl_info info;
if (dladdr(addr, &info) && info.dli_sname) {
symbol = info.dli_sname;

}
os << " #" << idx << ": " << addr << " " <<" "<<symbol <<"\n" ;

最终我们可以通过dladdr,一一把保存的地址信息解析出来,打印到native日志中比如Signal中demo crash信息(如果需要打印so名称,也可以通过dli_fname去获取,这里不举例)


image.png


native堆栈产生过程


通过上面的日志分析(最好看下demo中的app演示crash),我们其实在MainActivity中设定了一个crash函数


private external fun throwNativeCrash()

按照堆栈日志分析来看,只有在第16条才出现了调用符号,这跟我们在日常java开发中是不是很不一样!因为java层的堆栈一般都是最近的堆栈消息代表着错误消息,比如应该是第0条才导致的crash,但是演示中真正的堆栈crash却隐藏在了日志海里面!相信有不少朋友在看native crash日志也是,是不是也感到无从下手,因为首条日志往往并不是真正crash的主因!我们来看一下真正的过程:我们程序从正常态到crash,究竟发生了什么!


image.png


可以看到,我们真正dump_stack前,是有很多前置的步骤,为什么会有这么多呢!其实这就涉及到linux内核中断的原理,这里给一张粗略图


image.png
crash产生后,一般会在用户态阶段调用中断进入内核态,把自己的中断信号(这里区分一下,不是我们signal.h里面的信号)放在eax寄存器中(大部分,也有其他的寄存器,这里仅举例)


然后内核层通过传来的中断信号,找到信号表,然后根据对应的处理程序,再抛回给用户态,这个时候才进行sigaction的逻辑


所以说,crash产生到真正dump日志,其实会有一个过程,这里面根据sigaction的设置也会有多个变化,我们要了解的一点是,真正的crash信息,往往藏在堆栈海中,需要我们一步步去解析,比如通过addr2line等工具去分析地址,才能得到真正的原因,而且一般的android项目,都是依赖于第三方的so,这也给我们的排查带来难度,不过只要我们能识别出特定的so(dli_fname信息就有),是不是就可以把锅甩出去了呢,对吧!


最后


看到这里,读者朋友应该有一个对native堆栈的大概模型了,当然也不用怕!Signal项目中就包含了相关的unwind-utils工具类,直接用也是可以的,不过目前打印的信息比较简单,后续可以根据大家的实际,去添加参数!代码都在里面,求star求pr !Signal,当然,看完了本文,别忘了留下你的赞跟评论呀!


作者:Pika
链接:https://juejin.cn/post/7118609781832548383
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Kotlin 快速编译背后的黑科技,了解一下~

前言 快速编译大量代码一向是一个难题,尤其是当编译器必须执行很多复杂操作时,例如重载方法解析和泛型类型推断。 本文主要介绍在日常开发中做一些小改动时,Kotlin编译器是如何加快编译速度的 为什么编译那么耗时? 编译时间长通常有三大原因: 代码库大小:通常代...
继续阅读 »

前言


快速编译大量代码一向是一个难题,尤其是当编译器必须执行很多复杂操作时,例如重载方法解析和泛型类型推断。 本文主要介绍在日常开发中做一些小改动时,Kotlin编译器是如何加快编译速度的


为什么编译那么耗时?


编译时间长通常有三大原因:



  1. 代码库大小:通常代码码越大,编译耗时越长

  2. 你的工具链优化了多少,这包括编译器本身和你正在使用的任何构建工具。

  3. 你的编译器有多智能:无论是在不打扰用户的情况下计算出许多事情,还是需要不断提示和样板代码


前两个因素很明显,让我们谈谈第三个因素:编译器的智能。 这通常是一个复杂的权衡,在 Kotlin 中,我们决定支持干净可读的类型安全代码。这意味着编译器必须非常智能,因为我们在编译期需要做很多工作。


Kotlin 旨在用于项目寿命长、规模大且涉及大量人员的工业开发环境。


因此,我们希望静态类型安全,能够及早发现错误,并获得精确的提示(支持自动补全、重构和在 IDE 中查找使用、精确的代码导航等)。


然后,我们还想要干净可读的代码,没有不必要的噪音。这意味着我们不希望代码中到处都是类型。 这就是为什么我们有支持 lambda 和扩展函数类型的智能类型推断和重载解析算法等等。 Kotlin 编译器会自己计算出很多东西,以同时保持代码干净和类型安全。


编译器可以同时智能与高效吗?


为了让智能编译器快速运行,您当然需要优化工具链的每一部分,这是我们一直在努力的事情。 除此之外,我们正在开发新一代 Kotlin 编译器,它的运行速度将比当前编译器快得多,但这篇文章不是关于这个的。


不管编译器有多快,在大型项目上都不会太快。 而且,在调试时所做的每一个小改动都重新编译整个代码库是一种巨大的浪费。 因此,我们试图尽可能多地复用之前的编译,并且只编译我们绝对需要的文件。


有两种通用方法可以减少重新编译的代码量:



  • 编译避免:即只重新编译受影响的模块,

  • 增量编译:即只重新编译受影响的文件。


人们可能会想到一种更细粒度的方法,它可以跟踪单个函数或类的变化,因此重新编译的次数甚至少于一个文件,但我不知道这种方法在工业语言中的实际实现,总的来说它似乎没有必要。


现在让我们更详细地了解一下编译避免和增量编译。


编译避免


编译避免的核心思想是:



  • 查找dirty(即发生更改)的文件

  • 重新编译这些文件所属的module

  • 确定哪些其他模块可能会受到更改的影响,重新编译这些模块,并检查它们的ABI

  • 然后重复这个过程直到重新编译所有受影响的模块


从以上步骤可以看出,没有人依赖的模块中的更改将比每个人都依赖的模块(比如util模块)中的更改编译得更快(如果它影响其 ABI),因为如果你修改了util模块,依赖了它的模块全都需要编译


ABI是什么


上面介绍了在编译过程中会检查ABI,那么ABI是什么呢?


ABI 代表应用程序二进制接口,它与 API 相同,但用于二进制文件。本质上,ABI 是依赖模块关心的二进制文件中唯一的部分。


粗略地说,Kotlin 二进制文件(无论是 JVM 类文件还是 KLib)包含declarationbody两部分。其他模块可以引用declaration,但不是所有declaration。因此,例如,私有类和成员不是 ABI 的一部分。


body可以成为 ABI 的一部分吗?也是可以的,比如当我们使用inline时。 同时Kotlin 具有内联函数和编译时常量(const val)。因此如果内联函数的bodyconst val 的值发生更改,则可能需要重新编译相关模块。


因此,粗略地说,Kotlin 模块的 ABIdeclaration、内联body和其他模块可见的const val值组成。


因此检测 ABI 变化的直接方法是



  • 以某种形式存储先前编译的 ABI(您可能希望存储哈希以提高效率)

  • 编译模块后,将结果与存储的 ABI 进行比较:

  • 如果相同,我们就完成了;

  • 如果改变了,重新编译依赖模块。


编译避免的优缺点


避免编译的最大优点是相对简单。


当模块很小时,这种方法确实很有帮助,因为重新编译的单元是整个模块。 但如果你的模块很大,重新编译的耗时会很长。 因此为了尽可能地利用编译避免提升速度,决定了我们的工程应该由很多小模块组成。作为开发人员,我们可能想要也可能不想要这个。 小模块不一定听起来像一个糟糕的设计,但我宁愿为人而不是机器构建我的代码。为了利用编译避免,实际上限制了我们项目的架构。


另一个观察结果是,许多项目都有类似于util的基础模块,其中包含许多有用的小功能。 几乎所有其他模块都依赖于util模块,至少是可传递的。 现在,假设我想添加另一个在我的代码库中使用了 3 次的小实用函数。 它添加到util模块中会导致ABI发生变化,因此所有依赖模块都受到影响,进而导致整个项目都需要重新编译。


最重要的是,拥有许多小模块(每个都依赖于多个其他模块)意味着我的项目的configuration时间可能会变得巨大,因为对于每个模块,它都包含其独特的依赖项集(源代码和二进制文件)。 在 Gradle 中配置每个模块通常需要 50-100 毫秒。 大型项目拥有超过 1000 个模块的情况并不少见,因此总配置时间可能会超过一分钟。 它必须在每次构建以及每次将项目导入 IDE 时都运行(例如,添加新依赖项时)。


Gradle 中有许多特性可以减轻编译避免的一些缺点:例如,可以使用缓存configuration cache。 尽管如此,这里仍有很大的改进空间,这就是为什么在 Kotlin 中我们使用增量编译。


增量编译


增量编译比编译避免更加细粒度:它适用于单个文件而不是模块。 因此,当通用模块的 ABI 发生微小变化时,它不关心模块大小,也不重新编译整个项目。这种方式不会限制用户项目的架构,并且可以加快编译速度


JPS(IntelliJ的内置构建系统)一直支持增量编译。 而Gradle仅支持开箱即用的编译避免。 从 1.4 开始,Kotlin Gradle 插件为 Gradle 带来了一些有限的增量编译实现,但仍有很大的改进空间。


理想情况下,我们只需查看更改的文件,准确确定哪些文件依赖于它们,然后重新编译所有这些文件。


听起来很简单,但实际上准确地确定这组依赖文件非常复杂。


一方面,源文件之间可能存在循环依赖关系,这是大多数现代构建系统中的模块所不允许的。并且单个文件的依赖关系没有明确声明。请注意,如果引用了相同的包和链调用,imports不足以确定依赖关系:对于 A.b.c(),我们最多需要导入 A,但 B 类型的更改也会影响我们。


由于所有这些复杂性,增量编译试图通过多轮来获取受影响的文件集,以下是它的完成方式的概要:



  • 查找dirty(更改)的文件

  • 重新编译它们(使用之前编译的结果作为二进制依赖,而不是编译其他源文件)

  • 检查这些文件对应的ABI是否发生了变化

  • 如果没有,我们就完成了!

  • 如果发生了变化,则查找受更改影响的文件,将它们添加到脏文件集中,重新编译

  • 重复直到 ABI 稳定(这称为“固定点”)


由于我们已经知道如何比较 ABI,所以这里基本上只有两个棘手的地方:



  • 使用先前编译的结果来编译源的任意子集

  • 查找受一组给定的 ABI 更改影响的文件。


这两者都是 Kotlin 增量编译器的功能。 让我们一个一个看一下。


编译脏文件


编译器知道如何使用先前编译结果的子集来跳过编译非脏文件,而只需加载其中定义的符号来为脏文件生成二进制文件。 如果不是为了增量,编译器不一定能够做到这一点:从模块生成一个大二进制文件而不是每个源文件生成一个小二进制文件,这在 JVM 世界之外并不常见。 而且它不是 Kotlin 语言的一个特性,它是增量编译器的一个实现细节。


当我们将脏文件的 ABI 与之前的结果进行比较时,我们可能会发现我们很幸运,不需要再进行几轮重新编译。 以下是一些只需要重新编译脏文件的更改示例(因为它们不会更改 ABI):



  • 注释、字符串文字(const val 除外)等,例如:更改调试输出中的某些内容

  • 更改仅限于非内联且不影响返回类型推断的函数体,例如:添加/删除调试输出,或更改函数的内部逻辑

  • 仅限于私有声明的更改(它们可以是类或文件私有的),例如:引入或重命名私有函数

  • 重新排序函数声明


如您所见,这些情况在调试和迭代改进代码时非常常见。


扩大脏文件集


如果我们不那么幸运并且某些声明已更改,则意味着某些依赖于脏文件的文件在重新编译时可能会产生不同的结果,即使它们的代码中没有任何一行被更改。


一个简单的策略是此时放弃并重新编译整个模块。

这将把所有编译避免的问题都摆在桌面上:一旦你修改了一个声明,大模块就会成为一个问题,而且大量的小模块也有性能成本,如上所述。

所以,我们需要更细化:找到受影响的文件并重新编译它们。


因此,我们希望找到依赖于实际更改的 ABI 部分的文件。

例如,如果用户将 foo 重命名为 bar,我们只想重新编译关心名称 foobar 的文件,而不管其他文件,即使它们引用了此 ABI的其他部分。

增量编译器会记住哪些文件依赖于先前编译中的哪个声明,我们可以使用这种数据,有点像模块依赖图。同样,这不是非增量编译器通常会做的事情。


理想情况下,对于每个文件,我们应该存储哪些文件依赖于它,以及它们关心 ABI 的哪些部分。实际上,如此精确地存储所有依赖项的成本太高了。而且在许多情况下,存储完整签名毫无意义。


我们看一下下面这个例子:


// dirty.kt
// rename this to be 'fun foo(i: Int)'
fun changeMe(i: Int) = if (i == 1) 0 else bar().length

// clean.kt
fun foo(a: Any) = ""
fun bar() = foo(1)

我们定义两个kt文件 ,dirty.ktclean.kt


假设用户将函数 changeMe 重命名为 foo。 请注意,虽然 clean.kt 没有改变,但 bar() 的主体将在重新编译时改变:它现在将从dirty.kt 调用 foo(Int),而不是从 clean.kt 调用 foo(Any) ,并且它的返回类型 也会改变。


这意味着我们必须重新编译dirty.ktclean.kt。 增量编译器如何发现这一点?


我们首先重新编译更改的文件:dirty.kt。 然后我们看到 ABI 中的某些内容发生了变化:



  • 没有功能 changeMe

  • 有一个函数 foo 接受一个 Int 并返回一个 Int


现在我们看到 clean.kt 依赖于名称 foo。 这意味着我们必须再次重新编译 clean.ktdirty.kt。 为什么? 因为类型不能被信任。


增量编译必须产生与所有代码的完全重新编译相同的结果。

考虑dirty.kt 中新出现的foo 的返回类型。它是推断出来的,实际上它取决于 clean.ktbar 的类型,它是文件之间的循环依赖。

因此,当我们将 clean.kt 添加到组合中时,返回类型可能会发生变化。在这个例子中,我们会得到一个编译错误,但是在我们重新编译 clean.ktdirty.kt 之前,我们不知道它。


Kotlin 增量编译的第一原则:您可以信任的只是名称。


这就是为什么对于每个文件,我们存储它产生的 ABI,以及在编译期间查找的名称(不是完整的声明)。


我们存储所有这些的方式可以进行一些优化。


例如,某些名称永远不会在文件之外查找,例如局部变量的名称,在某些情况下还有局部函数的名称。

我们可以从索引中省略它们。为了使算法更精确,我们记录了在查找每个名称时查阅了哪些文件。为了压缩我们使用散列的索引。这里有更多改进的空间。


您可能已经注意到,我们必须多次重新编译初始的脏文件集。 唉,没有办法解决这个问题:可能存在循环依赖,只有一次编译所有受影响的文件才能产生正确的结果。


在最坏的情况下,增量编译可能会比编译避免做更多的工作,因此应该有适当的启发式方法来防止它。


跨模块的增量编译


迄今为止最大的挑战是可以跨越模块边界的增量编译。


比如说,我们在一个模块中有脏文件,我们做了几轮并在那里到达一个固定点。现在我们有了这个模块的新 ABI,需要对依赖的模块做一些事情。


当然,我们知道初始模块的 ABI 中哪些名称受到影响,并且我们知道依赖模块中的哪些文件查找了这些名称。


现在,我们可以应用基本相同的增量算法,但从 ABI 更改开始,而不是从一组脏文件开始。


如果模块之间没有循环依赖,单独重新编译依赖文件就足够了。但是,如果他们的 ABI 发生了变化,我们需要将更多来自同一模块的文件添加到集合中,并再次重新编译相同的文件。


Gradle 中完全实现这一点是一个公开的挑战。这可能需要对 Gradle 架构进行一些更改,但我们从过去的经验中知道,这样的事情是可能的,并且受到 Gradle 团队的欢迎。


总结


现在,您对现代编程语言中的快速编译所带来的挑战有了基本的了解。请注意,一些语言故意选择让他们的编译器不那么智能,以避免不得不做这一切。不管好坏,Kotlin 走的是另一条路,让 Kotlin 编译器如此智能似乎是用户最喜欢的特性,因为它们同时提供了强大的抽象、可读性和简洁的代码。


虽然我们正在开发新一代编译器前端,它将通过重新考虑核心类型检查和名称解析算法的实现来加快编译速度,但我们知道这篇博文中描述的所有内容都不会过时。


原因之一是使用 Java 编程语言的体验,它享受 IntelliJ IDEA 的增量编译功能,甚至拥有比今天的 kotlinc 快得多的编译器。


另一个原因是我们的目标是尽可能接近解释语言的开发体验,这些语言无需任何编译即可立即获取更改。


所以,Kotlin 的快速编译策略是:优化的编译器 + 优化的工具链 + 复杂的增量。


译者总结


本文主要介绍了Kotlin编译器在加快编译速度方面做的一些工作,介绍了编译避免与增量编译的区别以及什么是ABI


了解Kotlin增量编译的原理可以帮助我们提高增量编译成功的概率,比如inline函数体也是ABI的一部分,因此当我们声明内联函数时,内联函数体应该写得尽量简单,内部通常只需要调用另一个非内联函数即可。


这样当inline函数内部逻辑发生更改时,不需要重新编译依赖于它的那些文件,从而实现增量编译。


同时从实际开发过程中体验,Kotlin增量编译还是经常会失效,尤其是发生跨模块更改时。Kotlin新一代编译器已经发布了Alpha版本,期待会有更好的表现~


作者:程序员江同学
链接:https://juejin.cn/post/7118908219841314823
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

2022年各国开发者薪资水平报告:中国排19位,第1位是中国的5倍

来源:oschina.net/news/202254/software-engineer-salary-by-country-2022智能招聘平台 CodeSubmit 统计分析了 20 多个国家在 2022 年的软件工程领域的平均工资发现,美国的软件工程师薪...
继续阅读 »

来源:oschina.net/news/202254/software-engineer-salary-by-country-2022

智能招聘平台 CodeSubmit 统计分析了 20 多个国家在 2022 年的软件工程领域的平均工资发现,美国的软件工程师薪资水平最高,瑞士和以色列紧随其后。前十榜单还包括丹麦、加拿大、挪威、澳大利亚、英国、德国和瑞典。中国则排在第 19 顺位,平均薪资水平为 23,790 美元 / 年。



其他方面,印度是一个西方公司倾向于将其技术需求外包的国家,其平均年薪为 7,725 美元。尼日利亚薪资水平垫底,为 7,255 美元 / 年。CodeSubmit 方面指出,总体而言软件开发是世界上最受欢迎的职业。对软件开发人员需求最高的国家是加拿大、澳大利亚、俄罗斯、瑞典和新西兰;由于人才紧缺,工资水平也往往更高。

美国软件开发人员的平均工资为每年 110,140 美元或每月 9,178 美元。初级开发人员的平均工资为每年 69,354 美元或每月 5,779 美元,高级开发人员的平均工资为每年 104,188 美元或每月 8,682 美元。软件开发人员收入最高的州是加利福尼亚,平均工资为 146,770 美元;华盛顿次之。收入最高的城市包括圣何塞(167,420 美元)、旧金山(158,320 美元)和西雅图(148,200 美元)。

该国的编程语言平均薪资水平中,Go 和 Python 等流行的后端语言位居榜首。具体表现为:

  • Go 是收入最高的语言,120,577 美元。
  • Ruby 以 119,558 美元位居第二
  • Python 为 114,904 美元
  • Java 的平均工资为 112,013 美元
  • JavaScript 为 111,922 美元
  • Android 开发者的平均收入为 109,377 美元
  • 与 Android 相比,iOS 的平均工资略低,为 108,783 美元
  • Rust 紧随其后,为 108,744 美元
  • C 语言 101,734 美元
  • PHP 为 92,867 美元
  • SQL 最少为 85,845 美元

欧洲软件开发人员的平均工资水平低于美国。总体而言,欧洲国家在东西方之间存在很大差异。西欧开发者的年收入至少为 40,000 美元以上,而东欧的开发者期望的收入要少得多,约为 20,000 美元以上;南欧开发者的薪酬也要低于北欧开发者。西班牙、意大利、葡萄牙和希腊的开发人员预计年薪范围在 21,314 到 36,323 美元。

美国和欧洲国家之外,以色列软件开发人员的平均年薪为 71,559 美元或每月 5,963 美元。初级开发人员每年赚 69,851 美元或每月 5,820 美元,高级开发人员通常年薪为 114,751 美元或每月 9,562 美元。

语言方面,Golang(每年 109,702 美元)和 Python(每年 83,369 美元)平均薪资水平最高。PHP 和 Ruby 在以色列支付的薪资水平最低,分别为每年 64,573 美元和 64,525 美元。

  • Golang 开发人员的年平均收入为 109,702 美元。
  • Python 开发人员的收入为 83,369 美元。
  • 移动开发者的薪酬排名第三和第四:Android 开发者的年薪为 78,558 美元,iOS 开发者的年薪为 76,692 美元。
  • Java 开发人员的薪酬为 74,251 美元。
  • JavaScript 开发人员的收入为 72,028 美元。
  • SQL 开发人员在以色列的薪酬为 65,770 美元。
  • PHP(64,573 美元)和 Ruby(64,525 美元)是以色列收入最低的语言。

此外,日本开发人员的平均工资为每年 36,024 美元或每月 3,002 美元。编程语言薪资方面,iOS 水平最高,Ruby 位居第二;SQL 和 Java 是日本收入最低的编程语言。印度软件开发人员的平均工资为每年 7,725 美元或每月 643 美元。Ruby 是印度收入最高的编程语言,每年 12,372 美元。Android 是薪资水平最低的语言,为 5,181 美元 / 年。

总体而言,各国总体编程语言薪资水平中,Golang 和 Ruby 往往是高薪语言,而 JavaScript 和 PHP 则是工资最低的语言。


完整数据:https://codesubmit.io/blog/software-engineer-salary-by-country/

来源:oschina.net/news/202254/software-engineer-salary-by-country-2022

收起阅读 »

笑哭了!日本网友求助如何卸载360浏览器,过程堪比“ 拆弹 ”...

360全家桶,相信是每个人都会经历且最后不会再继续用的一款软件吧。从垃圾软件杀手到垃圾软件之王,我们一起见证了它的成长。本以为这个知名软件就在国内火,没想到居然在国外也火了,但并不是因为软件本身有多牛x,而是一名日本网友想要卸载,但一路坎坷,过程堪比“拆弹”。...
继续阅读 »

360全家桶,相信是每个人都会经历且最后不会再继续用的一款软件吧。从垃圾软件杀手到垃圾软件之王,我们一起见证了它的成长。本以为这个知名软件就在国内火,没想到居然在国外也火了,但并不是因为软件本身有多牛x,而是一名日本网友想要卸载,但一路坎坷,过程堪比“拆弹”。下面一起来看看这个经过吧!

就在前段时间,推特上一位日本网友发的帖子,在国内火了。这事说来也挺搞笑,因为——他不会卸载360安全浏览器。

网友发帖求助

说实话,这个弹窗中的卸载按钮,也太难找了。好在评论区里出现了懂汉语的老哥,提醒他选择「忍痛卸载」。师爷,您给翻译翻译,什么叫卸载 ▼

网友回复

但这位日本网友显然还是太年轻,接下来又遇到了新的问题。“ 好的,那我接下来该怎么办?”,他对着下一个页面继续发呆。热心老哥也耐心回复:“ 点右下角那个继续卸载 ”。

网友发帖求助

本以为终于结束了,谁成想360又杀出一记回马枪,来了一手卸载问卷调查。每个选项里还用星号标记,显示必填。

网友发帖求助

不得不说,360还真是个逆向思维大师,藏东西有一手。它推荐你点的按钮,比如加粗加大的字体,没有一个是用来卸载的,反而一个手滑就能给你安装上最新版。真正的卸载按钮,则藏得比私房钱都难找

别说看不懂中文的外国人了,即使熟知这种套路的我们,也相当容易翻车。而且,评论区还有另一位过来人表示:“这东西你卸载不干净的,它很可能会卷土重来。”

网友回复

随后,闻讯而来的国内网友,也立马加入了支招大军。提醒博主卸载后也要留个心眼,因为“请不要在下面取消你不想安装的附加软件”这里,曾让三个语文老师口吐白沫。万一不小心再安装个贪玩蓝月,里面的人不但不会帮你卸载,还会让你拿刀砍他。并表示,360这玩意我们自己都不用,你下载它是为了啥?

网友回复

而这位日本网友的目的,其实也很简单。他只是想在4399上体验一款小游戏,听说用360浏览器才能畅玩。后来听说自己在中国火了,微博贴吧都在讨论这事,表示有点震惊,又有点开心。

网友发帖求助

到此,整件事终于告一段落,世超看完觉得有点气,又有点好笑。因为外国人想卸载国内软件,却一不小搞了个全家桶的事,属实不少。比如YouTube上,你甚至能找到一堆,专门「教你如何卸载360安全卫士的教学视频

在这类视频评论区下方,各国人民齐聚一堂,他们虽然有着不同的语言,却有着同一个梦想——卸载360安全卫士。也因为按照视频步骤,成功卸载了360,他们脸上终于露出了久违的笑容。

世超不知道,有多少个国家的人被360“制裁”过。但视频评论区里不止出现过英语、葡萄牙语、西班牙语、阿拉伯语、韩语等语言,再凑凑应该又够开一波奥运会。

网友评论

很少有人是单纯地想给自己电脑杀毒,他们目标不同,只是机缘巧合之下,最后都同时安装了360软件。比如有位美国网友,本来只是在用360 WIFI,但不知道点到了哪,最后直接被安排上了一套完整的360豪华套餐。还有的人是把电脑借给自己叔叔,拿回来时桌面图标都快被占满了。

网友评论

虽然对于咱们来说,这些套路已经见怪不怪了。但估计谁也讲不清楚,国内电脑软件到底什么时候玩起「卸载伤痛文学」的。每每世超要卸载一个软件,都像是要辜负一个深爱着我的女孩。重要的是,她还一片痴心,并对我报以最诚挚的祝福,我还真有点下不去手。说出来你可能不信,我跟浏览器网恋了 ▼

而与堪比“ 拆弹 ”的卸载过程相比,更麻烦的还是软件删不干净。比如另一个我们熟悉的老朋友——2345,无论国内外都臭名昭著。和它相关的软件,称得上是互联网时代的狗皮膏药。

也许你开始只是想玩玩小游戏,或想随便找一个格式转换器什么的,误打误撞运行了一个 p2p 下载器,这之后噩梦便开始了。默认浏览器被篡改,右下角开始出现弹窗,油腻的师姐带你冒险,上古鲲鹏打起了篮球,成龙大哥也在沙场准时等你。虽然你拼命卸载、粉碎文件,但它的再生能力胜过魔人布欧,隔三差五就会跳出来向你挑衅一番。甚至你为了卸载这个电脑管家,又安装了好几个电脑管家,最后惊醒自己原来是在养蛊。

因为无论如何都删不净,被2345气到的网友义愤填膺,差点冲动行事,打算来一手擒贼先擒王。

网友评论

最后,实在没办法,只好颤抖地点上一支烟,打开一则视频——“ 3分钟教你重装系统,奶奶看了都学会 ”。然后心如止水,等待电脑重启,这都算是老网民们共同的过去了。

可能有的差友会觉得——“ 国人警惕性已经提高了,任凭这些流氓软件作去吧,反正都要被时代淘汰了,现在也就暂时欺负欺负不懂中文、又单纯的老外了 ”。然而,事实并非如此,这些软件过得可比我们想象中滋润多了。不但早早就上了市,并顺应时代搞了一款互联网借贷,2021年还转亏为盈,赚的盆满钵满。

更讽刺的是,前段时间2345还和金山毒霸打了个官司。原因是金山毒霸利用技术手段,将用户的「2345网址导航」替换成了「毒霸网址导航」,最后金山还被判罚了250万。这个事,叫什么喊捉什么来着?

而且流氓软件从未失去自己的市场,它们仍像小广告一般贴在网上各个角落。一旦趁虚而入你的电脑,就算卸载了也会残留文件和注册表项,以便卷土重来。有的则会将自己并入系统进程,增加内存负担。强行删除的话,会反复跳出“ 该程序处于占用中 ”。彻底结束流氓进程,还有可能导致Win崩溃,最后只好重装系统。

再加上,它们还在用着邪道的全家桶策略。本来只下一个浏览器,结果又给你装上了 XX 看图王、XX 手机助手、XX 管家卫士。打开图片会默认启动,连接手机也会自动跳出,甚至能跟你混个脸熟。

其实,如今干净又卫生的软件不少。比如 Chrome 浏览器、Win 系统自带的 Windows Security 和照片管理、火绒安全。但还是有人屡屡中招,这又是为啥?

因为,现在的流氓软件,恰恰就是利用了信息差做买卖。尽管有些软件确实好用,不过依然有很多人无从了解,这点在国内也好,国外也罢。我们能在网上看到的人,确实都在控诉流氓软件的罪过。而没发声的人里,又有多少人还在默默忍受流氓的骚扰呢?这就无从而知了。。。

天下苦流氓软件久矣。以至于,现在世超卸载一款国产软件时,如果它消失得很干脆,我甚至有种离奇的失落感。居然没和我勾心斗角?也没有捆绑下载?它真的,我哭死,居然可以一键卸载,好想把它再下载回来啊 ( 就是这么错乱)!

来源:c.m.163.com/news/a/H336IVP80526D8LR.html

收起阅读 »

易洋千玺也考公,宇宙尽头是编制?

经常有读者问:做程序员太累要不要考个公务员或者找一份国企工作、刚毕业是选择考公还是去互联网公司?这些问题不同人有不同的答案,很多时候没法回答,只好回一句:要不先考上公务员或者拿了国企Offer再做选择?现在考公和进国企(有编制的那种)可真不简单,用难如登天夸张...
继续阅读 »

经常有读者问:做程序员太累要不要考个公务员或者找一份国企工作、刚毕业是选择考公还是去互联网公司?


这些问题不同人有不同的答案,很多时候没法回答,只好回一句:要不先考上公务员或者拿了国企Offer再做选择?


现在考公和进国企(有编制的那种)可真不简单,用难如登天夸张了点,但绝对要耗费很多努力和精力。


这不最近易洋千玺都进话剧院了,成了有编制的人。这个事一下就上热搜了,一堆人各种喷。


很多人质疑为啥没公开三次面试的情况、为啥能以应届生身份考上编制,以及易洋千玺在入选后注销公司是不是违规的问题。


大家质疑的原因也很简单:今年就业多困难啊,高校毕业生目前签约率不足30%,你这么一个大明显还要来跟大家抢饭碗,还抢的是普通人眼里的“金饭碗”。抢就算了,还不需要笔试直接面试就行了。


关于明星背后的故事洋哥不想多探讨,这篇重点想聊聊工作选择的问题。


很多行业都有类似“康波周期”的因素在驱动,今天你看着是“金饭碗”,明天搞不好就成了鸡肋。


我本科毕业是2006年,那个年代最好的工作选择是什么?是西门子、北电为代表的通讯外企。


依稀记得考完研究生后我参加过北电在武汉的招聘,地点是香格里拉大酒店,跟师兄们打探的薪资简直惊呆了我幼小的心灵,最后面试没过,只好乖乖读研去了。

两年后研究生毕业,最好的Offer又发生了一些变化。2008年,已经有各种传闻通讯行业不行了,北电可能都要倒闭了,这时期最受学子们欢迎的是软件外企,比如微软、IBM等。


考公和国企在那个年代只能排在外企屈居第二


毕业四年后,我担任360武汉校招的面试官,这次经历让我发现互联网企业已经成了最受学子们欢迎的选项。


前几年就更不用说了,大厂工牌都成了一个梗,尤其是某条工牌,仿佛拥有了它就拥有了全世界。


变化总是一刻不停的悄悄发生着,你看看,今天的外企是什么样子,你再看看这才两年时间大厂工牌马上就要成为过去时,现在大家开始说:宇宙的尽头是编制。


其实狗屁尽头啊,无非是最近两年经济不行了,大家开始求稳躺平而已。


问题是这种跟着潮流的选择真的能对吗?


拉长时间线来看,这种选择模式大概率不会对。你追求外企的高薪,却忽略了自己是个渴望主动权的人,不愿意做边角料的工作、你追求公务员的稳定,却没想到自己耐不住低工资,更抵挡不住老婆的嘀咕、你追求财务自由选择了互联网公司,却扛不住996的痛苦和摧残。


抛开爱好和心性做出来的从众选择,很难正确。这里拿我自己举个栗子吧:


旧文也说过我毕业的时候面临了好几个选择:外企(autodesk)、互联网民企(腾讯)、国企(国家开发银行)。


如果从众应该选择外企或者国企,但最后我选择的是腾讯。


原因很简单,我喜欢玩游戏,渴望去腾讯做出顶级的游戏。并且在研究生期间也学习了大量的游戏开发的知识。


因为这个选择,被父母唠叨了好多年,在他们看来只有公务员或国企才算正儿八经的工作。民企?那不是黑工厂吗?


但也是因为做了有目标的选择,所以才能扛下来在腾讯、在360的巨大压力。


当然我也是幸运的,因为自己的爱好,撞到了互联网的黄金10年,但即便没有吃到任何红利我也不会后悔自己的选择。


前段时间跟一个在国企的老同学约饭,他挺眉飞色舞的说:“幸好毕业选择了国企,虽然过去痛苦了好多年,但现在很多人失业,我就不用担心这种问题。”


这哥们曾经抱怨过无数次薪资低,甚至曾经咨询过我要跳出来应该做什么,彼时还帮他全面评估过,最终建议他继续呆着。


看着他这么开心的样子,我也为他开心,但又总觉得哪里不对劲。最后才想明白:如果一个人的开心要建立在这种基础上,那过两年经济好了,市场上的薪资高了,财务自由的机会多了,他是不是又要痛苦了?


当然我并不是想说:大家不要考公、不要进国企。而是想说:工作的选择除了从众也要考虑自己的爱好和特性,尤其要用以终为始的目标思维去思考:想一想20年后的自己,是一种什么职场状态,这种状态能否让你满意。


我们还要明白几乎不存在完美的工作,大部分工作都有两面性,不光要想好的一面,更重要是坏的一面能否接受。


这些问题,值得我们每一个人多思考思考。

来源:findyi ,作者findyi

收起阅读 »

两天两夜,1M图片优化到100kb!

坦白从宽吧,我就是那个花了两天两夜把 1M 图片优化到 100kb 的家伙——王小二! 自从因为一篇报道登上热搜后,我差点抑郁,每天要靠 50 片安眠药才能入睡。 网络上曝光的那些关于一码通的消息,有真有假,我这里就不再澄清了。就说说我是怎么把图片从 1M ...
继续阅读 »

坦白从宽吧,我就是那个花了两天两夜把 1M 图片优化到 100kb 的家伙——王小二!


自从因为一篇报道登上热搜后,我差点抑郁,每天要靠 50 片安眠药才能入睡。


网络上曝光的那些关于一码通的消息,有真有假,我这里就不再澄清了。就说说我是怎么把图片从 1M 优化到 100kb 的故事吧。


是的,由于系统群体规模和访问规模的特殊性,每一行代码、每一张图片、每一个技术文档都反复核准,优化再优化,精益求精。为确保系统运行得更高效,我们将一张图片从1MB压缩到500KB,再从500KB优化到100KB。


这样的工作在外人看起来,简单到就好像悄悄给学妹塞一张情书就能让她做我女朋友一样简单。


但殊不知,这其中蕴含着极高的技术含量!


不信,我给你们普及下。


一、图像压缩


图像压缩是数据压缩技术在数字图像上的应用,目的是减少图像数据中的冗余信息,从而用更加高效的格式存储和传输数据。


图像压缩可以是有损数据压缩,也可以是无损数据压缩。




怎么样?


是不是感觉图像压缩技术没有想象中那么简单了?


更多关于图像压缩的资料可参考以下链接。



机器之心:http://www.jiqizhixin.com/graph/techn…



二、Java数字图像处理


作为这次“20 多万外包项目”的“主力开发人员”,我这里就给大家介绍下 Java 数字图像处理技术吧,一开始我就是用它来处理图片的。


数字图像处理(Digital Image Processing)是通过计算机对图像进行去除噪声、增强、复原、分割、提取特征等处理的方法和技术。



输入的是图像信号,然后经过 DIP 进行有效的算法处理后,输出为数字信号。


为了压缩图像,我们需要读取图像并将其转换成 BufferedImage 对象,BufferedImage 是 Image 类的一个子类,描述了一个具有可访问的图像数据缓冲区,由 ColorModel 和 Raster 的图像数据组成。



废话我就不多说了,直接进入实战吧!


三、图像压缩实战


刚好我本地有一张之前用过的封面图,离 1M 只差 236 KB,可以拿来作为测试用。



这其中要用到 ImageIO 类,这是一个静态类,提供了一系列方法用来读和写图像,同时还可以对图像进行简单的编码和解码。


比如说通过 ImageIO.read() 可以将图像读取到 BufferedImage 对象:


File input = new File("ceshi.jpg");
BufferedImage image = ImageIO.read(input);

比如说通过 ImageIO.getImageWritersByFormatName() 可以返回一个Iterator,其中包含了通过命名格式对图像进行编码的 ImageWriter。


Iterator<ImageWriter> writers =  ImageIO.getImageWritersByFormatName("jpg");
ImageWriter writer = (ImageWriter) writers.next();

比如说通过 ImageIO.createImageOutputStream() 可以创建一个图像的输出流对象,有了该对象后就可以通过 ImageWriter.setOutput() 将其设置为输出流。


File compressedImageFile = new File("bbcompress.jpg");
OutputStream os =new FileOutputStream(compressedImageFile);
ImageOutputStream ios = ImageIO.createImageOutputStream(os);
writer.setOutput(ios);

紧接着,可以对 ImageWriter 进行一些参数配置,比如说压缩模式,压缩质量等等。


ImageWriteParam param = writer.getDefaultWriteParam();

param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.01f);

压缩模式一共有四种,MODE_EXPLICIT 是其中一种,表示 ImageWriter 可以根据后续的 set 的附加信息进行平铺和压缩,比如说接下来的 setCompressionQuality() 方法。


setCompressionQuality() 方法的参数是一个 0-1 之间的数,0.0 表示尽最大程度压缩,1.0 表示保证图像质量很重要。对于有损压缩方案,压缩质量应该控制文件大小和图像质量之间的权衡(例如,通过在写入 JPEG 图像时选择量化表)。 对于无损方案,压缩质量可用于控制文件大小和执行压缩所需的时间之间的权衡(例如,通过优化行过滤器并在写入 PNG 图像时设置 ZLIB 压缩级别)。


整体代码如下所示:


public class Demo {
public static void main(String[] args) {

try {
File input = new File("ceshi.jpg");
BufferedImage image = ImageIO.read(input);


Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpg");
ImageWriter writer = (ImageWriter) writers.next();

File compressedImageFile = new File("bbcompress.jpg");
OutputStream os = new FileOutputStream(compressedImageFile);
ImageOutputStream ios = ImageIO.createImageOutputStream(os);
writer.setOutput(ios);


ImageWriteParam param = writer.getDefaultWriteParam();

param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.01f);

writer.write(null, new IIOImage(image, null, null), param);

os.close();
ios.close();
writer.dispose();

} catch (IOException e) {
e.printStackTrace();
}
}
}

执行压缩后,可以看到图片的大小压缩到了 19 KB:



可以看得出,质量因子为 0.01f 的时候图片已经有些失真了,可以适当提高质量因子比如说 0.5f,再来看一下。



图片质量明显提高了,但大小依然只有 64 KB,压缩效果还是值得信赖的。


四、其他开源库


接下来,推荐一些可以轻松集成到项目中的图像处理库吧,它们全都是免费的。


1)ImageJ,用 Java 编写的,可以编辑、分析、处理、保存和打印图像。



2)Apache Commons Imaging,一个读取和写入各种图像格式的库,包括快速解析图像信息(如大小,颜色,空间,ICC配置文件等)和元数据。



3)ImageMagick,可以读取和写入超过100种格式的图像,包括DPX、EXR、GIF、JPEG、JPEG-2000、PDF、PNG、Postscript、SVG和TIFF。还可以调整大小、翻转、镜像、旋转、扭曲、剪切和变换图像,调整图像颜色,应用各种特殊效果,包括绘制文本、线条、多边形、椭圆和贝塞尔曲线。



4)OpenCV,由BSD许可证发布,可以免费学习和商业使用,提供了包括 C/C++、Python 和 Java 等主流编程语言在内的接口。OpenCV 专为计算效率而设计,强调实时应用,可以充分发挥多核处理器的优势。



这里就以 OpenCV 为例,来演示一下图像压缩。当然了,OpenCV 用来压缩图像属于典型的大材小用。


第一步,添加 OpenCV 依赖到我们的项目当中,以 Maven 为例。


<dependency>
<groupId>org.openpnp</groupId>
<artifactId>opencv</artifactId>
<version>4.5.1-2</version>
</dependency>

第二步,要想使用 OpenCV,需要先初始化。


OpenCV.loadShared();

第三步,使用 OpenCV 读取图片。


Mat src = Imgcodecs.imread(imagePath);

第四步,使用 OpenCV 压缩图片。


MatOfInt dstImage = new MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, 1);
Imgcodecs.imwrite("resized_image.jpg", sourceImage, dstImage);

MatOfInt 的构造参数是一个可变参数,第一个参数 IMWRITE_JPEG_QUALITY 表示对图片的质量进行改变,第二个是质量因子,1-100,值越大表示质量越高。


执行代码后得到的图片如下所示:



借这个机会,来对比下 OpenCV 和 JDK 原生 API 在压缩图像时所使用的时间。


这是我本机的配置情况,早年买的顶配 iMac,也是我的主力机。一开始只有 16 G 内存,后来加了一个 16 G 内存条,不过最近半年电脑突然死机重启的频率明显提高了,不知道是不是 Big Sur 这个操作系统的问题还是电脑硬件老了。



结果如下所示:


opencvCompress压缩完成,所花时间:1070
jdkCompress压缩完成,所花时间:322

压缩后的图片大小差不多,都是 19 KB,并且质量因子都是最低值。



四、一点点心声


经过上面的技术分析后,相信你们都明白了,把1M图片优化到100kb实在是一件“不太容易”的事情。。。。


100KB 很小了吧?只有原来的 1/10。


要知道,我可是连续加班了两天两夜,不眠不休。



累到最后,我趴在电脑上都睡着了。


没想到哈喇子直接给电脑整短路了,我这才算是从梦里面吓醒来了!


😔,生活不易,且行且珍惜吧~


作者:沉默王二
链接:https://juejin.cn/post/7072614545381916708
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

重学Android-EditText的进阶操作

EditText的进阶使用 EditText 是我们常用的输入框控件,平常我们只是使用它输入文本,这里记录一些它不太常见的操作和一些解决方案。 一、焦点的自动获取 如果一个页面内定义了EditText,那么有可能我们进入此页面的时候会自动弹起软键盘,(分机型,...
继续阅读 »

EditText的进阶使用


EditText 是我们常用的输入框控件,平常我们只是使用它输入文本,这里记录一些它不太常见的操作和一些解决方案。


一、焦点的自动获取


如果一个页面内定义了EditText,那么有可能我们进入此页面的时候会自动弹起软键盘,(分机型,有的会弹,有的不弹)。如果我们需要弹软键盘,我们制定给 EditText 设置


    android:focusable="true"
android:focusableInTouchMode="true"

但是如果我们不想这个页面进去就弹出软键盘,我们可以给根布局或者 EditText 的父布局设置 focusable 。


二、光标和背景的控制


默认的 EditText 是带下划线和粗光标的,我们可以对它们进行简单的修改


android:background="@null" //去掉了下划线

android:textCursorDrawable="@null" //去掉光标的颜色

自定义光标的颜色和宽度:


<shape xmlns:android="http://schemas.android.com/apk/res/android">

<size android:width="2dp" />

<solid android:color="#BDC7D8" />

</shape>

使用自定义光标


android:textCursorDrawable="@drawable/edittext_cursor"

三、限制小数点位数


我们可以通过监听 EditText 的文本变化的方式来改变文本值,我们还能通过 DigitsKeyListener 的方式监听文本的改变。


3.1 TextWatcher的方式

我们可以通过监听 EditText 的文本变化,比如我们只想要小数点后面2位数,我们就监听文本变化,点后面的2位数,如果多了就把他删除掉。


public class MoneyTextWatcher implements TextWatcher {
private EditText editText;
private int digits = 2;

public MoneyTextWatcher(EditText et) {
editText = et;
}
public MoneyTextWatcher setDigits(int d) {
digits = d;
return this;
}


@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
//删除“.”后面超过2位后的数据
if (s.toString().contains(".")) {
if (s.length() - 1 - s.toString().indexOf(".") > digits) {
s = s.toString().subSequence(0,
s.toString().indexOf(".") + digits+1);
editText.setText(s);
editText.setSelection(s.length()); //光标移到最后
}
}
//如果"."在起始位置,则起始位置自动补0
if (s.toString().trim().substring(0).equals(".")) {
s = "0" + s;
editText.setText(s);
editText.setSelection(2);
}

//如果起始位置为0,且第二位跟的不是".",则无法后续输入
if (s.toString().startsWith("0")
&& s.toString().trim().length() > 1) {
if (!s.toString().substring(1, 2).equals(".")) {
editText.setText(s.subSequence(0, 1));
editText.setSelection(1);
return;
}
}
}

@Override
public void afterTextChanged(Editable s) {

}
}


使用:


//默认两位小数
mEditText.addTextChangedListener(new MoneyTextWatcher(mEditText1));

//手动设置其他位数,例如3
mEditText.addTextChangedListener(new MoneyTextWatcher(mEditText1).setDigits(3);

3.2 DigitsKeyListener的方式

public class ETMoneyValueFilter extends DigitsKeyListener {

public ETMoneyValueFilter(int d) {
super(false, true);
digits = d;
}

private int digits = 2; //默认显示二位数的小数点

public ETMoneyValueFilter setDigits(int d) {
digits = d;
return this;
}

@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend) {
CharSequence out = super.filter(source, start, end, dest, dstart, dend);

if (out != null) {
source = out;
start = 0;
end = out.length();
}

int len = end - start;

if (len == 0) {
return source;
}

//以点开始的时候,自动在前面添加0
if (source.toString().equals(".") && dstart == 0) {
return "0.";
}
//如果起始位置为0,且第二位跟的不是".",则无法后续输入
if (!source.toString().equals(".") && dest.toString().equals("0")) {
return "";
}

int dlen = dest.length();

for (int i = 0; i < dstart; i++) {
if (dest.charAt(i) == '.') {
return (dlen - (i + 1) + len > digits) ?
"" :
new SpannableStringBuilder(source, start, end);
}
}

for (int i = start; i < end; ++i) {
if (source.charAt(i) == '.') {
if ((dlen - dend) + (end - (i + 1)) > digits)
return "";
else
break;
}
}

return new SpannableStringBuilder(source, start, end);
}
}

其实是和 TextWatcher 类似的方式,那么使用的时候我们这样使用:


//默认两位小数
mEditText.setFilters(new InputFilter[]{new MoneyValueFilter()});

//手动设置其他位数,例如3
mEditText.setFilters(new InputFilter[]{new MoneyValueFilter().setDigits(3)});

在Kotlin代码中是这样使用:


et_input.filters = arrayOf(ETMoneyValueFilter().setDigits(3))

这样就可以实现小数点后面二位数的控制,还顺便加入了.的判断,自动加0的操作。


四、EditText的Search操作


当此 EditText 软键盘弹起的时候,右下角的确定变为搜索,我们需要给 EditText 设置一个属性:


 android:imeOptions="actionSearch"

然后给软键盘设置一个监听


       //点击软键盘搜索按钮
etSearch.setOnKeyListener(new View.OnKeyListener() {

@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {

if (keyCode == KeyEvent.KEYCODE_ENTER) {
// 先隐藏键盘
((InputMethodManager) getSystemService(INPUT_METHOD_SERVICE))
.hideSoftInputFromWindow(TransactionHistorySearchActivity.this.getCurrentFocus()
.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS);

if (isSearch){
isSearch = false;
if (!TextUtils.isEmpty(etSearch.getText().toString()))
searchHistory(etSearch.getText().toString());
}

}
return false;
}
});

这里使用一个flag来判断,是因为部分机型会回调2次。所以为了统一效果,我们使用拦截判断只调用一次。


当然Search的逻辑如果你使用 Kotlin + DataBinding 来实现,那么就更简单了。


        //执行搜索
fun doSearch() {
KeyboardUtils.hideSoftInput(mActivity)
scrollTopRefresh()
}

//搜索的删除
fun searchDel() {
mViewModel.mKeywordLiveData.value = ""
doSearch()
}

           <LinearLayout
android:layout_width="match_parent"
android:layout_height="32dp"
android:layout_marginLeft="@dimen/d_15dp"
android:background="@drawable/shape_search_gray_bg_corners20"
android:gravity="center_vertical"
android:orientation="horizontal">

<ImageView
android:layout_width="@dimen/d_16dp"
android:layout_height="@dimen/d_16dp"
android:layout_marginLeft="@dimen/d_12dp"
android:src="@drawable/search_icon"
binding:clicks="@{click.doSearch}" />

<EditText
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/d_12dp"
android:layout_weight="1"
android:background="@color/transparent"
android:hint="大家都在搜"
android:imeOptions="actionSearch"
android:singleLine="true"
android:text="@={viewModel.mKeywordLiveData}"
android:textColor="@color/black"
android:textColorHint="@color/gray_99"
android:textSize="@dimen/d_14sp"
binding:onKeyEnter="@{click.doSearch}"
binding:typefaceMedium="@{true}" />

<ImageView
android:layout_width="@dimen/d_16dp"
android:layout_height="@dimen/d_16dp"
android:layout_marginRight="@dimen/d_10dp"
android:src="@drawable/search_delete"
android:visibility="gone"
binding:clicks="@{click.searchDel}"
binding:isVisibleGone="@{!TextUtils.isEmpty(viewModel.MKeywordLiveData)}" />

</LinearLayout>

主要的 Binding Adapter 方法为 onKeyEnter ,它实现了软键盘的搜索。


下面是自定义BindingAdapter的方法:


var _viewClickFlag = false
var _clickRunnable = Runnable { _viewClickFlag = false }

/**
* Edit的确认按键事件
*/
@BindingAdapter("onKeyEnter")
fun EditText.onKeyEnter(action: () -> Unit) {
setOnKeyListener { _, keyCode, _ ->
if (keyCode == KeyEvent.KEYCODE_ENTER) {
KeyboardUtils.closeSoftKeyboard(this)

if (!_viewClickFlag) {
_viewClickFlag = true
action()
}
removeCallbacks(_clickRunnable)
postDelayed(_clickRunnable, 1000)
}
return@setOnKeyListener false
}
}

和上面Java的实现方式类似,同样的做了防抖的操作。为了部分机型连续调用多次的问题。


效果:



五、焦点与软键盘的自由控制


上面说到的焦点,不自动弹出软键盘,如果我想自由的控制焦点与软键盘怎么办?


一个例子来说明,比如我们的需求,点击 EditText 的时候弹出弹框提示用户注意事项,当点击确定或者取消之后再继续输入。分解步骤如下:



  1. 我们点击EditText不能弹出软键盘

  2. 监听焦点获取之后弹出弹框

  3. 弹框完成之后我们需要手动的给EditText焦点

  4. 获取焦点之后需要设置光标与软键盘


代码逻辑如下:


      mBankAccountEt.setShowSoftInputOnFocus(false);
mBankAccountEt.setOnFocusChangeListener((v, hasFocus) -> {
if (hasFocus && !isShowedBankAccountNotice) {
showBankAccountNoticePopup();
}
});


private void showBankAccountNoticePopup() {
BasePopupView mPopupView = new XPopup.Builder(mActivity)
.moveUpToKeyboard(false)
.hasShadowBg(true)
.asCustom(new BankNameNoticePopup(mActivity, () -> {
isShowedBankAccountNotice = true;
mBankAccountEt.setShowSoftInputOnFocus(true);

//需要把焦点设置回EditText
mBankAccountEt.setFocusable(true);
mBankAccountEt.setFocusableInTouchMode(true);
mBankAccountEt.requestFocus();
mBankAccountEt.setSelection(mBankAccountEt.getText().toString().length());

KeyboardUtils.showKeyboard(mBankAccountEt);
}));

if (mPopupView != null && !mPopupView.isShow()) {
mPopupView.show();
}
}

弹框就不给大家展示了,非常简单的弹窗,定义使用的弹窗库,逻辑都在完成的回调中。


KeyboardUtils工具类,控制EditText的软键盘展示与隐藏


	/*
* 显示键盘
* */
public static void showKeyboard(View view) {
InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
view.requestFocus();
imm.showSoftInput(view, 0);
}
}

/*
* 隐藏键盘
* */
public static void hideKeyboard(View view){
InputMethodManager imm = (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
if (imm != null) {
imm.hideSoftInputFromWindow(view.getWindowToken(),0);
}
}

效果:



六、RV + EditText复用的问题


不知道大家有没有在RV中使用过 EditText ,Item中如果有 EditText 那么在滚出屏幕之后 再拉回来可能刚才输入的文本就消失了,或者换成不是刚才输入的文本了,是因为缓存复用,可能复用了别的Item上面的 EditText 控件。


有几种解决方法如下:


方法一: 强制的停用Recyclerview的复用


helper.setIsRecyclable(false);

但是RV就无法缓存与回收了,如果你的Item数量就是固定的并且不多,那么使用这个方法是最好的。


方法二: 通过监听焦点来添加或移除Edittext的TextChangedListener


@Override
protected void convert(BaseViewHolder helper, EdittextInRecyclerViewOfBean item) {
EditText editText = helper.getView(R.id.et);
editText.setText(item.getNum() + "");

TextWatcher textWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {

}

@Override
public void afterTextChanged(Editable s) {
//这里处理数据
if (TextUtils.isEmpty(s.toString())) {
item.setNum(0);
} else {
item.setNum(Integer.parseInt(s.toString()));
}
}
};

editText.setOnFocusChangeListener(new View.OnFocusChangeListener() {
@Override
public void onFocusChange(View v, boolean hasFocus) {
if (hasFocus){
editText.addTextChangedListener(textWatcher);
}else {
editText.removeTextChangedListener(textWatcher);
}
}
});
}


方法三: 通过view的setTag()方法解决


@Override
protected void convert(BaseViewHolder helper, EdittextInRecyclerViewOfBean item) {
TextWatcher textWatcher = new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {

}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {

}

@Override
public void afterTextChanged(Editable s) {
if (TextUtils.isEmpty(s.toString())) {
item.setNum(0);
} else {
item.setNum(Integer.parseInt(s.toString()));
}
}
};

EditText editText = helper.getView(R.id.et);
//为了避免TextWatcher在调用settext()时被调用,提前将它移除
if (editText.getTag() instanceof TextWatcher) {
editText.removeTextChangedListener((TextWatcher) editText.getTag());
}
editText.setText(item.getNum() + "");
//重新添加上TextWatcher监听
editText.addTextChangedListener(textWatcher);
//将TextWatcher绑定到EditText
editText.setTag(textWatcher);
}


方法四: 为每个EditText的绑定位置


public class EditTextInRecyclerViewAdapter extends RecyclerView.Adapter {
private List<EdittextInRecyclerViewOfBean> mList = new ArrayList<>();

public void setData(List<EdittextInRecyclerViewOfBean> list) {
this.mList = list;
}

@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_edittext, parent, false);
return new ViewHolder(v, new ITextWatcher());
}

@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
ViewHolder viewHolder = (ViewHolder) holder;
viewHolder.mITextWatcher.bindPosition(position);
viewHolder.mEditText.setText(mList.get(position).getNum()+"");
}

@Override
public int getItemCount() {
return mList.size();
}

class ViewHolder extends RecyclerView.ViewHolder {
EditText mEditText;
ITextWatcher mITextWatcher;

private ViewHolder(View v, ITextWatcher watcher) {
super(v);
this.mEditText = v.findViewById(R.id.et);
this.mITextWatcher = watcher;
this.mEditText.addTextChangedListener(watcher);
}
}

class ITextWatcher implements TextWatcher {
private int position;

private void bindPosition(int position) {
this.position = position;
}

@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}

@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}

@Override
public void afterTextChanged(Editable s) {
if (TextUtils.isEmpty(s.toString())) {
mList.get(position).setNum(0);
} else {
mList.get(position).setNum(Integer.parseInt(s.toString()));
}
}
}
}


方法五: 构造方法中添加TextChanged


class PicViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

var ivPic: ImageView = itemView.findViewById(R.id.ivPic)
var etScore: EditText = itemView.findViewById(R.id.etScore)
var tvTitle: TextView = itemView.findViewById(R.id.tvTitle)
var myTextWatcher: MyTextWatcher = MyTextWatcher()

init {
etScore.addTextChangedListener(myTextWatcher)
}

fun updateView(picItem: PicItem) {
myTextWatcher.picItem = picItem
ivPic.setImageResource(picItem.picResId)
tvTitle.text = picItem.title
if (picItem.score == null) {
etScore.hint = "请输入分数"
etScore.setText("")
} else {
etScore.setText(picItem.score)
}
}
}

class MyTextWatcher: TextWatcher {

lateinit var picItem:PicItem

override fun afterTextChanged(s: Editable?) {
picItem?.apply {
score=s?.toString()
}
}

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
}

方法六: 让产品改需求,不要使用EditText,或者我们干脆使用TextView,然后点击Item弹出输入框的弹框方式来实现。


作者:newki
链接:https://juejin.cn/post/7115621556700577828
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

Android架构之路--热更新Tinker

当前市面的热补丁方案有很多,其中比较出名的有阿里的 AndFix、美团的 Robust 以及 QZone 的超级补丁方案。但它们都存在无法解决的问题,这也是正是最后使用 Tinker 的原因。先看一张图对比:Tinker热补丁方案不仅支持类、So 以及资源的替...
继续阅读 »

一、简介

当前市面的热补丁方案有很多,其中比较出名的有阿里的 AndFix、美团的 Robust 以及 QZone 的超级补丁方案。但它们都存在无法解决的问题,这也是正是最后使用 Tinker 的原因。先看一张图对比:

1-1:热更新对比图

Tinker热补丁方案不仅支持类、So 以及资源的替换,它还是2.X-7.X的全平台支持。利用Tinker我们不仅可以用做 bugfix,甚至可以替代功能的发布。Tinker已运行在微信的数亿Android设备上。

TinkerPatch 平台在 Github 为大家提供了各种各样的 Sample,大家可点击前往 [TinkerPatch Github].

Tinker原理图

1-2:原理图

Tinker流程图

1-3:Tinker 流程图

二、Tinker相关网站

微信Tinker Patch官网:Tinker Patch
Github地址:tinker

三、接入Tinker步骤

基础步骤

  • 注册Tinker账户、添加APP、记录AppKey,添加 APP 版本、 发布补丁。详细步骤请移步Tinker平台使用文档

主要来说下配置Gradle和代码

1. 配置Tinker版本信息

我们使用配置文件去配置Tinker版本信息,易于统一版本和后面更换版本,如图:

2-1 gradle.properties文件

代码如下:

TINKER_VERSION=1.9.6
TINKERPATCH_VERSION=1.2.6

2. 使用Tinker插件

在根目录下的build.gradle文件下配置,如图:

2-2 添加Tinker插件



代码如下:

classpath "com.tinkerpatch.sdk:tinkerpatch-gradle-plugin:${TINKERPATCH_VERSION}"

3. 配置Tinker的gradle脚本

在项目app目录下新建tinkerparch.gradle文件,如图:

2-3 tinkerpatch.gradle

代码如下:

apply plugin: 'tinkerpatch-support'
/**
* TODO: 请按自己的需求修改为适应自己工程的参数
*/

def bakPath = file("${buildDir}/bakApk/")
def baseInfo = "app-1.0.0-0529-14-38-02"
def variantName = "release"
/**
* 对于插件各参数的详细解析请参考
* http://tinkerpatch.com/Docs/SDK
*/

tinkerpatchSupport {
/** 可以在debug的时候关闭 tinkerPatch **/
/** 当disable tinker的时候需要添加multiDexKeepProguard和proguardFiles,
* 这些配置文件本身由tinkerPatch的插件自动添加,当你disable后需要手动添加
* 你可以copy本示例中的proguardRules.pro和tinkerMultidexKeep.pro,
* 需要你手动修改'tinker.sample.android.app'本示例的包名为你自己的包名,
* com.xxx前缀的包名不用修改
**/

tinkerEnable = true
reflectApplication = true
/**
* 是否开启加固模式,只能在APK将要进行加固时使用,否则会patch失败。
* 如果只在某个渠道使用了加固,可使用多flavors配置
**/

protectedApp = false
/**
* 实验功能
* 补丁是否支持新增 Activity (新增Activity的exported属性必须为false)
**/

supportComponent = true

autoBackupApkPath = "${bakPath}"
/** 注意:换成自己在Tinker平台上申请的appKey**/
appKey = "521db2518e0ca16d"

/** 注意: 若发布新的全量包, appVersion一定要更新 **/
appVersion = "1.0.0"

def pathPrefix = "${bakPath}/${baseInfo}/${variantName}/"
def name = "${project.name}-${variantName}"

baseApkFile = "${pathPrefix}/${name}.apk"
baseProguardMappingFile = "${pathPrefix}/${name}-mapping.txt"
baseResourceRFile = "${pathPrefix}/${name}-R.txt"

/**
* 若有编译多flavors需求, 可以参照:
* https://github.com/TinkerPatch/tinkerpatch-flavors-sample
* 注意: 除非你不同的flavor代码是不一样的,
* 不然建议采用zip comment或者文件方式生成渠道信息
* (相关工具:walle 或者 packer-ng)
**/

}

/**
* 用于用户在代码中判断tinkerPatch是否被使用
*/

android {
defaultConfig {
buildConfigField "boolean", "TINKER_ENABLE",
"${tinkerpatchSupport.tinkerEnable}"
}
}

/**
* 一般来说,我们无需对下面的参数做任何的修改
* 对于各参数的详细介绍请参考:
* https://github.com/Tencent/tinker/wiki/Tinker-
* %E6%8E%A5%E5%85%A5%E6%8C%87%E5%8D%97
*/

tinkerPatch {
ignoreWarning = false
useSign = true
dex {
dexMode = "jar"
pattern = ["classes*.dex"]
loader = []
}
lib {
pattern = ["lib/*/*.so"]
}

res {
pattern = ["res/*", "r/*", "assets/*", "resources.arsc",
"AndroidManifest.xml"]
ignoreChange = []
largeModSize = 100
}

packageConfig {
}
sevenZip {
zipArtifact = "com.tencent.mm:SevenZip:1.1.10"
// path = "/usr/local/bin/7za"
}
buildConfig {
keepDexApply = false
}
}

注意

  • AppKey:换成自己在Tinker平台上申请的。
  • baseInfo:基准包名称,使用Tinker脚本编译在模块的build/bakApk生成编译副本。
  • variantName: 这个一般对应buildTypes里面你基准包生成的类型,release、debug或其他
  • appVersion:配置和Tinker后台新建补丁包的一致。

其他地方可以暂时不用改

4. 配置模块下的build.gradle

配置签名

如果有不会的同学可以看这篇 Android Studio的两种模式及签名配置

2-4:配置签名

在配置混淆代码的时候,想要提醒下大家,当设置 minifyEnabled 为false时代表不混淆代码,shrinkResources也应设置为false ,它们通常是彼此关联。
要是你设置minifyEnabled 为false,shrinkResources为true,将会报异常,信息如下:

Error:A problem was found with the configuration of task':watch:packageOfficialDebug'.
File '...\build\intermediates\res\resources-official-debug-stripped.ap_' specified for property 'resourceFile' does not exist.

2-4-1:混淆配置

配置依赖

2-5:配置Tinker依赖

使用插件

2-6:使用Tinker插件

具体代码如下:

apply plugin: 'com.android.application'
apply from: 'tinkerpatch.gradle'

android {
compileSdkVersion 25
buildToolsVersion "25.0.3"
defaultConfig {
applicationId "qqt.com.tinkerdemo"
minSdkVersion 17
targetSdkVersion 25
versionCode 1
versionName "1.0.0"
multiDexEnabled true
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release {
storeFile file("./jks/tinker.jks")
storePassword "123456"
keyAlias "tinker"
keyPassword "123456"

}
debug {
storeFile file("./jks/debug.keystore")
}
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
signingConfig signingConfigs.debug
}
}
}

dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
exclude group: 'com.android.support', module: 'support-annotations'
})
compile 'com.android.support:appcompat-v7:25.3.1'
compile 'com.android.support.constraint:constraint-layout:1.0.2'
testCompile 'junit:junit:4.12'

annotationProcessor("com.tinkerpatch.tinker:tinker-android-anno:${TINKER_VERSION}") {
changing = true
}
provided("com.tinkerpatch.tinker:tinker-android-anno:${TINKER_VERSION}") {
changing = true
}
compile("com.tinkerpatch.sdk:tinkerpatch-android-sdk:${TINKERPATCH_VERSION}") {
changing = true
}

compile 'com.android.support:multidex:1.0.1'
}

5. 代码集成

最后一步,在自己的代码新建一个Application,把代码集成在App中,别忘了在AndroidManifest里面配置APP。。。
如图:

2-7:集成代码



我是继承MultiDexApplication主要是防止64k异常。有关这块知识,请看 Android 方法数超过64k、编译OOM、编译过慢解决方案

具体代码如下:

package qqt.com.tinkerdemo;

import android.support.multidex.MultiDexApplication;

import com.tencent.tinker.loader.app.ApplicationLike;
import com.tinkerpatch.sdk.TinkerPatch;
import com.tinkerpatch.sdk.loader.TinkerPatchApplicationLike;

/**
* 邮箱:ljh12520jy@163.com
*
*
@author Ljh on 2018/5/28
*/


public class App extends MultiDexApplication {

private ApplicationLike mApplicationLike;

@Override
public void onCreate() {
super.onCreate();
mApplicationLike = TinkerPatchApplicationLike.getTinkerPatchApplicationLike();
// 初始化TinkerPatch SDK, 更多配置可参照API章节中的,初始化SDK
TinkerPatch.init(mApplicationLike)
.reflectPatchLibrary()
.fetchPatchUpdate(true)
// 强制更新
.setPatchRollbackOnScreenOff(true)
.setPatchRestartOnSrceenOff(true)
.setFetchPatchIntervalByHours(3);

// 每隔3个小时(通过setFetchPatchIntervalByHours设置)去访问后台时候有更新,
//通过handler实现轮训的效果
TinkerPatch.with().fetchPatchUpdateAndPollWithInterval();
}
}

四、生成基准包

在生成基准包的时候,要注意一个问题,就是关闭 instant run(当tinkerEnable = true时,false的时候,就不需要),如图:

3-1:关闭InstantRun

在Android Studio的右上角,点击Gradle,如图:

3-2:准备生成基准包

双击assembleRelease生成成功后安装模块/build/outputs/apk/release/app-release.apk就OK了,这时候进去模块/build/bakApk里面记录一下类似app-1.0.0-0530-18-01-59的文件名称,只生成一次基准包,那么就会生成一个。这里需要注意一下,如果点太多生成太多的话确定不了刚刚生成的是哪个,那么就选最新那个或者删掉重新生成基准包。
生成后的基准包如图:

3-3:生成基准包

五、修改bug

在自己的代码中随便修改点代码(Tinker1.9.6 里面支持新增Activity代码)

六、生成补丁包

在生成补丁包前,我们需要去tinkerpatch.gradle文件下修改一些信息。

  • baseInfo :把前面app-1.0.0-0529-14-38-02换成我们刚生成记录下的基准包(app-1.0.0-0530-18-01-59)就可以。
  • variantName : 因为刚刚我们使用assembleRelease生成的补丁,所以我们只需要使用release

双击TinkerPatchRelease生成差分包,patch_signed_7zip.apk就是补丁包

生成的补丁包如图:

3-4:生成补丁包

3-5:tinkerPatch下的一些文件说明

七、发布补丁包

回到Tinker后台,选中我们开始新建的项目,补丁下发->添加APP版本。然后上传刚刚的patch_signed_7zip.apk。

APP开启强制更新的话那么重启应用就会更新,否则会通过轮询去更新。应用重启才生效。

3-6:发布补丁包

注:在Tinker后台发布的差分包(补丁包)是根据app-1.0.0-0530-18-01-59为基准包下,修复bug生成的补丁包,只对于app-1.0.0-0530-18-01-59版本的apk生效。

3-7:差分包


一、多渠道打包

tinker官方文档推荐用walle或者packer-ng-plugin来辅助打渠道包。估计有不少同学用过,今天我想推荐另外一款多渠道打包的插件ApkMultiChannelPlugin,它作为Android Studio插件进行多渠道打包。
安装步骤:打开 Android Studio: 打开 Setting/Preferences -> Plugins -> Browse repositories 然后搜索 ApkMultiChannel 安装重启。
有不了解的同学,可以直接看它的文档。

我是采用add channel file to META-INF方式进行多渠道打包,在这里提供一个读取渠道的工具类ChannelHelper。

二、多渠道打包步骤

1. 选择一个基准包

选择基准包的一个apk,然后右键,点击Build MultiChannel

1-1:选择基准包

2. 配置

配置签名信息,打包方式和渠道等。

1-2:配置多渠道

配置说明:

Key Store Path: 签名文件的路径
Key Store Password: 签名文件的密码
Key Alias: 密钥别名
Key Password: 密钥密码

Zipalign Path: zipalign 文件的路径(用于优化 apk;zipalign 可以确保所有未压缩的数据均是以相对于文件开始部分的特定字节对齐开始,这样可减少应用消耗的 RAM 量。)
Signer Version: 选择签名版本:apksigner 和 jarsigner
Build Type: 打包方式

Channels: 渠道列表,每行一个,最前面可加 > 或不加(保存信息的时候,程序会自行加上)

我们刚才刚才配置的东西会保存在根目录的 channels.properties里

1-3:channel配置文件

3. 开始打包

配置完成后,选择基准包的一个apk,然后右键,点击Build MultiChannel,就会开始进行多渠道打包,文件会输出在选中的apk的当前目录下的channels是目录下,如图:

1-4:多渠道打包

4. 发布APK

将刚才打包完成的包,分别发布到对应的应用市场。

5. 修改bug

随便修改部分代码

6. 生成补丁包

在生成补丁包前,我们需要去tinkerpatch.gradle文件下修改一些信息。

  • baseInfo :改成我们刚才选择基准包的目录app-1.0.1-0601-14-30-42就可以。
    双击TinkerPatchRelease生成差分包,patch_signed_7zip.apk就是补丁包
  • variantName : 因为刚刚我们使用assembleRelease生成的补丁,所以我们只需要使用release

双击TinkerPatchRelease生成差分包,patch_signed_7zip.apk就是补丁包

1-5:生成补丁包

7. 发布补丁包

回到Tinker后台,选中我们开始新建的项目,补丁下发->添加APP版本。然后上传刚刚的patch_signed_7zip.apk。

APP开启强制更新的话那么重启应用就会更新,否则会通过轮询去更新。应用重启才生效。

1-6: 发布补丁包.png

注:

  1. 这个补丁包对于以app-1.0.1-0601-14-30-42为基准宝,进行多渠道打包的apk都能生效(亲测成功),如果你把该渠道包进行360加固(protectedApp = true),也生效。
  2. 当我们在正式环境需要混淆代码:设置 minifyEnabled true,添加混淆:
-keep public class * implements com.tencent.tinker.loader.app.ApplicationLike

如图:

1-7: 混淆代码

本文转载自: https://cloud.tencent.com/developer/article/1872556

收起阅读 »

AFNetworking源码探究 —— UIKit相关之UIProgressView+AFNetworking分类

iOS
下面我们先看一下接口的API/** This category adds methods to the UIKit framework's `UIProgressView` class. The methods in this category provide...
继续阅读 »

接口API

下面我们先看一下接口的API

/**
This category adds methods to the UIKit framework's `UIProgressView` class. The methods in this category provide support for binding the progress to the upload and download progress of a session task.
*/
@interface UIProgressView (AFNetworking)

///------------------------------------
/// @name Setting Session Task Progress
///------------------------------------

/**
Binds the progress to the upload progress of the specified session task.

@param task The session task.
@param animated `YES` if the change should be animated, `NO` if the change should happen immediately.
*/
- (void)setProgressWithUploadProgressOfTask:(NSURLSessionUploadTask *)task
animated:(BOOL)animated;

/**
Binds the progress to the download progress of the specified session task.

@param task The session task.
@param animated `YES` if the change should be animated, `NO` if the change should happen immediately.
*/
- (void)setProgressWithDownloadProgressOfTask:(NSURLSessionDownloadTask *)task
animated:(BOOL)animated;

@end


该类为UIKit框架的UIProgressView类添加方法。 此类别中的方法为将进度绑定到会话任务的上载和下载进度提供了支持。

该接口比较少,其实就是一个上传任务和一个下载任务分别和进度的绑定,可动画。

这里大家还要注意一个关于类的继承的细节。

// 上传
@interface NSURLSessionUploadTask : NSURLSessionDataTask
@interface NSURLSessionDataTask : NSURLSessionTask

// 下载
@interface NSURLSessionDownloadTask : NSURLSessionTask

给大家贴出来就是想让大家注意下这个结构。

runtime获取是否可动画

这里还是用runtime分别绑定下载和上传是否可动画。

- (BOOL)af_uploadProgressAnimated {
return [(NSNumber *)objc_getAssociatedObject(self, @selector(af_uploadProgressAnimated)) boolValue];
}

- (void)af_setUploadProgressAnimated:(BOOL)animated {
objc_setAssociatedObject(self, @selector(af_uploadProgressAnimated), @(animated), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BOOL)af_downloadProgressAnimated {
return [(NSNumber *)objc_getAssociatedObject(self, @selector(af_downloadProgressAnimated)) boolValue];
}

- (void)af_setDownloadProgressAnimated:(BOOL)animated {
objc_setAssociatedObject(self, @selector(af_downloadProgressAnimated), @(animated), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

这个还算是很好理解的,有了前面的基础,这里就不多说了。

接口的实现

下面我们就看一下接口的实现。

1. 上传任务

static void * AFTaskCountOfBytesSentContext = &AFTaskCountOfBytesSentContext;
static void * AFTaskCountOfBytesReceivedContext = &AFTaskCountOfBytesReceivedContext;
- (void)setProgressWithUploadProgressOfTask:(NSURLSessionUploadTask *)task
animated:(BOOL)animated
{
if (task.state == NSURLSessionTaskStateCompleted) {
return;
}

[task addObserver:self forKeyPath:@"state" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesSentContext];
[task addObserver:self forKeyPath:@"countOfBytesSent" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesSentContext];

[self af_setUploadProgressAnimated:animated];
}

这里逻辑很清晰,简单的说一下,如果任务是完成状态,那么就直接return,然后给task添加KVO观察,观察属性是state和countOfBytesSent,最后就是设置是否可动画的状态。

2. 下载任务

- (void)setProgressWithDownloadProgressOfTask:(NSURLSessionDownloadTask *)task
animated:(BOOL)animated
{
if (task.state == NSURLSessionTaskStateCompleted) {
return;
}

[task addObserver:self forKeyPath:@"state" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesReceivedContext];
[task addObserver:self forKeyPath:@"countOfBytesReceived" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesReceivedContext];

[self af_setDownloadProgressAnimated:animated];
}

这里逻辑很清晰,简单的说一下,如果任务是完成状态,那么就直接return,然后给task添加KVO观察,观察属性是state和countOfBytesReceived,最后就是设置是否可动画的状态。


KVO观察实现

下面看一下KVO观察的实现,这里也是这个类的精髓所在。

- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(__unused NSDictionary *)change
context:(void *)context
{
if (context == AFTaskCountOfBytesSentContext || context == AFTaskCountOfBytesReceivedContext) {
if ([keyPath isEqualToString:NSStringFromSelector(@selector(countOfBytesSent))]) {
if ([object countOfBytesExpectedToSend] > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
[self setProgress:[object countOfBytesSent] / ([object countOfBytesExpectedToSend] * 1.0f) animated:self.af_uploadProgressAnimated];
});
}
}

if ([keyPath isEqualToString:NSStringFromSelector(@selector(countOfBytesReceived))]) {
if ([object countOfBytesExpectedToReceive] > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
[self setProgress:[object countOfBytesReceived] / ([object countOfBytesExpectedToReceive] * 1.0f) animated:self.af_downloadProgressAnimated];
});
}
}

if ([keyPath isEqualToString:NSStringFromSelector(@selector(state))]) {
if ([(NSURLSessionTask *)object state] == NSURLSessionTaskStateCompleted) {
@try {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(state))];

if (context == AFTaskCountOfBytesSentContext) {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(countOfBytesSent))];
}

if (context == AFTaskCountOfBytesReceivedContext) {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(countOfBytesReceived))];
}
}
@catch (NSException * __unused exception) {}
}
}
}
}

这里还是很简单的吧。

  • 如果keyPath是@"countOfBytesSent",那么就获取countOfBytesExpectedToSend,计算进度百分比,在主线程调用[self setProgress:[object countOfBytesSent] / ([object countOfBytesExpectedToSend] * 1.0f) animated:self.af_uploadProgressAnimated];得到进度。
  • 如果keyPath是@"countOfBytesReceived",那么就获取countOfBytesExpectedToReceive,计算进度百分比,在主线程调用[self setProgress:[object countOfBytesReceived] / ([object countOfBytesExpectedToReceive] * 1.0f) animated:self. af_downloadProgressAnimated];得到进度。
  • 如果keyPath是@"state"并且任务是完成状态NSURLSessionTaskStateCompleted,那么就要移除对这几个keyPath的观察者。

后记

本篇主要分析了UIProgressView+AFNetworking分类,主要实现了上传任务和下载任务与进度之间的绑定。

转载自:https://cloud.tencent.com/developer/article/1872398





收起阅读 »

AFNetworking源码探究(二十五) —— UIKit相关之UIRefreshControl+AFNetworking分类

iOS
上一篇主要分析了UIProgressView+AFNetworking分类,主要实现了上传任务和下载任务与进度之间的绑定。这一篇主要分析UIRefreshControl+AFNetworking这个分类。接口API下面我们先看一下接口的API/** This ...
继续阅读 »

回顾

上一篇主要分析了UIProgressView+AFNetworking分类,主要实现了上传任务和下载任务与进度之间的绑定。这一篇主要分析UIRefreshControl+AFNetworking这个分类。


接口API

下面我们先看一下接口的API

/**
This category adds methods to the UIKit framework's `UIProgressView` class. The methods in this category provide support for binding the progress to the upload and download progress of a session task.
*/
@interface UIProgressView (AFNetworking)

///------------------------------------
/// @name Setting Session Task Progress
///------------------------------------

/**
Binds the progress to the upload progress of the specified session task.

@param task The session task.
@param animated `YES` if the change should be animated, `NO` if the change should happen immediately.
*/
- (void)setProgressWithUploadProgressOfTask:(NSURLSessionUploadTask *)task
animated:(BOOL)animated;

/**
Binds the progress to the download progress of the specified session task.

@param task The session task.
@param animated `YES` if the change should be animated, `NO` if the change should happen immediately.
*/
- (void)setProgressWithDownloadProgressOfTask:(NSURLSessionDownloadTask *)task
animated:(BOOL)animated;

@end

该类为UIKit框架的UIProgressView类添加方法。 此类别中的方法为将进度绑定到会话任务的上载和下载进度提供了支持。

该接口比较少,其实就是一个上传任务和一个下载任务分别和进度的绑定,可动画。

这里大家还要注意一个关于类的继承的细节。

// 上传
@interface NSURLSessionUploadTask : NSURLSessionDataTask
@interface NSURLSessionDataTask : NSURLSessionTask

// 下载
@interface NSURLSessionDownloadTask : NSURLSessionTask

给大家贴出来就是想让大家注意下这个结构。


runtime获取是否可动画

这里还是用runtime分别绑定下载和上传是否可动画。

- (BOOL)af_uploadProgressAnimated {
return [(NSNumber *)objc_getAssociatedObject(self, @selector(af_uploadProgressAnimated)) boolValue];
}

- (void)af_setUploadProgressAnimated:(BOOL)animated {
objc_setAssociatedObject(self, @selector(af_uploadProgressAnimated), @(animated), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BOOL)af_downloadProgressAnimated {
return [(NSNumber *)objc_getAssociatedObject(self, @selector(af_downloadProgressAnimated)) boolValue];
}

- (void)af_setDownloadProgressAnimated:(BOOL)animated {
objc_setAssociatedObject(self, @selector(af_downloadProgressAnimated), @(animated), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

这个还算是很好理解的,有了前面的基础,这里就不多说了。


接口的实现

下面我们就看一下接口的实现。

1. 上传任务

static void * AFTaskCountOfBytesSentContext = &AFTaskCountOfBytesSentContext;
static void * AFTaskCountOfBytesReceivedContext = &AFTaskCountOfBytesReceivedContext;
- (void)setProgressWithUploadProgressOfTask:(NSURLSessionUploadTask *)task
animated:(BOOL)animated
{
if (task.state == NSURLSessionTaskStateCompleted) {
return;
}

[task addObserver:self forKeyPath:@"state" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesSentContext];
[task addObserver:self forKeyPath:@"countOfBytesSent" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesSentContext];

[self af_setUploadProgressAnimated:animated];
}

这里逻辑很清晰,简单的说一下,如果任务是完成状态,那么就直接return,然后给task添加KVO观察,观察属性是state和countOfBytesSent,最后就是设置是否可动画的状态。

2. 下载任务

- (void)setProgressWithDownloadProgressOfTask:(NSURLSessionDownloadTask *)task
animated:(BOOL)animated
{
if (task.state == NSURLSessionTaskStateCompleted) {
return;
}

[task addObserver:self forKeyPath:@"state" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesReceivedContext];
[task addObserver:self forKeyPath:@"countOfBytesReceived" options:(NSKeyValueObservingOptions)0 context:AFTaskCountOfBytesReceivedContext];

[self af_setDownloadProgressAnimated:animated];
}

这里逻辑很清晰,简单的说一下,如果任务是完成状态,那么就直接return,然后给task添加KVO观察,观察属性是state和countOfBytesReceived,最后就是设置是否可动画的状态。


KVO观察实现

下面看一下KVO观察的实现,这里也是这个类的精髓所在。

- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(__unused NSDictionary *)change
context:(void *)context
{
if (context == AFTaskCountOfBytesSentContext || context == AFTaskCountOfBytesReceivedContext) {
if ([keyPath isEqualToString:NSStringFromSelector(@selector(countOfBytesSent))]) {
if ([object countOfBytesExpectedToSend] > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
[self setProgress:[object countOfBytesSent] / ([object countOfBytesExpectedToSend] * 1.0f) animated:self.af_uploadProgressAnimated];
});
}
}

if ([keyPath isEqualToString:NSStringFromSelector(@selector(countOfBytesReceived))]) {
if ([object countOfBytesExpectedToReceive] > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
[self setProgress:[object countOfBytesReceived] / ([object countOfBytesExpectedToReceive] * 1.0f) animated:self.af_downloadProgressAnimated];
});
}
}

if ([keyPath isEqualToString:NSStringFromSelector(@selector(state))]) {
if ([(NSURLSessionTask *)object state] == NSURLSessionTaskStateCompleted) {
@try {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(state))];

if (context == AFTaskCountOfBytesSentContext) {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(countOfBytesSent))];
}

if (context == AFTaskCountOfBytesReceivedContext) {
[object removeObserver:self forKeyPath:NSStringFromSelector(@selector(countOfBytesReceived))];
}
}
@catch (NSException * __unused exception) {}
}
}
}
}

这里还是很简单的吧。

  • 如果keyPath是@"countOfBytesSent",那么就获取countOfBytesExpectedToSend,计算进度百分比,在主线程调用[self setProgress:[object countOfBytesSent] / ([object countOfBytesExpectedToSend] * 1.0f) animated:self.af_uploadProgressAnimated];得到进度。
  • 如果keyPath是@"countOfBytesReceived",那么就获取countOfBytesExpectedToReceive,计算进度百分比,在主线程调用[self setProgress:[object countOfBytesReceived] / ([object countOfBytesExpectedToReceive] * 1.0f) animated:self. af_downloadProgressAnimated];得到进度。
  • 如果keyPath是@"state"并且任务是完成状态NSURLSessionTaskStateCompleted,那么就要移除对这几个keyPath的观察者。

后记

本篇主要分析了UIProgressView+AFNetworking分类,主要实现了上传任务和下载任务与进度之间的绑定。

本文转载自腾讯社区:作者conanma  https://cloud.tencent.com/developer/article/1872401



收起阅读 »

AFNetworking源码探究 —— UIKit相关之AFAutoPurgingImageCache缓存

iOS
回顾上一篇主要讲述了UIRefreshControl+AFNetworking这个分类,将刷新状态和任务状态进行了绑定和同步。这一篇主要讲述AFAutoPurgingImageCache有关的缓存。接口API按照老惯例,我们还是先看一下接口API文档。这个接口...
继续阅读 »

回顾

上一篇主要讲述了UIRefreshControl+AFNetworking这个分类,将刷新状态和任务状态进行了绑定和同步。这一篇主要讲述AFAutoPurgingImageCache有关的缓存。


接口API

按照老惯例,我们还是先看一下接口API文档。这个接口文档包括三个部分,两个协议一个类。

  • 协议AFImageCache
  • 协议AFImageRequestCache
  • AFAutoPurgingImageCache

1. AFImageCache

这个协议包括四个方法

/**
Adds the image to the cache with the given identifier.

@param image The image to cache.
@param identifier The unique identifier for the image in the cache.
*/
- (void)addImage:(UIImage *)image withIdentifier:(NSString *)identifier;

/**
Removes the image from the cache matching the given identifier.

@param identifier The unique identifier for the image in the cache.

@return A BOOL indicating whether or not the image was removed from the cache.
*/
- (BOOL)removeImageWithIdentifier:(NSString *)identifier;

/**
Removes all images from the cache.

@return A BOOL indicating whether or not all images were removed from the cache.
*/
- (BOOL)removeAllImages;

/**
Returns the image in the cache associated with the given identifier.

@param identifier The unique identifier for the image in the cache.

@return An image for the matching identifier, or nil.
*/
- (nullable UIImage *)imageWithIdentifier:(NSString *)identifier;

该协议定义了包括加入、移除、获取缓存中的图片。

2. AFImageRequestCache

该协议包含下面几个方法,这里注意这个协议继承自协议AFImageCache

@protocol AFImageRequestCache <AFImageCache>

/**
Asks if the image should be cached using an identifier created from the request and additional identifier.

@param image The image to be cached.
@param request The unique URL request identifing the image asset.
@param identifier The additional identifier to apply to the URL request to identify the image.

@return A BOOL indicating whether or not the image should be added to the cache. YES will cache, NO will prevent caching.
*/
- (BOOL)shouldCacheImage:(UIImage *)image forRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;

/**
Adds the image to the cache using an identifier created from the request and additional identifier.

@param image The image to cache.
@param request The unique URL request identifing the image asset.
@param identifier The additional identifier to apply to the URL request to identify the image.
*/
- (void)addImage:(UIImage *)image forRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;

/**
Removes the image from the cache using an identifier created from the request and additional identifier.

@param request The unique URL request identifing the image asset.
@param identifier The additional identifier to apply to the URL request to identify the image.

@return A BOOL indicating whether or not all images were removed from the cache.
*/
- (BOOL)removeImageforRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;

/**
Returns the image from the cache associated with an identifier created from the request and additional identifier.

@param request The unique URL request identifing the image asset.
@param identifier The additional identifier to apply to the URL request to identify the image.

@return An image for the matching request and identifier, or nil.
*/
- (nullable UIImage *)imageforRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;

@end

根据请求和标识对图像进行是否需要缓存、加入到缓存或者移除缓存等进行操作。

3. AFAutoPurgingImageCache

这个是这个类的接口,大家注意下这个类遵循协议AFImageRequestCache

/**
The `AutoPurgingImageCache` in an in-memory image cache used to store images up to a given memory capacity. When the memory capacity is reached, the image cache is sorted by last access date, then the oldest image is continuously purged until the preferred memory usage after purge is met. Each time an image is accessed through the cache, the internal access date of the image is updated.
*/
@interface AFAutoPurgingImageCache : NSObject <AFImageRequestCache>

/**
The total memory capacity of the cache in bytes.
*/
// 内存缓存总的字节数
@property (nonatomic, assign) UInt64 memoryCapacity;

/**
The preferred memory usage after purge in bytes. During a purge, images will be purged until the memory capacity drops below this limit.
*/
// 以字节为单位清除后的首选内存使用情况。 在清除过程中,图像将被清除,直到内存容量降至此限制以下。
@property (nonatomic, assign) UInt64 preferredMemoryUsageAfterPurge;

/**
The current total memory usage in bytes of all images stored within the cache.
*/
// 当前所有图像内存缓存使用的总的字节数
@property (nonatomic, assign, readonly) UInt64 memoryUsage;

/**
Initialies the `AutoPurgingImageCache` instance with default values for memory capacity and preferred memory usage after purge limit. `memoryCapcity` defaults to `100 MB`. `preferredMemoryUsageAfterPurge` defaults to `60 MB`.
// 初始化,memoryCapcity为100M,preferredMemoryUsageAfterPurge为60M

@return The new `AutoPurgingImageCache` instance.
*/
- (instancetype)init;

/**
Initialies the `AutoPurgingImageCache` instance with the given memory capacity and preferred memory usage
after purge limit.

@param memoryCapacity The total memory capacity of the cache in bytes.
@param preferredMemoryCapacity The preferred memory usage after purge in bytes.

@return The new `AutoPurgingImageCache` instance.
*/
- (instancetype)initWithMemoryCapacity:(UInt64)memoryCapacity preferredMemoryCapacity:(UInt64)preferredMemoryCapacity;

@end

内存中图像缓存中的AutoPurgingImageCache用于存储图像到给定内存容量。 达到内存容量时,图像缓存按上次访问日期排序,然后最旧的图像不断清除,直到满足清除后的首选内存使用量。 每次通过缓存访问图像时,图像的内部访问日期都会更新。


AFAutoPurgingImageCache接口及初始化

从接口描述中我们可以看出来,类的初始化规定了内存总的使用量以及清楚以后的内存最优大小。

- (instancetype)init {
return [self initWithMemoryCapacity:100 * 1024 * 1024 preferredMemoryCapacity:60 * 1024 * 1024];
}

- (instancetype)initWithMemoryCapacity:(UInt64)memoryCapacity preferredMemoryCapacity:(UInt64)preferredMemoryCapacity {
if (self = [super init]) {
self.memoryCapacity = memoryCapacity;
self.preferredMemoryUsageAfterPurge = preferredMemoryCapacity;
self.cachedImages = [[NSMutableDictionary alloc] init];

NSString *queueName = [NSString stringWithFormat:@"com.alamofire.autopurgingimagecache-%@", [[NSUUID UUID] UUIDString]];
self.synchronizationQueue = dispatch_queue_create([queueName cStringUsingEncoding:NSASCIIStringEncoding], DISPATCH_QUEUE_CONCURRENT);

[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(removeAllImages)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];

}
return self;
}

我们看一下这个初始化方法中都做了什么事情

设置置缓存图像的字典

self.cachedImages = [[NSMutableDictionary alloc] init];

常见和UUID关联的并发队列

NSString *queueName = [NSString stringWithFormat:@"com.alamofire.autopurgingimagecache-%@", [[NSUUID UUID] UUIDString]];
self.synchronizationQueue = dispatch_queue_create([queueName cStringUsingEncoding:NSASCIIStringEncoding], DISPATCH_QUEUE_CONCURRENT);

增加移除所有图像的通知

[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(removeAllImages)
name:UIApplicationDidReceiveMemoryWarningNotification
object:nil];
- (BOOL)removeAllImages {
__block BOOL removed = NO;
dispatch_barrier_sync(self.synchronizationQueue, ^{
if (self.cachedImages.count > 0) {
[self.cachedImages removeAllObjects];
self.currentMemoryUsage = 0;
removed = YES;
}
});
return removed;
}

这里就是在上面生成的队列中,清空数组,重置一些属性值。


AFCachedImage接口及初始化

这里我们就看一下AFCachedImage的接口及初始化。

@interface AFCachedImage : NSObject

@property (nonatomic, strong) UIImage *image;
@property (nonatomic, strong) NSString *identifier;
@property (nonatomic, assign) UInt64 totalBytes;
@property (nonatomic, strong) NSDate *lastAccessDate;
@property (nonatomic, assign) UInt64 currentMemoryUsage;

@end

- (instancetype)initWithImage:(UIImage *)image identifier:(NSString *)identifier {
if (self = [self init]) {
self.image = image;
self.identifier = identifier;

CGSize imageSize = CGSizeMake(image.size.width * image.scale, image.size.height * image.scale);
CGFloat bytesPerPixel = 4.0;
CGFloat bytesPerSize = imageSize.width * imageSize.height;
self.totalBytes = (UInt64)bytesPerPixel * (UInt64)bytesPerSize;
self.lastAccessDate = [NSDate date];
}
return self;
}

这个初始化方法里面初始化图像的字节数,并更新上次获取数据的时间。


协议方法的实现

1. AFImageCache协议的实现

将图像根据标识添加到内存

- (void)addImage:(UIImage *)image withIdentifier:(NSString *)identifier;
- (void)addImage:(UIImage *)image withIdentifier:(NSString *)identifier {
dispatch_barrier_async(self.synchronizationQueue, ^{
AFCachedImage *cacheImage = [[AFCachedImage alloc] initWithImage:image identifier:identifier];

AFCachedImage *previousCachedImage = self.cachedImages[identifier];
if (previousCachedImage != nil) {
self.currentMemoryUsage -= previousCachedImage.totalBytes;
}

self.cachedImages[identifier] = cacheImage;
self.currentMemoryUsage += cacheImage.totalBytes;
});

dispatch_barrier_async(self.synchronizationQueue, ^{
if (self.currentMemoryUsage > self.memoryCapacity) {
UInt64 bytesToPurge = self.currentMemoryUsage - self.preferredMemoryUsageAfterPurge;
NSMutableArray <AFCachedImage*> *sortedImages = [NSMutableArray arrayWithArray:self.cachedImages.allValues];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"lastAccessDate"
ascending:YES];
[sortedImages sortUsingDescriptors:@[sortDescriptor]];

UInt64 bytesPurged = 0;

for (AFCachedImage *cachedImage in sortedImages) {
[self.cachedImages removeObjectForKey:cachedImage.identifier];
bytesPurged += cachedImage.totalBytes;
if (bytesPurged >= bytesToPurge) {
break ;
}
}
self.currentMemoryUsage -= bytesPurged;
}
});
}

这里用到了两个阻塞

第一个阻塞

dispatch_barrier_async(self.synchronizationQueue, ^{
AFCachedImage *cacheImage = [[AFCachedImage alloc] initWithImage:image identifier:identifier];

AFCachedImage *previousCachedImage = self.cachedImages[identifier];
if (previousCachedImage != nil) {
self.currentMemoryUsage -= previousCachedImage.totalBytes;
}

self.cachedImages[identifier] = cacheImage;
self.currentMemoryUsage += cacheImage.totalBytes;
});

这里的作用其实很清楚,就是先根据image和identify实例化AFCachedImage对象。然后在字典中根据identifier查看是否有AFCachedImage对象,如果有的话,那么就减小当前使用内存的值。并将前面实例化的AFCachedImage对象存入字典中,并增加当前使用内存的值。

第二个阻塞

dispatch_barrier_async(self.synchronizationQueue, ^{
if (self.currentMemoryUsage > self.memoryCapacity) {
UInt64 bytesToPurge = self.currentMemoryUsage - self.preferredMemoryUsageAfterPurge;
NSMutableArray <AFCachedImage*> *sortedImages = [NSMutableArray arrayWithArray:self.cachedImages.allValues];
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"lastAccessDate"
ascending:YES];
[sortedImages sortUsingDescriptors:@[sortDescriptor]];

UInt64 bytesPurged = 0;

for (AFCachedImage *cachedImage in sortedImages) {
[self.cachedImages removeObjectForKey:cachedImage.identifier];
bytesPurged += cachedImage.totalBytes;
if (bytesPurged >= bytesToPurge) {
break ;
}
}
self.currentMemoryUsage -= bytesPurged;
}
});

这里完成的功能是,首先判断如果当前内存使用量大于内存总量,那么就需要清理了,这里需要计算需要清理多少内存,就是当前内存值 - 最优内存值。然后sortedImages实例化字典中所有的图片,并对这些图片进行按照时间的排序,遍历这个排序后的数组,逐一从字典中移除,终止条件就是移除的字节数大于上面计算的要清除的字节数值。最后,更新下当前内存使用的值。

根据指定标识将图像移出内存

- (BOOL)removeImageWithIdentifier:(NSString *)identifier;
- (BOOL)removeImageWithIdentifier:(NSString *)identifier {
__block BOOL removed = NO;
dispatch_barrier_sync(self.synchronizationQueue, ^{
AFCachedImage *cachedImage = self.cachedImages[identifier];
if (cachedImage != nil) {
[self.cachedImages removeObjectForKey:identifier];
self.currentMemoryUsage -= cachedImage.totalBytes;
removed = YES;
}
});
return removed;
}

这个还是很好理解的,在定义的并行队列中,取出identifier对应的AFCachedImage对象,然后从字典中移除,并更新当前内存的值。

从内存中移除所有的图像

- (BOOL)removeAllImages;
- (BOOL)removeAllImages {
__block BOOL removed = NO;
dispatch_barrier_sync(self.synchronizationQueue, ^{
if (self.cachedImages.count > 0) {
[self.cachedImages removeAllObjects];
self.currentMemoryUsage = 0;
removed = YES;
}
});
return removed;
}

其实就是一句话,清空字典,更新当前内存使用值。

根据指定的标识符从内存中获取图像

- (nullable UIImage *)imageWithIdentifier:(NSString *)identifier;
- (nullable UIImage *)imageWithIdentifier:(NSString *)identifier {
__block UIImage *image = nil;
dispatch_sync(self.synchronizationQueue, ^{
AFCachedImage *cachedImage = self.cachedImages[identifier];
image = [cachedImage accessImage];
});
return image;
}
- (UIImage*)accessImage {
self.lastAccessDate = [NSDate date];
return self.image;
}

其实就是从字典中取值,并更新上次获取图像的时间。

2. AFImageRequestCache协议的实现

根据请求和标识符将图像加入到内存

- (void)addImage:(UIImage *)image forRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;
- (void)addImage:(UIImage *)image forRequest:(NSURLRequest *)request withAdditionalIdentifier:(NSString *)identifier {
[self addImage:image withIdentifier:[self imageCacheKeyFromURLRequest:request withAdditionalIdentifier:identifier]];
}
- (NSString *)imageCacheKeyFromURLRequest:(NSURLRequest *)request withAdditionalIdentifier:(NSString *)additionalIdentifier {
NSString *key = request.URL.absoluteString;
if (additionalIdentifier != nil) {
key = [key stringByAppendingString:additionalIdentifier];
}
return key;
}

这里其实是调用上面我们讲过的那个根据identifier取出AFCachedImage对象的那个方法。不过下面这个identifier是通过调用下面这个方法生成的。、

根据请求和标识符将图像移出内存

- (BOOL)removeImageforRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;
- (BOOL)removeImageforRequest:(NSURLRequest *)request withAdditionalIdentifier:(NSString *)identifier {
return [self removeImageWithIdentifier:[self imageCacheKeyFromURLRequest:request withAdditionalIdentifier:identifier]];
}

这个,就是还是利用那个生成indentifier的方法,获取identify,然后调用前面我们讲过的方法移除对应的图像。

根据请求和标识符获取内存中图像

- (nullable UIImage *)imageforRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;
- (nullable UIImage *)imageforRequest:(NSURLRequest *)request withAdditionalIdentifier:(NSString *)identifier {
return [self imageWithIdentifier:[self imageCacheKeyFromURLRequest:request withAdditionalIdentifier:identifier]];
}

这个,就是还是利用那个生成indentifier的方法,获取identify,然后调用前面我们讲过的方法获取对应的图像。

是否将图像缓存到内存

- (BOOL)shouldCacheImage:(UIImage *)image forRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier;
- (BOOL)shouldCacheImage:(UIImage *)image forRequest:(NSURLRequest *)request withAdditionalIdentifier:(nullable NSString *)identifier {
return YES;
}

这里就是写死的,默认就是需要进行缓存。

后记

本篇主要讲述了关于图像缓存方面的内容,包括使用标识符或者请求进行图像相关的缓存操作

本文转载自:https://cloud.tencent.com/developer/article/1872403

收起阅读 »

Idea不推荐使用@Autowired进行Field注入的原因

大家在使用IDEA开发的时候有没有注意到过一个提示,在字段上使用Spring的依赖注入注解@Autowired后会出现如下警告 Field injection is not recommended (字段注入是不被推荐的) 但是使用@Resource却不会...
继续阅读 »

大家在使用IDEA开发的时候有没有注意到过一个提示,在字段上使用Spring的依赖注入注解@Autowired后会出现如下警告



Field injection is not recommended (字段注入是不被推荐的)



但是使用@Resource却不会出现此提示


网上文章大部分都是介绍两者的区别,没有提到为什么,当时想了好久想出了可能的原因,今天来总结一下


Spring常见的DI方式



  • 构造器注入:利用构造方法的参数注入依赖

  • Setter注入:调用Setter的方法注入依赖

  • 字段注入:在字段上使用@Autowired/Resource注解


@Autowired VS @Resource


事实上,他们的基本功能都是通过注解实现依赖注入,只不过@AutowiredSpring定义的,而@ResourceJSR-250定义的。大致功能基本相同,但是还有一些细节不同:



  • 依赖识别方式:@Autowired默认是byType可以使用@Qualifier指定Name,@Resource默认ByName如果找不到则ByType

  • 适用对象:@Autowired可以对构造器、方法、参数、字段使用,@Resource只能对方法、字段使用

  • 提供方:@Autowired是Spring提供的,@Resource是JSR-250提供的


各种DI方式的优缺点


参考Spring官方文档,建议了如下的使用场景:



  • 构造器注入强依赖性(即必须使用此依赖),不变性(各依赖不会经常变动)

  • Setter注入可选(没有此依赖也可以工作),可变(依赖会经常变动)

  • Field注入:大多数情况下尽量少使用字段注入,一定要使用的话, @Resource相对@Autowired对IoC容器的耦合更低


Field注入的缺点



  • 不能像构造器那样注入不可变的对象

  • 依赖对外部不可见,外界可以看到构造器和setter,但无法看到私有字段,自然无法了解所需依赖

  • 会导致组件与IoC容器紧耦合(这是最重要的原因,离开了IoC容器去使用组件,在注入依赖时就会十分困难)

  • 导致单元测试也必须使用IoC容器,原因同上

  • 依赖过多时不够明显,比如我需要10个依赖,用构造器注入就会显得庞大,这时候应该考虑一下此组件是不是违反了单一职责原则


为什么IDEA只对@Autowired警告


Field注入虽然有很多缺点,但它的好处也不可忽略:那就是太方便了。使用构造器或者setter注入需要写更多业务无关的代码,十分麻烦,而字段注入大幅简化了它们。并且绝大多数情况下业务代码和框架就是强绑定的,完全松耦合只是一件理想上的事,牺牲了敏捷度去过度追求松耦合反而得不偿失。


那么问题来了,为什么IDEA只对@Autowired警告,却对@Resource视而不见呢?


个人认为,就像我们前面提到过的: @AutowiredSpring提供的,它是特定IoC提供的特定注解,这就导致了应用与框架的强绑定,一旦换用了其他的IoC框架,是不能够支持注入的。而 @ResourceJSR-250提供的,它是Java标准,我们使用的IoC容器应当去兼容它,这样即使更换容器,也可以正常工作。


作者:小亮哥Ya
链接:https://juejin.cn/post/7080441168462348319
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

官方core-ktx库能对富文本Span开发带来哪些便利?

当前SpannableStringBuilder的使用现状private fun test() { val stringBuilder = SpannableStringBuilder() var length = stringBuilder....
继续阅读 »

当前SpannableStringBuilder的使用现状

private fun test() {
val stringBuilder = SpannableStringBuilder()
var length = stringBuilder.length
stringBuilder.append("开始了")
//设置文本大小
stringBuilder.setSpan(
RelativeSizeSpan(20f),
length,
stringBuilder.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
length = stringBuilder.length
stringBuilder.append("执行了")
//设置背景颜色
stringBuilder.setSpan(
BackgroundColorSpan(Color.parseColor("#ffffff")),
length,
stringBuilder.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
length = stringBuilder.length
stringBuilder.append("结束了")
//设置点击事件
stringBuilder.setSpan(
object : ClickableSpan() {
override fun onClick(widget: View) {
}
},
length,
stringBuilder.length,
Spannable.SPAN_INCLUSIVE_EXCLUSIVE
)
}

以上代码就实现了三个功能,设置文本大小、背景颜色及点击事件,却写了这么一大坨代码,写起来好麻烦!!

core-ktx库的SpannableStringBuilder扩展

  1. 看下如何构造一个SpannableStringBuilder:

    image.png

    我们就可以在代码中这样使用:

    private fun test4() {
    val build = buildSpannedString {
    //操作各种Span
    }
    }

    请注意,这个buildSpannedString()方法的函数类型属于带接收者的函数类型,意为着我们可以访问SpannableStringBuilder定义的公共的属性方法(包括扩展方法),接下来我们就看下core-ktx库为SpannableStringBuilder提供了哪些扩展方法。

  2. SpannableStringBuilder.backgroundColor()设置背景色:

    image.png

    这个扩展方法需要传入一个颜色值充当背景色,backgroundColor()会自动帮助我们创建一个ForegroundColorSpan对象;还可以传入一个函数类型builderAction,比如用作使用append()方法设置要渲染的文本内容,最终会调用到inSpan()方法:

    image.png

    是不是明白了,最终我们是在这个方法中将xxxSpan设置给SpannableStringBuilder的。最终就可以这样使用了:

    val build = buildSpannedString {
    //操作各种Span
    backgroundColor(Color.RED) {
    append("开始了")
    }
    }
  3. SpannableStringBuilder.bold()设置粗体:

    image.png

    可以看到bold()方法中会自动帮助我们创建一个StyleSpan对象,使用起来和上面差不多:

    val build = buildSpannedString {
    bold {
    append("开始了")
    }
    }
  4. 其他SpannableStringBuilder.xxx()富文本设置扩展:

    core-ktx库提供了很多富文本设置的扩展方法,这里就只介绍上面的两个,其他的就不再这里介绍了,可以自行看下源码:

    image.png

  5. 一个非常非常简单的使用技巧

    假设当前有一小段文本遮天是一群人的完美,完美是一个人的遮天,我想要对整段文本设置一个背景色,对一群人这三个字设置一个粗体大小,利用上面core-ktx库提供的扩展,我们可以这样实现:

    private fun test4() {
    val build = buildSpannedString {
    backgroundColor(Color.RED) {
    append("遮天是")
    bold {
    append("一群人")
    }
    append("的完美,完美是一个人的遮天")
    }
    }
    }

    核心就是SpannableStringBuilder.xxx()系列的富文本扩展方法的第二个参数是一个接收者为SpannableStringBuilder的函数类型,所以backgroundColor()bold()strikeThrough()等等可以相互嵌套使用,从来更简单的实现一些富文本效果。

使用时请注意,buildSpannedString()这个方法创建的SpannableStringBuilder最终会包装成一个SpannedString不可变对象,请根据实际情况使用。

core-ktx库的Spannable扩展

SpannableStringBuilderSpannableString等实现了Spannable接口,所以Spannable定义的扩展方法对常用的SpannableStringBuilderSpannableString同样适用。

  1. Spannable.clearSpans清理所有标识(包括各种Span)

    image.png

    使用时,直接对Spannable及其子类调用clearSpans()即可。

  2. Spannable.set(start: Int, end: Int, span: Any)设置Span

    image.png

    这个扩展方法就比较牛逼了,它是一个运算符重载函数且重载了[xxx]运算符来设置Span的,我们看下使用:

    val stringBuilder = SpannableStringBuilder()
    //设置背景色
    stringBuilder[0, 2] = BackgroundColorSpan(Color.RED)

    有没有眼前一亮的感觉哈!!

  3. Spannable.set(range: IntRange, span: Any)设置Span

    image.png

    这个方法和上一个方法很像,不过传入的设置Span标识范围的方式发生了改变,变成了一个IntRange类型,我们直接看下使用:

    val stringBuilder = SpannableStringBuilder()
    //设置背景色
    stringBuilder[0..3] = BackgroundColorSpan(Color.RED)
  4. CharSequence.toSpannable()转换CharSequenceSpannableString

    image.png

    这个很简单,就不再进行举例说明了。

core-ktx库的Spanned扩展

Spanned的子接口包括我们上面刚讲到的Spannable,所以它定义的扩展方法对于SpannableStringBuilderSpannableString同样适用。

  1. CharSequence.toSpanned()转换CharSequenceSpannedString

    image.png

    注意和isSpannable()转换的区别,一个能设置Span,一个不能设。

  2. Spanned.getSpans()获取指定类型的Span标识

    image.png

    借助于Kotlin的泛型实化reified+inline简化了传入具体Span类型的逻辑,我们看下使用:

    private fun test4(builder: SpannableStringBuilder) {
    val spans = builder.getSpans<BackgroundColorSpan>()
    }

    获取类型为BackgroudColorSpan的所有Span对象,如果我们想要获取所有的Span对象,直接将传入的泛型类型改为Any即可。

  3. Spanned.toHtml()将富文本转换成同等效果显示的html代码

    image.png

    也就是说如果你富文本中存在ImageSpan,转换成html代码时,就会帮你在对应位置添加一个<img src="" />的标签,我们简单看下其核心源码Html.withinParagraph()中的片段:

    image.png

富文本绘制复杂布局的两种技巧

  1. ReplacementSpan这个Span使用非常灵活,它提供了方法draw()可自定义绘制你想要的布局效果;

  2. 如果使用ReplacementSpan自定义绘制布局还是太过于复杂,可以考虑先使用原生组件在xml中实现这个布局效果,然后将这个xml通过Inflate转换成View,并将调用ViewonDraw()方法,手动绘制到我们自定义Bitmap中,经过这个流程,我们就将这个复杂的布局转换成了Bitmap图像,然后使用ImageSpan加载该Bitmap,最终渲染到富文本中即可。

    请注意,请根据实际情况判断,是否需要先手动测量这个转换的View,然后再将其绘制到我们自定义的Bitmap中,否则可能不生效。

总结

以上就是core-ktx库针对于富文本提供的所有扩展方法,核心的源码就在SpannableStringBuilder.ktSpannableString.ktSpannedString.kt这三个文件中,大家有需要请自行查看。


作者:长安皈故里
链接:https://juejin.cn/post/7116920821150400519
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android 无障碍监听通知的过程

监听通知 Android 中的 AccessibilityService 可以监听通知信息的变化,首先需要创建一个无障碍服务,这个教程可以自行百度。在无障碍服务的配置文件中,需要以下配置: <accessibility-service ... and...
继续阅读 »

监听通知


Android 中的 AccessibilityService 可以监听通知信息的变化,首先需要创建一个无障碍服务,这个教程可以自行百度。在无障碍服务的配置文件中,需要以下配置:


<accessibility-service
...
android:accessibilityEventTypes="其他内容|typeNotificationStateChanged"
android:canRetrieveWindowContent="true" />

然后在 AccessibilityService 的 onAccessibilityEvent 方法中监听消息:


override fun onAccessibilityEvent(event: AccessibilityEvent?) {
when (event.eventType) {
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED -> {
Log.d(Tag, "Notification: $event")
}
}
}

当有新的通知或 Toast 出现时,在这个方法中就会收到 AccessibilityEvent 。


另一种方案是通过 NotificationListenerService 进行监听,这里不做详细介绍了。两种方案的应用场景不同,推荐使用 NotificationListenerService 而不是无障碍服务。stackoverflow 上一个比较好的回答:



It depends on WHY you want to read it. The general answer would be Notification Listener. Accessibility Services are for unique accessibility services. A user has to enable an accessibility service from within the Accessibility Service menu (where TalkBack and Switch Access are). Their ability to read notifications is a secondary ability, to help them achieve the goal of creating assistive technologies (alternative ways for people to interact with mobile devices).


Whereas, Notification Listeners, this is their primary goal. They exist as part of the context of an app and as such don't need to be specifically turned on from the accessibility menu.


Basically, unless you are in fact building an accessibility service, you should not use this approach, and go with the generic Notification Listener.



无障碍服务监听通知逻辑


从用法中可以看出一个关键信息 -- TYPE_NOTIFICATION_STATE_CHANGED ,通过这个事件类型入手,发现它用于两个类中:



  • ToastPresenter:用于在应用程序进程中展示系统 UI 样式的 Toast 。

  • NotificationManagerService:通知管理服务。


ToastPresenter


ToastPresenter 的 trySendAccessibilityEvent 方法中,构建了一个 TYPE_NOTIFICATION_STATE_CHANGED 类型的消息:


public void trySendAccessibilityEvent(View view, String packageName) {
if (!mAccessibilityManager.isEnabled()) {
return;
}
AccessibilityEvent event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
event.setClassName(Toast.class.getName());
event.setPackageName(packageName);
view.dispatchPopulateAccessibilityEvent(event);
mAccessibilityManager.sendAccessibilityEvent(event);
}

这个方法的调用在 ToastPresenter 中的 show 方法中:


public void show(View view, IBinder token, IBinder windowToken, int duration, int gravity,
int xOffset, int yOffset, float horizontalMargin, float verticalMargin,
@Nullable ITransientNotificationCallback callback) {
// ...
trySendAccessibilityEvent(mView, mPackageName);
// ...
}

而这个方法的调用就是在 Toast 中的 TN 类中的 handleShow 方法。


Toast.makeText(this, "", Toast.LENGTH_SHORT).show()

在 Toast 的 show 方法中,获取了一个 INotificationManager ,这个是 NotificationManagerService 在客户端暴露的 Binder 对象,通过这个 Binder 对象的方法可以调用 NMS 中的逻辑。


也就是说,Toast 的 show 方法调用了 NMS :


public void show() {
// ...
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
final int displayId = mContext.getDisplayId();

try {
if (Compatibility.isChangeEnabled(CHANGE_TEXT_TOASTS_IN_THE_SYSTEM)) {
if (mNextView != null) {
// It's a custom toast
service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
} else {
// It's a text toast
ITransientNotificationCallback callback = new CallbackBinder(mCallbacks, mHandler);
service.enqueueTextToast(pkg, mToken, mText, mDuration, displayId, callback);
}
} else {
service.enqueueToast(pkg, mToken, tn, mDuration, displayId);
}
} catch (RemoteException e) {
// Empty
}
}

这里是 enqueueToast 方法中,最后调用:


private void enqueueToast(String pkg, IBinder token, @Nullable CharSequence text,
@Nullable ITransientNotification callback, int duration, int displayId,
@Nullable ITransientNotificationCallback textCallback) {
// ...
record = getToastRecord(callingUid, callingPid, pkg, token, text, callback, duration, windowToken, displayId, textCallback);
// ...
}

getToastRecord 中根据 callback 是否为空产生了不同的 Toast :


private ToastRecord getToastRecord(int uid, int pid, String packageName, IBinder token,
@Nullable CharSequence text, @Nullable ITransientNotification callback, int duration,
Binder windowToken, int displayId,
@Nullable ITransientNotificationCallback textCallback) {
if (callback == null) {
return new TextToastRecord(this, mStatusBar, uid, pid, packageName, token, text,duration, windowToken, displayId, textCallback);
} else {
return new CustomToastRecord(this, uid, pid, packageName, token, callback, duration, windowToken, displayId);
}
}

两者的区别是展示对象的不同:




  • TextToastRecord 因为 ITransientNotification 为空,所以它是通过 mStatusBar 进行展示的:


        @Override
    public boolean show() {
    if (DBG) {
    Slog.d(TAG, "Show pkg=" + pkg + " text=" + text);
    }
    if (mStatusBar == null) {
    Slog.w(TAG, "StatusBar not available to show text toast for package " + pkg);
    return false;
    }
    mStatusBar.showToast(uid, pkg, token, text, windowToken, getDuration(), mCallback);
    return true;
    }



  • CustomToastRecord 调用 ITransientNotification 的 show 方法:


        @Override
    public boolean show() {
    if (DBG) {
    Slog.d(TAG, "Show pkg=" + pkg + " callback=" + callback);
    }
    try {
    callback.show(windowToken);
    return true;
    } catch (RemoteException e) {
    Slog.w(TAG, "Object died trying to show custom toast " + token + " in package "
    + pkg);
    mNotificationManager.keepProcessAliveForToastIfNeeded(pid);
    return false;
    }
    }

    这个 callback 最在 Toast.show() 时传进去的 TN :


    TN tn = mTN;
    service.enqueueToast(pkg, mToken, tn, mDuration, displayId);

    也就是调用到了 TN 的 show 方法:


            @Override
    @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023)
    public void show(IBinder windowToken) {
    if (localLOGV) Log.v(TAG, "SHOW: " + this);
    mHandler.obtainMessage(SHOW, windowToken).sendToTarget();
    }




TN 的 show 方法中通过 mHandler 来传递了一个类型是 SHOW 的消息:


            mHandler = new Handler(looper, null) {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case SHOW: {
IBinder token = (IBinder) msg.obj;
handleShow(token);
break;
}
case HIDE: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
break;
}
case CANCEL: {
handleHide();
// Don't do this in handleHide() because it is also invoked by
// handleShow()
mNextView = null;
try {
getService().cancelToast(mPackageName, mToken);
} catch (RemoteException e) {
}
break;
}
}
}
};

而这个 Handler 在处理 SHOW 时,会调用 handleShow(token) 这个方法里面也就是会触发 ToastPresenter 的 show 方法的地方:


public void handleShow(IBinder windowToken) {
// If a cancel/hide is pending - no need to show - at this point
// the window token is already invalid and no need to do any work.
if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
return;
}
if (mView != mNextView) {
// remove the old view if necessary
handleHide();
mView = mNextView;
// 【here】
mPresenter.show(mView, mToken, windowToken, mDuration, mGravity, mX, mY, mHorizontalMargin, mVerticalMargin, new CallbackBinder(getCallbacks(), mHandler));
}
}

本章节最开始介绍到了 ToastPresenter 的 show 方法中会调用 trySendAccessibilityEvent 方法,也就是从这个方法发送类型是 TYPE_NOTIFICATION_STATE_CHANGED 的无障碍消息给无障碍服务的。


NotificationManagerService


在通知流程中,是通过 NMS 中的 sendAccessibilityEvent 方法来向无障碍发送消息的:


void sendAccessibilityEvent(Notification notification, CharSequence packageName) {
if (!mAccessibilityManager.isEnabled()) {
return;
}

AccessibilityEvent event =
AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED);
event.setPackageName(packageName);
event.setClassName(Notification.class.getName());
event.setParcelableData(notification);
CharSequence tickerText = notification.tickerText;
if (!TextUtils.isEmpty(tickerText)) {
event.getText().add(tickerText);
}

mAccessibilityManager.sendAccessibilityEvent(event);
}

这个方法的调用有两处,均在 NMS 的 buzzBeepBlinkLocked 方法中,buzzBeepBlinkLocked 方法是用来处理通知是否应该发出铃声、震动或闪烁 LED 的。省略无关逻辑:


int buzzBeepBlinkLocked(NotificationRecord record) {
// ...
if (!record.isUpdate && record.getImportance() > IMPORTANCE_MIN && !suppressedByDnd) {
sendAccessibilityEvent(notification, record.getSbn().getPackageName());
sentAccessibilityEvent = true;
}

if (aboveThreshold && isNotificationForCurrentUser(record)) {
if (mSystemReady && mAudioManager != null) {
// ...
if (hasAudibleAlert && !shouldMuteNotificationLocked(record)) {
if (!sentAccessibilityEvent) {
sendAccessibilityEvent(notification, record.getSbn().getPackageName());
sentAccessibilityEvent = true;
}
// ...
} else if ((record.getFlags() & Notification.FLAG_INSISTENT) != 0) {
hasValidSound = false;
}
}
}
// ...
}

buzzBeepBlinkLocked 的调用路径有两个:




  • handleRankingReconsideration 方法中 RankingHandlerWorker (这是一个 Handler)调用 handleMessage 处理 MESSAGE_RECONSIDER_RANKING 类型的消息:


    @Override
    public void handleMessage(Message msg) {
    switch (msg.what) {
    case MESSAGE_RECONSIDER_RANKING:
    handleRankingReconsideration(msg);
    break;
    case MESSAGE_RANKING_SORT:
    handleRankingSort();
    break;
    }
    }

    handleRankingReconsideration 方法中调用了 buzzBeepBlinkLocked :


    private void handleRankingReconsideration(Message message) {
    // ...
    synchronized (mNotificationLock) {
    // ...
    if (interceptBefore && !record.isIntercepted()
    && record.isNewEnoughForAlerting(System.currentTimeMillis())) {
    buzzBeepBlinkLocked(record);
    }
    }
    if (changed) {
    mHandler.scheduleSendRankingUpdate();
    }
    }



  • PostNotificationRunnable 的 run 方法。




PostNotificationRunnable


这个东西是用来发送通知并进行处理的,例如提示和重排序等。


PostNotificationRunnable 的构建和 post 在 EnqueueNotificationRunnable 中。在 EnqueueNotificationRunnable 的 run 最后,进行了 post:


public void run() {
// ...
// tell the assistant service about the notification
if (mAssistants.isEnabled()) {
mAssistants.onNotificationEnqueuedLocked(r);
mHandler.postDelayed(new PostNotificationRunnable(r.getKey()), DELAY_FOR_ASSISTANT_TIME);
} else {
mHandler.post(new PostNotificationRunnable(r.getKey()));
}
}

EnqueueNotificationRunnable 在 enqueueNotificationInternal 方法中使用,enqueueNotificationInternal 方法是 INotificationManager 接口中定义的方法,它的实现在 NotificationManager 中:


    public void notifyAsPackage(@NonNull String targetPackage, @Nullable String tag, int id,
@NonNull Notification notification) {
INotificationManager service = getService();
String sender = mContext.getPackageName();

try {
if (localLOGV) Log.v(TAG, sender + ": notify(" + id + ", " + notification + ")");
service.enqueueNotificationWithTag(targetPackage, sender, tag, id,
fixNotification(notification), mContext.getUser().getIdentifier());
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}

@UnsupportedAppUsage
public void notifyAsUser(String tag, int id, Notification notification, UserHandle user)
{
INotificationManager service = getService();
String pkg = mContext.getPackageName();

try {
if (localLOGV) Log.v(TAG, pkg + ": notify(" + id + ", " + notification + ")");
service.enqueueNotificationWithTag(pkg, mContext.getOpPackageName(), tag, id,
fixNotification(notification), user.getIdentifier());
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}

一般发送一个通知都是通过 NotificationManager 或 NotificationManagerCompat 来发送的,例如:


NotificationManagerCompat.from(this).notify(1, builder.build());

NotificationManagerCompat 中的 notify 方法本质上调用的是 NotificationManager:


// NotificationManagerCompat
public void notify(int id, @NonNull Notification notification) {
notify(null, id, notification);
}

public void notify(@Nullable String tag, int id, @NonNull Notification notification) {
if (useSideChannelForNotification(notification)) {
pushSideChannelQueue(new NotifyTask(mContext.getPackageName(), id, tag, notification));
// Cancel this notification in notification manager if it just transitioned to being side channelled.
mNotificationManager.cancel(tag, id);
} else {
mNotificationManager.notify(tag, id, notification);
}
}

mNotificationManager.notify(tag, id, notification) 中的实现:


public void notify(String tag, int id, Notification notification) {
notifyAsUser(tag, id, notification, mContext.getUser());
}

public void cancel(@Nullable String tag, int id) {
cancelAsUser(tag, id, mContext.getUser());
}

串起来了,最终就是通过 NotificationManager 的 notify 相关方法发送通知,然后触发了通知是否要触发铃声/震动/LED 闪烁的逻辑,并且在这个逻辑中,发送出了无障碍消息。


作者:自动化BUG制造器
链接:https://juejin.cn/post/7117191453821894686
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

监控主线程耗时操作,从开发中解决ANR

ANR
背景:在 debug 环境中主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,减少 ANR 的发生此工具类主要是通过向主线程Looper打印超过指定时间的耗时堆栈信息以及耗时时...
继续阅读 »

背景:

在 debug 环境中主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,减少 ANR 的发生

此工具类主要是通过向主线程Looper打印超过指定时间的耗时堆栈信息以及耗时时长,其中校验时间自已定义,主动查看主线程中的耗时操作,防患未然。

原理:

此工具类为最简单最直接处理、优化耗时操作的工具

大家都知道Android 对于ANR的判断标准:

最简单的一句话就是:ANR——应用无响应,Activity是5秒,BroadCastReceiver是10秒,Service是20秒

然后此工具类的方案就是将主线程的堆栈信息作时间对比监控,超时的打印出来

Looper.loop 解析:

  1. 应用之所以未退出,就是运行在loop 中,如果有阻塞loop 的操作就会发生ANR、崩溃
public static void loop() {
final Looper me = myLooper();
//....
for (;;) {
if (!loopOnce(me, ident, thresholdOverride)) {
return;
}
}
}
  1. 主要看死循环

loopOnce

private static boolean loopOnce(final Looper me,
final long ident, final int thresholdOverride) {
Message msg = me.mQueue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return false;
}

// This must be in a local variable, in case a UI event sets the logger
// *当有任务的时候打印Dispatching to *
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " "
+ msg.callback + ": " + msg.what);
}
//.... 中间部分未任务执行的代码

//执行结束之后打印 Finished to
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

// Make sure that during the course of dispatching the
// identity of the thread wasn't corrupted.
final long newIdent = Binder.clearCallingIdentity();
if (ident != newIdent) {
Log.wtf(TAG, "Thread identity changed from 0x"
+ Long.toHexString(ident) + " to 0x"
+ Long.toHexString(newIdent) + " while dispatching to "
+ msg.target.getClass().getName() + " "
+ msg.callback + " what=" + msg.what);
}

msg.recycleUnchecked();

return true;
}
  1. 上述注释之间的耗时就是主线程在执行某个任务时的耗时,我们只要拿这个时间和指定时间相比就能监控主线程的耗时堆栈信息了

使用方式:

  1. Application:
 //主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,
MainThreadDoctor.init(500)
  1. 查看日志:

image.png

日志等级为明显起见使用error级别

工具类:

 /**
* @author kong
* @date 2022/7/6 15:55
* @description 在debug环境中主线程中方法体执行的时间与指定的时间做对比后的堆栈信息,针对性的优化超过指定时间的耗时方法体,减少ANR的发生
**/
object MainThreadDoctor {

private var startTime = 0L
private var currentJob: Job? = null
private const val START = ">>>>> Dispatching"
private const val END = "<<<<< Finished"

fun init(diagnoseStandardTime: Long) {
if (BuildConfigs.DEBUG) {
diagnoseFromMainThread(diagnoseStandardTime)
}
}

/**
* @param diagnoseStandardTime 执行诊断的标准时间
*/
fun diagnoseFromMainThread(diagnoseStandardTime: Long) {
Looper.getMainLooper().setMessageLogging {
if (it.startsWith(START)) {
startTime = System.currentTimeMillis()
currentJob = GlobalScope.launch(Dispatchers.IO) {
delay(diagnoseStandardTime)
val stackTrace = Looper.getMainLooper().thread.stackTrace
val builder = StringBuilder()
for (s in stackTrace) {
builder.append(s.toString())
builder.append("\n")
}
PPLog.e("looperMessageMain $builder")
}
}

if (it.startsWith(END)) {
if (currentJob?.isCompleted == false) {
currentJob?.cancel()
} else {
PPLog.e("looperMessageMain 总时间 = ${System.currentTimeMillis() - startTime} 毫秒")
}
}
}
}
}


作者:汐颜染瞳べ
链接:https://juejin.cn/post/7117194640826368036
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

听说Compose与RecyclerView结合会有水土不服?

背景&笔者碎碎谈 最近Compose也慢慢火起来了,作为google力推的ui框架,我们也要用起来才能进步呀!在最新一期的评测中LazyRow等LazyXXX列表组件已经慢慢逼近RecyclerView的性能了!但是还是有很多同学顾虑呀!没关系,我们就...
继续阅读 »

背景&笔者碎碎谈


最近Compose也慢慢火起来了,作为google力推的ui框架,我们也要用起来才能进步呀!在最新一期的评测中LazyRow等LazyXXX列表组件已经慢慢逼近RecyclerView的性能了!但是还是有很多同学顾虑呀!没关系,我们就算用原有的view开发体系,也可以快速迁移到compose,这个利器就是ComposeView了,那么我们在RecyclerView的基础上,集成Compose用起来!这样我们有RecyclerView的性能又有Compose的好处不是嘛!相信很多人都有跟我一样的想法,但是这两者结合起来可是有隐藏的性能开销!(本次使用compose版本为1.1.1)


在原有view体系接入Compose


在纯compose项目中,我们都会用setContent代替原有view体系的setContentView,比如


setContent {
ComposeTestTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Greeting("Android")
Hello()
}
}
}

那么setContent到底做了什么事情呢?我们看下源码


public fun ComponentActivity.setContent(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
val existingComposeView = window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? ComposeView

if (existingComposeView != null) with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
} else ComposeView(this).apply {
// 第一步走到这里
// Set content and parent **before** setContentView
// to have ComposeView create the composition on attach
setParentCompositionContext(parent)
setContent(content)
// Set the view tree owners before setting the content view so that the inflation process
// and attach listeners will see them already present
setOwners()
setContentView(this, DefaultActivityContentLayoutParams)
}
}

由于是第一次进入,那么一定就走到了else分支,其实就是创建了一个ComposeView,放在了android.R.id.content里面的第一个child中,这里就可以看到,compose并不是完全脱了原有的view体系,而是采用了移花接木的方式,把compose体系迁移了过来!ComposeView就是我们能用Compose的前提啦!所以在原有的view体系中,我们也可以通过ComposeView去“嫁接”到view体系中,我们举个例子


class CustomActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_custom)
val recyclerView = this.findViewById<RecyclerView>(R.id.recyclerView)
recyclerView.adapter = MyRecyclerViewAdapter()
recyclerView.layoutManager = LinearLayoutManager(this)
}
}


class MyRecyclerViewAdapter:RecyclerView.Adapter<MyComposeViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyComposeViewHolder {
val view = ComposeView(parent.context)
return MyComposeViewHolder(view)
}

override fun onBindViewHolder(holder: MyComposeViewHolder, position: Int) {
holder.composeView.setContent {
Text(text = "test $position", modifier = Modifier.size(200.dp).padding(20.dp), textAlign = TextAlign.Center)
}

}

override fun getItemCount(): Int {
return 200
}
}

class MyComposeViewHolder(val composeView:ComposeView):RecyclerView.ViewHolder(composeView){

}

这样一来,我们的compose就被移到了RecyclerView中,当然,每一列其实就是一个文本。嗯!普普通通,好像也没啥特别的对吧,假如这个时候你打开了profiler,当我们向下滑动的时候,会发现内存会慢慢的往上浮动


image.png
滑动嘛!有点内存很正常,毕竟谁不生成对象呢,但是这跟我们平常用RecyclerView的时候有点差异,因为RecyclerView滑动的涨幅可没有这个大,那究竟是什么原因导致的呢?


探究Compose


有过对Compose了解的同学可能会知道,Compose的界面构成会有一个重组的过程,当然!本文就不展开聊重组了,因为这类文章有挺多的(填个坑,如果有机会就填),我们聊点特别的,那么什么时候停止重组呢?或者说什么时候这个Compose被dispose掉,即不再参与重组!


Dispose策略


其实我们的ComposeView,以1.1.1版本为例,其实创建的时候,也创建了取消重组策略,即


@Suppress("LeakingThis")
private var disposeViewCompositionStrategy: (() -> Unit)? =
ViewCompositionStrategy.DisposeOnDetachedFromWindow.installFor(this)

这个策略是什么呢?我们点进去看源码


object DisposeOnDetachedFromWindow : ViewCompositionStrategy {
override fun installFor(view: AbstractComposeView): () -> Unit {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}

override fun onViewDetachedFromWindow(v: View?) {
view.disposeComposition()
}
}
view.addOnAttachStateChangeListener(listener)
return { view.removeOnAttachStateChangeListener(listener) }
}
}

看起来是不是很简单呢,其实就加了一个监听,在onViewDetachedFromWindow的时候调用的view.disposeComposition(),声明当前的ComposeView不参与接下来的重组过程了,我们再继续看


fun disposeComposition() {
composition?.dispose()
composition = null
requestLayout()
}

再看dispose方法


override fun dispose() {
synchronized(lock) {
if (!disposed) {
disposed = true
composable = {}
val nonEmptySlotTable = slotTable.groupsSize > 0
if (nonEmptySlotTable || abandonSet.isNotEmpty()) {
val manager = RememberEventDispatcher(abandonSet)
if (nonEmptySlotTable) {
slotTable.write { writer ->
writer.removeCurrentGroup(manager)
}
applier.clear()
manager.dispatchRememberObservers()
}
manager.dispatchAbandons()
}
composer.dispose()
}
}
parent.unregisterComposition(this)
}

那么怎么样才算是不参与接下里的重组呢,其实就是这里


slotTable.write { writer ->
writer.removeCurrentGroup(manager)
}

...
composer.dispose()

而removeCurrentGroup其实就是把当前的group移除了


for (slot in groupSlots()) {
when (slot) {
....
is RecomposeScopeImpl -> {
val composition = slot.composition
if (composition != null) {
composition.pendingInvalidScopes = true
slot.composition = null
}
}
}
}

这里又多了一个概念,slottable,我们可以这么理解,这里面就是Compose的快照系统,其实就相当于对应着某个时刻view的状态!之所以Compose是声明式的,就是通过slottable里的slot去判断,如果最新的slot跟前一个slot不一致,就回调给监听者,实现更新!这里又是一个大话题了,我们点到为止


image.png


跟RecyclerView有冲突吗


我们看到,默认的策略是当view被移出当前的window就不参与重组了,嗯!这个在99%的场景都是有效的策略,因为你都看不到了,还重组干嘛对吧!但是这跟我们的RecyclerView有什么冲突吗?想想看!诶,RecyclerView最重要的是啥,Recycle呀,就是因为会重复利用holder,间接重复利用了view才显得高效不是嘛!那么问题就来了


image.png
如图,我们item5其实完全可以利用item1进行显示的对不对,差别就只是Text组件的文本不一致罢了,但是我们从上文的分析来看,这个ComposeView对应的composition被回收了,即不参与重组了,换句话来说,我们Adapter在onBindViewHolder的时候,岂不是用了一个没有compositon的ComposeView(即不能参加重组的ComposeView)?这样怎么行呢?我们来猜一下,那么这样的话,RecyclerView岂不是都要生成新的ComposeView(即每次都调用onCreateViewHolder)才能保证正确?emmm,很有道理,但是却不是的!如果我们把代码跑起来看的话,复用的时候依旧是会调用onBindViewHolder,这就是Compose的秘密了,那么这个秘密在哪呢


override fun onBindViewHolder(holder: MyComposeViewHolder, position: Int) {
holder.composeView.setContent {
Text(text = "test $position", modifier = Modifier.size(200.dp).padding(20.dp), textAlign = TextAlign.Center)
}

}

其实就是在ComposeView的setContent方法中,


fun setContent(content: @Composable () -> Unit) {
shouldCreateCompositionOnAttachedToWindow = true
this.content.value = content
if (isAttachedToWindow) {
createComposition()
}
}

fun createComposition() {
check(parentContext != null || isAttachedToWindow) {
"createComposition requires either a parent reference or the View to be attached" +
"to a window. Attach the View or call setParentCompositionReference."
}
ensureCompositionCreated()
}

最终调用的是


private fun ensureCompositionCreated() {
if (composition == null) {
try {
creatingComposition = true
composition = setContent(resolveParentCompositionContext()) {
Content()
}
} finally {
creatingComposition = false
}
}
}

看到了吗!如果composition为null,就会重新创建一个!这样ComposeView就完全嫁接到RecyclerView中而不出现问题了!


其他Dispose策略


我们看到,虽然在ComposeView在RecyclerView中能正常运行,但是还存在缺陷对不对,因为每次复用都要重新创建一个composition对象是不是!归根到底就是,我们默认的dispose策略不太适合这种拥有复用逻辑或者自己生命周期的组件使用,那么有其他策略适合RecyclerView吗?别急,其实是有的,比如DisposeOnViewTreeLifecycleDestroyed


object DisposeOnViewTreeLifecycleDestroyed : ViewCompositionStrategy {
override fun installFor(view: AbstractComposeView): () -> Unit {
if (view.isAttachedToWindow) {
val lco = checkNotNull(ViewTreeLifecycleOwner.get(view)) {
"View tree for $view has no ViewTreeLifecycleOwner"
}
return installForLifecycle(view, lco.lifecycle)
} else {
// We change this reference after we successfully attach
var disposer: () -> Unit
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View?) {
val lco = checkNotNull(ViewTreeLifecycleOwner.get(view)) {
"View tree for $view has no ViewTreeLifecycleOwner"
}
disposer = installForLifecycle(view, lco.lifecycle)

// Ensure this runs only once
view.removeOnAttachStateChangeListener(this)
}

override fun onViewDetachedFromWindow(v: View?) {}
}
view.addOnAttachStateChangeListener(listener)
disposer = { view.removeOnAttachStateChangeListener(listener) }
return { disposer() }
}
}
}

然后我们在ViewHolder的init方法中对composeview设置一下就可以了


class MyComposeViewHolder(val composeView:ComposeView):RecyclerView.ViewHolder(composeView){
init {
composeView.setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
}
}

为什么DisposeOnViewTreeLifecycleDestroyed更加适合呢?我们可以看到在onViewAttachedToWindow中调用了
installForLifecycle(view, lco.lifecycle) 方法,然后就removeOnAttachStateChangeListener,保证了该ComposeView创建的时候只会被调用一次,那么removeOnAttachStateChangeListener又做了什么呢?


val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_DESTROY) {
view.disposeComposition()
}
}
lifecycle.addObserver(observer)
return { lifecycle.removeObserver(observer) }

可以看到,是在对应的生命周期事件为ON_DESTROY(Lifecycle.Event跟activity生命周期不是一一对应的,要注意)的时候,才调用view.disposeComposition(),本例子的lifecycleOwner就是CustomActivity啦,这样就保证了只有当前被lifecycleOwner处于特定状态的时候,才会销毁,这样是不是就提高了compose的性能了!


扩展


我们留意到了Compose其实存在这样的小问题,那么如果我们用了其他的组件类似RecyclerView这种的怎么办,又或者我们的开发没有读过这篇文章怎么办!(ps:看到这里的同学还不点赞点赞),没关系,官方也注意到了,并且在1.3.0-alpha02以上版本添加了更换了默认策略,我们来看一下


val Default: ViewCompositionStrategy
get() = DisposeOnDetachedFromWindowOrReleasedFromPool

object DisposeOnDetachedFromWindowOrReleasedFromPool : ViewCompositionStrategy {
override fun installFor(view: AbstractComposeView): () -> Unit {
val listener = object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(v: View) {}

override fun onViewDetachedFromWindow(v: View) {
// 注意这里
if (!view.isWithinPoolingContainer) {
view.disposeComposition()
}
}
}
view.addOnAttachStateChangeListener(listener)

val poolingContainerListener = PoolingContainerListener { view.disposeComposition() }
view.addPoolingContainerListener(poolingContainerListener)

return {
view.removeOnAttachStateChangeListener(listener)
view.removePoolingContainerListener(poolingContainerListener)
}
}
}

DisposeOnDetachedFromWindow从变成了DisposeOnDetachedFromWindowOrReleasedFromPool,其实主要变化点就是一个view.isWithinPoolingContainer = false,才会进行dispose,isWithinPoolingContainer定义如下


image.png


也就是说,如果我们view的祖先存在isPoolingContainer = true的时候,就不会进行dispose啦!所以说,如果我们的自定义view是这种情况,就一定要把isPoolingContainer变成true才不会有隐藏的性能开销噢!当然,RecyclerView也要同步到1.3.0-alpha02以上才会有这个属性改写!现在稳定版本还是会存在本文的隐藏性能开销,请注意噢!不过相信看完这篇文章,性能优化啥的,不存在了对不对!


作者:Pika
链接:https://juejin.cn/post/7117176526893744142
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
收起阅读 »

反对居家办公,马斯克尴尬了:车没地停、工位不够坐、Wi-Fi还太差

每一个特斯拉员工每周都要在办公室工作 40 个小时……如果你不来,那么我们就认为你辞职了。在马斯克“蛮横”地放出了这番话后,迫于失业危机,许多特斯拉员工只能整顿心情并起个大早,只为及时到达办公室——位于美国加利福尼亚州弗里蒙特的特斯拉工厂。万万没想到,员工做好...
继续阅读 »


每一个特斯拉员工每周都要在办公室工作 40 个小时……如果你不来,那么我们就认为你辞职了。

在马斯克“蛮横”地放出了这番话后,迫于失业危机,许多特斯拉员工只能整顿心情并起个大早,只为及时到达办公室——位于美国加利福尼亚州弗里蒙特的特斯拉工厂。

万万没想到,员工做好了返回办公室上班的准备,特斯拉自己却状况百出。

1 车没地停、工位不够、Wi-Fi 太差

首先是停车位问题。很多特斯拉员工花费数小时驱车赶往公司后,面临的第一个问题就是:停车位早已爆满。转悠了好几圈之后,他们只能无奈选择把车停在附近的 BART(一种有轨捷运交通)车站,再转车去上班。

好不容易历经周折到达公司后,看到工位上乌泱泱地挤满了同受马斯克“威胁”来上班的人,员工们再次眉头一紧:我们坐哪里办公?据了解,特斯拉曾在疫情期间将弗里蒙特工厂进行了一些区域调动,新员工不知所措的同时,老员工也一片茫然。

当部分员工幸运地在角落找到位置坐下、以为终于可以开始投入工作后,没想到居然还有一道坎:Wi-Fi 信号太差,差到大多数员工根本无法正常工作。

……就很想问马斯克一句:你当初在威胁员工来公司上班时,是不是忘记了你公司的员工人数早在疫情期间已经翻一番了?

根据外媒 The Information 的数据,从 2019 年到 2021 年,即特斯拉允许远程办公后,其员工人数已增加至 99210 名员工,几乎翻了一番,其中光弗里蒙特工厂就有 2 万多名员工。

在此情形下,即便马斯克一心要求,但由于特斯拉目前无法有效处理因重返办公司政策引发的一系列问题,有些员工被要求每周来公司上班的天数不得超过 5 天。

然而,结合特斯拉最近的裁员计划,这一安排又令许多人心生担忧。

2 暂停全球招聘,计划裁员 10%

本月初,在马斯克发布要求员工每周到岗办公 40 小时的通知后,又在 6 月 2 日向公司高管发送了一封名为“暂停全球所有招聘”的电子邮件。据路透社报道,马斯克在邮件中表示,他对经济形势“感觉非常糟糕”,需要裁员约 10%,并暂停全球招聘。

而上周,一些特斯拉前员工宣布起诉,称特斯拉要裁员 10% 的决定违反了联邦法律,因为没有提供裁员所需的提前通知,即“警告法案”(WARN Act),其中要求公司在任何影响到 50 名或以上员工的大规模裁员前需提前 60 天通知。

据起诉书称,在被裁的 500 多名特斯拉员工中,John Lynch 在 6 月 10 日收到被解雇的通知,立即生效,Daxton Hartsfield 也在 6 月 15 日收到即刻生效的解雇通知。

因而,面临这次的裁员风波,目前在职的特斯拉员工大多都牟足了劲,想凭借勤勉优秀、不出错的表现躲过这次大规模裁员。实际上,马斯克之前在推特全体大会上曾透露,到岗办公并非强制要求,一些“优秀”人才仍然可以选择远程工作。

只是,对于这个毫无具体说明的“优秀”标准,大多数员工都不敢赌,因此应马斯克要求选择到岗办公的员工人数自然也就暴涨——但从目前的情况来看,特斯拉显然没有做好同时容纳这么多到岗员工的准备。

参考链接:

本文转自公众号“CSDN”,ID:CSDNnews

收起阅读 »

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

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

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

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

理论基础—R文件

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


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

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

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


R文件瘦身的可行性分析

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

setContentView(2131427356);

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

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

setContentView(R.layout.activity_main);

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

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

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

制定技术方案

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

公共资源R类ID内联

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

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


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

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

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

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


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


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

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


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

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

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

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


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

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

参考文章:

Gradle Plugin:

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

Gradle Transform:

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

APK 构建流程:

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

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

收起阅读 »

DevOps之【持续集成】

引言对于客户或者需求方来说,可以集成交付的软件才是有价值的。每个软件都有集成的过程,如果软件规模比较小,比如只有一个人而且没有外部依赖,那集成没什么问题。随着软件变得复杂,依赖变多,开发人员变多,那么早集成、常集成,就可以尽早暴露问题,做出相应的调整,防止在软...
继续阅读 »

引言

对于客户或者需求方来说,可以集成交付的软件才是有价值的。

每个软件都有集成的过程,如果软件规模比较小,比如只有一个人而且没有外部依赖,那集成没什么问题。随着软件变得复杂,依赖变多,开发人员变多,那么早集成、常集成,就可以尽早暴露问题,做出相应的调整,防止在软件后期才发现问题,从而导致软件失败。

定义

大师Martin Fowler对持续集成是这样定义的:

持续集成是一种软件开发实践,即团队开发成员经常集成它们的工作,通常每个成员每天至少集成一次,也就意味着每天可能会发生多次集成。每次集成都通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽快地发现集成错误。许多团队发现这个过程可以大大减少集成的问题,让团队能够更快的开发内聚的软件。

优点

  • 降低软件风险 早集成,常集成,并且做了有效的测试,有利于尽早暴露问题和软件缺陷,了解软件的健康情况。假定越少的软件,对于维护和新业务开发都是有利的。 如果botslab每一个品类的设备都单独分支开发,不能及时集成进来,依赖问题、冲突问题、业务复用问题不能尽早解决,问题累积到一定程度解决成本会变大,业务发展是不会为代码重构让路的,那么软件质量的降低是必然的,很多项目都是这样失败的

  • 减少重复过程 软件集成的过程看起来简单,但是做起来难。软件的编译,测试,审查,部署,反馈,这些重复劳动是非常耗时,且没有意义的,自动化集成可以让开发解放出来,做一些用脑袋的事情。 如果出现疏漏,会给下游的参与者带来额外工作量和项目质量的误判。软件的输出质量是会影响项目计划的,所以持续集成很重要的一点就是自动化

  • 随时生成可以部署的软件 持续集成可以随时随地输出可以部署的软件,这一点对于需求方或客户是明显的好处。我们可以对客户说软件有多么好的架构,多么高质量的代码,但是对于客户来说,一个可以使用的软件才是他的实际资产。持续交付可以尽早的暴露产品问题和开发方向,客户才能给出有效的意见和开发重点。

  • 软件是透明的 持续集成会生成软件构建状态和品质信息,经常集成可以看到一些趋势,预测一些软件质量走向。

  • 团队信心 持续集成可以建立团队的信心,开发清楚的知道自己的代码产生了什么影响,测试对软件质量的预测稳定,产品或客户可以放心的需求了

步骤

  1. 统一的代码库

  2. 自动构建

  3. 自动测试

  4. 每个人每天都要向代码库主干提交代码

  5. 每次代码递交后都会在持续集成服务器上触发一次构建

  6. 保证快速构建

  7. 模拟生产环境的自动测试

  8. 每个人都可以很容易的获取最新可执行的应用程序

  9. 每个人都清楚正在发生的状况

  10. 自动化的部署

原则

  1. 所有的开发人员需要在本地机器上做本地构建,然后再提交的版本控制库中,从而确保他们的变更不会导致持续集成失败。

  2. 开发人员每天至少向版本控制库中提交一次代码。

  3. 开发人员每天至少需要从版本控制库中更新一次代码到本地机器。

  4. 需要有专门的集成服务器来执行集成构建,每天要执行多次构建。

  5. 每次构建都要100%通过。

  6. 每次构建都可以生成可发布的产品。

  7. 修复失败的构建是优先级最高的事情。



作者:QiShare
来源:juejin.cn/post/6986884632222384141

收起阅读 »

Vue2全家桶之一:vue-cli

vue
vue.js有著名的全家桶系列,包含了vue-router,vuex, vue-resource,再加上构建工具vue-cli,就是一个完整的vue项目的核心构成。 1.安装vue-cli② 全局安装vue-cli,在cmd中输入命令:安装成功:打开C:\U...
继续阅读 »

都说Vue2简单上手容易,的确,看了官方文档确实觉得上手很快,除了ES6语法和webpack的配置让你感到陌生,重要的是思路的变换,以前用jq随便拿全局变量和修改dom的锤子不能用了,vue只用关心数据本身,不用再频繁繁琐的操作dom,注册事件、监听事件、取消事件。。。。(确实很烦)。vue的官方文档还是不错的,由浅到深,如果不使用构建工具确实用的很爽,但是这在实际项目应用中是不可能的,当用vue-cli构建一个工程的时候,发现官方文档还是不够用,需要熟练掌握es6,而vue的全家桶(vue-cli,vue-router,vue-resource,vuex)还是都要上的。

vue.js有著名的全家桶系列,包含了vue-router,vuex, vue-resource,再加上构建工具vue-cli,就是一个完整的vue项目的核心构成。

vue-cli这个构建工具大大降低了webpack的使用难度,支持热更新,有webpack-dev-server的支持,相当于启动了一个请求服务器,给你搭建了一个测试环境,只关注开发就OK。

1.安装vue-cli

使用npm(需要安装node环境)全局安装webpack,打开命令行工具输入:npm install webpack -g或者(npm install -g webpack),安装完成之后输入 webpack -v,如下图,如果出现相应的版本号,则说明安装成功。

全局安装vue-cli,在cmd中输入命令:

npm install --global vue-cli

安装成功:



安装完成之后输入 vue -V(注意这里是大写的“V”),如下图,如果出现相应的版本号,则说明安装成功。

打开C:\Users\Andminster\AppData\Roaming\npm目录下可以看到:



打开node_modules也可以看到:

2.用vue-cli来构建项目

① 我首先在D盘新建一个文件夹(dxl_vue)作为项目存放地,然后使用命令行cd进入到项目目录输入:

vue init webpack baoge

baoge是自定义的项目名称,命令执行之后,会在当前目录生成一个以该名称命名的项目文件夹。
输入命令后,会跳出几个选项让你回答:

  • Project name (baoge): -----项目名称,直接回车,按照括号中默认名字(注意这里的名字不能有大写字母,如果有会报错Sorry, name can no longer contain capital letters),阮一峰老师博客为什么文件名要小写 ,可以参考一下。
  • Project description (A Vue.js project): ----项目描述,也可直接点击回车,使用默认名字
  • Author (): ----作者,输入dongxili
    接下来会让用户选择:
  • Runtime + Compiler: recommended for most users 运行加编译,既然已经说了推荐,就选它了
    Runtime-only: about 6KB lighter min+gzip, but templates (or any Vue-specificHTML) are ONLY allowed in .vue files - render functions are required elsewhere 仅运行时,已经有推荐了就选择第一个了
  • Install vue-router? (Y/n) 是否安装vue-router,这是官方的路由,大多数情况下都使用,这里就输入“y”后回车即可。
  • Use ESLint to lint your code? (Y/n) 是否使用ESLint管理代码,ESLint是个代码风格管理工具,是用来统一代码风格的,一般项目中都会使用。
    接下来也是选择题Pick an ESLint preset (Use arrow keys) 选择一个ESLint预设,编写vue项目时的代码风格,直接y回车
  • Setup unit tests with Karma + Mocha? (Y/n) 是否安装单元测试,我选择安装y回车
  • Setup e2e tests with Nightwatch(Y/n)? 是否安装e2e测试 ,我选择安装y回车

回答完毕后上图就开始构建项目了。

配置完成后,可以看到目录下多出了一个项目文件夹baoge,然后cd进入这个文件夹:
安装依赖

npm install

 ( 如果安装速度太慢。可以安装淘宝镜像,打开命令行工具,输入:
npm install -g cnpm --registry=https://registry.npm.taobao.org
 然后使用cnpm来安装 )

npm install :安装所有的模块,如果是安装具体的哪个个模块,在install 后面输入模块的名字即可。而只输入install就会按照项目的根目录下的package.json文件中依赖的模块安装(这个文件里面是不允许有任何注释的),每个使用npm管理的项目都有这个文件,是npm操作的入口文件。因为是初始项目,还没有任何模块,所以我用npm install 安装所有的模块。安装完成后,目录中会多出来一个node_modules文件夹,这里放的就是所有依赖的模块。


然后现在,baoge文件夹里的目录是这样的:



解释下每个文件夹代表的意思(仔细看一下这张图):

image.png

3.启动项目

npm run dev


如果浏览器打开之后,没有加载出页面,有可能是本地的 8080 端口被占用,需要修改一下配置文件 config里的index.js

还有,如果本地调试项目时,建议将build 里的assetsPublicPath的路径前缀修改为 ' ./ '(开始是 ' / '),因为打包之后,外部引入 js 和 css 文件时,如果路径以 ' / ' 开头,在本地是无法找到对应文件的(服务器上没问题)。所以如果需要在本地打开打包后的文件,就得修改文件路径。
我的端口没有被占用,直接成功(服务启动成功后浏览器会默认打开一个“欢迎页面”):



注意:在进行vue页面调试时,一定要去谷歌商店下载一个vue-tool扩展程序。

4.vue-cli的webpack配置分析

  • package.json可以看到开发和生产环境的入口。

  • 可以看到dev中的设置,build/webpack.dev.conf.js,该文件是开发环境中webpack的配置入口。
  • 在webpack.dev.conf.js中出现webpack.base.conf.js,这个文件是开发环境和生产环境,甚至测试环境,这些环境的公共webpack配置。可以说,这个文件相当重要。
  • 还有config/index.js 、build/utils.js 、build/build.js等,具体请看这篇介绍:
    https://segmentfault.com/a/1190000008644830

5.打包上线

注意,自己的项目文件都需要放到 src 文件夹下。
在项目开发完成之后,可以输入 npm run build 来进行打包工作。

npm run build

另:

1.npm 开启了npm run dev以后怎么退出或关闭?
ctrl+c
2.--save-dev
自动把模块和版本号添加到模块配置文件package.json中的依赖里devdependencies部分
3. --save-dev 与 --save 的区别
--save 安装包信息将加入到dependencies(生产阶段的依赖)
--save-dev 安装包信息将加入到devDependencies(开发阶段的依赖),所以开发阶段一般使用它

打包完成后,会生成 dist 文件夹,如果已经修改了文件路径,可以直接打开本地文件查看。
项目上线时,只需要将 dist 文件夹放到服务器就行了。

转载自: https://cloud.tencent.com/developer/article/1896690

收起阅读 »

[PHP 安全] pcc —— PHP 安全配置检测工具

PHP
背景在 PHP 安全测试中最单调乏味的任务之一就是检查不安全的 PHP 配置项。作为一名 PHP 安全海报的继承者,我们创建了一个脚本用来帮助系统管理员如同安全专家一样尽可能快速且全面地评估 php.ini 和相关主题的状态。在下文中,该脚本被称作“PHP 安...
继续阅读 »

背景

在 PHP 安全测试中最单调乏味的任务之一就是检查不安全的 PHP 配置项。作为一名 PHP 安全海报的继承者,我们创建了一个脚本用来帮助系统管理员如同安全专家一样尽可能快速且全面地评估 php.ini 和相关主题的状态。在下文中,该脚本被称作“PHP 安全配置项检查器”,或者 pcc

https://github.com/sektioneins/pcc

概念

  • 一个便于分发的单文件
  • 有对每个安全相关的 ini 条目的简单测试
  • 包含一些其他测试 - 但不太复杂
  • 兼容 PHP >= 5.4, 或者 >= 5.0
  • 没有复杂/过度设计的代码,例如没有类/接口,测试框架,类库等等。它应该第一眼看上去是显而易见的-甚至对于新手-这个工具怎么使用能用来做什么。
  • 没有(或者少量的)依赖

使用 / 安装

  • CLI:简单调用 php phpconfigcheck.php。然后,添加参数 -a 以便更好的查看隐藏结果, -h 以 HTML 格式输出, -j 以 JSON 格式输出.
  • WEB: 复制这个脚本文件到你的服务器上的任意一个可访问目录,比如 root 目录。参见下面的“防护措施”。
    在非 CLI 模式下默认输出 HTML 格式。可以通过修改设置环境变量PCC_OUTPUT_TYPE=text 或者 PCC_OUTPUT_TYPE=json改变这个行为。
    一些测试用例默认是被隐藏的,特别是skipped、ok和 unknown/untested这些。要显示全部结果,可以用 phpconfigcheck.php?showall=1,但这并不适用于 JSON 输出,它默认返回全部结果。
    在 WEB 模式下控制输出格式用 phpconfigcheck.php?format=...format的值可以是 text, html 或者 json中的一个,例如: phpconfigcheck.php?format=textformat 参数优先于 PCC_OUTPUT_TYPE。

保障措施

大多数情况下,最好是自己来关注与安全性相关的问题比如PHP的配置。脚本已实现下列保障措施:

  • mtime检查:脚本在非CLI环境中只能工作两天。可以通过touch phpconfigcheck.php或者将脚本文件再次复制到你的服务器(例如通过SCP)来重新进行mtime检查。可以通过设置环境量: PCC_DISABLE_MTIME=1,比如在apache的.htaccess文件中设置SetEnv PCC_DISABLE_MTIME 1来禁用mtime检查。
  • 来源IP检查:默认情况下,只有localhost (127.0.0.1 和 ::1)才能访问这个脚本。其他主机可以通过在PCC_ALLOW_IP中添加IP地址或者通配符表达式的方式来访问脚本,比如在.htaccess文件中设置SetEnv PCC_ALLOW_IP 10.0.0.*。你还可以选择通过SSH端口转发访问您的web服务器, 比如 ssh -D 或者 ssh -L

下载

可以通过github下载第一个完整的开发版: https://github.com/sektioneins/pcc

如果有好的建议或者遇到bug请给我们提issue:

截图

HTML输出的列表是根据问题严重性排序的,通过颜色代码的形式列出了所有建议。列表顶部的状态行会显示问题的数量。


转载自: https://cloud.tencent.com/developer/article/1911011

收起阅读 »

Java Exception

异常指不期而至的各种状况,如:文件找不到、网络连接失败、非法参数等。异常是一个事件,它发生在程序运行期间,干扰了正常的指令流程。Java通 过API中Throwable类的众多子类描述各种不同的异常。因而,Java异常都是对象,是Throwable子类的实例,...
继续阅读 »

Java异常

异常指不期而至的各种状况,如:文件找不到、网络连接失败、非法参数等。异常是一个事件,它发生在程序运行期间,干扰了正常的指令流程。Java通 过API中Throwable类的众多子类描述各种不同的异常。因而,Java异常都是对象,是Throwable子类的实例,描述了出现在一段编码中的 错误条件。当条件生成时,错误将引发异常。
Java异常类层次结构图:


图1 Java异常类层次结构图
在 Java 中,所有的异常都有一个共同的祖先 Throwable(可抛出)。Throwable 指定代码中可用异常传播机制通过 Java 应用程序传输的任何问题的共性。 Throwable: 有两个重要的子类:Exception(异常)和 Error(错误),二者都是 Java 异常处理的重要子类,各自都包含大量子类。
Error(错误):是程序无法处理的错误,表示运行应用程序中较严重问题。大多数错误与代码编写者执行的操作无关,而表示代码运行时 JVM(Java 虚拟机)出现的问题。例如,Java虚拟机运行错误(Virtual MachineError),当 JVM 不再有继续执行操作所需的内存资源时,将出现 OutOfMemoryError。这些异常发生时,Java虚拟机(JVM)一般会选择线程终止。
这些错误表示故障发生于虚拟机自身、或者发生在虚拟机试图执行应用时,如Java虚拟机运行错误(Virtual MachineError)、类定义错误(NoClassDefFoundError)等。这些错误是不可查的,因为它们在应用程序的控制和处理能力之 外,而且绝大多数是程序运行时不允许出现的状况。对于设计合理的应用程序来说,即使确实发生了错误,本质上也不应该试图去处理它所引起的异常状况。在 Java中,错误通过Error的子类描述。
Exception(异常):是程序本身可以处理的异常。
Exception 类有一个重要的子类 RuntimeException。RuntimeException 类及其子类表示“JVM 常用操作”引发的错误。例如,若试图使用空值对象引用、除数为零或数组越界,则分别引发运行时异常(NullPointerException、ArithmeticException)和 ArrayIndexOutOfBoundException。
注意:异常和错误的区别:异常能被程序本身可以处理,错误是无法处理。
通常,Java的异常(包括Exception和Error)分为可查的异常(checked exceptions)和不可查的异常(unchecked exceptions)
可查异常(编译器要求必须处置的异常):正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。

除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常。这种异常的特点是Java编译器会检查它,也就是说,当程序中可能出现这类异常,要么用try-catch语句捕获它,要么用throws子句声明抛出它,否则编译不会通过。

不可查异常(编译器不要求强制处置的异常):包括运行时异常(RuntimeException与其子类)和错误(Error)。

Exception 这种异常分两大类运行时异常和非运行时异常(编译异常)。程序中应当尽可能去处理这些异常。

运行时异常:都是RuntimeException类及其子类异常,如NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。

运行时异常的特点是Java编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用try-catch语句捕获它,也没有用throws子句声明抛出它,也会编译通过。 非运行时异常 (编译异常):是RuntimeException以外的异常,类型上都属于Exception类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如IOException、SQLException等以及用户自定义的Exception异常,一般情况下不自定义检查异常。

4.处理异常机制

在 Java 应用程序中,异常处理机制为:抛出异常,捕捉异常。

抛出异常:当一个方法出现错误引发异常时,方法创建异常对象并交付运行时系统,异常对象中包含了异常类型和异常出现时的程序状态等异常信息。运行时系统负责寻找处置异常的代码并执行。

捕获异常 :在方法抛出异常之后,运行时系统将转为寻找合适的异常处理器(exception handler)。潜在的异常处理器是异常发生时依次存留在调用栈中的方法的集合。当异常处理器所能处理的异常类型与方法抛出的异常类型相符时,即为合适 的异常处理器。运行时系统从发生异常的方法开始,依次回查调用栈中的方法,直至找到含有合适异常处理器的方法并执行。当运行时系统遍历调用栈而未找到合适 的异常处理器,则运行时系统终止。同时,意味着Java程序的终止。

对于运行时异常、错误或可查异常,Java技术所要求的异常处理方式有所不同。

由于运行时异常的不可查性,为了更合理、更容易地实现应用程序,Java规定,运行时异常将由Java运行时系统自动抛出,允许应用程序忽略运行时异常。

对于方法运行中可能出现的Error,当运行方法不欲捕捉时,Java允许该方法不做任何抛出声明。因为,大多数Error异常属于永远不能被允许发生的状况,也属于合理的应用程序不该捕捉的异常。

对于所有的可查异常,Java规定:一个方法必须捕捉,或者声明抛出方法之外。也就是说,当一个方法选择不捕捉可查异常时,它必须声明将抛出异常。

能够捕捉异常的方法,需要提供相符类型的异常处理器。所捕捉的异常,可能是由于自身语句所引发并抛出的异常,也可能是由某个调用的方法或者Java运行时 系统等抛出的异常。也就是说,一个方法所能捕捉的异常,一定是Java代码在某处所抛出的异常。简单地说,异常总是先被抛出,后被捕捉的。

任何Java代码都可以抛出异常,如:自己编写的代码、来自Java开发环境包中代码,或者Java运行时系统。无论是谁,都可以通过Java的throw语句抛出异常。

从方法中抛出的任何异常都必须使用throws子句。

捕捉异常通过try-catch语句或者try-catch-finally语句实现。

总体来说,Java规定:对于可查异常必须捕捉、或者声明抛出。允许忽略不可查的RuntimeException和Error。

4.1 捕获异常:try、catch 和 finally

1.try-catch语句

在Java中,异常通过try-catch语句捕获。其一般语法形式为:

try {  
// 可能会发生异常的程序代码
} catch (Type1 id1){
// 捕获并处置try抛出的异常类型Type1
}
catch (Type2 id2){
//捕获并处置try抛出的异常类型Type2
}

关键词try后的一对大括号将一块可能发生异常的代码包起来,称为监控区域。Java方法在运行过程中出现异常,则创建异常对象。将异常抛出监控区域之 外,由Java运行时系统试图寻找匹配的catch子句以捕获异常。若有匹配的catch子句,则运行其异常处理代码,try-catch语句结束。

匹配的原则是:如果抛出的异常对象属于catch子句的异常类,或者属于该异常类的子类,则认为生成的异常对象与catch块捕获的异常类型相匹配。

例1 捕捉throw语句抛出的“除数为0”异常。

public class TestException {  
public static void main(String[] args) {
int a = 6;
int b = 0;
try { // try监控区域

if (b == 0) throw new ArithmeticException(); // 通过throw语句抛出异常
System.out.println("a/b的值是:" + a / b);
}
catch (ArithmeticException e) { // catch捕捉异常
System.out.println("程序出现异常,变量b不能为0。");
}
System.out.println("程序正常结束。");
}
}

运行结果:程序出现异常,变量b不能为0。

程序正常结束。

例1 在try监控区域通过if语句进行判断,当“除数为0”的错误条件成立时引发ArithmeticException异常,创建 ArithmeticException异常对象,并由throw语句将异常抛给Java运行时系统,由系统寻找匹配的异常处理器catch并运行相应异 常处理代码,打印输出“程序出现异常,变量b不能为0。”try-catch语句结束,继续程序流程。

事实上,“除数为0”等ArithmeticException,是RuntimException的子类。而运行时异常将由运行时系统自动抛出,不需要使用throw语句。

例2 捕捉运行时系统自动抛出“除数为0”引发的ArithmeticException异常。

public static void main(String[] args) {  
int a = 6;
int b = 0;
try {
System.out.println("a/b的值是:" + a / b);
} catch (ArithmeticException e) {
System.out.println("程序出现异常,变量b不能为0。");
}
System.out.println("程序正常结束。");
}
}

运行结果:程序出现异常,变量b不能为0。
程序正常结束。

例2 中的语句:

System.out.println("a/b的值是:" + a/b);

在运行中出现“除数为0”错误,引发ArithmeticException异常。运行时系统创建异常对象并抛出监控区域,转而匹配合适的异常处理器catch,并执行相应的异常处理代码。

由于检查运行时异常的代价远大于捕捉异常所带来的益处,运行时异常不可查。Java编译器允许忽略运行时异常,一个方法可以既不捕捉,也不声明抛出运行时异常。

例3 不捕捉、也不声明抛出运行时异常。

public class TestException {  
public static void main(String[] args) {
int a, b;
a = 6;
b = 0; // 除数b 的值为0
System.out.println(a / b);
}
} 复制

运行结果:
Exception in thread "main" java.lang.ArithmeticException: / by zero at Test.TestException.main(TestException.java:8)

例4 程序可能存在除数为0异常和数组下标越界异常。

public class TestException {  
public static void main(String[] args) {
int[] intArray = new int[3];
try {
for (int i = 0; i <= intArray.length; i++) {
intArray[i] = i;
System.out.println("intArray[" + i + "] = " + intArray[i]);
System.out.println("intArray[" + i + "]模 " + (i - 2) + "的值: "
+ intArray[i] % (i - 2));
}
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("intArray数组下标越界异常。");
} catch (ArithmeticException e) {
System.out.println("除数为0异常。");
}
System.out.println("程序正常结束。");
}
}

运行结果:

intArray[0] = 0

intArray[0]模 -2的值: 0

intArray[1] = 1

intArray[1]模 -1的值: 0

intArray[2] = 2

除数为0异常。

程序正常结束。

例4 程序可能会出现除数为0异常,还可能会出现数组下标越界异常。程序运行过程中ArithmeticException异常类型是先行匹配的,因此执行相匹配的catch语句:

catch (ArithmeticException e){  
System.out.println("除数为0异常。");
}

需要注意的是,一旦某个catch捕获到匹配的异常类型,将进入异常处理代码。一经处理结束,就意味着整个try-catch语句结束。其他的catch子句不再有匹配和捕获异常类型的机会。

Java通过异常类描述异常类型,异常类的层次结构如图1所示。对于有多个catch子句的异常程序而言,应该尽量将捕获底层异常类的catch子 句放在前面,同时尽量将捕获相对高层的异常类的catch子句放在后面。否则,捕获底层异常类的catch子句将可能会被屏蔽。

RuntimeException异常类包括运行时各种常见的异常,ArithmeticException类和ArrayIndexOutOfBoundsException类都是它的子类。因此,RuntimeException异常类的catch子句应该放在 最后面,否则可能会屏蔽其后的特定异常处理或引起编译错误。

收起阅读 »