注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

2021年,跨端是否已成趋势?Android 开发还有必要学 Flutter 吗?

由于手机APP的运行受不同操作系统的限制,目前大多数的移动APP应用开发仍然需要针对不同的系统环境进行单独的开发。不过,为了降低开发成本、提高代码复用率,减少开发者对多个平台差异适配的工作量一直是跨平台开发框架追求的目标。 但是目前,很多开发者还不不确定应该选...
继续阅读 »

由于手机APP的运行受不同操作系统的限制,目前大多数的移动APP应用开发仍然需要针对不同的系统环境进行单独的开发。不过,为了降低开发成本、提高代码复用率,减少开发者对多个平台差异适配的工作量一直是跨平台开发框架追求的目标。


但是目前,很多开发者还不不确定应该选择哪种技术来快速且低成本的开发应用程序,不过如果你熟知跨平台的发展历史,那么2021年可供大家选择的跨平台方案主选项只有两个:Flutter或者React Native



在正式进行对比之前,首先需要明确一点,即Flutter和React Native这两个框架都是构建跨平台移动应用程序的优质框架,但有时做出正确的决定取决于业务使用的角度。因此,我们选取了九个重要的参数,用于两者的比较:



  • 由谁提供技术支持?

  • 框架的市场份额占比。

  • Dart Vs JavaScript

  • 技术架构

  • 性能

  • 是否对开发者友好,便利性和社区支持

  • UI组件和定制

  • 代码的可维护性

  • 开发者的工作成本


技术支持:谷歌 VS Facebook


Flutter与React Native两大框架背后都站着科技巨头,分别是谷歌和Facebook,所以从这个角度来看两者未来会在竞争中变得更加完善,毕竟他们背后都自己的利益链。


首先,我们来看一下Flutter,Flutter是2017年由谷歌正式推出,是一个先进的应用程序软件开发工具包(SDK),包括所有的小部件和工具,理论上可以让开发者的开发过程更容易和更简单。广泛的小工具选择使开发人员能够以一种简单的方式建立和部署视觉上有吸引力的、原生编译的应用程序,用于多个平台,包括移动、网络和桌面,都使用单一的代码库。因此,Flutter应用程序开发公司有更好的机会,可以确保你更快、更快、更可靠的应用程序开发解决方案。


事实上,Flutter早再2015年Dart开发者峰会上便以“Sky”的身份亮相,Flutter具有几大买点:首先它是免费的,而且是开源的;其次,该架构基于流行的反应式编程,因为它遵循与Reactive相同的风格;最后,归功于小部件体验,Flutter应用程序有一个令人愉快的UI,整体来说转化为应用程序看起来和感觉都不错。


我们再来看一下React Native,React Native也是Facebook在2015年推出的一个跨平台原生移动应用开发框架。React Native主要使用的是JavaScript开发语言,对于使用同一代码库为iOS和Android开发应用程序来说非常方便。此外,它的代码共享功能可以更快的开发和减少开发时间。像其他跨平台技术一样,Flutter允许开发者使用相同的代码库来构建独立的应用程序,因此,相比原生应用程序更容易维护。


当然,Flutter和React Native都支持热重载功能,允许开发者直接在运行中的应用程序中添加或纠正代码,而不必保存应用程序,从而加速了开发过程。除此之外,React Native是基于一种非常流行的语言--JavaScript,开发者更易上手;React组件包裹着现有的本地代码,并通过React的声明性UI范式和JavaScript与本地API进行交互,React Native的这些特点使开发人员的工作速度大大加快。


市场份额:五五开的格局正在改变


整体上来说,这两者的市场份额是十分相近的,但Flutter在最近有后来居上之势。2019年和2020年全球软件开发公司使用的最佳跨平台移动应用开发框架时,其结果是42%的开发者更愿意留在React Native,而39%的开发者选择了Flutter。根据StackOverFlow的数据,68.8%的开发者喜欢使用Flutter进行进一步的开发项目,而57.9%的开发者对使用React Native技术进行应用开发进一步表现出兴趣。


不同的市场报告有不同的统计数字,Flutter、React Native究竟孰强孰弱或许只能从一些市场趋势中窥见一二:




  • 市场趋势一:谷歌Google Trends的统计数字显示,在过去12个月的分析中,Flutter的搜索指数已反超React Native。




  • 市场趋势二:更年轻的Flutter在Github上拥有16.8万名成员和11.8万颗星的社区,而更成熟的React Native在Github仅有20.7万名成员和9.46万颗星。


    image.png




  • 趋势三:根据Statista的数据,React Native以42%的市场份额力压Flutter,但Flutter与React Native的差距正变得越来越小,其在一年内市场份额从30%急剧跃升至39%。




image.png


语言对比:Dart Vs JavaScript


Flutter所采用的Dart开发语言是谷歌2011年在丹麦奥尔胡斯举行的GOTO大会上亮相的,Dart是一门面向对象的、类定义的、单继承的语言,它的语法类似C语言,可以转译为JavaScript,支持接口(interfaces)、混入(mixins)、抽象类(abstract classes)、具体化泛型(reified generics)、可选类型(optional typing)和sound type system,并且具有AOT与JIT编译器,Dart的最大优势在于速度,运行比JavaScript快2倍,不过Dart作为一门较新的语言,开发者还需要熟悉Java或C++的应用程序开发工作才更易上手。


而React Native则采用的为已经在IT行业广泛应用多年的Javascript语言,类似于HTML的JSX,以及CSS来开发移动应用,因此熟悉Web前端开发的技术人员只需很少的学习就可以进入移动应用开发领域,不过JavaScript线程需要时间来初始化,所以导致React Native在最初渲染之前需要花费大量时间来初始化运行,不过React Native已经发布了升级线路,并且会在最近开源升级的版本,相信随着React Native新版本的发布,性能上将会追平Flutter。


技术架构


如果单从技术上讲,Flutter绝对是一个先进的跨平台技术方案,它提供了一个分层的架构,以确保高度的定制化,而React Native依赖于其他软件来构建反应组件,并使用JavaScriptBridge来桥接原生本地模块的连接。桥接会影响性能,即使发生轻微的变化,而Flutter可以在没有桥接的情况下管理一切。


Flutter提供的分层的架构,为简单快速的UI定制铺平了道路。它被认为可以让你完全控制屏幕上的每一个像素,并允许移动应用开发公司整合叠加和动画图形、文本、视频和控件,没有任何限制。


Flutter移动平台与其他Web平台的架构略有差异,不同平台相同的公共部分就是Dart部分,即Dart Framework。Flutter的公共部分主要实现了两个逻辑:第一,开发人员可以通过Flutter Ui系统编写UI,第二使用Dart虚拟机及Dart语言可以编写跟平台资源无关的逻辑。同时这也是Flutter跨平台的核心,和Java程序可以在Linux,Window,MacOs同时运行, Web程序可以在任意平台运行类似。通过Dart虚拟机,UI及和系统无光的逻辑都可以用Dart语言编写,运行在Dart虚拟机中,是跨平台的。


而React Native依赖于其他软件来构建反应组件,其架构整体上分为三大块:Native、JavaScript 与 Bridge,其中Native 管理UI 更新及交互,JavaScript 调用 Native 能力实现业务功能,Bridge 在二者之间传递消息。React Native 中主要有 3 个线程,应用中的主线程UI Thread、进行布局计算和构造 UI 界面的线程Shadow Thread与React 等 JavaScript 代码都在这个线程执行任务的JS Thread。


正因其依赖于其他软件来构建反应组件,因此在启动上会受到以下,必须先初始化 React Native 运行时环境(即Bridge),Bridge 准备好之后开始 run JS,最后开始 Native 渲染。从架构上来看,Flutter确实性能更高,也更符合当下跨平台开发的需求。


image.png


学习成本和社区支持


当涉及到构建企业应用程序时,社区支持是必须检查的因素。而React Native和Flutter都在行业中发展了多年,并且在谷歌与Facebook两大巨头的支持下都有最新的技术更新与广泛的社区支持。而随着每一个递增的版本和技术更新,社区对该框架的兴趣和需求逐渐增加。让我们了解一下这两个框架在社区参与方面的情况。


React Native在2015年推出,其社区一直处于成长阶段,Github上对该框架的贡献者数量就是证明。但是,尽管Flutter还很年轻,也比较新,但它正在已开始显示后来居上之势。


image.png


代码的可维护性


无论你开发的应用程序多么出色,为了使其顺利运行,不断地升级和调试是必要的。与Flutter相比,用React Native维护代码真的很困难。


在React Native中,当你为了开发适配不同系统的应用程序时就需要分开编写适配代码,它会干扰框架的逻辑,从而减慢了开发过程。另外,在React Native应用程序中,大多数本地组件都有一个第三方库的依赖性,所以维护这些过时的库确实是一个具有挑战性的任务。


对于Flutter来说,由于代码逻辑相对简单,不需要适配不同的操作系统,维护代码就要容易得多,允许移动应用程序开发人员轻松发现问题,为外部工具和支持第三方库提供数据支撑。


此外,与使用React Native的热重新加载功能相比,在Flutter中发布质量更新和对应用程序进行即时更改所花费的时间也比React Native表现更好。


开发成本


无论是一个初创公司还是一个先进的互联网企业,开发成本总是大家比较关心的内容。因此,当你选择雇用反应原生开发公司或Flutter应用程序工程师时,你可能需要评估他们的费率,不同的地方有不同的开发成本。


因此,在正式启动项目之前,无论是Flutter还是React Native,都需要考虑开发人员的素质,如经验、专业知识、项目处理等开发成本问题,以评估开发人员的实际小时费用,下面是Flutter和React Native的一个开发成本的问题。


image.png


除此之外,在选择Flutter还是React Native的问题上,我们还需要考虑他们的自定义开发能力。
Flutter和React Native都有一套属于自己的UI组件和小工具。并且,Flutter就以其漂亮的UI原生型小部件而闻名,这些小部件由框架的图形引擎进行渲染和管理。


而React Native只提供了适应平台的基本工具,如按钮、滑块、加载指示灯等基础组件,如果需要开发复杂的功能,就需要使用第三方组组件。


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

当 Adapter 遇上 Kotlin DSL,无比简单的调用方式

早在去年的时候我就提到过使用工厂的方式获取 Adapter 而不是为每个 Adapter 定义一个类文件。这样的好处是,对于不是那么复杂的 Adapter 可以节省大量的代码,提升开发效率和解放双手,同时更好的支持多类型布局效果。 1、Kotlin DSL 和...
继续阅读 »

早在去年的时候我就提到过使用工厂的方式获取 Adapter 而不是为每个 Adapter 定义一个类文件。这样的好处是,对于不是那么复杂的 Adapter 可以节省大量的代码,提升开发效率和解放双手,同时更好的支持多类型布局效果。


1、Kotlin DSL 和 Adapter 工厂方法


可以把 Kotlin DSL 当作构建者使用。这里有一篇不错的文章,想了解的可以阅读下,



http://www.ximedes.com/2020-04-21/…



Kotlin DSL 是拓展函数的延申,比如我们常用的 with 等函数就是函数的拓展,


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

这里是泛型 T 的拓展。这里的 T 可以类比到 Java 构建者模式中的 Builder,通过方法接收外部参数之后调用 build() 方法创建一个最终的对象即可。


对于 Adapter 工厂方法,之前我是通过如下方式使用的,


fun <T> getAdapter(
@LayoutRes itemLayout:Int,
converter: (helper: BaseViewHolder, item: T) -> Unit,
data: List<T>
): Adapter<T> = Adapter(itemLayout, converter, data)

class Adapter<T>(
@LayoutRes private val layout: Int,
private val converter: (helper: BaseViewHolder, item: T) -> Unit,
val list: List<T>
): BaseQuickAdapter<T, BaseViewHolder>(layout, list) {
override fun convert(helper: BaseViewHolder, item: T) {
converter(helper, item)
}
}

也就是每次想要得到 Adapter 的时候只要调用 getAdapter() 方法即可。这种封装方式比较简陋,支持的功能有限。后来慢慢采用了 Kotlin DSL 之后,我封装了 Kotlin DSL 风格的工厂方法。采用 Kotlin DSL 风格之后更加优雅和方便快捷,同时更好的支持多类型布局效果。


2、使用


2.1 引入依赖


首先,该项目依赖于 BRVAH,所以,你需要引入该库之后才可以使用。BRVAH 可以说是目前开源的最好用的 Adapter,我们没必要再另起炉灶自己再造轮子。这个框架设计最好地方在于通过 SpareArray 收集了 ViewHolder 控件,从而避免了自定义 ViewHolder,这是我们框架设计的基础思想。


该项目已经上传到了 MavenCentral,你需要先在项目中引入该仓库,


allprojects {
repositories {
mavenCentral()
}
}

然后在项目中添加如下依赖,


implementation "com.github.Shouheng88:xadapter:${latest_version}"

2.2 使用 Adapter 工厂方法


使用 xAdapter 之后,当你需要定义一个 Adapter 的时候,你无需单独创建一个类文件,只需要通过 createAdapter() 方法获取一个 Adapter,


adapter = createAdapter {
withType(Item::class.java, R.layout.item_eyepetizer_home) {
// Bind data with viewholder.
onBind { helper, item ->
helper.setText(R.id.tv_title, item.data.title)
helper.setText(R.id.tv_sub_title, item.data.author?.name + " | " + item.data.category)
helper.loadCover(requireContext(), R.id.iv_cover, item.data.cover?.homepage, R.drawable.recommend_summary_card_bg_unlike)
helper.loadRoundImage(requireContext(), R.id.iv_author, item.data.author?.icon, R.mipmap.eyepetizer, 20f.dp2px())
}
// Item level click and long click events.
onItemClick { _, _, position ->
adapter?.getItem(position)?.let {
toast("Clicked item: " + it.data.title)
}
}
}
}

在这种新的调用方式中,你需要通过 withType() 方法指定数据类型及其对应的布局文件,然后在 onBind() 方法中即可实现数据到 ViewHolder 的绑定操作。这里的 onBind() 方法的使用与 BRVAH 中的 convert() 方法使用一致,可以通过阅读该库了解如何使用。总之,xAapter 在 BRVAH 的基础上做了二次封装,可以说,比简单更简单。


xAdapter 支持为每个 ViewHolder 绑定点击和长按事件,同时也支持为 ViewHolder 上的某个单独的 View 添加点击和长按事件。使用方式如上所示,只需要添加 onItemClick() 方法并实现自己的逻辑即可。其他的点击事件可以参考项目的示例代码。


效果,





2.3 使用多类型 Adapter


多类型 Adapter 的使用方式非常简单,类似于上面的调用方式,只需要在 createAdapter() 内再添加一个 withType() 方法即可。下面是一个写起来可能相当复杂的 Adapter,但是采用了 xAdpater 的调用方式之后,一切变得非常简单,


private fun createAdapter() {
adapter = createAdapter {
withType(MultiTypeDataGridStyle::class.java, R.layout.item_list) {
onBind { helper, item ->
val rv = helper.getView<RecyclerView>(R.id.rv)
rv.layoutManager = GridLayoutManager(context, 3)
val adapter = createSubAdapter(R.layout.item_home_page_data_module_1, 1)
rv.adapter = adapter
adapter.setNewData(item.items)
}
}
withType(MultiTypeDataListStyle1::class.java, R.layout.item_home_page_data_module_2) {
onBind { helper, item ->
converter.invoke(helper, item)
}
onItemClick { _, _, position ->
(adapter?.getItem(position) as? MultiTypeDataListStyle1)?.let {
toast("Clicked style[2] item: " + it.item.data.title)
}
}
}
withType(MultiTypeDataListStyle2::class.java, R.layout.item_list) {
onBind { helper, item ->
val rv = helper.getView<RecyclerView>(R.id.rv)
rv.layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false)
val adapter = createSubAdapter(R.layout.item_home_page_data_module_4, 3)
rv.adapter = adapter
adapter.setNewData(item.items)
}
}
withType(MultiTypeDataListStyle3::class.java, R.layout.item_home_page_data_module_3) {
onBind { helper, item ->
converter.invoke(helper, item)
}
onItemClick { _, _, position ->
(adapter?.getItem(position) as? MultiTypeDataListStyle3)?.let {
toast("Clicked style[4] item: " + it.item.data.title)
}
}
}
}
}

xAdapter 对多类型布局方式的支持是在 BRVAH 之上进行的改造,在这种封装方式中,数据类无需实现任何类和接口。Adpater 内部通过 Class 区分各个 ViewHolder.


效果,





总结


相对于为各种类型的数据定义 Adapter 的使用方式,以上封装方式的优势是:



  1. 借助 BRVAH 的优势,封装了大量的方法,进一步简化了 Adapter 的使用;

  2. 通过工厂和 DSL 封装,简化了调用 Adapter 的方式,你无需为数据类型定义 Adapter 文件,减少了项目中需要维护的代码和类文件数量;

  3. 通过以上封装,使用 Adapter 更加简洁,节省了大量的代码,提升开发效率和解放双手;

  4. 自由地在单一类型布局和多类型布局之间进行切换,但是少了没必要的工厂方法。


当有更加简洁的使用方式的时候,继续采用复杂的调用方式无异于抱残守缺,对于程序员而言,做这种重复而没有太大价值的工作,付出再多的汗水都不值得同情。以上是部分功能和代码的展示,可以通过阅读源码了解更多。后续我参考其他优秀的库的设计思想,支持更多 Adapter 特性的封装来实现快速调用。


项目已开源,感兴趣的可以直接阅读项目源码,源码地址:github.com/Shouheng88/…


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

Hook AMS + APT实现集中式登录框架

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 本文链接:[juejin.cn/post/700695…) 1, 背景 登录功能是App开发中一个很常见的功能,一般存在两种登录方式: 一种是进入应用...
继续阅读 »

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:[juejin.cn/post/700695…)


1, 背景


登录功能是App开发中一个很常见的功能,一般存在两种登录方式:




  • 一种是进入应用就必须先登录才能使用(如聊天类软件)




  • 另一种是以游客身份使用,需要登录的时候才会去登录(如商城类软件)




针对第二种的登录方式,一般都是在要跳转到需要登录才能访问的页面(以下简称目标页面)时通过if-else判断是否已登录,未登录则跳转到登录界面,登录成功后退回到原界面,用户继续进行操作。伪代码如下:


if (需要登录) {
// 跳转到登录页面
} else {
// 跳转到目标页面
}

这中方式存在着以下几方面问题:



  1. 当项目功能逐渐庞大以后,存在大量重复的用于判断登录的代码,且判断逻辑可能分布在不同模块,维护成本很高。

  2. 增加或删除目标页面时需要修改判断逻辑,存在耦合。

  3. 跳转到登录页面,登录成功后只能退回到原界面,用户原本的意图被打断,需要再次点击才能进入目标界面(如:用户在个人中心界面点击“我的订单”按钮想要跳转到订单界面,由于没有登录就跳转到了登录界面,登录成功后返回个人中心界面,用户需要再次点击“我的订单”按钮才能进入订单界面)。


大致流程如下图所示:


login.png


针对传统登录方案存在的问题本文提出了一种通过Hook AMS + APT实现集中式登录方案。




  1. 首先通过Hook AMS实现集中处理判断,实现了跟业务逻辑解耦。




  2. 通过注解标记需要登录的页面,然后通过APT生成需要登录页面的集合,便于Hook中的判断。




  3. 最后在Hook AMS时将原意图放入登录页面的意图中,登录页面登录成功后可以获取到原意图,实现了继续用户原意图的目的。




本方案能达到的业务流程如下:


hook_login.png


1, 集中处理


这里借鉴插件化的思路通过Hook AMS实现拦截并统一处理的目的


1.1 分析Activity启动过程

了解Activity启动过程的应该都知道Activity中的startActivity()最终会进入Instrumentation


// Activity.java
@Override
public void startActivityForResult(
String who, Intent intent, int requestCode, @Nullable Bundle options) {
...
Instrumentation.ActivityResult ar =
mInstrumentation.execStartActivity(
this, mMainThread.getApplicationThread(), mToken, who,
intent, requestCode, options);
...
}

InstrumentationexecStartActivity代码如下:


public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token, String target,
Intent intent, int requestCode, Bundle options) {
...
try {
...
int result = ActivityManagerNative.getDefault()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target, requestCode, 0, null, options);
checkStartActivityResult(result, intent);
} catch (RemoteException e) {
throw new RuntimeException("Failure from system", e);
}
return null;
}

其中调用了ActivityManagerNative.getDefault()startActivity(),那么此处getDefault()获取到的是什么?接着看代码:


/**
* Retrieve the system's default/global activity manager.
*/
static public IActivityManager getDefault() {
// step 1
return gDefault.get();
}

// step 2
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
// step 5
IBinder b = ServiceManager.getService("activity");
if (false) {
Log.v("ActivityManager", "default service binder = " + b);
}
IActivityManager am = asInterface(b);
if (false) {
Log.v("ActivityManager", "default service = " + am);
}
return am;
}
};

public abstract class Singleton<T> {
private T mInstance;

protected abstract T create();

// step 3
public final T get() {
synchronized (this) {
if (mInstance == null) {
// step 4
mInstance = create();
}
return mInstance;
}
}
}

gDefault是一个Singleton<IActivityManager>类型的静态常量,它的get()方法返回的是Singleton类中的private T mInstance;,这个mInstance的创建又是在gDefault实例化时通过create()方法实现。


这里代码有点绕,根据上面代码注释的step1 ~ 5,应该能理清楚:gDefault.get()获取到的mInstance实例就是ActivityManagerService(AMS)实例。


由于gDefault是一个静态常量,因此可以通过反射获取到它的实例,同时它是Singleton类型的,因此可以获取到其中的mInstance


到这里你应该能明白接下来要干什么了吧,没错就是Hook AMS。


1.2 Hook AMS


本文以android 6.0代码为例。注:8.0以下实现方式是相同的,8.0和9.0实现相同,10.0到12.0方式是一样的。


这里涉及到反射及动态代理的姿势,请自行了解。


1,获取gDefault实例


Class<?> activityManagerNative = Class.forName("android.app.ActivityManagerNative");
Field singletonField = activityManagerNative.getDeclaredField("gDefault");
singletonField.setAccessible(true);
// 获取gDefault实例
Object singleton = singletonField.get(null);

2,获取Singleton中的mInstance


Class<?> singletonClass = Class.forName("android.util.Singleton");
Field mInstanceField = singletonClass.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
/* Object mInstance = mInstanceField.get(singleton); */
Method getMethod = singletonClass.getDeclaredMethod("get");
Object mInstance = getMethod.invoke(singleton);

这里本可以直接通过mInstanceField及第一步中获取的gDefault实例反射得到mInstance实例,但是实测发现在Android 10以上无法获取,不过还好可以通过Singleton中的get()方法可以获取到其实例。


3,获取要动态代理的Interface


Class<?> iActivityManagerClass = Class.forName("android.app.IActivityManager");

4,创建一个代理对象


Object proxyInstance = Proxy.newProxyInstance(context.getClassLoader(), new Class[]{iActivityManagerClass},
(proxy, method, args) -> {
if (method.getName().equals("startActivity") && !isLogin()) {
// 拦截逻辑
}
return method.invoke(mInstance, args);
});

5,用代理对象替换原mInstance对象


mInstanceField.set(singleton, proxyInstance);

6,兼容性


针对8.0以下,8.0到9.0,10.0到12.0进行适配,可以兼容各个系统版本。


至此已经实现了对AMS的Hook,只需要在代理中判断当前要启动的Activity是否需要登录,然后跳转到登录即可。


但是此时出现了一个问题,这里如何判断哪些Activity需要登录的?最简单的方式就是写死,如下:


// 获取要启动的Activity的全类名。
String intentName = xxx
if (intentName.equals("aaaActivity")
|| intentName.equals("bbbActivity")
...
|| intentName.equals("xxxActivity")){
// 去登陆
}

这样的代码存在着耦合,添加删除目标Activity都需要改这里。


接下来就是通过APT实现解耦的方案。


2, APT实现解耦


APT就不多说了,就是注解处理器,很多流行框架都在用它,如果你不了解请自行了解。


首先定义注解,然后给目标Activity加上注解就相当于打了个标记,接着通过APT找到打了这些标记的Activity,将其全类名保存起来,最后在需要使用的地方通过反射调用即可。


2.1,定义注解


// 目标页面注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface RequireLogin {
// 需要登录的Activity加上该注解
}

// 登录页面注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginActivity {
// 给登录页面加上该注解,方便在Hook中直接调用
}

// 判断是否登录方法的注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface JudgeLogin {
// 给判断是否登录的方法添加注解,需要是静态方法。
}

2.2,注解处理器


这里就不贴代码了,重点是思路:


1,获取所有添加了RequireLogin注解的Activity,存入一个集合中


2,通过JavaPoet创建一个Class


3,在其中添加方法,返回1中集合里Activity的全类名的List


最终通过APT生成的类文件如下:


package me.wsj.login.apt;

public class AndLoginUtils {
// 需要登录的Activity的全类名集合
public static List<String> getNeedLoginList() {
List<String> result = new ArrayList<>();
result.add("me.wsj.andlogin.activity.TargetActivity1");
result.add("me.wsj.andlogin.activity.TargetActivity2");
return result;
}

// 登录Activity的全类名
public static String getLoginActivity() {
return "me.wsj.andlogin.activity.LoginActivity";
}

// 判断是否登录的方法全类名
public static String getJudgeLoginMethod() {
return "me.wsj.andlogin.activity.LoginActivity#checkLogin";
}
}

2.3,反射调用


在动态代理的InvocationHandler中通过反射获取


new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目标Activity全类名
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 该Activity需要登录,跳转到登录页面
}
}
return null;
}
}

/**
* 该activity是否需要登录
*
* @param activityName
* @return
*/
private static boolean isRequireLogin(String activityName) {
if (requireLoginNames.size() == 0) {
// 反射调用apt生成的方法
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getNeedLoginListMethod = NeedLoginClazz.getDeclaredMethod("getRequireLoginList");
getNeedLoginListMethod.setAccessible(true);
requireLoginNames.addAll((List<String>) getNeedLoginListMethod.invoke(null));
Log.d("HootUtil", "size" + requireLoginNames.size());
} catch (Exception e) {
e.printStackTrace();
}
}
return requireLoginNames.contains(activityName);
}

2.4,其他


实现了判断目标页面的解耦,同样的方式也可以实现跳转登录及判断是否登录的解耦。


1,跳转登录页面


前面定义了LoginActivity()注解,APT也生成了getLoginActivity()方法,那就可以反射获取到配置的登录Activity,然后创建新的Intent,替换掉原Intent,进而实现跳转到登录页面。


if (需要跳转到登录) {
Intent intent = new Intent(context, getLoginActivity());
// 然后需要将该intent替换掉原intent接口
}

/**
* 获取登录activity
*
* @return
*/
private static Class<?> getLoginActivity() {
if (loginActivityClazz == null) {
try {
Class<?> NeedLoginClazz = Class.forName(UTILS_PATH);
Method getLoginActivityMethod = NeedLoginClazz.getDeclaredMethod("getLoginActivity");
getLoginActivityMethod.setAccessible(true);
String loginActivity = (String) getLoginActivityMethod.invoke(null);
loginActivityClazz = Class.forName(loginActivity);
} catch (Exception e) {
e.printStackTrace();
}
}
return loginActivityClazz;
}

2,判断是否登录


同理为了实现对判断是否登录的解耦,在判断是否能登录的方法上添加一个JudgeLogin注解,就可以在Hook中反射调用判断。当然这里也可以通过添加回调的方式实现。


2.5,小结


通过APT实现了对判断是否登录、判断哪些页面需要登录及跳转登录的解耦。


此时面临着最后一个问题,虽然前面已经实现了拦截并跳转到了登录页面,但是登录完成后再返回到原页面看似合理,实则不XXXX(词穷了,自行脑补😂),用户的意图被打断了。


接着就看看如何在登录成功后继续用户意图。


3, 继续用户意图


由于Intent实现了Parcelable接口,因此可以将它作为一个Intent的Extra参数传递。在Hook过程中可以获取原始Intent,因此只需在Hook中将用户的原始意图Intent作为一个附加参数存入跳转登录的Intent中,然后在登录页面获取到这个参数,登录成功后跳转到这个原始Intent即可。


1,传递原始意图


在动态代理中先拿到原始Intent,然后将它作为参数存入新的Intent中


new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("startActivity") && !isLogin()) {
// 目标Activity全类名
Intent originIntent = xxx;
String intentName = xxx;
if (isRequireLogin(intentName)) {
// 该Activity需要登录,跳转到登录页面
Intent intent = new Intent(context, getLoginActivity());
intent.putExtra(Constant.Hook_AMS_EXTRA_NAME, originIntent);
// 然后替换原Intent
...
}
}
return null;
}
}

2,获取原始意图并跳转


在登录页面,登录成功后判断其intent中是否有特定键值的附加数据,如果有则直接用它作为意图启动新页面,实现了继续用户意图的目的;


@LoginActivity
class LoginActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

...
binding.btnLogin.setOnClickListener {
// 登录成功了
var targetIntent = intent.getParcelableExtra<Intent>(AndLogin.TARGET_ACTIVITY_NAME)
// 如果存在targetIntent则启动目标intent
if (targetIntent != null) {
startActivity(targetIntent)
}
finish()
}
}

companion object {
// 该方法用于返回是否登录
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

如上所示,如果可以在当前Intent中获取到Hook时保存的数据,则说明存在目标Intent,只需将其启动即可。


看一下最终效果:


preview.gif


4, ARouter方案


熟悉ARouter的都知道,它有一个拦截器的东西,可以在跳转前做拦截操作。如下:


@Interceptor(name = "login", priority = 1)
public class LoginInterceptorImpl implements IInterceptor {
@Override
public void process(Postcard postcard, InterceptorCallback callback) {
...
if (isLogin) { // 已经登录不拦截
callback.onContinue(postcard);
} else { // 未登录则拦截
// callback.onInterrupt(null);
}
}

@Override
public void init(Context context) {
}
}

实现IInterceptor接口并添加Interceptor注解即可在路由跳转时实现拦截。


了解其原理的话可知:ARouter也只是在启动Activity前提供了拦截判断的时机,相当于本方案的第一步(Hook AMS)操作,后续实现解耦以及继续用户意图操作还需要自己实现。


5, 总结


本文提出了一种通过Hook AMS + APT实现集中式登录的方案,对比传统方式本方案存在以下优势:




  1. 以非侵入性的方式将分散的登录判断逻辑集中处理,减少了代码量,提高了开发效率。




  2. 增加或删除目标页面时无需修改判断逻辑,只需增加或删除其对应注解即可,符合开闭原则,降低了耦合度




  3. 在用户登录成功后直接跳转到目标界面,保证了用户操作不被中断。




本方案并没有太高深的东西,只是把常用的东西整合在一起,综合运用了一下。另外方案只是针对需要跳转页面的情况,对于判断是否登录后做其他操作的,比如弹出一个Toast这样的操作,可以通过AspectJ等来实现。


项目地址:github.com/wdsqjq/AndL…


最后,本方案提供了远程依赖,使用startup实现了无侵入初始化,使用方式如下:


1,添加依赖


allprojects {
repositories {
maven { url 'https://www.jitpack.io' }
}
}


dependencies {
implementation 'com.github.wdsqjq.AndLogin:lib:1.0.0'
kapt 'com.github.wdsqjq.AndLogin:apt_processor:1.0.0'
}

2,给需要登录的Activity添加注解


@RequireLogin
class TargetActivity1 : AppCompatActivity() {
...
}

@RequireLogin
class TargetActivity2 : AppCompatActivity() {
...
}


3,给登录Activity添加注解


@LoginActivity
class LoginActivity : AppCompatActivity() {
...
}

4,提供判断是否登录的方法


需要是一个静态方法


@LoginActivity
class LoginActivity : AppCompatActivity() {

companion object {
// 该方法用于返回是否登录
@JudgeLogin
@JvmStatic
fun checkLogin(): Boolean {
return SpUtil.isLogin()
}
}
}

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

一文彻底搞懂js中的位置计算

引言 文章中涉及到的api列表:scroll相关Apiclient相关Apioffset相关ApiElement.getBoundingClientRectAPiWindow.getComputedStyleApi 我们会结合api定义,知名开源库中的应用场...
继续阅读 »

引言


文章中涉及到的api列表:

scroll相关Api

client相关Api

offset相关Api

Element.getBoundingClientRectAPi

Window.getComputedStyleApi



我们会结合api定义,知名开源库中的应用场景来逐层分析这些api。足以应对工作中关于元素位置计算的大部分场景。




注意在使用位置计算api时要格外的小心,不合理的使用他们可能会造成布局抖动Layout Thrashing影响页面渲染。



scroll


首先我们先来看看scroll相关的属性和方法。


Element.scroll()


Element.scroll()方法是用于在给定的元素中滚动到某个特定坐标的Element 接口。


element.scroll(x-coord, y-coord)
element.scroll(options)


  • x-coord 是指在元素左上方区域横轴方向上想要显示的像素。

  • y-coord 是指在元素左上方区域纵轴方向上想要显示的像素。


也就是element.scroll(x,y)会将元素滚动条位置滚动到对应x,y的位置。


同时也支持element.scroll(options)方式调用,支持传入额外的配置:


{
left: number,
top: number,
behavior: 'smooth' | 'auto' // 平滑滚动还是默认直接滚动
}

Element.scrollHeight/scrollWidth



  • Element.scrollHeight 这个只读属性是一个元素内容高度的度量,包括由于溢出导致的视图中不可见内容。



scrollHeight 的值等于该元素在不使用滚动条的情况下为了适应视口中所用内容所需的最小高度。 没有垂直滚动条的情况下,scrollHeight值与元素视图填充所有内容所需要的最小值clientHeight相同。包括元素的padding,但不包括元素的border和margin。scrollHeight也包括 ::before 和 ::after这样的伪元素。



换句话说Element.scrollHeight在元素不存在滚动条的情况下是恒等于clientHeight的。


但是如果出现了滚动条的话scrollHeight指的是包含元素不可以见内容的高度,出现滚动条的情况下是scrollHeight恒大于clientHeight



  • Element.scrollWidth 这也是一个元素内容宽度的只读属性,包含由于溢出导致视图中不可以见的内容。



原理上和scrollHeight是同理的,只不过这里是宽度而非高度。



简单来说一个元素如果不存在滚动条,那么他们的scrollclient都是相等的值。如果存在了滚动条,client只会计算出当前元素展示出来的高度/宽度,而scroll不仅仅会计算当前元素展示出的,还会包含当前元素的滚动条隐藏内容的高度/宽度。


clientWidth/height + [滚动条被隐藏内容宽度/高度] = scrollWidth/Height


Element.scrollLeft/scrollTop




  • Element.scrollTop 属性可以获取或设置一个元素的内容垂直滚动的像素数.




  • Element.scrollLeft 属性可以读取或设置元素滚动条到元素左边的距离.





需要额外注意的是: 注意如果这个元素的内容排列方向(direction) 是rtl (right-to-left) ,那么滚动条会位于最右侧(内容开始处),并且scrollLeft值为0。此时,当你从右到左拖动滚动条时,scrollLeft会从0变为负数。



scrollLeft/Top在日常工作中是比较频繁使用关于操作滚动条的相关api,他们是一个可以设置的值。根据不同的值对应可以控制滚动条的位置。


其实这两个属性和上方的Element.scroll()可以达到相同的效果。



在实际工作中如果对于滚动操作有很频繁的需求,个人建议去使用better-scroll,它是一个移动/web端的通用js滚动库,内部是基于元素transform去操作的滚动并不会触发相关重塑/回流。



判断当前元素是否存在滚动条



出现滚动条便意味着元素空间将大于其内容显示区域,根据这个现象便可以得到判断是否出现滚动条的规则。



export const hasScrolled = (element, direction) => {
if (!element || element.nodeType !== 1) return;
if (direction === "vertical") {
return element.scrollHeight > element.clientHeight;
} else if (direction === "horizontal") {
return element.scrollWidth > element.clientWidth;
}
};

判断用户是否滚动到底部



本质上就是当元素出现滚动条时,判断当前元素出现的高度 + 滚动条高度 = 元素本身的高度(包含隐藏部分)



element.scrollHeight - element.scrollTop === element.clientHeight

client


MouseEvent.clientX/Y


MounseEvent.clientX/Y同样也是只读属性,它提供事件发生时的应用客户端区域的水平坐标。



例如,不论页面是否有垂直/水平滚动,当你点击客户端区域的左上角时,鼠标事件的 clientX/Y 值都将为 0 。



其实MouseEvent.clientX/Y也就是相对于当前视口(浏览器可视区)进行位置计算。


转载一张非常直白的图:


clientX


Element.clientHeight/clientWidth


Element.clientWidth/clinetHeight 属性表示元素的内部宽度,以像素计。该属性包括内边距 padding,但不包括边框 border、外边距 margin 和垂直滚动条(如果有的话)。



内联元素以及没有 CSS 样式的元素的 clientWidth 属性值为 0。



在不出现滚动条时候Element.clientWidth/Height === Element.scrollWidth/Height


image.png


Element.clientTop/clientLeft


Element.clientLeft表示一个元素的左边框的宽度,以像素表示。如果元素的文本方向是从右向左(RTL, right-to-left),并且由于内容溢出导致左边出现了一个垂直滚动条,则该属性包括滚动条的宽度。clientLeft 不包括左外边距和左内边距。clientLeft 是只读的。


同样的Element.clientTop表示元素上边框的宽度,也是一个只读属性。



这两个属性日常使用会比较少,但是也应该了解以避免搞混这些看似名称都类似的属性。



offset


MouseEvent.offsetX/offsetY


MouseEvent 接口的只读属性 offsetX/Y 规定了事件对象与目标节点的内填充边(padding edge)在 X/Y 轴方向上的偏移量。


相信使用过offest的同学对这个属性深有体会,它是相对于父元素的左边/上方的偏移量。



注意是触发元素也就是 e.target,额外小心如果事件对象中存在从一个子元素当移动到子元素内部时,e.offsetX/Y 此时相对于子元素的左上角偏移量。



offsetWidth/offsetHeight


HTMLElement.offsetWidth/Height 是一个只读属性,返回一个元素的布局宽度/高度。


所谓的布局宽度也就是相对于我们上边说到的clientHeight/Width,offsetHeight/Width,他们都是不包含border以及滚动条的宽/高(如果存在的话)。


offsetWidth/offsetHeight返回元素的布局宽度/高度,包含元素的边框(border)、水平线/垂直线上的内边距(padding)、竖直/水平方向滚动条(scrollbar)(如果存在的话)、以及CSS设置的宽度(width)的值


offsetTop/left


HTMLElement.offsetLeft 是一个只读属性,返回当前元素左上角相对于 HTMLElement.offsetParent 节点的左边界偏移的像素值。



注意返回的是相对于 HTMLElement.offsetParent 节点左边边界的偏移量。



何为HTMLElement.offsetParent?



HTMLElement.offsetParent 是一个只读属性,返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的 table,td,th,body 元素。当元素的 style.display 设置为 "none" 时,offsetParent 返回 null。offsetParent 很有用,因为 offsetTop 和 offsetLeft 都是相对于其内边距边界的。 -- MDN



讲讲人话,当前元素的祖先组件节点如果不存在任何 table,td,th 以及 position 属性为 relative,absolute 等为定位元素时,offsetLeft/offsetTop 返回的是距离 body 左/上角的偏移量。


当祖先元素中有定位元素(或者上述标签元素)时,它就可以被称为元素的offsetParent。元素的 offsetLeft/offsetTop 的值等于它的左边框左侧/顶边框顶部到它的 offsetParent 元素左边框的距离。


我们来看看这张图:


image.png


计算元素距离 body 的偏移量


当我们需要获得元素距离 body 的距离时,但是又无法确定父元素是否存在定位元素时(大多数时候在组件开发中,并不清楚父节点是否存在定位)。此时需要实现类似 jqery 的 offset()方法:获得当前元素对于 body 的偏移量。



  • 无法直接使用 offsetLeft/offsetTop 获取,因为并不确定父元素是否存在定位元素。

  • 使用递归解决,累加偏移量 offset,当前 offsetParent 不为 body 时。

  • 继续递归向上超着 offsetParent 累加 offset,直到遇到 body 元素停止。


const getOffsetSize = function(Node: any, offset?: any): any {
if (!offset) {
offset = {
x: 0,
y: 0
};
}
if (Node === document.body) return offset;
offset.x = offset.x + Node.offsetLeft;
offset.y = offset.y + Node.offsetTop;
return getOffsetSize(Node.offsetParent, offset);
};


注意:这里不可以使用 parentNode 上文已经讲过 offsetLeft/top 针对的是 HTMLElement.offsetParent 的偏移量而非 parentNode 的偏移量。



Element.getBoundingClientRect


用法讲解


Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置。



element.getBoundingClientRect()返回的相对于视口左上角的位置。



element.getBoundingClientRect()返回的 heightwidth 是针对元素可见区域的宽和高(具体尺寸根据 box-sizing 决定),并不包含滚动条被隐藏的内容。



TIP: 如果是标准盒子模型,元素的尺寸等于 width/height + padding + border-width 的总和。如果 box-sizing: border-box,元素的的尺寸等于 width/height。



rectObject = object.getBoundingClientRect();

返回值是一个 DOMRect 对象,这个对象是由该元素的 getClientRects() 方法返回的一组矩形的集合,就是该元素的 CSS 边框大小。返回的结果是包含完整元素的最小矩形,并且拥有 left, top, right, bottom, x, y, width, 和 height 这几个以像素为单位的只读属性用于描述整个边框。除了 widthheight 以外的属性是相对于视图窗口的左上角来计算的。


widthheight是计算元素的大小,其他属性都是相对于视口左上角来说的。


当计算边界矩形时,会考虑视口区域(或其他可滚动元素)内的滚动操作,也就是说,当滚动位置发生了改变,top 和 left 属性值就会随之立即发生变化(因此,它们的值是相对于视口的,而不是绝对的) 。如果你需要获得相对于整个网页左上角定位的属性值,那么只要给 top、left 属性值加上当前的滚动位置(通过 window.scrollX 和 window.scrollY),这样就可以获取与当前的滚动位置无关的值。


image.png


计算元素是否出现在视口内


利用的还是元素距离视口的位置小于视口的大小。



注意即便变成了负值,那么也表示元素曾经出现过在屏幕中只是现在不显示了而已。(就比如滑动过)



vue-lazy图片懒加载库源码就是这么判断的。


 isInView (): boolean {
const rect = this.el.getBoundingClientRect()
return rect.top < window.innerHeight && rect.left < window.innerWidth
}


如果rect.top < window.innerHeight表示当前元素已经已经出现在(过)页面中,left同理。



window.getComputedStyle


用法讲解


Window.getComputedStyle()方法返回一个对象,该对象在应用活动样式表并解析这些值可能包含的任何基本计算后报告元素的所有CSS属性的值。 私有的CSS属性值可以通过对象提供的API或通过简单地使用CSS属性名称进行索引来访问。


let style = window.getComputedStyle(element, [pseudoElt]);



  • element


     用于获取计算样式的Element




  • pseudoElt 可选


    指定一个要匹配的伪元素的字符串。必须对普通元素省略(或null)。




返回的style是一个实时的 CSSStyleDeclaration 对象,当元素的样式更改时,它会自动更新本身。


作者:19组清风
链接:https://juejin.cn/post/7006878952736161829

收起阅读 »

面试贼坑的十道js面试题(我只会最后一题)

前言 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批 typeof null 为什么是object null就出了一个 bug。...
继续阅读 »

前言



  • 现在前端面试经常遇到奇葩的题,有的听都没听过,何谈能答对,这些是小伙伴们投稿的题,大家来看看,出这些题的人,都优秀到不行啊,想要拿到满意的offer,不得不卷啊,头疼一批


typeof null 为什么是object




  • null就出了一个 bug。根据 type tags 信息,低位是 000,因此 null被判断成了一个对象。这就是为什么 typeofnull的返回值是 "object"。




  • 关于 null的类型在 MDN 文档中也有简单的描述:typeof - java | MDN




  • 在 ES6 中曾有关于修复此 bug 的提议,提议中称应该让 typeofnull==='null'wiki.ecma.org/doku.php?id… 但是该提议被无情的否决了,自此 typeofnull终于不再是一个 bug,而是一个 feature,并且永远不会被修复




0.1+0.2为什么不等于0.3,以及怎么等于0.3



  • 在开发过程中遇到类似这样的问题:


let n1 = 0.1, n2 = 0.2
console.log(n1 + n2) // 0.30000000000000004


  • 这里得到的不是想要的结果,要想等于0.3,就要把它进行转化:


(n1 + n2).toFixed(2) // 注意,toFixed为四舍五入

toFixed(num) 方法可把 Number 四舍五入为指定小数位数的数字。那为什么会出现这样的结果呢?


计算机是通过二进制的方式存储数据的,所以计算机计算0.1+0.2的时候,实际上是计算的两个数的二进制的和。0.1的二进制是0.0001100110011001100...(1100循环),0.2的二进制是:0.00110011001100...(1100循环),这两个数的二进制都是无限循环的数。那JavaScript是如何处理无限循环的二进制小数呢?


一般我们认为数字包括整数和小数,但是在 JavaScript 中只有一种数字类型:Number,它的实现遵循IEEE 754标准,使用64位固定长度来表示,也就是标准的double双精度浮点数。在二进制科学表示法中,双精度浮点数的小数部分最多只能保留52位,再加上前面的1,其实就是保留53位有效数字,剩余的需要舍去,遵从“0舍1入”的原则。


根据这个原则,0.1和0.2的二进制数相加,再转化为十进制数就是:0.30000000000000004


下面看一下双精度数是如何保存的:


2020080420355853.png



  • 第一部分(蓝色):用来存储符号位(sign),用来区分正负数,0表示正数,占用1位

  • 第二部分(绿色):用来存储指数(exponent),占用11位

  • 第三部分(红色):用来存储小数(fraction),占用52位


对于0.1,它的二进制为:


0.00011001100110011001100110011001100110011001100110011001 10011...

转为科学计数法(科学计数法的结果就是浮点数):


1.1001100110011001100110011001100110011001100110011001*2^-4

可以看出0.1的符号位为0,指数位为-4,小数位为:


1001100110011001100110011001100110011001100110011001

那么问题又来了,指数位是负数,该如何保存呢?


IEEE标准规定了一个偏移量,对于指数部分,每次都加这个偏移量进行保存,这样即使指数是负数,那么加上这个偏移量也就是正数了。由于JavaScript的数字是双精度数,这里就以双精度数为例,它的指数部分为11位,能表示的范围就是0~2047,IEEE固定双精度数的偏移量为1023



  • 当指数位不全是0也不全是1时(规格化的数值),IEEE规定,阶码计算公式为 e-Bias。 此时e最小值是1,则1-1023= -1022,e最大值是2046,则2046-1023=1023,可以看到,这种情况下取值范围是-1022~1013

  • 当指数位全部是0的时候(非规格化的数值),IEEE规定,阶码的计算公式为1-Bias,即1-1023= -1022。

  • 当指数位全部是1的时候(特殊值),IEEE规定这个浮点数可用来表示3个特殊值,分别是正无穷,负无穷,NaN。 具体的,小数位不为0的时候表示NaN;小数位为0时,当符号位s=0时表示正无穷,s=1时候表示负无穷。


对于上面的0.1的指数位为-4,-4+1023 = 1019 转化为二进制就是:1111111011.


所以,0.1表示为:


0 1111111011 1001100110011001100110011001100110011001100110011001

说了这么多,是时候该最开始的问题了,如何实现0.1+0.2=0.3呢?


对于这个问题,一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2-52,在ES6中,提供了Number.EPSILON属性,而它的值就是2-52,只要判断0.1+0.2-0.3是否小于Number.EPSILON,如果小于,就可以判断为0.1+0.2 ===0.3


function numberepsilon(arg1,arg2){                   
return Math.abs(arg1 - arg2) < Number.EPSILON;
}

console.log(numberepsilon(0.1 + 0.2, 0.3)); // true

为什么要用weakMap




  • WeakMap 为弱引用,利于垃圾回收机制。




  • 一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。




  • 总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。




RAF 和 RIC 是什么



  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。

  • requestIdleCallback:: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行,但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序。


escape、encodeURI、encodeURIComponent 的区别



  • encodeURI 是对整个 URI 进行转义,将 URI 中的非法字符转换为合法字符,所以对于一些在 URI 中有特殊意义的字符不会进行转义。

  • encodeURIComponent 是对 URI 的组成部分进行转义,所以一些特殊字符也会得到转义。

  • escape 和 encodeURI 的作用相同,不过它们对于 unicode 编码为 0xff 之外字符的时候会有区别,escape 是直接在字符的 unicode 编码前加上 %u,而 encodeURI 首先会将字符转换为 UTF-8 的格式,再在每个字节前加上 %。


await 到底在等啥


await 在等待什么呢? 一般来说,都认为 await 是在等待一个 async 函数完成。不过按语法说明,await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。


因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数,但要清楚,它等的实际是一个返回值。注意到 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果,所以,await 后面实际是可以接普通函数调用或者直接量的。所以下面这个示例完全可以正确运行:


function getSomething() {
return "something";
}
async function testAsync() {
return Promise.resolve("hello async");
}
async function test() {
const v1 = await getSomething();
const v2 = await testAsync();
console.log(v1, v2);
}
test();

await 表达式的运算结果取决于它等的是什么。



  • 如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。

  • 如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。


来看一个例子:


function testAsy(x){
return new Promise(resolve=>{setTimeout(() => {
resolve(x);
}, 3000)
}
)
}
async function testAwt(){
let result = await testAsy('hello world');
console.log(result); // 3秒钟之后出现hello world
console.log('cuger') // 3秒钟之后出现cug
}
testAwt();
console.log('cug') //立即输出cug

这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。await暂停当前async的执行,所以'cug''最先输出,hello world'和‘cuger’是3秒钟后同时出现的。


|| 和 && 操作符的返回值



  • || 和 && 首先会对第一个操作数执行条件判断,如果其不是布尔值就先强制转换为布尔类型,然后再执行条件判断。

  • 对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为 false 就返回第二个操作数的值。

  • && 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。

  • || 和 && 返回它们其中一个操作数的值,而非条件判断的结果


2 == [[[2]]]



  • 根据ES5规范,如果比较的两个值中有一个是数字类型,就会尝试将另外一个值强制转换成数字,再进行比较。而数组强制转换成数字的过程会先调用它的 toString方法转成字符串,然后再转成数字。所以 [2]会被转成 "2",然后递归调用,最终 [[[2]]] 会被转成数字 2。


var x = [typeof x, typeof y][1];typeof typeof x;//"string"




  • 因为没有声明过变量y,所以typeof y返回"undefined"




  • 将typeof y的结果赋值给x,也就是说x现在是"undefined"




  • 然后typeof x当然是"string"




  • 最后typeof "string"的结果自然还是"string"




你能接受加班吗?而且我们加班不给钱!



  • f¥¥¥¥¥k y**********u

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

收起阅读 »

for 循环不是目的,map 映射更有意义!【FP探究】

楔子 在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递! 例🌰: function sayHi() { console.log("...
继续阅读 »

楔子


在 JavaScript 中,由于 Function 本质也是对象(这与 Haskell 中【函数的本质是值】思路一致),所以我们可以把 Function 作为参数来进行传递


例🌰:


function sayHi() {
console.log("Hi");
}
function sayBye() {
console.log("Bye");
}

function greet(type, sayHi, sayBye) {
type === 1 ? sayHi() : sayBye()
}

greet(1, sayHi, sayBye); // Hi

又得讲这个老生常谈的定义:如果一个函数“接收函数作为参数”或“返回函数作为输出”,那么这个函数被称作“高阶函数”


本篇要谈的是:高阶函数中的 mapfilterreduce 是【如何实践】的,我愿称之为:高阶映射!!


先别觉得这东西陌生,其实咱们天天都见!!


例🌰:


[1,2,3].map(item => item*2)

实践



Talk is cheap. Show me the code.



以下有 4 组代码,每组的 2 个代码片段实现目标一致,但实现方式有异,感受感受,你更喜欢哪个?💖


第 1 组:


1️⃣


const arr1 = [1, 2, 3];
const arr2 = [];
for(let i = 0; i < arr1.length; i++) {
arr2.push(arr1[i] * 2);
}
console.log(arr2); // [ 2, 4, 6 ]

2️⃣


const arr1 = [1, 2, 3];
const arr2 = arr1.map(item => item * 2);
console.log(arr2); // [ 2, 4, 6 ]

第 2 组:


1️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = [];
for(let i = 0; i < birthYear.length; i++) {
let age = 2018 - birthYear[i];
ages.push(age);
}
console.log(ages); // [ 43, 21, 16, 23, 33 ]

2️⃣


const birthYear = [1975, 1997, 2002, 1995, 1985];
const ages = birthYear.map(year => 2018 - year);
console.log(ages); // [ 43, 21, 16, 23, 33 ]

第 3 组:


1️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = [];
for(let i = 0; i < persons.length; i++) {
if(persons[i].age >= 18) {
fullAge.push(persons[i]);
}
}
console.log(fullAge);

2️⃣


const persons = [
{ name: 'Peter', age: 16 },
{ name: 'Mark', age: 18 },
{ name: 'John', age: 27 },
{ name: 'Jane', age: 14 },
{ name: 'Tony', age: 24},
];
const fullAge = persons.filter(person => person.age >= 18);
console.log(fullAge);

第 4 组:


1️⃣


const arr = [5, 7, 1, 8, 4];
let sum = 0;
for(let i = 0; i < arr.length; i++) {
sum = sum + arr[i];
}
console.log(sum); // 25

2️⃣


const arr = [5, 7, 1, 8, 4];
const sum = arr.reduce(function(accumulator, currentValue) {
return accumulator + currentValue;
});
console.log(sum); // 25

更喜欢哪个?有答案了吗?


image.png


每组的代码片段 2️⃣ 就是map/filter/reduce高阶函数的应用,没有别的说的,就是更加简洁易读


手写


实际上,map/filter/reduce 也是基于 for 循环封装来的,所以我们也能自己实现一套相同的 高阶映射 🚀;



  • map1


Array.prototype.map1 = function(fn) {
let newArr = [];
for (let i = 0; i < this.length; i++) {
newArr.push(fn(this[i]))
};
return newArr;
}

console.log([1,2,3].map1(item => item*2)) // [2,4,6]


  • filter1


Array.prototype.filter1 = function (fn) {
let newArr=[];
for(let i=0;i<this.length;i++){
fn(this[i]) && newArr.push(this[i]);
}
return newArr;
};

console.log([1,2,3].filter1(item => item>2)) // [3]


  • reduce1


Array.prototype.reduce1 = function (reducer,initVal) {
for(let i=0;i<this.length;i++){
initVal =reducer(initVal,this[i],i,this);
}
return initVal
};

console.log([1,2,3].reduce1((a,b)=>a+b,0)) // 6

如果你不想直接挂在原型链上🛸:



  • mapForEach


function mapForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
newArray.push(
fn(arr[i])
);
}
return newArray;
}

mapForEach([1,2,3],item=>item*2) // [2,4,6]


  • filterForEach


function filterForEach(arr, fn) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
fn(arr[i]) && newArray.push(arr[i]);
}
return newArray;
}

filterForEach([1,2,3],item=>item>2) // [3]


  • reduceForEach


function reduceForEach(arr,reducer,initVal) {
const newArray = [];
for(let i = 0; i < arr.length; i++) {
initVal =reducer(initVal,arr[i],i,arr);
}
return initVal;
}

reduceForEach([1,2,3],(a,b)=>a+b,0) // 6

这里本瓜有个小疑惑,在 ES6 之前,有没有一个库做过这样的封装❓


小结


本篇虽基础,但很重要


对一些惯用写法的审视、改变,会产生一些奇妙的思路~ 稀松平常的 map 映射能做的比想象中的要多得多!


for 循环遍历只是操作性的手段,不是目的!而封装过后的 map 映射有了更易读的意义,映射关系(输入、输出)也是函数式编程之核心!


YY一下:既然 map 这类函数都是从 for 循环封装来的,如果你能封装一个基于 for 循环的另一种特别实用的高阶映射或者其它高阶函数,是不是意味着:有朝一日有可能被纳入 JS 版本标准 API 中?🐶🐶🐶


或许:先意识到我们每天都在使用的高阶函数,刻意的去使用、训练,然后能举一反三,才能做上面的想象吧~~~



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

收起阅读 »

用canvas实现一个大气球送给你

一、背景 近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。 二、实现 在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分: 实现球体部分; 实现气球口...
继续阅读 »

一、背景



近期在做一个气球挂件的特效需求,值此契机,来跟大家分享一下如何利用canvas以及对应的数学知识构造一个栩栩如生的气球。



balloon1.gif


二、实现



在实现这个看似是圆鼓鼓的气球之前,先了解一下其实现思路,主要分为以下几个部分:




  1. 实现球体部分;

  2. 实现气球口子部分;

  3. 实现气球的线部分;

  4. 进行颜色填充;

  5. 实现动画;


气球.PNG


2.1 球体部分实现



对于这样的气球的球体部分,大家都有什么好的实现思路的?相信大家肯定会有多种多样的实现方案,我也是在看到某位大佬的效果后,感受到了利用四个三次贝塞尔曲线实现这个效果的妙处。为了看懂后续代码,先了解一下三次贝塞尔曲线的原理。(注:引用了CSDN上某位大佬的文章,写的很好,下图引用于此)



三次贝塞尔曲线.gif



在上图中P0为起始点、P3为终止点,P1和P2为控制点,其最终的曲线公式如下所示:



B(t)=(1−t)^3 * P0+3t(1−t)^2 * P1+3t ^ 2(1−t) * P2+t ^ 3P3, t∈[0,1]



上述已经列出了三次贝塞尔曲线的效果图和公式,但是通过这个怎么跟我们的气球挂上钩呢?下面通过几张图就理解了:



image.png



如上图所示,就是实现整个气球球体的思路,具体解释如下所示:




  1. A图中起始点为p1,终止点为p2,控制点为c1、c2,让两个控制点重合,绘制出的效果并不是很像气球的一部分,此时就要通过改变控制点来改变其外观;

  2. 改变控制点c1、c2,c1中y值不变,减小x值;c2中x值不变,增大y值(注意canvas中坐标方向即可),改变后就得到了图B的效果,此时就跟气球外观很像了;

  3. 紧接着按照这个方法就可以实现整个的气球球体部分的外观。


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.translate(250, 250);
drawCoordiante(ctx);
ctx.save();
ctx.beginPath();
ctx.moveTo(0, -80);
ctx.bezierCurveTo(45, -80, 80, -45, 80, 0);
ctx.bezierCurveTo(80, 85, 45, 120, 0, 120);
ctx.bezierCurveTo(-45, 120, -80, 85, -80, 0);
ctx.bezierCurveTo(-80, -45, -45, -80, 0, -80);
ctx.stroke();
ctx.restore();
}

function drawCoordiante(ctx) {
ctx.beginPath();
ctx.moveTo(-120, 0);
ctx.lineTo(120, 0);
ctx.moveTo(0, -120);
ctx.lineTo(0, 120);
ctx.closePath();
ctx.stroke();
}

2.2 口子部分实现



口子部分可以简化为一个三角形,效果如下所示:



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(-5, 130);
ctx.lineTo(5, 130);
ctx.closePath();
ctx.stroke();
ctx.restore();
}

2.3 线部分实现



线实现的比较简单,就用了一段直线实现



image.png


function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

……

ctx.save();
ctx.beginPath();
ctx.moveTo(0, 120);
ctx.lineTo(0, 300);
ctx.stroke();
ctx.restore();
}

2.4 进行填充



气球部分的填充用了圆形渐变效果,相比于纯色来说更加漂亮一些。



function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

ctx.fillStyle = getBalloonGradient(ctx, 0, 0, 80, 210);
……

}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}

image.png


2.5 动画效果及整体代码



上述流程已经将一个静态的气球部分绘制完毕了,要想实现动画效果只需要利用requestAnimationFrame函数不断循环调用即可实现。下面直接抛出整体代码,方便同学们观察效果进行调试,整体代码如下所示:



let posX = 225;
let posY = 300;
let points = getPoints();
draw();

function draw() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (posY < -200) {
posY = 300;
posX += 300 * (Math.random() - 0.5);
points = getPoints();
}
else {
posY -= 2;
}
ctx.save();
ctx.translate(posX, posY);
drawBalloon(ctx, points);
ctx.restore();

window.requestAnimationFrame(draw);
}

function drawBalloon(ctx, points) {
ctx.scale(points.scale, points.scale);
ctx.save();
ctx.fillStyle = getBalloonGradient(ctx, 0, 0, points.R, points.hue);
// 绘制球体部分
ctx.moveTo(points.p1.x, points.p1.y);
ctx.bezierCurveTo(points.pC1to2A.x, points.pC1to2A.y, points.pC1to2B.x, points.pC1to2B.y, points.p2.x, points.p2.y);
ctx.bezierCurveTo(points.pC2to3A.x, points.pC2to3A.y, points.pC2to3B.x, points.pC2to3B.y, points.p3.x, points.p3.y);
ctx.bezierCurveTo(points.pC3to4A.x, points.pC3to4A.y, points.pC3to4B.x, points.pC3to4B.y, points.p4.x, points.p4.y);
ctx.bezierCurveTo(points.pC4to1A.x, points.pC4to1A.y, points.pC4to1B.x, points.pC4to1B.y, points.p1.x, points.p1.y);

// 绘制气球钮部分
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.knowA.x, points.knowA.y);
ctx.lineTo(points.knowB.x, points.knowB.y);
ctx.fill();
ctx.restore();

// 绘制线部分
ctx.save();
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(points.p3.x, points.p3.y);
ctx.lineTo(points.lineEnd.x, points.lineEnd.y);
ctx.stroke();
ctx.restore();
}

function getPoints() {
const offset = 35;
return {
scale: 0.3 + Math.random() / 2,
hue: Math.random() * 255,
R: 80,
p1: {
x: 0,
y: -80
},
pC1to2A: {
x: 80 - offset,
y: -80
},
pC1to2B: {
x: 80,
y: -80 + offset
},
p2: {
x: 80,
y: 0
},
pC2to3A: {
x: 80,
y: 120 - offset
},
pC2to3B: {
x: 80 - offset,
y: 120
},
p3: {
x: 0,
y: 120
},
pC3to4A: {
x: -80 + offset,
y: 120
},
pC3to4B: {
x: -80,
y: 120 - offset
},
p4: {
x: -80,
y: 0
},
pC4to1A: {
x: -80,
y: -80 + offset
},
pC4to1B: {
x: -80 + offset,
y: -80
},
knowA: {
x: -5,
y: 130
},
knowB: {
x: 5,
y: 130
},
lineEnd: {
x: 0,
y: 250
}
};
}

function getBalloonGradient(ctx, x, y, r, hue) {
const grd = ctx.createRadialGradient(x, y, 0, x, y, r);
grd.addColorStop(0, 'hsla(' + hue + ', 100%, 65%, .95)');
grd.addColorStop(0.4, 'hsla(' + hue + ', 100%, 45%, .85)');
grd.addColorStop(1, 'hsla(' + hue + ', 100%, 25%, .80)');
return grd;
}


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

收起阅读 »

通过一个例子学习css层叠上下文

层叠上下文 & 层叠等级 & 层叠规则 http://www.w3.org/TR/CSS22/vi… The order in which the rendering tree is painted onto the canvas is d...
继续阅读 »

层叠上下文 & 层叠等级 & 层叠规则



http://www.w3.org/TR/CSS22/vi…


The order in which the rendering tree is painted onto the canvas is described in terms of stacking contexts. Stacking contexts can contain further stacking contexts. A stacking context is atomic from the point of view of its parent stacking context; boxes in other stacking contexts may not come between any of its boxes.


Each box belongs to one stacking context. Each positioned box in a given stacking context has an integer stack level, which is its position on the z-axis relative other stack levels within the same stacking context. Boxes with greater stack levels are always formatted in front of boxes with lower stack levels. Boxes may have negative stack levels. Boxes with the same stack level in a stacking context are stacked back-to-front according to document tree order.


The root element forms the root stacking context.



翻译一下:
渲染树被绘制到画布上的顺序是根据层叠上下文来描述的。层叠上下文可以包含更多的层叠上下文。从父层叠上下文的角度来看,层叠上下文是原子的;其他层叠上下文中的盒子可能不会出现在它的任何盒子中。


每个框都属于一个层叠上下文。给定层叠上下文中的每个定位框都有一个整数层叠等级,这是它在 z 轴上相对于同一层叠上下文中其他层叠等级的位置。具有较高层叠等级的框始终放置在具有较低层叠等级的框之前。盒子可能有负的层叠等级。层叠上下文中具有相同层叠等级的框根据文档树顺序从后到前绘制。


根元素创建根层叠上下文。


理解:
所有的元素都属于一个层叠上下文,所以所有的元素都有自己的层叠等级。
每个元素都有自己所属的层叠上下文,在当前层叠上下文中具有自己的层叠等级。



那层叠等级的规则是啥呢?



http://www.w3.org/TR/CSS22/vi…


Within each stacking context, the following layers are painted in back-to-front order:


the background and borders of the element forming the stacking context.
the child stacking contexts with negative stack levels (most negative first).
the in-flow, non-inline-level, non-positioned descendants.
the non-positioned floats.
the in-flow, inline-level, non-positioned descendants, including inline tables and inline blocks.
the child stacking contexts with stack level 0 and the positioned descendants with stack level 0.
the child stacking contexts with positive stack levels (least positive first).



在每一个层叠上下文中,阿照下面的顺序从后往前绘制。



  1. 创建层叠上下文元素的背景和边框

  2. 创建层叠上下文元素的具有负层叠等级子元素

  3. 非inline元素并且没有定位的后代【block后代】

  4. 非定位的浮动元素

  5. 包括inline-table / inline-block的非定位inline元素

  6. 创建层叠上下文元素的层叠等级为0的子元素【0 / auto】

  7. 创建层叠上下文元素的层叠等级为大于0的子元素



关于这个等级张鑫旭有一张图说明
image.png
这里提到了一个新增的:不依赖于z-index的层叠上下文,这里指的应该是css3会有一些元素在不通过定位来创建新的层叠上下文



  1. z-index值不为auto的flex项(父元素display:flex|inline-flex).

  2. 元素的opacity值不是1.

  3. 元素的transform值不是none.

  4. 元素mix-blend-mode值不是normal.

  5. 元素的filter值不是none.

  6. 元素的isolation值是isolate.

  7. will-change指定的属性值为上面任意一个。

  8. 元素的-webkit-overflow-scrolling设为touch.





Demo



先看parent元素


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 100px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div class="parent">
parent
<div class="child1">child1
<div class="child1-2">child1-2</div>
<div class="child1-1">child1-1</div>
<!-- <div>child1-1</div>
<div>child1-2</div> -->
</div>
<div class="child2">
child2
</div>
</div>
</body>

image.png


先从根节点看起:根节点上根层级上下文,因为只有一个子节点parent。然后parent有自己的层叠上下文。parent有两个子节点,上文说到每个盒子属于一个层叠上下文,parent属于html的层叠上下文,parent会创建自己的层叠上下文,当然这个层叠上下文的作用主要针对parent的子元素。child1,child2。


image.pngimage.png


因为child1的z-index为1,child2的z-index为-1。所以这里的child1会绘制在child2的上面。


当我们在看child1的子元素和child2的子元素就不能放在一起看了,因为child1和child2都创建了自己的层叠上下文。只能独立看了。


这里child2的绘制会在parent的上面,尽管child2的z-index为负树。这里也对应了上面说的7层关系。因为parent属于创建层叠上下文的元素。



知识点:层叠上下文



  1. 普通元素的层叠等级优先由其所在的层叠上下文决定。

  2. 层叠等级的比较只有在当前层叠上下文元素中才有意义。不同层叠上下文中比较层叠等级是没有意义的。





知识点:层叠等级



  1. 在同一个层叠上下文中,它描述定义的是该层叠上下文中的层叠上下文元素在Z轴上的上下顺序。

  2. 在其他普通元素中,它描述定义的是这些普通元素在Z轴上的上下顺序。





接下来看block层级小于float


image.png


再看具体的页面渲染,我们修改一下代码,将child1-2和child1-2的顺序调换一下:


image.pngimage.png


这里不同的顺序会有不同的效果:第二张图看得出来是我们期望的,child1-2绘制到了chil1-1下面。因为float元素没有脱离文本流,所以child1-2的文本会被挤压到下面去。那么我们看一下第一张图为什么会这样。
从float的概念当中就可以看出来了。
浮动定位作用的是当前行,当前浮动元素在绘制的时候,child1父元素第一个元素是block元素,所以。float在绘制的时候,因为child1-1的宽度和child1的宽度相同,所以float所在的当前行就是目前的位置。第二张图是我们期望的结果是因为float在绘制的时候所在的当前行就是第一行。所以会按照我们期望的体现。



接下来看float小于inline / inline-block


我们接着上面第二张图继续看。这样是看不出来效果的,需要修改一下代码再看。


<style>
.parent {
width: 100px;
height: 200px;
background: #168bf5;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.child1 {
width: 200px;
height: 200px;
background: #32d19c;
position: absolute;
top: 20px;
left: 20px;
z-index: 1;
}
.child1-2 {
width: 100px;
height: 200px;
background: #7131c1;
display: inline-block;
}
.child1-1 {
width: 100px;
height: 200px;
background: #808080;
margin: 10px -15px 10px 10px;
float: left;
}
.child2 {
width: 100px;
height: 200px;
background: #e4c950;
position: absolute;
top: 40px;
left: 40px;
z-index: -1;
}
</style>
</head>

<body>
<div>
parent
<div>child1
<divhljs-number">1">child1-1</div>
<divhljs-number">2">child1-2</div>
</div>
<div>
child2
</div>
</div>
</body>

image.png
修改代码是需要将float元素和inline-block元素放在同一行,如果不是在同一行是没意义的。我们可以看到child1的文本节点和child1-2的inline-block元素都绘制在了child1-1的元素上面了。


论证一下css3的内容


也就是下面这个红框的内容:


image.png
继续用上面的例子:
上面看到的float元素已经放置在了inline / inline-block内容的下面。现在我们加一下:上面说的css3的样式在看一下。下面的两个例子可以看到之前放置在inline / inline-block下面的child1-1已经绘制在上面了。



opacity


image.png



tranform


image.png





概念



z-index



  1. 首先,z-index属性值并不是在任何元素上都有效果。它仅在定位元素(定义了position属性,且属性值为非static值的元素)上有效果。

  2. 判断元素在Z轴上的堆叠顺序,不仅仅是直接比较两个元素的z-index值的大小,这个堆叠顺序实际由元素的层叠上下文层叠等级共同决定。





层叠上下文的特性



  • 层叠上下文的层叠水平要比普通元素高;

  • 层叠上下文可以嵌套,内部层叠上下文及其所有子元素均受制于外部的层叠上下文。

  • 每个层叠上下文和兄弟元素独立,也就是当进行层叠变化或渲染的时候,只需要考虑后代元素。

  • 每个层叠上下文是自成体系的,当元素发生层叠的时候,整个元素被认为是在父层叠上下文的层叠顺序中



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

收起阅读 »

【中秋】纯CSS实现日地月的公转

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。 但因为我根本没咋学过前端,这两天恶补了一下重学了 flexbox 和 grid ,成...
继续阅读 »

我们都知道中秋的月亮又大又圆,是因为太阳地球月亮在公转过程中处在了一条直线上,地球在中间,太阳和月球分别在地球的两端,这天的月相便是满月。这段可以略过,是为了跟中秋扯上关系。


但因为我根本没咋学过前端,这两天恶补了一下重学了 flexboxgrid ,成果应该说还挺好看(如果我的审美没有问题的话)。


配色我挺喜欢的,希望你也喜欢。


源码我放到了 CodePen 上,链接 Sun Earth Moon (codepen.io)


HTML


重点是CSS,HTML放上三个 div 就🆗了。


<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Mancuoj</title>
<link
href="simulation.css"
rel="stylesheet"
/>
</head>

<body>
<h1>Mancuoj</h1>
<figure class="container">
<div class="sun"></div>
<div class="earth">
<div class="moon"></div>
</div>
</figure>
</body>
</html>

背景和文字


导入我最喜欢的 Lobster 字体,然后设为白色,字体细一点。


@import url("https://fonts.googleapis.com/css2?family=Lobster&display=swap");

h1 {
color: white;
font-size: 60px;
font-family: Lobster, monospace;
font-weight: 100;
}

背景随便找了一个偏黑紫色,然后把画的内容设置到中间。


body {
margin: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #2f3141;
}

.container {
font-size: 10px;
width: 40em;
height: 40em;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

日地月动画


众所周知:地球绕着太阳转,月球绕着地球转。


我们画的是公转,太阳就直接画出来再加个阴影高光,月亮地球转就可以了。


最重要的其实是配色(文章末尾有推荐网站),我实验好长时间的配色,最终用了三个渐变色来表示日地月。


日: linear-gradient(#fcd670, #f2784b);
地: linear-gradient(#19b5fe, #7befb2);
月: linear-gradient(#8d6e63, #ffe0b2);

CSS 应该难不到大家,随便看看吧。


轨道用到了 border,用银色线条当作公转的轨迹。


动画用到了自带的 animation ,每次旋转一周。


.sun {
position: absolute;
width: 10em;
height: 10em;
background: linear-gradient(#fcd670, #f2784b);
border-radius: 50%;
box-shadow: 0 0 8px 8px rgba(242, 120, 75, 0.2);
}

.earth {
--diameter: 30;
--duration: 36.5;
}

.moon {
--diameter: 8;
--duration: 2.7;
top: 0.3em;
right: 0.3em;
}

.earth,
.moon {
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
border-width: 0.1em;
border-style: solid solid none none;
border-color: silver transparent transparent transparent;
border-radius: 50%;
animation: orbit linear infinite;
animation-duration: calc(var(--duration) * 1s);
}

@keyframes orbit {
to {
transform: rotate(1turn);
}
}

.earth::before {
--diameter: 3;
--color: linear-gradient(#19b5fe, #7befb2);
--top: 2.8;
--right: 2.8;
}

.moon::before {
--diameter: 1.2;
--color: linear-gradient(#8d6e63, #ffe0b2);
--top: 0.8;
--right: 0.2;
}

.earth::before,
.moon::before {
content: "";
position: absolute;
width: calc(var(--diameter) * 1em);
height: calc(var(--diameter) * 1em);
background: var(--color);
border-radius: 50%;
top: calc(var(--top) * 1em);
right: calc(var(--right) * 1em);
}

总结


参加个活动真不容易,不过前端还是挺好玩的。


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

收起阅读 »

Bitmap和Drawable

Bitmap:图片信息的存储工具,保存每一个像素是什么颜色image: width:640 height:400 pixel:ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000f...
继续阅读 »
  • Bitmap:图片信息的存储工具,保存每一个像素是什么颜色

image: width:640 height:400 pixel:ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000ff0000

  • Drawable是什么(Drawable在代码上是接口,BitmapDrawable、ColorDrawable等实现)?

drawable是绘制工具,重写draw进行绘制

  • view和drawable?
  1. 都是使用canvas进行绘制。
  2. drawable纯绘制工具。
  3. view会包含测量、布局、绘制。
  4. drawable绘制的时候一定要设置边界setBounds
class DrawableViewcontextContextattrAttributeSet):Viewcontextattr){
private val drawable = ColorDrawable(Color.RED)
override fun onDraw(canvas:Canvas){
super.onDraw(canvas)
drawable.setBounds(0,0,width,height)
drawable.draw(canvas)
}
}
  • Bitmap和Drawable怎么互转?(其实不是互转,是使用一个实例创建了另外一个实例)
  1. Bitmap转Drawable

java

Drawable d = new BitmapDrawable(getResource(),bitmap);

kotlin(ktx)

bitmap.toDrawable(resource)
  1. Drawable转Bitmap

java

public static Bitmap drawableToBitmap(Drawable drawable){
Bitmap bitmap = null;
if(drawable instance BitmapDrawable){//1、如果是BitmapDrawable
BitmapDrawable bitmapDrawable = (BitmapDrawable)drawable;
if(bitmapDrawable.getBitmap()!=null){
return bitmapDrawable.getBitmap();
}
}
//2、如果drawable的宽高小于等于0
if(drawable.getIntrinsicWidth()<=0||drawable.getIntrinsicHeight()<=0){
bitmap = Bitmap.createBitmap(1,1,Bitmap.ARGB_8888);
}else{
bitmap =Bitmap.createBitmap(drawable.getIntrinsicWidth(),drawable.getIntrinsicHeight(),
Bitmap.Config.ARGB_8888)
}
Canvas canvas = new Canvas(bitmap);
drawable.setBounds(0,0,canvas.getWidth(),canvas.getHeight());
drawable.draw(canvas);
return bitmap;
}

kotlin(ktx)

drawable.toBitmap()
  • 自定义Drawable(作用:一个自定义drawable可以把多个view中的重复代码提出来,在多个view之间进行复用)
//画一个网眼
private val INTERVAL = 50.dp
class MeshDrawable:Drawable(){
private val paint = Paint(Paint.ANT_ALIAS_FLAG)
override fun draw(canvas:Canvas){
var x = bounds.lef.toFloat()
while(x<=bounds.right.toFloat()){
canvas.drawLine(x,bound.top.toFloat(),
x,bounds.bottom.toFloat(),paint)
x+=INTERVAL
}
var y = bounds.top.toFloat()
while(y<=bounds.bottom.toFloat()){
canvas.drawLine(bounds.left.toFloat(),y,
bounds.right.toFloat(),y,paint)
y+=INTERVAL
}
}
override fun setAlpha(alpha:Int){
paint.alpha = alpha
}
override fun getAlpha():Int{
return paint.alpha
}
override fun getOpacity():Int{//不透明度
return when(paint.alpha){
0->PixelFormat.TRANSPARENT
0xff ->PixelFormat.OPAQUE
else ->PixelFormat.TRANSLUCENT
}
}
override fun setColorFilter(colorFilter:ColorFilter?){
paint.colorFilter = colorFilter
}
override fun getColorFilter():ColorFilter{
return paint.colorFilter
}
}

getWidth() 是实际显示的宽度。

getMeasureWidth() 是测量宽度,在布局之前计算出来的。

getIntrinsicWidth() 是原有宽度,有时候原有宽度可能很大,但是实际上空间不够,所有效果上并没有那么大,这个方法可以获得原有宽度,可以辅助测量的时候选择合适的展示宽度。

getMinimumWidth() 是最小宽度,是XML参数定义里的 minWidth,也是一个辅助测量展示的参数。

收起阅读 »

Android高德地图踩坑记录-内存泄漏问题

1、问题现象最近做项目优化,在查找app可能存在的内存泄漏地方,项目中有用到高德地图SDK,有一个页面有展示地图,每次退出该页面的时候,LeakCanary老是提示有内存泄漏,泄漏的大概信息如下:2、排查问题看样子像是高德地图相关的内存泄漏,不过为了进一步可以...
继续阅读 »

1、问题现象

最近做项目优化,在查找app可能存在的内存泄漏地方,项目中有用到高德地图SDK,有一个页面有展示地图,每次退出该页面的时候,LeakCanary老是提示有内存泄漏,泄漏的大概信息如下:

image.png

2、排查问题

看样子像是高德地图相关的内存泄漏,不过为了进一步可以定位到问题,通常可以采用一种虽然有些笨但是可以定位到问题点的方法:控制变量法,排除到不太可能出现问题的地方,只保留可能出现的问题,具体是先注释掉和高德地图无关的代码,然后复现问题,确保问题是出在和高德地图相关的代码上

经过一系列的注释代码然后复现操作,明确内存泄漏的点是在高德地图相关的操作上,通过分析LeakCanary生成的Heap Dump(堆转储)文件,也验证了这个猜想

image.png

我在代码里有封装过一个关于地图操作的utils类,刚开始以为是在页面销毁的时候,这个utils类里有一些资源没有释放,比如当前Activity的context引用,在改为Application引用之后,发现问题还是有,然后在Activity销毁的时候,对utils里的一些资源进行了释放,发现还是不可以

后来经过在网上查找资料,查看高德地图官方demo,发现一个细节有可能是使用Butterknife的问题

image.png

因为在onDestroy方法里,我有写MapView的销毁方法,但是没有进入到if语句里面

image.png

3、问题解决方式

不使用ButterKnife的方式获取MapView控件,采用原生的findViewById的方式来获取控件对象

image.png

image.png

经过反复测试,退出页面之后,LeakCanary没有报内存泄漏的吐司

4、总结

使用高德地图SDK,地图控件MapView,使用原生的findViewById的方式来获取

收起阅读 »

如何打造一款权限请求框架

原理通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。实现不可见的Fragmentinternal class EPermissionFragment : Fragment() { private var mCal...
继续阅读 »

原理

通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。

实现

不可见的Fragment

internal class EPermissionFragment : Fragment() {
private var mCallback: EPermissionCallback? = null

fun requestPermission(callback: EPermissionCallback, vararg permissions: String) {
mCallback = callback
// 申请权限
requestPermissions(permissions, CODE_REQUEST_PERMISSION)
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CODE_REQUEST_PERMISSION) {
val deniedList = ArrayList<String>()
val deniedForeverList = ArrayList<String>()
grantResults.forEachIndexed { index, result ->
// 提取权限申请结果
if (result != PackageManager.PERMISSION_GRANTED) {
val permission = permissions[index]
deniedList.add(permission)
// 是否拒绝且不再显示
if (!shouldShowRequestPermissionRationale(permission)) {
deniedForeverList.add(permission)
}
}
}
if (deniedList.isEmpty()) mCallback?.onAllGranted()
if (deniedList.isNotEmpty()) mCallback?.onDenied(deniedList)
if (deniedForeverList.isNotEmpty()) mCallback?.onDeniedForever(deniedForeverList)
}
}

override fun onDestroy() {
mCallback = null
super.onDestroy()
}
}

封装权限申请

// 扩展FragmentActivity
fun FragmentActivity.runWithPermissions(
vararg permissions: String,
onDenied: (ArrayList<String>) -> Unit = { _ -> },
onDeniedForever: (ArrayList<String>) -> Unit = { _ -> },
onAllGranted: () -> Unit = {}
) {
if (checkPermissions(*permissions)) {
onAllGranted()
return
}
// 添加一个不可见的Fragment
val isFragmentExist = supportFragmentManager.findFragmentByTag(EPermissionFragment.TAG)
val fragment = if (isFragmentExist != null) {
isFragmentExist as EPermissionFragment
} else {
val invisibleFragment = EPermissionFragment()
supportFragmentManager.beginTransaction().add(invisibleFragment, EPermissionFragment.TAG).commitNowAllowingStateLoss()
invisibleFragment
}
val callback = object : EPermissionCallback {
override fun onAllGranted() {
onAllGranted()
}

override fun onDenied(deniedList: ArrayList<String>) {
onDenied(deniedList)
}

override fun onDeniedForever(deniedForeverList: ArrayList<String>) {
onDeniedForever(deniedForeverList)
}
}
// 申请权限
fragment.requestPermission(callback, *permissions)
}

使用方法

项目build.gradle添加

allprojects {
repositories {
...
maven { url 'https://www.jitpack.io' }
}
}

模块build.gradle添加

dependencies {
implementation 'com.github.RickyHal:EPermission:$latest_version'
}

在Activity或者Fragment中直接调用

// 申请存储权限
runWithPermissions(
*EPermissions.STORAGE,
onDenied = {
Toast.makeText(this, "STORAGE permission denied", Toast.LENGTH_SHORT).show()
},
onDeniedForever = {
Toast.makeText(this, "STORAGE permission denied forever", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "STORAGE permission granted", Toast.LENGTH_SHORT).show()
}
)

也可以用下面这个简单的方法

runWithStoragePermission(onFailed = {
Toast.makeText(this, "SMS permission denied", Toast.LENGTH_SHORT).show()
}) {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

一次申请多个权限

runWithPermissions(*EPermissions.CAMERA, *EPermissions.STORAGE,
onDenied = { deniedList ->
Toast.makeText(this, "permission denied $deniedList", Toast.LENGTH_SHORT).show()
},
onDeniedForever = { deniedForeverList ->
Toast.makeText(this, "permission denied forever $deniedForeverList", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "Permission all granted", Toast.LENGTH_SHORT).show()
})

如果不需要处理失申请权限败的情况,也可以直接这样写

runWithStoragePermission {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

如果某些操作执行的时候,只能有权限才去执行,则可以使用下面的方法

doWhenPermissionGranted(*EPermissions.CAMERA){
Toast.makeText(this, "Do this when camera Permission is granted", Toast.LENGTH_SHORT).show()
}

检查权限

if (checkPermissions(*EPermissions.CAMERA)) {
Toast.makeText(this, "Camera Permission is granted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Camera Permission is not granted", Toast.LENGTH_SHORT).show()
}

收起阅读 »

Dart 2.14 发布,新增语言特性和共享标准 lint

Dart 2.14 的发布对 Apple Silicon 处理器提供了更好的支持,并新增了更多提升生产力的功能,例如通过代码样式分析捕获 lint 错误、更快的发布工具、更好的级联代码格式以及一些细小的语言特性更新。 Dart SDK 对 Apple Sili...
继续阅读 »

Dart 2.14 的发布对 Apple Silicon 处理器提供了更好的支持,并新增了更多提升生产力的功能,例如通过代码样式分析捕获 lint 错误、更快的发布工具、更好的级联代码格式以及一些细小的语言特性更新。


Dart SDK 对 Apple Silicon 支持


自从在 2020 年末 Apple 发布了新的 Apple Silicon 处理器以来, Dart SDK 一直致力于增加对该处理器上的 Native 执行支持。


现在从 Dart 2.14.1 正式增加了对 Apple Silicon 的支持,当 下载 MacOS 的 Dart SDK时,一定要选择 ARM64 选项,这里需要额外注意, Flutter SDK 中的 Dart SDK 还没有绑定这一项改进


本次更新支持在 Apple Silicon 上运行 SDK/Dart VM 本身,以及对 dart compile 编译后的可执行文件在 Apple Silicon 上运行的支持,由于 Dart 命令行工具使用原生 Apple Silicon ,因此它们的启动速度会快得多


Dart 和 Flutter 共享的标准 lint


开发人员通常会需要他们的代码遵循某种风格,其中许多规则不仅仅是风格偏好(如众所周知的制表符与空格的问题),还涵盖了可能导致错误或引入错误的编码风格。


比如 Dart 风格指南要求对所有控制流结构使用花括号,例如 if-else 语句,这可以防止经典的 dangling else 问题,也就是在多个嵌套的 if-else 语句上会存在解释歧义。



另一个例子是类型推断,虽然在声明具有初始值的变量时使用类型推断没有问题,但声明未初始化的变量 时指定类型很重要,因为这可以确保类型安全



良好代码风格的通常是通过代码审查来维持,但是通过在编写代码时,运行静态分析来强制执行规则通常会更有效得多。


在 Dart 中,这种静态分析规则是高度可配置的,Dart 提供了有数百条样式规则(也称为lints),有了如此丰富的选项,选择启用这些的规则时,一开始可能会有些不知所措。



配置支持: dart.dev/guides/lang…


lint 规则: dart.dev/tools/linte…



Dart 团队维护了一个 Dart 风格指南,它描述了 Dart 团队认为编写和设计 Dart 代码的最佳方式。



风格指南: dart.dev/guides/lang…



许多开发人员以及 pub.dev 站点评分引擎都使用了一套叫 Pedantic 的 lint 规则, Pedantic 起源于 Google 内部的 Dart 风格指南,由于历史原因它不同于一般的 Dart 风格指南,此外 Flutter 框架也从未使用过 Pedantic 的规则集,而是拥有自己的一套规范规则。


这听起来可能有点混乱,但是在本次的 2.14 发布中,Dart 团队很高兴地宣布现在拥有一套全新的 lint 集合来实现代码样式指南,并且 Dart 和 Flutter SDK 默认情况下将这些规则集用于新项目:




  • package:lints/core.yaml所有 Dart 代码都应遵循的 Dart 风格指南中的主要规则,pub.dev 评分引擎已更新为 lints/core 而不是 Pedantic。




  • package:lints/recommended.yaml :核心规则之外加上推荐规则,建议将它用于所有通用 Dart 代码。




  • package:flutter_lints/flutter.yaml:核心和推荐之外的 Flutter 特定推荐规则,这个集合推荐用于所有 Flutter 代码。




如果你已经存在现有的 Dart 或者 Flutter项目,强烈建议升级到这些新规则集,从 pedantic 升级只需几步:github.com/dart-lang/l…


Dart 格式化程序和级联


Dart 2.14 对 Dart 格式化程序如何使用级联 格式化代码进行了一些优化。


以前格式化程序在某些情况下出现一些令人困惑的格式,例如 doIt() 在这个例子中调用了什么?


var result = errorState ? foo : bad..doIt();

它看起来像是被 bad 调用 ,但实际上级联适是用于整个 ? 表达式上的,因此级联是在该表达式的结果上调用的,而不仅仅是在 false 子句上,新的格式化程序清晰地描述了这一点:


 var result = errorState ? foo : bad\
..doIt();

Dart 团队还大大提高了格式化包含级联的代码的速度;在协议缓冲区生成的 Dart 代码中,可以看到格式化速度提高了 10 倍。


Pub 支持忽略文件


目前当开发者将包发布pub.dev社区时,pub 会抓取该文件夹中的所有文件,但是会跳过隐藏文件(以 . 开头的文件)和.gitignore 文件。


Dart 2.14 中更新的 pub 命令支持新 .pubignore 文件,开发者可以在其中列出不想上传到 pub.dev 的文件,此文件使用与 .gitignore 文件相同的格式。



有关详细信息,请参阅包发布文档 dart.dev/tools/pub/p…



Pub and "dart test" 性能


虽然 pub 最常用于管理代码依赖项,但它还有第二个重要的用途:驱动工具。


比如 Dart 测试工具通过 dart test 命令运行,而它实际上只是 command pub run test:test 命令的包装, package:test 在调用该 test 入口点之前,pub 首先将其编译为可以更快运行的本机代码。


在 Dart 2.14 之前对 pubspec 的任何更改(包括与 package:test 无关的更改)都会使此测试构建无效,并且还会看到一堆这样的输出,其中包含“预编译可执行文件”:


$ dart test\
Precompiling executable... (11.6s)\
Precompiled test:test.\
00:01 +1: All tests passed!

在 Dart 2.14 中,pub 在构建步骤方面更加智能,让构建仅在版本更改时发生,此外还使用并行化改进了执行构建步骤的方式,因此可以完成得更快。


新的语言功能


Dart 2.14 还包含一些语言特性变化。


首先添加了一个新的 三重移位 运算符 ( >>>),这类似于现有的移位运算符 ( >>),但 >> 执行算术移位,>>> 执行逻辑或无符号移位,其中零位移入最高有效位,而不管被移位的数字是正数还是负数。


此次还删除了对类型参数的旧限制,该限制不允许使用泛型函数类型作为类型参数,以下所有内容在 2.14 之前都是无效的,但现在是允许的:


late List<T Function<T>(T)> idFunctions;
var callback = [<T>(T value) => value];
late S Function<S extends T Function<T>(T)>(S) f;

最后对注释类型进行了小幅调整,(诸如 @Deprecated 在 Dart 代码中常用来捕获元数据的注解)以前注解不能传递类型参数,因此 @TypeHelper<int>(42, "The meaning") 不允许使用诸如此类的代码,而现在此限制现已取消。


包和核心库更改


对核心 Dart 包和库进行了许多增强修改,包括:




  • dart:core: 添加了静态方法 hashhashAllhashAllUnordered




  • dart:coreDateTime 类现在可以更好地处理本地时间。




  • package:ffi:添加了对使用 arena 分配器管理内存的支持(示例)。Arenas 是一种基于区域的内存管理形式,一旦退出 arena/region 就会自动释放资源。




  • package:ffigen:现在支持从 C 类型定义生成 Dart 类型定义。




重大变化


Dart 2.14 还包含一些重大更改,预计这些变化只会影响一些特定的用例。


#46545:取消对 ECMAScript5 的支持


所有浏览器都支持最新的 ECMAScript 版本,因此两年前 Dart 就宣布 计划弃用对 ECMAScript 5 (ES5) 的支持,这使 Dart 能够利用最新 ECMAScript 中的改进并生成更小的输出,在 Dart 2.14 中,这项工作已经完成,Dart Web 编译器不再支持 ES5。因此不再支持较旧的浏览器(例如 IE11)


#46100:弃用 stagehand、dartfmt 和 dart2native


在 2020 年 10 月的 Dart 2.10 博客文章中 宣布了将所有 Dart CLI 开发人员工具组合成一个单一的组合dart工具(类似于该flutter工具),而现在 Dart 2.14 弃用了 dartfmtdart2native 命令,并停止使用 stagehand ,这些工具在统一在 dart-tool 中都有等价的替代品。


#45451:弃用 VM Native 扩展


Dart SDK 已弃用 Dart VM 的 Native 扩展,这是从 Dart 代码调用 Native 代码的旧机制,Dart FFI(外来函数接口)是当前用于此用例的新机制,正在积极发展 以使其功能更加强大且易于使用。


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

如何打造一款权限请求框架

原理 通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。 实现 不可见的Fragment internal class EPermissionFragment : Fragment() { private var ...
继续阅读 »

原理


通过向当前Activity添加一个不可见的Fragment,从而实现权限申请流程的封装。


实现


不可见的Fragment


internal class EPermissionFragment : Fragment() {
private var mCallback: EPermissionCallback? = null

fun requestPermission(callback: EPermissionCallback, vararg permissions: String) {
mCallback = callback
// 申请权限
requestPermissions(permissions, CODE_REQUEST_PERMISSION)
}

override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == CODE_REQUEST_PERMISSION) {
val deniedList = ArrayList<String>()
val deniedForeverList = ArrayList<String>()
grantResults.forEachIndexed { index, result ->
// 提取权限申请结果
if (result != PackageManager.PERMISSION_GRANTED) {
val permission = permissions[index]
deniedList.add(permission)
// 是否拒绝且不再显示
if (!shouldShowRequestPermissionRationale(permission)) {
deniedForeverList.add(permission)
}
}
}
if (deniedList.isEmpty()) mCallback?.onAllGranted()
if (deniedList.isNotEmpty()) mCallback?.onDenied(deniedList)
if (deniedForeverList.isNotEmpty()) mCallback?.onDeniedForever(deniedForeverList)
}
}

override fun onDestroy() {
mCallback = null
super.onDestroy()
}
}

封装权限申请


// 扩展FragmentActivity
fun FragmentActivity.runWithPermissions(
vararg permissions: String,
onDenied: (ArrayList<String>) -> Unit = { _ -> },
onDeniedForever: (ArrayList<String>) -> Unit = { _ -> },
onAllGranted: () -> Unit = {}
) {
if (checkPermissions(*permissions)) {
onAllGranted()
return
}
// 添加一个不可见的Fragment
val isFragmentExist = supportFragmentManager.findFragmentByTag(EPermissionFragment.TAG)
val fragment = if (isFragmentExist != null) {
isFragmentExist as EPermissionFragment
} else {
val invisibleFragment = EPermissionFragment()
supportFragmentManager.beginTransaction().add(invisibleFragment, EPermissionFragment.TAG).commitNowAllowingStateLoss()
invisibleFragment
}
val callback = object : EPermissionCallback {
override fun onAllGranted() {
onAllGranted()
}

override fun onDenied(deniedList: ArrayList<String>) {
onDenied(deniedList)
}

override fun onDeniedForever(deniedForeverList: ArrayList<String>) {
onDeniedForever(deniedForeverList)
}
}
// 申请权限
fragment.requestPermission(callback, *permissions)
}

使用方法


项目build.gradle添加


allprojects {
repositories {
...
maven { url 'https://www.jitpack.io' }
}
}

模块build.gradle添加


dependencies {
implementation 'com.github.RickyHal:EPermission:$latest_version'
}

在Activity或者Fragment中直接调用


// 申请存储权限
runWithPermissions(
*EPermissions.STORAGE,
onDenied = {
Toast.makeText(this, "STORAGE permission denied", Toast.LENGTH_SHORT).show()
},
onDeniedForever = {
Toast.makeText(this, "STORAGE permission denied forever", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "STORAGE permission granted", Toast.LENGTH_SHORT).show()
}
)

也可以用下面这个简单的方法


runWithStoragePermission(onFailed = {
Toast.makeText(this, "SMS permission denied", Toast.LENGTH_SHORT).show()
}) {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

一次申请多个权限


runWithPermissions(*EPermissions.CAMERA, *EPermissions.STORAGE,
onDenied = { deniedList ->
Toast.makeText(this, "permission denied $deniedList", Toast.LENGTH_SHORT).show()
},
onDeniedForever = { deniedForeverList ->
Toast.makeText(this, "permission denied forever $deniedForeverList", Toast.LENGTH_SHORT).show()
},
onAllGranted = {
Toast.makeText(this, "Permission all granted", Toast.LENGTH_SHORT).show()
})

如果不需要处理失申请权限败的情况,也可以直接这样写


runWithStoragePermission {
Toast.makeText(this, "SMS permission granted", Toast.LENGTH_SHORT).show()
}

如果某些操作执行的时候,只能有权限才去执行,则可以使用下面的方法


doWhenPermissionGranted(*EPermissions.CAMERA){
Toast.makeText(this, "Do this when camera Permission is granted", Toast.LENGTH_SHORT).show()
}

检查权限


if (checkPermissions(*EPermissions.CAMERA)) {
Toast.makeText(this, "Camera Permission is granted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Camera Permission is not granted", Toast.LENGTH_SHORT).show()
}

GitHub传送门


作者:应用软件开发爱好者
链接:https://juejin.cn/post/7005913659394228232
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Frida笔记 - Android 篇 (一)

前言 相信不少小伙伴对Xposed、Cydia Substrate、Frida等hook工具都有所了解, 并且用在了自己的工作中, 本文主要分享Frida的环境配置以及基本使用, 以及相关功能在日常开发调试带来的帮助 配置Frida的环境 Frida的环境安装...
继续阅读 »

前言


相信不少小伙伴对Xposed、Cydia Substrate、Frida等hook工具都有所了解, 并且用在了自己的工作中, 本文主要分享Frida的环境配置以及基本使用, 以及相关功能在日常开发调试带来的帮助


配置Frida的环境


Frida的环境安装可以参考官方文档, 或者参考网上分享的实践, 使用较为稳定的特定版本


# 通过pip3安装Frida的CLI工具
pip3 install frida-tools
# 安装的frida版本
frida --version
# 本机目前使用的15.0.8的frida版本
# 在https://github.com/frida/frida/releases下载对应的server版本frida-server-15.0.8-android-arm64.xz
# unxz 解压缩
unxz frida-server-15.0.8-android-arm64.xz
adb root
adb push frida-server-15.0.8-android-arm64 /data/locl/tmp/
adb shell
chmod 755 /data/local/tmp/frida-server-15.0.8-android-arm64
/data/local/tmp/frida-server-15.0.8-android-arm64 &
# 打印已安装程序及包名
frida-ps -Uai

基本使用




  • Frida的开发环境, 可以参考作者在github上的exmaple


    下载完成后通过vscode打开


    git clone git://github.com/oleavr/frida-agent-example.git
    npm install




  • 配置完成后, 使用对应函数就会有相应代码提示及函数说明






  • JavaScript API可以参考官方文档说明, 了解基本使用


Hook类的构造函数


// frida -U --no-pause -f com.gio.test.three -l agent/constructor.js

function main() {
Java.perform(function () {
Java.use(
"com.growingio.android.sdk.autotrack.AutotrackConfiguration"
).$init.overload("java.lang.String", "java.lang.String").implementation =
function (projectId, urlScheme) {
// 调用原函数
var result = this.$init(projectId, urlScheme);
// 打印参数
console.log("projectId, urlScheme: ", projectId, urlScheme);
return result;
};
});
}

setImmediate(main);

Hook类的普通函数


// frida -U --no-pause -f com.gio.test.three -l agent/function.js

function main() {
Java.perform(function () {
Java.use(
"com.growingio.android.sdk.CoreConfiguration"
).setDebugEnabled.implementation = function (enabled) {
console.log("enabled: ", enabled);
// 直接返回原函数执行结果
return this.setDebugEnabled(enabled);
};
});
}

setImmediate(main);

修改类/实例参数


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/instance.js
function main() {
Java.perform(function () {
Java.choose("com.growingio.android.sdk.autotrack.AutotrackConfiguration", {
onMatch: function (instance) {
console.log("instance.mProjectId", instance.mProjectId.value);
console.log("instance.mUrlScheme", instance.mUrlScheme.value);
// 修改变量时通过赋值, 如果变量与函数同名, 需要在变量前加'_', 如: _mProjectId
instance.mProjectId.value = "t-bfc5d6a3693a110d";
instance.mUrlScheme.value = "t-growing.d80871b41ef40518";
},
onComplete: function () {},
});
});
}

setImmediate(main);

构造数组


frida -U -n demos -l agent/array.js

function main() {
Java.perform(function () {
// 构造byte数组
var byteArray = Java.array("byte", [0x46, 0x72, 0x69, 0x64, 0x61]);
// 输出为: Frida
console.log(Java.use("java.lang.String").$new(byteArray));

// 构造char数组
var charArray = Java.array("char", ["F", "r", "i", "d", "a"]);
console.log(Java.use("java.lang.String").$new(charArray));
});
}

setImmediate(main);

静态函数主动调用


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/staticFunction.js

function main() {
Java.perform(function () {
// setWebContentsDebuggingEnabled 需要在主线程调用
Java.scheduleOnMainThread(function () {
console.log("isMainThread", Java.isMainThread());
// 主动触发静态函数调用, 允许WebView调试
Java.use("android.webkit.WebView").setWebContentsDebuggingEnabled(true);
});
});
}

setImmediate(main);

动态函数主动调用


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/dynamicFunction.js

function main() {
Java.perform(function () {
Java.choose("com.growingio.android.sdk.autotrack.AutotrackConfiguration", {
onMatch: function (instance) {
// 主动触发动态函数调用
console.log("instance.isDebugEnabled: ", instance.isDebugEnabled());
console.log("instance.getChannel: ", instance.getChannel());
},
onComplete: function () {},
});
});
}

setImmediate(main);

定义一个类


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/registerClass.js

function main() {
Java.perform(function () {
var TestRunnable = Java.registerClass({
name: "com.example.TestRunnable",
// 实现接口
implements: [Java.use("java.lang.Runnable")],
// 成员变量
fields: {
testFields: "java.lang.String",
},
methods: {
// 构造函数
$init: [
{
returnType: "void",
argumentTypes: ["java.lang.String"],
implementation: function (testFields) {
// 调用父类构造函数
this.$super.$init();
// 给成员变量赋值
this.testFields.value = testFields;
console.log("$init: ", this.testFields.value);
},
},
],
// 方法
run: [
{
returnType: "void",
implementation: function () {
console.log(
"testFields: ",
this.testFields.value
);
},
},
],
},
});

TestRunnable.$new("simple test").run();
});
}

setImmediate(main);

打印函数调用堆栈


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/printStackTrace.js

function main() {
Java.perform(function () {
Java.use(
"com.growingio.android.sdk.autotrack.click.ViewClickInjector"
).viewOnClick.overload(
"android.view.View$OnClickListener",
"android.view.View"
).implementation = function (listener, view) {
// 打印当前调用堆栈信息
console.log(
Java.use("android.util.Log").getStackTraceString(
Java.use("java.lang.Throwable").$new()
)
);
return this.viewOnClick(listener, view);
};
});
}

setImmediate(main);

枚举classLoader


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/enumerateClassLoaders.js
// 适用于加固的应用, 找到对应的classloader
// 通常直接在application.attach.overload('android.content.Context').implementation获取context对应的classloader

function main() {
Java.perform(function () {
Java.enumerateClassLoaders({
onMatch: function (loader) {
try {
// 判断该loader中是否存在我们需要hook的类
if (loader.findClass("com.growingio.android.sdk.CoreConfiguration")) {
console.log("found loader:", loader);
Java.classFactory.loader = loader;
}
} catch (error) {
console.log("found error: ", error);
console.log("failed loader: ", loader);
}
},
onComplete: function () {
console.log("enum completed!");
},
});
console.log(
Java.use("com.growingio.android.sdk.CoreConfiguration").$className
);
});
}

setImmediate(main);

枚举类


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/enumerateLoadedClasses.js

function main() {
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name, handle) {
// 判断是否是我们要查找的类
if (name.toString() == "com.growingio.android.sdk.CoreConfiguration") {
console.log("name, handle", name, handle);
Java.use(name).isDebugEnabled.implementation = function () {
return true;
};
}
},
onComplete: function () {},
});
});
}

setImmediate(main);

加载外部dex并通过gson打印对象


// 以attach的方式附加到进程, 或者使用setTimeout替换setImmediate
// frida -U -n demos -l agent/printObject.js
// 通过d8将 gson.jar 转为 classes.dex
// ~/Library/Android/sdk/build-tools/30.0.3/d8 --lib ~/Library/Android/sdk/platforms/android-30/android.jar gson-2.8.8.jar
// 如果SDK中已经有了, 可以直接使用Java.use加载
// adb push classes.dex /data/local/tmp

function main() {
Java.perform(function () {
Java.choose("com.growingio.android.sdk.autotrack.AutotrackConfiguration", {
onMatch: function (instance) {
// 加载外部dex
Java.openClassFile("/data/local/tmp/classes.dex").load();
var Gson = Java.use("com.google.gson.Gson");
// JSON.stringify: "<instance: com.growingio.android.sdk.autotrack.AutotrackConfiguration>"
console.log("JSON.stringify: ", JSON.stringify(instance));
// Gson.$new().toJson: {"mImpressionScale":0.0,"mCellularDataLimit":10,"mDataCollectionEnabled":true,"mDataCollectionServerHost":"http://api.growingio.com","mDataUploadInterval":15,"mDebugEnabled":true,"mOaidEnabled":false,"mProjectId":"bfc5d6a3693a110d","mSessionInterval":30,"mUploadExceptionEnabled":false,"mUrlScheme":"growing.d80871b41ef40518"}
console.log("Gson.$new().toJson: ", Gson.$new().toJson(instance));
},
onComplete: function () {},
});
});
}

setImmediate(main);

使用场景




  1. 绕过证书绑定、校验, 进行埋点请求验证




  2. SDK开发过程中, 一般客户反馈问题都需要使用客户的app进行问题的复现及排查, 此时通过frida获取运行时特定函数的参数信息及返回信息, 能有效缩短与客户的沟通时间, 该场景使用objection最为方便




  3. 新客户在集成前, 希望看到SDK能够提供的效果, 通过frida加载dex并完成初始化, 可以提前发现兼容性问题




  4. 当碰到集成早期版本SDK的应用反馈异常, 通过类似Tinker热修复的思想替换SDK验证是否已经在当前版本修复




  5. 开放SDK相关函数远程rpc调用, 用于测试埋点的协议等场景




外链地址




  1. Frida官方文档: frida.re/docs/instal…




  2. Frida作者提供的example github地址: github.com/oleavr/frid…




  3. JavaScript API官方文档: frida.re/docs/javasc…




  4. 功能介绍中所使用demo: github.com/growingio/g…




  5. r0capture 安卓应用层通杀脚本 github地址: github.com/r0ysue/r0ca…




  6. objection github地址: github.com/sensepost/o…


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

正式版即将到来 | Android 12 Beta 5 现已发布

作者 / Dave Burke, VP of Engineering距离 Android 12 的正式发布只有几周时间了!新版 Android 系统的润色已经进入收尾阶段,今天我们带来最后一个 Beta 版更新,供大家进行测试和开发。对于开发者来说,是时候让自...
继续阅读 »

作者 / Dave Burke, VP of Engineering

距离 Android 12 的正式发布只有几周时间了!新版 Android 系统的润色已经进入收尾阶段,今天我们带来最后一个 Beta 版更新,供大家进行测试和开发。对于开发者来说,是时候让自己的应用做好准备了!

今天,您就可以在 Pixel 设备上 (包括 5G 版 Pixel 5a) 通过 OTA 更新 开始体验 Android 12 Beta 5。如果您之前已经加入了 Beta 测试,则会自动获得更新。您还可以在我们的设备制造商合作伙伴的若干指定设备上体验 Android 12 Beta 5,具体请查看 这里

有关 Android 12 的详细信息以及如何开始开发,请访问 Android 12 开发者网站

请大家关注即将发布的 Android 12 正式版的更多信息!

Beta 5 更新一览

今天的更新包含适用于 Pixel 和其他设备以及 Android 模拟器的 Android 12 发布候选版本。我们已经 在 Beta 4 抵达平台稳定性里程碑,所有面向应用的接口都已最终确定,包括 SDK 和 NDK API、面向应用的系统行为,以及非 SDK 接口限制都已确定。除此之外,Beta 5 还带来了最新的修复和优化,为您提供了完成测试所需的一切。

让您的应用做好准备

随着 Android 12 正式版的临近,我们要求所有的应用和游戏开发者完成最终兼容性测试,并在正式版到来之前发布应用和游戏的兼容性更新。对于所有 SDK、开发库、工具和游戏引擎的开发者来说,尽快发布兼容性更新更为重要: 在获得来自您的更新之前,您的下游应用和游戏开发者的工作可能会受阻。

要测试应用的兼容性,只需在运行 Android 12 Beta 5 的设备上安装您的应用,并测试应用的所有流程,找出功能或 UI 上暴露的问题。请通过 行为变更清单 (针对所有应用) 来找出可能影响应用的潜在变更,从而确定测试重点。

这里列出一些需要注意的变更:

  • 隐私中心 - 这是系统设置 (Settings) 中新加入的一个界面,可以让用户看到哪些应用在访问哪些类型的数据,以及何时访问。如果需要,用户可以对权限进行调整,并从应用获知其访问数据的详细原因。请访问 官方文档 了解详细信息。

  • 麦克风和摄像头指示标志 - 当应用正在使用摄像头或麦克风时,Android 12 会在状态栏中显示指示图标。请访问 官方文档 了解详细信息。

  • 麦克风和摄像头全局开关 - 快速设置 (Quick Settings) 中新增的全局开关功能,可以让用户立即禁用所有应用的麦克风和摄像头访问权限。请访问 官方文档 了解详细信息。

  • 剪贴板访问通知 - 当应用从剪贴板中读取数据时,系统会提醒用户。请访问 官方文档 了解详细信息。

  • 过度滚动拉伸效果 - 过度滚动时,新的 "拉伸" 效果在全系统范围内取代了以前的发光效果。请访问 官方文档 了解详细信息。

  • 应用启动画面 - Android 12 在启动应用时会使用全新的启动动画。请访问 官方文档 了解详细信息。

  • Keygen 变更 - 我们移除了一些被废弃的 BouncyCastle 加密算法,转而使用 Conscrypt 实现。如果您的应用使用 512 位的 AES 密钥,您需要将其改为 Conscrypt 支持的标准长度。请访问 官方文档 了解详细信息。

别忘了测试应用里的开发库和 SDK 的兼容性。如果您发现 SDK 的问题,请尝试更新到最新版本的 SDK ,或向其开发者寻求帮助。

一旦您发布了当前应用的兼容版本,就可以 开始着手升级 应用的 targetSdkVersion。请查阅 行为变更清单 (针对面向 Android 12 的应用),并使用 兼容性框架工具 来快速检测问题。

探索新功能和 API

Android 12 拥有大量的新功能,可以帮助您为用户构建良好的体验。请回顾我们 在 Beta 2 时所做的介绍,以及 Google I/O 上的 Android 12 演讲。要了解所有新功能和 API 的完整细节,请访问 Android 12 开发者网站

另外别忘了试用 Android Studio Arctic Fox 进行 Android 12 的开发和测试。我们已经添加了可以帮助您发现代码中可能受到 Android 12 变更影响的 lint 检查,如对启动画面的自定义声明、请求精细位置的粗略位置许可、媒体格式,以及高传感器采样率权限等。您可以 下载 并 配置 最新版本的 Android Studio 来尝试这些新功能。

即刻开始体验 Android 12

不论您是想体验 Android 12 的功能、测试应用还是 提交反馈,都可以从这次的 Beta 5 开始。只需 使用支持的 Pixel 设备注册参加测试,即可通过无线 (OTA) 方式获得更新。要开始进行开发,请先安装并设置 Android 12 SDK

您也可以在参与 Android 12 开发者预览计划的设备制造商的设备上体验 Android 12 Beta 5,请访问 developer.android.google.cn/about/versi… 查看合作伙伴的完整列表。您也可以通过 Android GSI 映像 在更多设备上进行更广泛的测试。如果您没有合适的设备,也可以在 Android 模拟器 上进行测试。Beta 5 也适用于 Android TV,您可以查看最新的功能,测试自己的应用,并尝试全新的 Google TV 体验。

下一步

Android 12 会在接下来几周内正式发布,请大家保持关注!在此期间,欢迎继续通过问题反馈页面向我们 分享您的使用反馈,包括 平台问题应用兼容性问题 以及 第三方 SDK 问题

再次感谢我们的开发者社区为打造 Android 12 做出的巨大贡献!大家分享了 数以千计的问题报告 和洞察,帮助我们调整 API、改进功能、修复重大问题,从而为用户和开发者们打造出更好的平台。

收起阅读 »

Java多线程

运行环境与工具jdk1.8.0macOS 11.4IDEA操作系统可以在同一时刻运行多个程序。例如一边播放音乐,一边下载文件和浏览网页。操作系统将cpu的时间片分配给每一个进程,给人一种并行处理的感觉。一个多线程程序可以同时执行多个任务。通常,每一个任务称为一...
继续阅读 »

运行环境与工具

  • jdk1.8.0
  • macOS 11.4
  • IDEA

操作系统可以在同一时刻运行多个程序。例如一边播放音乐,一边下载文件和浏览网页。操作系统将cpu的时间片分配给每一个进程,给人一种并行处理的感觉。

一个多线程程序可以同时执行多个任务。通常,每一个任务称为一个线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序成为多线程程序(multithreaded)。

多进程多线程有哪些区别呢?

本质区别在于进程每个进程有自己的一整套变量,而线程则共享数据。 线程比进程更轻量级,创建、销毁一个线程比启动新进程的开销要小。

实际应用中,多线程非常有用。例如应用一边处理用户的输入指令,一遍联网获取数据。

本文我们介绍Java中的Thread类。

Thread

Thread类属于java.lang包。

要创建一个线程很简单,新建一个Thread对象,并传入一个Runnable,实现run()方法。 调用start()方法启动线程。

    Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("rustfisher said: hello");
}
});
t1.start();

Java lambda

    Thread t1 = new Thread(() -> System.out.println("rustfisher said: hello"));
t1.start();

不要直接调用run()方法。 直接调用run()方法不会启动新的线程,而是直接在当前线程执行任务。

我们来看一个使用了Thread.sleep()方法的例子。

Thread t1 = new Thread(() -> {
for (String a : "rustfisher said: hello".split("")) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(a);
}
});
t1.start();

sleep(int)方法会让线程睡眠一个指定的时间(单位毫秒)。并且需要try-catch捕获InterruptedException异常。

中断线程

run()方法执行完最后一条语句后,或者return,或者出现了未捕获的异常,线程将会终止。

使用Thread的interrupt方法也可以终止线程。调用interrupt方法时,会修改线程的中断状态为true。 用isInterrupted()可以查看线程的中断状态。

但如果线程被阻塞了,就没法检测中断状态。当在一个被阻塞的线程(sleep或者wait)上调用interrupt方法,阻塞调用将会被InterruptedException中断。

被中断的线程可以决定如何响应中断。可以简单地将中断作为一个终止请求。比如我们主动捕获InterruptedException

Thread t2 = new Thread(() -> {
for (String a : "rustfisher said: hello".split("")) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println("被中断 退出线程");
return;
}
System.out.print(a);
}
});
t2.start();

new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
t2.interrupt();
}).start();

上面这个小例子展示了用interrupt()来中断线程t2。而线程t2run()方法中捕获InterruptedException后,可以进行自己的处理。

线程的状态

线程有6种状态,用枚举类State来表示:

  • NEW(新创建)
  • RUNNABLE(可运行)
  • BLOCKED(被阻塞)
  • WAITING(等待)
  • TIMED_WAITING(计时等待)
  • TERMINATED(被终止)

getState()方法可以获取到线程的状态。

新创建线程

new一个线程的时候,线程还没开始运行,此时是NEW(新创建)状态。在线程可以运行前,还有一些工作要做。

可运行线程

一旦调用start()方法,线程处于RUNNABLE(可运行)状态。调用start()后并不保证线程会立刻运行,而是要看操作系统的安排。

一个线程开始运行后,它不一定时刻处于运行状态。操作系统可以让其他线程获得运行机会。一个可运行的线程可能正在运行也可能没在运行。

被阻塞和等待

线程处于被阻塞和等待状态时,它暂时不活动。不运行代码,且只消耗最少的资源。直到线程调度器重新激活它。

  • 一个线程试图获取一个内部的对象锁,而该锁被其他线程持有,则这个线程进入阻塞状态。当这个锁被释放,并且线程调度器允许这个线程持有它,该线程变成非阻塞状态。
  • 当线程等待另一个线程通知调度器,它自己进入等待状态。例如调用Object.wait()或者Thread.join()方法。
  • 带有超时参数的方法可让线程进入超时等待状态。例如Thread.sleep()Object.wait(long)Thread.join(long)Lock.tryLock(long time, TimeUnit unit)

thread-state.png

上面这个图展示了状态之间的切换。

被终止

终止的原因:

  • run方法正常退出
  • 出现了没有捕获的异常而终止了run方法

线程属性

线程优先级,守护线程,线程组以及处理未捕获异常的处理器。

线程优先级

Java中每个线程都有一个优先级。默认情况下,线程继承它的父线程的优先级。 可用setPriority(int)方法设置优先级。优先级最大为MAX_PRIORITY = 10,最小为MIN_PRIORITY = 1,普通的是NORM_PRIORITY = 5。 线程调度器有机会选新线程是,会优先选高优先级的线程。

守护线程

调用setDaemon(true)可以切换为守护线程(daemon thread)。守护线程的用途是为其他线程提供服务。例如计时线程。 当只剩下守护线程是,虚拟机就退出了。

守护线程不应该去访问固有资源,如文件和数据库。

未捕获异常处理器

run()方法里抛出一个未捕获异常,在线程死亡前,异常被传递到一个用于未捕获异常的处理器。 要使用这个处理器,需要实现接口Thread.UncaughtExceptionHandler,并且用setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler)方法把它交给线程。

Thread t3 = new Thread(() -> {
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
return;
}
int x = 0, y = 3;
int z = y / x; // 故意弄一个异常
});
t3.setUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println(t + "有未捕获异常");
e.printStackTrace();
}
});
t3.start();

运行后,run()方法里抛出ArithmeticException异常

Thread[Thread-0,5,main]有未捕获异常
java.lang.ArithmeticException: / by zero
at Main.lambda$main$0(Main.java:15)
at java.lang.Thread.run(Thread.java:748)

也可以用静态方法Thread.setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler)给所有的线程安装一个默认处理器。可以在这个默认处理器里做一些工作,例如记录日志。

ThreadGroup代表着一组线程。也可以包含另外的线程组。

ThreadGroup类实现了UncaughtExceptionHandler接口。它的uncaughtException(Thread t, Throwable e)方法会有如下操作

  • 如果该线程组有父线程组,则父线程组的uncaughtException被调用。
  • 否则,如果Thread.getDefaultUncaughtExceptionHandler()返回一个非空处理器,则使用这个处理器。
  • 否则,如果抛出的ThrowableThreadDeath对象,就什么也不做。
  • 否则,线程的名字和Throwable的栈踪迹输出到System.err上。
收起阅读 »

Android compose自定义布局

开新坑了,compose自定义布局。基础知识不说了,直接上正题。我们知道,在views体系下,自定义布局需要view集成viewgroup重写onMeasure、onLayout方法,在compse中,是使用Layout的compose方法,结构如下:以一个自...
继续阅读 »

开新坑了,compose自定义布局。基础知识不说了,直接上正题。

我们知道,在views体系下,自定义布局需要view集成viewgroup重写onMeasure、onLayout方法,在compse中,是使用Layout的compose方法,结构如下:

以一个自定义Column为例:

1、首先我们定义自己的cpmpose函数

@Composablefun 
MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
)

这个方法包含最基础的两个入参,一个是修饰符modifier,一个是@composable注解的lamda表达式作为子项的内容

2、看看具体函数体的操作

@Composable
fun MyBasicColumn(
modifier: Modifier = Modifier,
content: @Composable () -> Unit) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// Don't constrain child views further, measure them with given constraints
// List of measured children
val placeables = measurables.map { measurable ->
// Measure each children
measurable.measure(constraints)
}
// Set the size of the layout as big as it can
layout(constraints.maxWidth, constraints.maxHeight) {
// Track the y co-ord we have placed children up to
var yPosition = 0
// Place children in the parent layout
placeables.forEach { placeable ->
// Position item on the screen
placeable.placeRelative(x = 0, y = yPosition)
// Record the y co-ord placed up to
yPosition += placeable.height
}
}
}
}

代码是从官网抄的,正确性就不用说了,具体分析一下作用。

1、在函数体中使用已经定义好的Layout方法。(这个有点类似 类 的继承,compose中所有组件定义都是使用方法,没有类中的子类父类的概念,如果想要做一些统一的封装操作会比较麻烦,可以使用这种方法,函数体内去执行另一个封装好的函数,而函数最后一个参数使用@composable注解的lamda)Layout方法把修饰符和content接收,回调中发送的是 measurables和constraints.从名字就可以猜出这两个参数的作用

  • measurables:可测量元素,就是传进来的子元素
  • constraints:父类约束条件

Layout函数的lamda来自于第三个入参MeasurePolicy,这是一个接口,上面两个参数就来自于这个接口的回调:

fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult

2、使用map函数遍历measurables,每一个measurable调用measure并把constraint传入,相当于给每个子控件根据父控件的约束进行测量,类似于views体系下面的measure,得到palceables。

3、调用layout方法(注意小写,这是单独放置一个控件的方法,是单个可组合项的修饰,具体下面会再讲),layout(width,heigiht)传入布局的宽和高,在layout方法lamda中,对每一个placeable调用place方法(有几个类似的,这里使用placeRelative),传入相应坐标,完成子view的布局

到这里一个自定义布局就完成了,其实和views体系下面很像,也是相似的两步:

1、测量每个子view在父view约束下的大小

2、遍历子view,使用layout方法将每个view放在正确的位置上。

大同小异大同小异

3、关于layout(注意是小写的)

先抄一段官网的说明:

您可以使用 layout 修饰符来修改元素的测量和布局方式,layout 是一个 lambda;它的参数包括您可以测量的元素(以 measurable 的形式传递)以及该可组合项的传入约束条件(以 constraints 的形式传递)

再抄一段代码:

fun Modifier.firstBaselineToTop(
firstBaselineToTop: Dp) =
layout { measurable, constraints ->
// Measure the composable
val placeable = measurable.measure(constraints)
// Check the composable has a first baseline
check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
val firstBaseline = placeable[FirstBaseline]
// Height of the composable with padding - first baseline
val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
val height = placeable.height + placeableY
layout(placeable.width, height) {
// Where the composable gets placed
placeable.placeRelative(0, placeableY)
}}

这个是官网上面修改text baseline top padding的方法。具体内容就不说了,可以看到,是定义了一个modifier的扩展函数,回调参数是一个measurable,内部具体还是调用layout,大同小异大同小异。

到这里自定义布局就结束了,自定义布局难点主要还是在子view在父view约束下面的布局逻辑,也可以看到其实这个移植以前views下面的自定义布局应该是比较容易的,把坐标计算逻辑抽离,然后就可以轻松完成移植了。(另外我从这里还发现了compose下面怎么实现类似以前类的继承,那就是活用fun中最后一个lamda参数,由于kotlin语法的关系,容易把lamda看作是函数体,其实在fun中只是调用了一个fun,函数具体执行都被隐藏了起来)

收起阅读 »

Android 非Root设备下调试so

准备工作手机:Google Pixel 3 Android 11, API 30工具:IDA 7.0、Android Studio电脑系统:win10写一个C++ demo稍微改动下代码,点击Hello World调用c++class MainActivity...
继续阅读 »

准备工作

  1. 手机:Google Pixel 3 Android 11, API 30
  2. 工具:IDA 7.0、Android Studio
  3. 电脑系统:win10

写一个C++ demo

image.png

稍微改动下代码,点击Hello World调用c++

class MainActivity : AppCompatActivity() {

@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

// Example of a call to a native method
sample_text.setOnClickListener {
sample_text.text = stringFromJNI() + intFromJNI()
}
}

/**
* A native method that is implemented by the 'native-lib' native library,
* which is packaged with this application.
*/

private external fun stringFromJNI(): String

private external fun intFromJNI(): Int

companion object {
// Used to load the 'native-lib' library on application startup.
init {
System.loadLibrary("native-lib")
}
}
}

native-lib.cpp代码

#include <jni.h>
#include <string>

int test_add();

extern "C" JNIEXPORT jstring JNICALL
Java_com_example_testcpp_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++ ";
return env->NewStringUTF(hello.c_str());
}

extern "C" JNIEXPORT jint JNICALL
Java_com_example_testcpp_MainActivity_intFromJNI(JNIEnv *env, jobject thiz) {
int ret = test_add();
return (jint)ret;
}

int test_add() {
return 1 + 1;
}

运行效果(左),点击后(右)

 

将IDA目录dbgsrv下的android_server64放到Android应用目录下

这里要注意看手机是多少位的,我是64位就用64位的android_server64

image.png

通过Android Studio的Device File Explorer upload到对应的应用目录下,这个目录没有root权限通过adb是不能push文件进去 image.png

打开终端进入adb shell启动android_server

C:\Users\Administrator\Desktop\fby>adb shell
* daemon not running; starting now at tcp:5037
* daemon started successfully
blueline:/ $

这里有个关键步骤,如果直接进入到/data/data/com.example.testcpp是没有权限的,也就不能启动android_server

blueline:/ $ cd data/data/com.example.testcpp
/system/bin/sh: cd: /data/data/com.example.testcpp: Permission denied

执行run-as com.example.testcpp,进入到了应用目录,ls看下当前目录,然后启动android_server

2|blueline:/ $ run-as com.example.testcpp
blueline:/data/user/0/com.example.testcpp $ ls
android_server64 cache code_cache databases files no_backup shared_prefs
blueline:/data/user/0/com.example.testcpp $ ./android_server64
IDA Android 64-bit remote debug server(ST) v1.22. Hex-Rays (c) 2004-2017
Listening on 0.0.0.0:23946...

再打开一个终端,转发端口23946

C:\Users\Administrator>adb forward tcp:23946 tcp:23946
23946

打开IDA64 attch进程

image.png

image.png

image.png

点击ok进入到调试页面,这里已经进入断点,按F9让程序执行

image.png

在Modules窗口找到自己写的那个native-lib.so,下断点

image.png

image.png

app上点击Hello World,进入到断点

image.png


收起阅读 »

Android消息队列原理

本文章是对任玉刚前辈的《Android开发艺术》一书中的第10章“Android的消息机制”的简单理解,不足之处请多多指正。Handler是Android机制的上层接口,它的运行要依靠MessQueue和Looper。Handler的使用想必大家都很了解,一般...
继续阅读 »

本文章是对任玉刚前辈的《Android开发艺术》一书中的第10章“Android的消息机制”的简单理解,不足之处请多多指正。

Handler是Android机制的上层接口,它的运行要依靠MessQueue和Looper。Handler的使用想必大家都很了解,一般在开发中,我们会在子线程中执行耗时的操作,将操作的结果通过Handler发送给主线程,主线程用这个结果来执行UI的操作,这是我们最常见的用法,Android默认只有主线程才能更新UI,这是因为每次更新UI时都会做UI验证操作,Android在UI验证操作时首先会检查当前线程是否为主线程,如果不是就会报出异常,那为什么Android要规定只有主线程才能操作UI呢?这是因为Android的UI控件并不是线程安全的,在高并发状态下当有多个线程访问一个UI时就会出错,加锁又会让UI效率变慢。MessageQueue是消息队列,它以队列的形式对外提供插入和删除消息的工作,但其本身的数据结构并不是一个队列而是一个单向链表。Looper可以理解为消息循环处理器,它会以无限循环的方式去查找MessageQueue中的消息,如果没有消息就会一直等待。Android消息队列的Handler、MessageQueue、Looper作为一个整体,不可分割,那么接下来就对这几个模板分开探索一下。

MessageQueue

MessageQueue即消息队列,主要包含插入(enqueueMessage方法)和读取(next方法),读取一条消息的同时也会把这条消息从队列中删除,消息队列由单链表实现,因为单链表对插入和删除操作有很好的优势。

enqueueMessage方法
    boolean enqueueMessage(Message msg, long when) {
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
synchronized (this) {
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}

if (mQuitting) {
IllegalStateException e = new IllegalStateException(
msg.target + " sending message to a Handler on a dead thread");
Log.w(TAG, e.getMessage(), e);
msg.recycle();
return false;
}
msg.markInUse();
msg.when = when;
Message p = mMessages;
boolean needWake;
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
// Inserted within the middle of the queue. Usually we don't have to wake
// up the event queue unless there is a barrier at the head of the queue
// and the message is the earliest asynchronous message in the queue.
needWake = mBlocked && p.target == null && msg.isAsynchronous();
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}
// We can assume mPtr != 0 because mQuitting is false.
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}

当新消息到来时,将消息插入链表中。

next方法
    Message next() {
// Return here if the message loop has already quit and been disposed.
// This can happen if the application tries to restart a looper after quit
// which is not supported.
final long ptr = mPtr;
if (ptr == 0) {
return null;
}
int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}

nativePollOnce(ptr, nextPollTimeoutMillis);
synchronized (this) {
// Try to retrieve the next message. Return if found.
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// Stalled by a barrier. Find the next asynchronous message in the queue.
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// Next message is not ready. Set a timeout to wake up when it is ready.
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// Got a message.
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}

// Process the quit message now that all pending messages have been handled.
if (mQuitting) {
dispose();
return null;
}
// If first time idle, then get the number of idlers to run.
// Idle handles only run if the queue is empty or if the first message
// in the queue (possibly a barrier) is due to be handled in the future.
if (pendingIdleHandlerCount < 0
&& (mMessages == null || now < mMessages.when)) {
pendingIdleHandlerCount = mIdleHandlers.size();
}
if (pendingIdleHandlerCount <= 0) {
// No idle handlers to run. Loop and wait some more.
mBlocked = true;
continue;
}
if (mPendingIdleHandlers == null) {
mPendingIdleHandlers = new IdleHandler[Math.max(pendingIdleHandlerCount, 4)];
}
mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler
boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}
// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

next方法会一直阻塞,当有消息到来时,next方法会返回这条消息,并把这条消息从MessageQueue中删除。

Looper

Looper是一个消息循环处理器,在一个线程中,要打开一个Looper才能接收到其他线程发来的Message。在一个线程中,Looper本身是默认不存在的,只有子线程会初始化一个Looper,这就是为什么主线程可以默认使用Handler的原因了。 我们一般用Looper.prepare()方法给线程创建一个Looper,然后用Looper.loop()方法开启消息循环,当然Looper也可以退出,有quit()方法和quitSafely()方法,quit()方法会立即退出这个消息循环,而quitSafely()会将消息队列中的消息处理完再退出。子线程在开启消息循环处理完消息之后一定要退出,否则子线程会一直等待下去,消耗资源。

Looper.loop()方法
    public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
if (me.mInLoop) {
Slog.w(TAG, "Loop again would have the queued messages be executed"
+ " before this one completed.");
}

me.mInLoop = true;
final MessageQueue queue = me.mQueue;
// Make sure the identity of this thread is that of the local process,
// and keep track of what that identity token actually is.
Binder.clearCallingIdentity();
final long ident = Binder.clearCallingIdentity();

// Allow overriding a threshold with a system prop. e.g.
// adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
final int thresholdOverride =
SystemProperties.getInt("log.looper."
+ Process.myUid() + "."
+ Thread.currentThread().getName()
+ ".slow", 0);

boolean slowDeliveryDetected = false;
for (;;) {
Message msg = queue.next(); // might block
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}

// This must be in a local variable, in case a UI event sets the logger
final Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
// Make sure the observer won't change while processing a transaction.
final Observer observer = sObserver;
final long traceTag = me.mTraceTag;
long slowDispatchThresholdMs = me.mSlowDispatchThresholdMs;
long slowDeliveryThresholdMs = me.mSlowDeliveryThresholdMs;
if (thresholdOverride > 0) {
slowDispatchThresholdMs = thresholdOverride;
slowDeliveryThresholdMs = thresholdOverride;
}
final boolean logSlowDelivery = (slowDeliveryThresholdMs > 0) && (msg.when > 0);
final boolean logSlowDispatch = (slowDispatchThresholdMs > 0);

final boolean needStartTime = logSlowDelivery || logSlowDispatch;
final boolean needEndTime = logSlowDispatch;

if (traceTag != 0 && Trace.isTagEnabled(traceTag)) {
Trace.traceBegin(traceTag, msg.target.getTraceName(msg));
}
final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
final long dispatchEnd;
Object token = null;
if (observer != null) {
token = observer.messageDispatchStarting();
}
long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
try {
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}
if (logSlowDelivery) {
if (slowDeliveryDetected) {
if ((dispatchStart - msg.when) <= 10) {
Slog.w(TAG, "Drained");
slowDeliveryDetected = false;
}
} else {
if (showSlowLog(slowDeliveryThresholdMs, msg.when, dispatchStart, "delivery",
msg)) {
// Once we write a slow delivery log, suppress until the queue drains.
slowDeliveryDetected = true;
}
}
}
if (logSlowDispatch) {
showSlowLog(slowDispatchThresholdMs, dispatchStart, dispatchEnd, "dispatch", msg);
}

if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

// Make sure that during the course of dispatching the
// identity of the thread wasn't corrupted.
final long newIdent = Binder.clearCallingIdentity();
if (ident != newIdent) {
Log.wtf(TAG, "Thread identity changed from 0x"
+ Long.toHexString(ident) + " to 0x"
+ Long.toHexString(newIdent) + " while dispatching to "
+ msg.target.getClass().getName() + " "
+ msg.callback + " what=" + msg.what);
}
msg.recycleUnchecked();
}
}

loop()方法是一个死循环,只有当消息队列next()方法返回null或者quit()方法被调用后才会跳出死循环,loop()会一直调用next()方法。

Handler

Handler的主要工作就是接收和发送消息,消息发送最终会使用send的一系列方法来实现,在平常使用中我们一般会使用sendMessage()方法发送Message,Handler会调用dispatchMessage()方法。

    public void dispatchMessage(@NonNull Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}

最后重写handleMessage方法来接收消息。

收起阅读 »

使用Flutter撸一个极简的吃月饼小游戏

先看效果 游戏规则很简单,在月饼投掷出去之后能够砸中月亮即记1分,否则该轮游戏结束,连续击中的次数则为本轮分数。 编码实现 代码上其实没有太多复杂度,大体逻辑如下: 1 月亮的移动动画,使用Tween实现一个补间动画,使用TweenSequence指定动画序...
继续阅读 »

先看效果


2021-09-08 22.17.51.gif


游戏规则很简单,在月饼投掷出去之后能够砸中月亮即记1分,否则该轮游戏结束,连续击中的次数则为本轮分数。


编码实现


代码上其实没有太多复杂度,大体逻辑如下:


1 月亮的移动动画,使用Tween实现一个补间动画,使用TweenSequence指定动画序列,监听动画的完成重复动画,从而实现月亮左右不间断移动的效果。


_animationController =
AnimationController(duration: Duration(milliseconds: 600), vsync: this);
_animationControllerCake =
AnimationController(duration: Duration(milliseconds: 900), vsync: this);
TweenSequenceItem<double> downMarginItem = TweenSequenceItem<double>(
tween: Tween(begin: 1.0, end: 50.0),
weight: 50,
);
TweenSequenceItem<double> upMarginItem = TweenSequenceItem<double>(
tween: Tween(begin: 50.0, end: 300.0),
weight: 100,
);

TweenSequence<double> tweenSequence = TweenSequence<double>([
downMarginItem,
upMarginItem,
]);

_animation = tweenSequence.animate(_animationController);

_animation.addListener(() {
if (_animation.isCompleted) {
_animationController.reverse();
}
if (_animation.isDismissed) {
_animationController.forward();
}
setState(() {});
});

2 月饼的投掷位移效果,使用StreamBuilder控件,配合Timer倒计时,在1秒内不断地Stream发送当前位置数据,从而不断的改变月饼距离底部的距离,看上去就像也是一个位移动画效果,而实际上是不断改变距离形成的视觉效果。


_countdownTimer = new Timer.periodic(new Duration(milliseconds: 1), (timer) {
if (_milliSecond > 0) {
_milliSecond = _milliSecond - 5;
} else {
_countdownTimer.cancel();
}
_cakeStreamController.sink.add(_milliSecond < 0 ? 0 : _milliSecond);
});

Container(
margin: EdgeInsets.only(bottom: distance),
child: Image.asset(
"assets/images/cake.png",
width: 60,
height: 60,
),
)

3 比较关键的一点就是,如何判断月饼投掷出去之后会和月亮发生碰撞,也就是“吃到月饼”。实现方案是:月亮的高度是已知的(屏幕高度 - 状态栏高度 - 月亮距上方距离),月饼的横坐标是固定的(屏幕宽度的一半)。在StreamBuilder中监听:当月饼高度达到月亮的高度时,判断月亮的横坐标是否和月饼一致即可,如果一致则月饼与月亮重合,记为一次有效分数。而这里为了增加游戏的可玩性,并不是很严格的判断坐标完全重合,如图所示,月饼到达月亮高度时,月亮如果在红色区域内都记为有效分数。


image.png


判断逻辑:


// 当月饼高度达到月球所处的高度时,判断月球的位置是否处于中间
if (distance < (_screenHeight - 120) &&
distance > (_screenHeight - 170 - MediaQuery.of(context).padding.top)) {
print(_animation.value);
if (_animation.value < (_screenWidth / 2 + 10) && _animation.value > (_screenWidth / 2 - 90)) {
_hintStreamController.add("太棒了");
print("撞到了");
_score++;
} else {
_hintStreamController.add("MISS");
print("MISS");
}
_milliSecond = 1000;
_cakeStreamController.sink.add(_milliSecond);
_countdownTimer.cancel();
}

引入分数排行榜


为了增加游戏的趣味性,在游戏里面增加了联机的分数排行榜机制,游戏中会将玩家的最高分数上传至服务器,在主界面的右上角可以查看自己在排行榜的位置。


Screenshot_2021-09-08-22-23-35-489_com.flutter.mo.jpg


这里云端服务器存储功能使用的是第三方平台LeanCloud,这个平台是支持Flutter的,而且在8月份刚好增加了对空安全的迭代。


扫码下载链接


目前支持安卓版本的下载,还在等什么,赶快下载登上排行榜吧~


image.png


代码地址


Github链接


由于云端服务器使用了LeanCloud,下载的老铁需要去LeanCloud平台申请AppKey填写在项目中的main.dart中的初始化位置即可。


LeanCloud.initialize(
"", "",
server: "https://zsyju4p5.lc-cn-n1-shared.com", // to use your own custom domain
queryCache: new LCQueryCache() // optinoal, enable cache
)

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

Android项目中集成Flutter,实现秒开Flutter模块

本文目标 成功在Android原生项目中集成Flutter Warning 从Flutter v1.1.7版本开始,Flutter module仅支持AndroidX应用 在release模式下Flutter仅支持以下架构:x86_64,armeabi-v7...
继续阅读 »

本文目标


成功在Android原生项目中集成Flutter


Warning



  • 从Flutter v1.1.7版本开始,Flutter module仅支持AndroidX应用

  • 在release模式下Flutter仅支持以下架构:x86_64,armeabi-v7a,arm64-v8a,不支持mips和x86,所以引入Flutter前需要选取Flutter支持的架构


android{
//...
defaultConfig {
//配置支持的动态库类型
ndk {
abiFilters 'x86_64','armeabi-v7a', 'arm64-v8a'
}
}
}

混合开发的一些适用场景



  • 在原有项目中加入Flutter页面


image



  • 原生页面中嵌入Flutter模块


image



  • 在Flutter项目中嵌入原生模块


image


主要步骤



  • 创建Flutter module

  • 为已存在的Android项目添加Flutter module依赖

  • 早Kotlin/Java中调用Flutter module

  • 编写Dart代码

  • 运行项目

  • 热重启/重新加载

  • 调试Dart代码

  • 发布应用


请把所有的项目都放在同一个文件夹内


- WorkProject
- AndroidProject
- iOSProject
- flutrter_module

WorkProject下面分别是原生Android模块,原生iOS模块,flutter模块,并且这三个模块是并列结构


创建Flutter module


在做混合开发之前我们需要创建一个Flutter module
这个时候需要


  cd xxx/WorkProject /

创建flutter_module


flutter create -t module flutter_module

如果要指定包名


flutter create -t module --org com.example flutter_module

然后就会创建成功


image



  • .android - flutter_module的Android宿主工程

  • .ios - flutter_module的iOS宿主工程

  • lib - flutter_module的Dart部分代码

  • pubspec.yaml - flutter_module的项目依赖配置文件
    因为宿主工程的存在,我们这个flutter_module在布甲额外的配置的情况下是可以独立运行的,通过安装了Flutter和Dart插件的AndroidStudio打开这个flutter_module项目,通过运行按钮可以直接运行


构建flutter aar(非必须)


可以通过如下命令构建aar


cd .android/
./gradlew flutter:assembleRelease

这会在.android/Flutter/build/outputs/aar/中生成一个flutter-release.aar归档文件


为已存在的Android用意添加Flutter module依赖


打开我们的Android项目的 settings.gradle添加如下代码


setBinding(new Binding([gradle: this]))                              
evaluate(new File(
settingsDir.parentFile,
'flutter_module/.android/include_flutter.groovy'
))

//可选,主要作用是可以在当前AS的Project下显示flutter_module以方便查看和编写Dart代码
include ':flutter_module'
project(':flutter_module').projectDir = new File('../flutter_module')

setBinding与evaluate允许Flutter模块包括它自己在内的任何Flutter插件,在setting.gradle中以类似:flutter package_info :video_player的方式存在


添加:flutter依赖


dependencies {
implementation project(':flutter')
}

添加Java8编译选项


因为Flutter的Android engine使用了Java8的特性,所有在引入Flutter时需要配置你的项目的Java8编译选项


//在app的build.gradle文件的android{}节点下添加
android {
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
}

在Kotlin中调用Flutter module


支持,我们已经为我们的Android项目添加了Flutter所必须的依赖,接下来我们来看如何在项目中以Kotlin的方式在Fragment中调用Flutter模块,在这里我们能做到让Flutter优化提升加载速度,实现秒开Flutter模块


原生Kotlin端代码


/**
* flutter抽象的基类fragment,具体的业务类fragment可以继承
**/
abstract class FlutterFragment(moduleName: String) : IBaseFragment() {

private val flutterEngine: FlutterEngine?
private lateinit var flutterView: FlutterView

init {
flutterEngine =FlutterCacheManager.instance!!.getCachedFlutterEngine(AppGlobals.get(), moduleName)
}

override fun getLayoutId(): Int {
return R.layout.fragment_flutter
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
(mLayoutView as ViewGroup).addView(createFlutterView(activity!!))
}

private fun createFlutterView(context: Context): FlutterView {
val flutterTextureView = FlutterTextureView(activity!!)
flutterView = FlutterView(context, flutterTextureView)
return flutterView
}

/**
* 设置标题
*/
fun setTitle(titleStr: String) {
rl_title.visibility = View.VISIBLE
title_line.visibility = View.VISIBLE
title.text = titleStr
title.setOnClickListener {

}
}

/**
* 生命周期告知flutter
*/
override fun onStart() {
flutterView.attachToFlutterEngine(flutterEngine!!)
super.onStart()
}

override fun onResume() {
super.onResume()
//for flutter >= v1.17
flutterEngine!!.lifecycleChannel.appIsResumed()
}

override fun onPause() {
super.onPause()
flutterEngine!!.lifecycleChannel.appIsInactive()
}

override fun onStop() {
super.onStop()
flutterEngine!!.lifecycleChannel.appIsPaused()
}

override fun onDetach() {
super.onDetach()
flutterEngine!!.lifecycleChannel.appIsDetached()
}

override fun onDestroy() {
super.onDestroy()
flutterView.detachFromFlutterEngine()
}
}

R.layout.fragment_flutter的布局


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<RelativeLayout
android:id="@+id/rl_title"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="@dimen/dp_45"
android:background="@color/color_white"
android:gravity="center_vertical"
android:orientation="horizontal">

<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:layout_gravity="center"
android:gravity="center"
android:textColor="@color/color_000"
android:textSize="16sp" />
</RelativeLayout>

<View
android:id="@+id/title_line"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="2px"
android:background="@color/color_eee" />
</LinearLayout>

/**
* flutter缓存管理,主要是管理多个flutter引擎
**/
class FlutterCacheManager private constructor() {

/**
* 伴生对象,保持单例
*/
companion object {

//喜欢页面,默认是flutter启动的主入口
const val MODULE_NAME_FAVORITE = "main"
//推荐页面
const val MODULE_NAME_RECOMMEND = "recommend"

@JvmStatic
@get:Synchronized
var instance: FlutterCacheManager? = null
get() {
if (field == null) {
field = FlutterCacheManager()
}
return field
}
private set
}

/**
* 空闲时候预加载Flutter
*/
fun preLoad(context: Context){
//在线程空闲时执行预加载任务
Looper.myQueue().addIdleHandler {
initFlutterEngine(context, MODULE_NAME_FAVORITE)
initFlutterEngine(context, MODULE_NAME_RECOMMEND)
false
}
}

/**
* 初始化Flutter
*/
private fun initFlutterEngine(context: Context, moduleName: String): FlutterEngine {
//flutter 引擎
val flutterLoader: FlutterLoader = FlutterInjector.instance().flutterLoader()
val flutterEngine = FlutterEngine(context,flutterLoader, FlutterJNI())
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint(
flutterLoader.findAppBundlePath(),
moduleName
)
)
//存到引擎缓存中
FlutterEngineCache.getInstance().put(moduleName,flutterEngine)
return flutterEngine
}

/**
* 获取缓存的flutterEngine
*/
fun getCachedFlutterEngine(context: Context?, moduleName: String):FlutterEngine{
var flutterEngine = FlutterEngineCache.getInstance()[moduleName]
if(flutterEngine==null && context!=null){
flutterEngine=initFlutterEngine(context,moduleName)
}
return flutterEngine!!
}

}

具体业务类使用


//在app初始化中初始一下
public class MyApplication extends Application {

@Override
public void onCreate() {
super.onCreate();
FlutterCacheManager.getInstance().preLoad(this);
}
}

收藏页面


class FavoriteFragment : FlutterFragment(FlutterCacheManager.MODULE_NAME_FAVORITE) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setTitle(getString(R.string.title_favorite))
}
}

推荐页面


class RecommendFragment : FlutterFragment(FlutterCacheManager.MODULE_NAME_RECOMMEND) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setTitle(getString(R.string.title_recommend))
}
}

Dart端代码


import 'package:flutter/material.dart';
import 'package:flutter_module/favorite_page.dart';
import 'package:flutter_module/recommend_page.dart';

//至少要有一个入口,而且这下面的man() 和 recommend()函数名字 要和FlutterCacheManager中定义的对应上
void main() => runApp(MyApp(FavoritePage()));

//必须加注解
@pragma('vm:entry-point')
void recommend() => runApp(MyApp(RecommendPage()));

class MyApp extends StatelessWidget {
final Widget page;
const MyApp(this.page);

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
body: page,
),
);
}
}

Dart侧收藏页面


import 'package:flutter/material.dart';

class FavoritePage extends StatefulWidget {
@override
_FavoritePageState createState() => _FavoritePageState();
}

class _FavoritePageState extends State<FavoritePage> {
@override
Widget build(BuildContext context) {
return Container(
child: Text("收藏"),
);
}
}

Dart侧推荐页面


import 'package:flutter/material.dart';

class RecommendPage extends StatefulWidget {
@override
_RecommendPageState createState() => _RecommendPageState();
}

class _RecommendPageState extends State<RecommendPage> {
@override
Widget build(BuildContext context) {
return Container(
child: Text("推荐"),
);
}
}

最终效果


image


image


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

Android启动优化之精确测量启动各个阶段的耗时

1. 直观地观察应用启动时长 我们可以通过观察logcat日志查看Android应用启动耗时,过滤关键字"Displayed": ActivityTaskManager: Displayed com.peter.viewgrouptutorial/.acti...
继续阅读 »

1. 直观地观察应用启动时长


我们可以通过观察logcat日志查看Android应用启动耗时,过滤关键字"Displayed"



ActivityTaskManager: Displayed com.peter.viewgrouptutorial/.activity.DashboardActivity: +797ms



启动时长(在这个例子中797ms)表示从启动App到系统认为App启动完成所花费的时间。


2. 启动时间包含哪几个阶段


从用户点击桌面图标,到Activity启动并将界面第一帧绘制出来大概会经过以下几个阶段。



  1. system_server展示starting window

  2. Zygote fork Android 进程

  3. ActivityThread handleBindApplication(这个阶段又细分为)

    • 加载程序代码和资源

    • 初始化ContentProvider

    • 执行Application.onCreate()



  4. 启动Activity(执行 onCreate、onStart、onResume等方法)

  5. ViewRootImpl执行doFrame()绘制View,计算出首帧绘制时长。


流程图如下:


我们可以看出:阶段1和2都是由系统控制的。App开发者对这两个阶段的耗时能做的优化甚微。


3. 系统是如何测量启动时长的?


本文源码基于android-30


我们在cs.android.com源码阅读网站上全局搜索



  1. ActivityMetricsLogger.logAppDisplayed()方法中发现了打印日志语句



private void logAppDisplayed(
TransitionInfoSnapshot info
) {
if (info.type != TYPE_TRANSITION_WARM_LAUNCH && info.type != TYPE_TRANSITION_COLD_LAUNCH) {
return;
}

EventLog.writeEvent(WM_ACTIVITY_LAUNCH_TIME,
info.userId, info.activityRecordIdHashCode, info.launchedActivityShortComponentName,
info.windowsDrawnDelayMs);

StringBuilder sb = mStringBuilder;
sb.setLength(0);
sb.append("Displayed ");
sb.append(info.launchedActivityShortComponentName);
sb.append(": ");
TimeUtils.formatDuration(info.windowsDrawnDelayMs, sb);
Log.i(TAG, sb.toString());
}


  1. TransitionInfoSnapshot.windowsDrawnDelayMs是启动的时长。它在以下方法中被赋值:



  • ActivityMetricsLogger.notifyWindowsDrawn()

  • ➡️ TransitionInfo.calculateDelay()


//ActivityMetricsLogger.java
TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

private static final class TransitionInfo {
int calculateDelay(long timestampNs) {
long delayNanos = timestampNs - mTransitionStartTimeNs;
return (int) TimeUnit.NANOSECONDS.toMillis(delayNanos);
}
}


  1. timestampNs表示启动结束时间,mTransitionStartTimeNs表示启动开始时间。它们分别是在哪赋值的呢?


mTransitionStartTimeNs启动开始时间在notifyActivityLaunching方法中被赋值。调用堆栈如下:



  • ActivityManagerService.startActivity()

  • ➡️ActivityManagerService.startActivityAsUser()

  • ➡️ActivityStarter.execute()

  • ➡️ActivityMetricsLogger.notifyActivityLaunching()



ActivityMetricsLogger.notifyActivityLaunching(...)

//ActivityMetricsLogger.java
private LaunchingState notifyActivityLaunching(
Intent intent,
ActivityRecord caller,
int callingUid
) {
...
long transitionStartNs = SystemClock.elapsedRealtimeNanos();
LaunchingState launchingState = new LaunchingState();
launchingState.mCurrentTransitionStartTimeNs = transitionStartNs;
...
return launchingState;
}

启动时间记录到LaunchingState.mCurrentTransitionStartTimeNs


ActivityStarter.execute()

//ActivityStarter.java
int execute() {
try {
final LaunchingState launchingState;
synchronized (mService.mGlobalLock) {
final ActivityRecord caller = ActivityRecord.forTokenLocked(mRequest.resultTo);
launchingState = mSupervisor.getActivityMetricsLogger().notifyActivityLaunching(
mRequest.intent, caller);
}

if (mRequest.activityInfo == null) {
mRequest.resolveActivity(mSupervisor);
}

int res;
synchronized (mService.mGlobalLock) {

mSupervisor.getActivityMetricsLogger().notifyActivityLaunched(launchingState, res,
mLastStartActivityRecord);
return getExternalResult(mRequest.waitResult == null ? res
: waitForResult(res, mLastStartActivityRecord));
}
} finally {
onExecutionComplete();
}
}

该方法作用如下:



  1. 调用ActivityMetricsLogger().notifyActivityLaunching()生成LaunchingState。将启动时间记录其中

  2. 执行StartActivity逻辑

  3. 调用ActivityMetricsLogger().notifyActivityLaunched()把launchingState和ActivityRecord映射保存起来


ActivityMetricsLogger.notifyActivityLaunched(...)

//ActivityMetricsLogger.java
void notifyActivityLaunched(
LaunchingState launchingState,
int resultCode,
ActivityRecord launchedActivity) {
...
final TransitionInfo newInfo = TransitionInfo.create(launchedActivity, launchingState,
processRunning, processSwitch, resultCode);
if (newInfo == null) {
abort(info, "unrecognized launch");
return;
}

if (DEBUG_METRICS) Slog.i(TAG, "notifyActivityLaunched successful");
// A new launch sequence has begun. Start tracking it.
mTransitionInfoList.add(newInfo);
mLastTransitionInfo.put(launchedActivity, newInfo);
startLaunchTrace(newInfo);
if (newInfo.isInterestingToLoggerAndObserver()) {
launchObserverNotifyActivityLaunched(newInfo);
} else {
// As abort for no process switch.
launchObserverNotifyIntentFailed();
}
}

该方法将根据LaunchingState和ActivityRecord生成TransitionInfo保存到mTransitionInfoList中。这样就将启动开始时间保存起来了。


ActivityMetricsLogger.notifyWindowsDrawn(...)

//ActivityMetricsLogger.java
TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

//ActivityMetricsLogger.java
private TransitionInfo getActiveTransitionInfo(WindowContainer wc) {
for (int i = mTransitionInfoList.size() - 1; i >= 0; i--) {
final TransitionInfo info = mTransitionInfoList.get(i);
if (info.contains(wc)) {
return info;
}
}
return null;
}

notifyWindowsDraw方法正是通过查找mTransitionInfoList中对应的TransitionInfo获取到Activity的启动开始时间。


启动完成调用堆栈如下



  • ActivityRecord.onFirstWindowDrawn()

  • ➡️ActivityRecord.updateReportedVisibilityLocked()

  • ➡️ActivityRecord.onWindowsDrawn()

  • ➡️ActivityMetricsLogger.notifyWindowsDrawn()



ActivityRecord.updateReportedVisibilityLocked()

//ActivityRecord.java
void updateReportedVisibilityLocked() {
...
boolean nowDrawn = numInteresting > 0 && numDrawn >= numInteresting;
boolean nowVisible = numInteresting > 0 && numVisible >= numInteresting && isVisible();

if (nowDrawn != reportedDrawn) {
onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos());
reportedDrawn = nowDrawn;
}
...
}

void onWindowsDrawn(boolean drawn, long timestampNs) {
mDrawn = drawn;
if (!drawn) {
return;
}
final TransitionInfoSnapshot info = mStackSupervisor
.getActivityMetricsLogger().notifyWindowsDrawn(this, timestampNs);
...
}

我们看到在updateReportedVisibilityLocked()方法中把SystemClock.elapsedRealtimeNanos()传递给onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos())


4. 调试技巧


通过断点调试记录应用冷启动记录耗时调用栈



  1. 准备一台root的手机(或者非Google Play版本模拟器)

  2. compileSdkVersion、targetSdkVersion与模拟器版本一致(本文30)

  3. notifyActivityLaunching和notifyWindowsDrawn中增加断点

  4. 调试勾选Show all processes选择system_process



几个重要的时间节点


  1. ActivityManagerService接收到startActivity信号时间,等价于launchingState.mCurrentTransitionStartTimeNs。时间单位纳秒。

  2. 进程Fork的时间,时间单位毫秒。可以通过以下方式获取:


object Processes {
@JvmStatic
fun readProcessForkRealtimeMillis(): Long {
val myPid = android.os.Process.myPid()
val ticksAtProcessStart = readProcessStartTicks(myPid)
// Min API 21, use reflection before API 21.
// See https://stackoverflow.com/a/42195623/703646
val ticksPerSecond = Os.sysconf(OsConstants._SC_CLK_TCK)
return ticksAtProcessStart * 1000 / ticksPerSecond
}

// Benchmarked (with Jetpack Benchmark) on Pixel 3 running
// Android 10. Median time: 0.13ms
fun readProcessStartTicks(pid: Int): Long {
val path = "/proc/$pid/stat"
val stat = BufferedReader(FileReader(path)).use { reader ->
reader.readLine()
}
val fields = stat.substringAfter(") ")
.split(' ')
return fields[19].toLong()
}
}


  1. ActivityThread.handleBindApplication时设置的进程启动时间,单位毫秒。Process.getStartElapsedRealtime()


//ActivityThread.java
private void handleBindApplication(AppBindData data) {
...
// Note when this process has started.
Process.setStartTimes(SystemClock.elapsedRealtime(), SystemClock.uptimeMillis());
...
}


  1. 程序代码和资源加载的时间,时间单位毫秒。Application类初始化时的时间handleBindApplication的时间差


class MyApp extends Application {
static {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
long loadApkAndResourceDuration = SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime();
}
}
}


  1. ContentProvider初始化时间,时间单位毫秒。 Application.onCreate() 与Application.attachBaseContext(Context context) 之间的时间差


 class MyApp extends Application {
long mAttachBaseContextTime = 0L;
long mContentProviderDuration = 0L;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
mAttachBaseContextTime = SystemClock.elapsedRealtime();
}

@Override
public void onCreate() {
super.onCreate();
mContentProviderDuration = SystemClock.elapsedRealtime() - mAttachBaseContextTime;
}
}



  1. Application.onCreate()花费时间,时间单位毫秒。很简单方法开始和结束时间差。




  2. 首帧绘制时间,比较复杂,使用到了com.squareup.curtains:curtains:1.0.1代码如下,firstDrawTime就是首帧的绘制时间。从ActivityThread.handleBindApplication()到首帧绘制所花费的时间:




class MyApp extends Application {

@Override
public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
Window window = activity.getWindow();
WindowsKt.onNextDraw(window, () -> {
if (firstDraw) return null;
firstDraw = true;
handler.postAtFrontOfQueue(() -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
long firstDrawTime = (SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime()));
}
});
return null;
});
}
}
}
}

调试launchingState.mCurrentTransitionStartTimeNs

由于ActivityMetricsLogger是运行在system_process进程中。我们无法在应用进程中获取到transitionStartTimeNs,我们可以用过Debug打印日志。我们需要将断点设置成non-suspending。如图将Suspend反勾选。选中Evaluate and log,并写入日志语句。




日志输出如下:



2021-08-08 12:55:36.295 537-579/system_process D/AppStart: 19113098274557 Intent received



5. 总结


本文主要介绍了Android系统是如何测量应用启动时间以及应用开发者如何测量应用启动各个阶段的启动耗时。有了这些我们能够很好的定位启动过程中的耗时以及性能瓶颈。如果你在应用启动优化有比较好的实践成果欢迎留言讨论哟


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

6年的老项目迁移vite2,提速几十倍,真香

背景 gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它 先看看vue-cli3的启动编译吧... 该项目为内部运营管理系统...
继续阅读 »

vite-dev.png


背景



gou系统又老又大又乱,每一次的需求开发都极其难受,启动30|40几秒勉强能接受吧,毕竟一天也就这么一回,但是HMR更新也要好几秒实在是忍不了,看到了vite2就看到了曙光!盘它



先看看vue-cli3的启动编译吧...


编译-new-48803ms.png



  • 该项目为内部运营管理系统,年龄6岁+

  • 基于vue2+elementui,2年入职时将vue-cli2升级到了vue-cli3,2年后的今天迫不及待的的奔向vite2

  • 仅迁移开发环境(我的痛点只是开发环境,对于生产环境各位自行考虑)


痛点分析


实质上是对webpack工作原理的分析,webpack在开发环境的工作流大致如下(个人见解不喜勿喷):



查找入口文件 => 分析依赖关系 => 转化模块函数 => 打包生成bundle => node服务启动



所以随着项目越来越大,速度也就越来越慢...


至于HMR也是同理,只不过HMR是将当前文件作为入口,进行rebuild,涉及的相关依赖都需要重载


为什么是Vite



  • vite是基于esm实现的,主流浏览器已支持,所以不需要对文件进行打包编译

  • 项目启动超快(迁移后简单的概算数据是从30s 提升到 1s。30倍?3000%?一点都不夸张...)

  • 还是基于esmHMR很快,不需要编译重载,速度可以用一闪而过来形容...


vite大致工作流:



启动服务 => 查找入口文件(module script) => 浏览器发送请求 => vite劫持请求处理返回文件到浏览器



开盘,踏上迁移之路




  1. 安装相关npm包


    npm i vite vite-plugin-vue vite-plugin-html -D


    • vite-plugin-vue,用于构建vue,加载jsx

    • vite-plugin-html,用于入口文件模板注入




  2. package.json文件中,新增一个vite启动命令:


    "vite": "cross-env VITE_NODE_ENV=dev vite"



  3. 根目录新建vite.config.js文件




  4. public下的index.html复制一份到根目录



    仅迁移开发环境,public下仍然需要index.html,支持开发环境下vite和webpack两种模式





  5. 修改根目录下index.html(vite启动的入口文件,必须是根目录)


    <% if (htmlWebpackPlugin.options.isVite) { %>
    <script type="module" src="/src/main.js"></script>
    <%}%>


    htmlWebpackPlugin在vite.config.js注入,isVite用于标识是否是vite启动



    import { injectHtml } from 'vite-plugin-html';
    export default defineConfig({
     plugins:[
       injectHtml({
         injectData: {
           htmlWebpackPlugin: {
             options: {
               isVite: true
            }
          },
           title: '运营管理平台'
        }
      })
    ]
    })



  6. 完整vite.config.js 配置


    import { defineConfig } from 'vite'
    import path from 'path'
    import fs from 'fs'
    import { createVuePlugin } from 'vite-plugin-vue2'
    import { injectHtml, minifyHtml } from 'vite-plugin-html'
    import dotenv from 'dotenv'

    try {
       // 根据环境变量加载环境变量文件
       const VITE_NODE_ENV = process.env.VITE_NODE_ENV
       const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
       const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
           debug: true
      })
       for (const key in file) {
           process.env[key] = file[key]
      }
    } catch (e) {
       console.error(e)
    }

    const resolve = (dir) => {
       return path.join(__dirname, './', dir)
    }
    export default defineConfig({
       root: './',
       publicDir: 'public',
       base: './',
       mode: 'development',
       optimizeDeps: {
           include: []
      },
       resolve: {
           alias: {
               'vendor': resolve('src/vendor'),
               '@': resolve('src'),
               '~component': resolve('src/components')
          },
           extensions: [
               '.mjs',
               '.js',
               '.ts',
               '.jsx',
               '.tsx',
               '.json',
               '.vue'
          ]
      },
       plugins: [
           createVuePlugin({
               jsx: true,
               jsxOptions: {
                   injectH: false
              }
          }),
           minifyHtml(),
           injectHtml({
               injectData: {
                   htmlWebpackPlugin: {
                       options: {
                           isVite: true
                      }
                  },
                   title: '运营管理平台'
              }
          })
      ],
       define: {
           'process.env': process.env
      },
       server: {
           host: '0.0.0.0',
           open: true,
           port: 3100,
           proxy: {}
      }
    })



    相关配置会在下文遇到的问题中做具体描述





迁移过程中遇到的问题




  1. Uncaught SyntaxError: The requested module 'xx.js' does not provide an export named 'xx'


    本人遇到的分以下两类情况:


    a. 一个模块只能有一个默认输出,导入默认输出时,import命令后不需要加大括号,否则会报错


    处理方式:将原先{}导入的keys,改成导入默认keyes6解构赋值


    -import { postRedeemDistUserUpdate } from '@/http-handle/api_types'

    +import api_types from '@/http-handle/api_types'
    +const { postRedeemDistUserUpdate } = api_types

    b. 浏览器仅支持 esm,不支持 cjs,需要将cjs改为esm (看了网文有通过cjs2esmodule处理的,但是本人应用有些场景是报错的,最后就去掉了)


    处理方式:不推荐使用cjs2esmodule,手动将module.exports更改为export


    -module.exports = {

    +export default {



  2. .vue文件扩展,最新版本的vite貌似已支持extensions添加.vue,不过还是推荐手动添加下后缀。(骚操作:正则匹配批量添加)




  3. Uncaught ReferenceError: require is not defined


    浏览器不支持cjs


    处理方式:require引用的文件都需要修改为import引用




  4. vite启动,页面空白


    处理方式:注意入口文件index.html,需要放置项目根目录




  5. vite环境下默认没有process.env,可通过define定义全局变量


    vue-cli模式下,环境变量都是读取根目录.env文件中的变量,那么vite模式下是否也可以读取.env文件中的变量最终注入到process.env中呢?


    这样不就可以两种模式共存了么?成本变小了么?


    处理方式:



    1. 安装环境变量加载工具:dotenv


    npm i dotenv -D




    1. 自定义全局变量process.env


      vite.config.js中配置




    define: {
    'process.env': {}
    }



    1. 加载环境变量,并添加到process.env


      vite.config.js中配置



      因为仅迁移开发环境,所以我这里默认是读取.local文件。


      VITE_NODE_ENV是在启动时通过cross-env注入的







import dotenv from 'dotenv'
try {
const VITE_NODE_ENV = process.env.VITE_NODE_ENV
const envLocalSuffix = VITE_NODE_ENV === 'dev' ? '.local' : ''
const file = dotenv.parse(fs.readFileSync(`./.env.${VITE_NODE_ENV}${envLocalSuffix}`), {
debug: true
})
console.log(file)
for (const key in file) {
process.env[key] = file[key]
}
} catch (e) {
console.error(e)
}




  1. jsx支持


    vite.config.js中配置


    plugins: [
    createVuePlugin({
      jsx: true,
      jsxOptions: {
        injectH: false
      }
    })



  2. webpack中require.context方法,在vite中使用import.meta.glob替换




现存问题


项目中导入/导出的功能,是纯前端实现的


require('script-loader!file-saver')
require('script-loader!@/vendor/Blob')

由于以上文件目前不支持import引入,webpack下是通过script-loader加载挂载到全局的,vite环境下未能解决。需要导入导出功能时只能切换到vue-cli模式启动服务...


如果各位大大有方案,麻烦指导指导~,实在是不想回到webpack开发了...


最后


总体迁移上并没有遇到什么疑难杂症,迁移成本还是不大的,实操1-2天,性价比很高哦,我这个项目按数据看就是几十倍的启动提效,几倍的HMR提效...各位可以在内部系统上做下尝试。



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

收起阅读 »

50行代码串行Promise,koa洋葱模型原来是这么实现?

1. 前言 写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。 所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂。 之前写过 ko...
继续阅读 »

1. 前言


写相对很难的源码,耗费了自己的时间和精力,也没收获多少阅读点赞,其实是一件挺受打击的事情。从阅读量和读者受益方面来看,不能促进作者持续输出文章。


所以转变思路,写一些相对通俗易懂的文章。其实源码也不是想象的那么难,至少有很多看得懂


之前写过 koa 源码文章学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理比较长,读者朋友大概率看不完,所以本文从koa-compose50行源码讲述。


本文涉及到的 koa-compose 仓库 文件,整个index.js文件代码行数虽然不到 50 行,而且测试用例test/test.js文件 300 余行,但非常值得我们学习。


歌德曾说:读一本好书,就是在和高尚的人谈话。 同理可得:读源码,也算是和作者的一种学习交流的方式。


阅读本文,你将学到:


1. 熟悉 koa-compose 中间件源码、可以应对面试官相关问题
2. 学会使用测试用例调试源码
3. 学会 jest 部分用法

2. 环境准备


2.1 克隆 koa-compose 项目


本文仓库地址 koa-compose-analysis,求个star~


# 可以直接克隆我的仓库,我的仓库保留的 compose 仓库的 git 记录
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose/compose
npm i

顺带说下:我是怎么保留 compose 仓库的 git 记录的。


# 在 github 上新建一个仓库 `koa-compose-analysis` 克隆下来
git clone https://github.com/lxchuan12/koa-compose-analysis.git
cd koa-compose-analysis
git subtree add --prefix=compose https://github.com/koajs/compose.git main
# 这样就把 compose 文件夹克隆到自己的 git 仓库了。且保留的 git 记录

关于更多 git subtree,可以看这篇文章用 Git Subtree 在多个 Git 项目间双向同步子项目,附简明使用手册


接着我们来看怎么根据开源项目中提供的测试用例调试源码。


2.2 根据测试用例调试 compose 源码


VSCode(我的版本是 1.60 )打开项目,找到 compose/package.json,找到 scriptstest 命令。


// compose/package.json
{
"name": "koa-compose",
// debug (调试)
"scripts": {
"eslint": "standard --fix .",
"test": "jest"
},
}

scripts上方应该会有debug或者调试字样。点击debug(调试),选择 test


VSCode 调试


接着会执行测试用例test/test.js文件。终端输出如下图所示。


koa-compose 测试用例输出结果


接着我们调试 compose/test/test.js 文件。
我们可以在 45行 打上断点,重新点击 package.json => srcipts => test 进入调试模式。
如下图所示。


koa-compose 调试


接着按上方的按钮,继续调试。在compose/index.js文件中关键的地方打上断点,调试学习源码事半功倍。


更多 nodejs 调试相关 可以查看官方文档


顺便提一下几个调试相关按钮。





    1. 继续(F5)





    1. 单步跳过(F10)





    1. 单步调试(F11)





    1. 单步跳出(Shift + F11)





    1. 重启(Ctrl + Shift + F5)





    1. 断开链接(Shift + F5)




接下来,我们跟着测试用例学源码。


3. 跟着测试用例学源码


分享一个测试用例小技巧:我们可以在测试用例处加上only修饰。


// 例如
it.only('should work', async () => {})

这样我们就可以只执行当前的测试用例,不关心其他的,不会干扰调试。


3.1 正常流程


打开 compose/test/test.js 文件,看第一个测试用例。


// compose/test/test.js
'use strict'

/* eslint-env jest */

const compose = require('..')
const assert = require('assert')

function wait (ms) {
return new Promise((resolve) => setTimeout(resolve, ms || 1))
}
// 分组
describe('Koa Compose', function () {
it.only('should work', async () => {
const arr = []
const stack = []

stack.push(async (context, next) => {
arr.push(1)
await wait(1)
await next()
await wait(1)
arr.push(6)
})

stack.push(async (context, next) => {
arr.push(2)
await wait(1)
await next()
await wait(1)
arr.push(5)
})

stack.push(async (context, next) => {
arr.push(3)
await wait(1)
await next()
await wait(1)
arr.push(4)
})

await compose(stack)({})
// 最后输出数组是 [1,2,3,4,5,6]
expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
})
}

大概看完这段测试用例,context是什么,next又是什么。


koa的文档上有个非常代表性的中间件 gif 图。


中间件 gif 图


compose函数作用就是把添加进中间件数组的函数按照上面 gif 图的顺序执行。


3.1.1 compose 函数


简单来说,compose 函数主要做了两件事情。





    1. 接收一个参数,校验参数是数组,且校验数组中的每一项是函数。





    1. 返回一个函数,这个函数接收两个参数,分别是contextnext,这个函数最后返回Promise




/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/
function compose (middleware) {
// 校验传入的参数是数组,校验数组中每一项是函数
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}

/**
* @param {Object} context
* @return {Promise}
* @api public
*/

return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0)
function dispatch(i){
// 省略,下文讲述
}
}
}

接着我们来看 dispatch 函数。


3.1.2 dispatch 函数


function dispatch (i) {
// 一个函数中多次调用报错
// await next()
// await next()
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
// 取出数组里的 fn1, fn2, fn3...
let fn = middleware[i]
// 最后 相等,next 为 undefined
if (i === middleware.length) fn = next
// 直接返回 Promise.resolve()
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}

值得一提的是:bind函数是返回一个新的函数。第一个参数是函数里的this指向(如果函数不需要使用this,一般会写成null)。
这句fn(context, dispatch.bind(null, i + 1)i + 1 是为了 let fn = middleware[i]middleware中的下一个函数。
也就是 next 是下一个中间件里的函数。也就能解释上文中的 gif图函数执行顺序。
测试用例中数组的最终顺序是[1,2,3,4,5,6]


3.1.3 简化 compose 便于理解


自己动手调试之后,你会发现 compose 执行后就是类似这样的结构(省略 try catch 判断)。


// 这样就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = stack;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};


也就是说koa-compose返回的是一个Promise,从中间件(传入的数组)中取出第一个函数,传入context和第一个next函数来执行。

第一个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第二个函数,传入context和第二个next函数来执行。

第二个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第三个函数,传入context和第三个next函数来执行。

第三个...

以此类推。最后一个中间件中有调用next函数,则返回Promise.resolve。如果没有,则不执行next函数。



这样就把所有中间件串联起来了。这也就是我们常说的洋葱模型。


洋葱模型图如下图所示:


不得不说非常惊艳,“玩还是大神会玩”


3.2 错误捕获


it('should catch downstream errors', async () => {
const arr = []
const stack = []

stack.push(async (ctx, next) => {
arr.push(1)
try {
arr.push(6)
await next()
arr.push(7)
} catch (err) {
arr.push(2)
}
arr.push(3)
})

stack.push(async (ctx, next) => {
arr.push(4)
throw new Error()
})

await compose(stack)({})
// 输出顺序 是 [ 1, 6, 4, 2, 3 ]
expect(arr).toEqual([1, 6, 4, 2, 3])
})

相信理解了第一个测试用例和 compose 函数,也是比较好理解这个测试用例了。这一部分其实就是对应的代码在这里。

try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}

3.3 next 函数不能调用多次


it('should throw if next() is called multiple times', () => {
return compose([
async (ctx, next) => {
await next()
await next()
}
])({}).then(() => {
throw new Error('boom')
}, (err) => {
assert(/multiple times/.test(err.message))
})
})

这一块对应的则是:


index = -1
dispatch(0)
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
}

调用两次后 iindex 都为 1,所以会报错。


compose/test/test.js文件中总共 300余行,还有很多测试用例可以按照文中方法自行调试。


4. 总结


虽然koa-compose源码 50行 不到,但如果是第一次看源码调试源码,还是会有难度的。其中混杂着高阶函数、闭包、Promisebind等基础知识。


通过本文,我们熟悉了 koa-compose 中间件常说的洋葱模型,学会了部分 jest 用法,同时也学会了如何使用现成的测试用例去调试源码。


相信学会了通过测试用例调试源码后,会觉得源码也没有想象中的那么难


开源项目,一般都会有很全面的测试用例。除了可以给我们学习源码调试源码带来方便的同时,也可以给我们带来的启发:自己工作中的项目,也可以逐步引入测试工具,比如 jest


此外,读开源项目源码是我们学习业界大牛设计思想和源码实现等比较好的方式。



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

收起阅读 »

这是一个被面烂的面试题——简述 JavaScript 的事件捕获和事件冒泡

JavaScript 事件冒泡是为了捕捉和处理 DOM 内部传播的事件。但是你知道事件冒泡和事件捕获之间的区别吗? 在这篇文章中,我将用相关的示例来讨论关于这个主题你所需要了解的全部情况。 事件流的传播 在介绍事件捕获和事件冒泡之前,先来看下一个事件是如何在 ...
继续阅读 »

JavaScript 事件冒泡是为了捕捉和处理 DOM 内部传播的事件。但是你知道事件冒泡和事件捕获之间的区别吗?


在这篇文章中,我将用相关的示例来讨论关于这个主题你所需要了解的全部情况。


事件流的传播


在介绍事件捕获和事件冒泡之前,先来看下一个事件是如何在 DOM 内部传播的。


如果我们有几个嵌套的元素处理同一个事件,我们会对哪个事件处理程序会先触发的问题感到困惑。这时,理解事件传播顺序就变得很有必要。



通常,一个事件会从父元素开始向目标元素传播,然后它将被传播回父元素。



JavaScript 事件分为三个阶段:



  • 捕获阶段:事件从父元素开始向目标元素传播,从 Window 对象开始传播。

  • 目标阶段:该事件到达目标元素或开始该事件的元素。

  • 冒泡阶段:这时与捕获阶段相反,事件向父元素传播,直到 Window 对象。


下图将让你进一步了解事件传播的生命周期:


22.jpg


现在你大概了解了 DOM 内部的事件流程,让我们再来看下事件捕获和冒泡是如何出现的。


什么是事件捕获



事件捕获是事件传播的初始场景,从包装元素开始,一直到启动事件生命周期的目标元素。



如果你有一个与浏览器的 Window 对象绑定的事件,它将是第一个被执行的。所以,在下面的例子中,事件处理的顺序将是 WindowDocumentDIV 2DIV 1,最后是 button


33.gif


这里我们可以看到,事件捕获只发生在被点击的元素或目标上,该事件不会传播到子元素。


我们可以使用 addEventListener() 方法的 useCapture 参数来注册捕捉阶段的事件。


target.addEventListener(type, listener, useCapture)

你可以使用下面的代码来测试上述示例,并获得事件捕获的实践经验。


window.addEventListener("click", () => {
console.log('Window');
},true);

document.addEventListener("click", () => {
console.log('Document');
},true);

document.querySelector(".div2").addEventListener("click", () => {
console.log('DIV 2');
},true);

document.querySelector(".div1").addEventListener("click", () => {
console.log('DIV 1');
},true);

document.querySelector("button").addEventListener("click", () => {
console.log('CLICK ME!');
},true);

什么是事件冒泡


如果你知道事件捕获,事件冒泡就很容易理解,它与事件捕获是完全相反的。



事件冒泡将从一个子元素开始,在 DOM 树上传播,直到最上面的父元素事件被处理。



addEventListener() 中省略或将 useCapture 参数设置为 false,将注册冒泡阶段的事件。所以,事件监听器默认监听冒泡事件。


44.gif


在我们的示例中,我们对所有的事件使用了事件捕获或事件冒泡。但是如果我们想在两个阶段内都处理事件呢?


让我们举个例子,在冒泡阶段处理 DocumentDIV 2 的点击事件,其他事件则在捕获阶段处理。


55.gif


连接到 WindowDIV 1button 的点击事件将在捕获过程中分别触发,而 DIV 2Document 监听器则在冒泡阶段依次触发。


window.addEventListener("click", () => {
console.log('Window');
},true);

document.addEventListener("click", () => {
console.log('Document');
}); // 已注册为冒泡

document.querySelector(".div2").addEventListener("click", () => {
console.log('DIV 2');
}); // 已注册为冒泡

document.querySelector(".div1").addEventListener("click", () => {
console.log('DIV 1');
},true);

document.querySelector("button").addEventListener("click", () => {
console.log('CLICK ME!');
},true);

我想现在你已经对事件流、事件冒泡和事件捕获有了很好的理解。那么,让我们看下什么时候可以使用事件冒泡和事件捕获。


事件捕获和冒泡的应用


通常情况下,我们只需要在全局范围内执行一个函数,就可以使用事件传播。例如,我们可以注册文档范围内的监听器,如果 DOM 内有事件发生,它就会运行。



同样地,我们可以使用事件捕获和冒泡来改变用户界面。



假设我们有一个允许用户选择单元格的表格,我们需要向用户显示所选单元格。


66.gif



在这种情况下,为每个单元格分配事件处理程序将不是一个好的做法。它最终会导致代码的重复。



作为一个解决方案,我们可以使用一个单独的事件监听器,并利用事件冒泡和捕获来处理这些事件。


因此,我为 table 创建了一个单独的事件监听器,它将被用来改变单元格的样式。


document.querySelector("table").addEventListener("click", (event) =>
{
if (event.target.nodeName == 'TD')
event.target.style.background = "rgb(230, 226, 40)";
}
);

在事件监听器中,我使用 nodeName 来匹配被点击的单元格,如果匹配,单元格的颜色就会改变。


如何防止事件传播



有时,如果事件冒泡和捕捉开始不受我们控制地传播时,就会让人感到厌烦。



如果你有一个严重嵌套的元素结构,这也会导致性能问题,因为每个事件都会创建一个新的事件周期。


77.gif


在上述情况下,当我点击删除按钮时,包装元素的点击事件也被触发了。这是由于事件冒泡导致的。



我们可以使用 stopPropagation() 方法来避免这种行为,它将阻止事件沿着 DOM 树向上或向下进一步传播。



document.querySelector(".card").addEventListener("click", () => {
$("#detailsModal").modal();
});

document.querySelector("button").addEventListener("click",(event)=>{
event.stopPropagation(); // 停止冒泡
$("#deleteModal").modal();
});

88.gif


本文总结


JavaScript 事件捕获和冒泡可以用来有效地处理 Web 应用程序中的事件。了解事件流以及捕获和冒泡是如何工作的,将有助于你通过正确的事件处理来优化你的应用程序。


例如,如果你的应用程序中有任何意外的事件启动,了解事件捕获和冒泡可以节省你排查问题的时间。


因此,我希望你尝试上述示例并在评论区分享你的经验。


感谢阅读!


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

收起阅读 »

几个简单的小例子手把手带你入门webgl

各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点: 为什么需要有shader ? shader的作用是什么???? shader 中的每个参数到...
继续阅读 »

各位同学们大家好,又到了周末写文章的时间,之前群里有粉丝提问, 就是shader不是很理解。 然后今天他就来了, 废话不多说,读完今天的这篇文章你可以学到以下几点:



  1. 为什么需要有shader ? shader的作用是什么????

  2. shader 中的每个参数到底是什么意思?? 怎么去用???


你如果会了,这篇文章你可以不用看👀,不用浪费时间,去看别的文章。 如果哪里写的有问题欢迎大家指正,我也在不断地学习当中。


WHY NEED SHADER


这里我结合自己的思考🤔,讲讲webgl的整个的一个渲染过程。


渲染管线


Webgl的渲染依赖底层GPU的渲染能力。所以WEBGL 渲染流程和 GPU 内部的渲染管线是相符的。


渲染管线的作用是将3D模型转换为2维图像。


在早期,渲染管线是不可编程的,叫做固定渲染管线,工作的细节流程已经固定,修改的话需要调整一些参数。


现代的 GPU 所包含的渲染管线为可编程渲染管线,可以通过编程 GLSL 着色器语言 来控制一些渲染阶段的细节。


简单来说: 就是使用shader,我们可以对画布中每个像素点做处理,然后就可以生成各种酷炫的效果了。


渲染过程


渲染过程大概经历了下面这么多过程, 因为本篇文章的重点其实是在着色器,所以我重点分析从顶点着色器—— 片元着色器的一个过程



  • 顶点着色器

  • 图片装配

  • 光栅化

  • 片元着色器

  • 逐片段操作(本文不会分享此内容)

  • 裁剪测试

  • 多重采样操作

  • 背面剔除

  • 模板测试

  • 深度测试

  • 融合

  • 缓存


顶点着色器


WebGL就是和GPU打交道,在GPU上运行的代码是一对着色器,一个是顶点着色器,另一个是片元着色器。每次调用着色程序都会先执行顶点着色器,再执行片元着色器。


一个顶点着色器的工作是生成裁剪空间坐标值,通常是以下的形式:


const vertexShaderSource = `
   attribute vec3 position;
   void main() {
       gl_Position = vec4(position,1);
   }

每个顶点调用一次(顶点)着色器,每次调用都需要设置一个特殊的全局变量 gl_Position。 该变量的值就是裁减空间坐标值。 这里有同学就问了, 什么是裁剪空间的坐标值???


其实我之前有讲过,我在讲一遍。


何为裁剪空间坐标?就是无论你的画布有多大,裁剪坐标的坐标范围永远是 -1 到 1 。


看下面这张图:


裁剪坐标系


如果运行一次顶点着色器, 那么gl_Position 就是 (-0.5,-0.5,0,1) 记住他永远是个 Vec4, 简单理解就是对应x、y、z、w。即使你没用其他的,也要设置默认值, 这就是所谓的 3维模型转换到我们屏幕中。


顶点着色器需要的数据,可以通过以下四种方式获得。



  1. attributes 属性(从缓冲读取数据)

  2. uniforms 全局变量 (一般用来对物体做整体变化、 旋转、缩放)

  3. textures 纹理(从像素或者纹理获得数据)

  4. varyings 变量 (将顶点着色器的变量 传给 片元着色器)


ATTRIBUTES 属性


属性可以用 float, vec2, vec3, vec4, mat2, mat3mat4 数据类型


所以它内建的数据类型例如vec2, vec3vec4分别代表两个值,三个值和四个值, 类似的还有mat2, mat3mat4 分别代表 2x2, 3x3 和 4x4 矩阵。 你可以做一些运算例如常量和矢量的乘法。看几个例子吧:


vec4 a = vec4(1, 2, 3, 4);
vec4 b = a * 2.0;
// b 现在是 vec4(2, 4, 6, 8);

向量乘法 和矩阵乘法 :


mat4 a = ???
mat4 b = ???
mat4 c = a * b;

vec4 v = ???
vec4 y = c * v;

它还支持矢量调制,意味者你可以交换或重复分量。


v.yyyy  ===  vec4(y, y, y,y )
v.bgra  ===  vec4(v.b,v.g,v.r,v.a)
vec4(v.rgb, 1) ===  vec4(v.r, v.g, v.b, 1)
vec4(1) === vec4(1, 1, 1, 1)

这样你在处理图片的时候可以轻松进行 颜色通道 对调, 发现你可以实现各种各样的滤镜了。


后面的属性在下面实战中会讲解:我们接着往下走:


图元装配和光栅化


什么是图元?



描述各种图形元素的函数叫做图元,描述几何元素的称为几何图元(点,线段或多边形)。点和线是最简单的几何图元 经过顶点着色器计算之后的坐标会被组装成组合图元



通俗解释图元就是一个点、一条线段、或者是一个多边形。


什么是图元装配呢?


简单理解就是说将我们设置的顶点、颜色、纹理等内容组装称为一个可渲染的多边形的过程。


组装的类型取决于: 你最后绘制选择的图形类型


gl.drawArrays(gl.TRIANGLES, 0, 3)

如果是三角形的话,顶点着色器就执行三次


光栅化


什么是光栅化:


通过图元装配生成的多边形,计算像素并填充,剔除不可见的部分,剪裁掉不在可视范围内的部分。最终生成可见的带有颜色数据的图形并绘制。


光栅化流程图解:


光珊化图解


剔除和剪裁




  • 剔除


    在日常生活中,对于不透明物体,背面对于观察者来说是不可见的。同样,在webgl中,我们也可以设定物体的背面不可见,那么在渲染过程中,就会将不可见的部分剔除,不参与绘制。节省渲染开销。




  • 剪裁


    日常生活中不论是在看电视还是观察物体,都会有一个可视范围,在可视范围之外的事物我们是看不到的。类似的,图形生成后,有的部分可能位于可视范围之外,这一部分会被剪裁掉,不参与绘制。以此来提高性能。这个就是视椎体, 在📷范围内能看到的东西,才进行绘制。




片元着色器


光珊化后,每一个像素点都包含了 颜色 、深度 、纹理数据, 这个我们叫做片元



小tips : 每个像素的颜色由片元着色器的gl_FragColor提供



接收光栅化阶段生成的片元,在光栅化阶段中,已经计算出每个片元的颜色信息,这一阶段会将片元做逐片元挑选的操作,处理过的片元会继续向后面的阶段传递。 片元着色器运行的次数由图形有多少个片元决定的


逐片元挑选


通过模板测试和深度测试来确定片元是否要显示,测试过程中会丢弃掉部分无用的片元内容,然后生成可绘制的二维图像绘制并显示。



  • 深度测试: 就是对 z 轴的值做测试,值比较小的片元内容会覆盖值比较大的。(类似于近处的物体会遮挡远处物体)。

  • 模板测试: 模拟观察者的观察行为,可以接为镜像观察。标记所有镜像中出现的片元,最后只绘制有标记的内容。


实战——绘制个三角形


在进行实战之前,我们先给你看一张图,让你能大概了解,用原生webgl生成一个三角形需要那些步骤:


draw


我们就跟着这个流程图一步一步去操作:


初始化CANVAS


新建一个webgl画布


<canvas id="webgl" width="500" height="500"></canvas>

创建webgl 上下文:


const gl = document.getElementById('webgl').getContext('webgl')

创建着色器程序


着色器的程序这些代码,其实是重复的,我们还是先看下图,看下我们到底需要哪些步骤:


shader


那我们就跟着这个流程图: 一步一步来好吧。


创建着色器


 const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)

gl.VERTEX_SHADER 和 gl.FRAGMENT_SHADER 这两个是全局变量 分别表示顶点着色器片元着色器


绑定数据源


顾名思义: 数据源,也就是我们的着色器 代码。


编写着色器代码有很多种方式:



  1. 用 script 标签 type notjs 这样去写

  2. 模板字符串 (比较喜欢推荐这种)


我们先写顶点着色器:


const vertexShaderSource = `
   attribute vec4 a_position;
   void main() {
       gl_Position = a_position;
   }
`

顶点着色器 必须要有 main 函数 ,他是强类型语言, 记得加分号哇 不是js 兄弟们。 我这段着色器代码非常简单 定义一个vec4 的顶点位置, 然后传给 gl_Position


这里有小伙伴会问 ? 这里a_position一定要这么搞??


这里其实是这样的哇, 就是我们一般进行变量命名的时候 都会的前缀 用来区分 他是属性 还是 全局变量 还是纹理 比如这样:


uniform mat4 u_mat;

表示个矩阵,如果不这样也可以哈。 但是要专业呗,防止bug 影响。


我们接着写片元着色器:


const fragmentShaderSource = `
   void main() {
       gl_FragColor = vec4(1.0,0.0,0.0,1.0);
   }
`

这个其实理解起来非常简单哈, 每个像素点的颜色 是红色 , gl_FragColor 其实对应的是 rgba 也就是颜色的表示。


有了数据源之后开始绑定:


// 创建着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
//绑定数据源
gl.shaderSource(vertexShader, vertexShaderSource)
gl.shaderSource(fragmentShader, fragmentShaderSource)


是不是很简答哈哈哈哈,我觉得你应该会了。


后面着色器的一些操作


其实后面编译着色器绑定着色器连接着色器程序使用着色器程序 都是一个api 搞定的事不多说了 直接看代码:


// 编译着色器
gl.compileShader(vertexShader)
gl.compileShader(fragmentShader)
// 创建着色器程序
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
// 链接 并使用着色器
gl.linkProgram(program)
gl.useProgram(program)

这样我们就创建好了一个着色器程序了。


这里又有人问,我怎么知道我创建的着色器是对的还是错的呢? 我就是很粗心的人呢??? 好的他来了 如何调试:


const success = gl.getProgramParameter(program, gl.LINK_STATUS)
if (success) {
 gl.useProgram(program)
 return program
}
console.error(gl.getProgramInfoLog(program), 'test---')
gl.deleteProgram(program)


getProgramParameter 这个方法用来判断 我们着色器 glsl 语言写的是不是对的, 然后你可以通过 getProgramInfoLog这个方法 类似于打 日志 去发现❌了。


数据存入缓冲区


有了着色器,现在我们差的就是数据了对吧。


上文在写顶点着色器的时候用到了Attributes属性,说明是这个变量要从缓冲中读取数据,下面我们就来把数据存入缓冲中。


首先创建一个顶点缓冲区对象(Vertex Buffer Object, VBO)


const buffer = gl.createBuffer()

gl.createBuffer()函数创建缓冲区并返回一个标识符,接下来需要为WebGL绑定这个buffer


gl.bindBuffer(gl.ARRAY_BUFFER, buffer)

gl.bindBuffer()函数把标识符buffer设置为当前缓冲区,后面的所有的数据都会都会被放入当前缓冲区,直到bindBuffer绑定另一个当前缓冲区


我们新建一个数组 然后并把数据存入到缓冲区中。


const data = new Float32Array([0.0, 0.0, -0.3, -0.3, 0.3, -0.3])
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)

因为JavaScript与WebGL通信必须是二进制的,不能是传统的文本格式,所以这里使用了ArrayBuffer对象将数据转化为二进制,因为顶点数据是浮点数,精度不需要太高,所以使用Float32Array就可以了,这是JavaScript与GPU之间大量实时交换数据的有效方法。


gl.STATIC_DRAW 指定数据存储区的使用方法: 缓存区的内容可能会经常使用,但是不会更改


gl.DYNAMIC_DRAW 表示 缓存区的内容经常使用,也会经常更改。


gl.STREAM_DRAW 表示缓冲区的内容可能不会经常使用


从缓冲中读取数据


GLSL着色程序的唯一输入是一个属性值a_position。 我们要做的第一件事就是从刚才创建的GLSL着色程序中找到这个属性值所在的位置。


const aposlocation = gl.getAttribLocation(program, 'a_position')

接下来我们需要告诉WebGL怎么从我们之前准备的缓冲中获取数据给着色器中的属性。 首先我们需要启用对应属性


gl.enableVertexAttribArray(aposlocation)

最后是从缓冲中读取数据绑定给被激活的aposlocation的位置


gl.vertexAttribPointer(aposlocation, 2, gl.FLOAT, false, 0, 0)

gl.vertexAttribPointer()函数有六个参数:



  1. 读取的数据要绑定到哪

  2. 表示每次从缓存取几个数据,也可以表示每个顶点有几个单位的数据,取值范围是1-4。这里每次取2个数据,之前vertices声明的6个数据,正好是3个顶点的二维坐标。

  3. 表示数据类型,可选参数有gl.BYTE有符号的8位整数,gl.SHORT有符号的16位整数,gl.UNSIGNED_BYTE无符号的8位整数,gl.UNSIGNED_SHORT无符号的16位整数,gl.FLOAT32位IEEE标准的浮点数。

  4. 表示是否应该将整数数值归一化到特定的范围,对于类型gl.FLOAT此参数无效。

  5. 表示每次取数据与上次隔了多少位,0表示每次取数据连续紧挨上次数据的位置,WebGL会自己计算之间的间隔。

  6. 表示首次取数据时的偏移量,必须是字节大小的倍数。0表示从头开始取。


渲染


现在着色器程序 和数据都已经ready 了, 现在就差渲染了。 渲染之前和2d canvas 一样做一个清除画布的动作:


// 清除canvas
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)

我们用0、0、0、0清空画布,分别对应 r, g, b, alpha (红,绿,蓝,阿尔法)值, 所以在这个例子中我们让画布变透明了。


开启绘制三角形:


gl.drawArrays(gl.TRIANGLES, 0, 3)


  1. 第一个参数表示绘制的类型

  2. 第二个参数表示从第几个顶点开始绘制

  3. 第三个参数表示绘制多少个点,缓冲中一共6个数据,每次取2个,共3个点


绘制类型共有下列几种 看图:


drawtype


这里我们看下画面是不是一个红色的三角形 :


三角形截图


我们创建的数据是这样的:


画布的宽度是 500 * 500 转换出来的实际数据其实是这样的


0,0  ====>  0,0 
-0.3, -0.3 ====> 175, 325
0.3, -0.3 ====>  325, 325

矩阵的使用


有了静态的图形我们开始着色器,对三角形做一个缩放。


改写顶点着色器: 其实在顶点着色器上加一个全局变量 这就用到了 着色器的第二个属性 uniform


 const vertexShaderSource = `
 attribute vec4 a_position;
 // 添加矩阵代码
 uniform mat4 u_mat;
 void main() {
     gl_Position = u_mat * a_position;
 }
`

然后和属性一样,我们需要找到 uniform 对应的位置:


const matlocation = gl.getUniformLocation(program, 'u_mat')

然后初始化一个缩放举证:


// 初始化一个旋转矩阵。
 const mat = new Float32Array([
   Tx,  0.0, 0.0, 0.0,
   0.0,  Ty, 0.0, 0.0,
   0.0, 0.0,  Tz, 0.0,
   0.0, 0.0, 0.0, 1.0,
]);

Tx, Ty, Tz 对应的其实就是 x y z 轴缩放的比例。


最后一步, 将矩阵应用到着色器上, 在画之前, 这样每个点 就可以✖️ 这个缩放矩阵了 ,所以整体图形 也就进行了缩放。


gl.uniformMatrix4fv(matlocation, false, mat)

三个参数分别代表什么意思:



  1. 全局变量的位置

  2. 是否为转置矩阵

  3. 矩阵数据


OK 我写了三角形缩放的动画:


  let Tx = 0.1 //x坐标的位置
 let Ty = 0.1 //y坐标的位置
 let Tz = 1.0 //z坐标的位置
 let Tw = 1.0 //差值
 let isOver = true
 let step = 0.08
 function run() {
   if (Tx >= 3) {
     isOver = false
  }
   if (Tx <= 0) {
     isOver = true
  }
   if (isOver) {
     Tx += step
     Ty += step
  } else {
     Tx -= step
     Ty -= step
  }
   const mat = new Float32Array([
     Tx,  0.0, 0.0, 0.0,
     0.0,  Ty, 0.0, 0.0,
     0.0, 0.0,  Tz, 0.0,
     0.0, 0.0, 0.0, 1.0,
  ]);
   gl.uniformMatrix4fv(matlocation, false, mat)
   gl.drawArrays(gl.TRIANGLES, 0, 3)

   // 使用此方法实现一个动画
   requestAnimationFrame(run)
}

效果图如下:


缩放动画


最后 给大家看一下webgl 内部是怎么搞的 一张gif 动画 :


vertex-shader-anim


原始的数据通过 顶点着色器 生成一系列 新的点。


变量的使用


说完矩阵了下面👇,我们开始说下着色器中的varying 这个变量 是如何和片元着色器进行联动的。


我们还是继续改造顶点着色器:


const vertexShaderSource = `
 attribute vec4 a_position;
 uniform mat4 u_mat;
 // 变量
 varying vec4 v_color;
 void main() {
     gl_Position = u_mat * a_position;
     v_color = gl_Position * 0.5 + 0.5;
 }
`

这里有一个小知识 , gl_Position 他的值范围是 -1 -1 但是片元着色 他是颜色 他的范围是 0 - 1 , 所以呢这时候呢,我们就要 做一个范围转换 所以为什么要 乘 0.5 在加上 0.5 了, 希望你们明白。


改造下片元着色器:


const fragmentShaderSource = `
   precision lowp float;
   varying vec4 v_color;
   void main() {
       gl_FragColor = v_color;
   }
`

只要没一个像素点 改为由顶点着色器传过来的就好了。


我们看下这时候的三角形 变成啥样子了。


彩色三角形


是不是变成彩色三角形了, 这里很多人就会问, 这到底是怎么形成呢, 本质是在三角形的三个顶点, 做线性插值的过程:


插值过程


总结


本篇文章大概是对webgl 做了一个基本的介绍, 和带你用几个简单的小例子 带你入门了glsl 语言, 你以为webgl 就这样嘛 那你就错了,其实有一个texture 我是没有讲的, 后面我去专门写一篇文章去将纹理贴图 , 漫反射贴图、 法线贴图。 希望你关注下我,不然找不到我了, 如果你觉得本篇文章对你有帮助的话,欢迎 点赞 、评论、收藏。 我们下期再见👋。




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

收起阅读 »

Android技术分享| 开源Demo any自习室布局架构

需求 分析 布局分为横竖屏 涉及到视频窗口的大小、位置切换 通过观察需求原型图可得知,横竖屏切换可以简单分成7块区域 4个视频窗口 1个title,显示「XX号房间」 1个ViewGroup,放置「头像,头像,头像,N个观众」 另1个ViewGrou...
继续阅读 »

需求


在这里插入图片描述


在这里插入图片描述


分析



  • 布局分为横竖屏

  • 涉及到视频窗口的大小、位置切换


通过观察需求原型图可得知,横竖屏切换可以简单分成7块区域



  • 4个视频窗口

  • 1个title,显示「XX号房间」

  • 1个ViewGroup,放置「头像,头像,头像,N个观众」

  • 另1个ViewGroup,放置聊天窗口相关


横竖屏切一开始我的思路是完全不使用系统的横竖屏切换,使用rotation来切换横竖屏,切换过程添加一个转换动画,如下图所示:


在这里插入图片描述


(请无视中间那个小眼睛)


但因为横屏之后依然有聊天功能,不调用系统的旋转,输入法依然还是竖屏的形式弹出来的。暂时没有查到解决办法。无奈改为使用系统的横竖屏切换,切换时通知ViewGroup重新计算测量、布局子View。


实现


首先我们复写onMeasure方法,遍历子View测量,指定为我们计算后的宽高,Mode设置为EXACTLY。


override fun onMeasure(widthSpace: Int, heightSpace: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)

isVertical = height > width

if (!isVertical) {
videosWidth = (width * 0.548f).toInt()
horizontalSmallVideoWidth = (videosWidth / 3.0f).toInt()
horizontalSmallVideoHeight = (horizontalSmallVideoWidth * 0.6803f).toInt()
topicViewHeight = height - horizontalSmallVideoHeight - videoViewSpacing
}

for (i in 0 until childCount) {
val child = getChildAt(i)

val location = if (child.tag == null) {
val location = createAndCalcCoordinates(child, i, width, height)
if (location.fromLeft == 0 && location.fromRight == 0) location.run {
fromLeft = toLeft
fromTop = toTop
fromRight = toRight
fromBottom = toBottom
}
location
} else {
child.tag as ViewLocation
}

child.tag = location
child.measure(
MeasureSpec.makeMeasureSpec(location.right - location.left, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(location.bottom - location.top, MeasureSpec.EXACTLY)
)
}

setMeasuredDimension(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
}

其中ViewLocalion对象存储了View四个边的位置(left、top、right、bottom)。
首次加载时创建ViewLocation对象,并根据横竖屏计算每个View的坐标,最终对象会存储到View的tag中。再次触发onMeasure时不会重新计算布局,而是沿用之前的坐标数据。


计算子View位置的函数只会在横竖屏切换、用户点击切换视频位置或大小时调用。


onLayout中遍历子View,通知布局并传入对应的四边位置即可。


override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0 until childCount) {
val child = getChildAt(i)
val location = child.tag as ViewLocation

child.layout(location.left, location.top, location.right, location.bottom)
}
}

计算子View位置的算法可以点击这里查看,就不贴出来了。有一个需要注意的细节是:更新View坐标(除横竖屏切换)时,实际更改的是目标坐标,而非当前坐标,这样做的目的是为了实现动画效果。


当前坐标指的是在onMeasureonLayout中使用的变量:left、top、right、bottom。
目标坐标表示动画执行完毕后View的位置。


执行动画与坐标计算充分解耦,无论想新增怎样的动画(平移、缩放),只需要根据需求计算好左、上、右、下的位置,发送给动画执行的Runnable即可。


如切换大屏View的计算:


fun toEquallyDividedVideos() {
if (isAnimRunning) {
return
}
if (!isVertical/* || !isSmallMode*/) {
return
}
isAnimRunning = true
isSmallMode = false
topicIndex = -1

val videoWidth = measuredWidth.shr(1)
val videoHeight = (videoWidth.toFloat() * 0.6882f).toInt()

val arr = arrayOfNulls<ViewLocation>(childCount)
for (i in 0 until childCount) {
val location = getChildLocationAndResetFromLocations(i)
arr[i] = location

val remainder = (i % 2)
when (i) {
in 0..3 -> location.run {
toLeft = remainder * videoWidth + remainder * videoViewSpacing.shr(1)
toTop = (if (i >= 2) i / 2 else 0) * (videoViewSpacing + videoHeight) + titleHeight
toRight = toLeft + videoWidth - ((i + 1) % 2) * videoViewSpacing.shr(1)
toBottom = toTop + videoHeight
}
5 -> location.run {
toTop = titleHeight + videoHeight.shl(1) + videoViewSpacing
toBottom = toTop + titleHeight
}
6 -> location.run {
toTop = titleHeight.shl(1) + videoHeight.shl(1) + videoViewSpacing
}
}
}

post(AnimRunnable(arr.map { it!! }.toTypedArray(), mDuration = 200L, isRotation = false))
}

最终效果


在这里插入图片描述


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

Flutter 绘制番外篇 - 数学中的角度知识

前言 对一些有趣的绘制技能和知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进” 和 “活力”。另一方面,是为了让一些重要的知识有个 好的归宿。普通文章就像昙花一现,不管多美丽,终会被时间泯灭...
继续阅读 »

前言


对一些有趣的绘制技能知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”“活力”。另一方面,是为了让一些重要的知识有个 好的归宿。普通文章就像昙花一现,不管多美丽,终会被时间泯灭。



另外 [番外篇] 的文章是完全公开免费的,也会同时在普通文章中发表,且 [番外篇] 会在普通文章发布三日后入驻小册,这样便于错误的暴露收集建议反馈。本文作为 [番外篇] 之一,主要来探讨一下角度坐标 的知识。




一、两点间的角度


你有没有想过,两点之间的角度如何计算。比如下面的 p0p1 点间的角度,也就是两点之间的斜率。这上过初中的人都知道,使用 反三角函数 算一下就行了。那其中有哪些坑点要注意呢,下面一方面学知识,一方面练画技,一起画画吧!





1. 把线信息画出来

首先来画出如下效果,点 p0(0,0) ;点 p1(60,60)



为了方便数据管理,将起止点封装在 Line 类中。其中黑色部分的线体Line 类承担,这样在就能减少画板的绘制逻辑。


class Line {
Line({
this.start = Offset.zero,
this.end = Offset.zero,
});

Offset start;
Offset end;

final Paint pointPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1;

void paint(Canvas canvas){
canvas.drawLine(Offset.zero, end, pointPaint);
drawAnchor(canvas,start);
drawAnchor(canvas,end);
}

void drawAnchor(Canvas canvas, Offset offset) {
canvas.drawCircle(offset, 4, pointPaint..style = PaintingStyle.stroke);
canvas.drawCircle(offset, 2, pointPaint..style = PaintingStyle.fill);
}
}



画板是 AnglePainter ,其中虚线通过我的 dash_painter 库进行绘制,定义 line 对象之后,在 paint 方法中通过 line.paint(canvas); 即可绘制黑色的线体部分,蓝色的辅助信息通过 drawHelp 进行绘制。这样通过改变 line 对象的点位就可以改变线体绘制,如下是 p1 点变化对应的绘制表现:



















p1(60,60)p1(60,-80)p1(-60,-80)p1(-60,80)
image-20210902212856156image-20210902212949081

class AnglePainter extends CustomPainter {
// 绘制虚线
final DashPainter dashPainter = const DashPainter(span: 4, step: 4);

final Paint helpPaint = Paint()
..style = PaintingStyle.stroke..color = Colors.lightBlue..strokeWidth = 1;

final TextPainter textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);

Line line = Line(start: Offset.zero, end: const Offset(60, 60));

@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
drawHelp(canvas, size);
line.paint(canvas);
}

void drawHelp(Canvas canvas, Size size) {
Path helpPath = Path()
..moveTo(-size.width / 2, 0)
..relativeLineTo(size.width, 0);
dashPainter.paint(canvas, helpPath, helpPaint);
drawHelpText('0°', canvas, Offset(size.width / 2 - 20, 0));
drawHelpText('p0', canvas, line.start.translate(-20, 0));
drawHelpText('p1', canvas, line.end.translate(-20, 0));
}

void drawHelpText( String text, Canvas canvas, Offset offset, {
Color color = Colors.lightBlue
}) {
textPainter.text = TextSpan(
text: text,
style: TextStyle(fontSize: 12, color: color),
);
textPainter.layout(maxWidth: 200);
textPainter.paint(canvas, offset);
}

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



2.角度计算

Flutter 中的 Offset 对象有 direction 属性,它是通过 atan2 反正切函数进行计算的。下面来看一下通过 direction 属性获取的角度特点。


class Line {
// 略同...

double get rad => (end-start).direction;
}

---->[源码: Offset#direction]----
double get direction => math.atan2(dy, dx);

下面将计算出的弧度,转化为角度值,标注在左上角。源码中对 direction 属性的介绍是:
x 轴右向为正,y 轴向下为正的坐标系下,该偏移角度以是从 x 正轴顺时针方向偏移弧度,范围在 [-pi,pi] 之间。也就是说,x 轴的上部分的角度是负值 ,如下面的 34 图所示。



















p1(60,60)p1(-60,80)p1(-60,-60)p1(60,-80)

drawHelpText(
'角度: ${(line.rad * 180 / pi).toStringAsFixed(2)}°',
canvas,
Offset(
-size.width / 2 + 10,
-size.height / 2 + 10,
),
);
复制代码



这里角度在 [-pi,pi] 之间,那我们能不能让它在 [0,2*pi] 之间呢?这样比较符合 0~360° 的常归认识。其实很简单,如果为负,加个 2*pi 就行了,如下 positiveRad 的处理。


---->[Line]----
double get rad => (end - start).direction;

double get positiveRad => rad < 0 ? 2 * pi + rad : rad;



3.角度的使用

现在来做一个小案例,如下:通过两点间的角度来决定矩形旋转的角度,使用动画将 p1 点绕 p0 做圆周运动。由于两点的角度变化,矩形也会伴随旋转。



为了让 Line 的变化方便通知画板进行更新,这里让它继承自 ChangeNotifier ,成为可监听对象。并给出一个 rotate 方法,传入角度来更新坐标。这里为了方便,先以 0,0 为起点,只变更 end 坐标,已知 p1 做圆周运动,所以两点间距离不变,又知道了旋转角度,那 p1 在旋转 rad 时,p1 的坐标就很容易得出:



class Line with ChangeNotifier {
// 略同...

double get length => (end - start).distance;

void rotate(double rad) {
end = Offset(length * cos(rad), length * sin(rad));
notifyListeners();
}
}



上面实现了椭圆的角度伴随运动,那想一下,如何动态绘制如下的线与水平正方向的圆弧呢?



其实很简单,我们已经知道了角度值,通过 canvas.drawArc 就可以根据先的角度绘制圆弧。


---->[AnglePainter#drawHelp]----
canvas.drawArc(
Rect.fromCenter(center: Offset.zero, width: 20, height: 20),
0,
line.positiveRad,
false,
helpPaint,
);



4. 点任意的绕点旋转

其实刚才的圆周运动是一个及其特殊的情况,也就是线的起点在原点,且初始夹角为 0。这样在坐标计算时,不必考虑初始角度的影响。但对于一般场合,上面的运算方式会出现错误。那如何实现 p0 点的任意呢?其实这就是移到简单的初中数学题:



已知: p0(a,b)、p1(c,d),求 p1 绕 p0 顺时针旋转 θ 弧度后得到 p1' 点。
求: p1' 点的坐标。

 其实算起来很简单,如下,旋转了 θ 弧度后得到 p1' 。以 p0 为参考系原点的话,p1' 的坐标呼之欲出。


令两点间角度为 rad, 两点间距离为 length, 则: 
p1': (length*cos(rad+θ),length*sin(rad+θ))

已知 p0 坐标为 start,则以 (0,0) 为坐标系,则
p1': (length*cos(rad+θ),length*sin(rad+θ)) + start


由于 rotate 参数是总的旋转角度,而rotate 方法每次触发都会更新 end 的坐标,所以 rad 会不断更新,我们需要处理的是每次动画触发间的旋转角度,即下面的 detaRotate 。本案例完整源码见: rad_rotate


double detaRotate = 0;
void rotate(double rotate) {
detaRotate = rotate - detaRotate;
end = Offset(
length * cos(rad + detaRotate),
length * sin(rad + detaRotate),
) +
start;
detaRotate = rotate;
notifyListeners();
}



二、你的点又何须是点


也许上面在你眼中,这些只是点的运算而已,但在我眼中,它们是一种约束绑定关系,因为运算本身就是约束法则。两个点数据构成一种结构,一种骨架,那你所见的点,又何须是点呢?




1. 绘制箭头

如下,是绘制箭头的案例:界面上所展现的,是Line#paint 方法绘制的内容,只要通过两个点所提供的信息,绘制出箭头即可。绘制逻辑是:先画一个水平箭头,再根据旋转角度,绕 p0 旋转。



void paint(Canvas canvas) {
canvas.save();
canvas.translate(start.dx, start.dy);
canvas.rotate(positiveRad);
Path arrowPath = Path();
arrowPath
..relativeLineTo(length - 10, 3)
..relativeLineTo(0, 2)
..lineTo(length, 0)
..relativeLineTo(-10, -5)
..relativeLineTo(0, 2)..close();
canvas.drawPath(arrowPath,pointPaint);
canvas.restore();
}

这样,点位数据的变化,同样可以驱动绘制的变化。本案例完整源码见: arrow





2. 绘制图片

如下是一张图片,现在通过 PS 获取胳膊的区域数据:0, 93, 104, 212 。左上角和左下角两点构成直线,如果我们根据点的位置信息,来绘制图片会怎么样呢?



为了储存图片和区域信息,下面定义 ImageZone 对象,在构造中传入图片 image 和区域 rect 。另外通过 imagerect ,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象


import 'dart:ui';
import 'line.dart';

class ImageZone {
final Image image;
final Rect rect;

Line? _line;

ImageZone({required this.image, this.rect = Rect.zero});

Line get line {
if (_line != null) {
return _line!;
}
Offset start = Offset(
-(image.width / 2 - rect.right), -(image.height / 2 - rect.bottom));
Offset end = start.translate(-rect.width, -rect.height);
_line = Line(start: start, end: end);
return _line!;
}
}



ImageZone 中定义一个 paint 方法,通过 canvasline 进行图片的绘制。这样方便在 Line 类中进行图片绘制,简化 Line 的绘制逻辑。


---->[ImageZone]----
void paint(Canvas canvas, Line line) {
canvas.save();
canvas.translate(line.start.dx, line.start.dy);
canvas.rotate(line.positiveRad - this.line.positiveRad);
canvas.translate(-line.start.dx, -line.start.dy);
canvas.drawImageRect(
image,
rect,
rect.translate(-image.width / 2, -image.height / 2),
imagePaint,
);
canvas.restore();
}



Line 类中,添加一个 attachImage 方法,将 ImageZone 对象关联到 Line对象上。在 paint中只需要通过 _zone 对象进行绘制即可。


---->[Line]----
class Line with ChangeNotifier {
// 略同...

ImageZone? _zone;

void attachImage(ImageZone zone) {
_zone = zone;
start = zone.line.start;
end = zone.line.end;
notifyListeners();
}

void paint(Canvas canvas) {
// 绘制箭头略....
_zone?.paint(canvas, this);
}

这样我们就可以将图片的某个矩形区域 附魔 到一个线段上。手的图片通过 _loadImage 来加载,并通过 attachImage 方法为 line 对象 附魔



void _loadImage() async {
ByteData data = await rootBundle.load('assets/images/hand.png');
List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
_image = await decodeImageFromList(Uint8List.fromList(bytes));
line.attachImage(ImageZone(
rect: const Rect.fromLTRB(0, 93, 104, 212),
image: _image!,
));
}

同样,可以让线段绕起点进行旋转,如下的挥手动作。



void _updateLine() {
line.rotate(ctrl.value * 2* pi/50);
}

将背景图片进行绘制,就可以得到一个完整的效果。本案例完整源码见: body





三、线绕任意点旋转


下面我们来如何让已知线段按照某个点,进行旋转,这个问题等价于:


已知,p0、p1、p2点坐标,线段 p0、p1 绕 p2 顺时针旋转 θ 弧度后的到 p0'、p1'。
求:p0'、p1' 坐标。




1.问题分析

由于两点确定一条直线,线段 p0、p1p2旋转,等价于 p0p1 分别绕 p2 旋转。示意图如下:



对应于代码,就是在 rotate 方法中,传入一个坐标 centre ,根据该坐标和旋转角度,对 p0p1 点进行处理,得到新的点。


void rotate(double rotate,{Offset? centre}) {
//TODO
}



2.解决方案和代码处理

之前已经处理了绕起点旋转的逻辑,这里我们可以用一个非常巧妙的方案:


求 p0’ 的坐标,可以构建 p2,p0 线段,让该线段执行旋转逻辑,其 end 坐标即是 p0’。
求 p1’ 的坐标,可以构建 p2,p1 线段,让该线段执行旋转逻辑,其 end 坐标即是 p1’。

思路有了,下面来看一下代码的实现。前面实现的 绕起点旋转 封装到 _rotateByStart 方法中。


---->[Line]----
void _rotateByStart(double rotate) {
end = Offset(
length * cos(rad + rotate),
length * sin(rad + rotate),
) +
start;
}



外界可调用的的 rotate 方法,可以传入 centre 点,如果为空就以起点为旋转中心。下面 tag1tag2 出分别构建 p2p0p2p1 线段。之后两条线旋转即可获得我们期望的 p0’ p1’ 坐标。



double detaRotate = 0;

void rotate(double rotate, {Offset? centre}) {
detaRotate = rotate - detaRotate;
centre = centre ?? start;
Line p2p0 = Line(start: centre, end: start); // tag1
Line p2p1 = Line(start: centre, end: end); // tag2
p2p0._rotateByStart(detaRotate);
p2p1._rotateByStart(detaRotate);
start = p2p0.end;
end = p2p1.end;
detaRotate = rotate;
notifyListeners();
}



3.线段分度值出坐标

现在有个需求,计算线段 percent 分率处点的坐标。比如 0.5 就线段中间的坐标,0.4 就是距离顶点长 40% 线长位置的坐标。效果如下:

















0.20.50.8
image-20210907085552225

其实思路很简单,既然点在线上,那么斜率是不变的,只是长度发生变化,根据斜率长度即可求出坐标值,代码实现如下:


Offset percent(double percent){
return Offset(
length*percent*cos(rad),
length*percent*sin(rad),
)+start;
}



前面说过了线的,绕点旋转。现在已知分度值处的坐标,就可以很轻松地实现 线绕分度锚点旋转。本案例完整源码见: rotate_by_point





本文中的点线操作,都是对坐标本身的数据进行修改系。比如在旋转时,线对应的角度值是真实的。这种基于逻辑运算的数据驱动方式,可以进行一些很有意思的操作,更容易让数据间进行 联动 。另外,本文仅仅是两个点组成线 的简单研究。多个线的组合、约束也许会打开一个新世界的大门。相关以后有机会再深入研究一下,分享给大家。


那这里本文想介绍的内容就差不多了,谢谢观看,拜拜~




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

Jetpack Compose入门篇-简约而不简单

Compose简介 Jetpack Compose:利用声明式编程构建Android原生界面(UI)的 工具包 优势 更少的代码、代码量锐减 强大的工具/组件支持 直观的 Kotlin API 简单易用 Compose 编程思想 声明性编程范式:声...
继续阅读 »

Compose简介



  • Jetpack Compose:利用声明式编程构建Android原生界面(UI)的 工具包


优势



  • 更少的代码、代码量锐减

  • 强大的工具/组件支持

  • 直观的 Kotlin API

  • 简单易用


Compose 编程思想




  • 声明性编程范式:声明性的函数构建一个简单的界面组件,无需修改任何 XML 布局,也不需要使用布局编辑器,只需要调用 Jetpack Compose 函数来声明想要的元素,Compose 编译器即会完成后面的所有工作




  • 举个栗子:简单的可组合函数


    class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    Text("Hello world!")
    }
    }
    }



image-20210831180043978


  • 动态 :组合函数是用 Kotlin 而不是 XML 编写,见上$name 的传入




  • 需要注意的事项:




    • 可组合函数可以按任何顺序执行


      //可以按任何顺序进行,不能让 StartScreen() 设置某个全局变量(附带效应)并让 MiddleScreen() 利用这项更改。相反,其中每个函数都需要保持独立。
      @Composable
      fun ButtonRow() {
      MyFancyNavigation {
      StartScreen()
      MiddleScreen()
      EndScreen()
      }
      }



    • 可组合函数可以并行执行




      • Compose 可以通过并行运行可组合函数来优化重组,这样一来,Compose 就可以利用多个核心,并以较低的优先级运行可组合函数(不在屏幕上)




      • 这种优化意味着,可组合函数可能会在后台线程池中执行,如果某个可组合函数对 ViewModel 调用一个函数,则 Compose 可能会同时从多个线程调用该函数




      • 调用某个可组合函数时,调用可能发生在与调用方不同的线程上,这意味着,应避免使用修改可组合 lambda 中的变量的代码,既因为此类代码并非线程安全代码,又因为它是可组合 lambda 不允许的附带效应




      //此代码没有附带效应
      @Composable
      fun ListComposable(myList: List<String>) {
      Row(horizontalArrangement = Arrangement.SpaceBetween) {
      Column {
      for (item in myList) {
      Text("Item: $item")
      }
      }
      Text("Count: ${myList.size}")
      }
      }
      复制代码

      //如果函数写入局部变量,则这并非线程安全或正确的代码:
      @Composable
      @Deprecated("Example with bug 有问题的代码")
      fun ListWithBug(myList: List<String>) {
      var items = 0

      Row(horizontalArrangement = Arrangement.SpaceBetween) {
      Column {
      for (item in myList) {
      Text("Item: $item")
      items++ // Avoid! Side-effect of the column recomposing.
      }
      }
      Text("Count: $items")
      }
      }
      //每次重组时,都会修改 items。这可以是动画的每一帧,或是在列表更新时。但不管怎样,界面都会显示错误的项数。因此,Compose 不支持这样的写入操作;通过禁止此类写入操作,我们允许框架更改线程以执行可组合 lambda。



    • 重组会跳过尽可能多的 可组合函数和 lambda




    • 重组是乐观的操作,可能会被取消




    • 可组合函数可能会像动画的每一帧一样非常频繁地运行






环境准备




  • 已了解的同学,可直接跳过




  • 需要升级到Arctic Fox 2020-3-1 版本以上,此版本以下Android studio 无此支持-【下载最新Android studio】


    ComposeActivititysupport


  • 我们注意到此项目只支持Kotlin 最低sdk 版本为21,Android 5.0


    support01


  • Gradle Compose相关依赖




implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.0-alpha06'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"

 kotlinOptions {
jvmTarget = '1.8'
useIR = true//在 Gradle 构建脚本中指定额外编译器选项即可启用新的 JVM IR 后端
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.5.10'
}
buildFeatures {
compose true
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}


  • 由于新版本邀请java 11,安装 java 8 环境的需要以下修复


Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
You can try some of the following options:
- changing the IDE settings.
- changing the JAVA_HOME environment variable.
- changing `org.gradle.java.home` in `gradle.properties`.

org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-11.0.11.jdk/Contents/Home



  • @Preview起作用,环境正常


布局




  • Android 传统从xml-状态的变更关系,程序员需要大量的代码维护Ui 界面,以达到界面状态的正确性,费时费力,即便是借助MVVM架构,一样需要维护状态,因为布局只有一套





  • 声明式与传统XML 实现区别,Compose 声明式布局,是直接重建了UI,所以不会有状态问题






  • Text:Compose 提供了基础的 BasicTextBasicTextField,它们是用于显示文字以及处理用户输入的主要函数。Compose 还提供了更高级的 TextTextField


    Text("Hello World")



  • 重组Text->Button


    @Composable
    fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
    Text("I've been clicked $clicks times")
    }
    }



  • Modifier可以修改控件的位置、高度、边距、对齐方式等等


    //`padding` 设置各个UI的padding。padding的重载的方法一共有四个。
    Modifier.padding(10.dp) // 给上下左右设置成同一个值
    Modifier.padding(10.dp, 11.dp, 12.dp, 13.dp) // 分别为上下左右设值
    Modifier.padding(10.dp, 11.dp) // 分别为上下和左右设值
    Modifier.padding(InnerPadding(10.dp, 11.dp, 12.dp, 13.dp))// 分别为上下左右设值
    //这里设置的值必须为`Dp`,`Compose`为我们在Int中扩展了一个方法`dp`,帮我们转换成`Dp`。
    //`plus` 可以把其他的Modifier加入到当前的Modifier中。
    Modifier.plus(otherModifier) // 把otherModifier的信息加入到现有的modifier中
    //`fillMaxHeight`,`fillMaxWidth`,`fillMaxSize` 类似于`match_parent`,填充整个父layout。
    Modifier.fillMaxHeight() // 填充整个高度
    //`width`,`heigh`,`size` 设置Content的宽度和高度。
    Modifier.width(2.dp) // 设置宽度
    Modifier.height(3.dp) // 设置高度
    Modifier.size(4.dp, 5.dp) // 设置高度和宽度
    //`widthIn`, `heightIn`, `sizeIn` 设置Content的宽度和高度的最大值和最小值。
    Modifier.widthIn(2.dp) // 设置最大宽度
    Modifier.heightIn(3.dp) // 设置最大高度
    Modifier.sizeIn(4.dp, 5.dp, 6.dp, 7.dp) // 设置最大最小的宽度和高度
    //`gravity` 在`Column`中元素的位置。
    Modifier.gravity(Alignment.CenterHorizontally) // 横向居中
    Modifier.gravity(Alignment.Start) // 横向居左
    Modifier.gravity(Alignment.End) // 横向居右
    //`rtl`, `ltr` 开始布局UI的方向。
    Modifier.rtl // 从右到左
    //更多Modifier学习:https://developer.android.com/jetpack/compose/modifiers-list



  • Column 线性布局≈ Android LinearLayout-VERTICAL




  • Row 水平布局≈Android LinearLayout-HORIZONTAL




  • Box帧布局≈Android FrameLayout,可将一个元素放在另一个元素上,如需在 Row 中设置子项的位置,请设置 horizontalArrangementverticalAlignment 参数。对于 Column,请设置 verticalArrangementhorizontalAlignment 参数




  • 相对布局,需要引入 ConstraintLayout



    • 引入


    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta02"


    • constraintlayout-compose用法流程


    graphics-drawcircle

    • 完整用法示例


    @Composable
    fun testConstraintLayout() {
    ConstraintLayout() {
    //通过createRefs创建三个引用
    val (imageRef, nameRef) = createRefs()
    Image(painter = painterResource(id = R.mipmap.test),
    contentDescription = "图",
    modifier = Modifier
    .constrainAs(imageRef) {//通过constrainAs将Image与imageRef绑定,并增加约束
    top.linkTo(parent.top)
    start.linkTo(parent.start)
    bottom.linkTo(parent.bottom)
    }
    .size(100.dp)
    .clip(shape = RoundedCornerShape(5)),
    contentScale = ContentScale.Crop)
    Text(
    text = "名称",
    modifier = Modifier
    .constrainAs(nameRef) {
    top.linkTo(imageRef.top, 2.dp)
    start.linkTo(imageRef.end, 12.dp)
    end.linkTo(parent.end)
    width = Dimension.fillToConstraints
    }
    .fillMaxWidth(),
    fontSize = 18.sp,
    maxLines = 1,
    textAlign = TextAlign.Left,
    overflow = TextOverflow.Ellipsis,
    )
    }
    }



列表



  • 可以滚动的布局


//我们可以使用 verticalScroll() 修饰符使 Column 可滚动
Column (
modifier = Modifier.verticalScroll(rememberScrollState())){
messages.forEach { message ->
MessageRow(message)
}
}



  • 但以上布局并无法实现重用,可能导致性能问题,下面介绍我们重点布局,列表




  • LazyColumn/LazyRow==RecylerView/listView 列表布局,解决了滚动时的性能问题,LazyColumnLazyRow 之间的区别就在于它们的列表项布局和滚动方向不同




    • 内边距


      LazyColumn(
      contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
      ) {
      // ...
      }




    • item间距


      LazyColumn(
      verticalArrangement = Arrangement.spacedBy(4.dp),
      ) {
      // ...
      }



    • 浮动列表的浮动标题,使用 LazyColumn 实现粘性标题,可以使用实验性 stickyHeader()函数


      @OptIn(ExperimentalFoundationApi::class)
      @Composable
      fun ListWithHeader(items: List<Item>) {
      LazyColumn {
      stickyHeader {
      Header()
      }

      items(items) { item ->
      ItemRow(item)
      }
      }
      }





  • 网格布局LazyVerticalGrid


    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
    cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
    items(photos) { photo ->
    PhotoItem(photo)
    }
    }
    }



自定义布局




  • 通过重组基础布局实现




  • Canvas


    Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height
    drawCircle(
    color = Color.Blue,
    center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
    radius = size.minDimension / 4
    )
    }
    //drawCircle 画圆
    //drawRectangle 画矩形
    //drawLine //画线



graphics-drawcircle

动画



  • 动画Api 选择


animation-flowchart

其他库支持




  • 导航栏


    implementation("androidx.navigation:navigation-compose:2.4.0-alpha05")



总结



  • Compose总体来说,对于Android-Native布局实现上更加简单高效,值得大家一学

  • Compose 写法与Flutter-Dart 有高度类似的情况,后面我们可以做一篇与Flutter-Dart 语音写布局的一些对比

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

Fragment和Activity最佳通信方式 --- 共享ViewModel

背景在日常开发中,我们经常会遇到Activity和Fragment之间通信的问题,其中之前最简单的办法是通过接口回调,因为fragment在attach时会拿到activity实例,在activity内部能拿到fragment实例,只需要定义接口让activi...
继续阅读 »

背景

在日常开发中,我们经常会遇到Activity和Fragment之间通信的问题,其中之前最简单的办法是通过接口回调,因为fragment在attach时会拿到activity实例,在activity内部能拿到fragment实例,只需要定义接口让activity实现接口即可,但是这样一来不免接口定义的很多,如果逻辑比较复杂,不利于后期维护。

关于其他方案,比如eventBus都可以实现,但是如果你使用MVVM和Jetpack开发的话,这个问题就特别容易解决了,那就是使用共享ViewModel。

使用

话不多说,直接开整。

原理

ViewModel大家应该都很熟悉了,在MVVM架构中充当着保存数据以及逻辑操作的角色,最重要的是它在配置发生改变时依旧会保存数据而且具有恰当的生命周期,一般就是Activity或者Fragment都对应着一个ViewModel。

所以原理也非常简单,既然Activity生命周期比Fragment长,所以Activity的ViewModel的生命周期也比Fragment的ViewModel长,这里直接在Fragment中使用Activity的ViewModel实例即可,当然这里肯定是一个实例,关于为什么ViewModel能保证生命周期安全以及单例,可以查看文章:

juejin.cn/post/699694…

有具体解答,就不再赘述。

这里的ViewModel也就相当于是一个activity范围的容器,当然可以实现在activity和fragment以及内部fragment之间的通信了。

具体实现

首先是Activity的代码,这里有3个子Fragment,然后定义了2个ViewModel,其中一个是它自己的,一个是共享的:

@AndroidEntryPoint
class ShellMainActivity : BaseVMActivity<ShellMainViewModel>() {
//这个不管,这个是Activity自己的ViewModel,不需要共享的逻辑
private val shellMainViewModel: ShellMainViewModel by viewModels()
//这个是共享ViewModel
private val sharedViewModel: ShellMainSharedViewModel by viewModels()
//依次增加3个Fragment
private val mShellMainFragment by lazy { ShellMainFragment() }
private val mShellMainPluginFragment by lazy { ShellMainPluginFragment() }
private val mShellMainSettingFragment by lazy { ShellMainSettingFragment() }
override fun getLayoutResId(): Int = R.layout.activity_main

override fun initVM(): ShellMainViewModel {
return shellMainViewModel
}

override fun initView() {
//初始化fragment
val shellMainViewPager2Adapter = ShellMainAdapter(this
, arrayListOf(mShellMainFragment
,mShellMainPluginFragment
,mShellMainSettingFragment))
shellMainViewPager2.adapter = shellMainViewPager2Adapter
ViewPager2Delegate.install(shellMainViewPager2,shellMainTabLayout)
}

override fun initData() {
//对共享fragment里的值进行观察,同时弹出toast
sharedViewModel.testLiveData.observe(this,{
Toast.makeText(this, "$it", Toast.LENGTH_SHORT).show()
})
}

override fun startObserve() {

}
//分别添加3个子fragment
inner class ShellMainAdapter(activity: FragmentActivity, private val fragmentList: List<Fragment>)
: FragmentStateAdapter(activity){

override fun getItemCount(): Int {
return fragmentList.size
}

override fun createFragment(position: Int): Fragment {
return fragmentList[position]
}

}

}

看一下共享ViewModel代码:

@HiltViewModel
class ShellMainSharedViewModel @Inject constructor() : BaseViewModel() {

val testLiveData = MutableLiveData<String>("00")

fun setValue(view: View){
val random = (1 .. 100).random().toString()
Log.i(TAG, "setValue: 随机数 $random")
testLiveData.value = random
}
}

这里非常简单,就是一个LiveData,然后接着看一下Fragment,

这里的注意点是Fragment自己的ViewModel使用viewModels来获取,对于要和整个activity生命周期共享的ViewModel使用activityViewModels来获取:

@AndroidEntryPoint
class ShellMainFragment : BaseVMFragment<ShellMainFragmentViewModel>() {

private val shellMainFragmentViewModel: ShellMainFragmentViewModel by viewModels()
//获取共享ViewModel
private val shellMainSharedViewModel: ShellMainSharedViewModel by activityViewModels()

override fun getLayoutResId(): Int = R.layout.fragment_shell_main

override fun initVM(): ShellMainFragmentViewModel {
return shellMainFragmentViewModel
}
//依次绑定2个viewModel
override fun initView() {
mBinding.setVariable(BR.sharedViewModel,shellMainSharedViewModel)
mBinding.setVariable(BR.viewModel,shellMainFragmentViewModel)
}

override fun initData() {
}

override fun startObserve() {
}

}

然后在xml中,有一个textView控件可以显示viewModel中的值,以及修改值:

<?xml version="1.0" encoding="utf-8"?>

<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>

<data>

<variable
name="viewModel"
type="com.wayeal.yunapp.shell.mvvm.main.ShellMainFragmentViewModel" />

<variable
name="sharedViewModel"
type="com.wayeal.yunapp.shell.mvvm.main.ShellMainSharedViewModel" />

</data>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".shell.mvvm.main.ShellMainFragment"
android:orientation="vertical"
>


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="首页" />

<TextView
android:id="@+id/test"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{sharedViewModel.testLiveData}"
android:textSize="20sp"
android:textColor="@color/black"
/>

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="随机数"
android:onClick="@{sharedViewModel::setValue}"
/>

</LinearLayout>

</layout>

然后其他2个fragment可以一样使用这个共享ViewModel,这样不论是activity还是fragment之间都是用的同一个ViewModel实例,而且还是MVVM的数据驱动模式,可以在达到通信的同时不会造成内存泄漏。

最后我们能看到效果就是下图所示,2个fragment和activity可以无障碍通信:

11.gif

总结

其实除了Activity和Fragment之间的通信,我还想过是否可以搞个ViewModel在Activity之间通信呢,但是这种思想很快就被否决了,因为官方就不建议这样干,管理起来很麻烦,所以要想实现更大范围内的数据共享,建议在Repository层进行控制,通过Hilt规定Scope都可以实现,就不要搞一些奇奇怪怪的ViewModel了。

收起阅读 »

(译)Kotlin中的Lateinits vs Nullables

Kotlin给了我们很多简单明了的方式处理可空的变量,从而减少出问题的风险。当然前提是你正确地使用它。Lateinit修饰符通常来说,kotlin中所有不可空的属性都必须被正确地初始化。你可以用很多方式实现:在主构造器中,在初始化代码块中,直接在类里的属性声明...
继续阅读 »
Kotlin给了我们很多简单明了的方式处理可空的变量,从而减少出问题的风险。当然前提是你正确地使用它。

Lateinit修饰符

通常来说,kotlin中所有不可空的属性都必须被正确地初始化。你可以用很多方式实现:

  • 在主构造器中,
  • 在初始化代码块中,
  • 直接在类里的属性声明中,
  • 在getter方法中*,
  • 用delegate实现*.

*的方法严格意义上来说并不是初始化,但是它使得编译器理解这些变量是非null

但如果一个属性是生命周期驱动的(例如:一个button的引用,它会在Activity的生命周期中被inflated出来)或者它需要通过注入来初始化,那就没办法提供一个non-null的初始化,就只能把它声明成可空的。这就需要你每次使用它都进行null checks,很麻烦,尤其是在你百分百确定它在被使用之前,一定会被初始化的时候。

所以kotlin给这种情况提供了一种简单的解决方案,lateinit修饰符。这样就不需要每次都做null checks了,当然如果用到的时候该属性没有被初始化,系统就会抛出UninitializedPropertyAccessException

Lateinits vs nullables

尽管Lateinits本身是kotlin提供的非常有用的feature,但它更有可能会在很多不那么确定会被初始化(例如:有条件的初始化或者仅仅是初始化比较晚)的情况下滥用,从而造成空安全的风险,让代码变得更像java。下面是我的一些主观的经验。

Lateinit 的第一种使用情况:生命周期的开始就初始化

当一个属性可以在使用它的某个类的生命周期开始的时候,比如Activity.onCreate(),就初始化的情况下,推荐使用Lateinits。 比较常见的情况是项目中使用了依赖注入的框架,比如Dagger.

abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
super.onCreate(savedInstanceState)
}
}

class LoginActivity : BaseActivity() {

@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory

lateinit var viewModel: LoginViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

viewModel = ViewModelProviders.of(this, viewModelFactory).get(LoginViewModel::class.java)

setContentView(R.layout.login_activity)
}
}

或者当你希望getSystemService()的时候:

class MainActivity : AppCompatActivity() {

lateinit var alarmManager: AlarmManager

// This wouldn't work:
// val alarmService = getSystemService(AlarmManager::class.java)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
alarmManager = getSystemService(AlarmManager::class.java)
}

不然的话,就会报IllegalStateException的错误,说system services are not available to Activities before onCreate()

同时这样也可以避免在Object构建的时候使用this,导致线程安全的问题

class MyActivity : AppCompatActivity() {

lateinit var alien: Alien
// instead of:
// val alien = Alien(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
alien = Alien(this)
}
}

Lateinit 的第二种使用情况: fail-fast approach

在开发的过程中,我们会希望尽可能地暴露程序中的问题,并且当出现问题的时候直接crash而不是被catch然后带着缺陷继续运行。这样我们就能尽可能地修复在开发过程当中暴露的问题。如果代码逻辑比较简明的话,这是一种很有用的方法。

比如,一个Activity需要使用MediaPlayer来播放音乐,代码如下:

class PlayerActivity : AppCompatActivity() {

lateinit var mediaPlayer: MediaPlayer
lateinit var mediaUri: Uri

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
mediaUri = intent.getParcelableExtra("mediaUri")
}

override fun onStart() {
super.onStart()
mediaPlayer = MediaPlayer()
mediaPlayer.setDataSource(this, mediaUri)
mediaPlayer.prepare()
mediaPlayer.start()
}

override fun onStop() {
super.onStop()
mediaPlayer.stop()
mediaPlayer.release()
}
}

上一段代码有一个很明显的风险点在mediaUri 是在onCreate中通过传进来的intent解析出来的,如果intent中没传mediaUri,那就抛出java.lang.IllegalStateException: intent.getParcelableExtra("mediaUri") must not be null

这段代码简单清晰,所以不难发现问题。但如果到了一个大的工程里就麻烦了。

image.png

Nullable 的第一种使用情况:完全不希望出crash的情况下

有些情况下(译者:最好是所有情况下!)你希望全力避免app出crash,即使程序运行出了问题。 比如上面的音乐播放的例子,如果我们改成nullable的形式:

class PlayerActivity : AppCompatActivity() {

var mediaPlayer: MediaPlayer? = null
var mediaUri: Uri? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
mediaUri = intent.getParcelableExtra("mediaUri")
}

override fun onStart() {
super.onStart()

mediaUri?.let {
val player = MediaPlayer()
player.setDataSource(this, it)
player.prepare()
player.start()
mediaPlayer = player
}
}

override fun onStop() {
super.onStop()
mediaPlayer?.stop()
mediaPlayer?.release()
}
}

现在,如果没有在intent中拿到mediaUri,那app只是不会播放音乐了而不是直接crash,不过用户会很惊讶为啥没声儿(他可能会认为他的手机坏了)。这是否比crash更好(译者:当然!)取决于开发者的判断。但是这种方式的好处是,现在我们有机会在属性为null的时候做些什么处理这种未初始化的情况。

注:从Kotlin1.2开始你可以对lateinit的属性用.isInitialized ,当然这也有限制( 文档 ):

This check is only available for the properties that are lexically accessible, i.e. declared in the same type or in one of the outer types, or at top level in the same file.

Nullable 的第二种使用情况:初始化的时机很晚

下面的代码,Activity中用lateinit的变量保存了一个ItemData,然后再点击事件中调用startActivityForResult(),最后在onActivityResult()里使用了这个lateinit的变量。

class SomeActivity : AppCompatActivity() {

lateinit var selectedItemData: ItemData

// 很多代码 ...

private fun doSomethingWithItem(data: ItemData) {
// 很多代码 ...

if (...) { // 复杂冗长的判断
if (...) {
data?.anotherData?.let { // 也许不会继续进行而且不会通知上层
selectedItemData = data
startActivityForResult(...)
}
}
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
doSomethingElse(selectedItemData)
}
}

通常来说,我会避免这种“初始化太晚”的代码结构。这种情况下推荐使用nullable,因为大部分时候变量都是null

怎么更好地使用“?”

如果需要保证空安全就使用 把属性定为可空。lateinit如果用的好的话也是有它的优势在的。 但千万别把它变成了NullPointerException的标志。

译者注:

个人的建议是,除了dagger注入的情况之外一概不用lateinit。因为虽然lateinit的使用会带来代码上面的一些便利,但是比起所引入的crash风险来说,这真的值得么?这就好像用!!的方式来保证属性的非空一样,虽然kotlin确实提供了这样的方式,但是无论如何还是太危险了。

收起阅读 »

Jetpack Compose | Compose 滑动列表真的需要使用LazyColumn吗?No No No!

Jetpack Compose | 控件篇(五)-- Spacer、LazyRow、LazyColumn & 让Column可滑动在上一篇中,我们完成了 Box、Row、Column 相关内容的学习,并且留下了一个疑问:"如果容器大...
继续阅读 »

Jetpack Compose | 控件篇(五)-- Spacer、LazyRow、LazyColumn & 让Column可滑动

在上一篇中,我们完成了 Box、Row、Column 相关内容的学习,并且留下了一个疑问:"如果容器大小不足以承载内容,怎么处理呢?",这一篇我们一起学习这部分内容。

文中代码均基于 1.0.1版本

如无特殊说明,文中的 Compose 均指代 Jetpack compose

文中代码均可在 WorkShop 中获取,本篇代码集中于 post29 & post30 包下

完整系列目录: Github Pages | 掘金 | csdn

和Android进行简单对比

在Android中,SDK提供了诸如: ScrollView NestedScrollView ListView GridView RecyclerView,等针对各类场景下适用的控件, 基于滑动手势调整内容展示区域,以达到显示更多内容的目的。

进一步探索可以发现:View本身就包含了Scroll机制的 半成品实现,当然,本文我们不去深究Android的内容,借助我们已经掌握的Android知识,引出一点:

基于Scroll机制,用小容器展现大内容的本质:在视图测量的基础上,结合滑动手势处理,调整内容布局,绘制后展现。

在早期的一些文章中,有博主提到:Compose中对应的内容为 ScrollRow,ScrollColumn / LazyRow、LazyColumn

在早期的预览版中,短暂的存在过 ScrollRow,ScrollColumn等内容,似乎已经被移除

Compose中也是按照这样的思路设计的,我们将在后续的文章中再细致地展开研究,本篇中仅学习如何使用它们。

在真正开始这部分内容之前,先补充一个简单的控件 Spacer,可以简单的创建占位间距,后续的文章中已经没有他的位置了

Spacer

在之前的文章中,我们学习过Modifier,其中包含一些和布局相关的API,例如:paddingoffset,但并无 margin 等内容,按照业内惯例, 如果已经存在一个广为接受的名词,一般不会使用新词,至少词根是一致的 ,在Compose中,使用了Spacer,取缔了Margin的一些使用场景。

注:计算总是有损耗的,不要滥用Spacer,并且很多场景下有特定的方式处理间距,后续会逐渐学习到

如何使用

@Composable
fun Spacer(modifier: Modifier)

一般只需要指定他的宽高尺寸即可,例如:

Spacer(modifier = Modifier.size(3.dp))

LazyColumn

在上一篇文章中,我们已经学习过 Row和Column,它们仅仅是在方向上不一致,在实现上非常类似。同样的,LazyRow和LazyColumn也是如此。

Doc中提到:

The vertically scrolling list that only composes and lays out the currently visible items. The content block defines a DSL which allows you to emit items of different types. For example you can use LazyListScope.item to add a single item and LazyListScope.items to add a list of items.

仅 组合计算 以及 布局 当前可见元素的纵向可滑动列表。内容块定义了一个DSL,允许创建不同类型的元素。

例如:使用 LazyListScope.item 添加单个元素, LazyListScope.items 添加元素列表。

注:"内容块定义了一个DSL,允许创建不同类型的元素",这并不同于Android中概念: RecyclerView#Adapter 具有将数据映射为同类型 ViewHolder 或 不同类型 ViewHolder 的能力。而是指 "添加元素时可以是 单个元素,或者是 元素的列表,这是不同的类型。 字面翻译在中文语境下容易造成误解。

很显然,它类似于Android中的 ListView,RecyclerView着重点在于 Lazy不会将元素一股脑的全计算、布局出来

所以,它并不对标ScrollView,在它的使用场景下,可滑动需求非常普遍,便默认实现了!

我们前面提到:

在早期的一些文章中,有博主提到:Compose中对应的内容为 ScrollRow,ScrollColumn / LazyRow、LazyColumn

这本没有啥错误,但绝不是被曲解的: "Row和Column 无法提供滑动能力,而是需要使用 LazyRow、LazyColumn"

但气氛已经烘托到这里了,那我们先将其学完,再学习 Row和Column 如何提供滑动能力。

如何使用

@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState()
,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
content: LazyListScope.() -> Unit
)

各个参数含义:

  • modifier:修饰器
  • state:用于控制或者观测列表状态
  • contentPadding:整体内容周围的一个Padding,注:内容四周的留白,以纵向列表为例,尾部没有展示时看不到尾部的留白 这通过Modifier无法实现, 注:Modifier只能实现列表容器固定的留白间距 。可以使用它在第一个元素前和最后一个元素后留白。 如果需要在元素间留出间距,可以使用 verticalArrangement
  • reverseLayout:是否反转列表
  • verticalArrangement:子控件纵向的范围。可用于添加子控件之间的间距,以及内容不足以填满列表最小尺寸时,如何排布
  • horizontalAlignment:子控件横向对齐方式
  • flingBehavior:fling行为的处理逻辑
  • content:声明了如何提供子控件的DSL,有两种方式
@LazyScopeMarker
interface LazyListScope {

fun item(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)

fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
)


@ExperimentalFoundationApi
fun stickyHeader(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)
}

顺带一提:笔者参与的上一个项目中,高频使用RecycleView用作内容呈现,为了便捷的处理 "item之间的间距"、"首尾留白"、"特定item间不应用间距", 在项目中写了一套部件,后续可以拆出来同大家分享下。

基于LazyListScope.item 方法

在上一篇文章对应的WorkShop内容中,已经出现了这一用法 post29包下

例如:

private fun LazyListScope.rowDemo() {
item {
CodeSample(code = "row sample 1:")
Row {
// ignore
}
}

item {
CodeSample(code = "row sample 2:纵向居中对齐")
// ignore
}

// ignore
}

基于LazyListScope.items 方法

除了直接使用API,SDK中同样提供了部分内联函数,消除处理数据结构的代码冗余:

inline fun <T> LazyListScope.items(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)


inline fun <T> LazyListScope.itemsIndexed(
items: List<T>,
noinline key: ((index: Int, item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
)


inline fun <T> LazyListScope.items(
items: Array<T>,
noinline key: ((item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)


inline fun <T> LazyListScope.itemsIndexed(
items: Array<T>,
noinline key: ((index: Int, item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
)

按照以往Android中的开发经验,我们很容易写出如下的代码:

// WorkShop 中的入口页面,枚举了各个例子对应的Activity
@Composable
fun TestList(activity: Activity, cases: List<Pair<String, Class<out Activity>>>) {
LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)) {
itemsIndexed(items = cases) { _, item ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.size(3.dp))
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(
color = Color.LightGray,
shape = RoundedCornerShape(CornerSize(6.dp))
)
.clickable {
activity.startActivity(Intent(activity, item.second))
},
contentAlignment = Alignment.Center
) {
Text(text = item.first, color = MainTxt, textAlign = TextAlign.Center)
}
Spacer(modifier = Modifier.size(3.dp))
}
}
}
}

TestList(
activity = this@MainActivity, cases = arrayListOf(
"Layout samples" to P21LayoutSample::class.java,
"Draw samples" to P21DrawSample::class.java,
"Text samples" to P26TextSample::class.java,
"TextField samples" to P26TextFieldSample::class.java,
"Button samples" to P26ButtonSample::class.java,
"Icon samples" to P27IconSample::class.java,
"Image samples" to P27ImageSample::class.java,
"Switch,Checkbox,RadioButton samples" to P28SwitchRbCbSample::class.java,
"Box,Row,Column samples" to P29BoxRowColumnSample::class.java,
)
)

如果从Android的视角出发,这段代码相当于创建 ViewHolder的ItemView 以及 onBindViewHolder 的实现

Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.size(3.dp))
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(
color = Color.LightGray,
shape = RoundedCornerShape(CornerSize(6.dp))
)
.clickable {
activity.startActivity(Intent(activity, item.second))
},
contentAlignment = Alignment.Center
) {
Text(text = item.first, color = MainTxt, textAlign = TextAlign.Center)
}
Spacer(modifier = Modifier.size(3.dp))
}

也就是说,我们利用了ItemView固有的 "留白" 处理了Item之间的间距,显然这不是最佳实践方案!

更加优雅地处理间距和对齐

上文中已经提及:

  • contentPadding
  • verticalArrangement
  • horizontalAlignment

基于此我们对代码进行改造,以减少没用的嵌套

@Composable
fun TestList(activity: Activity, cases: List<Pair<String, Class<out Activity>>>) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = spacedBy(6.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(items = cases) { _, item ->
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(
color = Color.LightGray,
shape = RoundedCornerShape(CornerSize(6.dp))
)
.clickable {
activity.startActivity(Intent(activity, item.second))
},
contentAlignment = Alignment.Center
) {
Text(
text = item.first,
color = MainTxt,
textAlign = TextAlign.Center
)
}
}
}
}

可以得到一致的效果:

p30_demo1.webp

让Column可滑动

参考可以实现Row的可滑动

最开始在和Modifier混脸熟的过程中,我们提及了 androidx.compose.foundation 包,并且含有子包: androidx.compose.foundation.gestures,顾名思义,后者和手势处理有关。

  • androidx.compose.foundation.gestures.ScrollableKt#scrollable
  • androidx.compose.foundation.ScrollKt#verticalScroll
  • androidx.compose.foundation.ScrollKt#horizontalScroll

经过这一阶段的学习,我们可以做出一个结论:

Compose 中包含一部分基本的函数,以及结合实际使用场景,在基本函数上 "装饰" 出高级函数

从命名上,我们很容易得知 scrollable 是比较基本的函数,verticalScrollhorizontalScroll 是基于 scrollable 装饰出的高级函数。

阅读源码后也确实验证了我们的推测。

以 verticalScroll 为例,horizontalScroll暂不展开

Doc内容如下:

Modify element to allow to scroll vertically when height of the content is bigger than max constraints allow. In order to use this modifier, you need to create and own [ScrollState] @see [rememberScrollState]

修饰布局元素,当其内容高度超过最大允许的限制时,允许在纵向进行滚动。

注:内容最大高度地限制需要考虑容器的高度、padding、offset等内容

为了使用它,你需要创建并持有 ScrollState 实例,参见 rememberScrollState

方法原型

fun Modifier.verticalScroll(
state: ScrollState,
enabled: Boolean = true,
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
)

  • state:滚动状态,ScrollState实例
  • enabled:当触摸事件发生时,是否允许滑动
  • flingBehavior:fling处理逻辑
  • reverseScrolling:是否反向滑动

Demo

Column(
modifier = Modifier
.fillMaxWidth()
.height(600.dp)
.verticalScroll(
state = rememberScrollState()
)
) {
Box(
Modifier
.fillMaxWidth()
.height(400.dp)
.background(Color.Green)
)

Spacer(modifier = Modifier.height(50.dp))

Box(
Modifier
.fillMaxWidth()
.height(400.dp)
.background(Color.Blue)
)

}

很显然,内容高度已经超过了最大限制!

收起阅读 »

View实现3D效果

上次有文章介绍了利用传感器实现3D效果,根据加速度和重力传感器,计算xy偏移值,然后在移动view。1. 利用MotionLayout实现最开始想到的是用motionlayout也可以同样实现,但是最后发现我错了,motionlayout设置的view路径是固...
继续阅读 »

上次有文章介绍了利用传感器实现3D效果,根据加速度和重力传感器,计算xy偏移值,然后在移动view。

1. 利用MotionLayout实现

最开始想到的是用motionlayout也可以同样实现,但是最后发现我错了,motionlayout设置的view路径是固定的,无法在xy轴上自由移动。

一个简单的效果:
1.gif

2. 封装View

利用自定义ViewGroup实现,直接在布局中引用就行了

主要代码

传感器初始化:

//获取传感器XYZ值
private var mAcceleValues : FloatArray ?= null
private var mMageneticValues : FloatArray ?= null
private val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (event?.sensor?.type == Sensor.TYPE_ACCELEROMETER) {
mAcceleValues = event.values
//log("x:${event.values[0]},y:${event.values[1]},z:${event.values[2]}")
}
if (event?.sensor?.type == Sensor.TYPE_MAGNETIC_FIELD) {
mMageneticValues = event.values
}
if (mAcceleValues==null || mMageneticValues==null) return

val values = FloatArray(3)
val R = FloatArray(9)
SensorManager.getRotationMatrix(R, null, mAcceleValues, mMageneticValues);
SensorManager.getOrientation(R, values);

//val z = values[0].toDouble()
// x轴的偏转角度
//val x = Math.toDegrees(values[1].toDouble()).toFloat()
// y轴的偏转角度
//val y = Math.toDegrees(values[2].toDouble()).toFloat()

val degreeZ = Math.toDegrees(values[0].toDouble()).toInt()
val degreeX = Math.toDegrees(values[1].toDouble()).toInt()
val degreeY = Math.toDegrees(values[2].toDouble()).toInt()
log("x:${degreeX},y:${degreeY},z:${degreeZ}")
calculateScroll(degreeX, degreeY, degreeZ)
}

override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
}
}



//传感器初始化
private var hasInit = false
private fun initSensor(){
if (hasInit) return
log("initSensor")
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager

// 重力传感器
val acceleSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager.registerListener(listener, acceleSensor, SensorManager.SENSOR_DELAY_GAME)

// 地磁场传感器
val magneticSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
sensorManager.registerListener(listener, magneticSensor, SensorManager.SENSOR_DELAY_GAME)

hasInit = true
}

计算移动距离:

private fun calculateScroll(x: Int, y: Int, z: Int) {
var deltaY = 0
var deltaX = 0

//除去一下特殊的角度
//if (abs(x)==180 || abs(x)==90 || abs(y)==180 || abs(y)==90) return
//if (x !in -90 until 45) return

if (abs(z) > 90){
if (y in -180 until 0){
//从右向左旋转
//x值增加
deltaX = abs(((y / 180.0) * slideDistance).toInt())
}else if (y in 0 until 180){
//从左向右旋转
deltaX = -abs(((y / 180.0) * slideDistance).toInt())
}
}

if (x in -90 until 0){
deltaY = abs((((x) / 180.0) * slideDistance).toInt())
}else if (x in 0 until 90){
deltaY = -abs((((x) / 180.0) * slideDistance).toInt())
}

if (abs(deltaX) < 5) deltaX=0
if (abs(deltaY) < 5) deltaY=0

log("onSensorChanged,scrollX:${deltaX},scrollY:${deltaY},x:${x} y:${y}")
}

滑动:

 scroller.startScroll(deltaX,deltaY, (this.x+deltaX).toInt(),(this.y+deltaY).toInt(),1000)
override fun computeScroll() {
super.computeScroll()
//判断Scroller是否执行完毕
if (scroller.computeScrollOffset()) {
val slideX = scroller.currX
val slideY = scroller.currY

val bottomSlideX = slideX/3
val bottomSlideY = slideY/3

val middleSlideX = slideX/2
val middleSlideY = slideY/2

val topSlideX = -slideX
val topSlideY = -slideY

bottomImageView?.layout(0+bottomSlideX,0+bottomSlideY,measuredWidth+bottomSlideX,measuredHeight+bottomSlideY)
//middleImageView?.layout(middleLeft+middleSlideX,middleTop+middleSlideY,middleRight+middleSlideX,middleBottom+middleSlideY)
topImageView?.layout(topLeft+topSlideX,topTop+topSlideY,topRight+topSlideX,topBottom+topSlideY)

/*(bottomImageView as View).scrollTo(
scroller.currX,
scroller.currY
)*/
//通过重绘来不断调用computeScroll
invalidate()
}
}


属性配置:

<declare-styleable name="layout3d">
<!--上中下层资源文件-->
<attr name="TopLayer" format="reference" />
<attr name="MiddleLayer" format="reference" />
<attr name="BottomLayer" format="reference" />

<!--上中下层滑动距离-->
<attr name="SlideDistance" format="dimension"/>

<!--上中下层是否滑动-->
<attr name="TopSlidingEnable" format="boolean"/>
<attr name="MiddleSlidingEnable" format="boolean"/>
<attr name="BottomSlidingEnable" format="boolean"/>


<!--中层图片资源坐标设置-->
<attr name="MiddleLayerTop" format="dimension"/>
<attr name="MiddleLayerBottom" format="dimension"/>
<attr name="MiddleLayerLeft" format="dimension"/>
<attr name="MiddleLayerRight" format="dimension"/>

<!--上层图片资源坐标设置-->
<attr name="TopLayerTop" format="dimension"/>
<attr name="TopLayerBottom" format="dimension"/>
<attr name="TopLayerLeft" format="dimension"/>
<attr name="TopLayerRight" format="dimension"/>

</declare-styleable>
```

在布局中引入:

<com.example.montionlayout3d.view.Layout3D
android:id="@+id/vg3D"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/titi"
app:TopLayer="@drawable/circle"
app:MiddleLayer="@drawable/star1"
app:BottomLayer="@drawable/back"
layout3d:MiddleLayerTop="30dp"
layout3d:MiddleLayerBottom="70dp"
layout3d:MiddleLayerLeft="300dp"
layout3d:MiddleLayerRight="340dp"
layout3d:TopLayerTop="50dp"
layout3d:TopLayerBottom="200dp"
layout3d:TopLayerLeft="20dp"
layout3d:TopLayerRight="170dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

最终效果:

2.gif

3.gif


收起阅读 »

Activity启动流程(基于AOSP 11)

当点击Launcher的App icon的时候,点击事件传递给ItemClickHandler的onClickAppShortcut,并最终调用到launcher.startActivitySafely->BaseDraggingActivity.sta...
继续阅读 »

当点击Launcher的App icon的时候,点击事件传递给ItemClickHandler的onClickAppShortcut,并最终调用到launcher.startActivitySafely->BaseDraggingActivity.startActivitySafely:

    public boolean startActivitySafely(View v, Intent intent, @Nullable ItemInfo item,
@Nullable String sourceContainer) {
// ...
// 将Intent的Flag设置为FLAG_ACTIVITY_NEW_TASK
// 这样启动Activity就会在一个新的Activity任务栈中
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// ...
try {
// ... 省略一些快捷方式启动
else if (user == null || user.equals(Process.myUserHandle())) {
// Could be launching some bookkeeping activity
// 启动应用Activity
startActivity(intent, optsBundle);
AppLaunchTracker.INSTANCE.get(this).onStartApp(intent.getComponent(),
Process.myUserHandle(), sourceContainer);
}
// ...
return true;
} catch (NullPointerException|ActivityNotFoundException|SecurityException e) {
Toast.makeText(this, R.string.activity_not_found, Toast.LENGTH_SHORT).show();
Log.e(TAG, "Unable to launch. tag=" + item + " intent=" + intent, e);
}
return false;
}

startActivity的实现位于Activity中:

    public void startActivity(Intent intent, @Nullable Bundle options) {
// ... 都是调用startActivityForResult启动Activity
// 传入的requestCode是-1
if (options != null) {
startActivityForResult(intent, -1, options);
} else {
startActivityForResult(intent, -1);
}
}

启动Activity最终通过Instrumentation的execStartActivity方法启动,而Instrumentation的作用其实是监控应用和系统交互的中间这,是安卓提供的一层Hook操作,让开发者有能力介入到Activity的各项声明周期中,比如可以用来测试,Activity的各项生命周期的回调也是通过这个类作为中间层实现的,execStartActivity核心是调用到:

int result = ActivityTaskManager.getService().startActivity(whoThread,
who.getBasePackageName(), who.getAttributionTag(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()), token,
target != null ? target.mEmbeddedID : null, requestCode, 0, null, options);

ActivityTaskManager是Android 10中新增加的一个类,接替了ActivityManager中Activity与ActivityTask交互的工作,而ActivitTaskManager.getService返回的就是IActivityTaskManager这个AIDL文件中声明的接口,而SystemServer进程中的系统服务ActivityTaskManagerService只需要继承IActivityTaskManager.Stub就可以实现用户进程与服务进程的通信。

new Singleton<IActivityTaskManager>() {
@Override
protected IActivityTaskManager create() {
final IBinder b = ServiceManager.getService(Context.ACTIVITY_TASK_SERVICE);
return IActivityTaskManager.Stub.asInterface(b);
}
};

首先获取到SystemServer进程中ActivityTaskManagerService这个系统服务的IBinder接口,然后将这个IBinder接口转换为IActivityTaskManager接口,这样引用进程就可以调用到IActivityTaskManager的方法,与ATMS进行通信了。

那么应用进程调用startActivity终于还是调用到ATMS的startActivity方法了,到这里,创建Activity的逻辑就从Launcher进程进入到了ATMS所在的SystemServer进程:

在ATMS的startActivity中,调用到ATMS.startActivityAsUser:

    private int startActivityAsUser(IApplicationThread caller, String callingPackage,
@Nullable String callingFeatureId, Intent intent, String resolvedType,
IBinder resultTo, String resultWho, int requestCode, int startFlags,
ProfilerInfo profilerInfo, Bundle bOptions, int userId, boolean validateIncomingUser) {
// 前置权限校验
// 下面获取ActivityStart对象
// 这个对象是控制Activity启动的代理对象
// 7.0以后加入,收集Activity启动相关的各种参数逻辑
// 决定如何启动Activity一级Activity的Task和TaskStack关联
return getActivityStartController().obtainStarter(intent, "startActivityAsUser")
// ... 设置了很多参数
.execute();

}

在对ActivityStarter对象设置完一堆参数之后,调用其execute执行启动:

    int execute() {
try {
// ...
int res;
synchronized (mService.mGlobalLock) {
// ...
// 启动信息都在mRequest中了,执行这个请求
res = executeRequest(mRequest);
// 启动完成之后的一些通知等工作......
}
} finally {
onExecutionComplete();
}
}

executeRequest里边的逻辑很多,主要包含各种校验逻辑,比如检验启动参数,Flag等,总之,正常启动流程会去根据各种启动参数创建一个ActivityRecord对象,ActivityRecord对象就代表着一个在任务栈中的Activity实体,最后调用到ActivityStarter的startActivityUnchecked。

startActivityUnchecked->startActivityInner,在startActivityInner中,会先去对Activity启动Flag进行各种校验,比如如果启动的是一个根Activity,但是启动Flag并不是FLAG_ACTIVITY_NEW_TASK,那么会将LaunchFlag修改为FLAG_ACTIVITY_NEW_TASK。然后找到是否有可以重用的任务栈,如果没有,会去创建一个新的任务栈,在startActivityInner中关于启动的核心逻辑是调用:

mRootWindowContainer.resumeFocusedStacksTopActivities(
mTargetStack, mStartActivity, mOptions);

RootWindowContainer是在WMS中创建的对象,可以理解为其管理者屏幕显示的内容。在RootWindowContainer中,会调用到ActivityStack的resumeTopActivityUncheckedLocked->resumeTopActivityInnerLocked。这个方法在11上长达400+行,逻辑很多,和我们要分析的主流程相关的重点如下:

// attachedToProcess的判断逻辑:要启动的Activity的进程是否存在以及
// 对应进程的ActivityThread是否存在,而我们这里启动的是一个新进程
// 的根Activity,此时新进程还未创建以及ActivityRecord还没有绑定到新进程中
if (next.attachedToProcess()) {
// ...
} else {
// ...
mStackSupervisor.startSpecificActivity(next, true, true);
}

ActivityStackSupervisor的startSpecificActivity如下:

    void startSpecificActivity(ActivityRecord r, boolean andResume, boolean checkConfig) {
// Is this activity's application already running?
final WindowProcessController wpc =
mService.getProcessController(r.processName, r.info.applicationInfo.uid);

boolean knownToBeDead = false;
// 如果应用所在的进程已经存在,那么执行realStartActivityLocked
// 进行Activity启动
if (wpc != null && wpc.hasThread()) {
try {
// 这个函数后面再做分析
realStartActivityLocked(r, wpc, andResume, checkConfig);
return;
} catch (RemoteException e) {
Slog.w(TAG, "Exception when starting activity "
+ r.intent.getComponent().flattenToShortString(), e);
}

// If a dead object exception was thrown -- fall through to
// restart the application.
knownToBeDead = true;
}

r.notifyUnknownVisibilityLaunchedForKeyguardTransition();

final boolean isTop = andResume && r.isTopRunningActivity();
// 如果应用进程不存在,ATMS(最终是AMS)将会异步启动应用进程
mService.startProcessAsync(r, knownToBeDead, isTop, isTop ? "top-activity" : "activity");
}

对于应用进程不存在的情况,AMS会往SystemServer的消息队列中发送一个Lambda类型的Message(其实就是类似Runnable类型的消息),然后消息执行的时候,调用关系:

AMS.startProcessAsync->AMS.startProcess->AMS.startProcessLocked->ProcessList.startProcessLocked...->ProcessList.startProcess->Process.start,最终通过ZygoteProcess调用startViaZygote,通过Zygote进程fork出应用进程,完成应用进程的创建,在应用进程创建的同时,会往SystemServer的消息队列中发送一条超时消息(默认10s),超时到了应用进程仍然未启动的话,就会杀死这个启动失败的进程。

而在Zygote fork出子进程之后,就会执行到ActivityThrea的main方法,在ActivityThread的main方法中,做的最重要的两件事,开启主线程的Looper机制和创建ActivityThread并调用ActivityThread的attach函数,其中:

// mAppThread是ApplicationThread对象,在ActivityThread被创建的时候创建
// 是AMS与应用进程通信的桥梁
// 这里初始化RuntimeInit.mApplicationObject值
RuntimeInit.setApplicationObject(mAppThread.asBinder());
// 获取到AMS的Binder接口IActivityManager
final IActivityManager mgr = ActivityManager.getService();
try {
// 调用AMS的attachApplication
mgr.attachApplication(mAppThread, startSeq);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}

AMS中attachApplication实现如下:

    public final void attachApplication(IApplicationThread thread, long startSeq) {
// 如果传入的ApplicationThread是空的,那么就会异常终止应用进程
if (thread == null) {
throw new SecurityException("Invalid application interface");
}
synchronized (this) {
// 获取binder调用方进程的pid和uid,这里的调用方就是应用进程
int callingPid = Binder.getCallingPid();
final int callingUid = Binder.getCallingUid();
// 修改binder调用端的pid、uid为调用进程的pid、uid,并返回原uid和pid的组合
// 高32位表示上面的uid,低32位表示pid
final long origId = Binder.clearCallingIdentity();
attachApplicationLocked(thread, callingPid, callingUid, startSeq);
// 恢复pid、uid
Binder.restoreCallingIdentity(origId);
}
}

attachApplicationLocked又是一个超长函数,内部主要是对App进程做一些检查设置等工作。

// 内部首先会去查找有没有旧的进程记录,如果有,做一些清理类的工作
// ...
// 然后比如debug模式也会做一些特殊的设置工作
// ...
// 再比如建立Binder死亡回调,当进AMS所在服务程异常退出的时候,
// Binder驱动可以通知到应用进程,并释放死亡进程锁占用的没有正常关闭的binder文件。
// 当SystemServer进程因为异常挂掉的时候,是有重启机制的
// ...
// 移除在AMS启动进程的过程中埋下的PROC_START_TIMEOUT_MSG消息
mHandler.removeMessages(PROC_START_TIMEOUT_MSG, app);
// ...
// IPC调用应用进程ApplicationThread的bindApplication:
thread.bindApplication(...);

AMS在完成对ApplicationThread一些设置工作后,会回到应用进程的bindApplication,bindApplication的时候,AMS会将一些通用的系统服务传递给应用进程,比如WMS,IMS等等。

public final void bindApplication(...) {
if (services != null) {
// 将AMS传过来的通用系统服务缓存到ServiceManager的
// 静态cache中
ServiceManager.initServiceCache(services);
}
// 通过ActivityThread的H(继承自Handler)
// 发送一条SET_CORE_SETTINGS的消息给主线程,
// 将coreSettings设置给ActivityThread对象
// 并触发当前进程所有Activity的重启(因为ActivityThread的核心设置被修改了)
setCoreSettings(coreSettings);
// AppBindData是bindApplication的一堆数据的封装
AppBindData data = new AppBindData();
// 通过mH发送BIND_APPLICATION消息给主线程
sendMessage(H.BIND_APPLICATION, data);
}

下面bindApplication的逻辑进入H中,H是ActivityThread中的自定义Handler,比如Service的创建,绑定,Activity的生命周期处理都在这里边:

而对于BIND_APPLICATION,handleMessage将调用到handleBindApplication。

在handleBindApplication中,会填充data.info字段,其实是LoadApk对象,通过getPackageInfo获取,其包含了比如mPackagerName应用包名,mDataDir应用数据目录,mResources,mApplication等信息。

然后是完成Context的创建:

final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);
// createAppContext
static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo,
String opPackageName) {
if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
// 创建ContextImpl对象,初始化了ApplicationContext的mMainThread、packageInfo等字段
// 在ContextImpl的构造方法中,还创建了ApplicationContentResolver这个对象
ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, null,
0, null, opPackageName);
context.setResources(packageInfo.getResources());
// 是否是系统状态栏的Context
context.mIsSystemOrSystemUiContext = isSystemOrSystemUI(context);
return context;
}

完成Context的创建之后,后面会继续Application对象的创建,还是在handleBindApplication中:

Application app;
// ...
app = data.info.makeApplication(data.restrictedBackupMode, null);

// LoadedApk.makeApplication:
// ...
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
// 调用newApplication创建Application对象
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
// ...
// 将Application赋值给LoadedApk的mApplication,并添加到ActivityThread的Application列表
mApplication = app;

在newApplication中:

public Application newApplication(ClassLoader cl, String className, Context context)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
// 内部就是反射Application Class的newInstance
Application app = getFactory(context.getPackageName())
.instantiateApplication(cl, className);
// 将Context对象绑定给Application
app.attach(context);
return app;
}

// attach如下:
final void attach(Context context) {
// attachBaseContext就是将context赋值给Application(继承自ContextWrapper的)的mBase字段
attachBaseContext(context);
mLoadedApk = ContextImpl.getImpl(context).mPackageInfo;
}

继续回到应用进程的handleBindApplication中,在创建完Application对象之后:

// 回调到Application对象的onCreate方法
mInstrumentation.callApplicationOnCreate(app);

应用进程终于创建完成了,现在我们回到AMS的attachApplicationLocked中,在bindApplication完成Application对象的创建之后:

// 如果Application有待启动的Activity,那么执行Activity的启动
// 对于我们从Launcher中点击icon启动一个App来说,创建完新的进程和Application之后
// 就需要启动MainActivity
if (normalMode) {
try {
didSomething = mAtmInternal.attachApplication(app.getWindowProcessController());
} catch (Exception e) {
Slog.wtf(TAG, "Exception thrown launching activities in " + app, e);
badApp = true;
}
}
// ActivityTaskManagerService的内部类LocalService实现的attachApplication方法:
// 内部调用
mRootWindowContainer.attachApplication(wpc);

RootWindowContainer.attachApplication如下:

    boolean attachApplication(WindowProcessController app) throws RemoteException {
boolean didSomething = false;
for (int displayNdx = getChildCount() - 1; displayNdx >= 0; --displayNdx) {
mTmpRemoteException = null;
mTmpBoolean = false; // Set to true if an activity was started.

final DisplayContent display = getChildAt(displayNdx);
for (int areaNdx = display.getTaskDisplayAreaCount() - 1; areaNdx >= 0; --areaNdx) {
final TaskDisplayArea taskDisplayArea = display.getTaskDisplayAreaAt(areaNdx);
for (int taskNdx = taskDisplayArea.getStackCount() - 1; taskNdx >= 0; --taskNdx) {
final ActivityStack rootTask = taskDisplayArea.getStackAt(taskNdx);
if (rootTask.getVisibility(null /*starting*/) == STACK_VISIBILITY_INVISIBLE) {
break;
}
// 找到待启动的ActivityRecord,然后
// 调用startActivityForAttachedApplicationIfNeeded
final PooledFunction c = PooledLambda.obtainFunction(
RootWindowContainer::startActivityForAttachedApplicationIfNeeded, this,
PooledLambda.__(ActivityRecord.class), app,
rootTask.topRunningActivity());
rootTask.forAllActivities(c);
c.recycle();
if (mTmpRemoteException != null) {
throw mTmpRemoteException;
}
}
}
didSomething |= mTmpBoolean;
}
if (!didSomething) {
ensureActivitiesVisible(null, 0, false /* preserve_windows */);
}
return didSomething;
}

而startActivityForAttachedApplicationIfNeeded的实现,就是把我们找到的待启动的ActivityRecord,调用到mStackSupervisor.realStartActivityLocked。

回到ActivityStackSupervisor的startSpecificActivity:

    void startSpecificActivity(ActivityRecord r, boolean andResume, boolean checkConfig) {
// Is this activity's application already running?
final WindowProcessController wpc =
mService.getProcessController(r.processName, r.info.applicationInfo.uid);

boolean knownToBeDead = false;
// 如果应用所在的进程已经存在,那么执行realStartActivityLocked
// 进行Activity启动
if (wpc != null && wpc.hasThread()) {
try {
// 这个函数后面再做分析
realStartActivityLocked(r, wpc, andResume, checkConfig);
return;
} catch (RemoteException e) {
Slog.w(TAG, "Exception when starting activity "
+ r.intent.getComponent().flattenToShortString(), e);
}

// If a dead object exception was thrown -- fall through to
// restart the application.
knownToBeDead = true;
}

r.notifyUnknownVisibilityLaunchedForKeyguardTransition();

final boolean isTop = andResume && r.isTopRunningActivity();
// 如果应用进程不存在,ATMS(最终是AMS)将会异步启动应用进程
mService.startProcessAsync(r, knownToBeDead, isTop, isTop ? "top-activity" : "activity");
}

AMS在调用到mService.startProcessAsync启动一个新的应用进程之后,最终还是通过ActivityStackSupervisor的realStartActivityLocked,启动应用进程的首个Activity,那么再往下看,就是一般Activity的启动流程了,realStartActivityLocked仍然比较长,取我们关心的逻辑:

// 创建一个Activity启动的事务,ClientTransaction是一个实现了Parcelable接口的类
// 即该事务对象可以跨进程传递
final ClientTransaction clientTransaction = ClientTransaction.obtain(
proc.getThread(), r.appToken);
final DisplayContent dc = r.getDisplay().mDisplayContent;
// 往事务中添加一个LaunchActivityItem,代表事务的Callback,后面会执行Callback中的逻辑
clientTransaction.addCallback(LaunchActivityItem.obtain(new Intent(r.intent),...));
// ...
// 调度事务,
mService.getLifecycleManager().scheduleTransaction(clientTransaction);

先来看下事务是怎样获取的:

public static ClientTransaction obtain(IApplicationThread client, IBinder activityToken) {
// 先从对象池中找,找不到就创建
ClientTransaction instance = ObjectPool.obtain(ClientTransaction.class);
if (instance == null) {
instance = new ClientTransaction();
}
// 将IApplicationThread赋值给事务的mClient字段,
// 表示这个事务的接收端是哪个进程的ApplicationThread
instance.mClient = client;
instance.mActivityToken = activityToken;
return instance;
}

ClinetLifeCycleManager的调度事务实现如下:

void scheduleTransaction(ClientTransaction transaction) throws RemoteException {
final IApplicationThread client = transaction.getClient();
transaction.schedule();
// ...
}
//transaction.schedule();实现如下,IPC调用到应用进程ApplicationThread的scheduleTransaction
mClient.scheduleTransaction(this);

而ApplicationThread中的scheduleTransaction实现,就是调用ActivityThread的scheduleTransaction方法(AT继承自ClientTransactionHandler,实现在父类中):

void scheduleTransaction(ClientTransaction transaction) {
// 当事务在客户端进程执行的时候,在执行之前做一个hook callback操作
transaction.preExecute(this);
sendMessage(ActivityThread.H.EXECUTE_TRANSACTION, transaction);
}

发送出去的消息最终还是交给H来处理:

final ClientTransaction transaction = (ClientTransaction) msg.obj;
mTransactionExecutor.execute(transaction);

mTransactionExecutor并不是什么多线程的Executor,不过是对事务有序处理的逻辑封装,其execute如下:

public void execute(ClientTransaction transaction) {
// ...
// executeCallbacks中的重点就是执行传入事务的callback的execute方法
// item.execute(mTransactionHandler, token, mPendingActions);
//
executeCallbacks(transaction);
executeLifecycleState(transaction);
mPendingActions.clear();
// ...
}

而AMS在调用realStartActivityLocked传入事务的callback就是LaunchActivityItem,他的execute如下:

public void execute(ClientTransactionHandler client, IBinder token,
PendingTransactionActions pendingActions) {
// 创建一个ActivityClientRecord对象
ActivityClientRecord r = new ActivityClientRecord(token, mIntent, mIdent, mInfo,
mOverrideConfig, mCompatInfo, mReferrer, mVoiceInteractor, mState, mPersistentState,
mPendingResults, mPendingNewIntents, mIsForward,
mProfilerInfo, client, mAssistToken, mFixedRotationAdjustments);
// 这里client实际上就是TransactionExecutor中的mTransactionHandler
client.handleLaunchActivity(r, pendingActions, null /* customIntent */);
}

在ActivityThread中,ActivityThread对象创建的时候就会创建mTransactionExecutor对象,并把this引用传入到TransactionExecutor的构造方法中,所以这里mTransactionHandler正是实现了ClientTransactionHandler接口的ActivityThread对象,在ActivityThread的handleLaunchActivity中:

final Activity a = performLaunchActivity(r, customIntent);

而performLaunchActivity就会去创建对应的Activity对象,关于performLaunchActivity的分析,可以看前两篇Android UI系统工作流程。

收起阅读 »

完美解决macOS Homebrew安装JDK的一些问题

自从Oracle接手JDK之后,更新变快了,之前的“旧版本”也不容易下载了。最近一段时间Oracle一直不安生, 搞出来一堆幺蛾子, 所以安装方式也一直在变, 之前的方法已经不能用了, 网上找各种办法都不好使,下面针对各个版本给出了不同建议, 安装结束后, 可...
继续阅读 »

自从Oracle接手JDK之后,更新变快了,之前的“旧版本”也不容易下载了。最近一段时间Oracle一直不安生, 搞出来一堆幺蛾子, 所以安装方式也一直在变, 之前的方法已经不能用了, 网上找各种办法都不好使,下面针对各个版本给出了不同建议, 安装结束后, 可输入java -version确认是否安装成功。


一、JDK8~JDK12、OpenJDK及AdoptOpenJDK


这些都是比较主流的JDK版本, 目前大多数企业还在使用,但想要通过 Homebrew 却并不容易,网上查询的90%Homebrew安装JDK8的方式都是不能用的, 必须要寻求开源世界的帮助, 对于 JDK8 ~ JDK12, 这时会推荐 AdoptOpenJDK.


AdoptOpenJDK 是免费的、完全无品牌的 OpenJDK 版本,基于 GPL 开源协议(+Classpath Extension),以免费软件的形式提供社区版的 OpenJDK 二进制包,公司也可安全且放心使用。与由 Oracle 的 OpenJDK 构建版本不同,这些版本会提供更长的支持,像 Java 11 一样,至少提供 4 年的免费长期支持(LTS)计划。


通过 AdoptOpenJDK 可以安装最多版本的 JDK.


brew cask install AdoptOpenJDK/openjdk/adoptopenjdk8
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk9
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk10
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk11
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk12
brew cask install AdoptOpenJDK/openjdk/adoptopenjdk

二、JDK12、JDK13及OracleJDK


如果你想在电脑上装最新版的 JDK, 那么 Oracle 或许是你最想要的选择, 而 Oracle 家的最新版 JDK 也有两款, 一个是 Oracle 提供的 OpenJDK, 一个是商业版本 Oracle JDK, 但请注意 Oracle JDK 并不比 OpenJDK “更好”, 大家需要理性看待.


# 运行以下命令会安装 Oracle 提供的 Oracle JDK12
brew cask install oracle-jdk

# 在2019年5月
## 该命令会安装由 Oracle 提供的 OpenJDK12
brew cask install java
## 而该命令则安装由 Oracle 提供的 OpenJDK11
brew cask install java11

三、JDK7和Zulu


JDK7 甚至 AdoptOpenJDK 都不提供了, 这时候需要的是有商业背景的 Azul Zulu, zulu 是 OpenJDK 的免费版本, 在提供商业付费支持之外, Azul 也有为 zulu 提供免费的社区技术支持.


通过安装 zulu7 我们可以安装 OpenJDK7.


# Azul Zulu 也提供其他版本的 OpenJDK 像 zulu8 zulu11 和最新版的 zulu 均可使用
brew cask install homebrew/cask-versions/zulu7
brew cask install homebrew/cask-versions/zulu8
brew cask install homebrew/cask-versions/zulu11
brew cask install homebrew/cask-versions/zulu

四、JDK6


估计现在连好多企业都不用了吧,所以放到左后。 JDK6 主要由 Apple 自身提供。


brew cask install homebrew/cask-versions/java6



遇到问题耐心解决,如果还没解决,那肯定是时间没花够



  • 9月21日心心念想、梦寐以求的MacBook Pro (13-inch, 2019, Four Thunderbolt 3 ports)入手的第一天晚上,我拆机用了多半个小时,就是边拆边拍照,拆个盒子都要洗个手😂那种。在这之前,我平时抽空就提前“上手macOS”

  • 依稀还记得初一自己捣鼓的那台组装机,主板:华硕,CPU:英特尔奔腾E5400,硬盘512G HDD,金士顿2G,现在确实已经很卡了,尽管装了最新版的Windows v1903专业版。不过作为我的第一台电脑,好多东西都是在它上面学的,感谢下老爸将它作为考了年级第一奖励台电脑送给我。

  • 9月21日那天晚上,emmm,是晚上,大家应该和我有同感,一到晚上一些站点就特别慢,比如GitHub之类的,但强迫症的我肯定得在第一时间部署好我的开发环境吧,毕竟大二那会儿我快成专业的运维了~~同学电脑有毛病就找我,开发环境(Java/Android/Python/Node~)有问题还是找我。所以就先装Git、JDK、Maven吧。

  • Git简单,现在macOS 虽然不自带Git,但是安装Homebrew之前安装的Command Line Tools里面包括了Git、gcc等工具,很方便,也比较快(前提是切换了Homebrew的源

  • JDK确实费了老大劲才装好,还是第二天早晨6点10分起来干的,因为早晨访问GitHub这种站点确实比下午快很多。最后诞生了此文。

  • Maven不说了,可以Homebrew安装,也可以在Maven官网下载包之后解压,然后使用,好多人可能会想着配Mavne的环境变量,其实我个人认为没必要。直接说下我是怎么使用的吧:IDEA现在是Java开发的主流IDE工具,里面的终端可以自己配置,结合zsh加上oh my zsh简直无可挑剔!我是使用了“两个Maven”,一个是公司用(公司有自己的Maven仓库),另一个是自己玩(配置的阿里云镜像),在同一个IDEA切换不同的settings.xml便可以实现多Maven切换,之间互不影响,主要是IDEA确实智能,智能在部分配置是每个项目单独的,多个项目可以完全配置不同的Maven,公司项目和自己项目随意切换,Maven也跟着换,很方便。至于不配置Maven环境mvn命令不能使用的问题我想说:IDEA里面依然可以执行你手敲的mvn -U clean package -Dmaven.test.skip=true,所以我不推荐配置Maven的环境变量。用多个Maven的原因我想大家也没明白,如果公司项目里修改了某个包的源码或者重写了某个方法,而你自己项目同样使用了该包的,那么这种情况下极易出错,使用Maven或多或少可能会遇到Maven存在一些Bug,这种迭代了N个版本依然存在的Bug,也许这就是包统一管理的缺陷,如果至今你还没碰过这种情况,说明你的项目比较稳定或者emmmm...你平时自己不捣鼓学习一些东西。


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

收起阅读 »

Swift算法俱乐部:Swift队列数据结构(Queue)

准备开始队列(Queue)是一个列表,您只能在后面插入新项目并从前面删除项目。 这可确保入队的第一个元素也是首先出队的元素。 先到先出在许多算法中,我们希望在某个时间点将项目添加到临时列表中,然后在以后再次将它们从列表中拉出。 添加和删除这些项目的顺序非常重要...
继续阅读 »

准备开始

队列(Queue)是一个列表,您只能在后面插入新项目并从前面删除项目。 这可确保入队的第一个元素也是首先出队的元素。 先到先出
在许多算法中,我们希望在某个时间点将项目添加到临时列表中,然后在以后再次将它们从列表中拉出。 添加和删除这些项目的顺序非常重要。

队列提供先进先出或先入先出的顺序。 首先插入的元素也是第一个出来的元素(和堆栈(Stack)非常类似,是LIFO或后进先出。)

这是一个栗子
理解队列的最简单方法是看看它是如何使用的。

想象一下你有一个队列。 以下是你如何入选一个数字:

queue.enqueue(10)

队列现在是[10]。 然后,继续将下一个号码添加到队列中:

queue.enqueue(3)

队列现在是[10,3]。 继续添加:

queue.enqueue(57)

队列现在是[10,3,57]。 我们可以将队列中的第一个元素从队列中拉出:

queue.dequeue()

将返回10,因为这是插入的第一个数字。 队列现在将是[3,57]。 每个项目都向上移动一个地方。

queue.dequeue()

这将返回3.下一个出列将返回57,依此类推。 如果队列为空,则出队将返回零。

实现队列
在本节中,将实现一个存储Int值的简单通用队列。
创建一个新的playground,添加如下代码:

public struct Queue {

}

playground还包含LinkedList的代码(可以通过转到查看 Project Navigators Show Project Navigator并打开Sources LinkedList来看到这一点。

入队(Enqueue)

队列需要入队方法。 我们使用项目中包含的LinkedList实现来实现队列。 在花括号之间添加以下内容:

// 1
fileprivate var list = LinkedList<Int>()

// 2
public mutating func enqueue(_ element: Int) {
list.append(element)
}
  1. 添加了一个fileprivate LinkedList变量,用于将这些项目存储在队列中。
  2. 已经添加了一个方法来排列项目。 这个方法会改变底层的LinkedList,所以明确地指定了在方法前加上mutating关键字。

出列(Dequeue)

队列也需要一个出队方法。

// 1
public mutating func dequeque() -> Int? {
// 2
guard !list.isEmpty, let element = list.first else { return nil}

list.remove(element)

return element.value
}
  1. 添加一个返回队列中第一个项目的出队方法。 返回类型可以为空来处理队列为空。
  2. 使用guard语句处理队列为空。 如果这个队列是空的,那么guard将会进入else块。

查看(Peek)

队列还需要一个peek方法,它在队列的开始处返回该项目而不删除它。

public func peek() -> Int? {
return list.first?.value
}

IsEmpty

队列可以是空的。 添加一个isEmpty属性,该属性将返回基于LinkedList的值:

public var isEmpty: Bool {
return list.isEmpty
}

打印队列

让我们试试新队列。 在队列实现下面,将以下内容写入playground中:

var queue = Queue()
queue.enqueue(10)
queue.enqueue(3)
queue.enqueue(57)

定义队列后,尝试将队列打印到控制台:

print(queue)

输出如下:

Queue(list: [10, 3, 57])

这输出的样式不是很好。 要显示更可读的输出字符串,可以使队列采用CustomStringConvertable协议。 为此,请在Queue类的实现下方添加以下内容:

// 1
extension Queue: CustomStringConvertible {
// 2
public var description: String {
// 3
return list.description
}
}
  1. 声明Queue类的扩展,让它遵循CustomStringConvertible协议。 该协议期望使用字符串类型实现带名称描述的计算属性。
  2. 声明了description属性。 这是一个计算属性,它是一个返回String的只读属性。
  3. 返回基于LinkedList的描述。

现在控制台的输出编程如下样式:

[10, 3, 57]

Swift通用队列实现
此时,我们已经实现了一个存储Int值的通用队列,并提供了在Queue类中查看,排队和出列项目的功能。
在本节中,我们使用泛型从队列中抽象出类型需求。

将Queue类的实现更新为以下内容:

// 1
public struct Queue<T> {
// 2
fileprivate var list = LinkedList<T>()

// 3
public mutating func enqueue(_ element: T) {
list.append(element)
}

// 4
public mutating func dequeque() -> T? {

guard !list.isEmpty, let element = list.first else { return nil}

list.remove(element)

return element.value
}
// 5
public func peek() -> T? {
return list.first?.value
}

public var isEmpty: Bool {
return list.isEmpty
}
}

修正测试代码如下:

var queue = Queue<Int>()
queue.enqueue(10)
queue.enqueue(3)
queue.enqueue(57)
print(queue)

还可以尝试使用不同类型的Queue:

var queue2 = Queue<String>()
queue2.enqueue("mad")
queue2.enqueue("lad")
if let first = queue2.dequeque() {
print(first)
}
print(queue2)


收起阅读 »

iOS 类方法load和initialize的区别

Objective-C作为一门面向对象语言,有类和对象的概念。编译后,类相关的数据结构会保留在目标文件中,在运行时得到解析和使用。在应用程序运行起来的时候,类的信息会有加载和初始化过程。就像Application有生命周期回调方法一样,在Objective-C...
继续阅读 »

Objective-C作为一门面向对象语言,有类和对象的概念。编译后,类相关的数据结构会保留在目标文件中,在运行时得到解析和使用。在应用程序运行起来的时候,类的信息会有加载和初始化过程。
就像Application有生命周期回调方法一样,在Objective-C的类被加载和初始化的时候,也可以收到方法回调,可以在适当的情况下做一些定制处理。而这正是load和initialize方法可以帮我们做到的。

  • (void)load;
  • (void)initialize;

可以看到这两个方法都是以“+”开头的类方法,返回为空。通常情况下,我们在开发过程中可能不必关注这两个方法。如果有需要定制,我们可以在自定义的NSObject子类中给出这两个方法的实现,这样在类的加载和初始化过程中,自定义的方法可以得到调用。
+load

顾名思义,+load方法在这个文件被程序装载时调用。只要是在Compile Sources中出现的文件总是会被装载,这与这个类是否被用到无关,因此+load方法总是在main函数之前调用。
调用方式:
会循环调用所有类的 +load 方法。注意,这里是(调用分类的 +load 方法也是如此)直接使用函数内存地址的方式 (*load_method)(cls, SEL_load); 对 +load 方法进行调用的,而不是使用发送消息 objc_msgSend 的方式。
这样的调用方式就使得 +load 方法拥有了一个非常有趣的特性,那就是子类、父类和分类中的 +load 方法的实现是被区别对待的。也就是说如果子类没有实现 +load 方法,那么当它被加载时 runtime 是不会去调用父类的 +load 方法的。同理,当一个类和它的分类都实现了 +load 方法时,两个方法都会被调用。
要点:

  • 调用时机比较早,运行环境有不确定因素。具体说来,在iOS上通常就是App启动时进行加载,但当load调用的时候,并不能保证所有类都加载完成且可用,必要时还要自己负责做auto release处理。

补充上面一点,对于有依赖关系的两个库中,被依赖的类的+load会优先调用。但在一个库之内,父、子类、类别之间调用有顺序,不同类之间调用顺序是不确定的。

  • 关于继承:对于一个类而言,没有+load方法实现就不会调用,不会考虑对NSObject的继承,就是不会沿用父类的+load。
  • 父类和本类的调用:父类的方法优先于子类的方法。一个类的+load方法不用写明[super load],父类就会收到调用。
  • 本类和Category的调用:本类的方法优先于类别(Category)中的方法。Category的+load也会收到调用,但顺序上在本类的+load调用之后。
  • 不会直接触发initialize的调用。

+initialize

+initialize 方法是在类或它的子类收到第一条消息之前被调用的,这里所指的消息包括实例方法和类方法的调用,并且只会调用一次。initialize方法实际上是一种惰性调用,也就是说如果一个类一直没被用到,那它的initialize方法也不会被调用,这一点有利于节约资源。
调用方式:
runtime 使用了发送消息 objc_msgSend 的方式对 +initialize 方法进行调用。也就是说 +initialize 方法的调用与普通方法的调用是一样的,走的都是发送消息的流程。换言之,如果子类没有实现 +initialize 方法,那么继承自父类的实现会被调用;如果一个类的分类实现了 +initialize 方法,那么就会对这个类中的实现造成覆盖。
要点:

  • initialize的自然调用是在第一次主动使用当前类的时候。
  • 在initialize方法收到调用时,运行环境基本健全。
  • 关于继承:和load不同,即使子类不实现initialize方法,会把父类的实现继承过来调用一遍,就是会沿用父类的+initialize。(沿用父类的方法中,self还是指子类)
  • 父类和本类的调用:子类的+initialize将要调用时会激发父类调用的+initialize方法,所以也不需要在子类写明[super initialize]。(本着除主动调用外,只会调用一次的原则,如果父类的+initialize方法调用过了,则不会再调用)
  • 本类和Category的调用:Category中的+initialize方法会覆盖本类的方法,只执行一个Category的+initialize方法。

类别(Category)

对于+initialize,只有最后一个类别执行,本类的+initialize和前面类别的+initialize被隐藏。
而对于+load,本类和本类的所有类别都执行,并且如果Apple的文档中介绍顺序一样:先执行类自身的实现,再执行类别中的实现。
扩展

因为两个方法只会被系统调用一次(除主动调用外),并且是线程安全的,可以用来作为单例的实现。(可以用+initialize,+load有些隐患,看这里)
�注意

  • 在使用时都不要过重地依赖于这两个方法,除非真正必要。
  • 谨慎在分类中实现+initialize方法,因为如果在分类中实现了,本类实现的+initialize方法将不会被调用。
  • 谨慎在分类中实现+load方法。因为如果在本类中实现+load方法混淆A、B两个方法,分类中也混淆A、B,因为本类和分类的+load都实现了,所以都会调用,A、B在本类中置换后,又在分类中置换了回来。
  • load方法通常用来进行Method Swizzle,initialize方法一般用于初始化全局变量或静态变量。
  • load和initialize方法内部使用了锁,因此它们是线程安全的。实现时要尽可能保持简单,避免阻塞线程,不要再使用锁。

问题

问题:

  1. 子类、父类、分类中的相应方法什么时候会被调用?
  2. 需不需要在子类的实现中显式地调用父类的实现?

解答:

  1. super的方法会成功调用,但是这是多余的,因为runtime会自动对父类的+load方法进行调用,而+initialize则会随子类自动激发父类的方法(如Apple文档中所言)不需要显示调用。另一方面,如果父类中的方法用到的self(像示例中的方法),其指代的依然是类自身,而不是父类。

总结

收起阅读 »

如何将react-native的style样式转换成css样式

背景: 我们总是倾向于一套代码走天下,正所谓一招鲜,吃遍天。刚接触RN项目的时候,常常为RN style样式的写法而头痛,等到熟悉了RN样式写法时,一个web端项目从天而降,于是,你又不得不操练起日渐陌生的css写法。更过分的是,有时你还得在RN样式和css样...
继续阅读 »

背景: 我们总是倾向于一套代码走天下,正所谓一招鲜,吃遍天。刚接触RN项目的时候,常常为RN style样式的写法而头痛,等到熟悉了RN样式写法时,一个web端项目从天而降,于是,你又不得不操练起日渐陌生的css写法。更过分的是,有时你还得在RN样式和css样式之间来回切换,时刻处于水深火热之中。抬首间,不禁叹息一声:人间不值得。


一、准备工作


本文中详细讲解sass样式的转换,其它诸如less、css、PostCss的转换请参考:(https://github.com/kristerkari/react-native-css-modules)
这里面有较为详细说明。


我们需要准备四个依赖:

react-native-sass-transformer 将 Sass 转换为与 React Native 兼容的样式对象并处理实时重新加载

babel-plugin-react-native-platform-specific-extensions 如果磁盘上存在特定于平台的文件,则将 ES6 导入语句转换为特定于平台的 require 语句

babel-plugin-react-native-classname-to-style 将 className 属性转换为 style 属性

node-sass


二、 创建一个React-Native APP


参考官方文档创建即可。


三、安装依赖


yarn add babel-plugin-react-native-classname-to-style babel-plugin-react-native-platform-specific-extensions react-native-sass-transformer node-sass --dev

四、设置babel配置


对于React Native v0.57 或者更新版本


.babelrc (or babel.config.js)


{
"presets": ["module:metro-react-native-babel-preset"],
"plugins": [
"react-native-classname-to-style",
[
"react-native-platform-specific-extensions",
{
"extensions": ["scss", "sass"]
}
]
]
}

对于React Native v0.57以下版本


{
"presets": ["react-native"],
"plugins": [
"react-native-classname-to-style",
[
"react-native-platform-specific-extensions",
{
"extensions": ["scss", "sass"]
}
]
]
}

五、设置Metro配置


在项目根目录下新增一个metro.config.js的文件


const { getDefaultConfig } = require("metro-config");

module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve("react-native-sass-transformer")
},
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
}
};
})()
;


对于React Native v0.57以下版本,在根目录下新增rn-cli.config.js文件


module.exports = {
getTransformModulePath() {
return require.resolve("react-native-sass-transformer");
},
getSourceExts() {
return ["js", "jsx", "scss", "sass"];
}
};

六、接下来你就可以愉快的使用sass来写样式


style.scss


.container {
flex: 1;
justify-content: center;
align-items: center;
background-color: #f5fcff;
}

.blue {
color: blue;
font-size: 30px;
}


你既可以使用className来写样式,也可以使用style


import React, { Component } from "react";
import { Text, View } from "react-native";
import styles from "./styles.scss";

const BlueText = () => {
return Blue Text;
};

export default class App extends Component<{}> {
render() {
return (



);
}
}

七、为sass配置TypeScript


在ts项目中,为sass配置类型提示很有必要。首先我们需要把在第三步第五步中把react-native-sass-transformer依赖替换成react-native-typed-sass-transformer


为了让className 属性正常工作,我们还需要安装下面的依赖包:


对于React Native v0.57 或者更新版本


yarn add typescript --dev

老版本:


yarn add react-native-typescript-transformer typescript --dev

在package.json中添加下面依赖,然后运行yarn命令


"@types/react-native": "^0.57.55",

如果版本versions >=0.52.4


"@types/react-native": "kristerkari/react-native-types-for-css-modules#v0.57.55",

你也可以删掉版本号,但是不建议这样做


"@types/react-native": "kristerkari/react-native-types-for-css-modules",

如果你使用的rn版本>=0.57,这样就OK了,如果不是,请参照文档:github.com/kristerkari…


八、原生提供的属性和方法如何添加到scss文件中,如何做不同机型的适配?


我们需要自定义一个transform用于sass文件的转换。


metro.config.js文件中,修改如下:


const { getDefaultConfig } = require("metro-config");

module.exports = (async () => {
const {
resolver: { sourceExts }
} = await getDefaultConfig();
return {
transformer: {
babelTransformerPath: require.resolve("./transformer.js")
},
resolver: {
sourceExts: [...sourceExts, "scss", "sass"]
}
};
})()
;


metro.config.js


const upstreamTransformer = require("metro-react-native-babel-transformer");
const sassTransformer = require("react-native-typed-sass-transformer");
const DtsCreator = require("typed-css-modules");
const css2rn = require("css-to-react-native-transform").default;

const creator = new DtsCreator();

/** 引入原生的属性和方法 */
const preImport = `
import { PixelRatio, Dimensions, StatusBar, Platform } from 'react-native';
let DEVICE_WIDTH = Dimensions.get('window').width;
let DEVICE_HEIGHT = Dimensions.get('window').height;
let S=(designPx) => {
return PixelRatio.roundToNearestPixel((designPx / 750) * DEVICE_WIDTH);
}
`

function renderCSSToReactNative(css) {
return css2rn(css, { parseMediaQueries: true });
}

/** px转换成pt,做一个标记 */
function pxToPtForMark(code){
let newCode=code;
try {
newCode=code.replace(/([0-9]+)px/g,(...arg)=>{
const px=Number(arg[1]);
return `${px}pt`;
})
} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

/** px 或者 pt单位的适配 需要注意正负值 */
function unitAdaption(code){
let newCode=code;
try {
newCode=code.replace(/"([-+]{0,1})([0-9]+)pt"/g,(...arg)=>{
const px=arg[1]+arg[2];
return `S(${px})`;
})
} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

/** vh和vw的适配 */
function vhAndVwAdaption(code){
let newCode=code;
try {
newCode=code.replace(/"([0-9]+)vw"/g,(...arg)=>{
const vw=Number(arg[1]);
return `${vw/100} * DEVICE_WIDTH`;
}).replace(/"([0-9]+)vh"/g,(...arg)=>{
const vh=Number(arg[1]);
return `${vh/100} * DEVICE_HEIGHT`;
});

} catch (error) {
throw Error('样式解析错误');
}
return newCode;
}

function isPlatformSpecific(filename) {
var platformSpecific = [".native.", ".ios.", ".android."];
return platformSpecific.some(name => filename.includes(name));
}

module.exports ={
transform:async function({ src, filename, options }) {
if (filename.endsWith(".scss") || filename.endsWith(".sass")) {

let newSrc=pxToPtForMark(src);

let css =await sassTransformer.renderToCSS({ src:newSrc, filename, options });
let cssObject = renderCSSToReactNative(css);
let cssObjectStr=JSON.stringify(cssObject);

cssObjectStr=unitAdaption(cssObjectStr);

cssObjectStr=vhAndVwAdaption(cssObjectStr);

//特殊文件直接return
if (isPlatformSpecific(filename)) {
return upstreamTransformer.transform({
src: preImport+";module.exports = " + cssObjectStr,
filename,
options
});
}

//一般文件创建types文件之后再return
return creator.create(filename, css).then(content => {
return content.writeFile().then(() => {
return upstreamTransformer.transform({
src: preImport+";module.exports = " + cssObjectStr,
filename,
options
});
});
});
} else {
return upstreamTransformer.transform({ src, filename, options });
}
}
}

在scss文件中,px单位转换成style对象时,会自动去掉,如下:


.unpaidRemind {
position: absolute;
bottom: 56px;
right: 28px;
background-color: #999;
padding: 20px;
border-radius: 16px;
}
.unpaidRemindText {
color: rgba(255, 255, 255, 0.9);
font-size: 28px;
}

转换之后变成


{
unpaidRemind: {
position: 'absolute',
bottom: 56,
right: 28,
backgroundColor: '#999',
padding: 20,
borderRadius: 16,
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: 28,
},
}

我们的目标是在在转后之后把所有px都换成我们的适配方法:


{
unpaidRemind: {
position: 'absolute',
bottom: S(55),
right: S(28),
backgroundColor: '#999',
padding: S(20),
borderRadius: S(16),
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: S(28),
},
}

最终拿到的代码类似于这样,它是可以直接执行的,同样的道理,我们可以注入更多的RN属性到我们的文件中,这取决于我们是否需要这些属性。


import { PixelRatio, Dimensions, StatusBar, Platform } from 'react-native'; 
let DEVICE_WIDTH = Dimensions.get('window').width;
let DEVICE_HEIGHT = Dimensions.get('window').height;
let S=(designPx) => { return PixelRatio.roundToNearestPixel((designPx / 750) * DEVICE_WIDTH); }
module.exports ={
unpaidRemind: {
position: 'absolute',
bottom: S(55),
right: S(28),
backgroundColor: '#999',
padding: S(20),
borderRadius: S(16),
},
unpaidRemindText: {
color: 'rgba(255,255,255,0.9)',
fontSize: S(28),
},
}

pxToPtForMark方法将px转换成pt,这一步主要是方便我们后续把pt转成S(28)这种形式,unitAdaption方法就是实现这一功能。为什么不是直接把px转成S(28)这种形式?renderCSSToReactNative会把px转没掉,我们无法区分flex:1这种属性和fontSize:28的区别,但是它不会吧pt转没,而是变成fontSize:"28pt".


为了使vhvw这两个单位能够生效,我们使用vhAndVwAdaption方法做了处理,width:100vw最后会变成width:100/100 * DEVICE_WIDTH,其中DEVICE_WIDTH就是我们前面注入的设备宽度这个变量。


九、referenceError:'xx' is not defined 报错


const Button=(props)=>{ 
const {style}=props;
return
}
const Page=()=>{
return
}

//以上写法会导致报referenceError:'xx' is not defined,而且是非必现,偶尔会报
//要这样写
const Button=(props)=>{
const {style}=props;
return
}
const Page=()=>{
return
}

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

收起阅读 »

React Native JSI:将BridgeModule转换为JSIModule

我们原有的项目中有大量的使用OC或者Java编写的原生模块,其中的一些可以使用C++重写,但大多数模块使用了平台特有的API和SDK,他们没有对应的C++实现。 在本文中,将带领大家如何将原有的模块转化为JSI模块。本文不再讲解基础概念,如果你有不明白的地方请...
继续阅读 »

我们原有的项目中有大量的使用OC或者Java编写的原生模块,其中的一些可以使用C++重写,但大多数模块使用了平台特有的API和SDK,他们没有对应的C++实现。


在本文中,将带领大家如何将原有的模块转化为JSI模块。本文不再讲解基础概念,如果你有不明白的地方请参考上一篇文章


使用JSI实现js与原生交互

上图描述了两端是如何进行交互的,这里面没有了React Native 的 Bridge,而是使用了C++作为中介。



  1. 在iOS端可以很简单的实现,因为OC和C++可以混编。

  2. 在Android端要麻烦一些,需要通过JNI进行C++ 与 Java的交互。


iOS端实现


首先我们在SimpleJsi.mm 中增加 getModelsetItemgetItem 用以模拟原生模块。这些方法都使用到了平台特有的API。


- (NSString *)getModel {

struct utsname systemInfo;

uname(&systemInfo);

return [NSString stringWithCString:systemInfo.machine
encoding:NSUTF8StringEncoding];
}

- (void)setItem:(NSString *)key :(NSString *)value {

NSUserDefaults *standardUserDefaults = [NSUserDefaults standardUserDefaults];

[standardUserDefaults setObject:value forKey:key];

[standardUserDefaults synchronize];
}

- (NSString *)getItem:(NSString *)key {

NSUserDefaults *standardUserDefaults = [NSUserDefaults standardUserDefaults];

return [standardUserDefaults stringForKey:key];
}


接下来我们需要实现一个新的install方法:


static void install(facebook::jsi::Runtime &jsiRuntime, SimpleJsi *simpleJsi) {

auto getDeviceName = Function::createFromHostFunction(
jsiRuntime, PropNameID::forAscii(jsiRuntime, "getDeviceName"), 0,
[simpleJsi](Runtime &runtime, const Value &thisValue,
const Value *arguments, size_t count) -> Value {

facebook::jsi::String deviceName =
convertNSStringToJSIString(runtime, [simpleJsi getModel]);

return Value(runtime, deviceName);
});
jsiRuntime.global().setProperty(jsiRuntime, "getDeviceName", move(getDeviceName));
}

这个方法接收两个参数。其中SimpleJsi 用来调用 getModel 方法。这个方法的返回值是NSString。我们需要将其转化为JSI认识的String类型。这里我们使用了convertNSStringToJSIString 方法。这个放开来自开源代码YeetJSIUtils


然后,我们在修改RN端,修改APP.js


const press = () => {
// setResult(global.multiply(2, 2));
// global.multiplyWithCallback(4, 5, alertResult);
alert(global.getDeviceName());
};

执行结果。


执行结果

同理,我们适配一下其他两个方法。


关键的地方还是参数的获取与转换。


auto setItem = Function::createFromHostFunction(
jsiRuntime, PropNameID::forAscii(jsiRuntime, "setItem"), 2,
[simpleJsi](Runtime &runtime, const Value &thisValue,
const Value *arguments, size_t count) -> Value {
NSString *key =
convertJSIStringToNSString(runtime, arguments[0].getString(runtime));
NSString *value =
convertJSIStringToNSString(runtime, arguments[1].getString(runtime));

[simpleJsi setItem:key :value];

return Value(true);
});
jsiRuntime.global().setProperty(jsiRuntime, "setItem", move(setItem));


auto getItem = Function::createFromHostFunction(
jsiRuntime, PropNameID::forAscii(jsiRuntime, "getItem"), 0,
[simpleJsi](Runtime &runtime, const Value &thisValue,
const Value *arguments, size_t count) -> Value {

NSString *key =
convertJSIStringToNSString(runtime, arguments[0].getString(runtime));
facebook::jsi::String value =
convertNSStringToJSIString(runtime, [simpleJsi getItem:key]);

return Value(runtime, value);
});
jsiRuntime.global().setProperty(jsiRuntime, "getItem", move(getItem));

修改App.js


const press = () => {
global.setItem('RiverLi', '大前端');
setTimeout(() => {
alert(global.getItem('RiverLi'));
}, 300);
};

执行结果


image-20210816113702360

总结


使用JSI进行moudle开发虽然看着有些复杂,但还是值得我们花时间去研究的。因为它的性能是最佳的,没有不必要的转换,所有的操作都是那么直接的发生在一层上。





作者:RiverLi
链接:https://juejin.cn/post/6999799689155444773

收起阅读 »

聊聊 RN 中 Android 提供 View 的那些坑

最近笔者研究 Android 中使用自定义 View 提供原生组件给 React Native(下面统一写成 RN ) 端的时候,遇到一些实际问题,在这里从 RN 的一些...
继续阅读 »


最近笔者研究 Android 中使用自定义 View 提供原生组件给 React Native(下面统一写成 RN ) 端的时候,遇到一些实际问题,在这里从 RN 的一些工作机制入手,分享一下问题的原因和解决方案。

自定义 View 内容不生效

原因

在给 RN 提供自定义 View 的时候发现自定义 View 内部很多 UI 逻辑没有生效。
例如下图,根据逻辑隐藏/展示了一些控件,但是应显示控件的位置没有变化。被隐藏控件的位置还是空出来的。很明显整个自定义 View 的 requestLayout 没有执行。


问题的答案就在 RN 根布局 ReactRootView 的 measure 方法里面。


在这个View的测量过程中,会判断 measureSpec 是否有更新。


当 measureSpec 有变化,或者宽高有变化的时候,才会触发 updateRootLayoutSpecs 的逻辑。
继续看下 updateRootLayoutSpecs 里做了一些什么事情,跟着源码最后会执行到 UIImplementation 的 dispatchViewUpdates 方法:


最终执行:


这里会从根节点往下一直更新子 View ,执行 View的 measure 和 layout
所以 ReactRootView 在宽高和测量模式都没有变化的情况下,就相当于把子 View 发出的 requestLayout 请求都拦截了。

解决方案

知道了原因就非常好解决了,既然你不让我通知我的根控件需要重新布局,那我就自己给自己重新布局好了。参考了 RN 一些自带的自定义 View 的实现,我们可以在这个自定义 View 重新布局的时候,注册一个 FrameCallback 去执行自己的 measure 和 layout 方法。

RN 自定义View 必须在JS端设置宽高

实现了自定义 View 之后,在 JSX 里面指定标签之后,会发现这个原生组件并没有显示。通过 IDE 的 Layout Inspect 可以发现此时这个自定义 View 的 width 和 height 都是 0 。如果设置了 width 和height 的话就可以展示了。
这时候就很奇怪了, 为什么我的自定义 View 里面的内容明明是 WRAP_CONTENT 的,很多自定义 View 又是直接继承的 ConstraintLayout 、 RelativeLayout 这种 Android 的 ViewGroup ,但还是要指定宽高才能在 RN 中渲染出来呢?
要解决这个疑惑,就需要了解一下 RN 的渲染流程。

RN 是怎么确定 Native View的宽高的

我们顺着 RN 更新 View 结构的 UIImplementation#updateViewHierarchy 方法,发现有两处关键的逻辑:


calculateRootLayout 中调用了 cssRoot 的布局计算逻辑:


接下来就是 applyUpdatesRecursive,顾名思义就是递归的更新根节点的所有子节点,在我们的场景中即整个页面的布局。


需要更新的节点则调用了 dispatchUpdates 方法,执行 enqueueUpdateLayout, 调用 NativeViewHierarchyManager#updateLayout 逻辑。


updateLayout 的核心流程如下:

  • 调用 resolveView 方法获取到真实的控件对象。
  • 调用这个控件的 measure 方法。


  • 调用updateLayout,执行这个控件的 layout方法



发现了没有?这里的 widthheight 已经是固定的值分别传给了 meausre 和 layout, 也就是说,这些 View 的宽高根本不是 Android 的绘制流程决定的,那么这个 width 和 height 的值是从哪里来的呢?
回头看看就发现了答案:


宽高是 lefttoprightbottom坐标相减得到的,而这些坐标则是通过
getLayoutWidth 和 getLayoutHeight 得到的:


而这个 layoutWidth 和 layoutHeight,则都是 Yoga 帮我们计算好,存放在 YogoNode里面的。
关于 Yoga

Yoga 是 Facebook 实现的一个高性能、易用、 Flex 的跨端布局引擎。
React Native 内部则是使用 Yoga 来布局的。
具体内容可以看 Yoga 的官网:https://yogalayout.com/

这里也就解释了为什么自定义 View 需要在 jsx 中指定了 width 和 height 才会渲染出来。因为这些自定义 View 原本在 Android系统的 measure layout 流程都已经被 RN 给控制住了。
这里可以总结成一句话:
RN 中最终渲染出来的控件的宽高,都由 Yoga 引擎来计算决定,系统自身的布局流程无法直接决定这些控件的宽高
但是这时候还是有一个疑问,为什么RN自己的一些组件,例如  ,没有指定
宽高也可以正常自适应显示呢?

为什么 RN 自己的 Text 是有自己的宽高的

我们来看一下RN是怎么定义渲染出来的 TextView 的,找到对应的 TextView 的 ViewManager,
com.facebook.react.views.text.ReactTextViewManager
我们关注两个方法:

  1. createViewInstance


  1. createShadowNodeInstance



其中,ReactTextView 其实就是实现了一个普通的 Android TextViewReactTextShadowNode 则表示了这个 TextView 对应的 YogaNode 的实现。


在它的实现中,我们可以看到一个成员变量,从名字上看是负责这个 YogaNode 的 measure 工作。


YogaNodeJNIBase 会调用这个JNI的方法,给JNI的逻辑注册这样一个回调函数。


这个 YogaMeasureFunction 的具体实现:


这里截个图,可以看到这里调用了 Android 中 Text 绘制的 API 来确定的文本的宽高。函数返回的是


这里是使用了 YogaMeasureOutput.make 把 Layout 算出来的宽高转成一定格式的二进制回调给 Yoga 引擎,这也是为什么 RN 自己的 Text 标签是可以自适应宽高展示的。
这里我们也可以得到一个结论:如果 Android 端封装的自定义 View 可以是确定宽高或者内部的控件是非常固定可以通过 measure 和 layout 就能算出宽高的,我们可以通过注册 measureFunction 回调的方式告诉 Yoga 我们 View 的宽高。
但是在实际业务中,我们很多业务组件是封装在 ConstraintLayout 、RelativeLayout 等 ViewGroup 中,所以我们还需要其他的方法来解决组件宽高设置的问题。

解决方案

那么这个问题可以重写 View 的 onMeasure 和 layout 方法来解决吗?看起来是这个做法是可以解决 View 宽高为 0 渲染不出来的问题。但是如果 jsx 这样描述布局的时候:


这时候 AndroidView 和 Text 会同时显示,并且 AndroidView 被 Text 遮住。
稍微思考一下就能得到原因:对于 Yoga 引擎来说,AndroidView 所代表的的节点仍然是没有宽高的,YogaNode 里面的 widthheight 仍然是 0,那么当重写 onMeasure 和 onLayout 的逻辑生效后,View 显示的左上方顶点是 (0,0) 的坐标。
而 Yoga 引擎自己计算出 Text 的宽高后, Text 的左上方顶点坐标肯定也是 (0,0) ,所以这时候2个 View 会显示在同一个位置(重叠或者覆盖)。
所以这时候问题就变成了,我们想通过 Android 自己的布局流程来确定并刷新这个自定义控件,但是 Yoga 引擎并不知道。
所以想要解决这个问题,可行的有两条路:

  • 改变 UI 层级和自定义 View 的粒度
  • Native 测量出实际需要的宽高后同步给Yoga 引擎
增加自定义控件的粒度

举一个自定义控件的例子:


我们希望把这个图上第一行的控件拆分成粒度较低的自定义 View 交给 RN 来布局实现布局动态配置的能力。但是这类场景的左右两边控件都是自适应宽度。这时候在 JS 端其实没有办法提供一个合适的宽度。考虑到更多场景下同一个方向轴上的自适应宽度控件是有位置上的依赖性的,所以可以不拆分这两个部分,直接都定义在同一个自定义 View 内:


提供给 JS 端使用,没有宽高的话,就把整个 SingHeaderView 的宽度设置成


这时候内部的两个控件会自己去进行布局。最终展示出来的就是左右都是 Wrap_Content 的。

Native 测量出实际需要的宽高后同步给Yoga引擎

但是控制自定义 View 的粒度的方式总归是不够灵活,开发的时候也往往会让人犹豫是否拆分。接着之前的内容,既然这个问题的矛盾点在于 Yoga 不知道 Android 可以自己再次调用 measure 来确定宽高,那如果能把最新的宽高传给 Yoga,不就可以解决我们的问题吗?
具体怎么触发 YogaNode 的刷新呢?通过阅读源码可以找到解决方法。在 UIManage里面,有一个叫做 updateNodeSize 的 api:


这个 api 会更新 View 对应的 cssNode 的大小,然后分发刷新 View 的逻辑。这个逻辑是需要保证在后台消息队列里面执行的,所以需要把这个刷新的消息发送到 nativeModulesQueueThread 里面去执行。
我们在 ViewManager 里面保存这个 Manager 对应的 View 和 ReactNodeImpl 实例。例如 Android 端封装了一个 LinearLayout , 对应的 node 是 MyLinearLayoutNode


重写自定义 View 的 onMeasure, 让自己是 wrap_content 的布局:


在 requestLayout 中根据自己真实的宽高布局并触发以下逻辑:




不过上面这个方案虽然可以解决 View 的 wrap_content 显示的问题,但是存在一些缺点:
刷新 YogaNode 实际是在 requestLayout 的时候触发的,这就相当于 requestLayout 这种比较耗费性能的操作会双倍的执行。对于一些可能会频繁触发 requestLayout 的业务场景来说需要慎重考虑。如果遇到这种场景,还是需要根据自己的需求来灵活选择解决方式。

收起阅读 »

ReactNative在游戏营销场景中的实践和探索-新架构介绍

客户端跨端框架已经发展了很多年了,最近比较流行的小程序、Flutter、ReactNative,都算是比较成功、成熟的框架,面向的开发者也不一样,很多大型App都广泛的使用了,笔者有幸很早就参与学习使用了这些优秀的跨端方案,在这几年的开发和架构设计中,除了在A...
继续阅读 »

客户端跨端框架已经发展了很多年了,最近比较流行的小程序、Flutter、ReactNative,都算是比较成功、成熟的框架,面向的开发者也不一样,很多大型App都广泛的使用了,笔者有幸很早就参与学习使用了这些优秀的跨端方案,在这几年的开发和架构设计中,除了在App中支撑了千万级DAU,也慢慢将ReactNative跨端方案运用到了游戏,来提升开发、迭代效率。本次文章我们会分5个章节介绍我们在游戏中的一些探索和实践,相信大家也能从中有所收获:

前面章节介绍了我们使用ReactNative在游戏中的一些实践,通过不断的迭代,我们完成了游戏平台的搭建,整体性能和稳定性已经达到了最优,算得上是一个比较成熟的平台了,当然该平台同样适用于现在的客户端开发,集成成本很低。但是框架本身的设计缺陷还是没有办法解决,在复杂的交互性很强的UI场景中,渲染瓶颈很明显,在游戏中也能深刻的体验到。


相信大家也看过我的另外一篇关于ReactNative架构重构的文章《庖丁解牛!深入剖析React Native下一代架构重构》,Facebook 在 2018 年 6 月官方宣布了大规模重构 React Native 的计划及重构路线图。目的是为了让 ReactNative 更加轻量化、更适应混合开发,接近甚至达到原生的体验。文章写的时间比较久了,笔者一直忙于其他事情,对于新进展更新较少,而且最初也只是初步分析了下Facebook的设计想法,经过这么久的迭代新架构有了很多进展,或者说无限接近正式release了,很值得和大家分享分享,这篇文章会向大家更深层次介绍新架构的现状和开发流程。


下面我们会从原理上简单介绍新架构带来的一些变化,下图是新老架构的变化对比:



相信大家也能从中发现一些区别,原有架构JS层与Native的通讯都过多的依赖bridge,而且是异步通讯,导致一些通讯频率较高的交互和设计就很难实现,同时也影响了渲染性能,而新架构正是从这点,对bridge这层做了大量的改造,使得UI和API调用,从原有异步方式,调整到可以同步或者异步与Native通讯,解决了需要频繁通讯的瓶颈问题。




  1. 旧架构设计




在了解新架构前,我们还是先聊下目前的ReactNative框架的主要工作原理,这样也方便大家了解整体架构设计,以及为什么facebook要重构整个框架:



  • ReactNative是采用前端的方式及UI渲染了原生的组件,他同时提供了API和UI组件,也方便开发者自己设计、扩展自己的API,提供了ReactContextBaseJavaModule、ViewGroupManager,其中ReactNative的UI是通过UIManger来管理的,其实在Android端就是UIManagerModule,原理上也是一个BaseJavaModule,和API共享一个native module。

  • ReactNative页面所有的API和UI组件都是通过ReactPackageManger来管理的,引擎初始化instanceManager过程中会读取注入的package,并根据名称生成对应的NativeModule和Views,这里还仅仅是Java层的,实际在C++层会对应生成JNativeModule

  • 切换到以上架构图的部分来看,Native Module的作用就是打通了前端到原生端的API调用,前端代码运行在JSC的环境中,采用C++实现,为了打通到native调用,需要在运行前注入到global环境中,前端通过global对象来操作proxy Native Module,继而执行了JNativeModule





  • 前端代码render生成UI diff树后,通过ReactNativeRenderer来完成对原生端的UIManager的调用,以下是具体的API,主要作用是通知原生端创建、更新View、批量管理组件、measure高度、宽度等:




  • 通过上述一系列的API操作后,会在原生端生成shadow tree,用来管理各个node的关系,这点和前端是一一对应的,然后待整体UI刷新后,更新这些UI组件到ReactRootView


通过上面的分析,不难发现现在的架构是强依赖nativemodule,也就是大家通常说的bridge,对于简单的Native API调用来说性能还能接受,而对于UI来说,每次的操作都是需要通过bridge的,包括高度计算、更新等,且bridge限制了调用频率、只允许异步操作,导致一些前端的更新很难及时反应到UI上,特别是类似于滑动、动画,更新频率较高的操作,所以经常能看到白屏或者卡顿。





  1. 新架构设计




旧的架构JS层与Native的通讯都太依赖bridge,导致一些通讯频率较高的交互和设计就很难实现,同时也影响了渲染性能,这就是Facebook这次重构的主要目标,在新的设计上,ReactNative提出了几个新的概念和设计:



  1. JSI(javascript interface):这是本次架构重构的核心重点,也正是因为这层的调整,将原有重度依赖的native bridge架构解耦,实现了自由通讯。

  2. Fabric:依赖JSI的设计,并将旧架构下的shadow tree层移到C++层,这样可以透过JSI,实现前端组件对UI组件的一对一控制,摆脱了旧架构下对于UI的异步、批量操作。

  3. TuborModule:新的原生API架构,替换了原有的java module架构,数据结构上除了支持基础类型外,开始支持JSI对象,让前端和客户端的API形成一对一的调用

  4. 社区化:在不断迭代中,facebook团队发现,开源社区提供的组件和API越来越多,而且很多组件设计和架构上比ReactNative要好,而且官方组件因为资源问题,投入度并不够,对于一些社区问题的反馈,响应和解决问题也不太及时。社区化后,大量的系统组件会开放到社区中,交个开发者维护,例如现在的webview组件


上面这些概念其实在架构图上已经体现了,主要用于替换原有的bridge设计,下面我们将重点剖析这些模块的原理和作用:


JSI :


JSI在0.60后的版本就已经开始支持,它是Facebook在js引擎上设计的一个适配架构,允许我们向 Javascript 运行时注册方法的 Javascript 接口,这些方法可通过 Javascript 世界中的全局对象获得,可以完全用 C++ 编写,也可以作为一种与 iOS 上的 Objective C 代码和 Android 中的 Java 代码进行通信的方式。任何当前使用Bridge在 Javascript 和原生端之间进行通信的原生模块都可以通过用 C++ 编写一个简单的层来转换为 JSI 模块



  • 标准化的JS引擎接口,ReactNative可以替换v8、Hermes等引擎。




  • 它是架起 JS 和原生 java 或者 Objc 的桥梁,类似于老的 JSBridge架构的作用,但是不同的是采用的是内存共享、代理类的方式,JS所有的运行环境都是在 JSRuntime 环境下的,为了实现和 native 端直接通讯,我们需要有一层 C++ 层实现的 JSI::HostObject,该数据结构只有 get、set 两个接口,通过 prop 来区分不同接口的调用。




  • 原有JS与Native的数据沟通,更多的是采用json和基础类型数据,但有了JSI后,数据类型更丰富,支持JSI object。


所以API调用流程: JS->JSI->C++->JNI->JAVA,每个API更加独立化,不再全部依赖native module,但这也带来了另外一个问题,相比以前的设计更复杂了,设计一个API,开发者需要封装JS、C++、JNI、Java等一套接口。当然Facebook早已经想到了这个问题,所以在设计JSI的时候,就提供了一个codegen模块,帮忙大家完成基础代码和环境的搭建,以下我们会简单为大家介绍怎么使用这些工具:



  1. Facebook提供了一个脚手架工程,方便大家创建Native Module 模块,需提前增加npx命令


npx create-react-native-library react-native-simple-jsi


前面的步骤更多的是在配置一些模块的信息,值得注意的是在选择模块的开发语言时要注意,这边是支持很多种类型的,针对原生端开发我们用Java&OC比较多,也可以选择纯JS 或者C++的类型,大家根据自己的实际情况来选择,完成后需要选择是UI模块还是API模块,这里我们选择API(Native Module)来做测试:



以上是完成后的目录结构,大家可以看到这是个完整的ReactNative App工程,相应的API需要开发者在对应的Android、iOS目录中开发。



下面我们看下C++ Moulde的模式,相比Java模式,多了cpp 模块,并在Moudle中以Native lib的方式加载so:





  1. 其实到这里我们还是没有创建JSI的模块,删掉删掉example目录后,运行下面命令,完成后在Android studio中导入 example/android,编译后app 工程,就能打包我们cpp目录下的C++文件到so


npx react-native init example
cd example
yarn add ../



  1. 到这里我们完成了C++库的打包,但是不是我们想要的JSI Module,需要修改Module模块,代码如下,从代码中我们可以看到,不再有reactmethod标记,而是直接的一些install方法,在这个JSI Module 创建的时候调用注入环境


public class NewswiperJsiModule extends ReactContextBaseJavaModule {
public static final String NAME = "NewswiperJsi";
public NewswiperJsiModule(ReactApplicationContext reactContext) {
super(reactContext);
}

@Override
@NonNull
public String getName() {
return NAME;
}

static {
try {
// Used to load the 'native-lib' library on application startup.
System.loadLibrary("cpp");
} catch (Exception ignored) {
}
}

private native void nativeInstall(long jsi);

public void installLib(JavaScriptContextHolder reactContext) {
if (reactContext.get() != 0) {
this.nativeInstall(
reactContext.get()
);
} else {
Log.e("SimpleJsiModule", "JSI Runtime is not available in debug mode");
}
}
}

public class SimpleJsiModulePackage implements JSIModulePackage {
@Override
public List<JSIModuleSpec> getJSIModules(ReactApplicationContext reactApplicationContext, JavaScriptContextHolder jsContext) {
reactApplicationContext.getNativeModule(SimpleJsiModule.class).installLib(jsContext);
return Collections.emptyList();
}
}


  1. 后面就是我们要创建JSI Object了,用来直接和JS通讯,主要是通过createFromHostFunction 来创建JSI的代理对象,并通过global().setProperty注入到JS运行环境


void install(Runtime &jsiRuntime) {
auto multiply = Function::createFromHostFunction(jsiRuntime,
PropNameID::forAscii(jsiRuntime,
"multiply"),
2,
[](Runtime &runtime,
const Value &thisValue,
const Value *arguments,
size_t count) -> Value {
int x = arguments[0].getNumber();
int y = arguments[1].getNumber();

return Value(x * y);

});

jsiRuntime.global().setProperty(jsiRuntime, "multiply", move(multiply));

global.multiply(2,4) // 8

到这里相信大家知道了怎么通过JSI完成JSIMoudle的搭建了,这也是我们TurboModule和Fabric设计的核心底层设计。


Fabric :


Fabric是新架构的UI框架,和原有UImanager框架是类似,前面章节也说明UIManager框架的一些问题,特别在渲染性能上的瓶颈,似乎基于原有架构已经很难再有优化,体验上与原生端组件和动画的渲染性能还是差距比较大的,举个比较常见的问题,Flatlist快速滑动的状态下,会存在很长的白屏时间,交互比较强的动画、手势很难支持,这也是此次架构升级的重点,下面我们也从原理上简单说明下新架构的特点:



  1. JS层新设计了FabricUIManager,目的是支持Fabric render完成组件的更新,它采用了JSI的设计,可以和cpp层沟通,对应C++层UIManagerBinding,其实每个操作和API调用都有对应创建了不同的JSI,从这里就彻底解除了原有的全部依赖UIManager单个Native bridge的问题,同时组件大小的measure也摆脱了对Java、bridge的依赖,直接在C++层shadow完成,提升渲染效率


export type Spec = {|
+createNode: (
reactTag: number,
viewName: string,
rootTag: RootTag,
props: NodeProps,
instanceHandle: InstanceHandle,
) => Node,
+cloneNode: (node: Node) => Node,
+cloneNodeWithNewChildren: (node: Node) => Node,
+cloneNodeWithNewProps: (node: Node, newProps: NodeProps) => Node,
+cloneNodeWithNewChildrenAndProps: (node: Node, newProps: NodeProps) => Node,
+createChildSet: (rootTag: RootTag) => NodeSet,
+appendChild: (parentNode: Node, child: Node) => Node,
+appendChildToSet: (childSet: NodeSet, child: Node) => void,
+completeRoot: (rootTag: RootTag, childSet: NodeSet) => void,
+measure: (node: Node, callback: MeasureOnSuccessCallback) => void,
+measureInWindow: (
node: Node,
callback: MeasureInWindowOnSuccessCallback,
) => void,
+measureLayout: (
node: Node,
relativeNode: Node,
onFail: () => void,
onSuccess: MeasureLayoutOnSuccessCallback,
) => void,
+configureNextLayoutAnimation: (
config: LayoutAnimationConfig,
callback: () => void, // check what is returned here
// This error isn't currently called anywhere, so the `error` object is really not defined
// $FlowFixMe[unclear-type]
errorCallback: (error: Object) => void,
) => void,
+sendAccessibilityEvent: (node: Node, eventType: string) => void,
|};

const FabricUIManager: ?Spec = global.nativeFabricUIManager;

module.exports = FabricUIManager;

if (methodName == "createNode") {
return jsi::Function::createFromHostFunction(
runtime,
name,
5,
[uiManager](
jsi::Runtime &runtime,
jsi::Value const &thisValue,
jsi::Value const *arguments,
size_t count) noexcept -> jsi::Value {
auto eventTarget =
eventTargetFromValue(runtime, arguments[4], arguments[0]);
if (!eventTarget) {
react_native_assert(false);
return jsi::Value::undefined();
}
return valueFromShadowNode(
runtime,
uiManager->createNode(
tagFromValue(arguments[0]),
stringFromValue(runtime, arguments[1]),
surfaceIdFromValue(runtime, arguments[2]),
RawProps(runtime, arguments[3]),
eventTarget));
});
}


  1. 有了JSI后,以前批量依赖bridge的UI操作,都可以同步的执行到c++层,而在c++层,新架构完成了一个shadow层的搭建,而旧架构是在java层实现,以下也重点说明下几个重要的设计:



  • FabricUIManager (JS,Java) ,JS 端和原生端 UI 管理模块。




  • UIManager/UIManagerBinding(C++),C++中用来管理UI的模块,并通过binding JNI的方式通过FabricUIManager(Java)管理原生端组件




  • ComponentDescriptor (C++) ,原生端组件的唯一描述及组件属性定义,并注册在CoreComponentsRegistry模块中




  • Platform-specific




  • Component Impl (Java,ObjC++),原生端组件Surface,通过FabricUIManager来管理




  1. 新架构下,开发一个原生组件,需要完成Java层的原生组件及ComponentDescriptor (C++) 开发,难度相较于原有的viewManager有所提升,但ComponentDescriptor本身很多是shadow层代码,比较固定,Facebook后续也会提供codegen工具,帮助大家完成这部分代码的自动生成,简化代码难度



TurboModule:


实际上0.64版本已经支持TurboModule,在分析它的设计原理前,我们先说明下设计这个模块的目的,从上面架构图来看,主要用来替换NativeModule的重要一环:



  1. NativeModule 会包含很多我们初始化过程中就需要注册的的API,随着开发迭代,依赖NativeMoude的API和package会越来越多,解析及校验这些pakcages的时间会越来越长,最终会影响TTI时长

  2. 另外Native module其实大部分都是提供API服务,其实是可以采用单例子模式运行的,而不用跟随bridge的关闭打开,创建很多次


TurboModule的设计就是为了解决这些问题,原理上还是采用JSI提供的能力,方便JS可以直接调用到c++ 的host object,下面我们从代码层简单分析原理:



上面代码就是目前项目里面给出的一个例子,通过实现TurboModule来完NativeModule的开发,其实代码流程和原有的BaseJavaModule大致是一样的,不同的是底层的实现:



  1. 现有版本可以通过 ReactFeatureFlags.useTurboModules来打开这个模块功能

  2. TurboModule 组件是通过TurboModuleManager.java来管理的,被注入的modules可以分为初始化加载的和非初始化加载的组件

  3. 同样JNI/C++层也有一层TurboModuleManager用来管理注册java/C++的module,并通过TurboModuleBinding C++层的proxy moudle注入到JS层,到这里基本就和上面说的基础架构JSI接上轨了,js中可以通过代理的__turboModuleProxy来完成c++层的module调用,c++层透过jni最终完成对java代码的执行,这里facebook设计了两种类型的moudles,longLivedObject 和 非常驻的,设计思路上就和我们上面要解决的问题吻合了


void TurboModuleBinding::install(
jsi::Runtime &runtime,
const TurboModuleProviderFunctionType &&moduleProvider) {
runtime.global().setProperty(
runtime,
"__turboModuleProxy",
jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "__turboModuleProxy"),
1,

// Create a TurboModuleBinding that uses the global
// LongLivedObjectCollection
[binding =
std::make_shared<TurboModuleBinding>(std::move(moduleProvider))](
jsi::Runtime &rt,
const jsi::Value &thisVal,
const jsi::Value *args,
size_t count) {
return binding->jsProxy(rt, thisVal, args, count);
}));
}

const NativeModules = require('../BatchedBridge/NativeModules');
import type {TurboModule} from './RCTExport';
import invariant from 'invariant';

const turboModuleProxy = global.__turboModuleProxy;

function requireModule<T: TurboModule>(name: string): ?T {
// Bridgeless mode requires TurboModules
if (!global.RN$Bridgeless) {
// Backward compatibility layer during migration.
const legacyModule = NativeModules[name];
if (legacyModule != null) {
return ((legacyModule: $FlowFixMe): T);
}
}

if (turboModuleProxy != null) {
const module: ?T = turboModuleProxy(name);
return module;
}

return null;
}

CodeGen:



  1. 新架构UI增加了C++层的shadow、component层,而且大部分组件都是基于JSI,因而开发UI组件和API的流程更复杂了,要求开发者具有c++、JNI的编程能力,为了方便开发者快速开发Facebook也提供了codegen工具,帮助生成一些自动化的代码,具体工具参看:github.com/facebook/re…

  2. 以下是代码生成的大概流程,因codegen目前还没有正式release,关于如何使用的文档几乎没有,但也有开发者尝试使用生成了一些代码,可以参考github.com/karol-biszt…





  1. 总结:




上面我们从API、UI角度重新学习了新架构,JSI、Turbormodule已经在最新的版本上已经可以体验,而且开发者社区也用JSI开发了大量的API组件,例如以下的一些比较依赖C++实现的模块:

























从最新的代码结构来看,新架构离发布似乎已经进入倒计时了,作为一直潜心学习、研究ReactNative的开发者相信一定和我一样很期待,从Facebook官方了解到Facebook App已经采用了新的架构,预计今年应该就能正式release了,这一次我们可以相信ReactNative应该要正式进入1.0版本了吧,reactnative.dev/blog/2021/0…





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

收起阅读 »

ReactNative在游戏营销场景中的实践和探索-性能优化

客户端跨端框架已经发展了很多年了,最近比较流行的小程序、Flutter、ReactNative,都算是比较成功、成熟的框架,面向的开发者也不一样,很多大型App都广泛的使用了,笔者有幸很早就参与学习使用了这些优秀的跨端方案,在这几年的开发和架构设计中,除了在A...
继续阅读 »

客户端跨端框架已经发展了很多年了,最近比较流行的小程序、Flutter、ReactNative,都算是比较成功、成熟的框架,面向的开发者也不一样,很多大型App都广泛的使用了,笔者有幸很早就参与学习使用了这些优秀的跨端方案,在这几年的开发和架构设计中,除了在App中支撑了千万级DAU,也慢慢将ReactNative跨端方案运用到了游戏,来提升开发、迭代效率。本次文章我们会分5个章节介绍我们在游戏中的一些探索和实践,相信大家也能从中有所收获:

(随着版本不断迭代完善,基本具有大量上线游戏的能力,随着游戏业务越来越多,在不同的游戏环境中,也碰到不少问题,这也从侧面体现出了游戏场景和架构的复杂性,主要核心问题还是在于ReactNative的沉浸式体验、启动性能、内存、渲染性能问题等,似乎这些问题也是ReactNative的通病,为了解决这些问题,我们开始专项优化。


1. 启动性能优化


针对启动性能问题,我们也测试列大量数据,ReactNative在纯客户端App中,性能表现还算不错,但在游戏低内存、cpu过度占用的情况下,该问题显得格外突出,要解决这些问题,首先我们需要了解ReactNative加载的主要时间消耗,可以参考下图:



整体页面渲染显示前,需要首先加载加载初始化 React Native Core Bridge,主要包含ReactNative的运行环境、UI和API组件功能等,然后才能运行业务的 JS,执行render绘制UI,完成后,React Native 才能将 JS 的组件渲染成原生的组件。因页面的加载流程是固定不变的,所以我们可以采用了提前预加载Core bridge的方案来提升加载性能,当游戏营销页面启动前,预先加载好原生端bridge,这样在打开业务是指需要运行前端JS代码渲染,设计思路上我们也根据业务场景设计了模式:



  • 预加载业务包:提前加载好完整的业务包到内存,生成并缓存ReactInstanceManager对象,在业务启动时,从内存缓存中获取该对象,并直接运行绑定rootview,经过改造,该方案能提升整体的打开速度30%-50%左右,游戏环境下,手机设备基本都达到秒开,模拟器设备在2s内,但这种通过内存换取速度的方法,在业务量大后,很明显是不可取的,所以整包预加载的局限性比较强。




  • Common包预加载:针对全包预加载的局限性,我们提出了分包方案,预加载common包,研究发现ReactNative打包生成的业务包其实有两部分内容,一部分是公共的基础组件、API包,统称common包,一部分是业务的核心逻辑包。改造打包方式,可以把原有的全包模式分离成common+bussiness,在多业务包模式下,可以共享统一的common包,在打开业务前,我们会优先预加载common包,并缓存对应的ReactInstanceManager对象,用户触发打开业务后,再加载bussiness 包,该方案相对于全包预加载性能略差,但比不预加载能提升15%-20%左右,同时支持多业务运行环境,具体思路可以参考开源项目react-native-multibundler




  • 从时序运行上,除了core bridge的初始化外,js 运行到页面显示,实际上也占用了不少时间,在预加载core bridge上,我们更近一步,支持了预加载rootview,提前将要渲染页面的rootview运行起来缓存在内存,当然这里加载的还是基础模块,在业务打开时,路由触发展示页面即可,可以做到页面无延时打开,但是对内存的开销,比预加载core bridge 更高。


当然上述方案都是通过内存换性能,不同的加载方式都做到了云控,随时切换、关闭。除了这些方案外同样还有其他方式能优化启动性能:



  1. Lazy module,将引擎自定义的API Native Module改造成懒加载方式,整体性能提升在5% 左右。

  2. 业务代码做到按需require,不需要展示的部分,采用lazy require,提升页面的显示、渲染速度。

  3. 裁剪业务包,将业务代码没有用到React的module、API、组件删除,减少业务包大小来提升启动性能。

  4. 分包方案,从测试数据来看,业务包越小,启动性能越好,包大小无法减小后,将业务包按照路由拆分为子包,也能立竿见影的解决启动速度问题。将业务包按照路由页面和功能分成多个子的业务子包,让首屏业务逻辑包变小,做到按需加载其他业务包,提升首页启动性能。


这些方案都从引擎加载的角度解决了启动性能慢,做到了按需加载,整体性能达到了最优化。但是在游戏中,业务页面的显示还是太依赖服务度请求来完成页面的渲染,所以在逐步优化后,发现网络请求对于页面的显示也占了很大一部分,为了进一步提升首屏显示,我么增加了网络请求预拉取、图片预缓冲方案:



  1. 网络预拉取,对于一些对首屏显示影响较大的网络请求,在引擎加载后,在合适时机从云控平台获取后,根据配置拉取并缓存到内存,打开业务后,优先从缓存中读取网络接口内容并显示。

  2. 图片预缓存,对于一些加载较慢的图片,将链接配置到云端后,在合适时机提前预加载到Fresco内存,页面打开后Fresco会从缓存中直接读取bitmap


除了这些方案外,替换JSC引擎到hermes,也能很好的解决启动性能问题,后面章节会重点介绍。


2. 内存优化


以上所有的优化更多是针对启动性能的优化设计,也是业内用于提升加载性能的方案,在游戏的复杂环境下,除了性能外,对于内存的要求也是很严格的,游戏启动后,本身对于内存的消耗就比一般的原生app高,所以在内存使用上会更精确和严格,那ReactNative是怎么优化内存的:

分包方案,分包方案除了在启动速度上有很大优化外,实现了按需加载,对于内存来说也做到了最优化。

字体加载,因游戏字体库无法和原生字体共享,导致在ReactNative页面使用字体会大大增加整体的内存,为了降低字体的内存,我们支持了字体的裁剪方案,按需打入字体,删掉一些生僻的字,大大降低了字体包的大小。另外字体文件对于业务包大小影响也比较大,我们支持字体的动态下发和加载。

图片优化,除了业务UI和JS本身占用的内存外,内存上占用比较大的是图片,而且图片有缓存,为了降低图片的内存消耗,我们支持了webp、gif等格式的图片,有损压缩,同时对于网络图片做到了按手机分辨率下发。另外提供API到前端业务,按需清理不使用的图片,及时释放内存,并控制图片缓存大小。


3. 渲染性能


除了内存、启动性能外,在游戏中的渲染性能也至关重要,ReactNative受限于游戏内的内存和CPU负载高,同等复杂度页面,表现不如原生App。为了能优化这些指标,我们对ReactNative的渲染流程做了分析和优化,支持静止状态下帧率基本达到了60fps,大致优化如下:

ReactNative是前端事件驱动原生UI渲染的,所以设计上ReactNative会在Frame Buffer每一帧绘画结束后的回调在UI线程中处理UI更新,即使没有更新的情况下也会空运转,这在UI线程负载本就较高的游戏中,增加了UI的负担

动画、点击事件都是同样的设计,会不断的有任务空转占用UI线程,增加了UI线程每次绘制的时间

解决这个问题,就是要支持资源的按需加载,我们将动画、UI更新事件放到了消息map,每次一帧渲染完成后,我们会检查map消息,是否有需要处理的消息,没有后续就不再在一帧渲染完成后调度UI线程,当用户触发了动画或者UI更新,会发送消息map,并注册帧渲染的callback,在callback中检查map消息更新UI


另外ReactNative采用的是原生UI渲染,在打开硬件加速的情况,整体渲染性能表现比较高,但是在游戏环境中,大部分游戏都是不开硬件加速的(自渲染组件和引擎的缘故),对于比较复杂的ReactNative UI,更新UI时整体FPS会偏低,UI响应会比较慢,特别是在模拟器(限制fps30)的情况下,渲染性能更加差强人意。在复杂交互的情况,要怎么提升性能?

简单的UI设计,没有大图背景的情况下,不开硬件加速,整体渲染性还不算差,但有大的背景情况下,UI性能表现尤其差,所以解决渲染问题,其实更多的是要解决大图渲染的问题

ReactNative 提供了renderToHardwareTextureAndroid 来用native内存换渲染的性能,导致的问题是内存消耗较高,对于图片不是太多、内存限制不是很严格的业务,可以采用该方式提升性能

对于大量使用图片的业务,我们设计一套采用opengl渲染方式的组件,支持纹理图(比较通用的etc1),从内存和渲染性能上,明显都得到了很大的提升,但这种模式依赖硬件加速,所以一般是在Dialog窗口模式中使用,具体的实现原理,大家可以关注作者文章,后面会详细和大家分享


核心示例代码:


 /* GLES20.glCompressedTexImage2D(target, 0, ETC1.ETC1_RGB8_OES , bitmap.getWidth(), bitmap.getHeight(), 0, etc1tex.getData().capacity(), etc1tex.getData());*/

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

收起阅读 »

ReactNative——react-native-video实现视频全屏播放

react-native-video是github上一个专用于React Native做视频播放的组件。这个组件是React Native上功能最全最好用的视频播放组件,还在持续开发之中,虽然还有些bug,但基本不影响使用,强力推荐。 本篇文章主要介绍下怎么使...
继续阅读 »

react-native-video是github上一个专用于React Native做视频播放的组件。这个组件是React Native上功能最全最好用的视频播放组件,还在持续开发之中,虽然还有些bug,但基本不影响使用,强力推荐。


本篇文章主要介绍下怎么使用react-native-video播放视频,以及如何实现全屏播放,屏幕旋转时视频播放器大小随之调整,显示全屏或收起全屏。


首先来看看react-native-video有哪些功能。


基本功能



  1. 控制播放速率

  2. 控制音量大小

  3. 支持静音功能

  4. 支持播放和暂停

  5. 支持后台音频播放

  6. 支持定制样式,比如设置宽高

  7. 丰富的事件调用,如onLoad,onEnd,onProgress,onBuffer等等,可以通过对应的事件进行UI上的定制处理,如onBuffer时我们可以显示一个进度条提示用户视频正在缓冲。

  8. 支持全屏播放,使用presentFullscreenPlayer方法。这个方法在iOS上可行,在android上不起作用。参看issue#534,#726也是同样的问题。

  9. 支持跳转进度,使用seek方法跳转到指定的地方进行播放

  10. 可以加载远程视频地址进行播放,也可以加载RN本地存放的视频。


注意事项


react-native-video通过source属性设置视频,播放远程视频时使用uri来设置视频地址,如下:


source={{uri: "http://www.xxx.com/xxx/xxx/xxx.mp4"}}

播放本地视频时,使用方式如下:


source={require('../assets/video/turntable.mp4')}

需要注意的是,source属性不能为空,uri或本地资源是必须要设置的,否则会导致app闪退。uri不能设置为空字符串,必须是一个具体的地址。


安装配置


使用npm i -S react-native-videoyarn add react-native-video安装,完成之后使用react-native link react-native-video命令link这个库。


Android端在执行完link命令后,gradle中就已经完成了配置。iOS端还需要手动配置一下,这里简单说一下,与官方说明不同的是,我们一般不使用tvOS的,选中你自己的target,在build phases中先移除掉自动link进来的libRCTVideo.a这个库,然后点击下方加号重新添加libRCTVideo.a,注意不要选错。


视频播放


实现视频播放其实很简单,我们只需要给Video组件设置一下source资源,然后设置style调整Video组件宽高就行了。



    ref={(ref) => this.videoPlayer = ref}
source={{uri: this.state.videoUrl}}
rate={1.0}
volume={1.0}
muted={false}
resizeMode={'cover'}
playWhenInactive={false}
playInBackground={false}
ignoreSilentSwitch={'ignore'}
progressUpdateInterval={250.0}
style={{width: this.state.videoWidth, height: this.state.videoHeight}}
/>

其中videoUrl是我们用来设置视频地址的变量,videoWidth和videoHeight是用来控制视频宽高的。


全屏播放的实现


视频全屏播放其实就是在横屏情况下全屏播放,竖屏一般都是非全屏的。要实现设备横屏时视频全屏显示,说起来很简单,就是通过改变Video组件宽高来实现。


上面我们把videoWidth和videoHeight存放在state中,目的就是为了通过改变两个变量的值来刷新UI,使视频宽高能随之改变。问题是,怎样在设备的屏幕旋转时及时获取到改变后的宽高呢?


竖屏时我设置的视频初始宽度为设备屏幕的宽度,高度为宽度的9/16,即按16:9的比例显示。横屏时视频的宽度应为屏幕的宽度,高度应为当前屏幕的高度。由于横屏时设备宽高发生了变化,及时获取到宽高就能及时刷新UI,视频就能全屏展示了。


刚开始我想到的办法是使用react-native-orientation监听设备转屏的事件,在回调方法中判断当前是横屏还是竖屏,这个在iOS上是可行的,但是在Android上横屏和竖屏时获取到宽高值总是不匹配的(比如,横屏宽384高582,竖屏宽582高384,显然不合理),这样就无法做到统一处理。


所以,监听转屏的方案是不行的,不仅费时还得不到想要的结果。更好的方案是在render函数中使用View作为最底层容器,给它设置一个"flex:1"的样式,使其充满屏幕,在View的onLayout方法中获取它的宽高。无论屏幕怎么旋转,onLayout都可以获取到当前View的宽高和x、y坐标。


/// 屏幕旋转时宽高会发生变化,可以在onLayout的方法中做处理,比监听屏幕旋转更加及时获取宽高变化
_onLayout = (event) => {
//获取根View的宽高
let {width, height} = event.nativeEvent.layout;
console.log('通过onLayout得到的宽度:' + width);
console.log('通过onLayout得到的高度:' + height);

// 一般设备横屏下都是宽大于高,这里可以用这个来判断横竖屏
let isLandscape = (width > height);
if (isLandscape){
this.setState({
videoWidth: width,
videoHeight: height,
isFullScreen: true,
})
} else {
this.setState({
videoWidth: width,
videoHeight: width * 9/16,
isFullScreen: false,
})
}
};

这样就实现了屏幕在旋转时视频也随之改变大小,横屏时全屏播放,竖屏回归正常播放。注意,Android和iOS需要配置转屏功能才能使界面自动旋转,请自行查阅相关配置方法。


播放控制


上面实现了全屏播放还不够,我们还需要一个工具栏来控制视频的播放,比如显示进度,播放暂停和全屏按钮。具体思路如下:



  1. 使用一个View将Video组件包裹起来,View的宽高和Video一致,便于转屏时改变大小

  2. 设置一个透明的遮罩层覆盖在Video组件上,点击遮罩层显示或隐藏工具栏

  3. 工具栏中要显示播放按钮、进度条、全屏按钮、当前播放时间、视频总时长。工具栏以绝对位置布局,覆盖在Video组件底部

  4. 使用react-native-orientation中的lockToPortrait和lockToLandscape方法强制旋转屏幕,使用unlockAllOrientations在屏幕旋转以后撤销转屏限制。


这样才算是一个有模有样的视频播放器。下面是竖屏和横屏的效果图




再也不必为presentFullscreenPlayer方法不起作用而烦恼了,全屏播放实现起来其实很简单。具体代码请看demo:github.com/mrarronz/re…


总结



  1. react-native-orientation和react-native-video都还有缺陷,但是已经可以运用到项目中了

  2. 有时候解决问题要换种思路,不能一棵树上吊死。坐下来喝杯茶,换种心态、换个搜索关键词说不定就得到了你想要的答案。

作者:不變旋律
链接:https://juejin.cn/post/6844903570999869448

收起阅读 »

面试常问的ACTION_CANCEL到底何时触发,滑出子View范围会发生什么?

看完本文你将了解:ACTION_CANCEL的触发时机滑出子View区域会发生什么?为什么不响应onClick()事件首先看一下官方的解释:/** * Constant for {@link #getActionMasked}: The current ge...
继续阅读 »

看完本文你将了解:

  • ACTION_CANCEL的触发时机
  • 滑出子View区域会发生什么?为什么不响应onClick()事件

首先看一下官方的解释:

/**
* Constant for {@link #getActionMasked}: The current gesture has been aborted.
* You will not receive any more points in it. You should treat this as
* an up event, but not perform any action that you normally would.
*/

public static final int ACTION_CANCEL = 3;

说人话就是:当前的手势被中止了,你不会再收到任何事件了,你可以把它当做一个ACTION_UP事件,但是不要执行正常情况下的逻辑。

ACTION_CANCEL的触发时机

有四种情况会触发ACTION_CANCEL:

  • 在子View处理事件的过程中,父View对事件拦截
  • ACTION_DOWN初始化操作
  • 在子View处理事件的过程中被从父View中移除时
  • 子View被设置了PFLAG_CANCEL_NEXT_UP_EVENT标记时
1,父view拦截事件

首先要了解ViewGroup什么情况下会拦截事件,Look the Fuck Resource Code:

/**
* {@inheritDoc}
*/

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...

boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
...
// Check for interception.
final boolean intercepted;
// 判断条件一
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
// 判断条件二
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
...
}
...
}

有两个条件

  • MotionEvent.ACTION_DOWN事件或者mFirstTouchTarget非空也就是有子view在处理事件
  • 子view没有做拦截,也就是没有调用ViewParent#requestDisallowInterceptTouchEvent(true)

如果满足上面的两个条件才会执行onInterceptTouchEvent(ev)

如果ViewGroup拦截了事件,则intercepted变量为true,接着往下看:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
...

// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
// 当mFirstTouchTarget != null,也就是子view处理了事件
// 此时如果父ViewGroup拦截了事件,intercepted==true
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}

...

// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
...
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
...
} else {
// 判断一:此时cancelChild == true
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;

// 判断二:给child发送cancel事件
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
...
}
...
}
}
...
}
...
return handled;
}

以上判断一处cancelChild为true,然后进入判断二中一看究竟:

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

// Canceling motions is a special case. We don't need to perform any transformations
// or filtering. The important part is the action, not the contents.
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
// 将event设置成ACTION_CANCEL
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
...
} else {
// 分发给child
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}
...
}

当参数cancel为ture时会将event设置为MotionEvent.ACTION_CANCEL,然后分发给child。

2,ACTION_DOWN初始化操作
public boolean dispatchTouchEvent(MotionEvent ev) {

boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;

// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
// 取消并清除所有的Touch目标
cancelAndClearTouchTargets(ev);
resetTouchState();
}
...
}
...
}

系统可能会由于App切换、ANR等原因丢失了up,cancel事件。

因此需要在ACTION_DOWN时丢弃掉所有前面的状态,具体代码如下:

private void cancelAndClearTouchTargets(MotionEvent event) {
if (mFirstTouchTarget != null) {
boolean syntheticEvent = false;
if (event == null) {
final long now = SystemClock.uptimeMillis();
event = MotionEvent.obtain(now, now,
MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
syntheticEvent = true;
}

for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
resetCancelNextUpFlag(target.child);
// 分发事件同情况一
dispatchTransformedTouchEvent(event, true, target.child, target.pointerIdBits);
}
...
}
}

PS:在dispatchDetachedFromWindow()中也会调用cancelAndClearTouchTargets()

3,在子View处理事件的过程中被从父View中移除时
public void removeView(View view) {
if (removeViewInternal(view)) {
requestLayout();
invalidate(true);
}
}

private boolean removeViewInternal(View view) {
final int index = indexOfChild(view);
if (index >= 0) {
removeViewInternal(index, view);
return true;
}
return false;
}

private void removeViewInternal(int index, View view) {

...
cancelTouchTarget(view);
...
}

private void cancelTouchTarget(View view) {
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (target.child == view) {
...
// 创建ACTION_CANCEL事件
MotionEvent event = MotionEvent.obtain(now, now,
MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
分发给目标view
view.dispatchTouchEvent(event);
event.recycle();
return;
}
predecessor = target;
target = next;
}
}
4,子View被设置了PFLAG_CANCEL_NEXT_UP_EVENT标记时

在情况一种的两个判断处:

// 判断一:此时cancelChild == true
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;

// 判断二:给child发送cancel事件
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}

resetCancelNextUpFlag(target.child)为true时同样也会导致cancel,查看代码:

/**
* Indicates whether the view is temporarily detached.
*
* @hide
*/

static final int PFLAG_CANCEL_NEXT_UP_EVENT = 0x04000000;

private static boolean resetCancelNextUpFlag(View view) {
if ((view.mPrivateFlags & PFLAG_CANCEL_NEXT_UP_EVENT) != 0) {
view.mPrivateFlags &= ~PFLAG_CANCEL_NEXT_UP_EVENT;
return true;
}
return false;
}

根据注释大概意思是,该view暂时detacheddetached是什么意思?就是和attached相反的那个,具体什么时候打了这个标记,我觉得没必要深究。

以上四种情况最重要的就是第一种,后面的只需了解即可。

滑出子View区域会发生什么?

了解了什么情况下会触发ACTION_CANCEL,那么针对问题:滑出子View区域会触发ACTION_CANCEL吗?这个问题就很明确了:不会。

实践是检验真理的唯一标准,代码撸起来:

public class MyButton extends androidx.appcompat.widget.AppCompatButton {

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
LogUtil.d("ACTION_DOWN");
break;
case MotionEvent.ACTION_MOVE:
LogUtil.d("ACTION_MOVE");
break;
case MotionEvent.ACTION_UP:
LogUtil.d("ACTION_UP");
break;
case MotionEvent.ACTION_CANCEL:
LogUtil.d("ACTION_CANCEL");
break;
}
return super.onTouchEvent(event);
}
}

一波操作以后日志如下:

(MyButton.java:32) -->ACTION_DOWN (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:39) -->ACTION_UP

滑出view后依然可以收到ACTION_MOVEACTION_UP事件。

为什么有人会认为滑出view后会收到ACTION_CANCEL呢?

我想是因为滑出view后,view的onClick()不会触发了,所以有人就以为是触发了ACTION_CANCEL

那么为什么滑出view后不会触发onClick呢?再来看看View的源码:

在view的onTouchEvent()中:

case MotionEvent.ACTION_MOVE:
// Be lenient about moving outside of buttons
// 判断是否超出view的边界
if (!pointInView(x, y, mTouchSlop)) {
// Outside button
if ((mPrivateFlags & PRESSED) != 0) {
// 这里改变状态为 not PRESSED
// Need to switch from pressed to not pressed
mPrivateFlags &= ~PRESSED;
}
}
break;

case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
// 可以看到当move出view范围后,这里走不进去了
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
...
performClick();
...
}
mIgnoreNextUpEvent = false;
break;

1,在ACTION_MOVE中会判断事件的位置是否超出view的边界,如果超出边界则将mPrivateFlags置为not PRESSED状态。

2,在ACTION_UP中判断只有当mPrivateFlags包含PRESSED状态时才会执行performClick()等。

因此滑出view后不会执行onClick()

结论:
  • 滑出view范围后,如果父view没有拦截事件,则会继续受到ACTION_MOVEACTION_UP等事件。
  • 一旦滑出view范围,view会被移除PRESSED标记,这个是不可逆的,然后在ACTION_UP中不会执行performClick()等逻辑。
收起阅读 »

Android 高仿支付宝手势密码

前言支付宝的手势密码 支持两种方式,第一种是进入app 时启动,第二种是进入理财时启动。实现1,我们先来分析下第一种方式,进入APP 时启动手势密码进入app 时启动手势密码,有一个关键的知识点,前后台切换,如何判断app 应用做了前后台切换了呢?(1) 使用...
继续阅读 »

前言

支付宝的手势密码 支持两种方式,第一种是进入app 时启动,第二种是进入理财时启动。

实现

1,我们先来分析下第一种方式,进入APP 时启动手势密码

进入app 时启动手势密码,有一个关键的知识点,前后台切换,如何判断app 应用做了前后台切换了呢?

(1) 使用ProcessLifecycleOmner

ProcessLifecycleOwner

该类提供了整个 app 进程的 lifecycle。

可以将其视为所有 activity 的 LifecycleOwner ,其中 Lifecycle.Event.ON_START 代表app 进入前台,而 Lifecycle.Event.ON_STOP 代表app 进入后台。当然(Lifecycle.Event.On_RESUME 和 Lifecycle.Event.ON_PAUSE 也可以分别代表进入前台和后台)。

ProcessLifecycleOwner.get().lifecycle.addObserver(object:LifecycleObserver{

@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onForeground(){
EasyLog.e(TAG,"== onForeground==")
}

@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onBackground(){
EasyLog.e(TAG,"== onBackground==")
}


});

ProcessLifecycle 能很好的监听前后台切换,但是 不太适合做手势密码的前后台切换,首先首页和登录页是不需要弹出手势密码的,这些页面要过滤,ProcessLifecycle 不好做到这一点。下面看第二种方法。

(2)使用 lifecycleCallbacks接口:

通过这个接口,我们对onActivityStart回调方法里记录启动的次数 mActivityCount++,onActivityStop 回调里对 mActivityCount-- ,当mActivityCount == 1 时认为在前台,mActivityCount ==0 在后台。代码如下:

 
/**
* 监听 前后台启动
* 自定义 可以很容易过滤一些不需要跳出手势密码的特殊的场景,比如 登录页
*/
class GestureLifecycleHandler constructor(context:Context): Application.ActivityLifecycleCallbacks {


companion object{
private const val TAG = "GestureLifecycleHandler"
}

private val uiScope = CoroutineScope(Dispatchers.Main)

private var isOpenHandLock = false
init {


}

/**
* 记录 activity 前后台情况
*/
private var mActivityCount: Int = 0

override fun onActivityPaused(activity: Activity?) {

}

override fun onActivityResumed(activity: Activity?) {



}

override fun onActivityStarted(activity: Activity?) {
if(activityFilter(activity)){
return
}

mActivityCount ++
EasyLog.e(TAG,"onForeground = $mActivityCount")
uiScope.launch {
withContext(Dispatchers.IO){
isOpenHandLock = GestureManager.getAppGestureState()
if(isOpenHandLock && mActivityCount == 1){
GestureActivity.actionStart(activity!!,GestureActivity.GestureState.Verify)
}
}

}

}

override fun onActivityDestroyed(activity: Activity?) {

}

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

}

override fun onActivityStopped(activity: Activity?) {
if(activityFilter(activity)){
return
}
mActivityCount--
EasyLog.e(TAG,"onBackground = $mActivityCount")

}

override fun onActivityCreated(activity: Activity?, savedInstanceState: Bundle?) {

}

private fun activityFilter(activity: Activity?):Boolean{
return activity is SplashActivity
}
}

202109030924010.gif

2,我们分析第二种方式,进入理财时弹出手势密码

理财模块是个fragment ,也就是说要对财富fragment 监听前后台的变化,这个时候可以使用ProcessLifecycleOwner 对Fragment监听,代码如下:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
ProcessLifecycleOwner.get().lifecycle.addObserver(GestureLife(this))
}
private const val TAG = "GestureLife"

open class GestureLife(val fragment: GestureLockFragment) :LifecycleObserver{


private val uiScope = CoroutineScope(Dispatchers.Main)


@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onVisible() {
EasyLog.e(TAG,"==ON_RESUME==")
if(fragment.isHidden){
EasyLog.e(TAG,"等待台跳出手势密码")
fragment.waitingGesture = true

}
if(!fragment.isHidden||fragment.isVisible){
EasyLog.e(TAG,"==isVisible==")
uiScope.launch {
withContext(Dispatchers.IO){
val isOpenHandLock = GestureManager.getFragmentGestureState()
if(isOpenHandLock && !GestureLockFragment.showGesture){
GestureLockFragment.showGesture = true
GestureActivity.actionStart(ActivityUtils.getTopActivity(),GestureActivity.GestureState.Verify)
}
}

}
}


}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onBackground(){
EasyLog.e(TAG,"==onBackground==")
GestureLockFragment.showGesture = false
}

}

这里要处理两种情况,第一种财富这个fragment 前后台切换后,fragment 是可见的,那么久直接弹出手势,如果不可见要等待可见的时再次弹出,所以还要处理onHidden,

override fun onHiddenChanged(hidden: Boolean) {
super.onHiddenChanged(hidden)
EasyLog.e(TAG,"onHiddenChanged")
if(!hidden){
if(waitingGesture && !showGesture){
waitingGesture = false
showGesture = true
GestureActivity.actionStart(activity!!, GestureActivity.GestureState.Verify)
}
}
}

20210903094115133.gif

收起阅读 »

Kotlin 写自定义 ViewGroup

Android 最近推行的 Compose ,有着 Kotlin 的加持,使写 UI 更加方便快速,不用担心布局嵌套,还是声明式 UI,那么 Compose 有这么多好处,原生写法还有 “出路” 吗?今天给大家分享一种非传统的自定义 ViewGroup 写法,...
继续阅读 »

Android 最近推行的 Compose ,有着 Kotlin 的加持,使写 UI 更加方便快速,不用担心布局嵌套,还是声明式 UI,那么 Compose 有这么多好处,原生写法还有 “出路” 吗?

今天给大家分享一种非传统的自定义 ViewGroup 写法,让你对自定义 ViewGroup 不再 “恐惧”,再借助 Kotlin ,我们用原生写法,也可以快速写出无嵌套的布局。

为什么要用自定义 ViewGroup

平时大家写 UI,都直接用 xml 去写布局,或多或少都会注意去避免布局嵌套,自从有了 ConstraintLayout,嵌套的情况就减少了许多,但用 ConstraintLayout 就能达到极致性能吗?整体上相较于其他 Layout ,性能确实有所提升。但对于产品中具体的页面,可能就不会达到极致性能,因为 ConstraintLayout 要考虑的场景太多了,导致其逻辑很复杂,对于确定的页面来说,一个有 “针对性” 的自定义 ViewGroup ,是能够超越 ConstraintLayout 的,因为你只需要对一个页面负责即可,不用考虑那么全。

这里我所说的自定义 ViewGroup 就是用代码去写布局,不是写一个公共的控件让别人去使的那种。Telegram 的布局就全部用代码去写的。

那么我们平时为什么不去用代码写布局呢?

  • 自定义 ViewGroup 太复杂了,什么 MeasureSpec 情况有一堆。
  • 每次写都忘,还得去查,学了就忘
  • 效率太低,我为了那点性能提升,没必要

总结一下,就是因为自定义 ViewGroup 比较难,还费事。以前用 Java 写代码确实会比较麻烦,但现在有了 Kotlin,也可以很优雅的去用代码写布局了。接下来,我就带大家来捋一下自定义 ViewGroup 的流程,然后用 Kotlin 去实现。

自定义 ViewGroup 要做什么

一个 ViewGroup 有哪几步,我想大家都知道,无非就是测量、布局、绘制,就这三步,测量就是把子 View 的大小测量一下,再算一下自己的大小;布局就是设置一下子 View 的位置;绘制对于 ViewGroup 来说一般不需要,无非就是在自己这画点什么东西。这么看也不是很难嘛,那么难的是哪里呢?我想就是因为下面这张表:

img_01.png

就是测量的时候的各种模式,很多书上讲自定义 View 的时候都会给出这张表,其实这是作者自己总结出来的,Android 官网上是没有这些东西的。

现在让我们忘记上面这张表,就只看一下有几种测量模式:EXACTLY、AT_MOST、UNSPECIFIED,这三个英文意思已经很明确了。

  • EXACTLY 就是精确的,就是你设置多少就是多少
  • AT_MOST 就是最多能用多少,就是子 View 有多大,就给多大
  • UNSPECIFIED 就是不确定的,这种一般都是需要再次测量的,就比如 LinearLayout 使用 weight

其中 UNSPECIFIED 对于我们用代码写布局这种情况,几乎就不会用到,这种就可以不用考虑,那么就只剩下两种模式了,总结一下就是 “View 实际多少就是多少” 和 "View 最多能用多少",这么一想,是不是就没那么复杂了。光说大家估计也没有具体的概念,接下来上代码。

如何借助 Kotlin 提升写 ViewGroup 效率

接下来,让我们用 Kotlin 的扩展方法,来一步一步去完成自定义 ViewGroup 需要的东西。

测量的时候要传一个 MeasureSpec 对象,这个对象是根据宽高 Int 值和测量模式确定的,有了 Kotlin ,我们是不是可以直接给 Int 定义一个扩展方法,来获取这个 Int 值的 MeasureSpec 不就行了,来,看代码:

// EXACTLY 的测量模式
fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
// AT_MOST 的测量模式
fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

我们给一个控件设置宽高,一般都是给个具体的值,要么就是 MATCH_PARENT 或者 WRAP_CONTENT,那么我们是不是也这种常见的情况抽成一个方法呢?有了 Kotlin 我们可以直接在这个 View 上弄个扩展方法,来获取它的默认宽高:

// 获取 View 宽度的默认测量值
fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.width) {
// 如果是 MATCH_PARENT,就说明它要填满父布局,那就给它一个父布局宽度的精确值呗
MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
// 如果是 WRAP_CONTENT,就说明它满足自己的大小就行,那就给它最多能用的大小就行了
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
// 0 就是不确定的,这里我们有 UI 稿,就没有不确定的情况了,所以这里就不用考虑了
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
// 最后就是具体的值了,那就给你具体的呗
else -> layoutParams.width.toExactlyMeasureSpec()
}
}
// 获取 View 高度的默认测量值,和上面获取宽度的原理一样
fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.height) {
MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
else -> layoutParams.height.toExactlyMeasureSpec()
}
}

好了,有了这些,我们再写自定义 ViewGroup 是不是就简单多了,我们测量一个控件,直接这些写就可以了:

textView.measure(textView.defaultWidthMeasureSpec(this), textView.defaultHeightMeasureSpec(this))

等等,这样写还是有点复杂,我们为什么不干脆再定义一个扩展方法,让 View 直接按默认的测量好了:

fun View.autoMeasure(parent: ViewGroup) {
measure(
this.defaultWidthMeasureSpec(parent),
this.defaultHeightMeasureSpec(parent)
)
}

这样下次使用就可以这样写了:

textView.autoMeasure(this)

是不是更简单了,到这,测量的基本代码差不多就写完了,顺便把布局的基础方法也写一下吧,布局就比较简单了,就是告诉子 View 的位置就好了。

// 设置 view 的位置
fun View.autoLayout(parent: ViewGroup, x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
// 判断布局是不是从右边开始
if (!fromRight) {
// 注意这里为什么用 measuredWidth 而不是用 width
// 因为 width 是通过 mRight - mLeft 计算的,而这时它俩都没有被赋值,所以都是 0
layout(x, y, x + measuredWidth, y + measuredHeight)
} else {
autoLayout(parent.measuredWidth - x - measuredWidth, y)
}
}

我们其实可以把这些方法都写到一个类,以后写自定义 ViewGroup,直接继承它就可以了,就像下面这样:

 // 为了方便设置 dp sp,直接在这里声明了扩展属性
val Int.dp
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this.toFloat(),
Resources.getSystem().displayMetrics
).toInt()
val Float.sp
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, this,
Resources.getSystem().displayMetrics
)

abstract class CustomViewGroup(context: Context) : ViewGroup(context) {

// 方便获取带 Margin 的宽高
protected val View.measuredWidthWithMargins get() = measuredWidth + marginStart + marginEnd
protected val View.measuredHeightWithMargins get() = measuredHeight + marginTop + marginBottom

protected fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)

protected fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

protected fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.width) {
MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
else -> layoutParams.width.toExactlyMeasureSpec()
}
}

protected fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.height) {
MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
else -> layoutParams.height.toExactlyMeasureSpec()
}
}

protected fun View.autoMeasure() {
measure(
this.defaultWidthMeasureSpec(this@CustomViewGroup),
this.defaultHeightMeasureSpec(this@CustomViewGroup)
)
}

protected fun View.autoLayout(x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
if (!fromRight) {
layout(x, y, x + measuredWidth, y + measuredHeight)
} else {
autoLayout(this@CustomViewGroup.measuredWidth - x - measuredWidth, y)
}
}
}

写个自定义 ViewGroup 试试

img_02.png

就以计算器界面为例吧。上面👆是通过 ConstraintLayout 实现的,看看有哪些控件,1 个 EditText,17 个 Button。我们来试试用自定义 ViewGroup 来简单复刻一下,直接上代码吧:

class CalculatorLayout(context: Context) : CustomViewGroup(context) {
// 我们可以直接这样在把控件 new 出来,设置一些属性,这样我们还省去了 findViewById,而且不用担心空指针
val etResult = AppCompatEditText(context).apply {
typeface = ResourcesCompat.getFont(context, R.font.comfortaa_regular)
setTextColor(ResourcesCompat.getColor(resources, R.color.white, null))
background = null
textSize = 65f
gravity = Gravity.BOTTOM or Gravity.END
maxLines = 1
isFocusable = false
isCursorVisible = false
setPadding(16.dp, paddingTop, 16.dp, paddingBottom)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
// 注意,这里直接 add 不会触发 onMeasure 这些流程,可以放心 add
addView(this)
}
// 数字键盘后面的背景
val keyboardBackgroundView = View(context).apply {...}

// 抽出一个相同样式的按钮
class NumButton(context: Context, text: String, parent: ViewGroup) : AppCompatTextView(context) {
init {
setText(text)
gravity = Gravity.CENTER
background =
ResourcesCompat.getDrawable(resources, R.drawable.ripple_cal_btn_num, null)
layoutParams =
MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
leftMargin = 2.dp
rightMargin = 2.dp
topMargin = 6.dp
bottomMargin = 6.dp
}
isClickable = true
setTextAppearance(context, R.style.StyleCalBtn)
parent.addView(this)
}
}

// 具体的数组按钮
val btn0 = NumButton(context, "0", this)
...

init {
// 给自己设置个背景
background = ResourcesCompat.getDrawable(resources, R.drawable.shape_cal_bg, null)
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 先算一下数字按钮的大小
val allSize =
measuredWidth - keyboardBackgroundView.paddingLeft - keyboardBackgroundView.paddingRight -
btn0.marginLeft * 8
val numBtSize = (allSize * (1 / 3.8)).toInt()
// 计算操作按钮的大小
val operatorBtWidth = (allSize * (0.8 / 3.8)).toInt()
val operatorBtHeight = (numBtSize * 4 + btn0.marginTop * 6 - btnDel.marginTop * 8) / 5

// 再计算数字盘的高度
val keyboardHeight =
keyboardBackgroundView.paddingTop + keyboardBackgroundView.paddingBottom +
numBtSize * 4 + btn0.marginTop * 8

// 最后把高度剩余空间都给 EditText
val editTextHeight = measuredHeight - keyboardHeight

// 测量背景
keyboardBackgroundView.measure(
measuredWidth.toExactlyMeasureSpec(),
keyboardHeight.toExactlyMeasureSpec()
)

// 测量按钮
btn0.measure(numBtSize.toExactlyMeasureSpec(), numBtSize.toExactlyMeasureSpec())
...
btnDiv.measure(operatorBtWidth.toExactlyMeasureSpec(), operatorBtHeight.toExactlyMeasureSpec())
...

// 测量 EditText
etResult.measure(
measuredWidth.toExactlyMeasureSpec(),
editTextHeight.toExactlyMeasureSpec()
)

// 最后设置自己的宽高
setMeasuredDimension(measuredWidth, measuredHeight)
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 好了,测量都完了,就一个个放吧
// 先放 EditText
etResult.autoLayout()

// 把背景放上
keyboardBackgroundView.autoLayout(0, etResult.bottom)

// 开始放按钮吧
btn7.let {
it.autoLayout(
keyboardBackgroundView.paddingLeft + it.marginLeft,
keyboardBackgroundView.top + keyboardBackgroundView.paddingTop + it.marginTop
)
}
btn8.let {
it.autoLayout(
btn7.right + btn7.marginRight + it.marginLeft,
btn7.top
)
}
btn9.let {
it.autoLayout(
btn8.right + btn8.marginRight + it.marginLeft,
btn7.top
)
}
...
}
}

Ok,以上就完成了自定义 ViewGroup,我们可以直接这样用了:

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val contentView = CalculatorLayout(this)
setContentView(contentView)

// 不用 findViewById 和 ktx插件、ViewBinding 这些东西,直接用就可以
contentView.btnDel.setOnClickListener {
contentView.etResult.setText("")
}
}

看看最终的对比效果:

img_09.jpg

看上去还算可以,怎么样,是不是用 Kotlin 代码写布局也不是很复杂,缺点就是不能在 Android Studio 上预览。

结束了

虽然与 xml 的书写相比,确实有些麻烦,但熟练之后,我感觉都差不多,还能帮助我们对自定义 View 这块更加熟悉,感兴趣的小伙伴可以在项目不忙的时候先试试这种写法。当然,也可以试试 Compose,其实 Compose 最终也是一个 ViewGroup ,可以看看 AndroidComposeView ,它最终也是会添加到 DecorView 上。

收起阅读 »

Android开发太难了:Java Lambda ≠ Android Lambda

我又来了,继续回归写作中,目标 1 月 2 篇。需要两篇才能阐述清楚Java Lambda ≠ Android Lambda,本篇为上篇,先解释清楚 Java Lambda 的一些知识。耐心阅读本文,你一定会有收获。一、Java Lambda 不等于 匿名内部...
继续阅读 »

我又来了,继续回归写作中,目标 1 月 2 篇。

需要两篇才能阐述清楚Java Lambda ≠ Android Lambda,本篇为上篇,先解释清楚 Java Lambda 的一些知识。

耐心阅读本文,你一定会有收获。

一、Java Lambda 不等于 匿名内部类

测试环境JDK8。

首先我们看一段比较简单的代码片段:

public class TestJavaAnonymousInnerClass {
public void test() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("hello java lambda");
}
};
runnable.run();
}
}

先问个简单的问题,如果我javac编译一下,你觉得会生成几个class文件?

不用问,肯定是两个,一个是TestJavaLambda.class,一个是TestJavaLambda$1.class,那么试下:

没错,确实两个,扎实的Java基础怎么会被这种问题打败。

大家都知道上面这个匿名内部类的写法,我们可以换成lambda表达式的写法对吧,甚至编译器都会提醒你使用lambda,我们改成lambda表达式的写法:

public class TestJavaLambda {
public void test() {
Runnable runnable = () -> {
System.out.println("hello java lambda");
};
runnable.run();
}
}

再问个简单的问题,如果我javac编译一下,你觉得会生成几个class文件?

嗯...你在搞我?这和刚才的问题有啥区别?

还认为是两个吗?我们再javac试一下?

不好意思,只有一个class文件了。

那么,我的一个新的问题来了:

Java匿名内部类的写法和Lambda表达式的写法,在编译期这么看肯定有区别的,那么有何区别?

二、Java Lambda的背后,invokedynamic的出现

看这类问题,第一件事肯定是对比字节码了,那我们javap -v 一哈,看一下test()方法区别:

匿名内部类的test():

public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=2, args_size=1
0: new #2 // class com/example/zhanghongyang/blog02/TestJavaAnonymousInnerClass$1
3: dup
4: aload_0
5: invokespecial #3 // Method com/example/zhanghongyang/blog02/TestJavaAnonymousInnerClass$1."<init>":(Lcom/example/zhanghongyang/blog02/TestJavaAnonymousInnerClass;)V
8: astore_1
9: aload_1
10: invokeinterface #4, 1 // InterfaceMethod java/lang/Runnable.run:()V
15: return

很简单,就是new了一个TestJavaAnonymousInnerClass$1对象,然后调用其run()方法。

有个比较有意思的,就是调用构造方法的时候先aload_0,0就是当前对象this,把this传过去了,这个就是匿名内部类可以持有外部类对象的秘密,其实把当前对象this引用给了人家。

再来看lambda的test():

public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return

和匿名内部类不同,取而代之的是一个invokedynamic指令。

如果大家比较熟悉Java字节码方法调用相关,应该经常会看到一个问题:invokespecial,invokevirtual,invokeinterface,invokestatic,invokedynamic有和区别?

invokespecial 其实上面一段字节码上也出现了,一般指的是调用super方法,构造方法,private方法等;special嘛,指定的意思,调用的都是一些确定调用者的方法。

你可能会问,调用一个类的方法,调用者还能有不确定的时候?

有呀,比如重载,是不是能将父类的方法调用转而变成子类的?

所以类中非private成员方法,一般调用指令为invokevirtual。

invokeinterface,invokestatic字面意思理解就可以了。

这块大概解释是这样的,如果有困惑自己打字节码看就好了,例如抽象类抽象方法调用和接口方法调用指令一样吗?加了final修饰的方法不能被复写,指令会有变化吗?

最后一个就是invokedynamic了:

一般很罕见,今天我们也算是见到了,在Java lambda表达式的时候能够见到。

一些深入的研究,可以看这里:

每日一问 | Java中匿名内部类写成 lambda,真的只是语法糖吗?

我们现在知道使用了lambda表达式之后,和匿名内部类去比较,字节码有比较大的变化,那么更好奇了:

lambda表达式运行的时候,背后到底是什么样的呢?

三、lambda表达式不是真的没有内部类生成

想了解一段代码运行时状态,最简单的方式是什么呢?

嗯...debug?

现在IDE都越来越智能了,很多时候debug一些编译细节都给你抹去了。

有个比较简单的方式,打堆栈,我们修改下代码:

public class TestJavaLambda {
public void test() {
Runnable runnable = () -> {
System.out.println("hello java lambda");

int a = 1/0;
};
runnable.run();
}

public static void main(String[] args) {
new TestJavaLambda().test();
}
}

运行下,看下出错的堆栈:

hello java lambda
Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.example.zhanghongyang.blog02.TestJavaLambda.lambda$test$0(TestJavaLambda.java:8)
at com.example.zhanghongyang.blog02.TestJavaLambda.test(TestJavaLambda.java:10)
at com.example.zhanghongyang.blog02.TestJavaLambda.main(TestJavaLambda.java:14)

看下到底和何方神圣调用的我们的run方法:

嗯...最后的堆栈是:

TestJavaLambda.lambda$test$0(TestJavaLambda.java:8)

是我们TestJavaLambda中的lambdatest0方法调用的?

是我们刚才发编译看漏了,还有这个方法?我们再反编译看下:

javap /Users/zhanghongyang/repo/KotlinLearn/app/src/main/java/com/example/zhanghongyang/blog02/TestJavaLambda.class 
Compiled from "TestJavaLambda.java"
public class com.example.zhanghongyang.blog02.TestJavaLambda {
public com.example.zhanghongyang.blog02.TestJavaLambda();
public void test();
public static void main(java.lang.String[]);
private void lambda$test$0();
}

这次javap -p 查看,-p代表private方法也输出出来。

还真有这个方法,看下这个方法的字节码:

private static void lambda$test$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #8 // String hello java lambda
5: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8

很简单,就是我们上面lambda表达式{}里面的内容,打印一行日志。

那这个方法是test调用的?不对呀,这个堆栈好像有问题,我们在回头看下刚才堆栈:

Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.example.zhanghongyang.blog02.TestJavaLambda.lambda$test$0(TestJavaLambda.java:8)
at com.example.zhanghongyang.blog02.TestJavaLambda.test(TestJavaLambda.java:10)
at com.example.zhanghongyang.blog02.TestJavaLambda.main(TestJavaLambda.java:14)

有没有发现这个堆栈太过于简单了,我们的Runnable.run的调用栈呢?

这个堆栈应该是被简化了,那我们再加一行日志,看下run()方法执行时,自己身处于哪个类?

我们在run方法里面加了一行

System.out.println(this.getClass().getCanonicalName());

看下输出:

com.example.zhanghongyang.blog02.TestJavaLambda

嗯..其实我们执行了一个废操作,当前这个方法里面的代码都被放到lambdatest0()了,当然输出是TestJavaLambda。

不行了,我要放大招了。

我们修改下方法,让这个进程活的久一点:

public void test() {
Runnable runnable = () -> {
System.out.println("hello java lambda");
System.out.println(this.getClass().getCanonicalName());
// 新增
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int a = 1 / 0;
};
runnable.run();
}

运行后...

切到命令行,执行jps命令,查看当前程序进程的pid:

java zhanghongyang$ jps
99315 GradleDaemon
3682 TestJavaLambda
21298 Main
3685 Jps
3258 GradleDaemon
1275
3261 KotlinCompileDaemon

看到了3682,然后执行

jstack 3682

太感人了,终于把这行隐藏的run方法的堆栈找出来了。

这里大家不要太在意jps,jstack这些指令,都是jdk自带的,你就知道能查堆栈就行了,别出去搜这两个命令去啦,文章看完再说。
另外获取堆栈其实也能通过方法调用,小缘是通过Reflection.getCallerClass看的。

到现在我们具体真相又进了一步:

我们lambda$test$0()方法是这个对象:com.example.zhanghongyang.blog02.TestJavaLambda$$Lambda$1/1313922862的run方法调用的。

我们又能下个结论了:

文中lambda表达式的写法,在运行时,会帮我们生成中间类,类名格式为 原类名$$Lambda$数字,然后通过这个中间类最终完成调用。

那么你可能表示不服:

你说运行时生成就生成呀?你拿出来给我看看?

嗯...等会我拿出来给你看。

不过我们先思考另一个问题。

四、编译产物中遗漏的信息

上文我们一直在说:

  1. 对于文中例子中的Lambda表达式编译时没有生成中间类;
  2. 运行时帮我们生成了中间类;

那有个很明显的问题,编译时你没给我生成,运行时生成了;运行时它怎么知道要不要生成,生成什么样的类,你编译产物就那一个class文件,里面肯定要包含这类信息的呀?

是这么个道理。

我们再次发编译javap -v查看,在输出信息的最后:

SourceFile: "TestJavaLambda.java"
InnerClasses:
public static final #78= #77 of #81; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #35 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#36 ()V
#37 invokespecial com/example/zhanghongyang/blog02/TestJavaLambda.lambda$test$0:()V
#36 ()V

果然包含一段信息,而且包含TestJavaLambda.lambda$test$0关键词。

大家不用管那么多,你就知道,文中lambda的例子,会在编译的class文件中新增一个方法lambdatest0(),并且会携带一段信息告知JVM在运行时创建一个中间class。

其实LambdaMetafactory.metafactory正是用来生成中间class的,jdk中也有相关类可以查看,后续我们再详细说这个。

五、把中间类拿出来看看?

我们一直说运行时帮我们生成了一个中间类,类名大概为:TestJavaLambda$$Lambda$1,但是口说无凭,得拿出来大伙才信,对吧。

还好不是说我吃了两碗凉粉...

我们刚才说了JVM帮我们生成了中间类,其实java在运行的时候可以带很多参数,其中有个系统属性很神奇,我用给你们看:

java -Djdk.internal.lambda.dumpProxyClasses com.example.zhanghongyang.blog02.TestJavaLambda

懂了吧,加上这个系统属性运行,可以dump出生成的类:

是不是有点意思。

其实动态代理中间也会生成代理类,也可以通过类似方式导出。

然后我们看看这个类呗,这个类我们就不太在乎细节了,直接AS里面看反编译之后的:

真简单...

所以,本文中的例子,Lambda表达式和匿名内部类的区别还是挺大的,大家只要了解:

  1. invokedynamic可以用于lambda;
  2. Java lambda表达式的中间类并不是没有,而是在首次运行时生成的。

至于性能问题,影响应该是微乎其微的,几乎没有的。

下面有个灵魂一问:

你看这些有啥用?

毕竟我是搞Android的,其实我更在乎Android中lambda的实现,所以就先以Java Lambda为开始了,至于你问我为啥要看Android Lambda实现,毕竟现在经常要字节码插抓桩,自定义Transform,对于一些类背后的行为还是要搞清楚的。

但是,大家一定要注意,本文讲的是 Java lambda 的原理。

不要套用到Android上! 不要套用到Android上! 不要套用到Android上!

那 Android Lambda 是怎么一回事,后续会单独写一篇,Android 脱糖与D8 的一些事儿,还想起来上次有个同事被Android Lambda 坑了一次,会一起写出来。

本文基于1.8.0_181。


收起阅读 »

Android 11 绕过反射限制

1. 问题出现的背景腾讯视频在集成我们 replay sdk 的时候发现这么个错误,导致整个 db mock 功能完全失效。Accessing hidden field Landroid/database/sqlite/SQLiteCursor; ->m...
继续阅读 »

1. 问题出现的背景

腾讯视频在集成我们 replay sdk 的时候发现这么个错误,导致整个 db mock 功能完全失效。

Accessing hidden field Landroid/database/sqlite/SQLiteCursor;
->mDriver:Landroid/database/sqlite/SQLiteCursorDriver; (greylist-max-o, reflection, denied)

java.lang.NoSuchFieldException: No field mDriver in class Landroid/database/sqlite/SQLiteCursor;
(declaration of 'android.database.sqlite.SQLiteCursor' appears in /system/framework/framework.jar)

我清晰的记得我们引入了一个第三方解决方案,在 9.0 以上已经解决了这个问题,大致的方案是这样的:

if (SDK_INT >= Build.VERSION_CODES.P) {
try {
Method forName = Class.class.getDeclaredMethod("forName", String.class);
Method getDeclaredMethod = Class.class.getDeclaredMethod("getDeclaredMethod", String.class, Class[].class);

Class<?> vmRuntimeClass = (Class<?>) forName.invoke(null, "dalvik.system.VMRuntime");
Method getRuntime = (Method) getDeclaredMethod.invoke(vmRuntimeClass, "getRuntime", null);
setHiddenApiExemptions = (Method) getDeclaredMethod.invoke(vmRuntimeClass, "setHiddenApiExemptions", new Class[]{String[].class});
sVmRuntime = getRuntime.invoke(null);
} catch (Throwable e) {
Log.e(TAG, "reflect bootstrap failed:", e);
}
}

吓得我赶紧去看下到底有没有猫腻,发现在 Android 11 上果然有问题:

Accessing hidden method Ldalvik/system/VMRuntime;
->setHiddenApiExemptions([Ljava/lang/String;)V (blacklist,core-platform-api, reflection, denied)

Caused by: java.lang.NoSuchMethodException: dalvik.system.VMRuntime.setHiddenApiExemptions [class [Ljava.lang.String;]
......

2. 分析问题出现的原因

本着时间紧任务重尽量不影响进度的情况下,我还是想去网上搜索看看,但是发现都是一堆旧的方案。迫不得已去看看到底为什么?到底为什么?刚好前几天找同事要了一份 Android 11 的源码。

static jobject Class_getDeclaredMethodInternal(JNIEnv* env, jobject javaThis, jstring name, jobjectArray args) {
// ……
Handle<mirror::Method> result = hs.NewHandle(
mirror::Class::GetDeclaredMethodInternal<kRuntimePointerSize>(
soa.Self(),
klass,
soa.Decode<mirror::String>(name),
soa.Decode<mirror::ObjectArray<mirror::Class>>(args),
GetHiddenapiAccessContextFunction(soa.Self())));
if (result == nullptr || ShouldDenyAccessToMember(result->GetArtMethod(), soa.Self())) {
return nullptr;
}
return soa.AddLocalReference<jobject>(result.Get());
}

如果 ShouldDenyAccessToMember 返回 true,那么就会返回 null,上层就会抛出方法找不到的异常。这里和 Android P 没什么不同,只是把 ShouldBlockAccessToMember 改了个名而已。 ShouldDenyAccessToMember 会调用到 hiddenapi::ShouldDenyAccessToMember,该函数是这样实现的:

template<typename T>
inline bool ShouldDenyAccessToMember(T* member,
const std::function<AccessContext()>& fn_get_access_context,
AccessMethod access_method)
REQUIRES_SHARED(Locks::mutator_lock_) {

const uint32_t runtime_flags = GetRuntimeFlags(member);

// 1:如果该成员是公开API,直接通过
if ((runtime_flags & kAccPublicApi) != 0) {
return false;
}

// 2:不是公开API(即为隐藏API),获取调用者和被访问成员的 Domain
// 主要看这个
const AccessContext caller_context = fn_get_access_context();
const AccessContext callee_context(member->GetDeclaringClass());

// 3:如果调用者是可信的,直接返回
if (caller_context.CanAlwaysAccess(callee_context)) {
return false;
}
// ......
}

原来的方案失效了能在 FirstExternalCallerVisitor 的 VisitFrame 方法中找到答案

bool VisitFrame() override REQUIRES_SHARED(Locks::mutator_lock_) {
ArtMethod *m = GetMethod();
......
ObjPtr<mirror::Class> declaring_class = m->GetDeclaringClass();
if (declaring_class->IsBootStrapClassLoaded()) {
......
// 如果 PREVENT_META_REFLECTION_BLACKLIST_ACCESS 为 Enabled,跳过来自 java.lang.reflect.* 的访问
// 系统对“套娃反射”的限制的关键就在此
ObjPtr<mirror::Class> proxy_class = GetClassRoot<mirror::Proxy>();
if (declaring_class->IsInSamePackage(proxy_class) && declaring_class != proxy_class) {
if (Runtime::Current()->isChangeEnabled(kPreventMetaReflectionBlacklistAccess)) {
return true;
}
}
}

caller = m;
return false;
}

3. 解决方案

  • native hook 住 ShouldDenyAccessToMember 方法,直接返回 false
  • 破坏调用堆栈绕过去,使 VM 无法识别调用方

我们采用的是第二种方案,有什么方法可以让 VM 无法识别我的调用栈呢?这可以通过 JniEnv::AttachCurrentThread(…) 函数创建一个新的 Thread 来完成。具体我们可以看下这里 developer.android.com/training/ar… ,然后配合 std::async(…) 与 std::async::get(..) 就能搞定了,下面是关键代码:

// java 层直接用 jni 调用这个方法
static jobject Java_getDeclaredMethod(
JNIEnv *env,
jclass interface,
jobject clazz,
jstring method_name,
jobjectArray params) {
// ...... 省掉一些转换代码
// 先用 std::async 调用 getDeclaredMethod_internal 方法
auto future = std::async(&getDeclaredMethod_internal, global_clazz,
global_method_name,
global_params);
auto result = future.get();
return result;
}

static jobject getDeclaredMethod_internal(
jobject clazz,
jstring method_name,
jobjectArray params) {
// 这里就是一些普通的 jni 操作了
JNIEnv *env = attachCurrentThread();
jclass clazz_class = env->GetObjectClass(clazz);
jmethodID get_declared_method_id = env->GetMethodID(clazz_class, "getDeclaredMethod",
"(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;");
jobject res = env->CallObjectMethod(clazz, get_declared_method_id,
method_name, params);
detachCurrentThread();
return env->NewGlobalRef(res);
}

JNIEnv *attachCurrentThread() {
JNIEnv *env;
// AttachCurrentThread 核心在这里
int res = _vm->AttachCurrentThread(&env, nullptr);
return env;
}
收起阅读 »

初识 Jetpack Compose(三) :修饰符(Modifier)

Modifier modifier elements装饰或添加行为到 Compose UI 元素的有序的、不可变的集合。例如,背景、填充和单击事件侦听器装饰行、文本或按钮或向其添加行为。 正如其名,modifier主要为Compose组件提供修饰功能,包括...
继续阅读 »

Modifier



modifier elements装饰或添加行为到 Compose UI 元素的有序的、不可变的集合。例如,背景、填充和单击事件侦听器装饰行、文本或按钮或向其添加行为。



正如其名,modifier主要为Compose组件提供修饰功能,包括但不限于 样式修改事件监听等一系列对Compose组件的装饰。


有序的


需要注意官方描述的“有序的”一词,由于Modifier的使用方式是链式的,所以属性定义的先后顺序会影响到UI的展示效果。
比如:


@Composable
fun RoundButton() {
Box(
modifier = Modifier
.width(300.dp)
.height(90.dp)
.background(Color(0xFF3ADF00), shape = RoundedCornerShape(50))
.padding(20.dp),
contentAlignment = Alignment.Center){
Text(text = "RoundButton",color = Color.White)
}
}

由于backgroundpadding前,所以Modifier会先给Box设置背景,然后再设置边距,如图:
1630476697(1).jpg
同理,将backgroundpadding顺序替换,效果则如下:


1630477241(1).jpg


通过查看Modifier的源码实现可以发现,在我们通过链式点点点叠加属性的过程中,Modifier会创建一个CombinedModifier将旧的和新的属性组合在一起,合成一个单独的Modifier,相当于给被修饰的组件套上了一层又一层的Modifier,这也是Modifier为有序的原因。


可拓展的


Modifier.padding()源码为例:


@Stable
fun Modifier.padding(all: Dp) =
this.then(
PaddingModifier(
start = all,
top = all,
end = all,
bottom = all,
rtlAware = true,
inspectorInfo = debugInspectorInfo {
name = "padding"
value = all
}
)
)

可以发现,padding()Modifier的一个拓展函数,它调用了Modifier的 then() 函数,而这个 then() 需要接收一个Modifier对象,而PaddingModifier就是这个对象。


所以我们也当然可以为Modifier添加拓展函数,传入我们自定义的Modifier来达到一些特定的效果,比如以下这些属性我们会经常为不同的组件配置:


modifier = Modifier
.width(300.dp)
.height(90.dp)
.padding(20.dp)
.background(Color(0xFF3ADF00), RoundedCornerShape(50))
复制代码

为了方便调用,我们可以给Modifier添加一个拓展方法:


@Stable
fun Modifier.buttonDefault() = this.then(
width(300.dp)
.height(90.dp)
.padding(20.dp)
.background(Color(0xFF3ADF00), RoundedCornerShape(50))
)

这样在组件中,我们只需要这样使用就可以了。


modifier = Modifier.buttonDefault()
复制代码

Modifier 属性


Modifier可配置的属性多且杂,这里不一一列举了,具体可前往Andrid Developer查看




以下是根据职能分类列出的一些常用属性


宽&高



























































属性名含义
Modifier.width(width: Dp)设置自身的宽度,单位dp
Modifier.fillMaxWidth(fraction: Float = 1f)默认横向填充满父容器的宽度,参数可以控制宽度的比例。例如0.5就是当前元素占父元素宽度的一半
Modifier.wrapContentWidth(align: Alignment.Horizontal = Alignment.CenterHorizontally, unbounded: Boolean = false)根据子级元素的宽度来确定自身的宽度,如果自身设置了最小宽度的话则会被忽略。当unbounded参数为true的时候,自身设置了最大宽度的话也会被忽略
-----------------------------------------------------------------------------
Modifier.height(height: Dp)设置自身的高度,单位dp
Modifier.fillMaxHeight(fraction: Float = 1f)默认纵向填充满父容器的宽度,参数可以控制宽度的比例。例如0.5就是当前元素占父元素高度的一半
Modifier.wrapContentHeight(align: Alignment.Vertical = Alignment.CenterVertically, unbounded: Boolean = false)根据子级元素的高度来确定自身的高度,如果自身设置了最小高度的话则会被忽略。当unbounded参数为true的时候,自身设置了最大高度的话也会被忽略
-----------------------------------------------------------------------------
Modifier.size(size: Dp)设置自的宽高,单位dp
Modifier.size(width: Dp, height: Dp)设置自的宽高,单位dp
Modifier.fillMaxSize(fraction: Float = 1f)默认填充满父容器,参数可以控制比例。例如0.5就是当前元素占父元素的一半
Modifier.wrapContentSize(align: Alignment = Alignment.Center, unbounded: Boolean = false)根据子级元素的宽高来确定自身的宽高,如果自身设置了最小宽高的话则会被忽略。当unbounded参数为true的时候,自身设置了最大宽高的话也会被忽略

间距



























属性名含义
Modifier.padding(start: Dp = 0.dp, top: Dp = 0.dp, end: Dp = 0.dp, bottom: Dp = 0.dp)分别在四个方向上设置填充
Modifier.padding(horizontal: Dp = 0.dp, vertical: Dp = 0.dp)分别在横向和纵向上设置填充
Modifier.padding(all: Dp)统一设置所有方向上的填充
Modifier.padding(padding: PaddingValues)根据参数PaddingValues来设置填充,PaddingValues参数可以理解为以上三种方式的封装

绘制



































属性名含义
Modifier.alpha(alpha: Float)不透明度,范围从0-1
Modifier.clip(shape: Shape)裁剪为相应的形状,例如shape = RoundedCornerShape(20) 表示裁剪为20%圆角的矩形。
Modifier.shadow(elevation: Dp, shape: Shape = RectangleShape, clip: Boolean = elevation > 0.dp)绘制阴影效果
Modifier.rotate(degrees: Float)设置视图围绕其中心旋转的角度
Modifier.scale(scale: Float)设置视图的缩放比例
Modifier.scale(scaleX: Float, scaleY: Float)设置视图的缩放比例

背景&边框































属性名含义
Modifier.background(color: Color, shape: Shape = RectangleShape)设置背景色
Modifier.background(brush: Brush, shape: Shape = RectangleShape, alpha: Float = 1.0f)使用Brush来设置背景色,例如渐变色效果
Modifier.border(border: BorderStroke, shape: Shape = RectangleShape)绘制指定形状的边框
Modifier.border(width: Dp, color: Color, shape: Shape = RectangleShape)绘制指定宽度、颜色、形状的边框
Modifier.border(width: Dp, brush: Brush, shape: Shape)绘制指定宽度、brush、形状的边框

行为



























属性名含义
Modifier.clickable(  enabled: Boolean = true, onClickLabel: String? = null, role: Role? =null,  onClick: () -> Unit)点击事件
Modifier.combinedClickable( enabled: Boolean = true,onClickLabel: String? = null,role: Role? = null,onLongClickLabel: String? = null,onLongClick: () -> Unit = null,onDoubleClick: () -> Unit = null,onClick: () -> Unit)组合点击事件,包括单击、长按、双击
Modifier.horizontalScroll(state: ScrollState, enabled: Boolean = true, reverseScrolling: Boolean = false)使组件支持横向滚动模式
Modifier.verticalScroll(state: ScrollState, enabled: Boolean = true, reverseScrolling: Boolean = false)使组件支持纵向滚动模式

二、最后


好记性不如烂笔头,初识 Jetpack Compose 系列是我自己的学习笔记,在加深知识巩固的同时,也可以锻炼一下写作技能。文章中的内容仅作参考,如有问题请留言指正。


1. 参考



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