注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android启动优化之精确测量启动各个阶段的耗时

1. 直观地观察应用启动时长 我们可以通过观察logcat日志查看Android应用启动耗时,过滤关键字"Displayed": ActivityTaskManager: Displayed com.peter.viewgrouptutorial/.acti...
继续阅读 »

1. 直观地观察应用启动时长


我们可以通过观察logcat日志查看Android应用启动耗时,过滤关键字"Displayed"



ActivityTaskManager: Displayed com.peter.viewgrouptutorial/.activity.DashboardActivity: +797ms



启动时长(在这个例子中797ms)表示从启动App到系统认为App启动完成所花费的时间。


2. 启动时间包含哪几个阶段


从用户点击桌面图标,到Activity启动并将界面第一帧绘制出来大概会经过以下几个阶段。



  1. system_server展示starting window

  2. Zygote fork Android 进程

  3. ActivityThread handleBindApplication(这个阶段又细分为)

    • 加载程序代码和资源

    • 初始化ContentProvider

    • 执行Application.onCreate()



  4. 启动Activity(执行 onCreate、onStart、onResume等方法)

  5. ViewRootImpl执行doFrame()绘制View,计算出首帧绘制时长。


流程图如下:


我们可以看出:阶段1和2都是由系统控制的。App开发者对这两个阶段的耗时能做的优化甚微。


3. 系统是如何测量启动时长的?


本文源码基于android-30


我们在cs.android.com源码阅读网站上全局搜索



  1. ActivityMetricsLogger.logAppDisplayed()方法中发现了打印日志语句



private void logAppDisplayed(
TransitionInfoSnapshot info
) {
if (info.type != TYPE_TRANSITION_WARM_LAUNCH && info.type != TYPE_TRANSITION_COLD_LAUNCH) {
return;
}

EventLog.writeEvent(WM_ACTIVITY_LAUNCH_TIME,
info.userId, info.activityRecordIdHashCode, info.launchedActivityShortComponentName,
info.windowsDrawnDelayMs);

StringBuilder sb = mStringBuilder;
sb.setLength(0);
sb.append("Displayed ");
sb.append(info.launchedActivityShortComponentName);
sb.append(": ");
TimeUtils.formatDuration(info.windowsDrawnDelayMs, sb);
Log.i(TAG, sb.toString());
}


  1. TransitionInfoSnapshot.windowsDrawnDelayMs是启动的时长。它在以下方法中被赋值:



  • ActivityMetricsLogger.notifyWindowsDrawn()

  • ➡️ TransitionInfo.calculateDelay()


//ActivityMetricsLogger.java
TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

private static final class TransitionInfo {
int calculateDelay(long timestampNs) {
long delayNanos = timestampNs - mTransitionStartTimeNs;
return (int) TimeUnit.NANOSECONDS.toMillis(delayNanos);
}
}


  1. timestampNs表示启动结束时间,mTransitionStartTimeNs表示启动开始时间。它们分别是在哪赋值的呢?


mTransitionStartTimeNs启动开始时间在notifyActivityLaunching方法中被赋值。调用堆栈如下:



  • ActivityManagerService.startActivity()

  • ➡️ActivityManagerService.startActivityAsUser()

  • ➡️ActivityStarter.execute()

  • ➡️ActivityMetricsLogger.notifyActivityLaunching()



ActivityMetricsLogger.notifyActivityLaunching(...)

//ActivityMetricsLogger.java
private LaunchingState notifyActivityLaunching(
Intent intent,
ActivityRecord caller,
int callingUid
) {
...
long transitionStartNs = SystemClock.elapsedRealtimeNanos();
LaunchingState launchingState = new LaunchingState();
launchingState.mCurrentTransitionStartTimeNs = transitionStartNs;
...
return launchingState;
}

启动时间记录到LaunchingState.mCurrentTransitionStartTimeNs


ActivityStarter.execute()

//ActivityStarter.java
int execute() {
try {
final LaunchingState launchingState;
synchronized (mService.mGlobalLock) {
final ActivityRecord caller = ActivityRecord.forTokenLocked(mRequest.resultTo);
launchingState = mSupervisor.getActivityMetricsLogger().notifyActivityLaunching(
mRequest.intent, caller);
}

if (mRequest.activityInfo == null) {
mRequest.resolveActivity(mSupervisor);
}

int res;
synchronized (mService.mGlobalLock) {

mSupervisor.getActivityMetricsLogger().notifyActivityLaunched(launchingState, res,
mLastStartActivityRecord);
return getExternalResult(mRequest.waitResult == null ? res
: waitForResult(res, mLastStartActivityRecord));
}
} finally {
onExecutionComplete();
}
}

该方法作用如下:



  1. 调用ActivityMetricsLogger().notifyActivityLaunching()生成LaunchingState。将启动时间记录其中

  2. 执行StartActivity逻辑

  3. 调用ActivityMetricsLogger().notifyActivityLaunched()把launchingState和ActivityRecord映射保存起来


ActivityMetricsLogger.notifyActivityLaunched(...)

//ActivityMetricsLogger.java
void notifyActivityLaunched(
LaunchingState launchingState,
int resultCode,
ActivityRecord launchedActivity) {
...
final TransitionInfo newInfo = TransitionInfo.create(launchedActivity, launchingState,
processRunning, processSwitch, resultCode);
if (newInfo == null) {
abort(info, "unrecognized launch");
return;
}

if (DEBUG_METRICS) Slog.i(TAG, "notifyActivityLaunched successful");
// A new launch sequence has begun. Start tracking it.
mTransitionInfoList.add(newInfo);
mLastTransitionInfo.put(launchedActivity, newInfo);
startLaunchTrace(newInfo);
if (newInfo.isInterestingToLoggerAndObserver()) {
launchObserverNotifyActivityLaunched(newInfo);
} else {
// As abort for no process switch.
launchObserverNotifyIntentFailed();
}
}

该方法将根据LaunchingState和ActivityRecord生成TransitionInfo保存到mTransitionInfoList中。这样就将启动开始时间保存起来了。


ActivityMetricsLogger.notifyWindowsDrawn(...)

//ActivityMetricsLogger.java
TransitionInfoSnapshot notifyWindowsDrawn(
ActivityRecord r,
long timestampNs
) {
TransitionInfo info = getActiveTransitionInfo(r);
info.mWindowsDrawnDelayMs = info.calculateDelay(timestampNs);
return new TransitionInfoSnapshot(info);
}

//ActivityMetricsLogger.java
private TransitionInfo getActiveTransitionInfo(WindowContainer wc) {
for (int i = mTransitionInfoList.size() - 1; i >= 0; i--) {
final TransitionInfo info = mTransitionInfoList.get(i);
if (info.contains(wc)) {
return info;
}
}
return null;
}

notifyWindowsDraw方法正是通过查找mTransitionInfoList中对应的TransitionInfo获取到Activity的启动开始时间。


启动完成调用堆栈如下



  • ActivityRecord.onFirstWindowDrawn()

  • ➡️ActivityRecord.updateReportedVisibilityLocked()

  • ➡️ActivityRecord.onWindowsDrawn()

  • ➡️ActivityMetricsLogger.notifyWindowsDrawn()



ActivityRecord.updateReportedVisibilityLocked()

//ActivityRecord.java
void updateReportedVisibilityLocked() {
...
boolean nowDrawn = numInteresting > 0 && numDrawn >= numInteresting;
boolean nowVisible = numInteresting > 0 && numVisible >= numInteresting && isVisible();

if (nowDrawn != reportedDrawn) {
onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos());
reportedDrawn = nowDrawn;
}
...
}

void onWindowsDrawn(boolean drawn, long timestampNs) {
mDrawn = drawn;
if (!drawn) {
return;
}
final TransitionInfoSnapshot info = mStackSupervisor
.getActivityMetricsLogger().notifyWindowsDrawn(this, timestampNs);
...
}

我们看到在updateReportedVisibilityLocked()方法中把SystemClock.elapsedRealtimeNanos()传递给onWindowsDrawn(nowDrawn, SystemClock.elapsedRealtimeNanos())


4. 调试技巧


通过断点调试记录应用冷启动记录耗时调用栈



  1. 准备一台root的手机(或者非Google Play版本模拟器)

  2. compileSdkVersion、targetSdkVersion与模拟器版本一致(本文30)

  3. notifyActivityLaunching和notifyWindowsDrawn中增加断点

  4. 调试勾选Show all processes选择system_process



几个重要的时间节点


  1. ActivityManagerService接收到startActivity信号时间,等价于launchingState.mCurrentTransitionStartTimeNs。时间单位纳秒。

  2. 进程Fork的时间,时间单位毫秒。可以通过以下方式获取:


object Processes {
@JvmStatic
fun readProcessForkRealtimeMillis(): Long {
val myPid = android.os.Process.myPid()
val ticksAtProcessStart = readProcessStartTicks(myPid)
// Min API 21, use reflection before API 21.
// See https://stackoverflow.com/a/42195623/703646
val ticksPerSecond = Os.sysconf(OsConstants._SC_CLK_TCK)
return ticksAtProcessStart * 1000 / ticksPerSecond
}

// Benchmarked (with Jetpack Benchmark) on Pixel 3 running
// Android 10. Median time: 0.13ms
fun readProcessStartTicks(pid: Int): Long {
val path = "/proc/$pid/stat"
val stat = BufferedReader(FileReader(path)).use { reader ->
reader.readLine()
}
val fields = stat.substringAfter(") ")
.split(' ')
return fields[19].toLong()
}
}


  1. ActivityThread.handleBindApplication时设置的进程启动时间,单位毫秒。Process.getStartElapsedRealtime()


//ActivityThread.java
private void handleBindApplication(AppBindData data) {
...
// Note when this process has started.
Process.setStartTimes(SystemClock.elapsedRealtime(), SystemClock.uptimeMillis());
...
}


  1. 程序代码和资源加载的时间,时间单位毫秒。Application类初始化时的时间handleBindApplication的时间差


class MyApp extends Application {
static {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
long loadApkAndResourceDuration = SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime();
}
}
}


  1. ContentProvider初始化时间,时间单位毫秒。 Application.onCreate() 与Application.attachBaseContext(Context context) 之间的时间差


 class MyApp extends Application {
long mAttachBaseContextTime = 0L;
long mContentProviderDuration = 0L;
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
mAttachBaseContextTime = SystemClock.elapsedRealtime();
}

@Override
public void onCreate() {
super.onCreate();
mContentProviderDuration = SystemClock.elapsedRealtime() - mAttachBaseContextTime;
}
}



  1. Application.onCreate()花费时间,时间单位毫秒。很简单方法开始和结束时间差。




  2. 首帧绘制时间,比较复杂,使用到了com.squareup.curtains:curtains:1.0.1代码如下,firstDrawTime就是首帧的绘制时间。从ActivityThread.handleBindApplication()到首帧绘制所花费的时间:




class MyApp extends Application {

@Override
public void onCreate() {
super.onCreate();
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
Window window = activity.getWindow();
WindowsKt.onNextDraw(window, () -> {
if (firstDraw) return null;
firstDraw = true;
handler.postAtFrontOfQueue(() -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
long firstDrawTime = (SystemClock.elapsedRealtime() - Process.getStartElapsedRealtime()));
}
});
return null;
});
}
}
}
}

调试launchingState.mCurrentTransitionStartTimeNs

由于ActivityMetricsLogger是运行在system_process进程中。我们无法在应用进程中获取到transitionStartTimeNs,我们可以用过Debug打印日志。我们需要将断点设置成non-suspending。如图将Suspend反勾选。选中Evaluate and log,并写入日志语句。




日志输出如下:



2021-08-08 12:55:36.295 537-579/system_process D/AppStart: 19113098274557 Intent received



5. 总结


本文主要介绍了Android系统是如何测量应用启动时间以及应用开发者如何测量应用启动各个阶段的启动耗时。有了这些我们能够很好的定位启动过程中的耗时以及性能瓶颈。如果你在应用启动优化有比较好的实践成果欢迎留言讨论哟


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

Android技术分享| 开源Demo any自习室布局架构

需求 分析 布局分为横竖屏 涉及到视频窗口的大小、位置切换 通过观察需求原型图可得知,横竖屏切换可以简单分成7块区域 4个视频窗口 1个title,显示「XX号房间」 1个ViewGroup,放置「头像,头像,头像,N个观众」 另1个ViewGrou...
继续阅读 »

需求


在这里插入图片描述


在这里插入图片描述


分析



  • 布局分为横竖屏

  • 涉及到视频窗口的大小、位置切换


通过观察需求原型图可得知,横竖屏切换可以简单分成7块区域



  • 4个视频窗口

  • 1个title,显示「XX号房间」

  • 1个ViewGroup,放置「头像,头像,头像,N个观众」

  • 另1个ViewGroup,放置聊天窗口相关


横竖屏切一开始我的思路是完全不使用系统的横竖屏切换,使用rotation来切换横竖屏,切换过程添加一个转换动画,如下图所示:


在这里插入图片描述


(请无视中间那个小眼睛)


但因为横屏之后依然有聊天功能,不调用系统的旋转,输入法依然还是竖屏的形式弹出来的。暂时没有查到解决办法。无奈改为使用系统的横竖屏切换,切换时通知ViewGroup重新计算测量、布局子View。


实现


首先我们复写onMeasure方法,遍历子View测量,指定为我们计算后的宽高,Mode设置为EXACTLY。


override fun onMeasure(widthSpace: Int, heightSpace: Int) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)

isVertical = height > width

if (!isVertical) {
videosWidth = (width * 0.548f).toInt()
horizontalSmallVideoWidth = (videosWidth / 3.0f).toInt()
horizontalSmallVideoHeight = (horizontalSmallVideoWidth * 0.6803f).toInt()
topicViewHeight = height - horizontalSmallVideoHeight - videoViewSpacing
}

for (i in 0 until childCount) {
val child = getChildAt(i)

val location = if (child.tag == null) {
val location = createAndCalcCoordinates(child, i, width, height)
if (location.fromLeft == 0 && location.fromRight == 0) location.run {
fromLeft = toLeft
fromTop = toTop
fromRight = toRight
fromBottom = toBottom
}
location
} else {
child.tag as ViewLocation
}

child.tag = location
child.measure(
MeasureSpec.makeMeasureSpec(location.right - location.left, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(location.bottom - location.top, MeasureSpec.EXACTLY)
)
}

setMeasuredDimension(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
}

其中ViewLocalion对象存储了View四个边的位置(left、top、right、bottom)。
首次加载时创建ViewLocation对象,并根据横竖屏计算每个View的坐标,最终对象会存储到View的tag中。再次触发onMeasure时不会重新计算布局,而是沿用之前的坐标数据。


计算子View位置的函数只会在横竖屏切换、用户点击切换视频位置或大小时调用。


onLayout中遍历子View,通知布局并传入对应的四边位置即可。


override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0 until childCount) {
val child = getChildAt(i)
val location = child.tag as ViewLocation

child.layout(location.left, location.top, location.right, location.bottom)
}
}

计算子View位置的算法可以点击这里查看,就不贴出来了。有一个需要注意的细节是:更新View坐标(除横竖屏切换)时,实际更改的是目标坐标,而非当前坐标,这样做的目的是为了实现动画效果。


当前坐标指的是在onMeasureonLayout中使用的变量:left、top、right、bottom。
目标坐标表示动画执行完毕后View的位置。


执行动画与坐标计算充分解耦,无论想新增怎样的动画(平移、缩放),只需要根据需求计算好左、上、右、下的位置,发送给动画执行的Runnable即可。


如切换大屏View的计算:


fun toEquallyDividedVideos() {
if (isAnimRunning) {
return
}
if (!isVertical/* || !isSmallMode*/) {
return
}
isAnimRunning = true
isSmallMode = false
topicIndex = -1

val videoWidth = measuredWidth.shr(1)
val videoHeight = (videoWidth.toFloat() * 0.6882f).toInt()

val arr = arrayOfNulls<ViewLocation>(childCount)
for (i in 0 until childCount) {
val location = getChildLocationAndResetFromLocations(i)
arr[i] = location

val remainder = (i % 2)
when (i) {
in 0..3 -> location.run {
toLeft = remainder * videoWidth + remainder * videoViewSpacing.shr(1)
toTop = (if (i >= 2) i / 2 else 0) * (videoViewSpacing + videoHeight) + titleHeight
toRight = toLeft + videoWidth - ((i + 1) % 2) * videoViewSpacing.shr(1)
toBottom = toTop + videoHeight
}
5 -> location.run {
toTop = titleHeight + videoHeight.shl(1) + videoViewSpacing
toBottom = toTop + titleHeight
}
6 -> location.run {
toTop = titleHeight.shl(1) + videoHeight.shl(1) + videoViewSpacing
}
}
}

post(AnimRunnable(arr.map { it!! }.toTypedArray(), mDuration = 200L, isRotation = false))
}

最终效果


在这里插入图片描述


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

Flutter 绘制番外篇 - 数学中的角度知识

前言 对一些有趣的绘制技能和知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进” 和 “活力”。另一方面,是为了让一些重要的知识有个 好的归宿。普通文章就像昙花一现,不管多美丽,终会被时间泯灭...
继续阅读 »

前言


对一些有趣的绘制技能知识, 我会通过 [番外篇] 的形式加入《Flutter 绘制指南 - 妙笔生花》小册中,一方面保证小册的“与时俱进”“活力”。另一方面,是为了让一些重要的知识有个 好的归宿。普通文章就像昙花一现,不管多美丽,终会被时间泯灭。



另外 [番外篇] 的文章是完全公开免费的,也会同时在普通文章中发表,且 [番外篇] 会在普通文章发布三日后入驻小册,这样便于错误的暴露收集建议反馈。本文作为 [番外篇] 之一,主要来探讨一下角度坐标 的知识。




一、两点间的角度


你有没有想过,两点之间的角度如何计算。比如下面的 p0p1 点间的角度,也就是两点之间的斜率。这上过初中的人都知道,使用 反三角函数 算一下就行了。那其中有哪些坑点要注意呢,下面一方面学知识,一方面练画技,一起画画吧!





1. 把线信息画出来

首先来画出如下效果,点 p0(0,0) ;点 p1(60,60)



为了方便数据管理,将起止点封装在 Line 类中。其中黑色部分的线体Line 类承担,这样在就能减少画板的绘制逻辑。


class Line {
Line({
this.start = Offset.zero,
this.end = Offset.zero,
});

Offset start;
Offset end;

final Paint pointPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1;

void paint(Canvas canvas){
canvas.drawLine(Offset.zero, end, pointPaint);
drawAnchor(canvas,start);
drawAnchor(canvas,end);
}

void drawAnchor(Canvas canvas, Offset offset) {
canvas.drawCircle(offset, 4, pointPaint..style = PaintingStyle.stroke);
canvas.drawCircle(offset, 2, pointPaint..style = PaintingStyle.fill);
}
}



画板是 AnglePainter ,其中虚线通过我的 dash_painter 库进行绘制,定义 line 对象之后,在 paint 方法中通过 line.paint(canvas); 即可绘制黑色的线体部分,蓝色的辅助信息通过 drawHelp 进行绘制。这样通过改变 line 对象的点位就可以改变线体绘制,如下是 p1 点变化对应的绘制表现:



















p1(60,60)p1(60,-80)p1(-60,-80)p1(-60,80)
image-20210902212856156image-20210902212949081

class AnglePainter extends CustomPainter {
// 绘制虚线
final DashPainter dashPainter = const DashPainter(span: 4, step: 4);

final Paint helpPaint = Paint()
..style = PaintingStyle.stroke..color = Colors.lightBlue..strokeWidth = 1;

final TextPainter textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);

Line line = Line(start: Offset.zero, end: const Offset(60, 60));

@override
void paint(Canvas canvas, Size size) {
canvas.translate(size.width / 2, size.height / 2);
drawHelp(canvas, size);
line.paint(canvas);
}

void drawHelp(Canvas canvas, Size size) {
Path helpPath = Path()
..moveTo(-size.width / 2, 0)
..relativeLineTo(size.width, 0);
dashPainter.paint(canvas, helpPath, helpPaint);
drawHelpText('0°', canvas, Offset(size.width / 2 - 20, 0));
drawHelpText('p0', canvas, line.start.translate(-20, 0));
drawHelpText('p1', canvas, line.end.translate(-20, 0));
}

void drawHelpText( String text, Canvas canvas, Offset offset, {
Color color = Colors.lightBlue
}) {
textPainter.text = TextSpan(
text: text,
style: TextStyle(fontSize: 12, color: color),
);
textPainter.layout(maxWidth: 200);
textPainter.paint(canvas, offset);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}



2.角度计算

Flutter 中的 Offset 对象有 direction 属性,它是通过 atan2 反正切函数进行计算的。下面来看一下通过 direction 属性获取的角度特点。


class Line {
// 略同...

double get rad => (end-start).direction;
}

---->[源码: Offset#direction]----
double get direction => math.atan2(dy, dx);

下面将计算出的弧度,转化为角度值,标注在左上角。源码中对 direction 属性的介绍是:
x 轴右向为正,y 轴向下为正的坐标系下,该偏移角度以是从 x 正轴顺时针方向偏移弧度,范围在 [-pi,pi] 之间。也就是说,x 轴的上部分的角度是负值 ,如下面的 34 图所示。



















p1(60,60)p1(-60,80)p1(-60,-60)p1(60,-80)

drawHelpText(
'角度: ${(line.rad * 180 / pi).toStringAsFixed(2)}°',
canvas,
Offset(
-size.width / 2 + 10,
-size.height / 2 + 10,
),
);
复制代码



这里角度在 [-pi,pi] 之间,那我们能不能让它在 [0,2*pi] 之间呢?这样比较符合 0~360° 的常归认识。其实很简单,如果为负,加个 2*pi 就行了,如下 positiveRad 的处理。


---->[Line]----
double get rad => (end - start).direction;

double get positiveRad => rad < 0 ? 2 * pi + rad : rad;



3.角度的使用

现在来做一个小案例,如下:通过两点间的角度来决定矩形旋转的角度,使用动画将 p1 点绕 p0 做圆周运动。由于两点的角度变化,矩形也会伴随旋转。



为了让 Line 的变化方便通知画板进行更新,这里让它继承自 ChangeNotifier ,成为可监听对象。并给出一个 rotate 方法,传入角度来更新坐标。这里为了方便,先以 0,0 为起点,只变更 end 坐标,已知 p1 做圆周运动,所以两点间距离不变,又知道了旋转角度,那 p1 在旋转 rad 时,p1 的坐标就很容易得出:



class Line with ChangeNotifier {
// 略同...

double get length => (end - start).distance;

void rotate(double rad) {
end = Offset(length * cos(rad), length * sin(rad));
notifyListeners();
}
}



上面实现了椭圆的角度伴随运动,那想一下,如何动态绘制如下的线与水平正方向的圆弧呢?



其实很简单,我们已经知道了角度值,通过 canvas.drawArc 就可以根据先的角度绘制圆弧。


---->[AnglePainter#drawHelp]----
canvas.drawArc(
Rect.fromCenter(center: Offset.zero, width: 20, height: 20),
0,
line.positiveRad,
false,
helpPaint,
);



4. 点任意的绕点旋转

其实刚才的圆周运动是一个及其特殊的情况,也就是线的起点在原点,且初始夹角为 0。这样在坐标计算时,不必考虑初始角度的影响。但对于一般场合,上面的运算方式会出现错误。那如何实现 p0 点的任意呢?其实这就是移到简单的初中数学题:



已知: p0(a,b)、p1(c,d),求 p1 绕 p0 顺时针旋转 θ 弧度后得到 p1' 点。
求: p1' 点的坐标。

 其实算起来很简单,如下,旋转了 θ 弧度后得到 p1' 。以 p0 为参考系原点的话,p1' 的坐标呼之欲出。


令两点间角度为 rad, 两点间距离为 length, 则: 
p1': (length*cos(rad+θ),length*sin(rad+θ))

已知 p0 坐标为 start,则以 (0,0) 为坐标系,则
p1': (length*cos(rad+θ),length*sin(rad+θ)) + start


由于 rotate 参数是总的旋转角度,而rotate 方法每次触发都会更新 end 的坐标,所以 rad 会不断更新,我们需要处理的是每次动画触发间的旋转角度,即下面的 detaRotate 。本案例完整源码见: rad_rotate


double detaRotate = 0;
void rotate(double rotate) {
detaRotate = rotate - detaRotate;
end = Offset(
length * cos(rad + detaRotate),
length * sin(rad + detaRotate),
) +
start;
detaRotate = rotate;
notifyListeners();
}



二、你的点又何须是点


也许上面在你眼中,这些只是点的运算而已,但在我眼中,它们是一种约束绑定关系,因为运算本身就是约束法则。两个点数据构成一种结构,一种骨架,那你所见的点,又何须是点呢?




1. 绘制箭头

如下,是绘制箭头的案例:界面上所展现的,是Line#paint 方法绘制的内容,只要通过两个点所提供的信息,绘制出箭头即可。绘制逻辑是:先画一个水平箭头,再根据旋转角度,绕 p0 旋转。



void paint(Canvas canvas) {
canvas.save();
canvas.translate(start.dx, start.dy);
canvas.rotate(positiveRad);
Path arrowPath = Path();
arrowPath
..relativeLineTo(length - 10, 3)
..relativeLineTo(0, 2)
..lineTo(length, 0)
..relativeLineTo(-10, -5)
..relativeLineTo(0, 2)..close();
canvas.drawPath(arrowPath,pointPaint);
canvas.restore();
}

这样,点位数据的变化,同样可以驱动绘制的变化。本案例完整源码见: arrow





2. 绘制图片

如下是一张图片,现在通过 PS 获取胳膊的区域数据:0, 93, 104, 212 。左上角和左下角两点构成直线,如果我们根据点的位置信息,来绘制图片会怎么样呢?



为了储存图片和区域信息,下面定义 ImageZone 对象,在构造中传入图片 image 和区域 rect 。另外通过 imagerect ,我们可以算出以图片中心为原点,左上角和左下角对应坐标构成的线对象


import 'dart:ui';
import 'line.dart';

class ImageZone {
final Image image;
final Rect rect;

Line? _line;

ImageZone({required this.image, this.rect = Rect.zero});

Line get line {
if (_line != null) {
return _line!;
}
Offset start = Offset(
-(image.width / 2 - rect.right), -(image.height / 2 - rect.bottom));
Offset end = start.translate(-rect.width, -rect.height);
_line = Line(start: start, end: end);
return _line!;
}
}



ImageZone 中定义一个 paint 方法,通过 canvasline 进行图片的绘制。这样方便在 Line 类中进行图片绘制,简化 Line 的绘制逻辑。


---->[ImageZone]----
void paint(Canvas canvas, Line line) {
canvas.save();
canvas.translate(line.start.dx, line.start.dy);
canvas.rotate(line.positiveRad - this.line.positiveRad);
canvas.translate(-line.start.dx, -line.start.dy);
canvas.drawImageRect(
image,
rect,
rect.translate(-image.width / 2, -image.height / 2),
imagePaint,
);
canvas.restore();
}



Line 类中,添加一个 attachImage 方法,将 ImageZone 对象关联到 Line对象上。在 paint中只需要通过 _zone 对象进行绘制即可。


---->[Line]----
class Line with ChangeNotifier {
// 略同...

ImageZone? _zone;

void attachImage(ImageZone zone) {
_zone = zone;
start = zone.line.start;
end = zone.line.end;
notifyListeners();
}

void paint(Canvas canvas) {
// 绘制箭头略....
_zone?.paint(canvas, this);
}

这样我们就可以将图片的某个矩形区域 附魔 到一个线段上。手的图片通过 _loadImage 来加载,并通过 attachImage 方法为 line 对象 附魔



void _loadImage() async {
ByteData data = await rootBundle.load('assets/images/hand.png');
List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
_image = await decodeImageFromList(Uint8List.fromList(bytes));
line.attachImage(ImageZone(
rect: const Rect.fromLTRB(0, 93, 104, 212),
image: _image!,
));
}

同样,可以让线段绕起点进行旋转,如下的挥手动作。



void _updateLine() {
line.rotate(ctrl.value * 2* pi/50);
}

将背景图片进行绘制,就可以得到一个完整的效果。本案例完整源码见: body





三、线绕任意点旋转


下面我们来如何让已知线段按照某个点,进行旋转,这个问题等价于:


已知,p0、p1、p2点坐标,线段 p0、p1 绕 p2 顺时针旋转 θ 弧度后的到 p0'、p1'。
求:p0'、p1' 坐标。




1.问题分析

由于两点确定一条直线,线段 p0、p1p2旋转,等价于 p0p1 分别绕 p2 旋转。示意图如下:



对应于代码,就是在 rotate 方法中,传入一个坐标 centre ,根据该坐标和旋转角度,对 p0p1 点进行处理,得到新的点。


void rotate(double rotate,{Offset? centre}) {
//TODO
}



2.解决方案和代码处理

之前已经处理了绕起点旋转的逻辑,这里我们可以用一个非常巧妙的方案:


求 p0’ 的坐标,可以构建 p2,p0 线段,让该线段执行旋转逻辑,其 end 坐标即是 p0’。
求 p1’ 的坐标,可以构建 p2,p1 线段,让该线段执行旋转逻辑,其 end 坐标即是 p1’。

思路有了,下面来看一下代码的实现。前面实现的 绕起点旋转 封装到 _rotateByStart 方法中。


---->[Line]----
void _rotateByStart(double rotate) {
end = Offset(
length * cos(rad + rotate),
length * sin(rad + rotate),
) +
start;
}



外界可调用的的 rotate 方法,可以传入 centre 点,如果为空就以起点为旋转中心。下面 tag1tag2 出分别构建 p2p0p2p1 线段。之后两条线旋转即可获得我们期望的 p0’ p1’ 坐标。



double detaRotate = 0;

void rotate(double rotate, {Offset? centre}) {
detaRotate = rotate - detaRotate;
centre = centre ?? start;
Line p2p0 = Line(start: centre, end: start); // tag1
Line p2p1 = Line(start: centre, end: end); // tag2
p2p0._rotateByStart(detaRotate);
p2p1._rotateByStart(detaRotate);
start = p2p0.end;
end = p2p1.end;
detaRotate = rotate;
notifyListeners();
}



3.线段分度值出坐标

现在有个需求,计算线段 percent 分率处点的坐标。比如 0.5 就线段中间的坐标,0.4 就是距离顶点长 40% 线长位置的坐标。效果如下:

















0.20.50.8
image-20210907085552225

其实思路很简单,既然点在线上,那么斜率是不变的,只是长度发生变化,根据斜率长度即可求出坐标值,代码实现如下:


Offset percent(double percent){
return Offset(
length*percent*cos(rad),
length*percent*sin(rad),
)+start;
}



前面说过了线的,绕点旋转。现在已知分度值处的坐标,就可以很轻松地实现 线绕分度锚点旋转。本案例完整源码见: rotate_by_point





本文中的点线操作,都是对坐标本身的数据进行修改系。比如在旋转时,线对应的角度值是真实的。这种基于逻辑运算的数据驱动方式,可以进行一些很有意思的操作,更容易让数据间进行 联动 。另外,本文仅仅是两个点组成线 的简单研究。多个线的组合、约束也许会打开一个新世界的大门。相关以后有机会再深入研究一下,分享给大家。


那这里本文想介绍的内容就差不多了,谢谢观看,拜拜~




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

Jetpack Compose入门篇-简约而不简单

Compose简介 Jetpack Compose:利用声明式编程构建Android原生界面(UI)的 工具包 优势 更少的代码、代码量锐减 强大的工具/组件支持 直观的 Kotlin API 简单易用 Compose 编程思想 声明性编程范式:声...
继续阅读 »

Compose简介



  • Jetpack Compose:利用声明式编程构建Android原生界面(UI)的 工具包


优势



  • 更少的代码、代码量锐减

  • 强大的工具/组件支持

  • 直观的 Kotlin API

  • 简单易用


Compose 编程思想




  • 声明性编程范式:声明性的函数构建一个简单的界面组件,无需修改任何 XML 布局,也不需要使用布局编辑器,只需要调用 Jetpack Compose 函数来声明想要的元素,Compose 编译器即会完成后面的所有工作




  • 举个栗子:简单的可组合函数


    class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
    Text("Hello world!")
    }
    }
    }



image-20210831180043978


  • 动态 :组合函数是用 Kotlin 而不是 XML 编写,见上$name 的传入




  • 需要注意的事项:




    • 可组合函数可以按任何顺序执行


      //可以按任何顺序进行,不能让 StartScreen() 设置某个全局变量(附带效应)并让 MiddleScreen() 利用这项更改。相反,其中每个函数都需要保持独立。
      @Composable
      fun ButtonRow() {
      MyFancyNavigation {
      StartScreen()
      MiddleScreen()
      EndScreen()
      }
      }



    • 可组合函数可以并行执行




      • Compose 可以通过并行运行可组合函数来优化重组,这样一来,Compose 就可以利用多个核心,并以较低的优先级运行可组合函数(不在屏幕上)




      • 这种优化意味着,可组合函数可能会在后台线程池中执行,如果某个可组合函数对 ViewModel 调用一个函数,则 Compose 可能会同时从多个线程调用该函数




      • 调用某个可组合函数时,调用可能发生在与调用方不同的线程上,这意味着,应避免使用修改可组合 lambda 中的变量的代码,既因为此类代码并非线程安全代码,又因为它是可组合 lambda 不允许的附带效应




      //此代码没有附带效应
      @Composable
      fun ListComposable(myList: List<String>) {
      Row(horizontalArrangement = Arrangement.SpaceBetween) {
      Column {
      for (item in myList) {
      Text("Item: $item")
      }
      }
      Text("Count: ${myList.size}")
      }
      }
      复制代码

      //如果函数写入局部变量,则这并非线程安全或正确的代码:
      @Composable
      @Deprecated("Example with bug 有问题的代码")
      fun ListWithBug(myList: List<String>) {
      var items = 0

      Row(horizontalArrangement = Arrangement.SpaceBetween) {
      Column {
      for (item in myList) {
      Text("Item: $item")
      items++ // Avoid! Side-effect of the column recomposing.
      }
      }
      Text("Count: $items")
      }
      }
      //每次重组时,都会修改 items。这可以是动画的每一帧,或是在列表更新时。但不管怎样,界面都会显示错误的项数。因此,Compose 不支持这样的写入操作;通过禁止此类写入操作,我们允许框架更改线程以执行可组合 lambda。



    • 重组会跳过尽可能多的 可组合函数和 lambda




    • 重组是乐观的操作,可能会被取消




    • 可组合函数可能会像动画的每一帧一样非常频繁地运行






环境准备




  • 已了解的同学,可直接跳过




  • 需要升级到Arctic Fox 2020-3-1 版本以上,此版本以下Android studio 无此支持-【下载最新Android studio】


    ComposeActivititysupport


  • 我们注意到此项目只支持Kotlin 最低sdk 版本为21,Android 5.0


    support01


  • Gradle Compose相关依赖




implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
implementation 'androidx.activity:activity-compose:1.3.0-alpha06'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"

 kotlinOptions {
jvmTarget = '1.8'
useIR = true//在 Gradle 构建脚本中指定额外编译器选项即可启用新的 JVM IR 后端
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
kotlinCompilerVersion '1.5.10'
}
buildFeatures {
compose true
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}


  • 由于新版本邀请java 11,安装 java 8 环境的需要以下修复


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`.

org.gradle.java.home=/Library/Java/JavaVirtualMachines/jdk-11.0.11.jdk/Contents/Home



  • @Preview起作用,环境正常


布局




  • Android 传统从xml-状态的变更关系,程序员需要大量的代码维护Ui 界面,以达到界面状态的正确性,费时费力,即便是借助MVVM架构,一样需要维护状态,因为布局只有一套





  • 声明式与传统XML 实现区别,Compose 声明式布局,是直接重建了UI,所以不会有状态问题






  • Text:Compose 提供了基础的 BasicTextBasicTextField,它们是用于显示文字以及处理用户输入的主要函数。Compose 还提供了更高级的 TextTextField


    Text("Hello World")



  • 重组Text->Button


    @Composable
    fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
    Text("I've been clicked $clicks times")
    }
    }



  • Modifier可以修改控件的位置、高度、边距、对齐方式等等


    //`padding` 设置各个UI的padding。padding的重载的方法一共有四个。
    Modifier.padding(10.dp) // 给上下左右设置成同一个值
    Modifier.padding(10.dp, 11.dp, 12.dp, 13.dp) // 分别为上下左右设值
    Modifier.padding(10.dp, 11.dp) // 分别为上下和左右设值
    Modifier.padding(InnerPadding(10.dp, 11.dp, 12.dp, 13.dp))// 分别为上下左右设值
    //这里设置的值必须为`Dp`,`Compose`为我们在Int中扩展了一个方法`dp`,帮我们转换成`Dp`。
    //`plus` 可以把其他的Modifier加入到当前的Modifier中。
    Modifier.plus(otherModifier) // 把otherModifier的信息加入到现有的modifier中
    //`fillMaxHeight`,`fillMaxWidth`,`fillMaxSize` 类似于`match_parent`,填充整个父layout。
    Modifier.fillMaxHeight() // 填充整个高度
    //`width`,`heigh`,`size` 设置Content的宽度和高度。
    Modifier.width(2.dp) // 设置宽度
    Modifier.height(3.dp) // 设置高度
    Modifier.size(4.dp, 5.dp) // 设置高度和宽度
    //`widthIn`, `heightIn`, `sizeIn` 设置Content的宽度和高度的最大值和最小值。
    Modifier.widthIn(2.dp) // 设置最大宽度
    Modifier.heightIn(3.dp) // 设置最大高度
    Modifier.sizeIn(4.dp, 5.dp, 6.dp, 7.dp) // 设置最大最小的宽度和高度
    //`gravity` 在`Column`中元素的位置。
    Modifier.gravity(Alignment.CenterHorizontally) // 横向居中
    Modifier.gravity(Alignment.Start) // 横向居左
    Modifier.gravity(Alignment.End) // 横向居右
    //`rtl`, `ltr` 开始布局UI的方向。
    Modifier.rtl // 从右到左
    //更多Modifier学习:https://developer.android.com/jetpack/compose/modifiers-list



  • Column 线性布局≈ Android LinearLayout-VERTICAL




  • Row 水平布局≈Android LinearLayout-HORIZONTAL




  • Box帧布局≈Android FrameLayout,可将一个元素放在另一个元素上,如需在 Row 中设置子项的位置,请设置 horizontalArrangementverticalAlignment 参数。对于 Column,请设置 verticalArrangementhorizontalAlignment 参数




  • 相对布局,需要引入 ConstraintLayout



    • 引入


    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-beta02"


    • constraintlayout-compose用法流程


    graphics-drawcircle

    • 完整用法示例


    @Composable
    fun testConstraintLayout() {
    ConstraintLayout() {
    //通过createRefs创建三个引用
    val (imageRef, nameRef) = createRefs()
    Image(painter = painterResource(id = R.mipmap.test),
    contentDescription = "图",
    modifier = Modifier
    .constrainAs(imageRef) {//通过constrainAs将Image与imageRef绑定,并增加约束
    top.linkTo(parent.top)
    start.linkTo(parent.start)
    bottom.linkTo(parent.bottom)
    }
    .size(100.dp)
    .clip(shape = RoundedCornerShape(5)),
    contentScale = ContentScale.Crop)
    Text(
    text = "名称",
    modifier = Modifier
    .constrainAs(nameRef) {
    top.linkTo(imageRef.top, 2.dp)
    start.linkTo(imageRef.end, 12.dp)
    end.linkTo(parent.end)
    width = Dimension.fillToConstraints
    }
    .fillMaxWidth(),
    fontSize = 18.sp,
    maxLines = 1,
    textAlign = TextAlign.Left,
    overflow = TextOverflow.Ellipsis,
    )
    }
    }



列表



  • 可以滚动的布局


//我们可以使用 verticalScroll() 修饰符使 Column 可滚动
Column (
modifier = Modifier.verticalScroll(rememberScrollState())){
messages.forEach { message ->
MessageRow(message)
}
}



  • 但以上布局并无法实现重用,可能导致性能问题,下面介绍我们重点布局,列表




  • LazyColumn/LazyRow==RecylerView/listView 列表布局,解决了滚动时的性能问题,LazyColumnLazyRow 之间的区别就在于它们的列表项布局和滚动方向不同




    • 内边距


      LazyColumn(
      contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
      ) {
      // ...
      }




    • item间距


      LazyColumn(
      verticalArrangement = Arrangement.spacedBy(4.dp),
      ) {
      // ...
      }



    • 浮动列表的浮动标题,使用 LazyColumn 实现粘性标题,可以使用实验性 stickyHeader()函数


      @OptIn(ExperimentalFoundationApi::class)
      @Composable
      fun ListWithHeader(items: List<Item>) {
      LazyColumn {
      stickyHeader {
      Header()
      }

      items(items) { item ->
      ItemRow(item)
      }
      }
      }





  • 网格布局LazyVerticalGrid


    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun PhotoGrid(photos: List<Photo>) {
    LazyVerticalGrid(
    cells = GridCells.Adaptive(minSize = 128.dp)
    ) {
    items(photos) { photo ->
    PhotoItem(photo)
    }
    }
    }



自定义布局




  • 通过重组基础布局实现




  • Canvas


    Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height
    drawCircle(
    color = Color.Blue,
    center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
    radius = size.minDimension / 4
    )
    }
    //drawCircle 画圆
    //drawRectangle 画矩形
    //drawLine //画线



graphics-drawcircle

动画



  • 动画Api 选择


animation-flowchart

其他库支持




  • 导航栏


    implementation("androidx.navigation:navigation-compose:2.4.0-alpha05")



总结



  • Compose总体来说,对于Android-Native布局实现上更加简单高效,值得大家一学

  • Compose 写法与Flutter-Dart 有高度类似的情况,后面我们可以做一篇与Flutter-Dart 语音写布局的一些对比

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

Fragment和Activity最佳通信方式 --- 共享ViewModel

背景在日常开发中,我们经常会遇到Activity和Fragment之间通信的问题,其中之前最简单的办法是通过接口回调,因为fragment在attach时会拿到activity实例,在activity内部能拿到fragment实例,只需要定义接口让activi...
继续阅读 »

背景

在日常开发中,我们经常会遇到Activity和Fragment之间通信的问题,其中之前最简单的办法是通过接口回调,因为fragment在attach时会拿到activity实例,在activity内部能拿到fragment实例,只需要定义接口让activity实现接口即可,但是这样一来不免接口定义的很多,如果逻辑比较复杂,不利于后期维护。

关于其他方案,比如eventBus都可以实现,但是如果你使用MVVM和Jetpack开发的话,这个问题就特别容易解决了,那就是使用共享ViewModel。

使用

话不多说,直接开整。

原理

ViewModel大家应该都很熟悉了,在MVVM架构中充当着保存数据以及逻辑操作的角色,最重要的是它在配置发生改变时依旧会保存数据而且具有恰当的生命周期,一般就是Activity或者Fragment都对应着一个ViewModel。

所以原理也非常简单,既然Activity生命周期比Fragment长,所以Activity的ViewModel的生命周期也比Fragment的ViewModel长,这里直接在Fragment中使用Activity的ViewModel实例即可,当然这里肯定是一个实例,关于为什么ViewModel能保证生命周期安全以及单例,可以查看文章:

juejin.cn/post/699694…

有具体解答,就不再赘述。

这里的ViewModel也就相当于是一个activity范围的容器,当然可以实现在activity和fragment以及内部fragment之间的通信了。

具体实现

首先是Activity的代码,这里有3个子Fragment,然后定义了2个ViewModel,其中一个是它自己的,一个是共享的:

@AndroidEntryPoint
class ShellMainActivity : BaseVMActivity<ShellMainViewModel>() {
//这个不管,这个是Activity自己的ViewModel,不需要共享的逻辑
private val shellMainViewModel: ShellMainViewModel by viewModels()
//这个是共享ViewModel
private val sharedViewModel: ShellMainSharedViewModel by viewModels()
//依次增加3个Fragment
private val mShellMainFragment by lazy { ShellMainFragment() }
private val mShellMainPluginFragment by lazy { ShellMainPluginFragment() }
private val mShellMainSettingFragment by lazy { ShellMainSettingFragment() }
override fun getLayoutResId(): Int = R.layout.activity_main

override fun initVM(): ShellMainViewModel {
return shellMainViewModel
}

override fun initView() {
//初始化fragment
val shellMainViewPager2Adapter = ShellMainAdapter(this
, arrayListOf(mShellMainFragment
,mShellMainPluginFragment
,mShellMainSettingFragment))
shellMainViewPager2.adapter = shellMainViewPager2Adapter
ViewPager2Delegate.install(shellMainViewPager2,shellMainTabLayout)
}

override fun initData() {
//对共享fragment里的值进行观察,同时弹出toast
sharedViewModel.testLiveData.observe(this,{
Toast.makeText(this, "$it", Toast.LENGTH_SHORT).show()
})
}

override fun startObserve() {

}
//分别添加3个子fragment
inner class ShellMainAdapter(activity: FragmentActivity, private val fragmentList: List<Fragment>)
: FragmentStateAdapter(activity){

override fun getItemCount(): Int {
return fragmentList.size
}

override fun createFragment(position: Int): Fragment {
return fragmentList[position]
}

}

}

看一下共享ViewModel代码:

@HiltViewModel
class ShellMainSharedViewModel @Inject constructor() : BaseViewModel() {

val testLiveData = MutableLiveData<String>("00")

fun setValue(view: View){
val random = (1 .. 100).random().toString()
Log.i(TAG, "setValue: 随机数 $random")
testLiveData.value = random
}
}

这里非常简单,就是一个LiveData,然后接着看一下Fragment,

这里的注意点是Fragment自己的ViewModel使用viewModels来获取,对于要和整个activity生命周期共享的ViewModel使用activityViewModels来获取:

@AndroidEntryPoint
class ShellMainFragment : BaseVMFragment<ShellMainFragmentViewModel>() {

private val shellMainFragmentViewModel: ShellMainFragmentViewModel by viewModels()
//获取共享ViewModel
private val shellMainSharedViewModel: ShellMainSharedViewModel by activityViewModels()

override fun getLayoutResId(): Int = R.layout.fragment_shell_main

override fun initVM(): ShellMainFragmentViewModel {
return shellMainFragmentViewModel
}
//依次绑定2个viewModel
override fun initView() {
mBinding.setVariable(BR.sharedViewModel,shellMainSharedViewModel)
mBinding.setVariable(BR.viewModel,shellMainFragmentViewModel)
}

override fun initData() {
}

override fun startObserve() {
}

}

然后在xml中,有一个textView控件可以显示viewModel中的值,以及修改值:

<?xml version="1.0" encoding="utf-8"?>

<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>

<data>

<variable
name="viewModel"
type="com.wayeal.yunapp.shell.mvvm.main.ShellMainFragmentViewModel" />

<variable
name="sharedViewModel"
type="com.wayeal.yunapp.shell.mvvm.main.ShellMainSharedViewModel" />

</data>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".shell.mvvm.main.ShellMainFragment"
android:orientation="vertical"
>


<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="首页" />

<TextView
android:id="@+id/test"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{sharedViewModel.testLiveData}"
android:textSize="20sp"
android:textColor="@color/black"
/>

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="随机数"
android:onClick="@{sharedViewModel::setValue}"
/>

</LinearLayout>

</layout>

然后其他2个fragment可以一样使用这个共享ViewModel,这样不论是activity还是fragment之间都是用的同一个ViewModel实例,而且还是MVVM的数据驱动模式,可以在达到通信的同时不会造成内存泄漏。

最后我们能看到效果就是下图所示,2个fragment和activity可以无障碍通信:

11.gif

总结

其实除了Activity和Fragment之间的通信,我还想过是否可以搞个ViewModel在Activity之间通信呢,但是这种思想很快就被否决了,因为官方就不建议这样干,管理起来很麻烦,所以要想实现更大范围内的数据共享,建议在Repository层进行控制,通过Hilt规定Scope都可以实现,就不要搞一些奇奇怪怪的ViewModel了。

收起阅读 »

(译)Kotlin中的Lateinits vs Nullables

Kotlin给了我们很多简单明了的方式处理可空的变量,从而减少出问题的风险。当然前提是你正确地使用它。Lateinit修饰符通常来说,kotlin中所有不可空的属性都必须被正确地初始化。你可以用很多方式实现:在主构造器中,在初始化代码块中,直接在类里的属性声明...
继续阅读 »
Kotlin给了我们很多简单明了的方式处理可空的变量,从而减少出问题的风险。当然前提是你正确地使用它。

Lateinit修饰符

通常来说,kotlin中所有不可空的属性都必须被正确地初始化。你可以用很多方式实现:

  • 在主构造器中,
  • 在初始化代码块中,
  • 直接在类里的属性声明中,
  • 在getter方法中*,
  • 用delegate实现*.

*的方法严格意义上来说并不是初始化,但是它使得编译器理解这些变量是非null

但如果一个属性是生命周期驱动的(例如:一个button的引用,它会在Activity的生命周期中被inflated出来)或者它需要通过注入来初始化,那就没办法提供一个non-null的初始化,就只能把它声明成可空的。这就需要你每次使用它都进行null checks,很麻烦,尤其是在你百分百确定它在被使用之前,一定会被初始化的时候。

所以kotlin给这种情况提供了一种简单的解决方案,lateinit修饰符。这样就不需要每次都做null checks了,当然如果用到的时候该属性没有被初始化,系统就会抛出UninitializedPropertyAccessException

Lateinits vs nullables

尽管Lateinits本身是kotlin提供的非常有用的feature,但它更有可能会在很多不那么确定会被初始化(例如:有条件的初始化或者仅仅是初始化比较晚)的情况下滥用,从而造成空安全的风险,让代码变得更像java。下面是我的一些主观的经验。

Lateinit 的第一种使用情况:生命周期的开始就初始化

当一个属性可以在使用它的某个类的生命周期开始的时候,比如Activity.onCreate(),就初始化的情况下,推荐使用Lateinits。 比较常见的情况是项目中使用了依赖注入的框架,比如Dagger.

abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
AndroidInjection.inject(this)
super.onCreate(savedInstanceState)
}
}

class LoginActivity : BaseActivity() {

@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory

lateinit var viewModel: LoginViewModel

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

viewModel = ViewModelProviders.of(this, viewModelFactory).get(LoginViewModel::class.java)

setContentView(R.layout.login_activity)
}
}

或者当你希望getSystemService()的时候:

class MainActivity : AppCompatActivity() {

lateinit var alarmManager: AlarmManager

// This wouldn't work:
// val alarmService = getSystemService(AlarmManager::class.java)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
alarmManager = getSystemService(AlarmManager::class.java)
}

不然的话,就会报IllegalStateException的错误,说system services are not available to Activities before onCreate()

同时这样也可以避免在Object构建的时候使用this,导致线程安全的问题

class MyActivity : AppCompatActivity() {

lateinit var alien: Alien
// instead of:
// val alien = Alien(this)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
alien = Alien(this)
}
}

Lateinit 的第二种使用情况: fail-fast approach

在开发的过程中,我们会希望尽可能地暴露程序中的问题,并且当出现问题的时候直接crash而不是被catch然后带着缺陷继续运行。这样我们就能尽可能地修复在开发过程当中暴露的问题。如果代码逻辑比较简明的话,这是一种很有用的方法。

比如,一个Activity需要使用MediaPlayer来播放音乐,代码如下:

class PlayerActivity : AppCompatActivity() {

lateinit var mediaPlayer: MediaPlayer
lateinit var mediaUri: Uri

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
mediaUri = intent.getParcelableExtra("mediaUri")
}

override fun onStart() {
super.onStart()
mediaPlayer = MediaPlayer()
mediaPlayer.setDataSource(this, mediaUri)
mediaPlayer.prepare()
mediaPlayer.start()
}

override fun onStop() {
super.onStop()
mediaPlayer.stop()
mediaPlayer.release()
}
}

上一段代码有一个很明显的风险点在mediaUri 是在onCreate中通过传进来的intent解析出来的,如果intent中没传mediaUri,那就抛出java.lang.IllegalStateException: intent.getParcelableExtra("mediaUri") must not be null

这段代码简单清晰,所以不难发现问题。但如果到了一个大的工程里就麻烦了。

image.png

Nullable 的第一种使用情况:完全不希望出crash的情况下

有些情况下(译者:最好是所有情况下!)你希望全力避免app出crash,即使程序运行出了问题。 比如上面的音乐播放的例子,如果我们改成nullable的形式:

class PlayerActivity : AppCompatActivity() {

var mediaPlayer: MediaPlayer? = null
var mediaUri: Uri? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
mediaUri = intent.getParcelableExtra("mediaUri")
}

override fun onStart() {
super.onStart()

mediaUri?.let {
val player = MediaPlayer()
player.setDataSource(this, it)
player.prepare()
player.start()
mediaPlayer = player
}
}

override fun onStop() {
super.onStop()
mediaPlayer?.stop()
mediaPlayer?.release()
}
}

现在,如果没有在intent中拿到mediaUri,那app只是不会播放音乐了而不是直接crash,不过用户会很惊讶为啥没声儿(他可能会认为他的手机坏了)。这是否比crash更好(译者:当然!)取决于开发者的判断。但是这种方式的好处是,现在我们有机会在属性为null的时候做些什么处理这种未初始化的情况。

注:从Kotlin1.2开始你可以对lateinit的属性用.isInitialized ,当然这也有限制( 文档 ):

This check is only available for the properties that are lexically accessible, i.e. declared in the same type or in one of the outer types, or at top level in the same file.

Nullable 的第二种使用情况:初始化的时机很晚

下面的代码,Activity中用lateinit的变量保存了一个ItemData,然后再点击事件中调用startActivityForResult(),最后在onActivityResult()里使用了这个lateinit的变量。

class SomeActivity : AppCompatActivity() {

lateinit var selectedItemData: ItemData

// 很多代码 ...

private fun doSomethingWithItem(data: ItemData) {
// 很多代码 ...

if (...) { // 复杂冗长的判断
if (...) {
data?.anotherData?.let { // 也许不会继续进行而且不会通知上层
selectedItemData = data
startActivityForResult(...)
}
}
}
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
doSomethingElse(selectedItemData)
}
}

通常来说,我会避免这种“初始化太晚”的代码结构。这种情况下推荐使用nullable,因为大部分时候变量都是null

怎么更好地使用“?”

如果需要保证空安全就使用 把属性定为可空。lateinit如果用的好的话也是有它的优势在的。 但千万别把它变成了NullPointerException的标志。

译者注:

个人的建议是,除了dagger注入的情况之外一概不用lateinit。因为虽然lateinit的使用会带来代码上面的一些便利,但是比起所引入的crash风险来说,这真的值得么?这就好像用!!的方式来保证属性的非空一样,虽然kotlin确实提供了这样的方式,但是无论如何还是太危险了。

收起阅读 »

Jetpack Compose | Compose 滑动列表真的需要使用LazyColumn吗?No No No!

Jetpack Compose | 控件篇(五)-- Spacer、LazyRow、LazyColumn & 让Column可滑动在上一篇中,我们完成了 Box、Row、Column 相关内容的学习,并且留下了一个疑问:"如果容器大...
继续阅读 »

Jetpack Compose | 控件篇(五)-- Spacer、LazyRow、LazyColumn & 让Column可滑动

在上一篇中,我们完成了 Box、Row、Column 相关内容的学习,并且留下了一个疑问:"如果容器大小不足以承载内容,怎么处理呢?",这一篇我们一起学习这部分内容。

文中代码均基于 1.0.1版本

如无特殊说明,文中的 Compose 均指代 Jetpack compose

文中代码均可在 WorkShop 中获取,本篇代码集中于 post29 & post30 包下

完整系列目录: Github Pages | 掘金 | csdn

和Android进行简单对比

在Android中,SDK提供了诸如: ScrollView NestedScrollView ListView GridView RecyclerView,等针对各类场景下适用的控件, 基于滑动手势调整内容展示区域,以达到显示更多内容的目的。

进一步探索可以发现:View本身就包含了Scroll机制的 半成品实现,当然,本文我们不去深究Android的内容,借助我们已经掌握的Android知识,引出一点:

基于Scroll机制,用小容器展现大内容的本质:在视图测量的基础上,结合滑动手势处理,调整内容布局,绘制后展现。

在早期的一些文章中,有博主提到:Compose中对应的内容为 ScrollRow,ScrollColumn / LazyRow、LazyColumn

在早期的预览版中,短暂的存在过 ScrollRow,ScrollColumn等内容,似乎已经被移除

Compose中也是按照这样的思路设计的,我们将在后续的文章中再细致地展开研究,本篇中仅学习如何使用它们。

在真正开始这部分内容之前,先补充一个简单的控件 Spacer,可以简单的创建占位间距,后续的文章中已经没有他的位置了

Spacer

在之前的文章中,我们学习过Modifier,其中包含一些和布局相关的API,例如:paddingoffset,但并无 margin 等内容,按照业内惯例, 如果已经存在一个广为接受的名词,一般不会使用新词,至少词根是一致的 ,在Compose中,使用了Spacer,取缔了Margin的一些使用场景。

注:计算总是有损耗的,不要滥用Spacer,并且很多场景下有特定的方式处理间距,后续会逐渐学习到

如何使用

@Composable
fun Spacer(modifier: Modifier)

一般只需要指定他的宽高尺寸即可,例如:

Spacer(modifier = Modifier.size(3.dp))

LazyColumn

在上一篇文章中,我们已经学习过 Row和Column,它们仅仅是在方向上不一致,在实现上非常类似。同样的,LazyRow和LazyColumn也是如此。

Doc中提到:

The vertically scrolling list that only composes and lays out the currently visible items. The content block defines a DSL which allows you to emit items of different types. For example you can use LazyListScope.item to add a single item and LazyListScope.items to add a list of items.

仅 组合计算 以及 布局 当前可见元素的纵向可滑动列表。内容块定义了一个DSL,允许创建不同类型的元素。

例如:使用 LazyListScope.item 添加单个元素, LazyListScope.items 添加元素列表。

注:"内容块定义了一个DSL,允许创建不同类型的元素",这并不同于Android中概念: RecyclerView#Adapter 具有将数据映射为同类型 ViewHolder 或 不同类型 ViewHolder 的能力。而是指 "添加元素时可以是 单个元素,或者是 元素的列表,这是不同的类型。 字面翻译在中文语境下容易造成误解。

很显然,它类似于Android中的 ListView,RecyclerView着重点在于 Lazy不会将元素一股脑的全计算、布局出来

所以,它并不对标ScrollView,在它的使用场景下,可滑动需求非常普遍,便默认实现了!

我们前面提到:

在早期的一些文章中,有博主提到:Compose中对应的内容为 ScrollRow,ScrollColumn / LazyRow、LazyColumn

这本没有啥错误,但绝不是被曲解的: "Row和Column 无法提供滑动能力,而是需要使用 LazyRow、LazyColumn"

但气氛已经烘托到这里了,那我们先将其学完,再学习 Row和Column 如何提供滑动能力。

如何使用

@Composable
fun LazyColumn(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState()
,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
content: LazyListScope.() -> Unit
)

各个参数含义:

  • modifier:修饰器
  • state:用于控制或者观测列表状态
  • contentPadding:整体内容周围的一个Padding,注:内容四周的留白,以纵向列表为例,尾部没有展示时看不到尾部的留白 这通过Modifier无法实现, 注:Modifier只能实现列表容器固定的留白间距 。可以使用它在第一个元素前和最后一个元素后留白。 如果需要在元素间留出间距,可以使用 verticalArrangement
  • reverseLayout:是否反转列表
  • verticalArrangement:子控件纵向的范围。可用于添加子控件之间的间距,以及内容不足以填满列表最小尺寸时,如何排布
  • horizontalAlignment:子控件横向对齐方式
  • flingBehavior:fling行为的处理逻辑
  • content:声明了如何提供子控件的DSL,有两种方式
@LazyScopeMarker
interface LazyListScope {

fun item(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)

fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
)


@ExperimentalFoundationApi
fun stickyHeader(key: Any? = null, content: @Composable LazyItemScope.() -> Unit)
}

顺带一提:笔者参与的上一个项目中,高频使用RecycleView用作内容呈现,为了便捷的处理 "item之间的间距"、"首尾留白"、"特定item间不应用间距", 在项目中写了一套部件,后续可以拆出来同大家分享下。

基于LazyListScope.item 方法

在上一篇文章对应的WorkShop内容中,已经出现了这一用法 post29包下

例如:

private fun LazyListScope.rowDemo() {
item {
CodeSample(code = "row sample 1:")
Row {
// ignore
}
}

item {
CodeSample(code = "row sample 2:纵向居中对齐")
// ignore
}

// ignore
}

基于LazyListScope.items 方法

除了直接使用API,SDK中同样提供了部分内联函数,消除处理数据结构的代码冗余:

inline fun <T> LazyListScope.items(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)


inline fun <T> LazyListScope.itemsIndexed(
items: List<T>,
noinline key: ((index: Int, item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
)


inline fun <T> LazyListScope.items(
items: Array<T>,
noinline key: ((item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)


inline fun <T> LazyListScope.itemsIndexed(
items: Array<T>,
noinline key: ((index: Int, item: T) -> Any)? = null,
crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit
)

按照以往Android中的开发经验,我们很容易写出如下的代码:

// WorkShop 中的入口页面,枚举了各个例子对应的Activity
@Composable
fun TestList(activity: Activity, cases: List<Pair<String, Class<out Activity>>>) {
LazyColumn(contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)) {
itemsIndexed(items = cases) { _, item ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.size(3.dp))
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(
color = Color.LightGray,
shape = RoundedCornerShape(CornerSize(6.dp))
)
.clickable {
activity.startActivity(Intent(activity, item.second))
},
contentAlignment = Alignment.Center
) {
Text(text = item.first, color = MainTxt, textAlign = TextAlign.Center)
}
Spacer(modifier = Modifier.size(3.dp))
}
}
}
}

TestList(
activity = this@MainActivity, cases = arrayListOf(
"Layout samples" to P21LayoutSample::class.java,
"Draw samples" to P21DrawSample::class.java,
"Text samples" to P26TextSample::class.java,
"TextField samples" to P26TextFieldSample::class.java,
"Button samples" to P26ButtonSample::class.java,
"Icon samples" to P27IconSample::class.java,
"Image samples" to P27ImageSample::class.java,
"Switch,Checkbox,RadioButton samples" to P28SwitchRbCbSample::class.java,
"Box,Row,Column samples" to P29BoxRowColumnSample::class.java,
)
)

如果从Android的视角出发,这段代码相当于创建 ViewHolder的ItemView 以及 onBindViewHolder 的实现

Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.size(3.dp))
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(
color = Color.LightGray,
shape = RoundedCornerShape(CornerSize(6.dp))
)
.clickable {
activity.startActivity(Intent(activity, item.second))
},
contentAlignment = Alignment.Center
) {
Text(text = item.first, color = MainTxt, textAlign = TextAlign.Center)
}
Spacer(modifier = Modifier.size(3.dp))
}

也就是说,我们利用了ItemView固有的 "留白" 处理了Item之间的间距,显然这不是最佳实践方案!

更加优雅地处理间距和对齐

上文中已经提及:

  • contentPadding
  • verticalArrangement
  • horizontalAlignment

基于此我们对代码进行改造,以减少没用的嵌套

@Composable
fun TestList(activity: Activity, cases: List<Pair<String, Class<out Activity>>>) {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = spacedBy(6.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
itemsIndexed(items = cases) { _, item ->
Box(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(
color = Color.LightGray,
shape = RoundedCornerShape(CornerSize(6.dp))
)
.clickable {
activity.startActivity(Intent(activity, item.second))
},
contentAlignment = Alignment.Center
) {
Text(
text = item.first,
color = MainTxt,
textAlign = TextAlign.Center
)
}
}
}
}

可以得到一致的效果:

p30_demo1.webp

让Column可滑动

参考可以实现Row的可滑动

最开始在和Modifier混脸熟的过程中,我们提及了 androidx.compose.foundation 包,并且含有子包: androidx.compose.foundation.gestures,顾名思义,后者和手势处理有关。

  • androidx.compose.foundation.gestures.ScrollableKt#scrollable
  • androidx.compose.foundation.ScrollKt#verticalScroll
  • androidx.compose.foundation.ScrollKt#horizontalScroll

经过这一阶段的学习,我们可以做出一个结论:

Compose 中包含一部分基本的函数,以及结合实际使用场景,在基本函数上 "装饰" 出高级函数

从命名上,我们很容易得知 scrollable 是比较基本的函数,verticalScrollhorizontalScroll 是基于 scrollable 装饰出的高级函数。

阅读源码后也确实验证了我们的推测。

以 verticalScroll 为例,horizontalScroll暂不展开

Doc内容如下:

Modify element to allow to scroll vertically when height of the content is bigger than max constraints allow. In order to use this modifier, you need to create and own [ScrollState] @see [rememberScrollState]

修饰布局元素,当其内容高度超过最大允许的限制时,允许在纵向进行滚动。

注:内容最大高度地限制需要考虑容器的高度、padding、offset等内容

为了使用它,你需要创建并持有 ScrollState 实例,参见 rememberScrollState

方法原型

fun Modifier.verticalScroll(
state: ScrollState,
enabled: Boolean = true,
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
)

  • state:滚动状态,ScrollState实例
  • enabled:当触摸事件发生时,是否允许滑动
  • flingBehavior:fling处理逻辑
  • reverseScrolling:是否反向滑动

Demo

Column(
modifier = Modifier
.fillMaxWidth()
.height(600.dp)
.verticalScroll(
state = rememberScrollState()
)
) {
Box(
Modifier
.fillMaxWidth()
.height(400.dp)
.background(Color.Green)
)

Spacer(modifier = Modifier.height(50.dp))

Box(
Modifier
.fillMaxWidth()
.height(400.dp)
.background(Color.Blue)
)

}

很显然,内容高度已经超过了最大限制!

收起阅读 »

View实现3D效果

上次有文章介绍了利用传感器实现3D效果,根据加速度和重力传感器,计算xy偏移值,然后在移动view。1. 利用MotionLayout实现最开始想到的是用motionlayout也可以同样实现,但是最后发现我错了,motionlayout设置的view路径是固...
继续阅读 »

上次有文章介绍了利用传感器实现3D效果,根据加速度和重力传感器,计算xy偏移值,然后在移动view。

1. 利用MotionLayout实现

最开始想到的是用motionlayout也可以同样实现,但是最后发现我错了,motionlayout设置的view路径是固定的,无法在xy轴上自由移动。

一个简单的效果:
1.gif

2. 封装View

利用自定义ViewGroup实现,直接在布局中引用就行了

主要代码

传感器初始化:

//获取传感器XYZ值
private var mAcceleValues : FloatArray ?= null
private var mMageneticValues : FloatArray ?= null
private val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (event?.sensor?.type == Sensor.TYPE_ACCELEROMETER) {
mAcceleValues = event.values
//log("x:${event.values[0]},y:${event.values[1]},z:${event.values[2]}")
}
if (event?.sensor?.type == Sensor.TYPE_MAGNETIC_FIELD) {
mMageneticValues = event.values
}
if (mAcceleValues==null || mMageneticValues==null) return

val values = FloatArray(3)
val R = FloatArray(9)
SensorManager.getRotationMatrix(R, null, mAcceleValues, mMageneticValues);
SensorManager.getOrientation(R, values);

//val z = values[0].toDouble()
// x轴的偏转角度
//val x = Math.toDegrees(values[1].toDouble()).toFloat()
// y轴的偏转角度
//val y = Math.toDegrees(values[2].toDouble()).toFloat()

val degreeZ = Math.toDegrees(values[0].toDouble()).toInt()
val degreeX = Math.toDegrees(values[1].toDouble()).toInt()
val degreeY = Math.toDegrees(values[2].toDouble()).toInt()
log("x:${degreeX},y:${degreeY},z:${degreeZ}")
calculateScroll(degreeX, degreeY, degreeZ)
}

override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
}
}



//传感器初始化
private var hasInit = false
private fun initSensor(){
if (hasInit) return
log("initSensor")
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager

// 重力传感器
val acceleSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorManager.registerListener(listener, acceleSensor, SensorManager.SENSOR_DELAY_GAME)

// 地磁场传感器
val magneticSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
sensorManager.registerListener(listener, magneticSensor, SensorManager.SENSOR_DELAY_GAME)

hasInit = true
}

计算移动距离:

private fun calculateScroll(x: Int, y: Int, z: Int) {
var deltaY = 0
var deltaX = 0

//除去一下特殊的角度
//if (abs(x)==180 || abs(x)==90 || abs(y)==180 || abs(y)==90) return
//if (x !in -90 until 45) return

if (abs(z) > 90){
if (y in -180 until 0){
//从右向左旋转
//x值增加
deltaX = abs(((y / 180.0) * slideDistance).toInt())
}else if (y in 0 until 180){
//从左向右旋转
deltaX = -abs(((y / 180.0) * slideDistance).toInt())
}
}

if (x in -90 until 0){
deltaY = abs((((x) / 180.0) * slideDistance).toInt())
}else if (x in 0 until 90){
deltaY = -abs((((x) / 180.0) * slideDistance).toInt())
}

if (abs(deltaX) < 5) deltaX=0
if (abs(deltaY) < 5) deltaY=0

log("onSensorChanged,scrollX:${deltaX},scrollY:${deltaY},x:${x} y:${y}")
}

滑动:

 scroller.startScroll(deltaX,deltaY, (this.x+deltaX).toInt(),(this.y+deltaY).toInt(),1000)
override fun computeScroll() {
super.computeScroll()
//判断Scroller是否执行完毕
if (scroller.computeScrollOffset()) {
val slideX = scroller.currX
val slideY = scroller.currY

val bottomSlideX = slideX/3
val bottomSlideY = slideY/3

val middleSlideX = slideX/2
val middleSlideY = slideY/2

val topSlideX = -slideX
val topSlideY = -slideY

bottomImageView?.layout(0+bottomSlideX,0+bottomSlideY,measuredWidth+bottomSlideX,measuredHeight+bottomSlideY)
//middleImageView?.layout(middleLeft+middleSlideX,middleTop+middleSlideY,middleRight+middleSlideX,middleBottom+middleSlideY)
topImageView?.layout(topLeft+topSlideX,topTop+topSlideY,topRight+topSlideX,topBottom+topSlideY)

/*(bottomImageView as View).scrollTo(
scroller.currX,
scroller.currY
)*/
//通过重绘来不断调用computeScroll
invalidate()
}
}


属性配置:

<declare-styleable name="layout3d">
<!--上中下层资源文件-->
<attr name="TopLayer" format="reference" />
<attr name="MiddleLayer" format="reference" />
<attr name="BottomLayer" format="reference" />

<!--上中下层滑动距离-->
<attr name="SlideDistance" format="dimension"/>

<!--上中下层是否滑动-->
<attr name="TopSlidingEnable" format="boolean"/>
<attr name="MiddleSlidingEnable" format="boolean"/>
<attr name="BottomSlidingEnable" format="boolean"/>


<!--中层图片资源坐标设置-->
<attr name="MiddleLayerTop" format="dimension"/>
<attr name="MiddleLayerBottom" format="dimension"/>
<attr name="MiddleLayerLeft" format="dimension"/>
<attr name="MiddleLayerRight" format="dimension"/>

<!--上层图片资源坐标设置-->
<attr name="TopLayerTop" format="dimension"/>
<attr name="TopLayerBottom" format="dimension"/>
<attr name="TopLayerLeft" format="dimension"/>
<attr name="TopLayerRight" format="dimension"/>

</declare-styleable>
```

在布局中引入:

<com.example.montionlayout3d.view.Layout3D
android:id="@+id/vg3D"
android:layout_width="match_parent"
android:layout_height="300dp"
android:background="@color/titi"
app:TopLayer="@drawable/circle"
app:MiddleLayer="@drawable/star1"
app:BottomLayer="@drawable/back"
layout3d:MiddleLayerTop="30dp"
layout3d:MiddleLayerBottom="70dp"
layout3d:MiddleLayerLeft="300dp"
layout3d:MiddleLayerRight="340dp"
layout3d:TopLayerTop="50dp"
layout3d:TopLayerBottom="200dp"
layout3d:TopLayerLeft="20dp"
layout3d:TopLayerRight="170dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

最终效果:

2.gif

3.gif


收起阅读 »

Activity启动流程(基于AOSP 11)

当点击Launcher的App icon的时候,点击事件传递给ItemClickHandler的onClickAppShortcut,并最终调用到launcher.startActivitySafely->BaseDraggingActivity.sta...
继续阅读 »

当点击Launcher的App icon的时候,点击事件传递给ItemClickHandler的onClickAppShortcut,并最终调用到launcher.startActivitySafely->BaseDraggingActivity.startActivitySafely:

    public boolean startActivitySafely(View v, Intent intent, @Nullable ItemInfo item,
@Nullable String sourceContainer) {
// ...
// 将Intent的Flag设置为FLAG_ACTIVITY_NEW_TASK
// 这样启动Activity就会在一个新的Activity任务栈中
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// ...
try {
// ... 省略一些快捷方式启动
else if (user == null || user.equals(Process.myUserHandle())) {
// Could be launching some bookkeeping activity
// 启动应用Activity
startActivity(intent, optsBundle);
AppLaunchTracker.INSTANCE.get(this).onStartApp(intent.getComponent(),
Process.myUserHandle(), sourceContainer);
}
// ...
return true;
} catch (NullPointerException|ActivityNotFoundException|SecurityException e) {
Toast.makeText(this, R.string.activity_not_found, Toast.LENGTH_SHORT).show();
Log.e(TAG, "Unable to launch. tag=" + item + " intent=" + intent, e);
}
return false;
}

startActivity的实现位于Activity中:

    public void startActivity(Intent intent, @Nullable Bundle options) {
// ... 都是调用startActivityForResult启动Activity
// 传入的requestCode是-1
if (options != null) {
startActivityForResult(intent, -1, options);
} else {
startActivityForResult(intent, -1);
}
}

启动Activity最终通过Instrumentation的execStartActivity方法启动,而Instrumentation的作用其实是监控应用和系统交互的中间这,是安卓提供的一层Hook操作,让开发者有能力介入到Activity的各项声明周期中,比如可以用来测试,Activity的各项生命周期的回调也是通过这个类作为中间层实现的,execStartActivity核心是调用到:

int result = ActivityTaskManager.getService().startActivity(whoThread,
who.getBasePackageName(), who.getAttributionTag(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()), token,
target != null ? target.mEmbeddedID : null, requestCode, 0, null, options);

ActivityTaskManager是Android 10中新增加的一个类,接替了ActivityManager中Activity与ActivityTask交互的工作,而ActivitTaskManager.getService返回的就是IActivityTaskManager这个AIDL文件中声明的接口,而SystemServer进程中的系统服务ActivityTaskManagerService只需要继承IActivityTaskManager.Stub就可以实现用户进程与服务进程的通信。

new Singleton<IActivityTaskManager>() {
@Override
protected IActivityTaskManager create() {
final IBinder b = ServiceManager.getService(Context.ACTIVITY_TASK_SERVICE);
return IActivityTaskManager.Stub.asInterface(b);
}
};

首先获取到SystemServer进程中ActivityTaskManagerService这个系统服务的IBinder接口,然后将这个IBinder接口转换为IActivityTaskManager接口,这样引用进程就可以调用到IActivityTaskManager的方法,与ATMS进行通信了。

那么应用进程调用startActivity终于还是调用到ATMS的startActivity方法了,到这里,创建Activity的逻辑就从Launcher进程进入到了ATMS所在的SystemServer进程:

在ATMS的startActivity中,调用到ATMS.startActivityAsUser:

    private int startActivityAsUser(IApplicationThread caller, String callingPackage,
@Nullable String callingFeatureId, Intent intent, String resolvedType,
IBinder resultTo, String resultWho, int requestCode, int startFlags,
ProfilerInfo profilerInfo, Bundle bOptions, int userId, boolean validateIncomingUser) {
// 前置权限校验
// 下面获取ActivityStart对象
// 这个对象是控制Activity启动的代理对象
// 7.0以后加入,收集Activity启动相关的各种参数逻辑
// 决定如何启动Activity一级Activity的Task和TaskStack关联
return getActivityStartController().obtainStarter(intent, "startActivityAsUser")
// ... 设置了很多参数
.execute();

}

在对ActivityStarter对象设置完一堆参数之后,调用其execute执行启动:

    int execute() {
try {
// ...
int res;
synchronized (mService.mGlobalLock) {
// ...
// 启动信息都在mRequest中了,执行这个请求
res = executeRequest(mRequest);
// 启动完成之后的一些通知等工作......
}
} finally {
onExecutionComplete();
}
}

executeRequest里边的逻辑很多,主要包含各种校验逻辑,比如检验启动参数,Flag等,总之,正常启动流程会去根据各种启动参数创建一个ActivityRecord对象,ActivityRecord对象就代表着一个在任务栈中的Activity实体,最后调用到ActivityStarter的startActivityUnchecked。

startActivityUnchecked->startActivityInner,在startActivityInner中,会先去对Activity启动Flag进行各种校验,比如如果启动的是一个根Activity,但是启动Flag并不是FLAG_ACTIVITY_NEW_TASK,那么会将LaunchFlag修改为FLAG_ACTIVITY_NEW_TASK。然后找到是否有可以重用的任务栈,如果没有,会去创建一个新的任务栈,在startActivityInner中关于启动的核心逻辑是调用:

mRootWindowContainer.resumeFocusedStacksTopActivities(
mTargetStack, mStartActivity, mOptions);

RootWindowContainer是在WMS中创建的对象,可以理解为其管理者屏幕显示的内容。在RootWindowContainer中,会调用到ActivityStack的resumeTopActivityUncheckedLocked->resumeTopActivityInnerLocked。这个方法在11上长达400+行,逻辑很多,和我们要分析的主流程相关的重点如下:

// attachedToProcess的判断逻辑:要启动的Activity的进程是否存在以及
// 对应进程的ActivityThread是否存在,而我们这里启动的是一个新进程
// 的根Activity,此时新进程还未创建以及ActivityRecord还没有绑定到新进程中
if (next.attachedToProcess()) {
// ...
} else {
// ...
mStackSupervisor.startSpecificActivity(next, true, true);
}

ActivityStackSupervisor的startSpecificActivity如下:

    void startSpecificActivity(ActivityRecord r, boolean andResume, boolean checkConfig) {
// Is this activity's application already running?
final WindowProcessController wpc =
mService.getProcessController(r.processName, r.info.applicationInfo.uid);

boolean knownToBeDead = false;
// 如果应用所在的进程已经存在,那么执行realStartActivityLocked
// 进行Activity启动
if (wpc != null && wpc.hasThread()) {
try {
// 这个函数后面再做分析
realStartActivityLocked(r, wpc, andResume, checkConfig);
return;
} catch (RemoteException e) {
Slog.w(TAG, "Exception when starting activity "
+ r.intent.getComponent().flattenToShortString(), e);
}

// If a dead object exception was thrown -- fall through to
// restart the application.
knownToBeDead = true;
}

r.notifyUnknownVisibilityLaunchedForKeyguardTransition();

final boolean isTop = andResume && r.isTopRunningActivity();
// 如果应用进程不存在,ATMS(最终是AMS)将会异步启动应用进程
mService.startProcessAsync(r, knownToBeDead, isTop, isTop ? "top-activity" : "activity");
}

对于应用进程不存在的情况,AMS会往SystemServer的消息队列中发送一个Lambda类型的Message(其实就是类似Runnable类型的消息),然后消息执行的时候,调用关系:

AMS.startProcessAsync->AMS.startProcess->AMS.startProcessLocked->ProcessList.startProcessLocked...->ProcessList.startProcess->Process.start,最终通过ZygoteProcess调用startViaZygote,通过Zygote进程fork出应用进程,完成应用进程的创建,在应用进程创建的同时,会往SystemServer的消息队列中发送一条超时消息(默认10s),超时到了应用进程仍然未启动的话,就会杀死这个启动失败的进程。

而在Zygote fork出子进程之后,就会执行到ActivityThrea的main方法,在ActivityThread的main方法中,做的最重要的两件事,开启主线程的Looper机制和创建ActivityThread并调用ActivityThread的attach函数,其中:

// mAppThread是ApplicationThread对象,在ActivityThread被创建的时候创建
// 是AMS与应用进程通信的桥梁
// 这里初始化RuntimeInit.mApplicationObject值
RuntimeInit.setApplicationObject(mAppThread.asBinder());
// 获取到AMS的Binder接口IActivityManager
final IActivityManager mgr = ActivityManager.getService();
try {
// 调用AMS的attachApplication
mgr.attachApplication(mAppThread, startSeq);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}

AMS中attachApplication实现如下:

    public final void attachApplication(IApplicationThread thread, long startSeq) {
// 如果传入的ApplicationThread是空的,那么就会异常终止应用进程
if (thread == null) {
throw new SecurityException("Invalid application interface");
}
synchronized (this) {
// 获取binder调用方进程的pid和uid,这里的调用方就是应用进程
int callingPid = Binder.getCallingPid();
final int callingUid = Binder.getCallingUid();
// 修改binder调用端的pid、uid为调用进程的pid、uid,并返回原uid和pid的组合
// 高32位表示上面的uid,低32位表示pid
final long origId = Binder.clearCallingIdentity();
attachApplicationLocked(thread, callingPid, callingUid, startSeq);
// 恢复pid、uid
Binder.restoreCallingIdentity(origId);
}
}

attachApplicationLocked又是一个超长函数,内部主要是对App进程做一些检查设置等工作。

// 内部首先会去查找有没有旧的进程记录,如果有,做一些清理类的工作
// ...
// 然后比如debug模式也会做一些特殊的设置工作
// ...
// 再比如建立Binder死亡回调,当进AMS所在服务程异常退出的时候,
// Binder驱动可以通知到应用进程,并释放死亡进程锁占用的没有正常关闭的binder文件。
// 当SystemServer进程因为异常挂掉的时候,是有重启机制的
// ...
// 移除在AMS启动进程的过程中埋下的PROC_START_TIMEOUT_MSG消息
mHandler.removeMessages(PROC_START_TIMEOUT_MSG, app);
// ...
// IPC调用应用进程ApplicationThread的bindApplication:
thread.bindApplication(...);

AMS在完成对ApplicationThread一些设置工作后,会回到应用进程的bindApplication,bindApplication的时候,AMS会将一些通用的系统服务传递给应用进程,比如WMS,IMS等等。

public final void bindApplication(...) {
if (services != null) {
// 将AMS传过来的通用系统服务缓存到ServiceManager的
// 静态cache中
ServiceManager.initServiceCache(services);
}
// 通过ActivityThread的H(继承自Handler)
// 发送一条SET_CORE_SETTINGS的消息给主线程,
// 将coreSettings设置给ActivityThread对象
// 并触发当前进程所有Activity的重启(因为ActivityThread的核心设置被修改了)
setCoreSettings(coreSettings);
// AppBindData是bindApplication的一堆数据的封装
AppBindData data = new AppBindData();
// 通过mH发送BIND_APPLICATION消息给主线程
sendMessage(H.BIND_APPLICATION, data);
}

下面bindApplication的逻辑进入H中,H是ActivityThread中的自定义Handler,比如Service的创建,绑定,Activity的生命周期处理都在这里边:

而对于BIND_APPLICATION,handleMessage将调用到handleBindApplication。

在handleBindApplication中,会填充data.info字段,其实是LoadApk对象,通过getPackageInfo获取,其包含了比如mPackagerName应用包名,mDataDir应用数据目录,mResources,mApplication等信息。

然后是完成Context的创建:

final ContextImpl appContext = ContextImpl.createAppContext(this, data.info);
// createAppContext
static ContextImpl createAppContext(ActivityThread mainThread, LoadedApk packageInfo,
String opPackageName) {
if (packageInfo == null) throw new IllegalArgumentException("packageInfo");
// 创建ContextImpl对象,初始化了ApplicationContext的mMainThread、packageInfo等字段
// 在ContextImpl的构造方法中,还创建了ApplicationContentResolver这个对象
ContextImpl context = new ContextImpl(null, mainThread, packageInfo, null, null, null, null,
0, null, opPackageName);
context.setResources(packageInfo.getResources());
// 是否是系统状态栏的Context
context.mIsSystemOrSystemUiContext = isSystemOrSystemUI(context);
return context;
}

完成Context的创建之后,后面会继续Application对象的创建,还是在handleBindApplication中:

Application app;
// ...
app = data.info.makeApplication(data.restrictedBackupMode, null);

// LoadedApk.makeApplication:
// ...
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
// 调用newApplication创建Application对象
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
// ...
// 将Application赋值给LoadedApk的mApplication,并添加到ActivityThread的Application列表
mApplication = app;

在newApplication中:

public Application newApplication(ClassLoader cl, String className, Context context)
throws InstantiationException, IllegalAccessException,
ClassNotFoundException {
// 内部就是反射Application Class的newInstance
Application app = getFactory(context.getPackageName())
.instantiateApplication(cl, className);
// 将Context对象绑定给Application
app.attach(context);
return app;
}

// attach如下:
final void attach(Context context) {
// attachBaseContext就是将context赋值给Application(继承自ContextWrapper的)的mBase字段
attachBaseContext(context);
mLoadedApk = ContextImpl.getImpl(context).mPackageInfo;
}

继续回到应用进程的handleBindApplication中,在创建完Application对象之后:

// 回调到Application对象的onCreate方法
mInstrumentation.callApplicationOnCreate(app);

应用进程终于创建完成了,现在我们回到AMS的attachApplicationLocked中,在bindApplication完成Application对象的创建之后:

// 如果Application有待启动的Activity,那么执行Activity的启动
// 对于我们从Launcher中点击icon启动一个App来说,创建完新的进程和Application之后
// 就需要启动MainActivity
if (normalMode) {
try {
didSomething = mAtmInternal.attachApplication(app.getWindowProcessController());
} catch (Exception e) {
Slog.wtf(TAG, "Exception thrown launching activities in " + app, e);
badApp = true;
}
}
// ActivityTaskManagerService的内部类LocalService实现的attachApplication方法:
// 内部调用
mRootWindowContainer.attachApplication(wpc);

RootWindowContainer.attachApplication如下:

    boolean attachApplication(WindowProcessController app) throws RemoteException {
boolean didSomething = false;
for (int displayNdx = getChildCount() - 1; displayNdx >= 0; --displayNdx) {
mTmpRemoteException = null;
mTmpBoolean = false; // Set to true if an activity was started.

final DisplayContent display = getChildAt(displayNdx);
for (int areaNdx = display.getTaskDisplayAreaCount() - 1; areaNdx >= 0; --areaNdx) {
final TaskDisplayArea taskDisplayArea = display.getTaskDisplayAreaAt(areaNdx);
for (int taskNdx = taskDisplayArea.getStackCount() - 1; taskNdx >= 0; --taskNdx) {
final ActivityStack rootTask = taskDisplayArea.getStackAt(taskNdx);
if (rootTask.getVisibility(null /*starting*/) == STACK_VISIBILITY_INVISIBLE) {
break;
}
// 找到待启动的ActivityRecord,然后
// 调用startActivityForAttachedApplicationIfNeeded
final PooledFunction c = PooledLambda.obtainFunction(
RootWindowContainer::startActivityForAttachedApplicationIfNeeded, this,
PooledLambda.__(ActivityRecord.class), app,
rootTask.topRunningActivity());
rootTask.forAllActivities(c);
c.recycle();
if (mTmpRemoteException != null) {
throw mTmpRemoteException;
}
}
}
didSomething |= mTmpBoolean;
}
if (!didSomething) {
ensureActivitiesVisible(null, 0, false /* preserve_windows */);
}
return didSomething;
}

而startActivityForAttachedApplicationIfNeeded的实现,就是把我们找到的待启动的ActivityRecord,调用到mStackSupervisor.realStartActivityLocked。

回到ActivityStackSupervisor的startSpecificActivity:

    void startSpecificActivity(ActivityRecord r, boolean andResume, boolean checkConfig) {
// Is this activity's application already running?
final WindowProcessController wpc =
mService.getProcessController(r.processName, r.info.applicationInfo.uid);

boolean knownToBeDead = false;
// 如果应用所在的进程已经存在,那么执行realStartActivityLocked
// 进行Activity启动
if (wpc != null && wpc.hasThread()) {
try {
// 这个函数后面再做分析
realStartActivityLocked(r, wpc, andResume, checkConfig);
return;
} catch (RemoteException e) {
Slog.w(TAG, "Exception when starting activity "
+ r.intent.getComponent().flattenToShortString(), e);
}

// If a dead object exception was thrown -- fall through to
// restart the application.
knownToBeDead = true;
}

r.notifyUnknownVisibilityLaunchedForKeyguardTransition();

final boolean isTop = andResume && r.isTopRunningActivity();
// 如果应用进程不存在,ATMS(最终是AMS)将会异步启动应用进程
mService.startProcessAsync(r, knownToBeDead, isTop, isTop ? "top-activity" : "activity");
}

AMS在调用到mService.startProcessAsync启动一个新的应用进程之后,最终还是通过ActivityStackSupervisor的realStartActivityLocked,启动应用进程的首个Activity,那么再往下看,就是一般Activity的启动流程了,realStartActivityLocked仍然比较长,取我们关心的逻辑:

// 创建一个Activity启动的事务,ClientTransaction是一个实现了Parcelable接口的类
// 即该事务对象可以跨进程传递
final ClientTransaction clientTransaction = ClientTransaction.obtain(
proc.getThread(), r.appToken);
final DisplayContent dc = r.getDisplay().mDisplayContent;
// 往事务中添加一个LaunchActivityItem,代表事务的Callback,后面会执行Callback中的逻辑
clientTransaction.addCallback(LaunchActivityItem.obtain(new Intent(r.intent),...));
// ...
// 调度事务,
mService.getLifecycleManager().scheduleTransaction(clientTransaction);

先来看下事务是怎样获取的:

public static ClientTransaction obtain(IApplicationThread client, IBinder activityToken) {
// 先从对象池中找,找不到就创建
ClientTransaction instance = ObjectPool.obtain(ClientTransaction.class);
if (instance == null) {
instance = new ClientTransaction();
}
// 将IApplicationThread赋值给事务的mClient字段,
// 表示这个事务的接收端是哪个进程的ApplicationThread
instance.mClient = client;
instance.mActivityToken = activityToken;
return instance;
}

ClinetLifeCycleManager的调度事务实现如下:

void scheduleTransaction(ClientTransaction transaction) throws RemoteException {
final IApplicationThread client = transaction.getClient();
transaction.schedule();
// ...
}
//transaction.schedule();实现如下,IPC调用到应用进程ApplicationThread的scheduleTransaction
mClient.scheduleTransaction(this);

而ApplicationThread中的scheduleTransaction实现,就是调用ActivityThread的scheduleTransaction方法(AT继承自ClientTransactionHandler,实现在父类中):

void scheduleTransaction(ClientTransaction transaction) {
// 当事务在客户端进程执行的时候,在执行之前做一个hook callback操作
transaction.preExecute(this);
sendMessage(ActivityThread.H.EXECUTE_TRANSACTION, transaction);
}

发送出去的消息最终还是交给H来处理:

final ClientTransaction transaction = (ClientTransaction) msg.obj;
mTransactionExecutor.execute(transaction);

mTransactionExecutor并不是什么多线程的Executor,不过是对事务有序处理的逻辑封装,其execute如下:

public void execute(ClientTransaction transaction) {
// ...
// executeCallbacks中的重点就是执行传入事务的callback的execute方法
// item.execute(mTransactionHandler, token, mPendingActions);
//
executeCallbacks(transaction);
executeLifecycleState(transaction);
mPendingActions.clear();
// ...
}

而AMS在调用realStartActivityLocked传入事务的callback就是LaunchActivityItem,他的execute如下:

public void execute(ClientTransactionHandler client, IBinder token,
PendingTransactionActions pendingActions) {
// 创建一个ActivityClientRecord对象
ActivityClientRecord r = new ActivityClientRecord(token, mIntent, mIdent, mInfo,
mOverrideConfig, mCompatInfo, mReferrer, mVoiceInteractor, mState, mPersistentState,
mPendingResults, mPendingNewIntents, mIsForward,
mProfilerInfo, client, mAssistToken, mFixedRotationAdjustments);
// 这里client实际上就是TransactionExecutor中的mTransactionHandler
client.handleLaunchActivity(r, pendingActions, null /* customIntent */);
}

在ActivityThread中,ActivityThread对象创建的时候就会创建mTransactionExecutor对象,并把this引用传入到TransactionExecutor的构造方法中,所以这里mTransactionHandler正是实现了ClientTransactionHandler接口的ActivityThread对象,在ActivityThread的handleLaunchActivity中:

final Activity a = performLaunchActivity(r, customIntent);

而performLaunchActivity就会去创建对应的Activity对象,关于performLaunchActivity的分析,可以看前两篇Android UI系统工作流程。

收起阅读 »

面试常问的ACTION_CANCEL到底何时触发,滑出子View范围会发生什么?

看完本文你将了解:ACTION_CANCEL的触发时机滑出子View区域会发生什么?为什么不响应onClick()事件首先看一下官方的解释:/** * Constant for {@link #getActionMasked}: The current ge...
继续阅读 »

看完本文你将了解:

  • ACTION_CANCEL的触发时机
  • 滑出子View区域会发生什么?为什么不响应onClick()事件

首先看一下官方的解释:

/**
* Constant for {@link #getActionMasked}: The current gesture has been aborted.
* You will not receive any more points in it. You should treat this as
* an up event, but not perform any action that you normally would.
*/

public static final int ACTION_CANCEL = 3;

说人话就是:当前的手势被中止了,你不会再收到任何事件了,你可以把它当做一个ACTION_UP事件,但是不要执行正常情况下的逻辑。

ACTION_CANCEL的触发时机

有四种情况会触发ACTION_CANCEL:

  • 在子View处理事件的过程中,父View对事件拦截
  • ACTION_DOWN初始化操作
  • 在子View处理事件的过程中被从父View中移除时
  • 子View被设置了PFLAG_CANCEL_NEXT_UP_EVENT标记时
1,父view拦截事件

首先要了解ViewGroup什么情况下会拦截事件,Look the Fuck Resource Code:

/**
* {@inheritDoc}
*/

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
...

boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;
...
// Check for interception.
final boolean intercepted;
// 判断条件一
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
// 判断条件二
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
...
}
...
}

有两个条件

  • MotionEvent.ACTION_DOWN事件或者mFirstTouchTarget非空也就是有子view在处理事件
  • 子view没有做拦截,也就是没有调用ViewParent#requestDisallowInterceptTouchEvent(true)

如果满足上面的两个条件才会执行onInterceptTouchEvent(ev)

如果ViewGroup拦截了事件,则intercepted变量为true,接着往下看:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
...

// Check for interception.
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
// 当mFirstTouchTarget != null,也就是子view处理了事件
// 此时如果父ViewGroup拦截了事件,intercepted==true
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}

...

// Dispatch to touch targets.
if (mFirstTouchTarget == null) {
...
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
...
} else {
// 判断一:此时cancelChild == true
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;

// 判断二:给child发送cancel事件
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}
...
}
...
}
}
...
}
...
return handled;
}

以上判断一处cancelChild为true,然后进入判断二中一看究竟:

private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
View child, int desiredPointerIdBits) {
final boolean handled;

// Canceling motions is a special case. We don't need to perform any transformations
// or filtering. The important part is the action, not the contents.
final int oldAction = event.getAction();
if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
// 将event设置成ACTION_CANCEL
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
...
} else {
// 分发给child
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}
...
}

当参数cancel为ture时会将event设置为MotionEvent.ACTION_CANCEL,然后分发给child。

2,ACTION_DOWN初始化操作
public boolean dispatchTouchEvent(MotionEvent ev) {

boolean handled = false;
if (onFilterTouchEventForSecurity(ev)) {
final int action = ev.getAction();
final int actionMasked = action & MotionEvent.ACTION_MASK;

// Handle an initial down.
if (actionMasked == MotionEvent.ACTION_DOWN) {
// Throw away all previous state when starting a new touch gesture.
// The framework may have dropped the up or cancel event for the previous gesture
// due to an app switch, ANR, or some other state change.
// 取消并清除所有的Touch目标
cancelAndClearTouchTargets(ev);
resetTouchState();
}
...
}
...
}

系统可能会由于App切换、ANR等原因丢失了up,cancel事件。

因此需要在ACTION_DOWN时丢弃掉所有前面的状态,具体代码如下:

private void cancelAndClearTouchTargets(MotionEvent event) {
if (mFirstTouchTarget != null) {
boolean syntheticEvent = false;
if (event == null) {
final long now = SystemClock.uptimeMillis();
event = MotionEvent.obtain(now, now,
MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
syntheticEvent = true;
}

for (TouchTarget target = mFirstTouchTarget; target != null; target = target.next) {
resetCancelNextUpFlag(target.child);
// 分发事件同情况一
dispatchTransformedTouchEvent(event, true, target.child, target.pointerIdBits);
}
...
}
}

PS:在dispatchDetachedFromWindow()中也会调用cancelAndClearTouchTargets()

3,在子View处理事件的过程中被从父View中移除时
public void removeView(View view) {
if (removeViewInternal(view)) {
requestLayout();
invalidate(true);
}
}

private boolean removeViewInternal(View view) {
final int index = indexOfChild(view);
if (index >= 0) {
removeViewInternal(index, view);
return true;
}
return false;
}

private void removeViewInternal(int index, View view) {

...
cancelTouchTarget(view);
...
}

private void cancelTouchTarget(View view) {
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (target.child == view) {
...
// 创建ACTION_CANCEL事件
MotionEvent event = MotionEvent.obtain(now, now,
MotionEvent.ACTION_CANCEL, 0.0f, 0.0f, 0);
event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
分发给目标view
view.dispatchTouchEvent(event);
event.recycle();
return;
}
predecessor = target;
target = next;
}
}
4,子View被设置了PFLAG_CANCEL_NEXT_UP_EVENT标记时

在情况一种的两个判断处:

// 判断一:此时cancelChild == true
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;

// 判断二:给child发送cancel事件
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}

resetCancelNextUpFlag(target.child)为true时同样也会导致cancel,查看代码:

/**
* Indicates whether the view is temporarily detached.
*
* @hide
*/

static final int PFLAG_CANCEL_NEXT_UP_EVENT = 0x04000000;

private static boolean resetCancelNextUpFlag(View view) {
if ((view.mPrivateFlags & PFLAG_CANCEL_NEXT_UP_EVENT) != 0) {
view.mPrivateFlags &= ~PFLAG_CANCEL_NEXT_UP_EVENT;
return true;
}
return false;
}

根据注释大概意思是,该view暂时detacheddetached是什么意思?就是和attached相反的那个,具体什么时候打了这个标记,我觉得没必要深究。

以上四种情况最重要的就是第一种,后面的只需了解即可。

滑出子View区域会发生什么?

了解了什么情况下会触发ACTION_CANCEL,那么针对问题:滑出子View区域会触发ACTION_CANCEL吗?这个问题就很明确了:不会。

实践是检验真理的唯一标准,代码撸起来:

public class MyButton extends androidx.appcompat.widget.AppCompatButton {

@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
LogUtil.d("ACTION_DOWN");
break;
case MotionEvent.ACTION_MOVE:
LogUtil.d("ACTION_MOVE");
break;
case MotionEvent.ACTION_UP:
LogUtil.d("ACTION_UP");
break;
case MotionEvent.ACTION_CANCEL:
LogUtil.d("ACTION_CANCEL");
break;
}
return super.onTouchEvent(event);
}
}

一波操作以后日志如下:

(MyButton.java:32) -->ACTION_DOWN (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:36) -->ACTION_MOVE (MyButton.java:39) -->ACTION_UP

滑出view后依然可以收到ACTION_MOVEACTION_UP事件。

为什么有人会认为滑出view后会收到ACTION_CANCEL呢?

我想是因为滑出view后,view的onClick()不会触发了,所以有人就以为是触发了ACTION_CANCEL

那么为什么滑出view后不会触发onClick呢?再来看看View的源码:

在view的onTouchEvent()中:

case MotionEvent.ACTION_MOVE:
// Be lenient about moving outside of buttons
// 判断是否超出view的边界
if (!pointInView(x, y, mTouchSlop)) {
// Outside button
if ((mPrivateFlags & PRESSED) != 0) {
// 这里改变状态为 not PRESSED
// Need to switch from pressed to not pressed
mPrivateFlags &= ~PRESSED;
}
}
break;

case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
// 可以看到当move出view范围后,这里走不进去了
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
...
performClick();
...
}
mIgnoreNextUpEvent = false;
break;

1,在ACTION_MOVE中会判断事件的位置是否超出view的边界,如果超出边界则将mPrivateFlags置为not PRESSED状态。

2,在ACTION_UP中判断只有当mPrivateFlags包含PRESSED状态时才会执行performClick()等。

因此滑出view后不会执行onClick()

结论:
  • 滑出view范围后,如果父view没有拦截事件,则会继续受到ACTION_MOVEACTION_UP等事件。
  • 一旦滑出view范围,view会被移除PRESSED标记,这个是不可逆的,然后在ACTION_UP中不会执行performClick()等逻辑。
收起阅读 »

Kotlin 写自定义 ViewGroup

Android 最近推行的 Compose ,有着 Kotlin 的加持,使写 UI 更加方便快速,不用担心布局嵌套,还是声明式 UI,那么 Compose 有这么多好处,原生写法还有 “出路” 吗?今天给大家分享一种非传统的自定义 ViewGroup 写法,...
继续阅读 »

Android 最近推行的 Compose ,有着 Kotlin 的加持,使写 UI 更加方便快速,不用担心布局嵌套,还是声明式 UI,那么 Compose 有这么多好处,原生写法还有 “出路” 吗?

今天给大家分享一种非传统的自定义 ViewGroup 写法,让你对自定义 ViewGroup 不再 “恐惧”,再借助 Kotlin ,我们用原生写法,也可以快速写出无嵌套的布局。

为什么要用自定义 ViewGroup

平时大家写 UI,都直接用 xml 去写布局,或多或少都会注意去避免布局嵌套,自从有了 ConstraintLayout,嵌套的情况就减少了许多,但用 ConstraintLayout 就能达到极致性能吗?整体上相较于其他 Layout ,性能确实有所提升。但对于产品中具体的页面,可能就不会达到极致性能,因为 ConstraintLayout 要考虑的场景太多了,导致其逻辑很复杂,对于确定的页面来说,一个有 “针对性” 的自定义 ViewGroup ,是能够超越 ConstraintLayout 的,因为你只需要对一个页面负责即可,不用考虑那么全。

这里我所说的自定义 ViewGroup 就是用代码去写布局,不是写一个公共的控件让别人去使的那种。Telegram 的布局就全部用代码去写的。

那么我们平时为什么不去用代码写布局呢?

  • 自定义 ViewGroup 太复杂了,什么 MeasureSpec 情况有一堆。
  • 每次写都忘,还得去查,学了就忘
  • 效率太低,我为了那点性能提升,没必要

总结一下,就是因为自定义 ViewGroup 比较难,还费事。以前用 Java 写代码确实会比较麻烦,但现在有了 Kotlin,也可以很优雅的去用代码写布局了。接下来,我就带大家来捋一下自定义 ViewGroup 的流程,然后用 Kotlin 去实现。

自定义 ViewGroup 要做什么

一个 ViewGroup 有哪几步,我想大家都知道,无非就是测量、布局、绘制,就这三步,测量就是把子 View 的大小测量一下,再算一下自己的大小;布局就是设置一下子 View 的位置;绘制对于 ViewGroup 来说一般不需要,无非就是在自己这画点什么东西。这么看也不是很难嘛,那么难的是哪里呢?我想就是因为下面这张表:

img_01.png

就是测量的时候的各种模式,很多书上讲自定义 View 的时候都会给出这张表,其实这是作者自己总结出来的,Android 官网上是没有这些东西的。

现在让我们忘记上面这张表,就只看一下有几种测量模式:EXACTLY、AT_MOST、UNSPECIFIED,这三个英文意思已经很明确了。

  • EXACTLY 就是精确的,就是你设置多少就是多少
  • AT_MOST 就是最多能用多少,就是子 View 有多大,就给多大
  • UNSPECIFIED 就是不确定的,这种一般都是需要再次测量的,就比如 LinearLayout 使用 weight

其中 UNSPECIFIED 对于我们用代码写布局这种情况,几乎就不会用到,这种就可以不用考虑,那么就只剩下两种模式了,总结一下就是 “View 实际多少就是多少” 和 "View 最多能用多少",这么一想,是不是就没那么复杂了。光说大家估计也没有具体的概念,接下来上代码。

如何借助 Kotlin 提升写 ViewGroup 效率

接下来,让我们用 Kotlin 的扩展方法,来一步一步去完成自定义 ViewGroup 需要的东西。

测量的时候要传一个 MeasureSpec 对象,这个对象是根据宽高 Int 值和测量模式确定的,有了 Kotlin ,我们是不是可以直接给 Int 定义一个扩展方法,来获取这个 Int 值的 MeasureSpec 不就行了,来,看代码:

// EXACTLY 的测量模式
fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
// AT_MOST 的测量模式
fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

我们给一个控件设置宽高,一般都是给个具体的值,要么就是 MATCH_PARENT 或者 WRAP_CONTENT,那么我们是不是也这种常见的情况抽成一个方法呢?有了 Kotlin 我们可以直接在这个 View 上弄个扩展方法,来获取它的默认宽高:

// 获取 View 宽度的默认测量值
fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.width) {
// 如果是 MATCH_PARENT,就说明它要填满父布局,那就给它一个父布局宽度的精确值呗
MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
// 如果是 WRAP_CONTENT,就说明它满足自己的大小就行,那就给它最多能用的大小就行了
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
// 0 就是不确定的,这里我们有 UI 稿,就没有不确定的情况了,所以这里就不用考虑了
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
// 最后就是具体的值了,那就给你具体的呗
else -> layoutParams.width.toExactlyMeasureSpec()
}
}
// 获取 View 高度的默认测量值,和上面获取宽度的原理一样
fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.height) {
MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
else -> layoutParams.height.toExactlyMeasureSpec()
}
}

好了,有了这些,我们再写自定义 ViewGroup 是不是就简单多了,我们测量一个控件,直接这些写就可以了:

textView.measure(textView.defaultWidthMeasureSpec(this), textView.defaultHeightMeasureSpec(this))

等等,这样写还是有点复杂,我们为什么不干脆再定义一个扩展方法,让 View 直接按默认的测量好了:

fun View.autoMeasure(parent: ViewGroup) {
measure(
this.defaultWidthMeasureSpec(parent),
this.defaultHeightMeasureSpec(parent)
)
}

这样下次使用就可以这样写了:

textView.autoMeasure(this)

是不是更简单了,到这,测量的基本代码差不多就写完了,顺便把布局的基础方法也写一下吧,布局就比较简单了,就是告诉子 View 的位置就好了。

// 设置 view 的位置
fun View.autoLayout(parent: ViewGroup, x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
// 判断布局是不是从右边开始
if (!fromRight) {
// 注意这里为什么用 measuredWidth 而不是用 width
// 因为 width 是通过 mRight - mLeft 计算的,而这时它俩都没有被赋值,所以都是 0
layout(x, y, x + measuredWidth, y + measuredHeight)
} else {
autoLayout(parent.measuredWidth - x - measuredWidth, y)
}
}

我们其实可以把这些方法都写到一个类,以后写自定义 ViewGroup,直接继承它就可以了,就像下面这样:

 // 为了方便设置 dp sp,直接在这里声明了扩展属性
val Int.dp
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this.toFloat(),
Resources.getSystem().displayMetrics
).toInt()
val Float.sp
get() = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, this,
Resources.getSystem().displayMetrics
)

abstract class CustomViewGroup(context: Context) : ViewGroup(context) {

// 方便获取带 Margin 的宽高
protected val View.measuredWidthWithMargins get() = measuredWidth + marginStart + marginEnd
protected val View.measuredHeightWithMargins get() = measuredHeight + marginTop + marginBottom

protected fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)

protected fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

protected fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.width) {
MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
else -> layoutParams.width.toExactlyMeasureSpec()
}
}

protected fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
return when (layoutParams.height) {
MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
0 -> throw IllegalAccessException("我不考虑这种情况 $this")
else -> layoutParams.height.toExactlyMeasureSpec()
}
}

protected fun View.autoMeasure() {
measure(
this.defaultWidthMeasureSpec(this@CustomViewGroup),
this.defaultHeightMeasureSpec(this@CustomViewGroup)
)
}

protected fun View.autoLayout(x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
if (!fromRight) {
layout(x, y, x + measuredWidth, y + measuredHeight)
} else {
autoLayout(this@CustomViewGroup.measuredWidth - x - measuredWidth, y)
}
}
}

写个自定义 ViewGroup 试试

img_02.png

就以计算器界面为例吧。上面👆是通过 ConstraintLayout 实现的,看看有哪些控件,1 个 EditText,17 个 Button。我们来试试用自定义 ViewGroup 来简单复刻一下,直接上代码吧:

class CalculatorLayout(context: Context) : CustomViewGroup(context) {
// 我们可以直接这样在把控件 new 出来,设置一些属性,这样我们还省去了 findViewById,而且不用担心空指针
val etResult = AppCompatEditText(context).apply {
typeface = ResourcesCompat.getFont(context, R.font.comfortaa_regular)
setTextColor(ResourcesCompat.getColor(resources, R.color.white, null))
background = null
textSize = 65f
gravity = Gravity.BOTTOM or Gravity.END
maxLines = 1
isFocusable = false
isCursorVisible = false
setPadding(16.dp, paddingTop, 16.dp, paddingBottom)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
// 注意,这里直接 add 不会触发 onMeasure 这些流程,可以放心 add
addView(this)
}
// 数字键盘后面的背景
val keyboardBackgroundView = View(context).apply {...}

// 抽出一个相同样式的按钮
class NumButton(context: Context, text: String, parent: ViewGroup) : AppCompatTextView(context) {
init {
setText(text)
gravity = Gravity.CENTER
background =
ResourcesCompat.getDrawable(resources, R.drawable.ripple_cal_btn_num, null)
layoutParams =
MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
leftMargin = 2.dp
rightMargin = 2.dp
topMargin = 6.dp
bottomMargin = 6.dp
}
isClickable = true
setTextAppearance(context, R.style.StyleCalBtn)
parent.addView(this)
}
}

// 具体的数组按钮
val btn0 = NumButton(context, "0", this)
...

init {
// 给自己设置个背景
background = ResourcesCompat.getDrawable(resources, R.drawable.shape_cal_bg, null)
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 先算一下数字按钮的大小
val allSize =
measuredWidth - keyboardBackgroundView.paddingLeft - keyboardBackgroundView.paddingRight -
btn0.marginLeft * 8
val numBtSize = (allSize * (1 / 3.8)).toInt()
// 计算操作按钮的大小
val operatorBtWidth = (allSize * (0.8 / 3.8)).toInt()
val operatorBtHeight = (numBtSize * 4 + btn0.marginTop * 6 - btnDel.marginTop * 8) / 5

// 再计算数字盘的高度
val keyboardHeight =
keyboardBackgroundView.paddingTop + keyboardBackgroundView.paddingBottom +
numBtSize * 4 + btn0.marginTop * 8

// 最后把高度剩余空间都给 EditText
val editTextHeight = measuredHeight - keyboardHeight

// 测量背景
keyboardBackgroundView.measure(
measuredWidth.toExactlyMeasureSpec(),
keyboardHeight.toExactlyMeasureSpec()
)

// 测量按钮
btn0.measure(numBtSize.toExactlyMeasureSpec(), numBtSize.toExactlyMeasureSpec())
...
btnDiv.measure(operatorBtWidth.toExactlyMeasureSpec(), operatorBtHeight.toExactlyMeasureSpec())
...

// 测量 EditText
etResult.measure(
measuredWidth.toExactlyMeasureSpec(),
editTextHeight.toExactlyMeasureSpec()
)

// 最后设置自己的宽高
setMeasuredDimension(measuredWidth, measuredHeight)
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 好了,测量都完了,就一个个放吧
// 先放 EditText
etResult.autoLayout()

// 把背景放上
keyboardBackgroundView.autoLayout(0, etResult.bottom)

// 开始放按钮吧
btn7.let {
it.autoLayout(
keyboardBackgroundView.paddingLeft + it.marginLeft,
keyboardBackgroundView.top + keyboardBackgroundView.paddingTop + it.marginTop
)
}
btn8.let {
it.autoLayout(
btn7.right + btn7.marginRight + it.marginLeft,
btn7.top
)
}
btn9.let {
it.autoLayout(
btn8.right + btn8.marginRight + it.marginLeft,
btn7.top
)
}
...
}
}

Ok,以上就完成了自定义 ViewGroup,我们可以直接这样用了:

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val contentView = CalculatorLayout(this)
setContentView(contentView)

// 不用 findViewById 和 ktx插件、ViewBinding 这些东西,直接用就可以
contentView.btnDel.setOnClickListener {
contentView.etResult.setText("")
}
}

看看最终的对比效果:

img_09.jpg

看上去还算可以,怎么样,是不是用 Kotlin 代码写布局也不是很复杂,缺点就是不能在 Android Studio 上预览。

结束了

虽然与 xml 的书写相比,确实有些麻烦,但熟练之后,我感觉都差不多,还能帮助我们对自定义 View 这块更加熟悉,感兴趣的小伙伴可以在项目不忙的时候先试试这种写法。当然,也可以试试 Compose,其实 Compose 最终也是一个 ViewGroup ,可以看看 AndroidComposeView ,它最终也是会添加到 DecorView 上。

收起阅读 »

Android开发太难了:Java Lambda ≠ Android Lambda

我又来了,继续回归写作中,目标 1 月 2 篇。需要两篇才能阐述清楚Java Lambda ≠ Android Lambda,本篇为上篇,先解释清楚 Java Lambda 的一些知识。耐心阅读本文,你一定会有收获。一、Java Lambda 不等于 匿名内部...
继续阅读 »

我又来了,继续回归写作中,目标 1 月 2 篇。

需要两篇才能阐述清楚Java Lambda ≠ Android Lambda,本篇为上篇,先解释清楚 Java Lambda 的一些知识。

耐心阅读本文,你一定会有收获。

一、Java Lambda 不等于 匿名内部类

测试环境JDK8。

首先我们看一段比较简单的代码片段:

public class TestJavaAnonymousInnerClass {
public void test() {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("hello java lambda");
}
};
runnable.run();
}
}

先问个简单的问题,如果我javac编译一下,你觉得会生成几个class文件?

不用问,肯定是两个,一个是TestJavaLambda.class,一个是TestJavaLambda$1.class,那么试下:

没错,确实两个,扎实的Java基础怎么会被这种问题打败。

大家都知道上面这个匿名内部类的写法,我们可以换成lambda表达式的写法对吧,甚至编译器都会提醒你使用lambda,我们改成lambda表达式的写法:

public class TestJavaLambda {
public void test() {
Runnable runnable = () -> {
System.out.println("hello java lambda");
};
runnable.run();
}
}

再问个简单的问题,如果我javac编译一下,你觉得会生成几个class文件?

嗯...你在搞我?这和刚才的问题有啥区别?

还认为是两个吗?我们再javac试一下?

不好意思,只有一个class文件了。

那么,我的一个新的问题来了:

Java匿名内部类的写法和Lambda表达式的写法,在编译期这么看肯定有区别的,那么有何区别?

二、Java Lambda的背后,invokedynamic的出现

看这类问题,第一件事肯定是对比字节码了,那我们javap -v 一哈,看一下test()方法区别:

匿名内部类的test():

public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=2, args_size=1
0: new #2 // class com/example/zhanghongyang/blog02/TestJavaAnonymousInnerClass$1
3: dup
4: aload_0
5: invokespecial #3 // Method com/example/zhanghongyang/blog02/TestJavaAnonymousInnerClass$1."<init>":(Lcom/example/zhanghongyang/blog02/TestJavaAnonymousInnerClass;)V
8: astore_1
9: aload_1
10: invokeinterface #4, 1 // InterfaceMethod java/lang/Runnable.run:()V
15: return

很简单,就是new了一个TestJavaAnonymousInnerClass$1对象,然后调用其run()方法。

有个比较有意思的,就是调用构造方法的时候先aload_0,0就是当前对象this,把this传过去了,这个就是匿名内部类可以持有外部类对象的秘密,其实把当前对象this引用给了人家。

再来看lambda的test():

public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return

和匿名内部类不同,取而代之的是一个invokedynamic指令。

如果大家比较熟悉Java字节码方法调用相关,应该经常会看到一个问题:invokespecial,invokevirtual,invokeinterface,invokestatic,invokedynamic有和区别?

invokespecial 其实上面一段字节码上也出现了,一般指的是调用super方法,构造方法,private方法等;special嘛,指定的意思,调用的都是一些确定调用者的方法。

你可能会问,调用一个类的方法,调用者还能有不确定的时候?

有呀,比如重载,是不是能将父类的方法调用转而变成子类的?

所以类中非private成员方法,一般调用指令为invokevirtual。

invokeinterface,invokestatic字面意思理解就可以了。

这块大概解释是这样的,如果有困惑自己打字节码看就好了,例如抽象类抽象方法调用和接口方法调用指令一样吗?加了final修饰的方法不能被复写,指令会有变化吗?

最后一个就是invokedynamic了:

一般很罕见,今天我们也算是见到了,在Java lambda表达式的时候能够见到。

一些深入的研究,可以看这里:

每日一问 | Java中匿名内部类写成 lambda,真的只是语法糖吗?

我们现在知道使用了lambda表达式之后,和匿名内部类去比较,字节码有比较大的变化,那么更好奇了:

lambda表达式运行的时候,背后到底是什么样的呢?

三、lambda表达式不是真的没有内部类生成

想了解一段代码运行时状态,最简单的方式是什么呢?

嗯...debug?

现在IDE都越来越智能了,很多时候debug一些编译细节都给你抹去了。

有个比较简单的方式,打堆栈,我们修改下代码:

public class TestJavaLambda {
public void test() {
Runnable runnable = () -> {
System.out.println("hello java lambda");

int a = 1/0;
};
runnable.run();
}

public static void main(String[] args) {
new TestJavaLambda().test();
}
}

运行下,看下出错的堆栈:

hello java lambda
Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.example.zhanghongyang.blog02.TestJavaLambda.lambda$test$0(TestJavaLambda.java:8)
at com.example.zhanghongyang.blog02.TestJavaLambda.test(TestJavaLambda.java:10)
at com.example.zhanghongyang.blog02.TestJavaLambda.main(TestJavaLambda.java:14)

看下到底和何方神圣调用的我们的run方法:

嗯...最后的堆栈是:

TestJavaLambda.lambda$test$0(TestJavaLambda.java:8)

是我们TestJavaLambda中的lambdatest0方法调用的?

是我们刚才发编译看漏了,还有这个方法?我们再反编译看下:

javap /Users/zhanghongyang/repo/KotlinLearn/app/src/main/java/com/example/zhanghongyang/blog02/TestJavaLambda.class 
Compiled from "TestJavaLambda.java"
public class com.example.zhanghongyang.blog02.TestJavaLambda {
public com.example.zhanghongyang.blog02.TestJavaLambda();
public void test();
public static void main(java.lang.String[]);
private void lambda$test$0();
}

这次javap -p 查看,-p代表private方法也输出出来。

还真有这个方法,看下这个方法的字节码:

private static void lambda$test$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #8 // String hello java lambda
5: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8

很简单,就是我们上面lambda表达式{}里面的内容,打印一行日志。

那这个方法是test调用的?不对呀,这个堆栈好像有问题,我们在回头看下刚才堆栈:

Exception in thread "main" java.lang.ArithmeticException: / by zero
at com.example.zhanghongyang.blog02.TestJavaLambda.lambda$test$0(TestJavaLambda.java:8)
at com.example.zhanghongyang.blog02.TestJavaLambda.test(TestJavaLambda.java:10)
at com.example.zhanghongyang.blog02.TestJavaLambda.main(TestJavaLambda.java:14)

有没有发现这个堆栈太过于简单了,我们的Runnable.run的调用栈呢?

这个堆栈应该是被简化了,那我们再加一行日志,看下run()方法执行时,自己身处于哪个类?

我们在run方法里面加了一行

System.out.println(this.getClass().getCanonicalName());

看下输出:

com.example.zhanghongyang.blog02.TestJavaLambda

嗯..其实我们执行了一个废操作,当前这个方法里面的代码都被放到lambdatest0()了,当然输出是TestJavaLambda。

不行了,我要放大招了。

我们修改下方法,让这个进程活的久一点:

public void test() {
Runnable runnable = () -> {
System.out.println("hello java lambda");
System.out.println(this.getClass().getCanonicalName());
// 新增
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int a = 1 / 0;
};
runnable.run();
}

运行后...

切到命令行,执行jps命令,查看当前程序进程的pid:

java zhanghongyang$ jps
99315 GradleDaemon
3682 TestJavaLambda
21298 Main
3685 Jps
3258 GradleDaemon
1275
3261 KotlinCompileDaemon

看到了3682,然后执行

jstack 3682

太感人了,终于把这行隐藏的run方法的堆栈找出来了。

这里大家不要太在意jps,jstack这些指令,都是jdk自带的,你就知道能查堆栈就行了,别出去搜这两个命令去啦,文章看完再说。
另外获取堆栈其实也能通过方法调用,小缘是通过Reflection.getCallerClass看的。

到现在我们具体真相又进了一步:

我们lambda$test$0()方法是这个对象:com.example.zhanghongyang.blog02.TestJavaLambda$$Lambda$1/1313922862的run方法调用的。

我们又能下个结论了:

文中lambda表达式的写法,在运行时,会帮我们生成中间类,类名格式为 原类名$$Lambda$数字,然后通过这个中间类最终完成调用。

那么你可能表示不服:

你说运行时生成就生成呀?你拿出来给我看看?

嗯...等会我拿出来给你看。

不过我们先思考另一个问题。

四、编译产物中遗漏的信息

上文我们一直在说:

  1. 对于文中例子中的Lambda表达式编译时没有生成中间类;
  2. 运行时帮我们生成了中间类;

那有个很明显的问题,编译时你没给我生成,运行时生成了;运行时它怎么知道要不要生成,生成什么样的类,你编译产物就那一个class文件,里面肯定要包含这类信息的呀?

是这么个道理。

我们再次发编译javap -v查看,在输出信息的最后:

SourceFile: "TestJavaLambda.java"
InnerClasses:
public static final #78= #77 of #81; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #35 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#36 ()V
#37 invokespecial com/example/zhanghongyang/blog02/TestJavaLambda.lambda$test$0:()V
#36 ()V

果然包含一段信息,而且包含TestJavaLambda.lambda$test$0关键词。

大家不用管那么多,你就知道,文中lambda的例子,会在编译的class文件中新增一个方法lambdatest0(),并且会携带一段信息告知JVM在运行时创建一个中间class。

其实LambdaMetafactory.metafactory正是用来生成中间class的,jdk中也有相关类可以查看,后续我们再详细说这个。

五、把中间类拿出来看看?

我们一直说运行时帮我们生成了一个中间类,类名大概为:TestJavaLambda$$Lambda$1,但是口说无凭,得拿出来大伙才信,对吧。

还好不是说我吃了两碗凉粉...

我们刚才说了JVM帮我们生成了中间类,其实java在运行的时候可以带很多参数,其中有个系统属性很神奇,我用给你们看:

java -Djdk.internal.lambda.dumpProxyClasses com.example.zhanghongyang.blog02.TestJavaLambda

懂了吧,加上这个系统属性运行,可以dump出生成的类:

是不是有点意思。

其实动态代理中间也会生成代理类,也可以通过类似方式导出。

然后我们看看这个类呗,这个类我们就不太在乎细节了,直接AS里面看反编译之后的:

真简单...

所以,本文中的例子,Lambda表达式和匿名内部类的区别还是挺大的,大家只要了解:

  1. invokedynamic可以用于lambda;
  2. Java lambda表达式的中间类并不是没有,而是在首次运行时生成的。

至于性能问题,影响应该是微乎其微的,几乎没有的。

下面有个灵魂一问:

你看这些有啥用?

毕竟我是搞Android的,其实我更在乎Android中lambda的实现,所以就先以Java Lambda为开始了,至于你问我为啥要看Android Lambda实现,毕竟现在经常要字节码插抓桩,自定义Transform,对于一些类背后的行为还是要搞清楚的。

但是,大家一定要注意,本文讲的是 Java lambda 的原理。

不要套用到Android上! 不要套用到Android上! 不要套用到Android上!

那 Android Lambda 是怎么一回事,后续会单独写一篇,Android 脱糖与D8 的一些事儿,还想起来上次有个同事被Android Lambda 坑了一次,会一起写出来。

本文基于1.8.0_181。


收起阅读 »

Android 11 绕过反射限制

1. 问题出现的背景腾讯视频在集成我们 replay sdk 的时候发现这么个错误,导致整个 db mock 功能完全失效。Accessing hidden field Landroid/database/sqlite/SQLiteCursor; ->m...
继续阅读 »

1. 问题出现的背景

腾讯视频在集成我们 replay sdk 的时候发现这么个错误,导致整个 db mock 功能完全失效。

Accessing hidden field Landroid/database/sqlite/SQLiteCursor;
->mDriver:Landroid/database/sqlite/SQLiteCursorDriver; (greylist-max-o, reflection, denied)

java.lang.NoSuchFieldException: No field mDriver in class Landroid/database/sqlite/SQLiteCursor;
(declaration of 'android.database.sqlite.SQLiteCursor' appears in /system/framework/framework.jar)

我清晰的记得我们引入了一个第三方解决方案,在 9.0 以上已经解决了这个问题,大致的方案是这样的:

if (SDK_INT >= Build.VERSION_CODES.P) {
try {
Method forName = Class.class.getDeclaredMethod("forName", String.class);
Method getDeclaredMethod = Class.class.getDeclaredMethod("getDeclaredMethod", String.class, Class[].class);

Class<?> vmRuntimeClass = (Class<?>) forName.invoke(null, "dalvik.system.VMRuntime");
Method getRuntime = (Method) getDeclaredMethod.invoke(vmRuntimeClass, "getRuntime", null);
setHiddenApiExemptions = (Method) getDeclaredMethod.invoke(vmRuntimeClass, "setHiddenApiExemptions", new Class[]{String[].class});
sVmRuntime = getRuntime.invoke(null);
} catch (Throwable e) {
Log.e(TAG, "reflect bootstrap failed:", e);
}
}

吓得我赶紧去看下到底有没有猫腻,发现在 Android 11 上果然有问题:

Accessing hidden method Ldalvik/system/VMRuntime;
->setHiddenApiExemptions([Ljava/lang/String;)V (blacklist,core-platform-api, reflection, denied)

Caused by: java.lang.NoSuchMethodException: dalvik.system.VMRuntime.setHiddenApiExemptions [class [Ljava.lang.String;]
......

2. 分析问题出现的原因

本着时间紧任务重尽量不影响进度的情况下,我还是想去网上搜索看看,但是发现都是一堆旧的方案。迫不得已去看看到底为什么?到底为什么?刚好前几天找同事要了一份 Android 11 的源码。

static jobject Class_getDeclaredMethodInternal(JNIEnv* env, jobject javaThis, jstring name, jobjectArray args) {
// ……
Handle<mirror::Method> result = hs.NewHandle(
mirror::Class::GetDeclaredMethodInternal<kRuntimePointerSize>(
soa.Self(),
klass,
soa.Decode<mirror::String>(name),
soa.Decode<mirror::ObjectArray<mirror::Class>>(args),
GetHiddenapiAccessContextFunction(soa.Self())));
if (result == nullptr || ShouldDenyAccessToMember(result->GetArtMethod(), soa.Self())) {
return nullptr;
}
return soa.AddLocalReference<jobject>(result.Get());
}

如果 ShouldDenyAccessToMember 返回 true,那么就会返回 null,上层就会抛出方法找不到的异常。这里和 Android P 没什么不同,只是把 ShouldBlockAccessToMember 改了个名而已。 ShouldDenyAccessToMember 会调用到 hiddenapi::ShouldDenyAccessToMember,该函数是这样实现的:

template<typename T>
inline bool ShouldDenyAccessToMember(T* member,
const std::function<AccessContext()>& fn_get_access_context,
AccessMethod access_method)
REQUIRES_SHARED(Locks::mutator_lock_) {

const uint32_t runtime_flags = GetRuntimeFlags(member);

// 1:如果该成员是公开API,直接通过
if ((runtime_flags & kAccPublicApi) != 0) {
return false;
}

// 2:不是公开API(即为隐藏API),获取调用者和被访问成员的 Domain
// 主要看这个
const AccessContext caller_context = fn_get_access_context();
const AccessContext callee_context(member->GetDeclaringClass());

// 3:如果调用者是可信的,直接返回
if (caller_context.CanAlwaysAccess(callee_context)) {
return false;
}
// ......
}

原来的方案失效了能在 FirstExternalCallerVisitor 的 VisitFrame 方法中找到答案

bool VisitFrame() override REQUIRES_SHARED(Locks::mutator_lock_) {
ArtMethod *m = GetMethod();
......
ObjPtr<mirror::Class> declaring_class = m->GetDeclaringClass();
if (declaring_class->IsBootStrapClassLoaded()) {
......
// 如果 PREVENT_META_REFLECTION_BLACKLIST_ACCESS 为 Enabled,跳过来自 java.lang.reflect.* 的访问
// 系统对“套娃反射”的限制的关键就在此
ObjPtr<mirror::Class> proxy_class = GetClassRoot<mirror::Proxy>();
if (declaring_class->IsInSamePackage(proxy_class) && declaring_class != proxy_class) {
if (Runtime::Current()->isChangeEnabled(kPreventMetaReflectionBlacklistAccess)) {
return true;
}
}
}

caller = m;
return false;
}

3. 解决方案

  • native hook 住 ShouldDenyAccessToMember 方法,直接返回 false
  • 破坏调用堆栈绕过去,使 VM 无法识别调用方

我们采用的是第二种方案,有什么方法可以让 VM 无法识别我的调用栈呢?这可以通过 JniEnv::AttachCurrentThread(…) 函数创建一个新的 Thread 来完成。具体我们可以看下这里 developer.android.com/training/ar… ,然后配合 std::async(…) 与 std::async::get(..) 就能搞定了,下面是关键代码:

// java 层直接用 jni 调用这个方法
static jobject Java_getDeclaredMethod(
JNIEnv *env,
jclass interface,
jobject clazz,
jstring method_name,
jobjectArray params) {
// ...... 省掉一些转换代码
// 先用 std::async 调用 getDeclaredMethod_internal 方法
auto future = std::async(&getDeclaredMethod_internal, global_clazz,
global_method_name,
global_params);
auto result = future.get();
return result;
}

static jobject getDeclaredMethod_internal(
jobject clazz,
jstring method_name,
jobjectArray params) {
// 这里就是一些普通的 jni 操作了
JNIEnv *env = attachCurrentThread();
jclass clazz_class = env->GetObjectClass(clazz);
jmethodID get_declared_method_id = env->GetMethodID(clazz_class, "getDeclaredMethod",
"(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;");
jobject res = env->CallObjectMethod(clazz, get_declared_method_id,
method_name, params);
detachCurrentThread();
return env->NewGlobalRef(res);
}

JNIEnv *attachCurrentThread() {
JNIEnv *env;
// AttachCurrentThread 核心在这里
int res = _vm->AttachCurrentThread(&env, nullptr);
return env;
}
收起阅读 »

初识 Jetpack Compose(三) :修饰符(Modifier)

Modifier modifier elements装饰或添加行为到 Compose UI 元素的有序的、不可变的集合。例如,背景、填充和单击事件侦听器装饰行、文本或按钮或向其添加行为。 正如其名,modifier主要为Compose组件提供修饰功能,包括...
继续阅读 »

Modifier



modifier elements装饰或添加行为到 Compose UI 元素的有序的、不可变的集合。例如,背景、填充和单击事件侦听器装饰行、文本或按钮或向其添加行为。



正如其名,modifier主要为Compose组件提供修饰功能,包括但不限于 样式修改事件监听等一系列对Compose组件的装饰。


有序的


需要注意官方描述的“有序的”一词,由于Modifier的使用方式是链式的,所以属性定义的先后顺序会影响到UI的展示效果。
比如:


@Composable
fun RoundButton() {
Box(
modifier = Modifier
.width(300.dp)
.height(90.dp)
.background(Color(0xFF3ADF00), shape = RoundedCornerShape(50))
.padding(20.dp),
contentAlignment = Alignment.Center){
Text(text = "RoundButton",color = Color.White)
}
}

由于backgroundpadding前,所以Modifier会先给Box设置背景,然后再设置边距,如图:
1630476697(1).jpg
同理,将backgroundpadding顺序替换,效果则如下:


1630477241(1).jpg


通过查看Modifier的源码实现可以发现,在我们通过链式点点点叠加属性的过程中,Modifier会创建一个CombinedModifier将旧的和新的属性组合在一起,合成一个单独的Modifier,相当于给被修饰的组件套上了一层又一层的Modifier,这也是Modifier为有序的原因。


可拓展的


Modifier.padding()源码为例:


@Stable
fun Modifier.padding(all: Dp) =
this.then(
PaddingModifier(
start = all,
top = all,
end = all,
bottom = all,
rtlAware = true,
inspectorInfo = debugInspectorInfo {
name = "padding"
value = all
}
)
)

可以发现,padding()Modifier的一个拓展函数,它调用了Modifier的 then() 函数,而这个 then() 需要接收一个Modifier对象,而PaddingModifier就是这个对象。


所以我们也当然可以为Modifier添加拓展函数,传入我们自定义的Modifier来达到一些特定的效果,比如以下这些属性我们会经常为不同的组件配置:


modifier = Modifier
.width(300.dp)
.height(90.dp)
.padding(20.dp)
.background(Color(0xFF3ADF00), RoundedCornerShape(50))
复制代码

为了方便调用,我们可以给Modifier添加一个拓展方法:


@Stable
fun Modifier.buttonDefault() = this.then(
width(300.dp)
.height(90.dp)
.padding(20.dp)
.background(Color(0xFF3ADF00), RoundedCornerShape(50))
)

这样在组件中,我们只需要这样使用就可以了。


modifier = Modifier.buttonDefault()
复制代码

Modifier 属性


Modifier可配置的属性多且杂,这里不一一列举了,具体可前往Andrid Developer查看




以下是根据职能分类列出的一些常用属性


宽&高



























































属性名含义
Modifier.width(width: Dp)设置自身的宽度,单位dp
Modifier.fillMaxWidth(fraction: Float = 1f)默认横向填充满父容器的宽度,参数可以控制宽度的比例。例如0.5就是当前元素占父元素宽度的一半
Modifier.wrapContentWidth(align: Alignment.Horizontal = Alignment.CenterHorizontally, unbounded: Boolean = false)根据子级元素的宽度来确定自身的宽度,如果自身设置了最小宽度的话则会被忽略。当unbounded参数为true的时候,自身设置了最大宽度的话也会被忽略
-----------------------------------------------------------------------------
Modifier.height(height: Dp)设置自身的高度,单位dp
Modifier.fillMaxHeight(fraction: Float = 1f)默认纵向填充满父容器的宽度,参数可以控制宽度的比例。例如0.5就是当前元素占父元素高度的一半
Modifier.wrapContentHeight(align: Alignment.Vertical = Alignment.CenterVertically, unbounded: Boolean = false)根据子级元素的高度来确定自身的高度,如果自身设置了最小高度的话则会被忽略。当unbounded参数为true的时候,自身设置了最大高度的话也会被忽略
-----------------------------------------------------------------------------
Modifier.size(size: Dp)设置自的宽高,单位dp
Modifier.size(width: Dp, height: Dp)设置自的宽高,单位dp
Modifier.fillMaxSize(fraction: Float = 1f)默认填充满父容器,参数可以控制比例。例如0.5就是当前元素占父元素的一半
Modifier.wrapContentSize(align: Alignment = Alignment.Center, unbounded: Boolean = false)根据子级元素的宽高来确定自身的宽高,如果自身设置了最小宽高的话则会被忽略。当unbounded参数为true的时候,自身设置了最大宽高的话也会被忽略

间距



























属性名含义
Modifier.padding(start: Dp = 0.dp, top: Dp = 0.dp, end: Dp = 0.dp, bottom: Dp = 0.dp)分别在四个方向上设置填充
Modifier.padding(horizontal: Dp = 0.dp, vertical: Dp = 0.dp)分别在横向和纵向上设置填充
Modifier.padding(all: Dp)统一设置所有方向上的填充
Modifier.padding(padding: PaddingValues)根据参数PaddingValues来设置填充,PaddingValues参数可以理解为以上三种方式的封装

绘制



































属性名含义
Modifier.alpha(alpha: Float)不透明度,范围从0-1
Modifier.clip(shape: Shape)裁剪为相应的形状,例如shape = RoundedCornerShape(20) 表示裁剪为20%圆角的矩形。
Modifier.shadow(elevation: Dp, shape: Shape = RectangleShape, clip: Boolean = elevation > 0.dp)绘制阴影效果
Modifier.rotate(degrees: Float)设置视图围绕其中心旋转的角度
Modifier.scale(scale: Float)设置视图的缩放比例
Modifier.scale(scaleX: Float, scaleY: Float)设置视图的缩放比例

背景&边框































属性名含义
Modifier.background(color: Color, shape: Shape = RectangleShape)设置背景色
Modifier.background(brush: Brush, shape: Shape = RectangleShape, alpha: Float = 1.0f)使用Brush来设置背景色,例如渐变色效果
Modifier.border(border: BorderStroke, shape: Shape = RectangleShape)绘制指定形状的边框
Modifier.border(width: Dp, color: Color, shape: Shape = RectangleShape)绘制指定宽度、颜色、形状的边框
Modifier.border(width: Dp, brush: Brush, shape: Shape)绘制指定宽度、brush、形状的边框

行为



























属性名含义
Modifier.clickable(  enabled: Boolean = true, onClickLabel: String? = null, role: Role? =null,  onClick: () -> Unit)点击事件
Modifier.combinedClickable( enabled: Boolean = true,onClickLabel: String? = null,role: Role? = null,onLongClickLabel: String? = null,onLongClick: () -> Unit = null,onDoubleClick: () -> Unit = null,onClick: () -> Unit)组合点击事件,包括单击、长按、双击
Modifier.horizontalScroll(state: ScrollState, enabled: Boolean = true, reverseScrolling: Boolean = false)使组件支持横向滚动模式
Modifier.verticalScroll(state: ScrollState, enabled: Boolean = true, reverseScrolling: Boolean = false)使组件支持纵向滚动模式

二、最后


好记性不如烂笔头,初识 Jetpack Compose 系列是我自己的学习笔记,在加深知识巩固的同时,也可以锻炼一下写作技能。文章中的内容仅作参考,如有问题请留言指正。


1. 参考



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

初识 Jetpack Compose(五) :组件-Text

一、Text Compose中的Text的作用与 xml 中的TextView无二,作用于最基本的文本显示。 1.属性 @Composable fun Text( // text: String, text: AnnotatedString, ...
继续阅读 »

一、Text


Compose中的Text的作用与 xml 中的TextView无二,作用于最基本的文本显示。


1.属性


@Composable
fun Text(
// text: String,
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
...
}



























































































属性名类型作用
textString要显示的文本
textAnnotatedString要显示的文本,可设置文本中指定字符的颜色、字体、大小等属性
modifierModifier修饰符
fontSizeTextUnit文本字体大小
fontStyleFontStyle?文本字体变体
fontWeightfontWeight?文本字体字重
fontFamilyFontFamily?文本字体
letterSpacingTextUnit文本字符之间的间距
textDecorationTextDecoration?用于为文本绘制装饰(例如:下划线、删除线)
textAlignTextAlign?文本段落的对齐方式
lineHeightTextUnit文本段落之间的行高
overflowTextOverflow文本字符溢出的显示方式 ,同xml ellipsize
softWrapBoolean文本是否应在换行符处中断。如果为false,则文本的宽度会在水平方向上无限延伸,且textAlign属性失效,可能会出现异常情况
maxLinesInt文本可跨越的可选最大行数,必要时可以换行。如果文本超过给定的行数,则会根据textAlignsoftWrap属性截断文本。它的值必须大于零。
onTextLayout(TextLayoutResult) -> Unit计算新的文本布局时执行的回调
styleTextStyle文本的样式配置,例如颜色,字体,行高等。可参考主题-排版 使用

2.使用示例


2.1 示例一


@Composable
fun TextExampleMoney(value:Float){
var str = value.toString()
if(!str.contains("."))
str+=".00"
var strSplit = str.split(".")

val annotatedStringBuilder = AnnotatedString.Builder()
annotatedStringBuilder.apply {
pushStyle(
SpanStyle(
color = Color.Gray,
fontSize = 16.sp,
)
)
append("¥")
pop()
pushStyle(
SpanStyle(
color = Color.DarkGray,
fontSize = 26.sp,
fontWeight = FontWeight.Bold
)
)
append(strSplit[0])
pop()
pushStyle(
SpanStyle(
color = Color.Gray,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
)
append(".${strSplit[1]}")
pop()
}

Text(
text = annotatedStringBuilder.toAnnotatedString(),
)

}

//调用
TextExampleMoney(98.99f)

1630916637(1).jpg


2.2 示例二


@Composable
fun TextExample(){
val annotatedStringBuilder = AnnotatedString.Builder()
annotatedStringBuilder.apply {
append("Jetpack Compose ")
pushStyle(
SpanStyle(
color = Color.Blue,
fontSize = 16.sp,
)
)
append("widget")
pop()
append(" [ ")
pushStyle(
SpanStyle(
color = Color.Red,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
)
append("Text")
pop()
append(" ] usage example")
}

Text(
text = annotatedStringBuilder.toAnnotatedString(),
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)

}

//调用
TextExample()

1630916734(1).jpg


2.3 示例三


Text(
text = "High level element that displays text and provides semantics / accessibility information.\n" +
"\n" +
"The default style uses the LocalTextStyle provided by the MaterialTheme / components. If you are setting your own style, you may want to consider first retrieving LocalTextStyle, and using TextStyle.copy to keep any theme defined attributes, only modifying the specific attributes you want to override.\n" +
"\n" +
"For ease of use, commonly used parameters from TextStyle are also present here. The order of precedence is as follows:\n" +
"\n" +
"If a parameter is explicitly set here (i.e, it is notnull or TextUnit.Unspecified), then this parameter will always be used.\n" +
"\n" +
"If a parameter is not set, (null or TextUnit.Unspecified), then the corresponding value from style will be used instead.\n" +
"\n" +
"Additionally, for color, if color is not set, and style does not have a color, then LocalContentColor will be used with an alpha of LocalContentAlpha- this allows this Text or element containing this Text to adapt to different background colors and still maintain contrast and accessibility.",
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
style = MaterialTheme.typography.Description,
maxLines = 20,
lineHeight = 20.sp,
textDecoration = TextDecoration.Underline
)

image.png


二、ClickableText


在一些开发需求中,我们需要监听一个Text的某一段区域的Touch事件,比如:

请阅读并同意《xxx用户使用协议》,我们需要监听书名号内的文字的点击事件并进行跳转,ClickableText可以很轻松的帮我们做到这一点。


@Composable
fun ClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
//点击事件监听,参数为触发事件时点击的字符在文本中的位置
onClick: (Int) -> Unit
) {
...
}

通过源码得知,ClickableText区别于Text外,额外提供了onClick回调,会返回我们点击文本时,手指所点击的字符在文本中的位置。
比如:


ClickableText(
text = annotatedStringBuilder.toAnnotatedString(),
overflow = TextOverflow.Ellipsis
){
Log.d("ClickableText", "$it -> character is clicked.")
}

image.png


预计在未来还会提供LongClickableText:文本的长按事件,不过目前暂未支持。


三、最后


好记性不如烂笔头,初识 Jetpack Compose 系列是我自己的学习笔记,在加深知识巩固的同时,也可以锻炼一下写作技能。文章中的内容仅作参考,如有问题请留言指正。


1. 参考



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

【开源项目】简单易用的Compose版骨架屏,了解一下~

前言 骨架屏是页面的一个空白版本,通常会在页面完全渲染之前,通过一些灰色的区块大致勾勒出轮廓,待数据加载完成后,再替换成真实的内容。骨架屏加载中效果,比起传统的加载中效果可以提供更多信息,用户体验更好,因此也变得越来越流行 本文主要介绍如何使用Compose实...
继续阅读 »

前言


骨架屏是页面的一个空白版本,通常会在页面完全渲染之前,通过一些灰色的区块大致勾勒出轮廓,待数据加载完成后,再替换成真实的内容。骨架屏加载中效果,比起传统的加载中效果可以提供更多信息,用户体验更好,因此也变得越来越流行

本文主要介绍如何使用Compose实现一个简单易用的骨架屏效果,有兴趣的同学可以点个StarCompose版骨架屏


效果图


首先看下最终的效果图



特性



  1. 简单易用,可复用页面UI,不需要针对骨架屏定制UI

  2. 支持设置骨架屏是否显示,一般结合加载状态使用

  3. 支持设置骨架屏背景与高亮颜色

  4. 支持设置骨架屏高度部分宽度,渐变部分宽度

  5. 支持设置骨架屏动画的角度与方向

  6. 支持设置骨架屏动画的时间与两次动画间隔


使用


接入


第 1 步:在工程的build.gradle中添加:


allprojects {
repositories {
...
mavenCentral()
}
}

第2步:在应用的build.gradle中添加:


dependencies {
implementation 'io.github.shenzhen2017:shimmer:1.0.0'
}

简单使用


@Composable
fun ShimmerSample() {
var loading: Boolean by remember {
mutableStateOf(true)
}
Column(
modifier = Modifier
.fillMaxWidth()
.shimmer(loading,config = ShimmerConfig())
) {
repeat(3) {
PlaceHolderItem()
Spacer(modifier = Modifier.height(10.dp))
}
}
}

如上所示:



  1. 只需要在ColumnModifier中加上shimmerColumn下的所有组件即可实现骨架屏效果

  2. 可通过loading参数,控制骨架屏效果是否显示

  3. 如果需要定制骨架屏动画效果,也可通过一些参数配置


具体主要有以下这些参数


data class ShimmerConfig(
// 未高亮部分颜色
val contentColor: Color = Color.LightGray.copy(alpha = 0.3f),
// 高亮部分颜色
val higLightColor: Color = Color.LightGray.copy(alpha = 0.9f),
// 渐变部分宽度
@FloatRange(from = 0.0, to = 1.0)
val dropOff: Float = 0.5f,
// 高亮部分宽度
@FloatRange(from = 0.0, to = 1.0)
val intensity: Float = 0.2f,
//骨架屏动画方向
val direction: ShimmerDirection = ShimmerDirection.LeftToRight,
//动画旋转角度
val angle: Float = 20f,
//动画时长
val duration: Float = 1000f,
//两次动画间隔
val delay: Float = 200f
)

主要原理


通过图像混合模式复用页面UI


如果我们要实现骨架屏效果,首先想到的是需要按照页面的结构再写一套UI,然后在加载中的时候,显示这套UI,否则隐藏

一般的加载中效果都是这样实现的,但这样会带来一个问题,不同的页面结构不同,那我们岂不是要一个页面就重写一套UI?这显然是不可接受的


我们可以想到,页面的结构其实我们已经写过一遍了,如果我们能复用我们写的页面结构不就好了吗?

我们可以通过图像混合模式来实现这一点


图像混合模式定义的是,当两个图像合成时,图像最终的展示方式。在Androd中,有相应的API接口来支持图像混合模式,即Xfermode.

图像混合模式主要有以下16种,以下这张图片从一定程度上形象地说明了图像混合的作用,两个图形一圆一方通过一定的计算产生不同的组合效果,具体如下



我们介绍几个常用的,其它的感兴趣的同学可自行查阅



  • SRC_IN:只在源图像和目标图像相交的地方绘制【源图像】

  • DST_IN:只在源图像和目标图像相交的地方绘制【目标图像】,绘制效果受到源图像对应地方透明度影响

  • SRC_OUT:只在源图像和目标图像不相交的地方绘制【源图像】,相交的地方根据目标图像的对应地方的alpha进行过滤,目标图像完全不透明则完全过滤,完全透明则不过滤

  • DST_OUT:只在源图像和目标图像不相交的地方绘制【目标图像】,在相交的地方根据源图像的alpha进行过滤,源图像完全不透明则完全过滤,完全透明则不过滤


如果我们把页面的UI结构作为目标图像,骨架屏效果作为源图像,然后使用SRC_IN混合模式

就可以实现只在页面的结构上显示骨架屏,在空白部分不显示,这样就可以避免重复写UI


通过平移实现动画效果


上面我们已经实现了在页面结构上显示骨架屏,但是骨架屏效果还有一个动画效果

其实也很简单,给骨架屏设置一个渐变效果,然后做一个平移动画,然后看起来就是现在的骨架屏闪光动画了


fun Modifier.shimmer(): Modifier = composed {
var progress: Float by remember { mutableStateOf(0f) }
val infiniteTransition = rememberInfiniteTransition()
progress = infiniteTransition.animateFloat().value // 动画效果,计算百分比
ShimmerModifier(visible = visible, progress = progress, config = config)
}

internal class ShimmerModifier(progress:Float) : DrawModifier, LayoutModifier {
private val paint = Paint().apply {
blendMode = BlendMode.SrcIn //设置混合模式
shader = LinearGradientShader(Offset(0f, 0f),toOffset,colors,colorStops)//设置渐变色
}

override fun ContentDrawScope.draw() {
drawContent()
val (dx, dy) = getOffset(progress) //根据progress,设置平移的位置
paint.shader?.postTranslate(dx, dy) // 平移操作
it.drawRect(Rect(0f, 0f, size.width, size.height), paint = paint)//绘制骨架屏效果
}
}

如上所示,主要是几步:



  1. 启动动画,获得当前进度progress,并根据progress获得当前平移的位置

  2. 设置骨架屏的背景渐变颜色与混合模式

  3. 绘制骨架屏效果


自定义骨架屏效果


上面介绍了我们提供了一些参数,可以自定义骨架屏的效果,其它参数都比较好理解,主要是以下两个参数有点难理解



  1. dropOff:渐变部分宽度

  2. intensity: 高亮部分宽度


我们知道,可以通过contentColor自定义普通部分颜色,higLightColor自定义高亮部分颜色

但是这两种颜色是如何分布的呢?渐变的比例是怎样的呢?可以看下下面的代码:


    private val paint = Paint().apply {
shader = LinearGradientShader(Offset(0f, 0f),toOffset,colors,colorStops)//设置渐变色
}

private val colors = listOf(
config.contentColor,
config.higLightColor,
config.higLightColor,
config.contentColor
)

private val colorStops: List<Float> = listOf(
((1f - intensity - dropOff) / 2f).coerceIn(0f, 1f),
((1f - intensity - 0.001f) / 2f).coerceIn(0f, 1f),
((1f + intensity + 0.001f) / 2f).coerceIn(0f, 1f),
((1f + intensity + dropOff) / 2f).coerceIn(0f, 1f)
)

可以看出,我们的颜色渐变有以下特点:



  1. 渐变颜色分布为:contentColor->higLightColor->higLightColor->contentColor

  2. LinearGradientShader使用colors定义颜色,colorStops定义颜色渐变的分布,colorStopsintensitydropoff计算得来

  3. intensity决定了高亮部分的宽度,即intensity越大,高亮部分越大

  4. dropOff决定了渐变部分的宽度,即dropOff越大,渐变部分越大


总结


特别鸣谢


在实现Compose版本骨架屏的过程中,主要借鉴了以下开源框架的思想,有兴趣的同学也可以了解下

Facebook开源的shimmer-android

Habib Kazemi开源的compose-shimmer


项目地址


简单易用的Compose版骨架屏

开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~


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

Retorfit + 协程机制 + MVVM

协程是一种解决方案,是一种解决嵌套,并发、弱化线程概念的方案。能让多个任务之间更好的协作,能够以同步的方式编排代码完成异步工作,将异步代码写的像同步代码一样直观。 重点 协程的本质是方法的挂起与恢复:return + callback 协程是什么: ...
继续阅读 »

协程是一种解决方案,是一种解决嵌套,并发、弱化线程概念的方案。能让多个任务之间更好的协作,能够以同步的方式编排代码完成异步工作,将异步代码写的像同步代码一样直观。




重点
协程的本质是方法的挂起恢复:return + callback





  • 协程是什么:


协程是可以由程序自行控制挂起、恢复的程序
协程可以实现多任务的协作执行
协程可以用来解决异步任务控制流的灵活转移



  • 协程的作用:


协程可以让异步代码同步化
协程可以降低异步程序的设计复杂度
协程本身不能让代码异步,只是让异步代码更简单控制流程




总结一句话:协程就是将程序进行挂起和恢复,解决异步回调让异步代码同步化。



什么是协程


Kotlin 协程的标准库:kotlinx-coroutines-core 它是协程的框架层的,而kotlin本身带有协程的基础层,要是用协程框架层封装需要引入依赖库,才能使用协程的框架层


 implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'
复制代码

常见的异步回调: 在enqueue中创建回调实例接受回调


retrofit.create(GitHubService::class.java).listRepos("octocat")
.enqueue(object : Callback<List<Repo>> {
override fun onResponse(call: Call<List<Repo>>, response: Response<List<Repo>>) {
println("retrofit:" + response.body()?.size)
}

override fun onFailure(call: Call<List<Repo>>, t: Throwable) {
TODO("Not yet implemented")
}
})

经过协程改造的异步程序:



  1. 在函数前面加上suspend 定义为挂起函数,在retrofit 2.6以上的版本就已经支持协程了


    /**
* kotlin 协程
*/
@GET("users/{user}/repos")
suspend fun listReposKx(@Path("user") user: String?): List<Repo>


  1. 协程的方式请求网络,将异步代码写成同步代码更加简单化


code.png
执行结果:



协程函数的挂起需要有一个CoroutineScope空间,函数挂起并不会影响CoroutineScope独立空间之外的代码执行,非阻塞式的挂起
注意:通过suspend修饰的挂起函数只能被挂起函数协程调用。



image.png


协程运行:遇到挂起函数listReposKx在线程2中执行,执行完毕后返回结果继续执行。挂起恢复
image.png



协程和线程的区别:
线程Thread:指操作系统的线程,内核线程
协程Coroutines: 指语言实现的协程,运行在内核线程之上的



协程的基本要素



Kotlin的协程分为两层:



  • 基础设施层:标准库的协程API,主要对协程提供了概念和语义上最基本的支持

  • 业务框架层: 协程的上层框架支持



在上述代码中GlobalScope.launch其实是业务框架层的实现。如果抛去框架层的封装,kotlin提供的基础层是如何实现协程的?这就需要了解协程的基本要素
code.png


如下代码:就是kotlin协程的基础层API包括:suspend挂起函数ContinuationcreateCoroutine创建协程startCoroutine启动协程CoroutinContext协程上下文。这五个就是协程的基本要素
code.png


image.png


挂起函数suspend



挂起点: suspend修饰的函数,只能在其他挂起函数或者协程中调用。
挂起函数调用时包含了协程的“挂起”的语义,而挂起函数返回时则包含了协程的“恢复”语义。



挂起和恢复主要通过:Continuation来实现,如下代码在service中添加的suspend函数反编译后的结果,在挂起函数中多了一个参数Continuation
code.png
其实在上述的createCoroutine中传递了一个Continuation实例,而Continuation主要的作用就是执行挂起函数“恢复”后的代码并且得到挂起函数的返回值作为参数返回


如何将异步回调通过挂起函数的改造呢?其实就是retrofit中是如何处理suspend函数的,通过kotlin的基础层API代码如下:通过suspendCoroutine来实现挂起函数,在通过Continuation返回异步回调请求的结果
code.png
Retrofit中的实现其实是一样的原理,Retrofit通过对Call进行了扩展方法处理,在Service API 的suspend方法的基础上又调用了Retrofit自己写的suspend函数
code.png
其实对于常见的框架层的封装例如xxxScope.launch:就是基于kotlin的基础层的API进行实现
code.png


协程上下文



在协程上下文(CoroutineContext)中存在着拦截器ContinuationInterceptor是一类协程上下文元素,可以对协程上下文所在协程的Continuation进行拦截,可以实现切换线程。
协程上下文包含一个 协程调度器 (参见 CoroutineDispatcher)它确定了相关的协程在哪个线程或哪些线程上执行。协程调度器可以将协程限制在一个特定的线程执行,或将它分派到一个线程池,亦或是让它不受限地运行。
所有的协程构建器诸如 launchasync 接收一个可选的 CoroutineContext 参数,它可以被用来显式的为一个新协程或其它上下文元素指定一个调度器。



image.png


协程框架在Android上的应用



Kotlin 的框架层基于基础层进行的封装:kotlinx-coroutines



Kotlin 提供了协程框架的基础库以及协程Android库


//kotlin 标准库-提供了协程的基础层
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
def coroutines_version = "1.4.2"
//kotlin协程依赖库
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
//协程Android库,提供AndroidUI调度器
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"

和协程相关的一些扩展库
image.png


image.png



官方文档中都有讲解这里不在复述了,重点讲解协程在Android中的应用。




  • Job: 协程的启动模式

  • 调度器:协程的线程调度(将协程在特有的线程或者线程池中执行)

  • 作用域:协程的作用域(在Android中常见的:lifecycleScope viewmodelScope)

  • Channel:“热”数据流,并发安全的通信机制(不去订阅也会发数据)

  • Flow:“冷”数据流,协程的响应式API(和RxJava类似,只有订阅了才会发送数据)

  • Select:可以对多个挂起事件进行等待


协程简化Dialog


创建Dialog的挂起函数给Context扩展alert方法如下代码:



通过suspendCancellableCoroutine 创建挂起函数,将异步流程同步化



code.png
通过协程来调用挂起函数:



lifecycleScope 和Activity/Fragment的生命周期绑定,可以直接拿到弹窗点击的结果进行处理



        val show_dialog = findViewById<Button>(R.id.show_dialog)
show_dialog.setOnClickListener {
lifecycleScope.launch {
//Activity生命周期的协程
val myCheck = alert("警告", "Do You Want is?")
Log.e("TAG", "onCreate: $myCheck")
}
}

简化Handler的调用



Handler.post的异步调用



code.png


调用方式如下:


        lifecycleScope.launch {
val handler = Handler(Looper.getMainLooper())
val h1 = handler.run { "test"}
Log.e("TAG", "onCreate: $h1") // test
val h2 = handler.runDelay(1000) { "delay" }
Log.e("TAG", "onCreate: $h2") // delay
}


协程:可以将所有的异步调用简化为同步调用的解决方案



Job 启动模式


code.png
image.pngimage.png


image.pngimage.png


调度器


image.pngimage.png


Retrofit+协程配合LiveData的封装原理



协程只是为了解决异步调用问题而存在的,Retrofit中内部已经将异步问题通过协程解决了,协程更多的是通过和LiveData和ViewModel结合再次简化封装部分业务逻辑。



需要引入的依赖库:



在解析josn的converter,kotlin推荐的moshi,可以调研调研。



    //retrofit
api rootProject.depsLibs.retrofit
api rootProject.depsLibs.logging_interceptor
api rootProject.depsLibs.converter_gson

//协程相关
def coroutines_version = "1.4.2"
//kotlin协程依赖库
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
//协程Android库,提供AndroidUI调度器
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
api "androidx.core:core-ktx:1.3.2"
api "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"
api "androidx.lifecycle:lifecycle-livedata-core-ktx:2.3.1"

网络请求的生命周期管理由下面依赖库进行管理



lifecycleScope 来定义Activity/Fragment的生命周期管理的协程运行网络请求的挂起函数或者通过viewModelScope 的生命周期协程来管理网络请求的挂起函数(推荐)。
注意:Lifecycle 和 ViewModel的是不同的,ViewModel可以当屏幕发生旋转后继续存在。
关于Lifecycle和ViewModel的
本质
讲解看这里:
ViewModel 如何对视图状态管理
LiveData 如何安全观察数据



    api "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
api "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1"

框架层设计


NetWorkManager 是网络框架的入口类,API设计:
code.png


网络请求返回数据结构设计:



**ResultData **作为最终返回的数据结构类.**ResponseBean**作为JSON的基础数据结构



code.png


通过Kotlin的密封类,用于区分网络状态,具体设计ApiResonse 主要包括网络异常状态空数据状态成功状态、业务错误状态。(对于code的业务错误状态.需要根据上述的错误处理器进行设置)
code.png
ApiResponse 通过静态方法进行构建不同状态的返回数据



有两种create构建方法,第一个是捕捉到的网络请求系统异常,第二个create首先判断是否是成功的状态码,如果不是返回ApiAppErrorResponse业务状态异常,data 为空表示返回空数据返回空数据下的状态,否则返回成功下的状态



code.png


核心类:RequestAction 通过kotlin的DSL包装网络请求动作




  • api: 传递的就是Retrofit的Service中定义的suspend 挂起函数

  • loadCache : 传递的就是加载缓存数据的函数

  • saveCache:传递的是实现缓存数据的函数



注意:这几个关键的方法都是传递DSL包装的函数


code.png
例如这样的调用方式:代码看起来会非常舒服


    @GET("users/{user}/repos")
suspend fun getJson2(@Path("user") user: String): ResponseBean<List<Repo>>

private val netApi = NetWorkManager.instance.create(Service::class.java)

val requestLiveData = viewModelScope.requestLiveData<List<Repo>> {
//请求网络
api { netApi.getJson2(name) }

//加载数据库缓存
loadCache {
//数据库请求,将结果返回给liveData中
_databaseLiveData
}

//将数据保存到数据库
saveCache {
//向数据库中保存数据
Log.e("TAG", "getRepo: ${it.size}")
}
}

下面就是通过协程来进行整合Retrofit+LiveData



CoroutineScope 是所有协程作用域的父类,对其进行扩展一个函数requestLiveData,liveData 是扩展依赖库提供的使其可以在LiveDataScope中运行挂起函数 emit 以及api.invoke都是挂起函数



code.png


异常处理设计


统一异常类:统一使用ResponseThrowable作为网络请求的所有异常类
code.png


IExceptionHandler 作为将异常转换为ResponseThrowable的实现,appSuccessCode 用来定义业务层的成功码,非成功码下的都是业务异常,其他情况为系统异常


code.png


IAdapterHandler 用来处理 IExceptionHandler 转换的ResponseThrowable异常


code.png


NetManager 的API



  1. 初始化设置


code.png



  1. 全局下异常处理的实现类如下,通过转换后的ResponseThrowable中的code判断处于哪个异常情况


code.png



  1. 将异常转换为ResponseThrowable



框架层默认实现了:DefaultExceptionHandler 如果不设置addExceptionHandler会使用默认的异常转换类



code.png



  1. MVVM 架构模式下在ViewModel中实现网络请求,外部通过repoLiveData监听数据,这里_repoLiveData 使用MediaorLiveData 只能监听数据变化不能改变数据,为了防止外部改变数据导致不可预期的错误


code.png



  1. View层实现监听网络数据



监听到的数据都是在框架层定义好的ResultData直接拿到状态等信息,注意在Error状态下所有的异常在框架层已经全部转换为了ResponseThrowable通过自定义异常中的code来判断具体的异常情况



code.png


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

Android正确的保活方案,不要掉进保活需求死循环陷进

在开始前,还是给大家简单介绍一下,以前出现过的一些黑科技: 大概在6年前Github中出现过一个叫MarsDaemon,这个库通过双进程守护的方式实现保活,一时间风头无两。好景不长,进入 Android 8.0时代之后,这个库就废掉了。 最近2年Github上...
继续阅读 »

在开始前,还是给大家简单介绍一下,以前出现过的一些黑科技:


大概在6年前Github中出现过一个叫MarsDaemon,这个库通过双进程守护的方式实现保活,一时间风头无两。好景不长,进入 Android 8.0时代之后,这个库就废掉了。


最近2年Github上面出来一个Leoric 感兴趣的可以去看一下源码,谁敢用在生产环境呢,也就自己玩玩的才会用吧(不能因为保活而导致手机卡巴斯基),我没有试过这个,我想说的是:黑科技能黑的了一时,能黑的了一世吗?


没有规矩,不成方圆,要提升产品的存活率,最终还是要落到产品本身上面来,尊重用户,提升用户体验才是正道。


以前我也是深受保活需求的压迫,最近发现QQ群里有人又提到了如何保活,那么我们就来说一说,如何来正确保活App?




Android 8.0之后: 加强了应用后台限制,当时测试过一组数据:



应用处于前台,启动一个前台Service,里面使用JobScheduler启动定时任务(30秒触发一次),
此时手机锁屏,前10分钟内,定时任务都是正常执行;

大概在12分钟左右,发现应用进程就被kill掉了,解锁屏幕,app也不在前台了;



各大国产手机厂商底层都经过自己魔改,自家都有自己的一套自启动管理,小米手机更乱(当时有个神隐模式的概念,那也是杀后台高手),只能说当时Android手机各种性能方面都不足,各家都会有自己的一套省电模式,以此来达到省电和提高手机性能,Android 系统变得越来越完善,但是厂商定制的自启动、省电模式还在,所以我们要做保活。


1.Android 8.0之前-常用的保活方案



1.开启一个前台Service

2.Android 6.0+ 忽略电池优化开关(稍后会有代码)

3.无障碍服务(只针对有用这个功能的app,如支付宝语音增强提醒用了它)





2.Android 8.0之后-常用的保活方案



1.开启一个前台Service(可以加上,单独启用的话无法满足保活需求)

2.Android 6.0+ 忽略电池优化开关(稍后会有代码)

3.无障碍服务(只针对有用这个功能的app,如支付宝语音增强提醒用了它)

4.应用自启动权限(最简单的方案是针对不同系统提供教程图片-让用户自己去打开)

5.多任务列表窗口加锁(提供GIF教程图片-让用户自己去打开)

6.多任务列表窗口隐藏App(仅针对有这方面需求的App)

7.应用后台高耗电(仅针对Vivo手机)



3.保活方案实现步骤


(1). 前台Service


//前台服务
class ForegroundCoreService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
private var mForegroundNF:ForegroundNF by lazy {
ForegroundNF(this)
}
override fun onCreate() {
super.onCreate()
mForegroundNF.startForegroundNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if(null == intent){
//服务被系统kill掉之后重启进来的
return START_NOT_STICKY
}
mForegroundNF.startForegroundNotification()
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
mForegroundNF.stopForegroundNotification()
super.onDestroy()
}
}

//初始化前台通知,停止前台通知
class ForegroundNF(private val service: ForegroundCoreService) : ContextWrapper(service) {
companion object {
private const val START_ID = 101
private const val CHANNEL_ID = "app_foreground_service"
private const val CHANNEL_NAME = "前台保活服务"
}
private var mNotificationManager: NotificationManager? = null

private var mCompatBuilder:NotificationCompat.Builder?=null

private val compatBuilder: NotificationCompat.Builder?
get() {
if (mCompatBuilder == null) {
val notificationIntent = Intent(this, MainActivity::class.java)
notificationIntent.action = Intent.ACTION_MAIN
notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER)
notificationIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
//动作意图
val pendingIntent = PendingIntent.getActivity(
this, (Math.random() * 10 + 10).toInt(),
notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT
)
val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder(this,CHANNEL_ID)
//标题
notificationBuilder.setContentTitle(getString(R.string.notification_content))
//通知内容
notificationBuilder.setContentText(getString(R.string.notification_sub_content))
//状态栏显示的小图标
notificationBuilder.setSmallIcon(R.mipmap.ic_coolback_launcher)
//通知内容打开的意图
notificationBuilder.setContentIntent(pendingIntent)
mCompatBuilder = notificationBuilder
}
return mCompatBuilder
}

init {
createNotificationChannel()
}

//创建通知渠道
private fun createNotificationChannel() {
mNotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
//针对8.0+系统
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW
)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
channel.setShowBadge(false)
mNotificationManager?.createNotificationChannel(channel)
}
}

//开启前台通知
fun startForegroundNotification() {
service.startForeground(START_ID, compatBuilder?.build())
}

//停止前台服务并清除通知
fun stopForegroundNotification() {
mNotificationManager?.cancelAll()
service.stopForeground(true)
}
}

(2).忽略电池优化(Android 6.0+)


1.我们需要在AndroidManifest.xml中声明一下权限


<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />

2.通过Intent来请求忽略电池优化的权限(需要引导用户点击)


//在Activity的onCreate中注册ActivityResult,一定要在onCreate中注册
//监听onActivityForResult回调
mIgnoreBatteryResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { activityResult ->
//查询是否开启成功
if(queryBatteryOptimizeStatus()){
//忽略电池优化开启成功
}else{
//开启失败
}
}

通过Intent打开忽略电池优化弹框:


val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
intent.data = Uri.parse("package:$packageName")
//启动忽略电池优化,会弹出一个系统的弹框,我们在上面的
launchActivityResult(intent)

查询是否成功开启忽略电池优化开关:


fun Context.queryBatteryOptimizeStatus():Boolean{
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager?
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
powerManager?.isIgnoringBatteryOptimizations(packageName)?:false
} else {
true
}
}

(3).无障碍服务


看官方文档:创建自己的无障碍服务

它也是一个Service,它的优先级比较高,提供界面增强功能,初衷是帮助视觉障碍的用户或者是可能暂时无法与设备进行全面互动的用户完成操作。

可以做很多事情,使用了此Service,在6.0+不需要申请悬浮窗权限,直接使用WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY 挺方便的

(仅针对有需要此服务的app,可以开启增强后台保活)


(4).自启动权限(即:白名单管理列表页面)


是系统给用户自己去打开“自启动权限”开关的入口,我们需要针对不同的手机厂商和系统版本,弹出提示引导用户是否前去打开“自启动权限”

有的手机厂商叫:白名单管理,有的叫:自启动权限,两个是一个概念;

点击查看跳转到『手机自启动设置页面』完整代码


(需要注意:如果是代码控制跳转,无法保证永远可以调整,系统升级可能就给你屏蔽了,
最简单的方法是:显示一个如何找到自启动页面的引导图,下面以华为手机为例:)



华为手机-自启动管理

(5).多任务列表窗口加锁


可以针对不同手机厂商,显示引导用户,开启App窗口加锁之后,点击清理加速不会导致应用被kill



华为手机窗口加锁-教程图

(6).多任务列表窗口隐藏App窗口


刚刚上面多任务窗口加锁完,再提示用户去App里面把隐藏App窗口开关打开,这样用户就不会多任务列表里面把App窗口给手抖划掉


多任务窗口中『隐藏App窗口』,可以用如下代码控制:

(这个也只是针对有这方面需求App提供的一种增强方案罢了:因为隐藏了窗口,用户就不会去想他,不会去手痒去划掉它)


//在多任务列表页面隐藏App窗口
fun hideAppWindow(context: Context,isHide:Boolean){
try {
val activityManager: ActivityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
//控制App的窗口是否在多任务列表显示
activityManager.appTasks[0].setExcludeFromRecents(isHide)
}catch (e:Exception){
.....
}
}

(7).应用后台高耗电(Vivo手机独有)


开启的入口:“设置”>“电池”>“后台高耗电”>“找到xxxApp打开开关”



vivo允许后台高耗电



最后还是奉劝那些,仍然执着于找寻黑科技的开发者,醒醒吧,太阳晒屁股了。


如果说你的App用户群体不是普通用户,是专门给一些玩机大神们用的,都可以root手机的话,那么直接 move 到系统目录 priv/system/app 即可, 即使被用户强杀也会自动重新拉起。



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

Flutter实现"剑气"加载?️

效果图知识点Animation【动效】Clipper/Canvas【路径裁剪/画布】Matrix4【矩阵转化】剑气形状我们仔细看一道剑气,它的形状是一轮非常细小的弯弯的月牙;在Flutter中,我们可以通过Clipper路径来裁剪出来,或者也可以通过canva...
继续阅读 »

效果图

剑气加载.gif

知识点

  • Animation【动效】
  • Clipper/Canvas【路径裁剪/画布】
  • Matrix4【矩阵转化】

剑气形状

我们仔细看一道剑气,它的形状是一轮非常细小的弯弯的月牙;在Flutter中,我们可以通过Clipper路径来裁剪出来,或者也可以通过canvas绘制出来。

  1. 先看canvas如何进行绘制的
class MyPainter extends CustomPainter {
Color paintColor;

MyPainter(this.paintColor);

Paint _paint = Paint()
..strokeCap = StrokeCap.round
..isAntiAlias = true
..strokeJoin = StrokeJoin.bevel
..strokeWidth = 1.0;

@override
void paint(Canvas canvas, Size size) {
_paint..color = this.paintColor;
Path path = new Path();
// 获取视图的大小
double w = size.width;
double h = size.height;
// 月牙上边界的高度
double topH = h * 0.92;
// 以区域中点开始绘制
path.moveTo(0, h / 2);
// 贝塞尔曲线连接path
path.cubicTo(0, topH * 3 / 4, w / 4, topH, w / 2, topH);
path.cubicTo((3 * w) / 4, topH, w, topH * 3 / 4, w, h / 2);
path.cubicTo(w, h * 3 / 4, 3 * w / 4, h, w / 2, h);
path.cubicTo(w / 4, h, 0, h * 3 / 4, 0, h / 2);

canvas.drawPath(path, _paint);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false; // 一次性画好,不需要更新,返回false
}
  1. Clipper也上代码,跟canvas两种选其一即可,我用的是canvas
class SwordPath extends CustomClipper<Path> {
@override
getClip(Size size) {
print(size);
// 获取视图的大小
double w = size.width;
double h = size.height;
// 月牙上边界的高度
double topH = h * 0.92;
Path path = new Path();
// 以区域中点开始绘制
path.moveTo(0, h / 2);
// 贝塞尔曲线连接path
path.cubicTo(0, topH * 3 / 4, w / 4, topH, w / 2, topH);
path.cubicTo((3 * w) / 4, topH, w, topH * 3 / 4, w, h / 2);
path.cubicTo(w, h * 3 / 4, 3 * w / 4, h, w / 2, h);
path.cubicTo(w / 4, h, 0, h * 3 / 4, 0, h / 2);
return path;
}

@override
bool shouldReclip(covariant CustomClipper oldClipper) => false;
}
  1. 生成月牙控件
CustomPaint(
painter: MyPainter(widget.loadColor),
size: Size(200, 200),
),

让剑气旋转起来

我们需要剑气一直不停的循环转动,所以需要用到动画,让剑气围绕中心的转动起来。注意这里只是单纯的平面旋转,也就是我们说的2D变换。这里我们用到的是Transform.rotate控件,通过animation.value传入旋转的角度,从而实现360度的旋转。

class _SwordLoadingState extends State<SwordLoading>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double angle = 0;

@override
void initState() {
_controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 800));
// pi * 2:360°旋转
_animation = Tween(begin: 0.0, end: pi * 2).animate(_controller);
_controller.repeat(); // 循环播放动画
super.initState();
}

@override
Widget build(BuildContext context) {
return Transform.rotate(
alignment: Alignment.center,
angle: _animation.value,
child: CustomPaint(
painter: MyPainter(widget.loadColor),
size: Size(widget.size, widget.size),
),
);
}
}

转起来啦!

让剑气有角度的、更犀利的转动

  • 我们仔细看单独一条剑气,其实是在一个三维的模型中,把与Z轴垂直的剑气 向Y轴、X轴进行了一定角度的偏移。
  • 相当于在这个3D空间内,剑气不在某一个平面了,而是斜在这个空间内,然后 再绕着圆心去旋转。
  • 而观者的视图,永远与Z轴垂直【或者说:X轴和Y轴共同组成的平面上】,所以就会产生剑气 从外到里进行旋转 的感觉。

下图纯手工绘制,不要笑我~~~

纯手工绘制

不要笑我

综上,可以确定这个过程是一个3D的变换,很明显我们Transform.rotate这种2D的widget已经不满足需求了,这个时候Matrix4大佬上场了,我们通过Matrix4.identity()..rotate的方法,传入我们的3D转化,在通过rotateZ进行旋转,简直完美。代码如下

 AnimatedBuilder(
animation: _animation,
builder: (context, _) => Transform(
transform: Matrix4.identity()
..rotate(v.Vector3(0, -8, 12), pi)
..rotateZ(_animation.value),
alignment: Alignment.center,
child: CustomPaint(
painter: MyPainter(widget.loadColor),
size: Size(widget.size, widget.size),
),
),
),

这里多说一句,要完成矩阵变换,Matrix4必不可少,可以着重学习下。

让剑气一起动起来

完成一个剑气的旋转之后,我们回到预览效果,无非就是3个剑气堆叠在一起,通过偏移角度去区分。Flutter堆叠效果直接用Stack实现,完整代码如下:

import 'package:flutter/material.dart';
import 'dart:math';
import 'package:vector_math/vector_math_64.dart' as v;

class SwordLoading extends StatefulWidget {
const SwordLoading({Key? key, this.loadColor = Colors.black, this.size = 88})
: super(key: key);

final Color loadColor;
final double size;

@override
_SwordLoadingState createState() => _SwordLoadingState();
}

class _SwordLoadingState extends State<SwordLoading>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double angle = 0;

@override
void initState() {
_controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 800));
_animation = Tween(begin: 0.0, end: pi * 2).animate(_controller);
_controller.repeat();
super.initState();
}

@override
Widget build(BuildContext context) {
return Stack(
children: [
AnimatedBuilder(
animation: _animation,
builder: (context, _) => Transform(
transform: Matrix4.identity()
..rotate(v.Vector3(0, -8, 12), pi)
..rotateZ(_animation.value),
alignment: Alignment.center,
child: CustomPaint(
painter: MyPainter(widget.loadColor),
size: Size(widget.size, widget.size),
),
),
),
AnimatedBuilder(
animation: _animation,
builder: (context, _) => Transform(
transform: Matrix4.identity()
..rotate(v.Vector3(-12, 8, 8), pi)
..rotateZ(_animation.value),
alignment: Alignment.center,
child: CustomPaint(
painter: MyPainter(widget.loadColor),
size: Size(widget.size, widget.size),
),
),
),
AnimatedBuilder(
animation: _animation,
builder: (context, _) => Transform(
transform: Matrix4.identity()
..rotate(v.Vector3(-8, -8, 6), pi)
..rotateZ(_animation.value),
alignment: Alignment.center,
child: CustomPaint(
painter: MyPainter(widget.loadColor),
size: Size(widget.size, widget.size),
),
),
),
],
);
}
}

class MyPainter extends CustomPainter {
Color paintColor;

MyPainter(this.paintColor);

Paint _paint = Paint()
..strokeCap = StrokeCap.round
..isAntiAlias = true
..strokeJoin = StrokeJoin.bevel
..strokeWidth = 1.0;

@override
void paint(Canvas canvas, Size size) {
_paint..color = this.paintColor;
Path path = new Path();
// 获取视图的大小
double w = size.width;
double h = size.height;
// 月牙上边界的高度
double topH = h * 0.92;
// 以区域中点开始绘制
path.moveTo(0, h / 2);
// 贝塞尔曲线连接path
path.cubicTo(0, topH * 3 / 4, w / 4, topH, w / 2, topH);
path.cubicTo((3 * w) / 4, topH, w, topH * 3 / 4, w, h / 2);
path.cubicTo(w, h * 3 / 4, 3 * w / 4, h, w / 2, h);
path.cubicTo(w / 4, h, 0, h * 3 / 4, 0, h / 2);

canvas.drawPath(path, _paint);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) =>
false; // 一次性画好,不需要更新,返回false
}

业务端调用

SwordLoading(loadColor: Colors.black,size: 128),

写在最后

花了我整个周六下午的时间,很开心用Flutter实现了加载动画,说说感受吧。

  1. 在编写的过程中,对比html+css的方式,Flutter的实现难度其实更大,而且剑气必须使用canvas绘制出来。
  2. 如果你也懂前端,你可以深刻体会声明式和命令式UI在编写布局和动画所带来的强烈差异,从而加深Flutter万物皆对象的思想。*【因为万物皆对象,所以所有控件和动画,都是可以显示声明的对象,而不是像前端那样通过解析xml命令来显示】
  3. 2D/3D变换,我建议Flutter学者们,一定要深入学习,这种空间思维对我们实现特效是不可获取的能力。

好了,小弟班门弄斧,希望能一起学习进步!!!


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

收起阅读 »

常见问题之webView 内存泄露

WebView内存泄露的原因:webView内部的一些线程持有activity对象,导致activity无法释放。继而内存泄漏。现象正常使用都会有内存泄露反复进出多次后:内存没有下降。 网上查了一些解决办法,主要有两种:解决方法一1.在Activit...
继续阅读 »

WebView内存泄露的原因:

webView内部的一些线程持有activity对象,导致activity无法释放。继而内存泄漏。

现象

正常使用都会有内存泄露

image.png

反复进出多次后:
内存没有下降。 image.png

网上查了一些解决办法,主要有两种:

解决方法一

1.在Activity中手动new WebView,然后将Activity通过弱引用的方式传进去。并且在onDestroy里将webview先从父容器中移除webview,然后再销毁webview(调用webView的各种clear)

image.png

image.png

页面反复进出以后:

屁用没有

image.png

解决方法二

2.新启一个进程,单独进程跑webView。AIDL通信。
简化的代码以后:

AIDL接口:
image.png

AIDL的服务: image.png

设置服务隐士启动:
image.png

客户端显示webView的Activity:
image.png

将Activity设置成单独进程:
image.png

在Activity的onDestroy,解绑Service和结束进程
image.png

页面反复进出以后:
没有占用内存,完美。 image.png

补充
如果想要webView跟Activity有更多的数据交互,可以用AIDL->Service(数据交互)->Activity(主进程,Handler、回调等方式)

收起阅读 »

ConstraintLayout2.0一篇写不完之KeyCycles的秘密

KeyCycle与KeyFrame类似,但是又比KeyFrame复杂,复杂在于KeyFrame只是单帧,而KeyCycle则是在KeyFrame的基础上,增加了周期性的处理,所以,KeyCycle的核心就是周期,KeyCycle决定了在Scene中所有需要重复...
继续阅读 »

KeyCycle与KeyFrame类似,但是又比KeyFrame复杂,复杂在于KeyFrame只是单帧,而KeyCycle则是在KeyFrame的基础上,增加了周期性的处理,所以,KeyCycle的核心就是周期,KeyCycle决定了在Scene中所有需要重复处理的部分操作,它的核心API如下所示。

  • framePosition:作为一个KeyFrame,KeyCycle必须知道在场景的哪一点上进行操作
  • motionTarget:指定的View ID
  • wavePeriod:周期数量
  • waveOffset:起始位置的偏移
  • waveShape:Cycle的波形

image-20210827101705951

MotionLayout提供了CycleEditor来帮助开发者编辑KeyCycle,下载地址如下:

github.com/googlesampl…

直接执行即可,点击file中的parse,就可以将编辑区域的xml转换为波形。

java -jar CycleEditor.jar

分割Scene

在创建KeyCycle之前的第一件事,就是通过使用不同的framePositions把你的Scene分成多部分组合的Partial Scene,接下来就可以通过使用wavePeriod来指定你想要的每个部分的周期数,以及waveShape来指定具体的波形。

1*cdtzWO2JKu6VvmN9Ew2kvw

wavePeriod是KeyCycle最难理解的一部分,要掌握wavePeriod的定义,就必须先了解Partial Scene,Partial Scene指的是当前指定点的前一个点和后一个点,总共三个点构成的区域,这点非常重要。

在某个framePosition的KeyCycle中指定wavePeriod,其实就是指在这个Partial Scene中,有几个周期的波形来填满这个区域。

但是这里问题又来了,每个framePosition都被周围的framePosition有关,那么wavePeriod不是被重复计算了吗?

没错。。。所以在整个Partial Scene中的wavePeriod是由Partial Scene中所有framePosition的的wavePeriod之和确定的。很绕是不是,是就对了。

这也是为什么KeyCycle有个单独的生成工具的原因,结合KeyCycleEditor,还是比较能理解的。

wavePeriod已经很绕了,但是绕的还在后面。

我们再来看看KeyCycle中指定的具体属性值的含义。

例如,我们在KeyCycle中指定rotation为20,代码如下所示。

<KeyCycle 
motion:framePosition="0"
motion:target="@+id/button"
motion:wavePeriod="0"
motion:waveOffset="0"
motion:waveShape="sin"
android:rotation="20"/>

这个rotation为20是什么意思?你以为是当然framePosition的属性值为20吗?太年轻了。。。

其实这个属性值与View在当前framePosition的属性值,并没有直接联系。。。

是不是很奇怪,的确如此,那么这玩意儿到底是干嘛的呢???

这里我们需要转换一下思路,那就是KeyCycle里面设置的一切东西,都是为了画出「波形图」,所以,这些参数的设置,就是为了修改波形图的具体形状。

<KeyFrameSet>

<KeyCycle
motion:framePosition="0"
motion:target="@+id/button"
motion:wavePeriod="0"
motion:waveOffset="0"
motion:waveShape="sin"
android:rotation="0"/>

<KeyCycle
motion:framePosition="50"
motion:target="@+id/button"
motion:wavePeriod="1"
motion:waveOffset="0"
motion:waveShape="sin"
android:rotation="10"/>

<KeyCycle
motion:framePosition="100"
motion:target="@+id/button"
motion:wavePeriod="0"
motion:waveOffset="0"
motion:waveShape="sin"
android:rotation="30"/>

</KeyFrameSet>

这样一个KeyCycle最后形成的波形图就是这样。

image-20210827151332911

由此可以发现,每个framePosition的属性值,就是为了画出波形图的波峰。

在这个的基础上,waveOffset就好理解了,它的作用就是给framePosition的当前value增加一个初始值,这个初始值同样是为了修改波形。

要干嘛

你说KeyCycle这玩意儿整这么复杂,到底有什么用呢??

我们有了KeyFrame,可以用来添加中间态关键帧,那么还要KeyCycle干嘛呢?

说到这来,就不能不提下Monotonic Spline(单调采样)了,通常的关键帧插值算法都是使用的单调采样,但是这样无法做到曲线的圆滑过渡,就像下图中的绿色曲线,这样四个点使用单调采样,就变成了下面这样的曲线,过渡会非常生硬。

image-20210827154425111

那么为了让曲线圆滑过渡,KeyCycle使用的是Typical Spline(特征采样),就如上图中的紫色曲线,四个点被圆滑的连接了起来。

如果仅仅是为了让曲线能圆滑过渡,那么你就太小看KeyCycle了,不得不说老外做的这些东西,总能在一些你觉得无关紧要的地方,做的非常深入。

KeyCycle的核心在于波形,而波是什么呢?

image-20210827155302534

上面这张图表达了sin和cos的几何含义,也是sin和cos的来源。

说句不像傅里叶变换的话,我们可以将一个View的曲线运动,拆解成多个不同波形运动的叠加。

例如我们对一个View的translationX同时设置sin和cos的KeyCycle,最终形成的运动轨迹,就是一个圆形!

所以,由此及彼,我们可以复合多个属性的同时,通过不同的波形叠加,实现任何你想要的运动轨迹!这TM就牛逼了啊,简直就是傅里叶变换在Android动画中的实现了。

在CycleEditor中,有一些自带的Demo,可以让你充分的了解这个思想,例如下面这个例子。

image-20210827160218065

太复杂了是吗?

CustomWave shape in keyCycle

CL2.1之后,motion:waveShape除了之前定义的sin、cos、bounce这些预设曲线外,你还可以设置自定义的波形曲线,定义方式如下所示。

<KeyCycle motion:waveShape=”spline(0.0, 1.0, -1.0, 0)” />

这就有点牛逼了,本来就很复杂了,这下还来了自定义曲线,再见。

KeyCycle确实比较强大,但是也非常复杂,强烈建议大家使用CycleEditor来学习,KeyCycle这种东西,就像核武器一样,可以不用,但是不能没有。

收起阅读 »

Jetpack App Startup如何使用及原理分析

1.App Startup是什么?来自 Google官方App Startup文档: App Startup 库提供了一种在应用程序启动时初始化组件的简单、高效的方法。Libary开发人员和App开发人员都可以使用App Startup来简化启动顺序...
继续阅读 »

1.App Startup是什么?

来自 Google官方App Startup文档: App Startup 库提供了一种在应用程序启动时初始化组件的简单、高效的方法。
Libary开发人员和App开发人员都可以使用App Startup来简化启动顺序并明确设置初始化顺序。

2.简单回顾一下ContentProvider

内容提供者ContentProvider 玩法有很多种:可以进程间通信(数据共享)、ContentResolver#registerContentObserver观察uri变化(可以解析uri来处理各种命令) 等等
ContentProvider通过Binder实现进程间通信,使用起来比AIDL要简单,因为系统已经帮我们进行了封装(这里就不扩散介绍Binder,东西太多)

(1).注册

我们需要在AndroidManifest.xml中注册我们的ContentProvider

//AndroidManifest.xml
<provider
android:authorities="..."
android:name="..."/>

上面有两个属性,我们来看一下是干什么的?

  • android:authorities: 一个或多个URI授权方的列表,多个授权方需要使用分号( ; )分隔开。
  • android:name: 完整的ContentProvider类名,如:(com.xxx.xxx.xxxProvider)

(2).实现一个CustomProvider

class CustomProvider: ContentProvider() {
override fun onCreate(): Boolean {
// 返回true表示初始化成功
// 返回false表示初始化失败
}
override fun query(uri: Uri, projection: Array<out String>?,selection: String?,selectionArgs: Array<out String>?,sortOrder: String?): Cursor? {
//查询数据返回Cursor,如果是别的玩法,返回null也可以
}
override fun getType(uri: Uri): String? {
//没有类型的话可以直接返回null
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
//插入数据,返回uri,如果是别的玩法,返回null也可以
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
//删除数据,返回行数,如果是别的玩法,返回-1也可以
}
override fun update(uri: Uri,values: ContentValues?,selection: String?,selectionArgs: Array<out String>?): Int {
//更新数据,返回行数,如果是别的玩法,返回-1也可以
}
}

(3).ContentProvider在哪初始化的?

我们从ActivityThread的main函数开始跟踪:

//frameworks/base/core/java/android/app/ActivityThread.java
public static void main(String[] args) {
....
ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);
....
}

我们来看一下ActivityThread的attach方法

private void attach(boolean system, long startSeq) {
....
final IActivityManager mgr = ActivityManager.getService();
try {
//通过IBinder进程间通讯调用AMS
mgr.attachApplication(mAppThread, startSeq);
} catch (RemoteException ex) {
.....
}
.....
}

打开ActivityManagerService查看attachApplication方法里面会执行什么

//com.android.server.am.ActivityManagerService
public final void attachApplication(IApplicationThread thread, long startSeq) {
....
synchronized (this) {
....
attachApplicationLocked(thread, callingPid, callingUid, startSeq);
....
}
}

private boolean attachApplicationLocked(@NonNull IApplicationThread thread,
int pid, int callingUid, long startSeq) {
......
try {
......
if (instr2 != null) {
thread.bindApplication(processName, appInfo, providerList,
instr2.mClass,
profilerInfo, instr2.mArguments,
instr2.mWatcher,....);
} else {
thread.bindApplication(processName, appInfo, providerList, null, profilerInfo,
null, null, null, testMode,....);
}
......
} catch (Exception e) {
......
}
......
}

看到这里我们发现,调用了ActivityThread里面的bindApplication:

//android.app.ActivityThread.ApplicationThread#bindApplication

public final void bindApplication(String processName, ApplicationInfo appInfo,........){
.....
AppBindData data = new AppBindData();
data.processName = processName;
data.appInfo = appInfo;
//ContentProvider列表
data.providers = providerList.getList();
.....
sendMessage(H.BIND_APPLICATION, data);
}

看到这里,仿佛此刻看到了光,BIND_APPLICATION这条消息内部会执行到ActivityThread#handleBindApplication

//android.app.ActivityThread.ApplicationThread#handleBindApplication

private void handleBindApplication(AppBindData data) {
.....
Application app;
.....
try {
//触发LoadedApk调用makeApplication
app = data.info.makeApplication(data.restrictedBackupMode, null);
.....
mInitialApplication = app;
if (!data.restrictedBackupMode) {
if (!ArrayUtils.isEmpty(data.providers)) {
//安装ContentProvider入口
installContentProviders(app, data.providers);
}
}
....
try {
//调用Application#onCreate方法
mInstrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
....
}
} finally {
.....
}
.....
}

handleBindApplication步骤如下:
(1). 执行LoadedApk调用makeApplication
(2). 执行installContentProviders遍历providers列表,实例化ContentProvider
(3). mInstrumentation.callApplicationOnCreate触发Application#onCreate回调

//android.app.ActivityThread#installContentProviders
private void installContentProviders(
Context context, List<ProviderInfo> providers) {
final ArrayList<ContentProviderHolder> results = new ArrayList<>();
for (ProviderInfo cpi : providers) {
....
//真正初始化ContentProvider的地方
ContentProviderHolder cph = installProvider(context, null, cpi,
false /*noisy*/, true /*noReleaseNeeded*/, true /*stable*/);
if (cph != null) {
cph.noReleaseNeeded = true;
results.add(cph);
}
}
try {
//AMS内部会执行mHandler.removeMessages(CONTENT_PROVIDER_PUBLISH_TIMEOUT_MSG, r)
ActivityManager.getService().publishContentProviders(
getApplicationThread(), results);
} catch (RemoteException ex) {
....
}
}

installContentProviders步骤如下:
(1). 遍历providers列表,执行installProvider来实例化ContentProvider
(2). AMS调用publishContentProviders清除掉超时的消息,否则会导致应用被kill

//android.app.ActivityThread#installProvider
private ContentProviderHolder installProvider(Context context,
ContentProviderHolder holder, ProviderInfo info,
boolean noisy, boolean noReleaseNeeded, boolean stable) {
ContentProvider localProvider = null;
IContentProvider provider;
if (holder == null || holder.provider == null) {
....
Context c = null;
ApplicationInfo ai = info.applicationInfo;
//初始化Context
if (context.getPackageName().equals(ai.packageName)) {
c = context;
} else if (mInitialApplication != null &&
mInitialApplication.getPackageName().equals(ai.packageName)) {
c = mInitialApplication;
} else {
try {
c = context.createPackageContext(ai.packageName,
Context.CONTEXT_INCLUDE_CODE);
} catch (PackageManager.NameNotFoundException e) {
// Ignore
}
}
....
try {
final java.lang.ClassLoader cl = c.getClassLoader();
//ContentProvider和Application的加载器是同一个
LoadedApk packageInfo = peekPackageInfo(ai.packageName, true);
if (packageInfo == null) {
// System startup case.
packageInfo = getSystemContext().mPackageInfo;
}
//通过ClassLoader初始化ContentProvider
localProvider = packageInfo.getAppFactory()
.instantiateProvider(cl, info.name);
provider = localProvider.getIContentProvider();
if (provider == null) {
return null;
}
//ContentProvider实例化成功之后,回调ContentProvider#onCreate
localProvider.attachInfo(c, info);
} catch (java.lang.Exception e) {
return null;
}
} else {
provider = holder.provider;
}
....
return retHolder;
}

installProvider步骤如下:
(1). 获取Context
(2). 通过peekPackageInfo获取LoadedApk,然后通过ClassLoader来实例化ContentProvider
(3). ContentProvider实例化成功之后,attachInfo会触发ContentProvider#onCreate回调

从上面的代码分析之后得出结论:ContentProvider的onCreate会比Application的onCreate先执行

(4).ContentResolver

如果我们需要访问内容提供者里面数据的话,可以使用App的Context中的ContentResolver对象与ContentProvider进行通信。
ContentProvider对象从客户端接收数据请求、执行请求的操作并返回结果。ContentResolver方法可以提供基本的“CRUD”功能。

A.在哪初始化

//frameworks/base/core/java/android/app/LoadedApk.java

public Application makeApplication(boolean forceDefaultAppClass,
Instrumentation instrumentation) {
....
Application app = null;
....
try {
....
//初始化ContextImpl,并初始化一个ApplicationContentResolver「即ContentResolver」
ContextImpl appContext = ContextImpl.createAppContext(mActivityThread, this);
....
//执行进程中application对象的实例化,并执行Application#attach方法同时触发attachBaseContext回调
//更多细节,感兴趣的,可以自己看源码查看更多内容
app = mActivityThread.mInstrumentation.newApplication(
cl, appClass, appContext);
//设置applicationContext
appContext.setOuterContext(app);
} catch (Exception e) {
...
}
....
mApplication = app;
if (instrumentation != null) {
try {
//触发application的onCreate方法执行
instrumentation.callApplicationOnCreate(app);
} catch (Exception e) {
....
}
}
....
return app;
}

makeApplication简单逻辑如下:
(1).初始化ContextImpl同时初始化ContentResolver
(2).使用ActivityThread中的Instrumentation执行里面的newApplication:
初始化Application并调用attach方法并触发attachBaseContext回调
(3).调用Application的onCreate方法

B.监听数据变化

我们可以使用ContentResolver#registerContentObserver来观察数据是否发生变化。
我们来看一下简单的demo:

//注册内容观察者
contentResolver.registerContentObserver(Uri.parse("....."),true,xxxxCallback)

//不是和数据库进行操作交互的话,自己写的一些功能玩法的uri可以像下面这样
//uri => content://xxxx.service.status
//我们registerContentObserver观察这个原始的uri
//别的进程或者app通过:contentResolver.notifyChange(uri+"/lastPathSegment")
//可以通过下面的方式去解析uri来做一些操作,可玩性也比较好
private val xxxxCallback: ContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
super.onChange(selfChange, uri)
val lastPathSegment = uri?.lastPathSegment
//有些玩法可以自己定义,比如我们可以拿这个lastPathSegment来实现一些命令操作
.....
}
}

C.如何CURD

//查询数据
ContentResolver#query(android.net.Uri, java.lang.String[], java.lang.String, java.lang.String[], java.lang.String)

//插入一条数据
ContentResolver#insert(android.net.Uri, android.content.ContentValues)

//删除数据
ContentResolver#delete(android.net.Uri, java.lang.String, java.lang.String[])

//更新数据
ContentResolver#update(android.net.Uri, android.content.ContentValues, java.lang.String, java.lang.String[])

3.App Startup使用

(1).依赖

dependencies {
implementation("androidx.startup:startup-runtime:<新版本>")
}

(2).AndroidManifest.xml配置

App Startup是用InitializationProvider来发现和调用你的组件,所以这里我们需要定义<provider/>

<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data android:name="xxx.xxx.xxxInitializer"
android:value="androidx.startup" />
</provider>

使用tools:node="merge"合并属性,解决任何冲突的条目,合并到一起。
使用<meta-data/>,这使得我们自定义的Initializer可以被发现。
如果<provider/>里面增加tools:node="remove"禁用所有组件的自动初始化。
如果需要禁用单个组件自动初始化,可以在<meta-data/>里面增加:tools:node="remove"

(3).自定义Initializer

class MyInitializer :Initializer<Boolean>{
override fun create(context: Context): Boolean {
//可以在此处进行SDK初始化
....
return true
}
override fun dependencies(): List<Class<out Initializer<*>>> {
//没有依赖直接返回emptyList()即可
//如果有依赖,是针对实现了Initializer的类
return emptyList()
}
}

举个例子:
一、Leakcanary目前还没有集成App Startup,那么我们怎么使用呢?
首先我们去AndroidManifest.xml找到Leakcanary的<provider/>

WX20210902-105232@2x.png

然后我们在AndroidManifest.xml找到这两个<provider/>,添加tools:node="remove"

<provider
android:name="leakcanary.internal.PlumberInstaller"
android:authorities="写你自己的包名.plumber-installer"
android:enabled="@bool/leak_canary_plumber_auto_install"
android:exported="false"
tools:node="remove"/>
<provider
android:name="leakcanary.internal.AppWatcherInstaller$MainProcess"
android:authorities="写你自己的包名.leakcanary-installer"
android:enabled="@bool/leak_canary_watcher_auto_install"
android:exported="false"
tools:node="remove"/>

这个时候我们就可以在我们的App Startup中来控制初始化,愉快的玩耍了

class MyInitializer :Initializer<Boolean>{
override fun create(context: Context): Boolean {
val application = context.applicationContext as Application
AndroidLeakFixes.applyFixes(application)
AppWatcher.manualInstall(application)
....
return true
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}

二、WorkManager目前也没有集成App Startup,那么我们自己来扩展一下它实现Initializer,然后再看一下dependencies()的用处

class MyWorkManagerInitializer:Initializer<WorkManager> {
override fun create(context: Context): WorkManager {
//集成了WorkManager之后,可以在AndroidManifest.xml中找到默认的provider,可以看到内部实现
WorkManager.initialize(context, Configuration.Builder().build())
return WorkManager.getInstance(context)
}
override fun dependencies(): List<Class<out Initializer<*>>> {
return emptyList()
}
}

我们还需要把WorkManager默认的provider删除掉

<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="你自己的包名.workmanager-init"
android:directBootAware="false"
android:exported="false"
android:multiprocess="true"
tools:targetApi="n"
tools:node="remove"/>

在我们的MyInitializer中依赖MyWorkManagerInitializer

class MyInitializer :Initializer<Boolean>{
override fun create(context: Context): Boolean {
//先触发依赖的执行,执行完,我们这个create才会被执行到,表示依赖已经完成,可以正常使用了
return true
}
//先执行
override fun dependencies(): List<Class<out Initializer<*>>> {
return listOf(MyWorkManagerInitializer::class.java)
}
}

玩法使用如下: (1). 我们可以自己定义扩展的XXXInitializer来替换第三方Libary的ContentProvider,然后在自定义的Initializer的dependencies()中依赖扩展的XXXInitializer
(2). 不自定义扩展,我们知道第三方Libary的ContentProvider内部初始化的代码,可以写在自定义的Initializer的onCreate()中也可以。

4.原理分析

我们在上面提到AndroidManifest.xml中使用了InitializationProvider,它就是一个ContentProvider

//androidx.startup.InitializationProvider

public class InitializationProvider extends ContentProvider {
@Override
public final boolean onCreate() {
Context context = getContext();
if (context != null) {
//初始化解析
AppInitializer.getInstance(context).discoverAndInitialize();
} else {
throw new StartupException("Context cannot be null");
}
return true;
}
....
}

InitializationProvider#onCreate
获取内容提供者信息,解析meta-data数据,初始化Initializer并执行Initializer#create调用

//androidx.startup.AppInitializer

void discoverAndInitialize() {
try {
Trace.beginSection(SECTION_NAME);
ComponentName provider = new ComponentName(mContext.getPackageName(),
InitializationProvider.class.getName());
//通过PackageManager.getProviderInfo获取内容提供者信息
ProviderInfo providerInfo = mContext.getPackageManager()
.getProviderInfo(provider, GET_META_DATA);
//获取元数据
Bundle metadata = providerInfo.metaData;
//用于比较元数据里面的数据是不是androidx.startup
String startup = mContext.getString(R.string.androidx_startup);
if (metadata != null) {
Set<Class<?>> initializing = new HashSet<>();
Set<String> keys = metadata.keySet();
//遍历元数据
for (String key : keys) {
String value = metadata.getString(key, null);
if (startup.equals(value)) { //只初始化匹配成功的
Class<?> clazz = Class.forName(key);
if (Initializer.class.isAssignableFrom(clazz)) {
Class<? extends Initializer<?>> component =
(Class<? extends Initializer<?>>) clazz;
mDiscovered.add(component);
if (StartupLogger.DEBUG) {
StartupLogger.i(String.format("Discovered %s", key));
}
//初始化Initializer
doInitialize(component, initializing);
}
}
}
}
} catch (PackageManager.NameNotFoundException | ClassNotFoundException exception) {
throw new StartupException(exception);
} finally {
Trace.endSection();
}
}

discoverAndInitialize()逻辑如下:
(1). 通过PackageManager.getProviderInfo获取内容提供者信息 (2). 遍历元数据key列表,比较value是否等于androidx.startup (3). 条件匹配成功,执行doInitialize(component, initializing)

//androidx.startup.AppInitializer#doInitialize

<T> T doInitialize(
@NonNull Class<? extends Initializer<?>> component,
@NonNull Set<Class<?>> initializing) {
synchronized (sLock) {
try {
....
if (initializing.contains(component)) {
//没有初始化完成的抛异常
throw new IllegalStateException(....);
}
Object result;
//缓存里面是否已经有初始化过的数据
if (!mInitialized.containsKey(component)) {
//记录正在进行初始化
initializing.add(component);
try {
//实例化Initializer
Object instance = component.getDeclaredConstructor().newInstance();
Initializer<?> initializer = (Initializer<?>) instance;
//调用initializer.dependencies()
List<Class<? extends Initializer<?>>> dependencies =
initializer.dependencies();

//如果dependencies不为空
if (!dependencies.isEmpty()) {
for (Class<? extends Initializer<?>> clazz : dependencies) {
if (!mInitialized.containsKey(clazz)) {
//递归调用
doInitialize(clazz, initializing);
}
}
}
//回调Initializer#create
result = initializer.create(mContext);
//Initializer初始化完,移除临时添加的component
initializing.remove(component);
//添加到缓存中
mInitialized.put(component, result);
} catch (Throwable throwable) {
throw new StartupException(throwable);
}
} else {
//从缓存中获取
result = mInitialized.get(component);
}
return (T) result;
} finally {
...
}
}
}

doInitialize步骤如下:
(1). 正在初始化的HashSet集合(initializing)中如果包含component,需要抛出异常。
(2). 缓存的Map集合mInitialized中没有component: 先把component记录到正在初始化的HashSet集合(initializing),实例化Initializer。
检查initializer.dependencies()集合是否有数据,如果有数据,需要递归调用doInitialize
如果没有数据直接执行Initializer#create方法的调用,然后从正在初始化的HashSet集合(initializing)移除component,再把component添加到缓存的Map集合mInitialized中。
(3). 缓存的Map集合mInitialized中有component,直接从Map集合中get返回,不用重复初始化。
(4). 从上面代码分析可以看出来:Initializer中dependencies()优先执行,最后再执行Initializer#create

5.总结 + 耗时对比

App Startup VS 多个ContentProvider 耗时对比,默认对比数值是参考什么都不集成的原始apk

我们用三款设备进行测试冷启动比较(大概的一个数据值,仅供参考):

测试设备1: Huawei Mate20-HarmonyOS 2.0.0 001.png


测试设备2: Nexus 6-Android 7.1 002.png


测试设备3: Nokia N1平板-Android 5.1.1 003.png


结论如下:
App Startup使用的好处有:统一管理,简化启动顺序并明确设置初始化顺序。
如果每个Libary库开发者都自己搞一个ContentProvider来初始化,不方便管理。
设计初衷应该是为了收拢ContentProvider,实际上对启动优化的帮助不是特别大。
抛开ContentProvider中初始化数据,如果大家在项目中大多数的时候会使用线程池异步加载初始化一些数据,这个时候App Startup就没有必要使用了。

我们从测试的数据对比可以看出来:
针对少量的SDK使用少量ContentProvider的时候,如果使用App Startup不一定能缩短应用启动时间。
但是在多个不同性能的设备上(尤其是低端机),App Startup启动时间会比使用多个ContentProvider有些许缩短时间。

看完上面的内容,我想大家应该知道App Startup什么时候用,怎么用,为什么要用。
这样我写这篇文章的目的就达到了。

收起阅读 »

Jetpack生命周期管理 -Lifecycle实战及源码分析

上次我们聊了 Android 触摸事件传递机制,这次我们来聊聊 Jetpack。具体地说是聊聊他的生命周期管理组件 LifeCycle,因为JetPack这个官方库还蛮大。这里不会再讲 Jetpack的前世今生,以及他的作用什么的。这里我们主要讲讲 LifeC...
继续阅读 »

上次我们聊了 Android 触摸事件传递机制,这次我们来聊聊 Jetpack。具体地说是聊聊他的生命周期管理组件 LifeCycle,因为JetPack这个官方库还蛮大。这里不会再讲 Jetpack的前世今生,以及他的作用什么的。这里我们主要讲讲 LifeCycle的基本使用,以及用LifeCycle改进一下上次我们讲到的 MVP 的例子。

然后从源码角度分析一下 LifeCycle是如何帮助 Activity 或 Fragment管理生命周期的。后续会继续推出分析 Jetpack其他组件的文章。

我们知道,我们在用某些模块进行数据加载的时候,往往需要去监听 Activity或 Fragment的生命周期。再根据生命周期的变化去调整数据加载或回调的策略。

不使用组件来管理的话,一般我们可以在 Activity或 Fragment的生命周期回调方法里手动去调用模块的生命周期方法。这样页面的生命周期回调方法里可能就会出现大量这样的刻板代码,也不好管理。LifeCycle就用来解决这样的一些问题。

1、使用

使用 LifeCycle不用添加依赖了,因为已经内置了。如果要使用 Viewmodel和 Livedata则需要加依赖,这两个后续会有文章分析。

首先,既然是生命周期的监听,那就会有观察者和被观察者。被观察者需要实现的接口是 LifecycleOwner,也就是生命周期拥有者(Activity 或 Fragment)。这两者都实现了 LifecycleOwner接口,我们可以看一下 Activity 的父类 ComponentActivity:

public class ComponentActivity extends androidx.core.app.ComponentActivity implements
LifecycleOwner,
...
...
{

生命周期观察者需要实现的接口是 LifecycleObserver。下面我们就结合上次 MVP架构的例子,给 Presenter添加 LifeCycle生命周期监听方法。

首先让 BasePresenter实现观察者接口:

// 实现 LifecycleObserver
public class BasePresenter implements LifecycleObserver {
private V mView;
public void attach(V iView) {
this.mView = iView;
}
public void detach() {
this.mView = null;
}
public V getView() {
return mView;
}
}

然后我们给 BasePresenter的实现类 Presenter添加几个生命周期的方法,并加上**@OnLifecycleEvent**注解:

//  Presenter.java

// onCreate
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
public void onCreate(){
Log.d(TAG, "-----------LifecycleObserver -- onCreate");
}
// onStart
@OnLifecycleEvent(Lifecycle.Event.ON_START)
public void onStart(){
Log.d(TAG, "-----------LifecycleObserver -- onStart");
}
// onPause
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
public void onPause(){
Log.d(TAG, "-----------LifecycleObserver -- onPause");
}
// onStop
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
public void onStop(){
Log.d(TAG, "-----------LifecycleObserver -- onStop");
}
// onDestroy
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
public void onDestroy(){
Log.d(TAG, "-----------LifecycleObserver -- onDestroy");
}

然后在 BaseMvpActivity初始化时添加观察者:

// BaseMvpActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView();
initView();
initData();
mPresenter = createP();
//mPresenter.attach(this);
// 注释 1, 添加观察者
getLifecycle().addObserver(mPresenter);
}
@Override
protected void onDestroy() {
super.onDestroy();
// mPresenter.detach();
// 移除观察者
getLifecycle().removeObserver(mPresenter);
}

上次例子使用了模板设计模式,所以现在生命周期观察者的添加和移除也放在模板里了。 然后打开 Activity后退出,看打印结果:

com.ethan.mvpapplication D/Presenter: -----------LifecycleObserver -- onCreate
com.ethan.mvpapplication D/Presenter: -----------LifecycleObserver -- onStart
com.ethan.mvpapplication D/Presenter: -----------LifecycleObserver -- onPause
com.ethan.mvpapplication D/Presenter: -----------LifecycleObserver -- onStop
com.ethan.mvpapplication D/Presenter: -----------LifecycleObserver -- onDestroy

Demo: MVP

2、源码分析

下面我们来分析一下 Lifecycle生命周期监听的原理。简单粗暴,直接点进上面注释 1的getLifecycle()方法,看看干了啥:

//  ComponentActivity.java

private final LifecycleRegistry mLifecycleRegistry = new LifecycleRegistry(this);

@Override
public Lifecycle getLifecycle() {
return mLifecycleRegistry;
}

返回的是一个 LifecycleRegistry 对象,我们可以点进去看。LifecycleRegistry 是抽象类 Lifecycle的实现类:

public abstract class Lifecycle {
@MainThread
public abstract void addObserver(@NonNull LifecycleObserver observer);
@MainThread
public abstract void removeObserver(@NonNull LifecycleObserver observer);
@MainThread
@NonNull
public abstract androidx.lifecycle.Lifecycle.State getCurrentState();
public enum Event {
ON_CREATE,
ON_START,
ON_RESUME,
ON_PAUSE,
ON_STOP,
ON_DESTROY,
ON_ANY
}
public enum State {
DESTROYED,
INITIALIZED,
CREATED,
STARTED,
RESUMED;
public boolean isAtLeast(@NonNull androidx.lifecycle.Lifecycle.State state) {
return compareTo(state) >= 0;
}
}
}

上面抽象类 Lifecycle 不仅包含了添加和移除观察者的方法,还包含了 Event 和 State 两个枚举。我们可以看到,Event 这个枚举包含了 Activity最主要的几个生命周期的方法。

下面我们继续看 LifeCycle是怎么监听生命周期的:

  public class ComponentActivity extends androidx.core.app.ComponentActivity implements
LifecycleOwner,
......{

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
......
// 注释 2, ComponentActivity注入 ReportFragment
ReportFragment.injectIfNeededIn(this);
......
}
......

@Override
public androidx.lifecycle.Lifecycle getLifecycle() {
return mLifecycleRegistry;
}
}

按理说可以在上面的 ComponentActivity的各个生命周期回调中调用观察者的对应方法,但我们可以看到 ComponentActivity 这个类里并没有这样做。而是在上面注释 2处将当前Activity对象注入 ReportFragment中,我们看看ReportFragment干了啥:

 public class ReportFragment extends Fragment {
private static final String REPORT_FRAGMENT_TAG = "androidx.lifecycle"
+ ".LifecycleDispatcher.report_fragment_tag";

public static void injectIfNeededIn(Activity activity) {
android.app.FragmentManager manager = activity.getFragmentManager();
if (manager.findFragmentByTag(REPORT_FRAGMENT_TAG) == null) {
// 注释 3, 创建ReportFragment 添加到 Activity,使得生命周期与之同步
manager.beginTransaction().add(new androidx.lifecycle.ReportFragment(), REPORT_FRAGMENT_TAG).commit();
manager.executePendingTransactions();
}
}
static androidx.lifecycle.ReportFragment get(Activity activity) {
return (androidx.lifecycle.ReportFragment) activity.getFragmentManager().findFragmentByTag(
REPORT_FRAGMENT_TAG);
}
// 分发生命周期回调事件
private void dispatchCreate(androidx.lifecycle.ReportFragment.ActivityInitializationListener listener) {
if (listener != null) {
listener.onCreate();
}
}
......
......
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
dispatchCreate(mProcessListener);
dispatch(androidx.lifecycle.Lifecycle.Event.ON_CREATE);
}

@Override
public void onStart() {
super.onStart();
// 注释 4, 开始调用观察者的生命周期
dispatchStart(mProcessListener);
dispatch(androidx.lifecycle.Lifecycle.Event.ON_START);
}
......
......
private void dispatch(androidx.lifecycle.Lifecycle.Event event) {
Activity activity = getActivity();
if (activity instanceof LifecycleRegistryOwner) {
((LifecycleRegistryOwner) activity).getLifecycle().handleLifecycleEvent(event);
return;
}

if (activity instanceof LifecycleOwner) {
androidx.lifecycle.Lifecycle lifecycle = ((LifecycleOwner) activity).getLifecycle();
if (lifecycle instanceof LifecycleRegistry) {
// 注释 5,生命周期事件分发
((LifecycleRegistry) lifecycle).handleLifecycleEvent(event);
}
}
}
}

上面注释 3处我们可以看到,这里创建了一个 Fragment,然后将 Fragment添加到 Activity中。这样的话,新创建的这个 Fragment和 Activity就可以同步生命周期。之后,在上面注释 4的地方,Fragment的各种生命周期的方法里就可以调用观察者(LifecycleObserver)的相关的生命周期方法了。

也就是说,ComponentActivity 创建了一个 ReportFragment ,并把生命周期回调的事务交给了Fragment。这sao操作是不是很熟悉?没错!我们之前分析过,Glide 也是这么管理生命周期的。

上面注释 5,我们再看看生命周期事件分发,看看观察者方法最终调用的地方:

  // LifecycleRegistry.java
static class ObserverWithState {
androidx.lifecycle.Lifecycle.State mState;
LifecycleEventObserver mLifecycleObserver;
ObserverWithState(LifecycleObserver observer, androidx.lifecycle.Lifecycle.State initialState) {
// 获取 ReflectiveGenericLifecycleObserver对象
mLifecycleObserver = Lifecycling.lifecycleEventObserver(observer);
mState = initialState;
}

void dispatchEvent(LifecycleOwner owner, androidx.lifecycle.Lifecycle.Event event) {
......
// 生命周期回调事件分发
mLifecycleObserver.onStateChanged(owner, event);
}
}

// Lifecycling.java
static LifecycleEventObserver lifecycleEventObserver(Object object) {
.....
// 返回 ReflectiveGenericLifecycleObserver对象
return new androidx.lifecycle.ReflectiveGenericLifecycleObserver(object);
}

// ReflectiveGenericLifecycleObserver.java
class ReflectiveGenericLifecycleObserver implements LifecycleEventObserver {
private final Object mWrapped;
private final ClassesInfoCache.CallbackInfo mInfo;
ReflectiveGenericLifecycleObserver(Object wrapped) {
mWrapped = wrapped;
mInfo = ClassesInfoCache.sInstance.getInfo(mWrapped.getClass());
}

@Override
public void onStateChanged(LifecycleOwner source, androidx.lifecycle.Lifecycle.Event event) {
// 生命周期回调
mInfo.invokeCallbacks(source, event, mWrapped);
}
}

// ClassesInfoCache.java
private ClassesInfoCache.CallbackInfo createInfo(Class klass, @Nullable Method[] declaredMethods) {
Class superclass = klass.getSuperclass();
for (Method method : methods) {
// 反射遍历观察者的各个方法,将带 @OnLifecycleEvent注解的方法保存在
// Map对象中,方便生命周期变化时调用
OnLifecycleEvent annotation = method.getAnnotation(OnLifecycleEvent.class);
if (annotation == null) { continue; }
Class[] params = method.getParameterTypes();
......
androidx.lifecycle.Lifecycle.Event event = annotation.value();
......
ClassesInfoCache.MethodReference methodReference = new ClassesInfoCache.MethodReference(callType, method);
verifyAndPutHandler(handlerToEvent, methodReference, event, klass);
mCallbackMap.put(klass, info);
mHasLifecycleMethods.put(klass, hasLifecycleMethods);
}
......
return info;
}
static class CallbackInfo {
final Map> mEventToHandlers;
final Map mHandlerToEvent;

CallbackInfo(Map handlerToEvent) {
mHandlerToEvent = handlerToEvent;
mEventToHandlers = new HashMap<>();
for (Map.Entry entry : handlerToEvent.entrySet()) {
androidx.lifecycle.Lifecycle.Event event = entry.getValue();
List methodReferences = mEventToHandlers.get(event);
if (methodReferences == null) {
methodReferences = new ArrayList<>();
mEventToHandlers.put(event, methodReferences);
}
methodReferences.add(entry.getKey());
}
}

@SuppressWarnings("ConstantConditions")
void invokeCallbacks(LifecycleOwner source, androidx.lifecycle.Lifecycle.Event event, Object target) {
invokeMethodsForEvent(mEventToHandlers.get(event), source, event, target);
invokeMethodsForEvent(mEventToHandlers.get(androidx.lifecycle.Lifecycle.Event.ON_ANY), source, event,
target);
}

private static void invokeMethodsForEvent(List handlers,
LifecycleOwner source, androidx.lifecycle.Lifecycle.Event event, Object mWrapped) {
if (handlers != null) {
for (int i = handlers.size() - 1; i >= 0; i--) {
// 调用 MethodReference的 invokeCallback方法
handlers.get(i).invokeCallback(source, event, mWrapped);
}
}
}
}

// ClassesInfoCache.MethodReference
static class MethodReference {
final int mCallType;
final Method mMethod;
.......
void invokeCallback(LifecycleOwner source, androidx.lifecycle.Lifecycle.Event event, Object target) {
//noinspection TryWithIdenticalCatches
try {
switch (mCallType) {
case CALL_TYPE_NO_ARG:
mMethod.invoke(target);
break;
case CALL_TYPE_PROVIDER:
mMethod.invoke(target, source);
break;
case CALL_TYPE_PROVIDER_WITH_EVENT:
mMethod.invoke(target, source, event);
break;
}
} catch (InvocationTargetException e) {
throw new RuntimeException("Failed to call observer method", e.getCause());
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}

时序图就不画了,大概写一下调用流程吧:

LifecycleRegistry.java

  • --> handleLifecycleEvent();
  • --> moveToState(next);
  • --> sync();
  • --> forwardPass(lifecycleOwner);
  • --> LifecycleRegistry.ObserverWithState --> dispatchEvent(owner, event);

ReflectiveGenericLifecycleObserver .java

  • --> onStateChanged()

ClassesInfoCache.java

  • --> CallbackInfo createInfo(); (反射将观察者带注解的方法保存)

MethodReference.java

  • -->invokeCallback(source, event, mWrapped);
  • --> mMethod.invoke(target); (反射调用观察者的生命周期的方法)

经过层层调用和包装,注册时最终会用反射将观察者带**@OnLifecycleEvent注解的方法保存在MethodReference中,并放入HashMap。当 Activity生命周期改变时,随着层层调用,最终保存在集合里的观察者LifecycleObserver**生命周期相关方法会被调用。

这波sao操作是不是又似曾相识?没错!EventBus也是这么管理订阅和发送事件的。


收起阅读 »

View的绘制流程 硬件渲染

负责硬件渲染的主体对象ThreadedRenderer在整个绘制流程中做了哪几个步骤。1.enableHardwareAcceleration 实例化ThreadedRenderer2.initialize 初始化3.updateSurface 更新Surfa...
继续阅读 »

负责硬件渲染的主体对象ThreadedRenderer在整个绘制流程中做了哪几个步骤。

  • 1.enableHardwareAcceleration 实例化ThreadedRenderer
  • 2.initialize 初始化
  • 3.updateSurface 更新Surface
  • 4.setup 启动ThreadedRenderer设置阴影等参数
  • 5.如果需要 执行invalidateRoot 判断是否需要从根部开始遍历查找无效的元素
  • 6.draw 开始硬件渲染进行View层级绘制
  • 7.updateDisplayListIfDirty 更新硬件渲染中的脏区
  • 8.destroy 销毁硬件渲染对象

在硬件渲染的过程中,有一个很核心的对象RenderNode,作为每一个View绘制的节点对象。

当每一次进行准备进行绘制的时候,都会雷打不动执行如下三个步骤:

  • 1.RenderNode.start 生成一个新的DisplayListCanvas
  • 2.DisplayListCanvas 上进行绘制 如调用Drawable的draw方法,把DisplayListCanvas作为参数
  • 3.RenderNode.end 完成RenderNode的操作

重要对象

  • 1.ThreadedRenderer 管理所有的硬件渲染对象,也是ViewRootImpl进行硬件渲染的入口对象。
  • 2.RenderNode 每一个View都会携带的对象,当打开了硬件渲染的时候,将会根据判断,把相关的渲染逻辑移动到RenderNode中。
  • 3.DisplayListCanvas 每一个RenderNode真正开始绘制自己的内容之前,需要通过RenderNode生成一个DisplayListCanvas,所有的绘制的行为都会在DisplayListCanvas中绘制,最后DisplayListCanvas会保存会RenderNode中。

在Java层中面向Framework中,只有这么多,下面是一一映射的简图。

image.png

能看到实际上RenderNode也会跟着View 树的构建同时一起构建整个显示层级。也是因此ThreadedRender也能以RenderNode为线索构建出一套和软件渲染一样的渲染流程。

让我继续介绍一下,在硬件渲染中native层的核心对象。

  • 1.RootRenderNode 所有RenderNode的根部RenderNode,一切的View层级结构遍历都从这个RenderNode开始。类似View中DecorView的职责。但是DecorView并非和RootRenderNode对应,而是拥有自己的RenderNode。
  • 2.RenderNode 对应于Java层的native对象
  • 3.RenderThread 硬件渲染线程,所有的渲染任务都会在该线程中使用硬件渲染线程的Looper进行。
  • 4.CanvasContext 是所有的渲染的上下文,它将持用PipeLine渲染管道
  • 5.PipeLine 如OpenGLPipeLine,SkiaOpenGLPipeLine,VulkanPipeLine渲染管道。而这个渲染管道将会根据Android系统的配置,执行真正的渲染行为
  • 6.DrawFrameTask 是整个ThreadedRender中真正开始执行渲染的对象
  • 7.RenderNodeProxy ThreadedRender的对应native层的入口。它将全局的作为RootRenderNode,CanvasContext,以及RenderThread门面(门面设计模式)。

如下是一个思维导图:

image.png

ThreadedRenderer 实例化

当发现mSurfaceHolder为空的时候会调用如下函数:

                if (mSurfaceHolder == null) {
enableHardwareAcceleration(attrs);
....
}

而这个方法则调用如下的方法对ThreadedRenderer进行创建:

 mAttachInfo.mThreadedRenderer = ThreadedRenderer.create(mContext, translucent,
attrs.getTitle().toString());
    public static boolean isAvailable() {
if (sSupportsOpenGL != null) {
return sSupportsOpenGL.booleanValue();
}
if (SystemProperties.getInt("ro.kernel.qemu", 0) == 0) {
sSupportsOpenGL = true;
return true;
}
int qemu_gles = SystemProperties.getInt("qemu.gles", -1);
if (qemu_gles == -1) {
return false;
}
sSupportsOpenGL = qemu_gles > 0;
return sSupportsOpenGL.booleanValue();
}


public static ThreadedRenderer create(Context context, boolean translucent, String name) {
ThreadedRenderer renderer = null;
if (isAvailable()) {
renderer = new ThreadedRenderer(context, translucent, name);
}
return renderer;
}

能不能创建的了ThreadedRenderer则决定于全局配置。如果ro.kernel.qemu的配置为0,说明支持OpenGL 则可以直接返回true。如果qemu.gles为-1说明不支持OpenGL es返回false,只能使用软件渲染。如果设置了qemu.gles并大于0,才能打开硬件渲染。

ThreadedRenderer构造函数

    ThreadedRenderer(Context context, boolean translucent, String name) {
...

long rootNodePtr = nCreateRootRenderNode();
mRootNode = RenderNode.adopt(rootNodePtr);
mRootNode.setClipToBounds(false);
mIsOpaque = !translucent;
mNativeProxy = nCreateProxy(translucent, rootNodePtr);
nSetName(mNativeProxy, name);

ProcessInitializer.sInstance.init(context, mNativeProxy);

loadSystemProperties();
}

我们能看到ThreadedRenderer在初始化,做了三件事情:

  • 1.nCreateRootRenderNode 创建native层的RootRenderNode,也就是所有RenderNode的根。类似DecorView的角色,是所有View的父布局,我们把整个View层次看成一个树,那么这里是根节点。
  • 2.RenderNode.adopt 根据native的 RootRenderNode创建Java层的根部RenderNode。
  • 3.nCreateProxy 创建RenderNode的代理者,nSetName给该代理者赋予名字。
  • 4.ProcessInitializer的初始化graphicsstats服务
  • 5.loadSystemProperties 读取系统给硬件渲染器设置的属性。

关键是看1-3点中ThreadRenderer都做了什么。

nCreateRootRenderNode

static jlong android_view_ThreadedRenderer_createRootRenderNode(JNIEnv* env, jobject clazz) {
RootRenderNode* node = new RootRenderNode(env);
node->incStrong(0);
node->setName("RootRenderNode");
return reinterpret_cast<jlong>(node);
}

能看到这里是直接实例化一个RootRenderNode对象,并把指针的地址直接返回。

class RootRenderNode : public RenderNode, ErrorHandler {
public:
explicit RootRenderNode(JNIEnv* env) : RenderNode() {
mLooper = Looper::getForThread();
env->GetJavaVM(&mVm);
}
}

能看到RootRenderNode继承了RenderNode对象,并且保存一个JavaVM也就是我们所说的Java虚拟机对象,一个java进程全局只有一个。同时通过getForThread方法,获取ThreadLocal中的Looper对象。这里实际上拿的就是UI线程的Looper。

native层RenderNode 的实例化
RenderNode::RenderNode()
: mDirtyPropertyFields(0)
, mNeedsDisplayListSync(false)
, mDisplayList(nullptr)
, mStagingDisplayList(nullptr)
, mAnimatorManager(*this)
, mParentCount(0) {}

在这个构造函数有一个mDisplayList十分重要,记住之后会频繁出现。接着来看看RenderNode的头文件:

class RenderNode : public VirtualLightRefBase {
friend class TestUtils; // allow TestUtils to access syncDisplayList / syncProperties
friend class FrameBuilder;


...

private:
...
} /* namespace uirenderer */
} /* namespace android */

实际上我把几个重要的对象留下来:

  • 1.mDisplayList 实际上就是RenderNode中持有的所有的子RenderNode对象
  • 2.mStagingDisplayList 这个一般是一个View遍历完后保存下来的DisplayList,之后会在绘制行为之前转化为mDisplayList
  • 3.RenderProperties mProperties 是指RenderNode的宽高等信息的存储对象
  • 4.OffscreenBuffer mProperties RenderNode真正的渲染内存对象。

RenderNode.adopt

    public static RenderNode adopt(long nativePtr) {
return new RenderNode(nativePtr);
}

能看到很简单,就是包裹一个native层的RenderNode返回一个Java层对应的对象开放Java层的操作API。

nCreateProxy

static jlong android_view_ThreadedRenderer_createProxy(JNIEnv* env, jobject clazz,
jboolean translucent, jlong rootRenderNodePtr) {
RootRenderNode* rootRenderNode = reinterpret_cast<RootRenderNode*>(rootRenderNodePtr);
ContextFactoryImpl factory(rootRenderNode);
return (jlong) new RenderProxy(translucent, rootRenderNode, &factory);
}

能看到这个过程生成了两个对象:

  • 1.ContextFactoryImpl 动画上下文工厂
class ContextFactoryImpl : public IContextFactory {
public:
explicit ContextFactoryImpl(RootRenderNode* rootNode) : mRootNode(rootNode) {}

virtual AnimationContext* createAnimationContext(renderthread::TimeLord& clock) {
return new AnimationContextBridge(clock, mRootNode);
}

private:
RootRenderNode* mRootNode;
};

这个对象实际上让RenderProxy持有一个创建动画上下文的工厂。RenderProxy可以通过ContextFactoryImpl为每一个RenderNode创建一个动画执行对象的上下文AnimationContextBridge。

  • 2.RenderProxy 一个 根RenderNode的代理对象。这个代理对象将作为所有绘制开始遍历入口。

RenderProxy 根RenderNode的代理对象的创建

RenderProxy::RenderProxy(bool translucent, RenderNode* rootRenderNode,
IContextFactory* contextFactory)
: mRenderThread(RenderThread::getInstance()), mContext(nullptr) {
mContext = mRenderThread.queue().runSync([&]() -> CanvasContext* {
return CanvasContext::create(mRenderThread, translucent, rootRenderNode, contextFactory);
});
mDrawFrameTask.setContext(&mRenderThread, mContext, rootRenderNode);
}
  • 1.RenderThread 硬件渲染线程,所有的硬件渲染命令都需要经过这个线程排队执行。初始化方法如下:
RenderThread::getInstance()
  • 2.CanvasContext 一个硬件Canvas的上下文,一般来说就在这个上下文决定了使用OpenGL es还是其他的渲染管道。初始化方法如下:
CanvasContext::create(mRenderThread, translucent, rootRenderNode, contextFactory);
  • 2.DrawFrameTask 每一帧绘制的任务对象。

我们依次看看他们初始化都做了什么。

RenderThread的初始化和运行机制

RenderThread& RenderThread::getInstance() {
static RenderThread* sInstance = new RenderThread();
gHasRenderThreadInstance = true;
return *sInstance;
}

能看到其实就是简单的调用RenderThread的构造函数进行实例化,并且返回对象的指针。

RenderThread是一个线程对象。先来看看其头文件继承的对象:

class RenderThread : private ThreadBase {
PREVENT_COPY_AND_ASSIGN(RenderThread);

public:
// Sets a callback that fires before any RenderThread setup has occured.
ANDROID_API static void setOnStartHook(void (*onStartHook)());

WorkQueue& queue() { return ThreadBase::queue(); }
...
}

其中RenderThread的中进行排队处理的任务队列实际上是来自ThreadBase的WorkQueue对象。

class ThreadBase : protected Thread {
PREVENT_COPY_AND_ASSIGN(ThreadBase);

public:
ThreadBase()
: Thread(false)
, mLooper(new Looper(false))
, mQueue([this]() { mLooper->wake(); }, mLock) {}

WorkQueue& queue() { return mQueue; }

void requestExit() {
Thread::requestExit();
mLooper->wake();
}

void start(const char* name = "ThreadBase") { Thread::run(name); }
...
}

ThreadBase则是继承于Thread对象。当调用start方法时候其实就是调用Thread的run方法启动线程。

另一个更加关键的对象,就是实例化一个Looper对象到WorkQueue中。而直接实例化Looper实际上就是新建一个Looper。但是这个Looper并没有获取当先线程的Looper,这个Looper做什么的呢?下文就会揭晓。

WorkQueue把一个Looper的方法指针设置到其中,其作用可能是完成了某一件任务后唤醒Looper继续工作。

RenderThread::RenderThread()
: ThreadBase()
, mVsyncSource(nullptr)
, mVsyncRequested(false)
, mFrameCallbackTaskPending(false)
, mRenderState(nullptr)
, mEglManager(nullptr)
, mVkManager(nullptr) {
Properties::load();
start("RenderThread");
}
  • 1.先从Properties读取一些全局配置,进行一些如debug的配置。
  • 2.start启动当前的线程

而start方法会启动Thread的run方法。而run方法最终会走到threadLoop方法中,至于是怎么走进来的,之后有机会会解剖虚拟机的源码线程篇章进行讲解。

RenderThread::threadLoop

bool RenderThread::threadLoop() {
setpriority(PRIO_PROCESS, 0, PRIORITY_DISPLAY);
if (gOnStartHook) {
gOnStartHook();
}
initThreadLocals();

while (true) {
waitForWork();
processQueue();

if (mPendingRegistrationFrameCallbacks.size() && !mFrameCallbackTaskPending) {
drainDisplayEventQueue();
mFrameCallbacks.insert(mPendingRegistrationFrameCallbacks.begin(),
mPendingRegistrationFrameCallbacks.end());
mPendingRegistrationFrameCallbacks.clear();
requestVsync();
}

if (!mFrameCallbackTaskPending && !mVsyncRequested && mFrameCallbacks.size()) {
requestVsync();
}
}

return false;
}

在threadloop中关键的步骤有如下四个:

  • 1.initThreadLocals 初始化线程本地变量
  • 2.waitForWork 等待RenderThread的渲染工作
  • 3.processQueue 执行保存在WorkQueue的渲染工作
  • 4.mPendingRegistrationFrameCallbacks大于0或者mFrameCallbacks大于0;并且mFrameCallbackTaskPending为false,则会调用requestVsync,打开SF进程的EventThread的阻塞让监听返回。mFrameCallbackTaskPending这个方法代表Vsync信号来了并且执行则mFrameCallbackTaskPending为true。
initThreadLocals
void RenderThread::initThreadLocals() {
mDisplayInfo = DeviceInfo::queryDisplayInfo();
nsecs_t frameIntervalNanos = static_cast<nsecs_t>(1000000000 / mDisplayInfo.fps);
mTimeLord.setFrameInterval(frameIntervalNanos);
initializeDisplayEventReceiver();
mEglManager = new EglManager(*this);
mRenderState = new RenderState(*this);
mVkManager = new VulkanManager(*this);
mCacheManager = new CacheManager(mDisplayInfo);
}

在这个过程中创建了几个核心对象:

  • 1.EglManager 当使用OpenGL 相关的管道的时候,将会通过EglManager对OpenGL进行上下文等操作。
  • 2.VulkanManager 当使用Vulkan 的渲染管道,将会使用VulkanManager进行操作(Vulkan 是新一代的3d硬件显卡渲染api,比起OpenGL更加轻量化,性能更佳)
  • 3.RenderState 渲染状态,内有OpenGL和Vulkan的管道,需要渲染的Layer等。

另一个核心的方法就是initializeDisplayEventReceiver,这个方法为WorkQueue的Looper注册了监听:

void RenderThread::initializeDisplayEventReceiver() {
LOG_ALWAYS_FATAL_IF(mVsyncSource, "Initializing a second DisplayEventReceiver?");

if (!Properties::isolatedProcess) {
auto receiver = std::make_unique<DisplayEventReceiver>();
status_t status = receiver->initCheck();

mLooper->addFd(receiver->getFd(), 0, Looper::EVENT_INPUT,
RenderThread::displayEventReceiverCallback, this);
mVsyncSource = new DisplayEventReceiverWrapper(std::move(receiver));
} else {
mVsyncSource = new DummyVsyncSource(this);
}
}

能看到在这个Looper中注册了对DisplayEventReceiver的监听,也就是Vsync信号的监听,回调方法为displayEventReceiverCallback。

我们暂时先对RenderThread的initializeDisplayEventReceiver方法探索到这里,我们稍后继续看看回调后的逻辑。

waitForWork 对Looper监听的对象进行阻塞等待
    void waitForWork() {
nsecs_t nextWakeup;
{
std::unique_lock lock{mLock};
nextWakeup = mQueue.nextWakeup(lock);
}
int timeout = -1;
if (nextWakeup < std::numeric_limits<nsecs_t>::max()) {
timeout = ns2ms(nextWakeup - WorkQueue::clock::now());
if (timeout < 0) timeout = 0;
}
int result = mLooper->pollOnce(timeout);
}

能看到这里的逻辑很简单实际上就是调用Looper的pollOnce方法,阻塞Looper中的循环,直到Vsync的信号到来才会继续往下执行。

processQueue
void processQueue() { mQueue.process(); }

实际上调用的是WorkQueue的process方法。

WorkQueue的process
    void process() {
auto now = clock::now();
std::vector<WorkItem> toProcess;
{
std::unique_lock _lock{mLock};
if (mWorkQueue.empty()) return;
toProcess = std::move(mWorkQueue);
auto moveBack = find_if(std::begin(toProcess), std::end(toProcess),
[&now](WorkItem& item) { return item.runAt > now; });
if (moveBack != std::end(toProcess)) {
mWorkQueue.reserve(std::distance(moveBack, std::end(toProcess)) + 5);
std::move(moveBack, std::end(toProcess), std::back_inserter(mWorkQueue));
toProcess.erase(moveBack, std::end(toProcess));
}
}
for (auto& item : toProcess) {
item.work();
}
}

能看到这个过程中很简单,几乎和Message的loop的逻辑一致。如果Looper的阻塞打开了,则首先找到预计执行时间比当前时刻都大的WorkItem。并且从mWorkQueue移除,最后添加到toProcess中,并且执行每一个WorkItem的work方法。而每一个WorkItem其实就是通过从某一个压入方法添加到mWorkQueue中。

到这里,我们就明白了RenderThread中是如何消费渲染任务的。那么这些渲染任务又是哪里诞生呢?

RenderThread 相应Vsync信号的回调

在RenderThread中的Looper会监听Vsync信号,当信号回调后将会执行下面的回调。

displayEventReceiverCallback

int RenderThread::displayEventReceiverCallback(int fd, int events, void* data) {
if (events & (Looper::EVENT_ERROR | Looper::EVENT_HANGUP)) {
return 0; // remove the callback
}

if (!(events & Looper::EVENT_INPUT)) {
return 1; // keep the callback
}

reinterpret_cast<RenderThread*>(data)->drainDisplayEventQueue();

return 1; // keep the callback
}

能看到这个方法的核心实际上就是调用drainDisplayEventQueue方法,对ui渲染任务队列进行处理。

RenderThread::drainDisplayEventQueue
void RenderThread::drainDisplayEventQueue() {
ATRACE_CALL();
nsecs_t vsyncEvent = mVsyncSource->latestVsyncEvent();
if (vsyncEvent > 0) {
mVsyncRequested = false;
if (mTimeLord.vsyncReceived(vsyncEvent) && !mFrameCallbackTaskPending) {
mFrameCallbackTaskPending = true;
nsecs_t runAt = (vsyncEvent + DISPATCH_FRAME_CALLBACKS_DELAY);
queue().postAt(runAt, [this]() { dispatchFrameCallbacks(); });
}
}
}

能到在这里mVsyncRequested设置为false,且mFrameCallbackTaskPending将会设置为true,并且调用queue的postAt的方法执行ui渲染方法。

实际上就是保存这三对象RenderThread;CanvasContext;RenderNode。

    template <class F>
auto runSync(F&& func) -> decltype(func()) {
std::packaged_task<decltype(func())()> task{std::forward<F>(func)};
post([&task]() { std::invoke(task); });
return task.get_future().get();
};

能看到这个方法实际上也是调用post执行排队执行任务,不同的是,这里使用了线程的Future方式,阻塞了执行,等待CanvasContext的setName工作完毕。


收起阅读 »

Android 中java多线程编程及注意事项

开启线程方式://方式1 public class MyThread extends Thread{ @Override public void run() { super.run(); //do my work...
继续阅读 »

开启线程方式:

//方式1
public class MyThread extends Thread{
@Override
public void run() {
super.run();
//do my work
}
}
new MyThread().start();

//方式2:
public class MyRunnable implements Runnable{
@Override
public void run() {
//do my work
}
}
new Thread(new MyRunnable()).start();

//方式3
class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "result";
}
}
FutureTask<String> futureTask = new FutureTask<>(new MyCallable());
new Thread(futureTask).start();

当然还有一些 HandlerThread、ThreadPool、AsyncTask 等也可以开启新线程,都是基于Thread的封装


线程状态:

image-20210829004506607.png

等待和阻塞的区别:简单理解,等待是线程判断条件不满足,主动调用指令进入的一种状态;而阻塞是被动进入,而且只有synchronized关键字修饰时可能触发。

还一种说法:将IO、sleep、synchronized等进入的线程block状态称为阻塞,他们的共同点是让出cpu,但不释放已经拿到的锁,参考:blog.csdn.net/weixin_3104…


线程安全

不共享数据:ThreadLocal

        ThreadLocal<String> threadLocal = new ThreadLocal<>();

Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
threadLocal.set("线程1数据");
//do some work ...

String data = threadLocal.get();
}
});

Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
threadLocal.set("线程2数据");
//do some work ...

String data = threadLocal.get();
}
});

原理:

没个线程里都有一个threadlocalmap数组, 调用threadlocalset方法时,以threadlocal变量hash值做key,将对于value存入数组中。 image-20210829011508921.png

共享数据:

1.jvm关键字 synchronized, 注:非公平、可重入

    private List<Integer> shareData = new ArrayList<>();

public void trySync(int data) {
synchronized (this) {//锁TestSyncData对象, thread1, thread2 ...
//操作共享数据
shareData.add(data);
}
//do something later ...
}

注意坑点:锁范围,存活周期不一样

    public void trySync1() {
synchronized (this) {//锁TestSyncData对象
}
}
//锁TestSyncData对象
public synchronized void trySync2() {
}

//锁TestSyncData类
public synchronized static void trySync3() {
}

//锁TestSyncData类
public void trySync4() {
synchronized (TestSyncData.class){
}
}

有耗时任务获取锁,后续收尾处理一定要考虑锁释放耗时问题;比如单例对象在主线程释放时,一定要注意锁能否及时拿到。

如果不能确定,考虑将锁降级存活时长,比如用栈内锁,线程安全型bean

synchronized原理详见:blog.csdn.net/weixin_3960…

2.wait-notify 函数,java祖先类Object的成员函数

    public void testWait() {
//1.创建同步对象
Object lock = new Object();

new Thread(new Runnable() {
@Override
public void run() {
//2. do something hard ...
shareData = new ShareData();

//3. 唤醒原线程
synchronized (lock){
lock.notify();
}
//5.some other logic
}
}).start();

//4.原线程等待
synchronized (lock){
try {
lock.wait(45000);
} catch (InterruptedException e) {
//6.线程中断触发
e.printStackTrace();
}
}

if (shareData != null) {
//do something happy ...
}
}

坑点多多:

  1. notify不能定向唤醒,只能随机唤醒一个wait的线程(保证notify的数量>=wait数量),使用的时候一定要保证没有多个线程处于wait状态;如果想定向唤醒,考虑使用 ReentrantLock的Condition

  2. 标记5的地方最好不要做其他逻辑,可能不会执行到,尽量保证notify是耗时任务里的最后逻辑

  3. 标记6的地方注意wait会被线程中断,而跳出同步逻辑,如果需要可以使用抗扰动写法:

            while (shareData == null) {//4 抗线程扰动写法
    synchronized (lock) {
    try {
    lock.wait();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    }
    }
  4. 业务销毁的时候,如果不需要等数据返回,可以直接notifyAll,提前结束线程任务,释放对象

3.基于aqs(队列同步器)接口的一系列同步锁或组件 ReentrantLock、Semaphore、CountDownLantch;通过队列+计数+CAS

    private ReentrantLock lock = new ReentrantLock();
private List<Integer> shareData = new ArrayList<>();

public void testLock() {//非标准
lock.lock();
shareData.add(1);
lock.unlock();
}

坑点:注意手动释放,以免死锁; 同步逻辑会有exception,标准最好用try-catch-finally


线程安全的数据类型:StringBuffer、CopyOnWriteArrayList、concurrentxxx、BlockingQueue、Atomicxxx(CAS)

有些基于锁,有些基于无锁化的CAS(compare and swap),有些两者混合

cas原理参考:cloud.tencent.com/developer/a…

image-20210829040630653.png

坑点:

  1. CopyOnWriteArrayList:先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加删除元素,添加删除完元素之后,再将原容器的引用指向新的容器,整个过程加锁,保证了写的线程安全。然而我们还是出现了多线程的数组越界异常
        //不安全写法
for (int i = 0; i < copyOnWriteArrayList.size(); i++) {
copyOnWriteArrayList.get(i);
}

//安全写法
Iterator iterator = copyOnWriteArrayList.iterator();
while (iterator.hasNext()){
iterator.next();
}
  1. Atomicxxx 注意使用场景

    ABA问题   AtomicStampedReference,AtomicMarkableReference
    开销问题
    只能保证一个共享变量的原子操作 AtomicReference

阻塞队列(BlockingQueue): 生产者与消费者模式里,生产者与消费者解耦,生产者与消费者性能均衡问题

BlockingQueue
ArrayBlockingQueue
数组结构的有界阻塞队列
LinkedBlockingQueue
链表结构的有界阻塞队列
PriorityBlockingQueue
优先级排序无界阻塞队列
DelayQueue
优先级队列无界阻塞队列
应用--过期缓存
SynchronousQueue
不存储元素的阻塞队列
应用--newCachedThreadPool
LinkedTransferQueue
链表结构无界阻塞队列
LinkedBlockingDeque
链表结构双向阻塞队列

有界:定义最大容量 无界:不定义


线程池:线程管理与统一调度

创建:

ThreadPoolExecutor(int corePoolSize,    //核心线程数
int maximumPoolSize, //最大线程数
long keepAliveTime, //空闲线程存活时间
TimeUnit unit,
BlockingQueue<Runnable> workQueue, //阻塞队列
ThreadFactory threadFactory, //线程池工厂
RejectedExecutionHandler handler) //拒绝策略

线程比较稀缺,需合理配置,根据任务特性

cpu密集型:内存中取数据计算 (最大线程数 <=Runtime.getRuntime().availableProcessors()+1) 注:+1 虚拟内存-页缺失

IO密集型:网络通信、读写磁盘 (最大线程数 <= cpu核心*2), 高阻塞低占用

混合型:以上两者,如果两者执行时间相当,拆分线程池;否则视权重大的分配。具体时长可以本地测试或者根据下面的经验表预估后选择 image-20210828234846390-0165731.png

执行:注意任务存放位置顺序 1、2、3、4(重点)

image-20210828225601263.png

拒绝策略(饱和策略):

RejectedExecutionHandler
AbortPolicy
直接抛出异常-默认
CallerRunsPolicy
调用者线程执行
DiscardOldestPolicy
丢弃旧的
DiscardPolicy
直接丢弃

关闭:

awaitTermination(long timeout, TimeUnit unit)//阻塞
shutDown()//中断没有执行任务的线程
shutdownNow()//中断所有线程,协作式处理,只是发出中断信号
isShutdown()//是否关闭
收起阅读 »

自定义View

判断自己有没有掌握这个知识点,就模拟面试,看看你能不能给对方讲清楚1. 坐标系在Android坐标系中,以屏幕左上角作为原点,这个原点向右是X轴的正轴,向下是Y轴正轴。如下所示:除了Android坐标系,还存在View坐标系,View坐标系内部关系如图所示。2...
继续阅读 »

判断自己有没有掌握这个知识点,就模拟面试,看看你能不能给对方讲清楚

1. 坐标系

在Android坐标系中,以屏幕左上角作为原点,这个原点向右是X轴的正轴,向下是Y轴正轴。如下所示:

image.png

除了Android坐标系,还存在View坐标系,View坐标系内部关系如图所示。

image.png

2. 自定义属性

Android系统的控件以android开头的都是系统自带的属性。为了方便配置自定义View的属性,我们也可以自定义属性值。
Android自定义属性可分为以下几步:

  1. 自定义一个View
  2. 编写values/attrs.xml,在其中编写styleable和item等标签元素
  3. 在布局文件中View使用自定义的属性(注意namespace)
  4. 在View的构造方法中通过TypedArray获取

自定义View属性很重要,但是并不复杂,需要的话再查一下就好了

3. View绘制流程

View的绘制基本由measure()、layout()、draw()这个三个函数完成

函数作用相关方法
measure()测量View的宽高measure(),setMeasuredDimension(),onMeasure()
layout()计算当前View以及子View的位置layout(),onLayout(),setFrame()
draw()视图的绘制工作draw(),onDraw()

3.1 MeasureSpec

MeasureSpec是View的内部类,它封装了一个View的尺寸,在onMeasure()当中会根据这个MeasureSpec的值来确定View的宽高。

MeasureSpec的值保存在一个int值当中。一个int值有32位,前两位表示模式mode后30位表示大小size。即MeasureSpecmodesize

MeasureSpec当中一共存在三种modeUNSPECIFIEDEXACTLY
AT_MOST

对于View来说,MeasureSpec的mode和Size有如下意义

模式意义对应
EXACTLY精准模式,View需要一个精确值,这个值即为MeasureSpec当中的Sizematch_parent
AT_MOST最大模式,View的尺寸有一个最大值,View不可以超过MeasureSpec当中的Size值wrap_content
UNSPECIFIED无限制,View对尺寸没有任何限制,View设置为多大就应当为多大一般系统内部使用

3.2 Layout()

layout()过程,对于View来说用来计算View的位置参数,对于ViewGroup来说,除了要测量自身位置,还需要测量子View的位置。

3.3 Draw()

draw流程也就是的View绘制到屏幕上的过程,整个流程的入口在Viewdraw()方法之中,而源码注释也写的很明白,整个过程可以分为6个步骤。

  1. 如果需要,绘制背景。
  2. 有过有必要,保存当前canvas。
  3. 绘制View的内容。
  4. 绘制子View。
  5. 如果有必要,绘制边缘、阴影等效果。
  6. 绘制装饰,如滚动条等等。

使用下方的流程图表示:

image.png

布局过程的自定义:

方式: 重写布局过程的相关方法\

1. 测量过程: onMeasure()
2. 布局过程: onLayout()
复制代码

具体:

1. 重写onMeasure()来修改已有的View的尺寸
2. 重写onMeasure()来全新计算自定义View的尺寸
3. 重写onMeasure()和onLayout()来全新计算自定义 ViewGroup 的内部布局
复制代码
public class SquareImageView extends AppCompatImageView {
private static final String TAG = "SquareImageView";

public SquareImageView(Context context) {
super(context);
}

public SquareImageView(Context context, AttributeSet attrs) {
super(context, attrs);
}



@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 先执行原测量算法
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

// 获取原先的测量结果
int measureWidth = getMeasuredWidth();
int measureHeight = getMeasuredHeight();
Log.d(TAG, "onMeasure11" +
", measureWidth = " + measureWidth +
", measureHeight = " + measureHeight +
"");

// 利用原先的测量结果计算出新尺寸
if (measureWidth > measureHeight) {
measureWidth = measureHeight;
} else {
measureHeight = measureWidth;
}
Log.d(TAG, "onMeasure22" +
", measureWidth = " + measureWidth +
", measureHeight = " + measureHeight +
"");
// 保存计算后的结果
setMeasuredDimension(measureWidth, measureHeight);
}
}
复制代码

重写onMeasure() 修改尺寸

1. 重写 onMeasure() 修改尺寸,并调用super.onMeasure触发原先的测量
2. 用getMeasuredWidth() 和 getMeasuredHeight() 取到之前测得的尺寸,利用这两个尺寸来计算出最终尺寸。
3. 使用 setMeasuredDimension() 保存尺寸
复制代码

收起阅读 »

你真的了解Handler吗?

Handler,一个面试中常问的高频词汇。大家想想这个知识点一般是怎么考察的?请解释一下Handler的原理?不不不,这个问题已经烂大街了,我要是面试官,我会这么问。我们知道在Handler中,存在一个方法叫 sendMessageDelay , 作用是延时发...
继续阅读 »

Handler,一个面试中常问的高频词汇。

大家想想这个知识点一般是怎么考察的?请解释一下Handler的原理?

不不不,这个问题已经烂大街了,我要是面试官,我会这么问。

我们知道在Handler中,存在一个方法叫 sendMessageDelay , 作用是延时发送消息,请解释一下Handler是如何实现延时发送消息的?

Looper.loop是一个死循环,拿不到需要处理的Message就会阻塞,那在UI线程中为什么不会导致ANR?

也请各位读者先自己思考一下这两个问题,换做是你该怎么回答。

Handler

我们先从Handler的定义来认识它,先上谷歌原文:

/**
* A Handler allows you to send and process {@link Message} and Runnable
*/

下面由我这枚英语渣上线,强行翻译一波。

  1. Handler是用来结合线程的消息队列来发送、处理Message对象Runnable对象的工具。每一个Handler实例化之后会关联一个线程和该线程的消息队列。当你创建一个Handler的时候,它就会自动绑定到到所在的线程或线程的消息队列,并陆续把Message/Runnable分发到消息队列,然后在它们出队的时候去执行。

  2. Handler主要有两个用途: (1) 调度在将来某个时候执行的MessageRunnable。(2)把需要在另一个线程执行的操作加入到消息队列中去。

  3. post runnablesend messagehandler时,您可以在消息队列准备就绪后立即处理该事务。也可以延迟一段时间执行,或者指定某个特定时间去执行。


我们先从Handler的构造方法来认识一下它:

public Handler(@NonNull Looper looper, @Nullable Callback callback, boolean async) 

Handler的构造方法有很多个,但最终调用的就是上述构造方法。

老规矩,先上官方解释,再上学渣翻译。

* Use the provided {@link Looper} instead of the default one and take a callback
* interface in which to handle messages. Also set whether the handler
* should be asynchronous.
*
* Handlers are synchronous by default unless this constructor is used to make
* one that is strictly asynchronous.
*
* Asynchronous messages represent interrupts or events that do not require global ordering
* with respect to synchronous messages. Asynchronous messages are not subject to
* the synchronization barriers introduced by conditions such as display vsync.
  1. 使用提供的Looper而不是默认的Looper,并使用回调接口来处理消息。还设置处理程序是否应该是异步的。

  2. 默认情况下,Handler是同步的,除非此构造函数用于生成严格异步的Handler

  3. 异步消息指的是不需要进行全局排序的中断或事件。异步消息不受同步障碍(比如display vsync)的影响。


Handler中的方法主要分为以下两类:

  1. 获取及查询消息,比如 obtainMessage(int what),hasMessages(int what)

  2. 将message或runnable添加/移出消息队列,比如 postAtTime(@NonNull Runnable r, long uptimeMillis),sendEmptyMessageDelayed(int what, long delayMillis)

在这些方法中,我们重点需要关注一下enqueueMessage这个方法。

为什么呢?

无论是 postAtTimesendMessageDelayed还是其他的post、send方法,它们最终都会调到enqueueMessage这个方法里去。

比如:

public final boolean sendMessageDelayed(@NonNull Message msg, long delayMillis) {
if (delayMillis < 0) {
delayMillis = 0;
}
return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}

可以看到,sendMessageDelayed方法里将延迟时间转换为消息触发的绝对时间,最终调用的是sendMessageAtTime方法。

public boolean sendMessageAtTime(@NonNull Message msg, long uptimeMillis) {
MessageQueue queue = mQueue;
if (queue == null) {
RuntimeException e = new RuntimeException(
this + " sendMessageAtTime() called with no mQueue");
Log.w("Looper", e.getMessage(), e);
return false;
}
return enqueueMessage(queue, msg, uptimeMillis);
}

而sendMessageAtTime方法调用了enqueueMessage方法。

private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
long uptimeMillis) {
msg.target = this;
msg.workSourceUid = ThreadLocalWorkSource.getUid();

if (mAsynchronous) {
msg.setAsynchronous(true);
}
return queue.enqueueMessage(msg, uptimeMillis);
}

enqueueMessage方法直接将message交给了MessageQueue去执行。

Message

在分析MessageQueue之前,我们应该先来认识一下Message这个消息载体类。

老规矩,先从定义看起:

* Defines a message containing a description and arbitrary data object that can be
* sent to a {@link Handler}. This object contains two extra int fields and an
* extra object field that allow you to not do allocations in many cases.
*
* <p>While the constructor of Message is public, the best way to get
* one of these is to call {@link #obtain Message.obtain()} or one of the
* {@link Handler#obtainMessage Handler.obtainMessage()} methods, which will pull
* them from a pool of recycled objects.</p>

下面是渣翻译:

  1. 定义一条包含描述和任意数据对象的消息,该对象可以发送到Handler。此对象包含两个额外的int字段和一个额外的object字段。

  2. 尽管Message的构造方法是public,但获取一个Message的最好的方法是调用Message.obtain或者Handler.obtainMessage方法,这些方法会从可回收的线程池中获取Message对象。


我们来认识一下Message里的字段:

public final class Message implements Parcelable {

}

在Message中,我们需要关注一下Message的回收机制。

先来看下recyclerUnchecked方法:

void recycleUnchecked() {
// Mark the message as in use while it remains in the recycled object pool.
// Clear out all other details.
flags = FLAG_IN_USE;
what = 0;
arg1 = 0;
arg2 = 0;
obj = null;
replyTo = null;
sendingUid = UID_NONE;
workSourceUid = UID_NONE;
when = 0;
target = null;
callback = null;
data = null;

synchronized (sPoolSync) {
if (sPoolSize < MAX_POOL_SIZE) {
next = sPool;
sPool = this;
sPoolSize++;
}
}
}

在这个方法中,有三个关键变量。

  1. sPoolSync :主要是给Message加一个对象锁,不允许多个线程同时访问Message类和recycleUnchecked方法。
  2. sPool:存储我们循环利用Message的单链表。这里sPool只是链表的头节点。
  3. sPoolSize:单链表的链表的长度,即存储的Message对象的个数。

当我们调用recycleUnchecked方法时,首先会将当前Message对象的属性清空。然后判断Message是否已到达缓存的上限(50个),如果没有,将当前的Message对象置于链表的头部。

那么取缓存的操作呢?

我们来看下obtain方法:

public static Message obtain() {
synchronized (sPoolSync) {
if (sPool != null) {
Message m = sPool;
sPool = m.next;
m.next = null;
m.flags = 0; // clear in-use flag
sPoolSize--;
return m;
}
}
return new Message();
}

可以看出,Message会尝试取出sPool链表的第一个元素,并将sPool的头元素往后移动一位。如果sPool链表为空,将会返回一个新的Message对象。

Message里提供obtain方法获取Message对象,使得Message到了重复的利用,减少了每次获取Message时去申请空间的时间。同时,这样也不会永无止境的去创建新对象,减小了Jvm垃圾回收的压力,提高了效率。

MessageQueue

MessageQueue用于保存由Looper发送的消息的列表。消息不会直接添加到消息队列,而是通过Handler对象中关联的Looper里的MessageQueue完成添加的动作。

您可以使用Looper.myQueue()检索当前线程的MessageQueue。

我们先来看看MessageQueue如何实现添加一个Message的操作。

boolean enqueueMessage(Message msg, long when) {
//判断msg是否有target属性以及是否正在使用中
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
if (msg.isInUse()) {
throw new IllegalStateException(msg + " This message is already in use.");
}

synchronized (this) {
if (mQuitting) {
IllegalStateException e = new IllegalStateException(
msg.target + " sending message to a Handler on a dead thread");
Log.w(TAG, e.getMessage(), e);
msg.recycle();
return false;
}


// We can assume mPtr != 0 because mQuitting is false.
if (needWake) {
//唤醒消息
nativeWake(mPtr);
}
}
return true;
}

mMessages是一个按照消息实际触发时间msg.when排序的链表,越往后的越晚触发。enqueueMessage方法根据新插入消息的when,将msg插入到链表中合适的位置。如果是及时消息,还需要唤醒MessageQueue

我们接着来看看nativeWake方法,nativeWake方法的源码位于\frameworks\base\core\jni\android_os_MessageQueue.cpp

static void android_os_MessageQueue_nativeWake(JNIEnv* env, jclass clazz, jlong ptr) {
NativeMessageQueue* nativeMessageQueue = reinterpret_cast<NativeMessageQueue*>(ptr);
nativeMessageQueue->wake();
}

继续看NativeMessageQueue里的wake函数。

void NativeMessageQueue::wake() {
mLooper->wake();
}

它又转交给了Looper(源码位置/system/core/libutils/Looper.cpp)去处理。

void Looper::wake() {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ wake", this);
#endif

uint64_t inc = 1;
ssize_t nWrite = TEMP_FAILURE_RETRY(write(mWakeEventFd.get(), &inc, sizeof(uint64_t)));
if (nWrite != sizeof(uint64_t)) {
if (errno != EAGAIN) {
LOG_ALWAYS_FATAL("Could not write wake signal to fd %d (returned %zd): %s",
mWakeEventFd.get(), nWrite, strerror(errno));
}
}
}

Looper里的wake函数很简单,它只是向mWakeEventFd里写入了一个 1 值。

上述的mWakeEventFd又是什么呢?

Looper::Looper(bool allowNonCallbacks)
: mAllowNonCallbacks(allowNonCallbacks),
mSendingMessage(false),
mPolling(false),
mEpollRebuildRequired(false),
mNextRequestSeq(0),
mResponseIndex(0),
mNextMessageUptime(LLONG_MAX) {

mWakeEventFd.reset(eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC));

...
}

从Looper的构造函数里可以找到答案,mWakeEventFd本质上是一个eventfd。至于什么是eventfd,这里只能说是eventfd是Linux 2.6提供的一种系统调用,它可以用来实现事件通知,更具体的内容需要各位读者自行查阅资料了。

既然有发送端,那么必然有接收端。接收端在哪呢?

void Looper::awoken() {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ awoken", this);
#endif

uint64_t counter;
TEMP_FAILURE_RETRY(read(mWakeEventFd.get(), &counter, sizeof(uint64_t)));
}

可以看到,awoken函数里的内容很简单,只是做了一个读取的动作,它并不关系读到的具体值是啥。为什么要这样设计呢,我们得结合awoken函数在哪里调用去分析。

awoken函数在LooperpollInner函数里调用。pollInner函数里有一条语句

int eventCount = epoll_wait(mEpollFd.get(), eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

它在这里起到阻塞的作用,如果没有调用nativeWake函数,epoll_wait将一直等待写入事件,直到超时为止。

如此,便回到我们文章一开始提出的问题了。

Looper.loop是一个死循环,拿不到需要处理的Message就会阻塞,那在UI线程中为什么不会导致ANR?

首先,我们需要明确一点,Handler中到底有没有阻塞?

答案是有!!!那它为什么不会导致ANR呢?

这得从ANR产生的原理说起。

ANR的本质也是一个Message,这一点很关键。我们拿前台服务的创建来举例,前台服务创建时,会发送一个 what值为ActivityManagerService.SERVICE_TIMEOUT_MSG的延时20s的Message,如果Service的创建 工作在上述消息的延时时间内完成,则会移除该消息,否则,在Handler正常收到这个消息后,就会进行服务超时处理,即弹出ANR对话框。

为什么不会ANR,现在各位读者清楚了吗?ANR消息本身就是通过Handler去派发的,Handler阻塞与否与ANR并没有必然关系。


我们看了MessageQueue是如何加入一条消息的,接下来,我们来看看它是如何取出一条消息的。

Message next() {
//如果消息循环已退出并已被释放,则return
//如果应用程序在退出后尝试重新启动looper,则可能发生这种情况
final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1; // -1 only during first iteration
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
//将当前线程中挂起的所有Binder命令刷新到内核驱动程序。
//在执行可能会阻塞很长时间的操作之前调用此函数非常有用,以确保已释放任何挂起的对象引用,
//从而防止进程保留对象的时间超过需要的时间。
Binder.flushPendingCommands();
}

// Run the idle handlers.
// We only ever reach this code block during the first iteration.
for (int i = 0; i < pendingIdleHandlerCount; i++) {
final IdleHandler idler = mPendingIdleHandlers[i];
mPendingIdleHandlers[i] = null; // release the reference to the handler

boolean keep = false;
try {
keep = idler.queueIdle();
} catch (Throwable t) {
Log.wtf(TAG, "IdleHandler threw exception", t);
}

if (!keep) {
synchronized (this) {
mIdleHandlers.remove(idler);
}
}
}

// Reset the idle handler count to 0 so we do not run them again.
pendingIdleHandlerCount = 0;

// While calling an idle handler, a new message could have been delivered
// so go back and look again for a pending message without waiting.
nextPollTimeoutMillis = 0;
}
}

next方法里主要做了三件事,(1)使用nativePollOnce阻塞指定时间,等待下一条消息的执行。 (2)获取下一条消息,并返回此消息。 (3)如果消息队列为空,则执行IdleHandler。

这里有个新名词IdleHandlerIdleHandler是可以在 Looper 事件循环的过程中,当出现空闲的时候,允许我们执行任务的一种机制。 MessageQueue中提供了addIdleHandlerremoveIdleHandler去添加删除IdleHandler


next方法的第一行有个ptr变量,这个ptr变量是什么含义呢?

MessageQueue(boolean quitAllowed) {
mQuitAllowed = quitAllowed;
mPtr = nativeInit();
}

mPtr是一个long型变量,它是在MessageQueue的构造方法中,通过nativeInit方法初始化的。

static jlong android_os_MessageQueue_nativeInit(JNIEnv* env, jclass clazz) {
NativeMessageQueue* nativeMessageQueue = new NativeMessageQueue();
if (!nativeMessageQueue) {
jniThrowRuntimeException(env, "Unable to allocate native queue");
return 0;
}

nativeMessageQueue->incStrong(env);
return reinterpret_cast<jlong>(nativeMessageQueue);
}

可以看到,ptr的本质是对 jni层的NativeMessageQueue对象的指针的引用。


我们重点来看下nativePollOnce方法,探寻一下Handler中的阻塞机制。nativePollOnce方法最终调用的是Looper.cpp中的pollOnce函数。

int Looper::pollOnce(int timeoutMillis, int* outFd, int* outEvents, void** outData) {
int result = 0;
for (;;) { //一个死循环
while (mResponseIndex < mResponses.size()) {
const Response& response = mResponses.itemAt(mResponseIndex++);
int ident = response.request.ident;
if (ident >= 0) {
int fd = response.request.fd;
int events = response.events;
void* data = response.request.data;
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ pollOnce - returning signalled identifier %d: "
"fd=%d, events=0x%x, data=%p",
this, ident, fd, events, data);
#endif
if (outFd != nullptr) *outFd = fd;
if (outEvents != nullptr) *outEvents = events;
if (outData != nullptr) *outData = data;
return ident;
}
}

if (result != 0) {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ pollOnce - returning result %d", this, result);
#endif
if (outFd != nullptr) *outFd = 0;
if (outEvents != nullptr) *outEvents = 0;
if (outData != nullptr) *outData = nullptr;
return result;
}

result = pollInner(timeoutMillis);
}
}

函数里有个关于mResponses的while循环,我们从java层调用的暂时不用管它,它是ndk的handler处理逻辑。我们重点来看pollInner函数。

int Looper::pollInner(int timeoutMillis) {
// 根据下一条消息的到期时间调整超时。
if (timeoutMillis != 0 && mNextMessageUptime != LLONG_MAX) {
nsecs_t now = systemTime(SYSTEM_TIME_MONOTONIC);
int messageTimeoutMillis = toMillisecondTimeoutDelay(now, mNextMessageUptime);
if (messageTimeoutMillis >= 0
&& (timeoutMillis < 0 || messageTimeoutMillis < timeoutMillis)) {
timeoutMillis = messageTimeoutMillis;
}
}

// 默认触发唤醒事件,POLL_WAKE == -1
int result = POLL_WAKE;
mResponses.clear();
mResponseIndex = 0;

// We are about to idle.
mPolling = true;

struct epoll_event eventItems[EPOLL_MAX_EVENTS];
//等待写入事件,写入事件由awoken函数触发。timeoutMillis为超时时间,0立即返回,-1一直等待
int eventCount = epoll_wait(mEpollFd.get(), eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

// No longer idling.
mPolling = false;

// Acquire lock.
mLock.lock();

...

// Check for poll error.
if (eventCount < 0) {
if (errno == EINTR) {
goto Done;
}
ALOGW("Poll failed with an unexpected error: %s", strerror(errno));
//POLL_ERROR == -4
result = POLL_ERROR;
goto Done;
}

// Check for poll timeout.
if (eventCount == 0) {
//POLL_TIMEOUT == -3,epoll超时会走此分支
result = POLL_TIMEOUT;
goto Done;
}

// Handle all events.
for (int i = 0; i < eventCount; i++) {
int fd = eventItems[i].data.fd;
uint32_t epollEvents = eventItems[i].events;
if (fd == mWakeEventFd.get()) {
if (epollEvents & EPOLLIN) {
//将eventfd里的数值取出,无实际含义,只是为了清空epoll事件和eventfd里的数据
awoken();
} else {
ALOGW("Ignoring unexpected epoll events 0x%x on wake event fd.", epollEvents);
}
} else {
//不会走到此分支,忽略它
ssize_t requestIndex = mRequests.indexOfKey(fd);
if (requestIndex >= 0) {
int events = 0;
if (epollEvents & EPOLLIN) events |= EVENT_INPUT;
if (epollEvents & EPOLLOUT) events |= EVENT_OUTPUT;
if (epollEvents & EPOLLERR) events |= EVENT_ERROR;
if (epollEvents & EPOLLHUP) events |= EVENT_HANGUP;
pushResponse(events, mRequests.valueAt(requestIndex));
} else {
ALOGW("Ignoring unexpected epoll events 0x%x on fd %d that is "
"no longer registered.", epollEvents, fd);
}
}
}
Done: ;

// 中间省略的代码不做探究,和ndk的handler实现有关
...
return result;
}

可以看到,pollInner函数主要的逻辑是使用epoll_wait去读取唤醒事件,它有一个最大的等待时长,其最大等待时长和下一条消息的触发时间有关。

需要注意一下pollInner的返回值result,它有三种状态。进入方法默认为POLL_WAKE,表示触发唤醒事件。 接下来通过对epoll_wait返回值的判断,它可能会变更为另两种状态。epoll_wait返回值为0,表示epoll_wait因超时而结束等待,result值设为POLL_TIMEOUT;epoll_wait返回值为-1,表示epoll_wait因系统中断等原因而结束等待,result值设为POLL_ERROR。但不管result值设为哪一个,都会导致pollOnce退出死循环,然代码流程回到java层的next方法中,去取得下一个Message对象。

因此,nativePollOnce简单意义上的理解,它就是一个阻断器,可以将当前线程阻塞,直到超时或者因需立即执行的新消息入队才结束阻塞。

各位读者,看到这里,大家再回过头去想想文章的第一个问题该怎么回答吧。

Looper

Handler 机制中,我们还剩最后一个一个模块没有分析———— Looper。我们先从官方定义来看起:

* Class used to run a message loop for a thread.  Threads by default do
* not have a message loop associated with them; to create one, call
* {@link #prepare} in the thread that is to run the loop, and then
* {@link #loop} to have it process messages until the loop is stopped.

概括一下:

Looper是一个用于在线程中循环遍历消息的类。默认情况下,线程没有与之关联的消息循环;如果要创建一个,请在运行Looper的线程中调用Looper.prepare(),然后使用Looper.loop()让它处理消息直到循环停止。

上面的定义提到了两个比较关键的方法,我们一个一个来看。

Looper.prepare()

private static void prepare(boolean quitAllowed) {
if (sThreadLocal.get() != null) {
throw new RuntimeException("Only one Looper may be created per thread");
}
sThreadLocal.set(new Looper(quitAllowed));
}

prepare的方法内容非常简单,创建一个Looper对象,并把它放到sThreadLocal里,其中sThreadLocal是一个ThreadLocal类。

ThreadLocal类又是什么呢?

多线程访问同一个共享变量的时候容易出现并发问题,特别是多个线程对一个变量进行写入的时候,为了保证线程安全,一般使用者在访问共享变量的时候需要进行额外的同步措施才能保证线程安全性。ThreadLocal是除了加锁这种同步方式之外的一种保证一种规避多线程访问出现线程不安全的方法,当我们在创建一个变量后,如果每个线程对其进行访问的时候访问的都是线程自己的变量,这样就不会存在线程不安全问题。

因此,使用ThreadLocal能够保证不同线程的Looper对象都有一个独立的副本,它们彼此独立,互不干扰。


Looper.looper()

public static void loop() {
//获取当前线程的Looper对象
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
//获取与Looper关联的messagequeue
final MessageQueue queue = me.mQueue;

// Make sure the identity of this thread is that of the local process,
// and keep track of what that identity token actually is.
Binder.clearCallingIdentity();
final long ident = Binder.clearCallingIdentity();

// Allow overriding a threshold with a system prop. e.g.
// adb shell 'setprop log.looper.1000.main.slow 1 && stop && start'
final int thresholdOverride =
SystemProperties.getInt("log.looper."
+ Process.myUid() + "."
+ Thread.currentThread().getName()
+ ".slow", 0);

boolean slowDeliveryDetected = false;

for (;;) {
//进入死循环,不断去从MessageQueue中去拉取Message
Message msg = queue.next(); // next方法我们已经在MessageQueue中做了分析
if (msg == null) {
// No message indicates that the message queue is quitting.
return;
}


// Make sure the observer won't change while processing a transaction.
final Observer observer = sObserver;

...

final long dispatchStart = needStartTime ? SystemClock.uptimeMillis() : 0;
final long dispatchEnd;
Object token = null;
if (observer != null) {
token = observer.messageDispatchStarting();
}
long origWorkSource = ThreadLocalWorkSource.setUid(msg.workSourceUid);
try {
//注意这里,msg.target是一个handler对象,这个方法最终调用了handler的dispatchMessage
//去做消息分发
msg.target.dispatchMessage(msg);
if (observer != null) {
observer.messageDispatched(token, msg);
}
dispatchEnd = needEndTime ? SystemClock.uptimeMillis() : 0;
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
if (traceTag != 0) {
Trace.traceEnd(traceTag);
}
}

//回收Message,上文中有做过分析
msg.recycleUnchecked();
}
}

loop方法主要的工作是:建立一个死循环,不断的通过调用MessageQueue中的next方法获取下一个消息,并最终通过取得的消息关联的handler去完成消息的分发。

总结

最后,我们再来理一理 HandlerMessageMessageQueueLooper四者的关系和职责。

  • Handler : 消息分发的管理者。负责获取消息、封装消息、派发消息以及处理消息。
  • Message :消息的载体类。
  • MessageQueue :消息的容器。负责按消息的触发时间对消息入队出队,以及在合适的时间唤醒或休眠消息队列。
  • Looper : 消息分发的执行者。负责从消息队列中拉去消息并交给handler去执行。

为了更好的理解它们的关系,拿现实生活中的场景来举个例子:

Handler是快递员,负责收快递,取快递,查快递以及退回快递。

Message是快递包裹,message的target属性就是收件地址,而延时消息就是收件人预约了派送时间,
希望在指定的时间上门派送。

MessageQueue是菜鸟驿站,要对快递进行整理并摆放在合适的位置。

Looper是一个24小时不休息的资本家,他总是不停的在看菜鸟驿站有没有需要派送的快递,一有快递就立马取
出然后压榨快递员去派送。

最后,我们用一张四者之间的流程图来结束整篇文章:

image.png

收起阅读 »

View的绘制流程 onDraw

performTravel的方法走完onMeasure和onLayout流程后会走到下面这段代码段。 if (mFirst) { if (sAlwaysAssignFocus || !isInTouchMode()) { ...
继续阅读 »


performTravel的方法走完onMeasure和onLayout流程后会走到下面这段代码段。

        if (mFirst) {
if (sAlwaysAssignFocus || !isInTouchMode()) {
if (mView != null) {
if (!mView.hasFocus()) {
mView.restoreDefaultFocus();
} else {
...
}
}
} else {

View focused = mView.findFocus();
if (focused instanceof ViewGroup
&& ((ViewGroup) focused).getDescendantFocusability()
== ViewGroup.FOCUS_AFTER_DESCENDANTS) {
focused.restoreDefaultFocus();
}
}
}

final boolean changedVisibility = (viewVisibilityChanged || mFirst) && isViewVisible;
final boolean hasWindowFocus = mAttachInfo.mHasWindowFocus && isViewVisible;
final boolean regainedFocus = hasWindowFocus && mLostWindowFocus;
if (regainedFocus) {
mLostWindowFocus = false;
} else if (!hasWindowFocus && mHadWindowFocus) {
mLostWindowFocus = true;
}

if (changedVisibility || regainedFocus) {

boolean isToast = (mWindowAttributes == null) ? false
: (mWindowAttributes.type == WindowManager.LayoutParams.TYPE_TOAST);
...
}

mFirst = false;
mWillDrawSoon = false;
mNewSurfaceNeeded = false;
mActivityRelaunched = false;
mViewVisibility = viewVisibility;
mHadWindowFocus = hasWindowFocus;

if (hasWindowFocus && !isInLocalFocusMode()) {
final boolean imTarget = WindowManager.LayoutParams
.mayUseInputMethod(mWindowAttributes.flags);
if (imTarget != mLastWasImTarget) {
mLastWasImTarget = imTarget;
InputMethodManager imm = InputMethodManager.peekInstance();
if (imm != null && imTarget) {
imm.onPreWindowFocus(mView, hasWindowFocus);
imm.onPostWindowFocus(mView, mView.findFocus(),
mWindowAttributes.softInputMode,
!mHasHadWindowFocus, mWindowAttributes.flags);
}
}
}

在进入onDraw的流程之前,会先处理焦点。这个过程中可以分为2大步骤:

  • 1.如果是第一次渲染,则说明之前的宽高都是都为0.在requestFocus方法中会有这个判断把整个焦点集中拦截下来:
    private boolean canTakeFocus() {
return ((mViewFlags & VISIBILITY_MASK) == VISIBLE)
&& ((mViewFlags & FOCUSABLE) == FOCUSABLE)
&& ((mViewFlags & ENABLED_MASK) == ENABLED)
&& (sCanFocusZeroSized || !isLayoutValid() || hasSize());
}

而在每一次onMeasure之前,都会尝试集中一次焦点的遍历。其中requestFocusNoSearch方法中,如果没有测量过就会直接返回false。因为每一次更换焦点或者集中焦点都可能伴随着如背景drawable,statelistDrawable等切换。没有测量过就没有必要做这无用功。

因此此时为了弥补之前拒绝焦点的行为,会重新进行一次restoreDefaultFocus的行为进行requestFocus处理。

  • 2.如果存在窗体焦点,同时不是打开了FLAG_LOCAL_FOCUS_MODE标志(这是一种特殊情况,一般打上这个标志位只有在startingWindow的快照中才会有。

则会调用InputMethodManager的onPostWindowFocus方法启动带了android.view.InputMethod这个action的软键盘服务。

onDraw流程

        if ((relayoutResult & WindowManagerGlobal.RELAYOUT_RES_FIRST_TIME) != 0) {
reportNextDraw();
}

boolean cancelDraw = mAttachInfo.mTreeObserver.dispatchOnPreDraw() || !isViewVisible;

if (!cancelDraw && !newSurface) {
if (mPendingTransitions != null && mPendingTransitions.size() > 0) {
for (int i = 0; i < mPendingTransitions.size(); ++i) {
mPendingTransitions.get(i).startChangingAnimations();
}
mPendingTransitions.clear();
}

performDraw();
} else {
if (isViewVisible) {
scheduleTraversals();
} else if (mPendingTransitions != null && mPendingTransitions.size() > 0) {
for (int i = 0; i < mPendingTransitions.size(); ++i) {
mPendingTransitions.get(i).endChangingAnimations();
}
mPendingTransitions.clear();
}
}

mIsInTraversal = false;
  • 1.判断到如果是第一次调用draw方法,则会调用reportNextDraw。
    private void reportNextDraw() {
if (mReportNextDraw == false) {
drawPending();
}
mReportNextDraw = true;
}

void drawPending() {
mDrawsNeededToReport++;
}

能看到实际上就是设置mReportNextDraw为true。我们回顾一下前两个流程mReportNextDraw参与了标志位的判断。在执行onMeasure和onLayout有两个大前提,一个是mStop为false,一个是mReportNextDraw为true。只要满足其一就会执行。

这么做的目的只有一个,保证调用一次onDraw方法。为什么会这样呢?performDraw是整个Draw流程的入口。然而在这个入口,必须要保证cancelDraw为false以及newSurface为false。

注意,如果是第一次渲染因为会添加进新的Surface,此时newSurface为true。所以会走到下面的分之,如果串口可见则调用scheduleTraversals执行下一次Loop的绘制流程。否则判断是否有需要执行的LayoutTransitions layout动画就执行了。

因此第一次是不会走到onDraw,是从第二次Looper之后View的绘制流程才会执行onDraw。

ViewRootImpl performDraw

    private void performDraw() {
if (mAttachInfo.mDisplayState == Display.STATE_OFF && !mReportNextDraw) {
return;
} else if (mView == null) {
return;
}

final boolean fullRedrawNeeded = mFullRedrawNeeded || mReportNextDraw;
mFullRedrawNeeded = false;

mIsDrawing = true;
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "draw");

boolean usingAsyncReport = false;
if (mReportNextDraw && mAttachInfo.mThreadedRenderer != null
&& mAttachInfo.mThreadedRenderer.isEnabled()) {
usingAsyncReport = true;
mAttachInfo.mThreadedRenderer.setFrameCompleteCallback((long frameNr) -> {
pendingDrawFinished();
});
}

try {
boolean canUseAsync = draw(fullRedrawNeeded);
if (usingAsyncReport && !canUseAsync) {
mAttachInfo.mThreadedRenderer.setFrameCompleteCallback(null);
usingAsyncReport = false;
}
} finally {
mIsDrawing = false;
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}

if (mAttachInfo.mPendingAnimatingRenderNodes != null) {
final int count = mAttachInfo.mPendingAnimatingRenderNodes.size();
for (int i = 0; i < count; i++) {
mAttachInfo.mPendingAnimatingRenderNodes.get(i).endAllAnimators();
}
mAttachInfo.mPendingAnimatingRenderNodes.clear();
}

if (mReportNextDraw) {
mReportNextDraw = false;

if (mWindowDrawCountDown != null) {
try {
mWindowDrawCountDown.await();
} catch (InterruptedException e) {
Log.e(mTag, "Window redraw count down interrupted!");
}
mWindowDrawCountDown = null;
}

if (mAttachInfo.mThreadedRenderer != null) {
mAttachInfo.mThreadedRenderer.setStopped(mStopped);
}

if (mSurfaceHolder != null && mSurface.isValid()) {
SurfaceCallbackHelper sch = new SurfaceCallbackHelper(this::postDrawFinished);
SurfaceHolder.Callback callbacks[] = mSurfaceHolder.getCallbacks();

sch.dispatchSurfaceRedrawNeededAsync(mSurfaceHolder, callbacks);
} else if (!usingAsyncReport) {
if (mAttachInfo.mThreadedRenderer != null) {
mAttachInfo.mThreadedRenderer.fence();
}
pendingDrawFinished();
}
}
}

我们把整个流程抽象出来实际上就是可以分为如下几个步骤:
对于软件渲染:

  • 1.调用draw方法,遍历View的层级。
  • 2.如果Surface是生效的,则在SurfaceHolder.Callback的surfaceRedrawNeededAsync回调中调用pendingDrawFinished。
  • 3.如果是强制同步渲染,则会直接调用pendingDrawFinished。

对于硬件渲染:

  • 1.调用draw方法,遍历View的层级。
  • 2.通过监听mThreadedRenderer的setFrameCompleteCallback回调执行pendingDrawFinished方法。

我们先关注软件渲染的流程。也就是draw和pendingDrawFinished。

ViewRootImpl draw

    private boolean draw(boolean fullRedrawNeeded) {
Surface surface = mSurface;
if (!surface.isValid()) {
return false;
}

if (!sFirstDrawComplete) {
synchronized (sFirstDrawHandlers) {
sFirstDrawComplete = true;
final int count = sFirstDrawHandlers.size();
for (int i = 0; i< count; i++) {
mHandler.post(sFirstDrawHandlers.get(i));
}
}
}

scrollToRectOrFocus(null, false);

if (mAttachInfo.mViewScrollChanged) {
mAttachInfo.mViewScrollChanged = false;
mAttachInfo.mTreeObserver.dispatchOnScrollChanged();
}

boolean animating = mScroller != null && mScroller.computeScrollOffset();
final int curScrollY;
if (animating) {
curScrollY = mScroller.getCurrY();
} else {
curScrollY = mScrollY;
}
if (mCurScrollY != curScrollY) {
mCurScrollY = curScrollY;
fullRedrawNeeded = true;
if (mView instanceof RootViewSurfaceTaker) {
((RootViewSurfaceTaker) mView).onRootViewScrollYChanged(mCurScrollY);
}
}

final float appScale = mAttachInfo.mApplicationScale;
final boolean scalingRequired = mAttachInfo.mScalingRequired;

final Rect dirty = mDirty;
if (mSurfaceHolder != null) {
dirty.setEmpty();
if (animating && mScroller != null) {
mScroller.abortAnimation();
}
return false;
}

if (fullRedrawNeeded) {
mAttachInfo.mIgnoreDirtyState = true;
dirty.set(0, 0, (int) (mWidth * appScale + 0.5f), (int) (mHeight * appScale + 0.5f));
}


mAttachInfo.mTreeObserver.dispatchOnDraw();

int xOffset = -mCanvasOffsetX;
int yOffset = -mCanvasOffsetY + curScrollY;
final WindowManager.LayoutParams params = mWindowAttributes;
final Rect surfaceInsets = params != null ? params.surfaceInsets : null;
if (surfaceInsets != null) {
xOffset -= surfaceInsets.left;
yOffset -= surfaceInsets.top;

dirty.offset(surfaceInsets.left, surfaceInsets.right);
}

...

mAttachInfo.mDrawingTime =
mChoreographer.getFrameTimeNanos() / TimeUtils.NANOS_PER_MS;

boolean useAsyncReport = false;
if (!dirty.isEmpty() || mIsAnimating || accessibilityFocusDirty) {
if (mAttachInfo.mThreadedRenderer != null && mAttachInfo.mThreadedRenderer.isEnabled()) {
boolean invalidateRoot = accessibilityFocusDirty || mInvalidateRootRequested;
mInvalidateRootRequested = false;

mIsAnimating = false;

if (mHardwareYOffset != yOffset || mHardwareXOffset != xOffset) {
mHardwareYOffset = yOffset;
mHardwareXOffset = xOffset;
invalidateRoot = true;
}

if (invalidateRoot) {
mAttachInfo.mThreadedRenderer.invalidateRoot();
}

dirty.setEmpty();

final boolean updated = updateContentDrawBounds();

if (mReportNextDraw) {
mAttachInfo.mThreadedRenderer.setStopped(false);
}

if (updated) {
requestDrawWindow();
}

useAsyncReport = true;

final FrameDrawingCallback callback = mNextRtFrameCallback;
mNextRtFrameCallback = null;
mAttachInfo.mThreadedRenderer.draw(mView, mAttachInfo, this, callback);
} else {

if (mAttachInfo.mThreadedRenderer != null &&
!mAttachInfo.mThreadedRenderer.isEnabled() &&
mAttachInfo.mThreadedRenderer.isRequested() &&
mSurface.isValid()) {

try {
mAttachInfo.mThreadedRenderer.initializeIfNeeded(
mWidth, mHeight, mAttachInfo, mSurface, surfaceInsets);
} catch (OutOfResourcesException e) {
handleOutOfResourcesException(e);
return false;
}

mFullRedrawNeeded = true;
scheduleTraversals();
return false;
}

if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset,
scalingRequired, dirty, surfaceInsets)) {
return false;
}
}
}

if (animating) {
mFullRedrawNeeded = true;
scheduleTraversals();
}
return useAsyncReport;
}

大致上完成了如下流程:

  • 1.如果surface无效则直接返回
    1. sFirstDrawHandlers这个存储着runnable静态对象。实际上是在ActivityThread启动后调用attach方法通过addFirstDrawHandler添加进来的目的只是为了启动jit模式。
  • 3.scrollToRectOrFocus 处理滑动区域或者焦点区域。如果发生了滑动则回调TreeObserver.dispatchOnScrollChanged。接下来则通过全局的mScroller通过computeScrollOffset判断是否需要滑动动画。如果需要执行动画,则调用DeocView的onRootViewScrollYChanged,进行Y轴上的动画执行。
  • 4.通过ViewTreeObserver的dispatchOnDraw开始分发draw开始绘制的监听者。
  • 5.判断是否存在surface面上偏移量,有就矫正一次脏区,把偏移量添加上去。

接下来则会进入到硬件渲染和软件渲染的分支。但是进一步进行调用draw的流程有几个前提条件:脏区不为空,需要执行动画,辅助服务发生了焦点变化

  • 6.如果ThreadedRenderer不为空且可用。ThreadedRenderer通过onPreDraw回调到ViewRootImpl,更新mHardwareYOffset,mHardwareXOffset。如果这两个参数发生了变化,则说明整个发生了硬件绘制的区域变化,需要从头遍历一次所有的区域设置为无效区域,mThreadedRenderer.invalidateRoot。

最后调用ThreadedRenderer.draw方法执行硬件渲染绘制。并且设置通过registerRtFrameCallback设置进来的callback设置到ThreadedRenderer中。

  • 7.如果此时ThreadedRenderer不可用但是不为空,说明此时需要对ThreadedRenderer进行初始化,调用scheduleTraversals在下一轮的绘制流程中才进行硬件渲染。
  • 8.如果以上情况都不满足,说明是软件渲染,则调用drawSoftware进行软件渲染。
  • 9.如果不许要draw方法遍历全局的View树,则判断是否需要执行滑动动画,需要则调用scheduleTraversals进入下一轮的绘制。

本文先抛开硬件渲染,来看看软件渲染drawSoftware中做了什么。还有scrollToRectOrFocus滑动中做了什么?

ViewRootImpl scrollToRectOrFocus

    boolean scrollToRectOrFocus(Rect rectangle, boolean immediate) {
final Rect ci = mAttachInfo.mContentInsets;
final Rect vi = mAttachInfo.mVisibleInsets;
int scrollY = 0;
boolean handled = false;

if (vi.left > ci.left || vi.top > ci.top
|| vi.right > ci.right || vi.bottom > ci.bottom) {

final View focus = mView.findFocus();
if (focus == null) {
return false;
}
View lastScrolledFocus = (mLastScrolledFocus != null) ? mLastScrolledFocus.get() : null;
if (focus != lastScrolledFocus) {

rectangle = null;
}

if (focus == lastScrolledFocus && !mScrollMayChange && rectangle == null) {

} else {
mLastScrolledFocus = new WeakReference<View>(focus);
mScrollMayChange = false;
if (focus.getGlobalVisibleRect(mVisRect, null)) {
if (rectangle == null) {
focus.getFocusedRect(mTempRect);
if (mView instanceof ViewGroup) {
((ViewGroup) mView).offsetDescendantRectToMyCoords(
focus, mTempRect);
}
} else {
mTempRect.set(rectangle);
}
if (mTempRect.intersect(mVisRect)) {
if (mTempRect.height() >
(mView.getHeight()-vi.top-vi.bottom)) {
}
else if (mTempRect.top < vi.top) {
scrollY = mTempRect.top - vi.top;
} else if (mTempRect.bottom > (mView.getHeight()-vi.bottom)) {
scrollY = mTempRect.bottom - (mView.getHeight()-vi.bottom);
} else {
scrollY = 0;
}
handled = true;
}
}
}
}

if (scrollY != mScrollY) {
if (!immediate) {
if (mScroller == null) {
mScroller = new Scroller(mView.getContext());
}
mScroller.startScroll(0, mScrollY, 0, scrollY-mScrollY);
} else if (mScroller != null) {
mScroller.abortAnimation();
}
mScrollY = scrollY;
}

return handled;
}

能看到在这个过程中实际上就是处理两个区域mVisibleInsets可见区域以及mContentInsets内容区域。

实际上这个过程就是从根部节点开始寻找焦点,然后整个画面定格在焦点处。因为mVisibleInsets一般是屏幕中出去过扫描区的大小,但是内容区域就不一定了,可能内容会超出屏幕大小,因此会通过mScroller滑动定位。

计算原理如下,分为2个情况:

  • 1.可视区域的顶部比起获得了焦点的view的顶部要低,说明这个view在屏幕外了,需要向下滑动:

scrollY = mTempRect.top - vi.top;

  • 2.如果焦点view的底部比起可视区域要比可视区域的低,说明需要向上滑动,注意滑动之后需要展示view,因此滑动的距离要减去view的高度:

scrollY = mTempRect.bottom - (mView.getHeight()-vi.bottom);
稍微变一下如下:
scrollY = mTempRect.bottom +vi.bottom - mView.getHeight();


收起阅读 »

如何用Rust做AndroidUI渲染

大力智能客户端团队 西豢沝尔 背景 Rust优秀的安全性、媲美C++的性能以及对跨平台编译和外部语言(ffi)的支持使得其成为高性能跨平台库的上佳实现语言。然而,Rust是否可以在逻辑层之上进一步服务于一些通用性的UI渲染?我们大力智能客户端团队针对开...
继续阅读 »

大力智能客户端团队 西豢沝尔



背景


Rust优秀的安全性、媲美C++的性能以及对跨平台编译和外部语言(ffi)的支持使得其成为高性能跨平台库的上佳实现语言。然而,Rust是否可以在逻辑层之上进一步服务于一些通用性的UI渲染?我们大力智能客户端团队针对开源项目rust-windowing( github.com/rust-window… )中几个核心工程进行剖析,并结合在Android系统的接入对此进行探索。


未命名.gif


Rust UI渲染:


Android系统上使用Rust渲染核心围绕ANativeWindow类展开,ANativeWindow位于android ndk中,是egl跨平台EGLNativeWindowType窗口类型在Android架构下的特定实现,因而基于ANativeWindow就可以创建一个EglSurface并通过GLES进行绘制和渲染。另一方面,ANativeWindow可以简单地与Java层的Surface相对应,因而将Android层需要绘制的目标转换为ANativeWindow是使用Rust渲染的关键,这一部分可以通过JNI完成。首先,我们先看一下rust-windowing对UI渲染的支持。


1 软件绘制:


在rust-windowing项目中,android-ndk-rs提供了rust与android ndk之间的胶水层,其中与UI渲染最相关的就是NativeWindow类,NativeWindow在Rust上下文实现了对ANativeWindow的封装,支持通过ffi对ANativeWindow进行操作,达到与在java层使用lockCanvas()和unlockCanvasAndPost()进行绘制相同的效果,基于这些api,我们可以实现在(A)NativeWindow上的指定区域绘制一个长方形:


unsafe fn draw_rect_on_window(nativewindow: &NativeWindow, colors: Vec<u8>, rect: ndk_glue::Rect) {
let height = nativewindow.height();
let width = nativewindow.width();
let color_format = get_color_format();
let format = color_format.0;
let bbp = color_format.1;
nativewindow.set_buffers_geometry(width, height, format);
nativewindow.acquire();
let mut buffer = NativeWindow::generate_epmty_buffer(width, height, width, format);
let locked = nativewindow.lock(&mut buffer, &mut NativeWindow::generate_empty_rect(0, 0, width, height));
if locked < 0 {
nativewindow.release();
return;
}

draw_rect_into_buffer(buffer.bits, colors, rect, width, height);
let result = nativewindow.unlock_and_post();
nativewindow.release();
}

unsafe fn draw_rect_into_buffer(bits: *mut ::std::os::raw::c_void, colors: Vec<u8>, rect: ndk_glue::Rect, window_width: i32, window_height: i32) {
let bbp = colors.len() as u32;
let window_width = window_width as u32;
for i in rect.top+1..=rect.bottom {
for j in rect.left+1..=rect.right {
let cur = (j + (i-1) * window_width - 1) * bbp;
for k in 0..bbp {
*(bits.offset((cur + (k as u32)) as isize) as *mut u8) = colors[k as usize];
}
}
}
}

这样就通过提交一个纯色像素填充的Buffer在指定的位置成功渲染出了一个长方形,不过这种方式本质上是软件绘制,性能欠佳,更好的方式是通过在Rust层封装GL在ANativeWindow上使能硬件绘制。


2 硬件绘制:


2.1 跨平台窗口系统:winit


2.1.1 Window:窗口

窗口系统最主要的目的是提供平台无关的Window抽象,提供一系列通用的基础方法、属性方法、游标相关方法、监控方法。winit以Window类抽象窗口类型并持有平台相关的window实现,通过WindowId唯一识别一个Window用于匹配后续产生的所有窗口事件WindowEvent,最后通过建造者模式对外暴露实例化的能力,支持在Rust侧设置一些平台无关的参数(大小、位置、标题、是否可见等)以及平台相关的特定参数,基本结构如下:


// src/window.rs
pub struct Window {
pub(crate) window: platform_impl::Window,
}

impl Window {
#[inline]
pub fn request_redraw(&self) {
self.window.request_redraw()
}

pub fn inner_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError> {
self.window.inner_position()
}

pub fn current_monitor(&self) -> Option<MonitorHandle> {
self.window.current_monitor()
}
}

pub struct WindowId(pub(crate) platform_impl::WindowId);

pub struct WindowBuilder {
/// The attributes to use to create the window.
pub window: WindowAttributes,

// Platform-specific configuration.
pub(crate) platform_specific: platform_impl::PlatformSpecificWindowBuilderAttributes,
}

impl WindowBuilder {
#[inline]
pub fn build<T: 'static>(
self,
window_target: &EventLoopWindowTarget<T>,
) -> Result<Window, OsError> {
platform_impl::Window::new(&window_target.p, self.window, self.platform_specific).map(
|window| {
window.request_redraw();
Window { window }
},
)
}
}

在Android平台,winit暂时不支持使用给定的属性构建一个“Window”,大部分方法给出了空实现或者直接panic,仅保留了一些事件循环相关的能力,真正的窗口实现仍然从android-ndk-rs胶水层获得:当前的android-ndk-rs仅针对ANativeActivity进行了适配,通过属性宏代理了unsafe extern "C" fn ANativeActivity_onCreate(...)方法,在获得ANativeActivity指针*activity后,注入自定义的生命周期回调,在onNativeWindowCreated回调中获得ANativeWindow(封装为NativeWindow)作为当前上下文活跃的窗口。当然,android-ndk-rs的能力也支持我们在任意一个ANativeWindow上生成对应的上层窗口。


2.1.2 EventLoop:事件循环 - 上层

事件循环是整个窗口系统行为的驱动,统一响应抛出的系统任务和用户交互并将反馈渲染到窗口上形成闭环,当你需要合理地触发渲染时,最好的方式就是将指令发送给事件循环。winit中,将事件循环封装为EventLoop,使用ControlFlow控制EventLoop如何获取、消费循环中的事件,并对外提供一个EventLoopProxy代理用于作为与用户交互的媒介,支持用户通过proxy向EventLoop发送用户自定义的事件:



Android平台的事件循环建立在ALooper之上,通过android-ndk-rs提供的胶水层注入的回调处理生命周期行为和窗口行为,通过代理InputQueue处理用户手势,同时支持响应用户自定义事件和内部事件。一次典型的循环根据当前first_event的类型分发处理,一次处理一个主要事件;当first_event处理完成后,触发一次MainEventsCleared事件回调给业务方,并判断是否需要触发Resized和RedrawRequested,最后触发RedrawEventsCleared事件标识所有事件处理完毕。


单次循环处理完所有事件后进入控制流,决定下一次处理事件的行为,控制流支持Android epoll多路复用,在必要时唤醒循环处理后续事件,此外,控制流提供了强制执行、强制退出的能力。事实上,android-ndk-rs就是通过添加fd的方式将窗口行为抛到EventLoop中包装成Callback事件处理:



  • 首先,新建一对fdPIPE: [RawFd; 2],并把读端加到ALooper中,指定标识符为NDK_GLUE_LOOPER_EVENT_PIPE_IDENT;

  • 然后,在适当的时机调用向fd写端写入事件;

  • 最后,fd写入后触发ALooper在poll时被唤醒,且得到被唤醒fd的ident为NDK_GLUE_LOOPER_EVENT_PIPE_IDENT,便可以从fd读端读出此前wake()写入的事件并进行相应的处理;


// <--1--> 挂载fd
// ndk-glue/src/lib.rs
lazy_static! {
static ref PIPE: [RawFd; 2] = {
let mut pipe: [RawFd; 2] = Default::default();
unsafe { libc::pipe(pipe.as_mut_ptr()) };
pipe
};
}

{
...
thread::spawn(move || {
let looper = ThreadLooper::prepare();
let foreign = looper.into_foreign();
foreign
.add_fd(
PIPE[0],
NDK_GLUE_LOOPER_EVENT_PIPE_IDENT,
FdEvent::INPUT,
std::ptr::null_mut(),
)
.unwrap();
});
...
}

// <--2--> 向fd写入数据
// ndk-glue/src/lib.rs
unsafe fn wake(_activity: *mut ANativeActivity, event: Event) {
log::trace!("{:?}", event);
let size = std::mem::size_of::<Event>();
let res = libc::write(PIPE[1], &event as *const _ as *const _, size);
assert_eq!(res, size as _);
}

// <--3--> 唤醒事件循环读出事件
// src/platform_impl/android/mod.rs
fn poll(poll: Poll) -> Option<EventSource> {
match poll {
Poll::Event { ident, .. } => match ident {
ndk_glue::NDK_GLUE_LOOPER_EVENT_PIPE_IDENT => Some(EventSource::Callback),
...
},
...
}
}

// ndk-glue/src/lib.rs
pub fn poll_events() -> Option<Event> {
unsafe {
let size = std::mem::size_of::<Event>();
let mut event = Event::Start;
if libc::read(PIPE[0], &mut event as *mut _ as *mut _, size) == size as _ {
Some(event)
} else {
None
}
}
}

2.2 跨平台egl上下文:glutin


我们有了跨平台的OpenGL(ES)用于描述图形对象,也有了跨平台的窗口系统winit封装窗口行为,但是如何理解图形语言并将其渲染到各个平台的窗口上?这就是egl发挥的作用,它实现了OpenGL(ES)和底层窗口系统之间的接口层。在rust-windowing项目中,glutin工程承接了这个职责,以上下文的形式把窗口系统winit和gl关联了起来。



Context是gl的上下文环境,全局可以有多个gl上下文,但是一个线程同时只能有一个活跃的上下文,使用ContextCurrentState区分这一状态。glutin中Context可关联零个或多个Window,当Context与Window相关联时,使用ContextWrapper类,ContextWrapper使得可以方便地在上下文中同时操作gl绘制以及Window渲染。在其上衍生出两个类型:(1)RawContext:Context与Window虽然关联但是分开存储;(2)WindowedContext:同时存放相互关联的一组Context和Window。常见的场景下WindowedContext更加适用,通过ContextBuilder指定所需的gl属性和像素格式就可以构造一个WindowedContext,内部会初始化egl上下文,并基于持有的EglSurfaceType类型的window创建一个eglsurface作为后续gl指令绘制(draw)、回读(read)的作用目标(指定使用该surface上的缓冲)。


2.3 硬件绘制的例子:


基于winit和glutin提供的能力,使用Rust进行渲染的准备工作只需基于特定业务需求去创建一个glutin的Context,通过Context中创建的egl上下文可以调用gl api进行绘制,而window让我们可以掌控渲染流程,在需要的时候(比如基于EventLoop重绘指令或者一个简单的无限循环)下发绘制指令。简单地实现文章开头的三角形demo动画效果如下:


fn render(&mut self, gl: &Gl) {
let time_elapsed = self.startTime.elapsed().as_millis();
let percent = (time_elapsed % 5000) as f32 / 5000f32;
let angle = percent * 2f32 * std::f32::consts::PI;

unsafe {
let vs = gl.CreateShader(gl::VERTEX_SHADER);
gl.ShaderSource(vs, 1, [VS_SRC.as_ptr() as *const _].as_ptr(), std::ptr::null());
gl.CompileShader(vs);
let fs = gl.CreateShader(gl::FRAGMENT_SHADER);
gl.ShaderSource(fs, 1, [FS_SRC.as_ptr() as *const _].as_ptr(), std::ptr::null());
gl.CompileShader(fs);
let program = gl.CreateProgram();
gl.AttachShader(program, vs);
gl.AttachShader(program, fs);
gl.LinkProgram(program);
gl.UseProgram(program);
gl.DeleteShader(vs);
gl.DeleteShader(fs);
let mut vb = std::mem::zeroed();
gl.GenBuffers(1, &mut vb);
gl.BindBuffer(gl::ARRAY_BUFFER, vb);
let vertex = [
SIDE_LEN * (BASE_V_LEFT+angle).cos(), SIDE_LEN * (BASE_V_LEFT+angle).sin(), 0.0, 0.4, 0.0,
SIDE_LEN * (BASE_V_TOP+angle).cos(), SIDE_LEN * (BASE_V_TOP+angle).sin(), 0.0, 0.4, 0.0,
SIDE_LEN * (BASE_V_RIGHT+angle).cos(), SIDE_LEN * (BASE_V_RIGHT+angle).sin(), 0.0, 0.4, 0.0,
];

gl.BufferData(
gl::ARRAY_BUFFER,
(vertex.len() * std::mem::size_of::<f32>()) as gl::types::GLsizeiptr,
vertex.as_ptr() as *const _,
gl::STATIC_DRAW,
);

if gl.BindVertexArray.is_loaded() {
let mut vao = std::mem::zeroed();
gl.GenVertexArrays(1, &mut vao);
gl.BindVertexArray(vao);
}

let pos_attrib = gl.GetAttribLocation(program, b"position\0".as_ptr() as *const _);
let color_attrib = gl.GetAttribLocation(program, b"color\0".as_ptr() as *const _);
gl.VertexAttribPointer(
pos_attrib as gl::types::GLuint,
2,
gl::FLOAT,
0,
5 * std::mem::size_of::<f32>() as gl::types::GLsizei,
std::ptr::null(),
);

gl.VertexAttribPointer(
color_attrib as gl::types::GLuint,
3,
gl::FLOAT,
0,
5 * std::mem::size_of::<f32>() as gl::types::GLsizei,
(2 * std::mem::size_of::<f32>()) as *const () as *const _,
);

gl.EnableVertexAttribArray(pos_attrib as gl::types::GLuint);
gl.EnableVertexAttribArray(color_attrib as gl::types::GLuint);
gl.ClearColor(1.3 * (percent-0.5).abs(), 0., 1.3 * (0.5 - percent).abs(), 1.0);
gl.Clear(gl::COLOR_BUFFER_BIT);
gl.DrawArrays(gl::TRIANGLES, 0, 3);
}
}

3 Android - Rust JNI开发


以上Rust UI渲染部分完全运行在Rust上下文中(包括对c++的封装),而实际渲染场景下很难完全脱离Android层进行UI的渲染或不与Activity等容器进行交互。所幸Rust UI渲染主要基于(A)NativeWindow,而Android Surface在c++的对应类实现了ANativeWindow,ndk也提供了ANativeWindow_fromSurface方法从一个surface获得ANativeWindow对象,因而我们可以通过JNI的方式使用Rust在Android层的Surface上进行UI渲染:


// Android

surface_view.holder.addCallback(object : SurfaceHolder.Callback2 {

override fun surfaceCreated(p0: SurfaceHolder) {
RustUtils.drawColorTriangle(surface, Color.RED)
}

override fun surfaceChanged(p0: SurfaceHolder, p1: Int, p2: Int, p3: Int) {}

override fun surfaceDestroyed(p0: SurfaceHolder) {}

override fun surfaceRedrawNeeded(p0: SurfaceHolder) {}

})

// Rust
pub unsafe extern fn Java_com_example_rust_1demo_RustUtils_drawColorTriangle__Landroid_view_Surface_2I(env: *mut JNIEnv, _: JClass, surface: jobject, color: jint) -> jboolean {
println!("call Java_com_example_rust_1demo_RustUtils_drawColor__Landroid_view_Surface_2I");
ndk_glue::set_native_window(NativeWindow::from_surface(env, surface));
runner::start();
0
}

需要注意,由于EventLoop是基于ALooper的封装,调用Rust实现渲染时需要确保调用在有Looper的线程(比如HandlerThread中),或者在Rust渲染前初始化时为当前线程准备ALooper。


总结



使用Rust在Android上进行UI渲染的可行性已经得证,但是它的性能表现究竟如何?未来又将在哪些业务上落地?这些仍待进一步探索。

收起阅读 »

带倒计时RecyclerView的设计心路历程

需求 目前有这样一个需求: 1 需要一个页面,展示多个条目 2 每个条目有独立的倒计时,倒计时结束后就删除此条目 3 每个条目上有删除按钮,点击可以删除该条目 4 列表上的条目类型是多样的 可行性分析 首先肯定是可以做的: ...
继续阅读 »

需求



目前有这样一个需求:



  • 1 需要一个页面,展示多个条目

  • 2 每个条目有独立的倒计时,倒计时结束后就删除此条目

  • 3 每个条目上有删除按钮,点击可以删除该条目

  • 4 列表上的条目类型是多样的


可行性分析


首先肯定是可以做的:



  • 1 用一个RecyclerView来实现

  • 2 每个item里面添加一个倒计时控件,注意倒计时是在item对应的数据里面,不是UI里面

  • 3 添加删除按钮,点击就删除对应的数据,并且停止数据对应的倒计时,同时更新适配器

  • 4 使用getViewType()来实现多个item类型


三流程序员看到这里已经去写代码了...


二流以上程序员接着往下看。


需求分析


首先,第1条没问题。


第2条,需要在item对应的数据里面添加一个倒计时组件,这听着就不对,倒计时组件明明是用来更新UI的,应该是UI持有,现在让数据持有,那不就等价于数据间接持有了UI吗,长生命周期持有短生命周期了,不行。而且,数据多的时候,比如10w条数据,就有10w个倒计时组件,cpu不吃不喝也忙不过来(cpu:wqnmlgb)!这明显属于量变引起质变的问题,为了避免这个问题,我们需要将倒计时组件常量化,也就是: 只有常数个倒计时,从而让倒计时组件的个数,不受数据数量的影响。


那么,我们怎么定义这个常量呢?


我们考虑到倒计时是用来更新UI的,那么屏幕内可见的item有多少个,就创建多少个 倒计时组件 不就行了吗,反正屏幕外的,别人也看不见,所以我们可以让ViewHolder持有倒计时组件,而且正好可以利用RecyclerView对ViewHolder的复用机制。


但是,如果让ViewHolder持有,当ViewHolder滑出屏幕外,就会回收,那么倒计时就终止了,此时就无法触发倒计时结束的删除操作,因为即使在屏幕外,只要触发了倒计时的删除数据,我们屏幕内的数据就会向上滑动一个位置,是可以感知的,所以,如果滑出屏幕后,倒计时终止了,就无法触发删除,那么我们可能等了很久,也没发现屏幕内的数据向上滑动,明显是不对的。


程序是为用户服务的,根据以上分析,我们只站在用户角度来考虑:



  • case1 如果倒计时放在数据内,用户可以感知到删除操作,因为有滑动,但是数据多了明显会感觉到卡顿,因为有很多倒计时

  • case2 如果倒计时放在ViewHolder内,用户无法感知到删除操作,因为滑出屏幕倒计时就终止了,但是数据多了不会感觉到卡顿


此乃死锁,无法解决!那么就需要退一步来改下需求了。既然无法完美解决用户的问题,我们就来改变用户的习惯,我们让:倒计时结束后,不再删除item,只是置灰


为什么这么改呢?因为针对case1,我们没法解决,只能从case2入手,而case2的问题就是: 用户无法感知到删除操作,那我就不删除了,这样你也不用感知了,只置灰即可。


好,第二条解决。


第3条,没啥问题,直接remove(index),然后调用adapter.notifyItemRemoved()完事。


第4条,也没啥问题,可以if-else/switch-case,根据不同的type返回不同的ViewHolder。但是可以写的更好点,就是使用工厂模式


设计


可行性分析和需求分析完了后,我们就开始进行概要设计了



  • 1 我们需要创建个RecyclerView。

  • 2 我们需要在ViewHolder里面添加一个倒计时组件,这里我们使用Handler就足够,并且我们需要在进入屏幕时,开启倒计时,在滑出屏幕后,停止倒计时来省cpu。

  • 3 删除数据就不废话了,这都不会的话,回炉重造吧。

  • 4 使用工厂模式,来根据不同的ViewType创建不同的ViewHolder。


这里面有几点需要注意:



  • 1 ViewHolder进入屏幕会触发onBindViewHolder(),滑出屏幕会触发onViewRecycled()。

  • 2 工厂模式要使用多工厂,这样可以降低耦合,有新的ViewType时,只添加就行,可以做到OCP原则。

  • 3 我们可以提前加载工厂,使用map缓存,就跟工厂模式的实现思想里面最后的源码类似,Android源码也是提前加载工厂的。


好,分析结束,开始撸码。


编码


首先,我们先定义数据实体:


// 注意这里的terminalTime,指的指终止时间,不是时间差,是一个时间值。
// type可以理解为ViewType,当然中间有对应关系的
data class BaseItemBean(val id: Long, var terminalTime: Long, val type: Int)

很简单的一行代码,是个Bean对象,直接上data class即可。


然后,我们来定义两个ViewHolder,因为有相同布局,我们可以直接用继承关系:


// 基础ViewHolder
open inner class BaseVH(itemView: View) : RecyclerView.ViewHolder(itemView) {

// 展示倒计时
private val tvTimer = itemView.findViewById<TextView>(R.id.tv_time)
// 删除按钮
private val btnDelete = itemView.findViewById<TextView>(R.id.btn_delete)

init {
btnDelete.setOnClickListener {
onItemDeleteClick?.invoke(adapterPosition)
}
}

/**
* 剩余倒计时
*/
private var delay = 0L

private val timerRunnable = Runnable {
// 这里打印日志,来印证我们只跑了 "屏幕内可展示item数量的 倒计时"
Log.d(TAG, "run: ${hashCode()}")
delay -= 1000
updateTimerState()
}

// 开始倒计时
private fun startTimer() {
timerHandler.postDelayed(timerRunnable, 1000)
}

// 结束倒计时
private fun endTimer() {
timerHandler.removeCallbacks(timerRunnable)
}

// 检测倒计时 并 更新状态
private fun updateTimerState() {
if (delay <= 0) {
// 倒计时结束了
tvTimer.text = "已结束"
itemView.setBackgroundColor(Color.GRAY)
endTimer()
} else {
// 继续倒计时
tvTimer.text = "${delay / 1000}S"
itemView.setBackgroundColor(Color.parseColor("#FFBB86FC"))
startTimer()
}
}

/**
* 进入屏幕时: 填充数据,这里声明为open,让子类重写
*/
open fun display(bean: BaseItemBean) {
Log.d(TAG, "display: $adapterPosition")

// 使用 终止时间 - 当前时间,计算倒计时还有多少秒
delay = bean.terminalTime - System.currentTimeMillis()

// 检测并更新timer状态
updateTimerState()
}

/**
* 滑出屏幕时: 移除倒计时
*/
fun onRecycled() {
Log.d(TAG, "onRecycled: $adapterPosition")

// 终止计时
endTimer()
}
}

在基础ViewHolder里,我们添加了倒计时套件,并且在进入屏幕时,计算并开始倒计时,滑出屏幕后,就终止倒计时,下次滑入屏幕,重新计算delay时间差,再倒计时。


然后看另一个ViewHolder:


// 继承自BaseViewHolder,因为有公共的倒计时套件
inner class OnSaleVH(itemView: View) : BaseVH(itemView) {
// 添加了一个名字
private val tvName = itemView.findViewById<TextView>(R.id.tv_name)

override fun display(bean: BaseItemBean) {
super.display(bean)
// 添加名字
tvName.text = "${bean.id} 在售"
}
}

接下来我们来看创建ViewHolder的工厂:


/**
* 定义抽象工厂
*/
abstract class VHFactory {
abstract fun createVH(context: Context, parent: ViewGroup): BaseVH
}

/**
* BaseViewHolder工厂
*/
inner class BaseVHFactory : VHFactory() {
override fun createVH(context: Context, parent: ViewGroup): BaseVH {
return BaseVH(LayoutInflater.from(context).inflate(R.layout.item_base, parent, false))
}
}

/**
* OnSaleVH工厂
*/
inner class OnSaleVHFactory : VHFactory() {
override fun createVH(context: Context, parent: ViewGroup): BaseVH {
return OnSaleVH(LayoutInflater.from(context).inflate(R.layout.item_on_sale, parent, false))
}
}

很简单,接下来,我们来看Adapter:


class Adapter(private val datas: List<BaseItemBean>) : RecyclerView.Adapter<Adapter.BaseVH>() {

private val TAG = "Adapter"

/**
* 点击item的事件
*/
var onItemDeleteClick: ((position: Int) -> Unit)? = null

/**
* ViewHolder的工厂
*/
private val vhs = SparseArray<VHFactory>()

/**
* 用来执行倒计时
*/
private val timerHandler = Handler(Looper.getMainLooper())

/**
* 初始化工厂
*/
init {
vhs.put(ItemType.ITEM_BASE, BaseVHFactory())
vhs.put(ItemType.ITEM_ON_SALE, OnSaleVHFactory())
}

// 直接从工厂map中获取对应的工厂调用createVH()方法即可
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseVH = vhs.get(viewType).createVH(parent.context, parent)

// 滑入屏幕内调用,直接使用hoder.display()展示数据
override fun onBindViewHolder(holder: BaseVH, position: Int) = holder.display(datas[position])

override fun getItemCount(): Int = datas.size

// ViewHolder滑出屏幕调用,进行回收
override fun onViewRecycled(holder: BaseVH) = holder.onRecycled()

/**
* 根据数据类型返回ViewType
*/
override fun getItemViewType(position: Int): Int = datas[position].type
}

代码也很easy,就是使用工厂模式来返回不同的ViewHolder。


写代码的心路历程:



  • 1 因为有多个ViewType,肯定有多个ViewHolder,ViewType和ViewHolder是映射关系

  • 2 可以用if-else,可以用switch-case,但是这样扩展性差

  • 3 所以用多工厂来实现

  • 4 这样需要创建工厂,每次onCreateViewHolder()都要创建吗?不行,那就缓存起来。

  • 5 缓存需要知道哪个工厂创建哪个ViewHolder,而ViewHolder和ViewType对应,所以可以让工厂和ViewType对应,那就创建一个Map。

  • 6 ViewType是Integer类型的,那就可以用更加省内存的SparseArray(),原因可以看这里

  • 7 于是,我们就有了上述代码。


我们定义的ViewType(都是int类型的,因为int的匹配速度快):


object ItemType {
const val ITEM_BASE = 0x001
const val ITEM_ON_SALE = 0x002
}

接下来我们就可以在Activity中使用了:


class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.recyclerView.layoutManager = LinearLayoutManager(this)

// 添加测试数据
val beans = ArrayList<BaseItemBean>()
for (i in 0..100) {
// 计算终止时间,这里都是当前时间 + i乘以10s
val terminalTime = System.currentTimeMillis() + i * 10_000
// 这里手动计算了ViewType (i%2)+1
beans.add(BaseItemBean(i.toLong(), terminalTime, (i % 2) + 1))
}

val adapter = Adapter(beans)
adapter.onItemDeleteClick = { position ->
// 点击就删除
beans.removeAt(position)
adapter.notifyItemRemoved(position)
}
binding.recyclerView.adapter = adapter

}
}

效果如下:


收起阅读 »

在 Flutter 中探索 StreamBuilder

原文 medium.com/flutterdevs… 正文 异步交互可能需要一个理想的机会来进行总结。偶尔,在周期结束之前可能会发出一些值。在 Dart 中,您可以创建一个返回 Stream 的容量,该容量可以在异步进程处于活动状态时发射一些值...
继续阅读 »


原文



medium.com/flutterdevs…



正文


异步交互可能需要一个理想的机会来进行总结。偶尔,在周期结束之前可能会发出一些值。在 Dart 中,您可以创建一个返回 Stream 的容量,该容量可以在异步进程处于活动状态时发射一些值。假设您需要根据一个 Stream 的快照在 Flutter 中构造一个小部件,那么有一个名为 StreamBuilder 的小部件。


在这个博客中,我们将探索 Flutter 中的 StreamBuilder。我们还将实现一个演示程序,并向您展示如何在您的 Flutter 应用程序中使用 StreamBuilder。


介绍:


StreamBuilder 可以监听公开的流,并返回小部件和捕获获得的流信息的快照。造溪者提出了两个论点。



A stream


构建器,它可以将流中的多个组件更改为小部件



Stream 像一条线。当您从一端输入值而从另一端输入侦听器时,侦听器将获得该值。一个流可以有多个侦听器,这些侦听器的负载可以获得流水线,流水线将获得等价值。如何在流上放置值是通过使用流控制器实现的。流构建器是一个小部件,它可以将用户定义的对象更改为流。


建造者:



要使用 StreamBuilder,需要调用下面的构造函数:



const StreamBuilder({
Key? key,
Stream<T>? stream,
T? initialData,
required AsyncWidgetBuilder<T> builder,
})

实际上,您需要创建一个 Stream 并将其作为流争用传递。然后,在这一点上,您需要传递一个 AsyncWidgetBuilder,该 AsyncWidgetBuilder 可用于构造依赖于 Stream 快照的小部件。


参数:



下面是 StreamBuilderare 的一些参数:




  • Key? key: 小部件的键,用于控制小部件如何被另一个小部件取代

  • Stream<T>? stream: 一个流,其快照可以通过生成器函数获得

  • T? initialData: 将利用这些数据制作初始快照

  • required AsyncWidgetBuilder<T> builder: 生成过程由此生成器使用


如何实现 dart 文件中的代码:


你需要分别在你的代码中实现它:



让我们创建一个流:



下面的函数返回一个每秒生成一个数字的 Stream。你需要使用 async * 关键字来创建一个流。若要发出值,可以使用 yield 关键字后跟要发出的值。


Stream<int> generateNumbers = (() async* {
await Future<void>.delayed(Duration(seconds: 2));

for (int i = 1; i <= 10; i++) {
await Future<void>.delayed(Duration(seconds: 1));
yield i;
}
})();


From that point onward, pass it as the stream argument


从那一点开始,把它作为流参数传递下去



StreamBuilder<int>(
stream: generateNumbers,
// other arguments
)


让我们创建一个 AsyncWidgetBuilder



构造函数期望您传递一个类型为 AsyncWidgetBuilder 的命名争用构建器。这是一个有两个参数的函数,它们的类型都是 BuildContext 和 AsyncSnapshot < t > 。后续的边界(包含当前快照)可以用来确定应该呈现的内容。


要创建这个函数,首先需要了解 AsyncSnapshot。AsyncSnapshot 是使用异步计算的最新通信的不变描述。在这种独特的情况下,它解决了与 Stream 的最新通信。可以通过 AsyncSnapshot 属性获取流的最新快照。您可能需要使用的属性之一是 connectionState,这个枚举将当前关联状态转换为异步计算,在这种特殊情况下,这种异步计算就是 Steam。


StreamBuilder<int>(
stream: generateNumbers,
builder: (
BuildContext context,
AsyncSnapshot<int> snapshot,
) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.connectionState == ConnectionState.active
|| snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return const Text('Error');
} else if (snapshot.hasData) {
return Text(
snapshot.data.toString(),
style: const TextStyle(color: Colors._red_, fontSize: 40)
);
} else {
return const Text('Empty data');
}
} else {
return Text('State: ${snapshot.connectionState}');
}
},
),

AsyncSnapshot 还有一个名为 hasError 的属性,可用于检查快照是否包含非空错误值。如果异步活动的最新结果失败,hasError 值将有效。为了获取信息,首先,您可以通过获取其 hasData 属性来检查快照是否包含信息,如果 Stream 有效地释放了任何非空值,那么 hasData 属性将是有效的。然后,在这一点上,您可以从 AsyncSnapshot 的数据属性获取信息。


由于上面属性的值,您可以计算出应该在屏幕上呈现什么。在下面的代码中,当 connectionState 值正在等待时,将显示一个 CircularProgressIndicator。当 connectionState 更改为 active 或 done 时,可以检查快照是否有错误或信息。建造函数称为 Flutter 管道的检测。因此,它将获得一个与时间相关的快照子组。这意味着,如果在实际上相似的时间里,Stream 发出了一些值,那么一部分值可能没有传递给构建器。



枚举有一些可能的值:




  • > none: 无: 不与任何异步计算关联。如果流为空,则可能发生

  • > waiting: 等待: 与异步计算关联并等待协作。在这个上下文中,它暗示流还没有完成

  • > active: 活跃的: 与活动的异步计算相关联。例如,如果一个 Stream 已经返回了任何值,但此时还没有结束

  • > done: > 完成: 与结束的异步计算相关联。在这个上下文中,它暗示流已经完成



设置初始数据:



您可以选择传递一个 worth 作为 initialData 参数,这个参数将被利用,直到 Stream 发出 a。如果传递的值不为空,那么当 connectionState 在等待时,hasData 属性在任何事件中首先都将为 true


StreamBuilder<int>(
initialData: 0,
// other arguments
)

要在 connectionState 等待时显示初始数据,应该调整 if snapshot.connectionState = = connectionState.waiting,然后调整上面代码中的块。


if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
Visibility(
visible: snapshot.hasData,
child: Text(
snapshot.data.toString(),
style: const TextStyle(color: Colors._black_, fontSize: 24),
),
),
],
);
}

当我们运行应用程序,我们应该得到屏幕的输出像下面的屏幕视频。



Code File:


密码档案:


import 'package:flutter/material.dart';
import 'package:flutter_steambuilder_demo/splash_screen.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Splash(),
debugShowCheckedModeBanner: false,
);
}
}

Stream<int> generateNumbers = (() async* {
await Future<void>.delayed(Duration(seconds: 2));

for (int i = 1; i <= 10; i++) {
await Future<void>.delayed(Duration(seconds: 1));
yield i;
}
})();

class StreamBuilderDemo extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _StreamBuilderDemoState ();
}
}

class _StreamBuilderDemoState extends State<StreamBuilderDemo> {

@override
initState() {
super.initState();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('Flutter StreamBuilder Demo'),
),
body: SizedBox(
width: double._infinity_,
child: Center(
child: StreamBuilder<int>(
stream: generateNumbers,
initialData: 0,
builder: (
BuildContext context,
AsyncSnapshot<int> snapshot,
) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
Visibility(
visible: snapshot.hasData,
child: Text(
snapshot.data.toString(),
style: const TextStyle(color: Colors._black_, fontSize: 24),
),
),
],
);
} else if (snapshot.connectionState == ConnectionState.active
|| snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return const Text('Error');
} else if (snapshot.hasData) {
return Text(
snapshot.data.toString(),
style: const TextStyle(color: Colors._red_, fontSize: 40)
);
} else {
return const Text('Empty data');
}
} else {
return Text('State: ${snapshot.connectionState}');
}
},
),
),
),
);
}
}

结语:


在本文中,我已经简单介绍了 StreamBuilder 的基本结构; 您可以根据自己的选择修改这段代码。这是我对 StreamBuilder On User Interaction 的一个小小介绍,它正在使用 Flutter 工作。

收起阅读 »

带着问题学习Android中View的measure测量

在进行研究measure原理之前,我们先带着这三个问题来想想。因为我是遇到这三个问题才开始研究measure的源码,所以我也把下面的三个问题当做引子。调用measure(int widthMeasureSpec, int heightMeasureSpec)方...
继续阅读 »

在进行研究measure原理之前,我们先带着这三个问题来想想。因为我是遇到这三个问题才开始研究measure的源码,所以我也把下面的三个问题当做引子。

调用measure(int widthMeasureSpec, int heightMeasureSpec)方法传递的参数是什么? 为什么调用measure方法View控件没有进行测量? 如何强制view进行测量? 在进行研究之前,我们先来看一个简单的布局,

        <Button
android:id="@+id/btn_click"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="点击"
android:onClick="start"
/>

<LinearLayout
android:id="@+id/linear"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FF0000">

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="点击"/>

</LinearLayout>

看效果图:

image.png

根据布局文件,我们并没有设置边距属性,为什么显示的效果的Button跟下面的没有对齐。这就是。在实际开发中,我们细心点会发现,对于Button控件,我们选中它的时候显示的区域比它展现的区域大。

如果我们给Button控件添加背景色:

        <Button
android:id="@+id/btn_click"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="点击"
android:background="#FF0000"
android:onClick="start"
/>

可以看到Button的背景色和LinearLayout的背景色无缝连接在一起,同时我们观察下面的那个点击的Button,发现它的周围区域实际是存在的,是白色与我们的背景色重叠起来了。这就引入了我们的一个重要概念:控件边界布局和视觉编辑布局。我们在真机上打开【显示布局边界】,在设置——>开发者选项——>显示布局边界。 我们看下效果图。

注:蓝色 为控件的布局边界;粉红色为视觉边界 这就涉及到我们的一个ViewGroup属性:android:layoutMode

说的通俗一点,clipBounds就是默认值,不处理一些控件之间的“留白”,opticalBounds消除控件之间的留白。

我们抽出LinearLayout的布局来说:

        <LinearLayout
android:id="@+id/linear"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#fff000"
android:layoutMode="clipBounds">

<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="点击"/>

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="测试的"
/>

</LinearLayout>

我们修改属性android:layoutMode=”opticalBounds”,效果图:

image.png

通过对比发现就是一个清除的效果。

MeasureSpec 我们分析第一个问题,onMeasure()方法里传的是什么?传的就是MeasureSpec变量。它是View的一个内部类。源码设计非常简单精悍。

一个MeasureSpec封装了父布局传递给子布局的布局要求,每个MeasureSpec代表了一组宽度和高度的要求。一个MeasureSpec由大小和模式组成。由32位组成,头8位为模式,后24位封装大小。它有三种模式:UNSPECIFIED(未指定),父元素部队自元素施加任何束缚,子元素可以得到任意想要的大小;EXACTLY(完全),父元素决定自元素的确切大小,子元素将被限定在给定的边界里而忽略它本身大小;AT_MOST(至多),子元素至多达到指定大小的值。它常用的三个函数:

static int getMode(int measureSpec):根据提供的测量值(格式)提取模式(上述三个模式之一) static int getSize(int measureSpec):根据提供的测量值(格式)提取大小值(这个大小也就是我们通常所说的大小) static int makeMeasureSpec(int size,int mode):根据提供的大小值和模式创建一个测量值(格式) Mode的取值:

MeasureSpec.AT_MOST,即十进制2,该值表示View最大可以取其父ViewGroup给其指定的尺寸,例如现在有个Int值widthMeasureSpec,ViewGroup将其传递给了View的measure方法,如果widthMeasureSpec中的mode值是AT_MOST,size是300,那么表示View能取的最大的宽度是300。

MeasureSpec.EXACTLY,即十进制1,该值表示View必须使用其父ViewGroup指定的尺寸,还是以widthMeasureSpec为例,如果其mode值是EXACTLY,控件大小就是它老子的大小

MeasureSpec.UNSPECIFIED,即十进制0,该值表示View的父ViewGroup没有给View在尺寸上设置限制条件,这种情况下View可以忽略measureSpec中的size,View可以取自己想要的值作为量算的尺寸。

我们常看到measure(0,0)或者measure(1,1)之类的,这就是传入的测量模式。

measure()方法 下面就开始分析measure方法。

       public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
//判断当前view的LayoutMode是否为opticalbounds
boolean optical = isLayoutModeOptical(this);
if (optical != isLayoutModeOptical(mParent)) {//判断当前view的ParentView的LayoutMode是否为opticalbounds
Insets insets = getOpticalInsets();
int oWidth = insets.left + insets.right;
int oHeight = insets.top + insets.bottom;
widthMeasureSpec = MeasureSpec.adjust(widthMeasureSpec, optical ? -oWidth : oWidth);
heightMeasureSpec = MeasureSpec.adjust(heightMeasureSpec, optical ? -oHeight : oHeight);
}

// 根据我们传入的widthMeasureSpec和heightMeasureSpec计算key值,我们在mMeasureCache中存储我们view的信息
long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;
//如果mMeasureCache为null,则进行new一个对象
if (mMeasureCache == null) mMeasureCache = new LongSparseLongArray(2);

//mOldWidthMeasureSpec和mOldHeightMeasureSpec分别表示上次对View进行量算时的widthMeasureSpec和heightMeasureSpec
//执行View的measure方法时,View总是先检查一下是不是真的有必要费很大力气去做真正的量算工作
//mPrivateFlags是一个Int类型的值,其记录了View的各种状态位
//如果(mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT,
//那么表示当前View需要强制进行layout(比如执行了View的forceLayout方法),所以这种情况下要尝试进行量算
//如果新传入的widthMeasureSpec/heightMeasureSpec与上次量算时的mOldWidthMeasureSpec/mOldHeightMeasureSpec不等,
//那么也就是说该View的父ViewGroup对该View的尺寸的限制情况有变化,这种情况下要尝试进行量算
if ((mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT ||
widthMeasureSpec != mOldWidthMeasureSpec ||
heightMeasureSpec != mOldHeightMeasureSpec) {

//通过运算,重置mPrivateFlags值,即View的测量状态
mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;
//解决布局中的Rtl问题
resolveRtlPropertiesIfNeeded();
//判断当前View是否是强制进行测量,如果是则将cacheIndex=-1,反之从mMeasureCache中获取
//对应的index,即从缓存中读取存储的大小。
int cacheIndex = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT ? -1 :
mMeasureCache.indexOfKey(key);
//根据cacheIndex的大小判断是否需要重新测量,或者根据布尔变量sIgnoreMeasureCache进行判断。
if (cacheIndex < 0 || sIgnoreMeasureCache) {
// 重新测量,则调用我们重写的onMeasure()方法进行测量,然后重置View的状态
onMeasure(widthMeasureSpec, heightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
} else {
// 通过我们计算的cacheIndex值,从缓存中读取我们的测量值。
long value = mMeasureCache.valueAt(cacheIndex);
// 通过setMeasuredDimension()方法设置我们的测量值,然后重置View的状态
setMeasuredDimension((int) (value >> 32), (int) value);
mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}

// 如果View的状态没有改变,则会抛出异常“我们没有调用”setMeasuredDimension()“方法,一般出现在我们重写onMeasure方法,
//但是没有调用setMeasuredDimension方法导致的。
if ((mPrivateFlags & PFLAG_MEASURED_DIMENSION_SET) != PFLAG_MEASURED_DIMENSION_SET) {
throw new IllegalStateException("onMeasure() did not set the"
+ " measured dimension by calling"
+ " setMeasuredDimension()");
}

mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
}

mOldWidthMeasureSpec = widthMeasureSpec;
mOldHeightMeasureSpec = heightMeasureSpec;
//将最新的widthMeasureSpec和heightMeasureSpec进行存储到mMeasureCache
mMeasureCache.put(key, ((long) mMeasuredWidth) << 32 |
(long) mMeasuredHeight & 0xffffffffL); // suppress sign extension
}

在上面的代码中,注释还算详细,仔细看应该能知道测量的流程。 (1)、测量首先判断控件的模式,通过调用isLayoutModeOptical方法进行判断。

        public static boolean isLayoutModeOptical(Object o) {
return o instanceof ViewGroup && ((ViewGroup) o).isLayoutModeOptical();
}

//ViewGroup的isLayoutModeOptical方法
boolean isLayoutModeOptical() {
return mLayoutMode == LAYOUT_MODE_OPTICAL_BOUNDS;
}

这个方法就是判断view是否为ViewGroup类型,然后判断layoutMode设定是否为opticalBounds。如果是,则对传入的widthMeasureSpec、heightMeasureSpec进行重新计算封装,通过上面的试验,我们看到了设定的区别,所以需要重新计算封装。

(2)、判断当前view是否强制重新计算,或者传入进来的MeasureSpec是否和上次不同。这两种情况满足一种则进行测量运算。 (3)、系统还不满足,又判断是否为强制测量,如果为强制测量或者忽略缓存,则调用我们重写的onMeasure()方法进行测量,反之,从mMeasureCache缓存中读取上次的测量数据。

为什么调用measure()方法控件没有进行重新测量?

通过前面的源码分析,是不是对结果知道一二,View也不是因为我们调用了measure方法就进行真真切切的重新测量,首先,它会判断我们是否是强制测量或者测量模式发生了改变没有,这个是必要条件,如果这里都不满足就不会进入执行到我们的onMeasure方法,之后还要判断我们是否强制重新测量,不然取缓存的值,只样实际上还没有达到我们的测量。 注:Android不同版本对应的measure方法源码可能有所不同。

说到这里,measure的源码是分析了,我们在往深入的想,我们如果在我们的自定义View时没有对onMeasure()方法进行重写,那么系统调用的onMeasure()方法是怎么实现的呢?不错,我们就瞧一瞧View中默认的onMeasure()方法是怎么实现的。

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

这里面涉及到三个方法:

  • getDefaultSize
  • getSuggestedMinimumWidth
  • getSuggestedMinimumHeight

稍微思考下,我们也知道肯定是设置一个默认值的,我们看下后两个函数的源码:

    protected int getSuggestedMinimumWidth() {
return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}

protected int getSuggestedMinimumHeight() {
return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
}

都是进行判断backgroud是否为空,如果为空,返回view最小的高度或宽度,如果不为空,返回与backgroud的最小宽高中的最大值。可能你会疑惑,view的最小宽度或高度是怎么来的?这个就要回归到我们的View构造函数。

    case R.styleable.View_minWidth:
mMinWidth = a.getDimensionPixelSize(attr, 0);
break;
case R.styleable.View_minHeight:
mMinHeight = a.getDimensionPixelSize(attr, 0);
break;

可以从这里获取,当然我们也可以进行设定:

    public void setMinimumWidth(int minWidth) {
mMinWidth = minWidth;
requestLayout();
}


public void setMinimumHeight(int minHeight) {
mMinHeight = minHeight;
requestLayout();
}

我们接着看看看getDefaultSize()的源码:

    public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);

switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}

在getDefaultSize中,传入进来我们获取的最小值,然后根据我们设定的MeasureSpec获取对应size和mode,然后判断mode,如果为MeasureSpec.UNSPECIFIED就将size赋值我们获取的最小大小。模式为MeasureSpec.AT_MOST、MeasureSpec.EXACTLY时,赋值为我们从MeasureSpec获取的大小。这也证实了自定义控件时,我们没有重写onMeasure方法,同时给控件设置wrap_content属性,控件显示的效果是match_parent的效果。

说到这里measure流程的大概也基本搞明白了。 我们来看第三个问题,如何强制一个view进行重绘? 根据上面的分析,我们强制重绘就得清除缓存mMeasureCache缓存中的数据。这里就得提及forceLayout()方法,看下这个方法的源码:

    public void forceLayout() {
if (mMeasureCache != null) mMeasureCache.clear();
mPrivateFlags |= PFLAG_FORCE_LAYOUT;
mPrivateFlags |= PFLAG_INVALIDATED;
}

这个方法中就是清除缓存mMeasureCache中的缓存数据,然后改变View的mPrivateFlags属性值。这里又得说起requestLayout()函数,用于请求重新布局。


public void requestLayout() {
if (mMeasureCache != null) mMeasureCache.clear();

if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
// Only trigger request-during-layout logic if this is the view requesting it,
// not the views in its parent hierarchy
ViewRootImpl viewRoot = getViewRootImpl();
if (viewRoot != null && viewRoot.isInLayout()) {
if (!viewRoot.requestLayoutDuringLayout(this)) {
return;
}
}
mAttachInfo.mViewRequestingLayout = this;
}

mPrivateFlags |= PFLAG_FORCE_LAYOUT;
mPrivateFlags |= PFLAG_INVALIDATED;

if (mParent != null && !mParent.isLayoutRequested()) {
mParent.requestLayout();
}
if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
mAttachInfo.mViewRequestingLayout = null;
}
}

这样就可以完成View的强制测量。在实际的开发中,我们在对自定义View进行测量的时候,只需要重写onMeasure()方法即可,在onMeasure()方法中指定我们要求的控件大小,除非我们在刷新控件的时候需要我们去考虑一些方法的实现,探究源码让我们知道了为什么是这样,不至于迷惘。

收起阅读 »

kotlin协程最佳实践-android官网

协程最佳实践 android官网地址 这些实践可以让你的程序在使用协程的时候更加的易扩展和易测试 1.注入调度器 不要在创建一个协程的时候或者调用withContext,硬编码来指定调度器 比如这样的 class NewsRepository { ...
继续阅读 »

协程最佳实践 android官网地址


这些实践可以让你的程序在使用协程的时候更加的易扩展和易测试


1.注入调度器


不要在创建一个协程的时候或者调用withContext,硬编码来指定调度器 比如这样的


class NewsRepository {
// DO NOT use Dispatchers.Default directly, inject it instead
suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

而应该进行注入


class NewsRepository(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

原因:依赖注入的模式可以让你在测试的时候容易更换调度器 详细参考Android中简易的协程


2. 挂起函数在实现的时候,应该保证对主线程是安全的


比如这样的:


suspend fun fetchLatesNews():ListArtical{
withContext(Dispatchers.IO){
}
}

主线程调用的时候


suspend operator fun updateContent(){
val news = fetchLatesNews()
}

这样可以保证你的App是易扩展的,类挂起方法调用的时候,不需要担心线程是在哪个环境调度的,由具体实现类中的方法来确保线程调度的安全


3. viewModle 应该去创建一个协程


viewModle更应该去创建一个协程,而不是去暴露一个suspend方法。 比如应该是这样:


//示例代码,viewModle内去创建一个协程
class LastestNewsViewModel{
//内部维护了一个可观察的带状态的数据
private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
val uiState:StateFlow<LatestNewsUiState> = _uiState

//重点来了,这里不是一个suspend方法
fun loadNews(){
viewModleScope.lanuch{
val lastestNewsWithAuthors = getLatestNewsWithAuthors()
_uiState.valule = LastestNewUiState.Success(lastestNewsWithAuthors)
}
}

}


而不是这样的


class LastestNewsViewModel():ViewModel{
//这种是直接返回了一个suspend方法
suspend fun loadNews() = getLatestNewsWithAuthors()
}


除非不需要调用知道数据流的状态,而只需要发射一个单独的数据。(个人理解,是保持viewModle中的定义,维护一个可观察的带状态的数据,而不是直接扔原始数据出来)


4.不要暴露可修改的参数类型


应该对其他类暴露不可修改的的类型,这样所有可变类型数据的变更都集中在一个类里,如果有问题的时候,更容易调试(也是迪米特原则) 比如应该是这样的


class LastestNewsViewModel : ViewModel{
//可修改类型
private val _uiState = _MutalbeStateFlwow(LastestNewsViewModel.Loading)
//对外暴露不可修改类型数据(对外不提供修改功能)
val uiState : StateFlow<LatestNewsUiState> = _uiState
}

5. 数据和业务层应该暴露挂起函数 或 Flow


数据层和业务层通常需要暴露方法,去执行一次性的调用或者需要持续接收数据的变化,这时候应该提供为一次性调用提供挂起函数 或者 提供Flow来帮忙观察数据的变化操作 比如这样的:


class ExampleRepository{
//为一次性的调用提供 suspend方法
suspend fun makeNetworkRequest(){}

//为一需要观察的数据提供Flow对象
fun getExamples():Flow<Example>{}
}

最佳的实践可以使调用者通常是业务层,能够控制业务的执行和生命周期的运转,并且在需要的时候可以取消任务


6. 在业务和数据层创建协程


在数据和业务层需要创建协程的原因可能有不几的原因,下边是一些可能的选项



  • 如果协程的任务是相关的,且只在用户在当前界面时才显示,那么它需要关联调用者的生命周期,这个调用者通常就是ViewModel,在这种 情况下, 应该使用coroutineScope 和 supervisorScope


示例代码:


class GetAllBooksAndAuthorsUseCase(
private val booksRepository:BooksRepository,
private val authorsRepository:AuthorsRepository,
private val defaultDispatcher:CoroutineDispatcher = Dispatchers.Default
){
suspend fun getBookdAndAuthors():BookAndAuthors{
//平行的情况需要等待结果,书籍列表和作者列表需要同时准备好之后再返回
return coroutineScope{
val books = async(defaultDispatcher){
booksRepository.getAllBooks()
}
val authors = async(defaultDispatcher){
authorsRepository.getallAuthors()
}
//准备好数据之后再返回
BookAndAuthors(books.await(),authors.await())
}
}
}


  • 如果这个任务是在App开启期间需要执行,这个任务也不绑定到某一个具体的界面,这时候任务是需要在超出调用者的生命周期的,这种场景下,需要用到


external 的 CoroutineScope ,详细可参考 协程设计模式之任务不应该被取消


参考示例代码:


class ArticalesRepository(
private val articlesDataSource: ArticlesDataSource,
private val externalScope:CoroutineScope,
private val defaultDispatcher:CoroutineDispatcher = Dispatchers.Default
){
//这个场景是这样的,即使我们离开的屏幕,也希望这个预订操作是能够被完整执行的,那么这任务斋要在外部域开启一个新的协程里来完成这wh
suspend fun bookmarkArtical(artical:Article){
externalScope.lanuch(defaultDispatcher){
articlesDataSource.bookmarkArticle(article)
}.join() //等待协程执行完毕
}
}


说明: 外部域需要被一个比当前界面的生命周期更长的一个类来创建,比如说 Application或者是一个navigatin grah的ViewModel


7. 避免使用GlobalScope全局作用域


就像最佳实践里边的注入调度器,如果用了GlobalScope,那就是在类里边使用硬编码,可能会有以下几个负面影响



  • 硬编码。

  • 难以测试


8. 协程需要可以被取消


取消操作也是一种协程的操作,意思是说当协程被取消的时候,协程并没有直接被取消,除非它在 挂起 或者 有取消操作,如果你的协程是在操作一个阻塞的操作,需要确保协程是中途可以被取消的。 举个例子,如果你正在读取多个文件,需要在读取每个文件之前,检查下协程是否已经被取消了,一个检查协程是否被取消的方法就是 调用 ensureActivite方法,(或者还有isActive可用) 参考示例代码:


    someScope.lanuch{
ensureActive()//检查协程是否已经被取消
readFile(file)
}

更多详细的描述信息可以参考 取消协程


9. 协程的异常处理


如果协程抛出的异常处理不当,可能会导致你的App崩溃。如果异常出现了,就在协程里就捕获好异常并进行处理


参考示例代码:


class LoginViewModel(
private val loginRepository:LoginRepository
):ViewModel(){
fun login(username:String,token:String){
viewModleScope.lanuch{
try{
loginRepository.login(username,token)
//通知界面登录成功
}catch(error:Throwable){
//通知view 登录操作失败
}
}
}
}

更多协程异常的处理,或者其他场景需要用到CoroutineExceptionHandler,可以参考 协程异常处理



																			        	收起阅读 »
								        											

影响性能的 Kotlin 代码(一)

Kotlin 高级函数的特性不仅让代码可读性更强,更加简洁,而且还提高了生产效率,但是简洁的背后是有代价的,隐藏着不能被忽视的成本,特别是在低端机上,这种成本会被放大,因此我们需要去研究 kotlin 语法糖背后的魔法,选择合适的语法糖,尽量避免这些坑。 L...
继续阅读 »

Kotlin 高级函数的特性不仅让代码可读性更强,更加简洁,而且还提高了生产效率,但是简洁的背后是有代价的,隐藏着不能被忽视的成本,特别是在低端机上,这种成本会被放大,因此我们需要去研究 kotlin 语法糖背后的魔法,选择合适的语法糖,尽量避免这些坑。


Lambda 表达式


Lambda 表达式语法简洁,避免了冗长的函数声明,代码如下。


fun requestData(type: Int, call: (code: Int, type: Int) -> Unit) {
call(200, type)
}

Lambda 表达式语法虽然简洁,但是隐藏着两个性能问题。



  • 每次调用 Lambda 表达式,都会创建一个对象



图中标记 1 所示的地方,涉及一个字节码类型的知识点。



















标识符 含义
I 基本类型 int
L 对象类型,以分号结尾,如 Lkotlin/jvm/functions/Function2;

Lambda 表达式 call: (code: Int, type: Int) -> Unit 作为函数参数,传递到函数中,Lambda 表达式会继承 kotlin/jvm/functions/Function2 , 每次调用都会创建一个 Function2 对象,如图中标记 2 所示的地方。



  • Lambda 表达式隐含自动装箱和拆箱过程



正如你所见 lambda 表达式存在装箱和拆箱的开销,会将 int 转成 Integer,之后进行一系列操作,最后会将 Integer 转成 int


如果想要避免 Lambda 表达式函数对象的创建及装箱拆箱开销,可以使用 inline 内联函数,直接执行 lambda 表达式函数体。


Inline 修饰符


Inline 内联函数的作用:提升运行效率,调用被 inline 修饰符标记的函数,会把函数内的代码放到调用的地方。


如果阅读过 Koin 源码的朋友,应该会发现 inline 都是和 lambda 表达式和 reified 修饰符配套在一起使用的,如果只使用 inline 修饰符标记普通函数,Android Studio 也会给一个大大大的警告。



编译器建议我们在含有 lambda 表达式作为形参的函数中使用内联,既然 Inline 修饰符可以提升运行效率,为什么编译器会给我们一个警告? 这是为了防止 inline 操作符滥用而带来的性能损失。


inline 修饰符适用于以下情况



  • inline 修饰符适用于把函数作为另一个函数的参数,例如高阶函数? filter、map、joinToString 或者一些独立的函数 repeat

  • inline 操作符适合和 reified 操作符结合在一起使用

  • 如果函数体很短,使用 inline 操作符可以提高效率


Kotlin 遍历数组


这一小节主要介绍 Kotlin 数组,一起来看一下遍历数组都有几种方式。



  • 通过 forEach 遍历数组

  • 通过区间表达式遍历数组(..downTountil)

  • 通过 indices 遍历数组

  • 通过 withIndex 遍历数组


通过 forEach 遍历数组


先来看看通过 forEach 遍历数组,和其他的遍历数组的方式,有什么不同。


array.forEach { value ->

}

反编译后:

Integer[] var5 = array;
int var6 = array.length;
for(int var7 = 0; var7 < var6; ++var7) {
Object element$iv = var5[var7];
int value = ((Number)element$iv).intValue();
boolean var10 = false;
}

正如你所见通过 forEach 遍历数组的方式,会创建额外的对象,并且存在装箱/拆箱开销,会占用更多的内存。


通过区间表达式遍历数组


在 Kotlin 中区间表达式有三种 ..downTountil



  • .. 关键字,表示左闭右闭区间

  • downTo 关键字,实现降序循环

  • until 关键字,表示左闭右开区间


.. 、downTo 、until


for (value in 0..size - 1) {
// case 1
}

for (value in size downTo 0) {
// case 2
}

for (value in 0 until size) {
// case 3
}

反编译后

// case 1
if (value <= var4) {
while(value != var4) {
++value;
}
}

// case 2
for(boolean var5 = false; value >= 0; --value) {
}

// case 3
for(var4 = size; value < var4; ++value) {
}

如上所示 区间表达式 ( ..downTountil) 除了创建一些临时变量之外,不会创建额外的对象,但是区间表达式 和 step 关键字结合起来一起使用,就会存在内存问题。


区间表达式 和 step 关键字


step 操作的 ..downTountil, 编译之后如下所示。


for (value in 0..size - 1 step 2) {
// case 1
}

for (value in 0 downTo size step 2) {
// case 2
}

反编译后:

// case 1
var10000 = RangesKt.step((IntProgression)(new IntRange(var6, size - 1)), 2);
while(value != var4) {
value += var5;
}

// case 2
var10000 = RangesKt.step(RangesKt.downTo(0, size), 2);
while(value != var4) {
value += var5;
}

step 操作的 ..downTountil 除了创建一些临时变量之外,还会创建 IntRangeIntProgression 对象,会占用更多的内存。


通过 indices 遍历数组


indices 通过索引的方式遍历数组,每次遍历的时候通过索引获取数组里面的元素,如下所示。


for (index in array.indices) {
}

反编译后:

for(int var4 = array.length; var3 < var4; ++var3) {
}

通过 indices 遍历数组, 编译之后的代码 ,除了创建了一些临时变量,并没有创建额外的对象。


通过 withIndex 遍历数组


withIndexindices 遍历数组的方式相似,通过 withIndex 遍历数组,不仅可以获取的数组索引,同时还可以获取到每一个元素。


for ((index, value) in array.withIndex()) {

}

反编译后:

Integer[] var5 = array;
int var6 = array.length;
for(int var3 = 0; var3 < var6; ++var3) {
int value = var5[var3];
}

正如你所看到的,通过 withIndex 方式遍历数组,虽然不会创建额外的对象,但是存在装箱/拆箱的开销


总结:



  • 通过 forEach 遍历数组的方式,会创建额外的对象,占用内存,并且存在装箱 / 拆箱开销

  • 通过 indices 和区间表达式 ( ..downTountil) 都不会创建额外的对象

  • 区间表达式 和 step 关键字结合一起使用, 会有创建额外的对象的开销,占用更多的内存

  • 通过 withIndex 方式遍历数组,不会创建额外的对象,但是存在装箱/拆箱的开销


尽量少使用 toLowerCase 和 toUpperCase 方法



这一小节内容,在我之前的文章中分享过,但是这也是很多小伙伴,遇到最多的问题,所以单独拿出来在分析一次



当我们比较两个字符串,需要忽略大小写的时候,通常的写法是调用 toLowerCase() 方法或者 toUpperCase() 方法转换成大写或者小写,然后在进行比较,但是这样的话有一个不好的地方,每次调用 toLowerCase() 方法或者 toUpperCase() 方法会创建一个新的字符串,然后在进行比较。


调用 toLowerCase() 方法


fun main(args: Array<String>) {
// use toLowerCase()
val oldName = "Hi dHL"
val newName = "hi Dhl"
val result = oldName.toLowerCase() == newName.toLowerCase()

// or use toUpperCase()
// val result = oldName.toUpperCase() == newName.toUpperCase()
}

toLowerCase() 编译之后的 Java 代码



如上图所示首先会生成一个新的字符串,然后在进行字符串比较,那么 toUpperCase() 方法也是一样的如下图所示。


toUpperCase() 编译之后的 Java 代码



这里有一个更好的解决方案,使用 equals 方法来比较两个字符串,添加可选参数 ignoreCase 来忽略大小写,这样就不需要分配任何新的字符串来进行比较了。


fun main(args: Array<String>) {
val oldName = "hi DHL"
val newName = "hi dhl"
val result = oldName.equals(newName, ignoreCase = true)
}

equals 编译之后的 Java 代码



使用 equals 方法并没有创建额外的对象,如果遇到需要比较字符串的时候,可以使用这种方法,减少额外的对象创建。


by lazy


by lazy 作用是懒加载,保证首次访问的时候才初始化 lambda 表达式中的代码, by lazy 有三种模式。



  • LazyThreadSafetyMode.NONE 仅仅在单线程

  • LazyThreadSafetyMode.SYNCHRONIZED 在多线程中使用

  • LazyThreadSafetyMode.PUBLICATION 不常用


LazyThreadSafetyMode.SYNCHRONIZED 是默认的模式,多线程中使用,可以保证线程安全,但是会有 double check + lock 性能开销,代码如下图所示。



如果是在主线程中使用,和初始化相关的逻辑,建议使用 LazyThreadSafetyMode.NONE 模式,减少不必要的开销。


仓库 KtKit 是用 Kotlin 语言编写的小巧而实用的工具库,包含了项目中常用的一系列工具, 正在逐渐完善中,如果你有兴趣,想邀请你和我一起来完善这个库。


收起阅读 »

从android系统源码看java层的so加载。

理论基础我们在android开发项目过程中都必然会更so加载打交道,那么so加载在系统中的顺序和流程是怎样的,我们就有必要对这个加载过程进行熟悉了解掌握。so的加载是一种解析式装载,这与dex有一定区别,dex是先加载进行优化验证生成odex,再去解析odex...
继续阅读 »

理论基础

我们在android开发项目过程中都必然会更so加载打交道,那么so加载在系统中的顺序和流程是怎样的,我们就有必要对这个加载过程进行熟悉了解掌握。
so的加载是一种解析式装载,这与dex有一定区别,dex是先加载进行优化验证生成odex,再去解析odex文件,而so更像边解析边装载,在加载过程中主要解析是load段。
下面主要是以java层的so加载进行从源码上进行解析加载流程。

java层的so加载流程分析

System.loadLibrary入口点

在java层我们知道加载so文件是通过System.loadLibrary函数其实现的,下面就以其作为入口点进行分析它的调用关系和实现。 System.loadLibrary在的函数定义系统source\libcore\luni\src\main\java\java\lang\system.java的文件中。

下面是其函数定义实现。

//参数就是要加载的so文件名称
public static void loadLibrary(String libName) {
//通过调用Runtime下面的loadLibrary函数实现
//函数有两个参数,参数1是加载的so文件名,参数2 类加载器。
Runtime.getRuntime().loadLibrary(libName, VMStack.getCallingClassLoader());
}

Runtime的loadLibray

通过上面的System.java的loadLibrary函数我们需要继续分析Runtime.java文件中的loadLibray函数的定义实现。 Runtime的loadLibrary函数在android系统中的位置是 source\libcore\luni\src\main\java\java\lang\Runtime.java文件。

下面是Runtime的 loadLibrary函数的定义实现源码。

    /*
* Searches for and loads the given shared library using the given ClassLoader.
*/

void loadLibrary(String libraryName, ClassLoader loader) {
if (loader != null) {
//通过加载器去查找要加载的so文件名
String filename = loader.findLibrary(libraryName);
//查找失败
if (filename == null) {
// It's not necessarily true that the ClassLoader used
// System.mapLibraryName, but the default setup does, and it's
// misleading to say we didn't find "libMyLibrary.so" when we
// actually searched for "liblibMyLibrary.so.so".
throw new UnsatisfiedLinkError(loader + " couldn't find \"" +
System.mapLibraryName(libraryName) + "\"");
}
//加载so文件名
String error = doLoad(filename, loader);
if (error != null) {
throw new UnsatisfiedLinkError(error);
}
return;
}

String filename = System.mapLibraryName(libraryName);
List<String> candidates = new ArrayList<String>();
String lastError = null;
//循环遍历文件路径
for (String directory : mLibPaths) {
//文件路径和文件名进行拼接
String candidate = directory + filename;
candidates.add(candidate);

if (IoUtils.canOpenReadOnly(candidate)) {
String error = doLoad(candidate, loader);
if (error == null) {
return; // We successfully loaded the library. Job done.
}
lastError = error;
}
}

if (lastError != null) {
throw new UnsatisfiedLinkError(lastError);
}
throw new UnsatisfiedLinkError("Library " + libraryName + " not found; tried " + candidates);
}

Runtime的doLoad

通过上面的Runtime的loadLibrary函数,我们看到加载so的函数是走到doLoad函数,那么我们就需要继续分析Runtime下的doload函数的定义实现。 Rutime下的doload函数在系统中的 source\libcore\luni\src\main\java\java\lang\Runtime.java文件中。

下面的代码是Runtime的doload函数的定义实现。

 private String doLoad(String name, ClassLoader loader) {
// Android apps are forked from the zygote, so they can't have a custom LD_LIBRARY_PATH,
// which means that by default an app's shared library directory isn't on LD_LIBRARY_PATH.

// The PathClassLoader set up by frameworks/base knows the appropriate path, so we can load
// libraries with no dependencies just fine, but an app that has multiple libraries that
// depend on each other needed to load them in most-dependent-first order.

// We added API to Android's dynamic linker so we can update the library path used for
// the currently-running process. We pull the desired path out of the ClassLoader here
// and pass it to nativeLoad so that it can call the private dynamic linker API.

// We didn't just change frameworks/base to update the LD_LIBRARY_PATH once at the
// beginning because multiple apks can run in the same process and third party code can
// use its own BaseDexClassLoader.

// We didn't just add a dlopen_with_custom_LD_LIBRARY_PATH call because we wanted any
// dlopen(3) calls made from a .so's JNI_OnLoad to work too.

// So, find out what the native library search path is for the ClassLoader in question...
String ldLibraryPath = null;
if (loader != null && loader instanceof BaseDexClassLoader) {
ldLibraryPath = ((BaseDexClassLoader) loader).getLdLibraryPath();
}
// nativeLoad should be synchronized so there's only one LD_LIBRARY_PATH in use regardless
// of how many ClassLoaders are in the system, but dalvik doesn't support synchronized
// internal natives.
synchronized (this) {
return nativeLoad(name, loader, ldLibraryPath);
}
}

总结

从以上的源码实现流程分析,我们可以看出Android在java层加载so的接口是System.loadLibrary(),通过层层递进关系从而实现java层的加载so。

下图是详细的java层加载so函数的调用关系。

收起阅读 »

View的绘制流程 onLayout

onLayout的原理 final boolean didLayout = layoutRequested && (!mStopped || mReportNextDraw); boolean triggerGlo...
继续阅读 »

onLayout的原理

        final boolean didLayout = layoutRequested && (!mStopped || mReportNextDraw);
boolean triggerGlobalLayoutListener = didLayout
|| mAttachInfo.mRecomputeGlobalAttributes;
if (didLayout) {
performLayout(lp, mWidth, mHeight);

if ((host.mPrivateFlags & View.PFLAG_REQUEST_TRANSPARENT_REGIONS) != 0) {
host.getLocationInWindow(mTmpLocation);
mTransparentRegion.set(mTmpLocation[0], mTmpLocation[1],
mTmpLocation[0] + host.mRight - host.mLeft,
mTmpLocation[1] + host.mBottom - host.mTop);

host.gatherTransparentRegion(mTransparentRegion);
if (mTranslator != null) {
mTranslator.translateRegionInWindowToScreen(mTransparentRegion);
}

if (!mTransparentRegion.equals(mPreviousTransparentRegion)) {
mPreviousTransparentRegion.set(mTransparentRegion);
mFullRedrawNeeded = true;
try {
mWindowSession.setTransparentRegion(mWindow, mTransparentRegion);
} catch (RemoteException e) {
}
}
}

}

if (triggerGlobalLayoutListener) {
mAttachInfo.mRecomputeGlobalAttributes = false;
mAttachInfo.mTreeObserver.dispatchOnGlobalLayout();
}
//分发内部的insets,这里我们暂时不去关心省略的逻辑
...
}

接下来performTraversals后续事情分为如下几个方面:

  • 1.就是判断当前的View是否需要重新摆放位置。如果通过requestLayout执行performTraversals方法,则layoutRequested为true;此时需要调用performLayout进行重新的摆放。
  • 2.判断到调用了requestTransparentRegion方法,需要重新计算透明区域,则会调用gatherTransparentRegion方法重新计算透明区域。如果发现当前的和之前的透明区域发生了变化,则通过WindowSession更新WMS那边的区域。

这种情况通常是指存在SurfaceView的情况。因为SurfaceView本身就拥有自己的一套体系沟通到SF体系中进行渲染。Android没有必要把SurfaceView纳入到层级中处理,需要把这部分当作透明,当作不必要的层级进行优化。

整个核心我还是回头关注performLayout究竟做了什么?

ViewRootImpl performLayout

    private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
int desiredWindowHeight) {
mLayoutRequested = false;
mScrollMayChange = true;
mInLayout = true;

final View host = mView;
if (host == null) {
return;
}


try {
host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

mInLayout = false;
int numViewsRequestingLayout = mLayoutRequesters.size();
if (numViewsRequestingLayout > 0) {

ArrayList<View> validLayoutRequesters = getValidLayoutRequesters(mLayoutRequesters,
false);
if (validLayoutRequesters != null) {

mHandlingLayoutInLayoutRequest = true;

int numValidRequests = validLayoutRequesters.size();
for (int i = 0; i < numValidRequests; ++i) {
final View view = validLayoutRequesters.get(i);
view.requestLayout();
}
measureHierarchy(host, lp, mView.getContext().getResources(),
desiredWindowWidth, desiredWindowHeight);
mInLayout = true;
host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

mHandlingLayoutInLayoutRequest = false;

}

}
} finally {
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
mInLayout = false;
}

其实整个核心还是这一段代码:

            host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

这一段代码将会开启遍历View树的layout的流程,也就是View的摆放的流程。

当处理完layout流程之后,就会继续检查是否有View在测量,摆放的流程请求
中是否有别的View请求进行刷新,如果请求则把这个View保存在mLayoutRequesters对象中。此时取出重新进行测量和摆放。

记住此时的根布局是DecorView是layout方法.由于DecorView和FrameLayout都有重写layout,我们来看看ViewGroup的layout.

ViewGroup layout

    public final void layout(int l, int t, int r, int b) {
if (!mSuppressLayout && (mTransition == null || !mTransition.isChangingLayout())) {
if (mTransition != null) {
mTransition.layoutChange(this);
}
super.layout(l, t, r, b);
} else {
// record the fact that we noop'd it; request layout when transition finishes
mLayoutCalledWhileSuppressed = true;
}
}

能看到,layout走到View的layout方法的条件有2:

  • 1.mSuppressLayout为false,也就是不设置抑制Layout方法
  • 2.mTransition LayoutTransition 布局动画为空或者没有改变才可以。

在Android中动画api中提供了LayoutTransition,用于对子View的加入和移除添加自定义的属性动画。

记住此时从DecorView传进来的layout四个参数,分别代表该View可以摆放的左部,顶部,右部,底部四个位置。但是不代表该View就是摆放到这个位置。

View layout

    public void layout(int l, int t, int r, int b) {
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}

int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;

boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b)

final boolean wasLayoutValid = isLayoutValid();

mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;

if (!wasLayoutValid && isFocused()) {
mPrivateFlags &= ~PFLAG_WANTS_FOCUS;
if (canTakeFocus()) {
clearParentsWantFocus();
} else if (getViewRootImpl() == null || !getViewRootImpl().isInLayout()) {

clearFocusInternal(null, /* propagate */ true, /* refocus */ false);
clearParentsWantFocus();
} else if (!hasParentWantsFocus()) {

clearFocusInternal(null, /* propagate */ true, /* refocus */ false);
}

} else if ((mPrivateFlags & PFLAG_WANTS_FOCUS) != 0) {
mPrivateFlags &= ~PFLAG_WANTS_FOCUS;
View focused = findFocus();
if (focused != null) {
// Try to restore focus as close as possible to our starting focus.
if (!restoreDefaultFocus() && !hasParentWantsFocus()) {
focused.clearFocusInternal(null, /* propagate */ true, /* refocus */ false);
}
}
}

if ((mPrivateFlags3 & PFLAG3_NOTIFY_AUTOFILL_ENTER_ON_LAYOUT) != 0) {
mPrivateFlags3 &= ~PFLAG3_NOTIFY_AUTOFILL_ENTER_ON_LAYOUT;
notifyEnterOrExitForAutoFillIfNeeded(true);
}
}

在这里可以分为如下几个步骤:

  • 1.判断PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT是否开启了。这个标志位打开的时机是在onMeasure步骤发现原来父容器传递下来的大小不变,就会设置老的测量结果在View中。在layout的步骤会先调用一次onMeasure继续遍历测量底层的子View的大小。
  • 2.判断isLayoutModeOptical是否开启了光学边缘模式。打开了则setOpticalFrame进行四个方向的边缘设置,否则则setFrame处理。用于判断是否需要更新四个方向的数值。
  • 3.如果发生了大小或者摆放的位置变化,则进行onLayout的回调。一般子类都会重写这个方法,进行进一步的摆放设置。
  • 4.如果需要显示滑动块,则初始化RoundScrollbarRenderer对象。这个对象实际上就一个封装好如何绘制绘制一个滑动块的自定义View。
  • 5.回调已经进行了Layout变化监听的OnLayoutChangeListener回调。
  • 6.当前View的layout的行为进行的同时没有另一个layout进行,说明当前的Layout行为是有效的。如果layout的行为是无效的,此时的View又获取了焦点则清除。如果此时是想要请求焦点,则清空焦点。
  • 7.通知AllFillManager进行相关的处理。

这里我们着重看看setFrame方法做了什么。

setFrame

    protected boolean setFrame(int left, int top, int right, int bottom) {
boolean changed = false;

if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
changed = true;
int drawn = mPrivateFlags & PFLAG_DRAWN;

int oldWidth = mRight - mLeft;
int oldHeight = mBottom - mTop;
int newWidth = right - left;
int newHeight = bottom - top;
boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);

invalidate(sizeChanged);

mLeft = left;
mTop = top;
mRight = right;
mBottom = bottom;
mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);

mPrivateFlags |= PFLAG_HAS_BOUNDS;


if (sizeChanged) {
sizeChange(newWidth, newHeight, oldWidth, oldHeight);
}

if ((mViewFlags & VISIBILITY_MASK) == VISIBLE || mGhostView != null) {

mPrivateFlags |= PFLAG_DRAWN;
invalidate(sizeChanged);

invalidateParentCaches();
}

mPrivateFlags |= drawn;

mBackgroundSizeChanged = true;
mDefaultFocusHighlightSizeChanged = true;
if (mForegroundInfo != null) {
mForegroundInfo.mBoundsChanged = true;
}

notifySubtreeAccessibilityStateChangedIfNeeded();
}
return changed;
}

就以setFrame为例子来看看其核心思想。实际上很简单:

  • 1.比较左上右下四个方向的数值是否发生了变化。如果发生了变化,则更新四个方向的大小,并判断整个需要绘制的区域是否发生了变化,把sizechange作为参数调用invalidate进行onDraw的刷新。
  • 2.获取mRenderNode这个硬件渲染的对象,并且设置这个渲染点的位置。
  • 3.调用sizeChange方法进行onSizeChange的回调:
    private void sizeChange(int newWidth, int newHeight, int oldWidth, int oldHeight) {
onSizeChanged(newWidth, newHeight, oldWidth, oldHeight);
if (mOverlay != null) {
mOverlay.getOverlayView().setRight(newWidth);
mOverlay.getOverlayView().setBottom(newHeight);
}

if (!sCanFocusZeroSized && isLayoutValid()
// Don't touch focus if animating
&& !(mParent instanceof ViewGroup && ((ViewGroup) mParent).isLayoutSuppressed())) {
if (newWidth <= 0 || newHeight <= 0) {
if (hasFocus()) {
clearFocus();
if (mParent instanceof ViewGroup) {
((ViewGroup) mParent).clearFocusedInCluster();
}
}
clearAccessibilityFocus();
} else if (oldWidth <= 0 || oldHeight <= 0) {
if (mParent != null && canTakeFocus()) {
mParent.focusableViewAvailable(this);
}
}
}
rebuildOutline();
}

如果当前不是ViewGroup且新的宽高小于0焦点则清除焦点,并且通知AccessibilityService。如果新的宽高大于0,则通知父容器焦点可集中。最后重新构建外框。

  • 4.如果判断到mGhostView不为空,且当前的View可见。则对mGhostView发出draw的刷新命令。并通知父容器也刷新。这里mGhostView实际上是一层覆盖层,作用和ViewOverLay相似。

到这里View和ViewGroup的onLayout似乎就看完了。但是还没有完。记得我们现在分析的是DecorView。因此我们看看DecorView在onLayout中做了什么。

DecorView onLayout

    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
getOutsets(mOutsets);
if (mOutsets.left > 0) {
offsetLeftAndRight(-mOutsets.left);
}
if (mOutsets.top > 0) {
offsetTopAndBottom(-mOutsets.top);
}
if (mApplyFloatingVerticalInsets) {
offsetTopAndBottom(mFloatingInsets.top);
}
if (mApplyFloatingHorizontalInsets) {
offsetLeftAndRight(mFloatingInsets.left);
}

updateElevation();
mAllowUpdateElevation = true;

if (changed && mResizeMode == RESIZE_MODE_DOCKED_DIVIDER) {
getViewRootImpl().requestInvalidateRootRenderNode();
}
}
  • 1.先调用了FrameLayout的onLayout的方法后,确定每一个子View的摆放位置。

  • 2.getOutsets方法获取mAttachInfo中的mOutSet区域。从上一篇文章的打印看来,mOutsets的区域实际上就是指当前屏幕最外层的四个padding大小。如果左右都大于0,则调用offsetLeftAndRight和offsetTopAndBottom进行设置。

  • 3.如果mApplyFloatingVerticalInsets或者mApplyFloatingHorizontalInsets为true,说明DecorView自己需要处理一次onApplyWindowInsets的回调。如果关闭FLAG_LAYOUT_IN_SCREEN标志位也就是非全屏模式,且宽或高是WRAP_CONTENT模式,则给横轴或者纵轴两端增加systemwindowInset的padding数值。

    public WindowInsets onApplyWindowInsets(WindowInsets insets) {
final WindowManager.LayoutParams attrs = mWindow.getAttributes();
mFloatingInsets.setEmpty();
if ((attrs.flags & FLAG_LAYOUT_IN_SCREEN) == 0) {

if (attrs.height == WindowManager.LayoutParams.WRAP_CONTENT) {
mFloatingInsets.top = insets.getSystemWindowInsetTop();
mFloatingInsets.bottom = insets.getSystemWindowInsetBottom();
insets = insets.inset(0, insets.getSystemWindowInsetTop(),
0, insets.getSystemWindowInsetBottom());
}
if (mWindow.getAttributes().width == WindowManager.LayoutParams.WRAP_CONTENT) {
mFloatingInsets.left = insets.getSystemWindowInsetTop();
mFloatingInsets.right = insets.getSystemWindowInsetBottom();
insets = insets.inset(insets.getSystemWindowInsetLeft(), 0,
insets.getSystemWindowInsetRight(), 0);
}
}
mFrameOffsets.set(insets.getSystemWindowInsets());
insets = updateColorViews(insets, true /* animate */);
insets = updateStatusGuard(insets);
if (getForeground() != null) {
drawableChanged();
}
return insets;
}
  • 4.当所有都Layout好之后,则调用updateElevation更新窗体的阴影面积。
    // The height of a window which has focus in DIP.
private final static int DECOR_SHADOW_FOCUSED_HEIGHT_IN_DIP = 20;
// The height of a window which has not in DIP.
private final static int DECOR_SHADOW_UNFOCUSED_HEIGHT_IN_DIP = 5;

private void updateElevation() {
float elevation = 0;
final boolean wasAdjustedForStack = mElevationAdjustedForStack;
final int windowingMode =
getResources().getConfiguration().windowConfiguration.getWindowingMode();
if ((windowingMode == WINDOWING_MODE_FREEFORM) && !isResizing()) {
elevation = hasWindowFocus() ?
DECOR_SHADOW_FOCUSED_HEIGHT_IN_DIP : DECOR_SHADOW_UNFOCUSED_HEIGHT_IN_DIP;

if (!mAllowUpdateElevation) {
elevation = DECOR_SHADOW_FOCUSED_HEIGHT_IN_DIP;
}
elevation = dipToPx(elevation);
mElevationAdjustedForStack = true;
} else if (windowingMode == WINDOWING_MODE_PINNED) {
elevation = dipToPx(PINNED_WINDOWING_MODE_ELEVATION_IN_DIP);
mElevationAdjustedForStack = true;
} else {
mElevationAdjustedForStack = false;
}

if ((wasAdjustedForStack || mElevationAdjustedForStack)
&& getElevation() != elevation) {
mWindow.setElevation(elevation);
}
}

能看到这个过程中,如果窗体模式是freedom模式(也就是更像电脑中可以拖动的窗体一样)且不是正在拖拽变化大小,则会根据是否窗体聚焦了来决定阴影的的四个方向的大小。注意如果是没有焦点则为5,有焦点则为20.这里面的距离并非是measure的时候增加当前测量的大小,而是在测量好的大小中继续占用内容空间,也就是相当于设置了padding数值。

如果窗体是WINDOWING_MODE_PINNED模式或者WINDOWING_MODE_FREEFORM模式,且elevation发生了变化则通过PhoneWindow.setElevation设置Surface的Insets数值。

  • 5.最后调用requestInvalidateRootRenderNode,通知ViweRootImpl中的硬件渲染对象ThreadRenderer进行刷新绘制。

整个过程中,有一系列函数用于更新摆放的偏移量,就以offsetLeftAndRight比较重要,看看是如何计算的。

offsetLeftAndRight

    public void offsetLeftAndRight(int offset) {
if (offset != 0) {
final boolean matrixIsIdentity = hasIdentityMatrix();
if (matrixIsIdentity) {
if (isHardwareAccelerated()) {

if (!matrixIsIdentity) {
invalidateViewProperty(false, true);
}
invalidateParentIfNeeded();
}
notifySubtreeAccessibilityStateChangedIfNeeded();
}
}

这个过程会判断offset如果不等于0才会进行计算。如果从RenderNode判断到存在单位变化矩阵(关于这个矩阵我们暂时不去聊,涉及到了硬件渲染的机制)。

  • 1.判断到如果有硬件加速,则直接调用invalidateViewProperty方法刷新。
  • 2.没有硬件加速,软件渲染的逻辑本质上也是一样的。

在这里能看到如果offset小于0,则把minLeft的大小增加。如果offset大于0,则增加maxRight的数值。计算出刷新的区域:

offset>0 maxRight = maxRight + mRight
offset<0 minLeft = minLeft + mLeft
刷新横向范围:maxRight - minLeft

计算出需要刷新的区域通过获取父布局的invalidateChild发送刷新命令。换算成图就是如下原理:

image.png

  • 3.如果没有单位变换矩阵,则调用invalidateViewProperty发送刷新命令
  • 4.最后mLeft和mRight增加offset。并把left和right同步数据到mRenderNode中。
  • 5.如果打开了硬件加速,又一次调用了invalidateViewProperty,并且调用invalidateParentIfNeededAndWasQuickRejected拒绝遍历刷新。
  • 6.关闭硬件加速,如果没有单位变换矩阵,则调用invalidateViewProperty。接着调用invalidateParentIfNeeded。

能看到这个过程中有几个方法被频繁的调用:

  • 1.invalidate
  • 2.invalidateChild
  • 3.invalidateViewProperty
  • 4.invalidateParentIfNeededAndWasQuickRejected
  • 5.invalidateParentIfNeeded

这几个方法决定了绘制需要更新的区域。

FrameLayout onLayout

 protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
layoutChildren(left, top, right, bottom, false /* no force left gravity */);
}

void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
final int count = getChildCount();

final int parentLeft = getPaddingLeftWithForeground();
final int parentRight = right - left - getPaddingRightWithForeground();

final int parentTop = getPaddingTopWithForeground();
final int parentBottom = bottom - top - getPaddingBottomWithForeground();

for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child.getVisibility() != GONE) {
final LayoutParams lp = (LayoutParams) child.getLayoutParams();

final int width = child.getMeasuredWidth();
final int height = child.getMeasuredHeight();

int childLeft;
int childTop;

int gravity = lp.gravity;
if (gravity == -1) {
gravity = DEFAULT_CHILD_GRAVITY;
}

final int layoutDirection = getLayoutDirection();
final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;

switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
case Gravity.CENTER_HORIZONTAL:
childLeft = parentLeft + (parentRight - parentLeft - width) / 2 +
lp.leftMargin - lp.rightMargin;
break;
case Gravity.RIGHT:
if (!forceLeftGravity) {
childLeft = parentRight - width - lp.rightMargin;
break;
}
case Gravity.LEFT:
default:
childLeft = parentLeft + lp.leftMargin;
}

switch (verticalGravity) {
case Gravity.TOP:
childTop = parentTop + lp.topMargin;
break;
case Gravity.CENTER_VERTICAL:
childTop = parentTop + (parentBottom - parentTop - height) / 2 +
lp.topMargin - lp.bottomMargin;
break;
case Gravity.BOTTOM:
childTop = parentBottom - height - lp.bottomMargin;
break;
default:
childTop = parentTop + lp.topMargin;
}

child.layout(childLeft, childTop, childLeft + width, childTop + height);
}
}
}

FrameLayout的onLayout方法很简单。实际上就是遍历每一个可见的子View处理其gravity。
可以分为横轴和竖轴两个方向进行处理:
横轴的处理方向:

  • 1.是判断到gravity是CENTER_HORIZONTAL,说明要横向居中:

每一个孩子左侧 = 父View的左侧位置 - (父亲的宽度 - 孩子的宽度)/ 2 + 孩子的marginLeft - 孩子的marginRight

保证孩子位置的居中。

  • 2.Gravity.RIGHT:

孩子的左侧= 父View的右侧 - 孩子宽度 - 孩子的marginRight

保证了孩子是从右边还是摆放位置。

  • 3.Gravity.LEFT

孩子的左侧= 父View的左侧 + 孩子的marginLeft

竖直方向上同理。

最后把每一个摆放好的孩子位置通过child.layout进行迭代执行子View的layout流程。


收起阅读 »

Java “优雅”地中断线程(原理篇)

前言之前有分析过如何优雅地中断线程,秉着"既要知其然,也要知其所以然"精神,本篇将从底层源码分析中断是如何工作的。 通过本篇文章,你将了解到:1、线程底层源码入口2、中断的作用3、Thread.sleep/Object.join/Object.wait 对中断...
继续阅读 »

前言

之前有分析过如何优雅地中断线程,秉着"既要知其然,也要知其所以然"精神,本篇将从底层源码分析中断是如何工作的。 通过本篇文章,你将了解到:

1、线程底层源码入口
2、中断的作用
3、Thread.sleep/Object.join/Object.wait 对中断的处理
4、究竟该如何停止线程
5、疑难点分析

1、线程底层源码入口

Java 层的Thread

以Thread.java里的方法Thread.interrupt()为例,最终调用了interrupt0()方法:

    private native void interrupt0();

可以看出,是native方法,接下来看看怎么找到其JNI实现。

JNI 入口

在Thread.c里定义了JNI 方法: image.png interrupt0()方法对应JVM_Interrupt()函数。 在jvm.cpp里:

#jvm.cpp
JVM_ENTRY(void, JVM_Interrupt(JNIEnv* env, jobject jthread))
JVMWrapper("JVM_Interrupt");

oop java_thread = JNIHandles::resolve_non_null(jthread);
MutexLockerEx ml(thread->threadObj() == java_thread ? NULL : Threads_lock);
JavaThread* thr = java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread));
if (thr != NULL) {
//调用Thread.cpp里的函数
Thread::interrupt(thr);
}
JVM_END

继续跟进Thread.cpp:

#Thread.cpp
void Thread::interrupt(Thread* thread) {
//调用os里的函数
os::interrupt(thread);
}

最终调用了os里的函数。

2、中断的作用

中断源码分析

入口找到了,接着继续深入分析。上面分析到了os::interrupt(thread),os是区分系统的,此处以Linux系统为例:

#os_linux.cpp
void os::interrupt(Thread* thread) {
...
OSThread* osthread = thread->osthread();

//中断标记位没有设置
if (!osthread->interrupted()) {
//则设置中断标记位为true
osthread->set_interrupted(true);
OrderAccess::fence();
ParkEvent * const slp = thread->_SleepEvent ;
//唤醒线程,对应sleep挂起
if (slp != NULL) slp->unpark() ;
}

//唤醒线程,对应wait/join 操作挂起等
if (thread->is_Java_thread())
((JavaThread*)thread)->parker()->unpark();

//唤醒线程,对应synchronized 获取锁挂起
ParkEvent * ev = thread->_ParkEvent ;
if (ev != NULL) ev->unpark() ;
}

显然,在Java 层调用Thread.interrupt()方法,最终底层完成了两件事:

1、将中断标记位设置为true。
2、将挂起的线程唤醒。

image.png

中断状态查询

Java 层的Thread.java里提供了两个方法来查询中断标记位的值,分别是:

#Thread.java
//成员方法
public boolean isInterrupted() {
return isInterrupted(false);
}

//静态方法
public static boolean interrupted() {
return currentThread().isInterrupted(true);
}

无论是成员方法还是静态方法,最终都调用了Thread.isInterrupted(xx)方法:

#Thread.java
//ClearInterrupted 表示是否清空中断标记位
private native boolean isInterrupted(boolean ClearInterrupted);

可以看出:

1、成员方法isInterrupted()没有清空中断标记位。
2、静态方法interrupted()清空了中断标记位。

继续跟进isInterrupted(xx)方法,由上面跟踪的入口经验最终有如下代码:

#Thread.cpp
bool Thread::is_interrupted(Thread* thread, bool clear_interrupted) {
return os::is_interrupted(thread, clear_interrupted);
}

#os_linux.cpp
bool os::is_interrupted(Thread* thread, bool clear_interrupted) {
OSThread* osthread = thread->osthread();
//查询当前中断值
bool interrupted = osthread->interrupted();

if (interrupted && clear_interrupted) {
//如果参数clear_interrupted 为true,表示要清空标记位
//则设置标记位为false
osthread->set_interrupted(false);
}

//返回查询到的中断标记位的值
return interrupted;
}

因此,Thread.isInterrupted(xx)方法的作用是:

1、查询当前线程的中断标记位的值。
2、根据参数决定是否重置中断标记。

而Thread.isInterrupted(xx) 是私有方法,因此Thread.java 对外提供了两种方法:Thread.isInterrupted()(成员方法)和Thread.interrupted(静态方法)。

3、Thread.sleep/Object.join/Object.wait 对中断的处理

中断线程Demo

既然知道了中断的作用,接着来看看如何使用Thread.interrupt()来中断线程,先来看看个Demo。

public class TestThread {
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("t1 is alive " + System.currentTimeMillis());
}
}
});

t1.start();

try {
//保证t1运行一会
Thread.sleep(2000);
//中断t1
t1.interrupt();
} catch (InterruptedException e) {
e.printStackTrace();
}

while (true) {
System.out.println("t1 interrupt status:" + t1.isInterrupted() + " t1 isAlive:" + t1.isAlive() + " " + System.currentTimeMillis());
}
}
}

该Demo的目的是:

1、先开启线程t1,让t1不断循环打印时间。
2、在另一个线程(主线程)中断t1,并不断查询t1中断标记位的值。

如果没有分析上面的中断原理,你可能会认为t1应该被中断退出了循环。实际上从打印结果可知,t1的中断标记位被设置为true,然而t1并没有退出循环。这结果符合我们的原理分析,因为中断线程的操作底层只做了两件事:设置中断标记位和唤醒线程。 在上面的例子里,t1并没有被挂起,因此唤醒线程没啥实际意义。 总而言之:

"中断线程"这个词听起来比较霸气,让人误以为就是让一个线程停止运行。实际上线程的停止与否并不是它控制的,而是线程执行过程中主动退出或是有异常抛出。

将上面的Demo稍加修改如下:

public class TestThread {
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
while (true) {
System.out.println("t1 is alive " + System.currentTimeMillis());
//睡眠一会再执行
Thread.sleep(1000);
}
} catch (InterruptedException e) {
System.out.println("t1 catch exception:" + e.getMessage());
}
}
});

t1.start();

try {
//保证t1运行一会
Thread.sleep(2000);
//中断t1
t1.interrupt();
} catch (InterruptedException e) {
e.printStackTrace();
}

while (true) {
System.out.println("t1 interrupt status:" + t1.isInterrupted() + " t1 isAlive:" + t1.isAlive() + " " + System.currentTimeMillis());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

相比上一个Demo,仅仅是在t1里增加1s的睡眠时间,打印结果如下: image.png 可以看出,t1被中断后因为抛出了异常而退出了循环,其中断标记位为false,线程已经停止了运行。

Thread.sleep 源码解析

通过比对上面两个Demo可知,引起结果不同是因为增加了Thread.sleep(xx)方法,因此来看看它内部到底做了什么。

#jvm.cpp
JVM_ENTRY(void, JVM_Sleep(JNIEnv* env, jclass threadClass, jlong millis))
JVMWrapper("JVM_Sleep");
//在睡眠之前先检查是否已经发生了中断,若是则抛出中断异常
if (Thread::is_interrupted (THREAD, true) && !HAS_PENDING_EXCEPTION) {
THROW_MSG(vmSymbols::java_lang_InterruptedException(), "sleep interrupted");
}
...

if (millis == 0) {
...
} else {
ThreadState old_state = thread->osthread()->get_state();
thread->osthread()->set_state(SLEEPING);
if (os::sleep(thread, millis, true) == OS_INTRPT) {
//睡眠结束后,判断是否已经发生了中断,若是则抛出中断异常
THROW_MSG(vmSymbols::java_lang_InterruptedException(), "sleep interrupted");
}
}
thread->osthread()->set_state(old_state);
}
...
JVM_END

#os_linux.cpp
int os::sleep(Thread* thread, jlong millis, bool interruptible) {
assert(thread == Thread::current(), "thread consistency check");

//_SleepEvent 是ParkEvent类型
ParkEvent * const slp = thread->_SleepEvent ;
...
//interruptible 表示是否支持中断,默认支持
if (interruptible) {
...
for (;;) {
//此处是死循环,退出依靠return或者break
if (os::is_interrupted(thread, true)) {
//再次判断是否已经发生中断,若是则返回OS_INTRPT
//该值在外层判断
return OS_INTRPT;
}

//时间耗尽,则退出
if(millis <= 0) {
return OS_OK;
}
...
{
//挂起线程
slp->park(millis);
//线程被唤醒后继续循环
}
}
} else {
for (;;) {
...
//时间耗尽,则退出
if(millis <= 0) break ;

prevtime = newtime;
slp->park(millis);
}
return OS_OK ;
}
}

可以看出,Thread.sleep(xx)方法作用如下:

1、线程挂起一定的时间,时间到达后继续执行循环。
2、interruptible==true场景下,在循环里判断是否发生了中断,若是,则抛出中断异常。

再来分析上面的Demo:

1、线程调用Thread.sleep(xx)进行睡眠,此时线程被挂起。
2、外部调用Thread.interrupt()中断该线程,此时中断标记位值为true。
3、线程被唤醒,唤醒后判断是否发生了中断(通过中断标记位),若是则抛出异常。

虽然Thread.interrupt()没有直接停止线程,但是可以利用中断标记位来查看是否发生过中断的动作,根据这个动作来决定是否停止线程的执行。 而对于Thread.sleep(xx)来说,作为一个公共方法,当检测到中断时,抛出中断异常让外部处理。

以上分析了Thread.sleep(xx)原理,还有个小细节: 当外界调用Thread.interrupt()并捕获了中断异常的时候,此时线程的中断标记位的值位false,这个在哪修改的呢? 判断中断标记时使用了如下代码:

os::is_interrupted(thread, true)

第二个参数表示重置当前中断标记位的值,该函数上面已经分析过。

调用Object.join/Object.wait 挂起线程,此时线程若被中断,表现与Thread.sleep差不多,此处就不再展开分析了。

4、究竟该如何停止线程

还是来看Demo:

public class TestThread {
static volatile boolean isCancelThread = false;
public static void main(String args[]) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while(!isCancelThread || !Thread.currentThread().isInterrupted()) {
try {
doSomething1();
doSomething2();
} catch (Exception e) {
if (e instanceof InterruptedException) {
isCancelThread = true;
}
}
}
}
});

t1.start();

//另一个线程两种方式停止线程
//1、设置isCancelThread = true
//2、调用t1.interrupt()
}

private static void doSomething1() {
//具体逻辑
}

private static void doSomething2() {
//具体逻辑
}
}

来分析Demo逻辑: doSomething1() 、doSomething2() 可能是自己写的方法,也可能是其它人提供的方法,它们内部可能会使得线程阻塞。

第一: 外部通过设置isCancelThread 来标记是否让线程停止运行。这种写法有个缺陷,若是线程在调用doSomething1() 或doSomething2()时阻塞,那么将不会立即检测isCancelThread的值,也即是不能立即停止线程。

第二 针对doSomething1() 或doSomething2()可能阻塞的问题,外部通过使用 Thread.interrupt()中断线程,此时需要捕获异常,捕获到了中断异常意味着可以停止线程运行了。 当然,如果你不想额外使用isCancelThread标记,可以直接判断中断标记位:Thread.currentThread().isInterrupted(),此时Demo再改改:

    while(!Thread.currentThread().isInterrupted()) {
try {
doSomething1();
doSomething2();
} catch (Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
}

为什么要额外在catch里增加中断动作呢?原因是中断时可能会遇到sleep/wait/join 等方法将中断标记位的值置为false,此处再次中断是为了让while感知到中断已经发生过了,从而退出循环。

5、疑难点分析

网上有些文章将线程能不能中断总结为:

1、若是线程处在RUNNABLE状态,则interrupt不能中断线程。 2、若是线程处在WAITING/WAITING 则interrupt能中断线程。

通过上面的分析,相信你已经知道了这种说法并不严谨。 来看个Demo,Thread处在RUNNABLE时调用interrupt()中断:

public class TestThread {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
doSomething();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();


Thread.sleep(2000);

t1.interrupt();
System.out.println("main thread interrupt Thread t1");

}

private static void doSomething() throws InterruptedException{
while(true) {
System.out.println("thread state:" + Thread.currentThread().getState() + " " + System.currentTimeMillis());
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException();
}
}
}
}

显然Thread1 一直处在RUNNABLE 状态,但是调用interrupt()后Thread1停止了。 再来看来看个Demo,Thread处在WAITING时调用interrupt()中断:

public class TestThread {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
doSomething();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();

long count = 100000;
while(count >= 0) {
System.out.println("thread state:" + t1.getState() + " " + System.currentTimeMillis());
count--;
}

t1.interrupt();
System.out.println("main thread interrupt Thread t1");
while (true) {
Thread.sleep(1000);
System.out.println("after interrupt thread state:" + t1.getState() + " " + System.currentTimeMillis());
}

}

private static void doSomething() throws InterruptedException{
while(true) {
LockSupport.park();
}
}
}

显然Thread1 一直处在WAITING 状态,但是调用interrupt()后Thread1却没停止。 问题的根源是:

线程停止与否要看线程执行体里的代码(方法/代码块)有没有检测中断,并且检测之后是否有处理中断(抛出异常/return 出正常流程)。

因此更严谨的说法是:线程是否能被中断取决于它是否检测并处理了中断状态。 这在AQS里实现可中断锁/不可中断锁时会充分体现。

本文基于JDK 1.8,运行环境也是jdk1.8。

收起阅读 »

讲讲ViewGroup的setPersistentDrawingCache方法

这是一篇采坑文章,灵感来源于博主某篇文章中与大佬的聊天前言记得在三年前,还在上一个学校,学生时代的时候,接过一个外包的Android单,里面有个需求是在一个Activity中根据用户的点击反复的执行两个动画以达到比较好的交互效果。当时在网上没有找到类似的效果,...
继续阅读 »

这是一篇采坑文章,灵感来源于博主某篇文章中与大佬的聊天


前言

记得在三年前,还在上一个学校,学生时代的时候,接过一个外包的Android单,里面有个需求是在一个Activity中根据用户的点击反复的执行两个动画以达到比较好的交互效果。当时在网上没有找到类似的效果,最后还是在Android官方Demo里找到的。

而本人对其案例中setPersistentDrawingCache方法的理解一直是在对其进行优化。

mContainer.setPersistentDrawingCache(ViewGroup.PERSISTENT_ANIMATION_CACHE);

并且在后续的自己开发APP中,甚至是拿了软著权准备上架的那几款APP相同情景下一直使用此方法。今天和大佬在另一篇文章中聊到性能消耗,于是测试了下setPersistentDrawingCache加与不加的性能消耗,结果发现貌似不加效果还好一点。


一、看看Demo中给出的注释

// Since we are caching large views, we want to keep their cache
//由于我们缓存的是大视图,我们希望保留它们的缓存

开始想着是动画的强度不够,加大了动画的强度,结果不加setPersistentDrawingCache还是好一点,于是去Android官方文档查看了下,才明白。

二、Android官方文档给出的介绍

此方法在APl级别28中已弃用,随着APl 11中硬件加速渲染的引入,视图绘图缓存在很大程度上已经过时了。在使用硬件加速时,中间缓存层基本上是不必要的,并且由于创建和更新该层的成本,很容易导致性能的净损失。在极少数情况下,缓存层是有用的,比如alpha动画。View.setLaverTvpe (int。android.araphics.Paint)通过硬件渲染处理这个问题。对于视图层次结构或单个视图的一小部分的软件渲染快照,建议从位图或图片创建一个Canvas,并在视图上调用View. draw(android.graphics.Canvas)。然而,这些软件渲染的用法是不鼓励的,并且与硬件渲染特性(如Config)存在兼容性问题。硬件位图,实时阴影,轮廓剪辑。对于Ul反馈报告或单元测试的屏幕截图,建议使用PixelCopy APl。

三、丢一个案例源码(此案例为官方Demo源码,根据个人修改了点效果)

代码比较简单就不解释了

1.3d动画类

public class Rotate3dAnimation extends Animation {
private final float mFromDegrees;
private final float mToDegrees;
private final float mCenterX;
private final float mCenterY;
private final float mDepthZ;
private final boolean mReverse;
private Camera mCamera;


public Rotate3dAnimation(float fromDegrees, float toDegrees,
float centerX, float centerY, float depthZ, boolean reverse) {
mFromDegrees = fromDegrees;
mToDegrees = toDegrees;
mCenterX = centerX;
mCenterY = centerY;
mDepthZ = depthZ;
mReverse = reverse;
}

@Override
public void initialize(int width, int height, int parentWidth, int parentHeight) {
super.initialize(width, height, parentWidth, parentHeight);
mCamera = new Camera();
}

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
final float fromDegrees = mFromDegrees;
float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);

final float centerX = mCenterX;
final float centerY = mCenterY;
final Camera camera = mCamera;

final Matrix matrix = t.getMatrix();

camera.save();
if (mReverse) {
camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime);
} else {
camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime));
}
camera.rotateY(degrees);
camera.getMatrix(matrix);
camera.restore();

matrix.preTranslate(-centerX, -centerY);
matrix.postTranslate(centerX, centerY);
}
}

2.Activity类

public class Transition3d extends Activity implements
AdapterView.OnItemClickListener, View.OnClickListener {
private ListView mPhotosList;
private ViewGroup mContainer;
private ImageView mImageView;

//item-name
private static final String[] PHOTOS_NAMES = new String[] {
"百度关键词",
"微信公众号",
"微信小程序",
"个人网站",
"掘金",
"同id:计蒙不吃鱼"
};
//item-img
private static final int[] PHOTOS_RESOURCES = new int[] {
R.drawable.aaaa,
R.drawable.aaaa1,
R.drawable.aaaa1,
R.drawable.aaaa,
R.drawable.aaaa,
R.drawable.aaaa

};

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

setContentView(R.layout.animations_main_screen);

mPhotosList = (ListView) findViewById(android.R.id.list);
mImageView = (ImageView) findViewById(R.id.picture);
mContainer = (ViewGroup) findViewById(R.id.container);

// 准备列表视图
final ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_list_item_1, PHOTOS_NAMES);
mPhotosList.setAdapter(adapter);
mPhotosList.setOnItemClickListener(this);
// 准备图片
mImageView.setClickable(true);
mImageView.setFocusable(true);
mImageView.setOnClickListener(this);


//由于我们缓存的是大视图,我们希望在每个动画之间保持它们的缓存
mContainer.setPersistentDrawingCache(ViewGroup.PERSISTENT_ANIMATION_CACHE);
}

/**
* 在容器视图上设置一个新的3D旋转。
*
* @param position 单击该项以显示图片,或单击-1以显示列表
* @param start 旋转必须开始的起始角度
* @param end •旋转的结束角度
*/

private void applyRotation(int position, float start, float end) {
//找到容器的中心
final float centerX = mContainer.getWidth() / 2.0f;
final float centerY = mContainer.getHeight() / 2.0f;

//使用提供的参数创建一个新的3D旋转
// 动画监听器用于触发网络动画
final Rotate3dAnimation rotation =
new Rotate3dAnimation(start, end, centerX, centerY, 310.0f, true);
rotation.setDuration(500);
rotation.setFillAfter(true);
rotation.setInterpolator(new AccelerateInterpolator());
rotation.setAnimationListener(new DisplayNextView(position));

mContainer.startAnimation(rotation);
}

public void onItemClick(AdapterView<?> parent, View v, int position, long id) {
//预加载图像然后开始动画
mImageView.setImageResource(PHOTOS_RESOURCES[position]);
applyRotation(position, 0, 90);
}

public void onClick(View v) {
applyRotation(-1, 180, 90);
}

/**
这class收听动画前半部分的结尾部分。•当容器旋转90度,因此不可见时,它会发布一个新的动作,有效地交换视图。
*/

private final class DisplayNextView implements Animation.AnimationListener {
private final int mPosition;

private DisplayNextView(int position) {
mPosition = position;
}

public void onAnimationStart(Animation animation) {
}

public void onAnimationEnd(Animation animation) {
mContainer.post(new SwapViews(mPosition));
}

public void onAnimationRepeat(Animation animation) {
}
}

/**
这个类负责交换视图并启动第二个视图
动画的一半。
*/

private final class SwapViews implements Runnable {
private final int mPosition;

public SwapViews(int position) {
mPosition = position;
}

public void run() {
final float centerX = mContainer.getWidth() / 2.0f;
final float centerY = mContainer.getHeight() / 2.0f;
Rotate3dAnimation rotation;

if (mPosition > -1) {
mPhotosList.setVisibility(View.GONE);
mImageView.setVisibility(View.VISIBLE);
mImageView.requestFocus();

rotation = new Rotate3dAnimation(90, 180, centerX, centerY, 310.0f, false);
} else {
mImageView.setVisibility(View.GONE);
mPhotosList.setVisibility(View.VISIBLE);
mPhotosList.requestFocus();

rotation = new Rotate3dAnimation(90, 0, centerX, centerY, 310.0f, false);
}

rotation.setDuration(500);
rotation.setFillAfter(true);
rotation.setInterpolator(new DecelerateInterpolator());

mContainer.startAnimation(rotation);
}
}

}

3.布局文件

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">


<ListView
android:id="@android:id/list"
android:persistentDrawingCache="animation|scrolling"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layoutAnimation="@anim/layout_bottom_to_top_slide" />


<ImageView
android:id="@+id/picture"
android:scaleType="fitCenter"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />


</FrameLayout>

4.Listview的载入动画

  • layout_bottom_to_top_slide.xml
<layoutAnimation xmlns:android="http://schemas.android.com/apk/res/android"
android:delay="30%"
android:animationOrder="reverse"
android:animation="@anim/slide_right" />

  • slide_right.xml
<set xmlns:android="http://schemas.android.com/apk/res/android" android:interpolator="@android:anim/accelerate_interpolator">
<translate android:fromXDelta="-100%p" android:toXDelta="0"
android:duration="@android:integer/config_shortAnimTime" />

</set>

5.效果图

Video_20210831_035149_726.gif

收起阅读 »

Android 开发小总结

1、Java 用FileReader 和 FileWriter 进行文件读写FileReader 和FileWriter是对文件进行读取和写入的;具体流程://文件路径 String path = Environment.getExternalStorageD...
继续阅读 »

1、Java 用FileReader 和 FileWriter 进行文件读写

FileReader 和FileWriter是对文件进行读取和写入的;

具体流程:

//文件路径
String path = Environment.getExternalStorageDirectory().getAbsolutePath()+File.separator+"bz_Contact"+File.separator+"bz_contact.txt";

FileWriter.java

//根据给定的 File 对象构造一个 FileWriter 对象。如果第二个参数为 true,则将字节写入文件末尾处,而不是写入文件开始处。

FileWriter writer = new FileWriter(file,true);
//写入类
BufferWriter bw = new BufferWriter(writer);
//写入方法
bw.writer("XXXXXXXXXXXXXXXXX");
//清空管道
bw.flush();
//关闭管道
bw.close();

读取 FileReader.java

//创建 读取器
FileReader fr= new FileReader(file);

//字符串结果
StringBuffer sb = new StringBuffer();

//读取工具
BufferReader reader = new BufferReader(fr);

//按行读取
String line = reader.readerLine();

//遍历读取
while(line != null){
//追加
sb.appen(line);
//读取
line = reader.readerLine();
}

//清空管道
reader.flush();

//关闭管道
reader.close();

//或者使用
BufferedReader buffer = new BufferedReader(new InputStreamReader(inputStream,"utf-8"));
String str = buffer.readLine();
System.out.println(str);
buffer.close();

如果要清空文件夹中所有数据

/**
* 清除之前的所有数据
*/
FileWriter writer = new FileWriter(file);
writer.write("");
writer.flush();
writer.close();

//判断此文件是否还有数据
FileInputStream inputStream = new FileInputStream(file);
//根据这个size来判断,如果是0,表示没有数据
int size = inputStream.available();
Log.e("清除缓存","清除缓存"+size);

这个是判断文件里面是否有数据,如有数据就先清除,之后在新增

public void saveHtml(String html){
try {
if(Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
String tag = Environment.getExternalStorageDirectory().getAbsolutePath()+File.separator+"news.html";
File file = new File(tag);
if(!file.exists()){
file.createNewFile();
}
FileInputStream inputStream = new FileInputStream(file);
if(inputStream.available()>0){
FileWriter writer = new FileWriter(file); BufferedWriter bufferedWriter = new BufferedWriter(writer);
bufferedWriter.write("");
bufferedWriter.flush();
bufferedWriter.close();
writer.close();
writer = null;
}
FileOutputStream outputStream = new FileOutputStream(file);
FileWriter writer = new FileWriter(file);
BufferedWriter bufferedWriter = new BufferedWriter(writer);
bufferedWriter.write(html);
bufferedWriter.flush();
bufferedWriter.close();
writer.close();
writer = null;
}
} catch (Exception e) {
// TODO: handle exception
e.printStackTrace();
}
}

2、获取手机屏幕的几种方法

1、
WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
int width = wm.getDefaultDisplay().getWidth();
int height = wm.getDefaultDisplay().getHeight();
2、
WindowManager wm = this.getWindowManager();
int width = wm.getDefaultDisplay().getWidth();
int height = wm.getDefaultDisplay().getHeight();
3、
DisplayMetrics  dm = new DisplayMetrics();     
getWindowManager().getDefaultDisplay().getMetrics(dm);     
intscreenWidth = dm.widthPixels;    
intscreenHeight = dm.heightPixels;

3、LinkedHashMap与Map的区别以及遍历MAP的几种中方式

  • public class LinkedHashMap<K,V>extends HashMap<K,V>implements Map<K,V>

  • Map 接口的哈希表和链接列表实现,具有可预知的迭代顺序。此实现与 HashMap 的不同之处在于,后者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序通常就是将键插入到映射中的顺序(插入顺序)。注意,如果在映射中重新插入 键,则插入顺序不受影响。(如果在调用 m.put(k, v) 前 m.containsKey(k) 返回了 true,则调用时会将键 k 重新插入到映射 m 中。)

  • 遍历Map的四种方法

public static void main(String[] args) {
Map map = new HashMap();
map.put("1", "value1");
map.put("2", "value2");
map.put("3", "value3");

//第一种:普遍使用,二次取值
System.out.println("通过Map.keySet遍历key和value:");
for (String key : map.keySet()) {
System.out.println("key= "+ key + " and value= " + map.get(key));
}

//第二种
System.out.println("通过Map.entrySet使用iterator遍历key和value:");
Iterator> it = map.entrySet().iterator();
while (it.hasNext()) {
Map.Entry entry = it.next();
System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
}

//第三种:推荐,尤其是容量大时
System.out.println("通过Map.entrySet遍历key和value");
for (Map.Entry entry : map.entrySet()) {
System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue());
}

//第四种
System.out.println("通过Map.values()遍历所有的value,但不能遍历key");
for (String v : map.values()) {
System.out.println("value= " + v);
}
 }
收起阅读 »

iOS 优雅的处理网络数据,你真的会吗?不如看看这篇.

相信大家平时在用 App 的时候, 往往有过这样的体验,那就是加载网络数据等待的时间过于漫长,滚动浏览时伴随着卡顿,甚至在没有网络的情况下,整个应用处于不可用状态。那么我们该怎么去提高用户体验,保证用户没有漫长的等待感,还可以轻松自在的享受等待,对加载后的内容...
继续阅读 »
相信大家平时在用 App 的时候, 往往有过这样的体验,那就是加载网络数据等待的时间过于漫长,滚动浏览时伴随着卡顿,甚至在没有网络的情况下,整个应用处于不可用状态。那么我们该怎么去提高用户体验,保证用户没有漫长的等待感,还可以轻松自在的享受等待,对加载后的内容有明确的预期呢?

案例分享

在现代的工作生活中,手机早已不是单纯的通信工具了,它更像是一个集办公,娱乐,消费的终端,潜移默化的成为了我们生活的一部分。所以作为 iOS 开发者的我们,在日常的开发中,也早已不是处理显示零星的数据这么简单,为了流量往往我们需要在 App 里显示大量有价值的信息来吸引用户,如何优雅的显示这些海量的数据,考量的就是你的个人经验了。

正如大多数 iOS 开发人员所知,显示滚动数据是构建移动应用中常见的任务,Apple 的 SDK 提供了 UITableView 和 UICollectionVIew 这俩大组件来帮助执行这样的任务。但是,当需要显示大量数据时,确保平滑如丝的滚动可能会非常的棘手。所以今天正好趁这个机会,和大家分享一下处理大量可滚动数据方面的个人经验。

在这篇文章中,你将会学到以下内容:

1.让你的 App 可以无限滚动(infinite scrolling),并且滚动数据无缝加载

2.让你的 App 数据滚动时避免卡顿,实现平滑如丝的滚动

3.异步存储(Cache)和获取图像,来使你的 App 具有更高的响应速度

无限滚动,无缝加载

提到列表分页,相信大家第一个想到的就是 MJRefresh,用于上拉下拉来刷新数据,当滚动数据到达底部的时候向服务器发送请求,然后在控件底部显示一个 Loading 动画,待请求数据返回后,Loading 动画消失,由 UITableView 或者 UICollectionView 控件继续加载这些数据并显示给用户,效果如下图所示:




在这种情况下就造成了一种现象,那就是 App 向服务器请求数据到数据返回这段时间留下了一个空白,如果在网络差的情况下,这段空白的时间将会持续,这给人的体验会很不好。那该如何去避免这种现象呢!或者说我们能否去提前获取到其余的数据,在用户毫无感知的情况下把数据请求过来,看上去就像无缝加载一样呢!

答案当然是肯定的!

为了改善应用程序体验,在 iOS 10 上,Apple 对 UICollectionView 和 UITableView 引入了 Prefetching API,它提供了一种在需要显示数据之前预先准备数据的机制,旨在提高数据的滚动性能。

首先,我先和大家介绍一个概念:无限滚动,无限滚动是可以让用户连续的加载内容,而无需分页。在 UI 初始化的时候 App 会加载一些初始数据,然后当用户滚动快要到达显示内容的底部时加载更多的数据。

多年来,像 Instagram, Twitter 和 Facebook 这样的社交媒体公司都使这种技术。如果查看他们的 App ,你就可以看到无限滚动的实际效果,这里我就给大伙展示下 Instagram 的效果吧!


如何实现

由于 Instagram 的 UI 过于复杂,在这我就不去模仿实现了,但是我模仿了它的加载机制,同样的实现了一个简单的数据无限滚动和无缝加载的效果。

简单的说下我的思路:

先自定义一个 Cell 视图,这个视图由一个 UILabel 和 一个 UIImageView 构成,用于显示文本和网络图片;然后模拟网络请求来获取数据,注意该步骤一定是异步执行的;最后用 UITableView 来显示返回的数据,在 viewDidLoad 中先请求网络数据来获取一些初始化数据,然后再利用 UITableView 的 Prefetching API 来对数据进行预加载,从而来实现数据的无缝加载。

那关于无限滚动该如何实现呢!其实这个无限滚动并不是真正意义上的永无止尽,严格意义上来讲它是有尽头的,只不过这个功能背后的数据是不可估量的,只有大量的数据做支持才能让应用一直不断的从服务端获取数据。

正常情况下,我们在构建 UITableView 这个控件的时候,需要对它的行数(numsOfRow)做一个初始化,这个行数对我们实现无限加载和无缝加载是一个很关键的因素,假设我们每次根据服务端返回的数据量去更新 UITableView 的行数并 Reload,那我之前说的 Prefetching API 在这种情况下就失去作用了,因为它起作用的前提是要保证预加载数据时 UITableView 当前的行数要小于它的总行数。当然前者也可以实现数据加载,但它的效果就不是无缝加载,它在每次加载数据的时候都会有一个 Loading 等待的时间。

回到我上面所说的无限滚动, 其实实现起来并不难,正常情况下,我们向服务端请求大量相同类型的数据的时候,都会提供一个接口,我称之为分页请求接口,该接口在每次数据返回的时候,都会告诉客户端总共有多少页数据,每页的数据量是多少,当前是第几页,这样我们就能计算出来总共的数据有多少,而这就是 UITableView 的总行数。

响应数据的示范如下(为清楚起见,它仅显示与分页有关的字段):

{

"has_more": true,
"page": 1,
"total": 84,
"items": [

...
...
]
}

下面,我就用代码来一步步的实现它吧!

模拟分页请求

由于没有找到合适的分页测试接口,于是我自己模拟一了一个分页请求接口,每次调用该接口的时候都延时 2s 来模拟网络请求的状态,代码如下:

 func fetchImages() {
guard !isFetchInProcess else {
return
}

isFetchInProcess = true
// 延时 2s 模拟网络环境
print("+++++++++++ 模拟网络数据请求 +++++++++++")
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 2) {
print("+++++++++++ 模拟网络数据请求返回成功 +++++++++++")
DispatchQueue.main.async {
self.total = 1000
self.currentPage += 1
self.isFetchInProcess = false
// 初始化 30个 图片
let imagesData = (1...30).map {
ImageModel(url: baseURL+"\($0).png", order: $0)
}
self.images.append(contentsOf: imagesData)

if self.currentPage > 1 {
let newIndexPaths = self.calculateIndexPathsToReload(from: imagesData)
self.delegate?.onFetchCompleted(with: newIndexPaths)
} else {
self.delegate?.onFetchCompleted(with: .none)
}
}
}
}

数据回调处理:

extension ViewController: PreloadCellViewModelDelegate {

func onFetchCompleted(with newIndexPathsToReload: [IndexPath]?) {
guard let newIndexPathsToReload = newIndexPathsToReload else {
tableView.tableFooterView = nil
tableView.reloadData()
return
}

let indexPathsToReload = visibleIndexPathsToReload(intersecting: newIndexPathsToReload)
indicatorView.stopAnimating()
tableView.reloadRows(at: indexPathsToReload, with: .automatic)
}

func onFetchFailed(with reason: String) {
indicatorView.stopAnimating()
tableView.reloadData()
}
}

预加载数据

首先如果你想要 UITableView 预加载数据,你需要在 viewDidLoad() 函数中插入如下代码,并且请求第一页的数据:

override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
...
tableView.prefetchDataSource = self
...
// 模拟请求图片
viewModel.fetchImages()
}

然后,你需要实现 UITableViewDataSourcePrefetching 的协议,它的协议里包含俩个函数:


// this protocol can provide information about cells before they are displayed on screen.

@protocol UITableViewDataSourcePrefetching <NSObject>

@required

// indexPaths are ordered ascending by geometric distance from the table view
- (void)tableView:(UITableView *)tableView prefetchRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

@optional

// indexPaths that previously were considered as candidates for pre-fetching, but were not actually used; may be a subset of the previous call to -tableView:prefetchRowsAtIndexPaths:
- (void)tableView:(UITableView *)tableView cancelPrefetchingForRowsAtIndexPaths:(NSArray<NSIndexPath *> *)indexPaths;

@end

第一个函数会基于当前滚动的方向和速度对接下来的 IndexPaths 进行 Prefetch,通常我们会在这里实现预加载数据的逻辑。

第二个函数是一个可选的方法,当用户快速滚动导致一些 Cell 不可见的时候,你可以通过这个方法来取消任何挂起的数据加载操作,有利于提高滚动性能, 在下面我会讲到。

实现这俩个函数的逻辑代码为:

extension ViewController: UITableViewDataSourcePrefetching {
// 翻页请求
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}
if needFetch {
// 1.满足条件进行翻页请求
indicatorView.startAnimating()
viewModel.fetchImages()
}

for indexPath in indexPaths {
if let _ = viewModel.loadingOperations[indexPath] {
return
}

if let dataloader = viewModel.loadImage(at: indexPath.row) {
print("在 \(indexPath.row) 行 对图片进行 prefetch ")
// 2 对需要下载的图片进行预热
viewModel.loadingQueue.addOperation(dataloader)
// 3 将该下载线程加入到记录数组中以便根据索引查找
viewModel.loadingOperations[indexPath] = dataloader
}
}
}

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){
// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费
indexPaths.forEach {
if let dataLoader = viewModel.loadingOperations[$0] {
print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")
dataLoader.cancel()
viewModel.loadingOperations.removeValue(forKey: $0)
}
}
}
}

最后,再加上俩个有用的方法该功能就大功告成了:

    // 用于计算 tableview 加载新数据时需要 reload 的 cell
func visibleIndexPathsToReload(intersecting indexPaths: [IndexPath]) -> [IndexPath] {
let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows ?? []
let indexPathsIntersection = Set(indexPathsForVisibleRows).intersection(indexPaths)
return Array(indexPathsIntersection)
}

// 用于确定该索引的行是否超出了目前收到数据的最大数量
func isLoadingCell(for indexPath: IndexPath) -> Bool {
return indexPath.row >= (viewModel.currentCount)
}

见证时刻的奇迹到了,请看效果:




通过日志,我们也可以清楚的看到,在滚动的过程中是有 Prefetch 和 CancelPrefetch 操作的:




好了,到这里我就简单的实现了 UITableView 无尽滚动和对数据无缝加载的效果,你学会了吗?

如何避免滚动时的卡顿

当你遇到滚动卡顿的应用程序时,通常是由于任务长时间运行阻碍了 UI 在主线程上的更新,想让主线程有空来响应这类更新事件,第一步就是要将消耗时间的任务交给子线程去执行,避免在获取数据时阻塞主线程。

苹果提供了很多为应用程序实现并发的方式,例如 GCD,我在这里对 Cell 上的图片进行异步加载使用的就是它。

代码如下:

class DataLoadOperation: Operation {
var image: UIImage?
var loadingCompleteHandle: ((UIImage?) -> ())?
private var _image: ImageModel
private let cachedImages = NSCache<NSURL, UIImage>()

init(_ image: ImageModel) {
_image = image
}

public final func image(url: NSURL) -> UIImage? {
return cachedImages.object(forKey: url)
}

override func main() {
if isCancelled {
return
}

guard let url = _image.url else {
return
}
downloadImageFrom(url) { (image) in
DispatchQueue.main.async { [weak self] in
guard let ss = self else { return }
if ss.isCancelled { return }
ss.image = image
ss.loadingCompleteHandle?(ss.image)
}
}

}

// Returns the cached image if available, otherwise asynchronously loads and caches it.
func downloadImageFrom(_ url: NSURL, completeHandler: @escaping (UIImage?) -> ()) {
// Check for a cached image.
if let cachedImage = image(url: url) {
DispatchQueue.main.async {
print("命中缓存")
completeHandler(cachedImage)
}
return
}

URLSession.shared.dataTask(with: url as URL) { data, response, error in
guard
let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let _image = UIImage(data: data)
else { return }
// Cache the image.
self.cachedImages.setObject(_image, forKey: url, cost: data.count)
completeHandler(_image)
}.resume()
}
}

那具体如何使用呢!别急,听我娓娓道来,这里我再给大家一个小建议,大家都知道 UITableView 实例化 Cell 的方法是:tableView:cellForRowAtIndexPath: ,相信很多人都会在这个方法里面去进行数据绑定然后更新 UI,其实这样做是一种比较低效的行为,因为这个方法需要为每个 Cell 调用一次,它应该快速的执行并返回重用 Cell 的实例,不要在这里去执行数据绑定,因为目前在屏幕上还没有 Cell。我们可以在 tableView:willDisplayCell:forRowAtIndexPath: 这个方法中进行数据绑定,这个方法在显示cell之前会被调用。

为每个 Cell 执行下载任务的实现代码如下:

 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "PreloadCellID") as? ProloadTableViewCell else {
fatalError("Sorry, could not load cell")
}

if isLoadingCell(for: indexPath) {
cell.updateUI(.none, orderNo: "\(indexPath.row)")
}

return cell
}

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// preheat image ,处理将要显示的图像
guard let cell = cell as? ProloadTableViewCell else {
return
}

// 图片下载完毕后更新 cell
let updateCellClosure: (UIImage?) -> () = { [unowned self] (image) in
cell.updateUI(image, orderNo: "\(indexPath.row)")
viewModel.loadingOperations.removeValue(forKey: indexPath)
}

// 1\. 首先判断是否已经存在创建好的下载线程
if let dataLoader = viewModel.loadingOperations[indexPath] {
if let image = dataLoader.image {
// 1.1 若图片已经下载好,直接更新
cell.updateUI(image, orderNo: "\(indexPath.row)")
} else {
// 1.2 若图片还未下载好,则等待图片下载完后更新 cell
dataLoader.loadingCompleteHandle = updateCellClosure
}
} else {
// 2\. 没找到,则为指定的 url 创建一个新的下载线程
print("在 \(indexPath.row) 行创建一个新的图片下载线程")
if let dataloader = viewModel.loadImage(at: indexPath.row) {
// 2.1 添加图片下载完毕后的回调
dataloader.loadingCompleteHandle = updateCellClosure
// 2.2 启动下载
viewModel.loadingQueue.addOperation(dataloader)
// 2.3 将该下载线程加入到记录数组中以便根据索引查找
viewModel.loadingOperations[indexPath] = dataloader
}
}
}

对预加载的图片进行异步下载(预热):

func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let needFetch = indexPaths.contains { $0.row >= viewModel.currentCount}
if needFetch {
// 1.满足条件进行翻页请求
indicatorView.startAnimating()
viewModel.fetchImages()
}

for indexPath in indexPaths {
if let _ = viewModel.loadingOperations[indexPath] {
return
}

if let dataloader = viewModel.loadImage(at: indexPath.row) {
print("在 \(indexPath.row) 行 对图片进行 prefetch ")
// 2 对需要下载的图片进行预热
viewModel.loadingQueue.addOperation(dataloader)
// 3 将该下载线程加入到记录数组中以便根据索引查找
viewModel.loadingOperations[indexPath] = dataloader
}
}
}

取消 Prefetch 时,cancel 任务,避免造成资源浪费

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]){
// 该行在不需要显示的时候,取消 prefetch ,避免造成资源浪费
indexPaths.forEach {
if let dataLoader = viewModel.loadingOperations[$0] {
print("在 \($0.row) 行 cancelPrefetchingForRowsAt ")
dataLoader.cancel()
viewModel.loadingOperations.removeValue(forKey: $0)
}
}
}

经过这般处理,我们的 UITableView 滚动起来肯定是如丝般顺滑,小伙伴们还等什么,还不赶紧试一试。

图片缓存

虽然我在上面对我的应用增加了并发操作,但是我一看 Xcode 的性能分析,我不禁陷入了沉思,我的应用程序太吃内存了,假如我不停的刷,那我的手机应该迟早会把我的应用给终止掉,下图是我刷到 200 行的时候的性能分析图:

内存



磁盘



可以看到我的应用的性能分析很不理想,究其原因在于我的应用里显示了大量的图片资源,每次来回滚动的时候,都会重新去下载新的图片,而没有对图片做缓存处理。

所以,针对这个问题,我为我的应用加入了缓存 NSCache 对象,来对图片做一个缓存,具体代码实现如下:

class ImageCache: NSObject {

private var cache = NSCache<AnyObject, UIImage>()
public static let shared = ImageCache()
private override init() {}

func getCache() -> NSCache<AnyObject, UIImage> {
return cache
}
}

在下载开始的时候,检查有没有命中缓存,如果命中则直接返回图片,否则重新下载图片,并添加到缓存中:

func downloadImageFrom(_ url: URL, completeHandler: @escaping (UIImage?) -> ()) {
// Check for a cached image.
if let cachedImage = getCacheImage(url: url as NSURL) {
print("命中缓存")
DispatchQueue.main.async {
completeHandler(cachedImage)
}
return
}

URLSession.shared.dataTask(with: url) { data, response, error in
guard
let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let _image = UIImage(data: data)
else { return }

// Cache the image.
ImageCache.shared.getCache().setObject(_image, forKey: url as NSURL)

completeHandler(_image)
}.resume()
}

有了缓存的加持后,再用 Xcode 来查看我的应用的性能,就会发现内存和磁盘的占用已经下降了很多:

内存


磁盘



关于图片缓存的技术,这里只是用了最简单的一种,外面很多开源的图片库都有不同的缓存策略,感兴趣的可以去 GitHub 上学习一下它们的源码,我在这里就不做赘述了。

最后

终于写完了,长舒了一口气,这篇文章的篇幅有点长,主要是我花了一点时间做调研,然后看到这个知识点想给大家讲一下,看到那个知识点也想给大家讲一下,最终引经据典(东拼西凑)完成了这篇文章,希望大家能够喜欢 :)。




作者:iOS鑫
链接:https://www.jianshu.com/p/f00f43e401da

收起阅读 »

Swift声明参考-超详细(下)

枚举声明在你的程序里使用枚举声明来引入一个枚举类型。枚举声明有两种基本的形式,使用关键字enum来声明。枚举声明体使用从零开始的变量——叫做枚举事件,和任意数量的 声明,包括计算型属性,实例方法,静态方法,构造器,类型别名,甚至其他枚举,结构体,和类。枚举声明...
继续阅读 »


枚举声明

在你的程序里使用枚举声明来引入一个枚举类型。

枚举声明有两种基本的形式,使用关键字enum来声明。枚举声明体使用从零开始的变量——叫做枚举事件,和任意数量的 声明,包括计算型属性,实例方法,静态方法,构造器,类型别名,甚至其他枚举,结构体,和类。枚举声明不能 包含析构器或者协议声明。

不像类或者结构体。枚举类型并不提供隐式的初始构造器,所有构造器必须显式的声明。构造器可以委托枚举中的其他 构造器,但是构造过程仅当构造器将一个枚举时间完成后才全部完成。

和结构体类似但是和类不同,枚举是值类型:枚举实例在赋予变量或常量时,或者被函数调用时被复制。 更多关于值类型的信息,参见结构体和枚举都是值类型(Structures and Enumerations Are Value Types)一节。

你可以扩展枚举类型,正如在扩展名声明(Extension Declaration)中讨论的一样。

任意事件类型的枚举

如下的形式声明了一个包含任意类型枚举时间的枚举变量


1.
enum enumeration name { case enumeration case 1 case enumeration case 2(associated value types) }

这种形式的枚举声明在其他语言中有时被叫做可识别联合(discrinminated)。

这种形式中,每一个事件块由关键字case开始,后面紧接着一个或多个以逗号分隔的枚举事件。每一个事件名必须是 独一无二的。每一个事件也可以指定它所存储的指定类型的值,这些类型在关联值类型的元祖里被指定,立即书写在事件 名后。获得更多关于关联值类型的信息和例子,请查看关联值(associated values)一节。

使用原始事件值的枚举

以下的形式声明了一个包含相同基础类型的枚举事件的枚举:


1.
enum enumeration name: raw value type { case enumeration case 1 = raw value 1 case enumeration case 2 = raw value 2 }

在这种形式中,每一个事件块由case关键字开始,后面紧接着一个或多个以逗号分隔的枚举事件。和第一种形式的枚举 事件不同,这种形式的枚举事件包含一个同类型的基础值,叫做原始值(raw value)。这些值的类型在原始值类型(raw value type) 中被指定,必须是字面上的整数,浮点数,字符或者字符串。

每一个事件必须有唯一的名字,必须有一个唯一的初始值。如果初始值类型被指定为int,则不必为事件显式的指定值, 它们会隐式的被标为值0,1,2等。每一个没有被赋值的Int类型时间会隐式的赋予一个初始值,它们是自动递增的。


1.
num ExampleEnum: Int { case A, B, C = 5, D }

在上面的例子中,ExampleEnum.A的值是0,ExampleEnum.B的值是。因为ExampleEnum.C的值被显式的设定为5,因此 ExampleEnum.D的值会自动增长为6.

枚举事件的初始值可以调用方法roRaw获得,如ExampleEnum.B.toRaw()。你也可以通过调用fromRaw方法来使用初始值找到 其对应的事件,并返回一个可选的事件。查看更多信息和获取初始值类型事件的信息,参阅初始值(raw values)。

获得枚举事件

使用点(.)来引用枚举类型的事件,如 EnumerationType.EnumerationCase。当枚举类型可以上下文推断出时,你可以 省略它(.仍然需要),参照枚举语法(Enumeration Syntax)和显式成员表达(Implicit Member Expression).

使用switch语句来检验枚举事件的值,正如使用switch语句匹配枚举值(Matching Enumeration Values with a Switch Statement)一节描述的那样。

枚举类型是模式匹配(pattern-matched)的,和其相反的是switch语句case块中枚举事件匹配,在枚举事件类型(Enumeration Case Pattern)中有描述。

GRAMMAR OF AN ENUMERATION DECLARATION
enum-declaration → attributes­opt­union-style-enum­ attributes­opt­raw-value-style-enum­ union-style-enum → enum-name­generic-parameter-clause­opt­{­union-style-enum-members­opt­}­ union-style-enum-members → union-style-enum-member­union-style-enum-members­opt­ union-style-enum-member → declaration­ union-style-enum-case-clause­ union-style-enum-case-clause → attributes­opt­case­union-style-enum-case-list­ union-style-enum-case-list → union-style-enum-case­ union-style-enum-case­,­union-style-enum-case-list­ union-style-enum-case → enum-case-name­tuple-type­opt­ enum-name → identifier­ enum-case-name → identifier­ raw-value-style-enum → enum-name­generic-parameter-clause­opt­:­type-identifier­{­raw-value-style-enum-members­opt­}­ raw-value-style-enum-members → raw-value-style-enum-member­raw-value-style-enum-members­opt­ raw-value-style-enum-member → declaration­ raw-value-style-enum-case-clause­ raw-value-style-enum-case-clause → attributes­opt­case­raw-value-style-enum-case-list­ raw-value-style-enum-case-list → raw-value-style-enum-case­ raw-value-style-enum-case­,­raw-value-style-enum-case-list­ raw-value-style-enum-case → enum-case-name­raw-value-assignment­opt­ raw-value-assignment → =­literal­

结构体声明

使用结构体声明可以在你的程序里引入一个结构体类型。结构体声明使用struct关键字,遵循如下的形式:


1.
struct structure name: adopted protocols { declarations }

结构体内包含零或多个声明。这些声明可以包括存储型和计算型属性,静态属性,实例方法,静态方法,构造器, 类型别名,甚至其他结构体,类,和枚举声明。结构体声明不能包含析构器或者协议声明。详细讨论和包含多种结构体 声明的实例,参见类和结构体一节。

结构体可以包含任意数量的协议,但是不能继承自类,枚举或者其他结构体。

有三种方法可以创建一个声明过的结构体实例:
-调用结构体内声明的构造器,参照构造器(initializers)一节。
—如果没有声明构造器,调用结构体的逐个构造器,详情参见Memberwise Initializers for Structure Types.
—如果没有声明析构器,结构体的所有属性都有初始值,调用结构体的默认构造器,详情参见默认构造器(Default Initializers).
结构体的构造过程参见初始化(initiaization)一节。
结构体实例属性可以用点(.)来获得,详情参见获得属性(Accessing Properties)一节。
结构体是值类型;结构体的实例在被赋予变量或常量,被函数调用时被复制。获得关于值类型更多信息,参见 结构体和枚举都是值类型(Structures and Enumerations Are Value Types)一节。
你可以使用扩展声明来扩展结构体类型的行为,参见扩展声明(Extension Declaration).

GRAMMAR OF A STRUCTURE DECLARATION
struct-declaration → attributes­opt­struct­struct-name­generic-parameter-clause­opt­type-inheritance-clause­opt­struct-body­ struct-name → identifier­ struct-body → {­declarations­opt­}

类声明

你可以在你的程序中使用类声明来引入一个类。类声明使用关键字class,遵循如下的形式:


1
. class class name: superclass, adopted protocols { declarations }

一个类内包含零或多个声明。这些声明可以包括存储型和计算型属性,实例方法,类方法,构造器,单独的析构器方法, 类型别名,甚至其他结构体,类,和枚举声明。类声明不能包含协议声明。详细讨论和包含多种类声明的实例,参见类和 结构体一节。

一个类只能继承一个父类,超类,但是可以包含任意数量的协议。这些超类第一次在type-inheritance-clause出现,遵循任意协议。

正如在初始化声明(Initializer Declaration)谈及的那样,类可以有指定和方便的构造器。当你声明任一中构造器时, 你可以使用requierd变量来标记构造器,要求任意子类来重写它。指定类的构造器必须初始化类所有的已声明的属性, 它必须在子类构造器调用前被执行。

类可以重写属性,方法和它的超类的构造器。重写的方法和属性必须以override标注。

虽然超类的属性和方法声明可以被当前类继承,但是超类声明的指定构造器却不能。这意味着,如果当前类重写了超类 的所有指定构造器,它就继承了超类的方便构造器。Swift的类并不是继承自一个全局基础类。

有两种方法来创建已声明的类的实例:

  • 调用类的一个构造器,参见构造器(initializers)。
  • 如果没有声明构造器,而且类的所有属性都被赋予了初始值,调用类的默认构造器,参见默认构造器(default initializers).

类实例属性可以用点(.)来获得,详情参见获得属性(Accessing Properties)一节。

类是引用类型;当被赋予常量或变量,函数调用时,类的实例是被引用,而不是复制。获得更多关于引用类型的信息, 结构体和枚举都是值类型(Structures and Enumerations Are Value Types)一节。

你可以使用扩展声明来扩展类的行为,参见扩展声明(Extension Declaration).

GRAMMAR OF A CLASS DECLARATION
class-declaration → attributes­opt­class­class-name­generic-parameter-clause­opt­type-inheritance-clause­opt­class-body­ class-name → identifier­ class-body → {­declarations­opt­}

协议声明(translated by 小一)

一个协议声明为你的程序引入一个命名了的协议类型。协议声明使用 protocol 关键词来进行声明并有下面这样的形式:


1
. protocol protocol name: inherited protocols {
2. protocol member declarations
3. }

协议的主体包含零或多个协议成员声明,这些成员描述了任何采用该协议必须满足的一致性要求。特别的,一个协议可以声明必须实现某些属性、方法、初始化程序及附属脚本的一致性类型。协议也可以声明专用种类的类型别名,叫做关联类型,它可以指定协议的不同声明之间的关系。协议成员声明会在下面的详情里进行讨论。

协议类型可以从很多其它协议那继承。当一个协议类型从其它协议那继承的时候,来自其它协议的所有要求就集合了,而且从当前协议继承的任何类型必须符合所有的这些要求。对于如何使用协议继承的例子,查看协议继承

注意:你也可以使用协议合成类型集合多个协议的一致性要求,详情参见协议合成类型协议合成

你可以通过采用在类型的扩展声明中的协议来为之前声明的类型添加协议一致性。在扩展中你必须实现所有采用协议的要求。如果该类型已经实现了所有的要求,你可以让这个扩展声明的主题留空。

默认地,符合某一个协议的类型必须实现所有声明在协议中的属性、方法和附属脚本。也就是说,你可以用optional属性标注这些协议成员声明以指定它们的一致性类型实现是可选的。optional属性仅仅可以用于使用objc属性标记过的协议。这样的结果就是仅仅类类型可以采用并符合包含可选成员要求的协议。更多关于如何使用optional属性的信息及如何访问可选协议成员的指导——比如当你不能肯定是否一致性的类型实现了它们——参见可选协议要求

为了限制协议的采用仅仅针对类类型,需要使用class_protocol属性标记整个协议声明。任意继承自标记有class_protocol属性协议的协议都可以智能地仅能被类类型采用。

注意:如果协议已经用object属性标记了,class_protocol属性就隐性地应用于该协议;没有必要再明确地使用class_protocol属性来标记该协议了。

协议是命名的类型,因此它们可以以另一个命名类型出现在你代码的所有地方,就像协议类型里讨论的那样。然而你不能构造一个协议的实例,因为协议实际上不提供它们指定的要求的实现。

你可以使用协议来声明一个类的代理的方法或者应该实现的结构,就像委托(代理)模式描述的那样。

协议声明的语法 protocol-declaration → attributes­opt­protocol­protocol-name­type-inheritance-clause­opt­protocol-body­ protocol-name → identifier­ protocol-body → {­protocol-member-declarations­opt­}­ protocol-member-declaration → protocol-property-declaration­ protocol-member-declaration → protocol-method-declaration­ protocol-member-declaration → protocol-initializer-declaration­ protocol-member-declaration → protocol-subscript-declaration­ protocol-member-declaration → protocol-associated-type-declaration­ protocol-member-declarations → protocol-member-declaration­protocol-member-declarations­opt­

协议属性声明

协议声明了一致性类型必须在协议声明的主体里通过引入一个协议属性声明来实现一个属性。协议属性声明有一种特殊的类型声明形式:


1.
var property name: type { get set }

同其它协议成员声明一样,这些属性声明仅仅针对符合该协议的类型声明了getter和setter要求。结果就是你不需要在协议里它被声明的地方实现getter和setter。

getter和setter要求可以通过一致性类型以各种方式满足。如果属性声明包含get和set关键词,一致性类型就可以用可读写(实现了getter和setter)的存储型变量属性或计算型属性,但是属性不能以常量属性或只读计算型属性实现。如果属性声明仅仅包含get关键词的话,它可以作为任意类型的属性被实现。比如说实现了协议的属性要求的一致性类型,参见属性要求

更多参见变量声明

协议属性声明语法 protocol-property-declaration → variable-declaration-head­variable-name­type-annotation­getter-setter-keyword-block­

协议方法声明

协议声明了一致性类型必须在协议声明的主体里通过引入一个协议方法声明来实现一个方法. 协议方法声明和函数方法声明有着相同的形式,包含如下两条规则:他们不包括函数体,你不能在类的声明内为他们的 参数提供初始值.举例来说,符合的类型执行协议必需的方法。参见必需方法一节。

使用关键字class可以在协议声明中声明一个类或必需的静态方法。执行这些方法的类也用关键字class声明。 相反的,执行这些方法的结构体必须以关键字static声明。如果你想使用扩展方法,在扩展类时使用class关键字, 在扩展结构体时使用static关键字。

更多请参阅函数声明。

GRAMMAR OF A PROTOCOL METHOD DECLARATION
protocol-method-declaration → function-head­function-name­generic-parameter-clause­opt­function-signature­

协议构造器声明

协议声明了一致性类型必须在协议声明的主体里通过引入一个协议构造器声明来实现一个构造器。协议构造器声明 除了不包含构造器体外,和构造器声明有着相同的形式,
更多请参阅构造器声明。

GRAMMAR OF A PROTOCOL INITIALIZER DECLARATION
protocol-initializer-declaration → initializer-head­generic-parameter-clause­opt­parameter-clause­

协议附属脚本声明

协议声明了一致性类型必须在协议声明的主体里通过引入一个协议附属脚本声明来实现一个附属脚本。协议属性声明 对附属脚本声明有一个特殊的形式:

subscript (parameters) -> return type { get set }

附属脚本声明只为和协议一致的类型声明了必需的最小数量的的getter和setter。如果附属脚本申明包含get和set关键字, 一致的类型也必须有一个getter和setter语句。如果附属脚本声明值包含get关键字,一致的类型必须至少包含一个 getter语句,可以选择是否包含setter语句。

更多参阅附属脚本声明。

GRAMMAR OF A PROTOCOL SUBSCRIPT DECLARATION
protocol-subscript-declaration → subscript-head­subscript-result­getter-setter-keyword-block­

协议相关类型声明

协议声明相关类型使用关键字typealias。相关类型为作为协议声明的一部分的类型提供了一个别名。相关类型和参数 语句中的类型参数很相似,但是它们在声明的协议中包含self关键字。在这些语句中,self指代和协议一致的可能的类型。 获得更多信息和例子,查看相关类型或类型别名声明。

GRAMMAR OF A PROTOCOL ASSOCIATED TYPE DECLARATION
protocol-associated-type-declaration → typealias-head­type-inheritance-clause­opt­typealias-assignment­opt­

构造器声明

构造器声明会为程序内的类,结构体或枚举引入构造器。构造器使用关键字Init来声明,遵循两条基本形式。

结构体,枚举,类可以有任意数量的构造器,但是类的构造器的规则和行为是不一样的。不像结构体和枚举那样,类 有两种结构体,designed initializers 和convenience initializers,参见构造器一节。

如下的形式声明了结构体,枚举和类的指定构造器:


1
. init(parameters) { statements }

类的指定构造器将类的所有属性直接初始化。如果类有超类,它不能调用该类的其他构造器,它只能调用超类的一个 指定构造器。如果该类从它的超类处继承了任何属性,这些属性在当前类内被赋值或修饰时,必须带哦用一个超类的 指定构造器。

指定构造器可以在类声明的上下文中声明,因此它不能用扩展声明的方法加入一个类中。

结构体和枚举的构造器可以带哦用其他的已声明的构造器,来委托其中一个火全部进行初始化过程。

以关键字convenience来声明一个类的便利构造器:


1
. convenience init(parameters) { statements }

便利构造器可以将初始化过程委托给另一个便利构造器或类的一个指定构造器。这意味着,类的初始化过程必须 以一个将所有类属性完全初始化的指定构造器的调用作为结束。便利构造器不能调用超类的构造器。

你可以使用requierd关键字,将便利构造器和指定构造器标记为每个子类的构造器都必须拥有的。因为指定构造器 不被子类继承,他们必须被立即执行。当子类直接执行所有超类的指定构造器(或使用便利构造器重写指定构造器)时, 必需的便利构造器可以被隐式的执行,亦可以被继承。不像方法,附属脚本那样,你不需要为这些重写的构造器标注 overrride关键字。

查看更多关于不同声明方法的构造器的例子,参阅构造过程一节。

GRAMMAR OF AN INITIALIZER DECLARATION
initializer-declaration → initializer-head­generic-parameter-clause­opt­parameter-clause­initializer-body­ initializer-head → attributes­opt­convenience­opt­init­ initializer-body → code-block­

析构声明

析构声明为类声明了一个析构器。析构器没有参数,遵循如下的格式:


1
. deinit { statements }

当类没有任何语句时将要被释放时,析构器会自动的被调用。析构器在类的声明体内只能被声明一次——但是不能在 类的扩展声明内,每个类最多只能有一个。

子类继承了它的超类的析构器,在子类将要被释放时隐式的调用。子类在所有析构器被执行完毕前不会被释放。

析构器不会被直接调用。

查看例子和如何在类的声明中使用析构器,参见析构过程一节 。

GRAMMAR OF A DEINITIALIZER DECLARATION
deinitializer-declaration → attributes­opt­deinit­code-block

扩展声明

扩展声明用于扩展一个现存的类,结构体,枚举的行为。扩展声明以关键字extension开始,遵循如下的规则:



1
. extension type: adopted protocols { declarations }

一个扩展声明体包括零个或多个声明。这些声明可以包括计算型属性,计算型静态属性,实例方法,静态和类方法,构造器, 附属脚本声明,甚至其他结构体,类,和枚举声明。扩展声明不能包含析构器,协议声明,存储型属性,属性监测器或其他 的扩展属性。详细讨论和查看包含多种扩展声明的实例,参见扩展一节。

扩展声明可以向现存的类,结构体,枚举内添加一致的协议。扩展声明不能向一个类中添加继承的类,因此 type-inheritance-clause是一个只包含协议列表的扩展声明。

属性,方法,现存类型的构造器不能被它们类型的扩展所重写。

扩展声明可以包含构造器声明,这意味着,如果你扩展的类型在其他模块中定义,构造器声明必须委托另一个在 那个模块里声明的构造器来恰当的初始化。

GRAMMAR OF AN EXTENSION DECLARATION
extension-declaration → extension­type-identifier­type-inheritance-clause­opt­extension-body­ extension-body → {­declarations­opt­}­

附属脚本声明(translated by 林)

附属脚本用于向特定类型添加附属脚本支持,通常为访问集合,列表和序列的元素时提供语法便利。附属脚本声明使用关键字subscript,声明形式如下:

subscript (parameter) -> (return type){ get{ statements } set(setter name){ statements } } 附属脚本声明只能在类,结构体,枚举,扩展和协议声明的上下文进行声明。

变量(parameters)指定一个或多个用于在相关类型的附属脚本中访问元素的索引(例如,表达式object[i]中的i)。尽管用于元素访问的索引可以是任意类型的,但是每个变量必须包含一个用于指定每种索引类型的类型标注。返回类型(return type)指定被访问的元素的类型。

和计算性属性一样,附属脚本声明支持对访问元素的读写操作。getter用于读取值,setter用于写入值。setter子句是可选的,当仅需要一个getter子句时,可以将二者都忽略且直接返回请求的值即可。也就是说,如果使用了setter子句,就必须使用getter子句。

setter的名字和封闭的括号是可选的。如果使用了setter名称,它会被当做传给setter的变量的名称。如果不使用setter名称,那么传给setter的变量的名称默认是value。setter名称的类型必须与返回类型(return type)的类型相同。

可以在附属脚本声明的类型中,可以重载附属脚本,只要变量(parameters)或返回类型(return type)与先前的不同即可。此时,必须使用override关键字声明那个被覆盖的附属脚本。(注:好乱啊!到底是重载还是覆盖?!)

同样可以在协议声明的上下文中声明附属脚本,Protocol Subscript Declaration中有所描述。

更多关于附属脚本和附属脚本声明的例子,请参考Subscripts

GRAMMAR OF A SUBSCRIPT DECLARATION
subscript-declaration → subscript-head­subscript-result­code-block­ subscript-declaration → subscript-head­subscript-result­getter-setter-block­ subscript-declaration → subscript-head­subscript-result­getter-setter-keyword-block­ subscript-head → attributes­opt­subscript­parameter-clause­ subscript-result → ->­attributes­opt­type­

运算符声明(translated by 林)

运算符声明会向程序中引入中缀、前缀或后缀运算符,它使用上下文关键字operator声明。 可以声明三种不同的缀性:中缀、前缀和后缀。操作符的缀性描述了操作符与它的操作数的相对位置。 运算符声明有三种基本形式,每种缀性各一种。运算符的缀性通过在operator和运算符之间添加上下文关键字infix,prefix或postfix来指定。每种形式中,运算符的名字只能包含Operators中定义的运算符字符。

下面的这种形式声明了一个新的中缀运算符:

operator infix operator name{ precedence precedence level associativity associativity }

中缀运算符是二元运算符,它可以被置于两个操作数之间,比如表达式1 + 2 中的加法运算符(+)。

中缀运算符可以可选地指定优先级,结合性,或两者同时指定。

运算符的优先级可以指定在没有括号包围的情况下,运算符与它的操作数如何紧密绑定的。可以使用上下文关键字precedence并优先级(precedence level)一起来指定一个运算符的优先级。优先级可以是0到255之间的任何一个数字(十进制整数);与十进制整数字面量不同的是,它不可以包含任何下划线字符。尽管优先级是一个特定的数字,但它仅用作与另一个运算符比较(大小)。也就是说,一个操作数可以同时被两个运算符使用时,例如2 + 3 * 5,优先级更高的运算符将优先与操作数绑定。

运算符的结合性可以指定在没有括号包围的情况下,优先级相同的运算符以何种顺序被分组的。可以使用上下文关键字associativity并结合性(associativity)一起来指定一个运算符的结合性,其中结合性可以说是上下文关键字left,right或none中的任何一个。左结合运算符以从左到右的形式分组。例如,减法运算符(-)具有左结合性,因此4 - 5 - 6被以(4 - 5) - 6的形式分组,其结果为-7。 右结合运算符以从右到左的形式分组,对于设置为none的非结合运算符,它们不以任何形式分组。具有相同优先级的非结合运算符,不可以互相邻接。例如,表达式1 < 2 < 3非法的。

声明时不指定任何优先级或结合性的中缀运算符,它们的优先级会被初始化为100,结合性被初始化为none。

下面的这种形式声明了一个新的前缀运算符:

operator prefix operator name{}

紧跟在操作数前边的前缀运算符(prefix operator)是一元运算符,例如表达式++i中的前缀递增运算符(++)。

前缀运算符的声明中不指定优先级。前缀运算符是非结合的。

下面的这种形式声明了一个新的后缀运算符:

operator postfix operator name{}

紧跟在操作数后边的后缀运算符(postfix operator)是一元运算符,例如表达式i++中的前缀递增运算符(++)。

和前缀运算符一样,后缀运算符的声明中不指定优先级。后缀运算符是非结合的。

声明了一个新的运算符以后,需要声明一个跟这个运算符同名的函数来实现这个运算符。如何实现一个新的运算符,请参考Custom Operators

GRAMMAR OF AN OPERATOR DECLARATION
operator-declaration → prefix-operator-declaration­ postfix-operator-declaration­ >infix-operator-declaration­ prefix-operator-declaration → operator ­prefix­ operator­{­}­ postfix-operator-declaration → operator ­postfix­ operator­{­}­ infix-operator-declaration → operator­infix­operator­{­infix-operator-attributes­opt­}­ infix-operator-attributes → precedence-clause­opt­associativity-clause­opt­ precedence-clause → precedence­precedence-level­ precedence-level → Digit 0 through 255 associativity-clause → associativity­associativity­ associativity → left­ right­ none



作者:iOS鑫
链接:https://www.jianshu.com/p/6638bbb73c14


收起阅读 »

Swift声明参考-超详细(上)

一条声明可以在你的程序里引入新的名字和构造。举例来说,你可以使用声明来引入函数和方法,变量和常量,或者来定义 新的命名好的枚举,结构,类和协议类型。你也可以使用一条声明来延长一个已经存在的命名好的类型的行为。或者在你的 程序里引入在其他地方声明的符号。在swi...
继续阅读 »
一条声明可以在你的程序里引入新的名字和构造。举例来说,你可以使用声明来引入函数和方法,变量和常量,或者来定义 新的命名好的枚举,结构,类和协议类型。你也可以使用一条声明来延长一个已经存在的命名好的类型的行为。或者在你的 程序里引入在其他地方声明的符号。

在swift中,大多数声明在某种意义上讲也是执行或同事声明它们的初始化定义。这意味着,因为协议和他们的成员不匹配, 大多数协议成员需要单独的声明。为了方便起见,也因为这些区别在swift里不是很重要,声明语句同时包含了声明和定义。

GRAMMAR OF A DECLARATION
declaration → import-declaration­
declaration → constant-declaration­
declaration → variable-declaration­
declaration → typealias-declaration­
declaration → function-declaration­
declaration → enum-declaration­
declaration → struct-declaration­
declaration → class-declaration­
declaration → protocol-declaration­
declaration → initializer-declaration­
declaration → deinitializer-declaration­
declaration → extension-declaration­
declaration → subscript-declaration­
declaration → operator-declaration­
declarations → declaration­declarations­opt­
declaration-specifiers → declaration-specifier­declaration-specifiers­opt­
declaration-specifier → class­ | mutating ­| nonmutating­ | override­ | static­ | unowned |

模块范围

模块范围定义了对模块中其他源文件可见的代码。(注:待改进)在swift的源文件中,最高级别的代码由零个或多个语句, 声明和表达组成。变量,常量和其他的声明语句在一个源文件的最顶级被声明,使得他们对同一模块中的每个源文件都是可见的。

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:130 595 548,不管你是小白还是大牛都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

GRAMMAR OF A TOP-LEVEL DECLARATION
top-level-declaration → statements ­opt

代码块

代码块用来将一些声明和控制结构的语句组织在一起。它有如下的形式:


1.
{ `statements` }

代码块中的语句包括声明,表达式和各种其他类型的语句,它们按照在源码中的出现顺序被依次执行。

GRAMMAR OF A CODE BLOCK
code-block → ­statements ­opt­

引入声明

引入声明使你可以使用在其他文件中声明的内容。引入语句的基本形式是引入整个代码模块;它由import关键字开始,后面 紧跟一个模块名:


1.
import module


你可以提供更多的细节来限制引入的符号,如声明一个特殊的子模块或者在一个模块或子模块中做特殊的声明。(待改进) 当你使用了这些细节后,在当前的程序汇总只有引入的符号是可用的(并不是声明的整个模块)。


1.
import import kind module.symbol name import module.submodule
GRAMMAR OF AN IMPORT DECLARATION
import-declaration → attributes ­opt ­import­ import-kind­ opt import-path­ import-kind → typealias­ | struct­ | class­ | enum­ | protocol­ | var­ | func­ import-path → import-path-identifier­ import-path-identifier­.­import-path­ import-path-identifier → identifier­ operator

常量声明

常量声明可以在你的程序里命名一个常量。常量以关键词let来声明,遵循如下的格式:



1. let constant name: type = expression

当常量的值被给定后,常量就将常量名称和表达式初始值不变的结合在了一起,而且不能更改。 这意味着如果常量以类的形式被初始化,类本身的内容是可以改变的,但是常量和类之间的结合关系是不能改变的。 当一个常量被声明为全局变量,它必须被给定一个初始值。当一个常量在类或者结构体中被声明时,他被认为是一个常量 属性。常量并不是可计算的属性,因此不包含getters和setters。(译者注:getters和setters不知道怎么翻译,待改进)

如果常量名是一个元祖形式,元祖中的每一项初始化表达式中都要有对应的值



1. let (firstNumber, secondNumber) = (10, 42)
在上例中,firstNumber是一个值为10的常量,secnodeName是一个值为42的常量。所有常量都可以独立的使用:


1.
1 println("The first number is \(firstNumber).")
2 // prints "The first number is 10."
3 println("The second number is \(secondNumber).")
4 // prints "The second number is 42."

类型注释(:type)在常量声明中是一个可选项,它可以用来描述在类型接口(type inference)中找到的类型。

声明一个静态常量要使用关键字static。静态属性在类型属性(type propetries)中有介绍。

如果还想获得更多关于常量的信息或者想在使用中获得帮助,请查看常量和变量(constants and variables), 存储属性(stored properties)等节。

GRAMMAR OF A CONSTANT DECLARATION
constant-declaration → attributes­ opt ­declaration-specifiers­ opt ­let­pattern-initializer-list­ pattern-initializer-list → pattern-initializer­ | pattern-initializer­ , pattern-initializer-list­ pattern-initializer → pattern ­initializer ­opt­ initializer → =­expression

变量声明

变量声明可以在你的程序里声明一个变量,它以关键字var来声明。根据声明变量类型和值的不同,如存储和计算 变量和属性,存储变量和属性监视,和静态变量属性,有着不同的声明形式。(待改进) 所使用的声明形式取决于变量所声明的范围和你打算声明的变量类型。

注意:你也可以在协议声明的上下文声明属性,详情参见类型属性声明。

存储型变量和存储型属性

下面的形式声明了一个存储型变量或存储型变量属性


1.
var variable name: type = expression

你可以在全局,函数内,或者在类和结构体的声明(context)中使用这种形式来声明一个变量。当变量以这种形式 在全局或者一个函数内被声明时,它代表一个存储型变量。当他在类或者结构体中被声明时,他代表一个存储型变量属性。

构造器表达式可以和常量声明相比,如果变量名是一个元祖类型,元祖的每一项的名字都要和初始化表达式一致。

正如名字一样,存储型变量的值或存储型变量属性存储在内存中。

计算型变量和计算型属性

如下形式声明一个一个存储型变量或存储型属性:


1.
var variable name: type { get { statements } set(setter name) { statements } }

你可以在全局,函数体内或者类,结构体,枚举,扩展声明的上下文中使用这种形式的声明。 当变量以这种形式在全局或者一个函数内被声明时,它代表一个计算型变量。当他在类,结构体,枚举,扩展声明的上下文 中中被声明时,他代表一个计算型变量属性。

getter用来读取变量值,setter用来写入变量值。setter子句是可选择的,只有getter是必需的,你可以将这些语句 都省略,只是简单的直接返回请求值,正如在只读计算属性(read-only computed properites)中描述的那样。 但是如果你提供了一个setter语句,你也必需提供一个getter语句。

setter的名字和圆括号内的语句是可选的。如果你写了一个setter名,它就会作为setter的参数被使用。如果你不写setter名, setter的初始名为newValue,正如在seter声明速记(shorthand setter declaration)中提到的那样。

不像存储型变量和存储型属性那样,计算型属性和计算型变量的值不存储在内存中。

获得更多信息,查看更多关于计算型属性的例子,请查看计算型属性(computed properties)一节。

存储型变量监视器和属性监视器

你可以用willset和didset监视器来声明一个存储型变量或属性。一个包含监视器的存储型变量或属性按如下的形式声明:


1
. var variable name: type = expression { willSet(setter name) { statements } didSet(setter name { statements } }

你可以在全局,函数体内或者类,结构体,枚举,扩展声明的上下文中使用这种形式的声明。 当变量以这种形式在全局或者一个函数内被声明时,监视器代表一个存储型变量监视器; 当他在类,结构体,枚举,扩展声明的上下文中被声明时,监视器代表属性监视器。

你可以为适合的监视器添加任何存储型属性。你也可以通过重写子类属性的方式为适合的监视器添加任何继承的属性 (无论是存储型还是计算型的),参见重写属性监视器(overriding properyt observers)。

初始化表达式在类或者结构体的声明中是可选的,但是在其他地方是必需的。无论在什么地方声明, 所有包含监视器的变量声明都必须有类型注释(type annotation)。

当变量或属性的值被改变时,willset和didset监视器提供了一个监视方法(适当的回应)。 监视器不会在变量或属性第一次初始化时不会被运行,他们只有在值被外部初始化语句改变时才会被运行。

willset监视器只有在变量或属性值被改变之前运行。新的值作为一个常量经过过willset监视器,因此不可以在 willset语句中改变它。didset监视器在变量或属性值被改变后立即运行。和willset监视器相反,为了以防止你仍然 需要获得旧的数据,旧变量值或者属性会经过didset监视器。这意味着,如果你在变量或属性自身的didiset监视器语句 中设置了一个值,你设置的新值会取代刚刚在willset监视器中经过的那个值。

在willset和didset语句中,setter名和圆括号的语句是可选的。如果你写了一个setter名,它就会作为willset和didset的参数被使用。如果你不写setter名, willset监视器初始名为newvalue,didset监视器初始名为oldvalue。

当你提供一个willset语句时,didset语句是可选的。同样的,在你提供了一个didset语句时,willset语句是可选的。

获得更多信息,查看如何使用属性监视器的例子,请查看属性监视器(prpperty observers)一节。

类和静态变量属性

class关键字用来声明类的计算型属性。static关键字用来声明类的静态变量属性。类和静态变量在类型属性(type properties)中有详细讨论。

GRAMMAR OF A VARIABLE DECLARATION
variable-declaration → variable-declaration-head­pattern-initializer-list­
variable-declaration → variable-declaration-head ­variable-name ­type-annotation ­code-block­
variable-declaration → variable-declaration-head ­variable-name ­type-annotation ­getter-setter-block­
variable-declaration → variable-declaration-head ­variable-name­ type-annotation ­getter-setter-keyword-block­
variable-declaration → variable-declaration-head­ variable-name ­type-annotation­initializer­ opt ­willSet-didSet-block­
variable-declaration-head → attributes ­opt­ declaration-specifiers ­opt ­var ­ variable-name → identifier­
getter-setter-block → {­getter-clause ­setter-clause­ opt­}­
getter-setter-block → {­setter-clause ­getter-clause­}­
getter-clause → attributes ­opt­get­code-block­
setter-clause → attributes ­opt ­set­ setter-name­ opt­ code-block­
setter-name → (­identifier­)­
getter-setter-keyword-block → {­getter-keyword-clause ­setter-keyword-clause­ opt­} ­ getter-setter-keyword-block → {­setter-keyword-clause ­getter-keyword-clause­}
getter-keyword-clause → attributes­ opt­ get­
setter-keyword-clause → attributes ­opt­ set­
willSet-didSet-block → {­willSet-clause ­didSet-clause ­opt­}­
willSet-didSet-block → {­didSet-clause ­willSet-clause­}­
willSet-clause → attributes ­opt ­willSet ­setter-name­ opt ­code-block­
didSet-clause → attributes ­opt ­didSet ­setter-name ­opt­ code-block­

类型的别名声明

类型别名的声明可以在你的程序里为一个已存在的类型声明一个别名。类型的别名声明以关键字typealias开始,遵循如下的 形式:



1. typealias name = existing type

当一个类型被别名被声明后,你可以在你程序的任何地方使用别名来代替已存在的类型。已存在的类型可以是已经被命名的 类型或者是混合类型。类型的别名不产生新的类型,它只是简单的和已存在的类型做名称替换。

查看更多Protocol Associated Type Declaration.

GRAMMAR OF A TYPE ALIAS DECLARATION
typealias-declaration → typealias-head­ typealias-assignment typealias-head → typealias­ typealias-name typealias-name → identifier typealias-assignment → =type

函数声明

你可以使用函数声明在你的程序里引入新的函数。函数可以在类的上下文,结构体,枚举,或者作为方法的协议中被声明。 函数声明使用关键字func,遵循如下的形式:


1
. func function name(parameters) -> return type { statements }

如果函数不返回任何值,返回类型可以被忽略,如下所示:


1.
func function name(parameters) { statements }

每个参数的类型都要标明,它们不能被推断出来。初始时函数的参数是常值。在这些参数前面添加var使它们成为变量, 作用域内任何对变量的改变只在函数体内有效,或者用inout使的这些改变可以在调用域内生效。 更多关于in-out参数的讨论,参见in-out参数(in-out parameters)

函数可以使用元组类型作为返回值来返回多个变量。

函数定义可以出现在另一个函数声明内。这种函数被称作nested函数。更多关于nested函数的讨论,参见nestde functions。

参数名

函数的参数是一个以逗号分隔的列表 。函数调用是的变量顺序必须和函数声明时的参数顺序一致。 最简单的参数列表有着如下的形式:



1. parameter name: parameter type
对于函数参数来讲,参数名在函数体内被使用,而不是在函数调用时使用。对于方法参数,参数名在函数体内被使用, 同时也在方法被调用时作为标签被使用。该方法的第一个参数名仅仅在函数体内被使用,就像函数的参数一样,举例来讲:


1
. func f(x: Int, y: String) -> String { return y + String(x) } f(7, "hello") // x and y have no name class C { func f(x: Int, y: String) -> String { return y + String(x) } } let c = C() c.f(7, y: "hello") // x没有名称,y有名称

你可以按如下的形式,重写参数名被使用的过程:



1. external parameter name local parameter name: parameter type #parameter name: parameter type _ local parameter name: parameter type

在本地参数前命名的第二名称(second name)使得参数有一个扩展名。且不同于本地的参数名。 扩展参数名在函数被调用时必须被使用。对应的参数在方法或函数被调用时必须有扩展名 。

在参数名前所写的哈希符号(#)代表着这个参数名可以同时作为外部或本体参数名来使用。等同于书写两次本地参数名。 在函数或方法调用时,与其对应的语句必须包含这个名字。

本地参数名前的强调字符(_)使参数在函数被调用时没有名称。在函数或方法调用时,与其对应的语句必须没有名字。

特殊类型的参数

参数可以被忽略,值可以是变化的,并且提供一个初始值,这种方法有着如下的形式:


1.
_ : <#parameter type#. parameter name: parameter type... parameter name: parameter type = default argument value

以强调符(_)命名的参数明确的在函数体内不能被访问。

一个以基础类型名的参数,如果紧跟着三个点(...),被理解为是可变参数。一个函数至多可以拥有一个可变参数, 且必须是最后一个参数。可变参数被作为该基本类型名的数组来看待。举例来讲,可变参数int...被看做是int[]。 查看可变参数的使用例子,详见可变参数(variadic parameters)一节。

在参数的类型后面有一个以等号(=)连接的表达式,这样的参数被看做有着给定表达式的初试值。如果参数在函数 调用时被省略了,就会使用初始值。如果参数没有胜率,那么它在函数调用是必须有自己的名字.举例来讲, f()和f(x:7)都是只有一个变量x的函数的有效调用,但是f(7)是非法的,因为它提供了一个值而不是名称。

特殊方法

以self修饰的枚举或结构体方法必须以mutating关键字作为函数声明头。

子类重写的方法必须以override关键字作为函数声明头。不用override关键字重写的方法,使用了override关键字 却并没有重写父类方法都会报错。

和类型相关而不是和类型实例相关的方法必须在static声明的结构以或枚举内,亦或是以class关键字定义的类内。

柯里化函数和方法

柯里化函数或方法有着如下的形式:


1.
func function name(parameters)(parameters) -> return type { statements }

以这种形式定义的函数的返回值是另一个函数。举例来说,下面的两个声明时等价的:


1
. func addTwoNumbers(a: Int)(b: Int) -> Int { return a + b } func addTwoNumbers(a: Int) -> (Int -> Int) { func addTheSecondNumber(b: Int) -> Int { return a + b } return addTheSecondNumber } addTwoNumbers(4)(5) // Returns 9

多级柯里化应用如下

GRAMMAR OF A FUNCTION DECLARATION
function-declaration → function-head­ function-name­ generic-parameter-clause ­opt­function-signature­ function-body­ function-head → attributes ­opt ­declaration-specifiers ­opt ­func­ function-name → identifier­ operator­ function-signature → parameter-clauses ­function-result ­opt­ function-result → ->­attributes ­opt ­type­ function-body → code-block­ parameter-clauses → parameter-clause ­parameter-clauses ­opt­ parameter-clause → (­)­ (­parameter-list­...­opt­)­ parameter-list → parameter­ parameter­,­parameter-list­ parameter → inout ­opt ­let ­opt­#­opt­parameter-name local-parameter-name ­opt­ type-annotation ­default-argument-clause ­opt­ parameter → inout­opt­var­#­opt­parameter-name­local-parameter-name ­opt­ type-annotation­default-argument-clause ­opt­ parameter → attributes ­opt ­type­ parameter-name → identifier­ _­ local-parameter-name → identifier­ _­ default-argument-clause → =­expression­:




收起阅读 »

XCode 使用 PMD 扫描重复代码

使用  HomeBrew 安装 PMDbrew install pmd在 Xcode 的 Build Phases 中,我们增加一个新的 Run Script#检测swi...
继续阅读 »

使用  HomeBrew 安装 PMD

brew install pmd


在 Xcode 的 Build Phases 中,我们增加一个新的 Run Script

#检测swift代码
#pmd cpd --files ${EXECUTABLE_NAME} --minimum-tokens 50 --language swift --encoding UTF-8 --format net.sourceforge.pmd.cpd.XMLRenderer > cpd-output.xml --failOnViolation true

#检测objective-c代码
pmd cpd --files ${EXECUTABLE_NAME} --minimum-tokens 20 --language objectivec --encoding UTF-8 --format net.sourceforge.pmd.cpd.XMLRenderer > cpd-output.xml --failOnViolation true

# Running script
php ./cpd_script.php -cpd-xml cpd-output.xml






上面的脚本中使用 pmd 进行复制粘贴检查, 检查结果会存放在 cpd-output.xml 文件中。
--minimum-tokens 的值是一个经验值,这个需要根据具体情况确定。

另外脚本中需要 php 对输出脚本进行分析并显示到 Xcode 中,如果没有安装 php,则需要安装。安装PHP参考文章

MAC HomeBrew 安装 php

还有需要的 cpd_script.php 文件, 需要放到工程根目录,它的作用是利用之前生成的检查结果文件,将检查的结果显示到 Xcode 中,在工程主目录下,创建 cpd_script.php 文件

<?php
foreach (simplexml_load_file('cpd-output.xml')->duplication as $duplication) {
$files = $duplication->xpath('file');
foreach ($files as $file) {
echo $file['path'].':'.$file['line'].':1: warning: '.$duplication['lines'].' copy-pasted lines from: '
.implode(', ', array_map(function ($otherFile) { return $otherFile['path'].':'.$otherFile['line']; },
array_filter($files, function ($f) use (&$file) { return $f != $file; }))).PHP_EOL;
}
}
?>

php 脚本可以参考: https://link.jianshu.com/?t=https://krakendev.io/blog/generating-warnings-in-xcode

测试

新建空的 demo 工程,command + b 编译项目,工程目录下会出现 cpd-output.xml 文件,此时文件内容为空




在项目中添加相同代码



再次编译项目,cpd-output.xml 文件则会出现重复代码提示信息,项目中代码也同样会给出警告提示信息








作者:gaookey
链接:https://www.jianshu.com/p/04a2a93d7b52
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。




收起阅读 »

ConstraintLayout2.0一篇写不完之嵌套滚动怎么滚

在ConstraintLayout1.x阶段,它主要提供的能力是对静态布局的支撑,那么到2.x之后,MotionLayout的拓展,让它对动态布局的支持有了进一步的优化,在1.x阶段不能实现的嵌套滚动布局布局方式,现在也就非常简单了。在没有Constraint...
继续阅读 »

在ConstraintLayout1.x阶段,它主要提供的能力是对静态布局的支撑,那么到2.x之后,MotionLayout的拓展,让它对动态布局的支持有了进一步的优化,在1.x阶段不能实现的嵌套滚动布局布局方式,现在也就非常简单了。

在没有ConstraintLayout的时候,要实现嵌套滚动布局,通常都是使用CoordinatorLayout来实现,但是这个东西的使用局限性比较大,能非常简单的实现的嵌套布局,就那么几种,如果要实现一些特别的滚动效果,就需要自定义behavior来实现,这样一来,嵌套滚动布局就成了一个比较复杂的布局方式了,而MotionLayout的出现,就可以完美的解决这样一个布局难题。

在ConstraintLayout2.x中,有两种方式来实现嵌套滚动布局。

CoordinatorLayout配合MotionLayout

这种方式实际上还是借助CoordinatorLayout,是一种比较早期的实现方案,如果是对CoordinatorLayout比较熟悉的开发者,可以很快改造现有代码来适配MotionLayout的嵌套滚动。

这种方案的布局结构如下:

CoordinatorLayout

--------AppBarLayout

----------------MotionLayout

--------NestedScrollView

可以发现,这种方式,实际上就是利用MotionLayout来替代之前在AppBarLayout里面的CollapsingToolbarLayout,借助MotionLayout来实现之前CollapsingToolbarLayout的一些折叠效果。

这种方式的一般套路结构如下。

image-20210223105619990

在AppBarLayout中,我们通过MotionLayout控制动画效果。

那么在这里,一般又有两个套路,一是直接使用MotionLayout,然后在代码里面通过AppBarLayout.OnOffsetChangedListener的回调,设置MotionLayout的progress,另一种是直接自定义MotionLayout,实现AppBarLayout.OnOffsetChangedListener,这样通用性比较强,示例如下。

class CollapsibleToolbar @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), AppBarLayout.OnOffsetChangedListener {

override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
progress = -verticalOffset / appBarLayout?.totalScrollRange?.toFloat()!!
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()
(parent as? AppBarLayout)?.addOnOffsetChangedListener(this)
}
}

这两种方式没有本质上的不同,但是对于MotionEditor来说,如果使用自定义的MotionLayout,在非根布局下创建约束的时候会有一些问题(修改属性也会存在一些问题),所以,如果使用自定义MotionLayout的话,建议通过include的方式,引用新的根布局为自定义MotionLayout的方式来使用,而直接使用MotionLayout的方式,则没有这个限制,希望MotionEditor能早日改善这个问题。PS:好消息,Android Studio Arctic Fox已经修复了这个问题。

单纯MotionLayout实现

MotionLayout的出现,就是为了能替代动态的布局模型,所以,如果还使用CoordinatorLayout,那么就违背了开发者的初心了,所以,我们的目的就是去除CoordinatorLayout,而仅使用MotionLayout来实现嵌套滚动效果,实现滚动布局的大一统。

这种套路的一般结构如下所示。

MotionLayout

--------MotionLayout

--------NestedScrollView

我们可以发现,这里有两层MotionLayout,外层的MotionLayout,用于控制头部的伸缩布局,而内部的MotionLayout,则用于控制头部的滚动时效果。

这样一来,整个嵌套滚动的格局一下子就打开了,再也没了之前使用CoordinatorLayout的高度限制,效果限制,所有的内容,都可以通过约束来进行设置,再通过MotionLayout来进行动态约束,从而实现嵌套滚动布局。

对于外层的MotionLayout,它的Scene提供两个能力,一个是控制头部从200dp,变为56dp,即提供一个伸缩的功能,另一个重要的而且很容易被忽视的作用,就是给内层MotionLayout提供progress数据,有了这个progress,内部MotionLayout才能联动,这个和使用CoordinatorLayout配合MotionLayout使用要设置progress是一个道理。

我们来看下最外层的Scene,代码如下所示。

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start">

<OnSwipe
motion:dragDirection="dragUp"
motion:touchAnchorId="@+id/motionLayout"
motion:touchAnchorSide="bottom" />
</Transition>

<ConstraintSet android:id="@+id/start">
<ConstraintOverride
android:id="@id/motionLayout"
android:layout_height="200dp"
motion:motionProgress="0" />
</ConstraintSet>

<ConstraintSet android:id="@+id/end">
<ConstraintOverride
android:id="@id/motionLayout"
android:layout_height="56dp"
motion:motionProgress="1" />
</ConstraintSet>
</MotionScene>

对于非layout_constraintXXX_toXXXOf的约束,可以使用ConstraintOverride来直接覆写,这样可以少写很多重复的约束,这里的约束改变实际上只有两个,即layout_height从200变为56,而另一个重要的点,就是motionProgress的指定,motionProgress的作用就是设置motionProgress,如果不设置这个,那么progress数据是没办法传递到内部MotionLayout的,从而会导致内部无法联动。

解决完外部的MotionLayout之后,内部的MotionLayout就迎刃而解了,因为它真的就是一个平平常常的MotionLayout,你想要对它内部的元素做任何的改动,都和之前直接使用MotionLayout没有任何区别。

我们来看这个简单的例子,如图所示。

image-20210817161849272

头部伸缩配上文字的移动,一个简简单单的类CoordinatorLayout布局,外部的Scene我们已经解决了,再来看看内部的Scene,算了不看了,没什么必要,就是简单的体力劳动。

整个套路的布局结构如下所示。

image-20210817162156160

总体看来,MotionLayout是不是实现了大一统,它将滚动的布局效果,转化为了多层MotionLayout的Scene分解,利用progress串联起来,设计思路不可谓不精,一旦你熟练掌握了MotionLayout的各种基础布局,那么即使再复杂的布局,也能分而治之。

收起阅读 »

iOS AVPlayer的那些坑

这次主要是总结和记录下视频播放遇到的坑,视频播放采用的是AVPlayer这个控件,语法大致如下: NSURL * url = [NSURL fileURLWithPath:@"视频地址"]; AVPlayerItem *playerItem = ...
继续阅读 »

这次主要是总结和记录下视频播放遇到的坑,视频播放采用的是AVPlayer这个控件,语法大致如下:

    NSURL * url = [NSURL fileURLWithPath:@"视频地址"];
AVPlayerItem *playerItem = [AVPlayerItem playerItemWithURL:url];
self.player = [AVPlayer playerWithPlayerItem:playerItem];
[playerItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
self.player.actionAtItemEnd = AVPlayerActionAtItemEndNone;
self.playerLayer = [AVPlayerLayer playerLayerWithPlayer:self.player];
self.playerLayer.videoGravity = AVLayerVideoGravityResizeAspect;
self.playerLayer.frame = self.view.bounds;
[self.view.layer addSublayer:self.playerLayer];

一般说来,这里要监听AVPlayerItem的status属性:

 *
*AVPlayerItem的三种状态
*AVPlayerItemStatusUnknown,
*AVPlayerItemStatusReadyToPlay,
*AVPlayerItemStatusFailed
*/

如果是AVPlayerStatusFailed说明视频加载失败,这时可以通过self.player.error.description属性来找出具体的原因。 问题一:当status变为AVPlayerStatusReadyToPlay后,我们调用play方法真的就能保证视频正常播放吗?

众所周知,AVPlayer支持的视频、音频格式非常广泛,抛开那些无法正常编解码的情况,在某些情况下其可能就是无法正常播放。AVPlayer在进行播放时,会预先解码一些内容,而此时如果我们的App使用CPU过多,I/O读写过多时,有可能导致视频播放声/画不同步,这点尤其在iPhone4上面表现更为明显,用户反馈iOS9.3.2的系统上也很明显。而如果是发生在AVPlayer初始化解码视频的时候,有可能导致视频直接无法播放,这时,我们再调用play或者seekToTime:方法都无法正常播放。建议不要在CPU或者I/O很频繁的情况下使用AVPlayer,例如刚登录App加载各种数据的情况下,可以等App预热以后再使用。

问题二:当rate属性的值大于0后,真的就在播放视频了吗?

当然不是。当发生上面所讲的情况时,我打印了当前的rate情况,是大于0的,但是页面上显示的情况却还是什么也没有。有时候我们如果想要在视频一播放的时候去做一些事情,例如设置一下播放器的背景色,如果我们仅仅是监听这个rate可能无法100%保证有效,而如果我们真的要监听这种情况的话,有一个取巧的方法:


 id _timerObserver = [self.player addBoundaryTimeObserverForTimes:@[[NSValue valueWithCMTime:CMTimeMake(1, 30)]] queue:dispatch_get_main_queue()
usingBlock:^{
//do something
}];

另外如果不需要监听播放进度的时候可以调下面的方法:

[self.player removeTimeObserver:_timerObserver];

问题三:AVPlayer前后台播放

当我们切换到后台后,这时AVPlayer通常会自动暂停,当然如果设置了后台播放音频的话,是可以在后台继续播放声音的,正如苹果自己的WWDC这个App一样。这个功能在我的另一篇文章iOS AVPlayer之后台连续播放视频中解决了这个问题。

问题四:音频通道的抢占引起的无法播放视频问题

这个问题下周我会另开一篇博客专门讲述,今儿就此略过。

问题五:其它App播放声音打断

如果用户当时在后台听音乐,如QQ音乐,或者喜马拉雅这些App,这个时候播放视频后,其会被我们打断,当我们不再播放视频的时候,自然需要继续这些后台声音的播放。

首先,我们需要先向设备注册激活声音打断AudioSessionSetActive(YES);,当然我们也可以通过 [AVAudioSession sharedInstance].otherAudioPlaying;这个方法来判断还有没有其它业务的声音在播放。 当我们播放完视频后,需要恢复其它业务或App的声音,这时我们可以在退到后台的事件中调用如下方法:

- (void)applicationDidEnterBackground:(UIApplication *)application {

NSError *error =nil;
AVAudioSession *session = [AVAudioSession sharedInstance];

// [session setCategory:AVAudioSessionCategoryPlayback error:nil];
BOOL isSuccess = [session setActive:NO withOptions:AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation error:&error];

if (!isSuccess) {

NSLog(@"__%@",error);

}else{

NSLog(@"成功了");
}

}

问题六:在用户插入和拔出耳机时,导致视频暂停,解决方法如下

 //耳机插入和拔掉通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(audioRouteChangeListenerCallback:) name:AVAudioSessionRouteChangeNotification object:[AVAudioSession sharedInstance]];

//耳机插入、拔出事件
- (void)audioRouteChangeListenerCallback:(NSNotification*)notification {
NSDictionary *interuptionDict = notification.userInfo;

NSInteger routeChangeReason = [[interuptionDict valueForKey:AVAudioSessionRouteChangeReasonKey] integerValue];

switch (routeChangeReason) {

case AVAudioSessionRouteChangeReasonNewDeviceAvailable:

break;

case AVAudioSessionRouteChangeReasonOldDeviceUnavailable:
{
//判断为耳机接口
AVAudioSessionRouteDescription *previousRoute =interuptionDict[AVAudioSessionRouteChangePreviousRouteKey];

AVAudioSessionPortDescription *previousOutput =previousRoute.outputs[0];
NSString *portType =previousOutput.portType;

if ([portType isEqualToString:AVAudioSessionPortHeadphones]) {
// 拔掉耳机继续播放
if (self.playing) {

[self.player play];
}
}

}
break;

case AVAudioSessionRouteChangeReasonCategoryChange:
// called at start - also when other audio wants to play

break;
}
}

问题七:打电话等中断事件

//中断的通知
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleInterruption:) name:AVAudioSessionInterruptionNotification object:[AVAudioSession sharedInstance]];
//中断事件
- (void)handleInterruption:(NSNotification *)notification{

NSDictionary *info = notification.userInfo;
//一个中断状态类型
AVAudioSessionInterruptionType type =[info[AVAudioSessionInterruptionTypeKey] integerValue];

//判断开始中断还是中断已经结束
if (type == AVAudioSessionInterruptionTypeBegan) {
//停止播放
[self.player pause];

}else {
//如果中断结束会附带一个KEY值,表明是否应该恢复音频
AVAudioSessionInterruptionOptions options =[info[AVAudioSessionInterruptionOptionKey] integerValue];
if (options == AVAudioSessionInterruptionOptionShouldResume) {
//恢复播放
[self.player play];
}

}

}

小提示:如果不起作用,请检查退到后台事件中有什么其它的操作没。因为电话来时,会调用退到后台的事件。

问题七:内存泄露问题 当我们释放一个正在播放的视频时,需要先调用pause方法,如果由于某些原因,例如切前后台时,导致又调用了play方法,那么有可能会hold不住内存空间而导致内存泄漏。其实更靠谱的方法是,还要移除player加载的资源。

总的来说,AVPlayer能满足一般的需求,虽然坑不少。最后,再来安利一下我自己封装的LYAVPlayer,简单方便,支持cocoa pods,只需几行代码即可完成播放:

         LYAVPlayerView *playerView =[LYAVPlayerView alloc]init];         
playerView.frame =CGRectMake(0, 64, ScreenWidth,200);
playerView.delegate =self;//设置代理
[self.view addSubview:playerView];
[playerView setURL:[NSURL URLWithString:VideoURL]];//设置播放的URL
[playerView play];//开始播放

工程中pod 'LYAVPlayer','~> 1.0.1'即可使用。有什么问题请Issues


作者:卢叁
链接:https://www.jianshu.com/p/47c7144db817


收起阅读 »

GPUImage recalculateViewGeometry卡UI线程的问题

原因:更新xocde11.4之后发现GPUImage视频画面渲染特别慢,并且控制台输出如下信息:Main Thread Checker: UI API called on a background thread: -[UIView bounds] PID: 7...
继续阅读 »

原因:
更新xocde11.4之后发现GPUImage视频画面渲染特别慢,并且控制台输出如下信息:

Main Thread Checker: UI API called on a background thread: -[UIView bounds]
PID: 7360, TID: 1812926, Thread name: (none), Queue name: com.sunsetlakesoftware.GPUImage.openGLESContextQueue, QoS: 0
Backtrace:
4 KXLive 0x0000000100e12e60 __39-[GPUImageView recalculateViewGeometry]_block_invoke + 52
5 KXLive 0x0000000100dec788 runSynchronouslyOnVideoProcessingQueue + 108
6 KXLive 0x0000000100e12e0c -[GPUImageView recalculateViewGeometry] + 108
7 KXLive 0x0000000100e13804 __37-[GPUImageView setInputSize:atIndex:]_block_invoke + 312
8 KXLive 0x0000000100dec788 runSynchronouslyOnVideoProcessingQueue + 108
9 KXLive 0x0000000100e136ac -[GPUImageView setInputSize:atIndex:] + 136
10 KXLive 0x0000000100e0ee38 -[GPUImageVideoCamera updateTargetsForVideoCameraUsingCacheTextureAtWidth:height:time:] + 660
11 KXLive 0x0000000100e0fb48 -[GPUImageVideoCamera processVideoSampleBuffer:] + 2120
12 KXLive 0x0000000100e106c4 __74-[GPUImageVideoCamera captureOutput:didOutputSampleBuffer:fromConnection:]_block_invoke + 180
13 libdispatch.dylib 0x0000000105e5d260 _dispatch_call_block_and_release + 24
14 libdispatch.dylib 0x0000000105e5d220 _dispatch_client_callout + 16
15 libdispatch.dylib 0x0000000105e6be80 _dispatch_queue_serial_drain + 768
16 libdispatch.dylib 0x0000000105e60730 _dispatch_queue_invoke + 328
17 libdispatch.dylib 0x0000000105e6cdd8 _dispatch_root_queue_drain_deferred_wlh + 352
18 libdispatch.dylib 0x0000000105e73ebc _dispatch_workloop_worker_thread + 676
19 libsystem_pthread.dylib 0x0000000181e6fe70 _pthread_wqthread + 860
20 libsystem_pthread.dylib 0x0000000181e6fb08 start_wqthread + 4

意思是在子线程中UIView对象调用了bounds ,导致视频画面迟迟渲染不出来。
查找发现GPUImageView的视频渲染类的

(void)recalculateViewGeometry;

方法中有两处调用了bounds。
解决办法:
在GPUImageView中声明一个属性viewBounds来保存view的bounds值,在- (void)layoutSubviews方法中给viewBounds赋值,用viewBounds代替bounds就可以了,此时可以很快的调起摄像头并且渲染出画面。

- (void)layoutSubviews {
[super layoutSubviews];
self.viewBounds = self.bounds;
// The frame buffer needs to be trashed and re-created when the view size changes.
if (!CGSizeEqualToSize(self.bounds.size, boundsSizeAtFrameBufferEpoch) &&
!CGSizeEqualToSize(self.bounds.size, CGSizeZero)) {
runSynchronouslyOnVideoProcessingQueue(^{
[self destroyDisplayFramebuffer];
[self createDisplayFramebuffer];
[self recalculateViewGeometry];
});
}
}

- (void)recalculateViewGeometry;
{
runSynchronouslyOnVideoProcessingQueue(^{
CGFloat heightScaling, widthScaling;

CGSize currentViewSize = self.viewBounds.size;

// CGFloat imageAspectRatio = inputImageSize.width / inputImageSize.height;
// CGFloat viewAspectRatio = currentViewSize.width / currentViewSize.height;

CGRect insetRect = AVMakeRectWithAspectRatioInsideRect(inputImageSize, self.viewBounds);
在xcode11和iOS13开始,系统对在子线程中做UI操作要求更加严格。千万不要在子线程中使用与UI相关的代码

作者:那月无痕
链接:https://www.jianshu.com/p/15cc2cd3a862




收起阅读 »

iOS-GPUImage实现美颜相机功能

本文介绍了如何使用 GPUImage 来实现一个简单的相机。具体功能包括拍照、录制视频、多段视频合成、实时美颜、自定义滤镜实现等。前言AVFoundation 是苹果提供的用于处理基于时间的媒体数据的一个框架。我们想要实现一个相机,需要从手机摄像头采集数据,离...
继续阅读 »

本文介绍了如何使用 GPUImage 来实现一个简单的相机。具体功能包括拍照、录制视频、多段视频合成、实时美颜、自定义滤镜实现等。

前言

AVFoundation 是苹果提供的用于处理基于时间的媒体数据的一个框架。我们想要实现一个相机,需要从手机摄像头采集数据,离不开这个框架的支持。GPUImage 对 AVFoundation 做了一些封装,使我们的采集工作变得十分简单。

另外,GPUImage 的核心魅力还在于,它封装了一个链路结构的图像数据处理流程,简称滤镜链。滤镜链的结构使得多层滤镜的叠加功能变得很容易实现。

在下面介绍的功能中,有一些和 GPUImage 本身的关系并不大,我们是直接调用 AVFoundation 的 API 来实现的。但是,这些功能也是一个相机应用必不可少的一部分。所以,我们也会简单讲一下每个功能的实现方式和注意事项。

滤镜链简介

在 GPUImage 中,对图像数据的处理都是通过建立滤镜链来实现的。

这里就涉及到了一个类 GPUImageOutput 和一个协议 GPUImageInput 。对于继承了 GPUImageOutput 的类,可以理解为具备输出图像数据的能力;对于实现了 GPUImageInput 协议的类,可以理解为具备接收图像数据输入的能力。

顾名思义,滤镜链作为一个链路,具有起点和终点。根据前面的描述,滤镜链的起点应该只继承了 GPUImageOutput 类,滤镜链的终点应该只实现了 GPUImageInput 协议,而对于中间的结点应该同时继承了 GPUImageOutput 类并实现了 GPUImageInput 协议,这样才具备承上启下的作用。

一、滤镜链起点

在 GPUImage 中,只继承了 GPUImageOutput,而没有实现 GPUImageInput 协议的类有六个,也就是说有六种类型的输入源:

1、GPUImagePicture

GPUImagePicture 通过图片来初始化,本质上是先将图片转化为 CGImageRef,然后将 CGImageRef 转化为纹理。

2、GPUImageRawDataInput

GPUImageRawDataInput 通过二进制数据初始化,然后将二进制数据转化为纹理,在初始化的时候需要指明数据的格式(GPUPixelFormat)。

3、GPUImageTextureInput

GPUImageTextureInput 通过已经存在的纹理来初始化。既然纹理已经存在,在初始化的时候就不会重新去生成,只是将纹理的索引保存下来。

4、GPUImageUIElement

GPUImageUIElement 可以通过 UIView 或者 CALayer 来初始化,最后都是调用 CALayer 的 renderInContext: 方法,将当前显示的内容绘制到 CoreGraphics 的上下文中,从而获取图像数据。然后将数据转化为纹理。简单来说就是截屏,截取当前控件的内容。

这个类可以用来实现在视频上添加文字水印的功能。因为在 OpenGL 中不能直接进行文本的绘制,所以如果我们想把一个 UILabel 的内容添加到滤镜链里面去,使用 GPUImageUIElement 来实现是很合适的。

5、GPUImageMovie

GPUImageMovie 通过本地的视频来初始化。首先通过 AVAssetReader 来逐帧读取视频,然后将帧数据转化为纹理,具体的流程大概是:AVAssetReaderOutput -> CMSampleBufferRef -> CVImageBufferRef -> CVOpenGLESTextureRef -> Texture 。

6、GPUImageVideoCamera

GPUImageVideoCamera 通过相机参数来初始化,通过屏幕比例相机位置(前后置) 来初始化相机。这里主要使用 AVCaptureVideoDataOutput 来获取持续的视频流数据输出,在代理方法 captureOutput:didOutputSampleBuffer:fromConnection: 中可以拿到 CMSampleBufferRef ,将其转化为纹理的过程与 GPUImageMovie 类似。

然而,我们在项目中使用的是它的子类 GPUImageStillCamera。 GPUImageStillCamera 在原来的基础上多了一个 AVCaptureStillImageOutput,它是我们实现拍照功能的关键,在 captureStillImageAsynchronouslyFromConnection:completionHandler: 方法的回调中,同样能拿到我们熟悉 CMSampleBufferRef

简单来说,GPUImageVideoCamera 只能录制视频,GPUImageStillCamera 还可以拍照, 因此我们使用 GPUImageStillCamera 。

二、滤镜

滤镜链的关键角色是 GPUImageFilter,它同时继承了 GPUImageOutput 类并实现了 GPUImageInput 协议。GPUImageFilter 实现承上启下功能的基础是「渲染到纹理」,这个操作我们在 《使用 iOS OpenGL ES 实现长腿功能》 一文中已经介绍过了,简单来说就是将结果渲染到纹理而不是屏幕上

这样,每一个滤镜都能把输出的纹理作为下一个滤镜的输入,实现多层滤镜效果的叠加。

三、滤镜链终点

在 GPUImage 中,实现了 GPUImageInput 协议,而没有继承 GPUImageOutput 的类有四个:

1、GPUImageMovieWriter

GPUImageMovieWriter 封装了 AVAssetWriter,可以逐帧从帧缓存的渲染结果中读取数据,最后通过 AVAssetWriter 将视频文件保存到指定的路径。

2、GPUImageRawDataOutput

GPUImageRawDataOutput 通过 rawBytesForImage 属性,可以获取到当前输入纹理的二进制数据。

假设我们的滤镜链在输入源和终点之间,连接了三个滤镜,而我们需要拿到第二个滤镜渲染后的数据,用来做人脸识别。那我们可以在第二个滤镜后面再添加一个 GPUImageRawDataOutput 作为输出,则可以拿到对应的二进制数据,且不会影响原来的渲染流程。

3、GPUImageTextureOutput

这个类的实现十分简单,提供协议方法 newFrameReadyFromTextureOutput:,在每一帧渲染结束后,将自身返回,通过 texture 属性就可以拿到输入纹理的索引。

4、GPUImageView

GPUImageView 继承自 UIView,通过输入的纹理,执行一遍渲染流程。这次的渲染目标不是新的纹理,而是自身的 layer 。

这个类是我们实现相机功能的重要组成部分,我们所有的滤镜效果,都要依靠它来呈现。

功能实现

一、拍照

拍照功能只需调用一个接口就能搞定,在回调方法中可以直接拿到 UIImage。代码如下:

- (void)takePhotoWtihCompletion:(TakePhotoResult)completion {
GPUImageFilter *lastFilter = self.currentFilterHandler.lastFilter;
[self.camera capturePhotoAsImageProcessedUpToFilter:lastFilter withCompletionHandler:^(UIImage *processedImage, NSError *error) {
if (error && completion) {
completion(nil, error);
return;
}
if (completion) {
completion(processedImage, nil);
}
}];
}

值得注意的是,相机的预览页面由 GPUImageView 承载,显示的是整个滤镜链作用的结果。而我们的拍照接口,可以传入这个链路上的任意一个滤镜,甚至可以在后面多加一个滤镜,然后拍照接口会返回对应滤镜的渲染结果。即我们的拍照结果不一定要和我们的预览一致

示意图如下:

[图片上传失败...(image-68bb82-1610618344597)]

<figcaption style="box-sizing: border-box; display: block;"></figcaption>

二、录制视频

1、单段录制

录制视频首先要创建一个 GPUImageMovieWriter 作为链路的输出,与上面的拍照接口类似,这里录制的视频不一定和我们的预览一样。

整个过程比较简单,当我们调用停止录制的接口并回调之后,视频就被保存到我们指定的路径了。

- (void)setupMovieWriter {
NSString *videoPath = [SCFileHelper randomFilePathInTmpWithSuffix:@".m4v"];
NSURL *videoURL = [NSURL fileURLWithPath:videoPath];
CGSize videoSize = self.videoSize;

self.movieWriter = [[GPUImageMovieWriter alloc] initWithMovieURL:videoURL
size:videoSize];

GPUImageFilter *lastFilter = self.currentFilterHandler.lastFilter;
[lastFilter addTarget:self.movieWriter];
self.camera.audioEncodingTarget = self.movieWriter;
self.movieWriter.shouldPassthroughAudio = YES;

self.currentTmpVideoPath = videoPath;
}
- (void)recordVideo {
[self setupMovieWriter];
[self.movieWriter startRecording];
}
- (void)stopRecordVideoWithCompletion:(RecordVideoResult)completion {
@weakify(self);
[self.movieWriter finishRecordingWithCompletionHandler:^{
@strongify(self);
[self removeMovieWriter];
if (completion) {
completion(self.currentTmpVideoPath);
}
}];
}

2、多段录制

在 GPUImage 中并没有提供多段录制的功能,需要我们自己去实现。

首先,我们要重复单段视频的录制过程,这样我们就有了多段视频的文件路径。然后主要实现两个功能,一个是 AVPlayer 的多段视频循环播放;另一个是通过 AVComposition 来合并多段视频,并用 AVAssetExportSession 来导出新的视频。

整个过程逻辑并不复杂,出于篇幅的考虑,代码就不贴了,请到项目中查看。

三、保存

在拍照或者录视频结束后,通过 PhotoKit 保存到相册里。

1、保存图片

- (void)writeImageToSavedPhotosAlbum:(UIImage *)image
completion:(void (^)(BOOL success))completion {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
[PHAssetChangeRequest creationRequestForAssetFromImage:image];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
if (completion) {
completion(success);
}
}];
}

2、保存视频

- (void)saveVideo:(NSString *)path completion:(void (^)(BOOL success))completion {
NSURL *url = [NSURL fileURLWithPath:path];
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
[PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:url];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) {
completion(success);
}
});
}];
}

四、闪光灯

系统的闪光灯类型通过 AVCaptureDevice 的 flashMode 属性来控制,其实只有三种,分别是:

  • AVCaptureFlashModeOff 关闭
  • AVCaptureFlashModeOn 开启(在拍照的时候会闪一下)
  • AVCaptureFlashModeAuto 自动(系统会自动判断当前的环境是否需要闪光灯)

但是市面上的相机应用,一般还有一种常亮类型,这种类型在夜间的时候会比较适用。这个功能需要通过 torchMode 属性来实现,它其实是指手电筒。

我们对这个两个属性做一下封装,允许这四种类型来回切换,下面是根据封装的类型来同步系统类型的代码:

- (void)syncFlashState {
AVCaptureDevice *device = self.camera.inputCamera;
if (![device hasFlash] || self.camera.cameraPosition == AVCaptureDevicePositionFront) {
[self closeFlashIfNeed];
return;
}

[device lockForConfiguration:nil];

switch (self.flashMode) {
case SCCameraFlashModeOff:
device.torchMode = AVCaptureTorchModeOff;
device.flashMode = AVCaptureFlashModeOff;
break;
case SCCameraFlashModeOn:
device.torchMode = AVCaptureTorchModeOff;
device.flashMode = AVCaptureFlashModeOn;
break;
case SCCameraFlashModeAuto:
device.torchMode = AVCaptureTorchModeOff;
device.flashMode = AVCaptureFlashModeAuto;
break;
case SCCameraFlashModeTorch:
device.torchMode = AVCaptureTorchModeOn;
device.flashMode = AVCaptureFlashModeOff;
break;
default:
break;
}

[device unlockForConfiguration];
}


另外,由于前置摄像头不支持闪光灯,如果在前置的时候去切换闪光灯,只能修改我们封装的类型。所以在切换到后置的时候,需要去同步一下系统的闪光灯类型:


五、画幅比例

相机的比例通过设置 AVCaptureSession 的 sessionPreset 属性来实现。这个属性并不只意味着比例,也意味着分辨率。

由于不是所有的设备都支持高分辨率,所以这里只使用 AVCaptureSessionPreset640x480 和 AVCaptureSessionPreset1280x720 这两个分辨率,分别用来作为 3:4 和 9:16 的输出。

市面上的相机除了上面的两个比例外,一般还支持 1:1 和 Full (iPhoneX 系列的全屏)比例,但是系统并没有提供对应比例的 AVCaptureSessionPreset 。

这里可以通过 GPUImageCropFilter 来实现,这是 GPUImage 的一个内置滤镜,可以对输入的纹理进行裁剪。使用时通过 cropRegion 属性来传入一个归一化的裁剪区域。

切换比例的关键代码如下:

- (void)setRatio:(SCCameraRatio)ratio {
_ratio = ratio;

CGRect rect = CGRectMake(0, 0, 1, 1);
if (ratio == SCCameraRatio1v1) {
self.camera.captureSessionPreset = AVCaptureSessionPreset640x480;
CGFloat space = (4 - 3) / 4.0; // 竖直方向应该裁剪掉的空间
rect = CGRectMake(0, space / 2, 1, 1 - space);
} else if (ratio == SCCameraRatio4v3) {
self.camera.captureSessionPreset = AVCaptureSessionPreset640x480;
} else if (ratio == SCCameraRatio16v9) {
self.camera.captureSessionPreset = AVCaptureSessionPreset1280x720;
} else if (ratio == SCCameraRatioFull) {
self.camera.captureSessionPreset = AVCaptureSessionPreset1280x720;
CGFloat currentRatio = SCREEN_HEIGHT / SCREEN_WIDTH;
if (currentRatio > 16.0 / 9.0) { // 需要在水平方向裁剪
CGFloat resultWidth = 16.0 / currentRatio;
CGFloat space = (9.0 - resultWidth) / 9.0;
rect = CGRectMake(space / 2, 0, 1 - space, 1);
} else { // 需要在竖直方向裁剪
CGFloat resultHeight = 9.0 * currentRatio;
CGFloat space = (16.0 - resultHeight) / 16.0;
rect = CGRectMake(0, space / 2, 1, 1 - space);
}
}
[self.currentFilterHandler setCropRect:rect];
self.videoSize = [self videoSizeWithRatio:ratio];
}

六、前后置切换

通过调用 GPUImageVideoCamera 的 rotateCamera 方法来实现。

- (void)rotateCamera {
[self.camera rotateCamera];
// 切换摄像头,同步一下闪光灯
[self syncFlashState];
}


七、对焦

AVCaptureDevice 的 focusMode 用来设置聚焦模式,focusPointOfInterest 用来设置聚焦点;exposureMode 用来设置曝光模式,exposurePointOfInterest 用来设置曝光点。

前置摄像头只支持设置曝光,后置摄像头支持设置曝光和聚焦,所以在设置之前要先判断是否支持。

需要注意的是,相机默认输出的图像是横向的,图像向右偏转。而前置摄像头又是镜像,所以图像是向左偏转。我们从 UIView 获得的触摸点,要经过相应的转化,才是正确的坐标。关键代码如下:

- (void)setFocusPoint:(CGPoint)focusPoint {
_focusPoint = focusPoint;

AVCaptureDevice *device = self.camera.inputCamera;

// 坐标转换
CGPoint currentPoint = CGPointMake(focusPoint.y / self.outputView.bounds.size.height, 1 - focusPoint.x / self.outputView.bounds.size.width);
if (self.camera.cameraPosition == AVCaptureDevicePositionFront) {
currentPoint = CGPointMake(currentPoint.x, 1 - currentPoint.y);
}

[device lockForConfiguration:nil];

if ([device isFocusPointOfInterestSupported] &&
[device isFocusModeSupported:AVCaptureFocusModeAutoFocus]) {
[device setFocusPointOfInterest:currentPoint];
[device setFocusMode:AVCaptureFocusModeAutoFocus];
}
if ([device isExposurePointOfInterestSupported] &&
[device isExposureModeSupported:AVCaptureExposureModeAutoExpose]) {
[device setExposurePointOfInterest:currentPoint];
[device setExposureMode:AVCaptureExposureModeAutoExpose];
}

[device unlockForConfiguration];
}

八、改变焦距

改变焦距简单来说就是画面的放大缩小,通过设置 AVCaptureDevice 的 videoZoomFactor 属性实现。

值得注意的是,这个属性有最大值和最小值,设置之前需要做好判断,否则会直接崩溃。代码如下:

- (void)setVideoScale:(CGFloat)videoScale {
_videoScale = videoScale;

videoScale = [self availableVideoScaleWithScale:videoScale];

AVCaptureDevice *device = self.camera.inputCamera;
[device lockForConfiguration:nil];
device.videoZoomFactor = videoScale;
[device unlockForConfiguration];
}

- (CGFloat)availableVideoScaleWithScale:(CGFloat)scale {
AVCaptureDevice *device = self.camera.inputCamera;

CGFloat maxScale = kMaxVideoScale;
CGFloat minScale = kMinVideoScale;
if (@available(iOS 11.0, *)) {
maxScale = device.maxAvailableVideoZoomFactor;
}

scale = MAX(scale, minScale);
scale = MIN(scale, maxScale);

return scale;
}

九、滤镜

1、滤镜的使用

当我们想使用一个滤镜的时候,只需要把它加到滤镜链里去,通过 addTarget: 方法实现。来看一下这个方法的定义:

- (void)addTarget:(id<GPUImageInput>)newTarget;

可以看到,只要实现了 GPUImageInput 协议,就可以成为滤镜链的下一个结点。

2、美颜滤镜

目前美颜效果已经成为相机应用的标配,我们也来给自己的相机加上美颜的效果。

美颜效果本质上是对图片做模糊,想要达到比较好的效果,需要结合人脸识别,只对人脸的部分进行模糊处理。这里并不去探究美颜算法的实现,直接找开源的美颜滤镜来用。

目前找到的实现效果比较好的是 LFGPUImageBeautyFilter ,虽然它的效果肯定比不上现在市面上的美颜类 APP,但是作为学习级别的 Demo 已经足够了。


3、自定义滤镜

打开 GPUImageFilter 的头文件,可以看到有下面这个方法:

- (id)initWithVertexShaderFromString:(NSString *)vertexShaderString 
fragmentShaderFromString:(NSString *)fragmentShaderString;

很容易理解,通过一个顶点着色器和一个片段着色器来初始化,并且可以看到是字符串类型。

另外,GPUImageFilter 中还内置了简单的顶点着色器和片段着色器,顶点着色器代码如下:

NSString *const kGPUImageVertexShaderString = SHADER_STRING
(
attribute vec4 position;
attribute vec4 inputTextureCoordinate;

varying vec2 textureCoordinate;

void main()
{
gl_Position = position;
textureCoordinate = inputTextureCoordinate.xy;
}
);

这里用到了 SHADER_STRING 宏,看一下它的定义:

#define STRINGIZE(x) #x
#define STRINGIZE2(x) STRINGIZE(x)
#define SHADER_STRING(text) @ STRINGIZE2(text)

在 #define 中的 # 是「字符串化」的意思,返回 C 语言风格字符串,而 SHADER_STRING 在字符串前面加了一个 @符号,则 SHADER_STRING 的定义就是将括号中的内容转化为 OC 风格的字符串。

我们之前都是为着色器代码单独创建两个文件,而在 GPUImageFilter 中直接以字符串的形式,写死在代码中,两种方式本质上没什么区别。

当我们想自定义一个滤镜的时候,只需要继承 GPUImageFilter 来定义一个子类,然后用相同的方式来定义两个保存着色器代码的字符串,并且用这两个字符串来初始化子类就可以了。

总结

通过上面的步骤,我们实现了一个具备基础功能的相机。之后会在这个相机的基础上,继续做一些有趣的尝试,欢迎持续关注~


作者:那月无痕
链接:https://www.jianshu.com/p/42f841051337


收起阅读 »

iOS 超隐匿的开发技巧 !!!

1、递归查看 view 的子视图(私有方法,没有代码提示)[self.view recursiveDescription]2、过滤字符串的特殊字符// 定义一个特殊字符的集合 NSCharacterSet *set = [NSCharacterSet char...
继续阅读 »

1、递归查看 view 的子视图(私有方法,没有代码提示)

[self.view recursiveDescription]
2、过滤字符串的特殊字符
// 定义一个特殊字符的集合
NSCharacterSet *set = [NSCharacterSet characterSetWithCharactersInString:
@"@/:;()¥「」"、[]{}#%-*+=_\\|~<>$€^•'@#$%^&*()_+'\""];
// 过滤字符串的特殊字符
NSString *newString = [trimString stringByTrimmingCharactersInSet:set];

3、Transform 属性

//平移按钮
CGAffineTransform transForm = self.buttonView.transform;
self.buttonView.transform = CGAffineTransformTranslate(transForm, 10, 0);

//旋转按钮
CGAffineTransform transForm = self.buttonView.transform;
self.buttonView.transform = CGAffineTransformRotate(transForm, M_PI_4);

//缩放按钮
self.buttonView.transform = CGAffineTransformScale(transForm, 1.2, 1.2);

//初始化复位
self.buttonView.transform = CGAffineTransformIdentity;

4、去掉分割线多余15pt

首先在viewDidLoad方法加入以下代码:
if ([self.tableView respondsToSelector:@selector(setSeparatorInset:)]) {
[self.tableView setSeparatorInset:UIEdgeInsetsZero];
}
if ([self.tableView respondsToSelector:@selector(setLayoutMargins:)]) {
[self.tableView setLayoutMargins:UIEdgeInsetsZero];
}
然后在重写willDisplayCell方法
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell
forRowAtIndexPath:(NSIndexPath *)indexPath{
if ([cell respondsToSelector:@selector(setSeparatorInset:)]) {
[cell setSeparatorInset:UIEdgeInsetsZero];
}
if ([cell respondsToSelector:@selector(setLayoutMargins:)]) {
[cell setLayoutMargins:UIEdgeInsetsZero];
}
}

5、计算耗时方法时间间隔

// 获取时间间隔
#define TICK CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
#define TOCK NSLog(@"Time: %f", CFAbsoluteTimeGetCurrent() - start)

6、Color颜色宏定义

// 随机颜色
#define RANDOM_COLOR [UIColor colorWithRed:arc4random_uniform(256) / 255.0 green:arc4random_uniform(256) / 255.0 blue:arc4random_uniform(256) / 255.0 alpha:1]
// 颜色(RGB)
#define RGBCOLOR(r, g, b) [UIColor colorWithRed:(r)/255.0f green:(g)/255.0f blue:(b)/255.0f alpha:1]
// 利用这种方法设置颜色和透明值,可不影响子视图背景色
#define RGBACOLOR(r, g, b, a) [UIColor colorWithRed:(r)/255.0f green:(g)/255.0f blue:(b)/255.0f alpha:(a)]

7、退出iOS应用

- (void)exitApplication {
AppDelegate *app = [UIApplication sharedApplication].delegate;
UIWindow *window = app.window;

[UIView animateWithDuration:1.0f animations:^{
window.alpha = 0;
} completion:^(BOOL finished) {
exit(0);
}];
}

8、NSArray 快速求总和 最大值 最小值 和 平均值

NSArray *array = [NSArray arrayWithObjects:@"2.0", @"2.3", @"3.0", @"4.0", @"10", nil];
CGFloat sum = [[array valueForKeyPath:@"@sum.floatValue"] floatValue];
CGFloat avg = [[array valueForKeyPath:@"@avg.floatValue"] floatValue];
CGFloat max =[[array valueForKeyPath:@"@max.floatValue"] floatValue];
CGFloat min =[[array valueForKeyPath:@"@min.floatValue"] floatValue];
NSLog(@"%f\n%f\n%f\n%f",sum,avg,max,min);

9、Debug栏打印时自动把Unicode编码转化成汉字

 DXXcodeConsoleUnicodePlugin 插件

10、自动生成模型代码的插件

ESJsonFormat-for-Xcode

12、隐藏导航栏上的返回字体

//Swift
UIBarButtonItem.appearance().setBackButtonTitlePositionAdjustment(UIOffsetMake(0, -60), forBarMetrics: .Default)
//OC
[[UIBarButtonItem appearance] setBackButtonTitlePositionAdjustment:UIOffsetMake(0, -60) forBarMetrics:UIBarMetricsDefault];

13、设置导航栏透明

//方法一:设置透明度
[[[self.navigationController.navigationBar subviews]objectAtIndex:0] setAlpha:0.1];
//方法二:设置背景图片
/**
* 设置导航栏,使其透明
*
*/

- (void)setNavigationBarColor:(UIColor *)color targetController:(UIViewController *)targetViewController{
//导航条的颜色 以及隐藏导航条的颜色targetViewController.navigationController.navigationBar.shadowImage = [[UIImage alloc]init];
CGRect rect=CGRectMake(0.0f, 0.0f, 1.0f, 1.0f); UIGraphicsBeginImageContext(rect.size);
CGContextRef context = UIGraphicsGetCurrentContext(); CGContextSetFillColorWithColor(context, [color CGColor]); CGContextFillRect(context, rect);
UIImage *theImage = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); [targetViewController.navigationController.navigationBar setBackgroundImage:theImage forBarMetrics:UIBarMetricsDefault];
}

14、解决同时按两个按钮进两个view的问题

[button setExclusiveTouch:YES];

15、修改 textFieldplaceholder 字体颜色和大小

  textField.placeholder = @"请输入手机号码";
[textField setValue:[UIColor redColor] forKeyPath:@"_placeholderLabel.textColor"];
[textField setValue:[UIFont boldSystemFontOfSize:13] forKeyPath:@"_placeholderLabel.font"];

16、UIImage 与字符串互转

//图片转字符串  
-(NSString *)UIImageToBase64Str:(UIImage *) image
{
NSData *data = UIImageJPEGRepresentation(image, 1.0f);
NSString *encodedImageStr = [data base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength];
return encodedImageStr;
}

//字符串转图片
-(UIImage *)Base64StrToUIImage:(NSString *)_encodedImageStr
{
NSData *_decodedImageData = [[NSData alloc] initWithBase64Encoding:_encodedImageStr];
UIImage *_decodedImage = [UIImage imageWithData:_decodedImageData];
return _decodedImage;
}



收起阅读 »

自定义 UITableView 的 Cell 删除样式

一、需求先说下我们的需求,在一个 tableView 中,左滑删除某个 cell 时,需要展示如下图所示的样式,浅灰色底色,橘红色 文字。1、修改删除按钮的文字修改删除按钮的文字很简单,只需要实现下面的方法:/...
继续阅读 »

一、需求

先说下我们的需求,在一个 tableView 中,左滑删除某个 cell 时,需要展示如下图所示的样式,浅灰色底色,橘红色 文字。



1、修改删除按钮的文字

修改删除按钮的文字很简单,只需要实现下面的方法:

//修改编辑按钮文字
- (NSString *)tableView:(UITableView *)tableView titleForDeleteConfirmationButtonForRowAtIndexPath:(NSIndexPath *)indexPath {
return @"Delete";
}

2、修改删除按钮的背景颜色和文字颜色

首先我按照常规的在 editActionsForRowAtIndexPath 方法中处理:

- (NSArray*)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(nonnull NSIndexPath *)indexPath {
// delete action
UITableViewRowAction *deleteAction = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleNormal title:@"Delete" handler:^(UITableViewRowAction *action, NSIndexPath *indexPath) {

}];
deleteAction.backgroundColor = [UIColor colorWithHexString:Color_F7F7F7];
return @[deleteAction];
}


但发现只能通过 deleteAction.backgroundColor 改变背景颜色,却无法改变字体颜色。
而系统提供的几种 UITableViewRowActionStyle 也不符合我的需求:

typedef NS_ENUM(NSInteger, UITableViewRowActionStyle) {
UITableViewRowActionStyleDefault = 0,
UITableViewRowActionStyleDestructive = UITableViewRowActionStyleDefault,
UITableViewRowActionStyleNormal
}

默认 UITableViewRowActionStyleDefault 的样子:


UITableViewRowActionStyleNormal 的样子:



二、解决办法

解决办法有我从网上找来的,最新的 iOS12 的则是我自己试验出来的。解决办法也会随着 iOS 系统的升级而发生变化,因为系统控件的结构可能会发生变化,导致遍历不出要找到的视图。

1、系统版本 < iOS 11 的处理方式

iOS11 以前的处理方式是遍历 Cell 的 subViews 子视图找到 UITableViewCellDeleteConfirmationView ,只需在 Cell 的 .m 文件中添加 layoutSubviews 代码:


- (void)layoutSubviews {
[super layoutSubviews];
for (UIView *subView in self.subviews) {
if ([NSStringFromClass([subView class]) isEqualToString:@"UITableViewCellDeleteConfirmationView"]) {

UIButton *bgView = (UIButton *)[subView.subviews firstObject];
bgView.backgroundColor = [UIColor colorWithHexString:Color_F0F0F0];
[bgView setTitleColor:[UIColor colorWithHexString:Color_MainColor] forState:UIControlStateNormal];
}
}
}

2、iOS 11 <= 系统版本 < iOS 13 的处理方式

这个系统版本需要在 tableView 中添加 layoutSubviews,我是写了一个 tableView 的父类,在父类的 .m 中添加了 layoutSubviews。同时我还在 tableView 的 .h 中声明一个 cellHeightRef 来修改删除 Button 的高度。

  • 简单来说就是在 tableView 的 subviews 中遍历出 UISwipeActionPullView,再从 UISwipeActionPullView 中遍历出 UISwipeActionStandardButton。再修改 button 的样式即可。

- (void)layoutSubviews {
[super layoutSubviews];
for (UIView *subview in self.subviews) {
if ([subview isKindOfClass:NSClassFromString(@"UISwipeActionPullView")]) {
//修改背景颜色
subview.backgroundColor = [UIColor clearColor];
//修改按钮-颜色
UIButton *swipeActionStandardBtn = subview.subviews[0];
if ([swipeActionStandardBtn isKindOfClass:NSClassFromString(@"UISwipeActionStandardButton")]) {
CGFloat swipeActionStandardBtnOX = swipeActionStandardBtn.frame.origin.x;
CGFloat swipeActionStandardBtnW = swipeActionStandardBtn.frame.size.width;
swipeActionStandardBtn.frame = CGRectMake(swipeActionStandardBtnOX, 0, swipeActionStandardBtnW, self.cellHeightRef - 10);

[swipeActionStandardBtn setTitleColor:[UIColor colorWithHexString:Color_MainColor] forState:UIControlStateNormal];
[swipeActionStandardBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateHighlighted];
[swipeActionStandardBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateSelected];
}
}
}
}

3、系统版本 == iOS 13 的处理方式(大于 13 的还未知,等出了新的我再更新)

iOS 13 中和上面的 iOS 13 之前的方法几乎一样,只不过是 tableView 内部的父子视图关系发生了变化, UISwipeActionStandardButton 位置变了。即原来把 subView 改为了 subview.subviews.firstObject,才能得到 UISwipeActionStandardButton


- (void)layoutSubviews {
[super layoutSubviews];
for (UIView *subview in self.subviews) {
if ([subview.subviews.firstObject isKindOfClass:NSClassFromString(@"UISwipeActionPullView")]) {
//修改背景颜色
subview.subviews.firstObject.backgroundColor = [UIColor clearColor];
//修改按钮-颜色
UIButton *swipeActionStandardBtn = subview.subviews.firstObject.subviews[0];
if ([swipeActionStandardBtn isKindOfClass:NSClassFromString(@"UISwipeActionStandardButton")]) {
CGFloat swipeActionStandardBtnOX = swipeActionStandardBtn.frame.origin.x;
CGFloat swipeActionStandardBtnW = swipeActionStandardBtn.frame.size.width;
swipeActionStandardBtn.frame = CGRectMake(swipeActionStandardBtnOX, 0, swipeActionStandardBtnW, self.cellHeightRef - 10);

[swipeActionStandardBtn setTitleColor:[UIColor colorWithHexString:Color_MainColor] forState:UIControlStateNormal];
[swipeActionStandardBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateHighlighted];
[swipeActionStandardBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateSelected];
}
}
}
}

作者:凡几多
链接:https://www.jianshu.com/p/7bfa622cf4dd




收起阅读 »

NSMutableString 不要用 copy

疑问:我们都知道 copy 一般用来修饰 有对应可变类型的不可变对象上,比如 NSString,NSArray 和 NSDictionary。那么为什么不推荐用 copy 去修饰&...
继续阅读 »

疑问:

我们都知道 copy 一般用来修饰 有对应可变类型的不可变对象上,比如 NSString,NSArray 和 NSDictionary。那么为什么不推荐用 copy 去修饰 NSMutableString 和 NSMutableArray 而是用 strong 呢?

测试:

平时没怎么关注过这个问题,那么我就来测试一下。

一、先测试一下为什么 NSString 要用 copy

首先定义两个字符串属性,一个 strong 一个 copy

用 strong 修饰
@property (nonatomic, strong) NSString *str_strong;

用 copy 修饰
@property (nonatomic, copy) NSString *str_copy;
1、用可变字符串 NSMutableString 赋值:
- (void)viewDidLoad {
[super viewDidLoad];
NSMutableString *mutString = [[NSMutableString alloc] initWithFormat:@"原可变字符串"];
// 赋值
self.str_strong = mutString;
self.str_copy = mutString;
// 追加字符串
[mutString appendString:@"+++追加字符串"];

NSLog(@"\n mutString: %@, %p, %p \n str_strong: %@, %p, %p \n str_copy: %@, %p, %p \n" , mutString, mutString, &mutString, self.str_strong, _str_strong, &_str_strong, self.str_copy, _str_copy, &_str_copy);
}
打印结果如下:
 mutString: 原可变字符串+++追加字符串, 0x2828ab6f0, 0x16f027af8 
str_strong: 原可变字符串+++追加字符串, 0x2828ab6f0, 0x127f0cab0
str_copy: 原可变字符串, 0x2828ab8a0, 0x127f0cab8

可以看出 str_strong 和 mutString 指向对象内存是一样的,因为 strong 是 浅拷贝(指针拷贝),他们指向的都是同一个对象,地址没有变化,值当然也就一样了。
str_copy 指向对象的内存地址和他们不一样,因为 str_copy 对象使用的 copy 深拷贝,是一个新的对象,开辟了新的内存地址,不用以前的地址。

2、用不可变字符串 NSString 赋值:
- (void)viewDidLoad {
[super viewDidLoad];
NSString *str = [[NSString alloc] initWithFormat:@"不可变字符串"];
//进行赋值
self.str_strong = str;
self.str_copy = str;
NSLog(@"\n str: %@, %p, %p \n str_strong: %@, %p, %p \n str_copy: %@, %p, %p \n" , str, str, &str, self.str_strong, _str_strong, &_str_strong, self.str_copy, _str_copy, &_str_copy);
}

打印结果如下:

 str: 不可变字符串, 0x283d55860, 0x16fbcbaf8 
str_strong: 不可变字符串, 0x283d55860, 0x100a15bf0
str_copy: 不可变字符串, 0x283d55860, 0x100a15bf8

通过打印结果可以看出,str、str_strong 和 str_copy 这三者指向对象内存一样,不管是 strong 还是 copy 在这里都进行了 浅拷贝,没有重新开辟新的空间,因为这回的str 是 NSString,是不可变的。

所以一般我们是不希望我们创建的 NSString 字符串跟着之后的赋值 mutString 变化而变化的,所以都用 copy 。当然如果你希望字串的值跟着 mutString 变化,也可以使用 strong
但是,如果你创建的是 NSMutableString,那么不要用 copy

二、NSMutableString 不要用 copy

我们使用 NSMutableString 肯定是想用字符串的可变这个属性,但如果你用 copy 去修饰,那么生成的将会是不可变的,当你去调用可变方法时,程序将会崩溃!
测试:

用 copy 修饰 NSMutableString
@property (nonatomic, copy) NSMutableString *mutstr_copy;



同理,也不要对 NSMutableArray 和 NSMutableDictionary 使用 copy 修饰,不然也有可能出现崩溃。

三、总结

  • 1、当原字符串是 NSString ,即不可变字符串时,不管是 strong 还是 copy 属性的对象,都指向原对象,copy操作也只是做了浅拷贝。

  • 2、当原字符串是 NSMutableString 时,即可变字符串时,strong 属性只是增加了原字符串的引用计数,而 copy 属性则是对原字符串做了次深拷贝,产生一个新的对象,且 copy 属性对象指向这个新的对象,且这个 copy 属性对象的类型始终是 NSString,而不是NSMutableString,因此其是不可变的,这时候调用可变操作,将会造成崩溃!

  • 3、 因为 NSMutableString 是 NSString 的子类,父类指针可以指向子类对象,使用 copy 的目的是为了让本对象的属性不受外界影响,这样无论给我传入是一个可变对象还是不可变对象,我本身持有的就是一个不可变的副本,这样更安全。

所以,在声明 NSString 属性时,一般我们都不希望它改变,所以大多数情况下,我们建议用 copy,以免因可变字符串的修改导致的一些非预期问题。而在声明 NSMutableString 则需要使用 strong

举一反三:

把 NSMutableArray 用 copy 修饰有时就会崩溃,因为对这个数组进行了增删改操作,而 copy 后的数组变成了不可变数组 NSArray ,没有响应的增删改方法,所以就崩溃了。

  • 当修饰可变类型的属性时,如 NSMutableArray、NSMutableDictionary、NSMutableString,用 strong
    当修饰不可变类型的属性时,如 NSArray、NSDictionary、NSString,用 copy


作者:凡几多
链接:https://www.jianshu.com/p/5d138efee024

收起阅读 »