注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

快速搭建一个网关服务,动态路由、鉴权的流程,看完秒会(含流程图)

最近发现网易号有盗掘金文章的,xdm有空可以关注一下这个问题,希望帮助到大家同时能够保障自己权益。前言本文记录一下我是如何使用Gateway搭建网关服务及实现动态路由的,帮助大家学习如何快速搭建一个网关服务,了解路由相关配置,鉴权的流程及业务处理,有兴趣的一定...
继续阅读 »


最近发现网易号有盗掘金文章的,xdm有空可以关注一下这个问题,希望帮助到大家同时能够保障自己权益。

前言

本文记录一下我是如何使用Gateway搭建网关服务及实现动态路由的,帮助大家学习如何快速搭建一个网关服务,了解路由相关配置,鉴权的流程及业务处理,有兴趣的一定看到最后,非常适合没接触过网关服务的同学当作入门教程。

搭建服务

框架

  • SpringBoot 2.1

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>2.1.0.RELEASE</version>
</parent>
  • Spring-cloud-gateway-core

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-gateway-core</artifactId>
</dependency>
  • common-lang3

<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-lang3</artifactId>
</dependency>

路由配置

网关作为请求统一入口,路由就相当于是每个业务系统的入口,通过路由规则则可以匹配到对应微服务的入口,将请求命中到对应的业务系统中

server:
port: 8080

spring:
cloud:
  gateway:
    enabled: true
    routes:
    - id: demo-server
      uri: http://localhost:8081
      predicates:
      - Path=/demo-server/**
      filters:
        - StripPrefix= 1

routes

配置项描述
id路由唯一id,使用服务名称即可
uri路由服务的访问地址
predicates路由断言
filters过滤规则

解读配置

  • 现在有一个服务demo-server部署在本机,地址和端口为127.0.0.1:8081,所以路由配置uri为http://localhost:8081

  • 使用网关服务路由到此服务,predicates -Path=/demo-server/**,网关服务的端口为8080,启动网关服务,访问localhost:8080/demo-server,路由断言就会将请求路由到demo-server

  • 直接访问demo-server的接口localhost:8081/api/test,通过网关的访问地址则为localhost:8080/demo-server/api/test,predicates配置将请求断言到此路由,filters-StripPrefix=1代表将地址中/后的第一个截取,所以demo-server就截取掉了

使用gateway通过配置文件即可完成路由的配置,非常方便,我们只要充分的了解配置项的含义及规则就可以了;但是这些配置如果要修改则需要重启服务,重启网关服务会导致整个系统不可用,这一点是无法接受的,下面介绍如何通过Nacos实现动态路由

动态路由

使用nacos结合gateway-server实现动态路由,我们需要先部署一个nacos服务,可以使用docker部署或下载源码在本地启动,具体操作可以参考官方文档即可

Nacos配置


groupId: 使用网关服务名称即可

dataId: routes

配置格式: json

[{
     "id": "xxx-server",
     "order": 1, #优先级
     "predicates": [{ #路由断言
         "args": {
             "pattern": "/xxx-server/**"
        },
         "name": "Path"
    }],
     "filters":[{ #过滤规则
         "args": {
             "parts": 0 #k8s服务内部访问容器为http://xxx-server/xxx-server的话,配置0即可
        },
         "name": "StripPrefix" #截取的开始索引
    }],
     "uri": "http://localhost:8080/xxx-server" #目标地址
}]

json格式配置项与yaml中对应,需要了解配置在json中的写法

比对一下json配置与yaml配置

{
   "id":"demo-server",
   "predicates":[
      {
           "args":{
               "pattern":"/demo-server/**"
          },
           "name":"Path"
      }
  ],
   "filters":[
      {
           "args":{
               "parts":1
          },
           "name":"StripPrefix"
      }
  ],
   "uri":"http://localhost:8081"
}
spring:
 cloud:
   gateway:
     enabled: true
     routes:
     - id: demo-server
       uri: http://localhost:8081
       predicates:
       - Path=/demo-server/**
       filters:
         - StripPrefix= 1

代码实现

Nacos实现动态路由的方式核心就是通过Nacos配置监听,配置发生改变后执行网关相关api创建路由


@Component
public class NacosDynamicRouteService implements ApplicationEventPublisherAware {

   private static final Logger LOGGER = LoggerFactory.getLogger(NacosDynamicRouteService.class);

   @Autowired
   private RouteDefinitionWriter routeDefinitionWriter;

   private ApplicationEventPublisher applicationEventPublisher;

   /** 路由id */
   private static List<String> routeIds = Lists.newArrayList();

   /**
    * 监听nacos路由配置,动态改变路由
    * @param configInfo
    */
   @NacosConfigListener(dataId = "routes", groupId = "gateway-server")
   public void routeConfigListener(String configInfo) {
       clearRoute();
       try {
           List<RouteDefinition> gatewayRouteDefinitions = JSON.parseArray(configInfo, RouteDefinition.class);
           for (RouteDefinition routeDefinition : gatewayRouteDefinitions) {
               addRoute(routeDefinition);
          }
           publish();
           LOGGER.info("Dynamic Routing Publish Success");
      } catch (Exception e) {
           LOGGER.error(e.getMessage(), e);
      }
       
  }


   /**
    * 清空路由
    */
   private void clearRoute() {
       for (String id : routeIds) {
           routeDefinitionWriter.delete(Mono.just(id)).subscribe();
      }
       routeIds.clear();
  }

   @Override
   public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
       this.applicationEventPublisher = applicationEventPublisher;
  }

   /**
    * 添加路由
    *
    * @param definition
    */
   private void addRoute(RouteDefinition definition) {
       try {
           routeDefinitionWriter.save(Mono.just(definition)).subscribe();
           routeIds.add(definition.getId());
      } catch (Exception e) {
           LOGGER.error(e.getMessage(), e);
      }
  }

   /**
    * 发布路由、使路由生效
    */
   private void publish() {
       this.applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this.routeDefinitionWriter));
  }
}

过滤器

gateway提供GlobalFilter及Ordered两个接口用来定义过滤器,我们自定义过滤器只需要实现这个两个接口即可

  • GlobalFilter filter() 实现过滤器业务

  • Ordered getOrder() 定义过滤器执行顺序

通常一个网关服务的过滤主要包含 鉴权(是否登录、是否黑名单、是否免登录接口...) 限流(ip限流等等)功能,我们今天简单介绍鉴权过滤器的流程实现

鉴权过滤器

需要实现鉴权过滤器,我们先得了解登录及鉴权流程,如下图所示

由图可知,我们鉴权过滤核心就是验证token是否有效,所以我们网关服务需要与业务系统在同一个redis库,先给网关添加redis依赖及配置

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
spring:
redis:
  host: redis-server
  port: 6379
  password:
  database: 0

代码实现

  • 1.定义过滤器AuthFilter

  • 2.获取请求对象 从请求头或参数或cookie中获取token(支持多种方式传token对于客户端更加友好,比如部分web下载请求会新建一个页面,在请求头中传token处理起来比较麻烦)

  • 3.没有token,返回401

  • 4.有token,查询redis是否有效

  • 5.无效则返回401,有效则完成验证放行

  • 6.重置token过期时间、添加内部请求头信息方便业务系统权限处理

@Component
public class AuthFilter implements GlobalFilter, Ordered {

@Autowired
private RedisTemplate<String, String> redisTemplate;

private static final String TOKEN_HEADER_KEY = "auth_token";

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.获取请求对象
ServerHttpRequest request = exchange.getRequest();
// 2.获取token
String token = getToken(request);
ServerHttpResponse response = exchange.getResponse();
if (StringUtils.isBlank(token)) {
// 3.token为空 返回401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 4.验证token是否有效
String userId = getUserIdByToken(token);
if (StringUtils.isBlank(userId)) {
// 5.token无效 返回401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// token有效,后续业务处理
// 从写请求头,方便业务系统从请求头获取用户id进行权限相关处理
ServerHttpRequest.Builder builder = exchange.getRequest().mutate();
request = builder.header("user_id", userId).build();
// 延长缓存过期时间-token缓存用户如果一直在操作就会一直重置过期
// 这样避免用户操作过程中突然过期影响业务操作及体验,只有用户操作间隔时间大于缓存过期时间才会过期
resetTokenExpirationTime(token, userId);
// 完成验证
return chain.filter(exchange);
}


@Override
public int getOrder() {
// 优先级 越小越优先
return 0;
}

/**
* 从redis中获取用户id
* 在登录操作时候 登陆成功会生成一个token, redis得key为auth_token:token 值为用户id
*
* @param token
* @return
*/
private String getUserIdByToken(String token) {
String redisKey = String.join(":", "auth_token", token);
return redisTemplate.opsForValue().get(redisKey);
}

/**
* 重置token过期时间
*
* @param token
* @param userId
*/
private void resetTokenExpirationTime(String token, String userId) {
String redisKey = String.join(":", "auth_token", token);
redisTemplate.opsForValue().set(redisKey, userId, 2, TimeUnit.HOURS);
}


/**
* 获取token
*
* @param request
* @return
*/
private static String getToken(ServerHttpRequest request) {
HttpHeaders headers = request.getHeaders();
// 从请求头获取token
String token = headers.getFirst(TOKEN_HEADER_KEY);
if (StringUtils.isBlank(token)) {
// 请求头无token则从url获取token
token = request.getQueryParams().getFirst(TOKEN_HEADER_KEY);
}
if (StringUtils.isBlank(token)) {
// 请求头和url都没有token则从cookies获取
HttpCookie cookie = request.getCookies().getFirst(TOKEN_HEADER_KEY);
if (cookie != null) {
token = cookie.getValue();
}
}
return token;
}
}

总结

Gateway通过配置项可以实现路由功能,整合Nacos及配置监听可以实现动态路由,实现GlobalFilter, Ordered两个接口可以快速实现一个过滤器,文中也详细的介绍了登录后的请求鉴权流程,如果有不清楚地方可以评论区见咯。

来源:juejin.cn/post/7004756545741258765

收起阅读 »

如何利用 Flutter 实现炫酷的 3D 卡片和帅气的 360° 展示效果

本篇将带你在 Flutter 上快速实现两个炫酷的动画特效,希望最后的效果可以惊艳到你。 这次灵感的来源于更新 MIUI 13 时刚好看到的卡片效果,其中除了卡片会跟随手势出现倾斜之外,内容里的部分文本和绿色图标也有类似悬浮的视差效果,恰逢此时灵机一动,我们也...
继续阅读 »

本篇将带你在 Flutter 上快速实现两个炫酷的动画特效,希望最后的效果可以惊艳到你。


这次灵感的来源于更新 MIUI 13 时刚好看到的卡片效果,其中除了卡片会跟随手势出现倾斜之外,内容里的部分文本和绿色图标也有类似悬浮的视差效果,恰逢此时灵机一动,我们也来用 Flutter 快速实现炫酷的 3D 视差卡片,最后再拓展实现一个支持帅气的 360° 展示的卡片效果



❤️ 本文正在参加征文投稿活动,还请看官们走过路过来个点赞一键三连,感激不尽~




既然需要卡片跟随手势产生不规则形变,我们第一个想到的肯定是矩阵变换,在 Flutter 里我们可以使用 Matrix4 配合 Transform 来实现矩阵变换效果。


开始之前,首先我们创建用 Transform 嵌套一个 GestureDetector ,并绘制出一个 300x400 的圆角卡片,用于后续进行矩阵变换处理。


Transform(
transform: Matrix4.identity(),
child: GestureDetector(
child: Container(
width: 300,
height: 400,
padding: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.blueGrey,
borderRadius: BorderRadius.circular(20),
),
),
),
);


接着,如下代码所示,因为我们需要卡片跟随手势进行矩阵变换,所以我们可以直接在 GestureDetectoronPanUpdate 里获取到手势信息,例如 localPosition 位置信息,然后把对应的 dxdy赋值到 Matrix4rotateXrotateY 上实现旋转。


child: Transform(
transform: Matrix4.identity()
..rotateX(touchY)
..rotateY(touchX),
alignment: FractionalOffset.center,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
touchX = details.localPosition.dx;
touchY = details.localPosition.dy;
});
},
child: Container(

这里有个需要注意的是:上面代码里 rotateX 使用的是 touchY ,而 rotateY 使用的是 touchX ,为什么要这样做呢?



⚠️举个例子,当我们手指左右移动时,是希望卡片可以围绕 Y 轴进行旋转,所以我们会把 touchX 传递给了 rotateY ,同样 touchY 传递给 rotateX 也是一个道理。




但是当我们实际运行上述代码之后,如下图所示,可以看到基本上我们只是稍微移动手指,卡片就会陷入疯狂旋转的情况,并且实际的旋转速度会比 GIF 里快很多。



问题的原因其实是因为 rotateXrotateY 需要的是一个 angle 参数,假设这里对 rotateXrotateY 设置 pi / 4 ,就可以看到卡片在 X 轴和 Y 轴上都产生了 45 度的旋转效果。


 Transform(
transform: Matrix4.identity()
..rotateX(pi / 4)
..rotateY(pi / 4),
alignment: FractionalOffset.center,


所以如果直接使用手势的 localPosition 作用于 Matrix4 肯定是不行的,我们首先需要对手势数据进行一个采样,因为代码里我们设置了 FractionalOffset.center ,所以我们可以用卡片的中心点来计算手指位置,再进行压缩处理


如下代码所示,我们通过以卡片中心点为原点进行计算,其中 / 2 就是得到卡片的中心点,/ 100 是对数据进行压缩采样,但是为什么 touchXtouchY 的计算方式是相反的呢


touchX = (cardWidth / 2 - details.localPosition.dx) / 100;
touchY = (details.localPosition.dy - cardHeight / 2 ) / 100;

如下图所示,因为在设置 rotateXrotateY 时,赋予 > 0 的数据时卡片就会以图片中的方向进行旋转,由于我们是需要手指往哪边滑动,卡片就往哪边倾斜,所以:



  • 当我们往左水平滑动时,需要卡片往左边倾斜,也就是图中绕 Y 轴转动的 >0 的方向,并且越靠近左边需要正向的 Angle 数值越大,由于此时 localPosition.dx 是越往左越小,所以需要利用 CardWidth / 2 - details.localPosition.dx 进行计算,得到越往左有越大的正向 Angle 数值

  • 同理,当我们往下滑动时,需要卡片往下边倾斜,也就是图中绕 X 轴转动的 >0 的方向,并且越靠近下边需要正向 Angle 数值越大,由于此时 localPosition.dy 越往下越大,所以使用 details.localPosition.dy - cardHeight / 2 去计算得到正确数据










如果觉得太抽象,可以结合上边右侧的动图,和大家买股票一样,图中显示红色时是正数,显示绿色时是负数,可以看到:



  • 手指往左移动时,第一行 TouchX 是红色正数,被设置给 rotateY , 然后卡片绕 Y 轴正方向旋转

  • 手指往下移动时,第二行 TouchY 是红色正数,被设置给 rotateX , 然后卡片绕 X 轴正方向旋转


到这里我们就初步实现了卡片跟随手机旋转的效果,但是这时候的立体旋转效果看起来其实“很别扭”,总感觉差了点什么,其实这是因为卡片在旋转时没有产生视觉上的深度感知


所以我们可以通过矩阵的透视变换调整视觉效果,而为了在 Z 方向实现深度感知,我们需要在矩阵中配置 .setEntry(3, 2, 0.001) ,这里的 3 表示第 3 列,2 表示第 2 行,因为是从 0 开始排列,所以也就是图片中 Z 的位置。



其实 .setEntry(3, 2, 0.001) 就是调整 Z 轴的视角,而在 Z 上的 0.001 就是需要的透视效果测量值,类似于相机上的对焦点进行放大和缩小的作用,这个数字越大就会让交点处看起来好像离你视觉更近,所以最终代码如下


Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(touchY)
..rotateY(touchX),
alignment: FractionalOffset.center,

运行之后,可以看到在增加了 Z 角度的视角调整之后,这时候看起来的立体效果就好了很多,并且也有了类似 3D 空间的感觉。



接着我们在卡片上放上一个添加一个 13Text 文本,运行之后可以看到此时文本是跟随卡片发生变化,而接下来我们需要做的,就是通过另外一个 Transform 来让 Text 文本和卡片之间产生视差,从而出现悬浮的效果










所以接下来需要给文本内容设置一个 translateMatrix4 ,让它向着倾斜角度的相反方向移动,然后对前面的 touchXtouchY 进行放大,然后再通过 - 10 操作来产生一个位差。


    Transform(
transform: Matrix4.identity()
..translate(touchX * 100 - 10,
touchY * 100 - 10, 0.0),


-10 这个是我随意写的,你也可以根据自己的需求调节。



例如,这时候当卡片往左倾斜时,文字就会向右移动,从而产生视觉差的效果,得到类似悬浮的感觉。










完成这一步之后,接下来可以我们对文本内容进行一下美化处理,例如增加渐变颜色,添加阴影,更换字体,目的是让字体看起来更加具备立体的效果,这里使用的 shader ,也可以让文字在移动过程中出现不同角度的渐变效果










最后,我们还需要对卡片旋转进行一个范围约束,这里主要是通过卡片大小比例:



  • onPanUpdate 时对 touchXtouchY 进行范围约束,从而约束的卡片的倾斜角度

  • 增加了 startTransform 标志位,用于在 onTapUp 或者 onPanEnd 之后,恢复卡片回到默认状态的作用。


Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateX(startTransform ? touchY : 0.0)
..rotateY(startTransform ? touchX : 0.0),
alignment: FractionalOffset.center,
child: GestureDetector(
onTapUp: (_) => setState(() {
startTransform = false;
}),
onPanCancel: () => setState(() => startTransform = false),
onPanEnd: (_) => setState(() {
startTransform = false;
}),
onPanUpdate: (details) {
setState(() => startTransform = true);
///y轴限制范围
if (details.localPosition.dx < cardWidth * 0.55 &&
details.localPosition.dx > cardWidth * 0.3) {
touchX = (cardWidth / 2 - details.localPosition.dx) / 100;
}

///x轴限制范围
if (details.localPosition.dy > cardHeight * 0.4 &&
details.localPosition.dy < cardHeight * 0.6) {
touchY = (details.localPosition.dy - cardHeight / 2) / 100;
}
},
child:

到这里,我们只需要在全局再进行一些美化处理,运行之后就会如下图所示,再配合阴影和渐变效果,整体的视觉立体感会更强烈,此时我们基本就实现了一开始想要的功能,




完整代码可见: card_perspective_demo_page.dart


Web 体验地址,PC 端记得开 Chrome 手机模式: 3D 视差卡片



那有人可能就想问了: 学会了这个我们还可以实现什么


举个例子,比如我们可以实现一个 “伪3D” 的 360° 卡片效果,利用堆叠实现立体的电子银行卡效果。


依旧是前面的手势旋转逻辑,只是这里我们可以把具有前后画面的银行卡图片,通过 IndexedStack 嵌套起来,嵌套之后主要是根据旋转角度来调整 IndexedStack 里需要展示的图片,然后利用透视旋转来实现类似 3D 物体的 360° 旋转展示



这里的关键是通过手势旋转角度,判断当前需要展示 IndexedStack 里的哪个卡片,因为 Flutter 使用的 Skia 是 2D 渲染引擎,如果没有这部分逻辑,你就只会看到单张图片画面的旋转效果。


if (touchX.abs() % (pi * 3 / 2) >= pi / 2 ||
touchY.abs() % (pi * 3 / 2) >= pi / 2) {
showIndex = 0;
} else {
showIndex = 1;
}

运行效果如下图所示,可以看到在视差和图片切换的作用下,我们用很低的成本在 Flutter 上实现了 “伪3D” 的卡片的 360° 展示,类似的实现其实还可以用于一些商品展示或者页面切换的场景,本质上就是利用视差的效果,在 2D 屏幕上模拟现实中的画面效果,从而达到类似 3D 的视觉作用










最后我们只需要用 Text 在卡片上添加“模拟”凹凸的文字,就实现了我们现实中类似银行卡的卡面效果




完整代码可见: card_3d_demo_page.dart


Web 体验地址,PC 端记得开 chrome 手机模式: 360° 可视化 3D 电子银行卡



好了,本篇动画特效就到此为止,如果你有什么想法,欢迎留言评论,感谢大家耐心看完,也还请看官们走过路过的来个点赞一键三连,感激不尽


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

Android Native 异常捕获库

Android Native 异常捕获库 基于google/breakpad的Android Native 异常捕获库,在native层发生异常时java层能得到相关异常信息。 项目主页 现状 发生native异常时,安卓系统会将native异常信息输...
继续阅读 »

Android Native 异常捕获库


image image image


基于google/breakpad的Android Native 异常捕获库,在native层发生异常时java层能得到相关异常信息。


项目主页


现状



  • 发生native异常时,安卓系统会将native异常信息输出到logcat中,但是java层无法感知到native异常的发生,进而无法获取这些异常信息并上报到业务的异常监控系统。

  • 业务部门可以快速实现java层的异常监控系统(java层全局异常捕获的实现很简单),又或者业务部门已经实现了java层的异常监控系统,但没有覆盖到native层的异常捕获。

  • 安卓还可以接入Breakpad,其导出的minidump文件不仅体积小信息还全,但有两个问题:

    • 1.和现状第1点的问题相同。

    • 2.:需要拉取minidump文件并经过比较繁琐的步骤才可以得出有用的信息:

      • 启动时检测Breakpad是否有导出过minidump文件,有则说明发生过native异常。

      • 到客户现场,或者远程拉取minidump文件。

      • 编译出自己电脑的操作系统的minidump_stackwalk工具。

      • 使用minidump_stackwalk工具翻译minidump文件内容,例如拿到崩溃时的程序计数器寄存器内的值(下文称为pc值)。

      • 找到对应崩溃so库ABI的add2line工具,并根据上一步拿到的pc值定位出发生异常的代码行数。






整个步骤十分复杂和繁琐,且没有java层的crash线程栈信息,不利于java开发者快速定位调用native的代码。


设计意图



  1. 让java层有知悉native异常的通道:

    • java开发者可以在java代码中得到native异常的情况,进而对native异常做出反应,而不是再次启动后去检测Breakpad是否有导出过minidump文件。



  2. 增加信息的可用性,进而提升问题分析的效率:


    • 回调中提供naive异常信息、naive和java调用栈信息和minidump文件文件路径,这些信息可以直接通过业务部门的异常监控系统上报。




    • 划分为两个阶段解决问题,我预想是大部分都在阶段一解决了问题,而不需要再对minidump文件进行分析,总体来讲是提升了分析效率的:



      • 阶段一:有了java的调用栈和native的调用栈信息,大部分异常原因都可以快速定位并分析出来。

      • 阶段二:回调中也会提供minidump文件的存储路径,业务部门可以按需拉取。(这一步需要业务部门本身有拉取日志的功能,且需要按上文”现状部分进行操作”,较费时费力)





  3. 最少改动:

    • 让接入方不因为引入新功能而大量改动现有代码。例如:在native崩溃回调处,使用现有的java层异常监控系统上报native异常信息。



  4. 单一职责:

    • 只做native的crash捕获,不做系统内存情况、cpu使用率、系统日志等信息的采集功能。




整体流程


image.png


功能介绍



  • 保留breakpad导出minidump文件功能 (可选择是否启用)

  • 发生native异常时将异常信息、native层调用栈、java层的调用栈通过回调提供给开发者,将这些信息输出到控制台的效果如下:


2022-02-14 11:33:08.598 30228-30253/com.babyte.banativecrash E/crash:  
/data/user/0/com.babyte.banativecrash/cache/f1474006-60ca-40f4-c9d8e89a-47e90c2e.dmp
2022-02-14 11:33:08.599 30228-30253/com.babyte.banativecrash E/crash:
Operating system: Android 28 Linux 4.4.146 #37 SMP PREEMPT Wed Jan 20 18:26:59 CST 2021
CPU: aarch64 (8 core)

Crash reason: signal 11(SIGSEGV) Invalid address
Crash address: 0000000000000000
Crash pc: 0000000000000650
Crash so: /data/app/com.babyte.banativecrash-ptLzOQ_6UYz-W3Vgyact8A==/lib/arm64/libnative-lib.so(arm64)
Crash method: _Z5Crashv
2022-02-14 11:33:08.602 30228-30253/com.babyte.banativecrash E/crash:
Thread[name:DefaultDispatch] (NOTE: linux thread name length limit is 15 characters)
#00 pc 0000000000000650 /data/app/com.babyte.banativecrash-ptLzOQ_6UYz-W3Vgyact8A==/lib/arm64/libnative-lib.so (Crash()+20)
#01 pc 0000000000000670 /data/app/com.babyte.banativecrash-ptLzOQ_6UYz-W3Vgyact8A==/lib/arm64/libnative-lib.so (Java_com_babyte_banativecrash_MainActivity_nativeCrash+20)
#02 pc 0000000000565de0 /system/lib64/libart.so (offset 0xc1000) (art_quick_generic_jni_trampoline+144)
#03 pc 000000000055cd88 /system/lib64/libart.so (offset 0xc1000) (art_quick_invoke_stub+584)
#04 pc 00000000000cf740 /system/lib64/libart.so (art::ArtMethod::Invoke(art::Thread*, unsigned int*, unsigned int, art::JValue*, char const*)+200)
#05 pc 00000000002823b8 /system/lib64/libart.so (offset 0xc1000)
...
2022-02-14 11:33:08.603 30228-30253/com.babyte.banativecrash E/crash:
Thread[DefaultDispatcher-worker-1,5,main]
at com.babyte.banativecrash.MainActivity.nativeCrash(Native Method)
at com.babyte.banativecrash.MainActivity$onCreate$2$1.invokeSuspend(MainActivity.kt:39)
at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:571)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:750)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:678)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
Thread[DefaultDispatcher-worker-2,5,main]
at java.lang.Object.wait(Native Method)
at java.lang.Thread.parkFor$(Thread.java:2137)
at sun.misc.Unsafe.park(Unsafe.java:358)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:353)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.park(CoroutineScheduler.kt:795)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.tryPark(CoroutineScheduler.kt:740)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:711)
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:665)
...

定位到so中具体代码行示例


可以使用ndk中的add2line工具根据pc值和带符号信息的so库,定位出具体代码行数。


例:从上文的异常信息中可以看到abi是aarch64,对应的so库abi是arm64,所以add2line的使用如下:


$ ./ndk/android-ndk-r16b/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line -Cfe ~/arm64-v8a/libnative-lib.so 0000000000000650

输出结果如下:


Crash()
/Users/ba/AndroidStudioProjects/NativeCrash2Java/app/.cxx/cmake/debug/arm64-v8a/../../../../src/main/cpp/native-lib.cpp:6

接入方式


根项目的build.gradle中:


allprojects {
repositories {
mavenCentral()//添加这一行
}
}

模块的build.gradle中:


dependencies {   
//添加这一行,releaseVersionCode填最新的版本
implementation 'io.github.BAByte:native-crash:releaseVersionCode'
}

初始化


两种模式可选:


//发生native异常时:回调异常信息并导出minidump到指定目录,
BaByteBreakpad.initBreakpad(this.cacheDir.absolutePath) { info:CrashInfo ->
//格式化输出到控制台
BaByteBreakpad.formatPrint(TAG, info)
}

//发生native异常时:回调异常信息
BaByteBreakpad.initBreakpad { info:CrashInfo ->
//格式化输出到控制台
BaByteBreakpad.formatPrint(TAG, info)
}

示例项目


点击查看:示例项目


致谢



  • 感谢google breakpad库提供的源码

  • 感谢腾讯bugly团队提供在发生异常时,native回调java层的思路

  • 感谢爱奇艺xCrash库源码中的dlopen思路

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

不掌握这些坑,你敢用BigDecimal吗?

背景 一直从事金融相关项目,所以对BigDecimal再熟悉不过了,也曾看到很多同学因为不知道、不了解或使用不当导致资损事件发生。 所以,如果你从事金融相关项目,或者你的项目中涉及到金额的计算,那么你一定要花时间看看这篇文章,全面学习一下BigDecimal。...
继续阅读 »

背景


一直从事金融相关项目,所以对BigDecimal再熟悉不过了,也曾看到很多同学因为不知道、不了解或使用不当导致资损事件发生。


所以,如果你从事金融相关项目,或者你的项目中涉及到金额的计算,那么你一定要花时间看看这篇文章,全面学习一下BigDecimal。


BigDecimal概述


Java在java.math包中提供的API类BigDecimal,用来对超过16位有效位的数进行精确的运算。双精度浮点型变量double可以处理16位有效数,但在实际应用中,可能需要对更大或者更小的数进行运算和处理。


一般情况下,对于不需要准确计算精度的数字,可以直接使用Float和Double处理,但是Double.valueOf(String) 和Float.valueOf(String)会丢失精度。所以如果需要精确计算的结果,则必须使用BigDecimal类来操作。


BigDecimal对象提供了传统的+、-、*、/等算术运算符对应的方法,通过这些方法进行相应的操作。BigDecimal都是不可变的(immutable)的, 在进行每一次四则运算时,都会产生一个新的对象 ,所以在做加减乘除运算时要记得要保存操作后的值。


BigDecimal的4个坑


在使用BigDecimal时,有4种使用场景下的坑,你一定要了解一下,如果使用不当,必定很惨。掌握这些案例,当别人写出有坑的代码,你也能够一眼识别出来,大牛就是这么练成的。


第一:浮点类型的坑


在学习了解BigDecimal的坑之前,先来说一个老生常谈的问题:如果使用Float、Double等浮点类型进行计算时,有可能得到的是一个近似值,而不是精确的值。


比如下面的代码:


  @Test
public void test0(){
float a = 1;
float b = 0.9f;
System.out.println(a - b);
}

结果是多少?0.1吗?不是,执行上面代码执行的结果是0.100000024。之所以产生这样的结果,是因为0.1的二进制表示是无限循环的。由于计算机的资源是有限的,所以是没办法用二进制精确的表示 0.1,只能用「近似值」来表示,就是在有限的精度情况下,最大化接近 0.1 的二进制数,于是就会造成精度缺失的情况


关于上述的现象大家都知道,不再详细展开。同时,还会得出结论在科学计数法时可考虑使用浮点类型,但如果是涉及到金额计算要使用BigDecimal来计算。


那么,BigDecimal就一定能避免上述的浮点问题吗?来看下面的示例:


  @Test
public void test1(){
BigDecimal a = new BigDecimal(0.01);
BigDecimal b = BigDecimal.valueOf(0.01);
System.out.println("a = " + a);
System.out.println("b = " + b);
}

上述单元测试中的代码,a和b结果分别是什么?


a = 0.01000000000000000020816681711721685132943093776702880859375
b = 0.01

上面的实例说明,即便是使用BigDecimal,结果依旧会出现精度问题。这就涉及到创建BigDecimal对象时,如果有初始值,是采用new BigDecimal的形式,还是通过BigDecimal#valueOf方法了。


之所以会出现上述现象,是因为new BigDecimal时,传入的0.1已经是浮点类型了,鉴于上面说的这个值只是近似值,在使用new BigDecimal时就把这个近似值完整的保留下来了。


而BigDecimal#valueOf则不同,它的源码实现如下:


    public static BigDecimal valueOf(double val) {
      // Reminder: a zero double returns '0.0', so we cannot fastpath
      // to use the constant ZERO. This might be important enough to
      // justify a factory approach, a cache, or a few private
      // constants, later.
      return new BigDecimal(Double.toString(val));
  }

在valueOf内部,使用Double#toString方法,将浮点类型的值转换成了字符串,因此就不存在精度丢失问题了。


此时就得出一个基本的结论:第一,在使用BigDecimal构造函数时,尽量传递字符串而非浮点类型;第二,如果无法满足第一条,则可采用BigDecimal#valueOf方法来构造初始化值


这里延伸一下,BigDecimal常见的构造方法有如下几种:


BigDecimal(int)       创建一个具有参数所指定整数值的对象。
BigDecimal(double)   创建一个具有参数所指定双精度值的对象。
BigDecimal(long)     创建一个具有参数所指定长整数值的对象。
BigDecimal(String)   创建一个具有参数所指定以字符串表示的数值的对象。

其中涉及到参数类型为double的构造方法,会出现上述的问题,使用时需特别留意。


第二:浮点精度的坑


如果比较两个BigDecimal的值是否相等,你会如何比较?使用equals方法还是compareTo方法呢?


先来看一个示例:


  @Test
public void test2(){
BigDecimal a = new BigDecimal("0.01");
BigDecimal b = new BigDecimal("0.010");
System.out.println(a.equals(b));
System.out.println(a.compareTo(b));
}

乍一看感觉可能相等,但实际上它们的本质并不相同。


equals方法是基于BigDecimal实现的equals方法来进行比较的,直观印象就是比较两个对象是否相同,那么代码是如何实现的呢?


    @Override
  public boolean equals(Object x) {
      if (!(x instanceof BigDecimal))
          return false;
      BigDecimal xDec = (BigDecimal) x;
      if (x == this)
          return true;
      if (scale != xDec.scale)
          return false;
      long s = this.intCompact;
      long xs = xDec.intCompact;
      if (s != INFLATED) {
          if (xs == INFLATED)
              xs = compactValFor(xDec.intVal);
          return xs == s;
      } else if (xs != INFLATED)
          return xs == compactValFor(this.intVal);

      return this.inflated().equals(xDec.inflated());
  }

仔细阅读代码可以看出,equals方法不仅比较了值是否相等,还比较了精度是否相同。上述示例中,由于两者的精度不同,所以equals方法的结果当然是false了。而compareTo方法实现了Comparable接口,真正比较的是值的大小,返回的值为-1(小于),0(等于),1(大于)。


