万丈红尘千杯酒,
千秋霸业一壶茶。
在 Compose 1.1.0 版本及之前, ConstraintLayout 的 父层级元素不能用 IntrinsicSize.Max 去约束它的高度,否则 ConstraintLayout 的宽度会出现非预期的情况。
具体代码可以看我提交给官方的 issue,并看看官方会不会改。 地址为:https://issuetracker.google.com/issues/220527863
ConstraintLayout 自身就可以实现内容匹配 height 为 IntrinsicSize.Max,所以我们其实也没必要去写这种布局,但是万一父组件是别人提供的,而且用了这个 modifier,就可能会怀疑好一会儿人生了。
在 UI 开发过程中,经常会遇到如下一个需求:
假设一个布局是 【头像】【人名】【推荐信息】,正常用 LinearLayout 实现, 是没有任何问题的,但是要求在人名过长,整体内容会超过容器宽度时,不要省略推荐信息,而是省略人名信息。
针对这种场景, QMUI 提供了 QMUIPriorityLinearLayout 组件,但是在 ConstraintLayout 出来后,它的存在就没有价值了, 我们可以借助 ConstraintLayout 的 chain 行为实现这种功能:
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.LightGray)
) {
val (one, two, three) = createRefs()
val horChain = createHorizontalChain(one, two, three, chainStyle = ChainStyle.Packed(0f))
constrain(horChain){
start.linkTo(parent.start)
end.linkTo(parent.end)
}
Text(
"此处不压缩",
color = Color.White,
maxLines = 1,
modifier = Modifier
.background(Color.Red)
.constrainAs(one) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
})
Text(
"此处如果内容有那么一点点过长,那就压缩省略压缩省略压缩省略",
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.background(Color.Green)
.constrainAs(two) {
width = Dimension.preferredWrapContent // 此处是重点
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
})
Text(
"此处也不压缩",
color = Color.White,
maxLines = 1,
modifier = Modifier
.background(Color.Black)
.constrainAs(three) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
})
}
这里是 Compose 的实现, View 体系相似, 只是需要省略的 View 的 LayoutParams 需要加 constrainedWidth = true
Tinker 打 patch 包时,自己 copy 了一份官方的 DexFormat 来自己维护,但是这样就造成了其更新落后于官方的实现。随着 Android miniSDKVersion 的变化, gradle 打包出来的 Dex 文件的 format 是会变化的,这就导致,如果更新了 miniSDKVersion, tinker 就可能打包失败。 所以, 改 miniSDKVersion 时要考虑到这一点。(debug 包的 dex 格式可能并不跟随 miniSDKVersion,所以测试 tinker 时还是用 release 包吧。)
ViewPager + FragmentPagerAdapter 时, ViewPager 的 id 不要用 View.generateViewId(),用固定 id 的方式。 任何 Fragment 的父容器 View,不要用动态生成 id 的形式,否则状态保存于恢复就很容易出现难以定位的问题。
React Native 的 Text 在 Android 部分手机上单行渲染会出现“明明渲染空间足够,但是却出了省略号”的现象, 通过一通源码阅读和操作, 发现原因是这样的:
React Native 不是简单的去将 Text 设置给 TextView,然后通过 TextView 去 measure 获取 View 的大小, 而是自己实例化一个 Paint 去测量 Text,测量得到大小后,设置给 TextView。
那么不一致就产生了, TextView 主动 measure 和 实例化一个 paint 去 measure 得到的大小并不一致。
原因是某些手机(例如小米10、MIUI12)的 paint 在没有设置 typeface 时,它默认使用的 typeface 竟然不是系统指定的默认字体(这实属于魔改官方系统不全面,出漏洞了)
这就导致某些手机上 React Native 测量的大小小于 TextView 上实际能够展现全部 Text 的大小,因而出现了省略号。
所以我们我们魔改下 RN 源码:
//ReactTextShadowNode.java
private Layout measureSpannedText(Spannable text, float width, YogaMeasureMode widthMode) {
if(!isTypefaceSet){
// 从 TextView 里获取 typeface 以使得测量准确
isTypefaceSet = true;
TextView tv = new TextView(getThemedContext());
sTextPaintInstance.setTypeface(tv.getTypeface());
}
// 原本的代码
}
这样我们就能使得测量一致了。这也告诉我们:
当我们想用 Paint 绘制文本的时,我们也需要这样从 TextView 里获取默认 Typeface,否则就会被像素眼设计师怼了,最后 app 里从 TextView 里读取出 typeface,然后存储起来,公用。
吐槽:RN 这种完全自己搞 measure,完全不走官方逻辑,搞得问题一堆一堆的。
VIVO 最新手机做了一个优化,如果你短时间不停的初始化 AudioTrack 对象然后释放,那么它就直接杀死你的进程。在做 TTS 时,因为每一句话请求回来都是一个独立的音频,因而每次都会重新初始化 AudioTrack 对象,就中了 VIVO 这个杀进程的逻辑。改成在 AudioTrack 参数有变化时才重新创建,否则复用旧的 AudioTrack 对象就可以。(这种优化还是有意义的,不过也得让开发者更容易感知才好啊,一查查半天。)
在使用 Function Component 来写 RN bundle 时,踩到了一个坑:
如果首次加载的是这个 bundle,那么样式就会丢失。最终发现实遗漏了 EStyleSheet.build() 方法调用,导致样式对象为空了。而在使用 Class Component 时,会把这个方法写在基类,而业务人员使用时也不会被遗漏。 而我们采用将 EStyleSheet.build() 放在一个通用的 hook 上来最大程度避免遗漏。
Android Studio 可以很方便的让 svg 转换成 Vector Drawable。但是 Vector Drawable 只能支持简单的 svg 元素,稍加不注意我们就会踩坑:
1. 不支持渐变
2. 不支持文字,如果有必要,可以让设计师用 path 的方式绘制文字
我们在开启新任务时,要清晰的思考其是否会以及是否需要并发执行。一个常见的场景是:
点击按钮 -> 开启一个任务;如果重复点击,那么就会造成任务的重复开启,就会造成很多并发问题。开发者的手机允许速度很快,可能还不一定能发现问题,但在基数很大的用户群体中,就会出现各种问题了,因此我们需要处理这种情况:
1. throttle 、 debounce
2. disable 掉 button 或者加浮层
3. 在任务层面做处理:1. 取消前一个任务;2. 使用队列,串行处理,3. 加入前一个任务,共享结果
更多可以参考这篇文章:https://medium.com/androiddevelopers/coroutines-on-android-part-iii-real-work-2ba8a2ec2f45
TextView 或者 Paint 对象在设置 Typeface 时,设置成 Typeface.Default 和 null 并不相同,Typeface.Default 表示默认字体,是固定的,而 null 则会跟随系统设置里选择的字体。所以一般我们要设置成 null,而不要用 Typeface.Default
Room 的 bool 类型的数据,在填写 defaultValue 时,要填写 0/1,true/false 在低版本 Android(包括部分高版本国产 ROM ) 上会 crash
WorkManager 通过 OneTimeWorkRequest.Builder 或 PeriodicWorkRequest.Builder 构建出的 WorkRequest 并不是 immutable 的, WorkManager 执行时会更改这个对象,因此不能将其重复传入 WorkManager.beginUniqueWork 或者 WorkManager.enqueueUniquePeriodicWork,而应该每次都重新 build 一个新的 WorkRequest 对象传进去。(曾经我 cache 了 build 出的 WorkRequest 对象,然后造成了 crash,定位了我好久)
GlobalScope 默认使用 Dispatchers.Unconfined,而并非 Dispatcher.Main。使用时需要注意。
使用 Retrofit 搭配 OKHttp 的 Interceptor 只会向上层抛 IOException。其它类型的错误会直接导致 App Crash。所以只能是在 Interceptor 里 catch 错误,将非 IOException 包裹成 IOException 然后向外抛。。。。
随着 UI 复杂度日益增加,再加上 kotlin 语法的便利性, 我们逐渐开始用纯代码写布局了。但这样就导致 View.generateId() 大量的被使用。 如果还有界面使用了 RN,那么 RN 生成的也是用代码生成 id。那么会出现以下几种情况:
1. View.generateId() 一个走完一个周期,重新开始从 0 开始计数(View 很多、长时间使用 App) 。从而造成同一个界面不同控件的 id 相同
2. RN 与原生的 id 生成逻辑不一致,因而 RN 控件与原生控件 id 重复(同一个界面混用 RN 和原生控件)
上述两种场景都会造成不同控件 id 相同, 那么在 onRestoreInstanceState 时就可能出错,因为 onSaveInstanceState 是按 id 作为 key 的。这个时候就需要一些特殊的技巧来兼容这种问题了:
1. View.setSaveFromParentEnabled(false)。阻止父 View 向自己派发 saveInstanceState。 Fragment 也是通过这种方式隔离 SaveState 的。 ReactRootView 也应该加上这个,因为 RN 没有状态恢复的需要。
2. 上述方案会一刀切,所有子 View 都不能恢复状态了,但有时 ViewPager、RecycerView 还是需要状态恢复的。因此我们采用需要状态恢复的 id 写入 ids.xml 中。然后界面 RootView 重写掉 dispatchSaveInstanceState 方法:
// 判断 id 是否是生成的,从 View.java 中拷贝的方法
fun isViewIdGenerated(id: Int): Boolean {
return id and -0x1000000 == 0 && id and 0x00FFFFFF != 0
}
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>?) {
super.dispatchSaveInstanceState(container)
// 如果是生成的 id,则不要去保存状态
if(container != null){
for(i in container.size() - 1 downTo 0){
val key = container.keyAt(i)
if(isViewIdGenerated(key)){
container.removeAt(i)
}
}
}
}
我们在使用 tintColor 等手段改变一个 Drawable 的颜色时,是需要调用 Drawable.mutate() 来使得它的状态不被共享。但它并不是返回一个新的 Drawable,而是仅仅使得当前 Drawable 的状态不再与全局共享(默认情况下从 Resource 里读取的同名 Drawable 共享一个状态)。Drawable.mutate() 的返回值仅仅是方便链式调用而已。
如果我们想要利用 tintColor 来让同一个 icon 作用于 StateListDrawable 的不同状态时,对于每一个状态,我们都需要单独从 resource 里加载一次。
在使用 systrace 时,要使得 Trace.beginSection 和 Trace.endSection 生效的话,必须通过 -a 指定追踪的App的包名
RecyclerView 通过 setOnFlingListener 设置的 OnFlingListener 的返回值非常重要:
如果返回值为 true, 那么需要确保 RecyclerView 是滚动了的, 否则会造成 scrollState 不会被重置为 SCROLL_STATE_IDLE
SnapHelper 就是通过 setOnFlingListener 来实现的,但其实它并没有做好这种保护,以 PageSnapHelper 为例,当滑动到第一个和最后一个时,如果继续滑动触发 over scroll,这个时候 scrollState 就会停留在 SCROLL_STATE_DRAGGING,除非我们触发反方向的滚动,那它就永远不会回到 SCROLL_STATE_IDLE 状态,如果我们的业务代码依赖了 scrollState, 那就会出翔。这个时候我们需要重写 SnapHelper 的 onFling 方法,例如:
private val pagerSnapHelper = object: PagerSnapHelper() {
override fun onFling(velocityX: Int, velocityY: Int): Boolean {
// 如果已经在顶部(底部),继续往上(下)滚动时,直接返回 false,交给系统处理
if((velocityY < 0 && !canScrollVertically(-1)) || (velocityY > 0 && !canScrollVertically(1))){
return false
}
return super.onFling(velocityX, velocityY)
}
}
使用 View.setBackgroundResource(resource) 时,不能随意调用 View.getBackground().setAlpha(),因为这会使得 alpha 直接作用于这个 resource,会影响到 App 中使用这个 resource 的所有地方。
案例:QMUITopBar 中提供了 setBackgroundAlpha() 这个方法,是通过 getBackground().setAlpha() 来实现的,因此使用 setBackgroundResource(R.drawable.xxx)和 setBackgroundResource(R.color.xxx)时都有可能影响到全局 resource,这也就埋下了坑点。
横竖屏切换时,如果 Activity 有设置 android:configChanges="orientation|keyboardHidden|screenSize" 时, Activity 会调用 onConfigurationChanged 。 否则,Activity 会销毁当前实例,重新构造新的实例,生命周期也会重新走一次:onPause() →onStop()→onDestroy()→onCreate()→onStart()→onResume()
ART 日志消息格式
ART 不会为未明确请求的垃圾回收记录消息。只有在认为垃圾回收速度较慢时才会打印垃圾回收(垃圾回收暂停时间超过 5ms 或垃圾回收持续时间超过 100ms)
I/art: <GC_Reason> <GC_Name> <Objects_freed>(<Size_freed>) AllocSpace Objects, <Large_objects_freed>(<Large_object_size_freed>) <Heap_stats> LOS objects, <Pause_time(s)>
示例:
I/art : Explicit concurrent mark sweep GC freed 104710(7MB) AllocSpace objects, 21(416KB) LOS objects, 33% free, 25MB/38MB, paused 1.230ms total 67.216ms
解析
<GC_Reason>:
<GC_Name>:
<Objects_freed>(<Size_freed>):
此次垃圾回收从非大型对象空间回收的对象数量与大小
<Large_objects_freed>(<Large_object_size_freed>):
此次垃圾回收从大型对象空间回收的对象数量与大小
<Heap_stats>:
空闲百分比与(活动对象数量)/(堆总大小)
<Pause_time(s)>:
通常情况下,暂停时间与垃圾回收运行时修改的对象引用数量成正比。当前,ART CMS 垃圾回收仅在垃圾回收即将完成时暂停一次。移动的垃圾回收暂停时间较长,会在大部分垃圾回收期间持续出现
内存耗用:
CPU/ABI | armeabi | armeabi-v7a | arm64-v8a | x86 | x86_64 | mips | mips64 |
---|---|---|---|---|---|---|---|
ARMv5 | √ - 1 | ||||||
ARMv7 | √ - 2 | √ - 1 | |||||
ARMv8 | √ - 3 | √ - 2 | √ - 1 | ||||
x86 | √ - 3 | √ - 2 | √ - 1 | ||||
x86_64 | √ - 4 | √ - 3 | √ - 2 | √ - 1 | |||
MIPS | √ - 1 | ||||||
MIPS64 | √ - 2 | √ - 1 |
备注:1,2,3,4 表示 ABI 优先级递减。应用安装到设备时,只有该设备的 CPU 架构支持的最优 so 库才会被安装。
Android 开启 开发者模式 -> GPU呈现模式 可以方便我们观察界面是否丢帧:
绿色水平线:代表16ms,要确保一秒内打到 60fps,你需要确保这些帧的每一条线都在绿色的 16ms 标记线之下。
Android 6+ 柱形图颜色说明:
Android 4.x 和 Android 5.x 柱形图颜色说明:
官网地址:https://developer.android.com/studio/profile/inspect-gpu-rendering
在开发过程中,我们很容易遇到一个现象:界面上 ViewPager 或者 RecyclerView 或者 ListView 显示空白,但是有数据,用 Layout Inspector 查看时,显示正常。 有时候滚动就能恢复正常。
那这是什么原因呢?
一个可能的因素就是多个 RecyclerView(ViewPager, ListView) 设置了同一个 Adapter。一个场景就是 Fragment -> onCreateView 每次都重新 new 一个RecyclerView,然后设置同一个 Adapter,这样再从新界面返回时,旧的 RecyclerView 还没释放时,就存在多个 RecyclerView 引用同一个 Adapter。当然还存在其它场景,这值得我们注意!