|
|
不敢表白的海豚 · 负对数似然(NLL)和困惑度(PPL) - ...· 1 年前 · |
|
|
强悍的钥匙 · Python SQL 驅動程式 - ...· 2 年前 · |
|
|
慈祥的饺子 · 这样处理shell脚本参数,爽多了!-腾讯云 ...· 3 年前 · |
|
|
腼腆的红茶 · 【8月18日】拥抱容器实战:Docker 进 ...· 3 年前 · |
|
|
大力的牛肉面 · wcf - .Net Core 3.1 ...· 3 年前 · |
1、为什么需要代码规范任何系统性的项目都需要架构设计,而架构设计的核心命题是控制复杂度。但随着项目的不断迭代,复杂度就会不断上升,研发效率就会不断下降。而代码规范正是对抗软件复杂度的有效手段,通过约定俗成的规则,降低复杂度,提升研发效能。从团队角度来说,统一的代码规范有利于减少阅读成本和理解成本,并且能提高代码质量,长期来说,项目的稳定且可维护,也能更好更快速的支撑业务发展。走向卓越工程实践:https://code.alibaba-inc.com/coe/practice/tree/master2、代码是怎么变坏的2.1、重复的代码同一个功能,在不同的业务域,大家用同一种技术甚至不同技术都去实现了一遍,撇开复用性不说,当我们要去修改底层的逻辑实现时,上层调用有一个漏改都是莫大的风险。2.2、早期有效的决策不再有效初期,我们有能力把一段代码写的简洁且逻辑清晰,但是当业务不断迭代,逻辑就变得越来越复杂,当其他同学来接手时,是改还是不改,改了出问题谁来背锅?这是一个灵魂的拷问,然后一边叹气一边新增自己的代码。。。2.3、过早的优化过早的优化是万恶之源,为什么这么说,百万和千万级别日活的架构肯定是不一样的,架构是需要演进的,如果一开始百万级别日活就想奔着千万级别的架构去,不仅脱离了实际的业务需求,还浪费大量的人力物力财力,反而得不偿失,从实际出发,适合自己的才是最重要的。2.4、对合理性没有苛求技术方案往往都不是单一的,当我们有「能跑就行」的想法时,就会选择最简单粗暴的方案,但是往往这种方案的复用性和扩展性都很差,当历史的浪潮不断向前推进,就会演变成技术负债,后人一边叹气一边又在屎山上拉了一泡。2.5、过度设计调用链路就像老奶奶的裹脚布一样,又臭又长,一层又一层,增加理解成本,降低开发效率。2.6、没有设计没有设计也相当可怕,一个函数动辄上千行,剪不断理还乱,然后后来者又是一边叹气一边。。。2.7、排期紧曾经看到这么一个问题,为什么大厂屎山也这么多,高赞的前两个回答是这么说的:因为只允许有写一遍就成的时间因为能用就行,需求都排不过来进入一个死循环,屎山越堆越高。。3、如何让代码变好3.1、命名大到项目名、模块名、包名、对外暴露的接口,小到类名、函数名、变量名、参数名,只要是做开发,我们就逃不过「起名字」这一关。命名的好坏,对于代码的可读性来说非常重要,甚至可以说是起决定性作用的。3.1.1、命名多长最合适?有两种情况,一种命名特别短,对于代码的编写者来说,自己对代码的逻辑很清楚,总感觉用什么样的命名都可以达意,实际上,对于不熟悉你代码的同事来讲,可能就不这么认为了。另一种,命名很长,觉得命名一定要准确达意,哪怕长一点也没关系,但是,如果函数、变量的命名很长,那由它们组成的语句就会很长。在代码列长度有限制的情况下,就会经常出现一条语句被分割成两行的情况,这其实会影响代码可读性。原则上,命名是以能准确达意为目标。多换位思考,以阅读者的视角去考量命名是否够直观。3.1.2、命名要可读、可搜索什么是命名可读,指的是不要用一些特别生僻、难发音的英文单词来命名,更不要随意造词。虽然我们并不排斥一些独特的命名方式,但起码得让大部分人看一眼就能知道怎么读。而生僻、难发音的单词会严重影响交流沟通。其次是可搜索,我们在IDE中编写代码的时候,经常会用「关键词联想」的方法来自动补全和搜索。比如,键入某个对象「.get」,希望IDE返回这个对象的所有get开头的方法。3.1.3、行业规范还有一些行业通用的规范:接口前缀加「I」,表示一个Interface。比如IUserService,对应的实现类命名为UserService;弹窗后缀加「Dialog」,表示一个Dialog。比如AppUpdateDialog;工具类后缀加「Utils」,表示一个工具类。比如TrackUtils;等等;3.1.4、示例bad:fun getName(){}good:fun getUserName(){}3.2、注释命名很重要,注释跟命名同等重要。3.2.1、注释应该写什么?注释的目的就是让代码更容易看懂。只要符合这个要求的内容,你就可以将它写到注释里。比如,阐述代码的逻辑,你为什么这么做,想要达到什么样的效果等等。3.2.2、注释是不是越多越好?注释太多和太少都有问题。太多,有可能意味着代码写得不够可读,需要写很多注释来补充。除此之外,注释太多也会对代码本身的阅读起到干扰。而且,后期的维护成本也比较高,有时候代码改了,注释忘了同步修改,就会让代码阅读者更加迷惑。当然,如果代码中一行注释都没有,那只能说明这个程序员很懒,我们要适当督促一下,让他注意添加一些必要的注释。3.2.3、注释类别类函数代码块行/** * author:作者 * date:时间 * desc:描述 *//** * @param 参数 */// 描述3.2.4、示例bad: /** protected fun cancelJob(job: Job?) { if (job != null && job.isActive && !job.isCompleted && !job.isCancelled) { job.cancel() }good: /** * 取消协程 会抛出CancellationException * @param job 协程job protected fun cancelJob(job: Job?) { if (job != null && job.isActive && !job.isCompleted && !job.isCancelled) { job.cancel() }3.3、代码风格3.3.1、函数、类多大才合适?函数的代码行数不要超过一屏幕的大小,比如50行。3.3.2、一行代码多长最合适?最好不要超过IDE的显示宽度。当然,也不能太小,否则会导致很多稍微长点的语句被折成两行,也会影响到代码的整洁,不利于阅读。3.3.3、善用空行分割单元块对于比较长的函数,为了让逻辑更加清晰,可以使用空行来分割各个代码块。除此之外,在类的成员变量与函数之间、静态成员变量与普通成员变量之间、各函数之间、甚至各成员变量之间,我们都可以通过添加空行的方式,让这些不同模块的代码之间,界限更加明确。写代码就类似写文章,善于应用空行,可以让代码的整体结构看起来更加有清晰、有条理。3.3.4、格式化使用统一的格式化规则,比如空格、换行等,格式化规则不统一,容易引起不必要的变更,不利于代码评审和历史变更查询。3.3.5、示例bad:AlertDialog.Builder(context).setView(0).setTitle(R.string.dialog_title).setMessage(R.string.dialog_message).setIcon(0) .create()good:AlertDialog.Builder(context) .setView(0) .setTitle(R.string.dialog_title) .setMessage(R.string.dialog_message) .setIcon(0) .create()3.4、编码技巧3.4.1、把代码分割成更小的单元块大部分人阅读代码的习惯都是,先看整体再看细节。所以,我们要有模块化和抽象思维,善于将大块的复杂逻辑提炼成类或者函数,屏蔽掉细节,让阅读代码的人不至于迷失在细节中,这样能极大地提高代码的可读性。不过,只有代码逻辑比较复杂的时候,我们其实才建议提炼类或者函数。毕竟如果提炼出的函数只包含两三行代码,在阅读代码的时候,还得跳过去看一下,这样反倒增加了阅读成本。3.4.2、避免函数参数过多我个人觉得,函数包含3、4个参数的时候还是能接受的,大于等于5个的时候,我们就觉得参数有点过多了,会影响到代码的可读性,使用起来也不方便。一般有2种处理方法:考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。将函数的参数封装成对象。3.4.3、函数设计要职责单一我们在前面讲到单一职责原则的时候,针对的是类、模块这样的应用对象。实际上,对于函数的设计来说,更要满足单一职责原则。相对于类和模块,函数的粒度比较小,代码行数少,所以在应用单一职责原则的时候,没有像应用到类或者模块那样模棱两可,能多单一就多单一。整洁的代码只做好一件事,干脆利落,直接了当,易于阅读,易于维护。3.4.4、移除过深的嵌套层级代码嵌套层级过深往往是因为if-else、switch-case、for循环过度嵌套导致的。我个人建议,嵌套最好不超过两层,超过两层之后就要思考一下是否可以减少嵌套。过深的嵌套本身理解起来就比较费劲,除此之外,嵌套过深很容易因为代码多次缩进,导致嵌套内部的语句超过一行的长度而折成两行,影响代码的整洁。针对层级嵌套过深的代码可以使用多态简化逻辑,移除不必要的if或else,也可以使用策略模式,提前return退出嵌套等。3.4.5、少即是多无形装逼最为致命:过度设计:有的同学为了炫技,各种设计模式咔咔往上整,反而增加复杂度;隐式耦合:这种是想炫技但功力不够的,设计的不够优雅反而留下后遗症;建筑师米斯.凡德洛曾说过,less is more,提倡简单,反对度装饰的设计理念。简单的东西往往带给人们的是更多的享受。4、客户端的技术栈上面介绍了一些通用的规范,然而时代在变化,技术在演进,客户端的技术更新也是日新月异,因此也需要针对不同的技术栈有系统性的规约及代码风格。比如:Java的文件名遵循驼峰命名法,而在Flutter中文件名使用下划线隔开;Java和Kotlin,OC和Swift,不仅有类型推导上的区别,还有一些语法糖的特性;等等;下面是端上现有的一些技术栈:端语言规范文档AndroidJava集团Android开发规约Google Java Style GuideKotlinKotlin 编码规约iOSObjective-CCoding Guidelines for Cocoa集团iOS开发规约SwiftSwift开发规约Swift Style Guide跨端Flutter闲鱼核心Liner规则Effective DartMIST(json -> TypeScript)Google TypeScript Style Guide其他配置文件(xml、yml,json)Google XML Document Format Style Guide脚本、插件(Python、Shell、Groovy)Shell Style GuidePython Style Guide阿里巴巴开发规约:https://ata.alibaba-inc.com/articles/50331#ax_4d3h2google开源规约:https://google.github.io/styleguide/5、治理手段5.1、检测工具通过一些类似Lint之类的检测工具,在编写阶段通过警告,把不符合规范的代码扼杀在摇篮里。灵狐(Aone-Idea):https://ide.alibaba-inc.com/?spm=ata.21736010.0.0.46207c7afSs5LC5.2、代码评审代码评审也称code review,俗称cr,cr的意义在于,作为局中人,尽管我们在编写代码的时候小心翼翼,但也可能会在无意间犯下一个小错误,而此时cr的人作为旁观者,可有一语点醒梦中人的效果,而且从实际问题出发产生思想的碰撞,相互学习,也有利于提升团队整体的编码水平。5.3、扫码行动我们端上正在做一些代码的治理,删除无用代码,下线老代码,比如原有的灰度校验在全量之后理应下线老的逻辑代码。5.4、代码重构我们也正在做模块化的重构治理,把以前设计不合理或者不满足现状诉求的地方做改进和优化。6、最后治理行动我们可以一年来一次,但这很明显不是最好的解决办法,代码是人写的,工具是辅助是底线,如何长治久安,还是要从根源上着手下功夫,这就需要我们团结一致,从思想上认可,从行动上落实,认真做好code review,始终对代码保持敬畏,对自己的代码负责,做个有信仰有追求的程序员。7、相关书籍代码整洁之道架构整洁之道编程珠玑重构·改善既有代码的设计设计模式之美
1、效果2、前言在开发中,随意拖拽可吸边的View还是比较常见的,这种功能网上也有各种各样的轮子,其实写起来并不复杂,看完本文,你也可以手写一个,不到400行代码就能实现一个通用的随意拖拽可吸边的View组件。3、功能拆解4、功能实现4.1、基础实现4.1.1、自定义view类先定义一个FloatView类,继承自FrameLayout,实现构造方法。创建一个ShapeableImageView,并添加到这个FloatView中。class FloatView : FrameLayout { constructor(context: Context) : this(context, null) constructor(context: Context, attributeSet: AttributeSet?) : this(context, attributeSet, 0) constructor(context: Context, attributeSet: AttributeSet?, defStyle: Int) : super(context, attributeSet, defStyle) { initView() private fun initView() { val lp = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) layoutParams = lp val imageView = ShapeableImageView(context) imageView.setImageResource(R.mipmap.ic_avatar) addView(imageView) }4.1.2、添加到window在页面的点击事件中,通过decorView把这个FloatView添加到window中mBinding.btnAddFloat.setOnClickListener { val contentView = this.window.decorView as FrameLayout contentView.addView(FloatView(this)) }来看下效果:默认在左上角,盖住了标题栏,也延伸到了状态栏,不是很美观。从这个视图层级关系中可以看出,我们是把FloatView添加到decorView的根布局(rootView)里面了,实际下面还有一层contentView,contentView是不包含状态栏、导航栏和ActionBar的。我们改一下添加的层级(content):val contentView = this.window.decorView.findViewById(android.R.id.content) as FrameLayout contentView.addView(FloatView(this))再看下效果:此时,是默认显示在状态栏下面了,但还是盖住了标题栏。这是因为标题栏是在activity的layout中加的toolbar,不是默认的ActionBar,app主题是Theme.Material3.DayNight.NoActionBar,所以显示效果其实是正确的。手动加上ActionBar看看效果:这就验证了我们之前的论点了。不管我们添加的根布局是rootView还是contentView,实际上可能都有需求不要盖住原有页面上的某些元素,这时候可以通过margin或者x/y坐标位置来限制view显示的位置。4.1.3、视图层级关系4.2、拖拽4.2.1、View.OnTouchListener实现View.OnTouchListener接口,重写onTouch方法,在onTouch方法中根据拖动的坐标实时修改view的位置。 override fun onTouch(v: View, event: MotionEvent): Boolean { val x = event.x val y = event.y when (event.action) { MotionEvent.ACTION_DOWN -> { mDownX = event.x mDownY = event.y MotionEvent.ACTION_MOVE -> { offsetTopAndBottom((y - mDownY).toInt()) offsetLeftAndRight((x - mDownX).toInt()) MotionEvent.ACTION_UP -> { return true }MotionEvent.ACTION_DOWN 手指按下MotionEvent.ACTION_MOVE 手指滑动MotionEvent.ACTION_UP 手指抬起效果:ok,这就实现随意拖拽了。4.2.2、动态修改view坐标上面我们修改view坐标用的是offsetTopAndBottom和offsetLeftAndRight,分别是垂直方向和水平方向的偏移,当然也还有别的方式可以修改坐标view.layout()view.setX/view.setYview.setTranslationX/view.setTranslationYlayoutParams.topMargin...offsetTopAndBottom/offsetLeftAndRight4.2.3、view坐标系上面我们获取坐标用的是event.x,实际上还有event.rawX,他们的区别是什么,view在视图上的坐标又是怎么定义的?搞清楚了这些,在做偏移计算时,就能达到事半功倍的效果,省去不必要的调试工作。一图胜前言:4.3、吸边吸边的场景基本可以分为两种:上下吸边左右吸边要么左右吸,要么上下吸,上下左右同时吸一般是违背交互逻辑的(四象限),用户也会觉得很奇怪。吸边的效果其实就是当手指抬起(MotionEvent.ACTION_UP)的时候,根据滑动的距离,以及初始的位置,来决定view最终的位置。比如默认在顶部,向下滑动的距离不足半屏,那就还是吸附在顶部,超过半屏,则自动吸附在底部,左右同理。4.3.1、上下吸边计算公式:1.上半屏: 1.1.滑动距离<半屏=吸顶 1.2.滑动距离>半屏=吸底 2.下半屏: 2.1.滑动距离<半屏=吸底 2.2.滑动距离>半屏=吸顶先看下效果:可以看到基础效果我们已经实现了,但是顶部盖住了ToolBar,底部也被NavigationBar遮住了,我们再优化一下,把ToolBar和NavigationBar的高度也计算进去。看下优化后的效果:这样看起来就好很多了。上图效果最终代码: private fun adsorbTopAndBottom(event: MotionEvent) { if (isOriginalFromTop()) { // 上半屏 val centerY = mViewHeight / 2 + abs(event.rawY - mFirstY) if (centerY < getScreenHeight() / 2) { //滑动距离<半屏=吸顶 val topY = 0f + mToolBarHeight animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(topY).start() } else { //滑动距离>半屏=吸底 val bottomY = getContentHeight() - mViewHeight animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(bottomY.toFloat()).start() } else { // 下半屏 val centerY = mViewHeight / 2 + abs(event.rawY - mFirstY) if (centerY < getScreenHeight() / 2) { //滑动距离<半屏=吸底 val bottomY = getContentHeight() - mViewHeight animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(bottomY.toFloat()).start() } else { //滑动距离>半屏=吸顶 val topY = 0f + mToolBarHeight animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(topY).start() }4.3.2、左右吸边计算公式:1.左半屏: 1.1.滑动距离<半屏=吸左 1.2.滑动距离>半屏=吸右 2.右半屏: 2.1.滑动距离<半屏=吸右 2.2.滑动距离>半屏=吸左看下效果:左右吸边的效果相对上下吸边来说要简单些,因为不用计算ToolBar和NavigationBar,计算逻辑与上下吸边相通,只不过参数是从屏幕高度变为屏幕宽度,Y轴变为X轴。代码: private fun adsorbLeftAndRight(event: MotionEvent) { if (isOriginalFromLeft()) { // 左半屏 val centerX = mViewWidth / 2 + abs(event.rawX - mFirstX) if (centerX < getScreenWidth() / 2) { //滑动距离<半屏=吸左 val leftX = 0f animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(leftX).start() } else { //滑动距离<半屏=吸右 val rightX = getScreenWidth() - mViewWidth animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(rightX.toFloat()).start() } else { // 右半屏 val centerX = mViewWidth / 2 + abs(event.rawX - mFirstX) if (centerX < getScreenWidth() / 2) { //滑动距离<半屏=吸右 val rightX = getScreenWidth() - mViewWidth animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(rightX.toFloat()).start() } else { //滑动距离<半屏=吸左 val leftX = 0f animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(leftX).start() }5、进阶封装为什么要封装一下呢,因为现在的计算逻辑、参数配置都是在FloatView这一个类里,定制化太强反而不具备通用性,可以进行一个简单的抽取封装,向外暴露一些配置和接口,这样在其他的业务场景下也可以复用,避免重复造轮子。5.1、View封装5.1.1、BaseFloatView把FloatView改成BaseFloatView,然后把一些定制化的能力交给子view去实现。这里列举了3个方法: /** * 获取子view protected abstract fun getChildView(): View * 是否可以拖拽 protected abstract fun getIsCanDrag(): Boolean * 吸边的方式 protected abstract fun getAdsorbType(): Int 5.1.2、子viewclass AvatarFloatView(context: Context) : BaseFloatView(context) { override fun getChildView(): View { val imageView = ShapeableImageView(context) imageView.setImageResource(R.mipmap.ic_avatar) return imageView override fun getIsCanDrag(): Boolean { return true override fun getAdsorbType(): Int { return ADSORB_VERTICAL }这样稍微抽一下,代码看起来就简洁很多了,只需要配置一下就可以拥有随意拖拽的能力了。5.2、调用封装5.2.1、管理类新建一个FloatManager的管理类,它来负责FloatView的显示隐藏,以及回收逻辑。设计模式还是使用单例,我们需要在这个单例类里持有Activity,因为需要通过Activity的window获取decorView然后把FloatView添加进去,但是Activity与单例的生命周期是不对等的,这就很容易造成内存泄露。怎么解?也好办,管理一下activity的生命周期就好了。在之前分析LifecycleScope源码的文章中有提到关于Activity生命周期的管理,得益于lifecycle的强大,这个问题解起来也变得更简单。 private fun addLifecycle(activity: ComponentActivity?) { activity?.lifecycle?.addObserver(mLifecycleEventObserver) private var mLifecycleEventObserver = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_DESTROY) { hide() fun hide() { if (::mContentView.isInitialized && mFloatView != null && mContentView.contains(mFloatView!!)) { mContentView.removeView(mFloatView) mFloatView?.release() mFloatView = null mActivity?.lifecycle?.removeObserver(mLifecycleEventObserver) mActivity = null }添加生命周期的监听在ON_DESTROY的时候处理回收逻辑5.2.2、FloatManager完整代码@SuppressLint("StaticFieldLeak") object FloatManager { private lateinit var mContentView: FrameLayout private var mActivity: ComponentActivity? = null private var mFloatView: BaseFloatView? = null fun with(activity: ComponentActivity): FloatManager { mContentView = activity.window.decorView.findViewById(android.R.id.content) as FrameLayout mActivity = activity addLifecycle(mActivity) return this fun add(floatView: BaseFloatView): FloatManager { if (::mContentView.isInitialized && mContentView.contains(floatView)) { mContentView.removeView(floatView) mFloatView = floatView return this fun setClick(listener: BaseFloatView.OnFloatClickListener): FloatManager { mFloatView?.setOnFloatClickListener(listener) return this fun show() { checkParams() mContentView.addView(mFloatView) private fun checkParams() { if (mActivity == null) { throw NullPointerException("You must set the 'Activity' params before the show()") if (mFloatView == null) { throw NullPointerException("You must set the 'FloatView' params before the show()") private fun addLifecycle(activity: ComponentActivity?) { activity?.lifecycle?.addObserver(mLifecycleEventObserver) private var mLifecycleEventObserver = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_DESTROY) { hide() fun hide() { if (::mContentView.isInitialized && mFloatView != null && mContentView.contains(mFloatView!!)) { mContentView.removeView(mFloatView) mFloatView?.release() mFloatView = null mActivity?.lifecycle?.removeObserver(mLifecycleEventObserver) mActivity = null }5.2.3、调用方式显示FloatManager.with(this).add(AvatarFloatView(this)).show()隐藏FloatManager.hide()带点击事件FloatManager.with(this).add(AvatarFloatView(this)) .setClick(object : BaseFloatView.OnFloatClickListener { override fun onClick(view: View) { Toast.makeText(this@FloatViewActivity, "click", Toast.LENGTH_SHORT).show() .show()6、Githubhttps://github.com/yechaoa/MaterialDesign7、最后写作不易,且看且珍惜啊喂~
1、前言曾经产品经理的奇思妙想,现在终于可以实现了…2、效果效果中,原本是红色壁纸对应的红色App主题,在改成绿色壁纸之后,App主题也相应的变成绿色了。模拟器演示略微小卡…3、介绍这个效果主要是使用了Material You中的动态颜色功能。3.1、什么是Material YouMaterial You也称Material3或M3,是第三代Material Design设计语言。Material You将颜色重新定义为更加个性化的体验,助力于构建出色且富有表现力的应用。3.2、什么是动态颜色动态颜色(Dynamic Color)是Material You的关键部分,通过动态配色提取算法从用户的壁纸中派生出颜色方案,且符合无障碍使用的标准,亦或是自定义的个性化颜色方案,然后应用于系统UI和应用程序,从而让设备变得更加个性化。3.3、动态颜色的原理是什么首先,从用户的壁纸上提取一种源颜色,并推算出5中关键颜色,比如primary、Secondary、Tertiary等。然后,将每个关键颜色转化为由13种色调组成的调色板,且每种色调会生成对应的浅、深色方案。最后,通过系统Token将从壁纸提取的颜色方案和App Theme关联起来,在DynamicColorsActivityLifecycleCallbacks中判断是否需要覆盖。4、实现4.1、迁移到M34.1.1、更新Gradle依赖升级material库版本到1.5.0及以上implementation 'com.google.android.material:material:1.5.0'4.1.2、修改版本修改compileSdkVersion和targetSdkVersion到31及以上android { compileSdkVersion 31 defaultConfig { applicationId "com.yechaoa.materialdesign" minSdkVersion 23 targetSdkVersion 31 }全性,需要对所有的四大组件(带有intent-filter)添加android:exported属性。4.1.3、修改App主题将Theme.AppCompat.*或Theme.MaterialComponents.* 改为 Theme.Material3.* <style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar"> </style>4.2、添加动态颜色4.2.1、应用动态颜色class App: Application() { class App: Application() { override fun onCreate() { super.onCreate() // apply dynamic color DynamicColors.applyToActivitiesIfAvailable(this) }在Application中应用动态颜色能力,除applyToActivitiesIfAvailable(@NonNull Application application)方法之外主要还有:applyToActivitiesIfAvailable(@NonNull Application application, @StyleRes int theme) 第二个参数表示自定义的覆盖系统的themeapplyIfAvailable(@NonNull Activity activity) 或是某一个ActivityapplyIfAvailable(@NonNull Activity activity, @StyleRes int theme) 或是只有某一个Activity覆盖等等这里直接整个App都使用动态颜色。也可以在App中通过开关的形式来决策。4.2.2、注册到manifest通过android:name来注册我们自定义的Application <application android:name=".App" android:theme="@style/AppTheme"> </application>4.2.3、使用动态颜色我们过去在使用颜色时可能是硬编码,比如这样:android:background="@color/colorPrimary"现在颜色的色值不确定了,应改为动态访问的方式:android:background="?attr/colorPrimary"OK,至此已经可以在Android 12 上试试动态颜色的效果了。Author:yechaoa4.3、Material Theme Builder在4.2.1中,我们提到applyIfAvailable(@NonNull Activity activity, @StyleRes int theme) ,可以通过第二个参数只给某一个Activity覆盖Theme。场景还是有很多的,比如某个特殊的页面就是需要不一样的主题,再比如在国庆节这天App的首页一定要是红色的。官方其实也考虑到了这种场景,并提供了Material Theme Builder(Figma&Web)工具进行可视化自定义。下面主要介绍Material Theme Builder在Web上的使用。4.3.1、壁纸预览截图是自带的效果,在右下角可以点击add your wallpaper添加你的壁纸并查看显示效果。4.3.2、自定义Theme点击顶部的CUSTOM Tab可以进入自定义页面。左边的核心颜色可以自定义修改,并可以添加扩展颜色。中间是实时效果,右边是调色板。在右上角有一个EXPORT导出按钮除了可以应用Android,还有Flutter和Web。导出的文件包含日间模式和夜间模式的Color和Theme文件,可以直接copy到项目中使用。4.3.3、Theme覆盖基于规范,覆盖的主题最好是加上overlay标示,比如: <style name="AppTheme.Overlay" parent="ThemeOverlay.Material3.DynamicColors.DayNight"> <item name="colorPrimary">@color/overlay_colorPrimary</item> </style>代码应用:DynamicColors.applyToActivitiesIfAvailable(this, R.style.AppTheme.Overlay)也可以添加一个开关,让用户可以在动态颜色和自定义颜色来回切换。4.4、是否支持动态颜色基于Android的开源,可能并不是每个厂商的Android12设备都支持动态颜色。所以官方也提供了Api进行判断:@SuppressLint("DefaultLocale") @ChecksSdkIntAtLeast(api = VERSION_CODES.S) public static boolean isDynamicColorAvailable() { if (VERSION.SDK_INT < VERSION_CODES.S) { return false; DeviceSupportCondition deviceSupportCondition = DYNAMIC_COLOR_SUPPORTED_MANUFACTURERS.get(Build.MANUFACTURER.toLowerCase()); if (deviceSupportCondition == null) { deviceSupportCondition = DYNAMIC_COLOR_SUPPORTED_BRANDS.get(Build.BRAND.toLowerCase()); return deviceSupportCondition != null && deviceSupportCondition.isSupported(); }截止目前,跟进的厂商有: deviceMap.put("oppo", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("realme", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("oneplus", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("vivo", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("xiaomi", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("motorola", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("itel", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("tecno mobile limited", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("infinix mobility limited", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("hmd global", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("sharp", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("sony", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("tcl", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("lenovo", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("lge", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("google", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("robolectric", DEFAULT_DEVICE_SUPPORT_CONDITION); deviceMap.put("samsung", SAMSUNG_DEVICE_SUPPORT_CONDITION); 5、总结总的来说,这个功能还是非常nice的,可以让你的App在Android12上表现的更加个性化且富有表现力。但还是能识别出很多成本的,比如targetSdkVersion升级到31,Theme迁移到Material3,以及升级后的整包回归。所以,个人建议,对于老项目最好是申请专项来做,其实更建议用新项目来尝鲜。6、Githubhttps://github.com/yechaoa/MaterialDesign7、文档Migrating to Material Design 3Material Design 3Material Theme BuilderDynamicColors8、最后写作不易,感谢点赞支持 ^ - ^
前言为什么会有这篇文章呢,是因为之前关于TabLayout的使用陆陆续续也写了好几篇了,感觉比较分散,且不成体系,写这篇文章的目的就是希望能把各种效果的实现一次性讲齐,所以也有了标题的「看这篇就够了」。TabLayout作为导航组件来说,使用场景非常的多,也意味着要满足各种各样的需求。在效果实现上,有同学会选择自定义View来做,定制性高,但易用性、稳定性、维护性不敢保证,使用官方组件能避免这些不确定性,一是开源,有很多大佬共建,会不停的迭代;二是经过大型app验证,比如google play;有了这两点,基本可以放心大胆的使用官方组件了。那可能有的同学又会说,道理我都懂,可是不满足需求啊,只能自定义了。是的,早期的api确实不够丰富,在某些需求的实现上显得捉襟见肘,但是google也在不断的迭代,目前为止,常见的样式都能满足。效果图简介TabLayout:一个横向可滑动的菜单导航ui组件Tab:TabLayout中的item,可以通过newTab()创建TabView:Tab的实例,是一个包含ImageView和TextView的线性布局TabItem:一种特殊的“视图”,在TabLayout中可以显式声明Tab官方文档功能拆解Material Design 组件最新正式版依赖:implementation 'com.google.android.material:material:1.5.0'1.基础实现1.1 xml动态写法 <com.google.android.material.tabs.TabLayout android:id="@+id/tab_layout1" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/white" app:tabIndicatorColor="@color/colorPrimary" app:tabMaxWidth="200dp" app:tabMinWidth="100dp" app:tabMode="fixed" app:tabSelectedTextColor="@color/colorPrimary" app:tabTextColor="@color/gray" />只写一个Layout,item可以配合ViewPager来生成。1.2 xml静态写法<com.google.android.material.tabs.TabLayout android:layout_height="wrap_content" android:layout_width="match_parent"> <com.google.android.material.tabs.TabItem android:text="@string/tab_text"/> <com.google.android.material.tabs.TabItem android:icon="@drawable/ic_android"/> </com.google.android.material.tabs.TabLayout>属于固定写法,比如我们非常确定item有几个,可以通过TabItem显式声明。1.3 kotlin/java代码写法 val tab = mBinding.tabLayout7.newTab() tab.text = it.key //... mBinding.tabLayout7.addTab(tab)这种情况适合Tab的数据是动态的,比如接口数据回来之后,再创建Tab并添加到TabLayout中。2.添加图标mBinding.tabLayout2.getTabAt(index)?.setIcon(R.mipmap.ic_launcher)获取Tab然后设置icon。Tab内部其实是一个TextView和ImageView,添加图标就是给ImageView设置icon。3.字体大小、加粗通过app:tabTextAppearance给TabLayout设置文本样式 <com.google.android.material.tabs.TabLayout app:tabTextAppearance="@style/MyTabLayout" />style:<style name="MyTabLayout"> <item name="android:textSize">20sp</item> <item name="android:textStyle">bold</item> <item name="android:textAllCaps">false</item> </style>比如这里设置了字体大小和加粗。默认字体大小14sp:<dimen name="design_tab_text_size">14sp</dimen>4.去掉Tab长按提示文字长按Tab时会有一个提示文字,类似Toast一样。/** * 隐藏长按显示文本 private fun hideToolTipText(tab: TabLayout.Tab) { // 取消长按事件 tab.view.isLongClickable = false // api 26 以上 设置空text if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { tab.view.tooltipText = "" }可以取消长按事件,在api26以上也可以设置提示文本为空。5.去掉下划线indicatorapp:tabIndicatorHeight="0dp"设置高度为0即可。注意,单纯设置tabIndicatorColor为透明,其实不准确,默认还是有2dp的,根本瞒不过射鸡师的眼睛。6.下划线的样式通过app:tabIndicator可以设置自定义的样式,比如通过shape设置圆角和宽度。<com.google.android.material.tabs.TabLayout app:tabIndicator="@drawable/shape_tab_indicator" app:tabIndicatorColor="@color/colorPrimary" />注意:Indicator的颜色在shape中设置是无效的,需要通过app:tabIndicatorColor设置才可以shape:<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item android:width="15dp" android:height="5dp" android:gravity="center"> <shape> <corners android:radius="5dp" /> <!--color无效,源码用tabIndicatorColor--> <solid android:color="@color/colorPrimary" /> </shape> </item> </layer-list>7.下划线的宽度默认情况下,tabIndicator的宽度是填充整个Tab的,比如上图中的第一个,我们可以简单的设置不填充,与文本对齐,即第二个效果app:tabIndicatorFullWidth="false"也可以像上一节那样,通过shape自定义tabIndicator的宽度。8.Tab分割线/** A {@link LinearLayout} containing {@link Tab} instances for use with {@link TabLayout}. */ public final class TabView extends LinearLayout { }通过源码可以看到内部实现TabView继承至LinearLayout,我们知道LinearLayout是可以给子view设置分割线的,那我们就可以通过遍历来添加分割线 //设置 分割线 for (index in 0..mBinding.tabLayout4.tabCount) { val linearLayout = mBinding.tabLayout4.getChildAt(index) as? LinearLayout linearLayout?.let { it.showDividers = LinearLayout.SHOW_DIVIDER_MIDDLE it.dividerDrawable = ContextCompat.getDrawable(this, R.drawable.shape_tab_divider) it.dividerPadding = 30 }shape_tab_divider:<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <solid android:color="@color/colorPrimary" /> <size android:width="1dp" android:height="10dp" /> </shape>9.TabLayout样式上图中的效果其实是TabLayout样式+tabIndicator样式形成的一个「整体」的效果。TabLayout是两边半圆的一个长条,这个我们通过编写shape设置给其背景即可实现。shape_tab_bg:<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <corners android:radius="999dp" /> <solid android:color="@color/colorPrimary" /> </shape>这个效果的关键在于tabIndicator的高度与TabLayout的高度相同,所以二者高度设置一致即可。shape_full_tab_indicator:<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item android:gravity="center" android:top="0.5dp" android:bottom="0.5dp"> <shape> <!-- 上下边距合计1dp 高度减少1dp --> <size android:height="41dp" /> <corners android:radius="999dp" /> <solid android:color="@color/white" /> </shape> </item> </layer-list>TabLayout: <com.google.android.material.tabs.TabLayout android:id="@+id/tab_layout6" android:layout_width="wrap_content" android:layout_height="42dp" android:layout_gravity="center" android:layout_marginTop="10dp" android:background="@drawable/shape_tab_bg" app:tabIndicator="@drawable/shape_full_tab_indicator" app:tabIndicatorColor="@color/white" app:tabIndicatorFullWidth="true" app:tabIndicatorHeight="42dp" app:tabMinWidth="96dp" app:tabMode="fixed" app:tabSelectedTextColor="@color/colorPrimary" app:tabTextColor="@color/black" />10.Tab添加小红点添加小红点的功能还是比较常见的,好在TabLayout也提供了这种能力,其实添加起来也非常简单,难在未知。可以设置带数字的红点,也可以设置没有数字单纯的一个点。通过getOrCreateBadge可以对红点进行简单的配置: // 数字 mBinding.tabLayout5.getTabAt(defaultIndex)?.let { tab -> tab.orCreateBadge.apply { backgroundColor = Color.RED maxCharacterCount = 3 number = 99999 badgeTextColor = Color.WHITE // 红点 mBinding.tabLayout5.getTabAt(1)?.let { tab -> tab.orCreateBadge.backgroundColor = ContextCompat.getColor(this, R.color.orange) }getOrCreateBadge实际上是获取或创建BadgeDrawable。通过源码发现,BadgeDrawable除了TabLayout引用之外,还有NavigationBarItemView、NavigationBarMenuView、NavigationBarView,意味着它们也同样具备着小红点这种能力。其实别的view也是可以具备的。关于小红点这里就不展开了,非常推荐查看我之前写的这篇:【涨姿势】你没用过的BadgeDrawableAuthor:yechaoa11.获取隐藏的Tab上一节中我们实现了小红点效果,那如果一屏显示不够的情况下,如何提示未展示的信息呢,比如上面我们如何把未显示的tab且有数字的Tab提示出来呢?常见的解决方案都是在尾部加一个红点提示。那么问题来了,如何判断某一个Tab是否可见呢,翻看了源码,可惜并没有提供相应的api,那只能我们自己实现了。我们前面添加小红点是根据Tab添加的,Tab内部实现也是一个view,那view就可以判断其是否可见。 private fun isShowDot(): Boolean { var showIndex = 0 var tipCount = 0 companyMap.keys.forEachIndexed { index, _ -> mBinding.tabLayout7.getTabAt(index)?.let { tab -> val tabView = tab.view as LinearLayout val rect = Rect() val visible = tabView.getLocalVisibleRect(rect) // 可见范围小于80%也在计算范围之内,剩下20%宽度足够红点透出(可自定义) if (visible && rect.right > tab.view.width * 0.8) { showIndex = index } else { //if (index > showIndex) // 任意一个有count的tab隐藏就会显示,比如第一个在滑动过程中会隐藏,也在计算范围之内 if (index > lastShowIndex) { // 只检测右侧隐藏且有count的tab 才在计算范围之内 tab.badge?.let { tipCount += it.number } lastShowIndex = showIndex return tipCount > 0 }上面的方法中就是判断是否需要显示右侧提示的小红点。计算规则:Tab不可见,且Tab上的红点数字大于0的即在计算范围之内。这里有一个优化的点,比如上图中的“腾讯”Tab,它是可见的,但是红点不可见,那么问题就来了,如果我们没有提示到,是很容易产生客诉的,所以这里在计算的时候也加了一个条件,就是可见范围小于80%也在计算范围之内,剩下20%的宽度是足够Tab上的红点透出的(也可自定义)。同时在TabLayout滑动的过程中也应该加上判断显示的逻辑: // mBinding.tabLayout7.setOnScrollChangeListener() // min api 23 (6.0) // 适配 5.0 滑动过程中判断右侧小红点是否需要显示 mBinding.tabLayout7.viewTreeObserver.addOnScrollChangedListener { mBinding.vArrowDot.visibility = if (isShowDot()) View.VISIBLE else View.INVISIBLE }还有初始化时的判断逻辑: override fun onResume() { super.onResume() // 初始化判断右侧小红点是否需要显示 mBinding.tabLayout7.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { mBinding.vArrowDot.visibility = if (isShowDot()) View.VISIBLE else View.INVISIBLE mBinding.tabLayout7.viewTreeObserver.removeOnGlobalLayoutListener(this) }12.Tab宽度自适应细心的同学会发现,这个TabLayout的item左右间距都是一样的,不管标题是两个字还是四个字的,左右间距都是相等的,而实际上的效果是两个字的Tab要比四个字的Tab左右间距要大一些的,那这个效果是怎么实现的呢?实际上是我们设置了tabMinWidth:app:tabMinWidth="50dp"源码中默认的是:private int getTabMinWidth() { if (requestedTabMinWidth != INVALID_WIDTH) { // If we have been given a min width, use it return requestedTabMinWidth; // Else, we'll use the default value return (mode == MODE_SCROLLABLE || mode == MODE_AUTO) ? scrollableTabMinWidth : 0; }requestedTabMinWidth是根据xml设置获取的。假如xml没设置tabMinWidth的情况下,且tabMode是scrollable的情况下,会返回默认配置,否则为0,即tabMode为fixed的情况。系统默认配置scrollableTabMinWidth:<dimen name="design_tab_scrollable_min_width">72dp</dimen>在两个字和四个字的标题都存在的情况下,两个字用这个默认宽度就会有多余的间距,所以会出现间距不均等的情况,通过设置覆盖默认即可解决。13.自定义Item View前面讲到Tab内部实现是一个View,那我们就可以通过官方提供api(setCustomView)来自定义这个view。setCustomView的两种方式:public Tab setCustomView(@Nullable View view)public Tab setCustomView(@LayoutRes int resId)我们先编写一个自定义的布局文件,布局文件比较简单,一个LottieAnimationView和TextView。再通过Tab添加进去即可。 val animMap = mapOf("party" to R.raw.anim_confetti, "pizza" to R.raw.anim_pizza, "apple" to R.raw.anim_apple) animMap.keys.forEach { s -> val tab = mBinding.tabLayout8.newTab() val view = LayoutInflater.from(this).inflate(R.layout.item_tab, null) val imageView = view.findViewById<LottieAnimationView>(R.id.lav_tab_img) val textView = view.findViewById<TextView>(R.id.tv_tab_text) imageView.setAnimation(animMap[s]!!) imageView.setColorFilter(Color.BLUE) textView.text = s tab.customView = view mBinding.tabLayout8.addTab(tab) }14.使用LottieLottie是一个可以在多平台展示动画的库,相信很多同学都已经用过了,就不详细展开了,感兴趣的可以查看Lottie官方文档。Lottie依赖:implementation "com.airbnb.android:lottie:5.0.1"上一节中我们实现了自定义TabLayout的Item View,在这个自定义的布局中,我们用LottieAnimationView来承载动画的展示。<?xml version="1.0" encoding="utf-8"?> <androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/item_tab" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:orientation="vertical"> <com.airbnb.lottie.LottieAnimationView android:id="@+id/lav_tab_img" android:layout_width="30dp" android:layout_height="30dp" app:lottie_colorFilter="@color/black" app:lottie_rawRes="@raw/anim_confetti" /> <TextView android:id="@+id/tv_tab_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/app_name" android:textColor="@color/black" android:textSize="14sp" /> </androidx.appcompat.widget.LinearLayoutCompat>添加的方式也在上一节中讲过了,我们只需要控制好选中、未选中的状态即可。 mBinding.tabLayout8.addOnTabSelectedListener(object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { tab?.setSelected() tab?.let { mBinding.viewPager.currentItem = it.position } override fun onTabUnselected(tab: TabLayout.Tab?) { tab?.setUnselected() override fun onTabReselected(tab: TabLayout.Tab?) { })这里通过两个扩展方法分别处理不同的状态。选中状态,播放动画并设置icon颜色/** * 选中状态 fun TabLayout.Tab.setSelected() { this.customView?.let { val textView = it.findViewById<TextView>(R.id.tv_tab_text) val selectedColor = ContextCompat.getColor(this@TabLayoutActivity, R.color.colorPrimary) textView.setTextColor(selectedColor) val imageView = it.findViewById<LottieAnimationView>(R.id.lav_tab_img) if (!imageView.isAnimating) { imageView.playAnimation() setLottieColor(imageView, true) }未选中状态,停止动画并还原初始状态,然后设置icon颜色 /** * 未选中状态 fun TabLayout.Tab.setUnselected() { this.customView?.let { val textView = it.findViewById<TextView>(R.id.tv_tab_text) val unselectedColor = ContextCompat.getColor(this@TabLayoutActivity, R.color.black) textView.setTextColor(unselectedColor) val imageView = it.findViewById<LottieAnimationView>(R.id.lav_tab_img) if (imageView.isAnimating) { imageView.cancelAnimation() imageView.progress = 0f // 还原初始状态 setLottieColor(imageView, false) }关于修改lottie icon的颜色,目前网上的答案参差不齐,还是源码来的直接。源码: if (ta.hasValue(R.styleable.LottieAnimationView_lottie_colorFilter)) { int colorRes = ta.getResourceId(R.styleable.LottieAnimationView_lottie_colorFilter, -1); ColorStateList csl = AppCompatResources.getColorStateList(getContext(), colorRes); SimpleColorFilter filter = new SimpleColorFilter(csl.getDefaultColor()); KeyPath keyPath = new KeyPath("**"); LottieValueCallback<ColorFilter> callback = new LottieValueCallback<>(filter); addValueCallback(keyPath, LottieProperty.COLOR_FILTER, callback); }所以直接借鉴即可:/** * set lottie icon color private fun setLottieColor(imageView: LottieAnimationView?, isSelected: Boolean) { imageView?.let { val color = if (isSelected) R.color.colorPrimary else R.color.black val csl = AppCompatResources.getColorStateList(this@TabLayoutActivity, color) val filter = SimpleColorFilter(csl.defaultColor) val keyPath = KeyPath("**") val callback = LottieValueCallback<ColorFilter>(filter) it.addValueCallback(keyPath, LottieProperty.COLOR_FILTER, callback) }动画文件的下载网站推荐: lordicon15.关联ViewPager15.1 编写FragmentPagerAdapterprivate inner class SimpleFragmentPagerAdapter constructor(fm: FragmentManager) : FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { private val tabTitles = arrayOf("Android", "Kotlin", "Flutter") private val fragment = arrayOf(Fragment1(), Fragment2(), Fragment3()) override fun getItem(position: Int): Fragment { return fragment[position] override fun getCount(): Int { return fragment.size override fun getPageTitle(position: Int): CharSequence { return tabTitles[position] }15.2 给ViewPager设置AdaptermBinding.viewPager.adapter = SimpleFragmentPagerAdapter(supportFragmentManager)15.3 给TabLayout关联ViewPagermBinding.tabLayout1.setupWithViewPager(mBinding.viewPager)以上即可把TabLayout和ViewPager关联起来,TabLayout的Tab也会由FragmentPagerAdapter中的标题自动生成。15.4 setupWithViewPager源码分析究竟是怎么关联起来的呢?下面是setupWithViewPager中的部分源码: if (viewPager != null) { this.viewPager = viewPager; if (this.pageChangeListener == null) { // 步骤1 this.pageChangeListener = new TabLayout.TabLayoutOnPageChangeListener(this); this.pageChangeListener.reset(); viewPager.addOnPageChangeListener(this.pageChangeListener); // 步骤2 this.currentVpSelectedListener = new TabLayout.ViewPagerOnTabSelectedListener(viewPager); // 步骤3 this.addOnTabSelectedListener(this.currentVpSelectedListener); PagerAdapter adapter = viewPager.getAdapter(); if (adapter != null) { this.setPagerAdapter(adapter, autoRefresh); if (this.adapterChangeListener == null) { this.adapterChangeListener = new TabLayout.AdapterChangeListener(); this.adapterChangeListener.setAutoRefresh(autoRefresh); // 步骤4 viewPager.addOnAdapterChangeListener(this.adapterChangeListener); this.setScrollPosition(viewPager.getCurrentItem(), 0.0F, true); }先是创建了TabLayout.TabLayoutOnPageChangeListener,并设置给了viewPager.addOnPageChangeListener。然后又创建了TabLayout.ViewPagerOnTabSelectedListener(viewPager),并传入当前viewPager,然后设置给了addOnTabSelectedListener。所以,经过这种你来我往的操作之后,设置TabLayout的选中下标和设置ViewPager的选中下标,其实效果是一毛一样的,因为联动起来了…另外,FragmentPagerAdapter已经废弃了,官方推荐使用viewpager2 和 FragmentStateAdapter 代替。Deprecated Switch to androidx.viewpager2.widget.ViewPager2 and use androidx.viewpager2.adapter.FragmentStateAdapter instead.16.常用API整理16.1 TabLayout16.2 TabLayout.Tab16.3 BadgeDrawableGithubhttps://github.com/yechaoa/MaterialDesign最后写作不易,感谢点赞支持 ^ - ^
1.前言通常情况下,我们在做小红点效果的时候,会有两种选择:自定义BadgeView,然后设置给目标Viewxml写一个View,然后设置shape有的同学可能会想,能实现不就行了吗,是的,代码优不优雅、骚不骚的不重要,代码和人只要有一个能跑就行…不过,今天来介绍一种不同的方式来实现小红点效果,或许会让你眼前一亮~2.效果3.简介用途:给View添加动态显示信息(小红点提示效果)app主题需使用Theme.MaterialComponents.*api 要求18+ 也就Android 4.3以上(api等级对应关系)4.实现拆解4.1TabLayoutxml: <com.google.android.material.tabs.TabLayout android:id="@+id/tab_layout" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="10dp" android:background="#FFFAF0" android:textAllCaps="false" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/include" app:tabIndicator="@drawable/shape_tab_indicator" app:tabIndicatorColor="@color/colorPrimary" app:tabIndicatorFullWidth="false" app:tabMaxWidth="200dp" app:tabMinWidth="100dp" app:tabMode="fixed" app:tabSelectedTextColor="@color/colorPrimary" app:tabTextColor="@color/gray"> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Android" /> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Kotlin" /> <com.google.android.material.tabs.TabItem android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Flutter" /> </com.google.android.material.tabs.TabLayout>kotlin: private fun initTabLayout() { // 带数字小红点 mBinding.tabLayout.getTabAt(0)?.let { it.orCreateBadge.apply { backgroundColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.red) badgeTextColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.white) number = 6 // 不带数字小红点 mBinding.tabLayout.getTabAt(1)?.let { it.orCreateBadge.apply { backgroundColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.red) badgeTextColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.white) }4.2.TextViewxml: <TextView android:id="@+id/tv_badge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="30dp" android:text="小红点示例" android:textAllCaps="false" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/tab_layout" />kotlin:private fun initTextView() { // 在视图树变化 mBinding.tvBadge.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { BadgeDrawable.create(this@BadgeDrawableActivity).apply { badgeGravity = BadgeDrawable.TOP_END number = 6 backgroundColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.colorPrimary) isVisible = true BadgeUtils.attachBadgeDrawable(this, mBinding.tvBadge) mBinding.tvBadge.viewTreeObserver.removeOnGlobalLayoutListener(this) }4.3.Buttonxml: <FrameLayout android:id="@+id/fl_btn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="30dp" android:padding="10dp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/tv_badge"> <com.google.android.material.button.MaterialButton android:id="@+id/mb_badge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Button小红点示例" /> </FrameLayout>kotlin:private fun initButton() { mBinding.mbBadge.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { @SuppressLint("UnsafeOptInUsageError") override fun onGlobalLayout() { BadgeDrawable.create(this@BadgeDrawableActivity).apply { badgeGravity = BadgeDrawable.TOP_START number = 6 backgroundColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.red) // MaterialButton本身有间距,不设置为0dp的话,可以设置badge的偏移量 verticalOffset = 15 horizontalOffset = 10 BadgeUtils.attachBadgeDrawable(this, mBinding.mbBadge, mBinding.flBtn) mBinding.mbBadge.viewTreeObserver.removeOnGlobalLayoutListener(this) }关于MaterialButton的使用及解析可查看:Android MaterialButton使用详解,告别shape、selector4.4.ImageViewxml: <FrameLayout android:id="@+id/fl_img" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="30dp" android:padding="10dp" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@+id/fl_btn"> <com.google.android.material.imageview.ShapeableImageView android:id="@+id/siv_badge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:contentDescription="Image小红点示例" android:src="@mipmap/ic_avatar" /> </FrameLayout>kotlin:private fun initImageView() { mBinding.sivBadge.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { @SuppressLint("UnsafeOptInUsageError") override fun onGlobalLayout() { BadgeDrawable.create(this@BadgeDrawableActivity).apply { badgeGravity = BadgeDrawable.TOP_END number = 99999 // badge最多显示字符,默认999+ 是4个字符(带'+'号) maxCharacterCount = 3 backgroundColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.red) BadgeUtils.attachBadgeDrawable(this, mBinding.sivBadge, mBinding.flImg) mBinding.sivBadge.viewTreeObserver.removeOnGlobalLayoutListener(this) }关于ShapeableImageView的使用及解析可查看:Android ShapeableImageView使用详解,告别shape、三方库4.5.BottomNavigationViewxml: <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/navigation_view" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_margin="10dp" android:layout_marginStart="0dp" android:layout_marginEnd="0dp" android:background="?android:attr/windowBackground" app:itemBackground="@color/colorPrimary" app:itemIconTint="@color/white" app:itemTextColor="@color/white" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:menu="@menu/navigation" />kotlin:private fun initNavigationView() { mBinding.navigationView.getOrCreateBadge(R.id.navigation_home).apply { backgroundColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.red) badgeTextColor = ContextCompat.getColor(this@BadgeDrawableActivity, R.color.white) number = 9999 }TabLayout和BottomNavigationView源码中直接提供了创建BadgeDrawable的api,未提供的使用BadgeUtils。5.常用API整理6.源码解析来一段最简单的代码示例看看:BadgeDrawable.create(this@BadgeDrawableActivity).apply { // ... BadgeUtils.attachBadgeDrawable(this, mBinding.mbBadge, mBinding.flBtn) }不难发现,有两个关键点:BadgeDrawable.createBadgeUtils.attachBadgeDrawable下面继续跟一下,看看源码里究竟是做了什么6.1.BadgeDrawable.createcreate实际调用的是构造方法:private BadgeDrawable(@NonNull Context context) { this.contextRef = new WeakReference<>(context); ThemeEnforcement.checkMaterialTheme(context); Resources res = context.getResources(); badgeBounds = new Rect(); shapeDrawable = new MaterialShapeDrawable(); badgeRadius = res.getDimensionPixelSize(R.dimen.mtrl_badge_radius); badgeWidePadding = res.getDimensionPixelSize(R.dimen.mtrl_badge_long_text_horizontal_padding); badgeWithTextRadius = res.getDimensionPixelSize(R.dimen.mtrl_badge_with_text_radius); textDrawableHelper = new TextDrawableHelper(/* delegate= */ this); textDrawableHelper.getTextPaint().setTextAlign(Paint.Align.CENTER); this.savedState = new SavedState(context); setTextAppearanceResource(R.style.TextAppearance_MaterialComponents_Badge); }构造方法里有这么一行:ThemeEnforcement.checkMaterialTheme(context); 检测Material主题,如果不是会直接抛出异常private static void checkTheme( @NonNull Context context, @NonNull int[] themeAttributes, String themeName) { if (!isTheme(context, themeAttributes)) { throw new IllegalArgumentException( "The style on this component requires your app theme to be " + themeName + " (or a descendant)."); }这也是上面为什么说主题要使用Theme.MaterialComponents.*然后创建了一个文本绘制帮助类,TextDrawableHelper比如设置文本居中:textDrawableHelper.getTextPaint().setTextAlign(Paint.Align.CENTER);其他的就是text属性的获取和设置,跟我们平时设置一毛一样,比较好理解。绘制文本之后怎么显示出来呢?继续跟attachBadgeDrawable。6.2.BadgeUtils.attachBadgeDrawable public static void attachBadgeDrawable(@NonNull BadgeDrawable badgeDrawable, @NonNull View anchor, @Nullable FrameLayout customBadgeParent) { setBadgeDrawableBounds(badgeDrawable, anchor, customBadgeParent); if (badgeDrawable.getCustomBadgeParent() != null) { badgeDrawable.getCustomBadgeParent().setForeground(badgeDrawable); } else { if (USE_COMPAT_PARENT) { throw new IllegalArgumentException("Trying to reference null customBadgeParent"); anchor.getOverlay().add(badgeDrawable); }这里先是判断badgeDrawable.getCustomBadgeParent() != null,这个parent view的类型就是FrameLayout,不为空的情况下,层级前置。为空的情况下先是判断了if (USE_COMPAT_PARENT),这里其实是对api level的判断static { USE_COMPAT_PARENT = VERSION.SDK_INT < 18; }核心代码:anchor.getOverlay().add(badgeDrawable);如果有同学做过类似全局添加View的需求,这行代码就看着比较熟悉了。ViewOverlay,视图叠加,也可以理解为浮层,在不影响子view的情况下,可以添加、删除View,这个api就是android 4.3加的,这也是为什么前面说api 要求18+。ok,至此关于BadgeDrawable的使用和源码解析就介绍完了。7.Githubhttps://github.com/yechaoa/MaterialDesign欢迎去主页或Github,查看更多关于MaterialDesign组件的分享。8.相关文档BadgeDrawableBadgeUtilsViewOverlay9.最后写作不易,如果对你有一丢丢帮助或启发,感谢点赞支持 ^ - ^
前言包大小的重要性已经不需要多说,包大小直接影响用户的下载,留存,甚至部分厂商预装强制要求必须小于一定的值。但是随着业务的迭代开发,应用会越来越大,安装包会不停的膨胀,因此包大小缩减是一个长期持续的治理过程。提升下载转化率,安装包越小,转化率越高。降低渠道推广成本。降低安装时间,文件拷贝、Library解压、编译ODEX、签名校验这些,包体积越大越耗时。降低运行时内存等等。环境Android Studio Arctic Fox | 2020.3.1 Patch 2AGP 7.0项目地址:wanandroid_jetpack优化前4.7MB,4.2MB是google play下载的大小,会有压缩。除了AS自带的Analyzer之外,还有ApkChecker、ClassyShark等工具。APK的组成APK构建流程这是官方新版的打包流程,虽然省略了一些步骤,但是大致的流程还是比较清晰的。再次简化一下:资源文件、Java文件 > dex文件 > APK优化思路APK本质是一个压缩文件,是打包后的产物,那可以作为切入点的阶段就是打包前、以及打包中。打包前,即减少打包的文件,比如无用的资源、代码;打包中,对打包中的产物进行压缩,比如资源文件、So文件;关键词:减少、压缩。常规操作1.Lint检测无用资源文件Analyze > Run Inspection by Name > Unused resources检测结果:确定无用删除即可。注意:因为lint是本地静态扫描,所以动态引用的资源文件并不会识别出来,也会出现在检测列表里。2.Lint检测代码Analyze > Inspect code检测结果:因为这个项目是用kotlin写的,所以直接看kotlin目录下的检测结果。注意:因为lint是本地静态扫描,所以反射、动态引用的class并不会识别出来,也会出现在检测列表里。3.图片压缩推荐使用tinypng在线压缩。4.TinyPngPlugin手动压缩毕竟不高效,可以使用TinyPngPlugin一键压缩。plugins搜索TinyPng安装即可。(新版AS安装完plugin已经不需要重启了)压缩结果:9张图片,可以看到效果还是非常可观的。如果图片多,效果更加明显。经过上面的操作,包体积减小4%,这还只是一个4.7MB的APK而已。5.WebP那这9张图还能继续优化吗?可以,WebP格式的体积更小,而已AS也提供了一键转换支持。以ic_avatar.png为例:6.开启混淆minifyEnabled true,默认启用R8代码缩减功能。buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' }慎用R8,因为:R8 会忽略试图修改默认优化行为的所有 ProGuard 规则,例如 -optimizations 和 - optimizationpasses。可以开启混淆,而不使用R8。android.enableR8=false android.enableR8.libraries=false混淆参考:Android混淆从入门到精通7.缩减资源shrinkResources true假如有一些资源文件不确定还用不用,也不敢删,或者不确定需求是否会变更,所以先留着,那这种情况怎么办呢?可以使用shrinkResources来缩减资源。 buildTypes { debug { minifyEnabled false release { shrinkResources true minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' }要配合混淆minifyEnabled一起使用才行,原理也很简单,代码移除之后,引用的资源也就变成无用资源了,才可以进一步缩减。8.so文件缩减比如集成了一个三方的直播或者浏览器,可能会提供很多so文件,起初可能是一股脑的copy进项目,但并不一定都用的到。比如各种cpu架构的so:app/build/intermediates/cmake/universal/release/obj/ ├── armeabi-v7a/ │ ├── libgameengine.so │ ├── libothercode.so │ └── libvideocodec.so ├── arm64-v8a/ │ ├── libgameengine.so │ ├── libothercode.so │ └── libvideocodec.so ├── x86/ │ ├── libgameengine.so │ ├── libothercode.so │ └── libvideocodec.so └── x86_64/ ├── libgameengine.so ├── libothercode.so └── libvideocodec.so目前市面上的手机cpu都是arm架构的,所以保留arm的一种即可(定制的除外),armeabi-v7a或armeabi都可,其他直接删除。 android { defaultConfig { ndk { abiFilters 'armeabi-v7a' }如果开发需要模拟器调试,就加上x86的架构,正式包记得去掉,或者在local.properties中用变量控制一下。这块如果之前没有优化过,而又有很多so文件的话,或许可以减少30%以上,恐怖如斯!9.移除未使用的备用资源很多出海的应用会做国际化,但也适配不了这么多的语言。除了自己app的之外,还有一些官方的、三方的,可以统一配置支持的语言。 defaultConfig { resConfigs("en","zh","zh-rCN") }资源文件同理defaultConfig { resConfigs("xxhdpi","xxxhdpi") }10.小结针对上面的操作做个小结,看看目前效果如何。2MB,包体积减少57%,恐怖如斯!如果是大型项目,收益非常可观。author:yechaoa进阶操作上面只是一些常规操作,下面看一些进阶操作。1.resources.arsc资源混淆资源混淆就是将原本冗长的资源路径变短,例如将res/drawable/wechat变为r/d/a。开源工具AndResGuard。2.移除无用的三方库引入之后未使用的,或者是功能下架之后未移除的。3.功能重复的三方库整合比如glide和picasso,都是图片库,保留其一即可。4.ReDexdex文件是打包中的产物,redex是facebook开源的分包优化方案。可以参考:ReDex。5.so动态加载前面已经做了so文件缩减,但是可能so文件占比还是比较大,可以考虑除了首次启动外的so文件做动态下发。也就是插件化的思想,按需加载,但是收益很大的同时,风险也很大,有很多case需要考虑到,比如下载时机、网络环境、线程进程,加载失败是否有降级策略等等。可以参考facebook开源的SoLoader。6.插件化按需加载,收益越大风险越大,风险同上。极致操作那如果我想做到极致,还有哪些骚操作呢,ok,继续。1.原生改用H5或小程序等方案有些功能可能原生做就显得太重,比如各种促销活动,需要加载各种大图,原生既重又不够动态化,这个时候H5是一种很好的替代方案。但是如果你原本就不支持H5或者小程序的话,接入这种能力可能反而会加大包体积,做好对比。2.砍功能有些功能可能想的很美好,但上线之后收益并不大,是否需要重新思考价值点,最好找到数据依托,再跟产品打架。3.修改三方库的源码,不需要的代码剔除比如引入了一个功能很齐全的三方库utils,但实际只用到几个,对源码进行抽取也能减少包体积,同时还能减少网络下载的编译时间。弊端就是升级成本较大。4.图片网络化即把图片上传到服务器,通过动态下载的方式减少包体积,弊端就是首次加载的时候依赖网络环境,对加载速度、流量需要做一个平衡。图片可以预加载,但是流量消耗是无法避免了,如果比较在意流量指标,需要权衡了。5.DebugItemDebugItem 里面主要包含两种信息:调试的信息。函数的参数变量和所有的局部变量。排查问题的信息。所有的指令集行号和源文件行号的对应关系。去除debug信息与行号信息,如果不是极致,不推荐。可以参考支付宝的这篇 支付宝 App 构建优化解析:Android 包大小极致压缩。6.R Field内联内联R Field可以解决R Field过多导致MultiDex 65536的问题,而这一步骤对代码瘦身能够起到明显的效果。美团代码片段:ctBehaviors.each { CtBehavior ctBehavior -> if (!ctBehavior.isEmpty()) { try { ctBehavior.instrument(new ExprEditor() { @Override public void edit(FieldAccess f) { try { def fieldClassName = JavassistUtils.getClassNameFromCtClass(f.getCtClass()) if (shouldInlineRField(className, fieldClassName) && f.isReader()) { def temp = fieldClassName.substring(fieldClassName.indexOf(ANDROID_RESOURCE_R_FLAG) + ANDROID_RESOURCE_R_FLAG.length()) def fieldName = f.fieldName def key = "${temp}.${fieldName}" if (resourceSymbols.containsKey(key)) { Object obj = resourceSymbols.get(key) try { if (obj instanceof Integer) { int value = ((Integer) obj).intValue() f.replace("\$_=${value};") } else if (obj instanceof Integer[]) { def obj2 = ((Integer[]) obj) StringBuilder stringBuilder = new StringBuilder() for (int index = 0; index < obj2.length; ++index) { stringBuilder.append(obj2[index].intValue()) if (index != obj2.length - 1) { stringBuilder.append(",") f.replace("\$_ = new int[]{${stringBuilder.toString()}};") } else { throw new GradleException("Unknown ResourceSymbols Type!") } catch (NotFoundException e) { throw new GradleException(e.message) } catch (CannotCompileException e) { throw new GradleException(e.message) } else { throw new GradleException("******** InlineRFieldTask unprocessed ${className}, ${fieldClassName}, ${f.fieldName}, ${key}") } catch (NotFoundException e) { } catch (CannotCompileException e) { }同时可以参考字节开源的shrink-r-plugin,还有滴滴开源的booster。7.图片着色器针对同图不同色的处理,可以使用tint,比如原本是一个黑色的返回icon,现在另一个页面要用白色了,就不需要两张图了,而是使用tint来修改为白色即可。<ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_back_black" android:tint="@android:color/white" />8.减少ENUM的使用每减少一个ENUM可以减少大约1.0到1.4 KB的大小。包体积监控包体积监控应该作为发布流程的一个环节,最好是做到平台化、流程化,否则很难持续,没几个版本包体积又涨上来了。大致思想:当前版本与上一个版本的包大小做对比,超过200KB需要审批。临时审批需要给出后续优化方案等等。参考文档Improve your code with lint checksShrink, obfuscate, and optimize your appAndroid App包瘦身优化实践ReDexSoLoader支付宝 App 构建优化解析:Android 包大小极致压缩AndResGuard深入探索 Android 包体积优化Android开发高手课包体积优化
环境Android Studio Arctic Fox | 2020.3.1Gradle、AGP 7.0Kotlin 1.5.31问题1Value '/Applications/Android Studio.app/Contents/jre/jdk/Contents/Home' given for org.gradle.java.home Gradle property is invalid (Java home supplied is invalid)解决Gradle的java环境变量与系统JAVA_HOME不一致。终端输入:/usr/libexec/java_home -V,查看java路径。yechao@yechaoa ~ % /usr/libexec/java_home -V Matching Java Virtual Machines (2): 1.8.131.11 (x86_64) "Oracle Corporation" - "Java" /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home 1.8.0_131 (x86_64) "Oracle Corporation" - "Java SE 8" /Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home /Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home或是mac下,打开访达,然后打开user目录,command+shift+.显示隐藏文件夹,找到.bash_profile文件,查看或配置java环境# java home export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home export PATH=${PATH}:${JAVA_HOME}/bin export PATH=${PATH}:${JAVA_HOME}/jre/bin然后打开项目中的gradle.properties文件,查看org.gradle.java.home参数是否一致,改为一致即可。#org.gradle.java.home=/Applications/Android Studio.app/Contents/jre/jdk/Contents/Home org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk1.8.0_131.jdk/Contents/Home注意:以上路径改为自己的java路径。问题2Method 'buildStarted' from class 'BuildTraceListener' does not override method from its superclass or interfaces but is annotated with @Override.解决Gradle 7.0移除了BuildListener.buildStarted(Gradle),直接删除buildStarted方法即可。改用BuildListener.beforeSettings(Settings)代替。问题3Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.AGP 7.0必须使用Java 11.Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8. You can try some of the following options: - changing the IDE settings. - changing the JAVA_HOME environment variable. - changing `org.gradle.java.home` in `gradle.properties`. Gradle settings解决直接点击Gradle settings选择android studio 11的版本,重新编译即可。注意: AGP的java版本跟项目的版本可以不一致。也可以修改build.gradle中的配置为以下: compileOptions { sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 kotlinOptions { jvmTarget = '11' }只要你的项目支持即可。问题4DSL element 'dexOptions' is obsolete and should be removed. It will be removed in version 8.0 of the Android Gradle plugin. Using it has no effect, and the AndroidGradle plugin optimizes dexing automatically.解决dexOptions已经过时,会在AGP8.0移除,并且AGP会自动帮我们优化,所以,直接在build.gradle中移除dexOptions即可。问题5The specified Android SDK Build Tools version (30.0.1) is ignored, as it is below the minimum supported version (30.0.2) for Android Gradle Plugin 7.0.2. Android SDK Build Tools 30.0.2 will be used. To suppress this warning, remove "buildToolsVersion '30.0.1'" from your build.gradle file, as each version of the Android Gradle Plugin now has a default version of the build tools. Remove Build Tools version and sync project Affected Modules: app解决buildToolsVersion 30.0.1 被忽略了,主要是因为低于AGP7.0.2所要求的的最低版本30.0.2,所以会使用30.0.2的版本,而且后续AGP也会应用一个对应的默认的版本,直接移除buildToolsVersion即可。问题6Project property 'kotlin.parallel.tasks.in.project' is deprecated. By default it depends on Gradle parallel project execution option value.解决kotlin.parallel.tasks.in.project弃用了,是kotlin 1.5.20版本中的变更,使用org.gradle.parallel就够了,所以直接删除kotlin.parallel.tasks.in.project即可。其他还有一些代码上的警告,按照提示解决即可。Githubhttps://github.com/yechaoa/wanandroid_jetpack参考Android Studio release notesUpgrading your build from Gradle 6.x to the latestWhat’s new in Kotlin 1.5.20
前言使用协程,相信很多同学已经信手拈来了,但是关于ViewModelScope,可能很多同学在用,但却不知道原理,今天来一探究竟。ViewModelScope,顾名思义,在ViewModel中使用的协程。它是ViewModel的扩展属性。推荐理由:自动取消,不会造成内存泄漏,如果是CoroutineScope,就需要在onCleared()方法中手动取消了,否则可能会造成内存泄漏。配合ViewModel,能减少样板代码,提高效率。后面会重点介绍ViewModelScope是怎么做到不会内存泄漏的。使用引入协程:implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'viewmodel-ktx:implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1")ViewModelScope虽然是协程,但属于androidx.lifecycle包中ViewModel的扩展属性。示例:class MyViewModel :ViewModel() { fun getData(){ viewModelScope.launch { // do }使用非常简单,关键在于它是怎么保证不会内存泄露的?源码分析来看viewModelScope源码:public val ViewModel.viewModelScope: CoroutineScope get() { val scope: CoroutineScope? = this.getTag(JOB_KEY) if (scope != null) { return scope return setTagIfAbsent( JOB_KEY, CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope { override val coroutineContext: CoroutineContext = context override fun close() { coroutineContext.cancel() }先看get()方法:get() { val scope: CoroutineScope? = this.getTag(JOB_KEY) if (scope != null) { return scope return setTagIfAbsent( JOB_KEY, CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }return中通过setTagIfAbsent创建了协程,并且指定主线程。先忽略setTagIfAbsent,来看协程创建的方式:internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope { override val coroutineContext: CoroutineContext = context override fun close() { coroutineContext.cancel() }CloseableCoroutineScope,顾名思义,可以关闭的协程。实现Closeable接口,并重写唯一方法close(),并在方法中取消了协程。现在我们已经知道了viewModelScope是可以取消的了,关键就在于取消时机的控制了。回过头在再看setTagIfAbsent,setTagIfAbsent是ViewModel中的方法public abstract class ViewModel { @Nullable private final Map<String, Object> mBagOfTags = new HashMap<>(); private volatile boolean mCleared = false; @SuppressWarnings("WeakerAccess") protected void onCleared() { @MainThread final void clear() { mCleared = true; if (mBagOfTags != null) { synchronized (mBagOfTags) { for (Object value : mBagOfTags.values()) { closeWithRuntimeException(value); onCleared(); @SuppressWarnings("unchecked") <T> T setTagIfAbsent(String key, T newValue) { T previous; synchronized (mBagOfTags) { previous = (T) mBagOfTags.get(key); if (previous == null) { mBagOfTags.put(key, newValue); T result = previous == null ? newValue : previous; if (mCleared) { closeWithRuntimeException(result); return result; @SuppressWarnings({"TypeParameterUnusedInFormals", "unchecked"}) <T> T getTag(String key) { if (mBagOfTags == null) { return null; synchronized (mBagOfTags) { return (T) mBagOfTags.get(key); private static void closeWithRuntimeException(Object obj) { if (obj instanceof Closeable) { try { ((Closeable) obj).close(); } catch (IOException e) { throw new RuntimeException(e); }在setTagIfAbsent中,以HashMap的形式把协程对象保存起来了,并配有getTag方法。可能有同学已经注意到最后的方法closeWithRuntimeException,因为这个方法中调用了Closeable接口的close()方法,而close()方法就是用来取消协程的。而closeWithRuntimeException方法是谁调用的呢,主要是ViewModel中的clear()方法。 @MainThread final void clear() { mCleared = true; if (mBagOfTags != null) { synchronized (mBagOfTags) { for (Object value : mBagOfTags.values()) { closeWithRuntimeException(value); onCleared(); }这里是循环保存协程的HashMap,然后调用closeWithRuntimeException取消协程。那这个ViewModel中的clear()方法又是谁调用的呢?查看源码,只有一处调用,就是在ViewModelStore中public class ViewModelStore { private final HashMap<String, ViewModel> mMap = new HashMap<>(); final void put(String key, ViewModel viewModel) { ViewModel oldViewModel = mMap.put(key, viewModel); if (oldViewModel != null) { oldViewModel.onCleared(); final ViewModel get(String key) { return mMap.get(key); Set<String> keys() { return new HashSet<>(mMap.keySet()); public final void clear() { for (ViewModel vm : mMap.values()) { vm.clear(); mMap.clear(); }ViewModelStore的源码比较少,也很简单。同样也是以HashMap的形式来保存ViewModel。那是什么时候保存的呢,我们来追踪一下put方法:public class ViewModelProvider { //... @SuppressWarnings("unchecked") @NonNull @MainThread public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) { //... mViewModelStore.put(key, viewModel); return (T) viewModel; //... }在ViewModelProvider的get方法中调用了put,也就是说,我们在创建ViewModel的时候并把其保存了起来。回过头来再看ViewModelStore,同样也有一个clear()方法,同样循环调用vm.clear()。继续追踪ViewModelStore的clear()方法是在哪调用的。是在ComponentActivity.java中调用的:getLifecycle().addObserver(new LifecycleEventObserver() { @Override public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { if (event == Lifecycle.Event.ON_DESTROY) { if (!isChangingConfigurations()) { getViewModelStore().clear(); });先是获取Lifecycle,并添加生命周期监听。在生命周期为onDestroy的时候,获取ViewModelStore,并调用其clear()方法。至此,相信大部分同学都明白了ViewModelScope为什么不会造成内存泄露了,因为在onDestroy的时候会取消执行,只不过这部分工作源码已经替我们完成了。关于怎么获取到当前生命周期状态的,就涉及到Lifecycle相关的知识了,简而言之,不管是Activity还是Fragment,都是LifecycleOwner,其实是父类实现的,比如ComponentActivity。在父类中通过ReportFragment或ActivityLifecycleCallbacks接口来派发当前生命周期状态,具体使用哪种派发方式要看Api等级是否在29(10.0)及以上,及 则后者。author:yechaoa总结最后,我们再来总结一下ViewModelScope的整个流程。首先在创建ViewModel的时候,会通过ViewModelStore以HashMap的形式把ViewModel保存起来;随后我们在调用viewModelScope的时候,会在ViewModel中以HashMap的形式把协程也保存起来,而这个协程实现了Closeable接口,并在Closeable接口的close()方法中取消协程;在ViewModel中有个clear()方法,会循环调用close()方法取消协程;在ViewModelStore中也有个clear()方法,会循环调用ViewModel中的clear()方法;而ViewModelStore中的clear()方法,是由Lifecycle在生命周期执行到onDestroy的时候调用的。为避免有的同学没理解,我们再反推梳理一次在生命周期执行到onDestroy的时候,调用ViewModelStore中的clear()方法;在ViewModelStore中的clear()方法中,循环调用ViewModel的clear()方法;在ViewModel的clear()方法中,循环调用close()取消协程。ok,以上就是ViewModelScope的使用,以及源码分析。最后写作不易,如果对你有一丢丢帮助或启发,感谢点赞支持 ^ - ^
前言使用协程,相信很多同学已经信手拈来了,但是也有很多同学是不知道LifecycleScope的。LifecycleScope,顾名思义,具有生命周期的协程。它是LifecycleOwner生命周期所有者的扩展属性,与LifecycleOwner生命周期绑定,并会在LifecycleOwner生命周期destroyed的时候取消掉。推荐理由:自动取消,不会造成内存泄漏,可以替代MainScope。可以基于指定的生命周期执行。后面会重点介绍LifecycleScope是怎么做到的。使用引入协程:implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0'Lifecycle:implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1")LifecycleScope虽然是协程,但属于Lifecycle中的扩展属性。示例:lifecycleScope默认主线程,可以通过withContext来指定线程。lifecycleScope.launch { // do withContext(Dispatchers.IO) { // do // or lifecycleScope.launch(Dispatchers.IO){ // do // or lifecycleScope.launch { whenResumed { // do // or lifecycleScope.launchWhenResumed { // do }whenResumed和launchWhenResumed执行时机一样,区别在于:whenResumed 可以有返回结果launchWhenResumed 返回的是Job对象共有三个对应生命周期的扩展函数:whenCreatedwhenStartedwhenResumed使用非常简单,关键在于它是怎么保证不会内存泄露的,又是怎么知道在某个生命周期的时候去执行协程的?源码分析1、如何保证不会内存泄漏的先看lifecycleScope源码:val LifecycleOwner.lifecycleScope: LifecycleCoroutineScope get() = lifecycle.coroutineScope继承自LifecycleCoroutineScope,而LifecycleCoroutineScope是CoroutineScope的子类(协程层级关系)。get()返回lifecycle.coroutineScope这里有一个源码小技巧,当继承对象与返回对象不一致时,那么返回对象多半为继承对象的子类。继续看lifecycle.coroutineScope:public val Lifecycle.coroutineScope: LifecycleCoroutineScope get() { while (true) { val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl? if (existing != null) { return existing val newScope = LifecycleCoroutineScopeImpl( this, SupervisorJob() + Dispatchers.Main.immediate if (mInternalScopeRef.compareAndSet(null, newScope)) { newScope.register() return newScope }果不其然,也是继承LifecycleCoroutineScope。关键在于,通过LifecycleCoroutineScopeImpl创建了协程,默认主线程,随后又调用了newScope.register()继续看LifecycleCoroutineScopeImpl:internal class LifecycleCoroutineScopeImpl( override val lifecycle: Lifecycle, override val coroutineContext: CoroutineContext ) : LifecycleCoroutineScope(), LifecycleEventObserver { //... fun register() { launch(Dispatchers.Main.immediate) { if (lifecycle.currentState >= Lifecycle.State.INITIALIZED) { lifecycle.addObserver(this@LifecycleCoroutineScopeImpl) } else { coroutineContext.cancel() override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { if (lifecycle.currentState <= Lifecycle.State.DESTROYED) { lifecycle.removeObserver(this) coroutineContext.cancel() }在register()方法中添加了LifecycleEventObserver接口的监听,LifecycleEventObserver会在onStateChanged方法中派发当前生命周期,关键来了,在onStateChanged回调中,判断当前生命周期是destroyed的时候,移除监听,并取消协程。至此,相信大部分同学都明白了为什么不会造成内存泄露了,因为在页面destroyed的时候,协程会取消,并不会继续执行,而MainScope是需要手动取消的,否则会有内存泄露的风险。插曲,我们进一步思考,在其他的开发场景中,也可以学习源码通过添加LifecycleEventObserver监听的方式,做回收清理操作,来避免内存泄漏。author:yechaoa2、如何知道在某个生命周期去执行协程以lifecycleScope.launchWhenResumed为例,一探究竟。fun launchWhenResumed(block: suspend CoroutineScope.() -> Unit): Job = launch { lifecycle.whenResumed(block) }调用whenResumed:suspend fun <T> Lifecycle.whenResumed(block: suspend CoroutineScope.() -> T): T { return whenStateAtLeast(Lifecycle.State.RESUMED, block) }接着调用whenStateAtLeast,并传入一个具体生命周期状态作为标识。继续看whenStateAtLeast:suspend fun <T> Lifecycle.whenStateAtLeast( minState: Lifecycle.State, block: suspend CoroutineScope.() -> T ) = withContext(Dispatchers.Main.immediate) { val job = coroutineContext[Job] ?: error("when[State] methods should have a parent job") val dispatcher = PausingDispatcher() val controller = LifecycleController(this@whenStateAtLeast, minState, dispatcher.dispatchQueue, job) try { withContext(dispatcher, block) } finally { controller.finish() }这里创建了LifecycleController,并向下传入接收的具体状态,同时还有一个调度队列dispatcher.dispatchQueue。接着看LifecycleController:@MainThread internal class LifecycleController( private val lifecycle: Lifecycle, private val minState: Lifecycle.State, private val dispatchQueue: DispatchQueue, parentJob: Job private val observer = LifecycleEventObserver { source, _ -> if (source.lifecycle.currentState == Lifecycle.State.DESTROYED) { // cancel job before resuming remaining coroutines so that they run in cancelled // state handleDestroy(parentJob) } else if (source.lifecycle.currentState < minState) { dispatchQueue.pause() } else { dispatchQueue.resume() init { // If Lifecycle is already destroyed (e.g. developer leaked the lifecycle), we won't get // an event callback so we need to check for it before registering // see: b/128749497 for details. if (lifecycle.currentState == Lifecycle.State.DESTROYED) { handleDestroy(parentJob) } else { lifecycle.addObserver(observer) //... }在init初始化的时候,添加LifecycleEventObserver监听(又是一个使用案例,不过这里用的是lambda写法)。在回调中,对生命周期进行了判断,当大于当前状态的时候,也就是生命周期执行到当前状态的时候,会调用dispatchQueue.resume()执行队列,也就是协程开始执行。dispatchQueue.resume: @MainThread fun resume() { if (!paused) { return check(!finished) { "Cannot resume a finished dispatcher" paused = false drainQueue() //... @MainThread fun drainQueue() { if (isDraining) { // Block re-entrant calls to avoid deep stacks return try { isDraining = true while (queue.isNotEmpty()) { if (!canRun()) { break queue.poll()?.run() } finally { isDraining = false }关于怎么获取到当前生命周期状态的,就涉及到Lifecycle相关的知识了,简而言之,不管是Activity还是Fragment,都是LifecycleOwner,其实是父类实现的,比如ComponentActivity。在父类中通过ReportFragment或ActivityLifecycleCallbacks接口来派发当前生命周期状态,具体使用哪种派发方式要看Api等级是否在29(10.0)及以上,及 则后者。验证分析验证一下我们的分析是否正确。代码简单测试:class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) Log.i("tag","onCreate") lifecycleScope.launchWhenResumed { Log.i("tag","launchWhenResumed") override fun onResume() { super.onResume() Log.i("tag","onResume") }同时对源码进行debug。I/tag: onCreate I/tag: onResume I/tag: launchWhenResumed通过打印,并结合断点执行顺序来看,以上分析是完全正确的。总结我们再来总结一下lifecycleScope协程执行时机的流程。调用lifecycleScope,返回lifecycle.coroutineScope;在coroutineScope中通过LifecycleCoroutineScopeImpl创建了协程,并调用了register()方法添加了对生命周期的监听,这个监听其实是为了在生命周期destroyed的时候取消协程;随后才是调用具体执行状态的代码,比如launchWhenResumed;然后调用whenStateAtLeast,并传入协程具体要执行的状态,比如Lifecycle.State.RESUMED;在whenStateAtLeast中创建了LifecycleController,并向下传入具体执行状态,和一个队列;在LifecycleController初始化的时候,也添加了对生命周期的监听LifecycleEventObserver,在回调中,通过当前生命周期的状态与具体要执行状态的判断,来决定是否执行协程队列,满足条件,即执行。以上,就是lifecycleScope的使用,以及执行流程的具体分析。最后写作不易,如果对你有一丢丢帮助或启发,感谢点赞支持 ^ - ^
效果前言先来看一下ShapeableImageView是什么由上图可以看到ShapeableImageView也没有什么神秘的,不过是ImageView的一个子类而已,但是从效果图来看,在不写shape、不引入三方库的情况下,还是挺容易实现预期效果的,而且扩展性良好。使用引入material包implementation 'com.google.android.material:material:1.2.1'常规<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:src="@mipmap/ic_avatar" />和ImageView正常使用没有区别圆角<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/RoundedStyle" /><!--ShapeableImageView 圆角--> <style name="RoundedStyle"> <item name="cornerFamily">rounded</item> <item name="cornerSize">10dp</item> </style>没有直接设置圆角的属性,需要用到app:shapeAppearance,后面会说cornerFamily 角的处理方式,rounded圆角,cut裁剪cornerSize 圆角大小圆<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/CircleStyle" /><!--ShapeableImageView 圆 --> <style name="CircleStyle"> <item name="cornerFamily">rounded</item> <item name="cornerSize">50%</item> </style>圆角的大小可以用百分比,也可以自己计算,比如宽高100dp,圆角50dp描边<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:padding="2dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/CircleStyle" app:strokeColor="@color/red" app:strokeWidth="4dp" />app:strokeColor 描边颜色app:strokeWidth 描边宽度注意这里padding的数值是描边宽度的一半,后面会说切角<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:padding="2dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/CutStyle" app:strokeColor="@color/red" app:strokeWidth="4dp" /><!--ShapeableImageView 切角 --> <style name="CutStyle"> <item name="cornerFamily">cut</item> <item name="cornerSize">10dp</item> </style>cornerFamily:cut 处理模式变为裁剪菱形<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:padding="2dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/RhombusStyle" app:strokeColor="@color/red" app:strokeWidth="4dp" /><!--ShapeableImageView 菱形 --> <style name="RhombusStyle"> <item name="cornerFamily">cut</item> <item name="cornerSize">50%</item> </style>同样,裁剪模式下圆角大小也可以计算叶子<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:padding="2dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/LeafStyle" app:strokeColor="@color/red" app:strokeWidth="4dp" /><!--ShapeableImageView 叶子 --> <style name="LeafStyle"> <item name="cornerFamily">rounded</item> <item name="cornerSizeTopLeft">50%</item> <item name="cornerSizeBottomRight">50%</item> </style>cornerSizeTopLeft 左上圆角cornerSizeBottomRight 右下圆角以此类推,左上、左下、右上、右下等半圆<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="10dp" android:padding="2dp" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/SemicircleStyle" app:strokeColor="@color/red" app:strokeWidth="4dp" /><!--ShapeableImageView 半圆 --> <style name="SemicircleStyle"> <item name="cornerFamily">rounded</item> <item name="cornerSizeTopLeft">50%</item> <item name="cornerSizeTopRight">50%</item> </style>六边形<com.google.android.material.imageview.ShapeableImageView android:layout_width="wrap_content" android:layout_height="50dp" android:layout_margin="10dp" android:padding="2dp" android:scaleType="centerCrop" android:src="@mipmap/ic_avatar" app:shapeAppearance="@style/HexagonStyle" app:strokeColor="@color/red" app:strokeWidth="4dp" /><!--ShapeableImageView 六边形 --> <style name="HexagonStyle"> <item name="cornerFamily">cut</item> <item name="cornerSizeTopLeft">50%</item> <item name="cornerSizeTopRight">50%</item> <item name="cornerSizeBottomLeft">50%</item> <item name="cornerSizeBottomRight">50%</item> </style>author:yechaoa属性关于xml属性,我也做了一个整理,属性不多,只有4个扩展前面为了整体的排版,埋了几个伏笔,下面来一一解答。会涉及到源码,但是经过去繁从简,看起来也非常轻松的。shapeAppearanceShape appearance overlay style reference for ShapeableImageView.ShapeableImageView的形状外观覆盖样式参考。前面可以看到我们设置圆角其实是用的style,那为什么不直接用attrs呢,不是更加直观方便吗,带着疑问来看看源码是怎么处理的。直接看ShapeableImageView的次构造方法:public class ShapeableImageView extends AppCompatImageView implements Shapeable { public ShapeableImageView(Context context, @Nullable AttributeSet attrs, int defStyle) { super(wrap(context, attrs, defStyle, DEF_STYLE_RES), attrs, defStyle); // Ensure we are using the correctly themed context rather than the context that was passed in. context = getContext(); clearPaint = new Paint(); clearPaint.setAntiAlias(true); clearPaint.setColor(Color.WHITE); clearPaint.setXfermode(new PorterDuffXfermode(Mode.DST_OUT)); destination = new RectF(); maskRect = new RectF(); maskPath = new Path(); TypedArray attributes = context.obtainStyledAttributes( attrs, R.styleable.ShapeableImageView, defStyle, DEF_STYLE_RES); strokeColor = MaterialResources.getColorStateList( context, attributes, R.styleable.ShapeableImageView_strokeColor); strokeWidth = attributes.getDimensionPixelSize(R.styleable.ShapeableImageView_strokeWidth, 0); borderPaint = new Paint(); borderPaint.setStyle(Style.STROKE); borderPaint.setAntiAlias(true); shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyle, DEF_STYLE_RES).build(); shadowDrawable = new MaterialShapeDrawable(shapeAppearanceModel); if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { setOutlineProvider(new OutlineProvider()); 常规操作,获取自定义属性。关键的两行代码: shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyle, DEF_STYLE_RES).build(); shadowDrawable = new MaterialShapeDrawable(shapeAppearanceModel);也就是说我们给shapeAppearance设置的style,并不是ShapeableImageView自己来处理的,而是由ShapeAppearanceModel来构建的,然后又交给MaterialShapeDrawable来绘制的。ShapeAppearanceModel这个类就厉害了,有点像Flutter中的Decoration,可以构建出花里胡哨的效果。来看ShapeAppearanceModel部分源码:public class ShapeAppearanceModel { /** Builder to create instances of {@link ShapeAppearanceModel}s. */ public static final class Builder { @NonNull private CornerTreatment topLeftCorner = MaterialShapeUtils.createDefaultCornerTreatment(); @NonNull private CornerTreatment topRightCorner = MaterialShapeUtils.createDefaultCornerTreatment(); @NonNull private CornerTreatment bottomRightCorner = MaterialShapeUtils.createDefaultCornerTreatment(); @NonNull private CornerTreatment bottomLeftCorner = MaterialShapeUtils.createDefaultCornerTreatment(); @NonNull private CornerSize topLeftCornerSize = new AbsoluteCornerSize(0); @NonNull private CornerSize topRightCornerSize = new AbsoluteCornerSize(0); @NonNull private CornerSize bottomRightCornerSize = new AbsoluteCornerSize(0); @NonNull private CornerSize bottomLeftCornerSize = new AbsoluteCornerSize(0); @NonNull private EdgeTreatment topEdge = MaterialShapeUtils.createDefaultEdgeTreatment(); @NonNull private EdgeTreatment rightEdge = MaterialShapeUtils.createDefaultEdgeTreatment(); @NonNull private EdgeTreatment bottomEdge = MaterialShapeUtils.createDefaultEdgeTreatment(); @NonNull private EdgeTreatment leftEdge = MaterialShapeUtils.createDefaultEdgeTreatment(); public Builder() {} 可以看到有各种边和角的属性,这里注意两个点:MaterialShapeUtils.createDefaultCornerTreatment() 创建默认角的处理方式MaterialShapeUtils.createDefaultEdgeTreatment() 创建默认边的处理方式也就意味着,边和角除了默认,是可以自定义的,这就有极大的想象空间了,比如这样:// 代码设置 角和边 val shapeAppearanceModel2 = ShapeAppearanceModel.builder().apply { setAllCorners(RoundedCornerTreatment()) setAllCornerSizes(50f) setAllEdges(TriangleEdgeTreatment(50f, false)) }.build() val drawable2 = MaterialShapeDrawable(shapeAppearanceModel2).apply { setTint(ContextCompat.getColor(this@ShapeableImageViewActivity, R.color.colorPrimary)) paintStyle = Paint.Style.FILL_AND_STROKE strokeWidth = 50f strokeColor = ContextCompat.getColorStateList(this@ShapeableImageViewActivity, R.color.red) mBinding.text2.setTextColor(Color.WHITE) mBinding.text2.background = drawable2 // 代码设置 聊天框效果 val shapeAppearanceModel3 = ShapeAppearanceModel.builder().apply { setAllCorners(RoundedCornerTreatment()) setAllCornerSizes(20f) setRightEdge(object : TriangleEdgeTreatment(20f, false) { // center 位置 , interpolation 角的大小 override fun getEdgePath(length: Float, center: Float, interpolation: Float, shapePath: ShapePath) { super.getEdgePath(length, 35f, interpolation, shapePath) }.build() val drawable3 = MaterialShapeDrawable(shapeAppearanceModel3).apply { setTint(ContextCompat.getColor(this@ShapeableImageViewActivity, R.color.colorPrimary)) paintStyle = Paint.Style.FILL (mBinding.text3.parent as ViewGroup).clipChildren = false // 不限制子view在其范围内 mBinding.text3.setTextColor(Color.WHITE) mBinding.text3.background = drawable3MaterialShapeDrawable源码(有删减):public class MaterialShapeDrawable extends Drawable implements TintAwareDrawable, Shapeable { @Override public void draw(@NonNull Canvas canvas) { fillPaint.setColorFilter(tintFilter); final int prevAlpha = fillPaint.getAlpha(); fillPaint.setAlpha(modulateAlpha(prevAlpha, drawableState.alpha)); strokePaint.setColorFilter(strokeTintFilter); strokePaint.setStrokeWidth(drawableState.strokeWidth); final int prevStrokeAlpha = strokePaint.getAlpha(); strokePaint.setAlpha(modulateAlpha(prevStrokeAlpha, drawableState.alpha)); if (pathDirty) { calculateStrokePath(); calculatePath(getBoundsAsRectF(), path); pathDirty = false; maybeDrawCompatShadow(canvas); if (hasFill()) { drawFillShape(canvas); if (hasStroke()) { drawStrokeShape(canvas); static final class MaterialShapeDrawableState extends ConstantState { public MaterialShapeDrawableState(@NonNull MaterialShapeDrawableState orig) { shapeAppearanceModel = orig.shapeAppearanceModel; elevationOverlayProvider = orig.elevationOverlayProvider; strokeWidth = orig.strokeWidth; colorFilter = orig.colorFilter; fillColor = orig.fillColor; strokeColor = orig.strokeColor; tintMode = orig.tintMode; tintList = orig.tintList; alpha = orig.alpha; scale = orig.scale; shadowCompatOffset = orig.shadowCompatOffset; shadowCompatMode = orig.shadowCompatMode; useTintColorForShadow = orig.useTintColorForShadow; interpolation = orig.interpolation; parentAbsoluteElevation = orig.parentAbsoluteElevation; elevation = orig.elevation; translationZ = orig.translationZ; shadowCompatRadius = orig.shadowCompatRadius; shadowCompatRotation = orig.shadowCompatRotation; strokeTintList = orig.strokeTintList; paintStyle = orig.paintStyle; if (orig.padding != null) { padding = new Rect(orig.padding); 没什么特别的,你只需要知道除了可以设置描边之外,还可以设置背景、阴影等其他属性。说明ShapeAppearanceModel只能是实现Shapeable接口的View才可以设置,比如Chip、MaterialButtom等。而MaterialShapeDrawable其实就是Drawable,是所有View都可以设置的。描边问题这里借github一张图又是自定义view的常规操作,有一半画笔是在边界外面的,所以需要设置padding为strokeWidth的一半。默认圆角问题有细心的同学会发现啊,第一个常规的ShapeableImageView还是有一点圆角的,没错,属于默认的,跟踪一下源码来看一下:<style name="Widget.MaterialComponents.ShapeableImageView" parent="android:Widget"> <item name="strokeColor">@color/material_on_surface_stroke</item> <item name="shapeAppearance">?attr/shapeAppearanceMediumComponent</item> </style>第一个是颜色,很明显不是我们要找的,继续看shapeAppearanceMediumComponent<attr format="reference" name="shapeAppearanceMediumComponent"/>只是一个简单的属性,继续查找关联引用<item name="shapeAppearanceMediumComponent"> @style/ShapeAppearance.MaterialComponents.MediumComponent </item>又引用了一个style,继续看ShapeAppearance.MaterialComponents.MediumComponent这个style<style name="ShapeAppearance.MaterialComponents.MediumComponent"> <item name="cornerSize">@dimen/mtrl_shape_corner_size_medium_component</item> </style>哦豁,看到了熟悉的属性cornerSize,藏的还挺深,继续看看数值是多少<dimen name="mtrl_shape_corner_size_medium_component">4dp</dimen>默认4dp。那如果不想要这个圆角怎么办呢,可以学习源码仿写一个,不过上面也看到了,有点绕,不如直接写个style搞定: <!--ShapeableImageView 去圆角--> <style name="Corner0Style"> <item name="cornerSize">0dp</item> </style>然后引用app:shapeAppearance="@style/Corner0Style"效果:ok,到这里就差不多了,虽然还有很多相关知识点没有提及,但是也不少了,不如自己去尝试一番,慢慢消化。Githubhttps://github.com/yechaoa/MaterialDesign感谢ShapeableImageView 官方文档ShapeAppearanceModel 官方文档Android Material组件使用详解Android Notes|玩转 ShapeableImageViewMaterial Components——Shape的处理最后写作不易,如果对你有一丢丢帮助或启发,感谢点赞支持 ^ - ^
效果前言先来看一下MaterialButton是什么由上图可以看到MaterialButton也没有什么神秘的,不过是Button的一个子类而已,但是经过谷歌的封装之后,在符合Material Design的基础上,使用起来更加方便了,且容易实现预期效果。使用引入material包implementation 'com.google.android.material:material:1.2.1'常规<com.google.android.material.button.MaterialButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/app_name" android:textAllCaps="false" />与Button使用无异,textAllCaps是取消全部大小写。图标<com.google.android.material.button.MaterialButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/app_name" android:textAllCaps="false" app:icon="@mipmap/ic_launcher" />app:icon属性指定图标。圆角<com.google.android.material.button.MaterialButton android:id="@+id/btn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/app_name" android:textAllCaps="false" app:cornerRadius="25dp" app:icon="@mipmap/ic_launcher" app:iconGravity="end" />app:cornerRadius属性指定圆角大小。Button描边<com.google.android.material.button.MaterialButton android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/app_name" android:textAllCaps="false" app:cornerRadius="25dp" app:strokeColor="@color/black" app:strokeWidth="3dp" />app:strokeColor 描边颜色app:strokeWidth 描边宽度文字描边<com.google.android.material.button.MaterialButton style="@style/Widget.MaterialComponents.Button.OutlinedButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/app_name" android:textAllCaps="false" app:cornerRadius="5dp" app:rippleColor="@color/red" app:strokeColor="@color/red" app:strokeWidth="3dp" />与上面不同的是,这里用了style,区别在于上面的是常规Button状态下的描边,这个是文字Button状态下的描边。app:rippleColor 点击波纹颜色文字按钮<com.google.android.material.button.MaterialButton style="@style/Widget.MaterialComponents.Button.TextButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="20dp" android:text="@string/app_name" android:textAllCaps="false" />与常规使用方法一样,但是加了style,这个style在未选中的情况下,对背景色设置了透明。圆形Button<com.google.android.material.button.MaterialButton android:layout_width="100dp" android:layout_height="100dp" android:layout_marginTop="20dp" android:insetTop="0dp" android:insetBottom="0dp" android:text="@string/login" android:textAllCaps="false" android:textSize="20sp" app:cornerRadius="999dp" app:strokeColor="@color/orange" app:strokeWidth="5dp" />这里为什么来个圆形Button呢,是因为涉及到两个属性android:insetTop 上边距android:insetBottom 下边距这两个参数默认是6dp,不设置为0dp的话,就不是一个规则的圆。关于其他属性的默认参数,可以在xml文件的右上角,选中Design面板,选择要查看的View即可。author:yechaoa源码分析icon唯一不足的是MaterialButton不能像chip一样给icon设置事件。来看看源码中 icon具体是什么实现的:public void setIcon(@Nullable Drawable icon) { if (this.icon != icon) { this.icon = icon; updateIcon(/* needsIconUpdate = */ true); }这里比较简单,继续看调用的updateIcon方法private void updateIcon(boolean needsIconUpdate) { // Forced icon update if (needsIconUpdate) { resetIconDrawable(isIconStart); return; if (hasIconChanged) { resetIconDrawable(isIconStart); }有省略,看关键两段代码都调用了resetIconDrawable方法,继续private void resetIconDrawable(boolean isIconStart) { if (isIconStart) { TextViewCompat.setCompoundDrawablesRelative(this, icon, null, null, null); } else { TextViewCompat.setCompoundDrawablesRelative(this, null, null, icon, null); }相信到这里很多人就明白了,icon的实现等同于drawableStart。只不过在MaterialButton中drawableStart是没有效果的,而是icon和iconGravity配合使用来达到效果。属性关于xml属性,我做了一个整理 Githubhttps://github.com/yechaoa/MaterialDesign
效果分析效果图来看,点击事件触发view的展开收起,并在收起状态下保留了第一个子view显示,这个展开收起其实就是view的高度变化,所以只要控制好高度,就能很简单的实现这个效果。步骤1.初始化参数 设置方向等2.根据动画执行进度计算高度初始化class ExpandLinearLayout : LinearLayout { //是否展开,默认展开 private var isOpen = true //第一个子view的高度,即收起保留高度 private var firstChildHeight = 0 //所有子view高度,即总高度 private var allChildHeight = 0 * 动画值改变的时候 请求重新布局 private var animPercent: Float = 0f constructor(context: Context) : super(context) { initView() constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) { initView() constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super( context, attributeSet, defStyleAttr initView() private fun initView() { //横向的话 稍加修改计算宽度即可 orientation = VERTICAL animPercent = 1f isOpen = true }定义一个类ExpandLinearLayout ,继承自LinearLayout,当然也可以是其他的view。然后重写构造方法,并在构造方法里面调用initView方法。在initView方法中,我们对一些参数进行初始化操作,比如方向、默认展开。计算高度ok,这个就是重点了。因为只是view本身高度的变化,我们只需要重写onMeasure去计算高度即可。来看onMeasure: override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) //重置高度 allChildHeight = 0 firstChildHeight = 0 if (childCount > 0) { //遍历计算高度 for (index in 0 until childCount) { //这个地方实际使用中除了measuredHeight,以及margin等,也要计算在内 if (index == 0) { firstChildHeight = getChildAt(index).measuredHeight +getChildAt(index).marginTop + getChildAt(index).marginBottom +this.paddingTop + this.paddingBottom //实际使用时或包括padding等 allChildHeight += getChildAt(index).measuredHeight + getChildAt(index).marginTop + getChildAt(index).marginBottom //最后一条的时候 加上当前view自身的padding if (index == childCount - 1) { allChildHeight += this.paddingTop + this.paddingBottom // 根据是否展开设置高度 if (isOpen) { setMeasuredDimension( widthMeasureSpec, firstChildHeight + ((allChildHeight - firstChildHeight) * animPercent).toInt() } else { setMeasuredDimension( widthMeasureSpec, allChildHeight - ((allChildHeight - firstChildHeight) * animPercent).toInt() }onMeasure里面也是分了两个步骤的:遍历计算高度//遍历计算高度 for (index in 0 until childCount) { //这个地方实际使用中除了measuredHeight,以及margin等,也要计算在内 if (index == 0) { firstChildHeight = getChildAt(index).measuredHeight +getChildAt(index).marginTop + getChildAt(index).marginBottom +this.paddingTop + this.paddingBottom //实际使用时或包括padding等 allChildHeight += getChildAt(index).measuredHeight + getChildAt(index).marginTop + getChildAt(index).marginBottom //最后一条的时候 加上当前view自身的padding if (index == childCount - 1) { allChildHeight += this.paddingTop + this.paddingBottom }来看第一个if判断,记录了第一个子view的高度,这里需要注意,除了measuredHeight,margin也要算上,而且父view的内边距padding也要加上,因为如果父view的padding很大的话,收起时view可能会显示不出来的。然后就是总高度的计算,道理同上。来看最后一个if判断,同样总高度计算完之后也要加上父view的上下padding,才是完整的高度。第一个判断可以理解为收起状态的高度,第二个判断可以理解为展开状态的高度。展开收起逻辑// 根据是否展开设置高度 if (isOpen) { setMeasuredDimension( widthMeasureSpec, firstChildHeight + ((allChildHeight - firstChildHeight) * animPercent).toInt() } else { setMeasuredDimension( widthMeasureSpec, allChildHeight - ((allChildHeight - firstChildHeight) * animPercent).toInt() }因为第一个子view是保留显示的,所以在计算的时候都需要减去第一个子view的高度,就是剩余高度。剩余高度可以很简单的计算出来,但是如何在显示的时候不突兀呢。这里加一个动画,根据动画的执行进度来计算。展开:第一个子view的高度 + 剩余高度 × 0到1的Float动画值收起:总高度 - 剩余高度 × 0到1的Float动画值author:yechaoa动画写一个方法控制展开收起,并在展开收起的时候执行动画。 fun toggle(): Boolean { isOpen = !isOpen startAnim() return isOpen * 执行动画的时候 更改 animPercent 属性的值 即从0-1 @SuppressLint("AnimatorKeep") private fun startAnim() { //ofFloat,of xxxX 根据参数类型来确定 //1,动画对象,即当前view。2.动画属性名。3,起始值。4,目标值。 val animator = ObjectAnimator.ofFloat(this, "animPercent", 0f, 1f) animator.duration = 500 animator.start() }并修改我们的动画参数: /** * 动画值改变的时候 请求重新布局 private var animPercent: Float = 0f set(value) { field = value requestLayout() }set value的时候调用requestLayout(),重新执行onMeasure。调用xml<com.yechaoa.customviews.expand.ExpandLinearLayout android:id="@+id/ell" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#f5f5f5" android:orientation="vertical" android:padding="10dp"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:padding="10dp" android:text="@string/app_name" android:textColor="@android:color/holo_red_dark" android:textSize="20sp" /> </com.yechaoa.customviews.expand.ExpandLinearLayout>代码 ll_btn.setOnClickListener { val toggle = ell.toggle() tv_tip.text = if (toggle) "收起" else "展开" }扩展横向:计算高度变成计算宽度即可高度:可以根据xml自定义属性来控制保留高度总结总的来说,效果还是比较实用的,难度系数也不高,可以根据扩展自己去进一步完善。如果对你有一点点帮助,点个赞呗 ^ _ ^Githubhttps://github.com/yechaoa/CustomViews
效果音频播放,是比较常见或常用的功能,比如音乐播放器、新闻播报、听书等等,而恰巧如果你想自定义一个音频播放器的话,本文一定对你有帮助!常用方法start() 开始播放pause() 暂停播放stop() 停止播放prepare() 资源准备prepareAsync() 异步准备,不阻塞UI线程seekTo(int msec) 定位到指定位置,单位毫秒isLooping 是否循环播放isPlaying 播放状态duration 总时长currentPosition 当前位置release() 资源释放Component Tree具体的xml代码就不贴了,看一下组件树初始化 /** * 初始化 及 资源准备 private fun audioPrepare(path: String) { mMediaPlayer = MediaPlayer().apply { setDataSource(path)//支持文件、网络地址、uri prepareAsync()//异步准备,不阻塞UI线程 isLooping = false//循环播放 initMediaPlayerListener() }setDataSource,设置数据源,支持本地文件、网络请求的地址、uri等,看一下源码:setDataSource(FileDescriptor)setDataSource(String)setDataSource(Context, Uri)setDataSource(FileDescriptor, long, long)setDataSource(MediaDataSource)如果是本地文件,注意读写权限。prepareAsync() 异步准备,不阻塞UI线程然后看一下调用的initMediaPlayerListener 方法播放器监听事件及交互/** * 播放器监听事件 private fun initMediaPlayerListener() { mMediaPlayer?.setOnBufferingUpdateListener { mp, percent -> LogUtil.i("缓冲进度$percent%") mMediaPlayer?.setOnPreparedListener { LogUtil.i("准备完成") //在准备完成之后获取信息,否则会有异常 val duration = mMediaPlayer?.duration//时长 val currentPosition = mMediaPlayer?.currentPosition//当前位置 LogUtil.i("当前位置$currentPosition/时长$duration") tv_currentPosition.text = formatDuration(currentPosition!!) tv_duration.text = formatDuration(duration!!) seek_bar.max = duration mMediaPlayer?.setOnCompletionListener { LogUtil.i("播放完毕") mMediaPlayer?.setOnErrorListener { mp, what, extra -> LogUtil.i("播放错误") return@setOnErrorListener true mMediaPlayer?.setOnSeekCompleteListener { LogUtil.i("定位完成") seek_bar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { tv_currentPosition.text = formatDuration(seekBar!!.progress) override fun onStartTrackingTouch(seekBar: SeekBar?) { override fun onStopTrackingTouch(seekBar: SeekBar?) { //拖动结束之后再设置,如果在onProgressChanged中设置会有杂音 mMediaPlayer?.seekTo(seekBar!!.progress) tv_currentPosition.text = formatDuration(seekBar!!.progress) btn_start.setOnClickListener { audioStart() btn_pause.setOnClickListener { audioPause() btn_seek.setOnClickListener { seek_bar.progress = (seek_bar.max * 0.8).roundToInt() mMediaPlayer?.seekTo(seek_bar!!.progress) tv_currentPosition.text = formatDuration(seek_bar!!.progress) audioStart() btn_restart.setOnClickListener { audioRestart() }主要 是一些播放器的监听事件和按钮操作事件。https://blog.csdn.net/yechaoa着重介绍两个:1、setOnPreparedListener注意,在获取资源时长的时候,需要在播放器准备完成之后获取,否则会有异常:Attempt to call getDuration in wrong state: mPlayer=0x7244676280, mCurrentState=4 error (-38, 0)并会回调OnErrorListener。然后设置显示,并把时长赋值给seek_bar的最大值。2、setOnSeekBarChangeListener3个方法:onProgressChanged 进度改变onStartTrackingTouch 开始拖动onStopTrackingTouch 停止拖动我们需要在改变中和改变后对当前播放时长进行更新,并在最后的位置进行播放操作。如果程序上没有定位到指定播放位置这种操作的话,不要在onProgressChanged中执行播放操作,因为频繁的进度改变,频繁的调用播放,会有杂音。所以建议用户手动拖动来触发播放。如果非要程序可以跳到指定位置播放的话,建议如下操作: btn_seek.setOnClickListener { seek_bar.progress = (seek_bar.max * 0.8).roundToInt() mMediaPlayer?.seekTo(seek_bar!!.progress) tv_currentPosition.text = formatDuration(seek_bar!!.progress) audioStart() }手动赋值progress ,并调用播放。格式化播放时间这个获取时长返回的是毫秒,所以我们还需要对其格式化操作。 /** * 格式化播放时间 private fun formatDuration(duration: Int): String { val d = duration / 1000 val minute = d / 60 val second = d % 60 val m: String = if (minute < 10) "0$minute" else "$minute" val s: String = if (second < 10) "0$second" else "$second" return "$m:$s" }做了一个判断,不足两位数则前位补0。开始播放 /** * 开始播放 private fun audioStart() { mMediaPlayer?.run { if (!this.isPlaying) { start() startTimer() }因为没有播放中的回调接口,所以这里启动一个Timer获取当前位置并更新UITimer更新UI/** * 每隔一秒执行一次,更新当前播放时间 private fun startTimer() { mTimer = Timer().apply { schedule(object : TimerTask() { override fun run() { //非ui线程不能更新view,所以这里赋值给seek_bar,在seek_bar的事件中去更新 seek_bar.progress = mMediaPlayer!!.currentPosition //tv_currentPosition.text = formatDuration(mMediaPlayer!!.currentPosition) }, 0, 1000) }这里要注意,非ui线程不能更新view,所以这里赋值给seek_bar,在seek_bar的onProgressChanged 回调中去更新。暂停播放/** * 暂停播放 private fun audioPause() { mMediaPlayer?.run { if (this.isPlaying) { pause() cancelTimer() }同样,暂停的时候取消Timer,做到资源及时回收。取消Timer private fun cancelTimer() { mTimer?.run { cancel() mTimer = null }暂停/继续 播放 /** * 暂停/继续 播放 private fun audioToggle() { mMediaPlayer?.run { if (this.isPlaying) { audioPause() } else { audioStart() }如果只有一个事件触发的话,可以这么来写。重新播放播放器并没有自带restart()方法,不过我们可以手动把播放位置改到初始值,并调用播放。 /** * 重新播放 private fun audioRestart() { mMediaPlayer?.run { //定位到指定位置,单位毫秒 seekTo(0) audioStart() seek_bar.progress = 0 tv_currentPosition.text = formatDuration(seek_bar!!.progress) //如果是下一首,可以调用reset()重置,然后set新的数据源 }如果是下一首,可以调用reset()重置,然后set新的数据源。资源回收及时的回收,有利于更好的性能。 override fun onDestroy() { mAgentWeb.webLifeCycle.onDestroy() super.onDestroy() cancelTimer() mMediaPlayer?.run { stop() release() mMediaPlayer = null }ok,到此就讲解完了。
效果https://ucc.alicdn.com/images/user-upload-01/20210105173639401.gif#pic_center修改系统音量这种操作还是挺常见的,一般在多媒体开发中都多少会涉及到。常用方法获取音频管理器mAudioManager = this.getSystemService(Context.AUDIO_SERVICE) as AudioManager获取媒体音量最大值mMaxVolume = mAudioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)获取系统当前媒体音量mCurrentVolume = mAudioManager.getStreamVolume(AudioManager.STREAM_MUSIC)这里涉及到几个常见音量类型:STREAM_VOICE_CALL 通话STREAM_SYSTEM 系统STREAM_RING 铃声STREAM_MUSIC 媒体音量STREAM_ALARM 闹钟STREAM_NOTIFICATION 通知获取系统音量模式mAudioManager.ringerMode音量模式:RINGER_MODE_NORMAL 正常RINGER_MODE_SILENT 静音RINGER_MODE_VIBRATE 震动设置系统媒体音量来看一下是如何修改音量的 btn_add.setOnClickListener { if (mCurrentVolume < mMaxVolume) { mCurrentVolume++ } else { mCurrentVolume = mMaxVolume updateNum(mCurrentVolume) setStreamVolume(mCurrentVolume) btn_reduce.setOnClickListener { if (mCurrentVolume > 0) { mCurrentVolume-- } else { mCurrentVolume = 0 updateNum(mCurrentVolume) setStreamVolume(mCurrentVolume) 注意,这里要判断一下是否超出了音量的最大值最小值。在事件中,除了判断最大值最小值之外,还调用了两个方法updateNum更新页面显示:/** * 更新页面显示 private fun updateNum(volume: Int) { tv_volume.text = volume.toString() seekBar.progress = volume }还调用了setStreamVolume方法,这里就涉及到setStreamVolume和adjustStreamVolume的区别:setStreamVolume 直接设置音量,指哪打哪adjustStreamVolume 步长式设置音量,即10,20,30这样阶梯式二者都可以设置音量,可以根据自己的业务需求来选择。setStreamVolume来看一下具体的setStreamVolume方法:private fun setStreamVolume(volume: Int) { mAudioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, AudioManager.FLAG_SHOW_UI) }参数1:音量类型参数2:音量数值参数3:AudioManager.FLAG_SHOW_UI 调整音量时显示系统音量进度条 , 0 则不显示AudioManager.FLAG_ALLOW_RINGER_MODES 是否铃声模式AudioManager.FLAG_VIBRATE 是否震动模式AudioManager.FLAG_SHOW_VIBRATE_HINT 震动提示AudioManager.FLAG_SHOW_SILENT_HINT 静音提示AudioManager.FLAG_PLAY_SOUND 调整音量时播放声音adjustStreamVolume音量递增 private fun adjustRaise() { mAudioManager.adjustStreamVolume( AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI }private fun adjustRaise() { mAudioManager.adjustStreamVolume( AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI }参数1:音量类型参数2:音量调整方向AudioManager.ADJUST_RAISE 音量逐渐递增AudioManager.ADJUST_LOWER 音量逐渐递减AudioManager.ADJUST_SAME 不变参数3:同setStreamVolume参数3音量递减private fun adjustLower(volume: Int) { mAudioManager.adjustStreamVolume( AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI }Github: https://github.com/yechaoa/BrightnessAndVolume监听音量控制按键除了我们手动去改之外,用户也可以通过物理按键或是耳机来控制音量,这时,我们理应也要做出相应的改变,所以,还需要对音量按键做监听才行。这里就用到熟悉的老方法了,重写Activity的onKeyDown方法:/** * 监听并接管系统的音量按键, * 注意:最好保持原有逻辑不变 override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { when (keyCode) { //音量+按键 KeyEvent.KEYCODE_VOLUME_UP -> { if (mCurrentVolume < mMaxVolume) { mCurrentVolume++ } else { mCurrentVolume = mMaxVolume updateNum(mCurrentVolume) setStreamVolume(mCurrentVolume) return true //音量-按键 KeyEvent.KEYCODE_VOLUME_DOWN -> { if (mCurrentVolume > 0) { mCurrentVolume-- } else { mCurrentVolume = 0 updateNum(mCurrentVolume) setStreamVolume(mCurrentVolume) return true return super.onKeyDown(keyCode, event) }其实很简单,拦截事件,然后执行我们的逻辑就行了。总结总的来说,代码量并不多,难度系数也不高,唯一要注意的是各个参数的类型,要根据自己的实际业务来选择即可。ok,至此关于修改音量的讲解就全部结束了,如果对你有用,就点个赞吧^ - ^Githubhttps://github.com/yechaoa/BrightnessAndVolume
效果https://ucc.alicdn.com/images/user-upload-01/20210105150227259.gif#pic_center修改系统屏幕亮度这种操作还是挺常见的,一般在多媒体开发中都多少会涉及到。emmm 效果图好像看不出来变化。。不过不是很重要。。操作拆解上图中可以看到,分别有加减按钮和seekbar来控制亮度。后面会涉及到相关的事件。获取系统屏幕亮度 /** * 获取系统屏幕亮度(0-255) private fun getScreenBrightness(): Int { try { return Settings.System.getInt(this.contentResolver, Settings.System.SCREEN_BRIGHTNESS) } catch (e: SettingNotFoundException) { e.printStackTrace() return 0 注意,这里的返回值是0-255区间的。定义两个参数:private var mScreenBrightness: Int = 0 //当前屏幕亮度private var ratio: Int = 25 //每次加减的比例因为返回值最大是255,假设亮度调节是10档,每次加减1档大约是25,这个精度可以自己控制。设置当前应用屏幕亮度,只当前有效加减按钮操作 btn_add.setOnClickListener { if (mScreenBrightness < (255 - ratio)) { mScreenBrightness += ratio } else { mScreenBrightness = 255 setWindowBrightness(mScreenBrightness) updateNum(mScreenBrightness) btn_reduce.setOnClickListener { if (mScreenBrightness > ratio) { mScreenBrightness -= ratio } else { mScreenBrightness = 1 setWindowBrightness(mScreenBrightness) updateNum(mScreenBrightness) 如果设置亮度的值大于255了,不会报错,但是会回到初始值,所以加减操作的时候要判断一下最大值最小值。接下来看一下核心方法setWindowBrightness: /** * 设置当前应用屏幕亮度,只当前有效 private fun setWindowBrightness(brightness: Int) { val window = window val lp = window.attributes lp.screenBrightness = brightness / 255.0f window.attributes = lp }很简单,设置window的属性即可。这个只会对当前页面有效,返回页面或退到后台,屏幕亮度都会恢复到初始值状态。updateNum方法是更新页面显示: /** * 更新页面显示 private fun updateNum(mScreenBrightness: Int) { //转float 取四舍五入 val i: Int = (mScreenBrightness / (ratio.toFloat())).roundToInt() tv_brightness.text = i.toString() seekBar.progress = i 其实到这里,已经能满足大部分的需求了。Github: https://github.com/yechaoa/BrightnessAndVolume设置系统屏幕亮度,影响所有页面和app前面讲到的其实是单页面的亮度设置,也可以修改系统的屏幕亮度,即影响所有的页面和app,一般不会有这种操作。这也涉及到一个高级隐私权限,是否允许修改系统设置,且需要在app设置页面手动授权。且需要先在manifest中添加: <!-- 修改系统屏幕亮度 --> <uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions" /> 这里分几个小步骤:判断权限有则修改亮度无则引导授权seekBar操作 seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { Log.i("onProgressChanged----", "" + progress) mScreenBrightness = progress * ratio //判断是否有修改系统设置权限 if (Settings.System.canWrite(this@BrightnessActivity)) { setScreenBrightness(mScreenBrightness) updateNum(mScreenBrightness) } else { Toast.makeText(this@BrightnessActivity, "没有修改权限", Toast.LENGTH_SHORT).show() // 打开允许修改系统设置权限的页面 val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:$packageName")) startActivityForResult(intent, mRequestCode) override fun onStartTrackingTouch(seekBar: SeekBar?) { override fun onStopTrackingTouch(seekBar: SeekBar?) { 用Settings.System.canWrite来判断是否已授权。已授权看setScreenBrightness方法: /** * 设置系统屏幕亮度,影响所有页面和app * 注意:这种方式是需要手动权限的(android.permission.WRITE_SETTINGS) private fun setScreenBrightness(brightness: Int) { try { //先检测调节模式 setScreenManualMode() //再设置 Settings.System.putInt(this.contentResolver, Settings.System.SCREEN_BRIGHTNESS, brightness) } catch (e: SettingNotFoundException) { e.printStackTrace() 我们看到在设置之前,还有一步操作是先检测调节模式,因为如果当前亮度是自动调节的,需要改为手动才可以。 /** * 设置系统亮度调节模式(SCREEN_BRIGHTNESS_MODE) * SCREEN_BRIGHTNESS_MODE_MANUAL 手动调节 * SCREEN_BRIGHTNESS_MODE_AUTOMATIC 自动调节 private fun setScreenManualMode() { try { //获取当前系统亮度调节模式 val mode = Settings.System.getInt(this.contentResolver, Settings.System.SCREEN_BRIGHTNESS_MODE) //如果是自动,则改为手动 if (mode == Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC) { Settings.System.putInt( this.contentResolver, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL } catch (e: SettingNotFoundException) { e.printStackTrace() 亮度调节模式SCREEN_BRIGHTNESS_MODE_MANUAL 手动调节SCREEN_BRIGHTNESS_MODE_AUTOMATIC 自动调节未授权未授权的情况下,要提示并引导用户去授权 Toast.makeText(this@BrightnessActivity, "没有修改权限", Toast.LENGTH_SHORT).show() // 打开允许修改系统设置权限的页面 val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, Uri.parse("package:$packageName")) startActivityForResult(intent, mRequestCode) 同时,检测返回结果并处理即可 /** * 处理返回结果 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == mRequestCode) { if (Settings.System.canWrite(this@BrightnessActivity)) { setScreenBrightness(mScreenBrightness) } else { Toast.makeText(this@BrightnessActivity, "拒绝了权限", Toast.LENGTH_SHORT).show() 以上可以看到,不管是改模式还是改亮度,都是用的Settings.System.putInt方法,也就是修改了系统的设置,从而达到所有页面和app使用同一亮度的需求。监听系统亮度变化以上两种方式其实都是我们手动去改的,那如果用户自己去改变了亮度呢,我们页面理应也要做出相应的改变,所以,还需要去监听系统的亮度变化。这里也分几个小步骤:注册监听处理变化注销监听注册监听 /** * 注册监听 系统屏幕亮度变化 private fun registerContentObserver() { this.contentResolver?.registerContentObserver( Settings.System.getUriFor(Settings.System.SCREEN_BRIGHTNESS), true, mBrightnessObserver 处理变化 /** * 监听系统亮度变化 private val mBrightnessObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { override fun onChange(selfChange: Boolean) { super.onChange(selfChange) try { this@BrightnessActivity.contentResolver?.let { mScreenBrightness = Settings.System.getInt(it, Settings.System.SCREEN_BRIGHTNESS) updateNum(mScreenBrightness) setWindowBrightness(mScreenBrightness) } catch (e: SettingNotFoundException) { e.printStackTrace() 注销监听 override fun onDestroy() { super.onDestroy() //注销监听 this.contentResolver?.unregisterContentObserver(mBrightnessObserver) }ok,至此关于修改屏幕亮度的讲解就全部结束了,如果对你有用,就点个赞吧^ - ^Githubhttps://github.com/yechaoa/BrightnessAndVolume
在打release包的时候遇到了这个问题,算是比较常见,记一下解决思路。本文介绍了出现这个问题的原因,以及出现问题时我的建议和我的思路,着急的可以直接看解决方案。异常Subscriber class xxx and its super classes have no public methods with the @Subscribe annotation遇到异常不要慌,其实已经提示的很明显了,指定了具体的class,且明确告诉你这个类没有@Subscribe注解。原因但其实知道了问题后,可能依然会有疑惑,比如我,因为我这个类是有@Subscribe注解的,那是为什么呢?因为知道具体的class,也知道@Subscribe注解,所以很容易就能定位到是EventBus。随后去EventBus官网寻找答案,果然issues第一页就有相同的问题,作者团队是这么回复的:Check your R8 / ProGuard rules.https://github.com/greenrobot/EventBus#r8-proguard检查你的压缩/混淆规则。这才反应过来,原来开启了R8压缩导致的,minifyEnabled true buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' }那为什么开启了R8压缩就会导致上面的异常呢,是因为EventBus的注解使用了反射。反射 (Reflection) 会导致 R8 在跟踪代码时无法识别到代码的入口点。第三方库也可能用到反射,并且由于第三方库实际上是您的应用的一部分,您 (作为应用开发者) 将负责这些库以及您自己的代码中使用的反射。第三方库可能附带了它们自己的规则,但是切记,有些库不一定是为 Android 编写的,抑或是未考虑缩减问题,因此它们可能需要其他配置。解决方案明白了前因后果之后,解决方案就应然而生了。方案一关闭R8压缩minifyEnabled false但是这样会使得你的应用增大不少,比如你使用了某个第三方库时,应用中只使用了其中很小一部分,但打包时所有库代码都会保留在应用中。如果你不介意的话,这是最简单粗暴的解决方案。作者:https://blog.csdn.net/yechaoa方案二添加相应的压缩/混淆规则,比如EventBus:-keepattributes *Annotation* -keepclassmembers class * { @org.greenrobot.eventbus.Subscribe <methods>; -keep enum org.greenrobot.eventbus.ThreadMode { *; } # And if you use AsyncExecutor: -keepclassmembers class * extends org.greenrobot.eventbus.util.ThrowableFailureEvent { <init>(java.lang.Throwable); }总结其实问题并不复杂,关键是解决问题的过程,我想传达以下几点:遇到问题先不要慌,也不要盲目搜索答案,这样会浪费时间,且可能混淆你的视线先认真仔细的看一下日志,一般日志都会给你提示结合一切可用信息,快速准确的定位问题如果非要搜,建议先去官方文档找答案我遇到这个异常是怎么解决的:根据日志提示,我定位到问题是EventBus查看使用教程,并没有发现什么问题在EventBus的issues中看看是否有同样的问题,果然是有的,并知道了是R8压缩导致的去google官网查看R8相关介绍,并知道了原因,且提供了解决方案举一反三,也适用其他的问题。如果对你有用或启发的话,点个赞呗 ^ _ ^参考EventBusR8官方文档
实现效果:亦或者是在弹窗中显示,都是一样的。代码比较简单,涉及到SpannableStringBuilder知识点用到两个Span:ClickableSpan 处理点击事件ForegroundColorSpan 设置修饰代码: private fun setText() { //拼接字符串 val spanBuilder = SpannableStringBuilder("同意") * 服务协议 var span = SpannableString("服务协议") //服务协议点击事件 span.setSpan(object : ClickableSpan() { override fun onClick(widget: View) { Toast.makeText(this@LoginActivity,"服务协议",Toast.LENGTH_SHORT).show() }, 0, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) //设置颜色、下划线 span.setSpan( ForegroundColorSpan(Color.parseColor("#D1894A")), span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE spanBuilder.append(span) spanBuilder.append("与") * 隐私政策 span = SpannableString("隐私政策") span.setSpan(object : ClickableSpan() { override fun onClick(widget: View) { Toast.makeText(this@LoginActivity,"隐私政策",Toast.LENGTH_SHORT).show() }, 0, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) span.setSpan( ForegroundColorSpan(Color.parseColor("#D1894A")), span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE spanBuilder.append(span) // 赋值给TextView tv_service_agreement.movementMethod = LinkMovementMethod.getInstance() tv_service_agreement.text = spanBuilder //设置高亮颜色透明,因为点击会变色 tv_service_agreement.highlightColor = ContextCompat.getColor(applicationContext, R.color.transparent)
1.效果https://ucc.alicdn.com/images/user-upload-01/20201125155117589.gif#pic_centerMD风格的底部弹窗,比自定义dialog或popupwindow使用更简单,功能也更强大。其实细分来说,是BottomSheet、BottomSheetDialog、BottomSheetDialogFragment2.BottomSheethttps://ucc.alicdn.com/images/user-upload-01/20201125164718618.gif#pic_center与主界面同层级关系,可以事件触发,如果有设置显示高度的话,也可以拉出来,且不会影响主界面的交互。XML<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="com.yechaoa.materialdesign.activity.BottomSheetActivity"> <include android:id="@+id/include" layout="@layout/layout_toolbar" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="60dp" android:gravity="center" android:orientation="vertical"> <Button android:id="@+id/btn_bottom_sheet" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:text="BottomSheet" android:textAllCaps="false" /> </LinearLayout> <LinearLayout android:id="@+id/ll_bottom_sheet" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" app:behavior_peekHeight="80dp" app:layout_behavior="@string/bottom_sheet_behavior" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintLeft_toLeftOf="parent"> <TextView android:layout_width="match_parent" android:layout_height="80dp" android:background="@android:color/holo_red_light" android:gravity="center" android:text="上拉解锁隐藏功能" android:textColor="@color/white" android:textSize="20sp" /> <TextView android:layout_width="match_parent" android:layout_height="80dp" android:background="@android:color/holo_blue_light" android:gravity="center" android:text="a" android:textSize="20sp" /> <TextView android:layout_width="match_parent" android:layout_height="80dp" android:background="@android:color/holo_orange_dark" android:gravity="center" android:text="b" android:textSize="20sp" /> <TextView android:layout_width="match_parent" android:layout_height="80dp" android:background="@android:color/holo_green_light" android:gravity="center" android:text="c" android:textSize="20sp" /> </LinearLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout> 注意,这里需要协调布局CoordinatorLayout包裹才行app:behavior_peekHeight显示高度,不显示的话设置为0即可app:layout_behavior 标示这是一个bottom_sheet以上3个条件都是必须的。代码 btn_bottom_sheet.setOnClickListener { val behavior = BottomSheetBehavior.from(ll_bottom_sheet) if (behavior.state == BottomSheetBehavior.STATE_EXPANDED) { //如果是展开状态,则关闭,反之亦然 behavior.state = BottomSheetBehavior.STATE_COLLAPSED } else { behavior.state = BottomSheetBehavior.STATE_EXPANDED STATE_COLLAPSED: 折叠状态STATE_EXPANDED: 展开状态STATE_DRAGGING : 过渡状态STATE_SETTLING: 视图从脱离手指自由滑动到最终停下的这一小段时间STATE_HIDDEN : 默认无此状态(可通过app:behavior_hideable 启用此状态),启用后用户将能通过向下滑动完全隐藏 bottom sheet3.BottomSheetDialoghttps://ucc.alicdn.com/images/user-upload-01/20201125165449309.gif#pic_center可以看到弹出来之后是有一个半透明的蒙层的,这时候是影响主界面交互的,也就意味着此时BottomSheetDialog的优先级是要高于主界面的。代码 val bottomSheetDialog = BottomSheetDialog(this) bottomSheetDialog.setContentView(R.layout.dialog_bottom_sheet) bottomSheetDialog.show()dialog_bottom_sheet:<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center" android:paddingTop="80dp" android:paddingBottom="80dp" android:text="BottomSheetDialog" android:textSize="30sp" android:textStyle="bold" /> 比较简单的使用方式,直接实例化之后setContentView,然后调用show就可以了。这里只是一个展示效果,实际上使用场景可能会复杂一些,还要做一些操作等等,所以,也可以自定义dialog继承自BottomSheetDialog,然后处理自己的业务逻辑。比如:class MyDialog(context: Context) : BottomSheetDialog(context) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 4.BottomSheetDialogFragmenthttps://ucc.alicdn.com/images/user-upload-01/20201125170906448.gif#pic_center效果跟BottomSheetDialog差不多,代码跟DialogFragment差不多。代码class MyBottomSheetDialog : BottomSheetDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = super.onCreateDialog(savedInstanceState) val view = LayoutInflater.from(context).inflate(R.layout.dialog_my_bottom_sheet, null) dialog.setContentView(view) initView(view) return dialog private fun initView(rootView: View) { //do something rootView.tv_cancel.setOnClickListener { dismiss() } }在创建dialog的时候引入布局,然后setContentView即可。调用:MyBottomSheetDialog().show(supportFragmentManager, "MyBottomSheetDialog")FragmentManagertag但是在实际开发中,我们的需求可能并不能满足于此,比如上部分圆角效果、指定高度等5.圆角效果先设置原有背景透明style.xml <!--实现BottomSheetDialog圆角效果--> <style name="BottomSheetDialog" parent="Theme.Design.Light.BottomSheetDialog"> <item name="bottomSheetStyle">@style/bottomSheetStyleWrapper</item> </style> <style name="bottomSheetStyleWrapper" parent="Widget.Design.BottomSheet.Modal"> <item name="android:background">@android:color/transparent</item> </style> onCreate中设置style override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, R.style.BottomSheetDialog) }设置我们自己的style在根布局的view上设置backgroundandroid:background="@drawable/shape_sheet_dialog_bg"shape_sheet_dialog_bg<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"> <corners android:topLeftRadius="15dp" android:topRightRadius="15dp" /> <solid android:color="@color/white" /> </shape>6.去掉背景阴影https://ucc.alicdn.com/images/user-upload-01/20201125191542510.gif#pic_center可以看到是没有阴影蒙版的,还是style,设置backgroundDimEnabled为false即可<!--实现BottomSheetDialog圆角效果 且无背景阴影--> <style name="BottomSheetDialogBg" parent="Theme.Design.Light.BottomSheetDialog"> <item name="bottomSheetStyle">@style/bottomSheetStyleWrapper</item> <item name="android:backgroundDimEnabled">false</item> </style> <style name="bottomSheetStyleWrapper" parent="Widget.Design.BottomSheet.Modal"> <item name="android:background">@android:color/transparent</item> </style>7.设置固定高度https://ucc.alicdn.com/images/user-upload-01/20201125192224955.gif#pic_center可以看到这个弹窗一开始并不是完全展开的,但是可以继续拉出来。代码override fun onStart() { super.onStart() //拿到系统的 bottom_sheet val view: FrameLayout = dialog?.findViewById(R.id.design_bottom_sheet)!! //获取behavior val behavior = BottomSheetBehavior.from(view) //设置弹出高度 behavior.peekHeight = 350 }有一个peekHeight属性可以设置高度,但是这个api并没有开放给我们,不过也有解决办法我们可以查看bottomSheetDialog.setContentView的源码@Override public void setContentView(@LayoutRes int layoutResId) { super.setContentView(wrapInBottomSheet(layoutResId, null, null)); }这里调用了wrapInBottomSheet,继续探索private View wrapInBottomSheet(int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) { ensureContainerAndBehavior(); return container; }多余的可以不用看,直接探索ensureContainerAndBehavior();方法/** Creates the container layout which must exist to find the behavior */ private FrameLayout ensureContainerAndBehavior() { if (container == null) { container = (FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null); FrameLayout bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet); behavior = BottomSheetBehavior.from(bottomSheet); behavior.addBottomSheetCallback(bottomSheetCallback); behavior.setHideable(cancelable); return container; }到这里,我们就可以看到源码是怎么获取behavior的了,获取到behavior之后就可以调用peekHeight设置高度了。8.设置默认全屏显示既然有了上面的方法,是不是有思路了,那有人说了,我把高度设置全屏不就完事了吗事实上还真不行,BottomSheetDialogFragment只会显示实际高度,即布局有效高度,即使根布局高度match_parent也不行。既然我们自己的view不行,那就从BottomSheetDialogFragment本身下手,还记得上面我们通过dialog?.findViewById(R.id.design_bottom_sheet)!!拿到的view吗,我们试一下设置这个view的高度行不行view.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT看看效果首先是像默认效果一样,当内容大于等于全屏的时候,会先到达一个高度,即上面效果的高度,然后继续向上滑的话,可以铺满全屏。虽然不是预想的效果,但是既然还可以向上滑动至全屏,说明我们设置的高度是有效的,只是没有一次性展开而已,还记得前面提到的状态state吗,设置一下试试behavior.state = BottomSheetBehavior.STATE_EXPANDED看看效果https://ucc.alicdn.com/images/user-upload-01/20201125194433995.gif#pic_center可以了,这下是直接就全屏了,但是向下拉的时候发现,并没有一次性收起,而是先停在了全屏时显示的默认位置,我们再设置高度为全屏试试behavior.peekHeight = 3000实际高度可以自己计算最终代码 override fun onStart() { super.onStart() //拿到系统的 bottom_sheet val view: FrameLayout = dialog?.findViewById(R.id.design_bottom_sheet)!! //设置view高度 view.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT //获取behavior val behavior = BottomSheetBehavior.from(view) //设置弹出高度 behavior.peekHeight = 3000 //设置展开状态 behavior.state = BottomSheetBehavior.STATE_EXPANDED }看看最终效果https://ucc.alicdn.com/images/user-upload-01/20201125194954313.gif#pic_center效果是ok的,但是也有一点点不足,我们下拉的距离快到底部的时候才能关闭,所以建议在弹窗中也加上关闭的操作。9.监听展开收起behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { when(newState){ BottomSheetBehavior.STATE_EXPANDED->{} BottomSheetBehavior.STATE_COLLAPSED->{} BottomSheetBehavior.STATE_DRAGGING->{} BottomSheetBehavior.STATE_SETTLING->{} BottomSheetBehavior.STATE_HIDDEN->{} override fun onSlide(bottomSheet: View, slideOffset: Float) { })可以写在dialog里,也可以接口抛出去。10.Githubhttps://github.com/yechaoa/MaterialDesignok,至此BottomSheetDialog相关的功能完全演示完了。如果对你有用,点个赞呗 ^ _ ^
除了xml直接设置之外,可能会在动态添加TextView或自定义view的时候进行设置。maxLengthxmlandroid:maxLength="3"代码TextView.filters = arrayOf<InputFilter>(LengthFilter(3))这里会有一个问题,就是使用maxLength会让ellipsize设置失效android:ellipsize="end"上面的代码就没用了,但是还有另外一种方式maxEmsxmlandroid:maxEms="3"代码TextView.maxEms = 3此时ellipsize是起作用的,以上代码及表示最多显示3个字符。但是需要注意的是,大于3个字符的时候,这个最多显示是包含省略号的,实际则是2个字符。ellipsizexmlandroid:ellipsize="end"代码TextView.ellipsize = TextUtils.TruncateAt.END
在android开发中,因为各种原因,时常编译速度缓慢甚至卡住不动,切换到阿里云maven仓库之后,则可以提升编译速度。googlehttps://maven.aliyun.com/repository/googlejcenterhttps://maven.aliyun.com/repository/publicpublic(central仓和jcenter仓的聚合仓)https://maven.aliyun.com/repository/publicgradle-pluginhttps://maven.aliyun.com/repository/gradle-pluginjcenter和public二选一即可。build.gradle buildscript { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } mavenLocal() mavenCentral() google() jcenter() allprojects { repositories { maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } mavenLocal() mavenCentral() google() jcenter() 阿里云maven
一般打开url有以下几种方式:调用默认浏览器用WebView用CustomTabsIntent默认浏览器的话,是跳转到别的app,我方app就切换到后台了,至于什么时候返回回来就不确定了,且在后台有被回收的风险,不利于业务开展,如果是打开浏览器之后就完成任务的情况,可以使用。WebView是我们比较常用的,如果页面可控,且需要交互的,还是建议使用webview的。CustomTabs在用户的默认浏览器中显示网页,相当于在自己的app中用默认浏览器打开网页,效果类似WebView,但使用起来比WebView轻量,也更安全,性能更好。今天主要说说CustomTabs。效果:https://ucc.alicdn.com/images/user-upload-01/20201218154520480.gif#pic_center这是打开应用宝的一个App下载链接。可以看到加载速度还是很快的,就像打开activity一样。引入customtabs其实是browser包下的,browser是jetpack下的,现在统一纳入到androidx。dependencies { implementation "androidx.browser:browser:1.3.0" }使用简单使用的话,只要一行代码CustomTabsIntent.Builder().build().launchUrl(context, uri)就这么简单。上面提到效果图中打开网页像打开activity一样,如果更像呢,主题一直是不是更像了。定制uiCustomTabsIntent也支持定制ui//设置颜色方案 val schemeParams = CustomTabColorSchemeParams.Builder() .setToolbarColor(ContextCompat.getColor(context, R.color.colorPrimary)) .setSecondaryToolbarColor(ContextCompat.getColor(context, R.color.colorPrimaryDark)) .build() CustomTabsIntent.Builder() .setDefaultColorSchemeParams(schemeParams) .build().launchUrl(context, uri) CustomTabColorSchemeParams支持toolbarColorsecondaryToolbarColornavigationBarColornavigationBarDividerColor看看效果菜单比如setActionButtonval bitmap = BitmapFactory.decodeResource(this.resources, R.mipmap.ic_setting) val intent = Intent(context, LoginActivity::class.java) val activity = PendingIntent.getActivity(context, 0, intent, 0) //内置启动 CustomTabsIntent.Builder() .setActionButton(bitmap, "自定义Action", activity) .setDefaultColorSchemeParams(schemeParams) .build().launchUrl(context, uri) 这里用PendingIntent指定后续操作,比如打开一个页面或者发送一个广播。可以看到右上角多了一个设置的小图标。除此之外还有很多别的api,比如:addMenuItem(String label, PendingIntent pendingIntent)setCloseButtonIcon(Bitmap icon)setShowTitle(boolean showTitle)等等完整代码 btn_launch.setOnClickListener { openWebPage(this, Uri.parse("https://www.baidu.com")) private fun openWebPage(context: Context, uri: Uri) { if (context.isChromeSupported()) { //设置颜色方案 val schemeParams = CustomTabColorSchemeParams.Builder() .setToolbarColor(ContextCompat.getColor(context, R.color.colorPrimary)) .setSecondaryToolbarColor(ContextCompat.getColor(context, R.color.colorPrimaryDark)) .build() val bitmap = BitmapFactory.decodeResource(this.resources, R.mipmap.ic_setting) val intent = Intent(context, LoginActivity::class.java) val activity = PendingIntent.getActivity(context, 0, intent, 0) //内置启动 CustomTabsIntent.Builder() .setActionButton(bitmap, "自定义Action", activity) .setDefaultColorSchemeParams(schemeParams) .build().launchUrl(context, uri) } else { //启动默认浏览器 context.startActivity(Intent(Intent.ACTION_VIEW, uri)) private fun Context.isChromeSupported(): Boolean { val serviceIntent = Intent(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION) serviceIntent.setPackage("com.android.chrome") val resolveInfos = packageManager.queryIntentServices(serviceIntent, 0) return !resolveInfos.isNullOrEmpty()
前言前段时间在看机会,本文就是我对求职过程的一个总结。同时,也作为一个面试官,来说说求职中需要注意的点。各大厂的面试会考核哪些知识点5年开发应该具备哪些技术要点当下市场行情如何,真的很卷吗写简历需要注意什么自我介绍怎么准备关于为什么离职未来职业规划问题如何避坑被问到自闭,如何调整心态我个人的学习方法其他注意事项面试题先上主菜一面技术京东GC原理,有哪几种GC方式HashMap原理Hybrid开发流程、机制WebView内存泄露包体积优化自定义View需要注意哪些事件分发机制ViewModel原理屏幕旋转ViewModel怎么做到保存数据的LiveData原理Flutter线程机制,单线程多线程组件化开发介绍一个自己的开源项目有参与过别人的开源项目吗字节跳动浏览器输入一个地址,按下回车,这个过程是什么样的简单介绍一下TCP简单介绍一下Https说说对称加密和非对称加密,说说公钥并发操作如何保证线程安全锁有哪些简单介绍一下HashMapArraylist是线程安全的吗知道CAS、原子性吗AtomicBoolean和boolean的区别用过哪些设计模式介绍一下观察者模式用过哪些排序算法介绍一下贪心算法介绍一下快排原理算法,给定一个有序不重复数组,使用时间复杂度小于N方的方法,找到所有两两相加等于target值的组合:int[] a={1,2,3,4,5,6,7,8}int target=7美团Java对象生命周期GC机制Glide缓存机制Glide对Bitmap是怎么优化的Okhttp请求流程Retrofit中的设计模式App启动流程Apk打包流程重构做了哪些工作内存泄露,举例场景LeakCanary实现原理Handler消息机制线程有哪些状态Hybrid开发介绍Js功底怎么样未来职业规划哔哩哔哩MVC,MVP,MVVM的区别使用MVVM有遇到什么问题吗协程原理协程并发怎么处理的热修复原理用热修复的过程中有遇到什么问题吗启动优化包体积优化绘制优化全埋点是怎么做的Apk更新流程多渠道打包怎么辨别华为的包更新别的应用市场的包算法,插入排序原理声网觉得做的最好的项目Jetpack用了哪些组件ViewModel如何数据共享ViewModel在Activity旋转时如何保存数据的ViewModel怎么创建的,存在哪使用ViewModel过程中有没有遇到什么问题LiveData原理,怎么派发数据的postValue是怎么执行的使用LiveData的过程中有没有遇到什么问题自定义View有哪些注意事项简单介绍一下协程多个协程怎么保证数据安全的算法,输入(H₂O) ×2,输出h4o2掌门教育笔试逻辑题Java有哪些数据结构Arraylist是线程安全的吗String,StringBuilde,StringBuffer的区别Java中的锁介绍一下,同步锁举例四大引用区别,场景举例Activity生命周期Fragment生命周期Fragment销毁生命周期执行顺序自定义流程,举例自定义View刷新方式有哪些,执行流程事件分发原理,举例,延伸滑动冲突怎么解决怎么自己实现一个长按事件Service是哪个线程,怎么通信Handler消息机制Handler发送消息是一定立即执行的吗Handler运行在哪个线程子线程可以创建Handler吗,写法有哪些区别Kotlin定义变量的方式有哪些lateinit怎么确保使用时已经初始化let,run,apply介绍,区别inline原理简单介绍下协程协程并发怎么处理协程底层是怎么实现的内存泄露原理,举例场景启动优化传音控股做的最有成就感的项目是哪个印象最深刻的项目是哪个LiveData底层实现原理启动优化包体积优化自定义View流程View异步加载有用过哪些设计模式介绍一下单例,饿汉式,懒汉式,双重锁代理模式和装饰模式的区别策略模式和状态模式的区别说说观察者模式有看过哪些三方库的源码Glide四层缓存机制AMS了解多少得物简单介绍一下HashMapHashMap调用put的执行流程Activity启动流程Binder机制Handler消息机制Handler是怎么实现主线程和子线程的通信的Looper卡死为什么不会造成主线程的阻塞了解过内存泄露吗是怎么发现内存泄露的,怎么处理的线上的内存泄露是怎么监控的LeakCanary实现原理软引用和弱引用的区别了解过ANR吗什么原因会造成ANRActivity的响应时间为什么是5s事件分发机制怎么解决滑动冲突的录音功能是怎么做的编码解码相关包体积优化做了哪些工作哈啰出行挑一个项目详细说一下,以及相关技术栈介绍一下协程机制说一下Dispatchers,withContext,Scope他们的作用说一下你理解的MVP,MVVMFlutter相关性能优化做了哪些工作编译速度怎么提升的内存泄露LeakCanary检测原理热修复,Sophix原理,ClassLoaderApk打包流程多线程并发,如何保证线程安全synchronized修饰方法和修饰代码块有什么区别小红书技术调研你是怎么做的,会考虑哪些因素说一下MVVM用过Jetpack中的哪些组件LiveData是怎么做数据派发的ViewModel屏幕旋转的时候怎么做到不丢失数据的使用MVVM的过程中有遇到什么问题吗性能优化做了哪些工作内存泄露有哪些场景LeakCanary检测原理如果让你做一个自动化的工具去检测图片过大并自动压缩你会怎么做自定义View画板是怎么做的怎么检测页面的FPSHandler在onCreate中发送大量数据会导致主线程卡顿吗LayoutInflater.inflate有几个参数,分别是什么意思其他厂整理补充Android中的Context了解多少Application里面可以弹窗吗Activity、Window、View三者的关系OkHttp中有哪些设计模式Retrofit中有哪些设计模式Retrofit.create做了哪些工作自定义一个圆角View协程launch有哪些参数说说by关键字代理和委托的区别双亲委托模式有几种获取view宽高的方式view.post为什么可以获取到getWidth和getMesureWidth的区别手写遍历二叉树手写双重锁单例手写插入排序手写双数组去重并排序如何判断链表有环还有一些公司的没有记,比如蔚来、中欧基金、阿里等,如果上面这些你能掌握大部分,自然也不在话下。二面、三面技术都是偏项目和综合能力,因人而异就直接整理了。你负责项目中的哪些模块介绍一下xx功能的流程项目的架构是怎样的Kotlin和Java混编有哪些需要注意的项目中有遇到哪些难点如果让你重构,你会怎么做学习的途径有哪些你觉得什么样的代码是好代码团队是怎么分工的怎么做需求管理期望什么样的团队怎么看待大前端方向一个新技术如何在团队里推广未来的规划是什么,你打算怎么实现认为自己的优点是什么,缺点是什么为什么离职HR面整理。为什么离职介绍一下过往的工作经历在上家公司你有什么收获吗目前看机会会考虑哪些因素你在之前的团队中是怎样一个角色有什么兴趣爱好吗最有成就感的一件事有没有做过什么不可思议的事你认为自己的优点是什么最近有在看什么书吗,有什么感想你一般遇到问题都是怎么解决的未来的职业规划是什么你对未来的公司有什么期望吗目前薪资期望薪资还有什么想要问我的吗技术要点针对上面的问题,我总结了一下面试前需要掌握的一些知识点:Java基础、Kotlin基础、Android基础(重要)App启动流程Handler消息机制View绘制流程事件分发机制Jetpack常用组件原理Kotlin协程原理性能优化多线程、并发组件化开发热修复原理常用三方库原理常见的设计模式数据结构和算法建议面前多练练手写算法,最好是拿笔在纸上写。关于算法,不过是基于数据结构去操作数据的思想而已。如果说限制了复杂度而想不出来的话,可以先写再看如何优化。Author : yechaoa市场行情目前并不是招聘旺季,但机会还是有的。可能有些同学看了上面的面试题觉得卷,正常的。现在市场越来越成熟稳定,对面试者的要求也更高,加上技术更新又快,从Java到Kotlin、到Flutter、到Compose等等,确实有很多东西要学,很多同学都表示学不动了,我觉得,盲目跟风不如好好沉淀。另外,再从面试官的角度聊聊。虽然说大部分面试确实是各种底层实现、底层原理、手写算法什么的,不过作为面试官来说,其实有些也并不是要你都掌握的,问的深,一方面是校验八股文,另一方面主要是技术摸底,看看你的技术边际在哪,所以说,一场面试下来,能答上大部分即是通过了,当然,越多越加分。只要技术够硬,都是机会。如何写好简历简历是开启面试的第一步,重要性不言而喻,一方面反应你的实力,另一方面也反应跟目标岗位的匹配度,不过很多JD都是复制粘贴,我个人也没有动态改简历。如何写好简历,一定要简洁且突出重点。我也面过不少人了,看过7-8页的简历,有些项目经历是没必要全都往上写的,面试官也看不过来,最好是保持在3页左右比较合适,项目经历比较丰富的同学,可以挑重点来写。我个人简历大概模板:个人信息技能清单工作经历/项目经历开源项目/博客教育经历我是MD排版,PDF格式,参考模板投简历时有一个建议,不要开放简历,要主动投递。开放简历会有各种邀请面试,也不好拒绝,就可能会因为没准备好而错失一些机会。可以先去其他公司找找感觉,再面心仪的公司。另外,一定要对自己简历上写的东西做到熟练,没用过的,不会的就别写了,万一问了不会,就是在给自己挖坑。自我介绍自我介绍需要好好准备,因为这可能是你整个面试环节中唯一的主动机会,也是引导面试官的第一步,可以介绍最近的项目经历啥的,以及相关技术栈等等,引导面试官往你擅长的领域提问。挑重点不要啰嗦,时间控制在两分钟左右。离职原因这个其实大家都心知肚明,但是回答的时候还是委婉些的好,不抱怨原则。不过HR总是有很多种问法,比如:你在上家公司才做了一年多,为什么选择离职呢?你在上家公司已经做了四年多,为什么选择离职呢?回答建议:想去更好的平台薪资与个人付出不成正比公司业务方向与个人职业规划出现偏离关于公司倒闭,我个人觉得没问题,但如果是干一家倒一家,那HR可能会否你…职业规划很多都会问这个问题,相似问题,你最近在学什么技术,看什么书。这块很多同学其实会放松警惕,看似无关紧要,实则暗藏玄机,因为在求职过程中,在学在看的,可能是自己薄弱的地方。比如你说未来想研究一下主流三方库的源码,学习优秀的设计理念,看似好像很努力很上进,其实面试官听到的是,主流三方库我只会用,不知道原理,我就是个API调用师。(尬不尬?)所以这块的回答一定要有深度或者广度,要有想象空间,但是不能太离谱,需要好好斟酌。回答建议:技术方向:要么全要么精,全栈或细分领域专家管理方向:有较强的沟通能力、协作能力,希望能做团队的领头羊关于心态准备前,可能有些同学看到面试题已经不自信了,感觉自己一半都答不上来,这是正常的,人的记忆是有限的,慢慢复习就好了,放平心态。面试中,可能有些同学会被问到自闭,其实大可不必,东边不亮西边亮,总有面试官会挖掘出你的亮点。而且面试中除了技术之外,也有很多客观因素,比如面试官的心情、你的状态等等。面试后,好与坏都坦然接受,及时做好复盘,查漏补缺,才能在下一次面试中有更好的发挥,这也是一个愈战愈勇的过程。学习方法我个人是梳理知识树,不会的就去补充,制定学习计划。我个人的学习方式:看官方文档,比如Kotlin文档,第一遍快读,有个大概印象,第二遍精读,关注一些细节。看书,还是Kotlin,先快读,再重点精读。我觉得比看视频方便,可以划重点记笔记,随时可以停下来进入思考状态,也很方便反复阅读,主要是没有干扰。看相关开源项目,学习优秀的设计理念、代码风格,三人行必有我师。看一些针对性的博客。实践,这个很重要,纸上得来终觉浅,绝知此事要躬行。其他注意事项尽量不要迟到,不管是现场还是视频。如果是现场面试,记得关注当天的天气,提前查一下路线。如果要修改面试时间,提前一天跟HR沟通。如果不去,不要直接放鸽子,跟HR说一声。手机电量保持充足,面前可以临阵磨枪。面试登记,字尽量写的好认一些,个人作为面试官时,不好认的扣分,字如其人,代码同理。不要作假,编造一个谎言往往需要更多的谎言去圆它。大厂一般面试周期较长,注意时间安排。保持自信,保持自信,保持自信。寄语多思考,看问题尽量看透本质。技术上的问题都能找到解决办法,如何在思维上打通才是需要思考的。举个例子:说一下Android系统启动流程。可能很多同学对这个问题没有头绪,或者看了也记不住。回想一下Android平台架构,相信大家对下面这张图应该是很熟悉了。ok,再次回到问题,简单流程就是从电源键触发唤醒Linux内核,然后虚拟机、Framework,最后System Apps。先理解大致流程,再去串联细节,比如其中涉及到的Zygote、AMS、Launcher等等。最后ok,终于啰嗦完了,不过句句发自肺腑,真心希望能帮助到一些同学。祝大家都能在心仪的公司,拿着不错的薪水,开心的写bug~
看一下我们自定义的WrapperOnClickListener/*public*/ class WrapperOnClickListener implements android.view.View.OnClickListener { private android.view.View.OnClickListener source; WrapperOnClickListener(android.view.View.OnClickListener source) { this.source = source; @Override public void onClick(android.view.View view) { //调用原有的 OnClickListener try { if (source != null) { source.onClick(view); } catch (Exception e) { e.printStackTrace(); //插入埋点代码 SensorsDataPrivate.trackViewOnClick(view); 很简单,也是实现系统的OnClickListener方法,然后在执行click的时候插入埋点代码。然后看一下trackViewOnClick方法: public static void trackViewOnClick(android.view.View view) { try { JSONObject jsonObject = new JSONObject(); jsonObject.put("element_type", view.getClass().getCanonicalName()); jsonObject.put("element_id", getViewId(view)); jsonObject.put("element_content", getElementContent(view)); Activity activity = getActivityFromView(view); if (activity != null) { jsonObject.put("activity", activity.getClass().getCanonicalName()); SensorsDataAPI.getInstance().trackClick("$AppClick", jsonObject); } catch (Exception e) { e.printStackTrace(); 比较简单,但是有两个参数是需要注意的:element_type 控件的类型,比如TextView、Buttonelement_id 控件的id,页面全路径 + 控件id即可表示唯一标示了然后就是trackClick方法了 public void trackClick(@androidx.annotation.NonNull String eventName, @androidx.annotation.Nullable JSONObject properties) { try { JSONObject jsonObject = new JSONObject(); jsonObject.put("event", eventName); // jsonObject.put("device_id", mDeviceId); JSONObject sendProperties = new JSONObject(mDeviceInfo); String act = properties.get("activity").toString(); //获取页面的参数 if (act.contains("SecondActivity")) { SecondActivity activity = (SecondActivity) SensorsDataPrivate.getCurrentActivity(); String userId = activity.getIntent().getStringExtra("userId"); properties.put("userId", userId); if (properties != null) { SensorsDataPrivate.mergeJSONObject(properties, sendProperties); jsonObject.put("extras", sendProperties); jsonObject.put("eventTime", System.currentTimeMillis()); jsonObject.put("sessionId", UUID.randomUUID().toString().replace("-", "")); Log.i(TAG, SensorsDataPrivate.formatJson(jsonObject.toString())); } catch (Exception e) { e.printStackTrace(); 跟页面的埋点基本差不太多,来看看数据:{ "event": "$AppClick", "extras": { "app_name": "TrackDemo", "screen_width": 1440, "screen_height": 2621, "app_version": "1.0", "os_version": "10", "model": "Android SDK built for x86", "manufacturer": "Google", "element_type": "androidx.appcompat.widget.AppCompatButton", "element_id": "button", "element_content": "点击传值跳转", "activity": "com.yechaoa.trackdemo.ui.MainActivity" "eventTime": 1603279293756, "sessionId": "b8d1aa32039a4fb1b2ece7772d60cd0e" 可以看到数据都是正常的,但是element_content字段并不能太过依赖,为什么呢,因为这个是获取的控件文本,不是所有的控件都有文本的,比如没有描述的ImageView、CheckBox等。这些都是系统的控件,那如果是我们自定义View怎么办呢,正好演示一下事件的手动埋点。 button2.setOnClickListener { val jsonObject = JSONObject() jsonObject.put("element_type", "androidx.constraintlayout.widget.ConstraintLayout") jsonObject.put("element_id", "自定义id") jsonObject.put("element_content", "自定义内容") jsonObject.put("id", 1234) jsonObject.put("activity", this.javaClass.canonicalName) SensorsDataAPI.getInstance().trackClick("AppClick", jsonObject) 看一下数据: { "event":"AppClick", "extras":{ "app_name":"TrackDemo", "screen_width":1440, "screen_height":2621, "app_version":"1.0", "os_version":"10", "model":"Android SDK built for x86", "manufacturer":"Google", "element_type":"androidx.constraintlayout.widget.ConstraintLayout", "element_id":"自定义id", "element_content":"自定义内容", "id":1234, "activity":"com.yechaoa.trackdemo.ui.SecondActivity", "userId":"111" "eventTime":1603283095128, "sessionId":"addbc3d8335244328fcd352221a7a11d" 加入自定义view监测不到的情况下,就可以用这种方式来手动埋点。除了正常的单个控件的点击事件之外,还有列表的item click事件、还有RatingBar等等,限于篇幅就不细说了,原理都是相通的,具体可以查看Demo。Demo:https://github.com/yechaoa/TrackDemo
Fragment上面是Activity的埋点,关于fragment书中并没有讲解,不过我们也可以按照生命周期的方式来处理,比如在BaseFragment中进行统一埋点,又或者单独处理,正好演示一下手动埋点的操作。示例: private var mBeginTime = 0L override fun onResume() { super.onResume() mBeginTime = System.currentTimeMillis() }首先在onResume中记录一下开始时间。 override fun onHiddenChanged(hidden: Boolean) { super.onHiddenChanged(hidden) val blankFragment = this if (hidden) { val activity = activity as SecondActivity val jsonObject = JSONObject() jsonObject.put("useActivity", true) jsonObject.put("fragment", activity.javaClass.canonicalName + blankFragment.javaClass.canonicalName + "-custom" SensorsDataAPI.getInstance().track("AppViewScreen", jsonObject, mBeginTime) 然后在onHiddenChanged中判断显示与否进行埋点,自定义数据,然后调用track方法进行埋点。唯一标示的key用fragment表示,value用当前引用的activity全路径,加上fragment的全路径,最后加上自定义的参数,即可作为唯一标示。以上即为页面埋点的主要代码,以及一些关键的代码细节,最后附Demo地址。别忘了在Application中初始化埋点:class App : Application() { override fun onCreate() { super.onCreate() //初始化埋点 SensorsDataAPI.init(this) }事件一般来说就是点击事件,书中的解决方案挺多的,今天现在说说比较简单的,即代理模式。原理拦截系统的点击事件,然后替换成我们自己的点击事件,然后在自己的点击事件中进行埋点操作。通过获取页面的根布局,然后递归遍历出所有的view,并代理它们的click事件。 public static void registerActivityLifecycleCallbacks(Application application) { application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { private ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener; @Override public void onActivityCreated(final Activity activity, android.os.Bundle bundle) { final ViewGroup rootView = getRootViewFromActivity(activity, true); onGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { delegateViewsOnClickListener(activity, rootView); @Override public void onActivityStarted(Activity activity) { @Override public void onActivityResumed(Activity activity) { mBeginTime = System.currentTimeMillis(); mCurrentActivity = activity; //trackAppViewScreen(activity); //添加视图树监听器 final ViewGroup rootView = getRootViewFromActivity(activity, true); rootView.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener); @Override public void onActivityPaused(Activity activity) { trackAppViewScreen(activity); @Override public void onActivityStopped(Activity activity) { final ViewGroup rootView = getRootViewFromActivity(activity, true); rootView.getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener); @Override public void onActivitySaveInstanceState(Activity activity, android.os.Bundle bundle) { @Override public void onActivityDestroyed(Activity activity) { 在onActivityCreated中初始化代理方法,在onActivityResumed中添加代理事件,在onActivityStopped中移除代理事件。我们在看看这个代理事件是怎么代理的: protected static void delegateViewsOnClickListener(final Context context, final android.view.View view) { if (context == null || view == null) { return; //获取当前 view 设置的 OnClickListener final android.view.View.OnClickListener listener = getOnClickListener(view); //判断已设置的 OnClickListener 类型,如果是自定义的 WrapperOnClickListener,说明已经被 hook 过,防止重复 hook if (listener != null && !(listener instanceof WrapperOnClickListener)) { //替换成自定义的 WrapperOnClickListener view.setOnClickListener(new WrapperOnClickListener(listener)); } else if (view instanceof CompoundButton) { final CompoundButton.OnCheckedChangeListener onCheckedChangeListener = getOnCheckedChangeListener(view); if (onCheckedChangeListener != null && !(onCheckedChangeListener instanceof WrapperOnCheckedChangeListener)) { ((CompoundButton) view).setOnCheckedChangeListener( new WrapperOnCheckedChangeListener(onCheckedChangeListener)); } else if (view instanceof RadioGroup) { final RadioGroup.OnCheckedChangeListener radioOnCheckedChangeListener = getRadioGroupOnCheckedChangeListener(view); if (radioOnCheckedChangeListener != null && !(radioOnCheckedChangeListener instanceof WrapperRadioGroupOnCheckedChangeListener)) { ((RadioGroup) view).setOnCheckedChangeListener( new WrapperRadioGroupOnCheckedChangeListener(radioOnCheckedChangeListener)); } else if (view instanceof RatingBar) { final RatingBar.OnRatingBarChangeListener onRatingBarChangeListener = ((RatingBar) view).getOnRatingBarChangeListener(); if (onRatingBarChangeListener != null && !(onRatingBarChangeListener instanceof WrapperOnRatingBarChangeListener)) { ((RatingBar) view).setOnRatingBarChangeListener( new WrapperOnRatingBarChangeListener(onRatingBarChangeListener)); } else if (view instanceof android.widget.SeekBar) { final android.widget.SeekBar.OnSeekBarChangeListener onSeekBarChangeListener = getOnSeekBarChangeListener(view); if (onSeekBarChangeListener != null && !(onSeekBarChangeListener instanceof WrapperOnSeekBarChangeListener)) { ((android.widget.SeekBar) view).setOnSeekBarChangeListener( new WrapperOnSeekBarChangeListener(onSeekBarChangeListener)); //如果 view 是 ViewGroup,需要递归遍历子 View 并 hook if (view instanceof ViewGroup) { final ViewGroup viewGroup = (ViewGroup) view; int childCount = viewGroup.getChildCount(); if (childCount > 0) { for (int i = 0; i < childCount; i++) { android.view.View childView = viewGroup.getChildAt(i); delegateViewsOnClickListener(context, childView); 可以看到除了click之外还有check等事件,其实原理都是想通的,我们来挑一个click来看看。先获取OnClickListener,怎么获取呢,看getOnClickListener方法: private static android.view.View.OnClickListener getOnClickListener(android.view.View view) { boolean hasOnClick = view.hasOnClickListeners(); if (hasOnClick) { try { Class viewClazz = Class.forName("android.view.View"); Method listenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo"); if (!listenerInfoMethod.isAccessible()) { listenerInfoMethod.setAccessible(true); Object listenerInfoObj = listenerInfoMethod.invoke(view); Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo"); Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener"); if (!onClickListenerField.isAccessible()) { onClickListenerField.setAccessible(true); return (android.view.View.OnClickListener) onClickListenerField.get(listenerInfoObj); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (NoSuchFieldException e) { e.printStackTrace(); return null; 通过反射拿到OnClickListener,然后再判断是否被代理,如果没有代理,就换成我们自己的Listenerview.setOnClickListener(new WrapperOnClickListener(listener));看一下我们自定义的WrapperOnClickListener/*public*/ class WrapperOnClickListener implements android.view.View.OnClickListener { private android.view.View.OnClickListener source; WrapperOnClickListener(android.view.View.OnClickListener source) { this.source = source; @Override public void onClick(android.view.View view) { //调用原有的 OnClickListener try { if (source != null) { source.onClick(view); } catch (Exception e) { e.printStackTrace(); //插入埋点代码 SensorsDataPrivate.trackViewOnClick(view);
本文参考《Android全埋点解决方案》一书,并在实操中查漏补缺。前言为什么选择全埋点呢,因为传统的手动埋点虽然简单、扩展性强,但弊端也很明显:开发时间成本较高改动的时间成本也较高容易出现漏埋、埋错的情况代码侵入性强但也不是说全埋点就一定没有弊端,比如扩展性较差。经过调研,实际上都是以全埋点为主、手动埋点为辅的情况,从而达到比较理想的埋点效果。本文内容可能稍微有点长,但是很简单,别太长不看啊,最后有demo源码。页面一般来说我们需要的数据就是,用户在哪个页面干了什么,也就是页面和事件,现在来说页面。原理通过生命周期可以计算出时长数据,以及页面对象。Activity页面有两个核心的需求数据:浏览时长页面唯一标示这两个数据都挺好拿的,Application有一个registerActivityLifecycleCallbacks接口可以监测到activity的生命周期。有了生命周期,我们在onActivityResumed里面记录一下开始时间,然后在onActivityPaused中获取当前时间,就是整个页面的浏览时间;在生命周期方法中是有activity对象的,这样也可以拿到全路径作为唯一标示;示例:public static void registerActivityLifecycleCallbacks(Application application) { application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() { @Override public void onActivityCreated(Activity activity, Bundle bundle) { @Override public void onActivityStarted(Activity activity) { @Override public void onActivityResumed(Activity activity) { mBeginTime = System.currentTimeMillis(); @Override public void onActivityPaused(Activity activity) { trackAppViewScreen(activity); @Override public void onActivityStopped(Activity activity) { @Override public void onActivitySaveInstanceState(Activity activity, Bundle bundle) { @Override public void onActivityDestroyed(Activity activity) { 在onActivityPaused的时候我们调用了一下trackAppViewScreen方法,并传入当前activity,来看看trackAppViewScreen方法。private static void trackAppViewScreen(Activity activity) { try { if (activity == null) { return; if (mIgnoredActivities.contains(activity.getClass().getCanonicalName())) { return; JSONObject properties = new JSONObject(); //获取页面的参数 if (activity.toString().contains("SecondActivity")) { String userId = activity.getIntent().getStringExtra("userId"); properties.put("userId", userId); properties.put("activity", activity.getClass().getCanonicalName()); SensorsDataAPI.getInstance().track("$AppViewScreen", properties, mBeginTime); } catch (Exception e) { e.printStackTrace(); 我们在trackAppViewScreen方法中创建了JSONObject对象properties,用来添加我们需要埋点的数据,比如页面的唯一标示key我们用activity表示,并取全路径作为value。这里有一点需要注意的,我们除了可以收集一些固定参数之外,activity中intent的参数也是可以获取的,比如其他页面跳转到这个页面传的参数,我们同样可以获取到并作为埋点的参数使用的。就像上面的SecondActivity,当MainActivity跳转到SecondActivity时传的userId是可以通过getIntent获取到的。最后调用了SensorsDataAPI类的track方法,继续看 public void track(@NonNull String eventName, @Nullable JSONObject properties, long beginTime) { try { JSONObject jsonObject = new JSONObject(); jsonObject.put("event", eventName); // jsonObject.put("device_id", mDeviceId); JSONObject sendProperties = new JSONObject(mDeviceInfo); if (properties != null) { SensorsDataPrivate.mergeJSONObject(properties, sendProperties); jsonObject.put("extras", sendProperties); jsonObject.put("beginTime", beginTime); jsonObject.put("endTime", System.currentTimeMillis()); jsonObject.put("pageId", SensorsDataPrivate.getCurrentActivity().getClass().getCanonicalName()); jsonObject.put("sessionId", UUID.randomUUID().toString().replace("-", "")); Log.i(TAG, SensorsDataPrivate.formatJson(jsonObject.toString())); } catch (Exception e) { e.printStackTrace(); 这里也很简单,先后创建了两个JSONObject,一个是最外层的jsonObject ,一个是作为参数使用的sendProperties,然后又把传过来的参数合并到sendProperties中,然后sendProperties作为extras的value使用。endTime结束时间就取当前时间。sessionId表示是这个埋点的唯一标示,看自己需求,非必须。最后调用了Log打印出来,来看一下最后完整的数据:{ "event": "$AppViewScreen", "extras": { "app_name": "TrackDemo", "screen_width": 1440, "screen_height": 2621, "app_version": "1.0", "os_version": "10", "model": "Android SDK built for x86", "manufacturer": "Google", "activity": "com.yechaoa.trackdemo.ui.MainActivity" "beginTime": 1603279291751, "endTime": 1603279293759, "pageId": "com.yechaoa.trackdemo.ui.MainActivity", "sessionId": "5dbb96807e634b6498f897784972ade3" 可以看到除了我们必要的参数之外,还有一些附加参数,比如手机型号、系统版本等等。
常规操作,但是AS4.0有一些改动,且提交过程不是很顺畅,所以记录一下。git环境就不说了,自己配置1,先忽略文件有些文件不是公用的是可以不用上传的,还有一些是编译的文件也不用上传。AS4.0以前,忽略文件是可以在settings-version control里面可视化操作的,但是升级AS4.0之后,这个设置没有了,但是我们可以在根目录的.gitignore文件里添加。*.iml .gradle .idea local.properties .DS_Store /build */build /captures .externalNativeBuild .idea、.gradle、*.iml、*/build、都是常规操作,自己根据需求添加,包括module下的.gitignore文件也可以这么干。比如app下的.gitignore文件:/build *.iml /release /debug2,关联GitLab直接在as里可视化操作即可,然后选自己的项目就行。3,提交其实前面也是提交操作了,只不过是本地提交,远程仓库还是没有的。这个时候选择push顺利的话就会提示成功 Pushed。不顺利的话可以按以下步骤操作。比如会有以下异常:Push to origin/master was rejected Push master to origin/master was rejected by remote Can't Update No tracked branch configured for branch master or the branch doesn't exist. To make your branch track a remote branch call, for example, git branch --set-upstream-to=origin/master master (show balloon)
其实有挺多原因会导致安装失败的,所以解决办法也不尽相同,下面列举一些解决办法。一先查看手机环境,是否打开USB调试,是否允许安装应用,手机是否处于打开状态,黑屏状态下也是会安装失败的。二清除缓存并重启AS可以尝试同步重启手机。三重试安装,可能会在安装提示的时候点错了,导致安装失败。四删除.gradle文件夹、.idea文件夹,build文件夹。然后重新编译会自动生成,这个可能是因为升级了AS,或者改了某些配置导致的版本不对称,所以删除重新编译即可。五编译期间注意查看控制台中build、logcat、以及run的日志,可能是库升级或代码改动导致的错误。六List of apks: [0] 'xxx-debug.apk'在AS的新版本中,有一些API变更。在 Android Gradle 插件 3.0.0 及更高版本引入的 API 变更中,部分功能被移除,因此您现有的编译可能会出现异常。插件的更高版本可能会引入新的公共 API 来替代失效的旧功能。在编译时可能无法正常修改变体输出新插件不支持使用 Variant API 来操纵变体输出,但仍然支持使用该 API 处理某些简单任务,例如在编译时更改 APK 名称,具体如下所示: // If you use each() to iterate through the variant objects, // you need to start using all(). That's because each() iterates // through only the objects that already exist during configuration time— // but those object don't exist at configuration time with the new model. // However, all() adapts to the new model by picking up object as they are // added during execution. android.applicationVariants.all { variant -> variant.outputs.all { outputFileName = "${variant.name}-${variant.versionName}.apk" 不过,涉及访问 outputFile 对象的复杂任务已不再受支持。这是因为在配置阶段不会再创建专门针对特定变体的任务。这导致插件不能预先了解所有的输出,但也缩短了配置时间。具体可查看:https://developer.android.google.cn/studio/known-issues其他情况,欢迎讨论。
‘annotationProcessor’ dependencies won’t be recognized as kapt annotation processors. Please change the configuration name to ‘kapt’ for these artifacts: ‘com.airbnb:deeplinkdispatch-processor:4.1.0’.简而言之,需要把annotationProcessor切换为kapt就可以了。切换前: api 'com.github.bumptech.glide:glide:4.9.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.9.0'切换后: api 'com.github.bumptech.glide:glide:4.9.0' kapt 'com.github.bumptech.glide:compiler:4.9.0'
前言对于android开发者来说,模拟器是开发工具,但是对用户来说,可能就是薅羊毛、找漏洞的赚钱工具。不管是活动风控还是内容保护等等其他的出发点,应要求可能会要识别Android模拟器,甚至禁止模拟器登录或后续操作。今天做个简单的探讨。原理识别模拟器的原理,其实就是找出模拟器和真机的差别,但是这些差别体现在哪呢,比如运营商、手机卡、蓝牙、各种传感器等等。但是现在的模拟器也是越来越厉害了,可以模拟不同品牌的不同型号的不同版本的手机,而且有蓝牙、可以打电话。是不是很头疼,一不小心就会误判了,所以现在市面上有一种方案也是根据模拟器和真机差别的次数来做一个容错机制,比如模拟器和真机超过3个差别,就判为模拟器。但是实际上,有的老版本手机甚至还不如模拟器先进…所以容错机制中还可以加上对用户的区别对待,个别用户不检测等等。禁止模拟器安装apk模拟器一般的cpu架构都是x86的,我们可以在build.gradle中去掉对x86的支持。 ndk { // 设置支持的SO库架构 abiFilters 'armeabi-v7a', 'arm64-v8a' }当然也有一些手机可能也是x86的,这就误判了,因为目前来说还没有比较完善的解决方案,所以前面所说的容错机制就比较重要了。仅仅是去掉x86的支持还是不够的,因为有的模拟器也不是x86架构,所以还是要进一步找出模拟器和真机的区别。当然也可以不禁止,识别出来就行。代码识别这里还是原理中提到的,判断有没有蓝牙、序列号、传感器、cpu架构等等多重判断。这里推荐一个库:CacheEmulatorChecker调用也比较简单,一行代码即可: val emulator = EmulatorDetectUtil.isEmulator(this) if (emulator) { ToastUtils.show(this, "检测到您的设备可能为模拟器", Toast.LENGTH_LONG) }验证禁止加代码识别的方案,目前验证过的模拟器:逍遥模拟器蓝叠模拟器夜神模拟器天天模拟器腾讯手游助手雷电模拟器MUMU模拟器鲁大师手机模拟大师以及各种主流手机品牌的主流机型,测试ok最后目前来说,还是没有完善且公开的解决方案,所以为避免误判,还是要根据自己的业务做相关的容错机制,比如给user加个是否要判断的字段,要的情况下再判断,否则放过。看到最后,如果你有更好的方法,欢迎讨论。
效果图前言为什么调用第三方呢?集成在App里面不行吗?接入导航SDK,以百度为例,apk包体积能增加小几十兆之多,上一版本还是几兆的apk,迭代一版本直接几十兆了,落差之大,难以接受。虽说当下流量不值钱了,但是下载时长越久,客户丢失率越高。最关键的是,当下地图并非一家独大,客户应该有自主选择的权利,你集成了百度,但用户却钟爱于高德,这极为尴尬。且当下包括微信等一众主流App都是通过调用第三方地图来做的,这显然有一定道理,也是大势所趋。坑多,显然是干不过别人一个团队专门来做地图的,不如站在巨人的肩上。综上所诉,优点显而易见。坐标系有地图就有经纬度,有经纬度就扯到坐标系,简单介绍一下坐标系。主要有以下三种:WGS84:一种大地坐标系,也是目前广泛使用的GPS全球卫星定位系统使用的坐标系。GCJ02:由中国国家测绘局制订的地理信息系统的坐标系统,是由WGS84坐标系经过加密后的坐标系。BD09:百度坐标系,在GCJ02坐标系基础上再次加密。其中BD09LL表示百度经纬度坐标,BD09MC表示百度墨卡托米制坐标。更多的坐标知识介绍百度使用的自家BD09LL坐标系,高德和腾讯都是GCJ02即火星坐标系,所以相互之间是需要转换的,不然会有位置偏移。转换方法: /** * BD-09 坐标转换成 GCJ-02 坐标 public static LatLng BD2GCJ(LatLng bd) { double x = bd.longitude - 0.0065, y = bd.latitude - 0.006; double z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * Math.PI); double theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * Math.PI); double lng = z * Math.cos(theta); double lat = z * Math.sin(theta); return new LatLng(lat, lng); * GCJ-02 坐标转换成 BD-09 坐标 public static LatLng GCJ2BD(LatLng bd) { double x = bd.longitude, y = bd.latitude; double z = Math.sqrt(x * x + y * y) + 0.00002 * Math.sin(y * Math.PI); double theta = Math.atan2(y, x) + 0.000003 * Math.cos(x * Math.PI); double tempLon = z * Math.cos(theta) + 0.0065; double tempLat = z * Math.sin(theta) + 0.006; return new LatLng(tempLat, tempLon); 业务需求还是简单描述一下业务需求,点击一个地址或者按钮或者地图上的一个点,弹窗选择第三方地图导航,点击选择后调用第三方地图进行导航。1,点击按钮弹窗选择 @OnClick({R.id.tv_navigation}) public void onViewClicked(View view) { switch (view.getId()) { case R.id.tv_navigation: showMapList(); break; 2,弹窗 private void showMapList() { final String[] mapNames = {"百度地图", "高德地图", "腾讯地图"}; final String[] packageNames = {"com.baidu.BaiduMap", "com.autonavi.minimap", "com.tencent.map"}; AlertDialog.Builder builder = new AlertDialog.Builder(this) .setTitle("请选择地图") .setItems(mapNames, (dialogInterface, i) -> { boolean installed = isInstalled(packageNames[i]); if (installed) { switch (i) { case 0: gotoBaiDuMap(); break; case 1: gotoGaoDeMap(); break; case 2: gotoTencentMap(); break; } else { ToastUtil.showCenterToast(mapNames[i] + "未安装"); builder.create().show(); 调用之前判断一下是否安装: /** * 检测所选地图是否安装 private boolean isInstalled(String packageName) { PackageManager manager = this.getPackageManager(); List<PackageInfo> installedPackages = manager.getInstalledPackages(0); if (installedPackages != null) { for (PackageInfo info : installedPackages) { if (info.packageName.equals(packageName)) return true; return false; 3,调用地图别忘了 申请权限。百度地图参数说明:参数以字符串拼接的方式即可,这里用StringBuffer拼接,比String易读些。调用示例: private void gotoBaiDuMap() { // 驾车导航 StringBuffer sb = new StringBuffer("baidumap://map/navi") .append("?coord_type=gcj02") .append("&query=").append("长宁图书馆") .append("&src=").append(this.getPackageName()); Intent intent = new Intent(); intent.setData(Uri.parse(sb.toString())); startActivity(intent); 高德地图参数说明:调用示例: private void gotoGaoDeMap() { LatLng endPoint = BD2GCJ(new LatLng(31.220567, 121.395174));//转换坐标系 StringBuffer sb = new StringBuffer("androidamap://navi") .append("?sourceApplication=").append(getString(R.string.app_name)) .append("&lat=").append(endPoint.latitude) .append("&lon=").append(endPoint.longitude) .append("&dev=0"); Intent intent = new Intent(); intent.addCategory(Intent.CATEGORY_DEFAULT); intent.setPackage("com.autonavi.minimap"); intent.setData(Uri.parse(sb.toString())); startActivity(intent); 腾讯地图参数说明:调用示例: private void gotoTencentMap() { LatLng startPoint = BD2GCJ(new LatLng(mLatitude, mLongitude));//坐标转换 LatLng endPoint = BD2GCJ(new LatLng(31.220567, 121.395174));//坐标转换 StringBuffer sb = new StringBuffer("qqmap://map/routeplan") .append("?type=drive") .append("&from=我的位置") .append("&fromcoord=").append(startPoint.latitude).append(",").append(startPoint.longitude) .append("&to=长宁图书馆") .append("&tocoord=").append(endPoint.latitude).append(",").append(endPoint.longitude) .append("&referer=你的key"); Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setData(Uri.parse(sb.toString())); startActivity(intent); 注意,腾讯地图这里的from和to参数虽然可以省略,但是地图上就不显示地址了,默认是 地图上的点,而且referer参数需要申请开发者key。文档百度地图高德地图腾讯地图
这个功能也是很常见了,一般都是为了方便用户操作,比如复制订单编号。不废话,下面就来看看具体是怎么操作的。1.复制内容要剪切板tv_order_copy.setOnClickListener { //获取剪切板管理器 val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager //设置内容到剪切板 cm.primaryClip = ClipData.newPlainText(null, item.orderId) ToastUtils.show(context, "已复制") }非常简单,首先获取剪切板管理器,然后设置内容即可可以设置的内容有3中类型:newPlainTextnewHtmlTextnewIntent2.清除剪切板tv_order_clear.setOnClickListener { val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { //要api28以上 cm.clearPrimaryClip() } else { cm.primaryClip = ClipData.newPlainText(null, null) }在api等级28以上,直接调用clearPrimaryClip()即可,以下,重新设置为空即可。3.获取剪切板内容iv_order_get.setOnClickListener { val cm: ClipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (cm.hasPrimaryClip() && cm.primaryClip != null) { //cm.primaryClip.itemCount val text = cm.primaryClip.getItemAt(0).text ToastUtils.show(context, text.toString()) }在有内容的情况下,这里的代码是取的第一个,即getItemAt(0).text,如果有多个的情况下,且有取多个的需求,这里的下标取值就要根据cm.primaryClip.itemCount来动态设置了。
前言Android 模拟器可以运行,但是不能连接网络,事实上这是第二次遇到这个情况了,第一次是在公司,Windows系统,当时的处理方式是直接禁用了IPV6,最近又遇到了,这次是mac系统,但是距离上次已经好久了,所有又耽搁了点时间解决,所以决定搞清楚并解决这个问题。出现异常首先最直接的就是会给你报异常:java.net.UnknownHostException: Unable to resolve host "***.***.com": No address associated with hostname剖析问题很明显是直接不能连这个地址了,不是超时,也不是404什么的。鉴于人性,首先怀疑服务器崩了…测试,pc访问是可以的。那好吧,再从自身找毛病。首先可以确定的是,跟电脑系统没有关系,上次是Windows,这次是mac,都遇到了。并且电脑也是有网的,可以打开网页测试。其次我们的程序,也就是代码也是没有问题的,这个只能自己确定,是否有联网权限什么的,请求地址是否正确什么的。然后我们有的人可能会排查到Android studio 或者模拟器上面,这里的步骤可能会有但不限于清除缓存、重启as,重建模拟器等操作。最后我们会定位到模拟器这里,如图状态栏的wifi标志是有个小x的,设置里面也显示已连接但无法访问,ok,总算找到问题了,原来是模拟器不能连网。网上搜一波解决方案都是改DNS、禁用代理什么的,而且有的改了还没用,最后还是FQ找到的办法。解决方案好了啰嗦了半天了,终于到解决办法了。简而言之,Android模拟器7.0及以后都可能会出现这种问题,为什么会是可能呢,这就要取决于你的网络是不是有ipv6了,有就一定会,所以第一个解决方案也就来了:1、禁用IPV6不同的系统有不同的禁用方式,这个要自行解决。2、创建7.0以下的模拟器因为7.0以下的模拟器用的是你电脑的网,而不需要在模拟器里面设置。
flutter sdk升级遇到的异常:E/flutter (17545): [ERROR:flutter/lib/ui/ui_dart_state.cc(157)] Unhandled Exception: ServicesBinding.defaultBinaryMessenger was accessed before the binding was initialized. E/flutter (17545): If you're running an application and need to access the binary messenger before `runApp()` has been called (for example, during plugin initialization), then you need to explicitly call the `WidgetsFlutterBinding.ensureInitialized()` first. E/flutter (17545): If you're running a test, you can call the `TestWidgetsFlutterBinding.ensureInitialized()` as the first line in your test's `main()` method to initialize the binding. E/flutter (17545): #0 defaultBinaryMessenger.<anonymous closure> (package:flutter/src/services/binary_messenger.dart:76:7) E/flutter (17545): #1 defaultBinaryMessenger (package:flutter/src/services/binary_messenger.dart:89:4) E/flutter (17545): #2 MethodChannel.binaryMessenger (package:flutter/src/services/platform_channel.dart:140:62) E/flutter (17545): #3 MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:314:35) E/flutter (17545): #4 MethodChannel.invokeMapMethod (package:flutter/src/services/platform_channel.dart:349:48) E/flutter (17545): #5 SharedPreferences._getSharedPreferencesMap (package:shared_preferences/shared_preferences.dart:158:25) E/flutter (17545): #6 SharedPreferences.getInstance (package:shared_preferences/shared_preferences.dart:25:17) E/flutter (17545): #7 getTheme (package:wanandroid_flutter/main.dart:41:50) E/flutter (17545): #8 main (package:wanandroid_flutter/main.dart:32:26) E/flutter (17545): #9 _runMainZoned.<anonymous closure>.<anonymous closure> (dart:ui/hooks.dart:239:25) E/flutter (17545): #10 _rootRun (dart:async/zone.dart:1126:13) E/flutter (17545): #11 _CustomZone.run (dart:async/zone.dart:1023:19) E/flutter (17545): #12 _runZoned (dart:async/zone.dart:1518:10) E/flutter (17545): #13 runZoned (dart:async/zone.dart:1502:12) E/flutter (17545): #14 _runMainZoned.<anonymous closure> (dart:ui/hooks.dart:231:5) E/flutter (17545): #15 _startIsolate.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:307:19) E/flutter (17545): #16 _RawReceivePortImpl._handleMessage (dart:isolate-patch/isolate_patch.dart:174:12) E/flutter (17545): Lost connection to device. 不要慌,这里着重看一下第二行:If you're running an application and need to access the binary messenger before `runApp()` has been called (for example, during plugin initialization), then you need to explicitly call the `WidgetsFlutterBinding.ensureInitialized()` first. yechaoa大意:在runApp()之前如果访问二进制文件或者初始化插件,需要先调用WidgetsFlutterBinding.ensureInitialized() 。ok,那就照着来吧void main() { WidgetsFlutterBinding.ensureInitialized(); runApp(MyApp()); }那WidgetsFlutterBinding.ensureInitialized();这一行代码到底干啥的呢,WidgetsFlutterBinding字面意思呢,Widget和Flutter绑定,追溯一下源码看看:/// A concrete binding for applications based on the Widgets framework. /// This is the glue that binds the framework to the Flutter engine. class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding { /// Returns an instance of the [WidgetsBinding], creating and /// initializing it if necessary. If one is created, it will be a /// [WidgetsFlutterBinding]. If one was previously initialized, then /// it will at least implement [WidgetsBinding]. /// You only need to call this method if you need the binding to be /// initialized before calling [runApp]. /// In the `flutter_test` framework, [testWidgets] initializes the /// binding instance to a [TestWidgetsFlutterBinding], not a /// [WidgetsFlutterBinding]. static WidgetsBinding ensureInitialized() { if (WidgetsBinding.instance == null) WidgetsFlutterBinding(); return WidgetsBinding.instance; 继承自BindingBase ,然后还有一堆手势绑定、服务绑定什么的,一看就是初始化操作,然后看中间有一段注释:You only need to call this method if you need the binding to be initialized before calling [runApp].不细翻译了,大意就是在需要的时候调用,那什么时候需要呢,回到开头,在访问二进制文件或者初始化插件的时候,需要在runApp()之前调用WidgetsFlutterBinding.ensureInitialized() 。ok,结束。
开发中,序列化是常见操作,在java中,我们一般会用到Serializable或者Parcelable,优缺点就不赘述了,今天来看看kotlin中的序列化。Parcelable1.添加配置app目录下的build.gradle文件android { androidExtensions { experimental = true }2.添加注解并实现Parcelable@Parcelizedata class User(val name: String) : Parcelable以上就完成了序列化。就这么简单。3.泛型序列化我们在实际开发中经常会对bean对象做抽取,这里就涉及到了泛型序列化。@Parcelizedata class BaseBean<T : Parcelable>( val errorMsg: String, val errorCode: Int, val data: T ) : Parcelable这里需要注意的是泛型也要实现Parcelable,即<T : Parcelable> 。Parcelable源码public interface Parcelable { int CONTENTS_FILE_DESCRIPTOR = 1; int PARCELABLE_WRITE_RETURN_VALUE = 1; int describeContents(); void writeToParcel(Parcel var1, int var2); public interface Creator<T> { T createFromParcel(Parcel var1); T[] newArray(int var1); public interface ClassLoaderCreator<T> extends Parcelable.Creator<T> { T createFromParcel(Parcel var1, ClassLoader var2); }可以看到还是有writeToParcel方法和Creator等,其实也没少,只是不再需要我们去写了。SerializableSerializable的方式比较简单,直接实现Serializable就可以了data class User( val id: Int, val name: String ) : Serializable
效果https://ucc.alicdn.com/images/user-upload-01/20200114163153229.gif页面结构解析这是一个比较常见的APP首页的结构,侧边栏+主页,侧边栏里是一些菜单,主页由底部菜单控制内容区,内容区是可滑动的子页面。整体比较舒服合理,各自为阵,却又能关联在一起,加上又是大众喜爱的Material Design风格,所以成为了当下APP首页的主流结构。上图是做的一个简单的思维导图,并不复杂,理清了结构就能事半功倍。页面布局1.首页即整个大的容器。<?xml version="1.0" encoding="utf-8"?> <androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/drawer_layout" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" tools:context=".module.MainActivity" tools:openDrawer="start"> <include layout="@layout/app_bar_main" android:layout_width="match_parent" android:layout_height="match_parent" /> <com.google.android.material.navigation.NavigationView android:id="@+id/nav_view" android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_gravity="start" android:fitsSystemWindows="true" app:headerLayout="@layout/nav_header_main" app:menu="@menu/activity_main_drawer" /> </androidx.drawerlayout.widget.DrawerLayout> DrawerLayout包裹着include的主页和侧边栏内容NavigationView。app_bar_main是主页内容,采用include的方式引用是为了结构清晰,避免混乱。侧边栏NavigationView分为头部布局headerLayout和菜单menu,注意一个是layout一个是menu。其他需要注意的是,NavigationView的位置应与主内容app_bar_main同级,且在主内容之后。关于DrawerLayout更多使用可以查看:DrawerLayout使用详解。2.主页这里主页说的是首页除侧边栏以外的页面。<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".module.MainActivity"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay"/> </com.google.android.material.appbar.AppBarLayout> <include layout="@layout/content_main"/> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_marginRight="@dimen/dp_20" android:layout_marginEnd="@dimen/dp_20" android:layout_marginBottom="@dimen/dp_70" app:srcCompat="@android:drawable/ic_dialog_email"/> </androidx.coordinatorlayout.widget.CoordinatorLayout> 上面是标题,中间是内容区,FloatingActionButton可以忽略。3.主页内容区效果同2一样<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context=".module.MainActivity" tools:showIn="@layout/app_bar_main"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <androidx.viewpager.widget.ViewPager android:id="@+id/view_pager" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <com.google.android.material.bottomnavigation.BottomNavigationView android:id="@+id/bottom_navigation" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:background="?android:attr/windowBackground" app:layout_constraintBottom_toTopOf="@+id/viewPager" app:menu="@menu/bottom_navigation" /> </LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> 上面是ViewPager,可滑动的内容区,填充一个一个的Fragment子页面。下面是BottomNavigationView底部菜单,与上面的ViewPager关联。到此页面布局的部分介绍完毕,下面开始说代码部分。代码部分1.侧边栏我们要在toolbar上加一个按钮,把侧边栏关联起来,让其点击可以弹出侧边栏。 /** * Drawer关联Toolbar private fun initActionBarDrawer() { val toggle = ActionBarDrawerToggle( this, drawer_layout, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close drawer_layout.addDrawerListener(toggle) toggle.syncState() 2.内容区关联了侧边栏,我们来看内容区,上面说过内容区是ViewPager包含的一个个子页面Fragment,来看代码的实现 /** * 初始化Fragment private fun initFragments() { val viewPagerAdapter = CommonViewPagerAdapter(supportFragmentManager) viewPagerAdapter.addFragment(HomeFragment()) viewPagerAdapter.addFragment(TreeFragment()) viewPagerAdapter.addFragment(NaviFragment()) viewPagerAdapter.addFragment(ProjectFragment()) view_pager.offscreenPageLimit = 1 view_pager.adapter = viewPagerAdapter 处理事件1.侧边栏点击事件 /** * 侧边栏点击事件 nav_view.setNavigationItemSelectedListener { // Handle navigation view item clicks here. when (it.itemId) { R.id.nav_collect -> { ToastUtilKt.showToast("收藏") R.id.nav_share -> { ToastUtilKt.showToast("分享") R.id.nav_about -> { ToastUtilKt.showToast("关于") R.id.nav_logout -> { ToastUtilKt.showToast("退出") //关闭侧边栏 drawer_layout.closeDrawer(GravityCompat.START) 根据itemId判断触发事件,并关闭侧边栏,这一步可选,也可以不关闭 保持侧边栏打开的状态。2.view_pager 滑动监听 /** * view_pager 滑动监听 view_pager.addOnPageChangeListener(object : OnPageChangeListener { override fun onPageScrollStateChanged(state: Int) { override fun onPageScrolled( position: Int, positionOffset: Float, positionOffsetPixels: Int override fun onPageSelected(position: Int) { bottom_navigation.menu.getItem(position).isChecked = true //设置checked为true,但是不能触发ItemSelected事件,所以滑动时也要设置一下标题 when (position) { 0 -> { toolbar.title = resources.getString(R.string.app_name) 1 -> { toolbar.title = resources.getString(R.string.title_tree) 2 -> { toolbar.title = resources.getString(R.string.title_navi) else -> { toolbar.title = resources.getString(R.string.title_project) view_pager滑动之后底部对应的菜单选中,并重新设置标题。3.bottom_navigation 底部菜单点击事件 /** * bottom_navigation 点击事件 bottom_navigation.setOnNavigationItemSelectedListener { when (it.itemId) { R.id.navigation_home -> { view_pager.currentItem = 0 return@setOnNavigationItemSelectedListener true R.id.navigation_tree -> { view_pager.currentItem = 1 return@setOnNavigationItemSelectedListener true R.id.navigation_navi -> { view_pager.currentItem = 2 return@setOnNavigationItemSelectedListener true R.id.navigation_project -> { view_pager.currentItem = 3 return@setOnNavigationItemSelectedListener true false 底部菜单点击的时候也让view_pager滑动的响应的位置,同第2步其实是相互关联的。到此,整个搭建就完成了,从页面布局到初始化控件,再到处理事件,整体思路要清晰,搭建起来就很快,小的功能细节再调试完善完善就ok了。完整代码https://github.com/yechaoa/wanandroid_kotlin写作不易,有用就点个赞呗 ^ _ ^
效果思路获取整个BottomNavigationView菜单,再根据下标获取某一个子菜单tab,然后给这个tab添加我们自定义的view,可以是数字也可以是文字。代码 /** * 给BottomNavigationView 设置Badge 小红点 * BottomNavigationMenuView中的每一个Tab是一个FrameLayout,所以可以在上面随意添加View、这样就可以实现角标了 private fun setBadge() { //获取底部菜单view val menuView = bottom_navigation.getChildAt(0) as BottomNavigationMenuView //获取第2个itemView val itemView = menuView.getChildAt(1) as BottomNavigationItemView //引入badgeView val badgeView = LayoutInflater.from(this).inflate(R.layout.layout_badge_view, menuView, false) //把badgeView添加到itemView中 itemView.addView(badgeView) //获取子view并设置显示数目 val count = badgeView.findViewById<TextView>(R.id.tv_badge) count.text = "2" //不显示则隐藏 //count.visibility=View.GONE layout_badge_view就是自定义的一个layout<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <TextView android:id="@+id/tv_badge" android:layout_width="@dimen/dp_18" android:layout_height="@dimen/dp_18" android:layout_gravity="center" android:layout_marginLeft="@dimen/dp_10" android:layout_marginTop="@dimen/dp_2" android:background="@drawable/shape_oval_primary" android:gravity="center" android:includeFontPadding="false" android:text="1" android:textColor="@color/white" /> </LinearLayout> shape_oval_primary是一个背景为主题色的圆<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval" android:useLevel="false"> <solid android:color="@color/colorPrimary" /> <stroke android:width="0dp" android:color="@color/white" /> <size android:width="10dp" android:height="10dp" /> </shape> github:https://github.com/yechaoa/wanandroid_kotlin
效果:点击上图中右下角的图标滑动到顶部。其实很简单,但也有一些小细节。1.确定图标按钮的位置使用绝对位置使其固定在右下角的位置。wxml:<icon type="download" size="45" color="#4caf50" bindtap='scrollTop'/>这里使用官方的下载图标,然后修改了一下颜色。wxss: icon[type=download] { position: fixed; bottom: 30px; right: 20px; transform: rotate(180deg); }为了使其不跟随滑动,修改了position为fixed。原本箭头是向下的,使用transform: rotate(180deg)旋转180度向上。2.绑定事件bindtap='scrollTop' scrollTop: function() { wx.pageScrollTo({ scrollTop: 0, duration: 300 },在事件中,我们使用了官方的API wx.pageScrollTo,两个参数,一个是滑动的位置,一个是执行时长。到此,滑动到顶部的功能就简单实现了。3.进阶为什么会有这一步呢,是因为发现无论滑动的距离是远还是近,执行时长都是300,那能不能优化呢,其实是可以的。微信page中有一个onPageScroll方法(与onLoad平级),监听用户滑动页面事件。那我们就可以根据滑动的距离动态设置执行时长了。把时长定义为变量data: { scrollDuration: 500, },在监听事件中赋值onPageScroll: function(e) { console.log(e.scrollTop) this.setData({ scrollDuration: e.scrollTop / 2 }e.scrollTop即为页面滑动距离e.scrollTop / 2为执行时长,也可以设置其他值官方提示:请避免在 onPageScroll 中过于频繁的执行 setData 等引起逻辑层-渲染层通信的操作。修改滑动方法 scrollTop: function() { wx.pageScrollTo({ scrollTop: 0, duration: this.data.scrollDuration },ok,到此就全部完成了。
业务场景我们通常需要cookie持久化来保持回话统一,也因为某些业务需求,需要每个接口都传一些参数,比如token、userId等。原理解读原理呢,比较简单的,把参数以请求头的方式添加到拦截器中,然后在配置给okhttp。代码实现创建拦截器,并添加参数public class HeaderInterceptor implements Interceptor { @NonNull @Override public Response intercept(Chain chain) throws IOException { Request original = chain.request(); Request.Builder requestBuilder = original.newBuilder() .addHeader("version", "1.0.0") .addHeader("自定义key", "自定义value"); Request request = requestBuilder.build(); return chain.proceed(request); 配置拦截器给OkHttp OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(new HeaderInterceptor()) .connectTimeout(20, TimeUnit.SECONDS) .build();也可以用@Header或@Headers注解给某一个接口单独添加一个或多个请求头。
效果:style:button[type=primary] { background: linear-gradient(to right, #03A9F4, #7C4DFF); }注意这里要使用background,而不能用background-color。语法:同css一样:linear-gradient( color1 , color2 );默认从上到下,两个参数即可,省去了第一个参数,第一个参数控制方向。左右:background: linear-gradient(to right, #03A9F4, #7C4DFF);对角:background: linear-gradient(to bottom right, #03A9F4, #7C4DFF);
问题当我们在使用wx:for遍历列表数据的时候,可能会遇到以下警告:Now you can provide attr `wx:key` for a `wx:for` to improve performance.解析字面意思呢,缺少wx:key属性,不过既然是警告,也就表示可有可无,那为什么警告呢?当我们在使用wx:for遍历列表数据的时候,原则上来讲每条数据都是不一样的,所以需要使用 wx:key来指定列表中项目的唯一的标识符;但是也有可能会出现每条数据就是一样的情况,所以缺少wx:key属性是警告而不是异常报错。如不提供 wx:key,会报一个 warning, 如果明确知道该列表是静态,或者不必关注其顺序,可以选择忽略。解决添加wx:key属性。wx:key 的值以两种形式提供:字符串,代表在 for 循环的 array 中 item 的某个property,该 property 的值需要是列表中唯一的字符串或数字,且不能动态改变,如id,name等。保留关键字 *this 代表在 for 循环中的 item 本身,这种表示需要 item 本身是一个唯一的字符串或者数字,如:当数据改变触发渲染层重新渲染的时候,会校正带有 key 的组件,框架会确保他们被重新排序,而不是重新创建,以确保使组件保持自身的状态,并且提高列表渲染时的效率。示例<block wx:for="{{imgUrls}}" wx:key="item.id"> </block>或者<block wx:for="{{imgUrls}}" wx:key="*this"> </block>参考:https://developers.weixin.qq.com/miniprogram/dev/reference/wxml/list.html#wx:key
DatePickerDialog其实就是把CalendarView封装进Dialog中了,调用起来更方便快捷。显示 mBtn.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { Calendar calendar = Calendar.getInstance(); DatePickerDialog dialog = new DatePickerDialog(MainActivity.this, listener, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)); dialog.show(); });构造方法也很简单上下文监听回调年月日然后调用show方法即可。也可以在上下文参数后面加上自定义的dialog style,默认获取当前activity的theme。监听回调 private DatePickerDialog.OnDateSetListener listener = new DatePickerDialog.OnDateSetListener() { @Override public void onDateSet(DatePicker datePicker, int year, int monthOfYear, int dayOfMonth) { Log.i("---", "年=" + year + ",月=" + (monthOfYear + 1) + ",日=" + dayOfMonth); tvInfo.setText(year+"年"+(monthOfYear + 1)+"月"+dayOfMonth+"日");
虽然flutter可以同时运行在android和ios设备上,但是修改名称、logo、启动页还是需要分开配置的。修改应用名称android在项目下找到android目录,依次app》src》main》AndroidManifest.xml,打开AndroidManifest.xml文件,找到application节点,修改label参数即可 <application android:name="io.flutter.app.FlutterApplication" android:icon="@mipmap/ic_logo" android:label="玩安卓"> </application>ios在项目下找到ios目录,依次Runner》Info.plist,打开Info.plist文件,参数都是key-string的形式,找到CFBundleName,修改参数即可<dict> <key>CFBundleName</key> <string>玩安卓</string> </dict>修改应用图标android在项目下找到android目录,依次app》src》main》res,然后会有一组mipmap开头的目录,即不同目录存放不同的图标大小,把我们不同大小的图标分别放在对应的目录中。打开AndroidManifest.xml文件,找到application节点,修改icon参数即可<application android:name="io.flutter.app.FlutterApplication" android:icon="@mipmap/ic_logo" android:label="玩安卓"> </application>mipmap-hdpi - 72*72mipmap-mdpi - 48*48mipmap-xhdpi - 96*96mipmap-xxhdpi - 144*144mipmap-xxxhdpi - 192*192ios在项目下找到ios目录,依次Runner》Assets.xcassets》AppIcon.appiconset,然后会有一组后缀为1x、2x、3x的图标,根据尺寸存放即可。在同级目录的Contents.json文件中修改自己的配置{ "images" : [ "size" : "20x20", "idiom" : "iphone", "filename" : "Icon-App-20x20@2x.png", "scale" : "2x" "size" : "1024x1024", "idiom" : "ios-marketing", "filename" : "Icon-App-1024x1024@1x.png", "scale" : "1x" "info" : { "version" : 1, "author" : "xcode" }ios的图标尺寸较多,可以根据Contents.json文件中的配置挨个去修改,或者只修改通用的即可。启动页android在项目下找到android目录,依次app》src》main》res》drawable》launch_background.xml,<?xml version="1.0" encoding="utf-8"?> <!-- Modify this file to customize your launch splash screen --> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item android:drawable="@android:color/white" /> <!-- You can insert your own image assets here --> <item> <bitmap android:gravity="center" android:src="@mipmap/ic_logo" /> </item> </layer-list>只需修改这个文件即可。其他根据需求也可以自行修改values下的style文件,然后在AndroidManifest.xml文件设置给activity即可。ios在项目下找到ios目录,依次Runner》Assets.xcassets》LaunchImage.imageset,根据需求修改LaunchImage图片文件,并在同级别的Contents.json文件中配置即可。{ "images" : [ "idiom" : "universal", "filename" : "LaunchImage.png", "scale" : "1x" "idiom" : "universal", "filename" : "LaunchImage@2x.png", "scale" : "2x" "idiom" : "universal", "filename" : "LaunchImage@3x.png", "scale" : "3x" "info" : { "version" : 1, "author" : "xcode" }githubhttps://github.com/yechaoa/wanandroid_flutter
效果https://ucc.alicdn.com/images/user-upload-01/20190801172905657.gif构建Listbody: ListView.builder( itemCount: articleDatas.length, itemBuilder: (BuildContext context, int position) { return getRow(position); ),articleDatas是list数据源,可自行创建测试数据。getRow方法是构建list的item布局,可以是复杂的也可以只是一个text。添加滑动官方有一个Dismissible Widget,包裹住item即可实现滑动效果。 final item = articleDatas[position]; return Dismissible( // 滑动背景色 background: new Container(color: Theme.of(context).primaryColor), // 设置key标识 key: new Key(item.title), // 滑动回调 onDismissed: (direction) { // 根据位置移除 articleDatas.removeAt(position); //do something // 提示 Scaffold.of(context).showSnackBar(SnackBar(content: Text("已移除"))); child: getRow(position), githubhttps://github.com/yechaoa/wanandroid_flutter官方文档:https://flutter.dev/docs/cookbook/gestures/dismissible滑出来点击再删除推荐:https://github.com/letsar/flutter_slidable
网络dio网络请求dio: ^3.0.9https://pub.dev/packages/diohttp网络请求http: ^0.12.0+2https://pub.dev/packages/httpcookie_jarcookie持久化cookie_jar: ^1.0.0https://pub.dev/packages/cookie_jarUIfluttertoastToastfluttertoast: ^3.1.0https://pub.dev/packages/fluttertoastflutter_swiper轮播bannerflutter_swiper: ^1.1.6https://pub.dev/packages/flutter_swiperflutter_webview_plugin加载webviewflutter_webview_plugin: ^0.3.5https://pub.dev/packages/flutter_webview_pluginflutter_html加载富文本flutter_html: ^0.11.0https://pub.dev/packages/flutter_htmlflutter_markdown加载markdownflutter_markdown: ^0.2.0https://pub.dev/packages/flutter_markdownzefyr富文本编辑器zefyr: ^0.8.0https://pub.dev/packages/zefyrbadges徽章badges: ^1.1.0https://pub.dev/packages/badgesflutter_easyrefresh下拉刷新上拉加载flutter_easyrefresh: ^2.0.0https://pub.dev/packages/flutter_easyrefreshfl_chart图表fl_chart: ^0.1.5https://pub.dev/packages/fl_chartcity_picker城市选择器city_picker: ^0.1.3https://pub.dev/packages/city_pickerazlistview列表索引azlistview: ^0.1.2https://pub.dev/packages/azlistviewfile_picker文件选择器file_picker: ^1.3.8https://pub.dev/packages/file_pickerimage_picker图片选择器image_picker: ^0.6.1https://pub.dev/packages/image_pickercached_network_image图片缓存cached_network_image: ^1.1.1https://pub.dev/packages/cached_network_imagephoto_view图片手势photo_view: ^0.4.2https://pub.dev/packages/photo_viewflutter_calendar_carousel日历flutter_calendar_carousel: ^1.3.18https://pub.dev/packages/flutter_calendar_carouseldate_range_picker日期区间选择器date_range_picker: ^1.0.6https://pub.dev/packages/date_range_pickerfont_awesome_flutter图标库font_awesome_flutter: ^8.5.0https://pub.dev/packages/font_awesome_flutterflutter_spinkit动画库flutter_spinkit: ^3.1.0https://pub.dev/packages/flutter_spinkitflukitFlutter UI组件库flukit: ^1.0.2https://pub.dev/packages/flukit数据sqflite数据库sqflite: ^1.1.6https://pub.dev/packages/sqfliteshared_preferencessp本地存储shared_preferences: ^0.5.3+4https://pub.dev/packages/shared_preferences设备connectivity监听网络状态connectivity: ^0.4.3+6https://pub.dev/packages/connectivityshare分享share: ^0.6.2+1https://pub.dev/packages/sharecamera相机camera: ^0.5.2+2https://pub.dev/packages/cameradevice_info设备信息device_info: ^0.4.0+2https://pub.dev/packages/device_infopackage_info包信息package_info : ^0.4.0+6https://pub.dev/packages/package_infopath_provider获取本地文件路径path_provider: ^1.2.0https://pub.dev/packages/path_provider工具rxdart函数式编程rxdart: ^0.22.1+1https://pub.dev/packages/rxdartprovide状态管理provide: ^1.0.2https://pub.dev/packages/provideprovider状态管理provider: ^3.0.0+1https://pub.dev/packages/providerevent_bus消息传递event_bus: ^1.1.0https://pub.dev/packages/event_busurl_launcher打开链接url_launcher: ^5.1.2https://pub.dev/packages/url_launcherintl国际化intl: ^0.15.8https://pub.dev/packages/intlfluro路由fluro: ^1.5.1https://pub.dev/packages/flurosimple_permissions权限相关simple_permissions: ^0.1.9https://pub.dev/packages/simple_permissionsvideo_player视频播放器video_player: ^0.10.1+6https://pub.dev/packages/video_playeraudioplayers音频播放器audioplayers: ^0.13.1https://pub.dev/packages/audioplayersqr_flutter二维码qr_flutter: ^2.1.0+55https://pub.dev/packages/qr_flutter
效果简介相对布局,类似于android中的RelativeLayout、FrameLayout。既可以相对父容器确定自己的位置,也可以多个widget重叠显示。Stack与Positioned搭配使用。源码Stack Stack({ Key key, this.alignment = AlignmentDirectional.topStart, this.textDirection, this.fit = StackFit.loose, this.overflow = Overflow.clip, List<Widget> children = const <Widget>[], })alignment :对齐方式,即没有位置定位的widget的对齐方式,当只有横向定位时,比如left有实参,则影响其纵向的对齐方式,再如子widget的top有值,即纵向定位有效,则影响其横向的对齐方式。fit:没有定位的子widget的大小,默认StackFit.loose,使用子widget的大小,StackFit.expand则填充stack。overflow :超出stack的显示方式,默认Overflow.clip裁剪,Overflow.visible超出也显示。Positioned const Positioned({ Key key, this.left, this.top, this.right, this.bottom, this.width, this.height, @required Widget child, })主要用到left、top、right、bottom四个参数来定位。示例如效果图所示,一个简单的登录界面,顶部的logo和底部的登录button是重叠显示在中间Card之上的。所以我们大概结构就是一个Stack下有3个child,一个未定位的Card,和两个定位的Positioned。Stack(alignment: Alignment.topCenter, children: <Widget>[ Container( margin: EdgeInsets.only(top: 50), padding: EdgeInsets.all(40), child: Card( Positioned( top: 40, left: MediaQuery.of(context).size.width / 2 - 35, child: Center( Positioned( bottom: 20, left: 130, right: 130, ]),第一个就是Container,包裹住Card,主要是内填充和外填充。第二个就是logo,主要是左边位置是屏幕的一半减去自身的一半。第三个就是底部登录button了。具体位置显示还要根据自己需求调试。Githubhttps://github.com/yechaoa/wanandroid_flutter
报错flutter项目在运行到ios 模拟器的时候报错:Warning: CocoaPods not installed. Skipping pod install.还有Using new build systemnote: Planning buildnote: Constructing build descripti.... Xcode’s output: === BUILD TARGET Runner OF PROJECT Runner WITH CONFIGURATION Debug === Debug.xcconfig line 1: Unable to find included file "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" Debug.xcconfig line 1: Unable to find included file “Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig” === BUILD TARGET Runner OF PROJECT Runner WITH CONFIGURATION Debug === ld: framework not found Flutter clang: error: linker command failed with exit code 1 (use -v to see invocation) Could not build the application for the simulator. Error launching application on iPhone XR. 具体报错没记录,一开始也没想着会有这么麻烦。提示官方给出了两个提示执行命令:brew install cocoapods pod setup这个其实在一开始配环境执行flutter doctor的结果之后也给了提示,也是配置mac环境的条件。但是本来项目是可以运行到ios模拟器的,新项目也是可以运行的,但是最近项目改动较多,无从查起,没办法只能硬着头皮解决了。在网上搜了一圈,并没有相关的解决方案,没办法,站不了巨人的肩上了,只能自己摸索了。思路一按照指示在终端执行brew install cocoapods,等了好一会,开始出现下载==> Downloading https://homebrew.bintray.com/bottles/cocoapods-1.7.5.mojave.bottle.tar.gz ==> Downloading from https://akamai.bintray.com/5a/5a70fd60007975ce224824b1a8852ba9124faa6753061d0ecc024be4cebd6e8b?__gda__=exp=1565624237~hmac=b87d5d0111b49f600da053951890下载到百分之几的时候就失败了,然后又重试,结果在百分之六十多的时候又失败了,应该是要fq,国内速度较慢。然后又开始在网上搜CocoaPods的安装教程,搜了一圈下来,看着都麻烦。思路二然后我尝试在Android Studio自带的terminal中直接执行brew install cocoapods,默认是在项目根目录执行的,yechaodeMacBook-Pro:wanandroid_flutter yechao$ brew install cocoapods会有error提示Error: Another active Homebrew update process is already in progress. Please wait for it to finish or terminate it to continue.但是可以下载,而且速度挺快的,==> Downloading https://homebrew.bintray.com/bottles/cocoapods-1.7.5.mojave.bottle.tar.gz ==> Downloading from https://akamai.bintray.com/5a/5a70fd60007975ce224824b1a8852ba9124faa6753061d0ecc024be4cebd6e8b?__gda__=exp=1565624237~hmac=b87d5d0111b49f600da053951890 ######################################################################## 100.0%下载完成之后在执行 pod setup,又是一顿下载,这个要等一会了。yechaodeMacBook-Pro:wanandroid_flutter yechao$ pod setup Setting up CocoaPods master repo $ /usr/local/bin/git clone https://github.com/CocoaPods/Specs.git --progress -- master Cloning into 'master'... remote: Enumerating objects: 260, done. remote: Counting objects: 100% (260/260), done. remote: Compressing objects: 100% (249/249), done. Receiving objects: 10% (361304/3368611), 72.04 MiB | 57.00 KiB/s完了之后再次尝试运行到ios模拟器,ok,运行成功!
效果:非常的简单。导入字体随便在网上找一个免费的字体,下载下来会得到一个后缀为.ttf的文件,即字体文件。或者自己做一个。在项目的根目录中新建一个fonts文件夹(目录、命名可以随意),并把刚才的文件拷进去。引用字体在pubspec.yaml文件中配置字体...flutter: fonts: - family: mononoki fonts: - asset: fonts/mononoki-Regular.ttf weight: 500family:字体名称asset:字体路径weight:比重(400相当于normal,700相当于bold)使用字体child: Text( "wanandroid_flutter V1.0", style: TextStyle(fontSize: 25, fontFamily: 'mononoki'), ),Text的style中有一个fontFamily属性,参数即字体的名称。github:https://github.com/yechaoa/wanandroid_flutter官方文档:https://flutter.dev/docs/cookbook/design/fonts#from-packages
其实通读一下文档后的感觉就是还蛮复杂的,起码比windows下配置要复杂一下,可能也是我平时windows用的比较多吧,今天这篇文章就以最简单直接的方式引导在macOS上配置Flutter开发环境文章目录准备下载SDK先配置一下镜像下载sdk设置sdk路径解压sdk添加`flutter相关工具`到`path`中开始配置运行 flutter doctor 检测本机当前环境装插件新建项目运行项目更新环境变量ios配置:打开xcodeWindows下配置Flutter开发环境准备操作系统: macOS (64-bit)磁盘空间: 700 MB (不包括Xcode或Android Studio的磁盘空间)工具: Flutter 依赖下面这些命令行工具bash, mkdir, rm, git, curl, unzip, which下载SDK先配置一下镜像由于在国内访问Flutter有时可能会受到限制,Flutter官方为中国开发者搭建了临时镜像,大家可以将如下环境变量加入到用户环境变量中:export PUB_HOSTED_URL=https://pub.flutter-io.cn //国内用户需要设置 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn //国内用户需要设置 下载sdk下载地址:https://flutter.dev/docs/development/tools/sdk/releases?tab=macos#macos选择最新的正式版本就行。设置sdk路径我在user目录下新建了一个FlutterSDK文件夹来存放:解压sdk执行命令:cd ~/development unzip ~/Downloads/flutter_macos_v0.5.1-beta.zip就是进入存放sdk的目录,然后解压。比如我的就是:开始配置运行 flutter doctor 检测本机当前环境flutter doctor执行完会看到一堆报错,别慌,都好解决的。直接从下面的Android Studio开始看,提示flutter插件和dart插件没安装,还有就是没有设备可以连接,先一步一步来。装插件打开Android Studio,安装flutter插件,也会提示安装dart插件,装就完事了,然后会提示重启。运行项目新建项目之后会有一个默认的小demo模板,可以直接运行。先选择一个模拟器,点击运行不会帮你自动启一个模拟器,要自己先手动启一个。然后运行看看效果。第一次运行初始化会有点慢,但是后面就快了,而且还有热重,如果不是特别大的项目,基本上不到1s就能看到效果了。可以看到一个默认的项目 。然后我们来改一下title 和 加一个 hello flutter。这一步主要是体验一下热重载代码就不贴了,改好直接按command+s就行。可以看到是相当的快,不到1s,可以说是非常提升效率了。看效果:可以看到标题改了,而且中间也多了一行红色的hello flutter。更新环境变量上面基本上都没有什么难度,但是到这里可能就会遇到坑了,比如我就遇到了。。为什么会有这一步呢,是因为上面我们执行export PATH=pwd/flutter/bin:$PATH的时候,只是暂时把flutter相关工具加入到path中,这一步呢,就是永久的加入到path,以便可以运行flutter命令在任何终端会话中。官方的3步骤:确定您Flutter SDK的目录,您将在步骤3中用到。打开(或创建) $HOME/.bash_profile. 文件路径和文件名可能在您的机器上不同.添加以下行并更改[PATH_TO_FLUTTER_GIT_DIRECTORY]为克隆Flutter的git repo的路径:export PUB_HOSTED_URL=https://pub.flutter-io.cn //国内用户需要设置 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn //国内用户需要设置 export PATH=PATH_TO_FLUTTER_GIT_DIRECTORY/flutter/bin:$PATH第一步sdk路径是我们自己知道的,ok直接执行第二步:$HOME/.bash_profileok,爬坑之路开始了,别慌,直接带你跳坑。你可能会遇到:Permission denied没有权限,然后搜解决办法,参考https://blog.csdn.net/qq_16525279/article/details/80245350按照操作执行,但是又提示 sudo: chmode: command not found, 一直找不到原因。注意了,我们其实可以直接打开.bash_profile文件的(一般在Mac的当前用户目录下):open .bash_profile没有的话就创建:touch .bash_profile然后再打开。打开是这样的:然后编辑,把镜像和flutter加进去:export PUB_HOSTED_URL=https://pub.flutter-io.cn //国内用户需要设置 export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn //国内用户需要设置 export PATH=PATH_TO_FLUTTER_GIT_DIRECTORY/flutter/bin:$PATHPATH_TO_FLUTTER_GIT_DIRECTORY就是你flutter存放的路径。比如我的就是这样的:保存。然后执行刷新:source $HOME/.bash_profile然后来验证一下是否成功,输出path:echo $PATH可以看到第一个就是新增的flutter路径,到此更新环境变量就完成了。ios配置:上面是基于Android Studio进行配置的,现在看一下ios的配置。我们重新执行以下 flutter doctor 看一下:这时候可以看到只剩下xcode和ios的报错了。配置也可以,按照提示 安装cocoapods、libimobiledevice、ideviceinstaller等等就行。实际上开发环境只有一个就行了,主要是xcode不识别dart文件,as中有flutter和dart插件,毕竟都是google的亲儿子,所以相对来说是最佳开发环境,但是还是可以运行到ios模拟器上看效果的。打开xcode终端执行:$ open -a Simulator打开一个ios模拟器。然后as也能识别到并运行到ios模拟器中:然后运行到ios模拟器上看看效果:ok,到此就全部配置结束了,可能篇幅较长,但是图文的形式更容易理解些。写作不易,点个赞支持一下呗 ^ _ ^Windows下配置Flutter开发环境https://blog.csdn.net/yechaoa/article/details/89150852
banner是一个第三方轮播库,到现在为止已经有8000+的star了,使用的人也很多。使用的人越多,业务场景就越多,相应的问题也会越多。比如SwipeRefreshLayout嵌套banner的时候,就会使banner手动滑动起来特别的费劲,经常滑不过去。https://github.com/youth5201314/banner思路:因为下拉刷新,只有纵向滑动的时候才有效,只需要判断此时是纵向滑动还是横向滑动就可以了。纵向滑动就拦截事件,横向滑动不拦截。怎么判断是纵向滑动还是横向滑动,只要判断Y轴的移动距离大于X轴的移动距离那么就判定为纵向滑动就行了。自定义SwipeRefreshLayout/** * Created by yechaoa on 2019/06/25. * Describe : 重写SwipeRefreshLayout的onIntercept方法解决与viewpager冲突问题。 * 思路: * 1. 因为下拉刷新,只有纵向滑动的时候才有效,只需要判断此时是纵向滑动还是横向滑动就可以了。 * 2. 纵向滑动就拦截事件,横向滑动不拦截。 * 3. 怎么判断是纵向滑动还是横向滑动,只要判断Y轴的移动距离大于X轴的移动距离那么就判定为纵向滑动就行了。 public class SuperSwipeRefreshLayout extends SwipeRefreshLayout { private float startY; private float startX; // 记录viewPager是否拖拽的标记 private boolean mIsVpDragger; private final int mTouchSlop; public SuperSwipeRefreshLayout(Context context, AttributeSet attrs) { super(context, attrs); mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); @Override public boolean onInterceptTouchEvent(MotionEvent ev) { int action = ev.getAction(); switch (action) { case MotionEvent.ACTION_DOWN: // 记录手指按下的位置 startY = ev.getY(); startX = ev.getX(); // 初始化标记 mIsVpDragger = false; break; case MotionEvent.ACTION_MOVE: // 如果viewpager正在拖拽中,那么不拦截它的事件,直接return false; if (mIsVpDragger) { return false; // 获取当前手指位置 float endY = ev.getY(); float endX = ev.getX(); float distanceX = Math.abs(endX - startX); float distanceY = Math.abs(endY - startY); // 如果X轴位移大于Y轴位移,那么将事件交给viewPager处理。 if (distanceX > mTouchSlop && distanceX > distanceY) { mIsVpDragger = true; return false; break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: // 初始化标记 mIsVpDragger = false; break; // 如果是Y轴位移大于X轴,事件交给swipeRefreshLayout处理。 return super.onInterceptTouchEvent(ev); 而后把原来的SwipeRefreshLayout 换成SuperSwipeRefreshLayout 即可。扩展:SwipeRefreshLayout 嵌套 RecyclerView滑动冲突SwipeRefreshLayout 嵌套ScrollView 滑动冲突ScrollView嵌套RecyclerView滑动卡顿
原则上你使用的RecyclerView框架可能是自带刷新的,如果没有就可能会出现这种问题,滑动时会出现卡顿、触发刷新的时机也不对。思路:监听RecyclerView的滑动,当滑动到顶部的时候,再设置SwipeRefreshLayout刷新可用。代码 mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); //解决mRecyclerView与mSwipeRefreshLayout在部分机型上滑动冲突 mSwipeRefreshLayout.setEnabled(recyclerView.getChildCount() == 0 || recyclerView.getChildAt(0).getTop() >= 0); 扩展SwipeRefreshLayout 嵌套ScrollView 滑动冲突ScrollView嵌套RecyclerView滑动卡顿
annotationProcessor‘ dependencies won‘t be recognized as kapt annotation processors. Please change