基本结论:通常情况,如果比较两个BigDecimal值的大小,采用其实现的compareTo方法;如果严格限制精度的比较,那么则可考虑使用equals方法


另外,这种场景在比较0值的时候比较常见,比如比较BigDecimal("0")、BigDecimal("0.0")、BigDecimal("0.00"),此时一定要使用compareTo方法进行比较。


第三:设置精度的坑


在项目中看到好多同学通过BigDecimal进行计算时不设置计算结果的精度和舍入模式,真是着急人,虽然大多数情况下不会出现什么问题。但下面的场景就不一定了:


  @Test
public void test3(){
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
a.divide(b);
}

执行上述代码的结果是什么?ArithmeticException异常


java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.

at java.math.BigDecimal.divide(BigDecimal.java:1690)
...

这个异常的发生在官方文档中也有说明:


If the quotient has a nonterminating decimal expansion and the operation is specified to return an exact result, an ArithmeticException is thrown. Otherwise, the exact result of the division is returned, as done for other operations.


总结一下就是,如果在除法(divide)运算过程中,如果商是一个无限小数(0.333…),而操作的结果预期是一个精确的数字,那么将会抛出ArithmeticException异常。


此时,只需在使用divide方法时指定结果的精度即可:


  @Test
public void test3(){
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("3.0");
BigDecimal c = a.divide(b, 2,RoundingMode.HALF_UP);
System.out.println(c);
}

执行上述代码,输入结果为0.33。


基本结论:在使用BigDecimal进行(所有)运算时,一定要明确指定精度和舍入模式


拓展一下,舍入模式定义在RoundingMode枚举类中,共有8种:



  • RoundingMode.UP:舍入远离零的舍入模式。在丢弃非零部分之前始终增加数字(始终对非零舍弃部分前面的数字加1)。注意,此舍入模式始终不会减少计算值的大小。

  • RoundingMode.DOWN:接近零的舍入模式。在丢弃某部分之前始终不增加数字(从不对舍弃部分前面的数字加1,即截短)。注意,此舍入模式始终不会增加计算值的大小。

  • RoundingMode.CEILING:接近正无穷大的舍入模式。如果 BigDecimal 为正,则舍入行为与 ROUNDUP 相同;如果为负,则舍入行为与 ROUNDDOWN 相同。注意,此舍入模式始终不会减少计算值。

  • RoundingMode.FLOOR:接近负无穷大的舍入模式。如果 BigDecimal 为正,则舍入行为与 ROUNDDOWN 相同;如果为负,则舍入行为与 ROUNDUP 相同。注意,此舍入模式始终不会增加计算值。

  • RoundingMode.HALF_UP:向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为向上舍入的舍入模式。如果舍弃部分 >= 0.5,则舍入行为与 ROUND_UP 相同;否则舍入行为与 ROUND_DOWN 相同。注意,这是我们在小学时学过的舍入模式(四舍五入)。

  • RoundingMode.HALF_DOWN:向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则为上舍入的舍入模式。如果舍弃部分 > 0.5,则舍入行为与 ROUND_UP 相同;否则舍入行为与 ROUND_DOWN 相同(五舍六入)。

  • RoundingMode.HALF_EVEN:向“最接近的”数字舍入,如果与两个相邻数字的距离相等,则向相邻的偶数舍入。如果舍弃部分左边的数字为奇数,则舍入行为与 ROUNDHALFUP 相同;如果为偶数,则舍入行为与 ROUNDHALF_DOWN 相同。注意,在重复进行一系列计算时,此舍入模式可以将累加错误减到最小。此舍入模式也称为“银行家舍入法”,主要在美国使用。四舍六入,五分两种情况。如果前一位为奇数,则入位,否则舍去。以下例子为保留小数点1位,那么这种舍入方式下的结果。1.15 ==> 1.2 ,1.25 ==> 1.2

  • RoundingMode.UNNECESSARY:断言请求的操作具有精确的结果,因此不需要舍入。如果对获得精确结果的操作指定此舍入模式,则抛出ArithmeticException。


通常我们使用的四舍五入即RoundingMode.HALF_UP。


第四:三种字符串输出的坑


当使用BigDecimal之后,需要转换成String类型,你是如何操作的?直接toString?


先来看看下面的代码:


@Test
public void test4(){
BigDecimal a = BigDecimal.valueOf(35634535255456719.22345634534124578902);
System.out.println(a.toString());
}

执行的结果是上述对应的值吗?并不是:


3.563453525545672E+16

也就是说,本来想打印字符串的,结果打印出来的是科学计数法的值。


这里我们需要了解BigDecimal转换字符串的三个方法



  • toPlainString():不使用任何科学计数法;

  • toString():在必要的时候使用科学计数法;

  • toEngineeringString() :在必要的时候使用工程计数法。类似于科学计数法,只不过指数的幂都是3的倍数,这样方便工程上的应用,因为在很多单位转换的时候都是10^3;


三种方法展示结果示例如下:


计算法


基本结论:根据数据结果展示格式不同,采用不同的字符串输出方法,通常使用比较多的方法为toPlainString()


另外,NumberFormat类的format()方法可以使用BigDecimal对象作为其参数,可以利用BigDecimal对超出16位有效数字的货币值,百分值,以及一般数值进行格式化控制。


使用示例如下:


NumberFormat currency = NumberFormat.getCurrencyInstance(); //建立货币格式化引用
NumberFormat percent = NumberFormat.getPercentInstance(); //建立百分比格式化引用
percent.setMaximumFractionDigits(3); //百分比小数点最多3位

BigDecimal loanAmount = new BigDecimal("15000.48"); //金额
BigDecimal interestRate = new BigDecimal("0.008"); //利率
BigDecimal interest = loanAmount.multiply(interestRate); //相乘

System.out.println("金额:\t" + currency.format(loanAmount));
System.out.println("利率:\t" + percent.format(interestRate));
System.out.println("利息:\t" + currency.format(interest));

输出结果如下:


金额: ¥15,000.48 
利率: 0.8%
利息: ¥120.00

小结


本篇文章介绍了BigDecimal使用中场景的坑,以及基于这些坑我们得出的“最佳实践”。虽然某些场景下推荐使用BigDecimal,它能够达到更好的精度,但性能相较于double和float,还是有一定的损失的,特别在处理庞大,复杂的运算时尤为明显。故一般精度的计算没必要使用BigDecimal。而必须使用时,一定要规避上述的坑。


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

Flutter 组件集录 | 桌面导航 NavigationRail

我们都知道 BottomNavigationBar 是一个移动端非常常用的底部导航栏组件,可以用于点击处理激活菜单,并通过回调来处理界面的切换。 -- 但是在桌面端,由于一般是宽大于高,所以 BottomNavigationBar ...
继续阅读 »

我们都知道 BottomNavigationBar 是一个移动端非常常用的底部导航栏组件,可以用于点击处理激活菜单,并通过回调来处理界面的切换。















--



但是在桌面端,由于一般是宽大于高,所以 BottomNavigationBar 并不适用。而是侧边的导航栏较为常见,比如下面飞书的客户端界面布局。



为了满足桌面端的导航栏适用需求,官方新增了 NavigationRail 组件,而非对 BottomNavigationBar 组件进行适配。之前我也说过,对于差异较大的结构,并没有必要让一个组件通过适配来完成两端需求。分离开来也不是坏事,让一件衣服同时适配 蚂蚁燕子 是很困难的,这时做两件衣服,各司其职显然是更好地方式。


BottomNavigationBarNavigationRail 两个导航就是如此,从语义上来看 Bottom 就是用于底部的导航, Rail扶手铁轨 的意思,作为侧栏导航的语义,还是很生动有趣的。两者分别处理特定的结构,这也很符合 单一职责 的原则。


该组件已录入 【FlutterUnit】 ,可以在 App 中体验。另外,本文中的代码可在对应文件夹中查看:


image.png




1. NavigationRail 组件的基本使用

下面是 NavigationRail 组件的构造方法,其中必须传入的有两个参数:



  • destinations : 表示导航栏的信息,是 NavigationRailDestination 列表。

  • selectedIndex: 表示激活索引,int 类型。





我们先来实现如下最简单的使用场景,左侧导航栏,在点击时切换右侧内容页:



如果导航栏的数据是固定的,可以提前定义如下的 destinations 常量。如下的 _buildLeftNavigation 方法负责构建左侧导航栏,NavigationRail 在构造中可以通过 onDestinationSelected 回调方法,来监听用户和导航栏的交互事件,传递用点击的索引位置。


final List<NavigationRailDestination> destinations = const [
NavigationRailDestination(icon: Icon(Icons.message_outlined),label: Text("消息")),
NavigationRailDestination(icon: Icon(Icons.video_camera_back_outlined),label: Text("视频会议")),
NavigationRailDestination(icon: Icon(Icons.book_outlined),label: Text("通讯录")),
NavigationRailDestination(icon: Icon(Icons.cloud_upload_outlined),label: Text("云文档")),
NavigationRailDestination(icon: Icon(Icons.games_sharp),label: Text("工作台")),
NavigationRailDestination(icon: Icon(Icons.calendar_month),label: Text("日历"))
];

Widget _buildLeftNavigation(int index){
return NavigationRail(
onDestinationSelected: _onDestinationSelected,
destinations: destinations,
selectedIndex: index,
);
}

void _onDestinationSelected(int value) {
//TODO 更新索引 + 切换界面
}



NavigationRail 的文档注释中说道:该组件一般在 Row 中,使用于 Scaffold.body 属性下。这也很容易理解,这是一个左右结构,在 Row 中可以通过 Expanded 可以自动延伸主体内容。如下,主体内容界面通过 PageView 进行构建,其中的 TestContent 组件在实际使用中换成你的需求界面。


@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
_buildLeftNavigation(index),
Expanded(child: PageView(
children:const [
TestContent(content: '消息',),
TestContent(content: '视频会议',),
TestContent(content: '通讯录',),
TestContent(content: '云文档',),
TestContent(content: '工作台',),
TestContent(content: '日历',),
],
))
],
),
);
}



最后是关键的一点:点击时,如何实现导航索引的切换和主体内容的切页。思路其实很简单,我们已经知道用户点击导航菜单的回调事件。对于 PageView 来说,可以通过 PageController 切换界面,NavigationRail 可以通过 selectedIndex 确定激活索引,所以只要用新索引重新构建 NavigationRail即可。
如下代码所示,在 _onDestinationSelected 在处理这两件重要的事。如下 tag1 处,通过 PageControllerjumpToPage 方法进行界面跳转。


这里通过 ValueListenableBuilder 来监听 _selectIndex 实现局部更新构建,如下 tag2 处,只要更新 _selectIndex 的值,就可以通知 ValueListenableBuilder 触发 builder 方法,使用新索引,构建 NavigationRail 。这样可以避免直接触发 _MyHomePageState 的更新方法,对 Scaffold 整体进行更新。


class _MyHomePageState extends State<MyHomePage> {

final PageController _controller = PageController();
final ValueNotifier<int> _selectIndex = ValueNotifier(0);

// 略同...
@override
Widget build(BuildContext context) {
return Scaffold(
body: Row(
children: [
ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (_,index,__)=>_buildLeftNavigation(index),
),
Expanded(child: PageView(
controller: _controller,
// 略同...
}

void _onDestinationSelected(int value) {
_controller.jumpToPage(value); // tag1
_selectIndex.value = value; //tag2
}

@override
void dispose(){
_controller.dispose();
_selectIndex.dispose();
super.dispose();
}
}

这样就完成了 NavigationRail 最基本的使用,实现了左侧导航结构以及点击时的切换逻辑。NavigationRail 在构造方法中还有很多其他的配置参数用于样式调整,这些不是核心,但可以锦上添花,下面一起来看一下。




2.首尾组件与折叠

leadingtrailing 属性相当于两个插槽,如下所示,表示导航菜单外的首尾组件。



Widget _buildLeftNavigation(int index){
return NavigationRail(
leading: const Icon(Icons.menu_open,color: Colors.grey,),
trailing: FlutterLogo(),
onDestinationSelected: _onDestinationSelected,
destinations: destinations,
selectedIndex: index,
);
}



这里有个小细节,trailing 紧随最后一个菜单,如何让它像飞书的导航那样,在最尾部呢?偷瞄一些源码可以看出 trailing 是和导航菜单一起被放入 Column 中的。



所以我们可以通过 Expanded 来延伸剩余空间形成紧约束,通过 Align 使 FlutterLogo 排在下方:



Widget _buildLeftNavigation(int index){
return NavigationRail(
leading: const Icon(Icons.menu_open,color: Colors.grey,),
extended: false,
trailing: const Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 20.0),
child: FlutterLogo(),
),
),
),
onDestinationSelected: _onDestinationSelected,
destinations: destinations,
selectedIndex: index,
);
}



另外,NavigationRail 中有个 extendedbool 参数,用于控制是否展开侧边栏,当该属性变化时,会进行动画展开和收起。如下所示,点击头部时,更新 NavigationRailextended 入参即可:





3.影深 与 标签类型

elevation 表示阴影的深度,这是非常常见的一个属性,如下红框所示,设置 elevation 之后右侧会有阴影,该值越大,阴影越明显。





labelType 参数表示标签类型,对应的属性是 NavigationRailLabelType 枚举。用于表示什么时候显示文字标签,默认是 none ,也就是只显示图标,没有文字。


enum NavigationRailLabelType {
none,
selected,
all,
}

设置为 all 时,效果如下:导航菜单会同时显示 图标文字标签





设置为 selected 时,效果如下:只有激活的导航菜单会同时显示 图标文字标签



另外,有一点需要注意: 当 extended 属性为 true 时, labelType 必须为 NavigationRailLabelType.none 不然会报错。



---->[NavigationRail构造断言]----
assert(!extended || (labelType == null || labelType == NavigationRailLabelType.none)),



4.背景、文字、图标样式


  • unselectedLabelTextStyle : 未选中签文字样式

  • selectedLabelTextStyle : 选中标签文字样式

  • unselectedIconTheme : 未选中图标样式

  • selectedIconTheme : 选中图标样式


这四个样式基本上是顾名思义,下面通过一个深色背景版本来使用一下:



@override
Widget build(BuildContext context) {
const Color textColor = Color(0xffcfd1d7);
const Color activeColor = Colors.blue;
const TextStyle labelStyle = TextStyle(color: textColor,fontSize: 11);

return NavigationRail(
backgroundColor: const Color(0xff324465),
unselectedIconTheme: const IconThemeData(color: textColor) ,
selectedIconTheme: const IconThemeData(color: activeColor) ,
unselectedLabelTextStyle: labelStyle,
selectedLabelTextStyle: labelStyle,
// 略同...
}



5.指示器与最小宽度


  • useIndicator : 是否添加指示器

  • indicatorColor : 指示器颜色


这两个属性用于控制图标后面的背景指示器,如下是在 NavigationRailLabelType.all 类型下指示器的样式,通过圆角矩形包裹图标:





NavigationRailLabelType.none 类型下,指示器通过圆形包裹图标:






  • minWidth : 默认 72 ,未展开时导航栏宽度




  • indicatorColor :默认 256 ,展开时导航栏宽度



NavigationRail 组件的属性介绍就到这里,总的来看,悬浮和点击时,导航栏还是一股 Material 的味。个人觉得这并不适合桌面端,导航栏的菜单可定制性也一般般,只能满足基本的需求。对于稍微特别点的样式,无法支持,比如飞书客户端的导航样式。另外像 拖动更换菜单位置 这样的交互,我们也只通过自定义组件来实现。





6.剖析 NavigationRail 组件,借鉴思路

就像世界上并没有什么包治百病的 ,我们也并不能苛求一个组件能满足所有的布局需求。对于一个原生组件满足不了的需求,发挥创造能力去解决问题,这应是我们的本职工作。借鉴官方对于组件实现的思路是非常重要的,它可以为你提供一个主方向。



我们可以发现 NavigationRailSwitchBottomNavigationBar 等组件一样,虽然自身是 StatefulWidget, 但对于激活状态的数据并不是在内部状态中维护,而是让 使用者主动提供,比如这里在构造 NavigationRail 时必须传入 selectedIndex 。 该组件只提供回调事件来通知使用者,这样的用意是让使用者更容易 控制 该状态,而不是完全封装在状态类内部。


另外,从 selectedIndex 属性在状态类中的使用中可以看出,每个菜单的条目组件通过 _RailDestination 进行构建。从这里可以看出,_RailDestination 会通过 selected 属性来区分是否激活,而且会通过 onTap 回调点击事件。在此触发 widget.onDestinationSelected ,将当前索引 i 传递给用户。



这里 _RailDestinationStatelessWidget, 只说明并不需要维护内部状态的变化,组需要根据构造中的配置信息构建需要的组件即可。这就尽可能地简化了 _RailDestination 的构建逻辑,让其相对独立,专注地去做一件事。这就是组件分离的好处之一:既可以简化构建结构,增加可读性,又可以将相对独立的构建逻辑内聚在一起。我们完全可以在日常开发中对这样的分离进行借鉴和发挥。




另外这里比较值得借鉴的还有动画的处理,我看了一下目前桌面的一些应用,比如 微信飞书有道词典百度网盘AndroidStudio有道云笔记 ,这些导航栏在切换时都是没有动画的。如下所示,NavigationRail 对应的状态类中维护了两种动画控制器,这也是 NavigationRail 为什么需要是 StatefulWidget 的原因。



其中 _destinationControllers 用于处理,菜单背景指示器在点击时激活/非激活的透明度渐变动画。可以追踪一下动画器的去向: 在 NavigationIndicator 中通过 FadeTransition使用动画器完成透明度渐变动画。


_RailDestination -->  _AddIndicator --> NavigationIndicator
复制代码




最后看一下 _extendedController 动画控制器,它对应的动画器也被传入 _RailDestination 中来完成动画功能。这个动画控制器在 extended 属性变化时,展开折叠导航栏的动画。如下源码所示,可以看出关于这个动画更多的细节。 动画过程中文字标签有个透明度渐变的动画,宽度约束通过对 ConstrainedBox 进行限制,并通过 AlignwidthFactor 控制文字标签区域的尺寸。



这里的 ClipRect 组件套的很迷,我试了一下去除后并不影响动画效果,一开始不知道为什么要加。之后将动画时长拉长,进行了一些测试发现端倪,如果不进行裁剪,就会出现如下的不和谐情况。默认动画 200 ms 看不出太大差异。从这里我又学到了一个小技巧:如何动画展开一个区域。



所以说源码是最好的老师,通过分析源码的实现去思考和学习,是成长的一条很好的途径。而不是什么东西都靠别人给你灌输,遇到不会的或犹豫不决时就到处问。Flutter 组件的源码相对独立,套路也比较简单,很适合去研究学习。《Flutter 组件集录》 专栏专门用于收录我对 Flutter 常用组件的使用介绍,其中一般也会有相关源码实现的一些分析。对一些能力稍弱的朋友,也可以根据这些介绍去尝试研究。那本文就到这里,谢谢观看 ~


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

公司产品太多了,怎么实现一次登录产品互通?

大家好,我是老王,最近开发新产品,然后老板说我们现在系统太多了,每次切换系统登录太麻烦了,能不能做个优化,同一账号互通掉。作为一个资深架构狮,老板的要求肯定要满足,安排! 一个公司产品矩阵比较丰富的时候,用户在不同系统之间来回切换,固然对产品用户体验上较差,...
继续阅读 »

大家好,我是老王,最近开发新产品,然后老板说我们现在系统太多了,每次切换系统登录太麻烦了,能不能做个优化,同一账号互通掉。作为一个资深架构狮,老板的要求肯定要满足,安排!


image.png


一个公司产品矩阵比较丰富的时候,用户在不同系统之间来回切换,固然对产品用户体验上较差,并且增加用户密码管理成本。也没有很好地利用内部流量进行用户打通,并且每个产品的独立体系会导致产品安全度下降。因此实现集团产品的单点登录对用户使用体验以及效率提升有很大的帮助。那么如何实现统一认证呢?我们先了解一下传统的身份验证方式。


1 传统Session机制及身份认证方案


1.1 Cookie与服务器的交互


image.png


众所周知,http是无状态的协议,因此客户每次通过浏览器访问web

页面,请求到服务端时,服务器都会新建线程,打开新的会话,而且服务器也不会自动维护客户的上下文信息。比如我们现在要实现一个电商内的购物车功能,要怎么才能知道哪些购物车请求对应的是来自同一个客户的请求呢?


image.png


因此出现了session这个概念,session 就是一种保存上下文信息的机制,他是面向用户的,每一个SessionID 对应着一个用户,并且保存在服务端中。session主要 以 cookie 或 URL 重写为基础的来实现的,默认使用 cookie 来实现,系统会创造一个名为JSESSIONID的变量输出到cookie中。


JSESSIONID 是存储于浏览器内存中的,并不是写到硬盘上的,如果我们把浏览器的cookie 禁止,则 web 服务器会采用 URL 重写的方式传递 Sessionid,我们就可以在地址栏看到 sessionid=KWJHUG6JJM65HS2K6 之类的字符串。


通常 JSESSIONID 是不能跨窗口使用的,当你新开了一个浏览器窗口进入相同页面时,系统会赋予你一个新的sessionid,这样我们信息共享的目的就达不到了。


1.2 服务器端的session的机制


当服务端收到客户端的请求时候,首先判断请求里是否包含了JSESSIONID的sessionId,如果存在说明已经创建过了,直接从内存中拿出来使用,如果查询不到,说明是无效的。


如果客户请求不包含sessionid,则为此客户创建一个session并且生成一个与此session相关联的sessionid,这个sessionid将在本次响应中返回给客户端保存。


对每次http请求,都经历以下步骤处理:


-服务端首先查找对应的cookie的值(sessionid)。

-根据sessionid,从服务器端session存储中获取对应id的session数据,进行返回。

-如果找不到sessionid,服务器端就创建session,生成sessionid对应的cookie,写入到响应头中。


session是由服务端生成的,并且以散列表的形式保存在内存中


1.3 基于 session 的身份认证流程


基于seesion的身份认证主要流程如下:


image.png


因为 http 请求是无状态请求,所以在 Web 领域,大部分都是通过这种方式解决。但是这么做有什么问题呢?我们接着看


2 集群环境下的 Session 困境及解决方案


image.png


随着技术的发展,用户流量增大,单个服务器已经不能满足系统的需要了,分布式架构开始流行。通常都会把系统部署在多台服务器上,通过负载均衡把请求分发到其中的一台服务器上,这样很可能同一个用户的请求被分发到不同的服务器上,因为 session 是保存在服务器上的,那么很有可能第一次请求访问的 A 服务器,创建了 session,但是第二次访问到了 B 服务器,这时就会出现取不到 session 的情况。


我们知道,Session 一般是用来存会话全局的用户信息(不仅仅是登陆方面的问题),用来简化/加速后续的业务请求。

传统的 session 由服务器端生成并存储,当应用进行分布式集群部署的时候,如何保证不同服务器上 session 信息能够共享呢?


2.1 Session共享方案


Session共享一般有两种思路



  • session复制

  • session集中存储


2.1.1 session复制


session复制即将不同服务器上 session 数据进行复制,用户登录,修改,注销时,将session信息同时也复制到其他机器上面去

image.png


这种实现的问题就是实现成本高,维护难度大,并且会存在延迟登问题。


2.1.2 session集中存储


image.png


集中存储就是将获取session单独放在一个服务中进行存储,所有获取session的统一来这个服务中去取。这样就避免了同步和维护多套session的问题。一般我们都是使用redis进行集中式存储session。


3 多服务下的登陆困境及SSO方案


3.1 SSO的产生背景


image.png


如果企业做大了之后,一般都有很多的业务支持系统为其提供相应的管理和 IT 服务,按照传统的验证方式访问多系统,每个单独的系统都会有自己的安全体系和身份认证系统。进入每个系统都需要进行登录,获取session,再通过session访问对应系统资源。这样的局面不仅给管理上带来了很大的困难,对客户来说也极不友好,那么如何让客户只需登陆一次,就可以进入多个系统,而不需要重新登录呢?


image.png


“单点登录”就是专为解决此类问题的。 其大致思想流程如下:通过一个 ticket 进行串接各系统间的用户信息


3.2 SSO的底层原理 CAS


3.2.1 CAS实现单点登录流程


我们知道对于完全不同域名的系统,cookie 是无法跨域名共享的,因此 sessionId 在页面端也无法共享,因此需要实现单店登录,就需要启用一个专门用来登录的域名如(ouath.com)来提供所有系统的sessionId。当业务系统被打开时,借助中心授权系统进行登录,整体流程如下:


1.当b.com打开时,发现自己未登陆,于是跳转到ouath.com去登陆

2. ouath.com登陆页面被打开,用户输入帐户/密码登陆成功

3. ouath.com登陆成功,种 cookie 到ouath.com域名下

4. 把 sessionid 放入后台redis,存放<ticket,sesssionid>数据结构,然后页面重定向到A系统

5.当b.com重新被打开,发现仍然是未登陆,但是有了一个 ticket值

6. 当b.com用ticket 值,到 redis 里查到 sessionid,并做 session 同步,然后种cookie给自己,页面原地重定向

7. 当b.com打开自己页面,此时有了 cookie,后台校验登陆状态,成功


整个交互流程图如下:


image.png


3.2.2 单点登录流程演示


3.2.2.1 CAS登录服务demo核心代码


1.用户实体类



public class UserForm implements Serializable{
private static final long serialVersionUID = 1L;

private String username;
private String password;
private String backurl;

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

public String getBackurl() {
return backurl;
}

public void setBackurl(String backurl) {
this.backurl = backurl;
}

}

2.登录控制器


@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;

@GetMapping("/toLogin")
public String toLogin(Model model,HttpServletRequest request) {
Object userInfo = request.getSession().getAttribute(LoginFilter.USER_INFO);
//不为空,则是已登陆状态
if (null != userInfo){
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,userInfo,2, TimeUnit.SECONDS);
return "redirect:"+request.getParameter("url")+"?ticket="+ticket;
}
UserForm user = new UserForm();
user.setUsername("laowang");
user.setPassword("laowang");
user.setBackurl(request.getParameter("url"));
model.addAttribute("user", user);

return "login";
}

@PostMapping("/login")
public void login(@ModelAttribute UserForm user,HttpServletRequest request,HttpServletResponse response) throws IOException, ServletException {
System.out.println("backurl:"+user.getBackurl());
request.getSession().setAttribute(LoginFilter.USER_INFO,user);

//登陆成功,创建用户信息票据
String ticket = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(ticket,user,20, TimeUnit.SECONDS);
//重定向,回原url ---a.com
if (null == user.getBackurl() || user.getBackurl().length()==0){
response.sendRedirect("/index");
} else {
response.sendRedirect(user.getBackurl()+"?ticket="+ticket);
}
}

@GetMapping("/index")
public ModelAndView index(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView();
Object user = request.getSession().getAttribute(LoginFilter.USER_INFO);
UserForm userInfo = (UserForm) user;
modelAndView.setViewName("index");
modelAndView.addObject("user", userInfo);
request.getSession().setAttribute("test","123");
return modelAndView;
}
}

3.登录过滤器


public class LoginFilter implements Filter {
public static final String USER_INFO = "user";
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {

HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;

Object userInfo = request.getSession().getAttribute(USER_INFO);;

//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&amp;&amp; !requestUrl.startsWith("/login")//不是去登陆
&amp;&amp; null == userInfo) {//不是登陆状态

request.getRequestDispatcher("/toLogin").forward(request,response);
return ;
}

filterChain.doFilter(request,servletResponse);
}

@Override
public void destroy() {

}
}

4.配置过滤器


@Configuration
public class LoginConfig {

//配置filter生效
@Bean
public FilterRegistrationBean sessionFilterRegistration() {

FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new LoginFilter());
registration.addUrlPatterns("/*");
registration.addInitParameter("paramName", "paramValue");
registration.setName("sessionFilter");
registration.setOrder(1);
return registration;
}
}

5.登录页面


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>enjoy login</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div text-align="center">
<h1>请登陆</h1>
<form action="#" th:action="@{/login}" th:object="${user}" method="post">
<p>用户名: <input type="text" th:field="*{username}" /></p>
<p>密 码: <input type="text" th:field="*{password}" /></p>
<p><input type="submit" value="Submit" /> <input type="reset" value="Reset" /></p>
<input type="text" th:field="*{backurl}" hidden="hidden" />
</form>
</div>


</body>
</html>

3.2.2.2 web系统demo核心代码


1.过滤器


public class SSOFilter implements Filter {
private RedisTemplate redisTemplate;

public static final String USER_INFO = "user";

public SSOFilter(RedisTemplate redisTemplate){
this.redisTemplate = redisTemplate;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {

HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;

Object userInfo = request.getSession().getAttribute(USER_INFO);;

//如果未登陆,则拒绝请求,转向登陆页面
String requestUrl = request.getServletPath();
if (!"/toLogin".equals(requestUrl)//不是登陆页面
&amp;&amp; !requestUrl.startsWith("/login")//不是去登陆
&amp;&amp; null == userInfo) {//不是登陆状态

String ticket = request.getParameter("ticket");
//有票据,则使用票据去尝试拿取用户信息
if (null != ticket){
userInfo = redisTemplate.opsForValue().get(ticket);
}
//无法得到用户信息,则去登陆页面
if (null == userInfo){
response.sendRedirect("http://127.0.0.1:8080/toLogin?url="+request.getRequestURL().toString());
return ;
}

/**
* 将用户信息,加载进session中
*/
UserForm user = (UserForm) userInfo;
request.getSession().setAttribute(SSOFilter.USER_INFO,user);
redisTemplate.delete(ticket);
}

filterChain.doFilter(request,servletResponse);
}

@Override
public void destroy() {

}
}

2.控制器


@Controller
public class IndexController {
@Autowired
private RedisTemplate redisTemplate;

@GetMapping("/index")
public ModelAndView index(HttpServletRequest request) {
ModelAndView modelAndView = new ModelAndView();
Object userInfo = request.getSession().getAttribute(SSOFilter.USER_INFO);
UserForm user = (UserForm) userInfo;
modelAndView.setViewName("index");
modelAndView.addObject("user", user);

request.getSession().setAttribute("test","123");
return modelAndView;
}
}

3.首页


<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>enjoy index</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div th:object="${user}">
<h1>cas-website:欢迎你"></h1>
</div>
</body>
</html>

3.2.3 CAS的单点登录和OAuth2的区别


OAuth2:三方授权协议,允许用户在不提供账号密码的情况下,通过信任的应用进行授权,使其客户端可以访问权限范围内的资源。


CAS :中央认证服务(Central Authentication Service),一个基于Kerberos票据方式实现SSO单点登录的框架,为Web 应用系统提供一种可靠的单点登录解决方法(属于 Web SSO )。



  1. CAS的单点登录时保障客户端的用户资源的安全 ;OAuth2则是保障服务端的用户资源的安全 。

