注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

自定义过滤器和拦截器实现ThreadLocal线程封闭

线程封闭 线程封闭一般通过以下三个方法: Ad-hoc线程封闭:程序控制实现,最糟糕,忽略 堆栈封闭:局部变量,无并发问题 ThreadLocal线程封闭:特别好的封闭方法 方法2是最常用的,变量定义在接口内,本文主要讲解方法三,SpringBoot项目通...
继续阅读 »

线程封闭


线程封闭一般通过以下三个方法:



  1. Ad-hoc线程封闭:程序控制实现,最糟糕,忽略

  2. 堆栈封闭:局部变量,无并发问题

  3. ThreadLocal线程封闭:特别好的封闭方法


方法2是最常用的,变量定义在接口内,本文主要讲解方法三,SpringBoot项目通过自定义过滤器和拦截器实现ThreadLocal线程封闭。实现Filter接口自定义过滤器和继承HandlerInterceptorAdapter自定义拦截器。


ThreadLocal线程封闭实现步骤


封装ThredLocal的方法


/**
* <p>自定义RequestHolder</p></p>
*
* @Author zjq
* @Date 2021/12
*/
public class RequestHolder {

private final static ThreadLocal<Long> requestHolder = new ThreadLocal<>();

public static void set(Long id) {
requestHolder.set(id);
}

public static Long get() {
return requestHolder.get();
}

public static void remove() {
requestHolder.remove();
}

}

自定义过滤器


自定义定义拦截器继承Filter接口,实现ThredLocal.add()方法


/**
* <p>自定义过滤器</p>
*
* @Author zjq
* @Date 2021/12/7
*/
@Slf4j
public class HttpFilter implements Filter {

/**
* 为Filter初始化 提供支持
*
* @param filterConfig
* @throws ServletException
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

/**
* 拦截到要执行的请求时,doFilter就会执行。这里我们可以写对请求和响应的预处理。
* FilterChain把请求和响应传递给下一个 Filter处理
*
* @param servletRequest
* @param servletResponse
* @param filterChain
* @throws IOException
* @throws ServletException
*/
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
//把普通servlet强转成httpServlet
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
Long threadId = Thread.currentThread().getId();
log.info("do filter,threadId:{} servletPath:{}", threadId, httpServletRequest.getServletPath());
//把当前线程id放入requestHolder
RequestHolder.set(threadId);
//放行
filterChain.doFilter(httpServletRequest, servletResponse);
}

/**
* Filter 实例销毁前的准备工作
*/
@Override
public void destroy() {

}
}

自定义拦截器


自定义拦截器在线程使用完毕后移除ThredLocal中内容,避免内存溢出


/**
* <p>自定义拦截器</p>
*
* @Author zjq
* @Date 2021/12/7
*/
@Slf4j
public class HttpInterceptor extends HandlerInterceptorAdapter {

/**
* 拦截处理程序的执行。在 HandlerMapping 确定合适的处理程序对象之后,在 HandlerAdapter 调用处理程序之前调用。
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
log.info("preHandle执行。。。");
return true;
}

/**
* 请求处理完成后(渲染视图后)的回调。将在处理程序执行的任何结果上调用,从而允许进行适当的资源清理。
* @param request
* @param response
* @param handler
* @param ex
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
RequestHolder.remove();
log.info("afterCompletion执行。。。");
return;
}
}

Application类启动类中配置自定义过滤器和拦截器


/**
*
* @author zjq
*/
@SpringBootApplication
public class Application extends WebMvcConfigurationSupport {

public static void main(String[] args) {
SpringApplication.run(ConcurrencyApplication.class, args);
}

/**
* 自定义过滤器
* @return
*/
@Bean
public FilterRegistrationBean filterRegistrationBean(){
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
filterRegistrationBean.setFilter(new HttpFilter());
//设置自定义过滤器拦截的url
filterRegistrationBean.addUrlPatterns("/threadLocal/*");
return filterRegistrationBean;
}

/**
* 定义自定义拦截器原先需要继承WebMvcConfigurerAdapter
* SpringBoot2.0后WebMvcConfigurerAdapter被定义成过时了,推荐使用继承WebMvcConfigurationSupport
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HttpInterceptor()).addPathPatterns("/**");
}
}

定义调用接口


/**
* ThreadLocal测试controller
* @author zjq
*/
@Controller
@RequestMapping("/threadLocal")
public class ThreadLocalController {

@RequestMapping("/test")
@ResponseBody
public Long test() {
return RequestHolder.get();
}
}

请求访问验证


访问调用接口,控制台输出如下:
image.png


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

【flutter进阶】Widget源码详解-如何实现自由组合,动态刷新,布局绘制?

看到结局的问题:如何区分StatelessWidget 和 StatefulWidget 的使用场景,不禁开始自问,对于StatefulWidget ,StatelessWidget,以及flutter中Widget的众多子类我真的足够了解吗? 对于自己经常要...
继续阅读 »

看到结局的问题:如何区分StatelessWidgetStatefulWidget 的使用场景,不禁开始自问,对于StatefulWidget ,StatelessWidget,以及flutter中Widget的众多子类我真的足够了解吗?


对于自己经常要打交道的东西,如果只是一知半解则不利于进步。


下面就从源码的角度来学习下flutter基础的几个Widget 都起到了什么作用。


image.png


先给个简单总结:



  • 其中StatelessWidget 和 StatefulWidget 起到了组织组合子组件的作用。

  • RenderObjectWidget 起到渲染作用。包含绘制偏移和测量信息。

  • ProxyWidget 可以携带信息,以供其他组件使用。


一、探索StatelessWidget的组件构建


在使用StatelessWidget的时候,通常只需要实现一个build方法。就拿我们常用的Container组件举例,他就是StatelessWidget 的子类。他的build方法返回的就是各种组件的组合嵌套。
img


他的各种成员属性也只是用来配置子组件的组合方式而已。


1. StatelessWidget 的build调用时机,以及widget树遍历流程


Container组件是StatelessWidget的经典子类。


我们通过断点调试看看Container 组件build方法的调用堆栈


img


ComponentElementperformRebuild 方法调用的时候,触发了build方法,从stateless中获取了build返回的Widget,而又在performRebuild 调用了updateChild方法,对所有的子孙Element进行build遍历。



ComponentElement是Widget对应元素StatelessElementStatefulElement的父类。



我们拉到最初的调用栈。Element栈调用的起点在于attachRootWidget方法。


还记得我们flutter app开发的起点吗?就是runApp(App())方法,开启了整个flutter app。
attachRootWidget方法正是我们在调用runApp的时候执行的。


在其中,执行了RenderObjectToWidgetAdapter组件的初始化,将renderViewrootWidget作为入参。并且调用attachToRenderTree返回元素树顶点的Element。


img


三颗树的顶点


其中renderViewRenderObject树的顶点,_renderViewElementElement树的顶点。匿名的RenderObjectToWidgetAdapter则是Widget树的顶点,但是他没有被引用。Widget树的维护依赖于Element树,rootWidget就是我们的runApp组件节点,被作为参数挂载到RenderObjectToWidgetAdapter根组件中,被后续的Element挂载循环使用。


Element中也存放了_parent变量,所以我们通过Element对象可以轻松的追溯到祖先节点。


img


我们从上面的分析可以得出ComponentElement 的 performRebuild方法是element.build传承关键方法 ,mount方法也能由此挂载出所有子树(其他类型的Element实现方案略有不同)


在ComponentElement中。也由performRebuild构建出一层层的子孙节点。代码如下,注意红色方框的代码。


img


第一个红框中是build()方法的执行。意味着每次performRebuild被调用的时候,子组件都会被build出来,由此可知widget是唯一的,每次更新都会有新的Widget生成。


updateChild的过程中,如果子element还未生成,就会调用widget.createElement()方法获得element


我们再看StatelessWidget 的源码,实现了createElement方法返回了自定义的StatelessElement


img


生成的子Element 都会在ComponentElement中被持有,以便后续更新


img


由此可知,ComponentElement维系了祖孙关系,其子类Element对应的 StatelessWidget,StatefulWidget,ParentDataWidget 和 InheritedWidget都天然拥有子孙关系能力。


如下所示,StatefulElementComponentElement 的子类。
img


2. StatelessWidget 和Element在渲染中的更新


widget的创建都是在element树遍历的过程中执行的。
widget树依赖于element树,在Element创建的时候widget实例将会被持有。
StatelessWidget在布局和渲染流程中依赖Element维系,树关系被Element挖掘。
img


Element performeRebuild重新构建的时候,有一个是否更新Element的判定机制,以优化性能。
不管是更新update还是挂载mount,每次子widget都会先build()出来。再进行新旧比较。Widget都是一次性的,如果有状态需要保存是由其他方式实现的。
我们再看updateChild方法。上面一小节提到在子element为空的时候,会在其中createElement。而在子Element不为空的时候,会根据新旧Widget 的不同,进行不同的操作。
img


其中通过新旧widgetequals判定。决定是否复用之前的element。如果复用了element,根据canUpdate方法的返回值,来执行child.update方法。所以我们可以得出这样一个结论。


widgetcanUpdate 实现,将很大程度上决定 Element 的复用。减少重新绘制,对State重新赋值,甚至状态丢失的资源浪费。


3. 探索key的作用


canUpdate的默认实现中以Widget的类型和key作为关键字进行判断。如果有对key定义,那么Key的一致性就会对widget的更新显得尤为关键。


这也是我们在做性能优化的时候需要注意的。可以利用Key的配置,来控制组件是否需要更新。


static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
复制代码

Key的几种子类基本上都是根据需求,对== 操作符做不同的实现。以更好的自定义 canUpdate 的结果。


其中GlobalKey比较特殊。作为全局的唯一秘钥。提供了对应 widgetBuildContextwidget 的访问方式。并针对 StatefulWidget。还提供了 State 的访问。


以便用户对状态进行全局的更新。比如我们需要在外部使用 BuildContext 进行初始化的时候,可以进行这样调用


img


4. 小结


通过以上对StatelessWidgetComponentElement 的分析,可以得出以下的判断。
StatelessWidget 基于 ComponentElement。主要功能就是提供了组合各种widget的能力,并维持了祖孙的build传承。


当然在探索当中也发现了一些技术债务,由于我们已经知道了statelesswidget的使用场景,对于具体的源码细节先按下不表,在此只记录



  • 生命周期_lifecycleState 起到什么作用

  • _dirty 标记和 markNeedsBuild 的用法和原理是什么

  • BuildOwner 的作用是什么


二、探索StatefulWidget的动态刷新机制


StatefulWidgetStateflessWidget 有很多共同之处。最主要的原因就是他们创建的元素都是ComponentElement的子类,其提供了widget子孙build传承的能力。


可知StatefulWidgetStateflessWidget一样,也是一个有能力组合各种widget的组件。


1. State生命周期分析


StatefulWidget 定义了createState方法。提供了状态刷新能力。
img


再次从StatefullElementbuild方法入手。直接调用了state.build(this)。代理了state的构建行为。


performRebuild方法中也进行了state.didChangeDependencies生命周期回调。


img


在State中,除了生命周期方法外, 最重要的就是build方法了。作用和StatelessWidget的build方法一致。都是提供了组合widget的能力。
initState则给用户提供了初始化state状态的机会。断点调试看看调用栈如何。


img


调试中直观看到,在firstBuld的时候,stateinitState被调用。并在之后调用了didChangeDependencies生命周期方法,和build方法。


img


代码中也对方法做了限制,不可以返回Future类型。
所以我们可以在initState中放心做一些初始化工作,没有异步参与,工作将会在build之前完成。


2. setState方法刷新页面方式分析


对于setState方法。除开生命周期的判断之外,关键代码只有一句,就是调用了element 的markNeedsBuild()
img


该方法将对应的element标记为dirty。并且调用owner``!.scheduleBuildFor(``this``);将其加入到 BuildOwner的脏列表(_dirtyElements)中。
将会在下次帧刷新的时候调用BuildOwner.owner.buildScope 重新构建该列表中的元素。


3. 小结


StatelessWidget给使用者提供了一个便捷的布局刷新入口,我们可以利用setState刷新布局。该方法会将对应Element标记为待刷新元素,在下次帧刷新的时候重建布局。状态的改动将会被重建的布局重新获取。


三、探索SingleChildRenderObjectWidget


SingleChildRenderObjectWidget对应的元素类是SingleChildRenderObjectElement
我们作为开发者,布局过程中SingleChildRenderObjectWidget 的子类使用频率非常频繁,布局的约束,偏移和渲染都是由RenderObjectWidget 实现的,SingleChildRenderObjectWidget继承了RenderObjectWidget的渲染能力,并提供了单子传承的能力。布局的过程中该对象的子类不可或缺,flutter框架中也有不少对应的实现类。


Flutter 框架中实现的SingleChildRenderObjectWidget有以下几种。



  1. SizedBox

  2. LimitedBox

  3. ShaderMask

  4. RotatedBox

  5. SizedOverflowBox

  6. Padding

  7. ...


1. 探索SingleChildRenderObjectElement中对于子widget的挂载和更新


SingleChildRenderObjectElement`的`mount` 和 `update`方法都很简单,都是直接调用了`updateChild`方法,传进去的子widget直接是`widget.child

img


这个方法和ComponentElement基本上一样,都是利用canUpdate的结果进行更新或者是创建子Element


1. 以Padding为例了解RenderObjectWidget 的布局和绘制实现。


名词解释


RenderObject:渲染对象,flutter对象布局的约束,绘制,位移全是由该对象实现,RenderObject树的祖孙中传递着约束,以做到布局大小的传承影响。


RenderObject的创建


RenderObjectWidget 会在mount挂载的时候,创建RenderObject,直接调用widge.createRenderObject。我们的约束,绘制,位移全是由RenderObject传递和实现的。


img


RenderPadding的布局实现


Padding为例。createRenderObject创建了RenderPadding实例,widget的成员原封不动交给了该实例。


img


约束(BoxConstraint)是Flutter确定布局大小的方案,各种RenderObject对于约束的传递都有自己的实现。


下方是RenderPaddingperformLayout代码。红框标记起来的代码中就展示了Padding的约束传承逻辑。
其父布局传给自己约束基础上减去Padding再传递给子RenderObject


观察performLayout方法可以发现,该方法完成了约束的传递,计算了偏移量Offset,并确定了自己的大小。


img


确定大小约束之后,就会在paint中绘制自己和子孙。RenderPadding没有自定义绘制,直接使用了父类RenderShiftedBox的实现。RenderShiftedBox 提供了offset偏移。在绘制子renderObject的时候,为其施加绘制偏移量。有些需要计算子布局偏移的widget,如PaddingAlign等,都对RenderShiftedBox进行了实现。
img


可以看到子布局的offset存在他的parentData中。PaddingRender使用的parentDataBoxParentData,内部提供了offset变量以供父布局使用。


/// Parent data used by [RenderBox] and its subclasses.
class BoxParentData extends ParentData {
/// The offset at which to paint the child in the parent's coordinate system.
Offset offset = Offset.zero;
@override
String toString() => 'offset=$offset';
}

所有的RenderBox都持有BoxParentData对象,用于存储位移信息,在setUpPrentData的时候进行的初始化。红框中的代码展示了这一细节。


img


到此,就能了解RenderObject是如何被约束BoxConstraint,如何被布局layout,以及如何被绘制paint


1. RenderObjectElement的传承方式


RenderObjectElement 的父子传承在两个子类中实现,在第1小结中已经提到SingleChildRenderObjectWidgetComponentElement十分类似,只是直接把widget.child拿来传承,而不再提供build方法以供子组件组合。


MultiChildRenderObjectElement 也类似,只不过作为多子组件,三棵树分叉的主要因子,维护的是children 列表。
img


在mount 和 update 的时候,子孙组件会像点了爆竹一样被逐一构建和更新。


1. 小结


每个SingleChildRenderObjectWidget组件都实现了各自的布局和绘制方案,也各自处理了约束并传递下去。


比如ColordBox作为绘制组件,借助了RenderColord,绘制了自身颜色,约束则取得是父约束的最小值。Align作为定位组件,借助了RenderPositionedBox,布局的时候计算了对应的偏移量offset,在绘制子布局的时候使用,约束则在传递的时候转了松约束。


诸如此类,所有组件都利用了对应的RenderObject满足了各自布局和渲染的所有需求。我们自己当然也可以自定义对应的RenderObject实现自己的布局。
MultiChildRenderObjectWidgetSingleChildRenderObjectWidget类似,只是维护一个子widget变成了多个子widget。


他的RenderObject基本上都是ContainerRenderObjectMixinRenderBox的子类,内部维护了头尾两个子节点,并利用存储在parentData中的双相链表维护所有的子RenderObject


四、谈谈ProxyWidget


最后稍微提一下ProxyWidgetProxyElement也上ComponentElement的子类。和StatefulWidget 以及StatelessWidget是兄弟关系。也有子孙维系的能力,只不过他的build方法是固定的,返回的就是child。
UML 图.jpg


1. InheritedWidget


我们获取 Theme,MediaQuery数据的时候,都是使用了InheritedWidget


MediaQuery.of(context).size.width;
Theme.of(context).appBarTheme;

通过context 也就是Element实例,获取祖先节点的数据。实现数据共享的效果。
Element中维护了祖先的所有InheritedElement映射,就可以在需要的时候直接通过子孙Element获取。


2. ParentDataWidget


ParentDataWidget提供了子组件向父组件传递渲染信息的能力。
FlexiblePositioned 等组件都是ParentDataWidget 的子类。


需要注意的是:ParentDataWidget只用于渲染信息的传递


在Element.attachRenderObject的时候会调用updateParentData,然后会辗转调用到对应的ParentDataWidget.applyParentData。可以看出只有子组件是RenderObjectWidget子类的时候才会应用对应的ParentDataWidget传递信息。


img


由此可知,只有在子节点渲染的时候,才会应用RenderObject的数据传递赋值。
img


子节点的ParentData对象由父布局创建代码如下,创建时机在子节点插入的时候执行。
img


img


最后


作为开发者,很多时候完成一个任务只会建立在使用的层面。对于为什么这么使用往往不甚了解。
如果我们能更多的学习他的原理。那么如果在开发中碰到问题,我们能够更加得心应手得去解决。
flutter布局渲染的原理以前总是一层雾蒙在我地眼前。但现在,终于有一片薄雾散去,内部轮廓在我面前变得清晰。
坚持学习,见识真实的世界。


小试


我们最后尝试一下一个简单地布局,分析其三棵树结构。嵌套结构如下。其中builderStatelessWidgetColumnMultiChildRenderObjectWidget其他都是SingleChildRenderObjectWidget


void main() {
runApp(Builder(builder: (context) {
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: SizedBox(
width: 100,
height: 100,
child: ColoredBox(color: Colors.blue),
),
),
Expanded(
child: ColoredBox(color: Colors.red),
),
],
);
}));
}

展示出来的样式如下。


img

分析得出的三棵树如下,源头从RenderView而起,然后构建出RenderObjectToWidgetAdapter,再构建出RootRenderObjectElement。由此从根开始三棵树的循环,直到叶子节点。


RenderObjectWidget并非一一对应,只有RenderObjjectWidget才有,但是RenderObject能自动找出自己的组件RenderObjject 自动插入到其child中,所以也能自动成树。


流程图.jpg


至此,我们的Widget初步了解完结。


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

Flutter中如何独立绘制每一个像素点?

Flutter中如何独立绘制每一个像素点? 前提 前一阵我参照教程:GAMEBOY 仿真器 做了一个game boy模拟器,经过漫长的调试,终于成功的在电脑上运行了起来,但作为一个移动端开发者,我最终还是想要在手机上运行,在经过一番研究后,我卡在了第一个难点:...
继续阅读 »

Flutter中如何独立绘制每一个像素点?


前提


前一阵我参照教程:GAMEBOY 仿真器 做了一个game boy模拟器,经过漫长的调试,终于成功的在电脑上运行了起来,但作为一个移动端开发者,我最终还是想要在手机上运行,在经过一番研究后,我卡在了第一个难点:在Flutter中如何单独绘制每一个像素点呢?


Gamyboy的显示器尺寸是160 * 144,素点的格式是RGB,模拟器大概每隔16ms生成一帧画面,当模拟器运行时,我就能源源不断的拿到每一帧的像素数据(这里的像素数据可以看做是一个int32数组,长度为160 * 144),我要做的就是找到一种方法,将每一帧的像素数据绘制到屏幕上。一番搜素后,终于在How do I Render Individual Pixels in Flutter?上找到了答案


绘制像素点


想要直接绘制原始的像素点,需要用到Canvas的一个方法:


/// Draws the given [Image] into the canvas with its top-left corner at the
/// given [Offset]. The image is composited into the canvas using the given [Paint].
void drawImage(Image image, Offset offset, Paint paint)

从方法签名中可以看出,canvas绘制的是一个Image对象,它持有原始的像素数据,所以我们需要先将像素数据转换成Image对象,可以使用decodeImageFromPixels方法


/// Convert an array of pixel values into an [Image] object.
///
/// The `pixels` parameter is the pixel data in the encoding described by
/// `format`.
/// ...
void decodeImageFromPixels(
Uint8List pixels,
int width,
int height,
PixelFormat format,
ImageDecoderCallback callback, {
int? rowBytes,
int? targetWidth,
int? targetHeight,
bool allowUpscaling = true,
})


  • pixels是一个一维数组,每一个元素是一个字节

  • widthheight代表图片的宽和高

  • format用来设置像素点的格式,比如:PixelFormat.rgba8888表示一个像素点由四个字节组成,分别表示红,绿,蓝,透明度等信息

  • callback为图片解码完成后的回调函数,函数参数为最终生成的Image对象


/// Callback signature for [decodeImageFromList].
typedef ImageDecoderCallback = void Function(Image result);

至此,整个流程已经走通,共分为三步:



  1. 生成像素数据

  2. 调用decodeImageFromPixels方法将像素数据转换为Image对象

  3. 调用CanvasdrawImage方法绘制像素数据


读到这里,有些朋友可能会有疑惑,我从哪里去获取Canvas对象呢? 如何做到实时更新每一帧画面呢?接下来,我将用一个案例将整个流程串起来


演示案例



作为演示,这里用生成的雪花噪点数据来代替模拟器生成的像素数据,完整的案例请看:github.com/hcoderLee/f…



首先我们需要一个不断生成像素数据的类


import 'dart:ui' as ui;

class Emulator {
/// 每一帧生成的像素所对应的Image对象
ui.Image? _image;

ui.Image? get image => _image;

bool _isRunning = false;
Timer? _timer;

/// 用于生成雪花噪点数据
int xorshift32(int x) {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
return x;
}

int seed = 0xDEADBEEF;

/// 生成原始像素数据,并转换为Image对象
Future<ui.Image> makeImage() {
final c = Completer<ui.Image>();
final pixels = Int32List(lcdWidth * lcdHeight);
for (int i = 0; i < pixels.length; i++) {
seed = pixels[i] = xorshift32(seed);
}
void decodeCallback(ui.Image image) {
c.complete(image);
}

// 将像素数据转换为Image对象
ui.decodeImageFromPixels(
pixels.buffer.asUint8List(),
lcdWidth,
lcdHeight,
ui.PixelFormat.rgba8888,
decodeCallback,
);
return c.future;
}

/// 不断的生成每一帧的画面
void run() {
if (_isRunning) {
return;
}
_isRunning = true;

_timer?.cancel();
/// 每隔16ms(更新一帧的时间)更新一次画面
_timer = Timer.periodic(const Duration(milliseconds: 16), (timer) async {
final newImage = await makeImage();
_image?.dispose();
_image = newImage;
});
}

void dispose() {
_timer?.cancel();
_timer = null;
_image?.dispose();
}
}

当有了Image对象后,需要调用CanvasdrawImage方法来绘制,这里使用CustomPaint组件来获取Canvas对象:


CustomPaint(
painter: _LCD(
emulator: _emulator,
timer: _timer,
),
);

class _LCD extends CustomPainter {
final Emulator emulator;

_LCD({
required this.emulator,
required _Timer timer,
}) : super(repaint: timer);

@override
void paint(ui.Canvas canvas, ui.Size size) {
final image = emulator.image;
if (image != null) {
canvas.drawImage(image, Offset.zero, Paint());
}
}

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

CustomPaint组件有一个重要的参数: painter, 它是一个CustomPainter对象,我们可以自定义一个类去继承CustomPainter, 实现paint方法,获取到Canvas对象,至此,我们可以利用canvas去绘制任何我们想要的东西


这里我们定义了_LCD去继承CustomPainter,它持有Emulator对象,从而获取要绘制的Image对象,这里有两个地方需要重点关注一下:




  1. shouldRepaint方法表示如果上层组件发生重建,生成了新的CustomPaint对象,是否需要重新调用paint方法绘制内容,因为我的需求是每一帧都要绘制新的画面,所以这里直接返回true(表示需要重新调用paint方法),真实的业务场景需要根据具体情况去判断是否返回true




  2. 构造函数中有一个_Timer对象,并调用了super(repaint: timer),那么这个_Timer对象是用来做什么的呢?




查看CustomPainter的文档,有这么一段说明:


/// The painter will repaint whenever `repaint` notifies its listeners.

repaint会通知CustomPainter去重新绘制画面,我们再查看repaint的类型是Listenable,这里我们自定义一个_Timer类,用来在每一帧更新的时候去通知CustomPainter重绘


class _Timer extends ChangeNotifier {
final TickerProvider _vsync;
late final Ticker _ticker;

_Timer(this._vsync) {
_ticker = _vsync.createTicker(_onTick);
_ticker.start();
}

void _onTick(Duration elapsed) {
notifyListeners();
}

@override
void dispose() {
_ticker.stop();
_ticker.dispose();
super.dispose();
}
}

这里我们继承了ChangeNotifier,因此间接继承了Listenable
在构造函数里创建了一个Ticker对象,用来获取每一帧更新的时机,_onTick方法会在每一帧更新的时候调用,并通知CustomPaint去重绘。实际上,Flutter的动画也是使用Ticker对象,在每一帧更新的时候触发组件重绘
为了创建Ticker对象,需要用到TickerProvider对象,它提供了创建Ticker对象的方法(createTicker),并确保onTick回调函数只在组件处在前台活跃状态的时候才触发。为了获得TickerProvider对象,最常用的做法是创建一个StatefullWidget,并给State添加SingleTickerProviderStateMixin mixin,如果大家写过动画相关的代码,对这一套应该不陌生:


class _GameView extends StatefulWidget {
const _GameView({Key? key}) : super(key: key);

@override
State<_GameView> createState() => _GameViewState();
}

class _GameViewState extends State<_GameView>
with SingleTickerProviderStateMixin {
late final Emulator _emulator;
/// 在每一帧更新的时候去通知_LCD重绘
late final _Timer _timer;

@override
void initState() {
super.initState();
_emulator = Emulator();
// 运行模拟器,不断的产生每一帧的像素数据
_emulator.run();
_timer = _Timer(this);
}

@override
Widget build(BuildContext context) {
return SizedBox(
width: lcdWidth.toDouble(),
height: lcdHeight.toDouble(),
child: CustomPaint(
painter: _LCD(
emulator: _emulator,
timer: _timer,
),
),
);
}

@override
void dispose() {
_emulator.dispose();
_timer.dispose();
super.dispose();
}
}

至此,我们已经完成了所有的步骤,看一下运行效果:


1659887972838472.gif


总结


本文讲述了Flutter中如何实时绘制自己生成的像素数据,有以下几个步骤:



  1. 生成像素数据

  2. 调用decodeImageFromPixels方法将像素数据转换为Image对象

  3. 调用CanvasdrawImage方法绘制像素数据

  4. 使用Ticker对象获取每一帧更新的时机,并通知CustomPainter去重绘


如果有错误,还请大家帮忙指正, 希望能够对大家有所帮助


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

Java转Android:第8天 Service帮你背诵古诗词

一、我讲 今天,我主要讲一下android里面的服务Service,以及它的使用方法。 1.1 服务 Service Service其实是Android的四大组件之一。 安卓有四大组件,前面我们说的Activity和BroadCastReceiver,其实都属...
继续阅读 »

一、我讲


今天,我主要讲一下android里面的服务Service,以及它的使用方法。


1.1 服务 Service


Service其实是Android的四大组件之一。


安卓有四大组件,前面我们说的ActivityBroadCastReceiver,其实都属于四大组件。你看,我没有一上来就讲让你先记住四大组件,而是慢慢渗透,因为没有例子的应用,记那些没有意义。


Android中,能看到的属于Activity,看不到的属于Service


image3.png


Service的运行不依赖于任何用户界面,即使程序被切换到后台,或者用户打开了另一个应用程序,Service仍然能够保持正常运行。


1.2 Service的使用


创建


可选中包名,右键点击菜单,输入你的服务的名称进行创建。


image.png


其实,你这步操作,影响了两处代码。


第一,新建了一个继承Service的类。


public class MyService extends Service {

@Override
public void onCreate() {
super.onCreate();
}

@Override
public int onStartCommand(final Intent intent, int flags, int startId) {
// 巴拉巴拉
return super.onStartCommand(intent, flags, startId);
}

@Override
public void onDestroy() {
super.onDestroy();
}
}

当服务每次启动时,都会执行onStartCommand方法,所以我们终点会把逻辑写到这里面。


第二,在AndroidManifest.xml中做了注册。


<?xml version="1.0" encoding="utf-8"?>
<manifest>
<application>
……
<service
android:name=".MyService"
android:enabled="true"
android:exported="true"></service>

</application>
</manifest>

启动和停止


服务的启动和停止,一般通过IntentActivity中进行。


Intent intent = new Intent(this, MyService.class);
startService(intent);
stopService(intent);

通过Intent构建要提供哪一个服务,然后调用startService启动服务,stopService停止服务。


二、你做


我们要搞一个这样的功能:



启动服务后,在服务里面循环播放古诗,退出程序也不打断,看其他应用也不打断。这样,能播放一整天,你看抖音时也弹。这导致你,无奈地就学会了一首诗。



image8.GIF


虽然,我们说服务没有界面,但是启动它要界面,我们写在MainActivity中,它的布局是activity_main.xml,就两个按钮,一个启动,一个关闭。


<?xml version="1.0" encoding="utf-8"?>
<ConstraintLayout tools:context=".MainActivity">
<Button
android:id="@+id/button1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="启动服务"
android:onClick="start" />
<Button
android:id="@+id/button2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="关闭服务"
android:onClick="stop" />
</ConstraintLayout>

又看到了熟悉的onClick=""这个我们之前讲过


对应下面的逻辑控制代码,里面有服务的启动和停止。


public class MainActivity extends Activity {

Intent intent;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
intent = new Intent(this, MyService.class);
}


public void start(View view) {
startService(intent);
}

public void stop(View view) {
stopService(intent);
}
}

最后,也是重点,就是我们的服务实现逻辑。


先定义一个字符串数组texts用于存储每一句诗词。


然后在onStartCommand里,启动一个线程。


线程里有一个循环,每休眠3秒钟,就往外发送一条消息。


这条消息由Handler发送和接收,接收到后切换诗句,然后通过Toast弹出来。


public class MyService extends Service {

String[] texts = new String[]{"窗前明月光","疑是地上霜","举头望明月","低头思故乡"};
int index = 0;
boolean isRun = true;

……

@Override
public int onStartCommand(final Intent intent, int flags, int startId) {

new Thread(){

@Override
public void run() {
while (isRun){
if (index+1 > texts.length){
index = 0;
}
handler.sendEmptyMessage(index);
sleep(3000);
index++;
}
}
}.start();

return super.onStartCommand(intent, flags, startId);
}

Handler handler = new Handler(){
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
Toast.makeText(MyService.this, ""+texts[msg.what], Toast.LENGTH_SHORT).show();
}
};

@Override
public void onDestroy() {
super.onDestroy();
isRun = false;
}
}

需要注意,isRun是控制是否还循环,如果为false了,while循环就不执行了,线程结束。


另外,Service也是有生命周期的,这个生命周期和Activity的生命周期类似,当 onDestroy() 销毁时,需要做一些收尾工作。


好了,最后点击运行,启动服务,去看看效果吧。


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

发布Android库至Maven Central详解

最近,使用compose编写了一个类QQ的image picker。完成android library的编写,在此记录下发布这个Library到maven central的流程以及碰到的问题。 maven:mvnrepository.com/artifact/...
继续阅读 »

最近,使用compose编写了一个类QQ的image picker。完成android library的编写,在此记录下发布这个Library到maven central的流程以及碰到的问题。


maven:mvnrepository.com/artifact/io…


github:github.com/huhx/compos…


Sonatype 账号


MavenCentral 和 Sonatype 的关系

















库平台运营商管理后台
MavenCentralSonatypes01.oss.sonatype.org

因此我们要发布Library到Maven Central的话,首先需要Sonatype的账号以及权限。


申请 Sonatype 账号


申请账号地址: issues.sonatype.org/secure/Sign…


登录账号创建issue


创建issue地址:issues.sonatype.org/secure/View…


image-20220805223118398.png


点击 Create 按钮, 然后会弹出 Create Issue的窗口:


image-20220805224204197.png


点击Configure Fields, 选择 Custom 选项


image-20220805224557793.png



grouId的话最好使用: io.github.github_name, 要不然使用其他的还需要在 DNS 配置中配置一个TXT记录来验证域名所有权



填写完所有的信息点击创建,一个新的issue就创建成功了,以下就是我创建的issue,附上链接:issues.sonatype.org/browse/OSSR…


image-20220805225725812.png


值得注意的是sonatype要求我们创建一个github仓库来验证我们的github账号。创建完仓库之后,我们回复热心的工作人员,接下来就是等他们的处理结果了。大概30分钟就能好吧


image-20220805230217988.png
收到这样的回复,代表一切ready了你可以上传package到maven central


编写gradle脚本上传Lib


这篇文章里面,我是使用的android library做例子的。如果你想要发布java的Library,可以参考:docs.gradle.org/current/use…


In module project, build.gradle file


// add maven-publish and signing gradle plugin
plugins {
id 'maven-publish'
id 'signing'
}

// add publish script
publishing {
publications {
release(MavenPublication) {
pom {
name = 'Image Picker Compose'
description = 'An Image Picker Library for Jetpack Compose'
url = 'https://github.com/huhx/compose_image_picker'

licenses {
license {
name = 'The Apache License, Version 2.0'
url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
}
}

developers {
developer {
id = 'huhx'
name = 'hongxiang'
email = 'gohuhx@gmail.com'
}
}

scm {
connection = 'https://github.com/huhx/compose_image_picker.git'
developerConnection = 'https://github.com/huhx/compose_image_picker.git'
url = 'https://github.com/huhx/compose_image_picker'
}
}

groupId "io.github.huhx"
artifactId "compose-image-picker"
version "1.0.2"

afterEvaluate {
from components.release
}
}
}
repositories {
maven {
url "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
credentials {
username ossrhUsername // ossrhUsername is your sonatype username
password ossrhPassword // ossrhUsername is your sonatype password
}
}
}
}

// signing, this need key, secret, we put it into gradle.properties
signing {
sign publishing.publications.release
}

ossrhUsernameossrhPassword 是我们在第一步注册的sonatype账号。用户名和密码是敏感信息,我们放在gradle.properties并且不会提交到github。因此在 gradle.properties文件中,我们添加了以下内容:


# signing information
signing.keyId=key
signing.password=password
signing.secretKeyRingFile=file path

# sonatype account
ossrhUsername=username
ossrhPassword=password

其中包含了签名的三个重要信息,这个我们会在下面详细讲解


创建gpg密钥


我使用的是mac,这里就拿mac来说明如何创建gpg密钥。以下是shell脚本


# 安佳 gpg
> brew install gpg

# 创建gpg key,过程中会提示你输入密码。
# 记住这里要输入的密码就是上述提到你需要配置的signing.password
> gpg --full-gen-key

# 切换目录到~/.gnupg/openpgp-revocs.d, 你会发现有一个 .rev文件。
# 这个文件名称的末尾8位字符就是上述提到你需要配置的signing.keyId
> cd ~/.gnupg/openpgp-revocs.d && ls

# 创建secretKeyRingFile, 以下命令会创建一个文件secret.gpg
# 然后~/.gnupg/secret.gpg就是上述提到你需要配置的signing.secretKeyRingFile
> cd ~/.gnupg/ && gpg --export-secret-keys -o secret.gpg

把signing相关的信息成功填写到gradle.properties之后,我们就可以借助maven-publish插件发布我们的andoird包到maven的中心仓库了


maven publish的gradle task


# 这个是发布到我们的本地,你可以在~/.m2/repository/的目录找到你发布的包
> ./gradlew clean publishToMavenLocal

# 这个是发布到maven的中心仓库,你可以在https://s01.oss.sonatype.org/#stagingRepositories找到
> ./gradlew clean publish

我们执行./gradlew clean publish命令之后,访问地址:s01.oss.sonatype.org/#stagingRep…


image-20220805233310825.png


可以看到我们的android包已经在nexus repository了,接下来你要做的两步就是Close and Release。


Maven检验以及发布


第一步:点击Close按钮,它会触发对发布包的检验。我在这个过程中碰到一个signature validation失败的问题。


# 失败原因:No public key in hkp://keyserver.ubuntu.com:11371,是因为同步key可能会花些时间。这里我们可以手动发布我们的key到相应的服务器上
> gpg --keyserver hkp://keyserver.ubuntu.com:11371 --send-keys signing.keyId

第二步:确保你填入的信息是满足要求之后,Release按钮就会被激活。点击Release,接下来就是等待时间了,不出意外的话。30分钟你可以在nexus repository manager找到,但是在mvnrepository.com/找到的话得花更长的时间。


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

Android系统编译优化:使用Ninja加快编译

背景Android系统模块代码的编译实在是太耗时了,即使寥寥几行代码的修改,也能让一台具有足够性能的编译服务器工作十几分钟以上(模块单编),只为编出一些几兆大小的jar和dex。这里探究的是系统完成过一次整编后进行的模块单编,即m、mm、mmm等命令。除此之外...
继续阅读 »

背景

Android系统模块代码的编译实在是太耗时了,即使寥寥几行代码的修改,也能让一台具有足够性能的编译服务器工作十几分钟以上(模块单编),只为编出一些几兆大小的jar和dex。

这里探究的是系统完成过一次整编后进行的模块单编,即m、mm、mmm等命令。

除此之外,一些不会更新源码、编译配置等文件的内容的操作,如touch、git操作等,会被Android系统编译工具识别为有差异,从而在编译时重新生成编译配置,重新编译并没有更新的源码、重新生成没有差异的中间文件等一系列严重耗时操作。

本文介绍关于编译过程中的几个阶段,以及这些阶段的耗时点/耗时原因,并最后给出一个覆盖一定应用场景的基于ninja的加快编译的方法(实际上是裁剪掉冗余的编译工作)

环境

编译服务器硬件及Android信息:

  • Ubuntu 18.04.4 LTS
  • Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz (28核56超线程)
  • MemTotal: 65856428 kB (62.8GiB)
  • AOSP Android 10.0
  • 仅修改某个Java文件内部的boolean初始化值(true改false)
  • 不修改其他任何内容,包括源码、mk、bp的情况下,使用m单编模块(在清理后,使用对比的ninja进行单编)
  • 使用time计时
  • 此前整个系统已经整编过一次
  • 编译时不修改任何编译配置文件如Android.mk

之所以做一个代码修改量微乎其微的case,是因为要分析编译性能瓶颈,代码变更量越小的情况下,瓶颈就越明显,越有利于分析

关键编译阶段和耗时分析

由于Makefile结构复杂、不易调试、难以扩展,因此Android决定将它替换掉。Android在7.0时引入了Soong,它将Android从Makefile的编译架构带入到了ninja的时代。

Soong包含两大模块,其中Kati负责解析Makefile并转换为.ninja,第二个模块Ninja则基于生成的.ninja完成编译。

Kati是对GNU Make的clone,并将编译后端实现切换到ninja。Kati本身不进行编译,仅生成.ninja文件提供给Ninja进行编译。

Makefile/Android.mk -> Kati -> Ninja
Android.bp -> Blueprint -> Soong -> Ninja

因此在执行编译之前(即Ninja真正开动时),还有一些生成.ninja的步骤。关键编译阶段如下:

  1. Soong的自举(Bootstrap),将Soong本身编译出来
    1. 系统代码首次编译会比较耗时,其中一个原因是Soong要全新编译它自己
  2. 遍历源码树,收集所有编译配置文件(Makefile/Android.mk/Android.bp)
    1. 遍历、验证非常耗时,多么强劲配置的机器都将受限于单线程效率和磁盘IO效率
    2. 由于Android系统各模块之间的依赖、引入,因此即使是单编模块,Soong(Kati)也不得不确认目标模块以外的路径是否需要重新跟随编译
  3. 验证编译配置文件的合法性、有效性、时效性、是否应该加入编译,生成.ninja
    1. 如果没有任何更改,.ninja不需要重新生成
    2. 最终生成的.ninja文件很大(In my case,1GB以上),有很明显的IO性能效率问题,显然在查询效率方面也很低下
  4. 最后一步,真正执行编译,调用ninja进入多线程编译
    1. 由于Android加入了大量的代码编译期工作,如API权限控制检查、API列表生成等工作(比如,生成系统API保护名单、插桩工作等等),因此编译过程实际上不是完全投入到编译中
    2. 编译过程穿插“泛打包工作”,如生成odex、art、res资源打包。虽然不同的“泛打包”可以多线程并行进行,但是每个打包本身只能单线程进行

下面将基于模块单编(因开发环境系统全新编译场景频率较低,不予考虑),对这四个关键阶段进行性能分析。

阶段一:Soong bootstrap

在系统已经整编过一次的情况下,Soong已经完成了编译,因此其预热过程占整个编译时间的比例会比较小。

在“环境”下,修改一行Framework代码触发差异进行编译。并且使用下面的命令进行编译。

time m services framework -j57

编译实际耗时22m37s:

 build completed successfully (22:37 (mm:ss)) ####
real 22m37.504s
user 110m25.656s
sys 12m28.056s

对应的分阶段耗时如下图。

  • 可以看到,包括Soong bootstrap流程在内的预热耗时占比非常低,耗时约为11.6s,总耗时约为1357s,预热耗时占比为0.8%

Soong编译耗时占比

  • Kati和ninja,也就是上述编译关键流程的第2步和第3步,分别占了接近60%(820秒,13分钟半)和约35%(521秒,8分钟半)的耗时,合计占比接近95%的耗时。

注:这个耗时是仅小幅度修改Java代码后测试的耗时。如果修改编译配置文件如Android.mk,会有更大的耗时。

小结:看来在完成一次整编后的模块单编,包括Soong bootstrap、执行编译准备脚本、vendorsetup脚本的耗时占比很低,可以完全排除存在性能瓶颈的可能。

阶段二:Kati遍历、mk搜集与ninja生成

从上图可以看到,Kati耗时占比很大,它的任务是遍历源码树,收集所有的编译配置文件,经过验证和筛选后,将它们解析并转化为.ninja

从性能角度来看,它的主要特点如下:

  1. 它要遍历源码树,收集所有mk文件(In my case,有983个mk文件)
  2. 解析mk文件(In my case,framework/base/Android.mk耗费了~6800ms)
  3. 生成并写入对应的.ninja
  4. 单线程

直观展示如下,它是一个单线程的、IO速度敏感、CPU不敏感的过程:

Soong编译-Kati耗时细节.png

Kati串行地处理文件,此时对CPU利用率很低,对IO的压力也不高。

小结:可以确定它的性能瓶颈来源于IO速度,单纯为编译实例分配更多的CPU资源也无益于提升Kati的速度。

阶段三:Ninja编译

SoongClone了一份GNU Make,并将其改造为Kati。即使我们没有修改任何mk文件,前面Kati仍然会花费数分钟到数十分钟的工作耗时,只为了生成一份能够被Ninja.ninja的生成工具能够识别的文件。接下来是调用Ninja真正开始编译工作。

从性能角度来看,它的主要特点如下:

  1. 根据目标target及依赖,读取前面生成的.ninja配置,进行编译
  2. 比较独立,不与前面的组件,如blueprint、kati等耦合,只要.ninja文件中能找到target和build rule就能完成编译
  3. 多线程

直观展示如下,Ninja将会根据传入的并行任务数参数启动对应数量的线程进行编译。Ninja编译阶段会真正的启动多线程。但做不到一直多线程编译,因为部分阶段如部分编译目标(比如生成一个API文档)、泛打包阶段等本身无法多线程并行执行。

Soong编译-ninja耗时.png

可以看到此时CPU利用率应该是可以明显上升的。但是耗时较大的阶段仅启用了几个线程,后面的阶段和最后的图形很细(时间占比很小)的阶段才用起来更多的线程。

其中,一些阶段(图中时间占比较长的几条记录)没能跑满资源的原因是这些编译目标本身不支持并行,且本次编译命令指定的目标已经全部“安排”了,不需要调动更多资源启动其他编译目标的工作。当编译整个系统时就能够跑满了。

最后一个阶段(图中最后的几列很细的记录)虽然跑满了所有线程资源,但是运行时间很短。这是因为本case进行编译分析的过程中,仅修改了一行代码来触发编译。因编译工作量很小,所以这几列很细。

小结:我们看到,Ninja编译启动比较快,这表明Ninja.ninja文件的读取解析并不敏感。整个过程也没有看到显著的耗时点。且最后面编译量很小,表明Ninja能够确保增量编译、未更新不编译。

编译优化

本节完成点题——Android系统编译优化:使用Ninja加快编译。

根据前面分析的小结,可以总结性能瓶颈:

  1. Kati遍历、生成太慢,受限于IO速率
  2. Kati吞吐量太低,单线程
  3. 不论有无更新均重新解析Makefile

利用Ninja进行编译优化的思路是,大多数场景,可以舍弃Kati的工作,仅执行Ninja的工作,以节省掉60%以上的时间。其核心思路,也是制约条件,即在不影响编译正确性的前提下,舍弃不必要的Kati编译工作

  • 使用Ninja直接基于.ninja文件进行编译来改善耗时:

结合前面的分析,容易想到,如果目标被构建前,能够确保mk文件没有更新也不需要重新生成一长串的最终编译目标(即.ninja),那么make命令带来的Soong bootstrap、Kati等工作完全是重复的冗余的——这个性质Soong和Kati自己识别不出来,它们会重复工作一次。

既重新生成.ninja是冗余的,那么直接命令编译系统根据指定的.ninja进行编译显然会节省大量的工作耗时。ninja命令is the key:

使用源码中自带的ninja:

./prebuilts/build-tools/linux-x86/bin/ninja --version
1.8.2.git

对比最上面列出的make命令的编译,这里用ninja编译同样的目标:

 time ./prebuilts/build-tools/linux-x86/bin/ninja -j 57 -v -f out/combined-full_xxxxxx.ninja services framework

ninja自己识别出来CPU平台后,默认使用-j58。这里为了对比上面的m命令,使用-j57编译

-f参数指定.ninja文件。它是编译配置文件,在Android中由Kati生成。这里文件名用'x'替换修改

编译结果,对比上面的m,有三倍的提升:

real    7m57.835s
user 97m12.564s
sys 8m31.756s

编译耗时为8分半,仅make的三分之一。As we can see,当能够确保编译配置没有更新,变更仅存在于源码范围时,使用Ninja直接编译,跳过Kati可以取得很显著的提升

直接使用ninja:

./prebuilts/build-tools/linux-x86/bin/ninja -j $MAKE_JOBS -v -f out/combined-*.ninja <targets...>

对比汇总

这里找了一个其他项目的编译Demo,该Demo的特点是本身代码较简单,编译配置也较简单,整体编译工作较少,通过make编译的大部分耗时来自soong、make等工具自身的消耗,而真正执行编译的ninja耗时占比极其低。由于ninja本身跳过了soong,因此可以跳过这一无用的繁琐的耗时。可以看到下面,ninja编译iperf仅花费10秒。这个时间如果给soong来编译,预热都不够。

$ -> f_ninja_msf iperf
Run ninja with out/combined-full_xxxxxx.ninja to build iperf.
====== ====== ======
Ninja: ./prebuilts/build-tools/linux-x86/bin/ninja@1.8.2.git
Ninja: build with out/combined-full_xxxxxx.ninja
Ninja: build targets iperf
Ninja: j72
====== ====== ======
time /usr/bin/time ./prebuilts/build-tools/linux-x86/bin/ninja -j 72 -f out/combined-full_xxxxxx.ninja iperf

[24/24] Install: out/target/product/xxxxxx/system/bin/iperf
53.62user 11.09system 0:10.17elapsed 636%CPU (0avgtext+0avgdata 5696772maxresident)
4793472inputs+5992outputs (4713major+897026minor)pagefaults 0swaps

real 0m10.174s
user 0m53.624s
sys 0m11.096s

下面给出soong编译的恐怖耗时:

$ -> rm out/target/product/xxxxxx/system/bin/iperf
$ -> time m iperf -j72

...

[100% 993/993] Install: out/target/product/xxxxxx/system/bin/iperf

#### build completed successfully (14:45 (mm:ss)) ####


real 14m45.164s
user 23m40.616s
sys 11m46.248s

As we can see,m和ninja一个是10+ minutes,一个是10+ seconds,比例是88.5倍。


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

收起阅读 »

Kotlin 标准库随处可见的 contract 到底是什么?

Kotlin 的标准库提供了不少方便的实用工具函数,比如 with, let, apply 之流,这些工具函数有一个共同特征:都调用了 contract() 函数。@kotlin.internal.I...
继续阅读 »

Kotlin 的标准库提供了不少方便的实用工具函数,比如 withletapply 之流,这些工具函数有一个共同特征:都调用了 contract() 函数

@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
contract {
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
}
return receiver.block()
}

@kotlin.internal.InlineOnly
public inline fun repeat(times: Int, action: (Int) -> Unit) {
contract { callsInPlace(action) }

for (index in 0 until times) {
action(index)
}
}

contract?协议?它到底是起什么作用?

函数协议

contract 其实就是一个顶层函数,所以可以称之为函数协议,因为它就是用于函数约定的协议

@ContractsDsl
@ExperimentalContracts
@InlineOnly
@SinceKotlin("1.3")
@Suppress("UNUSED_PARAMETER")
public inline fun contract(builder: ContractBuilder.() -> Unit) { }

用法上,它有两点要求:

  • 仅用于顶层方法
  • 协议描述须置于方法开头,且至少包含一个「效应」(Effect)

可以看到,contract 的函数体为空,居然没有实现,真是一个神奇的存在。这么一来,此方法的关键点就只在于它的参数了。

ContractBuilder

contract的参数是一个将 ContractBuilder 作为接受者的lambda,而 ContractBuilder 是一个接口:

@ContractsDsl
@ExperimentalContracts
@SinceKotlin("1.3")
public interface ContractBuilder {
@ContractsDsl public fun returns(): Returns
@ContractsDsl public fun returns(value: Any?): Returns
@ContractsDsl public fun returnsNotNull(): ReturnsNotNull
@ContractsDsl public fun <R> callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN): CallsInPlace
}

其四个方法分别对应了四种协议类型,它们的功能如下:

  • returns:表明所在方法正常返回无异常
  • returns(value: Any?):表明所在方法正常执行,并返回 value(其值只能是 true、false 或者 null)
  • returnsNotNull():表明所在方法正常执行,且返回任意非 null 值
  • callsInPlace(lambda: Function<R>, kind: InvocationKind = InvocationKind.UNKNOWN):声明 lambada 只在所在方法内执行,所在方法执行完毕后,不会再被其他方法调用;可通过 kind 指定调用次数

前面已经说了,contract 的实现为空,所以作为接受着的 ContractBuilder 类型,根本没有实现类 —— 因为没有地方调用,就不需要啊。它的存在,只是为了声明所谓的协议代编译器使用。

InvocationKind

InvocationKind 是一个枚举类型,用于给 callsInPlace 协议方法指定执行次数的说明

@ContractsDsl
@ExperimentalContracts
@SinceKotlin("1.3")
public enum class InvocationKind {
// 函数参数执行一次或者不执行
@ContractsDsl AT_MOST_ONCE,
// 函数参数至少执行一次
@ContractsDsl AT_LEAST_ONCE,
// 函数参数执行一次
@ContractsDsl EXACTLY_ONCE,
// 函数参数执行次数未知
@ContractsDsl UNKNOWN
}

InvocationKind.UNKNOWN,次数未知,其实就是指任意次数。标准工具函数中,repeat 就指定的此类型,因为其「重复」次数由参数传入,确实未知;而除它外,其余像 letwith 这些,都是用的InvocationKind.EXACTLY_ONCE,即单次执行。

Effect

Effect 接口类型,表示一个方法的执行协议约定,其不同子接口,对应不同的协议类型,前面提到的 ReturnsReturnsNotNullCallsInPlace 均为它的子类型。

public interface Effect

public interface ConditionalEffect : Effect

public interface SimpleEffect : Effect {
public infix fun implies(booleanExpression: Boolean): ConditionalEffect
}

public interface Returns : SimpleEffect

public interface ReturnsNotNull : SimpleEffect

public interface CallsInPlace : Effect

简单明了,全员接口!来看一个官方使用,以便理解下这些接口的意义和使用:

public inline fun Array<*>?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}

return this == null || this.isEmpty()
}

这里涉及到两个 Effect:Returns 和 ConditionalEffect。此方法的功能为:判断数组为 null 或者是无元素空数组。它的 contract 约定是这样的:

  1. 调用 returns(value: Any?) 获得 Returns 协议(当然也就是 SimpleEffect 协议),其传入值是 false
  2. 第1步的 Returns 调用 implies 方法,条件是「本对象非空」,得到了一个 ConditionalEffect
  3. 于是,最终协议的意思是:函数返回 false 意味着 接受者对象非空

isNullOrEmpty() 的功能性代码给出了返回值为 true 的条件。虽然反过来说,不满足该条件,返回值就是 false,但还是通过 contract 协议里首先说明了这一点。

协议的意义

讲到这里,contract 协议涉及到的基本类型及其使用已经清楚了。回过头来,前面说到,contract() 的实现为空,即函数体为空,没有实际逻辑。这说明,这个调用是没有实际执行效果的,纯粹是为编译器服务。

不妨模仿着 let 写一个带自定义 contract 测试一下这个结论:

// 类比于ContractBuilder
interface Bonjour {

// 协议方法
fun <R> parler(f: Function<R>) {
println("parler something")
}
}


// 顶层协议声明工具,类比于contract
inline fun bonjour(b: Bonjour.() -> Unit) {}


// 模仿let
fun<T, R> T.letForTest(block: (T) -> R): R {
println("test before")
bonjour {
println("test in bonjour")
parler<String> {
""
}
}
println("test after")
return block(this)
}

fun main(args: Array<String>) {
"abc".letForTest {
println("main: $it called")
}
}

letForTest() 是类似于 let 的工具方法(其本身功能逻辑不重要)。执行结果:

test before
test after
main: abc called

如预期,bonjour 协议以及 Bonjour 协议构造器中的所有日志都未打印,都未执行。

这再一次印证,contract 协议仅为编译器提供信息。那协议对编码来说到底有什么意义呢?来看看下面的场景:

fun getString(): String? {
TODO()
}

fun String?.isAvailable(): Boolean {
return this != null && this.length > 0
}

getString() 方法返回一个 String 类型,但是有可能为 null。isAvailable 是 String? 类型的扩展,用以判断是否一个字符串非空且长度大于 0。使用如下:

val target = getString()
if (target.isAvailable()) {
val result: String = target
}

按代码的设计初衷,上述调用没问题,target.isAvailable() 为 true,证明 target 是非空且长度大于 0 的字符串,然后内部将它赋给 String 类型 —— 相当于 String? 转换成 String。

可惜,上述代码,编译器不认得,报错了:

Type mismatch.
Required:
String
Found:
String?

编译器果然没你我聪明啊!要解决这个问题,自然就得今天的主角上场了:

fun String?.isAvailable(): Boolean {
contract {
returns(true) implies (this@isAvailable != null)
}
return this != null && this.length > 0
}

使用 contract 协议指定了一个 ConditionalEffect,描述意思为:如果函数返回true,意味着 Receiver 类型非空。然后,编译器终于懂了,前面的错误提示消失。

这就是协议的意义所在:让编译器看不懂的代码更加明确清晰

小结

函数协议可以说是写工具类函数的利器,可以解决很多因为编译器不够智能而带来的尴尬问题。不过需要明白的是,函数协议还是实验性质的,还没有正式发布为 stable 功能,所以是有可能被 Kotlin 官方 去掉的。


作者:王可大虾
链接:https://juejin.cn/post/7128258776376803359
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

来,跟我一起撸Kotlin runBlocking/launch/join/async/delay 原理&使用

之前一些列的文章重点在于分析协程本质原理,了解了协程的内核再来看其它衍生的知识就比较容易了。 接下来这边文章着重分析协程框架提供的一些重要的函数原理,通过本篇文章,你将了解到: runBlocking 使用与原理 launch 使用与原理 join 使用与...
继续阅读 »

之前一些列的文章重点在于分析协程本质原理,了解了协程的内核再来看其它衍生的知识就比较容易了。

接下来这边文章着重分析协程框架提供的一些重要的函数原理,通过本篇文章,你将了解到:




  1. runBlocking 使用与原理

  2. launch 使用与原理

  3. join 使用与原理

  4. async/await 使用与原理

  5. delay 使用与原理



1. runBlocking 使用与原理


默认分发器的runBlocking


使用


老规矩,先上Demo:


    fun testBlock() {
println("before runBlocking thread:${Thread.currentThread()}")
//①
runBlocking {
println("I'm runBlocking start thread:${Thread.currentThread()}")
Thread.sleep(2000)
println("I'm runBlocking end")
}
//②
println("after runBlocking:${Thread.currentThread()}")
}

runBlocking 开启了一个新的协程,它的特点是:



协程执行结束后才会执行runBlocking 后的代码。



也就是① 执行结束后 ② 才会执行。



image.png


可以看出,协程运行在当前线程,因此若是在协程里执行了耗时函数,那么协程之后的代码只能等待,基于这个特性,runBlocking 经常用于一些测试的场景。


runBlocking 可以定义返回值,比如返回一个字符串:


    fun testBlock2() {
var name = runBlocking {
"fish"
}
println("name $name")
}

原理


    #Builders.kt
public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {
//当前线程
val currentThread = Thread.currentThread()
//先看有没有拦截器
val contextInterceptor = context[ContinuationInterceptor]
val eventLoop: EventLoop?
val newContext: CoroutineContext
//----------①
if (contextInterceptor == null) {
//不特别指定的话没有拦截器,使用loop构建Context
eventLoop = ThreadLocalEventLoop.eventLoop
newContext = GlobalScope.newCoroutineContext(context + eventLoop)
} else {
eventLoop = (contextInterceptor as? EventLoop)?.takeIf { it.shouldBeProcessedFromContext() }
?: ThreadLocalEventLoop.currentOrNull()
newContext = GlobalScope.newCoroutineContext(context)
}
//BlockingCoroutine 顾名思义,阻塞的协程
val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
//开启
coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
//等待协程执行完成----------②
return coroutine.joinBlocking()
}

重点看①②。


先说①,因为我们没有指定分发器,因此会使用loop,实际创建的是BlockingEventLoop,它继承自EventLoopImplBase,最终继承自CoroutineDispatcher(注意此处是个重点)。

根据我们之前分析的协程知识可知,协程启动后会构造DispatchedContinuation,然后依靠dispatcher将runnable 分发执行,而这个dispatcher 即是BlockingEventLoop。


    #EventLoop.common.kt
//重写dispatch函数
public final override fun dispatch(context: CoroutineContext, block: Runnable) = enqueue(block)

public fun enqueue(task: Runnable) {
//将task 加入队列,task = DispatchedContinuation
if (enqueueImpl(task)) {
unpark()
} else {
DefaultExecutor.enqueue(task)
}
}

BlockingEventLoop 的父类EventLoopImplBase 里有个成员变量:_queue,它是个队列,用来存储提交的任务。


再看②:

协程任务已经提交到队列里,就看啥时候取出来执行了。


#Builders.kt
fun joinBlocking(): T {
try {
try {
while (true) {
//当前线程已经中断了,直接退出
if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
//如果eventLoop!= null,则从队列里取出task并执行
val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
//协程执行结束,跳出循环
if (isCompleted) break
//挂起线程,parkNanos 指的是挂起时间
parkNanos(this, parkNanos)
//当线程被唤醒后,继续while循环
}
} finally { // paranoia
}
}
//返回结果
return state as T
}

#EventLoop.common.kt
override fun processNextEvent(): Long {
//延迟队列
val delayed = _delayed.value
//延迟队列处理,这里在分析delay时再解释
//从队列里取出task
val task = dequeue()
if (task != null) {
//执行task
task.run()
return 0
}
return nextTime
}

上面代码的任务有两个:




  1. 尝试从队列里取出Task。

  2. 若是没有则挂起线程。



结合①②两点,再来过一下场景:




  1. 先创建协程,包装为DispatchedContinuation,作为task。

  2. 分发task,将task加入到队列里。

  3. 从队列里取出task执行,实际执行的即是协程体。

  4. 当3执行完毕后,runBlocking()函数也就退出了。




image.png


其中虚线箭头表示执行先后顺序。

由此可见,runBlocking()函数需要等待协程执行完毕后才退出。


指定分发器的runBlocking


上个Demo在使用runBlocking 时没有指定其分发器,若是指定了又是怎么样的流程呢?


    fun testBlock3() {
println("before runBlocking thread:${Thread.currentThread()}")
//①
runBlocking(Dispatchers.IO) {
println("I'm runBlocking start thread:${Thread.currentThread()}")
Thread.sleep(2000)
println("I'm runBlocking end")
}
//②
println("after runBlocking:${Thread.currentThread()}")
}

指定在子线程里进行分发。

此处与默认分发器最大的差别在于:



默认分发器加入队列、取出队列都是同一个线程,而指定分发器后task不会加入到队列里,task的调度执行完全由指定的分发器完成。



也就是说,coroutine.joinBlocking()后,当前线程一定会被挂起。等到协程执行完毕后再唤醒当前被挂起的线程。

唤醒之处在于:


#Builders.kt
override fun afterCompletion(state: Any?) {
// wake up blocked thread
if (Thread.currentThread() != blockedThread)
//blockedThread 即为调用coroutine.joinBlocking()后阻塞的线程
//Thread.currentThread() 为线程池的线程
//唤醒线程
unpark(blockedThread)
}


image.png


红色部分比紫色部分先执行,因此红色部分执行的线程会阻塞,等待紫色部分执行完毕后将它唤醒,最后runBlocking()函数执行结束了。


不管是否指定分发器,runBlocking() 都会阻塞等待协程执行完毕。


2. launch 使用与原理


想必大家刚接触协程的时候使用最多的还是launch启动协程吧。

看个Demo:


    fun testLaunch() {
var job = GlobalScope.launch {
println("hello job1 start")//①
Thread.sleep(2000)
println("hello job1 end")//②
}
println("continue...")//③
}

非常简单,启动一个线程,打印结果如下:



image.png


③一定比①②先打印,同时也说明launch()函数并不阻塞当前线程。

关于协程原理,在之前的文章都有深入分析,此处不再赘述,以图示之:



image.png


3. join 使用与原理


虽然launch()函数不阻塞线程,但是我们就想要知道协程执行完毕没,进而根据结果确定是否继续往下执行,这时候该Job.join()出场了。

先看该函数的定义:


#Job.kt
public suspend fun join()

是个suspend 修饰的函数,suspend 是咱们的老朋友了,说明协程执行到该函数会挂起(当前线程不阻塞,另有他用)。

继续看其实现:


    #JobSupport.kt
public final override suspend fun join() {
//快速判断状态,不耗时
if (!joinInternal()) { // fast-path no wait
coroutineContext.ensureActive()
return // do not suspend
}
//挂起的地方
return joinSuspend() // slow-path wait
}

//suspendCancellableCoroutine 典型的挂起操作
//cont 是封装后的协程
private suspend fun joinSuspend() = suspendCancellableCoroutine<Unit> { cont ->
//执行完这就挂起
//disposeOnCancellation 是将cont 记录在当前协程的state里,构造为node
cont.disposeOnCancellation(invokeOnCompletion(handler = ResumeOnCompletion(cont).asHandler))
}

其中suspendCancellableCoroutine 是挂起的核心所在,关于挂起的详细分析请移步:讲真,Kotlin 协程的挂起没那么神秘(原理篇)


joinSuspend()函数有2个作用:




  1. 将当前协程体存储到Job的state里(作为node)。

  2. 将当前协程挂起。



什么时候恢复呢?当然是协程执行完成后。


#JobSupport.kt
private class ResumeOnCompletion(
private val continuation: Continuation<Unit>
) : JobNode() {
//continuation 为协程的包装体,它里面有我们真正的协程体
//之后重新进行分发
override fun invoke(cause: Throwable?) = continuation.resume(Unit)
}

当协程执行完毕,会例行检查当前的state是否有挂着需要执行的node,刚好我们在joinSuspend()里放了node,于是找到该node,进而找到之前的协程体再次进行分发。根据协程状态机的知识可知,这是第二次执行协程体,因此肯定会执行job.join()之后的代码,于是乎看起来的效果就是:



job.join() 等待协程执行完毕后才会往下执行。



语言比较苍白,来个图:



image.png


注:此处省略了协程挂起等相关知识,如果对此有疑惑请阅读之前的文章。


4. async/await 使用与原理


launch 有2点不足之处:协程执行没有返回值。

这点我们从它的定义很容易获悉:



image.png


然而,在有些场景我们需要返回值,此时轮到async/await 出场了。


    fun testAsync() {
runBlocking {
//启动协程
var job = GlobalScope.async {
println("job1 start")
Thread.sleep(10000)
//返回值
"fish"
}
//等待协程执行结束,并返回协程结果
var result = job.await()
println("result:$result")
}
}

运行结果:



image.png


接着来看实现原理。


    public fun <T> CoroutineScope.async(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> T
): Deferred<T> {
val newContext = newCoroutineContext(context)
//构造DeferredCoroutine
val coroutine = if (start.isLazy)
LazyDeferredCoroutine(newContext, block) else
DeferredCoroutine<T>(newContext, active = true)
//coroutine == DeferredCoroutine
coroutine.start(start, coroutine, block)
return coroutine
}

与launch 启动方式不同的是,async 的协程定义了返回值,是个泛型。并且async里使用的是DeferredCoroutine,顾名思义:延迟给结果的协程。

后面的流程都是一样的,不再细说。


再来看Job.await(),它与Job.join()类似:




  1. 先判断是否需要挂起,若是协程已经结束/被取消,当然就无需等待直接返回。

  2. 先将当前协程体包装到state里作为node存放,然后挂起协程。

  3. 等待async里的协程执行完毕,再重新调度执行await()之后的代码。

  4. 此时协程的值已经返回。



这里需要重点关注一下返回值是怎么传递过来的。



image.png


将testAsync()反编译:


    public final Object invokeSuspend(@NotNull Object $result) {
//result 为协程执行结果
Object var6 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Object var10000;
switch(this.label) {
case 0:
//第一次执行这
ResultKt.throwOnFailure($result);
Deferred job = BuildersKt.async$default((CoroutineScope) GlobalScope.INSTANCE, (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object var1) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure(var1);
String var2 = "job1 start";
boolean var3 = false;
System.out.println(var2);
Thread.sleep(10000L);
return "fish";
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
}
}), 3, (Object)null);
this.label = 1;
//挂起
var10000 = job.await(this);
if (var10000 == var6) {
return var6;
}
break;
case 1:
//第二次执行这
ResultKt.throwOnFailure($result);
//result 就是demo里的"fish"
var10000 = $result;
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}

String result = (String)var10000;
String var4 = "result:" + result;
boolean var5 = false;
System.out.println(var4);
return Unit.INSTANCE;
}

很明显,外层的协程(runBlocking)体会执行2次。

第1次:调用invokeSuspend(xx),此时参数xx=Unit,后遇到await 被挂起。

第2次:子协程执行结束并返回结果"fish",恢复外部协程时再次调用invokeSuspend(xx),此时参数xx="fish",并将参数保存下来,因此result 就有了值。


值得注意的是:

async 方式启动的协程,若是协程发生了异常,不会像launch 那样直接抛出,而是需要等待调用await()时抛出。


5. delay 使用与原理


线程可以被阻塞,协程可以被挂起,挂起后的协程等待时机成熟可以被恢复。


    fun testDelay() {
GlobalScope.launch {
println("before getName")
var name = getUserName()
println("after getName name:$name")
}
}
suspend fun getUserName():String {
return withContext(Dispatchers.IO) {
//模拟网络获取
Thread.sleep(2000)
"fish"
}
}

获取用户名字是在子线程获取的,它是个挂起函数,当协程执行到此时挂起,等待获取名字之后再恢复运行。


有时候我们仅仅只是想要协程挂起一段时间,并不需要去做其它操作,这个时候我们可以选择使用delay(xx)函数:


    fun testDelay2() {
GlobalScope.launch {
println("before delay")
//协程挂起5s
delay(5000)
println("after delay")
}
}

再来看看其原理。


#Delay.kt
public suspend fun delay(timeMillis: Long) {
//没必要延时
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
//封装协程为cont,便于之后恢复
if (timeMillis < Long.MAX_VALUE) {
//核心实现
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

主要看context.delay 实现:


#DefaultExecutor.kt
internal actual val DefaultDelay: Delay = kotlinx.coroutines.DefaultExecutor

//单例
internal actual object DefaultExecutor : EventLoopImplBase(), Runnable {
const val THREAD_NAME = "kotlinx.coroutines.DefaultExecutor"
//...
private fun createThreadSync(): Thread {
return DefaultExecutor._thread ?: Thread(this, DefaultExecutor.THREAD_NAME).apply {
DefaultExecutor._thread = this
isDaemon = true
start()
}
}
//...
override fun run() {
//循环检测队列是否有内容需要处理
//决定是否要挂起线程
}
//...
}

DefaultExecutor 是个单例,它里边开启了线程,并且检测队列里任务的情况来决定是否需要挂起线程等待。


先看队列的出入队情况。


放入队列

我们注意到DefaultExecutor 继承自EventLoopImplBase(),在最开始分析runBlocking()时有提到过它里面有成员变量_queue 存储队列元素,实际上它还有另一个成员变量_delayed:


#EventLoop.common.kt
internal abstract class EventLoopImplBase: EventLoopImplPlatform(), Delay {
//存放正常task
private val _queue = atomic<Any?>(null)
//存放延迟task
private val _delayed = atomic<EventLoopImplBase.DelayedTaskQueue?>(null)
}

private inner class DelayedResumeTask(
nanoTime: Long,
private val cont: CancellableContinuation<Unit>
) : EventLoopImplBase.DelayedTask(nanoTime) {
//协程恢复
override fun run() { with(cont) { resumeUndispatched(Unit) } }
override fun toString(): String = super.toString() + cont.toString()
}

delay.scheduleResumeAfterDelay 本质是创建task:DelayedResumeTask,并将该task加入到延迟队列_delayed里。


从队列取出

DefaultExecutor 一开始就会调用processNextEvent()函数检测队列是否有数据,如果没有则将线程挂起一段时间(由processNextEvent()返回值确定)。

那么重点转移到processNextEvent()上。


##EventLoop.common.kt
override fun processNextEvent(): Long {
if (processUnconfinedEvent()) return 0
val delayed = _delayed.value
if (delayed != null && !delayed.isEmpty) {
//调用delay 后会放入
//查看延迟队列是否有任务
val now = nanoTime()
while (true) {
//一直取任务,直到取不到(时间未到)
delayed.removeFirstIf {
//延迟任务时间是否已经到了
if (it.timeToExecute(now)) {
//将延迟任务从延迟队列取出,并加入到正常队列里
enqueueImpl(it)
} else
false
} ?: break // quit loop when nothing more to remove or enqueueImpl returns false on "isComplete"
}
}
// 从正常队列里取出
val task = dequeue()
if (task != null) {
//执行
task.run()
return 0
}
//返回线程需要挂起的时间
return nextTime
}

而执行任务最终就是执行DelayedResumeTask.run()函数,该函数里会对协程进行恢复。


至此,delay 流程就比较清晰了:




  1. 构造task 加入到延迟队列里,此时协程挂起。

  2. 有个单独的线程会检测是否需要取出task并执行,没到时间的话就要挂起等待。

  3. 时间到了从延迟队列里取出并放入正常的队列,并从正常队列里取出执行。

  4. task 执行的过程就是协程恢复的过程。



老规矩,上图:



image.png


图上虚线紫色框部分表明delay 执行到此就结束了,协程挂起(不阻塞当前线程),剩下的就交给单例的DefaultExecutor 调度,等待延迟的时间结束后通知协程恢复即可。


关于协程一些常用的函数分析到此就结束了,下篇开始我们一起探索协程通信(Channel/Flow 等)相关知识。

由于篇幅原因,省略了一些源码的分析,若你对此有疑惑,可评论或私信小鱼人。


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

封装一个有趣的 Loading 组件

前言在上一篇普通的加载千篇一律,有趣的 loading 万里挑一 中,我们介绍了使用Path类的PathMetrics属性来控制绘制点在路径上运动来实现比较有趣的loading效果。有评论说因为是黑色背景,所以看着好看。黑色背景确实显得高端一点,但是...
继续阅读 »

前言

在上一篇普通的加载千篇一律,有趣的 loading 万里挑一 中,我们介绍了使用Path类的PathMetrics属性来控制绘制点在路径上运动来实现比较有趣的loading效果。有评论说因为是黑色背景,所以看着好看。黑色背景确实显得高端一点,但是并不是其他配色也不行,本篇我们来封装一个可以自定义配置前景色和背景色的Loading组件。

组件定义

loading组件共定义4个入口参数:

  • 前景色:绘制图形的前景色;
  • 背景色:绘制图形的背景色;
  • 图形尺寸:绘制图形的尺寸;
  • 加载文字:可选,如果有文字就显示,没有就不显示。

得到的Loading组件类如下所示:

class LoadingAnimations extends StatefulWidget {
final Color bgColor;
final Color foregroundColor;
String? loadingText;
final double size;
LoadingAnimations(
{required this.foregroundColor,
required this.bgColor,
this.loadingText,
this.size = 100.0,
Key? key})
: super(key: key);

@override
_LoadingAnimationsState createState() => _LoadingAnimationsState();
}

圆形Loading

我们先来实现一个圆形的loading,效果如下所示。 circle_loading.gif 这里绘制了两组沿着一个大圆运动的轴对称的实心圆,半径依次减小,圆心间距随着动画时间逐步拉大。实际上实现的核心还是基于PathPathMetrics。具体实现代码如下:

_drawCircleLoadingAnimaion(
Canvas canvas, Size size, Offset center, Paint paint) {
final radius = boxSize / 2;
final ballCount = 6;
final ballRadius = boxSize / 15;

var circlePath = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));

var circleMetrics = circlePath.computeMetrics();
for (var pathMetric in circleMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}
}

其中路径比例为lengthRatio,通过animationValue乘以一个系数使得实心圆的间距越来越大 ,同时通过Offset(size.width - tangent.position.dx, size.height - tangent.position.dy)绘制了一组对对称的实心圆,这样整体就有一个圆形的效果了,动起来也会更有趣一点。

椭圆运动Loading

椭圆和圆形没什么区别,这里我们搞个渐变的效果看看,利用之前介绍过的Paintshader可以实现渐变色绘制效果。

oval_loading.gif

实现代码如下所示。

final ballCount = 6;
final ballRadius = boxSize / 15;

var ovalPath = Path()
..addOval(Rect.fromCenter(
center: center, width: boxSize, height: boxSize / 1.5));
paint.shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [this.foregroundColor, this.bgColor],
).createShader(Offset.zero & size);
var ovalMetrics = ovalPath.computeMetrics();
for (var pathMetric in ovalMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}

当然,如果渐变色的颜色更丰富一点会更有趣些。

colorful_loading.gif

贝塞尔曲线Loading

通过贝塞尔曲线构建一条Path,让一组圆形沿着贝塞尔曲线运动的Loading效果也很有趣。

bezier_loading.gif

原理和圆形的一样,首先是构建贝塞尔曲线Path,代码如下。

var bezierPath = Path()
..moveTo(size.width / 2 - boxSize / 2, center.dy)
..quadraticBezierTo(size.width / 2 - boxSize / 4, center.dy - boxSize / 4,
size.width / 2, center.dy)
..quadraticBezierTo(size.width / 2 + boxSize / 4, center.dy + boxSize / 4,
size.width / 2 + boxSize / 2, center.dy)
..quadraticBezierTo(size.width / 2 + boxSize / 4, center.dy - boxSize / 4,
size.width / 2, center.dy)
..quadraticBezierTo(size.width / 2 - boxSize / 4, center.dy + boxSize / 4,
size.width / 2 - boxSize / 2, center.dy);

这里实际是构建了两条贝塞尔曲线,先从左边到右边,然后再折回来。之后就是运动的实心圆了,这个只是数量上多了,ballCount30,这样效果看着就有一种拖影的效果。

var ovalMetrics = bezierPath.computeMetrics();
for (var pathMetric in ovalMetrics) {
for (var i = 0; i < ballCount; ++i) {
var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(
Offset(size.width - tangent.position.dx,
size.height - tangent.position.dy),
ballRadius / (1 + i),
paint);
}
}

这里还可以改变运动方向,实现一些其他的效果,例如下面的效果,第二组圆球的绘制位置实际上是第一组圆球的x、y坐标的互换。

bezier_loading_transform.gif

var lengthRatio = animationValue * (1 - i / ballCount);
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * lengthRatio);

var ballPosition = tangent!.position;
canvas.drawCircle(ballPosition, ballRadius / (1 + i), paint);
canvas.drawCircle(Offset(tangent.position.dy, tangent.position.dx),
ballRadius / (1 + i), paint);

组件使用

我们来看如何使用我们定义的这个组件,使用代码如下,我们用Future延迟模拟了一个加载效果,在加载过程中使用loading指示加载过程,加载完成后显示图片。

class _LoadingDemoState extends State<LoadingDemo> {
var loaded = false;

@override
void initState() {
super.initState();
Future.delayed(Duration(seconds: 5), () {
setState(() {
loaded = true;
});
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('Loading 使用'),
),
body: Center(
child: loaded
? Image.asset(
'images/beauty.jpeg',
width: 100.0,
)
: LoadingAnimations(
foregroundColor: Colors.blue,
bgColor: Colors.white,
size: 100.0,
),
),
);
}

最终运行的效果如下,源码已提交至:绘图相关源码,文件名为loading_animations.dart

loading_usage.gif

总结

本篇介绍了Loading组件的封装方法,核心要点还是利用Path和动画控制绘制元素的运动轨迹来实现更有趣的效果。在实际应用过程中,也可以根据交互设计的需要,做一些其他有趣的加载动效,提高等待过程的趣味性。


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

收起阅读 »

关于mmap不为人知的秘密

mmap初入 我们常说的mmap,其实是一种内存映射文件的方法,mmap将一个文件或者其它对象映射进内存。但是更加确切的来说,其实是linux中的线性区提供的可以和基于磁盘文件系统的普通文件的某一个部分相关联的操作。线性区其实是由进程中连续的一块虚拟文件区域,...
继续阅读 »

mmap初入


我们常说的mmap,其实是一种内存映射文件的方法,mmap将一个文件或者其它对象映射进内存。但是更加确切的来说,其实是linux中的线性区提供的可以和基于磁盘文件系统的普通文件的某一个部分相关联的操作。线性区其实是由进程中连续的一块虚拟文件区域,由struct vm_area_struct结构体表示,我们的mmap操作,其实就是最本质的,就是通过其进行的内存操作。


按照归类的思想,其实mmap主要用到的分为两类(还有其他标识不讨论)



  1. 共享的:即对该线性区中的页(注意是以页为单位)的任何写操作,都会修改磁盘上的文件,并且如果一个进程对进行了mmap的页进行了写操作,那么对于其他进程(同样也通过mmap进行了映射),同样也是可见的

  2. 私有的:对于私有映射页的任何写操作,都会使linux内核停止映射该文件的页(注意,假如有进程a,b同时映射了该页,a对页进行了修改,此时这个页就相当于复制出来了一份,a以后的操作就在复制的该页进行操作,b还是引用原来的页,原来的页就可以继续参与内存映射,而复制出来的页,就被停止映射了),因此,在私有情况下,写操作是不会改变磁盘上的文件,同时所做的修改对于其他进程来说,就是不可见的。


我们看一下图片
共享模式下


image.png
私有模式下


image.png


mmap分析


概念我们有了,我们看一下代码mmap定义


#if defined(__USE_FILE_OFFSET64)
void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset) __RENAME(mmap64);
#else
void* mmap(void* __addr, size_t __size, int __prot, int __flags, int __fd, off_t __offset);
#endif

#if __ANDROID_API__ >= 21
/**
* mmap64() is a variant of mmap() that takes a 64-bit offset even on LP32.
*
* See https://android.googlesource.com/platform/bionic/+/master/docs/32-bit-abi.md
*
* mmap64 wasn't really around until L, but we added an inline for it since it
* allows a lot more code to compile with _FILE_OFFSET_BITS=64.
*/
void* mmap64(void* __addr, size_t __size, int __prot, int __flags, int __fd, off64_t __offset) __INTRODUCED_IN(21);
#endif

mmap分为好多个版本,但是其他的也是根据不同的Android版本或者abi进行部分的区分,我们来看一下具体含义:



  1. addr:参数addr指定文件应被映射到进程空间的起始地址,一般被指定一个空指针,此时选择起始地址的任务留给内核来完成。当然,我们也可以设定一个自己的地址,但是如果flag中设定起来MAP_FIXED标志,且内核也没办法从我们指定的线性地址开始分配新线性区的话,就会产生调用失败

  2. size:映射到调用进程地址空间的字节数,它从被映射文件开头offset个字节开始算起

  3. prot:指定对线性区的一组权限,比如读权限(PROT_READ),写权限(PROT_WRITE),执行权限(PROT_EXEC)

  4. flags:一组标志,比如我们上面说的共享模式(MAP_SHARED)或者私有模式(MAP_PRIVATE)等

  5. fd:文件描述符,要映射的文件

  6. offset:要映射的文件的偏移量(比如我想要从文件的某部分开始映射)


使用例子


下面是demo例子



#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define TRUE 1
#define FALSE -1
#define FILE_SIZE 100

#define MMAP_FILE_PATH "./mmap.txt"


int main(int argc, char **argv)
{
int fd = -1;
//char buff[100] = {0};
void *result;
int lseek_result = -1;
int file_length = -1;

// 1. open the file
fd = open(MMAP_FILE_PATH, O_RDWR|O_CREAT, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH);
if (-1 == fd) {
printf("open failed\n");
printf("%s\n", strerror(errno));
return FALSE;
}

//2. call mmap 这里可以尝试一下其他的flag,比如MAP_PRIVATE
result = mmap(0, 100(这里是demo文件长度), \
PROT_READ|PROT_WRITE, \
MAP_SHARED, \
fd, 0);
if (result == (void *)-1) {
printf("mmap failed\n");
printf("%s\n", strerror(errno));
return FALSE;
}

//3. release the file descriptor
close(fd);

//4. write something to mmap addr,
strncpy(result, "test balabala...", file_length);

//5. call munmap
munmap(0, file_length);

return 0;
}

深入了解mmap


不得不说,mmap的函数实现非常复杂,首先会调用到do_mmap_pgoff进行平台无关的代码分支,然后会调用到do_mmap里面,这个函数非常长!!!


我们挑几个关键的步骤:



  1. 先检查要映射的文件是否定义了mmap文件操作,(如果是目录就不存在mmap文件操作了)如果没有的话就直接调用失败

  2. 检查一系列的一致性检查,同时根据mmap的参数与打开文件的权限标志进行对比:比如检查当前文件是否有文件锁,如果是以MAP_SHARED模式打开进行写入的话,也要检查文件是否有可写的权限,正所谓权限要一致

  3. 设置线性区的vm_flag字段,因为我们mmap返回的地址肯定也是要给到当前进程的,这个线性区的权限所以也要设置,比如VM_READ,VM_WRITE等标识,表明了这块内存的权限

  4. 增加文件的引用计数,因为我们进程mmap用到了该文件对吧,所以计数器也要加一,这样系统才会知道当前文件被多少个进程引用了

  5. 对文件进行映射操作,并且初始化该线性区的页,注意这个时候并没有对页进行操作,因为有可能申请了这个页而没有操作,所以这个时候是以no_page标识的,这里不得不感慨linux的写时复制思想用到了各处!只有真正访问的时候,就会产生一个缺页异常,由内核调度请求完成对该页的填充


unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate)
{
struct mm_struct *mm = current->mm; //获取该进程的memory descriptor
int pkey = 0;

*populate = 0;
//函数对传入的参数进行一系列检查, 假如任一参数出错,都会返回一个errno
if (!len)
return -EINVAL;

/*
* Does the application expect PROT_READ to imply PROT_EXEC?
*
* (the exception is when the underlying filesystem is noexec
* mounted, in which case we dont add PROT_EXEC.)
*/
if ((prot & PROT_READ) && (current->personality & READ_IMPLIES_EXEC))
if (!(file && path_noexec(&file->f_path)))
prot |= PROT_EXEC;
//假如没有设置MAP_FIXED标志,且addr小于mmap_min_addr, 因为可以修改addr, 所以就需要将addr设为mmap_min_addr的页对齐后的地址
if (!(flags & MAP_FIXED))
addr = round_hint_to_min(addr);

/* Careful about overflows.. */
len = PAGE_ALIGN(len); //进行Page大小的对齐
if (!len)
return -ENOMEM;

/* offset overflow? */
if ((pgoff + (len >> PAGE_SHIFT)) < pgoff)
return -EOVERFLOW;

/* Too many mappings? */
if (mm->map_count > sysctl_max_map_count) //判断该进程的地址空间的虚拟区间数量是否超过了限制
return -ENOMEM;

//get_unmapped_area从当前进程的用户空间获取一个未被映射区间的起始地址
addr = get_unmapped_area(file, addr, len, pgoff, flags);
if (offset_in_page(addr)) //检查addr是否有效
return addr;

if (prot == PROT_EXEC) {
pkey = execute_only_pkey(mm);
if (pkey < 0)
pkey = 0;
}

/* Do simple checking here so the lower-level routines won't have
* to. we assume access permissions have been handled by the open
* of the memory object, so we don't do any here.
*/
vm_flags |= calc_vm_prot_bits(prot, pkey) | calc_vm_flag_bits(flags) |
mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;
//假如flags设置MAP_LOCKED,即类似于mlock()将申请的地址空间锁定在内存中, 检查是否可以进行lock
if (flags & MAP_LOCKED)
if (!can_do_mlock())
return -EPERM;

if (mlock_future_check(mm, vm_flags, len))
return -EAGAIN;

if (file) { // file指针不为nullptr, 即从文件到虚拟空间的映射
struct inode *inode = file_inode(file); //获取文件的inode

switch (flags & MAP_TYPE) { //根据标志指定的map种类,把为文件设置的访问权考虑进去
case MAP_SHARED:
if ((prot&PROT_WRITE) && !(file->f_mode&FMODE_WRITE))
return -EACCES;

/*
* Make sure we don't allow writing to an append-only
* file..
*/
if (IS_APPEND(inode) && (file->f_mode & FMODE_WRITE))
return -EACCES;

/*
* Make sure there are no mandatory locks on the file.
*/
if (locks_verify_locked(file))
return -EAGAIN;

vm_flags |= VM_SHARED | VM_MAYSHARE;
if (!(file->f_mode & FMODE_WRITE))
vm_flags &= ~(VM_MAYWRITE | VM_SHARED);

/* fall through */
case MAP_PRIVATE:
if (!(file->f_mode & FMODE_READ))
return -EACCES;
if (path_noexec(&file->f_path)) {
if (vm_flags & VM_EXEC)
return -EPERM;
vm_flags &= ~VM_MAYEXEC;
}

if (!file->f_op->mmap)
return -ENODEV;
if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
return -EINVAL;
break;

default:
return -EINVAL;
}
} else {
switch (flags & MAP_TYPE) {
case MAP_SHARED:
if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
return -EINVAL;
/*
* Ignore pgoff.
*/
pgoff = 0;
vm_flags |= VM_SHARED | VM_MAYSHARE;
break;
case MAP_PRIVATE:
/*
* Set pgoff according to addr for anon_vma.
*/
pgoff = addr >> PAGE_SHIFT;
break;
default:
return -EINVAL;
}
}

/*
* Set 'VM_NORESERVE' if we should not account for the
* memory use of this mapping.
*/
if (flags & MAP_NORESERVE) {
/* We honor MAP_NORESERVE if allowed to overcommit */
if (sysctl_overcommit_memory != OVERCOMMIT_NEVER)
vm_flags |= VM_NORESERVE;

/* hugetlb applies strict overcommit unless MAP_NORESERVE */
if (file && is_file_hugepages(file))
vm_flags |= VM_NORESERVE;
}
//一顿检查和配置,调用核心的代码mmap_region
addr = mmap_region(file, addr, len, vm_flags, pgoff);
if (!IS_ERR_VALUE(addr) &&
((vm_flags & VM_LOCKED) ||
(flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE))
*populate = len;
return addr;
}
do_mmap() 根据用户传入的参数做了一系列的检查,然后根据参数初始化 vm_area_struct 的标志 vm_flags,vma->vm_file = get_file(file) 建立文件与vma的映射, mmap_region() 负责创建虚拟内存区域:

unsigned long mmap_region(struct file *file, unsigned long addr,
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff)
{
struct mm_struct *mm = current->mm; //获取该进程的memory descriptor
struct vm_area_struct *vma, *prev;
int error;
struct rb_node **rb_link, *rb_parent;
unsigned long charged = 0;

/* 检查申请的虚拟内存空间是否超过了限制 */
if (!may_expand_vm(mm, vm_flags, len >> PAGE_SHIFT)) {
unsigned long nr_pages;

/*
* MAP_FIXED may remove pages of mappings that intersects with
* requested mapping. Account for the pages it would unmap.
*/
nr_pages = count_vma_pages_range(mm, addr, addr + len);

if (!may_expand_vm(mm, vm_flags,
(len >> PAGE_SHIFT) - nr_pages))
return -ENOMEM;
}

/* 检查[addr, addr+len)的区间是否存在映射空间,假如存在重合的映射空间需要munmap */
while (find_vma_links(mm, addr, addr + len, &prev, &rb_link,
&rb_parent)) {
if (do_munmap(mm, addr, len))
return -ENOMEM;
}

/*
* Private writable mapping: check memory availability
*/
if (accountable_mapping(file, vm_flags)) {
charged = len >> PAGE_SHIFT;
if (security_vm_enough_memory_mm(mm, charged))
return -ENOMEM;
vm_flags |= VM_ACCOUNT;
}

//检查是否可以合并[addr, addr+len)区间内的虚拟地址空间vma
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
if (vma) //假如合并成功,即使用合并后的vma, 并跳转至out
goto out;
//如果不能和已有的虚拟内存区域合并,通过Memory Descriptor来申请一个vma
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
if (!vma) {
error = -ENOMEM;
goto unacct_error;
}
//初始化vma
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = vm_get_page_prot(vm_flags);
vma->vm_pgoff = pgoff;
INIT_LIST_HEAD(&vma->anon_vma_chain);

if (file) { //假如指定了文件映射
if (vm_flags & VM_DENYWRITE) { //映射的文件不允许写入,调用deny_write_accsess(file)排斥常规的文件操作
error = deny_write_access(file);
if (error)
goto free_vma;
}
if (vm_flags & VM_SHARED) { //映射的文件允许其他进程可见, 标记文件为可写
error = mapping_map_writable(file->f_mapping);
if (error)
goto allow_write_and_free_vma;
}

//递增File的引用次数,返回File赋给vma
vma->vm_file = get_file(file);
error = file->f_op->mmap(file, vma); //调用文件系统指定的mmap函数
if (error)
goto unmap_and_free_vma;

/* Can addr have changed??
*
* Answer: Yes, several device drivers can do it in their
* f_op->mmap method. -DaveM
* Bug: If addr is changed, prev, rb_link, rb_parent should
* be updated for vma_link()
*/
WARN_ON_ONCE(addr != vma->vm_start);

addr = vma->vm_start;
vm_flags = vma->vm_flags;
} else if (vm_flags & VM_SHARED) {
error = shmem_zero_setup(vma); //假如标志为VM_SHARED,但没有指定映射文件,需要调用shmem_zero_setup(),实际映射的文件是dev/zero
if (error)
goto free_vma;
}
//将申请的新vma加入mm中的vma链表
vma_link(mm, vma, prev, rb_link, rb_parent);
/* Once vma denies write, undo our temporary denial count */
if (file) {
if (vm_flags & VM_SHARED)
mapping_unmap_writable(file->f_mapping);
if (vm_flags & VM_DENYWRITE)
allow_write_access(file);
}
file = vma->vm_file;
out:
perf_event_mmap(vma);
//更新进程的虚拟地址空间mm
vm_stat_account(mm, vm_flags, len >> PAGE_SHIFT);
if (vm_flags & VM_LOCKED) {
if (!((vm_flags & VM_SPECIAL) || is_vm_hugetlb_page(vma) ||
vma == get_gate_vma(current->mm)))
mm->locked_vm += (len >> PAGE_SHIFT);
else
vma->vm_flags &= VM_LOCKED_CLEAR_MASK;
}

if (file)
uprobe_mmap(vma);

/*
* New (or expanded) vma always get soft dirty status.
* Otherwise user-space soft-dirty page tracker won't
* be able to distinguish situation when vma area unmapped,
* then new mapped in-place (which must be aimed as
* a completely new data area).
*/
vma->vm_flags |= VM_SOFTDIRTY;

vma_set_page_prot(vma);

return addr;

unmap_and_free_vma:
vma->vm_file = NULL;
fput(file);

/* Undo any partial mapping done by a device driver. */
unmap_region(mm, vma, prev, vma->vm_start, vma->vm_end);
charged = 0;
if (vm_flags & VM_SHARED)
mapping_unmap_writable(file->f_mapping);
allow_write_and_free_vma:
if (vm_flags & VM_DENYWRITE)
allow_write_access(file);
free_vma:
kmem_cache_free(vm_area_cachep, vma);
unacct_error:
if (charged)
vm_unacct_memory(charged);
return error;
}

munmap


当进程准备清除一个mmap映射时,就可以调用munmap函数


int munmap(void* __addr, size_t __size);

如demo例子所示,第一个函数是要删除的线性地址的第一个单元的地址,第二个函数是线性区的长度,由于这个函数比较简单,就是系统调用do_munmap函数,这里就不详细分析啦!


数据写回磁盘


经过上面的mmap操作,我们也知道了mmap的使用了,不知道大家有没有留意一个细节,就是上面图中我们内存写入的下一步是页高速缓存,这个时候真正的数据保存,其实还是没有真正写入到磁盘里面的,真正写入其实是靠系统调用写入,即msync函数


int msync(void* __addr, size_t __size, int __flags);

mmap的数据写入依靠着这个系统调用保证,即当前进程被异常销毁了,也可以通过这个系统级别的调用,把属于内存映射的脏页数据写回去磁盘。参数1跟2需要写回去的头地址跟大小,我们重点看一下最后一个参数flags,它有以下几个参数选择:



  • MS_SYNC:要求系统挂起调用进程,直到I/O操作完成

  • MS_ASYNC:可以系统调用立即返回,不用挂起调用进程(大多数使用到mmap库选择)

  • MS_INVALLDATE:要求系统调用从进程地址空间删除mmap映射的所有页
    这个函数主要功能是进行脏页标记,并进行真正的磁盘写入,大概的路径就是通过调用flush_tlb_page把缓冲器标记为脏页标识,同时获取文件对应的索引节点i_sem信号量,进行写入时的上锁,然后刷新到磁盘!


这里可以看到,mmap依靠系统调用,把数据刷新回到磁盘!虽然这个刷新动作是由linux系统进行刷入的,保证了进程出问题的时候,也能够在系统级别刷入数据,比如MMKV的设计就采用了这点,但是这个也不是百分百可靠的,因为这个刷入操作是没有备份操作的/异常容灾处理,如果系统异常或者断电的情况,就会出现错误数据或者没有完全刷入磁盘的数据,造成数据异常,我们也可以看到mmkv介绍


image.png


mmap不足


mmap最大的不足,就是被映射的线性区只能是按照页的整数倍进行计算,如果说我们要映射的内存少于这个数,也是按照一个页进行映射的,当然,一个页长度会根据不同架构而不同,比如常见的4kb等,同时也要时刻注意映射区的生命周期,不然无脑映射也很容易造成oom


扩展


当然,说到mmap,肯定也离不开对mmkv的介绍,虽然mmkv很优秀,但是也不能无脑就使用,这方面可以参考朱凯大佬发布的文章Android 的键值对存储有没有最优解?,这里就不再赘述,按照自己所需的使用,当然,jetpack的DataStore也是很香啊!有机会的话也出一篇解析文!


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

Kotlin协程-协程的暂停与恢复 & suspendCancellableCoroutine的使用

前言 之前在网上看到有人问协程能不能像线程一样 wait(暂停) 和 notify(恢复) 。 应用场景是开启一个线程然后执行一段逻辑,得到了某一个数据,然后需要拿到这个数据去处理一些别的事情,需要把线程先暂停,然后等逻辑处理完成之后再把线程 notify。 ...
继续阅读 »

前言


之前在网上看到有人问协程能不能像线程一样 wait(暂停) 和 notify(恢复) 。


应用场景是开启一个线程然后执行一段逻辑,得到了某一个数据,然后需要拿到这个数据去处理一些别的事情,需要把线程先暂停,然后等逻辑处理完成之后再把线程 notify。


首先我们不说有没有其他的方式实现,我当然知道有其他多种其他实现的方式。单说这一种逻辑来看,我们使用协程能不能达到同样的效果?


那么问题来了,协程能像线程那样暂停与恢复吗?


协程默认是不能暂停与恢复的,不管是协程内部还是返回的Job对象,都不能暂停与恢复,最多只能delay延时一下,无法精准控制暂停与恢复。


但是我们可以通过 suspendCancellableCoroutine 来间接的实现这个功能。


那问题又来了,suspendCancellableCoroutine 是个什么东西?怎么用?


一、suspendCancellableCoroutine的用法


很多人不了解这个类,不知道它是干嘛的,其实我们点击去看源码就知道,源码已经给出了很清晰的注释,并且还附带了使用场景



简单的说就是把Java/Kotlin 的一些回调方法,兼容改造成 suspend 的函数,让它可以运行在协程中。


以一个非常经典的例子,网络请求我们可以通过 Retrofit+suspend 的方式,也可以直接使用 OkHttp 的方式,我们很早之前都是自己封装 OkHttpUtils 的,然后以回调的方式返回正确结果和异常处理。


我们就可以通过 suspendCancellableCoroutine 把 OkHttpUtils 的回调方式进行封装,像普通的 suspend 方法一样使用了。


    fun suspendSth() {

viewModelScope.launch {

val school = mRepository.getSchool() //一个是使用Retrofit + suspend

try {
val industry = getIndustry() //一个是OkHttpUtils回调的方式
} catch (e: Exception) {
e.printStackTrace() //捕获OkHttpUtils返回的异常信息
}

}

}

private suspend fun getIndustry(): String? {
return suspendCancellableCoroutine { cancellableContinuation ->

OkhttpUtil.okHttpGet("http://www.baidu.com/api/industry", object : CallBackUtil.CallBackString() {

override fun onFailure(call: Call, e: Exception) {
cancellableContinuation.resumeWithException(e)
}

override fun onResponse(call: Call, response: String?) {
cancellableContinuation.resume(response)
}

})
}
}

感觉使用起来真是方便呢,那除了 suspendCancellableCoroutine 有没有其他的方式转换回调?有,suspendCoroutine,那它们之间的区别是什么?


suspendCancellableCoroutine 和 suspendCoroutine 区别


SuspendCancellableCoroutine 返回一个 CancellableContinuation, 它可以用 resume、resumeWithException 来处理回调和抛出 CancellationException 异常。


它与 suspendCoroutine的唯一区别就是 SuspendCancellableCoroutine 可以通过 cancel() 方法手动取消协程的执行,而 suspendCoroutine 没有该方法。


所以尽可能使用 suspendCancellableCoroutine 而不是 suspendCoroutine ,因为协程的取消是可控的。


那我们不使用回调直接用行不行?当然可以,例如:


  fun suspendSth() {

viewModelScope.launch {

val school = mRepository.getSchool()

if (school is OkResult.Success) {

val lastSchool = handleSchoolData(school.data)

YYLogUtils.w("处理过后的School:" + lastSchool)
}

}


}

private suspend fun handleSchoolData(data: List<SchoolBean>?): SchoolBean? {

return suspendCancellableCoroutine {

YYLogUtils.w("通过开启一个线程延时5秒再返回")
thread {
Thread.sleep(5000)

it?.resume(mSchoolList?.last(), null)
}

}
}

那怎么能达到协程的暂停与恢复那种效果呢?我们把参数接收一下,变成成员变量不就行了吗?想什么时候resume就什么时候resume。


二、实现协程的暂停与恢复


我们定义一个方法开启一个协程,内部使用一个 suspendCancellableCoroutine 函数包裹我们的逻辑(暂停),再定义另一个方法内部使用 suspendCancellableCoroutine 的 resume 来返回给协程(恢复)。


  fun suspendSth() {

viewModelScope.launch {

val school = mRepository.getSchool() //网络获取数据

if (school is OkResult.Success) {

val lastSchool = handleSchoolData(school.data)

//下面的不会执行的,除非 suspendCancellableCoroutine 的 resume 来恢复协程,才会继续走下去

YYLogUtils.w("处理过后的School:" + lastSchool)
}

}


}

private var mCancellableContinuation: CancellableContinuation<SchoolBean?>? = null
private var mSchoolList: List<SchoolBean>? = null

private suspend fun handleSchoolData(data: List<SchoolBean>?): SchoolBean? {

mSchoolList = data

return suspendCancellableCoroutine {

mCancellableContinuation = it

YYLogUtils.w("开启线程睡眠5秒再说")
thread {
Thread.sleep(5000)

YYLogUtils.w("就是不返回,哎,就是玩...")

}

}
}

//我想什么时候返回就什么时候返回
fun resumeCoroutine() {

YYLogUtils.w("点击恢复协程-返回数据")

if (mCancellableContinuation?.isCancelled == true) {
return
}

mCancellableContinuation?.resume(mSchoolList?.last(), null)

}

使用: 点击开启协程暂停了,再点击下面的按钮即恢复协程


fun testflow() {
mViewModel.suspendSth()
}

fun resumeScope() {
mViewModel.resumeCoroutine()
}

效果是点击开启协程之后我等了20秒恢复了协程,打印如下:



总结


协程虽然默认是不支持暂停与恢复,但是我们可以通过 suspendCancellableCoroutine 来间接的实现。


虽然如此,但实例开发上我还是不太推荐这么用,这样的场景我们有多种实现方式。可以用其他很好的方法实现,比如用一个协程不就好了吗串行执行,或者并发协程然后使用协程的通信来传递,或者用线程+队列也能做等等。真的一定要暂停住协程吗?不是不能实现,只是感觉不是太优雅。


(注:不好意思,这里有点主观意识了,大家不一定就要参考,毕竟它也只是一种场景需求实现的方式而已,只要性能没问题,所有的方案都是可行,大家按需选择即可)


当然关于 suspendCancellableCoroutine 谷歌的本意是让回调也能兼容协程,这也是它最大的应用场景。


本期内容如讲的不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。



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

ConstraintLayout 中的 Barrier 和 Chains

1. Barrier是一个准则,可以说是对其的规则,这样说还不够名义,我们可以列表一些比较常见的场景; 官网 Barrier。具体看图 “第二行的label”和“第二行value”是一个整体,他们距离上面是 100dp ...
继续阅读 »

1. Barrier

是一个准则,可以说是对其的规则,这样说还不够名义,我们可以列表一些比较常见的场景; 官网 Barrier

  1. 具体看图

1883633-62653bd01cb70813.webp “第二行的label”和“第二行value”是一个整体,他们距离上面是 100dp ,但是有可能“第二行value”的值为空或者是空,也需要“第二行label”距离上面的距离是 100dp ,由于我们知道“第二行value”的高度高于第一个,所以采用的是“第二行label”跟“第二行value”对其,“第二行value”距离上边 100dp 的距离,但是由于“第二行value”有可能为空,所以当“第二行value”为空的时候就会出现下面的效果:

1883633-043d00e43ff22557.webp 我们发现达不到预期,现在能想到的办法有,首先在代码控制的时候随便把“第二行label”的 marginTop 也添加进去;还有就是换布局,将“第二行label”和“第二行value”放到一个布局中,比如 LinearLayout ,这样上边的 marginTop 由 LinearLayout 控制;这样的话即便“第二行value”消失了也会保持上边的效果。

除了上边的方法还能使用其他的嘛,比如我们不使用代码控制,我们不使用其他的布局,因为我们知道布局嵌套太多性能也会相应的下降,所以在编写的时候能减少嵌套的情况下尽可能的减少,当然也不能为了减少嵌套让代码变得格外的复杂。

为了满足上面的需求, Barrier 出现了,它能做到隐藏的也能依靠它,并且与它的距离保持不变对于隐藏的“第二行value”来说,虽然消失了,但保留了 marginTop 的数值。下面看看布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="头部"
android:textSize="36sp"
app:layout_constraintBottom_toTopOf="@id/barrier3"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />


<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
android:layout_marginTop="100dp"
app:constraint_referenced_ids="textView2,textView3" />

<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="第二行label"
android:textSize="36sp"
app:layout_constraintBottom_toBottomOf="@+id/textView3"
app:layout_constraintStart_toStartOf="@+id/textView4"
app:layout_constraintTop_toTopOf="@+id/textView3"
app:layout_constraintVertical_bias="0.538" />

<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="100dp"
android:layout_marginStart="12dp"
android:layout_marginTop="100dp"
android:text="第二行value"
android:textSize="36sp"
app:layout_constraintStart_toEndOf="@+id/textView2"
app:layout_constraintTop_toBottomOf="@+id/barrier3" />

</androidx.constraintlayout.widget.ConstraintLayout>

这样即便将“第二行value”消失,那么总体的布局仍然达到预期,并且也没有添加很多布局内容。在代码中:

<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
android:layout_marginTop="100dp"
app:constraint_referenced_ids="textView2,textView3" />

这里主要有两个属性 app:barrierDirection 和 app:constraint_referenced_ids :

  • app:barrierDirection 是代表位置,也就是在包含内容的哪一个位置,我这里写的是 top ,是在顶部,还有其他的属性 top,bottom,left,right,start 和 end 这几个属性,看意思就很明白了。
  • app:constraint_referenced_ids 上面说的内容就是包含在这里面的,这里面填写的是 id 的名称,如果有多个,那么使用逗号隔开;这里面的到 Barrier 的距离不会改变,即便隐藏了也不会变。

这里可能会有疑惑,为啥我写的 id 为 textView4 的也依赖于 Barrier ,这是因为本身 Barrier 只是规则不是实体,它的存在只能依附于实体,不能单独存在于具体的位置,如果我们只有“第二行value”依赖于它,但是本身“第二行value”没有上依赖,也相当于没有依赖,这样只会导致“第二行label”和“第二行value”都消失,如果 textView4 依赖于 Barrier ,由于 textView4 的位置是确定的,所以 Barrier 的位置也就确定了。

  1. 类似表格的效果。看布局效果:

1883633-c4b862a2df57fb96.webp 我要做成上面的样子。也就是右边永远与左边最长的保持距离。下面是我的代码:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">


<TextView
android:id="@+id/textView4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="头部"
android:textSize="36sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="第二行"
android:textSize="36sp"
app:layout_constraintBottom_toBottomOf="@+id/textView3"
app:layout_constraintStart_toStartOf="@+id/textView4"
app:layout_constraintTop_toTopOf="@+id/textView3" />

<TextView
android:id="@+id/textView3"
android:layout_width="wrap_content"
android:layout_height="50dp"
android:layout_marginStart="20dp"
android:layout_marginTop="10dp"
android:text="第二行value"
android:textSize="36sp"
app:layout_constraintStart_toEndOf="@+id/barrier4"
app:layout_constraintTop_toBottomOf="@+id/textView4" />


<TextView
android:id="@+id/textView5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="第三次测试"
android:textSize="36sp"
app:layout_constraintBottom_toBottomOf="@+id/textView6"
app:layout_constraintStart_toStartOf="@+id/textView4"
app:layout_constraintTop_toTopOf="@+id/textView6" />

<TextView
android:id="@+id/textView6"
android:layout_width="wrap_content"
android:layout_height="50dp"
android:layout_marginStart="20dp"
android:layout_marginTop="10dp"
android:text="第三行value"
android:textSize="36sp"
app:layout_constraintStart_toEndOf="@+id/barrier4"
app:layout_constraintTop_toBottomOf="@+id/textView3" />

<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="textView2,textView5"
tools:layout_editor_absoluteX="411dp" />


</androidx.constraintlayout.widget.ConstraintLayout>

添加好了,记得让右边的约束指向 Barrier 。这里的 Barrier ,我们看到包含 textView2 和 textView5 ,这个时候就能做到谁长听谁的,如果此时 textView2 变长了,那么就会将就 textView2 。

2. Chains

我们特别喜欢使用线性布局,因为我们发现 UI 图上的效果使用线性布局都可以使用,当然也有可能跟大部分人的思维方式有关系。比如我们非常喜欢的,水平居中,每部分的空间分布等等都非常的顺手。既然线性布局这么好用,那为啥还有约束布局呢,因为线性布局很容易写出嵌套很深的布局,但约束布局不会,甚至大部分情况都可以不需要嵌套就能实现,那是不是代表线性布局有的约束布局也有,答案是肯定的。

使用普通的约束关系就很容易实现水平居中等常用效果,其他的如水平方向平均分布空间,使用一般的约束是实现不了的,于是就要使用 Chains ,这个就很容易实现下面的效果:

1883633-78aa31c23dcb4c4f.webp 其实上一篇中我已经把官网的教程贴上去了,这里主要写双向约束怎么做,一旦双向约束形成,那么就自然进入到 Chains 模式。

1)在视图模式中操作

1883633-618f9b2eb563a637.webp

如果直接操作,那么只能单向约束,如果要形成这样的约束,需要选择相关的的节点,比如我这里就是同时选择 A 和 B ,然后点击鼠标右键,就可以看到 Chains → Create Horizontal Chain 。

对应的操作

选择图中的选项即可完成从 A 指向 B ,修改的示意图为:

1883633-cf3984e22df83c7c.webp

我们发现已经实现了水平方向的排列效果了。至于怎么实现上面的效果,主要是改变 layout_constraintVertical_chainStyle 和 layout_constraintHorizontal_chainStyle 属性。至于权重则是属性 layout_constraintHorizontal_weight 。

layout_constraintHorizontal_chainStyle 属性说明:

  • spread 默认选项,效果就是上面的那种,也就是平均分配剩余空间;
  • spread_inside 两边的紧挨着非 Chains 的视图,中间的平均分配;

1883633-49c52026c6797e51.webp

  • packed 所有的都在中间

1883633-714e58d28eaab99c.webp 注意了, layout_constraintHorizontal_weight 这个属性只有在 A 身上设置才可以,也就是首节点上设置才可行,同时 layout_constraintHorizontal_weight 是代表水平方向,只能在水平方向才发生作用,如果水平的设置了垂直则不生效。

layout_constraintHorizontal_weight 这个属性只有在当前视图的宽或者高是 0dp 。至于这个的取值跟线性布局相同。

1883633-072f1f968528ef1a.webp

2)代码的方式 跟上面的差别就是在做双向绑定,用代码就很容易实现双向绑定,可平时添加约束相同。


作者:吴敬悦
链接:https://juejin.cn/post/6994385157306351653
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

❤️Android 12 高斯模糊-RenderEffect❤️

 Android 12 高斯模糊 新功能:更易用的模糊、彩色滤镜等特效 。 新的 API 让你能更轻松地将常见图形效果应用到视图和渲染结构上。 使用 RenderEffect 将模糊、色彩滤镜等效果应用于 RenderNode 或 View。 ...
继续阅读 »

 Android 12 高斯模糊


新功能:更易用的模糊、彩色滤镜等特效 。


新的 API 让你能更轻松地将常见图形效果应用到视图和渲染结构上。




  • 使用 RenderEffect 将模糊、色彩滤镜等效果应用于 RenderNode 或 View




  • 使用新的 Window.setBackgroundBlurRadius() API 为窗口背景创建雾面玻璃效果,




  • 使用 blurBehindRadius 来模糊窗口后面的所有内容。




咱们一个一个玩。


🔥 RenderEffect


💥 实现效果


    private void setBlur(){
View.setRenderEffect(RenderEffect.createBlurEffect(3, 3, Shader.TileMode.REPEAT));
...
}

使用特别简单,走你。


🌀 X 轴的模糊效果图



咱再看看代码


    private void setBlur(){
agb.iv1.setRenderEffect(RenderEffect.createBlurEffect(3, 0, Shader.TileMode.CLAMP));
agb.iv2.setRenderEffect(RenderEffect.createBlurEffect(8, 0, Shader.TileMode.REPEAT));
agb.iv3.setRenderEffect(RenderEffect.createBlurEffect(18, 0 ,Shader.TileMode.MIRROR));
agb.iv4.setRenderEffect(RenderEffect.createBlurEffect(36, 0,Shader.TileMode.DECAL));
}

RenderEffect.createBlurEffect()的四个参数:




  • radiusX 沿 X 轴的模糊半径




  • radiusY 沿 Y 轴的模糊半径




  • inputEffect 模糊一次(传入 RenderEffect)




  • edgeTreatment 用于如何模糊模糊内核边缘附近的内容




下面两种仅看效果图。就不做代码设置了。


🌀 Y 轴的模糊效果图



🌀 XY同时模糊效果图



第四个参数对边缘模糊,效果图如下:



Shader.TileMode 提供了四个选项恕我没看出来。。


这里还有一堆方法等你玩。




注意:注意如此完美的画面只能在 Android 12(SDK31)及以上的设备上使用,其他版本的设备使用会导致崩溃,谨记谨记。
效果有了,下面咱们一起看看源码。



💥 源码


🌀 View.setRenderEffect()


    public void setRenderEffect(@Nullable RenderEffect renderEffect) {
...
}

这个方法就是:renderEffect 应用于 View。 传入 null清除之前配置的RenderEffect 。这里咱们先看传入的 RenderEffect。


🌀 RenderEffect.createBlurEffect()


    public static RenderEffect createBlurEffect(
float radiusX,
float radiusY,
@NonNull RenderEffect inputEffect,
@NonNull TileMode edgeTreatment
) {
long nativeInputEffect = inputEffect != null ? inputEffect.mNativeRenderEffect : 0;
return new RenderEffect(
nativeCreateBlurEffect(
radiusX,
radiusY,
nativeInputEffect,
edgeTreatment.nativeInt
)
);
}

两个 createBlurEffect() 方法,分别为三参(模糊一次)和四参(模糊两次)。inputEffect 先进行了一次模糊。


看效果图:



模糊程度一样,但是实现方式不同:


    private void setBlur() {
RenderEffect radiusXRenderEffect = RenderEffect.createBlurEffect(10, 0, Shader.TileMode.MIRROR);
RenderEffect radiusYRenderEffect = RenderEffect.createBlurEffect(0, 10, Shader.TileMode.MIRROR);
agb.iv1.setRenderEffect(RenderEffect.createBlurEffect(10, 10, Shader.TileMode.CLAMP));
agb.iv2.setRenderEffect(RenderEffect.createBlurEffect(10, 10, Shader.TileMode.REPEAT));
//自身radiusY 为 0 ,传入的radiusYRenderEffect设置的radiusY为10;
agb.iv3.setRenderEffect(RenderEffect.createBlurEffect(10, 0, radiusYRenderEffect, Shader.TileMode.MIRROR));
//自身radiusX 为 0 ,传入的radiusXRenderEffect设置的radiusX为10;
agb.iv4.setRenderEffect(RenderEffect.createBlurEffect(0, 10, radiusXRenderEffect, Shader.TileMode.DECAL));
}

这个方法返回一个 new RenderEffect(nativeCreateBlurEffect(...)。


那咱们去看看 nativeCreateBlurEffect()


🌀 nativeCreateBlurEffect()


frameworks/base/libs/hwui/jni/RenderEffect.cpp


static const JNINativeMethod gRenderEffectMethods[] = {
...
{"nativeCreateBlurEffect", "(FFJI)J", (void*)createBlurEffect},
...
};

static jlong createBlurEffect(JNIEnv* env , jobject, jfloat radiusX,
jfloat radiusY, jlong inputFilterHandle, jint edgeTreatment) {
auto* inputImageFilter = reinterpret_cast<SkImageFilter*>(inputFilterHandle);
sk_sp<SkImageFilter> blurFilter =
SkImageFilters::Blur(
Blur::convertRadiusToSigma(radiusX),
Blur::convertRadiusToSigma(radiusY),
static_cast<SkTileMode>(edgeTreatment),
sk_ref_sp(inputImageFilter),
nullptr);
return reinterpret_cast<jlong>(blurFilter.release());
}

这里有两个函数来处理我们传过来的模糊的值,咱进去看看。


🌀 convertRadiusToSigma(convertSigmaToRadius)


//该常数近似于在SkBlurMask::Blur()(1/sqrt(3)中,在软件路径的"高质量"模式下进行的缩放。
static const float BLUR_SIGMA_SCALE = 0.57735f;

float Blur::convertRadiusToSigma(float radius) {
return radius > 0 ? BLUR_SIGMA_SCALE * radius + 0.5f : 0.0f;
}

float Blur::convertSigmaToRadius(float sigma) {
return sigma > 0.5f ? (sigma - 0.5f) / BLUR_SIGMA_SCALE : 0.0f;
}

🌀 sk_ref_sp(inputImageFilter)


external/skia/include/core/SkRefCnt.h


/*
* 返回包装提供的 ptr 的 sk_sp 并对其调用 ref (如果不为空)
*/
template <typename T> sk_sp<T> sk_ref_sp(T* obj) {
//sk_sp<SkImageFilter> :
return sk_sp<T>(SkSafeRef(obj));
}

//SkSafeRef:检查参数是否为非空,如果是,则调用 obj->ref() 并返回 obj。
template <typename T> static inline T* SkSafeRef(T* obj) {
if (obj) {
obj->ref();
}
return obj;
}

再往下走


🌀 SkImageFilters::Blur()



#define SK_Scalar1 1.0f
#define SK_ScalarNearlyZero (SK_Scalar1 / (1 << 12))

sk_sp<SkImageFilter> SkImageFilters::Blur(
SkScalar sigmaX, SkScalar sigmaY, SkTileMode tileMode, sk_sp<SkImageFilter> input,
const CropRect& cropRect) {
if (sigmaX < SK_ScalarNearlyZero && sigmaY < SK_ScalarNearlyZero && !cropRect) {
return input;
}
return sk_sp<SkImageFilter>(
new SkBlurImageFilter(sigmaX, sigmaY, tileMode, input, cropRect));
}

附上最后的倔强


    constexpr sk_sp() : fPtr(nullptr) {}
constexpr sk_sp(std::nullptr_t) : fPtr(nullptr) {}

/**
* Shares the underlying object by calling ref(), so that both the argument and the newly
* created sk_sp both have a reference to it.
*/
sk_sp(const sk_sp<T>& that) : fPtr(SkSafeRef(that.get())) {}
template <typename U,
typename = typename std::enable_if<std::is_convertible<U*, T*>::value>::type>
sk_sp(const sk_sp<U>& that) : fPtr(SkSafeRef(that.get())) {}

/**
* Move the underlying object from the argument to the newly created sk_sp. Afterwards only
* the new sk_sp will have a reference to the object, and the argument will point to null.
* No call to ref() or unref() will be made.
*/
sk_sp(sk_sp<T>&& that) : fPtr(that.release()) {}
template <typename U,
typename = typename std::enable_if<std::is_convertible<U*, T*>::value>::type>
sk_sp(sk_sp<U>&& that) : fPtr(that.release()) {}

/**
* Adopt the bare pointer into the newly created sk_sp.
* No call to ref() or unref() will be made.
*/
explicit sk_sp(T* obj) : fPtr(obj) {}

createBlurEffect() 得到 long 类型的 native 分配的的非零地址, 传入 new RenderEffect()


🌀 new RenderEffect()


    /* 构造方法:仅从静态工厂方法构造 */
private RenderEffect(long nativeRenderEffect) {
mNativeRenderEffect = nativeRenderEffect;
RenderEffectHolder.RENDER_EFFECT_REGISTRY.registerNativeAllocation(
this, mNativeRenderEffect);
}

继续



/**
* @param classLoader ClassLoader 类加载器。
* @param freeFunction 类型为 nativePtr 的本机函数的地址,用于释放这种本机分配
* @return 由系统内存分配器分配的本机内存的 NativeAllocationRegistry。此版本更适合较小的对象(通常小于几百 KB)。
*/
private static class RenderEffectHolder {
public static final NativeAllocationRegistry RENDER_EFFECT_REGISTRY =
NativeAllocationRegistry.createMalloced(
RenderEffect.class.getClassLoader(), nativeGetFinalizer());
}

🌀 NativeAllocationRegistry.createMalloced()


libcore/luni/src/main/java/libcore/util/NativeAllocationRegistry.java


    @SystemApi(client = MODULE_LIBRARIES)
public static NativeAllocationRegistry createMalloced(
@NonNull ClassLoader classLoader, long freeFunction, long size) {
return new NativeAllocationRegistry(classLoader, freeFunction, size, true);
}

🌀 NativeAllocationRegistry()


libcore/luni/src/main/java/libcore/util/NativeAllocationRegistry.java


    private NativeAllocationRegistry(ClassLoader classLoader, long freeFunction, long size,
boolean mallocAllocation) {
if (size < 0) {
throw new IllegalArgumentException("Invalid native allocation size: " + size);
}
this.classLoader = classLoader;
this.freeFunction = freeFunction;
this.size = mallocAllocation ? (size | IS_MALLOCED) : (size & ~IS_MALLOCED);
}

既然拿到 NativeAllocationRegistry 那就继续调用其
registerNativeAllocation() 方法。


🌀 registerNativeAllocation ()


    @SystemApi(client = MODULE_LIBRARIES)
@libcore.api.IntraCoreApi
public @NonNull Runnable registerNativeAllocation(@NonNull Object referent, long nativePtr) {
//当 referent 或nativePtr 为空
...
CleanerThunk thunk;
CleanerRunner result;
try {
thunk = new CleanerThunk();
Cleaner cleaner = Cleaner.create(referent, thunk);
result = new CleanerRunner(cleaner);
registerNativeAllocation(this.size);
} catch (VirtualMachineError vme /* probably OutOfMemoryError */) {
applyFreeFunction(freeFunction, nativePtr);
throw vme;
}
// Enable the cleaner only after we can no longer throw anything, including OOME.
thunk.setNativePtr(nativePtr);
// Ensure that cleaner doesn't get invoked before we enable it.
Reference.reachabilityFence(referent);
return result;
}

向 ART 注册新的 NativePtr 和关联的 Java 对象(也就是咱们设置的模糊类)。


返回的 Runnable 可用于在引用变得无法访问之前释放本机分配。如果运行时或使用 runnable 已经释放了本机分配,则 runnable 将不起作用。


RenderEffect 算是搞完了,咱们回到View.setRenderEffect()


🌀 View.setRenderEffect()


    public void setRenderEffect(@Nullable RenderEffect renderEffect) {
if (mRenderNode.setRenderEffect(renderEffect)) {
//视图属性更改(alpha、translationXY 等)的快速失效。
invalidateViewProperty(true, true);
}
}

这里有个 mRenderNode.setRenderEffect(renderEffect)。咱们近距离观望一番。


🌀 mRenderNode 的创建


咱们先找找他是在什么地方创建的。


    public View(Context context) {
...
//在View的构造方法中创建
mRenderNode = RenderNode.create(getClass().getName(), new ViewAnimationHostBridge(this));
...
}

🌀 RenderNode.create()


    /** @hide */
public static RenderNode create(String name, @Nullable AnimationHost animationHost) {
return new RenderNode(name, animationHost);
}

private RenderNode(String name, AnimationHost animationHost) {
mNativeRenderNode = nCreate(name);
//注册 Native Allocation。
NoImagePreloadHolder.sRegistry.registerNativeAllocation(this, mNativeRenderNode);
mAnimationHost = animationHost;
}

再往下感觉也看不到啥了 跟上面类似,看.cpp动态分配类的地址还是有点懵。让我缓缓~以后补充。


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

Flutter 中使用Chip 小部件

概述 典型的chip是一个圆角的小盒子。它有一个文本标签,并以一种有意义且紧凑的方式显示信息。chip可以在同一区域同时显示多个交互元素。一些流行的chip用例是: 发布标签(您可以在许多 WordPress ,VuePress,知乎,掘金,公众号或 Git...
继续阅读 »

概述


典型的chip是一个圆角的小盒子。它有一个文本标签,并以一种有意义且紧凑的方式显示信息。chip可以在同一区域同时显示多个交互元素。一些流行的chip用例是:



  • 发布标签(您可以在许多 WordPress ,VuePress,知乎,掘金,公众号或 GitHub等大型平台上看到它们)。

  • 可删除的内容列表(一系列电子邮件联系人、最喜欢的音乐类型列表等)。


img


在 Flutter 中,您可以使用以下构造函数来实现 Chip 小部件:


Chip({
 Key? key,
 Widget? avatar,
 required Widget label,
 TextStyle? labelStyle,
 EdgeInsetsGeometry? labelPadding,
 Widget? deleteIcon,
 VoidCallback? onDeleted,
 Color? deleteIconColor,
 bool useDeleteButtonTooltip = true,
 String? deleteButtonTooltipMessage,
 BorderSide? side,
 OutlinedBorder? shape,
 Clip clipBehavior = Clip.none,
 FocusNode? focusNode,
 bool autofocus = false,
 Color? backgroundColor,
 EdgeInsetsGeometry? padding,
 VisualDensity? visualDensity,
 MaterialTapTargetSize? materialTapTargetSize,
 double? elevation,
 Color? shadowColor
})

只有label属性是必需的,其他是可选的。一些常用的有:



  • avatar:在标签前显示一个图标或小图像。

  • backgroundColor : chip的背景颜色。

  • padding:chip内容周围的填充。

  • deleteIcon:让用户删除chip的小部件。

  • onDeleted:点击deleteIcon时调用的函数。


您可以在官方文档中找到有关其他属性的更多详细信息。但是,对于大多数应用程序,我们不需要超过一半。


简单示例


这个小例子向您展示了一种同时显示多个chip的简单使用的方法。我们将使用Wrap小部件作为chip列表的父级。当当前行的可用空间用完时,筹码会自动下行。由于Wrap 小部件的间距属性,我们还可以方便地设置chip之间的距离。


截屏:


image-20220125100331474


代码:


Scaffold(
     appBar: AppBar(
       title: const Text('大前端之旅'),
    ),
     body: Padding(
       padding: const EdgeInsets.symmetric(vertical: 30, horizontal: 10),
       child: Wrap(
           // space between chips
           spacing: 10,
           // list of chips
           children: const [
             Chip(
               label: Text('Working'),
               avatar: Icon(
                 Icons.work,
                 color: Colors.red,
              ),
               backgroundColor: Colors.amberAccent,
               padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10),
            ),
             Chip(
               label: Text('Music'),
               avatar: Icon(Icons.headphones),
               backgroundColor: Colors.lightBlueAccent,
               padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10),
            ),
             Chip(
               label: Text('Gaming'),
               avatar: Icon(
                 Icons.gamepad,
                 color: Colors.white,
              ),
               backgroundColor: Colors.pinkAccent,
               padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10),
            ),
             Chip(
               label: Text('Cooking & Eating'),
               avatar: Icon(
                 Icons.restaurant,
                 color: Colors.pink,
              ),
               backgroundColor: Colors.greenAccent,
               padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10),
            )
          ]),
    ),
  );

在这个例子中,chip只呈现信息。在下一个示例中,chip是可交互的。


复杂示例:动态添加和移除筹码


应用预览


chip


我们要构建的应用程序包含一个浮动操作按钮。按下此按钮时,将显示一个对话框,让我们添加一个新chip。可以通过点击与其关联的删除图标来删除每个chip。


以下是应用程序的工作方式:


完整代码


main.dart中的最终代码和解释:


// main.dart
import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

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

@override
Widget build(BuildContext context) {
  return MaterialApp(
    debugShowCheckedModeBanner: false,
    title: '大前端之旅',
    theme: ThemeData(
      primarySwatch: Colors.green,
    ),
    home: const HomePage(),
  );
}
}

// Data model for a chip
class ChipData {
// an id is useful when deleting chip
final String id;
final String name;
ChipData({required this.id, required this.name});
}

class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);

@override
_HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
// list of chips
final List<ChipData> _allChips = [];

// Text controller (that will be used for the TextField shown in the dialog)
final TextEditingController _textController = TextEditingController();
// This function will be triggered when the floating actiong button gets pressed
void _addNewChip() async {
  await showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          title: const Text('添加'),
          content: TextField(
            controller: _textController,
          ),
          actions: [
            ElevatedButton(
                onPressed: () {
                  setState(() {
                    _allChips.add(ChipData(
                        id: DateTime.now().toString(),
                        name: _textController.text));
                  });

                  // reset the TextField
                  _textController.text = '';

                  // Close the dialog
                  Navigator.of(context).pop();
                },
                child: const Text('提交'))
          ],
        );
      });
}

// This function will be called when a delete icon associated with a chip is tapped
void _deleteChip(String id) {
  setState(() {
    _allChips.removeWhere((element) => element.id == id);
  });
}

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('大前端之旅'),
    ),
    body: Padding(
      padding: const EdgeInsets.all(15),
      child: Wrap(
        spacing: 10,
        children: _allChips
            .map((chip) => Chip(
                  key: ValueKey(chip.id),
                  label: Text(chip.name),
                  backgroundColor: Colors.amber.shade200,
                  padding:
                      const EdgeInsets.symmetric(vertical: 7, horizontal: 10),
                  deleteIconColor: Colors.red,
                  onDeleted: () => _deleteChip(chip.id),
                ))
            .toList(),
      ),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: _addNewChip,
      child: const Icon(Icons.add),
    ),
  );
}
}


结论


我们已经探索了 Chip 小部件的许多方面,并经历了不止一个使用该小部件的示例。


大家喜欢的话,点赞支持一下坚果


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

DeepLink在转转的实践

1. DeepLink 简介 DeepLink:“深度链接”技术,这个名词初看比较抽象,不过在我们身边却有不少应用,比如以下场景: 刷抖音看到转转的广告,点击视频下方的下载链接,如果没有安装转转则下载转转,并在打开转转后跳转到相应活动页面 在微信看到朋友分享...
继续阅读 »

1. DeepLink 简介


DeepLink:“深度链接”技术,这个名词初看比较抽象,不过在我们身边却有不少应用,比如以下场景:



  • 刷抖音看到转转的广告,点击视频下方的下载链接,如果没有安装转转则下载转转,并在打开转转后跳转到相应活动页面

  • 在微信看到朋友分享的转转商品,点击后如果没有安装转转则下载转转,并在打开转转跳转到相应商品的详情页

  • 看到转转发送的订单提醒短信,点击链接后如果没有安装转转则下载转转, 并在打开转转跳转到相应订单详情页


DeepLink 使用户能够在目标 APP 之外,比如广告(抖音)/社交媒体(微信)/短信中通过点击链接,直接跳转到目标 APP 特定的页面(对于已经安装了 APP 会直接进行跳转,未安装 APP 会引导下载,下载安装完成之后跳转)。DeepLink 技术可以实现场景的快速还原,缩短用户使用路径,更重要的是能够用于 APP 拉新推广场景,降低用户流失率。


随着短视频的风靡,通过短视频投放广告获客的方式也流行起来, 本文主要介绍在新媒体拉新推广场景中 DeepLink 的应用以及服务端的搭建。


2 .应用场景


我在刷抖音时刷到一个转转回收的广告视频,而家里刚好有闲置的手机,我就抱着试一试的态度点击视频下方的下载链接下载转转,看看能不能在这个平台上处理掉手中的闲置,当下载安装成功之后打开跳转到了回收页面,我可能会眉头一挑,嗯~这个体验还挺好,然后测了下闲置手机值多少钱,回收价又刚好满足我的心理预期,而且还能为碳中和贡献一份自己的力量,何乐而不为之。


新媒体获客场景


以上是比较常见的一种场景,通过在抖音、快手或者其他渠道来投放广告来吸引一些有需求的用户来到转转,并通过 DeepLink 技术在下载完成打开转转后直接跳转到用户感兴趣的页面。


对于上述场景安卓和 IOS 的实现是有所区别的,包括下载策略以及 APP 内部跳转到用户感兴趣页面的策略。


2.1 IOS 应用场景


由于 IOS 下载APP只能通过 AppStore,所以 DeepLink 服务针对 IOS 会重定向到一个 H5 中间页,在 H5 中间页将服务端返回的 DeepLink 跳转链接复制到剪切板中,并拉起 AppStore 引导用户下载转转 APP,安装打开后 IOS 从剪切板中获取跳转链接进行跳转,到达用户感兴趣的页面。


IOS下载


2.2 安卓应用场景


安卓可以直接通过 DeepLink 服务下载转转 APP,而 DeepLink 跳转链接以 APK Signature Scheme v2 方式打入 apk 包中,安装打开后解析跳转链接跳转到用户感兴趣的页面。


安卓下载


APK Signature Scheme v2 是 Android 7.0 引入的一项新的应用签名方案 ,它能提供更快的应用安装时间和更多针对未授权 APK 文件更改的保护。


下图是新的签名方案和旧的签名方案的一个对比:


新旧签名方案对比


APK Signing Block 结构
































偏移字节数描述
@+08这个 Block 的长度(本字段的长度不计算在内)
@+8n一组 ID-value
@-248这个 Block 的长度(和第一个字段一样值)
@-1616魔数 “APK Sig Block 42”

APK Signing Block 中的 ID-value 是可扩展的,由于 APK Signing Block 的数据块不会参与签名校验,也就是说我们可以自定义一组 ID-value,用于存储额外的信息,我们通过自定义ID-value将跳转信息打入APK包中。


3. DeepLink 服务


在 IOS DeepLink 方案中服务端只是负责重定向到一个 H5 中间页,因此不再赘述,下面我们主要介绍下安卓的 DeepLink 方案。


概要设计


3.1 投放链接设计


投放链接是投放到各个渠道的下载链接,需要考虑以下几点:




  1. 各个渠道链接规则不一样,保证我们链接规则能够覆盖所有渠道


    通过我们的调研有些渠道只支持 Get 请求,有些渠道不允许带参数,有些渠道必须以.apk 进行结尾




  2. 投放方便,链接投放出去之后不需要再改动


    由于投放链接是给到一些自媒体创作者,在给出链接之后能够保证从始至终都能下到最新的APP




  3. 充分利用 CDN


    转转 APP、找靓机 APP 的包百兆左右,为了保证服务的稳定性同样为了节约带宽,尽量发挥 CDN 的作用把绝大多数请求让 CDN 服务器来进行处理返回




3.1.1 兼容版本1.0


考虑到兼容各个渠道,某些渠道必须以 apk 结尾、某些渠道不支持Get请求带参数,采用什么方式?


既然不能带参数,那我们的参数信息可以直接拼到path中,参数以某种规则组装,服务端解析,需要的信息包括 APP 类型、渠道信息、DeepLink 链接信息、版本号等,简要设计出的投放链接 1.0 大致如下:


apk.zhuanstatic.com/deeplink/**…



  • appType: APP 类型,目前支持转转和找靓机,可扩展,如 zhuanzhuan

  • channel:渠道类型,根据每个投放渠道单独设置渠道 id,如 douyin666

  • version:APP 版本号,如 9.0.0

  • deepLink:deepLink 信息,目前传输 deepLinkId,deepLinkId 和端内跳转链接的映射关系由后台维护,服务端通过映射关系拿到跳转链接打入 apk 包中,如 huishou


3.1.2 升级版本2.0


1.0的版本号是直接写到 path 中的,这会造成很多隐患



  1. 可以通过修改版本号恶意下载 APP 的任意版本

  2. 保证用户一直下到最新的包需要版本更新之后更新所有投放链接


这显然是不合理的,针对以上两点我们必然需要删掉 version,替代方案可以让服务端在处理下载请求的时候通过其他方式拿到版本信息,修正后的投放链接 2.0 如下:


apk.zhuanstatic.com/deeplink/**…


3.1.3 最终版本3.0


2.0中没有了版本信息进而导致相同的渠道投放链接是一致的,只要 CDN 中有老版本APP的缓存,下载的是缓存的老版本APP,无法获取最新APP


因此我们考虑中间做一次重定向,通过一个不接入 CDN 的固定链接去重定向到一个接入 CDN 的带版本号的链接,这样问题就迎刃而解了,因此投放链接 3.0 应运而生:


apk.zhuanzhuan.com/deeplink/**…

apk.zhuanstatic.com/deeplink/**…


apk.zhuanzhuan.com 不走 CDN,只是将链接中的版本号补全并重定向到走 CDN 的 apk.zhuanstatic.com ,这样在投放链接不变的情况下能保证用户下载到最新的包。


3.2 打包&下载


投放链接设计好之后,通过投放链接可以解析到一些参数信息,比如:
apk.zhuanstatic.com/deeplink/zh…


我们知道用户下载的是douyin666渠道转转9.0.0版本的包,并且APP打开后需要跳转回收的页面。


下载渠道包服务端逻辑主要分为两大块,第一部分是拿到相应版本的原始包,然后通过 APK Signature Scheme v2 方式将渠道号和 DeepLink 跳转链接打入原始包中获得渠道包,将渠道包提供给用户进行下载。


为了能应对 APP 升级和渠道投放带来的流量,尤其是 CDN 中还没有缓存的时候,避免大量请求将我们服务打垮,所以需要引入本地缓存,如何引入?


首先我们分析下服务端的主要逻辑找出不可变的数据,第一原始包肯定是不变的,第二在原始包相同的情况下如果 channel 和 deepLink 跳转链接是一致的,那我们打包出来的渠道包也相应是不可变的,因此我们可以针对这两部分来进行缓存。


接下来我们分析缓存选型以及缓存策略,本地缓存的组件有好多可选的,比如 Caffeine Cache、Guava Cache 等,网上关于他们的测评如下:


读场景性能对比


可以看到在读场景下,caffeine cache 是当之无愧的王者,而且我们的场景基本是接近 100%的读,所以我们优先选择了 Caffeine Cache。


以下是两个本地缓存策略介绍:


3.2.1 一级缓存(渠道包)


     /**
* 缓存高频渠道包文件
*/
private static final Cache<String, byte[]> channelFinalAppCache = CacheBuilder
.newBuilder()
.expireAfterAccess(1, TimeUnit.DAYS)
.maximumSize(15)
.build();

渠道包的缓存 key 是 appType+version+channel+deepLink,由于 channel 和 deepLink 组合的众多,通过分析之前的下载数据缓存最高频的 15 个渠道包就基本满足 90%以上的请求而且不至于占用太多的内存,而为了获取最高频的 15 个渠道,我们通过大数据平台以 T+1 的方式将渠道数据更新到数据库中,DeepLink服务通过定时任务读取数据库中的渠道数据刷新缓存。


3.2.2 二级缓存(原始包)


    /**
* 缓存原始包文件
*/
private static final Cache<String, byte[]> channelAppCache = CacheBuilder
.newBuilder()
.expireAfterAccess(2, TimeUnit.DAYS)
.maximumSize(10)
.build();

原始包的缓存 key 是 appType+version,由于我们只下载最新版本的包, APP 类型暂时只有转转和找靓机,所有我们设置最大数量 10 是足够的,在我们应用启动的时候会对这个缓存进行初始化,以避免第一次用户下载速度过慢,并在之后监听APP的发版信息,新版本更新后刷新缓存。


4. 总结


DeepLink 服务支撑了新媒体投放以及 APP 内置更新的下载能力,为了保证服务稳定性和性能,除上述缓存策略外,还有其他策略来协同,比如 APP 发新版本时会进行 CDN 预热,将下载量高的渠道包缓存到 CDN 中,以使大部分流量能够在 CDN 服务器被消化,即使有突发流量打过来也会有限流规则过滤流量以保证服务的稳定性。


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

Retrofit解密:接口请求是如何适配suspend协程?

最初的retrofit请求 我们先看下原来如何通过retrofit发起一个网络请求的,这里我们直接以官网的例子举例: 动态代理创建请求服务 interface GitHubService { //创建get请求方法 @GET("users/{u...
继续阅读 »

最初的retrofit请求


我们先看下原来如何通过retrofit发起一个网络请求的,这里我们直接以官网的例子举例:


动态代理创建请求服务


interface GitHubService {
//创建get请求方法
@GET("users/{user}/repos")
fun listRepos(@Path("user") user: String?): Call<Response>
}

//动态代理创建GitHubService
fun createService(): GitHubService {
val retrofit = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.build()

return retrofit.create(GitHubService::class.java)
}



  • retrofit.create底层是通过动态代理创建的GitHubService的一个子实现类;




  • 创建的这个GitHubService一般作为单例进行使用,这里只是简单举例没有实现单例;




发起网络请求


fun main() {
//异步执行网络请求
createService().listRepos("").enqueue(object : Callback<Response> {
override fun onResponse(call: Call<Response>, response: retrofit2.Response<Response>) {
//主线程网络请求成功回调
}

override fun onFailure(call: Call<Response>, t: Throwable) {
//主线程网络请求失败回调
}
})
}

这种调用enqueue()异步方法并执行callback的方式是不是感觉很麻烦,如果有下一个请求依赖上一个请求的执行结果,那就将会形成回调地狱这种可怕场景。


协程suspend本就有着以同步代码编写执行异步操作的能力,所以天然是解决回调地狱好帮手。接下来我们看下如何使用协程suspend


借助suspend发起网络请求


suspend声明接口方法


interface GitHubService {
@GET("users/{user}/repos")
suspend fun listRepos(@Path("user") user: String?): Response<String>
}

可以看到就是在listRepos方法声明前加了个suspend关键字就完了。


创建协程执行网络请求


fun main() {
//1.创建协程作用域,需要保证协程的调度器是分发到主线程执行
val scope = MainScope()
scope.launch(CoroutineExceptionHandler { _, _ ->
//2.捕捉请求异常
}) {
//3.异步执行网络请求
val result = createService().listRepos("")

val content = result.body()?
}
}



  1. 首先创建一个协程作用域,需要保证协程调度器类型为Dispatchers.Main,这样整个协程的代码块都会默认在主线程中执行,我们就可以直接在里面执行UI相关操作




  2. 创建一个CoroutineExceptionHandler捕捉协程执行过程中出现的异常,这个捕捉异常的粒度比较大,是捕捉整个协程块的异常,可以考虑使用try-catch专门捕获网络请求执行的异常:


    //异步执行网络请求
    try {
    val result = createService().listRepos("")
    } catch (e: Exception) {
    //可以考虑执行重连等逻辑或者释放资源
    }



  3. 直接调用listRepos()方法即可,不需要传入任何回调,并直接返回方法结果。这样我们就实现了以同步的代码实现了异步网络请求。




接下来我们就看下如何retrofit源码是如何实现这一效果的。


retrofit如何适配suspend


直接定位到HttpServiceMethod.parseAnnotations()方法:


static <ResponseT, ReturnT> HttpServiceMethod<ResponseT, ReturnT> parseAnnotations(
Retrofit retrofit, Method method, RequestFactory requestFactory) {
//1.判断是否为suspend挂起方法
boolean isKotlinSuspendFunction = requestFactory.isKotlinSuspendFunction;

//省略一堆和当前分析主题不想关的代码

if (!isKotlinSuspendFunction) {
return new CallAdapted<>(requestFactory, callFactory, responseConverter, callAdapter);
} else if (continuationWantsResponse) {
//挂起执行
return (HttpServiceMethod<ResponseT, ReturnT>) new SuspendForResponse<>();
} else {
//挂起执行
return (HttpServiceMethod<ResponseT, ReturnT>) new SuspendForBody<>();
}
}

1.判断是否为suspend挂起方法


看下requestFactory.isKotlinSuspendFunction赋值的地方,经过一番查找(省略...),最终方法在RequestFactoryparseParameter间接赋值:


private @Nullable ParameterHandler<?> parseParameter() {
//...
//1.是否是方法最后一个参数
if (allowContinuation) {
try {
if (Utils.getRawType(parameterType) == Continuation.class) {
//2.标识为suspend挂起方法
isKotlinSuspendFunction = true;
return null;
}
} catch (NoClassDefFoundError ignored) {
}
}
}

如果一个方法被声明为suspend,该方法翻译成java代码就会给该方法添加一个Continuation类型的参数,并且放到方法参数的最后一个位置,比如:


private suspend fun test66(name: String) {  
}

会被翻译成:


private final Object test66(String name, Continuation $completion) {
return Unit.INSTANCE;
}

所以上面的代码就可以判断出请求的接口方法是否被suspend声明,是isKotlinSuspendFunction将会被置为true。


2.挂起则创建SuspendForResponseSuspendForBody


这个地方我们以SuspendForBody进行分析,最终会执行到其adapt()方法:


@Override
protected Object adapt(Call<ResponseT> call, Object[] args) {
call = callAdapter.adapt(call);
//1.获取参数
Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];

try {
return isNullable
? KotlinExtensions.awaitNullable(call, continuation)
//2.调用真正的挂起方法
: KotlinExtensions.await(call, continuation);
} catch (Exception e) {
return KotlinExtensions.suspendAndThrow(e, continuation);
}
}



  1. 获取调用的suspend声明的接口方法中获取最后一个Continuation类型参数




  2. 调用await方法,由于这是一个kotlin定义的接收者为Call的挂起方法,如果在java中调用,首先第一个参数要传入接收者,也就是call,其实await()是一个挂起方法,翻译成java还会增加一个Continuation类型参数,所以调用await()还要传入第一步获取的Continuation类型参数。




3.核心调用await()方法探究


await()就是retrofit适配suspend实现同步代码写异步请求的关键,也是消除回调地狱的关键:


suspend fun <T : Any> Call<T>.await(): T {
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation {
cancel()
}
enqueue(object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
//关键
continuation.resumeWithException(KotlinNullPointerException())
} else {
//关键
continuation.resume(body)
}
} else {
//关键
continuation.resumeWithException(HttpException(response))
}
}

override fun onFailure(call: Call<T>, t: Throwable) {
//关键
continuation.resumeWithException(t)
}
})
}
}

使用到了协程的一个非常关键的方法suspendCancellableCoroutine{},该方法就是用来捕获传入的Continuation并决定什么恢复挂起的协程执行的,比如官方的delay()方法也是借助该方法实现的。


所以当我们执行调用enqueue()方法时在网络请求没有响应(成功或失败)前,协程一直处于挂起的状态,之后收到网络响应后,才会调用resume()resumeWithException()恢复挂起协程的执行,这样我们就实现了同步代码实现异步请求的操作,而不需要任何的callback嵌套地狱


总结


本篇文章详细分析retrofit如何适配suspend协程的,并且不用编写任何的callback回调,直接以同步代码编写实现异步请求的操作。


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

【Android】一键登录 - 三大运营商

业务背景: 在条件允许的情况下(无 SIM 卡的手机,无法触发一键登录),通过运行商提供的服务,进行【一键登录】。简化用户的登录操作,提高 App 的登录注册率以及使用率。 本方案采用的是阿里云中【一键登录】方案。 效果图: 前提知识: 整个流程如图所 ...
继续阅读 »

业务背景:


在条件允许的情况下(无 SIM 卡的手机,无法触发一键登录),通过运行商提供的服务,进行【一键登录】。简化用户的登录操作,提高 App 的登录注册率以及使用率。


本方案采用的是阿里云中【一键登录】方案。


效果图:



前提知识:



  • 整个流程如图所



(图源自网络[掘金大佬-NanBox],侵删)



  • 该方案下,不允许使用完全自定义的授权页。但是可以通过属性配置,进行一定的修改。可修改的属性如下图所示



Android 接入流程:


1.浅析 Demo


通常第一步都是下载官方 Demo 后,进行一番调试,盘点功能列表,是否符合自身需求。


链接:pan.baidu.com/s/1RX5yGp06… 提取码:qbx0


接下来,简单分析 Demo 项目架构,帮助大家尽快上手这个项目。


首先,我们要知道这个 Demo,是包括【一键登录】和【本机号码校验】两个功能。根据自己的需求分析对应的代码即可。这次我们只使用到前者,所以后者内容不在这里讲述。



主要看到下列三个模块:


Config - 就是上面预告知识中说到的配置项,主要是授权页的一些配置项


OneKeyLoginActivity - 登录页面


MessageActivity - 模拟【其他登录方式】页面


那具体的实现,就可以直接看对应模块的内容即可。可以在原 Demo,进行调试。


2.接入思路分析


基于判断是否支持【一键登录】的时机 提供两种接入思路


第一种:启动登录功能前判断



判断的方式可以通过


mPhoneNumberAuthHelper.checkEnvAvailable(PhoneNumberAuthHelper.SERVICE_TYPE_LOGIN)
复制代码

是否支持【一键登录】。该流程未经检验,大家可以执行验证。


第二种:直接唤起【一键登录】,失败后再唤起【其他登录方式】



Demo 也是第二种方式。这种方式需要用到一个壳 Activity 。但这个壳主要的作用是初始化SDK,以及做逻辑判断和处理(即并无实际内容展示)。


这里引发一个思考:


既然用不到 Activity 的内容,那能不能换种方式呢呢?对于单例,我思考后,一开始觉得是没问题的,但是等写完后,发现我写成了一个 OneKeyLoginHelper 的单例,发现相应逻辑处理需要传入 activity 或者 fragment 的引用。那么我们知道单例中是不能持有这样的引用的(这里可以考虑使用弱引用),这会导致内容泄漏。不知道是否还有其他的方法?


3.代码接入流程


//STEP 1.初始化监听器(这里根据业务自己做处理)


//STEP 2.初始化SDK实例


//STEP 3.设置SDK秘钥


//STEP 4.唤起一键登录页


4.避坑


接着,讲一下接入过程中,遇到的一些问题。帮大家避免无效劳动,可以有更多的时间学(hua )习(shui)。


问题描述: 因为选择了第二种思路,那么会有个壳 Activity 的问题。这个壳,我们不处理的话,是不透明的,这样当我们进到这个壳的时候,再跳转到别的页面就会有个空白页。


解决方案: 将壳的主题改为透明色,经过实验,下述代码可以实现。(壳Activity 需要继承 AppCompatActivity)


<style name="Theme.Transparent" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:backgroundDimEnabled">false</item>
</style>

问题描述: 发现从【授权页】跳到【其他方式登录】的时候,授权页会逐渐变透明,会看到下一层页面的内容。如动图中,粉红色的箭头所示。



解决方案: 可以直接忽略,这个是 SDK 本身的问题。因为阿里那边给的回复是:(是否有最新解决方案,会及时更新,或者可以以你们当时咨询的为准)


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

记录 Kotlin 实践的一些好建议

目录 注释 函数式接口 高阶函数 扩展函数 注释 Java:    /**     * @see AdVideoUserInfoContainerData#type     */ &nbs...
继续阅读 »

目录



  1. 注释

  2. 函数式接口

  3. 高阶函数

  4. 扩展函数


注释


Java:


    /**
    * @see AdVideoUserInfoContainerData#type
    */
   public Builder type(int type) {
       userInfoData.type = type;
       return this;
  }
   /** 事件配置, 对应于 {@link FeedAdLottieRepoInfo#name 属性} */
   public String lottieConfig;

Kotlin:


/**
* 由[CountDownType.type] mapTo [CountDownType]
* 避免了使用 when(type) 写 else 了
*/
private fun type2Enum(type: () -> String): CountDownType {
   return CountDownType.values().firstOrNull {
       it.type == type()
  } ?: CountDownType.CIRCLE
}

Kotlin 可以使用内联标记来引用类、方法、属性等,这比 Java 中的 @see、@link 更加易用。


文档:kotlinlang.org/docs/kotlin…


函数式接口


非函数式接口:


internal interface ICountDownCallback {
   /**
    * 倒计时完成时回调
    */
   fun finish()
}

internal fun setCountDownCallback(callback: ICountDownCallback) {
   // ignore
}

internal fun show() {
   setCountDownCallback(object : ICountDownCallback {
       override fun finish() {
           TODO("Not yet implemented")
      }
  })
}

函数式接口:


internal fun interface ICountDownCallback {
   /**
    * 倒计时完成时回调
    */
   fun finish()
}

internal fun setCountDownCallback(callback: ICountDownCallback) {
   // ignore
}

internal fun show() {
   setCountDownCallback {
       TODO("Not yet implemented")
  }
}

函数式接口也被称为单一抽象方法(SAM)接口,使用函数式接口可以使代码更加简洁,富有表现力。


对于 Java 的接口,比如 View.OnClickListener,它在使用的时候可以直接转 lambda 使用的,只有是 kotlin 的单一抽象方法,需要加 fun 关键字标示它为函数式接口。


文档:kotlinlang.org/docs/fun-in…


高阶函数


如果对象的初始化比较麻烦,可以使用高阶函数,让代码更加流畅:


    // 定义
open fun attachToViewGroup(
       viewGroup: ViewGroup,
       index: Int = -1,
       lp: () -> MarginLayoutParams = {
           MarginLayoutParams(
               LayoutParams.WRAP_CONTENT,
               LayoutParams.WRAP_CONTENT
          )
      }
  ) {
      (this.parent as? ViewGroup)?.removeView(this)
       viewGroup.addView(this, lp.invoke())
  }

// 使用
   override fun attachToViewGroup(viewGroup: ViewGroup, index: Int, lp: () -> MarginLayoutParams) {
       super.attachToViewGroup(viewGroup, index) {
           MarginLayoutParams(
               ViewGroup.LayoutParams.WRAP_CONTENT,
               ViewGroup.LayoutParams.WRAP_CONTENT
          ).apply {
               leftMargin = 14.px(context)
               topMargin = 44.px(context)
          }
      }
  }

如果参数的获取比较复杂,代码比较长,有不少判断逻辑,也可以使用高阶函数:


// 定义
fun getCountDownViewByType(context: Context, type: () -> String = { "0" }) {
// ignore
}
// 使用
countDownView = CountDownType.getCountDownViewByType(this) {
rewardVideoCmdData.cmdPolicyData?.countDownType ?: ""
}

如果方法的返回值是一个状态值,然后根据状态值去做相关逻辑处理。这种情况下,其实我们想要的是一个行为,比如代码中充斥着大量的数据解析、校验等逻辑,我们也可以是使用高阶函数重构:


// 重构之前
/**
* 校验数据有效(校验标题和按钮有一个不为空,就可以展示 Dialog)
*/
fun checkValid(): Boolean {
   return !dialogTitle.isNullOrEmpty() || !buttonList.isNullOrEmpty()
}

private fun bindData() {
   rewardData = RewardDialogData(arguments?.getString(EXTRA_REWARD_DATA) ?: "")
   // 弹窗数据不合法,就不需要展示 dialog 了
   if (rewardData == null || !rewardData!!.checkValid()) {
       dismiss()
       return
  }
   // 更新字体颜色等
   updateSkin()
}


// 重构之后
/**
* 数据校验失败,执行 [fail] 函数
*/
internal inline fun RewardDialogData?.checkFailed(fail: () -> Unit) {
   this?.let {
       if (dialogTitle.isNullOrEmpty() && buttonList.isNullOrEmpty()) {
           fail()
      }
  } ?: fail()
}


private fun bindData() {
   rewardData = RewardDialogData(arguments?.getString(EXTRA_REWARD_DATA) ?: "")
   // 弹窗数据不合法,就不需要展示 dialog 了
   rewardData?.checkFailed {
       dismiss()
       return
  }
   // 更新字体颜色等
   updateSkin()
}

kotlin 标准库里面也是有非常多的高阶函数的,比如作用域函数(let、apply、run等等),除此之外,还有一些集合类的标准库函数:


// filter
fun showCharge() {
   adMonitorUrl?.filter {
       !it.showUrl.isNullOrEmpty()
  }?.forEach {
       ParallelCharge.charge(it.showUrl)
  }
}
// forEachIndexed
list.forEachIndexed { index, i ->
// ignore
}

文档:kotlinlang.org/docs/lambda…


扩展函数


// 比较不流畅的写法
val topImgUrl = rewardData?.topImg
if (topImgUrl.isNullOrBlank()) {
   topImg.visibility = View.GONE
} else {
   topImg.hierarchy?.useGlobalColorFilter = false
   topImg.visibility = View.VISIBLE
   topImg.setImageURI(topImgUrl)
}

// 使用局部返回标签
topImg.apply {
   if (topImgUrl.isNullOrEmpty()) {
       visibility = View.GONE
       return@apply
  }
   hierarchy?.useGlobalColorFilter = false
   setImageURI(topImgUrl)
   visibility = View.VISIBLE
}

/**
* 校验 View 可见性
*
* @return [predicate] false: GONE;true: VISIBLE
*/
internal inline fun <reified T : View> T.checkVisible(predicate: () -> Boolean): T? {
   return if (predicate()) {
       visibility = View.VISIBLE
       this
  } else {
       visibility = View.GONE
       null
  }
}

// 使用扩展函数
topImg.checkVisible {
   !topImgUrl.isNullOrEmpty()
}?.run {
   hierarchy?.useGlobalColorFilter = false
   setImageURI(topImgUrl)
}

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

一天高中的女同桌突然问我是不是程序猿

背景 昨天一个我高中的女同桌突然发微信问我“你是不是程序猿 我有问题求助”, 先是激动后是茫然再是冷静,毕业多年不见联系,突然发个信息求助,感觉大脑有点反应不过来... 再说我一个搞Android的也不咋会python啊(不是说Java不能实现,大家懂的,人...
继续阅读 »

背景


昨天一个我高中的女同桌突然发微信问我“你是不是程序猿 我有问题求助”,


image-20211015101843733.png


先是激动后是茫然再是冷静,毕业多年不见联系,突然发个信息求助,感觉大脑有点反应不过来... 再说我一个搞Android的也不咋会python啊(不是说Java不能实现,大家懂的,人生苦短,我用python),即使如此,
为了大家的面子,为了程序猿们的脸,不就简单的小Python嘛,必须答应!


梳理需求


现有excel表格记录着 有效图片的名字,如:


image-20211015103418631.png


要从一个文件夹里把excel表格里记录名字的图片筛选出来;


需求也不是很难,代码思路就有了:



  1. 读取Excel表格第一列的信息并放入A集合

  2. 遍历文件夹下所有的文件,判断文件名字是否存在A集合

  3. 存在A集合则拷贝到目标文件夹


实现(Python 2.7)


读取Excel表格

加载Excel表格的方法有很多种,例如pandasxlrdopenpyxl,我这里选择openpyxl库,
先安装库



pip install openpyxl



代码如下:


from openpyxl import load_workbook

def handler_excel(filename=r'C:/Users/xxx/Desktop/haha.xlsx'):
   # 根据文件路径加载一个excel表格,这里包含所有的sheet
   excel = load_workbook(filename)
   # 根据sheet名称加载对应的table
   table = excel.get_sheet_by_name('Sheet1')
   imgnames = []
   # 读取所有列
   for column in table.columns:
       for cell in column:
           imgnames.append(cell.value+".png")
# 选择图片
   pickImg(imgnames)

遍历文件夹读取文件名,找到target并拷贝

使用os.listdir 方法遍历文件,这里注意windows环境下拿到的unicode编码,需要GBK重新解码


def pickImg(pickImageNames):
   # 遍历所有图片集的文件名
   for image in os.listdir(
           r"C:\Users\xxx\Desktop\work\img"):
       # 使用gbk解码,不然中文乱码
       u_file = image.decode('gbk')
       print(u_file)
       if u_file in pickImageNames:
           oldname = r"C:\Users\xxx\Desktop\work\img/" + image
           newname = r"C:\Users\xxx\Desktop\work\target/" + image
           # 文件拷贝
           shutil.copyfile(oldname, newname)

简单搞定!没有砸程序猿的招牌,豪横的把成果发给女同桌,结果:


image-20211015112550343.png


换来有机会请你吃饭,微信都不带回的,哎 ,xdm,小丑竟是我自己!
小丑竟是我自己什么梗-小丑竟是我自己是什么意思出自什么-55手游网


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

Kotlin协程之Dispatchers原理

Kotlin协程不是什么空中阁楼,Kotlin源代码会被编译成class字节码文件,最终会运行到虚拟机中。所以从本质上讲,Kotlin和Java是类似的,都是可以编译产生class的语言,但最终还是会受到虚拟机的限制,它们的代码最终会在虚拟机上的某个线程上被执...
继续阅读 »

Kotlin协程不是什么空中阁楼,Kotlin源代码会被编译成class字节码文件,最终会运行到虚拟机中。所以从本质上讲,Kotlin和Java是类似的,都是可以编译产生class的语言,但最终还是会受到虚拟机的限制,它们的代码最终会在虚拟机上的某个线程上被执行。


之前我们分析了launch的原理,但当时我们没有去分析协程创建出来后是如何与线程产生关联的,怎么被分发到具体的线程上执行的,本篇文章就带大家分析一下。


前置知识


要想搞懂Dispatchers,我们先来看一下Dispatchers、CoroutineDispatcher、ContinuationInterceptor、CoroutineContext之间的关系


public actual object Dispatchers {
@JvmStatic
public actual val Default: CoroutineDispatcher = DefaultScheduler

@JvmStatic
public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher

@JvmStatic
public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined

@JvmStatic
public val IO: CoroutineDispatcher = DefaultIoScheduler
}

public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
}

public interface ContinuationInterceptor : CoroutineContext.Element {}

public interface Element : CoroutineContext {}

Dispatchers中存放的是协程调度器(它本身是一个单例),有我们平时常用的IO、Default、Main等。这些协程调度器都是CoroutineDispatcher的子类,这些协程调度器其实都是CoroutineContext


demo


我们先来看一个关于launch的demo:


fun main() {
val coroutineScope = CoroutineScope(Job())
coroutineScope.launch {
println("Thread : ${Thread.currentThread().name}")
}
Thread.sleep(5000L)
}

在生成CoroutineScope时,demo中没有传入相关的协程调度器,也就是Dispatchers。那这个launch会运行到哪个线程之上?


运行试一下:


Thread : DefaultDispatcher-worker-1

居然运行到了DefaultDispatcher-worker-1线程上,这看起来明显是Dispatchers.Default协程调度器里面的线程。我明明没传Dispatchers相关的context,居然会运行到子线程上。说明运行到default线程是launch默认的。


它是怎么与default线程产生关联的?打开源码一探究竟:


public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
//代码1
val newContext = newCoroutineContext(context)

//代码2
val coroutine = if (start.isLazy)
LazyStandaloneCoroutine(newContext, block) else
StandaloneCoroutine(newContext, active = true)

//代码3
coroutine.start(start, coroutine, block)
return coroutine
}


  1. 将传入的CoroutineContext构造出新的context

  2. 启动模式,判断是否为懒加载,如果是懒加载则构建懒加载协程对象,否则就是标准的

  3. 启动协程


我们重点关注代码1,这是与CoroutineContext相关的。


public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
//从父协程那里继承过来的context+这次的context
val combined = coroutineContext.foldCopiesForChildCoroutine() + context
val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
//combined可以简单的把它看成是一个map,它是CoroutineContext类型的
//如果当前context不等于Dispatchers.Default,而且从map里面取ContinuationInterceptor(用于拦截之后分发线程的)值为空,说明没有传入协程应该在哪个线程上运行的相关参数
return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
debug + Dispatchers.Default else debug
}

调用launch的时候,我们没有传入context,默认参数是EmptyCoroutineContext。这里的combined,它其实是CoroutineContext类型的,可以简单的看成是map(其实不是,只是类似)。通过combined[ContinuationInterceptor]可以将传入的线程调度相关的参数给取出来,这里如果取出来为空,是给该context添加了一个Dispatchers.Default,然后把新的context返回出去了。所以launch默认情况下,会走到default线程去执行。


补充一点:CoroutineContext能够通过+连接是因为它内部有个public operator fun plus函数。能够通过combined[ContinuationInterceptor]这种方式访问元素是因为有个public operator fun get函数。


public interface CoroutineContext {
/**
* Returns the element with the given [key] from this context or `null`.
*/
public operator fun <E : Element> get(key: Key<E>): E?

/**
* Returns a context containing elements from this context and elements from other [context].
* The elements from this context with the same key as in the other one are dropped.
*/
public operator fun plus(context: CoroutineContext): CoroutineContext {
......
}
}

startCoroutineCancellable


上面我们分析了launch默认情况下,context中会增加Dispatchers.Default的这个协程调度器,到时launch的Lambda会在default线程上执行,其中具体流程是怎么样的,我们分析一下。


在之前的文章 Kotlin协程之launch原理 中我们分析过,launch默认情况下会最终执行到startCoroutineCancellable函数。


public fun <T> (suspend () -> T).startCoroutineCancellable(completion: Continuation<T>): Unit = runSafely(completion) {
//构建ContinuationImpl
createCoroutineUnintercepted(completion).intercepted().resumeCancellableWith(Result.success(Unit))
}

public actual fun <T> (suspend () -> T).createCoroutineUnintercepted(
completion: Continuation<T>
): Continuation<Unit> {
val probeCompletion = probeCoroutineCreated(completion)
return if (this is BaseContinuationImpl)
//走这里
create(probeCompletion)
else
createCoroutineFromSuspendFunction(probeCompletion) {
(this as Function1<Continuation<T>, Any?>).invoke(it)
}
}

Kotlin协程之launch原理 文章中,咱们分析过create(probeCompletion)这里创建出来的是launch的那个Lambda,编译器会产生一个匿名内部类,它继承自SuspendLambda,而SuspendLambda是继承自ContinuationImpl。所以 createCoroutineUnintercepted(completion)一开始构建出来的是一个ContinuationImpl,接下来需要去看它的intercepted()函数。


internal abstract class ContinuationImpl(
completion: Continuation<Any?>?,
private val _context: CoroutineContext?
) : BaseContinuationImpl(completion) {
constructor(completion: Continuation<Any?>?) : this(completion, completion?.context)

public override val context: CoroutineContext
get() = _context!!

@Transient
private var intercepted: Continuation<Any?>? = null

public fun intercepted(): Continuation<Any?> =
intercepted
?: (context[ContinuationInterceptor]?.interceptContinuation(this) ?: this)
.also { intercepted = it }
}

第一次走到intercepted()函数时,intercepted肯定是为null的,还没初始化。此时会通过context[ContinuationInterceptor]取出Dispatcher对象,然后调用该Dispatcher对象的interceptContinuation()函数。这个Dispatcher对象在demo这里其实就是Dispatchers.Default。


public actual object Dispatchers {
@JvmStatic
public actual val Default: CoroutineDispatcher = DefaultScheduler
}

可以看到,Dispatchers.Default是一个CoroutineDispatcher对象,interceptContinuation()函数就在CoroutineDispatcher中。


public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
public final override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> =
DispatchedContinuation(this, continuation)
}

public fun <T> (suspend () -> T).startCoroutineCancellable(completion: Continuation<T>): Unit = runSafely(completion) {
createCoroutineUnintercepted(completion).intercepted().resumeCancellableWith(Result.success(Unit))
}

这个方法非常简单,就是新建并且返回了一个DispatchedContinuation对象,将this和continuation给传入进去。这里的this是Dispatchers.Default。


所以,最终我们发现走完startCoroutineCancellable的前2步之后,也就是走完intercepted()之后,创建的是DispatchedContinuation对象,最后是调用的DispatchedContinuation的resumeCancellableWith函数。最后这步比较关键,这是真正将协程的具体执行逻辑放到线程上执行的部分。


internal class DispatchedContinuation<in T>(
//这里传入的dispatcher在demo中是Dispatchers.Default
@JvmField val dispatcher: CoroutineDispatcher,
@JvmField val continuation: Continuation<T>
) : DispatchedTask<T>(MODE_UNINITIALIZED), CoroutineStackFrame, Continuation<T> by continuation {

inline fun resumeCancellableWith(
result: Result<T>,
noinline onCancellation: ((cause: Throwable) -> Unit)?
) {
val state = result.toState(onCancellation)
//代码1
if (dispatcher.isDispatchNeeded(context)) {
_state = state
resumeMode = MODE_CANCELLABLE
//代码2
dispatcher.dispatch(context, this)
} else {
//代码3
executeUnconfined(state, MODE_CANCELLABLE) {
if (!resumeCancelled(state)) {
resumeUndispatchedWith(result)
}
}
}
}
}

internal abstract class DispatchedTask<in T>(
@JvmField public var resumeMode: Int
) : SchedulerTask() {
......
}

internal actual typealias SchedulerTask = Task

internal abstract class Task(
@JvmField var submissionTime: Long,
@JvmField var taskContext: TaskContext
) : Runnable {
......
}

public abstract class CoroutineDispatcher :
AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {

public abstract fun dispatch(context: CoroutineContext, block: Runnable)

public open fun isDispatchNeeded(context: CoroutineContext): Boolean = true

}

从DispatchedContinuation的继承结构来看,它既是一个Continuation(通过委托给传入的continuation参数),也是一个Runnable。



  • 首先看代码1:这个dispatcher在demo中其实是Dispatchers.Default ,然后调用它的isDispatchNeeded(),这个函数定义在CoroutineDispatcher中,默认就是返回true,只有Dispatchers.Unconfined返回false

  • 代码2:调用Dispatchers.Default的dispatch函数,将context和自己(DispatchedContinuation,也就是Runnable)传过去了

  • 代码3:对应Dispatchers.Unconfined的情况,它的isDispatchNeeded()返回false


现在我们要分析代码2之后的执行逻辑,也就是将context和Runnable传入到dispatch函数之后是怎么执行的。按道理,看到Runnable,那可能这个与线程执行相关,应该离我们想要的答案不远了。回到Dispatchers,我们发现Dispatchers.Default是DefaultScheduler类型的,那我们就去DefaultScheduler中或者其父类中去找dispatch函数。


public actual object Dispatchers {
@JvmStatic
public actual val Default: CoroutineDispatcher = DefaultScheduler
}

internal object DefaultScheduler : SchedulerCoroutineDispatcher(
CORE_POOL_SIZE, MAX_POOL_SIZE,
IDLE_WORKER_KEEP_ALIVE_NS, DEFAULT_SCHEDULER_NAME
) {
......
}

internal open class SchedulerCoroutineDispatcher(
private val corePoolSize: Int = CORE_POOL_SIZE,
private val maxPoolSize: Int = MAX_POOL_SIZE,
private val idleWorkerKeepAliveNs: Long = IDLE_WORKER_KEEP_ALIVE_NS,
private val schedulerName: String = "CoroutineScheduler",
) : ExecutorCoroutineDispatcher() {

private var coroutineScheduler = createScheduler()

private fun createScheduler() =
CoroutineScheduler(corePoolSize, maxPoolSize, idleWorkerKeepAliveNs, schedulerName)

override fun dispatch(context: CoroutineContext, block: Runnable): Unit = coroutineScheduler.dispatch(block)
}

最后发现dispatch函数在其父类SchedulerCoroutineDispatcher中,在这里构建了一个CoroutineScheduler,直接调用了CoroutineScheduler对象的dispatch,然后将Runnable(也就是上面的DispatchedContinuation对象)传入。


internal class CoroutineScheduler(
@JvmField val corePoolSize: Int,
@JvmField val maxPoolSize: Int,
@JvmField val idleWorkerKeepAliveNs: Long = IDLE_WORKER_KEEP_ALIVE_NS,
@JvmField val schedulerName: String = DEFAULT_SCHEDULER_NAME
) : Executor, Closeable {
override fun execute(command: Runnable) = dispatch(command)

fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
trackTask() // this is needed for virtual time support
//代码1:构建Task,Task实现了Runnable接口
val task = createTask(block, taskContext)
//代码2:取当前线程转为Worker对象,Worker是一个继承自Thread的类
val currentWorker = currentWorker()
//代码3:尝试将Task提交到本地队列并根据结果执行相应的操作
val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)
if (notAdded != null) {
//代码4:notAdded不为null,则再将notAdded(Task)添加到全局队列中
if (!addToGlobalQueue(notAdded)) {
throw RejectedExecutionException("$schedulerName was terminated")
}
}
val skipUnpark = tailDispatch && currentWorker != null
// Checking 'task' instead of 'notAdded' is completely okay
if (task.mode == TASK_NON_BLOCKING) {
if (skipUnpark) return
//代码5: 创建Worker并开始执行该线程
signalCpuWork()
} else {
// Increment blocking tasks anyway
signalBlockingWork(skipUnpark = skipUnpark)
}
}

private fun currentWorker(): Worker? = (Thread.currentThread() as? Worker)?.takeIf { it.scheduler == this }

internal inner class Worker private constructor() : Thread() {
.....
}
}

观察发现,原来CoroutineScheduler类实现了java.util.concurrent.Executor接口,同时实现了它的execute方法,这个方法也会调用dispatch()。



  • 代码1:首先是通过Runnable构建了一个Task,这个Task其实也是实现了Runnable接口,只是把传入的Runnable包装了一下

  • 代码2:将当前线程取出来转换成Worker,当然第一次时,这个转换不会成功,这个Worker是继承自Thread的一个类

  • 代码3:将task提交到本地队列中,这个本地队列待会儿会在Worker这个线程执行时取出Task,并执行Task

  • 代码4:如果task提交到本地队列的过程中没有成功,那么会添加到全局队列中,待会儿也会被Worker取出来Task并执行

  • 代码5:创建Worker线程,并开始执行


开始执行Worker线程之后,我们需要看一下这个线程的run方法执行的是啥,也就是它的具体执行逻辑。


internal inner class Worker private constructor() : Thread() {
override fun run() = runWorker()
private fun runWorker() {
var rescanned = false
while (!isTerminated && state != WorkerState.TERMINATED) {
//代码1
val task = findTask(mayHaveLocalTasks)
if (task != null) {
rescanned = false
minDelayUntilStealableTaskNs = 0L
//代码2
executeTask(task)
continue
} else {
mayHaveLocalTasks = false
}
if (minDelayUntilStealableTaskNs != 0L) {
if (!rescanned) {
rescanned = true
} else {
rescanned = false
tryReleaseCpu(WorkerState.PARKING)
interrupted()
LockSupport.parkNanos(minDelayUntilStealableTaskNs)
minDelayUntilStealableTaskNs = 0L
}
continue
}
tryPark()
}
tryReleaseCpu(WorkerState.TERMINATED)
}

fun findTask(scanLocalQueue: Boolean): Task? {
if (tryAcquireCpuPermit()) return findAnyTask(scanLocalQueue)
// If we can't acquire a CPU permit -- attempt to find blocking task
val task = if (scanLocalQueue) {
localQueue.poll() ?: globalBlockingQueue.removeFirstOrNull()
} else {
globalBlockingQueue.removeFirstOrNull()
}
return task ?: trySteal(blockingOnly = true)
}

private fun executeTask(task: Task) {
val taskMode = task.mode
idleReset(taskMode)
beforeTask(taskMode)
runSafely(task)
afterTask(taskMode)
}

fun runSafely(task: Task) {
try {
task.run()
} catch (e: Throwable) {
val thread = Thread.currentThread()
thread.uncaughtExceptionHandler.uncaughtException(thread, e)
} finally {
unTrackTask()
}
}

}

run方法直接调用的runWorker(),在里面是一个while循环,不断从队列中取Task来执行。



  • 代码1:从本地队列或者全局队列中取出Task

  • 代码2:执行这个task,最终其实就是调用这个Runnable的run方法。


也就是说,在Worker这个线程中,执行了这个Runnable的run方法。还记得这个Runnable是谁么?它就是上面我们看过的DispatchedContinuation,这里的run方法执行的就是协程任务,那这块具体的run方法的实现逻辑,我们应该到DispatchedContinuation中去找。



internal class DispatchedContinuation<in T>(
@JvmField val dispatcher: CoroutineDispatcher,
@JvmField val continuation: Continuation<T>
) : DispatchedTask<T>(MODE_UNINITIALIZED), CoroutineStackFrame, Continuation<T> by continuation {
......
}

internal abstract class DispatchedTask<in T>(
@JvmField public var resumeMode: Int
) : SchedulerTask() {
public final override fun run() {
assert { resumeMode != MODE_UNINITIALIZED } // should have been set before dispatching
val taskContext = this.taskContext
var fatalException: Throwable? = null
try {
val delegate = delegate as DispatchedContinuation<T>
val continuation = delegate.continuation
withContinuationContext(continuation, delegate.countOrElement) {
val context = continuation.context
val state = takeState() // NOTE: Must take state in any case, even if cancelled
val exception = getExceptionalResult(state)
/*
* Check whether continuation was originally resumed with an exception.
* If so, it dominates cancellation, otherwise the original exception
* will be silently lost.
*/
val job = if (exception == null && resumeMode.isCancellableMode) context[Job] else null

//非空,且未处于active状态
if (job != null && !job.isActive) {
//开始之前,协程已经被取消,将具体的Exception传出去
val cause = job.getCancellationException()
cancelCompletedResult(state, cause)
continuation.resumeWithStackTrace(cause)
} else {
//有异常,传递异常
if (exception != null) {
continuation.resumeWithException(exception)
} else {
//代码1
continuation.resume(getSuccessfulResult(state))
}
}
}
} catch (e: Throwable) {
// This instead of runCatching to have nicer stacktrace and debug experience
fatalException = e
} finally {
val result = runCatching { taskContext.afterTask() }
handleFatalException(fatalException, result.exceptionOrNull())
}
}
}

我们主要看一下代码1处,调用了resume开启协程。前面没有异常,才开始启动协程,这里才是真正的开始启动协程,开始执行launch传入的Lambda表达式。这个时候,协程的逻辑是在Worker这个线程上执行的了,切到某个线程上执行的逻辑已经完成了。



ps: rusume会走到BaseContinuationImpl的rusumeWith,然后走到launch传入的Lambda匿名内部类的invokeSuspend方法,开始执行状态机逻辑。前面的文章 Kotlin协程createCoroutine和startCoroutine原理 我们分析过这里,这里就只是简单提一下。



到这里,Dispatchers的执行流程就算完了,前后都串起来了。


小结


Dispatchers是协程框架中与线程交互的关键。底层会有不同的线程池,Dispatchers.Default、IO,协程任务来了的时候会封装成一个个的Runnable,丢到线程中执行,这些Runnable的run方法中执行的其实就是continuation.resume,也就是launch的Lambda生成的SuspendLambda匿名内部类,也就是开启协程状态机,开始协程的真正执行。


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

Flutter 使用 json_serializable 解析 JSON 支持泛型

一般情况下,服务端接口都会有一套数据结构规范,比如 { "items": [], "success": true, "msg": "" } 不同的接口,items 中返回的数据结构一般都是不一样的,这时使用泛型,可以简化代码 本文将以 ...
继续阅读 »

一般情况下,服务端接口都会有一套数据结构规范,比如


{
"items": [],
"success": true,
"msg": ""
}

不同的接口,items 中返回的数据结构一般都是不一样的,这时使用泛型,可以简化代码


本文将以 wanAndroid 提供的开放 API 为例,介绍如何通过泛型类接解析 JSON 数据,简化代码。另外,对 wanAndroid 提供开放 API 的行为表示感谢。


本文解析 JSON 使用的方案,是官方推荐的 json_serializable,至于为什么选择 json_serializable,可以参考我之前写的一篇文章:Flutter 使用 json_serializable 解析 JSON 最佳方案


下面开始进入正文


使用 json_serializable 支持泛型


json_serializable 在大概两年前发布的 v3.5.0 版本开始支持泛型,只需要在 @JsonSerializable() 注解中设置 genericArgumentFactories 为 true,同时需要对 fromJson 和 toJson 方法进行调整,即可支持泛型解析,如下所示:


@JsonSerializable(genericArgumentFactories: true)
class Response<T> {
int status;
T value;

factory Response.fromJson(
Map<String, dynamic> json,
T Function(dynamic json) fromJsonT,
) =>
_$ResponseFromJson<T>(json, fromJsonT);

Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$ResponseToJson<T>(this, toJsonT);
}

和正常实体类相比,fromJson 方法多了一个函数参数 T Function(dynamic json) fromJsonT;toJson 方法也多了一个函数参数:Object? Function(T value) toJsonT


分析数据结构


下面使用 wanAndroid 开放 API 接口数据,进行代码实践,我们先看一下服务端接口返回的数据结构


一般接口返回数据结构如下:


{
"data": [
{
"desc": "一起来做个App吧",
"id": 10,
"imagePath": "https://www.wanandroid.com/blogimgs/50c115c2-cf6c-4802-aa7b-a4334de444cd.png",
"isVisible": 1,
"order": 1,
"title": "一起来做个App吧",
"type": 1,
"url": "https://www.wanandroid.com/blog/show/2"
}
],
"errorCode": 0,
"errorMsg": ""
}

带分页信息的列表接口,返回数据结构如下:


{
"data": {
"curPage": 1,
"datas": [
{
"id": 23300,
"link": "https://juejin.cn/post/7114142706557075487",
"niceDate": "2022-06-28 15:30",
"niceShareDate": "2022-06-28 15:30",
"publishTime": 1656401449000,
"realSuperChapterId": 493,
"shareDate": 1656401449000,
"shareUser": "灰尘",
"superChapterId": 494,
"superChapterName": "广场Tab",
"title": "Flutter 使用 json_serializable 解析 JSON 最佳方案"
}
],
"offset": 0,
"over": false,
"pageCount": 3,
"size": 20,
"total": 46
},
"errorCode": 0,
"errorMsg": ""
}

通过上面的接口示例,我们可以发现,返回的数据结构有以下两种情况:


在一般情况下 data 是一个数组


{
"data": [],
"errorCode": 0,
"errorMsg": ""
}

在分页相关接口,data 是一个对象


{
"data": {},
"errorCode": 0,
"errorMsg": ""
}

复杂方案


如果想定义一个模型类,同时处理上述两种情况,可以把整个 data 都定义为泛型,代码如下:


import 'package:json_annotation/json_annotation.dart';

part 'base_response.g.dart';

@JsonSerializable(genericArgumentFactories: true)
class BaseResponse<T> {
T data;
int errorCode;
String errorMsg;

BaseResponse({
required this.data,
required this.errorCode,
required this.errorMsg,
});

factory BaseResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic json) fromJsonT,
) =>
_$BaseResponseFromJson<T>(json, fromJsonT);

Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$BaseResponseToJson<T>(this, toJsonT);
}



@JsonSerializable(genericArgumentFactories: true)
class ListData<T> {
int? curPage;
List<T> datas;
int? offset;
bool? over;
int? pageCount;
int? size;
int? total;

ListData({
this.curPage,
required this.datas,
this.offset,
this.over,
this.pageCount,
this.size,
this.total,
});

factory ListData.fromJson(
Map<String, dynamic> json,
T Function(dynamic json) fromJsonT,
) =>
_$ListDataFromJson<T>(json, fromJsonT);

Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$ListDataToJson<T>(this, toJsonT);
}

测试代码如下:


void main() {
test("json", () {
String str =
'{"data": [{"category": "设计","icon": "","id": 31,"link": "https://tool.gifhome.com/compress/","name": "gif压缩","order": 4444,"visible": 1}],"errorCode": 0,"errorMsg": ""}';

Map<String, dynamic> json = jsonDecode(str);

BaseResponse<List<CategoryModel>> result =
BaseResponse.fromJson(json, (json) {
return (json as List<dynamic>)
.map((e) => CategoryModel.fromJson(e as Map<String, dynamic>))
.toList();
});

List<CategoryModel> list = result.data;

CategoryModel model = list[0];

print(model.toJson());

expect("category:设计", "category:${model.category}");
});

test("json list", () {
String str =
'{"data": {"curPage": 1,"datas": [{"id": 23300,"link": "https://juejin.cn/post/7114142706557075487","niceDate": "2022-06-28 15:30","niceShareDate": "2022-06-28 15:30","publishTime": 1656401449000,"realSuperChapterId": 493,"shareDate": 1656401449000,"shareUser": "灰尘","superChapterId": 494,"superChapterName": "广场Tab","title": "Flutter 使用 json_serializable 解析 JSON 最佳方案"}],"offset": 0,"over": false,"pageCount": 3,"size": 20,"total": 46},"errorCode": 0,"errorMsg": ""}';

Map<String, dynamic> json = jsonDecode(str);

BaseResponse<ListData<ArticleModel>> result =
BaseResponse.fromJson(json, (json) {
return ListData.fromJson(json, (json) => ArticleModel.fromJson(json));
});

ListData<ArticleModel> listData = result.data;
List<ArticleModel> datas = listData.datas;
ArticleModel model = datas[0];

print(model.toJson());

expect("id:23300", "id:${model.id}");
});
}

虽然一个 BaseResponse 解决了两种数据结构,但使用时的代码会有些复杂,很容易出错。


一般接口:


    BaseResponse<List<CategoryModel>> result =
BaseResponse.fromJson(json, (json) {
return (json as List<dynamic>)
.map((e) => CategoryModel.fromJson(e as Map<String, dynamic>))
.toList();
});

分页接口:


    BaseResponse<ListData<ArticleModel>> result =
BaseResponse.fromJson(json, (json) {
return ListData.fromJson(json, (json) => ArticleModel.fromJson(json));
});

简化方案


可以对一般接口和列表分页接口进行单独处理,


处理一般接口的泛型类,命名为 BaseCommonResponse,代码如下:


import 'package:json_annotation/json_annotation.dart';

part 'base_common_response.g.dart';

@JsonSerializable(genericArgumentFactories: true)
class BaseCommonResponse<T> {
List<T> data;
int errorCode;
String errorMsg;

BaseCommonResponse({
required this.data,
required this.errorCode,
required this.errorMsg,
});

factory BaseCommonResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic json) fromJsonT,
) =>
_$BaseCommonResponseFromJson<T>(json, fromJsonT);

Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$BaseCommonResponseToJson<T>(this, toJsonT);
}

处理分页列表接口的泛型类,命令为 BaseListResponse


import 'package:json_annotation/json_annotation.dart';

part 'base_list_response.g.dart';

@JsonSerializable(genericArgumentFactories: true)
class BaseListResponse<T> {
ListData<T> data;
int errorCode;
String errorMsg;

BaseListResponse({
required this.data,
required this.errorCode,
required this.errorMsg,
});

factory BaseListResponse.fromJson(
Map<String, dynamic> json,
T Function(dynamic json) fromJsonT,
) =>
_$BaseListResponseFromJson<T>(json, fromJsonT);

Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$BaseListResponseToJson<T>(this, toJsonT);
}



@JsonSerializable(genericArgumentFactories: true)
class ListData<T> {
int? curPage;
List<T> datas;
int? offset;
bool? over;
int? pageCount;
int? size;
int? total;

ListData({
this.curPage,
required this.datas,
this.offset,
this.over,
this.pageCount,
this.size,
this.total,
});

factory ListData.fromJson(
Map<String, dynamic> json,
T Function(dynamic json) fromJsonT,
) =>
_$ListDataFromJson<T>(json, fromJsonT);

Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$ListDataToJson<T>(this, toJsonT);
}

测试代码如下:


void main() {
test("json", () {
String str =
'{"data": [{"category": "设计","icon": "","id": 31,"link": "https://tool.gifhome.com/compress/","name": "gif压缩","order": 4444,"visible": 1}],"errorCode": 0,"errorMsg": ""}';

Map<String, dynamic> json = jsonDecode(str);

BaseCommonResponse<CategoryModel> result = BaseCommonResponse.fromJson(
json, (json) => CategoryModel.fromJson(json));

List<CategoryModel> list = result.data;

CategoryModel model = list[0];

print(model.toJson());

expect("category:设计", "category:${model.category}");
});

test("json list", () {
String str =
'{"data": {"curPage": 1,"datas": [{"id": 23300,"link": "https://juejin.cn/post/7114142706557075487","niceDate": "2022-06-28 15:30","niceShareDate": "2022-06-28 15:30","publishTime": 1656401449000,"realSuperChapterId": 493,"shareDate": 1656401449000,"shareUser": "灰尘","superChapterId": 494,"superChapterName": "广场Tab","title": "Flutter 使用 json_serializable 解析 JSON 最佳方案"}],"offset": 0,"over": false,"pageCount": 3,"size": 20,"total": 46},"errorCode": 0,"errorMsg": ""}';

Map<String, dynamic> json = jsonDecode(str);

BaseListResponse<ArticleModel> result =
BaseListResponse.fromJson(json, (json) => ArticleModel.fromJson(json));

ListData<ArticleModel> listData = result.data;
List<ArticleModel> datas = listData.datas;
ArticleModel model = datas[0];

print(model.toJson());

expect("id:23300", "id:${model.id}");
});
}

这时使用时的代码,就比较简单了,代码如下:


一般接口,使用 BaseCommonResponse


    BaseCommonResponse<CategoryModel> result = BaseCommonResponse.fromJson(
json, (json) => CategoryModel.fromJson(json));

列表分页接口,使用 BaseListResponse


    BaseListResponse<ArticleModel> result = BaseListResponse.fromJson(
json, (json) => ArticleModel.fromJson(json));

以上就是我在 Flutter 中解析 JSON 数据时处理泛型的实践经验,如果对你有所帮助,欢迎一键三连,👍👍👍


如果大家有相关问题,欢迎评论留言。


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

倍投模型模拟:1w块搏10w,靠谱吗?

前两天刷影视解说看到一个短片,甲乙两个人打赌,甲每次输了后,都会加倍。一群人围观,甲连输8次,在最后一次赌上全部身家后,一把梭哈赢了,直接走上人生巅峰。 当然,这只是爽剧,让我们用代码模拟下真实情况是怎么的,超刺激哦! 让我们先看下代码(用JS简单写的): /...
继续阅读 »

前两天刷影视解说看到一个短片,甲乙两个人打赌,甲每次输了后,都会加倍。一群人围观,甲连输8次,在最后一次赌上全部身家后,一把梭哈赢了,直接走上人生巅峰。


当然,这只是爽剧,让我们用代码模拟下真实情况是怎么的,超刺激哦!


让我们先看下代码(用JS简单写的):


// 家底
var all = 10000;
// 第一次投注
var first = 1000;
// 假设玩1000次
for(var i=0;i<1000;i++){
// 输赢概率都是50%
var check = Math.random() >= 0.5;
console.log("第"+(i+1)+"次");
if(check){
// 赢了初始化投注
console.log("赚了"+first);
all+= first;
first=1000;
}else{
all -= first;
if(first*2>all){
// 输光了,梭哈
first=all
}else{
// 还有家底,加倍投注。
first=first*2;
}
console.log("输了"+first);
}
if(all<=0){
console.log("输光了,拜拜");
break;
}
console.log("现在有:"+all);
}

第一次模拟:


家底:10000,初始投注:1000。拿出十分之一去搏一搏,合理。


image.png


image.png


顶峰时第46轮:36000,在59轮时被一波带走。


再来一次:


image.png


image.png


这次就比较惨了,第27次顶峰:21000,在32轮时被一波带走。


上面这个有点离谱,我们在保守点,拿出家底的1%去搏一搏,把初始值设为100。


第二次模拟


家底:10000,初始投注:100,稳重求胜。


果然稳健才是硬道理,这一把运气绝对爆棚。


image.png
在第843轮,家底来到了45800,这收益率逆天啊,然而天道有轮回,仅仅到第852轮,我就输光了全部家底。果然人生得意莫嘚瑟。
image.png


再来一次:


image.png
image.png
这次运气一般,在第276轮,才14800。在340轮时被一波带走。


总结


投资有风险,入市需谨慎啊。


在资金有限的情况的下,倍投绝对不是一个好的选项,你们可以试下在当家底是100000时,初始值设为1,有意想不到的惊喜,虽然赚的少,但家底只要够厚,就不会赔。


如果你的资产是有限的,玩下去一定会输,毕竟运气总会用尽,50%概率赢在现实中也几乎不存在。


挺有意思的,欢迎大家试试,调整家底和初始值即可。



逆天了逆天了,我必须分享给大家。


初始我设定的家底1000,初始投注200,在1000轮后,家底来到了惊人的100200,简直运气爆棚,要上天。


image.png


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

Kotlin函数声明与闭包【Kotlin从拒绝到真香】

前言本文介绍闭包。闭包其实不算是新东西了。 其实 Kotlin 就基本没有多少新东西,甚至可以说新型编程语言基本都没有新东西。是把先前编程语言好用的特性组装起来,再加一部分拓展。本文大纲1. 闭包介绍首次接触 闭包 应该...
继续阅读 »

前言

本文介绍闭包。闭包其实不算是新东西了。 其实 Kotlin 就基本没有多少新东西,甚至可以说新型编程语言基本都没有新东西。是把先前编程语言好用的特性组装起来,再加一部分拓展。

本文大纲

Kotlin 函数声明与闭包.png

1. 闭包介绍

首次接触 闭包 应该是在 JavaScript 上,有函数为“一等公民”特性的编程语言都有这个概念。 函数是“一等公民”的意思是,函数跟变量一样,是某种类型的实例,可以被赋值,可以被引用。函数还可以被调用。变量类型是某个声明的类,函数类型就是规定了入参个数,类型和返回值类型(不规定名字。函数名就和变量名一样,任意定义但要符合规则)。

如要声明 Kotlin 一个函数类型,入参是两个整数,出参是一个整数,那应该这样写: val add: (Int, Int) -> Int。箭头左边括号内表示入参,括号不可省略。箭头右边表示返回值。

wiki上闭包的概念是:引用了自由变量的函数,这个被引用的自由变量将和这个函数一同存在。从定义来说,对闭包的理解,是基于普通函数之上的。一般的函数,能处理的只有入参和全局变量,然后返回一个结果。闭包比普通函数功能更强,可以获取当前上下文的局部变量。当然了,捕获局部变量的前提是可以在局部环境里声明一个函数,这只有把函数当作“一等公民”才可以做到。

2. 闭包与匿名类比较

在 Java 中,匿名类其实就是代替闭包而存在的。不过 Java 严格要求所有函数都需要在类里面,所以巧妙的把“声明一个函数”这样的行为变成了“声明一个接口”或“重写一个方法”。匿名类也可以获取当前上下文的 final 局部变量。和闭包不一样的是,匿名类无法修改获取的局部变量final 不可修改

匿名类能引用 final 局部变量,是因为在编译阶段,会把该局部变量作为匿名类的构造参数传入。

Java8 lambda 是进一步接近闭包的特性,lambda 的 JVM 实现是类似函数指针的东西。 但 Java7 中的 lambda 语法糖兼容不算是真正的 lambda,只是简化了匿名类的书写。

3. 闭包使用

来看一个闭包的例子:

fun returnFun(): () -> Int {
var count = 0
return { count++ }
}

fun main() {
val function = returnFun()
val function2 = returnFun()
println(function()) // 0
println(function()) // 1
println(function()) // 2

println(function2()) // 0
println(function2()) // 1
println(function2()) // 2
}

分析上面的代码,returnFun返回了一个函数,这个函数没有入参,返回值是Int。可以用变量接收它,还可以调用它。functionfunction2分别是创建的两个函数实例。

可以看到,每调用一次function()count都会加一,说明count 被function持有了而且可以被修改。而function2functioncount是独立的,不是共享的。

而通过 jadx 反编译可以看到:

public final class ClosureKt {
@NotNull
public static final Function0<Integer> returnFun() {
IntRef intRef = new IntRef();
intRef.element = 0;
return (Function0) new 1<>(intRef);
}

public static final void main() {
Function0 function = returnFun();
Function0 function2 = returnFun();
System.out.println(((Number) function.invoke()).intValue());
System.out.println(((Number) function.invoke()).intValue());
System.out.println(((Number) function2.invoke()).intValue());
System.out.println(((Number) function2.invoke()).intValue());
}
}

被闭包引用的 int 局部变量,会被封装成 IntRef 这个类。 IntRef 里面保存着 int 变量,原函数和闭包都可以通过 intRef 来读写 int 变量。Kotlin 正是通过这种办法使得局部变量可修改。除了 IntRef,还有 LongRefFloatRef 等,如果是非基础类型,就统一用 ObjectRef 即可。Ref 家族源码:github.com/JetBrains/k…

在 Java 中,如果想要匿名类来操作外部变量,一般做法是把这个变量放入一个 final 数组中。这和 Kotlin 的做法本质上是一样的,即通过持有该变量的引用来使得两个类可以修改同一个变量。

4. 总结

根据示例上面分析,可以总结出:

  • 闭包不算是新东西,是把函数作为“一等公民”的编程语言的特性;
  • 匿名类是 Java 世界里的闭包,但有局限性,即只能读 final 变量,不能写任何变量;
  • Kotlin 的闭包可以获取上下文的局部变量,并可以修改它。实现办法是 Kotlin 编译器给引用的局部变量封装了一层引用。


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

收起阅读 »

Kotlin-Flow常用封装类StateFlow的使用

Kotlin中StateFlow的使用 StateFlow 是 Flow 的实现,是一个特殊的流,默认的 Flow 是冷流,而StateFlow 是热流,和 LiveData 比较类似。关于冷热流后面一期 SharedFlow 会详细说明。 使用 StateF...
继续阅读 »

Kotlin中StateFlow的使用


StateFlow 是 Flow 的实现,是一个特殊的流,默认的 Flow 是冷流,而StateFlow 是热流,和 LiveData 比较类似。关于冷热流后面一期 SharedFlow 会详细说明。


使用 StateFlow 替代 LiveData 应该是目前很多开发者的呼吁了,确实 LiveData 的功能 StateFlow 都能实现,可以说是 LiveData 的升级版。


StateFlow的特点



  • 它始终是有值的。

  • 它的值是唯一的。

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

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


官方推荐当暴露 UI 的状态给视图时,应该使用 StateFlow。这是一种安全和高效的观察者,专门用于容纳 UI 状态。


一、StateFlow的使用


方式一,我们自己 new 出来


一般我们再ViewModel中定义读写分类的StateFlow


@HiltViewModel
class Demo4ViewModel @Inject constructor(
val savedState: SavedStateHandle
) : BaseViewModel() {

private val _searchFlow = MutableStateFlow("")
val searchFlow: StateFlow<String> = _searchFlow

fun changeSearch(keyword: String) {
_searchFlow.value = keyword
}
}

在Activity中我们就可以像类似 LiveData 一样的使用 StateFlow



private fun testflow() {
mViewModel.changeSearch("key")
}

override fun startObserve() {
lifecycleScope.launchWhenCreated {
mViewModel.searchFlow.collect {
YYLogUtils.w("value $it")
}
}
}

方式二,通过一个 冷流 Flow 转换为 StateFlow


    val stateFlow = flowOf(1, 2, 3).stateIn(
scope = lifecycleScope,
// started = WhileSubscribed(5000, 1000),
// started = Eagerly,
started = Lazily,
initialValue = 1
)

lifecycleScope.launch {
stateFlow.collect {

}
}

几个重要参数的说明如下



  • scope 共享开始时所在的协程作用域范围

  • started 控制共享的开始和结束的策略

  • Lazily: 当首个订阅者出现时开始,在 scope 指定的作用域被结束时终止。

  • Eagerly: 立即开始,而在 scope 指定的作用域被结束时终止。

  • WhileSubscribed能够指定当前不有订阅者后,多少时间取消上游数据和能够指定多少时间后,缓存中的数据被丢失,回复称initialValue的值。

  • initialValue 初始值


二、替代LiveData


不管是普通的 ViewModel 观察订阅模式,在Activity中订阅,还是DataBinding的模式,我们都可以使用StateFlow来代替ViewModel


    val withdrawMethod = MutableStateFlow(0)

<ImageView
android:id="@+id/iv_giro_checked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginRight="@dimen/d_15dp"
android:src="@drawable/pay_method_checked"
android:visibility="gone"
binding:isVisibleGone="@{viewModel.withdrawMethod == 1}" />

为什么我们需要用StateFlow来代替LiveData,或者说LiveData有什么缺点?


LiveData vs Flow


先上代码,看看它们的用法与差异


ViewModel的代码


@HiltViewModel
class Demo4ViewModel @Inject constructor(
val savedState: SavedStateHandle
) : BaseViewModel() {

private val _searchLD = MutableLiveData<String>()
val searchLD: LiveData<String> = _searchLD

private val _searchFlow = MutableStateFlow("")
val searchFlow: StateFlow<String> = _searchFlow

fun changeSearch(keyword: String) {
_searchFlow.value = keyword
_searchLD.value = keyword
}
}

Activity中触发与接收事件



private fun testflow() {
mViewModel.changeSearch("key")
}

override fun startObserve() {
mViewModel.searchLD.observe(this){
YYLogUtils.w("value $it")
}

lifecycleScope.launchWhenCreated {
mViewModel.searchFlow.collect {
YYLogUtils.w("value $it")
}
}
}

可以看到基本的使用几乎是没有差异,在DataBinding中同样的是都能使用。那么它们有哪些差异呢?


它们相同的地方:



  1. 仅持有单个且最新的数据

  2. 自动取消订阅

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

  4. 配合 DataBinding 实现「双向绑定」


相比StateFlow ,LiveData的确定:



  1. LiveData在某些特定的场景下会丢失数据

  2. LiveData 只能在主线程不能方便地支持异步化

  3. LiveData 的数据变换能力远远不如 Flow

  4. LiveData 粘性问题解决需要额外扩展

  5. LiveData 多数据源的合流能力远远不如 Flow

  6. LiveData 默认不支持防抖,值没有变化也会通知


这么惨,那我们开发是不是要放弃LiveData了?



恰恰不是!


如果大家全部是Koltin代码开发,那么是可以用Flow,这是基于Kotlin代码,基于协程实现的,但是现在很多项目还是 Java 语言开发的。那么LiveData还是很香的。


其二是LiveData的学习成本与 协程、Flow 的学习成本不可同日而语,开发项目是整个团队的事情,不能说你一个人会一个人用,目前LiveData的简单学习成本是很有优势的。


只是我们需要在一些特定的场景慎重使用postValue,比如数据比较秘籍的场景,我们尽量使用setValue方法。


总结


如果大家的项目的语言是 Kotlin ,并且小组成员都会 Flow 。那么我推荐你们使用StateFlow 替代LiveData 。如果不是,那么 LiveData 是你最好的选择。


谷歌也只是推荐使用Flow替代LiveData。但是并没有说打算放弃 LiveData 。并且 LiveData 与 StateFlow 都有各自的使用场景,不需要担心 LiveData的 使用。


本文我们只是简单的对比,关于StateFlow 与 SharedFlow 和LiveData 三者的差异与选择,后面等SharedFlow那一期详细的讲解。


为什么很多东西都要等SharedFlow,是因为 SharedFlow 是 StateFlow 的基础,StateFlow 像是 SharedFlow 的‘青春版’。很多东西需要讲完 SharedFlow 才能把知识点串起来,期待一下。


好了,本期内容如讲的不到位或错漏的地方,希望同学们可以指出交流。


如果感觉本文对你有一点点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。


Ok,这一期就此完结。



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

从val跟var了解虚拟机世界

val 跟 var val本意就是一个不可变的变量,即赋初始值后不可改变,想较于val,var其实就简单的多,就是可变变量。为什么说val是不可变的变量呢?这不就是矛盾了嘛,其实不矛盾,我们在字节码的角度出发,比如有 val a = Test() var b...
继续阅读 »

val 跟 var


val本意就是一个不可变的变量,即赋初始值后不可改变,想较于val,var其实就简单的多,就是可变变量。为什么说val是不可变的变量呢?这不就是矛盾了嘛,其实不矛盾,我们在字节码的角度出发,比如有


val a  = Test()
var b = Test()

变成的字节码是


  private final Lcom/example/newtestproject/Test; a

private Lcom/example/newtestproject/Test; b

其实val 本质就是用final修饰的变量罢了,而var,就是一个很普通的变量。两者默认都赋予private作用域,这个其实是kotlin世界赋予的额外操作,并不影响我们的理解。从这里出发,我们再继续深入进去!


一个有趣的实验


companion object{
val c = Test()
const val d = "1"
const val e = "1"
val r = "1"
val v = d
}

如果我们把val变量放在companion object里面,这个时候就会被赋予静态的特性,我们看下上面这段代码生成后的字节码



private final static Lcom/example/newtestproject/Test; c


public final static Ljava/lang/String; d = "1"


public final static Ljava/lang/String; e = "1"


private final static Ljava/lang/String; r


private final static Ljava/lang/String; v

我们可以看到,无论是普通对象还是基本数据类型,都被赋予了static的前缀,但是又有稍微不同??我们再来仔细观察一下。


对于String类型,可以用const关键字进行修饰,表示当前的String可用于字符串常量进行替换,这个就是完全的替换,直接进行了初始化!而没有const修饰的字符串r,可以看到,只是生成了一个r变量,并没有直接初始化。而r被初始化的阶段,是在clinit阶段


static void <clinit>() {
ldc "1"
putstatic 'com/example/newtestproject/ValClass.r','Ljava/lang/String;'
...

假如说我们用java代码去写的话,比如


public class JavaStaticClass {
static final String s = "123";
...
}

所生成的字节码是


  final static Ljava/lang/String; s = "123"

跟我们kotlin用const修饰的string变量一致,都是直接初始化的!(留到后面解释)我们继续深入一点,为什么有的变量直接就初始化了,有的却在clinit阶段被初始化?那就要从我们的类加载过程说起了!


类加载过程


虽然类加载有很多细分版本,但是这里笔者引用以下细分版本


image.png
由于类加载过程不是本篇的重点,这里我们稍微解释一下各阶段的主要任务即可



  1. 加载:载入类的过程 :主要是把类的二进制文件,转化为运行时内存的数据,包括静态的存储结构转为方法区等操作,在内存中生成一个代表这个类的java.lang.Class对象

  2. 验证:验证class文件等是否合法:确保Class文件的字节流中包含的信息符合《Java虚 拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

  3. 准备:准备初始数据 :准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段

  4. 解析:解析常量池,函数符号等 :解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,这个阶段就把我们普通的符号转化为对内存运行数据地址。

  5. 初始化:真正的初始化,调用clinit:在初始化阶段,则会根据代码去初始化类变量和其他资源,这个时候,就走到了我们clinit阶段了,上面的阶段都是由虚拟机操控,这个阶段过去后就正在把控制权给我们程序了


准备阶段对static数据的影响


我们主要看到准备阶段:准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,即在这个阶段过后,所有的static数据被赋予“零值”,以下是零值表


image.png
但是也有例外,就是如果类的属性表中存在ConstantValue这个特殊的属性值时,就会在准备阶段把真正的常量直接替换给当前的static变量,比如上述代码中的


省略companion object
const val d = "1"
public final static Ljava/lang/String; d = "1"

此时,只要对d的操作,就会被转化为以下字节码,比如


val v = d

字节码是
ldc "1"
putstatic 'com/example/newtestproject/ValClass.v','Ljava/lang/String;'

变成了ldc指令,即押入了一个字符串“1”进了操作数栈上,而原本的d变量盒子,已经彻底被虚拟机抛弃了。对于属性表中没有ConstantValue的变量,就会在初始化阶段,即调用clinti时,就会把数值赋给相关的变量,以替换“零值”(ps:这里就是各大字节码精简方案的核心,即删除把零值赋予零值的相关操作,比如static int xx = 0这种,就可以在Clint阶段把相关的赋值字节码删除掉也不影响其原本数值,参考框架bytex)。


当然,我们看到上面的对象c,也是在clinit阶段被赋值的,这其实就是ConstantValue生成机制的限制,ConstantValue只会对String跟基本数据类型进行生成,因为我们要替换的常量在常量池里面!对象肯定是不存在的对不对!


回归主题


看到这里,我们再回来看上面的问题,我们就知道了,kotlin中companion object里面的字符串变量,如果不用const修饰的话,其实对应的字符串String类型是不会以ConstantValue生成的,而是以静态对象相同的方式,在clinit进行!


说了半天!那么这个又有什么用呢!?其实这里主要是为了说明虚拟机背后生成的原理,同时也是为了提醒!如果以后有做指令优化的需求的时候,就要非常小心kotlin companion object里面的非const 修饰的String变量,我们就不能在Clinit的时候把这个赋值指令给清除掉!或者说不能跳过Clinit阶段就去用这个数值,因为它还是处于未初始化的状态!


最后


我们从val跟var的角度出发,分析了其背后隐含的故事,当然,看完之后你肯定就彻底懂得了这部分知识啦!无论是以后字节码插桩还是面试,相信可以很从容面对啦!


笔者说:如果你看过这篇文章 黑科技!让Native Crash 与ANR无处发泄!,就会了解到Signal的今生前世,同时我们也发布了beta版本到maven啦!快来用起来!


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

Kotlin协程:协程上下文与上下文元素

一.EmptyCoroutineContext    EmptyCoroutineContext代表空上下文,由于自身为空,因此get方法的返回值是空的,fold方法直接返回传入的初始值,plus方法也是直接返回传入的c...
继续阅读 »

一.EmptyCoroutineContext

    EmptyCoroutineContext代表空上下文,由于自身为空,因此get方法的返回值是空的,fold方法直接返回传入的初始值,plus方法也是直接返回传入的context,minusKey方法返回自身,代码如下:

public object EmptyCoroutineContext : CoroutineContext, Serializable {
private const val serialVersionUID: Long = 0
private fun readResolve(): Any = EmptyCoroutineContext

public override fun <E : Element> get(key: Key<E>): E? = null
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R = initial
public override fun plus(context: CoroutineContext): CoroutineContext = context
public override fun minusKey(key: Key<*>): CoroutineContext = this
public override fun hashCode(): Int = 0
public override fun toString(): String = "EmptyCoroutineContext"
}

二.CombinedContext

    CombinedContext是组合上下文,是存储Element的重要的数据结构。内部存储的组织结构如下图所示:
image.png

    可以看出CombinedContext是一种左偏(从左向右计算)的列表,这么设计的目的是为了让CoroutineContext中的plus方法工作起来更加自然。

    由于采用这种数据结构,CombinedContext类中的很多方法都是通过循环实现的,代码如下:

internal class CombinedContext(
// 数据结构左边可能为一个Element对象或者还是一个CombinedContext对象
private val left: CoroutineContext,
// 数据结构右边只能为一个Element对象
private val element: Element
) : CoroutineContext, Serializable {

override fun <E : Element> get(key: Key<E>): E? {
var cur = this
while (true) {
// 进行get操作,如果当前CombinedContext对象中存在,则返回
cur.element[key]?.let { return it }
// 获取左边的上下文对象
val next = cur.left
// 如果是CombinedContext对象
if (next is CombinedContext) {
// 赋值,继续循环
cur = next
} else { // 如果不是CombinedContext对象
// 进行get操作,返回
return next[key]
}
}
}
// 数据结构左右分开操作,从左到右进行fold运算
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(left.fold(initial, operation), element)

public override fun minusKey(key: Key<*>): CoroutineContext {
// 如果右边是指定的Element对象,则返回左边
element[key]?.let { return left }
// 调用左边的minusKey方法
val newLeft = left.minusKey(key)
return when {
// 这种情况,说明左边部分已经是去掉指定的Element对象的,右边也是如此,因此返回当前对象,不需要在进行包裹
newLeft === left -> this
// 这种情况,说明左边部分包含指定的Element对象,因此返回只右边
newLeft === EmptyCoroutineContext -> element
// 这种情况,返回的左边部分是新的,因此需要和右边部分一起包裹后,再返回
else -> CombinedContext(newLeft, element)
}
}

private fun size(): Int {
var cur = this
//左右各一个
var size = 2
while (true) {
cur = cur.left as? CombinedContext ?: return size
size++
}
}

// 通过get方法实现
private fun contains(element: Element): Boolean =
get(element.key) == element

private fun containsAll(context: CombinedContext): Boolean {
var cur = context
// 循环展开每一个CombinedContext对象,每个CombinedContext对象中的Element对象都要包含
while (true) {
if (!contains(cur.element)) return false
val next = cur.left
if (next is CombinedContext) {
cur = next
} else {
return contains(next as Element)
}
}
}
...
}

三.Key与Element

    Key接口与Element接口定义在CoroutineContext接口中,代码如下:

public interface Key<E : Element>

public interface Element : CoroutineContext {
// 一个Key对应着一个Element对象
public val key: Key<*>
// 相等则强制转换并返回,否则则返回空
public override operator fun <E : Element> get(key: Key<E>): E? =
@Suppress("UNCHECKED_CAST")
if (this.key == key) this as E else null
// 自身与初始值进行fold操作
public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
operation(initial, this)
// 如果要去除的是当前的Element对象,则返回空的上下文,否则返回自身
public override fun minusKey(key: Key<*>): CoroutineContext =
if (this.key == key) EmptyCoroutineContext else this
}

四.CoroutineContext

    CoroutineContext接口定义了协程上下文的基本行为以及Key和Element接口。同时,重载了"+"操作,相关代码如下:

public interface CoroutineContext {

public operator fun <E : Element> get(key: Key<E>): E?

public fun <R> fold(initial: R, operation: (R, Element) -> R): R

public operator fun plus(context: CoroutineContext): CoroutineContext =
// 如果要与空上下文相加,则直接但会当前对象,
if (context === EmptyCoroutineContext) this else
// 当前Element作为初始值
context.fold(this) { acc, element ->
// acc:已经加完的CoroutineContext对象
// element:当前要加的CoroutineContext对象

// 获取从acc中去掉element后的上下文removed,这步是为了确保添加重复的Element时,移动到最右侧
val removed = acc.minusKey(element.key)
// 去除掉element后为空上下文(说明acc中只有一个Element对象),则返回element
if (removed === EmptyCoroutineContext) element else {
// ContinuationInterceptor代表拦截器,也是一个Element对象
// 下面的操作是为了把拦截器移动到上下文的最右端,为了方便快速获取
// 从removed中获取拦截器
val interceptor = removed[ContinuationInterceptor]
// 若上下文中没有拦截器,则进行累加(包裹成CombinedContext对象),返回
if (interceptor == null) CombinedContext(removed, element) else {
// 若上下文中有拦截器
// 获取上下文中移除到掉拦截器后的上下文left
val left = removed.minusKey(ContinuationInterceptor)
// 若移除到掉拦截器后的上下文为空上下文,说明上下文left中只有一个拦截器,
// 则进行累加(包裹成CombinedContext对象),返回
if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else
// 否则,现对当前要加的element和left进行累加,然后在和拦截器进行累加
CombinedContext(CombinedContext(left, element), interceptor)
}
}
}

public fun minusKey(key: Key<*>): CoroutineContext

... // (Key和Element接口)
}

1.plus方法图解

    假设我们有一个上下文顺序为A、B、C,现在要按顺序加上D、C、A。

1)初始值A、B、C
27ee3db5-ba83-4f8b-b155-de7974e76e4a.png
2)加上D
335ec6b6-b12f-4367-a274-5f65b4330517.png
3)加上C
6c36e62f-f050-47ca-b769-c29a91ef6f07.png
4)加上A
de380c56-5377-4fcc-a8c3-e6a579bf6609.png

2.为什么要将ContinuationInterceptor放到协程上下文的最右端?

    在协程中有大量的场景需要获取ContinuationInterceptor。根据之前分析的CombinedContext的minusKey方法,ContinuationInterceptor放在上下文的最右端,可以直接获取,不需要经过多次的循环。

五.AbstractCoroutineContextKey与AbstractCoroutineContextElement

    AbstractCoroutineContextElement实现了Element接口,将Key对象作为构造方法必要的参数。

public abstract class AbstractCoroutineContextElement(public override val key: Key<*>) : Element

    AbstractCoroutineContextKey用于实现Element的多态。什么是Element的多态呢?假设类A实现了Element接口,Key为A。类B继承自类A,Key为B。这时将类B的对象添加到上下文中,通过指定不同的Key(A或B),可以得到不同类型对象。具体代码如下:

// baseKey为衍生类的基类的Key
// safeCast用于对基类进行转换
// B为基类,E为衍生类
public abstract class AbstractCoroutineContextKey<B : Element, E : B>(
baseKey: Key<B>,
private val safeCast: (element: Element) -> E?
) : Key<E> {
// 顶置Key,如果baseKey是AbstractCoroutineContextKey,则获取baseKey的顶置Key
private val topmostKey: Key<*> = if (baseKey is AbstractCoroutineContextKey<*, *>) baseKey.topmostKey else baseKey

// 用于类型转换
internal fun tryCast(element: Element): E? = safeCast(element)
// 用于判断当前key是否是指定key的子key
// 逻辑为与当前key相同,或者与当前key的顶置key相同
internal fun isSubKey(key: Key<*>): Boolean = key === this || topmostKey === key
}

1.getPolymorphicElement方法与minusPolymorphicKey方法

    如果衍生类使用了AbstractCoroutineContextKey,那么基类在实现Element接口中的get方法时,就需要通过getPolymorphicElement方法,实现minusKey方法时,就需要通过minusPolymorphicKey方法,代码如下:

public fun <E : Element> Element.getPolymorphicElement(key: Key<E>): E? {
// 如果key是AbstractCoroutineContextKey
if (key is AbstractCoroutineContextKey<*, *>) {
// 如果key是当前key的子key,则基类强制转换成衍生类,并返回
@Suppress("UNCHECKED_CAST")
return if (key.isSubKey(this.key)) key.tryCast(this) as? E else null
}
// 如果key不是AbstractCoroutineContextKey
// 如果key相等,则强制转换,并返回
@Suppress("UNCHECKED_CAST")
return if (this.key === key) this as E else null
}
public fun Element.minusPolymorphicKey(key: Key<*>): CoroutineContext {
// 如果key是AbstractCoroutineContextKey
if (key is AbstractCoroutineContextKey<*, *>) {
// 如果key是当前key的子key,基类强制转换后不为空,说明当前Element需要去掉,因此返回空上下文,否则返回自身
return if (key.isSubKey(this.key) && key.tryCast(this) != null) EmptyCoroutineContext else this
}
// 如果key不是AbstractCoroutineContextKey
// 如果key相等,说明当前Element需要去掉,因此返回空上下文,否则返回自身
return if (this.key === key) EmptyCoroutineContext else this
}


作者:李萧蝶
链接:https://juejin.cn/post/7126161787392622623
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

有趣的 Kotlin 0x0D: IntArray vs Array<Int>

介绍 IntArray 整数数组。在 JVM 平台上,对应 int[]。 Array Array<T> 表示 T 类型数组。在 JVM 平台上,Array<Int> 对应 Integer[]。 验证 fun main() { &nbs...
继续阅读 »

介绍


IntArray


整数数组。在 JVM 平台上,对应 int[]


Array


Array<T> 表示 T 类型数组。在 JVM 平台上,Array<Int> 对应 Integer[]


验证


fun main() {
   val one = IntArray(10) { it }
   val two = Array<Int>(10) { it }
}

Decompile


Java Code


综上,JVM 平台上,IntArrayArray<Int> 的区别在于对应的类型不同,一个是基础类型 int 数组,另外一个是封装类型 Integer 数组,有装箱开销


开销差距



一般情况下,看不出差距,只能用放大镜看一下了。



@OptIn(ExperimentalTime::class)
fun main() {

   val duration1 = measureTime {
       case1()
  }
   println(duration1)

   val duration2 = measureTime {
       case2()
  }
   println(duration2)
}

private fun case1() {
   val t = IntArray(10_000_000)
}

private fun case2() {
   val t = Array<Int>(10_000_000) { it }
}

运行结果


使用场景



  • 默认使用 IntArray,基础类型因无装箱开销而性能好,且每个元素都有默认值 0

  • 如果数组需要使用 null 值,使用 Array<Int>


StackOverflow



高赞回答,一言以蔽之。



StackOverflow Issues


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

Flutter实现微信朋友圈高斯模糊效果

1. 背景 最近一个需求改版UI视觉觉得微信朋友圈的边缘高斯模糊挺好看,然后就苦逼吭哧的尝试在Flutter实现了,来看微信朋友圈点击展开的大图效果图: 微信朋友圈高斯模糊效果大概分4部分区域实现,如下图: 居中图片为原始图,然后背景模糊全图是原始图放大c...
继续阅读 »

1. 背景


最近一个需求改版UI视觉觉得微信朋友圈的边缘高斯模糊挺好看,然后就苦逼吭哧的尝试在Flutter实现了,来看微信朋友圈点击展开的大图效果图:


image.png|400


微信朋友圈高斯模糊效果大概分4部分区域实现,如下图:
image.png


居中图片为原始图,然后背景模糊全图是原始图放大cover模式的高斯模糊,在上下两个区域分别是两层单独处理边界的高斯模糊效果特殊处理,因此有时候可以看到微信朋友圈在上下两侧有明显分界线;


2. 实践


在Flutter侧实现高斯模糊比较简单,可以直接使用系统的BackdropFilter函数实现,需要传入一个filter方式,然后对child区域进行模糊过滤;


  const BackdropFilter({
Key? key,
required this.filter,
Widget? child,
this.blendMode = BlendMode.srcOver,
}) : assert(filter != null),
super(key: key, child: child);

Flutter提供了简化ImageFiltered实现高斯模糊,代码如下:


ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Image.network(url,fit: BoxFit.cover, height: expandedHeight, width: width),
),

通过此方式,可以非常简约实现全屏高斯模糊~,现在难点是上下边界区域的边界模糊处理,这里需要使用一个ShaderMask组件,在Flutter侧ShaderMask主要是实现渐变过渡能力的;


  const ShaderMask({
Key? key,
required this.shaderCallback,
this.blendMode = BlendMode.modulate,
Widget? child,
}) : assert(shaderCallback != null),
assert(blendMode != null),
super(key: key, child: child);

其需要shaderCallback回调渐变Shader,共提供3种渐变模式:



  • RadialGradient:放射状渐变

  • LinearGradient:线性渐变

  • SweepGradient:扇形渐变


这里我们需要使用线性渐变LinearGradient从上到下的渐变过渡,代码如下:


             ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.white,
Colors.transparent
],
).createShader(bounds);
},
child: Image.network(url,
fit: BoxFit.cover, height: closeHeight, width: width),
)


就这样实现了?当我运行时候出现如下效果,效果还挺好的:


image.png


但是当我把封面图url替换了一个浅色图片,却出现如下效果,中间区域变成了黑色的,看来是我想的简单了:


image.png


分析了下Flutter线性过度源码,其将颜色进行过渡,
Color transparent = Color(0x00000000) , 而
Color white = Color(0xFFFFFFFF),可以看到除了透明度之外,需要保证颜色不要发生大变化,其实我们诉求只是需要将透明度发生渐变即可,因此将Colors.white改为Colors.black,


             ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
Colors.transparent
],
).createShader(bounds);
},
child: Image.network(url,
fit: BoxFit.cover, height: closeHeight, width: width),
)


出现如下效果:


image.png


这里颜色貌似符合预期,但是混合模式出现了问题,学过Android开发的一定属性如下这张BlendMode混合模式图片:


image.png


ShaderMaster默认的混合模式是BlendMode.modulate,这个我也解释不清楚:这里有一篇相关文章juejin.cn/post/684490…


这里我们将混合模式替换为BlendMode.dstIn:只显示src和dst重合部分,且src的重合部分只有不透明度有用,经过这些操作后,整体效果最后如下所示:


image.png


最后奉上完整demo的相关代码:


  Widget buildCover(BuildContext context) {
double width = MediaQuery.of(context).size.width;
double expandedHeight = 600;
double closeHeight = 300;
const String url =
'https://img.alicdn.com/imgextra/i2/O1CN01YWcPh81fbUvpcjUXp_!!6000000004025-2-tps-842-350.png';
return Container(
height: expandedHeight,
alignment: Alignment.center,
child: Stack(
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Image.network(url,
fit: BoxFit.cover, height: expandedHeight, width: width),
),
Container(
height: expandedHeight,
alignment: Alignment.center,
child: ShaderMask(
shaderCallback: (Rect bounds) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black,
Colors.transparent
],
).createShader(bounds);
},
blendMode: BlendMode.dstIn,
child: Image.network(url,
fit: BoxFit.cover, height: closeHeight, width: width),
),
)
],
),
);
}

3. 总结


通过实践,发现Flutter实现高斯模糊BackdropFilter/ImageFiltered组件,渐变实现方式ShaderMask,此外还需要掌握图形学的BlendMode混合模式,以后在碰到类似需求时候建议直接砍了UI视觉吧~~费劲~~~~


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

Android抓包从未如此简单

一、情景再现: 有一天你正在王者团战里杀的热火朝天,忽然公司测试人员打你电话问为什么某个功能数据展示不出来了,昨天还好好的纳,是不是你又偷偷写bug了。。。WTF!,你会说:把你手机给我,我连上电脑看看打印的请求日志是不是接口有问题。然后吭哧吭哧搞半天看到接...
继续阅读 »

一、情景再现:



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



二、Android抓包现状


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


三、效果展示



俗话说无图无真相



111.jpg


222.jpg


333.jpg


抓包pc.png


四、如何使用



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



Demo下载体验


源码地址


1、monitor接入


添加依赖


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

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


2、monitor-plugin接入



  1. 根目录build.gradle下添加如下依赖


    buildscript {
dependencies {
......
//monitor-plugin需要
classpath 'io.github.lygttpod:monitor-plugin:0.0.1'
}
}

复制代码

2.添加插件


    在APP的build.gradle中添加:

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

复制代码


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



3、 个性化配置


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

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

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

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

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


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

5、 温馨提示


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

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

6、如何使用



  • 集成之后编译运行项目即可在手机上自动生成一个抓包入口的图标,点击即可打开可视化页面查看网络请求数据,这样就可以随时随地的查看我们的请求数据了。

  • 虽然可以很方便的查看请求数据了但是手机屏幕太小,看起来不方便怎么办呐,那就去寻找在PC上展示的方法,首先想到的是能不能直接在浏览器里边直接看呐,这样不用安装任何程序在浏览输入一个地址就可以直接查看数据

  • PC和手机在同一局域网的前提下:直接在任意浏览器输入 手机ip地址+抓包工具设置的端口号即可(地址可以在抓包app首页TitleBar上可以看到)


五、原理介绍


①、 拦截APP的OKHTTP请求(添加拦截器处理抓包请求,使用ASM字节码插装技术实现)



  • 写一个Interceptor拦截器,获取请求及响应的数据,转化为需要的数据结构


override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!MonitorHelper.isOpenMonitor) {
return chain.proceed(request)
}
val monitorData = MonitorData()
monitorData.method = request.method
val url = request.url.toString()
monitorData.url = url
if (url.isNotBlank()) {
val uri = Uri.parse(url)
monitorData.host = uri.host
monitorData.path = uri.path + if (uri.query != null) "?" + uri.query else ""
monitorData.scheme = uri.scheme
}
......以上为部分代码展示
}
复制代码


  • 有了拦截器就可以通过字节码插桩技术在编译期自动为OKHTTP添加拦截器了,避免了使用者自己添加拦截器的操作


        mv?.let {
it.visitVarInsn(ALOAD, 0)
it.visitFieldInsn(GETFIELD, "okhttp3/OkHttpClient\$Builder", "interceptors", "Ljava/util/List;")
it.visitFieldInsn(GETSTATIC, "com/lygttpod/monitor/MonitorHelper", "INSTANCE", "Lcom/lygttpod/monitor/MonitorHelper;")
it.visitMethodInsn(INVOKEVIRTUAL, "com/lygttpod/monitor/MonitorHelper", "getHookInterceptors", "()Ljava/util/List;", false)
it.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "addAll", "(Ljava/util/Collection;)Z", true)
it.visitInsn(POP)
}
复制代码

②、 数据保存到本地数据库(room)



  • 数据库选择官方推荐Room进行数据操作


@Dao
interface MonitorDao {
@Query("SELECT * FROM monitor WHERE id > :lastId ORDER BY id DESC")
fun queryByLastIdForAndroid(lastId: Long): LiveData<MutableList<MonitorData>>

@Query("SELECT * FROM monitor ORDER BY id DESC LIMIT :limit OFFSET :offset")
fun queryByOffsetForAndroid(limit: Int, offset: Int): LiveData<MutableList<MonitorData>>

@Query("SELECT * FROM monitor")
fun queryAllForAndroid(): LiveData<MutableList<MonitorData>>

@Query("SELECT * FROM monitor WHERE id > :lastId ORDER BY id DESC")
fun queryByLastId(lastId: Long): MutableList<MonitorData>

@Query("SELECT * FROM monitor ORDER BY id DESC LIMIT :limit OFFSET :offset")
fun queryByOffset(limit: Int, offset: Int): MutableList<MonitorData>

@Query("SELECT * FROM monitor")
fun queryAll(): MutableList<MonitorData>

@Insert
fun insert(data: MonitorData)

@Update
fun update(data: MonitorData)

@Query("DELETE FROM monitor")
fun deleteAll()
}
复制代码

③、 APP本地开启一个socket服务AndroidLocalService



  • AndroidLocalService基于NanoHttpd实现的一个本地微服务库,底层是通过socket实现,同时使用注解加上javapoet框架自动生成模版代码,这样就可以很方便的创建服务了,下边是创建服务并启动服务示例代码


   //@Service标记这是一个服务,端口号是服务器的端口号,注意端口号唯一
@Service(port = 9527)
abstract class AndroidService {

//@Page标注页面类,打开指定h5页面
@Page("index")
fun getIndexFileName() = "test_page.html"

//@Get注解在方法上边
@Get("query")
fun query(aaa: Boolean, bbb: Double, ccc: Float, ddd: String, eee: Int,): List<String> {
return listOf("$aaa", "$bbb", "$ccc", "$ddd", "$eee")
}

@Get("saveData")
fun saveData(content: String) {
LiveDataHelper.saveDataLiveData.postValue(content + UUID.randomUUID());
}

@Get("queryAppInfo")
fun getAppInfo(): HashMap<String, Any> {
return hashMapOf(
"applicationId" to BuildConfig.APPLICATION_ID,
"versionName" to BuildConfig.VERSION_NAME,
"versionCode" to BuildConfig.VERSION_CODE,
"uuid" to UUID.randomUUID(),
)
}
}

//初始化
ALSHelper.init(this)
//启动服务
ALSHelper.startService(ServiceConfig(AndroidService::class.java))


然后就可以通过 ip地址 + 端口号 访问了,例如:http://172.18.41.157:9527/index

复制代码


使用AndroidLocalService之后创建和启动服务就是这么简单有没有,具体用法及细节请查看其说明文档



④、 与本地socket服务通信



  • 剩下的就是与服务器的通信了,无论使用前端使用aJax还是客户端使用okhttp都可以正常请求数据了


⑤、 UI展示数据(手机端和PC端)



  • 有了接口和数据具体展示就看可以随意定制了,如果你不喜欢默认的UI风格,那就拉源码自己定制UI哦



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

Android ViewModelScope 如何自动取消协程

先看一下 ViewModel 中的 ViewModelScope 是何方神圣 val ViewModel.viewModelScope: CoroutineScope get() { val scope: Corouti...
继续阅读 »

先看一下 ViewModel 中的 ViewModelScope 是何方神圣


val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
}

可以看到这个是一个扩展方法,


再点击 setTagIfAbsent 方法进去


 <T> T setTagIfAbsent(String key, T newValue) {
T previous;
synchronized (mBagOfTags) {
previous = (T) mBagOfTags.get(key);//第一次肯定为null
if (previous == null) {
mBagOfTags.put(key, newValue);//null 存储
}
}
T result = previous == null ? newValue : previous;
if (mCleared) {//判断是否已经clear了
// It is possible that we'll call close() multiple times on the same object, but
// Closeable interface requires close method to be idempotent:
// "if the stream is already closed then invoking this method has no effect." (c)
closeWithRuntimeException(result);
}
return result;
}

可以看到 这边 会把 我们的 ViewModel 存储到 ViewModel 内的 mBagOfTags 中


这个 mBagOfTags 是


    private final Map<String, Object> mBagOfTags = new HashMap<>();

这个时候 我们 viewModel 就会持有 我们 viewModelScope 的协程 作用域了。


那..这也只是 表述了 我们 viewModelScope 存在哪里而已,


什么时候清除呢?


先看一下 ViewModel 的生命周期



可以看到 ViewModel 的生命周期 会在 Activity onDestory 之后会被调用。


那...具体哪里调的?


翻看源码可以追溯到 ComponentActivity 的默认构造器内


 public ComponentActivity() {
/*省略一些*/
getLifecycle().addObserver(new LifecycleEventObserver() {
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_DESTROY) {
if (!isChangingConfigurations()) {
getViewModelStore().clear();
}
}
}
});
}

可以看到内部会通对 Lifecycle 添加一个观察者,观察当前 Activity 的生命周期变更事件,如果走到了 Destory ,并且 本次 Destory 并非由于配置变更引起的,才会真正调用 ViewModelStore 的 clear 方法。


跟进 clear 方法看看


public class ViewModelStore {

private final HashMap<String, ViewModel> mMap = new HashMap<>();

/**
* Clears internal storage and notifies ViewModels that they are no longer used.
*/
public final void clear() {
for (ViewModel vm : mMap.values()) {
vm.clear();
}
mMap.clear();
}
}

可以看到这个 ViewModelStore 内部实现 用 HashMap 存储 ViewModel


于是在 clear 的时候,会逐个遍历调用 clear方法


再次跟进 ViewModel 的 clear 方法


 @MainThread
final void clear() {
mCleared = true;
// Since clear() is final, this method is still called on mock objects
// and in those cases, mBagOfTags is null. It'll always be empty though
// because setTagIfAbsent and getTag are not final so we can skip
// clearing it
if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
// see comment for the similar call in setTagIfAbsent
closeWithRuntimeException(value);
}
}
}
onCleared();
}

可以发现我们最初 存放 viewmodelScope 的 mBagOfTags


这里面的逻辑 就是对 mBagOfTags 存储的数据 挨个提取出来并且调用 closeWithRuntimeException


跟进 closeWithRuntimeException


 private static void closeWithRuntimeException(Object obj) {
if (obj instanceof Closeable) {
try {
((Closeable) obj).close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}

该方法内会逐个判断 对象是否实现 Closeable 如果实现就会调用这个接口的 close 方法,


再回到最初 我们 viewModel 的扩展方法那边,看看我们 viewModelScope 的真正面目


internal class CloseableCoroutineScope(context: CoroutineContext) 
: Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context

override fun close() {
coroutineContext.cancel()
}
}

可以明确的看到 我们的 ViewModelScope 实现了 Closeable 并且充写了 close 方法,


close 方法内的实现 会对 协程上下文进行 cancel。


至此我们 可以大致整理一下



  1. viewModelScope 是 ViewModel 的扩展成员,该对象是 CloseableCoroutineScope,并且实现了 Closeable 接口

  2. ViewModelScope 存储在 ViewModel 的 名叫 mBagOfTags 的HashMap中 啊

  3. ViewModel 存储在 Activity 的 ViewModelStore 中,并且会监听 Activity 的 Lifecycle 的状态变更,在ON_DESTROY 且 非配置变更引起的事件中 对 viewModelStore 进行清空

  4. ViewModelStore 清空会对 ViewModelStore 内的所有 ViewModel 逐个调用 clear 方法。

  5. ViewModel的clear方法会对 ViewModel的 mBagOfTags 内存储的对象进行调用 close 方法(该对象需实现Closeable 接口)

  6. 最终会会调用 我们 ViewModelScope 的实现类 CloseableCoroutineScope 的 close 方法中。close 方法会对协程进行 cancel。

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

Android 12新功能:使用SplashScreen优化启动体验

前言由于很多应用在启动时需要进行一些初始化事务,导致在启动应用时有一定的空白延迟,在之前我们一般的做法是通过替换 android:windowBackground 的自定义主题,使应用启动时及时显示一张默认图片来改善启动体验。在Androi...
继续阅读 »

前言

由于很多应用在启动时需要进行一些初始化事务,导致在启动应用时有一定的空白延迟,在之前我们一般的做法是通过替换 android:windowBackground 的自定义主题,使应用启动时及时显示一张默认图片来改善启动体验。

在Android 12中,官方添加了SplashScreen API,它可为所有应用启用新的应用启动界面。新的启动界面是瞬时显示的,所以就不必再自定义android:windowBackground 了。新启动页面的样式默认是正中显示应用图标,但是允许我们自定义,以便应用能够保持其独特的品牌。下面我们来看看如何使用它。

启动画面实现

其实在Android 12上已经默认使用了SplashScreen,如果没有任何配置,会自动使用App图标。

当然也允许自定义启动画面,在value-v31中的style.xml中,可以在App的主Theme中通过如下属性来进行配置:

<style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar">
<item name="android:windowSplashScreenBackground">@android:color/white</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/anim_ai_loading</item>
<item name="android:windowSplashScreenAnimationDuration">1000</item>
<item name="android:windowSplashScreenBrandingImage">@mipmap/brand</item>
</style>
  • windowSplashScreenBackground设置启动画面的背景色

  • windowSplashScreenAnimatedIcon启动图标。就是显示在启动界面中间的图片,也可以是动画

  • windowSplashScreenAnimationDuration设置动画的长度。注意这里最大只能1000ms,如果需要动画时间更长,则需要通过代码的手段让启动画面在屏幕上显示更长时间(下面会讲到)

  • windowSplashScreenIconBackground设置启动图标的背景色

  • windowSplashScreenBrandingImage设置要显示在启动画面底部的图片。官方设计准则建议不要使用品牌图片。

运行启动应用就可以看到新的启动画面了,如下: 屏幕录制2022-01-19 上午10.gif

动画的元素

在Android 12上,显示在启动界面中间的图片会有一个圆形遮罩,所以在设计图片或动画的时候一定要注意,比如上面我的例子,动画其实就没有显示完整。对此官方给了详细的设计指导,如下:

image.png

  • 应用图标 (1) 应该是矢量可绘制对象,它可以是静态或动画形式。虽然动画的时长可以不受限制,但我们建议让其不超过 1000 毫秒。默认情况下,使用启动器图标。
  • 图标背景 (2) 是可选的,在图标与窗口背景之间需要更高的对比度时很有用。如果您使用一个自适应图标,当该图标与窗口背景之间的对比度足够高时,就会显示其背景。
  • 与自适应图标一样,前景的 ⅓ 被遮盖 (3)。
  • 窗口背景 (4) 由不透明的单色组成。如果窗口背景已设置且为纯色,则未设置相应的属性时默认使用该背景。

启动时长

默认当应用绘制第一帧后,启动画面会立即关闭。但是在我们实际使用中,一般在启动时进行一些初始化操作,另外大部分应用会请求启动广告,这样其实需要一些耗时的。通常情况下,这些耗时操作我们会进行异步处理,那么是否可以让启动画面等待这些初始化完成后才关闭?

我们可以使用 ViewTreeObserver.OnPreDrawListener让应用暂停绘制第一帧,直到一切准备就绪才开始,这样就会让启动画面停留更长的时间,如下:

...
var isReady = false
...

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
...

val content: View = findViewById(android.R.id.content)
content.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
return if (isReady) {
content.viewTreeObserver.removeOnPreDrawListener(this)
true
} else {
false
}
}
}
)
}

这样当初始化等耗时操作完成后,将isReady置为true即可关闭启动画面进入应用。

上面我们提到配置启动动画的时长最多只能是1000ms,但是通过上面的代码可以让启动画面停留更长时间,所以动画的展示时间也就更长了。

关闭动画

启动画面关闭时默认直接消失,当然我们也可以对其进行自定义。

在Activity中可以通过getSplashScreen来获取(注意判断版本,低版本中没有这个函数,会crash),然后通过它的setOnExitAnimationListener来定义关闭动画,如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { splashScreenView ->
val slideUp = ObjectAnimator.ofFloat(
splashScreenView,
View.TRANSLATION_Y,
0f,
-splashScreenView.height.toFloat()
)
slideUp.interpolator = AnticipateInterpolator()
slideUp.duration = 200L
//这里doOnEnd需要Android KTX库,即androidx.core:core-ktx:1.7.0
slideUp.doOnEnd { splashScreenView.remove() }
slideUp.start()
}
}

加上如上代码后,本来直接消失的启动画面就变成了向上退出了。

这里可以通过splashScreenView可以获取到启动动画的时长和开始时间,如下:

val animationDuration = splashScreenView.iconAnimationDurationMillis
val animationStart = splashScreenView.getIconAnimationStartMillis

这样就可以计算出启动动画的剩余时长。

顺便吐槽一下官网这里代码错了,开始时间也用了iconAnimationDurationMillis来获取,实际上应该是getIconAnimationStartMillis

低版本使用SplashScreen

只能在Android 12上体验官方的启动动画,显然不能够啊!官方提供了Androidx SplashScreen compat库,能够向后兼容,并可在所有 Android 版本上显示外观和风格一致的启动画面(这点我保留意见)。

首先要升级compileSdkVersion,并依赖SplashScreen库,如下:

android {
compileSdkVersion 31
...
}
dependencies {
...
implementation 'androidx.core:core-splashscreen:1.0.0-alpha01'
}

然后在style.xml添加代码如下:

<style name="Theme.App.Starting" parent="Theme.SplashScreen">
// Set the splash screen background, animated icon, and animation duration.
<item name="windowSplashScreenBackground">@android:color/white</item>

// Use windowSplashScreenAnimatedIcon to add either a drawable or an
// animated drawable. One of these is required.
<item name="windowSplashScreenAnimatedIcon">@drawable/anim_ai_loading</item>
<item name="windowSplashScreenAnimationDuration">1000</item> # Required for
# animated icons

// Set the theme of the Activity that directly follows your splash screen.
<item name="postSplashScreenTheme">@style/AppTheme</item> # Required.
</style>

前三个我们上面都介绍过了,这里新增了一个postSplashScreenTheme,它应该设置为应用的原主题,这样会将这个主题设置给启动画面之后的Activity,这样就可以保持样式的不变。

注意上面提到的windowSplashScreenIconBackgroundwindowSplashScreenBrandingImage没有,这是与Android12的不同之一。

然后我们将这个style设置给Application或Activity即可:

<manifest>
<application android:theme="@style/Theme.App.Starting">
<!-- or -->
<activity android:theme="@style/Theme.App.Starting">
...

最后需要在启动activity中,先调用installSplashScreen,然后才能调用setContentView,如下

class MainActivity : ComponentActivity() {

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       val splashScreen = installSplashScreen()
       setContentView(R.layout.activity_main)
...

然后在低版本系统上启动应用就可以看到启动画面了。

installSplashScreen这一步很重要,如果没有这一行代码,postSplashScreenTheme就无法生效,这样启动画面后Activity就无法使用之前的样式,严重的会造成崩溃。比如在Activity中存在AppCompat组件,这就需要使用AppCompat样式,否则就会Crash。

最后注意在Android 12上依然有圆形遮罩,所以需要遵循官方的设计准则;但是在低版本系统上则没发现有这个遮罩,而且在低版本上动画无效,只会显示第一帧的画面,所以我对官方说的风格一致保留意见。

现有启动画面迁移

目前市场上的App基本都自己实现了启动页面,如果直接添加SplashScreen,就会造成重复,所以我们需要对原有启动页面进行处理。具体处理还要根据每个App自己的启动页面的实现逻辑来定,这里官方给出了一些意见,大家可以参考一下:将现有的启动画面实现迁移到 Android 12 及更高版本

总结

官方的SplashScreen有点姗姗来迟,不过效果还是不错的,使用起来也非常简单,但是一定要注意版本。虽然Androidx SplashScreen compat库可以向后兼容,但是与Android 12上还是有一些不同。


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

收起阅读 »

WebView初体验【Android】

每天认真洗脸,多读书,按时睡,少食多餐。变得温柔,大度,继续善良,保持爱心。不在人前矫情,四处诉说以求宽慰,而是学会一个人静静面对,自己把道理想通。这样的你,单身也所谓啊,你在那么虔诚地做更好的自己,一定会遇到最好的,而那个人也一定值得你所有等待。 在We...
继续阅读 »

每天认真洗脸,多读书,按时睡,少食多餐。变得温柔,大度,继续善良,保持爱心。不在人前矫情,四处诉说以求宽慰,而是学会一个人静静面对,自己把道理想通。这样的你,单身也所谓啊,你在那么虔诚地做更好的自己,一定会遇到最好的,而那个人也一定值得你所有等待。



书客创作


在WebView没有出现之前,如果要访问一个网页只能通过打开手机内的浏览器,通过浏览器来加载网页,但是打开浏览器的同时,也脱离了当前的应用软件,这样就大大的降低了网页与应用软件的交互。随着Android SDK的不断升级,官方提供一个WebView控件,专门用于加载网页并实现交互。那么到底WebView是什么?又该如何使用呢?


什么是WebView?

简单来说WebView是移动端用于加载Web页面的控件。


怎么使用WebView?

1、移动端加载网页方式


A、通过打开浏览器访问网页


String weburl ="http://www.baidu.com/";
Uri uri = Uri.parse(weburl);// weburl网址
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
startActivity(intent);

B、通过WebView打开本地网页


WebView.loadUrl("file:///android_asset/baidu.html");

注意1:本地文件放在assets文件中,assets文件是main的子文件,与res文件同级。
注意2:设置WebView支持加载本地文件。


WebSettings webSettings = webView.getSettings();
// 允许加载Assets和resources文件
webSettings.setAllowFileAccess(true);

本地baidu.html代码


C、通过WebView加载网址


webView.loadUrl("http://www.baidu.com/");

加载网址,需要在清单文件中加上网络请求权限


<uses-permission android:name="android.permission.INTERNET"/>

当WebView加载失败时,可以使用webView.reload();来重新加载。
注意:当加载完网页之后,如果发现网页无法点击,这很可能是WebView没有获取焦点。


webView.requestFocus();// 使页面获取焦点,防止点击无响应

2、WebView基本属性设置


WebView提供很多属性,需要通过WebSettings来进行设置,下面是对一些常用属性进行设置。


// 设置WebView相关属性
WebSettings webSettings = webView.getSettings();
// 是否缓存表单数据
webSettings.setSaveFormData(false);
// 设置WebView 可以加载更多格式页面
webSettings.setLoadWithOverviewMode(true);
// 设置WebView使用广泛的视窗
webSettings.setUseWideViewPort(true);
// 支持2.2以上所有版本
webSettings.setPluginState(WebSettings.PluginState.ON);
// 允许加载Assets和resources文件
webSettings.setAllowFileAccess(true);
// 告诉webview启用应用程序缓存api
webSettings.setAppCacheEnabled(true);
// 排版适应屏幕
webSettings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NARROW_COLUMNS);
// 支持插件
webSettings.setPluginState(WebSettings.PluginState.ON);
// 设置是否启用了DOM storage AP搜索I
webSettings.setDomStorageEnabled(true);
// 设置缓存,默认不使用缓存-有缓存,使用缓存
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);
// 是否允许缩放
webSettings.setSupportZoom(false);
// 是否支持通过js打开新的窗口
webSettings.setJavaScriptCanOpenWindowsAutomatically(true);
// 允许加载JS
webSettings.setJavaScriptEnabled(true);

// 隐藏滚动条
webView.setScrollBarStyle(WebView.SCROLLBARS_OUTSIDE_OVERLAY);

3、WebView默认是通过浏览器打开网页,如何使用WebView打开网页?


WebViewClient是WebView的一个重要属性,它不仅仅能够实现WebView打开网页,而且还能够实现URL重构等功能。


// WebView默认是通过浏览器打开url,使用url在WebView中打开
webView.setWebViewClient(new WebViewClient() {
// // 旧版本
// @Override
// public boolean shouldOverrideUrlLoading(WebView view, String url) {
// 使url在WebView中打开,在这里可以进行重构url
// webView.loadUrl(url);
// return true;
// }

// 新版本
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
// 返回false,意味着请求过程中,不管有多少次的跳转请求(即新的请求地址),均交给webView自己处理,这也是此方法的默认处理
// 返回true,说明你自己想根据url,做新的跳转,比如在判断url符合条件的情况下,我想让webView加载http://baidu.com/
// 加载Url,使网页在WebView中打开,在这里可以进行重构url
if(Build.VERSION.SDK_INT>= Build.VERSION_CODES.LOLLIPOP) {
webView.loadUrl(request.getUrl().toString());
}
return true;
}

// WebViewClient帮助WebView去处理页面控制和请求通知
@Override
public void onLoadResource(WebView view, String url) {
super.onLoadResource(view, url);
}

// 错误代码处理,一般是加载本地Html页面,或者使用TextView显示错误
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
// 当网页加载出错时,加载本地错误文件
// webView.loadUrl("file:///android_asset/error.html");
}

// 页面开始加载-例如在这里开启进度条
@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
super.onPageStarted(view, url, favicon);
}

// 页面加载结束,一般用来加载或者执行javaScript脚本
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
}
});

4、设置WebView的WebChromeClient属性


WebChromeClient是WebView中一个非常重要的属性,使用它可以监听网页加载的进度,获取网页主题等信息。


// 监听网页加载进度
webView.setWebChromeClient(new WebChromeClient() {
// 网页Title信息
@Override
public void onReceivedTitle(WebView view, String title) {
super.onReceivedTitle(view, title);
}

// 监听网页alert方法
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
return super(view, url, message, result);
}

// 显示网页加载进度
@Override
public void onProgressChanged(WebView view, int newProgress) {
// newProgress 1-100
}
});

5、WebView中使用JavaScript


WebView与网页的交互大多数是使用JavaScript来实现


//设置WebView支持JavaScript
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);

6、下载文件监听


// 下载文件
webView.setDownloadListener(new DownloadListener() {
@Override
public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) {
// url下载文件地址
// 处理下载文件逻辑
}
});

7、后退与前进


// 返回键监听
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK) {
if (webView.canGoBack())
// 判断WebView是否能够返回,能-返回
webView.canGoBack();
else
finish();
return true;
}
return super.onKeyDown(keyCode, event);
}

8、WebView优化-缓存


//设置缓存,默认不使用缓存
webSettings.setCacheMode(WebSettings.LOAD_CACHE_ELSE_NETWORK);//有缓存,使用缓存
webSettings.setCacheMode(WebSettings.LOAD_NO_CACHE);//不使用缓存

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

普通的加载千篇一律,有趣的 loading 万里挑一

前言在网络速度较慢的场景,一个有趣的加载会提高用户的耐心和对 App 的好感,有些 loading 动效甚至会让用户有想弄清楚整个动效过程到底是怎么样的冲动。然而,大部分的 App的 loading 就是下面这种千篇一律...
继续阅读 »

前言

在网络速度较慢的场景,一个有趣的加载会提高用户的耐心和对 App 的好感,有些 loading 动效甚至会让用户有想弄清楚整个动效过程到底是怎么样的冲动。然而,大部分的 App的 loading 就是下面这种千篇一律的效果 —— 俗称“转圈”。

loading-ios.gif

loading-android.gif

本篇我们利用Flutter 的 PathMetric来玩几个有趣的 loading 效果。

效果1:圆环内滚动的球

加载圆形球动画.gif

如上图所示,一个红色的小球在蓝色的圆环内滚动,而且在往上滚动的时候速度慢,往下滚动的时候有个明显的加速过程。这个效果实现的思路如下:

  • 绘制一个蓝色的圆环,在蓝色的圆环内构建一个半径更小一号的圆环路径(Path)。
  • 让红色小球在动画控制下沿着内部的圆环定义的路径运动。
  • 选择一个中间减速(上坡)两边加速的动画曲线。

下面是实现代码:

// 动画控制设置
controller =
AnimationController(duration: const Duration(seconds: 3), vsync: this);
animation = Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
parent: controller,
curve: Curves.slowMiddle,
))
..addListener(() {
setState(() {});
});

// 绘制和动画控制方法
_drawLoadingCircle(Canvas canvas, Size size) {
var paint = Paint()..style = PaintingStyle.stroke
..color = Colors.blue[400]!
..strokeWidth = 2.0;
var path = Path();
final radius = 40.0;
var center = Offset(size.width / 2, size.height / 2);
path.addOval(Rect.fromCircle(center: center, radius: radius));
canvas.drawPath(path, paint);

var innerPath = Path();
final ballRadius = 4.0;
innerPath.addOval(Rect.fromCircle(center: center, radius: radius - ballRadius));
var metrics = innerPath.computeMetrics();
paint.color = Colors.red;
paint.style = PaintingStyle.fill;
for (var pathMetric in metrics) {
var tangent = pathMetric.getTangentForOffset(pathMetric.length * animationValue);
canvas.drawCircle(tangent!.position, ballRadius, paint);
}
}

效果2:双轨运动

双轨运动.gif

上面的实现效果其实比较简单,就是绘制了一个圆和一个椭圆,然后让两个实心圆沿着路径运动。因为有了这个组合效果,趣味性增加不少,外面的椭圆看起来就像是一条卫星轨道一样。实现的逻辑如下:

  • 绘制一个圆和一个椭圆,二者的中心点重合;
  • 在圆和椭圆的路径上分别绘制一个小的实心圆;
  • 通过动画控制实心圆沿着大圆和椭圆的路径上运动。

具体实现的代码如下所示。

controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
parent: controller,
curve: Curves.easeInOutSine,
))
..addListener(() {
setState(() {});
});

_drawTwinsCircle(Canvas canvas, Size size) {
var paint = Paint()
..style = PaintingStyle.stroke
..color = Colors.blue[400]!
..strokeWidth = 2.0;

final radius = 50.0;
final ballRadius = 6.0;
var center = Offset(size.width / 2, size.height / 2);
var circlePath = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));
paint.style = PaintingStyle.stroke;
paint.color = Colors.blue[400]!;
canvas.drawPath(circlePath, paint);

var circleMetrics = circlePath.computeMetrics();
for (var pathMetric in circleMetrics) {
var tangent = pathMetric
.getTangentForOffset(pathMetric.length * animationValue);

paint.style = PaintingStyle.fill;
paint.color = Colors.blue;
canvas.drawCircle(tangent!.position, ballRadius, paint);
}

paint.style = PaintingStyle.stroke;
paint.color = Colors.green[600]!;
var ovalPath = Path()
..addOval(Rect.fromCenter(center: center, width: 3 * radius, height: 40));
canvas.drawPath(ovalPath, paint);
var ovalMetrics = ovalPath.computeMetrics();

for (var pathMetric in ovalMetrics) {
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * animationValue);

paint.style = PaintingStyle.fill;
canvas.drawCircle(tangent!.position, ballRadius, paint);
}
}

效果3:钟摆运动

钟摆球动画.gif 钟摆运动的示意图如下所示,一条绳子系着一个球悬挂某处,把球拉起一定的角度释放后,球就会带动绳子沿着一条圆弧来回运动,这条圆弧的半径就是绳子的长度。 钟摆示意图.png 这个效果通过代码来实现的话,需要做下面的事情:

  • 绘制顶部的横线,代表悬挂的顶点;
  • 绘制运动的圆弧路径,以便让球沿着圆弧运动;
  • 绘制实心圆代表球,并通过动画控制沿着一条圆弧运动;
  • 用一条顶端固定,末端指向球心的直线代表绳子;
  • 当球运动到弧线的终点后,通过动画反转(reverse)控制球 返回;到起点后再正向(forward) 运动就可以实现来回运动的效果了。

具体实现的代码如下,这里在绘制球的时候给 Paint 对象增加了一个 maskFilter 属性,以便让球看起来发光,更加好看点。

controller =
AnimationController(duration: const Duration(seconds: 2), vsync: this);
animation = Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
parent: controller,
curve: Curves.easeInOutQuart,
))
..addListener(() {
setState(() {});
}
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});

_drawPendulum(Canvas canvas, Size size) {
var paint = Paint()
..style = PaintingStyle.stroke
..color = Colors.blue[400]!
..strokeWidth = 2.0;

final ceilWidth = 60.0;
final pendulumHeight = 200.0;
var ceilCenter =
Offset(size.width / 2, size.height / 2 - pendulumHeight / 2);
var ceilPath = Path()
..moveTo(ceilCenter.dx - ceilWidth / 2, ceilCenter.dy)
..lineTo(ceilCenter.dx + ceilWidth / 2, ceilCenter.dy);
canvas.drawPath(ceilPath, paint);

var pendulumArcPath = Path()
..addArc(Rect.fromCircle(center: ceilCenter, radius: pendulumHeight),
3 * pi / 4, -pi / 2);

paint.color = Colors.white70;
var metrics = pendulumArcPath.computeMetrics();

for (var pathMetric in metrics) {
var tangent =
pathMetric.getTangentForOffset(pathMetric.length * animationValue);

canvas.drawLine(ceilCenter, tangent!.position, paint);
paint.style = PaintingStyle.fill;
paint.color = Colors.blue;
paint.maskFilter = MaskFilter.blur(BlurStyle.solid, 4.0);
canvas.drawCircle(tangent.position, 16.0, paint);
}
}

总结

本篇介绍了三种 Loading 动效的绘制逻辑和实现代码,可以看到利用路径属性进行绘图以及动画控制可以实现很多有趣的动画效果。


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

收起阅读 »

写出优雅的Kotlin代码:聊聊我认为的 "Kotlinic"

"Kotlinic" 一词属于捏造的,参考的是著名的"Pythonic",后者可以译为“很Python”,意思是写的代码一看就很有Python味。照这个意思,"Kotlinic"就是“很Kotlin”,很有Kotlin味。 Kotlin程序员们不少是从Java...
继续阅读 »

"Kotlinic" 一词属于捏造的,参考的是著名的"Pythonic",后者可以译为“很Python”,意思是写的代码一看就很有Python味。照这个意思,"Kotlinic"就是“很Kotlin”,很有Kotlin味。


Kotlin程序员们不少是从Java转过来的,包括我;大部分时候,大家也都把它当大号的Java语法糖在用。但Kotlin总归是一门新语言,而且,在我眼里还是门挺优雅的语言。所以,或许我们可以把Kotlin写得更Kotlin些。我想简单粗浅的聊聊。



本文希望:聊聊一些好用的、简洁的但又不失语义的Kotlin代码


本文不希望:鼓励无脑追求高超技巧,完全放弃了可读性、可维护性,全篇奇技淫巧的操作



受限于本人水平,可能有错误或不严谨之处。如有此类问题,欢迎指出。也欢迎在评论区探讨交流~


善用with、apply、also、let


with和apply


with和apply,除了能帮忙少打一些代码外,重要的是能让代码区分更明确。比如


val textView = TextView(context)
textView.text = "fish"
textView.setTextColor(Color.BLUE)
textView.setOnClickListener { }
val imageView = ImageView(context)
// ...
复制代码

这就是典型的Java写法,自然,没什么问题。但要是类似的代码多起来,总感觉不知道哪里是哪里。如果换用apply呢?


val textView = TextView(context).apply {
text = "fish"
setTextColor(Color.BLUE)
setOnClickListener { }
}
val imageView = ImageView(context).apply {

}
复制代码

apply的大括号轻松划清了边界:我这里的代码和TextView相关。看着更整齐。


如果后面不需要这个变量,赋值还能省了


 // 设置某个view下的各个控件
with(view) {
findViewById<TextView>(R.id.some_id).apply {
text = "fish"
setTextColor(Color.BLUE)
setOnClickListener { }
}

findViewById<ImageView>(R.id.some_id).apply {

}
}
复制代码

apply的另一个常见场景是用于那些返回自己的函数,比如常见的Builder类的方法


fun setName(name: String): Builder{
this.name = name
return this
}
复制代码

改成apply就简洁得多


fun setName(name: String) = apply{ this.name = name }
复制代码

also


also的常见场景有很多,它的语义就是干完上一件事后附带干点什么事。 举个例子,给个函数


fun someFunc() : Model{
// ...
return Model(name = "model", value = "value")
}
复制代码

如果我们突然想加个Log,打印一下返回值,按Java的写法,要这么干:


fun someFunc(): Model{
// ...
val tempModel = Model(name = "model", value = "value")
print(tempModel)
return tempModel
}
复制代码

改的不少。但是按Kotlin的写法呢?


fun someFunc() : Model{
return Model(name = "model", value = "value").also {
print(it)
}
}
复制代码

不需要额外整个变量出来。


类似的,比如上面apply的例子,在没有声明变量的情况下,也可以这样用这个值


findViewById<ImageView>(R.id.some_id).apply {
// ...
}.also{ println(it) }
复制代码

整在一起


这几个函数结合起来,在针对一些比较复杂的场景时,对提高代码的可读性还是挺有帮助的。如【唐子玄】在这篇文章里所举的例子:



假设需求如下:“缩放 textView 的同时平移 button ,然后拉长 imageView,动画结束后 toast 提示”。



“Java”式写法


PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 1.3f);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 1.3f);
ObjectAnimator tvAnimator = ObjectAnimator.ofPropertyValuesHolder(textView, scaleX, scaleY);
tvAnimator.setDuration(300);
tvAnimator.setInterpolator(new LinearInterpolator());

PropertyValuesHolder translationX = PropertyValuesHolder.ofFloat("translationX", 0f, 100f);
ObjectAnimator btnAnimator = ObjectAnimator.ofPropertyValuesHolder(button, translationX);
btnAnimator.setDuration(300);
btnAnimator.setInterpolator(new LinearInterpolator());

ValueAnimator rightAnimator = ValueAnimator.ofInt(ivRight, screenWidth);
rightAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int right = ((int) animation.getAnimatedValue());
imageView.setRight(right);
}
});
rightAnimator.setDuration(400);
rightAnimator.setInterpolator(new LinearInterpolator());

AnimatorSet animatorSet = new AnimatorSet();
animatorSet.play(tvAnimator).with(btnAnimator);
animatorSet.play(tvAnimator).before(rightAnimator);
animatorSet.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {}
@Override
public void onAnimationEnd(Animator animation) {
Toast.makeText(activity,"animation end" ,Toast.LENGTH_SHORT).show();
}
@Override
public void onAnimationCancel(Animator animation) {}
@Override
public void onAnimationRepeat(Animator animation) {}
});
animatorSet.start();
复制代码

乱糟糟的。改成“Kotlin式”写法呢?


AnimatorSet().apply {
ObjectAnimator.ofPropertyValuesHolder(
textView,
PropertyValuesHolder.ofFloat("scaleX", 1.0f, 1.3f),
PropertyValuesHolder.ofFloat("scaleY", 1.0f, 1.3f)
).apply {
duration = 300L
interpolator = LinearInterpolator()
}.let {
play(it).with(
ObjectAnimator.ofPropertyValuesHolder(
button,
PropertyValuesHolder.ofFloat("translationX", 0f, 100f)
).apply {
duration = 300L
interpolator = LinearInterpolator()
}
)
play(it).before(
ValueAnimator.ofInt(ivRight,screenWidth).apply {
addUpdateListener { animation -> imageView.right= animation.animatedValue as Int }
duration = 400L
interpolator = LinearInterpolator()
}
)
}
addListener(object : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {}
override fun onAnimationEnd(animation: Animator?) {
Toast.makeText(activity,"animation end",Toast.LENGTH_SHORT).show()
}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationStart(animation: Animator?) {}
})
start()
}
复制代码

从上往下读,层次分明。读起来可以感觉到:


构建动画集,它包含{
动画1
将动画1和动画2一起播放
将动画3在动画1之后播放
。。。
}
复制代码

(上面的代码均来自所引文章)


用好拓展函数


继续上面动画的例子接着说,可以看到,最后的Listener实际上我们只用了onAnimationEnd这一部分,但却写出了一大堆。这时候,拓展函数就起作用了。


幸运的是,Google官方的androidx.core:core-ktx已经有了对应的拓展函数:


public inline fun Animator.doOnEnd(
crossinline action: (animator: Animator) -> Unit
): Animator.AnimatorListener =
addListener(onEnd = action)


public inline fun Animator.addListener(
crossinline onEnd: (animator: Animator) -> Unit = {} ,
crossinline onStart: (animator: Animator) -> Unit = {} ,
crossinline onCancel: (animator: Animator) -> Unit = {} ,
crossinline onRepeat: (animator: Animator) -> Unit = {}
): Animator.AnimatorListener {
val listener = object : Animator.AnimatorListener {
override fun onAnimationRepeat(animator: Animator) = onRepeat(animator)
override fun onAnimationEnd(animator: Animator) = onEnd(animator)
override fun onAnimationCancel(animator: Animator) = onCancel(animator)
override fun onAnimationStart(animator: Animator) = onStart(animator)
}
addListener(listener)
return listener
}
复制代码

所以上面的最后几行addListener可以改成


doOnEnd { Toast.makeText(activity,"animation end", Toast.LENGTH_SHORT).show() } 
复制代码

是不是简单得多?


当然,弹出Toast似乎也很常用,所以再搞个拓展函数


inline fun Activity.toast(text: String, duration: Int = Toast.LENGTH_SHORT) 
= Toast.makeText(this, text, duration).show()
复制代码

上面的代码又可以改成这样


 (animation.) doOnEnd  { activity.toast("animation end") } 
复制代码

再比较下原来的


 (animation.) addListener(object : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {}
override fun onAnimationEnd(animation: Animator?) {
Toast.makeText(activity,"animation end",Toast.LENGTH_SHORT).show()
}
override fun onAnimationCancel(animation: Animator?) {}
override fun onAnimationStart(animation: Animator?) {}
})
复制代码

是不是简洁得多?


上面提到androidx.core:core-ktx,其实它包含了大量有用的拓展函数。如果花点时间了解了解,或许能优化不少地方。最近掘金上也有不少类似的文章,可以参考参考


juejin.cn/post/711504…


juejin.cn/post/711692…


juejin.cn/post/712171…


用好运算符重载


Kotlin的运算符重载其实很有用,举个栗子


给List添加值


我见过这种代码


val list = listOf(1)
val newList = listOf(1, 2, 3)

val mutableList = list.toMutableList() // 转成可变的
mutableList.addAll(newList) // 添加新的
return mutableList.toList() // 返回,改成不可变的
复制代码

但是换成运算符重载呢?


val list = listOf(1)
val newList = listOf(1, 2, 3)
return list + newList
复制代码

一个"+"号,简明扼要。


又比如,想判断


某个View是否在ViewGroup中


最简单的看看索引呗


val group = LinearLayout(this)
val isContain = group.indexOfChild(view) != -1
复制代码

不过,借助core-ktx提供的运算符,我们可以写出这样的代码


val group = LinearLayout(this)
val isContain = view in group
复制代码

语义上更直接


想添加(删除)一个View?除了addView(removeView),也可以直接"+="(-=)


val group = LinearLayout(activity)
group += view // 添加子View

group -= view // 移除子View
复制代码

想遍历?重载下iterator()运算符(core-ktx也写好了),就可以直接for了


val group = LinearLayout(this)
for (child in group) {
//执行操作
}
复制代码

(这几个View的例子基本也来自上面的文章)


此外,良好设计的拓展属性和拓展函数也能帮助写出更符合语意的代码,形如


// 设置view的大小
view.setSize(width = 50.dp, height = 100.dp)
// 设置文字大小
textView.setFontSize(18.sp)
复制代码

// 获取三天后的时间
val dueTime = today + 3.days
复制代码

// 获取文本的md5编码
val md5 = "FunnySaltyFish".md5
复制代码

上面的代码很容易能看出是要干嘛,而且也非常容易实现,此处就不再赘述了。


DSL


关于DSL,大家可能都知道有这么个东西,但可能用的都不多。但DSL若用得好,确实能达到化繁为简的功效。关于DSL的基本原理和实现,fundroid大佬在Kotlin DSL 实战:像 Compose 一样写代码 - 掘金中已经写得非常清晰了,本人就不再画蛇添足,接下来仅谈谈可能的使用吧。


构建UI


DSL的一个广泛应用应该就是构建UI了。


Anko(已过时)


较早的时候,一个比较广泛的应用可能就是之前的anko库了。JetBrains推出的这个库允许我们能够不用xml写布局。放一个来自博客Kotlin之小试Anko(Anko库的导入及使用) - SoClear - 博客园的例子


private fun showCustomerLayout() {
verticalLayout {
padding = dip(30)
editText {
hint = "Name"
textSize = 24f
}.textChangedListener {
onTextChanged { str, _, _, _ ->
println(str)
}
}
editText {
hint = "Password"
textSize = 24f
}.textChangedListener {
onTextChanged { str, _, _, _ ->
println(str)
}
}
button("跳转到其它界面") {
textSize = 26f
id = BTN_ID
onClick {
// 界面跳转并携带参数
startActivity<IntentActivity>("name" to "小明", "age" to 12)
}
}

button("显示对话框") {
onClick {
makeAndShowDialog()
}
}
button("列表selector") {
onClick {
makeAndShowListSelector()
}
}
}
}

private fun makeAndShowListSelector() {
val countries = listOf("Russia", "USA", "England", "Australia")
selector("Where are you from", countries) { ds, i ->
toast("So you're living in ${countries[i]},right?")
}
}

private fun makeAndShowDialog() {
alert("this is the msg") {
customTitle {
verticalLayout {
imageView(R.mipmap.ic_launcher)
editText {
hint = "hint_title"
}
}
}

okButton {
toast("button-ok")
// 会自行关闭不需要我们手动调用
}
cancelButton {
toast("button-cancel")
}
}.show()
}
复制代码

简洁优雅,而且由于是Kotlin代码生成的,还省去了解析xml的消耗。不过,由于“现在有更好的选择”,Anko官方已经停止维护此库;而被推荐的、用于取而代之的两个库分别是:Views DSLJetpack Compose


Views DSL


关于这个库,Anko官方在推荐时说,它是“An extensible View DSL which resembles Anko.”。二者也确实很相像,但Views DSL在Anko之上提供了更高的拓展性、对AppCompat的支持、对Material的支持,甚至提供了直接预览kt布局的能力!



基本的使用可以看看上图,额外的感兴趣的大家可以去官网查看,此处就不多赘述。


\


Jetpack Compose


作为一个用Compose超过一年的萌新,我自己是十分喜欢这个框架的。但同时,目前(2022-07-25)Compose的基建确实还尚不完善,所以对企业项目来说还,是应该充分评估后再考虑。但我仍然推荐你尝试一下,因为它简单、易用。即使是在现有的View项目中,也能无缝嵌入部分Compose代码;反之亦然。


Talk is cheap, show me your code. 比如要实现一个列表,View项目(使用RecyclerView)需要xml+Adapter+ViewHolder。而Compose就简洁得多:


LazyColumn(Modifier.fillMaxSize()) {
items(10) { i ->
Text(text = "Item $i", modifier = Modifier
.fillMaxWidth()
.clickable {
context.toast("点击事件")
}
.padding(8.dp), style = MaterialTheme.typography.h4)
}
}
复制代码

上面的代码创造了一个全屏的列表,并且添加了10个子项。每个item是一个文本,并且简单设置了其样式和点击事件。即使是完全不懂Compose,阅读代码也不难猜到各项的含义。运行起来,效果如下:



构建复杂的“字符串”


拼接字符串是一项常见的工作,不过,当它复杂起来但又有一定结构时,简单的"+"或者模板字符串看起来就有些杂乱了。这时,DSL就能很优雅的解决这个任务。


举几个常见的例子吧:


Html


使用DSL,能够写出类似这样的代码


val htmlText = buildHtml{
html{
body{
div("id" to "wrapper"){
p{ +"这是一个段落" }
repeat(3){ i ->
li{ +"Item ${i+1}" }
}
img("src" to "https://www.xxx.xxx/", "width" to "100px")
}
}
}
}
复制代码

上述代码会生成类似这样的html


<!DOCTYPE html>
<html lang="zh-CN">
<body>
<div id="wrapper">
<p>这是一个段落</p>
<ul>Item 1</ul>
<ul>Item 2</ul>
<ul>Item 3</ul>
<img src="https://www.xxx.xxx/" width="100px">
</div>
</body>
</html>
复制代码

简洁直接,而且不容易出错。


你可能比较疑惑上面的+"xxx"是个啥,其实这是用了运算符重载把String转成了纯文本Tag。代码可能类似于


open class Tag()
open class TextTag(val value: String) : Tag()
operator fun String.unaryPlus() = TextTag(this)
复制代码

Markdown


类似的,也可以用这种方式生成markdown。代码可能类似于


val markDownText = buildMarkdown {
text("我是")
link("FunnyFaltyFish", "https://github.com/FunnySaltyFish")
newline()
bold("很高兴见到你~")
}
复制代码

生成的文本类似于


我是 [FunnySaltyFish](https://github.com/FunnySaltyFish)  
** 很高兴见到你~ **
复制代码

SpannableString


对Android开发者来说,这个东西估计更常见。但传统的构造方式可以说够复杂的,所以DSL也能用。好的是,Google已经在core-ktx里写好了更简便的方法


使用例子如下:


val build = buildSpannedString {
backgroundColor(Color.YELLOW) {
append("我叫")
bold {
append("FunnySaltyFish")
}
append(",是一名学生")
}
}
复制代码

渲染出的效果如下


image.png


待续


本文应该还没有完,不过貌似写着写着也不短了,所以就先发了吧(主要是再晚些就赶不上征稿了 (笑))。后面我还想聊聊kotlin的代理、协程、Collection……争取下次见!


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

Android 平台 Native Crash 问题分析与定位

一 Native Crash 简介 Native Crash 是发生在 Android 系统中 C/C++ 层面的 Crash,具体可参考: # Android 平台 Native Crash 捕获原理详解 二 Native C/C++ Libraries 简...
继续阅读 »

一 Native Crash 简介


Native Crash 是发生在 Android 系统中 C/C++ 层面的 Crash,具体可参考: # Android 平台 Native Crash 捕获原理详解


二 Native C/C++ Libraries 简介


Android 开发中通常是将 Native 层代码打包为.so格式的动态库文件,然后供 Java 层调用,.so库文件通常有以下三种来源:



  • Android 系统自带的核心组件和服务,如多媒体库、OpenGL ES 图形库等

  • 引入的第三方库

  • 开发者自行编译生成的动态库


2.1 .so文件组成


一个完整的 .so 文件由 C/C++代码和一些 debug 信息组成,这些 debug 信息会记录 .so中所有方法的对照表,就是方法名和其偏移地址的对应表,也叫做符号表 symbolic 信息,这种 .so被称为未 strip 的,通常体积会比较大。



通常 release 的.so都是需要经过 strip 操作,strip 之后的.so中的 debug 信息会被剥离,整个 so 的体积也会缩小许多。


可以简单将这个 debug 信息理解为 Java 代码混淆中的 mapping 文件,只有拥有这个 mapping 文件才能进行堆栈分析。如果堆栈信息丢了,基本上堆栈无法还原,问题也无法解决。


所以,这些 debug 信息尤为重要,是我们分析 Native Crash 问题的关键信息,那么我们在编译 .so 时 候务必保留一份未被 strip 的.so或者剥离后的符号表信息,以供后面问题分析。


2.2 查看 so 状态


也可以通过命令行来查看.so的状态,Linux 下使用 file 命令即可,在命令返回值里面可以查看到.so的一 些基本信息。


如下代码所示,stripped 代表是没有 debug 信息的.so,with debug_info, not stripped 代表携带 debug 信息的.so


file libbreakpad-core-s.so
libbreakpad-core-s.so: *******, BuildID[sha1]=54ad86d708f4dc0926ad220b098d2a9e71da235a, stripped
file libbreakpad-core.so
libbreakpad-core.so: ******, BuildID[sha1]=54ad86d708f4dc0926ad220b098d2a9e71da235a, with debug_info, not stripped
复制代码

2.3 获取 strip 和未被 strip 的 so


目前 Android Studio 无论是使用 mk 或者 Cmake 编译的方式都会同时输出 strip 和未 strip 的 so,如下图是 Cmake 编译 so 产生的两个对应的 so。




strip 之前的 so 路径:{project}/app/build/intermediates/merged_native_libs


strip 之后的 so 路径:{project}/app/build/intermediates/stripped_native_libs


三 Native Crash 捕获与解析


3.1 通过 DropBox 日志解析


Android Dropbox 是 Android 在 Froyo(API level 8) 引入的用来持续化存储系统数据的机制。主要用于记录 Android 运行过程中, 内核, 系统进程, 用户进程等出现严重问题时的 log, 可以认为这是一个可持续存储的系统级别的 logcat。


相关文件记录存储目录:/data/system/dropbox


只需要将 DropBox 的日志获取到即可进行分析解决,下面贴上一份 Log 示例。


DropBox 中的 Tombstone 文件显示,Native Crash 发生在动态库 libnativedemo.so 中,具体的方法和行数可以用 Android/SDK/NDK 提供的工具 linux-android-addr2line 来进一步定位。


addr2line 工具通常在 ndk 目录下,例如:


${SDK Path}/ndk/21.4.7075529/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin/aarch64-linux-android-addr2line
复制代码

然后使用命令行,既可将偏移地址转换为 crash 方法和行数


arm-linux-androideabi-addr2line [option(s)] [addr(s)]
复制代码

简单来说就是 arm-linux-androideabi-addr2line + 可选项 + 异常地址



























































[option(s)]介绍
@从文件中读取 options
-a在结果中显示地址 addr
-b设置二进制文件的格式
-e设置输入文件(常用:选项后面需要跟报错的共享库,用于 addr2line 程序分析)
-iunwind inline function
-jRead section-relative offsets instead of addresses
-p让输出更易读
-s在输出中,剥离文件夹名称
-f显示函数名称
-C(大写的) 将输出的函数名 demangle
-h输出帮助
-v输出版本信息

使用 addr2line 进行解析,结果可以看到,Native Crash 发生在文件 native-lib.cpp17 行的 Crash() 方法


结合代码分析,在 Crash() 中,对空指针 *a 进行了赋值操作,所以造成了 crash。


#include <jni.h>
#include <string>

extern "C" JNIEXPORT jstring JNICALL
Java_com_elijah_nativedemo_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}

/**
* 引起 crash
*/
void Crash() {
volatile int *a = (int *) (NULL);
*a = 1;
}

extern "C"
JNIEXPORT jstring JNICALL
Java_com_elijah_nativedemo_MainActivity_nativeCrash(JNIEnv *env, jobject thiz) {
Crash();
}
复制代码

通过读取 DropBox 获得 crash log -> addr2line 解析偏移地址的方法确实可以定位到 native crash 发生的现场,但是 DropBox 只有系统应用能访问,非系统应用拿不到日志。对于非系统应用,可以使用 google 提供的开源工具 BreakPad 进行监测分析。


3.2 通过 BreakPad 捕获解析


3.2.1 breakpad 简介


BreakPad 是 Google 开发的一个跨平台 C/C++ dump捕获开源库,崩溃文件使用微软的 minidump格式存储,也支持发送这个 dump 文件到你的服务器,breakpad 可以在程序崩溃时触发 dump 写入操作,也可以在没有触发 dump 时主动写 dump 文件。breakpad 支持 windows、linux、macos、android、ios 等。目前已有 Google Chrome, Firefox, Google Picasa, Camino, Google Earth 等项目使用。


3.2.2 实现原理


在不同平台下使用平台特有的函数以及方式实现异常捕获:


Windows:通过 SetUnhandledExceptionFilter()设置崩溃回掉函数


Max OS:监听 Mach Exception Port 获取崩溃事件


Linux:监听 SIGILL SIGSEGV 等异常信号 获取崩溃事件


工作原理示意图


图片右上角是一个完整的应用程序,它包含了三部分即程序代码、Breakpad Client(即 brekapad 提供出来的静态库),调式信息




  • Build System中 breakpad 的 symbol 生成工具借助应用层序中的 Debugging Information 这一部分生成一个 Google 自己的符号文件,最终在发布应用层序的时候使用 strip 将调式信息去除




  • User's System中运行的应用程序是通过 strip 去除了调式信息的,若应用程序发生 Crash,Breakpad client 就会写 minidump 文件到指定目录,也可以将产生的 minidump 文件发送到远端服务器即 Crash Colletcor。




  • Crash Collector就可以利用 Build System 中产生的 symol 文件和 User's System 中上报的 minidump 文件生成用户可读的 stack trace




3.2.3 使用示例


获取 breakpad 源码


github.com/google/brea…


执行安装 breakpad


1. cd breakpad 目录
2. 直接命令窗口输入:

./configure && make
复制代码

移植 Breakpad 到客户端程序


breakpad 源码导入应用程序 cpp 目录下



然后在 breakpad 中创建 CMakeLists.txt


cmake_minimum_required(VERSION 3.18.1)
 
#导入头文件
include_directories(src src/common/android/include)
#支持汇编文件的编译
enable_language(ASM)
#源文件编译为静态库
add_library(breakpad STATIC
        src/client/linux/crash_generation/crash_generation_client.cc
        src/client/linux/dump_writer_common/thread_info.cc
        src/client/linux/dump_writer_common/ucontext_reader.cc
        src/client/linux/handler/exception_handler.cc
        src/client/linux/handler/minidump_descriptor.cc
        src/client/linux/log/log.cc
        src/client/linux/microdump_writer/microdump_writer.cc
        src/client/linux/minidump_writer/linux_dumper.cc
        src/client/linux/minidump_writer/linux_ptrace_dumper.cc
        src/client/linux/minidump_writer/minidump_writer.cc
        src/client/linux/minidump_writer/pe_file.cc
        src/client/minidump_file_writer.cc
        src/common/convert_UTF.cc
        src/common/md5.cc
        src/common/string_conversion.cc
        src/common/linux/breakpad_getcontext.S
        src/common/linux/elfutils.cc
        src/common/linux/file_id.cc
        src/common/linux/guid_creator.cc
        src/common/linux/linux_libc_support.cc
        src/common/linux/memory_mapped_file.cc
        src/common/linux/safe_readlink.cc)
#导入相关的库
target_link_libraries(breakpad log)
复制代码

breakpad 中的 CMakeLists.txt 创建完成后,还需要在 cpp 目录下的 CMakeLists.txt 中进行配置,将刚刚创建的 CMakeLists.txt 引入进去


cmake_minimum_required(VERSION 3.18.1)
 
#引入头文件
include_directories(breakpad/src breakpad/src/common/android/include)
 
add_library(nativecrash SHARED nativecrashlib.cpp)
 
#添加子目录,会自动查找这个目录下的 CMakeList
add_subdirectory(breakpad)
 
target_link_libraries(nativecrash log breakpad)
复制代码

breakpad 初始化


然后在自己项目的 native 文件中对 breakpad 进行初始化,如下


#include <jni.h>
#include <string>
#include "breakpad/src/client/linux/handler/exception_handler.h"
#include "breakpad/src/client/linux/handler/minidump_descriptor.h"

/**
* 引起 crash
*/
void Crash() {
volatile int *a = (int *) (NULL);
*a = 1;
}

extern "C"
JNIEXPORT void JNICALL
Java_com_elijah_nativedemo_MainActivity_nativeCrash(JNIEnv *env, jobject thiz) {
Crash();
}

//回调函数
bool DumpCallback(const google_breakpad::MinidumpDescriptor& descriptor,
void* context,
bool succeeded) {
printf("Dump path: %s\n", descriptor.path());
return false;
}

//breakpad 初始化
extern "C"
JNIEXPORT void JNICALL
Java_com_elijah_nativedemo_MainActivity_initNative(JNIEnv *env, jclass clazz, jstring path_) {
const char *path = env->GetStringUTFChars(path_, 0);
google_breakpad::MinidumpDescriptor descriptor(path);
static google_breakpad::ExceptionHandler eh(descriptor, NULL, DumpCallback,
NULL, true, -1);
env->ReleaseStringUTFChars(path_, path);
}
复制代码

Java 层代码


Java 层传入 Crash dump 文件的保存路径,用于崩溃时文件的生成


package com.elijah.nativedemo;

import androidx.appcompat.app.AppCompatActivity;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import java.io.File;

public class MainActivity extends AppCompatActivity {

static {
System.loadLibrary("nativedemo");
}

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
init(this);
findViewById(R.id.crash)
.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View view) {
nativeCrash();
}
});
}

public static void init(Context context){
Context applicationContext = context.getApplicationContext();
File file = new File(applicationContext.getExternalCacheDir(),"native_crash");
if(!file.exists()){
file.mkdirs();
}
initNative(file.getAbsolutePath());
}

/**
* 模拟崩溃
*/
public static native void nativeCrash();

/**
* 初始化 breakpad
* @param path
*/
private static native void initNative(String path);
}
复制代码

捕获 Crash,解析 dump


Native Crash 产生后,breakpad 会捕获 crash 信息,生成后缀为.dmp的 dump 文件到指定目录下。


.dmp 格式的文件通常无法查看,需要解析工具对这个文件进行解析。解析工具在步骤“执行安装 breakpad”中就已经生成在 breakpad/src/processor目录下,名为 minidump_stackwalk


输入如下指令即可解析 dump 文件


./minidump_stackwalk my.dump > crash.txt
复制代码

生成的 crash.txt 如下图所示,关键代码是红框的部分,Thread 0 后面有一个 crashed 标识,说明这里是发生崩溃的线程,而下面就是崩溃的文件以及内存地址,使用 3.1 中介绍的 addr2line 工具进行解析即可得到问题方法与行号


参考文献


Android NativeCrash 捕获与解析


Android---Native层崩溃的监听工具BreakPad


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

关于标准 MVVM 设计模式在 Android 中应用的思考

本来这篇文章很早就应该写的,一直没(比)有(较)时(懒)间 今天决定把它写完咯 首先表明态度, I think: 网上流传的 ViewModel + LiveData + XXX 的号称 MVVM 的代码设计基本都是假的(fake news :P) MV...
继续阅读 »

本来这篇文章很早就应该写的,一直没(比)有(较)时(懒)间

今天决定把它写完咯



首先表明态度, I think:

网上流传的 ViewModel + LiveData + XXX 的号称 MVVM 的代码设计基本都是假的(fake news :P)




MVVM


首先说一下 MVVM 这种架构模式(或者说设计模式),我对它的认识源于维基百科


mvvm_pattern


我们来看看关于 MVVM 各组件的标准定义



  • model: 没啥好说的,跟 MVP MVC 之流大差不差,定义了业务(数据)逻辑和数据的模型

  • view: 还是没啥好说的,就是视图层,用户界面

  • view-model: 关键在这里,我们所谓的 view-model 其实是从 presenter 胶水演化出来的,暴露视图的公开属性和指令,本质上就是 view 视图对应的一个 model

    • 首先,它是一个 view model,对 model 层暴露来自 view 层的一些公开属性,以及来自 view 层的一些指令(presenter?)

    • And,binder:
      view-model 区别于 presentercontroller,它有一个称之 binder 的东西,作用是处理 view-model 中暴露的视图属性(状态)与视图 UI 的自动同步




所以 MVVM 模式下的流转路径应该是这样的:



  • view: user input event -> view-model: view property or command

  • view-model: handle input, biz model -> model: biz data/logic processing

  • model: state chagne events(data) -> view-model: handle biz state

  • view-model: ui state update -> binder: handle ui state synching


MVP


ok,我们再来看一下 MVP 是怎么流转的


mvp_mode



  • view: User input event -> presenter: function (command)

  • presenter: biz model -> model: biz data/logic processing

  • model: state change events(data) -> presenter: convert biz state

  • presenter: biz state/data -> view: refresh ui


仔细对比一下往上流传的基于 Jetpack ViewModel + LiveData 的伪 MVVM(MVP)的流转情况:



  1. Activity/Fragemnt -> ViewModel, 这是 view -> presenter

  2. ViewModel 调用 model 处理网络请求、数据逻辑、文件 IO 等业务逻辑,这是 presenter -> model

  3. 这里我们看一下在 ViewModel 中完成了业务逻辑通知 UI 刷新,通过 LiveDatasetValue/postValue 更新状态,

    view 层通过 viewModel.xxxLiveData.observe(lifecycleOwner) { data -> … }


在最后一个环节,我们对比一下经典的 MVP 模式的写法:presenter 通过持有的 view 接口通知视图变更,view 层在对应的接口实现中完成对 UI 组件的更新


看 ~ 发现了什么

即使是通过 LiveData 观察者模式在 view 层实现对数据的观察,省去了经典 MVP 写法的 view 接口定义和耦合,但是在事件(数据)流转的路径上,依然是走的 MVP 的模式


对比 MVVM 定义的工作流程,不难发现,其中最大的差异在于 binder 这个角色的存在

binder 作为实现数据和 UI 同步的重要组件,同时按照 MVVM 模式的定义,属于 view-model 的内部成员


因此可以得出结论:MVVM 的关键在于,用户事件的流转是单向,从 view 层开始,到 view-model 结束;而这其中的关键在于 binder


Jetpack data-binding


Databinding 就是 Google 爸爸为我们提供的一个官方 binder 实现方案


即:



  • MVVM 中的 binder 可以直接使用官方 data-binding 组件来实现

  • 不使用 data-binding 组件,自定义处理数据与 UI 的同步的 binder 并在 view-model 中维护,也是规范的 MVVM 写法


DataBinding 分为两个部分


ViewDataBinding:每个被 <layout> 标签包裹的布局都会对应生成一个 ViewDataBinding 的子类作为视图与数据绑定的管理者
Observable/BaseObservable: 实际的 binder 开发接口,需要绑定与 view 层建立绑定关系的数据通过实现此接口并注册成员,即可自动完成监听与同步(实际代码在生成的 XxxLayoutBindingImpl 类中)


关于 DataBinding 的使用及原理,此处不予赘述


MVVM 在实际项目中的落地


sample Activity


class SampleBindingActivity : AppCompatActivity(), ActivityBindingHolder<SampleActivityBinding> by ActivityBinding(R.layout.sample_activity) {

private val viewModel: SampleViewModel by viewModel()

override fun onCreate(savedInstanceState: Bundle?) {
// replace setContentView(), and hold binding instance
inflateBinding { binding ->
// init with binding
binding.initView()

viewModel.bind(binding)
}

}

private fun SampleActivityBinding.initView() {
val random = Random()
btnTest.onClick {
viewModel.random = random.nextInt(100)
}
}
}

sample layout


<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="binder"
type="package.SampleBinder" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/tv_nickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:text="@{binder.nickname}"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="@id/channel_name"
app:layout_constraintStart_toStartOf="@id/program_vip" />

<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btn_test"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_nickname" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

sample view-model


class SampleViewModel : ViewModel() {

var random: Int by ObservableProperty(0) { binder.nickname = "Nickname_$it" }

private val model = SampleModel() // biz handler model, network/data/io etc.
private val binder = SampleBinder() // binder for sync data and view state

fun bind(binding: ViewDataBinding) {
binding.setVariable(BR.binder, binder)
}


}

sample binder


class SampleBinder : BaseObservable() {

@get:Bindable
var nickname: String by observableField(BR.nickname, "Nickname")


}

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

Flutter【手势&绘制】模拟纸质书籍翻页

前言 今天继续探索绘制与手势的组合实践,之前在看电子书切换页面时会有一个模拟纸质书籍翻页效果,这是典型的绘制和手势的结合实现的效果,那么今天我们就用Flutter也实现这样的一个效果吧。 原理 大家可以找本书翻页到一半看下效果,从右下角翻到一半时,我们可以将可...
继续阅读 »

前言


今天继续探索绘制与手势的组合实践,之前在看电子书切换页面时会有一个模拟纸质书籍翻页效果,这是典型的绘制和手势的结合实现的效果,那么今天我们就用Flutter也实现这样的一个效果吧。


原理


大家可以找本书翻页到一半看下效果,从右下角翻到一半时,我们可以将可视区域分为下图ABC三部分区域。


image.png

A:下一页可视区域。

B:当前页不可视区域,翻的页不可见的区域。

C:当前页可视区域,也就是需要翻的页的可视区域。


原理分解:


我们可以先将A区域和B区域合为一个区域计算,那么根据路径联合C区域自然就可以得到,至于A、B区域区分后面再讲,看下图:

image.png

a为手指触摸点,表示翻页右下角位置。【已知】

f为固定书籍右下角位置。【已知】


a点和f已知,连接af,我们令g点为af的中点,过g点连接eh垂直af,为af中垂线, 可得 g = Point((a.x + f.x) / 2, (a.y + f.y) / 2);


并且知道△egf△emg△mfg为三个直角三角形,由直角三角形相似原理可知这三个三角型两两相似,所以,△emg相似△mfg,可知:

em/gm = gm/mf;

em = gm*gm/mf;

因为:gm = f.y-a.y; mf=f.x-g.x;

可得 e = Point(g.x - (pow(f.y - g.y, 2) / (f.x - g.x)), f.y);


同理过g点做fh垂直线可得h点坐标。略...


从上方理论图可知,cdb是一条二阶贝塞尔曲线,控制点为e点, abak为直线线段,接下来我们令nag的中点,同理过n点垂直于af连接cj,可知ce等于ef的一半;(可以画辅助线过gf中点垂直af得出)。

所以可得 c = Point(e.x - (f.x - e.x) / 2, f.y);

j点坐标同理。略...


接下来我们看下b点,目前我们已知 aecj点坐标,现在b点就是aecj的相交点。


那么问题来了:

用我们九年义务教育学的数学知识解决以下两个问题。


1、在坐标系中,已知两点(x1,y1)、(x2,y2)坐标,求过这两点直线函数?


2、已知两条直线函数求两条直线的相交点?


我们知道直线函数表达式为:y=kx+b;,假设k为正常值,我们可求得kb的值,


/// 两点求直线方程
static double towPointKb(Point p1, Point p2,
{bool isK = true})
{
/// 求得两点斜率
double k = 0;
double b = 0;
// 防止除数 = 0 出现的计算错误 a e x轴重合
if (p1.x == p2.x) {
// k 为无穷大 函数表达式变为 x= 常量。
k = (p1.y - p2.y) / (p1.x - p2.x-1);
} else {
k = (p1.y - p2.y) / (p1.x - p2.x);
}
b = p1.y - k * p1.x;
if (isK)
return k;
else
return b;
}

通过两条直线表达式的k值和b值,我们就可以求出两条直线是否平行、相交、重合等情况,若相交则可求出。


k相同b不同:平行无交点。

k相同b相同:重合。

k不同无论b相不相同,相交必有一交点。


那么就可得出b点坐标:(假设k永不相等)


b = Point((b2 - b1) / (k1 - k2), (b2 - b1) / (k1 - k2) * k1 + b1);

k点坐标同理。略...


绘制


以上AB区域的关键点已经全部得到了,我们将辅助线去掉将这些点连接起来看下效果。


image.png


得到AB区域的同时,我们间接的就得到了C区域,


// mPath 为书籍矩形区域
Path mPathC = Path.combine(PathOperation.reverseDifference, mPathAB, mPath);

接下来将AB区域进行区分,再回到上方,坐标图黄色线条部分,我们可以看到d点和i点坐标。

通过原理解析我们可知d点为pe的中点,而p点为cb的中点,那么就可以得出:

p.x = (e.x -c.x)/2; ,d.x = (e.x-p.x)/2;

p.y = (e.y -b.y)/2; ,d.y = (e.y-p.y)/2;


所以可得 d = Point(((c.x + b.x) / 2 + e.x) / 2, ((c.y + b.y) / 2 + e.y) / 2);

i点坐标同理。略...
接下来我们连接dai三角形区域,得到以下图形,
image.png


同理通过路径联合我们就可以将AB区域进行分开,


Path mPath1 = Path();
mPath1.moveTo(p.value.d.x, p.value.d.y);
mPath1.lineTo(p.value.a.x, p.value.a.y);
mPath1.lineTo(p.value.i.x, p.value.i.y);
mPath1.close();
Path mPathB = Path.combine(PathOperation.intersect, mPathAB, mPath1);

得到以下图形,


image.png


到这里梳理一下,目前我们A、B、C三个path路径区域已经全部得到,剩下的就是填充书籍颜色,接下来我们将画笔设置为填充不同颜色,通过手势不断变化a点坐标看下效果。


Jul-26-2022 14-47-57.gif


是不是有点翻书的意思了,这里有一个问题,书籍的左下角也就是c点坐标在我们翻页的过程中会跑到页面之外,一般书籍都是左侧装订,这里我们希望达到一个真实的翻页效果就需要将c点的x轴最小值设置为书籍最左侧0


image.png

这里涉及到相似图形的数学知识,手指触摸点是在不断变化的,当cx轴达到临界值固定的时候,我们需要重新计算a点坐标,
见下图,

image.png

a是我们真实的手指触碰的坐标,a1则为我们需要计算出来的触碰坐标,从上图可知,△acb相似△a1b1c1,并且acfd区域相似a1c1d1f,那么通过相似原理我们可以得到fb1/fc1 = fb/fc;


从而得到,fb1= fb * fc1/fc;,


已知:

fb = f.x - a.x;

fc1 = size.width;

fc = f.x-c.x;


同理 fd1/fd = fb1/fb; 得到,fd1 = fb1 * fd/fb; 即可得到a1点坐标。


计算代码:


double fc = f.x - cx;
double fa = f.x - a.x;

double bb1 = size.width * fa / fc;

double fd1 = f.y - a.y;
double fd = bb1 * fd1 / fa;

a1 = Point(f.x - bb1, f.y - fd);

这时候我们再来看下效果,


Jul-26-2022 14-45-21.gif


c点坐标被我们设定最小值为书籍最左侧,所以左侧不会被翻出区域,看起来更像真实的翻页效果。


添加阴影


我们可以在灯光下找本书翻页看下阴影效果,差不多是这个样子,这里我将阴影分为三个部分,A区域两个和C区域一个。


image.png


我们先添加A左区域的阴影,A左区域的阴影可以认为是从ha方向由h向a进行色值渐变,所以这里我们需要得到A左阴影区域左上角坐标点,也就是ha直线向外延伸固定数值的坐标。


image.png

可以理解为数学题表达:


已知ha直线方程式和a点坐标, 以a为圆心,画半径为r(r>0)的圆,


image.png


求:此圆和ha直线的相交的坐标。


设交点为坐标xy,可得 x²+y² =r²; y = kx+b;(k、b 、r)已知,最终我们得到一个一元二次方程。会解出两个坐标点,这里我们只需要往外延伸的坐标点就行,具体可以跟a点坐标判断得出,之后我们令double m1 = a.x-p1.x;double n1 = a.y-p1.y;


image.png


那么阴影外部曲线就可以用下方代码表示。


pyy1.moveTo(p.value.c.x - m1, p.value.c.y);
pyy1.quadraticBezierTo(p.value.e.x - m1, p.value.e.y - n1,
p.value.b.x - m1, p.value.b.y - n1);
pyy1.lineTo(p.value.p.x, p.value.p.y);
pyy1.lineTo(p.value.k.x, p.value.k.y);
pyy1.lineTo(p.value.f.x, p.value.f.y);
pyy1.close();

绘制出来看下效果

image.png

同理路径联合下:


Path startYY =
Path.combine(PathOperation.reverseDifference, mPathA, pyy1);

得到:

image.png

接下来通过设置画笔属性由a点向p1点进行渐变。


..shader = ui.Gradient.linear(
Offset(p.value.a.x, p.value.a.y),
Offset(p.value.p.x, p.value.p.y),
[Colors.black26, Colors.transparent]

效果:

image.png


这里我设置了由 black26,向透明渐变。延伸长度为10的效果,这里可以根据半径和色值调整影深。


A右同理,略...


效果:

image.png


接下来我们绘制C区域的阴影,C区域可以看到他是跟eh是平行的,那么我们连接c、j、h、e点,


// 右下
Path pr = Path();
pr.moveTo(p.value.c.x, p.value.c.y);
pr.lineTo(p.value.j.x, p.value.j.y);
pr.lineTo(p.value.h.x, p.value.h.y);
pr.lineTo(p.value.e.x, p.value.e.y);
pr.close();

得到下面效果:

image.png


继续与AB区域进行路径联合,


Path p1 = Path.combine(PathOperation.intersect, pr, mPathAB);

得到下面效果:


image.png


继续与B区域再次联合,


Path p2 = Path.combine(PathOperation.difference, p1, mPathB);

最终得到我们想要的阴影区域。


image.png


接下来就是跟A区域操作一样了,设置线性渐变色和渐变方向,这里渐变方向的坐标点我们为u点和g点,g点已知,主要求u点坐标,u点坐标为afdi直线的相交点。


image.png


通过两条直线方程求相交点,得到u点以后,设置渐变色和渐变方向。


核心代码:


// 右下
Path pc = Path();
pc.moveTo(p.value.c.x, p.value.c.y);
pc.lineTo(p.value.j.x, p.value.j.y);
pc.lineTo(p.value.h.x, p.value.h.y);
pc.lineTo(p.value.e.x, p.value.e.y);
pc.close();

Path p1 = Path.combine(PathOperation.intersect, pc, mPathA);
Path p2 = Path.combine(PathOperation.difference, p1, mPathB);

Offset u = Offset(
PaperPoint.toTwoPoint(p.value.a, p.value.f, p.value.d, p.value.i)
.x,
PaperPoint.toTwoPoint(p.value.a, p.value.f, p.value.d, p.value.i)
.y);
canvas.drawPath(
p2,
paint
..style = PaintingStyle.fill
..shader = ui.Gradient.linear(
u, Offset(p.value.g.x,p.value.g.y), [Colors.black26, Colors
.transparent]));

最后得到我们最终的效果。


image.png


这里阴影部分可能有些瑕疵,尤其上方a点坐标的处理有点生硬,但是没找到好的方式。以后有时间再优化。


翻页动画、回弹动画


目的: 我们希望可以滑动过程中页码可以自动翻过去,并且误触的情况下不要翻页。


这里我简单的判断当翻过去书籍宽度的3/1就理解为用户想翻页,当手势松开时自动翻过去;

当翻过去书籍宽度小于1/3,理解为用户误触并不想翻页,当手势松开自动回弹回去。


这里判断还可以根据用户滑动的速度进行判断,比如按下和松开之间的时间很快并且有想左滑动的距离,我们就可以判定用户想要翻页,不过这里就需要不断的调试优化达到一个比较理想的交互。


初始化动画


回弹动画,我们希望松开手指时,a点坐标回到和f点重合,这里我们需要在点击或移动的过程中保存当前手指触摸的坐标a


var move = d.localPosition;
// 临界值书籍以外区域 取消更新
if (move.dx >= size.width ||
move.dx < 0 ||
move.dy >= size.height ||
move.dy < 0) {
return;
}
currentA = Point(move.dx, move.dy);
...
if ((size.width - move.dx) / size.width > 1 / 3) {
isNext = false;
} else {
isNext = true;
}

然后通过动画将a点坐标置位f点;


Point currentA = Point(0, 0);
late AnimationController _controller = AnimationController(
vsync: this, duration: Duration(milliseconds: 800))
..addListener(() {
if (isNext) {
/// 不翻页 回到原始位置
_p.value = PaperPoint(
Point(
currentA.x + (size.width - currentA.x) * _controller.value,
currentA.y + (size.height - currentA.y) * _controller.value,
),
size);
} else {
/// 翻页
_p.value = PaperPoint(
Point(currentA.x - (currentA.x + size.width) * _controller.value,
currentA.y + (size.height - currentA.y) * _controller.value),
size);
}
});

翻页,我们希望a点坐标和(-f.x,f.y)重合,也就是f.x为负值,相当也我们书籍彻底翻过去,


这里需要注意的是当a.x<0时,也就是书籍左侧外面区域,这里需要将我们之前设定c值的最小值放开,否则无法彻底翻过去。


只有a.x>0才限制cx坐标点
if (a.x > 0) {
if (cx <= 0) {
// // 临界点
double fc = f.x - cx;
double fa = f.x - a.x;
double bb1 = size.width * fa / fc;
double fd1 = f.y - a.y;
double fd = bb1 * fd1 / fa;
a = Point(f.x - bb1, f.y - fd);
g = Point((a.x + f.x) / 2, (a.y + f.y) / 2);
e = Point(g.x - (pow((f - g).y, 2) / (f - g).x), f.y);
cx = 0;
}
}

ok,有了这些数据以后,我们看下效果。


Jul-26-2022 14-57-29.gif


填充内容


最后一步,填充内容,模拟书籍嘛,当然不能是这些纯色翻页了,上面我们有了A B C三个路径的区域,接下来就需要对书籍内容Widget进行裁剪,这里我们需要路径裁剪类ClipPath类,


// 裁剪的路径区域 默认组件的矩形区域
final CustomClipper? clipper;

const ClipPath({
Key? key,
this.clipper,
this.clipBehavior = Clip.antiAlias,
Widget? child,
}) : assert(clipBehavior != null),
super(key: key, child: child);


可以看到构造里有三个参数,除了子组件,clipBehavior是裁剪方式,可以设置抗锯齿等,clipper则是我们的核心裁剪方法,需要实现CustomClipper类里的Path getClip(Size size);方法。

通过它返回一个Path路径,即可将child进行自定义裁剪。


ok, 有了方法,接下来我们开始实现,首先我们将之前A区域的Path路径拿出来,裁剪当前页,通过Stack帧布局加载当前页和下一页内容,下一页内容永远在第一页内容下面,当翻过去动画结束时将下方页置位当前页,刷新第二页数据。


翻页动画结束当前页index+1;


if (status == AnimationStatus.completed) {
if (!isNext) {
setState(() {
currentIndex++;
});
}
}

填充内容布局代码:


// 定义电子书数据
List dataList = [
"第一页数据",
"第二页数据",
"第三页数据",
];

GestureDetector(
child: Stack(
children: [
currentIndex == dataList.length - 1
? SizedBox()
// 下一页
: ClipPath(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: size.width,
height: size.height,
child: Text(
dataList[currentIndex + 1],
style: TextStyle(fontSize: 20),
),
),
),
// // 当前页
ClipPath(
child: Container(
alignment: Alignment.center,
width: size.width,
height: size.height,
color: Colors.blue,
child: Text(
dataList[currentIndex],
style: TextStyle(fontSize: 20),
),
),
clipper: CurrentPaperClipPath(_p),
),

// 最上面只绘制B区域和阴影
CustomPaint(
size: size,
painter: _BookPainter(
_p,
),
),
],
),
onPanDown: (d) {
if (currentIndex == dataList.length - 1) {
ToastUtil.show("最后一页了");
return;
}
isNext = false;
var down = d.localPosition;
_p.value = PaperPoint(Point(down.dx, down.dy), size);
currentA = Point(down.dx, down.dy);
},
onPanUpdate: currentIndex == dataList.length - 1
? null
: (d) {
var move = d.localPosition;

// 临界值取消更新
if (move.dx >= size.width ||
move.dx < 0 ||
move.dy >= size.height ||
move.dy < 0) {
return;
}
currentA = Point(move.dx, move.dy);
_p.value = PaperPoint(Point(move.dx, move.dy), size);

if ((size.width - move.dx) / size.width > 1 / 3) {
isNext = false;
} else {
isNext = true;
}
},
onPanEnd: currentIndex == dataList.length - 1
? null
: (d) {
_controller.forward(
from: 0,
);
},
),



/// 当前页区域
class CurrentPaperClipPath extends CustomClipper {
ValueNotifier p;

CurrentPaperClipPath(
this.p,
) : super(reclip: p);

@override
Path getClip(Size size)
{
///书籍区域
Path mPath = Path();
mPath.addRect(Rect.fromCenter(
center: Offset(size.width / 2, size.height / 2),
width: size.width,
height: size.height));

Path mPathA = Path();
if (p.value.a != p.value.f && p.value.a.x > -size.width) {
print("当前页 ${p.value.a} ${p.value.f}");
mPathA.moveTo(p.value.c.x, p.value.c.y);
mPathA.quadraticBezierTo(
p.value.e.x, p.value.e.y, p.value.b.x, p.value.b.y);
mPathA.lineTo(p.value.a.x, p.value.a.y);
mPathA.lineTo(p.value.k.x, p.value.k.y);
mPathA.quadraticBezierTo(
p.value.h.x, p.value.h.y, p.value.j.x, p.value.j.y);
mPathA.lineTo(p.value.f.x, p.value.f.y);
mPathA.close();
Path mPathC =
Path.combine(PathOperation.reverseDifference, mPathA, mPath);
return mPathC;
}

return mPath;
}

@override
bool shouldReclip(covariant CurrentPaperClipPath oldClipper)
{
return p != oldClipper.p;
}
}

最终看下效果.


Jul-26-2022 15-05-31.gif


返回上一页


上面只有翻页,没有返回上一页,其实返回上一页也很简单,上面我们实现了回弹动画,这里只需要修改当前a点坐标为为书籍左侧外面,之后调用回弹动画,当前页面-1即可。非常简单。


ElevatedButton(
onPressed: () {
setState(() {
// 表示从页面左侧外面开始回弹
currentA = Point(-100, size.height - 100);
currentIndex--;
// 回弹动画
isNext = false;
});
// _p.value = PaperPoint(currentA, size);
_controller.forward(
from: 0,
);
},
child: Text("上一页"))

下面再看下最终效果:


Jul-26-2022 15-14-19.gif


这里示例只是简单的填充了一个Text文本,更多内容也是可以的,毕竟裁剪的是个Widget。


总结


翻页示例可以说是手势和绘制的典型结合,实现过程中也是踩了许多的坑,网上找了很多资料,并且实现原理上也用到了一些初中数学知识,总的来说,过程还是比较曲折的,本篇文章主要讲了我在实现的过程中的一个详细过程及思路,代码目前先不传了,毕竟现在还是有些小问题,后续有时间再优化吧,后续有时间也许会将他优化下,做成一个开源组件,ok,那本篇文章到这里就结束了,希望对你有所帮助~


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

Android | ViewModel源码分析

前言ViewMode 是我们日常开发中最常用的组件之一,也是实现 MVVM 模式不可缺少的一环,这篇文章将从使用到源码分析以及常见的一些知识点来分析一下 ViewModel了解 ViewModelViewModel 旨在注重生命周期的方式存储和管理界面的相关数...
继续阅读 »

前言

ViewMode 是我们日常开发中最常用的组件之一,也是实现 MVVM 模式不可缺少的一环,这篇文章将从使用到源码分析以及常见的一些知识点来分析一下 ViewModel

了解 ViewModel

ViewModel 旨在注重生命周期的方式存储和管理界面的相关数据,ViewModel 类可以再发生旋转等配置更改后继续留存。

一般 ViewModel 配合 LiveData / Flow 实现数据驱动,由于 Activity 存在因配置改变而重建的机制,就会造成页面的数据丢失,例如网络数据已经其他数据等,而 ViewModel 可以应对 Activity 应配置而改变的场景,再重建的过程中恢复数据,从而降低用户体验受损。

ViewModel 生命周期如下:

 ViewModel 随着 Activity 状态的改变而经历的生命周期。

上图说明了 Activity 经历屏幕旋转而后结束的各种生命周期状态,旁边显示的就是 ViewModel 的生命周期了。

ViewModel 的使用

class MyViewModel : ViewModel() {
private val users: MutableLiveData<List<User>> by lazy {
MutableLiveData<List<User>>().also {
loadUsers()
}
}

fun getUsers(): LiveData<List<User>> {
return users
}

private fun loadUsers() {
// Do an asynchronous operation to fetch users.
}
}
val model: MyViewModel by viewModels()
model.getUsers().observe(this, Observer<List<User>>{ users ->
// update UI
})

ViewModel 的创建方式

  • 方式1:通过 ViewModelProvider 创建

    ViewModelProvider(this).get(WorkViewModel::class.java)

    也可以使用带工厂的创建方式

    ViewModelProvider(this, WorkViewModelFactory()).get(WorkViewModel::class.java)

    class WorkViewModelFactory() : ViewModelProvider.Factory {

    private val repository = WorkRepository()

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
    return WorkViewModel(repository) as T
    }
    }
  • 方式2:使用 Kotlin by 委托属性,实际上也是使用了 ViewModelProvider

    private val viewModel by viewModels<UserViewModel>()
  • 方式3:使用 Hilt 进行注入

ViewModel 源码分析

Viewmodel 创建的方法最终都是通过 ViewModelProvider 来完成的,他可以理解为创建 ViewModel 的工具类,在创建的时候需要两个参数:

  • ViewModelStoreOwner

    对应着 Activity / Fragment 等持有 Viewmode 的宿主,他们内部通过 ViewModelStore 维持一个 ViewModel 的映射表,ViewModelStore 是实现 ViewModel 作用域和数据恢复的关键。

  • Factory

    对于于创建 ViewModel 的工厂,如果没有传采用默认的 NewInstanceFactory 工厂反射创建 VIewModel 的实例。

创建完 ViewModelProvider 工具类后,就可以调用 get 方法来创建 ViewModel 的实例。get 方法会先从映射表 ViewModelStore 中读取缓存,若没有命中,则通过 VIewModel 的工厂创建实例在缓存到映射表中。

ViewModelProvider

//使用默认的工厂创建 ViewModel
public ViewModelProvider(@NonNull ViewModelStoreOwner owner) {
this(owner.getViewModelStore(), ...NewInstanceFactory.getInstance());
}

//指定工厂
public ViewModelProvider(@NonNull ViewModelStoreOwner owner, @NonNull Factory factory) {
this(owner.getViewModelStore(), factory);
}
//记录宿主的 viewmodelStore 和 factory
public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) {
mFactory = factory;
mViewModelStore = store;
}
private static final String DEFAULT_KEY =
"androidx.lifecycle.ViewModelProvider.DefaultKey";

@NonNull
@MainThread
public <T extends ViewModel> T get(@NonNull Class<T> modelClass) {
String canonicalName = modelClass.getCanonicalName();
if (canonicalName == null) {
throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels");
}
//使用 Default_key + 类名作为缓存的 key
return get(DEFAULT_KEY + ":" + canonicalName, modelClass);
}

/** 通常是 fragment 使用*/
@SuppressWarnings("unchecked")
@NonNull
@MainThread
public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {
//先从 viewModelStore 中获取缓存
ViewModel viewModel = mViewModelStore.get(key);

if (modelClass.isInstance(viewModel)) {
if (mFactory instanceof OnRequeryFactory) {
((OnRequeryFactory) mFactory).onRequery(viewModel);
}
return (T) viewModel;
}
//使用 factory 创建 ViewModel
if (mFactory instanceof KeyedFactory) {
viewModel = ((KeyedFactory) mFactory).create(key, modelClass);
} else {
viewModel = mFactory.create(modelClass);
}
//存储到 viewModelStore 中
mViewModelStore.put(key, viewModel);
return (T) viewModel;
}

NewInstanceFactory

public static class NewInstanceFactory implements Factory {
private static NewInstanceFactory sInstance;

@NonNull
static NewInstanceFactory getInstance() {
if (sInstance == null) {
sInstance = new NewInstanceFactory();
}
return sInstance;
}


@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//反射创建 ViewModel
try {
return modelClass.newInstance();
}....
}
}

by viewModels

ActivityViewModelLazy

@MainThread
public inline fun <reified VM : ViewModel> ComponentActivity.viewModels(
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
defaultViewModelProviderFactory
}

return ViewModelLazy(VM::class, { viewModelStore }, factoryPromise)
}

ViewModelLazy

public class ViewModelLazy<VM : ViewModel> (
private val viewModelClass: KClass<VM>,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
private var cached: VM? = null

override val value: VM
get() {
val viewModel = cached
//如果第一次调用 by viewModels,则先初始化再返回
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
//最终是通过 ViewModelProvider 来创建
ViewModelProvider(store, factory).get(viewModelClass.java).also {
cached = it
}
} else {
//否则直接返回
viewModel
}
}

override fun isInitialized(): Boolean = cached != null
}

ViewModelStoreOwner

ViewModel 的宿主是 ViewModelStoreOwner 接口的实现类,例如 ComponentActivity,Fragment 等

public interface ViewModelStoreOwner {
@NonNull
ViewModelStore getViewModelStore();
}

该接口的实现的责任就是在配置期间保留拥有的 ViewModelStore,并在销毁的时候

此接口实现的责任是在配置更改期间保留拥有的 ViewModelStore 并在此范围将被销毁的时候调用 ViewModelStore.clear()

ComponentActivity
public class ComponentActivity extends androidx.core.app.ComponentActivity implements
ContextAware,
LifecycleOwner,
ViewModelStoreOwner.... {

static final class NonConfigurationInstances {
Object custom;
ViewModelStore viewModelStore;
}

//viewmodel 的存储容器
private ViewModelStore mViewModelStore;
//创建 viewmodel 的工厂
private ViewModelProvider.Factory mDefaultFactory;

@NonNull
@Override
public ViewModelStore getViewModelStore() {
//.....
ensureViewModelStore();
return mViewModelStore;
}

void ensureViewModelStore() {
if (mViewModelStore == null) {
//先从配置文件中获取,看能不能获取到
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
mViewModelStore = nc.viewModelStore;
}
//如果没有获取到则重新创建 ViewModelStore
if (mViewModelStore == null) {
mViewModelStore = new ViewModelStore();
}
}
}
//重建时保存 viewModelStore
// ViweModelStore 会被封装为 NonConfigurationInstances 类,然后保存在 NonConfigurationInstances 类的 Object activity 属性中。
//前一个 NonConfigurationInstances 是 ComponentActivity 中定义的,后一个是 Activity 类中定义的,不是同一个类,不要搞混了哟!
public final Object onRetainNonConfigurationInstance() {
Object custom = onRetainCustomNonConfigurationInstance();
ViewModelStore viewModelStore = mViewModelStore;
if (viewModelStore == null) {
NonConfigurationInstances nc =
(NonConfigurationInstances) getLastNonConfigurationInstance();
if (nc != null) {
viewModelStore = nc.viewModelStore;
}
}
if (viewModelStore == null && custom == null) {
return null;
}

NonConfigurationInstances nci = new NonConfigurationInstances();
nci.custom = custom;
nci.viewModelStore = viewModelStore;
return nci;
}
}

上面代码中的 NonConfigurationInstances 是一个配置文件的实例,当 activity 重建时,最终会调用到 onRetainNonConfigurationInstance() 方法中对 viewModelStore 进行缓存。所以上面才是先尝试从配置文件中获取,最后再创建新的 ViewModelStore。

Fragment
@NonNull
@Override
public ViewModelStore getViewModelStore() {
return mFragmentManager.getViewModelStore(this);
}
//fragment 中 ViewModel 的映射
private final HashMap<String, ViewModelStore> mViewModelStores = new HashMap<>();

@NonNull
ViewModelStore getViewModelStore(@NonNull Fragment f) {
return mNonConfig.getViewModelStore(f);
}

@NonNull
ViewModelStore getViewModelStore(@NonNull Fragment f) {
ViewModelStore viewModelStore = mViewModelStores.get(f.mWho);
if (viewModelStore == null) {
viewModelStore = new ViewModelStore();
mViewModelStores.put(f.mWho, viewModelStore);
}
return viewModelStore;
}

关于 ViewModel 的一些问题

  1. ViewModel 如何实现不同的作用域

    在使用 ViewModelProvider 时,需要传入一个 ViewModelStoreOwner 接口,这个接口的 getViewModelStore 会返回对应的 ViewModelStore 实例。

    对于 Activity 来说,ViewModelStore 是直接保存在成员变量中的。

    对于 Fragment 来说, ViewModelstore 是间接的存储在 FragmentManagerViewModel 中的 map 中。

    这样就实现了不同的 activity 或者 fragment 分别对应不同的 ViewModelStore 实例,进而区分不同的作用域

  2. 为什么 Activity 可以再重建后恢复 viewMdoel

    当 Activity 因为配置而发生重建时,我们可以将页面上的数据分为两类:

    1. 配置数据,例如窗口大小,主题资源等,当配置发生改变后,需要重新读取这些配置,因此这些数据在配置改变后就失去了意义,也就没有存在的价值

    2. 非配置数据,这些数据就是一些用户自己的信息,以及页面上显示的数据,这些数据和配置没有关系,如果丢失掉就会造成比较大的用户体验。

    说以,Activity 再重建时支持恢复非配置的数据,整个过程如下:

    1. 重建时保存数据

      Activity 再重建时会调用 retainNonConfigurationInstances 方法,在里面会获取需要保存的数据,例如 fragment ,activity 等数据,最后打包为 NonConfigurationInstances 类,保存在 ActivityClientRecord 中。

      NonConfigurationInstances retainNonConfigurationInstances() {
      //activity 中的非配置数据,例如 viewmodelStore
      //该方法需要子类实现 ,例如 ComponentActivity
      Object activity = onRetainNonConfigurationInstance();
      //fragment 中的非配置数据
      FragmentManagerNonConfig fragments = mFragments.retainNestedNonConfig();
      // .....
      //构建 NonConfigurationInstances
      NonConfigurationInstances nci = new NonConfigurationInstances();
      nci.activity = activity;
      nci.fragments = fragments;
      return nci;
      }
    2. 恢复数据

      Activity 重新启动的最后,会通过 ActivityThread 中的 performLaunchActivity() 来完成整个启动过程,再这个方法中会通过类加载器来创建 Activity 对象,并调用 attach 方法为其关联所需要的一些信息。

      我们需要关注的就是 attach 方法:

      final void attach(NonConfigurationInstances lastNonConfigurationInstances //.. ) {
      //.....
      mLastNonConfigurationInstances = lastNonConfigurationInstances;
      }

      最后,数据被保存在了 Activity . mLastNonConfigurationInstances 成员变量中。

    3. 获取数据

      这个我们之前已经分析过了,我们简单回顾一下

      void ensureViewModelStore() {
      if (mViewModelStore == null) {
      //获取之前保存的数据
      NonConfigurationInstances nc =
      (NonConfigurationInstances) getLastNonConfigurationInstance();
      if (nc != null) {
      // Restore the ViewModelStore from NonConfigurationInstances
      mViewModelStore = nc.viewModelStore;
      }
      if (mViewModelStore == null) {
      mViewModelStore = new ViewModelStore();
      }
      }
      }

      @Nullable
      public Object getLastNonConfigurationInstance() {
      //mLastNonConfigurationInstances 就是 attach 中保存的
      return mLastNonConfigurationInstances != null
      ? mLastNonConfigurationInstances.activity : null;
      }

    至此,就完成了 ViewModel 的数据恢复了。

  3. Activity 重建的过程

    再 Activity 重建时,系统会执行 Relaunch 重建过程。在这个过程中通过 ActivityClientRecord 来完成信息传递,并销毁 Activity,紧接着马上重建同一个 Activity。

    这些操作都是在 ActivityThread 中完成的:

    private void handleRelaunchActivityInner(ActivityClientRecord r //...) {
    final Intent customIntent = r.activity.mIntent;
    //处理 onPause
    performPauseActivity(r, false, reason, null /* pendingActions */);
    //处理 onStop
    callActivityOnStop(r, true /* saveState */, reason);
    //1
    handleDestroyActivity(r.token, false, configChanges, true, reason);
    //2
    handleLaunchActivity(r, pendingActions, customIntent);
    }
    //1
    public void handleDestroyActivity(IBinder token, boolean finishing,//...) {
    ActivityClientRecord r = performDestroyActivity(token, finishing,
    configChanges, getNonConfigInstance, reason);
    }
    ActivityClientRecord performDestroyActivity(IBinder token, boolean finishing,
    int configChanges, boolean getNonConfigInstance, String reason) {
    ActivityClientRecord r = mActivities.get(token);
    if (r != null) {
    if (getNonConfigInstance) {
    //调用 activity 的 retainNonConfigurationInstances 方法
    r.lastNonConfigurationInstances
    = r.activity.retainNonConfigurationInstances();
    }
    }
    return r;
    }
    //2
    public Activity handleLaunchActivity(ActivityClientRecord r,
    PendingTransactionActions pendingActions, Intent customIntent) {
    final Activity a = performLaunchActivity(r, customIntent);
    return a;
    }
    private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {

    activity = mInstrumentation.newActivity(
    cl, component.getClassName(), r.intent);
    //...
    //传递缓存数据以及其他数据
    activity.attach(appContext, this, getInstrumentation(), r.token,
    r.ident, app, r.intent, r.activityInfo, title, r.parent,
    r.embeddedID, r.lastNonConfigurationInstances, config,
    r.referrer, r.voiceInteractor, window, r.configCallback,
    r.assistToken);
    //....
    return activity;
    }

    上面的代码主要可以分为两部分:

    第一处:再处理 onDestory 逻辑时,调用 retainNonConfigurationInstances() 方法获取非配置数据,并临时保存在 ActivityClientRecord 上。

    第二处:再 Launch 新 activity 的时候通过 attach 方法将数据传到新 activity 中即可

    至此旧的 Activity 数据已经被传递到新的 Activity 中了。

  4. ViewModel 的数据在什么时候才会清除

    ViewModel 的数据会在 Activity 非配置变化销毁时清除,具体分为三种情况

    1. 直接调用 finish 或者按返回键退出
    2. 异常退出 Activity,例如内存不足
    3. 强制退出应用

    前两种都属于非配置变更触发的,再 Activity中存在一个 Lifecycle 的监听,当 Activity 进入 Destory 状态时,如果 Activity 不处于配置重建阶段,将调用 viewModelStore.clear() 清除 viewmodel 数据。

    public ComponentActivity() {
    Lifecycle lifecycle = getLifecycle();
    getLifecycle().addObserver(new LifecycleEventObserver() {
    @Override
    public void onStateChanged(@NonNull LifecycleOwner source,
    @NonNull Lifecycle.Event event) {
    if (event == Lifecycle.Event.ON_DESTROY) {
    // Clear out the available context
    mContextAwareHelper.clearAvailableContext();
    //是否处于配置变更引起的重建
    if (!isChangingConfigurations()) {
    getViewModelStore().clear();
    }
    }
    }
    });
    }
  5. ViewModel 和 onSaveInstanceState 对比

    这两种都是对数据恢复的机制,但是他们针对的场景不同,导致他们的实现原理也不同,进而优缺点也不同

    viewModel:使用常见针对于配置变更中的非配置数据恢复,由于数据是直接存储在内存中的,所以他的读取速度非常快,并且支持存储大数据,但是会收到内存空间的限制

    onSaveInstanceState:针对于应用被系统回收后重建时的数据恢复,由于应用进程坑会在这个过程中消亡,所以不能存在内存中,只能进行持久化存储,并且这种方式的数据传递是通过 Bundle 传递的,会受到 Binder 事务缓冲区的大小限制,只能存储小规模数据。

    这里借用一张大佬的图,来看一下具体的优缺点:

    https://juejin.cn/post/7121998366103306254#heading-12

总结

到这里,ViewModel 就整个分析完了,如果有任何问题可直接留言评论,谢谢!


作者:345丶
链接:https://juejin.cn/post/7124586548288290824
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

PermissionX 1.5发布,支持申请Android特殊权限啦

前言 Hello 大家早上好,说起 PermissionX,其实我已经有段时间没有更新这个框架了。一是因为现在工作确实比较忙,没有过去那么多的闲暇时间来写开源项目,二是因为,PermissionX 的主体功能已经相当稳定,并不需要频繁对其进行变更。 不过之前一...
继续阅读 »

前言


Hello 大家早上好,说起 PermissionX,其实我已经有段时间没有更新这个框架了。一是因为现在工作确实比较忙,没有过去那么多的闲暇时间来写开源项目,二是因为,PermissionX 的主体功能已经相当稳定,并不需要频繁对其进行变更。


不过之前一直有朋友在反映,对于 Android 中的一些特殊权限申请,PermissionX 并不支持。是的,PermissionX 本质上只是对 Android 运行时权限 API 进行了一层封装,用于简化运行时权限申请的。而这些特殊权限并不属于 Android 运行时权限的一部分,所以 PermissionX 自然也是不支持的。


但是特殊权限却是我们这些开发者们可能经常要与之打交道的一部分,它们并不难写,但是每次去写都感觉很繁琐。因此经慎重考虑之后,我决定将几个比较常用的特殊权限纳入 PermissionX 的支持范围。那么本篇文章我们就来看一看,对于这几个常见的特殊权限,使用 PermissionX 和不使用 PermissionX 的写法有什么不同之处。


事实上,Android 的权限机制也是经历过长久的迭代的。在 6.0 系统之前,Google 将权限机制设计的比较简单,你的应用程序需要用到什么权限,只需要在 AndroidManifest.xml 文件中声明一下就可以了。


但是从 6.0 系统开始,Android 引入了运行时权限机制。Android 将常用的权限大致归成了几类,一类是普通权限,一类是危险权限,一类是特殊权限。


普通权限指的是那些不会直接威胁到用户的安全和隐私的权限,这种权限和过去一样,只需要在 AndroidManifest.xml 文件中声明一下就可以了,不需要做任何特殊处理。


危险权限则表示那些可能会触及用户隐私或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等。这部分权限需要通过代码进行申请,并要用户手动同意才可获得授权。PermissionX 库主要就是处理的这种权限的申请。


而特殊权限则更加少见,Google 认为这种权限比危险权限还要敏感,因此不能仅仅让用户手动同意就可以获得授权,而是需要让用户到专门的设置页面去手动对某一个应用程序授权,该程序才能使用这个权限。


不过相比于危险权限,特殊权限没有非常固定的申请方式,每个特殊权限可能都要使用不同的写法才行,这也导致申请特殊权限比申请危险权限还要繁琐。


从 1.5.0 版本开始,PermissionX 对最常用的几个特殊权限进行了支持。正如刚才所说,特殊权限没有固定的申请方式,因此 PermissionX 也是针对于这几个特殊权限一个一个去适配并支持的。如果你发现你需要申请的某个特殊权限还没有被 PermissionX 支持,也可以向我提出需求,我会考虑在接下来的版本中加入。


在过去,我们发布开源库通常都是发布到 jcenter 上的,但是相信大家现在都已经知道了,jcenter 即将停止服务,具体可以参考我的这篇文章 浅谈 JCenter 即将被停止服务的事件


目前的 jcenter 处在一个半废弃的边缘,虽然还可以正常从 jcenter 下载开源库,但是已经不能再向 jcenter 发布新的开源库了。而在明年 2 月 1 号之后,下载服务也会被关停。


所以,以后要想再发布开源库我们只能选择发布到其他仓库,比如现在 Google 推荐我们使用 Maven Central。


于是,从 1.5.0 版本开始,PermissionX 也会将库发布到 Maven Center 上,之前的老版本由于迁移价值并不大,所以我也不想再耗费经历做迁移了。1.5.0 之前的版本仍然保留在 jcenter 上,提供下载服务直到明年的 2 月 1 号。


而关于如何将库发布到 Maven Central,请参考 再见 JCenter,将你的开源库发布到 MavenCentral 上吧


Android的特殊权限


Android 里具体有哪些特殊权限呢?


说实话,这个我也不太清楚。我所了解的特殊权限基本都是因为需要用到了,然后发现这个权限即不属于普通权限,也不属于危险权限,要用一种更加特殊的方式去申请,才知道原来这是一个特殊权限。


因此,PermissionX 1.5.0 版本中对特殊权限的支持,也就仅限于我知道的,以及从网友反馈得来的几个最为常用的特殊权限。


一共是以下 3 个:



  1. 悬浮窗

  2. 修改设置

  3. 管理外部存储


接下来我就分别针对这 3 个特殊权限做一下更加详细的介绍。


悬浮窗


悬浮窗功能在不少应用程序中使用得非常频繁,因为你可能总有一些内容是要置顶于其他内容之上显示的,这个时候用悬浮窗来实现就会非常方便。


当然,如果你只是在自己的应用内部实现悬浮窗功能是不需要申请权限的,但如果你的悬浮窗希望也能置顶于其他应用程序的上方,这就必须得要申请权限了。


悬浮窗的权限名叫做 SYSTEM_ALERT_WINDOW,如果你去查一下这个权限的文档,会发现这个权限的申请方式比较特殊:



按照文档上的说法,从 Android 6.0 系统开始,我们在使用 SYSTEM_ALERT_WINDOW 权限前需要发出一个 action 为 Settings.ACTION_MANAGE_OVERLAY_PERMISSION 的 Intent,引导用户手动授权。另外我们还可以通过 Settings.canDrawOverlays() 这个 API 来判断用户是否已经授权。


因此,想要申请悬浮窗权限,自然而然就可以写出以下代码:


if (Build.VERSION.SDK_INT >= 23) {
if (Settings.canDrawOverlays(context)) {
showFloatView()
} else {
val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION)
startActivity(intent)
}
} else {
showFloatView()
}

看上去也不复杂嘛。


确实,但是它麻烦的点主要在于,它的请求方式是脱离于一般运行时权限的请求方式的,因此得要为它额外编写独立的权限请求逻辑才行。


而 PermissionX 的目标就是要弱化这种独立的权限请求逻辑,减少差异化代码编写,争取使用同一套 API 来实现对特殊权限的请求。


如果你已经比较熟悉 PermissionX 的用法了,那么以下代码你一定不会陌生:


PermissionX.init(activity)
.permissions(Manifest.permission.SYSTEM_ALERT_WINDOW)
.onExplainRequestReason { scope, deniedList ->
val message = "PermissionX需要您同意以下权限才能正常使用"
scope.showRequestReasonDialog(deniedList, message, "Allow", "Deny")
}
.request { allGranted, grantedList, deniedList ->
if (allGranted) {
Toast.makeText(activity, "所有申请的权限都已通过", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(activity, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
}
}

可以看到,这就是最标准的 PermissionX 的正常用法,但是我们在这里却用来请求了悬浮窗权限。也就是说,即使是特殊权限,在 PermissionX 中也可以用普通的方式去处理。


另外不要忘记,所有申请的权限都必须在 AndroidManifest.xml 进行注册才行:


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.permissionx.app">

<uses-permission android: />

</manifest>

那么运行效果是什么样的呢?我们来看看吧:



可以看到,PermissionX 还自带了一个权限提示框,友好地告知用户我们需要悬浮窗权限,引导用户去手动开启。


修改设置


了解了悬浮窗权限的请求方式之后,接下来我们就可以快速过一下修改设置权限的请求方式了,因为它们的用法是完全一样的。


修改设置的权限名叫 WRITE_SETTINGS,如果我们去查看一下它的文档,你会发现它和刚才悬浮窗权限的文档简直如出一辙:



同样是从 Android 6.0 系统开始,在使用 WRITE_SETTINGS 权限前需要先发出一个 action 为 Settings.ACTION_MANAGE_WRITE_SETTINGS 的 Intent,引导用户手动授权。然后我们还可以通过 Settings.System.canWrite() 这个 API 来判断用户是否已经授权。


所以,如果是自己手动申请这个权限,相信你已经知道要怎么写了。


那么用 PermissionX 申请的话应该要怎么写呢?这个当然就更简单了,只需要把要申请的权限替换一下即可,其他部分都不用作修改:


PermissionX.init(activity)
.permissions(Manifest.permission.WRITE_SETTINGS)
...

当然,不要忘记在 AndroidManifest.xml 中注册权限:


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.permissionx.app">

<uses-permission android: />

</manifest>

运行一下,效果如下图所示:



管理外部存储


管理外部存储权限也是一种特殊权限,它可以允许你的 App 拥有对整个 SD 卡进行读写的权限。


有些朋友可能会问,SD 卡本来不就是可以全局读写的吗?为什么还要再申请这个权限?


那你一定是没有了解 Android 11 上的 Scoped Storage 功能。从 Android 11 开始,Android 系统强制启用了 Scoped Storage,所有 App 都不再拥有对 SD 卡进行全局读写的权限了。


关于 Scoped Storage 的更多内容,可以参考我的这篇文章 Android 11 新特性,Scoped Storage 又有了新花样


但是如果有的应用就是要对 SD 卡进行全局读写该怎么办呢(比如说文件浏览器)?


不用担心,Google 仍然还是给了我们一种解决方案,那就是请求管理外部存储权限。


这个权限是 Android 11 中新增的,为的就是应对这种特殊场景。


那么这个权限要怎么申请呢?我们还是先来看一看文档:



大致可以分为几步吧:


第一,在 AndroidManifest.xml 中声明 MANAGE_EXTERNAL_STORAGE 权限。


第二,发出一个 action 为 Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION 的 Intent,引导用户手动授权。


第三,调用 Environment.isExternalStorageManager() 来判断用户是否已授权。


传统请求权限的写法我就不再演示了,使用 PermissionX 来请求的写法仍然也还是差不多的。只不过要注意,因为 MANAGE_EXTERNAL_STORAGE 权限是 Android 11 系统新加入的,所以我们也只应该在 Android 11 以上系统去请求这个权限,代码如下所示:


if (Build.VERSION.SDK_INT >= 30) {
PermissionX.init(this)
.permissions(Manifest.permission.MANAGE_EXTERNAL_STORAGE)
...
}

AndroidManifest.xml 中的权限如下:


<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.permissionx.app">

<uses-permission android: />

</manifest>

运行一下程序,效果如下图所示:



这样我们就拥有全局读写 SD 卡的权限了。


另外 PermissionX 还有一个特别方便的地方,就是它可以一次性申请多个权限。假如我们想要同时申请悬浮窗权限和修改设置权限,只需要这样写就可以了:


PermissionX.init(activity)
.permissions(Manifest.permission.SYSTEM_ALERT_WINDOW, Manifest.permission.WRITE_SETTINGS)
...

运行效果如下图所示:



当然你也可以将特殊权限与普通运行时权限放在一起申请,PermissionX 对此也是支持的。只有当所有权限都请求结束时,PermissionX 才会将所有权限的请求结果一次性回调给开发者。


关于 PermissionX 新版本的内容变化就介绍到这里,升级的方式非常简单,修改一下 dependencies 当中的版本号即可:


repositories {
google()
mavenCentral()
}


dependencies {
implementation 'com.guolindev.permissionx:permissionx:1.5.0'
}

注意现在一定要使用 mavenCentral 仓库,而不能再使用 jcenter 了。


如果你对 PermissionX 的源码感兴趣,可以访问 PermissionX 的项目主页:


github.com/guolindev/P…


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

SDK无侵入初始化并获取Application

1.SDK无侵入初始化并获取Application 无侵入初始化SDK并获取Application的意思是不需要业务方手动调用SDK的初始化函数。 这个就得利用Android四大基本组件之一ContentProvider了,其执行的时机是位于Applicati...
继续阅读 »

1.SDK无侵入初始化并获取Application


无侵入初始化SDK并获取Application的意思是不需要业务方手动调用SDK的初始化函数。


这个就得利用Android四大基本组件之一ContentProvider了,其执行的时机是位于ApplicationattchBaseContext()之后,ApplicationonCreate()之前,无需程序手动调用。


所以我们就可以自定义个ContentProvider完成SDK的自动初始化并获取应用的Application。


class CPDemo : ContentProvider() {
override fun attachInfo(context: Context?, info: ProviderInfo?) {
super.attachInfo(context, info)
//编写SDK初始化逻辑,并获取Application
val application = context?.applicationContext
}

override fun onCreate(): Boolean = true
}

直接重写ContentProvider并在attachInfo执行SDK的初始化逻辑即可。


比较出名的内存泄漏检测库 LeakCanary、Google官方的ProcessLifecycleOwner就使用这个原理。


不过如果每个第三方库都借用ContentProvider来完成无侵入式的初始化,势必造成自定义的ContentProvider过多,直接增加了启动耗时:


image.png


为了避免ContentProvider过多的问题,Google官方提供了App Startup库,这个库主要是给SDK提供方实现无侵入初始化使用的:


    implementation("androidx.startup:startup-runtime:1.1.1")

该官方库会将所有用于初始化的ContentProvider合并成一个,减少启动的耗时


基本使用如下:


class CPDemo2 : Initializer<Unit> {
override fun create(context: Context) {
//执行初始化逻辑
}

override fun dependencies(): MutableList<Class<out Initializer<*>>> = mutableListOf()
}

然后再AndroidManifest中注册:


<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.gitlinux.CPDemo2"
android:value="androidx.startup" />
</provider>

更多用法可以参考郭神的文章:Jetpack新成员,App Startup一篇就懂


2.kotlin函数省略返回值类型真的好吗?


经常使用kotlin的程序都知道,kotlin的函数再某些场景下是可以不用显示声明返回值类型,这是为了提高开发效率,比如:


fun test() = ""

对于简单的函数来说,虽然省略了方法的返回类型,但是我们还是能够直接看出这个方法的返回值类型为String,但是方法中调用了其他方法呢,比如:


fun test() =  request()

//随便一个函数,这个函数体中还会调用其他的函数
fun request() = otherFun()

fun otherFun() = "hahaha"

这种情况下,如果我们要知道test()方法的返回值类型必须先通过request()函数再跳转到otherFun才能知道test()方法的返回值类型,这对于程序而言反而降低了开发效率。


所以我认为使用kotlin函数省略返回值类型的场景应该有一个前提:该函数的返回值类型程序能够很容易推断出来(尽量不依赖其他函数)


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

能说一说 Kotlin 中 lateinit 和 lazy 的区别吗?

使用 Kotlin 进行开发,对于 latelinit 和 lazy 肯定不陌生。但其原理上的区别,可能鲜少了解过,借着本篇文章普及下这方面的知识。 lateinit 用法 非空类型可以使用 lateinit 关键字达到延迟初始化。  class I...
继续阅读 »

使用 Kotlin 进行开发,对于 latelinit 和 lazy 肯定不陌生。但其原理上的区别,可能鲜少了解过,借着本篇文章普及下这方面的知识。


lateinit


用法


非空类型可以使用 lateinit 关键字达到延迟初始化。


 class InitTest() {
     lateinit var name: String
 
     public fun checkName(): Boolean = name.isNotEmpty()
 }

如果在使用前没有初始化的话会发生如下 Exception。


 AndroidRuntime: FATAL EXCEPTION: main
      Caused by: kotlin.UninitializedPropertyAccessException: lateinit property name has not been initialized
        at com.example.tiramisu_demo.kotlin.InitTest.getName(InitTest.kt:4)
        at com.example.tiramisu_demo.kotlin.InitTest.checkName(InitTest.kt:10)
        at com.example.tiramisu_demo.MainActivity.testInit(MainActivity.kt:365)
        at com.example.tiramisu_demo.MainActivity.onButtonClick(MainActivity.kt:371)
        ...

为防止上述的 Exception,可以在使用前通过 ::xxx.isInitialized 进行判断。


 class InitTest() {
     lateinit var name: String
 
     fun checkName(): Boolean {
         return if (::name.isInitialized) {
             name.isNotEmpty()
        } else {
             false
        }
    }
 }

 Init: testInit():false

当 name 初始化过之后使用亦可正常。


 class InitTest() {
     lateinit var name: String
 
     fun injectName(name: String) {
         this.name = name
    }
 
     fun checkName(): Boolean {
         return if (::name.isInitialized) {
             name.isNotEmpty()
        } else {
             false
        }
    }
 }

 Init: testInit():true

原理


反编译之后可以看到该变量没有 @NotNull 注解,使用的时候要 check 是否为 null。


 public final class InitTest {
    public String name;
       
    @NotNull
    public final String getName() {
       String var10000 = this.name;
       if (var10000 == null) {
          Intrinsics.throwUninitializedPropertyAccessException("name");
      }
 
       return var10000;
    }
 
     public final boolean checkName() {
       String var10000 = this.name;
       if (var10000 == null) {
          Intrinsics.throwUninitializedPropertyAccessException("name");
      }
 
       CharSequence var1 = (CharSequence)var10000;
       return var1.length() > 0;
    }
 }

null 则抛出对应的 UninitializedPropertyAccessException。


 public class Intrinsics {
  public static void throwUninitializedPropertyAccessException(String propertyName) {
         throwUninitializedProperty("lateinit property " + propertyName + " has not been initialized");
    }
 
  public static void throwUninitializedProperty(String message) {
         throw sanitizeStackTrace(new UninitializedPropertyAccessException(message));
    }
 
  private static <T extends Throwable> T sanitizeStackTrace(T throwable) {
         return sanitizeStackTrace(throwable, Intrinsics.class.getName());
    }
 
     static <T extends Throwable> T sanitizeStackTrace(T throwable, String classNameToDrop) {
         StackTraceElement[] stackTrace = throwable.getStackTrace();
         int size = stackTrace.length;
 
         int lastIntrinsic = -1;
         for (int i = 0; i < size; i++) {
             if (classNameToDrop.equals(stackTrace[i].getClassName())) {
                 lastIntrinsic = i;
            }
        }
 
         StackTraceElement[] newStackTrace = Arrays.copyOfRange(stackTrace, lastIntrinsic + 1, size);
         throwable.setStackTrace(newStackTrace);
         return throwable;
    }
 }
 
 public actual class UninitializedPropertyAccessException : RuntimeException {
    ...
 }

如果是变量是不加 lateinit 的非空类型,定义的时候即需要初始化。


 class InitTest() {
     val name: String = "test"
 
     public fun checkName(): Boolean = name.isNotEmpty()
 }

在反编译之后发现变量多了 @NotNull 注解,可直接使用。


 public final class InitTest {
    @NotNull
    private String name = "test";
 
    @NotNull
    public final String getName() {
       return this.name;
    }
 
    public final boolean checkName() {
       CharSequence var1 = (CharSequence)this.name;
       return var1.length() > 0;
    }
 }

::xxx.isInitialized 的话进行反编译之后可以发现就是在使用前进行了 null 检查,为空直接执行预设逻辑,反之才进行变量的使用。


 public final class InitTest {
    public String name;
    ...
    public final boolean checkName() {
       boolean var2;
       if (((InitTest)this).name != null) {
          String var10000 = this.name;
          if (var10000 == null) {
             Intrinsics.throwUninitializedPropertyAccessException("name");
          }
 
          CharSequence var1 = (CharSequence)var10000;
          var2 = var1.length() > 0;
      } else {
          var2 = false;
      }
 
       return var2;
    }
 }

lazy


用法


lazy 的命名和 lateinit 类似,但使用场景不同。其是用于懒加载,即初始化方式已确定,只是在使用的时候执行。而且修饰的只是能是 val 常量。


 class InitTest {
     val name by lazy {
         "test"
    }
     
     public fun checkName(): Boolean = name.isNotEmpty()
 }

lazy 修饰的变量可以直接使用,不用担心 NPE。


 Init: testInit():true

原理


上述是 lazy 最常见的用法,反编译之后的代码如下:


 public final class InitTest {
    @NotNull
    private final Lazy name$delegate;
 
    @NotNull
    public final String getName() {
       Lazy var1 = this.name$delegate;
       return (String)var1.getValue();
    }
 
    public final boolean checkName() {
       CharSequence var1 = (CharSequence)this.getName();
       return var1.length() > 0;
    }
 
    public InitTest() {
       this.name$delegate = LazyKt.lazy((Function0)null.INSTANCE);
    }
 }

所属 class 创建实例的时候,实际分配给 lazy 变量的是 Lazy 接口类型,并非 T 类型,变量会在 Lazy 中以 value 暂存,当使用该变量的时候会获取 Lazy 的 value 属性。


Lazy 接口的默认 mode 是 LazyThreadSafetyMode.SYNCHRONIZED,其默认实现是 SynchronizedLazyImpl,该实现中 _value 属性为实际的值,用 volatile 修饰。


value 则通过 get() 从 _value 中读写,get() 将先检查 _value 是否尚未初始化




  • 已经初始化过的话,转换为 T 类型后返回




  • 反之,执行同步方法(默认情况下 lock 对象为 impl 实例),并再次检查是否已经初始化:



    • 已经初始化过的话,转换为 T 类型后返回

    • 反之,执行用于初始化的函数 initializer,其返回值存放在 _value 中,并返回




 public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
 
 private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
     private var initializer: (() -> T)? = initializer
     @Volatile private var _value: Any? = UNINITIALIZED_VALUE
     // final field is required to enable safe publication of constructed instance
     private val lock = lock ?: this
 
     override val value: T
         get() {
             val _v1 = _value
             if (_v1 !== UNINITIALIZED_VALUE) {
                 @Suppress("UNCHECKED_CAST")
                 return _v1 as T
            }
 
             return synchronized(lock) {
                 val _v2 = _value
                 if (_v2 !== UNINITIALIZED_VALUE) {
                     @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                     val typedValue = initializer!!()
                     _value = typedValue
                     initializer = null
                     typedValue
                }
            }
        }
 
     override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE
 
     override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."
 
     private fun writeReplace(): Any = InitializedLazyImpl(value)
 }

总之跟 Java 里双重检查懒汉模式获取单例的写法非常类似。


 public class Singleton {
     private static volatile Singleton singleton;
 
     private Singleton() {
    }
 
     public static Singleton getInstance() {
         if (singleton == null) {
             synchronized (Singleton.class) {
                 if (singleton == null) {
                     singleton = new Singleton();
                }
            }
        }
         return singleton;
    }
 }

lazy 在上述默认的 SYNCHRONIZED mode 下还可以指定内部同步的 lock 对象。


     val name by lazy(lock) {
         "test"
    }

lazy 还可以指定其他 mode,比如 PUBLICATION,内部采用不同于 synchronizedCAS 机制。


     val name by lazy(LazyThreadSafetyMode.PUBLICATION) {
         "test"
    }

lazy 还可以指定 NONE mode,线程不安全。


     val name by lazy(LazyThreadSafetyMode.NONE) {
         "test"
    }

the end


lateinit 和 lazy 都是用于初始化场景,用法和原理有些区别,做个简单总结:


lateinit 用作非空类型的初始化:



  • 在使用前需要初始化

  • 如果使用时没有初始化内部会抛出 UninitializedPropertyAccess Exception

  • 可配合 isInitialized 在使用前进行检查


lazy 用作变量的延迟初始化:



  • 定义的时候已经明确了 initializer 函数体

  • 使用的时候才进行初始化,内部默认通过同步锁和双重校验的方式返回持有的实例

  • 还支持设置 lock 对象和其他实现 mode

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

Flutter 桌面探索 | 自定义可拖拽导航栏

1. 前言 上一篇 《桌面导航 NavigationRail》 中介绍了官方的桌面导航,但整体灵活性并不是太好,风格我也不是很喜欢。看到飞书桌面端的导航栏可以支持拖拽排序,感觉挺有意思。而且排序之后,下次进入时会使用该顺序,而且在其他设备上也会同步该配置顺序。...
继续阅读 »
1. 前言

上一篇 《桌面导航 NavigationRail》 中介绍了官方的桌面导航,但整体灵活性并不是太好,风格我也不是很喜欢。看到飞书桌面端的导航栏可以支持拖拽排序,感觉挺有意思。而且排序之后,下次进入时会使用该顺序,而且在其他设备上也会同步该配置顺序。这说明用户登录时会从服务器获取配置信息,作为导航栏的状态数据决定显示。



本文我们将来探讨两个问题:



  • 第一:如何将导航栏的数据变得 可配置

  • 第二:如何实现 拖拽 更改导航栏位置。




2.整体静态界面布局:

首先,我们先来对整体结构进行一下静态布局,也就是先抛开交互逻辑,对整体结构进行一下划分。整体是一个 上下 结构,下方是 导航栏 + 内容 的左右结构:



下面是对静态界面结构的简单仿写,本文主要介绍导航栏的交互实现,其他内容暂时忽略。以后有机会可以慢慢展开来说。





代码如下,整体界面的呈现由 AppNavigation 负责。通过 Column 实现上下结构,上面是 TopBar ,下面是通过 Expanded 包裹,可以让内容填充剩余部分。下方通过 Row 实现左右结构,左侧是今天的主角 LeftNavigationBar 组件,右侧是一个暂时空白的内容。


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

@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const TopBar(),
Expanded(
child: Row(
children: const [
LeftNavigationBar(),
// TODO 主题内容构建
Expanded(child: SizedBox.shrink()),
],
))
],
),
);
}
}

所以整体结构还是很简单的,通过 Expanded 组件,可以让指定的区域具有 “延展性” 。比如下面,当窗口尺寸变化时,中间的区域会自动收缩,而头部栏和导航栏不会受到影响。





3. 导航栏布局实现

导航栏是自定义的 LeftNavigationBar 组件,是一个上下结构:Logo 在最底端,LeftNavigationMenu 菜单在上方。这里的 Spacer 相当于一个占位组件,其高度为 Column 的剩余部分,也就是会 “撑开” 区域,在窗口高度发生变化时,这块区域会自动延展,来保证 Logo 始终在下方。





界面上呈现的内容,都有其对应的数据载体。这里先简单定义一个 LeftNavigationBarItem 的实体类,用于记录图标和标题信息。另外 id 用于菜单的唯一标识,因为后面要涉及到菜单位置的交换,不能靠索引进行标识:


class LeftNavigationBarItem {
final int id;
final IconData icon;
final String label;

const LeftNavigationBarItem({
required this.icon,
required this.label,
required this.id,
});
}



LeftNavigationMenu 组件中接收 LeftNavigationBarItem 列表数据。通过 Column 组件进行竖直排布,另外把每个菜单的单体抽离为 LeftNavigationBarItemWidget 组件方便维护:


class LeftNavigationMenu extends StatelessWidget {
final List<LeftNavigationBarItem> items;

const LeftNavigationMenu({Key? key, required this.items}) : super(key: key);

@override
Widget build(BuildContext context) {
return Column(
children: items
.map((e) => LeftNavigationBarItemWidget( item: e ))
.toList(),
);
}
}



对于导航栏而言,鼠标悬浮一般会有一个临时的激活状态。外界并不需要用到这个状态,所以可以将 LeftNavigationBarItemWidget 组件定义为 StatefulWidget ,来维护悬浮时的内部状态变化。



如下,在单体的组件状态类中定义 _hovering 私有状态量,通过 InkWell 监听悬浮的变化。由于这里是单独抽离的 LeftNavigationBarItemWidget 组件,所以这里在 _onHover 中触发的 setState 只会对局部组件进行构建。在构建时,根据 active 状态创建不同样式的条目即可。





4. 菜单的点击激活状态管理

界面上呈现的内容,都有其对应的数据载体,菜单的点击激活也不例外。比如你在飞书中点击了一个菜单,变成激活态,就表示在内存中一定对某个菜单的激活数据信息进行了变动,并重新渲染。我们想实现点击更换激活菜单,也是一样。需要考虑的只有两件事:



  • 如何 记录维护 数据的变化。

  • 如何在数据变化后触发更新。


状态管理的工具多种多样,但都不会脱离这两件本质的工作,不同的只是用法的形式而已。不必为了一些表面的功夫争论不休,而忽略问题的本质,适合自己就是好的。其实 State 类本身也是一种状态管理的工具,也有维护数据变化和触发更新的特定性,只不过处理较深层级间的共享数据时比较麻烦。


关于这一点,在上次掘金直播中进行过介绍,感兴趣的可以去看一下 回放 。由于没有什么直播经验,所以那次显得很紧张,不过想分享的核心知识还是都介绍到的。




这里用我比较熟悉的 flutter_bloc 来对激活菜单数据进行管理。现在引入 Cubit 后,对于小的数据进行管理变得非常方便。比如下面的 NavSelectionCubic ,只用 4 行代码就能实现对 激活菜单 id 的管理:


class NavSelectionCubic extends Cubit<int> {
NavSelectionCubic({int id = 1}) : super(id);

void selectMenu(int id) {
emit(id);
}
}



上面完成了 记录维护 数据的变化,那接下来的重点就是:如何在数据变化后触发更新。通过 BlocBuilder 可以在变化到新状态时,触发 builder 回调,重新构建局部组件,实现局部刷新。





在点击菜单是,触发 NavSelectionCubicselectMenu 方法,更新状态数据即可。这样就可以实现如下效果:点击某个菜单,变为激活状态:



---->[_LeftNavigationBarItemWidgetState#_onTap]----
void _onTap() {
BlocProvider.of<NavSelectionCubic>(context).selectMenu(widget.item.id);
}



5. 菜单数据的状态管理

我们现在的菜单数据是写死的,对于可拖拽的功能,需要对这些数据进行修改和触发更新。所以菜单数据本身也就上升为了需要管理的状态。对菜单数据状态进行管理,还有个好处:可以动态的修改菜单,比如不同角色的显示不同的菜单,只要根据角色维护数据即可。





这里再定义一个 NavMenuCubic 用于管理菜单数据,状态量是 NavMenus ,其中维护着 LeftNavigationBarItem 的列表。这样在拖拽时,执行 switchMenu 方法,进行拖拽菜单数据交换,再产出新的状态,即可完成需求。


class NavMenuCubic extends Cubit<NavMenus> {
NavMenuCubic({required List<LeftNavigationBarItem> item}) : super(NavMenus(menus:item ));

void switchMenu(int dragId, int targetId) {
// TODO 处理拖拽菜单数据交换
}
}

class NavMenus{
final List<LeftNavigationBarItem> menus;

const NavMenus({required this.menus});

}



另外说一点,导航模块使用了两个 Bloc ,可以单独抽离一个组件进行包裹 BlocProvider,这样其子树的上下文中才可以访问到相关的 Bloc。比如下面的 _NavigationScope ,这里的菜单数据直接给出,其实也可以通过服务端记录这些配置数据,在登录时读取进行初始化:


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

final List<LeftNavigationBarItem> items = const [
LeftNavigationBarItem(id: 1, icon: Icons.message_outlined, label: "消息"),
LeftNavigationBarItem(id: 2, icon: Icons.video_camera_back_outlined, label: "视频会议"),
LeftNavigationBarItem(id: 3, icon: Icons.book_outlined, label: "通讯录"),
LeftNavigationBarItem(id: 4, icon: Icons.cloud_upload_outlined, label: "云文档"),
LeftNavigationBarItem(id: 5, icon: Icons.games_sharp, label: "工作台"),
LeftNavigationBarItem(id: 6, icon: Icons.calendar_month, label: "日历"),
];

@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<NavSelectionCubic>(
create: (BuildContext context) => NavSelectionCubic(id:items.first.id),
),
BlocProvider<NavMenuCubic>(
create: (BuildContext context) => NavMenuCubic(items:items),
),
],
child: const LeftNavigationBar(),
);
}
}



6. 如何拖动菜单

我们先来分析一下拖拽菜单的界面表现。如下所示,可将一个菜单拖拽出来,拖出的组件具有一定的透明度;另外当拖拽物达到目标时,目标底部会显示蓝线示意移至其下。这里使用的是 DraggableDragTarget 的组合,其中 Draggable 指的是可拖拽物体,DragTarget 指的是受体目标。

可以看出,其实这里导航菜单同时承担着这两种角色,既需要拖拽,又需要作为目标接收拖拽物,这就是可拖拽导航的一个小难点。另外还有一个小细节,在拖拽过程中要禁止 _hovering 的悬浮激活,结束后要开启悬浮激活。



下面是代码实现的核心,其中对应 _disableHover 标识来控制是否可以悬浮激活,在 DragTarget 的相关回调中维护 _disableHover 的值。 DraggableDragTarget 需要一个泛型,也就是拖拽交互中需要传递的数据,这里是 int 类型的菜单 id 。数据由 Draggable 提供,如下 tag1 处所示,交互过程中有两个组件,其一是随拖拽浮动的部分,由 buildDraggableChild 方法构建,其二是主体菜单组件,由 buildTargetChild 方法构建。


class _LeftNavigationBarItemWidgetState  extends State<LeftNavigationBarItemWidget> {
bool _hovering = false;
bool _disableHover = false;

void _onTap() {
BlocProvider.of<NavSelectionCubic>(context).selectMenu(widget.item.id);
}

void _onHover(bool value) {
if (_disableHover) return;
setState(() {
_hovering = value;
});
}

final Color color = const Color(0xffcfd1d7);
final Color activeColor = Colors.blue;

@override
Widget build(BuildContext context) {
return DragTarget<int>(
onAccept: _onAccept,
builder: _buildTarget,
onMove: _onMove,
onLeave: _onLeave,
onWillAccept: _onWillAccept,
);
}

Widget buildTargetChild(bool active, bool dragging, int? dragItemId) {
// 暂略...
}

Widget buildDraggableChild(bool active) {
// 暂略...
}

Widget _buildTarget(BuildContext context, List<int?> candidateData,
List<dynamic> rejectedData) {
bool active = widget.active || _hovering;
int? id;
if (candidateData.isNotEmpty) {
id = candidateData.first;
}
Widget child = buildTargetChild(active, _disableHover, id);

return Draggable<int>(
data: widget.item.id, // tag1
feedback: buildDraggableChild(widget.active), // tag2
child: child,
);
}

下面来单独看一下 DragTarget 的几个回调方法。_onWillAccept 可以通过返回值来控制,是否拖拽物是否符合目标的接收条件,只有符合条件才会在后续触发 _onAccept。比如这里当携带的 id 不是自身的 id 时,符合接收条件,这样就可以避免自己拖到自己身上的问题。

_onAccept 顾名思义,表示拖拽符合条件被接收,我们之后在此回调中对菜单栏进行重排序,再触发更新即可。_onMove 在拖拽物移入目标时触发,_onLeave在拖拽物离开目标时触发。另外 Draggable 中有一些拖拽事件相关的回调,在这里作用不大,大家可以只了解一下。


  bool _onWillAccept(int? data) {
print('=====_onWillAccept=======$data===${data != widget.item.id}===');
return data != widget.item.id;
}

void _onAccept(int data) {
print('=====_onAccept=======$data======');
_disableHover = false;
}

void _onMove(DragTargetDetails<int> details) {
_hovering = false;
_disableHover = true;
}

void _onLeave(int? data) {
print('=====_onLeave=============');
_disableHover = false;
}
}

最后看一下 buildTargetChild 中的一个小细节,也就是达到目标时,目标组件底部出现蓝色线条示意。 DragTarget 组件的构建组件的回调中,可以感知到携带的数据。如下,只要根据 id 数据进行校验,当 enable 时添加底部边线即可:





7. 拖拽更新菜单数据

上面把所有的准备工作都完成了,接下来想要拖拽更新菜单数据,也就能水到渠成。前面说过。菜单数据由 NavMenuCubic 维护,现在只要在 switchMenu 中完成业务逻辑,在 _onAccept 中触发即可。这样界面交互、数据变化、界面更新三个层次就会非常清晰。


class NavMenuCubic extends Cubit<NavMenus> {
NavMenuCubic({required List<LeftNavigationBarItem> items}) : super(NavMenus(menus:items ));

void switchMenu(int dragId, int targetId) {
// TODO 处理拖拽菜单数据交换
}
}



如下,是交换的处理逻辑,根据 dragIdtargetId 获取在列表中的索引,然后移除和添加而已。就是最基本的数据处理,在刚才的 _onAccept 方法中触发交换即可,效果如下:



---->[NavMenuCubic#switchMenu]---- 
void switchMenu(int dragId, int targetId) {
List<LeftNavigationBarItem> items = state.menus;
int dragIndex = 0;
int targetIndex = 0;
for(int i =0;i<items.length;i++){
LeftNavigationBarItem item = items[i];
if(item.id == dragId){
dragIndex = i;
}
}
LeftNavigationBarItem dragItem = items.removeAt(dragIndex);
for(int i =0;i<items.length;i++) {
LeftNavigationBarItem item = items[i];
if (item.id == targetId) {
targetIndex = i;
}
}
items.insert(targetIndex+1, dragItem);
print(items);
emit(NavMenus(menus: items));
}
}

---->[_LeftNavigationBarItemWidgetState]----
void _onAccept(int data) {
print('=====_onAccept=======$data======');
BlocProvider.of<NavMenuCubic>(context).switchMenu(data,widget.item.id);
_disableHover = false;
}



这里只是进行最基础的拖拽导航栏需求,还有一些可以拓展的地方。比如将菜单的数据存储在本地,这样就可以保证程序关闭之后,再打开不会重置。另外也可以提供相关的后端接口,让数据同步到服务端,这样多设备就可以实现同步。

本文简单介绍了一下状态管理的使用价值,完成了一个简单的自定义可拖拽导航栏,相信从中你可以学到一些东西。后续会基于这个导航继续拓展,比如界面切换,支持添加移除等。那本文就到这里,谢谢观看~


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

Android AIDL使用指南

AIDL 全称 Android Interface Definition Language ,安卓接口定义语言。AIDL 用来解决 Android 的跨进程通信问题,底层原理是 Binder ,实现思路是 C / S 架构思想。Server:接收请求,提供处理...
继续阅读 »

AIDL 全称 Android Interface Definition Language ,安卓接口定义语言。

AIDL 用来解决 Android 的跨进程通信问题,底层原理是 Binder ,实现思路是 C / S 架构思想。

  • Server:接收请求,提供处理逻辑,并发送响应数据。
  • Client:发起请求,接收响应数据。

C / S 之间通过 Binder 对象进行通信。

  • Server 需要实现一个 Service 作为服务器,Client 侧则需要调用发起请求的能力。
  • Client 需要调用 bindService 绑定到远程服务,然后通过 ServiceConnection 来接收远程服务的 Binder 对象。拿到 Binder 对象后就可以调用远程服务中定义的方法了。

因为是跨进程通信,所以需要实现序列化,AIDL 专门为 Android 设计,所以它的序列化不能使用 Java 提供的 Serializable ,而是 Android 提供的 Parcelable 接口。

AIDL 的用法

以一个跨进程相互发送消息的 Demo 为例,演示 AIDL 的用法。

场景:两个 App ,一个作为 Server ,用来启动一个服务,接收另一个作为 Client 的 App 发来的请求(Request),然后并进行响应(Response),另外,Server 也可以主动发消息给绑定到 Server 的 Client 。

Step 1 定义通信协议

AIDL 解决的是远程通信问题,是一种 C / S 架构思想,参考网络通信模型,客户端和服务端之间,需要约定好彼此支持哪些东西,提供了什么能力给对方(这里主要是服务端提供给客户端的),而定义能力在面向对象的编程中,自然就是通过接口来定义。所以 AIDL 是 Interface Definition Language。

定义服务端暴露给客户端的能力,首先要创建 AIDL 文件。AIDL 文件在同包名下,与 java / res 等目录同级,创建 aidl 文件夹,其内部结构和 java 保持一致:

src
|- java
|-- com
|--- chunyu
|---- aidl
|----- service
|------ ...// java file

|- aidl
|-- com
|--- chunyu
|---- aidl
|----- service
|------ ... // aidl file

|- ...
复制代码

在定义 AIDL 接口前,我们现实现一个数据类,这个数据类作为服务端和客户端之间通信的数据结构。如果你的通信不需要复杂的数据对象,而是 int 、 long 等基本数据类型和 String ,则不需要这一个步骤。

通过 Android Studio 右键创建 AIDL 文件时,默认会生成一个方法:

interface IServerManager {
/**
* 演示了一些可以在AIDL中用作参数和返回值的基本类型。
*/
void basicTypes(int anInt, long aLong, boolean aBoolean, float aFloat,
double aDouble, String aString);
}
复制代码

这里说明了除了要实现 Parcelable 序列化接口的对象,这些类型可以直接传递。

这里我们定义一个 Msg 类,作为通信传递的数据类型,在 java 目录下实现这个类:

class Msg(var msg: String, var time: Long? = null): Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString() ?: "",
parcel.readValue(Long::class.java.classLoader) as? Long
) {
}

override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(msg)
parcel.writeValue(time)
}

override fun describeContents(): Int {
return 0
}

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

override fun newArray(size: Int): Array<Msg?> {
return arrayOfNulls(size)
}
}
}
复制代码

其实只需要定义参数即可,方法和成员通过快捷键会自动实现。实现完这个类后,需要在 aidl 同包名下创建一个 Msg.aidl 文件,将这个类型进行声明:

package com.chunyu.aidl.service;

parcelable Msg;
复制代码

数据对象定义好了,下一步就可以定义服务端暴露给客户端的接口了。

首先定义一个服务端主动回调给客户端的接口,所有注册了的 Client ,都可以接收到服务端的主动消息:

package com.chunyu.aidl.service;

import com.chunyu.aidl.service.Msg;

interface IReceiveMsgListener {
void onReceive(in Msg msg);
}
复制代码

需要注意的一点是,AIDL 文件中的 import 并不会自动导入,需要开发者自行添加。

然后定义 Server 暴露给 Client 的能力:

package com.chunyu.aidl.service;

import com.chunyu.aidl.service.Msg;
import com.chunyu.aidl.service.IReceiveMsgListener;

interface IMsgManager {
// 发消息
void sendMsg(in Msg msg);
// 客户端注册监听回调
void registerReceiveListener(IReceiveMsgListener listener);
// 客户端取消监听回调
void unregisterReceiveListener(IReceiveMsgListener listener);
}
复制代码

IMsgManager 提供了三个方法,用来解决两个场景:

  • Client 主动发送 Msg 给 Server (sendMsg 方法,也可以理解为网络通信中的客户端发起请求)。
  • Server 主动发消息给所有订阅者,通过回调 IReceiveMsgListener 中的 onReceive 方法,每个注册的 Client 都会收到回调(典型的观察者模式)。

所有关于 AIDL 的部分就到这里了,此时,开发者需要手动运行 build 重新构建项目,这样 AIDL 会在 build 后生成一些 class 文件,供项目代码中调用。

注意:每次 AIDL 的改动都需要手动 build 一下。

Step 2 定义服务端

服务端的定义需要创建一个 Service 来作为服务器。这里创建一个 MyService 类,然后在 AndroidManifest.xml 中配置一个 action ,这个 action 后续会用来进行跨进程启动:

        <service
android:name=".MyService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="com.chunyu.aidl.service.MyService"></action>
</intent-filter>
</service>
复制代码

配置完这个后,就可以在 MyService 中实现服务器的逻辑了。

Service 的启动方式有两种,startService 和 bindService ,后者是我们在实现 Client 连接 Server 的核心方法。通过 bindService 创建 C / S 之间的连接。而 Service 在通过 bindService 启动时,会回调 onBind 方法:

fun onBind(intent: Intent): IBinder 
复制代码

onBind 的返回类型时 IBinder ,这个就是跨进程通信间传递的 Binder 。在同一个进程中不需要进行跨进程通信,这里可以返回为 null 。而此时需要实现 IPC ,自然这个方法需要返回一个存在的 Binder 对象,所以,配置服务器的第二步,就是实现一个 Binder 并在 onBind 中返回。

我们在定义通信协议时,定义了一个用来表示 Server 提供给 Client 能力的接口 IMsgManager,经过 build 后,编译器会自动生成 IMsgManager.Stub ,这是一个自动生成的实现了 IMsgManager 接口的 Binder 抽象实现:

public static abstract class Stub extends android.os.Binder implements com.chunyu.aidl.service.IMsgManager
复制代码

在 MyService 中实现这个抽象类:

class MyService : Service() {

// ...

inner class MyBinder: IMsgManager.Stub() {
override fun sendMsg(msg: Msg?) {
// todo 收到 Client 发来的消息,此处实现 Server 的处理逻辑
}
override fun sendMsg(msg: Msg?) {
// todo 收到 Client 发来的消息,此处实现 Server 的处理逻辑
val n = receiveListeners.beginBroadcast()
for (i in 0 until n) {
val listener = receiveListeners.getBroadcastItem(i)
listener?.let {
try {
val serverMsg = Msg("服务器响应 ${Date(System.currentTimeMillis())}\n ${packageName}", System.currentTimeMillis())
listener.onReceive(serverMsg)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
}
receiveListeners.finishBroadcast()
}

override fun registerReceiveListener(listener: IReceiveMsgListener?) {
// receiveListeners 记录观察者
receiveListeners.register(listener)
}

override fun unregisterReceiveListener(listener: IReceiveMsgListener?) {
val success = receiveListeners.unregister(listener)
if (success) {
Log.d(TAG, "解除注册成功")
} else {
Log.d(TAG, "解除注册失败")
}
}
}
}
复制代码

然后,在 onBind 方法中返回这个类的对象:

    override fun onBind(intent: Intent): IBinder {
return MyBinder()
}
复制代码

整体的一个服务端代码:

class MyService : Service() {

private val receiveListeners = RemoteCallbackList<IReceiveMsgListener>()

override fun onBind(intent: Intent): IBinder {
return MyBinder()
}

inner class MyBinder: IMsgManager.Stub() {
override fun sendMsg(msg: Msg?) {
// server process request at here
}

override fun registerReceiveListener(listener: IReceiveMsgListener?) {
receiveListeners.register(listener)
}

override fun unregisterReceiveListener(listener: IReceiveMsgListener?) {
val success = receiveListeners.unregister(listener)
if (success) {
Log.d(TAG, "解除注册成功")
} else {
Log.d(TAG, "解除注册失败")
}
}
}
}
复制代码

这样,服务端的逻辑就完成了。

Step 3 客户端实现

客户端的实现在另一个 App 中实现,创建一个新项目,包名 com.chunyu.aidl.client ,将这个项目中的 MainActivity 作为 Client 。

Client 中需要实现的核心逻辑包括:

  • 创建 Client 到 Server 的连接。
  • 实现发送请求的功能。
  • 实现接收 Server 消息的功能。
  • 在销毁的生命周期中主动关闭连接。

创建连接

创建连接主要通过 bindService 来实现,分为两种情况,在同一个进程中,不需要跨进程,直接通过显式的 Intent 启动 Service :

val intent = Intent(this, MyService::class.java)
bindService(intent, connection!!, BIND_AUTO_CREATE)
复制代码

这是因为在同一个进程中,能直接访问到服务端 Service 的类。而跨进程没有这个 class ,需要通过隐式 Intent 启动 :

val intent = Intent()
intent.action = "com.chunyu.aidl.service.MyService"
intent.setPackage("com.chunyu.aidl.service")
bindService(intent, connection!!, BIND_AUTO_CREATE)
复制代码

Action 在实现服务端时,在 AndroidManifest.xml 中进行配置,此刻就用到了。

而不管是哪个进程调用 bindService ,都会需要一个 connection 参数,这是一个 ServiceConnection 的对象。

ServiceConnection 是一个监控应用 Service 状态的接口。和很多系统的其他回调一样,这个接口的实现的方法在进程的主线程中调用。

public interface ServiceConnection {

void onServiceConnected(ComponentName name, IBinder service);

void onServiceDisconnected(ComponentName name);

default void onBindingDied(ComponentName name) {
}

default void onNullBinding(ComponentName name) {
}
}
复制代码

了解了 ServiceConnection ,所以需要在 Client 中实现一个 ServiceConnection 对象,这里用匿名对象的形式:

private var deathRecipient = object : IBinder.DeathRecipient {
override fun binderDied() {
iMsgManager?.let {
// 当 binder 连接断开时,解除注册
it.asBinder().unlinkToDeath(this, 0)
iMsgManager = null
}
}
}

val connection = object : ServiceConnection {
// 服务连接创建成功时
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
iMsgManager = IMsgManager.Stub.asInterface(binder)
try {
iMsgManager?.asBinder()?.linkToDeath(deathRecipient, 0)
iMsgManager?.registerReceiveListener(receiveMsgListener)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun onServiceDisconnected(name: ComponentName?) { }
}
复制代码

接收 Server 消息

这里注册的 listener是一个 IReceiveMsgListener.Stub 对象:

    private var receiveMsgListener = object : IReceiveMsgListener.Stub() {
override fun onReceive(msg: Msg?) {
displayTv.post {
displayTv.text = "客户端:${msg?.msg}, time: ${msg?.time}"
}
}
}
复制代码

写到这里的时候,我是有一个疑问的,为什么是 IReceiveMsgListener.Stub 类型,而不是一个 IReceiveMsgListener 的匿名对象。

首先 IReceiveMsgListener.Stub 的继承关系是:

public static abstract class Stub extends android.os.Binder implements com.chunyu.aidl.service.IReceiveMsgListener
复制代码

IReceiveMsgListener.Stub 本身也是 IReceiveMsgListener 的实现,而且它还继承自 Binder ,也就是具备了 Binder 的能力,能够在跨进程通信中作为 Binder 传输,所以这里是 IReceiveMsgListener.Stub 类型的对象。

而如果直接使用 IReceiveMsgListener , 匿名对象要求多实现一个 asBinder 方法:

    private var receiveMsgListener = object : IReceiveMsgListener {
override fun asBinder(): IBinder {
TODO("Not yet implemented")
}

override fun onReceive(msg: Msg?) {
displayTv.post {
displayTv.text = "客户端:${msg?.msg}, time: ${msg?.time}"
}
Log.d(TAG, "客户端:$msg")
}
}
复制代码

其实 IReceiveMsgListener.Stub 就是帮我们实现好了一些逻辑,减少了开发的复杂度。

而从另一个方面也验证了 AIDL 跨进程通信调用的对象能需要具备作为 Binder 的能力。

Client 发送消息

val connection = object : ServiceConnection {
// 服务连接创建成功时
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
iMsgManager = IMsgManager.Stub.asInterface(binder)
try {
iMsgManager?.asBinder()?.linkToDeath(deathRecipient, 0)
iMsgManager?.registerReceiveListener(receiveMsgListener)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
override fun onServiceDisconnected(name: ComponentName?) { }
}
复制代码

onServiceConnected 代表着连接创建成功了,此时首先赋值了 iMsgManager ,iMsgManager 是 AIDL 中定义的 Server 暴露给 Client 的能力的接口对象:

private var iMsgManager: IMsgManager?  = null
复制代码

它的初始化:

iMsgManager = IMsgManager.Stub.asInterface(binder)
复制代码

这里的 asInerface 和 asBinder 方法,说明 AIDL 中定义的接口可以转换为 Binder ,Binder 也可以转换为 Interface ,因为这里的 Binder 来自于 Service 的 onBind 方法,在 onBind 中返回的就是 IMsgManager.Stub 的实现,自然可以转换为 IMsgManager 。

通过 IMsgManager 就可以调用 Server 中的方法了:

        sendMsgBtn.setOnClickListener {
iMsgManager?.sendMsg(Msg("from 客户端,当前第 ${count++} 次", System.currentTimeMillis()))
}
复制代码

随便给一个 button 的点击事件中调用 sendMsg 方法,发送消息给 MyService 。

生命周期管理

最后是在 Client 的生命周期中及时关闭连接,清除不需要的对象。

    // in MainActivity
override fun onDestroy() {
if (iMsgManager?.asBinder()?.isBinderAlive == true) {
try {
iMsgManager?.unregisterReceiveListener(receiveMsgListener)
} catch (e: RemoteException) {
e.printStackTrace()
}
}
connection?.let {
unbindService(it)
}
super.onDestroy()
}
复制代码

基本上这就是一个完整的 AIDL 流程了。

总结

  • AIDL 是一套快速实现 Android Binder 机制的框架。
  • Android 中的 Binder 机制,架构思想是 C / S 架构。
  • 所有跨进程传递的数据需要实现 Parcelable (除了一些基本的类型)。
  • 所有跨进程调用的对象,都必须是 Binder 的实现。
  • Binder 对象可以和 Interface 实例进行转换,这是因为 Service 中返回的 Binder 对象实现了 Interface。
  • 通过 aidl 文件中定义的接口,可以跨进程调用远程对象的方法。


作者:自动化BUG制造器
链接:https://juejin.cn/post/7123129439898042376
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

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

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

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


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



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




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


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


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


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


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

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



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




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



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


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


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


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


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

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



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

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










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



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

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


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


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



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


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

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



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










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


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


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



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










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










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



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

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


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

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

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




完整代码可见: card_perspective_demo_page.dart


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



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


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


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



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


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

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










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




完整代码可见: card_3d_demo_page.dart


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



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


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

Android Native 异常捕获库

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

Android Native 异常捕获库


image image image


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


项目主页


现状



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

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

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

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

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

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

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

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

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

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






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


设计意图



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

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



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


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




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



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

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





  3. 最少改动:

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



  4. 单一职责:

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




整体流程


image.png


功能介绍



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

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


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

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

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


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


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


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

输出结果如下:


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

接入方式


根项目的build.gradle中:


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

模块的build.gradle中:


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

初始化


两种模式可选:


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

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

示例项目


点击查看:示例项目


致谢



  • 感谢google breakpad库提供的源码

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

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

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

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

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

背景


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


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


BigDecimal概述


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


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


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


BigDecimal的4个坑


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


第一:浮点类型的坑


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


比如下面的代码:


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

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


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


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


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

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


a = 0.01000000000000000020816681711721685132943093776702880859375
b = 0.01

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


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


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


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

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


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


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


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

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


第二:浮点精度的坑


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


先来看一个示例:


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

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


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


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

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

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


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


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


第三:设置精度的坑


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


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

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


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

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

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


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


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


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


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

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


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


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



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

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

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

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

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

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

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

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


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


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


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


先来看看下面的代码:


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

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


3.563453525545672E+16

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


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



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

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

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


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


计算法


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


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


使用示例如下:


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

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

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

输出结果如下:


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

小结


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


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

Flutter 组件集录 | 桌面导航 NavigationRail

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

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















--



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



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


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


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


image.png




1. NavigationRail 组件的基本使用

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



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

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





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



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


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

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

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



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


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



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


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


class _MyHomePageState extends State<MyHomePage> {

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

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

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

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

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




2.首尾组件与折叠

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



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



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



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



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



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





3.影深 与 标签类型

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





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


enum NavigationRailLabelType {
none,
selected,
all,
}

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





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



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



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



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


  • unselectedLabelTextStyle : 未选中签文字样式

  • selectedLabelTextStyle : 选中标签文字样式

  • unselectedIconTheme : 未选中图标样式

  • selectedIconTheme : 选中图标样式


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



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

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



5.指示器与最小宽度


  • useIndicator : 是否添加指示器

  • indicatorColor : 指示器颜色


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





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






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




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



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





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

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



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


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



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




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



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


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




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



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



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


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

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

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

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


image.png


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


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


1.1 Cookie与服务器的交互


image.png


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

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


image.png


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


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


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


1.2 服务器端的session的机制


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


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


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


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

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

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


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


1.3 基于 session 的身份认证流程


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


image.png


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


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


image.png


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


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

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


2.1 Session共享方案


Session共享一般有两种思路



  • session复制

  • session集中存储


2.1.1 session复制


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

image.png


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


2.1.2 session集中存储


image.png


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


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


3.1 SSO的产生背景


image.png


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


image.png


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


3.2 SSO的底层原理 CAS


3.2.1 CAS实现单点登录流程


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


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

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

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

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

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

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

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


整个交互流程图如下:


image.png


3.2.2 单点登录流程演示


3.2.2.1 CAS登录服务demo核心代码


1.用户实体类



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

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

public String getUsername() {
return username;
}

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

public String getPassword() {
return password;
}

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

public String getBackurl() {
return backurl;
}

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

}

2.登录控制器


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

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

return "login";
}

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

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

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

3.登录过滤器


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

}

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

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

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

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

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

filterChain.doFilter(request,servletResponse);
}

@Override
public void destroy() {

}
}

4.配置过滤器


@Configuration
public class LoginConfig {

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

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

5.登录页面


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


</body>
</html>

3.2.2.2 web系统demo核心代码


1.过滤器


public class SSOFilter implements Filter {
private RedisTemplate redisTemplate;

public static final String USER_INFO = "user";

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

}

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

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

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

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

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

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

filterChain.doFilter(request,servletResponse);
}

@Override
public void destroy() {

}
}

2.控制器


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

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

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

3.首页


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

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


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


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



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

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


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


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


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

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

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

引子


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


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


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


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


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


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


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


等待并发网络请求


布尔值


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


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

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


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


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

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


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


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

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


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


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


CountdownLatch


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


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

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


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


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


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


协程


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


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


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

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


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

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


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


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

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


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

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


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


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


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



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

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

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


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


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


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


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


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

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


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


稍改动下代码:


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

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


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


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


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


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


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

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

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


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

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


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

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


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

}
})
}
})
}

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


题外话


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


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


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

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


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


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

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

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

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

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

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

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


统计相册加载图片耗时


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


1657970793022(1).gif


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


CountdownLatch


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

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

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

协程


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

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

你更喜欢哪种方式?


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

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

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

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



  • 1、创建一个ForegroundService继承Service

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

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



根据不同版本开启服务



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


显示前台服务


关闭前台服务


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


哦豁


完全不对,遇到的问题:



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

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



崩溃信息


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



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


变量++


变量--



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


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


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


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


关闭通知


显示通知


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


最后效果


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


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


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