注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

从零开发一款轻量级滑动验证码插件

效果演示 滑动验证组件基本使用和技术实现 上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果...
继续阅读 »

效果演示


slider.gif


滑动验证组件基本使用和技术实现


上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果大家有一定的技术基础,也可以直接跳到技术实现部分。


基本使用


因为 react-slider-vertify 这款组件我已经发布到 npm 上了,所以大家可以按照如下方式安装和使用:



  1. 安装


# 或者 yarn add @alex_xu/react-slider-vertify
npm i @alex_xu/react-slider-vertify -S


  1. 使用


import React from 'react';
import { Vertify } from '@alex_xu/react-slider-vertify';

export default () => {
return <Vertify
width={320}
height={160}
onSuccess={() => alert('success')}
onFail={() => alert('fail')}
onRefresh={() => alert('refresh')}
/>
};

通过以上两步我们就可以轻松使用这款滑动验证码组件了,是不是很简单?
image.png


当然我也暴露了很多可配置的属性,让大家对组件有更好的控制。参考如下:


image.png


技术实现


在做这个项目之前我也研究了一些滑动验证码的知识以及已有的技术方案,收获很多。接下来我会以我的组件设计思路来和大家介绍如何用 react 来实现和封装滑动验证码组件,如果大家有更好的想法和建议, 也可以在评论区随时和我反馈。


1.组件设计的思路和技巧


每个人都有自己设计组件的方式和风格,但最终目的都是更 优雅 的设计组件。这里我大致列举一下 优雅 组件的设计指标:




  • 可读性(代码格式统一清晰,注释完整,代码结构层次分明,编程范式使用得当)




  • 可用性(代码功能完整,在不同场景都能很好兼容,业务逻辑覆盖率)




  • 复用性(代码可以很好的被其他业务模块复用)




  • 可维护性(代码易于维护和扩展,并有一定的向下/向上兼容性)




  • 高性能




以上是我自己设计组件的考量指标,大家可以参考一下。


另外设计组件之前我们还需要明确需求,就拿滑动验证码组件举例,我们需要先知道它的使用场景(用于登录注册、活动、论坛、短信等高风险业务场景的人机验证服务)和需求(交互逻辑,以什么样的方式验证,需要暴露哪些属性)。


image.png


以上就是我梳理的一个大致的组件开发需求,在开发具体组件之前,如果遇到复杂的业务逻辑,我们还可以将每一个实现步骤列举出来,然后一一实现,这样有助于整理我们的思路和更高效的开发。


2.滑动验证码基本实现原理


在介绍完组件设计思路和需求分析之后,我们来看看滑动验证码的实现原理。


image.png


我们都知道设计验证码的主要目的是为了防止机器非法暴力地入侵我们的应用,其中核心要解决的问题就是判断应用是谁在操作( or 机器),所以通常的解决方案就是随机识别


上图我们可以看到只有用户手动将滑块拖拽到对应的镂空区域,才算验证成功,镂空区域的位置是随机的(随机性测试这里暂时以前端的方式来实现,更安全的做法是通过后端来返回位置和图片)。


基于以上分析我们就可以得出一个基本的滑动验证码设计原理图:


image.png


接下来我们就一起封装这款可扩展的滑动验证码组件。


3.封装一款可扩展的滑动验证码组件


按照我开发组件一贯的风格,我会先基于需求来编写组件的基本框架:


import React, { useRef, useState, useEffect, ReactNode } from 'react';

interface IVertifyProp {
/**
* @description canvas宽度
* @default 320
*/
width:number,
/**
* @description canvas高度
* @default 160
*/
height:number,
/**
* @description 滑块边长
* @default 42
*/
l:number,
/**
* @description 滑块半径
* @default 9
*/
r:number,
/**
* @description 是否可见
* @default true
*/
visible:boolean,
/**
* @description 滑块文本
* @default 向右滑动填充拼图
*/
text:string | ReactNode,
/**
* @description 刷新按钮icon, 为icon的url地址
* @default -
*/
refreshIcon:string,
/**
* @description 用于获取随机图片的url地址
* @default https://picsum.photos/${id}/${width}/${height}, 具体参考https://picsum.photos/, 只需要实现类似接口即可
*/
imgUrl:string,
/**
* @description 验证成功回调
* @default ():void => {}
*/
onSuccess:VoidFunction,
/**
* @description 验证失败回调
* @default ():void => {}
*/
onFail:VoidFunction,
/**
* @description 刷新时回调
* @default ():void => {}
*/
onRefresh:VoidFunction
}

export default ({
width = 320,
height = 160,
l = 42,
r = 9,
imgUrl,
text,
refreshIcon = 'http://yourimgsite/icon.png',
visible = true,
onSuccess,
onFail,
onRefresh
}: IVertifyProp) => {
return <div className="vertifyWrap">
<div className="canvasArea">
<canvas width={width} height={height}></canvas>
<canvas className="block" width={width} height={height}></canvas>
</div>
<div className={sliderClass}>
<div className="sliderMask">
<div className="slider">
<div className="sliderIcon">&rarr;</div>
</div>
</div>
<div className="sliderText">{ textTip }</div>
</div>
<div className="refreshIcon" onClick={handleRefresh}></div>
<div className="loadingContainer">
<div className="loadingIcon"></div>
<span>加载中...</span>
</div>
</div>
}

以上就是我们组件的基本框架结构。从代码中可以发现组件属性一目了然,这都是提前做好需求整理带来的好处,它可以让我们在编写组件时思路更清晰。在编写好基本的 css 样式之后我们看到的界面是这样的:


image.png


接下来我们需要实现以下几个核心功能:



  • 镂空效果的 canvas 图片实现

  • 镂空图案 canvas 实现

  • 滑块移动和验证逻辑实现


上面的描述可能比较抽象,我画张图示意一下:


image.png


因为组件实现完全采用的 react hooks ,如果大家对 hooks 不熟悉也可以参考我之前的文章:



1.实现镂空效果的 canvas 图片


image.png


在开始 coding 之前我们需要对 canvas 有个基本的了解,建议不熟悉的朋友可以参考高效 canvas 学习文档: Canvas of MDN


由上图可知首先要解决的问题就是如何用 canvas 画不规则的图形,这里我简单的画个草图:


image.png


我们只需要使用 canvas 提供的 路径api 画出上图的路径,并将路径填充为任意半透明的颜色即可。建议大家不熟悉的可以先了解如下 api :



  • beginPath() 开始路径绘制

  • moveTo() 移动笔触到指定点

  • arc() 绘制弧形

  • lineTo() 画线

  • stroke() 描边

  • fill() 填充

  • clip() 裁切路径


实现方法如下:


const drawPath  = (ctx:any, x:number, y:number, operation: 'fill' | 'clip') => {
ctx.beginPath()
ctx.moveTo(x, y)
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
ctx.lineTo(x + l, y)
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
ctx.lineTo(x + l, y + l)
ctx.lineTo(x, y + l)
// anticlockwise为一个布尔值。为true时,是逆时针方向,否则顺时针方向
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
ctx.lineTo(x, y)
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'
ctx.stroke()
ctx.globalCompositeOperation = 'destination-over'
// 判断是填充还是裁切, 裁切主要用于生成图案滑块
operation === 'fill'? ctx.fill() : ctx.clip()
}

这块实现方案也是参考了 yield 大佬的原生 js 实现,这里需要补充的一点是 canvasglobalCompositeOperation 属性,它的主要目的是设置如何将一个源(新的)图像绘制到目标(已有)的图像上。




  • 源图像 = 我们打算放置到画布上的绘图




  • 目标图像 = 我们已经放置在画布上的绘图




w3c上有个形象的例子:


image.png


这里之所以设置该属性是为了让镂空的形状不受背景底图的影响并覆盖在背景底图的上方。如下:


image.png


接下来我们只需要将图片绘制到画布上即可:


const canvasCtx = canvasRef.current.getContext('2d')
// 绘制镂空形状
drawPath(canvasCtx, 50, 50, 'fill')

// 画入图片
canvasCtx.drawImage(img, 0, 0, width, height)

当然至于如何生成随机图片和随机位置,实现方式也很简单,前端实现的话采用 Math.random 即可。


2.实现镂空图案 canvas


上面实现了镂空形状,那么镂空图案也类似,我们只需要使用 clip() 方法将图片裁切到形状遮罩里,并将镂空图案置于画布左边即可。代码如下:


const blockCtx = blockRef.current.getContext('2d')
drawPath(blockCtx, 50, 50, 'clip')
blockCtx.drawImage(img, 0, 0, width, height)

// 提取图案滑块并放到最左边
const y1 = 50 - r * 2 - 1
const ImageData = blockCtx.getImageData(xRef.current - 3, y1, L, L)
// 调整滑块画布宽度
blockRef.current.width = L
blockCtx.putImageData(ImageData, 0, y1)

上面的代码我们用到了 getImageDataputImageData,这两个 api 主要用来获取 canvas 画布场景像素数据和对场景进行像素数据的写入。实现后 的效果如下:


image.png


3.实现滑块移动和验证逻辑


实现滑块移动的方案也比较简单,我们只需要利用鼠标的 event 事件即可:



  • onMouseDown

  • onMouseMove

  • onMouseUp


image.png


以上是一个简单的示意图,具体实现代码如下:


const handleDragMove = (e) => {
if (!isMouseDownRef.current) return false
e.preventDefault()
// 为了支持移动端, 可以使用e.touches[0]
const eventX = e.clientX || e.touches[0].clientX
const eventY = e.clientY || e.touches[0].clientY
const moveX = eventX - originXRef.current
const moveY = eventY - originYRef.current
if (moveX < 0 || moveX + 36 >= width) return false
setSliderLeft(moveX)
const blockLeft = (width - l - 2r) / (width - l) * moveX
blockRef.current.style.left = blockLeft + 'px'
}

当然我们还需要对拖拽停止后的事件做监听,来判断是否验证成功,并埋入成功和失败的回调。代码如下:


const handleDragEnd = (e) => {
if (!isMouseDownRef.current) return false
isMouseDownRef.current = false
const eventX = e.clientX || e.changedTouches[0].clientX
if (eventX === originXRef.current) return false
setSliderClass('sliderContainer')
const { flag, result } = verify()
if (flag) {
if (result) {
setSliderClass('sliderContainer sliderContainer_success')
// 成功后的自定义回调函数
typeof onSuccess === 'function' && onSuccess()
} else {
// 验证失败, 刷新重置
setSliderClass('sliderContainer sliderContainer_fail')
setTextTip('请再试一次')
reset()
}
} else {
setSliderClass('sliderContainer sliderContainer_fail')
// 失败后的自定义回调函数
typeof onFail === 'function' && onFail()
setTimeout(reset.bind(this), 1000)
}
}

实现后的效果如下:


chrome-capture (4).gif


当然还有一些细节需要优化处理,这里在 github 上有完整的代码,大家可以参考学习一下,如果大家想对该组件参与贡献,也可以随时提 issue


4.如何使用 dumi 搭建组件文档


为了让组件能被其他人更好的理解和使用,我们可以搭建组件文档。作为一名热爱开源的前端 coder,编写组件文档也是个很好的开发习惯。接下来我们也为 react-slider-vertify 编写一下组件文档,这里我使用 dumi 来搭建组件文档,当然大家也可以用其他方案(比如storybook)。我们先看一下搭建后的效果:


image.png


image.png


dumi 搭建组件文档非常简单,接下来和大家介绍一下安装使用方式。



  1. 安装


$ npx @umijs/create-dumi-lib        # 初始化一个文档模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib

$ npx @umijs/create-dumi-lib --site # 初始化一个站点模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib --site


  1. 本地运行


npm run dev
# or
yarn dev


  1. 编写文档


dumi 约定式的定义了文档编写的位置和方式,其官网上也有具体的饭介绍,这里简单给大家上一个 dumi 搭建的组件目录结构图:


image.png


我们可以在 docs 下编写组件库文档首页和引导页的说明,在单个组件的文件夹下使用 index.md 来编写组件自身的使用文档,当然整个过程非常简单,我这里举一个文档的例子:


image.png


通过这种方式 dumi 就可以帮我们自动渲染一个组件使用文档。如果大家想学习更多组件文档搭建的内容,也可以在 dumi 官网学习。


5.发布自己第一个npm组件包


最后一个问题就是组件发布。之前很多朋友问我如何将自己的组件发布到 npm 上让更多人使用,这块的知识网上有很多资料可以学习,那今天就以滑动验证码 @alex_xu/react-slider-vertify 的例子,来和大家做一个简单的介绍。



  1. 拥有一个 npm 账号并登录


如果大家之前没有 npm 账号,可以在 npm 官网 注册一个,然后用我们熟悉的 IDE 终端登录一次:


npm login

跟着提示输入完用户名密码之后我们就能通过命令行发布组件包了:


npm publish --access public

之所以指令后面会加 public 参数,是为了避免权限问题导致组件包无法发布成功。我们为了省事也可以把发布命令配置到 package.json 中,在组件打包完成后自动发布:


{
"scripts": {
"start": "dumi dev",
"release": "npm run build && npm publish --access public",
}
}

这样我们就能将组件轻松发布到 npm 上供他人使用啦! 我之前也开源了很多组件库,如果大家对组件打包细节和构建流程有疑问,也可以参考我之前开源项目的方案。 发布到 npm 后的效果:


image.png


最后


如果大家对可视化搭建或者低代码/零代码感兴趣,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端真正的技术。


链接:https://juejin.cn/post/7007615666609979400

收起阅读 »

Flutter自适应瀑布流

前言:在电商app经常会看到首页商品推荐的瀑布流,或者类似短视频app首页也是瀑布流,这些都是需要自适应的,才能给用户带来好的体验 话不多说先上效果图: 根据效果图可以分为四步: 1.图片自适应 2.自适应标签 3.上拉刷新和下拉加载 4.底部的点赞按钮可以...
继续阅读 »

前言:在电商app经常会看到首页商品推荐的瀑布流,或者类似短视频app首页也是瀑布流,这些都是需要自适应的,才能给用户带来好的体验


话不多说先上效果图:


在这里插入图片描述在这里插入图片描述


根据效果图可以分为四步:


1.图片自适应

2.自适应标签

3.上拉刷新和下拉加载

4.底部的点赞按钮可以去掉或者自己修改样式,我这里使用的like_button库

注:本文使用的库:为啥这么多呢,因为我把图片缓存这样东西都加上了,单纯的瀑布流就用waterfall_flow

waterfall_flow: ^3.0.1
extended_image: any
extended_sliver: any
ff_annotation_route_library: any
http_client_helper: any
intl: any
like_button: any
loading_more_list: any
pull_to_refresh_notification: any
url_launcher: any

1.图片自适应:


Widget image = Stack(
children: <Widget>[
ExtendedImage.network(
item.imageUrl,
shape: BoxShape.rectangle,
//clearMemoryCacheWhenDispose: true,
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
loadStateChanged: (ExtendedImageState value) {
if (value.extendedImageLoadState == LoadState.loading) {
Widget loadingWidget = Container(
alignment: Alignment.center,
color: Colors.grey.withOpacity(0.8),
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor:
AlwaysStoppedAnimation<Color>(Theme.of(c).primaryColor),
),
);
if (!konwSized) {
//todo: not work in web
loadingWidget = AspectRatio(
aspectRatio: 1.0,
child: loadingWidget,
);
}
return loadingWidget;
} else if (value.extendedImageLoadState == LoadState.completed) {
item.imageRawSize = Size(
value.extendedImageInfo.image.width.toDouble(),
value.extendedImageInfo.image.height.toDouble());
}
return null;
},
),
Positioned(
top: 5.0,
right: 5.0,
child: Container(
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.6),
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
child: Text(
'${index + 1}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: fontSize, color: Colors.white),
),
),
)
],
);
if (konwSized) {
image = AspectRatio(
aspectRatio: item.imageSize.width / item.imageSize.height,
child: image,
);
} else if (item.imageRawSize != null) {
image = AspectRatio(
aspectRatio: item.imageRawSize.width / item.imageRawSize.height,
child: image,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
image,
const SizedBox(
height: 5.0,
),
buildTagsWidget(item),
const SizedBox(
height: 5.0,
),
buildBottomWidget(item),
],
);
}

2.自适应标签:


Widget buildTagsWidget(
TuChongItem item, {
int maxNum = 6,
}) {
const double fontSize = 12.0;
return Wrap(
runSpacing: 5.0,
spacing: 5.0,
children: item.tags.take(maxNum).map<Widget>((String tag) {
final Color color = item.tagColors[item.tags.indexOf(tag)];
return Container(
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: color,
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
child: Text(
tag,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: fontSize,
color: color.computeLuminance() < 0.5
? Colors.white
: Colors.black),
),
);
}).toList());
}

3.上拉刷新和下拉加载


class PullToRefreshHeader extends StatelessWidget {
const PullToRefreshHeader(this.info, this.lastRefreshTime, {this.color});
final PullToRefreshScrollNotificationInfo info;
final DateTime lastRefreshTime;
final Color color;
@override
Widget build(BuildContext context) {
if (info == null) {
return Container();
}
String text = '';
if (info.mode == RefreshIndicatorMode.armed) {
text = 'Release to refresh';
} else if (info.mode == RefreshIndicatorMode.refresh ||
info.mode == RefreshIndicatorMode.snap) {
text = 'Loading...';
} else if (info.mode == RefreshIndicatorMode.done) {
text = 'Refresh completed.';
} else if (info.mode == RefreshIndicatorMode.drag) {
text = 'Pull to refresh';
} else if (info.mode == RefreshIndicatorMode.canceled) {
text = 'Cancel refresh';
}

final TextStyle ts = const TextStyle(
color: Colors.grey,
).copyWith(fontSize: 13);

final double dragOffset = info?.dragOffset ?? 0.0;

final DateTime time = lastRefreshTime ?? DateTime.now();
final double top = -hideHeight + dragOffset;
return Container(
height: dragOffset,
color: color ?? Colors.transparent,
//padding: EdgeInsets.only(top: dragOffset / 3),
//padding: EdgeInsets.only(bottom: 5.0),
child: Stack(
children: <Widget>[
Positioned(
left: 0.0,
right: 0.0,
top: top,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: RefreshImage(top),
margin: const EdgeInsets.only(right: 12.0),
),
),
Column(
children: <Widget>[
Text(
text,
style: ts,
),
Text(
'Last updated:' +
DateFormat('yyyy-MM-dd hh:mm').format(time),
style: ts.copyWith(fontSize: 12),
)
],
),
Expanded(
child: Container(),
),
],
),
)
],
),
);
}
}

class RefreshImage extends StatelessWidget {
const RefreshImage(this.top);
final double top;
@override
Widget build(BuildContext context) {
const double imageSize = 40;
return ExtendedImage.asset(
Assets.assets_fluttercandies_grey_png,
width: imageSize,
height: imageSize,
afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) {
final double imageHeight = image.height.toDouble();
final double imageWidth = image.width.toDouble();
final Size size = rect.size;
final double y = (1 - min(top / (refreshHeight - hideHeight), 1)) *
imageHeight;

canvas.drawImageRect(
image,
Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y),
Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height,
size.width, (imageHeight - y) / imageHeight * size.height),
Paint()
..colorFilter =
const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn)
..isAntiAlias = false
..filterQuality = FilterQuality.low);

//canvas.restore();
},
);
}
}

4.底部的点赞按钮


LikeButton(
size: 18.0,
isLiked: item.isFavorite,
likeCount: item.favorites,
countBuilder: (int count, bool isLiked, String text) {
final ColorSwatch<int> color =
isLiked ? Colors.pinkAccent : Colors.grey;
Widget result;
if (count == 0) {
result = Text(
'love',
style: TextStyle(color: color, fontSize: fontSize),
);
} else {
result = Text(
count >= 1000 ? (count / 1000.0).toStringAsFixed(1) + 'k' : text,
style: TextStyle(color: color, fontSize: fontSize),
);
}
return result;
},
likeCountAnimationType: item.favorites < 1000
? LikeCountAnimationType.part
: LikeCountAnimationType.none,
onTap: (bool isLiked) {
return onLikeButtonTap(isLiked, item);
},
)

这样自适应的瀑布流就完成了。


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

Android 控制 ContentProvider的创建

序言 随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentP...
继续阅读 »

序言


随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentProvider中获取了敏感信息,又没有提供控制方法。我们就很被动。于是我花了点时间研究了怎么hook contentProvider的创建。让其在用户同意后再初始化。


方案1


声明在清单文件中的ContentProvider 会在应用启动后就创建。具体是在 ActivityThread的handleBindApplication方法中。(以下截图为Android 30的ActivityThread)
在这里插入图片描述
具体就在这一句
在这里插入图片描述


installContentProviders实现如下
在这里插入图片描述
最终是通过AppComponentFactory的instantiateProvider方法创建。
在这里插入图片描述
在这里插入图片描述


而AppComponentFactory是Android 28以后系统提供给我们的一个hook的工厂类。可以通过清单文件指定,在这里面可以hook 所有组件的初始化。
在这里插入图片描述
这么指定
在这里插入图片描述


但是在Android 28以下,比如这个截图是Android 25.没有这类,ContentProvider直接通过反射获得。无法通过该类来修改。
在这里插入图片描述


最终方案


为了兼容性,考虑如下方案。在调用installContentProviders前,如果这个data里面的providers为空岂不是不会走installContentProviders方法了吗。
在这里插入图片描述
这个data 是一个AppBindData类型,通过handleBindApplication方法的参数传入。会保存到ActivityThread的 mBoundApplication 字段中。
在这里插入图片描述


于是就可以通过获取这个mBoundApplication 字段中的providers 来保存要初始化的provider。再讲providers置为空即可。到了用户同意以后,再去通过反射调用ActivityThread的installContentProviders方法即可。
在这里插入图片描述


hook时机


这个时机只有Application的attachBaseContext方法中。该方法会比installContentProviders提前执行。


最后的代码App中


public class MyApp extends Application {

static MyApp app;

/**
*用户同意
*/
public static void agree(Action action) {
HookUtil.initProvider(app);
action.doAction();
}

public interface Action {
void doAction();
}


@Override
protected void attachBaseContext(Context base) {
app = this;
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
super.attachBaseContext(base);
}
}


HookUtil


package com.zgh.testcontentprovider;

import android.content.Context;

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


/**
* Created by zhuguohui
* Date: 2021/9/13
* Time: 11:23
* Desc:
*/
public class HookUtil {

private static Object providers;

private static Method installContentProvidersMethod;
private static Object currentActivityThread;

/*
*用户同意后调用
*/
public static void initProvider(Context context){
try {
installContentProvidersMethod.invoke(currentActivityThread,context,providers);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void attachContext() throws Exception {

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

hookInstallContentProvider(activityThreadClass);

}

private static void hookInstallContentProvider(Class activityThreadClass) throws Exception{
Field appDataField = activityThreadClass.getDeclaredField("mBoundApplication");
appDataField.setAccessible(true);
Object appData= appDataField.get(currentActivityThread);
Field providersField= appData.getClass().getDeclaredField("providers");
providersField.setAccessible(true);
providers = providersField.get(appData);
//清空provider,避免有些sdk通过provider来初始化
providersField.set(appData,null);

installContentProvidersMethod = activityThreadClass.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
}
}


搭配


搭配我之前写的工具,可以更完美的实现用户同意之前不初始化任何SDK的目标
通过拦截 Activity的创建 实现APP的隐私政策改造


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

不做跟风党,LiveData,StateFlow,SharedFlow 使用场景对比

Android 常用的分层架构 Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。 「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小...
继续阅读 »

Android 常用的分层架构


Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。



「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小,浅色模式与暗黑模式的切换,更改默认语言,更改字体大小等等



因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配置更改等场景。 例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(Domain Layer) 来保存业务逻辑,使用数据层(Data Layer)对上层屏蔽数据来源(数据可能来自远程服务,可能是本地数据库)。



表现层可以分成具有不同职责的组件:



  • View:处理生命周期回调,用户事件和页面跳转,Android 中主要是 Activity 和 Fragment

  • Presenter 或 ViewModel:向 View 提供数据,并不了解 View 所处的生命周期,通常生命周期比 View 长


Presenter 和 ViewModel 向 View 提供数据的机制是不同的,简单来说:



  • Presenter 通过持有 View 的引用并直接调用操作 View,以此向 View 提供数据

  • ViewModel 通过将可观察的数据暴露给观察者来向 View 提供数据


官方提供的可观察的数据 组件是 LiveData。Kotlin 1.4.0 正式版发布之后,开发者有了新的选择:StateFlowSharedFlow


最近网上流传出「LiveData 被弃用,应该使用 Flow 替代 LiveData」的声音。


LiveData 真的有那么不堪吗?Flow 真的适合你使用吗?


不人云亦云,只求接近真相。我们今天来讨论一下这两种组件。


ViewModel + LiveData


为了实现高效地加载 UI 数据,获得最佳的用户体验,应实现以下目标:



  • 目标1:已经加载的数据无需在「配置更改」的场景下再次加载

  • 目标2:避免在非活跃状态(不是 STARTEDRESUMED)下加载数据和刷新 UI

  • 目标3:「配置更改」时不会中断的工作


Google 官方在 2017 年发布了架构组件库:使用 ViewModel + LiveData 帮助开发者实现上述目标。



相信很多人在官方文档中见过这个图,ViewModelActivity/Fragment 的生命周期更长,不受「配置更改」导致 Activity/Fragment 重建的影响。刚好满足了目标 1 和目标 3。


LiveData 是可生命周期感知的。 新值仅在生命周期处于 STARTEDRESUMED 状态时才会分配给观察者,并且观察者会自动取消注册,避免了内存泄漏。 LiveData 对实现目标 1 和 目标 2 很有用:它缓存其持有的数据的最新值,并将该值自动分派给新的观察者。


LiveData 的特性


既然有声音说「LiveData 要被弃用了」,那么我们先对 LiveData 进行一个全面的了解。聊聊它能做什么,不能做什么,以及使用过程中有哪些要注意的地方。


LiveData 是 Android Jetpack Lifecycle 组件中的内容。属于官方库的一部分,Kotlin/Java 均可使用。


一句话概括 LiveDataLiveData 是可感知生命周期的,可观察的,数据持有者


它的能力和作用很简单:更新 UI


它有一些可以被认为是优点的特性:



  • 观察者的回调永远发生在主线程

  • 仅持有单个且最新的数据

  • 自动取消订阅

  • 提供「可读可写」和「仅可读」两个版本收缩权限

  • 配合 DataBinding 实现「双向绑定」


观察者的回调永远发生在主线程


这个很好理解,LiveData 被用来更新 UI,因此 ObserveronChanged() 方法在主线程回调。



背后的原理也很简单,LiveDatasetValue() 发生在主线程(非主线程调用会抛异常,postValue() 内部会切换到主线程调用 setValue())。之后遍历所有观察者的 onChanged() 方法。


仅持有单个且最新的数据


作为数据持有者(data holder),LiveData 仅持有 单个最新 的数据。


单个且最新,意味着 LiveData 每次持有一个数据,并且新数据会覆盖上一个。


这个设计很好理解,数据决定了 UI 的展示,绘制 UI 时肯定要使用最新的数据,「过时的数据」应该被忽略。



配合 Lifecycle,观察者只会在活跃状态下(STARTEDRESUMED)接收到 LiveData 持有的最新的数据。在非活跃状态下绘制 UI 没有意义,是一种资源的浪费。



自动取消订阅


这是 LiveData 可感知生命周期的重要表现,自动取消订阅意味着开发者无需手动写那些取消订阅的模板代码,降低了内存泄漏的可能性。


背后原理是在生命周期处于 DESTROYED 时,移除观察者。



提供「可读可写」和「仅可读」两个版本




点击查看代码
public abstract class LiveData<T> {
@MainThread
protected void setValue(T value) {
// ...
}

protected void postValue(T value) {
// ...
}

@Nullable
public T getValue() {
// ...
}
}

public class MutableLiveData<T> extends LiveData<T> {
@Override
public void postValue(T value) {
super.postValue(value);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}
复制代码



抽象类 LiveDatasetValue()postValue() 是 protected,而其实现类 MutableLiveData 均为 public。



LiveData 提供了 mutable(MutableLiveData) 和 immutable(LiveData) 两个类,前者「可读可写」,后者「仅可读」。通过权限的细化,让使用者各取所需,避免由于权限泛滥导致的数据异常。




点击查看代码
class SharedViewModel : ViewModel() {
private val _user : MutableLiveData<User> = MutableLiveData()

val user : LiveData<User> = _user

fun setUser(user: User) {
_user.posetValue(user)
}
}
复制代码


配合 DataBinding 实现「双向绑定」


LiveData 配合 DataBinding 可以实现 更新数据自动驱动 UI 变化,如果使用「双向绑定」还能实现 UI 变化影响数据的变化。




以下也是 LiveData 的特性,但我不会将其归类为「设计缺陷」或「LiveData 的缺点」。作为开发者应了解这些特性并在使用过程中正确处理它们。



  • value 是 nullable 的

  • 在 fragment 订阅时需要传入正确的 lifecycleOwner

  • LiveData 持有的数据是「事件」时,可能会遇到「粘性事件

  • LiveData 是不防抖的

  • LiveDatatransformation 工作在主线程


value 是 nullable 的




点击查看代码
@Nullable
public T getValue() {
Object data = mData;
if (data != NOT_SET) {
return (T) data;
}
return null;
}
复制代码


LiveData#getValue() 是可空的,使用时应该注意判空。


使用正确的 lifecycleOwner


fragment 调用 LiveData#observe() 方法时传入 thisviewLifecycleOwner 是不一样的。


原因之前写过,此处不再赘述。感兴趣的小伙伴可以移步查看


AS 在 lint 检查时会避免开发者犯此类错误。



粘性事件


官方在 [译] 在 SnackBar,Navigation 和其他事件中使用 LiveData(SingleLiveEvent 案例) 一文中描述了一种「数据只会消费一次」的场景。如展示 Snackbar,页面跳转事件或弹出 Dialog。


由于 LiveData 会在观察者活跃时将最新的数据通知给观察者,则会产生「粘性事件」的情况。


如点击 button 弹出一个 Snackbar,在屏幕旋转时,lifecycleOwner 重建,新的观察者会再次调用 Livedata#observe(),因此 Snackbar 会再次弹出。


解决办法是:将事件作为状态的一部分,在事件被消费后,不再通知观察者。这里推荐两种解决方案:



默认不防抖


setValue()/postValue() 传入相同的值多次调用,观察者的 onChanged() 会被多次调用。


严格讲这不算一个问题,看具体的业务场景,处理也很容易,调用 setValue()/postValue() 前判断一下 vlaue 与之前是否相同即可。




点击查看代码
class MainViewModel {
private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username

fun setUsername(username: String) {
if (_username.value != username)
_headerText.postValue(username)
}
}
复制代码


transformation 工作在主线程


有些时候我们从 repository 层拿到的数据需要进行处理,例如从数据库获得 User List,我们想根据 id 获取某个 User。


此时我们可以借助 MediatorLiveDataTransformatoins 来实现:





点击查看代码
class MainViewModel {
val viewModelResult = Transformations.map(repository.getDataForUser()) { data ->
convertDataToMainUIModel(data)
}
}
复制代码


mapswitchMap 内部均是使用 MediatorLiveData#addSource() 方法实现的,而该方法会在主线程调用,使用不当会有性能问题。




点击查看代码
@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}
复制代码


我们可以借助 Kotlin 协程和 RxJava 实现异步任务,最后在主线程上返回 LiveData。如 androidx.lifecycle:lifecycle-livedata-ktx 提供了这样的写法




点击查看代码
val result: LiveData<Result> = liveData {
val data = someSuspendingFunction() // 协程中处理
emit(data)
}
复制代码


LiveData 小结




  • LiveData 作为一个 可感知生命周期的,可观察的,数据持有者,被设计用来更新 UI




  • LiveData 很轻,功能十分克制,克制到需要配合 ViewModel 使用才能显示其价值




  • 由于 LiveData 专注单一功能,因此它的一些方法使用上是有局限性的,即通过设计来强制开发者按正确的方式编码(如观察者仅在主线程回调,避免了开发者在子线程更新 UI 的错误操作)




  • 由于 LiveData 专注单一功能,如果想在表现层之外使用它,MediatorLiveData 的操作数据的能力有限,仅有的 mapswitchMap 发生在主线程。可以在 switchMap 中使用协程或 RxJava 处理异步任务,最后在主线程返回 LiveData。如果项目中使用了 RxJavaAutoDispose,甚至可以不使用 LiveData,关于 Kotlin 协程的 Flow,我们后文介绍。




  • 笔者不喜欢将 LiveData 改造成 bus 使用,让组件做其分内的事(此条属于个人观点)




Flow


Flow 是 Kotlin 语言提供的功能,属于 Kotlin 协程的一部分,仅 Kotlin 使用。


Kotlin 协程被用来处理异步任务,而 Flow 则是处理异步数据流。


那么 suspend 方法和 Flow 的区别是什么?各自的使用场景是哪些?


一次性调用(One-shot Call)与数据流(data stream)



假如我们的 app 的某一屏里显示以下元素,其中红框部分实时性不高,不必很频繁的刷新,转发和点赞属于实时性很高的数据,需要定时刷新。



对于实时性不高的数据,我们可以使用 Kotlin 协程处理(此处数据的请求是异步任务):


suspend fun loadData(): Data

uiScope.launch {
val data = loadData()
updateUI(data)
}

而对于实时性较高的数据,挂起函数就无能为力了。有的小伙伴可能会说:「返回个 List 不就行了嘛」。其实无论返回什么类型,这种操作都是 One-shot Call,一次性的请求,有了结果就结束。


示例中的点赞和转发,需要一个 数据是异步计算的,能够 按顺序 提供 多个值 的结构,在 Kotlin 协程中我们有 Flow。


fun dataStream(): Flow<Data>

uiScope.launch {
dataStream().collect { data ->
updateUI(data)
}
}


当点赞或转发数发生变化时,updateUI() 会被执行,UI 根据最新的数据更新



Flow 的三驾马车


FLow 中有三个重要的概念:



  • 生产者(Producer)

  • 消费者(Consumer)

  • 中介(Intermediaries)


生产者提供数据流中的数据,得益于 Kotlin 协程,Flow 可以 异步地生产数据


消费者消费数据流内的数据,上面的示例中,updateUI() 方法是消费者。


中介可以对数据流中的数据进行更改,甚至可以更改数据流本身,我们可以借助官方视频中的动画来理解:



在 Android 中,数据层的 DataSource/Repository 是 UI 数据的生产者;而 view/ViewModel 是消费者;换一个角度,在表现层中,view 是用户输入事件的生产者(例如按钮的点击),其它层是消费者。


「冷流」与「热流」


你可能见过这样的描述:「流是冷的」



简单来说,冷流指数据流只有在有消费者消费时才会生产数据。


val dataFlow = flow {
// 代码块只有在有消费者 collect 后才会被调用
val data = dataSource.fetchData()
emit(data)
}

...

dataFlow.collect { ... }

有一种特殊的 Flow,如 StateFlow/SharedFlow ,它们是热流。这些流可以在没有活跃消费者的情况下存活,换句话说,数据在流之外生成然后传递到流。



BroadcastChannel 未来会在 Kotlin 1.6.0 中弃用,在 Kotlin 1.7.0 中删除。它的替代者是 StateFlowSharedFlow



StateFlow


StateFlow 也提供「可读可写」和「仅可读」两个版本。


SateFlow 实现了 SharedFlowMutableStateFlow 实现 MutableSharedFlow



StateFlowLiveData 十分像,或者说它们的定位类似。


StateFlowLiveData 有一些相同点:




  • 提供「可读可写」和「仅可读」两个版本(StateFlowMutableStateFlow




  • 它的值是唯一的




  • 它允许被多个观察者共用 (因此是共享的数据流)




  • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的




  • 支持 DataBinding




它们也有些不同点:



  • 必须配置初始值

  • value 空安全

  • 防抖


MutableStateFlow 构造方法强制赋值一个非空的数据,而且 value 也是非空的。这意味着 StateFlow 永远有值




StateFlow 的 emit()tryEmit() 方法内部实现是一样的,都是调用 setValue()



StateFlow 默认是防抖的,在更新数据时,会判断当前值与新值是否相同,如果相同则不更新数据。



SharedFlow


SateFlow 一样,SharedFlow 也有两个版本:SharedFlowMutableSharedFlow



那么它们有什么不同?



  • MutableSharedFlow 没有起始值

  • SharedFlow 可以保留历史数据

  • MutableSharedFlow 发射值需要调用 emit()/tryEmit() 方法,没有 setValue() 方法



MutableSharedFlow 不同,MutableSharedFlow 构造器中是不能传入默认值的,这意味着 MutableSharedFlow 没有默认值。


val mySharedFlow = MutableSharedFlow<Int>()
val myStateFlow = MutableStateFlow<Int>(0)
...
mySharedFlow.emit(1)
myStateFlow.emit(1)

SateFlowSharedFlow 还有一个区别是 SateFlow 只保留最新值,即新的订阅者只会获得最新的和之后的数据。


SharedFlow 根据配置可以保留历史数据,新的订阅者可以获取之前发射过的一系列数据。



后文会介绍背后的原理



它们被用来应对不同的场景:UI 数据是状态还是事件


状态(State)与事件(Event)


状态可以是的 UI 组件的可见性,它始终具有一个值(显示/隐藏)


而事件只有在满足一个或多个前提条件时才会触发,不需要也不应该有默认值


为了更好地理解 SateFlowSharedFlow 的使用场景,我们来看下面的示例:



  1. 用户点击登录按钮

  2. 调用服务端验证登录合法性

  3. 登录成功后跳转首页


我们先将步骤 3 视为 状态 来处理:



使用状态管理还有与 LiveData 一样的「粘性事件」问题,如果在 ViewNavigationState 中我们的操作是弹出 snackbar,而且已经弹出一次。在旋转屏幕后,snackbar 会再次弹出。



如果我们将步骤 3 作为 事件 处理:



使用 SharedFlow 不会有「粘性事件」的问题,MutableSharedFlow 构造函数里有一个 replay 的参数,它代表着可以对新订阅者重新发送多个之前已发出的值,默认值为 0。



SharedFlow 在其 replayCache 中保留特定数量的最新值。每个新订阅者首先从 replayCache 中取值,然后获取新发射的值。replayCache 的最大容量是在创建 SharedFlow 时通过 replay 参数指定的。replayCache 可以使用 MutableSharedFlow.resetReplayCache 方法重置。


replay 为 0 时,replayCache size 为 0,新的订阅者获取不到之前的数据,因此不存在「粘性事件」的问题。


StateFlowreplayCache 始终有当前最新的数据:



至此, StateFlowSharedFlow 的使用场景就很清晰了:


状态(State)用 StateFlow ;事件(Event)用 SharedFlow  


StateFlow,SharedFlow 与 LiveData 的使用对比


LiveData StateFlow SharedFlow 在 ViewModel 中的使用



上图分别展示了 LiveDataStateFlowSharedFlowViewModel 中的使用。


其中 LiveDataViewModel 中使用 LiveEventLiveData 处理「粘性事件


FlowViewModel 中使用 SharedFlow 处理「粘性事件


emit() 方法是挂起函数,也可以使用 tryEmit()



LiveData StateFlow SharedFlow 在 Fragment 中的使用



注意:Flow 的 collect 方法不能写在同一个 lifecycleScope


flowWithLifecyclelifecycle-runtime-ktx:2.4.0-alpha01 后提供的扩展方法



Flow 在 fragment 中的使用要比 LiveData 繁琐很多,我们可以封装一个扩展方法来简化:



关于 repeatOnLifecycle 的设计问题,可以移步 设计 repeatOnLifecycle API 背后的故事


使用 collect 方法时要注意一个问题。



这种写法是错误的!


viewModel.headerText.collect 在协程被取消前会一直挂起,这样后面的代码便不会执行。


Flow 与 RxJava


FlowRxJava 的定位很接近,限于篇幅原因,此处不展开讲,本节只罗列一下它们的对应关系:




  • Flow = (cold) Flowable / Observable / Single




  • Channel = Subjects




  • StateFlow = BehaviorSubjects (永远有值)




  • SharedFlow = PublishSubjects (无初始值)




  • suspend function = Single / Maybe / Completable




参考文档与推荐资源



总结




  • LiveData 的主要职责是更新 UI,要充分了解其特性,合理使用




  • Flow 可分为生产者,消费者,中介三个角色




  • 冷流和热流最大的区别是前者依赖消费者 collect 存在,而热流一直存在,直到被取消




  • StateFlowLiveData 定位相似,前者必须配置初始值,value 空安全并且默认防抖




  • StateFlowSharedFlow 的使用场景不同,前者适用于「状态」,后者适用于「事件」




回到文章开头的话题,LiveData 并没有那么不堪,由于其作用单一,功能简单,简单便意味着不易出错。所以在表现层中ViewModel 向 view 暴露 LiveData 是一个不错的选择。而在 RepositoryDataSource 中,我们可以利用 LiveData + 协程来处理数据的转换。当然,我们也可以使用功能更强大的 Flow


LiveDataStateFLowSharedFlow,它们都有着各自的使用场景。并且如果使用不当,都会或多或少地遇到一些所谓的「坑」。因此在使用某个组件时,要充分了解其设计缘由以及相关特性,否则就会掉进陷阱,收到不符合预期的行为。


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

android 事件分发机制

Android 事件分发机制解析1. view的事件分发机制view的事件分发是从 dispatchTouchEvent() 开始的,直接上代码;public boolean dispatchTouchEvent(MotionEvent event) { ...
继续阅读 »

Android 事件分发机制解析

1. view的事件分发机制

view的事件分发是从 dispatchTouchEvent() 开始的,直接上代码;

public boolean dispatchTouchEvent(MotionEvent event) {  
boolean result = false;
// 1. view 是否可以点击 && setOnTouchListener 有值 并且 setOnTouchListener 返回值是true 事件分发 结束

if ( (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener != null &&
mOnTouchListener.onTouch(this, event)) {
boolean result = true;

}
// 2.如果上述条件不都成立 执行 OnTouchEvent();
if (!result && onTouchEvent(event)) {
result = true;
}


return result;
}

/**
* 分析1:onTouchEvent()
*/
public boolean onTouchEvent(MotionEvent event) {



// 若该控件可点击,则进入switch判断中
if (((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {

// 根据当前事件类型进行判断处理
switch (event.getAction()) {

// a. 事件类型=抬起View(主要分析)
case MotionEvent.ACTION_UP:
performClick();
// ->>分析2
break;

// b. 事件类型=按下View
case MotionEvent.ACTION_DOWN:
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
break;

// c. 事件类型=结束事件
case MotionEvent.ACTION_CANCEL:
refreshDrawableState();
removeTapCallback();
break;

// d. 事件类型=滑动View
case MotionEvent.ACTION_MOVE:
final int x = (int) event.getX();
final int y = (int) event.getY();

int slop = mTouchSlop;
if ((x < 0 - slop) || (x >= getWidth() + slop) ||
(y < 0 - slop) || (y >= getHeight() + slop)) {
removeTapCallback();
if ((mPrivateFlags & PRESSED) != 0) {
removeLongPressCallback();
mPrivateFlags &= ~PRESSED;
refreshDrawableState();
}
}
break;
}

// 若该控件可点击,就一定返回true
return true;
}
// 若该控件不可点击,就一定返回false
return false;
}

**
* 分析2:performClick()
*/
public boolean performClick() {

if (mOnClickListener != null) {
// 只要通过setOnClickListener()为控件View注册1个点击事件
// 那么就会给mOnClickListener变量赋值(即不为空)
// 则会往下回调onClick() & performClick()返回true
playSoundEffect(SoundEffectConstants.CLICK);
mOnClickListener.onClick(this);
return true;
}
return false;
}

总结:如果你在执行ACTION_DOWN的时候返回了false,后面一系列其它的action就不会再得到执行了。简单的说,就是当dispatchTouchEvent在进行事件分发的时候,只有前一个action返回true,才会触发后一个action。

image.png

2.ViewGroup 事件分发机制

viewGroup事件分发可以分为以下阶段:1.点击事件是down,将mFirstTarget、其他的标记、状态值清空,2.检查当前事件是否被拦截,3.如果被拦截,当前的事件不会分发给子view(firstTarget为空),会交由viewGroup父类的dispatchTouchEvent处理;4.如果不拦截,会找到一个满足条件的子view,分发此次的down事件;5.如果找不到满足条件的子view,firstTouch=null,就会调用自身的dispatchTouchEvent;6.如何当前点击事件是move、up时;6.如果找到了符合条件的子view,把down事件分发给子view,并对firstTouchTarget赋值,down事件分发结束;7.接来下就是move、up事件的分发,如果down事件分发给子view了,会再次判断是否拦截;8.如果不拦截,就会把move、up分发给mFirstTouchTarget对应的子view;9.如果拦截,会分发一个cancel事件给firstTouchTarget对应的子view。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

// 1.将firstTouchTarget置空,其他状态清空
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}

final boolean intercepted;


/***
* 2.检查是否拦截事件,如果点击事件是down、或者事件已经分发给子view,通过viewGroup的
* onInterceptTouchEvent 判断
*
*/

if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
intercepted = true;
}


TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
// 3. 如果事件没有被拦截,会需找一个满足条件的子view分发事件
if (!canceled && !intercepted) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {


if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
/***
*4. 如果有子view可以分发当前的事件,对newTouchTarget,firstTouchTarget赋值,记
* 消费本次事件的view
*/
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
}

if (mFirstTouchTarget == null) {
// 5. 事件交由viewGroup父类的dispatchTouchEvent 处理
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
// 6.找到符合条件的子view,该事件分发结束
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
//
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}

if (cancelChild) { // 如果拦截了事件,清空 firstTouchTarget
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}

}
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;


final int oldAction = event.getAction();
// 如果是子view消费了viewGroup分发的事件,后续事件被viewGroup拦截,viewGroup会发送一
cancel事件给firstTouchTarget对应的子view,该事件结束。下一个事件就不会再分发给子view了。

if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}


final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;


if (newPointerIdBits == 0) {
return false;
}

if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}

handled = child.dispatchTouchEvent(transformedEvent);
}
transformedEvent.recycle();
return handled;
}

3.滑动冲突

3.1 滑动冲突场景

方向一致:父容器和子view的滑动方向一致,如:scrollView 嵌套一个recyclewView 方向不一致:父容器和子view的滑动方向不一致,如scrollView 嵌套一个 viewPage。

3.2 外部拦截法

子view需要处理事件时,在父容器里面通过onInterceptTouchEvent返回值为false,让事件交由子view处理;当父容器需要处理事件时,让onInterceptTouchEvent返回值未true,让父容器拦截子view的事件,自己处理事件。伪代码如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted=false;
int x= (int) event.getX();
int y= (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted=false;//必须不能拦截,否则后续的ACTION_MOME和ACTION_UP事件都会拦截。
break;
case MotionEvent.ACTION_MOVE:
if (父容器需要当前点击事件){
intercepted=true;
}else {
intercepted=false;
}
break;
case MotionEvent.ACTION_UP:
intercepted=false;
break;

default:
break;
}
mLastXIntercept=x;
mLastXIntercept=y;
return intercepted;
}

3.3 内部拦截法

if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}

内部拦截法通过子view(requestDisallowInterceptTouchEvent(disallowIntercept=false),disallowIntercept=false)改变viewGroup的disallowIntercept值,来干预viewGroup是否拦截子view。从上面代码,我们可以知道:disallowIntercept只能控制让viewGroup不拦截子view,拦截子view是通过viewGroup的 onInterceptTouchEvent方法值控制的。所以内部拦截法,就是结合viewGroup的 onInterceptTouchEvent方法和view通过viewgroup.requestDisallowInterceptTouchEvent改变 disallowIntercept值共同来完成。

// 重写 viewGroup  onInterceptTouchEvent方法,down返回值不能为false
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}

//  重写子view的 dispatchTouchEvent事件 
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
//如果是左右滑动
if (父容器) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
}
mLastXIntercept = x;
mLastYIntercept = y;
return super.dispatchTouchEvent(ev);
}


收起阅读 »

自定义可点击可滑动的通用RatingBar

介绍一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View。效果图预览实现自定义属性<declare-styleable name="CommonRatingBar"&g...
继续阅读 »

介绍

一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View。

效果图预览

untitled.gif

实现

自定义属性

<declare-styleable name="CommonRatingBar">
<attr name="starCount" format="integer" />
<attr name="starPadding" format="dimension" />
<!-- 默认选中时的图标,可不设置,使用纯色starColor -->
<attr name="starDrawable" format="reference" />
<!-- 默认未选中时的图标 -->
<attr name="starBgDrawable" format="reference" />
<!-- 纯色样式 -->
<attr name="starColor" format="color" />
<attr name="starClickable" format="boolean" />
<attr name="starScrollable" format="boolean" />
<attr name="starType" format="enum">
<enum name="normal" value="0" />
<enum name="half" value="1" />
<enum name="whole" value="2" />
</attr>
</declare-styleable>

测量View

将控件的高度设置为测量高度,测量宽度为星星的数量+每个星星之间的padding

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
starSize = measuredHeight
setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}

绘制ratingbar

  1. 绘制未选中的背景
/**
* 未选中Bitmap
*/

private val starBgBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 绘制星星默认未选中背景
*/

private fun drawStar(canvas: Canvas) {
for (i in 0 until starCount) {
val starLeft = i * (starSize + starPadding)
canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
}
}
  1. 绘制选中图标

这里bitmap宽度使用starSize + starPadding,配合BitmapShader的repeat模式,可以方便绘制出高亮的图标

/**
* 选中icon的Bitmap
*/

private val starBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 绘制高亮图标
*/

private fun drawStarDrawable(canvas: Canvas) {
starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}
  1. 绘制纯色的选中效果

使用离屏缓冲,纯色矩形与未选中背景相交的地方进行显示。具体使用可以参考扔物线大佬的文章

/**
* 星星纯色画笔
*/

private val starPaint = Paint().apply {
isAntiAlias = true
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

canvas?.let {
// xfermode需要使用离屏缓存
val saved = it.saveLayer(null, null)
drawStar(it)
if (starDrawable == -1) {
drawStarBgColor(it)
} else {
drawStarDrawable(it)
}
it.restoreToCount(saved)
}

/**
* 绘制高亮纯颜色
*/

private fun drawStarBgColor(canvas: Canvas) {
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starPaint)
}
  1. 绘制进度

根据type更正显示效果,是取半,取整还是任意取进度。open方法,可以方便修改

/**
* 获取星星绘制宽度
*/

private fun getStarProgressWidth(): Float {
val percent = progress / 100f
val starDrawCount = percent * starCount
return when (starType) {
StarType.HALF.ordinal -> {
ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
StarType.WHOLE.ordinal -> {
ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
else -> {
starDrawCount * starSize + starDrawCount.toInt() * starPadding
}
}
}

/**
* 取整规则
*/

private fun ceilWhole(x: Float): Float {
return ceil(x)
}

/**
* 取半规则
*/

private fun ceilHalf(x: Float): Float {
// 四舍五入 1.3->1+0.5->1.5 1.7->2
val round = round(x)
return when {
round < x -> round + 0.5f
round > x -> round
else -> x
}
}
  1. 点击+滑动

点击+滑动就是重写onTouchEvent事件:

  • 判断点击位置是否在范围内
/**
* 点击的point是否在view范围内
*/

private fun pointInView(x: Float, y: Float): Boolean {
return Rect(0, 0, width, height).contains(x.toInt(), y.toInt())
}
  • 记录按下位置,抬起位置。
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_UP -> {
if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onClickProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
  • 滑动记录手指move
MotionEvent.ACTION_MOVE -> {
if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onScrollProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
  1. 添加监听

添加OnCommonRatingBarListener,监听点击事件以及滑动事件,返回进度

click.gif

完整实现代码

class CommonRatingBar(context: Context, attrs: AttributeSet?) : View(context, attrs) {

/**
* 星星数量
*/

private var starCount = 5

/**
* 星星间隔
*/

private var starPadding = 0f

/**
* 星星大小
*/

private var starSize = 30

/**
* 星星选中背景图
*/

private var starDrawable: Int = -1

/**
* 星星未选中背景图
*/

private var starBgDrawable: Int = -1

/**
* 星星选择类型
*/

private var starType = StarType.NORMAL.ordinal

/**
* 星星颜色
*/

private var starColor: Int = Color.parseColor("#F7B500")

/**
* 星星可点击
*/

private var starClickable = false

/**
* 星星可滑动选择
*/

private var starScrollable = false

/**
* 星星未选中画笔
*/

private val starBgPaint = Paint().apply {
isAntiAlias = true
}

/**
* 星星选中画笔
*/

private val starDrawablePaint = Paint().apply {
isAntiAlias = true
}

/**
* 星星纯色画笔
*/

private val starPaint = Paint().apply {
isAntiAlias = true
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

private var progress = 0

/**
* 选中icon的Bitmap
*/

private val starBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 未选中Bitmap
*/

private val starBgBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

init {
initView(context, attrs)
starPaint.color = starColor
}

private fun initView(context: Context, attrs: AttributeSet?) {
val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.CommonRatingBar)
starCount = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starCount, 5)
starPadding = obtainStyledAttributes.getDimension(R.styleable.CommonRatingBar_starPadding, 10f)
starDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starDrawable, -1)
starBgDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starBgDrawable, -1)
starType = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starType, StarType.NORMAL.ordinal)
starColor = obtainStyledAttributes.getColor(R.styleable.CommonRatingBar_starColor, Color.parseColor("#F7B500"))
starClickable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starClickable, false)
starScrollable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starScrollable, false)
obtainStyledAttributes.recycle()
}

override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
// super.dispatchTouchEvent(event) -> 当前view的onTouchEvent
// false -> viewGroup的onTouchEvent
return if (starClickable || starScrollable) super.dispatchTouchEvent(event)
else false
}

/**
* 最小触摸范围
*/

private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
private var downY = 0f

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_MOVE -> {
if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onScrollProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP -> {
if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onClickProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return true
}

/**
* 点击的point是否在view范围内
*/

private fun pointInView(x: Float, y: Float): Boolean {
return Rect(0, 0, width, height).contains(x.toInt(), y.toInt())
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
starSize = measuredHeight
setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (starBgDrawable == -1) {
return
}
canvas?.let {
// xfermode需要使用离屏缓存
val saved = it.saveLayer(null, null)
drawStar(it)
if (starDrawable == -1) {
drawStarBgColor(it)
} else {
drawStarDrawable(it)
}
it.restoreToCount(saved)
}
}

/**
* 绘制星星默认未选中背景
*/

private fun drawStar(canvas: Canvas) {
for (i in 0 until starCount) {
val starLeft = i * (starSize + starPadding)
canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
}
}

/**
* 绘制高亮纯颜色
*/

private fun drawStarBgColor(canvas: Canvas) {
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starPaint)
}

/**
* 绘制高亮图标
*/

private fun drawStarDrawable(canvas: Canvas) {
starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}

/**
* 获取星星绘制宽度
*/

private fun getStarProgressWidth(): Float {
val percent = progress / 100f
val starDrawCount = percent * starCount
return when (starType) {
StarType.HALF.ordinal -> {
ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
StarType.WHOLE.ordinal -> {
ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
else -> {
starDrawCount * starSize + starDrawCount.toInt() * starPadding
}
}
}

private fun ceilWhole(x: Float): Float {
return ceil(x)
}

private fun ceilHalf(x: Float): Float {
// 四舍五入 1.3->1+0.5->1.5 1.7->2
val round = round(x)
return when {
round < x -> round + 0.5f
round > x -> round
else -> x
}
}

/**
* 星星的绘制进度
*/

fun setProgress(progress: Int) {
var p = progress
if (p < 0) p = 0
if (p > 100) p = 100
this.progress = p
postInvalidate()
}

fun setProgress(currentValue: Float, totalValue: Float) {
setProgress((currentValue * 100 / totalValue).toInt())
}

fun setOnCommonRatingBarListener(listener: OnCommonRatingBarListener) {
this.listener = listener
}

private var listener: OnCommonRatingBarListener? = null

interface OnCommonRatingBarListener {
fun onClickProgress(progress: Int)
fun onScrollProgress(progress: Int)
}

enum class StarType {
NORMAL, HALF, WHOLE
}

}

image.png

拓展

  • 修改纯色方法配合LinearGradient,可以有渐变的选中效果
收起阅读 »

Android 控制 ContentProvider的创建

序言随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentPr...
继续阅读 »

序言

随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentProvider中获取了敏感信息,又没有提供控制方法。我们就很被动。于是我花了点时间研究了怎么hook contentProvider的创建。让其在用户同意后再初始化。

方案1

声明在清单文件中的ContentProvider 会在应用启动后就创建。具体是在 ActivityThread的handleBindApplication方法中。(以下截图为Android 30的ActivityThread) 在这里插入图片描述 具体就在这一句 在这里插入图片描述

installContentProviders实现如下 在这里插入图片描述 最终是通过AppComponentFactory的instantiateProvider方法创建。 在这里插入图片描述 在这里插入图片描述

而AppComponentFactory是Android 28以后系统提供给我们的一个hook的工厂类。可以通过清单文件指定,在这里面可以hook 所有组件的初始化。 在这里插入图片描述 这么指定 在这里插入图片描述

但是在Android 28以下,比如这个截图是Android 25.没有这类,ContentProvider直接通过反射获得。无法通过该类来修改。 在这里插入图片描述

最终方案

为了兼容性,考虑如下方案。在调用installContentProviders前,如果这个data里面的providers为空岂不是不会走installContentProviders方法了吗。 在这里插入图片描述 这个data 是一个AppBindData类型,通过handleBindApplication方法的参数传入。会保存到ActivityThread的 mBoundApplication 字段中。 在这里插入图片描述

于是就可以通过获取这个mBoundApplication 字段中的providers 来保存要初始化的provider。再讲providers置为空即可。到了用户同意以后,再去通过反射调用ActivityThread的installContentProviders方法即可。 在这里插入图片描述

hook时机

这个时机只有Application的attachBaseContext方法中。该方法会比installContentProviders提前执行。

最后的代码App中

public class MyApp extends Application {

static MyApp app;

/**
*用户同意
*/

public static void agree(Action action) {
HookUtil.initProvider(app);
action.doAction();
}

public interface Action {
void doAction();
}


@Override
protected void attachBaseContext(Context base) {
app = this;
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
super.attachBaseContext(base);
}
}

HookUtil

package com.zgh.testcontentprovider;

import android.content.Context;

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


/**
* Created by zhuguohui
* Date: 2021/9/13
* Time: 11:23
* Desc:
*/

public class HookUtil {

private static Object providers;

private static Method installContentProvidersMethod;
private static Object currentActivityThread;

/*
*用户同意后调用
*/

public static void initProvider(Context context){
try {
installContentProvidersMethod.invoke(currentActivityThread,context,providers);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void attachContext() throws Exception {

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

hookInstallContentProvider(activityThreadClass);

}

private static void hookInstallContentProvider(Class activityThreadClass) throws Exception{
Field appDataField = activityThreadClass.getDeclaredField("mBoundApplication");
appDataField.setAccessible(true);
Object appData= appDataField.get(currentActivityThread);
Field providersField= appData.getClass().getDeclaredField("providers");
providersField.setAccessible(true);
providers = providersField.get(appData);
//清空provider,避免有些sdk通过provider来初始化
providersField.set(appData,null);

installContentProvidersMethod = activityThreadClass.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
}
}


收起阅读 »

Jetpack Compose Banner即拿即用

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。 效果图 accompanist组库 accompanist 旨在为Jetpack Compose提供补充功能的组库,里面有非...
继续阅读 »

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。


效果图


gif图.gif


accompanist组库


accompanist


旨在为Jetpack Compose提供补充功能的组库,里面有非常多很好用的实验性功能,之前用过的加载网络图片的rememberImagePainter就是其中之一,而做Banner的话需要用到的是其中的Pager库。


//导入依赖 
implementation "com.google.accompanist:accompanist-pager:$accompanist_pager"

这里我用的是0.16.1,因为其他库也是这个版本,目前最新是0.18.0


关键代码


1、rememberPagerState

用于记录分页状态的变量,一共有5个参数,我们用到了4个,还有一个是initialPageOffset,可以设置偏移量


val pagerState = rememberPagerState(
//总页数
pageCount = list.size,
//预加载的个数
initialOffscreenLimit = 1,
//是否无限循环
infiniteLoop = true,
//初始页面
initialPage = 0
)

2、HorizontalPager

用于创建一个可以横向滑动的分页布局,把上面的rememberPagerState传进去,其他也没啥


HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize(),
) { page ->
Image(
painter = rememberImagePainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

3、让HorizontalPager自己动起来

这里有两个方法可以让HorizontalPager动起来,一个是animateScrollToPage,另一个是scrollToPage,从名字上都可以看出来带animate的是有动画效果的方法,也正是我想要的东西。


//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
pagerState.animateScrollToPage((pagerState.currentPage + 1) % pagerState.pageCount)
}
}

在控件里添加这行代码就可以让控件自动起来了


但这是一段看起来没问题的代码


假设页面总数pagerState.pageCount为2,当((pagerState.currentPage + 1) % pagerState.pageCount) == 0时跳转到第1个页面,但最后的效果是这样的


gif图2.gif
轮播图往左滑了,而且还出现了轮播图中间页面的画面,页面有点闪烁的感觉。


修改后

//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是pagerState的infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}

只修改了animateScrollToPage参数的值,看到这里可能有人会问:pagerState.currentPage + 1不会报错吗?


确实不会!


因为当rememberPagerState中的infiniteLoop(无限循环)参数设置为true时最大页码其实为Int.MAX_VALUE,而currentPage只是当前页面的索引,并不是真实的页码。


也就是说,当Banner有4个页面,这里传个5的时候,并不会报错,而且animateScrollToPage会自动将这个"5"转换为页面索引,以保证下次使用currentPage不会出错。(菜鸟,我!啊吧啊吧看了好一阵子源码没看到这个是哪里转的)


不过有些地方值得注意:



调用pagerState.animateScrollToPage(target)的时候



  • 当target > pageCount 或 target > currentPage的时候,控件向右滑动

  • 当target < pageCount 且 target < currentPage的时候,控件向左滑动

  • 另外如果currentPage和target当两者相差页面大于4的时候只会在动画中显示(currentPage、currentPage + 1、target - 1、target)四个页面



以此类推,如果改为-1的话就是不断往左自动滑动啦


pagerState.animateScrollToPage(pagerState.currentPage - 1)

Banner中定义了几个参数,indicatorAlignment可以设置指示点的位置,默认为底部居中


/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.mipmap.ic_web,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String) -> Unit = {}
)

Alignment.BottomStart

bannerLeft.png


Alignment.BottomEnd

bannerRight.png


发现了个奇怪的问题


//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}

这段代码里,由于ReCompose时机是因为pagerState.currentPage这个值产生变化的时候;当我们触摸着HorizontalPager这个控件期间,动画会挂起取消


所以当我们滑动但是不滑动到上一页或下一页,且在本次跳转页面动画触发后才松开手指的时候,就会导致自动滚动停止的问题发生。


像这样


gif图3.gif


问题解决


问题的解决思路也不复杂,只需要在手指按下时记录当前页面索引,手指抬起时判断当前页面索引是否有所改变,如果没有改变的话就手动触发动画。


PointerInput Modifier


这是用于处理手势操作的Modifier,它为我们提供了PointerInputScope作用域,在这个作用域中我们可以使用一些有关于手势的API。


例如:detectDragGestures


我们可以在detectDragGestures中拿到拖动开始/拖动时/拖动取消/拖动结束的回调,但其中的onDrag(拖动时触发回调)是必传的参数,这会导致HorizontalPager控件拖动手势失效。


suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

所以最后使用了更基础的API - awaitPointerEvent,我们需要在awaitPointerEventScope方法为我们提供的AwaitPointerEventScope作用域内使用它。


HorizontalPager(
state = pagerState,
modifier = Modifier.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
...
)

另外,由于轮播图可以点击跳转到详情页面,所以还需要区分单击事件和滑动事件,需要用到pagerState.targetPage(当前页面是否有任何滚动/动画正在执行),如果没有的话就会返回null。


但只要用户拖动了Banner,松手的时候targetPage就不会为null。


//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}

gif图4.gif
解决!(gif图切换的时候卡了一下,真机上没问题)


即拿即用


给小林一个star


import androidx.annotation.DrawableRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.delay

/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.mipmap.ic_web,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String) -> Unit = {}
) {

Box(
modifier = Modifier.background(MaterialTheme.colors.background).fillMaxWidth()
.height(220.dp)
) {

if (list == null) {
//加载中的图片
Image(
painterResource(loadImage),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
val pagerState = rememberPagerState(
//总页数
pageCount = list.size,
//预加载的个数
initialOffscreenLimit = 1,
//是否无限循环
infiniteLoop = true,
//初始页面
initialPage = 0
)

//监听动画执行
var executeChangePage by remember { mutableStateOf(false) }
var currentPageIndex = 0

//自动滚动
LaunchedEffect(pagerState.currentPage, executeChangePage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}

HorizontalPager(
state = pagerState,
modifier = Modifier.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
.clickable(onClick = { onClick(list[pagerState.currentPage].linkUrl) })
.fillMaxSize(),
) { page ->
Image(
painter = rememberImagePainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

Box(
modifier = Modifier.align(indicatorAlignment)
.padding(bottom = 6.dp, start = 6.dp, end = 6.dp)
) {

//指示点
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
for (i in list.indices) {
//大小
var size by remember { mutableStateOf(5.dp) }
size = if (pagerState.currentPage == i) 7.dp else 5.dp

//颜色
val color =
if (pagerState.currentPage == i) MaterialTheme.colors.primary else Color.Gray

Box(
modifier = Modifier.clip(CircleShape).background(color)
//当size改变的时候以动画的形式改变
.animateContentSize().size(size)
)
//指示点间的间隔
if (i != list.lastIndex) Spacer(
modifier = Modifier.height(0.dp).width(4.dp)
)
}
}

}
}

}

}

/**
* 轮播图数据
*/
data class BannerData(
val imageUrl: String,
val linkUrl: String
)

特别感谢


RugerMc 手势处理


apk下载链接


项目地址


欢迎Star~PlayAndroid


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

三步实现一个自定义任意路径的嫦娥奔月(Flutter版)

前言 可能不少人看到这个标题,心里想的是: 要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧 不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货 要是真这么想的话,我只能说: 下面给大家整个活...
继续阅读 »

前言


可能不少人看到这个标题,心里想的是:


要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧


不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货


要是真这么想的话,我只能说:



我看你是完全不懂哦
真拿你没办法

下面给大家整个活,为大家介绍一下我们“listView是万能的”教会的唯一真主和慈父——ListView,是如何通过自定义,来实现这个需求的;


先放上效果图:


最终效果

前期准备,需要自定义并提供给ListView的部分;


1. 首先,我们需要一个又大又圆的月亮:


这里呢,就先用一个背景图替代,所以把一个背景图放到stack底层中:


Stack(
children: [
Positioned.fill(
child: Image.asset("img/bg_mid_autumn.jpg",fit: BoxFit.cover,),
),
Positioned.fill(
/// 自定义的ListView
/// 先以RecyclerView的形式命个名,毕竟思路参考自Android 的RecyclerView
child: RecyclerView.builder(...),
),
],

2. 以及主人公————嫦娥:


把它以item的形式加入到自定义ListView中


RecyclerView.builder(
...
itemBuilder: (context, index) {
return Container(
width: 100,
alignment: AlignmentDirectional.topCenter,
child: Image.asset("img/img_chang_e.png",fit: BoxFit.cover,width: 100,height: 100,),
);
}
)

3. 搞一个提供规划登月路径的Widget:


class ImageEditor extends CustomPainter {
ImageEditor();

Path? drawPath;

final Paint painter = new Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 10;

void update(Offset offset) {
if (drawPath == null) {
drawPath = Path()..moveTo(offset.dx, offset.dy);
}

drawPath?.lineTo(offset.dx, offset.dy);
}

@override
void paint(Canvas canvas, Size size) {
if (drawPath != null) {
canvas.drawPath(drawPath!, painter);
}
}

@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}

搞定~正好三步;


大家先别急着骂,先不提这个自定义的ListView以及一堆莫名其妙的东西从哪来的,就说是不是三步吧


虽然要实现这三步,需要做如下工作来实现:


关于奔月动画的实现原理、方式


这块参考自android的RecyclerView的自定义LayoutManager的部分,具体详细步骤可以看这个大佬的文章:


# Android自定义LayoutManager第十一式之飞龙在天


这块仅供提供思路,虽说Flutter中没有RecyclerView这种神器,也没有layoutManager这种东西,甚至onMeasure、onLayout这块的触发时机等方面跟android都不同;


但是下沉到onMeasure、onDraw、onLayout这个层面,其实都是一样的,并非不可参考


分析与实现,需要改造ListView哪些地方:


1. 首先,我们先从 ListView 本身开始:


ListView的结构其实并不复杂,或者嚣张点,大部分可滑动的View,也无非就在那几个类上面修修改改,换句话说:


学姐

当然我知道各位一点都不喜欢看代码(其实是因为这部分太多了……放一篇介绍文章中放不下),那我简化一下,只提一下这次涉及的部分和浅层解析,毕竟这块东西我也是简单了解一下(纯属个人理解,有错误请狠狠的打我脸):



  1. ListView、nestedScrollView、CustomScrollView等滑动View,都是直接或者间接继承自ScrollView,ScrollView这个抽线类,就是黑龙江职业学院,那几个可滑动View都是受ScrollView管控;

  2. ScrollView 中管事的就是Scrollable ,把它当成学生会就行;

  3. 在这次中,Scrollable 中有这么几个类要知道:ViewPortScrollControllScrollPostion


ViewPort负责管理提供可视范围视图(学生会生活部?负责提供我们去哪里查寝)、ScrollPostion负责记录滚动位置、最大最小距离之类的信息(学生会书记?记录一下查寝结果)、ScrollControll负责统筹滚动视图的展示、动画等部分(这个我懂,这个是主席,张美玉学姐好);


2. 打破ListView不可滚动溢出的限制,并控制初始位置:


要是嫌麻烦,直接往listView的item列表的头尾处,加个listView大小的空白页,也是可以实现同样效果的


用于装逼,了解listView逻辑思路的写法:



  1. 按照上面的分析,如果要让listView可以滚动溢出,那么需要做的事,就是去找ViewPort的麻烦;


下面我们来回忆一下,一个控件,想要显示,不可避免要经过的三个步骤是:


1、measure;2、layout;3、draw


要想获取滚动限制、明显是measure或者layout部分的东西,结合ScrollPostion的_minScrollExtent和_maxScrollExtent的来源,可以定位可以修改的位置是在 RenderViewPortperformLayout 方法中,调用 scrollPosition 的 applyContentDimensions 方法的地方;


比如说这样修改,将ListView本身大小作为滚动溢出范围:


do {
assert(offset.pixels != null);
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
if (offset.applyContentDimensions(

/// 在这里调整可溢出范围,比如说下面就把size.width 作为可溢出范围,最小范围减少Size.width,最大范围增加Size.width;
math.min(-size.width, _minScrollExtent + mainAxisExtent * anchor),
math.max(0.0,
_maxScrollExtent - mainAxisExtent * (1.0 - anchor) + size.width),
)) break;
}
count += 1;
} while (count < _maxLayoutCycles);


  1. 然后让ListView的初始展示位置,设置到-Size.width的位置;


在这里我的做法是通过 LayoutBuilder 获取约束范围,然后将约束最大值直接赋值给 ScrollController,例如下面代码:


 LayoutBuilder(builder: (_context, _constraint) {

return RecyclerView.builder(
scrollDirection: Axis.horizontal,

/// 这里将约束的最大值的负数提供到ScrollController的initialScrollOffset中
controller: ScrollController(
initialScrollOffset: -_constraint.maxWidth),
itemCount: 3,
reverse: true,
addRepaintBoundaries: false,
....
)
}

PS : 这块的源码,虽说我们只需要改这么一个小点,但是像override这种方式都会因为一堆私有变量什么的无法获取,所以直接从 RenderViewportBase 到 RenderViewPort 都完整复制出来吧



  1. 最后将自定义好的ViewPort的Render部分,传给ViewPort的Widget部分,最后放到自定义ListView的buildViewPort部分:(在这里,我将这个提供溢出滚动的ViewPort命名为OverScrollViewPort)


@override
Widget buildViewport(BuildContext context, ViewportOffset offset, AxisDirection axisDirection, List<Widget> slivers) {
if (shrinkWrap) {
return OverScrollShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
clipBehavior: clipBehavior,
);
}

return OverScrollViewPort(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
clipBehavior: clipBehavior,
);
}

如果我没有遗漏部分的话,这时候运行一下代码,应该是这种效果:


最终效果

3. 修改绘制,按path要求绘制:


如果你做好了准备工作,提供了一个自定义路径出来,那么将这个path传到负责绘制的 RenderObject 中,在paint方法中获取滑动比例对应path的位置,就调整绘制位置:


@override
void paint(PaintingContext context, Offset offset) {
...

/// 在这里处理path。
Path? customPath;
PathMetric? pm;
double? length;
if (layoutManager is PathLayoutManager) {
customPath = layoutManager.path;

var pathMetrics = customPath.computeMetrics(forceClosed: false);
pm = pathMetrics.elementAt(0);
length = pm.length;
}

while (child != null) {
double mainAxisDelta = childMainAxisPosition(child);
final double crossAxisDelta = childCrossAxisPosition(child);
...

/// 关于这块去掉原先 mainAxisDelta < constraints.remainingPaintExtent 部分的原因
/// 是因为之前第一个item会在滚动到边界前就被移除绘制
/// 具体是什么地方修改导致的,忘了(๑>؂<๑)
if (mainAxisDelta + paintExtentOf(child) > 0) {
if (customPath != null) {
var percent = (childOffset.dx + child.size.width) /
(child.size.width + constraints.viewportMainAxisExtent);
var tf = pm!.getTangentForOffset(length! * percent);
print("test :${tf?.position}");

var childItemOffset = childOffset;

if (tf?.position != null) {
/// 这里的50 魔法数,是因为之前设置item的height为100,
/// 因为listView好像强制将item的高度固定为listView的高度(横向情况)
/// 这块找个时间研究下怎么搞
/// 强调下,好孩子不要学我这写法
childItemOffset = Offset(
tf!.position.dx - child.size.width / 2, tf.position.dy - 50);
}

context.pushTransform(
needsCompositing,
childItemOffset,
Matrix4.identity(),
// Pre-transform painting function.
painter,
);
} else {
context.paintChild(child, childOffset);
}
}

...
}

...
}

PS:我这里弄了个LayoutManager,其实就是新建个类,把它从widget传到 renderObject &@&%……#;path的处理这块也是有问题的,不应该放在这里搞,好孩子不要学我这么搞,我这是实验性代码…………


当然,要想做到完美复刻RecyclerView,还有不少地方要改动


比如说,你给item加个点击事件,你会发现……现在这种方式,仅仅是改变了绘制的位置,item本身并未移动:


注意看弹toast前的点击位置,明明是左上角


现存问题

我猜想:这里就要涉及到listView 的 insertAndLayout 部分了,进而涉及到整体的滑动逻辑…………或者是hitTest的部分?(或许这是part 2新篇预告?)


在现在这个基础上,还有可以拓展的方面:


除了嫦娥奔月效果,其实还可以实现一些其他效果,例如:


覆盖翻页效果
覆盖翻页效果


item变换
item变换


另外在ParentData等部分中,也有一些有点意思的东西,个人感觉都挺有用的


题外话,上面正文的做法,为什么我个人并不推荐


在我看来,现在文中的这种自定义方式是不符合flutter的推荐方式的:


在我的理解中,在做flutter的自定义的时候,有个比较重要的一句话是需要遵守的:


万物均为widget


所以,如果可以的话,尽量使用widget来代替回调、方法这种,如果无法避免,也尽量约束到一个widget、及其对应element、renderObject;


所以,现在文中的方式,在我看来,虽然能实现需求,但是是通过各种回调、耦合了各个widget的及其对应的element、renderObject,因此不是flutter的良好代码,


这段代码,应急可以,偷懒也行,用于学习思路,分析步骤也是没问题的,但是,不推荐真这么搞哈


这篇文章的主要目的,是参考Android的实现方式,来分享思路与分析flutter中的listView,以及最重要的:



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

Compose 实现月亮阴晴圆缺动画

效果图 人有悲欢离合,月有阴晴圆缺,此事古难全。 但愿人长久,千里共婵娟。 恰逢中秋佳节,我们今天就使用Compose来实现一下月相变化动画吧~ 感兴趣的同学可以点个Star : Compose 实现月亮阴晴圆缺动画 主要思路 满天繁星 为了实现月相动画...
继续阅读 »

效果图




人有悲欢离合,月有阴晴圆缺,此事古难全。

但愿人长久,千里共婵娟。

恰逢中秋佳节,我们今天就使用Compose来实现一下月相变化动画吧~

感兴趣的同学可以点个Star : Compose 实现月亮阴晴圆缺动画



主要思路


满天繁星


为了实现月相动画,我们首先需要一个背景,因此我们需要一个好看的星空,最好还有闪烁的效果

为为实现星空背景,我们需要做以下几件事



  1. 绘制背景

  2. 生成几十个星星,在背景上随机分布

  3. 通过scalealpha动画,实现每个星星的闪烁效果


我们一起来看下代码


@Composable
fun Stars(starNum: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val list = remember { mutableStateListOf<Star>() }
LaunchedEffect(true) {
for (i in 0..starNum) {
delay(100L)
//添加星星,它们的位置在屏幕上随机
list.add(Star(maxWidth.value * density, maxHeight.value * density))
}
}
list.forEach {
Star(it)
}
}
}

@Composable
fun Star(star: Star) {
var progress: Float by remember { mutableStateOf(0f) }
val infiniteTransition = rememberInfiniteTransition()
....
star.updateStar(progress) // 通过动画更新progress,从而更新star的属性值
Canvas(modifier = Modifier.wrapContentSize()) {
scale(star.scale, Offset(star.x, star.y)) { // 缩放动画
drawCircle(
star.starColor,
star.radius,
center = Offset(star.x, star.y),
alpha = star.alpha // alpha动画
)
}
}
}

月相变化


月相,天文学术语。(phase of the moon)是天文学中对于地球上看到的月球被太阳照明部分的称呼。随着月亮每天在星空中自东向西移动一大段距离,它的形状也在不断地变化着,这就是月亮位相变化,叫做月相。

它的变化过程如下图所示



每个阶段都有各自的名字,如下图所示:


可以看出,月相变化过程还是有些复杂的,那我们怎么实现这个效果呢?


思路分析


为了实现月相变化,首先我们需要画一个圆,代表月亮,最终的满月其实就是这样,比较简单

有了满月,如何在它的基础上,画出其它的月相呢?我们可以通过图像混合模式来实现


图像混合模式定义的是,当两个图像合成时,图像最终的展示方式。在Androd中,有相应的API接口来支持图像混合模式,即Xfermode.

图像混合模式主要有以下16种,以下这张图片从一定程度上形象地说明了图像混合的作用,两个图形一圆一方通过一定的计算产生不同的组合效果,具体如下


我们为了实现月相动画,主要需要使用以下两种混合模式



  • DST_OUT:只在源图像和目标图像不相交的地方绘制【目标图像】,在相交的地方根据源图像的alpha进行过滤,源图像完全不透明则完全过滤,完全透明则不过滤

  • DST_OVER:将目标图像放在源图像上方


我们已经了解了图形混合模式,那么需要在满月上画什么才能实现其它效果呢?

我们可以通过在满月上放一个半圆+一个椭圆来实现



  1. 如上所示,椭圆上水平的线叫长轴,竖直的线叫短轴

  2. 短轴不变,长轴半径从0到满月半径发生变化,再加上一个半圆,就可以实现不同的月相

  3. 比如为了画上蛾眉月,可以通过左半边画半圆,再加上一个椭圆,两都都使用DST_OVER混合模式来实现,就实现了它们两的并集,然后覆盖在下层满月上,就实现了上蛾眉月

  4. 为了画渐盈凸月,则同样就左半边以DST_OVER画半圆,再以DST_OUT画椭圆,就只剩下半圆与椭圆不相交的部分,再与下层的满月混合,就实现了渐盈凸月


这样说可能还是比较抽象,感兴趣的同学可下载源码详细了解下


源码实现


//月亮动画控件
@Composable
fun Moon(modifier: Modifier) {
var progress: Float by remember { mutableStateOf(0f) }
BoxWithConstraints(modifier = modifier) {
Canvas(
modifier = Modifier
.size(canvasSize)
.align(Alignment.TopCenter)
) {
drawMoonCircle(this, progress)
drawIntoCanvas {
it.withSaveLayer(Rect(0f, 0f, size.width, size.height), paint = Paint()) {
if (progress != 1f) {
//必须先画半圆,再画椭圆
drawMoonArc(this, it, paint, progress)
drawMoonOval(this, it, paint, progress)
}
}
}
}
}
}

// 1.首先画一个满月
private fun drawMoonCircle(scope: DrawScope, progress: Float) {
//....
drawCircle(Color(0xfff9dc60))
}

// 2. 画半圆
private fun drawMoonArc(scope: DrawScope, canvas: Canvas, paint: Paint, progress: Float) {
val sweepAngle = when { //从新月到满月在一边画半圆,从满月回到新月则在另一边画半圆
progress <= 0.5f -> 180f
progress <= 1f -> 180f
progress <= 1.5f -> -180f
else -> -180f
}
paint.blendMode = BlendMode.DstOver //半圆的混合模式始终是DstOver
scope.run {
canvas.drawArc(Rect(0f, 0f, size.width, size.height), 90f, sweepAngle, false, paint)
}
}

// 3. 画椭圆
private fun drawMoonOval(scope: DrawScope, canvas: Canvas, paint: Paint, progress: Float) {
val blendMode = when { //椭圆的混合模式会发生变化,这里需要注意下
progress <= 0.5f -> BlendMode.DstOver
progress <= 1f -> BlendMode.DstOut
progress <= 1.5f -> BlendMode.DstOut
else -> BlendMode.DstOver
}
paint.blendMode = blendMode
scope.run {
canvas.drawOval(
Rect(offset = topLeft, size = Size(horizontalAxis, verticalAxis)), //椭圆的长轴会随着动画变化
paint = paint
)
}
}

如上所示:



  1. 主要就是3个步骤,画满月,再画半圆,再画椭圆

  2. 半圆的混合模式始终是DstOver,而椭圆的混合模式会发生变化,它们的颜色都是黑色。

  3. 可以看到半圆与椭圆新建了一个Layer,混合模式的变化,表示的就是最后剩下的是它们的并集,还是Dst不相交的部分,最后覆盖到满月上,所以必须先画半圆

  4. 随着动画的变化,椭圆的长轴会发生变化,这样就可以实现不同的月相


诗歌打字机效果


上面其实已经做得差不多了,我们最后再添加一些诗歌,并为它们添加打字机效果


@Composable
fun PoetryColumn(
list: List<Char>,
offsetX: Float = 0f,
offsetY: Float = 0f
) {
val targetList = remember { mutableStateListOf<Char>() }
LaunchedEffect(list) {
targetList.clear()
list.forEach {
delay(500) //通过在LaunchedEffect中delay实现动画效果
targetList.add(it)
}
}
//将 Jetpack Compose 环境的 Paint 对象转换为原生的 Paint 对象
val textPaint = Paint().asFrameworkPaint().apply {
//...
}
Canvas(modifier = Modifier.wrapContentSize()) {
drawIntoCanvas {
for (i in targetList.indices) {
it.nativeCanvas.drawText(list[i].toString(), x, y, textPaint)
y += delta // 更新文字y轴位置
}
}
}
}

如上所示,代码比较简单



  1. 通过在LaunchedEffect中调用挂起函数,来实现动画效果

  2. 为了实现竖直方向的文字,我们需要使用Paint来绘制Text,而不能使用Text组件

  3. Compose目前还不支持直接绘制Text,所以我们需要调用asFrameworkPaint将其转化为原生的Paint


总结


通过以上步骤,我们就通过Compose实现了月相阴晴圆缺+星空闪耀+诗歌打字机的动画效果

开发起来跟Android自定义绘制其实并没有多大差别,代码量因为Compose强大的API与声明式特点可能还有所减少

在我看来,Compose已经相当成熟了,而且将是Android UI的未来~


开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~


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

iOS KVO的基本使用

iOS
iOS - 关于 KVO 的一些总结1. 什么是 KVOKVO的全称是Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发KVO的监...
继续阅读 »

iOS - 关于 KVO 的一些总结

1. 什么是 KVO

  • KVO的全称是Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发KVO的监听方法来通知观察者。KVO是在MVC应用程序中的各层之间进行通信的一种特别有用的技术。
  • KVONSNotification都是iOS中观察者模式的一种实现。
  • KVO可以监听单个属性的变化,也可以监听集合对象的变化。监听集合对象变化时,需要通过KVCmutableArrayValueForKey:等可变代理方法获得集合代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发KVO的监听方法。集合对象包含NSArrayNSSet
  • KVOKVC有着密切的关系,如果想要深入了解KVO,建议先学习KVC


传送门:iOS - 关于 KVC 的一些总结

2. KVO 的基本使用

KVO使用三部曲:添加/注册KVO监听、实现监听方法以接收属性改变通知、 移除KVO监听。

  1. 调用方法addObserver:forKeyPath:options:context: 给被观察对象添加观察者;
  2. 在观察者类中实现observeValueForKeyPath:ofObject:change:context:方法以接收属性改变的通知消息;
  3. 当观察者不需要再监听时,调用removeObserver:forKeyPath:方法将观察者移除。需要注意的是,至少需要在观察者销毁之前,调用此方法,否则可能会导致Crash

2.1 注册方法

/*
** target: 被观察对象
** observer:观察者对象
** keyPath: 被观察对象的属性的关键路径,不能为nil
** options: 观察的配置选项,包括观察的内容(枚举类型):
NSKeyValueObservingOptionNew:观察新值
NSKeyValueObservingOptionOld:观察旧值
NSKeyValueObservingOptionInitial:观察初始值,如果想在注册观察者后,立即接收一次回调,可以加入该枚举值
NSKeyValueObservingOptionPrior:分别在值改变前后触发方法(即一次修改有两次触发)
** context: 可以传入任意数据(任意类型的对象或者C指针),在监听方法中可以接收到这个数据,是KVO中的一种传值方式
如果传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问context就可能导致Crash
*/

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

2.2 监听方法

如果对象被注册成为观察者,则该对象必须能响应以下监听方法,即该对象所属类中必须实现监听方法。当被观察对象属性发生改变时就会调用监听方法。如果没有实现就会导致Crash

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
/*
** keyPath:被观察对象的属性的关键路径
** object: 被观察对象
** change: 字典 NSDictionary,属性值更改的详细信息,根据注册方法中options参数传入的枚举来返回
key为 NSKeyValueChangeKey 枚举类型
{
1.NSKeyValueChangeKindKey:存储本次改变的信息(change字典中默认包含这个key)
{
对应枚举类型 NSKeyValueChange
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
如果是对被观察对象属性(包括集合)进行赋值操作,kind 字段的值为 NSKeyValueChangeSetting
如果被观察的是集合对象,且进行的是(插入、删除、替换)操作,则会根据集合对象的操作方式来设置 kind 字段的值
插入:NSKeyValueChangeInsertion
删除:NSKeyValueChangeRemoval
替换:NSKeyValueChangeReplacement
}
2.NSKeyValueChangeNewKey:存储新值(如果options中传入NSKeyValueObservingOptionNew,change字典中就会包含这个key)
3.NSKeyValueChangeOldKey:存储旧值(如果options中传入NSKeyValueObservingOptionOld,change字典中就会包含这个key)
4.NSKeyValueChangeIndexesKey:如果被观察的是集合对象,且进行的是(插入、删除、替换)操作,则change字典中就会包含这个key,
这个key的value是一个NSIndexSet对象,包含更改关系中的索引
5.NSKeyValueChangeNotificationIsPriorKey:如果options中传入NSKeyValueObservingOptionPrior,则在改变前通知的change字典中会包含这个key。
这个key对应的value是NSNumber包装的YES,我们可以这样来判断是不是在改变前的通知[change[NSKeyValueChangeNotificationIsPriorKey] boolValue] == YES]
}
** context:注册方法中传入的context
*/

}

2.3 移除方法

在调用注册方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期。至少需要在观察者销毁之前,调用以下方法移除观察者,否则如果在观察者被释放后,再次触发KVO监听方法就会导致Crash

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;

2.4 使用示例

以下使用KVOperson对象添加观察者为当前viewController,监听person对象的name属性值的改变。当name值改变时,触发KVO的监听方法。

- (void)viewDidLoad {
[super viewDidLoad];

self.person = [HTPerson new];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld) context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
self.person.name= @"张三";
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
NSLog(@"keyPath:%@",keyPath);
NSLog(@"object:%@",object);
NSLog(@"change:%@",change);
NSLog(@"context:%@",context);
}

- (void)dealloc
{
[self.person removeObserver:self forKeyPath:@"name"];
}

keyPath:name
object:
change:{ kind = 1; new = "\U70b9\U51fb"; old = ""; }
context:(null)

2.5 实际应用

KVO主要用来做键值观察操作,想要一个值发生改变后通知另一个对象,则用KVO实现最为合适。斯坦福大学的iOS教程中有一个很经典的案例,通过KVOModelController之间进行通信。如图所示: 斯坦福大学 KVO示例

2.6 KVO 触发监听方法的方式

KVO触发分为自动触发和手动触发两种方式。

2.6.1 自动触发

① 如果是监听对象特定属性值的改变,通过以下方式改变属性值会触发KVO

  • 使用点语法
  • 使用setter方法
  • 使用KVCsetValue:forKey:方法
  • 使用KVCsetValue:forKeyPath:方法

② 如果是监听集合对象的改变,需要通过KVCmutableArrayValueForKey:等方法获得代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发KVO。集合对象包含NSArrayNSSet

2.6.2 手动触发

① 普通对象属性或是成员变量使用:

- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

② NSArray对象使用:

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;

③ NSSet对象使用:

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;


收起阅读 »

ios Category无法覆写系统方法?

iOS
Category无法覆写系统方法?这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思,另一点就是人多力量大,多人讨论就会获得多种思路。首次提出这个问题的是反向抽烟,他遇到了不能用 Category 覆写系统方法的现象。问题抛到我这,我验...
继续阅读 »

Category无法覆写系统方法?

这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思,另一点就是人多力量大,多人讨论就会获得多种思路。

首次提出这个问题的是反向抽烟,他遇到了不能用 Category 覆写系统方法的现象。问题抛到我这,我验证了这个有点奇怪的现象,并决定好好探究一下,重看了 Category 那部分源码仍没有找到合理解释,于是将这个问题抛到开发群里,最后由皮拉夫大王在此给出了最为合理的解释。之后我又顺着他的思路找到了一些更有力的证据。以下是这一过程的经历。

问题提出

以下内容出自反向抽烟:

背景:想为 UITextField 提供单独的属性 placeholderColor ,用来直接设置占位符的颜色,这个时候使用分类设置属性,重写 setter 和 getter,set中直接使用 KVC 的方式对属性的颜色赋值;这个时候就有个bug,如果在其他类中使用 UITextField 这个控件的时候,先设置颜色,再设置文字,会发现占位符的颜色没有发生改变。

解决思路:首先想到 UITextField 中的 Label 是使用的懒加载,当有文字设置的时候,就会初始化这个label,这时候就考虑先设置颜色根本就没起到作用;

解决办法:在分类中 placeholderColor 的 setter 方法中,使用runtime的objc_setAssociatedObject先把颜色保存起来,这样就能保证先设置的颜色不会丢掉,然后需要重写 placeholder的setter方法,让在设置完文字的时候,拿到先前保存的颜色,故要在placeholderColor 的getter中用objc_getAssociatedObject取,这里有个问题点,在分类中重写 placeholder 的setter方法的话,在外面设置 placeholder 的时候,根本不走自己重写的这个 setPlaceholder方法,而走系统自带的,这里我还没研究。然后为了解决这个问题,我自己写了个setDsyPlaceholder方法,在setDsyPlaceholder里面对标签赋值,同时添加已经保存好的颜色,然后与setPlaceholder做交换,bug修复。

这里大家先不要关注解决 placeholderColor 的方式是否正确,以免思路走偏。我们应该避免使用Category 覆写系统方法的,但这里引出了一个问题:如果就是要覆写系统的方法,为啥没被执行?

问题探索

我测试发现自定义类是可以通过 Category 覆写的,只有系统方法不可以。当时选的是 UIViewController 的viewDidLoad 方法,其他几个 UIViewController 方法也试了都不可以。

测试代码如下:

1
2
3
4
5
6
7
8
9
#import "UIViewController+Test.h"

@implementation UIViewController (Test)

- (void)viewDidLoad {
NSLog(@"viewDidLoad");
}

@end

所以猜测:系统方法被做了特殊处理都不能覆写,只有自定义类可以覆写

有一个解释是:系统方法是会被缓存的,方法查找走了缓存,没有查完整的方法表。

这个说法好像能说得通,但是系统缓存是库的层面,方法列表的缓存又是另一个维度了。方法列表的缓存应该是应用间独立进行的,这样才能保证不同应用对系统库的修改不会相互影响,所以这个解释站不住脚。

这时有朋友提出他们之前使用Category 覆写过 UIScreen 的 mainScreen,是可以成功的。我试了下确实可以,观察之后发现该属性是一个类属性。又试了其他几个系统库的类属性,也都是可以的。

所以猜测变成了:只有系统实例方法不能被覆写,类属性,类方法可以覆写

这时已经感觉奇怪了,这个规律也说不通。后来又有朋友测试通过 Xcode10.3 能够覆写系统方法,好嘛。。。

这时的猜测又变成了:苹果在某个特定版本开始才做了系统方法覆写的拦截

可靠的证据

皮拉夫大王在此提出了很关键的信息,他验证了iOS12系统可以覆写系统方法(后来验证iOS13状况相同),iOS14不能覆写。

但iOS14的情况并不是所有的系统方法都覆盖不了,能否覆盖与类方法还是实例方法无关。

例如:UIResponder的分类,重写init 和 isFirstResponderinit可以覆盖,isFirstResponder不能覆盖。在iOS14的系统上NS的类,很多都可以被分类覆盖,但是UIKit的类,在涉及到UI的方法时,很多都无法覆盖。

这里猜测:系统做了白名单,命中白名单的函数会被系统拦截和处理

以下是对 iOS14 状况的验证,覆写isFirstResponder,打印method_list

1
2
3
4
5
6
7
8
unsigned int count;
Method *list = class_copyMethodList(UIResponder.class, &count);
for (int i = 0; i < count; i++) {
Method m = list[i];
if ([NSStringFromSelector(method_getName(m)) isEqualToString:@"isFirstResponder"]) {
IMP imp = method_getImplementation(m);
}
}

isFirstResponder会命中两次,两次po imp的结果是:

1
2
3
4
//第一次
(libMainThreadChecker.dylib`__trampolines + 67272)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])

同样的代码,在iOS12的设备也会命中两次,结果为:

1
2
3
4
//第一次
(SwiftDemo`-[UIResponder(xx) isFirstResponder] at WBOCTest.m:38)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])

所以可以确认的是,分类方法是可以正常添加到系统类的,但在iOS14的系统中,覆写的方法却被libMainThreadChecker.dylib里的方法接管了,导致没有执行。

那么问题来了,这个libMainThreadChecker.dylib库是干嘛的,它做了什么?

这个库对应了Main Thread Checker这个功能,它是在Xcode9新增的,因为开销比较小,只占用1-2%的CPU,启动时间占用时间不到0.1s,所以被默认置为开的状态。它在调试期的作用是帮助我们定位那些应该在主线程执行,却没有放到主线程的代码执行情况。

另外官方文档还有一个解释

The Main Thread Checker tool dynamically replaces system methods that must execute on the main thread with variants that check the current thread. The tool replaces only system APIs with well-known thread requirements, and doesn’t replace all system APIs. Because the replacements occur in system frameworks, Main Thread Checker doesn’t require you to recompile your app.

这个家伙会动态的替换尝试重写需要在主线程执行的系统方法,但也不是所有的系统方法。

终于找到了!这很好的解释了为什么本应被覆盖的系统方法却指向了libMainTreadChecker.dylib这个库,同时也解释了为什么有些方法可以覆写,有些却不可以。

测试发现当我们关闭了这个开关,iOS14的设备就可以正常执行覆写的方法了。

到此基本完事了,但还留有一个小疑问,那就是为什么iOS14之前的设备,不受这个开关的影响?目前没有找到实质的证据表明苹果是如何处理的,但可以肯定的是跟 Main Thread Checker 这个功能有关。

总结

稍微抽象下一开始处理问题的方式:遇到问题 -> 猜想 -> 佐证 -> 推翻猜想 -> 重新猜想 -> 再佐证。

这其实是错误的流程,猜想和佐证可以,但他们一般只会成为一个验证的样例,而不能带给我们答案。所以正确的处理方式是,不要把太多时间浪费在猜想和佐证猜想上,而应该去深挖问题本身。新的解题思路可以是这样的:遇到问题 -> 猜想 -> 深挖 -> 根据挖到的点佐证结果。

链接:https://zhangferry.com/2021/04/21/overwrite_system_category/

收起阅读 »

iOS14开发-网络

iOS
基础知识App如何通过网络请求数据?App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。主机通过本次网络请求指...
继续阅读 »

基础知识

App如何通过网络请求数据?

客户服务器模型

  1. App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。
  2. 主机通过本次网络请求指定的端口号找到对应的处理软件,然后将网络请求转发给该软件进行处理(处理的软件会运行在特定的端口)。针对 HTTP(HTTPS)请求,处理的软件会随着开发语言的不同而不同,如 Java 的 Tomcat、PHP 的 Apache、.net 的 IIS、Node.js 的 JavaScript 运行时等)
  3. 处理软件针对本次请求进行分析,分析的内容包括请求的方法、路径以及携带的参数等。然后根据这些信息,进行相应的业务逻辑处理,最后通过主机将处理后的数据返回(返回的数据一般为 JSON 字符串)。
  4. App 接收到主机返回的数据,进行解析处理,最后展示到界面上。
  5. 发送请求获取资源的一方称为客户端。接收请求提供服务的一方称为服务端

基本概念

URL

  • Uniform Resource Locator(统一资源定位符),表示网络资源的地址或位置。
  • 互联网上的每个资源都有一个唯一的 URL,通过它能找到该资源。
  • URL 的基本格式协议://主机地址/路径

HTTP/HTTPS

  • HTTP—HyperTextTransferProtocol:超文本传输协议。
  • HTTPS—Hyper Text Transfer Protocol over Secure Socket Layer 或 Hypertext Transfer Protocol Secure:超文本传输安全协议。

请求方法

  • 在 HTTP/1.1 协议中,定义了 8 种发送 HTTP 请求的方法,分别是GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT
  • 最常用的是 GET 与 POST

响应状态码

状态码描述含义
200Ok请求成功
400Bad Request客户端请求的语法出现错误,服务端无法解析
404Not Found服务端无法根据客户端的请求找到对应的资源
500Internal Server Error服务端内部出现问题,无法完成响应

请求响应过程

请求响应过程

JSON

  • JavaScript Object Notation。
  • 一种轻量级的数据格式,一般用于数据交互。
  • 服务端返回给 App 客户端的数据,一般都是 JSON 格式。

语法

  • 数据以键值对key : value形式存在。
  • 多个数据由,分隔。
  • 花括号{}保存对象。
  • 方括号[]保存数组。

key与value

  • 标准 JSON 数据的 key 必须用双引号""
  • JSON 数据的 value 类型:
    • 数字(整数或浮点数)
    • 字符串("表示)
    • 布尔值(true 或 false)
    • 数组([]表示)
    • 对象({}表示)
    • null

解析

  • 厘清当前 JSON 数据的层级关系(借助于格式化工具)。
  • 明确每个 key 对应的 value 值的类型。
  • 解析技术
    • Codable 协议(推荐)。
    • JSONSerialization。
    • 第三方框架。

URLSession

使用步骤

  1. 创建请求资源的 URL。
  2. 创建 URLRequest,设置请求参数。
  3. 创建 URLSessionConfiguration 用于设置 URLSession 的工作模式和网络设置。
  4. 创建 URLSession。
  5. 通过 URLSession 构建 URLSessionTask,共有 3 种任务。 (1)URLSessionDataTask:请求数据的 Task。  (2)URLSessionUploadTask:上传数据的 Task。 (3)URLSessionDownloadTask:下载数据的 Task。 
  6. 启动任务。
  7. 处理服务端响应,有 2 种方式。 (1)通过 completionHandler(闭包)处理服务端响应。 (2)通过 URLSessionDataDelegate(代理)处理请求与响应过程的事件和接收服务端返回的数据。

基本使用

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// get()
// post()
}

func get() {
// 1. 确定URL
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
// 2. 创建请求
let urlRequest = URLRequest(url: url!)
// cachePolicy: 缓存策略,App最常用的缓存策略是returnCacheDataElseLoad,表示先查看缓存数据,没有缓存再请求
// timeoutInterval:超时时间
// let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let config = URLSessionConfiguration.default
// 3. 创建URLSession
let session = URLSession(configuration: config)
// 4. 创建任务
let task = session.dataTask(with: urlRequest) { data, _, error in
if error != nil {
print(error!)
} else {
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
}
// 5. 启动任务
task.resume()
}

func post() {
let url = URL(string: "http://v.juhe.cn/toutiao/index")
var urlRequest = URLRequest(url: url!)
// 指明请求方法
urlRequest.httpMethod = "POST"
// 指明参数
let params = "type=top&key=申请的key"
// 设置请求体
urlRequest.httpBody = params.data(using: .utf8)
let config = URLSessionConfiguration.default
// delegateQueue决定了代理方法在哪个线程中执行
let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
let task = session.dataTask(with: urlRequest)
task.resume()
}
}

// MARK:- URLSessionDataDelegate
extension ViewController: URLSessionDataDelegate {
// 开始接收数据
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 允许接收服务器的数据,默认情况下请求之后不接收服务器的数据即不会调用后面获取数据的代理方法
completionHandler(URLSession.ResponseDisposition.allow)
}

// 获取数据
// 根据请求的数据量该方法可能会调用多次,这样data返回的就是总数据的一段,此时需要用一个全局的Data进行追加存储
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let result = String(data: data, encoding: .utf8)
if let result = result {
print(result)
}
}

// 获取结束
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
} else {
print("=======成功=======")
}
}
}

注意:如果网络请求是 HTTP 而非 HTTPS,默认情况下,iOS 会阻断该请求,此时需要在 Info.plist 中进行如下配置。

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

URL转码与解码

  • 当请求参数带中文时,必须进行转码操作。
let url = "https://www.baidu.com?name=张三"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(url) // URL中文转码
print(url.removingPercentEncoding!) // URL中文解码

  • 有时候只需要对URL中的中文处理,而不需要针对整个URL。
let str = "阿楚姑娘"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://music.163.com/#/search/m/?s=\(str)&type=1")

下载数据

class ViewController: UIViewController {
// 下载进度
@IBOutlet var downloadProgress: UIProgressView!
// 下载图片
@IBOutlet var downloadImageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

download()
}

func download() {
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/wall.png")!
let request = URLRequest(url: url)
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
let task = session.downloadTask(with: request)
task.resume()
}
}

extension ViewController: URLSessionDownloadDelegate {
// 下载完成
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// 存入沙盒
let savePath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
// 文件类型根据下载的内容决定
let fileName = "\(Int(Date().timeIntervalSince1970)).png"
let filePath = savePath + "/" + fileName
print(filePath)
do {
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: filePath))
// 显示到界面
DispatchQueue.main.async {
self.downloadImageView.image = UIImage(contentsOfFile: filePath)
}
} catch {
print(error)
}
}

// 计算进度
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
DispatchQueue.main.async {
self.downloadProgress.setProgress(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite), animated: true)
}
}
}

上传数据

上传数据需要服务端配合,不同的服务端代码可能会不一样,下面的上传代码适用于本人所写的服务端代码

  • 数据格式。

上传数据格式

  • 实现。
class ViewController: UIViewController {
let YFBoundary = "AnHuiWuHuYungFan"
@IBOutlet var uploadInfo: UILabel!
@IBOutlet var uploadProgress: UIProgressView!

override func viewDidLoad() {
super.viewDidLoad()

upload()
}

func upload() {
// 1. 确定URL
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/UploadServlet")!
// 2. 确定请求
var request = URLRequest(url: url)
// 3. 设置请求头
let head = "multipart/form-data;boundary=\(YFBoundary)"
request.setValue(head, forHTTPHeaderField: "Content-Type")
// 4. 设置请求方式
request.httpMethod = "POST"
// 5. 创建NSURLSession
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
// 6. 获取上传的数据(按照固定格式拼接)
var data = Data()
let header = headerString(mimeType: "image/png", uploadFile: "wall.png")
data.append(header.data(using: .utf8)!)
data.append(uploadData())
let tailer = tailerString()
data.append(tailer.data(using: .utf8)!)
// 7. 创建上传任务 上传的数据来自getData方法
let task = session.uploadTask(with: request, from: data) { _, _, error in
// 上传完毕后
if error != nil {
print(error!)
} else {
DispatchQueue.main.async {
self.uploadInfo.text = "上传成功"
}
}
}
// 8. 执行上传任务
task.resume()
}

// 开始标记
func headerString(mimeType: String, uploadFile: String) -> String {
var data = String()
// --Boundary\r\n
data.append("--" + YFBoundary + "\r\n")
// 文件参数名 Content-Disposition: form-data; name="myfile"; filename="wall.jpg"\r\n
data.append("Content-Disposition:form-data; name=\"myfile\";filename=\"\(uploadFile)\"\r\n")
// Content-Type 上传文件的类型 MIME\r\n\r\n
data.append("Content-Type:\(mimeType)\r\n\r\n")

return data
}

// 结束标记
func tailerString() -> String {
// \r\n--Boundary--\r\n
return "\r\n--" + YFBoundary + "--\r\n"
}

func uploadData() -> Data {
let image = UIImage(named: "wall.png")
let imageData = image!.pngData()
return imageData!
}
}

extension ViewController: URLSessionTaskDelegate {
// 上传进去
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.uploadProgress.setProgress(Float(totalBytesSent) / Float(totalBytesExpectedToSend), animated: true)
}
}

// 上传出错
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
}
}
}

URLCache

  • 网络缓存有很多好处:节省流量、更快加载、断网可用。
  • 使用 URLCache 管理缓存区域的大小和数据。
  • 每一个 App 都默认创建了一个 URLCache 作为缓存管理者,可以通过URLCache.shared获取,也可以自定义。
// 创建URLCache
// memoryCapacity:内存缓存容量
// diskCapacity:硬盘缓存容量
// directory:硬盘缓存路径
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, directory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first)
// 替换默认的缓存管理对象
URLCache.shared = cache

  • 常见属性与方法。
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let cache = URLCache.shared

// 内存缓存大小
cache.memoryCapacity
// 硬盘缓存大小
cache.diskCapacity
// 已用内存缓存大小
cache.currentMemoryUsage
// 已用硬盘缓存大小
cache.currentDiskUsage
// 获取某个请求的缓存
let cacheResponse = cache.cachedResponse(for: urlRequest)
// 删除某个请求的缓存
cache.removeCachedResponse(for: urlRequest)
// 删除某个时间点开始的缓存
cache.removeCachedResponses(since: Date().addingTimeInterval(-60 * 60 * 48))
// 删除所有缓存
cache.removeAllCachedResponses()

WKWebView

  • 用于加载 Web 内容的控件。
  • 使用时必须导入WebKit模块。

基本使用

  • 加载网页。
// 创建URL
let url = URL(string: "https://www.abc.edu.cn")
// 创建URLRequest
let request = URLRequest(url: url!)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载网页
webView.load(request)

  • 加载本地资源。
// 文件夹路径
let basePath = Bundle.main.path(forResource: "localWeb", ofType: nil)!
// 文件夹URL
let baseUrl = URL(fileURLWithPath: basePath, isDirectory: true)
// html路径
let filePath = basePath + "/index.html"
// 转成文件
let fileContent = try? NSString(contentsOfFile: filePath, encoding: String.Encoding.utf8.rawValue)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载html
webView.loadHTMLString(fileContent! as String, baseURL: baseUrl)

注意:如果是本地资源是文件夹,拖进项目时,需要勾选Create folder references,然后用Bundle.main.path(forResource: "文件夹名", ofType: nil)获取资源路径。

与JavaScript交互

创建WKWebView

lazy var webView: WKWebView = {
// 创建WKPreferences
let preferences = WKPreferences()
// 开启JavaScript
preferences.javaScriptEnabled = true
// 创建WKWebViewConfiguration
let configuration = WKWebViewConfiguration()
// 设置WKWebViewConfiguration的WKPreferences
configuration.preferences = preferences
// 创建WKUserContentController
let userContentController = WKUserContentController()
// 配置WKWebViewConfiguration的WKUserContentController
configuration.userContentController = userContentController
// 给WKWebView与Swift交互起一个名字:callbackHandler,WKWebView给Swift发消息的时候会用到
// 此句要求实现WKScriptMessageHandler
configuration.userContentController.add(self, name: "callbackHandler")
// 创建WKWebView
var webView = WKWebView(frame: UIScreen.main.bounds, configuration: configuration)
// 让WKWebView翻动有回弹效果
webView.scrollView.bounces = true
// 只允许WKWebView上下滚动
webView.scrollView.alwaysBounceVertical = true
// 设置代理WKNavigationDelegate
webView.navigationDelegate = self
// 返回
return webView
}()

创建HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,user-scalable=no"/>
</head>
<body>
iOS传过来的值:<span id="name"></span>
<button onclick="responseSwift()">响应iOS</button>
<script type="text/javascript">
// 给Swift调用
function sayHello(name) {
document.getElementById("name").innerHTML = name
return "Swift你也好!"
}
// 调用Swift方法
function responseSwift() {
// 这里的callbackHandler是创建WKWebViewConfiguration是定义的
window.webkit.messageHandlers.callbackHandler.postMessage("JavaScript发送消息给Swift")
}
</script>
</body>
</html>

两个协议

  • WKNavigationDelegate:判断页面加载完成,只有在页面加载完成后才能在实现 Swift 调用 JavaScript。WKWebView 调用 JavaScript:
// 加载完毕以后执行
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 调用JavaScript方法
webView.evaluateJavaScript("sayHello('WebView你好!')") { (result, err) in
// result是JavaScript返回的值
print(result, err)
}
}

  • WKScriptMessageHandler:JavaScript 调用 Swift 时需要用到协议中的一个方法来。JavaScript 调用 WKWebView:
// Swift方法,可以在JavaScript中调用
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}

ViewController

class ViewController: UIViewController {
// 懒加载WKWebView
...

// 加载本地html
let html = try! String(contentsOfFile: Bundle.main.path(forResource: "index", ofType: "html")!, encoding: String.Encoding.utf8)

override func viewDidLoad() {
super.viewDidLoad()
// 标题
title = "WebView与JavaScript交互"
// 加载html
webView.loadHTMLString(html, baseURL: nil)
view.addSubview(webView)
}
}

// 遵守两个协议
extension ViewController: WKNavigationDelegate, WKScriptMessageHandler {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
...
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
}
}

SFSafariViewController

  • iOS 9 推出的一种 UIViewController,用于加载与显示 Web 内容,打开效果类似 Safari 浏览器的效果。
  • 使用时必须导入SafariServices模块。
import SafariServices

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
showSafariViewController()
}

func showSafariViewController() {
// URL
let url = URL(string: "https://www.baidu.com")
// 创建SFSafariViewController
let sf = SFSafariViewController(url: url!)
// 设置代理
sf.delegate = self
// 显示
present(sf, animated: true, completion: nil)
}
}

extension ViewController: SFSafariViewControllerDelegate {
// 点击左上角的完成(done)
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
print(#function)
}

// 加载完成
func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
print(#function)
}
}
收起阅读 »

精益求精!记一次业务代码的优化探索

关键词:需求实现、设计模式、策略模式、程序员成长 承启: 本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。 会介绍一个运用策略模式的实战。 需求和编码本身小于打怪升级成长路径。 文中代码为伪代码。 场景...
继续阅读 »

关键词:需求实现、设计模式、策略模式、程序员成长



承启:


本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。



  • 会介绍一个运用策略模式的实战。

  • 需求和编码本身小于打怪升级成长路径。

  • 文中代码为伪代码。


场景说明:


需求描述:手淘内“充值中心”要投放在饿了么、淘宝极速版、UC浏览器等集团二方APP。
拿到需求之后,来梳理下“充值中心”在他端投放涉及到的核心功能点



  • 通讯录读取


不同客户端、操作系统,JSbridge API实现略有不同。



  • 支付


不同端支付JSbridge调用方式不同。



  • 账号体系:


集团内不同端账号体系可能不同,需要打通。



  • 容器兼容


手淘内采用PHA容器,淘宝极简版本投放H5,饿了么以手淘小程序的方式投放。环境变量、通信方式等需要兼容。



  • 各端个性化诉求


极速版投放极简链路,只保留核心模块等。


解决方案


需求明确了:充值相关核心模块,需要兼容每个APP,本质是提供一个多端投放的解决方案
那么这个场景如何编码实现呢?


1、方案一


首先第一个想法💡,在每个功能点模块用if-else判断客户端环境,编写此端逻辑。
下面以获取通讯录列表功能为例,代码如下:


// 业务代码文件 index.js
/**
* 获取通讯录列表
* @param clientName 端名称
*/
const getContactsList = (clientName) => {
if (clientName === 'eleme') {
getContactsListEleme()
} else if (clientName === 'taobao') {
getContactsListTaobao()
} else if (clientName === 'tianmao') {
getContactsListTianmao()
} else if (clientName === 'zhifubao') {
getContactsListZhifubao()
} else {
// 其他端
}
}

写完之后,review一下代码,思考一下这样编码的利弊。


:逻辑清晰,可快速实现。

:代码不美观、可读性略差,每兼容一个端都要在业务逻辑处改动,改一端测多端。


这时,有的同学就说了:“把if-else改成switch-case的写法,把获取通讯录模块抽象成独立的sdk封装,用户在业务层统一调用”,天才!动手实现一下。


2、方案二


核心功能模块,抽象成独立的sdk,模块内部对不同的端进行兼容,业务逻辑里统一方式调用。


/**
* 获取通讯录列表 sdk caontact.js
* @param clientName 端名称
* @param successCallback 成功回调
* @param failCallback 失败回调
*/
export default function (clientName, successCallback, failCallback) {
switch (clientName) {
case 'eleme':
getContactsListEleme()
break
case 'taobao':
getContactsListTaobao()
break
case 'zhifubao':
getContactsListTianmao()
break
case 'tianmao':
getContactsListZhifubao()
break
default:
// 省略
break
}
}

// 业务调用 index.js
<Contacts onIconClick={handleContactsClick} />

import getContactsList from 'Contacts'
import { clientName } from 'env'
const handleContactsClick = () => {
getContactsList(
clientName,
({ arr }) => {
this.setState({
contactsList: arr
})
},
() => {
alert('获取通讯录失败')
}
)
}

惯例,review一下代码:


:模块分工明确,业务层统一调用,代码可读性较高。

:多端没有解藕,每次迭代,需要各个端回归。


上面的实现,看起来代码可读性提高了不少,是一个不错的设计,可是这样是最优的设计吗?


3、方案三


熟悉设计模式的同学,这时候可能要说了,用策略模式啊,对了,这个场景可以用策略模式。
这里简单解释一下策略模式:
策略模式,英文全称是 Strategy Design Pattern。
在 GoF 的《设计模式》一书中,它是这样定义的:



Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.



翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。


难免有些晦涩,什么意思呢?我个人的理解为:策略模式用来解耦策略的定义、创建、使用。它典型的应用场景就是:避免冗长的if-else或switch分支判断编码。


下面看代码实现:


/**
* 策略定义
*/
const strategies = {
eleme: () => {
getContactsListEleme()
},
taobao: () => {
getContactsListTaobao()
},
tianmao: () => {
// 省略
}
}
/**
* 策略创建
*/
const getContactsStrategy = (clientName) => {
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
}
/**
* 策略使用
*/
import { clientName } from 'env'
getContactsStrategy(clientName)()

策略模式的运用,把策略的定义、创建、使用解耦,符合设计原则中的迪米特法则(LOD),实现“高内聚、松耦合”。
当需要新增一个适配端时,我们只需要修改策略定义Map,其他代码都不需要修改,这样就将代码改动最小化、集中化了。


能做到这里,相信你已经超越了一部分同学了,但是我们还要思考、精益求精,如何更优呢?这个时候单从编码层面思考已经受阻塞了,可否从工程构建角度、性能优化角度、项目迭代流程角度、后期代码维护角度思考一下,相信你会有更好的想法。


下面抛砖,聊聊我自己的思考:


4、方案四


从工程构建和性能优化角度出发:如果每个端独立一个文件,构建的时候shake掉其他端chunk,这样bundle可以变更小,网络请求也变更快。



等等... Tree-Shaking是基于ES静态分析,我们的策略判断,基于运行时,好像没什么用啊。



方案三使用策略模式来编码,本质是策略定义、创建和使用解藕,那可否使用刚才的想法,把每端各个功能模块兼容方法聚合成独立module,从更高维度,将多端业务策略定义、创建和使用解藕?



思考一下这样做的收益是什么?



因为每个端的适配,聚合在一个module,将多端业务策略解藕,某个端策略变更,只需要修改此端module,代码改动较小,且后续测试链路,不需要重复回归其他端。符合“高内聚、松耦合”。


代码实现:


/**
* 饿了么端策略定义module
*/
export const elmcStrategies = {
contacts: () => {
getContactsListEleme()
},
pay: () => {
payEleme()
},
// 其他功能略
}
/**
* 手淘端策略定义module
*/
export const tbStrategies = {
contacts: () => {
getContactsListTaobao()
},
pay: () => {
payTaobao()
},
// 其他功能略
};
// ...... (其他端略)
/**
* 策略创建 index.js
*/
import tbStrategies from './tbStrategies'
import elmcStrategies from './elmcStrategies'
export const getClientStrategy = (clientName) => {
const strategies = {
elmc: elmcStrategies,
tb: tbStrategies
// ...
}
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
};
/**
* 策略使用 pay
*/
import { clientName } from 'env'
getClientStrategy(clientName).pay()

代码目录如下图所示:index.js是多端策略的入口,其他文件为各端策略实现。




从方案四的推导来看,有时候,判断不一定是对的,但是从多个维度去思考,会打开思路,这时,更优方案往往就找上门来了~



5、方案五


既要解决眼前痛点,也要长远谋划,基于以上四种方案,再深入思考一步,如果业务有投放在第三方(非集团APP)的需求,比如投放在商家APP,且商家APP获取通讯录、支付逻辑等复杂多变,这个时候如何设计编码呢?
例如:拉起别端的唤端策略,受多方因素影响,涉及到产品壁垒,策略攻防,怎样控制代码改动次数,及时提高唤端率呢?
在这里简单抛砖,可以借助近几年很火的serverless,搭建唤端策略的faas函数,动态获取最优唤端策略,是不是一个好的方案呢?


沉淀&思考


以上针对多端兼容的问题,我们学习并运用了设计模式——策略模式。那么我们再来看看策略模式的设计思想是什么:

一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。

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

还有一点需要注意,在代码设计时,应该了解他的业务价值和复杂度,避免过度设计,如果一个if-else可以解决的问题,何必大费周折,阔谈设计模式呢?


总结


理一下全文的核心路径,也是我此篇文章想要主要传达的打怪升级成长路径。


接到一个复杂的需求--> 理清需求 --> 拆解技术难点 --> 编码实现 --> 代码优化 --> 设计模式和设计原则学习 --> 举一反三 --> 记录沉淀。


当下,前端工程师在工作中,难免会陷入业务漩涡中,被业务推着走。面对这种风险,我们要思考如何在保障完成业务迭代的基础上,运用适合的技术架构,抽象出通用解决方案,沉淀落地。这样,既能帮助业务更快更稳定增长,又能在这个过程中收获个人成长。


作者:喜橙
链接:https://juejin.cn/post/7006136807263830029

收起阅读 »

使用CSS实现中秋民风民俗-拜月

前言 好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。 看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的...
继续阅读 »

前言


image.png
好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。


看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的,那我来吧。


拜月,在我国是一种十分古老的习俗,实际上是源自我国一些地方古人对“月神”的一种崇拜活动。中秋节是上古天象崇拜——敬月习俗的遗痕,祭月作为中秋节重要的祭礼之一,从古代延续至今,逐渐演化为民间的赏月、颂月活动,同时也成为现代人渴望团聚、寄托对生活美好愿望的主要形态。「以上来自百度百科」


不知道大家那边有没有这个习俗,我老家是有的,每次拜月都会准备很多好吃的,可把我高兴坏了,因为第二天就是我生日,也就是八月十六,所以好吃的贼多。


废话不多说,开始进入正题,源码在这里:中秋拜月


HTML


一个大的div里面套了三个div,分别代表月亮,月亮上的嫦娥玉兔,月亮下的拜月人群。


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>中秋拜月</title>
</head>

<body>
<div class="background">
<div class="moon"></div>
<div class="change"></div>
<div class="table"></div>
</div>
</body>

CSS


星星背景


背景图片是从某位大佬的那里获取的,自己作图能力不行,流汗。很常规,设置了一个长宽适应全屏幕,然后就是背景图。


body {
margin: 0;
}

.background {
width: 100%;
height: calc(100vh);
background: #000 url("https://test-jou.oss-cn-beijing.aliyuncs.com/3760b5e2cc46f556.png") repeat top center;
z-index: 1;
}

image.png


月亮


月亮的颜色用的是#ff0,搭配了一个相符的阴影,之所以没有用白色,是我感觉主色调用黄色更能显示拜月的“神圣性”「非迷信」


 .moon {
position: absolute;
left: 108px;
top: 81px;
width: 180px;
height: 180px;
background-color: #ff0;
border-radius: 50%;
box-shadow: 0 0 20px 20px rgba(247, 247, 9, 0.5);
}

image.png


嫦娥和月兔


把嫦娥和月兔放到月亮上,有种嫦娥就在注视人间的味道


.change {
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/unnamed.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
position: absolute;
left: 100px;
top: 81px;
width: 180px;
height: 180px;
z-index: 99;
}

image.png


拜月人群


我感觉这是最不搭配的图了,如有不适,敬请谅解


  .table{
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/table.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
bottom: 0;
width: 640px;
right: 0;
height: 450px;
position: absolute;
}

image.png


总结


好几年中秋没有回家了,也有可能是忙或者其他原因,家里近几年中秋也不拜月了。有的时候会很怀念小时候,长大了,小时候也回不去了,因为是后端的缘故,页面也不是很懂,留作纪念吧……


附言


以前,车马很远,书信很慢,一生只够爱一个人「有感」


作者:Jouzeyu
链接:https://juejin.cn/post/7007288677831278628

收起阅读 »

一顿操作,我把 Table 组件性能提升了十倍

背景 Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。 通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM ...
继续阅读 »

背景


Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。


通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM 渲染的数量。在我们公司的项目中,会选择分页的方式,因为虚拟滚动不能正确的读出行的数量,会有 Accessibility 的问题。


记得 19 年的时候,我在 Zoom 已经推行了基于 Vue.js 的前后端分离的优化方案,并且基于 ElementUI 组件库开发了 ZoomUI。其中我们在重构用户管理页面的时候使用了 ZoomUI 的 Table 组件替换了之前老的用 jQuery 开发的 Table 组件。


因为绝大部分场景 Table 组件都是分页的,所以并不会有性能问题。但是在某个特殊场景下:基于关键词的搜索,可能会出现 200 * 20 条结果且不分页的情况,且表格是有一列是带有 checkbox 的,也就是可以选中某些行进行操作。


当我们去点选其中一行时,发现过了好久才选中,有明显的卡顿感,而之前的 jQuery 版本却没有这类问题,这一比较令人大跌眼镜。难道好好的技术重构,却要牺牲用户体验吗?


Table 组件第一次优化尝试


既然有性能问题,那么我们的第一时间的思路应该是要找出产生性能问题的原因。


列展示优化


首先,ZoomUI 渲染的 DOM 数量是要多于 jQuery 渲染的 Table 的,因此第一个思考方向是让 Table 组件尽可能地减少 DOM 的渲染数量


20 列数据通常在屏幕下是展示不全的,老的 jQuery Table 实现很简单,底部有滚动条,而 ZoomUI 在这种列可滚动的场景下,支持了左右列的固定,这样在左右滑动过程中,可以固定某些列一直展示,用户体验更好,但这样的实现是有一定代价的。


想要实现这种固定列的布局,ElementUI 用了 6 个 table 标签来实现,那么为什么需要 6 个 table 标签呢?


首先,为了让 Table 组件支持丰富的表头功能,表头和表体都是各自用一个 table 标签来实现。因此对于一个表格来说,就会有 2 个 table 标签,那么再加上左侧 fixed 的表格,和右侧 fixed 的表格,总共有 6 个 table 标签。


在 ElementUI 实现中,左侧 fixed 表格和右侧 fixed 表格从 DOM 上都渲染了完整的列,然后从样式上控制它们的显隐:


element.png


element1.png


但这么实现是有性能浪费的,因为完全不需要渲染这么多列,实际上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。ZoomUI 就是这么实现的,效果如下:


zoom-ui.png
当然,仅仅减少 fixed 表格渲染的列,性能的提升还不够明显,有没有办法在列的渲染这个维度继续优化呢?


这就是从业务层面的优化了,对于一个 20 列的表格,往往关键的列并没有多少,那么我们可不可以初次渲染仅仅渲染关键的列,其它列通过配置方式的渲染呢?


根据上述需求,我给 Table 组件添加了如下功能:


zoom-ui1.png


Table 组件新增一个 initDisplayedColumn 属性,通过它可以配置初次渲染的列,同时当用户修改了初次渲染的列,会在前端存储下来,便于下一次的渲染。


通过这种方式,我们就可以少渲染一些列。显然,列渲染少了,表格整体渲染的 DOM 数就会变少,对性能也会有一定的提升。


更新渲染的优化


当然,仅仅通过优化列的渲染还是不够的,我们遇到的问题是当点选某一行引起的渲染卡顿,为什么会引起卡顿呢?


为了定位该问题,我用 Table 组件创建了一个 1000 * 7 的表格,开启了 Chrome 的 Performance 面板记录 checkbox 点选前后的性能。


在经过几次 checkbox 选择框的点选后,可以看到如下火焰图:


element2.png


其中黄色部分是 Scripting 脚本的执行时间,紫色部分是 Rendering 所占的时间。我们再截取一次更新的过程:


element3.png


然后观察 JS 脚本执行的 Call Tree,发现时间主要花在了 Table 组件的更新渲染上


element4.png


我们发现组件的 render to vnode 花费的时间约 600ms;vnode patch to DOM 花费的时间约 160ms。


为什么会需要这么长时间呢,因为点选了 checkbox,在组件内部修改了其维护的选中状态数据,而整个组件的 render 过程中又访问了这个状态数据,因此当这个数据修改后,会引发整个组件的重新渲染。


而又由于有 1000 * 7 条数据,因此整个表格需要循环 1000 * 7 次去创建最内部的 td,整个过程就会耗时较长。


那么循环的内部是不是有优化的空间呢?对于 ElementUI 的 Table 组件,这里有非常大的优化空间。


其实优化思路主要参考我之前写的 《揭秘 Vue.js 九个性能优化技巧》 其中的 Local variables 技巧。举个例子,在 ElementUI 的 Table 组件中,在渲染每个 td 的时候,有这么一段代码:


const data = {
store: this.store,
_self: this.context || this.table.$vnode.context,
column: columnData,
row,
$index
}

这样的代码相信很多小伙伴随手就写了,但却忽视了其内部潜在的性能问题。


由于 Vue.js 响应式系统的设计,在每次访问 this.store 的时候,都会触发响应式数据内部的 getter 函数,进而执行它的依赖收集,当这段代码被循环了 1000 * 7 次,就会执行 this.store 7000 次的依赖收集,这就造成了性能的浪费,而真正的依赖收集只需要执行一次就足够了。


解决这个问题其实也并不难,由于 Table 组件中的 TableBody 组件是用 render 函数写的,我们可以在组件 render 函数的入口处定义一些局部变量:


render(h) {
const { store /*...*/} = this
const context = this.context || this.table.$vnode.context
}

然后在渲染整个 render 的过程中,把局部变量当作内部函数的参数传入,这样在内部渲染 td 的渲染中再次访问这些变量就不会触发依赖收集了:


rowRender({store, context, /* ...其它变量 */}) {
const data = {
store: store,
_self: context,
column: columnData,
row,
$index,
disableTransition,
isSelectedRow
}
}

通过这种方式,我们把类似的代码都做了修改,就实现了 TableBody 组件渲染函数内部访问这些响应式变量,只触发一次依赖收集的效果,从而优化了 render 的性能。


来看一下优化后的火焰图:


zoom-ui2.png


从面积上看似乎 Scripting 的执行时间变少了,我们再来看它一次更新所需要的 JS 执行时间:


zoom-ui3.png


我们发现组件的 render to vnode 花费的时间约 240ms;vnode patch to DOM 花费的时间约 127ms。


可以看到,ZoomUI Table 组件的 render 的时间和 update 的时间都要明显少于 ElementUI 的 Table 组件。render 时间减少是由于响应式变量依赖收集的时间大大减少,update 的时间的减少是因为 fixed 表格渲染的 DOM 数量减少。


从用户的角度来看,DOM 的更新除了 Scripting 的时间,还有 Rendering 的时间,它们是共享一个线程的,当然由于 ZoomUI Table 组件渲染的 DOM 数量更少,执行 Rendering 的时间也更短。


手写 benchmark


仅仅从 Performance 面板的测试并不是一个特别精确的 benchmark,我们可以针对 Table 组件手写一个 benchmark。


我们可以先创建一个按钮,去模拟 Table 组件的选中操作:


<div>
<zm-button @click="toggleSelection(computedData[1])
">切换第二行选中状态
</zm-button>
</div>
<div>
更新所需时间: {{ renderTime }}
</div>

然后实现这个 toggleSelection 函数:


methods: {
toggleSelection(row) {
const s = window.performance.now()
if (row) {
this.$refs.table.toggleRowSelection(row)
}
setTimeout(() => {
this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
})
}
}

我们在点击事件的回调函数中,通过 window.performance.now() 记录起始时间,然后在 setTimeout 的回调函数中,再去通过时间差去计算整个更新渲染需要的时间。


由于 JS 的执行和 UI 渲染占用同一线程,因此在一个宏任务执行过程中,会执行这俩任务,而 setTimeout 0 会把对应的回调函数添加到下一个宏任务中,当该回调函数执行,说明上一个宏任务执行完毕,此时做时间差去计算性能是相对精确的。


基于手写的 benchmark 得到如下测试结果:


element5.png


ElementUI Table 组件一次更新的时间约为 900ms。


zoom-ui4.png


ZoomUI Table 组件一次更新的时间约为 280ms,相比于 ElementUI 的 Table 组件,性能提升了约三倍


v-memo 的启发


经过这一番优化,基本解决了文章开头提到的问题,在 200 * 20 的表格中去选中一列,已经并无明显的卡顿感了,但相比于 jQuery 实现的 Table,效果还是要差了一点。


虽然性能优化了三倍,但我还是有个心结:明明只更新了一行数据的选中状态,却还是重新渲染了整个表格,仍然需要在组件 render 的过程中执行多次的循环,在 patch 的过程中通过 diff 算法来对比更新。


最近我研究了 Vue.js 3.2 v-memo 的实现,看完源码后,我非常激动,因为发现这个优化技巧似乎可以应用到 ZoomUI 的 Table 组件中,尽管我们的组件库是基于 Vue 2 版本开发的。


我花了一个下午的时间,经过一番尝试,果然成功了,那么具体是怎么做的呢?先不着急,我们从 v-memo 的实现原理说起。


v-memo 的实现原理


v-memo 是 Vue.js 3.2 版本新增的指令,它可以用于普通标签,也可以用于列表,结合 v-for 使用,在官网文档中,有这么一段介绍:



v-memo 仅供性能敏感场景的针对性优化,会用到的场景应该很少。渲染 v-for 长列表 (长度大于 1000) 可能是它最有用的场景:



<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>


当组件的 selected 状态发生变化时,即使绝大多数 item 都没有发生任何变化,大量的 VNode 仍将被创建。此处使用的 v-memo 本质上代表着“仅在 item 从未选中变为选中时更新它,反之亦然”。这允许每个未受影响的 item 重用之前的 VNode,并完全跳过差异比较。注意,我们不需要把 item.id 包含在记忆依赖数组里面,因为 Vue 可以自动从 item:key 中把它推断出来。



其实说白了 v-memo 的核心就是复用 vnode,上述模板借助于在线模板编译工具,可以看到其对应的 render 函数:


import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

基于 v-for 的列表内部是通过 renderList 函数来渲染的,来看它的实现:


function renderList(source, renderItem, cache, index) {
let ret
const cached = (cache && cache[index])
if (isArray(source) || isString(source)) {
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
}
else if (typeof source === 'number') {
// source 是数字
}
else if (isObject(source)) {
// source 是对象
}
else {
ret = []
}
if (cache) {
cache[index] = ret
}
return ret
}

我们只分析 source,也就是列表 list 是数组的情况,对于每一个 item,会执行 renderItem 函数来渲染。


从生成的 render 函数中,可以看到 renderItem 的实现如下:


(item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}

renderItem 函数内部,维护了一个 _memo 变量,它就是用来判断是否从缓存里获取 vnode 的条件数组;而第四个参数 _cached 对应的就是 item 对应缓存的 vnode。接下来通过 isMemoSame 函数来判断 memo 是否相同,来看它的实现:


function isMemoSame(cached, memo) {
const prev = cached.memo
if (prev.length != memo.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== memo[i]) {
return false
}
}
// ...
return true
}

isMemoSame 函数内部会通过 cached.memo 拿到缓存的 memo,然后通过遍历对比每一个条件来判断和当前的 memo 是否相同。


而在 renderItem 函数的结尾,就会把 _memo 缓存到当前 itemvnode 中,便于下一次通过 isMemoSame 来判断这个 memo 是否相同,如果相同,说明该项没有变化,直接返回上一次缓存的 vnode


那么这个缓存的 vnode 具体存储到哪里呢,原来在初始化组件实例的时候,就设计了渲染缓存:


const instance = {
// ...
renderCache: []
}

然后在执行 render 函数的时候,把这个缓存当做第二个参数传入:


const { renderCache } = instance
result = normalizeVNode(
render.call(
proxyToUse,
proxyToUse,
renderCache,
props,
setupState,
data,
ctx
)
)

然后在执行 renderList 函数的时候,把 _cahce 作为第三个参数传入:


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
// renderItem 实现
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

所以实际上列表缓存的 vnode 都保留在 _cache 中,也就是 instance.renderCache 中。


那么为啥使用缓存的 vnode 就能优化 patch 过程呢,因为在 patch 函数执行的时候,如果遇到新旧 vnode 相同,就直接返回,什么也不用做了。


const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
if(n1 === n2) {
return
}
// ...
}

显然,由于使用缓存的 vnode,它们指向同一个对象引用,直接返回,节约了后续执行 patch 过程的时间。


在 Table 组件的应用


v-memo 的优化思路很简单,就是复用缓存的 vnode,这是一种空间换时间的优化思路。


那么,前面我们提到在表格组件中选择状态没有变化的行,是不是也可以从缓存中获取呢?


顺着这思路,我给 Table 组件设计了 useMemo 这个 prop,它其实是专门用于有选择列的场景。


然后在 TableBody 组件的 created 钩子函数中,创建了用于缓存的对象:


created() {
if (this.table.useMemo) {
if (!this.table.rowKey) {
throw new Error('for useMemo, row-key is required.')
}
this.vnodeCache = []
}
}

这里之所以把 vnodeCache 定义到 created 钩子函数中,是因为它并不需要变成响应式对象。


另外注意,我们会根据每一行的 key 作为缓存的 key,因此 Table 组件的 rowKey 属性是必须的。


然后在渲染每一行的过程中,添加了 useMemo 相关的逻辑:


function rowRender({ /* 各种变量参数 */}) {
let memo
const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
let cached
if (useMemo) {
cached = this.vnodeCache[key]
const currentSelection = store.states.selection
if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
return cached
}
memo = currentSelection.slice()
}
// 渲染 row,返回对应的 vnode
const ret = rowVnode
if (useMemo && columns.length) {
ret.memo = memo
this.vnodeCache[key] = ret
}
return ret
}

这里的 memo 变量用于记录已选中的行数据,并且它也会在函数最后存储到 vnodememo,便于下一次的比对。


在每次渲染 rowvnode 前,会根据 row 对应的 key 尝试从缓存中取;如果缓存中存在,再通过 isRowSelectionChanged 来判断行的选中状态是否改变;如果没有改变,则直接返回缓存的 vnode


如果没有命中缓存或者是行选择状态改变,则会去重新渲染拿到新的 rowVnode,然后更新到 vnodeCache 中。


当然,这种实现相比于 v-memo 没有那么通用,只去对比行选中的状态而不去对比其它数据的变化。你可能会问,如果这一行某列的数据修改了,但选中状态没变,再走缓存不就不对了吗?


确实存在这个问题,但是在我们的使用场景中,遇到数据修改,是会发送一个异步请求到后端,然获取新的数据再来更新表格数据。因此我只需要观测表格数据的变化清空 vnodeCache 即可:


watch: {
'store.states.data'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
}
}

此外,我们支持列的可选则渲染功能,以及在窗口发生变化时,隐藏列也可能发生变化,于是在这两种场景下,也需要清空 vnodeCache


watch:{
'store.states.columns'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
},
columnsHidden(newVal, oldVal) {
if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
this.vnodeCache = []
}
}
}

以上实现就是基于 v-memo 的思路实现表格组件的性能优化。我们从火焰图上看一下它的效果:


zoom-ui6.png


我们发现黄色的 Scripting 时间几乎没有了,再来看它一次更新所需要的 JS 执行时间:


zoom-ui7.png
我们发现组件的 render to vnode 花费的时间约 20ms;vnode patch to DOM 花费的时间约 1ms,整个更新渲染过程,JS 的执行时间大幅减少。


另外,我们通过 benchmark 测试,得到如下结果:


zoom-ui5.png
优化后,ZoomUI Table 组件一次更新的时间约为 80ms,相比于 ElementUI 的 Table 组件,性能提升了约十倍


这个优化效果还是相当惊人的,并且从性能上已经不输 jQuery Table 了,我两年的心结也随之解开了。


总结


Table 表格性能提升主要是三个方面:减少 DOM 数量、优化 render 过程以及复用 vnode。有些时候,我们还可以从业务角度思考,去做一些优化。


虽然 useMemo 的实现还比较粗糙,但它目前已满足我们的使用场景了,并且当数据量越大,渲染的行列数越多,这种优化效果就越明显。如果未来有更多的需求,更新迭代就好。


由于一些原因,我们公司仍然在使用 Vue 2,但这并不妨碍我去学习 Vue 3,了解它一些新特性的实现原理以及设计思想,能让我开拓不少思路。


从分析定位问题到最终解决问题,希望这篇文章能给你在组件的性能优化方面提供一些思路,并应用到日常工作中。


链接:https://juejin.cn/post/7007252464726458399

收起阅读 »

vue3+typescript 实现一个中秋RPG游戏

前言 又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性....
继续阅读 »

前言


又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性. 所以我最后选择做类似这种风格的页面.


ChMkJ1tpIkuINzThAAUqitDPvVkAAqf4wHB5i0ABSqi715.jpg


选择好了技术栈和制作主题和风格. 就直接开干了. 肝了一天, 以下是制作完成后的成果


GIF.gif


先说一下剧本,这个剧本是春光灿烂猪八戒后羿(二牛)嫦娥的人物角色加上东成西就大理段王爷飞升桥段. 还有最后一个鬼畜飞升的效果,我先说一下,这个是实在没找到可用的素材,只能凑合的用网上找来的这个动画. o(╥﹏╥)o 好了, 那么就开始说说, 我是怎么实现这个类游戏的页面动画效果的.


页面组织结构


页面使用vite创建出来, 文件的结构是这样的


image.png


由于页面只有一个场景,所以整个页面是放在APP.vue中写的. interface文件夹存放定义的一些接口对象. 组件里边划分出来了4个组件, 依次是



  1. dialogBox: 底部对话框组件

  2. lottie: 输入咒语后的一个彩蛋爆炸效果组件

  3. sprite 精灵图动画组件

  4. typed 输入咒语的打字效果组件


那么我们就按照页面出现的动画效果依次去讲一下吧.


精灵图动画


页面开头首先是二牛角色从左边走上桥头的动画. 这个动画我们先来分析一下, 首先是帧动画, 也就是走路的这个动作的效果, 其次是从左边走上桥头的这个位移动画. 那么我们先说一下帧动画


帧动画



“逐帧动画是一种常见的动画形式(Frame By Frame),其原理是在“连续的关键帧”中分解动画动作,也就是在时间轴的每帧上逐帧绘制不同的内容,使其连续播放而成动画



image.png


用我这个项目举例, 二牛走路的动画其实是一张图片在我们前端这张图也叫雪碧图,图上有4个动作,4个动作在不停的切换的时候,在我们人眼中就形成了走路的动效了. 好的,原理解释清楚了,那么我们现在看一下代码


  <div ref="spriteBox">
<div ref="sprite" class="sprite"></div>
</div>

页面的结构很简单, 就三行html代码, 外边包裹的html其实是用来做位移动画用的, 里边的sprite就是做帧动画的. 下面我们看一下javascript代码


// 样式位置
export interface positionInterface {
left?: string,
top?: string,
bottom?: string,
right?: string
}

export interface spriteInterface {
length: number, // 精灵图的长度
url: string, // 图片的路径
width: number, // 图片的宽度
height: number, // 图片的高度
scale?: number, // 缩放
endPosition: positionInterface // 动画结束站的位置
}

import { Ref } from "vue";
import { positionInterface, spriteInterface } from "../../interface";

/**
* 精灵图实现逐帧动画
* @param spriteObj 精灵对象
* @param target 精灵节点
* @param wrap 精灵父节点 [控制精灵移动]
* @param callback 图片加载好回调函数
* @param moveCallback 移动到对应位置的回调函数
*/
export function useFrameAnimation(
spriteObj: spriteInterface,
target: Ref,
wrap: Ref,
callback: Function,
moveCallback: Function
) {
const { width, length, url, endPosition } = spriteObj;
let index = 0;

var img = new Image();
img.src = url;
img.addEventListener("load", () => {
let time;
(function autoLoop() {
callback && callback();
// 如果到达了指定的位置的话,则停止
if (isEnd(wrap, endPosition)) {
if (time) {
clearTimeout(time);
time = null;
moveCallback && moveCallback();
return;
}
}
if (index >= length) {
index = 0;
}
target.value.style.backgroundPositionX = -(width * index) + "px";
index++;
// 使用setTimeout, requestFrameAnimation 是60HZ进行渲染,部分设备会卡,使用setTimeout可以手动控制渲染时间
time = setTimeout(autoLoop, 160);
})();
});

// 走到了对应的位置
function isEnd(wrap, endPosition: positionInterface) {
let keys = Object.keys(endPosition);
for (let key of keys) {
if (window.getComputedStyle(wrap.value)[key] === endPosition[key]) {
return true;
}
}
return false;
}
}

参数


useFrameAnimation 这个帧动画的函数, 函数参数先传递精灵图的描述对象,它主要描述精灵图上是有几个动作组成的,图片的地址是多少,图片在DOM节点上的对象,以及移动到指定位置后,传递给调用函数的父级的回调函数. 其实在代码中的注释也描述的很清楚了.


图片加载


我们在使用这张图片做帧动画的时候,首先得在这张图片是加载好之后再去处理的. 所以我们得先new Image, 然后给它赋值上src, 然后监听它的load事件,


循环切换动画


在load事件句柄内, 写了一个loop循环切换图片的backgroundPositionX属性达到页面动作图片的切换,由于是循环动画,如果动画走到了最后一张图片的时候,得切回第一张图片


添加回调函数钩子


在图片加载完成的时候,回调一个callback函数,告诉外边图片已经加载完成了,如果有一些需要图片加载完成的事情做的话,可以在这个回调函数里边去写. 代码里边还有一个isEnd函数, 去判断位移动画是否已经完成,如果位移动画完成了的话,则停止帧动画的循环,让它静止下来成为一张图片. 然后再执行moveCallback告诉调用函数的父级,位移动画已经执行完成了. 这个函数大致做的事情就是这些了.


位移动画


位移动画就比较简单了, 我们先看下代码:


<script lang="ts">
import {
computed,
defineComponent,
defineEmit,
PropType,
reactive,
ref,
toRefs,
watchEffect,
} from "vue";
import { spriteInterface } from "../../interface";
import { useFrameAnimation } from "./useFrameAnimation";

export default defineComponent({
props: {
action: {
type: Boolean,
default: false,
},
spriteObj: Object as PropType<spriteInterface>,
},
defineEmit: ["moveEnd"],
setup(props, { emit }) {
const spriteBox = ref(null);
const sprite = ref({ style: "" });
const spriteObj = reactive(props.spriteObj || {}) as spriteInterface;
const { width, height, url, length } = toRefs(spriteObj);
watchEffect(() => {
if (props.action) {
useFrameAnimation(
spriteObj,
sprite,
spriteBox,
() => {
triggerMove();
},
() => {
emit("moveEnd");
}
);
}
});
// 给宽度后边加上单位
const widthRef = computed(() => {
return width.value + "px";
});
// 给高度后边加上单位
const heightRef = computed(() => {
return height.value + "px";
});
// 给背景图片连接添加url
const urlImg = computed(() => {
return `url("${url.value}")`;
});
// 移动到目标位置
function triggerMove() {
if (spriteObj.scale || spriteObj.scale === 0) {
spriteBox.value.style.transform = `scale(${spriteObj.scale})`;
}
if (spriteObj.endPosition) {
Object.keys(spriteObj.endPosition).forEach((o) => {
if (spriteBox.value && sprite.value.style) {
spriteBox.value.style[o] = spriteObj.endPosition[o];
}
});
}
}
return {
widthRef,
heightRef,
urlImg,
length,
sprite,
spriteBox,
triggerMove,
};
},
});
</script>

代码中主要的是这个watchEffect, 根据使用精灵组件传递的props.action去开始决定是否开始帧动画,在调用我们上一段讲的useFrameAnimation函数后,第四个参数回调函数是图片加载完成,图片加载完成的时候,我们可以在这里做位移动画,也就是triggerMove,triggerMove函数里实际上就是把在spriteObj配置好的一些位置以及缩放信息放到对应的DOM节点上,要说动画的话,其实是css去做的. 在监听到位移动画结束后,传递给父级一个moveEnd自定义事件.


<style lang="scss" scoped>
.sprite {
width: v-bind(widthRef);
height: v-bind(heightRef);
background-image: v-bind(urlImg);
background-repeat: no-repeat;
background-position: 0;
background-size: cover;
}
</style>

这里的css只描述了关于精灵图的宽度高度和图片路径,上边这种写法v-bind是vue3后可以使用的一种方式,这样就可以把动态的变量直接写在CSS里边了, 用过的都说好~ 关于精灵图真正的动画效果是写在了APP.vue里边的css里


  .boy {
position: absolute;
bottom: 90px;
left: 10px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}
.girl {
position: absolute;
bottom: 155px;
right: 300px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}

上面描述了二牛嫦娥的初始位置,以及动效.


对话框组件


二牛走到嫦娥旁边后,APP.vue就通过前面说的moveEnd自定义事件知晓了动画结束,然后在动画结束后,弹出对话框. 对话的话, 其实就得先想好一个对话的剧本以及对话剧本的格式了.


对话剧本


const dialogueContent = [
{
avatar: "/images/rpg_male.png",
content: "二牛:嫦娥你终于肯和我约会了, 哈哈",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:二牛对不起,我是从月宫来的,我不能和人间的你在一起!",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:今天是中秋节,我只有今天这个机会可以重新回月宫",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:回月宫的条件是找到真心人,让他念起咒语,我才能飞升!",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:而你就是我的真心人,你可以帮我嘛?",
},
{
avatar: "/images/rpg_male.png",
content: "二牛:好的,我明白了! 我会帮你的.",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:好的。 谢谢你!",
},
];

以上就是我这个小游戏的剧本了, 因为是别人先说一段,我再说一段,或者别人说了一段,再接着说一段. 这种的话,就是直接按照对话顺序写下来就好了, 然后我们在代码里边就可以通过点击时间的交互来按照顺序一个一个展现出来. 对话的结构主要就人物头像人物内容, 这里我为了省事,把人物的名称也直接在内容里边展现出来, 其实如果需要的话,可以提出来.


结构


我们先看一下它的html结构


  <div v-if="isShow" class="rpg-dialog" @click="increase">
<img :src="dialogue.avatar" class="rpg-dialog__role" />
<div class="rpg-dialog__body">
{{ contentRef.value }}
</div>
</div>

结构其实也很简单,里边就是一个头像和内容,我们用isShow去控制对话框的显示隐藏,用increase去走到下一个对话内容里边.


逻辑实现


    function increase() {
dialogueIndex.value++;
if (dialogueIndex.value >= dialogueArr.length) {
isShow.value = false;
emit("close");
return;
}
// 把下个内容做成打字的效果
contentRef.value = useType(dialogue.value.content);
}

increase方法里边也很简单,点击后,申明的索引(默认是0开始)+1,如果索引等于剧本的长度了的时候, 就把对话框关掉,然后给APP.vue一个close自定义事件, 如果小于剧本的长度的话,则走到下一个剧本内容,并且以打字的效果呈现. 也就是useType方法.


/**
* 打字效果
* @param { Object } content 打字的内容
*/
export default function useTyped(content: string): Ref<string> {
let time: any = null
let i:number = 0
let typed = ref('_')
function autoType() {
if (typed.value.length < content.length) {
time = setTimeout(() =>{
typed.value = content.slice(0, i+1) + '_'
i++
autoType()
}, 200)
} else {
clearTimeout(time)
typed.value = content
}
}
autoType()
return typed
}

打字效果实现也很简单,默认给一个_,然后逐一拿到字符串的每一个字符,一个一个的加在新字符串后边. 如果拿到完整的字符串的时候,则停止循环.


打字框(咒语)组件


在结束了剧本后, APP.vue会拿到组件跑出来的close自定义事件,在这里面,我们可以把诅咒组件给显示出来,


结构


<div v-if="isShow" class="typed-modal">
<div class="typed-box">
<div class="typed-oldFont">{{ incantation }}</div>
<div
@input="inputChange"
ref="incantainerRef"
contenteditable
class="typed-font"
>
{{ font }}
</div>
</div>
</div>

诅咒组件,这里的html结构,我们可以看一下,里边用到了contenteditable这个属性,设置了这个属性后,div就可以变的和输入框类似,我们可以直接在div上面的文字上自由修改. 所以我们就需要在用户修改的时候,监听它的input事件. incantation 这个放的就是底部的提示咒语, font放的就是我们需要输入的咒语.


逻辑实现


export default defineComponent({
components: {
ClickIcon,
},
emits: ["completeOver"],
setup(props, { emit }) {
const isShow = ref(true);
const lottie = ref(null);
const incantainerRef = ref(null);
const defaultOption = reactive(defaultOptions);
const incantation = ref("Happy Mid-autumn Day");
let font = ref("_");

nextTick(() => {
incantainerRef.value.focus();
});

function inputChange(e) {
let text = e.target.innerText.replace("_", "");
if (!incantation.value.startsWith(text)) {
e.target.innerText = font.value;
} else {
if (incantation.value.length === text.length) {
emit("completeOver");
font.value = text;
isShow.value = false;
lottie.value.toggle();
} else {
font.value = text + "_";
}
}
}

return {
font,
inputChange,
incantation,
incantainerRef,
defaultOption,
lottie,
isShow,
};
},
});
</script>

在组件弹窗的时候,我们用incantainerRef.value.focus();让它自动获取焦点. 在inputChange事件里边, 我们去判断输入的咒语是否和提示的咒语相同,如果不同的话,则无法继续输入, 并停留在输入正确的咒语上, 如果都输入正确了的话, 则会自动关闭咒语弹窗,并弹出一个类似恭喜通过的烟花效果. 传入一个completeOver自定义事件给APP.vue.


页面主题APP.vue


页面的话,其实就像一个导演了. 接收到演员的各种回馈后, 然后安排下一个演员就位


  setup() {
let isShow = ref(false); // 对话框窗口开关
let typedShow = ref(false); // 咒语窗口开关
let girlAction = ref(false); // 女孩动作开关, 导演喊一句action后,演员开始演绎
const boy = reactive(boyData);
const girl = reactive(girlData);
const dialogueArr = reactive(dialogueContent);
// 男孩移动动画结束
function boyMoveEnd() {
isShow.value = true;
}
// 完成输入咒语
function completeOver() {
girlAction.value = true;
}
function girlMoveEnd() {}
// 对话窗口关闭
function dialogClose() {
// 对话框关闭后,弹出咒语的窗口,二牛输入咒语后,嫦娥开始飞仙动作
typedShow.value = true;
}
return {
dialogueArr,
boy,
girl,
isShow,
boyMoveEnd,
girlMoveEnd,
girlAction,
dialogClose,
typedShow,
completeOver,
};

大家看看就好,其实没啥特别好说的.


写在最后


关于那个烟花效果的话,我就不讲了,因为我上次的文章如何在vue中使用Lottie已经详细的讲清楚了每一个细节. 并且这一次的这个组件其实就是复用的我这篇文章讲的这个自己封装的组件. 基本的效果就这些,如果大家有兴趣的话,可以参照在我这个基础上再加入一些细节在里边. 比如添加云彩动效,添加水波动效等. 需要源码的可以点这里看看 充实的一天就是过得这么快呀~ 大家下次再见咯. 提前祝大家中秋节快乐!.



链接:https://juejin.cn/post/7007011750746783757

收起阅读 »

LeetCode第一讲:哈希表相关讲解

哈希表简单说明哈希表的建立需要有哈希地址,那么哈希地址地址的生成需要一个哈希函数,什么是哈希函数呢?哈希函数就是一个精心设计好的函数,该函数可以计算出存储的数据要放在什么位置,举个例子说明:例:有4条电话数据:王二蛋 12345678985李狗蛋 115544...
继续阅读 »

哈希表简单说明

哈希表的建立需要有哈希地址,那么哈希地址地址的生成需要一个哈希函数,什么是哈希函数呢?
哈希函数就是一个精心设计好的函数,该函数可以计算出存储的数据要放在什么位置,举个例子说明:

例:有4条电话数据:
王二蛋 12345678985
李狗蛋 11554456555
赵二狗 18816848615
李桂花 15899484538

如果我想查找王二蛋的电话,我需要拿出这个列表,一个一个找。但我想要通过名字快速查找王二蛋如何做呢?

答:我构建一个哈希表,来快速查找。那么通过名字来存的话,我需要构建一套规则来定位数据的存储位置。那么我构建如下的函数 Addr = H(”姓名“的首字母 ASCII - 65 ),需要一个32大小数组来存储数据。

但是如果按照刚刚的设计,那么”李桂花“与”李狗蛋“的存储就会发生冲突,那么在设计哈希函数有如下几种方式(对于哈希表而言,冲突只能尽可能地少,无法完全避免。)

哈希表构建

1、直接定址法
例如:有一个从1到100岁的人口数字统计表,其中,年龄作为关键字,哈希函数取关键字自身。
2、数字分析法
有学生的生日数据如下:
年.月.日
75.10.03
75.11.23
76.03.02
76.07.12
75.04.21
76.02.15

经分析,第一位,第二位,第三位重复的可能性大,取这三位造成冲突的机会增加,所以尽量不取前三位,取后三位比较好。
3、平方取中法
取关键字平方后的中间几位为哈希地址。
4、折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址,这方法称为折叠法。
例如:每一种西文图书都有一个国际标准图书编号,它是一个10位的十进制数字,若要以它作关键字建立一个哈希表,当馆藏书种类不到10,000时,可采用此法构造一个四位数的哈希函数。
5、除留余数法
取关键字被某个不大于哈希表表长m的数p除后所得余数为哈希地址。
H(key)=key MOD p (p<=m)
6、随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即
H(key)=random(key),其中random为随机函数。通常用于关键字长度不等时采用此法。
若已知哈希函数及冲突处理方法,哈希表的建立步骤如下:
Step1. 取出一个数据元素的关键字key,计算其在哈希表中的存储地址D=H(key)。若存储地址为D的存储空间还没有被占用,则将该数据元素存入;否则发生冲突,执行Step2。
Step2. 根据规定的冲突处理方法,计算关键字为key的数据元素之下一个存储地址。若该存储地址的存储空间没有被占用,则存入;否则继续执行Step2,直到找出一个存储空间没有被占用的存储地址为止。

冲突处理

1、拉链法
拉出一个动态链表代替静态顺序存储结构,可以避免哈希函数的冲突,不过缺点就是链表的设计过于麻烦,增加了编程复杂度。此法可以完全避免哈希函数的冲突。
2、多哈希法
设计二种甚至多种哈希函数,可以避免冲突,但是冲突几率还是有的,函数设计的越好或越多都可以将几率降到最低(除非人品太差,否则几乎不可能冲突)。
3、开放地址法
开放地址法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)
其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。如果di值可能为1,2,3,…m-1,称线性探测再散列。
如果di取1,则每次冲突之后,向后移动1个位置.如果di取值可能为1,-1,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2)
称二次探测再散列。如果di取值可能为伪随机数列。称伪随机探测再散列。
4、建域法
假设哈希函数的值域为[0,m-1],则设向量HashTable[0…m-1]为基本表,另外设立存储空间向量OverTable[0…v]用以存储发生冲突的记录。

题目1:

给定两个数组,编写一个函数来计算它们的交集。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]

示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[9,4]

说明:
输出结果中的每个元素一定是唯一的。
我们可以不考虑输出结果的顺序。

题目地址:https://leetcode-cn.com/problems/intersection-of-two-arrays
来源:力扣(LeetCode)

题目解答:

public static int[] intersection(int[] nums1, int[] nums2) {
Set<Integer> set = new HashSet<>();
for(int i :nums1){
set.add(i);
}
Set<Integer> set2 = new HashSet<>();
for(int j:nums2){
if(set.contains(j)){
set2.add(j);
}
}
int[] st = new int[set2.size()];
Object[] kk = set2.toArray();
for(int i=0;i<kk.length;i++){
st[i] = (int)kk[i];
}
return st;
}

解答说明:
该题由于要算交集,数组还有重复的可能行,那么就采用java中有的HashSet来处理问题,HashSet底层源码是实现了一个hashMap,HashMap实际上就是哈希表的实现,那么可以直接用hash表的特点来去重,再由hash表的特点来查询交集。算法实际时间复杂度为O(m+n) 空间复杂度:O(m+n)

题目2:

写代码,移除未排序链表中的重复节点。保留最开始出现的节点。
示例1:
输入:[1, 2, 3, 3, 2, 1]
输出:[1, 2, 3]

示例2:
输入:[1, 1, 1, 1, 2]
输出:[1, 2]
提示:

链表长度在[0, 20000]范围内。
链表元素在[0, 20000]范围内。
题目地址:https://leetcode-cn.com/problems/remove-duplicate-node-lcci
来源:力扣(LeetCode)

题目解答:

public ListNode removeDuplicateNodes(ListNode head) {
Set<Integer> set = new HashSet<>();
ListNode root = head;
ListNode temp = head;
ListNode pre = null;
while (temp!=null){
if(set.contains(temp.val)){
temp = temp.next;
pre.next = temp;
}else{
set.add(temp.val);
pre = temp;
temp = temp.next;
}
}
return root;
}

解答说明:
采用hash表的hash唯一性来做缓冲区,时间复杂度:O(n) 空间复杂度:O(n)

收起阅读 »

面试再也不怕 Handler 了,消息传递机制全解析

一、为什么要使用 Handler众所周知,Android 不允许在子线程中更新 UI。但是我们在子线程完成耗时的操作之后,需要对界面数据进行更新,又该怎么处理呢?这时候,我们可以使用 Handler 进行 UI 更新。值得注意的是,更新 UI 我们需要把 Me...
继续阅读 »

一、为什么要使用 Handler

众所周知,Android 不允许在子线程中更新 UI。但是我们在子线程完成耗时的操作之后,需要对界面数据进行更新,又该怎么处理呢?这时候,我们可以使用 Handler 进行 UI 更新。值得注意的是,更新 UI 我们需要把 Message 发送到主线程持有的 MessageQueue ,否则程序依然就会发生奔溃。

另外,除了更新 UI,Handler 是 Android 系统的消息传递机制,它定义了一套处理消息的规则,广播、服务以及线程间的通信都需要靠它来完成。

与 Handler 相关的还有 Looper 和 MessageQueue,接下来我们就从它的使用开始分析,对这三剑客一网打尽。

二、Handler 发送消息的流程

Handler 发送消息有两种方式,一种是 sendMessage 的方式,一种是 post 的方式,通过对源码的阅读,post 的方式其实是调用到了 sendMessage 的方式。那我们就来看看 sendMessage 的流程吧。通过调用 sendMessage,最终会走到下面方法中:

image.png

这里做的事情很简单,必须满足 MessageQueue 不能为空,否则程序会抛出异常,接下来看 enqueueMessage 的流程:

image.png

在这里完成了两个重要的流程:

  • 为 msg 的 target 赋值,msg.target = this,因此这个 target 就是调用的 sendMessage 的 Handler。(记住这里的重点)
  • 调用了 MessageQueue 的 enqueueMessage 方法。

到目前为止,流程来到了 MessageQueue 中。现在看 MessageQueue 的 enqueueMessage 方法。

三、MessageQueue 的工作流程

由于 enqueueMessage 的方法比较长,我们这里不截图,直接看下面的代码:(省略部分代码)

boolean enqueueMessage(Message msg, long when) {
// 1、target 不能为空,否则直接抛出异常
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
// 2、加锁,不能有多个 Handler 同时发送消息
synchronized (this) {
msg.when = when;
Message p = mMessages; // 出队列的 msg 的下一个要出队列的 msg
boolean needWake;
// 3、下面这三种情况直接插在 head 节点上,(1)这个队列是一个空队列,
// (2)这个 msg 需要立即处理,(3)是它需要处理的时间比即将出队列的节
// 点的处理时间还要小
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
needWake = mBlocked && p.target == null && msg.isAsynchronous();

// 4、如果之前第三点的条件不满足,就会从 head 节点开始遍历,
// 插入到一个合适的时间,或者链表的尾部,这个 for 循环做的其实就是
// 链表节点的插入
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}
// 5、是否需要进行唤醒,在 queue.next() 方法中如果没有获取到 msg就会休眠
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}

解释其实已经在上面的代码里了,下面来做一个简单归纳:

  • MessageQueue 其本质上一个单向链表,入队列这个操作进行了加锁的处理,不能多个 msg 同时入队列。
  • 在插入队列的时候,会根据当前队列是否为空,或者处理消息的时间选择合适的插入位置。
  • 最后判断是否需要进行 wake up

到目前为止,我们看了 Handler 的发送消息的流程,以及消息是如何插入链表的,那么消息是如何处理的呢?我们知道,只有调用了 Looper 的 loop() 方法之后,才能处理消息,那接下来看 Looper 的 loop() 方法。

四、Looper 的工作流程

Looper 的 loop() 方法也是相当长,接下来看代码:(省略部分代码)

public static void loop() {
// 1、获取 Looper 对象,定进行判空处理
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}

// 2、获取了 MessageQueue 对象
final MessageQueue queue = me.mQueue;
for (;;) {
// 3、调用 MessageQueue 的 next(),返回值是 msg
Message msg = queue.next(); // might block
if (msg == null) {
return;
}
....
try {
// 4、之前说过,在 SendMessage 的时候设置了 msg 的target,这个 target 就是调用 sendMessage 的 Handler
msg.target.dispatchMessage(msg);
} catch (Exception exception) {
} finally {
}

msg.recycleUnchecked();
}
}

代码本身很长,但是其实做的事情也不多,现在简单归纳一下:

  • 在调用 Looper.loop() 之前,必须先调用 Looper.prepare(),如果没有 Looper 对象的话程序会直接抛异常。
  • 通过调用 MessageQueue 的 next 方法不断的从队列里取消息出来。
  • 最后把 msg 交给 Handler 的 dispatchMessage() 进行处理。

通过源码我们可以发现调用 queue.next() 时可能发生阻塞,那这个方法又做了什么?还有,为什么要先调用 Looper.prepare(),这个方法又做了什么处理?先来看比较简单的吧:

image.png

这个 Looper.prepare() 其实是创建了一个 Looper 对象,并且通过 ThreadLocal 实现每个线程有且仅有一个这样的 Looper 对象。为什么要创建 Looper 呢?没有就不行吗?我们来看 Handler 的构造函数:

image.png

可以看到,如果 Looper 为空的话,程序直接抛异常。这个 myLooper() 是用来获取当前线程的 Looper 对象:

image.png

从时序上说,我们调用 Looper.prepare() 的时机必须在 new Handler() 之前。那么,我们主线程使用 Handler 的时候,并没有调用 Looper.prepare() 这个方法,这又是怎么回事呢?

原来,在 ActivityThread 的 main() 方法中已经为我们进行了处理:

image.png

这个 prepareMainLooper() 在内部调用了 Looper.prepare() 。到目前为止,我们解决了 Looper 的相关问题,说明了必须存在 Looper 的原因。现在还有一个问题没有解决,queue.next() 方法做了什么事情?它为什么发生阻塞呢?

接下来看 MessageQueue 的 next() 方法:(已省略部分代码)

Message next() {

final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1;
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}
// 1、这是一个 native 方法,如果messageQueue 没有可以处理的消息就会休眠
nativePollOnce(ptr, nextPollTimeoutMillis);
synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// 2、同步屏障,寻找队列中的下一个异步消息
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// 3、下一个出队列的这个 msg 还没有到时间,并计算需要阻塞的时间
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// 4、得到一个能够处理的msg,并返回这个 msg
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}
...
}
}
}

重要的点其实已经在上面说了,下面总结一下:

  • 获取 msg 的这个过程有可能会发生阻塞,具体调用到的是 native 的 nativePollOnce 方法
  • 获取消息的时候,有一个同步屏障,也就是对 msg 对应的 target(Handler) 为空的消息进行了过滤。
  • 如果能获取到一个 msg ,那么就返回这个 msg。

四、再看 Handler

先来梳理一下我们现在明白了什么:

  • 在创建 Handler 的时候,必须先创建 Looper 对象,之后还需要调用 Looper.loop() 方法才能让 Handler 开始工作。
  • 通过 Handler sendMessage 发送消息,其实是调用了 queue.enqueueMessage,这个 Queue 其实是一个单向链表,在调用这个方法的时候,会根据当前队列的转态以及 when 把这个 msg 插入到合适的位置。
  • queue.next() 可能会发生休眠,原因是拿到不到合适的 msg,在 queue.enqueueMessgae 的时候会判断是否需要唤醒。

之前我们说过,这个 msg 其实是交给了 Handler 的 dispatchMessage 去处理,下面来看一下 Handler 是怎么处理的:

image.png

  • msg.callback 是我们通过 post 方法传递进来的一个 Runnable 对象,如果我们没有使用 post 的话,就不会走到 handleCallback(msg) 中。
  • mCallback 是一个 CallBack 对象,如果我们在创建 Handler 的时候没有传这个参数,那么 mCallback 也是为null 的。
  • 最后才会走到 handleMessage(msg) 中。

收起阅读 »

在android中如何制作一个方向轮盘

先上效果图原理很简单,其实就是一个自定义的view通过观察,很容易发现,我们自己的轮盘就两个view需要绘制,一个是外面的圆盘,一个就随手指移动的滑块; 外面的圆盘很好绘制,内部的滑块则需要采集手指的位置,根据手指的位置计算出滑块在大圆内的位置; 最后,我们做...
继续阅读 »

先上效果图

Screenrecorder-2021-09-13-09-55-26-155.gif

原理很简单,其实就是一个自定义的view

通过观察,很容易发现,我们自己的轮盘就两个view需要绘制,一个是外面的圆盘,一个就随手指移动的滑块; 外面的圆盘很好绘制,内部的滑块则需要采集手指的位置,根据手指的位置计算出滑块在大圆内的位置; 最后,我们做的UI不是单纯做一个UI吧,肯定还是要用于实际应用中去,所以要加一个通用性很好的回调.

计算滑块位置的原理:

  • 当触摸点在大圆与小圆的半径差之内:
    那么滑块的位置就是触摸点的位置
  • 当触摸点在大圆与小圆的半径差之外:
    已知大圆圆心坐标(cx,cy),大圆半径rout,小圆半径rinside,触摸点的坐标(px,py)
    求小圆的圆心(ax,ay)?

image.png

作为经过九义的你我来说,这不就是一个简简单单的数学题嘛,很容易就求解出小圆的圆心位置了。 利用三角形相似:
\frac{ax-cx}{rout-rinside} = \frac{px-cx}{\sqrt{(px-cx)^2+(py-cy)^2}}
\frac{ay-cy}{rout-rinside} = \frac{py-cy}{\sqrt{(px-cx)^2+(py-cy)^2}}

通用性很好的接口:

滑块在圆中的位置,可以很好的用一个二位向量来表示,也可以用两个浮点的变量来表示;
xratio = \frac{ax-cx}{rout-rinside}
yratio = \frac{ay-cy}{rout-rinside}

这个接口就可以很好的表示了小圆在大圆的位置了,他们的取值范围是[-1,1]

小技巧:

为了小圆能始终在脱手后回到终点位置,我们设计了一个动画,当然,实际情况中有一种情况是,你移动到某个位置后,脱手后位置不能动,那你禁用这个动画即可。

代码部分

tips:代码部分的变量名与原理的变量名有出入

public class ControllerView extends View implements View.OnTouchListener {
private Paint borderPaint = new Paint();//大圆的画笔
private Paint fingerPaint = new Paint();//小圆的画笔
private float radius = 160;//默认大圆的半径
private float centerX = radius;//大圆中心点的位置cx
private float centerY = radius;//大圆中心点的位置cy
private float fingerX = centerX, fingerY = centerY;//小圆圆心的位置(ax,ay)
private float lastX = fingerX, lastY = fingerY;//小圆自动回归中点动画中上一点的位置
private float innerRadius = 30;//默认小圆半径
private float radiusBorder = (radius - innerRadius);//大圆减去小圆的半径
private ValueAnimator positionAnimator;//自动回中的动画
private MoveListener moveListener;//移动回调的接口

public ControllerView(Context context) {
super(context);
init(context, null, 0);
}

public ControllerView(Context context,
@Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs, 0);
}

public ControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}

//初始化
private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
if (attrs != null) {
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ControllerView);
int fingerColor = typedArray.getColor(R.styleable.ControllerView_fingerColor,
Color.parseColor("#3fffffff"));
int borderColor = typedArray.getColor(R.styleable.ControllerView_borderColor,
Color.GRAY);
radius = typedArray.getDimension(R.styleable.ControllerView_radius, 220);
innerRadius = typedArray.getDimension(R.styleable.ControllerView_fingerSize, innerRadius);
borderPaint.setColor(borderColor);
fingerPaint.setColor(fingerColor);
lastX = lastY = fingerX = fingerY = centerX = centerY = radius;
radiusBorder = radius - innerRadius;
typedArray.recycle();
}
setOnTouchListener(this);
positionAnimator = ValueAnimator.ofFloat(1);
positionAnimator.addUpdateListener(animation -> {
Float aFloat = (Float) animation.getAnimatedValue();
changeFingerPosition(lastX + (centerX - lastX) * aFloat, lastY + (centerY - lastY) * aFloat);
});
}

@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(getActualSpec(widthMeasureSpec), getActualSpec(heightMeasureSpec));
}


//处理wrapcontent的测量
//默认wrapcontent,没有做matchParent,指定大小的适配
//view实际的大小是通过大圆半径确定的
public int getActualSpec(int spec) {
int mode = MeasureSpec.getMode(spec);
int len = MeasureSpec.getSize(spec);
switch (mode) {
case MeasureSpec.AT_MOST:
len = (int) (radius * 2);
break;
}
return MeasureSpec.makeMeasureSpec(len, mode);
}

//绘制
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(centerX, centerY, radius, borderPaint);
canvas.drawCircle(fingerX, fingerY, innerRadius, fingerPaint);
}

@Override public boolean onTouch(View v, MotionEvent event) {
float evx = event.getX(), evy = event.getY();
float deltaX = evx - centerX, deltaY = evy - centerY;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//圆外按压不生效
if (deltaX * deltaX + deltaY * deltaY > radius * radius) {
break;
}
case MotionEvent.ACTION_MOVE:
//如果触摸点在圆外
if (Math.abs(deltaX) > radiusBorder || Math.abs(deltaY) > radiusBorder) {
float distance = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY);
changeFingerPosition(centerX + (deltaX * radiusBorder / distance),
centerY + (deltaY * radiusBorder / distance));
} else { //如果触摸点在圆内
changeFingerPosition(evx, evy);
}
positionAnimator.cancel();
break;
case MotionEvent.ACTION_UP:
positionAnimator.setDuration(1000);
positionAnimator.start();
break;
}
return true;
}

/**
* 改变位置的回调出来
*/

private void changeFingerPosition(float fingerX, float fingerY) {
this.fingerX = fingerX;
this.fingerY = fingerY;
if (moveListener != null) {
float r = radius - innerRadius;
if (r == 0) {
invalidate();
return;
}
moveListener.move((fingerX - centerX) / r, (fingerY - centerY) / r);
}
invalidate();
}

@Override protected void finalize() throws Throwable {
super.finalize();
positionAnimator.removeAllListeners();
}

public void setMoveListener(
MoveListener moveListener)
{
this.moveListener = moveListener;
}

/**
*回调事件的接口
*
**/

public interface MoveListener {
void move(float dx, float dy);
}
}

style.xml

name="ControllerView">
name="fingerColor" format="color" />
name="borderColor" format="color" />
name="fingerSize" format="dimension" />
name="radius" format="dimension" />



原文链接:https://juejin.cn/post/7007252815672279053
收起阅读 »

Android 架构师之路 - AOP 面向切面编程

引言相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有...
继续阅读 »


引言

相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有缺陷,导致很多同模块、同一水平上的工作要在许多类中重复出现。比如说:输出日志,监控方法执行时间,修改程序运行时的参数等等这样的事情,其实它们的代码都是可以重用的。

如果在一个大型的项目当中,使用手动修改源码的方式来达到调试、监控的目的,第一,需要插入许多重复代码(打印日志,监控方法执行时间),代码无法复用;第二,修改的成本太高,处处需要手动修改(分分钟累死、眼花)。

  • OOP: 面向对象把所有的事物都当做对象看待,因此每一个对象都有自己的生命周期,都是一个封装的整体。每一个对象都有自己的一套垂直的系列方法和属性,使得我们使用对象的时候不需要太多的关系它的内部细节和实现过程,只需要关注输入和输出,这跟我们的思维方式非常相近,极大的降低了我们的编写代码成本(而不像C那样让人头痛!)。但在现实世界中,并不是所有问题都能完美得划分到模块中。举个最简单而又常见的例子:现在想为每个模块加上日志功能,要求模块运行时候能输出日志。在不知道AOP的情况下,一般的处理都是:先设计一个日志输出模块,这个模块提供日志输出API,比如Android中的Log类。然后,其他模块需要输出日志的时候调用Log类的几个函数,比如e(TAG,…),w(TAG,…),d(TAG,…),i(TAG,…)等。
  • AOP: OOP固然开启另一个编程时代,但是久而久之也显露了它的缺点,最明显的一点就是它无法横向切割某一类方法、属性,当我们需要了解某一类方法、某一类属性的信息时,就必须要在每一个类的方法里面(即便他们是同样的方法,只因是不同的类所以不同)添加监控代码,在代码量庞大的情况下,这是一个不可取的方法。因此,AOP编产生了,基于AOP的编程可以让我们横向的切割某一类方法和属性(不需要关心他是什么类别!),AOP并不是与OOP对立的,而是为了弥补OOP的不足,因为有了AOP我们的调试和监控就变得简单清晰。

1.AspectJ介绍

1.1 AspectJ只是一个代码编译器

AspectJ 意思就是Java的Aspect,Java的AOP。它其实不是一个新的语言,它就是一个代码编译器(ajc,后面以此代替),在Java编译器的基础上增加了一些它自己的关键字识别和编译方法。因此,ajc也可以编译Java代码。它在编译期将开发者编写的Aspect程序编织到目标程序中,对目标程序作了重构,目的就是建立目标程序与Aspect程序的连接(耦合,获得对方的引用(获得的是声明类型,不是运行时类型)和上下文信息),从而达到AOP的目的(这里在编译期还是修改了原来程序的代码,但是是ajc替我们做的)。

1.2 AspectJ是用来做AOP编程的

Cross-cutting concerns(横切关注点): 尽管面向对象模型中大多数类会实现单一特定的功能,但通常也会开放一些通用的附属功能给其他类。例如,我们希望在数据访问层中的类中添加日志,同时也希望当UI层中一个线程进入或者退出调用一个方法时添加日志。尽管每个类都有一个区别于其他类的主要功能,但在代码里,仍然经常需要添加一些相同的附属功能。

  • Advice(通知): 注入到class文件中的代码。典型的 Advice 类型有 before、after 和 around,分别表示在目标方法执行之前、执行后和完全替代目标方法执行的代码。 除了在方法中注入代码,也可能会对代码做其他修改,比如在一个class中增加字段或者接口。
  • Joint point(连接点): 程序中可能作为代码注入目标的特定的点,例如一个方法调用或者方法入口。
  • Pointcut(切入点): 告诉代码注入工具,在何处注入一段特定代码的表达式。例如,在哪些 joint points 应用一个特定的 Advice。切入点可以选择唯一一个,比如执行某一个方法,也可以有多个选择,比如,标记了一个定义成@DebguTrace 的自定义注解的所有方法。
  • Aspect(切面): Pointcut 和 Advice 的组合看做切面。例如,我们在应用中通过定义一个 pointcut 和给定恰当的advice,添加一个日志切面。
  • Weaving(织入): 注入代码(advices)到目标位置(joint points)的过程。

下面这张图简要总结了一下上述这些概念。

传统编程:逐个插入验证用户模块

AOP方案:关注点聚焦

1.3、为什么要用AspectJ?
  • 非侵入式监控: 支持编译期和加载时代码注入,可以在不修监控目标的情况下监控其运行,截获某类方法,甚至可以修改其参数和运行轨迹!
  • 易于使用: 它就是Java,只要会Java就可以用它。
  • 功能强大,可拓展性高: 它就是一个编译器+一个库,可以让开发者最大限度的发挥,实现形形色色的AOP程序!

2、下载AspectJ相关资源与build.gradle配置

2.1、下载地址

下载aspectj的地址http://www.eclipse.org/aspectj/dow…\

2.2、解压aspectj jar包得到aspectjrt.jar
2.3、build.gradle配置

参考build.gradle aspectJ 写法 fernandocejas.com/2014/08/03/…
根目录中build.gradle配置:


buildscript {

repositories {
mavenCentral()
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.0'
classpath 'org.aspectj:aspectjtools:1.8.13'
classpath 'org.aspectj:aspectjweaver:1.8.13'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

modules中build.gradle配置:


apply plugin: 'com.android.application'
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main


android {
compileSdkVersion 26
defaultConfig {
applicationId "com.haocai.aopdemo"
minSdkVersion 15
targetSdkVersion 26
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}

dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation 'com.android.support:appcompat-v7:26.1.0'
implementation 'com.android.support.constraint:constraint-layout:1.0.2'
testImplementation 'junit:junit:4.12'
compile files('libs/aspectjrt.jar')
androidTestImplementation 'com.android.support.test:runner:1.0.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
// compile 'org.aspectj:aspectjrt:1.8.+'
}
注意:

dependencies 不要忘记添加 compile files('libs/aspectjrt.jar') ,aspectjrt.jar就是上一步解压得到的文件,放到libs文件夹下

3、示例程序

创建注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface BehaviorTrace {
String value();
int type();
}
Aspect 类

/**
* Created by Xionghu on 2018/1/23.
* Desc: 切面
* 你想要切下来的部分(代码逻辑功能重复模块)
*/
@Aspect
public class BehaviorAspect {
private static final String TAG = "MainAspect";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 根据切点 切成什么样子
*
*/
@Pointcut("execution(@com.haocai.aopdemo.BehaviorTrace * *(..))")
public void annoBehavior() {

}
/**
* 切成什么样子之后,怎么去处理
*
*/

@Around("annoBehavior()")
public Object dealPoint(ProceedingJoinPoint point) throws Throwable{
//方法执行前
MethodSignature methodSignature = (MethodSignature)point.getSignature();
BehaviorTrace behaviorTrace = methodSignature.getMethod().getAnnotation(BehaviorTrace.class);
String contentType = behaviorTrace.value();
int type = behaviorTrace.type();
Log.i(TAG,contentType+"使用时间: "+simpleDateFormat.format(new Date()));
long beagin=System.currentTimeMillis();
//方法执行时
Object object = null;
try{
object = point.proceed();
}catch (Exception e){
e.printStackTrace();
}

//方法执行完成
Log.i(TAG,"消耗时间:"+(System.currentTimeMillis()-beagin)+"ms");
return object;
}
}
调用主程序


package com.haocai.aopdemo;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "Main";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);





}
/**
* 摇一摇的模块
*
* @param view
*/
@BehaviorTrace(value = "摇一摇",type = 1)
public void mShake(View view)
{
//摇一摇的代码逻辑
{
SystemClock.sleep(3000);
Log.i(TAG," 摇到一个红包");

}
}
/**
* 语音的模块
*
* @param view
*/
@BehaviorTrace(value = "语音:",type = 1)
public void mAudio(View view)
{
//语音代码逻辑
{
SystemClock.sleep(3000);
Log.i(TAG,"发语音:我要到一个红包啦");
}
}
/**
* 打字模块
*
* @param view
*/
@BehaviorTrace(value = "打字:",type = 1)
public void mText(View view)
{
//打字模块逻辑
{
SystemClock.sleep(3000);
Log.i(TAG,"打字逻辑,我摇到了一个大红包");

}

}


// /**
// * 摇一摇的模块
// *
// * @param view
// */
// @BehaviorTrace(value = "摇一摇",type = 1)
// public void mShake(View view)
// {
// SystemClock.sleep(3000);
// Log.i(TAG," 摇到一个嫩模: 约不约");
// }
//
// /**
// * 摇一摇的模块
// *
// * @param view
// */
// public void mShake(View view)
// {
//
// long beagin=System.currentTimeMillis();
// Log.i(TAG,"摇一摇: 使用时间: "+simpleDateFormat.format(new Date()));
// //摇一摇的代码逻辑
// {
// SystemClock.sleep(3000);
//
// Log.i(TAG," 摇到一个红包");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
//
// }

// /**
// * 语音的模块
// *
// * @param view
// */
// public void mAudio(View view)
// {
// long beagin=System.currentTimeMillis();
// Log.i(TAG,"语音: 使用时间: "+simpleDateFormat.format(new Date()));
// //语音代码逻辑
// {
// SystemClock.sleep(3000);
//
// Log.i(TAG,"发语音:我要到一个红包啦");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
// }
//
// /**
// * 打字模块
// *
// * @param view
// */
// public void mText(View view)
// {
// //统计用户行为 的逻辑
// Log.i(TAG,"文字: 使用时间: "+simpleDateFormat.format(new Date()));
// long beagin=System.currentTimeMillis();
//
// //打字模块逻辑
// {
// SystemClock.sleep(3000);
// Log.i(TAG,"打字逻辑,我摇到了一个大红包");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
// }

}
注意:下面注释部分为传统写法
运行结果

01-23 19:39:09.579 13051-13051/com.haocai.aopdemo I/MainAspect: 摇一摇使用时间:   2018-01-23 19:39:09
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/Main: 摇到一个红包
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3001ms
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/MainAspect: 语音:使用时间: 2018-01-23 19:39:12
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/Main: 发语音:我要到一个红包啦
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3000ms
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/MainAspect: 打字:使用时间: 2018-01-23 19:39:15
01-23 19:39:18.609 13051-13051/com.haocai.aopdemo I/Main: 打字逻辑,我摇到了一个大红包
01-23 19:39:18.609 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3000ms
收起阅读 »

安卓分页加载器——Paging使用指南

一、简介应用开发过程中分页加载时很普遍的需求,它能节省数据流量,提升应用的性能。 Google为了方便开发者完成分页加载而推出了分页组件—Paging。为几种常见的分页机制提供了统一的解决方案。优势分页数据的内存中缓存。该功能可确保应用在处理分页数据时高效利用...
继续阅读 »

一、简介

应用开发过程中分页加载时很普遍的需求,它能节省数据流量,提升应用的性能。 Google为了方便开发者完成分页加载而推出了分页组件—Paging。为几种常见的分页机制提供了统一的解决方案。

  • 优势
    • 分页数据的内存中缓存。该功能可确保应用在处理分页数据时高效利用系统资源。
    • 内置的请求重复信息删除功能,可确保应用高效利用网络带宽和系统资源。
    • 可配置的RecyclerView适配器,会在用户滚动到已加载数据的末尾时自动请求数据。
    • 对Kotlin协程和Flow以及LiveData和RxJava的一流支持。
    • 内置对错误处理功能的支持,包括刷新和重试功能。
  • 数据来源:Paging支持三种数据架构类型
    • 网络:对网络数据进行分页加载是最常见的需求。API接口通常不太一样,Paging提供了三种不同的方案,应对不同的分页机制。Paging不提供任务错误处理功能,发生错误后可重试网络请求。
    • 数据库:数据库进行分页加载和网络类似,推荐使用Room数据库修改和插入数据。
    • 网络+数据库:通常只采用单一数据源作为解决方案,从网络获取数据,直接缓存进数据库,列表直接从数据库中获取数据。

二、核心

2.1 核心类

Paging的工作原理主要涉及三个类:

  1. PagedListAdapter:RecyclerView.Adapter基类,用于在RecyclerView显示来自PagedList的分页数据。
  2. PagedList:PagedList负责通知DataSource何时获取数据,如加载第一页、最后一页及加载数量等。从DataSource获取的数据将存储在PagedList中。
  3. DataSource:执行具体的数据载入工作,数据载入需要在工作线程中进行

以上三个类的关系及数据加载流程如下图:

20181021221030916.gif

当一条新的item插入到数据库,DataSource会被初始化,LiveData后台线程就会创建一个新的PagedList。这个新的PagedList会被发送到UI线程的PagedListAdapter中,PagedListAdapter使用DiffUtil在对比现在的Item和新建Item的差异。当对比结束,PagedListAdapter通过调用RecycleView.Adapter.notifyItemInserted()将新的item插入到适当的位置

2.2 DataSource

根据分页机制的不同,Paing为我们提供了三种DataSource。

  1. PositionalDataSource

适用于可通过任意位置加载数据,且目标数据源数量固定的情况。

  1. PageKeyedDataSource

适合数据源以“页”的方式进行请求的情况。如获取数据携带pagepageSize时。本文代码使用此DataSource

  1. ItemKeyedDataSource

适用于当目标数据的下一页需要依赖上一页数据中的最后一个对象中的某个字段作为key的情况,如评论数据的接口携带参数sincepageSize

三、使用

3.1 构建自己的DataSource

DataSource控制数据加载,包括初始化加载,加载上页数据,加载下页数据。此处我们以PageKeyedDataSource为例

//泛型参数未Key Value,Key就是每页的标志,此处为Long,Value为数据类型
class ListDataSource : PageKeyedDataSource<Long, Item>() {
//重试加载时的参数
private var lastLoadParam: Pair<LoadParams<Long>, LoadCallback<Long, Item>>? = null

}

其中的关键点在于,每次Key的选定以及loadInitialloadBeforeloadAfter三个函数的重写。PageKeyedDataSource的Key一般依赖与服务端返回的数据。

3.2 构建PagedList

companion object{

private const val TAG = "List"
const val PAGE_SIZE = 5
const val FETCH_DIS = 1

}
val ListData: LiveData<PagedList<Item>> = LivePagedListBuilder(
dataSourceFactory,
Config(
PAGE_SIZE,
FETCH_DIS,
true
)
).build()

其中PAGE_SIZE是每页的数量,FETCH_DIS是距离最后一个数据item还有多少距离就触发加载动作。

此处ListData是LiveData类型,因此可以在Activity中进行监听,当发生数据变化时,则刷新adapter:

ListViewModel.ListData.observe(this) {
adapter.submitList(it)
}

3.3 构建自己的PagedListAdapter

一定要继承PagedListAdapter<Item, RecyclerView.ViewHolder>(``POST_COMPARATOR``)POST_COMPARATOR就是DiffUtil,PagedListAdapter使用DiffUtil在对比现在的Item和新建Item的差异。

typealias ItemClickListener = (Item) -> Unit
typealias onClickListener = () -> Unit

class ListAdapter(
pri
}
}

可以看到基本写法和普通的RecyclerView.Adapter是差不多的,只是多了DiffUtil,使用起来也是一样:

adapter = ListAdapter(
this,
onItemClickListener,
headRetryClickListener,
footRetryClickListener
)
list_rv.adapter = adapter

四、Paging 3.0

Paging3与旧版Paging存在很大区别。Paging2.x运行起来的效果无限滑动还不错,不过代码写起来有点麻烦,功能也不是太完善,比如下拉刷新的方法都没有提供,我们还得自己去调用DataSource#invalidate()方法重置数据来实现。Paging3.0功能更加强大,用起来更简单。

4.1 区别

  • DataSource

Paing2中的DataSource有三种,Paging3中将它们合并到了PagingSource中,实现load()和getRefreshKey(),在Paging3中,所有加载方法参数被一个LoadParams密封类替代,该类中包含了每个加载类型所对应的子类。如果需要区分load()中的加载类型,需要检查传入了LoadParams的哪个子类

  • PagedListAdapter

Adapter不在继承PagedListAdapter,而是由PagingDataAdapter替代,其它不变。

class ArticleAdapter : PagingDataAdapter<Article,ArticleViewHolder>(POST_COMPARATOR){

companion object{

val POST_COMPARATOR = object : DiffUtil.ItemCallback<Article>() {
override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem == newItem

override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem.id == newItem.id
}
}

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
holder.tvName.text = getItem(position)?.title
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
return ArticleViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item,parent,false))
}
}

class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
val tvName: TextView = itemView.findViewById(R.id.tvname)
}

4.2 获取数据并设置给Adapter

google提倡我使用三层架构来完成数据到Adapter的设置,如下图

image.png

代码库层

代码库层中的主要 Paging 库组件是 PagingSource。每个 PagingSource 对象都定义了数据源,以及如何从该数据源检索数据。PagingSource 对象可以从任何单个数据源(包括网络来源和本地数据库)加载数据。可使用的另一个 Paging 库组件是 RemoteMediatorRemoteMediator 对象会处理来自分层数据源(例如具有本地数据库缓存的网络数据源)的分页。

ViewModel 层

Pager 组件提供了一个公共 API,基于 PagingSource 对象和 PagingConfig 配置对象来构造在响应式流中公开的 PagingData 实例。将 ViewModel 层连接到界面的组件是 PagingData。 PagingData 对象是用于存放分页数据快照的容器。它会查询 PagingSource 对象并存储结果。

界面层

界面层中的主要 Paging 库组件是 PagingDataAdapter

收起阅读 »

安卓-Glidel图片加载框架学习笔记

引用地址: muyangmin.github.io/glide-docs-… 以glide Version = '4.12.0'为例 1.Gradle配置 此处配置在子模块里(要添加到app主模块也可以),非app主模块里 //glide图片加载框架 impl...
继续阅读 »

引用地址:


muyangmin.github.io/glide-docs-…


以glide Version = '4.12.0'为例


1.Gradle配置


此处配置在子模块里(要添加到app主模块也可以),非app主模块里


//glide图片加载框架
implementation "com.github.bumptech.glide:annotations:${rootProject.glideVersion}"
api "com.github.bumptech.glide:glide:${rootProject.glideVersion}"
annotationProcessor "com.github.bumptech.glide:compiler:${rootProject.glideVersion}"

app模块只需要添加下面一句,然后app主模块引用上面的子模块


implementation project(":CommonModule")
annotationProcessor "com.github.bumptech.glide:compiler:${rootProject.glideVersion}"

2.添加权限声明


添加到子模块AndroidManifest.xml里即可(非app主模块)


<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

3.如果使用到Proguard


如果你有使用到 proguard,那么请把以下代码添加到你的 proguard.cfg 文件中:


-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}

4.@GlideModule 定义AppGlideModule子类和LibraryGlideModule子类


定义一个AppGlideModule子类,定义在app主模块里,而且只能定义在app主模块里。


package com.example.myapp;

import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;

@GlideModule
public final class MyAppGlideModule extends AppGlideModule {}

定义LibraryGlideModule,定义在非app子模块里。


import android.content.Context;
import android.util.Log;

import androidx.annotation.NonNull;

import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.LibraryGlideModule;

@GlideModule
public class MyLibraryGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
super.registerComponents(context, glide, registry);
Log.d("MyLibraryGlideModule","MyLibraryGlideModule");
}
}

定义完成后,不要忘记了 Build ->Make Project,然后会生成会在app\build\generated\ap_generated_sources\debug\out的目录下生成相应的文件,如GlideApp.java,LibraryGlideModule也会自动在生成相应的文件。


5.使用 Generated API


Generated API 默认名为 GlideApp ,与 Application 模块中 AppGlideModule的子类包名相同。在 Application 模块中将 Glide.with() 替换为 GlideApp.with(),即可使用该 API 去完成加载工作:


GlideApp.with(fragment)
.load(myUrl)
.placeholder(R.drawable.placeholder)
.fitCenter()
.into(imageView);

占位符:.placeholder(placeholder) 加载中的占位符


加载失败占位符:.error(android.R.drawable.stat_notify_error)


RequestOptions在多个请求之间共享配置


RequestOptions sharedOptions = 
new RequestOptions()
.placeholder(placeholder)
.fitCenter();

Glide.with(fragment)
.load(myUrl)
.apply(sharedOptions)
.into(imageView1);

Glide.with(fragment)
.load(myUrl)
.apply(sharedOptions)
.into(imageView2);

6.在 ListView 和 RecyclerView 中的使用


在 ListView 或 RecyclerView 中加载图片的代码和在单独的 View 中加载完全一样。Glide 已经自动处理了 View 的复用和请求的取消:


View 调用 clear()into(View),表明在此之前的加载操作会被取消,并且在方法调用完成后,Glide 不会改变 view 的内容。如果你忘记调用 clear(),而又没有开启新的加载操作,那么就会出现这种情况,你已经为一个 view 设置好了一个 Drawable,但该 view 在之前的位置上使用 Glide 进行过加载图片的操作,Glide 加载完毕后可能会将这个 view 改回成原来的内容。


这里的代码以 RecyclerView 的使用为例,但规则同样适用于 ListView。


正确用法1


@Override
public void onBindViewHolder(ViewHolder holder, int position) {
String url = urls.get(position);
Glide.with(fragment)
.load(url)
.into(holder.imageView);
}

正确用法2


@Override
public void onBindViewHolder(ViewHolder holder, int position) {
if (isImagePosition(position)) {
String url = urls.get(position);
Glide.with(fragment)
.load(url)
.into(holder.imageView);
} else {
Glide.with(fragment).clear(holder.imageView);
holder.imageView.setImageDrawable(specialDrawable);
}
}

7.非 View 目标


除了将 BitmapDrawable 加载到 View 之外,你也可以开始异步加载到你的自定义 Target 中:


Glide.with(context
.load(url)
.into(new CustomTarget<Drawable>() {
@Override
public void onResourceReady(Drawable resource, Transition<Drawable> transition) {
// Do something with the Drawable here.
}

@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
// Remove the Drawable provided in onResourceReady from any Views and ensure
// no references to it remain.
}
});

8.后台线程


后台线程下载,


FutureTarget<Bitmap> futureTarget =
Glide.with(context)
.asBitmap()
.load(url)
.submit(width, height);

Bitmap bitmap = futureTarget.get();

// Do something with the Bitmap and then when you're done with it:
Glide.with(context).clear(futureTarget);

后台同步实现图片下载


 //不能直接在主线程里调用,会直接ANR
FutureTarget<File> futureTarget = Glide.with(GlideImageLoadFragment.this)
.asFile()
.load("https://dss3.bdstatic.com/iPoZeXSm1A5BphGlnYG/skin/822.jpg?2")
.addListener(new RequestListener<File>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e,
Object model, Target<File> target,
boolean isFirstResource) {
return false;
}

@Override
public boolean onResourceReady(File resource, Object model, Target<File> target, DataSource dataSource, boolean isFirstResource) {
return false;
}
}).submit();

//下载到的文件没有扩展名。
File file = futureTarget.get();

异常下载图片并回调 .asFile()


GlideApp.with(this)
.asFile()
.load("https://img.soogif.com/rSlMSm7msQagXhSSgIQ0LtqTusCK712l.gif")
.into(new CustomTarget<File>() {
@Override
public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
Log.d("", "");
}

@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
Log.d("", "");
}
});

8.Glide扩展 GlideExtension


@GlideExtension 注解用于标识一个扩展 Glide API 的类。任何扩展 Glide API 的类都必须使用这个注解来标记,否则其中被注解的方法就会被忽略。


@GlideExtension 注解的类应以工具类的思维编写。这种类应该有一个私有的、空的构造方法,应为 final 类型,并且仅包含静态方法。被注解的类可以含有静态变量,可以引用其他的类或对象。


在 Application 模块中可以根据需求实现任意多个被 @GlideExtension 注解的类,在 Library 模块中同样如此。当 AppGlideModule 被发现时,所有有效的 Glide 扩展类 会被合并,所有的选项在 API 中均可以被调用。合并冲突会导致 Glide 的 Annotation Processor 抛出编译错误。


@GlideExtention 注解的类有两种扩展方式:



  1. GlideOption - 为 RequestOptions 添加一个自定义的选项。

  2. GlideType - 添加对新的资源类型的支持(GIF,SVG 等等)。


@GlideExtension


@GlideExtension只能在app模块里使用。


//@GlideExtension 注解到类上面,只能在app模块里使用。
@GlideExtension
public class MyAppExtension {
// Size of mini thumb in pixels.
private static final int MINI_THUMB_SIZE = 100;

private MyAppExtension() { } // utility class

//@GlideOption注解到方法里面。标记的方法应该为静态方法
@NonNull
@GlideOption
public static BaseRequestOptions<?> miniThumb(BaseRequestOptions<?> options) {
return options
.fitCenter()
.override(MINI_THUMB_SIZE);
}

// 你可以为方法任意添加参数,但要保证第一个参数为 RequestOptions。 标记的方法应该为静态方法
@GlideOption
public static BaseRequestOptions<?> miniThumb(BaseRequestOptions<?> options, int size) {
return options
.fitCenter()
.override(size);
}
}

添加完成后 执行 Build ->Make Project,可以看到app\build\generated\ap_generated_sources\debug\out\my\android\architecture\samples\glide\GlideOptions.java


里生成了下面的方法


@SuppressWarnings("unchecked")
@CheckResult
@NonNull
public GlideOptions miniThumb() {
return (GlideOptions) MyAppExtension.miniThumb(this);
}

/**
* @see MyAppExtension#miniThumb(BaseRequestOptions, int)
*/
@SuppressWarnings("unchecked")
@CheckResult
@NonNull
public GlideOptions miniThumb(int size) {
return (GlideOptions) MyAppExtension.miniThumb(this, size);
}

调用


GlideApp.with(fragment)
.load(url)
.miniThumb(thumbnailSize)
.into(imageView);

GlideType


@GlideType 注解的静态方法用于扩展 RequestManager 。被 @GlideType 注解的方法允许你添加对新的资源类型的支持,包括指定默认选项。


例如,为添加对 GIF 的支持,你可以添加一个被 @GlideType 注解的方法:


@GlideExtension
public class MyAppExtension {
private static final RequestOptions DECODE_TYPE_GIF = decodeTypeOf(GifDrawable.class).lock();

@NonNull
@GlideType(GifDrawable.class)
public static RequestBuilder<GifDrwable> asGif(RequestBuilder<GifDrawable> requestBuilder) {
return requestBuilder
.transition(new DrawableTransitionOptions())
.apply(DECODE_TYPE_GIF);
}
}

这样会生成一个包含对应方法的 RequestManager


public class GlideRequests extends RequesetManager {

public GlideRequest<GifDrawable> asGif() {
return (GlideRequest<GifDrawable> MyAppExtension.asGif(this.as(GifDrawable.class));
}

...
}

9.占位符


Glide允许用户指定三种不同类型的占位符,分别在三种不同场景使用:


placeholder
error
fallback


占位符(Placeholder)


占位符是当请求正在执行时被展示的 Drawable 。当请求成功完成时,占位符会被请求到的资源替换。如果被请求的资源是从内存中加载出来的,那么占位符可能根本不会被显示。如果请求失败并且没有设置 error Drawable ,则占位符将被持续展示。类似地,如果请求的url/model为 null ,并且 error Drawablefallback 都没有设置,那么占位符也会继续显示。


错误符(Error)


error Drawable 在请求永久性失败时展示。error Drawable 同样也在请求的url/model为 null ,且并没有设置 fallback Drawable 时展示。


后备回调符(Fallback)


fallback Drawable 在请求的url/model为 null 时展示。设计 fallback Drawable 的主要目的是允许用户指示 null 是否为可接受的正常情况。例如,一个 null 的个人资料 url 可能暗示这个用户没有设置头像,因此应该使用默认头像。然而,null 也可能表明这个元数据根本就是不合法的,或者取不到。 默认情况下Glide将 null 作为错误处理,所以可以接受 null 的应用应当显式地设置一个 fallback Drawable


占位符是异步加载的吗?

No。占位符是在主线程从Android Resources加载的。我们通常希望占位符比较小且容易被系统资源缓存机制缓存起来。


变换是否会被应用到占位符上?

No。Transformation仅被应用于被请求的资源,而不会对任何占位符使用。


在应用中包含必须在运行时做变换才能使用的图片资源是很不划算的。相反,在应用中包含一个确切符合尺寸和形状要求的资源版本几乎总是一个更好的办法。假如你正在加载圆形图片,你可能希望在你的应用中包含圆形的占位符。另外你也可以考虑自定义一个View来剪裁(clip)你的占位符,而达到你想要的变换效果。


在多个不同的View上使用相同的Drawable可行么?

通常可以,但不是绝对的。任何无状态(non-stateful)的 Drawable(例如 BitmapDrawable )通常都是ok的。但是有状态的 Drawable 不一样,在同一时间多个 View 上展示它们通常不是很安全,因为多个View会立刻修改(mutate) Drawable 。对于有状态的 Drawable ,建议传入一个资源ID,或者使用 newDrawable() 来给每个请求传入一个新的拷贝。


10.选项


1.请求选项


Glide中的大部分设置项都可以直接应用在 Glide.with() 返回的 RequestBuilder 对象上。


可用的选项包括(但不限于):



  • 占位符(Placeholders)

  • 转换(Transformations)

  • 缓存策略(Caching Strategies)

  • 组件特有的设置项,例如编码质量,或Bitmap的解码配置等。


例如,要应用一个 CenterCrop 转换,你可以使用以下代码:


Glide.with(fragment)
.load(url)
.centerCrop()
.into(imageView);

RequestOptions对象 apply(@NonNull BaseRequestOptions<?> options)


如果你想让你的应用的不同部分之间共享相同的加载选项,你也可以初始化一个新的 RequestOptions 对象,并在每次加载时通过 apply() 方法传入这个对象:


RequestOptions cropOptions = new RequestOptions().centerCrop(context);
...
Glide.with(fragment)
.load(url)
.apply(cropOptions)
.into(imageView);

apply() 方法可以被调用多次,因此 RequestOption 可以被组合使用。如果 RequestOptions 对象之间存在相互冲突的设置,那么只有最后一个被应用的 RequestOptions 会生效。


过渡选项 transition


不同于RequestOptionsTransitionOptions是特定资源类型独有的,你能使用的变换取决于你让Glide加载哪种类型的资源。


这样的结果是,假如你请求加载一个 Bitmap ,你需要使用 BitmapTransitionOptions ,而不是 DrawableTransitionOptions 。同样,当你请求加载 Bitmap时,你只需要做简单的淡入,而不需要做复杂的交叉淡入。


RequestBuilder


使用 RequestBuilder 可以指定:



  • 你想加载的资源类型(Bitmap, Drawable, 或其他)

  • 你要加载的资源地址(url/model)

  • 你想最终加载到的View

  • 任何你想应用的(一个或多个)RequestOption 对象

  • 任何你想应用的(一个或多个)TransitionOption 对象

  • 任何你想加载的缩略图 thumbnail()


选择资源类型


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

Swift 5.5 新特性

iOS
Swift 5.5 内置于 Xcode 13,虽然版本号只增加了 0.1,看似是一个小版本升级,但却带来了非常多的新内容,其中最大的更新是引入了全新的并发编程方式。条件编译支持表达式SwiftUI 在跨平台时会使用到条件 Modifier,之前的解决方案是自己...
继续阅读 »

Swift 5.5 内置于 Xcode 13,虽然版本号只增加了 0.1,看似是一个小版本升级,但却带来了非常多的新内容,其中最大的更新是引入了全新的并发编程方式。

条件编译支持表达式

SwiftUI 在跨平台时会使用到条件 Modifier,之前的解决方案是自己写一套判断体系, Swift 5.5 以后,原生支持条件编译表达式,跨平台更加方便。

struct ContentView: View {
var body: some View {
Text("SwiftUI")
#if os(iOS)
.foregroundColor(.blue)
#elseif os(macOS)
.foregroundColor(.green)
#else
.foregroundColor(.pink)
#endif
}
}
复制代码

CGFloat与Double支持隐式转换

let number1: CGFloat = 12.34
let number2: Double = 56.78
let result = number1 + number2 // result为Double类型
复制代码

下面的代码在 Swift 5.5 之前会报错,因为scale为 Double 类型,而 SwiftUI 中需要绑定 CGFloat 类型。

struct ContentView: View {
@State private var scale = 1.0 // Double类型

var body: some View {
VStack {
Image(systemName: "heart")
.scaleEffect(scale) // 隐式转换为CGFloat

Slider(value: $scale, in: 0 ... 1)
}
}
}
复制代码

在通用上下文中扩展静态成员查找(static member lookup)

这个新特性使得 SwiftUI 中的部分语法更加简洁好用。

struct ContentView: View {
@Binding var name: String

var body: some View {
HStack {
Text(name)

TextField("", text: $name)
// .textFieldStyle(RoundedBorderTextFieldStyle()) // 以前写法
.textFieldStyle(.roundedBorder) // 新写法,更简洁
}
}
}
复制代码

局部变量支持lazy

func lazyInLocalContext() {
print("lazy之前")
lazy var swift = "Hello Swift 5.5"
print("lazy之后")

print(swift)
}

// 调用
lazyInLocalContext()

/* 输出
lazy之前
lazy之后
Hello Swift 5.5
*/
复制代码

函数和闭包参数支持属性包装

  • Swift 5.1 中引入了属性包装。
  • Swift 5.4 将属性包装支持到局部变量。
  • Swift 5.5 将属性包装支持到函数和闭包参数。
@propertyWrapper struct Trimmed {
private var value: String = ""

var wrappedValue: String {
get { value }
set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
}

init(wrappedValue initialValue: String) {
wrappedValue = initialValue
}
}

struct Post {
func trimed(@Trimmed content: String) { // 函数参数支持PropertyWrapper
print(content)
}
}

let post = Post()
post.trimed(content: " Swift 5.5 Property Wrappers ")
复制代码

带有关联值的枚举支持Codable

有了该功能之后,枚举就可以像结构体、类一样用来作为数据模型了。

  • 枚举到 JSON。
// 定义带有关联值的枚举
enum Score: Codable {
case number(score: Double)
case letter(score: String)
}

// 创建对象
let scores: [Score] = [.number(score: 98.5), .letter(score: "优")]

// 转JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let result = try encoder.encode(scores)
let json = String(decoding: result, as: UTF8.self)
print(json)
} catch {
print(error.localizedDescription)
}
复制代码
  • JSON 到枚举。
enum Score: Codable {
case number(score: Double)
case letter(score: String)
}

// JSON
let json = """
[
{
"number" : {
"score" : 98.5
}
},
{
"letter" : {
"score" : "优"
}
}
]
"""

// 转枚举
let decoder = JSONDecoder()
do {
let scores = try decoder.decode([Score].self, from: json.data(using: .utf8)!)
for score in scores {
switch score {
case let .number(value):
print(value)
case let .letter(value):
print(value)
}
}
} catch {
print(error.localizedDescription)
}
复制代码

并发编程

内容较多且尚不稳定,后面会单独出《Swift 5.5 Concurrency》

收起阅读 »

iOS - Core Graphics快速入门——从一行代码说起

iOS
Core Graphics入门想必每个第一次接触Core Graphics的开发者都被无数的API、混乱的代码逻辑折腾得头疼不已,甚至望而却步。即使是绘制一个简单的矩形也看上去非常繁琐。本文换一个角度,整理一下有关Core Graphics的知识,也算作是这段...
继续阅读 »

Core Graphics入门

想必每个第一次接触Core Graphics的开发者都被无数的API、混乱的代码逻辑折腾得头疼不已,甚至望而却步。即使是绘制一个简单的矩形也看上去非常繁琐。本文换一个角度,整理一下有关Core Graphics的知识,也算作是这段时间学习的总结。

Core Graphics和UIKit的区别

首先从概念上了解一下:


根据苹果的描述,UIKit是我们最容易也是最常接触到的框架。绝大多数图形界面都由UIKit完成。但是UIKit依赖于Core Graphics框架,也是基于Core Graphics框架实现的。如果想要完成某些更底层的功能或者追求极致的性能,那么依然推荐使用Core Graphics完成。

Core Graphics和UIKit在实际使用中也存在以下这些差异:

  1. Core Graphics其实是一套基于C的API框架,使用了Quartz作为绘图引擎。这也就意味着Core Graphics不是面向对象的。
  2. Core Graphics需要一个图形上下文(Context)。所谓的图形上下文(Context),说白了就是一张画布。这一点非常容易理解,Core Graphics提供了一系列绘图API,自然需要指定在哪里画图。因此很多API都需要一个上下文(Context)参数。
  3. Core Graphics的图形上下文(Context)是堆栈式的。只能在栈顶的上下文(画布)上画图。
  4. Core Graphics中有一些API,名称不同却有着相似的功能,新手只需要掌握一种,并能够看懂其他的即可。

从一行代码说起

下面这行代码应该是很多人最早也是最常写的代码。它简单到我们根本不用思考它的本质。

[self.view addSubview:myButton];

细想一下,UIButton也是继承自UIView。这段代码表示,UIKit绘图的基本思想是通过UIView的叠加实现最终的整体效果。它主要涉及三个内容:画布、被添加的控件和添加方法。这里的self.view其实就充当了一张画布。通过添加不同的UI控件达到最终效果。我们顺着这个线索整理一下Core Graphics的编程思路。

Core Graphics的基本使用

为了使用Core Graphics来绘图,最简单的方法就是自定义一个类继承自UIView,并重写子类的drawRect方法。在这个方法中绘制图形。
Core Graphics必须一个画布,才能把东西画在这个画布上。在drawRect方法方法中,我们可以直接获取当前栈顶的上下文(Context)。下面的代码演示了具体操作步骤:

- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
}

现在我们已经完成了Core Graphics绘图的三分之一——创建一个画布。
接下来需要考虑被画上去的东西。这在UIKit中往往是一个UI控件,如Button、Label等。而在Core Graphics中通常表现为一些基本图形:三角形、矩形、圆形、以及这些图形的边框等。

这通常会涉及到非常多的API,但是如果总结一下不难发现,任何一个要绘制的东西(为了避免混淆就不称为对象了)一定有一个边框,或者称为边界。在一个几英寸的屏幕上画出无界的图形是不可能的。所以一旦确定了一个边框,我们就可以设置边框的各种绘图属性、边框内部区域的绘图属性、绘制边框还是内部区域等。

这就引出了Core Graphics中的路径(Path)的概念。在前一段代码的基础上演示路径的使用:

- (void)drawSomething{
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
CGPathMoveToPoint(path, nil, 20, 50);//移动到指定位置(设置路径起点)
CGPathAddLineToPoint(path, nil, 20, 100);//绘制直线(从起始位置开始)
CGContextAddPath(context, path);//把路径添加到上下文(画布)中
}

这里通过CGPathCreateMutable方法创建了一个路径。路径的外在表现就像一条折线。为了绘制一条路径,需要用CGPathMoveToPoint函数指定路径的起点。CGPathAddLineToPoint函数表示在路径的最后结束点和新的点之间再加一条直线。相当于拓展了原来路径。通过这样的简单的点的累加,可以绘制非常复杂的折线。

但这存在两个问题:

  1. 绘制矩形等规则多边形的过程过于繁琐
  2. 无法绘制曲线。

这些问题Core Graphics早已提供了解决办法。注意到之前我们添加了一个非常普通的自定义路径。Core Graphics中还提供了很多预先设置好的路径。不妨在drawRect方法中输入“cgcontextadd”试试看。

技术分享

这些方法由Core Graphics提供,可以用来绘制圆形、椭圆、矩形、二次曲线等路径。创建完路径后还要记得调用CGContextAddPath方法将路径添加到上下文中。路径只是我们画的一条线而已,不把他画到上,他就没有什么卵用。

添加好路径后,就要开始画图了。正如前面提出的问题所说,画图的时候需要考虑画不画边框、画不画边框内部的区域,边框的粗细、颜色、内部区域颜色等问题。Core Graphics提供了另一个方法集合”CGContextSet”来进行这些设置。常见的设置内容如下:

- (void)drawSomething{
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
CGPathMoveToPoint(path, nil, 20, 50);//移动到指定位置(设置路径起点)
CGPathAddLineToPoint(path, nil, 20, 100);//绘制直线(从起始位置开始)
CGContextAddPath(context, path);//把路径添加到上下文(画布)中

//设置图形上下文状态属性
CGContextSetRGBStrokeColor(context, 1.0, 0, 0, 1);//设置笔触颜色
CGContextSetRGBFillColor(context, 0, 1.0, 0, 1);//设置填充色
CGContextSetLineWidth(context, 2.0);//设置线条宽度
CGContextSetLineCap(context, kCGLineCapRound);//设置顶点样式
CGContextSetLineJoin(context, kCGLineJoinRound);//设置连接点样式
CGFloat lengths[2] = { 18, 9 };
CGContextSetLineDash(context, 0, lengths, 2);
CGContextSetShadowWithColor(context, CGSizeMake(2, 2), 0, [UIColor blackColor].CGColor);
CGContextDrawPath(context, kCGPathFillStroke);//最后一个参数是填充类型
}

设置属性的前三行就不再解释了,看一些注释足矣。顶点指的是路径的起始点和结束点,连接点指的是路径中的转折点(折现才有)。SetLineDash用于绘制虚线,具体用法参见——《IOS中使用Quartz 2D绘制虚线》。SetShadow方法用于绘制阴影,第二个参数是一个CGSize对象,用于表示阴影偏移量,第三个参数表示模糊度,数值越大,阴影越模糊,第一个参数是一个CGColor,表示阴影颜色,需要由UIColor转换得到。

至此,我们完成了Core Graphics绘图的第二步,也是最复杂的一部分:设置绘图内容。这相当于此前那行代码的中的UI控件。

设置好了绘图的属性之后,就可以调用CGContextDrawPath方法绘图了。第一个参数表示要在哪一个上下文中绘图,第二个参数表示填充类型。在填充类型中可以选择只绘制边框、只填充、同时绘制边框和填充内部区域、奇偶规则填充等。


从方法名不难看出,但是也需要注意的是,这些设置都是对上下文(context)生效的。这样会导致,所有的边框颜色、粗细都一样。一个简单的解决办法就是在需要修改设置之前调用一次CGContextDrawPath方法绘图。再修改设置,修改设置之后再次绘制。

图画完了,还得做一下清理工作。CGPathCreateMutable方法返回的路径是一个Core Fundation Object。而这并不在ARC的管理范围之内。所以需要手动释放对象。

- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
/*
绘图
*/

CGPathRelease(path);
}

这样就完成了Core Graphics绘图的第三部分——开始绘图。
再总结一下使用Core Graphics绘图的步骤:

  1. 获取上下文(画布)
  2. 创建路径(自定义或者调用系统的API)并添加到上下文中。
  3. 进行绘图内容的设置(画笔颜色、粗细、填充区域颜色、阴影、连接点形状等)
  4. 开始绘图(CGContextDrawPath)
  5. 释放路径(CGPathRelease)
收起阅读 »

iOS - 绘图框架CoreGraphics分析

iOS
由于CoreGraphics框架有太多的API,对于初次接触或者对该框架不是十分了解的人,在绘图时,对API的选择会感到有些迷茫,甚至会觉得iOS的图形绘制有些繁琐。因此,本文主要介绍一下iOS的绘图方法和分析一下CoreGraphics框架的绘图原理。一、绘...
继续阅读 »

由于CoreGraphics框架有太多的API,对于初次接触或者对该框架不是十分了解的人,在绘图时,对API的选择会感到有些迷茫,甚至会觉得iOS的图形绘制有些繁琐。因此,本文主要介绍一下iOS的绘图方法和分析一下CoreGraphics框架的绘图原理。

一、绘图系统简介

iOS的绘图框架有多种,我们平常最常用的就是UIKit,其底层是依赖CoreGraphics实现的,而且绝大多数的图形界面也都是由UIKit完成,并且UIImage、NSString、UIBezierPath、UIColor等都知道如何绘制自己,也提供了一些方法来满足我们常用的绘图需求。除了UIKit,还有CoreGraphics、Core Animation,Core Image,OpenGL ES等多种框架,来满足不同的绘图要求。各个框架的大概介绍如下:

  • UIKit:最常用的视图框架,封装度最高,都是OC对象

  • CoreGraphics:主要绘图系统,常用于绘制自定义视图,纯C的API,使用Quartz2D做引擎

  • CoreAnimation:提供强大的2D和3D动画效果

  • CoreImage:给图片提供各种滤镜处理,比如高斯模糊、锐化等

  • OpenGL-ES:主要用于游戏绘制,但它是一套编程规范,具体由设备制造商实现

绘图系统


二、绘图方式

实际的绘图包括两部分:视图绘制视图布局,它们实现的功能是不同的,在理解这两个概念之前,需要了解一下什么是绘图周期,因为都是在绘图周期中进行绘制的。

绘图周期:

  • iOS在运行循环中会整合所有的绘图请求,并一次将它们绘制出来

  • 不能在子线程中绘制,也不能进行复杂的操作,否则会造成主线程卡顿

1.视图绘制

调用UIView的drawRect:方法进行绘制。如果调用一个视图的setNeedsDisplay方法,那么该视图就被标记为重新绘制,并且会在下一次绘制周期中重新绘制,自动调用drawRect:方法。

2.视图布局

调用UIView的layoutSubviews方法。如果调用一个视图的setNeedsLayout方法,那么该视图就被标记为需要重新布局,UIKit会自动调用layoutSubviews方法及其子视图的layoutSubviews方法。

在绘图时,我们应该尽量多使用布局,少使用绘制,是因为布局使用的是GPU,而绘制使用的是CPU。GPU对于图形处理有优势,而CPU要处理的事情较多,且不擅长处理图形,所以尽量使用GPU来处理图形。

三、绘图状态切换

iOS的绘图有多种对应的状态切换,比如:pop/push、save/restore、context/imageContext和CGPathRef/UIBezierPath等,下面分别进行介绍:

1.pop / push

设置绘图的上下文环境(context)

push:UIGraphicsPushContext(context)把context压入栈中,并把context设置为当前绘图上下文

pop:UIGraphicsPopContext将栈顶的上下文弹出,恢复先前的上下文,但是绘图状态不变

下面绘制的视图是黑色

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
UIGraphicsPushContext(UIGraphicsGetCurrentContext());
[[UIColor blackColor] setFill];
UIGraphicsPopContext();
UIRectFill(CGRectMake(90, 340, 100, 100)); // black color
}

2.save / restore

设置绘图的状态(state)

save:CGContextSaveGState 压栈当前的绘图状态,仅仅是绘图状态,不是绘图上下文

restore:恢复刚才保存的绘图状态

下面绘制的视图是红色

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
CGContextSaveGState(UIGraphicsGetCurrentContext());
[[UIColor blackColor] setFill];
CGContextRestoreGState(UIGraphicsGetCurrentContext());
UIRectFill(CGRectMake(90, 200, 100, 100)); // red color
}

3.context / imageContext

iOS的绘图必须在一个上下文中绘制,所以在绘图之前要获取一个上下文。如果是绘制图片,就需要获取一个图片的上下文;如果是绘制其它视图,就需要一个非图片上下文。对于上下文的理解,可以认为就是一张画布,然后在上面进行绘图操作。

context:图形上下文,可以通过UIGraphicsGetCurrentContext:获取当前视图的上下文

imageContext:图片上下文,可以通过UIGraphicsBeginImageContextWithOptions:获取一个图片上下文,然后绘制完成后,调用UIGraphicsGetImageFromCurrentImageContext获取绘制的图片,最后要记得关闭图片上下文UIGraphicsEndImageContext。

4.CGPathRef / UIBezierPath

图形的绘制需要绘制一个路径,然后再把路径渲染出来,而CGPathRef就是CoreGraphics框架中的路径绘制类,UIBezierPath是封装CGPathRef的面向OC的类,使用更加方便,但是一些高级特性还是不及CGPathRef。

四、具体绘图方法

由于iOS常用的绘图框架有UIKit和CoreGraphics两个,所以绘图的方法也有多种,下面介绍一下iOS的几种常用的绘图方法。

1.图片类型的上下文

图片上下文的绘制不需要在drawRect:方法中进行,在一个普通的OC方法中就可以绘制

使用UIKit实现

// 获取图片上下文
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
// 绘图
UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
// 从图片上下文中获取绘制的图片
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();

使用CoreGraphics实现

// 获取图片上下文
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
// 绘图
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
// 从图片上下文中获取绘制的图片
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();

2.drawRect:

在UIView子类的drawRect:方法中实现图形重新绘制,绘图步骤如下:

  • 获取上下文

  • 绘制图形

  • 渲染图形

UIKit方法

- (void) drawRect: (CGRect) rect {
UIBezierPath* p = [UIBezierPathbezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
}

CoreGraphics

- (void) drawRect: (CGRect) rect {
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
}

3.drawLayer:inContext:

在UIView子类的drawLayer:inContext:方法中也可以实现绘图任务,它是一个图层的代理方法,而为了能够调用该方法,需要给图层的delegate设置代理对象,其中代理对象不能是UIView对象,因为UIView对象已经是它内部根层(隐式层)的代理对象,再将它设置为另一个层的代理对象就会出问题。

一个view被添加到其它view上时,图层的变化如下:

  • 先隐式地把此view的layer的CALayerDelegate设置成此view

  • 调用此view的self.layer的drawInContext方法

  • 由于drawLayer方法的注释:If defined, called by the default implementation of -drawInContext:说明了drawInContext里if([self.delegate responseToSelector:@selector(drawLayer:inContext:)])就执行drawLayer:inContext:方法,这里我们因为实现了drawLayer:inContext:所以会执行

  • [super drawLayer:layer inContext:ctx]会让系统自动调用此view的drawRect:方法,至此self.layer画出来了

  • 在self.layer上再加一个子layer,当调用[layer setNeedsDisplay];时会自动调用此layer的drawInContext方法

  • 如果drawRect不重写,就不会调用其layer的drawInContext方法,也就不会调用drawLayer:inContext方法

调用内部根层的drawLayer:inContext:

//如果drawRect不重写,就不会调用其layer的drawInContext方法,也就不会调用drawLayer:inContext方法
-(void)drawRect:(CGRect)rect{
NSLog(@"2-drawRect:");
NSLog(@"drawRect里的CGContext:%@",UIGraphicsGetCurrentContext());
//得到的当前图形上下文正是drawLayer中传递过来的
[super drawRect:rect];
}
#pragma mark - CALayerDelegate
-(void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
NSLog(@"1-drawLayer:inContext:");
NSLog(@"drawLayer里的CGContext:%@",ctx);
// 如果去掉此句就不会执行drawRect!!!!!!!!
[super drawLayer:layer inContext:ctx];
}

调用外部代理对象的drawLayer:inContext:

由于不能把UIView对象设置为CALayerDelegate的代理,所以我们需要创建一个NSObject对象,然后实现drawLayer:inContext:方法,这样就可以在代理对象里绘制所需图形。另外,在设置代理时,不需要遵守CALayerDelegate的代理协议,即这个方法是NSObject的,不需要显式地指定协议。

// MyLayerDelegate.m
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
{
CGContextAddEllipseInRect(ctx, CGRectMake(100,100,100,100));
CGContextSetFillColorWithColor(ctx, [UIColor blueColor].CGColor);
CGContextFillPath(ctx);
}
// ViewController.m
@interface ViewController () @property (nonatomic, strong) id myLayerDelegate;
@end
@implementation ViewController
- (void)viewDidLoad {
// 设置layer的delegate为NSObject子类对象
_myLayerDelegate = [[MyLayerDelegate alloc] init];
MyView *myView = [[MyView alloc] initWithFrame:self.view.bounds];
[self.view addSubview:myView];
CALayer *layer = [CALayer layer];
layer.backgroundColor = [UIColor magentaColor].CGColor;
layer.bounds = CGRectMake(0, 0, 300, 500);
layer.anchorPoint = CGPointZero;
layer.delegate = _myLayerDelegate;
[layer setNeedsDisplay];
[myView.layer addSublayer:layer];
}

详细实现过程

当UIView需要显示时,它内部的层会准备好一个CGContextRef(图形上下文),然后调用delegate(这里就是UIView)的drawLayer:inContext:方法,并且传入已经准备好的CGContextRef对象。而UIView在drawLayer:inContext:方法中又会调用自己的drawRect:方法。平时在drawRect:中通过UIGraphicsGetCurrentContext()获取的就是由层传入的CGContextRef对象,在drawRect:中完成的所有绘图都会填入层的CGContextRef中,然后被拷贝至屏幕。

iOS绘图框架分析如上,如有不足之处,欢迎指出,共同进步。(本文图片来自互联网,版权归原作者所有)

收起阅读 »

2021年,跨端是否已成趋势?Android 开发还有必要学 Flutter 吗?

由于手机APP的运行受不同操作系统的限制,目前大多数的移动APP应用开发仍然需要针对不同的系统环境进行单独的开发。不过,为了降低开发成本、提高代码复用率,减少开发者对多个平台差异适配的工作量一直是跨平台开发框架追求的目标。 但是目前,很多开发者还不不确定应该选...
继续阅读 »

由于手机APP的运行受不同操作系统的限制,目前大多数的移动APP应用开发仍然需要针对不同的系统环境进行单独的开发。不过,为了降低开发成本、提高代码复用率,减少开发者对多个平台差异适配的工作量一直是跨平台开发框架追求的目标。


但是目前,很多开发者还不不确定应该选择哪种技术来快速且低成本的开发应用程序,不过如果你熟知跨平台的发展历史,那么2021年可供大家选择的跨平台方案主选项只有两个:Flutter或者React Native



在正式进行对比之前,首先需要明确一点,即Flutter和React Native这两个框架都是构建跨平台移动应用程序的优质框架,但有时做出正确的决定取决于业务使用的角度。因此,我们选取了九个重要的参数,用于两者的比较:



  • 由谁提供技术支持?

  • 框架的市场份额占比。

  • Dart Vs JavaScript

  • 技术架构

  • 性能

  • 是否对开发者友好,便利性和社区支持

  • UI组件和定制

  • 代码的可维护性

  • 开发者的工作成本


技术支持:谷歌 VS Facebook


Flutter与React Native两大框架背后都站着科技巨头,分别是谷歌和Facebook,所以从这个角度来看两者未来会在竞争中变得更加完善,毕竟他们背后都自己的利益链。


首先,我们来看一下Flutter,Flutter是2017年由谷歌正式推出,是一个先进的应用程序软件开发工具包(SDK),包括所有的小部件和工具,理论上可以让开发者的开发过程更容易和更简单。广泛的小工具选择使开发人员能够以一种简单的方式建立和部署视觉上有吸引力的、原生编译的应用程序,用于多个平台,包括移动、网络和桌面,都使用单一的代码库。因此,Flutter应用程序开发公司有更好的机会,可以确保你更快、更快、更可靠的应用程序开发解决方案。


事实上,Flutter早再2015年Dart开发者峰会上便以“Sky”的身份亮相,Flutter具有几大买点:首先它是免费的,而且是开源的;其次,该架构基于流行的反应式编程,因为它遵循与Reactive相同的风格;最后,归功于小部件体验,Flutter应用程序有一个令人愉快的UI,整体来说转化为应用程序看起来和感觉都不错。


我们再来看一下React Native,React Native也是Facebook在2015年推出的一个跨平台原生移动应用开发框架。React Native主要使用的是JavaScript开发语言,对于使用同一代码库为iOS和Android开发应用程序来说非常方便。此外,它的代码共享功能可以更快的开发和减少开发时间。像其他跨平台技术一样,Flutter允许开发者使用相同的代码库来构建独立的应用程序,因此,相比原生应用程序更容易维护。


当然,Flutter和React Native都支持热重载功能,允许开发者直接在运行中的应用程序中添加或纠正代码,而不必保存应用程序,从而加速了开发过程。除此之外,React Native是基于一种非常流行的语言--JavaScript,开发者更易上手;React组件包裹着现有的本地代码,并通过React的声明性UI范式和JavaScript与本地API进行交互,React Native的这些特点使开发人员的工作速度大大加快。


市场份额:五五开的格局正在改变


整体上来说,这两者的市场份额是十分相近的,但Flutter在最近有后来居上之势。2019年和2020年全球软件开发公司使用的最佳跨平台移动应用开发框架时,其结果是42%的开发者更愿意留在React Native,而39%的开发者选择了Flutter。根据StackOverFlow的数据,68.8%的开发者喜欢使用Flutter进行进一步的开发项目,而57.9%的开发者对使用React Native技术进行应用开发进一步表现出兴趣。


不同的市场报告有不同的统计数字,Flutter、React Native究竟孰强孰弱或许只能从一些市场趋势中窥见一二:




  • 市场趋势一:谷歌Google Trends的统计数字显示,在过去12个月的分析中,Flutter的搜索指数已反超React Native。




  • 市场趋势二:更年轻的Flutter在Github上拥有16.8万名成员和11.8万颗星的社区,而更成熟的React Native在Github仅有20.7万名成员和9.46万颗星。


    image.png




  • 趋势三:根据Statista的数据,React Native以42%的市场份额力压Flutter,但Flutter与React Native的差距正变得越来越小,其在一年内市场份额从30%急剧跃升至39%。




image.png


语言对比:Dart Vs JavaScript


Flutter所采用的Dart开发语言是谷歌2011年在丹麦奥尔胡斯举行的GOTO大会上亮相的,Dart是一门面向对象的、类定义的、单继承的语言,它的语法类似C语言,可以转译为JavaScript,支持接口(interfaces)、混入(mixins)、抽象类(abstract classes)、具体化泛型(reified generics)、可选类型(optional typing)和sound type system,并且具有AOT与JIT编译器,Dart的最大优势在于速度,运行比JavaScript快2倍,不过Dart作为一门较新的语言,开发者还需要熟悉Java或C++的应用程序开发工作才更易上手。


而React Native则采用的为已经在IT行业广泛应用多年的Javascript语言,类似于HTML的JSX,以及CSS来开发移动应用,因此熟悉Web前端开发的技术人员只需很少的学习就可以进入移动应用开发领域,不过JavaScript线程需要时间来初始化,所以导致React Native在最初渲染之前需要花费大量时间来初始化运行,不过React Native已经发布了升级线路,并且会在最近开源升级的版本,相信随着React Native新版本的发布,性能上将会追平Flutter。


技术架构


如果单从技术上讲,Flutter绝对是一个先进的跨平台技术方案,它提供了一个分层的架构,以确保高度的定制化,而React Native依赖于其他软件来构建反应组件,并使用JavaScriptBridge来桥接原生本地模块的连接。桥接会影响性能,即使发生轻微的变化,而Flutter可以在没有桥接的情况下管理一切。


Flutter提供的分层的架构,为简单快速的UI定制铺平了道路。它被认为可以让你完全控制屏幕上的每一个像素,并允许移动应用开发公司整合叠加和动画图形、文本、视频和控件,没有任何限制。


Flutter移动平台与其他Web平台的架构略有差异,不同平台相同的公共部分就是Dart部分,即Dart Framework。Flutter的公共部分主要实现了两个逻辑:第一,开发人员可以通过Flutter Ui系统编写UI,第二使用Dart虚拟机及Dart语言可以编写跟平台资源无关的逻辑。同时这也是Flutter跨平台的核心,和Java程序可以在Linux,Window,MacOs同时运行, Web程序可以在任意平台运行类似。通过Dart虚拟机,UI及和系统无光的逻辑都可以用Dart语言编写,运行在Dart虚拟机中,是跨平台的。


而React Native依赖于其他软件来构建反应组件,其架构整体上分为三大块:Native、JavaScript 与 Bridge,其中Native 管理UI 更新及交互,JavaScript 调用 Native 能力实现业务功能,Bridge 在二者之间传递消息。React Native 中主要有 3 个线程,应用中的主线程UI Thread、进行布局计算和构造 UI 界面的线程Shadow Thread与React 等 JavaScript 代码都在这个线程执行任务的JS Thread。


正因其依赖于其他软件来构建反应组件,因此在启动上会受到以下,必须先初始化 React Native 运行时环境(即Bridge),Bridge 准备好之后开始 run JS,最后开始 Native 渲染。从架构上来看,Flutter确实性能更高,也更符合当下跨平台开发的需求。


image.png


学习成本和社区支持


当涉及到构建企业应用程序时,社区支持是必须检查的因素。而React Native和Flutter都在行业中发展了多年,并且在谷歌与Facebook两大巨头的支持下都有最新的技术更新与广泛的社区支持。而随着每一个递增的版本和技术更新,社区对该框架的兴趣和需求逐渐增加。让我们了解一下这两个框架在社区参与方面的情况。


React Native在2015年推出,其社区一直处于成长阶段,Github上对该框架的贡献者数量就是证明。但是,尽管Flutter还很年轻,也比较新,但它正在已开始显示后来居上之势。


image.png


代码的可维护性


无论你开发的应用程序多么出色,为了使其顺利运行,不断地升级和调试是必要的。与Flutter相比,用React Native维护代码真的很困难。


在React Native中,当你为了开发适配不同系统的应用程序时就需要分开编写适配代码,它会干扰框架的逻辑,从而减慢了开发过程。另外,在React Native应用程序中,大多数本地组件都有一个第三方库的依赖性,所以维护这些过时的库确实是一个具有挑战性的任务。


对于Flutter来说,由于代码逻辑相对简单,不需要适配不同的操作系统,维护代码就要容易得多,允许移动应用程序开发人员轻松发现问题,为外部工具和支持第三方库提供数据支撑。


此外,与使用React Native的热重新加载功能相比,在Flutter中发布质量更新和对应用程序进行即时更改所花费的时间也比React Native表现更好。


开发成本


无论是一个初创公司还是一个先进的互联网企业,开发成本总是大家比较关心的内容。因此,当你选择雇用反应原生开发公司或Flutter应用程序工程师时,你可能需要评估他们的费率,不同的地方有不同的开发成本。


因此,在正式启动项目之前,无论是Flutter还是React Native,都需要考虑开发人员的素质,如经验、专业知识、项目处理等开发成本问题,以评估开发人员的实际小时费用,下面是Flutter和React Native的一个开发成本的问题。


image.png


除此之外,在选择Flutter还是React Native的问题上,我们还需要考虑他们的自定义开发能力。
Flutter和React Native都有一套属于自己的UI组件和小工具。并且,Flutter就以其漂亮的UI原生型小部件而闻名,这些小部件由框架的图形引擎进行渲染和管理。


而React Native只提供了适应平台的基本工具,如按钮、滑块、加载指示灯等基础组件,如果需要开发复杂的功能,就需要使用第三方组组件。


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

当 Adapter 遇上 Kotlin DSL,无比简单的调用方式

早在去年的时候我就提到过使用工厂的方式获取 Adapter 而不是为每个 Adapter 定义一个类文件。这样的好处是,对于不是那么复杂的 Adapter 可以节省大量的代码,提升开发效率和解放双手,同时更好的支持多类型布局效果。 1、Kotlin DSL 和...
继续阅读 »

早在去年的时候我就提到过使用工厂的方式获取 Adapter 而不是为每个 Adapter 定义一个类文件。这样的好处是,对于不是那么复杂的 Adapter 可以节省大量的代码,提升开发效率和解放双手,同时更好的支持多类型布局效果。


1、Kotlin DSL 和 Adapter 工厂方法


可以把 Kotlin DSL 当作构建者使用。这里有一篇不错的文章,想了解的可以阅读下,



http://www.ximedes.com/2020-04-21/…



Kotlin DSL 是拓展函数的延申,比如我们常用的 with 等函数就是函数的拓展,


public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}

这里是泛型 T 的拓展。这里的 T 可以类比到 Java 构建者模式中的 Builder,通过方法接收外部参数之后调用 build() 方法创建一个最终的对象即可。


对于 Adapter 工厂方法,之前我是通过如下方式使用的,


fun <T> getAdapter(
@LayoutRes itemLayout:Int,
converter: (helper: BaseViewHolder, item: T) -> Unit,
data: List<T>
): Adapter<T> = Adapter(itemLayout, converter, data)

class Adapter<T>(
@LayoutRes private val layout: Int,
private val converter: (helper: BaseViewHolder, item: T) -> Unit,
val list: List<T>
): BaseQuickAdapter<T, BaseViewHolder>(layout, list) {
override fun convert(helper: BaseViewHolder, item: T) {
converter(helper, item)
}
}

也就是每次想要得到 Adapter 的时候只要调用 getAdapter() 方法即可。这种封装方式比较简陋,支持的功能有限。后来慢慢采用了 Kotlin DSL 之后,我封装了 Kotlin DSL 风格的工厂方法。采用 Kotlin DSL 风格之后更加优雅和方便快捷,同时更好的支持多类型布局效果。


2、使用


2.1 引入依赖


首先,该项目依赖于 BRVAH,所以,你需要引入该库之后才可以使用。BRVAH 可以说是目前开源的最好用的 Adapter,我们没必要再另起炉灶自己再造轮子。这个框架设计最好地方在于通过 SpareArray 收集了 ViewHolder 控件,从而避免了自定义 ViewHolder,这是我们框架设计的基础思想。


该项目已经上传到了 MavenCentral,你需要先在项目中引入该仓库,


allprojects {
repositories {
mavenCentral()
}
}

然后在项目中添加如下依赖,


implementation "com.github.Shouheng88:xadapter:${latest_version}"

2.2 使用 Adapter 工厂方法


使用 xAdapter 之后,当你需要定义一个 Adapter 的时候,你无需单独创建一个类文件,只需要通过 createAdapter() 方法获取一个 Adapter,


adapter = createAdapter {
withType(Item::class.java, R.layout.item_eyepetizer_home) {
// Bind data with viewholder.
onBind { helper, item ->
helper.setText(R.id.tv_title, item.data.title)
helper.setText(R.id.tv_sub_title, item.data.author?.name + " | " + item.data.category)
helper.loadCover(requireContext(), R.id.iv_cover, item.data.cover?.homepage, R.drawable.recommend_summary_card_bg_unlike)
helper.loadRoundImage(requireContext(), R.id.iv_author, item.data.author?.icon, R.mipmap.eyepetizer, 20f.dp2px())
}
// Item level click and long click events.
onItemClick { _, _, position ->
adapter?.getItem(position)?.let {
toast("Clicked item: " + it.data.title)
}
}
}
}

在这种新的调用方式中,你需要通过 withType() 方法指定数据类型及其对应的布局文件,然后在 onBind() 方法中即可实现数据到 ViewHolder 的绑定操作。这里的 onBind() 方法的使用与 BRVAH 中的 convert() 方法使用一致,可以通过阅读该库了解如何使用。总之,xAapter 在 BRVAH 的基础上做了二次封装,可以说,比简单更简单。


xAdapter 支持为每个 ViewHolder 绑定点击和长按事件,同时也支持为 ViewHolder 上的某个单独的 View 添加点击和长按事件。使用方式如上所示,只需要添加 onItemClick() 方法并实现自己的逻辑即可。其他的点击事件可以参考项目的示例代码。


效果,





2.3 使用多类型 Adapter


多类型 Adapter 的使用方式非常简单,类似于上面的调用方式,只需要在 createAdapter() 内再添加一个 withType() 方法即可。下面是一个写起来可能相当复杂的 Adapter,但是采用了 xAdpater 的调用方式之后,一切变得非常简单,


private fun createAdapter() {
adapter = createAdapter {
withType(MultiTypeDataGridStyle::class.java, R.layout.item_list) {
onBind { helper, item ->
val rv = helper.getView<RecyclerView>(R.id.rv)
rv.layoutManager = GridLayoutManager(context, 3)
val adapter = createSubAdapter(R.layout.item_home_page_data_module_1, 1)
rv.adapter = adapter
adapter.setNewData(item.items)
}
}
withType(MultiTypeDataListStyle1::class.java, R.layout.item_home_page_data_module_2) {
onBind { helper, item ->
converter.invoke(helper, item)
}
onItemClick { _, _, position ->
(adapter?.getItem(position) as? MultiTypeDataListStyle1)?.let {
toast("Clicked style[2] item: " + it.item.data.title)
}
}
}
withType(MultiTypeDataListStyle2::class.java, R.layout.item_list) {
onBind { helper, item ->
val rv = helper.getView<RecyclerView>(R.id.rv)
rv.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = createSubAdapter(R.layout.item_home_page_data_module_4, 3)
rv.adapter = adapter
adapter.setNewData(item.items)
}
}
withType(MultiTypeDataListStyle3::class.java, R.layout.item_home_page_data_module_3) {
onBind { helper, item ->
converter.invoke(helper, item)
}
onItemClick { _, _, position ->
(adapter?.getItem(position) as? MultiTypeDataListStyle3)?.let {
toast("Clicked style[4] item: " + it.item.data.title)
}
}
}
}
}

xAdapter 对多类型布局方式的支持是在 BRVAH 之上进行的改造,在这种封装方式中,数据类无需实现任何类和接口。Adpater 内部通过 Class 区分各个 ViewHolder.


效果,





总结


相对于为各种类型的数据定义 Adapter 的使用方式,以上封装方式的优势是:



  1. 借助 BRVAH 的优势,封装了大量的方法,进一步简化了 Adapter 的使用;

  2. 通过工厂和 DSL 封装,简化了调用 Adapter 的方式,你无需为数据类型定义 Adapter 文件,减少了项目中需要维护的代码和类文件数量;

  3. 通过以上封装,使用 Adapter 更加简洁,节省了大量的代码,提升开发效率和解放双手;

  4. 自由地在单一类型布局和多类型布局之间进行切换,但是少了没必要的工厂方法。


当有更加简洁的使用方式的时候,继续采用复杂的调用方式无异于抱残守缺,对于程序员而言,做这种重复而没有太大价值的工作,付出再多的汗水都不值得同情。以上是部分功能和代码的展示,可以通过阅读源码了解更多。后续我参考其他优秀的库的设计思想,支持更多 Adapter 特性的封装来实现快速调用。


项目已开源,感兴趣的可以直接阅读项目源码,源码地址:github.com/Shouheng88/…


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

Hook AMS + APT实现集中式登录框架

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:[juejin.cn/post/700695…) 1, 背景 登录功能是App开发中一个很常见的功能,一般存在两种登录方式: 一种是进入应用...
继续阅读 »

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:[juejin.cn/post/700695…)


1, 背景


登录功能是App开发中一个很常见的功能,一般存在两种登录方式:




  • 一种是进入应用就必须先登录才能使用(如聊天类软件)




  • 另一种是以游客身份使用,需要登录的时候才会去登录(如商城类软件)




针对第二种的登录方式,一般都是在要跳转到需要登录才能访问的页面(以下简称目标页面)时通过if-else判断是否已登录,未登录则跳转到登录界面,登录成功后退回到原界面,用户继续进行操作。伪代码如下:


if (需要登录) {
// 跳转到登录页面
} else {
// 跳转到目标页面
}

这中方式存在着以下几方面问题:



  1. 当项目功能逐渐庞大以后,存在大量重复的用于判断登录的代码,且判断逻辑可能分布在不同模块,维护成本很高。

  2. 增加或删除目标页面时需要修改判断逻辑,存在耦合。

  3. 跳转到登录页面,登录成功后只能退回到原界面,用户原本的意图被打断,需要再次点击才能进入目标界面(如:用户在个人中心界面点击“我的订单”按钮想要跳转到订单界面,由于没有登录就跳转到了登录界面,登录成功后返回个人中心界面,用户需要再次点击“我的订单”按钮才能进入订单界面)。


大致流程如下图所示:


login.png


针对传统登录方案存在的问题本文提出了一种通过Hook AMS + APT实现集中式登录方案。




  1. 首先通过Hook AMS实现集中处理判断,实现了跟业务逻辑解耦。




  2. 通过注解标记需要登录的页面,然后通过APT生成需要登录页面的集合,便于Hook中的判断。




  3. 最后在Hook AMS时将原意图放入登录页面的意图中,登录页面登录成功后可以获取到原意图,实现了继续用户原意图的目的。




本方案能达到的业务流程如下:


hook_login.png


1, 集中处理


这里借鉴插件化的思路通过Hook AMS实现拦截并统一处理的目的


1.1 分析Activity启动过程

了解Activity启动过程的应该都知道Activity中的startActivity()最终会进入Instrumentation


// Activity.java
@Override
public void startActivityForResult(
String who, Intent intent, int requestCode, @Nullable Bundle options) {
...
Instrumentation.ActivityResult ar =
mInstrumentation.execStartActivity(
this, mMainThread.getApplicationThread(), mToken, who,
intent, requestCode, options);
...
}

InstrumentationexecStartActivity代码如下:


public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, String target,
Intent intent, int requestCode, Bundle options) {
...
try {
...
int result = ActivityManagerNative.getDefault()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target, requestCode, 0, null, options);
checkStartActivityResult(result, intent);
} catch (RemoteException e) {
throw new RuntimeException("Failure from system", e);
}
return null;
}

其中调用了ActivityManagerNative.getDefault()startActivity(),那么此处getDefault()获取到的是什么?接着看代码:


/**
* Retrieve the system's default/global activity manager.
*/
static public IActivityManager getDefault() {
// step 1
return gDefault.get();
}

// step 2
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
// step 5
IBinder b = ServiceManager.getService("activity");
if (false) {
Log.v("ActivityManager", "default service binder = " + b);
}
IActivityManager am = asInterface(b);
if (false) {
Log.v("ActivityManager", "default service = " + am);
}
return am;
}
};

public abstract class Singleton<T> {
private T mInstance;

protected abstract T create();

// step 3
public final T get() {
synchronized (this) {
if (mInstance == null) {
// step 4
mInstance = create();
}
return mInstance;
}
}
}

gDefault是一个Singleton<IActivityManager>类型的静态常量,它的get()方法返回的是Singleton类中的private T mInstance;,这个mInstance的创建又是在gDefault实例化时通过create()方法实现。


这里代码有点绕,根据上面代码注释的step1 ~ 5,应该能理清楚:gDefault.get()获取到的mInstance实例就是ActivityManagerService(AMS)实例。


由于gDefault是一个静态常量,因此可以通过反射获取到它的实例,同时它是Singleton类型的,因此可以获取到其中的mInstance


到这里你应该能明白接下来要干什么了吧,没错就是Hook AMS。


1.2 Hook AMS


本文以android 6.0代码为例。注:8.0以下实现方式是相同的,8.0和9.0实现相同,10.0到12.0方式是一样的。


这里涉及到反射及动态代理的姿势,请自行了解。


1,获取gDefault实例


Class<?> activityManagerNative = Class.forName("android.app.ActivityManagerNative");
Field singletonField = activityManagerNative.getDeclaredField("gDefault");
singletonField.setAccessible(true);
// 获取gDefault实例
Object singleton = singletonField.get(null);

2,获取Singleton中的mInstance


Class<?> singletonClass = Class.forName("android.util.Singleton");
Field mInstanceField = singletonClass.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
/* Object mInstance = mInstanceField.get(singleton); */
Method getMethod = singletonClass.getDeclaredMethod("get");
Object mInstance = getMethod.invoke(singleton);

这里本可以直接通过mInstanceField及第一步中获取的gDefault实例反射得到mInstance实例,但是实测发现在Android 10以上无法获取,不过还好可以通过Singleton中的get()方法可以获取到其实例。


3,获取要动态代理的Interface


Class<?> iActivityManagerClass = Class.forName("android.app.IActivityManager");

4,创建一个代理对象


Object proxyInstance = Proxy.newProxyInstance(context.getClassLoader(), new Class[]{iActivityManagerClass},
(proxy, method, args) -> {
if (method.getName().equals("startActivity") && !isLogin()) {
// 拦截逻辑
}
return method.invoke(mInstance, args);
});

5,用代理对象替换原mInstance对象


mInstanceField.set(singleton, proxyInstance);

6,兼容性


针对8.0以下,8.0到9.0,10.0到12.0进行适配,可以兼容各个系统版本。


至此已经实现了对AMS的Hook,只需要在代理中判断当前要启动的Activity是否需要登录,然后跳转到登录即可。


但是此时出现了一个问题,这里如何判断哪些Activity需要登录的?最简单的方式就是写死,如下:


// 获取要启动的Activity的全类名。
String intentName = xxx
if (intentName.equals("aaaActivity")
|| intentName.equals("bbbActivity")
...
|| intentName.equals("xxxActivity")){
// 去登陆
}

这样的代码存在着耦合,添加删除目标Activity都需要改这里。


接下来就是通过APT实现解耦的方案。


2, APT实现解耦


APT就不多说了,就是注解处理器,很多流行框架都在用它,如果你不了解请自行了解。


首先定义注解,然后给目标Activity加上注解就相当于打了个标记,接着通过APT找到打了这些标记的Activity,将其全类名保存起来,最后在需要使用的地方通过反射调用即可。


2.1,定义注解


// 目标页面注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface RequireLogin {
// 需要登录的Activity加上该注解
}

// 登录页面注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginActivity {
// 给登录页面加上该注解,方便在Hook中直接调用
}

// 判断是否登录方法的注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface JudgeLogin {
// 给判断是否登录的方法添加注解,需要是静态方法。
}

2.2,注解处理器


这里就不贴代码了,重点是思路:


1,获取所有添加了RequireLogin注解的Activity,存入一个集合中


2,通过JavaPoet创建一个Class


3,在其中添加方法,返回1中集合里Activity的全类名的List


最终通过APT生成的类文件如下:


package me.wsj.login.apt;

public class AndLoginUtils {
// 需要登录的Activity的全类名集合
public static List<String> getNeedLoginList() {
List<String> result = new ArrayList<>();
result.add("me.wsj.andlogin.activity.TargetActivity1");
result.add("me.wsj.andlogin.activity.TargetActivity2");
return result;
}

// 登录Activity的全类名
public static String getLoginActivity() {
return "me.wsj.andlogin.activity.LoginActivity";
}

// 判断是否登录的方法全类名
public static String getJudgeLoginMethod() {
return "me.wsj.andlogin.activity.LoginActivity#checkLogin";
}
}

2.3,反射调用


在动态代理的InvocationHandler中通过反射获取


new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目标Activity全类名
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 该Activity需要登录,跳转到登录页面
}
}
return null;
}
}

/**
* 该activity是否需要登录
*
* @param activityName
* @return
*/
private static boolean isRequireLogin(String activityName) {
if (requireLoginNames.size() == 0) {
// 反射调用apt生成的方法
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getNeedLoginListMethod = NeedLoginClazz.getDeclaredMethod("getRequireLoginList");
getNeedLoginListMethod.setAccessible(true);
requireLoginNames.addAll((List<String>) getNeedLoginListMethod.invoke(null));
Log.d("HootUtil", "size" + requireLoginNames.size());
} catch (Exception e) {
e.printStackTrace();
}
}
return requireLoginNames.contains(activityName);
}

2.4,其他


实现了判断目标页面的解耦,同样的方式也可以实现跳转登录及判断是否登录的解耦。


1,跳转登录页面


前面定义了LoginActivity()注解,APT也生成了getLoginActivity()方法,那就可以反射获取到配置的登录Activity,然后创建新的Intent,替换掉原Intent,进而实现跳转到登录页面。


if (需要跳转到登录) {
Intent intent = new Intent(context, getLoginActivity());
// 然后需要将该intent替换掉原intent接口
}

/**
* 获取登录activity
*
* @return
*/
private static Class<?> getLoginActivity() {
if (loginActivityClazz == null) {
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getLoginActivityMethod = NeedLoginClazz.getDeclaredMethod("getLoginActivity");
getLoginActivityMethod.setAccessible(true);
String loginActivity = (String) getLoginActivityMethod.invoke(null);
loginActivityClazz = Class.forName(loginActivity);
} catch (Exception e) {
e.printStackTrace();
}
}
return loginActivityClazz;
}

2,判断是否登录


同理为了实现对判断是否登录的解耦,在判断是否能登录的方法上添加一个JudgeLogin注解,就可以在Hook中反射调用判断。当然这里也可以通过添加回调的方式实现。


2.5,小结


通过APT实现了对判断是否登录、判断哪些页面需要登录及跳转登录的解耦。


此时面临着最后一个问题,虽然前面已经实现了拦截并跳转到了登录页面,但是登录完成后再返回到原页面看似合理,实则不XXXX(词穷了,自行脑补😂),用户的意图被打断了。


接着就看看如何在登录成功后继续用户意图。


3, 继续用户意图


由于Intent实现了Parcelable接口,因此可以将它作为一个Intent的Extra参数传递。在Hook过程中可以获取原始Intent,因此只需在Hook中将用户的原始意图Intent作为一个附加参数存入跳转登录的Intent中,然后在登录页面获取到这个参数,登录成功后跳转到这个原始Intent即可。


1,传递原始意图


在动态代理中先拿到原始Intent,然后将它作为参数存入新的Intent中


new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目标Activity全类名
Intent originIntent = xxx;
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 该Activity需要登录,跳转到登录页面
Intent intent = new Intent(context, getLoginActivity());
intent.putExtra(Constant.Hook_AMS_EXTRA_NAME, originIntent);
// 然后替换原Intent
...
}
}
return null;
}
}

2,获取原始意图并跳转


在登录页面,登录成功后判断其intent中是否有特定键值的附加数据,如果有则直接用它作为意图启动新页面,实现了继续用户意图的目的;


@LoginActivity
class LoginActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

...
binding.btnLogin.setOnClickListener {
// 登录成功了
var targetIntent = intent.getParcelableExtra<Intent>(AndLogin.TARGET_ACTIVITY_NAME)
// 如果存在targetIntent则启动目标intent
if (targetIntent != null) {
startActivity(targetIntent)
}
finish()
}
}

companion object {
// 该方法用于返回是否登录
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

如上所示,如果可以在当前Intent中获取到Hook时保存的数据,则说明存在目标Intent,只需将其启动即可。


看一下最终效果:


preview.gif


4, ARouter方案


熟悉ARouter的都知道,它有一个拦截器的东西,可以在跳转前做拦截操作。如下:


@Interceptor(name = "login", priority = 1)
public class LoginInterceptorImpl implements IInterceptor {
@Override
public void process(Postcard postcard, InterceptorCallback callback) {
...
if (isLogin) { // 已经登录不拦截
callback.onContinue(postcard);
} else { // 未登录则拦截
// callback.onInterrupt(null);
}
}

@Override
public void init(Context context) {
}
}

实现IInterceptor接口并添加Interceptor注解即可在路由跳转时实现拦截。


了解其原理的话可知:ARouter也只是在启动Activity前提供了拦截判断的时机,相当于本方案的第一步(Hook AMS)操作,后续实现解耦以及继续用户意图操作还需要自己实现。


5, 总结


本文提出了一种通过Hook AMS + APT实现集中式登录的方案,对比传统方式本方案存在以下优势:




  1. 以非侵入性的方式将分散的登录判断逻辑集中处理,减少了代码量,提高了开发效率。




  2. 增加或删除目标页面时无需修改判断逻辑,只需增加或删除其对应注解即可,符合开闭原则,降低了耦合度




  3. 在用户登录成功后直接跳转到目标界面,保证了用户操作不被中断。




本方案并没有太高深的东西,只是把常用的东西整合在一起,综合运用了一下。另外方案只是针对需要跳转页面的情况,对于判断是否登录后做其他操作的,比如弹出一个Toast这样的操作,可以通过AspectJ等来实现。


项目地址:github.com/wdsqjq/AndL…


最后,本方案提供了远程依赖,使用startup实现了无侵入初始化,使用方式如下:


1,添加依赖


allprojects {
repositories {
maven { url 'https://www.jitpack.io' }
}
}


dependencies {
implementation 'com.github.wdsqjq.AndLogin:lib:1.0.0'
kapt 'com.github.wdsqjq.AndLogin:apt_processor:1.0.0'
}

2,给需要登录的Activity添加注解


@RequireLogin
class TargetActivity1 : AppCompatActivity() {
...
}

@RequireLogin
class TargetActivity2 : AppCompatActivity() {
...
}


3,给登录Activity添加注解


@LoginActivity
class LoginActivity : AppCompatActivity() {
...
}

4,提供判断是否登录的方法


需要是一个静态方法


@LoginActivity
class LoginActivity : AppCompatActivity() {

companion object {
// 该方法用于返回是否登录
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

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

一文彻底搞懂js中的位置计算

引言 文章中涉及到的api列表:scroll相关Apiclient相关Apioffset相关ApiElement.getBoundingClientRectAPiWindow.getComputedStyleApi 我们会结合api定义,知名开源库中的应用场...
继续阅读 »

引言


文章中涉及到的api列表:

scroll相关Api

client相关Api

offset相关Api

Element.getBoundingClientRectAPi

Window.getComputedStyleApi



我们会结合api定义,知名开源库中的应用场景来逐层分析这些api。足以应对工作中关于元素位置计算的大部分场景。




注意在使用位置计算api时要格外的小心,不合理的使用他们可能会造成布局抖动Layout Thrashing影响页面渲染。



scroll


首先我们先来看看scroll相关的属性和方法。


Element.scroll()


Element.scroll()方法是用于在给定的元素中滚动到某个特定坐标的Element 接口。


element.scroll(x-coord, y-coord)
element.scroll(options)


  • x-coord 是指在元素左上方区域横轴方向上想要显示的像素。

  • y-coord 是指在元素左上方区域纵轴方向上想要显示的像素。


也就是element.scroll(x,y)会将元素滚动条位置滚动到对应x,y的位置。


同时也支持element.scroll(options)方式调用,支持传入额外的配置:


{
left: number,
top: number,
behavior: 'smooth' | 'auto' // 平滑滚动还是默认直接滚动
}

Element.scrollHeight/scrollWidth



  • Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。



scrollHeight 的值等于该元素在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度。 没有垂直滚动条的情况下,scrollHeight值与元素视图填充所有内容所需要的最小值clientHeight相同。包括元素的padding,但不包括元素的border和margin。scrollHeight也包括 ::before 和 ::after这样的伪元素。



换句话说Element.scrollHeight在元素不存在滚动条的情况下是恒等于clientHeight的。


但是如果出现了滚动条的话scrollHeight指的是包含元素不可以见内容的高度,出现滚动条的情况下是scrollHeight恒大于clientHeight



  • Element.scrollWidth 这也是一个元素内容宽度的只读属性,包含由于溢出导致视图中不可以见的内容。



原理上和scrollHeight是同理的,只不过这里是宽度而非高度。



简单来说一个元素如果不存在滚动条,那么他们的scrollclient都是相等的值。如果存在了滚动条,client只会计算出当前元素展示出来的高度/宽度,而scroll不仅仅会计算当前元素展示出的,还会包含当前元素的滚动条隐藏内容的高度/宽度。


clientWidth/height + [滚动条被隐藏内容宽度/高度] = scrollWidth/Height


Element.scrollLeft/scrollTop




  • Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数.




  • Element.scrollLeft 属性可以读取或设置元素滚动条到元素左边的距离.





需要额外注意的是: 注意如果这个元素的内容排列方向(direction) 是rtl (right-to-left) ,那么滚动条会位于最右侧(内容开始处),并且scrollLeft值为0。此时,当你从右到左拖动滚动条时,scrollLeft会从0变为负数。



scrollLeft/Top在日常工作中是比较频繁使用关于操作滚动条的相关api,他们是一个可以设置的值。根据不同的值对应可以控制滚动条的位置。


其实这两个属性和上方的Element.scroll()可以达到相同的效果。



在实际工作中如果对于滚动操作有很频繁的需求,个人建议去使用better-scroll,它是一个移动/web端的通用js滚动库,内部是基于元素transform去操作的滚动并不会触发相关重塑/回流。



判断当前元素是否存在滚动条



出现滚动条便意味着元素空间将大于其内容显示区域,根据这个现象便可以得到判断是否出现滚动条的规则。



export const hasScrolled = (element, direction) => {
if (!element || element.nodeType !== 1) return;
if (direction === "vertical") {
return element.scrollHeight > element.clientHeight;
} else if (direction === "horizontal") {
return element.scrollWidth > element.clientWidth;
}
};

判断用户是否滚动到底部



本质上就是当元素出现滚动条时,判断当前元素出现的高度 + 滚动条高度 = 元素本身的高度(包含隐藏部分)



element.scrollHeight - element.scrollTop === element.clientHeight

client


MouseEvent.clientX/Y


MounseEvent.clientX/Y同样也是只读属性,它提供事件发生时的应用客户端区域的水平坐标。



例如,不论页面是否有垂直/水平滚动,当你点击客户端区域的左上角时,鼠标事件的 clientX/Y 值都将为 0 。



其实MouseEvent.clientX/Y也就是相对于当前视口(浏览器可视区)进行位置计算。


转载一张非常直白的图:


clientX


Element.clientHeight/clientWidth


Element.clientWidth/clinetHeight 属性表示元素的内部宽度,以像素计。该属性包括内边距 padding,但不包括边框 border、外边距 margin 和垂直滚动条(如果有的话)。



内联元素以及没有 CSS 样式的元素的 clientWidth 属性值为 0。



在不出现滚动条时候Element.clientWidth/Height === Element.scrollWidth/Height


image.png


Element.clientTop/clientLeft


Element.clientLeft表示一个元素的左边框的宽度,以像素表示。如果元素的文本方向是从右向左(RTL, right-to-left),并且由于内容溢出导致左边出现了一个垂直滚动条,则该属性包括滚动条的宽度。clientLeft 不包括左外边距和左内边距。clientLeft 是只读的。


同样的Element.clientTop表示元素上边框的宽度,也是一个只读属性。



这两个属性日常使用会比较少,但是也应该了解以避免搞混这些看似名称都类似的属性。



offset


MouseEvent.offsetX/offsetY


MouseEvent 接口的只读属性 offsetX/Y 规定了事件对象与目标节点的内填充边(padding edge)在 X/Y 轴方向上的偏移量。


相信使用过offest的同学对这个属性深有体会,它是相对于父元素的左边/上方的偏移量。



注意是触发元素也就是 e.target,额外小心如果事件对象中存在从一个子元素当移动到子元素内部时,e.offsetX/Y 此时相对于子元素的左上角偏移量。



offsetWidth/offsetHeight


HTMLElement.offsetWidth/Height 是一个只读属性,返回一个元素的布局宽度/高度。


所谓的布局宽度也就是相对于我们上边说到的clientHeight/Width,offsetHeight/Width,他们都是不包含border以及滚动条的宽/高(如果存在的话)。


offsetWidth/offsetHeight返回元素的布局宽度/高度,包含元素的边框(border)、水平线/垂直线上的内边距(padding)、竖直/水平方向滚动条(scrollbar)(如果存在的话)、以及CSS设置的宽度(width)的值


offsetTop/left


HTMLElement.offsetLeft 是一个只读属性,返回当前元素左上角相对于 HTMLElement.offsetParent 节点的左边界偏移的像素值。



注意返回的是相对于 HTMLElement.offsetParent 节点左边边界的偏移量。



何为HTMLElement.offsetParent?



HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table,td,th,body 元素。当元素的 style.display 设置为 "none" 时,offsetParent 返回 null。offsetParent 很有用,因为 offsetTop 和 offsetLeft 都是相对于其内边距边界的。 -- MDN



讲讲人话,当前元素的祖先组件节点如果不存在任何 table,td,th 以及 position 属性为 relative,absolute 等为定位元素时,offsetLeft/offsetTop 返回的是距离 body 左/上角的偏移量。


当祖先元素中有定位元素(或者上述标签元素)时,它就可以被称为元素的offsetParent。元素的 offsetLeft/offsetTop 的值等于它的左边框左侧/顶边框顶部到它的 offsetParent 元素左边框的距离。


我们来看看这张图:


image.png


计算元素距离 body 的偏移量


当我们需要获得元素距离 body 的距离时,但是又无法确定父元素是否存在定位元素时(大多数时候在组件开发中,并不清楚父节点是否存在定位)。此时需要实现类似 jqery 的 offset()方法:获得当前元素对于 body 的偏移量。



  • 无法直接使用 offsetLeft/offsetTop 获取,因为并不确定父元素是否存在定位元素。

  • 使用递归解决,累加偏移量 offset,当前 offsetParent 不为 body 时。

  • 继续递归向上超着 offsetParent 累加 offset,直到遇到 body 元素停止。


const getOffsetSize = function(Node: any, offset?: any): any {
if (!offset) {
offset = {
x: 0,
y: 0
};
}
if (Node === document.body) return offset;
offset.x = offset.x + Node.offsetLeft;
offset.y = offset.y + Node.offsetTop;
return getOffsetSize(Node.offsetParent, offset);
};


注意:这里不可以使用 parentNode 上文已经讲过 offsetLeft/top 针对的是 HTMLElement.offsetParent 的偏移量而非 parentNode 的偏移量。



Element.getBoundingClientRect


用法讲解


Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。



element.getBoundingClientRect()返回的相对于视口左上角的位置。



element.getBoundingClientRect()返回的 heightwidth 是针对元素可见区域的宽和高(具体尺寸根据 box-sizing 决定),并不包含滚动条被隐藏的内容。



TIP: 如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于 width/height。



rectObject = object.getBoundingClientRect();

返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有 left, top, right, bottom, x, y, width, 和 height 这几个以像素为单位的只读属性用于描述整个边框。除了 widthheight 以外的属性是相对于视图窗口的左上角来计算的。


widthheight是计算元素的大小,其他属性都是相对于视口左上角来说的。


当计算边界矩形时,会考虑视口区域(或其他可滚动元素)内的滚动操作,也就是说,当滚动位置发生了改变,top 和 left 属性值就会随之立即发生变化(因此,它们的值是相对于视口的,而不是绝对的) 。如果你需要获得相对于整个网页左上角定位的属性值,那么只要给 top、left 属性值加上当前的滚动位置(通过 window.scrollX 和 window.scrollY),这样就可以获取与当前的滚动位置无关的值。


image.png


计算元素是否出现在视口内


利用的还是元素距离视口的位置小于视口的大小。



注意即便变成了负值,那么也表示元素曾经出现过在屏幕中只是现在不显示了而已。(就比如滑动过)



vue-lazy图片懒加载库源码就是这么判断的。


 isInView (): boolean {
const rect = this.el.getBoundingClientRect()
return rect.top < window.innerHeight && rect.left < window.innerWidth
}


如果rect.top < window.innerHeight表示当前元素已经已经出现在(过)页面中,left同理。



window.getComputedStyle


用法讲解


Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。 私有的CSS属性值可以通过对象提供的API或通过简单地使用CSS属性名称进行索引来访问。


let style = window.getComputedStyle(element, [pseudoElt]);



  • element


     用于获取计算样式的Element




  • pseudoElt 可选


    指定一个要匹配的伪元素的字符串。必须对普通元素省略(或null)。




返回的style是一个实时的 CSSStyleDeclaration 对象,当元素的样式更改时,它会自动更新本身。


作者:19组清风
链接:https://juejin.cn/post/7006878952736161829

收起阅读 »

面试贼坑的十道js面试题(我只会最后一题)

前言 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批 typeof null 为什么是object null就出了一个 bug。...
继续阅读 »

前言



  • 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批


typeof null 为什么是object




  • null就出了一个 bug。根据 type tags 信息,低位是 000,因此 null被判断成了一个对象。这就是为什么 typeofnull的返回值是 "object"。




  • 关于 null的类型在 MDN 文档中也有简单的描述:typeof - java | MDN




  • 在 ES6 中曾有关于修复此 bug 的提议,提议中称应该让 typeofnull==='null'wiki.ecma.org/doku.php?id… 但是该提议被无情的否决了,自此 typeofnull终于不再是一个 bug,而是一个 feature,并且永远不会被修复




0.1+0.2为什么不等于0.3,以及怎么等于0.3



  • 在开发过程中遇到类似这样的问题:


let n1 = 0.1, n2 = 0.2
console.log(n1 + n2) // 0.30000000000000004


  • 这里得到的不是想要的结果,要想等于0.3,就要把它进行转化:


(n1 + n2).toFixed(2) // 注意,toFixed为四舍五入

toFixed(num) 方法可把 Number 四舍五入为指定小数位数的数字。那为什么会出现这样的结果呢?


计算机是通过二进制的方式存储数据的,所以计算机计算0.1+0.2的时候,实际上是计算的两个数的二进制的和。0.1的二进制是0.0001100110011001100...(1100循环),0.2的二进制是:0.00110011001100...(1100循环),这两个数的二进制都是无限循环的数。那JavaScript是如何处理无限循环的二进制小数呢?


一般我们认为数字包括整数和小数,但是在 JavaScript 中只有一种数字类型:Number,它的实现遵循IEEE 754标准,使用64位固定长度来表示,也就是标准的double双精度浮点数。在二进制科学表示法中,双精度浮点数的小数部分最多只能保留52位,再加上前面的1,其实就是保留53位有效数字,剩余的需要舍去,遵从“0舍1入”的原则。


根据这个原则,0.1和0.2的二进制数相加,再转化为十进制数就是:0.30000000000000004


下面看一下双精度数是如何保存的:


2020080420355853.png



  • 第一部分(蓝色):用来存储符号位(sign),用来区分正负数,0表示正数,占用1位

  • 第二部分(绿色):用来存储指数(exponent),占用11位

  • 第三部分(红色):用来存储小数(fraction),占用52位


对于0.1,它的二进制为:


0.00011001100110011001100110011001100110011001100110011001 10011...

转为科学计数法(科学计数法的结果就是浮点数):


1.1001100110011001100110011001100110011001100110011001*2^-4

可以看出0.1的符号位为0,指数位为-4,小数位为:


1001100110011001100110011001100110011001100110011001

那么问题又来了,指数位是负数,该如何保存呢?


IEEE标准规定了一个偏移量,对于指数部分,每次都加这个偏移量进行保存,这样即使指数是负数,那么加上这个偏移量也就是正数了。由于JavaScript的数字是双精度数,这里就以双精度数为例,它的指数部分为11位,能表示的范围就是0~2047,IEEE固定双精度数的偏移量为1023



  • 当指数位不全是0也不全是1时(规格化的数值),IEEE规定,阶码计算公式为 e-Bias。 此时e最小值是1,则1-1023= -1022,e最大值是2046,则2046-1023=1023,可以看到,这种情况下取值范围是-1022~1013

  • 当指数位全部是0的时候(非规格化的数值),IEEE规定,阶码的计算公式为1-Bias,即1-1023= -1022。

  • 当指数位全部是1的时候(特殊值),IEEE规定这个浮点数可用来表示3个特殊值,分别是正无穷,负无穷,NaN。 具体的,小数位不为0的时候表示NaN;小数位为0时,当符号位s=0时表示正无穷,s=1时候表示负无穷。


对于上面的0.1的指数位为-4,-4+1023 = 1019 转化为二进制就是:1111111011.


所以,0.1表示为:


0 1111111011 1001100110011001100110011001100110011001100110011001

说了这么多,是时候该最开始的问题了,如何实现0.1+0.2=0.3呢?


对于这个问题,一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2-52,在ES6中,提供了Number.EPSILON属性,而它的值就是2-52,只要判断0.1+0.2-0.3是否小于Number.EPSILON,如果小于,就可以判断为0.1+0.2 ===0.3


function numberepsilon(arg1,arg2){                   
return Math.abs(arg1 - arg2) < Number.EPSILON;
}

console.log(numberepsilon(0.1 + 0.2, 0.3)); // true

为什么要用weakMap




  • WeakMap 为弱引用,利于垃圾回收机制。




  • 一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。




  • 总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。




RAF 和 RIC 是什么



  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。

  • requestIdleCallback:: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序。


escape、encodeURI、encodeURIComponent 的区别



  • encodeURI 是对整个 URI 进行转义,将 URI 中的非法字符转换为合法字符,所以对于一些在 URI 中有特殊意义的字符不会进行转义。

  • encodeURIComponent 是对 URI 的组成部分进行转义,所以一些特殊字符也会得到转义。

  • escape 和 encodeURI 的作用相同,不过它们对于 unicode 编码为 0xff 之外字符的时候会有区别,escape 是直接在字符的 unicode 编码前加上 %u,而 encodeURI 首先会将字符转换为 UTF-8 的格式,再在每个字节前加上 %。


await 到底在等啥


await 在等待什么呢? 一般来说,都认为 await 是在等待一个 async 函数完成。不过按语法说明,await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。


因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数,但要清楚,它等的实际是一个返回值。注意到 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果,所以,await 后面实际是可以接普通函数调用或者直接量的。所以下面这个示例完全可以正确运行:


function getSomething() {
return "something";
}
async function testAsync() {
return Promise.resolve("hello async");
}
async function test() {
const v1 = await getSomething();
const v2 = await testAsync();
console.log(v1, v2);
}
test();

await 表达式的运算结果取决于它等的是什么。



  • 如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。

  • 如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。


来看一个例子:


function testAsy(x){
return new Promise(resolve=>{setTimeout(() => {
resolve(x);
}, 3000)
}
)
}
async function testAwt(){
let result = await testAsy('hello world');
console.log(result); // 3秒钟之后出现hello world
console.log('cuger') // 3秒钟之后出现cug
}
testAwt();
console.log('cug') //立即输出cug

这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。await暂停当前async的执行,所以'cug''最先输出,hello world'和‘cuger’是3秒钟后同时出现的。


|| 和 && 操作符的返回值



  • || 和 && 首先会对第一个操作数执行条件判断,如果其不是布尔值就先强制转换为布尔类型,然后再执行条件判断。

  • 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。

  • && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。

  • || 和 && 返回它们其中一个操作数的值,而非条件判断的结果


2 == [[[2]]]



  • 根据ES5规范,如果比较的两个值中有一个是数字类型,就会尝试将另外一个值强制转换成数字,再进行比较。而数组强制转换成数字的过程会先调用它的 toString方法转成字符串,然后再转成数字。所以 [2]会被转成 "2",然后递归调用,最终 [[[2]]] 会被转成数字 2。


var x = [typeof x, typeof y][1];typeof typeof x;//"string"




  • 因为没有声明过变量y,所以typeof y返回"undefined"




  • 将typeof y的结果赋值给x,也就是说x现在是"undefined"




  • 然后typeof x当然是"string"




  • 最后typeof "string"的结果自然还是"string"




你能接受加班吗?而且我们加班不给钱!



  • f¥¥¥¥¥k y**********u

链接:https://juejin.cn/post/7005402640746020877

收起阅读 »

for 循环不是目的,map 映射更有意义!【FP探究】

楔子 在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递! 例🌰: function sayHi() { console.log("...
继续阅读 »

楔子


在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递


例🌰:


function sayHi() {
console.log("Hi");
}
function sayBye() {
console.log("Bye");
}

function greet(type, sayHi, sayBye) {
type === 1 ? sayHi() : sayBye()
}

greet(1, sayHi, sayBye); // Hi

又得讲这个老生常谈的定义:如果一个函数“接收函数作为参数”或“返回函数作为输出”,那么这个函数被称作“高阶函数”


本篇要谈的是:高阶函数中的 mapfilterreduce 是【如何实践】的,我愿称之为:高阶映射!!


先别觉得这东西陌生,其实咱们天天都见!!


例🌰:


[1,2,3].map(item => item*2)

实践



Talk is cheap. Show me the code.



以下有 4 组代码,每组的 2 个代码片段实现目标一致,但实现方式有异,感受感受,你更喜欢哪个?💖


第 1 组:


1️⃣


const arr1 = [1, 2, 3];
const arr2 = [];
for(let i = 0; i < arr1.length; i++) {
arr2.push(arr1[i] * 2);
}
console.log(arr2); // [ 2, 4, 6 ]

2️⃣


const arr1 = [1, 2, 3];
const arr2 = arr1.map(item => item * 2);
console.log(arr2); // [ 2, 4, 6 ]

第 2 组:


1️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = [];
for(let i = 0; i < birthYear.length; i++) {
let age = 2018 - birthYear[i];
ages.push(age);
}
console.log(ages); // [ 43, 21, 16, 23, 33 ]

2️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = birthYear.map(year => 2018 - year);
console.log(ages); // [ 43, 21, 16, 23, 33 ]

第 3 组:


1️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = [];
for(let i = 0; i < persons.length; i++) {
if(persons[i].age >= 18) {
fullAge.push(persons[i]);
}
}
console.log(fullAge);

2️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = persons.filter(person => person.age >= 18);
console.log(fullAge);

第 4 组:


1️⃣


const arr = [5, 7, 1, 8, 4];
let sum = 0;
for(let i = 0; i < arr.length; i++) {
sum = sum + arr[i];
}
console.log(sum); // 25

2️⃣


const arr = [5, 7, 1, 8, 4];
const sum = arr.reduce(function(accumulator, currentValue) {
return accumulator + currentValue;
});
console.log(sum); // 25

更喜欢哪个?有答案了吗?


image.png


每组的代码片段 2️⃣ 就是map/filter/reduce高阶函数的应用,没有别的说的,就是更加简洁易读


手写


实际上,map/filter/reduce 也是基于 for 循环封装来的,所以我们也能自己实现一套相同的 高阶映射 🚀;



  • map1


Array.prototype.map1 = function(fn) {
let newArr = [];
for (let i = 0; i < this.length; i++) {
newArr.push(fn(this[i]))
};
return newArr;
}

console.log([1,2,3].map1(item => item*2)) // [2,4,6]


  • filter1


Array.prototype.filter1 = function (fn) {
let newArr=[];
for(let i=0;i<this.length;i++){
fn(this[i]) && newArr.push(this[i]);
}
return newArr;
};

console.log([1,2,3].filter1(item => item>2)) // [3]


  • reduce1


Array.prototype.reduce1 = function (reducer,initVal) {
for(let i=0;i<this.length;i++){
initVal =reducer(initVal,this[i],i,this);
}
return initVal
};

console.log([1,2,3].reduce1((a,b)=>a+b,0)) // 6

如果你不想直接挂在原型链上🛸:



  • mapForEach


function mapForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
newArray.push(
fn(arr[i])
);
}
return newArray;
}

mapForEach([1,2,3],item=>item*2) // [2,4,6]


  • filterForEach


function filterForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
fn(arr[i]) && newArray.push(arr[i]);
}
return newArray;
}

filterForEach([1,2,3],item=>item>2) // [3]


  • reduceForEach


function reduceForEach(arr,reducer,initVal) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
initVal =reducer(initVal,arr[i],i,arr);
}
return initVal;
}

reduceForEach([1,2,3],(a,b)=>a+b,0) // 6

这里本瓜有个小疑惑,在 ES6 之前,有没有一个库做过这样的封装❓


小结


本篇虽基础,但很重要


对一些惯用写法的审视、改变,会产生一些奇妙的思路~ 稀松平常的 map 映射能做的比想象中的要多得多!


for 循环遍历只是操作性的手段,不是目的!而封装过后的 map 映射有了更易读的意义,映射关系(输入、输出)也是函数式编程之核心!


YY一下:既然 map 这类函数都是从 for 循环封装来的,如果你能封装一个基于 for 循环的另一种特别实用的高阶映射或者其它高阶函数,是不是意味着:有朝一日有可能被纳入 JS 版本标准 API 中?🐶🐶🐶


或许:先意识到我们每天都在使用的高阶函数,刻意的去使用、训练,然后能举一反三,才能做上面的想象吧~~~



链接:https://juejin.cn/post/7006077858338570270

收起阅读 »

用canvas实现一个大气球送给你

一、背景 近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。 二、实现 在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分: 实现球体部分; 实现气球口...
继续阅读 »

一、背景



近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。



balloon1.gif


二、实现



在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分:




  1. 实现球体部分;

  2. 实现气球口子部分;

  3. 实现气球的线部分;

  4. 进行颜色填充;

  5. 实现动画;


气球.PNG


2.1 球体部分实现



对于这样的气球的球体部分,大家都有什么好的实现思路的?相信大家肯定会有多种多样的实现方案,我也是在看到某位大佬的效果后,感受到了利用四个三次贝塞尔曲线实现这个效果的妙处。为了看懂后续代码,先了解一下三次贝塞尔曲线的原理。(注:引用了CSDN上某位大佬的文章,写的很好,下图引用于此)



三次贝塞尔曲线.gif



在上图中P0为起始点、P3为终止点,P1和P2为控制点,其最终的曲线公式如下所示:



B(t)=(1−t)^3 * P0+3t(1−t)^2 * P1+3t ^ 2(1−t) * P2+t ^ 3P3, t∈[0,1]



上述已经列出了三次贝塞尔曲线的效果图和公式,但是通过这个怎么跟我们的气球挂上钩呢?下面通过几张图就理解了:



image.png



如上图所示,就是实现整个气球球体的思路,具体解释如下所示:




  1. A图中起始点为p1,终止点为p2,控制点为c1、c2,让两个控制点重合,绘制出的效果并不是很像气球的一部分,此时就要通过改变控制点来改变其外观;

  2. 改变控制点c1、c2,c1中y值不变,减小x值;c2中x值不变,增大y值(注意canvas中坐标方向即可),改变后就得到了图B的效果,此时就跟气球外观很像了;

  3. 紧接着按照这个方法就可以实现整个的气球球体部分的外观。


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(250, 250);
drawCoordiante(ctx);
ctx.save();
ctx.beginPath();
ctx.moveTo(0, -80);
ctx.bezierCurveTo(45, -80, 80, -45, 80, 0);
ctx.bezierCurveTo(80, 85, 45, 120, 0, 120);
ctx.bezierCurveTo(-45, 120, -80, 85, -80, 0);
ctx.bezierCurveTo(-80, -45, -45, -80, 0, -80);
ctx.stroke();
ctx.restore();
}

function drawCoordiante(ctx) {
ctx.beginPath();
ctx.moveTo(-120, 0);
ctx.lineTo(120, 0);
ctx.moveTo(0, -120);
ctx.lineTo(0, 120);
ctx.closePath();
ctx.stroke();
}

2.2 口子部分实现



口子部分可以简化为一个三角形,效果如下所示:



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(-5, 130);
ctx.lineTo(5, 130);
ctx.closePath();
ctx.stroke();
ctx.restore();
}

2.3 线部分实现



线实现的比较简单,就用了一段直线实现



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(0, 300);
ctx.stroke();
ctx.restore();
}

2.4 进行填充



气球部分的填充用了圆形渐变效果,相比于纯色来说更加漂亮一些。



function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = getBalloonGradient(ctx, 0, 0, 80, 210);
……

}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}

image.png


2.5 动画效果及整体代码



上述流程已经将一个静态的气球部分绘制完毕了,要想实现动画效果只需要利用requestAnimationFrame函数不断循环调用即可实现。下面直接抛出整体代码,方便同学们观察效果进行调试,整体代码如下所示:



let posX = 225;
let posY = 300;
let points = getPoints();
draw();

function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (posY < -200) {
posY = 300;
posX += 300 * (Math.random() - 0.5);
points = getPoints();
}
else {
posY -= 2;
}
ctx.save();
ctx.translate(posX, posY);
drawBalloon(ctx, points);
ctx.restore();

window.requestAnimationFrame(draw);
}

function drawBalloon(ctx, points) {
ctx.scale(points.scale, points.scale);
ctx.save();
ctx.fillStyle = getBalloonGradient(ctx, 0, 0, points.R, points.hue);
// 绘制球体部分
ctx.moveTo(points.p1.x, points.p1.y);
ctx.bezierCurveTo(points.pC1to2A.x, points.pC1to2A.y, points.pC1to2B.x, points.pC1to2B.y, points.p2.x, points.p2.y);
ctx.bezierCurveTo(points.pC2to3A.x, points.pC2to3A.y, points.pC2to3B.x, points.pC2to3B.y, points.p3.x, points.p3.y);
ctx.bezierCurveTo(points.pC3to4A.x, points.pC3to4A.y, points.pC3to4B.x, points.pC3to4B.y, points.p4.x, points.p4.y);
ctx.bezierCurveTo(points.pC4to1A.x, points.pC4to1A.y, points.pC4to1B.x, points.pC4to1B.y, points.p1.x, points.p1.y);

// 绘制气球钮部分
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.knowA.x, points.knowA.y);
ctx.lineTo(points.knowB.x, points.knowB.y);
ctx.fill();
ctx.restore();

// 绘制线部分
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.lineEnd.x, points.lineEnd.y);
ctx.stroke();
ctx.restore();
}

function getPoints() {
const offset = 35;
return {
scale: 0.3 + Math.random() / 2,
hue: Math.random() * 255,
R: 80,
p1: {
x: 0,
y: -80
},
pC1to2A: {
x: 80 - offset,
y: -80
},
pC1to2B: {
x: 80,
y: -80 + offset
},
p2: {
x: 80,
y: 0
},
pC2to3A: {
x: 80,
y: 120 - offset
},
pC2to3B: {
x: 80 - offset,
y: 120
},
p3: {
x: 0,
y: 120
},
pC3to4A: {
x: -80 + offset,
y: 120
},
pC3to4B: {
x: -80,
y: 120 - offset
},
p4: {
x: -80,
y: 0
},
pC4to1A: {
x: -80,
y: -80 + offset
},
pC4to1B: {
x: -80 + offset,
y: -80
},
knowA: {
x: -5,
y: 130
},
knowB: {
x: 5,
y: 130
},
lineEnd: {
x: 0,
y: 250
}
};
}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}


链接:https://juejin.cn/post/7006967510134161438

收起阅读 »

通过一个例子学习css层叠上下文

层叠上下文 & 层叠等级 & 层叠规则 http://www.w3.org/TR/CSS22/vi… The order in which the rendering tree is painted onto the canvas is d...
继续阅读 »

层叠上下文 & 层叠等级 & 层叠规则



http://www.w3.org/TR/CSS22/vi…


The order in which the rendering tree is painted onto the canvas is described in terms of stacking contexts. Stacking contexts can contain further stacking contexts. A stacking context is atomic from the point of view of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.


Each box belongs to one stacking context. Each positioned box in a given stacking context has an integer stack level, which is its position on the z-axis relative other stack levels within the same stacking context. Boxes with greater stack levels are always formatted in front of boxes with lower stack levels. Boxes may have negative stack levels. Boxes with the same stack level in a stacking context are stacked back-to-front according to document tree order.


The root element forms the root stacking context.



翻译一下:
渲染树被绘制到画布上的顺序是根据层叠上下文来描述的。层叠上下文可以包含更多的层叠上下文。从父层叠上下文的角度来看,层叠上下文是原子的;其他层叠上下文中的盒子可能不会出现在它的任何盒子中。


每个框都属于一个层叠上下文。给定层叠上下文中的每个定位框都有一个整数层叠等级,这是它在 z 轴上相对于同一层叠上下文中其他层叠等级的位置。具有较高层叠等级的框始终放置在具有较低层叠等级的框之前。盒子可能有负的层叠等级。层叠上下文中具有相同层叠等级的框根据文档树顺序从后到前绘制。


根元素创建根层叠上下文。


理解:
所有的元素都属于一个层叠上下文,所以所有的元素都有自己的层叠等级。
每个元素都有自己所属的层叠上下文,在当前层叠上下文中具有自己的层叠等级。



那层叠等级的规则是啥呢?



http://www.w3.org/TR/CSS22/vi…


Within each stacking context, the following layers are painted in back-to-front order:


the background and borders of the element forming the stacking context.
the child stacking contexts with negative stack levels (most negative first).
the in-flow, non-inline-level, non-positioned descendants.
the non-positioned floats.
the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
the child stacking contexts with stack level 0 and the positioned descendants with stack level 0.
the child stacking contexts with positive stack levels (least positive first).



在每一个层叠上下文中,阿照下面的顺序从后往前绘制。



  1. 创建层叠上下文元素的背景和边框

  2. 创建层叠上下文元素的具有负层叠等级子元素

  3. 非inline元素并且没有定位的后代【block后代】

  4. 非定位的浮动元素

  5. 包括inline-table / inline-block的非定位inline元素

  6. 创建层叠上下文元素的层叠等级为0的子元素【0 / auto】

  7. 创建层叠上下文元素的层叠等级为大于0的子元素



关于这个等级张鑫旭有一张图说明
image.png
这里提到了一个新增的:不依赖于z-index的层叠上下文,这里指的应该是css3会有一些元素在不通过定位来创建新的层叠上下文



  1. z-index值不为auto的flex项(父元素display:flex|inline-flex).

  2. 元素的opacity值不是1.

  3. 元素的transform值不是none.

  4. 元素mix-blend-mode值不是normal.

  5. 元素的filter值不是none.

  6. 元素的isolation值是isolate.

  7. will-change指定的属性值为上面任意一个。

  8. 元素的-webkit-overflow-scrolling设为touch.





Demo



先看parent元素


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 100px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div class="parent">
parent
<div class="child1">child1
<div class="child1-2">child1-2</div>
<div class="child1-1">child1-1</div>
<!-- <div>child1-1</div>
<div>child1-2</div> -->
</div>
<div class="child2">
child2
</div>
</div>
</body>

image.png


先从根节点看起:根节点上根层级上下文,因为只有一个子节点parent。然后parent有自己的层叠上下文。parent有两个子节点,上文说到每个盒子属于一个层叠上下文,parent属于html的层叠上下文,parent会创建自己的层叠上下文,当然这个层叠上下文的作用主要针对parent的子元素。child1,child2。


image.pngimage.png


因为child1的z-index为1,child2的z-index为-1。所以这里的child1会绘制在child2的上面。


当我们在看child1的子元素和child2的子元素就不能放在一起看了,因为child1和child2都创建了自己的层叠上下文。只能独立看了。


这里child2的绘制会在parent的上面,尽管child2的z-index为负树。这里也对应了上面说的7层关系。因为parent属于创建层叠上下文的元素。



知识点:层叠上下文



  1. 普通元素的层叠等级优先由其所在的层叠上下文决定。

  2. 层叠等级的比较只有在当前层叠上下文元素中才有意义。不同层叠上下文中比较层叠等级是没有意义的。





知识点:层叠等级



  1. 在同一个层叠上下文中,它描述定义的是该层叠上下文中的层叠上下文元素在Z轴上的上下顺序。

  2. 在其他普通元素中,它描述定义的是这些普通元素在Z轴上的上下顺序。





接下来看block层级小于float


image.png


再看具体的页面渲染,我们修改一下代码,将child1-2和child1-2的顺序调换一下:


image.pngimage.png


这里不同的顺序会有不同的效果:第二张图看得出来是我们期望的,child1-2绘制到了chil1-1下面。因为float元素没有脱离文本流,所以child1-2的文本会被挤压到下面去。那么我们看一下第一张图为什么会这样。
从float的概念当中就可以看出来了。
浮动定位作用的是当前行,当前浮动元素在绘制的时候,child1父元素第一个元素是block元素,所以。float在绘制的时候,因为child1-1的宽度和child1的宽度相同,所以float所在的当前行就是目前的位置。第二张图是我们期望的结果是因为float在绘制的时候所在的当前行就是第一行。所以会按照我们期望的体现。



接下来看float小于inline / inline-block


我们接着上面第二张图继续看。这样是看不出来效果的,需要修改一下代码再看。


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 200px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
display: inline-block;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
margin: 10px -15px 10px 10px;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div>
parent
<div>child1
<divhljs-number">1">child1-1</div>
<divhljs-number">2">child1-2</div>
</div>
<div>
child2
</div>
</div>
</body>

image.png
修改代码是需要将float元素和inline-block元素放在同一行,如果不是在同一行是没意义的。我们可以看到child1的文本节点和child1-2的inline-block元素都绘制在了child1-1的元素上面了。


论证一下css3的内容


也就是下面这个红框的内容:


image.png
继续用上面的例子:
上面看到的float元素已经放置在了inline / inline-block内容的下面。现在我们加一下:上面说的css3的样式在看一下。下面的两个例子可以看到之前放置在inline / inline-block下面的child1-1已经绘制在上面了。



opacity


image.png



tranform


image.png





概念



z-index



  1. 首先,z-index属性值并不是在任何元素上都有效果。它仅在定位元素(定义了position属性,且属性值为非static值的元素)上有效果。

  2. 判断元素在Z轴上的堆叠顺序,不仅仅是直接比较两个元素的z-index值的大小,这个堆叠顺序实际由元素的层叠上下文层叠等级共同决定。





层叠上下文的特性



  • 层叠上下文的层叠水平要比普通元素高;

  • 层叠上下文可以嵌套,内部层叠上下文及其所有子元素均受制于外部的层叠上下文。

  • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需要考虑后代元素。

  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中



链接:https://juejin.cn/post/7006978541988347941

收起阅读 »

【中秋】纯CSS实现日地月的公转

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。 但因为我根本没咋学过前端,这两天恶补了一下重学了 flexbox 和 grid ,成...
继续阅读 »

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。


但因为我根本没咋学过前端,这两天恶补了一下重学了 flexboxgrid ,成果应该说还挺好看(如果我的审美没有问题的话)。


配色我挺喜欢的,希望你也喜欢。


源码我放到了 CodePen 上,链接 Sun Earth Moon (codepen.io)


HTML


重点是CSS,HTML放上三个 div 就🆗了。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Mancuoj</title>
<link
href="simulation.css"
rel="stylesheet"
/>
</head>

<body>
<h1>Mancuoj</h1>
<figure class="container">
<div class="sun"></div>
<div class="earth">
<div class="moon"></div>
</div>
</figure>
</body>
</html>

背景和文字


导入我最喜欢的 Lobster 字体,然后设为白色,字体细一点。


@import url("https://fonts.googleapis.com/css2?family=Lobster&display=swap");

h1 {
color: white;
font-size: 60px;
font-family: Lobster, monospace;
font-weight: 100;
}

背景随便找了一个偏黑紫色,然后把画的内容设置到中间。


body {
margin: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #2f3141;
}

.container {
font-size: 10px;
width: 40em;
height: 40em;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

日地月动画


众所周知:地球绕着太阳转,月球绕着地球转。


我们画的是公转,太阳就直接画出来再加个阴影高光,月亮地球转就可以了。


最重要的其实是配色(文章末尾有推荐网站),我实验好长时间的配色,最终用了三个渐变色来表示日地月。


日: linear-gradient(#fcd670, #f2784b);
地: linear-gradient(#19b5fe, #7befb2);
月: linear-gradient(#8d6e63, #ffe0b2);

CSS 应该难不到大家,随便看看吧。


轨道用到了 border,用银色线条当作公转的轨迹。


动画用到了自带的 animation ,每次旋转一周。


.sun {
position: absolute;
width: 10em;
height: 10em;
background: linear-gradient(#fcd670, #f2784b);
border-radius: 50%;
box-shadow: 0 0 8px 8px rgba(242, 120, 75, 0.2);
}

.earth {
--diameter: 30;
--duration: 36.5;
}

.moon {
--diameter: 8;
--duration: 2.7;
top: 0.3em;
right: 0.3em;
}

.earth,
.moon {
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
border-width: 0.1em;
border-style: solid solid none none;
border-color: silver transparent transparent transparent;
border-radius: 50%;
animation: orbit linear infinite;
animation-duration: calc(var(--duration) * 1s);
}

@keyframes orbit {
to {
transform: rotate(1turn);
}
}

.earth::before {
--diameter: 3;
--color: linear-gradient(#19b5fe, #7befb2);
--top: 2.8;
--right: 2.8;
}

.moon::before {
--diameter: 1.2;
--color: linear-gradient(#8d6e63, #ffe0b2);
--top: 0.8;
--right: 0.2;
}

.earth::before,
.moon::before {
content: "";
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
background: var(--color);
border-radius: 50%;
top: calc(var(--top) * 1em);
right: calc(var(--right) * 1em);
}

总结


参加个活动真不容易,不过前端还是挺好玩的。


链接:https://juejin.cn/post/7006507905050492935

收起阅读 »

Bitmap和Drawable

Bitmap:图片信息的存储工具,保存每一个像素是什么颜色image: width:640 height:400 pixel:ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000f...
继续阅读 »
  • Bitmap:图片信息的存储工具,保存每一个像素是什么颜色

image: width:640 height:400 pixel:ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000

  • Drawable是什么(Drawable在代码上是接口,BitmapDrawable、ColorDrawable等实现)?

drawable是绘制工具,重写draw进行绘制

  • view和drawable?
  1. 都是使用canvas进行绘制。
  2. drawable纯绘制工具。
  3. view会包含测量、布局、绘制。
  4. drawable绘制的时候一定要设置边界setBounds
class DrawableViewcontextContextattrAttributeSet):Viewcontextattr){
private val drawable = ColorDrawable(Color.RED)
override fun onDraw(canvas:Canvas){
super.onDraw(canvas)
drawable.setBounds(0,0,width,height)
drawable.draw(canvas)
}
}
  • Bitmap和Drawable怎么互转?(其实不是互转,是使用一个实例创建了另外一个实例)
  1. Bitmap转Drawable

java

Drawable d = new BitmapDrawable(getResource(),bitmap);

kotlin(ktx)

bitmap.toDrawable(resource)
  1. Drawable转Bitmap

java

public static Bitmap drawableToBitmap(Drawable drawable){
Bitmap bitmap = null;
if(drawable instance BitmapDrawable){//1、如果是BitmapDrawable
BitmapDrawable bitmapDrawable = (BitmapDrawable)drawable;
if(bitmapDrawable.getBitmap()!=null){
return bitmapDrawable.getBitmap();
}
}
//2、如果drawable的宽高小于等于0
if(drawable.getIntrinsicWidth()<=0||drawable.getIntrinsicHeight()<=0){
bitmap = Bitmap.createBitmap(1,1,Bitmap.ARGB_8888);
}else{
bitmap =Bitmap.createBitmap(drawable.getIntrinsicWidth(),drawable.getIntrinsicHeight(),
Bitmap.Config.ARGB_8888)
}
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0,0,canvas.getWidth(),canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}

kotlin(ktx)

drawable.toBitmap()
  • 自定义Drawable(作用:一个自定义drawable可以把多个view中的重复代码提出来,在多个view之间进行复用)
//画一个网眼
private val INTERVAL = 50.dp
class MeshDrawable:Drawable(){
private val paint = Paint(Paint.ANT_ALIAS_FLAG)
override fun draw(canvas:Canvas){
var x = bounds.lef.toFloat()
while(x<=bounds.right.toFloat()){
canvas.drawLine(x,bound.top.toFloat(),
x,bounds.bottom.toFloat(),paint)
x+=INTERVAL
}
var y = bounds.top.toFloat()
while(y<=bounds.bottom.toFloat()){
canvas.drawLine(bounds.left.toFloat(),y,
bounds.right.toFloat(),y,paint)
y+=INTERVAL
}
}
override fun setAlpha(alpha:Int){
paint.alpha = alpha
}
override fun getAlpha():Int{
return paint.alpha
}
override fun getOpacity():Int{//不透明度
return when(paint.alpha){
0->PixelFormat.TRANSPARENT
0xff ->PixelFormat.OPAQUE
else ->PixelFormat.TRANSLUCENT
}
}
override fun setColorFilter(colorFilter:ColorFilter?){
paint.colorFilter = colorFilter
}
override fun getColorFilter():ColorFilter{
return paint.colorFilter
}
}

getWidth() 是实际显示的宽度。

getMeasureWidth() 是测量宽度,在布局之前计算出来的。

getIntrinsicWidth() 是原有宽度,有时候原有宽度可能很大,但是实际上空间不够,所有效果上并没有那么大,这个方法可以获得原有宽度,可以辅助测量的时候选择合适的展示宽度。

getMinimumWidth() 是最小宽度,是XML参数定义里的 minWidth,也是一个辅助测量展示的参数。

收起阅读 »

Android高德地图踩坑记录-内存泄漏问题

1、问题现象最近做项目优化,在查找app可能存在的内存泄漏地方,项目中有用到高德地图SDK,有一个页面有展示地图,每次退出该页面的时候,LeakCanary老是提示有内存泄漏,泄漏的大概信息如下:2、排查问题看样子像是高德地图相关的内存泄漏,不过为了进一步可以...
继续阅读 »

1、问题现象

最近做项目优化,在查找app可能存在的内存泄漏地方,项目中有用到高德地图SDK,有一个页面有展示地图,每次退出该页面的时候,LeakCanary老是提示有内存泄漏,泄漏的大概信息如下:

image.png

2、排查问题

看样子像是高德地图相关的内存泄漏,不过为了进一步可以定位到问题,通常可以采用一种虽然有些笨但是可以定位到问题点的方法:控制变量法,排除到不太可能出现问题的地方,只保留可能出现的问题,具体是先注释掉和高德地图无关的代码,然后复现问题,确保问题是出在和高德地图相关的代码上

经过一系列的注释代码然后复现操作,明确内存泄漏的点是在高德地图相关的操作上,通过分析LeakCanary生成的Heap Dump(堆转储)文件,也验证了这个猜想

image.png

我在代码里有封装过一个关于地图操作的utils类,刚开始以为是在页面销毁的时候,这个utils类里有一些资源没有释放,比如当前Activity的context引用,在改为Application引用之后,发现问题还是有,然后在Activity销毁的时候,对utils里的一些资源进行了释放,发现还是不可以

后来经过在网上查找资料,查看高德地图官方demo,发现一个细节有可能是使用Butterknife的问题

image.png

因为在onDestroy方法里,我有写MapView的销毁方法,但是没有进入到if语句里面

image.png

3、问题解决方式

不使用ButterKnife的方式获取MapView控件,采用原生的findViewById的方式来获取控件对象

image.png

image.png

经过反复测试,退出页面之后,LeakCanary没有报内存泄漏的吐司

4、总结

使用高德地图SDK,地图控件MapView,使用原生的findViewById的方式来获取

收起阅读 »

如何打造一款权限请求框架

原理通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。实现不可见的Fragmentinternal class EPermissionFragment : Fragment() { private var mCal...
继续阅读 »

原理

通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。

实现

不可见的Fragment

internal class EPermissionFragment : Fragment() {
private var mCallback: EPermissionCallback? = null

fun requestPermission(callback: EPermissionCallback, vararg permissions: String) {
mCallback = callback
// 申请权限
requestPermissions(permissions, CODE_REQUEST_PERMISSION)
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CODE_REQUEST_PERMISSION) {
val deniedList = ArrayList<String>()
val deniedForeverList = ArrayList<String>()
grantResults.forEachIndexed { index, result ->
// 提取权限申请结果
if (result != PackageManager.PERMISSION_GRANTED) {
val permission = permissions[index]
deniedList.add(permission)
// 是否拒绝且不再显示
if (!shouldShowRequestPermissionRationale(permission)) {
deniedForeverList.add(permission)
}
}
}
if (deniedList.isEmpty()) mCallback?.onAllGranted()
if (deniedList.isNotEmpty()) mCallback?.onDenied(deniedList)
if (deniedForeverList.isNotEmpty()) mCallback?.onDeniedForever(deniedForeverList)
}
}

override fun onDestroy() {
mCallback = null
super.onDestroy()
}
}

封装权限申请

// 扩展FragmentActivity
fun FragmentActivity.runWithPermissions(
vararg permissions: String,
onDenied: (ArrayList<String>) -> Unit = { _ -> },
onDeniedForever: (ArrayList<String>) -> Unit = { _ -> },
onAllGranted: () -> Unit = {}
) {
if (checkPermissions(*permissions)) {
onAllGranted()
return
}
// 添加一个不可见的Fragment
val isFragmentExist = supportFragmentManager.findFragmentByTag(EPermissionFragment.TAG)
val fragment = if (isFragmentExist != null) {
isFragmentExist as EPermissionFragment
} else {
val invisibleFragment = EPermissionFragment()
supportFragmentManager.beginTransaction().add(invisibleFragment, EPermissionFragment.TAG).commitNowAllowingStateLoss()
invisibleFragment
}
val callback = object : EPermissionCallback {
override fun onAllGranted() {
onAllGranted()
}

override fun onDenied(deniedList: ArrayList<String>) {
onDenied(deniedList)
}

override fun onDeniedForever(deniedForeverList: ArrayList<String>) {
onDeniedForever(deniedForeverList)
}
}
// 申请权限
fragment.requestPermission(callback, *permissions)
}

使用方法

项目build.gradle添加

allprojects {
repositories {
...
maven { url 'https://www.jitpack.io' }
}
}

模块build.gradle添加

dependencies {
implementation 'com.github.RickyHal:EPermission:$latest_version'
}

在Activity或者Fragment中直接调用

// 申请存储权限
runWithPermissions(
*EPermissions.STORAGE,
onDenied = {
Toast.makeText(this, "STORAGE permission denied", Toast.LENGTH_SHORT).show()
},
onDeniedForever = {
Toast.makeText(this, "STORAGE permission denied forever", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "STORAGE permission granted", Toast.LENGTH_SHORT).show()
}
)

也可以用下面这个简单的方法

runWithStoragePermission(onFailed = {
Toast.makeText(this, "SMS permission denied", Toast.LENGTH_SHORT).show()
}) {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

一次申请多个权限

runWithPermissions(*EPermissions.CAMERA, *EPermissions.STORAGE,
onDenied = { deniedList ->
Toast.makeText(this, "permission denied $deniedList", Toast.LENGTH_SHORT).show()
},
onDeniedForever = { deniedForeverList ->
Toast.makeText(this, "permission denied forever $deniedForeverList", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "Permission all granted", Toast.LENGTH_SHORT).show()
})

如果不需要处理失申请权限败的情况,也可以直接这样写

runWithStoragePermission {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

如果某些操作执行的时候,只能有权限才去执行,则可以使用下面的方法

doWhenPermissionGranted(*EPermissions.CAMERA){
Toast.makeText(this, "Do this when camera Permission is granted", Toast.LENGTH_SHORT).show()
}

检查权限

if (checkPermissions(*EPermissions.CAMERA)) {
Toast.makeText(this, "Camera Permission is granted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Camera Permission is not granted", Toast.LENGTH_SHORT).show()
}

收起阅读 »

Dart 2.14 发布,新增语言特性和共享标准 lint

Dart 2.14 的发布对 Apple Silicon 处理器提供了更好的支持,并新增了更多提升生产力的功能,例如通过代码样式分析捕获 lint 错误、更快的发布工具、更好的级联代码格式以及一些细小的语言特性更新。 Dart SDK 对 Apple Sili...
继续阅读 »

Dart 2.14 的发布对 Apple Silicon 处理器提供了更好的支持,并新增了更多提升生产力的功能,例如通过代码样式分析捕获 lint 错误、更快的发布工具、更好的级联代码格式以及一些细小的语言特性更新。


Dart SDK 对 Apple Silicon 支持


自从在 2020 年末 Apple 发布了新的 Apple Silicon 处理器以来, Dart SDK 一直致力于增加对该处理器上的 Native 执行支持。


现在从 Dart 2.14.1 正式增加了对 Apple Silicon 的支持,当 下载 MacOS 的 Dart SDK时,一定要选择 ARM64 选项,这里需要额外注意, Flutter SDK 中的 Dart SDK 还没有绑定这一项改进


本次更新支持在 Apple Silicon 上运行 SDK/Dart VM 本身,以及对 dart compile 编译后的可执行文件在 Apple Silicon 上运行的支持,由于 Dart 命令行工具使用原生 Apple Silicon ,因此它们的启动速度会快得多


Dart 和 Flutter 共享的标准 lint


开发人员通常会需要他们的代码遵循某种风格,其中许多规则不仅仅是风格偏好(如众所周知的制表符与空格的问题),还涵盖了可能导致错误或引入错误的编码风格。


比如 Dart 风格指南要求对所有控制流结构使用花括号,例如 if-else 语句,这可以防止经典的 dangling else 问题,也就是在多个嵌套的 if-else 语句上会存在解释歧义。



另一个例子是类型推断,虽然在声明具有初始值的变量时使用类型推断没有问题,但声明未初始化的变量 时指定类型很重要,因为这可以确保类型安全



良好代码风格的通常是通过代码审查来维持,但是通过在编写代码时,运行静态分析来强制执行规则通常会更有效得多。


在 Dart 中,这种静态分析规则是高度可配置的,Dart 提供了有数百条样式规则(也称为lints),有了如此丰富的选项,选择启用这些的规则时,一开始可能会有些不知所措。



配置支持: dart.dev/guides/lang…


lint 规则: dart.dev/tools/linte…



Dart 团队维护了一个 Dart 风格指南,它描述了 Dart 团队认为编写和设计 Dart 代码的最佳方式。



风格指南: dart.dev/guides/lang…



许多开发人员以及 pub.dev 站点评分引擎都使用了一套叫 Pedantic 的 lint 规则, Pedantic 起源于 Google 内部的 Dart 风格指南,由于历史原因它不同于一般的 Dart 风格指南,此外 Flutter 框架也从未使用过 Pedantic 的规则集,而是拥有自己的一套规范规则。


这听起来可能有点混乱,但是在本次的 2.14 发布中,Dart 团队很高兴地宣布现在拥有一套全新的 lint 集合来实现代码样式指南,并且 Dart 和 Flutter SDK 默认情况下将这些规则集用于新项目:




  • package:lints/core.yaml所有 Dart 代码都应遵循的 Dart 风格指南中的主要规则,pub.dev 评分引擎已更新为 lints/core 而不是 Pedantic。




  • package:lints/recommended.yaml :核心规则之外加上推荐规则,建议将它用于所有通用 Dart 代码。




  • package:flutter_lints/flutter.yaml:核心和推荐之外的 Flutter 特定推荐规则,这个集合推荐用于所有 Flutter 代码。




如果你已经存在现有的 Dart 或者 Flutter项目,强烈建议升级到这些新规则集,从 pedantic 升级只需几步:github.com/dart-lang/l…


Dart 格式化程序和级联


Dart 2.14 对 Dart 格式化程序如何使用级联 格式化代码进行了一些优化。


以前格式化程序在某些情况下出现一些令人困惑的格式,例如 doIt() 在这个例子中调用了什么?


var result = errorState ? foo : bad..doIt();

它看起来像是被 bad 调用 ,但实际上级联适是用于整个 ? 表达式上的,因此级联是在该表达式的结果上调用的,而不仅仅是在 false 子句上,新的格式化程序清晰地描述了这一点:


 var result = errorState ? foo : bad\
..doIt();

Dart 团队还大大提高了格式化包含级联的代码的速度;在协议缓冲区生成的 Dart 代码中,可以看到格式化速度提高了 10 倍。


Pub 支持忽略文件


目前当开发者将包发布pub.dev社区时,pub 会抓取该文件夹中的所有文件,但是会跳过隐藏文件(以 . 开头的文件)和.gitignore 文件。


Dart 2.14 中更新的 pub 命令支持新 .pubignore 文件,开发者可以在其中列出不想上传到 pub.dev 的文件,此文件使用与 .gitignore 文件相同的格式。



有关详细信息,请参阅包发布文档 dart.dev/tools/pub/p…



Pub and "dart test" 性能


虽然 pub 最常用于管理代码依赖项,但它还有第二个重要的用途:驱动工具。


比如 Dart 测试工具通过 dart test 命令运行,而它实际上只是 command pub run test:test 命令的包装, package:test 在调用该 test 入口点之前,pub 首先将其编译为可以更快运行的本机代码。


在 Dart 2.14 之前对 pubspec 的任何更改(包括与 package:test 无关的更改)都会使此测试构建无效,并且还会看到一堆这样的输出,其中包含“预编译可执行文件”:


$ dart test\
Precompiling executable... (11.6s)\
Precompiled test:test.\
00:01 +1: All tests passed!

在 Dart 2.14 中,pub 在构建步骤方面更加智能,让构建仅在版本更改时发生,此外还使用并行化改进了执行构建步骤的方式,因此可以完成得更快。


新的语言功能


Dart 2.14 还包含一些语言特性变化。


首先添加了一个新的 三重移位 运算符 ( >>>),这类似于现有的移位运算符 ( >>),但 >> 执行算术移位,>>> 执行逻辑或无符号移位,其中零位移入最高有效位,而不管被移位的数字是正数还是负数。


此次还删除了对类型参数的旧限制,该限制不允许使用泛型函数类型作为类型参数,以下所有内容在 2.14 之前都是无效的,但现在是允许的:


late List<T Function<T>(T)> idFunctions;
var callback = [<T>(T value) => value];
late S Function<S extends T Function<T>(T)>(S) f;

最后对注释类型进行了小幅调整,(诸如 @Deprecated 在 Dart 代码中常用来捕获元数据的注解)以前注解不能传递类型参数,因此 @TypeHelper<int>(42, "The meaning") 不允许使用诸如此类的代码,而现在此限制现已取消。


包和核心库更改


对核心 Dart 包和库进行了许多增强修改,包括:




  • dart:core: 添加了静态方法 hashhashAllhashAllUnordered




  • dart:coreDateTime 类现在可以更好地处理本地时间。




  • package:ffi:添加了对使用 arena 分配器管理内存的支持(示例)。Arenas 是一种基于区域的内存管理形式,一旦退出 arena/region 就会自动释放资源。




  • package:ffigen:现在支持从 C 类型定义生成 Dart 类型定义。




重大变化


Dart 2.14 还包含一些重大更改,预计这些变化只会影响一些特定的用例。


#46545:取消对 ECMAScript5 的支持


所有浏览器都支持最新的 ECMAScript 版本,因此两年前 Dart 就宣布 计划弃用对 ECMAScript 5 (ES5) 的支持,这使 Dart 能够利用最新 ECMAScript 中的改进并生成更小的输出,在 Dart 2.14 中,这项工作已经完成,Dart Web 编译器不再支持 ES5。因此不再支持较旧的浏览器(例如 IE11)


#46100:弃用 stagehand、dartfmt 和 dart2native


在 2020 年 10 月的 Dart 2.10 博客文章中 宣布了将所有 Dart CLI 开发人员工具组合成一个单一的组合dart工具(类似于该flutter工具),而现在 Dart 2.14 弃用了 dartfmtdart2native 命令,并停止使用 stagehand ,这些工具在统一在 dart-tool 中都有等价的替代品。


#45451:弃用 VM Native 扩展


Dart SDK 已弃用 Dart VM 的 Native 扩展,这是从 Dart 代码调用 Native 代码的旧机制,Dart FFI(外来函数接口)是当前用于此用例的新机制,正在积极发展 以使其功能更加强大且易于使用。


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

如何打造一款权限请求框架

原理 通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。 实现 不可见的Fragment internal class EPermissionFragment : Fragment() { private var ...
继续阅读 »

原理


通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。


实现


不可见的Fragment


internal class EPermissionFragment : Fragment() {
private var mCallback: EPermissionCallback? = null

fun requestPermission(callback: EPermissionCallback, vararg permissions: String) {
mCallback = callback
// 申请权限
requestPermissions(permissions, CODE_REQUEST_PERMISSION)
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CODE_REQUEST_PERMISSION) {
val deniedList = ArrayList<String>()
val deniedForeverList = ArrayList<String>()
grantResults.forEachIndexed { index, result ->
// 提取权限申请结果
if (result != PackageManager.PERMISSION_GRANTED) {
val permission = permissions[index]
deniedList.add(permission)
// 是否拒绝且不再显示
if (!shouldShowRequestPermissionRationale(permission)) {
deniedForeverList.add(permission)
}
}
}
if (deniedList.isEmpty()) mCallback?.onAllGranted()
if (deniedList.isNotEmpty()) mCallback?.onDenied(deniedList)
if (deniedForeverList.isNotEmpty()) mCallback?.onDeniedForever(deniedForeverList)
}
}

override fun onDestroy() {
mCallback = null
super.onDestroy()
}
}

封装权限申请


// 扩展FragmentActivity
fun FragmentActivity.runWithPermissions(
vararg permissions: String,
onDenied: (ArrayList<String>) -> Unit = { _ -> },
onDeniedForever: (ArrayList<String>) -> Unit = { _ -> },
onAllGranted: () -> Unit = {}
) {
if (checkPermissions(*permissions)) {
onAllGranted()
return
}
// 添加一个不可见的Fragment
val isFragmentExist = supportFragmentManager.findFragmentByTag(EPermissionFragment.TAG)
val fragment = if (isFragmentExist != null) {
isFragmentExist as EPermissionFragment
} else {
val invisibleFragment = EPermissionFragment()
supportFragmentManager.beginTransaction().add(invisibleFragment, EPermissionFragment.TAG).commitNowAllowingStateLoss()
invisibleFragment
}
val callback = object : EPermissionCallback {
override fun onAllGranted() {
onAllGranted()
}

override fun onDenied(deniedList: ArrayList<String>) {
onDenied(deniedList)
}

override fun onDeniedForever(deniedForeverList: ArrayList<String>) {
onDeniedForever(deniedForeverList)
}
}
// 申请权限
fragment.requestPermission(callback, *permissions)
}

使用方法


项目build.gradle添加


allprojects {
repositories {
...
maven { url 'https://www.jitpack.io' }
}
}

模块build.gradle添加


dependencies {
implementation 'com.github.RickyHal:EPermission:$latest_version'
}

在Activity或者Fragment中直接调用


// 申请存储权限
runWithPermissions(
*EPermissions.STORAGE,
onDenied = {
Toast.makeText(this, "STORAGE permission denied", Toast.LENGTH_SHORT).show()
},
onDeniedForever = {
Toast.makeText(this, "STORAGE permission denied forever", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "STORAGE permission granted", Toast.LENGTH_SHORT).show()
}
)

也可以用下面这个简单的方法


runWithStoragePermission(onFailed = {
Toast.makeText(this, "SMS permission denied", Toast.LENGTH_SHORT).show()
}) {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

一次申请多个权限


runWithPermissions(*EPermissions.CAMERA, *EPermissions.STORAGE,
onDenied = { deniedList ->
Toast.makeText(this, "permission denied $deniedList", Toast.LENGTH_SHORT).show()
},
onDeniedForever = { deniedForeverList ->
Toast.makeText(this, "permission denied forever $deniedForeverList", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "Permission all granted", Toast.LENGTH_SHORT).show()
})

如果不需要处理失申请权限败的情况,也可以直接这样写


runWithStoragePermission {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

如果某些操作执行的时候,只能有权限才去执行,则可以使用下面的方法


doWhenPermissionGranted(*EPermissions.CAMERA){
Toast.makeText(this, "Do this when camera Permission is granted", Toast.LENGTH_SHORT).show()
}

检查权限


if (checkPermissions(*EPermissions.CAMERA)) {
Toast.makeText(this, "Camera Permission is granted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Camera Permission is not granted", Toast.LENGTH_SHORT).show()
}

GitHub传送门


作者:应用软件开发爱好者
链接:https://juejin.cn/post/7005913659394228232
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Frida笔记 - Android 篇 (一)

前言 相信不少小伙伴对Xposed、Cydia Substrate、Frida等hook工具都有所了解, 并且用在了自己的工作中, 本文主要分享Frida的环境配置以及基本使用, 以及相关功能在日常开发调试带来的帮助 配置Frida的环境 Frida的环境安装...
继续阅读 »

前言


相信不少小伙伴对Xposed、Cydia Substrate、Frida等hook工具都有所了解, 并且用在了自己的工作中, 本文主要分享Frida的环境配置以及基本使用, 以及相关功能在日常开发调试带来的帮助


配置Frida的环境


Frida的环境安装可以参考官方文档, 或者参考网上分享的实践, 使用较为稳定的特定版本


# 通过pip3安装Frida的CLI工具
pip3 install frida-tools
# 安装的frida版本
frida --version
# 本机目前使用的15.0.8的frida版本
# 在https://github.com/frida/frida/releases下载对应的server版本frida-server-15.0.8-android-arm64.xz
# unxz 解压缩
unxz frida-server-15.0.8-android-arm64.xz
adb root
adb push frida-server-15.0.8-android-arm64 /data/locl/tmp/
adb shell
chmod 755 /data/local/tmp/frida-server-15.0.8-android-arm64
/data/local/tmp/frida-server-15.0.8-android-arm64 &
# 打印已安装程序及包名
frida-ps -Uai

基本使用




  • Frida的开发环境, 可以参考作者在github上的exmaple


    下载完成后通过vscode打开


    git clone git://github.com/oleavr/frida-agent-example.git
    npm install




  • 配置完成后, 使用对应函数就会有相应代码提示及函数说明






  • JavaScript API可以参考官方文档说明, 了解基本使用


Hook类的构造函数


// frida -U --no-pause -f com.gio.test.three -l agent/constructor.js

function main() {
Java.perform(function () {
Java.use(
"com.growingio.android.sdk.autotrack.AutotrackConfiguration"
).$init.overload("java.lang.String", "java.lang.String").implementation =
function (projectId, urlScheme) {
// 调用原函数
var result = this.$init(projectId, urlScheme);
// 打印参数
console.log("projectId, urlScheme: ", projectId, urlScheme);
return result;
};
});
}

setImmediate(main);

Hook类的普通函数


// frida -U --no-pause -f com.gio.test.three -l agent/function.js

function main() {
Java.perform(function () {
Java.use(
"com.growingio.android.sdk.CoreConfiguration"
).setDebugEnabled.implementation = function (enabled) {
console.log("enabled: ", enabled);
// 直接返回原函数执行结果
return this.setDebugEnabled(enabled);
};
});
}

setImmediate(main);

修改类/实例参数


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/instance.js
function main() {
Java.perform(function () {
Java.choose("com.growingio.android.sdk.autotrack.AutotrackConfiguration", {
onMatch: function (instance) {
console.log("instance.mProjectId", instance.mProjectId.value);
console.log("instance.mUrlScheme", instance.mUrlScheme.value);
// 修改变量时通过赋值, 如果变量与函数同名, 需要在变量前加'_', 如: _mProjectId
instance.mProjectId.value = "t-bfc5d6a3693a110d";
instance.mUrlScheme.value = "t-growing.d80871b41ef40518";
},
onComplete: function () {},
});
});
}

setImmediate(main);

构造数组


frida -U -n demos -l agent/array.js

function main() {
Java.perform(function () {
// 构造byte数组
var byteArray = Java.array("byte", [0x46, 0x72, 0x69, 0x64, 0x61]);
// 输出为: Frida
console.log(Java.use("java.lang.String").$new(byteArray));

// 构造char数组
var charArray = Java.array("char", ["F", "r", "i", "d", "a"]);
console.log(Java.use("java.lang.String").$new(charArray));
});
}

setImmediate(main);

静态函数主动调用


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/staticFunction.js

function main() {
Java.perform(function () {
// setWebContentsDebuggingEnabled 需要在主线程调用
Java.scheduleOnMainThread(function () {
console.log("isMainThread", Java.isMainThread());
// 主动触发静态函数调用, 允许WebView调试
Java.use("android.webkit.WebView").setWebContentsDebuggingEnabled(true);
});
});
}

setImmediate(main);

动态函数主动调用


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/dynamicFunction.js

function main() {
Java.perform(function () {
Java.choose("com.growingio.android.sdk.autotrack.AutotrackConfiguration", {
onMatch: function (instance) {
// 主动触发动态函数调用
console.log("instance.isDebugEnabled: ", instance.isDebugEnabled());
console.log("instance.getChannel: ", instance.getChannel());
},
onComplete: function () {},
});
});
}

setImmediate(main);

定义一个类


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/registerClass.js

function main() {
Java.perform(function () {
var TestRunnable = Java.registerClass({
name: "com.example.TestRunnable",
// 实现接口
implements: [Java.use("java.lang.Runnable")],
// 成员变量
fields: {
testFields: "java.lang.String",
},
methods: {
// 构造函数
$init: [
{
returnType: "void",
argumentTypes: ["java.lang.String"],
implementation: function (testFields) {
// 调用父类构造函数
this.$super.$init();
// 给成员变量赋值
this.testFields.value = testFields;
console.log("$init: ", this.testFields.value);
},
},
],
// 方法
run: [
{
returnType: "void",
implementation: function () {
console.log(
"testFields: ",
this.testFields.value
);
},
},
],
},
});

TestRunnable.$new("simple test").run();
});
}

setImmediate(main);

打印函数调用堆栈


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/printStackTrace.js

function main() {
Java.perform(function () {
Java.use(
"com.growingio.android.sdk.autotrack.click.ViewClickInjector"
).viewOnClick.overload(
"android.view.View$OnClickListener",
"android.view.View"
).implementation = function (listener, view) {
// 打印当前调用堆栈信息
console.log(
Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Throwable").$new()
)
);
return this.viewOnClick(listener, view);
};
});
}

setImmediate(main);

枚举classLoader


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/enumerateClassLoaders.js
// 适用于加固的应用, 找到对应的classloader
// 通常直接在application.attach.overload('android.content.Context').implementation获取context对应的classloader

function main() {
Java.perform(function () {
Java.enumerateClassLoaders({
onMatch: function (loader) {
try {
// 判断该loader中是否存在我们需要hook的类
if (loader.findClass("com.growingio.android.sdk.CoreConfiguration")) {
console.log("found loader:", loader);
Java.classFactory.loader = loader;
}
} catch (error) {
console.log("found error: ", error);
console.log("failed loader: ", loader);
}
},
onComplete: function () {
console.log("enum completed!");
},
});
console.log(
Java.use("com.growingio.android.sdk.CoreConfiguration").$className
);
});
}

setImmediate(main);

枚举类


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/enumerateLoadedClasses.js

function main() {
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name, handle) {
// 判断是否是我们要查找的类
if (name.toString() == "com.growingio.android.sdk.CoreConfiguration") {
console.log("name, handle", name, handle);
Java.use(name).isDebugEnabled.implementation = function () {
return true;
};
}
},
onComplete: function () {},
});
});
}

setImmediate(main);

加载外部dex并通过gson打印对象


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/printObject.js
// 通过d8将 gson.jar 转为 classes.dex
// ~/Library/Android/sdk/build-tools/30.0.3/d8 --lib ~/Library/Android/sdk/platforms/android-30/android.jar gson-2.8.8.jar
// 如果SDK中已经有了, 可以直接使用Java.use加载
// adb push classes.dex /data/local/tmp

function main() {
Java.perform(function () {
Java.choose("com.growingio.android.sdk.autotrack.AutotrackConfiguration", {
onMatch: function (instance) {
// 加载外部dex
Java.openClassFile("/data/local/tmp/classes.dex").load();
var Gson = Java.use("com.google.gson.Gson");
// JSON.stringify: "<instance: com.growingio.android.sdk.autotrack.AutotrackConfiguration>"
console.log("JSON.stringify: ", JSON.stringify(instance));
// Gson.$new().toJson: {"mImpressionScale":0.0,"mCellularDataLimit":10,"mDataCollectionEnabled":true,"mDataCollectionServerHost":"http://api.growingio.com","mDataUploadInterval":15,"mDebugEnabled":true,"mOaidEnabled":false,"mProjectId":"bfc5d6a3693a110d","mSessionInterval":30,"mUploadExceptionEnabled":false,"mUrlScheme":"growing.d80871b41ef40518"}
console.log("Gson.$new().toJson: ", Gson.$new().toJson(instance));
},
onComplete: function () {},
});
});
}

setImmediate(main);

使用场景




  1. 绕过证书绑定、校验, 进行埋点请求验证




  2. SDK开发过程中, 一般客户反馈问题都需要使用客户的app进行问题的复现及排查, 此时通过frida获取运行时特定函数的参数信息及返回信息, 能有效缩短与客户的沟通时间, 该场景使用objection最为方便




  3. 新客户在集成前, 希望看到SDK能够提供的效果, 通过frida加载dex并完成初始化, 可以提前发现兼容性问题




  4. 当碰到集成早期版本SDK的应用反馈异常, 通过类似Tinker热修复的思想替换SDK验证是否已经在当前版本修复




  5. 开放SDK相关函数远程rpc调用, 用于测试埋点的协议等场景




外链地址




  1. Frida官方文档: frida.re/docs/instal…




  2. Frida作者提供的example github地址: github.com/oleavr/frid…




  3. JavaScript API官方文档: frida.re/docs/javasc…




  4. 功能介绍中所使用demo: github.com/growingio/g…




  5. r0capture 安卓应用层通杀脚本 github地址: github.com/r0ysue/r0ca…




  6. objection github地址: github.com/sensepost/o…


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

正式版即将到来 | Android 12 Beta 5 现已发布

作者 / Dave Burke, VP of Engineering距离 Android 12 的正式发布只有几周时间了!新版 Android 系统的润色已经进入收尾阶段,今天我们带来最后一个 Beta 版更新,供大家进行测试和开发。对于开发者来说,是时候让自...
继续阅读 »

作者 / Dave Burke, VP of Engineering

距离 Android 12 的正式发布只有几周时间了!新版 Android 系统的润色已经进入收尾阶段,今天我们带来最后一个 Beta 版更新,供大家进行测试和开发。对于开发者来说,是时候让自己的应用做好准备了!

今天,您就可以在 Pixel 设备上 (包括 5G 版 Pixel 5a) 通过 OTA 更新 开始体验 Android 12 Beta 5。如果您之前已经加入了 Beta 测试,则会自动获得更新。您还可以在我们的设备制造商合作伙伴的若干指定设备上体验 Android 12 Beta 5,具体请查看 这里

有关 Android 12 的详细信息以及如何开始开发,请访问 Android 12 开发者网站

请大家关注即将发布的 Android 12 正式版的更多信息!

Beta 5 更新一览

今天的更新包含适用于 Pixel 和其他设备以及 Android 模拟器的 Android 12 发布候选版本。我们已经 在 Beta 4 抵达平台稳定性里程碑,所有面向应用的接口都已最终确定,包括 SDK 和 NDK API、面向应用的系统行为,以及非 SDK 接口限制都已确定。除此之外,Beta 5 还带来了最新的修复和优化,为您提供了完成测试所需的一切。

让您的应用做好准备

随着 Android 12 正式版的临近,我们要求所有的应用和游戏开发者完成最终兼容性测试,并在正式版到来之前发布应用和游戏的兼容性更新。对于所有 SDK、开发库、工具和游戏引擎的开发者来说,尽快发布兼容性更新更为重要: 在获得来自您的更新之前,您的下游应用和游戏开发者的工作可能会受阻。

要测试应用的兼容性,只需在运行 Android 12 Beta 5 的设备上安装您的应用,并测试应用的所有流程,找出功能或 UI 上暴露的问题。请通过 行为变更清单 (针对所有应用) 来找出可能影响应用的潜在变更,从而确定测试重点。

这里列出一些需要注意的变更:

  • 隐私中心 - 这是系统设置 (Settings) 中新加入的一个界面,可以让用户看到哪些应用在访问哪些类型的数据,以及何时访问。如果需要,用户可以对权限进行调整,并从应用获知其访问数据的详细原因。请访问 官方文档 了解详细信息。

  • 麦克风和摄像头指示标志 - 当应用正在使用摄像头或麦克风时,Android 12 会在状态栏中显示指示图标。请访问 官方文档 了解详细信息。

  • 麦克风和摄像头全局开关 - 快速设置 (Quick Settings) 中新增的全局开关功能,可以让用户立即禁用所有应用的麦克风和摄像头访问权限。请访问 官方文档 了解详细信息。

  • 剪贴板访问通知 - 当应用从剪贴板中读取数据时,系统会提醒用户。请访问 官方文档 了解详细信息。

  • 过度滚动拉伸效果 - 过度滚动时,新的 "拉伸" 效果在全系统范围内取代了以前的发光效果。请访问 官方文档 了解详细信息。

  • 应用启动画面 - Android 12 在启动应用时会使用全新的启动动画。请访问 官方文档 了解详细信息。

  • Keygen 变更 - 我们移除了一些被废弃的 BouncyCastle 加密算法,转而使用 Conscrypt 实现。如果您的应用使用 512 位的 AES 密钥,您需要将其改为 Conscrypt 支持的标准长度。请访问 官方文档 了解详细信息。

别忘了测试应用里的开发库和 SDK 的兼容性。如果您发现 SDK 的问题,请尝试更新到最新版本的 SDK ,或向其开发者寻求帮助。

一旦您发布了当前应用的兼容版本,就可以 开始着手升级 应用的 targetSdkVersion。请查阅 行为变更清单 (针对面向 Android 12 的应用),并使用 兼容性框架工具 来快速检测问题。

探索新功能和 API

Android 12 拥有大量的新功能,可以帮助您为用户构建良好的体验。请回顾我们 在 Beta 2 时所做的介绍,以及 Google I/O 上的 Android 12 演讲。要了解所有新功能和 API 的完整细节,请访问 Android 12 开发者网站

另外别忘了试用 Android Studio Arctic Fox 进行 Android 12 的开发和测试。我们已经添加了可以帮助您发现代码中可能受到 Android 12 变更影响的 lint 检查,如对启动画面的自定义声明、请求精细位置的粗略位置许可、媒体格式,以及高传感器采样率权限等。您可以 下载 并 配置 最新版本的 Android Studio 来尝试这些新功能。

即刻开始体验 Android 12

不论您是想体验 Android 12 的功能、测试应用还是 提交反馈,都可以从这次的 Beta 5 开始。只需 使用支持的 Pixel 设备注册参加测试,即可通过无线 (OTA) 方式获得更新。要开始进行开发,请先安装并设置 Android 12 SDK

您也可以在参与 Android 12 开发者预览计划的设备制造商的设备上体验 Android 12 Beta 5,请访问 developer.android.google.cn/about/versi… 查看合作伙伴的完整列表。您也可以通过 Android GSI 映像 在更多设备上进行更广泛的测试。如果您没有合适的设备,也可以在 Android 模拟器 上进行测试。Beta 5 也适用于 Android TV,您可以查看最新的功能,测试自己的应用,并尝试全新的 Google TV 体验。

下一步

Android 12 会在接下来几周内正式发布,请大家保持关注!在此期间,欢迎继续通过问题反馈页面向我们 分享您的使用反馈,包括 平台问题应用兼容性问题 以及 第三方 SDK 问题

再次感谢我们的开发者社区为打造 Android 12 做出的巨大贡献!大家分享了 数以千计的问题报告 和洞察,帮助我们调整 API、改进功能、修复重大问题,从而为用户和开发者们打造出更好的平台。

收起阅读 »

Java多线程

运行环境与工具jdk1.8.0macOS 11.4IDEA操作系统可以在同一时刻运行多个程序。例如一边播放音乐,一边下载文件和浏览网页。操作系统将cpu的时间片分配给每一个进程,给人一种并行处理的感觉。一个多线程程序可以同时执行多个任务。通常,每一个任务称为一...
继续阅读 »

运行环境与工具

  • jdk1.8.0
  • macOS 11.4
  • IDEA

操作系统可以在同一时刻运行多个程序。例如一边播放音乐,一边下载文件和浏览网页。操作系统将cpu的时间片分配给每一个进程,给人一种并行处理的感觉。

一个多线程程序可以同时执行多个任务。通常,每一个任务称为一个线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序成为多线程程序(multithreaded)。

多进程多线程有哪些区别呢?

本质区别在于进程每个进程有自己的一整套变量,而线程则共享数据。 线程比进程更轻量级,创建、销毁一个线程比启动新进程的开销要小。

实际应用中,多线程非常有用。例如应用一边处理用户的输入指令,一遍联网获取数据。

本文我们介绍Java中的Thread类。

Thread

Thread类属于java.lang包。

要创建一个线程很简单,新建一个Thread对象,并传入一个Runnable,实现run()方法。 调用start()方法启动线程。

    Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("rustfisher said: hello");
}
});
t1.start();

Java lambda

    Thread t1 = new Thread(() -> System.out.println("rustfisher said: hello"));
t1.start();

不要直接调用run()方法。 直接调用run()方法不会启动新的线程,而是直接在当前线程执行任务。

我们来看一个使用了Thread.sleep()方法的例子。

Thread t1 = new Thread(() -> {
for (String a : "rustfisher said: hello".split("")) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(a);
}
});
t1.start();

sleep(int)方法会让线程睡眠一个指定的时间(单位毫秒)。并且需要try-catch捕获InterruptedException异常。

中断线程

run()方法执行完最后一条语句后,或者return,或者出现了未捕获的异常,线程将会终止。

使用Thread的interrupt方法也可以终止线程。调用interrupt方法时,会修改线程的中断状态为true。 用isInterrupted()可以查看线程的中断状态。

但如果线程被阻塞了,就没法检测中断状态。当在一个被阻塞的线程(sleep或者wait)上调用interrupt方法,阻塞调用将会被InterruptedException中断。

被中断的线程可以决定如何响应中断。可以简单地将中断作为一个终止请求。比如我们主动捕获InterruptedException

Thread t2 = new Thread(() -> {
for (String a : "rustfisher said: hello".split("")) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println("被中断 退出线程");
return;
}
System.out.print(a);
}
});
t2.start();

new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.interrupt();
}).start();

上面这个小例子展示了用interrupt()来中断线程t2。而线程t2run()方法中捕获InterruptedException后,可以进行自己的处理。

线程的状态

线程有6种状态,用枚举类State来表示:

  • NEW(新创建)
  • RUNNABLE(可运行)
  • BLOCKED(被阻塞)
  • WAITING(等待)
  • TIMED_WAITING(计时等待)
  • TERMINATED(被终止)

getState()方法可以获取到线程的状态。

新创建线程

new一个线程的时候,线程还没开始运行,此时是NEW(新创建)状态。在线程可以运行前,还有一些工作要做。

可运行线程

一旦调用start()方法,线程处于RUNNABLE(可运行)状态。调用start()后并不保证线程会立刻运行,而是要看操作系统的安排。

一个线程开始运行后,它不一定时刻处于运行状态。操作系统可以让其他线程获得运行机会。一个可运行的线程可能正在运行也可能没在运行。

被阻塞和等待

线程处于被阻塞和等待状态时,它暂时不活动。不运行代码,且只消耗最少的资源。直到线程调度器重新激活它。

  • 一个线程试图获取一个内部的对象锁,而该锁被其他线程持有,则这个线程进入阻塞状态。当这个锁被释放,并且线程调度器允许这个线程持有它,该线程变成非阻塞状态。
  • 当线程等待另一个线程通知调度器,它自己进入等待状态。例如调用Object.wait()或者Thread.join()方法。
  • 带有超时参数的方法可让线程进入超时等待状态。例如Thread.sleep()Object.wait(long)Thread.join(long)Lock.tryLock(long time, TimeUnit unit)

thread-state.png

上面这个图展示了状态之间的切换。

被终止

终止的原因:

  • run方法正常退出
  • 出现了没有捕获的异常而终止了run方法

线程属性

线程优先级,守护线程,线程组以及处理未捕获异常的处理器。

线程优先级

Java中每个线程都有一个优先级。默认情况下,线程继承它的父线程的优先级。 可用setPriority(int)方法设置优先级。优先级最大为MAX_PRIORITY = 10,最小为MIN_PRIORITY = 1,普通的是NORM_PRIORITY = 5。 线程调度器有机会选新线程是,会优先选高优先级的线程。

守护线程

调用setDaemon(true)可以切换为守护线程(daemon thread)。守护线程的用途是为其他线程提供服务。例如计时线程。 当只剩下守护线程是,虚拟机就退出了。

守护线程不应该去访问固有资源,如文件和数据库。

未捕获异常处理器

run()方法里抛出一个未捕获异常,在线程死亡前,异常被传递到一个用于未捕获异常的处理器。 要使用这个处理器,需要实现接口Thread.UncaughtExceptionHandler,并且用setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler)方法把它交给线程。

Thread t3 = new Thread(() -> {
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
return;
}
int x = 0, y = 3;
int z = y / x; // 故意弄一个异常
});
t3.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println(t + "有未捕获异常");
e.printStackTrace();
}
});
t3.start();

运行后,run()方法里抛出ArithmeticException异常

Thread[Thread-0,5,main]有未捕获异常
java.lang.ArithmeticException: / by zero
at Main.lambda$main$0(Main.java:15)
at java.lang.Thread.run(Thread.java:748)

也可以用静态方法Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler)给所有的线程安装一个默认处理器。可以在这个默认处理器里做一些工作,例如记录日志。

ThreadGroup代表着一组线程。也可以包含另外的线程组。

ThreadGroup类实现了UncaughtExceptionHandler接口。它的uncaughtException(Thread t, Throwable e)方法会有如下操作

  • 如果该线程组有父线程组,则父线程组的uncaughtException被调用。
  • 否则,如果Thread.getDefaultUncaughtExceptionHandler()返回一个非空处理器,则使用这个处理器。
  • 否则,如果抛出的ThrowableThreadDeath对象,就什么也不做。
  • 否则,线程的名字和Throwable的栈踪迹输出到System.err上。
收起阅读 »

Android compose自定义布局

开新坑了,compose自定义布局。基础知识不说了,直接上正题。我们知道,在views体系下,自定义布局需要view集成viewgroup重写onMeasure、onLayout方法,在compse中,是使用Layout的compose方法,结构如下:以一个自...
继续阅读 »

开新坑了,compose自定义布局。基础知识不说了,直接上正题。

我们知道,在views体系下,自定义布局需要view集成viewgroup重写onMeasure、onLayout方法,在compse中,是使用Layout的compose方法,结构如下:

以一个自定义Column为例:

1、首先我们定义自己的cpmpose函数

@Composablefun 
MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
)

这个方法包含最基础的两个入参,一个是修饰符modifier,一个是@composable注解的lamda表达式作为子项的内容

2、看看具体函数体的操作

@Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// Don't constrain child views further, measure them with given constraints
// List of measured children
val placeables = measurables.map { measurable ->
// Measure each children
measurable.measure(constraints)
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
// Track the y co-ord we have placed children up to
var yPosition = 0
// Place children in the parent layout
placeables.forEach { placeable ->
// Position item on the screen
placeable.placeRelative(x = 0, y = yPosition)
// Record the y co-ord placed up to
yPosition += placeable.height
}
}
}
}

代码是从官网抄的,正确性就不用说了,具体分析一下作用。

1、在函数体中使用已经定义好的Layout方法。(这个有点类似 类 的继承,compose中所有组件定义都是使用方法,没有类中的子类父类的概念,如果想要做一些统一的封装操作会比较麻烦,可以使用这种方法,函数体内去执行另一个封装好的函数,而函数最后一个参数使用@composable注解的lamda)Layout方法把修饰符和content接收,回调中发送的是 measurables和constraints.从名字就可以猜出这两个参数的作用

  • measurables:可测量元素,就是传进来的子元素
  • constraints:父类约束条件

Layout函数的lamda来自于第三个入参MeasurePolicy,这是一个接口,上面两个参数就来自于这个接口的回调:

fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult

2、使用map函数遍历measurables,每一个measurable调用measure并把constraint传入,相当于给每个子控件根据父控件的约束进行测量,类似于views体系下面的measure,得到palceables。

3、调用layout方法(注意小写,这是单独放置一个控件的方法,是单个可组合项的修饰,具体下面会再讲),layout(width,heigiht)传入布局的宽和高,在layout方法lamda中,对每一个placeable调用place方法(有几个类似的,这里使用placeRelative),传入相应坐标,完成子view的布局

到这里一个自定义布局就完成了,其实和views体系下面很像,也是相似的两步:

1、测量每个子view在父view约束下的大小

2、遍历子view,使用layout方法将每个view放在正确的位置上。

大同小异大同小异

3、关于layout(注意是小写的)

先抄一段官网的说明:

您可以使用 layout 修饰符来修改元素的测量和布局方式,layout 是一个 lambda;它的参数包括您可以测量的元素(以 measurable 的形式传递)以及该可组合项的传入约束条件(以 constraints 的形式传递)

再抄一段代码:

fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp) =
layout { measurable, constraints ->
// Measure the composable
val placeable = measurable.measure(constraints)
// Check the composable has a first baseline
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
// Height of the composable with padding - first baseline
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
val height = placeable.height + placeableY
layout(placeable.width, height) {
// Where the composable gets placed
placeable.placeRelative(0, placeableY)
}}

这个是官网上面修改text baseline top padding的方法。具体内容就不说了,可以看到,是定义了一个modifier的扩展函数,回调参数是一个measurable,内部具体还是调用layout,大同小异大同小异。

到这里自定义布局就结束了,自定义布局难点主要还是在子view在父view约束下面的布局逻辑,也可以看到其实这个移植以前views下面的自定义布局应该是比较容易的,把坐标计算逻辑抽离,然后就可以轻松完成移植了。(另外我从这里还发现了compose下面怎么实现类似以前类的继承,那就是活用fun中最后一个lamda参数,由于kotlin语法的关系,容易把lamda看作是函数体,其实在fun中只是调用了一个fun,函数具体执行都被隐藏了起来)

收起阅读 »

Android 非Root设备下调试so

准备工作手机:Google Pixel 3 Android 11, API 30工具:IDA 7.0、Android Studio电脑系统:win10写一个C++ demo稍微改动下代码,点击Hello World调用c++class MainActivity...
继续阅读 »

准备工作

  1. 手机:Google Pixel 3 Android 11, API 30
  2. 工具:IDA 7.0、Android Studio
  3. 电脑系统:win10

写一个C++ demo

image.png

稍微改动下代码,点击Hello World调用c++

class MainActivity : AppCompatActivity() {

@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// Example of a call to a native method
sample_text.setOnClickListener {
sample_text.text = stringFromJNI() + intFromJNI()
}
}

/**
* A native method that is implemented by the 'native-lib' native library,
* which is packaged with this application.
*/

private external fun stringFromJNI(): String

private external fun intFromJNI(): Int

companion object {
// Used to load the 'native-lib' library on application startup.
init {
System.loadLibrary("native-lib")
}
}
}

native-lib.cpp代码

#include <jni.h>
#include <string>

int test_add();

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_testcpp_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++ ";
return env->NewStringUTF(hello.c_str());
}

extern "C" JNIEXPORT jint JNICALL
Java_com_example_testcpp_MainActivity_intFromJNI(JNIEnv *env, jobject thiz) {
int ret = test_add();
return (jint)ret;
}

int test_add() {
return 1 + 1;
}

运行效果(左),点击后(右)

 

将IDA目录dbgsrv下的android_server64放到Android应用目录下

这里要注意看手机是多少位的,我是64位就用64位的android_server64

image.png

通过Android Studio的Device File Explorer upload到对应的应用目录下,这个目录没有root权限通过adb是不能push文件进去 image.png

打开终端进入adb shell启动android_server

C:\Users\Administrator\Desktop\fby>adb shell
* daemon not running; starting now at tcp:5037
* daemon started successfully
blueline:/ $

这里有个关键步骤,如果直接进入到/data/data/com.example.testcpp是没有权限的,也就不能启动android_server

blueline:/ $ cd data/data/com.example.testcpp
/system/bin/sh: cd: /data/data/com.example.testcpp: Permission denied

执行run-as com.example.testcpp,进入到了应用目录,ls看下当前目录,然后启动android_server

2|blueline:/ $ run-as com.example.testcpp
blueline:/data/user/0/com.example.testcpp $ ls
android_server64 cache code_cache databases files no_backup shared_prefs
blueline:/data/user/0/com.example.testcpp $ ./android_server64
IDA Android 64-bit remote debug server(ST) v1.22. Hex-Rays (c) 2004-2017
Listening on 0.0.0.0:23946...

再打开一个终端,转发端口23946

C:\Users\Administrator>adb forward tcp:23946 tcp:23946
23946

打开IDA64 attch进程

image.png

image.png

image.png

点击ok进入到调试页面,这里已经进入断点,按F9让程序执行

image.png

在Modules窗口找到自己写的那个native-lib.so,下断点

image.png

image.png

app上点击Hello World,进入到断点

image.png


收起阅读 »

Android消息队列原理

本文章是对任玉刚前辈的《Android开发艺术》一书中的第10章“Android的消息机制”的简单理解,不足之处请多多指正。Handler是Android机制的上层接口,它的运行要依靠MessQueue和Looper。Handler的使用想必大家都很了解,一般...
继续阅读 »

本文章是对任玉刚前辈的《Android开发艺术》一书中的第10章“Android的消息机制”的简单理解,不足之处请多多指正。

Handler是Android机制的上层接口,它的运行要依靠MessQueue和Looper。Handler的使用想必大家都很了解,一般在开发中,我们会在子线程中执行耗时的操作,将操作的结果通过Handler发送给主线程,主线程用这个结果来执行UI的操作,这是我们最常见的用法,Android默认只有主线程才能更新UI,这是因为每次更新UI时都会做UI验证操作,Android在UI验证操作时首先会检查当前线程是否为主线程,如果不是就会报出异常,那为什么Android要规定只有主线程才能操作UI呢?这是因为Android的UI控件并不是线程安全的,在高并发状态下当有多个线程访问一个UI时就会出错,加锁又会让UI效率变慢。MessageQueue是消息队列,它以队列的形式对外提供插入和删除消息的工作,但其本身的数据结构并不是一个队列而是一个单向链表。Looper可以理解为消息循环处理器,它会以无限循环的方式去查找MessageQueue中的消息,如果没有消息就会一直等待。Android消息队列的Handler、MessageQueue、Looper作为一个整体,不可分割,那么接下来就对这几个模板分开探索一下。

MessageQueue

MessageQueue即消息队列,主要包含插入(enqueueMessage方法)和读取(next方法),读取一条消息的同时也会把这条消息从队列中删除,消息队列由单链表实现,因为单链表对插入和删除操作有很好的优势。

enqueueMessage方法
    boolean enqueueMessage(Message msg, long when) {
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
synchronized (this) {
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}

if (mQuitting) {
IllegalStateException e = new IllegalStateException(
msg.target + " sending message to a Handler on a dead thread");
Log.w(TAG, e.getMessage(), e);
msg.recycle();
return false;
}
msg.markInUse();
msg.when = when;
Message p = mMessages;
boolean needWake;
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
// Inserted within the middle of the queue. Usually we don't have to wake
// up the event queue unless there is a barrier at the head of the queue
// and the message is the earliest asynchronous message in the queue.
needWake = mBlocked && p.target == null && msg.isAsynchronous();
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}
// We can assume mPtr != 0 because mQuitting is false.
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}

当新消息到来时,将消息插入链表中。

next方法
    Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.
final long ptr = mPtr;
if (ptr == 0) {
return null;
}
int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);
synchronized (this) {
// Try to retrieve the next message. Return if found.
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}

// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}
if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler
boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}
// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

next方法会一直阻塞,当有消息到来时,next方法会返回这条消息,并把这条消息从MessageQueue中删除。

Looper

Looper是一个消息循环处理器,在一个线程中,要打开一个Looper才能接收到其他线程发来的Message。在一个线程中,Looper本身是默认不存在的,只有子线程会初始化一个Looper,这就是为什么主线程可以默认使用Handler的原因了。 我们一般用Looper.prepare()方法给线程创建一个Looper,然后用Looper.loop()方法开启消息循环,当然Looper也可以退出,有quit()方法和quitSafely()方法,quit()方法会立即退出这个消息循环,而quitSafely()会将消息队列中的消息处理完再退出。子线程在开启消息循环处理完消息之后一定要退出,否则子线程会一直等待下去,消耗资源。

Looper.loop()方法
    public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
if (me.mInLoop) {
Slog.w(TAG, "Loop again would have the queued messages be executed"
+ " before this one completed.");
}

me.mInLoop = true;
final MessageQueue queue = me.mQueue;
// Make sure the identity of this thread is that of the local process,
// and keep track of what that identity token actually is.
Binder.clearCallingIdentity();
final long ident = Binder.clearCallingIdentity();

// Allow overriding a threshold with a system prop. e.g.
// adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
final int thresholdOverride =
SystemProperties.getInt("log.looper."
+ Process.myUid() + "."
+ Thread.currentThread().getName()
+ ".slow", 0);

boolean slowDeliveryDetected = false;
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}

// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// Make sure the observer won't change while processing a transaction.
final Observer observer = sObserver;
final long traceTag = me.mTraceTag;
long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
if (thresholdOverride > 0) {
slowDispatchThresholdMs = thresholdOverride;
slowDeliveryThresholdMs = thresholdOverride;
}
final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);

final boolean needStartTime = logSlowDelivery || logSlowDispatch;
final boolean needEndTime = logSlowDispatch;

if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
}
final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
final long dispatchEnd;
Object token = null;
if (observer != null) {
token = observer.messageDispatchStarting();
}
long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
try {
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
if (logSlowDelivery) {
if (slowDeliveryDetected) {
if ((dispatchStart - msg.when) <= 10) {
Slog.w(TAG, "Drained");
slowDeliveryDetected = false;
}
} else {
if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
msg)) {
// Once we write a slow delivery log, suppress until the queue drains.
slowDeliveryDetected = true;
}
}
}
if (logSlowDispatch) {
showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
}

if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

// Make sure that during the course of dispatching the
// identity of the thread wasn't corrupted.
final long newIdent = Binder.clearCallingIdentity();
if (ident != newIdent) {
Log.wtf(TAG, "Thread identity changed from 0x"
+ Long.toHexString(ident) + " to 0x"
+ Long.toHexString(newIdent) + " while dispatching to "
+ msg.target.getClass().getName() + " "
+ msg.callback + " what=" + msg.what);
}
msg.recycleUnchecked();
}
}

loop()方法是一个死循环,只有当消息队列next()方法返回null或者quit()方法被调用后才会跳出死循环,loop()会一直调用next()方法。

Handler

Handler的主要工作就是接收和发送消息,消息发送最终会使用send的一系列方法来实现,在平常使用中我们一般会使用sendMessage()方法发送Message,Handler会调用dispatchMessage()方法。

    public void dispatchMessage(@NonNull Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}

最后重写handleMessage方法来接收消息。

收起阅读 »

使用Flutter撸一个极简的吃月饼小游戏

先看效果 游戏规则很简单,在月饼投掷出去之后能够砸中月亮即记1分,否则该轮游戏结束,连续击中的次数则为本轮分数。 编码实现 代码上其实没有太多复杂度,大体逻辑如下: 1 月亮的移动动画,使用Tween实现一个补间动画,使用TweenSequence指定动画序...
继续阅读 »

先看效果


2021-09-08 22.17.51.gif


游戏规则很简单,在月饼投掷出去之后能够砸中月亮即记1分,否则该轮游戏结束,连续击中的次数则为本轮分数。


编码实现


代码上其实没有太多复杂度,大体逻辑如下:


1 月亮的移动动画,使用Tween实现一个补间动画,使用TweenSequence指定动画序列,监听动画的完成重复动画,从而实现月亮左右不间断移动的效果。


_animationController =
AnimationController(duration: Duration(milliseconds: 600), vsync: this);
_animationControllerCake =
AnimationController(duration: Duration(milliseconds: 900), vsync: this);
TweenSequenceItem<double> downMarginItem = TweenSequenceItem<double>(
tween: Tween(begin: 1.0, end: 50.0),
weight: 50,
);
TweenSequenceItem<double> upMarginItem = TweenSequenceItem<double>(
tween: Tween(begin: 50.0, end: 300.0),
weight: 100,
);

TweenSequence<double> tweenSequence = TweenSequence<double>([
downMarginItem,
upMarginItem,
]);

_animation = tweenSequence.animate(_animationController);

_animation.addListener(() {
if (_animation.isCompleted) {
_animationController.reverse();
}
if (_animation.isDismissed) {
_animationController.forward();
}
setState(() {});
});

2 月饼的投掷位移效果,使用StreamBuilder控件,配合Timer倒计时,在1秒内不断地Stream发送当前位置数据,从而不断的改变月饼距离底部的距离,看上去就像也是一个位移动画效果,而实际上是不断改变距离形成的视觉效果。


_countdownTimer = new Timer.periodic(new Duration(milliseconds: 1), (timer) {
if (_milliSecond > 0) {
_milliSecond = _milliSecond - 5;
} else {
_countdownTimer.cancel();
}
_cakeStreamController.sink.add(_milliSecond < 0 ? 0 : _milliSecond);
});

Container(
margin: EdgeInsets.only(bottom: distance),
child: Image.asset(
"assets/images/cake.png",
width: 60,
height: 60,
),
)

3 比较关键的一点就是,如何判断月饼投掷出去之后会和月亮发生碰撞,也就是“吃到月饼”。实现方案是:月亮的高度是已知的(屏幕高度 - 状态栏高度 - 月亮距上方距离),月饼的横坐标是固定的(屏幕宽度的一半)。在StreamBuilder中监听:当月饼高度达到月亮的高度时,判断月亮的横坐标是否和月饼一致即可,如果一致则月饼与月亮重合,记为一次有效分数。而这里为了增加游戏的可玩性,并不是很严格的判断坐标完全重合,如图所示,月饼到达月亮高度时,月亮如果在红色区域内都记为有效分数。


image.png


判断逻辑:


// 当月饼高度达到月球所处的高度时,判断月球的位置是否处于中间
if (distance < (_screenHeight - 120) &&
distance > (_screenHeight - 170 - MediaQuery.of(context).padding.top)) {
print(_animation.value);
if (_animation.value < (_screenWidth / 2 + 10) && _animation.value > (_screenWidth / 2 - 90)) {
_hintStreamController.add("太棒了");
print("撞到了");
_score++;
} else {
_hintStreamController.add("MISS");
print("MISS");
}
_milliSecond = 1000;
_cakeStreamController.sink.add(_milliSecond);
_countdownTimer.cancel();
}

引入分数排行榜


为了增加游戏的趣味性,在游戏里面增加了联机的分数排行榜机制,游戏中会将玩家的最高分数上传至服务器,在主界面的右上角可以查看自己在排行榜的位置。


Screenshot_2021-09-08-22-23-35-489_com.flutter.mo.jpg


这里云端服务器存储功能使用的是第三方平台LeanCloud,这个平台是支持Flutter的,而且在8月份刚好增加了对空安全的迭代。


扫码下载链接


目前支持安卓版本的下载,还在等什么,赶快下载登上排行榜吧~


image.png


代码地址


Github链接


由于云端服务器使用了LeanCloud,下载的老铁需要去LeanCloud平台申请AppKey填写在项目中的main.dart中的初始化位置即可。


LeanCloud.initialize(
"", "",
server: "https://zsyju4p5.lc-cn-n1-shared.com", // to use your own custom domain
queryCache: new LCQueryCache() // optinoal, enable cache
)

作者:单总不会亏待你
链接:https://juejin.cn/post/7005585014767222798
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android项目中集成Flutter,实现秒开Flutter模块

本文目标 成功在Android原生项目中集成Flutter Warning 从Flutter v1.1.7版本开始,Flutter module仅支持AndroidX应用 在release模式下Flutter仅支持以下架构:x86_64,armeabi-v7...
继续阅读 »

本文目标


成功在Android原生项目中集成Flutter


Warning



  • 从Flutter v1.1.7版本开始,Flutter module仅支持AndroidX应用

  • 在release模式下Flutter仅支持以下架构:x86_64,armeabi-v7a,arm64-v8a,不支持mips和x86,所以引入Flutter前需要选取Flutter支持的架构


android{
//...
defaultConfig {
//配置支持的动态库类型
ndk {
abiFilters 'x86_64','armeabi-v7a', 'arm64-v8a'
}
}
}

混合开发的一些适用场景



  • 在原有项目中加入Flutter页面


image



  • 原生页面中嵌入Flutter模块


image



  • 在Flutter项目中嵌入原生模块


image


主要步骤



  • 创建Flutter module

  • 为已存在的Android项目添加Flutter module依赖

  • 早Kotlin/Java中调用Flutter module

  • 编写Dart代码

  • 运行项目

  • 热重启/重新加载

  • 调试Dart代码

  • 发布应用


请把所有的项目都放在同一个文件夹内


- WorkProject
- AndroidProject
- iOSProject
- flutrter_module

WorkProject下面分别是原生Android模块,原生iOS模块,flutter模块,并且这三个模块是并列结构


创建Flutter module


在做混合开发之前我们需要创建一个Flutter module
这个时候需要


  cd xxx/WorkProject /

创建flutter_module


flutter create -t module flutter_module

如果要指定包名


flutter create -t module --org com.example flutter_module

然后就会创建成功


image



  • .android - flutter_module的Android宿主工程

  • .ios - flutter_module的iOS宿主工程

  • lib - flutter_module的Dart部分代码

  • pubspec.yaml - flutter_module的项目依赖配置文件
    因为宿主工程的存在,我们这个flutter_module在布甲额外的配置的情况下是可以独立运行的,通过安装了Flutter和Dart插件的AndroidStudio打开这个flutter_module项目,通过运行按钮可以直接运行


构建flutter aar(非必须)


可以通过如下命令构建aar


cd .android/
./gradlew flutter:assembleRelease

这会在.android/Flutter/build/outputs/aar/中生成一个flutter-release.aar归档文件


为已存在的Android用意添加Flutter module依赖


打开我们的Android项目的 settings.gradle添加如下代码


setBinding(new Binding([gradle: this]))                              
evaluate(new File(
settingsDir.parentFile,
'flutter_module/.android/include_flutter.groovy'
))

//可选,主要作用是可以在当前AS的Project下显示flutter_module以方便查看和编写Dart代码
include ':flutter_module'
project(':flutter_module').projectDir = new File('../flutter_module')

setBinding与evaluate允许Flutter模块包括它自己在内的任何Flutter插件,在setting.gradle中以类似:flutter package_info :video_player的方式存在


添加:flutter依赖


dependencies {
implementation project(':flutter')
}

添加Java8编译选项


因为Flutter的Android engine使用了Java8的特性,所有在引入Flutter时需要配置你的项目的Java8编译选项


//在app的build.gradle文件的android{}节点下添加
android {
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
}

在Kotlin中调用Flutter module


支持,我们已经为我们的Android项目添加了Flutter所必须的依赖,接下来我们来看如何在项目中以Kotlin的方式在Fragment中调用Flutter模块,在这里我们能做到让Flutter优化提升加载速度,实现秒开Flutter模块


原生Kotlin端代码


/**
* flutter抽象的基类fragment,具体的业务类fragment可以继承
**/
abstract class FlutterFragment(moduleName: String) : IBaseFragment() {

private val flutterEngine: FlutterEngine?
private lateinit var flutterView: FlutterView

init {
flutterEngine =FlutterCacheManager.instance!!.getCachedFlutterEngine(AppGlobals.get(), moduleName)
}

override fun getLayoutId(): Int {
return R.layout.fragment_flutter
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(mLayoutView as ViewGroup).addView(createFlutterView(activity!!))
}

private fun createFlutterView(context: Context): FlutterView {
val flutterTextureView = FlutterTextureView(activity!!)
flutterView = FlutterView(context, flutterTextureView)
return flutterView
}

/**
* 设置标题
*/
fun setTitle(titleStr: String) {
rl_title.visibility = View.VISIBLE
title_line.visibility = View.VISIBLE
title.text = titleStr
title.setOnClickListener {

}
}

/**
* 生命周期告知flutter
*/
override fun onStart() {
flutterView.attachToFlutterEngine(flutterEngine!!)
super.onStart()
}

override fun onResume() {
super.onResume()
//for flutter >= v1.17
flutterEngine!!.lifecycleChannel.appIsResumed()
}

override fun onPause() {
super.onPause()
flutterEngine!!.lifecycleChannel.appIsInactive()
}

override fun onStop() {
super.onStop()
flutterEngine!!.lifecycleChannel.appIsPaused()
}

override fun onDetach() {
super.onDetach()
flutterEngine!!.lifecycleChannel.appIsDetached()
}

override fun onDestroy() {
super.onDestroy()
flutterView.detachFromFlutterEngine()
}
}

R.layout.fragment_flutter的布局


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<RelativeLayout
android:id="@+id/rl_title"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="@dimen/dp_45"
android:background="@color/color_white"
android:gravity="center_vertical"
android:orientation="horizontal">

<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:gravity="center"
android:textColor="@color/color_000"
android:textSize="16sp" />
</RelativeLayout>

<View
android:id="@+id/title_line"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="2px"
android:background="@color/color_eee" />
</LinearLayout>

/**
* flutter缓存管理,主要是管理多个flutter引擎
**/
class FlutterCacheManager private constructor() {

/**
* 伴生对象,保持单例
*/
companion object {

//喜欢页面,默认是flutter启动的主入口
const val MODULE_NAME_FAVORITE = "main"
//推荐页面
const val MODULE_NAME_RECOMMEND = "recommend"

@JvmStatic
@get:Synchronized
var instance: FlutterCacheManager? = null
get() {
if (field == null) {
field = FlutterCacheManager()
}
return field
}
private set
}

/**
* 空闲时候预加载Flutter
*/
fun preLoad(context: Context){
//在线程空闲时执行预加载任务
Looper.myQueue().addIdleHandler {
initFlutterEngine(context, MODULE_NAME_FAVORITE)
initFlutterEngine(context, MODULE_NAME_RECOMMEND)
false
}
}

/**
* 初始化Flutter
*/
private fun initFlutterEngine(context: Context, moduleName: String): FlutterEngine {
//flutter 引擎
val flutterLoader: FlutterLoader = FlutterInjector.instance().flutterLoader()
val flutterEngine = FlutterEngine(context,flutterLoader, FlutterJNI())
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint(
flutterLoader.findAppBundlePath(),
moduleName
)
)
//存到引擎缓存中
FlutterEngineCache.getInstance().put(moduleName,flutterEngine)
return flutterEngine
}

/**
* 获取缓存的flutterEngine
*/
fun getCachedFlutterEngine(context: Context?, moduleName: String):FlutterEngine{
var flutterEngine = FlutterEngineCache.getInstance()[moduleName]
if(flutterEngine==null && context!=null){
flutterEngine=initFlutterEngine(context,moduleName)
}
return flutterEngine!!
}

}

具体业务类使用


//在app初始化中初始一下
public class MyApplication extends Application {

@Override
public void onCreate() {
super.onCreate();
FlutterCacheManager.getInstance().preLoad(this);
}
}

收藏页面


class FavoriteFragment : FlutterFragment(FlutterCacheManager.MODULE_NAME_FAVORITE) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setTitle(getString(R.string.title_favorite))
}
}

推荐页面


class RecommendFragment : FlutterFragment(FlutterCacheManager.MODULE_NAME_RECOMMEND) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setTitle(getString(R.string.title_recommend))
}
}

Dart端代码


import 'package:flutter/material.dart';
import 'package:flutter_module/favorite_page.dart';
import 'package:flutter_module/recommend_page.dart';

//至少要有一个入口,而且这下面的man() 和 recommend()函数名字 要和FlutterCacheManager中定义的对应上
void main() => runApp(MyApp(FavoritePage()));

//必须加注解
@pragma('vm:entry-point')
void recommend() => runApp(MyApp(RecommendPage()));

class MyApp extends StatelessWidget {
final Widget page;
const MyApp(this.page);

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
body: page,
),
);
}
}

Dart侧收藏页面


import 'package:flutter/material.dart';

class FavoritePage extends StatefulWidget {
@override
_FavoritePageState createState() => _FavoritePageState();
}

class _FavoritePageState extends State<FavoritePage> {
@override
Widget build(BuildContext context) {
return Container(
child: Text("收藏"),
);
}
}

Dart侧推荐页面


import 'package:flutter/material.dart';

class RecommendPage extends StatefulWidget {
@override
_RecommendPageState createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage> {
@override
Widget build(BuildContext context) {
return Container(
child: Text("推荐"),
);
}
}

最终效果


image


image


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

Android启动优化之精确测量启动各个阶段的耗时

1. 直观地观察应用启动时长 我们可以通过观察logcat日志查看Android应用启动耗时,过滤关键字"Displayed": ActivityTaskManager: Displayed com.peter.viewgrouptutorial/.acti...
继续阅读 »

1. 直观地观察应用启动时长


我们可以通过观察logcat日志查看Android应用启动耗时,过滤关键字"Displayed"



ActivityTaskManager: Displayed com.peter.viewgrouptutorial/.activity.DashboardActivity: +797ms



启动时长(在这个例子中797ms)表示从启动App到系统认为App启动完成所花费的时间。


2. 启动时间包含哪几个阶段


从用户点击桌面图标,到Activity启动并将界面第一帧绘制出来大概会经过以下几个阶段。



  1. system_server展示starting window

  2. Zygote fork Android 进程

  3. ActivityThread handleBindApplication(这个阶段又细分为)

    • 加载程序代码和资源

    • 初始化ContentProvider

    • 执行Application.onCreate()



  4. 启动Activity(执行 onCreate、onStart、onResume等方法)

  5. ViewRootImpl执行doFrame()绘制View,计算出首帧绘制时长。


流程图如下:


我们可以看出:阶段1和2都是由系统控制的。App开发者对这两个阶段的耗时能做的优化甚微。


3. 系统是如何测量启动时长的?


本文源码基于android-30


我们在cs.android.com源码阅读网站上全局搜索



  1. ActivityMetricsLogger.logAppDisplayed()方法中发现了打印日志语句



private void logAppDisplayed(
TransitionInfoSnapshot info
) {
if (info.type != TYPE_TRANSITION_WARM_LAUNCH && info.type != TYPE_TRANSITION_COLD_LAUNCH) {
return;
}

EventLog.writeEvent(WM_ACTIVITY_LAUNCH_TIME,
info.userId, info.activityRecordIdHashCode, info.launchedActivityShortComponentName,
info.windowsDrawnDelayMs);

StringBuilder sb = mStringBuilder;
sb.setLength(0);
sb.append("Displayed ");
sb.append(info.launchedActivityShortComponentName);
sb.append(": ");
TimeUtils.formatDuration(info.windowsDrawnDelayMs, sb);
Log.i(TAG, sb.toString());
}


  1. TransitionInfoSnapshot.windowsDrawnDelayMs是启动的时长。它在以下方法中被赋值:



  • ActivityMetricsLogger.notifyWindowsDrawn()

  • ➡️ TransitionInfo.calculateDelay()


//ActivityMetricsLogger.java
TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

private static final class TransitionInfo {
int calculateDelay(long timestampNs) {
long delayNanos = timestampNs - mTransitionStartTimeNs;
return (int) TimeUnit.NANOSECONDS.toMillis(delayNanos);
}
}


  1. timestampNs表示启动结束时间,mTransitionStartTimeNs表示启动开始时间。它们分别是在哪赋值的呢?


mTransitionStartTimeNs启动开始时间在notifyActivityLaunching方法中被赋值。调用堆栈如下:



  • ActivityManagerService.startActivity()

  • ➡️ActivityManagerService.startActivityAsUser()

  • ➡️ActivityStarter.execute()

  • ➡️ActivityMetricsLogger.notifyActivityLaunching()



ActivityMetricsLogger.notifyActivityLaunching(...)

//ActivityMetricsLogger.java
private LaunchingState notifyActivityLaunching(
Intent intent,
ActivityRecord caller,
int callingUid
) {
...
long transitionStartNs = SystemClock.elapsedRealtimeNanos();
LaunchingState launchingState = new LaunchingState();
launchingState.mCurrentTransitionStartTimeNs = transitionStartNs;
...
return launchingState;
}

启动时间记录到LaunchingState.mCurrentTransitionStartTimeNs


ActivityStarter.execute()

//ActivityStarter.java
int execute() {
try {
final LaunchingState launchingState;
synchronized (mService.mGlobalLock) {
final ActivityRecord caller = ActivityRecord.forTokenLocked(mRequest.resultTo);
launchingState = mSupervisor.getActivityMetricsLogger().notifyActivityLaunching(
mRequest.intent, caller);
}

if (mRequest.activityInfo == null) {
mRequest.resolveActivity(mSupervisor);
}

int res;
synchronized (mService.mGlobalLock) {

mSupervisor.getActivityMetricsLogger().notifyActivityLaunched(launchingState, res,
mLastStartActivityRecord);
return getExternalResult(mRequest.waitResult == null ? res
: waitForResult(res, mLastStartActivityRecord));
}
} finally {
onExecutionComplete();
}
}

该方法作用如下:



  1. 调用ActivityMetricsLogger().notifyActivityLaunching()生成LaunchingState。将启动时间记录其中

  2. 执行StartActivity逻辑

  3. 调用ActivityMetricsLogger().notifyActivityLaunched()把launchingState和ActivityRecord映射保存起来


ActivityMetricsLogger.notifyActivityLaunched(...)

//ActivityMetricsLogger.java
void notifyActivityLaunched(
LaunchingState launchingState,
int resultCode,
ActivityRecord launchedActivity) {
...
final TransitionInfo newInfo = TransitionInfo.create(launchedActivity, launchingState,
processRunning, processSwitch, resultCode);
if (newInfo == null) {
abort(info, "unrecognized launch");
return;
}

if (DEBUG_METRICS) Slog.i(TAG, "notifyActivityLaunched successful");
// A new launch sequence has begun. Start tracking it.
mTransitionInfoList.add(newInfo);
mLastTransitionInfo.put(launchedActivity, newInfo);
startLaunchTrace(newInfo);
if (newInfo.isInterestingToLoggerAndObserver()) {
launchObserverNotifyActivityLaunched(newInfo);
} else {
// As abort for no process switch.
launchObserverNotifyIntentFailed();
}
}

该方法将根据LaunchingState和ActivityRecord生成TransitionInfo保存到mTransitionInfoList中。这样就将启动开始时间保存起来了。


ActivityMetricsLogger.notifyWindowsDrawn(...)

//ActivityMetricsLogger.java
TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

//ActivityMetricsLogger.java
private TransitionInfo getActiveTransitionInfo(WindowContainer wc) {
for (int i = mTransitionInfoList.size() - 1; i >= 0; i--) {
final TransitionInfo info = mTransitionInfoList.get(i);
if (info.contains(wc)) {
return info;
}
}
return null;
}

notifyWindowsDraw方法正是通过查找mTransitionInfoList中对应的TransitionInfo获取到Activity的启动开始时间。


启动完成调用堆栈如下



  • ActivityRecord.onFirstWindowDrawn()

  • ➡️ActivityRecord.updateReportedVisibilityLocked()

  • ➡️ActivityRecord.onWindowsDrawn()

  • ➡️ActivityMetricsLogger.notifyWindowsDrawn()



ActivityRecord.updateReportedVisibilityLocked()

//ActivityRecord.java
void updateReportedVisibilityLocked() {
...
boolean nowDrawn = numInteresting > 0 && numDrawn >= numInteresting;
boolean nowVisible = numInteresting > 0 && numVisible >= numInteresting && isVisible();

if (nowDrawn != reportedDrawn) {
onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos());
reportedDrawn = nowDrawn;
}
...
}

void onWindowsDrawn(boolean drawn, long timestampNs) {
mDrawn = drawn;
if (!drawn) {
return;
}
final TransitionInfoSnapshot info = mStackSupervisor
.getActivityMetricsLogger().notifyWindowsDrawn(this, timestampNs);
...
}

我们看到在updateReportedVisibilityLocked()方法中把SystemClock.elapsedRealtimeNanos()传递给onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos())


4. 调试技巧


通过断点调试记录应用冷启动记录耗时调用栈



  1. 准备一台root的手机(或者非Google Play版本模拟器)

  2. compileSdkVersion、targetSdkVersion与模拟器版本一致(本文30)

  3. notifyActivityLaunching和notifyWindowsDrawn中增加断点

  4. 调试勾选Show all processes选择system_process



几个重要的时间节点


  1. ActivityManagerService接收到startActivity信号时间,等价于launchingState.mCurrentTransitionStartTimeNs。时间单位纳秒。

  2. 进程Fork的时间,时间单位毫秒。可以通过以下方式获取:


object Processes {
@JvmStatic
fun readProcessForkRealtimeMillis(): Long {
val myPid = android.os.Process.myPid()
val ticksAtProcessStart = readProcessStartTicks(myPid)
// Min API 21, use reflection before API 21.
// See https://stackoverflow.com/a/42195623/703646
val ticksPerSecond = Os.sysconf(OsConstants._SC_CLK_TCK)
return ticksAtProcessStart * 1000 / ticksPerSecond
}

// Benchmarked (with Jetpack Benchmark) on Pixel 3 running
// Android 10. Median time: 0.13ms
fun readProcessStartTicks(pid: Int): Long {
val path = "/proc/$pid/stat"
val stat = BufferedReader(FileReader(path)).use { reader ->
reader.readLine()
}
val fields = stat.substringAfter(") ")
.split(' ')
return fields[19].toLong()
}
}


  1. ActivityThread.handleBindApplication时设置的进程启动时间,单位毫秒。Process.getStartElapsedRealtime()


//ActivityThread.java
private void handleBindApplication(AppBindData data) {
...
// Note when this process has started.
Process.setStartTimes(SystemClock.elapsedRealtime(), SystemClock.uptimeMillis());
...
}


  1. 程序代码和资源加载的时间,时间单位毫秒。Application类初始化时的时间handleBindApplication的时间差


class MyApp extends Application {
static {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
long loadApkAndResourceDuration = SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime();
}
}
}


  1. ContentProvider初始化时间,时间单位毫秒。 Application.onCreate() 与Application.attachBaseContext(Context context) 之间的时间差


 class MyApp extends Application {
long mAttachBaseContextTime = 0L;
long mContentProviderDuration = 0L;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
mAttachBaseContextTime = SystemClock.elapsedRealtime();
}

@Override
public void onCreate() {
super.onCreate();
mContentProviderDuration = SystemClock.elapsedRealtime() - mAttachBaseContextTime;
}
}



  1. Application.onCreate()花费时间,时间单位毫秒。很简单方法开始和结束时间差。




  2. 首帧绘制时间,比较复杂,使用到了com.squareup.curtains:curtains:1.0.1代码如下,firstDrawTime就是首帧的绘制时间。从ActivityThread.handleBindApplication()到首帧绘制所花费的时间:




class MyApp extends Application {

@Override
public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
Window window = activity.getWindow();
WindowsKt.onNextDraw(window, () -> {
if (firstDraw) return null;
firstDraw = true;
handler.postAtFrontOfQueue(() -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
long firstDrawTime = (SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime()));
}
});
return null;
});
}
}
}
}

调试launchingState.mCurrentTransitionStartTimeNs

由于ActivityMetricsLogger是运行在system_process进程中。我们无法在应用进程中获取到transitionStartTimeNs,我们可以用过Debug打印日志。我们需要将断点设置成non-suspending。如图将Suspend反勾选。选中Evaluate and log,并写入日志语句。




日志输出如下:



2021-08-08 12:55:36.295 537-579/system_process D/AppStart: 19113098274557 Intent received



5. 总结


本文主要介绍了Android系统是如何测量应用启动时间以及应用开发者如何测量应用启动各个阶段的启动耗时。有了这些我们能够很好的定位启动过程中的耗时以及性能瓶颈。如果你在应用启动优化有比较好的实践成果欢迎留言讨论哟


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

6年的老项目迁移vite2,提速几十倍,真香

背景 gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它 先看看vue-cli3的启动编译吧... 该项目为内部运营管理系统...
继续阅读 »

vite-dev.png


背景



gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它



先看看vue-cli3的启动编译吧...


编译-new-48803ms.png



  • 该项目为内部运营管理系统,年龄6岁+

  • 基于vue2+elementui,2年入职时将vue-cli2升级到了vue-cli3,2年后的今天迫不及待的的奔向vite2

  • 仅迁移开发环境(我的痛点只是开发环境,对于生产环境各位自行考虑)


痛点分析


实质上是对webpack工作原理的分析,webpack在开发环境的工作流大致如下(个人见解不喜勿喷):



查找入口文件 => 分析依赖关系 => 转化模块函数 => 打包生成bundle => node服务启动



所以随着项目越来越大,速度也就越来越慢...


至于HMR也是同理,只不过HMR是将当前文件作为入口,进行rebuild,涉及的相关依赖都需要重载


为什么是Vite



  • vite是基于esm实现的,主流浏览器已支持,所以不需要对文件进行打包编译

  • 项目启动超快(迁移后简单的概算数据是从30s 提升到 1s。30倍?3000%?一点都不夸张...)

  • 还是基于esmHMR很快,不需要编译重载,速度可以用一闪而过来形容...


vite大致工作流:



启动服务 => 查找入口文件(module script) => 浏览器发送请求 => vite劫持请求处理返回文件到浏览器



开盘,踏上迁移之路




  1. 安装相关npm包


    npm i vite vite-plugin-vue vite-plugin-html -D


    • vite-plugin-vue,用于构建vue,加载jsx

    • vite-plugin-html,用于入口文件模板注入




  2. package.json文件中,新增一个vite启动命令:


    "vite": "cross-env VITE_NODE_ENV=dev vite"



  3. 根目录新建vite.config.js文件




  4. public下的index.html复制一份到根目录



    仅迁移开发环境,public下仍然需要index.html,支持开发环境下vite和webpack两种模式





  5. 修改根目录下index.html(vite启动的入口文件,必须是根目录)


    <% if (htmlWebpackPlugin.options.isVite) { %>
    <script type="module" src="/src/main.js"></script>
    <%}%>


    htmlWebpackPlugin在vite.config.js注入,isVite用于标识是否是vite启动



    import { injectHtml } from 'vite-plugin-html';
    export default defineConfig({
     plugins:[
       injectHtml({
         injectData: {
           htmlWebpackPlugin: {
             options: {
               isVite: true
            }
          },
           title: '运营管理平台'
        }
      })
    ]
    })



  6. 完整vite.config.js 配置


    import { defineConfig } from 'vite'
    import path from 'path'
    import fs from 'fs'
    import { createVuePlugin } from 'vite-plugin-vue2'
    import { injectHtml, minifyHtml } from 'vite-plugin-html'
    import dotenv from 'dotenv'

    try {
       // 根据环境变量加载环境变量文件
       const VITE_NODE_ENV = process.env.VITE_NODE_ENV
       const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
       const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
           debug: true
      })
       for (const key in file) {
           process.env[key] = file[key]
      }
    } catch (e) {
       console.error(e)
    }

    const resolve = (dir) => {
       return path.join(__dirname, './', dir)
    }
    export default defineConfig({
       root: './',
       publicDir: 'public',
       base: './',
       mode: 'development',
       optimizeDeps: {
           include: []
      },
       resolve: {
           alias: {
               'vendor': resolve('src/vendor'),
               '@': resolve('src'),
               '~component': resolve('src/components')
          },
           extensions: [
               '.mjs',
               '.js',
               '.ts',
               '.jsx',
               '.tsx',
               '.json',
               '.vue'
          ]
      },
       plugins: [
           createVuePlugin({
               jsx: true,
               jsxOptions: {
                   injectH: false
              }
          }),
           minifyHtml(),
           injectHtml({
               injectData: {
                   htmlWebpackPlugin: {
                       options: {
                           isVite: true
                      }
                  },
                   title: '运营管理平台'
              }
          })
      ],
       define: {
           'process.env': process.env
      },
       server: {
           host: '0.0.0.0',
           open: true,
           port: 3100,
           proxy: {}
      }
    })



    相关配置会在下文遇到的问题中做具体描述





迁移过程中遇到的问题




  1. Uncaught SyntaxError: The requested module 'xx.js' does not provide an export named 'xx'


    本人遇到的分以下两类情况:


    a. 一个模块只能有一个默认输出,导入默认输出时,import命令后不需要加大括号,否则会报错


    处理方式:将原先{}导入的keys,改成导入默认keyes6解构赋值


    -import { postRedeemDistUserUpdate } from '@/http-handle/api_types'

    +import api_types from '@/http-handle/api_types'
    +const { postRedeemDistUserUpdate } = api_types

    b. 浏览器仅支持 esm,不支持 cjs,需要将cjs改为esm (看了网文有通过cjs2esmodule处理的,但是本人应用有些场景是报错的,最后就去掉了)


    处理方式:不推荐使用cjs2esmodule,手动将module.exports更改为export


    -module.exports = {

    +export default {



  2. .vue文件扩展,最新版本的vite貌似已支持extensions添加.vue,不过还是推荐手动添加下后缀。(骚操作:正则匹配批量添加)




  3. Uncaught ReferenceError: require is not defined


    浏览器不支持cjs


    处理方式:require引用的文件都需要修改为import引用




  4. vite启动,页面空白


    处理方式:注意入口文件index.html,需要放置项目根目录




  5. vite环境下默认没有process.env,可通过define定义全局变量


    vue-cli模式下,环境变量都是读取根目录.env文件中的变量,那么vite模式下是否也可以读取.env文件中的变量最终注入到process.env中呢?


    这样不就可以两种模式共存了么?成本变小了么?


    处理方式:



    1. 安装环境变量加载工具:dotenv


    npm i dotenv -D




    1. 自定义全局变量process.env


      vite.config.js中配置




    define: {
    'process.env': {}
    }



    1. 加载环境变量,并添加到process.env


      vite.config.js中配置



      因为仅迁移开发环境,所以我这里默认是读取.local文件。


      VITE_NODE_ENV是在启动时通过cross-env注入的







import dotenv from 'dotenv'
try {
const VITE_NODE_ENV = process.env.VITE_NODE_ENV
const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
debug: true
})
console.log(file)
for (const key in file) {
process.env[key] = file[key]
}
} catch (e) {
console.error(e)
}




  1. jsx支持


    vite.config.js中配置


    plugins: [
    createVuePlugin({
      jsx: true,
      jsxOptions: {
        injectH: false
      }
    })



  2. webpack中require.context方法,在vite中使用import.meta.glob替换




现存问题


项目中导入/导出的功能,是纯前端实现的


require('script-loader!file-saver')
require('script-loader!@/vendor/Blob')

由于以上文件目前不支持import引入,webpack下是通过script-loader加载挂载到全局的,vite环境下未能解决。需要导入导出功能时只能切换到vue-cli模式启动服务...


如果各位大大有方案,麻烦指导指导~,实在是不想回到webpack开发了...


最后


总体迁移上并没有遇到什么疑难杂症,迁移成本还是不大的,实操1-2天,性价比很高哦,我这个项目按数据看就是几十倍的启动提效,几倍的HMR提效...各位可以在内部系统上做下尝试。



链接:https://juejin.cn/post/7005479358085201957

收起阅读 »