  2. CAS客户端要获取的最终信息是,这个用户到底有没有权限访问我(CAS客户端)的资源;OAuth2获取的最终信息是,我(oauth2服务提供方)的用户的资源到底能不能让你(oauth2的客户端)访问。


因此,需要统一的账号密码进行身份认证,用CAS;需要授权第三方服务使用我方资源,使用OAuth2;


好了,不知道大家对SSO是否有了更深刻的理解,大家有问题可以私信我。我是王老狮,一个有想法有内涵的工程狮,关注我,学习更多技术知识。


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

有哪些话一听就知道一个程序员是个水货?

要判断一个程序员是水货是非常难的。反过来说,要面试出一个程序员的真实水平是比较困难的。现在很多小公司面试,动不动就是手写代码。从框架问到具体的技术实现细节,结果错过了好多真正有能力的程序员。反而,大公司的面试就正常多了,也对,大公司已经通过简历筛选,把不入流的...
继续阅读 »

要判断一个程序员是水货是非常难的。反过来说,要面试出一个程序员的真实水平是比较困难的。

现在很多小公司面试,动不动就是手写代码。从框架问到具体的技术实现细节,结果错过了好多真正有能力的程序员。反而,大公司的面试就正常多了,也对,大公司已经通过简历筛选,把不入流的大学筛出去了。

实际上,我认为程序员面试应该只关注两方面的能力。代码能力和工程能力。二者有其一即可。

代码能力。这个不用多说,程序员招进去就是写代码的,所以很多公司会有上机或者直接让手写代码这一招,题目不用多难,就是基础的链表、数组、树就能解决的算法,给支笔能写个90%出来,招进去,这名同学便不会太差。所以各大公司都很欢迎ACM选手,这部分人受过严格的训练,再差也不会差到哪儿去。

工程能力。只要问清楚做了什么?为什么要这样做?还有没有更好的办法?这几个问题基本上就能判断出这个人的项目经历的真假。如果项目经历为真,对于项目实现有自己的思考,招进去也不会太差。如果用到了自己熟悉的框架,可以抓住细节再聊一下。

上述说的这两个能力其实都不太容易造假。反而,如果招安卓app开发,抓住相关的知识点,一顿猛问,太容易被培训班之流靠背题糊弄过去。也就是说,面试的时候要抓住一些编程需要的通用能力,通用技能扎实的同学,给点时间,上手其他的也很快。

下面,歪个题。说下我的两次失败的面试经历,两次面的都是头部互联网大厂。

先说A大厂。下文直接进入面试环节。

面试官:你对C++比较熟悉,对吧?

我:是的。

面试官:那请问子类与父类之间如何实现多态。

我:用一张表来实现。

面试官很疑惑的样子:什么表!?

我:这张表来保存函数的入口地址,子类如果重写了某个函数,那么同名函数的入口地址就用子类的函数地址来替换,这样。

面试官打断了我:你听说个虚函数吗?

我:听说过。

面试官:好了,我没有问题了。你回去等通知吧。

这位大厂的面试官铁定认为我是一个水货,连C++的基本概念都掌握不清楚,就是来浑水摸鱼的。实际上,按照我的理解,大厂面试官不会问这种C++的基本概念,应该问的是虚函数如何实现等比较深的背景,那段时间我刚好看完了C++之父写的《C++语言的设计和演化
》,凭着理解说了一通,但是面试官认为我在瞎说,妥妥的水货。实际上,我当时可能说成虚函数表就没有问题了。当然,我也没有等到通知,从酒店下楼出来喝了杯奶茶以后,就发现“面试未通过”。

再说下B大厂,该大厂我笔试获得了非常高的分数。下文直接讲面试环节。

面试官:讲一讲你这个项目中类是怎么划分的。

我:我用的是MFC,这个框架用了MVC模型。所以我对于需要展示处理效果的代码放在了xxxview类里面,讲计算相关的代码放到了xxxmodel类里面了。

面试官一头雾水的样子:也就是说用别人的框架,你就加点处理代码就行了。

我:差不多是这样。

然后,我又回去等通知了。实际上,这个项目完完全全是由我自己独立完成,并且前后重写了两遍,中间的思考和重构是非常值得说的。如果我当时的回答变一下,先讲一下项目的背景,具体需要实现的功能,功能之间如何解耦
,使用了xx设计模式来提供扩展和需求变化。按照这个思路讲一下去,肯定会得到一个比较好的面试评价。

上述两个经历,就是我第一、第二次面试大厂出的岔子。本质上,还是自己没明白别人在问什么,第一个问题以为别人问得很深,实际上很浅。第二个问题,以为别人随便问问,实际上是在判断这个项目是否造假。所以,看一下别人的面经还是很有用的,也不会白白浪费机会。

再后来,我靠上机满分顺利去了H厂,连续多次拿了绩效A。工作中也遇到了好些个水货程序员,有那种简历上SCI一作,入职后写个for循环用了一天的。也有那种一千行代码30+bug的,还有那种自己代码出bug,看着屏幕只会干瞪眼的。但是这些人的工资都比我高。

来源:blog.csdn.net/qq_66238169/article/details/124886138

收起阅读 »

社区纠纷不断:程序员何苦为难程序员

今年年初,我们报道“因为被多人侮辱大吼,Swift 之父正式退出 Swift 核心团队”。诸如此类的“语言暴力”、“网络暴力”事件在开源社区乃至整个 IT 社区屡见不鲜。多个技术社区,都出现过创始人、重要维护者、贡献者因为感觉“社区氛围糟糕”、“受到伤害”而宣...
继续阅读 »

今年年初,我们报道“因为被多人侮辱大吼,Swift 之父正式退出 Swift 核心团队”。

诸如此类的“语言暴力”、“网络暴力”事件在开源社区乃至整个 IT 社区屡见不鲜。多个技术社区,都出现过创始人、重要维护者、贡献者因为感觉“社区氛围糟糕”、“受到伤害”而宣布退出的现象。更有甚者,还有科技公司领导被爆出叫嚣着让 80 后退出 IT 圈。后者可粗略视为是该领导过于偏激,且是少数案例,先不做过多讨论。但是在开源圈,怎么就这样了呢?

为了回答这个问题,本文梳理了以往的一些社区纠纷事件,发现许多矛盾发生在社区实际的管理者/层,与社区贡献者、参与者之间,双方的不满大致也可以归总成几类原因。


众口难调

从大教堂的开发模式转向集市开发模式之后,技术社区存在的目的便是为了聚众人之力,让项目更好。在集市模式下,开源社区给了个人极大的自由度,所有贡献者都可以畅所欲言,发表自己的想法,为项目作出贡献。随之而来的问题,便是如何协调所有人的意见。

社区的管理模也在一定程度上决定了社区日后的争吵风险有多大。当下的开源社区管理主要分成四种模式。一是由社区主导,采用自由贡献模式,“共识”是其前提,功能开发和版本发布等重要决策以社区共识为准。二是公司主导,由公司资助社区及软件的发展,相对来说,公司会拥有更多的实权。三是 BDFL 仁慈的独裁者模式,这是在社区模式的基础之上,社区中有一个“独裁者”的角色存在,他对一些重要决策有最终决定权,而非依赖社区共识。四是精英治理,在社区中表现突出、贡献最大的人被任命为管理团队,决策基于投票确定。

我们常见的纠纷事件,往往可以归总为“掌权者”和“贡献者”之争,这种争议更容易出现在“仁慈的独裁者”模式的开源社区中。其他三种模式中,许多纠纷的出现也是因为实际管理团队未扮演好自己的角色。

掌权者的不满

闻名世界的大型开源项目往往是某个“天才程序员”的作品,早期的开源社区一般都是“仁慈的独裁者”模式,独裁者饱受敬仰,比如 Linux 社区的 Linus、Ubuntu 社区、Python 社区。然而,天才似乎更乐于单兵作战。

  • 对贡献不满

暴躁大佬 Linus 在评价那些没达到他个人标准的代码方面非常毒舌。曾有网友用了此前 Linus 在邮件列表中公开的一段回复,直指 Linus 在人际沟通中态度恶劣:“这也算是一个 BUG?你已经成为内核维护者多长时间了?还没有学会内核维护的第一条规则?我再也不想收到这种明显的垃圾,像白痴一样的提交…… ”

对于一直看不爽的 Intel,Linus 对其提交的代码也是口吐芬芳。2018 年初,为了修补 Spectre 漏洞,Intel 工程师提供了一个间接分支限制推测(indirect branch restricted speculation, IBRS)功能的补丁。Linus 当时就在邮件列表中公开指出 IBRS 会造成系统性能大幅降低,直言该补丁“就是彻彻底底的垃圾”。


不仅仅是 Linus,在崇尚“精英主义”的 BSD 社区,也存在明显的“鄙视链”。BSD 的第一版撰写者 Bill Joy 不愿意相信庞大的志愿贡献者者们,他曾说:“大多数人都是糟糕的程序员,让很多人盯着代码不会真正发现错误。真正的错误是由几个非常聪明的人发现的。大多数人看代码不会看到任何东西......不能期望成千上万的人做出贡献并都达到高标准。”

这种看法一直存在于 BSD 之后的发展中,FreeBSD 深度参与者 Marshall Kirk McKusick 就曾表示,90% 的 committers 所贡献的代码都不能用,还剩下的一小部分也需要被打磨。

  • 遭遇信任危机

在 Python 社区,很多年内,Python 之父 Guido van Rossum 都是最有威信的那个人,他也被社区授予“终身仁慈的独裁者”的称谓。但在 2018 年,当 Python 社区探讨改进提案时,Guido 发现“现在有这么多人鄙视我的决定”。

因此,他不想再为 PEP(Python 改进提案)[ PEP 572 ] (https://www.python.org/dev/peps/pep-0572/)争取什么,并决定自己将逐步脱离决策层,不再领导 Python 的发展,休息一段时间后将作为普通的核心开发者参与社区。


有时,“仁慈的独裁者”也会因为“不作为”被指责。Ubuntu 创始人 Mark Shuttleworth 在社区中被戏称为“自封的仁慈独裁者”。事实上,Ubuntu 社区早期也确实是“仁慈的独裁者”管理模式。

然而,在 Ubuntu 社区蓬勃发展,各项业务步入正轨之际,Mark Shuttleworth 本人的工作逐渐与开发者拉开距离,Jono Bacon 等一些早期在 Ubuntu 社区中颇具名望的核心成员相继离开,Mark Shuttleworth 也很少在社区活跃。逐渐,有一些资深的 Ubuntu 开发者认为社区正面临“群龙无首”的尴尬局面,指责沙特尔沃思没有尽到“BDFL”的责任。一位前 Ubuntu 开发人员在 Ubuntu 社区中留言指责 Mark Shuttleworth 作为 BDFL “放弃了社区,对社区治理的崩溃保持沉默”,令人失望。

Mark Shuttleworth 面对职责也欣然接受,承认自己在这些方面确实做得不够好,并表达了“挫败感”。

  • 没有回馈

开源项目最容易让人不满的点还当属“白嫖”,许多开源项目都是靠作者用爱发电,社区的汲取大于回馈,从而导致软件作者身心俱疲。

前段时间轰轰烈烈的 Apache Log4j2 高危漏洞事件,攻击者可以利用其 JNDI 注入漏洞远程执行代码,影响了众多项目和公司。此事也让 Log4j2 的作者受到关注与批评,Log4j2 的维护者之一 @Volkan Yazıcı 在推特上吐槽:Log4j2 维护者只有几个人,他们无偿、自愿地工作,没有人发工资,也没人提交代码修复问题,出了问题还要被一堆人在仓库里留言痛骂。

此前,PhantomJS 的核心开发者之一 Vitaly Slobodin 宣布辞任 maintainer ,不再维护项目,原因也是一个人发电太累:“我看不到 PhantomJS 的未来,作为一个单独的开发者去开发 PhantomJS 2 和 2.5 ,简直就像是一个血腥的地狱。即便是最近发布的 2.5 Beta 版本拥有全新、亮眼的 QtWebKit ,但我依然无法做到真正的支持 3 个平台。我们没有得到其他力量的支持!”

贡献者的不满

当社区随着项目生命周期的发展逐渐发生变化时,流程和规定上的改变不可避免,贡献者间交流的氛围也在不断变化中。贡献者对社区的不满往往是从社区发生变化开始……

  • 对项目或社区不再看好

一位 Debian 的包维护者 Stapelberg 在 2019 年写了篇长文宣布自己要退出 Debian 的开发流程,逐渐减少参与 Debian 的维护和相关活动。主要原因便在于,他发现 Debian 整个开发评估流程非常迟钝。

举例来说,补丁的评估没有截止日期,有时候他会收到通知说几年前递交的补丁现在合并了。Debian 的一些维护者出于个人喜好拒绝合作,维护者给予的个人自由度太高对 Debian 构成了严重影响。

在他对 Debian 的开发流程沮丧程度超过阈值之后,他便宣布退出,写了文章,希望能激励 Debian 作出改变。

在 LLVM 社区,2018 年一位名叫 Rafael Avila de Espindola 的资深开发者(LLVM 编译器贡献排名第五)发邮件宣布决定与该项目分道扬镳。 原因在于近几年的感受发生了变化,LLVM 日益庞大且变化缓慢,他也不赞成 LLVM 最近引入的社区行为规范。真正促使他做决定的是 LLVM 与一个公开根据性别和血统进行歧视的组织 Outreachy 进行合作,这让他感到非常不满。

除此之外,一些由公司主导的开源项目也会因为改变发展战略招致不满。

Qt 公司从 2021 年 1 月开始,将 Qt 5.15 作为仅供商业化的 LTS,现有的 Qt 5.15 分支将公开可见,但不会看到任何新补丁,只有付费账户才可以使用长期支持版本的 Qt 5.15 。

此举引起了社区的强烈不满,基于 Qt 开发的 KDE 社区的担忧。有用户在 Qt 官方公告下留言讽刺道:“所以,基本上您是在告诉所有忠实的开源用户,现在他们将仅被视为商业客户的 beta 测试者,并且作为奖励,他们将只能下载非 LTS 版本。你们真是太亲切了!”一位长期的 Qt 贡献者,来自英特尔公司的开发者 Thiago Macieira 表示,至少对于他在 Qt 中处理过的代码,他不会再参与修复、评论和审查后端错误报告。

  • 反独裁

与每个社区都有一个人或者几个人的实际管理团队向对应的是,在管理不当时,贡献者会起身反抗“管理”。

几个月前,Rust 审核团队 (Moderation Team) 昨日公告称,他们已集体辞职且即刻生效。团队成员 Andrew Gallant 表示此举是为了抗议 Rust 核心团队 (Core Team) 不对除自己以外的任何人负责。

Rust 的管理者分为很多个团队,比如核心团队、审核团队、发行团队、库管理团队等等...其中,Rust 核心团队的权限最大,而且其他团队无法影响他们。Andrew Gallant 在公告中写道,由于核心团队在组织结构层面的不负责任,他们一直无法按照社区对审核团队的期望和他们自己坚持的标准来执行 Rust 行为准则。对于在这种情况下选择离开,他们表达了对大家的歉意。但从治理 Rust 的角度来看,他们别无选择。因此 Rust 审核团队认为除了辞职和发表这份声明之外,已经没有其他办法了。


如何让众人满意?

矛盾激化的后果只有一个:成员慢慢离开。如果社区和项目不做出改变,则很有可能导致项目走向衰落。既然长时间充斥着争吵与不满的社区难以持久,那么,该如何构建一个让更多人愉快参与的社区?

首先,无论何种治理模式下的社区,个人文明表达观点,遵守社区行为准则都是减少冲突的必要条件。但是,个人行为是非常不稳定的因素,就像 Linus 曾说要改改自己的暴脾气,却每每被各种言论刺激,重回暴躁大佬。

其次,便是通过治理框架有效地管理社区,多权分立、避免独裁,并且成员间相互约束,基于“共识”发展。具体来说便是有一份好的“手册(原则、准则等)”,以及一个让人信服、能公证管理的团队。

比如在 Apache 软件基金会中,便采用精英治理的模式。Apache Way 中对于决策、监督、执行等各方面都有明确规定:包括结构扁平,无论职位如何,各个角色间是平等的,投票权重相同,不允许有仁慈的独裁者出现;单个项目由自选的活跃志愿者团队监督,倾向在达成共识的前提下决定项目发展,不能完全建立共识则用投票等方式做出决策;整个基金会的治理模型基于信任和委托监督……

在开放原子开源软件基金会中,项目的毕业标准考核中也有类似的关于社区需符合“贤能治理”的规定:

OA-CO-40

【英】The community strives to be meritocratic and over time aims to give more rights and responsibilities to contributors who add value to the project.

【中】社区要符合贤能治理的精神,随着时间的推移,为项目增值的贡献者会被赋予更多的权利和责任

TOC 成员徐亮曾介绍,贤能治理这四个字是经过很多轮讨论确定下来的。在英文语境中,这个词是 meritocracy,并不完全是一个褒义词,“我们并不觉得用 meritocracy 形容社区是完全正确的事情,但是我们又觉得在开源社区中,所谓的能者上氛围是比较积极向上的,是社区能够健康运转的主要因素。”

在许多项目中,一方面大家是贡献者,另一方面,每个人提交的功能越重要,或者对项目本身做出了更有意义的贡献,就更有可能被现任维护人员选中去承担更重要的角色。通过这种方式达成的精英治理或是贤能治理,往往是希望能够让项目可以自己成长,更好地走下去。

Ubuntu 的创始人 Mark Shuttleworth 在遭到信任危机之后,便着手参与将 Ubuntu 转向经营治理的模式。目前,Ubuntu 社区也重新组建了由 3 名核心成员组成的管理团队,分别是 Ubuntu 社区代表 Monica Ayhens-Madon,Ubuntu 开发者关系负责人 Rhys Davies,以及临时社区经理 Ken VanDine。Ken 还将负责继续为 Ubuntu 社区寻找一位合适的社区总监。

最后,借用一个当下共识:开源比以往任何时候都更加重要。也因此,维护一个好的社区氛围,正尤为重要。

来源:OSC开源社区

收起阅读 »

面试题 | 等待多个并发结果有哪几种方法?

引子 App 开发中,等待多个异步结果的场景很多见, 比如并发地在后台执行若干个运算,待所有运算执行完毕后归总结果。 比如并发地请求若干个接口,待所有结果返回后刷新界面。 比如统计相册页并发加载 20 张图片的耗时。 其实把若干异步任务串行化是最简单的解决办法...
继续阅读 »

引子


App 开发中,等待多个异步结果的场景很多见,


比如并发地在后台执行若干个运算,待所有运算执行完毕后归总结果。


比如并发地请求若干个接口,待所有结果返回后刷新界面。


比如统计相册页并发加载 20 张图片的耗时。


其实把若干异步任务串行化是最简单的解决办法,即前一个异步任务执行完毕后再执行下一个。但这样就无法利用多核性能,执行时间被拉长,此时的执行总时长 = 所有任务执行时长的和。


若允许任务并发,则执行总时长 = 执行时间最长任务的耗时。时间性能得以优化,但随之而来的一个复杂度是:“如何等待多个异步结果”。


本文会介绍几种解决方案,并将它们运用到不同的业务场景,比对一下哪个方案适用于哪个场景。


等待并发网络请求


布尔值


假设有如下两个网络请求:


// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) { ... }
})
}
// 拉取广告
fun fetchAd() {
newsApi.fetchAd().enqueue(object : Callback<List<Ad>> {
override fun onFailure(call: Call<List<Ad>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<Ad>>, response: Response<List<Ad>>) { ... }
})
}

广告需要按一定规则插入到新闻列表中。


最简单的做法是,先请求新闻,待其返回后再请求广告。显然这会增加用户等待时间。而且会写出这样的代码:


// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<News> {
override fun onFailure(call: Call<News>, t: Throwable) { ... }
override fun onResponse(call: Call<News>, response: Response<News>) {
// 拉取广告
newsApi.fetchAd().enqueue(object : Callback<Ad> {
override fun onFailure(call: Call<Ad>, t: Throwable) { ... }
override fun onResponse(call: Call<Ad>, response: Response<Ad>) { ... }
})
}
})
}

嵌套回调,若再加一个接口,回调层次就会再加一层,不能忍。
用户和程序员的体验都不好,得想办法解决。


第一个想到的方案是布尔值:


var isNewsDone = false
var isAdDone = false
var news = emptyList()
var ads = emptyList()
// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
isNewsDone = true
tryRefresh(news, ad)
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
isNewsDone = true
news = response.body().result
tryRefresh(news, ad)
}
})
}
// 拉取广告
fun fetchAd() {
newsApi.fetchAd().enqueue(object : Callback<List<Ad>> {
override fun onFailure(call: Call<List<Ad>>, t: Throwable) {
isAdDone = true
tryRefresh(news, ad)
}
override fun onResponse(call: Call<List<Ad>>, response: Response<List<Ad>>) {
isAdDone = true
ads = response.body().result
tryRefresh(news, ad)
}
})
}
// 尝试刷新界面(只有当两个请求都返回时才刷新)
fun tryRefresh(news: List<News>, ads: List<Ad>) {
if(isNewsDone && isAdDone){ //刷新界面 }
}

设置两个布尔值分别对应两个请求是否返回,并且在每个请求返回时检测两个布尔值,若都为 true 则进行刷新界面。


网络库通常会将请求成功的回调抛到主线程执行,所以这里没有线程安全问题。但如果不是网络请求,而是后台任务,此时需要将布尔值声明为volatile保证其可见性,关于 volatile 更详细的解释可以点击面试题 | 徒手写一个非阻塞线程安全队列 ConcurrentLinkedQueue?


这个方案能解决问题,但只适用于并发请求数量很少的请求,因为每个请求都要声明一个布尔值。而且每增加一个请求都要修改其余请求的代码,可维护性差。


CountdownLatch


更好的方案是CountDownLatch,它是java.util.concurrent包下的一个类,用来等待多个异步结果,用法如下:


val countdownLatch = CountDownLatch(2)//初始化,等待2个异步结果
var news = emptyList()
var ads = emptyList()
// 拉取新闻
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
countdownLatch.countDown()
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
news = response.body().result
countdownLatch.countDown()
}
})
}
// 拉取广告
fun fetchAd() {
newsApi.fetchAd().enqueue(object : Callback<List<Ad>> {
override fun onFailure(call: Call<List<Ad>>, t: Throwable) {
countdownLatch.countDown()
}
override fun onResponse(call: Call<List<Ad>>, response: Response<List<Ad>>) {
ads = response.body().result
countdownLatch.countDown()
}
})
}
// countdownLatch 在新线程中等待
thread {
countdownLatch.await() // 阻塞线程等待两个请求返回
liveData.postValue() // 抛数据到主线程刷刷新界面
}.start()

CountDownLatch 在构造时需传入一个数量,它的语义可以理解为一个计数器。countDown() 将计数器减一,而 await() 会阻塞当前线程直到计数器为 0 才被唤醒。


该计数器是一个 int 值,可能被多线程访问,为了保证线程安全,它被声明为 volatile,并且 countDown() 通过 CAS + 自旋的方式将其减一。


关于 CAS 的介绍可以点击面试题 | 徒手写一个非阻塞线程安全队列 ConcurrentLinkedQueue?


若新增一个接口,只需要将计数器的值加一,并在新接口返回时调用 countDown() 即可,可维护性陡增。


协程


Kotlin 是降低复杂度的大师,它对于这个问题的解决方案可以让代码看上去更简单。


在 Kotlin 的世界里异步操作应该被定义为suspend方法,retrofit 就支持这样的操作,比如:


interface NewsApi {
@GET("/xxx")
suspend fun fetchNews(): List<News>
@GET("/xxx")
suspend fun fetchAd(): List<Ad>
}

然后在协程中使用async启动异步任务:


scope.launch {
// 并发地请求网络
val newsDefered = async { fetchNews() }
val adDefered = async { fetchAd() }
// 等待两个网络请求返回
val news = newsDefered.await()
val ads = adDefered.await()
// 刷新界面
refreshUi(news, ads)
}

不管是写起来还是读起来,体验都非常好。因为协程把回调干掉了,逻辑不会跳来跳去。


其中的async()是 CoroutineScope 的扩展方法:


// 启动协程,并返回协程执行结果
public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
...
}

async() 和 launch() 唯一的不同是它的返回值是Defered,用于描述协程体执行的结果:


public interface Deferred<out T> : Job {
// 挂起方法: 等待值的计算,但不会阻塞当前线程,计算完成后恢复当前协程执行
public suspend fun await(): T
}

调用async()启动子协程不会挂起外层协程,而是立即返回一个Deferred对象,直到调用Deferred.await()协程的执行才会被挂起。当协程在多个Deferred对象上被挂起时,只有当它们都恢复后,协程才继续执行。这样就实现了“等待多个并行的异步结果”。


但这样写会问题:当广告拉取抛出异常时,新闻拉取也会被取消。


这是协程的一个默认设定,叫结构化并发,即并发是有结构性的。


Java 中线程的并发是没有结构的,所以做如下事情很困难:



  1. 结束一个线程时,如何一并结束它所有的子线程?

  2. 当某个子线程抛出异常时,如何结束和它同一层级的兄弟线程?

  3. 父线程如何等待所有子线程结束之后才结束?


之所以会很困难,是因为 Java 中的线程是没有级联关系的。而 Kotlin 通过协程域 CoroutineScope 以及协程上下文 CoroutineContext 实现级联关系。


在协程中启动的子协程会继承父协程的协程上下文,除了其中的 Job,一个新的 Job 会被创建并归属于父协程的子 Job。通过这套机制,协程和子协程之间有了级联关系,就能实现结构化并发。(以后会就结构化并发写一个系列,敬请期待~)


关于 CoroutineContext 内部结构的详细剖析可以点击Kotlin 协程 | CoroutineContext 为什么要设计成 indexed set?


但有些业务场景不需要子任务之间相互关联,比如当前场景,广告加载失败不应该影响新闻的拉取,大不了不展示广告。为此 kotlin 提供了supervisorScope


scope.launch {
supervisorScope {
// 并发地请求网络
val newsDefered = async { fetchNews() }
val adDefered = async { fetchAd() }
// 等待两个网络请求返回
val news = newsDefered.await()
val ads = adDefered.await()
// 刷新界面
refreshUi(news, ads)
}
}

supervisorScope 新建一个协程域继承父亲的协程上下文,但会将其中的 Job 重写为SupervisorJob,它的特点就是孩子的失败不会影响父亲,也不会影响兄弟。


现在广告和新闻加载互不影响,各自抛异常都不会影响对方。但就目前的业务场景来说,理想情况是这样的:“广告加载失败不应该影响新闻的加载。但新闻加载失败应该取消广告的加载(因为此时广告也没有展示的机会)”


稍改动下代码:


scope.launch {
supervisorScope {
// 并发地请求网络
val adDefered = async { fetchAd() }
val newsDefered = async { fetchNews() }
// 当新闻请求抛异常时,取消广告请求
newsDefered.invokeOnCompletion { throwable ->
throwable?.let { adDefered.cancel() }
}
// 等待新闻
val news = try {
newsDefered.await()
} catch (e: Exception) {
emptyList()
}
// 等待广告
val ads = try {
adDefered.await()
} catch (e: Exception) {
emptyList()
}
// 刷新界面
refreshUi(news, ads)
}
}

invokeOnCompletion()相当于注册了一个回调,在异步任务结束时调用,不管是正常结束还是因异常而结束。在该回调中判断,若新闻因异常而结束则取消广告任务。


因为新闻和广告任务都可能抛出异常,且 async 启动的异步任务是在调用 await() 时才会抛出异常,所以它应该包裹在 try-catch 中。Kotlin 中的 try-catch 是一个表达式,即是有返回值的。这个特性让正常和异常情况的值聚合在一个表达式中。


若不使用 try-catch,程序也不会奔溃,因为 supervisorScope 中异常是不会向上传播的,即子协程的异常不会影响兄弟和父亲。但这样就少了异常情况的处理。


若现有代码都是 Callback 形式的,还能不能享受协程的简洁?


能!Kotlin 提供了suspendCoroutine(),专门用于将回调风格的代码转换成 suspend 方法,以拉取新闻为例:


// Callback 形式
fun fetchNews() {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) { ... }
})
}

// suspend 形式
suspend fun fetchNews() = suspendCoroutine<List<News>> { continuation ->
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
continuation.resumeWithException(t)
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
continuation.resume(response.body().result)
}
})
}

其中的Continuation剩余的计算,从形式上看,它就是一个回调:


public interface Continuation<in T> {
public val context: CoroutineContext
public fun resumeWith(result: Result<T>) // 开始剩余的计算
}

每个 suspend 方法被编译成 java 之后,都会在原有方法参数表最后添加一个 Continuation 参数,用于表达这个挂起点之后“剩余的计算”,举个例子:


scope.launch {
fun1() // 普通方法
suspendFun1() // 挂起方法
// --------------------------
fun2() // 普通方法
suspendFun2() // 挂起方法
// --------------------------
}

整个协程体中有四个方法,其中两个是挂起方法,每个挂起方法都是一道水平的分割线,分割线下方的代码就是当前执行点相对于整个协程体剩余的计算,这“剩余的计算”会被包装成 Continuation 并作为参数传入挂起方法。所以上述代码翻译成 java 就类似于:


scope.launch {
fun1()
suspendFun1(new Continuation() {
@override
public void resumeWith(Result<T> result) {
fun2()
suspendFun2(new Continuation() {
@override
public void resumeWith(Result<T> result) {

}
})
}
})
}

所以挂起方法无异于 java 中带回调的方法,它自然不会阻塞当前线程,它只是把协程体中剩下的代码当成回调,该回调会在将来某个时间点被执行。通过这种方式,挂起方法主动让出了 cpu 执行权。


题外话


从业务上讲,将 Callback 方法改造成挂起式可以降低业务复杂度。举个例子:用户可以通过若干动作触发拉取新闻,比如首次进入新闻页、下拉刷新新闻页、上拉加载更多新闻、切换分区。新闻页有一个埋点,当首次展示某分区时,上报此时的新闻。


若没有 suspend 方法,代码应该这样写:


// NewsViewModel.kt
fun fetchNews(isFirstLoad: Boolean, isChangeType: Boolean) {
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) { ... }
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
// 将新闻抛给界面刷新
newsLiveData.value = response.body.result
// 只有当首次加载或切换分区时时才埋点
if(isFirstLoad || isChangeType) {
reportNews(response.body.result)
}
}
})
}
// NewsActivity.kt
// 分区切换监听
tab.setOnTabChangeListener { index ->
newsViewModel.fetchNews(false, true)
}
// 首次加载新闻
fun init() {
newsViewModel.fetchNews(true, false)
}
// 下拉刷新
refreshLayout.setOnRefreshListener {
newsViewModel.fetchNews(false, false)
}
// 上拉加载更多
refreshLayout.setOnLoadMoreListener {
newsViewModel.fetchNews(false, false)
}

因为埋点需要带上新闻列表,所以必须在请求返回之后上报。不同业务场景的拉取接口是同一个,所以只能在统一的 onResponse() 中分类讨论,分类讨论依赖于标记位,不得不为 fetchNews() 添加两个参数。


如果将拉取新闻的接口改成 suspend 方式就能化解这类复杂度:


// NewsViewModel.kt
suspend fun fetchNews() = suspendCoroutine<List<News>> { continuation ->
newsApi.fetchNews().enqueue(object : Callback<List<News>> {
override fun onFailure(call: Call<List<News>>, t: Throwable) {
continuation.resumeWithException(t)
}
override fun onResponse(call: Call<List<News>>, response: Response<List<News>>) {
val news = response.body.result
newsLiveData.value = news
continuation.resume(news)
}
})
}

// NewsActivity.kt
fun initNews() {
scope.launch {
val news = viewModel.fetchNews()
reportNews(news)
}
}

fun changeNewsType() {
scope.launch {
val news = viewModel.fetchNews()
reportNews(news)
}
}

fun loadMoreNews() {
scope.launch { viewModel.fetchNews() }
}

fun refreshNews() {
scope.launch { viewModel.fetchNews() }
}

newsViewModel.newsLiveData.observe {news ->
showNews(news)
}

所有界面的刷新还是走 LiveData,但拉取新闻的方法被改造成挂起之后,也会将新闻列表用类似同步的方式返回,所以可以在相关业务点进行单独埋点。


统计相册加载图片耗时


再通过一个更高并发数的场景比对下各个方案代码上的差异,场景如下:


1657970793022(1).gif


测试并发加载 20 张网络图片的总耗时。该场景下已经无法使用布尔值,因为并发数太多。


CountdownLatch


var start = SystemClock.elapsedRealtime()
var imageUrls = listOf(...)
val countdownLatch = CountDownLatch(imageUrls.size)
// 另起线程等待 CountDownLatch 并输出耗时
scope.launch(Dispatchers.IO) {
countdownLatch.await()
Log.d( "test", "time-consume=${SystemClock.elapsedRealtime() - start}" )
}

// 遍历 20 张图片 url
imageUrls.forEach { img ->
ImageView {// 动态构建 ImageView
layout_width = 100
layout_height = 100
Glide.with(this@GlideActivity)
.load(img)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
countdownLatch.countDown() // 加载完一张
return false
}

override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
countdownLatch.countDown() // 加载完一张
return false
}
})
.into(this)
}
}

协程


var imageUrls = listOf(...)
scope.launch {
val start = SystemClock.elapsedRealtime()
// 将每个 url 都变换为一个 Defered
val defers = imageUrls.map { img ->
val imageView = ImageView {
layout_width = 100
layout_height = 100
}
async { imageView.loadImage(img) }
}
defers.awaitAll()//等待所有的异步任务结束
Log.d( "test", "time-consume=${SystemClock.elapsedRealtime() - start}" )
}
// 将 Callback 方式的加载转换为挂起方式
private suspend fun ImageView.loadImage(img: String) = suspendCoroutine<String> { continuation ->
Glide.with(this@GlideActivity)
.load(img)
.listener(object : RequestListener<Drawable> {
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
continuation.resume("")
return false
}

override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: DataSource?,
isFirstResource: Boolean
): Boolean {
continuation.resume("")
return false
}
})
.into(this)
}

你更喜欢哪种方式?


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

Android 实现App应用退到后台显示通知

需求背景 刚开始接到这个需求时,第一时间想到的是做成跟银行类app一样用户退到主页之后,需要在通知栏显示“XXX在后台运行”,并且该通知不能被清除,只有用户重新进入app再消失。然后就想到了一个方案前台服务(foregroundService) 来实现,于是撸...
继续阅读 »
需求背景

刚开始接到这个需求时,第一时间想到的是做成跟银行类app一样用户退到主页之后,需要在通知栏显示“XXX在后台运行”,并且该通知不能被清除,只有用户重新进入app再消失。然后就想到了一个方案前台服务(foregroundService) 来实现,于是撸起袖子就是干。



  • 1、创建一个ForegroundService继承Service

  • 2、重写onCreate等一系列方法

  • 3、创建通知,根据不同版本来开启服务



根据不同版本开启服务



  • 4、监听Application的生命周期,在onActivityStopped中显示前台服务,在onActivityResumed中取消前台服务


显示前台服务


关闭前台服务


搞定,运行代码看看效果。。。


哦豁


完全不对,遇到的问题:



  • 1、并不是所有onActivityStopped执行都是应用被切换至后台---此处百度“如何监听应用被切换至后台”

  • 2、onActivityResumed的时候stopService如果操作快一下到后台一下到前台会收到一大堆的崩溃信息



崩溃信息


遇到问题那咱就解决问题呗,开干~~



  • 1、这个问题倒是很好解决,百度上一大把,添加一个refCount变量,在onActivityStarted方法中++,在onActivityStopped方法中--,然后在onActivityStopped中判断当refCount等于0时表示应用退到后台


变量++


变量--



  • 2、这个问题崩溃的信息意思就是调用了startForegroundService之后没有调用 Service.startForeground()方法,造成这个问题的原因就是短时间内重复进入退出应用,前台服务来不及start就已经被stop
    那怎么办呢?
    第一时间想到的是延迟几秒再stopService,写完运行结果还是一大堆崩溃0.0


于是:于是:发自内心的问自己,为什么要用前台服务?为什么要用前台服务?有没有其他方案呢?


答案肯定是有的,为什么一定要用前台服务呢?直接用通知不行么,好,就用通知


于是,就用一个通知管理类ForegroundPushManager来处理通知的显示和关闭


关闭通知


显示通知


这样就完成了应用退到后台显示通知的功能了。


最后效果


最后遇到的第二个问题如果有好的方案解决的话请大家踊跃指点,谢谢!!


