注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

【Android爬坑日记四】组合替代继承,减少Base类滥用

背景 先说一下背景,当接触了比较多的项目之后,其实会发现每一个项目都会封装BaseActivity、BaseFragment等等。其实初衷其实是好的。每一个Activity和Fragment都是很多模板代码的,为了减少模板代码,封装进Base类其实是一种比较方...
继续阅读 »

背景


先说一下背景,当接触了比较多的项目之后,其实会发现每一个项目都会封装BaseActivity、BaseFragment等等。其实初衷其实是好的。每一个Activity和Fragment都是很多模板代码的,为了减少模板代码,封装进Base类其实是一种比较方便且可行的选择。


Base类涵盖了抽象、继承等面向对象特性,用得好会减少很多样板代码,但是一旦滥用,会对项目有很多弊端。


举个例子


当项目大了,需要封装进Base类的逻辑会非常多,比如说打印生命周期、ViewBinding 或者DataBinding封装、埋点、监听广播、监听EventBus、展示加载界面、弹Dialog等等其他业务逻辑,更有甚者把需要Context的函数都封装进Base类中。


以下举一个BaseActivity的例子,里面封装了上面所说的大部分情况,实际情况可能更多。


abstract class BaseActivity<T: ViewBinding, VM: ViewModel>: AppCompatActivity {

protected lateinit var viewBinding: T

protected lateinit var viewModel: VM

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 打印日志!!
ELog.debugLifeCycle("${this.localClassName} - onCreate")

// 初始化viewModel
viewModel = initViewModel()
// 初始化视图!!
initView()
// 初始化数据!!
initData()
// 注册广播监听!!
registerReceiver()
// 注册EventBus事件监听!!
registerEventBus()

// 省略一堆业务逻辑!

// 设置导航栏颜色!!
window.navigationBarColor = ContextCompat.getColor(this, R.color.primary_color)
}

protected fun initViewModel(): VM {
// 初始化viewModel
}

private fun initViewbinding() {
// 初始化viewBinding
}

// 让子类必须实现
abstract fun initView()

abstract fun initData()

private fun registerReceiver() {
// 注册广播监听
}

private fun unregisterReceiver() {
// 注销广播监听
}

private fun registerEventBus() {
// 注册EventBus事件监听
}

protected fun showDialog() {
// 需要用到Context,因此也封装进来了
}

override fun onResume() {
super.onResume()
ELog.debugLifeCycle("${this.localClassName} - onResume")
}

override fun onPause() {
super.onPause()
ELog.debugLifeCycle("${this.localClassName} - onPause")
}

override fun onDestroy() {
super.onDestroy()
ELog.debugLifeCycle("${this.localClassName} - onDestroy")
unregisterReceiver()
}
}

其实看起来还好,但是在使用的时候难免会遇到一些问题,对于中途接手项目的人来说问题更加明显。我们从中途接手项目的心路历程看看Base类的缺陷。


心路历程




  1. 当创建新的Activity或者Fragment的时候需要想想有没有逻辑可以复用,就去找Base类,或许写Base类的人不同,发现一个项目中可能会存在多个Base类,甚至Base类仍然有多个Base子类实现不同逻辑。这个时候就需要去查看分析每个Base类分别实现了什么功能,决定继承哪个。




  2. 如果一个项目中只有一个Base类的话,仍需要看看Base类实现了什么逻辑,没有实现什么逻辑,防止重复写样板代码。




  3. 当出现Base类实现了的,而自己本身并不想需要,例如不想监听广播或者不想用ViewModel,对于不想监听广播的情况就要特殊做适配,例如往Base类加标志位。对于不想用ViewModel但是由于泛型限制,还是只能传进去,不然没法继承。




  4. 当发现自己集成Base类出BUG了,就要考虑改子类还是改Base类,由于大量的类都集成了Base类,显然改Base类比较麻烦,于是改自己比较方便。




  5. 如果一个Activity中展示了多个Fragment,可能会有业务逻辑的重复,其实只需要一个就好了。




其实第一第二点还好,时间成本其实没有重复写样板代码那么高。但是第三点的话其实用标志位来决定Base类的功能哪个需要实现哪个不需要实现并不是一种优雅的方式,反而需要重写的东西多了几个。第四点归根到底就是Base类其实并不好维护。


爬坑


那么对于Base类怎样实践才比较优雅呢?在我看来组合替代继承其实是一种不错的思路。对于Kotlin first的Android项目来说,组合替代继承其实是比较容易的。以下仅代表个人想法,有不同意见可以交流一下。


成员变量委托


对于ViewModel、Handler、ViewBinding这些Base变量使用委托的方式是比较方便的。


对于ViewBinding委托可以看看我之前的文章,使用起来其实是非常简单的,只需要一行代码即可。


// Activity
private val binding by viewBinding(ActivityMainBinding::inflate)
// Fragment
private val binding by viewBinding(FragmentMainBinding::bind)

对于ViewModel委托,官方库则提供了一个viewBindings委托函数。


private val viewModel:HomeViewModel by viewModels()

需要在Gradle中引入ktx库


implementation 'androidx.fragment:fragment-ktx:1.5.1'
implementation 'androidx.activity:activity-ktx:1.5.1'

而对于Base变量则尽量少封装在Base类中,需要使用可以使用委托,因为如果实例了没有使用其实是比较浪费内存资源的,尽量按需实例。


扩展方法


对于需要用到Context上下文的逻辑封装到Base类中其实是没有必要的,在Kotlin还没有流行的时候,如果说需要使用到Context的工具方法,使用起来其实是不太优雅的。


例如展示一个Dialog:


class DialogUtils {
public static void showDialog(Activity activity, String title, String content) {
// 逻辑
}
}

使用起来就是这样:


class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
DialogUtils.showDialog(this, "title", "content")
}
}
}

使用起来可能就会有一些迷惑,第一个参数把自己传进去了,这对于展示Dialog的语义上是有些奇怪的。按理来说只需要传title和content就好了。


这个时候就会有人想着把这个封装到Base类中。


public abstract class BaseActivity extends AppCompatActivity {

protected void showDialog(String title, String content) {
// 这里就可以用Context了
}
}

使用起来就是这样:


class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
showDialog("title", "content")
}
}
}

是不是感觉好很多了。但是写在Base类中在Java中比较好用,对于Kotlin则完全可以使用扩展函数语法糖来替代了,在使用的时候和定义在Base类是一样的。


fun Activity.showDialog(title: String, content: String) {
// this就能获取到Activity实例
}

class MyActivity : AppCompatActivity() {
...
fun initButton() {
button.setOnClickListener {
// 使用起来和定义在Base类其实是一样的
showDialog("title", "content")
}
}
}

这也说明了,需要使用到Context上下文的函数其实不用在Base类中定义,直接定义在顶层就好了,可以减少Base类的逻辑。


注册监听器


对于注册监听器这种情况则需要分情况,监听器是需要根据生命周期来注册和取消注册的,防止内存泄漏。对于不是每个子类都需要的情况,有的人可能觉得提供一个标志位就好了,如果不需要的话让子类重写。如果定义成抽象方法则每个子类都要重写,如果不是抽象方法的话,子类可能就会忘记重写。在我看来获取生命周期其实是比较简单的事情。按需添加代码监听就好了。


那么什么情况需要封装在Base类中呢?




  • 怕之后接手项目的人忘记写这部分代码,则可以写到Base类中,例如打印日志或者埋点。




  • 而对于界面太多难以测试的功能,例如收到被服务器踢下线的消息跳到登录页面,这个可以写进Base类中,因为基本上每个类都需要监听这种消息。




总结


没有最优秀的架构,只有最适合的架构!对于Base类大家的看法都不一样,追求更少的工作量完成更多事情这个目的是统一的。而Base类一旦臃肿起来了会造成整个项目难以维护,因此对于Base类应该辩证看待,养成只有必要的逻辑才写在Base类中的习惯,feature类应该使用组合的方式来使用,这对于项目的可维护性和代码的可调试性是有好处的。


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

付费上班,你听说过吗?

众所周知,打工人上班是要拿工资的,哪怕工资再少,也是对打工人劳动的报酬。然而,最近职场上出现了一个新概念,叫做“付费上班”。什么意思呢?你在公司给老板打工,老板不但不需要给你钱,你反倒要给老板“发工资”。有谁会傻到上班还要倒贴钱?这不是滑天下之大稽吗?最近,由...
继续阅读 »

众所周知,打工人上班是要拿工资的,哪怕工资再少,也是对打工人劳动的报酬。

然而,最近职场上出现了一个新概念,叫做“付费上班”。什么意思呢?你在公司给老板打工,老板不但不需要给你钱,你反倒要给老板“发工资”。

有谁会傻到上班还要倒贴钱?这不是滑天下之大稽吗?

最近,由于国内宏观经济形势不太乐观,上千万大学生的就业比较困难。在这样的背景下,还真有一位专家在微博上提出了“付费加班”的建议:


好一个两全其美!这条微博一出来,立刻招来了网友们的激烈嘲讽:

“上班不拿工资,还要倒贴钱给老板,那我们打工到底图什么?嫌家里钱太多吗?”

如果大家觉得,“付费加班”只是某些脑子进水的专家的天方夜谭,根本不可能在现实社会中出现,那就有些天真了。

在今年6月,某著名网络游戏公司,就曾经开设过一个“付费上班”项目:支付17800元,即可参加完美技术中心参与开发的一款无缝大世界多人在线生存类网络游戏。


项目参与者进入工作组后,需在理解项目核心玩法的基础之上,完成策划、原画、3D建模、动作、特效等一系列工作。根据游戏项目开发进度,实践时间将长达三个月或以上,超过三个月部分不再额外收费。

后来,这个项目在社会上引起较大的争议,终于被叫停了。

究竟是怎样的原因,才会滋生出“付费上班”这样畸形的职场模式呢?

我觉得原因主要有三点:

1. 高校的教学内容偏重理论,和实际工作脱节,使得应届毕业生无法快速满足职场需求。

2. 今年国内的经济形势不容乐观,用人单位对外招聘的岗位数量都在缩减。

3. 部分在校大学生自身也存在一定问题,要么选择了并不感兴趣的专业,要么没有很好地学习专业知识、为自己的职业发展做准备。(包括当年的我自己)

说起付费上班,小灰也不禁回想起了我当年刚毕业时候的黑历史。

小灰本科毕业是在2008年,那年正好赶上了全球经济危机,当年的应届毕业生普遍难就业。同时小灰对所学的专业(电气自动化)也不大感兴趣,大学四年整天翘课在网吧里打游戏,一学期下来,连课程老师长什么样都不知道。

转眼到了大四那年,小灰面试了好多家公司,都没有人要。最后,在一次机缘巧合之下,小灰去面试了一个只有30多人的小公司,其主营业务是航空维修。

面试官考察我专业知识,小灰基本上啥也不会,但公司老板娘看小灰比较可爱,就留下了我。就这样,小灰在公司做了半年维修工作,每天拧螺丝焊电路板,月工资只有可怜的2000元。

再后来,小灰报了一个IT培训班,培训了一年编程技术,最后总算是转行做了一名程序员。

这段经历很曲折,同时也教会了小灰,年轻时候的不学无术,早晚是要还债的。

那么,抛开情绪层面,让我们来理性看待一下“付费上班”的现象。

当今就业形势严峻,这是客观事实,只是一味在网上发泄,并不能真正解决问题。

如果用人单位真的能够用心培养应届毕业生,在不存在欺诈的前提下,那些竞争力较差的应届毕业生暂时花费一些金钱换取工作经验,也并非完全不可取。

暂时的投入,只是为了在未来更好地挣钱,这是可以理解的。

但是,“付费上班”这种模式也仅仅是非常时期的一种临时解决方案,**绝对不可以常态化**。

最后,小灰提醒广大的年轻朋友们,务必在学生时代为自己的前途做好规划,千万不要随意挥霍宝贵的青春年华。毕竟,机会都是留给有准备的人。

同时,也祝愿大家早日找到满意的工作,走上人生的正规。

来源:程序员小灰

收起阅读 »

uniapp的骨架屏生成指南

骨架屏一般用于页面在请求远程数据尚未完成时,页面用灰色块预显示本来的页面结构,给用户更好的体验。 使用到的API有uni.createSelectorQuery()uni.getSystemInfoSync()。常规首页的布局一般而言,我们的首页的基础布局是包...
继续阅读 »

骨架屏一般用于页面在请求远程数据尚未完成时,页面用灰色块预显示本来的页面结构,给用户更好的体验。 使用到的API有uni.createSelectorQuery()uni.getSystemInfoSync()

常规首页的布局

一般而言,我们的首页的基础布局是包含的有:顶部搜索、轮播、金刚区、新闻简报、活动魔方。

<template>
   <view class="content">
       <!-- 顶部搜索 -->
       <headerSerch></headerSerch>
       <!-- 轮播 -->
       <swiperBg></swiperBg>
       <!-- 金刚区 -->
       <menus></menus>
       <!-- 新闻简报 -->
       <news></news>
       <!-- 活动魔方 -->
       <activity></activity>
       <!-- 骨架屏 -->
       <skeleton :show="show"></skeleton>
   </view>
</template>

<script>
   import headerSerch from './components/headerSerch.vue'
   import swiperBg from './components/swiperBg.vue'
   import menus from './components/menus.vue'
   import news from './components/news.vue'
   import activity from './components/activity.vue'
   import skeleton from './components/skeleton.vue'
   export default {
       components: {
           headerSerch,
           swiperBg,
           menus,
           news,
           activity,
           skeleton
      },
       data() {
           return {
               show: true
          }
      },
       mounted() {
           setTimeout(()=>{
               this.show = false
          },1200)
      }
  }
</script>

<style scoped>

</style>

skeleton组件的实现

代码如下,稍后给大家解释


步骤一 设置骨架屏的基础样式

我们通过绝对定位的方式把组件的根元素提高层级,避免被父组件的其他组件覆盖掉。使用 uni.getSystemInfoSync()同步获取系统的可使用窗口宽度和可使用窗口高度并赋值给组件根元素的宽高。

<view :style="{
width: windowWidth,
height: windowHeight,
backgroundColor: bgColor,
position: 'absolute',
zIndex: 9999,
top: top,
left: left
}">
......
......
</view>

<script>
   let systemInfo = uni.getSystemInfoSync();
   export default {
       name: 'skeleton',
       props: {
           show: {
               type: Boolean,
               default: true
          },
      },
       data() {
           return {
               windowWidth: systemInfo.windowWidth + 'px',
               windowHeight: systemInfo.windowHeight + 'px',
               bgColor: '#fff',
               top: 0,
               left: 0,
          }
      }
    }
</script>

步骤二 渲染出占位的灰色块

通过uniapp的uni.createSelectorQuery()接口,查询页面带有指定类名的元素的位置和尺寸, 通过绝对定位的方式,用同样尺寸的灰色块定位到相同的位置。

在骨架屏中多数用的主要的矩形节点rectNodes 和圆形节点circleNodes。

首先给这些元素加上相同的skeleton-fade类,这个类的主要为了有一个灰色的背景并使用animate属性使其看到颜色的深浅变化。


按照官方的API使用说明,我们得在mounted 后进行调用方法。 在uni.createSelectorQuery()的后面加in(this.$parent)在微信小程序才能生效,在H5端不用加也生效。(我们主要是获取指定元素的位置和高度详细并赋值给rectNodes、circleNodes,所以得到之后可以把这两个方法删掉。)

mounted() {
   // 矩形骨架元素
   this.getRectEls();
   // 圆形骨架元素
   this.getCircleEls();
},

methods: {
   getRectEls() {
       let query = uni.createSelectorQuery().in(this.$parent)
       query.selectAll('.skeleton-rect').boundingClientRect(res => {
               console.log('rect', JSON.stringify(res));
      }).exec(function() {

      })
  },
   getCircleEls() {
       let query = uni.createSelectorQuery().in(this.$parent)
       query.selectAll('.skeleton-circle').boundingClientRect(res => {
               console.log('circle', JSON.stringify(res));
      }).exec(function() {

      })
  }
},

如下图,在控制台上可以得到我们想到的节点信息。


然后再复制粘贴给data中的rectNodes、circleNodes。 skeleton组件基本上就完成了。我们再做下优化,skeleton组件接收父组件传的show值,默认是true,当父组件的数据接口请求完成之后show设置为false。

大功告成,以下的在浏览器端和微信小程序端的骨架屏展示:



作者:清风programmer
来源:juejin.cn/post/7037476325480742920

收起阅读 »

前端人抓包羊了个羊,玩一次就过关

web
1. 前言最近微信小游戏「羊了个羊」非常火爆,火爆的原因不是因为它很好玩,而是第二关难度非常高,据说只有 0.1% 的人能通关。我也尝试了下,第一关非常容易,第二关玩到对自己的智商产生了怀疑:真的有人自己打通关吗?既然不能常规方法通关,能不能通过别的方式通关呢...
继续阅读 »

1. 前言

最近微信小游戏「羊了个羊」非常火爆,火爆的原因不是因为它很好玩,而是第二关难度非常高,据说只有 0.1% 的人能通关。我也尝试了下,第一关非常容易,第二关玩到对自己的智商产生了怀疑:真的有人自己打通关吗?既然不能常规方法通关,能不能通过别的方式通关呢?答案是可以的,我们可以使用抓包工具进行通关,如果你不知道抓包是什么,可以看看《前端人必须掌握的抓包技能》,里面有较详尽的解释。本文主要讲述羊了羊的通关原理以及使用 whistle 进行抓包通关。

2. 通关原理

2.1 游戏玩法

羊了个羊是一个消消乐类的游戏,只不过主角是羊,点击要消除的蔬菜类食物,三个进入槽内就可以消除。

一共有两关,两关都通关后即可获得一套新羊装皮肤,并加入自己所属省份的羊群去,为自己的省份排名出一分力。



可以看到第一关是非常容易的,一般都不需要使用任何道具就可以轻松过关。第二关显然要难得多,

既然如此,能否通过抓包的方式,篡改第二关的地图数据,让它加载第一关的数据呢。


2.2 环境配置

只要地图数据是通过服务端返回给客户端的,就可以通过抓包工具抓取篡改,现在先做好环境的配置:

whistle 是基于 Node 实现的跨平台抓包免费调试工具,可以使用 npm 进行安装

  1. 先安装 node,建议用 nvm 管理

  2. 全局安装 whistle

npm i -g whistle & w2 start

成功启动服务后,就可以通过浏览器访问 http://127.0.0.1:8899/ 查看抓包、修改请求等。


  1. 由于羊了羊客户端与服务端的通信是 https 协议,需要把 whistle 的 https 根证书安装到手机证书管理处并信任。


  1. 此时,再在与电脑连接了同一个 wifi 的手机上配置代理指向 PC 电脑的 IP 和 whistle 监听的端口即可在电脑上截获数据包。

  2. 通过电脑抓包,可以发现地图接口请求路径如下:

# 第一关地图数据

https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=80001

响应数据:{"err_code":0,"err_msg":"","data":"046ef1bab26e5b9bfe2473ded237b572"}

# 第二关地图数据

https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=90019

响应数据:{"err_code":0,"err_msg":"","data":"fdc2ccf2856998d37446c004bcc0aae7"}

知道了地图数据的请求路径,就可以改写响应了。

2022-09-20 更新

地图接口请求的数据变更为:

https://cat-match.easygame2021.com/sheep/v1/game/map_info_ex?matchType=3

3. 通关方式

3.1 改写响应体

在 whistle 中添加过滤规则,拦截第二关地图的请求,返回第一关地图的响应数据。

先在 rules 面板添加一条过滤规则,然后再 values 添加一条返回值,注意要检查是否对应清楚,否则会请求会一直卡住无法响应。



规则设置完后,删除小游戏,重新进入,即可看到抓取的第二关地图请求返回的数据时第一关地图的。


在测试过程中,发现第二关地图的请求 id 以日期递增,比如 900018、900019,注意修改,具体以抓取到的地图请求路径为准。

2022-09-20 更新

上面这种方式已被官方优化,不再有先有第一关和第二关先后请求,但原理是一样的。

添加 whistle 规则:

https://cat-match.easygame2021.com/sheep/v1/game/map_info_ex?matchType=3 resBody://{ylgyV2}

对应的 values 设置为:

{
   "err_code":0,
   "err_msg":"",
   "data":{
       "map_md5":[
           "046ef1bab26e5b9bfe2473ded237b572", // 第一关
           "046ef1bab26e5b9bfe2473ded237b572" // 第二关,用第一关的值替换第二关
      ],
       "map_seed": [4208390475,3613589232,3195281918,329197835]
  }
}

3.2 302 重定向

客户端与服务端是通过 https 通信,传递 HTTP 报文,HTTP 报文包括起始行、首部和主体。


HTTP 请求报文中包含命令和 URL,HTTP 响应报文中包含了事务的结果,而响应的状态码为客户端提供了一种理解事务处理结果的便捷方式。其中 300 ~ 399 代表重定向状态码,重定向状态码要么告知客户端使用替代位置来访问目标资源内容,

要么就提供一个替代的响应而不是资源的内容。如果资源已被移动,可发送一个重定向状态码和一个可选的 Location 首部来告知客户端资源已被移走,以及现在可以在哪里找到它。

常见的重定向状态对比:

  • 301:表明目标资源被永久的移动到了一个新的 URI,任何未来对这个资源的引用都应该使用新的 URI

  • 302:所请求的页面已经临时转移到新的 URI,302 允许各种各样的重定向,一般情况下都会实现为到 GET 的重定向,但是不能确保 POST 会重定向为 POST。

301和302跳转,最终看到的效果是一样的,但对于 SEO 来说有两个区别:

  • 301 重定向是永久的重定向,搜索引擎在抓取新内容的同时也将旧的网址替换为重定向之后的网址。

  • 302 存在网址URL劫持,一个不道德的人在他自己的网址A做一个302重定向到你的网址B,出于某种原因, Google搜索结果所显示的仍然是网址A,但是所用的网页内容却是你的网址B上的内容,这种情况就叫做网址URL劫持

知道重定向的原理后,请求第二关地图时,就可以通过返回重定向响应状态码,告诉客户端,资源已被移动,可以去请求第一关地图数据,

在 whistle 添加 302 重定向规则如下:

https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=90019 redirect://https://cat-match.easygame2021.com/sheep/v1/game/map_info_new?map_id=80001

2022-09-20 更新,上面这种方式已被官方优化,不再有先有第一关和第二关先后请求,此方式不再可行。

要实现了羊了个羊通关,除了更改地图数据还可以篡改道具数据,但尝试时发现它获取道具的方式不是通关网络请求,而是通关转发朋友圈/看广告后获得回调,前端直接做的逻辑处理,因此作罢。

4. 总结

本文是《前端人必须掌握的抓包技能》的案例实践,简单地讲述如何使用 whistle 实现羊了羊通关。考虑到羊了个羊的官方不断更新迭代,现在的漏洞很快会被修复,本文的通关策略会很快失效。如果你能学会到本文的抓包技巧,能给你在日常的开发调试工作中提供一种思路,本文的目的也就达到了。

感谢 Kagol 大佬的建议,才有此篇文章的延生。

声明:本文所述相关技术仅供学习交流使用。

作者:jecyu
来源:juejin.cn/post/7145256312488591391

收起阅读 »

Compose挑灯夜看 - 照亮手机屏幕里面的书本内容

一、前言上一篇文章 Compose回忆童年 - 手拉灯绳-开灯/关灯里面82年钨丝灯,让我又有了新的想法,我们怎么照亮手机里面的文本内容呢?我们会在上一篇文章的基础上来实现“挑灯夜看”的功能,怎么下手呢?往下看👇二、文本着色器我们想要实现照亮功能,那...
继续阅读 »

一、前言

上一篇文章 Compose回忆童年 - 手拉灯绳-开灯/关灯里面82年钨丝灯,让我又有了新的想法,我们怎么照亮手机里面的文本内容呢?

我们会在上一篇文章的基础上来实现“挑灯夜看”的功能,怎么下手呢?往下看👇

二、文本着色器

我们想要实现照亮功能,那肯定需要有不亮的文本内容。

通过透明度来可以吗?肯定不行,文本内容是可以上下滑动的,是一个整体,我们不能通过透明度来做。

在看到小米手机的文本着色效果之后:

小米万象息屏.png

我知道如何下手了,我先来看看ComposeText如何做渐变着色?

1. 有些同学可能喜欢用Canvas去绘制:

Canvas(...) {
drawIntoCanvas { canvas ->
canvas.nativeCanvas.drawText(text, x, y, paint)
}
}

2. 我们可以使用ModifeirdrawWithCache修饰符,官方文档的链接里面也给我们了不少示例。

QQ20220830-203813@2x.png

Text(
text = "永远相信美好的事情即将发生❤️",
modifier = Modifier
.graphicsLayer(alpha = 0.99f)
.drawWithCache {
val brush = Brush.horizontalGradient(
listOf(
Color(0xFFE24CE2),
Color(0xFF73BB70),
Color(0xFFE24CE2)
)
)
onDrawWithContent {
drawContent()
drawRect(brush, blendMode = BlendMode.SrcAtop)
}
}
)

上面代码,我们使用到了BlendMode,我们这里用的是BlendMode#SrcAtop: 将源图像合成到目标图像上,仅限于与目标重叠的位置,确保只有文本可见并且矩形的其余部分被剪切。

3. Google在Compose1.2.0-beta01API变更里面,向TextStyleSpanStyle添加了 Brush API,以提供使用渐变颜色绘制文本的方法。

兄弟们支持了吗.png

private val GradientColors = listOf(
Color(0xFF00FFFF), Color(0xFF97E063),
Color(0xFFE24CE2), Color(0xFF97E063)
)
Text(
modifier = Modifier.align(Alignment.Center).requiredWidthIn(max = 250.dp),
text = "永远相信美好的事情即将发生❤️,我们不会期待米粉的期待!\n\n兄弟们支持了吗?",
style = TextStyle(
brush = Brush.linearGradient(
colors = GradientColors
)
)
)

我们可以看到Emoji表情没有被着色,非常Nice。

我们看一下linearGradient/verticalGradient/radialGradient/sweepGradient效果对比:

linearGradient.pngverticalGradient.png

左边的是linearGradient右边的是verticalGradient

4444.png5555.png

左边的是radialGradient右边的是sweepGradient

还有一种内置的BrushSolidColor,填充指定颜色。

查看Brush#LinearGradient源码发现它继承自ShaderBrush

// androidx.compose.ui.graphics.Brush
class LinearGradient internal constructor(
private val colors: List<Color>,
private val stops: List<Float>? = null,
private val start: Offset,
private val end: Offset,
private val tileMode: TileMode = TileMode.Clamp
) : ShaderBrush()

自定义ShaderBrush,可以修改画笔大小,那么我们也来整一个,用于下面的钨丝灯的照亮效果,刚刚上面还介绍了到一个gradient符合我们的要求,radialGradient,更多的源码细节,这里就不做深入介绍,夜深了哈哈哈。

我们接下来需要初始化一个ShaderBrush

object : ShaderBrush() {
override fun createShader(size: Size): Shader {
return RadialGradientShader(
center = ...,
radius = ...,
colors = ...
)
}
...
}

三、实现照亮文本

刚刚上面初始化了一个ShaderBrush,我们照亮文本内容,文本内容不可能只有一屏对吧,肯定需要支持滑动文本,那要怎么做呢?

我想肯定有掘友知道了,我们可以用ModifierverticalScroll修饰符,记录滚动状态ScrollState,然后设置到RadialGradientShadercenter里面。

我们这里的文本内容引用了:三国演义的第一章内容,我们同样需要上一篇文章RopHandleState

private fun ComposeText(state: RopeHandleState) {
Text(
text = sanguoString,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(state.scrollState),
style = LocalTextStyle.current.merge(
TextStyle(
fontSize = 18.sp,
brush = state.lightContentBrush
)
)
)
}

这里我们用到了TextStyle#Brush的API,同时也添加了滚动修饰符,因为我们需要上下滑动文本,保证“钨丝灯”能照亮我们的文本内容。

我们在RopHandleState里面初始化ScrollState

val scrollState = ScrollState(0)

private val scrollOffset by derivedStateOf {
// 这里增加Y轴的距离
Offset(size.width / 2F, scrollState.value.toFloat() + size.width * 0.2F)
}

可以滚动,我们需要把滚动的距离同步给我们的ShaderBrush

// isOpen == true,钨丝灯亮了需要初始化ShaderBrush
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
lastScrollOffset = Offset(size.width/2F, scrollOffset.y)
return RadialGradientShader(
center = lastScrollOffset!!,
radius = size.minDimension,
colors = listOf(Color.Yellow, Color(0xff85733a), Color.DarkGray)
)
}
override fun equals(other: Any?): Boolean {
return lastScrollOffset?.y == scrollOffset.y
}
}

// isOpen == false,钨丝灯灭了
SolidColor(Color.DarkGray)

根据“钨丝灯”的状态,返回不同的Brush:

val lightContentBrush by derivedStateOf {
if(isOpen) {
object : ShaderBrush() { ... }
} else {
SolidColor(Color.DarkGray)
}
}

这里需要注意一下,我们在打开和关闭钨丝灯的时候,需要把lastScrollOffset设置为初始状态值

fun toggle() {
isOpen = !isOpen
lastScrollOffset = Offset.Zero
}

其他相关的代码,请参考上一篇文章 Compose回忆童年 - 手拉灯绳-开灯/关灯

我们来看看最终效果吧

2022-08-30 22_18_31.gif

延伸:这里其实还可通过手指触摸指定范围区域内高亮哦,有兴趣的可以去试试!!


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

收起阅读 »

把数据库里的未付款订单改成已付款,会发生什么

导言 不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改? 大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,...
继续阅读 »

导言


不知道大家在网上购物的时候,有没有这样的念头,如果能把未付款的订单偷偷用一条SQL改成已付款,该多么美好啊。那么在实际开发过程中,我们应当如何保证数据库里的数据在保存后不会被偷偷更改?



大家好我是日暮与星辰之间,创作不易,如果觉得有用,求点赞,求收藏,求转发,谢谢。



理论


在介绍具体的内容之间,先介绍MD5算法,简单的来说,MD5能把任意大小、长度的数据转换成固定长度的一串字符,经常玩大型游戏的朋友应该都注意到过,各种补丁包、端游客户端之类的大型文件一般都附有一个MD5值,用于确保你下载文件的完整性。那么在这里,我们可以借鉴其思想,对订单的某些属性进行加密计算,得出来一个 MD5值一并保存在数据库当中。从数据库取出数据后第一时间进行校验,如果有异常更改,那么及时抛出异常进行人工处理。


实现


道理我都懂,但是我要如何做呢,别急,且听我一一道来。


这种需求听起来并不强绑定于某个具体的业务需求,这就要用到了我们熟悉的鼎鼎有名的AOP(面向切面编程)来实现。


首先定义四个类型的注解作为AOP的切入点。@Sign@Validate都是作用在方法层面的,分别用于对方法的入参进行加签和验证方法的返回值的签名。@SignField用于注解关键的不容篡改的字段。@ValidateField用于注解保存计算后得出的签名值。


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Sign {
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Validate {
}

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SignField {
}

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidField {
}

以订单的实体为例 sn,amt,status,userId就是关键字段,绝不能允许有人在落单到数据库后对这些字段偷偷篡改。


public class Order {
@SignField
private String sn;
@SignField
private String amt;
@SignField
private int status;
@SignField
private int userId;
@ValidField
private String sign;
}

下面就到了重头戏的部分,如何通过AOP来进行实现。


1. 定义切入点


@Pointcut("execution(@com.example.demo.annotations.Sign * *(..))")
public void signPointCut() {

}

@Pointcut("execution(@com.example.demo.annotations.Validate * *(..))")
public void validatePointCut() {

}

2.环绕切入点


@Around("signPointCut()")
public Object signAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
for (Object o : args) {
System.out.println(o);
sign(o);
}
Object res = pjp.proceed(args);
return res;
}

@Around("validatePointCut()")
public Object validateAround(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
Object res = pjp.proceed(args);
valid(res);
return res;
}

3. 签名的实现


1.获取需要签名字段


private Map<String, String> getSignMap(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = new HashMap<>();
for (Field f : o.getClass().getDeclaredFields()) {
System.out.println(f.getName());
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(SignField.class)) {
String value = "";
f.setAccessible(true);
fieldNameToValue.put(f.getName(), f.get(o).toString());
}
}
}
return fieldNameToValue;
}

2.计算出签名值,这里在属性名和属性值以外加入了我的昵称以防止他人猜测,同时使用了自定义的分隔符来加强密码强度。


private String getSign(Map<String, String> fieldNameToValue) {
List<String> names = new ArrayList<>(fieldNameToValue.keySet());
StringBuilder sb = new StringBuilder();
for (String name : names)
sb.append(name).append("@").append(fieldNameToValue.get(name));
System.out.println(sb.append("日暮与星辰之间").toString());
String signValue = DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
return signValue;
}


  1. 找到保存签名的字段


private Field getValidateFiled(Object o) {
for (Field f : o.getClass().getDeclaredFields()) {
for (Annotation annotation : f.getDeclaredAnnotations()) {
if (annotation.annotationType().equals(ValidField.class)) {
return f;
}
}
}
return null;
}


  1. 对保存签名的字段进行赋值


public void sign(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
if (validateField == null)
return;
String signValue = getSign(fieldNameToValue);
validateField.setAccessible(true);
validateField.set(o, signValue);
}


  1. 对从数据库中取出的对象进行验证


public void valid(Object o) throws IllegalAccessException {
Map<String, String> fieldNameToValue = getSignMap(o);
if (fieldNameToValue.isEmpty()) {
return;
}
Field validateField = getValidateFiled(o);
validateField.setAccessible(true);
String signValue = getSign(fieldNameToValue);
if (!Objects.equals(signValue, validateField.get(o))) {
throw new RuntimeException("数据非法");
}

}

使用示例


对将要保存到数据库的对象进行签名


@Sign
public Order save( Order order){
orderList.add(order);
return order;
}

验证从数据库中取出的对象是否合理


@Validate
public Order query(@ String sn){
return orderList.stream().filter(e -> e.getSn().equals(sn)).findFirst().orElse(null);
}

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

超好用的官方core-ktx库,了解一下~

ktx
本篇文章主要是研究core-ktx库中graphics包下提供的关于View绘制、Bitmap、Rect、Color等操作的一系列扩展API,看看能为我们开发带来哪些便利。 Drawable与Bitmap相互间转换 Bitmap.toDrawable(Res...
继续阅读 »

本篇文章主要是研究core-ktx库中graphics包下提供的关于View绘制、BitmapRectColor等操作的一系列扩展API,看看能为我们开发带来哪些便利。



DrawableBitmap相互间转换


Bitmap.toDrawable(Resource)实现BitmapDrawable


image.png


Bitmap定义了一个快速转换成BitmapDrawable的扩展方法,还是少写了一些模板代码的。


Drawable.toBitmap()实现Drawable转换成Bitmap对象


image.png


Drawable转换成Bitmap应该是我们日常开发中常见的场景,这里官方库直接给我们提供了一个toBitmap()的API,非常的方便,下面我们来简单介绍下其中的原理:




  1. 首先判断当前Drawable的类型是否为BitmapDrawable,如果是直接调用其getBitmap()就能直接拿到Bitmap对象,然后根据传入的宽高进行一定比例的压缩转换后进行返回;




  2. 如果不是BitmapDrawable,就首先需要创建一个Bitmap对象,可以理解为一个"画布",然后接着创建一个Canvas对象并传入之前创建的Bitmap对象,这样我们就可以利用Canvas提供的绘制API在Bitmap这个"画布"上作画了,接下来直接调用Drawable的draw()方法并传入Canvas,就可以将Drawable中的显示内容绘制到我们一开始创建的Bitmap上了,这样就完成了DrawableBitmap的转换




Bitmap系列


简化对Bitmap的绘制操作


我们先看下日常开发中,我们怎么在Bitmap中绘制一点东西:


private fun test4(bitmap: Bitmap) {
val canvas = Canvas(bitmap)
canvas.apply {
//进行一些绘制操作
drawLine(0f, 0f, 100f, 100f, Paint())
}
}

有些繁琐,看下官方库给我们提供了什么便利的扩展实现:


image.png


帮助我们创建好Canvas对象,并且方法参数是一个接收者为Canvas的函数类型,这意味我们可以直接在外部传入的lambda中进行绘制操作:


private fun test4(bitmap: Bitmap) {
bitmap.applyCanvas {
//进行一些绘制操作
drawLine(0f, 0f, 100f, 100f, Paint())
}
}

简化Bitmap创建


1.createBitmap()创建指定大小和像素格式的Bitmap


image.png


还是简化了创建Bitmap的操作,虽然很小。


2.scale()缩放(压缩)Bitmap


image.png


这个也是我们常用的通过降低分辨率压缩Bitmap大小的一种方式。


操作Bitmap中的像素点


1.Bitmap.get(x: Int, y: Int)获取指定位置的像素点RGB值


image.png


经典的运算符重载函数,代码中可以直接val pointRGB = bitmap[100, 100]使用。


2.Bitmap.set()设置某个点的RGB像素值


image.png


同样也是个运算符重载方法,代码中直接bitmap[100, 100] = Color.RED使用。


3.Bitmap.contains()判断指定位置点是否落在Bitmap


image.png


运算符重载方法,直接Point(100, 100) in bitmap使用


color系列


普通扩展属性获取颜色的A、R、G、B


image.png


使用如下:


private fun test10(@ColorInt value: Int) {
val a = value.alpha
val r = value.red
val g = value.green
val b = value.blue
}

解构获取颜色的A、R、G、B


image.png


带有operator修饰componenX就是解构方法,X和参数声明的位置一一对应:


private fun test10(@ColorInt value: Int) {
val (a, r, g, b) = value
}

向我们常见的data classHashMap都实现了类似的解扩展。


转换颜色Color对象


1.Int.toColor()整形颜色转换Color对象


image.png


2.String.toColorInt()实现字符串转Color对象


image.png


这个应该比较常用,直接"#ffffff".toColorInt()即可


Rect系列


解构获取左、上、右、下的值


image.png


熟悉的解构,使用和上面一样(RectF也同样提供了相同的解构方法),如下:


private fun test10(rect: Rect) {
val (left, top, right, bottom) = rect
}

缩放Rect范围


下面是扩充Rect范围的API:


image.png
image.png


image.png


使用如下:


private fun test10(rect: Rect) {
val rect1 = rect + rect
val rect2 = rect + 10
val rect3 = rect + Point(100, 200)
}

同样也提供了minus()缩减Rect范围


Rect间取交集、并集等


image.png


image.png


判断某个点是否落在Rect


image.png


使用:Point(11, 11) in rect


Point、PointX系列



下面的扩展方法无非就是解构、通过运算符重载控制Point位置,上面已经讲了一大堆这样的使用,大家走马观花的看下就行,有个印象即可。



经典的解构取值方法


image.png


操作Point的位置


image.png


image.png


总结



上面的内容已经把graphics包下提供的扩展工具讲的七七八八了,大家主要是有个印象就行,使用的时候能想起来用更好,如果需要详细了解的请直接参考该包下的源码即可。




关于探索官方core-ktx库的还剩下大概最后一篇文章讲解了,希望可以在这个系列中带给大家一些帮助,提供大家的开发效率。并且通过学习官方库中的封装思路,也同样会给大家日常开发中小优化带来启发。


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

Android 官方模块化方案解读

前言 前不久整理下 Now In Android 项目是如何做模块化的(Android 官方项目是怎么做模块化的?快来学习下),没想到官方不久前也在官方文档中更新了模块化相关的章节,下面就一起看一下官方文档中是如何描述 Android App 模块化的。 概述...
继续阅读 »

前言


前不久整理下 Now In Android 项目是如何做模块化的(Android 官方项目是怎么做模块化的?快来学习下),没想到官方不久前也在官方文档中更新了模块化相关的章节,下面就一起看一下官方文档中是如何描述 Android App 模块化的。


概述


首先思考下,为什么要做模块化或者说如果不做模块化会有什么问题?


模块化解决了什么问题?


随着项目以及业务的不断迭代,整个项目中代码数量会不断的增长,在这个过程中代码的可扩展性、可读性都会随着时间的推移而下降。


解决这个问题方式大致有两种,一种是定期 Review 代码架构并做一些防劣化措施,从而保证项目的质量不会随着项目的增长而下降。但是这种方式在需求快速迭代的团队中由于工期及人力投入的原因是很难被执行的。另外就是需要团队中有能够敏锐发现代码劣化倾向的人,从而发起 Review,这个角色通常由技术专家或者是架构师承担。这种方式可操作性并不高。


另外一种解决思路就是将复杂问题拆解成多个小的、简单问题,而对简单问题的处理通常并不需要特别依赖高级人才。这种方式就是分而治之,将大型、复杂问题拆解成一个个小的、简单问题,从而可以做到各个击破。这种方式对应的工程手段之一就是模块化


什么是模块化?


模块化简单讲就是把多功能、高耦合的代码逻辑拆散成多个功能单一、职责明确的模块(module)。一个项目模块化后的整体架构大致如下:


图片



注::app:phone 是模块名,app表示是子目录的形式,具体可以参考我给 Now In Android 提交的 PR 241



模块化有哪些好处?


模块化的好处有很多,主要集中表现在提高代码库的可维护性和整体质量上。下表总结了主要优势。























优点概括
多 App复用模块化是在多 App 开发中复用代码逻辑的基础。每个模块都是一个独立有效的构建单元。
严格的访问权限模块可以很好做控制代码的可访问性,模块内部私有的逻辑添加 internal 或者 private 修饰。防止代码被其他模块引用而导致的过度耦合。
可定制的交付可以使用动态下发( Play Feature Delivery )功能(注:国内应用商店基本不可用) 。

上述好处只能通过模块化才能实现。以下是不使用模块化也能实现的好处,但模块化可以更好地实现。































优点概括
可扩展性在代码紧密耦合的代码仓库中,一个微小的更改都有可能导致牵一发动全身。一个好的模块化的项目会做到代码解耦(关注点分离原则),从而规避了上述问题。
负责人一个模块可以有一个专门的负责人,负责维护代码、修复错误、添加测试和 CodeReview 。方便代码与人员的双重管理。
封装封装意味着代码的每一部分都应该对其他部分有尽可能少的了解(最少知道原则)。孤立的代码更容易阅读和理解。
可测试性可测试性描述了测试代码的难易程度。可测试代码是可以轻松独立测试组件的代码。小类总比大型类容易测试,依赖少的类总比依赖多的类容易测试。
构建时间模块化可以提升编译速度,例如增量构建、构建缓存或并行构建。

模块化常见的误区


任何一项技术都有好坏,模块化也是如此。对模块化使用不当,可能也会引入一些问题。一些常见的问题如下:




  • 太细粒度:太细粒度就意味着项目中会有很多模块,而多模块又会导致编译耗时以及多模块配置同步的问题;




  • 太粗粒度:太粗粒度就意味着项目中会有很少模块,基本不能完全发挥出模块化的好处;当做这是一个循序渐进的过程,太粗粒度可以是一个开始不应该是一个结束;




  • 太复杂:将项目模块化并不总是有意义的。如果在可预见的未来项目的增长并不明确,保持现状也是一种不错的选择。




高内聚低耦合原则


没有适合所有项目的模块化方案。下面讲下模块化开发过程中可以采用的一些一般规则和常见模式。


高内聚低耦合是模块化项目的一种属性。耦合衡量模块相互依赖的程度,内聚衡量单个模块的元素在功能上的相关性。应该争取低耦合和高内聚:




  • 低耦合模块不应该了解其他模块的内部工作原理,这意味着模块应该尽可能地相互独立,以便对一个模块的更改对其他模块的影响为零或最小。




  • 高内聚意味着模块应该仅包含相关性的代码。在一个示例电子书应用程序,将书籍和支付的代码混合在同一个模块中可能是不合适的,因为它们是两个不同的功能领域。




如果两个模块严重依赖彼此,那么它们实际上应该作为一个系统运行。相反,如果一个模块的两个部分不经常交互,它们可能应该是单独的模块。


小结


模块化就是一种将复杂问题拆解成多个简单问题的工程化方案。所以如果你觉得项目还没有那么复杂,引入模块化的收益将没有那么明显。这里的复杂性包括多 App 复用、严格的代码可见性以及 Google Paly 的动态下发(Play Feature Delivery)。当然,如果希望在可扩展性、所有权、封装或构建时间中受益,那么模块化是值得考虑的事情。


模块的类型


App 模块


应用程序模块是应用程序的入口点。它们依赖于特性(feature)模块,通常提供导航能力。使用多渠道打包方案,单个应用程序模块可以编译为许多不同的二进制文件。


如,根据使用用途可以分为正式版本 App、 测试 Demo App,其中正式版本 App 根据其发布平台又可以分为 智能手机、汽车、电视、可穿戴设备等,其依赖关系大致如下:


图片


特性(Feature)模块


特性是 App 中功能相对独立的部分,通常对包含一个页面或一系列密切相关的页面,例如注册或结帐流程。如果您的应用具有底部栏导航,则很可能每个目的地都是一项功能。


特性模块中一般会包含页面或路由(destinations)。因此,在模块内部需求处理 UI Layer 中相关的内容。特性模块中不必局限于单个页面或导航目的地,可以包含多个页面。特性模块依赖于数据模块。


图片


数据(Data)模块


数据模块通常包含 RepositoryDataSource 和实体类。数据模块主要有三个职责:



  1. 封装某个领域的所有数据和业务逻辑:每个数据模块应该负责处理代表某个领域的数据。它可以处理多种相关类型的数据。




  1. 将 Repository 公开为外部 API:数据模块的公共 API 应该是 Repository,因为它们负责将数据公开给 App 的其余部分。




  1. 对外隐藏所有实现细节和 DataSource:DataSource 只能由同一模块的 Repository 访问,对外是隐藏的状态。可以通过使用 Kotlin 的 private 或者 internal 关键字来强制执行此操作。


图片


公共(Common)模块


公共模块,也称为核心模块或者基础模块,包含其他模块经常使用的代码。以下是常用模块的示例:



  • 基础 UI 模块:如果 App 中使用自定义 View 和样式(style),应该考虑将他们统一封装到一个模块中,以便可以复用。也就是大家通常所说的 UI 规范库,这可以使 UI 在不同特性模块之间保持一致。

  • 打点统计模块:打点统计模块,一般是使用市面上现有的 SDK,当然也有自研的。取决于项目需要。

  • 网络模块:网络库模块,通常是对三方网络库(如 OhHttp)的封装,简化自定义配置时,减少不必要的重复代码。

  • 工具模块:工具类,也称为帮助类,通常是在应用程序中重用的小段代码。如文件读写、电子邮件验证器或自定义运算符等。


App 模块化整体汇总形式大致如下:


图片


\


模块间通信



注:此部分结合自身经验以及官方文档整合而得,请批判性观看。



项目虽然采用了模块化方式进行开发,减少了代码之间的耦合,但是模块间的通信仍是不可避免的事情。模块间相互依赖的方式在工程上并不可行,Android 项目并不允许模块间的相互依赖。通常的做法就是引入第三个中介模块,模块间通过中介模块来进行通信。


中介模块在依赖形式上有可以分为两种,一种是向下抽象,抽离出两个模块共有的数据层逻辑,模块通过回调或者是数据流的方式监听逻辑的变化;另一种形式是抽象,在宿主 App 模块中组合拼装两个模块的逻辑。前者是下沉逻辑,后者是控制反转。


下面我以一个简单的业务场景举例:在购书籍列表页面,选择特定的一本书并下单购买。


图片


抽离基础模块


大致流程如下:



  1. 分别在 :feature:home 与 :feature:checkout 设置对基础依赖模块的初始化操作,如接口实现、回调监听等;




  1. 在 :feature:home 模块内通过依赖的 :data:books模块,调用其 navigate() 方法跳转至 :feature:checkout 模块;




  1. 在 :feature:books 模块内将跳转事件通过 onNavigationBook 分发出去,由 :feature:checkout模块模块实现;

  2. :feature:home 模块通过 :data:books模块提供的 onPaymentCanceled() 回调来监听对应的结果;



这种通讯方式随着业务的迭代,底层通用的数据模块会不断膨胀,耦合也会越加严重,所以并不建议使用此方式。官方文档中示例方式则是交由调用者处理,各自模块也相对内聚。


依赖调用者模块


这种方式一般是依赖 app 模块来组装各个业务模块的业务逻辑,也就是一样意义上的胶水代码。大致方式如下:



  1. :app 模块调用 :feature:home提供的 navigate 函数跳转至 home 页面,并通过 onCheckout 函数将对应的结果回调出去;

  2. :app 模块监听到 onCheckout() 回调后,调用 :feature:checkout模块提供 navigate 函数进行跳转,并通过 onPaymentCanceled() 回调将结果抛出;



此种方式使得各业务模块的逻辑更加内聚,虽然这种方式的结果及事件也能很好的暴露出去。但是如果这种方式在大型项目中使用时会导致产生大量的胶水代码(频繁的初始化以及 Callback 设置),不利于项目中后续迭代。为了解决胶水代码问题,可以在项目中引入依赖管理的框架。


依赖管理框架


依赖管理不仅可以很好地解决对象繁琐的初始化逻辑,还可以很好的实施控制反转的编码思想。目前主流的依赖管理的方案有两种,分别为依赖注入与服务查找:




  1. 依赖注入:依赖注入使类能够定义其依赖项而不构造它们。在运行时,另一个类负责提供这些依赖项。一般是使用注解方式,适合大中型项目,如 Hilt




  2. 服务查找:服务查找的方式一般是维护一个注册表,所需的依赖都可以在这个注册表中查找;一般是使用相对简单,适合中小型项目,如 koin




官方推荐使用 Hilt 来进行依赖管理,如果你的项目中在使用其他的依赖管理方式,并且没有遇到问题的话,那么继续使用当前的框架即可。


依赖管理的方式不仅可以使用模块间通信,在模块内部通信也是一种很好的解耦与复用的手段,只是在模块间通信会流程变得更加复杂,也更能突出依赖管理的重要性。整个依赖管理在模块化整体架构大致如下图:


图片


以服务查找方式实现,其大致流程(忽略模块内通讯)如下:



  1. 数据层可以将对应的数据注入到 DI 容器中;

  2. 在特性模块中可以获取到数据层提供的数据,同时也可以将自身的数据注入到 DI 中;

  3. 在 app 模块获取所需的数据或特性功能;


小结


其实整个模块间的通信可以按照其行为方式分为两大类,一种是不同模块间页面直接的跳转,另一种则是不同模块间的数据交互。


对于前者有各种路由框架,Android 官方也提供了 Navigation 库;对于后者也有不少框架,如 Dagger2、Koin,Android 官方也提供了 hilt 库;当然社区中也有两者都能满足的库,如阿里的 ARouter


官方文档中只是提到了比较原始的方式,也是对初学者比较友好的方式。大家可以根据自己项目中的现状选择适合自己的即可。


最佳实践


虽然开发模块化 App 没有唯一正确的方式,但是以下的一些建议仍可以使代码更具可读性、可维护性和可测试性。


保持配置一致


每个模块都会引入配置开销。当模块数量达到某个阈值,则管理一致的配置将成为一项挑战。下面的配置可以减少这部分的工作量:





  • 使用 约定插件 在模块之间共享 build.gradle 中的构建逻辑。


尽量少暴露


模块的公共接口应该是最小的,并且仅仅只公开必需公开的。它不应该在外面暴露任何实现细节。尽可能的缩小外部调用者的可访问范围,使用 Kotlin 的privateinternal 可以很好的做到这一点。在模块中声明依赖项时推荐使用implementation,而非apiimplementation不会透传依赖,从而可以做到缩短构建时间。


尽量使用 Kotlin 和 Java 模块


Android Studio 支持三种基本类型的模块:



  • 应用程序模块AndroidManifest.xml是您的应用程序的入口点。它们可以包含源代码、资源、资产和. 应用模块的输出是一个 Android App Bundle (AAB) 或一个 Android 应用程序包 (APK)。




  • 库模块 依赖项与应用程序模块具有相同的内容。它们被其他 Android 模块用作依赖项。库模块的输出是一个 Android Archive (AAR),在结构上与应用程序模块相同,但它们被编译为一个 Android Archive (AAR) 文件,以后可以被其他模块用作。库模块可以在许多应用程序模块中封装和重用相同的逻辑和资源。




  • Kotlin 和 Java 库不包含任何 Android 资源、资产或清单文件。


由于 Android 模块会带来开销,因此您最好尽可能使用 Kotlin 或 Java 类型。


总结


以上内容是根据官方文档整理而得,对部分内容做了结构调整、重新绘制了 UML 图以及添加了些自己的经验感悟。对原文整理会存在疏忽遗漏的部分,请大家到官方文档中查看,做到“交叉验证”。


如果你想快速搭建一个全新的 Android 模块化项目,可以到 architecture-templates 仓库中 clone,可以说是非常便捷了。


虽然模块化技术在国内并算不上什么新技术了,但是我仍然看到了一些积极的影响:



  • 对于初学者而言,有一套相对详细指导文档并且完整示例的项目(Now in Android)可以参考,从而可以快速搭建模块化的项目;

  •  对于已经实践过模块化项目团队,我相信仍能从官方文章中学习到一些新思路及方法,以复盘的视角审视自己团队中的模块化方案的优劣;


当然,模块化本身并不是终点。模块化之后还有组件化,组件化之后还有壳工程和动态化。每个技术阶段对应到团队发展的阶段,那些适合目前团队现状的技术才是”好“技术。


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

Android 三行代码实现高斯模糊

设计:有了毛玻璃效果,产品的逼格直接拉满了呀我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧设计:GayHub ???寻找可行的方案要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单...
继续阅读 »

设计:有了毛玻璃效果,产品的逼格直接拉满了呀

我:啊,对对对。我去 GayHub 上找找有没有好的解决方案吧

设计:GayHub ???

寻找可行的方案

要实现高斯模糊的方式有很多,StackBlur、RenderScript、Glide 等等都是不错的方式,但最简单直接效率最高的方式,还得是上 Github。

搜索的关键词为 android blur,可以看到有两个库是比较合适的, Blurry 和 BlurView。 这两个库 Star 数比较高,并且也还在维护着。

于是,便尝试了一番,发现 BlurView 比 Blurry 更好用,十分推荐上手 BlurView

image-20220917223800119

Blurry

  • 优点:API 使用非常简洁,效果也不错,提供同步和异步加载的解决方案
  • 缺点:奇奇怪怪的 Bug 非常多,并且只能作用于 ImageView
    • 使用时,基本会遇到这两个 Bug:issue1 和 issue2 。
    • issue1(NullPointerException) 已经有现成的解决方案
    • issue2(Canvas: trying to use a recycled bitmap) 则从 17 年至今毫无进展,并且复现概率还比较高

BlurView(推荐)

  • 优点:使用的过程中几乎没有遇到 bug,实现时调用的代码较少。并且,可以实现复杂的模糊 View

  • 缺点:需要在 xml 中配置,并且需要花几秒钟的时间理解一下 rootView 的概念

  • 使用方式:

    XML:

    <androidx.constraintlayout.widget.ConstraintLayout
    ...
    android:id="@+id/rootView"
    android:background="@color/purple_200" >

    <ImageView
    ...
    android:id="@+id/imageView" />

    <eightbitlab.com.blurview.BlurView
    ...
    android:id="@+id/blurView" />

    </androidx.constraintlayout.widget.ConstraintLayout>

    MainActivity#onCreate:

    // 这里的 rootView,只要是 blurView 的任意一个父 View 即可
    val rootView = findViewById<ConstraintLayout>(R.id.rootView)
    val blurView = findViewById<BlurView>(R.id.blurView)
    blurView.setupWith(rootView, RenderScriptBlur(this))
  • 实现的效果:

    使用前:

    181663475092_.pic

    使用后:

    171663475091_.pic
  • Tips :

    • 在 BlurView 以下的 View 都会有高斯模糊的效果

    • rootView 可以选择离 BlurView 最近的 ViewGroup

    • .setBlurRadius() 可以用来设置卷积核的大小,默认是 16F

    • .setOverlayColor() 可以用来设置高斯模糊覆盖的颜色值

    • 例如如下参数配置时可以达到这样的效果:

      blurView.setupWith(rootView, RenderScriptBlur(this))
      .setBlurRadius(5F)
      .setOverlayColor(Color.parseColor("#77000000"))
      191663495988_.pic


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

收起阅读 »

5年前端,我学会接受自己的平凡

前言前端工作之余,回顾了一下自己的职业生涯,感慨万千,每当经历过一些事,对个人对工作以及这份职业都会有新的感悟,只希望以一个过来人的经验和感悟,与诸君共勉~青春岁月本人17年毕业于某211高校,和很多大学生一样,每天基本3点一线,当我现在回首我的大学时光,是快...
继续阅读 »


前言

前端工作之余,回顾了一下自己的职业生涯,感慨万千,每当经历过一些事,对个人对工作以及这份职业都会有新的感悟,只希望以一个过来人的经验和感悟,与诸君共勉~

青春岁月

本人17年毕业于某211高校,和很多大学生一样,每天基本3点一线,当我现在回首我的大学时光,是快乐的,但也有后悔的地方,就是花费了很多时间在lol上,上课就是玩手机,下课就是看视频玩游戏,当时觉得很快乐,现在觉得错失了很多的时光,每到学期末,就开始读书复习,幸运的是,大学4年都没有挂科,除了论文答辩坎坷了点,第二次答辩顺利结业了。

毕业

我一共有过3个工作经历,说起第一段工作经历,那真是很怀念,邻近毕业了,没打算考研,就一股脑去找工作了,当时互联网比较火热,外加计算机课学过js基础,就开始拿了一本红宝书进行学习,那会才开始写下第一行代码console.log('hello world~'),这算是入行的一个契机;

就这样看了1个月的书,就开始写简历,跑各种各样的宣讲会,那会经常跑中大和华工去参加宣讲会,记忆犹新的有两个事,有一次去参加一个游戏公司的宣讲会,现场面试,我投递的是测试岗位,我现在记得问了我什么是黑盒什么是白盒测试,我一无所知,后面他问我程序设计学的怎么样,年轻的我说才刚开始学,他说如果我想走写代码这条路,就要坚持下去,不要转投其他的岗位。或许,这坚定了我选择前端的决心。

第二个记忆犹新的事,是在一个酒店,参加唯品会的校招面试,笔试貌似是线上的,当时我还记得面试官问了我盒子模型是什么,margin属性的设置规则(上右下左),这些我统统答不上来,后面他聊起了公司伙食好,他发福了好多哈哈哈~

总的来说,我的校招并不顺利,从并不知道自己能做什么,找什么样的工作,以及后面找到想做的事了,但是因为没有准备,也错过了很多的机会,导致了我秋招没找到像样的工作。

秋招

就这样,秋招快结束了,焦虑过彷徨过,后面又参加了一个在天河华师的宣讲会,现场笔试面试,终于拿到了offer,如果我没记错的话,当时的薪资是6500一个月,放到如今的现在,比90%的毕业生的现在薪水都要低,但那是我最快乐的一年,穷并快乐着。

工作

后面开始就是疯狂的吸收学习成长,在业务中成长,第二年开始跳到c厂,工资翻倍涨到13k,在c厂体验到了大公司的开发规范,先进的基础设施,学习成长很多,于是一呆又是2年。

工作3年后,面临着异地问题,于是双双从广州,顺德,一起奔赴深圳,也就是在工作的第四年,薪资终于涨到了20k,也是很多毕业生刚毕业就能拿到的薪资。

但是时代变化的太快了,13年上学,17年毕业参加工作,在我的青春读书的岁月里,人们谈论最多的并不是买房,房价也还没变成勒死年轻人的绳索,彼时互联网还是新兴产业,朝阳崛起,没有现在的反垄断和中概股暴跌。

于是乎到了现在,虽然收入每年都在增长,但是我已经不再是当年那个满口“路飞,橡胶橡胶xxx”的快乐的少年,焦虑焦虑还是焦虑

思考

其实,对于前端这份工作,我一直以来的心态就是平凡,我干的这份职业,并不值得对亲朋好友吹嘘,我干的这份工作,也仅仅是一份工作,他并不比扫大街送外卖这份职业高等好多,因为总归到头,我们都是一个大城市里的打工人。

我甚至觉得这份工作,只要正常念完中学的孩子,进培训班培训半年,也一样能做,所以我们没有资格对这份工作报以骄傲。

总有亲朋好友说:谁谁小孩进了字节,谁谁小孩进了腾讯,好像就能高人一等了,殊不知公司体量越大,裁员也会越多,谁都有生涯到头的时候吧。

最后,我想以一个平凡人的角度,希望正在挣扎的你能接受自己平凡

因为,很多的这样的你我,都是一样的平凡人

我们注定成不了金字塔里的顶尖

我们注定成不了行业中的翘楚

我们注定成不了传说中的扫地僧

我们注定成不了技术中的大拿

不管你究竟付出了多大的努力,也是如此。

所以,我希望这样的你,能宽恕自己的平凡,花更多的时间在陪伴家人,拓展社交,培养兴趣爱好,甚至是可以去养花花草草,有机会的话,多发展发展副业,人脉,如果能学会投资理财,这是最好的。

年近30,才有所这样的领悟,只是希望技术不要成为你的唯一,毕竟生活和健康,才是你真正需要拥有的东西

年近30,我接受这样平凡的自己

就像许巍的歌“曾梦想仗剑走天涯,看一看世界的繁华”

我也曾拥有每个前端人的那个最初的梦想“努力钻研技术,出任公司ceo,赢取白富美,走上人生巅峰”

但是繁华过后,确是生活的平凡和美好

作者:lemonwater

来源:https://juejin.cn/post/7145022995172425741

收起阅读 »

糟糕的 Kotlin 语法糖

这几天在 review 同事的代码的时候,发现一块有意思的代码,我将其写成对应的伪代码如下:class UserViewModel(val userUsecase: UserUsecase) {    // 根据 userId 获取 use...
继续阅读 »

这几天在 review 同事的代码的时候,发现一块有意思的代码,我将其写成对应的伪代码如下:

class UserViewModel(val userUsecase: UserUsecase) {

   // 根据 userId 获取 userName
   fun getUser(userId:Int) {
       val name = userUsecase(userId).name
  }
   
}

class User(val name: String, val age: Int) {}

起初在看到这段代码的时候,觉得十分反人类,在 Kotlin 中,对象的初始化可以省略 new 操作符,也即类后面再配个 () 即可,为啥一个初始化的对象还能继续用 (),在直观的感受下,我以为是初始化了一个对象,唯一让我觉得不像是初始化的就是 userUsecase 开头并不是大写,这才打消我认为他是初始化对象的疑虑。

在我想点进去看下根据 userId 获取 User 的过程,我无论追踪代码,都无法跳转到真正的逻辑代码调用处,点击 userUsecase 会直接跳转到 UserViewModel 的构造方法,点击 name 会跳转到 User 对象,这让我很苦恼。

我不得不点击 UserUsecase 类去看下里面的代码,这对于 review 人来说简直是灾难,但为了解决问题,先妥协,再一探究竟。

进入 UserUsecase 类,伪代码如下:

class UserUsecase {
   operator fun invoke(userId: Int): User {
       // 从数据库中根据 id 获取 User 数据
       // 返回 User 数据
       return User("lisi", 30)
  }
}

看到了奇怪的 invoke 函数,并且是用了 operator 操作重载符,为了了解这种语法,我在 Kotlin 中文网查了下该语法的使用,在调用操作符章节中有所说明:


对象() 等价于 对象.invoke()()内为函数的参数,也即我们上面的那段代码,可以翻译一下:

class UserViewModel(val userUsecase: UserUsecase) {
   fun getUser() {
       val name = userUsecase(1001).name
       // 等价于
       val name2 = userUsecase.invoke(1001).name
  }
}

也可以用 Kotlin Decompile 看下结果:


需要说明的是,对象() 这种写法是有条件的:

  • 必须用 operator 修饰方法

  • 方法名称必须是 invoke

  • invoke 参数可以多个,不做限制

由于 invoke 函数参数不加限制,这又带来了一个问题,如果重载了多个 invoke 函数,就更不知道业务方在调用的时候是做了什么事情,依然不得不进入代码才能知道逻辑。

上面的示例给的已足够简单,但实际在我们的业务中,比这还复杂,invoke 函数被封装到了父类,当我点进去的时候根本找不到 invoke 函数,只能往上查看父类有没有,在找到 invoke 函数时才发现,他最终调用了个抽象方法,该抽象方法由子类实现,我又不得不返回到子类查看这个方法,最终才敲定这个方法做了什么逻辑。

总结:

虽然 operator invoke 可以省略调用方写函数名这个过程,但需要注意的是,代码无论是类名还是方法名还是变量名,一定要做到见名识意,显然,他已经破坏了这个规则,让 review 人很抓狂。

我也很理解大家对 Jetpack 的热爱,这种写法在官方也有出现,可以参考 Domain Layer 这章。但我想说的是,省略方法名这个过程真的有必要吗?写代码到底是为了炫技还是为了让别人能看懂自己的代码呢?


作者:codelang
来源:juejin.cn/post/7081112122179977224

收起阅读 »

我在 Shopee 毕业了

大家好,2022年 9 月 19 日,我成为了一名前 Shopee 员工,是的,我毕业了。2022年 9 月 19 日可以被「转名」为 Shoppe 黑色星期一,原因很简单,Shopee 全公司开启大规模裁员,当然今天不会是最后一天,在接下来的几天可能还会持续...
继续阅读 »

大家好,2022年 9 月 19 日,我成为了一名前 Shopee 员工,是的,我毕业了。

2022年 9 月 19 日可以被「转名」为 Shoppe 黑色星期一,原因很简单,Shopee 全公司开启大规模裁员,当然今天不会是最后一天,在接下来的几天可能还会持续裁员。


早上刚到公司,同组的同事都在开玩笑说「虾皮大群发不了消息了,群禁言了,这难道是要裁员了」,当时大家都不以为然,以为真是一个玩笑。

然后没有过多久,大约 10点半左右,开启了一个全员线上会议,会议全程 7 分钟左右。会议由 CEO David 主持,会议直接进入主题,这次 David 没有往昔的和颜悦色,全程只有 David,败家大佬 YC 并没有出现。之前这样的会议都是至少一个小时起步的,今天只有短短的几分钟,内容很简单,总结一句话就是:「我们将开启裁员行动」。

会议结束后,行动立即开始,是的,立即开始。会议刚结束,同组成员就马上收到 HRBP 的邀请,1 VS 1 面谈,面谈最长 10 分钟,快的同学 5 分钟 over。

面谈只谈补偿,不谈其他无用的,在 Shopee 这么久,从来没有一个会议这么高效。面谈完,不需要任何的交接,交还电脑和工牌,直接收拾东西走人即可。

整个流程快速高效,不拖泥带水。

「毕业」前的叮铃

中秋前就有风声说中秋后会有大动作,还算人性的是,让大家拿了中秋礼盒过一个好的中秋之后在裁员。中秋节来之后,就收到了全员邮件「降本」,缩减成本,12~18月内会有更多的措施。还有机智的同学提前让老 leader 拉一把转组了去了波及小的团队。

「毕业」先前条件

  1. leader 上报维护系统,必须人员/模块负责人/组内人员排名。

  2. hr 根据条件1,以及裁员成本,人力成本制定排名。

  3. 极少高潜应届生留存。

  4. 项目组确定裁员数量。

「毕业」优先级

综合先前上面四点,裁员优先级大致:

  1. 非高潜应届生

  2. 试用期

  3. 年限短拿过c

  4. 年限长拿过c

  5. 年限短没拿过a/非核心

  6. 年现长没拿过a

  7. 年限短拿过a

  8. 年限长拿过a/非核心

  9. 基本团灭

「毕业」补偿

  • n + 2,base = 总包 / 12

  • 股票未归属部分,按时间进行归属,9月底可拿

  • 年假也会转换为钱进行补偿

「毕业」重灾区

  • IM

  • 金融

  • 支付

  • 供应链

  • ...

这部分业务裁员比较严重,我在的业务团队裁员 60 %。裁员少的团队15%~30%,裁员多的团队100%。

最后

「毕业」对我个人来说,感觉也不是一个坏事,无车、无房、无贷款、无娃,没有任何的压力,并且我还拿了钱,拿钱走人也不见得是不好的事,转换一个思路,作为打工人的我们本来的工作就是拿钱办事!

塞翁失马焉知非福,对于裁员这种事情看开点,洗洗睡,明天还是新的一天。放平心态,很多事情不是我们能决定的,所以不用给自己太多的压力。

大家要切记切记:社保不能断哈,这个是重点!

拿着补偿先在家休息一段时间,做一些在想做的事情,因为现在最多的就是时间。


接下里几天持续更新最新消息,大家一起吃瓜,😁。


作者:拜小白
来源:juejin.cn/post/7145282164932739109

收起阅读 »

被「羊了个羊」逼疯后,鹅厂程序员怒而自制「必通关版」|GitHub热榜

「羊了个羊」有多恶心?能逼程序员气到撸起袖子自己上……这两天,GitHub上就出现这么一个仿制版,名曰「鱼了个鱼」。不同于以「极低通关率」肝死玩家的原版,此版作者放出话来——没广告!可自定义关卡和图案!道具无限!。甚至可以定制出这(离)样(谱)的界面:目前,该...
继续阅读 »

「羊了个羊」有多恶心?

能逼程序员气到撸起袖子自己上……

这两天,GitHub上就出现这么一个仿制版,名曰「鱼了个鱼」


不同于以「极低通关率」肝死玩家的原版,此版作者放出话来——

没广告!可自定义关卡和图案!道具无限!

甚至可以定制出这(离)(谱)的界面:


目前,该项目已登GitHub热榜,获297个Star。(链接已附在文末)


比「羊」更让人舒适

先看看这款「鱼了个鱼」体验如何。

从最简单模式开启,简直不要太Easy,道具都无需使用。


再看中等和困难模式,稍有难度,还好有道具!

原版的洗牌、撤回、移出可无限次使用,还有更多玄妙功能。

比如透视,能看到最下方两列叠起来图案依次是什么,这感觉,相当于斗地主把最后三张看完了。


再比如圣光,能把一大堆图案下面的图层从灰变白,看得更清楚。


最逆天的还是破坏功能,直接消掉3个同样图案:


也就是说,一直狂按这个道具能直接通关。


值得一提的是,通关后祝贺画面是这个:


建议作者优化下前端,直接换成这个:


怒而自制必通关版

据作者介绍,自己也是玩「羊了个羊」几十次,其间,他用尽道具,看了几十遍借贷广告,向富家千金反复求婚,仍然过不了第二关——

他发现事情不对劲。

由于方块生成完全随机,那越到后期,越来越多方块叠压在一起,可选方块变少,自然越来越难,经常无解也是常事。

另一方面,正是极低的通关率让每个「自以为必胜」的玩家上头得不行,形成了上瘾感。


于是……他怒而自制一个必能通关的版本。

要求嘛,务必无广告,务必道具无限,要能自定义难度和图案,那更是一件美事儿。

具体到原理,作者提出四大纲领。

首先,游戏全局参数设置上,需要将槽位数量、层数等变量抽取成统一的全局变量,每当修改一处,系统自动适配,此外,作者还开放了参数自定义——

嫌槽位不足?可以自己多加一个!


其次是整体网格设计。

为了能快速做出游戏,作者直接将画布分为24×24的虚拟网格,类似一个棋盘——

每个网格又被划分成3×3的小单元,各层图案生成时,会相互错开1-2个单元,形成层层交叠、密密麻麻的样子。


第三步是设计随机生成块的图案和坐标。

先根据全局参数计算总块数,游戏难度越高,块数和相应层数也越多,然后作者用shuffle函数打乱存储所有动物图案的数组,再依次,把图案重新填充到方块中。

至于如何生成方块坐标,直接让程序随机选取坐标范围内的点,同时随层级变深,坐标范围也越来越小,造成一种——

越往深了去,图案越拥挤,难度相应越高的效果。


大致分布规律就是越「深」层越「挤」

最后,设定上下层块与块的关系。

作者先给每个块指定一个层级属性,随机生成时,给相互重叠的块绑定层级关系,确保消掉上层块,才能点击下层块。

基于上述思路,作者熬夜爆肝几个小时,就把游戏雏形做出来了,还放到GitHub上将代码开源——

他感慨道,总算是满足了自己的通关夙愿。


作者介绍

事实上,「鱼了个鱼」项目作者「程序员鱼皮」已小有名气。

据其个人公开资料显示,「程序员鱼皮」98年出生,现在鹅厂,从事全栈应用开发,同时,也是腾讯云开发高级布道师。

工作之外,鱼皮利用业余时间做了很多入职大厂经验、技术干货和资源分享,据他称,在校期间就带领工作室建设了几十个校园网站。


最后,附上「鱼了个鱼」在线体验链接,收获通关喜悦(狗头):

https://yulegeyu.cn

参考链接:
[1]https://github.com/liyupi/yulegeyu
[2]https://www.bilibili.com/video/BV1Pe411M7wh
[3]https://mp.weixin.qq.com/s/D_I1Tq-ofhKhlp0rkOpaLA

来源:詹士 发自 凹非寺

收起阅读 »

开发这么久,gradle 和 gradlew 啥区别、怎么选?

使用 Gradle 的开发者最常问的问题之一便是: gradle 和 gradlew 的区别? 。 这两个都是应用在特定场景的 Gradle 命令。通过本篇文章你将了解到每个命令干了什么,以及如何在两个命令中做选择。 快速摘要 如果你正在开发的项目当中已经包...
继续阅读 »

使用 Gradle 的开发者最常问的问题之一便是: gradlegradlew 的区别?


这两个都是应用在特定场景的 Gradle 命令。通过本篇文章你将了解到每个命令干了什么,以及如何在两个命令中做选择。



快速摘要


如果你正在开发的项目当中已经包含 gradlew 脚本,安啦,可以一直使用它。没有包含的话,请使用 gradle 命令生成这个脚本。


想知道为什么吗,请继续阅读。



gradle 命令


如果你从 Gradle 官网(gradle.org/releases)下载和安装了 Gradle 的话,你便可以使用安装在 bin 路径下的 gradle 命令了。当然你记得将该 bin 路径添加到设备的 PATH 环境变量中。


此后,在终端上运行 gradle 的话,你会看到如下输出:



你会注意到输出里打印了 Gradle 的版本,它对应着你运行的 gradle 命令在设备中的 Gradle 安装包版本。这听起来有点废话,但在谈论 gradlew 的时候需要明确这点,这很重要。


通过这个本地安装的 Gradle,你可以使用 gradle 命令做很多事情,包括:



  • 使用 gradle init 命令创建一个新的 Gradle 项目或者使用 gradle wrapper 命令创建 gradle wrapper 目录及文件

  • 在一个 Gradle 项目内使用 gradle build 命令进行 Gradle 编译

  • 通过 gradle tasks 命令查看当前的 Gradle 项目中支持哪些 task


上述的命令均使用你本地安装的 Gradle 程序,无论你安装的是什么版本。


如果你使用的是 Windows 设备,那么 gradle 命令等同于 gradle.bat,gradlew 命令等同于 gradlew.bat,非常简单。


gradlew 命令


gradlew 命令,也被了解为 Gradle wrapper,与 gradle 命令相比它是略有不同的。它是一个打包在项目内的脚本,并且它参与版本控制,所以当年复制了某项目将自动获得这个 gradlew 脚本。


“可那又如何?”


好吧,如果你这么想。让我告诉你,它有很多重要的优势。


1. 无需本地安装 gradle


gradlew 脚本不依赖本地的 Gradle 安装。在设备上第一次运行的时候会从网络获取 Gradle 的安装包并缓存下来。这使得任何人、在任何设备上,只要拷贝了这个项目就可以非常简单地开始编译。


2. 配置固定的 gradle 版本


这个 gradlew 脚本和指定的 Gradle 版本进行绑定。这非常有用,因为这意味着项目的管理者可以强制要求该项目编译时应当使用的 Gradle 版本。


Gradle 特性并不总是互相兼容各版本的,所以使用 Gradle wrapper 可以确保项目每次编译都能获得一致性的结果。


当然这需要编译项目的人使用 gradlew 命令,如下是在项目内运行 ./gradlew 的示例:



输出和运行 gradle 命令的结果比较相似。但仔细查看你会发现版本不一样,不是上面的 6.8.2 而是 6.6.1


这个差异说重要也重要,说不重要也不重要。


但当使用 gradlew 的话可以免于担心由于 Gradle 版本导致的不一致性,缘自它可以保证所有的团队成员以及 CI 服务端都会使用相同的 Gradle 版本来构建这个项目。


另外,几乎所有使用 gradle 命令可以做的事情,你也可以使用 gradlew来完成。比如编译一个项目就是 ./gradlew build


如果你愿意的话,可以拷贝 示例项目 并来试一下gradlew


gradle 和 gradlew 对比


至此你应该能看到在项目内使用 gradlew 通常是最佳选择。确保 gradlew 脚本受到版本控制,这样的话你以及其他开发者都可以收获如上章节提到的好处。


但是,难道没有任何情况需要使用 gradle 命令了吗?当然有。如果你期望在一个空目录下搭建一个新的 Gradle 项目,你可以使用 gradle init 来完成。这个命令同样会生成 gradlew 脚本。


(如下的表格简单列出两者如何选)可以说,使用 gradlew 确实是 Gradle 项目的最佳实践。

你想做什么?gradle 还是 gradlew?
编译项目gradlew
测试项目gradlew
项目内执行其他 Gradle taskgradlew
初始化一个 Gradle 项目或者生成 Gradle wrappergradle

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

Android性能优化 - 捕获java crash的那些事

背景 crash一直是影响app稳定性的大头,同时在随着项目逐渐迭代,复杂性越来越提高的同时,由于主观或者客观的的原因,都会造成意想不到的crash出现。同样的,在android的历史化过程中,就算是android系统本身,在迭代中也会存在着隐含的crash。...
继续阅读 »

背景


crash一直是影响app稳定性的大头,同时在随着项目逐渐迭代,复杂性越来越提高的同时,由于主观或者客观的的原因,都会造成意想不到的crash出现。同样的,在android的历史化过程中,就算是android系统本身,在迭代中也会存在着隐含的crash。我们常说的crash包括java层(虚拟机层)crash与native层crash,本期我们着重讲一下java层的crash。


java层crash由来


虽然说我们在开发过程中会遇到各种各样的crash,但是这个crash是如果产生的呢?我们来探讨一下一个crash是如何诞生的!


我们很容易就知道,在java中main函数是程序的开始(其实还有前置步骤),我们开发中,虽然android系统把应用的主线程创建封装在了自己的系统中,但是无论怎么封装,一个java层的线程无论再怎么强大,背后肯定是绑定了一个操作系统级别的线程,才真正得与驱动,也就是说,我们平常说的java线程,它其实是被操作系统真正的Thread的一个使用体罢了,java层的多个thread,可能会只对应着native层的一个Thread(便于区分,这里thread统一只java层的线程,Thread指的是native层的Thread。其实native的Thread也不是真正的线程,只是操作系统提供的一个api罢了,但是我们这里先简单这样定义,假设了native的线程与操作系统线程为同一个东西)


每一个java层的thread调用start方法,就会来到native层Thread的世界


public synchronized void start() {
throw new IllegalThreadStateException();
group.add(this);

started = false;
try {
nativeCreate(this, stackSize, daemon);
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}

最终调用的是一个jni方法


private native static void nativeCreate(Thread t, long stackSize, boolean daemon);

而nativeCreate最终在native层的实现是


static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size,
jboolean daemon) {
// There are sections in the zygote that forbid thread creation.
Runtime* runtime = Runtime::Current();
if (runtime->IsZygote() && runtime->IsZygoteNoThreadSection()) {
jclass internal_error = env->FindClass("java/lang/InternalError");
CHECK(internal_error != nullptr);
env->ThrowNew(internal_error, "Cannot create threads in zygote");
return;
}
// 这里就是真正的创建线程方法
Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}

CreateNativeThread 经过了一系列的校验动作,终于到了真正创建线程的地方了,最终在CreateNativeThread方法中,通过了pthread_create创建了一个真正的Thread


Thread::CreateNativeThread 方法中
...
pthread_create_result = pthread_create(&new_pthread,
&attr,
Thread::CreateCallback,
child_thread);
CHECK_PTHREAD_CALL(pthread_attr_destroy, (&attr), "new thread");

if (pthread_create_result == 0) {
// pthread_create started the new thread. The child is now responsible for managing the
// JNIEnvExt we created.
// Note: we can't check for tmp_jni_env == nullptr, as that would require synchronization
// between the threads.
child_jni_env_ext.release(); // NOLINT pthreads API.
return;
}
...

到这里我们就能够明白,一个java层的thread其实真正绑定的,是一个native层的Thread,有了这个知识,我们就可以回到我们的crash主题了,当发生异常的时候(即检测到一些操作不符合虚拟机规定时),注意,这个时候还是在虚拟机的控制范围之内,就可以直接调用


void Thread::ThrowNewException(const char* exception_class_descriptor,
const char* msg) {
// Callers should either clear or call ThrowNewWrappedException.
AssertNoPendingExceptionForNewException(msg);
ThrowNewWrappedException(exception_class_descriptor, msg);
}

进行对exception的抛出,我们目前所有的java层crash都是如此,因为对crash的识别还属于本虚拟机所在的进程的范畴(native crash 虚拟机就没办法直接识别),比如我们常见的各种crash


image.png


然后就会调用到Thread::ThrowNewWrappedException 方法,在这个方法里面再次调用到Thread::SetException方法,成功的把当次引发异常的信息记录下来


void Thread::SetException(ObjPtr<mirror::Throwable> new_exception) {
CHECK(new_exception != nullptr);
// TODO: DCHECK(!IsExceptionPending());
tlsPtr_.exception = new_exception.Ptr();
}

此时,此时就会调用Thread的Destroy方法,这个时候,线程就会在里面判断,本次的异常该怎么去处理


void Thread::Destroy() {
...

if (tlsPtr_.opeer != nullptr) {
ScopedObjectAccess soa(self);
// We may need to call user-supplied managed code, do this before final clean-up.
HandleUncaughtExceptions(soa);
RemoveFromThreadGroup(soa);
Runtime* runtime = Runtime::Current();
if (runtime != nullptr) {
runtime->GetRuntimeCallbacks()->ThreadDeath(self);
}

HandleUncaughtExceptions 这个方式就是处理的函数,我们继续看一下这个异常处理函数


void Thread::HandleUncaughtExceptions(ScopedObjectAccessAlreadyRunnable& soa) {
if (!IsExceptionPending()) {
return;
}
ScopedLocalRef<jobject> peer(tlsPtr_.jni_env, soa.AddLocalReference<jobject>(tlsPtr_.opeer));
ScopedThreadStateChange tsc(this, ThreadState::kNative);

// Get and clear the exception.
ScopedLocalRef<jthrowable> exception(tlsPtr_.jni_env, tlsPtr_.jni_env->ExceptionOccurred());
tlsPtr_.jni_env->ExceptionClear();

// Call the Thread instance's dispatchUncaughtException(Throwable)
// 关键点就在此,回到java层
tlsPtr_.jni_env->CallVoidMethod(peer.get(),
WellKnownClasses::java_lang_Thread_dispatchUncaughtException,
exception.get());

// If the dispatchUncaughtException threw, clear that exception too.
tlsPtr_.jni_env->ExceptionClear();
}

到这里,我们就接近尾声了,可以看到我们的处理函数最终通过jni,再次回到了java层的世界,而这个连接的java层函数就是dispatchUncaughtException(java_lang_Thread_dispatchUncaughtException)


public final void dispatchUncaughtException(Throwable e) {
// BEGIN Android-added: uncaughtExceptionPreHandler for use by platform.
Thread.UncaughtExceptionHandler initialUeh =
Thread.getUncaughtExceptionPreHandler();
if (initialUeh != null) {
try {
initialUeh.uncaughtException(this, e);
} catch (RuntimeException | Error ignored) {
// Throwables thrown by the initial handler are ignored
}
}
// END Android-added: uncaughtExceptionPreHandler for use by platform.
getUncaughtExceptionHandler().uncaughtException(this, e);
}

到这里,我们就彻底了解到了一个java层异常的产生过程!


为什么java层异常会导致crash


从上面我们文章我们能够看到,一个异常是怎么产生的,可能细心的读者会了解到,笔者一直在用异常这个词,而不是crash,因为异常发生了,crash是不一定产生的!我们可以看到dispatchUncaughtException方法最终会尝试着调用UncaughtExceptionHandler去处理本次异常,好家伙!那么UncaughtExceptionHandler是在什么时候设置的?其实就是在Init中,由系统提前设置好的!frameworks/base/core/java/com/android/internal/os/RuntimeInit.java


protected static final void commonInit() {
if (DEBUG) Slog.d(TAG, "Entered RuntimeInit!");

/*
* set handlers; these apply to all threads in the VM. Apps can replace
* the default handler, but not the pre handler.
*/
LoggingHandler loggingHandler = new LoggingHandler();
RuntimeHooks.setUncaughtExceptionPreHandler(loggingHandler);
Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler(loggingHandler));

/*
* Install a time zone supplier that uses the Android persistent time zone system property.
*/
RuntimeHooks.setTimeZoneIdSupplier(() -> SystemProperties.get("persist.sys.timezone"));

LogManager.getLogManager().reset();
new AndroidConfig();

/*
* Sets the default HTTP User-Agent used by HttpURLConnection.
*/
String userAgent = getDefaultUserAgent();
System.setProperty("http.agent", userAgent);

/*
* Wire socket tagging to traffic stats.
*/
TrafficStats.attachSocketTagger();

initialized = true;
}

好家伙,原来是KillApplicationHandler“捣蛋”,在异常到来时,就会通过KillApplicationHandler去处理,而这里的处理就是,杀死app!!



private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler {
private final LoggingHandler mLoggingHandler;
public KillApplicationHandler(LoggingHandler loggingHandler) {
this.mLoggingHandler = Objects.requireNonNull(loggingHandler);
}

@Override
public void uncaughtException(Thread t, Throwable e) {
try {
ensureLogging(t, e);

// Don't re-enter -- avoid infinite loops if crash-reporting crashes.
if (mCrashing) return;
mCrashing = true;

if (ActivityThread.currentActivityThread() != null) {
ActivityThread.currentActivityThread().stopProfiling();
}

// Bring up crash dialog, wait for it to be dismissed
ActivityManager.getService().handleApplicationCrash(
mApplicationObject, new ApplicationErrorReport.ParcelableCrashInfo(e));
} catch (Throwable t2) {
if (t2 instanceof DeadObjectException) {
// System process is dead; ignore
} else {
try {
Clog_e(TAG, "Error reporting crash", t2);
} catch (Throwable t3) {
// Even Clog_e() fails! Oh well.
}
}
} finally {
// Try everything to make sure this process goes away.
Process.killProcess(Process.myPid());
System.exit(10);
}
}


private void ensureLogging(Thread t, Throwable e) {
if (!mLoggingHandler.mTriggered) {
try {
mLoggingHandler.uncaughtException(t, e);
} catch (Throwable loggingThrowable) {
// Ignored.
}
}
}
}

看到了吗!异常的产生导致的crash,真正的源头就是在此了!


捕获crash


通过对前文的阅读,我们了解到了crash的源头就是KillApplicationHandler,因为它默认处理就是杀死app,此时我们也注意到,它是继承于UncaughtExceptionHandler的。当然,有异常及时抛出解决,是一件好事,但是我们也可能有一些异常,比如android系统sdk的问题,或者其他没那么重要的异常,直接崩溃app,这个处理就不是那么好了。但是不要紧,java虚拟机开发者也肯定注意到了这点,所以提供


Thread.java

public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh)

方式,导入一个我们自定义的实现了UncaughtExceptionHandler接口的类


public interface UncaughtExceptionHandler {
/**
* Method invoked when the given thread terminates due to the
* given uncaught exception.
* <p>Any exception thrown by this method will be ignored by the
* Java Virtual Machine.
* @param t the thread
* @param e the exception
*/
void uncaughtException(Thread t, Throwable e);
}

此时我们只需要写一个类,模仿KillApplicationHandler一样,就能写出一个自己的异常处理类,去处理我们程序中的异常(或者Android系统中特定版本的异常)。例子demo比如


class MyExceptionHandler:Thread.UncaughtExceptionHandler {
override fun uncaughtException(t: Thread, e: Throwable) {
// 做自己的逻辑
Log.i("hello",e.toString())
}
}

总结


到这里,我们能够了解到了一个java crash是怎么产生的了,同时我们也了解到了常用的UncaughtExceptionHandler为什么可以拦截一些我们不希望产生crash的异常,在接下来的android性能优化系列中,会持续带来相关的其他分享,感谢观看


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

从《羊了个羊》看随机数的生成原理

你的《羊了个羊》第二关通关了吗? 作为一款三消类的休闲小游戏,《羊了个羊》虽然在玩法上并没有多大创新,但却以其相邻关卡间巨大的游戏难度落差成功出圈。讨论度提高的同时,也招致了一些批评的声音,主要是指责《羊了个羊》毫无游戏性可言,罪状无一例外都提到同一个词——随...
继续阅读 »

你的《羊了个羊》第二关通关了吗?


作为一款三消类的休闲小游戏,《羊了个羊》虽然在玩法上并没有多大创新,但却以其相邻关卡间巨大的游戏难度落差成功出圈。讨论度提高的同时,也招致了一些批评的声音,主要是指责《羊了个羊》毫无游戏性可言,罪状无一例外都提到同一个词——随机性


简单讲就是,三消类的游戏虽然看起来是一堆混乱无序的元素,但大体遵循一些默认的游戏规则,比如每种元素的数量以及所有元素的总数量一定数字3的倍数,以保证所有的元素最终都能配对消除并获胜,只是每种元素出现的时机是随机的而已。


但《羊了个羊》偏偏“不讲武德”,它让每种元素出现的概率都是随机的,也就是批评者口中的“真随机性”,这样导致的结果就是,你分配到的牌局可能从一开始就是个死局,等你玩到最后才发现根本无法完全消除。


最后的无解情况.png


今天我们不讨论《羊了个羊》“武德”问题,但既然提到了游戏中的随机性问题,那我们就想站在程序的角度来好好说道说道了~




大家好,我是玩羊玩到晚上要数羊睡觉的椎锋陷陈,今天我们要分享的主题是随机数的生成原理


可能有读者要产生疑惑了,我们讨论的不是游戏中的随机性问题吗,怎么变成了随机数的生成原理了?这是因为,随机数本身就是随机性所产生的结果,又是反过来指导游戏行为的依据,比如《羊了个羊》中每回合出现的元素种类,所以引申出来讨论随机数的生成原理并不生硬。


这里我们首先要探究的一个问题就是,游戏中产生的随机数,是真的随机数吗?很遗憾,并不是,这里面大部分产生的都是伪随机数


伪随机数是什么?


计算机是确定性的,这意味着其产生的所有行为,都是由其预先编写的所有指令所决定的,仅依赖一个确定性的事物,是无法产生一个随机性的结果的。


随机数.png


我们拿到的所谓随机数,只是看起来随机而已,也就是说,只是符合统计学上对于随机性认定的要求,但随机数的产生过程却是确定的


什么意思呢?


首先,伪随机数生成器内部制定了一个算法,本质上就是一个数学公式。公式所得到的随机数集是一个序列,序列中的每一个随机数,都是由前一个随机数代入相同的公式计算得出的


而序列的起始值,我们称之为种子数,决定了整个随机数序列的所有数值。


所以,理论上,我们只要知道伪随机数生成器的种子数和内部算法,就可以推演出整个随机数序列。因此,伪随机数生成器是不安全的,不能用于安全系数要求高的场合,比如登录时默认的随机密码生成,但对于《羊了个羊》这一类的休闲小游戏来讲还是没啥问题的。


那么,伪随机数生成器都有哪些算法呢?


伪随机数生成器算法


平方取中法


平方取中法是由冯·诺伊曼提出的一种产生均匀伪随机数的算法,算法的原理很简单,首先选定一个种子数,假设是一个四位数字,比如4321。


接着,正如算法的名字所表述,先对种子数进行平方4321^2=18671041,再取中间四位数字18[6710]41,从而得到序列下一项6710。


如果平方后不足八位,则在数字的前面填充0直至满八位,如241^2=58081=00[0580]81=0580。


随后重复这个过程,就能持续生成多个位于0到9999之间的随机数。


线性同余生成器


不过,这显然不能满足我们需要生成伪随机数的多数场景。目前生成伪随机数的最佳算法,是一种叫做马特赛特旋转演算法的现代算法,其基于更简单的线性同余生成器,一种常见的伪随机数生成器。


线性同余生成器要求有4个输入,除了固定要求的种子数之外,还有模数m、乘数a以及增量c


计算方式是种子数乘以a再加上c,然后把计算结果对m进行求模,也即除以m并取余数。


线性同余生成器.png


随后重复这个过程,就可以得到余下的随机数序列,得到的随机数将位于0到m-1之间。


算法过程我们了解了,但线性同余生成器又是凭借什么优势,在伪随机数生成这方面更受青睐的呢?


线性同余生成器的优势


其实,无论是哪一种伪随机数生成器算法,除了前面所提到的安全性问题之外,还有一个相同的天然缺陷,那就是其生成的随机数序列,无论长短,最终都会重复出现,即形成一个循环。


因此就有了周期的说法,所谓周期,指的就是在两次循环之间出现的不同随机数项的数目


原因我们前面已经讲了:恒定的计算公式,以及依赖于前一个随机数。


我贴一部知名恐怖电影的海报你们就懂了:


恐怖游轮.png


而线性同余生成器的优势在于,其周期的长度是m-1,即取决于模数m,只要保证m的取值尽量大,比如2的32次方,就能极大地延长随机数重复的周期,但也只是延长,本质上仍无法避免。


那么,真的就无解了吗?既然有伪随机数的说法,那有没有真随机数呢?


还是有的。


真随机数怎么得到?


既然从内部无法自我解决,那就寻求外部的帮助吧,也即接受一个我们认为是随机性的外部事物的输入作为种子数,从而使得经过计算机处理之后的结果也是随机的,这个外部事物就是——自然界的噪声。


这个噪声是物理学上的含义,指的是一切不规则的信号,而不一定是声音。


比如RANDOM.ORG这个网站,就是以大气噪声,也即自然界雷暴活动所产生的电磁辐射作为随机性的外部事物的输入,借此提供各项服务以满足各种各样需要生成真随机数的场景。


闪电击中广州塔.png


既然需要外部事物的输入,那也就意味着需要额外的硬件设备支持,以收集和测量随机的物理现象或普通事件。但也不用把它想象的过于高大上,诸如鼠标、键盘的点击都可以作为随机事件的种子数。


另一方面,由于搜集外部的数据需要时间,也导致了真随机数生成器的另外一个缺点——不够快。以及,由于随机性的外部事物的输入很难重现,也将导致我们无法复现随机数生成过程,测试流程常常无法正常进行。


不过,己之缺点即是彼之优点,对于伪随机数生成器来说,不需要外部设备支持、计算效率高、可复现则是其明显的优势


好了,这个就是今天要分享的内容。


总结一下,《羊了个羊》每种元素的随机生成使用的仍然是伪随机数生成器,因此说它“真随机性”其实并不太准确。


而其宣传所谓的通关率不到0.1%,与游戏难度本身关系不大,你无法通过大概率是你刚好被分配到的牌局没有达到三消类游戏通关的基本要求。


最后,祝你是那0.1%的幸运儿,游戏如是,生活也如是。


通关截图.png


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

Android常用多线程解析(一)线程的使用

 上图是Android中多线程实现的主要方式,和线程的控制流程。1.最基础的方式就是在需要的时候new一个Thread,但是这种方式不利于线程的管理,容易引起内存泄漏。 试想一下,你在Activity中new一个Thread去处理耗时任务,并且在任务...
继续阅读 »
image.png 上图是Android中多线程实现的主要方式,和线程的控制流程。

1.最基础的方式就是在需要的时候new一个Thread,但是这种方式不利于线程的管理,容易引起内存泄漏。 试想一下,你在Activity中new一个Thread去处理耗时任务,并且在任务结束后通过Handler切换到UI线程上去操作UI。这时候你的Activity已经被销毁,因为Thread还在运行,所以他并不会被销毁,此外Thread中还持有Handler的引用,这时候必将会引发内存泄漏和crash。

newThread:可复写Thread#run方法,也可以传递Runnable对象 缺点:缺乏统一管理,线程无法复用,线程间会引起竞争,可能占用过多系统资源导致死机或oom。

Thread的两种写法
class ThreadRunable : Thread() {
override fun run() {
Thread.sleep(10000)
}
}

fun testThread(){
Thread{
Thread.sleep(10000)
}.start()
ThreadRunable().start()
}

2.在Android中我们也会使用AsyncTask来构建自己的异步任务。但是在Android中所有的AsyncTask都是共用一个核心线程数为1的线程池,也就是说如果你多次调用AsyncTask.execute方法后,你的任务需要等到前面的任务完成后才会执行。Android已经不建议使用AsyncTask了

@Deprecated
public static final Executor SERIAL_EXECUTOR = new SerialExecutor();

private static class SerialExecutor implements Executor {
final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();
Runnable mActive;

public synchronized void execute(final Runnable r) {
mTasks.offer(new Runnable() {
public void run() {
try {
r.run();
} finally {
scheduleNext();
}
}
});
if (mActive == null) {
scheduleNext();
}
}

protected synchronized void scheduleNext() {
if ((mActive = mTasks.poll()) != null) {
THREAD_POOL_EXECUTOR.execute(mActive);
}
}
}


使用AsyncTask的方式有以下几种

2.1.继承AsyncTask<String, Int, String>()并且复写他的三个方法

class MyAsyncTask : AsyncTask<String, Int, String>(){
//线程池中调用该方法,异步任务的代码运行在这个方法中,参数代表运行异步任务传递的参数,通过AsyncTask.execute方法传递
override fun doInBackground(vararg params: String?): String {
Log.e(TAG, Thread.currentThread().name)
for(progress in 0..100){
//传递任务进度
publishProgress(progress)
}
return "success"
}
//运行在UI线程上 参数代表异步任务传递过来的进度
override fun onProgressUpdate(vararg values: Int?) {
Log.e(TAG, "progress ${values}, Thread ${Thread.currentThread().name}")
}

//异步任务结束,运行在UI线程上 参数代表异步任务运行的结果
override fun onPostExecute(result: String?) {
Log.e(TAG, "result ${result}, Thread ${Thread.currentThread().name}")
}

}
MyAsyncTask().execute("123")

2.2.直接调用execute方法传递Runable对象

for(i in 0..10){
AsyncTask.execute(Runnable {
Log.e(TAG, "for invoke: ${Thread.currentThread().name} time ${System.currentTimeMillis()}" )
Thread.sleep(10000)
})
}

2.3.直接向线程池添加任务

/**
* 并发执行任务
*/

for(i in 0..10){
AsyncTask.THREAD_POOL_EXECUTOR.execute( Runnable {
Log.e(TAG, "for invoke: ${Thread.currentThread().name} time ${System.currentTimeMillis()}" )
})
}

2.4.其中第三种是并行执行,使用是AsyncTask内部的线程池

@Deprecated
public static final Executor THREAD_POOL_EXECUTOR;

static {
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(), sThreadFactory);
threadPoolExecutor.setRejectedExecutionHandler(sRunOnSerialPolicy);
THREAD_POOL_EXECUTOR = threadPoolExecutor;
}

3.使用HandlerThread

HandlerThread在内部维护一个loop遍历子线程的消息,允许你向子线程中发送任务

使用方法如下,我们需要先构建HandlerThread实例,并调用start方法,启动内部的loop。

之后需要创建Handler并且将HandlerThread的loop传递进去,在内部实现handleMessage方法处理任务。

fun handlerThread(){
val handlerThread = HandlerThread("Handler__Thread")
handlerThread.start()
//传递的loop是ThreadHandler
val handler = object : Handler(handlerThread.looper){
override fun handleMessage(msg: Message) {
Log.e(TAG, "handlerThread ${Thread.currentThread().name}")
}
}
handler.sendEmptyMessage(1)
}

4.使用IntentService执行完任务后自动销毁,适用于一次性任务(已经被弃用)推荐使用workManager。


/**
* 任务执行完成后自动销毁,适用于一次性任务
*/

class MyIntentService : IntentService("MyIntentService"){
override fun onHandleIntent(intent: Intent?) {
Log.e("MyIntentService", "Thread ${Thread.currentThread().name}")
}

}

5.使用线程池。

线程池是我们最常用的控制线程的方式,他可以集中管理你的线程,复用线程,避免过多开辟新线程造成的内存不足。 线程池的详细解析将在下一章中描述

public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)

上面是线程池的构造函数

corePoolSize是线程池的核心线程数,这些线程不会被回收。

maximumPoolSize是线程池最大线程数,线程池分为核心线程和非核心线程,两者之和为maximumPoolSize。非核心线程将会在任务执行完后一段时间被释放。

keepAliveTime非核心线程存在的时间。

unit 非核心线程存在的时间单位

workQueue任务队列,在核心线程都在处理任务的时候会将任务存放在任务队列中。只有当任务队列存放满后,才会启动非核心线程。

threadFactory构建线程的工程,一般会在其中处理一些线程启动前的操作。

handler拒绝策略,线程池被关闭的时候(调用shutdonw),线程池线程数等于maximumPoolSize,任务队列已满的时候会被调用。

主要方法

void execute(Runnable run)//提交任务,交由线程池调度

void shutdown()//关闭线程池,等待任务执行完成

void shutdownNow()//关闭线程池,不等待任务执行完成

int getTaskCount()//返回线程池找中所有任务的数量  (已完成的任务+阻塞队列中的任务)

int getCompletedTaskCount()//返回线程池中已执行完成的任务数量  (已完成的任务)

int getPoolSize()//返回线程池中已创建线程数量

int getActiveCount()//返回当前正在运行的线程数量

void terminated() 线程池终止时执行的策略

线程池还有几种内置的方式。

5.1.newFixedThreadPool 创建固定线程数的线程池。核心线程数和最大核心线程数相等为入参。

这种方式创建的线程池,因为使用的是无界LinkedBlockingQueue队列,不加控制的话会引起内存溢出

创建固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

5.2.newSingleThreadExecutor 创建一个核心线程数和最大线程数为1的线程池,使用的也是LinkedBlockingQueue。 也会引发内存问题

public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

5.3.newCachedThreadPool 创建一个无核心线程,最大线程数无限大的线程池。因为使用的是SynchronousQueue队列,不会存储任务,每提交一个任务就会创建一个新的线程使用。当任务足够多的情况下也会引起内存溢出。

public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

上述三种方式,其实都不建议。使用线程池应该根据使用的场景,合理的安排核心线程和非核心线程。

收起阅读 »

Twitter 上有趣的代码

全文分为 视频版 和 文字版, 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确 视频版: 视频以动画的形式会更加的直观,看完文字版,在看视频,知识点会更加清楚,Twitter 上有趣的代码_哔哩哔哩_bilibili 这是海外一...
继续阅读 »

全文分为 视频版文字版



  • 文字版: 文字侧重细节和深度,有些知识点,视频不好表达,文字描述的更加准确

  • 视频版: 视频以动画的形式会更加的直观,看完文字版,在看视频,知识点会更加清楚,Twitter 上有趣的代码_哔哩哔哩_bilibili


这是海外一位 Kotlin GDE 大佬,在 Twitter 上分享的一段代码,我觉得非常的有意思,代码如下所示,我们花 10s 思考一下,输出结果是什么。


fun printE() = { println("E") }

fun main() {
if (true) println("A")
if (true) { println("B") }
if (true) {
{ println("C") }
}

{ println("D") }

printE()

when {
true -> { println("F") }
}
}

在 Twitter 评论区中也能看到很多不同的答案。


pic02


实际上最后输出结果如下所示。


A
B
F

不知道你第一次看到这么多混乱的花括是什么感觉,当我第一次看到这段代码的时候,我觉得非常的有意思。


如果在实际项目中有小伙伴这么嵌套花括号,我相信肯定会被拉出去暴晒。但是细心观察这段代码,我们能学习到很多 Kotlin 相关的知识点,我们先来说一下为什么最后输出的结果是 A B F


下面图中红色标注部分,if 表达式、 when ... case 表达,如果表达式内只有一行代码的话,花括号是可以省略的,程序执行到代码位置会输出对应的结果, 即 A B F



那为什么 C D E 没有打印,因为图中绿色部分是 lambda 表达式,在 Kotlin 中 lambda 表达式非常的自由,它可以出现在很多地方比如方法内、 if 表达式内、循环语句内、甚至赋值给一个变量、或者当做方法参数进行传递等等。


lambda 表达式用花括号包裹起来,用箭头把实参列表和 lambda 函数体分离开来,如下所示。


{ x: Int -> println("lambda 函数体") }

如果没有参数,上面的代码可以简写成下面这样。


{ println("lambda 函数体") }

C D E 的输出语句在 lambda 函数体内, lambda 表达式我们可以理解为高阶函数,在上面的代码中只是声明了这个函数,但是并没有调用它,因此不会执行,自然也就不会有任何输出。现在我将上面的代码做一点点修改,在花 10s 思考一下输出结果是什么。


fun printE() = { println("E") }

fun main() {
if (true) println("A")
if (true) { println("B") }
if (true) {
{ println("C") }()
}

{ println("D") }()

printE()()

when {
true -> { println("F") }
}
}

最后的输出结果是:


A
B
C
D
E
F

应该有小伙伴发现了我做了那些修改,我只是在 lambda 表达式后面加了一个 (),表示执行当前的 lambda 表达式,所以我们能看到对应的输出结果。如下图所示,



lambda 表达式最终会编译成 FunctionN 函数,如下图所示。



如果没有参数会编译成 Function0,一个参数编译成 Function1,以此类推。FunctionN 重载了操作符 invoke。如下图所示。



因此我们可以调用 invoke 方法来执行 lambda 表达式。


{ println("lambda 函数体") }.invoke()
复制代码

当然 Kotlin 也提供了更加简洁的方式,我们可以使用 () 来代替 invoke(),最后的代码如下所示。


{ println("lambda 函数体") }()
复制代码

到这里我相信小伙伴已经明白了上面代码输出的结果,但是这里隐藏了一个有性能损耗的风险点,分享一段我在实际项目中见到的代码,示例中的代码,我做了简化。


fun main() {

(1..10).forEach { value ->
calculate(value) { result ->
println(result)
}
}

}


fun calculate(x: Int, lambda: (result: Int) -> Unit) {
lambda(x + 10)
}

上面的代码其实存在一个比较严重的性能问题,我们看一下反编译后的代码。



每次在循环中都会创建一个 FunctionN 的对象,那么如何避免这个问题,我们可以将 lambda 表达式放在循环之外,这样就能保证只会创建一个 FunctionN 对象,我们来看一下修改后的代码。


fun main() {
val lambda: (result: Int) -> Unit = { result ->
println(result)
}

(1..10).forEach { value ->
calculate(value, lambda)
}

}


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

代码中被植入了恶意删除操作,太狠了!

背景在交接的代码中做手脚进行删库等操作,之前只是网上听说的段子,没想到上周还真遇到了,并且亲自参与帮忙解决。事情是这样的,一老板接手了一套系统,可能因为双方在交接时出现了什么不愉快的事情,对方不提供源代码,只是把生产环境的服务器打了一个镜像给到对方。对方拿到镜...
继续阅读 »

背景

在交接的代码中做手脚进行删库等操作,之前只是网上听说的段子,没想到上周还真遇到了,并且亲自参与帮忙解决。

事情是这样的,一老板接手了一套系统,可能因为双方在交接时出现了什么不愉快的事情,对方不提供源代码,只是把生产环境的服务器打了一个镜像给到对方。

对方拿到镜像恢复之后,系统起来怎么也无法正常处理业务,于是就找到我帮忙看是什么原因。经过排查,原来交接的人在镜像中做了多处手脚,多处删除核心数据及jar包操作。下面来给大家细细分析排查过程。

排查过程

由于只提供了镜像文件,导致到底启动哪些服务都是问题。好在是Linux操作系统,镜像恢复之后,通过history命令可以查看曾经执行了哪些命令,能够找到都需要启动哪些服务。但服务启动之后,业务无法正常处理,很多业务都处于中间态。

原本系统是可以正常跑业务的,打个镜像之后再恢复就不可以了?这就奇怪了。于是对项目(jar包或war)文件进行排查,查看它们的修改时间。

在文件的修改时间上还真找到了一些问题,发现在打镜像的两个小时前,项目中一个多个项目底层依赖的jar包被修改过,另外还有两个class文件被修改过。

于是,就对它们进行了重点排查。首先反编译了那两个被修改过的class文件,在代码中找到了可疑的地方。


在两个被修改的类中都有上述代码。最开始没太留意这段代码,但直觉告诉我不太对,一个查询业务里面怎么可能出现删除操作呢?这太不符合常理了。

于是仔细阅读上述代码,发现上述红框中的代码无论何时执行最终的结果都是id=1。你是否看出来了?问题就出在三目表达式上,无论id是否为null,id被赋的值都是1。看到这里,也感慨对方是用心了。为了隐藏这个目的,前面写了那么多无用的代码。

但只有这个还不是什么问题,毕竟如果只是删除id为1的值,也只是删除了一条记录,影响范围应该有限。

紧接着反编译了被修改的jar包,依次去找上述删除方法的底层实现,看到如下代码:


原来前面传递的id=1是为了配合where条件语句啊,当id=1被传递进来之后,就形成了where 1=1的条件语句。这个大家在mybatis中拼接多条件语句时经常用到。结果就是一旦执行了上述业务逻辑,就会触发删除T_QUART_DATA全表数据的操作。

T_QUART_DATA表中是用于存储触发定时任务的表达式,到这里也就明白了,为啥前面的业务跑不起来,全部是中间态了。因为一旦在业务逻辑中触发开关,把定时任务的cron表达式全部删除,十多个定时任务全部歇菜,业务也就跑步起来了。

找到了问题的根源,解决起来就不是啥事了,由于没有源代码,稍微费劲的是只能把原项目整个反编译出来,然后将改修改地方进行了修改。

又起波折

本以为到此问题已经解决完毕了,没想到第二天又出现问题了,项目又跑不起来了。经过多方排查和定位,感觉还有定时任务再进行暗箱操作。

于是通过Linux的crontab命令查看是否有定时任务在执行,执行crontab -ecrontab -l,还真看到有三个定时任务在执行。跟踪到定时任务执行的脚本中,而且明目张胆的起名deleteXXX:


而在具体的脚本中,有如下执行操作:


这下找到为什么项目中第二天为啥跑不起来了,原来Linux的定时任务将核心依赖包删除了,并且还会去重启服务。

为了搞破坏,真是煞费苦心啊。还好的是这个jar包在前一天已经反编译出来了,也算有了备份。

小结

原本以为程序员在代码中进行删库操作或做一些其他小手脚只是网络上的段子,大多数人出于职业操守或个人品质是不会做的。没想到这还真遇到了,而且对方为了隐藏删除操作,还做了一些小伪装,真的是煞费苦心啊。如果有这样的能力和心思,用在写出更优秀的代码或系统上或许更好。

当然,不知道他们在交接的过程中到底发生了什么,竟然用这样的方式对待昔日合作的伙伴。之所以写这篇文章,是想让大家学习如何排查代码问题的过程,毕竟用到了不少知识点和技能,但这并不是教大家如何去做手脚。无论怎样,最起码的职业操守还是要有的,这点不接受反驳。


作者:程序新视界
来源:juejin.cn/post/7140066341469290532

收起阅读 »

深入理解MMAP原理,大厂爱不释手的技术手段

为什么大厂爱不释手如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:跨平台,C++编写,可以支持多平台跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信高性能,实现用户...
继续阅读 »

为什么大厂爱不释手

如微信的MMKV 组件、美团的Logan组件,还有微信的日志模块xlog,为什么大厂偏爱它呢?他到底有什么魔力么?我认为主要原因如下:

  • 跨平台,C++编写,可以支持多平台

  • 跨进程,通过文件共享可以实现多个进程内存共享,实现进程通信

  • 高性能,实现用户空间和内核空间的零拷贝,速度快且节约内存等

  • 高稳定,页中断保护神,由操作系统实现的,稳定性可想而知

函数介绍

void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
复制代码
  • addr 代表映射的虚拟内存起始地址;

  • length 代表该映射长度;

  • prot 描述了这块新的内存区域的访问权限;

  • flags 描述了该映射的类型;

  • fd 代表文件描述符;

  • offset 代表文件内的偏移值。

mmap的强大之处在于,它可以根据参数配置,用于创建共享内存,从而提高文件映射区域的IO效率,实现IO零拷贝,后面讲下零拷贝的技术,对比下,决定这些功能的主要就是三个参数,下面一一解释

prot

四种情况如下:

  • PROT_EXEC,代表该内存映射有可执行权限,可以看成是代码段,通常存储CPU可执行机器码

  • PROT_READ,代表该内存映射可读

  • PROT_WRITE,代表该内存映射可写

  • PROT_NONE,代表该内存映射不能被访问

flags

比较有代表性的如下:

  • MAP_SHARED,创建一个共享映射区域

  • MAP_PRIVATE,创建一个私有映射区域

  • MAP_ANONYMOUS,创建一个匿名映射区域,该情况只需要传入-1即可

  • MAP_FIXED,当操作系统以addr为起始地址进行内存映射时,如果发现不能满足长度或者权限要求时,将映射失败,如果非MAP_FIXED,则系统就会再找其他合适的区域进行映射

fd

当参数fd不等于0时,内存映射将与文件进行关联,如果等于0,就会变成匿名映射,此时flags必为MAP_ANONYMOUS

应用场景


一个mmap竟有如此丰富的功能,从申请分配内存到加载动态库,再到进程间通信,真的是无所不能,强大到让人五体投地。下面就着四种情况,拿一个我最关心的父子进程通信来举例看下,实现一个简单的父子进程通信逻辑,毕竟我们学习的目的就是为了应用,光有理论怎么能称之为合格的博客呢?

父子进程共享内存

#include <iostream>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/mman.h>

int main() {
  pid_t c_pid = fork();

  char* shm = (char*)mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);

  if (c_pid == -1) {
      perror("fork");
      exit(EXIT_FAILURE);
  } else if (c_pid > 0) {
      printf("parent process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, my child");
      printf("parent process got a message: %s\n", shm);
      wait(nullptr);
  } else {
      printf("child process pid: %d\n", getpid());
      sprintf(shm, "%s", "hello, father.");
      printf("child process got a message: %s\n", shm);
      exit(EXIT_SUCCESS);
  }

  return EXIT_SUCCESS;
}

运行后打印如下

parent process pid: 87799
parent process got a message: hello, my child
child process pid: 87800
child process got a message: hello, father.

Process finished with exit code 0

用mmap创建了一块匿名共享内存区域,fd传入-1MAP_ANONYMOUS配置实现匿名映射,使用MAP_SHARED创建共享区域,使用fork函数创建子进程,这样来实现子进程通信,通过sprintf将格式化后的数据写入到共享内存中。

通过简单的几行代码就实现了跨进程通信,如此简单,这么强大的东西,背后有什么支撑么?带着问题我们接着一探究竟。

MMAP背后的保护神

说到MMAP的保护神,首页了解下内存页:在页式虚拟存储器中,会在虚拟存储空间和物理主存空间都分割为一个个固定大小的页,为线程分配内存是也是以页为单位。比如:页的大小为 4K,那么 4GB 存储空间就需要4GB/4KB=1M 条记录,即有 100 多万个 4KB 的页,内存页中,当用户发生文件读写时,内核会申请一个内存页与文件进行读写操作,如图


这时如果内存页中没有数据,就会发生一种中断机制,它就叫缺页中断,此中断就是MMAP的保护神,为什么这么说呢?我们知道mmap函数调用后,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存,当访问这些没有建立映射关系的虚拟内存时,CPU加载指令发现代码段是缺失的,就触发了缺页中断,中断后,内核通过检查虚拟地址的所在区域,发现存在内存映射,就可以通过虚拟内存地址计算文件偏移,定位到内存所缺的页对应的文件的页,由内核启动磁盘IO,将对应的页从磁盘加载到内存中。最终保护mmap能顺利进行,无私奉献。了解完缺页中断,我们再来细聊下mmap四种场景下的内存分配原理

四种场景分配原理


上面是一个简单的原理总结,并没有详细的展开,感兴趣可以自己查查资料哈。

总结

本次分享,主要介绍了mmap的四种应用场景,通过一个实例验证了父子进程间的通信,并深入mmap找到它的保护神,且深入了解到mmap在四种场景下,操作系统是如何组织分配,通过对这些的了解,在你之后的mmap实战应用有了更好的理论基础,可以根据不同的需求,不同的性能要求等,选择最合适的实现。


作者:i校长
来源:juejin.cn/post/7119116943256190990

收起阅读 »

以往项目中的压缩apk经验

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。1.无需国际化时,去除额外的语言配置在项目ap...
继续阅读 »

过往的开发中,由于项目中使用的图片、音乐文件、特殊字体文件,以及导入的第三包等导致了最后生成的apk往往体积过大。过大的apk对于用户来说体验会非常的差,下载慢、耗费流量多等。所以开发者需要适当的压缩自己的apk。

1.无需国际化时,去除额外的语言配置

在项目app module的build.gradle中的defaultConfig中配置 resConfigs,仅配置需要的语言选项。


2.去除不需要的so架构

在项目app module的build.gradle中的defautlConfig中配置 ndk,仅配置需要的so库。 armeabi-v7a,arm64-v8a基本满足需求,如果需要用虚拟机测试可以加上x86


3.使用webg替代png或jpg

webp格式是谷歌推出的一种有损压缩格式,这种图片格式相比png或jpg格式的图片损失的质量几乎可以忽略不计,但是压缩后的图片体积却比png或jpg要小很多。

4.混淆配置

分为代码混淆和资源混淆

4.1代码混淆

proguard可以混淆以及优化代码,减小dex文件的大小,开启后需要需要配置proguard-rules.pro文件来保留不需要混淆的类,以及第三方包的类。 在项目app module的buildType中的release中设置minifyEnable为true,即可开启混淆, proguardFiles 是你制定的混淆规则文件。


4.3资源混淆

关于资源混淆,我使用的是微信的AndResGuard,它会将资源路径变短。 项目github地址:github.com/shwenzhang/…

配置过程: 1.在项目级的build.gradle添加


2.在app module中的build.gralde中添加插件


3.在app module中的build.gradle中添加andResGuard,需要注意的是whiteList,该项目的github中有列了一些三方库需要添加的白名单,还有特别要注意的是项目中如果有使用getIdentifier来查找drawable资源或者mipmap资源,需要将资源名也加入到白名单。



作者:ChenYhong
来源:juejin.cn/post/7027480502193881101

收起阅读 »

Android登录拦截的场景-面向切面基于AOP实现

AOP
前言 场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。 非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方...
继续阅读 »

前言


场景如下:用户第一次下载App,点击进入首页列表,点击个人页面,需要校验登录,然后跳转到登录页面,注册/登录完成跳转到个人页面。


非常常见的场景,正常我们开发就只能判断是否已经登录,如果未登录就跳转到登录,然后登录完成之后怎么继续执行?如何封装?有哪些方式?其实很多人并不清楚。


这里做一个系列总结一下,看看公共有多少种方式实现,你们使用的是哪一种方案,或者说你们觉得哪一种方案最好用。


这一次分享的是全网最多的方案 ,面向切面 AOP 的方式。你去某度一搜,Android拦截登录 最多的结果就是AOP实现登录拦截的功能,既然大家都推荐,我们就来看看它到底如何?


一、了解面向切面AOP


我们学习Java的开始,我们一直就知道 OOP 面向对象,其实 AOP 面向切面,是对OOP的一个补充,AOP采取横向收取机制,取代了传统纵向继承体系重复性代码,把某一类问题集中在一个地方进行处理,比如处理程序中的点击事件、打印日志等。


AOP是编程思想就是把业务逻辑和横切问题进行分离,从而达到解耦的目的,提高代码的重用性和开发效率。OOP的精髓是把功能或问题模块化,每个模块处理自己的家务事。但在现实世界中,并不是所有功能都能完美得划分到模块中。AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。


我记得我最开始接触 AOP 还是在JavaEE的框架SSH的学习中,AspectJ框架,开始流行于后端,现在在Android开发的应用中也越来越广泛了,Android中使用AspectJ框架的应用也有很多,比如点击事件防抖,埋点,权限申请等等不一而足,这里不展开说明,毕竟我们这一期不是专门讲AspectJ的应用的。


简单的说一下AOP的重点概念(摘抄):




  • 前置通知(Before):在目标方法被调用之前调用通知功能。




  • 后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么。




  • 返回通知(After-returning):在目标方法成功执行之后调用通知。




  • 异常通知(After-throwing):在目标方法抛出异常后调用通知。




  • 环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和调用之后执行自定义的行为。




  • 连接点:是在应用执行过程中能够插入切面的一个点。




  • 切点: 切点定义了切面在何处要织入的一个或者多个连接点。




  • 切面:是通知和切点的结合。通知和切点共同定义了切面的全部内容。




  • 引入:引入允许我们向现有类添加新方法或属性。




  • 织入:是把切面应用到目标对象,并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期中有多个点可以进行织入:




  • 编译期: 在目标类编译时,切面被织入。这种方式需要特殊的编译器。AspectJ的织入编译器就是以这种方式织入切面的。




  • 类加载期:切面在目标加载到JVM时被织入。这种方式需要特殊的类加载器(class loader)它可以在目标类被引入应用之前增强该目标类的字节码。




  • 运行期: 切面在应用运行到某个时刻时被织入。一般情况下,在织入切面时,AOP容器会为目标对象动态地创建一个代理对象。SpringAOP就是以这种方式织入切面的。




简单理解就是把一个方法拿出来,在这个方法执行前,执行后,做一些特别的操作。关于AOP的基本使用推荐大家看看大佬的教程:


深入理解Android之AOP


不多BB,我们直接看看Android中如何使用AspectJ实现AOP逻辑,实现拦截登录的功能。


二、集成AOP框架


Java项目集成


buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'org.aspectj:aspectjtools:1.8.9'
classpath 'org.aspectj:aspectjweaver:1.8.9'
}
}

组件build.gradle


dependencies {
implementation 'org.aspectj:aspectjrt:1.9.6'
}


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

// 获取log打印工具和构建配置
final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
// 判断是否debug,如果打release把return去掉就可以
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
// return;
}
// 使aspectj配置生效
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
//在编译时打印信息如警告、error等等
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}

Kotlin项目集成


dependencies {
classpath 'com.android.tools.build:gradle:3.6.1'

classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.10'

项目build.gradle


apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

apply plugin: 'android-aspectjx'

android {
...

// AOP 配置
aspectjx {
// 排除一些第三方库的包名(Gson、 LeakCanary 和 AOP 有冲突)
exclude 'androidx', 'com.google', 'com.squareup', 'com.alipay', 'com.taobao',
'org.apache',
'org.jetbrains.kotlin',
"module-info", 'versions.9'
}

}

ependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation 'org.aspectj:aspectjrt:1.9.5'
}

集成AOP踩坑:
zip file is empty



和第三方包有冲突,比如Gson,OkHttp等,需要配置排除一下第三方包,



gradle版本兼容问题



AGP版本4.0以上不支持 推荐使用3.6.1



kotlin兼容问题 :



基本都是推荐使用 com.hujiang.aspectjx



编译版本兼容问题:



4.0以上使用KT编译版本为Java11需要改为Java8



组件化兼容问题:



如果在library的moudle中自定义的注解, 想要通过AspectJ来拦截织入, 那么这个@Aspect类必须和自定义的注解在同一moudle中, 否则是没有效果的



等等...


难点就在集成,如何在指定版本的Gradle,Kotlin项目中集成成功。只要集成成功了,使用到是简单了。


三、定义注解实现功能


定义标记的注解


//不需要回调的处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Login {
}

定义处理类


@Aspect
public class LoginAspect {

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}

//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}


object LoginManager {

@JvmStatic
fun isLogin(): Boolean {
val token = SP().getString(Constants.KEY_TOKEN, "")
YYLogUtils.w("LoginManager-token:$token")
val checkEmpty = token.checkEmpty()
return !checkEmpty
}

@JvmStatic
fun gotoLoginPage() {
commContext().gotoActivity<LoginDemoActivity>()
}
}

其实逻辑很简单,就是判断是否登录,看是放行还是跳转到登录页面


使用的逻辑也是很简单,把需要处理的逻辑使用方法抽取,并标记注解即可


    override fun init() {

mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}

mBtnProfile.click {

//不带回调的登录方式
gotoProfilePage2()
}

}

@Login
private fun gotoProfilePage2() {
gotoActivity<ProfileDemoActivity>()
}

效果:



这..这和我使用Token自己手动判断有什么区别,完成登录之后还得我再点一次按钮,当然了这只是登录拦截,我想要的是登录成功之后继续之前的操作,怎么办?


其实使用AOP的方式的话,我们可以使用消息通知的方式,比如LiveBus FlowBus之类的间接实现这个效果。


我们先单独的定义一个注解


//需要回调的处理用来触发用户登录成功后的后续操作
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginCallback {
}

修改定义的切面类


@Aspect
public class LoginAspect {

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.Login)")
public void Login() {
}

@Pointcut("@annotation(com.guadou.kt_demo.demo.demo3_bottomtabbar_fragment.aop.LoginCallback)")
public void LoginCallback() {
}

//带回调的注解处理
@Around("LoginCallback()")
public void loginCallbackJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-LoginCallback()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

LoginCallback loginCallback = ((MethodSignature) signature).getMethod().getAnnotation(LoginCallback.class);
if (loginCallback == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();

} else {
LifecycleOwner lifecycleOwner = (LifecycleOwner) joinPoint.getTarget();

LiveEventBus.get("login").observe(lifecycleOwner, new Observer<Object>() {
@Override
public void onChanged(Object integer) {
try {
joinPoint.proceed();
LiveEventBus.get("login").removeObserver(this);

} catch (Throwable throwable) {
throwable.printStackTrace();
LiveEventBus.get("login").removeObserver(this);
}
}
});

LoginManager.gotoLoginPage();
}
}

//不带回调的注解处理
@Around("Login()")
public void loginJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
YYLogUtils.w("走进AOP方法-Login()");
Signature signature = joinPoint.getSignature();

if (!(signature instanceof MethodSignature)){
throw new RuntimeException("该注解只能用于方法上");
}

Login login = ((MethodSignature) signature).getMethod().getAnnotation(Login.class);
if (login == null) return;

//判断当前是否已经登录
if (LoginManager.isLogin()) {
joinPoint.proceed();
} else {
//如果未登录,去登录页面
LoginManager.gotoLoginPage();
}


}
}

在去登录页面之前注册一个LiveEventBus事件,当登录完成之后发出通知,这里就直接放行调用注解的方法。即可完成继续执行的操作。


使用:


    override fun init() {

mBtnCleanToken.click {
SP().remove(Constants.KEY_TOKEN)
toast("清除成功")
}

mBtnProfile.click {

//不带回调的登录方式
gotoProfilePage()
}

}

@LoginCallback
private fun gotoProfilePage() {
gotoActivity<ProfileDemoActivity>()
}

效果:



总结


从上面的代码我们就基于AOP思想实现了登录拦截功能,以后我们对于需要用户登录之后才能使用的功能只需要在对应的方法上添加指定的注解即可完成逻辑,彻底摆脱传统耗时耗力的开发方式。


需要注意的是AOP框架虽然使用起来很方便,能帮我们轻松完成函数插桩功能,但是它也有自己的缺点。AspectJ 在实现时会包装自己的一些类,不仅会影响切点方法的性能,还会导致安装包体积的增大。最关键的是对Kotlin不友好,对高版本AGP不友好,所以大家在使用时需要仔细权衡是否适合自己的项目。如有需求可以运行源码查看效果。源码在此


由于篇幅原因,后期会出单独出一些其他方式实现的登录拦截的实现,如果觉得这种方式不喜欢,大家可以对比一下哪一种方式比较你胃口。大家可以点下关注看看最新更新。


题外话:
我发现大家看我的文章都喜欢看一些总结性的,实战性的,直接告诉你怎么用的那种。所以我尽量不涉及到集成原理与基本使用,直接开箱即用。当然关于原理和基本的使用,我也不是什么大佬,我想大家也看不上我讲的。不过我也会给出推荐的文章。


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

定位都得集成第三方?Android原生定位服务LocationManager不行吗?

前言 现在的应用,几乎每一个 App 都存在定位的逻辑,方便更好的推荐产品或服务,获取当前设备的经纬度是必备的功能了。有些 App 还是以LBS(基于位置服务)为基础来实现的,比如美团,饿了吗,不获取到位置都无法使用的。 有些同学觉得不就是获取到经纬度么,An...
继续阅读 »

前言


现在的应用,几乎每一个 App 都存在定位的逻辑,方便更好的推荐产品或服务,获取当前设备的经纬度是必备的功能了。有些 App 还是以LBS(基于位置服务)为基础来实现的,比如美团,饿了吗,不获取到位置都无法使用的。


有些同学觉得不就是获取到经纬度么,Android 自带的就有位置服务 LocationManager ,我们无需引入第三方服务,就可以很方便的实现定位逻辑。


确实 LocationManager 的使用很简单,获取经纬度很方便,我们就无需第三方的服务了吗? 或者说 LocationManager 有没有坑呢?兼容性问题怎么样?获取不到位置有没有什么兜底策略?


一、LocationManager的使用


由于是Android的系统服务,直接 getSystemService 可以获取到


LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
复制代码

一般获取位置有两种方式 NetWork 与 GPS 。我们可以指定方式,也可以让系统自动提供最好的方式。


// 获取所有可用的位置提供器
List<String> providerList = locationManager.getProviders(true);
// 可以指定优先GPS,再次网络定位
if (providerList.contains(LocationManager.GPS_PROVIDER)) {
provider = LocationManager.GPS_PROVIDER;
} else if (providerList.contains(LocationManager.NETWORK_PROVIDER)) {
provider = LocationManager.NETWORK_PROVIDER;
} else {
// 当没有可用的位置提供器时,弹出Toast提示用户
return;
}
复制代码

当然我更推荐由系统提供,当我的设备在室内的时候就会以网络的定位提供,当设备在室外的时候就可以提供GPS定位。


 String provider = locationManager.getBestProvider(criteria, true);
复制代码

我们可以实现一个定位的Service实现这个逻辑


/**
* 获取定位服务
*/
public class LocationService extends Service {

private LocationManager lm;
private MyLocationListener listener;

@Override
public IBinder onBind(Intent intent) {
return null;
}

@SuppressLint("MissingPermission")
@Override
public void onCreate() {
super.onCreate();

lm = (LocationManager) getSystemService(LOCATION_SERVICE);
listener = new MyLocationListener();

Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_COARSE);
criteria.setAltitudeRequired(false);//不要求海拔
criteria.setBearingRequired(false);//不要求方位
criteria.setCostAllowed(true);//允许有花费
criteria.setPowerRequirement(Criteria.POWER_LOW);//低功耗

String provider = lm.getBestProvider(criteria, true);

YYLogUtils.w("定位的provider:" + provider);

Location location = lm.getLastKnownLocation(provider);

YYLogUtils.w("" + location);

if (location != null) {
//不为空,显示地理位置经纬度
String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("getLastKnownLocation:" + longitude + "-" + latitude);

stopSelf();

}

//第二个参数是间隔时间 第三个参数是间隔多少距离,这里我试过了不同的各种组合,能获取到位置就是能,不能获取就是不能
lm.requestLocationUpdates(provider, 3000, 10, listener);
}

class MyLocationListener implements LocationListener {
// 位置改变时获取经纬度
@Override
public void onLocationChanged(Location location) {

String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("onLocationChanged:" + longitude + "-" + latitude);


stopSelf(); // 获取到经纬度以后,停止该service
}

// 状态改变时
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
YYLogUtils.w("onStatusChanged - provider:"+provider +" status:"+status);
}

// 提供者可以使用时
@Override
public void onProviderEnabled(String provider) {
YYLogUtils.w("GPS开启了");
}

// 提供者不可以使用时
@Override
public void onProviderDisabled(String provider) {
YYLogUtils.w("GPS关闭了");
}

}

@Override
public void onDestroy() {
super.onDestroy();
lm.removeUpdates(listener); // 停止所有的定位服务
}

}
复制代码

使用:定义并动态申请权限之后即可开启服务



fun testLocation() {

extRequestPermission(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) {

startService(Intent(mActivity, LocationService::class.java))

}

}

复制代码

这样我们启动这个服务就可以获取到当前的经纬度,只是获取一次,大家如果想再后台持续定位,那么实现的方式就不同了,我们服务要设置为前台服务,并且需要额外申请后台定位权限。


话说回来,这么使用就一定能获取到经纬度吗?有没有兼容性问题


Android 5.0 Oppo



Android 6.0 Oppo海外版



Android 7.0 华为



Android 11 三星海外版



Android 12 vivo



目前测试不多,也能发现问题,特别是一些低版本,老系统的手机就可能无法获取位置,应该是系统的问题,这种服务跟网络没关系,开不开代理都是一样的。


并且随着测试系统的变高,越来越完善,提供的最好定位方式还出现混合定位 fused 的选项。


那是不是6.0的Oppo手机太老了,不支持定位了?并不是,百度定位可以获取到位置的。



既然只使用 LocationManager 有风险,有可能无法获取到位置,那怎么办?


二、混合定位


其实目前百度,高度的定位Api的服务SDK也不算大,相比地图导航等比较重的功能,定位的SDK很小了,并且目前都支持海外的定位服务。并且定位服务是免费的哦。


既然 LocationManager 有可能获取不到位置,那我们就加入第三方定位服务,比如百度定位。我们同时使用 LocationManager 和百度定位,哪个先成功就用哪一个。(如果LocationManager可用的话,它的定位比百度定位更快的)


完整代码如下:


@SuppressLint("MissingPermission")
public class LocationService extends Service {

private LocationManager lm;
private MyLocationListener listener;
private LocationClient mBDLocationClient = null;
private MyBDLocationListener mBDLocationListener;

@Override
public IBinder onBind(Intent intent) {
return null;
}

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

createNativeLocation();

createBDLocation();
}

/**
* 第三方百度定位服务
*/
private void createBDLocation() {
mBDLocationClient = new LocationClient(UIUtils.getContext());
mBDLocationListener = new MyBDLocationListener();
//声明LocationClient类
mBDLocationClient.registerLocationListener(mBDLocationListener);
//配置百度定位的选项
LocationClientOption option = new LocationClientOption();
option.setLocationMode(LocationClientOption.LocationMode.Battery_Saving);
option.setCoorType("WGS84");
option.setScanSpan(10000);
option.setIsNeedAddress(true);
option.setOpenGps(true);
option.SetIgnoreCacheException(false);
option.setWifiCacheTimeOut(5 * 60 * 1000);
option.setEnableSimulateGps(false);
mBDLocationClient.setLocOption(option);
//开启百度定位
mBDLocationClient.start();
}

/**
* 原生的定位服务
*/
private void createNativeLocation() {

lm = (LocationManager) getSystemService(LOCATION_SERVICE);
listener = new MyLocationListener();

Criteria criteria = new Criteria();
criteria.setAccuracy(Criteria.ACCURACY_COARSE);
criteria.setAltitudeRequired(false);//不要求海拔
criteria.setBearingRequired(false);//不要求方位
criteria.setCostAllowed(true);//允许有花费
criteria.setPowerRequirement(Criteria.POWER_LOW);//低功耗

String provider = lm.getBestProvider(criteria, true);

YYLogUtils.w("定位的provider:" + provider);

Location location = lm.getLastKnownLocation(provider);

YYLogUtils.w("" + location);

if (location != null) {
//不为空,显示地理位置经纬度
String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("getLastKnownLocation:" + longitude + "-" + latitude);

stopSelf();

}

lm.requestLocationUpdates(provider, 3000, 10, listener);
}

class MyLocationListener implements LocationListener {
// 位置改变时获取经纬度
@Override
public void onLocationChanged(Location location) {

String longitude = "Longitude:" + location.getLongitude();
String latitude = "Latitude:" + location.getLatitude();

YYLogUtils.w("onLocationChanged:" + longitude + "-" + latitude);


stopSelf(); // 获取到经纬度以后,停止该service
}

// 状态改变时
@Override
public void onStatusChanged(String provider, int status, Bundle extras) {
YYLogUtils.w("onStatusChanged - provider:" + provider + " status:" + status);
}

// 提供者可以使用时
@Override
public void onProviderEnabled(String provider) {
YYLogUtils.w("GPS开启了");
}

// 提供者不可以使用时
@Override
public void onProviderDisabled(String provider) {
YYLogUtils.w("GPS关闭了");
}

}


/**
* 百度定位的监听
*/
class MyBDLocationListener extends BDAbstractLocationListener {

@Override
public void onReceiveLocation(BDLocation location) {

double latitude = location.getLatitude(); //获取纬度信息
double longitude = location.getLongitude(); //获取经度信息


YYLogUtils.w("百度的监听 latitude:" + latitude);
YYLogUtils.w("百度的监听 longitude:" + longitude);

YYLogUtils.w("onBaiduLocationChanged:" + longitude + "-" + latitude);

stopSelf(); // 获取到经纬度以后,停止该service
}
}

@Override
public void onDestroy() {
super.onDestroy();
// 停止所有的定位服务
lm.removeUpdates(listener);

mBDLocationClient.stop();
mBDLocationClient.unregisterLocationListener(mBDLocationListener);
}

}
复制代码

其实逻辑都是很简单的,并且省略了不少回调通信的逻辑,这里只涉及到定位的逻辑,别的逻辑我就尽量不涉及到。


百度定位服务的API申请与初始化请自行完善,这里只是简单的使用。并且坐标系统一为国际坐标,如果需要转gcj02的坐标系,可以网上找个工具类,或者看我之前的文章


获取到位置之后,如何Service与Activity通信,就由大家自由发挥了,有兴趣的可以看我之前的文章


总结


所以说Android原生定位服务 LocationManager 还是有问题啊,低版本的设备可能不行,高版本的Android系统又很行,兼容性有问题!让人又爱又恨。


很羡慕iOS的定位服务,真的好用,我们 Android 的定位服务真是拉跨,居然还有兼容性问题。


我们使用第三方定位服务和自己的 LocationManager 并发获取位置,这样可以增加容错率。是比较好用的,为什么要加上 LocationManager 呢?我直接单独用第三方的定位服务不香吗?可以是可以,但是如果设备支持 LocationManager 的话,它会更快一点,体验更好。


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

【Flutter 异步编程 - 壹】 | 单线程下的异步模型

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究! 一、 本专栏图示概念规范 本专栏是对 异步编程 的系统探索,会通过各个方面去认知、思考 异步编程 的概念。期间会用到一些图片进行表达与示意,在一开始先对图中的元素和 ...
继续阅读 »

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!



一、 本专栏图示概念规范


本专栏是对 异步编程 的系统探索,会通过各个方面去认知、思考 异步编程 的概念。期间会用到一些图片进行表达与示意,在一开始先对图中的元素基本概念 进行规范和说明。




1. 任务概念规范


任务 : 完成一项需求的基本单位。

分发任务: 触发任务开始的动作。

任务结束: 任务完成的标识。

任务生命期: 任务从开始到完成的时间跨度。



如下所示,方块 表示任务;当 箭头指向一个任务时,表示对该任务进行分发;任何被分发的任务都会结束。在任务分发和结束之间,有一条虚线进行连接,表示 任务生命期





2. 任务的状态


未完成 : Uncompleted

成功完成 : Completed with Success

异常结束 : Completed with Error



一个任务生命期间有三种状态,如下通过三种颜色表示。在 任务结束 之前,该任务都是 未完成 态,通过 浅蓝色 表示;任何被分发的任务都是为了完成某项需求,任何任务都会结束,在结束时刻根据是否完成需求,可分为 成功完成异常结束 两种状态,如下分别用 绿色红色 表示。





3. 时刻与时间线


机体 : 任务分发者或处理者。

时刻: 机体运行中的某一瞬间。

时间线: 所有时刻构成的连续有向轴线。



在一个机体运行的过程中,时间线是绝对的,通过 紫色有向线段 表示时间的流逝的方向。时刻 是时间线上任意一点 ,通过 黑点 表示。





4.同步与异步


同步 : 机体在时间线上,将任务按顺序依次分发。



同步执行任务时,前一个任务完成后,才会分发下一任务。意思是说: 任意时刻只有一个任务在生命期中。




异步: 机体在时间线上,在一个任务未完成时,分发另一任务。



也就是说通过异步编程,允许某时刻有两个及以上的任务在生命期中。如下所示,在 任务1 完成后,分发 任务2; 在 任务2 未结束的情况下,可以分发 任务 3 。此时对于任务 3 来说,任务 2 就是异步执行的。


image.png




二、理解单线程中的异步任务


上面对基本概念进行了规范,看起来可能比较抽象,下面我们通过一个小场景来理解一下。妈妈早上出门散步,临走前嘱咐:



小捷,别睡了。快起床,把被子晒一下,地扫一下。还有,没开水了,记得烧。



当前场景下只有小捷 一个机体,需要完成的任务有四个:起床晒被拖地烧水


image.png




1. 任务的分配

当机体有多个任务需要分发时,需要对任务进行分配。认识任务之间的关系,是任务分配的第一步。只有理清关系,才能合理分配任务。分配过程中需要注意:


[1] 任务之间可能存在明确的先后顺序,比如起床 需要在 晒被 之前。

[2] 任务之间先后顺序也可能无所谓,比如先扫地还是先晒被,并没有太大区别。

[3] 某类任务只需要机体来分发,生命期中不需要机体处理,并且和后续的任务没有什么关联性,比如烧水任务。


image.png


像烧水这种任务,即耗时,又不需要机体在任务生命期中做什么事。如果这类任务使用同步处理,那么任务期间机体能做的事只有 等待 。对于一个机体来说,这种等待就会意味着阻塞,不能处理任何事。


结合日常生活,我们知道当前场景之中,想要发挥机体最大的效力,最好的方式是起床之后,先分发 烧水任务,不需要等待烧水任务完成,就去执行晒被、扫地任务。这样的任务分配就是将 烧水 作为一个异步任务来执行的。


但在如果在分配时,将烧水作为最后一个任务,那么异步执行的价值就会消失。所以对任务的合理分配,对机体的处理效率是非常重要的。




2.异步任务特点

从上面可以看出,异步任务 有很明显的特征,并不是任何任务都有必要异步执行。特别是对于单一机体来说,任务生命期间需要机体亲自参与,是无法异步处理的。 比如一个人不能一边晒被 ,一边 扫地 。所以对于单线程来说,像一些只需要 分发任务,任务的具体执行逻辑由其他机体完成的任务,适合使用 异步 处理,来避免不必要的等待。


这种任务,在应用程序中最常见的是网络 io磁盘 io 的任务。比如,从一个网络接口中获取数据,对于机体来说,只需要分发任务来发送请求,就像烧水时只需要装水按下启动键一样。而服务器如何根据请求,查询数据库来返回响应信息,数据如何在网络中传输的,和分发任务的机体没有关系。磁盘的访问也是一样,分发读写文件任务后,真正干活的是操作系统。


像这类任务通过异步处理,可以避免在分发任务后,机体因等待任务的结束而阻塞。在等待其他机体处理的过程中,去分发其他任务,可以更好地分配时间。比如下面所示,网络数据获取 的任务分发后,需要通过网络把请求传输给服务器,服务器进行处理,给出响应结果。



整个任务处理的过程,并不需要机体参与,所以分发 网络数据获取 任务后,无需等待任务完成,接着分发 构建加载中界面 的任务,来展示加载中的界面。从而给出用户交互的反馈,而不是阻塞在那里等待网络任务完成,这就是一个非常典型的异步任务使用场景。




3. 异步任务完成与回调

前面的介绍中可以看出,异步任务在分发之后,并不会等待任务完成,在任务生命期中,可以继续分发其他任务。但任何任务都会结束,很多时候我们需要知道异步任务何时完成,以及任务的完成情况、任务返回的结果,以便该任务后续的处理。比如,在烧水完成之后,我们需要处理 冲水 的任务。


image.png


这就要涉及到一个对异步而言非常重要的概念:



回调: 任务在生命期间向机体提供通知的方式。



比如 烧水 任务完成后,烧水壶 “叮” 的一声通知任务完成;或者烧水期间发生故障,发出报警提示。这种在任务生命期间向机体发送通知的方式称为回调 。在编程中,回调一般是通过 函数参数 来实现的,所以习惯称 回调函数 。 另外,函数可以传递数据,所以通过回调函数不仅可以知道任务结束的契机,还可以通过回调参数将任务的内部数据暴露给机体。


比如在实际开发中,分发 网络数据获取 的任务,其目的是为了通过网络接口获取数据。就像烧开水任务完成之后,需要把 开水 倒入瓶中一样。我们也需要知道 网络数据获取 的任务完成的时机,将获取的数据 "倒入" 界面中进行显示。



从发送异步任务,到异步任务结束的回调触发,就是一个异步任务完整的 生命期




三、 Dart 语言中的异步


上面只是介绍了 异步模型 中的概念,这些概念是共通的,无论什么编程语言都一样适用。就像现实中,无论使用哪国的语言表述,四则运算的概念都不会有任何区别。只是在表述过程中,表现形式会在语言的语法上有所差异。




1.编程语言中与异步模型的对应关系

每种语言的描述,都是对概念模型的具象化实现。这里既然是对 Flutter 中异步编程的介绍,自然要说一下 Dart 语言对异步模型的描述。


对于 任务 概念来说,在编程中和 函数 有着千丝万缕的联系:函数体 可以实现 任务处理的具体逻辑,也可以触发 任务分发的动作 。但我并不认为两者是等价的, 任务 有着明确的 目的性 ,而 函数 是实现这种 目的 的手段。在编程活动中,函数 作为 任务 在代码中的逻辑体现,任务 应先于 函数 存在。


如下代码所示,在 main 函数中,触发 calculate 任务,计算 0 ~ count 累加值和计算耗时,并返回。其中 calculate 函数就是对该任务的代码实现:


void main(){
TaskResult result = calculate();
}


TaskResult calculate({int count = 10000000}){
int startTime = DateTime.now().millisecondsSinceEpoch;
int result = loopAdd(count);
int cost = DateTime.now().millisecondsSinceEpoch-startTime;
return TaskResult(
cost:cost,
data:result,
taskName: "calculate"
);
}

int loopAdd(int count) {
int sum = 0;
for (int i = 0; i <= count; i++) {
sum+=i;
}
return sum;
}

这里 TaskResult 类用于记录任务完成的信息:


class TaskResult {
final int cost;
final String taskName;
final dynamic data;

TaskResult({
required this.cost,
required this.data,
required this.taskName,
});

Map<String,dynamic> toJson()=>{
"taskName":taskName,
"cost":cost,
"data": data
};
}



2.Dart 编程中的异步任务

如下在计算之后,还有两个任务:saveToFile 任务,将运算结果保存到文件中;以及 render 任务将运算结果渲染到界面上。


void main() {
TaskResult result = cacaulate();
saveToFile(result);
render(result);
}

这里 render 任务暂时通过在控制台打印显示作为渲染,逻辑如下:


void render(TaskResult result) {
print("结果渲染: ${result.toJson()}");
}

下面是将结果写入文件的任务实现逻辑。其中 File 对象的 writeAsString 是一个异步方法,可以将内容写入到文件中。通过 then 方法设置回调,监听任务完成的时机。



void saveToFile(TaskResult result) {
String filePath = path.join(Directory.current.path, "out.json");
File file = File(filePath);
String content = json.encode(result);
file.writeAsString(content).then((File value){
print("写入文件成功:!${value.path}");
});
}



3.当前任务分析

如下是这三个任务的执行示意,在 saveToFile 中使用 writeAsString 方法将异步处理写入逻辑。



这样就像在烧水任务分发后,可以执行晒被一样。saveToFile 任务分发之后,不需要等待文件写入完成,可以继续执行 render 方法。日志输出如下:渲染任务的执行并不会因写入文件任务而阻塞,这就是异步处理的价值。


image.png




四、异步模型的延伸


1. 单线程异步模型的局限性

本文主要介绍 异步模型 的概念,认识异步的作用,以及 Dart 编程语言中异步方法的基本使用。至于代码中更具体的异步使用方式,将在后期文章中结合详细介绍。另外,一般情况下,Dart 是以 单线程 运行的,所以本文中强调的是 单线程 下的异步模型。


仔细思考一下,可以看出,单线程中实现异步是有局限性的。比如说需要解析一个很大的 json ,或者进行复杂的逻辑运算等 耗时任务,这种必须由 本机体 处理的逻辑,而不是 等待结果 的场景,是无法在单线程中异步处理的。


就像是 扫地晒被 任务,对于单一机体来说,不可能同时参与到两个任务之中。在实际开发中这两个任务可类比为 解析超大 json显示解析中界面 两个任务。如果前者耗时三秒,由于单线程 中同步方法的阻塞,界面就会卡住三秒,这就是单线程异步模型的 局限性




2. 多线程与异步的关系

上面问题的本质矛盾是:一个机体无法 同时 参与到两件任务 具体执行过程中。解决方案也非常简单,一个人搞不定,就摇人呗。多个机体参与任务分配的场景,就是 多线程
很多人都会讨论 异步多线程 的关系,其实很简单:两个机体,一个 扫地,一个 晒被,同一时刻,存在两个及以上的任务在生命期中,一定是异步的。毫无疑问,多线程异步模型 的一种实现方式。





3. Dart 中如何解决单线程异步模型的局限性

C++Java 这些语言有 多线程 的支持,通过 “摇人” 可以充分调度 CPU 核心,来处理一些计算密集型的任务,实现任务在时间上的最合理分配。


绝大多数人可能觉得 Dart 是一个单线程的编程语言,其实不然。可能是很多人并没有在 Flutter 端做过计算密集型的任务,没有对多线程迫切的需要。毕竟 移动/桌面客户端 大多是网络、数据库访问等 io 密集型 的任务,人手一个终端,没有什么高并发的场景。不像后端那样需要保证一个终端被百万人同时访问。


或者计算密集型的任务都有由平台机体进行处理,将结果通知给 Flutter 端。这导致 Dart 看起来更像是一个 任务分发者,发号施令的人,绝大多数时候并不需要亲自参与任务的执行过程中。而这正是单线程下的异步模型所擅长的:借他人之力,监听回调信息


其实我们在日常开发中,使用的平台相关的插件,其中的方法基本上都是异步的,本质上就是这个原因。平台 是个烧水壶,烧水任务只需要分发监听回调。至于水怎么烧开,是 平台 需要关心的,这和 网络 io磁盘 io 是很类似的,都是 请求响应 的模式。这种任务,由单线程的异步模型进行处理,是最有效的,毕竟 “摇人” 还是要管饭的。


那如果非要在 Dart 中处理计算密集型的任务,该如何是好呢?不用担心,Dartisolate 机制可以完成这项需求。关于这点,在后面会进行详述。认识 异步 是什么,是本文的核心,那本文就到这里,谢谢观看 ~


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

栈都知道,单调栈有了解吗?

前言 大家好,我是小彭。 今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后进先出(LIFO)逻辑的数据结构,而单调栈实际上就是在栈的基础上增加单调的性质(单调递增或单调递减)。那么,单调栈是用来解决什...
继续阅读 »

前言


大家好,我是小彭。


今天分享到一种栈的衍生数据结构 —— 单调栈(Monotonic Stack)。栈(Stack)是一种满足后进先出(LIFO)逻辑的数据结构,而单调栈实际上就是在栈的基础上增加单调的性质(单调递增或单调递减)。那么,单调栈是用来解决什么问题的呢?




学习路线图:





1. 单调栈的典型问题


单调栈是一种特别适合解决 “下一个更大元素” 问题的数据结构。


举个例子,给定一个整数数组,要求输出数组中元素 ii 后面下一个比它更大的元素,这就是下一个更大元素问题。这个问题也可以形象化地思考:站在墙上向后看,问视线范围内所能看到的下一个更高的墙。例如,站在墙 [3] 上看,下一个更高的墙就是墙 [4]


形象化思考



这个问题的暴力解法很容易想到:就是遍历元素 ii 后面的所有元素,直到找到下一个比 ii 更大的元素为止,时间复杂度是 O(n)O(n),空间复杂度是 O(1)O(1)。单次查询确实没有优化空间了,那多次查询呢?如果要求输出数组中每个元素的下一个更大元素,那么暴力解法需要的时间复杂度是 O(n2)O(n^2) 。有没有更高效的算法呢?




2. 解题思路


我们先转变一下思路:


在暴力解法中,我们每处理一个元素就要去求它的 “下一个更大元素”。现在我们不这么做,我们每处理一个元素时,由于不清楚它的解,所以先将它缓存到某种数据结构中。后续如果能确定它的解,再将其从缓存中取出来。 这个思路可以作为 “以空间换时间” 优化时间复杂度的通用思路。


回到这个例子上:



  • 在处理元素 [3] 时,由于不清楚它的解,只能先将 [3] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 在处理元素 [2] 时,我们观察缓存中的 [1] 比当前元素小,说明当前元素就是 [1] 的解。此时我们可以把 [1] 从缓存中弹出,记录结果。再将 [2] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 在处理元素 [4] 时,我们观察缓存中的 [3] [2] [1] 都比当前元素小,说明当前元素就是它们的解。此时我们可以把它们从缓存中弹出,记录结果。再将 [4] 放到缓存中,继续处理下一个元素;

  • 在处理元素 [1] 时,我们观察缓存发现它比缓存中所有元素都小,只能先将它放到缓存中,继续处理下一个元素;

  • 遍历结束,从缓存中弹出过的元素都是有解的,保留在缓存中的元素都是无解的。


分析到这里,我们发现问题已经发生转变,问题变成了:“如何寻找在缓存中小于当前元素的数”。 现在,我们把注意力集中在这个缓存上,思考一下用什么数据结构、用什么算法可以更高效地解决问题。由于这个缓存是我们额外增加的,所以我们有足够的操作空间。


先说结论:



  • 方法 1 - 暴力: 遍历整个缓存中所有元素,最坏情况(递减序列)下所有数据都进入缓存中,单次操作的时间复杂度是 O(N)O(N),整体时间复杂度是 O(N2)O(N^2)

  • 方法 2 - 二叉堆: 不需要遍历整个缓存,只需要对比缓存的最小值,直到缓存的最小值都大于当前元素。最坏情况(递减序列)下所有数据都进入堆中,单次操作的时间复杂度是 O(lgN)O(lgN),整体时间复杂度是 O(NlgN)O(N·lgN)

  • 方法 3 - 单调栈: 我们发现元素进入缓存的顺序正好是有序的,且后进入缓存的元素会先弹出做对比,符合 “后进先出” 逻辑,所以这个缓存数据结构用栈就可以实现。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 O(n)O(n)


下面,我们先从优先队列说起。




3. 优先队列解法


寻找最值的问题第一反应要想到二叉堆。


我们可以维护一个小顶堆,每处理一个元素时,先观察堆顶的元素:



  • 如果堆顶元素小于当前元素,则说明已经确定了堆顶元素的解,我们将其弹出并记录结果;

  • 如果堆顶元素不小于当前元素,则说明小顶堆内所有元素都是不小于当前元素的,停止观察。


观察结束后,将当前元素加入小顶堆,堆会自动进行堆排序,堆顶就是整个缓存的最小值。此时,继续在后续元素上重复这个过程。


题解


fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 小顶堆
val heap = PriorityQueue<Int> { first, second ->
nums[first] - nums[second]
}
// 从前往后查询
for (index in 0 until nums.size) {
// while:当前元素比堆顶元素大,说明找到下一个更大元素
while (!heap.isEmpty() && nums[index] > nums[heap.peek()]) {
result[heap.poll()] = nums[index]
}
// 当前元素入堆
heap.offer(index)
}
return result
}

我们来分析优先队列解法的复杂度:



  • 时间复杂度: 最坏情况下(递减序列),所有元素都被添加到优先队列里,优先队列的单次操作时间复杂度是 O(lgN)O(lgN),所以整体时间复杂度是 O(NlgN)O(N·lgN)

  • 空间复杂度: 使用了额外的优先队列,所以整体的空间复杂度是 O(N)O(N)


优先队列解法的时间复杂度从 O(N2)O(N^2) 优化到 O(NlgN)O(N·lgN),还不错,那还有优化空间吗?




4. 单调栈解法


我们继续分析发现,元素进入缓存的顺序正好是逆序的,最后加入缓存的元素正好就是缓存的最小值。此时,我们不需要用二叉堆来寻找最小值,只需要获取最后一个进入缓存的元素就能轻松获得最小值。这符合 “后进先出” 逻辑,所以这个缓存数据结构用栈就可以实现。


这个问题也可以形象化地思考:把数字想象成有 “重量” 的杠铃片,每增加一个杠铃片,会把中间小的杠铃片压扁,当前的大杠铃片就是这些被压扁杠铃片的 “下一个更大元素”。


形象化思考



解题模板


// 从前往后遍历
fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 从前往后遍历
for (index in 0 until nums.size) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums[index] > nums[stack.peek()]) {
result[stack.pop()] = nums[index]
}
// 当前元素入队
stack.push(index)
}
return result
}

理解了单点栈的解题模板后,我们来分析它的复杂度:



  • 时间复杂度: 虽然代码中有嵌套循环,但它的时间复杂度并不是 O(N2)O(N^2),而是 O(N)O(N)。因为每个元素最多只会入栈和出栈一次,所以整体的计算规模还是与数据规模成正比的,整体时间复杂度是 O(N)O(N)

  • 空间复杂度: 最坏情况下(递减序列)所有元素被添加到栈中,所以空间复杂度是 O(N)O(N)


这道题也可以用从后往前遍历的写法,也是参考资料中提到的解法。 但是,我觉得正向思维更容易理解,也更符合人脑的思考方式,所以还是比较推荐小彭的模板(王婆卖瓜)。


解题模板(从后往前遍历)


// 从后往前遍历
fun nextGreaterElement(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 从后往前查询
for (index in nums.size - 1 downTo 0) {
// while:栈顶元素比当前元素小,说明栈顶元素不再是下一个更大元素,后续不再考虑它
while (!stack.isEmpty() && stack.peek() <= nums[index]) {
stack.pop()
}
// 输出到结果数组
result[index] = stack.peek() ?: -1
// 当前元素入队
stack.push(nums[index])
}
return result
}



5. 典型例题 · 下一个更大元素 I


理解以上概念后,就已经具备解决单调栈常见问题的必要知识了。我们来看一道 LeetCode 上的典型例题:LeetCode 496.


LeetCode 例题



第一节的示例是求 “在当前数组中寻找下一个更大元素” ,而这道题里是求 “数组 1 元素在数组 2 中相同元素的下一个更大元素” ,还是同一个问题吗?其实啊,这是题目抛出的烟雾弹。注意看细节信息:



  • 两个没有重复元素的数组 nums1和 nums2

  • nums1nums2 的子集。


那么,我们完全可以先计算出 nums2 中每个元素的下一个更大元素,并把结果记录到一个散列表中,再让 nums1 中的每个元素去散列表查询结果即可。


题解


class Solution {
fun nextGreaterElement(nums1: IntArray, nums2: IntArray): IntArray {
// 临时记录
val map = HashMap<Int, Int>()
// 单调栈
val stack = ArrayDeque<Int>()
// 从前往后查询
for (index in 0 until nums2.size) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums2[index] > stack.peek()) {
// 输出到临时记录中
map[stack.pop()] = nums2[index]
}
// 当前元素入队
stack.push(nums2[index])
}

return IntArray(nums1.size) {
map[nums1[it]] ?: -1
}
}
}



6. 典型例题 · 下一个更大元素 II(环形数组)


第一节的示例还有一道变型题,对应于 LeetCode 上的另一道典型题目:503. 下一个更大元素 II


LeetCode 例题



两道题的核心考点都是 “下一个更大元素”,区别只在于把 “普通数组” 变为 “环形数组 / 循环数组”,当元素遍历到数组末位后依然找不到目标元素,则会循环到数组首位继续寻找。这样的话,除了所有数据中最大的元素,其它每个元素都必然存在下一个更大元素。


其实,计算机中并不存在物理上的循环数组,在遇到类似的问题时都可以用假数据长度和取余的思路处理。如果你是前端工程师,那么你应该有印象:我们在实现无限循环轮播的控件时,有一个小技巧就是给控件 设置一个非常大的数据长度 ,长到永远不可能轮播结束,例如 Integer.MAX_VALUE。每次轮播后索引会加一,但在取数据时会对数据长度取余,这样就实现了循环轮播了。


无限轮播伪代码


class LooperView {

private val data = listOf("1", "2", "3")

// 假数据长度
fun getSize() = Integer.MAX_VALUE

// 使用取余转化为 data 上的下标
fun getItem(index : Int) = data[index % data.size]
}

回到这道题,我们的思路也更清晰了。我们不需要无限查询,所以自然不需要设置 Integer.MAX_VALUE 这么大的假数据,只需要 设置 2 倍的数据长度 ,就能实现循环查询(3 倍、4倍也可以,但没必要),例如:


题解


class Solution {
fun nextGreaterElements(nums: IntArray): IntArray {
// 结果数组
val result = IntArray(nums.size) { -1 }
// 单调栈
val stack = ArrayDeque<Int>()
// 数组长度
val size = nums.size
// 从前往后遍历
for (index in 0 until nums.size * 2) {
// while:当前元素比栈顶元素大,说明找到下一个更大元素
while (!stack.isEmpty() && nums[index % size] > nums[stack.peek() % size]) {
result[stack.pop() % size] = nums[index % size]
}
// 当前元素入队
stack.push(index)
}
return result
}
}



7. 总结


到这里,相信你已经掌握了 “下一个更大元素” 问题的解题模板了。除了典型例题之外,大部分题目会将 “下一个更大元素” 的语义隐藏在题目细节中,需要找出题目的抽象模型或转变思路才能找到,这是难的地方。


小彭在 20 年的文章里说过单调栈是一个相对冷门的数据结构,包括参考资料和网上的其他资料也普遍持有这个观点。 单调栈不能覆盖太大的问题域,应用价值不及其他数据结构。 —— 2 年前的文章


2 年后重新思考,我不再持有此观点。我现在认为:单调栈的关键是 “单调性”,而栈只是为了配合问题对操作顺序的要求而搭配的数据结构。 我们学习单调栈,应该当作学习单调性的思想在栈这种数据结构上的应用,而不是学习一种新的数据结构。对此,你怎么看?


下一篇文章,我们来学习单调性的思想在队列上数据结构上的应用 —— 单调队列。


更多同类型题目:

























































单调栈难度题解
496. 下一个更大元素 IEasy【题解】
1475. 商品折扣后的最终价格Easy【题解】
503. 下一个更大元素 IIMedium【题解】
739. 每日温度Medium【题解】
901. 股票价格跨度Medium【题解】
1019. 链表中的下一个更大节点Medium【题解】
402. 移掉 K 位数字Medium【题解】
42. 接雨水Hard【题解】
84. 柱状图中最大的矩形Hard【题解】

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

不用架构会怎么样?—— 在项目实战中探索架构演进(一)

复杂度 软件的首要技术使命是“管理复杂度” —— 《代码大全》 因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。 架构的目的在于“将复杂度分层” 复杂度为什么要被分层? 若不分层,复杂度会在同一层次展开,这...
继续阅读 »

复杂度



软件的首要技术使命是“管理复杂度” —— 《代码大全》



因为低复杂度才能降低理解成本和沟通难度,提升应对变更的灵活性,减少重复劳动,最终提高代码质量。



架构的目的在于“将复杂度分层”



复杂度为什么要被分层?


若不分层,复杂度会在同一层次展开,这样就太 ... 复杂了。


举一个复杂度不分层的例子:


小李:“你会做什么菜?”


小明:“我会做用土鸡生的土鸡蛋配上切片的番茄,放点油盐,开火翻炒的番茄炒蛋。”


听了小明的回答,你还会和他做朋友吗?


小明把不同层次的复杂度以不恰当的方式揉搓在一起,让人感觉是一种由“没有必要的具体”导致的“难以理解的复杂”。


小李其实并不关心土鸡蛋的来源、番茄的切法、添加的佐料、以及烹饪方式。


这样的回答除了难以理解之外,局限性也很大。因为它太具体了!只要把土鸡蛋换成洋鸡蛋、或是番茄片换成块、或是加点糖、或是换成电磁炉,其中任一因素发生变化,小明就不会做番茄炒蛋了。


再举个正面的例子,TCP/IP 协议分层模型自下到上定义了五层:



  1. 物理层

  2. 数据链路成

  3. 网络层

  4. 传输层

  5. 应用层


其中每一层的功能都独立且明确,这样设计的好处是缩小影响面,即单层的变动不会影响其他层。


这样设计的另一个好处是当专注于一层协议时,其余层的技术细节可以不予关注,同一时间只需要关注有限的复杂度,比如传输层不需要知道自己传输的是 HTTP 还是 FTP,传输层只需要专注于端到端的传输方式,是建立连接,还是无连接。


有限复杂度的另一面是“下层的可重用性”。当应用层的协议从 HTTP 换成 FTP 时,其下层的内容不需要做任何更改。


引子


为了降低客户端领域开发的复杂度,架构也在不断地演进。从 MVC 到 MVP,再到 MVVM,目前已经发展到 MVI。


MVVM 仍然是当下最常用的 Android 端架构,曾经的榜一大哥 MVP 已日落西山。


下图是 Google Trends 关于 “android mvvm” 和 “android mvp”的对比图,剪刀差发生在2018年:


微信图片_20220904192016.png


2018 年到底发生了什么使得架构改朝换代?


MVI 在架构设计上又做了哪些新的尝试?它是否能在将来取代 MVVM?


被如此多新名词弄得头晕脑胀的我,不由得倔强反问:“不是用架构又会怎么样?”


该系列以实战项目中的搜索场景为剧本,演绎了如何运用不同架构进行重构的过程,并逐个给出上述问题自己的理解。


搜索是 App 中常见的业务场景,该功能示意图如下:


1662106805162.gif


业务流程如下:在搜索条中输入关键词并同步展示联想词,点联想词跳转搜索结果页,若无匹配结果则展示推荐流,返回时搜索历史以标签形式横向铺开。点击历史直接发起搜索跳转到结果页。


技术选型


将搜索业务场景做了如下设计:


微信截图_20220902171024.png


搜索页用Activity来承载,它被分成两个部分,头部是常驻在 Activity 的搜索条。下面的“搜索体”用Fragment承载,它可能出现三种状态 1.搜索历史页 2.搜索联想页 3.搜索结果页。


Fragment 之间的切换采用 Jetpack 的Navigation


Navigation 封装了切换 Fragment 的细节,让开发者更轻松地实现界面切换。


它包含三个关键概念:



  1. Navigation graph:一个带标签的 xml 文件,用于配置页面及其包含的动作。

  2. NavHost:一个页面容器。

  3. NavController:页面跳转控制器,用于发起动作。


关于 Navigation 更详细的介绍可以点击Navigation 组件使用入门  |  Android 开发者  |  Android Developers


界面框架


class TemplateSearchActivity : AppCompatActivity() {
companion object {
const val NAV_HOST_ID = "searchFragmentContainer"
}
private lateinit var etSearch: EditText
private lateinit var tvSearch: TextView
private lateinit var ivClear: ImageView
private lateinit var ivBack: ImageView
private lateinit var vInputBg: View
private val contentView by lazy(LazyThreadSafetyMode.NONE) {
LinearLayout {
layout_width = match_parent
layout_height = match_parent
orientation = vertical
background_color = "#0C0D14"
fitsSystemWindows = true

// 搜索条
ConstraintLayout {
layout_width = match_parent
layout_height = wrap_content
// 返回按钮
ivBack = ImageView {
layout_id = "ivSearchBack"
layout_width = 7
layout_height = 14
scaleType = scale_fit_xy
start_toStartOf = parent_id
top_toTopOf = parent_id
margin_start = 22
margin_top = 11
src = R.drawable.search_back
onClick = { finish() }
}
// 搜索框背景
vInputBg = View {
layout_id = "vSearchBarBg"
layout_width = 0
layout_height = 36
start_toEndOf = "ivSearchBack"
align_vertical_to = "ivSearchBack"
end_toStartOf = ID_SEARCH
margin_start = 19.76
margin_end = 16
// 轻松定义圆角背景,省去新增一个 xml
shape = shape {
corner_radius = 54
solid_color = "#1AB8BCF1"
}
}
// 搜索框放大镜icon
ImageView {
layout_id = "ivSearchIcon"
layout_width = 16
layout_height = 16
scaleType = scale_fit_xy
start_toStartOf = "vSearchBarBg"
align_vertical_to = "vSearchBarBg"
margin_start = 16
src = R.drawable.template_search_icon
}
// 搜索框
etSearch = EditText {
layout_id = "etSearch"
layout_width = 0
layout_height = wrap_content
start_toEndOf = "ivSearchIcon"
end_toStartOf = ID_CLEAR_SEARCH
align_vertical_to = "vSearchBarBg"
margin_start = 7
margin_end = 12
textSize = 14f
textColor = "#F2F4FF"
imeOptions = EditorInfo.IME_ACTION_SEARCH
hint = "输入您想搜索的模板"
hint_color = "#686A72"
background = null
maxLines = 1
inputType = InputType.TYPE_CLASS_TEXT
}
// 搜索框尾部清空按钮
ivClear = ImageView {
layout_id = "ivClearSearch"
layout_width = 20
layout_height = 20
scaleType = scale_fit_xy
align_vertical_to = "vSearchBarBg"
end_toEndOf = "vSearchBarBg"
margin_end = 12
src = R.drawable.template_search_clear
// 搜索按钮
tvSearch = TextView {
layout_id = "tvSearch"
layout_width = wrap_content
layout_height = wrap_content
textSize = 14f
textColor = "#686A72"
text = "搜索"
gravity = gravity_center
align_vertical_to = "ivSearchBack"
end_toEndOf = parent_id
margin_end = 16
}
}
// 搜索体
FragmentContainerView {
layout_id = NAV_HOST_ID
layout_width = match_parent
layout_height = match_parent
NavHostFragment.create(R.navigation.search_navigation).also {
supportFragmentManager.beginTransaction()
.replace(NAV_HOST_ID.toLayoutId(), it)
.setPrimaryNavigationFragment(it)
.commit()
}
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
initView()
}

private fun initView() {
// 绘制界面初始状态
tvSearch?.apply {
isEnabled = false
textColor = "#484951"
}
ivClear?.visibility = gone
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

搜索页是一个 Activity,它的根布局是一个纵向的 LinearLayout,其中上部是一个搜索条,下部是搜索体。上述代码使用了 Kotlin 的 DSL 使得可以用声明式的语法动态的构建视图,避免了 XML 的解析并加载到内存,以及 findViewById() 遍历查找时间复杂度,性能略好,但缺点是无法预览。


关于 运用 Kotlin DSL 动态构建布局的详细讲解可以点击Android性能优化 | 把构建布局用时缩短 20 倍(下)


这套构建布局的 DSL 源码可以在这里找到wisdomtl/Layout_DSL: Build Android layout dynamically with kotlin, get rid of xml file, which has poor performance (github.com)


其中 FragmentContainerView 作为 Fragment 的容器,将其和 NavHostFragment 以及一个 navigation 资源文件绑定:


<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/search_navigation"
app:startDestination="@id/SearchHistoryFragment">
<!--联想页-->
<fragment
android:id="@+id/SearchHintFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchHintFragment"
android:label="search_hint_fragment">
<!--跳转结果页-->
<action
android:id="@+id/action_to_result"
app:destination="@id/SearchResultFragment" />
<!--跳转历史页-->
<action
android:id="@+id/action_to_history"
app:popUpTo="@id/SearchHistoryFragment" />
</fragment>
<!--历史页-->
<fragment
android:id="@+id/SearchHistoryFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchHistoryFragment"
android:label="search_history_fragment">
<!--跳转结果页-->
<action
android:id="@+id/action_to_result"
app:destination="@id/SearchResultFragment" />
<!--跳转联想页-->
<action
android:id="@+id/action_to_hint"
app:destination="@id/SearchHintFragment" />
</fragment>
<!--结果页-->
<fragment
android:id="@+id/SearchResultFragment"
android:name="com.bilibili.studio.search.template.fragment.SearchResultFragment"
android:label="search_result_fragment">
<!--跳转历史页-->
<action
android:id="@+id/action_to_history"
app:popUpTo="@id/SearchHistoryFragment" />
<!--跳转联想页-->
<action
android:id="@+id/action_to_hint"
app:destination="@id/SearchHintFragment" />
</fragment>
</navigation>

navigation 文件定义了 Fragment 实体以及对应的 跳转行为 action。然后就能用NavController方便地进行 Fragment 的切换:


findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result, // 预定义在 xml 中的 action
bundleOf("keywords" to event.keyword) // 携带跳转参数
)

支离破碎的刷新


了解了整个界面框架和技术选型之后,在不使用任何架构的情况下实现第一个业务界面——搜索条,看看会遇到哪些意想不到的坑。


看上去简单的搜索框,其实包含不少交互逻辑。


交互逻辑:进入搜索页时,搜索按钮置灰,隐藏清空按钮,搜索框获取焦点并自动弹出输入法:


飞书20220903-130310.jpg

用代码表达如下:


class TemplateSearchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(contentView)
initView()
}

private fun initView() {
// 绘制界面初始状态
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

交互逻辑:当输入关键词时,显示清空按钮,并高亮搜索按钮:


飞书20220903-130555.gif


通过addTextChangedListener()监听输入框内容变化并做出视图调整:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
// 初始化时立刻被执行
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
// 监听输入框字符变化
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?,s: Int,c: Int,after: Int){}
// 将来的某个时间点被执行的逻辑
override fun onTextChanged(char: CharSequence?, s: Int, b: Int, c: Int) {
val input = char?.toString() ?: ""
// 显示 X,并高亮搜索
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}
// 隐藏 X,并置灰搜索
else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {}
})

KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
}

为了让语义更加明确,抽象出initView()来表示初始化视图。其中包含了的确在初始化会被立刻执行的逻辑,以及在将来某个时间点会执行的逻辑。


如果按照这个趋势发展下去,界面越来越复杂时,initView() 会越来越长。


项目中,超 1000 行的initView()initConfig()就是这样练成的。如此庞大的初始化方法中,很难找到你想要的东西。


将来的逻辑和现在的逻辑最好不要待在一起,特别是当将来的逻辑很复杂时(嵌套回调)。


上述代码还有一个问题,更新搜索按钮tvSearch的逻辑有两个分身,分别处于现在和将来。这增加了理解界面状态的难度。当刷新同一控件的逻辑分处在各种各样的回调中时,如何轻松地回答“控件在某一时刻应该长什么样?”这个问题。当发生界面状态不一致的 Bug 时,又该从哪个地方下手排查问题?


名不副实的子程序


交互逻辑:点击键盘上的搜索或搜索条右侧的搜索进入结果页时,搜索框拉长并覆盖搜索按钮:


飞书20220903-130619.gif


用代码表达如下:


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
tvSearch.apply {
isEnabled = false
textColor = "#484951"
}
ivClear.visibility = gone
etSearch.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear.visibility = visible
tvSearch.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear.visibility = gone
tvSearch.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {
}
})
// 监听键盘搜索按钮
etSearch.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch.text.toString() ?: ""
if(input.isNotEmpty()) { searchAndHideKeyboard() }
true
} else false
}
// 监听搜索条搜索按钮
tvSearch.setOnClickListener {
searchAndHideKeyboard()
}
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
// 跳转到搜索页 + 拉长搜索条 + 隐藏搜索按钮 + 隐藏键盘
private fun searchAndHideKeyboard() {
vInputBg.end_toEndOf = parent_id // 拉长搜索框(与父亲右边对齐)
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).navigate(
R.id.action_history_to_result,
bundleOf("keywords" to etSearch?.text.toString())
)
}
tvSearch.visibility = gone
KeyboardUtils.hideSoftInput(etSearch)
}
}

因为跳转到结果页有两个入口,为了复用代码,不得不抽象出一个方法叫searchAndHideKeyboard()


这个命名是糟糕的,因为它没有表达出方法内做的所有事情,或者说这个子程序的抽象是糟糕的,因为它包含了多个目的,不够单纯。



子程序应该有单一且明确的目的。—— 《代码大全》



单纯的子程序最大的好处是能提高复用度。


当需求稍加改动后,searchAndHideKeyboard()就无法被复用,比如另一个搜索场景中,点击搜索时不需要拉长搜索框,也不隐藏搜索按钮,而是将搜索按钮名称改为取消。


之所以该方法难以被复用,因为它的视角错了,它以当前业务为视角,抽象出当前业务下会发生的界面变化,遂该方法也只能被用于当前业务。


若以界面变化为视角,当搜索行为发生时,界面会发生三个维度的变化:1. 搜索框绘制效果变化 2. 输入法的显示状态变化 3. Fragment 的切换。以这样的视角做抽象就能提高代码的复用度,详细的实现细节会在后续篇章展开。


剪不断理还乱的耦合


交互逻辑:当从结果页返回时(系统返回键/搜索条清空键/点击搜索框),搜索条缩回原始长度,并展示搜索按钮:


飞书20220903-135702.gif


class TemplateSearchActivity : AppCompatActivity() {
private fun initView() {
tvSearch?.apply {
isEnabled = false
textColor = "#484951"
}
ivClear?.visibility = gone
etSearch?.addTextChangedListener(object :TextWatcher{
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}

override fun onTextChanged(char: CharSequence?, start: Int, before: Int, count: Int) {
val input = char?.toString() ?: ""
if(input.isNotEmpty()) {
ivClear?.visibility = visible
tvSearch?.apply {
textColor = "#F2F4FF"
isEnabled = true
}
}else {
ivClear?.visibility = gone
tvSearch?.apply {
textColor = "#484951"
isEnabled = false
}
}
}

override fun afterTextChanged(s: Editable?) {
}
})
etSearch?.setOnEditorActionListener { v, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
val input = etSearch?.text?.toString() ?: ""
if(input.isNotEmpty()) {
searchAndHideKeyboard()
}
true
} else false
}
tvSearch?.setOnClickListener {
searchAndHideKeyboard()
}
// 监听清空按钮
ivClear?.setOnClickListener {
etSearch?.text = null
etSearch?.requestFocus()
// 弹键盘
KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
// 回到历史页
backToHistory()
}
// 监听搜索框触摸事件并回到历史页
etSearch?.setOnTouchListener { v, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
backToHistory()
}
false
}

KeyboardUtils.showSoftInputWithDelay(etSearch, 300)
}
// 监听系统返回并回退到历史页
override fun onBackPressed() {
super.onBackPressed()
backToHistory()
}

private fun backToHistory() {
runCatching {
findNavController(NAV_HOST_ID.toLayoutId()).currentDestination?.takeIf { it.id == R.id.SearchResultFragment }?.let {
// 还原搜索框和搜索按钮
tvSearch?.visibility = visible
vInputBg?.end_toStartOf = "tvSearch"
}
// 弹出结果页回到历史页
findNavController(NAV_HOST_ID.toLayoutId()).navigate(R.id.action_to_history)
}
}
}

这段代码和上一小节有着同样的问题,即在 Activity 中面向业务抽象出各种名不副实且难以复用的子程序。


backToHistory()还加重了这个问题,因为它和searchAndHideKeyboard()是耦合在一起的,分别表示进入搜索结果页和从结果页返回时搜索条的界面交互逻辑。


交互逻辑是易变的,当它发生变化时,就得修改两个地方,漏掉一处,就会产生 Bug。


修改一个子程序后,另一个子程序出 Bug 了。你说恐怖不恐怖?


总结


整个过程没有 ViewModel 的影子,也没有 Model 的影子。


Model,模型(名词)。Trygve Reenskaug,MVC 概念的发明者,在 1979 年就对 MVC 中的 M 下过这样的结论:



The View observes the Model for changes. —— Trygve Reenskaug



上面的代码没有抽象出一个模型用来表达界面的状态变化,界面也没有发送任何指令给 ViewModel,而是在 Activity 中独自消化了所有的业务逻辑。


这是很多 MVVM 在项目中的现状:只要不牵涉到网络请求,则业务逻辑都写在 View 层。这样的代码好写,不好懂,也不好改。


显而易见的坏处是,Activity 变得复杂(复杂度在一个类中被铺开),代码量增多,随之而来的是理解成本增加。


除了业务逻辑和界面展示混合在一起的复杂度之外,上述代码还有另一个复杂度:


代码中对搜索按钮tvSearch的引用有将近10处,且散落在代码的各个角落,有些在初始化initView()中、有些在输入框回调onTextChange()中、有些在业务方法searchAndHideKeyboard()中、还有些在系统返回回调onBackPress()中。在不同的地方对同一个控件做出“是否显示”、“字体颜色”、“是否可点击”等状态的修改。



如果有人在阅读你的代码时不得不搜索整个应用程序以便找到所需的信息,那么就应该重新组织你的代码了。——《代码大全》



这样的写法无法简明扼要地回答“搜索按钮应该长什么样?”这个问题。你不得不搜索整段代码中所有对它的引用,才能拼凑出问题答案。这样是吃力的!


关于“界面该长什么样”这个问题的答案应该内聚在一个点,Flutter 以及 Compose 就是这样降低复杂度的,即 View 和 Model 强绑定,且内建了 View 随 Model 变化而变化的刷新机制。(也可以使用 MVI 实现类似的效果)


除此之外,这样写还有一个坏处是“容易改出 bug”。当需求变更,假设换了一种搜索按钮高亮颜色,可能会发生“没改全”的 bug,因为决定按钮颜色的代码不止一处。


用一张图来表达所有的复杂度在 Activity 层铺开:


微信截图_20220903170226.png


本篇用无架构的方式完成了搜索条的实现。搜索历史及结果的无架构实现会在后续篇章中展开。


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

奶奶个腿!来了个40岁的项目经理,给人整自闭了

我开发的第一个正式的系统,是我做得最难受的一个系统,难受的地方,来自项目经理。第一个项目第一个正式的开发任务,是在我正式参加工作的第二周接到的,公司准备做一个内部的考勤管理系统。在这之前,我只做过毕业设计之类的小项目,开发模式基本是:自己提需求,自己完成。考勤...
继续阅读 »

我开发的第一个正式的系统,是我做得最难受的一个系统,难受的地方,来自项目经理。

第一个项目

第一个正式的开发任务,是在我正式参加工作的第二周接到的,公司准备做一个内部的考勤管理系统。

在这之前,我只做过毕业设计之类的小项目,开发模式基本是:自己提需求,自己完成。

考勤系统,算是第一个正式的、有用户使用的系统。

功能大致包括考勤、请假、工资结算、各种内部的报表功能...

项目的人员配备:1个项目经理、2个中级开发、2个实习生,我就是其中的实习生之一。

由于是第一个项目,当时经验还十分欠缺,连“业务”二字是什么意思都搞不明白,项目用到的技术栈也不熟悉(即便在这之前给了一周的时间给新人做培训)。

并且没有前端开发人员,所以前端代码也需要自己写,一时间貌似要学很多的东西,压力还是挺大的。

最开始是一名中级开发带着我做,相当于找了个“师傅”带带,他会给我分配一些简单的开发任务,很多不明白的地方他都会给我讲,所以虽然感觉到比较艰难,但也不是扛不住。

但是情况在一个月后发生了变化,随着工期越来越紧,大家工作量都开始加大。

很多业务和设计上的问题,这位中级开发也不清楚,就让我直接去对接项目经理,直接和负责人沟通。

由于不是核心项目,公司在人员配比上没有安排产品经理,所以系统界面风格、操作流程,全都是项目经理一个人负责把控。

当实习生直面项目经理的时候,问题就出现了... ...

我听不懂他在说啥

项目经理大概有四十岁左右,看上去比较严肃,据说以前是做C++的,后来才转的Java,从事开发很多年了。

他给我分配任务的方式,和之前带我的“师傅”,就有些不一样。

每次我听他给我讲完之后,我基本不知道该怎么动手。

在这之前,“师傅”会给我讲,这个功能需要做哪几个页面,可参考哪里的功能、需要有增删改、还需要加几个xx功能的弹框,大概需要用到哪些表。

听完这些,我就能自己鼓捣出一个大概的功能雏形,完成之后再找“师傅”看一看,不对的地方再修改,完全可以正常推进工作。

而项目经理就讲得粗糙一些,他会给我讲:这个功能未来谁会使用、需求是谁提出来的、最后要达到什么效果,基本上到这里就结束了。

用到的表,我需要自己根据文档、数据库、相关功能,去连蒙带猜自己找,界面功能应该是怎样的操作流程也不清楚,只能根据系统的整体风格,按照自己的想法来做。

通常一个功能,需要前前后后调整很多次,开发起来非常难受。

我印象非常深刻的一次是,当时遇到一个不清楚的问题:界面上某个内容输入到底是用下拉框还是输入框?如果是下拉框,相关参数我应该从哪里获取?

我就去询问项目经理,这位老大哥听完我的问题之后,就开始给我讲这个需求是谁提的,这个功能是做什么用的,相关联的功能大概有哪些,最终要方便使用人员达到什么目的...

一通讲述下来,二十分钟过去了,我回到工位上的时候,脑子都是蒙的:他给我讲的啥?咋没听懂?xxx又是一个什么新的概念?

本来我已经知道那个功能应该怎么做了,听他一番话之后,我突然不明白他希望我做些什么。

他给我讲那些内容,对于我的开发工作有帮助吗?

而且,我只想问一下,界面上如果用下拉框,那么下拉框的数据从哪里获取。

他完全没有回答我,我去问了个寂寞。

是不是我的理解能力有些问题?我忍不住开始怀疑自己的理解能力。

不止我听不懂

这时候,我把困惑给另一个实习生讲了一下,他就坐在我旁边,我俩交流很多。

“头疼,我之前也问了xx,也听不懂他在说什么,给我扯一大堆。”他这样回答我。

我当时松了一口气,貌似... 不是我的问题呀。

后来这个系统缝缝补补、修修改改,折腾了好几个月。

最后连带我的“师傅”,也开始抱怨难搞呀。

那段时间,几乎就是靠熬,硬撑过去的。

大家在一个没有做到把控全局的项目经理的带领下,都感觉到了艰难,系统应该怎么做,似乎他也不太清楚。

需求没有理清楚、操作流程没有想清楚、给下面的成员讲不清楚...

这就导致了开发时间的大量浪费,返工很多。

这就是我印象里,做的第一个系统,可以说非常难受了。

其实这位老大哥只是不怎么会项目管理,他自己干活还是非常多的,一点都不偷懒,但就是没有把这个小项目给统筹好。

这件事给我的启发是:除了提升硬实力,软技能也不容忽视,别只知道埋头工作,也要抽时间补一补自己的短板呀。

好啦,今天就唠到这里,咱们下期见~

来源:了不起的程序员(ID:great_developer)

收起阅读 »

由点汇聚成字的动效炫极了

前言在引入 fl_chart 绘制图表的时候,看到插件有下面这样的动效,随机散乱的圆点最后组合成了 Flutter 的 Logo,挺酷炫的。本篇我们来探讨类似的效果怎么实现。点阵在讲解代码实现之前,我们先科普一个知识,即点阵。点阵在日常生活中很常见,比如广告屏...
继续阅读 »

由点汇聚成字的动效炫极了

前言

在引入 fl_chart 绘制图表的时候,看到插件有下面这样的动效,随机散乱的圆点最后组合成了 Flutter 的 Logo,挺酷炫的。本篇我们来探讨类似的效果怎么实现。

logo 动画.gif

点阵

在讲解代码实现之前,我们先科普一个知识,即点阵。点阵在日常生活中很常见,比如广告屏,停车系统的显示,行业内称之为 LED 显示屏。

image.png

LED 显示屏实际上就是由很多 LED 灯组合成的一个显示面板,然后通过显示驱动某些灯亮,某些灯灭就可以实现文字、图形的显示。LED 显示屏的点距足够小时,色彩足够丰富时其实就形成了我们日常的显示屏,比如 OLED 显示屏其实原理也是类似的。之前报道过的大学宿舍楼通过控制每个房间的灯亮灯灭来形成文字的原理也是一样的。

image.png

现在来看看 LED显示文字是怎么回事,比如我们要 显示岛上码农的“岛”字,在16x16的点阵上,通过排布得到的就是下面的结果(不同字体的排布会有些差别)。

因为每一行是16个点,我们可以对应为16位二进制数,把黑色的标记为1,灰色的标记为0,每一行就可以得到一个二进制数。比如上面的第一行第8列为1,其他都是0,对应的二进制数就是0000000100000000,对应的16进制数就是0x0100。把其他行也按这种方式计算出来,最终得到的“岛”字对应的是16个16进制数,如下所示。

 [
0x0100, 0x0200, 0x1FF0, 0x1010,
0x1210, 0x1150, 0x1020, 0x1000,
0x1FFC, 0x0204, 0x2224, 0x2224,
0x3FE4, 0x0004, 0x0028, 0x0010
];
复制代码

又了这个基础,我们就可以用 Flutter 绘制点阵图形。

点阵图形绘制

首先我们绘制一个“LED 面板”,也就是绘制一个有若干个点构成的矩阵,这个比较简单,保持相同的间距,逐行绘制相同的圆即可,比如我们绘制一个16x16的点阵,实现代码如下所示。

var paint = Paint()..color = Colors.grey;
final dotCount = 16;
final fontSize = 100.0;
var radius = fontSize / dotCount;
var startPos =
Offset(size.width / 2 - fontSize, size.height / 2 - 2 * fontSize);
for (int i = 0; i < dotCount; ++i) {
var position = startPos + Offset(0.0, radius * i * 2);
for (int j = 0; j < dotCount; ++j) {
var dotPosition = startPos + Offset(radius * 2 * j, position.dy);
canvas.drawCircle(dotPosition, radius, paint);
}
}
复制代码

绘制出来的效果如下:

image.png

接下来是点亮对应的位置来绘制文字了。上面我们讲过了,每一行是一个16进制数,那么我们只需要判断每一行的16进制数的第几个 bit是1就可以了,如果是1就点亮,否则不点亮。点亮的效果用不同的颜色就可以了。 怎么判断16进制数的第几个 bit 是不是1呢,这个就要用到位运算技巧了。实际上,我们可以用一个第 N 个 bit 是1,其他 bit 都是0的数与要判断的数进行“位与”运算,如果结果不为0,说明要判断的数的第 N 个 bit 是1,否则就是0。听着有点绕,看个例子,我们以0x0100为例,按从第0位到第15位逐个判断第0位和第15位是不是1,代码如下:

for (i = 0 ; i < 16; ++i) {
if ((0x0100 & (1 << i)) > 0) {
// 第 i 位为1
}
}
复制代码

这里有两个位操作,1 << i是将1左移 i 位,为什么是这样呢,因为这样可以构成0x0001,0x0002,0x0004,...,0x8000等数字,这些数字依次从第0位,第1位,第2位,...,第15位为1,其他位都是0。然后我们用这样的数与另外一个数做位与运算时,就可以依次判断这个数的第0位,第1位,第2位,...,第15位是否为1了,下面是一个计算示例,第11位为1,其他位都是0,从而可以 判断另一个数的第11位是不是0。

位与运算

通过这样的逻辑我们就可以判断一行的 LED 中第几列应该点亮,然后实现文字的“显示”了,实现代码如下。wordHex是对应字的16个16进制数的数组。dotCount的值是16,用于控制绘制16x16大小的点阵。每隔一行我们向下移动一段直径距离,每隔一列,我们向右移动一段直径距离。然后如果当前绘制位置的数值对应的 bit位为1,就用蓝色绘制,否则就用灰色绘制。这里说一下为什么左移的时候要用dotCount - j - 1,这是因为绘制是从左到右的,而16进制数的左边是高位,而数字j是从小到大递增的,因此要通过这种方式保证判断的顺序是从高位(第15位)到低位(第0位),和绘制的顺序保持一致。

 for (int i = 0; i < dotCount; ++i) {
var position = startPos + Offset(0.0, radius * i * 2);
for (int j = 0; j < dotCount; ++j) {
var dotPosition = startPos + Offset(radius * 2 * j, position.dy);

if ((wordHex[i] & ((1 << dotCount - j - 1))) != 0) {
paint.color = Colors.blue[600]!;
canvas.drawCircle(dotPosition, radius, paint);
} else {
paint.color = Colors.grey;
canvas.drawCircle(dotPosition, radius, paint);
}
}
}
复制代码

绘制的结果如下所示。

image.png

由点聚集成字的动画实现

接下来我们来考虑如何实现开篇说的类似的动画效果。实际上方法也很简单,就是先按照文字应该“点亮”的 LED 的数量,先在随机的位置绘制这么多数量的 LED,然后通过动画控制这些 LED 移动到目标位置——也就是文字本该绘制的位置。这个移动的计算公式如下,其中 t 是动画值,取值范围为0-1.

移动公式

需要注意的是,随机点不能在绘图过程生成,那样会导致每次绘制产生新的随机位置,也就是初始位置会变化,导致上面的公式实际不成立,就达不到预期的效果。另外,也不能在 build 方法中生成,因为每次刷新 build 方法就会被调用,同样会导致初始位置发生变化。所以,生成随机位置应该在 initState方法完成。但是又遇到一个新问题,那就是 initState方法里没有 context,拿不到屏幕宽高,所以不能直接生成位置,我们只需要生成一个0-1的随机系数就可以了,然后在绘制的时候在乘以屏幕宽高就得到实际的初始位置了。初始位置系数生成代码如下:

@override
void initState() {
super.initState();
var wordBitCount = 0;
for (var hex in dao) {
wordBitCount += _countBitOne(hex);
}
startPositions = List.generate(wordBitCount, (index) {
return Offset(
Random().nextDouble(),
Random().nextDouble(),
);
});
...
}
复制代码

wordBitCount是计算一个字中有多少 bit 是1的,以便知道要绘制的 “LED” 数量。接下来是绘制代码了,我们这次对于不亮的直接不绘制,然后要点亮的位置通过上面的位置计算公式计算,这样保证了一开始绘制的是随机位置,随着动画的过程,逐步移动到目标位置,最终汇聚成一个字,就实现了预期的动画效果,代码如下。

void paint(Canvas canvas, Size size) {
final dotCount = 16;
final fontSize = 100.0;
var radius = fontSize / dotCount;
var startPos =
Offset(size.width / 2 - fontSize, size.height / 2 - fontSize);
var paint = Paint()..color = Colors.blue[600]!;

var paintIndex = 0;
for (int i = 0; i < dotCount; ++i) {
var position = startPos + Offset(0.0, radius * i * 2);
for (int j = 0; j < dotCount; ++j) {
// 判断第 i 行第几位不为0,不为0则绘制,否则不绘制
if ((wordHex[i] & ((1 << dotCount - j))) != 0) {
var startX = startPositions[paintIndex].dx * size.width;
var startY = startPositions[paintIndex].dy * size.height;
var endX = startPos.dx + radius * j * 2;
var endY = position.dy;
var animationPos = Offset(startX + (endX - startX) * animationValue,
startY + (endY - startY) * animationValue);
canvas.drawCircle(animationPos, radius, paint);
paintIndex++;
}
}
}
}
复制代码

来看看实现效果吧,是不是很酷炫?完整源码已提交至:绘图相关源码,文件名为:dot_font.dart

点阵汇聚文字动画.gif

总结

本篇介绍了点阵的概念,以及基于点阵如何绘制文字、图形,最后通过先绘制随机点,再汇聚成文字的动画效果。可以看到,化整为零,再聚零为整的动画效果还是蛮酷炫的。实际上,基于这种方式,可以构建更多有趣的动画效果。

作者:岛上码农

来源:juejin.cn/post/7120233450627891237

收起阅读 »

国内及出海企业如何抓住Discord社交红利

一、活动背景2010 年,中国企业吹响了出海远航的号角。10 余年间热门赛道几经嬗变,当工具产品热潮褪去,跨境电商、游戏、社交文娱等轮番登场。到 20 年代,出海对于中国企业来说,不是选择,而成为了必须。2021 年度,全球移动游戏市场收入达 907 亿美元,...
继续阅读 »

一、活动背景
2010 年,中国企业吹响了出海远航的号角。10 余年间热门赛道几经嬗变,当工具产品热潮褪去,跨境电商、游戏、社交文娱等轮番登场。到 20 年代,出海对于中国企业来说,不是选择,而成为了必须。2021 年度,全球移动游戏市场收入达 907 亿美元,中国以 313.7 亿美元稳居全球首位。国内游戏企业在海外大杀特杀,类Discord社区也正逐渐成为各大游戏厂商的运营标配。
据数据显示,2017~2022年,中国社交网络的增长速度不会低于10%,到2022年该行业的市场规模将接近500亿元。增量空间虽然明显,但存量空间早已是一片红海,因此,如何挖掘、探索新兴垂直细分领域及相关市场,如何通过一站式的技术能力提升用户体验,便成为企业生存与发展的关键所在。与此同时,微信已发布10年有余,十年一次系统性的机会,趁着海外Discord的强劲势头,国内也到了诞生新社交巨头的窗口期。
在此背景下,环信新晋的 IM 明星产品【环信超级社区】2.0 版本正式出道与大家见面了~,旨在一站式帮助客户快速开发和构建稳定超大规模用户即时通讯的"类Discord超级社区",作为构建实时交互社区的第一选择,环信超级社区自发布以来很好地满足了类 Discord 实时社区业务场景的客户需求,并支持开发者结合业务需要灵活自定义产品形态,目前已经广泛服务于国内头部出海企业以及海外东南亚和印度企业。
此次公开课以《国内及出海企业如何抓住 Discord 社交红利》为主题,重点聚焦国内及出海企业关注「类 Discord」的热门场景、赛道选择、运营创新玩法、出海安全合规,以及环信超级社区的技术优势和业务面临的挑战等问题,助力企业在市场上抢占先机,在众多竞争者中找到一片属于自己的蓝海。

01.活动流程


19:00-19:40   主题演讲

19:40-19:45   QA环节

19:45-20:00   直播间抽奖

 20:00            直播结束 


02. 演讲大纲

  • 互联网社交沟通模型的演变

  • 环信超级社区是什么?功能点/技术优势/落地场景有哪些

  • 如何做好一款类discord产品(赛道的选择)

  • 类Discord产品运营的最佳姿势(运营是难点)

  • 国内及企业出海做超级社区,环信可以提供什么?

  • 超级社区业务面临的一些挑战(技术角度讲解:群成员数量、消息分发量的急增、业务承载挑战、单条消息的处理(普通群和超级群的对比)

  • 环信在出海安全合规和技术服务方面的最佳实践


03. 听众收益

  • 了解超级社区模型的应用场景,为产品增长注入新的动力

  • 了解超级社区的赛道选择和运营玩法

  • 了解超大规模社区群组的技术实现原理

  • 出海企业如何利用超级社区实现商业成功

  • 海外社交产品的安全合规实战经验


04. 福利活动




收起阅读 »

五子棋AI进阶:极大极小值搜索

AI
前言 上篇文章,介绍了一下五子棋 AI 的入门实现,学完之后能用,就是 AI 还太年轻,只能思考一步棋。 本文将介绍一种提高 AI 思考能力的算法:极大极小值算法。 Minimax算法 又名极小化极大算法,是一种找出失败的最大可能性中的最小值的算法(即最小...
继续阅读 »

前言


上篇文章,介绍了一下五子棋 AI 的入门实现,学完之后能用,就是 AI 还太年轻,只能思考一步棋。


image.png


本文将介绍一种提高 AI 思考能力的算法:极大极小值算法



Minimax算法 又名极小化极大算法,是一种找出失败的最大可能性中的最小值的算法(即最小化对手的最大得益)。通常以递归形式来实现。
Minimax算法常用于棋类等由两方较量的游戏和程序。该算法是一个零总和算法,即一方要在可选的选项中选择将其优势最大化的选择,另一方则选择令对手优势最小化的一个,其输赢的总和为0(有点像能量守恒,就像本身两个玩家都有1点,最后输家要将他的1点给赢家,但整体上还是总共有2点)。 —— 百度百科



极大极小值搜索算法


算法实现原理


对于五子棋游戏来说,如果 AI 执黑子先下,那么第一步 AI 共有 225 种落子方式,AI 落子到一个点后,表示 AI 回合结束,换到对手(白子)落子,这时对手共有 224 种落子方式。我们可以将 AI 和对手交替落子形成的所有情况穷举出来,这样就形成了一棵树,叫做 博弈树


但是,穷举出所有情况太不现实了,这颗 博弈树 最后一层节点数就有 225! ,这个数字是特别庞大的,数字10后边要加432个0!!!这程序运行起来,电脑还要不要了?


image.png


所以,我们只考虑2步棋或4步棋的情况。


image.png


如图所示,我只列举出了走4步棋所形成的部分情况。A0 是起点,AI 将在这个点中选择出最佳的落子点位。A0 下面有两个分支(实际有225个分支,这里放不下,就只演示2个)A1A2,这两个分支表示的就是 AI 第一步落子的两种情况。


A1 如果落子到 (0,0),则当前局面就如下图所示


image.png


A2 如果落子到 (0,1),则当前局面就如下图所示


image.png


AI 落子完后,就轮到对方落子了。在 A1 分支中,对方有 B1B2 两种落子情况(实际有224种)


B1 情况如图所示


image.png


B2 情况如图所示


image.png


一直到第4步落子完时,B5 的局面就会像下图这样


image.png


要知道,这颗 博弈树 是以 AI 的角度建立的,AI 为了赢,它需要从 A1A2 分支中,选择一个对自己最有利的落子点,而 A1A2 分支的好坏需要它们下面的 B1B2B3B4 决定,所以说,下层分支的局面会影响上层分支的选择。


要确定 A1A2 分支哪个好,我们必须从这个分支的最深层看起。


image.png


B5 ~ B12 节点的局面是由对方造成的,我们就假设对方很聪明,他一定能选择一个最有利于他自己的落子点。怎么知道哪个落子点好?还是和之前一样,用评估函数评估一下,分高的就好呗,但有一点不同的是,之前评估的是一个点,现在需要评估一个局面,怎么评估本文后面会提到。


假设 B5 ~ B12 中 各个节点的得分如下图最底部所示


image.png


A3 节点得分为 0A4 节点得分为 1A5 节点得分为 3A6 节点得分为 2。这就很奇怪了,不是说让选得分最大的吗?这怎么都选的最小的得分???


这其实还是要从评估函数说起,因为我们现在的评估函数都是从 AI 角度出发的,评估的得分越高,只会对 AI 有利,对对方来说是不利的。所以,当是对方的分支的时候,我们要选得分最低的节点,因为 AI 要站在对方的角度去做选择,换位思考。这里如果还是没有搞懂的话,我们可以这么理解:



假如张三遇到了抢劫犯,他认为他身上值钱的东西有:《Java从入门到入土》、1000元现金、某厂月薪3.5K包吃包住的Offer。现在抢劫犯要抢劫他身上的一样东西,如果站在张三的角度思考的话,那肯定是让抢《Java从入门到入土》这本破书了,但是站在抢劫犯的角度思考,1000元现金比什么都强!



image.png


这就是思考角度的问题,对方如果很聪明,那他肯定是选择让 AI 利益最低的一个节点,现在我们就认为对方是一个绝顶聪明的人,所以在对方选择的分支里都选择了分值最低的,好让 AI 的利益受损。


再接下去就是 AI 选择分支了,不用说,AI 肯定选分高的。AI 要从对方给的那些低分分支里选择分最高的,也就是差的里面选好的。所以 B1 得分为 1B2 得分为 3


image.png


后面也是一样的流程,又轮到对方选择了,对方肯定选择 B1 分支,B1 分支是得分最低的节点,所以到最后,A1 分支的最终得分为 1


image.png


我们对 A2 分支也做如上操作:AI 选高分,对方选低分。最后可以得出如下图所示的结果


image.png


现在我们知道 A1 最终得分为 1A2 最终得分为 2,因为 AI 会选择最大得分的分支 A2,所以最终 A0 得分为 2,也就是说,AI 下一步的最佳落子点为 (0,1)


image.png


image.png


AI 选择的分支一定是选最高分值的叫做 Max 分支,对方选择的分支一定是选最低分值的叫做 Min 分支,然后由低到高,倒推着求出起点的得分,这就是 极大极小值搜索 的实现原理。


image.png


代码实现


我们接着上次的代码来,在 ZhiZhangAIService 类中定义一个全局变量 bestPoint 用于存放 AI 当前最佳下棋点位,再定义一个全局变量 attack 用于设置 AI 的进攻能力。


    /**
* AI最佳下棋点位
*/
private Point bestPoint;
/**
* 进攻系数
*/
private int attack;

新增 minimax 方法,编写 极大极小值搜索 算法的实现代码。这里是使用递归的方式,深度优先遍历 博弈树,生成树和选择节点是同时进行的。type 表示当前走棋方,刚开始时,因为要从根节点开始生成树,所以要传入 0 ,并且 AI 最后选择高分节点的时候也是在根节点进行的。depth 表示搜索的深度,也就是 AI 思考的步数
,我这边传入的是 2,也就是只思考两步棋,思考4步或6步都行,只要你电脑吃得消(计算量很大的哦)。



/**
* 极大极小值搜索
*
* @param type 当前走棋方 0.根节点表示AI走棋 1.AI 2.玩家
* @param depth 搜索深度
* @return
*/
private int minimax(int type, int depth) {
// 是否是根节点
boolean isRoot = type == 0;
if (isRoot) {
// 根节点是AI走棋
type = this.ai;
}

// 当前是否是AI走棋
boolean isAI = type == this.ai;
// 当前分值,
int score;
if (isAI) {
// AI因为要选择最高分,所以初始化一个难以到达的低分
score = -INFINITY;
} else {
// 对手要选择最低分,所以初始化一个难以到达的高分
score = INFINITY;
}

// 到达叶子结点
if (depth == 0) {
/**
* 评估每棵博弈树的叶子结点的局势
* 比如:depth=2时,表示从AI开始走两步棋之后的局势评估,AI(走第一步) -> 玩家(走第二步),然后对局势进行评估
* 注意:局势评估是以AI角度进行的,分值越大对AI越有利,对玩家越不利
*/
return evaluateAll();
}

for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该处已有棋子,跳过
continue;
}

/* 模拟 AI -> 玩家 交替落子 */
Point p = new Point(i, j, type);
// 落子
putChess(p);
// 递归生成博弈树,并评估叶子结点的局势获取分值
int curScore = minimax(3 - type, depth - 1);
// 撤销落子
revokeChess(p);

if (isAI) {
// AI要选对自己最有利的节点(分最高的)
if (curScore > score) {
// 最高值被刷新
score = curScore;
if (isRoot) {
// 根节点处更新AI最好的棋位
this.bestPoint = p;
}
}
} else {
// 对手要选对AI最不利的节点(分最低的)
if (curScore < score) {
// 最低值被刷新
score = curScore;
}
}
}
}

return score;
}

新增模拟落子 putChess 和撤销落子 revokeChess 等方法。



/**
* 下棋子
*
* @param point 棋子
*/
private void putChess(Point point) {
this.chessData[point.x][point.y] = point.type;
}

/**
* 撤销下的棋子
*
* @param point 棋子
*/
private void revokeChess(Point point) {
this.chessData[point.x][point.y] = 0;
}

新增一个评估函数 evaluateAll ,用于评估一个局面。这个评估函数实现原理为:搜索棋盘上现在所有的已落子的点位,然后调用之前的评估函数 evaluate 对这个点进行评分,如果这个位置上是 AI 的棋子,则加上评估的分值,是对方的棋子就减去评估的分值。注意这里有个进攻系数 attack,这个值我现在设定的是 2,如果这个值太低或太高都会影响 AI 的判断,我这边经过测试,觉得设置为 2 会比较好点。最后就是将 AI 所有棋子的总得分乘以进攻系数,再减去对手所有棋子的总得分,作为本局面的得分。



/**
* 以AI角度对当前局势进行评估,分数越大对AI越有利
*
* @return
*/
private int evaluateAll() {
// AI得分
int aiScore = 0;
// 对手得分
int foeScore = 0;

for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
int type = this.chessData[i][j];
if (type == 0) {
// 该点没有棋子,跳过
continue;
}

// 评估该棋位分值
int val = evaluate(new Point(i, j, type));
if (type == this.ai) {
// 累积AI得分
aiScore += val;
} else {
// 累积对手得分
foeScore += val;
}
}
}

// 该局AI最终得分 = AI得分 * 进攻系数 - 对手得分
return aiScore * this.attack - foeScore;
}

调整 AI 入口方法 getPoint,现在使用 minimax 方法获取 AI 的最佳落子点位。


    @Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
initChessData(chessData);
this.ai = 3 - point.type;
this.bestPoint = null;
this.attack = 2;

if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

// 基于极大极小值搜索获取最佳棋位
minimax(0, 2);

return this.bestPoint;
}

测试一下,因为现在的 AI 可以思考两步棋了,所以比之前厉害了许多。


image.png


但是,又因为要搜索很多个节点,所以响应耗时也变长了很多,思考两步的情况下,平均响应时间在 3s 左右。


image.png


再去和大佬的 AI 下一把(gobang.light7.cn/#/),思考两步棋的 AI 执黑子先下,已经可以很轻松的打败大佬的普通级别的 AI 了。


image.png


AI 执白后下的话,连萌新级别的都打不赢,这个应该是评估模型的问题,后续需要对评估模型做进一步的优化。


现在写的搜索算法,如果要让 AI 思考4步棋的话,我这普通电脑还是吃不消的,后续对搜索算法还有更多的优化空间。


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

教你写一个入门级别的五子棋AI

AI
前言 本文只是介绍五子棋AI的实现,最终的成品只是一个 AI 接口,并不包括 GUI,且不依赖 GUI。 五子棋 AI 的实现并不难,只需要解决一个问题就行: 怎么确定AI的最佳落子位置? 一般情况下,五子棋棋盘是由15条横线和15条纵线组合而成的,15...
继续阅读 »

前言



本文只是介绍五子棋AI的实现,最终的成品只是一个 AI 接口,并不包括 GUI,且不依赖 GUI



五子棋 AI 的实现并不难,只需要解决一个问题就行:


怎么确定AI的最佳落子位置?


image.png


一般情况下,五子棋棋盘是由15条横线和15条纵线组合而成的,15x15 的棋盘共有 225 个交叉点,也就是说共有 225 个落子点。


假如说,AI 是黑棋,先行落子,所以 AI 总共有 225 个落子点可以选择,我们可以对每个落子点进行评估打分,哪个分高下哪里,这样我们就能确定最佳落子点了。


但这样又引出了一个新的问题:


怎么对落子点进行评估打分呢?


这就是本文的重点了,请看后文!


image.png


实现过程


抽象



注:部分基础代码依赖于 lombok,请自行引入,或手写基础代码。



落子位置实体类,这里我们定义棋子类型字段:type1表示黑子,2表示白子。


/**
* 棋子点位
*
* @author anlingyi
* @date 2021/11/10
*/

@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Point {
/**
* 横坐标
*/

int x;
/**
* 纵坐标
*/

int y;
/**
* 棋子类型 1.黑 2.白
*/

int type;
}

AI 对外提供的接口,不会依赖任何 GUI 代码,方便其他程序调用。


/**
* 五子棋AI接口
*
* @author anlingyi
* @date 2021/11/10
*/

public interface AIService {

/**
* 获取AI棋位
*
* @param chessData 已下棋子数据
* @param point 对手棋位
* @param started 是否刚开局
* @return
*/

Point getPoint(int[][] chessData, Point point, boolean started);

}

这个接口需要知道我们现在的棋盘落子数据 chessData,还有对手上一步的落子位置 pointstarted 参数表示是否是刚开局,后续可能对刚开局情况做单独的处理。


实现AI接口


我们创建一个类 ZhiZhangAIService,这个类实现 AIService 接口,来写我们的实现逻辑。


/**
*
* 五子棋AI实现
*
* @author anlingyi
* @date 2021/11/10
*/

public class ZhiZhangAIService implements AIService {

/**
* 已下棋子数据
*/

private int[][] chessData;
/**
* 棋盘行数
*/

private int rows;
/**
* 棋盘列数
*/

private int cols;
/**
* AI棋子类型
*/

private int ai;

/**
* 声明一个最大值
*/

private static final int INFINITY = 999999999;

@Override
public Point getPoint(int[][] chessData, Point point, boolean started) {
// 初始化棋盘数据
initChessData(chessData);
// 计算AI的棋子类型
this.ai = 3 - point.type;

if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

// 获取最佳下棋点位
return getBestPoint();
}

/**
* 初始化棋盘数据
*
* @param chessData 当前棋盘数据
*/

private void initChessData(int[][] chessData) {
// 获取棋盘行数
this.rows = chessData.length;
// 获取棋盘列数
this.cols = chessData[0].length;
// 初始化棋盘数据
this.chessData = new int[this.cols][this.rows];
// 深拷贝
for (int i = 0; i < cols; i++) {
for (int j = 0; j < rows; j++) {
this.chessData[i][j] = chessData[i][j];
}
}
}

/**
* 获取最佳下棋点位
*
* @return
*/

private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 评估该点AI得分
int val = evaluate(p);
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

/**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 核心
}

}

首先看 getPoint 方法,这个是 AI 的出入口方法,我们要对传入的棋盘数据做一个初始化,调用 initChessData 方法,计算出当前游戏的棋盘行数、列数,并且拷贝了一份棋子数据到本地(深拷贝还是浅拷贝视情况而定)。


this.ai = 3 - point.type;

这行代码可以计算出AI是执黑子还是执白子,应该很好理解。


if (started) {
// AI先下,首子天元
int centerX = this.cols / 2;
int centerY = this.rows / 2;
return new Point(centerX, centerY, this.ai);
}

这段代码是处理刚开局时 AI 先行落子的情况,我们这边是简单的将落子点确定为棋盘中心位置(天元)。开局情况的落子我们可以自己定义,并不是固定的,只是说天元的位置比较好而已。


    private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 评估该点AI得分
int val = evaluate(p);
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

然后就到了我们最主要的方法了 getBestPoint,这个方法用于选择出 AI 的最佳落子位置。这个方法的思路就是遍历棋盘上所有能下棋的点,然后对这个点进行评分,如果这个点的评分比之前点的评分高,就更新当前最佳落子点位,并更新最高分,所有的落子点都评估完成之后,我们就能确定最好的点位在哪了。


   /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 核心
}

最后就是评估函数的实现了。


评估函数


在写评估函数之前,我们要先了解一下五子棋的几种棋型。(还不熟的朋友,五子棋入门了解一下:和那威学五子棋)


在这里,我把五子棋棋型大致分为:连五活四冲四活三眠三活二眠二眠一 等共8种棋型。


0:空位 1:黑子 2:白子

连五:11111
活四:011110
冲四:21111
活三:001110
眠三:211100
活二:001100
眠二:001120
眠一:001200

冲四活三 如果形成,赢的可能性很大,活四 如果形成,棋局胜负基本确定,连五 形成就已经赢了。所以说,如果 AI 落的点能够形成这几种胜率很高的棋型的话,我们要给这个点评一个高分,这样对 AI 最有利。


我这边定义好了各个棋型的分数情况











































棋型分数
连五10000000
活四1000000
活三10000
冲四8000
眠三1000
活二800
眠二50
眠一10

评估模型的抽象


我们创建一个枚举内部类,然后定义这几种棋型和它的分数。


    @AllArgsConstructor
private enum ChessModel {
/**
* 连五
*/

LIANWU(10000000, new String[]{"11111"}),
/**
* 活四
*/

HUOSI(1000000, new String[]{"011110"}),
/**
* 活三
*/

HUOSAN(10000, new String[]{"001110", "011100", "010110", "011010"}),
/**
* 冲四
*/

CHONGSI(8000, new String[]{"11110", "01111", "10111", "11011", "11101"}),
/**
* 眠三
*/

MIANSAN(1000, new String[]{"001112", "010112", "011012", "211100", "211010"}),
/**
* 活二
*/

HUOER(800, new String[]{"001100", "011000", "000110"}),
/**
* 眠二
*/

MIANER(50, new String[]{"011200", "001120", "002110", "021100", "001010", "010100"}),
/**
* 眠一
*/

MIANYI(10, new String[]{"001200", "002100", "020100", "000210", "000120"});

/**
* 分数
*/

int score;
/**
* 局势数组
*/

String[] values;
}

为了评估方便,我们可以把所有定义好的棋型以及棋型对应的分数存入 Hash 表。


创建一个 LinkedHashMap 类型的类变量 SCORE,然后在静态代码块内进行初始化。


    /**
* 棋型分数表
*/

private static final Map SCORE = new LinkedHashMap<>();

static {
// 初始化棋型分数表
for (ChessModel chessScore : ChessModel.values()) {
for (String value : chessScore.values) {
SCORE.put(value, chessScore.score);
}
}
}

判断落子点位的棋型


棋型和分数都定义好了,现在我们要知道一个点位它的棋型的情况,这样才能评估这个点位的分数。


我们以落子点位为中心,分横、纵、左斜、右斜等4个大方向,分别取出各方向的9个点位的棋子,每个方向的9个棋子都组合成一个字符串,然后匹配现有的棋型数据,累积分值,这样就计算出了这个点位的分数了。


image.png


以上图为例,对横、纵、左斜、右斜做如上操作,可以得出:


横:000111000 -> 活三 +10000
纵:000210000 -> 眠一 +10
左斜:000210000 -> 眠一 +10
右斜:000010000 -> 未匹配到棋型 +0

所以这个点位总得分为:


10000 + 10 + 10 + 0 = 10020

代码实现:


    /**
* 获取局势分数
*
* @param situation 局势
* @return
*/

private int getScore(String situation) {
for (String key : SCORE.keySet()) {
if (situation.contains(key)) {
return SCORE.get(key);
}
}
return 0;
}

/**
* 获取棋位局势
*
* @param point 当前棋位
* @param direction 大方向 1.横 2.纵 3.左斜 4.右斜
* @return
*/

private String getSituation(Point point, int direction) {
// 下面用到了relativePoint函数,根据传入的四个大方向做转换
direction = direction * 2 - 1;
// 以下是将各个方向的棋子拼接成字符串返回
StringBuilder sb = new StringBuilder();
appendChess(sb, point, direction, 4);
appendChess(sb, point, direction, 3);
appendChess(sb, point, direction, 2);
appendChess(sb, point, direction, 1);
sb.append(1); // 当前棋子统一标记为1(黑)
appendChess(sb, point, direction + 1, 1);
appendChess(sb, point, direction + 1, 2);
appendChess(sb, point, direction + 1, 3);
appendChess(sb, point, direction + 1, 4);
return sb.toString();
}

/**
* 拼接各个方向的棋子
*


* 由于现有评估模型是对黑棋进行评估
* 所以,为了方便对局势进行评估,如果当前是白棋方,需要将扫描到的白棋转换为黑棋,黑棋转换为白棋
* 如:point(x=0,y=0,type=2) 即当前为白棋方
* 扫描到的某个方向局势为:20212 -> 转换后 -> 10121
*
* @param sb 字符串容器
* @param point 当前棋子
* @param direction 方向 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下
* @param offset 偏移量
*/

private void appendChess(StringBuilder sb, Point point, int direction, int offset) {
int chess = relativePoint(point, direction, offset);
if (chess > -1) {
if (point.type == 2) {
// 对白棋进行转换
if (chess > 0) {
// 对棋子颜色进行转换,2->1,1->2
chess = 3 - chess;
}
}
sb.append(chess);
}
}

/**
* 获取相对点位棋子
*
* @param point 当前棋位
* @param direction 方向 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下
* @param offset 偏移量
* @return -1:越界 0:空位 1:黑棋 2:白棋
*/

private int relativePoint(Point point, int direction, int offset) {
int x = point.x, y = point.y;
switch (direction) {
case 1:
x -= offset;
break;
case 2:
x += offset;
break;
case 3:
y -= offset;
break;
case 4:
y += offset;
break;
case 5:
x += offset;
y -= offset;
break;
case 6:
x -= offset;
y += offset;
break;
case 7:
x -= offset;
y -= offset;
break;
case 8:
x += offset;
y += offset;
break;
}

if (x < 0 || y < 0 || x >= this.cols || y >= this.rows) {
// 越界
return -1;
}

// 返回该位置的棋子
return this.chessData[x][y];
}


评估函数的实现


到这一步,我们已经能知道某个落子点位的各个方向的局势,又能通过局势获取到对应的分值,这样一来,评估函数就很好写了,评估函数要做的就是累积4个方向的分值,然后返回就行。


    /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 分值
int score = 0;

for (int i = 1; i < 5; i++) {
// 获取该方向的局势
String situation = getSituation(point, i);
// 下此步的得分
score += getScore(situation);
}

return score;
}

现在,已经可以将我们写的 AI 接入GUI 程序做测试了。如果还没有 GUI,也可以自己写个测试方法,只要按照方法的入参信息传入就行,方法输出的就是 AI 下一步的落子位置。


    /**
* 获取AI棋位
*
* @param chessData 已下棋子数据
* @param point 对手棋位
* @param started 是否刚开局
* @return
*/

Point getPoint(int[][] chessData, Point point, boolean started);

image.png


测试了一下,现在的 AI 只知道进攻,不知道防守,所以我们需要对 getBestPoint 方法进行优化。之前只对 AI 落子进行了评估,现在我们也要对敌方落子进行评估,然后累积分值,这样可以提高 AI 的防守力度。


    private Point getBestPoint() {
Point best = null;
// 初始分值为最小
int score = -INFINITY;

/* 遍历所有能下棋的点位,评估各个点位的分值,选择分值最大的点位 */
for (int i = 0; i < this.cols; i++) {
for (int j = 0; j < this.rows; j++) {
if (this.chessData[i][j] != 0) {
// 该点已有棋子,跳过
continue;
}

Point p = new Point(i, j, this.ai);
// 该点得分 = AI落子得分 + 对手落子得分
int val = evaluate(p) + evaluate(new Point(i, j, 3 - this.ai));
// 选择得分最高的点位
if (val > score) {
// 最高分被刷新
score = val;
// 更新最佳点位
best = p;
}
}
}

return best;
}

只有这行代码进行了改动,现在加上了对手落子到该点的得分。


// 该点得分 = AI落子得分 + 对手落子得分
int val = evaluate(p) + evaluate(new Point(i, j, 3 - this.ai));

再次测试,现在 AI 棋力还是太一般,防守能力是提高了,但还是输给了我这个“臭棋篓子”。


image.png


有一些局势的评分需要提高,例如:



  • 活三又活二

  • 冲四又活二

  • 两个或两个以上的活三

  • 冲四又活三


上面这些情况都得加一些分数,如果分数太普通,AI 棋力就会很普通甚至更弱,可以说目前的 AI 只能算是一个刚入门五子棋的新手。


我这边对这些情况的处理是这样的:



  • 活三又活二:总分x2

  • 冲四又活二:总分x4

  • 两个或两个以上的活三:总分x6

  • 冲四又活三:总分x8


新增一个方法,用于判断当前局势是属于什么棋型


    /**
* 检查当前局势是否处于某个局势
*
* @param situation 当前局势
* @param chessModel 检查的局势
* @return
*/

private boolean checkSituation(String situation, ChessModel chessModel) {
for (String value : chessModel.values) {
if (situation.contains(value)) {
return true;
}
}
return false;
}

修改评估方法 evaluate,对各种棋型做一个统计,最后按照我上面给出的处理规则进行加分处理。


    /**
* 对当前棋位进行评估
*
* @param point 当前棋位
* @return
*/

private int evaluate(Point point) {
// 分值
int score = 0;
// 活三数
int huosanTotal = 0;
// 冲四数
int chongsiTotal = 0;
// 活二数
int huoerTotal = 0;

for (int i = 1; i < 5; i++) {
String situation = getSituation(point, i);
if (checkSituation(situation, ChessModel.HUOSAN)) {
// 活三+1
huosanTotal++;
} else if (checkSituation(situation, ChessModel.CHONGSI)) {
// 冲四+1
chongsiTotal++;
} else if (checkSituation(situation, ChessModel.HUOER)) {
// 活二+1
huoerTotal++;
}

// 下此步的得分
score += getScore(situation);
}

if (huosanTotal > 0 && huoerTotal > 0) {
// 活三又活二
score *= 2;
}
if (chongsiTotal > 0 && huoerTotal > 0) {
// 冲四又活二
score *= 4;
}
if (huosanTotal > 1) {
// 活三数大于1
score *= 6;
}
if (chongsiTotal > 0 && huosanTotal > 0) {
// 冲四又活三
score *= 8;
}

return score;
}

再次进行测试,AI 棋力已经可以打败我这个菜鸡了,但由于我棋艺不精,打败我不具代表性。


image.png


在网上找了一个大佬写的五子棋 AIgobang.light7.cn/#/), 我用我写的 AI 去和大佬的 AI 下棋,我的 AI 执黑,只能打败大佬的萌新级别执白的 AI


AI 执黑的情况,赢


image.png


AI 执白的情况,输


image.png


由于目前的 AI 只能思考一步棋,所以棋力不强,对方稍微套路一下可能就输了,后续还有很大的优化空间。


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

灯泡开关 Ⅱ : 分情况讨论

题目描述 这是 LeetCode 上的 672. 灯泡开关 Ⅱ ,难度为 中等。 Tag : 「脑筋急转弯」、「找规律」 房间中有 n 只已经打开的灯泡,编号从 1 到 n 。墙上挂着 4 个开关 。 这 4 个开关各自都具有不同的功能,其中: 开...
继续阅读 »

题目描述


这是 LeetCode 上的 672. 灯泡开关 Ⅱ ,难度为 中等


Tag : 「脑筋急转弯」、「找规律」


房间中有 n 只已经打开的灯泡,编号从 1n 。墙上挂着 4 个开关 。


4 个开关各自都具有不同的功能,其中:



  • 开关 1 :反转当前所有灯的状态(即开变为关,关变为开)

  • 开关 2 :反转编号为偶数的灯的状态(即 2, 4, ...

  • 开关 3 :反转编号为奇数的灯的状态(即 1, 3, ...

  • 开关 4 :反转编号为 j = 3k + 1 的灯的状态,其中 k = 0, 1, 2, ...(即 1, 4, 7, 10, ...


你必须 恰好 按压开关 presses 次。每次按压,你都需要从 4 个开关中选出一个来执行按压操作。


给你两个整数 npresses,执行完所有按压之后,返回 不同可能状态 的数量。


示例 1:


输入:n = 1, presses = 1

输出:2

解释:状态可以是:
- 按压开关 1 ,[关]
- 按压开关 2 ,[开]

示例 2:


输入:n = 2, presses = 1

输出:3

解释:状态可以是:
- 按压开关 1 ,[关, 关]
- 按压开关 2 ,[开, 关]
- 按压开关 3 ,[关, 开]

示例 3:


输入:n = 3, presses = 1

输出:4

解释:状态可以是:
- 按压开关 1 ,[关, 关, 关]
- 按压开关 2 ,[关, 开, 关]
- 按压开关 3 ,[开, 关, 开]
- 按压开关 4 ,[关, 开, 开]

提示:



  • 1<=n<=10001 <= n <= 1000

  • 0<=presses<=10000 <= presses <= 1000


分情况讨论


记灯泡数量为 nn(至少为 11),翻转次数为 kk(至少为 00),使用 1 代表灯亮,使用 0 代表灯灭。


我们根据 nnkk 的数值分情况讨论:



  • k=0k = 0 时,无论 nn 为何值,都只有起始(全 1)一种状态;

  • k>0k > 0 时,根据 nn 进一步分情况讨论:

    • n=1n = 1 时,若 kk 为满足「k>0k > 0」的最小值 11 时,能够取满「1/0」两种情况,而其余更大 kk 值情况能够使用操作无效化(不影响灯的状态);

    • n=2n = 2 时,若 k=1k = 1,能够取得「11/10/01」三种状态,当 k=2k = 2 时,能够取满「11/10/01/00」四种状态,其余更大 kk 可以通过前 k1k - 1 步归结到任一状态,再通过最后一次的操作 11 归结到任意状态;

    • n=3n = 3 时,若 k=1k = 1 时,对应 44 种操作可取得 44 种方案;当 k=2k = 2 时,可取得 77 种状态;而当 k=3k = 3 时可取满 23=82^3 = 8 种状态,更大的 kk 值可通过同样的方式归结到取满的 88 种状态。

    • n>3n > 3 时,根据四类操作可知,灯泡每 66 组一循环(对应序列 k + 12k + 22k + 13k + 1),即只需考虑 n<=6n <= 6 的情况,而 n=4n = 4n=5n = 5n=6n = 6 时,后引入的灯泡状态均不会产生新的组合(即新引入的灯泡状态由前三个灯泡的状态所唯一确定),因此均可归纳到 n=3n = 3 的情况。




Java 代码:


class Solution {
public int flipLights(int n, int k) {
if (k == 0) return 1;
if (n == 1) return 2;
else if (n == 2) return k == 1 ? 3 : 4;
else return k == 1 ? 4 : k == 2 ? 7 : 8;
}
}

TypeScript 代码:


function flipLights(n: number, k: number): number {
if (k == 0) return 1
if (n == 1) return 2
else if (n == 2) return k == 1 ? 3 : 4;
else return k == 1 ? 4 : k == 2 ? 7 : 8;
};


  • 时间复杂度:O(1)O(1)

  • 空间复杂度:O(1)O(1)


最后


这是我们「刷穿 LeetCode」系列文章的第 No.672 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。


作者:宫水三叶的刷题日记
链接:https://juejin.cn/post/7143438427050999838
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 收起阅读 »

Android 闪屏页适配

遇到的坑 按官方文档设置完之后,debug运行,或者直接点击Run运行,闪屏页的logo不显示,清掉后台,从桌面点击启动logo才显示,不过设置的windowBackgroud 都是显示正常的,这个问题我调了一天,,,AndroidStudio版本4.2.2...
继续阅读 »

遇到的坑



按官方文档设置完之后,debug运行,或者直接点击Run运行,闪屏页的logo不显示,清掉后台,从桌面点击启动logo才显示,不过设置的windowBackgroud 都是显示正常的,这个问题我调了一天,,,AndroidStudio版本4.2.2




内容来自官方文档 文档地址:点我



如果您之前在 Android 11 或更低版本中实现了自定义初始屏幕,则需要将您的应用迁移到 SplashScreenAPI 以确保它在 Android 12 及更高版本中正确显示。


从 Android 12 开始,系统始终在所有应用的 启动和 热启动时应用新的Android 系统默认启动画面。默认情况下,此系统默认启动画面是使用您的应用程序的启动器图标元素和 您的主题(如果它是单色)构建的。windowBackground


如果您不迁移您的应用,您在 Android 12 及更高版本上的应用启动体验将会降级或可能出现意外结果:



  • 如果您现有的初始屏幕是使用覆盖 的自定义主题android:windowBackground实现的,则系统会将您的自定义初始屏幕替换为 Android 12 及更高版本上的默认 Android 系统初始屏幕(这可能不是您应用的预期体验)。

  • 如果您现有的初始屏幕是使用专用的 实现的,则Activity在运行 Android 12 或更高版本的设备上启动您的应用会导致重复的初始屏幕:显示新的系统初始屏幕 ,然后是您现有的初始屏幕活动。


您可以通过完成本指南中描述的迁移过程来防止这些降级或意外体验。迁移后,新 API 会缩短启动时间,让您完全控制初始屏幕体验,并确保与平台上其他应用程序的启动体验更加一致。


SplashScreen 兼容库


您可以SplashScreen直接使用 API,但我们强烈建议使用 AndroidxSplashScreen兼容库 。compat 库使用SplashScreenAPI,支持向后兼容,并为所有 Android 版本的初始屏幕显示创建一致的外观。本指南是使用 compat 库编写的。


如果您选择直接使用 SplashScreen API 进行迁移,在 Android 11 上并降低您的初始屏幕看起来与以前完全相同;从 Android 12 开始,初始屏幕将具有新的 Android 12 外观。


迁移您的启动画面实施


完成以下步骤,将您现有的初始屏幕实施迁移到适用于 Android 12 及更高版本的新体验。


此过程适用于您从中迁移的任何类型的实现。如果您是从专用迁移Activity,您还应该遵循本文档中描述的最佳实践来调整您的自定义启动屏幕Activity。新的SplashScreenAPI 还减少了由专用启动屏幕活动引入的启动延迟。


使用SplashScreencompat 库迁移后,系统会在所有版本的 Android 上显示相同的初始屏幕。


要迁移初始屏幕:




  1. build.gradle文件中,更改您的 compileSdkVersion并将 SplashScreencompat 库包含在依赖项中。


    build.gradle

    android {
       compileSdkVersion 31
       ...
    }
    dependencies {
       ...
       implementation 'androidx.core:core-splashscreen:1.0.0-beta02'
    }



  2. 使用 的父项创建一个主题Theme.SplashScreen,并将 的值设置为 应该使用 postSplashScreenTheme的主题以及可绘制或动画可绘制的主题。其他属性是可选的。Activity``windowSplashScreenAnimatedIcon


    <style name="Theme.App.Starting" parent="Theme.SplashScreen">
       <!-- Set the splash screen background, animated icon, and animation duration. -->
       <item name="windowSplashScreenBackground">@color/...</item>

       <!-- Use windowSplashScreenAnimatedIcon to add either a drawable or an
            animated drawable. One of these is required. -->
       <item name="windowSplashScreenAnimatedIcon">@drawable/...</item>
       <!-- Required for animated icons -->
       <item name="windowSplashScreenAnimationDuration">200</item>

       <!-- Set the theme of the Activity that directly follows your splash screen. -->
       <!-- Required -->
       <item name="postSplashScreenTheme">@style/Theme.App</item>
    </style>

    如果要在图标下方添加背景颜色,可以使用 Theme.SplashScreen.IconBackground主题并设置 windowSplashScreenIconBackground属性。




  3. 在清单中,将启动活动的主题替换为您在上一步中创建的主题。


    <manifest>
       <application android:theme="@style/Theme.App.Starting">
        <!-- or -->
            <activity android:theme="@style/Theme.App.Starting">
    ...



  4. installSplashScreen在调用之前调用启动 活动super.onCreate()


    class MainActivity : Activity() {

       override fun onCreate(savedInstanceState: Bundle?) {
           // Handle the splash screen transition.
           val splashScreen = installSplashScreen()

           super.onCreate(savedInstanceState)
           setContentView(R.layout.main_activity)
    ...



installSplashScreen返回初始屏幕对象,您可以选择使用它来自定义动画或将初始屏幕保持在屏幕上更长的时间。有关自定义动画的更多详细信息,请参阅 让初始屏幕在屏幕上停留更长时间 和自定义动画以关闭初始屏幕


使您的自定义启动屏幕活动适应新的启动屏幕体验


在您迁移到适用于 Android 12 及更高版本的新初始屏幕体验后,您的自定义初始屏幕Activity仍然存在,因此您需要选择如何处理它。您有以下选择:



  • 保留自定义活动,但阻止其显示

  • 出于品牌原因保留自定义活动

  • 删除自定义活动,并根据需要调整您的应用程序


阻止自定义 Activity 显示


如果您现有的初始屏幕Activity主要用于路由,请考虑删除它的方法;例如,您可以直接链接到实际活动或移动到带有子组件的单个活动。如果这不可行,您可以使用SplashScreen#setKeepOnScreenCondition 将路由活动保持在原位,但停止渲染。这样做会将初始屏幕转移到下一个活动,并允许平滑过渡。


  class RoutingActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        val splashScreen = installSplashScreen()
        super.onCreate(savedInstanceState)

        // Keep the splash screen visible for this Activity
        splashScreen.setKeepOnScreenCondition { true }
        startSomeNextActivity()
        finish()
     }
   ...
 

保留品牌化的自定义活动


如果您想使用后续启动画面Activity来获得品牌体验,您可以Activity通过自定义关闭启动画面的动画,从系统启动画面过渡到您的自定义启动画面。但是,如果可能的话,最好避免这种情况,并使用新的 SplashScreenAPI 来标记您的启动画面。


移除自定义闪屏Activity


一般来说,我们建议您Activity 完全删除您自定义的启动画面,以避免重复启动画面无法迁移,提高效率并减少启动画面加载时间。您可以使用不同的技术来避免显示多余的闪屏活动。




  • 延迟加载组件、模块或库:避免加载或初始化应用程序启动时不需要的组件或库,并在应用程序需要时加载它们。


    如果您的应用确实需要某个组件才能正常工作,请仅在真正需要时而不是在启动时加载它,或者在应用启动后使用后台线程加载它。尽量保持你Application onCreate()的轻盈。


    您还可以受益于使用App Startup 库在应用程序启动时初始化组件。这样做时,请确保仍然加载启动活动所需的所有模块,并且不要在延迟加载的模块变得可用时引入卡顿。




  • 在本地加载少量数据时创建占位符:使用推荐的主题化方法并保留渲染,直到应用程序准备好。要实现向后兼容的初始屏幕,请按照使初始屏幕在屏幕上停留更长时间中概述的步骤。




  • 显示占位符:对于持续时间不确定的基于网络的加载,关闭初始屏幕并显示占位符以进行异步加载。考虑将微妙的动画应用于反映加载状态的内容区域。确保加载的内容结构 尽可能匹配骨架结构,以便在加载内容后实现平滑过渡。




  • 使用缓存:当用户第一次打开您的应用程序时,您可以显示某些 UI 元素的加载指示符(如下例所示)。下次用户返回您的应用时,您可以在加载更新的内容时显示此缓存内容。


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

Android将倒计时做到极致

前言 倒计时的实现有很多方式,我觉得分享这个技术的关键在于有些官方的,甚至第三方的,也许能帮我实现99%的效果,但是当你从99%优化到100%,哪怕这1%微不足道,但你能从这个过程中得到的东西远远比你想象中的要多。 已有倒计时方案存在的问题 在开发倒计时功能时...
继续阅读 »

前言


倒计时的实现有很多方式,我觉得分享这个技术的关键在于有些官方的,甚至第三方的,也许能帮我实现99%的效果,但是当你从99%优化到100%,哪怕这1%微不足道,但你能从这个过程中得到的东西远远比你想象中的要多。


已有倒计时方案存在的问题


在开发倒计时功能时往往我们会为了方便直接使用CountDownTimer或者使用Handler做延时来实现,当然CountDownTimer内部封装也是使用的Handler。


如果只是做次数很少的倒计时或者不需要精确的倒计时逻辑那倒没关系,比如说我只要倒计时10秒,或者我大概5分钟请求某个接口


但是如果是需要做精确的倒计时操作,比如说手机发送验证码60秒,那使用现有的倒计时方案就会存在问题。可能有些朋友没有注意到这一点,下面我们就来简单分析一下现有倒计时的问题。


1. CountDownTimer


这个可能是用得最多的,因为方便嘛。但其实倒计时每一轮倒计时完之后都是存在误差的,如果看过CountDownTimer的源码你就会知道,他的内部是有做校准操作的。(源码很简单这里就不分析了)


但是如果你认真的测试过CountDownTimer,你就会发现,即便它内部有做校准操作,他的每一轮都是有偏差,只是他最后一次倒计时完之后的总共时间和开始倒计时的时间相比没偏差。

什么意思呢,意思就是1秒,2.050秒,3.1秒......,这样的每轮偏差,导致他会出现10.95秒,下一次12秒的情况,那它的回调中如果你直接做取整就会出现少一秒的情况,但实际是没少的。

这只是其中的一个问题,你可以不根据它的回调做展示,自己用一个整形累加做展示也能解决。但是他还有个问题,有概率直接出现跳秒,就是比如3秒,下次直接5秒,这是实际的跳秒,是少了一次回调的那种。


跳秒导致你如果直接使用它可能会大问题,你可能自测的时候没发现,到时一上线应用在用户那概率跳秒,那就蛋疼了。


2. Handler


不搞这么多花里胡哨的,直接使用Handler来实现,会有什么问题。

因为直接使用handler来实现,没有校准操作,每次循环会出现几毫秒的误差,虽然比CountDownTimer的十几毫秒的误差要好,但是在基数大的倒计时情况下误差会累计,导致最终结果和现实时间差几秒误差,时间越久,误差越大


3. Timer


直接使用Timer也一样,只不过他每轮的误差更小,几轮才有1毫秒的误差,但是没有校准还是会出现误差累计,时间越久误差越大。


自己封装倒计时


既然无法直接使用原生的,那我们就自己做一个。

我们基于Handler进行封装,从上面可以看出主要为了解决两个问题,时间校准和跳秒。自己写一个CountDownTimer


public class CountDownTimer {

private int mTimes;
private int allTimes;
private final long mCountDownInterval;
private final Handler mHandler;
private OnTimerCallBack mCallBack;
private boolean isStart;
private long startTime;

public CountDownTimer(int times, long countDownInterval){
this.mTimes = times;
this.mCountDownInterval = countDownInterval;
mHandler = new Handler();
}

public synchronized void start(OnTimerCallBack callBack){
this.mCallBack = callBack;
if (isStart || mCountDownInterval <= 0){
return;
}

isStart = true;
if (callBack != null){
callBack.onStart();
}
startTime = SystemClock.elapsedRealtime();

if (mTimes <= 0){
finishCountDown();
return;
}
allTimes = mTimes;

mHandler.postDelayed(runnable, mCountDownInterval);
}

private final Runnable runnable = new Runnable() {
@Override
public void run() {
mTimes--;
if (mTimes > 0){
if (mCallBack != null){
mCallBack.onTick(mTimes);
}

long nowTime = SystemClock.elapsedRealtime();
long delay = (nowTime - startTime) - (allTimes - mTimes) * mCountDownInterval;
// 处理跳秒
while (delay > mCountDownInterval){
mTimes --;
if (mCallBack != null){
mCallBack.onTick(mTimes);
}

delay -= mCountDownInterval;
if (mTimes <= 0){
finishCountDown();
return;
}
}

mHandler.postDelayed(this, 1000 - delay);
}else {
finishCountDown();
}
}
};

private void finishCountDown(){
if (mCallBack != null){
mCallBack.onFinish();
}
isStart = false;
}

public void cancel(){
mHandler.removeCallbacksAndMessages(null);
isStart = false;
}

public interface OnTimerCallBack{

void onStart();

void onTick(int times);

void onFinish();

}

}

思路就是在倒计时开始前获取一次SystemClock.elapsedRealtime(),每轮倒计时再获取一次SystemClock.elapsedRealtime()相减得到误差,根据delay校准。然后使用while循环来处理跳秒的操作,与原生的CountDownTimer不同,这里如果跳了多少秒,就会返回多少次回调。


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

白话ThreadLocal原理

ThreadLocal作用 对于Android程序员来说,很多人都是在学习消息机制时候了解到ThreadLocal这个东西的。那它有什么作用呢?官方文档大致是这么描述的: ThreadLocal提供了线程局部变量 每个线程都拥有自己的变量副本,可以通过Thr...
继续阅读 »

ThreadLocal作用


对于Android程序员来说,很多人都是在学习消息机制时候了解到ThreadLocal这个东西的。那它有什么作用呢?官方文档大致是这么描述的:



  • ThreadLocal提供了线程局部变量

  • 每个线程都拥有自己的变量副本,可以通过ThreadLocal的set或者get方法去设置或者获取当前线程的变量,变量的初始化也是线程独立的(需要实现initialValue方法)

  • 一般而言ThreadLocal实例在类中被private static修饰

  • 当线程活着并且ThreadLocal实例能够访问到时,每个线程都会持有一个到它的变量的引用

  • 当一个线程死亡后,所有ThreadLocal实例给它提供的变量都会被gc回收(除非有其它的引用指向这些变量)
    上述中“变量”是指ThreadLocal的get方法获取的值


简单例子


先来看一个简单的使用例子吧:


public class ThreadId {

private static final AtomicInteger nextId = new AtomicInteger(0);

private static final ThreadLocal<Integer> threadId = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return nextId.get();
}
};

public static int get() {
return threadId.get();
}
}

这也是官方文档上的例子,非常简单,就是通过在不同线程调用ThredId.get()可以获取唯一的线程Id。如果在调用ThreadLocal的get方法之前没有主动调用过set方法设置值的话,就会返回initialValue方法的返回值,并把这个值存储为当前线程的变量。


ThreadLocal到底是用来解决什么问题,适用什么场景呢,例子是看懂了,但好像还是没什么体会?ThreadLocal既然是提供变量的,我们不妨把我们见过的变量类型拿出来,做个对比


局部变量、成员变量 、 ThreadLocal、静态变量










































变量类型作用域生命周期线程共享性作用
局部变量方法(代码块)内部,其他方法(代码块)不能访问方法(代码块)开始到结束只存在于每个线程的工作内存,不能在线程中共享解决变量在方法(代码块)内部的代码行之间的共享
成员变量实例内和实例相同可在线程间共享解决变量在实例方法之间的共享,否则方法之间只能靠参数传递变量
静态变量类内部和类的生命周期相同可在多个线程间共享解决变量在多个实例之间的共享
ThreadLocal存储的变量整个线程一般而言与线程的生命周期相同不再多线程间共享解决变量在单个线程中的共享问题,线程中处处可访问

ThreadLocal存储的变量本质上间接算是Thread的成员变量,ThreadLocal只是提供了一种对开发者透明的可以为每个线程存储同一维度成员变量的方式。


共享 or 隔离


网上有很多人持有如下的看法:
ThreadLocal为解决多线程程序的并发问题提供了一种新思路或者ThreadLocal是为了解决多线程访问资源时的共享问题。
个人认为这些都是错误的,ThreadLocal保存的变量是线程隔离的,与资源共享没有任何关系,也没有解决什么并发问题,这一点看了ThreadLocal的原理就会更加清楚。就好比上面的例子,每个线程应该有一个线程Id,这并不是什么并发问题啊。


同时他们会拿ThreadLocal与sychronized做对比,我们要清楚它们根本不是为了解决同一类问题设计的。sychronized是在牵涉到共享变量时候,要做到线程间的同步,保证并发中的原子性与内存可见性,典型的特征是多个线程会访问相同的变量。而ThreadLocal根本不是解决线程同步问题的,它的场景是A线程保存的变量只有A线程需要访问,而其它的线程并不需要访问,其他线程也只访问自己保存的变量。


原理


我们来一个开放性的问题,假如现在要给每个线程增加一个线程Id,并且Java的Thread类你能随便修改,你要怎么操作?非常简单吧,代码大概是这样


public class Thread{
private int id;

public void setId(int id){
this.id=id;
}
}

那好,现在题目变了,我们现在还得为每个线程保存一个Looper对象,那怎么办呢?再加一个Looper的字段不就好了,显然这种做法肯定是不具有扩展性的。那我们用一个容器类不就好了,很自然地就会想到Map,像下面这样


public class Thread{

private Map<String,Object> map;

public Map<String,Object> getMap(){
if(map==null)
map=new HashMap<>();
return map;
}

}

然后我们在代码里就可以通过如下代码来给Thread设置“成员变量”了


   Thread.currentThread().getMap().put("id",id);
Thread.currentThread().getMap().put("looper",looper);

然后可以在该线程执行的任意地方,这样访问:


  Looper looper=(Looper) Thread.currentThread().getMap().get("looper");

看上去还不错,但是还是有些问题:



  • 保存和获取变量都要用到字符换key

  • 因为map中要保存各种值,因此泛型只得用Object,这样获取时候就需要强制转换(可用泛型方法解)

  • 当该变量没有作用时候,此时线程还没有执行完,需要手动设置该变量为空,否则会造成内存泄漏


为了不通过字符串访问,同时省去强制转换,我们封装一个类,就叫ThreadLocal吧,伪代码如下:


  public class ThreadLocal<T> {

public void set(T value) {
Thread t = Thread.currentThread();
Map map = t.getMap();
if (map != null)
//以自己为键
map.put(this, value);
else
createMap(t, value);
}


public T get() {
Thread t = Thread.currentThread();
Map<ThreadLocal<?>,T> map = t.getMap();
if (map != null) {
T e = map.get(this);
return e;
}
return setInitialValue();
}
}

没错,以上基本上就是ThreadLocal的整体设计了,只是线程中存储数据的Map是特意实现的ThreadLocal.ThreadLocalMap。


ThreadLocal与线程的关系如下:
ThreadLocal与线程的关系.png


如上图如所示,ThredLocal本身并不存储变量,只是向每个线程的threadLocals中存储键值对。ThreadLocal横跨线程,提供一种类似切面的概念,这种切面是作用在线程上的。


我们对ThreadLocal已经有一个整体的认识了,接下来我们大致看一下源码


源码分析


TheadLocal


   public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

set方法通过Thread.currentThread方法获取当前线程,然后调用getMap方法获取线程的threadLocals字段,并往ThreadLocalMap中放入键值对,其中键为ThreadLocal实例自己。


 ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

接着看get方法:


public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

很清晰,其中值得注意的是最后一行的setInitialValue方法,这个方法在我们没有调用过set方法时候调用。


private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

setInitialValue方法会获取initialValue的返回值并把它放进当前线程的threadLocals中。默认情况下initialValue返回null,我们可以实现这个方法来对变量进行初始化,就像上面TheadId的例子一样。


remove方法,从当前线程的ThreadLocalMap中移除元素。


public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

TheadLocalMap


看ThreadLocalMap的代码我们主要是关注以下两个方面:



  1. 散列表的一般设计问题。包括散列函数,散列冲突问题解决,负载因子,再散列等。

  2. 内存泄漏的相关处理。一般而言ThreadLocal 引用使用private static修饰,但是假设某种情况下我们真的不再需要使用它了,手动把引用置空。上面我们知道TreadLocal本身作为键存储在TheadLocalMap中,而ThreadLocalMap又被Thread引用,那线程没结束的情况下ThreadLocal能被回收吗?


散列函数
先来理一下散列函数吧,我们在之后的代码中会看到ThreadLocalMap通过 int i = key.threadLocalHashCode & (len-1);决定元素的位置,其中表大小len为2的幂,因此这里的&操作相当于取模。另外我们关注的是threadLocalHashCode的取值。


  private final int threadLocalHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
private static AtomicInteger nextHashCode =
new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;

这里很有意思,每个ThreadLocal实例的threadLocalHashCode是在之前ThreadLocal实例的threadLocalHashCode上加 0x61c88647,为什么偏偏要加这么个数呢?
这个魔数的选取与斐波那契散列有关以及黄金分割法有关,具体不是很清楚。它的作用是这样产生的值与2的幂取模后能在散列表中均匀分布,即便扩容也是如此。看下面一段代码:


  public class MagicHashCode {
//ThreadLocal中定义的魔数
private static final int HASH_INCREMENT = 0x61c88647;

public static void main(String[] args) {
hashCode(16);//初始化16
hashCode(32);//2倍扩容
hashCode(64);
}

private static void hashCode(int length){
int hashCode = 0;
for(int i=0;i<length;i++){
hashCode = i*HASH_INCREMENT+HASH_INCREMENT;
System.out.print(hashCode & (length-1));//求取模后的下标
System.out.print(" ");
}
System.out.println();
}
}

输出结果为:


7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0   //容量为16时
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 //容量为32时
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 //容量为64时

因为ThreadLocalMap使用线性探测法解决冲突(下文会看到),均匀分布的好处在于发生了冲突也能很快找到空的slot,提高效率。


瞄一眼成员变量:


       /**
* 初始容量,必须是2的幂。这样的话,方便把取模运算转化为与运算,
* 效率高
*/
private static final int INITIAL_CAPACITY = 16;

/**
* 容纳Entry元素,长度必须是2的幂
*/
private Entry[] table;

/**
* table中的元素个数.
*/
private int size = 0;

/**
* table里的元素达到这个值就需要扩容了
* 其实是有个装载因子的概念的
*/
private int threshold; // Default to 0

构造函数:


  ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

firstKey和firstValue就是Map存放的第一个键值对喽。其中firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)很关键,就是当容量为2的幂时候,这相当于一个取模操作。然后把Entry存储到数组的第i个位置,设置扩容的阈值。


private void setThreshold(int len) {
threshold = len * 2 / 3;
}

这说明当数组里的元素容量达到2/3时候就要扩容,也就是装载因子是2/3。
接下来我们来看下Entry


 static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

就这么点东西,这个Entry只是与HashMap不同,只是个普通的键值对,没有链表结构相关的东西。另外Entry只持有对键,也就是ThreadLocal的弱引用,那么我们上面的第二个问题算是有答案了。当没有其他强引用指向ThreadLocal的时候,它其实是会被回收的。但是这有引出了另外一个问题,那Entry呢?当键都为空的时候这个Entry也是没有什么作用啊,也应该被回收啊。不慌,我们接着往下看。


set方法:


 private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//如果冲突的话,进入该循环,向后探测
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//判断键是否相等,相等的话只要更新值就好了
if (k == key) {
e.value = value;
return;
}

if (k == null) {
//该Entry对应的ThreadLocal已经被回收,执行replaceStaleEntry并返回
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
//进行启发式清理,如果没有清理任何元素并且表的大小超过了阈值,需要扩容并重哈希
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

我们发现如果发生冲突的话,整体逻辑会一直调用nextIndex方法去探测下一个位置,直到找到没有元素的位置,逻辑上整个表是一个环形。下面是nextIndex的代码,就是加1而已。


private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

线性探测的过程中,有一种情况是需要清理对应Entry的,也就是Entry的key为null,我们上面讨论过这种情况下的Entry是无意义的。因此调用
replaceStaleEntry(key, value, i);在看replaceStaleEntry(key, value, i)我们先明确几个问题。采用线性探测发解决冲突,在插入过程中产生冲突的元素之前一定是没有空的slot的。这样在也确保在查找过程,查找到空的slot就可以停止啦。但是假如我们删除了一个元素,就会破坏这种情况,这时需要对表中删除的元素后面的元素进行再散列,以便填上空隙。


空slot:即该位置没有元素
无效slot:该位置有元素,但key为null


replaceStaleEntry除了将value放入合适的位置之外,还会在前后连个空的slot之间做一次清理expungeStaleEntry,清理掉无效slot。


private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// 向前扫描到一个空的slot为止,找到离这个空slot最近的无效slot,记录为slotToExpunge
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len)) {
if (e.get() == null) {
slotToExpunge = i;
}
}

// 向后遍历table
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

// 找到了key,将其与无效slot交换
if (k == key) {
// 更新对应slot的value值
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
//如果之前还没有探测到过其他无效的slot
if (slotToExpunge == staleSlot) {
slotToExpunge = i;
}
// 从slotToExpunge开始做一次连续段的清理,再做一次启发式清理
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// 如果当前的slot已经无效,并且向前扫描过程中没有无效slot,则更新slotToExpunge为当前位置
if (k == null && slotToExpunge == staleSlot) {
slotToExpunge = i;
}
}

// 如果key之前在table中不存在,则放在staleSlot位置
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// 在探测过程中如果发现任何其他无效slot,连续段清理后做启发式清理
if (slotToExpunge != staleSlot) {
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
}

expungeStaleEntry主要是清除连续段之前无效的slot,然后对元素进行再散列。返回下一个空的slot位置。


 private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// 删除 staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
//对元素进行再散列
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

启发式地清理:
i对应是非无效slot(slot为空或者有效)
n是用于控制控制扫描次数
正常情况下如果log n次扫描没有发现无效slot,函数就结束了。
但是如果发现了无效的slot,将n置为table的长度len,做一次连续段的清理,再从下一个空的slot开始继续扫描。
这个函数有两处地方会被调用,一处是插入的时候可能会被调用,另外个是在替换无效slot的时候可能会被调用, 区别是前者传入的n为实际元素个数,后者为table的总容量。


private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
// i在任何情况下自己都不会是一个无效slot,所以从下一个开始判断
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
// 扩大扫描控制因子
n = len;
removed = true;
// 清理一个连续段
i = expungeStaleEntry(i);
}
} while ((n >>>= 1) != 0);
return removed;
}

接着看set函数,如果循环过程中没有返回,找到合适的位置,插入元素,表的size增加1。这个时候会做一次启发式清理,如果启发式清理没有清理掉任何无效元素,判断清理前表的大小大于阈值threshold的话,正常就要进行扩容了,但是表中可能存在无效元素,先把它们清除掉,然后再判断。


private void rehash() {
// 全量清理
expungeStaleEntries();
//因为做了一次清理,所以size可能会变小,这里的实现是调低阈值来判断是否需要扩容。 threshold默认为len*2/3,所以这里的threshold - threshold / 4相当于len/2。
if (size >= threshold - threshold / 4) {
resize();
}
}

作用即清除所有无效slot


private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null) {
expungeStaleEntry(j);
}
}
}

保证table的容量len为2的幂,扩容时候要扩大2倍


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
} else {
// 扩容后要重新放置元素
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null) {
h = nextIndex(h, newLen);
}
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

get方法:


private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 对应的entry存在且key未被回收
if (e != null && e.get() == key) {
return e;
} else {
// 继续往后查找
return getEntryAfterMiss(key, i, e);
}
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 不断向后探测直到遇到空entry
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到
if (k == key) {
return e;
}
if (k == null) {
// 该entry对应的ThreadLocal实例已经被回收,调用expungeStaleEntry来清理无效的entry
expungeStaleEntry(i);
} else {
// 下一个位置
i = nextIndex(i, len);
}
e = tab[i];
}
return null;
}

remove方法,比较简单,在table中找key,如果找到了断开弱引用,做一次连续段清理。


private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
//断开弱引用
e.clear();
// 连续段清理
expungeStaleEntry(i);
return;
}
}
}

ThreadLocal与内存泄漏


从上文我们知道当调用ThreadLocalMap的set或者getEntry方法时候,有很大概率会去自动清除掉key为null的Entry,这样就可以断开value的强引用,使对象被回收。但是如果如果我们之后再也没有在该线程操作过任何ThreadLocal实例的set或者get方法,那么就只能等线程死亡才能回收无效value。因此当我们不需要用ThreadLocal的变量时候,显示调用ThreadLocal的remove方法是一种好的习惯。


小结



  • ThredLocal为每个线程保存一个自己的变量,但其实ThreadLocal本身并不存储变量,变量存储在线程自己的实例变量ThreadLocal.ThreadLocalMap threadLocals

  • ThreadLocal的设计并不是为了解决并发问题,而是解决一个变量在线程内部的共享问题,在线程内部处处可以访问

  • 因为每个线程都只会访问自己ThreadLocalMap 保存的变量,所以不存在线程安全问题

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

一类有趣的无限缓存OOM现象

OOM
首先想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:串行处理改成并发处理,zxing的原生处理流程是通过CameraM...
继续阅读 »

首先

想必大家都知道OOM是啥吧,我就不扯花里胡哨的了,直接进入正题。先说一个背景故事,我司app扫码框架用的zxing,在很长一段时间以前,做过一系列的扫码优化,稍微列一下跟今天主题相关的改动:

  1. 串行处理改成并发处理,zxing的原生处理流程是通过CameraManager获取到一帧的数据之后,通过DecodeHandler去处理,处理完成之后再去获取下一帧,我们给改成了线程池去调度:
  • 单帧decode任务入队列之后立即获取下一帧数据
  • 二维码识别成功则停止其他解析任务
  1. 为了有更大的识别区域,选择对整张拍摄图片进行解码,保证中心框框没对准二维码也能识别到

现象

当时测试反馈,手上一个很古老的 Android 5.0 的机器,打开扫一扫必崩,一看错误栈,是个OOM

机器找不到了,我就不贴现象的堆栈了(埋在时光里了,懒得挖了)。

排查OOM三板斧

板斧一、 通过一定手段,抓取崩溃时的或者崩溃前的内存快照

咦,一年前的hprof文件还在?确实被我找到了。。。

从图中我们能获得哪些信息?

  1. 用户OOM时,byte数组的 java 堆占用是爆炸的

  2. 用户OOM时,byte数组里,有大量的 3M 的byte数组

  3. 3Mbyte 数组是被 zxing 的 DecodeHandler$2 引用的

板斧二、从内存对照出发,大胆猜测找到坏死根源

我们既然知道了 大对象 是被 DecodeHandler$2 引用的,那么 DecodeHandler$2 是个啥呀?

mDecodeExecutor.execute(new Runnable() {
@Override
public void run() {
for (Reader reader : mReaders) {
decodeInternal(data, width, height, reader, fullScreenFrame);
}
}
});

所以稍微转动一下脑瓜子就能知道,必然是堆积了太多的 Runnable,每个Runnable 持有了一个 data 大对象才导致了这个OOM问题。

但是为啥会堆积太多 Runnable 呢?结合一下只有 Android 5.0 机器会OOM,我们大胆猜测一下,就是因为这个机器消费(或者说解码)单张 Bitmap 太慢,同时像上面所说的,我们单帧decode任务入队列之后立即获取下一帧数据并入队下一帧decode 任务,这就导致大对象堆积在了LinkedBlockingDeque中。

OK,到这里原因也清楚了,改掉就完事了。

板斧三、 吃个口香糖舒缓一下心情

呵呵...

解决方案

解决方案其实很简单,从问题出发即可,问题是啥?我生产面包速度是一天10个,一个一斤,但是一天只能吃三斤,那岂不就一天就会多7斤囤货,假如囤货到了100斤地球会毁灭,怎么解决呢?

  1. 吃快点,一天吃10斤
  2. 少生产点,要么生产个数减少,要么生产单个重量减少,要么二者一起
  3. 生产前检查一下吃完没,吃完再生产都来得及,实在不行定个阈值觉得不够吃了再生产嘛。

那么自然而然的就大概知道有哪几种解决办法了:

  1. 生产的小点 - 隔几帧插一张全屏帧即可(如果要保留不在框框内也能解码的特性的话)
  2. 生产前检查一下吃完没 - 线程池的线程空闲时,才去 enqueue decode 任务
  3. 生产单个重量减少 - 限制队列大小
  4. blalala

总结

装模作样的总结一下。

这个例子是一年前遇到的,今天想水篇文章又突然想到了这个事就拿来写写,我总结为:线程池调度 + 进阻塞队列单任务数据过大 + 处理任务过慢

线程池调度任务是啥场景?

  • 有个 Queue,来了任务,先入队
  • 有个 ThreadPool ,空闲了,从 Queue 取任务。

那么,当入队的数据结构占内存太大,且 ThreadPool 处理速度小于 入队速度呢?就会造成 Queue 中数据越来越多,直到 OOM

扫一扫完美的满足了上面条件

  • 入队频率足够高

  • 入队对象足够大

  • 处理速度足够慢。

在这个例子中,做的不足的地方:

  1. 追求并发未考虑机器性能

  2. 大对象处理不够谨慎

当然,总结是为了避免未来同样的惨案发生,大家可以想想还会有什么类似的场景吧,转动一下聪明的小脑袋瓜~

未来展望

装模作样展望一下,未来展望就是,以后有空多水水贴子吧(不是多水水贴吧)。


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

收起阅读 »

2022 年 App 上架审核问题集锦,全面踩坑上线不迷路

相信这几年负责过上架应用市场的 App 开发,或多或少都躺过上线审核的坑,经历过的各种问题也是千奇百怪,今天就给大家做个汇总,希望可以帮助大家少走弯路,争取做一个“优雅”的客户端开发。 首先,近年来为了 “净化” App 环境、保护用户隐私和优化用户体验,各部...
继续阅读 »

相信这几年负责过上架应用市场的 App 开发,或多或少都躺过上线审核的坑,经历过的各种问题也是千奇百怪,今天就给大家做个汇总,希望可以帮助大家少走弯路,争取做一个“优雅”的客户端开发。


首先,近年来为了 “净化” App 环境、保护用户隐私和优化用户体验,各部委大致出台过如下所示的相关法规:

内容时间
《教育移动互联网应用程序备案管理办法》2019 年 11 月 13 日
《App违法违规收集使用个人信息行为认定方法》2019 年 12 月 30 日
《常见类型移动互联网应用程序必要个人信息范围规定》2021 年 03 月 22 日
《个人信息保护法》2021 年 11 月 1 日
《移动互联网应用程序(App)个人信息保护治理白皮书》2021 年 11 月 22 日
《互联网用户账号信息管理规定》2022 年 1 月 1 日
《数据出境安全评估办法》2022 年 9 月 1 日
《互联网弹窗信息推送服务管理规定》2022 年 9 月 30 日

可能还有一些我不知道的遗漏,那不知道这些法规你是否都听说过,这里举一些常见例子:




  • 《互联网用户账号信息管理规定》 的第十二条就是在 App 展示用户 IP 的要求相关条款

  • 《常见类型移动互联网应用程序必要个人信息范围规定》就规定了 App 类目所能获取的权限范围和个人信息索取范围,例如新闻资讯类、浏览器类、安全管理类、应用商店类等无须个人信息,即可使用基本功能服务。


针对上面这个无需权限和个人信息也要提供基本功能服务,如下动图所示,今日头条、知乎和懂车帝就是很好的参考例子,在不同意个人隐私协议的情况下,会有仅浏览的模式,在这个情况下依然可以阅读内容而不是退出 App












所以严格意义上讲,现在 App 按照类目的规定,如果你的 App 在某些类目就只能获取对应权限,多了就是违规,而且一些类目必须用户在没有提供权限和同意协议的情况下,也必须提供服务




  • 《互联网弹窗信息推送服务管理规定》里就有: 弹窗推送广告显著标明“广告”,一键关闭,提供取消渠道等


如下图所示,从意见稿开始之后,基本大部分 App 的启动广告就限制了有效点击范围,产品经理也不能拍着脑袋让你加各种奇奇怪怪的跳转。





首先用户必须同意了你才能收集,不同意是不能收集,所以 App 里各式各样的弹出框就来了,这也是目前最常见的“合规方式”。


而导出个人信息的功能普遍是通过邮箱发送实现,事实上目前还有不少 App 没提供类似支持,还有 App 必须提供用户注销功能,这也是现在 App 开发的必选项,另外 App 还需要提供个性化推荐的开关能力,不然也有审核风险,当时有时候只是需要你放个按键。









image-20220909162615563

另外,在《个保法》的提案里也提及了不能以用户不提供个人信息为由不提供服务,当时实际执行往往还是要看应用类目。



而在用户个人信息认定里,设备id (Android ID) 绝对是重灾区 ,因为几乎是个 App 就会使用到设备 ID,特别是接入的各类第三方 SDK 服务里普遍都会获取。




而处理方法也是普通粗旷,用户不同意隐私协议,就不初始化各类 SDK ,当然,有时候你可能还是会遇到某些奇葩的审核,明明你已经做了处理,平台还认定你违规,这时候可能你就需要学会申诉,不要傻傻自己一直摸索哪里还不对。




总的来说上架问题一般是和个人信息隐私相关的问题最多,而常见的问题有:



  • 未经用户允许手机个人信息

  • 所需信息和服务无关,过度收集

  • 未提供导出和删除个人信息的功能服务

  • 存在个人信息泄漏风险

  • 未明确公布个人信息收集的目的和使用范围


最后这一条也是经常出现问题的点,例如现在会要求你提供哪些 SDK 使用了哪些权限和信息,收集规则是什么用于做什么 ,这也就需要 App 里提供更详细和丰富的隐私政策内容,当然 SDK 提供方也要。



而一般情况下最常见也是最容易触发整改的,就是设备ID,MAC 地址等相关内容,或者说你的 App 其实根本不需要这些也能提供服务,就如前面 《常见类型移动互联网应用程序必要个人信息范围规定》里的要求一样。



这里还有个关键,那就是用户在同意隐私条款时,你不能默认勾选,也就是有需要用户同意☑️的 UI 时,默认时不能选中,需要用户手动勾选同意。



当然,随着审核颗粒度的细化,越来越多奇奇怪怪的问题出现了,例如 Apk 里的资源文件存在安全泄漏问题 ,而解决该问题的有效方法就是:混淆和加固



加固和混淆也适用于以下相关问题的解决,当然,加固的话建议选用第三方付费服务,免费加固的坑实在太多了





















  • 《数据出境安全评估办法》 里针对数据出境也做了要求,其中最直观的例子就是:高德 SDK 无法在以外地区范围服务



当然,不只是相关法规,平台有时候也有自己的规定和理解,比如有几位群友,先后在小米因为 App 里提供 UI 和商店截图一致被打回,理由是应用截图与应用实际功能不符 ,相信遇到这类问题的兄弟是相当郁闷,因为不一致这个认定其实很主观










另外小米等平台还有以没通过Monkey 自动化测试为理由拒绝上架 ,一般这种情况推荐自己上传 testit.miui.com ,通过小米自动化测试后在上传审核时把你通过截图作为附加,这样可以解决审核时的扯皮问题。


有时候一些平台也会有安全扫描,例如华为就会扫描同名的包名,然后附上 git 链接告诉你风险










另外,华为审核时可能会对你的产品逻辑提出他们的想法,比如空白页面,添加引导,没有客服返回渠道等等。










还有另外一个高风险点就是自启动,相信我,如果你要上架平台,2022 年了就不要再想做什么保活相关的逻辑了



除此之外,如果平台说你存在问题,尽量想办法要到检测报告,因为有时候一些平台委托的第三方可能会不是很“靠谱“,然后需要你自己出钱区做”二次付费检测“。










除了上面的问题之后,如果你还遇到如下图类似问题,都可以通过一些官方平台的检测如 open.oppomobile.com/opdp/privac… 帮助查找问题,这样也许就可以帮老板省下一笔开销,当然有一些第三方开源平台如 Hegui3.0PrivacySentry 等项目,也可以帮助你解决一些实际问题



最后,如果关于什么上架审核或者安全合规等问题,欢迎留言评论,也许以后本篇可以作为一个更新集合,继续帮助到更多需要的可怜 App 开发


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

iOS的CoreData技术笔记

前言最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。上网查了一堆资料后,...
继续阅读 »

前言

最近因为新项目想用到数据持久化,本来这是很简单的事情,复杂数据一般直接SQLite就可以解决了。
但是一直以来使用SQLite确实存在要自己设计数据库,处理逻辑编码,还有调试方面的种种繁琐问题。所以考虑使用iOS的Core Data方案。
上网查了一堆资料后,发现很多代码都已经是陈旧的了。甚至苹果官方文档提供的代码样例都未必是最新的Swift版本。于是萌生了自己写一篇文章来整理一遍思路的想法。尽可能让新人快速的上手,不但要知道其然,还要知道其设计的所以然,这样用起来才更得心应手。

什么是Core Data

我们写app肯定要用到数据持久化,说白了,就是把数据保存起来,app不删除的话可以继续读写。
iOS提供数据持久化的方案有很多,各自有其特定用途。
比如很多人熟知的UserDefaults,大部分时候是用来保存简单的应用配置信息;而NSKeyedArchiver可以把代码中的对象保存为文件,方便后来重新读取。
另外还有个常用的保存方式就是自己创建文件,直接在磁盘文件中进行读写。
而对于稍微复杂的业务数据,比如收藏夹,用户填写的多项表格等,SQLite就是更合适的方案了。关于数据库的知识,我这里就不赘述了,稍微有点技术基础的童鞋都懂。
Core DataSQLite做了更进一步的封装,SQLite提供了数据的存储模型,并提供了一系列API,你可以通过API读写数据库,去处理想要处理的数据。但是SQLite存储的数据和你编写代码中的数据(比如一个类的对象)并没有内置的联系,必须你自己编写代码去一一对应。
Core Data却可以解决一个数据在持久化层和代码层的一一对应关系。也就是说,你处理一个对象的数据后,通过保存接口,它可以自动同步到持久化层里,而不需要你去实现额外的代码。
这种 对象→持久化 方案叫 对象→关系映射(英文简称ORM)。
除了这个最重要的特性,Core Data还提供了很多有用的特性,比如回滚机制,数据校验等。


图1: Core Data与应用,磁盘存储的关系

数据模型文件 - Data Model

当我们用Core Data时,我们需要一个用来存放数据模型的地方,数据模型文件就是我们要创建的文件类型。它的后缀是.xcdatamodeld。只要在项目中选 新建文件→Data Model 即可创建。
默认系统提供的命名为 Model.xcdatamodeld 。下面我依然以 Model.xcdatamodeld 作为举例的文件名。
这个文件就相当于数据库中的“库”。通过编辑这个文件,就可以去添加定义自己想要处理的数据类型。

数据模型中的表格 - Entity

当在xcode中点击Model.xcdatamodeld时,会看到苹果提供的编辑视图,其中有个醒目的按钮Add Entity
什么是Entity呢?中文翻译叫“实体”,但是我这里就不打算用各种翻译名词来提高理解难度了。
如果把数据模型文件比作数据库中的“库”,那么Entity就相当于库里的“表格”。这么理解就简单了。Entity就是让你定义数据表格类型的名词。
假设我这个数据模型是用来存放图书馆信息的,那么很自然的,我会想建立一个叫BookEntity

属性 - Attributes

当建立一个名为BookEntity时,会看到视图中有栏写着Attributes,我们知道,当我们定义一本书时,自然要定义书名,书的编码等信息。这部分信息叫Attributes,即书的属性。
Book的Entity
属性名类型
nameString
isbmString
pageInteger32
其中,类型部分大部分是大家熟知的元数据类型,可以自行查阅。
同理,也可以再添加一个读者:Reader的Entity描述。
Reader的Entity
属性名类型
nameString
idCardString


图2: 在项目中创建数据模型文件

关系 - Relationship

在我们使用Entity编辑时,除了看到了Attributes一栏,还看到下面有Relationships一栏,这栏是做什么的?
回到例子中来,当定义图书馆信息时,刚书籍和读者的信息,但这两个信息彼此是孤立的,而事实上他们存在着联系。
比如一本书,它被某个读者借走了,这样的数据该怎么存储?
直观的做法是再定义一张表格来处理这类关系。但是Core Data提供了更有效的办法 - Relationship
Relationship的思路来思考,当一本书A被某个读者B借走,我们可以理解为这本书A当前的“借阅者”是该读者B,而读者B的“持有书”是A。
从以上描述可以看出,Relationship所描述的关系是双向的,即A和B互相以某种方式形成了联系,而这个方式是我们来定义的。
ReaderRelationship下点击+号键。然后在Relationship栏的名字上填borrow,表示读者和书的关系是“借阅”,在Destination栏选择Book,这样,读者和书籍的关系就确立了。
对于第三栏,Inverse,却没有东西可以填,这是为什么?
因为我们现在定义了读者和书的关系,却没有定义书和读者的关系。记住,关系是双向的。
就好比你定义了A是B的父亲,那也要同时去定义B是A的儿子一个道理。计算机不会帮我们打理另一边的联系。
理解了这点,我们开始选择Book的一栏,在Relationship下添加新的borrowByDestinationReader,这时候点击Inverse一栏,会发现弹出了borrow,直接点上。
这是因为我们在定义BookRelationship之前,我们已经定义了ReaderRelationship了,所以电脑已经知道了读者和书籍的关系,可以直接选上。而一旦选好了,那么在ReaderRelationship中,我们会发现Inverse一栏会自动补齐为borrowBy。因为电脑这时候已经完全理解了双方的关系,自动做了补齐。


一对一和一对多 - to one和to many


我们建立ReaderBook之间的联系的时候,发现他们的联系逻辑之间还漏了一个环节。
假设一本书被一个读者借走了,它就不能被另一个读者借走,而当一个读者借书时,却可以借很多本书。
也就是说,一本书只能对应一个读者,而一个读者却可以对应多本书。
这就是 一对一→to one 和 一对多→to many 。
Core Data允许我们配置这种联系,具体做法就是在RelationShip栏点击对应的关系栏,它将会出现在右侧的栏目中。(栏目如果没出现可以在xcode右上角的按钮调出,如果点击后栏目没出现Relationship配置项,可以多点击几下,这是xcode的小bug)。
Relationship的配置项里,有一项项名为Type,点击后有两个选项,一个是To One(默认值),另一个就是To Many了。


图3: 数据模型的关系配置


Core Data框架的主仓库 - NSPersistentContainer


当我们配置完Core Data的数据类型信息后,我们并没有产生任何数据,就好比图书馆已经制定了图书的规范 - 一本书应该有名字、isbm、页数等信息,规范虽然制定了,却没有真的引进书进来。
那么怎么才能产生和处理数据呢,这就需要通过代码真刀真枪的和Core Data打交道了。
由于Core Data的功能较为强大,必须分成多个类来处理各种逻辑,一次性学习多个类是不容易的,还容易混淆,所以后续我会分别一一列出。
要和这些各司其职的类打交道,我们不得不提第一个要介绍的类,叫NSPersistentContainer,因为它就是存放这多个类成员的“仓库类”。
这个NSPersistentContainer,就是我们通过代码和Core Data打交道的第一个目标。它存放着几种让我们和Core Data进行业务处理的工具,当我们拿到这些工具之后,就可以自由的访问数据了。所以它的名字 - Container 蕴含着的意思,就是 仓库、容器、集装箱。
进入正式的代码编写的第一步,我们先要在使用Core Data框架的swift文件开头引入这个框架:

import CoreData

早期,在iOS 10之前,还没有NSPersistentContainer这个类,所以Core Data提供的几种各司其职的工具,我们都要写代码一一获得,写出来的代码较为繁琐,所以NSPersistentContainer并不是一开始就有的,而是苹果框架设计者逐步优化出来的较优设计。


图4: NSPersistentContainer和其他成员的关系


NSPersistentContainer的初始化


在新建的UIKIT项目中,找到我们的AppDelegate类,写一个成员函数(即方法,后面我直接用函数这个术语替代):

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
}

这样,NSPersistentContainer类的建立就完成了,其中"Model"字符串就是我们建立的Model.xcdatamodeld文件。但是输入参数的时候,我们不需要(也不应该)输入.xcdatamodeld后缀。
当我们创建了NSPersistentContainer对象时,仅仅完成了基础的初始化,而对于一些性能开销较大的初始化,比如本地持久化资源的加载等,都还没有完成,我们必须调用NSPersistentContainer的成员函数loadPersistentStores来完成它。

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}
print("Load stores success")
}
}

从代码设计的角度看,为什么NSPersistentContainer不直接在构造函数里完成数据库的加载?这就涉及到一个面向对象的开发原则,即构造函数的初始化应该是(原则上)倾向于原子级别,即简单的、低开销内存操作,而对于性能开销大的,内存之外的存储空间处理(比如磁盘,网络),应尽量单独提供成员函数来完成。这样做是为了避免在构造函数中出错时错误难以捕捉的问题。


表格属性信息的提供者 - NSManagedObjectModel


现在我们已经持有并成功初始化了Core Data的仓库管理者NSPersistentContainer了,接下去我们可以使用向这个管理者索取信息了,我们已经在模型文件里存放了读者和书籍这两个Entity了,如何获取这两个Entity的信息?
这就需要用到NSPersistentContainer的成员,即managedObjectModel,该成员就是标题所说的NSManagedObjectModel类型。
为了讲解NSManagedObjectModel能提供什么,我通过以下函数来提供说明:

private func parseEntities(container: NSPersistentContainer) {
let entities = container.managedObjectModel.entities
print("Entity count = \(entities.count)\n")
for entity in entities {
print("Entity: \(entity.name!)")
for property in entity.properties {
print("Property: \(property.name)")
}
print("")
}
}

为了执行上面这个函数,需要修改createPersistentContainer,在里面调用parseEntities

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}

self.parseEntities(container: container)
}
}

在这个函数里,我们通过NSPersistentContainer获得了NSManagedObjectModel类型的成员managedObjectModel,并通过它获得了文件Model.xcdatamodeld中我们配置好的Entity信息,即图书和读者。
由于我们配置了两个Entity信息,所以运行正确的话,打印出来的第一行应该是Entity count = 2
container的成员managedObjectModel有一个成员叫entities,它是一个数组,这个数组成员的类型叫NSEntityDescription,这个类名一看就知道是专门用来处理Entity相关操作的,这里就没必要多赘述了。
示例代码里,获得了entity数组后,打印entity的数量,然后遍历数组,逐个获得entity实例,接着遍历entity实例的properties数组,该数组成员是由类型NSPropertyDescription的对象组成。
关于名词Property,不得不单独说明下,学习一门技术最烦人的事情之一就是理解各种名词,毕竟不同技术之间名词往往不一定统一,所以要单独理解一下。
Core Data的术语环境下,一个Entity由若干信息部分组成,之前已经提过的EntityRelationship就是了。而这些信息用术语统称为propertyNSPropertyDescription看名字就能知道,就是处理property用的。
只要将这一些知识点梳理清楚了,接下去打印的内容就不难懂了:

Entity count = 2

Entity: Book
Property: isbm
Property: name
Property: page
Property: borrowedBy

Entity: Reader
Property: idCard
Property: name
Property: borrow

我们看到,打印出来我们配置的图书有4个property,最后一个是borrowedBy,明显这是个Relationship,而前面三个都是Attribute,这和我刚刚对property的说明是一致的。

Entity对应的类

开篇我们就讲过,Core Data是一个 对象-关系映射 持久化方案,现在我们在Model.xcdatamodeld已经建立了两个Entity,那么如果在代码里要操作他们,是不是会有对应的类?
答案是确实如此,而且你还不需要自己去定义这个类。
如果你点击Model.xcdatamodeld编辑窗口中的Book这个Entity,打开右侧的属性面板,属性面板会给出允许你编辑的关于这个Entity的信息,其中Entity部分的Name就是我们起的名字Book,而下方还有一个Class栏,这一栏就是跟Entity绑定的类信息,栏目中的Name就是我们要定义的类名,默认它和Entity的名字相同,也就是说,类名也是Book。所以改与不改,看个人思路以及团队的规范。
所有Entity对应的类,都继承自NSManagedObject
为了检验这一点,我们可以在代码中编写这一行作为测试:

var book: Book! // 纯测验代码,无业务价值

如果写下这一行编译通过了,那说明开发环境已经给我们生成了Book这个类,不然它就不可能编译通过。
测试结果,完美编译通过。说明不需要我们自己编写,就可以直接使用这个类了。
关于类名,官方教程里一般会把类名更改为Entity名 + MO,比如我们这个Entity名为Book,那么如果是按照官方教程的做法,可以在面板中编辑Class的名字为BookMO,这里MO大概就是Model Object的简称吧。
但是我这里为简洁起见,就不做任何更改了,Entity名为Book,那么类名也一样为Book
另外,你也可以自己去定义Entity对应的类,这样有个好处是可以给类添加一些额外的功能支持,这部分Core Data提供了编写的规范,但是大部分时候这个做法反而会增加代码量,不属于常规操作。


数据业务的操作员 - NSManagedObjectContext


接下来我们要隆重介绍NSPersistentContainer麾下的一名工作任务最繁重的大将,成员viewContext,接下去我们和实际数据打交道,处理增删查改这四大操作,都要通过这个成员才能进行。
viewContext成员的类型是NSManagedObjectContext
NSManagedObjectContext,顾名思义,它的任务就是管理对象的上下文。从创建数据,对修改后数据的保存,删除数据,修改,五一不是以它为入口。
从介绍这个成员开始,我们就正式从 定义数据 的阶段,正式进入到 产生和操作数据 的阶段。


数据的插入 - NSentityDescription.insertNewObject


梳理完前面的知识,就可以正式踏入数据创建的学习了。
这里,我们先尝试创建一本图书,用一个createBook函数来进行。示例代码如下:

private func createBook(container: NSPersistentContainer,
name: String, isbm: String, pageCount: Int) {
let context = container.viewContext
let book = NSEntityDescription.insertNewObject(forEntityName: "Book",
into: context) as! Book
book.name = name
book.isbm = isbm
book.page = Int32(pageCount)
if context.hasChanges {
do {
try context.save()
print("Insert new book(\(name)) successful.")
} catch {
print("\(error)")
}
}
}

在这个代码里,最值得关注的部分就是NSEntityDescription的静态成员函数insertNewObject了,我们就是通过这个函数来进行所要插入数据的创建工作。
insertNewObject对应的参数forEntityName就是我们要输入的Entity名,这个名字当然必须是我们之前创建好的Entity有的名字才行,否则就出错了。因为我们要创建的是书,所以输入的名字就是Book
into参数就是我们的处理增删查改的大将NSManagedObjectContext类型。
insertNewObject返回的类型是NSManagedObject,如前所述,这是所有Entity对应类的父类。因为我们要创建的EntityBook,我们已经知道对应的类名是Book了,所以我们可以放心大胆的把它转换为Book类型。
接下来我们就可以对Book实例进行成员赋值,我们可以惊喜的发现Book类的成员都是我们在Entity表格中编辑好的,真是方便极了。
那么问题来了,当我们把Book编辑完成后,是不是这个数据就完成了持久化了,其实不是的。
这里要提一下Core Data的设计理念:懒原则。Core Data框架之下,任何原则操作都是内存级的操作,不会自动同步到磁盘或者其他媒介里,只有开发者主动发出存储命令,才会做出存储操作。这么做自然不是因为真的很懒,而是出于性能考虑。
为了真的把数据保存起来,首先我们通过context(即NSManagedObjectContext成员)的hasChanges成员询问是否数据有改动,如果有改动,就执行contextsave函数。(该函数是个会抛异常的函数,所以用do→catch包裹起来)。
至此,添加书本的操作代码就写完了。接下来我们把它放到合适的地方运行。
我们对createPersistentContainer稍作修改:

private func createPersistentContainer() {
let container = NSPersistentContainer(name: "Model")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error)")
}

//self.parseEntities(container: container)
self.createBook(container: container,
name: "算法(第4版)",
isbm: "9787115293800",
pageCount: 636)
}
}

运行项目,会看到如下打印输出:

Insert new book(算法(第4版)) successful.

至此,书本的插入工作顺利完成!

因为这个示例没有去重判定,如果程序运行两次,那么将会插入两条书名都为"算法(第4版)"的book记录。

数据的获取

有了前面基础知识的铺垫,接下去的例子只要 记函数 就成了,读取的示例代码:

private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {

}
}

处理数据处理依然是我们的数据操作主力context,而处理读取请求配置细节则是交给一个专门的类,NSFetchRequest来完成,因为我们处理读取数据有各种各样的类型,所以Core Data设计了一个泛型模式,你只要对NSFetchRequest传入对应的类型,比如Book,它就知道应该传回什么类型的对应数组,其结果是,我们可以通过Entity名为Book的请求直接拿到Book类型的数组,真是很方便。

打印结果:

Books count = 1
Book name = 算法(第4版)


数据获取的条件筛选 - NSPredicate


通过NSFetchRequest我们可以获取所有的数据,但是我们很多时候需要的是获得我们想要的特定的数据,通过条件筛选功能,可以实现获取出我们想要的数据,这时候需要用到NSFetchRequest的成员predicate来完成筛选,如下所示,我们要找书名叫 算法(第4版) 的书。
在新的代码示例里,我们在之前实现的readBooks函数代码里略作修改:

private func readBooks(container: NSPersistentContainer) {
let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")
do {
let books = try context.fetch(fetchBooks)
print("Books count = \(books.count)")
for book in books {
print("Book name = \(book.name!)")
}
} catch {
print("\(error)")
}
}

通过代码:

fetchBooks.predicate = NSPredicate(format: "name = \"算法(第4版)\"")

我们从书籍中筛选出书名为 算法(第4版) 的书,因为我们之前已经保存过这本书,所以可以正确筛选出来。
筛选方案还支持大小对比,如

fetchBooks.predicate = NSPredicate(format: "page > 100")

这样将筛选出page数量大于100的书籍。

数据的修改

当我们要修改数据时,比如说我们要把 isbm = "9787115293800" 这本书书名修改为 算法(第5版) ,可以按照如下代码示例:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
if !books.isEmpty {
books[0].name = "算法(第5版)"
if context.hasChanges {
try context.save()
print("Update success.")
}
}
} catch {
print("\(error)")
}

在这个例子里,我们遵循了 读取→修改→保存 的思路,先拿到筛选的书本,然后修改书本的名字,当名字被修改后,context将会知道数据被修改了,这时候判断数据是否被修改(实际上不需要判断我们也知道被修改了,只是出于编码规范加入了这个判断),如果被修改,就保存数据,通过这个方式,成功更改了书名。

数据的删除

数据的删除依然遵循 读取→修改→保存 的思路,找到我们想要的思路,并且删除它。删除的方法是通过contextdelete函数。
以下例子中,我们删除了所有 isbm="9787115293800" 的书籍:

let context = container.viewContext
let fetchBooks = NSFetchRequest<Book>(entityName: "Book")
fetchBooks.predicate = NSPredicate(format: "isbm = \"9787115293800\"")
do {
let books = try context.fetch(fetchBooks)
for book in books {
context.delete(books[0])
}
if context.hasChanges {
try context.save()
}
} catch {
print("\(error)")
}

扩展和进阶主题的介绍

如果跟我一步步走到这里,那么关于Core Data的基础知识可以说已经掌握的差不多了。当然了,这部分基础对于日常开发已经基本够用了。
关于Core Data开发的进阶部分,我在这里简单列举一下:
  1. Relationship部分的开发,事实上通过之前的知识可以独立完成。
  2. 回滚操作,相关类:UndoManager
  3. EntityFetched Property属性。
  4. 多个context一起操作数据的冲突问题。
  5. 持久化层的管理,包括迁移文件地址,设置多个存储源等。
以上诸个主题都可以自己进一步探索,不在这篇文章的讲解范围。不过后续不排除会单独出文探索。

结语

Core Data在圈内是比较出了名的“不好用”的框架,主要是因为其抽象的功能和机制较为不容易理解。本文已经以最大限度的努力试图从设计的角度去阐述该框架,希望对你有所帮助。

收起阅读 »

程序员的坏习惯

前言每位开发人员在自己的职业生涯、学习经历中,都会出一些坏习惯,本文将列举开发人员常犯的坏习惯。希望大家能够意识和改变这些坏习惯。不遵循项目规范每个公司都会定义一套代码规范、代码格式规范、提交规范等,但是有些开发人员就是不遵循相关的 规范,命名不规范、魔鬼数字...
继续阅读 »

前言

每位开发人员在自己的职业生涯、学习经历中,都会出一些坏习惯,本文将列举开发人员常犯的坏习惯。希望大家能够意识和改变这些坏习惯。


不遵循项目规范

每个公司都会定义一套代码规范、代码格式规范、提交规范等,但是有些开发人员就是不遵循相关的 规范,命名不规范、魔鬼数字、提交代码覆盖他人代码等问题经常发生,如果大家能够遵循相关规范,这些问题都可以避免。

用复杂SQL语句来解决问题

程序员在开发功能时,总想着是否能用一条SQL语句来完成这个功能,于是实现的SQL语句写的非常复杂,包含各种子查询嵌套,函数转换等。这样的SQL语句一旦出现了性能问题,很难进行相关优化。

缺少全局把控思维,只关注某一块业务

新增新功能只关注某一小块业务,不考虑系统整体的扩展性,其他模块已经有相关的实现了,却又重复实现,导致重复代码严重。修改功能不考虑对其他模块的影响。

函数复杂冗长,逻辑混乱

一个函数几百行,复杂函数不做拆分,导致代码变得越来月臃肿,最后谁也不敢动。函数还是要遵循设计模式的单一职责,一个函数只做一件事情。如果函数逻辑确实复杂,需要进行拆分,保证逻辑清晰。

缺乏主动思考,拿来主义

实现相关功能,先网上百度一下,拷贝相关的代码,能够运行成功认为万事大吉。到了生产却出现了各种各样的问题,因为网上的demo程序和实际项目的在场景使用上有区别,尤其是相关的参数配置,一定要弄清楚具体的含义,不同场景下,设置参数的值不同。

核心业务逻辑,缺少相关日志和注释

很多核心的业务逻辑实现,整个方法几乎没看到相关注释和日志打印,除了自己能看懂代码逻辑,其他人根本看不懂。一旦生产出了问题,找不到有效的日志输出,问题根本无法定位。

修改代码,缺少必要测试

很多人都会存在侥幸心里,认为只是改了一个变量或者只修改一行代码,不用自测了应该没有问题,殊不知就是因为改一行代码导致了严重的bug。所以修改代码一定要进行自测。

需求没理清,直接写代码

很多程序员在接到需求后,不怎么思考就开始写代码,写着写着发现自己的理解与实际的需求有偏差,造成无意义返工。所以需要多花些时间梳理需求,整理相关思路,能规避很多不合理的问题。

讨论问题,表达没有逻辑、没有重点

讨论问题不交代背景,上来就说自己的方案,别人听得云里雾里,让你从头描述你又讲不明。需要学会沟通和表达,才能进行有效的沟通和合作。

不能从错误中吸取教训

作为一位开发人员,你会犯很多错误,这不可避免也没什么大不了的。但如果你总是犯同样的错误,不能从中吸取教训,那态度就出现问题了。

总结

关于这些坏习惯,你是否中招了,大家应该尽早规避这些坏习惯,成为一名优秀的程序员。


作者:剑圣无痕
来源:juejin.cn/post/7136455796979662862

收起阅读 »

移动端页面秒开优化总结

前言  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升。...
继续阅读 »

前言

  App优化,是一个工作、面试或KPI都绕不开的话题,如何让用户使用流畅呢?今天谨以此篇文章总结一下过去两个月我在工作中的优化事项到底有那些,优化方面还算小白,有不对的地方还望指出海涵, 该文章主要通过讲述Native跳转到Flutter界面秒开率提升

问题分析

  当你拿到反馈App页面渲染时间长的工单的时候,第一步想到的不应该是有那些那些方法可以降低耗时,我们应该根据自己的真实业务触发,第一步 验证 通过打点或者工具去验证这个问题,了解 一个页面打开耗时的统计方式分析一个打开耗时是由那些方面组成,通过那些技术手段去解决80%的问题,抓大放小去处理问题。

  通过工具分析启动链路耗时,发现部分必要接口RT时间较长,Flutter引擎冷启耗时较长和View渲染耗时为主要耗时项。接下来就围绕着三个大方面去做一些优化。

网络优化

   以Android 界面跳转链路来说 ,具体链路看下图(模拟数据 主要明白思想)


   看到串行,就知道这里肯定可以有文章做


  可以看到在网络请求可以提前到 Router环节去解析并进行预加载,并行的话可以优化 必要接口RT的时长,节省的时间在页面秒开链路中占比最多。

  在这里需要兼容网络返回较慢的情况,我们可以引入骨架图,提升上屏率。

数据预请求

Router和请求

  通过拦截路由地址,判断路径是否属于预请求白名单。如果匹配,进入预请求逻辑,发起网络拼接和请求,在获取到结果进行本地缓存,供消费界面去消费。因为考虑到网络返回如果慢与界面,可以提供回调,消费界面进来进行绑定。

端侧通讯

   由于Native 跳转到 Flutter ,所以这里需要借助 Channel来进行管道传递,这里我们没有使用MethodChannel 而是选择 可以Native主动通知Flutter 的EventChannel来接收消息。

public class EventChannelManager implements IFlutterProphetPlugin {
   private static Map<String, EventChannel.EventSink> cachedEventSinkMap = new HashMap<>();
   private static LinkedList<Object> dataList = new LinkedList<>();

   public final static String CHANNEL_REQUEST_PRE = "event_channel";

   private static EventChannelManager instance;

   public static EventChannelManager getInstance() {
       if (null == instance) {
           instance = new EventChannelManager();
      }
       return instance;
  }

   @Creator
   public static IFlutterProphetPlugin create() {
       return new EventChannelManager();
  }

   //初始化
   @Override
   public void initChannel(FlutterEngine engine) {
       try {
           EventChannel eventChannel_pre = new EventChannel(engine.getDartExecutor(), CHANNEL_REQUEST_PRE);
           eventChannel_pre.setStreamHandler(new ProphetStreamHandler(CHANNEL_REQUEST_PRE));
      } catch (Exception ex) {
           Log.e(TAG, "init channel err :" + ex.getMessage());
      }
  }

   //发送消息
   @Override
   public void sendEventToStream(String eventChannel, Object data) {
       synchronized (this) {
           try {
               EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
               if (null != eventSink) {
                   eventSink.success(data);
              } else {
                   dataList.add(data);
              }
          } catch (Exception ex) {
          }
      }
  }

   //关闭
   public void cancel(String eventChannel) {
       EventChannel.EventSink eventSink = cachedEventSinkMap.get(eventChannel);
       if (null != eventSink) {
           eventSink.endOfStream();
      }
  }

   public static class ProphetStreamHandler implements EventChannel.StreamHandler {
       private String eventChannel;

       public ProphetStreamHandler(String eventChannel) {
           this.eventChannel = eventChannel;
      }

       @Override
       public void onListen(Object arguments, EventChannel.EventSink events) {
           cachedEventSinkMap.put(eventChannel, events);
           if (dataList.size() != 0) {
               for (Object obj : dataList) {
                   events.success(obj);
              }
               dataList.clear();
          }
      }

       @Override
       public void onCancel(Object arguments) {
           cachedEventSinkMap.remove(eventChannel);
      }
  }

}

上述代码为通用EventChannel创建和发送消息工具类,接口不贴了....

缓存

  预请求模块中,如果网络请求结果成功,可以将结果写入缓存SDK中(可以根据缓存SDK策略,内存和磁盘缓存都做好处理)。结合缓存策略,再次进入界面即可先读取缓存数据上屏,通过顶部Load状态提醒用户 预请求的数据正在加载中,来缩短秒开时间。

端智能

  通过大数据和算法对用户习惯性的使用链路进行分析,判断用户下一个节点将会进入哪个界面,匹配到预请求白名单,也可以更早的进行预请求逻辑 (没有集团SDK支撑的话可以不列为主要优化方式)。


数据后带

  以自己维护的App来说,首屏商品列表会返回很多数据包括但不限于:商品Url、商品名称、价格等核心信息,在进入商品详情中,我们通常会把商品id发送到详情界面,并再次进行商品详情接口的请求,那么我们可以通过数据后带的方式,先让详情页核心数据显示出来,然后通过局部骨架图来等待详情信息的返回,感官上缩短界面等待时长。

数据延后

  首屏中还会有很多二级弹窗列表数据接口的请求,其实这里的接口可以通过延后的方式来加载并渲染出来,减少首屏刚开始的CPU使用,为核心View渲染让步,减少CPU竞争。

业务逻辑优化

  部分不重要接口除了可以延后处理外,还可以通过推动后端合理缩小数据结构,减少不必要的网络消耗产生。对于部分小量接口,可以通过搭车的方式 进行接口合并 一块返回,部分数据可能不需要实时更新的,可以减少不必要请求来进行优化。

布局优化

异步加载

  假设场景是搜索结果列表,我们可以在数据请求前置的同时,去异步 inflate 一些 recyclerview 的 itemview,渲染阶段就可以节约 createViewHolder 的时间。(这里只是进行一个场景举例,更多的使用方法和业务强耦合,需要自行分析和合理设计避免负向优化)

递进加载

  顾名思义,其实递进加载和数据延后请求原理相似,每个界面可能都会有重要View,以商品列表为例,我可能更希望商品列表数据先返回回来,其他的接口可以延后,提升界面渲染速度。

作者:小肥羊冲冲冲Android
来源:juejin.cn/post/7121636526596816933

收起阅读 »

如何让一套代码完美适配各种屏幕?

一、适配的目的区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。2021市场移动设备分辨率统计可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码...
继续阅读 »


一、适配的目的

区别于iOS,android设备有不同的分辨率大小以及不同厂商的系统,目前市场的分辨率可以看下相关统计。

2021市场移动设备分辨率统计

可以看到主流的分辨率有10多种,当不做适配时,一套代码在不同设备上的效果偏大、偏小、截断以及留白严重,那一套代码如何完美的展示在不同的设备上,可以看下面的一些适配方案。

二、UI适配

2.1、常见的适配方式

2.1.1、xml布局控件适配

  1. 避免写死View的宽高,尽量使用warp_content和match_parent;

  2. 父布局为LinearLayout,选择使用android:layout_weight属性,为布局中的每个子View设置权重;

  3. 父布局为RelativeLayout,可以选择使用layout_centerInParent等属性,设置子View的相对位置;

  4. 谷歌官方在之前版本中提供了一个百分比布局方式:support:percent,它支持RelativeLayout和FrameLayout的百分比布局,但是目前官方已经不再维护,而将他取而代之的是新晋布局:ConstraintLayout,ConstraintLayout强大之处不仅在于它能够进行百分比布局,还可以进行相对定位、角度定位、尺寸约束、宽高比、Chainl链布局等,在不同设备间都能处理的游刃有余。

2.1.2、图片适配

  1. .9图
    .9.png图片本质上还是png图片,相对于普通png图来说,.9图可以让图片在指定的位置拉伸和在指定的位置显示内容且不会失真;

  2. 见2.1.4分辨率限定符;

2.1.3、依据产品设计适配

所谓产品设计适配,指的是产品流程在不同设备上有不同的展示方式,例如手机与Pad的区别,在手机设备上,一般来说具体Item列表是一个页面,点击每个Item会跳转至新的详情页;而在宽度>高度的Pad上,为了防止页面空白浪费,一般会要求屏幕左侧为Item列表,右侧即详情页,item与详情页会同时出现在用户的视觉内,如下图


关于这种类型的设计,其实郭霖《第一行代码》给出了一个方案,我在这里抛砖引玉一下,给出基本思路。

这种情况下,适配的核心在于利用android动态加载布局的机制,使得程序能够根据分辨率或者屏幕大小在运行时动态加载不同的布局,而动态加载就需要使用到限定符

  • 限定符 所谓限定符,指的是给res目录中的子目录加上“-限定符”,可以给不同设备提供不同的资源以及布局,如下图,layout添加-large,-small。


layout-small:指的是提供给小屏幕设备的资源;
layout-large:指的是提供给大屏幕设备的资源;
layout/layout-normal:指的是提供给中等屏幕设备的资源,也就是默认状态;
layout-xlarge:值得是提供给超大屏幕设备的资源;

在上面所提出的情景下,Pad即指的大屏幕,手机一般可看作为中等屏幕设备,为了在大屏幕下显示双页模式,我们可以在layout-large和layout目录下新建同一个name的布局xml,在layout-large下的xml针对Pad做双页处理,即左半边View+右半边View样式,layout目录下xml还是做普通处理。

在最后项目运行时,会根据不同设备来加载不同目录下的xml资源,即Pad会加载layout-large目录下的xml,普通手机设备会加载layout目录下的xml资源。

从而实现一套代码在不同设备上产品逻辑。

限定符可以大范围的区分设备,但是你还是不知道-large代表是多大的设备,-small代表的是多小的设备,如果需要清楚的区分各个屏幕的大小,那就需要用到最小宽度限定符。

  • 最小宽度限定符(Smallest-width Qualifier),简称SW 最小宽度限定符指的是,对屏幕的宽度设立一个最小的值(dp),当当前设备屏幕宽度大于这个值就加载一个布局,


例如在res下新建一个layout-sw720dp的文件夹,当屏幕宽度大于720dp时,项目就会加载layout-sw720dp/***.xml 资源文件。

2.1.4、限定符适配

在2.1.3中提到了限定符的概念,也解决了一部分的设计适配问题,但是还有一些限定符的概念没有涉及到,该目录下将会提到不同的限定符的概念,可以结合2.1.3一起食用。

  • 分辨率限定符 在Android项目中,会把放置图片资源的文件夹分为drawable-hdpi、xhdpi xxhdpi xxxhdpi等,这些指的就是分辨率限定符。

Andriod系统会根据手机屏幕的大小及屏幕密度去选择不同文件夹下的图片资源,以此来实现在不同大小不同屏幕分辨率下适配的问题。

这里提一点AS对图片资源的匹配规则:

举个例子,当当前的设备密度为xhdpi,此时代码中ImageView需要去引用drawable中的图片,那么根据匹配规则,系统首先会在drawable-xhdpi文件夹中去搜索,如果需要的图片存在,那么直接显示;如果不存在,那么系统将会开始从更高dpi中搜索,例如drawable-xxhdpi,drawable-xxxhdpi,如果在高dpi中搜索不到需要的图片,那么就会去drawable-nodpi中搜索,有则显示,无则继续向低dpi,如drawable-hdpi,drawable-mdpi,drawable-ldpi等文件夹一级一级搜索.

当在比当前设备密度低的文件夹中搜到图片,那么在ImageView(宽高在wrap_content状态下)中显示的图片将会被放大.图片放大也就意味着所占内存也开始增多.这也就是为什么分辨率不高的图片随意放置在drawable中也会出现OOM,而在高密度文件夹中搜到图片,图片在该设备上将会被缩小,内存也就相应减少。

在理想的状态下,不同dpi的文件下应该放置相应dpi的图片资源,以对不同的设备进行适配。

  • 尺寸限定符和最小宽度限定符 见2.1.3

  • 屏幕方向限定符 屏幕方向限定符即“-land”、“-port”,分别代表横排和竖屏。

手机会存在横竖屏切换的场景,当设备横屏时,会主动加载layout-land/目录下的资源文件,当设备为竖屏时,则加载layout-port目录下的资源文件。

2.2、今日头条适配方式

在开始今日头条的适配方案之前,需要提及px、dpi、density的概念。

px:即像素,我们常看到的480 * 800 、720 * 1280、1080 * 1920指的就是像素值宽高的意思;

dpi:即densityDpi,每英寸中的像素数;

density:屏幕密度,density = dpi / 160;

scaledDensity:字体的缩放因子,正常情况下和density相等,但是调节系统字体大小后会改变这个值

android中的dp在渲染前会将dp转为px,计算公式:

  • px = density * dp

从dp和px的转换公式 :px = dp * density 可以看出,如果设计图宽为360dp,想要保证在所有设备计算得出的px值都正好是屏幕宽度的话,我们只能修改 density 的值。这就是该方案的核心。

那如何修改系统的density?

可以通过DisplayMetrics获取系统density和scaledDensity值,

val displayMetrics = application.resources.displayMetrics

val density = displayMetrics.density
val scaledDensity = displayMetrics.scaledDensity
复制代码

设配的目的在于使用一套设计稿,能完好的展示在不同设备上,所以UI需要确定一个固定的尺寸,依据density=px / dp的公式,确定density的值,其中px指的是真实设备的值, 这里我们以设计稿的宽度作为一个纬度进行测算。

举个例子,如设计稿中固定宽度为360dp,当前设备的屏幕宽度为720,那么density = 720 / 360 = 2,其中当前设备的屏幕宽度也可以用DisplayMetrics来获取:

val targetDensity = displayMetrics.widthPixels / 360
复制代码

整体思路

//0.获取当前app的屏幕显示信息
val displayMetrics = application.resources.displayMetrics
if (appDensity == 0f) {
   //1.初始化赋值操作 获取app初始density和scaledDensity
   appDensity = displayMetrics.density
   appScaleDensity = displayMetrics.scaledDensity
}

/*
2.计算目标值density, scaleDensity, densityDpi
targetDensity为当前设备的宽度/设计稿固定的宽度
targetScaleDensity:目标字体缩放Density,等比例测算
targetDensityDpi:density = dpi / 160 即dpi = density * 160
*/
val targetDensity = displayMetrics.widthPixels / WIDTH
val targetScaleDensity = targetDensity * (appScaleDensity / appDensity)
val targetDensityDpi = (targetDensity * 160).toInt()

//3.替换Activity的density, scaleDensity, densityDpi
val dm = activity.resources.displayMetrics
dm.density = targetDensity
dm.scaledDensity = targetScaleDensity
dm.densityDpi = targetDensityDpi
复制代码

三、刘海屏适配



  • 有状态栏的界面:刘海区域会显示状态栏,无需适配;

  • 全屏界面:刘海区域可能遮挡内容,需要适配;

针对刘海屏适配,在Android P以上,谷歌官方给出了适配方案,可参考developer.android.google.cn/guide/topic… ,所以在 targetApi >= 28 上可以使用谷歌官方推荐的适配方案进行刘海屏适配。 而在Android O的设备上,如华为、小米、oppo等厂商给出了适配方案。

3.1、Android9.0官方适配

将内容呈现到刘海区域中,则可以使用 WindowInsets.getDisplayCutout() 来检索 DisplayCutout 对象,同时可以使用窗口布局属性 layoutInDisplayCutoutMode 控制内容如何呈现在刘海区域中。

layoutInDisplayCutoutMode

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT :在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:内容从不呈现到刘海区域中。

/**
* @param mode 刘海屏下内容显示模式,针对Android9.0
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT = 0; //在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER = 2;//不允许内容延伸进刘海区
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES = 1;//在竖屏模式和横屏模式下,内容都会呈现到刘海区域中
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun setDisplayCutoutMode(mode: Int) {
   window.attributes.apply {
       this.layoutInDisplayCutoutMode = mode
       window.attributes = this
  }

}
复制代码

判断是否当前设备是否有刘海:

/**
* 判断当前设备是否有刘海
*/
@RequiresApi(Build.VERSION_CODES.P)
private fun hasCutout(): Boolean {
   window.decorView.rootWindowInsets?.let {
       it.displayCutout?.let {
           if (it.boundingRects.size > 0 && it.safeInsetTop > 0) {
               return true
          }
      }
  }
   return false
}
复制代码

在activity的 setContentView(R.layout.activity_main)之前设置layoutInDisplayCutoutMode。

LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES



3.2、各大厂商适配方案(华为、小米、oppo等)

除了在AndroidP系统下官方给了适配方案,各大厂商针对自家系统也给出了相应的适配方案,可参考:

oppo
vivo
小米
华为

参考文档
今日头条适配方案
Android9.0官方适配方案

作者:付十一

来源:juejin.cn/post/7117630529595244558

收起阅读 »

前端按钮/组件权限管理

最近项目中遇到了按钮权限管理的需求,整理了一下目前的方案,有不对的地方望大家指出~方案1:数组+自定义指令把权限放到数组中,通过vue的自定义指令来判断是否拥有该权限,有则显示,反之则不显示我们可以把这个按钮需要的权限放到组件上<el-button v...
继续阅读 »

最近项目中遇到了按钮权限管理的需求,整理了一下目前的方案,有不对的地方望大家指出~

方案1:数组+自定义指令

把权限放到数组中,通过vue的自定义指令来判断是否拥有该权限,有则显示,反之则不显示

我们可以把这个按钮需要的权限放到组件上

<el-button
v-hasPermi="['home:advertising:update']"
>新建</el-button>

自定义指令:

逻辑就是我们在登陆后会获取该用户的权限,并存储到localStorage中,当一个按钮展示时会判断localStorage存储的权限列表中是否存在该按钮所需的权限。

/**
* 权限处理
*/

export default {
 inserted(el, binding, vnode) {
   const { value } = binding;
   const SuperPermission = "superAdmin"; // 超级用户,用于开发和测试
   const permissions = localStorage.getItem('userPermissions')&& localStorage.getItem('userPermissions').split(',');
// 判断传入的组件权限是否符合要求
   if (value && value instanceof Array && value.length > 0) {
     const permissionFlag = value;
     const hasPermissions = permissions && permissions.some(permission => all_permission === permission || permissionFlag.includes(permission));
// 判断是否有权限是否要展示
     if (!hasPermissions) {
       el.parentNode && el.parentNode.removeChild(el);
    }
  } else {
     throw new Error(`请设置操作权限标签值`);
  }
},
};

注册权限

import Vue from 'vue';
import Vpermission from "./permission";

// 按钮权限 自定义指令
Vue.directive('permission', Vpermission);

关于路由权限

数组的方案也可以用到菜单权限上,可以在路由的meta中携带该路由所需的权限,例如:

const router = [{
 path: 'needPermissionPage',
 name: 'NeedPermissionPage',
 meta: {
   role: ['permissionA', 'permissionB'],
},
}]

这个时候就需要在渲染权限的时候动态渲染了,该方案可以看一下其他的文章或成熟的项目,写的非常好

方案2: 二进制

通过二进制来控制权限:

假设我们有增删改查四个基本权限:

const UPDATE = 0b000001;
const DELETE = 0b000010;
const ADD = 0b000100;
const SEARCH = 0b001000;

每一位代表是否有该权限,有该权限则是1,反之是0

表达权限:

我们可以使用或运算来表达一个权限结果,或运算:两个任何一个为1,结果就为1

const reslut = UPDATE | DELETE | SEARCH;
console.log(reslut);  // 11

变成了十进制,我们可以通过toString方法变为二进制结果

const reslut = UPDATE | DELETE | SEARCH;
console.log(reslut.toString(2));  // 1011

result 这个结果就代表我们既拥有更新权限,同时也拥有删除和查询的权限

那么我们可以将十进制的reslut当作该用户的权限,把这个结果给后台,下次用户登陆后只需要返回这个结果就可以了。

权限判断

我们了解了如何表达一个权限,那如何做权限的判断呢?

可以通过且运算,且运算:两位都为1,这一位的结果才是1。

还是用上面的结果,当我们从接口中拿到了reslut,判断他是否有 DELETE 权限:

console.log((reslut & DELETE) === DELETE);  // true

是否有新增的权限

console.log((result & ADD) === ADD); // false

判断和使用

/**
* 接受该组件所需的权限,返回用户权限列表是否有该权限
* @param {String} permission
* @returns {Boolean}
*/
function hasPermission(permission) {
 const permissionList = {
   UPDATE: 0b000001,
   DELETE: 0b000010,
   CREATE: 0b000100,
   SEARCH: 0b001000
}
 let btnPermission = permissionList[permission] ? permissionList[permission] : -1;
 if (btnPermission === -1) return false;
 const userPermission = localStorage.getItem('userPermissions');
// 将本地十进制的值转换为二进制
 const userPermissionBinary = userPermission.toString(2);
// 对比组件所需权限和本地存储的权限
 return (userPermissionBinary & btnPermission) === btnPermission;
}

直接在组件中通过v-show/v-if来控制是否展示

<el-button v-show="hasPermission('UPDATE')">更新</el-button>

小结

我理解来说,对于方案1来说,方案2的优势在于更简洁,后台仅需要存储一个十进制的值,但如果后期新增需求更新了新的权限,可能需要调整二进制的位数来满足业务需求。方案1的优势在于更加易懂,新增权限时仅需要更新组件自定义指令的数组。

原文:https://juejin.cn/post/7142778249171435551





收起阅读 »

毕业5年了还不知道热修复?

前言 热修复到现在2022年已经不是一个新名词,但是作为Android开发核心技术栈的一部分,我这里还得来一次冷饭热炒。 随着移动端业务复杂程度的增加,传统的版本更新流程显然无法满足业务和开发者的需求, 热修复技术的推出在很大程度上改善了这一局面。国内大部分成...
继续阅读 »

前言


热修复到现在2022年已经不是一个新名词,但是作为Android开发核心技术栈的一部分,我这里还得来一次冷饭热炒。


随着移动端业务复杂程度的增加,传统的版本更新流程显然无法满足业务和开发者的需求,
热修复技术的推出在很大程度上改善了这一局面。国内大部分成熟的主流 App都拥有自己的热更新技术,像手淘、支付宝、微信、QQ、饿了么、美团等。


可以说,一个好的热修复技术,将为你的 App助力百倍。对于每一个想在 Android 开发领域有所造诣的开发者,掌握热修复技术更是必备的素质


热修复是 Android 大厂面试中高频面试知识点,也是我们必须要掌握的知识点。热修复技术,可以看作 Android平台发展成熟至一定阶段的必然产物。
Android热修复了解吗?修复哪些东西?
常见热修复框架对比以及各原理分析?


1.什么是热修复


热修复说白了就是不再使用传统的应用商店更新或者自更新方式,使用补丁包推送的方式在用户无感知的情况下,修复应用bug或者推送新的需求


传统更新热更新过程对比如下:


热修复过程图.jpg


热修复优缺点:



  • 优点:

    • 1.只需要打补丁包,不需要重新发版本。

    • 2.用户无感知,不需要重新下载最新应用

    • 3.修复成功率高



  • 缺点

    • 补丁包滥用,容易导致应用版本不可控,需要开发一套完整的补丁包更新机制,会增加一定的成本




2.热修复方案


首先我们得知道热修复修复哪些东西



  • 1.代码修复

  • 2.资源修复

  • 3.动态库修复


2.1:代码修复方案


从技术角度来说,我们的目的是非常明确的:把错误的代码替换成正确的代码。
注意这里的替换,并不是直接擦写dx文件,而是提供一份新的正确代码,让应用运行时绕过错误代码,执行新的正确代码。


热修复方法过程.png


想法简单直接,但实现起来并不容易。目前主要有三类技术方案:


2.1.1.类加载方案


之前分析类加载机制有说过:
加载流程先是遵循双亲委派原则,如果委派原则没有找到此前加载过此类,
则会调用CLassLoader的findClass方法,再去BaseDexClassLoader下面的dexElements数组中查找,如果没有找到,最终调用defineClassNative方法加载


代码修复就是基于这点:
将新的做了修复的dex文件,通过反射注入到BaseDexClassLoader的dexElements数组的第一个位置上dexElements[0],下次重新启动应用加载类的时候,会优先加载做了修复的dex文件,这样就达到了修复代码的目的。原理很简单


代码如下:


public class Hotfix {

public static void patch(Context context, String patchDexFile, String patchClassName)
throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
//获取系统PathClassLoader的"dexElements"属性值
PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
Object origDexElements = getDexElements(pathClassLoader);

//新建DexClassLoader并获取“dexElements”属性值
String otpDir = context.getDir("dex", 0).getAbsolutePath();
Log.i("hotfix", "otpdir=" + otpDir);
DexClassLoader nDexClassLoader = new DexClassLoader(patchDexFile, otpDir, patchDexFile, context.getClassLoader());
Object patchDexElements = getDexElements(nDexClassLoader);

//将patchDexElements插入原origDexElements前面
Object allDexElements = combineArray(origDexElements, patchDexElements);

//将新的allDexElements重新设置回pathClassLoader
setDexElements(pathClassLoader, allDexElements);

//重新加载类
pathClassLoader.loadClass(patchClassName);
}
private static Object getDexElements(ClassLoader classLoader) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);

//然后获取“pathList”实例的“dexElements”属性
Field dexElementField = pathList.getClass().getDeclaredField("dexElements");
dexElementField.setAccessible(true);

//读取"dexElements"的值
Object elements = dexElementField.get(pathList);
return elements;
}
//合拼dexElements
private static Object combineArray(Object obj, Object obj2) {
Class componentType = obj2.getClass().getComponentType();
//读取obj长度
int length = Array.getLength(obj);
//读取obj2长度
int length2 = Array.getLength(obj2);
Log.i("hotfix", "length=" + length + ",length2=" + length2);
//创建一个新Array实例,长度为ojb和obj2之和
Object newInstance = Array.newInstance(componentType, length + length2);
for (int i = 0; i < length + length2; i++) {
//把obj2元素插入前面
if (i < length2) {
Array.set(newInstance, i, Array.get(obj2, i));
} else {
//把obj元素依次放在后面
Array.set(newInstance, i, Array.get(obj, i - length2));
}
}
//返回新的Array实例
return newInstance;
}
private static void setDexElements(ClassLoader classLoader, Object dexElements) throws ClassNotFoundException, NoSuchFieldException, IllegalAccessException {
//首先获取ClassLoader的“pathList”实例
Field pathListField = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListField.setAccessible(true);//设置为可访问
Object pathList = pathListField.get(classLoader);

//然后获取“pathList”实例的“dexElements”属性
Field declaredField = pathList.getClass().getDeclaredField("dexElements");
declaredField.setAccessible(true);

//设置"dexElements"的值
declaredField.set(pathList, dexElements);
}
}

类加载过程如下:


findclass.png
微信Tinker,QQ 空间的超级补丁、手 QQ 的QFix 、饿了 么的 AmigoNuwa 等都是使用这个方式


缺点:因为类加载后无法卸载,所以类加载方案必须重启App,让bug类重新加载后才能生效。


2.1.2:底层替换方案


底层替换方案不会再次加载新类,而是直接在 Native 层 修改原有类
这里我们需要提到Art虚拟机中ArtMethod
每一个Java方法在Art虚拟机中都对应着一个 ArtMethodArtMethod记录了这个Java方法的所有信息,包括所属类、访问权限、代码执行地址等


结构如下:


// art/runtime/art_method.h
class ArtMethod FINAL {
...
protected:
GcRoot<mirror::Class> declaring_class_;
GcRoot<mirror::PointerArray> dex_cache_resolved_methods_;
GcRoot<mirror::ObjectArray<mirror::Class>> dex_cache_resolved_types_;
uint32_t access_flags_;
uint32_t dex_code_item_offset_;
uint32_t dex_method_index_;
uint32_t method_index_;

struct PACKED(4) PtrSizedFields {
void* entry_point_from_interpreter_; // 1
void* entry_point_from_jni_;
void* entry_point_from_quick_compiled_code_; //2
} ptr_sized_fields_;
...
}

在 ArtMethod结构体中,最重要的就是 注释1和注释2标注的内容,从名字可以看出来,他们就是方法的执行入口。
我们知道,Java代码在Android中会被编译为 Dex Code


Art虚拟机中可以采用解释模式或者 AOT机器码模式执行 Dex Code



  • 解释模式:
    就是去除Dex Code,逐条解释执行。
    如果方法的调用者是以解释模式运行的,在调用这个方法时,就会获取这个方法的 entry_point_from_interpreter_,然后跳转执行。

  • AOT模式:
    就会预先编译好 Dex Code对应的机器码,然后在运行期直接执行机器码,不需要逐条解释执行Dex Code。
    如果方法的调用者是以AOT机器码方式执行的,在调用这个方法时,就是跳转到 entry_point_from_quick_compiled_code_中执行。



那是不是只需要替换这个几个 entry_point_* 入口地址就能够实现方法替换了呢?
并没有那么简单,因为不论是解释模式还是AOT模式,在运行期间还会需要调用ArtMethod中的其他成员字段



AndFix采用的是改变指针指向


// AndFix/jni/art/art_method_replace_6_0.cpp
void replace_6_0(JNIEnv* env, jobject src, jobject dest) {
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src); // 1

art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest); // 2
...
// 3
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->dex_cache_resolved_methods_ = dmeth->dex_cache_resolved_methods_;
smeth->dex_cache_resolved_types_ = dmeth->dex_cache_resolved_types_;
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;

smeth->ptr_sized_fields_.entry_point_from_interpreter_ =
dmeth->ptr_sized_fields_.entry_point_from_interpreter_;

smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;

LOGD("replace_6_0: %d , %d",
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_,
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_);
}

缺点:存在一些兼容性问题,由于ArtMethod结构体是Android开源的一部分,所以每个手机厂商都可能会去更改这部分的内容,这就可能导致ArtMethod替换方案在某些机型上面出现未知错误。


Sophix为了规避上面的AndFix的风险,采用直接替换整个结构体。这样不管手机厂商如何更改系统,我们都可以正确定位到方法地址


2.4.3:install run方案


Instant Run 方案的核心思想是——插桩在编译时通过插桩在每一个方法中插入代码,修改代码逻辑,在需要时绕过错误方法,调用patch类的正确方法。


首先,在编译时Instant Run为每个类插入IncrementalChange变量


IncrementalChange  $change;

为每一个方法添加类似如下代码:


public void onCreate(Bundle savedInstanceState) {
IncrementalChange var2 = $change;
//$change不为null,表示该类有修改,需要重定向
if(var2 != null) {
//通过access$dispatch方法跳转到patch类的正确方法
var2.access$dispatch("onCreate.(Landroid/os/Bundle;)V", new Object[]{this, savedInstanceState});
} else {
super.onCreate(savedInstanceState);
this.setContentView(2130968601);
this.tv = (TextView)this.findViewById(2131492944);
}
}

如上代码,当一个类被修改后,Instant Run会为这个类新建一个类,命名为xxx&override,且实现IncrementalChange接口,并且赋值给原类的$change变量。


public class MainActivity$override implements IncrementalChange {


此时,在运行时原类中每个方法的var2 != null,通过accessdispatch(参数是方法名和原参数)定位到patch类MainActivitydispatch(参数是方法名和原参数)定位到patch类MainActivityoverride中修改后的方法。


Instant Run是google在AS2.0时用来实现“热部署”的,同时也为“热修复”提供了一个绝佳的思路。美团的Robust就是基于此


2.2:资源修复方案


这里我们来看看install run的原理即可,市面上的常见修复方案大部分都是基于此方法。


public static void monkeyPatchExistingResources(Context context,
String externalResourceFile, Collection<Activity> activities) {
if (externalResourceFile == null) {
return;
}
try {
// 创建一个新的AssetManager
AssetManager newAssetManager = (AssetManager) AssetManager.class
.getConstructor(new Class[0]).newInstance(new Object[0]); // ... 1
Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
"addAssetPath", new Class[] { String.class }); // ... 2
mAddAssetPath.setAccessible(true);
// 通过反射调用addAssetPath方法加载外部的资源(SD卡资源)
if (((Integer) mAddAssetPath.invoke(newAssetManager,
new Object[] { externalResourceFile })).intValue() == 0) { // ... 3
throw new IllegalStateException(
"Could not create new AssetManager");
}
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
"ensureStringBlocks", new Class[0]);
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources(); // ... 4
try {
// 反射得到Resources的AssetManager类型的mAssets字段
Field mAssets = Resources.class
.getDeclaredField("mAssets"); // ... 5
mAssets.setAccessible(true);
// 将mAssets字段的引用替换为新创建的newAssetManager
mAssets.set(resources, newAssetManager); // ... 6
} catch (Throwable ignore) {
...
}

// 得到Activity的Resources.Theme
Resources.Theme theme = activity.getTheme();
try {
try {
// 反射得到Resources.Theme的mAssets字段
Field ma = Resources.Theme.class
.getDeclaredField("mAssets");
ma.setAccessible(true);
// 将Resources.Theme的mAssets字段的引用替换为新创建的newAssetManager
ma.set(theme, newAssetManager); // ... 7
} catch (NoSuchFieldException ignore) {
...
}
...
} catch (Throwable e) {
Log.e("InstantRun",
"Failed to update existing theme for activity "
+ activity, e);
}
pruneResourceCaches(resources);
}
}
/**
* 根据SDK版本的不同,用不同的方式得到Resources 的弱引用集合
*/
Collection<WeakReference<Resources>> references;
if (Build.VERSION.SDK_INT >= 19) {
Class<?> resourcesManagerClass = Class
.forName("android.app.ResourcesManager");
Method mGetInstance = resourcesManagerClass.getDeclaredMethod(
"getInstance", new Class[0]);
mGetInstance.setAccessible(true);
Object resourcesManager = mGetInstance.invoke(null,
new Object[0]);
try {
Field fMActiveResources = resourcesManagerClass
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);

ArrayMap<?, WeakReference<Resources>> arrayMap = (ArrayMap) fMActiveResources
.get(resourcesManager);
references = arrayMap.values();
} catch (NoSuchFieldException ignore) {
Field mResourceReferences = resourcesManagerClass
.getDeclaredField("mResourceReferences");
mResourceReferences.setAccessible(true);

references = (Collection) mResourceReferences
.get(resourcesManager);
}
} else {
Class<?> activityThread = Class
.forName("android.app.ActivityThread");
Field fMActiveResources = activityThread
.getDeclaredField("mActiveResources");
fMActiveResources.setAccessible(true);
Object thread = getActivityThread(context, activityThread);

HashMap<?, WeakReference<Resources>> map = (HashMap) fMActiveResources
.get(thread);

references = map.values();
}
//遍历并得到弱引用集合中的 Resources ,将 Resources mAssets 字段引用替换成新的 AssetManager
for (WeakReference<Resources> wr : references) {
Resources resources = (Resources) wr.get();
if (resources != null) {
try {
Field mAssets = Resources.class
.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
...
}
resources.updateConfiguration(resources.getConfiguration(),
resources.getDisplayMetrics());
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}


  • 注释1处创建一个新的 AssetManager ,

  • 注释2注释3 处通过反射调用 addAssetPath 方法加载外部( SD 卡)的资源。

  • 注释4 处遍历 Activity 列表,得到每个 Activity 的 Resources ,

  • 注释5 处通过反射得到 Resources 的 AssetManager 类型的 rnAssets 字段 ,

  • 注释6处改写 mAssets 字段的引用为新的 AssetManager 。


采用同样的方式



  • 注释7处将 Resources. Theme 的 m Assets 字段 的引用替换为新创建的 AssetManager 。

  • 紧接着 根据 SDK 版本的不同,用不同的方式得到 Resources 的弱引用集合,

  • 再遍历这个弱引用集合, 将弱引用集合中的 Resources 的 mAssets 字段引用都替换成新创建的 AssetManager 。


资源修复原理




  • 1.创建新的AssetManager,通过反射调用addAssetPath方法,加载外部资源,这样新创建的AssetManager就含有了外部资源

  • 2.将AssetManager类型的mAsset字段全部用新创建的AssetManager对象替换。这样下次加载资源文件的时候就可以找到包含外部资源文件的AssetManager。



2.3:动态链接库so的修复


1.接口调用替换方案:


sdk提供接口替换System默认加载so库接口


SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)

SOPatchManager.loadLibrary接口加载 so库的时候优先尝试去加载sdk 指定目录下的补丁so


加载策略如下:


如果存在则加载补丁 so库而不会去加载安装apk安装目录下的so库
如果不存在补丁so,那么调用System.loadLibrary去加载安装apk目录下的 so库。


加载so库.jpg
我们可以很清楚的看到这个方案的优缺点:
优点:不需要对不同 sdk 版本进行兼容,因为所有的 sdk 版本都有 System.loadLibrary 这个接口。
缺点:调用方需要替换掉 System 默认加载 so 库接口为 sdk提供的接口, 如果是已经编译混淆好的三方库的so 库需要 patch,那么是很难做到接口的替换


虽然这种方案实现简单,同时不需要对不同 sdk版本区分处理,但是有一定的局限性没法修复三方包的so库同时需要强制侵入接入方接口调用,接着我们来看下反射注入方案。


2、反射注入方案


前面介绍过 System. loadLibrary ( "native-lib"); 加载 so库的原理,其实native-lib 这个 so 库最终传给 native 方法执行的参数是 so库在磁盘中的完整路径,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so库会在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 变量所表示的目录下去遍历搜索


sdk<23 DexPathList.findLibrary 实现如下


小余23.jpg


可以发现会遍历 nativeLibraryDirectories数组,如果找到了 loUtils.canOpenReadOnly (path)返回为 true, 那么就直接返回该 path, loUtils.canOpenReadOnly (path)返回为 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我们可以采取类似类修复反射注入方式,只要把我们的补丁so库的路径插入到nativeLibraryDirectories数组的最前面就能够达到加载so库的时候是补丁 库而不是原来so库的目录,从而达到修复的目的。


sdk>=23 DexPathList.findLibrary 实现如下


大于23.jpg
sdk23 以上 findLibrary 实现已经发生了变化,如上所示,那么我们只需要把补丁so库的完整路径作为参数构建一个Element对象,然后再插入到nativeLibraryPathElements 数组的最前面就好了。



  • 优点:可以修复三方库的so库。同时接入方不需要像方案1 —样强制侵入用 户接口调用

  • 缺点:需要不断的对 sdk 进行适配,如上 sdk23 为分界线,findLibrary接口实现已经发生了变化。


对于 so库的修复方案目前更多采取的是接口调用替换方式,需要强制侵入用户 接口调用。
目前我们的so文件修复方案采取的是反射注入的方案,重启生效。具有更好的普遍性。
如果有so文件修复实时生效的需求,也是可以做到的,只是有些限制情况。


常见热修复框架?










































































































特性DexposedAndFixTinker/AmigoQQ ZoneRobust/AcesoSophix
技术原理native底层替换native底层替换类加载类加载Instant Run混合
所属阿里阿里微信/饿了么QQ空间美团/蘑菇街阿里
即时生效YES   YES NONO YES混合
方法替换YES  YESYES YES   YES YES
类替换NO NOYESYES   YES  YES 
类结构修改NO  NOYES NO  NOYES 
资源替换NO NOYES YES NO YES 
so替换NO NO YES NO NO YES 
支持gradleNO NO YES YES YESYES 
支持ARTNO YES YES YES YES YES 

可以看出,阿里系多采用native底层方案,腾讯系多采用类加载机制。其中,Sophix是商业化方案;Tinker/Amigo支持特性较多,同时也更复杂,如果需要修复资源和so,可以选择;如果仅需要方法替换,且需要即时生效,Robust是不错的选择。


总结:


尽管热修复(或热更新)相对于迭代更新有诸多优势,市面上也有很多开源方案可供选择,但目前热修复依然无法替代迭代更新模式。有如下原因:
热修复框架多多少少会增加性能开销,或增加APK大小
热修复技术本身存在局限,比如有些方案无法替换so或资源文件
热修复方案的兼容性,有些方案无法同时兼顾Dalvik和ART,有些深度定制系统也无法正常工作
监管风险,比如苹果系统严格限制热修复


所以,对于功能迭代和常规bug修复,版本迭代更新依然是主流。一般的代码修复,使用Robust可以解决,如果还需要修复资源或so库,可以考虑Tinker


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

LinkedList源码解析

LinkedList源码解析 目标 理解LinkedList底层数据结构 深入源码掌握LinkedList查询慢,新增快的原因 1.简介 List 接口的链接列表实现。实现所有可选的列表操作,并且允许所有元素(包括 null )。除了实现 List 接口外...
继续阅读 »

LinkedList源码解析


目标



  • 理解LinkedList底层数据结构

  • 深入源码掌握LinkedList查询慢,新增快的原因


1.简介


List 接口的链接列表实现。实现所有可选的列表操作,并且允许所有元素(包括 null )。除了实现 List 接口外, LinkedList 类还为在列表的开头及结尾 get 、 remove 和 insert 元素提供了统一 的命名方法。这些操作允许将链接列表用作堆栈、队列或双端队列。


特点 :



  • 有序性 : 存入和取出的顺序是一致的

  • 元素可以重复

  • 含有带索引的方法

  • 独有特点 : 数据结构是链表,可以作为栈、队列或者双端队列!


image.png


2.LinkedList原理分析



双向链表



底层数据结构源码


public class LinkedList<E> {
   transient int size = 0;
   //双向链表的头结点
   transient Node<E> first;
   //双向链表的最后一个节点
   transient Node<E> last;

   //节点类【内部类】
   private static class Node<E> {
       E item;//数据元素
       Node<E> next;//下一个节点
       Node<E> prev;//上一个节点

       //节点的构造方法
       Node(Node<E> prev, E element, Node<E> next) {
           this.item = element;
           this.next = next;
           this.prev = prev;
      }
  } /
           /...
}

2.1 LinkedList的数据结构


LinkedList是双向链表,在代码中是一个Node类。内部并没有数组的结构。双向链表肯定存在一个头节 点和一个尾部节点。node节点类,是以内部类的形式存在于LinkedList中的。Node类都有两个成员变 量:



  • prev : 当前节点上一个节点,头节点的上一个节点是null

  • next : 当前节点下一个节点,尾结点的下一个节点是null


链表数据结构的特点 : 查询慢,增删快!



  • 链表数据结构基本构成,是一个node类

  • 每个node类中,有上一个节点【prev】和下一个节点【next】

  • 链表一定存在至少两个节点,first和last节点

  • 如果LinkedList没有数据,first和last都是为null


2.2 LinkedList默认容量&最大容量


image.png


没有默认容量,也没有最大容量


2.3 LinkedList扩容机制


无需扩容机制,只要你的内存足够大,可以无限制扩容下去。前提是不考虑查询的效率。


2.4 为什么LinkedList查询慢,增删快?


LinkedList的数据结构的特点,链表的数据结构就是这样的特点!



  • 链表是一种查询慢的结构【相对于数组来说】

  • 链表是一种增删快的结构【相对于数组来说】


2.5 LinkedList源码剖析-为什么增删快?


新增add


//想LinkedList添加一个元素
public boolean add(E e){
//连接到链表的末尾
       linkLast(e);
       return true;
      }/
       /连接到最后一个节点上去
       void linkLast(E e){
//将全局末尾节点赋值给l
final Node<E> l=last;
//创建一个新节点 : (上一个节点, 当前插入元素, null)
final Node<E> newNode=new Node<>(l,e,null);
//将当前节点作为末尾节点
       last=newNode;
//判断l节点是否为null
       if(l==null)
//既是尾结点也是头节点
       first=newNode;
       else
//之前的末尾节点,下一个节点时末尾节点!
       l.next=newNode;
       size++;//当前集合的元素数量+1
       modCount++;//操作集合数+1。modCount属性是修改技术器
      }/
       /------------------------------------------------------------------
//向链表中部添加
//参数1,添加的索引位置,添加元素
public void add(int index,E element){
//检查索引位是否符合要求
       checkPositionIndex(index);
//判断当前所有是否是存储元素个数
       if(index==size)//true,最后一个元素
       linkLast(element);
       else
//连接到指定节点的后面【链表中部插入】
       linkBefore(element,node(index));
      }/
       /根据索引查询链表中节点!
       Node<E> node(int index){
// 判断索引是否小于 已经存储元素个数的1/2
       if(index< (size>>1)){//二分法查找 : 提高查找节点效率
       Node<E> x=first;
       for(int i=0;i<index; i++)
       x=x.next;
       return x;
      }else{
       Node<E> x=last;
       for(int i=size-1;i>index;i--)
       x=x.prev;
       return x;
      }
      }/
       /将当前元素添加到指定节点之前
       void linkBefore(E e,Node<E> succ){
       // 取出当前节点的前一个节点
       final Node<E> pred=succ.prev;
       //创建当前元素的节点 : 上一个节点,当前元素,下一个节点
       final Node<E> newNode=new Node<>(pred,e,succ);
       //为指定节点上一个节点重新值
       succ.prev=newNode;
//判断当前节点的上一个节点是否为null
       if(pred==null)
       first=newNode;//当前节点作为头部节点
       else
       pred.next=newNode;//将新插入节点作为上一个节点的下个节点
       size++;//新增元素+1
       modCount++;//操作次数+1
      }

remove删除指定索引元素


//删除指定索引位置元素
public E remove(int index){
//检查元素索引
       checkElementIndex(index);
//删除元素节点,
//node(index) 根据索引查到要删除的节点
//unlink()删除节点
       return unlink(node(index));
      }//根据索引查询链表中节点!
       Node<E> node(int index){
// 判断索引是否小于 已经存储元素个数的1/2
       if(index< (size>>1)){//二分法查找 : 提高查找节点效率
       Node<E> x=first;
       for(int i=0;i<index; i++)
       x=x.next;
       return x;
      }else{
       Node<E> x=last;
       for(int i=size-1;i>index;i--)
       x=x.prev;
       return x;
      }
      }/
       /删除一个指定节点
       E unlink(Node<E> x){
//获取当前节点中的元素
final E element=x.item;
//获取当前节点的上一个节点
final Node<E> next=x.next;
//获取当前节点的下一个节点
final Node<E> prev=x.prev;
//判断上一个节点是否为null
       if(prev==null){
//如果为null,说明当前节点为头部节点
       first=next;
      }else{
//上一个节点,的下一个节点改为下下节点
       prev.next=next;
//将当前节点的上一个节点置空
       x.prev=null;
      }/
       /判断下一个节点是否为null
       if(next==null){
//如果为null,说明当前节点为尾部节点
       last=prev;
      }else{
//下一个节点的上节点,改为上上节点
       next.prev=prev;
//当前节点的上节点置空
       x.next=null;
      }/
       /删除当前节点内的元素
       x.item=null;
       size--;//集合中的元素个数-1
       modCount++;//当前集合操作数+1。modCount计数器,记录当前集合操作次数
       return element;//返回删除的元素
      }

2.6 LinkedList源码剖析-为什么查询慢?


查询快和慢是一个相对概念!相对于数组来说


//根据索引查询一个元素
public E get(int index){
//检查索引是否存在
       checkElementIndex(index);
// node(index)获取索引对应节点,获取节点中的数据item
       return node(index).item;
      }/
       /根据索引获取对应节点对象
       Node<E> node(int index){
//二分法查找索引对应的元素
       if(index< (size>>1)){
       Node<E> x=first;
//前半部分查找【遍历节点】
       for(int i=0;i<index; i++)
       x=x.next;
       return x;
      }else{
       Node<E> x=last;
//后半部分查找【遍历】
       for(int i=size-1;i>index;i--)
       x=x.prev;
       return x;
      }
      }/
       /查看ArrayList里的数组获取元素的方式
public E get(int index){
       rangeCheck(index);//检查范围
       return elementData(index);//获取元素
      }E
       elementData(int index){
       return(E)elementData[index];//一次性操作
      }

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

最安全的加密算法 Bcrypt,再也不用担心数据泄密了~

这是《Spring Security 进阶》专栏的第三篇文章,给大家介绍一下Spring Security 中内置的加密算法BCrypt,号称最安全的加密算法,究竟有着什么魔力能让黑客闻风丧胆 哈希(Hash)与加密(Encrypt) 哈希(Hash)是将目标...
继续阅读 »

这是《Spring Security 进阶》专栏的第三篇文章,给大家介绍一下Spring Security 中内置的加密算法BCrypt,号称最安全的加密算法,究竟有着什么魔力能让黑客闻风丧胆


哈希(Hash)与加密(Encrypt)


哈希(Hash)是将目标文本转换成具有相同长度的、不可逆的杂凑字符串(或叫做消息摘要),而加密(Encrypt)是将目标文本转换成具有不同长度的、可逆的密文。



  • 哈希算法往往被设计成生成具有相同长度的文本,而加密算法生成的文本长度与明文本身的长度有关。

  • 哈希算法是不可逆的,而加密算法是可逆的。


HASH 算法是一种消息摘要算法,不是一种加密算法,但由于其单向运算,具有一定的不可逆性,成为加密算法中的一个构成部分。


JDK的String的Hash算法。代码如下:


public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}

从JDK的API可以看出,它的算法等式就是s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1],其中s[i]就是索引为i的字符,n为字符串的长度。


HashMap的hash计算时先计算hashCode(),然后进行二次hash。代码如下:


// 计算二次Hash    
int hash = hash(key.hashCode());

static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}

可以发现,虽然算法不同,但经过这些移位操作后,对于同一个值使用同一个算法,计算出来的hash值一定是相同的。


那么,hash为什么是不可逆的呢?


假如有两个密码3和4,我的加密算法很简单就是3+4,结果是7,但是通过7我不可能确定那两个密码是3和4,有很多种组合,这就是最简单的不可逆,所以只能通过暴力破解一个一个的试。


在计算过程中原文的部分信息是丢失了。一个MD5理论上是可以对应多个原文的,因为MD5是有限多个而原文是无限多个的。


不可逆的MD5为什么是不安全的?


因为hash算法是固定的,所以同一个字符串计算出来的hash串是固定的,所以,可以采用如下的方式进行破解。



  1. 暴力枚举法:简单粗暴地枚举出所有原文,并计算出它们的哈希值,看看哪个哈希值和给定的信息摘要一致。

  2. 字典法:黑客利用一个巨大的字典,存储尽可能多的原文和对应的哈希值。每次用给定的信息摘要查找字典,即可快速找到碰撞的结果。

  3. 彩虹表(rainbow)法:在字典法的基础上改进,以时间换空间。是现在破解哈希常用的办法。


对于单机来说,暴力枚举法的时间成本很高(以14位字母和数字的组合密码为例,共有1.24×10^25种可能,即使电脑每秒钟能进行10亿次运算,也需要4亿年才能破解),字典法的空间成本很高(仍以14位字母和数字的组合密码为例,生成的密码32位哈希串的对照表将占用5.7×10^14 TB的存储空间)。但是利用分布式计算和分布式存储,仍然可以有效破解MD5算法。因此这两种方法同样被黑客们广泛使用。


如何防御彩虹表的破解?


虽然彩虹表有着如此惊人的破解效率,但网站的安全人员仍然有办法防御彩虹表。最有效的方法就是“加盐”,即在密码的特定位置插入特定的字符串,这个特定字符串就是“盐(Salt)”,加盐后的密码经过哈希加密得到的哈希串与加盐前的哈希串完全不同,黑客用彩虹表得到的密码根本就不是真正的密码。即使黑客知道了“盐”的内容、加盐的位置,还需要对H函数和R函数进行修改,彩虹表也需要重新生成,因此加盐能大大增加利用彩虹表攻击的难度。


一个网站,如果加密算法和盐都泄露了,那针对性攻击依然是非常不安全的。因为同一个加密算法同一个盐加密后的字符串仍然还是一毛一样滴!


一个更难破解的加密算法Bcrypt


BCrypt是由Niels Provos和David Mazières设计的密码哈希函数,他是基于Blowfish密码而来的,并于1999年在USENIX上提出。


除了加盐来抵御rainbow table 攻击之外,bcrypt的一个非常重要的特征就是自适应性,可以保证加密的速度在一个特定的范围内,即使计算机的运算能力非常高,可以通过增加迭代次数的方式,使得加密速度变慢,从而可以抵御暴力搜索攻击。


Bcrypt可以简单理解为它内部自己实现了随机加盐处理。使用Bcrypt,每次加密后的密文是不一样的。


对一个密码,Bcrypt每次生成的hash都不一样,那么它是如何进行校验的?



  1. 虽然对同一个密码,每次生成的hash不一样,但是hash中包含了salt(hash产生过程:先随机生成salt,salt跟password进行hash);

  2. 在下次校验时,从hash中取出salt,salt跟password进行hash;得到的结果跟保存在DB中的hash进行比对。


在Spring Security 中 内置了Bcrypt加密算法,构建也很简单,代码如下:


@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

生成的加密字符串格式如下:


$2b$[cost]$[22 character salt][31 character hash]

比如:


$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash

上面例子中,$2a$ 表示的hash算法的唯一标志。这里表示的是Bcrypt算法。


10 表示的是代价因子,这里是2的10次方,也就是1024轮。


N9qo8uLOickgx2ZMRZoMye 是16个字节(128bits)的salt经过base64编码得到的22长度的字符。


最后的IjZAgcfl7p92ldGxad68LJZdL17lhWy是24个字节(192bits)的hash,经过bash64的编码得到的31长度的字符。


PasswordEncoder 接口


这个接口是Spring Security 内置的,如下:


public interface PasswordEncoder {
String encode(CharSequence rawPassword);

boolean matches(CharSequence rawPassword, String encodedPassword);

default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

这个接口有三个方法:



  • encode方法接受的参数是原始密码字符串,返回值是经过加密之后的hash值,hash值是不能被逆向解密的。这个方法通常在为系统添加用户,或者用户注册的时候使用。

  • matches方法是用来校验用户输入密码rawPassword,和加密后的hash值encodedPassword是否匹配。如果能够匹配返回true,表示用户输入的密码rawPassword是正确的,反之返回fasle。也就是说虽然这个hash值不能被逆向解密,但是可以判断是否和原始密码匹配。这个方法通常在用户登录的时候进行用户输入密码的正确性校验。

  • upgradeEncoding设计的用意是,判断当前的密码是否需要升级。也就是是否需要重新加密?需要的话返回true,不需要的话返回fasle。默认实现是返回false。


例如,我们可以通过如下示例代码在进行用户注册的时候加密存储用户密码


//将User保存到数据库表,该表包含password列
user.setPassword(passwordEncoder.encode(user.getPassword()));

BCryptPasswordEncoder 是Spring Security推荐使用的PasswordEncoder接口实现类


public class PasswordEncoderTest {
@Test
void bCryptPasswordTest(){
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String rawPassword = "123456"; //原始密码
String encodedPassword = passwordEncoder.encode(rawPassword); //加密后的密码

System.out.println("原始密码" + rawPassword);
System.out.println("加密之后的hash密码:" + encodedPassword);

System.out.println(rawPassword + "是否匹配" + encodedPassword + ":" //密码校验:true
+ passwordEncoder.matches(rawPassword, encodedPassword));

System.out.println("654321是否匹配" + encodedPassword + ":" //定义一个错误的密码进行校验:false
+ passwordEncoder.matches("654321", encodedPassword));
}
}

上面的测试用例执行的结果是下面这样的。(注意:对于同一个原始密码,每次加密之后的hash密码都是不一样的,这正是BCryptPasswordEncoder的强大之处,它不仅不能被破解,想通过常用密码对照表进行大海捞针你都无从下手),输出如下:


原始密码123456
加密之后的hash密码:$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm
123456是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:true
654321是否匹配$2a$10$zt6dUMTjNSyzINTGyiAgluna3mPm7qdgl26vj4tFpsFO6WlK5lXNm:false

BCrypt 产生随机盐(盐的作用就是每次做出来的菜味道都不一样)。这一点很重要,因为这意味着每次encode将产生不同的结果。


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

Android DIY你的菜单栏

前言个人打算开发个视频编辑的APP,然后把一些用上的技术总结一下,这次主要是APP的底部菜单栏用到了一个自定义View去绘制实现的,所以这次主要想讲讲自定义View的一些用到的点和自己如何去DIY一个不一样的自定义布局。实现的效果和思路可以先看看实现的效果两个...
继续阅读 »

前言

个人打算开发个视频编辑的APP,然后把一些用上的技术总结一下,这次主要是APP的底部菜单栏用到了一个自定义View去绘制实现的,所以这次主要想讲讲自定义View的一些用到的点和自己如何去DIY一个不一样的自定义布局。

实现的效果和思路

可以先看看实现的效果

sp1gif.gif

两个页面的内容还没做,当前就是一个Demo,可以看到底部的菜单栏是一个绘制出来的不规则的一个布局,那要如何实现呢。可以先来看看它的一个绘制区域:

a94f6a185c3ebee1cc62a9731b2a1be.jpg

就是一个底部的布局和3个子view,底部的区域当然也是个规则的区域,只不过我们是在这块区域上去进行绘制。

可以把整个过程分为几个步骤:

1. 绘制底部布局
(1) 绘制矩形区域
(2) 绘制外圆形区域
(3) 绘制内圆形区域
2. 添加子view进行布局
3. 处理事件分发的区域 (底部菜单上边的白色区域不触发菜单的事件)
4. 写个动画意思意思

1. 绘制底部布局

这里做的话就没必要手动去添加view这些了,直接全部手动绘制就行。

companion object{

const val DIMENS_64 = 64.0
const val DIMENS_96 = 96.0
const val DIMENS_50 = 50.0
const val DIMENS_48 = 48.0

interface OnChildClickListener{
fun onClick(index : Int)
}

}

private var paint : Paint ?= null // 绘制蓝色区域的画笔
private var paint2 : Paint ?= null // 绘制白色内圆的画笔
private var allHeight : Int = 0 // 总高度,就是绘制的范围
private var bgHeight : Int = 0 // 背景的高度,就是蓝色矩阵的范围
private var mRadius : Int = 0 // 外圆的高度
private var mChildSize : Int = 0
private var mChildCenterSize : Int = 0

private var mWidthZone1 : Int = 0
private var mWidthZone2 : Int = 0
private var mChildCentre : Int = 0

private var childViews : MutableList<View> = mutableListOf()
private var objectAnimation : ObjectAnimator ?= null
var onChildClickListener : OnChildClickListener ?= null

init {
initView()
}

private fun initView(){
val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
DimensionUtils.dp2px(context, DIMENS_64).toInt())
layoutParams = lp

allHeight = DimensionUtils.dp2px(context, DIMENS_96).toInt()
bgHeight = DimensionUtils.dp2px(context, DIMENS_64).toInt()
mRadius = DimensionUtils.dp2px(context, DIMENS_50).toInt()
mChildSize = DimensionUtils.dp2px(context, DIMENS_48).toInt()
mChildCenterSize = DimensionUtils.dp2px(context, DIMENS_64).toInt()
setWillNotDraw(false)

initPaint()
}

private fun initPaint(){
paint = Paint()
paint?.isAntiAlias = true
paint?.color = context.resources.getColor(R.color.kylin_main_color)

paint2 = Paint()
paint2?.isAntiAlias = true
paint2?.color = context.resources.getColor(R.color.kylin_third_color)
}

上边是先把一些尺寸给定义好(我这边是没有设计图,自己去直接调整的,所以可能有些视觉效果不太好,如果有设计师帮忙的话效果肯定会好些),绘制流程就是绘制3个形状,然后代码里也加了些注释哪个变量有什么用,这步应该不难,没什么可以多解释的。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)
// 拿到子view做操作的,和这步无关,可以先不看
if (childViews.size <= 0) {
for (i in 0 until childCount) {
val cView = getChildAt(i)
initChildView(cView, i)
childViews.add(cView)
if (i == childCount/2){
val ms: Int = MeasureSpec.makeMeasureSpec(mRadius, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}else {
val ms: Int = MeasureSpec.makeMeasureSpec(mChildSize, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}
}
}

setMeasuredDimension(wSize, allHeight)
}

这步其实也很简单,就是说给当前自定义view设置高度为allHeight

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// 绘制长方形区域
canvas?.drawRect(left.toFloat(), ((allHeight - bgHeight).toFloat()),
right.toFloat(), bottom.toFloat(), paint!!)

// 绘制圆形区域
paint?.let {
canvas?.drawCircle(
(width/2).toFloat(), mRadius.toFloat(),
mRadius.toFloat(),
it
)
}

// 绘制内圆区域
paint2?.let {
canvas?.drawCircle(
(width/2).toFloat(), mRadius.toFloat(),
(mRadius - 28).toFloat(),
it
)
}
}

最后进行绘制, 就是上面说的绘制3个图形,代码里的注释也说得很清楚。

2. 添加子view

我这里是外面布局去加子view的,想弄得灵活点(但感觉也不太好,后面还是想改成里面定义一套规范来弄会好些,如果自由度太高的话去做自定义就很麻烦,而且实际开发中这种需求也没必要把扩展性做到这种地步,基本就是整个APP只有一个地方使用)

但是这边也只是一个Demo先做个演示。

<com.kylin.libkcommons.widget.BottomMenuBar
android:id="@+id/bv_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
>

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/home"
/>

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/video"
/>

<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/more"
/>

</com.kylin.libkcommons.widget.BottomMenuBar>
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val wSize = MeasureSpec.getSize(widthMeasureSpec)

if (childViews.size <= 0) {
for (i in 0 until childCount) {
val cView = getChildAt(i)
initChildView(cView, i)
childViews.add(cView)
if (i == childCount/2){
val ms: Int = MeasureSpec.makeMeasureSpec(mRadius, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}else {
val ms: Int = MeasureSpec.makeMeasureSpec(mChildSize, MeasureSpec.AT_MOST)
measureChild(cView, ms, ms)
}
}
}

setMeasuredDimension(wSize, allHeight)
}

拿到子view进行一个管理,做一些初始化的操作,主要是设点击事件这些,这里不是很重要。

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
if (mChildCentre == 0){
mChildCentre = width / 6
}

// 辅助事件分发区域
if (mWidthZone1 == 0 || mWidthZone2 == 0) {
mWidthZone1 = width / 2 - mRadius / 2
mWidthZone2 = width / 2 + mRadius / 2
}

// 设置每个子view的显示区域
for (i in 0 until childViews.size) {
if (i == childCount/2){
childViews[i].layout(mChildCentre*(2*i+1) - mChildCenterSize/2 ,
allHeight/2 - mChildCenterSize/2,
mChildCentre*(2*i+1) + mChildCenterSize/2 ,
allHeight/2 + mChildCenterSize/2)
}else {
childViews[i].layout(mChildCentre*(2*i+1) - mChildSize/2 ,
allHeight - bgHeight/2 - mChildSize/2,
mChildCentre*(2*i+1) + mChildSize/2 ,
allHeight - bgHeight/2 + mChildSize/2)
}
}

}

进行布局,这里比较重要,因为能看出,中间的图标会更大一些,所以要做一些适配。其实这里就是把宽度分为6块,然后3个view分别在1,3,5这三个左边点,y的话就是除中间那个,其它两个都是bgHeight绘制高度的的一半,中间那个是allHeight总高度的一半,这样3个view的x和y坐标都能拿到了,再根据宽高就能算出l,t,r,b四个点,然后布局。

3. 处理事件分发

可以看出我们的区域是一个不规则的区域,按照我们用抽象的角度去思考,我们希望这个菜单栏的区域只是显示蓝色的那个区域,所以蓝色区域上面的白色区域就算是我们自定义view的范围,他触发的事件也应该是后面的view的事件(Demo中后面的View是一个ViewPager),而不是菜单栏。

// 辅助事件分发区域
if (mWidthZone1 == 0 || mWidthZone2 == 0) {
mWidthZone1 = width / 2 - mRadius / 2
mWidthZone2 = width / 2 + mRadius / 2
}

这两块是圆外的x的区域。

/**
* 判断点击事件是否在点击区域中
*/
private fun isShowZone(x : Float, y : Float) : Boolean{
if (y >= allHeight - bgHeight){
return true
}
if (x >= mWidthZone1 && x <= mWidthZone2){
// 在圆内
val relativeX = abs(x - width/2)
val squareYZone = mRadius.toDouble().pow(2.0) - relativeX.toDouble().pow(2.0)
return y >= mRadius - sqrt(squareYZone)
}
return false
}

先判断y如果在背景的矩阵中(上面说了自定义view分成矩阵,外圆,内圆),那肯定是菜单的区域。如果不在,那就要判断y在不在圆内,这里就必须用勾股定理去判断。

override fun onTouchEvent(event: MotionEvent?): Boolean {
// 点击区域进行拦截
if (event?.action == MotionEvent.ACTION_DOWN && isShowZone(event.x, event.y)){
return true
}
return super.onTouchEvent(event)
}

最后做一个事件分发的拦截。除了计算区域那可能需要去想想,其它地方我觉得都挺好理解的吧。

4. 做个动画

给子view设点击事件让外部处理,然后给中间的按钮做个动画效果。

private fun initChildView(cView : View?, index : Int) {
cView?.setOnClickListener {
if (index == childViews.size/2) {
startAnim(cView)
}else {
onChildClickListener?.onClick(index)
}
}
}
private fun startAnim(view : View){
if (objectAnimation == null) {
objectAnimation = ObjectAnimator.ofFloat(view,
"rotation", 0f, -15f, 180f, 0f)
objectAnimation?.addListener(object : Animator.AnimatorListener {

override fun onAnimationStart(p0: Animator) {
}

override fun onAnimationEnd(p0: Animator) {
onChildClickListener?.onClick(childViews.size / 2)
}

override fun onAnimationCancel(p0: Animator) {
onChildClickListener?.onClick(childViews.size / 2)
}

override fun onAnimationRepeat(p0: Animator) {
}

})
objectAnimation?.duration = 1000
objectAnimation?.interpolator = AccelerateDecelerateInterpolator()
}
objectAnimation?.start()
}

注意做释放操作。

fun onDestroy(){
try {
objectAnimation?.cancel()
objectAnimation?.removeAllListeners()
}catch (e : Exception){
e.printStackTrace()
}finally {
objectAnimation = null
}
}

5. 小结

其实代码都挺简单的,关键是你要去想出一个方法来实现这个场景,然后感觉这个自定义viewgroup也是比较经典的,涉及到measure、layout、draw,涉及到动画,涉及到点击冲突。

这个Demo表示你要实现怎样的效果都可以,只要是draw能画出来的,你都能实现,我这个是中间凸出来,你可以实现凹进去,你可以实现波浪的样子,可以实现复杂的曲线,都行,你用各种基础图形去做拼接,或者画贝塞尔等等,其实都不难,主要是要有个计算和调试的过程。但是你的形状要和点击区域关联起来,你设计的图案越复杂,你要适配的点击区域计算量就越大。

甚至我还能做得效果更屌的是,那3个子view的图标,我都能画出来,就不用ImagerView,直接手动画出来,这样做的好处是什么呢?我对子view的图标能做各种炫酷的属性动画,我在切换viewpager时对图标做属性动画,那不得逼格再上一层。 为什么我没做呢,因为没有设计,我自己做的话要花大量的时间去调,要是有设计的话他告诉我尺寸啊位置啊这些信息,做起来就很快。我的APP主要是打算实现视频的编辑为主,所以这些支线就没打算花太多时间去处理。


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

收起阅读 »

Kotlin中 Flow、SharedFlow与StateFlow区别

一、简介 了解过协程Flow 的同学知道是典型的冷数据流,而SharedFlow与StateFlow则是热数据流。 冷流:只有当订阅者发起订阅时,事件的发送者才会开始发送事件。 热流:不管订阅者是否存在,只要发送了事件就会被消费,意思是不管接受方是...
继续阅读 »

一、简介


了解过协程Flow 的同学知道是典型的冷数据流,而SharedFlowStateFlow则是热数据流。



  • 冷流:只有当订阅者发起订阅时,事件的发送者才会开始发送事件。

  • 热流:不管订阅者是否存在,只要发送了事件就会被消费,意思是不管接受方是否能够接收到,在这一点上有点像我们Android的LiveData


解释:LiveData新的订阅者不会接收到之前发送的事件,只会收到之前发送的最后一条数据,这个特性和SharedFlow的参数replay设置为1相似


二、使用分析


最好的分析是从使用时入手冷流flow热流SharedFlow和StateFlow热流的具体的实现类分别是MutableSharedFlow和MutableStateFlow


用一个简单的例子来说明什么是冷流,什么是热流。



  • 冷流flow:


private fun testFlow() {
val flow = flow<Int> {
(1..5).forEach {
delay(1000)
emit(it)
}
}
mBind.btCollect.setOnClickListener {
lifecycleScope.launch {
flow.collect {
Log.d(TAG, "testFlow 第一个收集器: 我是冷流:$it")
}
}
lifecycleScope.launch {
delay(5000)
flow.collect {
Log.d(TAG, "testFlow:第二个收集器 我是冷流:$it")
}
}
}

}

我点击收集按钮响应事件后,打印结果如下图:
image.png
这就是冷流,需要去触发收集,才能接收到结果。


从上图时间可知flow每次重新订阅收集都会将所有事件重新发送一次



  • 热流MutableSharedFlow和


private fun testSharedFlow() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")

}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a 100
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

image.png


第二个流收集被延迟,晚了100毫秒后就收不到了,想当于不管是否订阅,流都会发送,只管发,而collect1能够收集到是因为他在发送之前进行了订阅收集。


三、分析MutableSharedFlow中参数的具体含义


以上面testSharedFlow()方法中对象为例,上面的配置就是,当前对象的默认配置
源码如下图:


image.png


val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND //产生背压现象后的,执行策略
)

3.1、 reply:事件粘滞数


reply:事件粘滞数以testSharedFlow方法为例如果设置了数目的话,那么其他订阅者不管什么时候订阅都能够收到replay数目的最新的事件,reply=1的话有点类似Android中使用的livedata。


eg:和testSharedFlow方法区别在于 replay = 2


private fun testSharedFlowReplay() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 2,//相当于粘性数据
extraBufferCapacity = 0,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")

}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

按照上面的解释collect2会收集到最新的4,5两个事件如下图:


image.png


3.2 extraBufferCapacity:缓存容量


extraBufferCapacity:缓存容量,就是先发送几个事件,不管已经订阅的消费者是否接收,这种只管发不管消费者消费能力的情况就会出现背压,参数onBufferOverflow就是用于处理背压问题


eg:和testSharedFlow方法区别在于 extraBufferCapacity = 2


private fun testSharedFlowCapacity() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")

}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

结果如下图:


优先发送将其缓存起来,testSharedFlow测试中发送与接收在没有干扰(延时之类的干扰)的情况下 是一条顺序链,而设置了extraBufferCapacity优先发送两条,不管消费情况,不设置的话(extraBufferCapacity = 0)这时如果在collect1里面设置延时delay(100),send会被阻塞(因为默认是 onBufferOverflow = BufferOverflow.SUSPEND的策略)
image.png


3.3、onBufferOverflow


onBufferOverflow:由背压就有处理策略,sharedflow默认为BufferOverflow.SUSPEND
,也即是如果当事件数量超过缓存,发送就会被挂起,上面提到了一句,DROP_OLDEST销毁最旧的值,DROP_LATEST销毁最新的值


三种参数含义


public enum class BufferOverflow {
/**
* 在缓冲区溢出时挂起。
*/
SUSPEND,

/**
* 在缓冲区溢出时删除** *旧的**值,添加新的值到缓冲区,不挂起。
*/
DROP_OLDEST,

/**
* 在缓冲区溢出时,删除当前添加到缓冲区的最新的**值\
*(使缓冲区内容保持不变),不要挂起。
*/
DROP_LATEST
}

eg:和testSharedFlowCapacity方法区别在于 多了个delay(100)



  • SUSPEND模式


private fun testSharedFlow2() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.SUSPEND
)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")
delay(100)
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

image.png


image.png


SUSPEND情况下从第一张图知道collect1都收集了,第二张图发现collect2也打印了两次,为什么只有两次呢?


因为 extraBufferCapacity = 2,等于2,错过了两次的事件发送的接收,不信的话可以试一下extraBufferCapacity = 0,这时候肯定打印了4次,可能有人问为什么是4次呢,因为collect2的订阅者延时了100毫秒才开始订阅,



  • DROP_LATEST模式


private fun testSharedFlow2() {

val sharedFlow = MutableSharedFlow<Int>(
replay = 0,//相当于粘性数据
extraBufferCapacity = 2,//接受的慢时候,发送的入栈
onBufferOverflow = BufferOverflow.DROP_LATEST

)
lifecycleScope.launch {
launch {

sharedFlow.collect {
println("collect1 received ago shared flow $it")
delay(100)
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
sharedFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
sharedFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

发送过快的话,销毁最新的,只保留最老的两条事件,我们可以知道1,2,肯定保留其他丢失


image.png


要想不丢是怎么办呢,很简单不要产生背压现象就行,在emit中延时delay(200),比收集耗时长就行。



  • DROP_OLDEST模式
    该模式同理DROP_LATEST模式,保留最新的extraBufferCapacity = 2(多少)的数据就行


四、StateFlow


初始化


val stateFlow = MutableStateFlow<Int>(value = -1)

image.png


image.png


由上图的继承关系可知stateFlow其实就是一种特殊的SharedFlow,它多了个初始值value


image.png
由上图可知:每次更新数据都会和旧数据做一次比较,只有不同时候才会更新数值。


SharedFlow和StateFlow的侧重点



  • StateFlow就是一个replaySize=1的sharedFlow,同时它必须有一个初始值,此外,每次更新数据都会和旧数据做一次比较,只有不同时候才会更新数值。

  • StateFlow重点在状态,ui永远有状态,所以StateFlow必须有初始值,同时对ui而言,过期的状态毫无意义,所以stateFLow永远更新最新的数据(和liveData相似),所以必须有粘滞度=1的粘滞事件,让ui状态保持到最新。另外在一个时间内发送多个事件,不会管中间事件有没有消费完成都会执行最新的一条.(中间值会丢失)

  • SharedFlow侧重在事件,当某个事件触发,发送到队列之中,按照挂起或者非挂起、缓存策略等将事件发送到接受方,在具体使用时,SharedFlow更适合通知ui界面的一些事件,比如toast等,也适合作为viewModel和repository之间的桥梁用作数据的传输。


eg测试如下中间值丢失:


    private fun testSharedFlow2() {
val stateFlow = MutableStateFlow<Int>(value = -1)

lifecycleScope.launch {
launch {

stateFlow.collect {
println("collect1 received ago shared flow $it")
}
}
launch {
(1..5).forEach {
println("emit1 send ago flow $it")
stateFlow.emit(it)
println("emit1 send after flow $it")
}
}
// wait a minute
delay(100)
launch {
stateFlow.collect {
println("collect2 received shared flow $it")
}
}
}

}

由下图可知,中间值丢失,collect2结果可知永远有状态
image.png
好了到这里文章就结束了,源码分析后续再写。


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

为什么B站的弹幕可以不挡人物

那天在B站看视频的时候偶然发现当字幕遇到人物的时候就被裁切了,不会挡住人物,觉得很神奇,于是决定一探究竟。高端的效果,往往只需要采用最朴素的实现方式,忙碌了两个小时,陈师傅打开了F12,豁然开朗。一张图片+一个属性,直接搞定。为了印证我的想法,我决定自己写一个...
继续阅读 »


那天在B站看视频的时候偶然发现当字幕遇到人物的时候就被裁切了,不会挡住人物,觉得很神奇,于是决定一探究竟。

高端的效果,往往只需要采用最朴素的实现方式,忙碌了两个小时,陈师傅打开了F12,豁然开朗。一张图片+一个属性,直接搞定。


为了印证我的想法,我决定自己写一个demo

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.video {
width: 668px;
height: 376px;
position: relative;
-webkit-mask-image: url("mask.svg");
-webkit-mask-size: 668px 376px;
}
.bullet {
position: absolute;
font-size: 20px;
}
</style>
</head>
<body>
<div class="video">
<div class="bullet" style="left: 100px; top: 0;">元芳,你怎么看</div>
<div class="bullet" style="left: 200px; top: 20px;">你难道就是传说中的奶灵</div>
<div class="bullet" style="left: 300px; top: 40px;">你好,我是胖灵</div>
<div class="bullet" style="left: 400px; top: 60px;">这是第一集,还没有舔灵</div>
</div>
</body>
</html>

效果是这样的


加一个红背景,看的清楚一些


至此我们就实现了B站同款的不遮挡人物的弹幕。至于这张图片是怎么来的,肯定是AI识别出来然后生成的,一张图片也就一两K,一次加载很多张也不会造成很大的负担。

最后来看看这个神奇的css属性吧

所以在开发需求的时候可以把它当成一个亮点使用,但是不能强依赖于这个属性做需求。

原文链接:https://juejin.cn/post/7141012605535010823

收起阅读 »

项目中第三方库并不是必须的

前言有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免重复造轮子。虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先...
继续阅读 »

前言

有时候集成一个特定的库(比如 PayPal)是必须的,有时候是避免去开发一些非常复杂的功能,有时候仅仅只是避免重复造轮子。

虽然这些都是合理的考量,但使用第三方库的风险和相关成本往往被忽视或误解。在某些情况下,风险是值得的,但是在决定冒险之前,首先要能够明确的定义风险。为了使风险评估更加的透明和一致,我们制定了一个流程来衡量我们将其集成到app有多大的风险。


风险

大多数大型组织,包括我们,都有某种形式的代码审查,作为开发实践的一部分。对这些团队来说,添加一个第三方库就相当于添加了一堆由不属于团队成员开发,未经审查的代码。这破坏了团队一直坚持的代码审查原则,交付了质量未知的代码。这给app的运行方式以及长期开发带来了风险,对于大型团队而言,更是对整体业务带来了风险。

运行时风险

库代码通常来说,对于系统资源,和app拥有相同级别的访问权限,但它们不一定应用团队为管理这些资源而制定的最佳实践。这意味着它们可以在没有限制的情况下访问磁盘,网络,内存,CPU等等,因此,它们可以(过度)将文件写入磁盘,使用未优化的代码占用内存或CPU,导致死锁或主线程延迟,下载(和上传!)大量数据等等。更糟糕的是他们会导致崩溃,甚至崩溃循环。两次。

其中许多情况直到 app 已经上架才被发现,在这种情况下,修复它需要创建一个新版本,并通过审核,这通常需要大量时间和成本。这种风险可以通过一个变量控制是否调用来进行一定程度的控制,但是这种方法也并非万无一失(看下文)。

开发风险

引用一个同事的话:“每一行代码都是一种负担”,对不是你自己写的代码而言,这句话更甚。库在适配新技术或API时可能很慢,这阻碍了代码开发,或者太快,导致开发的版本过高。

库在采用新技术或API时可能很慢,阻碍了代码库,或者太快,导致部署目标太高。每当 Apple 和 Google 每年发布一个新 OS 版本时,他们通常要求开发人员根据SDK的变化更新代码,库开发人员也必须这样做。这需要协调一致的努力、优先事项的一致性以及及时完成工作的能力。

随着移动平台的不断变化,以及团队(成员)也不是一成不变,这将会成为一个持续不断的风险。当被集成的库不存在了,而库又需要更新时,会花很多时间来决定谁来做。事实证明一旦一个库存在,就很少也很难被移除,因此我们将其视为长期维护成本。

商业风险

如同我上面所说,现代的操作系统并没有对 app 代码和库代码进行区分,因此除了系统资源之外,它们还可以访问用户信息。作为 app 的开发者,我们负责恰当的使用这部分信息,也需要为任何第三方库负责。

如果用户给了 Lyft app 地理位置授权,任何第三方库也将自动得获得授权。他们可以将那些(地理位置)数据上传到自己服务器,竞对服务器,或者谁知道还有什么地方。当一个库需要我们没有的权限时,那问题就更大了。

同样,一个系统的安全取决于其最薄弱的环节,但如果其中包含未经审核的代码,那么你就不知道它到底有多安全。你精心设计的安全编码实践可能会被一个行为不当的库所破坏。苹果和谷歌实施的任何政策都是如此,例如“你不得对用户追踪”。


减少风险

当对一个库(是否)进行使用评估时,我们首先要问几个问题,以了解对库的需求。

我们内部能做么?

有时候我们只需要简单的粘贴复制真正需要的部分。在更复杂的场景中,库与自定义后端通信,我们对该API进行了逆向,并自己构建了一个迷你SDK(同样,只构建了我们需要的部分)。在90%的情况下,这是首选,但在与非常特定的供应商或需求集成时并不总是可行。

有多少用户从该库中受益?

在一种情况下,我们正在考虑添加一个风险很大的库(根据下面的标准),旨在为一小部分用户提供服务,同时将我们的所有用户都暴露在该库中。对于我们认为会从中受益的一小部分客户,我们冒了为我们所有用户带来问题的风险。

这个库有什么传递依赖?

我们还需要评估库的所有依赖项的以下标准。

退出标准是什么?

如果集成成功,是否有办法将其转移到内部?如果不成功,是否有办法删除?


评价标准

如果此时团队仍然希望集成库,我们要求他们根据一组标准对库进行“评分”。下面的列表并不全面,但应该能很好地说明我们希望看到的。

阻断标准

这些标准将阻止我们从技术上或者公司政策上集成此库,在进行下一步之前,我们必须解决:

过高的 deployment target/target SDKs。 我们支持过去4年主流的操作系统(版本),所以第三方库至少也需要支持一样多。

许可证不正确/缺失。 我们将许可文件与应用捆绑在一起,以确保我们可以合法使用代码并将其归属于许可持有人。

没有冲突的传递依赖关系。 一个库不能有一个我们已经包含但版本不同的传递依赖项。

不显示它自己的 UI 。 我们非常小心地使我们的产品看起来尽可能统一,定制用户界面对此不利。

它不使用私有 API 。 我们不愿意冒 app 因使用私有 API 而被拒绝的风险。

主要关注点

闭源。 访问源代码意味着我们可以选择我们想要包含的库的哪些部分,以及如何将该源代码与应用程序的其余部分捆绑在一起。对于我们来说,一个封闭源代码的二进制发行版更难集成。

编译时有警告。 我们启用了“警告视为错误”,具有编译警告的库是库整体质量(下降)的良好指示。

糟糕的文档。 我们希望有高质量的内联文档,外部”如何使用“文档,以及有意义的更新日志。

二进制体积。 这个库有多大?一些库提供了很多功能,而我们只需要其中的一小部分。尤其是在没有访问源码权限的情况下,这通常是一个全有或全无的情况。

外部的网络流量。 与我们无法控制的上游服务器/端点通信的库可能会在服务器关闭、错误数据被发回等时关闭整个应用程序。这也与我上面提到的隐私问题相同。

技术支持。 当事情不能正常工作时,我们需要能够报告/上报问题,并在合理的时间内解决问题。开源项目通常由志愿者维护,也很难有一个时间线,但至少我们可以自己进行修改。这在闭源项目是不可能的。

无法禁用。 虽然大多数库特别要求我们初始化它,但有些库在实例化时更“主动”,并且在我们不调用它的情况下可以自己执行工作。这意味着当库导致问题时,我们无法通过功能变量或其他机制将其关闭。

我们为所有这些(和其他一些)标准分配了点数,并要求工程师为他们想要集成的库汇总这些点数。虽然默认情况下,低分数并不难被拒绝,但我们通常会要求更多的理由来继续前进。


最后

虽然这个过程看起来非常严格,在许多情况下,潜在风险是假设的,但我们有我在这篇博文中描述的每个场景的实际例子。将评估记录下来并公开,也有助于将相对风险传达给不熟悉移动平台工作方式的人,并证明我们没有随意评估风险。

收起阅读 »

Kotlin 协程 Select:看我如何多路复用

前言协程通信三剑客:Channel、Select、Flow,上篇已经分析了Channel的深水区,本篇将会重点分析Select的使用及原理。通过本篇文章,你将了解到:Select 的引入Select 的使用Invoke函数 的妙用Select 的原理Selec...
继续阅读 »

前言

协程通信三剑客:Channel、Select、Flow,上篇已经分析了Channel的深水区,本篇将会重点分析Select的使用及原理。
通过本篇文章,你将了解到:

  1. Select 的引入
  2. Select 的使用
  3. Invoke函数 的妙用
  4. Select 的原理
  5. Select 注意事项

1. Select 的引入

多路数据的选择

串行执行

如今的二维码识别应用场景越来越广了,早期应用比较广泛的识别SDK如zxing、zbar,它们各有各的特点,也存在识别不出来的情况,为了将两者优势结合起来,我们想到的方法是同一份二维码图片分别给两者进行识别。
如下:

    //从zxing 获取二维码信息
suspend fun getQrcodeInfoFromZxing(bitmap: Bitmap?): String {
//模拟耗时
delay(2000)
return "I'm fish"
}

//从zbar 获取二维码信息
suspend fun getQrcodeInfoFromZbar(bitmap: Bitmap?): String {
delay(1000)
return "I'm fish"
}

fun testSelect() {
runBlocking {
var bitmap = null
var starTime = System.currentTimeMillis()
var qrcoe1 = getQrcodeInfoFromZxing(bitmap)
var qrcode2 = getQrcodeInfoFromZbar(bitmap)
println("qrcode1=$qrcoe1 qrcode2=$qrcode2 useTime:${System.currentTimeMillis() - starTime} ms")
}
}

查看打印,最后花费的时间:

qrcode1=I'm fish qrcode2=I'm fish useTime:3013 ms

当然这是串行的方式效率比较低,我们想到了用协程来优化它。

协程并行执行

如下:

    fun testSelect1() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}

var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}

runBlocking {
//挂起等待识别结果
var qrcoe1 = deferredZxing.await()
//挂起等待识别结果
var qrcode2 = deferredZbar.await()
println("qrcode1=$qrcoe1 qrcode2=$qrcode2 useTime:${System.currentTimeMillis() - starTime} ms")
}
}

查看打印,最后花费的时间:

qrcode1=I'm fish qrcode2=I'm fish useTime:2084 ms

可以看出,花费时间明显变少了。
与上个Demo 相比,虽然识别过程是放在协程里并行执行的,但是在等待识别结果却是串行的。我们引入两个识别库的初衷是哪个识别快就用哪个的结果,为了达成这个目的,传统的方式是:

同时监听并记录识别结果的返回。

同时监听多路结果

如下:

    fun testSelect2() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}

var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}

var isEnd = false
var result: String? = null
GlobalScope.launch {
if (!isEnd) {
//没有结束,则继续识别
var resultTmp = deferredZxing.await()
if (!isEnd) {
//识别没有结束,说明自己是第一个返回结果的
result = resultTmp
println("zxing recognize ok useTime:${System.currentTimeMillis() - starTime} ms")
//标记识别结束
isEnd = true
}
}
}

GlobalScope.launch {
if (!isEnd) {
var resultTmp = deferredZbar.await()
if (!isEnd) {
//识别没有结束,说明自己是第一个返回结果的
result = resultTmp
println("zbar recognize ok useTime:${System.currentTimeMillis() - starTime} ms")
isEnd = true
}
}
}

//检测是否有结果返回
runBlocking {
while (!isEnd) {
delay(1)
}
println("recognize result:$result")
}
}

通过检测isEnd 标记来判断是否有某个模块返回结果。
结果如下:

  • zbar recognize ok useTime:1070 ms
  • recognize result:I'm fish

由于模拟设定的zbar 解析速度快,因此每次都是采纳的是zbar的结果,所花费的时间大幅减少了,该结果符合预期。

Select 闪亮登场

虽说上个Demo结果符合预期,但是多了很多额外的代码、多引入了其它协程,并且需要子模块对标记进行赋值(对"isEnd"进行赋值),没有达到解耦的目的。我们希望子模块的任务是单一且闭环的,如果能在一个函数里统一检测结果的返回就好了。
Select 就是为了解决多路数据的选择而生的。
来看看它是怎么解决该问题的:

    fun testSelect3() {
var bitmap = null;
var starTime = System.currentTimeMillis()
var deferredZxing = GlobalScope.async {
getQrcodeInfoFromZxing(bitmap)
}
var deferredZbar = GlobalScope.async {
getQrcodeInfoFromZbar(bitmap)
}
runBlocking {
//通过select 监听zxing、zbar 结果返回
var result = select<String> {
//监听zxing
deferredZxing.onAwait {value->
//value 为deferredZxing 识别的结果
"zxing result $value"
}

//监听zbar
deferredZbar.onAwait { value->
"zbar result $value"
}
}

//运行到此,说明已经有结果返回
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}

结果如下:

result from zbar result I'm fish useTime:1079

符合预期,同时可以看出:相比上个Demo,这样写简洁了许多。

2. Select 的使用

除了可以监听async的结果,Select 还可以监听Channel的发送方/接收方 数据,我们以监听接收方数据为例:

    fun testSelect4() {
runBlocking {
var bitmap = null;
var starTime = System.currentTimeMillis()
var receiveChannelZxing = produce {
//生产数据
var result = getQrcodeInfoFromZxing(bitmap)
//发送数据
send(result)
}

var receiveChannelZbar = produce {
var result = getQrcodeInfoFromZbar(bitmap)
send(result)
}

var result = select<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive {
value->"zxing result $value"
}

receiveChannelZbar.onReceive {
value->"zbar result $value"
}
}

println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}

结果如下:

result from zbar result I'm fish useTime:1028

不论是async还是Channel,Select 都可以监听它们的数据,从而形成多路复用的效果。

image.png

在监听协程里调用select 表达式,表达式{}内声明需要监听的协程的数据,对于select 来说有两种场景:

  1. 没有数据,则select 挂起协程并等待直到其它协程数据准备完成后再次恢复select 所在的协程。
  2. 有数据,则select 正常执行并返回获取的数据。

3. Invoke函数 的妙用

在分析Select 原理之前,需要弄明白invoke函数的原理。
对于Kotlin 类来说,都可以重写其invoke函数。

    operator fun invoke():String {
return "I'm fish"
}

如上,重写了SelectDemo里的invoke函数,和普通成员函数一样,我们可以通过对象调用它。

fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo.invoke()
println("result:$result")
}

当然,可以进一步简化:

fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo()
println("result:$result")
}

这里涉及到了kotlin的语法糖:对象居然可以像函数一样调用。
作为函数,invoke 当然也可以接收高阶函数作为参数:

    operator fun invoke(block: (Int) -> String): String {
return block(3)
}

fun main(args: Array<String>) {
var selectDemo = SelectDemo()
var result = selectDemo { age ->
when (age) {
3 -> "I'm fish3"
4 -> "I'm fish4"
else -> "error"
}
}
println("result:$result")
}

因此,当看到对象作为函数调用时,实际上调用的是invoke函数,具体的逻辑需要查看其invoke函数的实现。

4. Select 的原理

上篇分析过Channel,因此本篇趁热打铁,通过Select 监听Channel数据的变化来分析其原理,为方便讲解,我们先以监听一个Channel的为例。
先从select 表达式本身入手。

    fun testSelect5() {
runBlocking {
var starTime = System.currentTimeMillis()
var receiveChannelZxing = produce {
//发送数据
send("I'm fish")
}

//确保channel 数据已经send
delay(1000)
var result = select<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive { value ->
"zxing result $value"
}
}
println("result from $result useTime:${System.currentTimeMillis() - starTime}")
}
}

select 是挂起函数,因此协程运行到此有可能被挂起。

#Select.kt
public suspend inline fun <R> select(crossinline builder: SelectBuilder<R>.() -> Unit): R {
//...
return suspendCoroutineUninterceptedOrReturn { uCont ->
//传入父协程体
val scope = SelectBuilderImpl(uCont)
try {
//执行builder
builder(scope)
} catch (e: Throwable) {
scope.handleBuilderException(e)
}
//通过返回值判断是否需要挂起协程
scope.getResult()
}
}

重点看builder(scope),builder 是高阶函数,实际上就是执行了select花括号里的内容,而它里面就是监听数据是否返回。

receiveChannelZxing.onReceive
刚开始看的时候势必以为onReceive是个函数,然而它是ReceiveChannel 里的成员变量:

#Channel.kt
public val onReceive: SelectClause1<E>

通过上一节的分析可知,关键是要找到SelectClause1 的invoke的实现。

#Select.kt
public interface SelectBuilder<in R> {
//block 有个入参
//声明了SelectClause1的扩展函数invoke
public operator fun <Q> SelectClause1<Q>.invoke(block: suspend (Q) -> R)
}

override fun <Q> SelectClause1<Q>.invoke(block: suspend (Q) -> R) {
//SelectBuilderImpl 实现了 SelectClause1 的invoke函数
registerSelectClause1(this@SelectBuilderImpl, block)
}

再看onReceive 的赋值:

#AbstractChannel.kt
final override val onReceive: SelectClause1<E>
get() = object : SelectClause1<E> {
@Suppress("UNCHECKED_CAST")
override fun <R> registerSelectClause1(select: SelectInstance<R>, block: suspend (E) -> R) {
registerSelectReceiveMode(select, RECEIVE_THROWS_ON_CLOSE, block as suspend (Any?) -> R)
}
}

因此,简单总结调用栈如下:

当调用receiveChannelZxing.onReceive{},实际上调用了SelectClause1.invoke(),而它里面又调用了SelectClause1.registerSelectClause1(),最终调用了AbstractChannel.registerSelectReceiveMode。

AbstractChannel. registerSelectReceiveMode

#AbstractChannel.kt
private fun <R> registerSelectReceiveMode(select: SelectInstance<R>, receiveMode: Int, block: suspend (Any?) -> R) {
while (true) {
//如果已经有结果了,则直接返回------->①
if (select.isSelected) return
if (isEmptyImpl) {
//没有发送者在等待,则入队等待,并返回 ------->②
if (enqueueReceiveSelect(select, block, receiveMode)) return
} else {
//直接取出值------->③
val pollResult = pollSelectInternal(select)
when {
pollResult === ALREADY_SELECTED -> return
pollResult === POLL_FAILED -> {} // retry
pollResult === RETRY_ATOMIC -> {} // retry
//调用block------->④
else -> block.tryStartBlockUnintercepted(select, receiveMode, pollResult)
}
}
}
}

分为4个点,接着来一一分析。

select 同时监听多个值,若是有1个符合要求的数据返回了,那么该isSelected 标记为true,当检测到该标记为true时直接退出。
结合之前的Demo,zbar 已经识别出结果了,当select 检测zxing的结果时直接返回。

#AbstractChannel.kt
private fun <R> enqueueReceiveSelect(
select: SelectInstance<R>,
block: suspend (Any?) -> R,
receiveMode: Int
): Boolean {
//构造为Node元素
val node = AbstractChannel.ReceiveSelect(this, select, block, receiveMode)
//添加到Channel队列里
val result = enqueueReceive(node)
if (result) select.disposeOnSelect(node)
return result

当select 时,发现Channel里没有数据,说明Channel还没有开始send,因此构造了Node(ReceiveSelect)加入到Channel queue里。当send数据时,会查找queue里是否有接收者等待,若有则调用Node(ReceiveSelect.completeResumeReceive):

#AbstractChannel.kt
override fun completeResumeReceive(value: E) {
block.startCoroutineCancellable(
if (receiveMode == RECEIVE_RESULT) ChannelResult.success(value) else value,
select.completion,
resumeOnCancellationFun(value)
)
}

block 被调度执行,最后会恢复select 协程的执行。


取出数据,并尝试恢复send协程。


在③的基础上,拿到数据后,直接执行block(此时并没有切换线程进行调度)。

小结一下select 原理:

image.png

可以看出:

select 本身执行并不耗时,若最终没有数据返回则挂起等待,若是有数据返回则不会挂起协程。

我们从头再捋一下select 配合Channel 的原理:

image.png

虽然以Channel为例讲解了select 原理,实际上async等结合select 原理大致差不多,重点都是利用了协程的挂起/恢复做文章。

5. Select 注意事项

如果select有多个数据同时到达,select 默认会选择第一个数据,若想要随机选择数据,可做如下处理:

            var result = selectUnbiased<String> {
//监听是否有数据发送过来
receiveChannelZxing.onReceive { value ->
"zxing result $value"
}
}

想要知道select 还可以监听哪些数据,可查看该数据是否实现了SelectClauseX(X 表示0、1、2)。

以上即为Select 的原理及其使用,下篇将会进入协程的精华部分:Flow的运用,该部分内容较多,可能会分几篇分析,敬请期待。

本文基于Kotlin 1.5.3,文中完整Demo请点击


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

收起阅读 »