注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

底部缩略库:RateBottomSheet

有时候,为了推广我们的应用,我们需要让用户跳转到应用商店为我们的APP打分,传统的对话框用户体验很不好,而本库则是用BottomSheet来进行提示,它位于底部缩略区域,用户体验很好。7.1 如何使用呢?在build.gradle 中添加如下依赖:depend...
继续阅读 »

有时候,为了推广我们的应用,我们需要让用户跳转到应用商店为我们的APP打分,传统的对话框用户体验很不好,而本库则是用BottomSheet来进行提示,它位于底部缩略区域,用户体验很好。

7.1 如何使用呢?

build.gradle 中添加如下依赖:

dependencies {
implementation 'com.mikhaellopez:ratebottomsheet:1.1.0'
}

然后修改默认的string资源文件来改变显示文案:


Like this App?
Do you like using this application?
Yes I do
Not really

Rate this app
Would you mind taking a moment to rate it? It won\'t take more than a minute. Thanks for your support!
Rate it now
Remind me later
No, thanks

代码中使用:

RateBottomSheetManager(this)
.setInstallDays(1) // 3 by default
.setLaunchTimes(2) // 5 by default
.setRemindInterval(1) // 2 by default
.setShowAskBottomSheet(false) // True by default
.setShowLaterButton(false) // True by default
.setShowCloseButtonIcon(false) // True by default
.monitor()

// Show bottom sheet if meets conditions
// With AppCompatActivity or Fragment
RateBottomSheet.showRateBottomSheetIfMeetsConditions(this)
7.2 效果图

更多详情请看Github:https://github.com/lopspower/RateBottomSheet

下载地址:RateBottomSheet-master.zip

收起阅读 »

带动画的底部导航栏库:AnimatedBottomBar

这是一个带动画的底部导航栏库。它使你可以以编程方式以及通过XML添加和删除选项卡。此外,我们可以轻松地从BottomBar拦截选项卡。限制访问应用程序导航中的高级区域时,“拦截”标签非常有用。流畅的动画提供了许多自定义选项,从动画插值器到设置波纹效果。6.1 ...
继续阅读 »

这是一个带动画的底部导航栏库。它使你可以以编程方式以及通过XML添加和删除选项卡。此外,我们可以轻松地从BottomBar拦截选项卡。限制访问应用程序导航中的高级区域时,“拦截”标签非常有用。流畅的动画提供了许多自定义选项,从动画插值器到设置波纹效果。

6.1 如何使用?

build.gradle 中添加如下依赖:

dependencies {
implementation 'nl.joery.animatedbottombar:library:1.0.8'
}

在xml文件中添加AnimatedBottomBar和自定义属性


res/menu目录下定义tabs.xml文件:







最后,代码中添加tab

// Creating a tab by passing values
val bottomBarTab1 = AnimatedBottomBar.createTab(drawable, "Tab 1")

// Creating a tab by passing resources
val bottomBarTab2 = AnimatedBottomBar.createTab(R.drawable.ic_home, R.string.tab_2, R.id.tab_home)
6.2 效果图
tab1tab2
tab1.giftab2.gif

详情信息请看Github: https://github.com/Droppers/AnimatedBottomBar

下载地址:AnimatedBottomBar-master.zip

收起阅读 »

Android 颜色库:ColorX

Android ColorX 以Kotlin 扩展函数的形式提供了一些重要的获取颜色的方法。通过提供不同颜色格式(RGB,HSV,CYMK等)的转换功能,它使开发变得更加轻松。该库的USP具有以下功能:颜色的不同阴影和色调。较深和较浅的阴影。颜色的补码5.1 ...
继续阅读 »

Android ColorX 以Kotlin 扩展函数的形式提供了一些重要的获取颜色的方法。
通过提供不同颜色格式(RGB,HSV,CYMK等)的转换功能,它使开发变得更加轻松。该库的USP具有以下功能:

  • 颜色的不同阴影和色调。
  • 较深和较浅的阴影。
  • 颜色的补码
5.1 如何使用?

build.gradle 中添加如下依赖:

dependencies {
implementation 'me.jorgecastillo:androidcolorx:0.2.0'
}

在代码中,一系列的转换方法:

val color = Color.parseColor("#e91e63")

val rgb = color.asRgb()
val argb = color.asArgb()
val hex = color.asHex()
val hsl = color.asHsl()
val hsla = color.asHsla()
val hsv = color.asHsv()
val cmyk = color.asCmyk()

val colorHsl = HSLColor(hue = 210f, saturation = 0.5f, lightness = 0.5f)

val colorInt = colorHsl.asColorInt()
val rgb = colorHsl.asRgb()
val argb = colorHsl.asArgb()
val hex = colorHsl.asHex()
val cmyk = colorHsl.asCmyk()
val hsla = colorHsl.asHsla()
val hsv = colorHsl.asHsv()
5.2 效果图

更多详细使用信息请看Github:https://github.com/JorgeCastilloPrz/AndroidColorX

下载地址:AndroidColorX-master.zip

收起阅读 »

reveal动画效果的库:EasyReveal

从名字就知道,这是一个提供reveal动画效果的库,它的厉害之处在于可以提供不同尺寸、不同形状的reveal动画,并且还可以在定义它在屏幕任意位置开始和结束动画。4.1 如何使用?在build.gradle 中添加如下依赖:dependencies { .....
继续阅读 »

从名字就知道,这是一个提供reveal动画效果的库,它的厉害之处在于可以提供不同尺寸、不同形状的reveal动画,并且还可以在定义它在屏幕任意位置开始和结束动画。

4.1 如何使用?

build.gradle 中添加如下依赖:

dependencies {
...
implementation 'com.github.Chrisvin:EasyReveal:1.2'
}

然后,xml中,需要添加显示或者隐藏动画的View应该包裹在EasyRevealLinearLayout中:







也可以在代码中添加:

val revealLayout = EasyRevealLinearLayout(this)
// Set the ClipPathProvider that is used to clip the view for reveal animation
revealLayout.clipPathProvider = StarClipPathProvider(numberOfPoints = 6)
// Set the duration taken for reveal animation
revealLayout.revealAnimationDuration = 1500
// Set the duration taken for hide animation
revealLayout.hideAnimationDuration = 2000
// Set listener to get updates during reveal/hide animation
revealLayout.onUpdateListener = object: RevealLayout.OnUpdateListener {
override fun onUpdate(percent: Float) {
Toast.makeText(this@MainActivity, "Revealed percent: $percent", Toast.LENGTH_SHORT).show()
}
}
// Start reveal animation
revealLayout.reveal()
// Start hide animation
revealLayout.hide()
4.2效果图
Emotion DialogDrake DialogEmoji Dialog

更多详细使用信息请看Github: https://github.com/Chrisvin/EasyReveal

下载地址:EasyReveal-master.zip

收起阅读 »

美观而时尚的AlterDialog库:AestheticDialogs

这是一个美观而时尚的AlterDialog库,目前可支持六种不同的对话框,如:Flash DialogConnectify DialogToaster DialogEmotion DialogDrake DialogEmoji Dialog并且啊,还提供了暗黑...
继续阅读 »

这是一个美观而时尚的AlterDialog库,目前可支持六种不同的对话框,如:

  • Flash Dialog
  • Connectify Dialog
  • Toaster Dialog
  • Emotion Dialog
  • Drake Dialog
  • Emoji Dialog
    并且啊,还提供了暗黑模式的适配。
3.1 如何使用?

build.gradle 中添加如下依赖:

dependencies {
...
implementation 'com.github.gabriel-TheCode:AestheticDialogs:1.1.0'
}

代码中,显示不同种类的对话框则调用对应的方法就好

Flash:

AestheticDialog.showFlashDialog(this, "Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showFlashDialog(this, "Your dialog Title", "Your message", AestheticDialog.ERROR);

Connectify:

AestheticDialog.showConnectify(this,"Your message", AestheticDialog.SUCCESS);
AestheticDialog.showConnectify(this, "Your message", AestheticDialog.ERROR);

/// Dark Theme
AestheticDialog.showConnectifyDark(this,"Your message",AestheticDialog.SUCCESS);
AestheticDialog.showConnectifyDark(this, "Your message", AestheticDialog.ERROR);

Toaster:

 AestheticDialog.showToaster(this, "Your dialog Title", "Your message", AestheticDialog.ERROR);
AestheticDialog.showToaster(this, "Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showToaster(this, "Your dialog Title", "Your message", AestheticDialog.WARNING);
AestheticDialog.showToaster(this, "Your dialog Title", "Your message", AestheticDialog.INFO);

/// Dark Theme
AestheticDialog.showToasterDark(this, "Your dialog Title", "Your message", AestheticDialog.ERROR);
AestheticDialog.showToasterDark(this, "Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showToasterDark(this, "Your dialog Title", "Your message", AestheticDialog.WARNING);
AestheticDialog.showToasterDark(this, "Your dialog Title", "Your message", AestheticDialog.INFO);

Drake :

 AestheticDialog.showDrake(this, AestheticDialog.SUCCESS);
AestheticDialog.showDrake(this, AestheticDialog.ERROR);

Emoji :

 AestheticDialog.showEmoji(this,"Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showEmoji(this, "Your dialog Title", "Your message", AestheticDialog.ERROR);

/// Dark Theme
AestheticDialog.showEmojiDark(this,"Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showEmojiDark(this, "Your dialog Title", "Your message", AestheticDialog.ERROR);

Emotion :

 AestheticDialog.showEmotion(this,"Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showEmotion(this, "Your dialog Title", "Your message", AestheticDialog.ERROR);

Rainbow :

 AestheticDialog.showRainbow(this,"Your dialog Title", "Your message", AestheticDialog.SUCCESS);
AestheticDialog.showRainbow(this,"Your dialog Title", "Your message", AestheticDialog.ERROR);
AestheticDialog.showRainbow(this,"Your dialog Title", "Your message", AestheticDialog.WARNING);
AestheticDialog.showRainbow(this,"Your dialog Title", "Your message", AestheticDialog.INFO);
3.2 效果如下
Flash DialogConnectify DialogToaster Dialog
d1.gifd2.gifd3.gif
Emotion DialogDrake DialogEmoji Dialog

d5.gifd6.gif

更多详情使用方法请看Github:https://github.com/gabriel-TheCode/AestheticDialogs

下载地址:AestheticDialogs-master.zip

收起阅读 »

炫酷的显示或者隐藏一个布局:Flourish

Flourish提供了一个炫酷的方式来显示或者隐藏一个布局,实现方式也很简单,就是对View或者布局进行了包装,通过构建者模式来提供api给上层调用。就像使用dialog一样,调用show和dissmiss方法来显示和隐藏。此外,通过这些类,我们还可以自定义动...
继续阅读 »

Flourish提供了一个炫酷的方式来显示或者隐藏一个布局,实现方式也很简单,就是对View或者布局进行了包装,通过构建者模式来提供api给上层调用。就像使用dialog一样,调用showdissmiss方法来显示和隐藏。此外,通过这些类,我们还可以自定义动画(正常,加速,反弹),或为布局方向设置我们自己的起点(左上,右下等)。

2.1 如何使用?

在build.gradle 中添加如下依赖:

dependencies {
implementation "com.github.skydoves:flourish:1.0.0"
}

然后在代码中,构建布局:

Flourish flourish = new Flourish.Builder(parentLayout)
// sets the flourish layout for showing and dismissing on the parent layout.
.setFlourishLayout(R.layout.layout_flourish_main)
// sets the flourishing animation for showing and dismissing.
.setFlourishAnimation(FlourishAnimation.BOUNCE)
// sets the orientation of the starting point.
.setFlourishOrientation(FlourishOrientation.TOP_LEFT)
// sets a flourishListener for listening changes.
.setFlourishListener(flourishListener)
// sets the flourish layout should be showed on start.
.setIsShowedOnStart(false)
// sets the duration of the flourishing.
.setDuration(800L)
.build();

还提供有更简介的DSL:

val myFlourish = createFlourish(parentLayout) {
setFlourishLayout(R.layout.layout_flourish_main)
setFlourishAnimation(FlourishAnimation.ACCELERATE)
setFlourishOrientation(FlourishOrientation.TOP_RIGHT)
setIsShowedOnStart(true)
setFlourishListener { }
}
2.2 效果图
效果1效果2

更多详细使用请看Github:https://github.com/skydoves/Flourish

下载地址:Flourish-master.zip

收起阅读 »

动画ViewPager库:LiquidSwipe

这是一个很棒的ViewPager库,它在浏览ViewPager的不同页面时,显示波浪的滑动动画,效果非常炫酷。该库的USP是触摸交互的。这意味着在视图中显示类似液体的显示过渡时,应考虑触摸事件。1.1如何使用呢?导入以下Gradle依赖项:implementa...
继续阅读 »

这是一个很棒的ViewPager库,它在浏览ViewPager的不同页面时,显示波浪的滑动动画,效果非常炫酷。该库的USP是触摸交互的。这意味着在视图中显示类似液体的显示过渡时,应考虑触摸事件。

1.1如何使用呢?

导入以下Gradle依赖项:

implementation 'com.github.Chrisvin:LiquidSwipe:1.3'

然后将LiquidSwipeLayout添加为保存fragment布局的容器的根布局:






1.2 效果图
效果1效果2

更多详细使用方法请看Github: https://github.com/Chrisvin/LiquidSwipe

下载地址:LiquidSwipe-master.zip

收起阅读 »

总是听到有人说AndroidX,到底什么是AndroidX?

本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。 Android技术迭代更新很快,各种新出的技术和名词也是层出不穷。不知从什么时候开始,总是会时不时听到AndroidX这个名词,这难道又是什么新出...
继续阅读 »



本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。



Android技术迭代更新很快,各种新出的技术和名词也是层出不穷。不知从什么时候开始,总是会时不时听到AndroidX这个名词,这难道又是什么新出技术吗?相信有很多朋友也会存在这样的疑惑,那么今天我就来写一篇科普文章,向大家介绍AndroidX的前世今生。





Android系统在刚刚面世的时候,可能连它的设计者也没有想到它会如此成功,因此也不可能在一开始的时候就将它的API考虑的非常周全。随着Android系统版本不断地迭代更新,每个版本中都会加入很多新的API进去,但是新增的API在老版系统中并不存在,因此这就出现了一个向下兼容的问题。


举个例子,当Android系统发布到3.0版本的时候,突然意识到了平板电脑的重要性,因此为了让Android可以更好地兼容平板,Android团队在3.0系统(API 11)中加入了Fragment功能。但是Fragment的作用并不只局限于平板,以前的老系统中也想使用这个功能该怎么办?于是Android团队推出了一个鼎鼎大名的Android Support Library,用于提供向下兼容的功能。比如我们每个人都熟知的support-v4库,appcompat-v7库都是属于Android Support Library的,这两个库相信任何做过Android开发的人都使用过。


但是可能很多人并没有考虑过support-v4库的名字到底是什么意思,这里跟大家解释一下。4在这里指的是Android API版本号,对应的系统版本是1.6。那么support-v4的意思就是这个库中提供的API会向下兼容到Android 1.6系统。它对应的包名如下:



类似地,appcompat-v7指的是将库中提供的API向下兼容至API 7,也就是Android 2.1系统。它对应的包名如下:



可以发现,Android Support Library中提供的库,它们的包名都是以android.support.*开头的。


但是慢慢随着时间的推移,什么1.6、2.1系统早就已经被淘汰了,现在Android官方支持的最低系统版本已经是4.0.1,对应的API版本号是15。support-v4、appcompat-v7库也不再支持那么久远的系统了,但是它们的名字却一直保留了下来,虽然它们现在的实际作用已经对不上当初命名的原因了。


那么很明显,Android团队也意识到这种命名已经非常不合适了,于是对这些API的架构进行了一次重新的划分,推出了AndroidX。因此,AndroidX本质上其实就是对Android Support Library进行的一次升级,升级内容主要在于以下两个方面。


第一,包名。之前Android Support Library中的API,它们的包名都是在android.support.*下面的,而AndroidX库中所有API的包名都变成了在androidx.*下面。这是一个很大的变化,意味着以后凡是android.*包下面的API都是随着Android操作系统发布的,而androidx.*包下面的API都是随着扩展库发布的,这些API基本不会依赖于操作系统的具体版本。


第二,命名规则。吸取了之前命名规则的弊端,AndroidX所有库的命名规则里都不会再包含具体操作系统API的版本号了。比如,像appcompat-v7库,在AndroidX中就变成了appcompat库。


一个AndroidX完整的依赖库格式如下所示:


implementation 'androidx.appcompat:appcompat:1.0.2'

了解了AndroidX是什么之后,现在你应该放轻松了吧?它其实并不是什么全新的东西,而是对Android Support Library的一次升级。因此,AndroidX上手起来也没有任何困难的地方,比如之前你经常使用的RecyclerView、ViewPager等等库,在AndroidX中都会有一个对应的版本,只要改一下包名就可以完全无缝使用,用法方面基本上都没有任何的变化。


但是有一点需要注意,AndroidX和Android Support Library中的库是非常不建议混合在一起使用的,因为它们可能会产生很多不兼容的问题。最好的做法是,要么全部使用AndroidX中的库,要么全部使用Android Support Library中的库。


而现在Android团队官方的态度也很明确,未来都会为AndroidX为主,Android Support Library已经不再建议使用,并会慢慢停止维护。另外,从Android Studio 3.4.2开始,我发现新建的项目已经强制勾选使用AndroidX架构了。





那么对于老项目的迁移应该怎么办呢?由于涉及到了包名的改动,如果从Android Support Library升级到AndroidX需要手动去改每一个文件的包名,那可真得要改死了。为此,Android Studio提供了一个一键迁移的功能,只需要对着你的项目名右击 → Refactor → Migrate to AndroidX,就会弹出如下图所示的窗口。





这里点击Migrate,Android Studio就会自动检查你项目中所有使用Android Support Library的地方,并将它们全部改成AndroidX中对应的库。另外Android Studio还会将你原来的项目备份成一个zip文件,这样即使迁移之后的代码出现了问题你还可以随时还原回之前的代码。


好了,关于AndroidX的内容就讲到这里,相信也是解决了不少朋友心中的疑惑。由于这段时间以来一直在努力赶《第一行代码 第3版》的进度,所以原创文章的数量偏少了一些,也请大家见谅。





关注我的技术公众号,每个工作日都有优质技术文章推送。


微信扫一扫下方二维码即可关注:



收起阅读 »

Android kotlin+协程+Room数据库的简单使用

Room Room是Google为了简化旧版的SQLite操作专门提供的 1.拥有了SQLite的所有操作功能 2.使用简单(类似于Retrofit),通过注解的方式实现相关功能。编译时自动生成实现类impl 3.LiveData,LifeCycle,Pag...
继续阅读 »


Room


Room是Google为了简化旧版的SQLite操作专门提供的
1.拥有了SQLite的所有操作功能
2.使用简单(类似于Retrofit),通过注解的方式实现相关功能。编译时自动生成实现类impl
3.LiveData,LifeCycle,Paging天然融合支持


导入


...

plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-android-extensions'
id 'kotlin-kapt'
}

dependencies {
//room数据库
implementation "androidx.room:room-runtime:2.2.5"
kapt "androidx.room:room-compiler:2.2.5" // Kotlin 使用 kapt
implementation "androidx.room:room-ktx:2.2.5"//Coroutines support for Room 协程操作库

//lifecycle
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.2.0'
}

User


package com.zhangyu.myroom.data

import android.os.Parcelable
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.android.parcel.Parcelize

@Parcelize
@Entity(tableName = "User")
data class User(
@PrimaryKey
var id: String,
var name: String
) : Parcelable

UserDao


package com.zhangyu.myroom.data

import androidx.room.*

@Dao
interface UserDao {

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun putUser(cacheBean: User)

@Query("select * from User where id =:id")
suspend fun getUser(id: String): User?

@Query("select * from User")
suspend fun getAllUser(): List<User>?

@Delete
fun delete(user: User)

@Update(onConflict = OnConflictStrategy.REPLACE)
fun update(user: User)

}

UserDatabase


package com.zhangyu.myroom.data

import android.util.Log
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import com.zhangyu.myroom.App

private const val TAG = "CacheDataBase"

//后续的数据库升级是根据这个version来比较的,exportSchema导出架构
@Database(entities = [User::class], version = 1, exportSchema = false)
abstract class UserDatabase : RoomDatabase() {
companion object {
var dataBase: UserDatabase

init {
//如果databaseBuilder改为inMemoryDatabaseBuilder则创建一个内存数据库(进程销毁后,数据丢失)
dataBase = Room.databaseBuilder(App.context, UserDatabase::class.java, "db_user")
//是否允许在主线程进行查询
.allowMainThreadQueries()
//数据库创建和打开后的回调,可以重写其中的方法
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
Log.d(TAG, "onCreate: db_user")
}
})
//数据库升级异常之后的回滚
.fallbackToDestructiveMigration()
.build()
}

}

abstract fun getUserDao(): UserDao
}

MainActivity


package com.zhangyu.myroom

import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.zhangyu.myroom.data.User
import com.zhangyu.myroom.data.UserDatabase
import kotlinx.coroutines.launch

private const val TAG = "MainActivity"

class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
testCache()
}

private fun testCache() {
val userDao = UserDatabase.dataBase.getUserDao()
userDao.putUser(User("1001", "zhangyu"))
userDao.putUser(User("1002", "liming"))

lifecycleScope.launch {
val users = userDao.getAllUser()
Log.e(TAG, "users: $users")
val user = userDao.getUser("1001")
Log.e(TAG, "user: $user")
Log.e(TAG, "testCache: 协程执行完毕")
}

Log.e(TAG, "testCache: ")

}


}

结果


E/MainActivity: testCache: 
E/MainActivity: users: [User(id=1001, name=zhangyu), User(id=1002, name=liming)]
E/MainActivity: user: User(id=1001, name=zhangyu)
E/MainActivity: testCache: 协程执行完毕
收起阅读 »

Android开发基础之控件RadioButton、RadioGroup

目录 一、基础属性 RadioButton RadioGroup 二、自定义样式 三、监听事件       &nb...
继续阅读 »





       


一、基础属性


RadioButton











































1、layout_width 宽度
2、layout_height 高度
3、id 设置组件id
4、text 设置显示的内容
5、textColor 设置字体颜色
6、textStyle 设置字体风格:normal(无效果)、bold(加粗)、italic(斜体)
7、textSize 字体大小,单位常用sp
8、background 控件背景颜色
9、checked 默认选中该选项

       
1、layout_width
2、layout_height


        组件宽度和高度有4个可选值,如下图:
在这里插入图片描述


       
3、id


// activity_main.xml
android:id="@+id/btn1" // 给当前控件取个id叫btn1

// MainActivity.java
Button btn1=findViewById(R.id.btn1); // 按id获取控件
btn1.setText("hh"); // 对这个控件设置显示内容

        如果在.java和.xml文件中对同一属性进行了不同设置,比如.java中设置控件内容hh,.xml中设置内容为aa,最后显示的是.java中的内容hh。


       
4、text
       可以直接在activity_main.xml中写android:text="嘻嘻",也可以在strings.xml中定义好字符串,再在activity_main.xml中使用这个字符串。


// strings.xml
<string name="str1">嘻嘻</string>

// activity_main.xml
android:text="@string/str1"

       
5、textColor
       与text类似,可以直接在activity_main.xml中写android:textColor="#FF0000FF",也可以在colors.xml中定义好颜色,再在activity_main.xml中使用这个颜色。


       
       


9、checked
       checked=“true”,默认这个RadioButton是选中的。该属性只有在RadioGroup中每个RadioButton都设置了id的条件下才有效。


       


程序示例:


    <RadioButton
android:id="@+id/rb1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/blue_700"
android:textSize="50sp"
android:background="@color/blue_50">

</RadioButton>
<RadioButton
android:id="@+id/rb2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/i_purple_500"
android:textSize="50sp"
android:background="@color/i_purple_200">

</RadioButton>
<RadioButton
android:id="@+id/rb3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="其他"
android:textColor="@color/green_700"
android:textSize="50sp"
android:background="@color/green_100">

</RadioButton>

        如果仅使用RadioButton而不使用RadioGroup,那么每个RadioButton都是可以选中的,如图:


        要实现仅能选中一个,应将几个RadioButton添加进一个组RadioGroup。
       
       


RadioGroup































1、layout_width 宽度
2、layout_height 高度
3、id 设置组件id
4、orientation 内部控件排列的方向,例如水平排列或垂直排列
5、paddingXXX 内边距,该控件内部控件间的距离
6、background 控件背景颜色

       


4、orientation


内部控件的排列方式:



  • orientation=“vertical”,垂直排列

  • orientation=“horizontal”,水平排列


       


5、paddingXXX


内边距,该控件与内部的控件间的距离,常用的padding有以下几种:



  • padding,该控件与内部的控件间的距离

  • paddingTop,该控件与内部的控件间的上方的距离

  • paddingBottom,该控件与内部的控件间的下方的距离

  • paddingRight,该控件与内部的控件间的左侧的距离

  • paddingLeft,该控件与内部的控件间的右侧的距离


       


程序示例:


<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/rg1"
android:orientation="vertical"
android:background="@color/yellow_100"
android:padding="10dp">

<RadioButton
android:id="@+id/rb1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/blue_700"
android:textSize="50sp"
android:background="@color/blue_50">

</RadioButton>
<RadioButton
android:id="@+id/rb2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text=""
android:textColor="@color/i_purple_500"
android:textSize="50sp"
android:background="@color/i_purple_200">

</RadioButton>
<RadioButton
android:id="@+id/rb3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="其他"
android:textColor="@color/green_700"
android:textSize="50sp"
android:background="@color/green_100">

</RadioButton>
</RadioGroup>

效果:


       


       


二、自定义样式


       
1、去掉RadioButton的圆圈
       在RadioButton的属性里写上button="@null"


       
2、自定义背景


新建一个选择器selector
在这里插入图片描述
在这里插入图片描述


       


你取的名字.xml文件内编写代码:



  • item android:state_checked=“false” , 未选中这个RadioButton时的样式

  • item android:state_checked=“true” ,选中这个RadioButton时的样式

  • solid android:color="@color/yellow_100" ,设置实心的背景颜色

  • stroke android:width=“10dp” ,设置边框粗细
               android:color="@color/i_purple_700" ,设置边框颜色

  • corners android:radius=“50dp” ,设置边框圆角大小


程序示例:
blue_selector.xml


<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="false">
<shape android:shape="rectangle">
<solid android:color="@color/blue_100"></solid>
<stroke android:color="@color/blue_700" android:width="5dp"></stroke>
<corners android:radius="30dp"></corners>
</shape>
</item>
<item android:state_checked="true">
<shape android:shape="rectangle">
<solid android:color="@color/blue_500"></solid>
<corners android:radius="30dp"></corners>
</shape>
</item>
</selector>

activity_main.xml


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:orientation="vertical">

<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/rg1"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="50dp">

<RadioButton
android:id="@+id/rb1"
android:layout_width="130dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text=""
android:textSize="50sp"
android:background="@drawable/blue_selector"
android:layout_marginRight="10dp"
android:button="@null">

</RadioButton>
<RadioButton
android:id="@+id/rb2"
android:layout_width="130dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text=""
android:textSize="50sp"
android:background="@drawable/purple_selector"
android:button="@null">

</RadioButton>
<RadioButton
android:id="@+id/rb3"
android:layout_width="130dp"
android:layout_height="wrap_content"
android:gravity="center"
android:text="其他"
android:textSize="50sp"
android:background="@drawable/green_selector"
android:layout_marginLeft="10dp"
android:button="@null">

</RadioButton>
</RadioGroup>
</LinearLayout>



都未选:


选中男:


选中女:


选中其他:


       


       


       


三、监听事件


        在MainActivity.java内添加监听,当选中的按钮变化时,就会执行写好的操作:


public class MainActivity extends AppCompatActivity {
private RadioGroup rg1;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// 获取控件id
rg1=findViewById(R.id.rg1);
// 监听事件
rg1.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(RadioGroup group, int checkedId) {
// 得到当前组被选中的RadioButton
RadioButton rb=group.findViewById(checkedId);
// 显示当前选中的RadioButton的内容
Toast.makeText(MainActivity.this,rb.getText(),Toast.LENGTH_SHORT).show();
}
});
}
}

        监听到选中的按钮变化,并弹出选中按钮的内容:


       

       

收起阅读 »

[干货]手把手教你写一个安卓app

摘要:最近有很多小伙伴在后台留言:Android Studio。我想大家是想写一个手机app,前面已经分享了在QT上如何写一个安卓蓝牙app,虽然qt可以做app,但是比起Android Studio还是差很多。这里介绍一种快速入门的方法来制作一款app,就算...
继续阅读 »


摘要:最近有很多小伙伴在后台留言:Android Studio。我想大家是想写一个手机app,前面已经分享了在QT上如何写一个安卓蓝牙app,虽然qt可以做app,但是比起Android Studio还是差很多。这里介绍一种快速入门的方法来制作一款app,就算你是零基础小白没有学习过java语言也没有关系,相信看完我的文章,半天时间也能做一个安卓app。本文针对初学者,大佬勿喷啊


1. 创建HelloWorld项目


这里我就不介绍如何安装这个Android Studio软件了,网上有很多教程或者去B站找对应的安装视频就可以了。安装好软件之后就开始按照下面的步骤新建工程了。
 选择一个空应用
 按照图片的配置方法,设置好工程名和路径


2. 修改阿里云镜像源


这一步一定要需要,不然的话你需要编译很久,因为在sync的过程中要下载的很多资源是在外网的,这里使用阿里云镜像源就会很快。修改后只对本项目有效:
 第一处代码


maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/jcenter' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/google' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/gradle-plugin' }

第二处代码


maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/jcenter' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/google' }
maven { url 'http://maven.aliyun.com/nexus/content/repositories/gradle-plugin' }

 这样编译起来就会快很多,建议这样修改,不然很可能下载失败导致编译不成功!


3. 真机调试


我们可以编译完成后打包成apk文件发送到你的手机进行安装运行,但我建议还是手机连上数据线在线调试比较好,省去很多时间也非常方便。手机连接电脑后打开USB调试,这里以华为荣耀V10手机作为参考。



  • 1.选择USB连接方式是MIDI(将设备用做MIDI输入设备)

  • 2.在设置的“系统和更新”—>开发人员选项—>打开USB调试











设备作为MIDI设备


开启USB调试


然后点击这个三角形,就可以看到手机上的APP显示了。
















运行结果和上图一样。到这里我们已经完成了一个app的制作怎么样是不是很简单啊!


接下来介绍一下代码目录,方便大家能够快速的掌握和了解项目所生成文件功能和用途!


4. Android代码目录


这里有两种文件架构,所打开的也是两种不同的目录文件。


5. Android应用程序大致启动流程


5.1. APP配置文件



5.2. 活动文件(Java)



5.3. 布局文件(XML)


Android设计讲究前后端分离设计,上面的java文件是后端,引入了activity_main这个前端界面布局文件,如果想再设计一个界面就在layout文件夹下再新建一个 .xml文件就可以了。


5.4. res资源目录(统一管理)



5.4.1. colors.xml



三个颜色有点少我们可以在加一些颜色但这里面来。


    <color name="white">#FFFFFF</color> <!--白色 -->
<color name="ivory">#FFFFF0</color> <!--象牙色 -->
<color name="lightyellow">#FFFFE0</color> <!--亮黄色 -->
<color name="yellow">#FFFF00</color> <!--黄色 -->
<color name="snow">#FFFAFA</color> <!--雪白色 -->
<color name="floralwhite">#FFFAF0</color> <!--花白色 -->
<color name="lemonchiffon">#FFFACD</color> <!--柠檬绸色 -->
<color name="cornsilk">#FFF8DC</color> <!--米绸色 -->

5.4.2. strings.xml



5.4.3. styles.xml


 ***


5、主界面布置


5.1线性布局(LinearLayout)


线性布局的形式可以分为两种,第一种横向线性布局,第二种纵向线性布局,总而言之都是以线性的形式一个个排列出来的,纯线性布局的缺点是很不方便修改控件的显示位置,所以开发中经常会以线性布局与相对布局嵌套的形式设置布局。


5.2相对布局(RelativeLayout)


相对布局是android布局中最为强大的,首先它可以设置的属性是最多了,其次它可以做的事情也是最多的。android手机屏幕的分辨率五花八门,为了考虑屏幕自适应的情况,在开发中建议大家都去使用相对布局,它的坐标取值范围都是相对的,所以使用它来做自适应屏幕是正确的。


5.3帧布局(FrameLayout)


帧布局原理是在控件中绘制任何一个控件都可以被后绘制的控件覆盖,最后绘制的控件会盖住之前的控件。界面中先绘制的ImageView 然后再绘制的TextView和EditView,后者就会覆盖在前者上面。


5.4绝对布局(AbsoluteLayout)


使用绝对布局可以设置任意控件在屏幕中XY坐标点,和帧布局一样绘制的控件会覆盖住之前绘制的控件,不建议大家使用绝对布局。android的手机分辨率五花八门,使用绝对布局的话在其它分辨率的手机上就无法正常的显示了。


5.5表格布局(TableLayout)


在表格布局中可以设置TableRow,可以设置表格中每一行显示的内容以及位置 ,可以设置显示的缩进,对齐的方式。


在实际应用中线行布局和相对布局是最常用的,一般自己写的app布局都相对比较简单,所以这里我们使用线性布局。打开APP配置文件中的activity_main.xml,就可以在这里面愉快的编程了。如果你之前没有玩过Android Studio也没有关系,左边修改右边预览多试试几次就大概明白了。


 在这里我们可以修改点击图片所转换的网址,大家打开源码就知道如何修改了,这里就不在赘述!


 activity_main.xml文件中我们可以修改界面的布局。
 到这里基本上一个简单的安卓应用就完成了。只要你安装了Android Studio软件并且拿到我的源码就可以愉快的玩耍了。什么?你拿到我的代码却不能正常编译通过?下面就教大家如何把别人的源码拿到自己的软件中编译通过!


6、代码移植


以下是需要修改文件的地方,具体修改成啥样,可以参考一个你可以打的开的工程中的配置,参考对应的文件即可。


1.修改build.gradle文件



2.修改app/build.gradle文件


修改版本号


3.修改gradle/wrapper/gradle-wrapper.properties


这个地方修改成你可以打开的工程的 . zip


4.修改local.properties


这个地方是你的软件安装路径所在的位置,要修改成你自己的安装路径

公众号后台回复:firstapp,即可获取源码和教程文档!

收起阅读 »

Android开发杂记--打包release(发行版)App,并将其体积压缩至最小

#Android开发杂记--打包 release(发行版)App,并将其体积压缩至最小 引言 生成签名文件 配置build.gradle文件 执行 Release 打包脚本 引言 &...
继续阅读 »




#Android开发杂记--打包 release(发行版)App,并将其体积压缩至最小





引言


       我们在 Android Studio 中开发完App,直接点击右上角的 Run 会发现,App的大小至少10MB左右,且没有任何签名。
       这是因为我们直接 Run 的时候,生成的是 Debug 版本,为了开发时的编译速度,因此其体积比较大。但当我们想要将 App 正式上线时,不可能拿着 Debug 版本给人用,因此需要生成 Release(发行) 版。




生成签名文件


       想要生成 Release 版,首先需要一个签名文件,制作工具很多,这里不重点介绍,我这里使用腾讯云·移动安全制作签名文件。如下所示,填写相关信息,点击制作签名即可。




配置build.gradle文件


       首先在项目的根build.gradle中,添加一个依赖:


buildscript {
...
dependencies {
classpath "com.android.tools.build:gradle:4.1.2"
// 需要新添加的依赖
classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.20'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
...

       随后在app目录下的build.gradle中添加签名和打包配置,有注释的地方表示要自己进行配置:


...
// 添加打包插件
apply plugin: 'AndResGuard'

android {
// 填写签名文件信息
signingConfigs {
key {
storeFile file('D:\\Projects\\AndroidStudio\\key.keystore')
storePassword '123456'
keyAlias 'key'
keyPassword '123456'
}
}
...
defaultConfig {
...
// 添加刚刚配置的签名文件
signingConfig signingConfigs.key
}

buildTypes {
release {
// 修改为 true
minifyEnabled true
// 允许打包成多Dex文件
multiDexEnabled true
...
}
}
...
}

dependencies {
...
}

// 以下配置直接复制过去即可
andResGuard {
// mappingFile = file("./resource_mapping.txt")
mappingFile = null
use7zip = true
useSign = true
// 打开这个开关,会keep住所有资源的原始路径,只混淆资源的名字
keepRoot = false
// 设置这个值,会把arsc name列混淆成相同的名字,减少string常量池的大小
fixedResName = "arg"
// 打开这个开关会合并所有哈希值相同的资源,但请不要过度依赖这个功能去除去冗余资源
mergeDuplicatedRes = true
whiteList = [
// for your icon
"R.drawable.icon",
// for fabric
"R.string.com.crashlytics.*",
// for google-services
"R.string.google_app_id",
"R.string.gcm_defaultSenderId",
"R.string.default_web_client_id",
"R.string.ga_trackingId",
"R.string.firebase_database_url",
"R.string.google_api_key",
"R.string.google_crash_reporting_api_key"
]
compressFilePattern = [
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
]
sevenzip {
artifact = 'com.tencent.mm:SevenZip:1.2.20'
//path = "/usr/local/bin/7za"
}

/**
* 可选: 如果不设置则会默认覆盖assemble输出的apk
**/
// finalApkBackupPath = "${project.rootDir}/final.apk"

/**
* 可选: 指定v1签名时生成jar文件的摘要算法
* 默认值为“SHA-1”
**/
// digestalg = "SHA-256"
}



执行 Release 打包脚本


       在 Android Studio 中点击 Gradle 选项卡,默认在 Android Studio 的右上角,如图所示。

       找到如下界面,右击 resguardRelease,再单击 Run 即可自动打包完成 Release(发行) 版本。

       等待打包完成(需要一点儿时间),在项目路径下的 app\build\outputs\apk\release中即可找到打包完成的apk,可以很明显的看出来,大小相比 Debug 版已经小了很多了。

收起阅读 »

RecyclerView 动画原理 | 如何存储并应用动画属性值?(2)

RecyclerView 动画原理 | 如何存储并应用动画属性值?(1)存预布局动画属性值 InfoRecord中除了postInfo还有一个preInfo,分别表示后布局和预布局表项的动画信息。想必还有一个addToPreLayout()与addToPost...
继续阅读 »

RecyclerView 动画原理 | 如何存储并应用动画属性值?(1)


存预布局动画属性值


InfoRecord中除了postInfo还有一个preInfo,分别表示后布局和预布局表项的动画信息。想必还有一个addToPreLayout()addToPostLayout()对应:


class ViewInfoStore {
// 存储预布局表项与其动画信息
void addToPreLayout(RecyclerView.ViewHolder holder, RecyclerView.ItemAnimator.ItemHolderInfo info) {
InfoRecord record = mLayoutHolderMap.get(holder);
if (record == null) {
record = InfoRecord.obtain();
mLayoutHolderMap.put(holder, record);
}
record.preInfo = info; // 将后布局表项动画信息存储在 preInfo 字段中
record.flags |= FLAG_PRE; // 追加 FLAG_PRE 到标志位
}
}
复制代码

addToPreLayout()在预布局阶段被调用:


public class RecyclerView {
private void dispatchLayoutStep1() {
...
// 遍历可见表项
int count = mChildHelper.getChildCount();
for (int i = 0; i < count; ++i) {
final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
...
// 构建表项动画信息
final ItemHolderInfo animationInfo = mItemAnimator
.recordPreLayoutInformation(mState, holder,
ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),
holder.getUnmodifiedPayloads());
// 将表项动画信息保存到 mViewInfoStore
mViewInfoStore.addToPreLayout(holder, animationInfo);
...
}
...
// 预布局
mLayout.onLayoutChildren(mRecycler, mState);
}
}
复制代码

RecyclerView 布局的第一个阶段中,在第一次执行onLayoutChildren()之前,即预布局之前,遍历了所有的表项并逐个构建动画信息。以 Demo 为例,预布局之前,表项 1、2 的动画信息被构建并且标志位追加了FLAG_PRE,这些信息都被保存到mViewInfoStore实例中。


紧接着RecyclerView执行了onLayoutChildren(),即进行预布局。


public class RecyclerView {
private void dispatchLayoutStep1() {
// 遍历预布局前所有表项
int count = mChildHelper.getChildCount();
for (int i = 0; i < count; ++i) {
final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
...
final ItemHolderInfo animationInfo = mItemAnimator
.recordPreLayoutInformation(mState, holder,
ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),
holder.getUnmodifiedPayloads());
mViewInfoStore.addToPreLayout(holder, animationInfo);
...
}
...
// 预布局
mLayout.onLayoutChildren(mRecycler, mState);
// 遍历预布局之后所有的表项
for (int i = 0; i < mChildHelper.getChildCount(); ++i) {
final View child = mChildHelper.getChildAt(i);
final ViewHolder viewHolder = getChildViewHolderInt(child);
...
// 如果 ViewInfoStore 中没有对应的 ViewHolder 信息
if (!mViewInfoStore.isInPreLayout(viewHolder)) {
...
// 构建表项动画信息
final ItemHolderInfo animationInfo = mItemAnimator.recordPreLayoutInformation(mState, viewHolder, flags, viewHolder.getUnmodifiedPayloads());
...
// 将表项 ViewHolder 和其动画信息绑定并保存在 mViewInfoStore 中
mViewInfoStore.addToAppearedInPreLayoutHolders(viewHolder, animationInfo);

}
}
}
}
复制代码

RecyclerView 在预布局之后再次遍历了所有表项。因为预布局会把表项 3 也填充到列表中,所以表项 3 的动画信息也会被存入mViewInfoStore,不过调用的是ViewInfoStore.addToAppearedInPreLayoutHolders()


class ViewInfoStore {
void addToAppearedInPreLayoutHolders(RecyclerView.ViewHolder holder, RecyclerView.ItemAnimator.ItemHolderInfo info) {
InfoRecord record = mLayoutHolderMap.get(holder);
if (record == null) {
record = InfoRecord.obtain();
mLayoutHolderMap.put(holder, record);
}
record.flags |= FLAG_APPEAR; // 追加 FLAG_APPEAR 到标志位
record.preInfo = info; // 将预布局表项动画信息存储在 preInfo 字段中
}
}
复制代码

addToAppearedInPreLayoutHolders()addToPreLayout()的实现几乎一摸一样,唯一的不同是,标志位追加了FLAG_APPEAR,用于标记表项 3 是即将出现在屏幕中的表项。


分析至此,可以得出下面的结论:



RecyclerView 经历了预布局、后布局及布局第三阶段后,ViewInfoStore中就记录了每一个参与动画表项的三重信息:预布局位置信息 + 后布局位置信息 + 经历过的布局阶段。



以 Demo 为例,表项 1、2、3 的预布局和后布局位置信息都被记录在ViewInfoStore中,其中表项 1 在预布局和后布局中均出现了,所以标志位中包含了FLAG_PRE | FLAG_POSTInfoRecord中用一个新的常量表示了这种状态FLAG_PRE_AND_POST


class ViewInfoStore {
static class InfoRecord {
static final int FLAG_PRE = 1 << 2;
static final int FLAG_POST = 1 << 3;
static final int FLAG_PRE_AND_POST = FLAG_PRE | FLAG_POST;
}
}
复制代码

而表项 2 只出现在预布局阶段,所以标志位仅包含了FLAG_PRE。表项 3 出现在预布局之后及后布局中,所以标志位中包含了FLAG_APPEAR | FLAG_POST


应用动画属性值


public class RecyclerView {
private void dispatchLayoutStep3() {
// 遍历后布局表项并构建动画信息再存储到 mViewInfoStore
for (int i = mChildHelper.getChildCount() - 1; i >= 0; i--) {
ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
long key = getChangedHolderKey(holder);
final ItemHolderInfo animationInfo = mItemAnimator.recordPostLayoutInformation(mState, holder);
ViewHolder oldChangeViewHolder = mViewInfoStore.getFromOldChangeHolders(key);
mViewInfoStore.addToPostLayout(holder, animationInfo);
}
// 触发表项执行动画
mViewInfoStore.process(mViewInfoProcessCallback);
...
}
}
复制代码

RecyclerView 布局的第三个阶段中,在遍历完后布局表项后,调用了mViewInfoStore.process(mViewInfoProcessCallback)来触发表项执行动画:


class ViewInfoStore {
void process(ProcessCallback callback) {
// 遍历所有参与动画表项的位置信息
for (int index = mLayoutHolderMap.size() - 1; index >= 0; index--) {
// 获取表项 ViewHolder
final RecyclerView.ViewHolder viewHolder = mLayoutHolderMap.keyAt(index);
// 获取与 ViewHolder 对应的动画信息
final InfoRecord record = mLayoutHolderMap.removeAt(index);
// 根据动画信息的标志位确定动画类型以执行对应的 ProcessCallback 回调
if ((record.flags & FLAG_APPEAR_AND_DISAPPEAR) == FLAG_APPEAR_AND_DISAPPEAR) {
callback.unused(viewHolder);
} else if ((record.flags & FLAG_DISAPPEARED) != 0) {
if (record.preInfo == null) {
callback.unused(viewHolder);
} else {
callback.processDisappeared(viewHolder, record.preInfo, record.postInfo);
}
} else if ((record.flags & FLAG_APPEAR_PRE_AND_POST) == FLAG_APPEAR_PRE_AND_POST) {
callback.processAppeared(viewHolder, record.preInfo, record.postInfo);
} else if ((record.flags & FLAG_PRE_AND_POST) == FLAG_PRE_AND_POST) {
callback.processPersistent(viewHolder, record.preInfo, record.postInfo);// 保持
} else if ((record.flags & FLAG_PRE) != 0) {
callback.processDisappeared(viewHolder, record.preInfo, null); // 消失动画
} else if ((record.flags & FLAG_POST) != 0) {
callback.processAppeared(viewHolder, record.preInfo, record.postInfo);// 出现动画
} else if ((record.flags & FLAG_APPEAR) != 0) {
}
// 回收动画信息实例到池中
InfoRecord.recycle(record);
}
}
}
复制代码

ViewInfoStore.process()中遍历了包含所有表项动画信息的mLayoutHolderMap结构,并根据每个表项的标志位来确定执行的动画类型:




  • 表项 1 的标志位为FLAG_PRE_AND_POST所以会命中callback.processPersistent()




  • 表项 2 的标志位中只包含FLAG_PRE,所以(record.flags & FLAG_PRE) != 0成立,callback.processDisappeared()会命中。




  • 表项 3 的标志位中只包含FLAG_APPEAR | FLAG_POST,所以(record.flags & FLAG_APPEAR_PRE_AND_POST) == FLAG_APPEAR_PRE_AND_POST不成立,而(record.flags & FLAG_POST) != 0成立,callback.processAppeared()会命中。




作为参数传入ViewInfoStore.process()ProcessCallback是 RecyclerView 中预定义的动画回调:


class ViewInfoStore {
// 动画回调
interface ProcessCallback {
// 消失动画
void processDisappeared(RecyclerView.ViewHolder viewHolder, RecyclerView.ItemAnimator.ItemHolderInfo preInfo,RecyclerView.ItemAnimator.ItemHolderInfo postInfo);
// 出现动画
void processAppeared(RecyclerView.ViewHolder viewHolder, RecyclerView.ItemAnimator.ItemHolderInfo preInfo,RecyclerView.ItemAnimator.ItemHolderInfo postInfo);
...
}
}

public class RecyclerView {
// RecyclerView 动画回调默认实现
private final ViewInfoStore.ProcessCallback mViewInfoProcessCallback =
new ViewInfoStore.ProcessCallback() {
@Override
public void processDisappeared(ViewHolder viewHolder, ItemHolderInfo info, ItemHolderInfo postInfo) {
mRecycler.unscrapView(viewHolder);
animateDisappearance(viewHolder, info, postInfo);//消失动画
}
@Override
public void processAppeared(ViewHolder viewHolder,ItemHolderInfo preInfo, ItemHolderInfo info) {
animateAppearance(viewHolder, preInfo, info);//出现动画
}
...
};
// 表项动画执行器
ItemAnimator mItemAnimator = new DefaultItemAnimator();
// 出现动画
void animateAppearance(@NonNull ViewHolder itemHolder,ItemHolderInfo preLayoutInfo, ItemHolderInfo postLayoutInfo) {
itemHolder.setIsRecyclable(false);
if (mItemAnimator.animateAppearance(itemHolder, preLayoutInfo, postLayoutInfo)) {
postAnimationRunner();
}
}
// 消失动画
void animateDisappearance(@NonNull ViewHolder holder,ItemHolderInfo preLayoutInfo, ItemHolderInfo postLayoutInfo) {
addAnimatingView(holder);
holder.setIsRecyclable(false);
if (mItemAnimator.animateDisappearance(holder, preLayoutInfo, postLayoutInfo)) {
postAnimationRunner();
}
}
}
复制代码

RecyclerView 执行表项动画的代码结构如下:


if (mItemAnimator.animateXXX(holder, preLayoutInfo, postLayoutInfo)) {
postAnimationRunner();
}
复制代码

根据ItemAnimator.animateXXX()的返回值来决定是否要在下一帧执行动画,以 Demo 中表项 3 的出现动画为例:


public abstract class SimpleItemAnimator extends RecyclerView.ItemAnimator {
@Override
public boolean animateAppearance(RecyclerView.ViewHolder viewHolder,ItemHolderInfo preLayoutInfo, ItemHolderInfo postLayoutInfo) {
// 如果预布局和后布局中表项左上角的坐标有变化 则执行位移动画
if (preLayoutInfo != null
&& (preLayoutInfo.left != postLayoutInfo.left || preLayoutInfo.top != postLayoutInfo.top)) {
// 执行位移动画,并传入动画起点坐标(预布局表项左上角坐标)和终点坐标(后布局表项左上角坐标)
return animateMove(viewHolder,
preLayoutInfo.left,
preLayoutInfo.top,
postLayoutInfo.left,
postLayoutInfo.top);
} else {
return animateAdd(viewHolder);
}
}
}
复制代码

之前存储的表项位置信息,终于在这里被用上了,它作为参数传入animateMove(),这是一个定义在SimpleItemAnimator中的抽象方法,DefaultItemAnimator实现了它:


public class DefaultItemAnimator extends SimpleItemAnimator {
@Override
public boolean animateMove(final RecyclerView.ViewHolder holder, int fromX, int fromY,
int toX, int toY)
{
final View view = holder.itemView;
fromX += (int) holder.itemView.getTranslationX();
fromY += (int) holder.itemView.getTranslationY();
resetAnimation(holder);
int deltaX = toX - fromX;
int deltaY = toY - fromY;
if (deltaX == 0 && deltaY == 0) {
dispatchMoveFinished(holder);
return false;
}
// 表项水平位移
if (deltaX != 0) {
view.setTranslationX(-deltaX);
}
// 表项垂直位移
if (deltaY != 0) {
view.setTranslationY(-deltaY);
}
// 将待移动的表项动画包装成 MoveInfo 并存入 mPendingMoves 列表
mPendingMoves.add(new MoveInfo(holder, fromX, fromY, toX, toY));
// 表示在下一帧执行动画
return true;
}
}
复制代码

如果水平或垂直方向的位移增量不为 0,则将待移动的表项动画包装成MoveInfo并存入mPendingMoves列表,然后返回 true,表示在下一帧执行动画:


public class RecyclerView {  
// 出现动画
void animateAppearance(ViewHolder itemHolder,ItemHolderInfo preLayoutInfo, ItemHolderInfo postLayoutInfo) {
itemHolder.setIsRecyclable(false);
if (mItemAnimator.animateAppearance(itemHolder, preLayoutInfo, postLayoutInfo)) {
postAnimationRunner();// 触发动画执行
}
}

// 将动画执行代码抛到 Choreographer 中的动画队列中
void postAnimationRunner() {
if (!mPostedAnimatorRunner && mIsAttached) {
ViewCompat.postOnAnimation(this, mItemAnimatorRunner);
mPostedAnimatorRunner = true;
}
}
// 动画执行代码
private Runnable mItemAnimatorRunner = new Runnable() {
@Override
public void run() {
if (mItemAnimator != null) {
// 在下一帧执行动画
mItemAnimator.runPendingAnimations();
}
mPostedAnimatorRunner = false;
}
};
}
复制代码

通过将一个Runnable抛到Choreographer的动画队列中来触发动画执行,当下一个垂直同步信号到来时,Choreographer会从动画队列中获取待执行的Runnable实例,并将其抛到主线程执行(关于Choreographer的详细解析可以点击读源码长知识 | Android卡顿真的是因为”掉帧“?)。执行的内容定义在ItemAnimator.runPendingAnimations()中:


public class DefaultItemAnimator extends SimpleItemAnimator {
@Override
public void runPendingAnimations() {
// 如果位移动画列表不空,则表示有待执行的位移动画
boolean movesPending = !mPendingMoves.isEmpty();
// 是否有待执行的删除动画
boolean removalsPending = !mPendingRemovals.isEmpty();
...
// 处理位移动画
if (movesPending) {
final ArrayList moves = new ArrayList<>();
moves.addAll(mPendingMoves);
mMovesList.add(moves);
mPendingMoves.clear();
Runnable mover = new Runnable() {
@Override
public void run() {
for (MoveInfo moveInfo : moves) {
// 位移动画具体实现
animateMoveImpl(moveInfo.holder, moveInfo.fromX, moveInfo.fromY,
moveInfo.toX, moveInfo.toY);
}
moves.clear();
mMovesList.remove(moves);
}
};
// 若存在删除动画,则延迟执行位移动画,否则立刻执行
if (removalsPending) {
View view = moves.get(0).holder.itemView;
ViewCompat.postOnAnimationDelayed(view, mover, getRemoveDuration());
} else {
mover.run();
}
}
...
}
}
复制代码

遍历mPendingMoves列表,为每一个待执行的位移动画调用animateMoveImpl()构建动画:


public class DefaultItemAnimator extends SimpleItemAnimator {
void animateMoveImpl(final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) {
final View view = holder.itemView;
final int deltaX = toX - fromX;
final int deltaY = toY - fromY;
if (deltaX != 0) {
view.animate().translationX(0);
}
if (deltaY != 0) {
view.animate().translationY(0);
}

// 获取动画实例
final ViewPropertyAnimator animation = view.animate();
mMoveAnimations.add(holder);
// 设置动画参数并启动
animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
dispatchMoveStarting(holder);
}

@Override
public void onAnimationCancel(Animator animator) {
if (deltaX != 0) {
view.setTranslationX(0);
}
if (deltaY != 0) {
view.setTranslationY(0);
}
}

@Override
public void onAnimationEnd(Animator animator) {
animation.setListener(null);
dispatchMoveFinished(holder);
mMoveAnimations.remove(holder);
dispatchFinishedWhenDone();
}
}).start();
}
}
复制代码

原来默认的表项动画是通过ViewPropertyAnimator实现的。


总结



  1. RecyclerView 将表项动画数据封装了两层,依次是ItemHolderInfoInfoRecord,它们记录了列表预布局和后布局表项的位置信息,即表项矩形区域与列表左上角的相对位置,它还用一个int类型的标志位来记录表项经历了哪些布局阶段,以判断表项应该做的动画类型(出现,消失,保持)。

  2. InfoRecord被集中存放在一个商店类ViewInfoStore中。所有参与动画的表项的ViewHolderInfoRecord都会以键值对的形式存储其中。

  3. RecyclerView 在布局的第三阶段会遍历商店类中所有的键值对,以InfoRecord中的标志位为依据,判断执行哪种动画。表项预布局和后布局的位置信息会一并传递给RecyclerView.ItemAnimator,以触发动画。

  4. RecyclerView.ItemAnimator收到动画指令和数据后,又将他们封装为MoveInfo,不同类型的动画被存储在不同的MoveInfo列表中。然后将执行动画的逻辑抛到 Choreographer 的动画队列中,当下一个垂直同步信号到来时,Choreographer 从动画队列中取出并执行表项动画,执行动画即遍历所有的MoveInfo列表,为每一个MoveInfo构建 ViewPropertyAnimator 实例并启动动画。

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

RecyclerView 动画原理 | 如何存储并应用动画属性值?(1)

RecyclerView 表项动画的属性值是怎么获取的,又存储在哪里?这一篇继续通过 走查源码 的方式解答这个疑问。 通过上两篇的分析得知,为了做动画 RecyclerView 会布局两次:预布局+后布局,依次将动画前与动画后的表项填充到列表。表项被填充后,就...
继续阅读 »

RecyclerView 表项动画的属性值是怎么获取的,又存储在哪里?这一篇继续通过 走查源码 的方式解答这个疑问。


通过上两篇的分析得知,为了做动画 RecyclerView 会布局两次:预布局+后布局,依次将动画前与动画后的表项填充到列表。表项被填充后,就确定了它相对于 RecyclerView 左上角的位置,在两次布局过程中,这些位置信息是如何被保存的?


引子


这一篇源码分析还是基于下面这个 Demo 场景:



列表中有两个表项(1、2),删除 2,此时 3 会从屏幕底部平滑地移入并占据原来 2 的位置。


为了实现该效果,RecyclerView的策略是:为动画前的表项先执行一次pre-layout,将不可见的表项 3 也加载到布局中,形成一张布局快照(1、2、3)。再为动画后的表项执行一次post-layout,同样形成一张布局快照(1、3)。比对两张快照中表项 3 的位置,就知道它该如何做动画了。


在此援引上一篇已经得出的结论:





  1. RecyclerView为了实现表项动画,进行了 2 次布局(预布局 + 后布局),在源码上表现为LayoutManager.onLayoutChildren()被调用 2 次。




  2. 预布局的过程始于RecyclerView.dispatchLayoutStep1(),终于RecyclerView.dispatchLayoutStep2()




  3. 在预布局阶段,循环填充表项时,若遇到被移除的表项,则会忽略它占用的空间,多余空间被用来加载额外的表项,这些表项在屏幕之外,本来不会被加载。





其中第三点表现在源码上,是这样的:


public class LinearLayoutManager {
// 布局表项
public void onLayoutChildren() {
// 不断填充表项
fill() {
while(列表有剩余空间){
// 填充单个表项
layoutChunk(){
// 让表项成为子视图
addView(view)
}
if (表项没有被移除) {
剩余空间 -= 表项占用空间
}
...
}
}
}
}
复制代码

这是RecyclerView填充表项的伪码。以 Demo 为例,预布局阶段,第一次执行onLayoutChildren(),因表项 2 被删除,所以它占用的空间不会被扣除,导致while循环多执行一次,这样表项 3 就被填充进列表。


后布局阶段,会再次执行onLayoutChildren(),再把表项 1、3 填入列表。那此时列表中不是得有两个表项 1,两个表项 3,和一个表项 2 吗?


这显然是不可能的,用上一篇介绍的断点调试,运行 Demo,把断点断在addView(),发现后布局阶段再次调用该方法时,RecyclerView的子控件个数为 0。


先清空表项再填充


难道每次布局之前都会删掉现有布局中所有的表项?


fill()开始,往上走查代码,果然发现了一个线索:


public class LinearLayoutManager {
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// detach 并 scrap 表项
detachAndScrapAttachedViews(recycler);
...
// 填充表项
fill()
}
复制代码

在填充表项之前,有一个 detach 操作:


public class RecyclerView {
public abstract static class LayoutManager {
public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
// 遍历所有子表项
final int childCount = getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View v = getChildAt(i);
// 回收子表项
scrapOrRecycleView(recycler, i, v);
}
}
}
}
复制代码

果不其然,在填充表项之前会遍历所有子表项,并逐个回收它们:


public class RecyclerView {
public abstract static class LayoutManager {
// 回收表项
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.isInvalid() && !viewHolder.isRemoved()&& !mRecyclerView.mAdapter.hasStableIds()) {
removeViewAt(index);
recycler.recycleViewHolderInternal(viewHolder);
} else {
// detach 表项
detachViewAt(index);
// scrap 表项
recycler.scrapView(view);
...
}
}
}
}
复制代码

回收表项时,根据viewHolder的不同状态执行不同分支。硬看源码很难快速判断会走哪个分支,果断运行 Demo,断点调试一把。在上述场景中,所有表项都走了第二个分支,即在布局表项之前,对现有表项做了两个关键的操作:



  1. detach 表项detachViewAt(index)

  2. scrap 表项recycler.scrapView(view)


detach 表项


先看看 detach 表项是个什么操作:


public class RecyclerView {
public abstract static class LayoutManager {
ChildHelper mChildHelper;
// detach 指定索引的表项
public void detachViewAt(int index) {
detachViewInternal(index, getChildAt(index));
}

// detach 指定索引的表项
private void detachViewInternal(int index, @NonNull View view) {
...
// 将 detach 委托给 ChildHelper
mChildHelper.detachViewFromParent(index);
}
}
}

// RecyclerView 子表项管理类
class ChildHelper {
// 将指定位置的表项从 RecyclerView detach
void detachViewFromParent(int index) {
final int offset = getOffset(index);
mBucket.remove(offset);
// 最终实现 detach 操作的回调
mCallback.detachViewFromParent(offset);
}
}
复制代码

LayoutManager会将 detach 任务委托给ChildHelperChildHelper再执行detachViewFromParent()回调,它在初始化ChildHelper时被实现:


public class RecyclerView {
// 初始化 ChildHelper
private void initChildrenHelper() {
// 构建 ChildHelper 实例
mChildHelper = new ChildHelper(new ChildHelper.Callback() {
@Override
public void detachViewFromParent(int offset) {
final View view = getChildAt(offset);
...
// 调用 ViewGroup.detachViewFromParent()
RecyclerView.this.detachViewFromParent(offset);
}
...
}
}
}
复制代码

RecyclerView detach 表项的最后一步调用了ViewGroup.detachViewFromParent()


public abstract class ViewGroup {
// detach 子控件
protected void detachViewFromParent(int index) {
removeFromArray(index);
}

// 删除子控件的最后一步
private void removeFromArray(int index) {
final View[] children = mChildren;
// 将子控件持有的父控件引用置空
if (!(mTransitioningViews != null && mTransitioningViews.contains(children[index]))) {
children[index].mParent = null;
}
final int count = mChildrenCount;
// 将父控件持有的子控件引用置空
if (index == count - 1) {
children[--mChildrenCount] = null;
} else if (index >= 0 && index < count) {
System.arraycopy(children, index + 1, children, index, count - index - 1);
children[--mChildrenCount] = null;
}
...
}
}
复制代码

ViewGroup.removeFromArray()是容器控件移除子控件的最后一步(ViewGroup.removeView()也会调用这个方法)。


至此可以得出结论:



在每次向RecyclerView填充表项之前都会先清空现存表项。



目前看来,detach viewremove view差不多,它们都会将子控件从父控件的孩子列表中删除,唯一的区别是detach更轻量,不会触发重绘。而且detach是短暂的,被detach的 View 最终必须被彻底 remove 或者重新 attach。(下面就会马上把他们重新 attach)


scrap 表项


scrap 表项的意思是回收表项并将其存入mAttachedScrap列表,它是回收器Recycler中的成员变量:


public class RecyclerView {
public final class Recycler {
// scrap 列表
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
}
}
复制代码

mAttachedScrap是一个 ArrayList 结构,用于存储ViewHolder实例。


RecyclerView 填充表项前,除了会 detach 所有可见表项外,还会同时 scrap 它们:


public class RecyclerView {
public abstract static class LayoutManager {
// 回收表项
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
...
// detach 表项
detachViewAt(index);
// scrap 表项
recycler.scrapView(view);
...
}
}
}
复制代码

scrapView()是回收器Recycler的方法,正是这个方法将表项回收到了mAttachedScrap列表中:


public class RecyclerView {
public final class Recycler {
void scrapView(View view) {
final ViewHolder holder = getChildViewHolderInt(view);
// 表项不需要更新,或被移除,或者表项索引无效时,将被会收到 mAttachedScrap
if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
|| !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
holder.setScrapContainer(this, false);
// 将表项回收到 mAttachedScrap 结构中
mAttachedScrap.add(holder);
} else {
// 只有当表项没有被移除且有效且需要更新时才会被回收到 mChangedScrap
if (mChangedScrap == null) {
mChangedScrap = new ArrayList<ViewHolder>();
}
holder.setScrapContainer(this, true);
mChangedScrap.add(holder);
}
}
}
}
复制代码

scrapView()中根据ViewHolder状态将其会收到不同的结构中,同样地,硬看源码很难快速判断执行了那个分支,继续断点调试,Demo 场景中所有的表项都会被回收到mAttachedScrap结构中。(关于 mAttachedScrap 和 mChangedScrap 的区别会在后续文章分析)


分析至此,进一步细化刚才得到的结论:



在每次向RecyclerView填充表项之前都会先清空 LayoutManager 中现存表项,将它们 detach 并同时缓存入 mAttachedScrap列表中。



将结论应用在 Demo 的场景,即是:RecyclerView 在预布局阶段准备向列表中填充表项前,会清空现有的表项 1、2,把它们都 detach 并回收对应的 ViewHolder 到 mAttachedScrap列表中。


从缓存拿填充表项


预布局与 scrap 缓存的关系


缓存定是为了复用,啥时候用呢?紧接着的“填充表项”中就立马会用到:


public class LinearLayoutManager {
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// detach 表项
detachAndScrapAttachedViews(recycler);
...
// 填充表项
fill()
}

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
// 计算剩余空间
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 不停的往列表中填充表项,直到没有剩余空间
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
}
}

// 填充单个表项
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
// 获取下一个被填充的视图
View view = layoutState.next(recycler);
...
// 填充视图
addView(view);
...
}
}
复制代码

填充表项时,通过layoutState.next(recycler)获取下一个该被填充的表项视图:


public class LinearLayoutManager {
static class LayoutState {
View next(RecyclerView.Recycler recycler) {
...
// 委托 Recycler 获取下一个该填充的表项
final View view = recycler.getViewForPosition(mCurrentPosition);
...
return view;
}
}
}

public class RecyclerView {
public final class Recycler {
public View getViewForPosition(int position) {
return getViewForPosition(position, false);
}
}

View getViewForPosition(int position, boolean dryRun) {
// 调用链最终传递到 tryGetViewHolderForPositionByDeadline()
return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}
}
复制代码

沿着调用链一直往下,最终走到了Recycler.tryGetViewHolderForPositionByDeadline(),在RecyclerView缓存机制(咋复用?)中对其做过详细介绍,援引结论如下:



  1. 在 RecyclerView 中,并不是每次绘制表项,都会重新创建 ViewHolder 对象,也不是每次都会重新绑定 ViewHolder 数据。

  2. RecyclerView 填充表项前,会通过Recycler获取表项的 ViewHolder 实例。

  3. RecyclertryGetViewHolderForPositionByDeadline()方法中,前后尝试 5 次,从不同缓存中获取可复用的 ViewHolder 实例,其中第一优先级的缓存即是scrap结构。

  4. scrap缓存获取的表项不需要重新构建,也不需要重新绑定数据。


从 scrap 结构获取 ViewHolder 的源码如下:


public class RecyclerView {
public final class Recycler {
ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
ViewHolder holder = null;
...
// 从 scrap 结构中获取指定 position 的 ViewHolder 实例
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
...
}
...
}

ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();
// 遍历 mAttachedScrap 列表中所有的 ViewHolder 实例
for (int i = 0; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
// 校验 ViewHolder 是否满足条件,若满足,则缓存命中
if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
&& !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
...
}
}
}
复制代码

mAttachedScrap列表中获取的ViewHolder实例后,得进行校验。校验的内容很多,其中最重要的的是:ViewHolder索引值和当前填充表项的位置值是否相等,即:


scrap 结构缓存的 ViewHolder 实例,只能复用于和它回收时相同位置的表项。


也就是说,若当前列表正准备填充 Demo 中的表项 2(position == 1),即使 scrap 结构中有相同类型 ViewHolder,只要viewHolder.getLayoutPosition()的值不为 1,缓存不会命中。


分析至此,可以把上面得到的结论进一步拓展:



在每次向RecyclerView填充表项之前都会先清空 LayoutManager 中现存表项,将它们 detach 并同时缓存入 mAttachedScrap列表中。在紧接着的填充表项阶段,就立马从mAttachedScrap中取出刚被 detach 的表项并重新 attach 它们。



(弱弱地问一句,这样折腾意义何在?可能接着往下看就知道了。。)


将结论应用在 Demo 的场景,即是:RecyclerView 在预布局阶段准备向列表中填充表项前,会清空现有的表项 1、2,把它们都 detach 并回收对应的 ViewHolder 到 mAttachedScrap 列表中。然后又在填充表项阶段从 mAttachedScrap 中重新获取了表项 1、2 并填入列表。


上一篇的结论说“Demo 场景中,预布局阶段还会额外加载列表第三个位置的表项 3”,但mAttachedScrap只缓存了表项 1、2。所以在填充表项 3 时,scrap 缓存未命中。不仅如此,因表项 3 是从未被加载过的表项,遂所有的缓存都不会命中,最后只能沦落到重新构建表项并绑定数据


public class RecyclerView {
public final class Recycler {
ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
if (holder == null) {
...
// 构建 ViewHolder
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
// 获取表项偏移的位置
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
// 绑定 ViewHolder 数据
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
}
}
}
复制代码

沿着上述代码的调用链往下走查,就能找到熟悉的onCreateViewHolder()onBindViewHolder()


在绑定 ViewHolder 数据之前,先调用了mAdapterHelper.findPositionOffset(position)获取了“偏移位置”。断点调试告诉我,此时它会返回 1,即表项 2 被移除后,表项 3 在列表中的位置。


AdapterHelper将所有对表项的操作都抽象成UpdateOp并保存在列表中,当获取表项 3 偏移位置时,它发现有一个表项 2 的删除操作,所以表项 3 的位置会 -1。(有关 AdapterHelper 的内容就不展开了~)


至此,预布局阶段的填充表项结束了,LayoutManager 中现有表项 1、2、3,形成了第一张快照(1,2,3)。


后布局与 scrap 缓存的关系


再次援引上一篇的结论:




  1. RecyclerView 为了实现表项动画,进行了 2 次布局,第一次预布局,第二次后布局,在源码上表现为 LayoutManager.onLayoutChildren() 被调用 2 次。




  2. 预布局的过程始于 RecyclerView.dispatchLayoutStep1(),终于 RecyclerView.dispatchLayoutStep2()。




在紧接着执行的dispatchLayoutStep2()中,开始了后布局


public class RecyclerView {
void dispatchLayout() {
...
dispatchLayoutStep1();// 预布局
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();// 后布局
...
}

private void dispatchLayoutStep2() {
mState.mInPreLayout = false;// 预布局结束
mLayout.onLayoutChildren(mRecycler, mState); // 第二次 onLayoutChildren()
}
复制代码

布局子表项的老花样要再来一遍,即先 detach 并 scrap 现有表项,然后再填充。


但这次会有一些不同:



  1. 因为 LayoutManager 中现有表项 1、2、3,所以 scrap 完成后,mAttachedScrap中存有表项1、2、3 的 ViewHolder 实例(position 依次为 0、0、1,被移除表项的 position 会被置 0)。

  2. 因为第二次执行onLayoutChildren()已不属于预布局阶段,所以不会加载额外的表项,即LinearLayoutManager.layoutChunk()只会执行 2 次,分别填充位置为 0 和 1 的表项。

  3. mAttachedScrap缓存的 ViewHolder 中,有 2 个 position 为 0,1 个 position 为 1。毫无疑问,填充列表位置 1 的表项时,表项 3 必会命中(因为 position 相等)。但填充列表位置 0 的表项时,是表项 1 还是 表项 2 命中?(它们的 position 都为 0)再回看一遍,缓存命中前的校验逻辑:


public class RecyclerView {
public final class Recycler {
// 从 缓存中获取 ViewHolder 实例
ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();
// 遍历 mAttachedScrap
for (int i = 0; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
if (!holder.wasReturnedFromScrap()
&& holder.getLayoutPosition() == position // 位置相等
&& !holder.isInvalid()
&& (mState.mInPreLayout || !holder.isRemoved()) // 在预布局阶段 或 表项未被移除
) {
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
}
}
}
复制代码

当遍历到mAttachedScrap的表项 2 时,虽然它的位置满足了要求,但校验的最后一个条件把它排除了,因为现在已经不再是预布局阶段,且表项 2 是被移除的。所以列表的位置 0 只能被剩下的表项 1 填充。


分别用表项 1、3 填充了列表的位置 0、1 ,后布局的填充表项也结束了。


此时就形成第二张快照(1,3),和预布局形成的快照(1,2,3)比对之后,就知道表项 2 需要做消失动画,而表项 3 需要做移入动画。那动画具体是怎么实现的?限于篇幅,下次再析。


总结


回到篇中的那个问题:“何必这样折腾?即先 detach 并 缓存表项到 scrap 结构中,然后紧接着又在填充表项时从中取出?”


因为 RecyclerView 要做表项动画,


为了确定动画的种类和起终点,需要比对动画前和动画后的两张“表项快照”,


为了获得两张快照,就得布局两次,分别是预布局和后布局(布局即是往列表中填充表项),


为了让两次布局互不影响,就不得不在每次布局前先清除上一次布局的内容(就好比先清除画布,重新作画),


但是两次布局中所需的某些表项大概率是一摸一样的,若在清除画布时,把表项的所有信息都一并清除,那重新作画时就会花费更多时间(重新创建 ViewHolder 并绑定数据),


RecyclerView 采取了用空间换时间的做法:在清除画布时把表项缓存在 scrap 结构中,以便在填充表项可以命中缓存,以缩短填充表项耗时。



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

RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?(2)

RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?(1)4. mCachedViews 中缓存的表项被删除 表项移出屏幕后,立刻被回收到mCachedViews结构中。若恰巧该表项又被删除了,则表项对应的 ViewHolder 从mCach...
继续阅读 »

RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?(1)


4. mCachedViews 中缓存的表项被删除


表项移出屏幕后,立刻被回收到mCachedViews结构中。若恰巧该表项又被删除了,则表项对应的 ViewHolder 从mCachedViews结构中移除,并添加到缓存池中:


public class RecyclerView {
public final class Recycler {
void recycleCachedViewAt(int cachedViewIndex) {
// 从 mCacheViews 结构中获取指定位置的 ViewHolder 实例
ViewHolder viewHolder = mCachedViews.get(cachedViewIndex);
// 将 ViewHolder 存入缓存池
addViewHolderToRecycledViewPool(viewHolder, true);
// 将 ViewHolder 从 mCacheViews 中移除
mCachedViews.remove(cachedViewIndex);
}

void addViewHolderToRecycledViewPool(@NonNull ViewHolder holder, boolean dispatchRecycled) {
...
getRecycledViewPool().putRecycledView(holder);
}
}
}
复制代码

5. pre-layout 中额外填充的表项在 post-layout 中被移除


pre-layout & post-layout


pre-layoutpost-layoutRecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系有介绍过,援引如下:



RecyclerView 要做表项动画,


为了确定动画的种类和起终点,需要比对动画前和动画后的两张“表项快照”


为了获得两张快照,就得布局两次,分别是 pre-layout 和 post-layout(布局即是往列表中填充表项),


为了让两次布局互不影响,就不得不在每次布局前先清除上一次布局的内容(就好比先清除画布,重新作画),


但是两次布局中所需的某些表项大概率是一摸一样的,若在清除画布时,把表项的所有信息都一并清除,那重新作画时就会花费更多时间(重新创建 ViewHolder 并绑定数据),


RecyclerView 采取了用空间换时间的做法:在清除画布时把表项缓存在 scrap 缓存中,以便在填充表项可以命中缓存,以缩短填充表项耗时。




Gif 的场景中,在 pre-layout 阶段,item 1、item 2、item 3 被填充到列表中,形成一张动画前的表项快照。而 post-layout 将 item 1、item 3 填充到列表中,形成一张动画后的表项快照。


对比这两张快照中的 item 3 的位置就能知道它该从哪里平移到哪里,也知道 item 2 需要做消失动画,当动画结束后,item 2 的 ViewHolder 会被回收到缓存池,回收的调用链和“表项被挤出屏幕”是一样的,都是由动画结束来触发的。


在 pre-layout 阶段填充额外表项


考虑另外一种场景,这次不是移除 item 2,而是更新它,比如把 item 2 更新成 item 2.1,那 pre-layout 还会将 item 3 填充进列表吗?


RecyclerView 动画原理 | 换个姿势看源码(pre-layout) 详细分析了,在 pre-layout 阶段,额外的表项是如何被填充到列表,其中关键源码再拿出来看一下:


public class LinearLayoutManager{
// 向列表中填充表项
int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
...
// 计算剩余空间
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 循环填充表项,直到没有剩余空间
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
// 在列表剩余空间中扣除刚填充表项所消耗的空间
if (!layoutChunkResult.mIgnoreConsumed || layoutState.mScrapList != null || !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
remainingSpace -= layoutChunkResult.mConsumed;
}
...
}
...
}
}
复制代码

直觉上,每填充一个表项都应该将其消耗的空间扣除,但扣除逻辑套在了一个 if 中,即扣除是有条件的。


条件表达式中一共有三个条件,在预布局阶段!state.isPreLayout()必然是 false,layoutState.mScrapList != null也是 false(断点告诉我的),最后一个条件!layoutChunkResult.mIgnoreConsumed起了决定性的作用,它在填充单个表项时被赋值:


public class LinearLayoutManager {
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
// 获取下一个该被填充的表项视图
View view = layoutState.next(recycler);
...// 省略了实施填充的具体逻辑
// 如果表项被移除或被更新 则 mIgnoreConsumed 置为 true
if (params.isItemRemoved() || params.isItemChanged()) {
result.mIgnoreConsumed = true;
}
...
}
}
复制代码

layoutChunkResult被作为参数传入layoutChunk(),并且当填充表项是被删除的或是被更新的,就将layoutChunkResult.mIgnoreConsumed置为 true。表示该表项虽然被填充进了列表但是它占用的空间应该呗忽略。至此可以得出结论:



在预布局阶段,循环填充表项时,若遇到被移除的或是被更新的表项,则会忽略它占用的空间,多余空间被用来加载额外的表项,这些表项在屏幕之外,本来不会被加载。



虽然这结论就是代码的本意,但还是有一点让我不太明白。忽略被移除表项占用的空间容易理解,那为啥更新的表项也一同被忽略?


那是因为,更新表项时,表项的布局可能发生变化(取决于onBindViewHolder()的实现),万一表项布局变长,则会造成其他表项被挤出屏幕,或是表项变短,造成新表项移入屏幕。


记录表项动画信息


RecyclerView 动画原理 | 如何存储并应用动画属性值?中介绍了 RecyclerView 是如何存储动画属性值的,现援引如下:





  1. RecyclerView 将表项动画数据封装了两层,依次是ItemHolderInfoInfoRecord,它们记录了列表预布局和后布局表项的位置信息,即表项矩形区域与列表左上角的相对位置,它还用一个int类型的标志位来记录表项经历了哪些布局阶段,以判断表项应该做的动画类型(出现,消失,保持)。




  2. InfoRecord被集中存放在一个商店类ViewInfoStore中。所有参与动画的表项的ViewHolderInfoRecord都会以键值对的形式存储其中。




  3. RecyclerView 在布局的第三阶段会遍历商店类中所有的键值对,以InfoRecord中的标志位为依据,判断执行哪种动画。表项预布局和后布局的位置信息会一并传递给RecyclerView.ItemAnimator,以触发动画。





在 pre-layout 阶段,存储动画信息的代码如下:


public class RecyclerView {
private void dispatchLayoutStep1() {
...
// 遍历列表中现有表项
int count = mChildHelper.getChildCount();
for (int i = 0; i < count; ++i) {
final ViewHolder holder = getChildViewHolderInt(mChildHelper.getChildAt(i));
// 为表项构建 ItemHolderInfo 实例
final ItemHolderInfo animationInfo = mItemAnimator.recordPreLayoutInformation(mState, holder, ItemAnimator.buildAdapterChangeFlagsForAnimations(holder),holder.getUnmodifiedPayloads());
// 将 ItemHolderInfo 实例存入 ViewInfoStore
mViewInfoStore.addToPreLayout(holder, animationInfo);
}
...
// 预布局
mLayout.onLayoutChildren(mRecycler, mState);
// 预布局后,再次遍历所有孩子(预布局可能填充额外的表项)
for (int i = 0; i < mChildHelper.getChildCount(); ++i) {
final View child = mChildHelper.getChildAt(i);
final ViewHolder viewHolder = getChildViewHolderInt(child);
// 过滤掉带有 FLAG_PRE 标志位的表项
if (!mViewInfoStore.isInPreLayout(viewHolder)) {
// 为额外填充的表项构建 ItemHolderInfo 实例
final ItemHolderInfo animationInfo = mItemAnimator.recordPreLayoutInformation(mState, viewHolder, flags, viewHolder.getUnmodifiedPayloads());
// 将 ItemHolderInfo 实例存入 ViewInfoStore
mViewInfoStore.addToAppearedInPreLayoutHolders(viewHolder, animationInfo);
}
}
...
}
}

class ViewInfoStore {
void addToPreLayout(RecyclerView.ViewHolder holder, RecyclerView.ItemAnimator.ItemHolderInfo info) {
InfoRecord record = mLayoutHolderMap.get(holder);
if (record == null) {
record = InfoRecord.obtain();
mLayoutHolderMap.put(holder, record);
}
record.preInfo = info;
// 添加 FLAG_PRE 标志位
record.flags |= FLAG_PRE;
}

void addToAppearedInPreLayoutHolders(RecyclerView.ViewHolder holder, RecyclerView.ItemAnimator.ItemHolderInfo info) {
InfoRecord record = mLayoutHolderMap.get(holder);
if (record == null) {
record = InfoRecord.obtain();
mLayoutHolderMap.put(holder, record);
}
// 添加 FLAG_APPEAR 标志位
record.flags |= FLAG_APPEAR;
record.preInfo = info;
}
}
复制代码

在 pre-layout 的前后,遍历了两次表项。


对于 Demo 的场景来说,第一次遍历,item 1 和 2 的动画属性被存入 ViewInfoStore 并添加了FLAG_PRE标志位。遍历结束后执行预布局,把屏幕之外的 item 3 也填充到列表中。再紧接着的第二次遍历中,item 3 的动画属性也会被存入 ViewInfoStore 并添加了FLAG_APPEAR标志位,表示该表项是在预布局过程中额外被填充的。


在 post-layout 阶段,为了形成动画后的表项快照,得清空列表,重新填充表项,出于时间性能的考虑,被移除表项的 ViewHolder 缓存到了 scrap 结构中(item 1 2 3的 ViewHodler 实例)。


重新向列表中填充 item 1 和更新后的 item 2,它们的 ViewHolder 实例可以从 scrap 结构中快速获取,不必再执行 onCreateViewHolder()。填充完后,列表的空间已经用完,而 scrap 结构中还剩一个 item 3 的 ViewHolder 实例。它会在 post-layout 阶段被添加新的标志位:


public class LinearLayoutManager {
// 在 dispatchLayoutStep2() 中第二次调用 onLayoutChildren() 进行 post-layout
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// 为动画而进行布局
layoutForPredictiveAnimations(recycler, state, startOffset, endOffset);
}

private void layoutForPredictiveAnimations(RecyclerView.Recycler recycler,RecyclerView.State state, int startOffset,int endOffset) {
final List scrapList = recycler.getScrapList();
final int scrapSize = scrapList.size();
// 遍历 scrap 结构
for (int i = 0; i < scrapSize; i++) {
RecyclerView.ViewHolder scrap = scrapList.get(i);
final int position = scrap.getLayoutPosition();
final int direction = position < firstChildPos != mShouldReverseLayout? LayoutState.LAYOUT_START : LayoutState.LAYOUT_END;
// 计算 scrap 结构中对应表项所占用的空间
if (direction == LayoutState.LAYOUT_START) {
scrapExtraStart += mOrientationHelper.getDecoratedMeasurement(scrap.itemView);
} else {
scrapExtraEnd += mOrientationHelper.getDecoratedMeasurement(scrap.itemView);
}
}
// mLayoutState.mScrapList 被赋值
mLayoutState.mScrapList = scrapList;
// 再次尝试填充表项
if (scrapExtraStart > 0) {
...
fill(recycler, mLayoutState, state, false);
}

if (scrapExtraEnd > 0) {
...
fill(recycler, mLayoutState, state, false);
}
mLayoutState.mScrapList = null;
}

void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result) {
View view = layoutState.next(recycler);
RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) view.getLayoutParams();
// 分支1:把表项填充到列表中
if (layoutState.mScrapList == null) {
if (mShouldReverseLayout == (layoutState.mLayoutDirection == LayoutState.LAYOUT_START)) {
addView(view);
} else {
addView(view, 0);
}
}
// 分支2:把表项动画信息存储到 ViewInfoStore 中
else {
if (mShouldReverseLayout == (layoutState.mLayoutDirection == LayoutState.LAYOUT_START)) {
// 委托给父类 LayoutManger
addDisappearingView(view);
} else {
addDisappearingView(view, 0);
}
}
...
}
}
复制代码

这次填充表项的layoutChunk()因为layoutState.mScrapList不为空,会走不一样的分支,即调用addDisappearingView()


public class RecyclerView {
public abstract static class LayoutManager {
public void addDisappearingView(View child) {
addDisappearingView(child, -1);
}

public void addDisappearingView(View child, int index) {
addViewInt(child, index, true);
}

private void addViewInt(View child, int index, boolean disappearing) {
final ViewHolder holder = getChildViewHolderInt(child);
if (disappearing || holder.isRemoved()) {
// 置 FLAG_DISAPPEARED 标志位
mRecyclerView.mViewInfoStore.addToDisappearedInLayout(holder);
} else {
mRecyclerView.mViewInfoStore.removeFromDisappearedInLayout(holder);
}
...
}
}
}

class ViewInfoStore {
// 置 FLAG_DISAPPEARED 标志位
void addToDisappearedInLayout(RecyclerView.ViewHolder holder) {
InfoRecord record = mLayoutHolderMap.get(holder);
if (record == null) {
record = InfoRecord.obtain();
mLayoutHolderMap.put(holder, record);
}
record.flags |= FLAG_DISAPPEARED;
}

复制代码

至此 item 3 在经历了 pre-layout 和 post-layout 后,它的动画信息被存储在ViewInfoStore中,且添加了两个标志位,分别是FLAG_APPEARFLAG_DISAPPEARED


在布局的第三阶段,会调用ViewInfoStore.process()触发动画:


public class RecyclerView {
private void dispatchLayoutStep3() {
...
// 触发表项执行动画
mViewInfoStore.process(mViewInfoProcessCallback);
...
}
}

class ViewInfoStore {
void process(ProcessCallback callback) {
// 遍历所有参与动画表项的位置信息
for (int index = mLayoutHolderMap.size() - 1; index >= 0; index--) {
// 获取表项 ViewHolder
final RecyclerView.ViewHolder viewHolder = mLayoutHolderMap.keyAt(index);
// 获取与 ViewHolder 对应的动画信息
final InfoRecord record = mLayoutHolderMap.removeAt(index);
// 根据动画信息的标志位确定动画类型以执行对应的 ProcessCallback 回调
if ((record.flags & FLAG_APPEAR_AND_DISAPPEAR) == FLAG_APPEAR_AND_DISAPPEAR) {
callback.unused(viewHolder);
} else if ((record.flags & FLAG_DISAPPEARED) != 0) {
...
}
}
}
}
复制代码

Demo 中的 item 3 会命中第一个 if 条件,因为:


class ViewInfoStore {
static class InfoRecord {
// 在 post-layout 中消失
static final int FLAG_DISAPPEARED = 1;
// 在 pre-layout 中出现
static final int FLAG_APPEAR = 1 << 1;
// 上两者的合体
static final int FLAG_APPEAR_AND_DISAPPEAR = FLAG_APPEAR | FLAG_DISAPPEARED;
}
}
复制代码

回收 item 3 到缓存池的逻辑就在callback.unused(viewHolder)中:


public class RecyclerView {
private final ViewInfoStore.ProcessCallback mViewInfoProcessCallback = new ViewInfoStore.ProcessCallback() {
...
@Override
public void unused(ViewHolder viewHolder) {
// 回收没有用的表项
mLayout.removeAndRecycleView(viewHolder.itemView, mRecycler);
}
};

public abstract static class LayoutManager {
public void removeAndRecycleView(@NonNull View child, @NonNull Recycler recycler) {
removeView(child);
// 委托给 Recycler
recycler.recycleView(child);
}
}

public final class Recycler {
public void recycleView(@NonNull View view) {
// 回收表项到缓存池
recycleViewHolderInternal()
}
}
}
复制代码

至此可以得出结论:



所有在 pre-layout 阶段被额外填充的表项,若最终没能在 post-layout 阶段也填充到列表中,就都会被回到到缓存池。


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

RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?(1)

缓存是 RecyclerView 时间性能优越的重要原因。缓存池是所有缓存中速度最慢的,其中的ViewHodler是脏的,得重新执行onBindViewHolder()。这一篇从源码出发,探究哪些情况下“表项会被回收到缓存池”。 缓存池结构 在分析不同的回收场...
继续阅读 »

缓存是 RecyclerView 时间性能优越的重要原因。缓存池是所有缓存中速度最慢的,其中的ViewHodler是脏的,得重新执行onBindViewHolder()。这一篇从源码出发,探究哪些情况下“表项会被回收到缓存池”。


缓存池结构


在分析不同的回收场景前,先回顾一下“缓存池是什么?”


表项被回收到缓存池,在源码上的表项为 ViewHolder 实例被存储到RecycledViewPool结构中:


public class RecyclerView {
public final class Recycler {
// 回收表项视图
public void recycleView(@NonNull View view) {
ViewHolder holder = getChildViewHolderInt(view);
// 回收表项 ViewHolder
recycleViewHolderInternal(holder);
}
// 回收 ViewHolder
void recycleViewHolderInternal(ViewHolder holder) {
...
// 将 ViewHolder 存入缓存池
addViewHolderToRecycledViewPool(holder, true);
}

// 将 ViewHolder 实例存储到 RecycledViewPool 结构中
void addViewHolderToRecycledViewPool(@NonNull ViewHolder holder, boolean dispatchRecycled) {
...
getRecycledViewPool().putRecycledView(holder);
}
// 获取 RecycledViewPool 实例
RecycledViewPool getRecycledViewPool() {
if (mRecyclerPool == null) {
mRecyclerPool = new RecycledViewPool();
}
return mRecyclerPool;
}
}
// 缓存池
public static class RecycledViewPool {
// 单类型缓存列表
static class ScrapData {
final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
}
// 多类型缓存列表构成的缓存池(以 int 为键)
SparseArray<ScrapData> mScrap = new SparseArray<>();
public void putRecycledView(ViewHolder scrap) {
// 获取 ViewHolder 类型
final int viewType = scrap.getItemViewType();
// 获取指定类型的 ViewHolder 缓存列表
final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
...
// ViewHolder 实例存入缓存列表
scrapHeap.add(scrap);
}
}
}
复制代码

RecycledViewPool用一个SparseArray将不同类型的 ViewHolder 实例缓存在内存,每种类型对应一个列表。当有相同类型的表项插入列表时,不用重新创建 ViewHolder 实例(执行 onCreateViewHolder()),从缓存池中获取即可。


关于缓存池的详细解析可以点击RecyclerView 缓存机制 | 回收到哪去?


1. 表项主动移出屏幕


这种回收表项的场景是最常见的。效果图如下:



为啥要等 item 3 滚出屏幕后,item 1 才刚刚被回收,而 item 4 滚出屏幕后,item 2 立马被回收了?


这是因为mCachedViews的存在,它是默认大小为 2 的列表。用于缓存移出屏幕表项的 ViewHolder。


所有移出的表项都会依次被缓存至其中,当mCachedViews满时,按照先进先出原则,将最先存入的 ViewHolder 实例移除并转存至RecycledViewPool,即缓存池中。


所以 item 1 和 2 移出屏幕时,正好填满mCachedViews,当 item 3 移出屏幕时,item 1 就被挤出并存入缓存池。更详细的源码跟踪分析可以点击RecyclerView 缓存机制 | 回收到哪去?


那 RecyclerView 在滚动中是如何判断哪些表项应该被回收?


上一篇文章中详细分析了列表滚动时,表项是如何被回收的,现援引结论和图示如下。





  1. RecyclerView 在滚动发生之前,会根据预计滚动位移大小来决定需要向列表中填充多少新的表项。在填充表项的同时,也会回收表项,回收的依据是 limit 隐形线




  2. limit 隐形线 是 RecyclerView 在滚动发生之前根据滚动位移计算出来的一条线,它是决定哪些表项该被回收的重要依据。它可以理解为:隐形线当前所在位置,在滚动完成后会和列表顶部重叠。




  3. limit 隐形线 的初始值 = 列表当前可见表项的底部到列表底部的距离,即列表在不填充新表项时,可以滑动的最大距离。每一个新填充表项消耗的像素值都会被追加到 limit 值之上,即limit 隐形线会随着新表项的填充而不断地下移。




  4. 触发回收逻辑时,会遍历当前所有表项,若某表项的底部位于limit 隐形线下方,则该表项上方的所有表项都会被回收。





下图形象地描述了 limit 隐形线(图中红色虚线):


回收逻辑落实在源码上,就是如下(0-5)的调用链:


public class RecyclerView {
public final class Recycler {
// 5
public void recycleView(View view) {...}
}

public abstract static class LayoutManager {
public void removeAndRecycleViewAt(int index, @NonNull Recycler recycler) {
final View view = getChildAt(index);
removeViewAt(index);
// 4
recycler.recycleView(view);
}
}
}

public class LinearLayoutManager {
private void recycleChildren(RecyclerView.Recycler recycler, int startIndex, int endIndex) {
// 3:回收索引值为 endIndex -1 到 startIndex 的表项
for (int i = endIndex - 1; i >= startIndex; i--) {
removeAndRecycleViewAt(i, recycler);
}
}

private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset,int noRecycleSpace) {
...
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (mOrientationHelper.getDecoratedEnd(child) > limit|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
// 2
recycleChildren(recycler, 0, i);
}
}
}

private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
// 1
recycleViewsFromStart(recycler, scrollingOffset, noRecycleSpace);
}

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
...
// 循环填充表项
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
// 0:回收表项
recycleByLayoutState(recycler, layoutState);
}
...
}
}
}
复制代码

每填充一个表项都会遍历已加载的所有表项,以检测其中是否有可以回收的。


若对结论的源码分析过程感兴趣,可以点击RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?


2. 表项被挤出屏幕


当列表中有表项插入,把现有表项挤出屏幕时,也会发生表项回收。效果图如下:


这种场景下 item 2 会被回收,当表项动画完成后,就会触发表项回收逻辑:


// RecyclerView 默认表项动画器
public class DefaultItemAnimator extends SimpleItemAnimator {
// 启动表项位移动画
void animateMoveImpl(final RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) {
final ViewPropertyAnimator animation = view.animate();
animation.setDuration(getMoveDuration()).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animator) {
// 往上分发动画结束事件
dispatchMoveFinished(holder);
...
}
}).start();
}
}

public abstract class SimpleItemAnimator extends RecyclerView.ItemAnimator {
public final void dispatchMoveFinished(RecyclerView.ViewHolder item) {
// 继续往上分发动画结束事件
dispatchAnimationFinished(item);
}
}

public class RecyclerView {
public abstract static class ItemAnimator {
private ItemAnimatorListener mListener = null;
public final void dispatchAnimationFinished(ViewHolder viewHolder) {
// 将动画结束事件分发给监听器
if (mListener != null) { mListener.onAnimationFinished(viewHolder); }
}
}

private class ItemAnimatorRestoreListener implements ItemAnimator.ItemAnimatorListener {
@Override
public void onAnimationFinished(ViewHolder item) {
// 设置 ViewHolder 为可回收的
item.setIsRecyclable(true);
// 回收表项
if (!removeAnimatingView(item.itemView) && item.isTmpDetached()) {
removeDetachedView(item.itemView, false);
}
}
}

boolean removeAnimatingView(View view) {
startInterceptRequestLayout();
final boolean removed = mChildHelper.removeViewIfHidden(view);
// 当表项做完位移动画后确实移出了屏幕
if (removed) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
mRecycler.unscrapView(viewHolder);
// 回收 ViewHolder
mRecycler.recycleViewHolderInternal(viewHolder);
}
...
return removed;
}
}
复制代码

RecyclerView 的表项动画器将移动表项动画的结束事件层层传递,最终传递到了 RecyclerView 内部的监听器,由监听器通知 Recycler 触发表项回收动作。


3. 高速缓存命中的 ViewHolder 变脏


变脏的意思是表项需要重绘,即调用onBindViewHolder()重新为表项绑定数据。


RecyclerView 中有四级缓存,它会优先去高速缓存中找 ViewHolder 实例。缓存池是其中速度最慢的,因为从中取出的 ViewHolder 需要重新执行onBindViewHolder()scrapview cache的速度都比它快,但命中后需要进行额外的校验(关于四级缓存的详解可以点击这里):


public class RecyclerView
public final class Recycler {
// RecyclerView 获取 ViewHolder 的入口
ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
// 从 scrap 或 view cache 中获取 ViewHolder 实例
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
// 若缓存命中
if (holder != null) {
// 校验 ViewHolder
if (!validateViewHolderForOffsetPosition(holder)) {
// 校验失败
if (!dryRun) {// dryRun 始终为 false
....
// 回收命中的 ViewHolder (丢到缓存池)
recycleViewHolderInternal(holder);
}
// 标记从 scrap 或 view cache 中获取缓存失败
// 会触发从其他缓存继续获取 ViewHolder实例
holder = null;
} else {
// 标记校验成功
fromScrapOrHiddenOrCache = true;
}
}
....
}
}
}
复制代码

从 scrap 或 view cache 命中的 ViewHolder 会从三个方面被校验:



  1. 表项是否被移除

  2. 表项 viewType 是否相同

  3. 表项 id 是否相同


public class RecyclerView{
public final class Recycler {
// 校验 ViewHolder 合法性
boolean validateViewHolderForOffsetPosition(ViewHolder holder) {
// 如果表项已被移除
if (holder.isRemoved()) {
// 是否在 preLayout 阶段
return mState.isPreLayout();
}

if (!mState.isPreLayout()) {
// 检查从缓存中获取的 ViewHolder 是否和 Adapter 对应位置的 ViewHolder 有相同的 viewType
final int type = mAdapter.getItemViewType(holder.mPosition);
if (type != holder.getItemViewType()) {
return false;
}
}
// 检查从缓存中获取的 ViewHolder 是否和 Adapter 对应位置的 ViewHolder 有相同的 id
if (mAdapter.hasStableIds()) {
return holder.getItemId() == mAdapter.getItemId(holder.mPosition);
}
return true;
}
}
}
复制代码

只有和指定位置表项具有相同的 viewType 或相同的 id 时,scrapview cache中命中的缓存才会被使用。否则即使命中也会视为无效ViewHolder被丢到缓存池中。


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

RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系

RecyclerView 缓存之一的 scrap 结构中缓存的是什么?为什么需要 scrap 缓存?pre-layout 及 post-layout 过程中 scrap 缓存内容会如何变化?这一篇继续通过 走查源码 + 断点调试的方式解答这些疑问。引子 这一篇...
继续阅读 »

RecyclerView 缓存之一的 scrap 结构中缓存的是什么?为什么需要 scrap 缓存?pre-layout 及 post-layout 过程中 scrap 缓存内容会如何变化?这一篇继续通过 走查源码 + 断点调试的方式解答这些疑问。

引子


这一篇源码分析还是基于下面这个 Demo 场景:



列表中有两个表项(1、2),删除 2,此时 3 会从屏幕底部平滑地移入并占据原来 2 的位置。


为了实现该效果,RecyclerView的策略是:为动画前的表项先执行一次pre-layout,将不可见的表项 3 也加载到布局中,形成一张布局快照(1、2、3)。再为动画后的表项执行一次post-layout,同样形成一张布局快照(1、3)。比对两张快照中表项 3 的位置,就知道它该如何做动画了。


在此援引上一篇已经得出的结论:





  1. RecyclerView为了实现表项动画,进行了 2 次布局(预布局 + 后布局),在源码上表现为LayoutManager.onLayoutChildren()被调用 2 次。




  2. 预布局的过程始于RecyclerView.dispatchLayoutStep1(),终于RecyclerView.dispatchLayoutStep2()




  3. 在预布局阶段,循环填充表项时,若遇到被移除的表项,则会忽略它占用的空间,多余空间被用来加载额外的表项,这些表项在屏幕之外,本来不会被加载。





其中第三点表现在源码上,是这样的:


public class LinearLayoutManager {
// 布局表项
public void onLayoutChildren() {
// 不断填充表项
fill() {
while(列表有剩余空间){
// 填充单个表项
layoutChunk(){
// 让表项成为子视图
addView(view)
}
if (表项没有被移除) {
剩余空间 -= 表项占用空间
}
...
}
}
}
}
复制代码

这是RecyclerView填充表项的伪码。以 Demo 为例,预布局阶段,第一次执行onLayoutChildren(),因表项 2 被删除,所以它占用的空间不会被扣除,导致while循环多执行一次,这样表项 3 就被填充进列表。


后布局阶段,会再次执行onLayoutChildren(),再把表项 1、3 填入列表。那此时列表中不是得有两个表项 1,两个表项 3,和一个表项 2 吗?


这显然是不可能的,用上一篇介绍的断点调试,运行 Demo,把断点断在addView(),发现后布局阶段再次调用该方法时,RecyclerView的子控件个数为 0。


先清空表项再填充


难道每次布局之前都会删掉现有布局中所有的表项?


fill()开始,往上走查代码,果然发现了一个线索:


public class LinearLayoutManager {
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// detach 并 scrap 表项
detachAndScrapAttachedViews(recycler);
...
// 填充表项
fill()
}
复制代码

在填充表项之前,有一个 detach 操作:


public class RecyclerView {
public abstract static class LayoutManager {
public void detachAndScrapAttachedViews(@NonNull Recycler recycler) {
// 遍历所有子表项
final int childCount = getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
final View v = getChildAt(i);
// 回收子表项
scrapOrRecycleView(recycler, i, v);
}
}
}
}
复制代码

果不其然,在填充表项之前会遍历所有子表项,并逐个回收它们:


public class RecyclerView {
public abstract static class LayoutManager {
// 回收表项
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
if (viewHolder.isInvalid() && !viewHolder.isRemoved()&& !mRecyclerView.mAdapter.hasStableIds()) {
removeViewAt(index);
recycler.recycleViewHolderInternal(viewHolder);
} else {
// detach 表项
detachViewAt(index);
// scrap 表项
recycler.scrapView(view);
...
}
}
}
}
复制代码

回收表项时,根据viewHolder的不同状态执行不同分支。硬看源码很难快速判断会走哪个分支,果断运行 Demo,断点调试一把。在上述场景中,所有表项都走了第二个分支,即在布局表项之前,对现有表项做了两个关键的操作:



  1. detach 表项detachViewAt(index)

  2. scrap 表项recycler.scrapView(view)


detach 表项


先看看 detach 表项是个什么操作:


public class RecyclerView {
public abstract static class LayoutManager {
ChildHelper mChildHelper;
// detach 指定索引的表项
public void detachViewAt(int index) {
detachViewInternal(index, getChildAt(index));
}

// detach 指定索引的表项
private void detachViewInternal(int index, @NonNull View view) {
...
// 将 detach 委托给 ChildHelper
mChildHelper.detachViewFromParent(index);
}
}
}

// RecyclerView 子表项管理类
class ChildHelper {
// 将指定位置的表项从 RecyclerView detach
void detachViewFromParent(int index) {
final int offset = getOffset(index);
mBucket.remove(offset);
// 最终实现 detach 操作的回调
mCallback.detachViewFromParent(offset);
}
}
复制代码

LayoutManager会将 detach 任务委托给ChildHelperChildHelper再执行detachViewFromParent()回调,它在初始化ChildHelper时被实现:


public class RecyclerView {
// 初始化 ChildHelper
private void initChildrenHelper() {
// 构建 ChildHelper 实例
mChildHelper = new ChildHelper(new ChildHelper.Callback() {
@Override
public void detachViewFromParent(int offset) {
final View view = getChildAt(offset);
...
// 调用 ViewGroup.detachViewFromParent()
RecyclerView.this.detachViewFromParent(offset);
}
...
}
}
}
复制代码

RecyclerView detach 表项的最后一步调用了ViewGroup.detachViewFromParent()


public abstract class ViewGroup {
// detach 子控件
protected void detachViewFromParent(int index) {
removeFromArray(index);
}

// 删除子控件的最后一步
private void removeFromArray(int index) {
final View[] children = mChildren;
// 将子控件持有的父控件引用置空
if (!(mTransitioningViews != null && mTransitioningViews.contains(children[index]))) {
children[index].mParent = null;
}
final int count = mChildrenCount;
// 将父控件持有的子控件引用置空
if (index == count - 1) {
children[--mChildrenCount] = null;
} else if (index >= 0 && index < count) {
System.arraycopy(children, index + 1, children, index, count - index - 1);
children[--mChildrenCount] = null;
}
...
}
}
复制代码

ViewGroup.removeFromArray()是容器控件移除子控件的最后一步(ViewGroup.removeView()也会调用这个方法)。


至此可以得出结论:



在每次向RecyclerView填充表项之前都会先清空现存表项。



目前看来,detach viewremove view差不多,它们都会将子控件从父控件的孩子列表中删除,唯一的区别是detach更轻量,不会触发重绘。而且detach是短暂的,被detach的 View 最终必须被彻底 remove 或者重新 attach。(下面就会马上把他们重新 attach)


scrap 表项


scrap 表项的意思是回收表项并将其存入mAttachedScrap列表,它是回收器Recycler中的成员变量:


public class RecyclerView {
public final class Recycler {
// scrap 列表
final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();
}
}
复制代码

mAttachedScrap是一个 ArrayList 结构,用于存储ViewHolder实例。


RecyclerView 填充表项前,除了会 detach 所有可见表项外,还会同时 scrap 它们:


public class RecyclerView {
public abstract static class LayoutManager {
// 回收表项
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
...
// detach 表项
detachViewAt(index);
// scrap 表项
recycler.scrapView(view);
...
}
}
}
复制代码

scrapView()是回收器Recycler的方法,正是这个方法将表项回收到了mAttachedScrap列表中:


public class RecyclerView {
public final class Recycler {
void scrapView(View view) {
final ViewHolder holder = getChildViewHolderInt(view);
// 表项不需要更新,或被移除,或者表项索引无效时,将被会收到 mAttachedScrap
if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
|| !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
holder.setScrapContainer(this, false);
// 将表项回收到 mAttachedScrap 结构中
mAttachedScrap.add(holder);
} else {
// 只有当表项没有被移除且有效且需要更新时才会被回收到 mChangedScrap
if (mChangedScrap == null) {
mChangedScrap = new ArrayList<ViewHolder>();
}
holder.setScrapContainer(this, true);
mChangedScrap.add(holder);
}
}
}
}
复制代码

scrapView()中根据ViewHolder状态将其会收到不同的结构中,同样地,硬看源码很难快速判断执行了那个分支,继续断点调试,Demo 场景中所有的表项都会被回收到mAttachedScrap结构中。(关于 mAttachedScrap 和 mChangedScrap 的区别会在后续文章分析)


分析至此,进一步细化刚才得到的结论:



在每次向RecyclerView填充表项之前都会先清空 LayoutManager 中现存表项,将它们 detach 并同时缓存入 mAttachedScrap列表中。



将结论应用在 Demo 的场景,即是:RecyclerView 在预布局阶段准备向列表中填充表项前,会清空现有的表项 1、2,把它们都 detach 并回收对应的 ViewHolder 到 mAttachedScrap列表中。


从缓存拿填充表项


预布局与 scrap 缓存的关系


缓存定是为了复用,啥时候用呢?紧接着的“填充表项”中就立马会用到:


public class LinearLayoutManager {
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// detach 表项
detachAndScrapAttachedViews(recycler);
...
// 填充表项
fill()
}

int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
// 计算剩余空间
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 不停的往列表中填充表项,直到没有剩余空间
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
}
}

// 填充单个表项
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
// 获取下一个被填充的视图
View view = layoutState.next(recycler);
...
// 填充视图
addView(view);
...
}
}
复制代码

填充表项时,通过layoutState.next(recycler)获取下一个该被填充的表项视图:


public class LinearLayoutManager {
static class LayoutState {
View next(RecyclerView.Recycler recycler) {
...
// 委托 Recycler 获取下一个该填充的表项
final View view = recycler.getViewForPosition(mCurrentPosition);
...
return view;
}
}
}

public class RecyclerView {
public final class Recycler {
public View getViewForPosition(int position) {
return getViewForPosition(position, false);
}
}

View getViewForPosition(int position, boolean dryRun) {
// 调用链最终传递到 tryGetViewHolderForPositionByDeadline()
return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView;
}
}
复制代码

沿着调用链一直往下,最终走到了Recycler.tryGetViewHolderForPositionByDeadline(),在RecyclerView缓存机制(咋复用?)中对其做过详细介绍,援引结论如下:



  1. 在 RecyclerView 中,并不是每次绘制表项,都会重新创建 ViewHolder 对象,也不是每次都会重新绑定 ViewHolder 数据。

  2. RecyclerView 填充表项前,会通过Recycler获取表项的 ViewHolder 实例。

  3. RecyclertryGetViewHolderForPositionByDeadline()方法中,前后尝试 5 次,从不同缓存中获取可复用的 ViewHolder 实例,其中第一优先级的缓存即是scrap结构。

  4. scrap缓存获取的表项不需要重新构建,也不需要重新绑定数据。


从 scrap 结构获取 ViewHolder 的源码如下:


public class RecyclerView {
public final class Recycler {
ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
ViewHolder holder = null;
...
// 从 scrap 结构中获取指定 position 的 ViewHolder 实例
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
...
}
...
}

ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();
// 遍历 mAttachedScrap 列表中所有的 ViewHolder 实例
for (int i = 0; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
// 校验 ViewHolder 是否满足条件,若满足,则缓存命中
if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
&& !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
...
}
}
}
复制代码

mAttachedScrap列表中获取的ViewHolder实例后,得进行校验。校验的内容很多,其中最重要的的是:ViewHolder索引值和当前填充表项的位置值是否相等,即:


scrap 结构缓存的 ViewHolder 实例,只能复用于和它回收时相同位置的表项。


也就是说,若当前列表正准备填充 Demo 中的表项 2(position == 1),即使 scrap 结构中有相同类型 ViewHolder,只要viewHolder.getLayoutPosition()的值不为 1,缓存不会命中。


分析至此,可以把上面得到的结论进一步拓展:



在每次向RecyclerView填充表项之前都会先清空 LayoutManager 中现存表项,将它们 detach 并同时缓存入 mAttachedScrap列表中。在紧接着的填充表项阶段,就立马从mAttachedScrap中取出刚被 detach 的表项并重新 attach 它们。



(弱弱地问一句,这样折腾意义何在?可能接着往下看就知道了。。)


将结论应用在 Demo 的场景,即是:RecyclerView 在预布局阶段准备向列表中填充表项前,会清空现有的表项 1、2,把它们都 detach 并回收对应的 ViewHolder 到 mAttachedScrap 列表中。然后又在填充表项阶段从 mAttachedScrap 中重新获取了表项 1、2 并填入列表。


上一篇的结论说“Demo 场景中,预布局阶段还会额外加载列表第三个位置的表项 3”,但mAttachedScrap只缓存了表项 1、2。所以在填充表项 3 时,scrap 缓存未命中。不仅如此,因表项 3 是从未被加载过的表项,遂所有的缓存都不会命中,最后只能沦落到重新构建表项并绑定数据


public class RecyclerView {
public final class Recycler {
ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
if (holder == null) {
...
// 构建 ViewHolder
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
// 获取表项偏移的位置
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
// 绑定 ViewHolder 数据
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
}
}
}
复制代码

沿着上述代码的调用链往下走查,就能找到熟悉的onCreateViewHolder()onBindViewHolder()


在绑定 ViewHolder 数据之前,先调用了mAdapterHelper.findPositionOffset(position)获取了“偏移位置”。断点调试告诉我,此时它会返回 1,即表项 2 被移除后,表项 3 在列表中的位置。


AdapterHelper将所有对表项的操作都抽象成UpdateOp并保存在列表中,当获取表项 3 偏移位置时,它发现有一个表项 2 的删除操作,所以表项 3 的位置会 -1。(有关 AdapterHelper 的内容就不展开了~)


至此,预布局阶段的填充表项结束了,LayoutManager 中现有表项 1、2、3,形成了第一张快照(1,2,3)。


后布局与 scrap 缓存的关系


再次援引上一篇的结论:




  1. RecyclerView 为了实现表项动画,进行了 2 次布局,第一次预布局,第二次后布局,在源码上表现为 LayoutManager.onLayoutChildren() 被调用 2 次。




  2. 预布局的过程始于 RecyclerView.dispatchLayoutStep1(),终于 RecyclerView.dispatchLayoutStep2()。




在紧接着执行的dispatchLayoutStep2()中,开始了后布局


public class RecyclerView {
void dispatchLayout() {
...
dispatchLayoutStep1();// 预布局
mLayout.setExactMeasureSpecsFrom(this);
dispatchLayoutStep2();// 后布局
...
}

private void dispatchLayoutStep2() {
mState.mInPreLayout = false;// 预布局结束
mLayout.onLayoutChildren(mRecycler, mState); // 第二次 onLayoutChildren()
}
复制代码

布局子表项的老花样要再来一遍,即先 detach 并 scrap 现有表项,然后再填充。


但这次会有一些不同:



  1. 因为 LayoutManager 中现有表项 1、2、3,所以 scrap 完成后,mAttachedScrap中存有表项1、2、3 的 ViewHolder 实例(position 依次为 0、0、1,被移除表项的 position 会被置 0)。

  2. 因为第二次执行onLayoutChildren()已不属于预布局阶段,所以不会加载额外的表项,即LinearLayoutManager.layoutChunk()只会执行 2 次,分别填充位置为 0 和 1 的表项。

  3. mAttachedScrap缓存的 ViewHolder 中,有 2 个 position 为 0,1 个 position 为 1。毫无疑问,填充列表位置 1 的表项时,表项 3 必会命中(因为 position 相等)。但填充列表位置 0 的表项时,是表项 1 还是 表项 2 命中?(它们的 position 都为 0)再回看一遍,缓存命中前的校验逻辑:


public class RecyclerView {
public final class Recycler {
// 从 缓存中获取 ViewHolder 实例
ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();
// 遍历 mAttachedScrap
for (int i = 0; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
if (!holder.wasReturnedFromScrap()
&& holder.getLayoutPosition() == position // 位置相等
&& !holder.isInvalid()
&& (mState.mInPreLayout || !holder.isRemoved()) // 在预布局阶段 或 表项未被移除
) {
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
}
}
}
复制代码

当遍历到mAttachedScrap的表项 2 时,虽然它的位置满足了要求,但校验的最后一个条件把它排除了,因为现在已经不再是预布局阶段,且表项 2 是被移除的。所以列表的位置 0 只能被剩下的表项 1 填充。


分别用表项 1、3 填充了列表的位置 0、1 ,后布局的填充表项也结束了。


此时就形成第二张快照(1,3),和预布局形成的快照(1,2,3)比对之后,就知道表项 2 需要做消失动画,而表项 3 需要做移入动画。那动画具体是怎么实现的?限于篇幅,下次再析。


总结


回到篇中的那个问题:“何必这样折腾?即先 detach 并 缓存表项到 scrap 结构中,然后紧接着又在填充表项时从中取出?”


因为 RecyclerView 要做表项动画,


为了确定动画的种类和起终点,需要比对动画前和动画后的两张“表项快照”,


为了获得两张快照,就得布局两次,分别是预布局和后布局(布局即是往列表中填充表项),


为了让两次布局互不影响,就不得不在每次布局前先清除上一次布局的内容(就好比先清除画布,重新作画),


但是两次布局中所需的某些表项大概率是一摸一样的,若在清除画布时,把表项的所有信息都一并清除,那重新作画时就会花费更多时间(重新创建 ViewHolder 并绑定数据),


RecyclerView 采取了用空间换时间的做法:在清除画布时把表项缓存在 scrap 结构中,以便在填充表项可以命中缓存,以缩短填充表项耗时。



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

RecyclerView缓存机制 | scrap view 的生命周期

RecyclerView 内存性能优越,这得益于它独特的缓存机制。第一篇中遗留的一个问题还没有解决:复用表项时优先级最高的scrap view是用来干嘛的?这篇文章试着通过阅读源码来解答这个问题。scrap view对应的存储结构是final ArrayLis...
继续阅读 »

RecyclerView 内存性能优越,这得益于它独特的缓存机制。第一篇中遗留的一个问题还没有解决:复用表项时优先级最高的scrap view是用来干嘛的?这篇文章试着通过阅读源码来解答这个问题。

scrap view对应的存储结构是final ArrayList<ViewHolder> mAttachedScrap = new ArrayList<>();。理解成员变量用途的最好办法是 “搜索它在什么时候被访问” 。对于列表结构来说就相当于 1. 在什么时候往列表添加内容? 2. 在什么时候清空列表内容?


添加内容


全局搜索mAttachedScrap被访问的地方,其中只有一处调用了mAttachedScrap.add():


public final class Recycler {
// 回收 ViewHolder 到 scrap 集合(mAttachedScrap或mChangedScrap),
void scrapView(View view) {
final ViewHolder holder = getChildViewHolderInt(view);
if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)
|| !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) {
holder.setScrapContainer(this, false);
//添加到 mAttachedScrap 集合中
mAttachedScrap.add(holder);
} else {
if (mChangedScrap == null) {
mChangedScrap = new ArrayList<ViewHolder>();
}
holder.setScrapContainer(this, true);
//添加到 mChangedScrap 集合中
mChangedScrap.add(holder);
}
}
}
复制代码

沿着调用链继续往上:


public abstract static class LayoutManager {
private void scrapOrRecycleView(Recycler recycler, int index, View view) {
final ViewHolder viewHolder = getChildViewHolderInt(view);
// 删除表项并入回收池
if (viewHolder.isInvalid() && !viewHolder.isRemoved()
&& !mRecyclerView.mAdapter.hasStableIds()) {
removeViewAt(index);
recycler.recycleViewHolderInternal(viewHolder);
}
// detach 表项并入 scrap 集合
else {
detachViewAt(index);
recycler.scrapView(view);
mRecyclerView.mViewInfoStore.onViewDetached(viewHolder);
}
}
}
复制代码

根据viewHolder的不同状态,要么将其添加到mAttachedScrap集合,要么将其存入回收池。其中recycleViewHolderInternal()RecyclerView缓存机制(回收去哪?)分析过。
沿着调用链继续向上:


public abstract static class LayoutManager {
// 暂时将当可见表项进行分离并回收
public void detachAndScrapAttachedViews(Recycler recycler) {
final int childCount = getChildCount();
// 遍历所有可见表项并回收他们
for (int i = childCount - 1; i >= 0; i--) {
final View v = getChildAt(i);
scrapOrRecycleView(recycler, i, v);
}
}

// 布局所有子表项
public void onLayoutChildren(Recycler recycler, State state) {
...
// 在填充表项之前回收所有表项
detachAndScrapAttachedViews(recycler);
...
// 填充表项
fill(recycler, mLayoutState, state, false);
...
}
}

public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {
// RecyclerView布局的第二步
private void dispatchLayoutStep2() {
...
mLayout.onLayoutChildren(mRecycler, mState);
...
}
}
复制代码


  • 在将表项一个个填充到列表之前会先将其先回收到mAttachedScrap中,回收数据的来源是LayoutManager的孩子,而LayoutManager的孩子都是屏幕上可见的或即将可见的表项。

  • 注释中“暂时将当当前可见表项进行分离并回收”,既然是“暂时回收”,那待会必然会发生“复用”。复用逻辑可移步RecyclerView缓存机制(咋复用?)

  • 至此可以得出结论:mAttachedScrap用于屏幕中可见表项的回收和复用


清空内容


全局搜索mAttachedScrap被访问的地方,其中只有一处调用了mAttachedScrap.clear():


public class RecyclerView {
public final class Recycler {
// 清空 scrap 结构
void clearScrap() {
mAttachedScrap.clear();
if (mChangedScrap != null) {
mChangedScrap.clear();
}
}
}
}
复制代码

Recycler.clearScrap()清空了 scrap 列表。而它会在LayoutManager.removeAndRecycleScrapInt()中被调用:


public abstract static class LayoutManager {
// 回收所有 scrapped view
void removeAndRecycleScrapInt(Recycler recycler) {
final int scrapCount = recycler.getScrapCount();
// Loop backward, recycler might be changed by removeDetachedView()
// 遍历搜有 scrap view 重置 ViewHolder 状态,并将其回收到缓存池
for (int i = scrapCount - 1; i >= 0; i--) {
final View scrap = recycler.getScrapViewAt(i);
final ViewHolder vh = getChildViewHolderInt(scrap);
if (vh.shouldIgnore()) {
continue;
}
vh.setIsRecyclable(false);
if (vh.isTmpDetached()) {
mRecyclerView.removeDetachedView(scrap, false);
}
if (mRecyclerView.mItemAnimator != null) {
mRecyclerView.mItemAnimator.endAnimation(vh);
}
vh.setIsRecyclable(true);
recycler.quickRecycleScrapView(scrap);
}
// 清空 scrap view 集合
recycler.clearScrap();
if (scrapCount > 0) {
mRecyclerView.invalidate();
}
}
}
复制代码

沿着调用链向上:


public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {
// RecyclerView布局的最后一步
private void dispatchLayoutStep3() {
...
mLayout.removeAndRecycleScrapInt(mRecycler);
...
}
复制代码

至此可以得出结论:mAttachedScrap生命周期起始于RecyclerView布局开始,终止于RecyclerView布局结束。


分析完了 scrap 结构的生命周期和作用后,不免产生新的疑问:什么场景下需要回收并复用屏幕中可见的表项?限于篇幅原因,在读原码长知识 | RecyclerView 预布局 ,后布局与 scrap 缓存的关系中做了详细分析。


总结


经过四篇文章的分析,RecyclerVeiw的四级缓存都分析完了,总结如下:




  1. Recycler有4个层次用于缓存ViewHolder对象,优先级从高到底依次为ArrayList<ViewHolder> mAttachedScrapArrayList<ViewHolder> mCachedViewsViewCacheExtension mViewCacheExtensionRecycledViewPool mRecyclerPool。如果四层缓存都未命中,则重新创建并绑定ViewHolder对象




  2. 缓存性能:



























    缓存重新创建ViewHolder重新绑定数据
    mAttachedScrapfalsefalse
    mCachedViewsfalsefalse
    mRecyclerPoolfalsetrue



  3. 缓存容量:



    • mAttachedScrap:没有大小限制,但最多包含屏幕可见表项。

    • mCachedViews:默认大小限制为2,放不下时,按照先进先出原则将最先进入的ViewHolder存入回收池以腾出空间。

    • mRecyclerPool:对ViewHolderviewType分类存储(通过SparseArray),同类ViewHolder存储在默认大小为5的ArrayList中。




  4. 缓存用途:



    • mAttachedScrap:用于布局过程中屏幕可见表项的回收和复用。

    • mCachedViews:用于移出屏幕表项的回收和复用,且只能用于指定位置的表项,有点像“回收池预备队列”,即总是先回收到mCachedViews,当它放不下的时候,按照先进先出原则将最先进入的ViewHolder存入回收池。

    • mRecyclerPool:用于移出屏幕表项的回收和复用,且只能用于指定viewType的表项




  5. 缓存结构:



    • mAttachedScrapArrayList<ViewHolder>

    • mCachedViewsArrayList<ViewHolder>

    • mRecyclerPool:对ViewHolderviewType分类存储在SparseArray<ScrapData>中,同类ViewHolder存储在ScrapData中的ArrayList





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

RecyclerView 缓存机制 | 回收到哪去?

RecyclerView 内存性能优越,这得益于它独特的缓存机制,上两篇已经分析了 RecyclerView 缓存机制会回收哪些表项,及如何从缓存中获取表项。本篇在此基础上继续走读源码,分析“回收的表项是以怎样的形式存放”。回收入口 上一篇以列表滑动事件为起点...
继续阅读 »

RecyclerView 内存性能优越,这得益于它独特的缓存机制,上两篇已经分析了 RecyclerView 缓存机制会回收哪些表项,及如何从缓存中获取表项。本篇在此基础上继续走读源码,分析“回收的表项是以怎样的形式存放”。

回收入口


上一篇以列表滑动事件为起点沿着调用链一直往下寻找,验证了“滑出屏幕的表项”会被回收。那它们被回收去哪里了?沿着上一篇的调用链继续往下探究:


public class LinearLayoutManager {
...
// 回收滚出屏幕的表项
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int dt) {
final int limit = dt;
final int childCount = getChildCount();
//遍历LinearLayoutManager的孩子找出其中应该被回收的
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
//直到表项底部纵坐标大于 limit 隐形线,回收该表项以上的所有表项
if (mOrientationHelper.getDecoratedEnd(child) > limit
|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
//回收索引为 0 到 i-1 的表项
recycleChildren(recycler, 0, i);
return;
}
}
}
...
}
复制代码

limit 隐形线 是“列表滚动后,哪些表项被该被回收”的依据,当列表向下滚动时,所有位于这条线上方的表项都会被回收。关于 limit隐形线 的详细解释可以点击这里


recycleViewsFromStart()通过遍历找到滑出屏幕的表项,然后调用了recycleChildren()回收他们:


public class LinearLayoutManager {
// 回收子表项
private void recycleChildren(RecyclerView.Recycler recycler, int startIndex, int endIndex) {
if (startIndex == endIndex) {
return;
}
if (endIndex > startIndex) {
for (int i = endIndex - 1; i >= startIndex; i--) {
removeAndRecycleViewAt(i, recycler);
}
} else {
for (int i = startIndex; i > endIndex; i--) {
removeAndRecycleViewAt(i, recycler);
}
}
}
}
复制代码

最终调用了父类LayoutManager.removeAndRecycleViewAt()


public abstract static class LayoutManager {
public void removeAndRecycleViewAt(int index, Recycler recycler) {
final View view = getChildAt(index);
removeViewAt(index);
recycler.recycleView(view);
}
}
复制代码

先从LayoutManager中删除表项,然后调用Recycler.recycleView()回收表项:


public final class Recycler {
public void recycleView(View view) {
// 获取表项 ViewHolder
ViewHolder holder = getChildViewHolderInt(view);
if (holder.isTmpDetached()) {
removeDetachedView(view, false);
}
if (holder.isScrap()) {
holder.unScrap();
} else if (holder.wasReturnedFromScrap()) {
holder.clearReturnedFromScrapFlag();
}
recycleViewHolderInternal(holder);
}
}
复制代码

先通过表项视图拿到了对应ViewHolder,然后把其传入Recycler.recycleViewHolderInternal(),现在就可以更准地回答上一篇的那个问题“回收些啥?”:回收的是滑出屏幕表项对应的ViewHolder


public final class Recycler {
...
int mViewCacheMax = DEFAULT_CACHE_SIZE;
static final int DEFAULT_CACHE_SIZE = 2;
final ArrayList<ViewHolder> mCachedViews = new ArrayList<ViewHolder>();
...

void recycleViewHolderInternal(ViewHolder holder) {
...
if (forceRecycle || holder.isRecyclable()) {
//先存在mCachedViews里面
//这里的判断条件决定了复用mViewCacheMax中的ViewHolder时不需要重新绑定数据
if (mViewCacheMax > 0
&& !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
| ViewHolder.FLAG_REMOVED
| ViewHolder.FLAG_UPDATE
| ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
// Retire oldest cached view
//如果mCachedViews大小超限了,则删掉最老的被缓存的ViewHolder
int cachedViewSize = mCachedViews.size();
if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) {
recycleCachedViewAt(0);
cachedViewSize--;
}

int targetCacheIndex = cachedViewSize;
if (ALLOW_THREAD_GAP_WORK
&& cachedViewSize > 0
&& !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) {
// when adding the view, skip past most recently prefetched views
int cacheIndex = cachedViewSize - 1;
while (cacheIndex >= 0) {
int cachedPos = mCachedViews.get(cacheIndex).mPosition;
if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) {
break;
}
cacheIndex--;
}
targetCacheIndex = cacheIndex + 1;
}
//ViewHolder加到缓存中
mCachedViews.add(targetCacheIndex, holder);
cached = true;
}
//若ViewHolder没有入缓存则存入回收池
if (!cached) {
addViewHolderToRecycledViewPool(holder, true);
recycled = true;
}
} else {
...
}
...
}
复制代码

ViewHolder 最终的落脚点有两个:



  1. mCachedViews

  2. RecycledViewPool


落脚点通过cached这个布尔值,实现互斥,即ViewHolder要么存入mCachedViews,要么存入pool


mCachedViews有大小限制,默认只能存2个ViewHolder,当第三个ViewHolder存入时会把第一个移除掉:


public final class Recycler {
// 讲 mCachedViews 中的 ViewHolder 移到 RecycledViewPool 中
void recycleCachedViewAt(int cachedViewIndex) {
ViewHolder viewHolder = mCachedViews.get(cachedViewIndex);
//将ViewHolder加入到回收池
addViewHolderToRecycledViewPool(viewHolder, true);
//将ViewHolder从cache中移除
mCachedViews.remove(cachedViewIndex);
}
...
}
复制代码

mCachedViews移除掉的ViewHolder会加入到回收池中。 mCachedViews有点像“回收池预备队列”,即总是先回收到mCachedViews,当它放不下的时候,按照先进先出原则将最先进入的ViewHolder存入回收池


public final class Recycler {
// 缓存池实例
RecycledViewPool mRecyclerPool;
// 将viewHolder存入缓存池
void addViewHolderToRecycledViewPool(ViewHolder holder, boolean dispatchRecycled) {
...
getRecycledViewPool().putRecycledView(holder);
}
// 获取 RecycledViewPool 实例
RecycledViewPool getRecycledViewPool() {
if (mRecyclerPool == null) {
mRecyclerPool = new RecycledViewPool();
}
return mRecyclerPool;
}
}

//缓存池
public static class RecycledViewPool {
// 但类型 ViewHolder 列表
static class ScrapData {
// 最终存储 ViewHolder 实例的列表
ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
//每种类型的 ViewHolder 最多存 5 个
int mMaxScrap = DEFAULT_MAX_SCRAP;
...
}
//键值对:以 viewType 为键,ScrapData 为值,用以存储不同类型的 ViewHolder 列表
SparseArray<ScrapData> mScrap = new SparseArray<>();
//ViewHolder 入池 按 viewType 分类入池,相同的 ViewType 存放在同一个列表中
public void putRecycledView(ViewHolder scrap) {
final int viewType = scrap.getItemViewType();
final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
//如果超限了,则放弃入池
if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
return;
}
// 入回收池之前重置 ViewHolder
scrap.resetInternal();
// 最终 ViewHolder 入池
scrapHeap.add(scrap);
}
}
复制代码

ViewHolder会按viewType分类存入回收池,最终存储在ScrapData ArrayList中,回收池数据结构分析详见RecyclerView缓存机制(咋复用?)


缓存优先级


还记得RecyclerView缓存机制(咋复用?)中得出的结论吗?这里再引用一下:



虽然为了获取ViewHolder做了5次尝试(共从6个地方获取),先排除3种特殊情况,即从mChangedScrap获取、通过id获取、从自定义缓存获取,正常流程中只剩下3种获取方式,优先级从高到低依次是:



  1. 从 mAttachedScrap 获取

  2. 从 mCachedViews 获取

  3. 从 mRecyclerPool 获取


这样的缓存优先级意味着,对应的复用性能也是从高到低(复用性能越好意味着所做的昂贵操作越少)



  1. 最坏情况:重新创建 ViewHodler 并重新绑定数据

  2. 次好情况:复用 ViewHolder 但重新绑定数据

  3. 最好情况:复用 ViewHolder 且不重新绑定数据



当时分析了mAttachedScrapmRecyclerPool的复用性能,即 mRecyclerPool中复用的ViewHolder需要重新绑定数据,从mAttachedScrap 中复用的ViewHolder不需要重新创建也不需要重新绑定数据


把存入mCachedViews的代码和复用时绑定数据的代码结合起来看一下:


void recycleViewHolderInternal(ViewHolder holder) {
...
//满足这个条件才能存入mCachedViews
if (mViewCacheMax > 0
&& !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID
| ViewHolder.FLAG_REMOVED
| ViewHolder.FLAG_UPDATE
| ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) {
}
...
}

ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
...
//满足这个条件就需要重新绑定数据
if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()){
}
...
复制代码

重新绑定数据的三个条件中,holder.needsUpdate()holder.isInvalid()都是false时才能存入mCachedViews ,而!holder.isBound()对于mCachedViews 中的ViewHolder来说必然为false,因为只有当调用ViewHolder.resetInternal()重置ViewHolder后,才会将其设置为未绑定状态,而只有存入回收池时才会重置ViewHolder。所以 mCachedViews中复用的ViewHolder不需要重新绑定数据


总结



  • 滑出屏幕表项对应的 ViewHolder 会被回收到mCachedViews+mRecyclerPool 结构中。

  • mCachedViews是 ArrayList ,默认存储最多2个 ViewHolder ,当它放不下的时候,按照先进先出原则将最先进入的 ViewHolder 存入回收池的方式来腾出空间。mRecyclerPool 是 SparseArray ,它会按viewType分类存储 ViewHolder ,默认每种类型最多存5个。

  • mRecyclerPool中复用的 ViewHolder 需要重新绑定数据

  • mCachedViews中复用的 ViewHolder 不需要重新绑定数据

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

RecyclerView 缓存机制 | 回收些什么?

RecyclerView 内存性能优越,这得益于它独特的缓存机制,上一篇分析了“如何从缓存中复用表项?”,这一篇继续以走读源码的方式探究一下“哪些表项会被回收?”回收场景 在众多回收场景中最显而易见的就是“滚动列表时移出屏幕的表项被回收”。滚动是由Motion...
继续阅读 »

RecyclerView 内存性能优越,这得益于它独特的缓存机制,上一篇分析了“如何从缓存中复用表项?”,这一篇继续以走读源码的方式探究一下“哪些表项会被回收?”

回收场景


在众多回收场景中最显而易见的就是“滚动列表时移出屏幕的表项被回收”。滚动是由MotionEvent.ACTION_MOVE事件触发的,就以RecyclerView.onTouchEvent()为切入点寻觅“回收表项”的时机


public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {
@Override
public boolean onTouchEvent(MotionEvent e) {
...
case MotionEvent.ACTION_MOVE: {
...
// 内部滚动
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
vtev)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
...
}
} break;
...
}
}
复制代码

去掉了大量位移赋值逻辑后,一个处理滚动的函数出现在眼前:


public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {
...
LayoutManager mLayout;// 处理滚动的LayoutManager
...
boolean scrollByInternal(int x, int y, MotionEvent ev) {
...
if (mAdapter != null) {
...
if (x != 0) { // 水平滚动
consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState);
unconsumedX = x - consumedX;
}
if (y != 0) { // 垂直滚动
consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState);
unconsumedY = y - consumedY;
}
...
}
...
}
复制代码

RecyclerView把滚动委托给LayoutManager来处理:


public class LinearLayoutManager extends RecyclerView.LayoutManager implements ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider {

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
RecyclerView.State state) {
if (mOrientation == HORIZONTAL) {
return 0;
}
return scrollBy(dy, recycler, state);
}

int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
//更新LayoutState(这个函数对于“回收哪些表项”来说很关键,待会会提到)
updateLayoutState(layoutDirection, absDy, true, state);
//滚动时向列表中填充新的表项
final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
...
return scrolled;
}
...
}
复制代码

沿着调用链往下找,发现了一个上一篇中介绍过的函数LinearLayoutManager.fill(),列表滚动的同时会不断的向其中填充表项。


上一遍只关注了其中填充的逻辑,里面还有回收逻辑:


public class LinearLayoutManager extends RecyclerView.LayoutManager {
int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) {
...
int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
//不断循环获取新的表项用于填充,直到没有填充空间
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
...
//填充新的表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) {
//在当前滚动偏移量基础上追加因新表项插入增加的像素(这句话对于“回收哪些表项”来说很关键)
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
...
//回收表项
recycleByLayoutState(recycler, layoutState);
}
...
}
...
return start - layoutState.mAvailable;
}
}
复制代码

在不断获取新表项用于填充的同时也在回收表项,就好比滚动着的列表,有表项插入的同时也有表项被移出,移步到回收表项的函数:


public class LinearLayoutManager extends RecyclerView.LayoutManager {
...
private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
if (!layoutState.mRecycle || layoutState.mInfinite) {
return;
}
if (layoutState.mLayoutDirection == LayoutState.LAYOUT_START) {
// 从列表头回收
recycleViewsFromEnd(recycler, layoutState.mScrollingOffset);
} else {
// 从列表尾回收
recycleViewsFromStart(recycler, layoutState.mScrollingOffset);
}
}
...
/**
* 当向列表尾部滚动时回收滚出屏幕的表项
* @param dt(该参数被用于检测滚出屏幕的表项)
*/
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset,int noRecycleSpace) {
final int limit = scrollingOffset - noRecycleSpace;
//从头开始遍历 LinearLayoutManager,以找出应该会回收的表项
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 如果表项的下边界 > limit 这个阈值
if (mOrientationHelper.getDecoratedEnd(child) > limit
|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
//回收索引为 0 到 i-1 的表项
recycleChildren(recycler, 0, i);
return;
}
}
}
...
}
复制代码

RecyclerView的回收分两个方向:1. 从列表头回收 2.从列表尾回收。


就以“从列表头回收”为研究对象分析下RecyclerView在滚动时到底是怎么判断“哪些表项应该被回收?”。
(“从列表头回收表项”所对应的场景是:手指上滑,列表向下滚动,新的表项逐个插入到列表尾部,列表头部的表项逐个被回收。)


回收哪些表项


要回答这个问题,刚才那段代码中套在recycleChildren(recycler, 0, i)外面的判断逻辑是关键:mOrientationHelper.getDecoratedEnd(child) > limit


其中的mOrientationHelper.getDecoratedEnd(child)代码如下:


// 屏蔽方向的抽象接口,用于减少关于方向的 if-else
public abstract class OrientationHelper {
// 获取当前表项相对于列表头部的坐标
public abstract int getDecoratedEnd(View view);
// 垂直布局对该接口的实现
public static OrientationHelper createVerticalHelper(RecyclerView.LayoutManager layoutManager) {
return new OrientationHelper(layoutManager) {
@Override
public int getDecoratedEnd(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)view.getLayoutParams();
return mLayoutManager.getDecoratedBottom(view) + params.bottomMargin;
}
}
复制代码

mOrientationHelper.getDecoratedEnd(child) 表示当前表项的尾部相对于列表头部的坐标,OrientationHelper这层抽象屏蔽了列表的方向,所以这句话在纵向列表中可以翻译成“当前表项的底部相对于列表顶部的纵坐标”。


判断条件mOrientationHelper.getDecoratedEnd(child) > limit中的limit又是什么意思?


在纵向列表中,“表项底部纵坐标 > 某个值”意味着表项位于某条线的下方,即 limit 是列表中隐形的线,所有在这条线上方的表项都应该被回收。


那这条线是如何被计算的?


public class LinearLayoutManager extends RecyclerView.LayoutManager {
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset,int noRecycleSpace) {
final int limit = scrollingOffset - noRecycleSpace;
...
}
}
复制代码

limit的值由 2 个变量决定,其中noRecycleSpace的值为 0(这是断点告诉我的,详细过程可移步RecyclerView 动画原理 | 换个姿势看源码(pre-layout)


scrollingOffset的值由外部传入:


public class LinearLayoutManager {
private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
int scrollingOffset = layoutState.mScrollingOffset;
...
recycleViewsFromStart(recycler, scrollingOffset, noRecycleSpace);
}
}
复制代码

问题转换为layoutState.mScrollingOffset的值由什么决定?全局搜索下它被赋值的地方:


public class LinearLayoutManager {
private void updateLayoutState(int layoutDirection, int requiredSpace,boolean canUseExistingSpace, RecyclerView.State state) {
...
int scrollingOffset;
// 获取末尾的表项视图
final View child = getChildClosestToEnd();
// 计算在不往列表里填充新表项的情况下,列表最多可以滚动多少像素
scrollingOffset = mOrientationHelper.getDecoratedEnd(child) - mOrientationHelper.getEndAfterPadding();
...
mLayoutState.mScrollingOffset = scrollingOffset;
}
}
复制代码

updateLayoutState()方法中先获取了列表末尾表项的视图,并通过mOrientationHelper.getDecoratedEnd(child)计算出该表项底部到列表顶部的距离,然后在减去列表长度。这个差值可以理解为在不往列表里填充新表项的情况下,列表最多可以滚动多少像素。略抽象,图示如下:



图中蓝色边框表示列表,灰色矩形表示表项。


LayoutManager只会加载可见表项,图中表项 6 有一半露出了屏幕,所以它会被加载到列表中,而表项 7 完全不可见,所以不会被加载。这种情况下,如果不继续往列表中填充表项 7,那列表最多滑动的距离就是半个表项 6 的距离,表项在代码中即是mLayoutState.mScrollingOffset的值。


若非常缓慢地滑动列表,并且只滑动“半个表项 6”的距离(即表项 7 没有机会展示)。在这个理想的场景下limit的值 = 半个表项 6 的长度。也就是说limit这根隐形的线应该在如下位置:



回看一下,回收表项的代码:


public class LinearLayoutManager {
private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset,int noRecycleSpace) {
final int limit = scrollingOffset - noRecycleSpace;
//从头开始遍历 LinearLayoutManager,以找出应该会回收的表项
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 如果表项的下边界 > limit 这个阈值
if (mOrientationHelper.getDecoratedEnd(child) > limit
|| mOrientationHelper.getTransformedEndWithDecoration(child) > limit) {
//回收索引为 0 到 i-1 的表项
recycleChildren(recycler, 0, i);
return;
}
}
}
}
复制代码

回收逻辑从头开始遍历 LinearLayoutManager,当遍历到表项 1 的时候,发现它的下边界 > limit,所以触发表项回收,回收表项的索引区间为 0 到 0,即没有任何表项被回收。(想想也是,表项 1 还未完整地被移出屏幕)。


若滑动速度和距离更大会发生什么?


计算limit值的方法updateLayoutState()scrollBy()中被调用:


public class LinearLayoutManager {
int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
// 将滚动距离的绝对值传入 updateLayoutState()
final int absDelta = Math.abs(delta);
updateLayoutState(layoutDirection, absDelta, true, state);
...
}

private void updateLayoutState(int layoutDirection, int requiredSpace,boolean canUseExistingSpace, RecyclerView.State state) {
...
// 计算在不往列表里填充新表项的情况下,列表最多可以滚动多少像素
scrollingOffset = mOrientationHelper.getDecoratedEnd(child)- mOrientationHelper.getEndAfterPadding();
...
// 将列表因滚动而需要的额外空间存储在 mLayoutState.mAvailable
mLayoutState.mAvailable = requiredSpace;
mLayoutState.mScrollingOffset = scrollingOffset;
...
}
}
复制代码

至此,两个重要的值被分别存储在mLayoutState.mScrollingOffsetmLayoutState.mAvailable,分别是“在不往列表里填充新表项的情况下,列表最多可以滚动多少像素”,及“滚动总像素值”。


srollBy()在调用updateLayoutState()存储了这两个重要的值之后,立马进行了填充表项的操作:


public class LinearLayoutManager {
int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final int absDelta = Math.abs(delta);
updateLayoutState(layoutDirection, absDelta, true, state);
final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
...
}
}
复制代码

填充表项


其中的fill()即是向列表填充表项的方法:


public class LinearLayoutManager {
// 根据剩余空间填充表项
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
...
// 计算剩余空间 = 可用空间 + 额外空间(=0)
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 循环,当剩余空间 > 0 时,继续填充更多表项
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
...
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult)
...
// 从剩余空间中扣除新表项占用像素值
layoutState.mAvailable -= layoutChunkResult.mConsumed;
remainingSpace -= layoutChunkResult.mConsumed;
...
}
}
}
复制代码

填充表项是一个while循环,循环结束条件是“列表剩余空间是否 > 0”,每次循环调用layoutChunk()将单个表项填充到列表中:


public class LinearLayoutManager {
// 填充单个表项
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
// 1.获取下一个该被填充的表项视图
View view = layoutState.next(recycler);
// 2.使表项成为 RecyclerView 的子视图
addView(view);
...
// 3.测量表项视图(把 RecyclerView 内边距和表项装饰考虑在内)
measureChildWithMargins(view, 0, 0);
// 获取填充表项视图需要消耗的像素值
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
...
// 4.布局表项
layoutDecoratedWithMargins(view, left, top, right, bottom);
}
}
复制代码

layoutChunk()先从缓存池中获取下一个该被填充表项的视图(关于复用的详细分析可以移步RecyclerView 缓存机制 | 如何复用表项?)。


紧接着调用了addView()使表项视图成为 RecyclerView 的子视图,调用链如下:


public class RecyclerView {
ChildHelper mChildHelper;
public abstract static class LayoutManager {
public void addView(View child) {
addView(child, -1);
}

public void addView(View child, int index) {
addViewInt(child, index, false);
}

private void addViewInt(View child, int index, boolean disappearing) {
...
mChildHelper.attachViewToParent(child, index, child.getLayoutParams(), false);
...
}
}
}

class ChildHelper {
final Callback mCallback;
void attachViewToParent(View child, int index, ViewGroup.LayoutParams layoutParams,boolean hidden) {
...
mCallback.attachViewToParent(child, offset, layoutParams);
}
}
复制代码

调用链从RecyclerViewLayoutManager再到ChildHelper,最后又回到了RecyclerView


public class RecyclerView {
ChildHelper mChildHelper;
private void initChildrenHelper() {
mChildHelper = new ChildHelper(new ChildHelper.Callback() {
@Override
public void attachViewToParent(View child, int index,ViewGroup.LayoutParams layoutParams) {
...
RecyclerView.this.attachViewToParent(child, index, layoutParams);
}
...
}
}
}
复制代码

addView()的最终落脚点是ViewGroup.attachViewToParent()


public abstract class ViewGroup {
protected void attachViewToParent(View child, int index, LayoutParams params) {
child.mLayoutParams = params;

if (index < 0) {
index = mChildrenCount;
}

// 将子视图添加到数组中
addInArray(child, index);
// 子视图和父亲关联
child.mParent = this;
child.mPrivateFlags = (child.mPrivateFlags & ~PFLAG_DIRTY_MASK
& ~PFLAG_DRAWING_CACHE_VALID)
| PFLAG_DRAWN | PFLAG_INVALIDATED;
this.mPrivateFlags |= PFLAG_INVALIDATED;

if (child.hasFocus()) {
requestChildFocus(child, child.findFocus());
}
dispatchVisibilityAggregated(isAttachedToWindow() && getWindowVisibility() == VISIBLE
&& isShown());
notifySubtreeAccessibilityStateChangedIfNeeded();
}
}
复制代码

attachViewToParent()中包含了“添加子视图”最具标志性的两个动作:1. 将子视图添加到数组中 2. 子视图和父亲关联。


使表项成为 RecyclerView 子视图之后,对其进行了测量:


public class LinearLayoutManager {
// 填充单个表项
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
// 1.获取下一个该被填充的表项视图
View view = layoutState.next(recycler);
// 2.使表项成为 RecyclerView 的子视图
addView(view);
...
// 3.测量表项视图(把 RecyclerView 内边距和表项装饰考虑在内)
measureChildWithMargins(view, 0, 0);
// 获取填充表项视图需要消耗的像素值
result.mConsumed = mOrientationHelper.getDecoratedMeasurement(view);
...
// 4.布局表项
layoutDecoratedWithMargins(view, left, top, right, bottom);
}
}
复制代码

测量之后,有了视图的尺寸,就可以知道填充该表项会消耗掉多少像素值,将该数值存储在LayoutChunkResult.mConsumed中。


有了尺寸后,就可以布局表项了,即确定表项上下左右四个点相对于 RecyclerView 的位置:


public class RecyclerView {
public abstract static class LayoutManager {
public void layoutDecoratedWithMargins(@NonNull View child, int left, int top, int right,
int bottom) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
final Rect insets = lp.mDecorInsets;
// 为表项定位
child.layout(left + insets.left + lp.leftMargin, top + insets.top + lp.topMargin,
right - insets.right - lp.rightMargin,
bottom - insets.bottom - lp.bottomMargin);
}
}
}
复制代码

调用控件的layout()方法即是为控件定位,关于定位子控件的详细介绍可以移步Android自定义控件 | View绘制原理(画在哪?)


填充完一个表项后,会从remainingSpace中扣除它所占用的空间(这样 while 循环才能结束)


public class LinearLayoutManager {
// 根据剩余空间填充表项
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
...
// 计算剩余空间 = 可用空间 + 额外空间(=0)
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 循环,当剩余空间 > 0 时,继续填充更多表项
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
...
// 填充单个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult)
...
// 从剩余空间中扣除新表项占用像素值
layoutState.mAvailable -= layoutChunkResult.mConsumed;
remainingSpace -= layoutChunkResult.mConsumed;
...
// 在 limit 上追加新表项所占像素值
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
...
// 根据当前状态回收表项
recycleByLayoutState(recycler, layoutState);
}
}
}
}
复制代码

layoutState.mScrollingOffset会追加新表项所占用的像素值,即它的值在不断增大(limit 隐形线在不断下移)。


在一次while循环的最后,会根据当前limit 隐形线的位置回收表项:


public class LinearLayoutManager {
private void recycleByLayoutState(RecyclerView.Recycler recycler, LayoutState layoutState) {
...
ecycleViewsFromStart(recycler, scrollingOffset, noRecycleSpace);
}
}

private void recycleViewsFromStart(RecyclerView.Recycler recycler, int scrollingOffset,int noRecycleSpace) {
final int limit = scrollingOffset - noRecycleSpace;
final int childCount = getChildCount();
// 从头遍历表项
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 当某表项底部位于 limit 隐形线之后时,回收它以上的所有表项
if (mOrientationHelper.getDecoratedStart(child) > limit || mOrientationHelper.getTransformedStartWithDecoration(child) > limit) {
recycleChildren(recycler, 0, i);
return;
}
}
}
}
复制代码

每向列表尾部填充一个表项,limit隐形线的位置就往下移动表项占用的像素值,这样列表头部也就有更多的表项符合被回收的条件。


关于回收细节的分析,可以移步RecyclerView 缓存机制 | 回收到哪去?


预计的滑动距离被传入scrollBy()scrollBy()把即将滑入屏幕的表项填充到列表中,同时把即将移出屏幕的表项回收到缓存池,最后它会比较预计滑动值和计算滑动值的大小,取其中的较小者返回:


public class LinearLayoutManager {
// 第一个参数是预计的滑动距离
int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final int absDelta = Math.abs(delta);
updateLayoutState(layoutDirection, absDelta, true, state);
// 经过计算的滚动值
final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
// 最终返回的滚动值
final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta;
...
return scrolled;
}
}
复制代码

沿着scrollBy()调用链网上寻找:


public class LinearLayoutManager {
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
RecyclerView.State state) {
if (mOrientation == HORIZONTAL) {
return 0;
}
return scrollBy(dy, recycler, state);
}
}

public class RecyclerView {
void scrollStep(int dx, int dy, @Nullable int[] consumed) {
...
if (dy != 0) {
consumedY = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
}
...
}

boolean scrollByInternal(int x, int y, MotionEvent ev) {
...
scrollStep(x, y, mReusableIntPair);
...
dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,TYPE_TOUCH, mReusableIntPair);
...
}
}
复制代码

只有当执行了dispatchNestedScroll()才会真正触发列表的滚动,也就说 RecyclerView 在列表滚动发生之前就预先计算好了,哪些表项会移入屏幕,哪些表项会移出屏幕,并分别将它们填充到列表或回到到缓存池。而做这两件事的依据即是limit隐形线,最后用一张图来概括下这条线的意义:


limit的值表示这一次滚动的总距离。(图中是一种理想情况,即当滚动结束后新插入表项 7 的底部正好和列表底部重叠)


limit隐形线可以理解为:隐形线当前所在位置,在滚动完成后会和列表顶部重合


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

RecyclerView 缓存机制 | 如何复用表项?(2)

RecyclerView 缓存机制 | 如何复用表项?(1)第四次尝试 ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun,...
继续阅读 »

RecyclerView 缓存机制 | 如何复用表项?(1)

第四次尝试


ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs)
{
...
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
final View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
if (view != null) {
//获得view对应的ViewHolder
holder = getChildViewHolder(view);
if (holder == null) {
throw new IllegalArgumentException("getViewForPositionAndType returned"
+ " a view which does not have a ViewHolder"
+ exceptionLabel());
} else if (holder.shouldIgnore()) {
throw new IllegalArgumentException("getViewForPositionAndType returned"
+ " a view that is ignored. You must call stopIgnoring before"
+ " returning this view." + exceptionLabel());
}
}
}
...
}
复制代码

经过从mAttachedScrapmCachedViews获取ViewHolder未果后,继续尝试通过ViewCacheExtension 获取:



/**
* ViewCacheExtension is a helper class to provide an additional layer of view caching that can
* be controlled by the developer.
* ViewCacheExtension提供了额外的表项缓存层,用户帮助开发者自己控制表项缓存
*


* When {@link Recycler#getViewForPosition(int)} is called, Recycler checks attached scrap and
* first level cache to find a matching View. If it cannot find a suitable View, Recycler will
* call the {@link #getViewForPositionAndType(Recycler, int, int)} before checking
* {@link RecycledViewPool}.
* 当Recycler从attached scrap和first level cache中未能找到匹配的表项时,它会在去RecycledViewPool中查找之前,先尝试从自定义缓存中查找
*


*/

public abstract static class ViewCacheExtension {

/**
* Returns a View that can be binded to the given Adapter position.
*


* This method should
not create a new View. Instead, it is expected to return
* an already created View that can be re-used for the given type and position.
* If the View is marked as ignored, it should first call
* {@link LayoutManager#stopIgnoringView(View)} before returning the View.
*


* RecyclerView will re-bind the returned View to the position if necessary.
*/

public abstract View getViewForPositionAndType(Recycler recycler, int position, int type);
}
复制代码


注释揭露了很多信息:ViewCacheExtension用于开发者自定义表项缓存,且这层缓存的访问顺序位于mAttachedScrapmCachedViews之后,RecycledViewPool 之前。这和Recycler. tryGetViewHolderForPositionByDeadline()中的代码逻辑一致,那接下来的第五次尝试,应该是从 RecycledViewPool 中获取 ViewHolder


第五次尝试


ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs)
{
...
if (holder == null) {
...
//从回收池中获取ViewHolder对象
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
...
}
复制代码

前四次尝试都未果,最后从RecycledViewPool 中获取ViewHolder稍等片刻!相对于从mAttachedScrap mCachedViews 中获取 ViewHolder,此处并没有严格的检验逻辑。为啥要区别对待不同的缓存? 大大的问号悬在头顶,但现在暂时无法解答,还是接着看RecycledViewPool 的结构吧~


public final class Recycler {
...
RecycledViewPool mRecyclerPool;
//获得RecycledViewPool实例
RecycledViewPool getRecycledViewPool() {
if (mRecyclerPool == null) {
mRecyclerPool = new RecycledViewPool();
}
return mRecyclerPool;
}
...
}
public static class RecycledViewPool {
...
//从回收池中获取ViewHolder对象
public ViewHolder getRecycledView(int viewType) {
final ScrapData scrapData = mScrap.get(viewType);
if (scrapData != null && !scrapData.mScrapHeap.isEmpty()) {
final ArrayList scrapHeap = scrapData.mScrapHeap;
return scrapHeap.remove(scrapHeap.size() - 1);
}
return null;
}
...
}
复制代码

函数中只要访问了类成员变量,它的复杂度就提高了,因为类成员变量的作用于超出了函数体,使得函数就和类中其他函数耦合,所以不得不进行阅读更多以帮助理解该函数:


    public static class RecycledViewPool {
//同类ViewHolder缓存个数上限
private static final int DEFAULT_MAX_SCRAP = 5;

/**
* Tracks both pooled holders, as well as create/bind timing metadata for the given type.
* 回收池中存放单个类型ViewHolder的容器
*/

static class ScrapData {
//同类ViewHolder存储在ArrayList中
ArrayList mScrapHeap = new ArrayList<>();
//每种类型的ViewHolder最多存5个
int mMaxScrap = DEFAULT_MAX_SCRAP;
}
//回收池中存放所有类型ViewHolder的容器
SparseArray mScrap = new SparseArray<>();
...
//ViewHolder入池 按viewType分类入池,一个类型的ViewType存放在一个ScrapData中
public void putRecycledView(ViewHolder scrap) {
final int viewType = scrap.getItemViewType();
final ArrayList scrapHeap = getScrapDataForType(viewType).mScrapHeap;
//如果超限了,则放弃入池
if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {
return;
}
if (DEBUG && scrapHeap.contains(scrap)) {
throw new IllegalArgumentException("this scrap item already exists");
}
scrap.resetInternal();
//回收时,ViewHolder从列表尾部插入
scrapHeap.add(scrap);
}
//从回收池中获取ViewHolder对象
public ViewHolder getRecycledView(int viewType) {
final ScrapData scrapData = mScrap.get(viewType);
if (scrapData != null && !scrapData.mScrapHeap.isEmpty()) {
final ArrayList scrapHeap = scrapData.mScrapHeap;
//复用时,从列表尾部获取ViewHolder(优先复用刚入池的ViewHoler)
return scrapHeap.remove(scrapHeap.size() - 1);
}
return null;
}
}
复制代码


  • 上述代码列出了RecycledViewPool 中最关键的一个成员变量和两个函数。至此可以得出结论:RecycledViewPool中的ViewHolder存储在SparseArray中,并且按viewType分类存储(即是Adapter.getItemViewType()的返回值),同一类型的ViewHolder存放在ArrayList 中,且默认最多存储5个。

  • 相比较于mCachedViews,从mRecyclerPool中成功获取ViewHolder对象后并没有做合法性和表项位置校验,只检验viewType是否一致。所以 mRecyclerPool中取出的ViewHolder只能复用于相同viewType的表项


创建ViewHolder并绑定数据


ViewHolder tryGetViewHolderForPositionByDeadline(int position,boolean dryRun, long deadlineNs) {
...
//所有缓存都没有命中,只能创建ViewHolder
if (holder == null) {
...
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
...
boolean bound = false;
if (mState.isPreLayout() && holder.isBound()) {
// do not update unless we absolutely have to.
holder.mPreLayoutPosition = position;
}
//如果表项没有绑定过数据 或 表项需要更新 或 表项无效 且表项没有被移除时绑定表项数据
else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
if (DEBUG && holder.isRemoved()) {
throw new IllegalStateException("Removed holder should be bound and it should"
+ " come here only in pre-layout. Holder: " + holder
+ exceptionLabel());
}
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
//为表项绑定数据
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
}
复制代码


  • 再进行了上述所有尝试后,如果依然没有获得ViewHolder,只能重新创建并绑定数据。沿着调用链往下,就会找到熟悉的onCreateViewHolder()onBindViewHolder()

  • 绑定数据的逻辑嵌套在一个大大的if中(原来并不是每次都要绑定数据,只有满足特定条件时才需要绑定。

  • 那什么情况下需要绑定,什么情况下不需要呢?这就要引出“缓存优先级”这个概念。


缓存优先级




  • 缓存有优先级一说,在使用图片二级缓存(内存+磁盘)时,会先尝试去优先级高的内存中获取,若未命中再去磁盘中获取。优先级越高意味着性能越好。RecyclerView的缓存机制中是否也能套用“缓存优先级”这一逻辑?




  • 虽然为了获取ViewHolder做了5次尝试(共从6个地方获取),先排除3种特殊情况,即从mChangedScrap获取、通过id获取、从自定义缓存获取,正常流程中只剩下3种获取方式,优先级从高到低依次是:



    1. mAttachedScrap获取

    2. mCachedViews获取

    3. mRecyclerPool 获取




  • 这样的缓存优先级是不是意味着,对应的复用性能也是从高到低?(复用性能越好意味着所做的昂贵操作越少)



    1. 最坏情况:重新创建ViewHodler并重新绑定数据

    2. 次好情况:复用ViewHolder但重新绑定数据

    3. 最好情况:复用ViewHolder且不重新绑定数据


    毫无疑问,所有缓存都未命中的情况下会发生最坏情况。剩下的两种情况应该由3种获取方式来分摊,猜测优先级最低的 mRecyclerPool 方式应该命中次好情况,而优先级最高的 mAttachedScrap应该命中最好情况,去源码中验证一下:




ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();

// Try first for an exact, non-invalid match from scrap.
//1.从attached scrap回收集合中
for (int i = 0; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
//只有当holder是有效时才返回
if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
&& !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
}

ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs)
{
...
if (holder == null) {
...
//从回收池中获取ViewHolder对象
holder = getRecycledViewPool().getRecycledView(type);
if (holder != null) {
//重置ViewHolder
holder.resetInternal();
if (FORCE_INVALIDATE_DISPLAY_LIST) {
invalidateDisplayListInt(holder);
}
}
}
...
//如果表项没有绑定过数据 或 表项需要更新 或 表项无效 且表项没有被移除时绑定表项数据
else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
if (DEBUG && holder.isRemoved()) {
throw new IllegalStateException("Removed holder should be bound and it should"
+ " come here only in pre-layout. Holder: " + holder
+ exceptionLabel());
}
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
//为表项绑定数据
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
...
}

public abstract static class ViewHolder {
/**
* This ViewHolder has been bound to a position; mPosition, mItemId and mItemViewType
* are all valid.
* 绑定标志位
*/

static final int FLAG_BOUND = 1 << 0;
/**
* This ViewHolder’s data is invalid. The identity implied by mPosition and mItemId
* are not to be trusted and may no longer match the item view type.
* This ViewHolder must be fully rebound to different data.
* 无效标志位
*/

static final int FLAG_INVALID = 1 << 2;
//判断ViewHolder是否无效
boolean isInvalid() {
//将当前ViewHolder对象的flag和无效标志位做位与操作
return (mFlags & FLAG_INVALID) != 0;
}
//判断ViewHolder是否被绑定
boolean isBound() {
//将当前ViewHolder对象的flag和绑定标志位做位与操作
return (mFlags & FLAG_BOUND) != 0;
}
/**
* 将ViewHolder重置
*/

void resetInternal() {
//将ViewHolder的flag置0
mFlags = 0;
mPosition = NO_POSITION;
mOldPosition = NO_POSITION;
mItemId = NO_ID;
mPreLayoutPosition = NO_POSITION;
mIsRecyclableCount = 0;
mShadowedHolder = null;
mShadowingHolder = null;
clearPayload();
mWasImportantForAccessibilityBeforeHidden = ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO;
mPendingAccessibilityState = PENDING_ACCESSIBILITY_STATE_NOT_SET;
clearNestedRecyclerViewIfNotNested(this);
}
}
复制代码

温故知新,回看 mRecyclerPool 复用逻辑时,发现在成功获得ViewHolder对象后,立即对其重置(将flag置0)。这样就满足了绑定数据的判断条件(因为0和非0位与之后必然为0)。
同样的,在才mAttachedScrap中获取ViewHolder时,只有当其是有效的才会返回。所以猜测成立:mRecyclerPool中复用的ViewHolder需要重新绑定数据,从mAttachedScrap 中复用的ViewHolder不要重新出创建也不需要重新绑定数据


总结



  1. 在 RecyclerView 中,并不是每次绘制表项,都会重新创建 ViewHolder 对象,也不是每次都会重新绑定 ViewHolder 数据。

  2. RecyclerView 通过Recycler获得下一个待绘制表项。

  3. Recycler有4个层次用于缓存 ViewHolder 对象,优先级从高到底依次为ArrayList mAttachedScrapArrayList mCachedViewsViewCacheExtension mViewCacheExtensionRecycledViewPool mRecyclerPool。如果四层缓存都未命中,则重新创建并绑定 ViewHolder 对象。

  4. RecycledViewPool 对 ViewHolder 按viewType分类存储(通过SparseArray),同类 ViewHolder 存储在默认大小为5的ArrayList中。

  5. mRecyclerPool中复用的 ViewHolder 需要重新绑定数据,从mAttachedScrap 中复用的 ViewHolder 不需要重新创建也不需要重新绑定数据。

  6. mRecyclerPool中复用的ViewHolder ,只能复用于viewType相同的表项,从mCachedViews中复用的 ViewHolder ,只能复用于指定位置的表项。

  7. mCachedViews用于缓存指定位置的 ViewHolder ,只有“列表回滚”这一种场景(刚滚出屏幕的表项再次进入屏幕),才有可能命中该缓存。该缓存存放在默认大小为 2 的ArrayList中。


这篇文章粗略的回答了关于“复用”的4个问题,即“复用什么?”、“从哪里获得复用?”、“什么时候复用?”、“复用优先级”。读到这里,可能会有很多疑问:



  1. scrap view是什么?

  2. changed scrap viewattached scrap view有什么区别?

  3. 复用的 ViewHolder 是在什么时候被缓存的?

  4. 为什么要4层缓存?它们的用途有什么区别?


分析完“复用”,后续文章会进一步分析“回收”,希望到时候这些问题都能迎刃而解。


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

RecyclerView 缓存机制 | 如何复用表项?(1)

RecyclerView 内存性能优越,这得益于它独特的缓存机制,这一篇以走读源码的方式探究 RecyclerView 的缓存机制。引子 如果列表中每个移出屏幕的表项都直接销毁,移入时重新创建,很不经济。所以RecyclerView引入了缓存机制。 回收是为...
继续阅读 »

RecyclerView 内存性能优越,这得益于它独特的缓存机制,这一篇以走读源码的方式探究 RecyclerView 的缓存机制。

引子



  • 如果列表中每个移出屏幕的表项都直接销毁,移入时重新创建,很不经济。所以RecyclerView引入了缓存机制。

  • 回收是为了复用,复用的好处是有可能免去两个昂贵的操作:

    1. 为表项视图绑定数据

    2. 创建表项视图



  • 下面几个问题对于理解“回收复用机制”很关键:

    1. 回收什么?复用什么?

    2. 回收到哪里去?从哪里获得复用?

    3. 什么时候回收?什么时候复用?




这一篇试着从已知的知识出发在源码中寻觅未知的“RecyclerView复用机制”。


(ps: 下文中的 粗斜体字 表示引导源码阅读的内心戏)


寻觅


触发复用的众多时机中必然包含下面这种:“当移出屏幕的表项重新回到界面”。表项本质上是一个View,屏幕上的表项必然需要依附于一棵View树,即必然有一个父容器调用了addView()。而 RecyclerView继承自 ViewGroup,遂以RecyclerView.addView()为切入点向上搜寻复用的代码。


RecyclerView.java中全局搜索“addView”,发现RecyclerView()并没有对addView()函数重载,但找到一处addView()的调用:


//RecyclerView是ViewGroup的子类
public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {
...
private void initChildrenHelper() {
mChildHelper = new ChildHelper(new ChildHelper.Callback() {
...
@Override
public void addView(View child, int index) {
if (VERBOSE_TRACING) {
TraceCompat.beginSection("RV addView");
}
//直接调用ViewGroup.addView()
RecyclerView.this.addView(child, index);
if (VERBOSE_TRACING) {
TraceCompat.endSection();
}
dispatchChildAttached(child);
}
}
}
...
}
复制代码

ChildHelper.Callback.addView()为起点沿着调用链继续向上搜寻,经历了如下方法调用:



  • ChildHelper.addView()

  • LayoutManager.addViewInt()

  • LayoutManager.addView()

  • LinearLayoutManager.layoutChunk()


void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
//获得下一个表项
View view = layoutState.next(recycler);
...
LayoutParams params = (LayoutParams) view.getLayoutParams();
if (layoutState.mScrapList == null) {
//将表项插入到列表中
if (mShouldReverseLayout == (layoutState.mLayoutDirection == LayoutState.LAYOUT_START)) {
addView(view);
} else {
addView(view, 0);
}
}
...
}
复制代码

addView(view)中传入的view是函数layoutState.next()的返回值。猜测该函数是用来获得下一个表项的。表项不止一个,应该有一个循环不断的获得下一个表项才对。 沿着刚才的调用链继续往上搜寻,就会发现:的确有一个循环!


public class LinearLayoutManager extends RecyclerView.LayoutManager implements ItemTouchHelper.ViewDropHandler, RecyclerView.SmoothScroller.ScrollVectorProvider {
...
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
RecyclerView.State state, boolean stopOnFocusable) {
...
//recyclerview 剩余空间
int remainingSpace = layoutState.mAvailable + layoutState.mExtra;
LayoutChunkResult layoutChunkResult = mLayoutChunkResult;
//不断填充,直到空间消耗完毕
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
if (VERBOSE_TRACING) {
TraceCompat.beginSection("LLM LayoutChunk");
}
//填充一个表项
layoutChunk(recycler, state, layoutState, layoutChunkResult);
...
}
...
}
}
复制代码

fill()是在onLayoutChildren()中被调用:


/**
* Lay out all relevant child views from the given adapter.
* 布局所有给定adapter中相关孩子视图
*/
public void onLayoutChildren(Recycler recycler, State state) {
Log.e(TAG, "You must override onLayoutChildren(Recycler recycler, State state) ");
}
复制代码

看完注释,感觉前面猜测应该是正确的。onLayoutChildren()是用来布局RecyclerView中所有的表项的。回头去看一下layoutState.next(),表项复用逻辑应该就在其中。


public class LinearLayoutManager {
static class LayoutState {
/**
* Gets the view for the next element that we should layout.
* 获得下一个元素的视图用于布局
*/
View next(RecyclerView.Recycler recycler) {
if (mScrapList != null) {
return nextViewFromScrapList();
}
//调用了Recycler.getViewForPosition()
final View view = recycler.getViewForPosition(mCurrentPosition);
mCurrentPosition += mItemDirection;
return view;
}
}
}
复制代码

最终调用了Recycler.getViewForPosition(),Recycler是回收器的意思,感觉离想要找的“复用”逻辑越来越近了。 Recycler到底是做什么用的?


public class RecyclerView {
/**
* A Recycler is responsible for managing scrapped or detached item views for reuse.
* Recycler负责管理scrapped和detached表项的复用
*/
public final class Recycler {
...
}
}
复制代码

终于找到你~~ ,Recycler用于表项的复用!沿着Recycler.getViewForPosition()的调用链继续向下搜寻,找到了一个关键函数(函数太长了,为了防止头晕,只列出了关键节点):


public class RecyclerView {
public final class Recycler {
/**
* Attempts to get the ViewHolder for the given position, either from the Recycler scrap,
* cache, the RecycledViewPool, or creating it directly.
* 尝试获得指定位置的ViewHolder,要么从scrap,cache,RecycledViewPool中获取,要么直接重新创建
*/
@Nullable
ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
boolean fromScrapOrHiddenOrCache = false;
ViewHolder holder = null;
//0 从changed scrap集合中获取ViewHolder
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
//1. 通过position从attach scrap或一级回收缓存中获取ViewHolder
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
...
}

if (holder == null) {
...
final int type = mAdapter.getItemViewType(offsetPosition);
//2. 通过id在attach scrap集合和一级回收缓存中查找viewHolder
if (mAdapter.hasStableIds()) {
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),
type, dryRun);
...
}
//3. 从自定义缓存中获取ViewHolder
if (holder == null && mViewCacheExtension != null) {
// We are NOT sending the offsetPosition because LayoutManager does not
// know it.
final View view = mViewCacheExtension
.getViewForPositionAndType(this, position, type);
...
}
//4.从缓存池中拿ViewHolder
if (holder == null) { // fallback to pool
...
holder = getRecycledViewPool().getRecycledView(type);
...
}
//所有缓存都没有命中,只能创建ViewHolder
if (holder == null) {
...
holder = mAdapter.createViewHolder(RecyclerView.this, type);
...
}
}

boolean bound = false;
if (mState.isPreLayout() && holder.isBound()) {
// do not update unless we absolutely have to.
holder.mPreLayoutPosition = position;
}
//只有invalid的viewHolder才能绑定视图数据
else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
final int offsetPosition = mAdapterHelper.findPositionOffset(position);
//获得ViewHolder后,绑定视图数据
bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs);
}
...
return holder;
}
}
}
复制代码


  • 函数的名字以“tryGet”开头,“尝试获得”表示可能获得失败,再结合注释中说的:“尝试获得指定位置的ViewHolder,要么从scrap,cache,RecycledViewPool中,要么直接重新创建。”猜测scrap,cache,RecycledViewPool是回收表项的容器,相当于表项缓存,如果缓存未命中则只能重新创建。

  • 函数的返回值是ViewHolder难道回收和复用的是ViewHolder? 函数开头声明了局部变量ViewHolder holder = null;最终返回的也是这个局部变量,并且有4处holder == null的判断,这样的代码结构是不是有点像缓存?每次判空意味着上一级缓存未命中并继续尝试新的获取方法?缓存是不是有不止一种存储形式? 让我们一次一次地看:


第一次尝试


ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
if (mState.isPreLayout()) {
holder = getChangedScrapViewForPosition(position);
fromScrapOrHiddenOrCache = holder != null;
}
...
}
复制代码

只有在mState.isPreLayout()true时才会做这次尝试,这应该是一种特殊情况,先忽略。


第二次尝试


ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
//下面一段代码蕴含着一个线索,买个伏笔,先把他略去
...
}
...
}
复制代码


  • 当第一次尝试失败后,尝试通过getScrapOrHiddenOrCachedHolderForPosition()获得ViewHolder

  • 这里故意省略了一段代码,先埋个伏笔,待会分析。先沿着获取ViewHolder的调用链继续往下:


//省略非关键代码
/**
* Returns a view for the position either from attach scrap, hidden children, or cache.
* 从attach scrap,hidden children或者cache中获得指定位置上的一个ViewHolder
*/
ViewHolder getScrapOrHiddenOrCachedHolderForPosition(int position, boolean dryRun) {
final int scrapCount = mAttachedScrap.size();
// Try first for an exact, non-invalid match from scrap.
//1.在attached scrap中搜索ViewHolder
for (int i = 0; i < scrapCount; i++) {
final ViewHolder holder = mAttachedScrap.get(i);
if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
&& !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
return holder;
}
}
//2.从移除屏幕的视图中搜索ViewHolder,找到了之后将他存入scrap回收集合中
if (!dryRun) {
View view = mChildHelper.findHiddenNonRemovedView(position);
if (view != null) {
final ViewHolder vh = getChildViewHolderInt(view);
mChildHelper.unhide(view);
int layoutIndex = mChildHelper.indexOfChild(view);
...
mChildHelper.detachViewFromParent(layoutIndex);
scrapView(view);
vh.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP
| ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
return vh;
}
}
// Search in our first-level recycled view cache.
//3.在缓存中搜索ViewHolder
final int cacheSize = mCachedViews.size();
for (int i = 0; i < cacheSize; i++) {
final ViewHolder holder = mCachedViews.get(i);
//若找到ViewHolder,还需要对ViewHolder的索引进行匹配判断
if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
...
return holder;
}
}
return null;
}
复制代码

依次从三个地方搜索ViewHolder:1. mAttachedScrap 2. 隐藏表项 3. mCachedViews,找到立即返回。
其中mAttachedScrapmCachedViews作为Recycler的成员变量,用来存储一组ViewHolder


    public final class Recycler {
final ArrayList mAttachedScrap = new ArrayList<>();
...
final ArrayList mCachedViews = new ArrayList();
...
RecycledViewPool mRecyclerPool;
}
复制代码


  • 看到这里应该可以初步得出结论:RecyclerView回收机制中,回收复用的对象是ViewHolder,且以ArrayList为结构存储在Recycler对象中

  • RecycledViewPool mRecyclerPool; 看着也像是回收容器,那待会是不是也会到这里拿 ViewHolder?

  • 值得注意的是,当成功从mCachedViews中获取ViewHolder对象后,还需要对其索引进行判断,这就意味着 mCachedViews中缓存的ViewHolder只能复用于指定位置 ,打个比方:手指向上滑动,列表向下滚动,第2个表项移出屏幕,第4个表项移入屏幕,此时再滑回去,第2个表项再次出现,这个过程中第4个表项不能复用被回收的第2个表项的ViewHolder,因为他们的位置不同,而再次进入屏幕的第2个表项就可以成功复用。 待会可以对比一下其他复用是否也需要索引判断

  • 回到刚才埋下的伏笔,把第二次尝试获取ViewHolder的代码补全:


ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
if (holder == null) {
holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun);
//下面一段代码蕴含这一个线索,买个伏笔,先把他略去
if (holder != null) {
//检验ViewHolder有效性
if (!validateViewHolderForOffsetPosition(holder)) {
// recycle holder (and unscrap if relevant) since it can not be used
if (!dryRun) {
// we would like to recycle this but need to make sure it is not used by
// animation logic etc.
holder.addFlags(ViewHolder.FLAG_INVALID);
if (holder.isScrap()) {
removeDetachedView(holder.itemView, false);
holder.unScrap();
} else if (holder.wasReturnedFromScrap()) {
holder.clearReturnedFromScrapFlag();
}
//若不满足有效性检验,则回收ViewHolder
recycleViewHolderInternal(holder);
}
holder = null;
} else {
fromScrapOrHiddenOrCache = true;
}
}
}
...
}
复制代码

如果成功获得ViewHolder则检验其有效性,若检验失败则将其回收。好不容易获取了ViewHoler对象,一言不合就把他回收?难道对所有复用的 ViewHolder 都有这么严格的检验吗? 暂时无法回答这些疑问,还是先把复用逻辑看完吧:


第三次尝试


ViewHolder tryGetViewHolderForPositionByDeadline(int position,
boolean dryRun, long deadlineNs) {
...
//只有当Adapter设置了id,才会进行这次查找
if (mAdapter.hasStableIds()) {
holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition),type, dryRun);
if (holder != null) {
// update position
holder.mPosition = offsetPosition;
fromScrapOrHiddenOrCache = true;
}
}
...
}
复制代码

这一次尝试调用的函数名(“byId”)和上一次(“byPosition”)只是后缀不一样。上一次是通过表项位置,这一次是通过表项id。内部实现也几乎一样,判断的依据从表项位置变成表项id。为表项设置id属于特殊情况,先忽略。



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

源码篇:ThreadLocal的奇思妙想(万字图文)(二)

源码篇:ThreadLocal的奇思妙想(万字图文)(一)取index值上面代码中,用取得的hash值,与ThreadLocalMap实例中数组长度减一的与操作,计算出了index值这个很重要的,因为大于长度的高位hash值是不需要的此处会将传入的Thread...
继续阅读 »

源码篇:ThreadLocal的奇思妙想(万字图文)(一)


取index值

上面代码中,用取得的hash值,与ThreadLocalMap实例中数组长度减一的与操作,计算出了index值

这个很重要的,因为大于长度的高位hash值是不需要的

此处会将传入的ThreadLocal实例计算出一个hash值,怎么计算的后面再说,这地方有个位与的操作,这地方是和长度减一的与操作,这个很重要的,因为大于长度的高位hash值是不需要的

  • 假设hash值为:010110011101
  • 长度(此处选择默认值:16-1):01111
  • 看下图可知,这个与操作,可去掉高位无用的hash值,取到的index值可限制在数组长度中

hash值低位与操作

塞值

  • 看下塞值进入ThreadLocalMap数组的操作
    • 关于Key:因为Entry是继承的WeakReference类,get()方法是获取其内部弱引用对象,所以可以通过get()拿到当前ThreadLocal实例
    • 关于value:直接 .value 就OK了
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
...
}

分析下塞值流程

  • 实际上面的循环还值得去思考,来思考下这循环处理的事情

  • 循环中获取当前index值,从Entry数组取到当前index位置的Entry对象

  1. 如果获取的这Entry是null,则直接结束这个循环体
    • 在Entry数组的index塞入一个新生成的节点
  2. 如果获取的这Entry不为null
    1. key值相等,说明Entry对象存在,覆盖其value值即可
    2. key为null,说明该节点可被替换(替换算法后面讲),new一个Entry对象,在此节点存储数据
    3. 如果key不相等且不为null,循环获取下一节点的Entry对象,并重复上述逻辑

整体的逻辑比较清晰,如果key已存在,则覆盖;不存在,index位置是否可用,可用则使用该节点,不可用,往后寻找可用节点:线性探测法

  • 替换旧节点的逻辑,实在有点绕,下面单独提出来说明

map.set流程

替换算法

在上述set方法中,当生成的index节点,已被占用,会向后探测可用节点

  • 探测的节点为null,则会直接使用该节点
  • 探测的节点key值相同,则会覆盖value值
  • 探测的节点key值不相同,继续向后探测
  • 探测的节点key值为null,会执行一个替换旧节点的操作,逻辑有点绕,下面来分析下
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
...
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
...
}
  • 来看下replaceStaleEntry方法中的逻辑
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}

private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;

// Back up to check for prior stale entry in current run.
// We clean out whole runs at a time to avoid continual
// incremental rehashing due to garbage collector freeing
// up refs in bunches (i.e., whenever the collector runs).
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;

// Find either the key or trailing null slot of run, whichever
// occurs first
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();

// If we find key, then we need to swap it
// with the stale entry to maintain hash table order.
// The newly stale slot, or any other stale slot
// encountered above it, can then be sent to expungeStaleEntry
// to remove or rehash all of the other entries in run.
if (k == key) {
e.value = value;

tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// If we didn't find stale entry on backward scan, the
// first stale entry seen while scanning for key is the
// first still present in the run.
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// If key not found, put new entry in stale slot
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);

// If there are any other stale entries in run, expunge them
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
  • 上面的代码,很明显俩个循环是重点逻辑,这里面有俩个很重要的字段:slotToExpunge和staleSlot

    • staleSlot:记录传进来节点key为null的位置
    • slotToExpunge:标定是否需要执行最后的清理方法
  • 第一个循环:很明显是往前列表头结点方向探测,是否还有key为null的节点,有的话将其下标赋值给slotToExpunge;这个探测是一个连续的不为null的节点链范围,有空节点,立马结束循环

替换算法-前探测

  • 第二个循环:很明显主要是向后探测,探测整个数组,这里有很重要逻辑

    • 这地方已经开始有点绕了,我giao,大家要好好想想
    • 当探测的key和传入的需要设值的key相同时,会复写探测到Entry的value,然后将探测到位置和传入位置,俩者相互调换
  • 为什么会出现探测到Entry和传入key相同?

    • 相同是因为,存在到数组的时候,产生了hash冲突,会自动向后探测合适的位置存储
    • 当你第二次用ThreadLocal存值的时候,hash产生的index,比较俩者key,肯定是不可能相同,因为产生了hash冲突,真正储存Entry,在往后的位置;所以需要向后探测
    • 假设探测的时候,一直没有遇到key为null的Entry:正常循环的话,肯定是能探测到key相同的Entry,然后进行复写value的操作
    • 但是在探测的时候,遇到key为null的Entry的呢?此时就进入了替换旧Entry算法,所以替换算法就也有了一个向后探测的逻辑
    • 探测到相同key值的Entry,就说明了找到了我们需要复写value的Entry实例
  • 为什么要调换俩者位置呢?

    • 这个问题,大家可以好好想想,我们时候往后探测,而这key为null的Entry实例,属于较快的探测到Entry

    • 而这个Entry实例的key又为null,说明这个Entry可以被回收了,此时正处于占着茅坑不拉屎的位置

    • 此时就可以把我需要复写Entry实例和这个key为null的Entry调换位置

    • 可以使得我们需要被操作的Entry实例,在下次被操作时候,可以尽快被找到

  • 调换了位置之后,就会执行擦除旧节点算法

替换算法-后探测(需复写)

  • 上面是探查连续的Entry节点,未碰到null节点的情况;如果碰到null节点,会直接结束探测
    • 请注意,如果数组中,有需要复写value的节点;在计算的hash值处,向后探测的过程,一定不会碰到null节点
    • 毕竟,第一次向后探测可用节点是,碰到第一个null节点,就停下来使用了

替换算法-后探测(null节点)

  • 在第二个循环中,还有一段代码,比较有意思,这判断逻辑的作用是
    • 以key为null的Entry,以它为界限
    • 向前探测的时候,未碰到key为null的Entry
    • 而向后探测的时候,碰到的key为null的Entry
    • 然后改变slotToExpunge的值,使其和staleSlot不相等
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
...
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
...
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
...
}

替换算法-后探测(寻找key为null)

  • 可以看出来这俩个循环的操作,是有关联性,对此,我表示

img

为什么这俩个循环都这么执着的,想改变slotToExpunge的数值呢?

  • 来看下关于slotToExpunge的关键代码
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
...
int slotToExpunge = staleSlot;
...
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

明白了吧!都是为了替换方法里面的最后一段逻辑:为了判断是否需要执行擦除算法

总结

  • 双向探测流程

    • 替换算法会以传入的key为null的Entry节点为界限,在一个连续的Entry范围往俩边探测
      • 什么是连续的Entry范围?这边数组的节点都不能为null,碰到为null节点会结束探测
    • 先向前探测:如果碰到key为null的Entry,会将其下标赋值给slotToExpunge
    • 向后探测使:如果向前探测没有碰到key的节点,只要向后探测的时候碰到为null的节点,会将其下标赋值给slotToExpunge
    • 上面向俩边探测的逻辑,是为了:遇到key为null的节点,能确保slotToExpunge不等于staleSlot
  • 在向后探测的时候,如果遇到key值对比相同的Entry,说明遇到我们需要复写value的Entry

    • 此时复写value的Entry,用我们传入的value数值将其原来的value数值覆盖
    • 然后将传入key为null的Entry(通过传入的下标得知Entry)和需要复写value的Entry交换位置
    • 最后执行擦除算法
  • 如果在向后探测的时候,没有遇到遇到key值对比相同的Entry

    • 传入key为null的Entry,将其value赋值为null,断开引用
    • 创建一个新节点,放到此位置,key为传入当前ThreadLocal实例,value为传入的value数据
    • 然后根据lotToExpunge和staleSlot是否相等,来判断是否要执行擦除算法

总结

来总结下

  • 再来看下总流程

set总流程

  • 上面分析完了替换旧节点方法逻辑,终于可以把map.set的那块替换算法操作流程补起来了
    • 不管后续遇到null,还是遇到需要被复写value的Entry,这个key为null的Entry都将被替换掉

map.set流程(完善)

这俩个图示,大概描述了ThreadLocal进行set操作的整个流程;现在,进入下一个栏目吧,来看看ThreadLocal的get操作!

get

get流程,总体要比set流程简单很多,可以轻松一下了

总流程

  • 来看下代码
    • 总体流程非常简单,将自身作为key,传入map.getEntry方法,获取符合实例的Entry,然后拿到value,返回就行了
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
  • 如果通过map.getEntry获取的Entry为null,会返回setInitialValue(),来看下这个方法是干嘛的
    • 从这个方法可知,如果我们没有进行set操作,直接进行get操作,他会给ThreadLocal的threadLocals方法赋初值
    • setInitialValue() 方法,返回的是initialValue() 方法的数据,可知默认为null
    • 所以通过key没查到对应的Entry,get方法会返回null
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

protected T initialValue() {
return null;
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

map.getEntry

  • 从上面的代码可以看出来,getEntry方法是获取符合条件的节点
    • 这里逻辑很简单,通过当前ThreadLocal实例获取HashCode,然后算出index值
    • 直接获取当前index下标的Entry,将其key和当前ThreadLocal实例对比,看是否一样
    • 相同:说明没有发生Hash碰撞,可以直接使用
    • 不相同:说明发生了Hash碰撞,需要向后探测寻找,执行getEntryAfterMiss()方法
    • 此时,就需要来看看getEntryAfterMiss()方法逻辑了
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

getEntryAfterMiss

  • 来看下代码
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

整体逻辑还是很清晰了,通过while循环,不断获取Entry数组中的下一个节点,循环中有三个逻辑走向

  1. 当前节点的key等于当前ThreadLocal实例:直接返回这个节点的Entry
  2. 当前节点的key为null:执行擦除旧节点算法,继续循环
  3. 当前节点的可以不等于当前ThreadLocal实例且不为null:获取下一节点的下标,然后继续上面的逻辑
  • 如果没有获取到符合条件的Entry节点,会直接返回null

get流程-getEntryAfterMiss

总结

ThreadLocal的流程,总体上比较简单

  • 将当前ThreadLocal实例当为key,查找Entry数组当前节点(使用ThreadLocal中的魔术值算出的index)是否符合条件

  • 不符合条件将返回null

    • 从未进行过set操作
    • 未查到符合条件的key
  • 符合条件就直接返回当前节点

    • 如果遇到哈希冲突,算出的index值的Entry数组上存在Entry,但是key不相等,就向后查找
    • 如果遇到key为null的Entry,就执行擦除算法,然后继续往后寻找
    • 如果遇到key相当的Entry,就直接结束寻找,返回这个Entry节点
  • 这里大家一定要明确一个概念:在set的流程,发生了hash冲突,是在冲突节点向后的连续节点上,找到符合条件的节点存储,所以查询的时候,只需要在连续的节点上查找,如果碰到为null的节点,就可以直接结束查找

get流程

擦除算法

在set流程和get流程都使用了这个擦除旧节点的逻辑,它可以及时清除掉Entry数组中,那些key为null的Entry,如果key为null,说明这些这节点,已经没地方使用了,所以就需要清除掉

  • 来看看这个方法代码
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}

前置操作

从上面的代码,可以发现,再进行主要的循环体,有个前置操作

private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

...
}
  • 这地方做了很简单的置空操作,如果Entry节点的key为空,说明这个节点可以被清除,value置空,和数组的链接断开

擦除算法-前置操作

主体逻辑

  • 很明显,循环体里面的逻辑是最重要,而且循环体里面做了一个相当有趣的操作!
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
...
// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
  • 上面的循环体里面,就是不断的获取下一节点的Entry实例,然后判断key值进行相关处理
  • key为null:中规中矩的,将value置空,断开与数组的链接
  • key不为null:这时候就有意思了
    • 首先,会获取当前ThreadLocal实例的hash值,然后取得index值
    • 判断h(idnex值)和i是否相等,不相等进行下述操作,因为Entry数组是环形结构,是完成存在相等的情况
      1. 会将当前循环到节点置空,该节点的Entry记为e
      2. 从通过hreadLocal实例的hash值获取到index处,开始进行循环
      3. 循环到节点Entry为null,则结束循环
      4. 将e赋值给为null的节点
    • 这里面的逻辑就是关键了
  • 大家可能对这个文字的描述,感觉比较抽象,来个图,来体会下这短短几行代码的妙处

擦除算法-主体逻辑

总结

代码很少,但是实现的功能却并不少

  • 擦除旧节点的方法,在Entry上探测的时候
    • 遇到key为空的节点,会将该节点置空
    • 遇到key不为空的节点,会将该节点移到靠前位置(具体移动逻辑,请参考上述说明)
  • 交互到靠前节点位置,可以看出,主要的目的,是为了:
    • ThreadLocal实例计算出的index节点位置往后的位置,能让节点保持连续性
    • 也能让交换的节点,更快的被操作

扩容

在进行set操作的时候,会进行相关的扩容操作

  • 来看下扩容代码入口:resize方法便是扩容方法
public void set(T value) {
...
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

private void set(ThreadLocal<?> key, Object value) {
...
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

private void rehash() {
expungeStaleEntries();

// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
  • 来看下扩容代码
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

触发条件

先来看下扩容的触发条件吧

  • 整体代码
public void set(T value) {
...
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

private void set(ThreadLocal<?> key, Object value) {
...
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

private void rehash() {
expungeStaleEntries();

// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}

上面主要的代码就是:!cleanSomeSlots(i, sz) && sz >= threshold

  • 来看下threshold是什么
    • 只要Entry数组含有Entry实例大于等于数组的长度的三分之二,便能满足后一段判定
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

private void setThreshold(int len) {
threshold = len * 2 / 3;
}
  • 来看看前一段的判定,看下cleanSomeSlots,只要返回false,就能触发扩容方法了
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
i = nextIndex(i, len);
Entry e = tab[i];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}

n >>>= 1:表达是无符号右移一位,正数高位补0,负数高位补1

举例:0011 ---> 0001

在上面的cleanSomeSlots方法中,只要在探测节点的时候,没有遇到Entry的key为null的节点,该方法就会返回false

  • rehash方法就非常简单了
    • 执行擦除方法
    • 只要size(含有Entry实例数)长度大于等于3/4 threshold,就执行扩容操作
private void rehash() {
expungeStaleEntries();

// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}

总结

满足下面俩个条件即可

  1. Entry数组中不含key为null的Entry实例
  2. 数组中含有是实例数大于等于threshold的四分之三(threshold为数组长度的 三分之二)

扩容逻辑

private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}
  • 从上面的逻辑,可以看出来,将旧数组的数据赋值到扩容数组,并不是全盘赋值到扩容数组的对应位置

  • 遍历旧数组,取出其中的Entry实例

    • key为null:需要将该节点value置空,等待GC处理(Help the GC,hhhh)
      • 这里你可能有个疑问,不是说数组的节点key不为null,才会触发扩容机制吗?
      • 在多线程的环境里,执行扩容的时候,key的强引用断开,导致key被回收,从而key为null,这是完全存在的
    • key不为null:算出index值,向扩容数组中存储,如果该节点冲突,向后找到为null的节点,然后存储
  • 这里的扩容存储和ArrayList之类是有区别

扩容机制

总结

可以发现

  • set,替换,擦除,扩容,基本无时无刻,都是为了使hash冲突节点,向冲突的节点靠近

  • 这是为了提高读写节点的效率

remove

remove方法是非常简单的,ThreadLocal拥有三个api:set、get、remove;虽然非常简单,但是还有一些必要,来稍微了解下

  • remove代码
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}

private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

逻辑非常的清晰,通过ThreadLocal实例,获取当前的index,然后从此开始查找符合条件Entry,找到后,会将其key值清掉,然后执行擦除算法

e.clear就是,弱引用的清理弱引用的方法,很简单,将弱引用referent变量置空就行了,这个变量就是持有弱引用对象的变量

remove流程

最后

文章写到这里,基本上到了尾声了,写了差不多万余字,希望大家看完后,对ThreadLocal能有个更加深入的认识

ThreadLocal的源码虽然并不多,但是其中有很多奇思妙想,有种萝卜雕花的感觉,这就是高手写的代码吗?

img

系列文章

收起阅读 »

源码篇:ThreadLocal的奇思妙想(万字图文)(一)

前言 ThreadLocal的文章在网上也有不少,但是看了一些后,理解起来总感觉有绕,而且看了ThreadLocal的源码,无论是线程隔离、类环形数组、弱引用结构等等,实在是太有意思了!我必须也要让大家全面感受下其中所蕴含的那些奇思妙想! 所以这里我想写一篇...
继续阅读 »

前言


ThreadLocal的文章在网上也有不少,但是看了一些后,理解起来总感觉有绕,而且看了ThreadLocal的源码,无论是线程隔离、类环形数组、弱引用结构等等,实在是太有意思了!我必须也要让大家全面感受下其中所蕴含的那些奇思妙想! 所以这里我想写一篇超几儿通俗易懂解析ThreadLocal的文章,相关流程会使用大量图示解析,以证明:我是干货,不是水比!


ThreadLocal这个类加上庞大的注释,总共也才七百多行,而且你把这个类的代码拷贝出来,你会发现,它几乎没有报错!耦合度极低!(唯一的报错是因为ThreadLocal类引用了Thread类里的一个包内可见变量,所以把代码复制出来,这个变量访问就报错了,仅仅只有此处报错!)


ThreadLocal的线程数据隔离,替换算法,擦除算法,都是有必要去了解了解,仅仅少量的代码,却能实现如此精妙的功能,让我们来体会下 Josh Bloch 和 Doug Lea 俩位大神,巧夺天工之作吧!



一些说明


这篇文章画了不少图,大概画了十八张图,关于替换算法和擦除算法,这俩个方法所做的事情,如果不画图,光用文字描述的话,十分的抽象且很难理解;希望这些流程图,能让大家更能体会这些精炼代码的魅力!



image-20210506091320057


使用


哔哔原理之前,必须要先来看下使用



  • 使用起来出奇的简单,仅仅使用set()get()方法即可


public class Main {

public static void main(String[] args) {
ThreadLocal<String> threadLocalOne = new ThreadLocal<>();
ThreadLocal<String> threadLocalTwo = new ThreadLocal<>();

new Thread(new Runnable() {
@Override
public void run() {
threadLocalOne.set("线程一的数据 --- threadLocalOne");
threadLocalTwo.set("线程一的数据 --- threadLocalTwo");
System.out.println(threadLocalOne.get());
System.out.println(threadLocalTwo.get());
}
}).start();

new Thread(new Runnable() {
@Override
public void run() {
System.out.println(threadLocalOne.get());
System.out.println(threadLocalTwo.get());
threadLocalOne.set("线程二的数据 --- threadLocalOne");
threadLocalTwo.set("线程二的数据 --- threadLocalTwo");
System.out.println(threadLocalOne.get());
System.out.println(threadLocalTwo.get());
}
}).start();
}
}


  • 打印结果

    • 一般来说,我们在主存(或称工作线程)创建一个变量;在子线程中修改了该变量数据,子线程结束的时候,会将修改的数据同步到主存的该变量上

    • 但是,在此处,可以发现,俩个线程都使用同一个变量,但是在线程一里面设置的数据,完全没影响到线程二

    • cool!简单易用,还实现了数据隔离与不同的线程



线程一的数据 --- threadLocalOne
线程一的数据 --- threadLocalTwo
null
null
线程二的数据 --- threadLocalOne
线程二的数据 --- threadLocalTwo

前置知识


在解释ThreadLocal整体逻辑前,需要先了解几个前置知识


下面这些前置知识,是在说set和get前,必须要先了解的知识点,了解下面这些知识点,才能更好去了解整个存取流程


线程隔离


在上面的ThreadLocal的使用中,我们发现一个很有趣的事情,ThreadLocal在不同的线程,好像能够存储不同的数据:就好像ThreadLocal本身具有存储功能,到了不同线程,能够生成不同的'副本'存储数据一样


实际上,ThreadLocal到底是怎么做到的呢?



  • 来看下set()方法,看看到底怎么存数据的:此处涉及到ThreadLocalMap类型,暂且把他当成Map,详细的后面栏目分析

    • 其实这地方做了一个很有意思的操作:线程数据隔离的操作,是Thread类和ThreadLocal类相互配合做到的

    • 在下面的代码中可以看出来,在塞数据的时候,会获取执行该操作的当前线程

    • 拿到当前线程,取到threadLocals变量,然后仿佛以当前实例为key,数据value的形式往这个map里面塞值(有区别,set栏目再详细说)

    • 所以使用ThreadLocal在不同的线程中进行写操作,实际上数据都是绑定在当前线程的实例上,ThreadLocal只负责读写操作,并不负责保存数据,这就解释了,为什么ThreadLocal的set数据,只在操作的线程中有用

    • 大家有没有感觉这种思路有些巧妙!



//存数据
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocal.ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

//获取当前Thread的threadLocals变量
ThreadLocal.ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

//Thread类
public class Thread implements Runnable {
...

/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */

ThreadLocal.ThreadLocalMap threadLocals = null;

...
}


  • 来看下图示

    • 图上只花了了一个ThreadLocal,想多花几个,然后线交叉了,晕

    • threadLocals是可以存储多个ThreadLocal,多个存取流程同理如下



线程隔离



  • 总结下:通过上面的很简单的代码,就实现了线程的数据隔离,也能得到几点结论

    • ThreadLocal对象本身是不储存数据的,它本身的职能是执行相关的set、get之类的操作

    • 当前线程的实例,负责存储ThreadLocal的set操作传入的数据,其数据和当前线程的实例绑定

    • 一个ThreadLocal实例,在一个线程中只能储存一类数据,后期的set操作,会覆盖之前set的数据

    • 线程中threadLocals是数组结构,能储存多个不同ThreadLocal实例set的数据



Entry



  • 说到Entry,需要先知道下四大引用的基础知识



强引用:不管内存多么紧张,gc永不回收强引用的对象


软引用:当内存不足,gc对软引用对象进行回收


弱引用:gc发现弱引用,就会立刻回收弱引用对象


虚引用:在任何时候都可能被垃圾回收器回收



Entry就是一个实体类,这个实体类有俩个属性:key、value,key是就是咱们常说的的弱引用


当我们执行ThreadLocal的set操作,第一次则新建一个Entry或后续set则覆盖改Entry的value,塞到当前Thread的ThreadLocals变量中



  • 来看下Entry代码

    • 此处key取得是ThreadLocal自身的实例,可以看出来Entry持有的key属性,属于弱引用属性

    • value就是我们传入的数据:类型取决于我们定义的泛型



static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}


  • Entry有个比较巧妙的结构,继承弱引用类,然后自身内部又定义了一个强引用属性,使得该类有一强一弱的属性

  • 结构图


Entry结构


你可能会想,what?我用ThreadLocal来set一个数据,然后gc一下,我Entry里面key变量引用链就断开了?


img



  • 来试一下


public class Main {

public static void main(String[] args) {
ThreadLocal<String> threadLocalOne = new ThreadLocal<>();

new Thread(new Runnable() {
@Override
public void run() {
threadLocalOne.set("线程一的数据 --- threadLocalOne");
System.gc();
System.out.println(threadLocalOne.get());
}
}).start();
}
}


  • 结果


线程一的数据 --- threadLocalOne


看来这里gc了个寂寞。。。


在这里,必须明确一个道理:gc回收弱引用对象,是先回收弱引用的对象,弱引用链自然断开;而不是先断开引用链,再回收对象。Entry里面key对ThreadLocal的引用是弱引用,但是threadLocalOne对ThreadLocal的引用是强引用啊,所以ThreadLocal这个对象是没法被回收的




  • 来看下上面代码真正的引用关系


Entry的key值引用链



  • 此处可以演示下,threadLocalOne对ThreadLocal的引用链断开,Entry里面key引用被gc回收的情况


public class Main {
static ThreadLocal<String> threadLocalOne = new ThreadLocal<>();

public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
threadLocalOne.set("线程一的数据 --- threadLocalOne");
try {
threadLocalOne = null;
System.gc();

//下面代码来自:https://blog.csdn.net/thewindkee/article/details/103726942
Thread t = Thread.currentThread();
Class<? extends Thread> clz = t.getClass();
Field field = clz.getDeclaredField("threadLocals");
field.setAccessible(true);
Object threadLocalMap = field.get(t);
Class<?> tlmClass = threadLocalMap.getClass();
Field tableField = tlmClass.getDeclaredField("table");
tableField.setAccessible(true);
Object[] arr = (Object[]) tableField.get(threadLocalMap);
for (Object o : arr) {
if (o == null) continue;
Class<?> entryClass = o.getClass();
Field valueField = entryClass.getDeclaredField("value");
Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField("referent");
valueField.setAccessible(true);
referenceField.setAccessible(true);
System.out.println(String.format("弱引用key:%s 值:%s", referenceField.get(o), valueField.get(o)));
}
} catch (Exception e) { }
}
}).start();
}
}


  • 结果

    • key为null了!上面有行代码:threadLocalOne = null,这个就是断开了对ThreadLocal对象的强引用

    • 大家如果有兴趣的话,可以把threadLocalOne = null去掉,再运行的话,会发现,key不会为空

    • 反射代码的功能就是取到Thread中threadLocals变量,循环取其中的Entry,打印Entry的key、value值



弱引用key:null    值:线程一的数据 --- threadLocalOne
弱引用key:java.lang.ThreadLocal@387567b2 值:java.lang.ref.SoftReference@2021fb3f


  • 总结

    • 大家心里可能会想,这变量一直持有强引用,key那个弱引用可有可无啊,而且子线程代码执行时间一般也不长

    • 其实不然,我们可以想想Android app里面的主线程,就是一个死循环,以事件为驱动,里面可以搞巨多巨难的逻辑,这个强引用的变量被赋其它值就很可能了

      • 如果key是强引用,那么这个Entry里面的ThreadLocal基本就很难被回收

      • key为弱引用,当ThreadLocal对象强引用链断开后,其很容易被回收了,相关清除算法,也能很容易清理key为null的Entry


    • 一个弱引用都能玩出这么多花样



img


ThreadLocalMap环形结构



  • 咱们来看下ThreadLocalMap代码

    • 先去掉一堆暂时没必要关注的代码

    • table就是ThreadLocalMap的主要结构了,数据都存在这个数组里面

    • 所以说,ThreadLocalMap的主体结构就是一个Entry类型的数组



public class ThreadLocal<T> {

...

static class ThreadLocalMap {

static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

/**
* The table, resized as necessary.
* table.length MUST always be a power of two.
*/

private Entry[] table;

...
}
}



  • 在此处你可能又有疑问了,这东西不就是一个数组吗?怎么和环形结构扯上关系了?


img



  • 数组正常情况下确实是下面的这种结构


UML时序图



  • 但是,ThreadLocalMap类里面,有个方法做了一个骚操作,看下代码


public class ThreadLocal<T> {

...

static class ThreadLocalMap {
...

private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

...
}
}


  • 这个nextIndex方法,大家看懂了没?

    • 它的主要作用,就是将传入index值加一

    • 但是!当index值长度超过数组长度后,会直接返回0,又回到了数组头部,这就完成了一个环形结构



Entry结构变形



  • 总结

    • 这样做有个很大的好处,能够大大的节省内存的开销,能够充分的利用数组的空间

    • 取数据的时候会降低一些效率,时间置换空间



set


总流程



  • 塞数据的操作,来看下这个set操作的代码:下面的代码,逻辑还是很简单的

    1. 获取当前线程实例

    2. 获取当前线程中的threadLocals实例

    3. threadLocals不为空执行塞值操作

    4. threadLocals为空,new一个ThreadLocalMap赋值给threadLocals,同时塞入一个Entry



public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}


  • 需要注意的是,ThreadLocalMap生成Entry数组,设置了一个默认长度,默认为:16


 private static final int INITIAL_CAPACITY = 16;

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
...
}


  • 流程图


set总流程


map.set



  • 上面说了一些细枝末节,现在来说说最重要的map.set(this, value) 方法


private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

取哈希值



  • 上面代码有个计算哈希值的操作

    • key.threadLocalHashCode这行代码上来看,就好像将自身的实例计算hash值

    • 其实看了完整的代码,发现传入key,只不过是为了调用nextHashCode方法,用它来计算哈希值,并不是将当前ThreadLocal对象转化成hash值



public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();

private static final int HASH_INCREMENT = 0x61c88647;

private static AtomicInteger nextHashCode = new AtomicInteger();

private void set(ThreadLocal<?> key, Object value) {
...
int i = key.threadLocalHashCode & (len-1);
...
}

private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
}


  • 这地方用了一个原子类的操作,来看下getAndAdd() 方法的作用

    • 这就是个相加的功能,相加后返回原来的旧值,保证相加的操作是个原子性不可分割的操作



public class Main {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger();

System.out.println(atomicInteger.getAndAdd(1)); //0
System.out.println(atomicInteger.getAndAdd(1)); //1
System.out.println(atomicInteger.getAndAdd(1)); //2
}
}


  • HASH_INCREMENT = 0x61c88647,为什么偏偏将将0x61c88647这个十六进制相加呢,为什么不能是1,2,3,4,5,6呢?


该值的相加,符合斐波那契散列法,可以使得的低位的二进制数值分布的更加均匀,这样会减少在数组中产生hash冲突的次数


具体分析可查看:从 ThreadLocal 的实现看散列算法



等等大家有没有看到 threadLocalHashCode = nextHashCode(),nextHashCode()是获取下一个节点的方法啊,这是什么鬼?


难道每次使用key.threadLocalHashCode的时候,HashCode都会变?




  • 看下完整的赋值语句

    • 这是在初始化变量的时候,就直接定义赋值的

    • 说明实例化该类的时候,nextHashCode()获取一次HashCode之后,就不会再次获取了

    • 加上用的final修饰,仅能赋值一次

    • 所以threadLocalHashCode变量,在实例化ThreadLocal的时候,获取HashCode一次,该数值就定下来了,在该实例中就不会再变动了



public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();
}


好像又发现一个问题!threadHashCode通过 nextHashCode() 获取HashCode,然后nextHashCode是使用AtomicInteger类型的 nextHashCode变量相加,这玩意每次实例化的时候不都会归零吗?


难道我们每次新的ThreadLocal实例获取HashCode的时候,都要从0开始相加?




  • 来看下完整代码

    • 大家看下AtomicInteger类型的nextHashCode变量,他的修饰关键字是static

    • 这说明该变量的数值,是和这个类绑定的,和这个类生成的实例无关,而且从始至终,只会实例化一次

    • 当不同的ThreadLocal实例调用nextHashCode,他的数值就会相加一次

    • 而且每个实例只能调用一次nextHashCode()方法,nextHashCode数值会很均匀的变化



public class ThreadLocal<T> {
private final int threadLocalHashCode = nextHashCode();

private static final int HASH_INCREMENT = 0x61c88647;

private static AtomicInteger nextHashCode = new AtomicInteger();

private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
}


总结




  • 通过寥寥数行的初始化,几个关键字,就能形成在不同实例中,都能稳步变化的HashCode数值

  • 这些基础知识大家或许都知道,又有多少能这样信手拈来呢?


img

收起阅读 »

View嵌套太深会卡?来用JetpackCompose,随便套——IntrinsicMeasurement

视频先行 如果你方便看视频,直接去 哔哩哔哩 或者 YouTube 看视频就好,下面的文章就不用看了。如果你不方便看视频,下面是视频内容的脚本整理稿。 开场 做 Android 开发的都知道一个规矩:布局文件的界面层级要尽量地少,越少越好,因为层级的增加...
继续阅读 »

视频先行



如果你方便看视频,直接去 哔哩哔哩 或者 YouTube 看视频就好,下面的文章就不用看了。如果你不方便看视频,下面是视频内容的脚本整理稿。



开场


做 Android 开发的都知道一个规矩:布局文件的界面层级要尽量地少,越少越好,因为层级的增加会大幅拖慢界面的加载。这种拖慢的主要原因就在于各种 Layout 的重复测量。虽然重复测量对于布局过程是必不可少的,但这也确实让界面层级的数量对加载时间的影响变成了指数级。而 Jetpack Compose 是不怕层级嵌套的,因为它从根源上解决了这种问题。它解决的方式也非常巧妙而简单——它不许重复测量。


……嗯?



View 层数和界面加载性能的关系


大家好,我是扔物线朱凯。


在定制 ViewGroup 的布局过程的时候,我们需要重写两个方法: onMeasure() 用来测量子 View,onLayout() 用来摆放测量好的子 View。测量和摆放明明是连续的过程,为什么要拆成两步呢?因为我们在 ViewGroup 里可能会对子 View 进行多次测量。


比如一个纵向的 LinearLayout,当它的宽度被设置成了 wrap_content 的时候:


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">

...
LinearLayout>

它会依次测量自己所有的子 View,然后把它们最宽的那个的宽度作为自己最终的宽度。


但……如果它内部有一个子 View 的宽度是 match_parent


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">


<View
android:layout_width="match_parent"
android:layout_height="48dp" />


<View
android:layout_width="120dp"
android:layout_height="48dp" />


<View
android:layout_width="160dp"
android:layout_height="48dp" />

LinearLayout>



这时候, LinearLayout 就会先以 0 为强制宽度测量一下这个子 View,并正常地测量剩下的其他子 View,然后再用其他子 View 里最宽的那个的宽度,二次测量这个 match_parent 的子 View,最终得出它的尺寸,并把这个宽度作为自己最终的宽度。




这是对单个子 View 的二次测量,如果有多个子 View 写了 match_parent ,那就需要对它们每一个都进行二次测量。




而如果所有的子 View 全都是 match_parent——哎呀跑题了。总之,在 Android 里,一个 ViewGroup 是可能对子 View 进行二次测量的。不只是二次,有时候还会出现三次甚至更多次的测量。而且这不是特殊场景,重复测量在 Android 里是很常见的。


重复测量是 ViewGroup 实现正确测量所必需的手段,但同时也让我们需要非常注意尽量减少布局的层级。


为什么呢?来看一个最简单的例子,如果我们的布局有两层,其中父 View 会对每个子 View 做二次测量,那它的每个子 View 一共需要被测量 2 次:



如果增加到三层,并且每个父 View 依然都做二次测量,这时候最下面的子 View 被测量的次数就直接翻倍了,变成 4 次:



同理,增加到 4 层的话会再次翻倍,子 View 需要被测量 8 次:



也就是说,对于会做二次测量的系统,每个 View 的测量算法的时间复杂度是 O(2?) ,其中这个 n 是 View 的层级深度。


当然了,现实中并不是每个父 View 都会进行二次测量,以及有些父 View 会对子 View 做三次或者更多次的测量,所以这只是一个粗略估计,不过——大致就是这个数量级了。


而 O(2?) 这种指数型的时间复杂度,说白了就是,View 的层级每增加 1,加载时间就会翻一倍。


所以为什么 Android 官方文档会建议我们的布局文件少一些层级?因为它对性能的影响太大了!


Compose 的 Intrinsic Measurement


而 Compose 是禁止二次测量的。


如果每个父组件对每个子组件只测量一次,那就直接意味着界面中的每个组件只会被测量一次:



这样的话,就把组件加载的时间复杂度从 O(2?) 降到了 O(n)。


不过……如果禁用二次测量这么好用的话,Android 干嘛不在传统的 View 系统直接禁掉?——因为它有用啊!


那 Compose 禁用了二次测量,它就不用了吗?


这就是 Compose 巧妙的地方了:Compose 禁用了二次测量,但加入了一个新东西:Intrinsic Measurement,官方把它翻译做「固有特性测量」。


这个「固有特性测量」,你要说翻译得不对吧,其实字面上已经非常精确了,但这么翻却又完全没抓住这个功能的灵魂。


所谓的 Intrinsic Measurement,指的是 Compose 允许父组件在对子组件进行测量之前,先测量一下子组件的「固有尺寸」,直白地说就是「你内部内容的最大或者最小尺寸是多少」。这是一种粗略的测量,虽说没有真正的「二次测量」模式那么自由,但功能并不弱,因为各种 Layout 里的重复测量,其实本来就是先进行这种「粗略测量」再进行最终的「正式测量」的——比如刚才说的那种「外面 wrap_content 里面 match_parent」的,对吧?想想是不是?这种「粗略」的测量是很轻的,并不是因为它量得快,而是因为它在机制上不会像传统的二次测量那样,让组件的测量时间随着层级的加深而不断加倍。


当界面需要这种 Intrinsic Measurement——也就是说那个所谓的「固有特性测量」——的时候,Compose 会先对整个组件树进行一次 Intrinsic 测量,然后再对整体进行正式的测量。这样开辟两个平行的测量过程,就可以避免因为层级增加而对同一个子组件反复测量所导致的测量时间的不断加倍了。



总结成一句话就是,在 Compose 里疯狂嵌套地写界面,和把所有组件全都写进同一层里面,性能是一样的!


这……还怕嵌套?


刚才那个「固有特性测量」的翻译,我为什么觉得没有灵魂呢,主要是那个「固有特性」指的其实就是「固有尺寸」,也就是这个组件它自身的宽度和高度。而翻译成「固有特性测量」就有点太直了,直到反而让含义有点扭曲了。不过无伤大雅啊,不管是「固有尺寸测量」还是「固有特性测量」,这个设计真的很好,它让 Compose 逃过了 Android 原生 View 系统里的一个性能陷阱。


事实上,你用一用 Compose 也会发现,它的性能已经在一些方面超越原生了——尤其是对于复杂场景,比如多组件共同参与的动画。不过目前为止,还只是一些方面而已,并没有全方位超越。比如滑动列表的性能,Compose 目前是不如原生的 RecyclerView 的。现在 Compose 的正式版发布已经越来越近了,而且从发布日志来看,目前 Compose 的开发重心还在 API 完整性的填补和 Bug 修复上,所以到了正式发布那天能不能看到 Compose 全方位超越原生的性能,我是有点怀疑的。不过从原理上看,就算发布的时候不行,未来应该是有可能的。


总结


如果你做 Android 开发,Compose 真的是时候了解一下了。我以后还会发布更多关于 Compose 以及 Android 开发相关的内容,所以关注我吧,没错的!如果你想快速成为 Compose 高手,也可以了解一下我的 Compose 课程,我的同名公众号「扔物线」里面有免费试听课。



好了今天的内容就到这里,我是扔物线,我不和你比高低,我只助你成长,我们下期见。

收起阅读 »

快速上手Flutter空安全

学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,欢迎关注,共同进步。 欢迎搜索公众号:进击的Flutter或者runflutter 里...
继续阅读 »

学习最忌盲目,无计划,零碎的知识点无法串成系统。学到哪,忘到哪,面试想不起来。这里我整理了Flutter面试中最常问以及Flutter framework中最核心的几块知识,欢迎关注,共同进步。image.png 欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,探讨你的问题,获取我的最新文章~



导语


在 Flutter 2.0 中,一项重要的升级就是 Dart 支持 空安全Alex 为我们贴心地翻译了多篇关于空安全的文章 :迁移指南深入理解空安全 等,通过 迁移指南 我也将 fps_monitor 迁移空安全。但在对项目适配后,日常开发中我们该怎么使用?空安全究竟是什么?下面我们通过几个练习来快速上手 Flutter 空安全。




一、空安全解决了什么问题?


要想弄明白空安全是什么,我们先要知道空安全帮我们解决了什么?


先来看个例子


void main() {
String stringNullException;
print(stringNullException.length);
}

在适配空安全之前,这段代码在 在编译阶段不会有任何提示。但显然这是一段有问题的代码。在 Debug 模式下会抛出空异常,屏幕爆红提示。


I/flutter (31305): When the exception was thrown, this was the stack:
I/flutter (31305): #0 Object.noSuchMethod (dart:core-patch/object_patch.dart:53:5)

在 release 模式下,这个异常会让整个屏幕变成灰色。


这是一个典型的例子,stringNullException 在没有赋值的情况下是空的,但是却我们调用了 .length 方法,导致程序异常。


同样的代码在适配空安全之后,在编译期便给出了报错提示,开发者可以及时进行修复。


image.png


所以简单的来说,空安全在代码编辑阶段帮助我们提前发现可能出现的空异常问题,但这并不意味着程序不会出现空异常




二、如何使用空安全?


那么空安全包含哪些内容,我们在日常开发的时候该如何使用?下面我们通过 Null safety codelab 中的几个练习来进行学习。


1、非空类型和可空类型


在空安全中,所有类型在默认情况下都是非空的。例如,你有一个 String 类型的变量,那么它应该总是包含一个字符串。


如果你想要一个 String 类型的变量接受任何字符串或者 null,通过在类型名称后添加一个问号(?)表示该变量可以为空。例如,一个类型为 String? 可以包含任何字符串,也可以为空。


练习 A:非空类型和可空类型


void main() {
int a;
a = null; // 提示错误,因为 int a 表示 a 不能为空
print('a is $a.');
}

这段代码通过 int 声明了变量 a 是一个非空变量,在执行 a = null 的时候报错。可以修改为 int? 类型,允许 a 为空:


void main() {
int? a; // 表示允许 a 为空
a = null;
print('a is $a.');
}

练习 B:泛型的可空类型


void main() {
List<String> aListOfStrings = ['one', 'two', 'three'];
List<String> aNullableListOfStrings = [];
// 报错提示,因为泛型 String 表示非 null
List<String> aListOfNullableStrings = ['one', null, 'three'];

print('aListOfStrings is $aListOfStrings.');
print('aNullableListOfStrings is $aNullableListOfStrings.');
print('aListOfNullableStrings is $aListOfNullableStrings.');
}

在这个练习中,因为 aListOfNullableStrings 变量的类型是 List<String> ,表示非空的 String 数组,但在后面创建过程中却提供了一个 null 元素,引起报错。因此可以将 null 改成其他字符串,或者在泛型中表示为可空的字符串。


void main() {
List<String> aListOfStrings = ['one', 'two', 'three'];
List<String> aNullableListOfStrings = [];
// 数组元素允许为空,所以不再报错
List<String?> aListOfNullableStrings = ['one', null, 'three'];

print('aListOfStrings is $aListOfStrings.');
print('aNullableListOfStrings is $aNullableListOfStrings.');
print('aListOfNullableStrings is $aListOfNullableStrings.');
}

2、空断言操作符(!)


如果确定某个 可为空的表达式 非空,可以使用空断言操作符 ! 使 Dart 将其视为非空。通过添加 ! 在表达式之后,可以将其赋值给一个非空变量。


练习 A:空断言


/// 这个方法的返回值可能为空
int? couldReturnNullButDoesnt() => -3;

void main() {
int? couldBeNullButIsnt = 1;
List<int?> listThatCouldHoldNulls = [2, null, 4];

// couldBeNullButIsnt 变量虽然可为空,但是已经赋予初始值,因此不会报错
int a = couldBeNullButIsnt;
// 列表泛型中声明元素可为空,与 int b 类型不匹配报错
int b = listThatCouldHoldNulls.first; // first item in the list
// 上面声明这个方法可能返回空,而 int c 表示非空,所以报错
int c = couldReturnNullButDoesnt().abs(); // absolute value

print('a is $a.');
print('b is $b.');
print('c is $c.');
}

在这个练习中,方法 couldReturnNullButDoesnt 和数组 listThatCouldHoldNulls 都通过可空类型进行声明,但是后面的变量 b 和 c,都是通过非空类型来声明,因此报错。可以在表达式最后加上 ! 表示操作非空(你必须确认这个表达式是一定不会为空,否则仍然可能引起空指针异常)修改如下:


int? couldReturnNullButDoesnt() => -3;

void main() {
int? couldBeNullButIsnt = 1;
List<int?> listThatCouldHoldNulls = [2, null, 4];

int a = couldBeNullButIsnt;
// 添加 ! 断言 表示非空,赋值成功
int b = listThatCouldHoldNulls.first!; // first item in the list
int c = couldReturnNullButDoesnt()!.abs(); // absolute value

print('a is $a.');
print('b is $b.');
print('c is $c.');
}

3、类型提升


Dart 的 流程分析 中已经扩展到考虑零值性。不可能为空的可空变量会被视为非空变量,这种行为称为类型提升


bool isEmptyList(Object object) {
if (object is! List) return false;
// 在空安全之前会报错,因为 Object 对象并不包含 isEmpty 方法
// 在空安全后不报错,因为流程分析会根据上面的判断语句将 object 变量提升为 List 类型。
return object.isEmpty;
}

这段代码在空安全之前会报错,因为 object 变量是 Object 类型,并不包含 isEmpty 方法。


在空安全后不会报错,因为流程分析会根据上面的判断语句将 object 变量提升为 List 类型。


练习 A:明确地赋值


void main() {
String? text;
//if (DateTime.now().hour < 12) {
// text = "It's morning! Let's make aloo paratha!";
//} else {
// text = "It's afternoon! Let's make biryani!";
//}
print(text);
// 报错提示,text 变量可能为空
print(text.length);
}

这段代码中我们使用 String? 声明了一个可空的变量 text,在后面直接使用了 text.length。Dart 会认为这是不安全的,因此报错提示。


但当我们去掉上面注释的代码后,将不会在报错。因为 Dart 对 text 赋值的地方判断后,认为 text 不会为空,将 text 提升为非空类型(String),不再报错。


练习 B:空检查


int getLength(String? str) {
// 此处报错,因为 str 可能为空
return str.length;
}

void main() {
print(getLength('This is a string!'));
}

这个例子中,因为 str 可能为空,所以使用 str.length 会提示错误,通过类型提升我们可以这样修改:


int getLength(String? str) {
// 判断 str 为空的场景 str 提升为非空类型
if (str == null) return 0;
return str.length;
}

void main() {
print(getLength('This is a string!'));
}

提前判断 str 为空的场景,这样后面 str 的类型由 String?(可空)提升为 String(非空),不再报错。


3、late 关键字


有时变量(例如:类中的字段或顶级变量)应该是非空的,但不能立即给它们赋值。对于这种情况,使用 late 关键字。


当你把 late 放在变量声明的前面时,会告诉 Dart 以下信息:



  • 先不要给变量赋值。

  • 稍后将为它赋值

  • 你会在使用前对这个变量赋值。

  • 如果在给变量赋值之前读取该变量,则会抛出一个错误。


练习 A:使用 late


class Meal {
// description 变量没有直接或者在构造函数中赋予初始值,报错
String description;

void setDescription(String str) {
description = str;
}
}
void main() {
final myMeal = Meal();
myMeal.setDescription('Feijoada!');
print(myMeal.description);
}

这个例子中,Meal 类包含一个非空变量 description,但该变量却没有直接或者在构造函数中赋予初始值,因此报错。这种情况下,我们可以使用 late 关键字 表示这个变量是延迟声明:


class Meal {
// late 声明不在报错
late String description;
void setDescription(String str) {
description = str;
}
}
void main() {
final myMeal = Meal();
myMeal.setDescription('Feijoada!');
print(myMeal.description);
}

练习 B:循环引用下使用 late


class Team {
// 非空变量没有初始值,报错
final Coach coach;
}

class Coach {
// 非空变量没有初始值,报错
final Team team;
}

void main() {
final myTeam = Team();
final myCoach = Coach();
myTeam.coach = myCoach;
myCoach.team = myTeam;

print('All done!');
}

通过添加 late 关键字解决报错。注意,我们不需要删除 final。late final 声明的变量表示:只需设置它们的值一次,然后它们就成为只读变量


class Team {
late final Coach coach;
}

class Coach {
late final Team team;
}

void main() {
final myTeam = Team();
final myCoach = Coach();
myTeam.coach = myCoach;
myCoach.team = myTeam;
print('All done!');
}

练习 C:late 关键字和懒加载


int _computeValue() {
print('In _computeValue...');
return 3;
}

class CachedValueProvider {
final _cache = _computeValue();
int get value => _cache;
}

void main() {
print('Calling constructor...');
var provider = CachedValueProvider();
print('Getting value...');
print('The value is ${provider.value}!');
}

这个练习并不会报错,不过可以看看运行这段代码的输出结果:


Calling constructor...
In _computeValue...
Getting value...
The value is 3!

在打印完第一句 Calling constructor... 之后,生成 CachedValueProvider() 对象。生成过程会初始化它的变量 final _cache = _computeValue() 所以打印第二句话 In _computeValue...,再打印后续的语句。


当我们对 _cache 变量添加 late 关键字后,结果又如何?


int _computeValue() {
print('In _computeValue...');
return 3;
}

class CachedValueProvider {
// late 关键字,该变量不会在构造的时候初始化
late final _cache = _computeValue();
int get value => _cache;
}

void main() {
print('Calling constructor...');
var provider = CachedValueProvider();
print('Getting value...');
print('The value is ${provider.value}!');
}

日志如下:


Calling constructor...
Getting value...
In _computeValue...
The value is 3!

日志中In _computeValue... 的执行被延后了,其实就是 _cache 变量没有在构造的时候初始化,而是延迟到了使用的时候。




四、空安全并不意味没有空异常


这几个练习,也更加的反应了安全的作用:空安全在代码编辑阶段帮助我们提前发现可能出现的空异常问题。但要注意,这并不意味着不存在空异常。例如下面的例子


void main() {
String? text;
print(text);
// 不会报错,因为使用 ! 断言 表示 text 变量不可能为空
print(text!.length);
}

因为 text!.length 表示变量 text 不可能为空。但实际上 text 可能因为各种原因(例如,json 解析为 null)为空,导致程序异常。


上面 late 关键字的场景同样也会存在:


class Meal {
// late 声明编辑阶段将不会报错
late String description;
void setDescription(String str) {
description = str;
}
}
void main() {
final myMeal = Meal();
// 先去读取这个未初始化变量,导致异常
print(myMeal.description);
myMeal.setDescription('Feijoada!');
}

我们在对 description 赋值之前提前读取,同样会导致程序异常。


所以还是那句话:空安全只是在代码编辑阶段帮助我们提前发现可能出现的空异常问题,但这并不意味着程序不会出现空异常。开发者任需要对代码进行完善的边界判断,确保程序的健壮运行!


看到这儿给大家留个作业,如何在空安全下写工厂单例,欢迎在评论区留下你的答案,我会在下周公布答案~。


如果你还想了解更多关于空安全的文章,推荐:





五、最后 感谢各位吴彦祖和彭于晏的点赞和关注


感谢 Alex 在空安全文档上的贡献。


image.png


我近期也将翻译:Null safety codelab 欢迎关注。


如果你对 Flutter 其他内容感兴趣,推荐阅读往期精彩文章:


ListView流畅度翻倍!!Flutter卡顿分析和通用优化方案 将在本月内进行开源,欢迎关注


Widget、Element、Render树究竟是如何形成的?


ListView的构建过程与性能问题分析


深度分析·不同版本中的 Flutter 生命周期差异


欢迎搜索公众号:进击的Flutter或者runflutter 里面整理收集了最详细的Flutter进阶与优化指南。关注我,探讨你的问题,获取我的最新文章~

收起阅读 »

RecyclerView的滚动是怎么实现的?解锁阅读源码新姿势

RecyclerView 是一个展示列表的控件,其中的子控件可以被滚动。这是怎么实现的?以走查源码的方式一探究竟。 切入点:滚动事件 阅读源码时,如何在浩瀚的源码中选择合适的切入点很重要,选好了能少走弯路。 对于滚动这个场景,最显而易见的切入点是触摸事件...
继续阅读 »

RecyclerView 是一个展示列表的控件,其中的子控件可以被滚动。这是怎么实现的?以走查源码的方式一探究竟。


切入点:滚动事件


阅读源码时,如何在浩瀚的源码中选择合适的切入点很重要,选好了能少走弯路。


对于滚动这个场景,最显而易见的切入点是触摸事件,即手指在 RecyclerView 上滑动,列表跟手滚动。


就以RecyclerView.OnTouchEvent()为切入点。手指滑动,列表随之而动的逻辑应该在ACTION_MOVE中,其源码如下(略长可跳过):


public class RecyclerView {
@Override
public boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_MOVE: {
final int index = e.findPointerIndex(mScrollPointerId);
if (index < 0) {
Log.e(TAG, "Error processing scroll; pointer index for id "
+ mScrollPointerId + " not found. Did any MotionEvents get skipped?");
return false;
}

final int x = (int) (e.getX(index) + 0.5f);
final int y = (int) (e.getY(index) + 0.5f);
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;

if (mScrollState != SCROLL_STATE_DRAGGING) {
boolean startScroll = false;
if (canScrollHorizontally) {
if (dx > 0) {
dx = Math.max(0, dx - mTouchSlop);
} else {
dx = Math.min(0, dx + mTouchSlop);
}
if (dx != 0) {
startScroll = true;
}
}
if (canScrollVertically) {
if (dy > 0) {
dy = Math.max(0, dy - mTouchSlop);
} else {
dy = Math.min(0, dy + mTouchSlop);
}
if (dy != 0) {
startScroll = true;
}
}
if (startScroll) {
setScrollState(SCROLL_STATE_DRAGGING);
}
}

if (mScrollState == SCROLL_STATE_DRAGGING) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH
)) {
dx -= mReusableIntPair[0];
dy -= mReusableIntPair[1];
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
// Scroll has initiated, prevent parents from intercepting
getParent().requestDisallowInterceptTouchEvent(true);
}

mLastTouchX = x - mScrollOffset[0];
mLastTouchY = y - mScrollOffset[1];

if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (mGapWorker != null && (dx != 0 || dy != 0)) {
mGapWorker.postFromTraversal(this, dx, dy);
}
}
} break;
}
}
}

读源码新方法:Profiler法


虽然已经精准定位到滑动相关逻辑,但ACTION_MOVE这个分支中的源码还是太长,头痛!


如何快速地在庞杂的源码中定位到关键逻辑?


RecyclerView 动画原理 | 换个姿势看源码(pre-layout)中介绍过一种方法:“断点调试法”。即写一个最简单的 demo 来模拟场景,然后通过断点调试确定源码调用链上的关键路径。


今天再介绍一种更加快捷的方法:“Profiler法”


还是写一个 demo 加载一个列表,打开 AndroidStudio 自带的性能调试工具 Profiler,选中 CPU 栏,用手指触发列表滚动,然后点击 Record 按钮,开始记录列表滚动过程中完整的函数调用链,待列表滚动完毕后,点击 Stop 停止记录。就能得到这样的画面:


微信截图_20210505163116.png 横轴表示时间,纵轴表示该时间点发生的函数调用,调用链的方向是从上到下的,即上面的是调用者,下面的是被调用者。


图片上方有一条红色的线段,表示这段时间内发生用户交互,demo 场景中的交互就是手指滑动列表。触发列表滚动的逻辑应该就包含在红色线段对应的时间内,按 w 键把这段调用链放大查看:


微信截图_20210505163628.png 调用链实在是很长,若看不清可以点击大图。


调用链的最顶端是Looper.loop()方法,因为所有主线程的逻辑都在其中执行。


沿着调用链往下看,Looper 调用了 MessageQueue.next(),表示取出消息队列中的下一条消息,并紧接着执行了Hanlder.dispatchMessage()Hander.handleCallback(),表示分发并处理这条消息。


因为这条消息是触摸事件的处理,所以Choreographer又委托ViewRootImpl分发触摸事件,经过一条很长的分发链,终于看到一个熟悉的方法Activity.dispatchTouchEvent(),表示触摸事件已经传递到 Activity。然后根据界面的层级结构,一层层地分发到RecyclerView.onTouchEvent(),走到这里,我们关心的列表滑动逻辑就一下子全部展现在面前,将这个布局再放大看一下:


微信截图_20210505164944.png 一条清晰的调用链搜地一下扑面而来:


RecyclerView.onTouchEvent()
RecyclerView.scrollByInternal()
RecyclerView.scrollStep()
LinearLayoutManager.scrollVerticallyBy()
LinearLayoutManager.scrollBy()
OrientationHelper.offsetChildren()
LayoutManager.offsetChildrenVertical()
RecyclerView.offsetChildrenVertical()
View.offsetTopAndBottom()

已经不需要和RecyclerView.onTouchEvent()中庞杂的逻辑纠缠了,沿着这个调用走查,所有的关键信息一个都不会漏掉。


沿着关键调用链走查


有了上面的关键调用链,就节省了很多时间。现在可以对RecyclerView.onTouchEvent()中的逻辑披沙拣金:


public class RecyclerView {
@Override
public boolean onTouchEvent(MotionEvent e) {
switch (action) {
case MotionEvent.ACTION_MOVE: {
...
if (mScrollState == SCROLL_STATE_DRAGGING) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 1. 触发嵌套滚动,让嵌套滚动中的父控件优先消费滚动距离
if (dispatchNestedPreScroll(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
mReusableIntPair, mScrollOffset, TYPE_TOUCH
)) {
dx -= mReusableIntPair[0];
dy -= mReusableIntPair[1];
mNestedOffsets[0] += mScrollOffset[0];
mNestedOffsets[1] += mScrollOffset[1];
getParent().requestDisallowInterceptTouchEvent(true);
}
...

// 2. 触发列表自身的滚动
if (scrollByInternal(
canScrollHorizontally ? dx : 0,
canScrollVertically ? dy : 0,
e)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
...
}
} break;
}
}
}

在关键调用链scrollByInternal()的上面,意外地发现了处理嵌套滚动的逻辑,这是为了在列表消费滚动距离之前优先让其父控件消费。


public class RecyclerView {
boolean scrollByInternal(int x, int y, MotionEvent ev) {
int unconsumedX = 0;
int unconsumedY = 0;
int consumedX = 0;
int consumedY = 0;

consumePendingUpdateOperations();
if (mAdapter != null) {
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 触发列表滚动(手指滑动距离被传入)
scrollStep(x, y, mReusableIntPair);
// 记录列表滚动消耗的像素值和剩余未消耗的像素值
consumedX = mReusableIntPair[0];
consumedY = mReusableIntPair[1];
unconsumedX = x - consumedX;
unconsumedY = y - consumedY;
}
...
mReusableIntPair[0] = 0;
mReusableIntPair[1] = 0;
// 将列表未消耗的滚动距离继续留给其父控件消耗
dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
TYPE_TOUCH, mReusableIntPair);
unconsumedX -= mReusableIntPair[0];
unconsumedY -= mReusableIntPair[1];
...
}
}

scrollByInternal()是触发列表滚动调用链的起点,它先调用了scrollStep()以触发列表自身的滚动,紧接着还调用了dispatchNestedScroll()将自身消费后剩下的滚动余量继续交给其父控件消费。


沿着关键调用链继续往下走查:


public class RecyclerView {
LayoutManager mLayout;
void scrollStep(int dx, int dy, @Nullable int[] consumed) {
// 在滚动之前禁止重新布局
startInterceptRequestLayout();
onEnterLayoutOrScroll();

int consumedX = 0;
int consumedY = 0;
// 横向滚动 dx
if (dx != 0) {
consumedX = mLayout.scrollHorizontallyBy(dx, mRecycler, mState);
}
// 纵向滚动 dy
if (dy != 0) {
consumedY = mLayout.scrollVerticallyBy(dy, mRecycler, mState);
}
...
// 将滚动消耗通过数组传递出去
if (consumed != null) {
consumed[0] = consumedX;
consumed[1] = consumedY;
}
}
}

scrollStep()把触发滚动的任务委托给了LayoutManager,调用了它的scrollVerticallyBy()


public class RecyclerView {
public abstract static class LayoutManager {
// 空实现
public int scrollVerticallyBy(int dy, Recycler recycler, State state) {
return 0;
}
}
}

public class LinearLayoutManager extends RecyclerView.LayoutManager {
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
// 若是横向布局则不会发生纵向滚动
if (mOrientation == HORIZONTAL) {
return 0;
}
// 触发纵向滚动
return scrollBy(dy, recycler, state);
}

int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {
...
final int layoutDirection = delta > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START;
final int absDelta = Math.abs(delta);
// 计算和滚动相关的各种数据并将其保存在 mLayoutState 中
updateLayoutState(layoutDirection, absDelta, true, state);
// 填充额外的表项,并计算实际消耗的滚动值
final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
...
final int scrolled = absDelta > consumed ? layoutDirection * consumed : delta;
// 将列表所有孩子都想滚动的反方向平移对应像素
mOrientationHelper.offsetChildren(-scrolled);
...
mLayoutState.mLastScrollDelta = scrolled;
return scrolled;
}
}

若阅读过RecyclerView 动画原理 | 换个姿势看源码(pre-layout),对LinearLayoutManager.fill()方法一定不陌生。它用来向列表中填充额外的表项,填充个数由额外空间mLayoutState.mAvailable说了算,它在updateLayoutState()方法里被absDelta赋值,即滚动距离。


fill()的源码如下:


public class LinearLayoutManager {
// 根据剩余空间填充表项
int fill(RecyclerView.Recycler recycler, LayoutState layoutState,RecyclerView.State state, boolean stopOnFocusable) {
...
// 计算剩余空间 = 可用空间 + 额外空间
int remainingSpace = layoutState.mAvailable + layoutState.mExtraFillSpace;
// 当剩余空间 > 0 时,继续填充更多表项
while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
...
layoutChunk()
...
}
}

void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state,LayoutState layoutState, LayoutChunkResult result) {
// 获取下一个该被填充的表项视图
View view = layoutState.next(recycler);
...
addView(view);
...
}

}

fill()方法会根据剩余空间来循环地调用layoutChunk()向列表中填充表项,滚动列表的场景中,剩余空间的值由滚动距离决定。


关于列表滚动时,填充和复用表项的细节分析可以点击RecyclerView 面试题 | 滚动时表项是如何被填充或回收的?


layoutChunk()中获取下一个该被填充表项方法layoutState.next()最终会触发onCreateViewHolder()onBindViewHolder(),所以这俩方法执行的速度,即表项加载速度,也会影响列表滑动的流畅度,关于如何提高表项加载速度可以点击RecyclerView 性能优化 | 把加载表项耗时减半 (一)


scrollBy()方法会根据滚动距离,在列表滚动方向上填充额外的表项。填充完,再调用mOrientationHelper.offsetChildren()将所有表项向滚动的反方向平移:


public abstract class OrientationHelper {
// 抽象的平移子表项
public abstract void offsetChildren(int amount);

public static OrientationHelper createVerticalHelper(RecyclerView.LayoutManager layoutManager) {
return new OrientationHelper(layoutManager) {
@Override
public void offsetChildren(int amount) {
// 委托给 LayoutManager 在垂直方向上平移子表项
mLayoutManager.offsetChildrenVertical(amount);
}
...
}
}

public class RecyclerView {
public abstract static class LayoutManager {
public void offsetChildrenVertical(@Px int dy) {
if (mRecyclerView != null) {
// 委托给 RecyclerView 在垂直方向上平移子表项
mRecyclerView.offsetChildrenVertical(dy);
}
}
}

public void offsetChildrenVertical(@Px int dy) {
// 遍历所有子表项
final int childCount = mChildHelper.getChildCount();
for (int i = 0; i < childCount; i++) {
// 在垂直方向上平移子表项
mChildHelper.getChildAt(i).offsetTopAndBottom(dy);
}
}
}

经过一系列调用链,最终执行了View.offsetTopAndBottom()


public class View {
public void offsetTopAndBottom(int offset) {
if (offset != 0) {
final boolean matrixIsIdentity = hasIdentityMatrix();
if (matrixIsIdentity) {
if (isHardwareAccelerated()) {
invalidateViewProperty(false, false);
} else {
final ViewParent p = mParent;
if (p != null && mAttachInfo != null) {
final Rect r = mAttachInfo.mTmpInvalRect;
int minTop;
int maxBottom;
int yLoc;
if (offset < 0) {
minTop = mTop + offset;
maxBottom = mBottom;
yLoc = offset;
} else {
minTop = mTop;
maxBottom = mBottom + offset;
yLoc = 0;
}
r.set(0, yLoc, mRight - mLeft, maxBottom - minTop);
p.invalidateChild(this, r);
}
}
} else {
invalidateViewProperty(false, false);
}

// 修改 view 的顶部和底部值
mTop += offset;
mBottom += offset;
mRenderNode.offsetTopAndBottom(offset);
if (isHardwareAccelerated()) {
invalidateViewProperty(false, false);
invalidateParentIfNeededAndWasQuickRejected();
} else {
if (!matrixIsIdentity) {
invalidateViewProperty(false, true);
}
invalidateParentIfNeeded();
}
notifySubtreeAccessibilityStateChangedIfNeeded();
}
}
}


该方法会修改 View 的 mTop 和 mBottom 值,并触发轻量级的重绘。


分析至此,已经可以回到开篇的问题了:



RecyclerView 在处理 ACTION_MOVE 事件时计算出手指滑动距离,以此作为滚动位移值。


RecyclerView 根据滚动位移长度在滚动方向上填充额外的表项,然后将所有表项向滚动的反方向平移相同的位移值,以此实现滚动。



推荐阅读


RecyclerView 系列文章目录如下:



  1. RecyclerView 缓存机制 | 如何复用表项?


  2. RecyclerView 缓存机制 | 回收些什么?


  3. RecyclerView 缓存机制 | 回收到哪去?


  4. RecyclerView缓存机制 | scrap view 的生命周期


  5. 读源码长知识 | 更好的RecyclerView点击监听器


  6. 代理模式应用 | 每当为 RecyclerView 新增类型时就很抓狂


  7. 更好的 RecyclerView 表项子控件点击监听器


  8. 更高效地刷新 RecyclerView | DiffUtil二次封装


  9. 换一个思路,超简单的RecyclerView预加载


  10. RecyclerView 动画原理 | 换个姿势看源码(pre-layout)


  11. RecyclerView 动画原理 | pre-layout,post-layout 与 scrap 缓存的关系


  12. RecyclerView 动画原理 | 如何存储并应用动画属性值?


  13. RecyclerView 面试题 | 列表滚动时,表项是如何被填充或回收的?


  14. RecyclerView 面试题 | 哪些情况下表项会被回收到缓存池?


  15. RecyclerView 性能优化 | 把加载表项耗时减半 (一)


  16. RecyclerView 性能优化 | 把加载表项耗时减半 (二)


  17. RecyclerView 性能优化 | 把加载表项耗时减半 (三)


  18. RecyclerView 的滚动是怎么实现的?| 解锁阅读源码新姿势


收起阅读 »

【Jetpack篇】协程+Retrofit网络请求状态封装实战

前言 在App中,对于网络请求状态一般性的就分为加载中、请求错误、请求成功、请求成功但数据为null。为了用户体验,不同的状态需要对用户展示不同的界面,例如网络异常的提醒,点击重新请求等。 之前项目一直都是以Retrofit+RxJava+OkHttp为网...
继续阅读 »

前言


在App中,对于网络请求状态一般性的就分为加载中、请求错误、请求成功、请求成功但数据为null。为了用户体验,不同的状态需要对用户展示不同的界面,例如网络异常的提醒,点击重新请求等。


之前项目一直都是以Retrofit+RxJava+OkHttp为网络请求框架,RxJava已经很好的封装了不同的请求状态,onSubscribe、onNext、onError等,只需要在不同的回调中做出相应的动作就ok了。


RxJava很好用,但随着新技术的出现,RxJava的可替代性也就越高。Kotlin的协程就是这么一个存在。


本文是以Jetpack架构为基础,协程+Retrofit+Okhttp为网络请求框架,对不同的请求状态(loading,error,empty等)做了封装,让开发者不用再去关心哪里需要loading,哪里需要展示error提示。


同时,在封装的过程中,Jetpack和协程的使用也存在着几个坑,本文也将一一描述。


协程的基本使用



API:www.wanandroid.com/project/tre… 来自鸿洋大大的wanandroid



如果需要使用协程,则添加依赖


dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

在Retrofit2.6.0前,我们使用协程,api请求后返回的数据可以用Call或者Defeerd包裹处理,2.6后,可以直接返回数据,只不过需要加上suspend的修饰,如下:


interface ProjectApi {

@GET("project/tree/json")
suspend fun loadProjectTree(): BaseResp<List<ProjectTree>>
}

因为使用的是Jetpack架构,所以将整个网络请求主要分为UI、ViewModel、Repository三层,以LiveData为媒介进行通信。


首先是Repository层进行网络请求,


 class ProjectRepo{
private lateinit var mService: ProjectApi

init {
mService = RetrofitManager.initRetrofit().getService(ProjectApi::class.java)
}

suspend fun loadProjectTree(): List<ProjectTree> {
return mService.loadProjectTree()
}
}

利用Retrofit和OkHttp创建了一个apiService,内部细节在这里就先不展开,接着直接调用loadProjectTree()进行网络请求,将数据返回。loadProjectTree()用suspend关键字进行标记,Kotlin 利用此关键字强制从协程内调用函数。


接着ViewModel层,


class ProjectViewModel : ViewModel(){
//LiveData
val mProjectTreeLiveData = MutableLiveData<List<ProjectTree>>()
fun loadProjectTree() {
viewModelScope.launch(Dispatchers.IO) {
val data = mRepo.loadProjectTree()
mProjectTreeLiveData.postValue(data)
}
}
}

创建类ProjectViewModel并继承ViewModel,内部新建一个LiveData做UI通信使用,利用viewModelScope.launch(Dispatchers.IO) 创建一个新的协程,然后在 I/O 线程上执行网络请求,请求的数据利用LiveData通知给UI。


这里提到了viewModelScope.launch(Dispatchers.IO)viewModelScope是一个协程的作用域,ViewModel KTX 扩展中已经将此作用域封装好,直接使用就可以。Dispatchers.IO 表示此协程在 I/O线程上执行,而launch则是创建一个新的协程。


最后是UI层,


class ProjectFragment : Fragment {

override fun initData() {
//请求数据,调用loadProjectTree
mViewModel?.loadProjectTree()
mViewModel?.mProjectTreeLiveData?.observe(this, Observer {
//更新UI
})
}

UI层开始调用ViewModel的请求方法执行网络请求,LiveData注册一个观察者,观察数据变化,并且更新UI。


到这里,网络请求的逻辑基本上通顺了。


在一切环境正常的情况下,上面的请求是可以的,但是app还存在网络不畅,异常,数据为null的情况,上述就不在满足要求了,接下来就开始对数据异常的情况进行处理。


网络请求异常处理


对于协程异常的处理,Android开发者的官网上也给出了答案(developer.android.google.cn/kotlin/coro… ) ,直接对网络请求进行一个try-catch处理,发生异常了,直接在catch中做出相应动作就ok了,我们就来看看具体实现。


class ProjectViewModel : ViewModel(){
//LiveData
val mProjectTreeLiveData = MutableLiveData<List<ProjectTree>>()
fun loadProjectTree() {
viewModelScope.launch(Dispatchers.IO) {
try {
val data = mRepo.loadProjectTree()
mProjectTreeLiveData.postValue(data)
} catch (e: Exception) {
//异常
error(e)
} finally {

}
}
}
}

还是在ViewModel层,对mRepo.loadProjectTree()的请求加上了try-catch块,当发生异常时根据Exception类型对用户做出提示。


到这里,异常的来源已经找到了,接着就需要将异常显示在UI层来提醒用户。我们都知道mProjectTreeLiveData利用PostValue将数据分发给了UI,如法炮制,也就可以利用LiveData将异常也分发给UI。


说干就干。


网络请求状态封装


1、 [Error状态]


依旧在ViewModel层,我们新添加一个针对异常的LiveData:errorLiveData


class ProjectViewModel : ViewModel(){
//异常LiveData
val errorLiveData = MutableLiveData<Throwable>()
//LiveData
val mProjectTreeLiveData = MutableLiveData<List<ProjectTree>>()
fun loadProjectTree() {
viewModelScope.launch(Dispatchers.IO) {
try {
val data = mRepo.loadProjectTree()
mProjectTreeLiveData.postValue(data)
} catch (e: Exception) {
//异常
error(e)
errorLiveData.postValue(e)
} finally {

}
}
}
}

在UI层,利用errorLiveData注册一个观察者,如果有异常通知,则显示异常的UI(UI层代码省略)。这样确实可以实现我们一开始要的功能:请求成功则显示成功界面,失败显示异常界面。但是有一个问题,就是不够优雅,如果有多个ViewModel,多个UI,那就要每个页面都要写errorLiveData,很冗余。


那我们可以将公共方法抽离出来,新建一个BaseViewModel类,


open class BaseViewModel : ViewModel() {
val errorLiveData = MutableLiveData<Throwable>()

fun launch(
block: suspend ()
-> Unit,
error: suspend (Throwable) -> Unit,
complete: suspend () -> Unit
)
{
viewModelScope.launch(Dispatchers.IO) {
try {
block()
} catch (e: Exception) {
error(e)
} finally {
complete()
}
}
}


}

除了定义errorLiveData外,还将新建协程的操作放到其中,开发者只需要将每个ViewModel继承BaseViewModel,重写launch()即可,那么上面的案例中的ViewModel就修改成下面这种,


class ProjectViewModel : BaseViewModel(){

//LiveData
val mProjectTreeLiveData = MutableLiveData<List<ProjectTree>>()
fun loadProjectTree() {
launch(
{
val state = mRepo.loadProjectTree()
mProjectTreeLiveData.postValue(state.data)
},
{
errorLiveData.postValue(it)
},
{
loadingLiveData.postValue(false)
}
)
}
}

同样的,UI层也可以新建一个BaseFragment抽象类,在onViewCreated中利用errorLiveData注册观察者,收到异常通知,则进行相应的动作


abstract class BaseFragment<T : ViewDataBinding, VM : BaseViewModel> : Fragment(){

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mViewModel = getViewModel()

mViewModel?.errorLiveData?.observe(viewLifecycleOwner, Observer {
Log.d(TAG, "onViewCreated: error ")
showError()
throwableHandler(it)
})
}
}

每个子Fragment只需要继承BaseFragment即可,具体的异常监听就不用开发者管理。


2、 [Loading状态]


除了异常状态外,请求必不可少的就是Loading,这里Loading分为两种,一种是整个页面替换为Loading,例如Recyclerview列表时,就可以直接整个页面先Loading,而后显示数据;还有一种是数据界面不替换,只是个Loading Dialog显示在上层,例如点击登录时,需要一个loading。


Loading和异常处理的思路一致,可以在BaseViewModel中添加一个LoadingLiveData,数据类型为Boolean,在每个请求一开始LoadingLiveData.postValue(true),结束请求或者请求异常时,就LoadingLiveData.postValue(false)。UI层BaseFragment中,则可以监听LoadingLiveData发出的是true还是false,以便对Loading的显示和隐藏进行控制。


ViewModel层:


open class BaseViewModel : ViewModel() {
//加载中
val loadingLiveData = SingleLiveData<Boolean>()
//异常
val errorLiveData = SingleLiveData<Throwable>()

fun launch(
block: suspend ()
-> Unit,
error: suspend (Throwable) -> Unit,
complete: suspend () -> Unit
)
{
loadingLiveData.postValue(true)
viewModelScope.launch(Dispatchers.IO) {
try {
block()
} catch (e: Exception) {
Log.d(TAG, "launch: error ")
error(e)
} finally {
complete()
}
}
}
}

在BaseViewModel 中launch一开始就通知Loading显示,在try-catch-finally代码块的finally中将请求结束的通知分发出去。


UI层:


abstract class BaseFragment<T : ViewDataBinding, VM : BaseViewModel> : Fragment(){

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mViewModel = getViewModel()
//Loading 显示隐藏的监听
mViewModel?.loadingLiveData?.observe(viewLifecycleOwner, Observer {
if (it) {
//show loading
showLoading()
} else {

dismissLoading()
}
})

//请求异常的监听
mViewModel?.errorLiveData?.observe(viewLifecycleOwner, Observer {
Log.d(TAG, "onViewCreated: error ")
showError()
throwableHandler(it)
})
}
}

注册一个loading的观察者,当通知为true时,显示loading,false则隐藏。


3、 [Empty状态]


数据为空的状态发生在请求成功后,对于这种情况,可以直接在UI层中,请求成功的监听中对数据是否为null进行判断。


到这里,网络请求的基本封装已经完成,但是在运行测试的过程中,存在几个问题需要去解决,例如网络不通的情况下try-catch却不会抛出异常。接下来就开始进行二次封装。


暴露问题二次封装


问题一:网络请求异常,try-catch却不会将异常抛出


因为业务场景比较复杂,只依赖try-catch来获取异常,明显也会有所遗漏,那这种情况下我们可以直接以服务器返回的code,作为请求状态的依据。以上面Wanandroid的api为例,当errorCode=0时,则表示请求成功,其他的值都表示失败,那这就好办了。


我们新建一个密封类ResState,存放Success和Error状态,


sealed class ResState<out T : Any> {
data class Success<out T : Any>(val data: T) : ResState<T>()
data class Error(val exception: Exception) : ResState<Nothing>()
}

对Repository层请求返回的数据进行code判断处理,新建一个BaseRepository类,


open class BaseRepository() {

suspend fun <T : Any> executeResp(
resp: BaseResp<T>, successBlock: (suspend CoroutineScope.() -> Unit)? = null,
errorBlock: (suspend CoroutineScope.() -> Unit)? = null
): ResState<T> {
return coroutineScope {
if (resp.errorCode == 0) {
successBlock?.let { it() }
ResState.Success(resp.data)
} else {
Log.d(TAG, "executeResp: error")
errorBlock?.let { it() }
ResState.Error(IOException(resp.errorMsg))
}
}
}

}

errorCode == 0时,将ResState置为Success并将数据返回,errorCode !=0时,则将状态置为Error并将Exception返回。而子Repository则只需要继承BaseRepository即可,


class ProjectRepo : BaseRepository() {

suspend fun loadProjectTree(): ResState<List<ProjectTree>> {
return executeResp(mService.loadProjectTree())
}

修改后返回值用ResState<>包裹,并直接将请求的结果传给executeResp()方法,而ViewModel中也做出相应的修改,


class ProjectViewModel : BaseViewModel() {
val mProjectTreeLiveData = MutableLiveData<List<ProjectTree>>()

fun loadProjectTree() {
launch(
{
val state = mRepo.loadProjectTree()
//添加ResState判断
if (state is ResState.Success) {
mProjectTreeLiveData.postValue(state.data)
} else if (state is ResState.Error) {
Log.d(TAG, "loadProjectTree: ResState.Error")
errorLiveData.postValue(state.exception)
}
},
{
errorLiveData.postValue(it)
},
{
loadingLiveData.postValue(false)
}
)
}
}

ViewModel层新增了一个ResState判断,通过请求的返回值ResState,如果是ResState.Success则将数据通知给UI,如果是ResState.Error,则将异常通知给UI。


服务器返回的code值进行判断,无疑是最准确的。


问题二:errorLiveData注册观察者一次后,不管请求失败还是成功,它还是会收到通知。


这是MutableLiveData的一个特性,只要当注册的观察者处于前台时,都会收到通知。那这个特性又影响了什么呢? 我在errorLiveData的监听中,对不同的异常进行了Toast的弹出提醒,如果每次进入一个页面,虽然请求成功了,但是因为errorLiveData还是能接收到通知,就会弹出一个Toast提醒框。现象如下:


dem.gif


那我们针对MutableLiveData将其修改为单事件响应的liveData,只有一个接收者能接收到信息,可以避免不必要的业务的场景中的事件消费通知。


class SingleLiveData<T> : MutableLiveData<T>() {

private val mPending = AtomicBoolean(false)

@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {

if (hasActiveObservers()) {
Log.w(TAG, "多个观察者存在的时候,只会有一个被通知到数据更新")
}

super.observe(owner, Observer { t ->
if (mPending.compareAndSet(true, false)) {
observer.onChanged(t)
}
})

}

override fun setValue(value: T?) {
mPending.set(true)
super.setValue(value)
}

@MainThread
fun call() {
value = null
}

companion object {
private const val TAG = "SingleLiveData"
}
}

将BaseViewModel中的MutableLiveData替换为SingleLiveData就可以了。


最后


至此,协程+Retrofit网络请求状态封装也就完成了,对于Error、Empty等view的切换以及点击重新请求等操作,这里就不一一展示了,可以移步到github里查看。最后我们来看一下请求效果。


demoo.gif



源码:组件化+Jetpack+kotlin+mvvm


收起阅读 »

LiveData 单元测试

文参考自 作者:HaroldGao链接:https://juejin.cn/post/6956588138487775240来源:掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

文参考自 Unit-testing LiveData and other common observability problems


参考 Google 代码官方测试代码 here



==单元测试时,LiveData.value 返回 null==


    @Test
@Throws(Exception::class)
fun testLiveDataFail() = runBlocking {
meditationDao.insert(MeditationTrip())
val trips = meditationDao.getAllTrips()
assertEquals(1, trips.value!!.size) // NullPointerException
}
复制代码

首先,Transformations#map 得到的 LiveData 必须有观察者,才会在原始 LiveData 更新时调用 map 函数更新值。理解起来也很合理,没有人观察的值没有必要被实时更新。实现原理是,Transformations#map 方法将 LiveData 转化为 MediatorLiveData,最终通过 LiveData#observeForever 向原始的 LiveData 添加一个 AlwaysActiveObserver,但是前提是这个 MediatorLiveData 必须要有 active 观察者(androidx.lifecycle.MediatorLiveData#addSource)。


Room 库中为 DAO 注解生成的实现类,返回的 LiveData 是 androidx.room.RoomTrackingLiveData 类型,类似地也只有在有 active Observer 的前提下,才会在数据库表更新时,执行查询语句,更新 value。因为没有观察者时,没必要更新。实现原理是在 RoomTrackingLiveData 第一次添加 Obeserver 时(OnActive),往 RoomDatabase 的 InvalidationTracker 中添加 WeakObserver,这样当数据库发生变化时,就会通知这些 Observer(androidx.room.InvalidationTracker#addWeakObserver)


以上问题的原因都是因为没有 active Observer,解决办法:


fun <T> LiveData<T>.getOrWaitValue(
time: Long = 2,
timeUnit: TimeUnit = TimeUnit.SECONDS,
afterObserve: () -> Unit = {}
): T {
var data: T? = null
val latch = CountDownLatch(1)
val observer = object : Observer<T> {
override fun onChanged(t: T) {
data = t
latch.countDown()
this@getOrWaitValue.removeObserver(this) // 添加了观察者
}
}
this.observeForever(observer)
afterObserve.invoke()

// wait for short time
if (!latch.await(time, timeUnit)) {
this.removeObserver(observer)
throw TimeoutException("LiveData value is never set!")
}

@Suppress("unchecked_cast")
return data as T
}
复制代码

单元测试时,又报错:


java.lang.IllegalStateException: Cannot invoke observeForever on a background thread
at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:487)
at androidx.lifecycle.LiveData.observeForever(LiveData.java:224)
复制代码

这是因为 LiveData 注册 Observer 时,要求必须是在主线程,通过 ArchTaskExecutor.getInstance().isMainThread() 来判断。


解决办法是为单元测试添加 InstantTaskExecutorRule:


    @Rule
@JvmField
val instantExecutorRule = InstantTaskExecutorRule()
复制代码

InstantTaskExecutorRule 作为 TestWatcher 的子类,会在单元测试开始前,替换 Archtechture Component 的后台执行器 ArchTaskExecutor,每个任务都是同步运行(runnable#run),isMainThread 返回 true。


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

CompletableFuture使用与解读

1 前言 jdk8后给出的类,android需要N版本之后才能使用;提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,并且提供了函数式编程的能力,也提供了转换和组合 CompletableFuture 的方法; 本文会从以下方面来介绍 ...
继续阅读 »

1 前言


jdk8后给出的类,android需要N版本之后才能使用;提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,并且提供了函数式编程的能力,也提供了转换和组合 CompletableFuture 的方法;


本文会从以下方面来介绍



  • 使用、方法意义以及总结归纳

  • 流程解读


2 使用


从类来看,其实现了CompletionStage接口以及Future接口;futrue的用法就不在这里说了,这里仅仅说明CompletionStage方法以及相关方法用法;


调用整个过程,我把它看成是个流,每次方法生成的CompletableFuture都是一个流节点,每个流有自己的完成结果,其后面的流依赖其完成后才可执行


2.1 流的产生



  • 静态方法


    val ff = CompletableFuture<Int>()
复制代码


  • 数据提供者Supplier


CompletableFuture.supplyAsync {
println("create thread ${Thread.currentThread().name}")
100
}
复制代码


  • 任务事件Runnable


 CompletableFuture.runAsync {
println("create: buy meat and vegetables")
}
复制代码


  • 组合并集任务


 CompletableFuture.allOf(CompletableFuture.runAsync{
println("create: wear shoes")
}, CompletableFuture.runAsync{
println("create: wear dress")
})
复制代码


  • 组合互斥任务


    CompletableFuture.anyOf(CompletableFuture.runAsync{
println("create: read a book")
}, CompletableFuture.runAsync{
println("create: write")
})
复制代码

2.2 流的处理


流的处理方法比较多了,有37个,写代码不方便;完成方法表格如下,表格备注表达了我对这些方法的抽象与理解,看了这个,有助于更好理解下面涉及的东西


CompletableFuture详细方法.png
太多了很难记,也不好理解,下面给出了简略精华版本方法表;通过这些方法,清除明了这个类可以做到什么样得组合变换


CompletableFuture简略方法.png


下面给出几个简单事例



  1. 无组合的变化、消费


CompletableFuture.supplyAsync {
println("create thread ${Thread.currentThread().name}")
100
}.thenApply {
println("map thread ${Thread.currentThread().name}")
it * 10
}.thenAccept {
println("consume $it")
}
复制代码


  1. 组合变化、消费


CompletableFuture.supplyAsync {
10
}.applyToEither(CompletableFuture.supplyAsync {
100
}, Function<Int, Int> {
it * 10 + 3
}).thenCombine(CompletableFuture.supplyAsync{
"Lily"
}, BiFunction<Int, String, Stu> { t, u -> Stu(u, t)}).thenAccept {
println("name ${it.name}, age ${it.age}")
}
复制代码


  1. 异常转换、多次消费


val ff = CompletableFuture<Int>()
ff.handle<Int>{
_, _ -> 10
}.whenComplete{
t, u -> println("first handler $t")
}.whenComplete { t, u -> println("second handler $t")}
ff.obtrudeValue(null)
复制代码

2.3 流结果设置


这里也通过表格方式,有下面几种方法


CompletableFuture结果设置.png


我们通过构造器生成时,需要自己设置值,如下


val ff = CompletableFuture<Int>()
ff.thenApply {
it / 2 + 4
}
ff.complete(16)
复制代码

设置值后,后面的流才会执行


3. 源码解读


CompletableFuture是流的一个节点,内部持有了完成状态以及依赖其的任务节点信息,其内部同样实现了完成态时依赖任务执行处理;


3.1 数据结构


这主要体现这两个成员变量上


    volatile Object result; 
volatile Completion stack;
复制代码


  • result:结果为null,表示未执行;执行结果为空,则设置为静态常量NIL,异常则设置为AltResult实例,正常完成,则表示实际的值; AltResult内容如下


   static final AltResult NIL = new AltResult(null);
static final class AltResult {
final Throwable ex;
AltResult(Throwable x) { this.ex = x; }
}
复制代码


  • stack:链表尾部指针,组成了后进先出的链表结构;是依赖当前完成状态需要执行的任务集合;内容如下,其实现ForkJoinTask,只是为了利用ForkJoinPoo线程池,其最大有点就是解决频繁的异步任务的,很配


    abstract static class Completion extends ForkJoinTask<Void>
implements Runnable, AsynchronousCompletionTask {
volatile Completion next;
abstract CompletableFuture<?> tryFire(int mode);

abstract boolean isLive();
public final void run() { tryFire(ASYNC); }
public final boolean exec() { tryFire(ASYNC); return false; }
public final Void getRawResult() { return null; }
public final void setRawResult(Void v) {}
}
复制代码

对于stack处理



  • postFire方法: 通知其依赖的节点,进行完成传播;由于没有使用锁,只使用了原子操作,这样可以防止,有些节点加入到依赖集合中,却不能得到执行

  • cleanStack方法:清除失活以及无效的节点

  • postComplete方法:执行stack集合中任务

  • casStack方法:改变队尾操作

  • tryPushStack方法:尝试加入队尾数据

  • pushStack:队尾加入数据


3.2 Completion以及子类


Completion类,抽象类,待执行的任务节点;其内部持有下个流以及流任务执行的逻辑;其继承关系类图如下:


Completion类图.jpg


内部变量


        CompletableFuture<V> dep; 
CompletableFuture<T> src;
CompletableFuture<U> snd
复制代码

dep代表当前操作新成的流节点,src、snd为其依赖的流节点;其中每个类,还有流任务执行的对象:Runable、Function、ConSumer、BiFunction、BiConsumer等


tryFire方法很重要,其持有的转换对象、消费对象代表了需要执行的操作;其实他们对应的tryFire方法内部实际操作,都在CompletableFuture内有对应方法


tryFire方法


很关键的方法,其持有的转换对象、消费对象代表了需要执行的操作;其情况与具体的模式有关,其情况如下



  • SYNC = 0, 同步状态;执行线程为当前方法调用线程或者上个流执行所在线程;同时其可能仅仅是为了启动线程池启动任务

  • ASYNC = 1,异步,表示需要在线程池内执行

  • NESTED = -1,传播模式,表示依赖的流节点已经处于完成状态,正在传递处理


claim方法


线程池任务提交,并且执行有且提交一次


3.3 中间流生成与执行原理


中间流处理,就是CompletionStage声明的方法;其系列处理方法,基本逻辑相同,也就是方法名称不同而已,而由于持有的任务不同而略有不同


3.3.1 thenRun系列


均是通过私有方法uniRunStage进行处理,进行添加时尝试处理的


  private CompletableFuture<Void> uniRunStage(Executor e, Runnable f) {
if (f == null) throw new NullPointerException();
CompletableFuture<Void> d = newIncompleteFuture();
if (e != null || !d.uniRun(this, f, null)) {
UniRun<T> c = new UniRun<T>(e, d, this, f);
push(c);
c.tryFire(SYNC);
}
return d;
}
复制代码

对于此方法有下面逻辑



  1. 同步执行,且uniRun执行成功,则返回生成流节点

  2. 否则,添加相应Completion子类到等待集合中,并再次尝试执行;和之前提到的postFire结合确保一定能够执行


   final boolean uniRun(CompletableFuture<?> a, Runnable f, UniRun<?> c) {
Object r; Throwable x;
if (a == null || (r = a.result) == null || f == null)
return false;
if (result == null) {
if (r instanceof AltResult && (x = ((AltResult)r).ex) != null)
completeThrowable(x, r);
else
try {
if (c != null && !c.claim())
return false;
f.run();
completeNull();
} catch (Throwable ex) {
completeThrowable(ex);
}
}
return true;
}
复制代码

方法的最后一个参数,当是触发线程池提交任务操作时,需要传入任务实例,否则传入空指;也就是传入空指,代表此方法中直接执行,这时,线程可能为生成流节点方法线程,也可能是上个流节点执行的线程,也可能是线程池创建的线程中(好像等于白说了);这个方法流程如下:




  1. 检验依赖节点执行状态,未完成则结束




  2. 执行异常结束,则设置异常状态,结束




  3. 正常执行结束时,尝试执行当前任务



    • 需要向线程池提交任务,则通过claim方法,进行处理,并返回;提交任务后会执行tryFire方法

    • 不需要向线程池提交任务,执行;若执行成功,有结果直接设置结果,无结果设置NIL值;若是发生已成设置异常




如果调用CompletionStage声明的方法未能立刻执行的,则需要通过依赖的流节点完成后通过postComplete方法进行分发;


    final void postComplete() {
CompletableFuture<?> f = this; Completion h;
while ((h = f.stack) != null ||
(f != this && (h = (f = this).stack) != null)) {
CompletableFuture<?> d; Completion t;
if (f.casStack(h, t = h.next)) {
if (t != null) {
if (f != this) {
pushStack(h);
continue;
}
h.next = null;
}
f = (d = h.tryFire(NESTED)) == null ? this : d;
}
}
}
复制代码

tryFire方法,返回空表示流节点任务没有完成,否则表示已完成,继续这个节点的分发;也就是分发时通过tryFire方法去执行依赖节点的任务


        final CompletableFuture<Void> tryFire(int mode) {
CompletableFuture<Void> d; CompletableFuture<T> a;
if ((d = dep) == null ||
!d.uniRun(a = src, fn, mode > 0 ? null : this))
return null;
dep = null; src = null; fn = null;
return d.postFire(a, mode);
}
复制代码

逻辑如下



  1. 当前任务执行的流节点为空、或者未执行,则返回null,也就是此节点未完成操作

  2. 已经执行成功,则把持有对象全部置空,以便gc;并通过postFire通知其依赖节点进行清理依赖节点集合或者继续传播触发


    final CompletableFuture<T> postFire(CompletableFuture<?> a, int mode) {
if (a != null && a.stack != null) {
if (mode < 0 || a.result == null)
a.cleanStack();
else
a.postComplete();
}
if (result != null && stack != null) {
if (mode < 0)
return this;
else
postComplete();
}
return null;
}
复制代码

主要逻辑




  1. 依赖流节点不为空,且依赖集合不为空



    • 传播模式或者其未完成执行,则进行节点清理

    • 否则,进行传播




  2. 当前流节点执行完毕,且依赖集合不为空



    • 正在处于传播模式,则返回当前对象,继续传播

    • 否则,进行传播处理




整个添加流节点以及执行流程,已经分析完了;那么这个相似处,根据这个例子再来具体的说下:


整个流程:Completion子类(UniRun)以及子类tryFire方法、CompletableFuture中辅助方法(uniRun)以及postFire、postComplete等分发方法


3.3.2 thenRun相似流程系列



  • thenApply系列方法:子类UniApply、辅助方法uniApply

  • thenAccept系列方法:子类UniAccept、辅助方法uniAccept

  • thenCombine系列方法:子类BiApply、辅助方法biApply

  • thenAcceptBoth系列方法:子类BiAccept、辅助方法biAccept

  • runAfterBoth系列方法:子类biRun、辅助方法BiRun

  • applyToEither系列方法:子类orApply、辅助方法OrApply

  • acceptEither系列方法:子类OrAccept、辅助方法orAccept

  • runAfterEither系列方法:子类OrRun、辅助方法orRun

  • handle系列方法:子类UniHandle、辅助方法uniHandle

  • whenComplete系列方法:子类UniWhenComplete、辅助方法uniWhenComplete

  • exceptionally方法:子类UniExceptionally、辅助方法uniExceptionally


uiWhenComplete、uniHandle和uniExceptionally,在异常处理中,因为需要处理异常,而在检测其依赖节点异常时,并不直接退出,而是继续处理


3.3.3 thenCompose系列


这个为何特殊呢,因为它相当于两个任务;



  1. 通过Function<? super T, ? extends CompletionStage>转换流为一个任务

  2. 转换的流执行又是一个任务,其又关联一个流


第一个子类是UniCompose,辅助方法是uniCompose,执行了转换流逻辑,并通过Relay实例把当前加入到转换流执行的依赖集合中;也就是说thenCompose系列方法产生的流,依赖于转换流操作以及转换的流完成


转换的流执行逻辑子类是UniRelay,辅助方法uniRelay


执行逻辑并没有区别;relay表示接力,也就是,其传递上个流节点结果即可


4 小结


CompletableFuture这个类,我觉得异步编程,还是需要一定的功底,它并没有把相应操作等封装的很到位,37个方法组合使用,可以达到不同的效果;


技术变化都很快,但基础技术、理论知识永远都是那些;作者希望在余后的生活中,对常用技术点进行基础知识分享;如果你觉得文章写的不错,请给与关注和点赞;如果文章存在错误,也请多多指教!


作者:众少成多积小致巨
链接:https://juejin.cn/post/6956585105875795998
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

Swift的高级技巧 - 动态注入和更改代码

虽然Xcode为lldb命令提供了几个可视化抽象,例如通过单击代码行添加断点并通过单击播放按钮来运行,但lldb提供了一些Xcode UI中不存在的有用命令。这可以是从即时创建方法到甚至更改CPU的寄存器以强制应用程序上的特定流而无需重新编译它,并且了解它们可...
继续阅读 »

虽然Xcode为lldb命令提供了几个可视化抽象,例如通过单击代码行添加断点并通过单击播放按钮来运行,但lldb提供了一些Xcode UI中不存在的有用命令。这可以是从即时创建方法到甚至更改CPU的寄存器以强制应用程序上的特定流而无需重新编译它,并且了解它们可以极大地改善您的调试体验。

并非所有Swift都是在Xcode中开发的 - 像Swift编译器或Apple的SourceKit-LSP这样的东西通过其他方式更好地工作,这些方法通常最终会让你手动使用lldb 。如果没有Xcode来帮助您,其中一些技巧可能会阻止您再次编译应用程序以测试某些更改。

注入属性和方法

您可能已经知道po(“打印对象”的缩写) - 通常用于打印属性内容的友好命令:

func foo() {
var myProperty = 0
} // a breakpoint
po myProperty
0

然而,po比这更强大 - 尽管名称暗示它打印的东西,po是一个别名,更原始(或只是)命令的论证版本,使输出更加开放:expression --object-description -- expression e

e myProperty
(Int) $R4 = 0 // not very pretty!

因为它是别名,po所以可以做任何事情e。e用于评估表达式,表达式的范围可以从打印属性到更改其值,甚至可以定义新类。作为一个简单的用法,我们可以在代码中更改属性的值以强制新流而无需重新编译代码:

po myProperty
0
po myProperty = 1
po myProperty
1

除此之外,如果你po单独写,你将能够编写这样的多线表达式。我们可以使用它在我们的调试会话中创建全新的方法和类:

po
Enter expressions, then terminate with an empty line to evaluate:
1 class $BreakpointUtils {
2 static var $counter = 0
3 }
4 func $increaseCounter() {
5 $BreakpointUtils.$counter += 1
6 print("Times I've hit this breakpoint: \($BreakpointUtils.$counter)")
7 }
8

(这里使用美元符号表示这些属性和方法属于lldb,而不是实际代码。)

前面的例子允许我直接从lldb 调用,这将在我的“我无法处理这个bug”计数器上加1。$increaseCounter()

po $increaseCounter()
Times I've hit this breakpoint: 1
po $increaseCounter()
Times I've hit this breakpoint: 2

这样做的能力可以与lldb导入插件的能力相结合,这可以大大增强您的调试体验。一个很好的例子就是Chisel,这是一个由Facebook制作的工具,它包含许多lldb插件 - 就像border命令一样,它增加了一个明亮的边框,UIView这样你就可以在屏幕上快速定位它们,并且它们都通过巧妙的用法来实现。e/ po。

然后,您可以使用lldb的断点操作在命中断点时自动触发这些方法。结合po的属性更改功能,您可以创建特殊的断点,这些断点将改变您尝试执行的测试的应用流程。

通常,所有高级断点命令都非常痛苦地在lldb中手动编写(这就是为什么我会在本文中避免它们),但幸运的是,您可以轻松地在Xcode中设置断点操作:

v- 避免po动态

如果你已经使用po了一段时间,你可能在过去看到过这样一个神秘的错误信息:

error: Couldn't lookup symbols:
$myProperty #1 : Swift.Int in __lldb_expr_26.$__lldb_expr(Swift.UnsafeMutablePointer<Any>) -> ()

这是因为po通过编译来评估您的代码,不幸的是,即使您尝试访问的代码是正确的,仍然存在可能出错的情况。

如果你正在处理不需要评估的东西(比如静态属性而不是方法或闭包),你可以使用v命令(简称frame variable)作为打印的替代,po以便立即获取内容。宾语。

v myProperty
(Int) myProperty = 1

disassemble - 打破内存地址以更改其内容

注意:以下命令仅在极端情况下有用。你不会在这里学习一个新的Swift技巧,但你可能会学到一些有趣的软件工程!

我通过使用越狱的iPad来使用流行的应用程序进入逆向工程,当你这样做时,你没有选择重新编译代码 - 你需要动态地改变它。例如,如果我无法重新编译代码,isSubscribed即使我没有订阅,如何强制以下方法进入条件?

var isSubscribed = false

func run() {
if isSubscribed {
print("Subscribed!")
} else {
print("Not subscribed.")
}
}

我们可以通过使用应用程序的内存来解决 - 在任何堆栈框架内,您可以调用该disassemble命令来查看该堆栈的完整指令集:

myapp`run():
-> 0x100000d60 <+0>: push rbp
0x100000d61 <+1>: mov rbp, rsp
0x100000d64 <+4>: sub rsp, 0x70
0x100000d68 <+8>: lea rax, [rip + 0x319]
0x100000d6f <+15>: mov ecx, 0x20
...
0x100000d9c <+60>: test r8, 0x1
0x100000da0 <+64>: jne 0x100000da7
0x100000da2 <+66>: jmp 0x100000e3c
0x100000da7 <+71>: mov eax, 0x1
0x100000dac <+76>: mov edi, eax
...
0x100000ec7 <+359>: call 0x100000f36
0x100000ecc <+364>: add rsp, 0x70
0x100000ed0 <+368>: pop rbp
0x100000ed1 <+369>: ret

这里整洁的东西不是命令本身,而是你可以用这些信息做些什么。我们习惯在Xcode中设置断点到代码行和特定选择器,但在lldb的控制台中你也可以使用断点特定的内存地址。

我们需要知道一些汇编来解决这个问题:如果我的代码包含一个if,那么该代码的结果汇编肯定会有一个跳转指令。在这种情况下,跳转指令将跳转到存储器地址,如果寄存器(在前一条指令中设置)不等于零(那么,为真)。由于我没有订阅,肯定会为零,这将阻止该指令被触发。0x100000da0 <+64>: jne0x100000da7 0x100000da7 r8 0x100000d9c <+60>: test r8, 0x1 r8

要看到这种情况发生并修复它,让我们首先断点并将应用程序放在jne指令处:

b 0x100000da0
continue
//Breakpoint hits the specific memory address

如果我disassemble再次运行,小箭头将显示我们在正确的内存地址处开始操作。

-> 0x100000da0 <+64>:  jne    0x100000da7

有两种方法可以解决这个问题:

方法1:更改CPU寄存器的内容

该register read和register write命令由LLDB提供,让您检查和修改的CPU寄存器的内容,并解决这个问题的第一种方式是简单地改变的内容r8。

通过定位jne指令,register read将返回以下内容:

General Purpose Registers:
rax = 0x000000010295ddb0
rbx = 0x0000000000000000
rcx = 0x00007ffeefbff508
rdx = 0x0000000000000000
rdi = 0x00007ffeefbff508
rsi = 0x0000000010000000
rbp = 0x00007ffeefbff520
rsp = 0x00007ffeefbff4b0
r8 = 0x0000000000000000General Purpose Registers:

因为r8为零,jne指令不会触发,从而使代码输出"Not subscribed."。但是,这是一个简单的修复 - 我们可以r8通过运行register write和恢复应用程序设置为不为零的东西:

register write r8 0x1
continue
"Subscribed!"

在日常的iOS开发中,register write可以用来替换代码中的整个对象。如果某个方法要返回你不想要的东西,你可以在lldb中创建一个新对象,获取其内存地址e并将其注入所需的寄存器。

方法2:更改指令本身

解决这个问题的第二种也可能是最疯狂的方法是实时重写应用程序本身。

就像寄存器一样,lldb提供memory read并memory write允许您更改应用程序使用的任何内存地址的内容。这可以用作动态更改属性内容的替代方法,但在这种情况下,我们可以使用它来更改指令本身。

这里可以做两件事:如果我们想要反转if指令的逻辑,我们可以改为(所以它检查一个条件),或者(跳空不是)to (跳空,或)。我发现后者更容易,所以这就是我要遵循的。如果我们阅读该指令的内容,我们会看到如下内容:test r8, 0x1 test r8, 0x0 false jne 0x100000da7 je 0x100000da7 if!condition

memory read 0x100000da0
0x100000da0: 75 05 e9 95 00 00 00 b8 01 00 00 00 89 c7 e8 71

这看起来很疯狂,但我们不需要了解所有这些 - 我们只需要知道指令的OPCODE对应于开头的两位(75)。按照这个图表,我们可以看到OPCODE for je是74,所以如果我们想要jne成为je,我们需要将前两位与74交换。

为此,我们可以使用memory write与该地址完全相同的内容,但前两位更改为74。

memory write 0x100000da0 74 05 e9 95 00 00 00 b8 01 00 00 00 89 c7 e8 71
dis
0x100000da0 <+64>:  je     0x100000da7

现在,运行应用程序将导致"Subscribed!"打印。

结论

虽然拆解和写入内存对于日常开发来说可能过于极端,但您可以使用一些更高级的lldb技巧来提高工作效率。更改属性,定义辅助方法并将它们与断点操作混合将允许您更快地导航和测试代码,而无需重新编译它。

转自:https://www.jianshu.com/p/281a2f61937e

收起阅读 »

iOS KVO 与 readonly的讨论 (数组array & setter)

在开发过程中,可能会有这样的需求:当数据源变动的时候及时刷新显示的列表。期望是去监听数据源数组的count,当count有变动就刷新UI,可是实际操作中却发现了不少的问题。例如:self.propertyArray = [NSMutableArray arra...
继续阅读 »

在开发过程中,可能会有这样的需求:当数据源变动的时候及时刷新显示的列表。
期望是去监听数据源数组的count,当count有变动就刷新UI,可是实际操作中却发现了不少的问题。
例如:

self.propertyArray = [NSMutableArray array];
[self.propertyArray addObserver:self forKeyPath:@"count" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

直接就报错了,信息如下:

Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '[<__NSArrayM 0x6000033db450> addObserver:forKeyPath:options:context:] is not supported. Key path: count'

字面意思是,不支持对数组count的监听。
回到问题的本质。
我们知道KVO是在属性的setter方法上做文章,进入到数组的类中看一下,发现count属性是readonly

@interface NSArray<__covariant ObjectType> : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>

@property (readonly) NSUInteger count;

readonly不会自动生成setter方法,但是可以手动添加setter方法。
我们来验证一下 例如:
创建一个people类 添加一个属性 readonly count

@interface People : NSObject

@property (nonatomic, readonly) NSInteger count;

@end

@implementation People

@end

我们来试一下 监听它的count属性会怎样

People *peo = [People new];
[peo addObserver:self forKeyPath:@"count" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

我们发现 并没有报错。
我们来看一下People的方法列表

const char *className = "People";
Class NSKVONotifying_People = objc_getClass(className);
unsigned int methodCount =0;
Method* methodList = class_copyMethodList(NSKVONotifying_People,&methodCount);
NSMutableArray *methodsArray = [NSMutableArray arrayWithCapacity:methodCount];

for(int i=0;i<methodCount;i++)
{
Method temp = methodList[i];
const char* name_s =sel_getName(method_getName(temp));
[methodsArray addObject:[NSString stringWithUTF8String:name_s]];
}
NSLog(@"%@",methodsArray);

通过打印我们可以看到 People中只有两个方法

(
dealloc,
count,
)

我们知道,KVO监听某个对象时,会动态生成名字叫做NSKVONotifying_XX的类,并且重写监听对象的setter方法。下面 我们来看下NSKVONotifying_People的方法列表:

const char *className = "NSKVONotifying_People";
Class NSKVONotifying_People = objc_getClass(className);
unsigned int methodCount =0;
Method* methodList = class_copyMethodList(NSKVONotifying_People,&methodCount);
NSMutableArray *methodsArray = [NSMutableArray arrayWithCapacity:methodCount];

for(int i=0;i<methodCount;i++)
{
Method temp = methodList[i];
const char* name_s =sel_getName(method_getName(temp));
[methodsArray addObject:[NSString stringWithUTF8String:name_s]];
}
NSLog(@"%@",methodsArray);

打印结果如下:

(
class,
dealloc,
"_isKVOA"
)

可以看到,里面没有count的getter方法,多了个class和isKVO, 当然也没有我们需要的setter方法。但是这样并不会导致crash。

下面我们再试一下,手动在People的.m中 添加上setter方法会怎么样:

@implementation People

- (void)setCount:(NSInteger)count{
_count = count;
}

@end

再次查看People和NSKVONotifying_People的方法列表,会发现多了一个count的setter方法。(如下所示)

(
dealloc,
count,
"setCount:"
)
(
"setCount:",
class,
dealloc,
"_isKVOA"
)

这样我们就可以得出一个结论:

KVO动态生成的类,重写setter方法的前提是:原来的类中,要有对应的setter方法。即便是readonly修饰,只要.m中有对应属性的setter方法,都是可以的。

OK 说了这么多,好像还是没有解决我们的问题。 为什么监听数组count就抛异常了呢? 带着这个问题 继续往下走。
通过点击array的监听方法 进入到ArrayObserving类中,我们发现,系统给出了注释:NSArrays are not observable, so these methods raise exceptions when invoked on NSArrays.


系统也不期望我们去监听数组的属性。is not supported. Key path: count' 应该就是系统在实现监听方法时,抛出的异常。

最后,我从网上找到了另一个方法

[self mutableArrayValueForKey:@"propertyArray"]

我们可以转换一个思路,不再监听count,选择监听数组本身,当数组变动时刷新页面。

[self addObserver:self forKeyPath:@"propertyArray" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

[[self mutableArrayValueForKey:@"propertyArray"] addObject:@"a"];

这个方法的具体实现,没有看到源码,但是看到有人说,这个方法会生成一个可变数组,添加完元素后,会将这个生成的数组赋值给叫做Key的数组。我试了一下,确实是有效果的,这里就不做考究了。

转自:https://www.jianshu.com/p/688c2512be01

收起阅读 »

【含视频、课件下载】一天开发一款灵魂社交APP

视频回放: 课件下载:社交应用开发分享.pptx零开发基础、源码共享 内容介绍:从互联网诞生之日起,社交需求就一直作为一种刚需存在,在人际过载与信息过载时代,微信已经不再能承载我们最简单、纯粹、美好的社交需求,在社交疲态和用户迁移的产品契机下,陌生人...
继续阅读 »


视频回放:


课件下载:

社交应用开发分享.pptx

零开发基础、源码共享

 

内容介绍:

从互联网诞生之日起,社交需求就一直作为一种刚需存在,在人际过载与信息过载时代,微信已经不再能承载我们最简单、纯粹、美好的社交需求,在社交疲态和用户迁移的产品契机下,陌生人社交领域逐渐孕育出“陌陌、探探、SOUL”等社交APP新贵。随着5G时代的到来,一波音视频社交领域的创业窗口期又重新打开。

本次课程,环信生态开发者“穿裤衩闯天下”将给我们带来一款基于环信即时通讯云(环信音视频云)开发的免费开源灵魂社交APP,分享其开发过程和项目源码,助力程序员高效开发,快速集成。

 

直播大纲:

1)项目介绍

国内首个程序猿非严肃婚恋交友应用——猿匹配

(2)开发环境

在最新的Android开发环境下开发,使用Java8的一些新特性,比如Lambda表达式等

· Mac OS 10.14.4

· Android Studio 3.3.2

(3)功能介绍

· IM功能

会话与消息功能,包括图片、文本、表情等消息,还包括语音实时通话与视频实时通话功能的开发等

· APP功能

包括聊天、设置、社区等板块开发

· 发布功能

含多渠道打包、签名配置、开发与线上环境配置、敏感信息保护等

(4)配置运行


提供一些地址:

自定义工具库:https://github.com/lzan13/VMLibrary

 

收起阅读 »

常见的8个前端防御性编程方案

关于前端防御性编程我们大多数情况可能遇到过,后端的由于同时请求人数过多,或者数据量过大,又或者是因为异常导致服务异常,接口请求失败,然后前端出现白屏或者报错还有一种情况,是前端自身写的代码存在一些缺陷,整个系统不够健壮,从而会出现白屏,或者业务系统异常,用户误...
继续阅读 »

关于前端防御性编程

  • 我们大多数情况可能遇到过,后端的由于同时请求人数过多,或者数据量过大,又或者是因为异常导致服务异常,接口请求失败,然后前端出现白屏或者报错
  • 还有一种情况,是前端自身写的代码存在一些缺陷,整个系统不够健壮,从而会出现白屏,或者业务系统异常,用户误操作等
  • 那么,就出现了前端防御性编程

常见的问题和防范

1.最常见的问题:
uncaught TypeError: Cannot read property 'c' of undefined

出现这个问题最根本原因是:

当我们初始化一个对象obj为{}时候,obj.a这个时候是undefined.我们打印obj.a可以得到undefined,但是我们打印obj.a.c的时候,就会出现上面的错误。js对象中的未初始化属性值是undefined,从undefined读取属性就会导致这个错误(同理,null也一样)

如何避免?

js和ts目前都出现了一个可选链概念,例如:

const obj = {};
console.log(obj?.b?.c?.d)
上面的代码并不会报错,原因是?.遇到是空值的时候便会返回undefined.
2.前端接口层面的错误机制捕获

前端的接口调用,一般都比较频繁,我们这时候可以考虑使用单例模式,将所有的axios请求都用一个函数封装一层。统一可以在这个函数中catch捕获接口调用时候的未知错误,伪代码如下:

function ajax(url,data,method='get'){
const promise = axios[method](url,data)
return promise.then(res=>{
}).catch(error){
//统一处理错误
}
}

那么只要发生接口调用的未知错误都会在这里被处理了

3.错误边界(Error Boundaries,前端出现未知错误时,展示预先设定的UI界面)

以React为例

部分 UI 的 JavaScript 错误不应该导致整个应用崩溃,为了解决这个问题,React 16 引入了一个新的概念 —— 错误边界。

错误边界是一种 React 组件,这种组件可以捕获并打印发生在其子组件树任何位置的 JavaScript 错误,并且,它会渲染出备用 UI,而不是渲染那些崩溃了的子组件树。错误边界在渲染期间、生命周期方法和整个组件树的构造函数中捕获错误。

使用示例:

class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError(error) {
// 更新 state 使下一次渲染能够显示降级后的 UI
return { hasError: true };
}

componentDidCatch(error, errorInfo) {
// 你同样可以将错误日志上报给服务器
logErrorToMyService(error, errorInfo);
}

render() {
if (this.state.hasError) {
// 你可以自定义降级后的 UI 并渲染
return <h1>Something went wrong.</h1>;
}

return this.props.children;
}
}
注意
  • 错误边界无法捕获以下场景中产生的错误:

    • 事件处理(了解更多)
    • 异步代码(例如 setTimeout 或 requestAnimationFrame 回调函数)
    • 服务端渲染
    • 它自身抛出来的错误(并非它的子组件)
4.前端复杂异步场景导致的错误
  • 这个问题可能远不止这么简单,但是大道至简,遵循单向数据流的方式去改变数据,例如:

  • //test.js
    export const obj = {
    a:1,
    b:2
    }

    //使用obj
    import {obj} from './test.js';
    obj.a=3;

    当你频繁使用这个obj对象时,你无法根据代码去知道它的改变顺序(即在某个时刻它的值是什么),而且这里面可能存在不少异步的代码,当我们换一种方式,就能知道它的改变顺序了,也更方便我们debug

    例如://test.js

    export const obj = {
    a:1,
    b:2
    }
    export function setObj (key,value) {
    console.log(key,value)
    obj[key] = value
    }
    这样,我们就做到了
    5.前端专注“前端”
    • 对于一些敏感数据,例如登录态,鉴权相关的。前端应该是尽量做无感知的转发、携带(这样也不会出现安全问题)
    6.页面做到可降级
    • 对于一些刚上新的业务,或者有存在风险的业务模块,或者会调取不受信任的接口,例如第三方的接口,这个时候就要做一层降级处理,例如接口调用失败后,剔除对应模块的展示,让用户无感知的使用
    7.巧用loading和disabled
    • 用户操作后,要及时loading和disabled确保不让用户进行重复,防止业务侧出现bug

    8.慎用innerHTML

    • 容易出现安全漏洞,例如接口返回了一段JavaScript脚本,那么就会立即执行。此时脚本如果是恶意的,那么就会出现不可预知的后果,特别是电商行业,尤其要注意


收起阅读 »

嗨,你真的懂this吗?

this关键字是JavaScript中最复杂的机制之一,是一个特别的关键字,被自动定义在所有函数的作用域中,但是相信很多JsvaScript开发者并不是非常清楚它究竟指向的是什么。听说你很懂this,是真的吗?请先回答第一个问题:如何准确判断this指向的是什...
继续阅读 »

this关键字是JavaScript中最复杂的机制之一,是一个特别的关键字,被自动定义在所有函数的作用域中,但是相信很多JsvaScript开发者并不是非常清楚它究竟指向的是什么。听说你很懂this,是真的吗?

请先回答第一个问题:如何准确判断this指向的是什么?【面试的高频问题】


【图片来源于网络,侵删】

再看一道题,控制台打印出来的值是什么?【浏览器运行环境】

var number = 5;
var obj = {
number: 3,
fn1: (function () {
var number;
this.number *= 2;
number = number * 2;
number = 3;
return function () {
var num = this.number;
this.number *= 2;
console.log(num);
number *= 3;
console.log(number);
}
})()
}
var fn1 = obj.fn1;
fn1.call(null);
obj.fn1();
console.log(window.number);

如果你思考出来的结果,与在浏览中执行结果相同,并且每一步的依据都非常清楚,那么,你可以选择继续往下阅读,或者关闭本网页,愉快得去玩耍。如果你有一部分是靠蒙的,或者对自己的答案并不那么确定,那么请继续往下阅读。

毕竟花一两个小时的时间,把this彻底搞明白,是一件很值得事情,不是吗?

本文将细致得讲解this的绑定规则,并在最后剖析前文两道题。

为什么要学习this?

首先,我们为什么要学习this?

  1. this使用频率很高,如果我们不懂this,那么在看别人的代码或者是源码的时候,就会很吃力。
  2. 工作中,滥用this,却没明白this指向的是什么,而导致出现问题,但是自己却不知道哪里出问题了。【在公司,我至少帮10个以上的开发人员处理过这个问题】
  3. 合理的使用this,可以让我们写出简洁且复用性高的代码。
  4. 面试的高频问题,回答不好,抱歉,出门右拐,不送。

不管出于什么目的,我们都需要把this这个知识点整的明明白白的。

OK,Let's go!

this是什么?

言归正传,this是什么?首先记住this不是指向自身!this 就是一个指针,指向调用函数的对象。这句话我们都知道,但是很多时候,我们未必能够准确判断出this究竟指向的是什么?这就好像我们听过很多道理 却依然过不好这一生。今天咱们不探讨如何过好一生的问题,但是呢,希望阅读完下面的内容之后,你能够一眼就看出this指向的是什么。

为了能够一眼看出this指向的是什么,我们首先需要知道this的绑定规则有哪些?

  1. 默认绑定
  2. 隐式绑定
  3. 硬绑定
  4. new绑定

上面的名词,你也许听过,也许没听过,但是今天之后,请牢牢记住。我们将依次来进行解析。

默认绑定

默认绑定,在不能应用其它绑定规则时使用的默认规则,通常是独立函数调用。

function sayHi(){
console.log('Hello,', this.name);
}
var name = 'YvetteLau';
sayHi();

在调用Hi()时,应用了默认绑定,this指向全局对象(非严格模式下),严格模式下,this指向undefined,undefined上没有this对象,会抛出错误。

上面的代码,如果在浏览器环境中运行,那么结果就是 Hello,YvetteLau

但是如果在node环境中运行,结果就是 Hello,undefined.这是因为node中name并不是挂在全局对象上的。

本文中,如不特殊说明,默认为浏览器环境执行结果。

隐式绑定

函数的调用是在某个对象上触发的,即调用位置上存在上下文对象。典型的形式为 XXX.fun().我们来看一段代码:

function sayHi(){
console.log('Hello,', this.name);
}
var person = {
name: 'YvetteLau',
sayHi: sayHi
}
var name = 'Wiliam';
person.sayHi();

打印的结果是 Hello,YvetteLau.

sayHi函数声明在外部,严格来说并不属于person,但是在调用sayHi时,调用位置会使用person的上下文来引用函数,隐式绑定会把函数调用中的this(即此例sayHi函数中的this)绑定到这个上下文对象(即此例中的person)

需要注意的是:对象属性链中只有最后一层会影响到调用位置。

function sayHi(){
console.log('Hello,', this.name);
}
var person2 = {
name: 'Christina',
sayHi: sayHi
}
var person1 = {
name: 'YvetteLau',
friend: person2
}
person1.friend.sayHi();

结果是:Hello, Christina.

因为只有最后一层会确定this指向的是什么,不管有多少层,在判断this的时候,我们只关注最后一层,即此处的friend。

隐式绑定有一个大陷阱,绑定很容易丢失(或者说容易给我们造成误导,我们以为this指向的是什么,但是实际上并非如此).

function sayHi(){
console.log('Hello,', this.name);
}
var person = {
name: 'YvetteLau',
sayHi: sayHi
}
var name = 'Wiliam';
var Hi = person.sayHi;
Hi();

结果是: Hello,Wiliam.

这是为什么呢,Hi直接指向了sayHi的引用,在调用的时候,跟person没有半毛钱的关系,针对此类问题,我建议大家只需牢牢继续这个格式:XXX.fn();fn()前如果什么都没有,那么肯定不是隐式绑定,但是也不一定就是默认绑定,这里有点小疑问,我们后来会说到。

除了上面这种丢失之外,隐式绑定的丢失是发生在回调函数中(事件回调也是其中一种),我们来看下面一个例子:

function sayHi(){
console.log('Hello,', this.name);
}
var person1 = {
name: 'YvetteLau',
sayHi: function(){
setTimeout(function(){
console.log('Hello,',this.name);
})
}
}
var person2 = {
name: 'Christina',
sayHi: sayHi
}
var name='Wiliam';
person1.sayHi();
setTimeout(person2.sayHi,100);
setTimeout(function(){
person2.sayHi();
},200);

结果为:

Hello, Wiliam
Hello, Wiliam
Hello, Christina
  • 第一条输出很容易理解,setTimeout的回调函数中,this使用的是默认绑定,非严格模式下,执行的是全局对象
  • 第二条输出是不是有点迷惑了?说好XXX.fun()的时候,fun中的this指向的是XXX呢,为什么这次却不是这样了!Why?

    其实这里我们可以这样理解: setTimeout(fn,delay){ fn(); },相当于是将person2.sayHi赋值给了一个变量,最后执行了变量,这个时候,sayHi中的this显然和person2就没有关系了。

  • 第三条虽然也是在setTimeout的回调中,但是我们可以看出,这是执行的是person2.sayHi()使用的是隐式绑定,因此这是this指向的是person2,跟当前的作用域没有任何关系。

读到这里,也许你已经有点疲倦了,但是答应我,别放弃,好吗?再坚持一下,就可以掌握这个知识点了。


显式绑定

显式绑定比较好理解,就是通过call,apply,bind的方式,显式的指定this所指向的对象。(注意:《你不知道的Javascript》中将bind单独作为了硬绑定讲解了)

call,apply和bind的第一个参数,就是对应函数的this所指向的对象。call和apply的作用一样,只是传参方式不同。call和apply都会执行对应的函数,而bind方法不会。

function sayHi(){
console.log('Hello,', this.name);
}
var person = {
name: 'YvetteLau',
sayHi: sayHi
}
var name = 'Wiliam';
var Hi = person.sayHi;
Hi.call(person); //Hi.apply(person)

输出的结果为: Hello, YvetteLau. 因为使用硬绑定明确将this绑定在了person上。

那么,使用了硬绑定,是不是意味着不会出现隐式绑定所遇到的绑定丢失呢?显然不是这样的,不信,继续往下看。

function sayHi(){
console.log('Hello,', this.name);
}
var person = {
name: 'YvetteLau',
sayHi: sayHi
}
var name = 'Wiliam';
var Hi = function(fn) {
fn();
}
Hi.call(person, person.sayHi);

输出的结果是 Hello, Wiliam. 原因很简单,Hi.call(person, person.sayHi)的确是将this绑定到Hi中的this了。但是在执行fn的时候,相当于直接调用了sayHi方法(记住: person.sayHi已经被赋值给fn了,隐式绑定也丢了),没有指定this的值,对应的是默认绑定。

现在,我们希望绑定不会丢失,要怎么做?很简单,调用fn的时候,也给它硬绑定。

function sayHi(){
console.log('Hello,', this.name);
}
var person = {
name: 'YvetteLau',
sayHi: sayHi
}
var name = 'Wiliam';
var Hi = function(fn) {
fn.call(this);
}
Hi.call(person, person.sayHi);

此时,输出的结果为: Hello, YvetteLau,因为person被绑定到Hi函数中的this上,fn又将这个对象绑定给了sayHi的函数。这时,sayHi中的this指向的就是person对象。

至此,革命已经快胜利了,我们来看最后一种绑定规则: new 绑定。

new 绑定

javaScript和C++不一样,并没有类,在javaScript中,构造函数只是使用new操作符时被调用的函数,这些函数和普通的函数并没有什么不同,它不属于某个类,也不可能实例化出一个类。任何一个函数都可以使用new来调用,因此其实并不存在构造函数,而只有对于函数的“构造调用”。

使用new来调用函数,会自动执行下面的操作:
  1. 创建一个新对象
  2. 将构造函数的作用域赋值给新对象,即this指向这个新对象
  3. 执行构造函数中的代码
  4. 返回新对象

因此,我们使用new来调用函数的时候,就会新对象绑定到这个函数的this上。

function sayHi(name){
this.name = name;

}
var Hi = new sayHi('Yevtte');
console.log('Hello,', Hi.name);

输出结果为 Hello, Yevtte, 原因是因为在var Hi = new sayHi('Yevtte');这一步,会将sayHi中的this绑定到Hi对象上。

绑定优先级

我们知道了this有四种绑定规则,但是如果同时应用了多种规则,怎么办?

显然,我们需要了解哪一种绑定方式的优先级更高,这四种绑定的优先级为:

new绑定 > 显式绑定 > 隐式绑定 > 默认绑定

这个规则时如何得到的,大家如果有兴趣,可以自己写个demo去测试,或者记住上面的结论即可。

绑定例外

凡事都有例外,this的规则也是这样。

如果我们将null或者是undefined作为this的绑定对象传入call、apply或者是bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。

var foo = {
name: 'Selina'
}
var name = 'Chirs';
function bar() {
console.log(this.name);
}
bar.call(null); //Chirs

输出的结果是 Chirs,因为这时实际应用的是默认绑定规则。

箭头函数

箭头函数是ES6中新增的,它和普通函数有一些区别,箭头函数没有自己的this,它的this继承于外层代码库中的this。箭头函数在使用时,需要注意以下几点:

(1)函数体内的this对象,继承的是外层代码块的this。

(2)不可以当作构造函数,也就是说,不可以使用new命令,否则会抛出一个错误。

(3)不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。

(4)不可以使用yield命令,因此箭头函数不能用作 Generator 函数。

(5)箭头函数没有自己的this,所以不能用call()、apply()、bind()这些方法去改变this的指向.

OK,我们来看看箭头函数的this是什么?

var obj = {
hi: function(){
console.log(this);
return ()=>{
console.log(this);
}
},
sayHi: function(){
return function() {
console.log(this);
return ()=>{
console.log(this);
}
}
},
say: ()=>{
console.log(this);
}
}
let hi = obj.hi(); //输出obj对象
hi(); //输出obj对象
let sayHi = obj.sayHi();
let fun1 = sayHi(); //输出window
fun1(); //输出window
obj.say(); //输出window

那么这是为什么呢?如果大家说箭头函数中的this是定义时所在的对象,这样的结果显示不是大家预期的,按照这个定义,say中的this应该是obj才对。

我们来分析一下上面的执行结果:

  1. obj.hi(); 对应了this的隐式绑定规则,this绑定在obj上,所以输出obj,很好理解。
  2. hi(); 这一步执行的就是箭头函数,箭头函数继承上一个代码库的this,刚刚我们得出上一层的this是obj,显然这里的this就是obj.
  3. 执行sayHi();这一步也很好理解,我们前面说过这种隐式绑定丢失的情况,这个时候this执行的是默认绑定,this指向的是全局对象window.
  4. fun1(); 这一步执行的是箭头函数,如果按照之前的理解,this指向的是箭头函数定义时所在的对象,那么这儿显然是说不通。OK,按照箭头函数的this是继承于外层代码库的this就很好理解了。外层代码库我们刚刚分析了,this指向的是window,因此这儿的输出结果是window.
  5. obj.say(); 执行的是箭头函数,当前的代码块obj中是不存在this的,只能往上找,就找到了全局的this,指向的是window.

你说箭头函数的this是静态的?

依旧是前面的代码。我们来看看箭头函数中的this真的是静态的吗?

我要说:非也

var obj = {
hi: function(){
console.log(this);
return ()=>{
console.log(this);
}
},
sayHi: function(){
return function() {
console.log(this);
return ()=>{
console.log(this);
}
}
},
say: ()=>{
console.log(this);
}
}
let sayHi = obj.sayHi();
let fun1 = sayHi(); //输出window
fun1(); //输出window

let fun2 = sayHi.bind(obj)();//输出obj
fun2(); //输出obj

可以看出,fun1和fun2对应的是同样的箭头函数,但是this的输出结果是不一样的。

所以,请大家牢牢记住一点: 箭头函数没有自己的this,箭头函数中的this继承于外层代码库中的this.

总结

关于this的规则,至此,就告一段落了,但是想要一眼就能看出this所绑定的对象,还需要不断的训练。

我们来回顾一下,最初的问题。

1. 如何准确判断this指向的是什么?

  1. 函数是否在new中调用(new绑定),如果是,那么this绑定的是新创建的对象。
  2. 函数是否通过call,apply调用,或者使用了bind(即硬绑定),如果是,那么this绑定的就是指定的对象。
  3. 函数是否在某个上下文对象中调用(隐式绑定),如果是的话,this绑定的是那个上下文对象。一般是obj.foo()
  4. 如果以上都不是,那么使用默认绑定。如果在严格模式下,则绑定到undefined,否则绑定到全局对象。
  5. 如果把Null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际应用的是默认绑定规则。
  6. 如果是箭头函数,箭头函数的this继承的是外层代码块的this。

2. 执行过程解析

var number = 5;
var obj = {
number: 3,
fn: (function () {
var number;
this.number *= 2;
number = number * 2;
number = 3;
return function () {
var num = this.number;
this.number *= 2;
console.log(num);
number *= 3;
console.log(number);
}
})()
}
var myFun = obj.fn;
myFun.call(null);
obj.fn();
console.log(window.number);

我们来分析一下,这段代码的执行过程。

1.在定义obj的时候,fn对应的闭包就执行了,返回其中的函数,执行闭包中代码时,显然应用不了new绑定(没有出现new 关键字),硬绑定也没有(没有出现call,apply,bind关键字),隐式绑定有没有?很显然没有,如果没有XX.fn(),那么可以肯定没有应用隐式绑定,所以这里应用的就是默认绑定了,非严格模式下this绑定到了window上(浏览器执行环境)。【这里很容易被迷惑的就是以为this指向的是obj,一定要注意,除非是箭头函数,否则this跟词法作用域是两回事,一定要牢记在心】

window.number * = 2; //window.number的值是10(var number定义的全局变量是挂在window上的)

number = number * 2; //number的值是NaN;注意我们这边定义了一个number,但是没有赋值,number的值是undefined;Number(undefined)->NaN

number = 3; //number的值为3

2.myFun.call(null);我们前面说了,call的第一个参数传null,调用的是默认绑定;

fn: function(){
var num = this.number;
this.number *= 2;
console.log(num);
number *= 3;
console.log(number);
}

执行时:

var num = this.number; //num=10; 此时this指向的是window
this.number * = 2; //window.number = 20
console.log(num); //输出结果为10
number *= 3; //number=9; 这个number对应的闭包中的number;闭包中的number的是3
console.log(number); //输出的结果是9

3.obj.fn();应用了隐式绑定,fn中的this对应的是obj.

var num = this.number;//num = 3;此时this指向的是obj
this.number *= 2; //obj.number = 6;
console.log(num); //输出结果为3;
number *= 3; //number=27;这个number对应的闭包中的number;闭包中的number的此时是9
console.log(number);//输出的结果是27

4.最后一步console.log(window.number);输出的结果是20

因此组中结果为:

10
9
3
27
20

严格模式下结果,大家可以根据今天所学,自己分析,巩固一下知识点。

最后,恭喜坚持读完的小伙伴们,你们成功get到了this这个知识点,但是想要完全掌握,还是要多回顾和练习。如果你有不错的this练习题,欢迎在评论区留言哦,大家一起进步!


原文:https://segmentfault.com/a/1190000018630013

收起阅读 »

前端基础-你真的懂函数吗?

前言众所周知,在前端开发领域中,函数是一等公民,由此可见函数的重要性,本文旨在介绍函数中的一些特性与方法,对函数有更好的认知正文1.箭头函数ECMAScript 6 新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与...
继续阅读 »

前言

众所周知,在前端开发领域中,函数是一等公民,由此可见函数的重要性,本文旨在介绍函数中的一些特性与方法,对函数有更好的认知

正文

1.箭头函数

ECMAScript 6 新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:

let arrowSum = (a, b) => { 
return a + b;
};
let functionExpressionSum = function(a, b) {
return a + b;
};
console.log(arrowSum(5, 8)); // 13
console.log(functionExpressionSum(5, 8)); // 13

使用箭头函数须知:

  • 箭头函数的函数体如果不用大括号括起来会隐式返回这行代码的值
  • 箭头函数不能使用 argumentssuper 和new.target,也不能用作构造函数
  • 箭头函数没有 prototype 属性

2.函数声明与函数表达式

JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。

// 没问题 
console.log(sum(10, 10));
function sum(num1, num2) {
return num1 + num2;
}

以上代码可以正常运行,因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升(function declaration hoisting)。在执行代码时,JavaScript 引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:

// 会出错
console.log(sum(10, 10));
let sum = function(num1, num2) {
return num1 + num2;
};

上述代码的报错有一些同学可能认为是let导致的暂时性死区。其实原因并不出在这里,这是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。这意味着代码如果没有执行到let的那一行,那么执行上下文中就没有函数的定义。大家可以自己尝试一下,就算是用var来定义,也是一样会出错。

3.函数内部

在 ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和 this。ECMAScript 6 又新增了 new.target 属性。

arguments

它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以 function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。但 arguments 对象其实还有一个 callee 属性,是一个指向 arguments 对象所在函数的指针。

function factorial(num) { 
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}

// 上述代码可以运用arguments来进行解耦
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
}

这个重写之后的 factorial()函数已经用 arguments.callee 代替了之前硬编码的 factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。

arguments.callee 的解耦示例
let trueFactorial = factorial; 
factorial = function() {
return 0;
};
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0

这里 factorial 函数在赋值给trueFactorial后被重写了 那么我们如果在递归中不使用arguments.callee 那么显然trueFactorial(5)的运行结果也是0,但是我们解耦之后,新的变量还是可以正常的进行

this

函数内部另一个特殊的对象是 this,它在标准函数和箭头函数中有不同的行为。

在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为 this 值(在网页的全局上下文中调用函数时,this 指向 windows)。

在箭头函数中,this引用的是定义箭头函数的上下文。

caller

这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为 null。

function outer() { 
inner();
}
function inner() {
console.log(inner.caller);
}
outer();

以上代码会显示 outer()函数的源代码。这是因为 ourter()调用了 inner(),inner.caller指向 outer()。如果要降低耦合度,则可以通过 arguments.callee.caller 来引用同样的值:

function outer() { 
inner();
}
function inner() {
console.log(arguments.callee.caller);
}
outer();

new.target

ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6 新增了检测函数是否使用 new 关键字调用的 new.target 属性。如果函数是正常调用的,则 new.target 的值是 undefined;如果是使用 new 关键字调用的,则 new.target 将引用被调用的构造函数。

function King() { 
if (!new.target) {
throw 'King must be instantiated using "new"'
}
console.log('King instantiated using "new"');
}
new King(); // King instantiated using "new"
King(); // Error: King must be instantiated using "new"

这里可以做一些延申,还有没有其他办法来判断函数是否通过new来调用的呢?

可以使用 instanceof 来判断。我们知道在new的时候发生了哪些操作?用如下代码表示:

var p = new Foo()
// 实际上执行的是
// 伪代码
var o = new Object(); // 或 var o = {}
o.__proto__ = Foo.prototype
Foo.call(o)
return o

上述伪代码在MDN是这么说的:

  1. 一个继承自 Foo.prototype 的新对象被创建。
  2. 使用指定的参数调用构造函数 Foo,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数列表,Foo 不带任何参数调用的情况。
  3. 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤1创建的对象。(一般情况下,构造函数不返回值,但是用户可以选择主动返回对象,来覆盖正常的对象创建步骤)

new 的操作说完了 现在我们看一下 instanceof,MDN上是这么说的:instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

也就是说,A的N个__proto__ 全等于 B.prototype,那么A instanceof B返回true,现在知识点已经介绍完毕,可以开始上代码了

  function Person() {
if (this instanceof Person) {
console.log("通过new 创建");
return this;
} else {
console.log("函数调用");
}
}
const p = new Person(); // 通过new创建
Person(); // 函数调用

解析:我们知道new构造函数的this指向实例,那么上述代码不难得出以下结论this.__proto__ === Person.prototype。所以这样就可以判断函数是通过new还是函数调用

这里我们其实还可以将 this instanceof Person 改写为 this instanceof arguments.callee

4.闭包

终于说到了闭包,闭包这玩意真的是面试必问,所以掌握还是很有必要的

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

function foo() {
var a = 20;
var b = 30;

function bar() {
return a + b;
}
return bar;
}

上述代码中,由于foo函数内部的bar函数使用了foo函数内部的变量,并且bar函数return把变量return了出去,这样闭包就产生了,这使得我们可以在外部拿到这些变量。

const b = foo();
b() // 50

foo函数在调用的时候创建了一个执行上下文,可以在此上下文中使用a,b变量,理论上说,在foo调用结束,函数内部的变量会v8引擎的垃圾回收机制通过特定的标记回收。但是在这里,由于闭包的产生,a,b变量并不会被回收,这就导致我们在全局上下文(或其他执行上下文)中可以访问到函数内部的变量。

我之前看到了一个说法:

无论何时声明新函数并将其赋值给变量,都要存储函数定义和闭包,闭包包含在函数创建时作用域中的所有变量,类似于背包,函数定义附带一个小背包,它的包中存储了函数定义时作用域中的所有变量

以此引申出一个经典面试题

for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}

怎样可以使得上述代码的输出变为1,2,3,4,5?

使用es6我们可以很简单的做出解答:将var换成let。

那么我们使用刚刚学到的闭包知识怎么来解答呢?代码如下:

for (var i = 1; i <= 5; i++) {
(function (i) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
})(i)
}

根据上面的说法,将闭包看成一个背包,背包中包含定义时的变量,每次循环时,将i值保存在一个闭包中,当setTimeout中定义的操作执行时,则访问对应闭包保存的i值,即可解决。

5.立即调用的函数表达式(IIFE)

如下就是立即调用函数表达式

(function() { 
// 块级作用域
})();

使用 IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。

// IIFE 
(function () {
for (var i = 0; i < count; i++) {
console.log(i);
}
})();
console.log(i); // 抛出错误

ES6的块级作用域:

// 内嵌块级作用域 
{
let i;
for (i = 0; i < count; i++) {
console.log(i);
}
}
console.log(i); // 抛出错误
// 循环的块级作用域
for (let i = 0; i < count; i++) {
console.log(i);
}
console.log(i); // 抛出错误

IIFE的另一个作用就是上文中的解决settimeout的输出问题

附录知识点

关于instanceof

Function instanceof Object;//true
Object instanceof Function;//true

上述代码大家可以尝试在浏览器中跑一下,非常的神奇,那么这是什么原因呢?

借用大佬的一张图


//构造器Function的构造器是它自身
Function.constructor=== Function;//true

//构造器Object的构造器是Function(由此可知所有构造器的constructor都指向Function)
Object.constructor === Function;//true



//构造器Function的__proto__是一个特殊的匿名函数function() {}
console.log(Function.__proto__);//function() {}

//这个特殊的匿名函数的__proto__指向Object的prototype原型。
Function.__proto__.__proto__ === Object.prototype//true

//Object的__proto__指向Function的prototype,也就是上面中所述的特殊匿名函数
Object.__proto__ === Function.prototype;//true
Function.prototype === Function.__proto__;//true

结论:

  1. 所有的构造器的constructor都指向Function
  2. Function的prototype指向一个特殊匿名函数,而这个特殊匿名函数的__proto__指向Object.prototype

结尾

本文主要参考 《JavaScript 高级程序设计 第四版》 由于作者水平有限,如有错误,敬请与我联系,谢谢您的阅读!

原文:https://segmentfault.com/a/1190000039904453



收起阅读 »

避免 iOS 组件依赖冲突的小技巧

问题缘由本文以 YBImageBrowser 组件举例。YBImageBrowser 依赖了 SDWebImage,在使用 CocoaPods 集成到项目中时,可能会出现一些依赖冲突的问题,最近社区提了多个 Issues 并且在 Insights -> ...
继续阅读 »

问题缘由

本文以 YBImageBrowser 组件举例。

YBImageBrowser 依赖了 SDWebImage,在使用 CocoaPods 集成到项目中时,可能会出现一些依赖冲突的问题,最近社区提了多个 Issues 并且在 Insights -> Traffic -> Popular content 中看到了此类问题很高的关注度,所以不得不着手解决。

严格的版本限制

一个开源组件的迭代过程中,保证上层接口的向下兼容就不错了。为了优化性能并且控制内存,YBImageBrowser 没有直接用其最上层的接口,而是单独使用了下载模块和缓存模块,SDWebImage 的迭代升级很容易导致笔者的组件兼容不了,所以之前一直是类似这样依赖的:

s.dependency 'SDWebImage', '~> 5.0.0'

这样做的好处是限制足够小版本范围,降低 SDWebImage 接口变动导致组件代码错误的风险。但如果 SDWebImage 升级到 5.1.0,不管相关 API 是否变动,CocoaPods 都视为依赖冲突。

其它组件依赖了不同版本的 SDWebImage

当两个组件依赖了同一个组件的不同版本,并且依赖的版本没有交集,比如:

A.dependency 'SDWebImage', '~> 4.0.0'
B.dependency 'SDWebImage', '~> 5.0.0'

那么 A 和 B 同时集成进项目会出现依赖冲突。

解决方案

使用 CocoaPods 集成项目非常便捷,对于组件使用者来说,总是想在任何场景下都能轻易集成,并且能在将来享受组件的更新优化,显然前面提到的问题可能会影响集成的便捷性。

更模糊的版本限制

很多时候一个大版本的组件不会改动 API,并且对于社区流行的组件我们可以寄一定希望于其做好向下兼容,所以放宽依赖的版本限制能覆盖将来更多的版本(规则参考:podspec dependency):

s.dependency 'SDWebImage', '>= 5.0.0'

为什么不干脆去掉版本限制呢?
因为 YBImageBrowser 3.x 是基于 SDWebImage 5.0.0 开发的,笔者可以明确不兼容 5.0.0 之前的版本,所以在 SDWebImage 将来迭代版本出现相关 API 不兼容之前,这个限制都是“完美”覆盖所有版本的。

避免依赖冲突的暴力方案

当有其它组件依赖了不同版本的 SDWebImage,粗暴的解决方案如下:

  • 直接修改其它组件依赖的 SDWebImage 版本。

  • 将 YBImageBrowser 手动导入项目,并且修改代码去适应当前的 SDWebImage 版本。

  • 社区朋友一个 Issue 中提到的方法:在 ~/.cocoapods/repos 目录下找到 YBImageBrowser 文件夹,更改对应版本的 podspec.json 文件里对 SDWebImage 的依赖版本。

显然,上面的几种方案不太优雅,手动导入项目难以享受组件的更新优化,修改本地 repo 信息会因为 repo 列表的更新而复位。

避免依赖冲突的优雅方案

出现依赖冲突是必须要解决的问题,其它组件依赖的版本限制可以视为不变量,解决方案可以从组件的制作方面考虑。

要做到的目标是,既满足部分用户快速集成组件,又能让部分用户解决依赖冲突的前提下保证能享受组件将来的更新优化。

答案就是subspec,以下是 YBImageBrowser.podspec 部分代码(完整代码):

s.subspec "Core" do |core|
core.source_files = "YBImageBrowser/**/*.{h,m}"
core.dependency 'SDWebImage', '>= 5.0.0'
end
s.subspec "NOSD" do |core|
core.source_files = "YBImageBrowser/**/*.{h,m}"
core.exclude_files = "YBImageBrowser/WebImageMediator/YBIBDefaultWebImageMediator.{h,m}"
end

由此,用户可以自由的选择是否需要依赖 SDWebImage,在 Podfile 里的观感大致是这样:

// 依赖 SDWebImage
pod 'YBImageBrowser'
// 不依赖 SDWebImage
pod 'YBImageBrowser/NOSD'

那么在 YBImageBrowser 代码中应该如何区分是否依赖了 SDWebImage 并且提供默认实现呢?

第一步是设计一个抽象接口(这个接口不依赖 SDWebImage):

@protocol YBIBWebImageMediator <NSObject>
// Download methode, caching methode, and so on.
@end

第二步是在YBImageBrowser.h中定义一个遵循该接口的属性:

/// 图片下载缓存相关的中介者(赋值可自定义)
@property (nonatomic, strong) id<YBIBWebImageMediator> webImageMediator;

第三步是实现一个默认的中介者(这个类依赖了 SDWebImage):

@interface YBIBDefaultWebImageMediator : NSObject <YBIBWebImageMediator>
@end
@implementation YBIBDefaultWebImageMediator
//通过 SDWebImage 的 API 实现 <YBIBWebImageMediator> 协议方法
@end

第四步是在内部代码中通过条件编译导入并初始化默认中介者:

#if __has_include("YBIBDefaultWebImageMediator.h")
#import "YBIBDefaultWebImageMediator.h"
#endif
...
#if __has_include("YBIBDefaultWebImageMediator.h")
_webImageMediator = [YBIBDefaultWebImageMediator new];
#endif

第五步在 YBImageBrowser.podspec 中也可以看到,在不依赖 SDWebImage 的集成方式时排除了两个文件:YBIBDefaultWebImageMediator.{h.m}。

由此便实现了目标:

  • 用依赖 SDWebImage 的集成方式快速集成。

  • 使用不依赖 SDWebImage 的集成方式避免各种情况下的依赖冲突,但注意这种情况需要自行实现一个遵循<YBIBWebImageMediator>协议的中介者。

以上便是避免依赖冲突的小技巧,希望读者朋友能提出更好的建议或意见😁。

链接:https://www.jianshu.com/p/0e3283275300

收起阅读 »

什么,项目构建时内存溢出了?了解一下 node 内存限制

背景在之前的一篇文章中, 我们遇到了一个项目在构建时内存溢出的问题。当时的解决方案是: 直接调大 node 的内存限制,避免达到内存上限。今天听同事分享了一个新方法,觉得不错, 特此记录, 顺便分享给大家, 希望对大家有所帮助。正文但 Node 进程...
继续阅读 »

背景

在之前的一篇文章中, 我们遇到了一个项目在构建时内存溢出的问题。

当时的解决方案是: 直接调大 node 的内存限制,避免达到内存上限。

今天听同事分享了一个新方法,觉得不错, 特此记录, 顺便分享给大家, 希望对大家有所帮助。

正文


但 Node 进程的内存限制会是多少呢?

在网上查阅了到如下描述:

Currently, by default V8 has a memory limit of 512mb on 32-bit systems, and 1gb on 64-bit systems. The limit can be raised by setting --max-old-space-size to a maximum of ~1gb (32-bit) and ~1.7gb (64-bit), but it is recommended that you split your single process into several workers if you are hitting memory limits.

翻译一下:

当前,默认情况下,V8在32位系统上的内存限制为512mb,在64位系统上的内存限制为1gb。

可以通过将--max-old-space-size设置为最大〜1gb(32位)和〜1.7gb(64位)来提高此限制,但是如果达到内存限制, 建议您将单个进程拆分为多个工作进程

如果你想知道自己电脑的内存限制有多大, 可以直接把内存撑爆, 看报错。

运行如下代码:

// Small program to test the maximum amount of allocations in multiple blocks.
// This script searches for the largest allocation amount.

// Allocate a certain size to test if it can be done.
function alloc (size) {
const numbers = size / 8;
const arr = []
arr.length = numbers; // Simulate allocation of 'size' bytes.
for (let i = 0; i < numbers; i++) {
arr[i] = i;
}
return arr;
};

// Keep allocations referenced so they aren't garbage collected.
const allocations = [];

// Allocate successively larger sizes, doubling each time until we hit the limit.
function allocToMax () {
console.log("Start");

const field = 'heapUsed';
const mu = process.memoryUsage();

console.log(mu);

const gbStart = mu[field] / 1024 / 1024 / 1024;

console.log(`Start ${Math.round(gbStart * 100) / 100} GB`);

let allocationStep = 100 * 1024;

// Infinite loop
while (true) {
// Allocate memory.
const allocation = alloc(allocationStep);
// Allocate and keep a reference so the allocated memory isn't garbage collected.
allocations.push(allocation);
// Check how much memory is now allocated.
const mu = process.memoryUsage();
const gbNow = mu[field] / 1024 / 1024 / 1024;

console.log(`Allocated since start ${Math.round((gbNow - gbStart) * 100) / 100} GB`);
}

// Infinite loop, never get here.
};

allocToMax();


我的电脑是 Macbook Pro masOS Catalina 16GB,Node 版本是 v12.16.1,这段代码大概在 1.6 GB 左右内存时候抛出异常。

那我们现在知道 Node Process 确实是有一个内存限制的, 那我们就来增大它的内存限制再试一下。

用 node --max-old-space-size=6000 来运行这段代码,得到如下结果:


内存达到 4.6G 的时候也溢出了。

你可能会问, node 不是有内存回收吗?这个我们在下面会讲。

使用这个参数:node --max-old-space-size=6000, 我们增加的内存中老生代区域的大小,比较暴力。

就像上文中提到的: 如果达到内存限制, 建议您将单个进程拆分为多个工作进程

这个项目是一个 ts 项目,ts 文件的编译是比较占用内存的,如果把这部分独立成一个单独的进程, 情况也会有所改善。

因为 ts-loader 内部调用了 tsc,在使用 ts-loader 时,会使用 tsconfig.js配置文件。

当项目中的代码变的越来越多,体积也越来越庞大时,项目编译时间也随之增加。

这是因为 Typescript 的语义检查器必须在每次重建时检查所有文件

ts-loader 提供了一个 transpileOnly 选项,它默认为 false,我们可以把它设置为 true,这样项目编译时就不会进行类型检查,也不会输出声明文件。

对一下 transpileOnly 分别设置 false 和 true 的项目构建速度对比:

  • 当 transpileOnly 为 false 时,整体构建时间为 4.88s.
  • 当 transpileOnly 为 true 时,整体构建时间为 2.40s.

虽然构建速度提升了,但是有了一个弊端: 打包编译不会进行类型检查

好在官方推荐了这样一个插件, 提供了这样的能力: fork-ts-checker-webpack-plugin

官方示例的使用也非常简单:

const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')

module.exports = {
...
plugins: [
new ForkTsCheckerWebpackPlugin()
]
}

在我这个实际的项目中,vue.config.js 修改如下:

configureWebpack: config => {
// get a reference to the existing ForkTsCheckerWebpackPlugin
const existingForkTsChecker = config.plugins.filter(
p => p instanceof ForkTsCheckerWebpackPlugin,
)[0];

// remove the existing ForkTsCheckerWebpackPlugin
// so that we can replace it with our modified version
config.plugins = config.plugins.filter(
p => !(p instanceof ForkTsCheckerWebpackPlugin),
);

// copy the options from the original ForkTsCheckerWebpackPlugin
// instance and add the memoryLimit property
const forkTsCheckerOptions = existingForkTsChecker.options;

forkTsCheckerOptions.memoryLimit = 4096;

config.plugins.push(new ForkTsCheckerWebpackPlugin(forkTsCheckerOptions));
}

修改之后, 构建就成功了。

关于垃圾回收

在 Node.js 里面,V8 自动帮助我们进行垃圾回收, 让我们简单看一下V8中如何处理内存。

一些定义

  • 常驻集大小:是RAM中保存的进程所占用的内存部分,其中包括:

    1. 代码本身
  • stack:包含原始类型和对对象的引用
  • 堆:存储引用类型,例如对象,字符串或闭包
  • 对象的浅层大小:对象本身持有的内存大小
  • 对象的保留大小:删除对象及其相关对象后释放的内存大小

垃圾收集器如何工作

垃圾回收是回收由应用程序不再使用的对象所占用的内存的过程。

通常,内存分配很便宜,而内存池用完时收集起来很昂贵。

如果无法从根节点访问对象,则该对象是垃圾回收的候选对象,因此该对象不会被根对象或任何其他活动对象引用。

根对象可以是全局对象,DOM元素或局部变量。

堆有两个主要部分,即 New Space和 Old Space

新空间是进行新分配的地方。

在这里收集垃圾的速度很快,大小约为1-8MB

留存在新空间中的物体被称为新生代

在新空间中幸存下来的物体被提升的旧空间-它们被称为老生代

旧空间中的分配速度很快,但是收集费用很高,因此很少执行。

node 垃圾回收

Why is garbage collection expensive?

The V8 JavaScript engine employs a stop-the-world garbage collector mechanism.

In practice, it means that the program stops execution while garbage collection is in progress.

通常,约20%的年轻一代可以存活到老一代,旧空间的收集工作将在耗尽后才开始。

为此,V8 引擎使用两种不同的收集算法

  1. Scavenge: 速度很快,可在新生代上运行,
  2. Mark-Sweep: 速度较慢,并且可以在老生代上运行。

篇幅有限,关于v8垃圾回收的更多信息,可以参考如下文章:

  1. http://jayconrod.com/posts/55...
  2. https://juejin.cn/post/684490...
  3. https://juejin.cn/post/684490...

总结

小小总结一下,上文介绍了两种方式:

  1. 直接加大内存,使用: node --max-old-space-size=4096
  2. 把一些耗内存进程独立出去, 使用了一个插件: fork-ts-checker-webpack-plugin

希望大家留个印象, 记得这两种方式。

好了, 内容就这么多, 谢谢。

才疏学浅,如有错误, 欢迎指正。

谢谢。

原文:https://segmentfault.com/a/1190000039877970


收起阅读 »

前端常用图片文件下载上传方法

本文整理了前端常用的下载文件以及上传文件的方法例子均以vue+element ui+axios为例,不使用el封装好的上传组件,这里自行进行封装实现先附上demo上传文件以图片为例,文件上传可以省略预览图片功能图片上传可以使用2种方式:文件流和base64;1...
继续阅读 »

本文整理了前端常用的下载文件以及上传文件的方法
例子均以vue+element ui+axios为例,不使用el封装好的上传组件,这里自行进行封装实现

先附上demo

上传文件

以图片为例,文件上传可以省略预览图片功能

图片上传可以使用2种方式:文件流base64;

1.文件流上传+预览

<input type="file" id='imgBlob' @change='changeImgBlob' />
<el-image style="width: 100px; height: 100px" :src="imgBlobSrc"></el-image>
// data
imgBlobSrc: ""

// methods
changeImgBlob() {
let file = document.querySelector("#imgBlob");
/**
*图片预览
*更适合PC端,兼容ie7+,主要功能点是window.URL.createObjectURL
*/
var ua = navigator.userAgent.toLowerCase();
if (/msie/.test(ua)) {
this.imgBlobSrc = file.value;
} else {
this.imgBlobSrc = window.URL.createObjectURL(file.files[0]);
}
//上传后台
const fd = new FormData();
fd.append("files", file.files[0]);
fd.append("xxxx", 11111); //其他字段,根据实际情况来
axios({
url: "/yoorUrl", //URL,根据实际情况来
method: "post",
headers: { "Content-Type": "multipart/form-data" },
data: fd
});
}



2.Base64上传+预览

<input type="file" id='imgBase' @change='changeImgBase' />
<el-image style="width: 100px; height: 100px" :src="imgBaseSrc"></el-image>
// data
imgBaseSrc : ""

// methods
changeImgBase() {
let that = this;
let file = document.querySelector("#imgBase");
/**
*图片预览
*更适合H5页面,兼容ie10+,图片base64显示,主要功能点是FileReader和readAsDataURL
*/
if (window.FileReader) {
var fr = new FileReader();
fr.onloadend = function (e) {
that.imgBaseSrc = e.target.result;
//上传后台
axios({
url: "/yoorUrl", //URL,根据实际情况来
method: "post",
data: {
files: that.imgBaseSrc
}
});
};
fr.readAsDataURL(file.files[0]);
}
}


下载文件

图片下载

假设需要下载图片为url文件流处理和这个一样

<el-image style="width: 100px; height: 100px" :src="downloadImgSrc"></el-image>
<el-button type="warning" round plain size="mini" @click='downloadImg'>点击下载</el-button>
  • 注意:这里需要指定 responseTypeblob
//data
downloadImgSrc:'https://i.picsum.photos/id/452/400/300.jpg?hmac=0-o_NOka_K6sQ_sUD84nxkExoDk3Bc0Qi7Y541CQZEs'
//methods
downloadImg() {
axios({
url: this.downloadImgSrc, //URL,根据实际情况来
method: "get",
responseType: "blob"
}).then(function (response) {
const link = document.createElement("a");
let blob = new Blob([response.data], { type: response.data.type });
let url = URL.createObjectURL(blob);
link.href = url;
link.download = `实际需要的文件名.${response.data.type.split('/')[1]}`;
link.click();
document.body.removeChild(link);
});
}

文件下载(以pdf为例)

<el-image style="width: 100px; height: 100px" :src="downloadImgSrc"></el-image>
<el-button type="warning" round plain size="mini" @click='downloadImg'>点击下载</el-button>
//data
downloadImgSrc:'https://i.picsum.photos/id/452/400/300.jpg?hmac=0-o_NOka_K6sQ_sUD84nxkExoDk3Bc0Qi7Y541CQZEs'
//methods
downloadImg() {
axios({
url: this.downloadImgSrc, //URL,根据实际情况来
method: "get",
responseType: "blob"
}).then(function (response) {
const link = document.createElement("a");
let blob = new Blob([response.data], { type: response.data.type });
let url = URL.createObjectURL(blob);
link.href = url;
link.download = `实际需要的文件名.${response.data.type.split('/')[1]}`;
link.click();
document.body.removeChild(link);
});
}

pdf预览可以参考如何预览以及下载pdf文件

原文:https://segmentfault.com/a/1190000039893814



收起阅读 »

iOS核心动画高级技巧-1

1. 图层树图层的树状结构巨妖有图层,洋葱也有图层,你有吗?我们都有图层 -- 史莱克Core Animation其实是一个令人误解的命名。你可能认为它只是用来做动画的,但实际上它是从一个叫做Layer Kit这么一个不怎么和动画有关的名字演变而来,所以做动画...
继续阅读 »

1. 图层树

图层的树状结构

巨妖有图层,洋葱也有图层,你有吗?我们都有图层 -- 史莱克
Core Animation其实是一个令人误解的命名。你可能认为它只是用来做动画的,但实际上它是从一个叫做Layer Kit这么一个不怎么和动画有关的名字演变而来,所以做动画这只是Core Animation特性的冰山一角。

Core Animation是一个复合引擎,它的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的图层,存储在一个叫做图层树的体系之中。于是这个树形成了UIKit以及在iOS应用程序当中你所能在屏幕上看见的一切的基础。

在我们讨论动画之前,我们将从图层树开始,涉及一下Core Animation的静态组合以及布局特性。

1.1 图层与视图

图层与视图

如果你曾经在iOS或者Mac OS平台上写过应用程序,你可能会对视图的概念比较熟悉。一个视图就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置。图1.1显示了一种典型的视图层级关系

1.2 图层的能力

图层的能力

如果说CALayer是UIView内部实现细节,那我们为什么要全面地了解它呢?苹果当然为我们提供了优美简洁的UIView接口,那么我们是否就没必要直接去处理Core Animation的细节了呢?

一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS交流群:1012951431, 分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!希望帮助开发者少走弯路。

某种意义上说的确是这样,对一些简单的需求来说,我们确实没必要处理CALayer,因为苹果已经通过UIView的高级API间接地使得动画变得很简单。

但是这种简单会不可避免地带来一些灵活上的缺陷。如果你略微想在底层做一些改变,或者使用一些苹果没有在UIView上实现的接口功能,这时除了介入Core Animation底层之外别无选择。

我们已经证实了图层不能像视图那样处理触摸事件,那么他能做哪些视图不能做的呢?这里有一些UIView没有暴露出来的CALayer的功能:

  • 阴影,圆角,带颜色的边框

  • 3D变换

  • 非矩形范围

  • 透明遮罩

  • 多级非线性动画

我们将会在后续章节中探索这些功能,首先我们要关注一下在应用程序当中CALayer是怎样被利用起来的。

1.3 使用图层

使用图层

首先我们来创建一个简单的项目,来操纵一些layer的属性。打开Xcode,使用Single View Application模板创建一个工程。

在屏幕中央创建一个小视图(大约200 X 200的尺寸),当然你可以手工编码,或者使用Interface Builder(随你方便)。确保你的视图控制器要添加一个视图的属性以便可以直接访问它。我们把它称作layerView。

运行项目,应该能在浅灰色屏幕背景中看见一个白色方块,如果没看见,可能需要调整一下背景window或者view的颜色

之后就可以在代码中直接引用CALayer的属性和方法。在清单1.1中,我们用创建了一个CALayer,设置了它的backgroundColor属性,然后添加到layerView背后相关图层的子图层(这段代码的前提是通过IB创建了layerView并做好了连接),图1.5显示了结果。

清单1.1 给视图添加一个蓝色子图层

#import "ViewController.h"
#import
@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *layerView;

@end

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad];
//create sublayer
CALayer *blueLayer = [CALayer layer];
blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
blueLayer.backgroundColor = [UIColor blueColor].CGColor;
//add it to our view
[self.layerView.layer addSublayer:blueLayer];
}
@end

1.4 总结

总结

这一章阐述了图层的树状结构,说明了如何在iOS中由UIView的层级关系形成的一种平行的CALayer层级关系,在后面的实验中,我们创建了自己的CALayer,并把它添加到图层树中。
在第二章,“图层关联的图片”,我们将要研究一下CALayer关联的图片,以及Core Animation提供的操作显示的一些特性。

2. 寄宿图

寄宿图

图片胜过千言万语,界面抵得上千图片 ——Ben Shneiderman
我们在第一章『图层树』中介绍了CALayer类并创建了一个简单的有蓝色背景的图层。背景颜色还好啦,但是如果它仅仅是展现了一个单调的颜色未免也太无聊了。事实上CALayer类能够包含一张你喜欢的图片,这一章节我们将来探索CALayer的寄宿图(即图层中包含的图)。

2.1 contents属性

contents属性

CALayer 有一个属性叫做contents,这个属性的类型被定义为id,意味着它可以是任何类型的对象。在这种情况下,你可以给contents属性赋任何值,你的app仍然能够编译通过。但是,在实践中,如果你给contents赋的不是CGImage,那么你得到的图层将是空白的。

contents这个奇怪的表现是由Mac OS的历史原因造成的。它之所以被定义为id类型,是因为在Mac OS系统上,这个属性对CGImage和NSImage类型的值都起作用。如果你试图在iOS平台上将UIImage的值赋给它,只能得到一个空白的图层。一些初识Core Animation的iOS开发者可能会对这个感到困惑。

头疼的不仅仅是我们刚才提到的这个问题。事实上,你真正要赋值的类型应该是CGImageRef,它是一个指向CGImage结构的指针。UIImage有一个CGImage属性,它返回一个"CGImageRef",如果你想把这个值直接赋值给CALayer的contents,那你将会得到一个编译错误。因为CGImageRef并不是一个真正的Cocoa对象,而是一个Core Foundation类型。

尽管Core Foundation类型跟Cocoa对象在运行时貌似很像(被称作toll-free bridging),他们并不是类型兼容的,不过你可以通过bridged关键字转换。如果要给图层的寄宿图赋值,你可以按照以下这个方法:

layer.contents = (__bridge id)image.CGImage;

如果你没有使用ARC(自动引用计数),你就不需要 __bridge 这部分。但是,你干嘛不用ARC?!

让我们来继续修改我们在第一章新建的工程,以便能够展示一张图片而不仅仅是一个背景色。我们已经用代码的方式建立一个图层,那我们就不需要额外的图层了。那么我们就直接把layerView的宿主图层的contents属性设置成图片。

清单2.1 更新后的代码。

@implementation ViewController

- (void)viewDidLoad
{
[super viewDidLoad]; //load an image
UIImage *image = [UIImage imageNamed:@"Snowman.png"];

//add it directly to our view's layer
self.layerView.layer.contents = (__bridge id)image.CGImage;
}
@end

图表2.1 在UIView的宿主图层中显示一张图片


我们用这些简单的代码做了一件很有趣的事情:我们利用CALayer在一个普通的UIView中显示了一张图片。这不是一个UIImageView,它不是我们通常用来展示图片的方法。通过直接操作图层,我们使用了一些新的函数,使得UIView更加有趣了。

contentGravity

你可能已经注意到了我们的雪人看起来有点。。。胖 ==! 我们加载的图片并不刚好是一个方的,为了适应这个视图,它有一点点被拉伸了。在使用UIImageView的时候遇到过同样的问题,解决方法就是把contentMode属性设置成更合适的值,像这样:
后续精彩内容请转到我的博客继续观看

作者:iOS_小久
链接:https://www.jianshu.com/p/a24cfd293f79
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

web 埋点实现原理了解一下

前言埋点,是网站分析的一种常用的数据采集方法。我们主要用来采集用户行为数据(例如页面访问路径,点击了什么元素)进行数据分析,从而让运营同学更加合理的安排运营计划。现在市面上有很多第三方埋点服务商,百度统计,友盟,growingIO 等大家应该都不太陌生,大多情...
继续阅读 »

前言

埋点,是网站分析的一种常用的数据采集方法。我们主要用来采集用户行为数据(例如页面访问路径,点击了什么元素)进行数据分析,从而让运营同学更加合理的安排运营计划。现在市面上有很多第三方埋点服务商,百度统计,友盟,growingIO 等大家应该都不太陌生,大多情况下大家都只是使用,最近我研究了下 web 埋点,你要不要了解下。

现有埋点三大类型

用户行为分析是一个大系统,一个典型的数据平台。由用户数据采集,用户行为建模分析,可视化报表展示几个模块构成。现有的埋点采集方案可以大致被分为三种,手动埋点,可视化埋点,无埋点
  1. 手动埋点
    手动代码埋点比较常见,需要调用埋点的业务方在需要采集数据的地方调用埋点的方法。优点是流量可控,业务方可以根据需要在任意地点任意场景进行数据采集,采集信息也完全由业务方来控制。这样的有点也带来了一些弊端,需要业务方来写死方法,如果采集方案变了,业务方也需要重新修改代码,重新发布。
  2. 可视化埋点
    可是化埋点是近今年的埋点趋势,很多大厂自己的数据埋点部门也都开始做这块。优点是业务方工作量少,缺点则是技术上推广和实现起来有点难(业务方前端代码规范是个大前提)。阿里的活动页很多都是运营通过可视化的界面拖拽配置实现,这些活动控件元素都带有唯一标识。通过埋点配置后台,将元素与要采集事件关联起来,可以自动生成埋点代码嵌入到页面中。
  3. 无埋点
    无埋点则是前端自动采集全部事件,上报埋点数据,由后端来过滤和计算出有用的数据,优点是前端只要加载埋点脚本。缺点是流量和采集的数据过于庞大,服务器性能压力山大,主流的 GrowingIO 就是这种实现方案。

我们暂时放弃可视化埋点的实现,在 手动埋点 和 无埋点 上进行了尝试,为了便于描述,下文我会称采集脚本为 SDK。

思考几个问题

埋点开发需要考虑很多内容,贯穿着不轻易动手写代码的原则,我们在开发前先思考下面这几个问题
  1. 我们要采集什么内容,进行哪些采集接口的约定
  2. 业务方通过什么方式来调用我们的采集脚本
  3. 手动埋点:SDK 需要封装一个方法给业务方进行调用,传参方式业务方可控
  4. 无埋点:考虑到数据量对于服务器的压力,我们需要对无埋点进行开关配置,可以配置进行哪些元素进行无埋点采集
  5. 用户标识:游客用户和登录用户的采集数据怎么进行区分关联
  6. 设备Id:用户通过浏览器来访问 web 页面,设备Id需要存储在浏览器上,同一个用户访问不同的业务方网站,设备Id要保持一样,怎么实现
  7. 单页面应用:现在流行的单页面应用和普通 web 页面的数据采集是否有差异
  8. 混合应用:app 与 h5 的混合应用我们要怎么进行通讯

我们要采集什么内容,进行哪些采集接口的约定

第一期我们先实现对 PV(即页面浏览量或点击量) 、UV(一天内同个访客多次访问) 、点击量、用户的访问路径的基础指标的采集。精细化分析的流量转化需要和业务相关,需要和数据分析方做约定,我们预留扩展。所以我们的采集接口需要进行以下的约定

{
"header":{ // HTTP 头部
"X-Device-Id":" 550e8400-e29b-41d4-a716-446655440000", //设备ID,用来区分用户设备
"X-Source-Url":"https://www.baidu.com/", //源地址,关联用户的整个操作流程,用于用户行为路径分析,例如登录,到首页,进入商品详情,退出这一整个完整的路径
"X-Current-Url":"", //当前地址,用户行为发生的页面
"X-User-Id":"",//用户ID,统计登录用户行为
},
"body":[{ // HTTP Body体
"PageSessionID":"", //页面标识ID,用来区分页面事件,例如加载和离开我们会发两个事件,这个标识可以让我们知道这个事件是发生在一个页面上
"Event":"loaded", //事件类型,区分用户行为事件
"PageTitle": "埋点测试页", //页面标题,直观看到用户访问页面
"CurrentTime": “1517798922201”, //事件发生的时间
"ExtraInfo": {
} //扩展字段,对具体业务分析的传参
}]
}

以上就是我们现在约定好了的通用的事件采集的接口,所传的参数基本上会根据采集事件的不同而发生变化。但是在用户的整一个访问行为中,用户的设备是不会变化的,如果你想采集设备信息可以重新约定一个接口,在整个采集开始之前发送设备信息,这样可以避免在事件采集接口上重复采集固定数据。

{
"header":{ // HTTP 头部
"X-Device-Id" :"550e8400-e29b-41d4-a716-446655440000" , // 设备id
},
"body":{ // HTTP Body体
"DeviceType": "web" , //设备类型
"ScreenWide" : 768 , // 屏幕宽
"ScreenHigh": 1366 , // 屏幕高
"Language": "zh-cn" //语言
}
}

手动埋点:SDK

如果业务方需要采集更多业务定制的数据,可以调用我们暴露出的方法进行采集

//自定义事件
sdk.dispatch('customEvent',{extraInfo:'自定义事件的额外信息'})

游客与用户关联

我们使用 userId 来做用户标识,同一个设备的用户,从游客用户切换到登录用户,如果我们要把他们关联起来,需要有一个设备Id 做关联

web 设备Id

用户通过浏览器来访问 web 页面,设备Id需要存储在浏览器上,同一个用户访问不同的业务方网站,设备Id要保持一样。web 变量存储,我们第一时间想到的就是 cookie,sessionStorage,localStorage,但是这3种存储方式都和访问资源的域名相关。我们总不能每次访问一个网站就新建一个设备指纹吧,所以我们需要通过一个方法来跨域共享设备指纹

我们想到的方案是,通过嵌套 iframe 加载一个静态页面,在 iframe 上加载的域名上存储设备id,通过跨域共享变量获取设备id,共享变量的原理是采用了iframe 的 contentWindow通讯,通过 postMessage 获取事件状态,调用封装好的回调函数进行数据处理具体的实现方式

//web 应用,通过嵌入 iframe 进行跨域 cookie 通讯,设置设备id,
collect.setIframe = function () {
var that = this
var iframe = document.createElement('iframe')
iframe.id = "frame",
iframe.src = 'http://collectiframe.trc.com' // 配置域名代理,目的是让开发测试生产环境代码一致
iframe.style.display='none' //iframe 设置的目的是用来生成固定的设备id,不展示
document.body.appendChild(iframe)

iframe.onload = function () {
iframe.contentWindow.postMessage('loaded','*');
}

//监听message事件,iframe 加载完成,获取设备id ,进行相关的数据采集
helper.on(window,"message",function(event){
that.deviceId = event.data.deviceId

if(event.data && event.data.type == 'loaded'){
that.sendDevice(that.getDevice(), that.deviceUrl);
setTimeout(function () {
that.send(that.beforeload)
that.send(that.loaded)
},1000)
}
})
}

iframe 与 SDK 通讯

function receiveMessageFromIndex ( event ) {
getDeviceInfo() // 获取设备信息
var data = {
deviceId: _deviceId,
type:event.data
}

event.source.postMessage(data, '*'); // 将设备信息发送给 SDK
}

//监听message事件
if(window.addEventListener){
window.addEventListener("message", receiveMessageFromIndex, false);
}else{
window.attachEvent("onmessage", receiveMessageFromIndex, false)

如果你想知道可以看我的另一篇博客 web 浏览器指纹跨域共享

单页面应用:现在流行的单页面应用和普通 web 页面的数据采集是否有差异

我们知道单页面应用都是无刷新的页面加载,所以我们在页面跳转的处理和我们的普通的页面会有所不同。单页面应用的路由插件运用了 window 自带的无刷新修改用户浏览记录的方法,pushState 和 replaceState。

window 的 history 对象 提供了两个方法,能够无刷新的修改用户的浏览记录,pushSate,和 replaceState,区别的 pushState 在用户访问页面后面添加一个访问记录, replaceState 则是直接替换了当前访问记录,所以我们只要改写 history 的方法,在方法执行前执行我们的采集方法就能实现对单页面应用的页面跳转事件的采集了

// 改写思路:拷贝 window 默认的 replaceState 函数,重写 history.replaceState 在方法里插入我们的采集行为,在重写的 replaceState 方法最后调用,window 默认的 replaceState 方法

collect = {}
collect.onPushStateCallback : function(){} // 自定义的采集方法

(function(history){
var replaceState = history.replaceState; // 存储原生 replaceState
history.replaceState = function(state, param) { // 改写 replaceState
var url = arguments[2];
if (typeof collect.onPushStateCallback == "function") {
collect.onPushStateCallback({state: state, param: param, url: url}); //自定义的采集行为方法
}
return replaceState.apply(history, arguments); // 调用原生的 replaceState
};
})(window.history);

这块介绍起来也比较的复杂,如果你想了解更多,可以看我的另一篇博客你需要知道的单页面路由实现原理

混合应用:app 与 h5 的混合应用我们要怎么进行通讯

现在大部分的应用都不是纯原生的应用, app 与 h5 的混合的应用是现在的一种主流。

纯 web 数据采集我们考虑到前端存储数据容易丢失,我们在每一次事件触发的时候都用采集接口传输采集到的数据。考虑到现在很多用户的手机会有流量管家的软件监控,如果在 App 中 h5 还是采集到数据就传输给服务端,很有可能会让流量管家检测到,给用户报警,从而使得用户不再信任你的 App , 所以我们在用户操作的时候将数据传给 app 端,存储到 app。用户切换应用到后台的时候,通过 app 端的 SDK 打包传输到服务器,我们给 app 提供的方法封装了一个适配器

// app 与 h5 混合应用,直接将数信息发给 app
collect.saveEvent = function (jsonString) {

collect.dcpDeviceType && setTimeout(function () {
if(collect.dcpDeviceType=='android'){
android.saveEvent(jsonString)
} else {
window.webkit && window.webkit.messageHandlers ? window.webkit.messageHandlers.nativeBridge.postMessage(jsonString) : window.postBridgeMessage(jsonString)
}

},1000)
}

实现思路

通过上面几个问题的思考,我们对埋点的实现大致已经有了一些想法,我们使用思维导图来还原下我们即将要做的事情,图片记得放大看哦,太小了可能看不清。

我们需要暴露给业务方调用的方法



我们来看下几个核心代码的实现

工具方法

我们定义了几个工具方法,提高开发的幸福指数 😝

var helper = {};

// 生成一个唯一的标识,pageSessionId (用这个变量来关联开始加载、加载完成、离开页面的事件,计算出页面加菜时间,停留时间)
helper.uuid = function(){}

// 元素绑定事件监听,兼容浏览器到IE8
helper.on = function(){}

//元素移除事件监听的适配器函数,兼容浏览器到IE8
helper.remove = function(){}

//将json转为字符串,事件传输的参数类型转化
helper.changeJSON2Query = function(){}

//将相对路径解析成文档全路径
helper.normalize = function(){}

采集逻辑

var collect = {
deviceUrl:'http://collect.trc.com/rest/collect/device/h5/v1',
eventUrl:'http://collect.trc.com/rest/collect/event/h5/v1',
isuploadUrl:'http://collect.trc.com/rest/collect/isupload/app/v1',
parmas:{ ExtraInfo:{} },
device:{}
};

//获取埋点配置
collect.setParames = function(){}

//更新访问路径及页面信息
collect.updatePageInfo = function(){}

//获取事件参数
collect.getParames = function(){}

//获取设备信息
collect.getDevice = function(){}

//事件采集
collect.send = function(){}

//设备采集
collect.sendDevice = function(){}

//判断才否采集,埋点采集的开关
collect.isupload = function(){

1. 判断是否采集,不采集就注销事件监听(项目中区分游客身份和用户身份的采集情况,这个方法会被判断两次)
2. 采集则判断是否已经采集过
a.已经采集过不做任何操作
b.没有采集过添加事件监听
3. 判断是 混合应用还是纯 web 应用
a.如果是web 应用,调用 collect.setIframe 设置 iframe
b.如果是混合应用 将开始加载和加载完成事件传输给 app
}

//点击事件处理函数
collect.clickHandler = function(){}

//离开页面的事件处理函数
collect.beforeUnloadHandler = function(){}

//页面回退事件处理函数
collect.onPopStateHandler = function(){}

//系统事件初始化,注册离开事件,浏览器后退事件
collect.event = function(){}

//获取记录开始加载数据信息
collect.getBeforeload = function(){}

//存储加载完成,获取设备类型,记录加载完成信息
collect.onload = function(){

1. 判断cookie是否有存设备类型信息,有表示混合应用
2. 采集加载完成时间等信息
3. 调用 collect.isupload 判断是否进行采集
}

//web 应用,通过嵌入 iframe 进行跨域 cookie 通讯,设置设备id
collect.setIframe = function(){}

//app 与 h5 混合应用,直接将数信息发给 app,判断设备类型做原生方法适配器
collect.saveEvent = function(){}

//采集自定义事件类型
collect.dispatch = function(){}

//将参数 userId 存入sessionStorage
collect.storeUserId = function(){}

//采集H5信息,如果是混合应用,将采集到的信息发送给 app 端
collect.saveEventInfo = function(){}

//页面初始化调用方法
collect.init = function(){

1. 获取开始加载的采集信息
2. 获取 SDK 配置信息,设备信息
3. 改写 history 两个方法,单页面应用页面跳转前调用我们自己的方法
4. 页面加载完成,调用 collect.onload 方法

}


collect.init(); // 初始化

//暴露给业务方调用的方法
return {
dispatch:collect.dispatch,
storeUserId:collect.storeUserId,
}

原文链接:https://segmentfault.com/a/1190000014922668


收起阅读 »

解决 SourceKitService 内存占用过高

SourceKitService 是用来服务于解析 Swift 代码格式的,和 Swift 的代码着色、类型自动推断等特性息息相关,如果我们在活动监视器中强制停止掉这个服务,那么会发现 Xcode 中 Swift 代码大部分都会变成白色,并代码提示和类型推断都...
继续阅读 »

SourceKitService 是用来服务于解析 Swift 代码格式的,和 Swift 的代码着色、类型自动推断等特性息息相关,如果我们在活动监视器中强制停止掉这个服务,那么会发现 Xcode 中 Swift 代码大部分都会变成白色,并代码提示和类型推断都失效了。

但是在我今天写代码的时候发现,这个服务突然占用了很高的 CPU 以及内存,曾一度达到 201% 和 5.7GB 的占用率,直接导致了无法编译、没有代码提示等问题。

搜索了一些资料后,网络上给出了两个具体的方案,根据这篇问题:https://stackoverflow.com/questions/26151954/sourcekitservice-consumes-cpu-and-grinds-xcode-to-a-halt

回答中指出了,首先可以尝试删除这个服务产生的缓存,然后手动终止掉这个服务,等待 Xcode 重新开启,可能会解决。
第二个办法就是,因为这个服务的天生缺陷,在进行复杂的字面量类型推断时,可能会造成占用大量资源,具体一点讲就是在写一个很长的数组时,不要写成以下这样:

let array = ["": [""], "": [""], "": [""], "": [""], "": [""], "": [""] ... ]

而是要给一个明确的类型,帮助 Xcode 进行推断:

let array: [String: [String]] = ["": [""], "": [""], "": [""], "": [""], "": [""], "": [""] ... ]

道理是这么个道理,但是我检查了我的代码之后,发现并没有类似的写法的数组,甚至连长数组都没有,就算给所有数组都手动加上类型,也无济于事。

后来发现,不光是数组,普通的变量频繁的进行“串联推断”也会导致这个问题,具体例子如下:

let userToken = (dataModel?.id ?? "") + (dataModel?.token ?? "") + (dataModel?.timestamp ?? "") + ...

这种写法同样会增加自动类型推断的负担,偶尔甚至会造成代码不能通过编译阶段。
所以,我改成了这种写法:

let userID = dataModel?.id ?? ""
let token = dataModel?.token ?? ""
let timestamp = dataModel?.timestamp ?? ""
...
let userToken = userID + token + timestamp + ...

经过改动之后,一切回归正常。

明明是想偷个懒,不想多写那么多属性,结果反而造成了雪崩式的麻烦,Xcode 瞬间变成了全球最大的 TXT 编辑器,看来以后还是要多注意一下规范问题啊~

作者:Fitmao
链接:https://www.jianshu.com/p/6a75301eb4bc
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

iOS-网络图片预览器(缩放,拖拽等手势)

预览效果(原位置启动,放大缩小,拖拽关闭,支持长图,跳转其他界面):视图结构:present跳转一个UINavigationController,UINavigationController的根跟控制是UIViewController,在viewcontrol...
继续阅读 »

预览效果(原位置启动,放大缩小,拖拽关闭,支持长图,跳转其他界面):





视图结构:present跳转一个UINavigationController,UINavigationController的根跟控制是UIViewController,在viewcontroller上添加预览器。

功能实现:
1.使用UICollectionView,在UICollectionViewCell上有个一UIScrollView的容器,在上面使用UIImageView展示图片,使用UIScrollView是为了方便实现长图预览,图片放大缩小的功能。

2.网络图片大小的处理:
第一种情况,一般来说在网络较好的情况下都会在列表页(如图列表)时已经加载完图片,在cell中直接拿到图片对象获得大小,根据显示区域的比例计算宽高。

第二种情况,在显示预览时,图片还没有加载完成(图片较大,网络较卡),我会给一个默认的宽高,在网络图片加载完成时,重新刷新他的宽高。(Data形式获取宽高有性能问题,放弃了)

3.拖拽动画和手势的处理:
在UIScrollView上添加了一个UIPanGestureRecognizer拖动手势,开启手势共享。然后根据触摸屏幕时水平方向和垂直方向的速度来判断是进行UICollectionView的左右滑动还是拖拽。在拖拽手势中,根据移动的Y轴距离的和整个区的的高度比例来缩放UIScrollView(使用transform来缩放)

总结:基本能满足一些普通要求的项目,如果想添加更多功能,可以在viewcontroller上添加,改变预览器的显示区域等。

Demo地址

作者:约德尔人郭敬明
链接:https://www.jianshu.com/p/7dda7add67e6
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

2019年11月:JD iOS开发岗面试题及答案!

随着移动互联网技术的不断发展和创新,访谈对于公司和开发人员和设计师来说都是费时且昂贵的项目,面对iOS开发者和设计师在访谈过程中可能遇到的问题,现在为大家总结iOS技术面试题及应对答案。一、如何绘制UIView?绘制一个UIView最灵活的方法就是由它自己完成...
继续阅读 »

随着移动互联网技术的不断发展和创新,访谈对于公司和开发人员和设计师来说都是费时且昂贵的项目,面对iOS开发者和设计师在访谈过程中可能遇到的问题,现在为大家总结iOS技术面试题及应对答案。

一、如何绘制UIView?
绘制一个UIView最灵活的方法就是由它自己完成绘制。实际上你不是绘制一个UIView,而是子类化一个UIView并赋予绘制自己的能力。当一个UIView需要执行绘制操作时,drawRect:方法就会被调用,覆盖此方法让你获得绘图操作的机会。当drawRect:方法被调用,当前图形的上下文也被设置为属于视图的图形上下文,你可以使用Core Graphic或者UIKit提供的方法将图形画在该上下文中。

二、什么是MVVM?主要目的是什么?优点有哪些?
MVVM即 Model-View-ViewModel

1.View主要用于界面呈现,与用户输入设备进行交互、

2.ViewModel是MVVM架构中最重要的部分,ViewModel中包含属性,方法,事件,属性验证等逻辑,负责View与Model之间的通讯

3.Model就是我们常说的数据模型,用于数据的构造,数据的驱动,主要提供基础实体的属性。

MVVM主要目的是分离视图和模型

MVVM优点:低耦合,可重用性,独立开发,可测试

三、get请求与post请求的区别

1.get是向服务器发索取数据的一种请求,而post是向服务器提交数据的一种请求

2.get没有请求体,post有请求体

3.get请求的数据会暴露在地址栏中,而post请求不会,所以post请求的安全性比get请求号

4.get请求对url长度有限制,而post请求对url长度理论上是不会收限制的,但是实际上各个服务器会规定对post提交数据大小进行限制。

四、谈谈你对多线程开发的理解?ios中有几种实现多线程的方法?
好处:

1.使用多线程可以把程序中占据时间长的任务放到后台去处理,如图片,视频的下载;

2.发挥多核处理器的优势,并发执行让系统运行的更快,更流畅,用户体验更好;

缺点:
1.大量的线程降低代码的可读性;

2.更多的线程需要更多的内存空间;

3当多个线程对同一个资源出现争夺的时候要注意线程安全的问题。

ios有3种多线程编程的技术:1.NSThread,2.NSOperationQueue,3.gcd;

五、XMPP工作原理;xmpp系统特点
原理:
1.所有从一个client到另一个client的jabber消息和数据都要通过xmpp server

2.client链接到server

3.server利用本地目录系统的证书对其认证

4.server查找,连接并进行相互认证

5.client间进行交互

特点:1)客户机/服务器通信模式;2)分布式网络;3)简单的客户端;4)XML的数据格式

六、地图的定位是怎么实现的?
1.导入了CoreLocation.framework

2.ios8以后,如果需要使用定位功能,就需要请求用户授权,在首次运行时会弹框提示

3.通过本机自带的gps获取位置信息(即经纬度)

七、苹果内购实现流程

程序通过bundle存储的plist文件得到产品标识符的列表。

程序向App Store发送请求,得到产品的信息。

App Store返回产品信息。

程序把返回的产品信息显示给用户(App的store界面)

用户选择某个产品

程序向App Store发送支付请求

App Store处理支付请求并返回交易完成信息。

App获取信息并提供内容给用户。

八、支付宝,微信等相关类型的sdk的集成

1.在支付宝开发平台创建应用并获取APPID

2.配置密钥

3.集成并配置SDK

4.调用接口(如交易查询接口,交易退款接口)

九、 gcd产生死锁的原因及解锁的方法

产生死锁的必要条件:1.互斥条件,2.请求与保持条件,3.不剥夺条件,4.循环等待条件。

解决办法:采用异步执行block。

十、生成二维码的步骤
1.使用CIFilter滤镜类生成二维码

2.对生成的二维码进行加工,使其更清晰

3.自定义二维码背景色、填充色

4.自定义定位角标

5.在二维码中心插入小图片

十一、在使用XMPP的时候有没有什么困难

发送附件(图片,语音,文档...)时比较麻烦

XMPP框架没有提供发送附件的功能,需要自己实现

实现方法,把文件上传到文件服务器,上传成功后获取文件保存路径,再把附件的路径发送给好友

十二、是否使用过环信,简单的说下环信的实现原理

环信是一个即时通讯的服务提供商

环信使用的是XMPP协议,它是再XMPP的基础上进行二次开发,对服务器Openfire和客户端进行功能模型的添加和客户端SDK的封装,环信的本质还是使用XMPP,基于Socket的网络通信

环信内部实现了数据缓存,会把聊天记录添加到数据库,把附件(如音频文件,图片文件)下载到本地,使程序员更多时间是花到用户体验体验上。

链接:https://www.jianshu.com/p/3b7cc68cce20

收起阅读 »

iOS 可用的热更新、热修复方案

前言JSPatch虽然在两年前被苹果邮件警告,但是ReactNative依然盛行,只不过ReactNative并没有对Native进行热修复的功能,只是动态下发新的bundle模块。动态加载而已。很多时候线上出现bug,可能是很小,很细微的。对此我们可能仅仅需...
继续阅读 »

前言
JSPatch虽然在两年前被苹果邮件警告,但是ReactNative依然盛行,只不过ReactNative并没有对Native进行热修复的功能,只是动态下发新的bundle模块。动态加载而已。

很多时候线上出现bug,可能是很小,很细微的。对此我们可能仅仅需要改动一个返回值就能解决线上bug。但是实际上我们并没有这么一套机制去对线上bug进行热修复,只有通过发版才能解决,这样对用户很不友好。

解决方案
Rollout.io 、 JSpatch、 DynamicCocoa、React Native、 Weex、Wax 、Hybrid
其实业界还是有很多方案的 -_-!

看了一下JSPatch的使用文档,其实就是把JS代码通过Oc的动态运行时,将JS方法调用映射到Oc的对应类和方法。
我们的技术栈储备如下:

<objc/runtime>
<objc/message>
JS

js会写点,ES5就可以。

下面就可以开始。按照JSPatch文档提供的功能,一步一步自己实现对应功能,想一下。以后大家就可以在手机上写代码,很刺激吧~

TTPatch开发问题记录

现在开发成果已经可以热修复,热更新,动态调用Oc方法,参数返回值类型处理,方法hook

对热更新、hook、感兴趣的同学可以下载demo玩玩。后续会跟目前JSPatch支持的功能看齐,但是具体实现是不一样的哦。大家可以对比一下各自实现的优缺点。
我知道肯定是我写的low,算是抛砖引玉吧~,希望大家提问,指正。

Commit问题记录

1.内存问题
解决方式 使用 __unsafe_unretained 修饰临时变量,防止 strong修饰的临时变量在局部方法结束时隐式调用 release,导致出现僵尸对象

2.Oc调用js方法,多参数传递问题
这里面利用arguments和js中的apply,就可以以多参数调用,而不是一个为数组的obj对象

3.关于添加addTarget——action方法
为View对象添加手势响应以及button添加action时,action(sender){sender为当前控制器 self} 为什么Oc中使用的时候sender为当前的手势orbutton对象?
如果Native未实现action方法,那么会导致获取方法签名失败而导致我们无法拿到正确参数,所以获得的参数为当前self.
这里要记录强调一下,如添加不存在的action时,要注意action参数不为当前的事件响应者.

4.JS调用Oc方法,如何支持 多参数、多类型 调用
首先,我们要讲目标Class的forwardingInvocation:方法替换成我们自己的实现TTPatch_Message_handle,
然后通过替换方法的方式,将目标方法的IMP替换为msg__objc_msgForward,直接开始消息住转发,这样直接通过消息转发最终会运行到我们的TTPatch_Message_handle函数中,在函数中我们可以拿到当前正在执行方法的invocation对象,这也就意味着我们可以拿到当前调用方法的全部信息,并且可以操作以及修改。我们也是通过这个方法来实现,返回值类型转换。返回值类型转发这里涉及到

然后通过替换方法的方式,将目标方法的IMP替换为msg__objc_msgForward,直接开始消息住转发,这样直接通过消息转发最终会运行到我们的TTPatch_Message_handle函数中,在函数中我们可以拿到当前正在执行方法的invocation对象,这也就意味着我们可以拿到当前调用方法的全部信息,并且可以操作以及修改。我们也是通过这个方法来实现,返回值类型转换。返回值类型转发这里涉及的细节比较多,暂时只说一下最好的一种解决方案。

《--------------------Github地址----------------》

上传一张Demo动态图


感兴趣的读者可以下载玩一玩.欢迎提出宝贵意见

转自:https://www.jianshu.com/p/1daf20977c4a

收起阅读 »

Android系统开发-选择并启动默认Launcher

如果在Android设备上又安装了一个Launcher应用,当我们返回主页的时候,Android就会弹出一个弹窗,要用户 选择要启动的Launcher应用,如下图所示: 这个是普通Android设备的正常流程,现在我们的需求是不再显示这个提示窗,在设置中增加...
继续阅读 »

如果在Android设备上又安装了一个Launcher应用,当我们返回主页的时候,Android就会弹出一个弹窗,要用户 选择要启动的Launcher应用,如下图所示:



这个是普通Android设备的正常流程,现在我们的需求是不再显示这个提示窗,在设置中增加一个选择默认启动Launcher的页面,默认选择Launcher3。


Settings



在设置中增加一个这样的页面,显示所有声明了"android.intent.category.HOME"的应用


 private fun getAllLauncherApps(): MutableList<AppInfo> {
val list = ArrayList<AppInfo>()
val launchIntent = Intent(Intent.ACTION_MAIN, null)
.addCategory(Intent.CATEGORY_HOME)
val intents = packageManager.queryIntentActivities(launchIntent, 0)

//遍历
for (ri in intents) {
//得到包名
val packageName = ri.activityInfo.applicationInfo.packageName
if (packageName == "com.android.settings") { //不显示原生设置
continue
}
//得到图标
val icon = ri.loadIcon(packageManager)
//得到应用名称
val appName = ri.loadLabel(packageManager).toString()

//封装应用信息对象
val appInfo = AppInfo(icon, appName, packageName)
//添加到list
list.add(appInfo)
}
return list
}
复制代码

使用PackageManager提供的queryIntentActivities方法就可以获取所有Launcher应用,原生设置中也有Activity声明了HOME属性,在这里就把它屏蔽掉。


默认选择Launcher3应用为默认启动


private val DEFAULT_LAUNCHER = "my_default_launcher"
defaultLauncher = Settings.Global.getString(contentResolver, DEFAULT_LAUNCHER)
if (defaultLauncher.isNullOrEmpty()) {
defaultLauncher = "com.android.launcher3"
Settings.Global.putString(contentResolver, DEFAULT_LAUNCHER, defaultLauncher)
}
复制代码

当选择另一个应用,就把选择应用的包名设置到 Settings.Global中。


这样应用选择页面完成,也设置了一个全局的参数提供给系统。


启动


最开始提到了Launcher选择弹窗,我们就考虑在这里做点事,把弹窗的逻辑给跳过,就可以实现默认启动。


弹窗源码位于frameworks/base/core/java/com/android/internal/app/ResolverActivity.java


在这里就不具体分析源码了,就看关键部分


public boolean configureContentView(List<Intent> payloadIntents, Intent[] initialIntents,
List<ResolveInfo> rList, boolean alwaysUseOption) {
// The last argument of createAdapter is whether to do special handling
// of the last used choice to highlight it in the list. We need to always
// turn this off when running under voice interaction, since it results in
// a more complicated UI that the current voice interaction flow is not able
// to handle.
mAdapter = createAdapter(this, payloadIntents, initialIntents, rList,
mLaunchedFromUid, alwaysUseOption && !isVoiceInteraction());

final int layoutId;
if (mAdapter.hasFilteredItem()) {
layoutId = R.layout.resolver_list_with_default;
alwaysUseOption = false;
} else {
layoutId = getLayoutResource();
}
mAlwaysUseOption = alwaysUseOption;

int count = mAdapter.getUnfilteredCount();
if (count == 1 && mAdapter.getOtherProfile() == null) {
// Only one target, so we're a candidate to auto-launch!
final TargetInfo target = mAdapter.targetInfoForPosition(0, false);
if (shouldAutoLaunchSingleChoice(target)) {
safelyStartActivity(target);
mPackageMonitor.unregister();
mRegistered = false;
finish();
return true;
}
}
if (count > 0) {
// add by liuwei,if set my_default_launcher,start default
String defaultlauncher = Settings.Global.getString(this.getContentResolver(), "my_default_launcher");

final TargetInfo defaultTarget = mAdapter.targetInfoForDefault(defaultlauncher);
if(defaultTarget != null){
safelyStartActivity(defaultTarget);
mPackageMonitor.unregister();
mRegistered = false;
finish();
return true;
}
//end
setContentView(layoutId);
mAdapterView = (AbsListView) findViewById(R.id.resolver_list);
onPrepareAdapterView(mAdapterView, mAdapter, alwaysUseOption);
} else {
setContentView(R.layout.resolver_list);

final TextView empty = (TextView) findViewById(R.id.empty);
empty.setVisibility(View.VISIBLE);

mAdapterView = (AbsListView) findViewById(R.id.resolver_list);
mAdapterView.setVisibility(View.GONE);
}
return false;
}
复制代码

在configureContentView中判断launcher应用个数,如果为1,则直接启动,finish当前页面。下面判断count>0,我们就在这里面增加自己的逻辑,获取配置的Settings.Global参数,再去Adapter中判断是否有应用包名和参数匹配,如果有就safelyStartActivity(),关闭弹窗。如果没有匹配包名,就走正常流程,弹窗提示用户。


mAdapter.targetInfoForDefault函数是在 public class ResolveListAdapter extends BaseAdapter中增加函数


 public TargetInfo targetInfoForDefault(String myDefault){
if(myDefault == null){
return null;
}

TargetInfo info = null;
for(int i=0;i<mDisplayList.size();i++){
String disPackageName = mDisplayList.get(i).getResolveInfo().activityInfo.applicationInfo.packageName;
if(myDefault.equals(disPackageName) ){
info = mDisplayList.get(i);
break;
}
}
return info;
}

复制代码

OK,功能实现完成,自测也没有问题。


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

ART虚拟机 | 锁

本文基于Android 11(R) Java中对临界区的锁定通常用synchronize代码块完成,因此标题中的“锁”实际上是对synchronize关键字的剖析。Synchronize代码块使用时必须传入一个对象,这个对象可以是this对象,可以是类对象(e...
继续阅读 »

本文基于Android 11(R)


Java中对临界区的锁定通常用synchronize代码块完成,因此标题中的“锁”实际上是对synchronize关键字的剖析。Synchronize代码块使用时必须传入一个对象,这个对象可以是this对象,可以是类对象(e.g. Foo.class),也可以是任何其他对象。因此我们可以说,锁的状态和对象关联。亦或者,每个对象天生都是一把锁。


Synchronize生成的字节码会对应两条指令,分别是monitor-entermonitor-exit。下面我们针对monitor_enter,分别从解释执行和机器码执行两个方向去寻找这个指令的最终实现。


解释执行


[art/runtime/interpreter/interpreter_switch_impl-inl.h]


HANDLER_ATTRIBUTES bool MONITOR_ENTER() {
...
ObjPtr<mirror::Object> obj = GetVRegReference(A());
if (UNLIKELY(obj == nullptr)) {
...
} else {
DoMonitorEnter<do_assignability_check>(self, &shadow_frame, obj); <===调用
...
}
}
复制代码

[art/runtime/interpreter/interpreter_common.h]


static inline void DoMonitorEnter(Thread* self, ShadowFrame* frame, ObjPtr<mirror::Object> ref)
NO_THREAD_SAFETY_ANALYSIS
REQUIRES(!Roles::uninterruptible_) {
...
StackHandleScope<1> hs(self);
Handle<mirror::Object> h_ref(hs.NewHandle(ref)); <===调用
h_ref->MonitorEnter(self);
...
}
复制代码

[art/runtime/mirror/object-inl.h]


inline ObjPtr<mirror::Object> Object::MonitorEnter(Thread* self) {
return Monitor::MonitorEnter(self, this, /*trylock=*/false); <===调用
}
复制代码

解释执行会使用switch-case方式分别解析每一条指令,由上述代码可知,monitor-enter指令最终会调用Monitor::MonitorEnter静态函数。


机器码执行


[art/runtime/arch/arm64/quick_entrypoints_arm64.S]


ENTRY art_quick_lock_object_no_inline
// This is also the slow path for art_quick_lock_object.
SETUP_SAVE_REFS_ONLY_FRAME // save callee saves in case we block
mov x1, xSELF // pass Thread::Current
bl artLockObjectFromCode // (Object* obj, Thread*) <===调用
...
END art_quick_lock_object_no_inline
复制代码

[art/runtime/entrypoints/quick/quick_lock_entrypoints.cc]


extern "C" int artLockObjectFromCode(mirror::Object* obj, Thread* self){
...
if (UNLIKELY(obj == nullptr)) {
...
} else {
ObjPtr<mirror::Object> object = obj->MonitorEnter(self); // May block <===调用
...
}
}
复制代码

[art/runtime/mirror/object-inl.h]


inline ObjPtr<mirror::Object> Object::MonitorEnter(Thread* self) {
return Monitor::MonitorEnter(self, this, /*trylock=*/false); <===调用
}
复制代码

殊途同归,机器码执行时最终也会调用Monitor::MonitorEnter


锁的两种形态


虚拟机中将锁实现为两种形态,一种称为Thin Lock,另一种称为Fat Lock。


Thin Lock用于竞争较弱的场景。在竞争发生时,采用自旋(spin)和让渡CPU(yield)的方式等待锁,而不是进行系统调用和上下文切换。当持有锁的线程很快完成操作时,短暂的自旋会比上下文切换开销更小。


可是如果自旋一段时间发现还无法获取到锁时,Thin Lock就会膨胀为Fat Lock,一方面增加数据结构存储与锁相关的具体信息,另一方面通过系统调用挂起线程。


总结一下,Fat Lock功能健全,但开销较大。而Thin Lock开销虽小,但无法用于长时间等待的情况。所以实际的做法是先使用Thin Lock,当功能无法满足时再膨胀为Fat Lock。


文章开头提到,每个对象天生都是一把锁。那么这个锁的信息到底存在对象的什么位置呢?


答案是存在art::mirror::Object的对象头中(详见ART虚拟机 | Java对象和类的内存结构)。对象头中有一个4字节长的字段monitor_,其中便存储了锁相关的信息。


monitor字段.png


4字节共32bits,高位的两个bits用于标记状态。不同的状态,存储的信息含义也不同。两个bits共4种状态,分别为ThinOrUnlock(Thin/Unlock共用一个状态),Fat,Hash和ForwardingAddress。ThinOrUnlock和Fat表示锁的状态,Hash是为对象生成HashMap中所用的哈希值,ForwardingAddress是GC时使用的状态。


上图中的m表示mark bit state,r表示read barrier state,都是配合GC使用的标志,在讨论锁的时候可以不关心。


当我们对一个空闲对象进行monitor-enter操作时,锁的状态由Unlock切换到Thin。代码如下。


switch (lock_word.GetState()) {
case LockWord::kUnlocked: {
// No ordering required for preceding lockword read, since we retest.
LockWord thin_locked(LockWord::FromThinLockId(thread_id, 0, lock_word.GCState()));
if (h_obj->CasLockWord(lock_word, thin_locked, CASMode::kWeak, std::memory_order_acquire)) {
...
return h_obj.Get(); // Success!
}
continue; // Go again.
}
复制代码

LockWord对象的大小就是4字节,所以可以将它等同于art::mirror::Objectmonitor_字段,只不过它内部实现了很多方法可以灵活操作4字节中的信息。锁状态切换时,将当前线程的thread id(thread id并非tid,对每个进程而言它都从1开始)存入monitor_字段,与GC相关的mr标志保持不变。


当对象被线程锁定后,假设我们在同线程内对该它再次进行monitor-enter操作,那么就会发生Thin Lock的重入。如果在不同线程对该对象进行monitor-enter操作,那么就会发生Thin Lock的竞争。代码和流程图如下。


case LockWord::kThinLocked: {
uint32_t owner_thread_id = lock_word.ThinLockOwner();
if (owner_thread_id == thread_id) {
uint32_t new_count = lock_word.ThinLockCount() + 1;
if (LIKELY(new_count <= LockWord::kThinLockMaxCount)) {
LockWord thin_locked(LockWord::FromThinLockId(thread_id,
new_count,
lock_word.GCState()));
if (h_obj->CasLockWord(lock_word,
thin_locked,
CASMode::kWeak,
std::memory_order_relaxed)) {
AtraceMonitorLock(self, h_obj.Get(), /* is_wait= */ false);
return h_obj.Get(); // Success!
}
continue; // Go again.
} else {
// We'd overflow the recursion count, so inflate the monitor.
InflateThinLocked(self, h_obj, lock_word, 0);
}
} else {
// Contention.
contention_count++;
Runtime* runtime = Runtime::Current();
if (contention_count <= runtime->GetMaxSpinsBeforeThinLockInflation()) {
sched_yield();
} else {
contention_count = 0;
// No ordering required for initial lockword read. Install rereads it anyway.
InflateThinLocked(self, h_obj, lock_word, 0);
}
}
continue; // Start from the beginning.
}
复制代码

ThinLock.png


在ThinLock膨胀为FatLock前,需要执行50次sched_yieldsched_yield会将当前线程放到CPU调度队列的末尾,这样既不用挂起线程,也不用一直占着CPU。不过android master分支已经将这个流程再度优化了,在50次sched_yield之前,再执行100次自旋操作。和sched_yield相比,自旋不会释放CPU。由于单次sched_yield耗时也有微秒,对于锁持有时间极短的情况,用自旋更省时间。


接下来介绍锁的膨胀过程。


void Monitor::InflateThinLocked(Thread* self, Handle<mirror::Object> obj, LockWord lock_word,
uint32_t hash_code) {
DCHECK_EQ(lock_word.GetState(), LockWord::kThinLocked);
uint32_t owner_thread_id = lock_word.ThinLockOwner();
if (owner_thread_id == self->GetThreadId()) {
// We own the monitor, we can easily inflate it.
Inflate(self, self, obj.Get(), hash_code);
} else {
ThreadList* thread_list = Runtime::Current()->GetThreadList();
// Suspend the owner, inflate. First change to blocked and give up mutator_lock_.
self->SetMonitorEnterObject(obj.Get());
bool timed_out;
Thread* owner;
{
ScopedThreadSuspension sts(self, kWaitingForLockInflation);
owner = thread_list->SuspendThreadByThreadId(owner_thread_id,
SuspendReason::kInternal,
&timed_out);
}
if (owner != nullptr) {
// We succeeded in suspending the thread, check the lock's status didn't change.
lock_word = obj->GetLockWord(true);
if (lock_word.GetState() == LockWord::kThinLocked &&
lock_word.ThinLockOwner() == owner_thread_id) {
// Go ahead and inflate the lock.
Inflate(self, owner, obj.Get(), hash_code);
}
bool resumed = thread_list->Resume(owner, SuspendReason::kInternal);
DCHECK(resumed);
}
self->SetMonitorEnterObject(nullptr);
}
}
复制代码

void Monitor::Inflate(Thread* self, Thread* owner, ObjPtr<mirror::Object> obj, int32_t hash_code) {
DCHECK(self != nullptr);
DCHECK(obj != nullptr);
// Allocate and acquire a new monitor.
Monitor* m = MonitorPool::CreateMonitor(self, owner, obj, hash_code);
DCHECK(m != nullptr);
if (m->Install(self)) {
if (owner != nullptr) {
VLOG(monitor) << "monitor: thread" << owner->GetThreadId()
<< " created monitor " << m << " for object " << obj;
} else {
VLOG(monitor) << "monitor: Inflate with hashcode " << hash_code
<< " created monitor " << m << " for object " << obj;
}
Runtime::Current()->GetMonitorList()->Add(m);
CHECK_EQ(obj->GetLockWord(true).GetState(), LockWord::kFatLocked);
} else {
MonitorPool::ReleaseMonitor(self, m);
}
}
复制代码

膨胀(Inflate)的具体操作比较简单,简言之就是创建一个Monitor对象,存储更多的信息,然后将Monitor Id放入原先的monitor_字段中。


关键的地方在于膨胀的充分条件。如果Thin Lock本来就由本线程持有,那么膨胀不需要经过任何人同意,可以直接进行。但如果该Thin Lock由其他线程持有,那么膨胀之前必须先暂停(这里的暂停并不是指将线程从CPU上调度出去,而是不允许它进入Java世界改变锁状态)持有线程,防止膨胀过程中对锁信息的更新存在竞争。膨胀之后,持有线程恢复运行,此时它看到的Lock已经变成了Fat Lock。


当锁膨胀为Fat Lock后,由于持有锁的动作并未完成,所以该线程会再次尝试。只不过这次走的是Fat Lock分支,执行如下代码。


case LockWord::kFatLocked: {
// We should have done an acquire read of the lockword initially, to ensure
// visibility of the monitor data structure. Use an explicit fence instead.
std::atomic_thread_fence(std::memory_order_acquire);
Monitor* mon = lock_word.FatLockMonitor();
if (trylock) {
return mon->TryLock(self) ? h_obj.Get() : nullptr;
} else {
mon->Lock(self);
DCHECK(mon->monitor_lock_.IsExclusiveHeld(self));
return h_obj.Get(); // Success!
}
}
复制代码

{
ScopedThreadSuspension tsc(self, kBlocked); // Change to blocked and give up mutator_lock_.

// Acquire monitor_lock_ without mutator_lock_, expecting to block this time.
// We already tried spinning above. The shutdown procedure currently assumes we stop
// touching monitors shortly after we suspend, so don't spin again here.
monitor_lock_.ExclusiveLock(self);
}
复制代码

上述代码的ScopedThreadSuspension对象用于完成线程状态的切换,之所以叫scoped,是因为它是通过构造和析构函数完成状态切换和恢复的。在作用域内的局部变量会随着作用域的结束而自动析构,因此花括号结束,线程状态也就由Blocked切换回Runnable了。


最终调用monitor_lock_(Mutex对象)的ExclusiveLock方法。


void Mutex::ExclusiveLock(Thread* self) {
if (!recursive_ || !IsExclusiveHeld(self)) {
#if ART_USE_FUTEXES
bool done = false;
do {
int32_t cur_state = state_and_contenders_.load(std::memory_order_relaxed);
if (LIKELY((cur_state & kHeldMask) == 0) /* lock not held */) {
done = state_and_contenders_.CompareAndSetWeakAcquire(cur_state, cur_state | kHeldMask);
} else {
...
if (!WaitBrieflyFor(&state_and_contenders_, self,
[](int32_t v) { return (v & kHeldMask) == 0; })) {
// Increment contender count. We can't create enough threads for this to overflow.
increment_contenders();
// Make cur_state again reflect the expected value of state_and_contenders.
cur_state += kContenderIncrement;
if (UNLIKELY(should_respond_to_empty_checkpoint_request_)) {
self->CheckEmptyCheckpointFromMutex();
}
do {
if (futex(state_and_contenders_.Address(), FUTEX_WAIT_PRIVATE, cur_state,
nullptr, nullptr, 0) != 0) {
...
cur_state = state_and_contenders_.load(std::memory_order_relaxed);
} while ((cur_state & kHeldMask) != 0);
decrement_contenders();
}
}
} while (!done);
...
exclusive_owner_.store(SafeGetTid(self), std::memory_order_relaxed);
RegisterAsLocked(self);
}
recursion_count_++;
...
}
复制代码

Mutex::ExclusiveLock最终通过futex系统调用陷入内核态,在内核态中将当前线程从CPU中调度出去,实现挂起。值得注意的是,FatLock中依然有spin和yield的操作(WaitBrieflyFor函数),这是因为Thin Lock一旦膨胀为Fat Lock就很难deflate回去,而后续对Fat Lock的使用依然会碰到短时持有锁的情况,这也意味先前的优化此处依然可用。


上面这一块代码算是锁的核心实现,被调用的次数也非常多,因此任何一点微小的优化都很重要。我之前写过一篇文章调试经验 | C++ memory order和一个相关的稳定性问题详细分析了一个由memory order使用错误导致的线程卡死的问题,其中还介绍了C++的memory order,它也正是Java volatile关键字的(ART)底层实现。


此外我还给谷歌提过ExclusiveLock的bug,这个bug既会消耗battery,也会在某些情况下导致系统整体卡死。下面是谷歌的具体回复,感兴趣的可以查看修复


Hans Reply.png


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

Android-Jetpack-Hilt 组件 包爽攻略

Hilt 是啥? Hilt 就是依赖Dagger2 而来的 一个 专属android 端的 依赖注入框架。Dagger2 是啥? Dagger是以前 square 做的 依赖注入框架,但是大量使用了反射,谷歌觉得这东西不错,拿来改了一下,使用编译期注解 大幅度...
继续阅读 »

Hilt 是啥?


Hilt 就是依赖Dagger2 而来的 一个 专属android 端的 依赖注入框架。Dagger2 是啥?
Dagger是以前 square 做的 依赖注入框架,但是大量使用了反射,谷歌觉得这东西不错,拿来改了一下,使用编译期注解 大幅度提高性能以后 的东西就叫Dagger2了, 国外的app 多数都用了Dagger2, 但是这个框架在国内用的人很少。


依赖注入是啥?


说简单一点,如果你构造一个对象所需要的值 是别人给你的,那就叫依赖注入,如果是你自己new出来的,那就不叫依赖注入


class A1 {
public A1(String name) {
this.name = name;
}

private String name;

}

class A2 {
public A2() {
this.name = "wuyue";
}

private String name;

}
复制代码

例如上面的, A1 这个类 构造函数的时候 name的值 是外面传过来的,那这个A1对象的构建过程 就是依赖注入,因为你A1对象 是依赖 外部传递过来的值


再看A2 A2的构造函数 是直接 自己 new出来 赋值的。那自然就不叫依赖注入了。


所以依赖注入 对于大部分人来说 其实每天都在用。


既然每天都在用 那用这些依赖注入的框架有啥用?


这是个好问题, 依赖注入的技术既然每天都在用,为啥我们还要用 这些什么Dagger2 Hilt 之类的依赖注入框架呢? 其实原因就是 你用了这些所谓的依赖注入框架 可以让你少写很多代码,且变的很容易维护


你在构造一个对象的时候 如果是手动new 出来的,那么如果日后这个对象的构造方法发生了改变,那么你所有new
的地方 都要挨个修改,这岂不是很麻烦? 如果有依赖注入框架帮你处理 那你其实只要改一个地方就可以了。


第一个简单的例子


在这个例子中,我们熟悉一下Hilt的基本用法。


首先在root project 中的 dependencies 加入依赖


 classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
复制代码

然后在你的app工程中


apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

implementation "com.google.dagger:hilt-android:2.28-alpha"
kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
复制代码

自定义Application 注意要加注解了。


@HiltAndroidApp
class MyApplication:Application() {

}
复制代码

首先定义一个class


data class Person constructor(val name: String, val age: Int) {
@Inject
constructor() : this("vivo", 18)
}
复制代码

注意 这个class 中 使用了 Inject注解 其实就是告诉 Hilt 如何来提供这个对象


然后写我们的activity 页面


class MainActivity : AppCompatActivity() {

@Inject
lateinit var person: Person

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
Log.v("wuyue", "person:$person")
}
}
复制代码

注意 要加入 @AndroidEntryPoint 这个注解,同样的你 声明person对象的时候也一样 要使用Inject注解。


这就是一个最简单的Hilt的例子


好处是显而易见的, 比方说 以后Person的使用 可以不用那么写了,直接Inject 就可以 我压根不用关心
这个Person对象是怎么被构造出来的,以后构造函数发生了改变 调用的地方 也不用修改代码。


当然了,这里有人会说, 你这我虽然明白了优点,但是实际android编程中 没人这么用呀,


有没有更好的例子呢?


获取 Retrofit/Okhttp 对象


通常来说,我们一个项目里面,总会有网络请求,这些网络请求 都会有一些 基础的Retrofit或者是Okhttp的对象, 我们很多时候都会写成单例 然后去get他们出来, 有没有更简便的写法?
有的


//retrofit的 service
interface BaiduApiService{

}

@Module
@InstallIn(ActivityComponent::class)
object BaiduApiModule{

@Provides
fun provideBaiduService():BaiduApiService{
return Retrofit.Builder().baseUrl("https://baidu.com").build().create(BaiduApiService::class.java)
}
}
复制代码

然后:


@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

@Inject
lateinit var baiduApiService: BaiduApiService

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

}
}

复制代码

即可。仔细体会体会 这种依赖注入框架的 写法 是不是比你之前 单例的写法要简洁方便了很多?


这里解释一下几个注解的含义


@Module 多数场景下 用来提供构造 无法使用@Inject的依赖,啥意思?


第一个例子中 Person 这个class 是我们自己写的吧,构造函数 前面 我们可以加入Inject 注解


但是例如像Retrofit这样的第三方库 ,我们拿不到他们的代码呀, 又想用 Hilt,怎么办呢


自然就是这个Module了,另外用module 的 时候,一般还要配合使用InstallIn
注解,后面跟的参数值 是用来指定module的范围的


可以看下有多少个范围


image.png


最后 就是 @Provides 这个注解, 这个很简单


一般也是用来 和@Module 一起配合的。 你哪个函数 提供了依赖注入 你就在这个函数上加入这个注解就可以了。


多对象 细节不同 怎么处理


举个例子 一个项目里面 可以有多个okhttp的client对吧,有的接口 我们要做一个拦截器 比如说打印一些埋点,
有些接口 我们要做一个拦截器 来判断下登录态是否失效,不一样的场景,我们需要 new不同的okhttp client
那有没有更简便的写法,答案是有的!


@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DataReportsOkHttpClient

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class CheckTokenOkHttpClient
复制代码

我们先用Qualifier 限定符 来标记一下


@Module
@InstallIn(ActivityComponent::class)
object OkHttpModule {
@DataReportsOkHttpClient
@Provides
fun provideDataReportInterceptorOkHttpClient(
dataReportInterceptor: DataReportInterceptor
): OkHttpClient {
return OkHttpClient.Builder().addInterceptor(dataReportInterceptor).build()
}

@CheckTokenOkHttpClient
@Provides
fun provideCheckTokenInterceptorOkHttpClient(
checkTokenInterceptor: CheckTokenInterceptor
): OkHttpClient {
return OkHttpClient.Builder().addInterceptor(checkTokenInterceptor).build()
}
}
复制代码

然后这里 provides的方法 注意了 要加上我们前面的我们先用Qualifier 标记, @DataReportsOkHttpClient


但是到这里还没完,这里一定注意一个原则:


使用Hilt的依赖注入组件 他自己的依赖 也必须是Hilt提供的,啥意思?


你看这里 我们2个provide 方法都需要一个参数 这个参数是干嘛的?就是函数参数 是一个okhttp的interceptor
对吧 ,


但是因为我们这里是依赖注入的模块,所以你使用的参数也必须是依赖注入提供的,


所以这里你如果拦截器 这么写:



class DataReportInterceptor : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(chain.request())
}

}
复制代码

那是编译不过的,因为Hilt组件 不知道你这个对象 应该如何去哪里构造,所以这里你必须也指定 这个拦截器的构造 是Hilt 注入的。


所以你只要这么改就可以了:


class DataReportInterceptor  @Inject constructor() : Interceptor {
init {
}

override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(chain.request())
}

}

class CheckTokenInterceptor @Inject constructor() : Interceptor {

init {
}

override fun intercept(chain: Interceptor.Chain): Response {
return chain.proceed(chain.request())
}

}


复制代码

这样Hilt 就知道要去哪里 取这个依赖了。(这个地方官方文档竟然没有提到,导致很多人照着官方文档写demo 一直报错)


一些小技巧


android中 构造很多对象 都需要Context,Hilt 默认为我们实现了这种Context,不需要我们再费尽心思 构造Context了(实际上你也很难构造处理 因为Context 是无法 new出来的)


class AnalyticsAdapter @Inject constructor(
@ActivityContext private val context: Context,
private val service: AnalyticsService
) { ... }
复制代码

对应的当然还有Application的Context


此外 我们还可以限定 这些依赖注入对象的 作用域


image.png


大家有兴趣可以去官网查看一下。很简单,就不演示了。


其实就是 你对象的作用与 如果是Activity 那么 fragment和view 肯定可以获取到 并且共享他的状态


能理解Activity》Fragment》View 那就很容易理解了。


到底为啥要用Hilt呀


我们学了前面的基础例子以后 一定要把这个问题想明白,否则这个框架你是无法真正理解的,理解他以后 才能用得好。


Hilt要解决的问题就是:


在android开发中,我们太多的场景是干啥?是在Activity里面 构造对象,而这些对象我们是怎么构建出来的?


大部分人都是New出来的对吧,但是这些New出来的对象 所属的Class 一旦发生了构造函数的变更,


我们还得去找出所有 引用这个Class 的 地方 11 去修改 调用方法。 这个就很不方便了。


回顾下前面我们的例子,使用Hilt的话 可以极大避免这种场景。


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

收起阅读 »

iOS多张图片合成一张

在我们的开发过程中,有时候会遇到不同的需求,比如将不同的图片合成一张图片下边是实现代码:#import "RootViewController.h"@interface RootViewController ()@end@implementation Root...
继续阅读 »

在我们的开发过程中,有时候会遇到不同的需求,比如将不同的图片合成一张图片


下边是实现代码:

#import "RootViewController.h"

@interface RootViewController ()

@end

@implementation RootViewController

- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
if (self) {
// Custom initialization
}
return self;
}

- (void)viewDidLoad
{
[super viewDidLoad];
// Do any additional setup after loading the view.

NSArray *imgArray = [[NSArray alloc] initWithObjects:
[UIImage imageNamed:@"1.jpg"],
[UIImage imageNamed:@"2.jpg"],
[UIImage imageNamed:@"3.jpg"],
[UIImage imageNamed:@"4.jpg"],
[UIImage imageNamed:@"5.jpg"],
nil];

NSArray *imgPointArray = [[NSArray alloc] initWithObjects:
@"10", @"10",
@"10", @"25",
@"30", @"15",
@"30", @"50",
@"20", @"80",
nil];


BOOL suc = [self mergedImageOnMainImage:[UIImage imageNamed:@"1.jpg"] WithImageArray:imgArray AndImagePointArray:imgPointArray];

if (suc == YES) {
NSLog(@"Images Successfully Mearged & Saved to Album");
}
else {
NSLog(@"Images not Mearged & not Saved to Album");
}

}
#pragma -mark -functions
//多张图片合成一张
- (BOOL) mergedImageOnMainImage:(UIImage *)mainImg WithImageArray:(NSArray *)imgArray AndImagePointArray:(NSArray *)imgPointArray
{

UIGraphicsBeginImageContext(mainImg.size);

[mainImg drawInRect:CGRectMake(0, 0, mainImg.size.width, mainImg.size.height)];
int i = 0;
for (UIImage *img in imgArray) {
[img drawInRect:CGRectMake([[imgPointArray objectAtIndex:i] floatValue],
[[imgPointArray objectAtIndex:i+1] floatValue],
img.size.width,
img.size.height)];

i+=2;
}

CGImageRef NewMergeImg = CGImageCreateWithImageInRect(UIGraphicsGetImageFromCurrentImageContext().CGImage,
CGRectMake(0, 0, mainImg.size.width, mainImg.size.height));

UIGraphicsEndImageContext();
if (NewMergeImg == nil) {
return NO;
}
else {
UIImageWriteToSavedPhotosAlbum([UIImage imageWithCGImage:NewMergeImg], self, nil, nil);
return YES;
}
}



- (void)didReceiveMemoryWarning
{
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}

@end

转自:https://www.cnblogs.com/gchlcc/p/6774420.html

收起阅读 »