Demo地址:github.com/ling9400/Fo…


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

七夕节马上要到了,前端工程师,后端工程师,算法工程师都怎么哄女朋友开心?

这篇文章的前提是,你得有个女朋友,没有就先收藏着吧!七夕节的来源是梁山伯与祝英台的美丽传说,化成了一对蝴蝶~ 美丽的神话!虽然现在一般是过214的情人节了,但是不得不说,古老的传统的文化遗产,还是要继承啊~在互联网公司中,主要的程序员品种包括:前端工程师,后端...
继续阅读 »

这篇文章的前提是,你得有个女朋友,没有就先收藏着吧!

七夕节的来源是梁山伯与祝英台的美丽传说,化成了一对蝴蝶~ 美丽的神话!虽然现在一般是过214的情人节了,但是不得不说,古老的传统的文化遗产,还是要继承啊~

在互联网公司中,主要的程序员品种包括:前端工程师,后端工程师,算法工程师。

对于具体的职业职能划分还不是很清楚的,我们简单的介绍一下不同程序员岗位的职责:

前端程序员:绘制UI界面,与设计和产品经理进行需求的对接,绘制特定的前端界面推向用户

后端程序员:接收前端json字符串,与数据库对接,将json推向前端进行显示

算法工程师:进行特定的规则映射,优化函数的算法模型,改进提高映射准确率。

七夕节到了,怎么结合自身的的专业技能,哄女朋友开心呢?

前端工程师:我先来,画个动态的晚霞页面!

1.定义样式风格:

.star {
 width: 2px;
 height: 2px;
 background: #f7f7b6;
 position: absolute;
 left: 0;
 top: 0;
 backface-visibility: hidden;
}

2.定义动画特性

@keyframes rotate {
 0% {
   transform: perspective(400px) rotateZ(20deg) rotateX(-40deg) rotateY(0);
}

 100% {
   transform: perspective(400px) rotateZ(20deg) rotateX(-40deg) rotateY(-360deg);
}
}

3.定义星空样式数据

export default {
 data() {
   return {
     starsCount: 800, //星星数量
     distance: 900, //间距
  }
}
}

4.定义星星运行速度与规则:

starNodes.forEach((item) => {
     let speed = 0.2 + Math.random() * 1;
     let thisDistance = this.distance + Math.random() * 300;
     item.style.transformOrigin = `0 0 ${thisDistance}px`;
     item.style.transform =
         `
       translate3d(0,0,-${thisDistance}px)
       rotateY(${Math.random() * 360}deg)
       rotateX(${Math.random() * -50}deg)
       scale(${speed},${speed})`;
  });

前端预览效果图:


后端工程师看后,先点了点头,然后表示不服,画页面太肤浅了,我开发一个接口,定时在女朋友生日的时候发送祝福邮件吧!

1.导入pom.xml 文件

        <!-- mail邮件服务启动器 -->
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-mail</artifactId>
       </dependency>

2.application-dev.properties内部增加配置链接

#QQ\u90AE\u7BB1\u90AE\u4EF6\u53D1\u9001\u670D\u52A1\u914D\u7F6E
spring.mail.host=smtp.qq.com
spring.mail.port=587

## qq邮箱
spring.mail.username=#yourname#@qq.com
## 这里填邮箱的授权码
spring.mail.password=#yourpassword#

3.配置邮件发送工具类 MailUtils.java

@Component
public class MailUtils {
   @Autowired
   private JavaMailSenderImpl mailSender;
   
   @Value("${spring.mail.username}")
   private String mailfrom;

   // 发送简单邮件
   public void sendSimpleEmail(String mailto, String title, String content) {
       // 定制邮件发送内容
       SimpleMailMessage message = new SimpleMailMessage();
       message.setFrom(mailfrom);
       message.setTo(mailto);
       message.setSubject(title);
       message.setText(content);
       // 发送邮件
       mailSender.send(message);
  }
}

4.测试使用定时注解进行注释

@Component
class DemoApplicationTests {

   @Autowired
   private MailUtils mailUtils;

   /**
    * 定时邮件发送任务,每月1日中午12点整发送邮件
    */
   @Scheduled(cron = "0 0 12 1 * ?")
   void sendmail(){
       // 定制邮件内容
       StringBuffer content = new StringBuffer();
       content.append("HelloWorld");
       //分别是接收者邮箱,标题,内容
       mailUtils.sendSimpleEmail("123456789@qq.com","自定义标题",content.toString());
  }
}

@scheduled注解 使用方法: cron:秒,分,时,天,月,年,* 号表示 所有的时间均匹配

5.工程进行打包,部署在服务器的容器中运行即可。

算法工程师,又开发接口,又画页面,我就训练一个自动写诗机器人把!

1.定义神经网络RNN结构

def neural_network(model = 'gru', rnn_size = 128, num_layers = 2):
   cell = tf.contrib.rnn.BasicRNNCell(rnn_size, state_is_tuple = True)
   cell = tf.contrib.rnn.MultiRNNCell([cell] * num_layers, state_is_tuple = True)
   initial_state = cell.zero_state(batch_size, tf.float32)
   with tf.variable_scope('rnnlm'):
       softmax_w = tf.get_variable("softmax_w", [rnn_size, len(words)])
       softmax_b = tf.get_variable("softmax_b", [len(words)])
       embedding = tf.get_variable("embedding", [len(words), rnn_size])
       inputs = tf.nn.embedding_lookup(embedding, input_data)
   outputs, last_state = tf.nn.dynamic_rnn(cell, inputs, initial_state = initial_state, scope = 'rnnlm')
   output = tf.reshape(outputs, [-1, rnn_size])
   logits = tf.matmul(output, softmax_w) + softmax_b
   probs = tf.nn.softmax(logits)
   return logits, last_state, probs, cell, initial_state

2.定义模型训练方法:

def train_neural_network():
   logits, last_state, _, _, _ = neural_network()
   targets = tf.reshape(output_targets, [-1])
   loss = tf.contrib.legacy_seq2seq.sequence_loss_by_example([logits], [targets], \
      [tf.ones_like(targets, dtype = tf.float32)], len(words))
   cost = tf.reduce_mean(loss)
   learning_rate = tf.Variable(0.0, trainable = False)
   tvars = tf.trainable_variables()
   grads, _ = tf.clip_by_global_norm(tf.gradients(cost, tvars), 5)
   #optimizer = tf.train.GradientDescentOptimizer(learning_rate)
   optimizer = tf.train.AdamOptimizer(learning_rate)
   train_op = optimizer.apply_gradients(zip(grads, tvars))

   Session_config = tf.ConfigProto(allow_soft_placement = True)
   Session_config.gpu_options.allow_growth = True

   trainds = DataSet(len(poetrys_vector))

   with tf.Session(config = Session_config) as sess:
       sess.run(tf.global_variables_initializer())

       saver = tf.train.Saver(tf.global_variables())
       last_epoch = load_model(sess, saver, 'model/')

       for epoch in range(last_epoch + 1, 100):
           sess.run(tf.assign(learning_rate, 0.002 * (0.97 ** epoch)))
           #sess.run(tf.assign(learning_rate, 0.01))

           all_loss = 0.0
           for batche in range(n_chunk):
               x,y = trainds.next_batch(batch_size)
               train_loss, _, _ = sess.run([cost, last_state, train_op], feed_dict={input_data: x, output_targets: y})

               all_loss = all_loss + train_loss

               if batche % 50 == 1:
                   print(epoch, batche, 0.002 * (0.97 ** epoch),train_loss)

           saver.save(sess, 'model/poetry.module', global_step = epoch)
           print (epoch,' Loss: ', all_loss * 1.0 / n_chunk)

3.数据集预处理

poetry_file ='data/poetry.txt'
# 诗集
poetrys = []
with open(poetry_file, "r", encoding = 'utf-8') as f:
   for line in f:
       try:
           #line = line.decode('UTF-8')
           line = line.strip(u'\n')
           title, content = line.strip(u' ').split(u':')
           content = content.replace(u' ',u'')
           if u'_' in content or u'(' in content or u'(' in content or u'《' in content or u'[' in content:
               continue
           if len(content) < 5 or len(content) > 79:
               continue
           content = u'[' + content + u']'
           poetrys.append(content)
       except Exception as e:
           pass

poetry.txt文件中存放这唐诗的数据集,用来训练模型

4.测试一下训练后的模型效果:

藏头诗创作:“七夕快乐”

模型运算的结果


哈哈哈,各种节日都是程序员的表(zhuang)演(bi) 时间,不过这些都是锦上添花,只有实实在在,真心,才会天长地久啊~

提前祝各位情侣七夕节快乐!

作者:千与编程
来源:juejin.cn/post/6995491512716918814

收起阅读 »

七夕到了,还不快给你女朋友做一个专属chrome插件

web
前言七夕节马上就要到了,作为拥有对象(没有的话,可以选择 new 一个出来)的程序员来说,肯定是需要有一点表示才行的。用钱能买到的东西不一定能表达咱们的心意,但是用心去写的代码,还能让对象每天看到那才是最正确的选择。除了手机之外,在电脑上使...
继续阅读 »

前言

七夕节马上就要到了,作为拥有对象(没有的话,可以选择 new 一个出来)的程序员来说,肯定是需要有一点表示才行的。用钱能买到的东西不一定能表达咱们的心意,但是用心去写的代码,还能让对象每天看到那才是最正确的选择。

除了手机之外,在电脑上使用浏览器搜索想要的东西是最常用的功能了,所以就需要一个打开即用的搜索框,而且还能表达心意的chrome标签页来让 TA 随时可用。

新建项目

由于我们是做chrome标签页,所以新建的项目不需要任何框架,只需要最简单的HTML、js、css即可。

在任意地方新建一个文件夹chrome

chrome目录下新建一个manifest.json文件

配置chrome插件

{
"name": "Every Day About You",
"description": "Every Day About You",
"version": "1.0",
"manifest_version": 2,
"browser_action": {
"default_icon": "ex_icon.png"
},
"permissions": [
"activeTab"
],
"content_scripts": [
{
"matches": [
""
],
"js": [
"demo.js",
"canvas.js"
],
"run_at": "document_start"
}
],
"chrome_url_overrides": {
"newtab": "demo.html"
},
"offline_enabled": true,
}
复制代码
  • name:扩展名称,加载扩展程序时显示的名称。
  • description:描述信息,用于描述当前扩展程序,限132个字符。
  • version:扩展程序版本号。
  • manifest_version:manifest文件版本号。chrome18开始必须为2。
  • browser_action:设置扩展程序的图标。
  • permissions:需要申请的权限,这里使用tab即可。
  • content_scripts:指定在页面中运行的js和css及插入时机。
  • chrome_url_overrides:新标签页打开的html文件。
  • offline_enabled:脱机运行。

还有很多配置项可以在chrome插件开发文档中查询到,这里因为不需要发布到chrome商店中,所以只需要配置一些固定的数据项。

image.png

新建HTML和JS

在配置项中的content_scriptschrome_url_overrides中分别定义了html文件和js文件,所以我们需要新建这两个文件,名称对应即可。

image.png

HTML背景

没有哪个小天使可以拒绝来自程序猿霸道的满屏小心心好吗? 接下来我来教大家做一个飘满屏的爱心。

html>
<html>
<head>
<meta charset="utf-8">
<title>Every Day About Youtitle>
<script src="http://libs.baidu.com/jquery/1.10.2/jquery.min.js">script>
<
script type="text/javascript" src="canvas.js" >script>
head>
<
body>
<
canvas id="c" style="position: absolute;z-index: -1;text-align: center;">canvas>
body>
html>
复制代码
  • 这里引入的 jquery 是 百度 的CDN(matches中配置了可以使用所有的URL,所以CDN是可以使用外部链接的。)
  • canvas.js中主要是针对爱心和背景色进行绘画。

canvas

$(document).ready(function () {
var canvas = document.getElementById("c");
var ctx = canvas.getContext("2d");
var c = $("#c");
var w, h;
var pi = Math.PI;
var all_attribute = {
num: 100, // 个数
start_probability: 0.1, // 如果数量小于num,有这些几率添加一个新的
size_min: 1, // 初始爱心大小的最小值
size_max: 2, // 初始爱心大小的最大值
size_add_min: 0.3, // 每次变大的最小值(就是速度)
size_add_max: 0.5, // 每次变大的最大值
opacity_min: 0.3, // 初始透明度最小值
opacity_max: 0.5, // 初始透明度最大值
opacity_prev_min: .003, // 透明度递减值最小值
opacity_prev_max: .005, // 透明度递减值最大值
light_min: 0, // 颜色亮度最小值
light_max: 90, // 颜色亮度最大值
};
var style_color = find_random(0, 360);
var all_element = [];
window_resize();

function start() {
window.requestAnimationFrame(start);
style_color += 0.1;
//更改背景色hsl(颜色值,饱和度,明度)
ctx.fillStyle = 'hsl(' + style_color + ',100%,97%)';
ctx.fillRect(0, 0, w, h);
if (all_element.length < all_attribute.num && Math.random() < all_attribute.start_probability) {
all_element.push(new ready_run);
}
all_element.map(function (line) {
line.to_step();
})
}

function ready_run() {
this.to_reset();
}

function arc_heart(x, y, z, m) {
//绘制爱心图案的方法,参数x,y是爱心的初始坐标,z是爱心的大小,m是爱心上升的速度
y -= m * 10;

ctx.moveTo(x, y);
z *= 0.05;
ctx.bezierCurveTo(x, y - 3 * z, x - 5 * z, y - 15 * z, x - 25 * z, y - 15 * z);
ctx.bezierCurveTo(x - 55 * z, y - 15 * z, x - 55 * z, y + 22.5 * z, x - 55 * z, y + 22.5 * z);
ctx.bezierCurveTo(x - 55 * z, y + 40 * z, x - 35 * z, y + 62 * z, x, y + 80 * z);
ctx.bezierCurveTo(x + 35 * z, y + 62 * z, x + 55 * z, y + 40 * z, x + 55 * z, y + 22.5 * z);
ctx.bezierCurveTo(x + 55 * z, y + 22.5 * z, x + 55 * z, y - 15 * z, x + 25 * z, y - 15 * z);
ctx.bezierCurveTo(x + 10 * z, y - 15 * z, x, y - 3 * z, x, y);
}
ready_run.prototype = {
to_reset: function () {
var t = this;
t.x = find_random(0, w);
t.y = find_random(0, h);
t.size = find_random(all_attribute.size_min, all_attribute.size_max);
t.size_change = find_random(all_attribute.size_add_min, all_attribute.size_add_max);
t.opacity = find_random(all_attribute.opacity_min, all_attribute.opacity_max);
t.opacity_change = find_random(all_attribute.opacity_prev_min, all_attribute.opacity_prev_max);
t.light = find_random(all_attribute.light_min, all_attribute.light_max);
t.color = 'hsl(' + style_color + ',100%,' + t.light + '%)';
},
to_step: function () {
var t = this;
t.opacity -= t.opacity_change;
t.size += t.size_change;
if (t.opacity <= 0) {
t.to_reset();
return false;
}
ctx.fillStyle = t.color;
ctx.globalAlpha = t.opacity;
ctx.beginPath();
arc_heart(t.x, t.y, t.size, t.size);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
}
}

function window_resize() {
w = window.innerWidth;
h = window.innerHeight;
canvas.width = w;
canvas.height = h;
}
$(window).resize(function () {
window_resize();
});

//返回一个介于参数1和参数2之间的随机数
function find_random(num_one, num_two) {
return Math.random() * (num_two - num_one) + num_one;
}

start();
});
复制代码
  • 因为使用了jquery的CDN,所以我们在js中就可以直接使用 $(document).ready方法

chrome-capture-2022-6-20.gif

土豪金色的标题

为了时刻展示出对 TA 的爱,我们除了在背景中体现出来之外,还可以再文字中体现出来,所以需要取一个充满爱意的标题。

<body>
<canvas id="c" style="position: absolute;z-index: -1;text-align: center;">canvas>
<div class="middle">
<h1 class="label">Every Day About Youh1>
div>
body>
复制代码

复制代码
  • 这里引入了googleapis中的字体样式。
  • 给label一个背景,并使用了动画效果。

text_bg.png

  • 这个就是文字后面的静态图片,可以另存为然后使用的哦~

chrome-capture-2022-6-20 (1).gif

百度搜索框

对于你心爱的 TA 来说,不管干什么估计都得用百度直接搜出来,就算是看个优酷、微博都不会记住域名,都会直接去百度一下,所以我们需要在标签页中直接集成百度搜索。让 TA 可以无忧无虑的搜索想要的东西。

由于现在百度搜索框不能直接去站长工具中获取了,所以我们可以参考掘金标签页插件中的百度搜索框。

1.gif

根据掘金的标签页插件我们可以发现,输入结果之后,直接跳转到百度的网址,并在url后面携带了一个 wd 的参数,wd 也就是我们输入的内容了。

http://www.baidu.com/s?wd=这里是输入的…

<div class="search">
<input id="input" type="text">
<button>百度一下button>
div>
复制代码

复制代码
.search {
width: 750px;
height: 50px;
margin: auto;
display: flex;
justify-content: center;
align-content: center;
min-width: 750px;
position: relative;
}

input {
width: 550px;
height: 40px;
border-right: none;
border-bottom-left-radius: 10px;
border-top-left-radius: 10px;
border-color: #f5f5f5;
/* 去除搜索框激活状态的边框 */
outline: none;
}

input:hover {
/* 鼠标移入状态 */
box-shadow: 2px 2px 2px #ccc;
}

input:focus {
/* 选中状态,边框颜色变化 */
border-color: rgb(78, 110, 242);
}

.search span {
position: absolute;
font-size: 23px;
top: 10px;
right: 170px;
}

.search span:hover {
color: rgb(78, 110, 242);
}

button {
width: 100px;
height: 44px;
background-color: rgb(78, 110, 242);
border-bottom-right-radius: 10px;
border-top-right-radius: 10px;
border-color: rgb(78, 110, 242);
color: white;
font-size: 14px;
}
复制代码

chrome-capture-2022-6-20 (2).gif

关于 TA

这里可以放置你们之间的一些生日,纪念日等等,也可以放置你想放置的任何浪漫,仪式感满满~

如果你不记得两个人之间的纪念日,那就换其他的日子吧。比如你和 TA 闺蜜的纪念日也可以。

<body>
<canvas id="c" style="position: absolute;z-index: -1;text-align: center;">canvas>
<div class="middle">
<h1 class="label">Every Day About Youh1>
<div class="time">
<span>
<div id="d">
00
div>
Love day
span> <span>
<div id="h">
00
div>
First Met
span> <span>
<div id="m">
00
div>
birthday
span> <span>
<div id="s">
00
div>
age
span>
div>
div>
<script type="text/javascript" src="demo.js">script>
body>
复制代码
  • 这里我定义了四个日期,恋爱纪念日、相识纪念日、TA 的生日、TA 的年龄。
  • 在页面最后引用了一个js文件,主要是等待页面渲染完成之后调用js去计算日期的逻辑。
恋爱纪念日
var date1 = new Date('2019-10-07')
var date2 = new Date()

var s1 = date1.getTime(),
s2 = date2.getTime();

var total = (s2 - s1) / 1000;

var day = parseInt(total / (24 * 60 * 60)); //计算整数天数

const d = document.getElementById("d");

d.innerHTML = getTrueNumber(day);

复制代码
相识纪念日
var date1 = new Date('2019-09-20')
var date2 = new Date()

var s1 = date1.getTime(),
s2 = date2.getTime();

var total = (s2 - s1) / 1000;

var day = parseInt(total / (24 * 60 * 60)); //计算整数天数

h.innerHTML = getTrueNumber(day);
复制代码
公共方法(将计算出来的日子转为绝对值)
const getTrueNumber = x => (x < 0 ? Math.abs(x) : x);
复制代码

chrome-capture-2022-6-20 (3).gif

由于生日和年龄的计算代码有些多,所以放在码上掘金中展示了。

添加到chrome浏览器中

image.png

开发完成之后,所有的文件就是这样的了,里面的icon可以根据自己的喜好去设计或者网上下载。

使用chrome浏览器打开:chrome://extensions/ 即可跳转到添加扩展程序页面。

2.gif

  • 打开右上角的开发者模式
  • 点击加载已解压的扩展程序
  • 选择自己的chrome标签页项目目录即可

3.gif

总结一下

为了让心爱的 TA 开心,作为程序员的我们可谓是煞费苦心呀!!

在给对象安装插件的时候,发现了一个小问题,可能是chrome版本原因,导致jquery的cdn无法直接引用,所以可能需要手动把jquery保存到项目文件中,然后在manifest.json配置js的地方把jquery的js加上即可。

码上掘金中我已经把jquery的代码、canvas的代码、计算纪念日的代码都放进去了,可以直接复制到自己项目中哦!!!

七夕节快到了,祝愿天下有情人终成眷属!

来源:juejin.cn/post/7122332008252080142

收起阅读 »

老板连夜抠掉全公司电脑Alt键,只为限制员工摸鱼...哄堂大笑了兄弟们

有人说,优秀公司抓产品,一般公司抓业绩,奇葩公司抓“摸鱼”。 “为了防止自家员工摸鱼,亲自出手扣除键盘alt键”,自认为这样一来,员工在摸鱼时想要切换窗口界面就没有那么方便了,就能更方便自己“抓到”摸鱼的人。正所谓天下代有“才人”出,在2022年的第...
继续阅读 »



有人说,优秀公司抓产品,一般公司抓业绩,奇葩公司抓“摸鱼”。 
近日,一位私企老板就为码君生动演绎了什么叫“与人斗,其乐无穷”。 



“为了防止自家员工摸鱼,亲自出手扣除键盘alt键”,自认为这样一来,员工在摸鱼时想要切换窗口界面就没有那么方便了,就能更方便自己“抓到”摸鱼的人。
说实话,刚看到这个消息的时候,码君的脑回路一时都没有转过弯来,甚至还在帮这位老板思考这样是不是真的有什么“深远”的作用。


仔细看了几遍后,我才终于接受了世界上真的有这么“离谱”的人和事存在。 
正所谓天下代有“才人”出,在2022年的第一个月,这位老板就成功预定了“年度十大迷惑事件”之一的位置。 


而根据视频中员工的爆料,这老板在之前还有过好几次类似的操作,比如:

“在厕所内偷偷装上信号屏蔽器,防止员工‘带薪蹲坑’”;

“给员工们定中午盒饭,美名其曰修复‘中午找不到人的BUG’”



码君属实是蚌埠住了,这老板的行事风格真是槽点满满。
从动机上来说,你一位私企老板不去好好谈项目,拉合作,每天关注自己员工有没有摸鱼,这样对公司的发展真的好嘛?

从某种意义上,这种抓摸鱼的行为对你自己来说是不是也是一种摸鱼呢?这就是老板,员工的摸鱼二象性?



而从行动上来说,您跑出来扣人家键盘是什么神奇操作啊?
咱首先想想,对于某些员工自配的机械键盘来说,人家是靠轴体操作的,换个不常用键位插上基本就没差啊。
再退一步想,拔了alt键是不是也会降低员工们干正事时的效率呢?
再再退一步想,这个事件有个最关键的问题在于——快捷键这个东西,它是可以自行改的呀!



扣除个别按键就想禁用“快速关闭窗口”和“快速切换窗口”功能,未免有些太小瞧人家微软了吧。 
还是说,作为一位老板,您不会不知道这个操作吧?
而面对这样的“大无语事件”,网友们自然也是有话要说—— 



有网友将其与“周扒皮”类比,要我说,多少是有些抬举了,至少人家周扒皮学鸡叫,比“扣alt键”可专业多了。



有网友也像文章开头那样认为,屁事越多的公司,基本都混得越差。 
员工手里的活都干完了,做一些自己的闲事又有什么关系呢,咱们是打工,又不是卖身是不是?



一些设计行业的朋友更是直呼内行——你都已经把我的alt键扣了,我不摸鱼我能干嘛呢?
不如一鼓作气,把Ctrl、Shift、C、V等键位都扣了吧,这样才叫皆大欢喜。



说实话,职场上限制员工摸鱼的事情,这几年也是挺常见的,有一些可以接受,但有一些简直就是“反智”。 

就比如码君之前曾报道过的“国美监控员工手机流量使用情况”的事情。

因为员工上班摸鱼对其进行行政处罚、通报批评,就算过了两个多月,再看也依旧觉得离谱。

还有“一 iOS 开发员工因玩手机被开除”的事情,不在业务上、产品上推陈出新,每天就对着员工制度作妖。

这样的公司要是日后黄了,码君都要欢呼一句:“好似,开香槟咯!” 




说到底,打工人们奔波在这社会上,落脚在你的公司里,无非是为了一个“”字。
职场制度存在的初心也不是为了限制什么“摸鱼”行为,而是更多让员工将力气在集中在业务上。
当你公司的业务做得红火,员工收获得满满当当,哪会有那么多时间浪费在摸鱼上呢?还不上赶着做项目,挣大钱?

希望这类新闻中的老板们早日清醒,不要等到哪天真的遭到“反噬”了,才能明白这些真理啊!

你遇见过哪些防摸鱼损招?
*期待你的留言!

来源 | 抓码青年
收起阅读 »

大公司为什么禁止SpringBoot项目使用Tomcat?

前言 在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。同时,SpringBoot也支持Undertow容器,我们可以很方便的用Undertow替换Tomcat,而Undertow的...
继续阅读 »

前言


在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。同时,SpringBoot也支持Undertow容器,我们可以很方便的用Undertow替换Tomcat,而Undertow的性能和内存使用方面都优于Tomcat,那我们如何使用Undertow技术呢?本文将为大家细细讲解。


SpringBoot中的Tomcat容器


SpringBoot可以说是目前最火的Java Web框架了。它将开发者从繁重的xml解救了出来,让开发者在几分钟内就可以创建一个完整的Web服务,极大的提高了开发者的工作效率。Web容器技术是Web项目必不可少的组成部分,因为任Web项目都要借助容器技术来运行起来。在SpringBoot框架中,我们使用最多的是Tomcat,这是SpringBoot默认的容器技术,而且是内嵌式的Tomcat。推荐:几乎涵盖你需要的SpringBoot所有操作


SpringBoot设置Undertow


对于Tomcat技术,Java程序员应该都非常熟悉,它是Web应用最常用的容器技术。我们最早的开发的项目基本都是部署在Tomcat下运行,那除了Tomcat容器,SpringBoot中我们还可以使用什么容器技术呢?没错,就是题目中的Undertow容器技术。SrpingBoot已经完全继承了Undertow技术,我们只需要引入Undertow的依赖即可,如下图所示。


image.png


image.png


配置好以后,我们启动应用程序,发现容器已经替换为Undertow。那我们为什么需要替换Tomcat为Undertow技术呢?


Tomcat与Undertow的优劣对比


Tomcat是Apache基金下的一个轻量级的Servlet容器,支持Servlet和JSP。Tomcat具有Web服务器特有的功能,包括 Tomcat管理和控制平台、安全局管理和Tomcat阀等。Tomcat本身包含了HTTP服务器,因此也可以视作单独的Web服务器。但是,Tomcat和ApacheHTTP服务器不是一个东西,ApacheHTTP服务器是用C语言实现的HTTP Web服务器。Tomcat是完全免费的,深受开发者的喜爱。


图片


Undertow是Red Hat公司的开源产品, 它完全采用Java语言开发,是一款灵活的高性能Web服务器,支持阻塞IO和非阻塞IO。由于Undertow采用Java语言开发,可以直接嵌入到Java项目中使用。同时, Undertow完全支持Servlet和Web Socket,在高并发情况下表现非常出色。


图片


我们在相同机器配置下压测Tomcat和Undertow,得到的测试结果如下所示:QPS测试结果对比: Tomcat


图片


Undertow


图片


内存使用对比:


Tomcat


image.png


Undertow


image.png


通过测试发现,在高并发系统中,Tomcat相对来说比较弱。在相同的机器配置下,模拟相等的请求数,Undertow在性能和内存使用方面都是最优的。并且Undertow新版本默认使用持久连接,这将会进一步提高它的并发吞吐能力。所以,如果是高并发的业务系统,Undertow是最佳选择。


最后


SpingBoot中我们既可以使用Tomcat作为Http服务,也可以用Undertow来代替。Undertow在高并发业务场景中,性能优于Tomcat。所以,如果我们的系统是高并发请求,不妨使用一下Undertow,你会发现你的系统性能会得到很大的提升。


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

Flutter 小技巧之优化使用的 BuildContext

Flutter 里的 BuildContext 相信大家都不会陌生,虽然它叫 Context,但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 ComponentElement 。 关于 ComponentElement...
继续阅读 »

Flutter 里的 BuildContext 相信大家都不会陌生,虽然它叫 Context,但是它实际是 Element 的抽象对象,而在 Flutter 里,它主要来自于 ComponentElement


关于 ComponentElement 可以简单介绍一下,在 Flutter 里根据 Element 可以简单地被归纳为两类:



  • RenderObjectElement :具备 RenderObject ,拥有布局和绘制能力的 Element

  • ComponentElement :没有 RenderObject ,我们常用的 StatelessWidgetStatefulWidget 里对应的 StatelessElementStatefulElement 就是它的子类。


所以一般情况下,我们在 build 方法或者 State 里获取到的 BuildContext 其实就是 ComponentElement


那使用 BuildContext 有什么需要注意的问题


首先如下代码所示,在该例子里当用户点击 FloatingActionButton 的时候,代码里做了一个 2秒的延迟,然后才调用 pop 退出当前页面。


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
Navigator.of(context).pop();
},
),
);
}
}

正常情况下是不会有什么问题,但是当用户在点击了 FloatingActionButton 之后,又马上点击了 AppBar 返回退出应用,这时候就会出现以下的错误提示。



可以看到此时 log 说,Widget 对应的 Element 已经不在了,因为在 Navigator.of(context) 被调用时,context 对应的 Element 已经随着我们的退出销毁。


一般情况下处理这个问题也很简单,那就是增加 mounted 判断,通过 mounted 判断就可以避免上述的错误


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
floatingActionButton: FloatingActionButton(
onPressed: () async {
await Future.delayed(Duration(seconds: 2));
if (!mounted) return;
Navigator.of(context).pop();
},
),
);
}
}

上面代码里的 mounted 标识位来自于 State因为 State 是依附于 Element 创建,所以它可以感知 Element 的生命周期,例如 mounted 就是判断 _element != null;



那么到这里我们收获了一个小技巧:使用 BuildContext 时,在必须时我们需要通过 mounted 来保证它的有效性


那么单纯使用 mounted 就可以满足 context 优化的要求了吗


如下代码所示,在这个例子里:



  • 我们添加了一个列表,使用 builder 构建 Item

  • 每个列表都有一个点击事件

  • 点击列表时我们模拟网络请求,假设网络也不是很好,所以延迟个 5 秒

  • 之后我们滑动列表让点击的 Item 滑出屏幕不可见


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
);
}
}
class ListItem extends StatefulWidget {
const ListItem({Key? key}) : super(key: key);
@override
State<ListItem> createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
await Future.delayed(Duration(seconds: 5));
if(!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}

由于在 5 秒之内,Item 被划出了屏幕,所以对应的 Elment 其实是被释放了,从而由于 mounted 判断,SnackBar 不会被弹出。


那如果假设需要在开发时展示点击数据上报的结果,也就是 Item 被释放了还需要弹出,这时候需要如何处理


我们知道不管是 ScaffoldMessenger.of(context) 还是 Navigator.of(context) ,它本质还是通过 context 去往上查找对应的 InheritedWidget 泛型,所以其实我们可以提前获取。


所以,如下代码所示,在 Future.delayed 之前我们就通过 ScaffoldMessenger.of(context); 获取到 sm 对象,之后就算你直接退出当前的列表页面,5秒过后 SnackBar 也能正常弹出。


class _ListItemState extends State<ListItem> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Container(
height: 160,
color: Colors.amber,
),
onTap: () async {
var sm = ScaffoldMessenger.of(context);
await Future.delayed(Duration(seconds: 5));
sm.showSnackBar(SnackBar(content: Text("Tip")));
},
);
}
}

为什么页面销毁了,但是 SnackBar 还能正常弹出


因为此时通过 of(context); 获取到的 ScaffoldMessenger 是存在 MaterialApp 里,所以就算页面销毁了也不影响 SnackBar 的执行。


但是如果我们修改例子,如下代码所示,在 Scaffold 上面多嵌套一个 ScaffoldMessenger ,这时候在 Item 里通过 ScaffoldMessenger.of(context) 获取到的就会是当前页面下的 ScaffoldMessenger


class _ControllerDemoPageState extends State<ControllerDemoPage> {
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(),
body: ListView.builder(
itemBuilder: (context, index) {
return ListItem();
},
itemCount: 30,
),
),
);
}
}

这种情况下我们只能保证Item 不可见的时候 SnackBar 还能正常弹出, 而如果这时候我们直接退出页面,还是会出现以下的错误提示,因为 ScaffoldMessenger 也被销毁了 。



所以到这里我们收获第二个小技巧:在异步操作里使用 of(context) ,可以提前获取,之后再做异步操作,这样可以尽量保证流程可以完整执行


既然我们说到通过 of(context) 去获取上层共享往下共享的 InheritedWidget ,那在哪里获取就比较好


还记得前面的 log 吗?在第一个例子出错时,log 里就提示了一个方法,也就是 State 的 didChangeDependencies 方法。



为什么是官方会建议在这个方法里去调用 of(context)


首先前面我们一直说,通过 of(context) 获取到的是 InheritedWidget ,而 当 InheritedWidget 发生改变时,就是通过触发绑定过的 Element 里 State 的didChangeDependencies 来触发更新,所以在 didChangeDependencies 里调用 of(context) 有较好的因果关系



