
1.环信3.9.3 sdk登录慢的问题




最近我观察到一个现象,当服务的请求量突发的增长一下时,服务的有效QPS会下降很多,有时甚至会降到0,这种现象网上也偶有提到,但少有解释得清楚的,所以这里来分享一下问题成因及解决方案。
目前的Web服务器,如Tomcat,请求处理过程大概都类似如下:
这是Tomcat请求处理的过程,如下:
这里不用太关心Acceptor
与Poller
线程,这是nio编程时常见的线程模型,我们将重点放在exec线程池上,虽然Tomcat做了一些优化,但它还是从Java原生线程池扩展出来的,即有一个任务队列与一组线程。
当请求量突发增长时,会发生如下的情况:
可是,exec线程们还是在一刻不停歇的处理着请求的呀,按理说服务QPS是不会减少的呀!
简单想想的确如此,但调用端一般是有超时时间设置的,不会无限等待下去,当客户端等待超时的时候,这个请求实际上Tomcat就不用再处理了,因为就算处理了,客户端也不会再去读响应数据的。
因此,当队列比较长时,队列后面的请求,基本上都是不用再处理的,但exec线程池不知道啊,它还是会一如既往地处理这些请求。
当exec线程执行这些已超时的请求时,若又有新请求进来,它们也会排在队尾,这导致这些新请求也会超时,所以在流量突发的这段时间内,请求的有效QPS会下降很多,甚至会降到0。
这种超时也叫做队列延迟,但队列在软件系统中应用得太广泛了,比如操作系统调度器维护了线程队列,TCP中有backlog连接队列,锁中维护了等待队列等等。
因此,很多系统也会存在这种现象,平时响应时间挺稳定的,但偶尔耗时很高,这种情况有很多都是队列延迟导致的。
知道了问题产生的原因,要优化它就比较简单了,我们只需要让队列中那些长时间未处理的请求暂时让路,让线程去执行那些等待时间不长的请求即可,毕竟这些长时间未处理的请求,让它们再等等也无防,因为客户端可能已经超时了而不需要请求结果了,虽然这破坏了队列的公平性,但这是我们需要的。
对于Tomcat,在springboot中,我们可以如下修改:
使用WebServerFactoryCustomizer自定义Tomcat的线程池,如下:
@Component
public class TomcatExecutorCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Resource
ServerProperties serverProperties;
@Override
public void customize(TomcatServletWebServerFactory factory) {
TomcatConnectorCustomizer tomcatConnectorCustomizer = connector -> {
ServerProperties.Tomcat.Threads threads = serverProperties.getTomcat().getThreads();
TaskQueue taskqueue = new SlowDelayTaskQueue(1000);
ThreadPoolExecutor executor = new org.apache.tomcat.util.threads.ThreadPoolExecutor(
threads.getMinSpare(), threads.getMax(), 60L, TimeUnit.SECONDS,
taskqueue, new CustomizableThreadFactory("http-nio-8080-"));
taskqueue.setParent(executor);
ProtocolHandler handler = connector.getProtocolHandler();
if (handler instanceof AbstractProtocol) {
AbstractProtocol<?> protocol = (AbstractProtocol<?>) handler;
protocol.setExecutor(executor);
}
};
factory.addConnectorCustomizers(tomcatConnectorCustomizer);
}
}
注意,这里还是使用的Tomcat实现的线程池,只是将任务队列TaskQueue扩展为了SlowDelayTaskQueue,它的作用是将长时间未处理的任务移到另一个慢队列中,待当前队列中无任务时,再把慢队列中的任务移回来。
为了能记录任务入队列的时间,先封装了一个记录时间的任务类RecordTimeTask,如下:
@Getter
public class RecordTimeTask implements Runnable {
private Runnable run;
private long createTime;
private long putQueueTime;
public RecordTimeTask(Runnable run){
this.run = run;
this.createTime = System.currentTimeMillis();
this.putQueueTime = this.createTime;
}
@Override
public void run() {
run.run();
}
public void resetPutQueueTime() {
this.putQueueTime = System.currentTimeMillis();
}
public long getPutQueueTime() {
return this.putQueueTime;
}
}
然后队列的扩展实现如下:
public class SlowDelayTaskQueue extends TaskQueue {
private long timeout;
private BlockingQueue<RecordTimeTask> slowQueue;
public SlowDelayTaskQueue(long timeout) {
this.timeout = timeout;
this.slowQueue = new LinkedBlockingQueue<>();
}
@Override
public boolean offer(Runnable o) {
// 将任务包装一下,目的是为了记录任务放入队列的时间
if (o instanceof RecordTimeTask) {
return super.offer(o);
} else {
return super.offer(new RecordTimeTask(o));
}
}
public void pullbackIfEmpty() {
// 如果队列空了,从慢队列中取回来一个
if (this.isEmpty()) {
RecordTimeTask r = slowQueue.poll();
if (r == null) {
return;
}
r.resetPutQueueTime();
this.add(r);
}
}
@Override
public Runnable poll(long timeout, TimeUnit unit) throws InterruptedException {
pullbackIfEmpty();
while (true) {
RecordTimeTask task = (RecordTimeTask) super.poll(timeout, unit);
if (task == null) {
return null;
}
// 请求在队列中长时间等待,移入慢队列中
if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
this.slowQueue.offer(task);
continue;
}
return task;
}
}
@Override
public Runnable take() throws InterruptedException {
pullbackIfEmpty();
while (true) {
RecordTimeTask task = (RecordTimeTask) super.take();
// 请求在队列中长时间等待,移入慢队列中
if (System.currentTimeMillis() - task.getPutQueueTime() > this.timeout) {
this.slowQueue.offer(task);
continue;
}
return task;
}
}
}
逻辑其实挺简单的,如下:
为了将请求的队列延迟记录在access.log中,我又修改了一下Task,并加了一个Filter,如下:
@Getter
public class RecordTimeTask implements Runnable {
private static final ThreadLocal<Long> WAIT_IN_QUEUE_TIME = new ThreadLocal<>();
private Runnable run;
private long createTime;
private long putQueueTime;
public RecordTimeTask(Runnable run){
this.run = run;
this.createTime = System.currentTimeMillis();
this.putQueueTime = this.createTime;
}
@Override
public void run() {
try {
WAIT_IN_QUEUE_TIME.set(System.currentTimeMillis() - this.createTime);
run.run();
} finally {
WAIT_IN_QUEUE_TIME.remove();
}
}
public void resetPutQueueTime() {
this.putQueueTime = System.currentTimeMillis();
}
public long getPutQueueTime() {
return this.putQueueTime;
}
public static long getWaitInQueueTime(){
return ObjectUtils.defaultIfNull(WAIT_IN_QUEUE_TIME.get(), 0L);
}
}
@WebFilter
@Component
public class WaitInQueueTimeFilter extends HttpFilter {
@Override
public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws
IOException,
ServletException {
long waitInQueueTime = RecordTimeTask.getWaitInQueueTime();
// 将等待时间设置到request的attribute中,给access.log使用
request.setAttribute("waitInQueueTime", waitInQueueTime);
// 如果请求在队列中等待了太长时间,客户端大概率已超时,就没有必要再执行了
if (waitInQueueTime > 5000) {
response.sendError(503, "service is busy");
return;
}
chain.doFilter(request, response);
}
}
server:
tomcat:
accesslog:
enabled: true
directory: /home/work/logs/applogs/java-demo
file-date-format: .yyyy-MM-dd
pattern: '%h %l %u %t "%r" %s %b %Dms %{waitInQueueTime}rms "%{Referer}i" "%{User-Agent}i" "%{X-Forwarded-For}i"'
注意,在access.log中配置%{xxx}r
表示取请求xxx属性的值,所以,%{waitInQueueTime}r
就是队列延迟,后面的ms是毫秒单位。
我使用接口压测工具wrk压了一个测试接口,此接口执行时间100ms,使用1000个并发去压,1s的超时时间,如下:
wrk -d 10d -T1s --latency http://localhost:8080/sleep -c 1000
然后,用arthas看一下线程池的队列长度,如下:
[arthas@619]$ vmtool --action getInstances \
--classLoaderClass org.springframework.boot.loader.LaunchedURLClassLoader \
--className org.apache.tomcat.util.threads.ThreadPoolExecutor \
--express 'instances.{ #{"ActiveCount":getActiveCount(),"CorePoolSize":getCorePoolSize(),"MaximumPoolSize":getMaximumPoolSize(),"QueueSize":getQueue().size()} }' \
-x 2
可以看到,队列长度远小于1000,这说明队列中积压得不多。
再看看access.log,如下:
可以发现,虽然队列延迟任然存在,但被控制在了1s以内,这样这些请求就不会超时了,Tomcat的有效QPS保住了。
而最后面那些队列延迟极长的请求,则是被不公平对待的请求,但只能这么做,因为在请求量超出Tomcat处理能力时,只能牺牲掉它们,以保全大局。
2023年到来,今年过年格外早,没几天就要迎新年了,因为是兔年,所以我创建了一个Rabbit为主题的App,里面以兔子为主题而添加各种相关内容,目前仅有十条2023兔年祝福语,后面会增加其他功能,下面,我们看看这个App的样子。
首先,这个App因为这两天才创建的,所以只是UI上看起来和兔子相关,内容并不是很充实。主要是找了一张兔子的图片做App的logo,以及找了几张动态图作为app内部的装饰UI,如下:
内部我是利用LottieAnimation去展示动图(让UI忙碌的安卓Lottie动画渲染库(一) - 掘金 (juejin.cn) & 让UI忙碌的安卓Lottie动画渲染库(二) - 掘金 (juejin.cn)),然后使用之前掘友推荐的刘强东写的列表神器BRV(liangjingkanji/BRV: [文档详细] Android上最好的RecyclerView框架, 比 BRVAH 更简单强大 (github.com)),琢磨了半天最后还是没有成功使用库作者推荐的DataBinding方式,我使用RecyclerView中使用BRV去加载10条祝福语。
这是使用作者推荐方式后运行不起来的截图:
看文档上的解决方法依次尝试还是没成功,所以还是采用ViewBinding的方式了。
部分XML布局如下,我虽然启用了DataBinding但目前还不会用,所以我也同时启用了ViewBinding:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
...
...
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/rabbit_easter_egg_slider"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_gravity="center"
app:lottie_autoPlay="true"
app:lottie_fileName="lottie/rabbit_easter_egg_slider.json"
app:lottie_loop="true"
app:lottie_repeatMode="restart" />
<androidx.core.widget.NestedScrollView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:ignore="SpeakableTextPresentCheck">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
...
...
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/rabbit_2023"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="center"
app:lottie_autoPlay="true"
app:lottie_fileName="lottie/rabbit_2023.json"
app:lottie_loop="true"
app:lottie_repeatMode="restart" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/vMainList"
android:layout_width="match_parent"
android:layout_height="wrap_content" >
</androidx.recyclerview.widget.RecyclerView>
</LinearLayout>
</layout>
我的activity中部分代码如下,很笨拙地使用列表的方式存了10条祝福语,后面还会优化一下并加上复制按钮:
...
...
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var text = arrayOf("兔年!...",
...
....,
....,
....")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
window.attributes.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
binding.vMainList.linear().setup {
addType<SimpleModel>(R.layout.item_simple)
setAnimation(AnimationType.SLIDE_BOTTOM)
onBind {
val binding = getBinding<ItemSimpleBinding>() // 使用ViewBinding/DataBinding都可以使用本方法
binding.tvName.text = getModel<SimpleModel>().name
}
}.models = getData()
}
private fun getData(): MutableList<Any> {
// 在Model中也可以绑定数据
return mutableListOf<Any>().apply {
for (i in 1..10) {
val simpleModel = SimpleModel(
"$i、${text[i-1]}"
, i)
add(simpleModel)
// add(SimpleModel())
}
}
}
}
运行后目前只可以滑动查看列表:
总之就是这个App目前还非常简陋,但是已经放到了GitHub上了,后续会逐渐添加优化一些功能和代码。
项目地址:ObliviateOnline/RabbitApp: 2023 rabbit app (github.com)
本来是想做一个搜索类的App,结果发现做着做着就偏离了方向,但是本来就是为了新年添个彩头,又是自己弄着玩的,加之看起来还是像那么回事,所以就这么直接发出来献丑了,希望大家喜欢!
在 App 的运营中,活跃度是一个重要的指标,日活/月活……为了提高活跃度,就发明了小红点,然后让强迫症用户“没法活”。
小红点虽然很讨厌,但是为了 KPI,程序员也不得不屈从运营同学的逼迫(讨好),得想办法实现。这一篇,来介绍一个徽标(Badge)组件,能够快速搞定应用内的小红点。
Badge
组件被 Flutter 官方推荐,利用它让小红点的实现非常轻松,只需要2个步骤就能搞定。
在 pubspec.yaml
文件种引入相应版本的依赖,如下所示。
badges: ^2.0.3
Badge(
badgeContent: Text('3'),
position: BadgePosition.topEnd(top: -10, end: -10),
badgeColor: Colors.blue,
child: Icon(Icons.settings),
)
position
可以设置徽标在组件的相对位置,包括右上角(topEnd
)、右下角(bottomEnd
)、左上角(topStart
)、左下角(bottomStart
)和居中(center
)等位置。并可以通过调整垂直方向和水平方向的相对位置来进行位置的细微调整。当然,Badge
组件考虑了很多应用场景,因此还有其他的一些参数:
elevation
:阴影偏移量,默认为2,可以设置为0消除阴影;gradient
:渐变色填充背景;toAnimate
:徽标内容改变后是否启用动效哦,默认有动效。shape
:徽标的形状,默认是原型,也可以设置为方形,设置为方形的时候可以使用 borderRadius
属性设置圆角弧度。borderRadius
:圆角的半径。animationType
:内容改变后的动画类型,有渐现(fade)、滑动(slide)和缩放(scale)三种效果。showBadge
:是否显示徽标,我们可以利用这个控制小红点的显示与否,比如没有提醒的时候该值设置为 false
即可隐藏掉小红点。总的来说,这些参数能够满足所有需要使用徽标的场景了。
我们来看一个实例,我们分别在导航栏右上角、内容区和底部导航栏使用了三种类型的徽标,实现效果如下。
其中导航栏的代码如下,这是 Badge
最简单的实现方式了。
AppBar(
title: const Text('Badge Demo'),
actions: [
Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(4.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: 4, end: 4),
child: IconButton(
onPressed: () {},
icon: const Icon(
Icons.message_outlined,
color: Colors.white,
),
),
),
],
),
内容区的徽标代码如下,这里使用了渐变色填充,动画形式为缩放,并且将徽标放到了左上角,注意如果使用了渐变色那么会覆盖 badgeColor
指定的背景色。
Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(6.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 10.0,
),
),
position: BadgePosition.topStart(top: -10, start: -10),
badgeColor: Colors.blue,
animationType: BadgeAnimationType.scale,
elevation: 0.0,
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.red,
Colors.orange,
Colors.green,
],
),
child: Image.asset(
'images/girl.jpeg',
width: 200,
height: 200,
),
),
底部导航栏的代码如下所示,这里需要注意,Badge
组件会根据内容区的尺寸自动调节大小,底部导航栏的显示控件有限,推荐使用小红点(不用数字标识)即可。
BottomNavigationBar(items: [
BottomNavigationBarItem(
icon: Badge(
showBadge: _badgeNumber > 0,
padding: const EdgeInsets.all(2.0),
badgeContent: Text(
_badgeNumber < 99 ? _badgeNumber.toString() : '99+',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 11.0,
),
),
position: BadgePosition.topEnd(top: -4, end: -6),
animationType: BadgeAnimationType.fade,
child: const Icon(Icons.home_outlined)),
label: '首页',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.star_border,
),
label: '推荐',
),
const BottomNavigationBarItem(
icon: Icon(
Icons.account_circle_outlined,
),
label: '我的',
),
]),
本篇介绍了使用 Badge
组件实现小红点徽标组件。可以看到,Badge
组件的使用非常简单,相比我们自己从零写一个 Badge
组件来说,使用它可以让我们省时省力、快速地完成运营同学要的小红点。本篇源码已上传至:实用组件相关代码。
最近公司的项目用Flutter
技术栈比较多,有些需求可能还需要一些Android
原生的支持,所以我做了一些Android
原生混合Flutter
开发的尝试,参考了一些文章,也遇到了一些问题,这里把总结的经验分享出来。
本文是针对 Android 项目添加 Flutter 模块的情况编写的。
Android
项目直接贴图带过了哈,这步应该都熟练的吧
Flutter
模块这里就有区别了,较新版的AS
中提供直接创建Flutter
模块的模板,但是我的北极狐版本没有,因此这里演示两种方式:
AS
模板创建在你的当前项目中,使用AS
菜单中的 File > New > New Module… 创建一个新的Flutter
模块,或者选择一个此前就已准备好的Flutter
模块。
如果你选择创建一个新的模块,你可以使用向导来帮助你设置模块的名称,模块存放的位置之类的配置项。
AS
,所以我并未实践官方提供的模板创建方式,按照官方的说法,它会自动帮你配置好依赖关系,但我也不确定会不会遇到问题,没有最好,有的话应该也都和手动创建的方式差不多。Flutter
模块在Terminal
执行下方命令
```
flutter create -t module --org com.randalldev fluttermodule
```
复制代码
然后官方提供了两种方式添加依赖关系:
AAR
依赖模式
AAR
模式有个好处就是团队中的其他成员不需要安装Flutter SDK
,最少只需要一个人输出AAR
即可。
但是我个人不喜欢这种方式,我更倾向于git submodule
的项目管理方式,并且安装Flutter SDK
的成本实在算不上高,因此,这种方式,我按下不表。
模块代码依赖模式
这种方式确保了一步完成Android
项目和Flutter
模块的编译。这种方式对于你的开发同时涉及两个部分并且快速迭代很方便,但这需要团队的每个人成员都安装Flutter SDK
来确保顺利编译这个混合app
。
在主项目的settings.gradle
中将Flutter
模块作为子项目引入。
// Include the host app project.
include ':app' // 默认已有的配置
setBinding(new Binding([gradle: this])) // 新增
evaluate(new File( // 新增
settingsDir.parentFile, // 新增
'${root_project}/fluttermodule/.android/include_flutter.groovy' // 新增
)) // 新增
此时
AS
会提示你gradle
配置变更了,需要重新sync
,别急,先别点!
假设fluttermodule
是和app
目录同层级的。
在app
的build.gradle
中添加flutter
模块的依赖
dependencies {
implementation project(':flutter')
}
官方的指南就到此为止了,与此同时,坑也来了/doge
sync
会出现如下报错* What went wrong:
A problem occurred evaluating script.
> Failed to apply plugin class 'FlutterPlugin'.
> Build was configured to prefer settings repositories over project repositories but repository 'maven' was added by plugin class 'FlutterPlugin'
将project
的setting.gradle
的
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}
改为
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}
sync
会出现如下报错A problem occurred evaluating project ':app'.
> Project with path ':fluttermodule' could not be found in project ':app'.
在project
的setting.gradle
的末尾添加
include ':fluttermodule'
sync
编译大概率能成功,但是会有很严重的警告Failed to resolve: androidx.core:core-ktx:1.9.0
Add Google Maven repository and sync project
Show in Project Structure dialog
Affected Modules: app
在project
的build.gradle
的
task clean(type: Delete) {
delete rootProject.buildDir
}
上方添加
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
至此,大体上一个混合的Android
原生+Flutter
项目的初步构建就完成了。
Android
原生打开Flutter
页面默认的跳转方式会出现明显的白屏,体验上很不好,这里直接给出优化后的方式
FlutterEngine
缓存并复用在app
的AndroidManifest.xml
中注册FlutterActivity
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:theme="@style/Theme.HybridFlutter"
android:windowSoftInputMode="adjustResize" >
</activity>
在app
中创建一个App.kt
继承Application
并在AndroidManifest.xml
中配置给application
节点的name
属性
class App : Application() {
···
}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.randalldev.hybridflutter">
<application
android:name=".App"
···
</manifest>
在App.kt
中准备好FlutterEngine
创建FlutterEngine
实例
private val flutterEngine by lazy {
FlutterEngine(this).apply {
dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
}
}
重写onCreate()
并将实例存储在FlutterEngineCache
中
override fun onCreate() {
super.onCreate()
FlutterEngineCache.getInstance().put("your_engine_id", flutterEngine)
}
重写onTerminate()
并将实例销毁
override fun onTerminate() {
super.onTerminate()
flutterEngine.destroy()
}
在业务需要的地方使用FlutterEngine
中的Intent
实例进行跳转
findViewById<TextView>(R.id.textView).setOnClickListener {
startActivity(FlutterActivity.withCachedEngine("your_engine_id").build(this))
}
选择app
进行run
如果遇到如下Java
版本问题,请进行如下配置变更
A problem occurred evaluating project ':flutter'.
> Failed to apply plugin 'com.android.internal.library'.
> Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
You can try some of the following options:
- changing the IDE settings.
- changing the JAVA_HOME environment variable.
- changing `org.gradle.java.home` in `gradle.properties`.
选择 Project Structure > SDK location > Gradle Settings 设置Gradle JDK
为11
在./gradle.properties
中添加上文中对应的java.home
路径
# replace with your own jdk11 or above
org.gradle.java.home=C\:\\Softwares\\Google\\Android\\Android_Studio\\jre
sync
后应该就可以顺利的run
了
最近接触系统App相关的开发,刚开始得知在系统源码中,开发系统应用,As 引用库的时候,居然不能代码联想,布局也不能预览,实在不习惯。后面搜了下网上的资源,有一些介绍,也不是特别完整,于是自己把这些零碎的点,整理出来,方面后续自己看看。
本文主要解决以下几个问题:
这一步,就是把系统App的代码拷贝出来(例如:/packages/apps/Settings),相当于移植到一个新的项目。创建一个新的 As 工程,然后按照源码的目录层级创建,记得包名跟源码一致,尽可能保存目录层级一致,接着就是各种 copy 操作了,把 src、res 等目录都搬过去新项目中。在移植的过程,需要将 Android.bp
文件里面依赖的库,按照 gradle 的方式来依赖进去。例如:
static_libs: [
"com.google.android.material_material",
"androidx.transition_transition",
"androidx-constraintlayout_constraintlayout",
"androidx.core_core",
"androidx.media_media",
"androidx.legacy_legacy-support-core-utils",
"androidx.legacy_legacy-support-core-ui",
"androidx.fragment_fragment",
"androidx.appcompat_appcompat",
"androidx.preference_preference",
"androidx.recyclerview_recyclerview",
"androidx.legacy_legacy-preference-v14",
"androidx.leanback_leanback",
"androidx.leanback_leanback-preference",
"androidx.lifecycle_lifecycle-extensions",
"androidx.lifecycle_lifecycle-common-java8",
"kotlin-stdlib",
"kotlinx-coroutines-android",
"androidx.navigation_navigation-common-ktx",
"androidx.navigation_navigation-fragment-ktx",
"androidx.navigation_navigation-runtime-ktx",
"androidx.navigation_navigation-ui-ktx",
]
对应到 gradle 代码,这个过程十分麻烦,因为很多资源缺失,需要一个个的寻找,以及代码的移植还会关联其他工程代码。而且库的版本也是需要注意的。所以需要耐心解决。
compileOnly files('libs/framework.jar')
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation 'com.android.support:multidex:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation "com.google.protobuf:protobuf-javalite:3.13.0"
def nav_version = "2.3.5"
// Java language implementation
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// Feature module Support
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
def lifecycle_version = "2.3.1"
def arch_version = "2.1.0"
// ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
// LiveData
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
// Lifecycles only (without ViewModel or LiveData)
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
// ???
implementation "android.arch.lifecycle:extensions:1.1.1"
// Saved state module for ViewModel
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
// Annotation processor
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
// alternately - if using Java8, use the following instead of lifecycle-compiler
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
// optional - helpers for implementing LifecycleOwner in a Service
implementation "androidx.lifecycle:lifecycle-service:$lifecycle_version"
// optional - ProcessLifecycleOwner provides a lifecycle for the whole application process
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
// optional - ReactiveStreams support for LiveData
implementation "androidx.lifecycle:lifecycle-reactivestreams-ktx:$lifecycle_version"
// optional - Test helpers for LiveData
testImplementation "androidx.arch.core:core-testing:$arch_version"
def leanback_version = "1.2.0-alpha01"
implementation "androidx.leanback:leanback:$leanback_version"
// leanback-preference is an add-on that provides a settings UI for TV apps.
implementation "androidx.leanback:leanback-preference:$leanback_version"
// leanback-paging is an add-on that simplifies adding paging support to a RecyclerView Adapter.
implementation "androidx.leanback:leanback-paging:1.1.0-alpha08"
// leanback-tab is an add-on that provides customized TabLayout to be used as the top navigation bar.
implementation "androidx.leanback:leanback-tab:1.1.0-beta01"
如果项目源码存在多个 src
目录,需要在 gradle 中指定 java
目录
sourceSets {
main {
java.srcDirs = ['src/main/src', 'src/main/src2', 'src/main/src_gen']
// 定义proto文件目录
proto {
// srcDir 'src/main/java'
srcDir 'src/main/src'
include '**/*.proto'
}
}
}
系统源码编译之后,找到/out/target/common/obj/JAVA_LIBRARIES/framework_intermediates 目录下的 classes.jar
文件,更名为 framework.jar
,按照jar包的引用方式,依赖进去工程。
同时需要更改jar的加载顺序,在工程目录的 gradle 添加如下代码
allprojects {
repositories {
google()
jcenter()
}
gradle.projectsEvaluated {
tasks.withType(JavaCompile) {
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
// options.compilerArgs.add('-Xbootclasspath/p:app/libs/framework.jar')
Set<File> fileSet = options.bootstrapClasspath.getFiles()
List<File> newFileList = new ArrayList<>();
//"../framework.jar" 为相对位置,需要参照着修改,或者用绝对位置
// 我这里用的是绝对路径,注意区分 linux 系统与 window 系统的反斜杠
newFileList.add(new File("/xxx/framework.jar"))
newFileList.addAll(fileSet)
options.bootstrapClasspath = files(newFileList.toArray())
// options.bootstrapClasspath.getFiles().forEach(new Consumer<File>() {
// @Override
// void accept(File file) {
// println(file.name)
// }
// })
//options.compilerArgs.add('-Xbootclasspath/p:app\\libs\\framework.jar')
}
}
}
aidl 代码可以用两种方式处理,一种是直接拷贝aidl的生成物,本质还是java代码,另一种方式是按源码那样创建aidl文件。
需要正确引入对应的 protobuf 的版本,以及生成代码的目录,我记得我当时还因为版本不匹配导致一些错误,具体时间太久了,当时也没存记录。
plugins {
id 'com.android.application'
id 'com.google.protobuf'
id 'kotlin-android'
id 'kotlin-kapt'
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.13.0"
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
}
}
}
}
···
// 工程 gradle 配置
buildscript {
ext.kotlin_version = "1.4.32"
repositories {
google()
jcenter()
}
dependencies {
classpath "com.android.tools.build:gradle:4.1.3"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.protobuf:protobuf-gradle-plugin:0.8.13"
}
}
源码中会使用一些过时的方法,在打包过程会导致失败。需要在 gradle 中配置,错误不中断
lintOptions {
abortOnError false
}
整个过程,就是多次修改,拷贝,然后编译的过程,直到没有错误提示,能够成功生成apk的时候,就成功了。之后就可以愉快的关联代码,以及布局预览了。
这是一个代码格式化工具,我们可以定义自己想要的代码规则在需要的时候去应用它,整个过程完全自动且可以应用于整个工程。
Spotless 支持多种语言:c、c++、java、kotlin、python 等等更多,应用广泛的开发语言基本都支持。
插件丰富,支持 Gradle、Maven、SBT。并且有 IDE 插件,如:VS Code、Intellij。
社区活跃,很多优秀的开源贡献者,如:ktlint、ktfmt、prittier 等, Github 上的提交经常是几天前。
编写代码时我们都希望遵循固定的代码风格,我们使用的 IDE 也都有代码格式化功能。但是很遗憾,代码格式化都需要开发者手动触发,所以你肯定会有忘记格式化的时候。
如果你的团队有 code review 的过程,你的小伙伴或许能纠正这些问题,当然,也有可能看不到,某段代码就这样”脏“了。
虽然良好、统一的代码风格并不能提高代码性能,即使代码风格很糟糕代码也能正确编译,且运行结果并无二致。但是当你的代码有良好统一风格时,代码会更美观,有更好的阅读性,小伙伴 code review 时可以完全不用关注代码风格。
而 [Spotless](diffplug/spotless: Keep your code spotless (github.com)) 就是这样一个能让你不用关注代码风格的工具,因为它会自动帮你格式化代码。
首先在 project build.gradle
中添加 Spotless 插件
classpath "com.diffplug.spotless:spotless-plugin-gradle:$spotless_version"
然后在 project 或 module 的 build.gradle
中做如下配置
apply plugin: 'com.diffplug.spotless'
spotless {
java{
target 'src/*/java/**/*.java'
googleJavaFormat()
}
kotlin {
target 'src/*/java/**/*.kt'
ktlint('0.43.2')
}
format 'misc', {
target '**/*.gradle', '**/*.md', '**/.gitignore'
}
format 'xml', {
target '**/*.xml'
}
}
结束了,就是这么简单,你需要的仅仅是去配置你想要的代码规则。具体规则配置请参考 [Spotless GitHub](spotless/plugin-gradle at main · diffplug/spotless (github.com))
Java (google-java-format, eclipse jdt, clang-format, prettier, palantir-java-format, formatAnnotations)
Kotlin (ktfmt, ktlint, diktat, prettier)
使用 Spotless 非常简单,一般我们只需要使用两个 task
./gradlew spotlessCheck
执行 spotlessCheck
可以校验代码风格,如果有问题会报错并列出异常代码的位置
./gradlew spotlessApply
执行 spotlessApply
可以在全工程范围内自动修复代码不合规的地方。不过它只能格式化代码,如果规则需要修改代码,比如要删除某个导入的包是需要手动操作的。
从以上两步操作:配置、执行,可以看到 Spotless 的依赖成本是非常低的,使用也非常简单。它带来的好处在我看来有两点:
所以如果你或你的团队在关注代码风格问题,那么 Spotless
一定适合你,请尝试使用吧!
对于 Android 开发最关心的就是 java 和 kotlin 了,这两个语言都有非常多的代码规范支持,不过使用较多的是 Java「google-java-format」
和 kotlin「ktlint」
。随着 kotlin 在 Android 开发的占比增长,kotlin 的代码规范就显得更受关注,并且如果你关注 Google 的官方源码,你会发现 Google 的这些工程大多都是使用 ktlint 约束代码风格,这是因为 ktlin 同时支持 official Kotlin coding conventions 和 Android Kotlin Style Guide 两种风格,Android 开发者可以用 ktlint 方便的遵循 Google 代码风格。
更多 ktlint
内容请关注 [ktlint](Ktlint (pinterest.github.io))。
前文提到的 google-java-format
、ktlint
是都有 IDE 插件的,可以在 plugins market
中安装。
个人目前开发比较少涉及到 java ,所以 AS 并没有安装 google-java-format
,有 java code format 的开发者可以在市场中安装此工具。下面我简单介绍下 AS ktlint 插件的使用体验。
比如此处多了一个空行,ktlint 会醒目标红,鼠标移过去会展示可操作的选项,可以选择 ktlint format 或禁用此规则。
安装插件后 AS 会增加一个菜单 Refactor -> Format With Ktlint
,此菜单可以格式化整个文件,就与 AS 的格式化操作一样,你也可以为 Format With Ktlint
指定快捷键,操作会更方便。
在 ktlint 的 gradle 、AS 插件加持下,相信代码风格在开发中不需要特别花时间去处理了。如果代码风格不正确首先是 AS 的 error 醒目提醒,如果看到了可以一键修复。如果看不到,在执行 ./gradlew spotlessApply
之后还可以全工程修复,让“脏”代码无所遁形。
上文介绍的配置,可能已经满足很多人了,不过还是有人会觉得,只要是手动操作的内容,那一定会有可能会忘记,有没有不需要手动操作的格式化操作。
有的有的,方案就是本小结标题 Git hooks
。Ktlint 官网有一键安装 git hooks
的操作,可以参考 [ktlint git hooks](Command line - Ktlint (pinterest.github.io))。
关于 git hooks 是什么可以参考这篇博文: [git hooks 简介与使用](git hooks 简介与使用 - 简书 (jianshu.com))
不过这个需要每个人都要手动去操作,其实我们可以把 git hooks 脚本文件放到我们工程里,然后通过 gradle 将脚本文件拷贝至 .git 目录
首先在项目下新建hooks
目录,新建pre-commit
文件:
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
./gradlew spotlessCheck
result=$?
printf "the spotlessCheck result code is $result"
if [[ "$result" = 0 ]] ; then
echo "\033[32m
....
....
SpotlessCheck Pass!!
....
....
\033[0m"
exit 0
else
./gradlew spotlessApply
echo "\033[31m
....
....
SpotlessCheck Failed!!
代码格式有问题;
....
已经自动调整格式,review代码后再git add . && git commit
....
....
\033[0m"
exit 1
fi
pre-commit
脚本可以根据自己需要作调整,这边仅仅是一个示例。
下一步需要将此文件拷贝至项目目录下 .git/hooks
目录,可以用 gradle 来处理拷贝事件。
task copyHooks(type: Copy) {
println 'copyHooks task'
from("hooks") {
include "**"
}
into ".git/hooks"
}
执行此 task 就可以将项目 hooks
目录内容拷贝至 .git
完成上述操作之后,在每次执行 git commit
之前都会先执行 pre-commit
,在校验失败后会自动格式化代码,开发者在 reivew 之后再重新提交。
如果你在开发过程中时不时会因为代码格式化问题造成困扰,那 Spotless 及 ktlint 会完全解放你。让你无需关注代码格式的同时也能保证代码风格的一致。
ktlint 的优秀远不止此篇文中所述,这仅仅是一篇指导文,大家可以去探索更适合自己项目的方法。
希望此文能让大家了解 Spotless 并尝试使用,谢谢!
因为 Kotlin Flow 是基于 响应式编程 的实现,所以先了解一下 响应式编程 的概念。
首先看下百度百科解释:
响应式编程是一种面向数据流和变化传播的编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。
这个释义很抽象,难以理解。只知道它的核心是:数据流。
如何理解这个数据流,先看下响应式编程 ReactiveX 下的一个框架 RxJava 。
RxJava 是基于响应式编程的实现,它的定义:
RxJava 是 Reactive Extensions 的 Java VM 实现:一个通过使用可观察序列来组合异步和基于事件的程序的库。
它扩展了观察者模式以支持数据/事件序列,并添加了运算符,允许您以声明方式组合序列,同时消除了对低级线程、同步、线程安全和并发数据结构等问题的担忧。
看完这个定义,脑袋中也很模糊。下面从 RxJava 应用的一个简单例子来分析:
Observable.just(bitmap).map { bmp->
//在子线程中执行耗时操作,存储 bitmap 到本地
saveBitmap(bmp)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe { bitmapLocalPath ->
//在主线程中处理存储 bitmap 后的本地路径地址
refreshImageView(bitmapLocalPath)
}
上面例子中: 将一个 bitmap 存储到本地并返回本地路径,从源数据 bitmap → 存储 btimap 到本地操作 → 获取本地图片路径值刷新UI。其实,就可以把这整个过程中按时间发生的事件序列理解为数据流。
数据流包含提供方(生产者),中介(中间操作),使用方(消费者):
那么,上面例子中的数据流是:
再看下 RxJava 中的数据流解释:
RxJava 中的数据流由源、零个或多个中间步骤组成,然后是数据消费者或组合器步骤(其中该步骤负责通过某种方式消费数据流):
source.operator1().operator2().operator3().subscribe(consumer);
source.flatMap(value -> source.operator1().operator2().operator3());
在这里,如果我们想象自己在操作符 operator2 上,向左看 source 被称为上游。向右看 subscriber/consumer 称为下游。当每个元素都写在单独的行上时,这一点通常更为明显:
source
.operator1()
.operator2()
.operator3()
.subscribe(consumer)
这也是 RxJava 的上游、下游概念。
其实,Flow 数据流中参看 RxJava,也可以有这样类似的上游和下游概念:
flow
.operator1()
.operator2()
.operator3()
.collect(consumer)
了解了 响应式编程 的核心 数据流 后,对 响应式编程 有了初步印象。但是 响应式编程 的实现远不止如此,它还涉及观察者模式,线程调度等。不管原理这些,用它来做开发有什么好处呢?其实,它主要优点是:
下面看两个业务例子:
Observable.just(bitmap).map { bmp ->
//在子线程中执行耗时操作,存储 bitmap 到本地
saveBitmap(bmp)
}.map { path ->
//在子线程中执行耗时操作,上传图片到服务端
uploadBitmap(path)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
.subscribe { downloadUrl ->
//在主线程中处理获取图片下载地址
}
//从服务端批量下载文件
Observable.from(downloadUrls).flatMap { downloadUrl ->
//下载单个文件,返回本地文件
Observable.just(downloadUrl).map {url-> downloadResource(url) }
}.map { file ->
//对文件解压
unzipFile(file)
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
.subscribe { folderPath ->
//拿到文件夹路径
}
所以 响应式编程 的实现,主要是帮我们解决了 并发编程问题,能用优雅简洁的代码做异步事件处理。
Kotlin 协程 和 Flow,它们结合在一起也实现了 响应式编程。在 Kotlin 环境中,再结合 Android 提供 Lifecycle, ViewModel, Flow 的扩展,能让我们在 Android 中做并发编程,异步事件管理如鱼得水。
Kotlin Flow 就是 Kotlin 数据流,它基于 Kotlin 协程构建。上一篇 Kotlin 协程探索 分析了 协程 的大致原理,知道协程就是 Kotlin 提供的一套线程 API 框架,方便做并发编程。那么 Kotlin 协程 和 Flow (数据流)的结合,和 RxJava 框架就有异曲同工之妙。
下面使用 Kotlin 协程 和 Flow 来实现上面 RxJava 的两个业务例子:
GlobalScope.launch(Dispatchers.Main) {
flowOf(bitmap).map { bmp ->
//在子线程中执行耗时操作,存储 bitmap 到本地
Log.d("TestFlow", "saveBitmap: ${Thread.currentThread()}")
saveBitmap(bmp)
}.flowOn(Dispatchers.IO).collect { bitmapLocalPath ->
//在主线程中处理存储 bitmap 后的本地路径地址
Log.d("TestFlow", "bitmapLocalPath=$bitmapLocalPath: ${Thread.currentThread()}")
}
}
//从服务端批量下载文件
GlobalScope.launch(Dispatchers.Main) {
downloadUrls.asFlow().flatMapConcat { downloadUrl ->
//下载单个文件,返回本地文件
flowOf(downloadUrl).map { url ->
Log.d("TestFlow", "downloadResource:url=$url: ${Thread.currentThread()}")
downloadResource(url)
}
}.map { file ->
//对文件解压
Log.d("TestFlow", "unzipFile:file=${file.path}: ${Thread.currentThread()}")
unzipFile(file)
}.flowOn(Dispatchers.IO).collect { folderPath ->
//拿到文件夹路径
Log.d("TestFlow", "folderPath=$folderPath: ${Thread.currentThread()}")
}
}
控制台结果输出:
TestFlow: saveBitmap: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: bitmapLocalPath=/mnt/sdcard/Android/data/com.wangjiang.example/files/images/flow.png: Thread[main,5,main]
TestFlow: downloadResource:url=https://www.wangjiang.example/coroutine.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: unzipFile:file=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/coroutine.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: downloadResource:url=https://www.wangjiang.example/flow.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: unzipFile:file=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/flow.zip: Thread[DefaultDispatcher-worker-1,5,main]
TestFlow: folderPath=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/coroutine: Thread[main,5,main]
TestFlow: folderPath=/mnt/sdcard/Android/data/com.wangjiang.example/files/zips/flow: Thread[main,5,main]
可以看到,和 RxJava 实现的效果是一致的。首先,使用launch
启动一个协程,然后使用源数据创建一个 Flow
(数据生产),再经过 flatMapConcat
, map
变换(多个中间操作),最后通过collect
获取结果数据(数据消费),这其中还包括线程切换:在主线程中启动子线程执行耗时任务,并将耗时任务结果返回给主线程(flowOn 指定了中间操作在 IO 线程中执行)。所以 协程 和 Flow(数据流) 结合,就是 响应式编程 的实现,这对我们来说,使用它可以在 Kotlin 环境中写出优雅的异步代码来做并发编程。
下面再分别来熟悉一下 协程 和 Flow。
首先来看一下协程中的一些概念和 API。
CoroutineScope 会跟踪它使用 launch 或 async 创建的所有协程。您可以随时调用 scope.cancel() 以取消正在进行的工作(即正在运行的协程)。在 Android 中,某些 KTX 库为某些生命周期类提供自己的 CoroutineScope。例如,ViewModel 有 viewModelScope,Lifecycle 有 lifecycleScope。不过,与调度程序不同,CoroutineScope 不运行协程。
Kotlin 提供了为 UI 组件使用的 MainScope
:
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
为应用程序整个生命周期使用的 GlobalScope
:
public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}
因为是应用程序整个生命周期,所以要慎重使用。
也可以自定义 Scope:
val scope = CoroutineScope(Job() + Dispatchers.Main)
另外,Android KTX 库针对 CoroutineScope
做了扩展,所以在 Android 中通常会使用 Activity 或 Fragment 生命周期相关的 lifecycleScope
,和 ViewModel 生命周期相关的viewModelScope
。
public val Lifecycle.coroutineScope: LifecycleCoroutineScope
get() {
while (true) {
val existing = mInternalScopeRef.get() as LifecycleCoroutineScopeImpl?
if (existing != null) {
return existing
}
val newScope = LifecycleCoroutineScopeImpl(
this,
SupervisorJob() + Dispatchers.Main.immediate
)
if (mInternalScopeRef.compareAndSet(null, newScope)) {
newScope.register()
return newScope
}
}
}
public val ViewModel.viewModelScope: CoroutineScope
get() {
val scope: CoroutineScope? = this.getTag(JOB_KEY)
if (scope != null) {
return scope
}
return setTagIfAbsent(
JOB_KEY,
CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
)
}
internal class CloseableCoroutineScope(context: CoroutineContext) : Closeable, CoroutineScope {
override val coroutineContext: CoroutineContext = context
override fun close() {
coroutineContext.cancel()
}
}
启动协程有两种方式:
launch
:启动一个新的协程,并返回一个 Job
,这个 Job
是可以取消的 Job.cancel
;async
:也会启动一个新的协程,并返回一个 Deferred
接口实现,这个接口其实也继承了Job
接口,可以使用 await
挂起函数等待返回结果。val scope = CoroutineScope(Job() + Dispatchers.Main)
在 CoroutineScope 中定义了 plus 操作:
public operator fun CoroutineScope.plus(context: CoroutineContext): CoroutineScope =
ContextScope(coroutineContext + context)
因为 Job
和 Dispatchers
顶层都继承了接口 Element
,而 Element
又继承了接口 CoroutineContext
:
public interface Element : CoroutineContext
所以 Job() 和 Dispatchers.Main 可以相加。这里 CoroutineScope 的构造方法中是必须要有 Job()
,如果没有,它自己也会创建一个 Job()
:
public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
ContextScope(if (context[Job] != null) context else context + Job())
Job 和 CoroutineDispatcher 在 CoroutineContext
中的作用是:
Job:控制协程的生命周期。
CoroutineDispatcher:将工作分派到适当的线程。
GlobalScope.launch(Dispatchers.Main) {
}
withContext(Dispatchers.IO){
}
.flowOn(Dispatchers.IO)
要使用协程,首先创建一个 scope: CoroutineScope
来负责管理协程,定义scope
时需要指定控制协程的生命周期的 Job
和将工作分派到适当线程的CoroutineDispatcher
。定义好 scope 后, 可通过 scope.launch
启动一个协程,也可以多次使用scope.launch
启动多个协程,启动的协程可通过 scope.cancel
取消,但它取消的是 scope 启动的所有协程。如果要取消单个协程,需要使用scope.launch
返回的 Job
来取消Job.cancel
,这个 Job 控制着单个协程的生命周期。当启动协程后,主线程中的任务依然可以继续执行,在执行launch{}
时,可以通过 withContext(Dispatchers.IO)
将协程的执行操作移至一个 I/O 子线程,子线程执行完任务,再将结果返回主线程继续执行。
简单示例:
//主线程分派任务
private val scope = CoroutineScope(Job() + Dispatchers.Main)
//管理对应的协程的生命周期
private var job1: Job? = null
fun exec() {
//启动一个协程
job1 = scope.launch {
//子线程执行耗时任务
withContext(Dispatchers.IO){
}
}
//启动一个协程
val job2 = scope.launch {
//启动一个协程
val taskResult1 = async {
//子线程执行耗时任务
withContext(Dispatchers.IO){
}
}
val taskResult2 = async {
//子线程执行耗时任务
withContext(Dispatchers.IO){
}
}
//taskResult1 和 taskResult2 都返回结果才会继续执行
taskResult1.await() + taskResult2.await()
}
}
fun cancelJob() {
//取消 job1 对应的协程
job1?.cancel("cancel job1")
}
fun cancelScope() {
//取消 scope 对应的所有协程
scope.cancel("cancel scope")
}
在上面的例子中:
scope
:定义主线程分派任务的 scope 来跟踪它使用 launch 或 async 创建的所有协程;job1
:管理它对应的协程的生命周期;withContext(Dispatchers.IO)
:切换到子线程执行耗时任务;cancelJob
会取消 job1 对应的协程;cancelScope
会取消 scope 启动的所有协程。了解了 Kotlin 协程的一些基础 概念和 API 后,知道了协程的基本使用。接下来,再了解一下 Kotlin Flow 相关的概念和 API。
Kotlin 中的 Flow API 旨在异步处理按顺序执行的数据流。Flow 本质上是一个 Sequence。我们可以像对 Kotlin 中 Sequence 一样来操作Flow:变换,过滤,映射等。Kotlin Sequences 和 Flow 的主要区别在于 Flow 可以挂起。
如果有理解 Kotlin Sequence,那其实很好理解 Kotlin Flow。刚好,在前面一篇 Kotlin 惰性集合操作-序列 Sequence文章中,有分析 Sequence 的原理,这里也可以把 Flow 按照类似的原理进行理解。
val sequenceResult = intArrayOf(1, 2, 3).asSequence().map { it * it }.toList()
MainScope().launch{
val flowResult = intArrayOf(1, 2, 3).asFlow().map { it * it }.toList(mutableListOf())
}
上面 sequenceResult 和 flowResult 的值都是:[1, 4, 9]
。
在 Sequence 中,如果没有末端操作,中间操作不会被执行。在 Flow 中也是一样,如果数据流没有数据消费collect
,中间操作也不会被执行。
flowOf(bitmap).map { bmp ->
//在子线程中执行耗时操作,存储 bitmap 到本地
saveBitmap(bmp)
}.flowOn(Dispatchers.Default)
上面代码中,map
操作不会被执行。
一个完整的数据流应该包含:数据生产( flowOf
, asFlow
, flow{}
)→ 中间操作(map
, filter
等)→ 数据消费(collect
,asList
,asSet
等)。下面将分别了解相关操作。
数据生产主要是通过数据源构建数据流。可以使用 Builders.kt
中提供的 Flow 相关扩展方法,如:
intArrayOf(1, 2, 3).asFlow().map { it * it }
val downloadUrl = "https://github.com/ReactiveX/RxJava"
flowOf(downloadUrl).map { downloadZip(it) }
(1..10).asFlow().filter { it % 2 == 0 }
通常使用 flowOf
和 asFlow
方法直接构建数据流。它们创建的都是冷流:
冷流:这段 flow 构建器中的代码直到流被收集(collect)的时候才运行。
也可以通过 flow{}
来构建数据流,使用emit
方法将数据源添加到数据流中:
flow<Int> {
emit(1)
withContext(Dispatchers.IO){
emit(2)
}
emit(3)
}.map { it * it }
不管是 flowOf
,asFlow
还是 flow{}
,它们都会实现接口 FlowCollector
:
public fun <T> flow(@BuilderInference block: suspend FlowCollector<T>.() -> Unit): Flow<T> = SafeFlow(block)
internal inline fun <T> unsafeFlow(@BuilderInference crossinline block: suspend FlowCollector<T>.() -> Unit): Flow<T> {
return object : Flow<T> {
override suspend fun collect(collector: FlowCollector<T>) {
collector.block()
}
}
}
接口 FlowCollector
提供的 emit
方法,负责将源数据添加到数据流中:
public fun interface FlowCollector<in T> {
/**
* Collects the value emitted by the upstream.
* This method is not thread-safe and should not be invoked concurrently.
*/
public suspend fun emit(value: T)
}
总结:构建数据流可以使用 Flow 相关扩展方法: flowOf
, asFlow
, flow{}
,它们都是通过接口 FlowCollector
提供的 emit
方法,将源数据添加到数据流中。
中间操作主要修改发送到数据流的值,或修正数据流本身。如 filter
, map
, flatMapConcat
操作等:
intArrayOf(1, 2, 3).asFlow().map { it * it }.collect{ }
(1..100).asFlow().filter { it % 2 == 0 }.collect{ }
val data = hashMapOf<String, List<String>>(
"Java" to arrayListOf<String>("xiaowang", "xiaoli"),
"Kotlin" to arrayListOf<String>("xiaozhang", "xiaozhao")
)
flow<Map<String, List<String>>> {
emit(data)
}.flatMapConcat {
it.values.asFlow()
}.collect{ }
中间操作符有很多,根据使用场景大概可分为:
filter
,映射 map
操作,复杂转换可以使用变换 transform
操作;take
操作,take(2)
表示只获取前两个值;drop
操作,drop(2)
表示丢弃前两个值;flatMapConcat
与 flattenConcat
操作表示顺序收集传入的流操作,flatMapMerge
与flattenMerge
操作表示并发收集所有传入的流,并将它们的值合并到一个单独的流,以便尽快的发射值操作,flatMapLatest
操作表示以展平的方式收集最新的流操作;zip
操作表示组合两个流的值,两个流都有值才进行组合操作,combine
操作表示组合两个流最新的值,每次组合的时候都是使用每个流最新的值;buffer
操作,在数据消费的时候可以缩短时间;conflate
操作,跳过中间值;flowOn
操作前的操作切换到 flowOn 指定的上下文Dispatchers.Default
,Dispatchers.IO
,Dispatchers.Main
,也就是指定前面的操作所执行的线程;上面介绍了主要的操作符的大致使用场景,操作符详细解释可以查看官方文档:异步流。
中间操作符代码示例:
(1..3).asFlow().take(2).collect{
//收集到结果值 1,2
}
(1..3).asFlow().drop(2).collect{
//收集到结果值 3
}
private fun downloadVideo(videoUrl: String): Pair<String, String> {
return Pair(videoUrl, "videoFile")
}
private fun downloadAudio(audioUrl: String): Pair<String, String> {
return Pair(audioUrl, "audioFile")
}
private fun downloadImage(imageUrl: String): Pair<String, String> {
return Pair(imageUrl, "imageFile")
}
MainScope().launch {
val imageDownloadUrls = arrayListOf<String>("image1", "image2")
val audioDownloadUrls = arrayListOf<String>("audio1", "audio2", "audio3")
val videoDownloadUrls = arrayListOf<String>("video1", "video2", "video3", "video4")
val imageFlows = imageDownloadUrls.asFlow().map {
downloadImage(it)
}
val audioFlows = audioDownloadUrls.asFlow().map {
downloadAudio(it)
}
val videoFlows = videoDownloadUrls.asFlow().map {
downloadVideo(it)
}
merge(imageFlows, audioFlows, videoFlows).flowOn(Dispatchers.IO).onEach {
Log.d("TestFlow", "result=$it")
}.collect()
}
控制台输出结果:
TestFlow: result=(image1, imageFile)
TestFlow: result=(image2, imageFile)
TestFlow: result=(audio1, audioFile)
TestFlow: result=(audio2, audioFile)
TestFlow: result=(audio3, audioFile)
TestFlow: result=(video1, videoFile)
TestFlow: result=(video2, videoFile)
TestFlow: result=(video3, videoFile)
TestFlow: result=(video4, videoFile)
merge 操作符将多个流合并到一个流,支持并发。类似 RxJava 的 zip 操作
(1..3).asFlow().onStart {
Log.d("TestFlow", "onStart:${Thread.currentThread()}")
}.flowOn(Dispatchers.Main).map {
Log.d("TestFlow", "map:$it,${Thread.currentThread()}")
if (it % 2 == 0)
throw IllegalArgumentException("fatal args:$it")
it * it
}.catch {
Log.d("TestFlow", "catch:${Thread.currentThread()}")
emit(-1)
}.flowOn(Dispatchers.IO)
.onCompletion { Log.d("TestFlow", "onCompletion:${Thread.currentThread()}") }
.onEach {
Log.d("TestFlow", "onEach:$it,${Thread.currentThread()}")
}.collect()
控制台输出结果:
TestFlow: onStart:Thread[main,5,main]
TestFlow: map:1,Thread[DefaultDispatcher-worker-3,5,main]
TestFlow: map:2,Thread[DefaultDispatcher-worker-3,5,main]
TestFlow: catch:Thread[DefaultDispatcher-worker-3,5,main]
TestFlow: onEach:1,Thread[main,5,main]
TestFlow: onEach:-1,Thread[main,5,main]
TestFlow: onCompletion:Thread[main,5,main]
flowOn 指定 onStart 在主线程中执行(Dispatchers.Main),指定 map 和 catch 在 IO 线程中执行(Dispatchers.IO)
总结:中间操作其实就是数据流的变换操作,与 Sequence 和 RxJava 的变换操作类似。
数据消费就是使用数据流的结果值。末端操作符最常使用 collect
来收集流结果值:
(1..3).asFlow().collect{
//收集到结果值 1,2,3
}
除了 collect
操作符外,还有一些操作符可以获取数据流结果值:
collectLatest
:使用数据流的最新值;toList
或 toSet
等:将数据流结果值转换为集合;first
:获取数据流的第一个结果值;single
:确保流发射单个(single)值;reduce
:累积数据流中的值;fold
:给定一个初始值,再累积数据流中的值。末端操作符代码示例:
(1..3).asFlow().collectLatest {
delay(300)
//只能获取到3
}
//转换为 List 集合 [1,2,3]
val list = (1..3).asFlow().toList()
//转换为 Set 集合 [1,2,3]
val set = (1..3).asFlow().toSet()
val first = (1..3).asFlow().first()
//first 为第一个结果值 1
val single = (1..3).asFlow().single()
//流不是发射的单个值,会抛异常
val reduce = (1..3).asFlow().reduce { a, b ->
a + b
}
//reduce 的值为6=1+2+3
val fold = (1..3).asFlow().fold(10) { a, b ->
a + b
}
//fold 的值为16=10+1+2+3
除了上面这些末端操作符,在末端之前还关联着一些操作符:
onStart
:在数据流结果值收集之前调用;onCompletion
:在数据流结果值收集之后调用;onEmpty
:在数据流完成而不发出任何元素时调用;onEach
:在数据流结果值收集时迭代流的每个值;catch
:在收集数据流结果时,声明式捕获异常。末端关联操作符代码示例:
(1..3).asFlow().onStart {
Log.d("TestFlow", "onStart")
}.map {
if (it % 2 == 0)
throw IllegalArgumentException("fatal args:$it")
it * it
}.catch { emit(-1) }.onCompletion { Log.d("TestFlow", "onCompletion") }.onEach {
Log.d("TestFlow", "onEach:$it")
}.collect()
控制台输出结果:
TestFlow: onStart
TestFlow: onEach:1
TestFlow: onEach:-1
TestFlow: onCompletion
总结:数据流进行数据消费时,可以结合末端操作符输出集合,累积值等,当要监听数据流收集结果值开始或结束,可以使用 onStart
和 onCompletion
,当遇到流抛出异常,可以声明 catch
进行异常处理。
响应式编程,可以理解为一种面向数据流编程的方式,也就是使用数据源构建数据流 → 修改数据流中的值 → 处理数据流结果值,在这个过程中,一系列的事件或操作都是按顺序发生的。在 Java 环境中,RxJava 框架实现了响应式编程,它结合了数据流、观察者模式、线程框架;在 Kotlin 环境中,Kotlin 协程和 Flow 结合在一起实现了响应式编程,其中协程就是线程框架,Flow 就是数据流。不管是 RxJava 还是 Kotlin 协程和 Flow 的实现的响应式编程,它们的目的都是为了:使用优雅,简洁,易阅读,易维护的代码来编写并发编程,处理异步操作事件。另外,Android LifeCycle 和 ViewModel 对 Kotlin 协程和 Flow 进行了扩展支持,这也对异步事件进行生命周期管理更方便。
参考文档:
下一篇将探索 Kotlin Flow 冷流和热流。
新年倒计时啦,又到了各大公司发放新年礼盒的时候了。
如何为辛苦了一年工作的员工们献上最真挚的祝愿,如何让员工在朋友圈晒图的时候感觉倍儿有面,如何让品牌文化在不经意间传播在各大社交圈,那非新年礼盒莫属了!
废话不多说,让我们一起看看各大公司在兔年到来之际都给我们带来了什么样的惊喜吧!
字节今年的礼盒依旧是走实用风,春联、红包必不可少;一封家书一支笔,纸短情长;一份月历、一个颈部按摩仪,守护你的健康,一起走过2023。
京东今年的礼盒可谓是“可可爱爱,没有头脑”。春联、红包雷打不动;携三两好友,坐在露营垫上,吃着零食礼包,捏一捏减压球,惬意的生活这就来啦。
阿里依旧是温情路线——家书,可爱公仔属实是拿捏了,新年撞上了“阿里动物园”,让人眼前一亮。
百度的新年礼盒堪称是全场的最重量级选手,重8.4KG,除了居家必备的空气炸锅外,这份“兔年大吉”大礼包还包含了毛绒小熊、包饺子4件套、吉语祝福筷、蘸料碟、月历、手账本, 是真的把你生活各方面需求全给安排上了。
华为奋斗者新年礼盒让人看了感觉热血澎湃!水杯、象棋、兔子盲盒、新年对联福字、一封信,颜值实用都在线,尤其是兔子盲盒,一下子戳中了心巴。(ps:省份之间礼盒不一样,据说还有WATCH 3手表)
腾讯今年的礼盒依旧延续了简约务实的风格,一看包装就很“腾讯”,里面有春联、红包、福字、笔记本,浓浓的年味。
网易互娱的新年礼盒外形设计主打摩登风,礼盒里有:舒压按摩仪、运动随行保温杯、文房四宝、智能两用跳绳、每日黑巧、每日坚果、新春周边,2023邀你易起奔走于新一章的热爱里。
小红书的“HAO HAO2023”礼盒,这一次可谓是玩“懂”了员工的心思,把新年装进挎包,带着薯队长,裹紧毛毯,拿着新的本子,轻松跨年!
知乎今年是一本日历,一支笔,一本笔记本,特别是知乎日历延续了知乎问答社区的属性,每天一个问答,既涨知识又充满仪式感。
得物新年礼盒第一层是“温暖”,毛毯、帽子和围巾,裹上一层温暖,心也开始有了温度;第二层是新年气氛组,灯笼、红包、春联和福字,年味瞬间拉满;第三层是新年防护套装,做好自己健康的第一责任人!
360的礼盒可以说是塞得满满,火力全开的电火锅、富得牛油火锅底料、同心并进筷子、春联福字红包组合套装,简直不要太懂干饭人。
携程的礼盒浓浓的酷炫风,香薰蜡烛、露营灯&口哨、转运扑克、红包套装,2023加满超级BUFF,开启新年礼遇。
搜狐今年的礼盒可以说得上是最干的了,甜香软糯的玉米、口感细腻的栗子、香甜软糯的薯干、颗粒饱满的小米,可以说听起来就让人流口水啦!(PS:搜狐文化还有礼盒哦)
大疆的新年礼盒是出乎意料的“大”,居家旅行必备,新年之际, 带上行李箱回家过年,你绝对是路上最靓的仔~
OPPO今年的新年礼盒很有新意,颜值担当非网绿小欧和《故宫里的神兽世界》系列盲盒莫属,台历也别具一格,每一页都有科技知识科普,还有很OPPO的笔记本和新年贺卡,这一波给满分!
ps:以上素材均由小编整理,来源于网络,排名无先后顺序。
来源:51CTO技术栈 | mp.weixin.qq.com/s/G6b_NraWT0gxF5CoIVD89Q
收起阅读 »回想小时候,一到冬天就开始期盼着学校快点放寒假,期盼着快点过年。因为过年有放不完的鞭炮与吃不完的糖果,犹记得那时候我的口袋里总是充满着各式各样的糖果。今天就以糖果为主题,实现糖果雨来啦这个互动小游戏。
开始引导页面 | 糖果收集页面 | 收集结束页面 |
---|---|---|
具体实现其实也很简单,主要分为3块内容:
开始引导页面:提供开始按钮来告诉用户如何开始,3秒倒计时动画,让用户做好准备。
糖果收集页面:自动生成糖果并从上往下掉落,用户点击糖果完成收集(糖果消失 & 糖果收集总数加一)。
收集结束页面:告诉用户一共收集了多少糖果,提供再玩一次按钮入口。
如果单单是一个静态页面,提供文字来提醒用户如何开始游戏,会略显单调,所以我加了一些自定义View动画,模拟点击动作,来达到提醒用户作用。
利用三个动画组合在一起同时执行,从达到该效果,分别是:
手指移动去点击动画。
点击后的水波纹动画。
点击后糖果+1
动画。
这里我们以 点击后糖果+1动画 举例。
我们先建一个res/anim/candy_add_anim.xml
文件,如下:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<alpha
android:duration="3000"
android:fromAlpha="0.0"
android:repeatCount="-1"
android:repeatMode="restart"
android:toAlpha="1.0" />
<translate
android:duration="3000"
android:fromYDelta="0%"
android:interpolator="@android:anim/accelerate_interpolator"
android:repeatCount="-1"
android:repeatMode="restart"
android:toYDelta="-10%p" />
<scale
android:duration="3000"
android:fromXScale="0"
android:fromYScale="0"
android:pivotX="50%"
android:pivotY="50%"
android:repeatCount="-1"
android:repeatMode="restart"
android:toXScale="1"
android:toYScale="1" />
</set>
然后在指定的View
中执行该动画,如下:
binding.candyAddOneTv.apply {
val animation = AnimationUtils.loadAnimation(context, R.anim.candy_add_anim)
startAnimation(animation)
}
从效果展示图中也可以看出,糖果的样式是各式各样的且其位置坐标是随机的。
我通过代码动态生成一个大小固定的TextView
,然后通过设置layoutParams.setMargins
来确定其坐标,通过setBackground(drawable)
来设置糖果背景(为了使生成的糖果是各式各样的,所以我找了一些糖果的SVG
图来作为背景),然后加入到View.root
。
具体代码如下:
//随机生成X坐标
val leftMargin = (0..(getScreenWidth() - 140)).random()
TextView(this).apply {
layoutParams = FrameLayout.LayoutParams(140, 140).apply {
setMargins(leftMargin, -140, 0, 0)
}
background = ContextCompat.getDrawable(this@MainActivity, generateRandomCandy())
binding.root.addView(this)
}
并且通过协程delay(250)
,来达到一秒钟生成4颗糖果。
fun generatePointViewOnTime() {
viewModelScope.launch {
for (i in 1..60) {
Log.e(TAG, "generatePointViewOnTime: i = $i")
pointViewLiveData.value = i
if (i % 4 == 0) {
countDownTimeLiveData.postValue(i / 4)
}
delay(250)
}
}
}
介绍完了糖果的生成,接着就是糖果的掉落效果实现。
这里我们同样使用View动画
即可完成,通过translationY(getScreenHeight().toFloat() + 200)
来让糖果从最上方平移出屏幕最下方,同时为其设置加速插值器,达到掉落速度越来越快的效果。
整个平移时间设置为3s,具体代码如下:
private fun startMoving(view: View) {
view.apply {
animate().apply {
interpolator = AccelerateInterpolator()
duration = 3000
translationY(getScreenHeight().toFloat() + 200)
start()
}
}
}
点击糖果,糖果消失,糖果收集总数+1。所以我们只需为其设置点击监听器,在用户点击时,为TextView
设置visibility
以及catchNumber++
即可。
TextView(this).apply {
···略···
setOnClickListener {
this.visibility = View.GONE
Log.e(TAG, "onCreate: tag = ${it.tag}, id = ${it.id}")
catchNumber++
binding.catchNumberTv.text = getString(R.string.catch_number, catchNumber)
doVibratorEffect()
}
}
为了更好的用户体验,为点击设置震动反馈效果。
private fun doVibratorEffect() {
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager =
getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
vibrator.vibrate(30)
}
}
当糖果收集结束后,弹出一个结束弹窗来告诉用户糖果收集情况,这里采用属性动画,让弹窗弹出的效果更加的生动。
private fun showAnimation(view: View) {
view.scaleX = 0F
view.scaleY = 0F
//zoom in 放大;zoom out 缩小;normal 恢复正常
val zoomInHolderX = PropertyValuesHolder.ofFloat("scaleX", 1.05F)
val zoomInHolderY = PropertyValuesHolder.ofFloat("scaleY", 1.05F)
val zoomOutHolderX = PropertyValuesHolder.ofFloat("scaleX", 0.8F)
val zoomOutHolderY = PropertyValuesHolder.ofFloat("scaleY", 0.8F)
val normalHolderX = PropertyValuesHolder.ofFloat("scaleX", 1F)
val normalHolderY = PropertyValuesHolder.ofFloat("scaleY", 1F)
val zoomIn = ObjectAnimator.ofPropertyValuesHolder(
view,
zoomInHolderX,
zoomInHolderY
)
val zoomOut = ObjectAnimator.ofPropertyValuesHolder(
view,
zoomOutHolderX,
zoomOutHolderY
)
zoomOut.duration = 400
val normal = ObjectAnimator.ofPropertyValuesHolder(
view,
normalHolderX,
normalHolderY
)
normal.duration = 500
val animatorSet = AnimatorSet()
animatorSet.playSequentially(zoomIn, zoomOut, normal)
animatorSet.start()
}
如果你对该小游戏有兴趣,想进一步了解一下代码,可以参考Github Candy-Catch,欢迎你给我点个小星星。
相信很多人都有这样的感受,随着年龄的增加,越来越觉得这年味越来越淡了,随之而来对过年的期盼度也是逐年下降。在这里,我愿大家童心未泯,归来仍是少年!
最后,给大家拜个早年,祝大家新春快乐!
其实分享文章的最大目的正是等待着有人指出我的错误,如果你发现哪里有错误,请毫无保留的指出即可,虚心请教。
另外,如果你觉得文章不错,对你有所帮助,请给我点个赞,就当鼓励,谢谢~Peace~!
作者:Jere_Chen
来源:juejin.cn/post/7054194708410531876
在开发中,不论是一个团队一起开发一个项目,还是自己独立开发一个项目。都少不了要和git
打交道。面对不同的开发场景,或许每个团队都有自己的git工作流
。这里,我想分享一下我的团队目前正在使用的基于gitlab
的git工作流
。一起交流一下。
规范化的git流程能降低我们的出错概率,也不会经常遇到git问题,然后去搜一堆git高阶用法。我们的这套git玩法儿,其实只要会基本的git操作就行了,然后规范化操作,基本不会遇到git问题,这样大家就可以将时间用于业务上。最终,希望大家研究git的时候是在感兴趣的时候,而不是遇到问题,紧急去寻找答案的时候
我们的这种git工作流玩儿法呢,主要是分为下面几个分支:
master
分支 最新的稳定代码
vx.x.x
分支 版本分支,x.x.x是此次开发的版本号。
feat-xxx
分支 特性(新的功能)分支
fix-xxx
分支 修复分支
上面的这些分支呢,就是我们在开发中需要经常去创建并使用的分支。下面详细说说每个分支代表的意思。
master
分支代表的是最新的稳定版本的代码,一般是版本分支或者修复分支的代码上线后合并过来的。
feat-xxx
分支表示的是为开发某个版本的某个新功能而创建的分支。
vx.x.x
代表的是版本分支,这个是我们在每个版本开始前,以此次版本号为名从master
创建的分支,比如版本号是 2.0.1
,那么版本分支则为 v2.0.1
。然后等到该版本的各个新功能在feat-xxx
开发完成并冒烟测试通过后,就到gitlab
上提一个mr
合并到该版本分支上。等到各个环境测试通过后,就将版本分支的代码合并到master
上,然后就可以删除本次的版本分支了。
fix-xxx
表示的是修复分支,通常在处理线上问题时,创建一个以缺陷名称命名的分支,在缺陷测试通过后,通过mr
合并到master
分支去
注意:这里有个细节是,在特性分支上开发提交的commit
信息,一般认为是无用信息,会在合并给版本分支的时候给合并到一个commit
(由于我们是使用gitlab
来合并,所以在发起mr
请求时勾选squash
选项就好了),而在提测后不论是修复测试过程中bug,或者是优化功能的commit
则会全部保留,这个目的是一个警示,因为我希望最好的情况是提测即上线,虽然达到这个目标有难度,但是这些留下的commit
信息可以帮助我们复盘
各个分支的作用如上面所描述的那样,接着聊聊我们开发的一些经典场景该怎么做:
我们以本次需要开发一个 1.0.0版本为例,这个其中有两个功能模块,一个是需要添加一个按钮,一个是需要添加一个表格
masterv1.0.0feat-add-buttonfeat-add-form从master切出 v1.0.0从master切出 feat-add-button从master切出 feat-add-button开发完成开发完成在gitlab发起mr到v1.0.0,并合并所有commit在gitlab发起mr到v1.0.0,并合并所有commit提测修复测试bug将修复的 commit cherry pick到 v1.0.0在gitlab上mr到master,并将合并信息改成 v1.0.0masterv1.0.0feat-add-buttonfeat-add-form
通过上面的时序图,可以看到,我们以我们即将开始的版本命名了一个版本分支 v1.0.0
,并且也根据这个版本下面的两个功能创建了两个特性分支 feat-add-button
和feat-add-form
,然后等功能开发完成后再通过gitlab
发起mr
(注意,这里要把合并commit
选项勾选上)合并到 v1.0.0
,那么 v1.0.0
分支的代码就会从dev环境开始流转,直到生产环境。这其中,如果有需要修复或者优化的地方,也是先修改特性分支,然后再cherry pick
到版本分支上面。上线以后删除版本分支以及下面的特性分支。
通过这个流程管理的代码版本非常清晰,这是截取的master的一部分片段
在正常迭代流程还有个场景。那就是在开发过程中,pm突然过来说,因为某种不可抗力,有一个功能需要砍掉。这个时候,如果是代码还没提测,亦或者是功能比较简单,处理起来还不算麻烦。但如果是,你的功能和其他同事的代码已经在测试了,并且也已经修复了一些bug,commit都交叉在一起,特别是那种涉及修改文件还多的需求,这个时候处理起来就很麻烦,不仅要看着别人的代码,还得警惕自己的代码别弄错了。那这个时候,在我们流程里就很简单,直接删除现有的版本分支就好了,再重新将需要上线的特性分支组合在一起就可以了。可以看到,版本分支是由特性分支组合起来的,也就是说,版本分支可以由不同的特性分支随意组合。这样处理起来就比较方便
我们以线上需要修复一个按钮的点击事件为例
masterfix-button-click从master切出 fix-button-click修复问题并测试从gitlab发起mr合并到mastermasterfix-button-click
其实这里的流程跟上面没多大的区别,但是这里需要注意的是,线上问题修复,一个bug一个commit,合并到master的时候不合并commit。而且需要将合并信息修改为本次的版本号。比如本次则为 v1.0.1
这个场景跟正常迭代场景并没啥区别,只是取决于你有多个版本,就创建对应的版本分支就可以了。每个版本分支按照正常迭代流程就可以了。
Q:为什么没有使用dev、test等对应环境的分支,这样也好实现push既部署
A:我们这个流程是放弃了使用这些固定的分支的。有几个原因,
代码提测后从dev到test,甚至再到uat(预发布)环境,如果在不同的环境都有代码的变动,那么为了保持这些分支代码一致的话,就需要将代码同步到各个环境分支,这点儿有些费事儿。而版本分支不存在这个问题,版本分支只有一个,可以对应到各个环境。
方便多版本并行开发。版本分支可以创建多个,并行开发的时候比较方便部署到不同的测试环境。如果版本之间的模块关联性不大,还可以并行测试。
语义化。版本分支可以通过分支名称就知道目前有哪些分支正在开发中。
Q: master分支有变动怎么处理
A: master分支有变动的话,及时的合并到自己的功能分支上,以防和其他成员代码有冲突
以上就是我的分享了,橘生淮南,适合我的未必适合大家,互相交流罢了
作者:雲天
来源:juejin.cn/post/7186946414620966967
b. 打包后登录时请求token有问题。
解决方案:如果打包h5平台出现以上两种情况,可以看下打包时想优化包体积大小是否有开启【摇钱树】具体配置如图:
ps: 不了解该配置的可以看下uniapp的官方文档介绍,附上链接https://uniapp.dcloud.io/collocation/manifest?id=treeshaking
问题原因:如果开启这个配置项,打包后所有uni没用到的方法都不会打包进去,这样就会导致SDK内部 uni去用request请求就拿不到,这样后续token就会有问题,或者识别不到scoket api等报错。
解决方案:升级到4.1.0的uni sdk即可。
问题原因:addEventListener 这个是监听浏览器网络变化的,移动端下不支持,所以提示未定义,但实际上并不会影响其他功能,在后续的版本也修复了下该报错~
集成过程中可能疑惑this.setData应该是小程序中的方法,为什么uni中会有,是因为demo中有对该方法重写通过minxin,具体在main.js文件中体现,如下图:
所以如果参照demo报此错可以看下这块是否有复制过来呢~
目前的录音实现依赖uni.getRecorderManager()方式, 是不支持 H5的 可以参考下这个文章
https://en.uniapp.dcloud.io/api/media/record-manager.html#getrecordermanager
今天问题就分享到这里啦,感谢大家的阅读!
收起阅读 »原本已经基于系统方案适配了暗黑主题,实现了白/黑两套皮肤,以及跟随系统。后来老板研究学习友商时,发现友商 App 有三套皮肤可选,除了常规的亮白和暗黑,还有一套暗蓝色。并且在跟随系统暗黑模式下,用户可选暗黑还是暗蓝。这不,新的需求马上就来了。
其实我们之前两个 App 的换肤方案都是使用 Android-skin-support 来做的,在此基础上再加套皮肤也不是难事。但在新的 App 实现多皮肤时,由于前两个 App 做了这么久都只有两套皮肤,而且新的 App 需要实现跟随系统,为了更好的体验和较少的代码实现,就采用了系统方案进行适配暗黑模式。
以 Android-skin-support 和系统两种方案适配经验来看,系统方案适配改动的代码更少,所花费的时间当然也就更少了。所以在需要新添一套皮肤的时候,也不可能再去切方案了。那么在使用系统方案的情况下,如何再加一套皮肤呢?来,先看源码吧。
以下源码基于 android-31
首先,在代码中获取资源一般通过 Context
对象的一些方法,例如:
// Context.java
@ColorInt
public final int getColor(@ColorRes int id) {
return getResources().getColor(id, getTheme());
}
@Nullable
public final Drawable getDrawable(@DrawableRes int id) {
return getResources().getDrawable(id, getTheme());
}
可以看到 Context
是通过 Resources
对象再去获取的,继续看 Resources
:
// Resources.java
@ColorInt
public int getColor(@ColorRes int id, @Nullable Theme theme) throws NotFoundException {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValue(id, value, true);
if (value.type >= TypedValue.TYPE_FIRST_INT
&& value.type <= TypedValue.TYPE_LAST_INT) {
return value.data;
} else if (value.type != TypedValue.TYPE_STRING) {
throw new NotFoundException("Resource ID #0x" + Integer.toHexString(id) + " type #0x" + Integer.toHexString(value.type) + " is not valid");
}
// 这里调用 ResourcesImpl#loadColorStateList 方法获取颜色
final ColorStateList csl = impl.loadColorStateList(this, value, id, theme);
return csl.getDefaultColor();
} finally {
releaseTempTypedValue(value);
}
}
public Drawable getDrawable(@DrawableRes int id, @Nullable Theme theme)
throws NotFoundException {
return getDrawableForDensity(id, 0, theme);
}
@Nullable
public Drawable getDrawableForDensity(@DrawableRes int id, int density, @Nullable Theme theme) {
final TypedValue value = obtainTempTypedValue();
try {
final ResourcesImpl impl = mResourcesImpl;
impl.getValueForDensity(id, density, value, true);
// 看到这里
return loadDrawable(value, id, density, theme);
} finally {
releaseTempTypedValue(value);
}
}
@NonNull
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
Drawable loadDrawable(@NonNull TypedValue value, int id, int density, @Nullable Theme theme)
throws NotFoundException {
// 这里调用 ResourcesImpl#loadDrawable 方法获取 drawable 资源
return mResourcesImpl.loadDrawable(this, value, id, density, theme);
}
到这里我们知道在代码中获取资源时,是通过 Context
-> Resources
-> ResourcesImpl
调用链实现的。
先看 ResourcesImpl.java
:
/**
* The implementation of Resource access. This class contains the AssetManager and all caches
* associated with it.
*
* {@link Resources} is just a thing wrapper around this class. When a configuration change
* occurs, clients can retain the same {@link Resources} reference because the underlying
* {@link ResourcesImpl} object will be updated or re-created.
*
* @hide
*/
public class ResourcesImpl {
...
}
虽然是 public
的类,但是被 @hide
标记了,意味着想通过继承后重写相关方法这条路行不通了,pass。
再看 Resources.java
,同样是 public
类,但没被 @hide
标记。我们就可以通过继承 Resources
类,然后重写 Resources#getColor
和 Resources#getDrawableForDensity
等方法来改造获取资源的逻辑。
先看相关代码:
// SkinResources.kt
class SkinResources(context: Context, res: Resources) : Resources(res.assets, res.displayMetrics, res.configuration) {
val contextRef: WeakReference<Context> = WeakReference(context)
override fun getDrawableForDensity(id: Int, density: Int, theme: Theme?): Drawable? {
return super.getDrawableForDensity(resetResIdIfNeed(contextRef.get(), id), density, theme)
}
override fun getColor(id: Int, theme: Theme?): Int {
return super.getColor(resetResIdIfNeed(contextRef.get(), id), theme)
}
private fun resetResIdIfNeed(context: Context?, resId: Int): Int {
// 非暗黑蓝无需替换资源 ID
if (context == null || !UIUtil.isNightBlue(context)) return resId
var newResId = resId
val res = context.resources
try {
val resPkg = res.getResourcePackageName(resId)
// 非本包资源无需替换
if (context.packageName != resPkg) return newResId
val resName = res.getResourceEntryName(resId)
val resType = res.getResourceTypeName(resId)
// 获取对应暗蓝皮肤的资源 id
val id = res.getIdentifier("${resName}_blue", resType, resPkg)
if (id != 0) newResId = id
} finally {
return newResId
}
}
}
主要原理与逻辑:
R.java
文件中生成对应的资源 id,而我们正是通过资源 id 来获取对应资源的。Resources
类提供了 getResourcePackageName
/getResourceEntryName
/getResourceTypeName
方法,可通过资源 id 获取对应的资源包名/资源名称/资源类型。Resources
还提供了 getIdentifier
方法来获取对应资源 id。_blue
后缀。Resources#getIdentifier
方法获取对应暗蓝皮肤的资源 id。如果没找到,改方法会返回 0
。现在就可以通过 SkinResources
来获取适配多皮肤的资源了。但是,之前的代码都是通过 Context
直接获取的,如果全部替换成 SkinResources
来获取,那代码改动量就大了。
我们回到前面 Context.java
的源码,可以发现它获取资源时,都是通过 Context#getResources
方法先得到 Resources
对象,再通过其去获取资源的。而 Context#getResources
方法也是可以重写的,这意味着我们可以维护一个自己的 Resources
对象。Application
和 Activity
也都是继承自 Context
的,所以我们在其子类中重写 getResources
方法即可:
// BaseActivity.java/BaseApplication.java
private Resources mSkinResources;
@Override
public Resources getResources() {
if (mSkinResources == null) {
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}
到此,基本逻辑就写完了,马上 build
跑起来。
咦,好像有点不太对劲,有些 color
或 drawable
没有适配成功。
经过一番对比,发现 xml
布局中的资源都没有替换成功。
那么问题在哪呢?还是先从源码着手,先来看看 View
是如何从 xml
中获取并设置 background
属性的:
// View.java
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);
// AttributeSet 是 xml 中所有属性的集合
// TypeArray 则是经过处理过的集合,将原始的 xml 属性值("@color/colorBg")转换为所需的类型,并应用主题和样式
final TypedArray a = context.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.View, defStyleAttr, defStyleRes);
...
Drawable background = null;
...
final int N = a.getIndexCount();
for (int i = 0; i < N; i++) {
int attr = a.getIndex(i);
switch (attr) {
case com.android.internal.R.styleable.View_background:
// TypedArray 提供一些直接获取资源的方法
background = a.getDrawable(attr);
break;
...
}
}
...
if (background != null) {
setBackground(background);
}
...
}
再接着看 TypedArray
是如何获取资源的:
// TypedArray.java
@Nullable
public Drawable getDrawable(@StyleableRes int index) {
return getDrawableForDensity(index, 0);
}
@Nullable
public Drawable getDrawableForDensity(@StyleableRes int index, int density) {
if (mRecycled) {
throw new RuntimeException("Cannot make calls to a recycled instance!");
}
final TypedValue value = mValue;
if (getValueAt(index * STYLE_NUM_ENTRIES, value)) {
if (value.type == TypedValue.TYPE_ATTRIBUTE) {
throw new UnsupportedOperationException(
"Failed to resolve attribute at index " + index + ": " + value);
}
if (density > 0) {
// If the density is overridden, the value in the TypedArray will not reflect this.
// Do a separate lookup of the resourceId with the density override.
mResources.getValueForDensity(value.resourceId, density, value, true);
}
// 看到这里
return mResources.loadDrawable(value, value.resourceId, density, mTheme);
}
return null;
}
TypedArray
是通过 Resources#loadDrawable
方法来加载资源的,而我们之前写 SkinResources
的时候并没有重写该方法,为什么呢?那是因为该方法是被 @UnsupportedAppUsage
标记的。所以,这就是 xml
布局中的资源替换不成功的原因。
这个问题又怎么解决呢?
之前采用 Android-skin-support 方案做换肤时,了解到它的原理,其会替换成自己的实现的 LayoutInflater.Factory2
,并在创建 View 时替换生成对应适配了换肤功能的 View 对象。例如:将 View
替换成 SkinView
,而 SkinView
初始化时再重新处理 background
属性,即可完成换肤。
AppCompat
也是同样的逻辑,通过 AppCompatViewInflater
将普通的 View 替换成带 AppCompat-
前缀的 View。
其实我们只需能操作生成后的 View,并且知道 xml 中写了哪些属性值即可。那么我们完全照搬 AppCompat
这套逻辑即可:
LayoutInflater.Factory2
,并实现 onCreateView
方法。onCreateView
主要是创建 View 的逻辑,而这部分逻辑完全 copy AppCompatViewInflater
类即可。onCreateView
中创建 View 之后,返回 View 之前,实现我们自己的逻辑。LayoutInflaterCompat#setFactory2
方法,设置我们自己的 Factory2。相关代码片段:
public class SkinViewInflater implements LayoutInflater.Factory2 {
@Nullable
@Override
public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
// createView 方法就是 AppCompatViewInflater 中的逻辑
View view = createView(parent, name, context, attrs, false, false, true, false);
onViewCreated(context, view, attrs);
return view;
}
@Nullable
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
return onCreateView(null, name, context, attrs);
}
private void onViewCreated(@NonNull Context context, @Nullable View view, @NonNull AttributeSet attrs) {
if (view == null) return;
resetViewAttrsIfNeed(context, view, attrs);
}
private void resetViewAttrsIfNeed(Context context, View view, AttributeSet attrs) {
if (!UIUtil.isNightBlue(context)) return;
String ANDROID_NAMESPACE = "http://schemas.android.com/apk/res/android";
String BACKGROUND = "background";
// 获取 background 属性值的资源 id,未找到时返回 0
int backgroundId = attrs.getAttributeResourceValue(ANDROID_NAMESPACE, BACKGROUND, 0);
if (backgroundId != 0) {
view.setBackgroundResource(resetResIdIfNeed(context, backgroundId));
}
}
}
// BaseActivity.java
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SkinViewInflater inflater = new SkinViewInflater();
LayoutInflater layoutInflater = LayoutInflater.from(this);
// 生成 View 的逻辑替换成我们自己的
LayoutInflaterCompat.setFactory2(layoutInflater, inflater);
}
至此,这套方案已经可以解决目前的换肤需求了,剩下的就是进行细节适配了。
上面只对 background
属性进行了处理,其他需要进行换肤的属性也是同样的处理逻辑。如果是自定义的控件,可以在初始化时调用 TypedArray#getResourceId
方法先获取资源 id,再通过 context
去获取对应资源,而不是使用 TypedArray#getDrawable
类似方法直接获取资源对象,这样可以确保换肤成功。而第三方控件也可通过 background
属性同样的处理逻辑进行适配。
<shape>
的处理<!-- bg.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background" />
</shape>
上面的 bg.xml
文件内的 color
并不会完成资源替换,根据上面的逻辑,需要新增以下内容:
<!-- bg_blue.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="8dp" />
<solid android:color="@color/background_blue" />
</shape>
如此,资源替换才会成功。
这次对第三款皮肤的适配还是蛮轻松的,主要是有以下基础:
这次适配的主要工作量还是来自 <shape>
的替换。
我知道很多换肤方案都会将皮肤资源制作成皮肤包,但是这个方案没有这么做。一是没有那么多需要替换的资源,二是为了减少相应的工作量。
我新建了一个资源文件夹,与 res
同级,取名 res-blue
。并在 gradle 配置文件中配置它。编译后系统会自动将它们合并,同时也能与常规资源文件隔离开来。
// build.gradle
sourceSets {
main {
java {
srcDir 'src/main/java'
}
res.srcDirs += 'src/main/res'
res.srcDirs += 'src/main/res-blue'
}
}
版本上线后,发现有 android.content.res.Resources$NotFoundException
异常上报,具体异常堆栈信息:
android.content.res.ResourcesImpl.getValue(ResourcesImpl.java:321)
android.content.res.Resources.getInteger(Resources.java:1279)
org.chromium.ui.base.DeviceFormFactor.b(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.n(chromium-TrichromeWebViewGoogle.apk-stable-447211483:1)
N7.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:8)
Gu.onCreateActionMode(chromium-TrichromeWebViewGoogle.apk-stable-447211483:2)
com.android.internal.policy.DecorView$ActionModeCallback2Wrapper.onCreateActionMode(DecorView.java:3255)
com.android.internal.policy.DecorView.startActionMode(DecorView.java:1159)
com.android.internal.policy.DecorView.startActionModeForChild(DecorView.java:1115)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.ViewGroup.startActionModeForChild(ViewGroup.java:1087)
android.view.View.startActionMode(View.java:7716)
org.chromium.content.browser.selection.SelectionPopupControllerImpl.I(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vc0.a(chromium-TrichromeWebViewGoogle.apk-stable-447211483:10)
Vf0.i(chromium-TrichromeWebViewGoogle.apk-stable-447211483:4)
A5.run(chromium-TrichromeWebViewGoogle.apk-stable-447211483:3)
android.os.Handler.handleCallback(Handler.java:938)
android.os.Handler.dispatchMessage(Handler.java:99)
android.os.Looper.loopOnce(Looper.java:233)
android.os.Looper.loop(Looper.java:334)
android.app.ActivityThread.main(ActivityThread.java:8333)
java.lang.reflect.Method.invoke(Native Method)
com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:582)
com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1065)
经查才发现在 WebView 中长按文本弹出操作菜单时,就会引发该异常导致 App 闪退。
这是其他插件化方案也踩过的坑,我们只需在创建 SkinResources
之前将外部 WebView
的资源路径添加进来即可。
@Override
public Resources getResources() {
if (mSkinResources == null) {
WebViewResourceHelper.addChromeResourceIfNeeded(this);
mSkinResources = new SkinResources(this, super.getResources());
}
return mSkinResources;
}
RePlugin/WebViewResourceHelper.java 源码文件
具体问题分析可参考
Fix ResourceNotFoundException in Android 7.0 (or above)
这个方案在原本使用系统方式适配暗黑主题的基础上,通过拦截 Resources
相关获取资源的方法,替换换肤后的资源 id,以达到换肤的效果。针对 XML 布局换肤不成功的问题,复制 AppCompatViewInflater
创建 View 的代码逻辑,并在 View 创建成功后重新设置需要进行换肤的相关 XML 属性。同一皮肤资源使用单独的资源文件夹独立存放,可以与正常资源进行隔离,也避免了制作皮肤包而增加工作量。
目前来说这套方案是改造成本最小,侵入性最小的选择。选择适合自身需求的才是最好的。
刚刚结束的 2022 年,不少应用都给出了自己的 2022 年度报告。趁着这股热潮,我自己维护的应用《译站》 也来凑个热闹,用 Jetpack Compose 写了个报告页面。效果如下:
效果还算不错?如果需要实际体验的,可以前往 这里 下载翻译后打开底部最右侧 tab,即可现场看到。
观察上图,需要完成的有三个难点:
下面将详细介绍
在我的上一篇文章 Jetpack Compose 十几行代码快速模仿即刻点赞数字切换效果 中,我基于 AnimatedContent
实现了 数字增加时自动做动画
的 Text,它的效果如下:
诶,既然如此,那实现这个数字跳动不就简单了吗?我们只需要让数字自动从 0
变成 目标数字
,不就有了动画的效果吗?
此处我选择 Animatable
,并且使用 LauchedEffect
让数字自动开始递增,并把数字格式化为 0013
(长度为目标数字的长度)传入到上次完成的微件中,这样一个自动跳动的动画就做好啦。
代码如下:
@Composable
fun AutoIncreaseAnimatedNumber(
modifier: Modifier = Modifier,
number: Int,
durationMills: Int = 10000,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black,
textWeight: FontWeight = FontWeight.Normal
) {
// 动画,Animatable 相关介绍可以见 https://compose.funnysaltyfish.fun/docs/design/animation/animatable?source=trans
val animatedNumber = remember {
androidx.compose.animation.core.Animatable(0f)
}
// 数字格式化后的长度
val l = remember {
number.toString().length
}
// Composable 进入 Composition 阶段时开启动画
LaunchedEffect(number) {
animatedNumber.animateTo(
targetValue = number.toFloat(),
animationSpec = tween(durationMillis = durationMills)
)
}
NumberChangeAnimatedText(
modifier = modifier,
text = "%0${l}d".format(animatedNumber.value.roundToInt()),
textPadding = textPadding,
textColor = textColor,
textSize = textSize,
textWeight = textWeight
)
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun NumberChangeAnimatedText(
modifier: Modifier = Modifier,
text: String,
textPadding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 12.dp),
textSize: TextUnit = 24.sp,
textColor: Color = Color.Black,
textWeight: FontWeight = FontWeight.Normal,
) {
Row(modifier = modifier) {
text.forEach {
AnimatedContent(
targetState = it,
transitionSpec = {
slideIntoContainer(AnimatedContentScope.SlideDirection.Up) with
fadeOut() + slideOutOfContainer(AnimatedContentScope.SlideDirection.Up)
}
) { char ->
Text(text = char.toString(), modifier = modifier.padding(textPadding), fontSize = textSize, color = textColor, fontWeight = textWeight)
}
}
}
}
这样就完成啦~
实际上,这个标题的难点在于“们”这个字,这意味着不但要完成“向上+淡出”的效果,还要有序,一个一个来。
对于这个问题,因为我的需求很简单:所有微件竖着排列,自上而下逐渐淡出。因此,我选择的解决思路是:自定义布局。(这不一定是唯一的思路,如果你有更好的方法,也欢迎一起探讨)。下面我们慢慢拆解:
这其实是最简单的一步,你可以阅读我曾经写的 深入Jetpack Compose——布局原理与自定义布局(一) 来了解。简单来说,我们只需要依次摆放所有微件,然后把总宽度设为宽度最大值,总高度设为高度之和即可。代码如下:
@Composable
fun AutoFadeInComposableColumn(
modifier: Modifier = Modifier,
content: @Composable FadeInColumnScope.() -> Unit
) {
val measurePolicy = MeasurePolicy { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints.copy(minHeight = 0, minWidth = 0))
}
var y = 0
// 宽度:父组件允许的最大宽度,高度:微件高之和
layout(constraints.maxWidth, placeables.sumOf { it.height }) {
// 依次摆放
placeables.forEachIndexed { index, placeable ->
placeable.placeRelativeWithLayer(0, y){
alpha = 1
}
y += placeable.height
}.also {
// 重置高度
y = 0
}
}
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)
}
上面的例子就是最简单的自定义布局了,它可以实现内部的 Composable 从上到下竖着排列。注意的是,在 place
的时候,我们使用了 placeRelativeWithLayer
,它可以调整组件的 alpha
(还有 rotation
/transform
),这个未来会被用于实现淡出效果。
到了关键的一步了。我们不妨想一想,淡出就是 alpha 从 0->1,y 偏移从 offsetY
-> 0 的过程,因此我们只需要在 place
时控制一下两者的值就行。作为一个动画过程,自然可以使用 Animatable
。现在的问题是:需要几个 Animatable 呢?
自然,你可以选择使用 n 个 Animatable
分别控制 n 个微件,不过考虑到同一时刻其实只有一个 @Composable
在做动画,因此我选择只用一个。因此我们需要增加一些变量:
实话说这两个变量或许可以合成一个,不过既然写成了两个,那就先这样写下去吧。
两个状态可以只放到 Layout
里面,也可以放到专门的 State
中,考虑到外部可能要用到(嘿嘿,其实是真的要用到)两个值,我们单独写一个 State
吧
class AutoFadeInColumnState {
var currentFadeIndex by mutableStateOf(-1)
var finishedFadeIndex by mutableStateOf(0)
companion object {
val Saver = listSaver<AutoFadeInColumnState, Int>(
save = { listOf(it.currentFadeIndex, it.finishedFadeIndex) },
restore = {
AutoFadeInColumnState().apply {
currentFadeIndex = it[0]; finishedFadeIndex = it[1]
}
}
)
}
}
@Composable
fun rememberAutoFadeInColumnState(): AutoFadeInColumnState {
return rememberSaveable(saver = AutoFadeInColumnState.Saver) { AutoFadeInColumnState() }
}
接下来,为我们的自定义 Composable 添加几个参数吧
@Composable
fun AutoFadeInComposableColumn(
modifier: Modifier = Modifier,
state: AutoFadeInColumnState = rememberAutoFadeInColumnState(),
fadeInTime: Int = 1000, // 单个微件动画的时间
fadeOffsetY: Int = 100, // 单个微件动画的偏移量
content: @Composable FadeInColumnScope.() -> Unit
)
接下来就是关键,修改 place
的代码完成动画效果。
// ...
placeables.forEachIndexed { index, placeable ->
// @1 实际的 y,对于动画中的微件减去偏移量,对于未动画的微件不变
val actualY = if (state.currentFadeIndex == index) {
y + (( 1 - fadeInAnimatable.value) * fadeOffsetY).toInt()
} else {
y
}
placeable.placeRelativeWithLayer(0, actualY){
// @2
alpha = if (index == state.currentFadeIndex) fadeInAnimatable.value else
if (index <= state.finishedFadeIndex) 1f else 0f
}
y += placeable.height
}.also {
y = 0
}
相较于之前,代码有两处主要更改。@1
处更改微件的 y
,对于动画中的微件减去偏移量,对于未动画的微件不变,以实现 “位移” 的效果; @2
处则设置 alpha
值实现淡出效果,具体逻辑如下:
接下来,问题在于执行完一个如何执行下一个了。我的思路是这样的:添加一个 LauchedState(state.currentFadeIndex)
使得在 currentFadeIndex
变化时(这表示当前执行动画的微件变了)重新把 Animatable
置0,开启动画效果。动画完成后又把 currentFadeIndex
加一,直至完成所有。代码如下:
@Composable
fun xxx(...){
LaunchedEffect(state.currentFadeIndex){
if (state.currentFadeIndex == -1) {
// 找到第一个需要渐入的元素
state.currentFadeIndex = 0
}
// 开始动画
fadeInAnimatable.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = fadeInTime,
easing = LinearEasing
)
)
// 动画播放完了,更新 finishedFadeIndex
state.finishedFadeIndex = state.currentFadeIndex
// 全部动画完了,退出
if(state.finishedFadeIndex >= whetherFadeIn.size - 1) return@LaunchedEffect
state.currentFadeIndex += 1
fadeInAnimatable.snapTo(0f) // snapTo(0f) 无动画直接置0
}
}
到这里,一个 内部子微件依次淡出
的自定义布局已经基本完成了。下面问题来了:在 Compose 中,我们使用 Spacer
创建间隔,但是往往 Spacer
是不需要动画的。因此我们需要支持一个特性:允许设置某些 Composable 不做动画,也就是直接跳过它们。这种子微件告诉父微件信息的时期,当然要交给 ParentData
来做
要了解 ParentData
,您可以参考我的文章 深入Jetpack Compose——布局原理与自定义布局(四)ParentData,此处不再赘述。
我们添加一个 class FadeInColumnData(val fade: Boolean = true)
和 对应的 Modifier,用于指定某些 Composable 跳过动画。考虑到这个特定的 Modifier
只能用在我们这个布局,因此需要加上 scope
的限制。这些代码如下:
class FadeInColumnData(val fade: Boolean = true) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any =
this@FadeInColumnData
}
interface FadeInColumnScope {
@Stable
fun Modifier.fadeIn(whetherFadeIn: Boolean = true): Modifier
}
object FadeInColumnScopeInstance : FadeInColumnScope {
override fun Modifier.fadeIn(whetherFadeIn: Boolean): Modifier = this.then(FadeInColumnData(whetherFadeIn))
}
有了这个,我们上面的布局也得做相应的更改,具体来说:
whetherFadeIn
记录 ParentData
提供的值0
,而是找到的第一个需要做动画的元素currentFadeIndex
的更新需要找到下一个需要做动画的值具体代码如下:
@Composable
fun AutoFadeInComposableColumn() {
var whetherFadeIn: List<Boolean> = arrayListOf()
// ...
LaunchedEffect(state.currentFadeIndex){
// 等待初始化完成
while (whetherFadeIn.isEmpty()){ delay(50) }
if (state.currentFadeIndex == -1) {
// 找到第一个需要渐入的元素
state.currentFadeIndex = whetherFadeIn.indexOf(true)
}
// 开始动画
// - state.currentFadeIndex = 0
for (i in state.finishedFadeIndex + 1 until whetherFadeIn.size){
if (whetherFadeIn[i]){
state.currentFadeIndex = i
fadeInAnimatable.snapTo(0f)
break
}
}
}
val measurePolicy = MeasurePolicy { measurables, constraints ->
// ...
whetherFadeIn = placeables.map { placeable ->
((placeable.parentData as? FadeInColumnData) ?: FadeInColumnData()).fade
}
// 宽度:父组件允许的最大宽度,高度:微件高之和
layout(constraints.maxWidth, placeables.sumOf { it.height }) {
// ...
}
}
Layout(modifier = modifier, content = { FadeInColumnScopeInstance.content() }, measurePolicy = measurePolicy)
}
完成啦!
事实上,整个布局的大体到目前已经趋于完成,不过目前有点小问题:对于 AutoIncreaseAnimatedNumber
,它的动画执行时机是错误的。你可以想象:尽管数字没有显示出来(alpha 为 0),但实际上它已经被摆放了,因此数字跳动的动画已经开始了。对于这个问题,我的解决方案是为 AutoIncreaseAnimatedNumber
额外添加一个 Boolean 参数 startAnim
,只有该值为 true
时才真正开始执行动画。
那么 startAnim
什么时候为 true 呢?就是 currentFadeIndex == 这个微件的 Index
时,这样就可以手工指定什么时候开始动画了。
代码如下:
@Composable
fun AutoIncreaseAnimatedNumber(
startAnim: Boolean = true,
...
) {
// Composable 进入 Composition 阶段,且 startAnim 为 true 时开启动画
LaunchedEffect(number, startAnim) {
if (startAnim)
animatedNumber.animateTo(
targetValue = number.toFloat(),
animationSpec = tween(durationMillis = durationMills)
)
}
NumberChangeAnimatedText(
...
)
}
实际使用时
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedNumber(number = 110, startAnim = state.currentFadeIndex == 7) // 或者 >=,如果动画时间长于 fadeInTime 的话
ResultText(text = "次")
}
完工!
如你所想,整体的布局是用 Pager
实现的,这个用的是 google/accompanist: A collection of extension libraries for Jetpack Compose 内的实现。鉴于不是本篇重点,此处略过,感兴趣的可以看下面的代码。
完整代码见 FunnyTranslation/AnnualReportScreen.kt at compose。
如果有用,欢迎 Star仓库 / 此处点赞 / 评论 ~
在Fragment中控制View十分简单,只需要声明+findViewById
即可:
class FragmentA : Fragment() {
private lateinit var imageView: ImageView
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
imageView = view.findViewById(R.id.imageView)
}
}
但这样同时也遇到了一个问题:在使用Navigation或者使用replace
并addToBackStack
进行FragmentA切换到FragmentB时,FragmentA会走到onDestroyView
,但不会destory
。FragmentA走到onDestroyView
时,Fragment对根View的引用会置空,由于imageView
被Fragment持有,所以此时imageView
并未被释放,从而导致了内存泄漏。
当页面变的复杂时,变量的声明以及赋值也会变成一个重复的工作。比较成熟的框架如Butter Knife通过@BindView
注解生成代码,以避免手工编写findViewById
代码,同时也提供了Unbinder
用以在onDestoryView
中进行解绑以防止内存泄漏。不过在Butter Knife的官方文档中提到目前Butter Knife已不再维护,推荐使用ViewBinding
作为视图绑定工具:
Attention: This tool is now deprecated. Please switch to view binding. Existing versions will continue to work, obviously, but only critical bug fixes for integration with AGP will be considered. Feature development and general bug fixes have stopped.
在ViewBinding的官方文档中,推荐的写法如下:
class TestFragment : Fragment() {
private var _binding: FragmentTestBinding? = null
// 只能在onCreateView与onDestoryView之间的生命周期里使用
private val binding: FragmentTestBinding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentTestBinding.inflate(inflater, container, false)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
这种方式虽然防止了内存泄漏,但仍然需要手工编写一些重复代码,大部分人甚至可能直接声明lateinit var binding
,从而导致更严重的内存泄漏问题。下面我们将介绍两种解放方案:
如果项目中存在一个BaseFragment
的话,我们完全可以将上面的逻辑放在BaseFragment
中:
open class BaseFragment<T : ViewBinding> : Fragment() {
protected var _binding: T? = null
protected val binding: T get() = _binding!!
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
或者更进一步,将onCreateView
的逻辑也放在父类中:
abstract class BaseFragment<T : ViewBinding> : Fragment() {
private var _binding: T? = null
protected val binding: T get() = _binding!!
abstract val bindingInflater: (LayoutInflater, ViewGroup?, Bundle?) -> T
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = bindingInflater.invoke(inflater, container, savedInstanceState)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
子类使用时:
class TestFragment : BaseFragment<FragmentTestBinding>() {
override val bindingInflater: (LayoutInflater, ViewGroup?, Bundle?) -> FragmentTestBinding
get() = { layoutInflater, viewGroup, _ ->
FragmentTestBinding.inflate(layoutInflater, viewGroup, false)
}
}
不过这种方式由于给基类增加了泛型,所以对于已有项目的侵入性比较高。
借助Kotlin的by
关键字,我们可以将binding
置空的任务交给Frament生命周期进行处理,比较简单的版本如下:
class LifecycleAwareViewBinding<F : Fragment, V : ViewBinding> : ReadWriteProperty<F, V>, LifecycleEventObserver {
private var binding: V? = null
override fun getValue(thisRef: F, property: KProperty<*>): V {
binding?.let {
return it
}
throw IllegalStateException("Can't access ViewBinding before onCreateView and after onDestroyView!")
}
override fun setValue(thisRef: F, property: KProperty<*>, value: V) {
if (thisRef.viewLifecycleOwner.lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Can't set ViewBinding after onDestroyView!")
}
thisRef.viewLifecycleOwner.lifecycle.addObserver(this)
binding = value
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
binding = null
source.lifecycle.removeObserver(this)
}
}
}
在使用时可以直接通过by
关键字,但仍需在onCreateView
中进行赋值:
class TestFragment : Fragment() {
private var binding: FragmentTestBinding by LifecycleAwareViewBinding()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentTestBinding.inflate(inflater, container, false)
return binding.root
}
}
如果想省略onCreateView
中的创建ViewBinding
的重复逻辑,有两种思路,一个是Fragment构造时传入布局Id,通过viewBinding生成的bind
函数创建ViewBinding
;另外一种思路则是通过反射调用ViewBinding
的inflate
方法。两种思路的主要不同就是创建ViewBinding
的方式不一样,而核心代码一样,实现如下:
class LifecycleAwareViewBinding<F : Fragment, V : ViewBinding>(
private val bindingCreator: (F) -> V
) : ReadOnlyProperty<F, V>, LifecycleEventObserver {
private var binding: V? = null
override fun getValue(thisRef: F, property: KProperty<*>): V {
binding?.let {
return it
}
val lifecycle = thisRef.viewLifecycleOwner.lifecycle
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
this.binding = null
throw IllegalStateException("Can't access ViewBinding after onDestroyView")
} else {
lifecycle.addObserver(this)
val viewBinding = bindingCreator.invoke(thisRef)
this.binding = viewBinding
return viewBinding
}
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_DESTROY) {
binding = null
source.lifecycle.removeObserver(this)
}
}
}
然后创建函数返回LifecycleAwareViewBinding
即可:
// 1. 通过bind函数
fun <V : ViewBinding> Fragment.viewBinding(binder: (View) -> V): LifecycleAwareViewBinding<Fragment, V> {
return LifecycleAwareViewBinding { binder.invoke(it.requireView()) }
}
// 使用
class TestFragment : Fragment(R.layout.fragment_test) {
private val binding: FragmentTestBinding by viewBinding(FragmentTestBinding::bind)
}
// 2. 通过反射的方式
inline fun <reified V : ViewBinding> Fragment.viewBinding(): LifecycleAwareViewBinding<Fragment, V> {
val method = V::class.java.getMethod("inflate", LayoutInflater::class.java, ViewGroup::class.java, Boolean::class.java)
return LifecycleAwareViewBinding { method.invoke(null, layoutInflater, null, false) as V }
}
// 使用
class TestFragment : Fragment() {
private val binding: FragmentTestBinding by viewBinding()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return binding.root
}
}
需要注意的是第一种方式使用了Fragment#requireView
方法,所以需要将布局id传给Fragment
的构造方法(将布局id传给Fragment实际上是借助了Fragment
默认的onCreateView
实现,虽然不传布局Id、手动实现也可以,但这样实际上和最上面提到的方法差不多了)。
上面的两种思路GitHub中已经有作者实现了,并且考虑了一些边界case以及优化,感兴趣的可以去看看:ViewBindingPropertyDelegate
对于ViewBinding
为了防止内存泄漏而出现的模板代码,可以将模板代码提取至基类Fragment中或者借助Fragment的viewLifecycleOwner
的生命周期进行自动清理;对于onCreateView
中为了创建ViewBinding
而出现的模板代码,可以借助Fragment#onCreateView
的默认实现以及ViewBinding
生成的bind
函数进行创建,或者通过反射调用ViewBinding
生成的inflate
方法创建ViewBinding
。
到了年末,难免少不了“年终总结”、“反思潮”,互联网的“大佬”们怎能落下?
前段时间,马化腾在 2022 年内部员工大会上,谈及了业务部门改革表面、追求大数字营收、内部贪腐“触目惊心”等问题,引发大家热议。随后,刘强东在京东高管管理培训会议上痛批“拿PPT和假大空词汇忽悠自己的人就是骗子”;蔚来李斌在全员信中表示有八大问题亟待解决。
作为曾经的 BAT 三巨头之一的百度,最近也因为一篇文章也在“反思潮”中引发了大家的关注。
据 36 氪报道,百度在 1 月 3 日内网发布的了一篇文章《简单之约:谈机会谈挑战,新思考新要求》。文章长达 7000 字,取自 2022 年 12 月末,李彦宏面向全体员工的一场内部直播。(“简单之约”,是百度管理层与全体员工沟通公司理念与战略的固定机制,由员工提问,高层答疑,每季度举行一次。)
从文章的整体来看,李彦宏从技术投入、商业本质、公司管理以及百度未来的机会等话题直指了百度目前的现状问题及思考,例如对资本、组织等宏观问题的复盘,以及对 AIGC 的商业化、疫情下的搜索产品、“端到端”战略等具体业务挑战的思考。
在谈及马化腾的内部讲话时,李彦宏表示,马化腾内部讲话提到的问题,百度也都有,并表示一直在试图积极地去解决这些“零容忍”的问题,一旦发现这种事情不会妥协。
此外,李彦宏还鼓励员工站在更高的角度去思考问题。做事情除了满足 OKR 之外,应该跳出这个范围,看看设置的目标对业务的长期成长、长期健康有没有作用。同时,李彦宏坦言了 2023 年的小目标,希望 2023 年至少能有一个高成长、有创新的业务。”
值得一提的是,百度作为一家技术驱动的互联网公司,在文章中多次谈及了有关技术方向的思考。
众所周知,“技术和创新”是李彦宏长期关注的话题,非常重视技术与市场的匹配的重要性。在本次全员会上他也指出,“要做市场真正需要的技术,否则就是自嗨”。此前,百度就在技术方面付出了很多的投入。根据财报显示,近年来百度的技术研发投入占收入的比例超过 20% 。
在文中,李彦宏还提到了许多有关 AI 领域新的技术进展,尤其是近期屡次出圈的 AIGC 和 ChatGPT ,他表示:“技术能做到这一步了,但是它会变成什么样的产品,产品能满足什么样的需求,这个链条上还有很多不确定性。”
早前, AI 领域就备受百度的关注。在 2022 万象·百度移动生态大会上,百度发布了 AI助理,覆盖了各种 AIGC 应用,包括 AI 自动生成文字、图片以及图片转换成视频。李彦宏表示,这是百度“天天在琢磨的技术方向”,将其商业化很难,但百度必须要做。
伴随着#李彦宏内部反思#、#李彦宏称马化腾说的问题百度都有#、#李彦宏称百度让更多人及时看到真相#的话题登上微博热搜榜,也掀起网友一股讨论热潮:
“能深刻剖析自己存在的问题也很有勇气,不过关键要看后续做法”;
“真的是非常敬佩李彦宏”;
“公司越大,就更要处理好内部问题”;
“百度还是有很强的技术氛围和底蕴的 ”。
欲戴王冠,必承其重。在互联网企业在抢占市场的过程中,难免需要迎接更多的挑战。对此,你怎么看?可以在评论区留言或讨论。
参考链接:
https://36kr.com/newsflashes/2075958593507332
https://finance.sina.com.cn/tech/internet/2023-01-06/doc-imxzheuc5666852.shtml
https://www.sohu.com/a/626669960_335395
https://new.qq.com/rain/a/20230106A0313400
作者:朱珂欣 | CSDN 程序人生
来源:blog.csdn.net/csdnsevenn/article/details/128629148
2020年,货拉拉运营部门和客户端开发对齐了https网络通信协议中的SSL网络证书校验方案;但是由于Android客户端的证书配置不规范,导致在客户端内置的SSL网络证书到期前十几天被发现证书校验异常,Android客户端面临全网访问异常的问题
本文主要介绍解决货拉拉Android客户端SSL证书到期的解决方案及Android端SSL证书相关知识
1994年,Netscape公司首先使用了SSL协议,SSL协议全称为:安全套接层协议(Secure Sockets Layer),它指定了在应用程序协议(如HTTP、Telnet、FTP)和TCP/IP之间提供数据安全性分层的机制,它是在传输通信协议(TCP/IP)上实现的一种安全协议,采用公开密钥技术,它为TCP/IP连接提供数据加密、服务器认证、消息完整性以及可选的客户端认证。由于SSL协议很好地解决了互联网明文传输的不安全问题,很快得到了业界的支持,并已经成为国际标准
HyperText Transfer Protocol over Secure Socket Layer。在HTTPS中,使用传输层安全性(TLS)或安全套接字层(SSL)对通信协议进行加密。也就是HTTP+SSL(TLS)=HTTPS
按类型划分,SSL证书包括CA证书、用户证书两种
证书的签发机构(CA)颁发的电子证书,包含根证书和中间证书两种
[i]根证书
属于根证书颁发机构(CA)的公钥证书,是在公开密钥基础建设中,信任链的起点
一般客户端会内置
[ii]中间证书
因为根证书太宝贵了,直接颁发风险太大了。因此,为了保护根证书,CAs通常会颁发所谓的中间证书。CA使用它的私钥对中间证书签名,使它受到信任。然后CA使用中间证书的私钥签署和颁发终端用户SSL证书。这个过程可以执行多次,其中一个中间根对另一个中间根进行签名
用户证书是由CA中间证书签发给用户的证书,包含服务器证书、客户端证书
[i]服务器证书
组成Web服务器的SSL安全功能的唯一的数字标识。 通过CA签发,并为用户提供验证您Web站点身份的手段。
服务器证书包含详细的身份验证信息,如服务器内容附属的组织、颁发证书的组织以及称为公开密钥的唯一的身份验证文件
[ii]客户端证书
在双向https验证中,就必须有客户端证书,生成方式同服务器证书一样;
单向证书则不用生成
SSL证书链是从用户证书、生成用户证书的CA中间证书、生成CA中间证书的CA中间证书...一直到CA根证书;其中根证书只能有一个,但是CA中间证书可以有多个
(1)以baidu的证书为例
(2)证书链
客户端(比如浏览器或者Android手机)验证我们SSL证书的有效性的时候,会一层层的去寻找颁发者的证书,直到自签名的根证书,然后通过相应的公钥再反过来验证下一级的数字签名的正确性
任何数字证书都必须要有根证书做支持,有了根证书的支持才说明这个数字证书是有效的是被信任的
证书的后缀主要有.key、.csr、.crt、.pem等
(1).key文件:密钥文件,SSL证书的私钥就包含在其中
(2).csr文件:这个文件里面包含着证书的公钥和其他一些公司信息,通过请求签名之后就可以直接生出证书
(3).crt文件:该文件中也包含了证书的公钥、签名信息以及根据不同类型证书携带不同的认证信息,如IP等(该文件在有些机构、系统中也可能表现为.cert后缀)
(4).pem文件:该文件相对比较少见,里面包含着证书的私钥以及部分证书信息
SSL用户证书主要分为(1)DV SSL证书 (2)OV SSL证书 (3)EV SSL证书
(1)DV SSL证书(域名验证型):只需验证域名所有权,无需人工验证申请单位真实身份,几分钟就可颁发的SSL证书。价格一般在百元至千元左右,适用于个人或者小型网站
(2)OV SSL证书(企业验证型):需要验证域名所有权以及企业身份信息,证明申请单位是一个合法存在的真实实体,一般在1~5个工作日颁发。价格一般在百元至几千元左右,适用于企业型用户申请
(3)EV SSL证书(扩展验证型):除了需要验证域名所有权以及企业身份信息之外,还需要提交一下扩展型验证,通常CA机构还会进行电话回访,一般在2~7个工作日颁发证书。价格一般在千元至万元左右,适用于在线交易网站、企业型网站
以Chorme上的baidu为例:
第1步
第2步
第3步
SSL/TLS握手(用非对称加密的手段传递密钥,然后用密钥进行对称加密传递数据)
校验流程主要在上述过程的第三步和第六步
第三步:Certificate
Server——>Client 服务端下发公钥证书
第六步:证书合法性校验
Client 对 Server下发的公钥证书进行合法性校验
客户端通过服务器证书 中签发机构信息,获取到中间证书公钥;利用中间证书公钥进行服务器证书的签名验证
a、中间证书公钥解密 服务器签名,得到证书摘要信息;
b、摘要算法计算 服务器证书 摘要信息;
c、然后对比两个摘要信息。
客户端通过中间证书中签发机构信息,客户端本地查找到根证书公钥;利用根证书公钥进行中间证书的签名验证
客户端获取到服务端的公钥:Https请求 TLS握手过程中,服务器公钥会下发到请求的客户端。
客户端用存储在本地的CA机构的公钥,对服务端公钥中对应的摘要信息进行解密,获取到服务端公钥的摘要信息A;
客户端根据对服务端公钥进行摘要计算,得到摘要信息B;
对比摘要信息A与B,相同则证书验证通过
若证书的申请主体出现:私钥丢失、申请证书无效等情况,CA机构需要废弃该证书
(详细策略见《四、Android端证书吊销校验策略》)
校验证书的有效期是否已经过期:主要判断证书中Validity period
字段是否过期(ps:Android系统默认不校验证书有效期,但浏览器和ios系统默认会校验证书有效期)
校验证书域名是否一致:核查
证书域名
*是否与当前的*访问域名
匹配
。
比如:我们请求的域名 http://www.huolala.cn 是否与证书文件
中DNS标签
下所列的域名
相匹配
;
证书吊销列表:是一个单独的文件,该文件包含了 CA机构 已经吊销的证书序列号与吊销日期;
证书中一般会包含一个 URL 地址 CRL Distribution Point,通知使用者去哪里下载对应的 CRL 以校验证书是否吊销。
该吊销方式的优点是不需要频繁更新,但是不能及时吊销证书,这期间可能已经造成了极大损失
证书状态在线查询协议:一个实时查询证书是否吊销的方式。
请求者发送证书的信息并请求查询,服务器返回正常、吊销或未知中的任何一个状态。
证书中一般也会包含一个 OCSP 的 URL 地址,要求查询服务器具有良好的性能。
部分 CA 或大部分的自签 CA (根证书)都是未提供 CRL 或 OCSP 地址的,对于吊销证书会是一件非常麻烦的事情
核心实现类是CertBlocklistImpl(维护了本地黑名单列表),部分源码逻辑如下:
第1步循环校验信任证书
第2步检查该证书是否在黑名单列表里面
黑名单校验逻辑:主要检查是否在黑名单列表里面
黑名单本地存储位置
可以看到黑名单文件储存在环境变量“ANDROID_DATA”/misc/keychain/pubkey_blacklist.txt;
可以通过adb shell--export--echo $ANDROID_DATA,拿到环境变量位置,一般在/data目录下
核心类在TrustManagerFactory、CertPathTrustManagerParameters、PKIXRevocationChecker
有两种init方式
[i]init(KeyStore ks) 默认使用
传递私钥,一般传递系统默认或者传空
以okhttp为例(默认传空)
[ii]init(ManagerFactoryParameters spec) 自定义方式
下面介绍下通过自定义方式来实现OCSP方式校验证书是否吊销
init方法传入基于CertPath的TrustManager
CertPathTrustManagerParameters,包装策略PKIXRevocationChecker
默认使用OCSP方式校验,可以自定义使用OCSP策略还是CLR策略
参考谷歌开发者文档:developers.google.cn/j2objc/java…
主要有四种校验方式:
客户端单向认证服务端---证书锁定
客户端单向认证服务端---公钥锁定
客户端服务端双向认证
客户端信任所有证书
校验服务端证书的subject信息和publickey信息是否与客户端内置证书一致,如果不一致会报错:
“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”
[i]network-security-config配置方式
(生效范围:app全局,包含webview请求)
(只支持android7.0及以上)
[ii]代码配置方式(生效范围:配置了该SSLParams的实例)
校验了subject信息和publickey信息,防信息篡改的安全等级高一点
[i]因为一般网络证书的有效期是1-2年,所以面临过期之后可能校验异常的问题(ps:本次货拉拉客户端遇到的就是这种内置的网络证书快到期的case)
[ii]内置在app里面,证书容易泄漏
校验服务端证书的公钥信息是否与客户端内置证书的一致
[i]network-security-config配置方式
(生效范围:app全局,包含webview请求)
(只支持android7.0及以上)
[ii]代码配置方式(生效范围:配置了该参数的实例)
只要服务端的公钥保持不变,更换证书也能通过校验
只校验了公钥,防信息篡改的安全等级低一点
自定义的SSLSocketFactory实现客户端和服务端双向认证
public class SSLHelper {
/** * 存储客户端自己的密钥 */ private final static String CLIENT_PRI_KEY = "client.bks";
/** * 存储服务器的公钥 */ private final static String TRUSTSTORE_PUB_KEY = "publickey.bks";
/** * 读取密码 */ private final static String CLIENT_BKS_PASSWORD = "123321";
/** * 读取密码 */ private final static String PUCBLICKEY_BKS_PASSWORD = "123321";
private final static String KEYSTORE_TYPE = "BKS";
private final static String PROTOCOL_TYPE = "TLS";
private final static String CERTIFICATE_STANDARD = "X509";
public static SSLSocketFactory getSSLCertifcation(Context context) {
SSLSocketFactory sslSocketFactory = null;
try {
// 服务器端需要验证的客户端证书,其实就是客户端的keystore
KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE);
// 客户端信任的服务器端证书
KeyStore trustStore = KeyStore.getInstance(KEYSTORE_TYPE);
//读取证书
InputStream ksIn = context.getAssets().open(CLIENT_PRI_KEY);
InputStream tsIn = context.getAssets().open(TRUSTSTORE_PUB_KEY);
//加载证书
keyStore.load(ksIn, CLIENT_BKS_PASSWORD.toCharArray());
trustStore.load(tsIn, PUCBLICKEY_BKS_PASSWORD.toCharArray());
//关闭流
ksIn.close();
tsIn.close();
//初始化SSLContext
SSLContext sslContext = SSLContext.getInstance(PROTOCOL_TYPE);
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(CERTIFICATE_STANDARD);
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(CERTIFICATE_STANDARD);
trustManagerFactory.init(trustStore);
keyManagerFactory.init(keyStore, CLIENT_BKS_PASSWORD.toCharArray());
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
sslSocketFactory = sslContext.getSocketFactory();
} catch (KeyStoreException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (CertificateException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (UnrecoverableKeyException e) {
e.printStackTrace();
} catch (KeyManagementException e) {
e.printStackTrace();
}
return sslSocketFactory;
}
}
双向校验更安全
需要服务端支持,TLS/SSL握手耗时增长
不检验任何证书,下面列两种常见的实现方式
背景:由于证书校验相关源码不在Android.jar中,为了方便调试证书校验的流程,这里简单介绍一种非android.jar包中的Android源码调试的方式
android官方提供了各个模块的git仓库地址
我们只需要conscrypt部分的源码:android.googlesource.com/platform/ex…
注意点:选择的分支要和被调试的手机版本一致(因为不同系统版本下源码有点区别)
如果测试及时Android10.0系统,我们可以选择android10-release分支
新建一个module 把刚才的系统源码复制进来,不需要依赖,只需要在setting.gradle中include,这样做隔离性好,方便移除
导入源码之后,可能会有部分编译问题,可以解决的可以先解决,如果解决不了可以先注释;
需要注意点:
(1)不能修改行号,否则调试的时候走不到
(2)不能新增代码,新增的代码不会执行
打好断点就可以发车了
可以看到app发起网络请求之后会走到TrustManagerImpl里面的checkServerTrusted校验服务端证书
验证证书合法性,判断是否由合法的CA签发,由上面的Android系统根证书库来判断
判断服务端证书是否为特定域名签发,验证网站身份,这里如果出错就会抛出
SSLPeerUnverifiedException
的异常
Android会内置常用的根证书,系统根证书存放在/system/etc/security/cacerts 目录下,文件均为 PEM 格式的 X509 证书格式,包含明文base64编码公钥,证书信息,哈希等
Android系统的根证书管理类
位于/frameworks/base/core/java/android/security/net/config
目录下
以下是根证书管理类的类关系图
CertificateSource
接口类,定义了对根证书可执行的获取和查询操作
有三个实现类,分别是KeyStoreCertificateSource、ResourceCertificateSource、DirectoryCertificateSource
从 KeyStore 中获取证书
基于 ResourceId 从资源目录读取文件并构造证书
遍历指定的目录 mDir 读取证书;还提供了一个抽象方法 isCertMarkedAsRemoved()
用于判断证书是否被移除
SystemCertificateSource
和 UserCertificateSource
继承了DirectoryCertificateSource并且分别定义了系统和用户根证书库的路径,并实现抽象方法
[i]SystemCertificateSource
定义了系统证书查询路径,并且还指定了被移除的证书文件的目录
判断证书是否移除就是直接判断证书文件是否存在于指定的目录
[ii]UserCertificateSource
定义了用户证书指定查询路径,证书是否移除永远为false
(以证书锁定方式的单向校验服务端证书为例)
核心类TrustManagerImpl、TrustedCertificateIndex、X500Principal
(1)第一步checkServerTrusted()
(2)第二步checkTrusted()
(3)第三步TrustedCertificateIndex类匹配证书issuer和signature信息
private final Map<X500Principal, List> subjectToTrustAnchors
= new HashMap<X500Principal, List>();
可以看到获取TrustAnchor是通过HashMap的key X500Principal匹配获取的,
(4)X500Principal
private transient X500Name thisX500Name;
查看X500Principal的源码可以看到它覆写了equals()方法,对比的是属性中的thisX500Name
调试下来发现我们客户端证书的 thisX500Name 的值为
“CN=*. huolala.cn , OU=IT, O=深圳货拉拉科技有限公司, L=深圳市, ST=广东省, C=CN”
(ps:后面会提到,货拉拉客户端证书异常主要因为新证书缺少了OU字段)
(5)subject和issue信息
2020年7月份的时候,货拉拉出现了因为网络证书过期导致的异常,所以运维的同事拉了客户端的同事一起对齐了方案,使用上述《客户端单向认证服务端---公钥锁定》的方式
由于历史原因:
货拉拉用户端使用了上述(三、1(2)客户端单向认证服务端---证书锁定,代码配置方式)
货拉拉司机端使用了上述(三、1(1)客户端单向认证服务端---证书锁定,network-security-config配置方式)
2021年7月份的时候,运维同事更新了服务端的证书,因为更换过程中没有出现异常,所以运维的同事以为android端都是按照之前约定的《客户端单向认证服务端---公钥锁定》方式
(但实际原因是用户和司机端提前内置了2022-8-19过期的证书)
2022-8-1的时候,运维同事开始操作更新服务端2023年的证书,在更新了H5部分域名的证书之后,司机Android端出现部分网页白屏的问题
排查之后发现服务端更新了证书导致客户端证书校验证书非法导致异常
2022-8-2的时候开始排查用户端的逻辑,发现是《客户端单向认证服务端---证书锁定,代码配置方式》,测试之后发现
(1)删除app内置2022年的证书,只保留2020年的证书之后,native请求异常,无法进入app
(2)手动调整手机设备时间,发现native请求正常,webview白屏和图片加载失败
意味着在服务端更换的证书2022-8-19到期之后,客户端将面临全网访问异常的问题
测试的时候发现,android端在证书过期时仍然可以访问服务端(客户端和服务端都保持一致的2022年的证书);
所以想的第1个解决方案是服务端仍然使用2022-8-19的证书,直到大部分用户升级上来之后再更换新证书;
但是ios和web发现如果服务端使用过期证书的情况,系统底层会拦截这个过期证书直接报错;
所以无法兼容所有客户端
在查看源码TrustManagerImpl类源码的时候发现,TrustManagerImpl的服务端检验只是校验了publickey(公钥),所以如果2022年的旧证书和2023年的新证书如果公钥一致的话,可能可以校验通过;
所以想的第2个解决方案是服务端使用的新证书保持和2022-8-19的证书的公钥一致就可以;
但是测试的时候发现native请求还是会报错
“java.security.cert.CertPathValidatorException: Trust anchor for certification path not found”
开发发现按照证书链的校验过程,如下:
如果有中间证书,那么这个中间证书机构颁发的任何服务器证书都可以都校验通过;
所以想出的第3个解决方案是服务器证书内置中间证书组成证书链;
但是排查之后发现服务器证书和客户端内置的证书里面都已经包含了中间证书,所以依然行不通
(ps:如果客户端内置的证书里面删除用户证书信息,只保留中间证书信息,那么只要是这家中间证书颁发的所有的服务器证书都是可以校验通过的,而且一般中间证书的有效期是10年,这也可以作为一个备选项,不过缺点是不安全)
(1)测试同学在网上找到一篇《那些年踩过HTTPS的坑(二)——APP证书链mp.weixin.qq.com/s/yv_XcMLvr…
所以想到的解决方案是重新申请一个带OU字段的新服务器证书
(2)但是运维同事咨询了两家之前的中间商之后对方的回复都是新的证书已经不再提供OU字段,理由是
(3)最后历经一言难尽的各种插曲最后找UniTrust颁发了带OU字段的新证书
(ps:还在使用证书锁定方式校验的可以留意下证书里面的OU字段,后续证书都不会再提供)
按照安全等级划分,从高到低依次为:
(1)客户端和服务端双向认证,参考上述《五、Android端证书校验方式-3、客户端和服务端双向认证》
(2)客户端单向认证服务端---证书锁定,参考上述《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》
(3)客户端单向认证服务端---公钥锁定,参考上述《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》
可以根据各自的安全需求选择合适的认证方式
具体方式参考《五、Android端证书校验方式-1、客户端单向认证服务端---证书锁定》;
为了增强安全性,app可以内置加密后的证书,将解密信息存放在加固后的c++端,增强安全性
具体方式参考《五、Android端证书校验方式-2、客户端单向认证服务端---公钥锁定》;
为了增强安全性,app可以内置加密后的公钥,将解密信息存放在加固后的c++端,增强安全性
为了在出现异常情况时不影响app访问,可以添加动态配置和动态降级能力
动态下发公钥和证书信息,需要留意下发的时机要尽量早一点,避免证书异常时走不到下发的请求
动态降级证书校验功能,在客户端证书或者服务端证书出现异常时,支持动态关闭所有的证书校验的功能
最后,总结一下整体的思路:
1、SSL证书分为CA证书和用户证书
2、客户端SSL证书校验是在网络连接的SSL/TLS握手环节进行校验
3、SSL证书的认证方式分为(1)单向认证(2)双向认证
4、SSL证书的校验方式分为(1)证书校验(2)公钥校验
5、SSL证书的校验流程主要是校验证书是否是由受信任的CA机构签发的合法证书
6、SSL证书的吊销校验策略分为(1)CRL本地校验证书吊销列表(2)OCSP证书状态在线查询
7、纵观本次踩坑之旅,也暴露出一个比较深刻的问题:大部分的客户端开发的认知还是停留在app上层,缺少对底层技术的认识和探索,导致一个很小的配置问题差点酿成大的事故;这也为想在客户端领域进一步提升提供了一个思路:多学习客户端的底层技术,包含网络底层实现、安全、系统底层源码等等
8、最后,解决技术类问题最核心的点还是学习和熟悉源代码;解决证书配置问题的过程中,走了不少弯路,本质上是最开始没有彻底掌握证书校验相关的系统源代码的逻辑,客观上是由于缺少非android.jar源码的调试手段导致阅读源码遗漏了部分校验逻辑,所以本次特意补上(六、Android端一种源码调试的方式),希望后续遇到系统级的疑难杂症可以用的上
参考:
http://www.cnblogs.com/xiaxveliang…
作者:货拉拉技术
来源:https://juejin.cn/post/7186837003026038843
Android 中使用 Kotlin 枚举 + when、java 枚举时,源代码编译后会产生额外的产物,进而带来一些额外开销,本文讲述了 Android 对枚举使用的优化的讲解和解决办法。
Google 文章 Kotlin Vocabulary | 枚举和 R8 编译器
: zhuanlan.zhihu.com/p/138420650…
eg: 使用 enum 定义枚举类 ClazzEnum.
public enum ClazzEnum {
ONE, TWO
}
enum 标识符声明的枚举类 ClazzEnum 默认继承自 java.lang.Enum
, 每个枚举类成员默认都是 public static final
修饰,每个枚举常量都相当于是一个 ClazzEnum 对象,而 Enum 默认实现已经声明了一些枚举属性,所以枚举通常会比静态常量多两倍以上的内存占用,所以在过去 Android 中不推荐使用枚举。
启用 R8 编译优化;
使用静态常量或TypeDef注解替换枚举;
R8 编译优化枚举,解决枚举造成的额外开销;
Android Studio 3.4.0+
以后,在 build.gradle 编译配置中通过 minifyEnabled=true
开启 R8 编译优化,R8 会直接调用枚举的序数值(ordinal
),在编译的时候将琐碎的枚举优化为整型,避免枚举造成的额外开销。
为了更好的理解 R8 对枚举的优化,我们简单了解下kotlin/java 代码的编译流程。
在 Android 应用中,kotlin/java 代码的编译流程:
kotlin/javac 编译器编译源代码文件为 java 字节码:
kotlin/javac 编译器会将代码转换为 java 字节码,Android 设备并不直接运行 Java 字节码,而是运行名为 DEX 的 Dalvik 可执行文件;
D8 编译器将 java字节码转为 DEX 代码;
R8 (可选项,推荐 release 使用)优化:
R8 在 build.gradle 中通将 minifyEnabled 设为 true 来开启,它将在所有其他编译工作后执行,来保证您获得的是一个缩减和优化过的应用。
在 Kotlin 中使用枚举时,也仅仅是将其转换为 Java 编程语言中的枚举而已,本身并不包含任何隐藏开销。但当 枚举+when
配合使用时,就会引入额外的开销。
我们举个例子:
package enums
fun main() {
val age: Int = getAge(People.CHILD);
println("ret: ${age}")
}
fun getAge(p: People): Int {
return when (p) {
People.ADULT -> 30
People.CHILD -> 18
}
}
enum class People {
ADULT,
CHILD
}
查看上述代码编译后的字节码:
# 查看字节码
# 方式一:IDEA(可能有些地方编译失败)
IDEA/AndroidStudio -> Tools -> Kotlin -> Show Kotlin Bytecode -> Decompile
# 方式二:kotlinc + JD-GUI
$ kotlinc test.kt -include-runtime -d ret.jar
// 编译后的字节码
public final class TestKt$WhenMappings {
public static final int[] $EnumSwitchMapping$0 = new int[People.values().length];
static {
$EnumSwitchMapping$0[People.ADULT.ordinal()] = 1;
$EnumSwitchMapping$0[People.CHILD.ordinal()] = 2;
}
}
@Metadata(...)
public final class TestKt {
public static final void main() {
int age = getAge(People.CHILD);
String str = Intrinsics.stringPlus("ret: ", Integer.valueOf(age));
boolean bool = false;
System.out.println(str);
}
public static final int getAge(@NotNull People p) {
Intrinsics.checkNotNullParameter(p, "p");
People people = p;
int i = WhenMappings.$EnumSwitchMapping$0[people.ordinal()];
switch (i) {
case 1:
case 2:
}
throw new NoWhenBranchMatchedException();
}
}
在上述编译后的代码中可以发现,当使用 when 语句接受枚举作为参数时,编译后 when 转换成的 switch 并没有让 switch 语句直接接受枚举,而是接受了 p 枚举对应 ordinal
作为索引对应 TestKt$WhenMappings
数组中的元素值作为参数。
可以发现使用 when 语句时,编译后产物中会生成 TestKt$WhenMappings
类,这个类里面有一个存储映射信息的数组 $EnumSwitchMapping$0
,接下来则是一些执行映射操作的静态代码。
示例中是只有一个 when 语句时的情况,如果我们写了更多的 when 语句,那么每个 when 语句都会在 TestKt$WhenMappings
类中生成一个对应的数组,即使这些 when 语句都在使用同一个枚举也一样。所以这就意味着,在您不知情的时候,会生成一个类,而且其中还包含了一些数组,这些都会让类加载和实例化消耗更多的时间。
Kotlin 中枚举可以用 Sealed Class 密封类替代;
启用 Android R8 编译会自动优化,避免生成类和映射数组,而且只会创建了您所需的最佳代码;
// 启用 R8 编译优化后,会直接把 when 转为 switch, 并接收 Enum#ordinal 作为参数;
public static final int getAge(@NotNull People p) {
switch (p.ordinal()) {
case 0:
// ...
}
}
作者:呛呛cei
来源:juejin.cn/post/7070074670036287496
object
关键字有三种不同的语义:匿名内部类、伴生对象、单例模式。因为 Kotlin 的设计者认为,这三种语义本质上都是在定义一个类的同时还创建了对象。在这样的情况下,与其分别定义三种不同的关键字,还不如将它们统一成 object
关键字。
Android中用java写View的点击事件:
findViewById(R.id.tv).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//do something
}
});
在 Kotlin 当中,我们会使用 object 关键字来创建匿名内部类。同样,在它的内部,我们也必须要实现它内部未实现的方法。这种方式不仅可以用于创建接口的匿名内部类,也可以创建抽象类的匿名内部类:
findViewById<TextView>(R.id.tv).setOnClickListener(object : View.OnClickListener {
override fun onClick(v: View?) {
//do something
}
})
//上面的代码可以用SAM转换简化,IDE会提示
Java 和 Kotlin 相同的地方就在于,它们的接口与抽象类,都不能直接创建实例。想要创建接口和抽象类的实例,我们必须通过匿名内部类的方式。
在 Kotlin 中,匿名内部类还有一个特殊之处,就是我们在使用 object 定义匿名内部类的时候,其实还可以在继承一个抽象类的同时,来实现多个接口:
//抽象类和抽象方法
abstract class Person{
abstract fun isAdult()
}
//接口
interface AListener {
fun getA()
}
//接口
interface BListener {
fun getB()
}
//继承一个抽象类的同时,来实现多个接口
private val item = object :Person(),AListener,BListener{
override fun isAdult() {
//do something
}
override fun getA() {
//do something
}
override fun getB() {
//do something
}
}
在日常的开发工作当中,我们有时会遇到这种情况:我们需要继承某个类,同时还要实现某些接口,为了达到这个目的,我们不得不定义一个内部类,然后给它取个名字。但这样的类,往往只会被用一次就再也没有其他作用了。所以针对这种情况,使用 object 的这种语法就正好合适。我们既不用再定义内部类,也不用想着该怎么给这个类取名字,因为用过一次后就不用再管了。
引申:可以把函数当做参数简化定义接口的操作。以前写java时应该都写过很多如下的接口回调:
class DownloadFile {
//携带token下载文件
fun downloadFile(token:String) {
val filePath = ""
listener?.onSuccess(filePath)
}
//定义成员变量
private var listener: OnDownloadResultListener? = null
//写set方法
fun setOnDownloadResultListener(listener: OnDownloadResultListener){
this.listener = listener
}
//定义接口
interface OnDownloadResultListener {
fun onSuccess(filePath:String)
}
}
通过函数当做参数就不需要定义接口了:
class DownloadFile {
private var onSuccess: ((String?) -> Unit)? = null
fun downloadFile(token:String) {
val filePath = ""
onSuccess?.invoke(filePath)
}
fun setOnDownloadResultListener(method:((String?) -> Unit)? = null){
this.onSuccess = method
}
}
//调用
DownloadFile().downloadFile("")
DownloadFile().setOnDownloadResultListener { filePath ->
print("$filePath")
}
在 Kotlin 当中,要实现单例模式其实非常简单,我们直接用 object 修饰类即可:
object StringUtils {
fun getLength(text: String?): Int = text?.length ?: 0
}
//反编译
public final class StringUtils {
@NotNull
public static final StringUtils INSTANCE; //静态单例对象
public final int getLength(@Nullable String text) {
return text != null ? text.length() : 0;
}
private StringUtils() {
}
static { //静态代码块
StringUtils var0 = new StringUtils();
INSTANCE = var0;
}
}
这种方式定义的单例模式,虽然简洁,但存在两个缺点:
1、不支持懒加载。
2、不支持传参构造单例。写构造方法会报错,会提示object修饰的类不允许有构造方法。
Kotlin 当中没有 static 关键字,所以我们没有办法直接定义静态方法和静态变量。不过,Kotlin 还是为我们提供了伴生对象,来帮助实现静态方法和变量。
我们先来看看 object 定义单例的一种特殊情况,看看它是如何演变成“伴生对象”的:
class User() {
object InnerClass {
fun foo() {}
}
}
用object修饰嵌套类,看下反编译的结果:
public final class User {
//object修饰的内部类为静态内部类
public static final class Inner {
@NotNull
public static final User.Inner INSTANCE; //静态单例对象
public final void foo() {
}
private Inner() {
}
//通过static静态代码块创建了单例对象
static {
User.Inner var0 = new User.Inner();
INSTANCE = var0;
}
}
}
调用的时候的代码
User.InnerClass.foo()
可以看到foo
方法并不是静态方法,那加上@JvmStatic
这个注解试试:
class User() {
object InnerClass {
@JvmStatic
fun foo() {}
}
}
//反编译结果
public final class User {
public static final class InnerClass {
@NotNull
public static final User.InnerClass INSTANCE;
@JvmStatic
public static final void foo() { //foo方法变成了静态方法
}
private InnerClass() {
}
static {
User.InnerClass var0 = new User.InnerClass();
INSTANCE = var0;
}
}
}
foo
方法变成了一个静态方法,但是在使用的时候还是要User.InnerClass.foo()
,而User类中的静态方法应该是直接User.foo()
调用才对,这还是不符合定义静态方法的初衷。那在 Kotlin 如何实现这样的静态方法呢?我们只需要在前面例子当中的 object 关键字前面,加一个 companion 关键字即可。
①不加@JvmStatic注解
//假如不加@JvmStatic注解
class User() {
companion object InnerClass {
fun foo() {}
}
}
//反编译
public final class User {
@NotNull
public static final User.InnerClass InnerClass = new User.InnerClass((DefaultConstructorMarker)null);
public static final class InnerClass {
public final void foo() {
}
private InnerClass() {
}
// $FF: synthetic method
public InnerClass(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
//调用
User.foo()
//反编译调用的代码
User.InnerClass.foo();
如果不加上@JvmStatic
注解调用的时候只是省略了前面的单例对象InnerClass
,foo
仍然不是User
的静态方法。
②加@JvmStatic注解
//假如加@JvmStatic注解
class User() {
companion object InnerClass {
@JvmStatic
fun foo() {}
}
}
//反编译
public final class User {
@NotNull
public static final User.InnerClass InnerClass = new User.InnerClass((DefaultConstructorMarker)null);
@JvmStatic
public static final void foo() { //多生成了一个foo方法,但其实还是调用的下面的foo方法
InnerClass.foo();
}
public static final class InnerClass {
@JvmStatic
public final void foo() { //实际的foo方法
}
private InnerClass() {
}
// $FF: synthetic method
public InnerClass(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
可以看到这个时候多生成了一个静态的foo
方法,可以通过User.foo()
真正去调用了,而不是省略掉了InnerClass
单例对象(把InnerClass
对象放在了静态方法的实现中)。
那又有问题来了,上面二种方式应该如何选择,哪种情况下哪个好,什么时候该加注解什么时候不该加注解?
解析:1、用companion
修饰的对象会创建一个Companion
的实例:
class User {
companion object {
fun foo() {}
}
}
//反编译
public final class User {
@NotNull
public static final User.Companion Companion = new User.Companion((DefaultConstructorMarker)null);
public static final class Companion {
public final void foo() {
}
private Companion() {
}
// $FF: synthetic method
public Companion(DefaultConstructorMarker $constructor_marker) {
this();
}
}
}
//java中调用
User.Companion.foo();
如果不加@JvmStatic
,java调用kotlin代码会多创建这个Companion
实例,会多一部分内存开销,所以如果这个静态方法java需要调用,那务必要把@JvmStatic
加上。
2、多创建一个静态foo
方法会不会多内存开销? 答案是不会,因为这个静态的foo
方法调用的也是Companion
中的方法foo
方法,所以不会有多的内存开销。
所谓的工厂模式,就是指当我们想要统一管理一个类的创建时,我们可以将这个类的构造函数声明成 private,然后用工厂模式来暴露一个统一的方法,以供外部使用。Kotlin 的伴生对象非常符合这样的使用场景:
// 私有的构造函数,外部无法调用
class User private constructor(name: String) {
companion object {
@JvmStatic
fun create(name: String): User? {
// 统一检查,比如敏感词过滤
return User(name)
}
}
}
class MainActivity : AppCompatActivity() {
//借助懒加载委托实现单例
private val people by lazy { People("张三", 18) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
//反编译后
public final class MainActivity extends AppCompatActivity {
private final Lazy people$delegate;
private final People getPeople() {
Lazy var1 = this.people$delegate;
Object var3 = null;
return (People)var1.getValue();
}
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.setContentView(1300000);
}
public MainActivity() { //构造方法
this.people$delegate = LazyKt.lazy((Function0)null.INSTANCE); //lazy方法有线程安全的实现
}
}
在MainActivity
的构造方法中通过LazyKt.lazy
获取类的代理对象,看下LazyKt.lazy
的源码实现:
/**
* Creates a new instance of the [Lazy] that uses the specified initialization function [initializer]
* and the default thread-safety mode [LazyThreadSafetyMode.SYNCHRONIZED]. //线程安全模式
*
* If the initialization of a value throws an exception, it will attempt to reinitialize the value at next access.
*
* Note that the returned instance uses itself to synchronize on. Do not synchronize from external code on
* the returned instance as it may cause accidental deadlock. Also this behavior can be changed in the future.
*/
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
/**
* Creates a new instance of the [Lazy] that uses the specified initialization function [initializer]
* and thread-safety [mode].
*
* If the initialization of a value throws an exception, it will attempt to reinitialize the value at next access.
*
* Note that when the [LazyThreadSafetyMode.SYNCHRONIZED] mode is specified the returned instance uses itself
* to synchronize on. Do not synchronize from external code on the returned instance as it may cause accidental deadlock.
* Also this behavior can be changed in the future.
*/
public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
when (mode) {
LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
}
class UserManager private constructor(name: String) {
companion object {
@Volatile
private var INSTANCE: UserManager? = null
fun getInstance(name: String): UserManager =
// 第一次判空
INSTANCE?: synchronized(this) {
// 第二次判空
INSTANCE?:UserManager(name).also { INSTANCE = it }
}
}
}
// 使用
UserManager.getInstance("Tom")
我们定义了一个伴生对象,然后在它的内部,定义了一个 INSTANCE
,它是 private
的,这样就保证了它无法直接被外部访问。同时它还被注解“@Volatile
”修饰了,这可以保证INSTANCE
的可见性,而getInstance()
方法当中的synchronized
,保证了INSTANCE
的原子性。因此,这种方案还是线程安全的。
同时,我们也能注意到,初始化情况下,INSTANCE
是等于 null
的。这也就意味着,只有在getInstance()
方法被使用的情况下,我们才会真正去加载用户数据。这样,我们就实现了整个UserManager
的懒加载,而不是它内部的某个参数的懒加载。
另外,由于我们可以在调用getInstance(name)
方法的时候传入初始化参数,因此,这种方案也是支持传参的。
单例模式最多的写法,注意如果参数是上下文,不能传递Activity
或Fragment
的上下文,不然会有内存泄漏。(单例的内存泄漏)
如果有多个类似于上面的单例,那么就会有很多重复代码,于是尝试抽象成模板代码:
//要实现单例类,就只需要继承这个 BaseSingleton 即可
//P为参数,T为返回值
abstract class BaseSingleton<in P, out T> {
@Volatile
private var instance: T? = null
//抽象方法,需要我们在具体的单例子类当中实现此方法
protected abstract fun creator(param: P): T
fun getInstance(param: P): T =
instance ?: synchronized(this) {
instance ?: creator(param).also { instance = it }
}
}
通过伴生对象实现抽象类,并给出具体实现
//构建UploadFileManager对象需要一个带参数的构造方法
class UploadFileManager(val param: String) {
//伴生对象实现BaseSingleton抽象类
companion object : BaseSingleton<String, UploadFileManager>() {
//重写方法并给出具体实现
override fun creator(param: String): UploadFileManager {
return UploadFileManager(param)
}
}
fun foo(){
print("foo")
}
}
//调用
UploadFileManager.getInstance("张三").foo()
因为构造方法的限制这种封装也有一定的局限性。
关于 ViewModel ,Android 开发的小伙伴应该都非常熟悉,无论是新项目还是老项目,基本都会使用到。而 ViewModel 作为 JetPack
核心组件,其本身也更是承担着不可或缺的作用。
因此,了解 ViewModel 的设计思想更是每个应用层开发者必不可缺的基本功。
随着这两年 ViewModel
的逐步迭代,比如 SaveStateHandle 的加入等,ViewModel 也已经不是最初版本的样子。要完全理解其设计体系,往往也要伴随着其他组件的基础,所以并不是特别容易能被开发者吃透。
故本篇将以最新视角开始,与你一起,用力一瞥 ViewModel 的设计原理。
本文对应的组件版本:
本篇定位中等,将从背景与使用方式开始,再到源码解读。由浅入深,解析
ViewModel
的方方面面。
学完本篇,你将了解或明白以下内容:
ViewModel
的使用方式;SavedStateHandle
的使用方式;ViewModel
创建与销毁流程;SavedStateHandle
创建流程;好了,让我们开始吧! 🐊
在开始本篇前,我们先解释一些基础概念,以便更加清晰的了解后续的状态保存相关。
配置变更指的是,应用在运行时,内置的配置参数变更从而触发的Activity重新创建。
常见的场景有:旋转屏幕、深色模式切换、屏幕大小变化、更改了默认语言或者时区、更改字体大小或主题颜色等。
异常重建指的是非配置变更情况下导致的 Activity
重新创建。
常见场景大多是因为 内存不足,从而导致后台应用被系统回收 ,当我们切换到前台时,从而触发的重建,这个机制在Android中为 Low Memory Killer
机制,简称 LMK
。
可以在开发者模式,限制后台任务数为1,从而测试该效果。
在 ViewModel
出现之前,对于 View
逻辑与数据,我们往往都是直接存在 Activity
或者 Fragment
中,优雅一点,会细分到具体的单独类中去承载。当配置变更时,无可避免,会触发界面重绘。相应的,我们的数据在没有额外处理的情况下,往往也会被初始化,然后在界面重启时重新加载。
但如果当前页面需要维护某些状态不被丢失呢,比如 选择、上传状态 等等? 此时问题就变得棘手起来。
稍有经验同学会告诉你,在 onSaveInstanceState 中重写,使用bundle去存储相应的状态啊?➡️
但状态如果少点还可以,多一点就非常头痛,更别提包含继承关系的状态保存。 😶🌫️
所以,不出意外的话,我们 App 的 Activity-manifest 中通常默认都是下列写法:
android:configChanges="keyboard|orientation|uiMode|..."
这也是为啥Android程序普遍不支持屏幕旋转的一部分原因,从源头扼杀因部分配置变更导致的状态丢失问题。🐶保命
随着 ViewModel
组件推出之后,上述因配置变更而导致的状态丢失问题就迎刃而解。
ViewModel
可以做到在配置变更后依然持有状态。所以,在现在的开发中,我们开始将 View数据 与 逻辑 藏于 ViewModel
中,然后对外部暴漏观察者,比如我们常常会搭配 LiveData
一起使用,以此更容易的保持状态同步。
关于 ViewModel
的生命周期,具体如下图所示:
虽然 ViewModel
非常好用,但 ViewModel
也不是万能,其只能避免配置变更时避免状态丢失。比如如果我们的App是因为 内存不足 而被系统kill 掉,此时 ViewModel
也会被清除 🔺 。
不过对于这种情况,仍然有以下三个方法可以依然保存我们的状态:
onSaveInstanceState()
与 onRestoreInstanceState()
;SavedState
,本质上其实还是 onSaveInstanceState()
;SavedStateHandle
,本质上是依托于 SaveState
的实现;
上述的后两种都是随着 JetPack 逐步被推出,可以理解为是对原有的onSavexx的封装简化,从而使其变得更易用。
关于这三种方法,我们会在 SavedStateHandle
流程解析中再进行具体叙述,这里先提出来,留个伏笔。
作为文章的开始,我们还是要先聊一聊 ViewModel
的使用方式,如下例所示:
当然,你也可以选择引入 activity-ktx ,从而以更简便的写法去写:
implementation 'androidx.activity:activity-ktx:1.5.1'
private val mainModel by viewModels()
示例比较简单,我们创建了一个 ViewModel
,如上所示,并在 MainActivity
的 onCreate() 中进行了初始化。
这也是我们日常的使用方式,具体我们这里就不再做阐述。
我们知道,ViewModel
可以处理因为配置更改而导致的的状态丢失,但并不保证异常终止的情况,而官方的 SavedStateHandle
正是用于这种情况的解决方式。
SavedStateHandle
,如名所示,用于保存状态的手柄。再细化点就是,用于保存状态的工具,从而配合 ViewModel
而使用,其内部使用一个 map 保存我们要存储的状态,并且其本身使用 operator
重载了 set() 与 get() 方法,所以对于我们来说,可以直接使用 键值对 的形式去操作我们要保存的状态,这也是官方为什么称 SavedStateHandle
是一个 具有键值映射Map 特性的原因。
在 Fragment1.2 及 Activity1.1.0 之后,
SavedStateHandle
可以作为 ViewModel 的构造函数,从而反射创建带有SavedStateHandle
的 ViewModel 。
具体使用方式如下:
我们在 MainViewModel
构造函数中新增了一个参数 state:SavedStateHandle ,这个参数在 ViewModel
初始化时,会帮我们自动进行注入。从而我们可以利用 SavedStateHandle
以key-value的形式去保存一些 自定义状态 ,从而在进程异常终止,Act重建后,也能获取到之前保存的状态。
至于为什么能实现保存状态呢?
主要是因为 SavedStateHandle
内部默认有一个 SavedStateRegistry.SavedStateProvider 状态保存提供者对象,该对象会在我们创建ViewModel
时绑定到 SavedStateRegistry 中,从而在我们 Activity
异常重建时做到状态的 恢复 与 绑定 (通过重写 onSavexx()
与 onCreate()
方法监听)。
关于这部分内容,我们下面的源码解析部分也会再聊到,这里我们只需要知道是这么回事即可。
本章节,我们将从 ViewModelProvider() 开始,理清 ViewModel
的 创建 与 销毁 流程,从而理解其背后的 [魔法]。
不过 ViewModel 的源码其实并不是很复杂,所以别担心😉。
仔细想想,要解析ViewModel的源码,应该从哪里入手呢?
ViewModelProvider(this).get(MainViewModel::class.java)
最简单的方式还是初始化这里,所以我们直接从 ViewModelProvider() 初始化开始->
public constructor(owner: ViewModelStoreOwner)
: this(owner.viewModelStore, defaultFactory(owner), defaultCreationExtras(owner))
相应的,这里开始,我们就涉及到了三个方面,即 viewModelStore 、 Factory、 Exras 。所以接下来我们就顺藤摸瓜,分别看看这三处的实现细节。
ViewModelStoreOwner 顾名思义,用于保存 ViewModelStore
对象。
而 ViewModelStore
是负责维护我们 ViewModel
实例的具体类,内部有一个 map 的合集,用于保存我们创建的所有 ViewModel
,并对外提供了 clear()
方法,以 便于非配置变更时清除缓存 。
该方法用于初始化 ViewModel
默认的创造工厂🏭 。默认有两个实现,前者是 HasDefaultViewModelProviderFactory ,也是我们 Fragment
或者 ComponentActivity
都默认实现的接口,而后者是是指全局 NewInstanceFactory 。
两者的不同点在于,后者只能创建 空构造函数 的 ViewModel
,而前者没有这个限制。
示例源码:
HasDefaultViewModelProviderFactory 在 ComponentActivity 中的实现如下:
用于辅助 ViewModel
初始化时需要传入的参数,具体源码如下:
如上所示,默认有两个实现,前者是 HasDefaultViewModelProviderFactory ,也就是我们 ComponentActivity
实现的接口,具体的实现如下:
默认会帮我们注入 application
以及 intent
等,注意这里还默认使用了 getIntent().getExtras() 作为 ViewModel
的 默认状态 ,如果我们 ViewModel
构造函数中有 SavedStateHandle
的话。
更多关于 CreationExtras 可以了解这篇 创建 ViewModel 的新方式,CreationExtras 了解一下?
从缓存中获取现有的 ViewModel
或者 反射创建 新的 ViewModel
。
示例源码如下:
当我们使用 get() 方法获取具体的 ViewModel
对象时,内部会先利用 当前包名+ViewModel类名 作为 key
,然后从 viewModelStore
中取。如果当前已创建,则直接使用;反之则调用我们的 ViewModel工厂 create() 方法创建新的 ViewModel
。 创建完成后,并将其保存到 ViewModelStore
中。
具体的创造逻辑里,这里的 factory 正是我们在 ViewModelProvider
初始化时,默认构造函数 defaultFactory() 方法中生成的SavedStateViewModelFactory ,所以我们直接去看这个工厂类即可。
具体源码如下:
兼容旧的版本以及用户操作行为。
相应的,这里我们还需要再提一下,LegacySavedStateHandleController.create() 方法:
当我们调用创建 ViewModel
时,内部会调用具体的 ViewModel
工厂去创建,如果当前 ViewModel
已创建,则直接返回,否则调用其 create() 方法创建新的 ViewModel
。在具体的创建方法中,需要判断当前构造函数是不是带 application
或者 SaveStateHandle
,从而调用合适的 newInstance()
方法,最后再将创建好的 ViewModel
添加到 ViewModelStore
的 缓存 中。
在初始化 ViewModelProvider
时,还记得我们需要传递的 ViewModelStoreOwner
吗?
而这个接口正是被我们的 ComponentActivity 或者 Fragment 各自实现,相应的 ViewModelStore
也是存在于我们的 ComponentActivity 中,所以我们直接去看示例代码即可:
以ComponentActivity为例,具体的源码如下:
如上所示:在初始化Activity时,内部会使用 lifecycle
添加一个生命周期观察者,并监听 onDestory() 通知(Act销毁),如果当前销毁的原因非配置更改导致,则调用 ViewModeltore.clear() ,即清空我们的ViewModel缓存列表,从而这也是为什么 ViewModel
不支持非配置更改的实例保存。
你可能会惊讶,那还怎么借助SavedStateHandle保存状态,viewModel已经被清空了啊🤔?
如果你记得 Activity
传统处理状态的方式,此时也就能理解为什么了?因为源头都是一个地方,而 SavedStateHandle 仅仅只是一个更简便的封装而已。不过关于这个问题具体解析,我们将在下面继续进行探讨,从而理解 SavedStateHandle 的完整流程。
关于 SavedStateHandle
的使用方法我们在上面已经叙述过了,其相关的 api 使用源码也不是我们所关注的重点,因为并不复杂,而我们主要要探讨的是其整个流程。
要摸清 SavedStateHandle
的流程,无非就两个方向,即 从何而来 ,又 在哪里进行使用 🤔。
在上面探索 ViewModel
创建流程时,我们发现,在 get(ViewModel:xx) 方法内部,最终的 create() 方法里,存在两个分支:
- 存在附加参数extras(viewModel2.5.0新增);
- 不存在附加参数extras(兼容历史版本或者用户自定义的行为);
相应的,如果 ViewModel
的构造函数中存在 SavedStateHandle ,则各自的流程如下所示:
前者使用了 CreationExtras 的扩展函数 createSavedStateHandle()
:
而后者使用了 LegacySavedStateHandleController 控制器去创建:
总结:
上述流程中,两者大致是一样的,都需要先调用 consumeRestoredStateForKey(key)
拿到要还原的 Bundle , 再调用 SavedStateHandle.createHandle()
去创建 SavedStateHandle
。
那 SavedStateRegistry 又是什么呢?
我们的插入点也就在于此开始。
我们暂时先不关注如何还原状态,而是先搞清楚 SavedStateRegistry
是什么,它又是从哪来而传递来的。然后再来看 状态如何被还原,以及 SavedStateHandle
的创建流程,最后再搞清与 SavedStateRegistry
又是如何进行关联。
其是一个用于保存状态的注册表,往往由 SavedStateRegistryOwner 接口所提供实现,从而以便与拥有生命周期的组件相关联。
比如我们常用的 ComponentActivity
或者 Fragment
默认都实现了该接口。
源码如下所示:
分析上面的代码不难发现,SavedStateRegistry
本身提供了状态 还原 与 保存 的具体能力,并使用一个 map 保存当前所有的状态提供者,具体的状态提供者由 SavedStateProvider 接口实现。
相当于是拥有 SavedStateRegistry
的具体类,因为本身继承了 LifecycleOwner
接口,故其也具备 生命感知 能力,如下所示:
interface SavedStateRegistryOwner : LifecycleOwner {
val savedStateRegistry: SavedStateRegistry
}
以 ComponentActivity
为例,我们会发现,ComponentActivity
默认实现 SavedStateRegistryOwner 接口。即 SavedStateRegistry
的创造以及状态的保存,肯定也是 经过我们Activity转发处理(不然它自己怎么处理呢😅)。
而在上面探索 ViewModel 初始化时,我们了解到,ComponentActivity
默认实现了 HasDefaultViewModelProviderFactory
接口,用于创建ViewModel工厂 。相应的,其接口方法 getDefaultViewModelProviderFactory()
默认返回的是 SavedStateViewModelFactory
,即支持状态保存的ViewModel工厂。而该工厂构造函数中正是需要接受一个 SavedStateRegistry
变量,也正是我们 ComponentActivity
中默认保存的实例,所以也不难猜测 ViewModel工厂 是如何与 SavedStateRegistry
如何关联的。
以 ComponentActivity
的实现为例,源码如下:
ComponentActivity
初始化时,会创建一个 用于保存状态注册表的控制器 SavedStateRegistryController
对象,见面知意,不难猜出,其是用于控制 SavedStateRegistry
的具体类。并且该控制器对象会在 onCreate() 中调用 performRestore() 还原状态,并在onSaveInstanceState() 中去保存状态,此时也就解释了为什么 SavedStateRegistry
能做到状态保存。
相应的,我们还是要再去看看 SavedStateRegistryController ,以便更好的理解。
用于控制 SavedStateRegistry
,对外提供了 初始化 ,状态 还原、保存 等方法,如下所示:
简而言之,其主要用于辅助 SavedStateRegistry 进行状态保存与还原。
我们再回顾一下上面的步骤,在只关心 SavedStateHandle 如何被创建这样一个大背景下,我们大致可以梳理出这样的流程:
因为我们的 ComponentActivity 或者 Fragment 默认已经实现了 SavedStateRegistryOwner
接口,而且默认是由 SavedStateRegistryController
作为 SavedStateRegistry
的具体控制,因此具体的状态保存与还原都由该控制器去操作。
当我们的 Activity
因为异常生命周期重建时,此时会回调 onSaveInstanceState() 去保存状态,此时 SavedStateRegistryController
就会调用 performSave() 去保存当前状态(即将我们ViewModel的状态保存到bundle里),然后在 Activity 重建时,在 onCreate() 方法里进行还原(即从bundle里取出我们保存的状态)。
当我们创建 ViewModel
时,默认使用的 ViewModel
工厂是支持保存状态的 SavedStateViewModelFactory
。在初始化该工厂时,需要显式传递 SavedStateRegistryOwner
接口对象到该工厂中,而该工厂的构造函数内,会将 SavedStateRegistry
自行保存起来。
最后,如果要创建的 ViewModel
需要保存状态(即构造函数中存在SavedStateHadnle),则使用保存的 SavedStateRegistry
变量去获取我们将要还原的状态,然后再调用 SavedStateHandle.createHandle()
去创建具体的 SavedStateHadnle
。
由此结合 ViewModel
创建的流程,我们可以总结 SavedStateRegistry
的传递流程伪代码如下:
在上面,我们聊完了 SavedStateRegistry
是如何被创建以及被传递给我们的 ViewModel工厂 ,而这一小节,我们将要聊聊 SavedStateHandle
如何被创建,以及状态是如何被还原的。
我们知道,当创建 SavedStateHandle
前,需要先获取已保存的状态,也即 consumeRestoredStateForKey()
方法,所以我们本章节的插入点也就是从这里开始。
而与 consumeRestoredStateForKey()
关联的类有两个, SavedStateHandlesProvider 与 SavedStateRegistry 。
前者是
viewModel
(2.5.0) 新提供的 创建SavedStateHandle 的方式,后者则是用于 适配 2.5.0 之前的方式。
以 SavedStateHandlesProvider 为例,源码如下:
当我们调用 consumeRestoredStateForKey()
获取具体状态时,内部先会调用 performRestore()
从 SavedStateRegistry 获取我们保存的状态集,然后将其保存到 provider
中。再从这个总的 状态bundle 中获取我们当前 viewModel
所对应的状态。
相应的,我们再去看看 SavedStateHandle.createHandle() 方法,即 SavedStateHandle
最终被怎么创建出来。
源码如下:
上述的逻辑也比较简单,具体如源码中所示,当我们创建 SavedStateHandle 时,需要先从 SavedStateRegistry 获取我们的状态Bundle,然后再调用 createHandle()
方法创建具体的 SavedStateHandle。并在其 createHandle()
内将我们传入的 bundle 转为 Map 形式,从而传入 SavedStateHandle 的构造函数中用于初始化。
在这一章节,我们主要探讨的是 SavedStateHandle
的创建流程,以 ComponentActivity
为例:
我们知道 Android 中关于状态的保存与还原,官方建议使用 onSaveInstanceState() 与 onRestoreInstanceState() ,但随着JetPack组件库的完善,官方在这两个方法的基础上新增了 SavedState
,目的是简化状态保存的成本。从原理上,其创建了一个 状态保存的的注册表 SavedStateRegistry
,内部缓存着具体的 状态提供者合集(key为string,value为SavedStateProvider)。
当我们 Activity 因为配置更改或者不可控原因需要重建时,系统此时会主动调用 onSaveInstanceState() 方法,从而触发调用 savedStateRegistry.performSave()
去保存状态。该方法内部会创建一个新的 Bundle 对象,用于保存所有状态,然后再调用所有缓存的状态提供者(SavedStateProvider)的 saveState()
方法,从而将所有需要需要保存的状态以 key-value 的方式存到 Bundle 中去。最后再将这个整体的 bundle 存入 onSaveInstanceState()
方法参数提供的 bundle 中。
当我们的 Activity 重建完成后,在 onCreate()
方法中,再使用 SavedStateRegistry
还原我们自己保存的状态 restoredState。
最后当我们创建 ViewModel
时,因为我们的 ViewModel工厂(SavedStateViewModelFactory) 持有了 SavedStateRegistry
,也即持有着我们要还原的状态(如果有)。在创建具体的 ViewModel
时,如果我们要创建的 ViewModel
构造函数中存在 SavedStateHandle
参数,则该 ViewModel
支持保存状态,所以需要先去使用 SavedStateRegistry
获取我们保存的状态,最后再调用 SavedStateHandle.create() 去创建具体 SaveStateHandle
,从而创建出支持保存状态 ViewModel
。
在本篇中,我们从 ViewModel
的背景开始,再到 ViewModel
与 SavedStateHandle
的使用方式,最后又从源码层级分析了两者的具体流程,从而较完整的解析了 ViewModel
的底层实现与 SavedStateHandle
的整体创建流程。
至于更加详细的使用方式,这也非本篇要深入探索的细节,具体可参照其他同学的教程即可。
至此,关于 ViewModel
设计思想 以及 状态保存原理 到这里就结束了。也相信读过本篇的你也将不会再有所疑惑 :)
兔子主要还是画在画布上面,所以我们首先得生成个Canvas,然后确定Canvas的宽高跟画笔颜色
val drawColor = colorResource(id = R.color.color_EC4126)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
}
宽高这边只是写死的两个数值,我们也可以用系统api来获取真实的屏幕宽高,画笔颜色选用偏红的颜色,毕竟要过年了,喜庆点~,接下去开始准备画兔子
脑袋其实就是个椭圆,我们用canvas的drawPath方法去绘制,我们需要做的就是确定这个椭圆的中心点坐标,以及绘制这个椭圆的左上坐标以及右下坐标
val startX = screenWidth() / 4
val startY = screenHeight() / 3
val headPath = Path()
headPath.moveTo(screenWidth() / 2, screenHeight() / 2)
headPath.addOval(Rect(startX, startY, screenWidth() - startX, screenHeight() - startY))
headPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = headPath, color = drawColor, style = Stroke(width = 12f))
}
脑袋的中心点坐标x轴跟y轴分别就是画布宽高的一半,左上的x坐标就是画布宽的四分之一,y坐标就是画布高的三分之一,右下的x坐标就是画布宽减去左上的x坐标,右下的y坐标就是减去左上的y坐标,最终在Canvas里面将这个椭圆的path绘制出来,我们看下效果图
画完脑袋我们接着画耳朵,两只耳朵其实也就是两个椭圆,分别以中心线左右对称,绘制思路同画脑袋一样,确定两个path的中心点坐标,以及各自左上跟右下的xy坐标
val leftEarPath = Path()
val leftEarPathX = screenWidth() * 3 / 8
val leftEarPathY = screenHeight() / 6
leftEarPath.moveTo(leftEarPathX, leftEarPathY)
leftEarPath.addOval(
Rect(
leftEarPathX - 60f,
leftEarPathY / 2,
leftEarPathX + 60f,
startY + 30f
)
)
leftEarPath.close()
val rightEarPath = Path()
val rightEarPathX = screenWidth() * 5 / 8
val rightEarPathY = screenHeight() / 6
rightEarPath.moveTo(rightEarPathX, rightEarPathY)
rightEarPath.addOval(
Rect(
rightEarPathX - 60f,
rightEarPathY / 2,
rightEarPathX + 60f,
startY + 30f
)
)
rightEarPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftEarPath, color = drawColor, style = Stroke(width = 10f))
drawPath(path = rightEarPath, color = drawColor, style = Stroke(width = 10f))
}
看下效果图
这样感觉耳朵不是很立体,看起来有点平面,毕竟兔耳朵会有点往里凹的感觉,所以我们给这副耳朵加个内耳增加点立体感,内耳其实很简单,道理同外面的耳朵一样,只是中心点跟左上点,右下点的xy坐标会小一点,我们稍微改一下外耳的path就可以了
val leftEarSubPath = Path()
val leftEarSubPathX = screenWidth() * 3 / 8
val leftEarSubPathY = screenHeight() / 4
leftEarSubPath.moveTo(leftEarSubPathX, leftEarSubPathY)
leftEarSubPath.addOval(
Rect(
leftEarSubPathX - 30f,
screenHeight() / 6,
leftEarSubPathX + 30f,
startY + 30f
)
)
leftEarSubPath.close()
val rightEarSubPath = Path()
val rightEarSubPathX = screenWidth() * 5 / 8
val rightEarSubPathY = screenHeight() / 4
rightEarSubPath.moveTo(rightEarSubPathX, rightEarSubPathY)
rightEarSubPath.addOval(
Rect(
rightEarSubPathX - 30f,
screenHeight() / 6,
rightEarSubPathX + 30f,
startY + 30f
)
)
rightEarSubPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftEarSubPath, color = drawColor, style = Stroke(width = 6f))
drawPath(path = rightEarSubPath, color = drawColor, style = Stroke(width = 6f))
}
看下效果图
有内味儿了,内耳的画笔粗细稍微调小了一点,为了突出个近大远小嘛哈哈哈,我们接着下一步
画完耳朵我们开始画眼睛了,眼睛也很好画,主要是先找到中心点位置就好,中心点的x坐标其实跟耳朵的x坐标是一样的,y坐标在脑袋中心点y坐标稍微靠上一点的位置
val leftEyePath = Path()
val leftEyePathX = screenWidth() * 3 / 8
val leftEyePathY = screenHeight() * 11 / 24
leftEyePath.moveTo(leftEyePathX, leftEyePathY)
leftEyePath.addOval(
Rect(
leftEyePathX - 35f,
leftEyePathY - 35f,
leftEyePathX + 35f,
leftEyePathY + 35f
)
)
leftEyePath.close()
val rightEyePath = Path()
val rightEyePathX = screenWidth() * 5 / 8
val rightEyePathY = screenHeight() * 11 / 24
rightEyePath.moveTo(rightEyePathX, rightEyePathY)
rightEyePath.addOval(
Rect(
rightEyePathX - 35f,
rightEyePathY - 35f,
rightEyePathX + 35f,
rightEyePathY + 35f
)
)
rightEyePath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftEyePath, color = drawColor, style = Stroke(width = 10f))
drawPath(path = rightEyePath, color = drawColor, style = Stroke(width = 10f))
}
效果图如下
眼神有点空洞,无神是不,缺个眼珠子,那我们再给小兔子画上眼珠吧,眼珠就在眼睛的中心点位置,画一个圆点,圆点就要用到drawCircle,它有这些属性
fun drawCircle(
color: Color,
radius: Float = size.minDimension / 2.0f,
center: Offset = this.center,
/*@FloatRange(from = 0.0, to = 1.0)*/
alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
我们不需要用到全部,只需要用到颜色color,也就是红色,圆点半径radius,肯定要比眼睛的半径要小一点,我们就设置为10f,圆点中心坐标center,就是眼睛的中心点坐标,知道了以后我们开始绘制眼珠
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawCircle(color = drawColor, radius = 10f, center = Offset(leftEyePathX,leftEyePathY))
drawCircle(color = drawColor, radius = 10f, center = Offset(rightEyePathX,rightEyePathY))
}
我们再看下效果图
接下去我们画鼻子,鼻子肯定在脑袋的中间,所以中心点x坐标就是脑袋中心点的x坐标,那鼻子的y坐标就设置成比中心点y坐标稍微高一点的位置,代码如下
val nosePath = Path()
val nosePathX = screenWidth() / 2
val nosePathY = screenHeight() * 13 / 24
nosePath.moveTo(nosePathX, nosePathY)
nosePath.addOval(Rect(nosePathX - 15f, nosePathY - 15f, nosePathX + 15f, nosePathY + 15f))
nosePath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = nosePath, color = drawColor, style = Stroke(width = 10f))
}
我们看下效果图
兔子的样子逐渐出来了,画完鼻子我们接着画啥呢?没错,兔子最有特点的位置也就是兔唇,我们脑补下兔唇长啥样子,首先位置肯定是在鼻子的下面,然后从鼻子开始往两边分叉,也就是两个扇形,扇形怎么画呢,我们也有现成的api,drawArc,我们看下drawArc都提供了哪些属性
fun drawArc(
color: Color,
startAngle: Float,
sweepAngle: Float,
useCenter: Boolean,
topLeft: Offset = Offset.Zero,
size: Size = this.size.offsetSize(topLeft),
/*@FloatRange(from = 0.0, to = 1.0)*/
alpha: Float = 1.0f,
style: DrawStyle = Fill,
colorFilter: ColorFilter? = null,
blendMode: BlendMode = DefaultBlendMode
)
我们需要用到的就是颜色color,这个扇形起始角度startAngle,扇形终止的角度sweepAngle,是否扇形两端跟中心点连接起来的布尔值useCenter,扇形的左上位置topLeft以及扇形的大小size也就是设置半径,知道这些以后我们开始逐个代入参数吧
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawArc(
color = drawColor,
0f,
120f,
style = Stroke(width = 10f),
useCenter = false,
size = Size(120f, 120f),
topLeft = Offset(nosePathX - 120f, nosePathY)
)
drawArc(
color = drawColor,
180f,
-120f,
style = Stroke(width = 10f),
useCenter = false,
size = Size(120f, 120f),
topLeft = Offset(nosePathX + 10f, nosePathY)
)
}
画兔唇的时候其实就是在鼻子的两端各画一个坐标轴,左边的兔唇起始角度就是从x轴开始也就是0度,顺时针旋转120度,左上位置的x坐标刚好离开鼻子一个半径的位置,右边的兔唇刚好相反,逆时针旋转120度,起始角度是180度,左上位置的x坐标刚好在鼻子的位置那里,稍微加个10f让兔唇可以对称一些,我们看下效果图
脸上好像空了点,兔子的胡须还没有呢,胡须其实就是两边各画三条线,用drawLine这个api,起始位置的x坐标跟眼睛中心点的x坐标一样,中间胡须起始位置的y坐标跟鼻子的y坐标一样,上下胡须的y坐标各减去一定的数值
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawLine(
color = drawColor,
start = Offset(leftEyePathX, nosePathY - 60f),
end = Offset(leftEyePathX - 250f, nosePathY - 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(leftEyePathX, nosePathY),
end = Offset(leftEyePathX - 250f, nosePathY),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(leftEyePathX, nosePathY + 60f),
end = Offset(leftEyePathX - 250f, nosePathY + 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(rightEyePathX, nosePathY - 60f),
end = Offset(rightEyePathX + 250f, nosePathY - 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(rightEyePathX, nosePathY),
end = Offset(rightEyePathX + 250f, nosePathY),
strokeWidth = 5f,
cap = StrokeCap.Round
)
drawLine(
color = drawColor,
start = Offset(rightEyePathX, nosePathY + 60f),
end = Offset(rightEyePathX + 250f, nosePathY + 90f),
strokeWidth = 5f,
cap = StrokeCap.Round
)
}
很简单的画了六条线,线的粗细也稍微设置的小一点,毕竟胡须还是比较细的,我们看下效果图
就这样兔子脑袋部分所有元素都画完了,我们接着给兔子画身体
身体其实也是个椭圆,位置刚好在画布下方三分之一的位置,左上x坐标比脑袋左上x坐标大一点,y坐标就是画布三分之二的位置处,右下x坐标比脑袋右下x坐标稍微小一点,y坐标就是画布的底端,知道以后我们就仿照着脑袋画身体
val bodyPath = Path()
val bodyPathX = screenWidth() / 2
val bodyPathY = screenHeight() * 5 / 6
bodyPath.moveTo(bodyPathX, bodyPathY)
bodyPath.addOval(
Rect(
startX + 50f,
screenHeight() * 2 / 3,
screenWidth() - startX - 50f,
screenHeight()
)
)
bodyPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = bodyPath, color = drawColor, style = Stroke(width = 10f))
}
效果图如下
画完身体我们再画兔子的双爪,双爪其实也是画两个椭圆,椭圆中心点的x坐标同两只眼睛的x坐标一样,y坐标在画布六分之五的位置
val leftHandPath = Path()
val leftHandPathX = screenWidth() * 3 / 8
val leftHandPathY = screenHeight() * 5 / 6
leftHandPath.moveTo(leftHandPathX, leftHandPathY)
leftHandPath.addOval(
Rect(
leftHandPathX - 35f,
leftHandPathY - 90f,
leftHandPathX + 35f,
leftHandPathY + 90f
)
)
leftHandPath.close()
val rightHandPath = Path()
val rightHandPathX = screenWidth() * 5 / 8
val rightHandPathY = screenHeight() * 5 / 6
rightHandPath.moveTo(rightHandPathX, rightHandPathY)
rightHandPath.addOval(
Rect(
rightHandPathX - 35f,
rightHandPathY - 90f,
rightHandPathX + 35f,
rightHandPathY + 90f
)
)
rightHandPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = leftHandPath, color = drawColor, style = Stroke(width = 10f))
drawPath(path = rightHandPath, color = drawColor, style = Stroke(width = 10f))
}
我们看下效果图
还差最后一步,我们给兔子画上尾巴,尾巴的中心点x坐标就是画布宽度减去脑袋右边x轴坐标,尾巴中心点的y坐标就是画布高度减去一定的数值,我们看下代码
val tailPath = Path()
val tailPathX = screenWidth() - startX
val tailPathY = screenHeight() - 200f
tailPath.moveTo(tailPathX, tailPathY)
tailPath.addOval(Rect(tailPathX - 60f, tailPathY - 90f, tailPathX + 60f, tailPathY + 90f))
tailPath.close()
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawPath(path = tailPath, color = drawColor, style = Stroke(width = 10f))
}
就这样一只兔子画完了,我们看下最终效果图
看起来像那么回事了,我们再稍微点缀下,背景我们发现还有点单调,毕竟是过年了嘛,虽然多地不让放烟花,但我们看看还是可以的,网上找张烟花图片给兔子当背景吧,刚好也有drawImage这样的api可以将图片绘制到画布上,代码如下
val bgBitmap = ImageBitmap.imageResource(id = R.drawable.firework_night)
Canvas(
modifier = Modifier
.size(screenWidth().dp, screenHeight().dp)
.background(color = Color.White)
) {
drawImage(image = bgBitmap,
srcOffset = IntOffset(0,0),
srcSize = IntSize(bgBitmap.width,bgBitmap.height),
dstSize = IntSize(screenWidth().toInt()*5/4,screenHeight().toInt()*5/4),
dstOffset = IntOffset(0,0)
)
}
我们来看下效果怎么样
嗯~~大功告成~~好像也不是很好看哈哈哈,不过重点咱也不是为了美观,而是一个过年了图个寓意,另一个就是用下Compose里面Canvas这些api,毕竟随着kotlin逐步成熟,个人感觉Compose很有可能成为Android以后主流的UI开发模式
最后给大家拜个早年了,祝大家兔年大吉,“兔”飞猛进~~
有读者咨询我,在国企做开发怎么样?
当然是有利有弊,国企相对稳定,加班总体没有互联网多,不过相对而言,工资收入没有互联网高,而且国企追求稳定,往往技术栈比较保守,很难接触新的技术,导致技术水平进步缓慢。
下面分享一位国企程序员的经历,希望能给大家一些参考价值。
下文中的“我”代表故事主人公
我校招加入了某垄断央企,在里面从事研发工程师的工作。下面我将分享一些入职后的一些心得体会。
在国企中,开发是最底层最苦B的存在,在互联网可能程序员还能够和产品经理argue,但是在国企中,基本都是领导拍脑袋的决定,即便这个需求不合理,或者会造成很多问题等等,你所需要的就是去执行,然后完成领导的任务。下面我会分享一些国企开发日常。
在入职前几个月,我们都要基于一种国产编辑器培训,说白了集团的领导看市场上有eclipse,idea这样编译器,然后就说咱们内部也要搞一个国产的编译器,所有的项目都要强制基于这样一个编译器。
在国企里搞开发,通常会在项目中塞入一大堆其他项目插件,本来一个可能基于eclipse轻松搞定的事情,在国企需要经过2、3个项目跳转。但国企的项目本来就是领导导向,只需给领导演示即可,并不具备实用性。所以在一个项目集成多个项目后,可以被称为X山。你集成的其他项目会突然出一些非常奇怪的错误,从而导致自己项目报错。但是这也没有办法,在国企中搞开发,有些项目或者插件是被要求必须使用的。
说到开发,在国企必然是离不开外包的。在我这个公司,可以分为直聘+劳务派遣两种用工形式,劳务派遣就是我们通常所说的外包,直聘就是通过校招进来的校招生。
直聘的优势在于会有公司的统一编制,可以在系统内部调动。当然这个调动是只存在于规定中,99.9%的普通员工是不会调动。劳务派遣通常是社招进来的或者外包。在我们公司中,项目干活的主力都是外包。我可能因为自身本来就比较喜欢技术,并且觉得总要干几年技术才能对项目会有比较深入的理解,所以主动要求干活,也就是和外包一起干活。一开始我认为外包可能学历都比较低或者都不行,但是在实际干活中,某些外包的技术执行力是很强的,大多数项目的实际控制权在外包上,我们负责管理给钱,也许对项目的了解的深度和颗粒度上不如外包。
上次我空闲时间与一个快40岁的外包聊天,才发现他之前在腾讯、京东等互联网公司都有工作过,架构设计方面都特别有经验。然后我问他为什么离开互联网公司,他就说身体受不了。所以身体如果不是特别好的话,国企也是一个不错的选择。
在日常开发中,国企的技术一般不会特别新。我目前接触的技术,前端是JSP,后端是Springboot那一套。开发的过程一般不会涉及到多线程,高并发等技术。基本上都是些表的设计和增删改查。如果个人对技术没啥追求,可能一天的活2,3小时就干完了。如果你对技术有追求,可以在剩余时间去折腾新技术,自由度比较高。
所以在国企,作为普通基层员工,一般会有许多属于自己的时间,你可以用这些时间去刷手机,当然也可以去用这些时间去复盘,去学习新技术。在社会中,总有一种声音说在国企呆久了就待废了,很多时候并不是在国企待废了,而是自己让自己待废了。
每个研发类央企都有自己的职级序列,一般分为技术和管理两种序列。
首先,管理序列你就不用想了,那是留给有关系+有能力的人的。其实,个人觉得在国企有关系也是一种有能力的表现,你的关系能够给公司解决问题那也行。
其次,技术序列大多数情况也是根据你的工龄长短和PPT能力。毕竟,国企研发大多数干的活不是研发与这个系统的接口,就是给某个成熟互联网产品套个壳。技术深度基本上就是一个大专生去培训机构培训3个月的结果。你想要往上走,那就要学会去PPT,学会锻炼自己的表达能力,学会如何讲到领导想听到的那个点。既然来了国企,就不要再想钻研技术了,除非你想跳槽互联网。
最后,在国企底层随着工龄增长工资增长(不当领导)还是比较容易的。但是,如果你想当领导,那还是天时地利人和缺一不可。
在前面说到,我们公司属于成本单位,到工资这一块就体现为钱是总部发的。工资构成分由工资+年终奖+福利组成。
1.工资构成中没有绩效,没有绩效,没有绩效,重要的事情说三遍。工资是按照你的级别+职称来决定的,公司会有严格的等级晋升制度。但是基本可以概括为混年限。年限到了,你的级别就上去了,年限没到,你天天加班,与工资没有一毛钱关系。
2.年终奖,是总部给公司一个大的总包,然后大领导根据实际情况对不同部门分配,部门领导再根据每个人的工作情况将奖金分配到个人。所以,你干不干活,活干得好不好只和你的年终奖相关。据我了解一个部门内部员工的年终奖并不会相差太多。
3.最后就是福利了,以我们公司为例,大致可以分为通信补助+房补+饭补+一些七七八八的东西,大多数国企都是这样模式。
1、老生常谈了。在国企,工资待遇可以保证你在一线城市吃吃喝喝和基本的生活需要没问题,当然房子是不用想的了。
2、国企搞开发,技术不会特别新,很多时候是项目管理的角色。工作内容基本体现为领导的决定。
3、国企研究技术没有意义,想当领导,就多学习做PPT和领导搞好关系。或者当一个平庸的人,混吃等死,把时间留给家人,也不乏是一种好选择。
作者:程序员大彬
来源:juejin.cn/post/7182355327076007996
本文阐述了个人对移动端页面加载耗时监控的一些理解,主要从:节点划分及对应的实现方案,线上监控注意点,后续还能做的事 三个方面来和大家分享。
移动端的页面加载速度,作为最为影响用户体验的因素之一,是我们做移动端性能优化的重点方向之一。
而优化的效果体现,需要置信的指标进行衡量(常见方法论:寻找方向->确定指标->实践->量化收益),而本文想要分享的就是:如何真实、完整、方便的获得页面加载时间,并会向线上监控环节,有一定延伸。
本文的示例代码都是OC(因为Java和kotlin我也不会😅),但相关思路和方案也适用于Android(Android端已实现并上线)。
页面加载时长是一直以来大家都在攻坚的方向,所以市面上也有非常非常多的度量方案,从节点划分角度看:
较为基础的:ViewController 的 init -> viewDidLoad -> viewDidAppear
更进一步的:ViewController 的 init -> viewDidLoad -> viewDidAppear -> user Interactable
主流方案:ViewController 的 init -> viewDidLoad -> viewDidAppear -> view render completed -> user Interactable
对于这些成熟方案,我还有什么可以更进一步的吗?主要总结为以下几个方面吧:
完整反映用户体感
我们做性能优化,归根结底,更是用户体验优化,在满足功能需要的同时,不影响用户的使用体验。 所以,我个人认为,大多数的性能指标,都要考虑到用户体验这个方向;页面启动速度这一块,更是如此;而传统的方案,能够完整的反应用户体感吗? 我觉得还是有一部分的缺失的:用户主动发起交互到ViewController这个阶段。这一部分有什么呢,不就是直接tap触发的action里vc就初始化了吗? 实际在一些较为复杂、大型的项目中,并不然,中间可能会有很多其他处理,例如:方法hook、路由调度、参数解析、containerVC的初始化、动态库加载等等。这一部分的耗时,实际上也是用户体感的一部分,而这一部分的耗时,如果不加监控的话,也会对整体耗时产生劣化。(这里可能会有小伙伴问了,这些东西,不应该由各自负责的同学,例如负责路由的同学,自行监控吗?这里我想阐述的一个观点时,时长类的监控,如果由几个时间段拼接,相比于endTime - startTime,难免会产生gap,即,加入endTime = 10,startTime = 0,那么中间分成两段,很有可能endTime2 = 10,startTime2 = 6;endTime1 = 4,startTime1 = 0,造成总时长不准。总而言之,还是希望得到一个能够完整反映用户体感的时长。)
数据采集与业务解耦
这一点其实市面上的很多方案已经做得很好了。解耦,一方面是为了,提效:避免后续有新的页面需要监控时,需要进行新的开发;另一方面,也是避免业务迭代对于监控数据的影响:如果是手动侵入性埋点,很难保证后续新增的耗时任务对监控数据不产生影响。 而本文方案,不需要在业务代码中插入任何代码,大都是通过方法hook来实现数据采集的;而对范围、以及匹配关系等的控制,也都是通过配置来完成的。
根据一个页面(ViewController)的加载过程中,开发主要进行的处理,以及可能对用户体感产生影响的因素,将页面加载过程划分为如上图所示的11个节点,具体解释及实现方案如下:
由于页面的跳转一般是通过用户点击、滑动等行为触发的,因此这里监听用户触摸屏幕的时间点;但有效节点仅为VC在初始化前的最后一次点击/交互。
具体实现: hook UIWidow 的 sendEvent:
方法,在swizzle方法内记录信息;为了性能考虑,目前仅记录一个uint64_t的时间戳,且仅内存写; 注意这里需要记录手指抬起的时间,即 touch.phase == UITouchPhaseEnded
,因为一般action被调用的时机就是此时; 同时,为了适配各种行为触发的新页面出现,还增加了一个手动添加该节点的方法,使一些较复杂且不通用,业务特性较强的初始化场景,也能够有该节点数据,且不依赖hook;但注意该手动方法为侵入式数据采集方式。
具体实现:hook UIViewController
或你的VC基类 的 - (instancetype)init
的方法;
不依赖于网络数据的UI开始初始化。
这个节点,我实际上并没有在本次实现,这里的一个理想态是:将这部分行为(即UI初始化的代码),通过协议的方式,约束到指定方法中;例如,架构层面约束一个setupSubviews的接口,回调给各业务VC,供其进行基础UI绘制(目前这种方式再一些更复杂的业务场景下实现并运行较好);有这个基础约束的前提下,才能准确的采集我理想中该节点的耗时。而我目前所负责的模块,并没有这种强约束,而又不能简单的去认为所有基础UI都是在viewDidLoad
中去完成的。因此需要 对原有架构的一定修改 或 能够保证所有基础UI行为都在viewDidLoad
中实现,才能够实现该节点数据的准确采集。 因此2 ~ 3和3 ~ 4间的耗时,被融合为了一段2 ~ 4的耗时。
不依赖于网络数据的UI初始化完成。
具体实现:监听主线程的闲时状态,VC初始化 节点后的首个闲时状态表示 本地UI初始化完成;(闲时状态即runloop进入kCFRunLoopBeforeWaiting
)
调用网络SDK的时间点。
这里描述的就是上面的节点划分图的第二条线,因为两条线的节点间没有强制的线性关系,虽然图中当前节点是放在了VC初始化平行的位置,但实际上,有些实现会在VC初始化之前就发起网络请求,进行预加载,这种情况在实现的时候也是需要兼容的。
具体实现:hook 业务调用网络SDK发起请求方法的api;这里的网络库各家实现方案就可能有较大差异了,根据自身情况实现即可。
网络SDK的回调触发的时间点。
具体实现:hook 网络SDK向业务层回调的api;差异性同5。
真正 发出网络请求 和 收到response 的时间点,用于计算真正的网络层耗时。 这俩和5、6是不是重复了啊?并不然,因为,网络库在接收到发起网络请求的请求后,实际上在端阶段,还会进行很多处理,例如公参的处理、签名、验签、json2Model等,都会产生耗时;而真正离开了端,在网上逛荡那一段,更是几乎“完全不可控”的状态。所以,分开来统计:端部分 和 网络阶段,才能够为后续的优化提供数据基础,这也是数据监控的意义所在。
具体实现: 实际上系统网络api中就有对网络层详细性能数据的收集
- (void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics;
根据官方文档中的描述
可以发现,我们实际上需要的时长就是从 fetchStartDate
到 responseEndDate
间的时间。 因此可以该delegate,获取这两个时间点。
详细UI指,依赖于网络接口数据的UI,这部分UI渲染完成才是页面达到对用户可见的状态。
具体实现:这里我们认为从网络SDK触发回调时,即开始进行详细UI的渲染,因此该节点和节点6是同一个节点。
页面对用户来说,真正达到可见状态的节点。
具体实现: 对于一个常规的App页面来说,如何定义一个页面是否真正渲染完成了呢?
被有效的视图铺满。
什么是有效视图呢?视频,图片,文字,按钮,cell,能向用户传递信息,或者产生交互的view; 铺满,并不是指完全铺满,而是这些有效视图填充到一定比例即可,因为按照正常的视觉设计和交互体验,都不会让整个屏幕的每一个像素点都充满信息或具备交互能力;而这个比例,则是根据业务的不同而不同的。 下面则是上述逻辑的实现思路:
UITextView
UITextField
UIButton
UILabel
UIImageView
UITableViewCell
UICollectionViewCell
主流方案中比较常见的,是前几种类,并不包括最后的两个cell;而这里为什么将cell也作为有效视图类呢? 首先,出于业务特征考虑,目前应用该套监控方案的页面,主要是以卡片列表样式呈现的;而且个人认为,市面上很多App的页面也都是列表形式来呈现内容的;当然,如果业务特征并不相符,例如全屏的视频播放页,就可以不这样处理。 其次,将cell作为有效视图,确实能够极大的降低每次计算覆盖率的耗时的。性能监控本身产生的性能消耗,是性能方向一直以来需要着重关注的点,毕竟你一个为了性能优化服务的工具,反而带来了不小的劣化,怎样也说不太过去啊😂~ 我也测试了是否包含cell对计算耗时的影响: 下表中为,在一个层级较为复杂的业务页面,页面完全渲染完成之后,完成一次覆盖率达到阈值的扫描所需的时长。
有效视图 | 包含 cell | 不包含 cell |
---|---|---|
检测一次覆盖率耗时(ms) | 1~5 | 15~18 |
耗时减少 | 15ms/次(83%) |
而且,有效视图的类,建议支持在线配置,也可以是一些自定义类。
将cell作为有效视图,大家可能会产生一个新的顾虑:占位cell的情况,再具体点,就是常见的骨架图怎么办?骨架图是什么,就是在网络请求未返回的时候,用缓存的data或者模拟样式,渲染出一个包含大致结构,但不包含具体内容的页面状态,例如这种:
这种情况下,cell已经铺满了屏幕,但实际上并未完成渲染。这里就要依赖于节点的前后顺序了,详细UI是依赖于网络数据的,而骨架图是在网络返回之前绘制完成的,所以真正的覆盖率计算,是从网络数据返回开始的,因此骨架图的填充完成节点,并不会被错误统计未详细UI渲染完成的节点。
如上图所示,开辟两个数组a、b,数组空间分别为屏幕长宽的像素数,并以0填充,分别代表横纵坐标; 从ViewController的view开始递归遍历他的subView,遇见有效视图时,将其frame的width和height,对应在数组a、b中的range的内存空间,都填充为1,每次遍历结束后,计算数组a、b中内容为1的比例,当达到阈值比例时,则视为可见状态。 示例代码如下:
- (void)checkPageRenderStatus:(UIView *)rootView {
if (kPhoneDeviceScreenSize.width <= 0 || kPhoneDeviceScreenSize.height <= 0) {
return;
}
memset(_screenWidthBitMap, 0, kPhoneDeviceScreenSize.width);
memset(_screenHeightBitMap, 0, kPhoneDeviceScreenSize.height);
[self recursiveCheckUIView:rootView];
}
- (void)recursiveCheckUIView:(UIView *)view {
if (_isCurrentPageLoaded) {
return;
}
if (view.hidden) {
return;
}
// 检查view是否是白名单中的实例,直接用于填充bitmap
for (Class viewClass in _whiteListViewClass) {
if ([view isKindOfClass:viewClass]) {
[self fillAndCheckScreenBitMap:view isValidView:YES];
return;
}
}
// 最后递归检查subviews
if ([[view subviews] count] > 0) {
for (UIView *subview in [view subviews]) {
[self recursiveCheckUIView:subview];
}
}
}
- (BOOL)fillAndCheckScreenBitMap:(UIView *)view isValidView:(BOOL)isValidView {
CGRect rectInWindow = [view convertRect:view.bounds toView:nil];
NSInteger widthOffsetStart = rectInWindow.origin.x;
NSInteger widthOffsetEnd = rectInWindow.origin.x + rectInWindow.size.width;
if (widthOffsetEnd <= 0 || widthOffsetStart >= _screenWidth) {
return NO;
}
if (widthOffsetStart < 0) {
widthOffsetStart = 0;
}
if (widthOffsetEnd > _screenWidth) {
widthOffsetEnd = _screenWidth;
}
if (widthOffsetEnd > widthOffsetStart) {
memset(_screenWidthBitMap + widthOffsetStart, isValidView ? 1 : 0, widthOffsetEnd - widthOffsetStart);
}
NSInteger heightOffsetStart = rectInWindow.origin.y;
NSInteger heightOffsetEnd = rectInWindow.origin.y + rectInWindow.size.height;
if (heightOffsetEnd <= 0 || heightOffsetStart >= _screenHeight) {
return NO;
}
if (heightOffsetStart < 0) {
heightOffsetStart = 0;
}
if (heightOffsetEnd > _screenHeight) {
heightOffsetEnd = _screenHeight;
}
if (heightOffsetEnd > heightOffsetStart) {
memset(_screenHeightBitMap + heightOffsetStart, isValidView ? 1 : 0, heightOffsetEnd - heightOffsetStart);
}
NSUInteger widthP = 0;
NSUInteger heightP = 0;
for (int i=0; i< _screenWidth; i++) {
widthP += _screenWidthBitMap[i];
}
for (int i=0; i< _screenHeight; i++) {
heightP += _screenHeightBitMap[i];
}
if (widthP > _screenWidth * kPageLoadWidthRatio && heightP > _screenHeight * kPageLoadHeightRatio) {
_isCurrentPageLoaded = YES;
return YES;
}
return NO;
}
但是也会有极端情况(类似下图)
无法正确反应有效视图的覆盖情况。但是出于性能考虑,并不会采用二维数组,因为w*h的量太大,遍历和计算的耗时,会有指数级的激增;而且,正常业务形态,应该不太会有类似的极端形态。
即使真的会较高频的出现类似情况,也有一套备选方案:计算有效视图的面积 占 总面积 的比例;该种方式会涉及到UI坐标系的频繁转换,耗时也会略差于当前的方式。
在某些业务场景下,例如 无/少结果情况,关于页面等,完全渲染后,也无法达到铺满阈值。 这种情况,会以用户发生交互(同 1、用户行为触发页面跳转 的获取方式)和 主线程闲时状态超过5s (可配)来做兜底,看是否属于这种状态,如果是,则相关性能数据不上报,因为此种页面对性能的消耗较正常铺满的情况要低,并不能真实的反应性能消耗、瓶颈,因此,仅正常铺满的业务场景进行监控并优化,即可。
以帧刷新为准,因为只有每次帧刷新后,UI才会真正产生变化;出于性能考虑,不会每帧都进行扫描,每间隔x帧(x可配,默认为1),扫描一次;同时,考虑高刷屏 和 大量UI绘制时会丢帧 的情况,设置 扫描时间间隔 的上下限,即:满足 隔x帧 的前提下,如果和上次扫描的时间差小于 下限,仍不扫描;如果 某次扫描时,和上次扫描的时间间隔 大于 上限,则无论中间隔几帧,都开启一次扫描。
用户可见之后的下一个对用户来说至关重要的节点。如果只是可见,然后就疯狂占用主线程或其他资源,造成用户的点击等交互行为,还是会被卡主,用户只能看,不能动,这个体感也是很差的;
具体实现:详细UI渲染完成 后的 首次主线程闲时状态。
这里由于各家的基建并不相同,因此只是总结一些小的建议,可能会比较零散,大家见谅。
建议采样收集
首先,数据的采集或者其他的新增行为/方法,一定是会产生耗时的,虽然可能不多,但还是秉着尽善尽美的原则,还是能少点就少点的,所以数据的采集,包括前面的hook等等一切行为,都只是随机的面向一部分用户开放,降低影响范围; 而且,如果数据量极大,全量的数据上报,其实对数据链路本身也会产生压力、增加成本。 当前,采样的前提是基本数据量足够,不然的话,采样样本量过小,容易对统计结果产生较大波动,造成不置信的结果。
可配置
除了基本的是否开启的开关之外,还有其他的很多的点 需要/可以/建议 使用线上配置控制。个人认为,线上配置,除了实现对逻辑的控制,更重要的一个作用,就是出现问题时及时止损。 举一些我目前使用的配置中的例子: - 有效视图类 - 渲染完成状态,横纵坐标的填充百分比阈值 - 终态的兜底阈值 - VC的类名、对应的网络请求 等等。
本地异常数据过滤
由于我们的样本数据量会非常大,所以对于异常数据我们不需要“手软”,我们需要有一套本地异常数据过滤的机制,来保证上报的数据都是符合要求的;不然我们后续统计处理的时候,也会因此出现新的问题需要解决。
这一部分,是对后续可实现方案的一个美好畅想~
其实,实际业务场景中,很多cell,即使绘制完,并渲染到屏幕上,此时,用户可见的也没有达到我们真正希望用户可见的状态,很多内容,都还是一个placeholder的状态。例如,通过url加载的image,我们一般都是先把他的size算好,把他的位置留好,cell渲染完就直接展示了;再进一步,如果是一个视频的播放卡片,即使网络图片加载好了,还要等待视频帧的返回,才能真正达到这张卡片的业务终态\color{red}{业务终态}业务终态(求教这里标红后如何能够让字体大小一致)。
这个非常后置,而且我们端上可能也影响不了什么的节点,采集起来有意义吗?
我觉得这是一个非常有价值的节点。一直都在说“技术反哺业务”,那么业务想要用户真正看到的那个终态,就是很重要的一环;因此,用户能在什么时间点看到,从业务角度说,能够影响其后续的方案设计(表现形式),完善用户体感对业务指标的影响;从技术角度说,可以感知真实的全链路的表现(不只是端),从而有针对性的进行优化。
如何获取到所有的业务终态呢?
这里一定是和业务有所耦合的,因为每个业务的终态,只有业务自身才知道;但是我们还是要尽量降低耦合度。 这里可以用协议的方式,为各个业务增加一个达到终态的标识,那么在某个业务达到终态之后,设置该标识即可,这里就是唯一对业务的侵入了;然后和计算覆盖率类似,这里的遍历,是业务维度(这里想象为卡片更好理解一点),只有全部业务的标识都ready之后,才是真正达到业务上的终态。
其实,现在性能监控,各类平台,各个团队,或多或少的都在做,我相信,性能数据采集的代码,在工程中,也不仅仅只有一份;这个现状,在很多成一定规模的互联网公司中都可能存在。
而如果您和我一样,作为一个业务团队,如何在不重复造轮子的情况下,夹缝中求生存呢?
我个人目前的理解:将 性能表现 与 业务场景 相关联。
帧率、启动耗时、CPU、内存等等,这些性能指标数据的获取,在业界都有非常成熟的方案,而且我们的工程里,一定也有相关的代码;而我们能做的,仅仅是,调一下人家的api,然后把数据再自己上传一份(甚至有的连上传都包含了),就完事了吗?
这样我觉得并不能体现出我们自建监控的价值。个人理解,监控的意义在于:暴露问题 + 辅助定位问题 + 验证问题的解决效果。
所以我们作为业务团队,将 性能数据 和 我们的业务做了什么 bind 到一起了,是不是就能一定程度上完成了上面的目的呢?
我们可以明确,我们什么样的业务行为,会影响我们的性能数据,也就是影响我们的用户基础体验。这样,不仅会帮助我们定位问题的原因,甚至会影响产品侧的一些产品能力设计方案。
完成这些建设之后,可能我们的监控就可以变成这样,甚至更好的状态:
性能数据的关注、监控,不应该仅仅在线上阶段,开发期 → 测试期 → 线上,全链路各个环节都应该具有。
目前各家都比较关注线上监控,相信都已经较为完善;
测试期的业务流程性能脚本;对于测试的性能测试方案,开放应该参与共建或者有一定程度的参与,这样才能从一定程度上保证数据的准确性,以及双方性能数据的相互认可;
开发期,目前能够提供展示实时CPU、FPS、内存数据的基础能力的工具很常见,也比较容易实现;但实际上,在日常开发的过程中,很难让RD同时关注需求情况与性能数据表现。因此,还是需要一些工具来辅助:例如,我们可以对某些性能指标,设置一些阈值,当日常开发中,超过阈值时,则弹窗提醒RD确认是否原因、是否需要优化,例如,详细UI绘制阶段的耗时阈值是800ms,如果某位同学在进行变更后,实际绘制耗时多次超越该值,则弹窗提醒。
作者:XTShow
来源:juejin.cn/post/7184033051289059384
列表的元素可以在一行中进行方便的循环。
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
even_numbers = [number for number in numbers if number % 2 == 0]
print(even_numbers)
输出:
[1,3,5,7]
同时,也可以用在字典上。
dictionary = {'first_num': 1, 'second_num': 2,
'third_num': 3, 'fourth_num': 4}
oddvalues = {key: value for (key, value) in dictionary.items() if value % 2 != 0}
print(oddvalues)Output: {'first_num': 1, 'third_num': 3}
枚举是一个有用的函数,用于迭代对象,如列表、字典或文件。该函数生成一个元组,其中包括通过对象迭代获得的值以及循环计数器(从0的起始位置)。当您希望根据索引编写代码时,循环计数器很方便。
sentence = 'Just do It'
length = len(sentence)
for index, element in enumerate(sentence):
print('{}: {}'.format(index, element))
if index == 0:
print('The first element!')
elif index == length - 1:
print('The last element!')
在设计函数时,我们经常希望返回多个值。这里我们将介绍两种典型的方法:
最简单的方式就是返回一个tuple。
get_student 函数,它根据员工的ID号以元组形式返回员工的名字和姓氏。
# returning a tuple.
def get_student(id_num):
if id_num == 0:
return 'Taha', 'Nate'
elif id_num == 1:
return 'Jakub', 'Abdal'
else:
raise Exception('No Student with this id: {}'.format(id_num))
Student = get_student(0)
print('first_name: {}, last_name: {}'.format(Student[0], Student[1]))
返回一个字典类型。因为字典是键、值对,我们可以命名返回的值,这比元组更直观。
# returning a dictionary
def get_data(id_num):
if id_num == 0:
return {'first_name': 'Muhammad', 'last_name': 'Taha', 'title': 'Data Scientist', 'department': 'A', 'date_joined': '20200807'}
elif id_num == 1:
return {'first_name': 'Ryan', 'last_name': 'Gosling', 'title': 'Data Engineer', 'department': 'B', 'date_joined': '20200809'}
else:
raise Exception('No employee with this id: {}'.format(id_num))
employee = get_data(0)
print('first_name: {},nlast_name: {},ntitle: {},ndepartment: {},ndate_joined: {}'.format(
employee['first_name'], employee['last_name'], employee['title'], employee['department'], employee['date_joined']))
如果你有一个值,并希望将其与其他两个值进行比较,则可以使用以下基本数学表达式:1<x<30。
你也许经常使用的是这种
1<x and x<30
在python中,你可以这么使用
x = 5
print(1<x<30)
当你输入 "[[1, 2, 3],[4, 5, 6]]" 时,你想转换为列表,你可以这么做。
import ast
def string_to_list(string):
return ast.literal_eval(string)
string = "[[1, 2, 3],[4, 5, 6]]"
my_list = string_to_list(string)
print(my_list)
Python 中 esle 特殊的用法。
number_List = [1, 3, 8, 9,1]
for number in number_List:
if number % 2 == 0:
print(number)
break
else:
print("No even numbers!!")
使用 heapq 模块在列表中查找n个最大或n个最小的元素。
import heapq
numbers = [80, 25, 68, 77, 95, 88, 30, 55, 40, 50]
print(heapq.nlargest(5, numbers))
print(heapq.nsmallest(5, numbers))
value = "Taha"
print(value * 5)
print("-" * 21)
cities= ['Vienna', 'Amsterdam', 'Paris', 'Berlin']
print(cities.index('Berlin'))
print("Analytics", end="")
print("Vidhya")
print("Analytics", end=" ")
print("Vidhya")
print('Data', 'science', 'blogathon', '12', sep=', ')
输出
AnalyticsVidhya
Analytics Vidhya
Data, science, blogathon, 12
有时,当你试图打印一个大数字时,传递整数真的很混乱,而且很难阅读。然后可以使用下划线,使其易于阅读。
print(5_000_000_000_000)
print(7_543_291_635)
输出:
5000000000000
7543291635
切片列表时,需要传递最小、最大和步长。要以相反的顺序进行切片,只需传递负步长。让我们来看一个例子:
sentence = "Data science blogathon"
print(sentence[21:0:-1])
输出
nohtagolb ecneics ata
如果要检查两个变量是否指向同一个对象,则需要使用“is”
但是,如果要检查两个变量是否相同,则需要使用“==”。
list1 = [7, 9, 4]
list2 = [7, 9, 4]
print(list1 == list2)
print(list1 is list2)
list3 = list1
print(list3 is list1)
输出
True
False
True
first_dct = {"London": 1, "Paris": 2}
second_dct = {"Tokyo": 3, "Seol": 4}
merged = {**first_dct, **second_dct}
print(merged)
输出
{‘London’: 1, ‘Paris’: 2, ‘Tokyo’: 3, ‘Seol’: 4}
sentence = "Analytics Vidhya"
print(sentence.startswith("b"))
print(sentence.startswith("A"))
print(ord("T"))
print(ord("A"))
print(ord("h"))
print(ord("a"))
cities = {'London': 1, 'Paris': 2, 'Tokyo': 3, 'Seol': 4}
for key, value in cities.items():
print(f"Key: {key} and Value: {value}")
cities = ["London", "Vienna", "Rome"]
cities.append("Seoul")
print("After append:", cities)
cities.insert(0, "Berlin")
print("After insert:", cities)
输出:
[‘London’, ‘Vienna’, ‘Rome’, ‘Seoul’] After insert: [‘Berlin’, ‘London’, ‘Vienna’, ‘Rome’, ‘Seoul’]
它通过在其中传递的特定函数过滤特定迭代器,并且返回一个迭代器。
mixed_number = [8, 15, 25, 30,34,67,90,5,12]
filtered_value = filter(lambda x: x > 20, mixed_number)
print(f"Before filter: {mixed_number}")
print(f"After filter: {list(filtered_value)}")
输出:
Before filter: [8, 15, 25, 30, 34, 67, 90, 5, 12]
After filter: [25, 30, 34, 67, 90]
def multiplication(*arguments):
mul = 1
for i in arguments:
mul = mul * i
return mul
print(multiplication(3, 4, 5))
print(multiplication(5, 8, 10, 3))
print(multiplication(8, 6, 15, 20, 5))
输出:
60
1200
72000
capital = ['Vienna', 'Paris', 'Seoul',"Rome"]
countries = ['Austria', 'France', 'South Korea',"Italy"]
for cap, country in zip(capital, countries):
print(f"{cap} is the capital of {country}")
import sys
mul = 5*6
print(sys.getsizeof(mul))
map() 函数用于将特定函数应用于给定迭代器。
values_list = [8, 10, 6, 50]
quotient = map(lambda x: x/2, values_list)
print(f"Before division: {values_list}")
print(f"After division: {list(quotient)}")
可以在 list 上调用 count 函数。
cities= ["Amsterdam", "Berlin", "New York", "Seoul", "Tokyo", "Paris", "Paris","Vienna","Paris"]
print("Paris appears", cities.count("Paris"), "times in the list")
cities_tuple = ("Berlin", "Paris", 5, "Vienna", 10)
print(cities_tuple.index("Paris"))
cities_list = ['Vienna', 'Paris', 'Seoul',"Amsterdam"]
print(cities_list.index("Amsterdam"))
set1 = {'Vienna', 'Paris', 'Seoul'}
set2 = {"Tokyo", "Rome",'Amsterdam'}
print(set1.union(set2))
from collections import Counter
count = Counter([7, 6, 5, 6, 8, 6, 6, 6])
print(count)
print("Sort values according their frequency:", count.most_common())
输出:
Counter({6: 5, 7: 1, 5: 1, 8: 1})
Sort values according their frequency: [(6, 5), (7, 1), (5, 1), (8, 1)]
cities_list = ['Vienna', 'Paris', 'Seoul',"Amsterdam","Paris","Amsterdam","Paris"]
cities_list = set(cities_list)
print("After removing the duplicate values from the list:",list(cities_list))
cities_list1 = ['Vienna', 'Paris', 'Seoul',"Amsterdam", "Berlin", "London"]
cities_list2 = ['Vienna', 'Paris', 'Seoul',"Amsterdam"]
cities_set1 = set(cities_list1)
cities_set2 = set(cities_list2)
difference = list(cities_set1.symmetric_difference(cities_set2))
print(difference)
number = [1, 2, 3]
cities = ['Vienna', 'Paris', 'Seoul']
result = dict(zip(number, cities))
print(result)
作者:程序员学长
来源:juejin.cn/post/7126728825274105886
前提条件
1.macOS系统,安装了xcode和flutter集成环境
2.有苹果开发者账号
3.有环信开发者账号
(注册地址:https://console.easemob.com/user/register)
4.参考这篇文章https://www.imgeek.org/article/825360043,完成推送证书的创建和上传
集成IM离线推送
1.创建一个新的项目
2.导入flutterSDK
3.初始化环信sdk
void initSDK() async {
var options = EMOptions(
appKey: “你的appkey”,
);
options.enableAPNs("EaseIM_APNS_Developer");
await EMClient.getInstance.init(options);
debugPrint("has init");
}
EaseIM_APNS_Developer是你在环信后台创建的证书名,需要注意,iOS需要上传开发证书和生产证书
4.可以在 _incrementCounter 这个按钮点击事件中调用一下登录操作,到此flutter层的工作已经完成
5.打开原生项目,修改包名,添加推送功能
6.打开AppDelegate 文件 导入im_flutter_sdk,并且在didRegisterForRemoteNotificationsWithDeviceToken方面里面调用环信的registerForRemoteNotifications方法,进行token的绑定
注:IM离线推送机制:
1.环信这边需要针对设备deviceToken和环信的username进行绑定,
2.IMserver 收到消息,会检测接收方是否在线,如果在线直接投递消息,如果不在线,则根据username 取设备的deviceToken
3.根据设备的deviceToken 和 上传的证书给设备推送消息
4.当app第一次运行的时候,就会走didRegisterForRemoteNotificationsWithDeviceToken方法,这个时候绑定token信息会报错,这个时候是正常的,因为你并没有登录,此时SDK内部会保存deviceToken,当你调用登录接口成功之后,SDK内部会进行一次绑定token的操作,
到此,推送功能已经集成完毕,注意测试时建议先把项目杀死,保证该用户已经离线
点击推送获取推送信息
第一种方法 自己做桥接,实现原生层与flutter层做交互
第二种方法 可以利用先有api 实现原生层给flutter层传递消息
今天主要介绍第二种方法
1.打开原生层 在didFinishLaunchingWithOptions和didReceiveRemoteNotification 方法里调用EMClientWrapper.shared().sendData(toFlutter: userInfo) 方法,把需要传递的数据传到flutter层
didFinishLaunchingWithOptions 是在app没有打开的情况下点击推送,从launchOptions里面拿到推送信息
didReceiveRemoteNotification是在 app已经打开的情况下点击推送,从userInfo里面拿到推送信息
注意:EMClientWrapper.shared().sendData 这个方法填的参数必须是一个字典
如下图所示
2.打开flutter层 调用EMClient.getInstance.customEventHandler方法 需要赋值一个函数,这个函数就是接受来自原生层传递过来的消息
3.此时 点击推送消息 在flutter层就能获取到信息,如图我测试的结果
完毕
Flutter 弹性布局的基石 是 Flex 和 Flexible。理解了这两个 widget,后面的 Row,Column 就都轻而易举了。本文用示例的方式详细介绍 Flex 的布局算法。
小写字母开头的 flex 是指 Flexible 的 属性 flex。
先布局 flex 为 0 或 null 的 child。在 main 轴上 child 受到的约束是 unbounded。如果 crossAxisAlignment 是 CrossAxisAlignment.stretch, 在 cross 轴上的约束是 tight,值是 cross 轴上约束的最大值。否则,在 cross 轴上的约束是 loose。
为 flex 不为 0 的 child 申请空间,flex 值越大,按比例得到的可以占用的空间越大。
为 flex 不为 0 的 child 分配空间。main 轴方向的最大值是第二步申请到的空间的值。如果 child 的 fit 参数为 FlexFit.tight,child 在主轴方向 受到 tight 约束,值为第二步申请到的空间的值。如果 child 的 fit 参数为 FlexFit.loose,child 在主轴方向 受到 loose 约束。child 在主轴方向可以任意小,但不能超第二步申请到的空间的值。
Flex cross 轴的高度是能包住所有 child,并不超过最大约束。
Flex main 轴的宽度与 mainAxisSize 有关。如果 mainAxisSize 是 MainAxisSize.max,main 轴的宽度是最大约束值,否则是能包住所有 child ,但不超过最大约束。
Flex 自己的尺寸和 child 的尺寸确认后,根据 mainAxisAlignment 和 crossAxisAlignment 摆放 child。
看了算法并不直观,下面通过实例讲解。
Flex(
direction: Axis.horizontal,
children: [
Container(
width: 1000,
height: 100,
color: Colors.red[200],
),
],
)
我们看到,Flex 在主轴的约束是 unbounded,所以 container 可以取值 1000,超出屏幕,显示警告。
Flex(
direction: Axis.horizontal,
children: [
Flexible(flex:2 ,child: Container(width: 50,height: 80,color: Colors.green,),),
Flexible(flex:1, child: Container(width: 100,height: 50,color: Colors.blue[300],),),
Container(width: 50,height: 100,color: Colors.red[200],
),
],
)
假设宽一共 200,布局过程:
如果绿色块的 fit 值修改为 FlexFit.tight,剩下的空间就会被占满了,这个时候 width 会被忽略。
Flexible 的作用就是为了修改 child 的 parentData,给 child 增加 fit, flex 布局信息。让 Flex 根据这些信息为 child 布局。
class Expanded extends Flexible {
const Expanded({
super.key,
super.flex,
required super.child,
}) : super(fit: FlexFit.tight);
}
Expanded 其实就是 fit 固定为 FlexFit.tight 的 Flexible。其实可以直接用 Flexible 的,但因为 Expanded 太常用了,所以单独加了一个类。同时 Expanded 也更加有语义。Expanded 和 Flexible 的关系就像 Center 和 Align的一样。
class Spacer extends StatelessWidget {
const Spacer({super.key, this.flex = 1})
: assert(flex != null),
assert(flex > 0);
final int flex;
@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: const SizedBox.shrink(),
);
}
}
Spacer 的 child 是 SizedBox.shrink(),用来占位,没有实际的意义。Spacer 是 Expanded 的包装,就是为了占空位用的。
至于摆放 child 的规则大同小异,如果有不明白的同学可以看 这篇 Flutter Wrap 图例
Flex 和 Flexible 如果都掌握了,Row 和 Colmn 自然就会了。因为 Row 只是 direction 为 Axis.horizontal 的 Flex,Column 只是 direction 为 Axis.vertical 的 Flex。
ModalBarrier 是一个蒙层控件,可以对他后面的 UI 进行遮挡,阻止用户和后面的 UI 发生交互。
在实现上,核心代码是 是一个 ConstrainedBox 包了一个 ColoredBox 。ConstrainedBox 的作用是让 ModalBarrier 拥有允许范围内的最大尺寸。ColoredBox 就是画一个背景色。
ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: color == null ? null : ColoredBox(
color: color!,
),
),
参数主要有 3 个 color 设置背景色,dismissible 决定点击的时候是否隐藏,onDismiss 是一个回调,当隐藏的时候调用。
使用 ModalBarrier 需要 用到 Stack,下面是一个例子。开始的时候 ModalBarrier 不显示,点击按钮的时候,显示 ModalBarrier,过几秒后自动消失。显示 ModalBarrier 的时候,尝试点击按钮,是点不到的,因为已经被 ModalBarrier 遮挡了。
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
var showBarrier = false;
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 300,
height: 300,
child: Stack(
alignment: Alignment.center,
children: [
ElevatedButton(
onPressed: (() async {
setState(() {
showBarrier = true;
});
await Future.delayed(Duration(seconds: 5));
setState(() {
showBarrier = false;
});
}),
child: Text('显示 barrier')),
if (showBarrier)
ModalBarrier(
color: Colors.black38,
),
],
));
}
}
在这个例子中,你点遮罩,遮罩 是不消失的。因为让遮罩消失执行的代码是 Navigator.maybePop(context)
。我们并没有 push ,所以 pop 也就没有反应了。
一般来说,如果想要全屏遮罩,直接用 Dialog 为好。想部分遮罩,才需要直接用 ModalBarrier,这个时候自己控制显隐。
if (onDismiss != null) {
onDismiss!();
} else {
Navigator.maybePop(context);
}
}
ModalBarrier 源码的逻辑是这样的,所以我们可以添加 onDismiss 回调,在回调函数里隐藏遮罩,这样就不会再走 Navigator.maybePop(context);
了。
ModalBarrier(
dismissible: true,
onDismiss: () {
setState(() {
showBarrier = false;
});
},
color: Colors.black38,
);
大家好,我是 17。
在 Flutter 中,涉及到滚动布局的时候,很多同学会大量使用 ListView。
没错,在实现效果的方面 ListView 确实能做到大多数,但是有些情况下会很别扭,性能也不好。你可能遇到过下面的设计:
banner 和下面的列表是一起滚动的。如果用 ListView ,你一定可以马上写出代码:
ListView.builder(
itemBuilder: (context, index) {
if (index == 0) {
return Container(
height: 100,
color: Colors.blue,
child: Text('banner'),
);
} else {
return ListTile(title: Text('${index - 1}'));
}
},
itemCount: 100
)
上面的代码会有一个问题,banner 的高度和下面列表的高度不一样,导致无法使用定高列表,造成性能下降。需要 if else,如果有多个 banner,if else 也要多个,那就相当复杂了。
还有一个问题,在你没有设置任何边距的情况下,ListView 和上面的 Widget 可能会一段空白。
你需要这样去除空白。
ListView.builder(
padding: EdgeInsets.zero,
为什么会有空白呢?这是因为 ListView 继承自 BoxScrollView,它的主要贡献就是加了这个空白!
这个空白的值是多少呢?就是取的 mediaQuery 的 padding。因为浏海屏的出现,ios 中,上面和下面会有一部分不适合显示主要内容,所以就有了这个安全 padding。BoxScrollView 在设计的时候也考虑到了这一点,于是就默认加了这个 padding。但实际上,如果 listView 不是在最顶部,反而是帮了倒忙。
ListView 最理想的使用场景是展示的 item 都一样高,但多数情况下,item 是不一样高的。ListView 出现的目的是为了方便使用,但却是牺牲了灵活性。它只能有一个 SliverChild,这会导致 itemBuilder 函数逻辑的复杂和性能的下降。
其实我们可以直接从 ScrollView 继承,根据实际情况定制需要的组件。说到定制你可能会觉得一定很复杂,实际上是非常简单的,而且因为我们是根据业务量身定做的组件,所以用起来会特别顺手。
要用 ScrollView 实现上面的设计,只需要下面的代码:
class MyListView extends ScrollView {
const MyListView(
{Key? key,
this.banner,
required this.itemBuilder,
required this.itemExtent,
required this.itemCount})
: super(key: key);
final Widget? banner;
final IndexedWidgetBuilder itemBuilder;
final double itemExtent;
final int itemCount;
@override
List<Widget> buildSlivers(BuildContext context) {
List<Widget> list = [];
if (banner != null) {
list.add(SliverToBoxAdapter(child: banner!));
}
list.add(SliverFixedExtentList(
delegate: SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
itemExtent: itemExtent,
));
return list;
}
}
很简单吧。实际上,我们只是 override buildSlivers 方法,生成一个 list。SliverToBoxAdapter 可以看作是一个转换器,把普通的 Widget 转换为 Sliver Widget。虽然 buildSlivers 的返回值是 List<Widget>
,但实际上,Widget 应该是 Sliver Widget,否则无法滚动。
MyListView 使用起来也很方便,代码更简洁,没有了讨厌的 if else 了。
MyListView(
banner: Container(color: Colors.green, height: 100),
itemExtent: 20,
itemCount: 100,
itemBuilder: (context, index) => Text('$index'),
)
现在 banner 和 item 的逻辑是分开的,代码更加清晰,也更好维护。把 banner 这个高度不一样的 widget 分开后,剩下的 item 高度都是一样的,本例中,我们设置固定高度 itemExtent: 20
,每个 item 的高度都是 20,在 buildSlivers 中用 itemExtent 做为参数,用 SliverFixedExtentList 生成定高列表,性能得到大大提高。
这个需求还是很常见的,在某个时刻,需要把某条数据显示在第一的位置。如果用 ListView 实现起来不容易,你可能想要调整数据的位置,但需求是数据的位置不变,只是想让 ViewPort 滚动到 第 10 条数据的位置。你可能还想到了用 ListView 的 controller 来控制滚动位置,尝试一下可以知道并不方便实现,或者实现了也不方便维护。
直接用 ScrollView 就很简单了。 ScrollView 有一个参数可以直接实现在这样的功能,这个参数就是 center
。你可能很奇怪,ListView 是从 BoxScrollView 继承,BoxScrollView 是从 ScrollView 继承,但是在 ListView 中没有发现这个参数啊?为了方便使用,BoxScrollView 只有一个 Sliver Child,center 参数没有了用武之地,在 ListView 中找不到这个参数也就不奇怪了。
先看下效果,不使用 center 参数,banner 在第一个位置显示。
使用 center 参数后,第 10 条数据,自动显示在第一个位置。
下面是完整代码,贴到 main.dart 就能运行
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(body: MyWidget()),
);
}
}
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: MyListView(
banner: Container(
color: Colors.blue[100],
alignment: Alignment.center,
height: 100,
child: const Text(
'IAM17 Flutter 天天更新',
),
),
itemBuilder: (context, index) {
return ListTile(
title: Text('$index'),
);
},
center: const ValueKey(9),
itemExtent: 20,
itemCount: 100));
}
}
class MyListView extends ScrollView {
const MyListView(
{Key? key,
this.banner,
required this.itemBuilder,
required this.itemExtent,
required this.itemCount,
Key? center})
: super(key: key, center: center);
final Widget? banner;
final IndexedWidgetBuilder itemBuilder;
final double itemExtent;
final int itemCount;
@override
List<Widget> buildSlivers(BuildContext context) {
List<Widget> list = [];
if (banner != null) {
list.add(SliverToBoxAdapter(child: banner!));
}
if (center == null) {
list.add(SliverFixedExtentList(
delegate:
SliverChildBuilderDelegate(itemBuilder, childCount: itemCount),
itemExtent: itemExtent,
));
} else {
for (var i = 0; i < itemCount; i++) {
list.add(SliverToBoxAdapter(
key: ValueKey(i),
child: itemBuilder(context, i),
));
}
}
return list;
}
}
当 center 不为 null 的时候,放弃使用 SliverFixedExtentList,只能把 child 一个一个加到 list 中。这样会损失一些性能,但能快速实现需求,还是值得的。
在 ViewPort 的构造函数中有一个 assert,如果 center 不为空,那么在 slivers 中必须要找到 key 为 center 的 child。
Viewport({
...
this.center,
...
}) :
assert(center == null || slivers.where((Widget child) => child.key == center).length == 1),
最终是给 ViewPort 对应的 renderObject 的 center 赋值。
代码位置 : flutter/lib/src/widgets/viewport.dart
void _updateCenter() {
// TODO(ianh): cache the keys to make this faster
final Viewport viewport = widget as Viewport;
if (viewport.center != null) {
int elementIndex = 0;
for (final Element e in children) {
if (e.widget.key == viewport.center) {
renderObject.center = e.renderObject as RenderSliver?;
break;
}
elementIndex++;
}
assert(elementIndex < children.length);
_centerSlotIndex = elementIndex;
} else if (children.isNotEmpty) {
renderObject.center = children.first.renderObject as RenderSliver?;
_centerSlotIndex = 0;
} else {
renderObject.center = null;
_centerSlotIndex = null;
}
}
总之,就是通过 key 找到对应的 Sliver Widget,对应到 renderObject,实现 center 的功能。
通过这个简单的案例说明,我们应该自己动手定制适合自己项目的 ”ListView“!通过简单的封装,就能让我们的代码更简洁,更容易维护,性能也会更好。
更多关于滚动的参数介绍可以看这篇 flutter 滚动的基石 Scrollable。
回答下 @法的空间 提的问题:CustomScrollView 的意义何在?
BoxScrollView 和 CustomScrollView 都是 ScrollView 的 子类。BoxScrollView 只能创建一块滑动内容,CustomScrollView 可以支持滑动列表,这就是 CustomScrollView 的意义。
之所以没有直接用 CustomScrollView ,而是直接从 ScrollView 继承是为了可以把一些属性和滑动列表一起封装起来,方便使用。
如果代码不需要复用,直接用 CustomScrollView 也是可以的,而且也是最简单的方式。
CustomScrollView 的代码就一句:
@override
List<Widget> buildSlivers(BuildContext context) => slivers;
ScrollView 是抽象类,不能直接用,CustomScrollView 的意义在于:我们不需要每次都要 extends 一个类出来,用 CustomScrollView 就可以支持滑动列表。
希望已经解答了你的问题,谢谢提问!
张大胖走出公司的时候,已经将近半夜。
此时天上的月亮仍旧散发着清冷的幽光,无情地审视着大地。
最近公司业务繁忙,大批技术人员都被迫加班到很晚,张大胖正是其中之一。
在公司门口趴活的出租车排成了长龙,连出租车司机都知道,这个时候是园区的下班高峰,最容易拉活儿。
张大胖上了车,熟练地从背包中掏出了笔记本电脑。
“明天晚上有个面试,得抓紧时间再补补技术基础。” 张大胖心想。
张大胖没有注意到,前方的司机瞟了一眼后视镜,微微叹了一口气。
司机也是个胖子, 年龄看起来要比张大胖大个10岁左右,若隐若现的发际线都无意间展露出中年人的危机。
张大胖今天复习的是CPU缓存, 他一边看着电脑上的图,一边回忆之前的知识,喃喃自语:
“CPU 缓存「Cache」指的访问速度比一般内存快得多的高速存储器,主要是为了解决 CPU 运算速率与内存读写速率不匹配的问题。..... ”
张大胖发现由于连续加班熬夜,自己的脑子有点儿不太好使了。
“小伙子,你是搞计算机的吧, 太晚了,别再用功了,身体要紧!” 前座传来一声沧桑的话语。
“多谢师傅,明天晚上是四十大盗公司的面试,我要奋斗一下,努力进大厂!”
“进了大厂又如何?你看人家把面试安排在晚上,说明了什么?”
“唉!说明都在加班啊!” , 张大胖略微有点儿诧异,这位司机似乎懂点儿IT行业。
“不过,只要钱多,累点儿也值,像我这种没有背景的人,想在大城市立足,不吃苦是不可能啊。” 张大胖补充道。
司机沉默了, 稳稳地握着方向盘,眼光眺向远方。
张大胖的双眼也被笔记本电脑中的图给吸引了。
沉默了一会儿,司机突然开口:“对了,我有个亲戚,在一个创业公司写代码,叫什么舞动来着,发展势头很好,正在招人,你愿不愿意去啊?”
他扭过头来,两眼放光补充道:“如果公司上市,你就财务自由了!”
“师傅,小心前面的车,” 张大胖看他居然不看路了,马上提醒道。
“舞什么动啊?创业公司九死一生,风险太大了。” 张大胖一心求稳,想进大公司。
“年轻人,不冒险太可惜了!”前座的师傅无奈地摇了摇头。
张大胖心想,我凭什么相信你呢,你说财务自由就能自由?还有可能浪费掉我的青春呢!
“对了,我听说现在有个什么比特币,你们程序员应该懂,可以买一点儿啊!”
张大胖咧嘴笑了,没想到这位师傅信息挺灵通,连比特币都知道。
“师傅你可能不懂,比特币我研究过,什么Hash,什么去中心化,都是数字游戏而已,没有任何价值!”
“哎呀,现在好像几毛钱一个,你可以花个百十块,买几千个比特币玩玩不就行了!囤几年肯定涨价!” 听到张大胖不屑一顾,师傅似乎有点着急。
“哈哈,那还不如我和同学吃一顿自助!”
张大胖说完,就低头又去看CPU缓存去了。
车行驶了十分钟, 师傅又幽幽地说到:“小伙子,你炒股不?我可以给你推荐几个潜力股,比如腾讯,阿里,茅台,格力,微软,苹果...... ”
“师傅你好厉害啊,炒股都炒到美国去了,不过我不炒股,每天心惊肉跳的,实在受不了!”
“这些都是潜力股,你可以长期持有,收益绝对在几十倍以上,以后就不用这么辛苦了!”
张大胖笑了笑,心说这个北京的司机师傅可真会吹牛,股市中七亏二平一赚,自己可当不了那个幸运儿。
他又把思路拉回到CPU缓存中,开始复习地址映像的三种方法,直接映像,全相联映像和组相联映像。
“那你买房子吗?现在北京的房子正好处于的最低点,北京作为超级大都市,将来的房价会像香港那样,10几万一平。”
“师傅您说笑了,现在几万一平我们年轻人都买不起了,还十几万,到时候卖个谁去?”
“吱---” 突然一个急刹车,把张大胖吓了一跳。
司机师傅打开车门,一把就把张大胖拖了出来,揪住张大胖的衣领吼道:“我真TMD想揍你一顿,我给你指了好几条光明大道你不走,为什么非要去挤那独木桥?!”
张大胖愣了一下:“师傅,我们俩似乎没啥关系吧...... 小心我那16寸的顶配MacBook Pro,很贵的......”
出租车师傅的脸色慢慢缓和,深深地叹了一口气:“唉,我可真傻!”
他松开了张大胖,回到车上继续开车。
现已经能够看到小区耸立的高楼。一栋栋楼盘,亮着灯的已经不多。
惊魂未定张大胖开始收拾东西,准备下车。
“小伙子,坚定地学习技术确实难能可贵,坚持下来必定有所收获,但是我想给你几个建议:
不能只盯着技术,还要搞定业务,让技术为业务服务,一定要产生业务价值。
你要想方设法地增加技术影响力。
除了技术之外,要再发展一个领域,形成交叉优势
.....”
张大胖心想,这师傅真是啰嗦,他不客气地把师傅打断:“谢谢师傅,这些道理我在码农翻身公众号看了很多了,我会遵照执行的。”
“还有啊,要拥抱不确定性,多去尝试一些投入低,可能有巨大回报的事情,比如.....”
“多谢师傅关心!” 张大胖确实有点不耐烦了。
司机师傅送给张大胖一张名片:“以后可以联系我啊!”
张大胖看都没看,随手扔到了包里,付款下车。
身后传来了师傅的喊声:“CPU缓存这一块儿最常考的是LRU算法,面试的时候要手写......”
张大胖头也不回,快步回家,逃离了这个啰嗦的“唐僧”。
第二天晚上,四十大盗公司的面试,面试官果然如同出租车司机预料的那样,要手写LRU算法。
张大胖准备充分,顺利通过。
张大胖回到家,赶紧翻出那张名片,只见上面写着三个大字:张大胖。
还有一行小字:我是十年以后的你
张大胖大为震惊,他拼命地回想昨晚和出租车司机谈话的内容,却如同做了一场梦,什么都想不起来了......
来源:mp.weixin.qq.com/s/Y4xHuLfd7U4s4wpn1H3vWw
收起阅读 »目前大多数PC端应用都有配套的移动端APP,如微信,淘宝等,通过使用手机APP上的扫一扫功能去扫页面二维码图片进行登录,使得用户登录操作更方便,安全,快捷。
扫码登录功能涉及到网页端、服务器和手机端,三端之间交互大致步骤如下:
网页端展示二维码,同时不断的向服务端发送请求询问该二维码的状态;
手机端扫描二维码,读取二维码成功后,跳转至确认登录页,若用户确认登录,则服务器修改二维码状态,并返回用户登录信息;
网页端收到服务器端二维码状态改变,则跳转登录后页面;
若超过一定时间用户未操作,网页端二维码失效,需要重新刷新生成新的二维码。
二维码内容是一段字符串,可以使用uuid 作为二维码的唯一标识;
使用qrcode插件 import QRCode from 'qrcode'; 把uuid变为二维码展示给用户
import {v4 as uuidv4} from "uuid"
import QRCode from "qrcodejs2"
let timeStamp = new Date().getTime() // 生成时间戳,用于后台校验有效期
let uuid = uuidv4()
let content = `uid=${uid}&timeStamp=${timeStamp}`
this.$nextTick(()=> {
const qrcode = new QRCode(this.$refs.qrcode, {
text: content,
width: 180,
height: 180,
colorDark: "#333333",
colorlight: "#ffffff",
correctLevel: QRCode.correctLevel.H,
render: "canvas"
})
qrcode._el.title = ''
使用前端计时器setInterval, 初始化有效时间effectiveTime, 倒计时失效后重新刷新二维码
export default {
name: "qrCode",
data() {
return {
codeStatus: 1, // 1- 未扫码 2-扫码通过 3-过期
effectiveTime: 30, // 有效时间
qrCodeTimer: null // 有效时长计时器
uid: '',
time: ''
};
},
methods: {
// 轮询获取二维码状态
getQcodeStatus() {
if(!this.qsCodeTimer) {
this.qrCodeTimer = setInterval(()=> {
// 二维码过期
if(this.effectiveTime <=0) {
this.codeStatus = 3
clearInterval(this.qsCodeTimer)
this.qsCodeTimer = null
return
}
this.effectiveTime--
}, 1000)
}
},
// 刷新二维码
refreshCode() {
this.codeStatus = 1
this.effectiveTime = 30
this.qsCodeTimer = null
this.generateORCode()
}
},
前端向服务端发送二维码状态查询请求,通常使用轮询的方式
定时轮询:间隔1s 或特定时段发送请求,通过调用setInterval(), clearInterval()来停止;
长轮询:前端判断接收到的返回结果,若二维码仍未被扫描,则会继续发送查询请求,直至状态发生变化(失效或扫码成功)
Websocket:前端在生成二维码后,会与后端建立连接,一旦后端发现二维码状态变化,可直接通过建立的连接主动推送信息给前端。
使用长轮询实现:
// 获取后台状态
async checkQRcodeStatus() {
const res = await checkQRcode({
uid: this.uid,
time: this.time
})
if(res && res.code == 200) {
let codeStatus - res.codeStatus
this.codeStatus = codeStatus
let loginData = res.loginData
switch(codeStatus) {
case 3:
console.log("二维码过期")
clearInterval(this.qsCodeTimer)
this.qsCodeTimer = null
this.effectiveTime = 0
break;
case 2:
console.log("扫码通过")
clearInterval(this.qsCodeTimer)
this.qsCodeTimer = null
this.$emit("login", loginData)
break;
case 1:
console.log("未扫码")
this.effectiveTime > 0 && this.checkQRcodeStatus()
break;
default:
break;
}
}
},
参考资料:
作者:前端碎碎念
来源:juejin.cn/post/7179821690686275621
消息推送在现在的App中已经十分常见,我们经常会收到不同App的各种消息。消息推送的实现,国内与海外发行的App需要考虑不同的方案。国内发行的App,常见的有可以聚合各手机厂商推送功能的极光、个推等,海外发行的App肯定是直接使用Firebase Cloud Message(FCM)。
下面介绍下如何接入FCM与发送通知。
FCM的SDK不包含创建和发送通知的功能,这部分需要我们自己实现。
Android 13 引入了用于显示通知的新运行时权限。这会影响在 Android 13 或更高版本上运行的所有使用 FCM 通知的应用。需要动态申请POST_NOTIFICATIONS
权限后才能推送通知,代码如下:
class ExampleActivity : AppCompatActivity() {
private val requestPermissionCode = this.hashCode()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
// 申请通知权限
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), requestPermissionCode)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == requestPermissionCode) {
// 处理回调结果
}
}
}
从 Android 8.0(API 级别 26)开始,必须为所有通知分配渠道,否则通知将不会显示。通过将通知归类到不同的渠道中,用户可以停用您应用的特定通知渠道(而非停用您的所有通知),还可以控制每个渠道的视觉和听觉选项。
创建通知渠道代码如下:
class ExampleActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val notificationManager = NotificationManagerCompat.from(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val applicationInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getApplicationInfo(packageName, PackageManager.ApplicationInfoFlags.of(0))
} else {
packageManager.getApplicationInfo(packageName, 0)
}
val appLabel = getText(applicationInfo.labelRes)
val exampleNotificationChannel = NotificationChannel("example_notification_channel", "$appLabel Notification Channel", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "The description of this notification channel"
}
notificationManager.createNotificationChannel(minigameChannel)
}
}
}
创建与发送通知,代码如下:
class ExampleActivity : AppCompatActivity() {
private var notificationId = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate()
val notificationManager = NotificationManagerCompat.from(this)
...
if (notificationManager.areNotificationsEnabled()) {
val notification = NotificationCompat.Builder(this, "example_notification_channel")
//设置小图标
.setSmallIcon(R.drawable.notification)
// 设置通知标题
.setContentTitle("title")
// 设置通知内容
.setContentText("content")
// 设置是否自动取消
.setAutoCancel(true)
// 设置通知声音
.setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
// 设置点击的事件
.setContentIntent(PendingIntent.getActivity(this, requestCode, packageManager.getLaunchIntentForPackage(packageName)?.apply { putExtra("routes", "From notification") }, PendingIntent.FLAG_IMMUTABLE))
.build()
// notificationId可以记录下来
// 可以通过notificationId对通知进行相应的操作
notificationManager.notify(notificationId, notification)
}
}
}
注意,smallIcon必须设置,否则会导致崩溃。***
Firebase Cloud Message (FCM) 是一种跨平台消息传递解决方案,可让您免费可靠地发送消息。
在项目下的build.gradle中添加如下代码:
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
...
classpath("com.google.gms:google-services:4.3.14")
}
}
在app module下的build.gradle中添加代码,如下:
dependencies {
// 使用Firebase Andorid bom(官方推荐)
implementation platform('com.google.firebase:firebase-bom:31.1.0')
implementation 'com.google.firebase:firebase-messaging'
// 不使用bom
implementation 'com.google.firebase:firebase-messaging:23.1.1'
}
在Firebase后台获取项目的google-services.json文件,放到app目录下
要接收FCM的消息推送,需要自定义一个Service继承FirebaseMessagingService,如下:
class ExampleFCMService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
// FCM生成的令牌,可以用于标识用户的身份
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
// 接收到推送消息时回调此方法
}
在AndroidManifest中注册Service,如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<service
android:name="com.minigame.fcmnotificationsdk.MinigameFCMService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
当App处于不活跃状态时,如果收到通知,FCM会使用默认的图标与颜色来展示通知,如果需要更改的话,可以在AndroidManifest中通过meta-data进行配置,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!--修改默认图标-->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/notification" />
<!--修改默认颜色-->
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/color_blue_0083ff" />
</application>
</manifest>
修改前:
修改后:
如果有特殊的需求,不希望FCM自动初始化,可以通过在AndroidManifest中配置meta-data来实现,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<!--如果同时引入了谷歌分析,需要配置此参数-->
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false" />
</application>
</manifest>
需要重新启动FCM自动初始化时,更改FirebaseMessaging
的isAutoInitEnabled
的属性,代码如下:
FirebaseMessaging.getInstance().isAutoInitEnabled = true
// 如果同时禁止了Google Analytics,需要配置如下代码
FirebaseAnalytics.getInstance(context).setAnalyticsCollectionEnabled(true)
调用此代码后,下次App启动时FCM会自动初始化。
在Firebase后台中,选择Messageing,并点击制作首个宣传活动,如图:
选择Firebase 通知消息,如图:
输入标题和内容后,点击发送测试消息,如图:
输入在FirebaseMessagingService的onNewToken方法中获取到的token,并点击测试,如图:
已整合到demo中。
效果如图:
作者:ChenYhong
来源:juejin.cn/post/7180616999695810597
拓展:
上文提到的用户注册模式是什么
据了解,环信的用户注册模式分为两种,一种是授权注册,一种是开放注册,这两种注册模式在即时通讯>服务概览>设置>用户注册模式可以看到,但是这两种注册模式有什么区别呢?
以下是环信文档对于开放注册和授权注册的解释,文档地址:http://docs-im-beta.easemob.com/document/server-side/account_system.html#%E5%BC%80%E6%94%BE%E6%B3%A8%E5%86%8C%E5%8D%95%E4%B8%AA%E7%94%A8%E6%88%B7
通俗解释就是授权注册比开放注册增加了token认证,授权注册更安全,但是如果在端上启用授权注册会比较麻烦,还需要自己封装请求,我这边建议大家注册还是交给后端同事来搞吧~~~~
最近我在负责一段代码库,需要在使用 Flow
的 Data 层和仍然依赖 LiveData
暴露 State 数据的 UI 层之间实现桥接。好在 androidx.lifecycle
框架已经提供了一个叫做 asLiveData()
的方法,可以让你毫不费力地将 Flow
转为 LiveData
。
然而使用这种方式得到的 LiveData 需要牢记一点:在拥有一个及以上活跃的观察者的条件下,它才会发射数据。假使上游的 flow 产生了更新,但对应的 LiveData 并非活跃的状态,那么它将无法获得最新的数值。
让我通过如下的实例,向你展示我们可能会遇到的这种潜在问题。
我们有一个简单的 Activity,它持有 AAC ViewModel
的实例:
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
该 ViewModel
的实现是这样的:
class MainViewModel : ViewModel() {
private val repository = Repository()
val state: LiveData<Int> = repository.state.asLiveData()
}
它持有一个 Repository 实例,充当琐碎的数据层。
同时 ViewModel
还通过前面提到的 asLiveData()
方法,将 Repository 持有的 StateFlow
转为了 LiveData 并对外暴露了其 State 数据。
Repository 的实现如下:
class Repository {
private val _state = MutableStateFlow(-1)
val state: StateFlow<Int> = _state
suspend fun update() {
_state.emit(Random.nextInt(until = 1000))
}
}
它拥有一个包裹着 Integer 数据(初始值为 -1)的 StateFlow
示例,同时对外提供了一个方法允许外界更新它的 State:从 0 到 1000 之间取得一个新的随机数。
试想一下,假使希望 Activity 创建的时候就能执行这个数据更新。我们可以这么实现:
MainViewModel
内创建一个 init()
来做这个操作onCreate()
里调用该方法 // MainViewModel
fun init() {
// update() is suspending, so we launch a new coroutine here
viewModelScope.launch {
repository.update()
}
}
// MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel.init()
}
这样的话,Activity 创建的时候一个新的协程将被启动,最终会调用 Repository 的 update()
,生成一个随机数并发射到它的 State。
此外,我们可能还需要在 ViewModel
中去发送包含了新生成数值的事件出去。可以在 ViewModel
中添加一个sendAnalyticalEvent()
,这样可以在执行完 Repository 的 update()
之后立即调用它。
// MainViewModel
fun init() {
viewModelScope.launch {
repository.update()
sendAnalyticalEvent() // <-- NEW
}
}
private fun sendAnalyticalEvent() {
// Typically, we would schedule a network request here
val liveDataValue = state.value
val flowValue = repository.state.value
Log.d("Current number in LiveData", "$liveDataValue")
Log.d("Current number in StateFlow", "$flowValue")
}
该方法内,我们可以做些典型的操作,比如向后端服务器发送网络请求。这里,让我们仅仅在 Logcat 里打印来自 LiveData
and Flow
的数值即可。
上面的运行结果相当出乎意料。你可能会争辩道:LiveData
没有获取到最新的数值,是因为没有足够的时间从上游的 flow 中收集数据,不然的话肯定能够拿到正确的数值。
但这个 case 里,不仅仅是 LiveData
获得到的是错误的数值,它获得到的是 null。而且请别忘了,它的存放在 Repository 里的初值是 -1。这只能代表一个意思:这里的 LiveData
压根没有从 StateFlow
里收集任何数据。
原因是我们还没有开始观察这个 LiveData
,它自然会被当作是非活跃的。而且根据 asLiveData()
方法的文档可以知道,在这种情况下 LiveData
不会从上游的 flow 收集任何数据。
asLiveData:Creates a
LiveData
that has values collected from the originFlow
.
上游 flow 数据的收集发生在
LiveData
变成活跃的时候,即LiveData.onActive
。如果 flow 尚未完成,而LiveData
变成了非激活状态,即LiveData.onActive
,那么 flow 的数据收集将在timeoutInMs
参数指定的时间后被取消。除非在超时之前,LiveData
变成活跃状态。
一旦我们开始在 Activity 里观察 LiveData
的数据(因此将促使 LiveData 变成活跃状态),它就能够拥有正确的、最新的数值了。
// MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
viewModel.init()
viewModel.state.observe(this) { // <-- NEW
Log.d("Current number in MainActivity", "$it")
}
}
如下是 Logcat 里新的输出。
上面的示例里,我们采用的是 StateFlow
,但规则同样适用于 SharedFlow
。
而且,情况将更加糟糕,因为当 LiveData
处于非激活状态的时候,任何发送给 SharedFlow
的事件都将永久丢失(默认情况下 SharedFlow
不会将任何数值重新发送给新的订阅者)。
请时刻记住采用 asLiveData()
方法转换 Flow
得到的 LiveData
将会和预期的稍稍不同:它只会在注册了活跃观察者的情况下发射数据。
就我个人而言,这种行为无可厚非:因为我们都还没有观察它、自然不会在意 LiveData
的数值是啥、能不能获取得到。但话说回来,确实存在一些场景,需要在你尚未开始观察的时候,去访问 ViewModel
中 LiveData
的当前数值。
通过阅读这篇文章,我希望你在遇到这种获取不到正确数值的情况时,不要惊讶、心中有数。
Compose是Android官方提供的声明式UI开发框架,而Compose Multiplatform是由JetBrains 维护的,对于Android开发来说,个人认为学习Jetpack Compose是必须的,因为它会成为Android主流的开发模式,而compose-jb作为一个扩展能力,我们可以有选择的去尝试。今天我们先来了解一下使用compose-jb开发一个桌面端应用的流程。
接下来还会有第二弹,第三弹...
开发Compose for Desktop环境要求主要有两点:
JDK 11或更高版本
IntelliJ IDEA 2020.3 或更高版本(也可以使用AS,这里为了使用IDEA提供的项目模板)
接着我们来一步步体验Compose for Desktop的开发流程。
下载好IDEA后,我们直接新建项目,选择Compose Multipalteform类型,输入项目名称,这里只选择Single platform且平台为Desktop即可。
创建好项目后,来看项目目录结构,目录结构如下图所示。
在配置文件中指定了程序入口为MainKt以及包名、版本号等。MainKt文件代码如下所示。
@Composable
@Preview
fun App() {
var text by remember { mutableStateOf("Hello, World!") }
MaterialTheme {
Button(onClick = {
text = "Hello, Desktop!"
}) {
Text(text)
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
App()
}
}
在MainKt文件中,入口处调用了App()方法,App方法中绘制了一个按钮,运行程序,结果如下图所示。
我们可以看到一个Hello World的桌面端程序就显示出来了。接下来我们来添加一些页面元素。
为了让桌面端程序更“像样子”,我们首先修改桌面程序的标题为“学生管理系统”,这毕竟是我们学生时代最喜欢的名字。代码如下所示:
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "学生管理系统") {
App()
}
}
在App方法中,添加两个输入框分别为学号、密码,添加一个登陆按钮,写法与Android中的Compose一致,代码如下所示。
MaterialTheme {
var name by remember {
mutableStateOf("")
}
var password by remember {
mutableStateOf("")
}
Column {
TextField(name, onValueChange = {
name = it
}, placeholder = {
Text("请输入学号")
})
TextField(password, onValueChange = {
password = it
}, placeholder = {
Text("请输入密码")
})
Button(onClick = {
}) {
Text("登陆")
}
}
}
再次运行程序,页面如下所示。
接着我们再来添加头像显示,我们将下载好的图片资源放在resources目录下
然后使用Image组件将头像显示出来即可,代码如下所示。
Image(
painter = painterResource("photo.png"),
contentDescription = null,
modifier = Modifier.size(width = 100.dp, height = 100.dp)
.clip(CircleShape)
)
再次运行程序,结果如下所示。
当然我们还可以将布局稍微修饰一下,使得布局看起来更好看一些。但这并不是这里的重点。
当我们点击左上角(macOS)的X号时,应用程序就直接退出了,这是因为在Window函数中指定了退出事件,再来看一下这部分代码,如下所示。
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "学生管理系统") {
App()
}
}
接下来我们增加一个确认退出的弹窗提醒。代码如下所示。
fun main() = application {
var windowsOpen by remember {
mutableStateOf(true)
}
var isClose by remember {
mutableStateOf(false)
}
if (windowsOpen) {
Window(onCloseRequest = { isClose = true }, title = "学生管理系统") {
App()
if (isClose) {
Dialog(onCloseRequest = { isClose = false }, title = "确定退出应用程序吗?") {
Row {
Button(onClick = {
windowsOpen = false
}) {
Text("确定")
}
}
}
}
}
}
}
这里我们新增了两个变量windowsOpen、isClose分别用来控制应用程序的Window是否显示与确认弹窗的显示。这部分代码相信使用过Jetpack Compose的都可以看得懂。
运行程序,点击X号,弹出退出确认弹窗,点击确定,应用程序将退出。效果如下图所示。
在 KMM入门 中我们借用「wanandroid」中「每日一问」接口实现了一个网络请求,现在我们将这部分功能移植到Desktop程序中,网络请求框架仍然使用Ktor,当然其实你也可以使用Retrofit,这一点并不重要。
首先添加Ktor的依赖,代码如下所示。
val jvmMain by getting {
dependencies {
implementation(compose.desktop.currentOs)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
val ktorVersion = "2.1.2"
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("io.ktor:ktor-client-android:$ktorVersion")
}
}
添加一个Api接口
object Api {
val dataApi = "https://wanandroid.com/wenda/list/1/json"
}
创建HttpUtil类,用于创建HttpClient对象和获取数据的方法,代码如下所示。
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class HttpUtil {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
/**
* 获取数据
*/
suspend fun getData(): String {
val rockets: DemoReqData =
httpClient.get(Api.dataApi).body()
return "${rockets.data} "
}
}
DemoReqData是接口返回数据对应映射的实体类,这里就不再给出了。
然后我们编写UI,点击按钮开始网络请求,代码如下所示。
Column() {
val scope = rememberCoroutineScope()
var demoReqData by remember { mutableStateOf(DemoReqData()) }
Button(onClick = {
scope.launch {
try {
demoReqData = HttpUtil().getData()
} catch (e: Exception) {
}
}
}) {
Text(text = "请求数据")
}
LazyColumn {
repeat(demoReqData.data?.datas?.size ?: 0) {
item {
Message(demoReqData.data?.datas?.get(it))
}
}
}
}
获取数据后,通过
Message方法
将数据展示出来,这里只将作者与标题内容显示出来,代码如下所示。
@Composable
fun Message(data: DemoReqData.DataBean.DatasBean?) {
Card(
modifier = Modifier
.background(Color.White)
.padding(10.dp)
.fillMaxWidth(), elevation = 10.dp
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(
text = "作者:${data?.author}"
)
Text(text = "${data?.title}")
}
}
}
运行程序,点击“请求数据”,结果如下图所示。
这样我们就实现了一个简单的桌面端数据请求与显示功能。
当然,在Compose For Desktop中还有许多的组件,比如Tooltips、Context Menu等等,这里无法一一介绍,需要我们在使用的时候去实践,我们将在后面的N弹中持续探索...
在实际的Flutter开发中,可以发现编辑器AS会提示在组件之前加上const关键字,
这是因为Flutter2之后,多了一个linter规则,prefer_const_constructors,官方建议首选使用const来实例化常量构造函数。
那const作用是什么?并且在性能方面对整个app有多大的提升?
const 是 constant 的缩写,本意是不变的,不易改变的意思,包括C++、go中都有此关键字,同样的,在Flutter中也是表示不变的意思。具体来看看下面的代码。
Row(
children: [
Image(image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg')),
Text("$_counter")
],
);
这是一个水平布局,内部排列了一个Image和Text,注意这个Text的是有一个动态的值_counter。
为了能够更新_counter,必然要调用setState() 方法。我们都知道,如果调用setState() ,那么整个Row包括Image和Text都会自动递归重建。每调用一次,父widget和子widget都会重建一次,那么在复杂的UI和业务场景下,就加深了app的不稳定性。
这就是为什么在开发中,要尽量在小的范围去使用setState,避免不必要的重建任务。为了优化这个问题,官方就更新出了const关键字,被const修饰的widget,就代表永远不会被重建。
比如在上述代码中Image是不可变的,Text是可变的,那么在Image之间加上const修饰,当调用setState() 时,只会更新Text,Image不会被重新构建。
Row(
children: [
const Image(image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg')),
Text("$_counter")
],
);
DevTools提供了一个查询widget rebuild状态的工具,在 Widget rebuild stats 中勾选 Track widget rebuilds 来查看 widget 的重建信息。重建信息包括 Widget 名字、源码位置、上一帧中重建次数、当前页面中重建次数。
在每个widget之前都有一个小图标,
为了进行const对比,我们以上面代码为例,
Row(
children: [
const Image(image: NetworkImage('https://flutter.github.io/assets-for-api-docs/assets/widgets/owl.jpg')),
Text("$_counter")
],
);
在Image前加上const,Text则不加,当调用setState时,观察两个widget的情况。
清楚的发现,没加const的Image widget前面的圆圈在旋转,则表示Image在重建,且重建次数+1。
关于内存,DevTool同样提供了内存分析工具Memory,接下来结合案例进行分析。
在项目中新建两个类,内部不做额外的动作,
void _buildConstObject(){
const ConstObject();
}
void _buildConstObjectNot(){
ConstObjectNot();
}
其中ConstObject 加上const修饰,ConstObjectNot则不进行修饰,在触发build时,两个对象同时进行1000次的创建,
void _doBuild(){
for(var i = 0; i< 1000;i++){
_buildConstObject();
_buildConstObjectNot();
}
}
打开内存分析工具,可以发现未加Const修饰的ConstObjectNot创建了1000个对象,所占用内存约16k,而加了const的ConstObject则可以忽略不计。
注意这里ConstObjectNot和ConstObject内部是没有做任何widget创建的,如果在实际复杂的项目中,未使用const,内存将成倍增加。
在DevTool中打开performance overlay, 在app顶部就会出现性能图层,这两张图表显示的是应用的耗时信息。如果 UI 产生了卡顿(跳帧),这些图表可以帮助分析应用中卡顿,每一张图表都代表当前线程的最近 300 帧表现。
如上图,第一张图属于raster 线程的性能情况即GPU性能,第二张图显示的UI线程性能表现。
当中垂直的绿色条条代表的是当前帧。每一帧都应该在 1/60 秒(大约 16 ms)内创建并显示。如果有一帧超时(任意图像)而无法显示,就导致了卡顿,图表之一就会展示出来一个红色竖条。如果是在 UI 图表出现了红色竖条,则表明 Dart 代码消耗了大量资源。而如果红色竖条是在 GPU 图表出现的,意味着场景太复杂导致无法快速渲染。
为了验证流畅性,我们开启了一个动画,动画在规定时间内进行重复性的放大缩小动作,且分为两个场景,一个场景是在所有widget以及对象前加上const修饰,另外一个场景则什么都不做,对比查看每帧的耗时。
class AnLogo extends AnimatedWidget {
static final _opacityTween = Tween<double>(begin: 0.1, end: 1.0);
static final _sizeTween = Tween<double>(begin: 0.0, end: 300.0);
const AnLogo({Key? key, required Animation<double> animation})
: super(key: key, listenable: animation);
@override
Widget build(BuildContext context) {
Animation<double> animation1 = listenable as Animation<double>;
return Scaffold(
appBar: AppBar(
title: const Text("动画"),
),
body: Center(
child: Opacity(
opacity: _opacityTween.evaluate(animation1),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 10.0),
height: _sizeTween.evaluate(animation1),
width: _sizeTween.evaluate(animation1),
child: Image.asset("images/ic_1.jpeg"),
),
),
),
);
}
}
no const | const |
---|---|
no const | const |
---|---|
GPU帧率:
GPU | |
---|---|
no const平均最大耗时/帧 | 9.9ms/frame |
const平均最大耗时/帧 | 7.6ms/frame |
UI线程帧率:
UI线程 | |
---|---|
no const平均最大耗时/帧 | 7.8ms/frame |
const平均最大耗时/帧 | 7.1ms/frame |
从实验结果上看,没有加const的GPU帧率平均最大达到9.9ms/帧,而加了const的GPU帧率比之降低了约2.3ms;UI帧率(CPU)加const与不加const相差不大,约0.7ms。
从上面的测试看,不管是内存占用还是流畅性,添加const修饰的性能都是优于未添加const修饰的性能,const减少了组件的重建以及对象的创建,进行flutter开发时,在合适的时机去使用const以减少不必要的开销。
推荐阅读:
作为客户端的开发,工作中经常遇到,后端的同事来帮忙找接口详情。产品经理来询问之前的某些功能的业务逻辑,而这些代码或者逻辑都是前人遗留下来的……没有人知道在哪。那如何快速的找到你想找到的代码位置呢?
双击shift键,页面上有什么就在代码中全局搜索什么,比如标题,按钮名字~找到资源文件布局文件,再进一步搜索用到这些文件的代码位置。
在不方便debug的时候,可以输出一些log,通过查看log的输出,可以明确的看出程序运行时的运行逻辑和变量值。
我们要善于利用AndroidStudio提供的工具,比如profiler。在profiler中可以看到手机中正在运行的Activity的名字,甚至能看到网络请求的详情等等,功能很强大!
在你的Application中注册一个Activity的生命周期监听,
ActivityLifeCycle lifecycleCallbacks = new Application.ActivityLifecycleCallbacks();
registerActivityLifecycleCallbacks(lifecycleCallbacks);
在进入到页面的时候,直接输出页面路径~
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
Log.e(TAG, "onActivityCreated :" + getActivityName(activity));
}
这里讨论的是那些第一时间没有思路不知道如何解决的bug。这些bug有的是因为开发过程中粗心写错变量名,变量值,使用了错误的方法,少执行了方法,之前修改bug时某些地方被遗漏了,或者不小心把不应该改动的地方做了改动。也可能是因为使用的第三方库存在缺陷,也可能是数据问题,接口返回的数据不正确,用户做了意料之外的操作没有被程序正确处理等等。
解决棘手的bug之前,首先要稳定自己的心态。记住,心态很重要。无论这个bug已经造成了线上多么大的影响,你的boss多么着急的催着你解决bug,要有一个平稳的心态才能解决问题,否者,慌慌忙忙紧紧张张的状态下去解决bug,很可能会造成更多的bug!
解决bug的第一步,当然是稳定的复现bug。根据我的经验,如果一个bug可以被稳定的复现,至少它就被解决了70%。
通过观察bug的现象,就可以对bug做个大致的归类或者定位了。是因为数据问题?还是第三方库的问题?还或者是代码的问题?
接着就是debug,看日志等常规操作了~
如果经过上面的操作,你还是一筹莫展,那么请往下看。
如果你真的是一点思路也没有,很可能某些可能造成bug的代码也看不太懂。我建议你做一些改变现状的操作,比如:注掉某些代码,尝试其他的输入数据或者操作。总而言之,就是让bug的现象出现改变! 那么你做的这些操作肯定是对这个bug是有影响的!!!然后再逐步恢复之前注掉的代码,直到恢复某些注掉代码之后,bug的现象恢复了。很有可能这里就是造成bug的位置。bug定位了之后,再去思考解决办法。
在实际的开发过程中,很多问题是通过技术手段解决不了的。可能是业务逻辑就出现了矛盾,也有可能是是因为一些奇奇怪怪的王八的屁股。这类问题要早点发现,早点提出,才能早点解决。有些可能踩红线的问题,作为开发,不要试图通过技术去解决!!!否则可能要去踩缝纫机了~~~
我一直坚信,世界上有更多能力比我强的人。我现在面对的bug也肯定不是只有我面对了。张张嘴问问周围的同事,问问网站上的大神,现在网络这么发达,只要别人解决过的问题,就不是问题。
很多时候的bug可能只是因为你对某些领域不熟悉,去请教那些对这个领域熟悉的人,你的问题对他们来说可能不是问题。
有的时候的bug可能不是bug。提出bug的人可能只是对某些操作或者现象不理解,或者没有达到他们的预期。他们就会提出来,他们觉得现在的程序是有问题的……这个时候可以去尝试解决这个提出bug的人!让他们觉得这不是一个bug。当然你没有这种“解决人”的能力的话,就还是老老实实去解决bug吧~
人的成长在于,遇到了问题,敢于直面问题,解决问题,并让自己今后避免再出现类似的问题!
解决了bug,无论这个bug是自己造成的还是别人造成的。要善于总结,避免日后自己再写出类似的问题。
遇到如何实现不会的功能,内心首先不要着急抗拒。
人总要成长,开发的技能如何成长?总不是像流水线工人那样做些一些“熟练”操作吧?总要走出自己的舒适圈,尝试解决一些问题,突破自己的上限吧~
你要知道,在Android开发这个领域,其实没有什么逾越不了技术壁垒!只要别人家有的,你就可能有!别人家做出来的东西,你就能做出来。这种信心,至少要有的~
一个复杂的功能,通常可以分解成一些简单功能,简单的功能就可以攻克!
那么当你在面对要实现一个复杂功能或者没有接触过的功能开发的时候,你所要做的其实就是分解这个功能,然后处理分解后的小功能,最后再把这些小功能组合回去!
遇到问题,尝试解决,实在不行,就要及时向上级反馈。作为你的上级,他们有责任也有能力帮你解决问题,或者至少给你提供解决问题的一种思路。心态要稳,天塌了有个高的顶着。
工作不是生活的全部,工作只是为了更好的生活!不要让那些无聊的代码影响你的心情影响你的生活!
作者:我是绿色大米呀
来源:juejin.cn/post/7182379138752675898
相信听过DDD的人有很大一部分都不知道这玩意具体是干嘛的,甚至觉得它有那么一些虚无缥缈。原因之一是但凡讲DDD的,都是一堆特别高大上的概念,然后冠之以一堆让人看不懂的解释,。作者曾经在极客时间上买了本DDD实战的电子书,被那些概念一路从头灌到尾,灌得作者头昏脑涨,一本电子书那么多文章愣是没有一点点像样的案例,看到最后也 没明白那本电子书的作者究竟想写啥。原因之二是DDD经常出现在互联网黑话中,如果不能稍微了解一下DDD中的名词,我们一般的程序员甚至都不配和那些说这些黑话的人一起共事。
为了帮助大家更好的理解这种虚无缥缈的概念,也为了更好的减少大家在新词频出的IT行业工作的痛苦,作者尝试用人话来解释下DDD,并且最后会举DDD在不同层面上使用的例子,来帮助大家彻底理解这个所谓的“高大上”的概念。
核心的概念还是必须列的,否则你都不知道DDD的名词有多么恶心,但我会用让你能听懂的话来解释。
DDD中最重要的一个概念,也是黑话中说的最多的,领域指的是特定的业务问题领域,是专门用来确定业务的边界。
有时候一个业务领域可能比较复杂,因此会被分为多个子域,子域分为了如下几种:
核心子域:业务成功的核心竞争力。用人话来说,就是领域中最重要的子域,如果没有它其他的都不成立,比如用户服务这个领域中的用户子域
通用子域:不是核心,但被整个业务系统所使用。在领域这个层面中,这里指的是通用能力,比如通用工具,通用的数据字典、枚举这类(感叹DDD简直恨不得无孔不入)。在整个业务系统这个更高层面上,也会有通用域的存在,指的通用的服务(用户服务、权限服务这类公共服务可以作为通用域)。
支撑子域:不是核心,不被整个系统使用,完成业务的必要能力。
指的是一个领域内,同一个名词必须是同一个意思,即统一交流的术语。比如我们在搞用户中心的时候,用户统一指的就是系统用户,而不能用其他名词来表达,目的是提高沟通的效率以及增加设计的可读性
限界上下文指的是领域的边界,通常来说,在比较高的业务层面上,一个限界上下文之内即一个领域。这里用一张不太好看的图来解释:
指的是领域内的业务事件,比如用户中心中,新增用户,授权,用户修改密码等业务事件。
用最俗的人话解释,就是一堆人坐在一个小会议室中开会,去梳理业务系统都有哪些业务事件。
领域内,子域和子域之间交互的事件,如用户服务中用户和角色交互是为用户分配角色,或者是为角色批量绑定用户,这里的领域事件有两个,一个是“为用户分配角色”,另一个是“为角色批量绑定用户”。
这里可以理解为有着唯一标识符的东西,比如用户实体。
实体的具体化,比如用户实体中的张三和李四。
实体和值对象可以简单的理解成java中类和对象,只不过这里通常需要对应数据实体。
实体和实体之间需要共同协作来让业务运转,比如我们的授权就是给用户分配一个角色,这里涉及到了用户和角色两个实体,这个聚合即是用户和角色的关系。
聚合根是聚合的管理者,即一个聚合中必定是有个聚合根的,通常它也是对外的接口。比如说,在给用户分配角色这个事件中涉及两个实体分别是用户和角色,这时候用户就是聚合根。而当这个业务变成给角色批量绑定用户的时候,聚合根就变成了角色。即使没有这样一个名词,我们也会有这样一个标准,让业务按照既定规则来运行,举个上文中的例子,给用户A绑定角色1,用户为聚合根,这样往后去查看用户拥有的角色,也是以用户的唯一标识来查,即访问聚合必须通过聚合根来访问,这个也就是聚合根的作用。
目前DDD的应用主要是在战略阶段和战术阶段,这两个名词也是非常的不讲人话,所谓的战略阶段,其实就是前期去规划业务如何拆分服务,服务之间如何交互。战术阶段,就是工程上的应用,用工程化做的比较好的java语言举例子,就是把传统的三层架构变成了四层架构甚至是N层架构而已。
这是对于DDD在战略阶段做的事情:假如目前我司有个客服系统,内部的客服人员使用这个系统对外上亿的用户提供了形形色色的服务,同时内部人员觉得我们的客服系统也非常好用,老板觉得我们的系统做的非常好,可以拿出去对外售卖以提高公司的利润,那么这时候问题就来了,客服系统需要怎样去改造,才能够支持对外售卖呢?经过激烈的讨论,大致需求如下:
对外售卖的形式有两种,分别是SaaS模式和私有化部署的模式。
SaaS模式需要新开发较为复杂的基础设施来支持,比如租户管理,用户管理,基于用户购买的权限系统,能够根据购买情况来给予不同租户不同的权限。而私有化的时候,由于客户是打包购买,这时候权限系统就不需要再根据用户购买来判断。
数据同步能力,很多公司原本已经有一套员工管理系统,通常是HR系统或者是ERP,这时候客服系统也有一套员工管理,需要把公司人员一个一个录入进去,非常麻烦,因此需要和公司原有的数据来进行同步。
老板的野心还比较大,希望造出来的这套基础设施可以为公司其他业务系统赋能,能支持其他业务系统对外售卖
在经过比较细致的梳理(DDD管这个叫事件风暴/头脑风暴)之后,我们整理出了主要的业务事件,大致如下:
1、用户可以自行注册租户,也可以由运营在后台为用户开通租户,每个租户内默认有一个超级管理员,租户开通之后默认有系统一个月的试用期,试用期超级管理员即可在管理端进行用户管理,添加子用户,分配一些基本权限,同时子用户可以使用系统的一些基本功能。
2、高级的功能,比如客服中的机器人功能是属于要花钱买的,试用期不具备此权限,用户必须出钱购买。每次购买之后会生成购买订单,订单对应的商品即为高级功能包。
3、权限系统需要能够根据租户购买的功能以及用户拥有的角色来鉴权,如果是私有化,由于客户此时购买的是完整系统,所以此时权限系统仅仅根据用户角色来鉴权即可。
4、基础设施还需要对其他业务系统赋能。
根据上面的业务流程,我们梳理出了下图中的实体
最后再根据实体和实体之间的交互,划分出了用户中心服务以及计费服务,这两个服务是两个通用能力服务,然后又划分出了基于通用服务的业务层,分别是租户管理端和运营后台以及提供给业务接入的应用中心,架构图如下:
基础设施层即为我们要做的东西,为业务应用层提供通用的用户权限能力、以及售卖的能力,同时构建开发者中心、租户控制台以及运营后台三个基础设施应用。
这个是对于DDD在战术设计阶段的运用,以java项目来举例子,现在的搞微服务的,都是把工程分为了主要的三层,即控制层->逻辑层->数据层,但是到了DDD这里,则是多了一层,变成了控制层->逻辑层->领域能力层->数据层。这里一层一层来解释下:
分层 | 描述 |
---|---|
控制层 | 对外暴漏的接口层,举个例子,java工程的controller |
逻辑层 | 主要的业务逻辑层 |
领域能力层 | 模型层,系统的核心,负责表达业务概念,业务状态信息以及业务规则。即包含了该领域(问题域)所有复杂的业务知识抽象和规则定义。 |
数据层 | 操作数据,java中主要是dao层 |
在解释完了各种概念以及举例子之后,我们对DDD是什么有了个大概的认知,相信也是有非常多的争议。作者搞微服务已经搞了多年,也曾经在梳理业务的时候被DDD的各种黑话毒打过,也使用过DDD搞过工程。经历了这么多这方面的实践之后觉得DDD最大的价值其实还是在梳理业务的时候划分清楚业务领域的边界,其核心思想其实还是高内聚低耦合而已。至于工程方面,现在微服务的粒度已经足够细,完全没必要再多这么一层。这多出来的这一层,多少有种没事找事的感觉。更可笑的是,这个概念本身在对外普及自己的东西的时候,玩足了文字游戏,让大家学的一头雾水。真正好的东西,是能够解决问题,并且能够很容易的让人学明白,而不是一昧的造新词去迷惑人,也希望以后互联网行业多一些实干,少说一些黑话。
作者:李少博
来源:juejin.cn/post/7184800180984610873
本文基于 RxJava 和 Retrofit 库,设计并实现了一种用于大文件分块上传的工具,并对其进行了全面的拆解分析。抛砖引玉,对同样有处理文件分块上传诉求的读者,可能会起到一定的启发作用。
文章主体由四部分构成:
另外,在文章提供的完整代码中,还附了一段由 PHP 编写,用来接收多线程分段数据的服务端接口实现,其中处理了因客户端都线程上传片段,导致服务端接收的文件片段无序,故需在适当时机合并分块构成目标文件。
受限于笔者的开发经验与理论理解,文章的思路和代码难免可能有偏颇,对于有改进和优化的部分,欢迎大家讨论区提出。
要完成文件分段上传到服务端,第一步是分段读取本地文件。通常分段是为了多线程同时执行上传,提高设备计算和网络资源利用率,减少上传时间优化体验,这样即需要一个支持多线程的文件分段读取工具。由于文件可能超过设备内存大小,在读取这类超大文件时需要控制最大读取量防止内存溢出。此时文件已从磁盘数据转换为内存中的字节数据,只需要将这些内存数据传给服务端即可。这样问题被分成 3 个子问题:
问题 1 很好解决,利用 Java 的 RandomAccessFile
可对文件的随机读取的特性,即可按需读取文件片段到内存中。问题 2 相对复杂一点,但如果有阅读过 JDK 中线程池源码的读者,就会发现这个问题的和控制线程池中线程数量其实是类似的。问题 3 就不复杂了,Retrofit 基于 OKhttp ,OkHttp是很容易基于字节数组构建 multipart/form-data
请求的。
根据上述对问题 1、2 的拆解,可将读取抽象为一个文件读取器,构建时传入文件对象和分段大小以及最大并发数,以及分段数据的回调。当外部启动读取时将根据文件大小和配置的分段大小构建若干个 Task 用于读取对应片段的数据。
public BlockReader(@NotNull File file, @NotNull BlockCallback callback, int poolSize, int blockSize) {
mFile = file;
mCallback = callback;
mPoolSize = poolSize;
mBlockSize = blockSize;
}
public void start(@Nullable BlockFilter filter) {
Observable.empty().observeOn(Schedulers.computation()).doOnComplete(() -> {
long length = mFile.length();
for (long offset = 0; offset < length; offset += mBlockSize) {
if (null != filter && filter.ignore(offset)) {
continue;
}
mQueue.offer(new ReadTask(offset));
}
for (int i = 0; i < Math.min(mPoolSize, mQueue.size()); i++) {
Observable.empty().observeOn(Schedulers.io()).doOnComplete(this::schedule).subscribe();
}
}).subscribe();
}
多线程调度部分,可通过加锁和记录状态变量统计当前正运行的线程数,则可控制字节数组数,这样就相当于控制住了最大内存占用。
private void schedule() {
if (mRunning.get() >= mPoolSize) {
return;
}
ReadTask task;
synchronized (mQueue) {
if (mRunning.get() >= mPoolSize) {
return;
}
task = mQueue.poll();
if (null != task) {
mRunning.incrementAndGet();
}
}
if (null != task) {
task.run();
}
}
最后是文件随机读取,直接调用 RandomAccessFile
的 API 即可:
private class ReadTask implements Action {
@Override
public void run() {
try (RandomAccessFile raf = new RandomAccessFile(mFile, RAF_MODE);
ByteArrayOutputStream out = new ByteArrayOutputStream(mBlockSize)) {
raf.seek(mOffset);
byte[] buf = new byte[DEF_BLOCK_SIZE];
long cnt = 0;
for (int bytes = raf.read(buf); bytes != -1 && cnt < mBlockSize; bytes = raf.read(buf)) {
out.write(buf, 0, bytes);
cnt += bytes;
}
out.flush();
mCallback.onFinished(mOffset, out.toByteArray());
} catch (IOException e) {
mCallback.onFinished(mOffset, null);
} finally {
mRunning.decrementAndGet();
schedule();
}
}
}
上传部分则使用 Retrofit 提供的注解和 OKHttp 的类库构建请求。但值得一提的是需要在磁盘IO线程同步完成网络IO,这样可以避免网络IO速度落后磁盘IO太多而导致任务堆积造成内存溢出。
public interface BlockUploader {
@POST("test/upload.php")
@Multipart
Single<Response<ResponseBody>> upload(@Header("filename") String filename,
@Header("total") long total,
@Header("offset") long offset,
@Part List<MultipartBody.Part> body);
}
private static void syncUpload(String fileName, long fileLength, long offset, byte[] bytes) {
RequestBody data = RequestBody.create(MediaType.parse("application/octet-stream"), bytes);
MultipartBody body = new MultipartBody.Builder()
.addFormDataPart("file", fileName, data)
.setType(MultipartBody.FORM)
.build();
retrofit.create(BlockUploader.class).upload(fileName, fileLength, offset, body.parts()).subscribe(resp -> {
if (resp.isSuccessful()) {
System.out.println("✓ offset: " + offset + " upload succeed " + resp.code());
} else {
System.out.println("✗ offset: " + offset + " upload failed " + resp.code());
}
}, throwable -> {
System.out.println("! offset: " + offset + " upload failed");
});
}
为控制篇幅,完整代码请移步 Github,服务端部分处理形如:
喜欢户外运动的朋友一般都应该使用过运动APP(keep, 咕咚,悦跑圈,国外的Strava等)的一项功能,就是运动轨迹视频分享,分享到朋友圈或是运动群的圈子里。笔者本身平常也是喜欢户外跑、骑行、爬山等户外运动,也跑过半马、全马,疫情原因之前报的杭州的全马也延期了好几次了。回归正题,本文笔者基于自己的思想实现运动轨迹回放的一套算法策略,实现本身是基于Mapbox地图的,但是其实可以套用在任何地图都可以实现,基本可以脱离地图SDK的API。Mapbox 10 版本之后的官方给出的Demo里已经有类似轨迹回放的Case了,但是深度地依赖地图SDK本身的API,倘若在高德上实现很难可以迁移的。
这里先看下gif动图的效果,这是我在奥森跑的10KM的一个轨迹:
整个的实现包含了轨迹的回放,视频的录制,然后视频的录制这块不再笔者这篇文章的介绍的范畴内。所以这里主要介绍轨迹的回放,这个回放过程其实也是包含了大概10多种动画在里面的,辅助信息距离的文字跳转动画;距离下面配速、运动时间等的flap in 及 out的动画;播放button,底部button的渐变Visibility; 地图的缩放以及视觉角度的变化等;以上的这些也不做讨论。主要介绍轨迹回放、整公里点的显示(起始、结束), 回放过程中窗口控制等,作为主要的讲解范畴。
首先介绍笔者最开始的一种实现,假如以上轨迹List 有一百个点,每相邻的两个点做Animation之后,在AnimationEnd的Listener里开起距离下一个点的Animation,直到所有点结束,这里有个问题每次的运动轨迹的点的数量不一样,所以开起Animation的次数也不一样,整个轨迹回放的时间等于所有的Animation执行的时间和,每次动画启动需要损耗20~30ms。倘若要分享到微信朋友圈,视频的时间是限制的,但之前的那种方式时间上显然不可控,每次动画启动的损耗累加导致视频播放不完。
紧接着换成AnimationSet, 将各个线段Animation的动画放入Set里,然后playSequentially执行,同样存在上面的问题。假如只执行一次动画,那么这次动画start的损耗在整个视频播放上时长上的占比就可以忽略不计了,那如何才能将整个List的回放在一个Animation下执行完呢?假如轨迹只是一个普通的 Path,那么我们就可以基于Path的 length一个属性动画了,当转化到地图运动轨迹上去时,又如何去实现呢?
这里有两套Point体系,一个是View的Path对应的Points, 然后就是Map上的List对应的Points,运动轨迹原始数据是Map上的List 点,上面的第一步就是将Map上的Points 转成屏幕Pixel对应的点并生成Path; 第二部通过PathMeasure 计算Path的Length; 最后在Path Length上做属性动画,然而这里并非将属性动画中每次渐变的值(这里对应的是View的Point点)绘制成View对应的Path,而是将渐变中的点又通过Map的SDK转成地图Location点,绘制地图轨迹。这里一共做了两道转换,中间只是借助View的Path做了一个依仗Length属性做的一个动画。因为基本上每种地图SDK都有Pixel 跟Location Point点互相transform的API,所以这个可以直接迁移到其它地图上,例如高德地图等。
下面具体看下代码,先将Location 转成View的Point体系,这里保存了总的一个Path,以及List 中两两相邻点对应的分段Path的一个list.
其中用到 Mapbox地图API Location 点转View的PointF 接口API toScreenLocation(LatLng latlng), 这里生成List, 然后计算得到Path.
首先创建属性动画的 Instance:
ValueAnimator.ofObject(new DstPathEvaluator(), 0, mPathMeasure.getLength());
将每次渐变的值经过 calculateAnimPathData(value) 计算后存入到 以下的四个变量中,这里除了Length的渐变值,还附带有角度的一个二元组值。
dstPathEndPoint[0] = 0;//x坐标
dstPathEndPoint[1] = 0;//y坐标
dstPathTan[0] = 0;//角度值
dstPathTan[1] = 0;//角度值
然后将dstPathEndPoint 的值转成Mapbox的 Location的 Latlng 经纬度点,
PointF lastPoint = new PointF(dstPathEndPoint[0], dstPathEndPoint[1]);
LatLng lastLatLng = mapboxMap.getProjection().fromScreenLocation(lastPoint);
Point point = Point.fromLngLat(lastLatLng.getLongitude(), lastLatLng.getLatitude());
过滤掉一些动画过程中可能产生的异常点,最后加入到Mapbox的轨迹绘制的Layer中形成轨迹的一个渐变:
Location curLocation = mLocationList.get(animIndex);
float degrees = MapBoxPathUtil.getRotate(curLocation, point);
if (animIndex < 5 || Math.abs(degrees - curRotate) < 5) {//排除异常点
setMarkerRecord(point);
}
setMarkerRecord(point) 方法调用加入到 Map 轨迹的绘制Layer中
动画过程中,当加入到Path中的点超过一定占比时,做了一个窗口显示的动画,窗口List跟整个List的一个计算:
//这里可以取后半段的数据,滑动窗口,保持 moveCamera 的窗口值不变。
int moveSize = passedPointList.size();
List<LatLng> windowPassList = passedPointList.subList(moveSize - windowLength, moveSize);
接下来看整公里点的绘制,看之前先看下上面的calculateAnimPathData()方法的逻辑
如上,length为当前Path走过的距离,假设轨迹一共100点,当前走到 49 ~ 50 点之间,那么calculateLength就是0到50这个点的Path的长度,它是大于length的,offsetLength = calculateLength - length; 记录的是 当前点到50号点的一个长度offsetLength,animIndex值当前值对应50,recordPathList为一开始提到的跟计算总Path时一个分段Path的List, 获取到49 ~ 50 这个Path对应的一个model.
RecordPathBean recordPathBean = recordPathList.get(animIndex);
获得Path(49 ~ 50) 的长度减去 当前点到 50的Path(cur ~ 50)的到 Path(49 ~ cur) 的长度
float stopD = (float) (pathMeasure.getLength() - offsetLengthCur);
然后最终通过PathMeasure的 getPosTan 获得dstPathEndPoint以及dstPathTan数据。
pathMeasure.getSegment(0, stopD, dstPath, false);
mDstPathMeasure = new PathMeasure(dstPath, false);
//这里有个参数 tan
mDstPathMeasure.getPosTan(mDstPathMeasure.getLength(), dstPathEndPoint, dstPathTan);
原始数据中的List的Location中存储了一个字段kilometer, 当某个Location是整公里点时该字段就有对应的值,每次Path属性渐变时,上面的逻辑里记录了lastAnimIndex, animIndex。当 animIndex > lastAnimIndex时, 上面的calculateAnimPathData() 方法里分析animIndex有可能还没走到,所以在animIndex > lastAnimIndex时lastAnimIndex肯定走到了。
当lastAnimIndex对应的点是 整公里时,做一个响应的属性动画。
至此,运动轨迹回放的一个动画执行逻辑分析完了,如文章开始所说,整个过程中其实还包含了好多种其它的动画,处理它们播放的一个时序问题,如何编排实现等等也是一个难点。另外还就是轨迹播放时的一个Camera的一个视觉跟踪的效果没有实现,这个用地图本身的Camera 的API是一种实现,但是如何跟上面的这些结合到一块;然后就是自行通过计算角度偏移,累计到一定的旋转角度时,转移地图的指南针;以上是笔者想到的方案,以上有计算角度的,但需要找准那个累计的角度值,然后大量实际数据适配。
最后,有需要了解轨迹回放功能其它实现的,可留言或私信笔者进行一起探讨。
重温RxJava2源码,做个简单的记录,本文仅分析事件的发射与消费简单逻辑,从源码角度分析被观察者(上游事件)是如何与观察者(下游事件)进行关联的。
Observable.just(1,2,3)
.subscribe();
Observable.create(new ObservableOnSubscribe<Integer>() {
@Override
public void subscribe(@NonNull ObservableEmitter<Integer> emitter) throws Exception {
emitter.onNext(1);
emitter.onNext(2);
emitter.onNext(3);
}
}).subscribe();
上述两种方式都是由被观察者发出3个事件,交给观察者(下游事件)去处理。这里分析一下Observable.just
与Observable.create
方法的区别
public static <T> Observable<T> just(T item1, T item2, T item3) {
return fromArray(item1, item2, item3);
}
这里将传入的item…
继续传入fromArray
方法
public static <T> Observable<T> fromArray(T... items) {
return RxJavaPlugins.onAssembly(new ObservableFromArray<T>(items));
}
最终将参数传入实例化的ObservableFromArray
对象中,并将该对象返回,此处可先不关注RxJavaPlugins
类,继续探索ObservableFromArray
类都做了什么;
public final class ObservableFromArray<T> extends Observable<T> {
final T[] array;
public ObservableFromArray(T[] array) {
this.array = array;
}
@Override
public void subscribeActual(Observer<? super T> observer) {
FromArrayDisposable<T> d = new FromArrayDisposable<T>(observer, array);
observer.onSubscribe(d);
if (d.fusionMode) {
return;
}
d.run();
}
}
作为Observable
的子类,每个被观察者都要实现自己的subscribeActual
方法,这里才是真正与观察者进行绑定的具体实现,其中实例化了FromArrayDisposable
对象,并将observer
(观察者)与array
传入,方法结尾调用了其run
方法。
void run() {
T[] a = array;
int n = a.length;
for (int i = 0; i < n && !isDisposed(); i++) {
T value = a[i];
if (value == null) {
downstream.onError(new NullPointerException("The element at index " + i + " is null"));
return;
}
downstream.onNext(value);
}
if (!isDisposed()) {
downstream.onComplete();
}
}
可以看到其中对于最初传入的1、2、3,以此进行了onNext
方法的调用,分发结束后调用了onComplete
,事件结束。
首先从上面的实例代码可以看到,create
方法中还需要传入ObservableOnSubscribe
的实例对象,暂且不管,我们来挖掘一下create
方法
public static <T> Observable<T> create(ObservableOnSubscribe<T> source) {
return RxJavaPlugins.onAssembly(new ObservableCreate<T>(source));
}
最终将上述我们创建的ObservableOnSubscribe
对象传入新实例化的ObservableCreate
对象中,并将该对象返回;
public final class ObservableCreate<T> extends Observable<T> {
final ObservableOnSubscribe<T> source;
public ObservableCreate(ObservableOnSubscribe<T> source) {
this.source = source;
}
@Override
protected void subscribeActual(Observer<? super T> observer) {
CreateEmitter<T> parent = new CreateEmitter<T>(observer);
observer.onSubscribe(parent);
try {
source.subscribe(parent);
} catch (Throwable ex) {
Exceptions.throwIfFatal(ex);
parent.onError(ex);
}
}
}
看到在subscribeActual
方法中,创建了CreateEmitter
对象,接着分别调用observer#onSubscribe
方法和source#subscribe
方法,这里要搞清楚其中的3个变量分别是什么
source
:被观察者(上游事件),最初我们create
方法中传入的接口对象,我们就是在source
中进行事件分发的observer
:观察者(下游事件),我们的事件最终交给observer
去处理,这里将observer
传入了CreateEmitter
,就是要在Emitter
中进行中转分发事件给observer
parent
:理解为一个上下游的中转站,上游事件发射后在这里交给下游去处理最后我们看一下CreateEmitter
类中的实现
static final class CreateEmitter<T>
extends AtomicReference<Disposable>
implements ObservableEmitter<T>, Disposable {
private static final long serialVersionUID = -3434801548987643227L;
final Observer<? super T> observer;
CreateEmitter(Observer<? super T> observer) {
this.observer = observer;
}
@Override
public void onNext(T t) {
if (t == null) {
onError(new NullPointerException("onNext called with null. Null values are generally not allowed in 2.x operators and sources."));
return;
}
if (!isDisposed()) {
observer.onNext(t);
}
}
}
这里只贴出了onNext
方法,可以看到当onNext
方法被调用后,其中就会去调用observer
的onNext
方法,而onNext
最初的触发就是在实例代码中我们实例化的ObservableOnSubscribe
其中的subscribe
方法中
...
.subscribe(new Consumer<Integer>() {
@Override
public void accept(Integer integer) throws Exception {
}
});
...
.subscribe(new Observer<Integer>() {
@Override
public void onSubscribe(@NonNull Disposable d) {
}
@Override
public void onNext(@NonNull Integer integer) {
}
@Override
public void onError(@NonNull Throwable e) {
}
@Override
public void onComplete() {
}
});
上述两种方式都是接收被观察者(上游事件)发出的事件,进行处理消费。这里分析一下Consumer
与Observer
的区别
public interface Consumer<T> {
/**
* Consume the given value.
* @param t the value
* @throws Exception on error
*/
void accept(T t) throws Exception;
}
Consumer
仅为一个接口类,其中accept
方法接收事件并消费,我们需要去到上游事件订阅下游事件时的subscribe
方法,根据下游事件的参数类型与数量,会进入不同的subscribe
重载方法中;
subscribe(Consumer<? super T> onNext) : Diposable
public final Disposable subscribe(Consumer<? super T> onNext) {
return subscribe(onNext, Functions.ON_ERROR_MISSING, Functions.EMPTY_ACTION, Functions.emptyConsumer());
}
public final Disposable subscribe(Consumer<? super T> onNext, Consumer<? super Throwable> onError,
Action onComplete, Consumer<? super Disposable> onSubscribe) {
LambdaObserver<T> ls = new LambdaObserver<T>(onNext, onError, onComplete, onSubscribe);
subscribe(ls);
return ls;
}
该方法中包装了一个LambdaObserver
,将我们传入的onNext
方法再传入其中
public final class LambdaObserver<T> extends AtomicReference<Disposable>
implements Observer<T>, Disposable, LambdaConsumerIntrospection {
private static final long serialVersionUID = -7251123623727029452L;
final Consumer<? super T> onNext;
final Consumer<? super Throwable> onError;
final Action onComplete;
final Consumer<? super Disposable> onSubscribe;
public LambdaObserver(Consumer<? super T> onNext, Consumer<? super Throwable> onError,
Action onComplete,
Consumer<? super Disposable> onSubscribe) {
super();
this.onNext = onNext;
this.onError = onError;
this.onComplete = onComplete;
this.onSubscribe = onSubscribe;
}
@Override
public void onNext(T t) {
if (!isDisposed()) {
try {
onNext.accept(t);
} catch (Throwable e) {
Exceptions.throwIfFatal(e);
get().dispose();
onError(e);
}
}
}
可以看到LambdaObserver
实际上就是Observer
的实现类,其中实现了onSubscribe
onNext
onError
onComplete
方法,上述代码中我们看到我们最初的Consumer
对象实际上就是其中的onNext
变量,在LambdaObserver
收到onNext
事件消费时,再将事件交给Consumer
去处理。Consumer
相当于一种简易模式的观察者,根据被观察者的subscribe
订阅方法消费特定的事件(onNext
或onError
等)。
public interface Observer<T> {
void onSubscribe(@NonNull Disposable d);
void onNext(@NonNull T t);
void onError(@NonNull Throwable e);
void onComplete();
}
Observer
是最原始的观察者,是所有Observer
的顶层接口,其中方法为观察者可以消费的四个事件
subscribe(Observer<? super T> observer)
该方法也是其他所有订阅观察者方法最终会进入的方法
public final void subscribe(Observer<? super T> observer) {
ObjectHelper.requireNonNull(observer, "observer is null");
try {
observer = RxJavaPlugins.onSubscribe(this, observer);
subscribeActual(observer);
} catch (NullPointerException e) { // NOPMD
...
} catch (Throwable e) {
...
}
}
最终在subscribeActual
方法中进行被观察者与观察者(上游与下游事件)的绑定。
抛开所有的操作符、线程切换来说,RxJava的上下游事件绑定逻辑还是十分清晰易读的,可以通过源码了解每个事件是如何从上游传递至下游的。至于其他逻辑,另起篇幅分析。
本文只是自己经过研究后,对 Kotlin 协程的理解概括,如有偏差,还请斧正。
简要概括:
协程是 Kotlin 提供的一套线程 API 框架,可以很方便的做线程切换。 而且在不用关心线程调度的情况下,能轻松的做并发编程。也可以说协程就是一种并发设计模式。
下面是使用传统线程和协程执行任务:
Thread{
//执行耗时任务
}.start()
val executors = Executors.newCachedThreadPool()
executors.execute {
//执行耗时任务
}
GlobalScope.launch(Dispatchers.IO) {
//执行耗时任务
}
在实际应用开发中,通常是在主线中去启动子线程执行耗时任务,等耗时任务执行完成,再将结果给主线程,然后刷新UI:
Thread{
//执行耗时任务
runOnMainThread {
//获取耗时任务结果,刷新UI
}
}.start()
val executors = Executors.newCachedThreadPool()
executors.execute {
//执行耗时任务
runOnMainThread {
//获取耗时任务结果,刷新UI
}
}
Observable.unsafeCreate<Unit> {
//执行耗时任务
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe {
//获取耗时任务结果,刷新UI
}
GlobalScope.launch(Dispatchers.Main) {
val result = withContext(Dispatchers.IO){
//执行耗时任务
}
//直接拿到耗时任务结果,刷新UI
refreshUI(result)
}
从上面可以看到,使用Java 的 Thread
和 Executors
都需要手动去处理线程切换,这样的代码不仅不优雅,而且有一个重要问题,那就是要去处理与生命周期相关的上下文判断,这导致逻辑变复杂,而且容易出错。
RxJava 是一套优雅的异步处理框架,代码逻辑简化,可读性和可维护性都很高,很好的帮我们处理线程切换操作。这在 Java 语言环境开发下,是如虎添翼,但是在 Kotlin 语言环境中开发,如今的协程就比 RxJava 更方便,或者说更有优势。
下面看一个 Kotlin 中使用协程的例子:
GlobalScope.launch(Dispatchers.Main) {
Log.d("TestCoroutine", "launch start: ${Thread.currentThread()}")
val numbersTo50Sum = withContext(Dispatchers.IO) {
//在子线程中执行 1-50 的自然数和
Log.d("TestCoroutine", "launch:numbersTo50Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo50 = naturalNumbers.takeWhile { it <= 50 }
numbersTo50.sum()
}
val numbers50To100Sum = withContext(Dispatchers.IO) {
//在子线程中执行 51-100 的自然数和
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
val result = numbersTo50Sum + numbers50To100Sum
Log.d("TestCoroutine", "launch end:result=$result ${Thread.currentThread()}")
}
Log.d("TestCoroutine", "Hello World!,${Thread.currentThread()}")
控制台输出结果:
2023-01-02 16:05:45.846 10153-10153/com.wangjiang.example D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-02 16:05:48.058 10153-10153/com.wangjiang.example D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-02 16:05:48.059 10153-10322/com.wangjiang.example D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-02 16:05:49.114 10153-10322/com.wangjiang.example D/TestCoroutine: launch:numbers50To100Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-02 16:05:50.376 10153-10153/com.wangjiang.example D/TestCoroutine: launch end:result=5050 Thread[main,5,main]
在上面的代码中:
launch
是一个函数,用于创建协程并将其函数主体的执行分派给相应的调度程序。Dispatchers.MAIN
指示此协程应在为 UI 操作预留的主线程上执行。Dispatchers.IO
指示此协程应在为 I/O 操作预留的线程上执行。withContext(Dispatchers.IO)
将协程的执行操作移至一个 I/O 线程。从控制台输出结果中,可以看出在计算 1-50 和 51-100 的自然数和的时候,线程是从主线程(Thread[main,5,main]
)切换到了协程的线程(DefaultDispatcher-worker-1,5,main
),这里计算 1-50 和 51-100 都是同一个子线程。
在这里有一个重要的现象,代码从逻辑上看起来是同步的,并且启动协程执行任务的时候,没有阻塞主线程继续执行相关操作,而且在协程中的异步任务执行完成之后,又自动切回了主线程。这就是 Kotlin 协程给开发做并发编程带来的好处。这也是有个概念的来源: Kotlin 协程同步非阻塞。
同步非阻塞”是真的“同步非阻塞” 吗?下面探究一下其中的猫腻,通过 Android Studio ,查看 .class 文件中的上面一段代码:
BuildersKt.launch$default((CoroutineScope)GlobalScope.INSTANCE, (CoroutineContext)Dispatchers.getMain(), (CoroutineStart)null, (Function2)(new Function2((Continuation)null) {
int I$0;
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var10000;
int numbersTo50Sum;
label17: {
Object var5 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
Function2 var10001;
CoroutineContext var6;
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch start: " + Thread.currentThread());
var6 = (CoroutineContext)Dispatchers.getIO();
var10001 = (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch:numbersTo50Sum: " + Thread.currentThread());
this.label = 1;
if (DelayKt.delay(1000L, this) == var4) {
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
Sequence naturalNumbers = SequencesKt.generateSequence(Boxing.boxInt(0), (Function1)null.INSTANCE);
Sequence numbersTo50 = SequencesKt.takeWhile(naturalNumbers, (Function1)null.INSTANCE);
return Boxing.boxInt(SequencesKt.sumOfInt(numbersTo50));
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
});
this.label = 1;
var10000 = BuildersKt.withContext(var6, var10001, this);
if (var10000 == var5) {
return var5;
}
break;
case 1:
ResultKt.throwOnFailure($result);
var10000 = $result;
break;
case 2:
numbersTo50Sum = this.I$0;
ResultKt.throwOnFailure($result);
var10000 = $result;
break label17;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
numbersTo50Sum = ((Number)var10000).intValue();
var6 = (CoroutineContext)Dispatchers.getIO();
var10001 = (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch:numbers50To100Sum: " + Thread.currentThread());
this.label = 1;
if (DelayKt.delay(1000L, this) == var4) {
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
Sequence naturalNumbers = SequencesKt.generateSequence(Boxing.boxInt(51), (Function1)null.INSTANCE);
Sequence numbers50To100 = SequencesKt.takeWhile(naturalNumbers, (Function1)null.INSTANCE);
return Boxing.boxInt(SequencesKt.sumOfInt(numbers50To100));
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
});
this.I$0 = numbersTo50Sum;
this.label = 2;
var10000 = BuildersKt.withContext(var6, var10001, this);
if (var10000 == var5) {
return var5;
}
}
int numbers50To100Sum = ((Number)var10000).intValue();
int result = numbersTo50Sum + numbers50To100Sum;
Log.d("TestCoroutine", "launch end:result=" + result + ' ' + Thread.currentThread());
return Unit.INSTANCE;
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), 2, (Object)null);
Log.d("TestCoroutine", "Hello World!," + Thread.currentThread());
虽然上面 .class 文件中的代码比较复杂,但是从大体逻辑可以看出,Kotlin 协程也是通过回调接口来实现异步操作的,这也解释了 Kotlin 协程只是让代码逻辑是同步非阻塞,但是实际上并没有,只是 Kotlin 编译器为代码做了很多事情,这也是说 Kotlin 协程其实就是一套线程 API 框架的原因。
再看一个上面例子的变种:
GlobalScope.launch(Dispatchers.Main) {
Log.d("TestCoroutine", "launch start: ${Thread.currentThread()}")
val numbersTo50Sum = async {
withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbersTo50Sum: ${Thread.currentThread()}")
delay(2000)
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo50 = naturalNumbers.takeWhile { it <= 50 }
numbersTo50.sum()
}
}
val numbers50To100Sum = async {
withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(500)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
}
// 计算 1-50 和 51-100 的自然数和是两个并发操作
val result = numbersTo50Sum.await() + numbers50To100Sum.await()
Log.d("TestCoroutine", "launch end:result=$result ${Thread.currentThread()}")
}
Log.d("TestCoroutine", "Hello World!,${Thread.currentThread()}")
控制台输出结果:
2023-01-02 16:32:12.637 13303-13303/com.wangjiang.example D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-02 16:32:13.120 13303-13303/com.wangjiang.example D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-02 16:32:14.852 13303-13444/com.wangjiang.example D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-2,5,main]
2023-01-02 16:32:14.853 13303-13443/com.wangjiang.example D/TestCoroutine: launch:numbers50To100Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-02 16:32:17.462 13303-13303/com.wangjiang.example D/TestCoroutine: launch end:result=5050 Thread[main,5,main]
async
创建了一个协程,它让计算 1-50 和 51-100 的自然数和是两个并发操作。上面控制台输出结果可以看到计算 1-50 的自然数和是在线程 Thread[DefaultDispatcher-worker-2,5,main]
中,而计算 51-100 的自然数和是在另一个线程Thread[DefaultDispatcher-worker-1,5,main]
中。
从上面的例子,协程在异步操作,也就是线程切换上:主线程启动子线程执行耗时操作,耗时操作执行完成将结果更新到主线程的过程中,代码逻辑简化,可读性高。
suspend 直译就是:挂起
suspend 是 Kotlin 语言中一个 关键字,用于修饰方法,当修饰方法时,表示这个方法只能被 suspend 修饰的方法调用或者在协程中被调用。
下面看一下将上面代码案例拆分成几个 suspend 方法:
fun getNumbersTo100Sum() {
GlobalScope.launch(Dispatchers.Main) {
Log.d("TestCoroutine", "launch start: ${Thread.currentThread()}")
val result = calcNumbers1To100Sum()
Log.d("TestCoroutine", "launch end:result=$result ${Thread.currentThread()}")
}
Log.d("TestCoroutine", "Hello World!,${Thread.currentThread()}")
}
private suspend fun calcNumbers1To100Sum(): Int {
return calcNumbersTo50Sum() + calcNumbers50To100Sum()
}
private suspend fun calcNumbersTo50Sum(): Int {
return withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbersTo50Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo50 = naturalNumbers.takeWhile { it <= 50 }
numbersTo50.sum()
}
}
private suspend fun calcNumbers50To100Sum(): Int {
return withContext(Dispatchers.IO) {
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
}
控制台输出结果:
2023-01-03 14:47:57.047 11349-11349/com.wangjiang.example D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-03 14:47:59.311 11349-11349/com.wangjiang.example D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-03 14:47:59.312 11349-11537/com.wangjiang.example D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-3,5,main]
2023-01-03 14:48:00.336 11349-11535/com.wangjiang.example D/TestCoroutine: launch:numbers50To100Sum: Thread[DefaultDispatcher-worker-1,5,main]
2023-01-03 14:48:01.339 11349-11349/com.wangjiang.example D/TestCoroutine: launch end:result=5050 Thread[main,5,main]
suspend 关键字标记方法时,其实是告诉 Kotlin 从协程内调用方法。所以这个“挂起”,并不是说方法或函数被挂起,也不是说线程被挂起。
假设一个非 suspend 修饰的方法调用 suspend 修饰的方法会怎么样呢?
private fun calcNumbersTo100Sum(): Int {
return calcNumbersTo50Sum() + calcNumbers50To100Sum()
}
此时,编译器会提示:
Suspend function 'calcNumbersTo50Sum' should be called only from a coroutine or another suspend function
Suspend function 'calcNumbers50To100' should be called only from a coroutine or another suspend function
下面查看 .class 文件中的上面方法 calcNumbers50To100Sum 代码:
private final Object calcNumbers50To100Sum(Continuation $completion) {
return BuildersKt.withContext((CoroutineContext)Dispatchers.getIO(), (Function2)(new Function2((Continuation)null) {
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch(this.label) {
case 0:
ResultKt.throwOnFailure($result);
Log.d("TestCoroutine", "launch:numbers50To100Sum: " + Thread.currentThread());
this.label = 1;
if (DelayKt.delay(1000L, this) == var4) {
return var4;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
Sequence naturalNumbers = SequencesKt.generateSequence(Boxing.boxInt(51), (Function1)null.INSTANCE);
Sequence numbers50To100 = SequencesKt.takeWhile(naturalNumbers, (Function1)null.INSTANCE);
return Boxing.boxInt(SequencesKt.sumOfInt(numbers50To100));
}
@NotNull
public final Continuation create(@Nullable Object value, @NotNull Continuation completion) {
Intrinsics.checkNotNullParameter(completion, "completion");
Function2 var3 = new <anonymous constructor>(completion);
return var3;
}
public final Object invoke(Object var1, Object var2) {
return ((<undefinedtype>)this.create(var1, (Continuation)var2)).invokeSuspend(Unit.INSTANCE);
}
}), $completion);
}
可以看到 private suspend fun calcNumbers50To100Sum()
经过 Kotlin 编译器编译后变成了private final Object calcNumbers50To100Sum(Continuation $completion)
, suspend
消失了,方法多了一个参数 Continuation $completion
,所以 suspend
修饰 Kotlin 的方法或函数,编译器会对此方法做特殊处理。
另外,suspend
修饰的方法,也预示着这个方法是耗时方法,告诉方法调用者要使用协程。当执行 suspend
方法,也预示着要切换线程,此时主线程依然可以继续执行,而协程里面的代码可能被挂起了。
下面再稍为修改 calcNumbers50To100Sum
方法:
private suspend fun calcNumbers50To100Sum(): Int {
Log.d("TestCoroutine", "launch:numbers50To100Sum:start: ${Thread.currentThread()}")
val sum= withContext(Dispatchers.Main) {
Log.d("TestCoroutine", "launch:numbers50To100Sum: ${Thread.currentThread()}")
delay(1000)
val naturalNumbers = generateSequence(51) { it + 1 }
val numbers50To100 = naturalNumbers.takeWhile { it in 51..100 }
numbers50To100.sum()
}
Log.d("TestCoroutine", "launch:numbers50To100Sum:end: ${Thread.currentThread()}")
return sum
}
控制台输出结果:
2023-01-03 15:28:04.349 15131-15131/com.bilibili.studio D/TestCoroutine: Hello World!,Thread[main,5,main]
2023-01-03 15:28:04.803 15131-15131/com.bilibili.studio D/TestCoroutine: launch start: Thread[main,5,main]
2023-01-03 15:28:04.804 15131-15266/com.bilibili.studio D/TestCoroutine: launch:numbersTo50Sum: Thread[DefaultDispatcher-worker-3,5,main]
2023-01-03 15:28:06.695 15131-15131/com.bilibili.studio D/TestCoroutine: launch:numbers50To100Sum:start: Thread[main,5,main]
2023-01-03 15:28:06.696 15131-15131/com.bilibili.studio D/TestCoroutine: launch:numbers50To100Sum: Thread[main,5,main]
2023-01-03 15:28:07.700 15131-15131/com.bilibili.studio D/TestCoroutine: launch:numbers50To100Sum:end: Thread[main,5,main]
2023-01-03 15:28:07.700 15131-15131/com.bilibili.studio D/TestCoroutine: launch end:result=5050 Thread[main,5,main]
主线程不受协程线程的影响。
Kotlin 协程是一套线程 API 框架,在 Kotlin 语言环境下使用它做并发编程比传统 Thread, Executors 和 RxJava 更有优势,代码逻辑上“同步非阻塞“,而且简洁,易阅读和维护。
suspend
是 Kotlin 语言中一个关键字,用于修饰方法,当修饰方法时,该方法只能被 suspend
修饰的方法和协程调用。此时,也预示着该方法是一个耗时方法,告诉调用者需要在协程中使用。
参考文档:
下一篇,将研究 Kotlin Flow。
Android开发中,列表页面是常见需求,流式布局的标签效果也是常见需求,那么两者结合的效果啥样呢?这篇文章简单实现一下。
implementation 'com.google.android.flexbox:flexbox:3.0.0'
package com.example.androidstudy;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.os.Bundle;
import android.widget.Toast;
import com.example.androidstudy.adapter.MyRecyclerAdapter;
import com.example.androidstudy.bean.TestData;
import java.util.ArrayList;
import java.util.List;
public class RecyclerViewActivity extends AppCompatActivity {
private RecyclerView recyclerView;
private MyRecyclerAdapter adapter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recycler_view);
initViews();
initListener();
}
private void initListener() {
adapter.setItemCellClicker(tag -> Toast.makeText(RecyclerViewActivity.this, tag, Toast.LENGTH_SHORT).show());
}
private void initViews() {
recyclerView = findViewById(R.id.recyclerview);
// 设置布局管理器
recyclerView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
List<String> sss = new ArrayList<>();
sss.add("重型卡车1");
sss.add("重车11");
sss.add("重型卡车3445");
sss.add("重型卡车6677");
List<String> sss1 = new ArrayList<>();
sss1.add("轻型卡车1");
sss1.add("轻车11");
sss1.add("轻型卡车3445");
sss1.add("轻型卡车6677");
List<String> sss2 = new ArrayList<>();
sss2.add("其他1");
sss2.add("其他2");
List<TestData> list = new ArrayList<>();
list.add(new TestData("重型",sss));
list.add(new TestData("轻型", sss1));
list.add(new TestData("其他", sss2));
// 实例化Adapter对象
adapter = new MyRecyclerAdapter(this, list);
// 设置Adapter
recyclerView.setAdapter(adapter);
adapter.notifyDataSetChanged();
}
}
Activity页面布局activity_recycler_view.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".RecyclerViewActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
package com.example.androidstudy.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.example.androidstudy.R;
import com.example.androidstudy.bean.TestData;
import com.google.android.flexbox.FlexboxLayout;
import java.util.List;
public class MyRecyclerAdapter extends RecyclerView.Adapter<MyRecyclerAdapter.MyViewHolder>{
private List<TestData> data;
private Context myContext;
public MyRecyclerAdapter(Context context, List<TestData> data) {
this.myContext = context;
this.data = data;
}
@NonNull
@Override
public MyViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View inflate = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_cell, parent, false);
return new MyViewHolder(inflate);
}
public interface ItemCellClicker{
void onItemClick(String tag);
}
// 流式布局标签点击事件
public ItemCellClicker itemCellClicker;
// 设置点击事件回调
public void setItemCellClicker(ItemCellClicker itemCellClicker){
this.itemCellClicker = itemCellClicker;
}
@Override
public void onBindViewHolder(@NonNull MyViewHolder holder, int position) {
TextView title = holder.itemView.findViewById(R.id.tv_title);
FlexboxLayout flexboxLayout = holder.itemView.findViewById(R.id.flexbox_layout);
TestData data = this.data.get(position);
List<String> tags = data.getTag();
flexboxLayout.removeAllViews();
// flexbox布局动态添加标签
for (int i = 0; i < tags.size(); i++) {
String temp = tags.get(i);
View tagView = LayoutInflater.from(myContext).inflate(R.layout.item_tag_cell, null, false);
TextView tag = tagView.findViewById(R.id.tv_tag);
tag.setText(temp);
// 设置标签点击事件
tag.setOnClickListener(view -> itemCellClicker.onItemClick(temp));
flexboxLayout.addView(tagView);
}
title.setText(data.getTitle());
}
@Override
public int getItemCount() {
return data.size();
}
public static class MyViewHolder extends RecyclerView.ViewHolder{
public MyViewHolder(@NonNull View itemView) {
super(itemView);
}
}
}
列表项布局item_cell.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="10dp"
tools:context=".MyActivity">
<TextView
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
android:id="@+id/tv_title"
android:text="Hello android"
android:textSize="20sp"
android:textColor="@color/black"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<!--流式布局-->
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/flexbox_layout"
android:orientation="horizontal"
app:flexWrap="wrap"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
列表中标签布局item_tag_cell.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
tools:context=".MyActivity">
<TextView
android:id="@+id/tv_tag"
android:paddingHorizontal="12dp"
android:background="@drawable/item_tag_bg"
android:gravity="center"
android:text="Hello android"
android:textSize="20sp"
android:textColor="@color/black"
android:layout_width="wrap_content"
android:layout_height="32dp"/>
</LinearLayout>
Shape是Android中一个必不可少的资源,很多的背景,比如圆角,分割线、渐变等等效果,几乎都有它的影子存在,毕竟写起来简单便捷,使用起来也是简单便捷,又占用内存小,谁能不爱?无论是初级,还是中高级,创建一个shape文件,相信大家都是信手拈来。
虽然在项目里,我们可以直接复制一个Shape文件,改一改,就能很简单的实现,但是为了更方便的创建,直接拿来可以用,于是搞了一个在线的Shape生成,目前包含了,实心、空心、渐变的模式,希望可以帮助到大家,虽然是属于造轮子了,但猜测一下,估计有需要的人,哈哈~
今天的内容大致如下:
1、在线生成Shape效果
2、如何实现这样一个在线生成平台
3、具体的主要代码实现
4、总结及问题须知
效果不是很好,毕竟咱也不是搞UI的,不过功能均可用,问题不大,目前就是左侧功能选择区域,右侧是效果及代码展示区域,包含文件的下载操作。
在线地址:abnerming888.github.io/vip/shape/s…
实际效果如下:
其实大家可以发现,虽然是辅助生成的Android功能,但本身就是网页,所以啊,懂得Web这是最基本的,不要求多么精通,但基本的页面得需要掌握,其次就是,清楚自己要实现什么功能,得有思路,比如这个Shape,那么你就要罗列常用的几种Shape类型,其主要的代码是如何呈现的,这是最重要的,搞定下面两步问题不大。
Shape的生成,其实是根据模板来的,只不过根据动态配置,改其中的参数而已,所以啊,是非常简单的,罗列基本的模板后,就可以选择性的更改。
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<corners android:radius="10dp"></corners>
<solid android:color="#ff0000" />
</shape>
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
>
<stroke
android:width="1dp"
android:color="#ff0000" />
<corners android:radius="10dp" />
<solid android:color="#171616"/>
</shape>
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="90"
android:centerColor="#000000"
android:endColor="#ff0000"
android:startColor="#ff0000"
android:type="linear" />
<corners android:radius="10dp"></corners>
</shape>
在上边的模板中,其实需要更改的元素并不是很多,无非就是,颜色值,角度大小,边框等信息,这些信息,需要用户自己选择,所以需要抛给用户触发。
有了相关模板,那么就需要绘制UI进行实现了,其实在Android studio里的插件最合适不过了,插件也已经实现了,这个我们后面说,目前的在线,就需要大家进行Web绘制了,也就是Html、Css、JavaScript相关的技术了,相对于Android而言,还是比较简单的,编码思想都是一样的,具体的编写,大家可以自行发挥。
其实大家最关心的是,我们的页面,如何让别人进行使用,一般的情况下,服务器是必须的,如果我们没有服务器,其实也有很多的三方免费的托管,比如Github上,Github搭建静态网站,大家可以去搜,网上很多资料,按照步骤来就可以轻松实现了。
颜色用到了coloris插件,它可以在触摸输入框的时候,弹出颜色选择框,效果如下图:
使用起来也是很简答,在标签后面增加data-coloris属性即可。
<input type="text" style="width: 75%" class="input_color" value="#ff0000" data-coloris/>
下载代码是用到了一个三方插件,FileSaver.js,下载的时候,也是非常的简单:
let blob = new Blob([code], {type: "text/plain;charset=utf-8"});
saveAs(blob, fileName + ".xml");
常规代码,确实没啥好说的,无非就是Html、Css、JavaScript,大家可以直接右键看源代码即可。
其实大家可以发现,目前的生成,颜色也好,角度边框也好,都是固定写死的,其实,在实际的项目开发中,这些都是在资源里进行配置好的,直接选择资源里的即可,其实应该加个,可配置的参数,只配置一次,就可以动态的选择项目中的资源。
在线的毕竟还不是很方便,其实自己一直在搞一个自动化脚手架,可以直接生成到项目中,目前是针对公司里架构,不太方便开源出来,但2023年,改为自己的框架后,会给大家开源出来,很多代码,真的可以自动生成,真是方便了很多。
显卡市场的寒气,藏不住了。
刚刚过去的2022年,全球独显出货量创下二十年新低,比2021年同期下跌将近50%。
Jon Peddie Research(JPR)最新数据显示,今年第三季度独显出货量仅690万块。
如果追溯到2005年Q3,这一数据为2000万+。
而英伟达作为全球显卡市场头号玩家,遭受的重创早就开始显现:今年Q2、Q3业绩连续下滑,如今股价已跌至去年最高点一半左右。
内忧之下,还有外患。
前有CPU巨头英特尔高调官宣分拆图形芯片部门,为更好和英伟达、AMD打擂台;后有中国GPU厂商异军突起,多家公司在今年宣布流片或量产,已引起国外关注。
看来老黄的2022,或许并不好过。
如果以“短缺”概括2021年显卡市场,那么今年的江湖,则如过山车般跌宕。
年初还在到处缺货,市场价高过发售价太过正常,一些装机玩家索性改买品牌高性价笔记本。1月时,Meta还被曝一次性从英伟达买下1.6万个GPU,还引来不少艳羡目光。
3月,情况就发生了变化。
显卡市场价已有跳水现象,再到7月,国内外消费者已基本都能以建议零售价从官方渠道及主流平台购入英伟达及AMD显卡。
“空气卡”一词逐渐隐退,不再是引发大家共鸣的表达。
缺芯潮基本结束。
短短数月的变化,主要源于两点。
其一,全球消费热潮冷却;
其二,大规模挖矿行动的终结。
当然,此前显卡缺货引发的供应链加码生产,一消一涨,数月内就将显卡从“空气”变成“实体”。
但很快,产品过剩去库存,就成为了后半年主旋律。对各大厂商,冷热交替过快过烈,着实一番冰火两重天体验。
以占大半壁江山的英伟达为例。
7月初大批产品跌至零售价,到中旬,高端款RTX 3090 Ti跌到了比零售价还便宜38%。
一个月后,英伟达颤颤巍巍预披露了Q2财报,不出所料,与消费级显卡直接挂钩的游戏业务塌方,营收环比跌掉44%,黄仁勋表示,随季度推进,该板块销售预测还将下调,去库存成为主要目标。
随后,就是官方打折,甚至搞出买30系显卡及配备的电脑,送59.99美元游戏的促销路数。
在这种动荡之下,英伟达生意越来越不好做,从财报上就能看到。
2022年5-7月,公司营收环比下跌了66%(non-GAAP),净利润环比下跌62%(non-GAAP)。后面一季的数据略有回涨,营收环比涨幅为16%(non-GAAP),但同比去年同期,跌幅还是很大,达到了55%(non-GAAP)。
这当中,英伟达还和最大合作伙伴EVGA闹掰了。
9月,EVGA单方面宣布,不会同英伟达下一代产品合作。
要知道,两者合作20多年,而且EVGA收入中80%来自英伟达合作的显卡。
根据EVGA的说法,英伟达的合作态度是两者关系恶化的关键。具体来说,英伟达一方沟通越来越少,新产品信息不同步,重要活动也不cue合作方,连价格调整也不事先同步。
比如RTX 3090 Ti显卡,英伟达给零售商报价比EVGA对外低了300美元,却不事先沟通,这下,合作方相当“被动”。
由于双方交恶时间点又赶在40系列显卡前一周,当时引发不小震动。
而几天后40系高调发布,售价最高12999人民币,很多消费者反馈却是“不值”二字,更别说4090电源接口熔化,又是一波不满。
而更大的变动或许还没到来——英伟达的新对手也越来越多。
最明显的一个动向就是,英特尔开抢GPU市场份额了。
本月初,英特尔宣布将把图形芯片部门(AXG)一分为二,通过重组业务,更好地和英伟达、AMD竞争。
过去英特尔一直在主导CPU市场,GPU方面一直不是其发展核心。但在AI热浪下,英特尔也不得不重视起加速计算市场了。
其在官方声明表示:
图形芯片和加速计算是英特尔的关键增长引擎。我们正在改进我们的结构,以加速和扩大它们的影响,并通过向客户发出统一的声音来推动上市战略。
据JPR统计,今年第三季度独显市场中,英特尔占比4%。对比来看,AMD也仅有8%。
而更引人注目的变化,发生在国内。
今年,摩尔线程一年内交出两块全功能GPU;芯动科技发布了“风华2号”、“风华1号”开始量产;面向数据中心的壁仞则发布了首款通用GPU芯片BR100,单芯片峰值算力达到PFLOPS级别;象帝先也发布了拥有100%自主知识产权的通用GPU……
脚步之快,已引发海外关注。
权威机构Jon Peddie Research在其对2022全球GPU市场的年度报告中写道:
在AI和高性能计算的驱动下,中国厂商正在向GPU市场发起进军。
由此也带动全球GPU厂商数量激增,独显厂商中,中国面孔就占据了一半席位。
当然这不是一夜之间发生的事。
在AI浪潮的驱动下,中国在数字化升级和人工智能行业融入的脚步上都十分迅速,国内对于GPU的需求空前高涨。
另一边,中国人工智能行业过度依赖英伟达显卡的情况也确实存在。这不光会造成资金上的压力,还容易出现“卡脖子”的情况。
在多种趋势和因素的影响下,早在20年下半年开始,资本市场上讲出了包括图形渲染在内的全功能GPU的新故事。壁仞科技、摩尔线程先后成立并大笔融资,芯动科技、兆芯等老牌芯片公司的独立显卡项目也在这附近官宣。
如今2年时间过去,已有多家厂商完成了流片或量产。
不可否认,当下或许还只是国内厂商迈出的第一步。从IP供应商处购买授权的方式,好处是能够减少投入加速回报,还能迅速积累经验、逐步建立起人才队伍。但在自研上后面还有很长的路要走。
而且如苹果、三星等攀登IP自研之路时,也并非一帆风顺。苹果分手3年后又回头重新与Imagination合作,据市场传闻有专利方面的原因。
因此,对于国内GPU自研,还需要更多耐心。
但无论如何,在全球显卡市场遭遇动荡的背景下,风险和机遇都随之而来。眼下,或许只是市场变革的开始了。
另外,最新消息显示,英伟达、AMD以及英特尔都已削减在台积电的订单。
参考链接:
[1]https://www.tomshardware.com/news/sales-of-desktop-graphics-cards-hit-20-year-low
[2]https://www.tomshardware.com/news/ai-and-tech-sovereignity-drive-number-of-gpu-developers-in-china
詹士 明敏 发自 凹非寺
来自|量子位
收起阅读 »简单说下为什么React选择函数式组件,主要是class组件比较冗余、生命周期函数写法不友好,骚写法多,functional组件更符合React编程思想等等等。更具体的可以拜读dan大神的blog。其中Function components capture the rendered values这句十分精辟的道出函数式组件的优势。
但是在16.8之前react的函数式组件十分羸弱,基本只能作用于纯展示组件,主要因为缺少state和生命周期。本人曾经在hooks出来前负责过纯函数式的react项目,所有状态处理都必须在reducer中进行,所有副作用都在saga中执行,可以说是十分艰辛的经历了。在hooks出来后我在公司的一个小中台项目中使用,落地效果不错,代码量显著减少的同时提升了代码的可读性。因为通过custom hooks可以更好地剥离代码结构,不会像以前类组件那样在cDU等生命周期堆了一大堆逻辑,在命令式代码和声明式代码中有一个良性的边界。
Hooks take some getting used to — and especially at the boundary of imperative and declarative code.
如果对hooks不太了解的可以先看看这篇文章:前情提要,十分简明的介绍了hooks的核心原理,但是我对useEffect,useRef等钩子的实现比较好奇,所以开始啃起了源码,下面我会结合源码介绍useState的原理。useState具体逻辑分成三部分:mountState,dispatch, updateState
首先的是hooks的结构,hooks是挂载在组件Fiber结点上memoizedState的
//hook的结构
export type Hook = {
memoizedState: any, //上一次的state
baseState: any, //当前state
baseUpdate: Update<any, any> | null, // update func
queue: UpdateQueue<any, any> | null, //用于缓存多次action
next: Hook | null, //链表
};
在reconciler中处理函数式组件的函数是renderWithHooks,其类型是:
renderWithHooks(
current: Fiber | null, //当前的fiber结点
workInProgress: Fiber,
Component: any, //jsx中用<>调用的函数
props: any,
refOrContext: any,
nextRenderExpirationTime: ExpirationTime, //需要在什么时候结束
): any
在renderWithHooks,核心流程如下:
//从memoizedState中取出hooks
nextCurrentHook = current !== null ? current.memoizedState : null;
//判断通过有没有hooks判断是mount还是update,两者的函数不同
ReactCurrentDispatcher.current =
nextCurrentHook === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
//执行传入的type函数
let children = Component(props, refOrContext);
//执行完函数后的dispatcher变成只能调用context的
ReactCurrentDispatcher.current = ContextOnlyDispatcher;
return children;
mountState
在HooksDispatcherOnMount中,useState调用的是下面的mountState,作用是创建一个新的hook并使用默认值初始化并绑定其触发器,因为useState底层是useReducer,所以数组第二个值返回的是dispatch。
type BasicStateAction<S> = (S => S) | S;
function mountState<S>(
initialState: (() => S) | S,
){
const hook = mountWorkInProgressHook();
//如果入参是func则会调用,但是不提供参数,带参数的需要包一层
if (typeof initialState === 'function') {
initialState = initialState();
}
//上一个state和基本(当前)state都初始化
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
last: null,
dispatch: null,
eagerReducer: basicStateReducer, // useState使用基础reducer
eagerState: (initialState: any),
});
//返回触发器
const dispatch: Dispatch<
//useState底层是useReducer,所以type是BasicStateAction
(queue.dispatch = (dispatchAction.bind(
null,
//绑定当前fiber结点和queue
((currentlyRenderingFiber: any): Fiber),
queue,
): any));
return [hook.memoizedState, dispatch];
}
mountWorkInProgressHook
这个函数是mountState时调用的构建hook的方法,在初始化完毕后会连接到当前hook.next(如果有的话)
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
if (workInProgressHook === null) {
// 列表中的第一个hook
firstWorkInProgressHook = workInProgressHook = hook;
} else {
// 添加到列表的末尾
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
在上面我们提到,useState底层是useReducer,所以返回的第二个参数是dispatch函数,其中的设计十分巧妙。
假设我们有以下代码:
相关参考视频讲解:进入学习
const [data, setData] = React.useState(0)
setData('first')
setData('second')
setData('third')
在第一次setData后, hooks的结构如上图
在第二次setData后, hooks的结构如上图
在第三次setData后, hooks的结构如上图
在正常情况下,是不会在dispatcher中触发reducer而是将action存入update中在updateState中再执行,但是如果在react没有重渲染需求的前提下是会提前计算state即eagerState。作为性能优化的一环。
function dispatchAction<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const alternate = fiber.alternate;
{
flushPassiveEffects();
//获取当前时间并计算可用时间
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);
const update: Update<S, A> = {
expirationTime,
action,
eagerReducer: null,
eagerState: null,
next: null,
};
//下面的代码就是为了构建queue.last是最新的更新,然后last.next开始是每一次的action
// 取出last
const last = queue.last;
if (last === null) {
// 自圆
update.next = update;
} else {
const first = last.next;
if (first !== null) {
update.next = first;
}
last.next = update;
}
queue.last = update;
if (
fiber.expirationTime === NoWork &&
(alternate === null || alternate.expirationTime === NoWork)
) {
// 当前队列为空,我们可以在进入render阶段前提前计算出下一个状态。如果新的状态和当前状态相同,则可以退出重渲染
const lastRenderedReducer = queue.lastRenderedReducer; // 上次更新完后的reducer
if (lastRenderedReducer !== null) {
let prevDispatcher;
if (__DEV__) {
prevDispatcher = ReactCurrentDispatcher.current; // 暂存dispatcher
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
const currentState: S = (queue.lastRenderedState: any);
// 计算下次state
const eagerState = lastRenderedReducer(currentState, action);
// 在update对象中存储预计算的完整状态和reducer,如果在进入render阶段前reducer没有变化那么可以服用eagerState而不用重新再次调用reducer
update.eagerReducer = lastRenderedReducer;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 在后续的时间中,如果这个组件因别的原因被重渲染且在那时reducer更变后,仍有可能重建这次更新
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
if (__DEV__) {
ReactCurrentDispatcher.current = prevDispatcher;
}
}
}
}
scheduleWork(fiber, expirationTime);
}
}
updateReducer
因为useState底层是useReducer,所以在更新时的流程(即重渲染组件后)是调用updateReducer的。
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
所以其reducer十分简单
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
return typeof action === 'function' ? action(state) : action;
}
我们先把复杂情况抛开,跑通updateReducer流程
function updateReducer(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
){
// 获取当前hook,queue
const hook = updateWorkInProgressHook();
const queue = hook.queue;
queue.lastRenderedReducer = reducer;
// action队列的最后一个更新
const last = queue.last;
// 最后一个更新是基本状态
const baseUpdate = hook.baseUpdate;
const baseState = hook.baseState;
// 找到第一个没处理的更新
let first;
if (baseUpdate !== null) {
if (last !== null) {
// 第一次更新时,队列是一个自圆queue.last.next = queue.first。当第一次update提交后,baseUpdate不再为空即可跳出队列
last.next = null;
}
first = baseUpdate.next;
} else {
first = last !== null ? last.next : null;
}
if (first !== null) {
let newState = baseState;
let newBaseState = null;
let newBaseUpdate = null;
let prevUpdate = baseUpdate;
let update = first;
let didSkip = false;
do {
const updateExpirationTime = update.expirationTime;
if (updateExpirationTime < renderExpirationTime) {
// 优先级不足,跳过这次更新,如果这是第一次跳过更新,上一个update/state是newBaseupdate/state
if (!didSkip) {
didSkip = true;
newBaseUpdate = prevUpdate;
newBaseState = newState;
}
// 更新优先级
if (updateExpirationTime > remainingExpirationTime) {
remainingExpirationTime = updateExpirationTime;
}
} else {
// 处理更新
if (update.eagerReducer === reducer) {
// 如果更新被提前处理了且reducer跟当前reducer匹配,可以复用eagerState
newState = ((update.eagerState: any): S);
} else {
// 循环调用reducer
const action = update.action;
newState = reducer(newState, action);
}
}
prevUpdate = update;
update = update.next;
} while (update !== null && update !== first);
• if (!didSkip) {
• newBaseUpdate = prevUpdate;
• newBaseState = newState;
• }
• // 只有在前后state变了才会标记
• if (!is(newState, hook.memoizedState)) {
• markWorkInProgressReceivedUpdate();
• }
• hook.memoizedState = newState;
• hook.baseUpdate = newBaseUpdate;
• hook.baseState = newBaseState;
queue.lastRenderedState = newState;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
export function markWorkInProgressReceivedUpdate() {
didReceiveUpdate = true;
}
作为系列的第一篇文章,我选择了最常用的hooks开始,抛开提前计算及与react-reconciler的互动,整个流程是十分清晰易懂的。mount的时候构建钩子,触发dispatch时按序插入update。updateState的时候再按序触发reducer。可以说就是一个简单的redux。
作者:flyzz177
来源:juejin.cn/post/7184636589564231735
运行时注解主要是通过反射来实现的,而编译时注解则是在编译期间帮助我们生成代码,所以编译时注解效率高,但是实现起来复杂一点,运行时注解效率较低,但是实现起来简单。 首先来看下运行时注解怎么实现的吧。
首先定义两个运行时注解,其中Retention标明此注解在运行时生效,Target标明此注解的程序元范围,下面两个示例RuntimeBindView用于描述成员变量和类,成员变量绑定view,类绑定layout;RuntimeBindClick用于描述方法,让指定的view绑定click事件。
@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface RuntimeBindView {
int value() default View.NO_ID;
}
@Retention(RetentionPolicy.RUNTIME)//运行时生效
@Target(ElementType.METHOD)//描述方法
public @interface RuntimeBindClick {
int[] value();
}
以下代码是用反射实现的注解功能,其中ClassInfo是一个能解析处类的各种成员和方法的工具类, 源码见github.com/huangbei199… 其实逻辑很简单,就是从Activity里面取出指定的注解,然后再调用相应的方法,如取出RuntimeBindView描述类的注解,然后得到这个注解的返回值,接着调用activity的setContentView将layout的id设置进去就可以了。
public static void bindId(Activity obj){
ClassInfo clsInfo = new ClassInfo(obj.getClass());
//处理类
if(obj.getClass().isAnnotationPresent(RuntimeBindView.class)) {
RuntimeBindView bindView = (RuntimeBindView)clsInfo.getClassAnnotation(RuntimeBindView.class);
int id = bindView.value();
clsInfo.executeMethod(clsInfo.getMethod("setContentView",int.class),obj,id);
}
//处理类成员
for(Field field : clsInfo.getFields()){
if(field.isAnnotationPresent(RuntimeBindView.class)){
RuntimeBindView bindView = field.getAnnotation(RuntimeBindView.class);
int id = bindView.value();
Object view = clsInfo.executeMethod(clsInfo.getMethod("findViewById",int.class),obj,id);
clsInfo.setField(field,obj,view);
}
}
//处理点击事件
for (Method method : clsInfo.getMethods()) {
if (method.isAnnotationPresent(RuntimeBindClick.class)) {
int[] values = method.getAnnotation(RuntimeBindClick.class).value();
for (int id : values) {
View view = (View) clsInfo.executeMethod(clsInfo.getMethod("findViewById", int.class), obj, id);
view.setOnClickListener(v -> {
try {
method.invoke(obj, v);
} catch (Exception e) {
e.printStackTrace();
}
});
}
}
}
}
如下所示,将我们定义好的注解写到相应的位置,然后调用BindApi的bind函数,就可以了。很简单吧
@RuntimeBindView(R.layout.first)//类
public class MainActivity extends AppCompatActivity {
@RuntimeBindView(R.id.jump)//成员
public Button jump;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
BindApi.bindId(this);//调用反射
}
@RuntimeBindClick({R.id.jump,R.id.jump2})//方法
public void onClick(View view){
Intent intent = new Intent(this,SecondActivity.class);
startActivity(intent);
}
}
编译时注解就是在编译期间帮你自动生成代码,其实原理也不难。
我们可以看到,编译时注解定义的时候Retention的值和运行时注解不同。
@Retention(RetentionPolicy.CLASS)//编译时生效
@Target({ElementType.FIELD,ElementType.TYPE})//描述变量和类
public @interface CompilerBindView {
int value() default -1;
}
@Retention(RetentionPolicy.CLASS)//编译时生效
@Target(ElementType.METHOD)//描述方法
public @interface CompilerBindClick {
int[] value();
}
首先我们要新建一个java的lib库,因为接下需要继承AbstractProcessor类,这个类Android里面没有。
然后我们需要引入两个包,javapoet是帮助我们生成代码的包,auto-service是帮助我们自动生成META-INF等信息,这样我们编译的时候就可以执行我们自定义的processor了。
apply plugin: 'java-library'
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
api 'com.squareup:javapoet:1.9.0'
api 'com.google.auto.service:auto-service:1.0-rc2'
}
sourceCompatibility = "1.8"
targetCompatibility = "1.8"
如下所示,我们需要自定义一个类继承子AbstractProcessor并复写他的方法,并加上AutoService的注解。 ClassElementsInfo是用来存储类信息的类,这一步先暂时不用管,下一步会详细说明。 其实从函数的名称就可以看出是什么意思,init初始化,getSupportedSourceVersion限定所支持的jdk版本,getSupportedAnnotationTypes需要处理的注解,process我们可以在这个函数里面拿到拥有我们需要处理注解的类,并生成相应的代码。
首先我们看下ClassElementsInfo这个类,也就是我们需要搜集的信息。 TypeElement为类元素,VariableElement为成员元素,ExecutableElement为方法元素,从中我们可以获取到各种注解信息。 classSuffix为前缀,例如原始类为MainActivity,注解生成的类名就为MainActivity+classSuffix
public class ClassElementsInfo {
//类
public TypeElement mTypeElement;
public int value;
public String packageName;
//成员,key为id
public Map<Integer,VariableElement> mVariableElements = new HashMap<>();
//方法,key为id
public Map<Integer,ExecutableElement> mExecutableElements = new HashMap<>();
//后缀
public static final String classSuffix = "proxy";
public String getProxyClassFullName() {
return mTypeElement.getQualifiedName().toString() + classSuffix;
}
public String getClassName() {
return mTypeElement.getSimpleName().toString() + classSuffix;
}
......
}
然后我们就可以开始搜集注解信息了, 如下所示,按照注解类型一个一个的搜集,可以通过roundEnvironment.getElementsAnnotatedWith函数拿到注解元素,拿到之后再根据注解元素的类型分别填充到ClassElementsInfo当中。 其中ClassElementsInfo是存储在Map当中,key是String是classPath。
private void collection(RoundEnvironment roundEnvironment){
//1.搜集compileBindView注解
Set<? extends Element> set = roundEnvironment.getElementsAnnotatedWith(CompilerBindView.class);
for(Element element : set){
//1.1搜集类的注解
if(element.getKind() == ElementKind.CLASS){
TypeElement typeElement = (TypeElement)element;
String classPath = typeElement.getQualifiedName().toString();
String className = typeElement.getSimpleName().toString();
String packageName = mElementUtils.getPackageOf(typeElement).getQualifiedName().toString();
CompilerBindView bindView = element.getAnnotation(CompilerBindView.class);
if(bindView != null){
ClassElementsInfo info = classElementsInfoMap.get(classPath);
if(info == null){
info = new ClassElementsInfo();
classElementsInfoMap.put(classPath,info);
}
info.packageName = packageName;
info.value = bindView.value();
info.mTypeElement = typeElement;
}
}
//1.2搜集成员的注解
else if(element.getKind() == ElementKind.FIELD){
VariableElement variableElement = (VariableElement) element;
String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
CompilerBindView bindView = variableElement.getAnnotation(CompilerBindView.class);
if(bindView != null){
ClassElementsInfo info = classElementsInfoMap.get(classPath);
if(info == null){
info = new ClassElementsInfo();
classElementsInfoMap.put(classPath,info);
}
info.mVariableElements.put(bindView.value(),variableElement);
}
}
}
//2.搜集compileBindClick注解
Set<? extends Element> set1 = roundEnvironment.getElementsAnnotatedWith(CompilerBindClick.class);
for(Element element : set1){
if(element.getKind() == ElementKind.METHOD){
ExecutableElement executableElement = (ExecutableElement) element;
String classPath = ((TypeElement)element.getEnclosingElement()).getQualifiedName().toString();
CompilerBindClick bindClick = executableElement.getAnnotation(CompilerBindClick.class);
if(bindClick != null){
ClassElementsInfo info = classElementsInfoMap.get(classPath);
if(info == null){
info = new ClassElementsInfo();
classElementsInfoMap.put(classPath,info);
}
int[] values = bindClick.value();
for(int value : values) {
info.mExecutableElements.put(value,executableElement);
}
}
}
}
}
如下所示使用javapoet生成代码,使用起来并不复杂。
public class ClassElementsInfo {
......
public String generateJavaCode() {
ClassName viewClass = ClassName.get("android.view","View");
ClassName clickClass = ClassName.get("android.view","View.OnClickListener");
ClassName keepClass = ClassName.get("android.support.annotation","Keep");
ClassName typeClass = ClassName.get(mTypeElement.getQualifiedName().toString().replace("."+mTypeElement.getSimpleName().toString(),""),mTypeElement.getSimpleName().toString());
//构造方法
MethodSpec.Builder builder = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(typeClass,"host",Modifier.FINAL);
if(value > 0){
builder.addStatement("host.setContentView($L)",value);
}
//成员
Iterator<Map.Entry<Integer,VariableElement>> iterator = mVariableElements.entrySet().iterator();
while(iterator.hasNext()){
Map.Entry<Integer,VariableElement> entry = iterator.next();
Integer key = entry.getKey();
VariableElement value = entry.getValue();
String name = value.getSimpleName().toString();
String type = value.asType().toString();
builder.addStatement("host.$L=($L)host.findViewById($L)",name,type,key);
}
//方法
Iterator<Map.Entry<Integer,ExecutableElement>> iterator1 = mExecutableElements.entrySet().iterator();
while(iterator1.hasNext()){
Map.Entry<Integer,ExecutableElement> entry = iterator1.next();
Integer key = entry.getKey();
ExecutableElement value = entry.getValue();
String name = value.getSimpleName().toString();
MethodSpec onClick = MethodSpec.methodBuilder("onClick")
.addAnnotation(Override.class)
.addModifiers(Modifier.PUBLIC)
.addParameter(viewClass,"view")
.addStatement("host.$L(host.findViewById($L))",value.getSimpleName().toString(),key)
.returns(void.class)
.build();
//构造匿名内部类
TypeSpec clickListener = TypeSpec.anonymousClassBuilder("")
.addSuperinterface(clickClass)
.addMethod(onClick)
.build();
builder.addStatement("host.findViewById($L).setOnClickListener($L)",key,clickListener);
}
TypeSpec typeSpec = TypeSpec.classBuilder(getClassName())
.addModifiers(Modifier.PUBLIC)
.addAnnotation(keepClass)
.addMethod(builder.build())
.build();
JavaFile javaFile = JavaFile.builder(packageName,typeSpec).build();
return javaFile.toString();
}
}
最终使用了注解之后生成的代码如下
package com.android.hdemo;
import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;
@Keep
public class MainActivityproxy {
public MainActivityproxy(final MainActivity host) {
host.setContentView(2131296284);
host.jump=(android.widget.Button)host.findViewById(2131165257);
host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
host.onClick(host.findViewById(2131165258));
}
});
host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
host.onClick(host.findViewById(2131165257));
}
});
}
}
我们生成了代码之后,还需要让原始的类去调用我们生成的代码
public class BindHelper {
static final Map<Class<?>,Constructor<?>> Bindings = new HashMap<>();
public static void inject(Activity activity){
String classFullName = activity.getClass().getName() + ClassElementsInfo.classSuffix;
try{
Constructor constructor = Bindings.get(activity.getClass());
if(constructor == null){
Class proxy = Class.forName(classFullName);
constructor = proxy.getDeclaredConstructor(activity.getClass());
Bindings.put(activity.getClass(),constructor);
}
constructor.setAccessible(true);
constructor.newInstance(activity);
}catch (Exception e){
e.printStackTrace();
}
}
}
首先在gradle.properties里面加入如下的代码
android.enableSeparateAnnotationProcessing = true
org.gradle.daemon=true
org.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888
然后点击Edit Configurations
新建一个remote
然后填写相关的参数,127.0.0.1表示本机,port与刚才gradle.properties里面填写的保持一致,然后点击ok
然后将Select Run/Debug Configuration选项调整到刚才新建的Configuration上,然后点击Build--Rebuild Project,就可以开始调试了。
如下所示为原始的类
@CompilerBindView(R.layout.first)
public class MainActivity extends AppCompatActivity {
@CompilerBindView(R.id.jump)
public Button jump;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
BindHelper.inject(this);
}
@CompilerBindClick({R.id.jump,R.id.jump2})
public void onClick(View view){
Intent intent = new Intent(this,SecondActivity.class);
startActivity(intent);
}
}
以下为生成的类
package com.android.hdemo;
import android.support.annotation.Keep;
import android.view.View;
import android.view.View.OnClickListener;
import java.lang.Override;
@Keep
public class MainActivityproxy {
public MainActivityproxy(final MainActivity host) {
host.setContentView(2131296284);
host.jump=(android.widget.Button)host.findViewById(2131165257);
host.findViewById(2131165258).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
host.onClick(host.findViewById(2131165258));
}
});
host.findViewById(2131165257).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
host.onClick(host.findViewById(2131165257));
}
});
}
}
注解框架看起来很高大上,其实弄懂之后也不难,都是一个套路。
作者:我是黄大仙
来源:juejin.cn/post/7180166142093656120
常坐北京地铁4号线的人可能听过这样一句调侃 “坐4号线的学生谁先下车谁就输了,坚持到最后的都是学霸中的学霸。”
因为这一路会经过十多所高校,全都是名校。
虽然是个玩笑话,但很多地方的地铁都喜爱用高校名做站名。
比如2022年11月28日,深圳地铁6号线支线正式通车,其中,“深理工站”就以正在筹建的深圳理工大学来作为站名。
另一方面,大多数的高校也会有意的去争夺地铁站,一方面是方便学生出行,另一方面,地铁站命名也是一次对学校的宣传。
甚至在2021年,西安还曾发生过两高校掐架“争夺”地铁站命名的事,当时,西安地铁官网发布了14号线相关站点初步命名信息。
其中在西安北郊大学城的一站,暂被命名为“西安工业大学”。此站距离西安工业大学正门,陕西科技大学南门都非常近,仅200米左右。
这立刻引起了陕西科技大学的强烈不满。为了争取命名权,陕西科技大学先后两次和西安工业大学的校领导进行了沟通,并提出一些条件。
因为两所高校谈崩了,陕西科技大学要求旗下幼儿园方3月24日起不再接受西安工业大学子女入托。
最后,在被媒体和舆论痛批后,两所高校握手言和,解决了幼儿园不让孩子入园事件,同时,西安地铁14号线也更改了地铁站名,修改为 “西安工大·武德路站”
虽然只是一件小事,但高校间争夺地铁站命名确实不是第一次,有时候,地铁方面也会一碗水端平,把大家的校名都列上去。
比如 西工程大●西科大(临潼校区)站、
南医大●江苏经贸学院站 等。
但凡事都有例外,也有那么一些学校为了让地铁“远离”自己,还有学者专门写了论文来论证理由。
这可能真是中国最“恨”地铁的一所高校。
2018年,北京地铁4号线列车在13.5米深的地下呼啸而过,100米外北京大学信息科学技术学院大楼中,一台电子显微镜内“仿佛刮起了一阵飓风”。
用肉眼看,这台1米多高的白色金属镜筒安稳立在桌上。将它调至最高精度却会发现,显示屏上的黑白图像长了“毛刺”,原本纤毫毕现的原子图案因为振动变得模糊不清。
在北大校园内,因地铁运行受到影响的精密仪器,远不止这台价值数百万元的电镜。4号线开通时,北大有价值11亿元的精密仪器,其中4亿元的仪器受到影响。
地图上与地铁线路相邻的北京大学校园
原因很简单——交通微振动。**虽然这种振动几乎不易察觉,但对高校内的精密仪器来说,地铁几乎意味着“灾难性打击”。**
北大环境振动监测与评估实验室主任雷军,曾和学生拎着地震仪,测量过北京多条地铁线路,他们发现,在精密仪器更敏感的低频范围内,离地铁100米内地表振动强度比没有列车通过时高了30~100倍。
许多仪器的使用者并不知晓地铁振动会影响仪器。曾有同事找到雷军,抱怨实验室一台测量岩石年龄的精密仪器突然不正常了。这位老师叫来厂家,左调右调,愣是修不好,厂家也摸不着头脑。
事实上,并非仪器坏了,而是地铁4号线开通后,振动干扰了仪器。
实际上,当年在地铁4号线线路规划出来后,北大就曾和地铁公司为两个方案反复争论。
● 北大拒绝4号线地铁经过,想让地铁改线。
● 地铁公司表示,北大也可以整个搬走。
直至最后一次研讨会,双方仍僵持不下。那次会议由北京市一位副市长主持,邀请了一位院士和多位北大校外专家。
最后大家采取了一个折中方案,4号线经过北大的789米轨道段,将采用世界上最先进的轨道减振技术,也就是在钢轨下铺设钢弹簧浮置板。这种浮置板由一家德国公司发明,上面是约50厘米厚的钢筋混凝土板,下面是支撑着的钢弹簧,能将列车的振动与道床隔离。
最后北大做了妥协,这才有了后来的【北京大学东门站】。
图片来源:北京大学新闻中心
不过,4号线真的开通后,北大学者发现虽然轨道减振有用,但也不算完全有用,很多精密仪器还是会受到干扰。
最后,北大自己一合计,决定在受地铁振动影响最小的西南边的校医院旧址那盖综合科研楼,将部分受影响的仪器搬过来。在此之前,很多科研人为了能正常做实验,只能选择在地铁停运的深夜开始运行精密仪器。
谁知道一波未平一波又起,北大综合科研楼地基刚打好,正在施工时,北京地铁16号线的规划出来了,好家伙,地铁16号线将绕经北大西门,离综合科研楼仅200米。
这一次可把北大气坏了,由于校内精密仪器已无处可挪,北大开始了强烈抗议。
后面才知道,因为地铁4号线的成功,地铁方面以为减振成功了,北大也没有把自己准备盖科研楼挪仪器的事告诉地铁方,这才有了擦着北大西边而过的地铁16号线规划。
这一次,北大再次重拳出击,首先论文论证是不能少的。
北京市为此还拨出上千万元专项资金,让大家拿出一个合理的解决方案,包括地铁轨道减振、重新设计综合科研楼,考虑在低层装减振平台等等。
最后,双方谁也不愿意退让的时候,项目戛然而止。据说北大领导和一位市领导在某个会议碰面,双方握手言好。地铁16号退后一步,往西绕开300多米,甩掉两座车站,北大也不再提要求。
就这样,这场北大和地铁的交锋,双方鸣鼓收兵。
不过,高校和地铁的对抗,北大也绝不是个例。
与北大相似的还有清华,但是在拒绝这件事上,清华更强硬了一点。
早在1955年,清华大学就曾让铁路改过线。那时候,京张铁路位于清华校园同侧,振动曾严重干扰科研,在清华的争取下,铁路线向东迁了800米。
后面,地铁15号线原计划下穿清华大学,遭清华极力反对。最终,15号线只进入清华校内120米,没与4号线相连,形成换乘站。
受地铁影响的高校还有复旦大学、南京大学、中国科学院、首都医科大学、郑州大学医学院等。
不过并不是所有的高校都拥有强大的谈判能力。要知道,一个地铁线路方案如果已落成,再挪动位置几乎是不可能的。
因此,有的985高校没太多考虑,直接在同意文件上盖了章。有的高校遭遇了损失,却不愿意公开化。
中国电子工程设计院有限公司曾表示,给复旦大学、南京大学等多个受地铁影响的高校做过减振方案。
没想到一个小小的振动,也能引起如此大的漩涡,这可能就是“地铁蝴蝶效应”吧~
本文选自募格学术。参考资料:人民资讯、中科院深圳理工大学、潇湘晨报、人民日报等。
收起阅读 »但凡涉及到gradle开发,我一般都是会在buildSrc文件夹下进行,还有没有伙伴不太了解buildSrc的,其实buildSrc是Android中默认的插件工程,在gradle编译的时候,会编译这个项目并配置到classpath下。这样的话在buildSrc中创建的插件,每个项目都可以引入。
在buildSrc中可以创建groovy目录(如果对groovy或者kotlin了解),也可以创建java目录,对于插件开发个人更便向使用groovy,因为更贴近gradle。
创建插件,需要实现Plugin接口,在引入这个插件后,项目编译的时候,就会执行apply方法。
class ASMPlugin implements Plugin<Project>{
@Override
void apply(Project project) {
def ext = project.extensions.getByType(AppExtension)
if (ext != null){
ext.registerTransform(new ASMTransform())
}
}
}
在apply方法中,可以执行自定义的Task,也可以执行自定义的Transform(其实也可以看做是一种特殊的Task),这里我们自定义了插桩相关的Transform。
什么是Transform呢?就是在class文件打包生成dex文件的过程中,对class字节码做处理,最终生成新的dex文件,那么有什么方式能够对字节码操作呢?ASM是一种方式,使用Javassist也可以织入字节码。
class ASMTransform extends Transform {
@Override
String getName() {
return "ASMTransform"
}
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}
@Override
Set<QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT
}
@Override
boolean isIncremental() {
return false
}
@Override
void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
inputs.each { input ->
input.directoryInputs.each { dic ->
/**这里会拿到两个路径,分别是java代码编译后的javac/debug/classes,以及kotlin代码编译后的 tmp/kotlin-classes/debug */
println("dic path == >${dic.file.path}")
/**所有的class文件的根路径,我们已经拿到了,接下来就是分析这些文件夹下的class文件*/
findAllClass(dic.file)
/**这里一定不能忘记写*/
def dest = outputProvider.getContentLocation(dic.name, dic.contentTypes, dic.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(dic.file, dest)
}
input.jarInputs.each { jar ->
/**这里也一定不能忘记写*/
def dest = outputProvider.getContentLocation(jar.name,jar.contentTypes,jar.scopes,Format.JAR)
FileUtils.copyFile(jar.file,dest)
}
}
}
/**
* 查找class文件
* @param file 可能是文件也可能是文件夹
*/
private void findAllClass(File file) {
if (file.isDirectory()) {
file.listFiles().each {
findAllClass(it)
}
} else {
modifyClass(file)
}
}
/**
* 进行字节码插桩
* @param file 需要插桩的字节码文件
*/
private void modifyClass(File file) {
println("最终的class文件 ==> ${file.absolutePath}")
/**如果不是.class文件,抛弃*/
if (!file.absolutePath.endsWith(".class")) {
return
}
/**BuildConfig.class文件以及R文件都抛弃*/
if (file.absolutePath.contains("BuildConfig.class") || file.absolutePath.contains("R")) {
return
}
doASM(file)
}
/**
* 进行ASM字节码插桩
* @param file 需要插桩的class文件
*/
private void doASM(File file) {
def fis = new FileInputStream(file)
def cr = new ClassReader(fis)
def cw = new ClassWriter(ClassWriter.COMPUTE_MAXS)
cr.accept(new ASMClassVisitor(Opcodes.ASM9, cw), ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG)
/**重新覆盖*/
def bytes = cw.toByteArray()
def fos = new java.io.FileOutputStream(file.absolutePath)
fos.write(bytes)
fos.flush()
fos.close()
}
}
如果想要使用Transform,那么需要引入transform-api,其实在transform 1.5之后gradle就支持Transform了。
implementation 'com.android.tools.build:transform-api:1.5.0'
当执行Transform任务的时候,最终会执行到transform方法,在这个方法中可以获取TransformInput的输入,主要包括两种:文件夹和Jar包;对于Jar包,我们不需要处理,只需要拷贝到目标文件夹下即可。
对于文件夹我们是需要处理的,因为这里包含了我们要处理的.class文件,对于Java编译后的class文件是存在javac/debug/classes根文件夹下,对于kotlin编译后的class文件是存在temp/classes根文件下。
所以在整个编译的过程中,只要是.class文件都会执行doASM这个方法,在这个方法中就是我们在上节提到的对于字节码的插桩。
class ASMClassVisitor extends ClassVisitor {
ASMClassVisitor(int api) {
super(api)
}
@Override
MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
println("visitMethod==>$name")
/**所有的方法都会在ASMMethodVisitor中插入字节码*/
def method = super.visitMethod(access, name, descriptor, signature, exceptions)
return new ASMMethodVisitor(api, method, access, name, descriptor)
}
ASMClassVisitor(int api, ClassVisitor classVisitor) {
super(api, classVisitor)
}
@Override
FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) {
return super.visitField(access, name, descriptor, signature, value)
}
@Override
AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
return super.visitAnnotation(descriptor, visible)
}
}
class ASMMethodVisitor extends AdviceAdapter {
private def methodName
/**
* Constructs a new {@link AdviceAdapter}.
*
* @param api the ASM API version implemented by this visitor. Must be one of {@link
* Opcodes#ASM4}, {@link Opcodes#ASM5}, {@link Opcodes#ASM6} or {@link Opcodes#ASM7}.
* @param methodVisitor the method visitor to which this adapter delegates calls.
* @param access the method's access flags (see {@link Opcodes}).
* @param name the method's name.
* @param descriptor the method's descriptor (see {@link Type Type}).
*/
protected ASMMethodVisitor(int api, MethodVisitor methodVisitor, int access, String name, String descriptor) {
super(api, methodVisitor, access, name, descriptor)
this.methodName = name
}
@Override
protected void onMethodEnter() {
super.onMethodEnter()
visitFieldInsn(GETSTATIC,
"com/lay/learn/base_net/LoggUtils",
"INSTANCE",
"Lcom/lay/learn/base_net/LoggUtils;")
visitMethodInsn(INVOKEVIRTUAL, "com/lay/learn/base_net/LoggUtils", "start", "()V", false)
}
@Override
protected void onMethodExit(int opcode) {
super.onMethodExit(opcode)
visitFieldInsn(GETSTATIC,
"com/lay/learn/base_net/LoggUtils",
"INSTANCE",
"Lcom/lay/learn/base_net/LoggUtils;")
visitLdcInsn(methodName)
visitMethodInsn(INVOKEVIRTUAL, "com/lay/learn/base_net/LoggUtils", "end", "(Ljava/lang/String;)V",false)
}
}
这里就不再细说了,贴上源码大家可以借鉴一下哈。
最终在编译的过程中,对所有的方法插入了我们自己的耗时计算逻辑,当运行之后
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}
虽然我们没有显示地在MainActivity的onCreate中插入耗时检测代码,但是在控制台中我们可以看到,onCreate方法耗时180ms
2022-12-28 19:50:19.243 13665-13665/com.lay.learn.asm E/LoggUtils: <init> 耗时==>0
2022-12-28 19:50:19.458 13665-13665/com.lay.learn.asm E/LoggUtils: onCreate 耗时==>180
当我们完成一个插件之后,需要在META-INF文件夹下创建一个gradle-plugins文件夹,并在properties文件中声明插件全类名。
implementation-class=com.lay.asm.ASMPlugin
要注意插件id就是properties文件的名字。
这样只要某个工程中需要字节码插桩,只需要引入asm_plugin这个插件即可在编译的时候扫描整个工程。
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'asm_plugin'
}
附上buildSrc中的gradle配置文件
plugins{
id 'groovy'
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation gradleApi()
implementation localGroovy()
implementation 'org.apache.commons:commons-io:1.3.2'
implementation "com.android.tools.build:gradle:7.0.3"
implementation 'com.android.tools.build:transform-api:1.5.0'
implementation 'org.ow2.asm:asm:9.1'
implementation 'org.ow2.asm:asm-util:9.1'
implementation 'org.ow2.asm:asm-commons:9.1'
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
最后需要说一点就是,在Transform任务执行时,一定要将文件夹或者jar包传递到下一级的Transform中,否则会导致apk打包时缺少文件导致apk无法运行。
在了解 Kotlin 惰性集合之前,先看一下 Koltin 标准库中的一些集合操作函数。
定义一个数据模型 Person 和 Book 类:
data class Person(val name: String, val age: Int)
data class Book(val title: String, val authors: List<String>)
filter 和 map 操作:
val people = listOf<Person>(
Person("xiaowang", 30),
Person("xiaozhang", 32),
Person("xiaoli", 28)
)
//大于 30 岁的人的名字集合列表
people.filter { it.age >= 30 }.map(Person::name)
count 操作:
val people = listOf<Person>(
Person("xiaowang", 30),
Person("xiaozhang", 32),
Person("xiaoli", 28)
)
//小于 30 岁人的个数
people.count { it.age < 30 }
flatmap 操作:
val books = listOf<Book>(
Book("Java 语言程序设计", arrayListOf("xiaowang", "xiaozhang")),
Book("Kotlin 语言程序设计", arrayListOf("xiaoli", "xiaomao")),
)
// 所有书的名字集合列表
books.flatMap { it.authors }.toList()
在上面这些函数,每做一步操作,都会创建中间集合,也就是每一步的中间结果都被临时存储在一个临时集合中。
filter 函数源码:
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
//创建一个新的集合列表
return filterTo(ArrayList<T>(), predicate)
}
public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
for (element in this) if (predicate(element)) destination.add(element)
return destination
}
map 函数源码:
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
//创建一个新的集合列表
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this)
destination.add(transform(item))
return destination
}
如果被操作的元素过多,假设 people 或 books 超过 50个、100个,那么 函数链式调用 如:fliter{}.map{}
就会变得低效,且浪费内存。
Kotlin 为解决上面这种问题,提供了惰性集合操作 Sequence
接口。这个接口表示一个可以逐个列举的元素列表。Sequence 只提供了一个 方法, iterator,用来从序列中获取值。
public interface Sequence<out T> {
/**
* Returns an [Iterator] that returns the values from the sequence.
*
* Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.
*/
public operator fun iterator(): Iterator<T>
}
public inline fun <T> Sequence(crossinline iterator: () -> Iterator<T>): Sequence<T> = object : Sequence<T> {
override fun iterator(): Iterator<T> = iterator()
}
/**
* Creates a sequence that returns all elements from this iterator. The sequence is constrained to be iterated only once.
*
* @sample samples.collections.Sequences.Building.sequenceFromIterator
*/
public fun <T> Iterator<T>.asSequence(): Sequence<T> = Sequence { this }.constrainOnce()
序列中的元素求值是惰性的。因此,可以使用序列更高效地对集合元素执行链式操作,而不需要创建额外的集合来保存过程中产生的中间结果。关于这个惰性是怎么来的,后面再详细解释。
可以调用扩展函数 asSequence 把任意集合转换成序列,调用 toList 来做反向的转换。
val people = listOf<Person>(
Person("xiaowang", 30),
Person("xiaozhang", 32),
Person("xiaoli", 28)
)
people.asSequence().filter { it.age >= 30 }.map(Person::name).toList()
val books = listOf<Book>(
Book("Java 语言程序设计", arrayListOf("xiaowang", "xiaozhang")),
Book("Kotlin 语言程序设计", arrayListOf("xiaoli", "xiaomao")),
)
books.asSequence().flatMap { it.authors }.toList()
序列操作分为两类:中间的和末端的。一次中间操作返回的是另一个序列,这个新序列知道如何变换原始序列中的元素。而一次末端返回的是一个结果,这个结果可能是集合、元素、数字,或者其他从初始集合的变换序列中获取的任意对象。
中间操作始终是惰性的。
下面从例子来理解这个惰性:
listOf(1, 2, 3, 4).asSequence().map {
println("map${it}")
it * it
}.filter {
println("filter${it}")
it % 2 == 0
}
上面这段代码在控制台不会输出任何内容(因为没有末端操作)。
listOf(1, 2, 3, 4).asSequence().map {
println("map${it}")
it * it
}.filter {
println("filter${it}")
it % 2 == 0
}.toList()
控制台输出:
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map1
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter1
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map2
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter4
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map3
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter9
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: map4
2023-01-01 20:23:05.071 17000-17000/com.wangjiang.example D/TestSequence: filter16
在末端操作 .toList()
的时候,map
和 filter
变换才被执行,而且元素是被逐个执行的。并不是所有元素经在 map 操作执行完成后,再执行 filter 操作。
为什么元素是逐个被执行,首先看下 toList()
方法:
public fun <T> Sequence<T>.toList(): List<T> {
return this.toMutableList().optimizeReadOnlyList()
}
public fun <T> Sequence<T>.toMutableList(): MutableList<T> {
return toCollection(ArrayList<T>())
}
public fun <T, C : MutableCollection<in T>> Sequence<T>.toCollection(destination: C): C {
for (item in this) {
destination.add(item)
}
return destination
}
最后的 toCollection
方法中的 for (item in this)
,其实就是调用 Sequence
中的迭代器 Iterator
进行元素迭代。其中这个 this
来自于 filter
,也就是使用 filter
的 Iterator
进行元素迭代。来看下 filter
:
public fun <T> Sequence<T>.filter(predicate: (T) -> Boolean): Sequence<T> {
return FilteringSequence(this, true, predicate)
}
internal class FilteringSequence<T>(
private val sequence: Sequence<T>,
private val sendWhen: Boolean = true,
private val predicate: (T) -> Boolean
) : Sequence<T> {
override fun iterator(): Iterator<T> = object : Iterator<T> {
val iterator = sequence.iterator()
var nextState: Int = -1 // -1 for unknown, 0 for done, 1 for continue
var nextItem: T? = null
private fun calcNext() {
while (iterator.hasNext()) {
val item = iterator.next()
if (predicate(item) == sendWhen) {
nextItem = item
nextState = 1
return
}
}
nextState = 0
}
override fun next(): T {
if (nextState == -1)
calcNext()
if (nextState == 0)
throw NoSuchElementException()
val result = nextItem
nextItem = null
nextState = -1
@Suppress("UNCHECKED_CAST")
return result as T
}
override fun hasNext(): Boolean {
if (nextState == -1)
calcNext()
return nextState == 1
}
}
}
filter
中又会使用上一个 Sequence
的 sequence.iterator()
进行元素迭代。再看下 map
:
public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
return TransformingSequence(this, transform)
}
internal class TransformingSequence<T, R>
constructor(private val sequence: Sequence<T>, private val transformer: (T) -> R) : Sequence<R> {
override fun iterator(): Iterator<R> = object : Iterator<R> {
val iterator = sequence.iterator()
override fun next(): R {
return transformer(iterator.next())
}
override fun hasNext(): Boolean {
return iterator.hasNext()
}
}
internal fun <E> flatten(iterator: (R) -> Iterator<E>): Sequence<E> {
return FlatteningSequence<T, R, E>(sequence, transformer, iterator)
}
}
也是使用上一个 Sequence
的 sequence.iterator()
进行元素迭代。所以以此类推,最终会使用转换为 asSequence()
的源 iterator()
。
下面自定义一个 Sequence
来验证上面的猜想:
listOf(1, 2, 3, 4).asSequence().mapToString {
Log.d("TestSequence","mapToString${it}")
it.toString()
}.toList()
fun <T> Sequence<T>.mapToString(transform: (T) -> String): Sequence<String> {
return TransformingStringSequence(this, transform)
}
class TransformingStringSequence<T>
constructor(private val sequence: Sequence<T>, private val transformer: (T) -> String) : Sequence<String> {
override fun iterator(): Iterator<String> = object : Iterator<String> {
val iterator = sequence.iterator()
override fun next(): String {
val next = iterator.next()
Log.d("TestSequence","next:${next}")
return transformer(next)
}
override fun hasNext(): Boolean {
return iterator.hasNext()
}
}
}
控制台输出:
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:1
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString1
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:2
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString2
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:3
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString3
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: next:4
2023-01-01 20:43:43.899 21797-21797/com.wangjiang.example D/TestSequence: mapToString4
所以这就是 Sequence
为什么在获取结果的时候才会被应用,也就是末端操作被调用的时候,才会依次处理每个元素,这也是 被称为惰性集合操作的原因。
经过一系列的 序列操作,每个元素逐个被处理,那么优先处理 filter
序列,其实可以减少变换的总次数。因为每个序列都是使用上一个序列的 sequence.iterator()
进行元素迭代。
在集合操作上,可以使用集合直接调用 asSequence()
转换为序列。那么不是集合,有类似集合一样的变换,该怎么操作呢。
下面以求 1到100 的所有自然数之和为例子:
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
val sum = numbersTo100.sum()
println(sum)
控制台输出:
5050
先看下 generateSequence
源码:
public fun <T : Any> generateSequence(seed: T?, nextFunction: (T) -> T?): Sequence<T> =
if (seed == null)
EmptySequence
else
GeneratorSequence({ seed }, nextFunction)
private class GeneratorSequence<T : Any>(private val getInitialValue: () -> T?, private val getNextValue: (T) -> T?) : Sequence<T> {
override fun iterator(): Iterator<T> = object : Iterator<T> {
var nextItem: T? = null
var nextState: Int = -2 // -2 for initial unknown, -1 for next unknown, 0 for done, 1 for continue
private fun calcNext() {
//getInitialValue 获取的到就是 generateSequence 的第一个参数 0
//getNextValue 获取到的就是 generateSequence 的第二个参数 it+1,这个it 就是 nextItem!!
nextItem = if (nextState == -2) getInitialValue() else getNextValue(nextItem!!)
nextState = if (nextItem == null) 0 else 1
}
override fun next(): T {
if (nextState < 0)
calcNext()
if (nextState == 0)
throw NoSuchElementException()
val result = nextItem as T
// Do not clean nextItem (to avoid keeping reference on yielded instance) -- need to keep state for getNextValue
nextState = -1
return result
}
override fun hasNext(): Boolean {
if (nextState < 0)
calcNext()
return nextState == 1
}
}
}
上面代码其实就是创建一个 Sequence
接口实现类,并实现它的 iterator
接口方法,返回一个 Iterator
迭代器。
public fun <T> Sequence<T>.takeWhile(predicate: (T) -> Boolean): Sequence<T> {
return TakeWhileSequence(this, predicate)
}
internal class TakeWhileSequence<T>
constructor(
private val sequence: Sequence<T>,
private val predicate: (T) -> Boolean
) : Sequence<T> {
override fun iterator(): Iterator<T> = object : Iterator<T> {
val iterator = sequence.iterator()
var nextState: Int = -1 // -1 for unknown, 0 for done, 1 for continue
var nextItem: T? = null
private fun calcNext() {
if (iterator.hasNext()) {
//iterator.next() 调用的就是上一个 GeneratorSequence 的 next 方法,而返回值就是它的 it+1
val item = iterator.next()
//判断条件,也就是 it <= 100 -> item <= 100
if (predicate(item)) {
nextState = 1
nextItem = item
return
}
}
nextState = 0
}
override fun next(): T {
if (nextState == -1)
calcNext() // will change nextState
if (nextState == 0)
throw NoSuchElementException()
@Suppress("UNCHECKED_CAST")
val result = nextItem as T
// Clean next to avoid keeping reference on yielded instance
nextItem = null
nextState = -1
return result
}
override fun hasNext(): Boolean {
if (nextState == -1)
calcNext() // will change nextState
return nextState == 1
}
}
}
在 TakeWhileSequence
的 next
方法中,会优先调用内部方法 calcNext
,而这个方法内部又是调用 GeneratorSequence
的 next
方法,这样就 拿到了当前值 it+1(上一个是0+1,下一个就是1+1),拿到值后再判断 it <= 100 -> item <= 100
。
public fun Sequence<Int>.sum(): Int {
var sum: Int = 0
for (element in this) {
sum += element
}
return sum
}
sum
方法是序列的末端操作,也就是获取结果。for (element in this)
,调用上一个 Sequence
中的迭代器 Iterator
进行元素迭代,以此类推,直到调用 源 Sequence
中的迭代器 Iterator
进行元素迭代。
Kotlin 标准库提供的集合操作函数:filter,map, flatmap 等,在操作的时候会创建存储中间结果的临时列表,当集合元素较多时,这种链式操作就会变得低效。为了解决这种问题,Kotlin 提供了惰性集合操作 Sequence
接口,只有在 末端操作被调用的时候,也就是获取结果的时候,序列中的元素才会被逐个执行,处理完第一个元素后,才会处理第二个元素,这样中间操作是被延期执行的。而且因为是顺序地去执行每一个元素,所以可以先做 filter 变换,再做 map 变换,这样有助于减少变换的总次数。