对于这部分内容感兴趣的,可以看 Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密全面理解State与Provider



那我能在 initState 里提前调用吗


当然不行,首先如果在 initState 直接调用如 ScaffoldMessenger.of(context).showSnackBar 方法,就会看到以下的错误提示。



这是因为 Element 里会判断此时的 _StateLifecycle 状态,如果此时是 _StateLifecycle.created 或者 _StateLifecycle.defunct ,也就是在 initStatedispose ,是不允许执行 of(context) 操作。




of(context) 操作指的是 context.dependOnInheritedWidgetOfExactTyp



当然,如果你硬是想在 initState 下调用也行,增加一个 Future 执行就可以成功执行


@override
void initState() {
super.initState();
Future((){
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("initState")));
});
}


简单理解,因为 Dart 是单线程轮询执行,initState 里的 Future 相当于是下一次轮询,自然也就不在 _StateLifecycle.created 的状态下。



那我在 build 里直接调用不行吗


直接在 build 里调用肯定可以,虽然 build 会被比较频繁执行,但是 of(context) 操作其实就是在一个 map 里通过 key - value 获取泛型对象,所以对性能不会有太大的影响。


真正对性能有影响的是 of(context) 的绑定数量和获取到对象之后的自定义逻辑,例如你通过 MediaQuery.of(context).size 获取到屏幕大小之后,通过一系列复杂计算来定位你的控件。


  @override
Widget build(BuildContext context) {
var size = MediaQuery.of(context).size;
var padding = MediaQuery.of(context).padding;
var width = size.width / 2;
var height = size.width / size.height * (30 - padding.bottom);
return Container(
color: Colors.amber,
width: width,
height: height,
);
}

例如上面这段代码,可能会导致键盘在弹出的时候,虽然当前页面并没有完全展示,但是也会导致你的控件不断重新计算从而出现卡顿。



详细解释可以参考 Flutter 小技巧之 MediaQuery 和 build 优化你不知道的秘密



所以到这里我们又收获了一个小技巧: 对于 of(context) 的相关操作逻辑,可以尽量放到 didChangeDependencies 里去处理


最后,今天主要分享了在使用 BuildContext 时的一些注意事项和技巧,如果你对于这方面还有什么疑问,欢迎留言评论。


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

记录一个温度曲线的View

 最近做项目需求的看到需要自定义一个温度曲线的图。由于之前的同事理解需求的时候没有很好的理解产品的需求,将温度的折线图分成了两个View,温度高的在一个View,温度低的在一个View。这样的做法其实是没有很好的理解产品的需求的。为什么这么说,因为一...
继续阅读 »

image-20220713155216246.png 最近做项目需求的看到需要自定义一个温度曲线的图。由于之前的同事理解需求的时候没有很好的理解产品的需求,将温度的折线图分成了两个View,温度高的在一个View,温度低的在一个View。这样的做法其实是没有很好的理解产品的需求的。为什么这么说,因为一旦拆成两个View,那么哪些相交的点绘制就会有缺陷了。什么意思,看图。

image-20220713155901206.png

如果按照两个View去做,就会有这种局限性。相交的点就会被切。所以这里就重新修改了这个自定义View。

有了上面的需求,那么就开始我们的设计了。首先为了我们自定义View的能比较好的通用性,我们需要把一些可能会变的东西提取出来。这里只是提取一些很常用的属性,其余需要自定义的,可自己加上。直接看代码

<declare-styleable name="NewWeatherChartView">
   <!--开始的x坐标-->
   <attr name="new_start_point_x" format="dimension"/>
   <!--两点之间x坐标的间隔-->
   <attr name="new_point_x_margin" format="dimension"/>
   <!--显示温度的字体大小-->
   <attr name="temperature_text_size" format="dimension"/>
   <!--圆点的半径-->
   <attr name="point_radius" format="dimension"/>

   <!--选中天气项,温度字体的颜色-->
   <attr name="select_temperature_text_color" format="reference|color"/>
   <!--未选中天气项,温度字体的颜色-->
   <attr name="unselect_temperature_text_color" format="reference|color"/>
   <!--选中天气项,圆点的颜色-->
   <attr name="select_point_color" format="reference|color"/>
   <!--未选中天气项,圆点的颜色-->
   <attr name="unselect_point_color" format="reference|color"/>
<!--连接线的颜色-->
   <attr name="line_color" format="reference|color"/>
   <!--连接线的类型,可以是实线,也可以是虚线,默认是虚线。0虚线,1实线-->
   <attr name="line_type" format="integer"/>

</declare-styleable>
public class NewWeatherChartView extends View {
   private final static String TAG = "NewWeatherChartView";
   private List<WeatherInfo> items;//温度的数据源

   //都是可以在XML里面配置的属性,目前项目里面都是用的默认配置。
   private int mLineColor;
   private int mSelectTemperatureColor;
   private int mUnSelectTemperatureColor;
   private int mSelectPointColor;
   private int mUnselectPointColor;
   private int mLineType;
   private int mTemperatureTextSize;
   private int mPointStartX = 0;
   private int mPointXMargin = 0;
   private int mPointRadius;


   
   private Point[] mHighPoints; //高温的点的坐标
   private Point[] mLowPoints; //低温的点的坐标

   //这里是为了方便写代码,多创建了几个画笔,也可以用一个画笔,然后配置不同的属性
   private Paint mLinePaint; //用于画线画笔
   private Paint mTextPaint; // 用于画小圆点旁边的温度文字的画笔
   private Paint mCirclePaint;//用来画小圆点的画笔
 

   private Float mMaxTemperature = Float.MIN_VALUE;//最高温度
   private Float mMinTemperature = Float.MAX_VALUE;//最低温度
   private Path mPath;//连接线的路径
   
   private DecimalFormat mDecimalFormat;


   private int mTodayIndex = -1;//用于判断哪一个被选中

   private Context mContext;
...
}

以上就是一些初始化的东西了,那么现在就来思考一下,怎么去画这些东西,上面的初始化也说明了,我们主要是画线,画文字,然后画圆点。那么应该从哪开始呢?首先是从点坐标开始,因为无论是线,还是文字,他们的位置和点都有关系。那么找到点的坐标就是首要的工作。怎么找点的坐标,以及最开始的X坐标是多少。第一个点的X坐标是根据我们的配置来的,那么第二个点的x坐标呢?,第二个点的x坐标就是第一个点的x坐标加上他们之间的在X方向上距离,而在x方向上的距离也是根据属性配置的。所以我们可以很容易得到所有点的x坐标。那么圆点的y坐标呢?首先我们看一张图。

image-20220713172903532.png

我们的点,应该是均匀分布在剩余高度里面的。

剩余高度 = 控件高度-2*文字的高度。

点的y坐标为

*剩余高度-((当前温度-最低温度)/(最高温度-最低温度)剩余高度)+文字高度

看起来有点复杂,但是有公式的话,代码会比较简单。接下来就需要看初始化的代码了和计算点坐标的代码了

代码如下:

//首先从两个参数的构造函数里面获取各种配置的值
public NewWeatherChartView(Context context, AttributeSet attrs) {
   super(context, attrs);
   TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.NewWeatherChartView);
   mPointStartX = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_new_start_point_x, 0);
   mPointXMargin = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_new_point_x_margin, 0);
   mTemperatureTextSize = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_temperature_text_size, 20);
   mPointRadius = (int) typedArray.getDimension(R.styleable.NewWeatherChartView_point_radius, 8);

   mSelectPointColor = typedArray.getColor(R.styleable.NewWeatherChartView_select_point_color, context.getResources().getColor(R.color.weather_select_point_color));
   mUnselectPointColor = typedArray.getColor(R.styleable.NewWeatherChartView_unselect_point_color, context.getResources().getColor(R.color.weather_unselect_point_color));
   mLineColor = typedArray.getColor(R.styleable.NewWeatherChartView_line_color, context.getResources().getColor(R.color.weather_line_color));
   mSelectTemperatureColor = typedArray.getColor(R.styleable.NewWeatherChartView_select_temperature_text_color, context.getResources().getColor(R.color.weather_select_temperature_color));
   mUnSelectTemperatureColor = typedArray.getColor(R.styleable.NewWeatherChartView_unselect_temperature_text_color, context.getResources().getColor(R.color.weather_unselect_temperature_color));

   mLineType = typedArray.getInt(R.styleable.NewWeatherChartView_line_type, 0);

   this.mContext = context;
   typedArray.recycle();
}

private void initData() {
   //初始化线的画笔
   mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
   mLinePaint.setStyle(Paint.Style.STROKE);
   mLinePaint.setStrokeWidth(2);
   mLinePaint.setDither(true);
   //配置虚线
   if (mLineType == 0) {
       DashPathEffect pathEffect = new DashPathEffect(new float[]{10, 5}, 1);
       mLinePaint.setPathEffect(pathEffect);
  }
   mPath = new Path();

   //初始化文字的画笔
   mTextPaint = new Paint();
   mTextPaint.setAntiAlias(true);
   mTextPaint.setTextSize(sp2px(mTemperatureTextSize));
   mTextPaint.setTextAlign(Paint.Align.CENTER);

   // 初始化圆点的画笔
   mCirclePaint = new Paint();
   mCirclePaint.setStyle(Paint.Style.FILL);

   mDecimalFormat = new DecimalFormat("0");

   for (int i = 0; i < items.size(); i++) {
       float highY = items.get(i).getHigh();
       float lowY = items.get(i).getLow();
       if (highY > mMaxTemperature) {
           mMaxTemperature = highY;
      }
       if (lowY < mMinTemperature) {
           mMinTemperature = lowY;
      }
       if (DateUtil.fromTodayDate(items.get(i).getDate()) == 0) {
           mTodayIndex = i;
      }
  }
   float span = mMaxTemperature - mMinTemperature;
   //这种情况是为了防止所有温度都一样的情况
   if (span == 0) {
       span = 6.0f;
  }
   mMaxTemperature = mMaxTemperature + span / 6.0f;
   mMinTemperature = mMinTemperature - span / 6.0f;

   mHighPoints = new Point[items.size()];
   mLowPoints = new Point[items.size()];
}

public int sp2px(float spValue) {
   return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, spValue, Resources.getSystem().getDisplayMetrics());
}

public int dip2px(float dpValue) {
   return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, Resources.getSystem().getDisplayMetrics());

}

这些准备工作昨晚之后,我们就可以去onDraw里面画图了。

protected void onDraw(Canvas canvas) {
   Logging.d(TAG, "onDraw: ");
   if (items == null) {
       return;
  }
   int pointX = mPointStartX; // 开始的X坐标
   int textHeight = sp2px(mTemperatureTextSize);//文字的高度
   int remainingHeight = getHeight() - textHeight * 2;//除去文字后,剩余的高度

   // 计算每一个点的X和Y坐标
   for (int i = 0; i < items.size(); i++) {
       int x = pointX + mPointXMargin * i;
       float highTemp = items.get(i).getHigh();
       float lowTemp = items.get(i).getLow();
       int highY = remainingHeight - (int) (remainingHeight * ((highTemp - mMinTemperature) / (mMaxTemperature - mMinTemperature))) + textHeight;
       int lowY = remainingHeight - (int) (remainingHeight * ((lowTemp - mMinTemperature) / (mMaxTemperature - mMinTemperature))) + textHeight;
       mHighPoints[i] = new Point(x, highY);
       mLowPoints[i] = new Point(x, lowY);
  }

   // 画线
   drawLine(mHighPoints, canvas);
   drawLine(mLowPoints, canvas);
   for (int i = 0; i < mHighPoints.length; i++) {
       // 画文本度数 例如:3°
       String yHighText = mDecimalFormat.format(items.get(i).getHigh());
       String yLowText = mDecimalFormat.format(items.get(i).getLow());
       int highDrawY = mHighPoints[i].y - dip2px(mPointRadius + 8);
       int lowDrawY = mLowPoints[i].y + dip2px(mPointRadius + 8 + sp2px(mTemperatureTextSize));

       if (i == mTodayIndex) {
           mTextPaint.setColor(mSelectTemperatureColor);
           mCirclePaint.setColor(mSelectPointColor);
      } else {
           mTextPaint.setColor(mUnSelectTemperatureColor);
           mCirclePaint.setColor(mUnselectPointColor);
      }
       canvas.drawText(yHighText + "°", mHighPoints[i].x, highDrawY, mTextPaint);
       canvas.drawText(yLowText + "°", mLowPoints[i].x, lowDrawY, mTextPaint);
       canvas.drawCircle(mHighPoints[i].x, mHighPoints[i].y, mPointRadius, mCirclePaint);
       canvas.drawCircle(mLowPoints[i].x, mLowPoints[i].y, mPointRadius, mCirclePaint);

  }
}


private void drawLine(Point[] ps, Canvas canvas) {
   Point startp;
   Point endp;
   mPath.reset();
   mLinePaint.setAntiAlias(true);
   for (int i = 0; i < ps.length - 1; i++) {
       startp = ps[i];
       endp = ps[i + 1];
       mLinePaint.setColor(mLineColor);
       canvas.drawLine(startp.x, startp.y, endp.x, endp.y, mLinePaint);
  }
}

以上就是所有关键代码了,当然,还有一个赋值的代码

public void setData(List<WeatherInfo> list) {
   this.items = list;
   initData();
}

来看一下最后的效果图吧。

image-20220713194524550.png 以上就是一个简单的温度图了,但是这个图有很多地方可以优化,也有很多地方可以提取出来当作属性。比如我举一个优化的点,文字的测量,上面的代码对文字的测量其实是非常粗糙的。仔细观察会发现上面一条线,文字距离点的距离和下面一条线文字距离点的距离是不一样的。这就是上面没有进行文字测量的结果,我这里进行了一轮文字测量的优化,如下图: image-20220713194423946.png 这里是不是好很多了呢?大家还可以进行很多地方的优化。以上就是这篇文章的全部内容了。


作者:爱海贼的小码农
链接:https://juejin.cn/post/7119826029463470088
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Android全局的通知的弹窗

需求分析 如何创建一个全局通知的弹窗?如下图所示。 从手机顶部划入,短暂停留后,再从顶部划出。 首先需要明确的是: 1、这个弹窗的弹出逻辑不一定是当前界面编写的,比如用户上传文件,用户可能继续浏览其他页面的内容,但是监听文件是否上传完成还是在原来的Activ...
继续阅读 »

需求分析


如何创建一个全局通知的弹窗?如下图所示。


image.png


从手机顶部划入,短暂停留后,再从顶部划出。


首先需要明确的是:

1、这个弹窗的弹出逻辑不一定是当前界面编写的,比如用户上传文件,用户可能继续浏览其他页面的内容,但是监听文件是否上传完成还是在原来的Activity,但是Dialog的弹出是需要当前页面的上下文Context的。


2、Dialog弹窗必须支持手势,用户在Dialog上向上滑时,Dialog需要退出,点击时可能需要处理点击事件。


一、Dialog的编写


/**
* 通知的自定义Dialog
*/
class NotificationDialog(context: Context, var title: String, var content: String) :
Dialog(context, R.style.dialog_notifacation_top) {

private var mListener: OnNotificationClick? = null
private var mStartY: Float = 0F
private var mView: View? = null
private var mHeight: Int? = 0

init {
mView = LayoutInflater.from(context).inflate(R.layout.common_layout_notifacation, null)
}


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(mView!!)
window?.setGravity(Gravity.TOP)
val layoutParams = window?.attributes
layoutParams?.width = ViewGroup.LayoutParams.MATCH_PARENT
layoutParams?.height = ViewGroup.LayoutParams.WRAP_CONTENT
layoutParams?.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
window?.attributes = layoutParams
window?.setWindowAnimations(R.style.dialog_animation)
//按空白处不能取消
setCanceledOnTouchOutside(false)
//初始化界面数据
initData()
}

private fun initData() {
val tvTitle = findViewById<TextView>(R.id.tv_title)
val tvContent = findViewById<TextView>(R.id.tv_content)
if (title.isNotEmpty()) {
tvTitle.text = title
}

if (content.isNotEmpty()) {
tvContent.text = content
}
}


override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (isOutOfBounds(event)) {
mStartY = event.y
}
}

MotionEvent.ACTION_UP -> {
if (mStartY > 0 && isOutOfBounds(event)) {
val moveY = event.y
if (abs(mStartY - moveY) >= 20) { //滑动超过20认定为滑动事件
//Dialog消失
} else { //认定为点击事件
//Dialog的点击事件
mListener?.onClick()
}
dismiss()
}
}
}
return false
}

/**
* 点击是否在范围外
*/
private fun isOutOfBounds(event: MotionEvent): Boolean {
val yValue = event.y
if (yValue > 0 && yValue <= (mHeight ?: (0 + 40))) {
return true
}
return false
}


private fun setDialogSize() {
mView?.addOnLayoutChangeListener { v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
mHeight = v?.height
}
}

/**
* 显示Dialog但是不会自动退出
*/
fun showDialog() {
if (!isShowing) {
show()
setDialogSize()
}
}

/**
* 显示Dialog,3000毫秒后自动退出
*/
fun showDialogAutoDismiss() {
if (!isShowing) {
show()
setDialogSize()
//延迟3000毫秒后自动消失
Handler(Looper.getMainLooper()).postDelayed({
if (isShowing) {
dismiss()
}
}, 3000L)
}
}

//处理通知的点击事件
fun setOnNotificationClickListener(listener: OnNotificationClick) {
mListener = listener
}

interface OnNotificationClick {
fun onClick()
}
}

Dialog的主题


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

<style name="dialog_notifacation_top">
<item name="android:windowIsTranslucent">true</item>
<!--设置背景透明-->
<item name="android:windowBackground">@android:color/transparent</item>
<!--设置dialog浮与activity上面-->
<item name="android:windowIsFloating">true</item>
<!--去掉背景模糊效果-->
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowNoTitle">true</item>
<!--去掉边框-->
<item name="android:windowFrame">@null</item>
</style>


<style name="dialog_animation" parent="@android:style/Animation.Dialog">
<!-- 进入时的动画 -->
<item name="android:windowEnterAnimation">@anim/dialog_enter</item>
<!-- 退出时的动画 -->
<item name="android:windowExitAnimation">@anim/dialog_exit</item>
</style>

</resources>

Dialog的动画


<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="600"
android:fromYDelta="-100%p"
android:toYDelta="0%p" />
</set>

<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:duration="300"
android:fromYDelta="0%p"
android:toYDelta="-100%p" />
</set>

Dialog的布局,通CardView包裹一下就有立体阴影的效果


<androidx.cardview.widget.CardView
android:id="@+id/cd"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/size_15dp"
app:cardCornerRadius="@dimen/size_15dp"
app:cardElevation="@dimen/size_15dp"
app:layout_constraintTop_toTopOf="parent">

<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/et_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/size_15dp"
app:layout_constraintTop_toTopOf="parent">

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#000000"
android:textSize="@dimen/font_14sp" android:textStyle="bold"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/size_15dp"
android:textColor="#333"
android:textSize="@dimen/font_12sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_title" />


</androidx.constraintlayout.widget.ConstraintLayout>

</androidx.cardview.widget.CardView>

二、获取当前显示的Activity的弱引用


/**
* 前台Activity管理类
*/
class ForegroundActivityManager {

private var currentActivityWeakRef: WeakReference<Activity>? = null

companion object {
val TAG = "ForegroundActivityManager"
private val instance = ForegroundActivityManager()

@JvmStatic
fun getInstance(): ForegroundActivityManager {
return instance
}
}


fun getCurrentActivity(): Activity? {
var currentActivity: Activity? = null
if (currentActivityWeakRef != null) {
currentActivity = currentActivityWeakRef?.get()
}
return currentActivity
}


fun setCurrentActivity(activity: Activity) {
currentActivityWeakRef = WeakReference(activity)
}

}

监听所有Activity的生命周期


class AppLifecycleCallback:Application.ActivityLifecycleCallbacks {

companion object{
val TAG = "AppLifecycleCallback"
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
//获取Activity弱引用
ForegroundActivityManager.getInstance().setCurrentActivity(activity)
}

override fun onActivityStarted(activity: Activity) {
}

override fun onActivityResumed(activity: Activity) {
//获取Activity弱引用
ForegroundActivityManager.getInstance().setCurrentActivity(activity)
}

override fun onActivityPaused(activity: Activity) {
}

override fun onActivityStopped(activity: Activity) {
}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}

override fun onActivityDestroyed(activity: Activity) {
}
}

在Application中注册


//注册Activity生命周期
registerActivityLifecycleCallbacks(AppLifecycleCallback())

三、封装和使用


/**
* 通知的管理类
* example:
* //发系统通知
* NotificationControlManager.getInstance()?.notify("文件上传完成", "文件上传完成,请点击查看详情")
* //发应用内通知
* NotificationControlManager.getInstance()?.showNotificationDialog("文件上传完成","文件上传完成,请点击查看详情",
* object : NotificationControlManager.OnNotificationCallback {
* override fun onCallback() {
* Toast.makeText(this@MainActivity, "被点击了", Toast.LENGTH_SHORT).show()
* }
* })
*/

class NotificationControlManager {

private var autoIncreament = AtomicInteger(1001)
private var dialog: NotificationDialog? = null

companion object {
const val channelId = "aaaaa"
const val description = "描述信息"

@Volatile
private var sInstance: NotificationControlManager? = null


@JvmStatic
fun getInstance(): NotificationControlManager? {
if (sInstance == null) {
synchronized(NotificationControlManager::class.java) {
if (sInstance == null) {
sInstance = NotificationControlManager()
}
}
}
return sInstance
}
}


/**
* 是否打开通知
*/
fun isOpenNotification(): Boolean {
val notificationManager: NotificationManagerCompat =
NotificationManagerCompat.from(
ForegroundActivityManager.getInstance().getCurrentActivity()!!
)
return notificationManager.areNotificationsEnabled()
}


/**
* 跳转到系统设置页面去打开通知,注意在这之前应该有个Dialog提醒用户
*/
fun openNotificationInSys() {
val context = ForegroundActivityManager.getInstance().getCurrentActivity()!!
val intent: Intent = Intent()
try {
intent.action = Settings.ACTION_APP_NOTIFICATION_SETTINGS

//8.0及以后版本使用这两个extra. >=API 26
intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
intent.putExtra(Settings.EXTRA_CHANNEL_ID, context.applicationInfo.uid)

//5.0-7.1 使用这两个extra. <= API 25, >=API 21
intent.putExtra("app_package", context.packageName)
intent.putExtra("app_uid", context.applicationInfo.uid)

context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()

//其他低版本或者异常情况,走该节点。进入APP设置界面
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.putExtra("package", context.packageName)

//val uri = Uri.fromParts("package", packageName, null)
//intent.data = uri
context.startActivity(intent)
}
}

/**
* 发通知
* @param title 标题
* @param content 内容
* @param cls 通知点击后跳转的Activity,默认为null跳转到MainActivity
*/
fun notify(title: String, content: String, cls: Class<*>) {
val context = ForegroundActivityManager.getInstance().getCurrentActivity()!!
val notificationManager =
context.getSystemService(AppCompatActivity.NOTIFICATION_SERVICE) as NotificationManager
val builder: Notification.Builder
val intent = Intent(context, cls)
val pendingIntent: PendingIntent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
} else {
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationChannel =
NotificationChannel(channelId, description, NotificationManager.IMPORTANCE_HIGH)
notificationChannel.enableLights(true);
notificationChannel.lightColor = Color.RED;
notificationChannel.enableVibration(true);
notificationChannel.vibrationPattern =
longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
notificationManager.createNotificationChannel(notificationChannel)
builder = Notification.Builder(context, channelId)
.setSmallIcon(R.drawable.jpush_notification_icon)
.setContentIntent(pendingIntent)
.setContentTitle(title)
.setContentText(content)
} else {
builder = Notification.Builder(context)
.setSmallIcon(R.drawable.jpush_notification_icon)
.setLargeIcon(
BitmapFactory.decodeResource(
context.resources,
R.drawable.jpush_notification_icon
)
)
.setContentIntent(pendingIntent)
.setContentTitle(title)
.setContentText(content)

}
notificationManager.notify(autoIncreament.incrementAndGet(), builder.build())
}


/**
* 显示应用内通知的Dialog,需要自己处理点击事件。listener默认为null,不处理也可以。dialog会在3000毫秒后自动消失
* @param title 标题
* @param content 内容
* @param listener 点击的回调
*/
fun showNotificationDialog(
title: String,
content: String,
listener: OnNotificationCallback? = null
) {
val activity = ForegroundActivityManager.getInstance().getCurrentActivity()!!
dialog = NotificationDialog(activity, title, content)
if (Thread.currentThread() != Looper.getMainLooper().thread) { //子线程
activity.runOnUiThread {
showDialog(dialog, listener)
}
} else {
showDialog(dialog, listener)
}
}

/**
* show dialog
*/
private fun showDialog(
dialog: NotificationDialog?,
listener: OnNotificationCallback?
) {
dialog?.showDialogAutoDismiss()
if (listener != null) {
dialog?.setOnNotificationClickListener(object :
NotificationDialog.OnNotificationClick {
override fun onClick() = listener.onCallback()
})
}
}

/**
* dismiss Dialog
*/
fun dismissDialog() {
if (dialog != null && dialog!!.isShowing) {
dialog!!.dismiss()
}
}


interface OnNotificationCallback {
fun onCallback()
}

}

另外需要注意的点是,因为dialog是延迟关闭的,可能用户立刻退出Activity,导致延迟时间到时dialog退出时报错,解决办法可以在BaseActivity的onDestroy方法中尝试关闭Dialog:


override fun onDestroy() {
super.onDestroy()
NotificationControlManager.getInstance()?.dismissDialog()
}

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

Android Studio Debug:编码五分钟,调试俩小时

前言 整理并积累Android开发过程中用到的一些调试技巧,通过技巧性的调试技能,辅助增强代码的健壮性、安全性、正确性 案例一:抛出明显异常 常见的:除数为0问题 class MainActivty : AppCompatActivity(){ o...
继续阅读 »

前言


整理并积累Android开发过程中用到的一些调试技巧,通过技巧性的调试技能,辅助增强代码的健壮性、安全性、正确性


案例一:抛出明显异常



  • 常见的:除数为0问题


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
val i = 1/0
}
}
}

image.png



会提示错误原因,并告知在哪一行




  • 一般错误


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
val s = "Candy" //假设此处是在一个方法内,我们无法看到
var i = 0
i = s.toInt()
}
}
}

image.png



会提示错误原因,并告知在哪一行


错误原因可能不认识,直接找错误关键字,检索百度



案例二:逻辑问题



  • println()方式调试


class MainActivty : AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
var res = 0
for (i in 1 until 10){
res=+i //该处将逻辑故意写错 应为 +=
println("i=${i},res=${res}")
}
val s:String = StringTo(res)
Toast.makeText(this,s,Toat.LENGTH.SHORT).show()
}
}
private fun StringTo(res:String){
println("将Int转换成String")
resturn res.roString()
}
}

image.png



会掺杂其他方法日志




  • log方式调试


class MainActivty : AppCompatActivity(){
val TAG = "MainActivity"
override fun onCreate(savedInstanceState: Bundle?){
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

button.setOnClickListener {
var res = 0
for (i in 1 until 10){
res=+i //该处将逻辑故意写错 应为 +=
Log.d(TAG,"i=${i},res=${res}")
}
val s:String = StringTo(res)
Toast.makeText(this,s,Toat.LENGTH.SHORT).show()
}
}
private fun StringTo(res:String){
println("将Int转换成String")
resturn res.roString()
}
}

image.png



筛选条件多:Debug、Info、Worn、Error以及自定义筛选等


可以直接根据key筛选


调试数据较多时,不方便查看,不够灵活




  • debug模式调试


image.png



  • resume progrem: 继续执行

  • step over: 跳入下一行

  • step into: 进入自定义方法,非方法则下一行

  • force step into:进入所有方法,非方法则下一行

  • step out: 跳出方法,且方法执行完成

  • run to cursor: 跳入逻辑的下一个标记点
    image.png



debug运行时,会出现提示框,无需操作



案例三:代码丢失||项目问题



  • history

    • 不小心删除代码/文件且已save并退出
      右击项目 -> Local History -> Show History -> 选择某一历史右键 -> Revert




image.png


image.png


image.png


image.png


image.png


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

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

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

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


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


1.1  背景


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


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



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



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



1.2  我们的挑战


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



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

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

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

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


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


二、 开发者的窘境


2.1 全机型适配成本太高


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

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


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



2.2 全机型适配成本高在哪


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


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



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


3.1 方案讨论与确定


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


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


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



3.2 方案落地示意图


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


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


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


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



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


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



3.2.3 PAD 机型适配方案骨骼图


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


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


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



3.3 方案落地规范


3.3.1 一套代码适配所有机型


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


描述如下:


(1)需求

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

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

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

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


(2)横竖屏切换

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

通过在 AndroidManifest.xml 中设置:

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

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

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

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


方式 2)

在代码中设置:

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

(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);    设置竖屏;

b)setRequestedOrientation

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

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


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


1)直板屏:

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


2)折叠屏:

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


3)PAD 端:

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


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

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



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


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


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


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


核心代码如下:

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


(4)apk 的数量


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


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


3.3.2 一套物料配所有机型


1、等比放大物料

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

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

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

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



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



2、高度不变,裁剪物料


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

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

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

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


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



3.3.4 无感刷新


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

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

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

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



3.4 方案落地实战


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



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


3.4.1 UI 如何设计的



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


3.4.2 不同设备的区分方式


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


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

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

1)分辨率

2)机型

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


先说结论:

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

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

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


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

不同设备的区分方式。

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


3.4.3 实现方案


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


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

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


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


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


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


(2)内容区域


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


每个子楼层

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


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


(3)数据源


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


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

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

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


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


子楼层的数据源解析

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


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


(4)屏幕类型切换

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


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

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


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


四、至简之路的铸就


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


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


1、文字显示区域放大

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


2、弹框样式的兼容

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


3、摄像头位置的处理

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



五、我们摆脱困扰了吗


5.1 解决原先的问题


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


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

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


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

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


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

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


5.2 我们还可以做什么


5.2.1 我们的优点


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


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

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


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

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


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

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

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


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


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

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


2、全场景适配。

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


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

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


5.2.2 我们还有什么要改进


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


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

收起阅读 »

奇葩!一公司面试题竟问如厕习惯、吃饭时长、入睡时间等

为了更好地了解求职者的个人情况,公司面试官在与求职者交流之前,往往会让他们填写一些面试题。但最近有网友曝光了长沙一家公司的面试题,题目奇葩、详细到让人感到很“惊悚”。 1 面试题涉及个人隐私,公司:可填可不填据@正观视频报道,这家公司一共设置了15道题,包含哲...
继续阅读 »

为了更好地了解求职者的个人情况,公司面试官在与求职者交流之前,往往会让他们填写一些面试题。但最近有网友曝光了长沙一家公司的面试题,题目奇葩、详细到让人感到很“惊悚”。

1 面试题涉及个人隐私,公司:可填可不填

据@正观视频报道,这家公司一共设置了15道题,包含哲学、数学、日常生活等方面。其中提到了吃饭时长、入睡时间、如厕习惯等问题,甚至详细到“日常如大厕一般在家还是在外、有无规律、用时多久”等涉及个人隐私的内容。


招聘员工需要了解这么多个人隐私内容吗?不少网友表示实在是匪夷所思:

  • “这是在想怎么压榨员工的吧”

  • “吃饭睡觉都问,你没事吧”

  • “还问这些……什么公司哦!奇葩”

据记者从涉事公司了解到,这是一家商务信息咨询公司,主要从事汽车后市场领域。一名工作人员表示,该面试题是通用测试题,没有标准答案。一个人日常的生活行为肯定与其工作的行为息息相关(比如生活习性、行为逻辑和思维架构等),公司希望通过这些题从不同维度了解一个人,综合得出其个人基本情况。

当被问及题目是否涉及隐私时,工作人员回复称,面试者可以填也可以不填,把这些题往“个人隐私”上扣的话,“只有没事做、或者矫情,生活中不怎么样的人才会这样想,现实中没人会关心陌生人这些信息。”公司这套题下来最终是为了达到一个效果,让员工能够以自我驱动,不需要公司真正的管理。

工作人员还表示,面试者普遍对这套题持欢迎态度,也有极个别人觉得题目与自己的技术和工作没有关系。对于公司这一做法,有律师表示,求职者在应聘时遇到涉及隐私的不当问题,有权拒绝回答。

2 程序员面试时遇到过的奇葩问题

除了像这家公司让面试者填写涉及个人隐私问题的操作外,在广大程序员群体中,也有不少人分享了自己在面试时遇到的奇葩问题:

  • “面试时被问为什么电脑屏幕是方的,而不是其他形状?”

  • “面试XX公司安卓岗,面试官叫我写出某段JS代码的机器码”

  • “面试初创公司,HR丢了一支笔和一张A4,让我一个小时写出一个APP,还说简单点就可以。”

  • “面试找实习,问我开发一个程序和种苹果有什么关系……”

当然,对于程序员来说,也许最讨厌的就是被问“会不会修电脑”。

最后,你怎么看待这家公司的面试题?你在面试时有遇到过什么奇葩问题吗?欢迎留言~

参考链接:

来源:程序人生

收起阅读 »

对移动端app容灾的思考

移动端app容灾 可能很多人对这个概念比较陌生,我们常说的容灾策略,一般都特指服务器端的容灾,那么移动端容灾是个啥!其实跟服务器一样,就是持续保证我们app的可用性,在crash或者anr的时候,能够通过一些手段实现保证后续可用。 本篇不涉及复杂技术,更多的是...
继续阅读 »

移动端app容灾


可能很多人对这个概念比较陌生,我们常说的容灾策略,一般都特指服务器端的容灾,那么移动端容灾是个啥!其实跟服务器一样,就是持续保证我们app的可用性,在crash或者anr的时候,能够通过一些手段实现保证后续可用。


本篇不涉及复杂技术,更多的是对方案的探讨,请放心食用!


为什么会有这个概念


其实在笔者角度上看,技术与业务的关系其实是比较单一的,虽然不至于对立,但是一个业务人员看待技术,最关心的可能就是稳定性了,在“老板”角度上看,他其实不太关心所用的技术是什么,但是一定关心这个服务能不能保证自己的业务能不能持续,这也是笔者访谈了几位非技术人员得出的结论,同时在“降本增效”的今天,追求稳定性可能是大部分公司的选择了。还有就是站在长远立场上看,移动端的容灾也慢慢会成为各大公司角逐的一个点。一个由于crash导致而离开的用户,就有可能带走10个相关联客户,在app场景如此,在游戏场景也是,如果打着游戏突然闪退了,肯定是一个非常不好的体验。


本文希望介绍一些移动端的容灾策略,希望能够给各大开发者提供一个启发。


容灾策略


降级


首先是第一个策略,降级,比如app crash的时候,我们采用降级的手段,转移到h5页面


image.png


这个方案的特点是 存在两套页面,一个是原生页面,一个是h5页面,大部分公司可能都会同时有这两套ui,一个用于投放app,一个用于h5页面,比如网页还有m站这些。
当主页(也可以是特定activity),跳转到其他页面时,如果发生了crash,就从主页直接打开h5容器,展示h5页面,这个在拼*多app方案上常用


进程多活


android在多进程上给我们提供了很多便捷的地方,只需要在activity或者其他的manifest文件上声明process即可


android:process=":test"

一般我们不做特殊配制的话,activity等就是运行在以包名为名称的进程上。这里的多进程方案有两个



  • app crash的时候,通过安全气囊机制,重新为用户打开到当前页面,即我们会杀掉原本的进程,重新打开一个新进程,并为用户定位到当前页,可以携带本地的tag或者其他标识进行页面的定位,这个方案可以运用在游戏中,如果crash了立马主动帮用户重开,并提高这部分用户的载入速度!


image.png



  • (这也是我最推荐的)app crash/anr的时候,不重新进入原页面,而是通过安全气囊机制,打开一个纯净版的链路这个链路是怎么理解呢?这里特指是业务简单的链路,即满足用户最基本需求的链路。比如说我们有一个商城app,那么下单就是最关键的链路,我们只需要在app crash的时候,打开一个业务最简单的页面,让用户去操作即可,这样就避免二次可能产生的crash!


image.png


强制升级


如果某个用户在app的crash次数达到一定时,就直接采取强制升级的方案,让用户的app始终保持最新版本,避免由于老版本的影响导致这部分的用户流失。这个方案的实现可直接对接到app内的升级策略


脏数据清除


有一些crash可能是由于用户本地的脏数据引起而导致的,那么我们可以在crash的时候,把这部分数据清除,或者简单来说直接清除所有缓存,这种“重置”操作会一定程度上避免由于脏数据等特定crash的发生,比较适用于线上存在脏数据用户的情况。


安全气囊机制


可以看到,无论是哪一个方案,我们都需要依靠crash/anr检测的机制,才能够实现,没关系,相关的文章早已准备好黑科技!让Native Crash 与ANR无处发泄!,同时也配备了开源库Signal,运用Signal,我们可以实现很多crash后的安全措施,也希望大家运行起来demo,尝试一下各种脑洞大开的方案!


让业务能够持续稳定下去,降低由于异常导致的损失,这是笔者一开始想要实现的,当然,目前我们的库还在不断完善的过程中,也希望广大开发者能够加入进来,一起去探索一个新方向!


最后


当然,一个app好坏大部分责任在于产品的选择,赛道的选择!能否提供一个好的服务给用户,才是决定一款app好坏的标准!我们技术能做的,就是不断突破场景的限制,给产品提供好的工具啦!


本来决定要分享asm相关的,但是在洗澡的过程中发现其实很多对服务器端的容灾策略的思想也是可以在移动端上去进行的,在app的业务迭代过程中,一定会对稳定性造成很多挑战,在各大公司人员缩减的背景下更是如此,所以说,建立一套安全气囊装置,一定会是后面多个公司的探索方向!


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

你真的敢落地Flutter桌面端吗?

如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题:1. 应用窗口化,提供窗口操作的能力;2. 实现多窗口;3. 对外设的支持。前言首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达...
继续阅读 »

如果你想用Flutter技术在桌面端落地,从技术上来讲,你必须解决这三大难题:
1. 应用窗口化,提供窗口操作的能力;
2. 实现多窗口;
3. 对外设的支持。

前言

首先给个结论,Flutter在桌面端落地,完全是可行的;但生态远没有官方所说的那么完善,我甚至认为其达不到stable的标准
目前我们的桌面设备主要有Windows、Android系统,系统不同但UI一致,我们将在这两个平台上解决以上问题,并落地Flutter。

一、窗口化和窗口操作存在的问题

  1. 实现应用窗口化:即应用是窗口化展示的,同时可拖拽、可以点击应用外的地方
    Flutter Windows本身是窗口化的;
    而Android默认是全屏应用,需要让普通应用支持窗口化;若是小工具性质的应用,还需要支持可拖拽、可点击应用外的地方,这些在Flutter上都是需要我们在原生实现的。
  2. 实现应用窗口化后,一般开发过程中,肯定会需要以下对窗口的操作:
    • 应用窗体圆形、阴影效果;
    • 配置应用初始的显示位置;(很多小工具可能不是居中展示)
    • 从窗口变为全屏、从全屏变为窗口;
    • ......

二、支持多窗口

目前Flutter是明确不支持多窗口的。官方好像对多窗口不太感兴趣,一直没有把优先级提上来,还是停留在p4级别,具体见issue
但是作为桌面应用,多窗口的需求是非常普遍的,因此这个技术壁垒是必须打破的。

三、窗口化实现方案

1. Windows端

Windows端Flutter默认支持窗口化,交互方式基本符合习惯,因此无需再做开发。

2. Android端

  • Android普通应用实现窗口化,是把整个应用展示成窗口的效果,但是点击外部窗口外的地方其实是不响应。 同一时间只能显示一个应用进程,这是安卓的机制,也保证了其安全性。要实现窗口化,需要把应用Theme设置成Dialog的样式;同时设置窗口全屏,但是背景色为透明,设置点击外部Dialog不消失,即可实现应用的窗口化展示。

    1. 设置主题

      <style name="Theme.DialogApp" parent="Theme.AppCompat.Light.Dialog"> 
      <item name="android:windowBackground">@drawable/launch_application</item>
      <item name="android:windowIsTranslucent">true</item>
      <item name="android:windowContentOverlay">@null</item>
      <!-- 不显示遮罩层 -->
      <item name="android:backgroundDimEnabled">false</item>
      <item name="windowActionBar">false</item>
      <item name="windowNoTitle">true</item>
      </style>
      <activity
      android:name=".MainActivity"
      android:exported="true"
      android:hardwareAccelerated="true"
      android:launchMode="singleTop"
      android:theme="@style/Theme.DialogApp"
      android:windowSoftInputMode="adjustResize"> <meta-data
      android:name="io.flutter.embedding.android.NormalTheme"
      android:resource="@style/Theme.DialogApp" />
      <intent-filter>
      <action android:name="android.intent.action.MAIN" />
      <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
      </activity>
    2. 设置窗口全屏,但是背景色为透明,点击外部Dialog不消失

      class MainActivity : FlutterActivity() {
      // 设置窗口背景透明
      override fun getTransparencyMode(): TransparencyMode {
      return TransparencyMode.transparent
      }
      override fun onResume() {
      super.onResume()
      // 点击外部,dialog不消失
      setFinishOnTouchOutside(false)
      // 设置窗口全屏
      var lp = window.attributes
      lp.width = -1
      lp.height = -1
      window.attributes = lp
      }
      }
    3. 到这里原生提供给Flutte一个全屏的透明窗体,那么Flutter的视图想长成啥样都可以

  • 若是小工具之类的,需要实现应用可拖拽,可点击应用区域外,这在android的实现相对复杂。我们利用原生的窗口管理,弹出一个悬浮框,然后通过entry-point 找到Flutter层的UI。这其实就是我们实现多窗口的思路,这里就不单纯讲解,跟着后面一起讲了。

窗口化操作

实现窗口化后,需要做很多相关的操作,我们分两个系统讲。

1. Windows端

  • 应用窗体圆形、阴影效果:通过window_manager插件,让应用背景色透明;然后我们在MaterialApp外面套一层Container可以设置圆角和阴影,再在外面加一次Container,加入padding以展示内层容器的阴影;
  • 小工具配置初始位置:通过window_manager插件的setPosition可以设置位置;
  • 从窗口变为全屏、从全屏变为窗口:通过window_manager插件可以实现全屏和退出全屏,在切换的过程中页面会闪烁,解决思路是:把透明度设置为0 → 全屏 → 透明度恢复为1。设置透明度的方法也由window_manager插件提供。

2. Android端

对于普通应用,我们上面实现窗口化后,原生就已经为Flutter提供了一个透明的全屏窗口,因此任何窗体的操作都是Flutter层去实现的,没啥技术难度。

  • 应用窗体圆形、阴影效果:上面我们实现应用窗口后,其实整个应用窗体的背景色就是透明的了,因此我们比Windows少做了背景色透明这一步,然后后面的Container都是通用的,代码达到多平台复用;
  • 小工具配置初始位置:直接通过Stack和Positioned来配置就行了。但这种场景一般使用悬浮弹框做,设置定位见后面多窗口;
  • 从窗口变为全屏、从全屏变为窗口:Android依然很简单,只需要在全屏的时候把整个Flutter窗口的padding去除,恢复的时候加上就可以了。

多窗口的实现

首先明确一个观点,Flutter应用是基于Flutter engine,由原生提供的一个Surface画布,在这个画布上面用Skia 2绘制Flutter Widget。
也就是说本身这个应用就是一个窗口,它绝对没有能力为自己再创建一个窗口。 所以多窗口的实现,需要依赖于原生的窗口管理。下面是Android端的实现原理图,这个原理适用于任何平台。 

  • 原生新建一个Flutter engine,通过dart执行器DartExecutor执行方法executeDartEntrypoint,根据传入的字符串找到对应的方法入口点Entrypoint,从而拿到Flutter widget;
  • Flutter在方法上声明@pragma('vm:entry-point') 后,此方法即便在Flutter项目没有被调用到,也能编译进去,因此原生新的engine就能找到这个切入点,拿到方法返回的widget;

这是非常典型的Flutter玩法,诸如混合开发都是如此。带来的影响是存在多引擎(engine),增加一些内存,但是这个不可避免,除非你定制Flutter引擎. 目前pub上支持多窗口的库也都是这个原理,但是库的质量其实不高,大家还是自己写吧。

实现步骤

  1. Plugin与原生通信,由于操作都是异步的,所以务必使用双向通信机制BasicMessageChannel,而且需要两个通道:主应用与子窗口通道
  2. 定义接口协议,一般至少需提供以下能力
// 主应用打开子窗口
void open(String entryPoint, Size size, GravityConfig? gravityConfig,
bool draggable);

// 主应用关闭子窗口
void close();

// 主应用设置大小
void resize(int width, int height);

// 主应用设置位置
void setPosition(int x, int y);

// 子窗口启动app,需要支持后台唤起以及命令行启动
void launchApp();

// 子窗口自行关闭
void closeByWindows();

// 子窗口设置大小
void resizeByWindows(int width, int height);

// 子窗口设置位置
void setPositionByWindows(int x, int y);
  1. 各端实现,下面贴下Android端的关键代码
  • 新建Flutter engine,找到Dart中的方法,此时engine就拿到了Flutter的widget实例;

    engine = FlutterEngine(application)
    val entry = intent.getStringExtra("entryPoint") ?: "multiWindow"
    val entryPoint = DartExecutor.DartEntrypoint(findAppBundlePath(), entry)
    engine.dartExecutor.executeDartEntrypoint(entryPoint)
  • 新建窗口管理类,通过FlutterViewe吸附engin,然后渲染到悬浮框的view上

    ///......
    private var windowManager = service.getSystemService(Service.WINDOW_SERVICE) as WindowManager
    ///......
    windowManager.addView(rootView, layoutParams)
    ///......///......
    flutterView = FlutterView(inflater.context, FlutterSurfaceView(inflater.context, true))
    flutterView.attachToFlutterEngine(engine)
    ///......
    engine.lifecycleChannel.appIsResumed()
    ///......
    rootView.findViewById<LinearLayout>(R.id.floating_window)
    .addView(
    flutterView,
    ViewGroup.LayoutParams(
    ViewGroup.LayoutParams.MATCH_PARENT,
    ViewGroup.LayoutParams.MATCH_PARENT
    )
    )
  1. 实现悬浮框后,Android平台上的桌面小工具,也就顺利成章的实现了,只是在小工具这个项目的MainAcitivy上,就不需要去加载FlutterActivity了,直接启动悬浮框即可。

外设支持

usb设备在Flutter上,支持也是非常若的。具体可见我上一篇文章:Flutter桌面端实践之识别外接媒体设备

写在最后

以上是我在桌面端预研Flutter的一些经验和思路分享,如果你想在桌面端落地Flutter,我想这边文章对你是很有帮助的。
以上问题,我们遇到了,也解决了。但转念一想这么多基础的操作Flutter都不支持,这真的可以称得上Stable版本了吗?
Flutter桌面端的生态,急需我们共同建设,文中多次提起的window_manager插件就是国内出色的组织:LeanFlutter 提供的,期待Flutter桌面端越来越好!


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

收起阅读 »

Android性能优化之APK瘦身详解(瘦身73%)

公司项目在不断的改版迭代中,代码在不断的累加,终于apk包不负重负了,已经到了八十多M了。可能要换种方式表达,到目前为止没有正真的往外推过,一直在内部执行7天讨论需求,5天代码实现的阶段。你在写上个版本的内容,好了,下个版本的更新内容已经定稿了。基于这种快速开...
继续阅读 »

公司项目在不断的改版迭代中,代码在不断的累加,终于apk包不负重负了,已经到了八十多M了。可能要换种方式表达,到目前为止没有正真的往外推过,一直在内部执行7天讨论需求,5天代码实现的阶段。你在写上个版本的内容,好了,下个版本的更新内容已经定稿了。基于这种快速开发的现状,我们app优化前已经有87.1M了,包大了,运营说这样转化不高,只能好好搞一下咯。优化过后包大小为23.1M(优化了73%,不要说我标题党)。好了好了,我要阐述我的apk超级无敌魔鬼瘦身之心得了。


文章主要内容从理论出发,再做实际操作。分为下面几个方面:



  1. 结构分析

  2. 具体实操

  3. 总结

  4. 参考资料



1. 结构分析


首先上传一张瘦身前通过Analyze app分析出来的图片(打开方式:Android Studio下 ——> Build——> Analyze app):


这里写图片描述


APK包结构如下:



  1. lib/:包含特定于处理器软件层的编译代码。该目录包含了每种平台的子目录,像armeabi,armeabi-v7a, arm64-v8a,x86,x86_64,和mips。大多数情况下我们可以只用一种armeabi-v7a,后面会讲到原因。

  2. assets/:包含应用可以使用AssetManager对象检索的应用资源。

  3. res/:包含未编译到的资源 resources.arsc,主要有图片资源文件。

  4. META-INF/:包含CERT.SF和 CERT.RSA签名文件以及MANIFEST.MF 清单文件。

  5. resources.arsc:包含已编译的资源。该文件包含res/values/ 文件夹所有配置中的XML内容。打包工具提取此XML内容,将其编译为二进制格式,并将内容归档。此内容包括语言字符串和样式,以及直接包含在resources.arsc文件中的内容路径 ,例如布局文件和图像。

  6. classes.dex:包含以Dalvik / ART虚拟机可理解的DEX文件格式编译的类。

  7. AndroidManifest.xml:包含核心Android清单文件。该文件列出应用程序的名称,版本,访问权限和引用的库文件。该文件使用Android的二进制XML格式。


通过分析图可以知道,目前app主要是so文件占比比较大,占了31.7M,占了整个应用是38.2%。其次是assets目录,整个目录占了32M,第三就是资源文件res目录了。所以接下来我们处理步骤就是按这个顺序来处理。(简单说下图中的Raw File Size(磁盘解压后的大小)和DownLoad Size(从应用商店下载的大小),如果想了解更多关于Analyaer分析的知识,可以参考这篇文章使用APK Analyzer分析你的APK),分析了包结构组成之后,我们可以开始瘦身操作了。


2.具体实操


1. 对lib目录下的文件进行瘦身处理


1. 修改lib配置:


参考资料
so文件的优化:通常我们在使用NDK开发的时候,我们经常会有如下这么一段代码:


ndk {
//设置支持的so库架构
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64", "armeabi"
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xNDtH3zt-1571353784450)(upload-images.jianshu.io/upload_imag…)]


最后我的修改代码如下:


ndk 	{
//设置支持的so库架构
abiFilters "armeabi-v7a"
}

接下来说明这么做的依据:
看上面图分析,armeabi-v7主要不支持ARMv5(1998年诞生)和ARMv6(2001年诞生).目前这两款处理器的手机设备基本不在我公司的适配范围(市场占比太少)。
而许多基于 x86 的设备也可运行 armeabi-v7a 和 armeabi NDK 二进制文件。对于这些设备,主要 ABI 将是 x86,辅助 ABI 是 armeabi-v7a。
最后总结一点:如果适配版本高于4.1版本,可以直接像我上面这样写,当然,如果armeabi-v7a不是设备主要ABI,那么会在性能上造成一定的影响。
参考文章:安卓app打包的时候还需要兼容armeabi么?


好了,我们再打一次包试试。
这里写图片描述


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xv3lhgYo-1571353784451)(upload-images.jianshu.io/upload_imag…)]
确实有点震惊,一下子包小了这么多,从87.1M到51.9M,容我好好算算少了多少M.赶快让测试帮忙测一下。基于之前的理论知识,心里还是有点底。果然,测试效果和之前是一样的。心里的石头先落下罗。


2. 重新编译so文件,用更小的库代替


相信很多开发者都有这种苦恼,很多第三方我们导入进来只用到其中很小一部分功能,大部分功能都是我们用不上的。这时候我们找到源代码,将我们需要的那部分代码提取出来,重新编译成新的so文件,再导入到我们项目中。当然,如果之前没有编译过so文件,这部分建议做最后的优化去处理。不然你会遇到很多问题。上一波处理后的效果图:


这里写图片描述
这里说下,因为项目中有使用到ffmpeg库,之前导入的第三方的放在assets文件夹下,重写编写后的so库文件放在lib文件夹下,所以lib文件夹反而大了。从51.9M到35.6M,效果还是蛮不错的。


对了,别问我为什么assets文件夹下为什么还有12.6M资源,因为很多.mp3都是第三方的人脸识别必备配置文件,我也很无奈。


这里写图片描述


2. 优化res,assets文件大小


1. 手动lint检查,手动删除无用资源


在Android Studio中打开“Analyze” 然后选择"Inspect Code...",范围选择整个项目,然后点击"OK"。配置如下:


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aczX7vG1-1571353784454)(upload-images.jianshu.io/upload_imag…)]


2. 使用tinypng等图片压缩工具对图片进行压缩。


打开网址,将大图片导入到tinypng,替换之前的图片资源。


3. 大部分图片使用Webp格式代替。


可以给UI提要求,让他们将图片资源设置为Webp格式,这样的话图片资源会小很多。如果想了解更多关于webp,请点击这里webp,当然,如果对图片颜色通道要求不高,可以考虑转jpg,最好用webp,因为效果更佳。


4. 尽量不要在项目中使用帧动画


一个帧动画几十张图片,再怎么压缩都还是占很大内存比重的。所以建议是让UI去搞,这里可以参考使用lottie-android,如果项目中动画效果多的话效果更加明显。当然这就要辛苦我们UI设计师大大了。


5. 使用gradle开启shrinkResources


移除无用资源文件,下面是我的配置:


 buildTypes {
release {
// 不显示Log
buildConfigField "boolean", "LOG_DEBUG", "false"
//混淆
minifyEnabled true
// 移除无用的resource文件
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}

通过上述步骤操作,apk效果如下:


这里写图片描述


又优化了将近5M,别问我为什么还有7.5M,里面大量的gif和webp格式的动图,都是UI丢给我的,一个2.7M.后面再慢慢和他细究这个问题。后面要做的两部分,一部分是将资源文件下的所有gif图放后台下载处理,第二个是和UI讨论下如何减小webp 动图的大小(我看其他平台只有100K的样子,给我的就2.7M?)。


3. 减少chasses.dex大小


classes.dex中包含了所有的java代码,当你打包时,gradle会将所有模板力的.class文件转换成classes.dex文件,当然,如果方法数超过64K,将要新增其他文件进行存储。可以通过multidexing分多个文件,比如我这里的chasses2.dex。换句话说,就是减少代码量。我们可以通过以下方法来实现:



  1. 尽量减少第三方库的引用,这个在上面我们已经做过优化了。

  2. 避免使用枚举,这里特别去网上查了一下,具体可以参考下这篇文章Android 中的 Enum 到底占多少内存?该如何用?,得出的结论是,可能几十个枚举的内存占有量才相当一张图片这样子,优化效果也不会特别明显。当然,如果你是个追求极致的人,我不反对你用静态常量替代枚举。

  3. 如果你的dex文件太大,检查是否引入了重复功能的第三方库(图片加载库,glide,picasso,fresco,image_loader,如果不是你一个人单独开发完成的很容易出现这种情况),尽量做到一个功能点一个库解决。


关于classes.dex文件大小分析可以参考这篇译文使用 APK Analyzer 分析你的 APK


4. 其他



  1. 删除无用的语7zip代替

  2. 删除翻译资源,只保留中英文

  3. 尝试将andorid support库彻底踢出你的项目。

  4. 尝试使用动态加载so库文件,插件化开发。

  5. 将大资源文件放到服务端,启动后自动下载使用。


3. 总结


好了,说道这里基本上就结束了,apk包从87.1M减小到了23.1M(优化了73%,不要说我标题党)已经差不多了,关于第四部其他部分的优化我是没有进行再操作的。因为公司运营觉得二三十M的包比较真实,太小了就太假了。所以我暂时就不进行优化了。如果再上面提到的部分通过所有将所有非启动页面首页之外的所有资源,so库放服务端,理论上apk包大小能在10M以内这样子。当然我们有做到就不多加评价了。最后,如果对插件化开发感兴趣的话可以参考下这篇文章Android全面插件化方案-RePlugin踩坑。最后,如果你在Android上有什么疑问,可以添加我的同名微信公众号「aserbaocool」和我一块交流。


4. 参考资料:


文章主要参考文章如下,文章有少部分文字参考了下面文章中的语句。如果有侵犯到作者权益,请和我联系,查实后马上删除。



  1. Android APK 瘦身 - JOOX Music项目实战

  2. APK 瘦身记,如何实现高达 53% 的压缩效果

  3. 使用APK Analyzer分析你的APK

  4. 安卓app打包的时候还需要兼容armeabi么?

  5. 百度百科webp

  6. Android 中的 Enum 到底占多少内存?该如何用?

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

Android动态更换应用图标

一、背景 近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还...
继续阅读 »

一、背景


近日,微博官方发布了一项新功能,即可以在App设置中动态更换微博的显示图标样式。根据微博官方的说法,除了最原始的图标外,微博还推出了另外10种不同的样式,既有3D微博、炫彩微博等保留了眼睛造型的新样式,也有奶酪甜馨、巧克力等以食物命名的“新口味”,还有梦幻紫、幻想星空等抽象派新造型,给了微博用户多种选择的自由。


不过需要注意的是,这一功能并不是面对所有人开放的,只有微博年费会员才能享受。此外,iOS 10.3及以上和Android 10及以上系统版本支持该功能,但是iPad与一加8Pro手机无法使用该功能。因部分手机存在系统差异,会导致该功能不可用,微博方面后续还会对该功能进行进一步优化。


image.png


二、技术实现


其实,说到底,上述功能用到的是动态更换桌面图标的技术。如果说多年以前,实现图标的切换还是一种时髦的技术,那么,我们可以直接使用PackageManager就可以实现动态更换桌面图标。


实现的细节是,在Manifest文件中使用标签准备多个Activity入口,没个activity都指向入口Activity,并且为每个拥有标签的activity设置单独的icon和应用名,最后调用SystemService 服务kill掉launcher,并执行launcher的重启操作。


首先,我们在AndroidManifest.xml文件中添加如下代码:


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

<!-- 权限-->
<uses-permission android:name="android.permission.KILL_BACKGROUND_PROCESSES"/>

<application
android:allowBackup="true"
android:icon="@mipmap/wb_default_logo"
android:label="@string/app_name"
android:roundIcon="@mipmap/wb_default_logo"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidDemo">

...//省略其他代码

<!-- 默认微博-->
<activity-alias
android:name="com.xzh.demo.default"
android:targetActivity=".MainActivity"
android:label="@string/app_name"
android:enabled="false"
android:icon="@mipmap/wb_default_logo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<!-- 3D微博-->
<activity-alias
android:name=".threedweibo"
android:targetActivity=".MainActivity"
android:label="@string/wb_3d"
android:enabled="false"
android:icon="@mipmap/wb_3dweibo"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

... //省略其他

</application>
</manifest>

上面配置中涉及到的属性如下:



  • android:name:注册的组件名字,启动组件的名称。

  • android:enabled:是否启用这个组件,也就是是否显示这个入口。

  • android:icon:图标

  • android:label:名称

  • android:targetActivity:默认的activity没有这个属性,指定目标activity,与默认的activity中的name属性是一样的,需要有相应的java类文件。


接着,我们在MainActivity触发Logo图标更换逻辑,代码如下:


class MainActivity : AppCompatActivity() {
var list: List<LogoBean> = ArrayList()
var recyclerView: RecyclerView? = null
var adapter: LogoAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initView()
initData()
initRecycle()
}
private fun initView() {
recyclerView = findViewById(R.id.recycle_view)
}
private fun initData() {
list = Arrays.asList(
LogoBean(R.mipmap.wb_default_logo, "默认图标", true),
LogoBean(R.mipmap.wb_3dweibo, "3D微博", false),
LogoBean(R.mipmap.wb_cheese_sweetheart, "奶酪甜心", false),
LogoBean(R.mipmap.wb_chocolate_sweetheart, "巧克力", false),
LogoBean(R.mipmap.wb_clear_colorful, "清透七彩", false),
LogoBean(R.mipmap.wb_colorful_sunset, "多彩日落", false),
LogoBean(R.mipmap.wb_colorful_weibo, "炫彩微博", false),
LogoBean(R.mipmap.wb_cool_pool, "清凉泳池", false),
LogoBean(R.mipmap.wb_fantasy_purple, "梦幻紫", false),
LogoBean(R.mipmap.wb_fantasy_starry_sky, "幻想星空", false),
LogoBean(R.mipmap.wb_hot_weibo, "热感微博", false),
)
}
private fun initRecycle() {
adapter =LogoAdapter(this,list);
val layoutManager = GridLayoutManager(this, 3)
recyclerView?.layoutManager = layoutManager
recyclerView?.adapter = adapter
adapter?.setOnItemClickListener(object : OnItemClickListener {
override fun onItemClick(view: View?, position: Int) {
if(position==1){
changeLogo("com.xzh.demo.threedweibo")
}else if (position==2){
changeLogo("com.xzh.demo.cheese")
}else if (position==3){
changeLogo("com.xzh.demo.chocolate")
}else {
changeLogo("com.xzh.demo.default")
}
}
})
}

fun changeLogo(name: String) {
val pm = packageManager
pm.setComponentEnabledSetting(
componentName,
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP
)
pm.setComponentEnabledSetting(
ComponentName(this, name),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP
)
reStartApp(pm)
}
fun reStartApp(pm: PackageManager) {
val am = getSystemService(ACTIVITY_SERVICE) as ActivityManager
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_HOME)
intent.addCategory(Intent.CATEGORY_DEFAULT)
val resolveInfos = pm.queryIntentActivities(intent, 0)
for (resolveInfo in resolveInfos) {
if (resolveInfo.activityInfo != null) {
am.killBackgroundProcesses(resolveInfo.activityInfo.packageName)
}
}
}
}

注意上面的changeLogo()方法中的字符串需要和AndroidManifest.xml文件中的<activity-alias>的name相对应。运行上面的代码,然后点击应用中的某个图标,就可以更换应用的桌面图标,如下图所示。


image.png


不过,测试的时候也遇到一些适配问题:



  • 小米9:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标消失。

  • magic 4:版本升级时,新版本在AndroidManifest中删除A3,老版本切换图标到A3,为卸载直接覆盖安装新版本,手机桌面图标切换到默认图标,但点击之后未能打开APP。

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

堪比坐牢!深圳一公司给每个工位都装监控,只为防止泄密?

近日,据@白鹿视频报道,网传深圳一家公司的办公室内,每个工位上都“一对一”安装了监控摄像头。 从爆料的图片可以看出,摄像头直对电脑屏幕,员工的操作可以被清晰拍到。            &n...
继续阅读 »

近日,据@白鹿视频报道,网传深圳一家公司的办公室内,每个工位上都“一对一”安装了监控摄像头。 


从爆料的图片可以看出,摄像头直对电脑屏幕,员工的操作可以被清晰拍到。



                             监控系游戏研发公司安装,防止员工泄密

随后有网友发现,图片中门上贴着的logo是一家科技公司,但当极目新闻记者联系到这家公司的负责人后,该负责人表示公司在今年4月就搬走了,摄像头并不是他们装的。门上的logo是搬走之后留下的装修,现在不知道租给了谁。


另据负责安装监控的师傅称,他6月份曾安装过一次,上周又安装了第二次,但不清楚具体是干什么的。

那监控到底是什么人安装的呢?记者从该公司所在的写字楼租赁处了解到,装监控的是一家5月份搬来的游戏研发公司。这家公司刚装修时,租赁处的工作人员就发现装了很多摄像头,还曾问过是不是为了防止员工摸鱼。当时一位工作人员解释称,公司是做游戏研发的,老板需要关注到游戏研发的每一个细节,在游戏还没上线的情况下,万一出现泄密会造成很大损失,公司也没法和投资人交代。

对于这一说法,网友们显然不能接受,认为就是换个说法在监控员工:

  • “这哪是打工,跟坐大牢一样!”

  • “高情商:防泄密;低情商:监控员工摸鱼”

  • “侵犯隐私权,你要是为了防止游戏泄密大可在电脑上监控,你搞个摄像头对准人什么意思啊”

  • “防泄密有很多技术手段可以用。通过安监控来防泄密,看来这家公司技术也不咋样!”

根据网传的最新消息,涉事公司还在筹建和装修阶段,尚未注册、没有具体名称、还未开始招聘员工和开展业务。据筹建方一位合伙人表示:“在办公公共区域安装摄像头的初衷是防止游戏在未发行之前泄露,监控内容也不会用于其他用途。如招聘员工,将事先征得员工同意。安装监控摄像头也花了2万余元,但因为现在引起部分网友误解,我们已经将它拆除了。”


监控员工手段层出不穷

在每个工位上都安装监控的做法让人大开眼界,但近年来,网上曝出各公司监控员工的手段可谓是层出不穷。

去年11月,国美一则对员工的通报火上热搜。通报显示,国美通过排查员工在工作时间使用公司公共网络资源的情况,通报了11名员工。让人吃惊的是,通报结果详细到在哪些软件分别使用了多少G流量。

今年4月,武汉一位网友爆料,称公司因为近期效益不好,领导要求员工下班前将手机电量消耗截图私发检查,查看员工各个APP的使用情况,防止员工在上班时摸鱼玩手机。对此,网友纷纷表示很无语,比装监控还可怕。

今年5月,在北京不少公司实行居家办公政策时,有网友称教育机构尚德要求员工连夜在电脑上安装监控软件。摄像头每5分钟抓拍一次人脸,如果几次抓拍不到,就要扣除全部绩效,领导和HR也跟着扣钱,甚至不够89次算旷工。对于这一规定,有员工表示“大家都不敢去上厕所”。

除此之外,还有公司搞出智能座椅,只要员工离开就开始计时;员工上厕所要手机扫码,门框上装着计时器,只要超时就通知领导……

虽然各大企业为了监控员工手段尽出,但正如职场上流传的一句话:“公司开始突然严抓纪律和考勤,是企业衰败的标志”。所以,与其想着怎么监控员工,不如想办法调动员工的工作积极性。

来源链接:

收起阅读 »

一个老程序员的30年生涯回顾(译文)

1、1967年,我13岁时开始学习编程。1988年,我正式进入了软件行业,通过编程养活自己。那一年,我34岁。2、1989年,我加入微软公司,那是微软为程序员提供单人办公室的最后一年。我们编程时,几乎没有干扰,这真是太好了。当时,微软的观念是必须为程序员创造不...
继续阅读 »

1、


1967年,我13岁时开始学习编程。

1988年,我正式进入了软件行业,通过编程养活自己。那一年,我34岁。

2、


1989年,我加入微软公司,那是微软为程序员提供单人办公室的最后一年。我们编程时,几乎没有干扰,这真是太好了。当时,微软的观念是必须为程序员创造不受打扰的环境,让他们全身心地投入工作。

3、


1990年5月,Windows 3.0 发布,公司出现了真正的变化。

突然之间,我与一个吸烟的同事共用一个办公室,他整天在电话里大声聊天。更糟糕的是,我们开始有更多的会议。

4、


接下来的20年,情况越变越糟。程序员像农奴一样被使用,许多人饱受压力、精疲力尽,每周工作70个小时以上。但是实际上,其中真正用来完成工作的时间只有4-6个小时,其余时间都为通过质量检查系统苦苦挣扎,设法应付各种质量措施。

5、


到了2009年,一切都变得混乱了。程序员对代码质量的热爱,完全被复选框式的机械处理取代了。在2008年末,我的主管要求我,代码都必须有单元测试,以便在系统中为该项目勾选"具有单元测试"的那个框。不久,他又要求我尝试"测试驱动的开发"(TDD)的新编程模式。

最后,当他们要求我做结对编程时,第二天我就因为愤怒而辞职了。

6、


离开微软后,我去了西雅图市中心的 Real Networks 公司工作。在西雅图,交通堵塞是一个大问题,我一般在早上高峰时间之后的9:30去上班,这样只要开车30分钟,就能到公司,还算不错。

7、


不久,我所在的团队开始尝试敏捷开发,每天早上8:30举行一次"站立会议"。这正好赶上早高峰,30分钟的通勤时间变成了90分钟,我必须在早上7:00出门才行。我几乎没有办法准时到达,并且感到非常疲倦。我询问是否可以稍微推迟会议。不行,你难道不知道站立会议必须在早晨举行吗?

为此,我只能(无偿地)多花了额外的时间开车去上班。

8、


这种会议真是很荒谬,每个程序员报告自己正在做的事情。大部分时候,我们做的事情跟昨天相同,偶尔会做一些新的事情,但没有什么特别可说的。会议上,产品经理会表现出生机勃勃、欢快愉悦的情绪,听起来很投入,而实际上我知道他们上班时很多时间都在脸书上玩游戏。

9、


许多次,我听到"故事"(Story)这个词。我问,"故事"是什么意思?回答是用户场景或者使用案例的新名称。随着我对敏捷开发的了解越多,遇到的重命名和名词重定义就越多。我看不出来这能对工作带来多少的新价值,唯一带来的就是更多的会议。

我建议不要使用"故事"这个名词,结果被冷淡地告知,"故事"是敏捷开发的一部分,我们将紧跟这种新的开发方法。

10、


我的原计划是,2019年65岁时退休,然后搬到东南亚国家享受退休生活。但是,经历过了沉闷的站立会议、白板上的迭代看板、一系列高压力的工作、对"故事"的不停谈论,我越来越对这个工作感到恶心。

2010年11月15日,56岁时,我退休了。

11、


我在越南买了一栋房子(上图),然后收拾行装,离开了美国。我非常喜欢这栋漂亮的新房子,准备在那边弹吉他,阅读物理书籍,体验截然不同的文化,放松身心。

12、


在越南过了一段日子以后,生活变得很闲,我只好把时间用来学越南语,否则就太无聊了。

13、


一位朋友建议我可以试试 iPhone 和 iPad 开发,软件工具是免费的。我怀念编程,就买了一台 MacBook,学习了 iOS、Objective-C 和 Xcode,很快就写出了一个可以出售的 App。我又回到了这个行业。

14、


2011年到2016年,我一开始为自己写 iOS 和 MacOS 应用程序,然后出售。这样也不错,但是我想挣更多的钱,就开始通过自由职业网站的中介,接一些客户的活。

15、


2017年,我获得了一家加利福尼亚公司的远程工作,为他们做服务器端开发。我学习了 C#、Entity 框架、ASP. NET。当推荐我的人离职了,我就接管了服务器端和数据库开发。这样已经持续了30个月。这是一段很棒的经历,让我掌握了一些最新技能,我喜欢服务器端和数据库编程。

这些时间我一直是一个人工作,但也是团队的一员。整个开发部门都是远程的,浏览器客户端开发人员在悉尼,我在越南。我们通过 RESTful API 协作,彼此都是独立工作。

16、


回顾我的30年程序员生涯,软件行业发生了翻天覆地的变化。

现在的软件业有更多时尚的行话和术语,比如用户故事、技术债务、敏捷、重构、迭代、里程碑等等。在我看来,所谓迭代,就是说这段时间你会过度劳累,没有其他含义。

奇怪的是,他们用各种办法监督程序员,但是招聘的时候,职位要求依然写着,需要具有独立工作精神、高度主动性的人。这真是讽刺。

17、


现在的软件业还流行开放办公室,这意味着完全不可能集中精力。你的工作被持续不断地打断,没法关门保持沉默和集中注意力。如果你戴着耳机,就意味着你的团队合作精神不够。

18、


最后,测试已经变味了。以前,我在微软公司,我们没有那么认真对待测试。微软经常开玩笑说,任何人都不应该使用偶数版本的软件,因为它是测试版,适合那些愿意向我们报告错误的客户。比如,请勿使用2.0版,因为2.1版将修复客户报告的所有2.0版的错误,至少是比较严重的错误。

现在的软件业提倡测试驱动开发这种荒谬方法。我在许多地方都读到,在软件开发中,没有什么比单元测试更重要了,甚至比交付成果的本身还要重要。单元测试是设计,是定义API的地方。测试覆盖率不到100%,就是存在欠缺,100%覆盖率是程序员的荣誉, 开发人员应该负责测试他们的产品。我们不再需要黑匣子测试流程,也不需要测试工程师。

我认为,这些态度充满了狂热主义。每个人都有盲点,总是会存在忽略编写测试的案例与忽略编写代码的案例。

19、


我喜欢编程,喜欢解决问题和开发功能,从小开始直到现在都是如此。

以前,我选择服从那些流行的做法,但是现在不会了。我不会在开放式办公室工作,不会持续一个星期听所谓的专业术语,不会将各种新词用来描述旧事物,不会结对编程,不会参加频繁的会议,不会在意对团队协作精神的要求,也不会嘲笑那些独自工作的人。

20、


我喜欢服务器端开发,未来希望还可以做这方面的工作。同时,我正在转向技术写作,学习远程工作所需的新技能。

我喜欢现在这种一点不疯狂的环境。

原文网址:hackernoon.com

作者:Chris Fox,翻译:阮一峰

收起阅读 »

程序员的酒后真言

美国最大的论坛 Reddit,最近有一个热帖。一个程序员说自己喝醉了,软件工程师已经当了10年,心里有好多话想说,"我可能会后悔今天说了这些话。"他洋洋洒洒写了一大堆,获得9700多个赞。内容很有意思,值得一读,下面是节选。(1)职业发展的最好方法是换公司。(...
继续阅读 »

美国最大的论坛 Reddit,最近有一个热帖

一个程序员说自己喝醉了,软件工程师已经当了10年,心里有好多话想说,"我可能会后悔今天说了这些话。"


他洋洋洒洒写了一大堆,获得9700多个赞。内容很有意思,值得一读,下面是节选。


(1)职业发展的最好方法是换公司。

(2)技术栈不重要。技术领域有大约 10-20 条核心原则,重要的是这些原则,技术栈只是落实它们的方法。你如果不熟悉某个技术栈,不需要过度担心。

(3)工作和人际关系是两回事。有一些公司,我交到了好朋友,但是工作得并不开心;另一些公司,我没有与任何同事建立友谊,但是工作得很开心。

(4)我总是对经理实话实说。怕什么?他开除我?我会在两周内找到一份新工作。

(5)如果一家公司的工程师超过 100 人,它的期权可能在未来十年内变得很有价值。对于工程师人数很少的公司,期权一般都是毫无价值。

(6)好的代码是初级工程师可以理解的代码。伟大的代码可以被第一年的 CS 专业的新生理解。

(7)作为一名工程师,最被低估的技能是记录。说真的,如果有人可以教我怎么写文档,我会付钱,也许是 1000 美元。

(8)网上的口水战,几乎都无关紧要,别去参与。

(9)如果我发现自己是公司里面最厉害的工程师,那就该离开了。

(10)我们应该雇佣更多的实习生,他们很棒。那些精力充沛的小家伙用他们的想法乱搞。如果他们公开质疑或批评某事,那就更好了。我喜欢实习生。

(11)技术栈很重要。如果你使用 Python 或 C++ 语言,就会忍不住想做一些非常不同的事情。因为某些工具确实擅长某些工作。

(12)如果你不确定自己想做什么东西,请使用 Java。这是一种糟糕的编程语言,但几乎无所不能。

(13)对于初学者来说,最赚钱的编程语言是 SQL,干翻所有其他语言。你只了解 SQL 而不会做其他事情,照样赚钱。人力资源专家的年薪?也许5万美元。懂 SQL 的人力资源专家?9万美元。

(14)测试很重要,但 TDD (测试驱动的开发)几乎变成了一个邪教。

(15) 政府单位很轻松,但并不像人们说的那样好。对于职业生涯早期到中期的工程师,12 万美元的年薪 + 各种福利 + 养老金听起来不错,但是你将被禁锢在深奥的专用工具里面,离开政府单位以后,这些知识就没用了。我非常尊重政府工作人员,但说真的,这些地方的工程师,年龄中位数在 50 岁以上是有原因的。

(16)再倒一杯酒。

(17)大多数头衔都无关紧要,随便什么公司都可以有首席工程师。

(18)手腕和背部的健康问题可不是开玩笑的,好的设备值得花钱。

(19)当一个软件工程师,最好的事情是什么?你可以结识很多想法相同的人,大家互相交流,不一定有相同的兴趣,但是对方会用跟你相同的方式思考问题,这很酷。

(20)有些技术太流行,我不得不用它。我心里就会很讨厌这种技术,但会把它推荐给客户,比如我恨 Jenkins,但把它推荐给新客户,我不觉得做错了。

(21)成为一名优秀的工程师意味着了解最佳实践,成为高级工程师意味着知道何时打破最佳实践。

(22)发生事故时,如果周围的人试图将责任归咎于外部错误或底层服务中断,那么是时候离开这家公司,继续前进了。

(23)我遇到的最好的领导,同意我的一部分观点,同时耐心跟我解释,为什么不同意我的另一部分观点。我正在努力成为像他们一样的人。

(24)算法和数据结构确实重要,但不应该无限夸大,尤其是面试的时候。我没见过药剂师面试时,还要测试有机化学的细节。这个行业的面试过程有时候很糟糕。

(25)做自己喜欢的事情并不重要,不要让我做讨厌的事情更重要。

(26)越接近产品,就越接近推动收入增长。无论工作的技术性如何,只要它接近产品,我都感到越有价值。

(27)即使我平时用 Windows 工作,Linux 也很重要。为什么?因为服务器是 Linux 系统,你最终在 Linux 系统上工作。

(28)人死了以后,你想让代码成为你的遗产吗?如果是那样,就花很多时间在代码上面吧,因为那是你的遗产。但是,如果你像我一样,更看重与家人、朋友和生活中其他人相处的时光,而不是写的代码,那就别对它太在意。

(29)我挣的钱还不错,对此心存感激,但还是需要省钱。

(30)糟糕,我没酒了。

来源:http://www.ruanyifeng.com/blog/2021/06/drunk-post-of-a-programmer.html

收起阅读 »

Swift 中的热重载

前言    这一年是2040年,我们最新的 MacBook M30X 处理器可以感知到瞬间编译大型 Swift 项目,听起来很神奇,对吧?除此之外,编译代码库只是我们迭代周期的一部分。包括:    1...
继续阅读 »

前言

    这一年是2040年,我们最新的 MacBook M30X 处理器可以感知到瞬间编译大型 Swift 项目,听起来很神奇,对吧?除此之外,编译代码库只是我们迭代周期的一部分。包括:
    1、重新启动它(或将其部署到设备)
    2、导航到您在应用程序中的先前位置
    3、重新生成您需要的数据。
    如果您只需要做一次的话,听起来还不错。但是如果您和我一样,在特别的一天中,对代码库进行 200 - 500 次迭代,该怎么办呢?它增加了。
    有一种更好的方法,被其他平台所接受,并且可以在 Swift/iOS 生态系统中实现。我已经用了十多年了。
    从今天开始,您想每周节省多达 10 小时的工作时间吗?


热重载

    热重载是关于摆脱编译整个应用程序并尽可能避免部署/重新启动周期,同时允许您编辑正在运行的应用程序代码并且能立即看到更改。
    这种流程改进可以每天为您节省数小时的开发时间。我跟踪我的工作一个多月,对我来说,每天节省了 1-2 小时。
    坦白地说,如果每周节省10个小时的开发时间都不能说服您去尝试,那么我认为任何方法都不能说服你。


其他平台在做什么?

    如果您只使用 Apple 平台,您会惊讶地发现有好多平台几十年前已经采用了热重载。无论您是编写 Node 还是任何其他 JS 框架,都有一个使用热重载的设置。Go 也提供了热重载(本博客使用了该特性)
    另一个例子是谷歌的 Flutter 架构,从一开始就设计用于热重载。如果您与从事 Flutter 工作的工程师交谈,你会发现他们最喜欢 Flutter 开发者体验的一点就是能够实时编写他们的应用程序。当我为《纽约时报》写了一个拼字游戏时,我很喜欢它。
    微软最近推出了 Visual Studio 2022,并为 .NET 和 标准 C++ 应用程序提供热重载,在过去的十年中,微软在开发工具和经验方面一直在大杀四方,所以这并不令人惊讶。


苹果生态系统怎么样?

    早在 2014 年推出时,很多人都对 Swift Playgrounds 感到敬畏,因为它们允许我们快速迭代并查看代码的结果,但它们并不能很好地工作,因为它存在崩溃、挂起等问题。不能支持整个iPad环境。
    在它们发布后不久,我启动了一个名为 Objective-C Playgrounds 的开源项目,它比官方 Playgrounds 运行得更快、更可靠。我的想法是设计一个架构/工作流程,利用我已经使用了几年的 DyCI 代码注入工具,该工具已经由 Paul 制作。
    自从 Swift Playgrounds 存在以来,已经过去了八年,而且它们变得更好了,但它们可靠吗?人们是否在使用它们来推动开发?

    SwiftUI 出现了,它是一项了不起的技术(尽管仍然存在错误),它引入了与 Playgrounds 非常相似的 Swift Previews 的想法,它们有什么好处吗?
    类似的故事,当它工作的时候是很好的,但是在更大的项目中,它的工作是不可靠的,而且往往中断的次数比它们工作的次数多。如果你有任何错误,他们不会为你提供调试代码的能力,因此,采用的情况有限。


我们需要等待 Apple 吗?

    如果你关注我一段时间,你就已经知道答案了,绝对不要。毕竟,我的职业生涯是构建普通 Apple 解决方案无法解决的问题:从像 Sourcery 这样的语言扩展、像 Sourcery Pro 这样的 Xcode 改进,再到 LifetimeTracker 以及许多其他开源工具。
    我们可以利用我最初在 2014 Playgrounds 中使用的相同方法。我已经使用它十多年了,并且在数十个 Swift 项目中使用它并取得了巨大的成功!
    许多年前,我从使用 DyCI 切换到 InjectionForXcode,通过利用 LLVM 互操作而不是任何 swizzling ,它的效果更好。它是一个完全免费的开源工具,您可以在菜单栏中运行,它是由多产的工程师 John Holdsworth 创建的。你应该看看他的书 Swift Secrets。
    我意识到 Playgrounds 的方法可能过于笨重,所以今天,我开源了。一个非常专注的名为 Inject 的微型库,与 InjectionForXcode 搭配使用时,将使您的 Apple 开发更加高效和愉快!
    但不要只相信我的话。看看 Alexandra 和 Nate 的反馈,在我将这个工作流程引入 The Browser Company 设置之前,他们已经非常精通了,这使得它更加令人印象深刻。


Inject

    这个小型库是完全通用的,无论您使用 UIKit、 AppKit 还是 SwiftUI,您都可以使用它。
    您无需为生产应用程序添加条件或删除 Inject 代码。它变成了无操作内联代码,将在非调试版本中被编译过程剥离。您可以在每个视图中集成一次,并持续使用数年。
    请参考 GitHub repo中关于配置项目的说明。现在让我们来看看您有哪些工作流程选项。


工作流

    SwiftUI
        只需要两行字就可以使任何 SwiftUI 启用实时编程,而当您这样做时,您将拥有比使用 Swift Previews 更快的工作流程,同时能够使用实际的生产数据。
        这是我的 Sourcery Pro 应用程序的示例,其中加载了我所有的实际数据和逻辑,使我能够即时快速迭代整个应用程序设计,而无需任何重新启动、重新加载或类似的事情。
        看看这个开发工作流程有多快吧,告诉我你宁愿在我每次接触代码时等待Xcode的重新构建和重新部署。


    UIKit / AppKit
        我们需要一种方法来清理标准命令式UI框架的代码注入阶段之间的状态。
        我创建了 Host 的概念并且在这种情况下工作的很好。有两个:

        - Inject.ViewHost
        - Inject.ViewControllerHost

        我们如何集成它?我们把我们想迭代的类包装在父级,因此我们不修改要注入的类型,而是改变父级的调用站点。
        例如,如果你有一个 SplitViewController ,它创建了 PaneA 和 PaneB ,而你想在PaneA 中迭代布局/逻辑代码,你就修改 SplitViewController 中的调用站点。

        paneA = Inject.ViewHost(
            PaneAView(whatever: arguments, you: want)
        )

        这就是你需要做的所有改变。注入现在允许你更改 PaneAView 中的任何东西,除了它的初始化API。这些变化将立即反映在你的应用程序中。


        一个更具体的例子?
        1、我下载了 Covid19 App
        2、添加 -Xlinker -interposable 到 Other Linker Flags
        3、交换了一行 Covid19TabController.swift:L63 行

        从这句:

        let vc = TwitterViewController(title: Tab.twitter.name, usernames: Twitter.content)

        替换为:

        let vc = Inject.ViewControllerHost(TwitterViewController(title: Tab.twitter.name, usernames: Twitter.content))

        现在,我可以在不重新启动应用程序的情况下迭代控制器设计。


这是如何运作的呢?

    Hosts 利用了自动闭包,因此每次您注入代码时,我们都会使用与最初相同的参数创建您类型的新实例,从而允许您迭代任何代码、内存布局和其他所有内容。你唯一不能改变的是你的初始化 API。


逻辑注入如何呢?

    像 MVVM / MVC 这样的标准架构可以获得免费的逻辑注入,重新编译你的类,当方法重新执行时,你已经在使用新代码了。
    如果像我一样,你喜欢 PointFree Composable Architecture,你可能想要注入 reducer 代码。Vanilla TCA 不允许这样做,因为 reducer 代码是一个免费功能,不能直接用注入替换,但我们在 The Browser Company 的分支 支持它。
    当我最初开始咨询 TBC 时,我想要的第一件事是将 Inject 和 XcodeInjection 集成到我们的工作流程中。公司管理层非常支持。
    如果您切换到我们的 TCA 分支(我们保持最新),你可以在 UI 和 TCA 层上使用 Inject 。


它有多可靠?

    没有什么是完美的,但我已经使用它十多年了。它比 Apple 技术(Playgrounds / Previews)可靠得多。
如果您投入时间学习它,它将为您和您的团队节省数千小时!

收起阅读 »

一些常见的HTTP返回码

一些常见的状态码为:·       200 – 服务器成功返回网页·       404 – 请求的网页不存在·&nbs...
继续阅读 »

一些常见的状态码为:

·      
200 – 服务器成功返回网页

·      
404 – 请求的网页不存在

·      
503 – 服务器超时

1xx(临时响应)

表示临时响应并需要请求者继续执行操作的状态码。











100(继续)



请求者应当继续提出请求。服务器返回此代码表示已收到请求的第一部分,正在等待其余部分。



101(切换协议)



请求者已要求服务器切换协议,服务器已确认并准备切换。


2xx (成功)

表示成功处理了请求的状态码。































200(成功)



服务器已成功处理了请求。通常,这表示服务器提供了请求的网页。如果是对您的 robots.txt 文件显示此状态码,则表示 Googlebot 已成功检索到该文件。



201(已创建)



请求成功并且服务器创建了新的资源。



202(已接受)



服务器已接受请求,但尚未处理。



203(非授权信息)



服务器已成功处理了请求,但返回的信息可能来自另一来源。



204(无内容)



服务器成功处理了请求,但没有返回任何内容。



205(重置内容)



服务器成功处理了请求,但没有返回任何内容。与 204 响应不同,此响应要求请求者重置文档视图(例如,清除表单内容以输入新内容)。



206(部分内容)



服务器成功处理了部分 GET
请求。


3xx (重定向) 

要完成请求,需要进一步操作。通常,这些状态码用来重定向。Google 建议您在每次请求中使用重定向不要超过 5 次。您可以使用网站管理员工具查看一下 Googlebot 在抓取重定向网页时是否遇到问题。诊断下的网络抓取页列出了由于重定向错误导致 Googlebot 无法抓取的网址。































300(多种选择)



针对请求,服务器可执行多种操作。服务器可根据请求者 (user agent) 选择一项操作,或提供操作列表供请求者选择。



301(永久移动)



请求的网页已永久移动到新位置。服务器返回此响应(对 GET HEAD 请求的响应)时,会自动将请求者转到新位置。您应使用此代码告诉 Googlebot 某个网页或网站已永久移动到新位置。



302(临时移动)



服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来响应以后的请求。此代码与响应 GET HEAD 请求的
301
代码类似,会自动将请求者转到不同的位置,但您不应使用此代码来告诉 Googlebot 某个网页或网站已经移动,因为 Googlebot 会继续抓取原有位置并编制索引。



303(查看其他位置)



请求者应当对不同的位置使用单独的 GET 请求来检索响应时,服务器返回此代码。对于除 HEAD 之外的所有请求,服务器会自动转到其他位置。



304(未修改)



自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。


如果网页自请求者上次请求后再也没有更改过,您应将服务器配置为返回此响应(称为 If-Modified-Since HTTP 标头)。服务器可以告诉
Googlebot
自从上次抓取后网页没有变更,进而节省带宽和开销。


.



305(使用代理)



请求者只能使用代理访问请求的网页。如果服务器返回此响应,还表示请求者应使用代理。



307(临时重定向)



服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来响应以后的请求。此代码与响应 GET HEAD 请求的
<a href=answer.py?answer=>301</a>
代码类似,会自动将请求者转到不同的位置,但您不应使用此代码来告诉 Googlebot 某个页面或网站已经移动,因为 Googlebot 会继续抓取原有位置并编制索引。


4xx(请求错误) 

这些状态码表示请求可能出错,妨碍了服务器的处理。







































































400(错误请求)



服务器不理解请求的语法。



401(未授权)



请求要求身份验证。对于登录后请求的网页,服务器可能返回此响应。



403(禁止)



服务器拒绝请求。如果您在
Googlebot
尝试抓取您网站上的有效网页时看到此状态码(您可以在 Google 网站管理员工具诊断下的网络抓取页面上看到此信息),可能是您的服务器或主机拒绝了 Googlebot 访问。



404(未找到)



服务器找不到请求的网页。例如,对于服务器上不存在的网页经常会返回此代码。


如果您的网站上没有 robots.txt 文件,而您在 Google 网站管理员工具诊断”标签的 robots.txt 上看到此状态码,则这是正确的状态码。但是,如果您有 robots.txt 文件而又看到此状态码,则说明您的 robots.txt 文件可能命名错误或位于错误的位置(该文件应当位于顶级域,名为 robots.txt)。


如果对于 Googlebot 抓取的网址看到此状态码(在诊断标签的 HTTP 错误页面上),则表示 Googlebot 跟随的可能是另一个页面的无效链接(是旧链接或输入有误的链接)。



405(方法禁用)



禁用请求中指定的方法。



406(不接受)



无法使用请求的内容特性响应请求的网页。



407(需要代理授权)



此状态码与 <a
href=answer.py?answer=35128>401
(未授权)</a>类似,但指定请求者应当授权使用代理。如果服务器返回此响应,还表示请求者应当使用代理。



408(请求超时)



服务器等候请求时发生超时。



409(冲突)



服务器在完成请求时发生冲突。服务器必须在响应中包含有关冲突的信息。服务器在响应与前一个请求相冲突的 PUT 请求时可能会返回此代码,以及两个请求的差异列表。



410(已删除)



如果请求的资源已永久删除,服务器就会返回此响应。该代码与 404(未找到)代码类似,但在资源以前存在而现在不存在的情况下,有时会用来替代
404
代码。如果资源已永久移动,您应使用 301 指定资源的新位置。



411(需要有效长度)



服务器不接受不含有效内容长度标头字段的请求。



412(未满足前提条件)



服务器未满足请求者在请求中设置的其中一个前提条件。



413(请求实体过大)



服务器无法处理请求,因为请求实体过大,超出服务器的处理能力。



414(请求的 URI 过长)



请求的 URI(通常为网址)过长,服务器无法处理。



415(不支持的媒体类型)



请求的格式不受请求页面的支持。



416(请求范围不符合要求)



如果页面无法提供请求的范围,则服务器会返回此状态码。



417(未满足期望值)



服务器未满足期望请求标头字段的要求。


5xx(服务器错误)

这些状态码表示服务器在处理请求时发生内部错误。这些错误可能是服务器本身的错误,而不是请求出错。



























500(服务器内部错误)



服务器遇到错误,无法完成请求。



501(尚未实施)



服务器不具备完成请求的功能。例如,服务器无法识别请求方法时可能会返回此代码。



502(错误网关)



服务器作为网关或代理,从上游服务器收到无效响应。



503(服务不可用)



服务器目前无法使用(由于超载或停机维护)。通常,这只是暂时状态。



504(网关超时)



服务器作为网关或代理,但是没有及时从上游服务器收到请求。



505HTTP 版本不受支持)



服务器不支持请求中所用的
HTTP
协议版本。














































 

收起阅读 »

安全对等问题:确保移动应用跨平台安全性

一段时间以来,人们都“知道”,作为移动平台的 Android 不如 iOS 安全,这已经成为常识。似乎除了消费者,每个人都知道。2021 年 8 月对 10000 名移动消费者进行的一项全球调查发现,iOS 和 Android 用户的安全期望基本一致。然而,尽...
继续阅读 »

一段时间以来,人们都“知道”,作为移动平台的 Android 不如 iOS 安全,这已经成为常识。似乎除了消费者,每个人都知道。2021 年 8 月对 10000 名移动消费者进行的一项全球调查发现,iOS 和 Android 用户的安全期望基本一致。

然而,尽管消费者有这样的期望,而且从本质上讲,一个移动平台并不一定比另一个平台更安全,但移动应用很少能实现 Android 和 iOS 的安全功能对等。事实上,许多移动应用甚至缺少最基本的安全保护措施。让我们看看这是为什么。

1

移动应用安全需要多层次防御

大多数安全专家和第三方标准组织都会同意,移动应用安全需要多层次防御,包括以下核心领域的多种安全特性:

  • 代码混淆和应用护盾(Application Shielding):保护移动应用的二进制文件和源代码,防止逆向工程。

  • 数据加密:保护应用中存储和使用的数据。

  • 安全通信:保护在应用和应用后端之间传递的数据,包括确保用于建立可信连接的数字证书的真实性和有效性。

  • 操作系统防护:保护应用免受未经授权的操作系统修改(如 rooting 和越狱)所影响。

开发人员应该在应用的 iOS 和 Android 版本中均衡地实现这些功能的组合,形成一致的安全防御。而且,他们应该在开发周期的早期添加这些功能——这个概念被称为安全“左移”。听起来很容易吧?理论上,是的,但在实践中,如果使用“传统”方法,要实现移动应用多层次安全防御实际上是相当困难的。

多年来,移动开发人员一直试图使用传统的工具集来实现应用内移动应用安全,包括第三方开源库、商业移动应用安全 SDK 或专用编译器。第一个主要的挑战是,移动应用的安全从来无法通过“银弹”实现。由于移动应用在不受保护的环境中运行,并存储和处理大量有价值的信息,有许多方法可以攻击它们。黑客有无穷无尽的、免费提供而又非常强大的工具集可以使用,而且可以全天候地研究和攻击应用而不被发现。

2

移动安全要求

因此,为了构建一个强大的防御体系,移动开发人员需要实施一个既“广”且“深”的多层次防御。所谓“广”,我指的是不同保护类别的多种安全特性,它们彼此相互补充,如加密和混淆。所谓“深”,我指的是每个安全特性都应该有多种检测或保护方法。例如,一个越狱检测 SDK 如果只在应用启动时进行检查,就不会很有效,因为攻击者很容易绕过。

或者考虑下反调试,这是一种重要的运行时防御,可以防止攻击者使用调试器来执行动态分析——他们会在一个受控的环境中运行应用,为的是了解或修改应用的行为。有许多类型的调试器——有一些基于 LLDB——是针对原生代码的,如 C++ 或 Objective C,其他的在 Java 或 Kotlin 层进行检查,诸如此类。每个调试器连接和分析应用的工作方式都略有不同。因此,为了使反调试防御奏效,应用需要识别正在使用的多种调试方法,并动态地进行恰当的防御,因为黑客会继续尝试不同的调试工具或方法,直到他们找到一个可以成功的。

3

防篡改

安全要求清单并不仅限于此。每个应用都需要防篡改功能,如校验和验证、预防二进制补丁,以及应用的重新打包、重新签名、模拟器和仿真器,等等。毫不夸张,仅是针对一个操作系统研究和实现这些功能或保护方法中的一项,就需要至少几个人周的开发时间。而且还要有一个前提,就是移动开发人员已经拥有特定安全领域的专业知识,但情况往往并非如此。复杂度可能会快速增加,到目前为止,我们只讨论了一个保护类别——运行时或动态保护。想象一下,如果提到的每个功能都需要一到两周的开发时间,那么实现全部安全特性得付出多大的时间成本。

4

防越狱 /Rooting

接下来,你还需要操作系统层面的保护,如防越狱 /rooting,在移动操作系统遭破坏的情况下保护应用。越狱 /rooting 使移动应用容易受到攻击,因为它允许对操作系统和文件系统进行完全的管理控制,破坏了整个安全模型。而且,仅仅检测越狱 /rooting 已经不够了,因为黑客们一直在不断地改进他们的工具。要说最先进的越狱和 rooting 工具,在 iOS 上是 Checkra1n,在 Android 上是 Magisk——还有许多其他的工具。其中,还有一些工具用于隐藏或掩盖活动及管理超级用户权限——通常授予恶意应用。朋友们,如果你使用 SDK 或第三方库实现了越狱或 rooting 检测,那么你的保护措施很有可能已经过时或者很容易被绕过,尤其是在没有对应用的源代码进行充分混淆的情况下。

5

代码混淆

如果你使用 SDK 或第三方库来实现安全防护,那在未混淆的应用中几乎没什么用——为什么?因为黑客使用 Hopper、IDA-pro 等开源工具,就可以很容易地反编译或反汇编,找到 SDK 的源代码,或使用类似 Frida 这样的动态二进制工具箱,注入他们自己的恶意代码,修改应用的行为,或简单地禁用安全 SDK。

代码混淆可以防止攻击者了解移动应用的源代码。而且,我们总是建议使用多种混淆方法,包括混淆本地代码或非本地代码和库,以及混淆应用的逻辑结构或控制流。例如,可以使用控制流混淆或重命名函数、类、方法、变量等来实现。不要忘了还要混淆调试信息。

从现实世界的数据中可以看出,大多数移动应用都缺乏足够的混淆,只混淆了应用的一小部分代码,这项对超过 100 万个 Android 应用的研究清楚地说明了这一点。正如该研究指出的那样,造成这种情况的原因是,对于大多数移动开发人员来说,依赖专用编译器的传统混淆方法实在是太复杂和费时,难以全面实施。相反,许多开发人员只实现了单一的混淆功能,或者只混淆了代码库的一小部分。在这项研究中,研究人员发现,大多数应用只实现了类名混淆,这本身很容易被攻陷。拿书打个比方,类名混淆本身就像是混淆了一本书的“目录”,但书中所有实际的页和内容却并没有混淆。这种表面的混淆相当容易被绕过。

6

数据保护和加密

接着说数据保护,你还需要借助加密来保护应用和用户数据——在移动应用中,有很多地方存储着数据,包括沙盒、内存以及应用的代码或字符串。要自己实现加密,有很多棘手的问题需要解决,包括密钥衍生(key derivation)、密码套件和加密算法组合、密钥大小及强度。许多应用使用了多种编程语言,每一种都需要不同的 SDK,或者会导致你无法控制的不兼容性,又或是需要你无法访问的依赖。而数据类型的差异也有复杂性增加和性能下降的风险。

然后,还有一个典型的问题,即在哪里存储加密密钥。如果密钥存储在应用内部,那它们可能会被反向工程的攻击者发现,然后他们就可以用来解密数据。这就是为什么我们说动态密钥生成是一个非常重要的功能。通过动态密钥生成,加密密钥只在运行时生成,而不会存储在应用或移动设备上。此外,密钥只使用一次,可以防止攻击者发现或截获它们。

那么传输中的数据呢?仅靠 TLS 是不够的,因为有很多方法可以侵入应用的连接。检查和验证 TLS 会话和证书很重要,这可以确保所有的证书和 CA 都是有效且真实的,受到行业标准加密的保护。这可以防止黑客获得 TLS 会话的控制权。然后还有证书固定,可以防止连接到遭到入侵的服务器,或保护服务器,拒绝遭到入侵的应用连接(例如,如果你的应用被变成了一个恶意机器人)。

7

欺诈、恶意软件、防盗版

最后,还有反欺诈、反恶意软件和反盗版保护,你可以在上述基线保护的基础上增加防护层,用于防止非常高级或专门的威胁。这些保护措施可能包括可以防止应用覆盖攻击、自动点击器、钩子框架和动态二进制工具、内存注入、键盘记录器、密钥注入或可访问性滥用的功能,所有这些都是移动欺诈或移动恶意软件的常用武器。

不难想象,即使是实现上述功能的一个子集,也需要大量的时间和资源。到目前为止,我只是谈了一个强大的安全防御所需的特性和功能。即使你内部有资源和所需的技能组合,那么拼凑出一个防御体系的行动挑战又是什么呢?让我们探讨一下开发团队可能会遇到的一些实施挑战。

8

不同平台和框架之间的实现差异

鉴于用于构建移动应用的 SDK/ 库及原生或非原生编程语言之间存在无数的框架差异和不兼容,开发人员将面临的下一个问题是如何分别为 Android 和 iOS 实现这些安全功能。虽然软件开发工具包(SDK)提供了一些标准安全功能,但没有 SDK 能普遍覆盖所有的平台或框架。

当开发人员试图使用 SDK 或开源库来实现移动应用安全时,所面临的一个主要挑战在于,这些方法都依赖于源代码,需要对应用代码进行修改。而结果是,这些方法中的每一个都明确地与应用所使用的特定编程语言绑定,并且还暴露给了各种编程语言或是这些语言和框架的包“依赖”。

通常,iOS 应用使用 Objective-C 或 Swift 构建,而 Android 应用使用 Java 或 Kotlin 以及使用 C 和 C++ 编写原生库。例如,假如你想对存储在 Android 和 iOS 应用中的数据进行加密。如果你找到了一些第三方 Android 加密库亦或是 Java 或 Kotlin 的 SDK,它们不一定适用于应用中使用的 C 或 C++ 代码部分(原生库)。

在 iOS 中也是如此。你浏览 StackOverflow 时可能会发现,在 Swift 中常用的 Cryptokit 框架对 Objective C 不起作用。

那么,非原生或跨平台应用呢?它们是完全不同的赛道,因为你要处理的是 JavaScript 等 Web 技术和 React Native、Cordova、Flutter 或 Xamarin 等非原生框架,它们无法直接(或根本不能)使用为原生语言构建的 SDK 或库。此外,对于非原生应用,你可能无法获得相关的源代码文件,从源头实现加密。

关于这个问题,有一个真实的例子,请看 Stack Overflow 上的这篇帖子。开发人员需要在一个 iOS 应用中实现代码混淆,其中 React Native(一个非原生框架)和 Objective C(一种原生编码语言)之间存在多个依赖关系。由于 iOS 项目中没有可以混淆 React Native 代码的内置库,开发人员需要使用一个外部包(依赖关系 #1)。此外,该外部包还依赖下游的一个库或包来混淆 JavaScript 代码(依赖关系 #2)。现在,如果第三方库的开发人员决定废弃该解决方案,会发生什么?我们的一个客户就面临着这样的问题,这导致他们的应用不符合 PCI 标准。

那么,你认为需要多少开发人员来实现我刚才描述的哪怕是一小部分功能?又需要多长时间?你有足够的时间在现有的移动应用发布过程中实现所需的安全功能吗?

9

DevOps 是敏捷 + 自动化,传统安全是单体 + 手动

移动应用是在一个快节奏、灵活且高度自动化的敏捷模式下开发和发布的。为了使构建和发布更快速、更简单,大多数 Android 和 iOS DevOps 团队都围绕 CI/CD 和其他自动化工具构建了最佳管道。另一方面,安全团队无法访问或查看 DevOps 系统,而且大多数安全工具并不是针对敏捷方法构建的,因为它们在很大程度上依赖于手动编程或实施,在这种情况下,单个安全功能的实施时间可能会长于发布时间表允许的时间。

为了弥补这些不足,一些组织在向公共应用商店发布应用之前,会使用代码扫描和渗透测试,以深入探查漏洞和其他移动应用问题。当发现漏洞时,企业就会面临一个艰难的决定:是在未进行必要保护的情况下发布应用,还是推迟发布,让开发人员有时间来解决安全问题。当这种情况发生时,推荐的安全保护措施往往会被忽视。

开发人员并不懒惰,而是他们用于实现安全保护的系统和工具根本无法匹配现代敏捷 /DevOps 开发的快节奏。

10

实现强大的移动应用安全和平台对等的五个步骤

一般来说,自动化是实现安全对等和强大的移动应用安全的关键所在。以下是在应用发布周期内将移动应用安全打造为应用组成部分的五个步骤。

第 1 步:明确希望得到什么样的安全成果

开发、运营和安全团队必须就移动安全预期达成一致。对于组织作为起点的安全目标,人们要有一个共同的理解,如 OWASP Mobile Top 10、TRM 移动应用安全指南和移动应用安全验证标准(MASVS)。一旦确定了目标并选择了标准,所有团队成员都要知道这对他们的工作流有何影响。

第 2 步:移动应用安全的实施必须自动化

安全非常复杂,手动编码很慢,而且容易出错。评估并利用自动化系统,借助人工智能和机器学习(ML)将安全集成到移动应用中。通常情况下,这些都是无代码平台,可以自动将安全构建到移动应用中,它们通常被称为安全构建系统。

第 3 步:将安全作为开发周期的一部分——安全左移

移动应用安全模型左移是指,移动开发人员需要在构建应用的同时构建安全特性。

一旦选择了自动化安全实施平台,就应该将其整合到团队的持续集成(CI)和持续交付(CD)流程中,这可以加速开发生命周期,所有团队——开发、运营和安全——在整个冲刺期间都应该保持密切合作。此外,企业可以为每个 Android 和 iOS 应用所需的特定安全特性创建可重复使用的移动安全模板,从而更接近实现平台对等。

第 4 步:确保即时确认和验证

如果没有办法即时验证所需的安全功能是否包含在发布中,那么在发布会议上就会出现争执,可能导致应用发布或更新延期。验证和确认应该自动记录,防止最后一刻的发布混乱。

第 4 步:确保即时确认和验证

开发团队需要可预测性和明确的预算。通过采用自动化的安全方法,应用开发团队可以减少人员和开发费用的意外变化,因为它消除了手动将安全编码到移动应用时固有的不确定性。

11

小结

安全对等问题是一个大问题,但它是一个更大问题的一部分,即移动应用普遍缺乏安全性。通过在安全实现中采用与特性开发相同或更高程度的自动化,开发人员可以确保他们针对每个平台发布的每一个应用都免受黑客、骗子和网络犯罪分子的侵害。

作者简介:

Alan Bavosa 是 Appdome 的安全产品副总裁。长期以来,他一直担任安全产品执行官,曾是 Palerra(被 Oracle 收购)和 Arcsight(被 HP 收购)的产品主管。

原文链接:

https://www.infoq.com/articles/secure-mobile-apps-parity-problem/


收起阅读 »

iOS-底层原理 04:NSObject的alloc 源码分析

iOS
主要自定义类的alloc的alloc的源码实现中加一个断点,同时需要暂时关闭断点运行target,断点断在alloc源码的断点,然后继续执行,会出现以下这种现象探索Why【第一步】探索Debug --> Debug Workflow --> 勾选 ...
继续阅读 »

主要NSObject中的alloc是与自定义类的alloc源码流程的区别,以及为什么NSObject中的alloc不走源码工程。

上一篇文章中分析了alloc的源码,这篇文章是作为对上一篇文章的补充,去探索为什么NSObject的alloc方法不走源码工程。

NSObject的alloc无法进入源码的问题

首先在objc4-781可编译源码中的main函数中增加一个NSObject定义的对象,NSObject 和 LGPersong同时加上断点



alloc的源码实现中加一个断点,同时需要暂时关闭断点


运行target,断点断在NSObject部分,打开alloc源码的断点,然后继续执行,会出现以下这种现象


探索Why

【第一步】探索[NSObject alloc]走的是哪步源码

接下来,我们就来探索为什么NSObject的alloc会出现这种情况,首先,

  • 打开Debug --> Debug Workflow --> 勾选 Always Show Disassemly,开启汇编调试

    关闭源码的断点,只留main中的断点,重新运行程序,然后通过下图的汇编可以发现NSObject并没有走 alloc源码,而是走的objc_alloc


然后关闭汇编调试,在全局搜索 objc_alloc,在objc_alloc中加一个断点,先暂时关闭,


重新运行进行调试,断住,然后打开objc_alloc的断点,发现会进入objc_alloc的源码实现,此时查看 cls 是 NSObject


【第二步】探索 NSObject 为什么走 objc_alloc?

首先,我们来看看 NSObject 与 LGPerson的区别

  • NSObject 是iOS中的基类,所有自定义的类都需要继承自NSObject
  • LGPerson 是继承NSObject类的,重写NSObject中的alloc方法

然后根据第一步中汇编的显示,可以看出,NSObject 和 LGPerson 都调用了objc_alloc,所以这里就有两个疑问

  • 为什么NSObject 调用alloc方法 会走到 objc_alloc 源码?
  • 为什么LGPerson中的alloc 会走两次?即调用了alloc,进入源码,然后还要走到 objc_alloc

LGPerson中alloc 走两次 的 Why?

首先,需要在源码中调试,在mainLGPerson加断点,断在LGPerson,再在alloc 、 objc_alloc 和 calloc 源码加断点,运行demo,会断在objc_alloc源码中(重新运行前需要暂时关闭源码中的所有断点)


继续运行,发现LGPerson 第一次的alloc会走到 objc_alloc --> callAlloc方法中最下方的objc_msgSend,表示向系统发送消息



所以由上述调试过程可以得出,LGPerson两次的原因是首先需要去查找sel,以及对应的imp的关系,当前需要查找的是 alloc的方法编号,但是为什么会找到objc_alloc?这个就需要问系统了,肯定是系统在底层做了一些操作。请接着往下看

NSObject中alloc 走到 objc_alloc 的 why?

这部分需要通过 LLVM源码(即llvm-project) 来分析

准备工作:首先需要一份llvm源码

在llvm源码中搜索objc_alloc


搜索shouldUseRuntimeFunctionForCombinedAllocInit,表示版本控制


搜索tryEmitSpecializedAllocInit,非常著名的特殊消息发送,在这里也没有找到 objc_alloc


继续尝试,开启上帝视角,通过alloc字符串搜索,如果还找不到,还可以通过omf_alloc:找到tryGenerateSpecializedMessageSend,表示尝试生成特殊消息发送


然后在这个case中可以找到调用alloc,转而调用了objc_objc的逻辑,其中的关键代码是EmitObjCAlloc


跳转至EmitObjCAlloc的定义可以看到alloc 的处理是调用了 objc_alloc


由此可以得出 NSObject中的alloc 会走到 objc_alloc,其实这部分是由系统级别的消息处理逻辑,所以NSObject的初始化是由系统完成的,因此也不会走到alloc的源码工程中

总结

总结下NSObject中alloc 和自定义类中alloc的调用流程

NSObject


自定义类


作者:style_月月
链接:https://blog.csdn.net/lin1109221208/article/details/108480971

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

iOS 底层原理03:objc4-781 源码编译 & 调试

iOS
准备工作环境版本 & 最新objc源码mac OS 10.15Xcode 11.4objc4-781依赖文件下载需要下载以下依赖文件源码编译源码编译就是不断的调试修改源码的问题,主要有以下问题问题一:unable to find sdk 'macosx...
继续阅读 »

准备工作

环境版本 & 最新objc源码

  • mac OS 10.15
  • Xcode 11.4
  • objc4-781

依赖文件下载

需要下载以下依赖文件


源码编译

源码编译就是不断的调试修改源码的问题,主要有以下问题

问题一:unable to find sdk 'macosx.internal'


选择 target -> objc -> Build Settings -> Base SDK -> 选择 macOS 【target中的 objc 和 obc-trampolines都需要更改】


问题二:文件找不到的报错问题

【1】‘sys/reason.h’ file not found


在Apple source的 macOS10.15 --> xnu-6153.11.26/bsd/sys/reason.h 路径自行下载

在objc4-781的根目录下新建CJLCommon文件, 同时在CJLCommon文件中创建sys文件

最后将 reason.h文件拷贝到sys文件中

设置文件检索路径:选择 target -> objc -> Build Settings,在工程的 Header Serach Paths 中添加搜索路径 $(SRCROOT)/CJLCommon

【2】‘mach-o/dyld_priv.h’ file not found

  • CJLCommon文件中 创建 mach-o 文件
  • 找到文件:dyld-733.6 -- include -- mach-o -- dyld_priv.h


拷贝到 mach-o文件中



  • 拷贝到文件后,还需要修改 dyld_priv.h 文件,即在 dyld_priv.h文件顶部加入一下宏:


【3】‘os/lock_private.h’ file not found 和 ‘os/base_private.h’ file not found

  • 在CJLCommon中创建 os文件
  • 找到lock_private.h、base_private.h文件:libplatform-220 --> private --> os --> lock_private.h 、base_private.h,并将文件拷贝至 os 文件中

【4】‘pthread/tsd_private.h’ file not found 和 ‘pthread/spinlock_private.h’ file not found

在CJLPerson中创建 pthread 文件
找到tsd_private.h、spinlock_private.h文件,h文件路径为:libpthread-416.11.1 --> private --> tsd_private.h、spinlock_private.h,并拷贝到 pthread文件


【5】‘System/machine/cpu_capabilities.h’ file not found

创建 System -- machine 文件
找到 cpu_capabilities.h文件拷贝到 machine文件,h文件路径为:xnu6153.11.26 --> osfmk --> machine --> cpu_capabilities.h


【6】os/tsd.h’ file not found

找到 tsd.h文件,拷贝到os文件, h文件路径为:xnu6153.11.26 --> libsyscall --> os --> tsd.h


【7】‘System/pthread_machdep.h’ file not found

  • 这个地址下载pthread_machdep.h文件,h文件路径为:Libc-583/pthreads/pthread_machdep.h
  • 将其拷贝至system文件中


【8】‘CrashReporterClient.h’ file not found

导入下载的还是报错,可以通过以下方式解决
- 需要在 Build Settings -> Preprocessor Macros 中加入:LIBC_NO_LIBCRASHREPORTERCLIENT
- 或者下载我给大家的文件CrashReporterClient,这里面我们直接更改了里面的宏信息 #define LIBC_NO_LIBCRASHREPORTERCLIENT

【9】‘objc-shared-cache.h’ file not found

文件路径为:dyld-733.6 --> include --> objc-shared-cache.h


  • 将h文件报备制拷贝到CJLCommon

【10】Mismatch in debug-ness macros

注释掉objc-runtime.mm中的#error mismatch in debug-ness macros


【11】’_simple.h’ file not found

文件路径为:libplatform-220 --> private --> _simple.h


  • 将文件拷贝至CJLCommon
【12】‘kern/restartable.h’ file not found

  • 在CJLCommon中创建kern 文件
  • 找到 h文件,路径为xnu-6153.11.26 --> osfmk --> kern -->restartable.h


【13】‘Block_private.h’ file not found

找到 h 文件,文件路径为libclosure-74 --> Block_private.h



拷贝至CJLCommon目录

【14】libobjc.order 路径问题

问题描述为:can't open order file: /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/AppleInternal/OrderFiles/libobjc.order

  • 选择 target -> objc -> Build Settings
  • 在工程的 Order File 中添加搜索路径 $(SRCROOT)/libobjc.order



【14】Xcode 脚本编译问题
问题描述为:/xcodebuild:1:1: SDK "macosx.internal" cannot be located.

选择 target -> objc -> Build Phases -> Run Script(markgc)
把脚本文本 macosx.internal 改成 macosx


编译调试

新建一个target :CJLTest



绑定二进制依赖关系



源码调试

自定义一个CJLPerson类

image

在main.m中 创建 CJLPerson的对象,进行源码调试



作者:style_月月
链接:https://blog.csdn.net/lin1109221208/article/details/108435967

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

现今 Swift 包中的二进制目标

一、目录      1、理解二进制在 Swift 中的演变    2、命令行工具相关    3、结论二、前言    在 iOS 和...
继续阅读 »

一、目录  

    1、理解二进制在 Swift 中的演变
    2、命令行工具相关
    3、结论

二、前言

    在 iOS 和 macOS 开发中, Swift 包现在变得越来越重要。Apple 已经努力推动桥接那些缝隙,并且修复那些阻碍开发者的问题,例如阻碍开发者将他们的库和依赖由其他诸如 Carthage 或 CocoaPods依赖管理工具迁移到 Swift 包依赖管理工具的问题,例如没有能力添加构建步骤的问题。这对任何依赖一些代码生成的库来说都是破坏者,比如,协议和 Swift 生成。


    1、理解二进制在 Swift 中的演变

        为了充分理解 Apple 的 Swift 团队在二进制目标和他们引入的一些新 API 方面采取的一些步骤,我们需要理解它们从何而来。在后续的部分中,我们将调研 Apple 架构的演变,以及为什么二进制目标的 API 在过去几年中逐渐形成的,特别是自 Apple 发布了自己的硅芯片之后。


        胖二进制和 Frameworks 框架

        如果你曾必须处理二进制依赖,或者你曾创建一个属于你自己的可执行文件,你将会对 胖二进制 这个术语感到熟悉。这些被扩展(或增大)的可执行文件,是包含了为多个不同架构原生构建的切片。这允许库的所有者分发一个运行在所有预期的目标架构上的单独的二进制。
        当源码不能被暴露或当处理非常庞大的代码仓库时,预编译库成为可执行文件非常有意义,因为预编译源码以及以二进制文件分发他们,将节省构建程序在他们的应用上的构建时间。
        Pods 是一个非常好的例子,当开发者发现他们自己没必要构建那些非常少改动的依赖。这是一个很共通的问题,它激发了诸如 cocoapods-binary之类的项目,该项目预编译了 pod 依赖项以减少客户端的构建时间。


        Frameworks 框架

        嵌入静态二进制文件可能对应用程序来说已经足够了,但如果需要某些资源(如 assets 或头文件),则需要将这些资源与包含所有切片的 胖二进制文件 捆绑在一起,形成所谓的 frameworks 文件。
这就是诸如 Google Cast[5] 之类的预编译库在过渡到使用 xcframework 进行分发之前所做的事情 —— 下一节将详细介绍这种过渡的原因。
        到目前为止,一切都很好。如果我们要为分发预编译一个库,那么胖二进制文件听起来很理想,对吧?并且,如果我们需要捆绑一些其他资源,我们可以只使用一个 frameworks。一个二进制来统治他们所有!


        XCFrameworks 框架

        好吧,不完全是。胖二进制文件有一个大问题,那就是你不能有两个架构相同但命令/指令不同的切片。这曾经很好,因为设备和模拟器的架构总是不同的,但是随着 Apple Silicon 计算机 (M1) 的推出,模拟器和设备共享相同的架构 (arm64),但具有不同的加载器命令。这与面向未来的二进制目标相结合,正是 Apple 引入 XCFrameworks 的原因。
        XCFrameworks现在允许将多个二进制文件捆绑在一起,解决了 M1 Mac 引入的设备和模拟器冲突架构问题,因为我们现在可以为每个用例提供包含相关切片的二进制文件。事实上,如果我们需要,我们可以走得更远,例如,在同一个 xcframework 中捆绑一个包含 iOS 目标的 UIKit 接口的二进制文件和一个包含 macOS 的 AppKit 接口的二进制文件,然后让 Xcode 基于期望的目标架构决定使用哪一个。
        在 Swift 包中,那先能够以 binaryTarget 被包含进项目的,能够在包中被引入任意其他目标。这相同的操作同样适用于 frameworks。


     2、命令行工具相关

        由于 Swift 5.6 版本中引入了用于 Swift 包管理器的 可扩展构建工具[9] ,因此可以在构建过程中的不同时间执行命令。

        这是 iOS 社区长期以来一直强烈要求的事情,例如格式化源代码、代码生成甚至收集公制代码库的指标。Swift 5.6 中所有这些所谓的 插件最终都需要调用可执行文件来执行特定任务。这是二进制文件再次在 Swift 包中参与的地方。
        在大多数情况下,对于我们 iOS 开发人员来说,这些工具将来自同时支持 macOS 的不同架构切片 —— Apple Silicon 的 arm64 架构和 Intel Mac 的 x86_64 架构。开发者工具如, SwiftLint或 SwiftGen 正是这种案例。在这种情况下,可以使用包含可执行文件(本地或远程)的 .zip 文件的路径创建新的二进制目标。


        Artifact Bundles

        到目前为止,命令行工具所采用的方法仅适用于 macOS 架构。但我们不能忘记,Linux 机器也支持 Swift 包。这意味着如果要同时支持 M1 macs (arm64) 和 Linux arm64 机器,上面的胖二进制方法将不起作用 —— 请记住,二进制不能包含具有相同架构的多个切片。在这个阶段可能有人会想,我们可以不只使用 xcframeworks 吗?不,因为它们在 Linux 操作系统上不受支持!
        Apple 已经考虑到这一点,除了引入 可扩展构建工具[13] 之外,Artifact Bundles和对二进制目标的其他改进也作为 Swift 5.6 的一部分发布。
        工件包(Artifact Bundles) 是包含 工件 的目录。这些工件需要包含支持架构的所有不同二进制文件。二进制文件和支持的架构的路径是使用清单文件 (info.json) 指定的,该文件位于 Artifact Bundle 目录的根目录中。你可以将此清单文件视为一个地图或指南,以帮助 Swift 确定哪些可执行文件可用于哪种架构以及可以在哪里找到它们。


        以 SwiftLint 为例

        SwiftLint 在整个社区中被广泛用作 Swift 代码的静态代码分析工具。由于很多人都非常渴望让这个插件在他们的 SwiftPM 项目中运行,我认为这将是一个很好的例子来展示我们如何将分发的可执行文件从他们的发布页面变成一个与 macOS 架构和 Linux arm64 兼容的工件包。
        让我们从下载两个可执行文件(macOS 和 Linux)开始。
        至此,bundle的结构就可以创建好了。为此,创建一个名为 swiftlint.artifactbundle 的目录并在其根目录添加一个空的 info.json:

        mkdir swiftlint.artifactbundle

        touch swiftlint.artifactbundle/info.json

        现在可以使用 schemaVersion 填充清单文件,这可能会在未来版本的工件包和具有两个变体的工件中发生变化,这将很快定义:

        {

            "schemaVersion": "1.0",
            "artifacts": {
                "swiftlint": {
                    "version": "0.47.0", # The version of SwiftLint being used
                    "type": "executable",
                    "variants": [
                    ]
                },
            }
        }

        需要做的最后一件事是将二进制文件添加到包中,然后将它们作为变体添加到 info.json 文件中。让我们首先创建目录并将二进制文件放入其中(macOS 的一个在 swiftlint-macos/swiftlint,Linux 的一个在 swiftlint-linux/swiftlint)。
        添加这些之后,可以在清单文件中变量:

        {
            "schemaVersion": "1.0",
            "artifacts": {
                "swiftlint": {
                    "version": "0.47.0", # The version of SwiftLint being used
                    "type": "executable",
                    "variants": [
                    {
                        "path": "swiftlint-macos/swiftlint",
                        "supportedTriples": ["x86_64-apple-macosx", "arm64-apple-macosx"]
                    },
                    {
                        "path": "swiftlint-linux/swiftlint",
                        "supportedTriples": ["x86_64-unknown-linux-gnu"]
                    },
                    ]
                },
            }
        }

        为此,需要为每个变量指定二进制文件的相对路径(从工件包目录的根目录)和支持的三元组。如果您不熟悉 目标三元组,它们是一种选择构建二进制文件的架构的方法。请注意,这不是 主机(构建可执行文件的机器)的体系结构,而是 目标 机器(应该运行所述可执行文件的机器)。

        这些三元组具有以下格式: ---- 并非所有字段都是必需的,如果其中一个字段未知并且要使用默认值,则可以省略或替换为 unknown 关键字。
        可执行文件的架构切片可以通过运行 file 找到,这将打印捆绑的任何切片的供应商、系统和架构。在这种情况下,为这两个命令运行它会显示:


        swiftlint-macos/swiftlint


        swiftlint: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64]

        swiftlint (for architecture x86_64): Mach-O 64-bit executable x86_64

        swiftlint (for architecture arm64): Mach-O 64-bit executable arm64


        swiftlint-linux/swiftlint


        -> file swiftlint
        swiftlint: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, with debug_info, not stripped


       这带来了上面显示的 macOS 支持的两个三元组(x86_64-apple-macosx、arm64-apple-macosx)和 Linux 支持的一个三元组(x86_64-unknown-linux-gnu)。

        与 XCFrameworks 类似,工件包也可以通过使用 binaryTarget 包含在 Swift 包中。


    3、结论

        简而言之,我们可以总结 2022 年如何在 Swift 包中使用二进制文件的最佳实践,如下所示:

        1、如果你需要为你的 iOS/macOS 项目添加预编译库或可执行文件,您应该使用 XCFramework,并为每个用例(iOS 设备、macOS 设备和 iOS 模拟器)包含单独的二进制文件。
        2、如果你需要创建一个插件并运行一个可执行文件,你应该将其嵌入为一个工件包,其中包含适用于不同支持架构的二进制文件。

收起阅读 »

怎么看待996

近日有网传消息称“现在的乐视仍有400多名员工,过着没有‘老板’的神仙日子,无996无内卷,公司没有拖欠过工资和社保,当年无敌高端配置的电视日活仍然不错,靠运营和版权收入养活员工”。在此感谢大家的关注,情况基本属实,我们再稍作补充:1、乐视确实没有996,而且...
继续阅读 »

近日有网传消息称“现在的乐视仍有400多名员工,过着没有‘老板’的神仙日子,无996无内卷,公司没有拖欠过工资和社保,当年无敌高端配置的电视日活仍然不错,靠运营和版权收入养活员工”。在此感谢大家的关注,情况基本属实,我们再稍作补充:
1、乐视确实没有996,而且以后也不会有。工作是永远做不完的,在有限的时间内完成有限的工作,这合情合理。如果有一天,我们合法地率先推行每周工作四天半、36小时工作制,大家也不要感到意外。
2、乐视员工“无内卷”过于绝对了,毕竟有人的地方就有“江湖”。只不过在乐视,“内卷”的程度低一些,因为员工只有400多,很多岗位是“一个萝卜一个坑”,跟谁卷?但凡多一两个人可能就卷起来了。
3、公司近五年确实从未拖欠过员工工资和社保。
4、“没有老板的神仙日子”这个说法我们尚高攀不起,神仙日子般的工作基本会是任何员工的一种奢求,如果能让员工觉得“工作似神仙”那公司一定很成功。而“老板”这个用词这些年本就是一个相对模糊的概念,不同语境有不同含义。很多企业部门员工私下称部门负责人为老板,部门负责人称CEO为老板,CEO称董事长、创始人、实际控制人为老板。按此理解,乐视会有很多“老板”,各业务负责人是老板,CEO、董事长是老板,股东拜访公司我们也称老板,创始人贾跃亭先生也是老板,原战略股东“融创”来也是老板。所以说,乐视不是没有老板,也许是因为公司内部各业务条线的“老板”们勤勤恳恳、尽职尽责,不需要其他上级“老板”亲力亲为,才让大家觉得没有老板。
5、乐视超级电视当年的配置确实无敌,即便是14年、15年和16年的产品,配置的Mstar系列918/928/938等当时的旗舰芯片,现在依然运行速度飞快,不逊于当下其他品牌的主流配置互联网电视,乐视超级电视如今的日活离不开满级性能配置的策略。乐视生态曾经的理念是 “两倍性能一半价格”,如今虽已告别烧钱模式,但仍坚持以“同等性能更低价格”来做电视、手机等新品,请大家多多关注和支持我们的业务进展。

收起阅读 »

MySQL:max_allowed_packet 影响了什么?

数据包如果要发送超过 16M 的数据怎么办?那怎样算一个数据包?下面我们通过测试来讨论 max_allowed_packet 的实际影响。如果 SQL 文件中有单个 SQL 大小超过 max_allowed_packet ,会报错:##导出时设置 mysqld...
继续阅读 »

max_allowed_packet 表示 MySQL Server 或者客户端接收的 packet 的最大大小,packet 即数据包,MySQL Server 和客户端上都有这个限制。

数据包

每个数据包,都由包头、包体两部分组成,包头由 3 字节的包体长度、1 字节的包编号组成。3 字节最多能够表示 2 ^ 24 = 16777216 字节(16 M),也就是说,一个数据包的包体长度必须小于等于 16M 。

如果要发送超过 16M 的数据怎么办?

当要发送大于 16M 的数据时,会把数据拆分成多个 16M 的数据包,除最后一个数据包之外,其它数据包大小都是 16M。而 MySQL Server 收到这样的包后,如果发现包体长度等于 16M ,它就知道本次接收的数据由多个数据包组成,会先把当前数据包的内容写入缓冲区,然后接着读取下一个数据包,并把下一个数据包的内容追加到缓冲区,直到读到结束数据包,就接收到客户端发送的完整数据了。

那怎样算一个数据包?

  • 一个 SQL 是一个数据包

  • 返回查询结果时,一行数据算一个数据包

  • 解析的 binlog ,如果用 mysql 客户端导入,一个 SQL 算一个数据包

  • 在复制中,一个 event 算一个数据包

下面我们通过测试来讨论 max_allowed_packet 的实际影响。

导入 SQL 文件受 max_allowed_packet 限制吗?

如果 SQL 文件中有单个 SQL 大小超过 max_allowed_packet ,会报错:

##导出时设置 mysqldump --net-buffer-length=16M,这样保证导出的sql文件中单个 multiple-row INSERT 大小为 16M
mysqldump -h127.0.0.1 -P13306 -uroot -proot --net-buffer-length=16M \
--set-gtid-purged=off sbtest sbtest1 > /data/backup/sbtest1.sql

##设置max_allowed_packet=1M

##导入报错
[root@localhost data]# mysql -h127.0.0.1 -P13306 -uroot -proot db3 < /data/backup/sbtest1.sql
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1153 (08S01) at line 41: Got a packet bigger than 'max_allowed_packet' bytes

导入解析后的 binlog 受 max_allowed_packet 限制吗?

row 格式的 binlog,单个SQL修改的数据产生的 binlog 如果超过 max_allowed_packet,也会报错。

在恢复数据到指定时间点的场景,解析后的binlog单个事务大小超过1G,并且这个事务只包含一个SQL,此时一定会触发 max_allowed_packet 的报错。但是恢复数据的任务又很重要,怎么办呢?可以将 binlog 改名成 relay log,用 sql 线程回放来绕过这个限制。

查询结果受 max_allowed_packet 限制吗?

查询结果中,只要单行数据不超过客户端设置的 max_allowed_packet 即可:

##插入220M大小的数据
[root@localhost tmp]# dd if=/dev/zero of=20m.img bs=1 count=0 seek=20M
记录了0+0 的读入
记录了0+0 的写出
0字节(0 B)已复制,0.000219914 秒,0.0 kB/秒
[root@localhost tmp]# ll -h 20m.img
-rw-r--r-- 1 root root 20M 6月   6 15:15 20m.img

mysql> create table t1(id int auto_increment primary key,a longblob);
Query OK, 0 rows affected (0.03 sec)

mysql> insert into t1 values(NULL,load_file('/tmp/20m.img'));
Query OK, 1 row affected (0.65 sec)

mysql> insert into t1 values(NULL,load_file('/tmp/20m.img'));
Query OK, 1 row affected (0.65 sec)

##mysql客户端默认 --max-allowed-packet=16M,读取失败
mysql> select * from t1;
ERROR 2020 (HY000): Got packet bigger than 'max_allowed_packet' bytes

##设置 mysql 客户端 --max-allowed-packet=22M,读取成功
[root@localhost ~]# mysql -h127.0.0.1 -P13306 -uroot -proot --max-allowed-packet=23068672 sbtest -e "select * from t1;" > /tmp/t1.txt

[root@localhost ~]# ll -h /tmp/t1.txt
-rw-r--r-- 1 root root 81M 6月   6 15:30 /tmp/t1.txt

load data 文件大小受 max_allowed_packet 限制吗?

load data 文件大小、单行大小都不受 max_allowed_packet 影响:

##将上一个测试中的数据导出,2行数据一共81M
mysql> select * int o outfile '/tmp/t1.csv' from t1;
Query OK, 2 rows affected (0.57 sec)

[root@localhost ~]# ll -h /tmp/t1.csv
-rw-r----- 1 mysql mysql 81M 6月   6 15:32 /tmp/t1.csv

##MySQL Server max_allowed_packet=16M
mysql> select @@max_allowed_packet;
+----------------------+
| @@max_allowed_packet |
+----------------------+
|             16777216 |
+----------------------+
1 row in set (0.00 sec)

##load data 成功,不受 max_allowed_packet 限制
mysql> load data infile '/tmp/t1.csv' into table t1;
Query OK, 2 rows affected (1.10 sec)
Records: 2 Deleted: 0 Skipped: 0 Warnings: 0

binlog 中超过 1G 的 SQL ,是如何突破 max_allowed_packet 复制到从库的?

从库 slave io 线程、slave sql 线程可以处理的最大数据包大小由参数 slave_max_allowed_packet 控制。这是限制 binlog event 大小,而不是单个 SQL 修改数据的大小。

主库 dump 线程会自动设置 max_allowed_packet为1G,不会依赖全局变量 max_allowed_packet。用来控制主库 DUMP 线程每次读取 event 的最大大小。

具体可以参考:mp.weixin.qq.com/s/EfNY_UwEthiu-DEBO7TrsA

另外超过 4G 的大事务,从库心跳会报错:https://opensource.actionsky.com/20201218-mysql/

作者:胡呈清,爱可生 DBA 团队成员,擅长故障分析、性能优化

来源:jianshu.com/u/a95ec11f67a8

收起阅读 »

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事故”后我们是如何执行优化落地的,敬请期待。

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

来源:哔哩哔哩技术

收起阅读 »