注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

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 工作。

收起阅读 »

iOS 高效绘图 二

异步绘制    UIKit的单线程天性意味着寄宿图通畅要在主线程上更新,这意味着绘制会打断用户交互,甚至让整个app看起来处于无响应状态。我们对此无能为力,但是如果能避免用户等待绘制完成就好多了。  ...
继续阅读 »

异步绘制

    UIKit的单线程天性意味着寄宿图通畅要在主线程上更新,这意味着绘制会打断用户交互,甚至让整个app看起来处于无响应状态。我们对此无能为力,但是如果能避免用户等待绘制完成就好多了。

    针对这个问题,有一些方法可以用到:一些情况下,我们可以推测性地提前在另外一个线程上绘制内容,然后将由此绘出的图片直接设置为图层的内容。这实现起来可能不是很方便,但是在特定情况下是可行的。Core Animation提供了一些选择:CATiledLayerdrawsAsynchronously属性。

CATiledLayer

    我们在第六章简单探索了一下CATiledLayer。除了将图层再次分割成独立更新的小块(类似于脏矩形自动更新的概念),CATiledLayer还有一个有趣的特性:在多个线程中为每个小块同时调用-drawLayer:inContext:方法。这就避免了阻塞用户交互而且能够利用多核心新片来更快地绘制。只有一个小块的CATiledLayer是实现异步更新图片视图的简单方法。

drawsAsynchronously

    iOS 6中,苹果为CALayer引入了这个令人好奇的属性,drawsAsynchronously属性对传入-drawLayer:inContext:的CGContext进行改动,允许CGContext延缓绘制命令的执行以至于不阻塞用户交互。

    它与CATiledLayer使用的异步绘制并不相同。它自己的-drawLayer:inContext:方法只会在主线程调用,但是CGContext并不等待每个绘制命令的结束。相反地,它会将命令加入队列,当方法返回时,在后台线程逐个执行真正的绘制。

    根据苹果的说法。这个特性在需要频繁重绘的视图上效果最好(比如我们的绘图应用,或者诸如UITableViewCell之类的),对那些只绘制一次或很少重绘的图层内容来说没什么太大的帮助。

总结

本章我们主要围绕用Core Graphics软件绘制讨论了一些性能挑战,然后探索了一些改进方法:比如提高绘制性能或者减少需要绘制的数量。

第14章,『图像IO』,我们将讨论图片的载入性能。

收起阅读 »

iOS 高效绘图 一

高效绘图不必要的效率考虑往往是性能问题的万恶之源。 ——William Allan Wulf    在第12章『速度的曲率』我们学习如何用Instruments来诊断Core Animation性能问题。在构建一个iOS...
继续阅读 »

高效绘图

不必要的效率考虑往往是性能问题的万恶之源。 ——William Allan Wulf

    在第12章『速度的曲率』我们学习如何用Instruments来诊断Core Animation性能问题。在构建一个iOS app的时候会遇到很多潜在的性能陷阱,但是在本章我们将着眼于有关绘制的性能问题。

软件绘图

    术语绘图通常在Core Animation的上下文中指代软件绘图(意即:不由GPU协助的绘图)。在iOS中,软件绘图通常是由Core Graphics框架完成来完成。但是,在一些必要的情况下,相比Core Animation和OpenGL,Core Graphics要慢了不少。

    软件绘图不仅效率低,还会消耗可观的内存。CALayer只需要一些与自己相关的内存:只有它的寄宿图会消耗一定的内存空间。即使直接赋给contents属性一张图片,也不需要增加额外的照片存储大小。如果相同的一张图片被多个图层作为contents属性,那么他们将会共用同一块内存,而不是复制内存块。

    但是一旦你实现了CALayerDelegate协议中的-drawLayer:inContext:方法或者UIView中的-drawRect:方法(其实就是前者的包装方法),图层就创建了一个绘制上下文,这个上下文需要的大小的内存可从这个算式得出:图层宽*图层高*4字节,宽高的单位均为像素。对于一个在Retina iPad上的全屏图层来说,这个内存量就是 2048*1526*4字节,相当于12MB内存,图层每次重绘的时候都需要重新抹掉内存然后重新分配。

    软件绘图的代价昂贵,除非绝对必要,你应该避免重绘你的视图。提高绘制性能的秘诀就在于尽量避免去绘制。


矢量图形

    我们用Core Graphics来绘图的一个通常原因就是只是用图片或是图层效果不能轻易地绘制出矢量图形。矢量绘图包含一下这些:

  • 任意多边形(不仅仅是一个矩形)
  • 斜线或曲线
  • 文本
  • 渐变

    举个例子,清单13.1 展示了一个基本的画线应用。这个应用将用户的触摸手势转换成一个UIBezierPath上的点,然后绘制成视图。我们在一个UIView子类DrawingView中实现了所有的绘制逻辑,这个情况下我们没有用上view controller。但是如果你喜欢你可以在view controller中实现触摸事件处理。图13.1是代码运行结果。

清单13.1 用Core Graphics实现一个简单的绘图应用

#import "DrawingView.h"

@interface DrawingView ()

@property (nonatomic, strong) UIBezierPath *path;

@end

@implementation DrawingView

- (void)awakeFromNib
{
//create a mutable path
self.path = [[UIBezierPath alloc] init];
self.path.lineJoinStyle = kCGLineJoinRound;
self.path.lineCapStyle = kCGLineCapRound;

self.path.lineWidth = 5;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//move the path drawing cursor to the starting point
[self.path moveToPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the current point
CGPoint point = [[touches anyObject] locationInView:self];

//add a new line segment to our path
[self.path addLineToPoint:point];

//redraw the view
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect
{
//draw path
[[UIColor clearColor] setFill];
[[UIColor redColor] setStroke];
[self.path stroke];
}
@end

图13.1

图13.1 用Core Graphics做一个简单的『素描』

    这样实现的问题在于,我们画得越多,程序就会越慢。因为每次移动手指的时候都会重绘整个贝塞尔路径(UIBezierPath),随着路径越来越复杂,每次重绘的工作就会增加,直接导致了帧数的下降。看来我们需要一个更好的方法了。

    Core Animation为这些图形类型的绘制提供了专门的类,并给他们提供硬件支持(第六章『专有图层』有详细提到)。CAShapeLayer可以绘制多边形,直线和曲线。CATextLayer可以绘制文本。CAGradientLayer用来绘制渐变。这些总体上都比Core Graphics更快,同时他们也避免了创造一个寄宿图。

    如果稍微将之前的代码变动一下,用CAShapeLayer替代Core Graphics,性能就会得到提高(见清单13.2).虽然随着路径复杂性的增加,绘制性能依然会下降,但是只有当非常非常浮躁的绘制时才会感到明显的帧率差异。

清单13.2 用CAShapeLayer重新实现绘图应用

#import "DrawingView.h"
#import

@interface DrawingView ()

@property (nonatomic, strong) UIBezierPath *path;

@end

@implementation DrawingView

+ (Class)layerClass
{
//this makes our view create a CAShapeLayer
//instead of a CALayer for its backing layer
return [CAShapeLayer class];
}

- (void)awakeFromNib
{
//create a mutable path
self.path = [[UIBezierPath alloc] init];

//configure the layer
CAShapeLayer *shapeLayer = (CAShapeLayer *)self.layer;
shapeLayer.strokeColor = [UIColor redColor].CGColor;
shapeLayer.fillColor = [UIColor clearColor].CGColor;
shapeLayer.lineJoin = kCALineJoinRound;
shapeLayer.lineCap = kCALineCapRound;
shapeLayer.lineWidth = 5;
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//move the path drawing cursor to the starting point
[self.path moveToPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the current point
CGPoint point = [[touches anyObject] locationInView:self];

//add a new line segment to our path
[self.path addLineToPoint:point];

//update the layer with a copy of the path
((CAShapeLayer *)self.layer).path = self.path.CGPath;
}
@end

脏矩形

    有时候用CAShapeLayer或者其他矢量图形图层替代Core Graphics并不是那么切实可行。比如我们的绘图应用:我们用线条完美地完成了矢量绘制。但是设想一下如果我们能进一步提高应用的性能,让它就像一个黑板一样工作,然后用『粉笔』来绘制线条。模拟粉笔最简单的方法就是用一个『线刷』图片然后将它粘贴到用户手指碰触的地方,但是这个方法用CAShapeLayer没办法实现。

    我们可以给每个『线刷』创建一个独立的图层,但是实现起来有很大的问题。屏幕上允许同时出现图层上线数量大约是几百,那样我们很快就会超出的。这种情况下我们没什么办法,就用Core Graphics吧(除非你想用OpenGL做一些更复杂的事情)。

    我们的『黑板』应用的最初实现见清单13.3,我们更改了之前版本的DrawingView,用一个画刷位置的数组代替UIBezierPath。图13.2是运行结果

清单13.3 简单的类似黑板的应用

#import "DrawingView.h"
#import
#define BRUSH_SIZE 32

@interface DrawingView ()

@property (nonatomic, strong) NSMutableArray *strokes;

@end

@implementation DrawingView

- (void)awakeFromNib
{
//create array
self.strokes = [NSMutableArray array];
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the starting point
CGPoint point = [[touches anyObject] locationInView:self];

//add brush stroke
[self addBrushStrokeAtPoint:point];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
//get the touch point
CGPoint point = [[touches anyObject] locationInView:self];

//add brush stroke
[self addBrushStrokeAtPoint:point];
}

- (void)addBrushStrokeAtPoint:(CGPoint)point
{
//add brush stroke to array
[self.strokes addObject:[NSValue valueWithCGPoint:point]];

//needs redraw
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect
{
//redraw strokes
for (NSValue *value in self.strokes) {
//get point
CGPoint point = [value CGPointValue];

//get brush rect
CGRect brushRect = CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE);

//draw brush stroke 
[[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect];
}
}
@end

图13.2

图13.2 用程序绘制一个简单的『素描』

    这个实现在模拟器上表现还不错,但是在真实设备上就没那么好了。问题在于每次手指移动的时候我们就会重绘之前的线刷,即使场景的大部分并没有改变。我们绘制地越多,就会越慢。随着时间的增加每次重绘需要更多的时间,帧数也会下降(见图13.3),如何提高性能呢?

图13.3

图13.3 帧率和线条质量会随时间下降。

    为了减少不必要的绘制,Mac OS和iOS设备将会把屏幕区分为需要重绘的区域和不需要重绘的区域。那些需要重绘的部分被称作『脏区域』。在实际应用中,鉴于非矩形区域边界裁剪和混合的复杂性,通常会区分出包含指定视图的矩形位置,而这个位置就是『脏矩形』。

    当一个视图被改动过了,TA可能需要重绘。但是很多情况下,只是这个视图的一部分被改变了,所以重绘整个寄宿图就太浪费了。但是Core Animation通常并不了解你的自定义绘图代码,它也不能自己计算出脏区域的位置。然而,你的确可以提供这些信息。

    当你检测到指定视图或图层的指定部分需要被重绘,你直接调用-setNeedsDisplayInRect:来标记它,然后将影响到的矩形作为参数传入。这样就会在一次视图刷新时调用视图的-drawRect:(或图层代理的-drawLayer:inContext:方法)。

    传入-drawLayer:inContext:CGContext参数会自动被裁切以适应对应的矩形。为了确定矩形的尺寸大小,你可以用CGContextGetClipBoundingBox()方法来从上下文获得大小。调用-drawRect()会更简单,因为CGRect会作为参数直接传入。

    你应该将你的绘制工作限制在这个矩形中。任何在此区域之外的绘制都将被自动无视,但是这样CPU花在计算和抛弃上的时间就浪费了,实在是太不值得了。

    相比依赖于Core Graphics为你重绘,裁剪出自己的绘制区域可能会让你避免不必要的操作。那就是说,如果你的裁剪逻辑相当复杂,那还是让Core Graphics来代劳吧,记住:当你能高效完成的时候才这样做。

    清单13.4 展示了一个-addBrushStrokeAtPoint:方法的升级版,它只重绘当前线刷的附近区域。另外也会刷新之前线刷的附近区域,我们也可以用CGRectIntersectsRect()来避免重绘任何旧的线刷以不至于覆盖已更新过的区域。这样做会显著地提高绘制效率(见图13.4)

    清单13.4 用-setNeedsDisplayInRect:来减少不必要的绘制

- (void)addBrushStrokeAtPoint:(CGPoint)point
{
//add brush stroke to array
[self.strokes addObject:[NSValue valueWithCGPoint:point]];

//set dirty rect
[self setNeedsDisplayInRect:[self brushRectForPoint:point]];
}

- (CGRect)brushRectForPoint:(CGPoint)point
{
return CGRectMake(point.x - BRUSH_SIZE/2, point.y - BRUSH_SIZE/2, BRUSH_SIZE, BRUSH_SIZE);
}

- (void)drawRect:(CGRect)rect
{
//redraw strokes
for (NSValue *value in self.strokes) {
//get point
CGPoint point = [value CGPointValue];

//get brush rect
CGRect brushRect = [self brushRectForPoint:point];

//only draw brush stroke if it intersects dirty rect
if (CGRectIntersectsRect(rect, brushRect)) {
//draw brush stroke
[[UIImage imageNamed:@"Chalk.png"] drawInRect:brushRect];
}
}
}

图13.4

图13.4 更好的帧率和顺滑线条

收起阅读 »

iOS 性能调优 三

Instruments    Instruments是Xcode套件中没有被充分利用的一个工具。很多iOS开发者从没用过Instruments,或者只是用Leaks工具检测循环引用。实际上有很多Instruments工具...
继续阅读 »

Instruments

    Instruments是Xcode套件中没有被充分利用的一个工具。很多iOS开发者从没用过Instruments,或者只是用Leaks工具检测循环引用。实际上有很多Instruments工具,包括为动画性能调优的东西。

    你可以通过在菜单中选择Profile选项来打开Instruments(在这之前,记住要把目标设置成iOS设备,而不是模拟器)。然后将会显示出图12.1(如果没有看到所有选项,你可能设置成了模拟器选项)。

图12.1

图12.1 Instruments工具选项窗口

    就像之前提到的那样,你应该始终将程序设置成发布选项。幸运的是,配置文件默认就是发布选项,所以你不需要在分析的时候调整编译策略。

我们将讨论如下几个工具:

  • 时间分析器 - 用来测量被方法/函数打断的CPU使用情况。

  • Core Animation - 用来调试各种Core Animation性能问题。

  • OpenGL ES驱动 - 用来调试GPU性能问题。这个工具在编写Open GL代码的时候很有用,但有时也用来处理Core Animation的工作。

    Instruments的一个很棒的功能在于它可以创建我们自定义的工具集。除了你初始选择的工具之外,如果在Instruments中打开Library窗口,你可以拖拽别的工具到左侧边栏。我们将创建以上我们提到的三个工具,然后就可以并行使用了(见图12.2)。

图12.2

图12.2 添加额外的工具到Instruments侧边栏

时间分析器

    时间分析器工具用来检测CPU的使用情况。它可以告诉我们程序中的哪个方法正在消耗大量的CPU时间。使用大量的CPU并不一定是个问题 - 你可能期望动画路径对CPU非常依赖,因为动画往往是iOS设备中最苛刻的任务。

    但是如果你有性能问题,查看CPU时间对于判断性能是不是和CPU相关,以及定位到函数都很有帮助(见图12.3)。

图12.3

图12.3 时间分析器工具

    时间分析器有一些选项来帮助我们定位到我们关心的的方法。可以使用左侧的复选框来打开。其中最有用的是如下几点:

  • 通过线程分离 - 这可以通过执行的线程进行分组。如果代码被多线程分离的话,那么就可以判断到底是哪个线程造成了问题。

  • 隐藏系统库 - 可以隐藏所有苹果的框架代码,来帮助我们寻找哪一段代码造成了性能瓶颈。由于我们不能优化框架方法,所以这对定位到我们能实际修复的代码很有用。

  • 只显示Obj-C代码 - 隐藏除了Objective-C之外的所有代码。大多数内部的Core Animation代码都是用C或者C++函数,所以这对我们集中精力到我们代码中显式调用的方法就很有用。

Core Animation

    Core Animation工具用来监测Core Animation性能。它给我们提供了周期性的FPS,并且考虑到了发生在程序之外的动画(见图12.4)。

图12.4

图12.4 使用可视化调试选项的Core Animation工具

    Core Animation工具也提供了一系列复选框选项来帮助调试渲染瓶颈:

  • Color Blended Layers - 这个选项基于渲染程度对屏幕中的混合区域进行绿到红的高亮(也就是多个半透明图层的叠加)。由于重绘的原因,混合对GPU性能会有影响,同时也是滑动或者动画帧率下降的罪魁祸首之一。

  • ColorHitsGreenandMissesRed - 当使用shouldRasterizep属性的时候,耗时的图层绘制会被缓存,然后当做一个简单的扁平图片呈现。当缓存再生的时候这个选项就用红色对栅格化图层进行了高亮。如果缓存频繁再生的话,就意味着栅格化可能会有负面的性能影响了(更多关于使用shouldRasterize的细节见第15章“图层性能”)。

  • Color Copied Images - 有时候寄宿图片的生成意味着Core Animation被强制生成一些图片,然后发送到渲染服务器,而不是简单的指向原始指针。这个选项把这些图片渲染成蓝色。复制图片对内存和CPU使用来说都是一项非常昂贵的操作,所以应该尽可能的避免。

  • Color Immediately - 通常Core Animation Instruments以每毫秒10次的频率更新图层调试颜色。对某些效果来说,这显然太慢了。这个选项就可以用来设置每帧都更新(可能会影响到渲染性能,而且会导致帧率测量不准,所以不要一直都设置它)。

  • Color Misaligned Images - 这里会高亮那些被缩放或者拉伸以及没有正确对齐到像素边界的图片(也就是非整型坐标)。这些中的大多数通常都会导致图片的不正常缩放,如果把一张大图当缩略图显示,或者不正确地模糊图像,那么这个选项将会帮你识别出问题所在。

  • Color Offscreen-Rendered Yellow - 这里会把那些需要离屏渲染的图层高亮成黄色。这些图层很可能需要用shadowPath或者shouldRasterize来优化。

  • Color OpenGL Fast Path Blue - 这个选项会对任何直接使用OpenGL绘制的图层进行高亮。如果仅仅使用UIKit或者Core Animation的API,那么不会有任何效果。如果使用GLKView或者CAEAGLLayer,那如果不显示蓝色块的话就意味着你正在强制CPU渲染额外的纹理,而不是绘制到屏幕。

  • Flash Updated Regions - 这个选项会对重绘的内容高亮成黄色(也就是任何在软件层面使用Core Graphics绘制的图层)。这种绘图的速度很慢。如果频繁发生这种情况的话,这意味着有一个隐藏的bug或者说通过增加缓存或者使用替代方案会有提升性能的空间。

    这些高亮图层的选项同样在iOS模拟器的调试菜单也可用(图12.5)。我们之前说过用模拟器测试性能并不好,但如果你能通过这些高亮选项识别出性能问题出在什么地方的话,那么使用iOS模拟器来验证问题是否解决也是比真机测试更有效的。

图12.5

图12.5 iOS模拟器中Core Animation可视化调试选项

OpenGL ES驱动

    OpenGL ES驱动工具可以帮你测量GPU的利用率,同样也是一个很好的来判断和GPU相关动画性能的指示器。它同样也提供了类似Core Animation那样显示FPS的工具(图12.6)。

图12.6

图12.6 OpenGL ES驱动工具

侧栏的邮编是一系列有用的工具。其中和Core Animation性能最相关的是如下几点:

  • Renderer Utilization - 如果这个值超过了~50%,就意味着你的动画可能对帧率有所限制,很可能因为离屏渲染或者是重绘导致的过度混合。

  • Tiler Utilization - 如果这个值超过了~50%,就意味着你的动画可能限制于几何结构方面,也就是在屏幕上有太多的图层占用了。

一个可用的案例

    现在我们已经对Instruments中动画性能工具非常熟悉了,那么可以用它在现实中解决一些实际问题。

    我们创建一个简单的显示模拟联系人姓名和头像列表的应用。注意即使把头像图片存在应用本地,为了使应用看起来更真实,我们分别实时加载图片,而不是用–imageNamed:预加载。同样添加一些图层阴影来使得列表显示得更真实。清单12.1展示了最初版本的实现。

清单12.1 使用假数据的一个简单联系人列表

#import "ViewController.h"
#import

@interface ViewController ()

@property (nonatomic, strong) NSArray *items;
@property (nonatomic, weak) IBOutlet UITableView *tableView;

@end

@implementation ViewController

- (NSString *)randomName
{
NSArray *first = @[@"Alice", @"Bob", @"Bill", @"Charles", @"Dan", @"Dave", @"Ethan", @"Frank"];
NSArray *last = @[@"Appleseed", @"Bandicoot", @"Caravan", @"Dabble", @"Ernest", @"Fortune"];
NSUInteger index1 = (rand()/(double)INT_MAX) * [first count];
NSUInteger index2 = (rand()/(double)INT_MAX) * [last count];
return [NSString stringWithFormat:@"%@ %@", first[index1], last[index2]];
}

- (NSString *)randomAvatar
{
NSArray *images = @[@"Snowman", @"Igloo", @"Cone", @"Spaceship", @"Anchor", @"Key"];
NSUInteger index = (rand()/(double)INT_MAX) * [images count];
return images[index];
}

- (void)viewDidLoad
{
[super viewDidLoad];
//set up data
NSMutableArray *array = [NSMutableArray array];
for (int i = 0; i < 1000; i++) {
//add name
[array addObject:@{@"name": [self randomName], @"image": [self randomAvatar]}];
}
self.items = array;
//register cell class
[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"Cell"];
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
return [self.items count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];
//load image
NSDictionary *item = self.items[indexPath.row];
NSString *filePath = [[NSBundle mainBundle] pathForResource:item[@"image"] ofType:@"png"];
//set image and text
cell.imageView.image = [UIImage imageWithContentsOfFile:filePath];
cell.textLabel.text = item[@"name"];
//set image shadow
cell.imageView.layer.shadowOffset = CGSizeMake(0, 5);
cell.imageView.layer.shadowOpacity = 0.75;
cell.clipsToBounds = YES;
//set text shadow
cell.textLabel.backgroundColor = [UIColor clearColor];
cell.textLabel.layer.shadowOffset = CGSizeMake(0, 2);
cell.textLabel.layer.shadowOpacity = 0.5;
return cell;
}

@end

    当快速滑动的时候就会非常卡(见图12.7的FPS计数器)。

图12.7

图12.7 滑动帧率降到15FPS

    仅凭直觉,我们猜测性能瓶颈应该在图片加载。我们实时从闪存加载图片,而且没有缓存,所以很可能是这个原因。我们可以用一些很赞的代码修复,然后使用GCD异步加载图片,然后缓存。。。等一下,在开始编码之前,测试一下假设是否成立。首先用我们的三个Instruments工具分析一下程序来定位问题。我们推测问题可能和图片加载相关,所以用Time Profiler工具来试试(图12.8)。

图12.8

图12.8 用The timing profile分析联系人列表

    -tableView:cellForRowAtIndexPath:中的CPU时间总利用率只有~28%(也就是加载头像图片的地方),非常低。于是建议是CPU/IO并不是真正的限制因素。然后看看是不是GPU的问题:在OpenGL ES Driver工具中检测GPU利用率(图12.9)。

图12.9

图12.9 OpenGL ES Driver工具显示的GPU利用率

    渲染服务利用率的值达到51%和63%。看起来GPU需要做很多工作来渲染联系人列表。

    为什么GPU利用率这么高呢?我们来用Core Animation调试工具选项来检查屏幕。首先打开Color Blended Layers(图12.10)。

图12.10

图12.10 使用Color Blended Layers选项调试程序

    屏幕中所有红色的部分都意味着字符标签视图的高级别混合,这很正常,因为我们把背景设置成了透明色来显示阴影效果。这就解释了为什么渲染利用率这么高了。

    那么离屏绘制呢?打开Core Animation工具的Color Offscreen - Rendered Yellow选项(图12.11)。

图12.11

图12.11 Color Offscreen–Rendered Yellow选项

    所有的表格单元内容都在离屏绘制。这一定是因为我们给图片和标签视图添加的阴影效果。在代码中禁用阴影,然后看下性能是否有提高(图12.12)。

图12.12

图12.12 禁用阴影之后运行程序接近60FPS

    问题解决了。干掉阴影之后,滑动很流畅。但是我们的联系人列表看起来没有之前好了。那如何保持阴影效果而且不会影响性能呢?

    好吧,每一行的字符和头像在每一帧刷新的时候并不需要变,所以看起来UITableViewCell的图层非常适合做缓存。我们可以使用shouldRasterize来缓存图层内容。这将会让图层离屏之后渲染一次然后把结果保存起来,直到下次利用的时候去更新(见清单12.2)。

清单12.2 使用shouldRasterize提高性能

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
//dequeue cell
UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell"
forIndexPath:indexPath];
...
//set text shadow
cell.textLabel.backgroundColor = [UIColor clearColor];
cell.textLabel.layer.shadowOffset = CGSizeMake(0, 2);
cell.textLabel.layer.shadowOpacity = 0.5;
//rasterize
cell.layer.shouldRasterize = YES;
cell.layer.rasterizationScale = [UIScreen mainScreen].scale;
return cell;
}

    我们仍然离屏绘制图层内容,但是由于显式地禁用了栅格化,Core Animation就对绘图缓存了结果,于是对提高了性能。我们可以验证缓存是否有效,在Core Animation工具中点击Color Hits Green and Misses Red选项(图12.13)。

图12.13

图12.13 Color Hits Green and Misses Red验证了缓存有效

    结果和预期一致 - 大部分都是绿色,只有当滑动到屏幕上的时候会闪烁成红色。因此,现在帧率更加平滑了。

    所以我们最初的设想是错的。图片的加载并不是真正的瓶颈所在,而且试图把它置于一个复杂的多线程加载和缓存的实现都将是徒劳。所以在动手修复之前验证问题所在是个很好的习惯!

总结

    在这章中,我们学习了Core Animation是如何渲染,以及我们可能出现的瓶颈所在。你同样学习了如何使用Instruments来检测和修复性能问题。

    在下三章中,我们将对每个普通程序的性能陷阱进行详细讨论,然后学习如何修复。


收起阅读 »

带着问题学习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);
}
 }
收起阅读 »

JS中this的指向原理

前言 在JS中,每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。与声明的位置无关。 调用位置 理解调用位置:调用位置就是函数在执行时被调用的位置(而不是声明的位置)。 要找到函数的调用位置,最重要是找到函数的调用...
继续阅读 »

前言


在JS中,每个函数的 this 是在调用时被绑定的,完全取决于函数的调用位置(也就是函数的调用方法)。与声明的位置无关。


调用位置



理解调用位置:调用位置就是函数在执行时被调用的位置(而不是声明的位置)。



要找到函数的调用位置,最重要是找到函数的调用栈(就是为了到达当前执行位置所调用的所有函数),而函数的调用位置就是当前所在栈顶的前一个位置。


举个栗子


function baz() { 
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域,浏览器下位window,node下为global
console.log( "baz" );
bar(); // <-- bar 的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在 baz 中
console.log( "bar" );
foo(); // <-- foo 的调用位置
}
function foo() {
// 当前调用栈是 baz -> bar -> foo
// 因此,当前调用位置在 bar 中
console.log( "foo" );
}
baz(); // <-- baz 的调用位置

this绑定规则


函数的this在js引擎执行时,会根据一些规则去绑定到上下文中。


默认绑定


默认绑定应用在最常用的函数调用类型:独立函数调用上。可以把这条规则看作是无法应用其他规则时的默认规则。


function foo() {
//默认规则下,this指向全局对象,即顶层作用域
console.log( this.a );
}

var a = 2;
foo()//2

怎么知道这里应用了默认绑定呢?可以通过分析调用位置来看看 foo()是如何调用的。在代码中,foo() 是直接使用不带任何修饰的函数引用进行调用的,因此只能使用
默认绑定,无法应用其他规则。



严格模式下,不能将全局对象用于默认绑定,因此 this 会绑定到undefined,在浏览器和node中是一样的。



这里有一个微妙但是非常重要的细节,虽然this的绑定规则完全取决于调用位置,但是只有 foo() 运行在非严格模式下时,默认绑定才能绑定到全局对象;在严格模式下调用
foo() 则不影响默认绑定:


function foo() { 
//在非严格模式下运行
console.log( this.a );
}
var a = 2;
(function(){
//在严格模式下调用
"use strict";
foo(); // 2
})();

以上代码混合使用了严格模式和非严格模式,因此foothis不受严格模式影响,但混合使用严格模式是不提倡的,幸运的是es6默认是严格模式


隐式绑定


当一个函数的引用被一个对象持有时(作为该对象的方法),那么该函数的this就绑定在了这个对象上。通常这在声明一个对象,并将一个已声明的函数作为该对象属性时触发。


function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2

obj对象声明时,foo作为obj的一个属性,因此其this被隐式绑定到了obj上,因为obj持有对foo的引用。



在对象属性引用链中,只有上一层或者说最后一层在调用位置中起作用。



举个栗子


function foo() { 
console.log( this.a );
}

// obj2.foo引用了foo函数
var obj2 = {
a: 42,
foo: foo
};

//obj1.obj2 引用了obj1对象
var obj1 = {
a: 2,
obj2: obj2
};

//但是foo中的this永远指向直接持有它的引用的那个对象,即obj2
obj1.obj2.foo(); // 42

一个函数的引用被一个对象持有,而这个对象的引用又被另一个对象持有,另一个对象的引用再被另一个对象持有...,这就像一条项链,但是不管层次有多深,这个函数的this永远指向直接持有它的引用的那个对象。


隐式丢失



一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。



function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!

var a = "oops, global"; // a 是全局对象的属性
bar(); // "oops, global"

虽然barobj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此==此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定==。


再看一个栗子,发生在传入回调函数时


function foo() { 
console.log( this.a );
}
function doFoo(fn) {
// fn 其实引用的是 foo
fn(); // <-- 调用位置!,很明显,这是个默认绑定
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
doFoo( obj.foo ); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一
个例子一样。


如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一
样的,没有区别:


function foo() { 
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a 是全局对象的属性
setTimeout( obj.foo, 100 ); // "oops, global"

以上的栗子再次向我们证明了,函数this是在运行时绑定的,与声明位置无关。



除此之外,还有一种情



况 this 的行为会出乎我们意料:调用回调函数的函数可能会修改 this。在一些流行的
JavaScript 库中事件处理器常会把回调函数的 this 强制绑定到触发事件的 DOM 元素上。如onclick,addEventListener,会将this绑定在dom元素 上。


显式绑定


显示绑定就是利用js提供的一些内置函数,将this绑定到指定的上下文中。



具体点说,可以使用函数的 call(..) 和



apply(..) 方法。严格来说,JavaScript 的宿主环境有时会提供一些非常特殊的函数,它们
并没有这两个方法。但是这样的函数非常罕见,JavaScript 提供的绝大多数函数以及你自
己创建的所有函数都可以使用 call(..) 和 apply(..) 方法。


function foo() { 
console.log( this.a );
}
var obj = {
a:2
};
//执行时,foo的this就是obj了
foo.call( obj ); // 2


如果你传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)、new Boolean(..) 或者



new Number(..))。这通常被称为“装箱”。



显式绑定仍然无法解决我们之前提出的丢失绑定问题。



硬绑定


硬绑定是显式绑定的一个变种。


function foo() { 
console.log( this.a );
}
var obj = {
a:2
};
var bar = function() {
foo.call( obj );
};
bar(); // 2
setTimeout( bar, 100 ); // 2
// 硬绑定的 bar 不可能再修改它的 this
bar.call( window ); // 2

很好理解,就是在函数运行时再把这个函数绑定到我们制定的this上。



硬绑定的典型应用场景就是创建一个包裹函数,负责接收参数并返回值



function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = function() {
return foo.apply( obj, arguments );
};
var b = bar( 3 ); // 2 3
console.log( b ); // 5


另一种使用方法是创建一个可以重复使用的辅助函数



function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
return function() {
return fn.apply( obj, arguments );
};
}
var obj = {
a:2
};
var bar = bind( foo, obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

由于硬绑定是一种非常常用的模式,所以 ES5 提供了内置的方法 Function.prototype.bind,bind(..) 会返回一个硬编码的新函数,它会把你指定的参数设置为 this 的上下文并调用原始函数


function foo(something) { 
console.log( this.a, something );
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind( obj );
var b = bar( 3 ); // 2 3
console.log( b ); // 5

API中可选的调用“上下文”



第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一



个可选的参数,通常被称为"上下文"(context),其作用和 bind(..) 一样,确保你的回调函数使用指定的 this。这些函数实际上就是通过 call(..) 或者 apply(..) 实现了显式绑定。


function foo(el) { 
console.log( el, this.id );
}
var obj = {
id: "awesome"
};
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome

new 绑定


使用new来调用函数时(函数也是对象),或者说发生构造函数调用时,会自动执行下面的操作:



  1. 创建(或者说构造)一个全新的对象。

  2. 这个新对象会被执行原型链[[Prototype]] 连接。

  3. 这个新对象会绑定到函数调用的 this

  4. 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。


function foo(a) { 
this.a = a;
}
var bar = new foo(2);
console.log( bar.a ); // 2


使用 new 来调用foo(..)时,我们会构造一个新对象并把它绑定到 foo(..) 调用中的 this上。ne是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。



规则的优先级


实际判断时,一个场景可能存在多个规则,因此判定时需要由高优先级往下判定。


可以按照下面的顺序来进行判断:




  1. 函数是否在new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。




  2. 函数是否通过callapply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是




指定的对象。



  1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上


下文对象。



  1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定


到全局对象。


规则例外



在某些场景下this的绑定行为会出乎意料,你认为应当应用其他绑定规则时,实际上应用的可能是默认绑定规则。



1. 将null或undefined作为this进行显式绑定


2. 赋值表达式的返回值


function foo() { 
console.log( this.a );
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };
o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是
p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。


3.软绑定


硬绑定很好地解决了隐式绑定可能会无意间将this绑定在顶级作用对象(严格模式下,为undefined)上的问题,但降低了其灵活性,我们要的结果是,保留其灵活性,既能绑定到指定的this上,但又不想让它默认绑定到全局对象上,解决方法就是软绑定。


通俗的说,就是有一个默认值,指定了绑定对象的话就绑定到指定的对象上,否则就绑定到默认对象。


//实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。
if (!Function.prototype.softBind) {
Function.prototype.softBind = function(obj) {
var fn = this;
// 捕获所有 curried 参数
var curried = [].slice.call( arguments, 1 );
var bound = function() {
return fn.apply(
(!this || this === (window || global)) ?
obj : this,
curried.concat.apply( curried, arguments )
);
};
bound.prototype = Object.create( fn.prototype );
return bound;
};
}

总结


如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。


找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。



  1. new 调用?绑定到新创建的对象。

  2. call 或者 apply(或者 bind)调用?绑定到指定的对象。

  3. 由上下文对象调用?绑定到那个上下文对象。

  4. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。


箭头函数不会以上四条标准的绑定规则,而是根据当前的词法作用域来决定this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论this绑定到什么)。这和我们创建一个变量来保存当前的this的效果是一样的。


链接:https://juejin.cn/post/7000756069244862477

收起阅读 »

一道看似简单的阿里前端算法题

题目描述 题目分析 我们以下面这个数组为例,我们首先要明白题目中的第2大的元素指的是4,第3大的元素指的是3,也就是说指的是去重后的数组中的排序。我们之所以要建立一个哈希表是因为我们需要知道第k大和第m大的元素总共出现了几次,因为最后需要进行求和。 [1...
继续阅读 »

题目描述


image.png


题目分析



我们以下面这个数组为例,我们首先要明白题目中的第2大的元素指的是4,第3大的元素指的是3,也就是说指的是去重后的数组中的排序。我们之所以要建立一个哈希表是因为我们需要知道第k大和第m大的元素总共出现了几次,因为最后需要进行求和。



[1, 2, 4, 4, 3, 5]

解题思路



本题博主采用的是哈希表 + 堆排序的方式来求解。



第一步:构建哈希表,键为目标元素,值为目标元素出现的次数


const map = new Map();
for (let v of arr) {
if (!map.get(v)) {
map.set(v,1);
} else {
map.set(v,map.get(v) + 1)
}
}

第二步:对数组去重


const singleNums = [...new Set(arr)]

第三步:构建大顶堆


// 堆的尺寸指的是去重后的数组
let heapSize = singleNums.length;
buildMaxHeap(singleNums, heapSize);
function buildMaxHeap(arr, heapSize) {
// 从最后一个叶子节点开始进行堆化
for (let i = Math.floor(heapSize / 2) - 1; i >= 0; i--) {
// 进行堆化
maxHeapify(arr, i, heapSize);
}
}
function maxHeapify(arr, i, heapSize) {
// 首先假定第i个是最大的
let max = i;
let leftChild = 2 * i + 1;
let rightChild = 2 * i + 2;
// 如果下标不越界,并且左孩子的比最大值大则更新最大值
if (leftChild < heapSize && arr[leftChild] > arr[max]) {
max = leftChild;
}
if (rightChild < heapSize && arr[rightChild] > arr[max]) {
max = rightChild;
}
if (max !== i) {
swap(arr, i, max);
// 上来的元素的位置往下要接着堆化
maxHeapify(arr, max, heapSize);
}
}
// 交换数组中两个元素
function swap(nums, a, b) {
let temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}

第四步:求第k大的元素和第m大元素


function target(arr, x) {
for (let i = 0; i < x - 1; i++) {
// 交换不需要进行堆化的元素
if (i === min - 1) result.push(arr[0]);
swap(arr, 0, arr.length - 1 - i);
arr
heapSize--;
maxHeapify(arr, 0, heapSize)
}
}
target(singleNums, max)
result.push(singleNums[0]);

第五步:根据哈希表出现的次数计算并返回结果


return result.reduce((pre,cur) => pre + cur * map.get(cur),0)

AC代码


/*
* @Author: FaithPassion
* @Date: 2021-07-09 10:06:00
* @LastEditTime: 2021-08-28 11:09:30
* @Description: 找出数组中第k大和第m大的数字相加之和
* let arr = [1,2,4,4,3,5], k = 2, m = 4
* findTopSum(arr, k, m); // 第2大的数是4,出现2次,第4大的是2,出现1次,所以结果为10
*/

/**
* @description: 采用堆排序求解
* @param {*} arr 接收一个未排序的数组
* @param {*} k 数组中第k大的元素
* @param {*} m 数组中第m大的元素
* @return {*} 返回数组中第k大和第m大的数字相加之和
*/
function findTopSum(arr, k, m) {


function buildMaxHeap(arr, heapSize) {
// 从最后一个叶子节点开始进行堆化
for (let i = Math.floor(heapSize / 2) - 1; i >= 0; i--) {
// 进行堆化
maxHeapify(arr, i, heapSize);
}
}
// 最大堆化函数
function maxHeapify(arr, i, heapSize) {
// 首先假定第i个是最大的
let max = i;
let leftChild = 2 * i + 1;
let rightChild = 2 * i + 2;
// 如果下标不越界,并且左孩子的比最大值大则更新最大值
if (leftChild < heapSize && arr[leftChild] > arr[max]) {
max = leftChild;
}
if (rightChild < heapSize && arr[rightChild] > arr[max]) {
max = rightChild;
}
if (max !== i) {
swap(arr, i, max);
// 上来的元素的位置往下要接着堆化
maxHeapify(arr, max, heapSize);
}
}

// 交换数组中两个元素
function swap(nums, a, b) {
let temp = nums[a];
nums[a] = nums[b];
nums[b] = temp;
}
let result = []
// k和m中较大的
let max = Math.max(k, m);
// k和m中较小的
let min = Math.min(k, m);
const map = new Map();

for (let v of arr) {
if (!map.get(v)) {
map.set(v,1);
} else {
map.set(v,map.get(v) + 1)
}
}
// 求第x大的元素
function target(arr, x) {
for (let i = 0; i < x - 1; i++) {
// 交换不需要进行堆化的元素
if (i === min - 1) result.push(arr[0]);
swap(arr, 0, arr.length - 1 - i);
arr
heapSize--;
maxHeapify(arr, 0, heapSize)
}
}
const singleNums = [...new Set(arr)]
// 堆的大小
let heapSize = singleNums.length;
// 构建大顶堆
buildMaxHeap(singleNums, heapSize);

target(singleNums, max)
result.push(singleNums[0]);
return result.reduce((pre,cur) => pre + cur * map.get(cur),0)

}

findTopSum([1, 2, 4, 4, 3, 5], 2, 4)

题目反思



  • 学会通过堆排序的方式来求解Top K问题。

  • 学会对数组进行去重。

  • 学会使用reduce Api。


链接:https://juejin.cn/post/7001397295912583198

收起阅读 »

cookie和session、localStorage和sessionStorage、IndexedDB、JWT汇总

cookie和session HTTP协议是一种无状态协议,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;Session和Cookie的主要目的就是为了弥补HTTP的无状态特性。 cookie是什么? cookie是...
继续阅读 »

cookie和session


HTTP协议是一种无状态协议,即每次服务端接收到客户端的请求时,都是一个全新的请求,服务器并不知道客户端的历史请求记录;SessionCookie的主要目的就是为了弥补HTTP的无状态特性。


cookie是什么?


cookie是服务器发送到Web浏览器的一小块数据,服务器发送到浏览器的Cookie,浏览器会进行存储,并与下一个请求一起发送到服务器,用于判断请求是否来自同一个浏览器,例如用户保持登录状态。


cookie的属性




  • name:表示cookie的名称




  • valuecookie对应的值。




  • domain:该字段为可以访问此cookie的域名,即cookie在哪个域有效




  • path: cookie的有效路径。DomainPath标识共同定义了Cookie的作用域:即 Cookie应该发送给哪些URL




  • sizecookie的大小(不超过4kb)




  • expires/Max-Age:有效期。expirescookie被删除的时间戳;Max-Age有效期的时间戳(服务器返回的时间,和客户端可能存在误差),默认为-1,页面关闭立即失效。




  • HttpOnly: 设置为true时不允许通过脚本document.cookie去更改cookie值,也不可获取,能有效的防止xss攻击。但发送请求仍会携带cookie。




  • secure: 标记为SecureCookie只应通过被HTTPS协议加密过的请求发送给服务端,可以保护Cookie在浏览器和Web服务器间的传输过程中不被窃取和篡改。




  • SameSite: 该属性可以让Cookie在跨站请求时不会被发送,用来防止CSRF攻击和用户追踪




    • Strict:完全禁止第三方cookie,跨站点时,任何情况下都不会发送cookie。也就是说,只有当前网页的URL与请求目标一致,才会带上cookie




    • Lax: 大多数情况不发送第三方cookie,但导航到目标网址的get请求(链接,预加载请求,GET表单)除外。




    • None: 网站可以选择显式关闭SameSite属性,将其设为None。不过,前提是必须同时设置Secure属性(Cookie 只能通过 HTTPS 协议发送),否则无效。


      浏览器查看cookie






cookie安全


安全问题可以看我总结的这篇:前端安全—常见的攻击方式及防御方法


session是什么?


Session是保存在服务器记录客户状态的机制。客户端浏览器访问服务器的时候,服务器会为这次请求开辟一块内存空间,这个对象便是Session 对象,存储结构为 ConcurrentHashMap。Session 弥补了 HTTP 无状态特性,服务器可以利用 Session 存储客户端在同一个会话期间的一些操作记录。


session的创建



  • 用户向服务器发送用户名和密码

  • 服务器通过验证后,在当前对话(session)里面保存相关数据(比如用户角色,登录时间等)

  • 服务器向用户返回一个session_id,写入要不干湖的cookie

  • 用户随后的每一次请求都会通过cookie,将session_id传回服务器

  • 服务器收到session_id,找到前期保存的数据,由此得知用户的身份。


cookie和session的区别



  • 储存方式:cookie是服务端产生,储存在客户端;session储存在服务端

  • 储存大小:单个cookie不超过4kb;session没有大小限制

  • 安全性:session更安全

  • 储存内容:cookie只能保存字符串,以文本的方式;session通过类似hashtable的数据结构来储存,能支持任何类型的对象

  • 使用方式

    • cookie机制:如果不在浏览器设置过期时间,cookie被保存在内存中,cookie生命周期随浏览器的关闭而结束。如果在浏览器中设置了cookie的过期时间,cookie被保存在硬盘中,关闭浏览器后,cookie数据仍然存在,知道过期时间才消失。

    • session机制:当服务器收到请求需要创建session对象时,首先会检查客户端请求中是否包含session_id,如果有,服务器将根据id返回对应的session对象。如果没有session_id,服务器会创建新的session对象,并把session_id在本次响应中返回给客户端。




cookie、localStorage和sessionStorage


HTML5提供了两种在客户端存储数据的新方法:localStorage和sessionStorage,挂载在window对象下。


webStorage是本地存储,数据不是由服务器请求传递的。从而它可以存储大量的数据,而不影响网站的性能。


Web Storage的目的是为了克服由cookie带来的一些限制,当数据需要被严格控制在客户端上时,无须持续地将数据发回服务器。比如客户端需要保存的一些用户行为或数据,或从接口获取的一些短期内不会更新的数据,我们就可以利用Web Storage来存储。


localStorage


生命周期是永久性的。localStorage存储的数据,以“键值对”的形式存在。即使关闭浏览器,也不会让数据消失,除非主动的去删除数据。如果想设置失效时间,需自行封装。localStorage 在所有同源窗口中都是共享的。


sessionStorage


sessionStorage保存的数据用于浏览器的一次会话,当会话结束(关闭浏览器或者页面),数据被清空;SessionStorage的属性和方法与LocalStorage完全一样。


sessionStorage特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享
localStorage 在所有同源窗口中都是共享的; cookie也是在所有同源窗口中都是共享的。除了保存期限的长短不同,


cookie、localStorage和sessionStorage的区别



  • 共同点:都是保存在浏览器端,且都遵循同源策略。

  • 不同点:在于生命周期与作用域等不同


image.png


IndexedDB


IndexedDB是一个运行在浏览器上的非关系型数据库,储存空间大,用于客户端存储大量结构化数据(包括文件和blobs) 。可以存字符串,也可以存二进制数据,数据以"键值对"的形式保存,不能有重复,否则会报错。除非被清理,否则一直存在。



  • 键值对储存

  • 异步

  • 支持事务

  • 同源策略

  • 支持二进制储存


JWT(JSON Web Token)


互联网服务离不开用户认证。一般流程看上面session的创建


什么是Token?




  • Token的定义


    Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。token其实说的更通俗点可以叫暗号,在一些数据传输之前,要先进行暗号的核对,不同的暗号被授权不同的数据操作。




  • 简单 token 的组成



    • uid(用户唯一的身份标识)

    • time(当前时间戳)

    • sign(签名,token 的前几位以哈希算法压缩成的一定长度的十六进制字符串)




  • token 的身份验证流程



    • 客户端使用用户名跟密码请求登录

    • 服务端收到请求,去验证用户名与密码

    • 客户端收到 token 以后,会把它存储起来,比如放在 cookie 里或者 localStorage

    • 客户端每次向服务端请求资源的时候需要带着服务端签发的 token

    • 服务端收到请求,然后去验证客户端请求里面带着的 token ,如果验证成功,就向客户端返回请求的数据
      image.png




  • 使用Token的目的


    Token的目的是为了减少频繁的查询数据库,减轻服务器的压力。基于Token用户认证是一种服务器无状态的认证方式,服务器不存放数据,所有数据都保存在客户端,每次请求都发回服务器,用解析token的时间来换取session的储存空间,从而减轻服务器的压力,减少频繁的查询数据库。token 完全由应用管理,所以它可以避开同源策略。




什么是 JWT?


JWT的原理


JWT的原理是,服务器认证以后,生成一个 JSON 对象,发回给用户,以后,用户与服务端通信的时候,都要发回这个 JSON 对象。服务器完全只靠这个对象认定用户身份。为了防止用户篡改数据,服务器在生成这个对象的时候,会加上签名。服务器就不保存任何 session 数据了,也就是说,服务器变成无状态了,从而比较容易实现扩展。


JWT的数据结构




  • Header(头部)


    Header部分是一个JSON对象,描述JWT的元数据,使用Base64编码转成字符串。




  • Payload(负载)


    Payload是一个JSON对象,用来存放实际需要传递的数据,使用Base64编码转成字符串。



    • iss (issuer):签发人

    • exp (expiration time):过期时间

    • sub (subject):主题

    • aud (audience):受众

    • nbf (Not Before):生效时间

    • iat (Issued At):签发时间

    • jti (JWT ID):编号




  • Signature(签名)


    Signature是对前两部分的签名,防止数据篡改。首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用Header里面指定的签名算法(默认是 HMAC SHA256)产生签名。用"点"(.)分隔拼接成字符串后返回给用户。




JWT的特点



  • 默认不加密,也可以加密

  • 可以用于认证,也可以用于交换信息。降低服务器查询数据库的次数,减小服务器压力

  • 服务器无状态,因此无法在使用过程中废除某个Token,或者更改Token的权限。即一旦JWT签发了,在到期之前始终有效,除非服务器部署额外的逻辑

  • JWT本身包含了认证信息,为保证安全性,有效期应设置得比较短

  • 为了减少盗用,JWT应使用HTTPS协议传输

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

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

收起阅读 »

Flutter 系列 - 环境搭建

Flutter 作为火热的跨端工具包,在 github 上超过 120k 的关注量,可见一斑。 基于目前本人正在学习 Flutter 的路上,会将整个学习的过程记录下来。 本博文主要讲解环境的搭建,先把项目搭建好,跑通 demo 才有玩下去的必要和成就感,你说...
继续阅读 »

Flutter 作为火热的跨端工具包,在 github 上超过 120k 的关注量,可见一斑。


基于目前本人正在学习 Flutter 的路上,会将整个学习的过程记录下来。


本博文主要讲解环境的搭建,先把项目搭建好,跑通 demo 才有玩下去的必要和成就感,你说是吧?


本人开发环境




  • macOS Big Sur 版本 11.2 芯片 Apple M1




  • 磁盘空间:> 2.8 GB (要求的最小的空间)




  • $SHELL




echo $SHELL
/bin/bash


⚠️ 之后出现并解决的问题都是基于本人的环境



安装 Flutter


通过官网下载安装包。


将安装包放到自己想存放的地方。这里,我放在 文稿 -> sdk 方便管理,然后解压下载包。


配置 flutterPATH 环境变量,格式如下:


export PATH=$PATH:${pwd}/flutter/bin

export PATH=${pwd}/flutter/bin:$PATH

这里我需要编辑 ~/.bash_profile 文件,添加下面这行内容:


export PATH=/Users/jimmy/Documents/sdk/flutter/bin:$PATH

安装 IDE


作为一个前端开发者,比较偏向 VS code,直接安装其稳定版即可。


因为需要调试安卓平台,还需要安装编辑器 Android StudioAndroid StudioFlutter 提供了一个完整的集成开发环境。


不管 VS code 还是 Android Studio 都需要安装 Flutter 插件。



Android Studio 我还是安装在 文稿 -> sdk



注意安装android studio的路径,也许会报sdk的错误。类似错误 ❌


# [Flutter-Unable to find bundled Java version(flutter doctor), after updated android studio Arctic Fox(2020.3.1) on M1 Apple Silicon](https://stackoverflow.com/questions/68569430/flutter-unable-to-find-bundled-java-versionflutter-doctor-after-updated-andro)

对应的解决方法:flutter-unable-to-find-bundled-java-versionflutter-doctor-after-updated-andro


验证


之后,运行 flutter doctor 或者 flutter doctor -v 来检查是否安装了必要的安装包。


下面是自己搭建环境的情况flutter doctor -v


[✓] Flutter (Channel stable, 2.2.3, on macOS 11.2 20D64 darwin-arm, locale

    zh-Hans-CN)

    • Flutter version 2.2.3 at /Users/jimmy/Documents/sdk/flutter

    • Framework revision f4abaa0735 (9 weeks ago), 2021-07-01 12:46:11 -0700

    • Engine revision 241c87ad80

    • Dart version 2.13.4

[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)

    • Android SDK at /Users/jimmy/Library/Android/sdk

    • Platform android-31, build-tools 31.0.0

    • Java binary at: /Users/jimmy/Documents/sdk/Android

      Studio.app/Contents/jre/jdk/Contents/Home/bin/java

    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS

    • Xcode at /Applications/Xcode.app/Contents/Developer

    • Xcode 12.5.1, Build version 12E507

    • CocoaPods version 1.10.2

[✓] Chrome - develop for the web

    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2020.3)

    • Android Studio at /Users/jimmy/Documents/sdk/Android Studio.app/Contents # 留意 Android Studio 路径

    • Flutter plugin can be installed from:

      🔨 https://plugins.jetbrains.com/plugin/9212-flutter

    • Dart plugin can be installed from:

      🔨 https://plugins.jetbrains.com/plugin/6351-dart

    • Java version OpenJDK Runtime Environment (build 11.0.10+0-b96-7249189)

[✓] VS Code (version 1.59.1)

    • VS Code at /Applications/Visual Studio Code.app/Contents

    • Flutter extension version 3.25.0

[✓] Connected device (1 available)

    • Chrome (web) • chrome • web-javascript • Google Chrome 92.0.4515.159

• No issues found!

出现 No issues found! 的提示,说明你捣鼓成功了~


运行 Demo


我们在 VS code 上新建一个项目:


查看 -> 命令面板 -> Flutter: New Application Project

初始化项目之后,运行 -> 启动调试,然后按照下图运行应用:


vscode_demo.png


如果选中 Chrome web 会直接调起你安装好的谷歌浏览器。


如果选中 Start iOS Simulator 会调起 xCode 的模拟器。


如果选中 Start Pixel 2 API 31 会调起 Android Studio 的模拟器。



当然你得在 Android Studio 上预设手机型号是哪个,不然初次在 VS code 上调不起来。



effect_result.png


【完】~ 下次可以更加愉快玩耍了


作者:Jimmy
链接:https://juejin.cn/post/7002401225270362143

收起阅读 »

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的各种基础布局,那么即使再复杂的布局,也能分而治之。

收起阅读 »

面试官问:我们聊聊原型和继承?我:这里边水深,我把握不住。。。

前言 原型和继承一直是js中非常基础和重要的部分,我们来看看日常开发中经常会用到的原型和继承。 class Person extends React.Component { componentDidMount() {} render() {...
继续阅读 »

前言


原型和继承一直是js中非常基础和重要的部分,我们来看看日常开发中经常会用到的原型和继承。


  class Person extends React.Component {
componentDidMount() {}
render() {}
}

这行代码代码大家都很熟悉,Person通过extends关键字继承了React的特性,componentDidMount和render在class类中的是一个普通定义好的函数。特殊的是,它们也是在Component中提前定义好的钩子函数,用于在某个固定的时机触发。


看完了基本的使用,下面我们一起来深入探索下class和extends。


class只是一个语法糖


class是ES6中引入的概念,我们也称它为类。class的用途是作为对象模版,用来创建对象。但需要明确的是,class只是一个语法糖,它内部实现上还是和ES5创建对象是相同的。由于class的写法更加符合面向对象编程的习惯,所以被推广使用,逐步替代了ES5中的对象创建。


   console.log(typeof React.Component); // function

ES5是通过构造函数函数来创建对象,React.Component的类型同样是一个function,所以想要完全搞清楚对象和原型,还是要去学习下ES5中对象的创建。后面有一篇文章是关于ES5中对象的创建和继承,有需要的大家可以自己去看,这里就不展开说了。


class与构造函数的对比


class的本质还是构造函数,但是与构造函数又有些许使用上的不同。


相同点


定义方式


class与构造函数都有两种定义方式,声明和表达式,这两种写法完全等价。且名称都必须大写,以区别于它创建的实例.


  // 函数声明
function Person() {};
// 函数表达式
let Person = function () {};

// 类声明
class Person {}
// 类表达式
let Person = class {};

// 创建实例(函数和类)
let person = new Person();

通过name访问原表达式。


表达式赋值时,可通过name访问原表达式。


  let Student = function Person() {};
// 通过name属性获取类表达式的名称
console.log(Student.name); // Person

let Student = class Person {};
// 通过name属性获取类表达式的名称
console.log(Student.name); // Person

表达式外部,无法访问原表达式


  let Student = function Person() {};
// 外部无法访问类表达式
console.log(Person); // Person is not defined

let Student = class Person {};
// 外部无法访问类表达式
console.log(Person); // Person is not defined

不同点


类不可以变量提升


函数可以变量提升,而类不可以。


  // 声明
console.log(Person); // 报错
console.log(Student); // ƒ Student() {}

class Person {}
function Student() {}

console.log(Person); // class Person {}
console.log(Student); // ƒ Student() {}

// 表达式定义
console.log(Person); // undefined
console.log(Student); // undefined

var Person = class {};
var Student = function () {};

console.log(Person); // class {}
console.log(Student); // ƒ () {}

类受块级作用域限制


  {
class Person {}
function Student() {}
}
console.log(Person); // 报错,Person is not defined
console.log(Student); // ƒ Student() {}

类必须通过new来调用


类必须通过new来调用,否则会报错。构造函数不使用new调用也可以,就会把全局的this作为内部对象。


  function Person() {}
class Animal {}

let p = Person(); // Person内部this指向window
let a = Animal(); // TypeError: class constructor Animal cannot be invoked without 'new'

class的实例化


class实例化的时候,会调用class中的constructor函数。constructor是类的默认方法,如果没有定义,constructor方法会被默认添加。


  class Bar {}
等同于
class Bar {
constructor() {}
}

constructor方法会默认返回一个实例对象(即this),也可以完全返回另一个对象。但返回另一个对象,会导致返回的对象不是Bar的实例(因为它的原型指针没有被更改,具体的原因后面分析)。


  // 返回一个对象
class Bar {
constructor() {
return {
name: 1,
};
}
}

let bar = new Bar();
console.log(bar); // {name: 1}
console.log(bar instanceof Bar); // false

// 返回默认对象
class Bar {
constructor() {}
}

let bar = new Bar();
console.log(bar); // Bar {}
console.log(bar instanceof Bar); // true

前面说到了,如果手动返回了一个对象,会导致返回的对象不是class的实例。那么我们看看生成一个对象的过程是什么样的,为什么手动返回一个对象,这个对象就不是类的实例了。


实例化的过程:



  1. 在内存中创建一个对象

  2. 新对象的__proto__赋值为构造函数的prototype

  3. 构造函数内部的this指向新对象

  4. 执行构造函数内部代码(给新对象添加属性)

  5. 如果构造函数返回非空对象,则返回该对象。否则,则返回新创建的对象。


通过上面的第二步可以看到,原型的赋值作用在新对象上,只有新对象与原型有关系,人为的在constructor返回的对象,与原型毫无关联,自然不是class的实例。


数据共享


定义在constructor中的属性,是每个实例独有的,不会在原型上共享。


  class Person {
constructor() {
this.name = new String("Jack");
// 定义在constructor中的函数是不被原型共享的
this.sayName = () => console.log(this.name);
this.nicknames = ["Jake", "J-Dog"];
}
}
let p1 = new Person();
let p2 = new Person();
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); //false

实例化的时候相当于复制了一个新函数


  class Person {
constructor() {
this.name = new String("Jack");
this.sayName = new Function();
this.nicknames = new Array(["Jake", "J-Dog"]);
}
}

如果想在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。


  class Person {
constructor() {
// 定义在constructor中的方法是属于每个实例的
this.locate = () => console.log("instance");
}
// 定义在类块中的方法是所有实例共享的
test() {
console.log("test");
}
}

let person1 = new Person();
let person2 = new Person();
console.log(person1.locate === person2.locate); // false
console.log(person1.test === person2.test); // true
// 实例中有该属性
console.log(person1.hasOwnProperty("locate")); // true
// 实例中没有该属性
console.log(person1.hasOwnProperty("test")); // false

类的静态方法


类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。


作用


在日常开发中,我会通过类的静态方法去处理一些名称管理和接口,如下面所示:


  class Home {
static getData() {
return [];
}
}
console.log(Home.getData());

静态方法中的this至与类有关


需要注意的是,静态方法不要求存在类的实例,所以this引用类自身,而不是实例。


  class Bar {
static test() {
console.log(this);
}
}
// 类可以直接调用静态方法
Bar.test(); // class Bar {}

var bar = new Bar();
// 实例与静态方法无关
bar.test(); // 报错,bar.test is not a function

静态方法也可被继承


  class Bar {
static test() {
console.log(this);
}
}

class Foo extends Bar {}
Foo.test(); // class Foo extends Bar {}

静态方法也是可以从super对象上调用的


  class Bar {
static test() {
return "test1";
}
}

class Foo extends Bar {
static test2() {
return super.test() + " test2";
}
}
console.log(Foo.test2()); // test1 test2

类中this指向



  1. this存在于类的构造函数中,this指向实例

  2. this存在于类的原型对象上,this指向类的原型

  3. this存在于类的静态方法中,this指向当前类


类的继承


类的继承使用的是新语法,但它的本质依旧是原型链。


ES6中,使用extends关键字,就可以继承任何拥有constructor和原型的对象。所以它不仅可以继承一个类,还可以继承普通的构造函数。


  class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true
function Person() {}
// 继承普通构造函数
class Engineer extends Person {}
let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true

super


派生类的方法可以通过 super 关键字引用它们的原型。这个关键字只能在派生类中使用,在类构造函数中使用 super 可以调用父类构造函数。


提炼几个要点:



  1. super关键字只能在派生类的构造函数和静态方法上使用,如下所示,Vehicle不是派生类


  class Vehicle {
constructor() {
// SyntaxError: 'super' keyword unexpected
super();
}
}


  1. 在类构造函数中,不能在调用 super()之前引用 this。


  class Vehicle {}
class Bus extends Vehicle {
constructor() {
console.log(this);
}
}
new Bus();
// Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor


  1. 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super(),要么必须在其中返回一个对象。


  class Vehicle {}

class Car extends Vehicle {}
console.log(new Car()); // Car {}

class Bus extends Vehicle {
constructor() {
super();
}
}
console.log(new Bus()); // Bus {}

class Van extends Vehicle {
constructor() {
return {};
}
}
console.log(new Van()); // {}

class Test extends Vehicle {
constructor() {}
}
console.log(new Test());
// Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

链接:https://juejin.cn/post/7001502812261580836

收起阅读 »

二进制都不了解?也配做什么程序员???

最近在学习一些计算机专业课,学习的过程中二进制的基础是必须要有的,不管是计算机网络,还是组成原理,还是操作系统,都是以二进制为基础的,所以本文总结一些二进制基础。今天,2021年8月30日,写下第一版,后面会陆续增加一些内容,增加一些应用便于更多人理解。 本文...
继续阅读 »

最近在学习一些计算机专业课,学习的过程中二进制的基础是必须要有的,不管是计算机网络,还是组成原理,还是操作系统,都是以二进制为基础的,所以本文总结一些二进制基础。今天,2021年8月30日,写下第一版,后面会陆续增加一些内容,增加一些应用便于更多人理解。


本文目标:



  • 理解的概念

  • 熟记常见的2的次幂,例如128是2的几次幂(2的几次幂就需要多少个二进制位)

  • 理解字节,对于1个字节能存储多少数据做到理性认知

  • 熟记16进制0-16,对应的2进制


带着问题阅读:



  1. 一个ip地址 192.168.1.1共有几位

  2. CSS中的颜色表示 #ffaaff,需要占用多大的存储空间存储

  3. 为什么计算机专业书籍中,表示内存地址大部分都是用16进制表示的,而不是10进制或者2进制

  4. javascirpt中的数字类型在计算机内存储为多少Byte

  5. 宽带的带宽是200M,为什么下载的时候怎么都达不到200M呢


如果所有的问题,你都会,就不用读了,直接退出。


进制


10进制,一位数可以是0-9,共10种可能,如果要表示第11种可能,就要进位。


类比一下,2进制,一位数只能是0或1,有2种可能。


16进制,一位数可以是0-15,有16种可能


10进制的进位规则如下:满10进一位


0  10  20
1 11
2 12
3 13
4 14
5 15
6 16
7 17
8 18
9 19

2进制的进位规则如下:满两位进一位,10进制的0是2进制0,10进制的1是2进制的1,如果要表示10进制的2,就要用两位2进制数,10


0  10  100  1000
1 11 101 1001
110 1010
111 1011
1100
1101
1110
1111


16进制的规则,满16进一位(a表示10进制的10,b:11,c:12...)


0  10(10进制的16)
1 11(10进制的17)
2 12
3
4
5
6
7
8
9
a
b
c
d
e
f

2进制与16进制


一位二进制数,称为1bit。


image.png


1位二进制数,也就是1bit,有2种可能,可以表示数0,1


2位二进制数,2bit,有4种可能(2x2),可以表示数0,1,2,3


3位二进制数,3bit,有8种可能(2x2x2),可以表示数0,1,2,3,4,5,6,7


...


n位二进制数,有 2^n -1 种可能。


有一些常用的2的次幂需要记住,必须记在脑子里,例如看到10进制的128,就想起来是2的7次方,就想起来有7位,0000000


image.png


2进制是计算机用的,人用起来写起来并不方便,所以就有了16进制。


一个16进制,可以表示16种可能性,也就是2的4次方,就是4位2进制数,就是4bit


举个栗子,


16进制是f,表示为2进制就是1111


16进制的ff,表示为2进制就是1111 1111


规律就是,一位16进制,可以用4位2进制来表示。2位16进制,用8位2进制数来表示。


那么16进制的ffffff表示为2进制是多少位呢


字节



字节(英语:Byte),通常用作计算机信息计量单位,不分数据类型。是通信和数据存储的概念。



一个字节能存储8位2进制数据(这个是规范,需要刻在DNA里面)


1Byte =8bit

2^8是256,1个字节能表示的数就是0-255,共256种可能性。


1位16进制数能表示为4位2进制,所以一个字节能表示2个16进制。


总结如下:


1Byte
8bit 1111 1111
2个16进制位 f f

KB,MB,GB,Kb,Mb,Gb


KB(Kilobyte) 千字节,国际单位法一般以1000来定义千,例如1千米=1000米,但是在信息领域,尤其是表示主存储容量时,千字节一般表示1024(2^10)个字节


1KB = 1024 B   2^10 Byte
1MB = 1024 KB 2^20 Byte
1GB = 1024 MB 2^30 Byte

Kb与KB是不同的,Kb是 Kilobit,


1Kb = 1024bit

我们的宽带的带宽是200M每秒,其实是200Mb/s,但是文件是以Byte为单位的,而不是bit,所以需要换算一下


200Mb / 8 = 25 MB

其实能够达到的最高下载速度是25MB/s


简单应用


一个ip地址 192.168.1.1,共32位,why?


因为ip地址是10进制表示的,ip地址用.分开,每一段的范围是0-255,就是2^8,共8位,4*8=32,一共32位。


CSS中的颜色表示 #ffaaff,需要占用多大的存储空间存储


1个Byte存储8位2进制,


1个16进制相当于4位2进制,


所以1个Byte存储2位16进制


#ffaaff存储需要 3Byte


本文就先到这里,后续要有一些内容需要补充,比如按位&``|``!左移右移以及更多的应用(在内存层面的应用,在计算机网络中的应用,在字符编码中的应用等)等我学会了,整理了,补充在这篇文章的后面。


有问题请在评论区提出。


链接:https://juejin.cn/post/7002088412903637022

收起阅读 »

一个"剑气"加载?️

🙇 前言 我们在网页加载的时候总会加上一些过渡效果来引导用户,减少白屏时间,而加载的效果可以用svg也可以使用一些我们封装好的组件,今天就来分享一种"剑气"加载效果。 相信大家看封面都知道效果了,那我们就直接开干吧。 🏋️‍♂️ToDoList 剑气...
继续阅读 »

🙇 前言



  • 我们在网页加载的时候总会加上一些过渡效果来引导用户,减少白屏时间,而加载的效果可以用svg也可以使用一些我们封装好的组件,今天就来分享一种"剑气"加载效果。

  • 相信大家看封面都知道效果了,那我们就直接开干吧。


src=http___image.17173.com_bbs_v1_2012_12_01_1354372326576.gif&refer=http___image.17173.gif


🏋️‍♂️ToDoList



  • 剑气形状

  • 剑气转动

  • 组合剑气


🚴 Just Do It



  • 其实做一个这样的效果仔细看就是有三个类似圆环状的元素进行循环转动,我们只需要拆解出一个圆环来做效果即可,最后再将三个圆环组合起来。


剑气形状



  • 仔细看一道剑气,它的形状是不是很像一把圆圆的镰刀分成一半,而这个镰刀我们可以通过边框和圆角来做。

  • 首先准备一个剑气雏形。


  <div class="sword">
<span>
</div>


  • 我们只需要对一个圆加上一个方向的边框就可以做成半圆的形状,这样类似剑气的半圆环形状就完成了🌪️。


.sword {
position: relative;
margin: 200px auto;
width: 64px;
height: 64px;
border-radius: 50%;
}
.sword span{
position: absolute;
box-sizing: border-box;
width: 100%;
height: 100%;
border-radius: 50%;
}
.sword :first-child{
left: 0%;
top: 0%;
border-bottom: 3px solid #EFEFFA;
}

image.png


剑气转动



  • 因为我们需要剑气一直不停的循环转动,所以我们可以借助cssanimation动画属性就可以自己给它添加一个动画了。

  • animation属性是一个简写属性,可以用于设置以下动画属性分别是:

    • animation-name:指定要绑定到选择器的关键帧的名称

    • animation-duration:动画指定需要多少秒或毫秒完成

    • animation-timing-function:设置动画将如何完成一个周期

    • animation-delay:设置动画在启动前的延迟间隔

    • animation-iteration-count:定义动画的播放次数

    • animation-direction:指定是否应该轮流反向播放动画

    • animation-fill-mode:规定当动画不播放时,要应用到元素的样式

    • animation-play-state:指定动画是否正在运行或已暂停



  • 更多的动画学习可以参考MDN


...
.sword :first-child{
...
animation: sword-one 1s linear infinite;
...
}
@keyframes sword-one {
0% {
transform: rotateZ(0deg);
}
100% {
transform: rotateZ(360deg);
}
}
...


  • 我们可以给定一个不断绕z0deg360deg转动的动画,设定为一秒完成一次一直无限循环,我们来看看效果:


剑气1.gif



  • 接下来让这个半圆弧分别绕x轴和y轴也转动一定角度即可完成一个剑气的转动。


...
@keyframes sword-one {
0% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(360deg);
}
}
...


  • 我们来看看完成后的效果:


剑气2.gif


组合剑气



  • 最后我们只需要再制作两个剑气在组装起来就好了。


<div class="sword">
<span></span>
<span></span>
<span></span>
</div>


  • 给新添的两个span添加动画和样式。


...
.sword :nth-child(2){
right: 0%;
top: 0%;
animation: sword-two 1s linear infinite;
border-right: 3px solid #EFEFFA;
}

.sword :last-child{
right: 0%;
bottom: 0%;
animation: sword-three 1s linear infinite;
border-top: 3px solid #EFEFFA;
}

@keyframes sword-two {
0% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0deg);
}
100% {
transform: rotateX(50deg) rotateY(10deg) rotateZ(360deg);
}
}

@keyframes sword-three {
0% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0deg);
}
100% {
transform: rotateX(35deg) rotateY(55deg) rotateZ(360deg);
}
}
...


  • 这样我们的剑气加载效果就制作好了,以上就是全部代码了,喜欢的可以拿去用哟。

  • 我们来看看最终的效果吧~


剑气3.gif



链接:https://juejin.cn/post/7001779766852321287

收起阅读 »

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

收起阅读 »

【环信MQTT消息云集成指南】常见问题整理

业务开通1. 注册后无法开通MQTT业务问题描述:在注册账户后,登录console控制台,选择【应用列表】中的某一个App,无法开通MQTT业务。 问题原因:(1)使用系统提供的默认demo开通,目前该demo暂不支持开通MQTT业务; (...
继续阅读 »

业务开通

1. 注册后无法开通MQTT业务

问题描述:
在注册账户后,登录console控制台,选择【应用列表】中的某一个App,无法开通MQTT业务。 

问题原因:
(1)使用系统提供的默认demo开通,目前该demo暂不支持开通MQTT业务; 
(2)在console控制台【添加应用】时,数据中心默认为“国内2区”,需要更改为“国内1区”; 


解决方案:
新建应用时,数据中心选择“国内一区”。

客户端集成 

 1.哪些开发语言支持集成MQTT客户端

问题描述:
都有哪些开发语言支持集成MQTT客户端?
解决方案:
MQTT协议属于标准协议,目前已支持多种开发语言集成,包括:Java、iOS、Android、JavaScript、C、Node.js等等。同时集成MQTT客户端可支持两种方式:
(1)选择开源的MQTT SDK:社区提供多种开源的MQTT 客户端SDK,环信已将下载链接整理如下,可根据需要下载。
客户端SDK下载
(2)基于标准协议进行开发:基于标准的协议开发MQTT客户端,也可无差异对接MQTT服务器。

 2.小程序是否支持集成MQTT客户端 

问题描述:
小程序(微信小程序、支付宝小程序)是否支持集成MQTT客户端?
解决方案:
支持的,目前微信及支付宝小程序都支持集成MQTT客户端,可选择使用mqtt.min.js SDK。 
集成步骤如下: 
步骤1:选择mqtt.min.js开源的SDK,目前测试只有4.2.1之前的版本小程序端可以使用。 
步骤2:配置域名地址,在【微信/支付宝开发者平台】->【开发】->【开发管理】->【开发设置】->【服务域名配置】部分配置获取token的域名地址和服务器连接的域名地址。 
(1)服务器连接的域名地址:在console后台【MQTT】->【服务概览】->【服务配置】下的【连接地址】获取; 
(2)获取token域名地址:在console后台【即时通讯】->【服务概览】->【服务版本】下的【REST API访问地址】获取; 
步骤3:获取orgname和appname:appkey由orgname#appname组成,例如“1145210806152081#demo”,orgname为“1145210806152081”,appname为“demo”; 
步骤4:设置用户登录账户,在console后台【应用概览】->【用户认证】下创建账户信息。 
步骤5:获取上述参数,配置到环信小程序demo中的相应字段上,即可成功连接服务器实现消息通信。 下载微信小程序demo下载支付宝小程序demo

3.安卓端如何集成MQTT客户端

问题描述:
安卓端如何集成MQTT客户端?
解决方案:
环信已提供安卓端使用的MQTT demo,下载链接: 安卓端demo

4.iOS端如何集成MQTT客户端 

问题描述:
iOS端如何集成MQTT客户端?
解决方案:
环信已提供iOS端使用的MQTT demo,下载链接:iOS端demo

 5.Java端如何集成MQTT客户端 

问题描述:
Java端如何集成MQTT客户端?
解决方案:
环信已提供Java端使用的MQTT demo,下载链接:Java端demo

6.Web端如何集成MQTT客户端 

问题描述:
Web端如何集成MQTT客户端?
解决方案:
环信已提供Web端使用的MQTT demo,下载链接:Web端demo

 服务器端集成

1.服务器端如何集成MQTT服务

问题描述:
服务器端如何集成MQTT服务?
解决方案:
为实现客户服务器、环信MQTT消息云及客户终端之间进行消息交互,不仅提供了客户终端使用的SDK,同时提供了服务器端集成方法,包括REST API方式服务器端SDK。 
(1)REST API方法 

(2)服务器端SDK下载

客户端连接

1.开通后无法连接MQTT消息云

问题描述:
在创建APP后,通过console后台获取的参数,无法连接MQTT消息云。 
问题原因:
(1)认证鉴权信息填写错误:
a)如果使用MQTT桌面客户端连接,登录密码直接使用token内容。token在【应用概览】->【用户认证】页面,选择相应账户后的【查看token】按钮获取;
b)如果使用代码连接,需要通过【应用概览】->【用户认证】->【用户ID】列表中的username/password先获取token,在通过token进行认证连接; 
(2)clientID填写错误:
a)clientID由两部分组成,形式为“deviceID@AppID”,其中deviceID由用户自定义,AppID通过【服务概览】->【服务配置】中获取。 例:“0023B8@ipd7a0”;
b)部分MQTT客户端会在clientID后自动添加时间戳,需要在连接时,将Append timestamp to MQTT client id置false; 
解决方案:
MQTT消息云连接需要5个基本参数,包括连接地址(Host)、端口(Port)、clientID(MQTT client ID)、用户ID(Username)、token(Password)
以下图为例,连接5个参数为: 
连接地址:aitbj0.cn1.mqtt.chat 
端口:1883(mqtt) 
clientID:0023B8@aitbj0(自定义@AppID) 
Username:test 
token:相应用户ID后的【查看token】内容




 2.MQTT客户端连接消息云时,登录密码是选择用户密码还是token? 

问题描述:
MQTT客户端连接消息云时,登录密码是选择用户密码还是token值。 
问题原因:
取决于使用者是否可以对MQTT客户端连接过程进行修改。 
(1)如果可以修改,则需要先编写获取token流程,然后使用获取的token进行连接登录。当获取token时,会使用用户名、用户密码、org_name、app_name以及token域名等信息;
(2)如果不可以修改,则直接使用token进行连接登录; 
解决方案:
确定使用的MQTT客户端: 
(1)如果使用MQTT桌面客户端,比如MQTT Broker、MQTT BOX等,登录密码直接使用token内容。token在【应用概览】->【用户认证】页面,选择相应账户后的【查看token】按钮获取;
(2)如果使用代码连接,需要通过【应用概览】->【用户认证】->【用户ID】列表中的用户名和用户密码请求token,在通过获取到的token进行认证连接; 

3.MQTT客户端反复出现断开重连现象 

问题描述:
MQTT客户端反复出现断开重连现象。 
问题原因:
(1)在配置参数时,设置了断开重连模式; 
(2)存在两个或两个以上的客户端使用相同的clientid登录,他们会互相踢对方,如果设置成自动重连机制,将会无限循环断开重连; 
解决方案:
确保当前APP下每个clientID的唯一性。

 4.用户名存在100个限制怎么办?

问题描述:
在【应用概览】->【用户认证】下创建用户会有100个 数量的限制,如果登录clientID超过100个怎么办? 
问题原因:
(1)MQTT消息云以clientID作为唯一标识,不同的clientID可以使用相同的用户信息进行登录,因此100个用户信息可以复用;
(2)如果客户需要集成IM、Push等其他的业务,可以联系商务进行版本升级,提高创建用户数量; 
解决方案:
复用用户账户,不同的clientID可以使用相同的用户信息进行登录。

 5.登录报错信息Server has closed connection without connack. 

问题描述:
在MQTT客户端连接时,服务器返回‘Server has closed connection without connack.’ 
问题原因:
(1)MQTT客户端没有启用遗嘱消息(will:false),但是遗嘱消息的Qos等级设置为“1”,标准规定will为false时,willqos必须是0; 
解决方案:
将遗嘱消息的QoS等级设置为'0'。

异常排查

1.如何查看消息是否发送成功 

问题描述:
发布客户端发送消息后,订阅客户端一直收不到消息,如何确认消息是否发送成功?
问题原因:
消息接收不到,有以下可能原因: 
(1)发布客户端没有将消息发布出去; 
(2)订阅客户端没有接收到消息; 
解决方案:
(1)通过调用REST API中查询客户端消息发送&投递记录接口查询; 
(2)通过调用服务器端SDK中QueryMqttRecordMessageOfClient函数查询; 
(3)使用环信console后台中的【记录查询】→【消息记录查询】功能,输入发布/订阅客户端的clientID及相应时间段,即可查看上下行消息记录,定位问题出在发布客户端还是订阅客户端。 


 2.如何查看客户端的订阅关系

问题描述:
如何查看客户端的订阅关系?
解决方案:
(1)通过调用REST API中查询客户端session信息接口查询; 
(2)通过调用服务器端SDK中QuerySessionByClientId函数查询; 
(3)使用环信console后台中的【记录查询】→【设备状态查询】功能,输入待查询客户端的clientID,即可查看在线状态、登录用户ID以及订阅关系。 


3.如何查看客户端的连接记录 

问题描述:
如何查看客户端的连接记录?
解决方案:
(1)通过调用REST API中查询客户端连接记录接口查询; 
(2)通过调用服务器端SDK中QueryMqttRecordDevice函数查询; 
(3)使用环信console后台中的【记录查询】→【设备记录查询】功能,输入待查询客户端的clientID及相应时间段,即可查看客户端连接、订阅以及断开连接等记录。 

 

4.如何查看客户端是否在线 

问题描述:
如何查看客户端是否在线?
解决方案:
(1)通过调用REST API中查询客户端session信息接口查询; 
(2)通过调用服务器端SDK中QuerySessionByClientId函数查询; 
(3)使用环信console后台中的【记录查询】→【设备状态查询】功能,输入待查询客户端的clientID,即可查看在线状态、登录用户ID以及订阅关系。 

收起阅读 »

学会这个,我的http加载速度更快了!

1. 前言 说到 HTTP 怎么提升网络加载速度,就不得不聊一聊 HTTP/2 了。 HTTP/2 的主要目标是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。 HTTP/2 ...
继续阅读 »

1. 前言


说到 HTTP 怎么提升网络加载速度,就不得不聊一聊 HTTP/2 了。


HTTP/2 的主要目标是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。


HTTP/2 没有改动 HTTP 的应用语义。 HTTP 方法、状态代码、URI 和标头字段等核心概念一如往常。 不过,HTTP/2 修改了数据格式化(分帧)以及在客户端与服务器间传输的方式。这两点统帅全局,通过新的分帧层向我们的应用隐藏了所有复杂性。 因此,所有现有的应用都可以不必修改而在新协议下运行。


2. 二进制分帧层


HTTP/2 所有性能增强的核心在于新的二进制分帧层,它定义了如何封装 HTTP 消息并在客户端与服务器之间传输。


image.png


这里所谓的“层”,指的是位于套接字接口与应用可见的高级 HTTP API 之间一个经过优化的新编码机制: HTTP 的语义(包括各种动词、方法、标头)都不受影响,不同的是传输期间对它们的编码方式变了。 HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式对它们编码。


3. 数据流、消息和帧


新的二进制分帧机制改变了客户端与服务器之间交换数据的方式。 为了说明这个过程,我们需要了解 HTTP/2 的三个概念:



  • 数据流: 已建立的连接内的双向字节流,可以承载一条或多条消息。

  • 消息: 与逻辑请求或响应消息对应的完整的一系列帧。

  • : HTTP/2 通信的最小单位,每个帧都包含帧头,至少也会标识出当前帧所属的数据流。


这些概念的关系总结如下:



  • 所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。

  • 每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。

  • 每条消息都是一条逻辑 HTTP 消息(例如请求或响应),包含一个或多个帧。

  • 帧是最小的通信单位,承载着特定类型的数据,例如 HTTP 标头、消息负载等等。 来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。


image.png


简言之,HTTP/2 将 HTTP 协议通信分解为二进制编码帧的交换,这些帧对应着特定数据流中的消息。所有这些都在一个 TCP 连接内复用。 这是 HTTP/2 协议所有其他功能和性能优化的基础。


4. 请求与响应复用


在 HTTP/1.x 中,如果客户端要想发起多个并行请求以提升性能,则必须使用多个 TCP 连接(请参阅使用多个 TCP 连接)。 这是 HTTP/1.x 交付模型的直接结果,该模型可以保证每个连接每次只交付一个响应(响应排队)。 更糟糕的是,这种模型也会导致队首阻塞,从而造成底层 TCP 连接的效率低下。


HTTP/2 中新的二进制分帧层突破了这些限制,实现了完整的请求和响应复用: 客户端和服务器可以将 HTTP 消息分解为互不依赖的帧,然后交错发送,最后再在另一端把它们重新组装起来。


image.png


快照捕捉了同一个连接内并行的多个数据流。 客户端正在向服务器传输一个 DATA 帧(数据流 5),与此同时,服务器正向客户端交错发送数据流 1 和数据流 3 的一系列帧。因此,一个连接上同时有三个并行数据流。


将 HTTP 消息分解为独立的帧,交错发送,然后在另一端重新组装是 HTTP 2 最重要的一项增强。事实上,这个机制会在整个网络技术栈中引发一系列连锁反应,从而带来巨大的性能提升,让我们可以:



  • 并行交错地发送多个请求,请求之间互不影响。

  • 并行交错地发送多个响应,响应之间互不干扰。

  • 使用一个连接并行发送多个请求和响应。

  • 不必再为绕过 HTTP/1.x 限制而做很多工作(请参阅针对 HTTP/1.x 进行优化,例如级联文件、image sprites 和域名分片。

  • 消除不必要的延迟和提高现有网络容量的利用率,从而减少页面加载时间。

  • 等等…


HTTP/2 中的新二进制分帧层解决了 HTTP/1.x 中存在的队首阻塞问题,也消除了并行处理和发送请求及响应时对多个连接的依赖。 结果,应用速度更快、开发更简单、部署成本更低。


5. 数据流优先级


将 HTTP 消息分解为很多独立的帧之后,我们就可以复用多个数据流中的帧,客户端和服务器交错发送和传输这些帧的顺序就成为关键的性能决定因素。 为了做到这一点,HTTP/2 标准允许每个数据流都有一个关联的权重和依赖关系:



  • 可以向每个数据流分配一个介于 1 至 256 之间的整数。

  • 每个数据流与其他数据流之间可以存在显式依赖关系。


数据流依赖关系和权重的组合让客户端可以构建和传递“优先级树”,表明它倾向于如何接收响应。 反过来,服务器可以使用此信息通过控制 CPU、内存和其他资源的分配设定数据流处理的优先级,在资源数据可用之后,带宽分配可以确保将高优先级响应以最优方式传输至客户端。


image.png


HTTP/2 内的数据流依赖关系通过将另一个数据流的唯一标识符作为父项引用进行声明;如果忽略标识符,相应数据流将依赖于“根数据流”。 声明数据流依赖关系指出,应尽可能先向父数据流分配资源,然后再向其依赖项分配资源。 换句话说,“请先处理和传输响应 D,然后再处理和传输响应 C”。


共享相同父项的数据流(即,同级数据流)应按其权重比例分配资源。 例如,如果数据流 A 的权重为 12,其同级数据流 B 的权重为 4,那么要确定每个数据流应接收的资源比例,请执行以下操作:



  1. 将所有权重求和: 4 + 12 = 16

  2. 将每个数据流权重除以总权重: A = 12/16, B = 4/16


因此,数据流 A 应获得四分之三的可用资源,数据流 B 应获得四分之一的可用资源;数据流 B 获得的资源是数据流 A 所获资源的三分之一。


我们来看一下上图中的其他几个操作示例。 从左到右依次为:



  1. 数据流 A 和数据流 B 都没有指定父依赖项,依赖于隐式“根数据流”;A 的权重为 12,B 的权重为 4。因此,根据比例权重: 数据流 B 获得的资源是 A 所获资源的三分之一。

  2. 数据流 D 依赖于根数据流;C 依赖于 D。 因此,D 应先于 C 获得完整资源分配。 权重不重要,因为 C 的依赖关系拥有更高的优先级。

  3. 数据流 D 应先于 C 获得完整资源分配;C 应先于 A 和 B 获得完整资源分配;数据流 B 获得的资源是 A 所获资源的三分之一。

  4. 数据流 D 应先于 E 和 C 获得完整资源分配;E 和 C 应先于 A 和 B 获得相同的资源分配;A 和 B 应基于其权重获得比例分配。


如上面的示例所示,数据流依赖关系和权重的组合明确表达了资源优先级,这是一种用于提升浏览性能的关键功能,网络中拥有多种资源类型,它们的依赖关系和权重各不相同。 不仅如此,HTTP/2 协议还允许客户端随时更新这些优先级,进一步优化了浏览器性能。 换句话说,我们可以根据用户互动和其他信号更改依赖关系和重新分配权重。


注: 数据流依赖关系和权重表示传输优先级,而不是要求,因此不能保证特定的处理或传输顺序。 即,客户端无法强制服务器通过数据流优先级以特定顺序处理数据流。 尽管这看起来违反直觉,但却是一种必要行为。 我们不希望在优先级较高的资源受到阻止时,还阻止服务器处理优先级较低的资源。


6. 每个来源一个连接


有了新的分帧机制后,HTTP/2 不再依赖多个 TCP 连接去并行复用数据流;每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别设定优先级。 因此,所有 HTTP/2 连接都是永久的,而且仅需要每个来源一个连接,随之带来诸多性能优势。



SPDY 和 HTTP/2 的杀手级功能是,可以在一个拥塞受到良好控制的通道上任意进行复用。 这一功能的重要性和良好运行状况让我吃惊。 我喜欢的一个非常不错的指标是连接拆分,这些拆分仅承载一个 HTTP 事务(并因此让该事务承担所有开销)。 对于 HTTP/1,我们 74% 的活动连接仅承载一个事务 - 永久连接并不如我们所有人希望的那般有用。 但是在 HTTP/2 中,这一比例锐减至 25%。 这是在减少开销方面获得的巨大成效。  (HTTP/2 登陆 Firefox,Patrick McManus)



大多数 HTTP 传输都是短暂且急促的,而 TCP 则针对长时间的批量数据传输进行了优化。 通过重用相同的连接,HTTP/2 既可以更有效地利用每个 TCP 连接,也可以显著降低整体协议开销。 不仅如此,使用更少的连接还可以减少占用的内存和处理空间,也可以缩短完整连接路径(即,客户端、可信中介和源服务器之间的路径) 这降低了整体运行成本并提高了网络利用率和容量。 因此,迁移到 HTTP/2 不仅可以减少网络延迟,还有助于提高通量和降低运行成本。


注: 连接数量减少对提升 HTTPS 部署的性能来说是一项特别重要的功能: 可以减少开销较大的 TLS 连接数、提升会话重用率,以及从整体上减少所需的客户端和服务器资源。


7. 流控制


流控制是一种阻止发送方向接收方发送大量数据的机制,以免超出后者的需求或处理能力: 发送方可能非常繁忙、处于较高的负载之下,也可能仅仅希望为特定数据流分配固定量的资源。 例如,客户端可能请求了一个具有较高优先级的大型视频流,但是用户已经暂停视频,客户端现在希望暂停或限制从服务器的传输,以免提取和缓冲不必要的数据。 再比如,一个代理服务器可能具有较快的下游连接和较慢的上游连接,并且也希望调节下游连接传输数据的速度以匹配上游连接的速度来控制其资源利用率;等等。


上述要求会让您想到 TCP 流控制吗?您应当想到这一点;因为问题基本相同(请参阅流控制)。 不过,由于 HTTP/2 数据流在一个 TCP 连接内复用,TCP 流控制既不够精细,也无法提供必要的应用级 API 来调节各个数据流的传输。 为了解决这一问题,HTTP/2 提供了一组简单的构建块,这些构建块允许客户端和服务器实现其自己的数据流和连接级流控制:



  • 流控制具有方向性。 每个接收方都可以根据自身需要选择为每个数据流和整个连接设置任意的窗口大小。

  • 流控制基于信用。 每个接收方都可以公布其初始连接和数据流流控制窗口(以字节为单位),每当发送方发出 DATA 帧时都会减小,在接收方发出 WINDOW_UPDATE 帧时增大。

  • 流控制无法停用。 建立 HTTP/2 连接后,客户端将与服务器交换 SETTINGS 帧,这会在两个方向上设置流控制窗口。 流控制窗口的默认值设为 65,535 字节,但是接收方可以设置一个较大的最大窗口大小(2^31-1 字节),并在接收到任意数据时通过发送 WINDOW_UPDATE 帧来维持这一大小。

  • 流控制为逐跃点控制,而非端到端控制。 即,可信中介可以使用它来控制资源使用,以及基于自身条件和启发式算法实现资源分配机制。


HTTP/2 未指定任何特定算法来实现流控制。 不过,它提供了简单的构建块并推迟了客户端和服务器实现,可以实现自定义策略来调节资源使用和分配,以及实现新传输能力,同时提升网页应用的实际性能和感知性能(请参阅速度、性能和人类感知)。


例如,应用层流控制允许浏览器仅提取一部分特定资源,通过将数据流流控制窗口减小为零来暂停提取,稍后再行恢复。 换句话说,它允许浏览器提取图像预览或首次扫描结果,进行显示并允许其他高优先级提取继续,然后在更关键的资源完成加载后恢复提取。


8. 服务器推送


HTTP/2 新增的另一个强大的新功能是,服务器可以对一个客户端请求发送多个响应。 换句话说,除了对最初请求的响应外,服务器还可以向客户端推送额外资源(图 12-5),而无需客户端明确地请求。


image.png


注: HTTP/2 打破了严格的请求-响应语义,支持一对多和服务器发起的推送工作流,在浏览器内外开启了全新的互动可能性。 这是一项使能功能,对我们思考协议、协议用途和使用方式具有重要的长期影响。


为什么在浏览器中需要一种此类机制呢?一个典型的网络应用包含多种资源,客户端需要检查服务器提供的文档才能逐个找到它们。 那为什么不让服务器提前推送这些资源,从而减少额外的延迟时间呢? 服务器已经知道客户端下一步要请求什么资源,这时候服务器推送即可派上用场。


事实上,如果您在网页中内联过 CSS、JavaScript,或者通过数据 URI 内联过其他资产(请参阅资源内联),那么您就已经亲身体验过服务器推送了。 对于将资源手动内联到文档中的过程,我们实际上是在将资源推送给客户端,而不是等待客户端请求。 使用 HTTP/2,我们不仅可以实现相同结果,还会获得其他性能优势。 推送资源可以进行以下处理:



  • 由客户端缓存

  • 在不同页面之间重用

  • 与其他资源一起复用

  • 由服务器设定优先级

  • 被客户端拒绝


PUSH_PROMISE 101


所有服务器推送数据流都由 PUSH_PROMISE 帧发起,表明了服务器向客户端推送所述资源的意图,并且需要先于请求推送资源的响应数据传输。 这种传输顺序非常重要: 客户端需要了解服务器打算推送哪些资源,以免为这些资源创建重复请求。 满足此要求的最简单策略是先于父响应(即,DATA 帧)发送所有 PUSH_PROMISE 帧,其中包含所承诺资源的 HTTP 标头。


在客户端接收到 PUSH_PROMISE 帧后,它可以根据自身情况选择拒绝数据流(通过 RST_STREAM 帧)。 (例如,如果资源已经位于缓存中,便可能会发生这种情况。) 这是一个相对于 HTTP/1.x 的重要提升。 相比之下,使用资源内联(一种受欢迎的 HTTP/1.x“优化”)等同于“强制推送”: 客户端无法选择拒绝、取消或单独处理内联的资源。


使用 HTTP/2,客户端仍然完全掌控服务器推送的使用方式。 客户端可以限制并行推送的数据流数量;调整初始的流控制窗口以控制在数据流首次打开时推送的数据量;或完全停用服务器推送。 这些优先级在 HTTP/2 连接开始时通过 SETTINGS 帧传输,可能随时更新。


推送的每个资源都是一个数据流,与内嵌资源不同,客户端可以对推送的资源逐一复用、设定优先级和处理。 浏览器强制执行的唯一安全限制是,推送的资源必须符合原点相同这一政策: 服务器对所提供内容必须具有权威性。


9. 标头压缩


每个 HTTP 传输都承载一组标头,这些标头说明了传输的资源及其属性。 在 HTTP/1.x 中,此元数据始终以纯文本形式,通常会给每个传输增加 500–800 字节的开销。如果使用 HTTP Cookie,增加的开销有时会达到上千字节。 (请参阅测量和控制协议开销。) 为了减少此开销和提升性能,HTTP/2 使用 HPACK 压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:



  1. 这种格式支持通过静态霍夫曼代码对传输的标头字段进行编码,从而减小了各个传输的大小。

  2. 这种格式要求客户端和服务器同时维护和更新一个包含之前见过的标头字段的索引列表(换句话说,它可以建立一个共享的压缩上下文),此列表随后会用作参考,对之前传输的值进行有效编码。


利用霍夫曼编码,可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码,索引值可用于有效查询和重构完整的标头键值对。


image.png


作为一种进一步优化方式,HPACK 压缩上下文包含一个静态表和一个动态表: 静态表在规范中定义,并提供了一个包含所有连接都可能使用的常用 HTTP 标头字段(例如,有效标头名称)的列表;动态表最初为空,将根据在特定连接内交换的值进行更新。 因此,为之前未见过的值采用静态 Huffman 编码,并替换每一侧静态表或动态表中已存在值的索引,可以减小每个请求的大小。


注: 在 HTTP/2 中,请求和响应标头字段的定义保持不变,仅有一些微小的差异: 所有标头字段名称均为小写,请求行现在拆分成各个 :method:scheme:authority 和 :path 伪标头字段。


HPACK 的安全性和性能


早期版本的 HTTP/2 和 SPDY 使用 zlib(带有一个自定义字典)压缩所有 HTTP 标头。 这种方式可以将所传输标头数据的大小减小 85% - 88%,显著减少了页面加载时间延迟:



在带宽较低的 DSL 链路中,上行链路速度仅有 375 Kbps,仅压缩请求标头就显著减少了特定网站(即,发出大量资源请求的网站)的页面加载时间。 我们发现,仅仅由于标头压缩,页面加载时间就减少了 45 - 1142 毫秒。  (SPDY 白皮书, chromium.org)



10. 相关阅读



链接:https://juejin.cn/post/7002025354542415902

收起阅读 »

从伪代码理解View事件分发过程

事件从起源从手指从屏幕按下的瞬间,触摸事件经过一系列处理会来到Activity的dispatchTouchEvent中。Activity.javapublic boolean dispatchTouchEvent(MotionEvent ev) { i...
继续阅读 »

事件从起源

从手指从屏幕按下的瞬间,触摸事件经过一系列处理会来到ActivitydispatchTouchEvent中。

Activity.java

public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
//getWindow().superDispatchTouchEvent(ev) 返回true代表消费了事件
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
//否则调用Activity的onTouchEvent
return onTouchEvent(ev);
}

getWindow()实际返回的是PhoneWindow

PhoneWindow.java

@Override
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}

getWindow().superDispatchTouchEvent(ev)实际会调用到mDecor.superDispatchTouchEvent(event)

DecorView.java

public class DecorView extends FrameLayout 
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
}

跟踪DecorView会发现DecorView继承自FrameLayout,因为FrameLayout没有重写dispatchTouchEvent方法,所以事件从Activity一路下来,最终事件的入口是ViewGroupdispatchTouchEvent

开发中,事件一般通过层层ViewGroup传递到View中,进行消费。一般View做真正的事件消费。

View的事件分发

View.java--伪代码

/**
* view接收事件的入口,事件由ViewGroup分发过来
*/

public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;

//如果设置了OnTouchListener,并且mOnTouchListener.onTouch返回了True,
//设置Result为True,那么代表事件到这已经消费完成了。
if (mOnTouchListener != null && mOnTouchListener.onTouch(this, event)) {
result = true;
}

//没有设置OnTouchListener,或者mOnTouchListener.onTouch返回了false时,result为false
//此时会回调View.onTouchEvent方法
if(!result&& onTouchEvent(event)){
result = true;
}
return result;
}

public boolean onTouchEvent(MotionEvent event) {
//如果设置了onClickListener,那么返回True代表事件到这已经消费完成了。
if (onClickListener != null) {
onClickListener.onClick(this);
return true;
}
return false;
}

dispatchTouchEvent是传入事件的入口,如果设置了mOnTouchListener,并且返回了true,那么dispatchTouchEvent就会返回true,代表事件被当前View消费了。如果没有设置,那么就会回调onTouchEvent方法,如果设置了onClickListener,那么onTouchEvent返回true,同理dispatchTouchEvent就会返回true,代表事件被当前View消费了.

从上面可以看出OnTouchListener先于onTouchEvent执行,onTouchEvent先于onClickListener执行。

ViewGroup的事件分发

ViewGroup.java--伪代码

    /**
* onInterceptTouchEvent 拦截事件
* @return true 代表拦截当前事件,那么事件就不会分发给ViewGroup的child View ,会调用自身的 super.dispatchTouchEvent(event)
* false 代表不拦截当前事件,不拦截事件,那么在dispatchTouchEvent会遍历child View,寻找能消费事件的child View
*/

public boolean onInterceptTouchEvent(MotionEvent ev) {
return false;
}

/**
* @param event 事件
* @param child 如果child 不为null,那么事件分发给它,否则,调用调用自身的 super.dispatchTouchEvent(event)
* @return 是否消费了该事件
*/

private boolean dispatchTransformedTouchEvent(MotionEvent event, View child) {
boolean handled = false;
if (child != null) {
handled = child.dispatchTouchEvent(event);
} else {
handled = super.dispatchTouchEvent(event);
}

return handled;
}


public boolean dispatchTouchEvent(MotionEvent event) {
boolean handled = false;
//是否拦截当前事件
boolean intercepted = onInterceptTouchEvent(event);
//触碰的对象
TouchTarget newTouchTarget = null;
int actionMasked = event.getActionMasked();

if (actionMasked != MotionEvent.ACTION_CANCEL && !intercepted) {
if (actionMasked == MotionEvent.ACTION_DOWN) {
//ViewGroup child View 数组
final View[] children = mChildren;
//倒序遍历,最后的通常是需要处理事件的
for (int i = children.length - 1; i >= 0; i--) {
View child = mChildren[i];
//isContainer 方法判断事件是否落在View中
if (!child.isContainer(event.getX(), event.getY())) {
continue;
}
//找到可以接收事件的View,把事件分发给他,
//如果dispatchTransformedTouchEvent返回了True代表消费了事件
if (dispatchTransformedTouchEvent(event, child)) {
handled = true;
//通过child包装成TouchTarget对象
newTouchTarget = addTouchTarget(child);
break;
}

}
}
}
//如果TouchTarget为null,那么事件就发就自己处理
//mFirstTouchTarget == null 在onInterceptTouchEvent返回true时,或没有找到可以消费的child View时成立
if (mFirstTouchTarget == null) {
handled = dispatchTransformedTouchEvent(event, null);
}
return handled;
}

dispatchTouchEvent是事件接收的入口,如果拦截事件,那么就调用super.dispatchTouchEvent(event),我们知道ViewGroup是继承View的,那么调用super.dispatchTouchEvent(event)等于调用ViewdispatchTouchEvent

View.java

/**
* view接收事件的入口,事件由ViewGroup分发过来
*/

public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;

//如果设置了OnTouchListener,并且mOnTouchListener.onTouch返回了True,
//设置Result为True,那么代表事件到这已经消费完成了。
if (mOnTouchListener != null && mOnTouchListener.onTouch(this, event)) {
result = true;
}

//没有设置OnTouchListener,或者mOnTouchListener.onTouch返回了false时,result为false
//此时会回调View.onTouchEvent方法
if(!result&& onTouchEvent(event)){
result = true;
}
return result;
}

如果不拦截,那么就会遍历当前ViewGroupchild view,找能消费事件的View,如果找到,调用dispatchTransformedTouchEvent(event, child),这里的child可以是ViewGroup或者是View,最后根据dispatchTransformedTouchEvent返回值判断是否消费了事件,如果返回false后,那么调用ViewGroupsuper.dispatchTouchEvent(event)

收起阅读 »

我是如何用这3个小工具,助力小姐姐提升100%开发效率的

前言 简单的知识点搭配合适的业务场景,往往能起到意想不到的效果。这篇文章会用三个最基础人人都知道的前端知识来说明如何助力运营小姐姐、公司48+前端开发同学的日常工作,让他们的工作效率得到极大地提升。 看完您可以会收获:用vue从零开始写一个chrome插件&n...
继续阅读 »

前言


简单的知识点搭配合适的业务场景,往往能起到意想不到的效果。这篇文章会用三个最基础人人都知道的前端知识来说明如何助力运营小姐姐、公司48+前端开发同学的日常工作,让他们的工作效率得到极大地提升。


看完您可以会收获:用vue从零开始写一个chrome插件 如何用Object.defineProperty拦截fetch请求`  如何使用油猴脚本开发一个扩展程序  日常提效的一些思考


油猴脚本入门示例



因为接下来的两个小工具都是基于油猴脚本来实现的,所以我们提前先了解一下它



油猴脚本是什么?



油猴脚本(Tampermonkey)是一个流行的浏览器扩展,可以运行用户编写的扩展脚本,来实现各式各样的功能,比如去广告、修改样式、下载视频等。



如何写一个油猴脚本?


1. 安装油猴


以chrome浏览器扩展为例,点击这里先安装


安装完成之后可以看到右上角多了这个


image.png


2. 新增示例脚本 hello world



// ==UserScript==
// @name hello world // 脚本名称
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://juejin.cn/* // 表示怎样的url才执行下面的代码
// @icon https://www.google.com/s2/favicons?domain=juejin.cn
// @grant none
// ==/UserScript==

(function() {
'use strict';
alert('hello world')
// Your code here...
})();

没错当打开任意一个https://juejin.cn/*掘金的页面时,都会弹出hello world,而其他的网页如https://baidu.com则不会。


到此你就完成了一个最简单的油猴脚本,接下来我们看一下用同样简单的代码,来解决一个实际问题吧!O(∩_∩)O


3行代码让SSO自动登录


问题是什么?


1. 有一天运营小姐姐要在几个系统之间配置点东西


一顿操作,终于把事情搞定了,心情美美的。


但是她心想,为啥每个系统都要我登录一次,不开心 o( ̄ヘ ̄o#)


1.gif


2. 下午一觉醒来,领导让把上午的配置重新改一下(尽职的小姐姐马上开始操作)


但是让她没想到的是:上午的登录页面仿佛许久没有见到她一样,又和小姐姐来了一次亲密接触😭


此时,她的内心已经开始崩溃了


2.gif


3. 但是这不是结束,以后的每一天她都是这种状态😭😭😭


3.gif


痛点在哪里?



看完上面的动图,我猜你已经在替小姐姐一起骂娘了,这做的什么玩意,太垃圾了。SSO是统一登录,你们这搞的是什么东西。



是的,我的内心和你一样愤愤不平, 一样有一万个草泥马在奔腾,这是哪个sb设计的方案,简直不配做人,一天啥事也不干,尽是跳登录页,输入用户名密码点登录按钮了,久而久之,朋友间见面说的第一句话不是“你吃了吗?”,而是“你登录了吗?”。


不过吐槽完,我们还是要想想如何通过技术手段解决这两个痛点,达到只需要登录一次的目的


1. 在A系统登录之后,跑到其他系统需要重新登录。


2. 登录时效只有2小时,2小时后,需要重新登录


该如何解决?


根本原因还是公司的SSO统一登录方案设计的有问题,所以需要推动他们修改,但是这是一个相对长期的过程,短期内有没有什么办法能让我们愉快的登录呢?


痛点1: 1. 在A系统登录之后,跑到其他系统需要重新登录。已无力回天


痛点2: 2. 登录时效只有2小时,2小时后,需要重新登录已无力回天


我们不好直接侵入各个系统去改造登录逻辑,改造其登录时效,但是却可以对登录页面(示例)做点手脚


image.png


最关键的是:




  1. 用户名输入框




  2. 密码输入框




  3. 点击按钮




所以可以借助油猴脚本,在DOMContentLoaded的时候,插入一下代码,来实现自动登录,减少手动操作的过程,大概原理如下。


结构图.jpg


// ==UserScript==
// @name SSO自动登录
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://*.xxx.com/login* // 这里是SSO登录页面地址,表示只有符合这个规则的才注入这段代码
// @grant none
// ==/UserScript==

document.querySelector('#username').value = 'xxx' // 用户名
document.querySelector('#password').value = 'yyy' // 密码
document.querySelector('#login-submit').click() // 自动提交登录

是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


是不是太简单了,简单到令人发指,令人痛恨,令人想吐口水!!!,没有一点技术含量


image.png


是的,就这 😄,第一次帮小姐姐解决了困扰她许久的问题,晚上就请我吃了麻辣烫,还夸我"技术"好(此处不是开车


试试效果


gif中前半部分没有开启自动登录的脚本需要手动登录,后半部开启了就可以自动登录了。


autoLogin.gif


拦截fetch请求,只留你想要的页面


问题是什么?


前端常见的调试方式



  1. chrome inspect

  2. vconsole

  3. weinre

  4. 等等


这些方式都有各自的优缺点,比如chrome inspect第一次需要翻墙才能使用,只适用于安卓; vconsole不方便直接调试样式; weinre只适用于调试样式等。


基于这些原因,公司很久之前搞了一个远程调试工具,可以很方便的增删DOM结构、调试样式、查看请求、查看application 修改后手机上立即生效。


autoLogin2.gif


远程调试平台使用流程


他的使用流程大概是这样的




  1. 打开远程调试页面列表


    此页面包含测试环境所有人打开的调试页面链接, 多的时候有上百个




image.png



  1. 点击你要调试的页面,就可以进入像chrome控制台一样调试了


image.png


看完流程你应该大概知道问题在哪里了, 远程调试页面列表不仅仅包含我自己的页面,还包括很多其他人的,导致很难快速找到自己想要调试的页面


该如何解决?


问题解析


有什么办法能让我快速找到自己想要调试的页面呢?其实观察解析这个页面会发现列表是



  1. 通过发送一个请求获取的

  2. 响应中包含设备关键字


image.png


拦截请求


所以聪明的你已经猜到了,我们可以通过Object.defineProperty拦截fetch请求,过滤设备让列表中只存在我们指定的设备(毕竟平时开发时调试的设备基本是固定的,而设备完全相同的概率是很低的,所以指定了设备其实就是唯一标识了自己)页面。


具体如何做呢?



// ==UserScript==
// @name 前端远程调试设备过滤
// @namespace http://tampermonkey.net/
// @version 0.1
// @description try to take over the world!
// @author You
// @match https://chii-fe.xxx.com/ // 指定脚本生效的页面
// @grant none
// @run-at document-start // 注意这里,脚本注入的时机是document-start
// ==/UserScript==

;(() => {
const replaceRe = /\s*/g
// 在这里设置设备白名单
const DEVICE_WHITE_LIST = [
'Xiaomi MI 8',
'iPhone9,2',
].map(
(it) => it.replace(replaceRe, '').toLowerCase())

const originFetch = window.fetch
const recordListUrl = 'record-list'
const filterData = (source) => {
// 数据过滤,返回DEVICE_WHITE_LIST指定的设备的数据
// 详细过程省略
return data
}
// 拦截fetch请求
Object.defineProperty(window, 'fetch', {
configurable:
true,
enumerable:
true,
get () {
return function (url, options) {
return originFetch(url, options).then((response) => {
// 只处理指定的url
if (url.includes(recordListUrl)) {
if (response.clone) {
const cloneRes = response.clone()

return new Promise((resolve, reject) => {
resolve(
{
text: (
) => {
return cloneRes.json().then(json => {
return filterData(JSON.stringify(json))
}
);
}
}
)
}
)
}
}

return response
}
)
}
}
}
)
}
)()


试试效果


通过下图可以看出,过滤前有37个页面,过滤后只剩3个,瞬间就找到你要调试页面,再也不用从几百个页面中寻找你自己的那个啦!


image.png


助力全公司45+前端开发 - chrome插件的始与终



通过插件一键设置ua,模拟用户登录状态,提高开发效率。



先看结果


插件使用方式


new.gif


插件使用结果



团队48+小伙伴也使用起来了



image.png


image.png


背景和问题



日常c端业务中有很多场景都需要用户登录后才能正常进行,而开发阶段基本都是通过chrome模拟手机设备来开发,所以往往会涉及到在chrome浏览器中模拟用户登录,其涉及以下三步(这个步骤比较繁琐)。



备注:保持用户的登录态一般是通过cookie,但也有通过header来做,比如我们公司是改写ua来做的



  1. 获取ua: 前往公司UA生成平台输入手机号生成ua

  2. 添加ua: 将ua复制到chrome devtool设置/修改device

  3. 使用ua: 选择新添加的ua,刷新页面,重新开发调试


ua.gif


来看一段对话



隔壁98年刚毕业妹子:



又过期了,谁又把我挤下去了嘛


好的,稍等一会哈,我换个账号测测


好麻烦哎!模拟一个用户信息,要这么多步骤,好烦呀!!!



我,好奇的大叔:



“细心”了解下,她正在做一个h5活动项目,场景复杂,涉及的状态很多,需要用不同的账号来做测试。


模拟一两个用户还好,但是此刻小姐姐测这么多场景,已经模拟了好多个(谁都会烦啊)


公司的登录体系是单点登录,一个好不容易模拟的账号,有可能别人也在用,结果又被顶掉了,得重新生成,我TM


看着她快气哭的小眼神,作为隔壁桌友好的邻居,此刻我心里只想着一件事...!帮她解决这个恼人的问题。


分析和解决问题



通过上面的介绍您应该可以感觉到我们开发阶段遇到需要频繁切换账号做测试时的烦恼,相对繁琐的ua生成过程导致了它一定是个费时费力的麻烦事。



有没有什么办法让我们的开发效率得到提升,别浪费在这种事情上呢?一起一步步做起来


需求有哪些



提供一种便捷地模拟ua的方式,助力开发效率提升。




  1. 基本诉求:本地开发阶段,希望有更便捷的方式来模拟用户登录

  2. 多账号: 一个项目需要多个账号,不同项目间的账号可以共享也可以不同

  3. 指定域: 只有指定的下才需要模拟ua,不能影响浏览器正常使用

  4. 过期处理: 账号过期后,可以主动生成,无需手动重新获取


如何解决




  1. 需求1:结合前面生成ua阶段,我们可以通过某种方式让用户能直接在当前页面生成ua,无需跳出,一键设置省略手动过程




  2. 需求2:提供多账号管理功能,能直接选中切换ua




  3. 需求3:限定指定域,该ua才生效




  4. 需求4:当使用到过期账号时,可一键重新生成即可




为什么是chrome插件




  1. 浏览器中发送ajax请求的ua无法直接修改,但是chrome插件可以修改请求的ua(很重要的一点




  2. chrome插件popup模式可直接在当前页面打开,无需跳出开发页面,减少跳出过程




用vue从零开始写一个chrome插件



篇幅原因,这里只做示例级别的简单介绍,如果您希望详细了解chrome插件的编写可以参考这里



从一个小例子开始



接下来我们会以下页面为例,说明用vue如何写出来。



ua3.gif


基本功能




  1. 底部tab切换区域viewAviewBviewC




  2. 中间内容区域:切换viewA、B、C分别展示对应的页面




content部分


借助chrome浏览器可以向网页插入脚本的特性,我们会演示如何插入脚本并且在网页加载的时候弹一个hello world


popup与background通信部分


popup完成用户的主要交互,在viewA页面点击获取自定义的ua信息


修改ajax请求ua部分


会演示如果通过chrome插件修改请求header


1. 了解一个chrome插件的构成



  1. manifest.json

  2. background script

  3. content script

  4. popup


1. manifest.json



几乎所有的东西都要在这里进行声明、权限资源页面等等




{
"manifest_version": 2, // 清单文件的版本,这个必须写
"name": "hello vue extend", // 插件的名称,等会我们写的插件名字就叫hello vue extend
"description": "hello vue extend", // 插件描述
"version": "0.0.1", // 插件的版本
// 图标,写一个也行
"icons": {
"48": "img/logo.png"
},
// 浏览器右上角图标设置,browser_action、page_action、app必须三选一
"browser_action": {
"default_icon": "img/logo.png",
"default_title": "hello vue extend",
"default_popup": "popup.html"
},
// 一些常驻的后台JS或后台页面
"background": {
"scripts": [
"js/hot-reload.js",
"js/background.js"
]
},
// 需要直接注入页面的JS
"content_scripts": [{
"matches": [""],
"js": ["js/content.js"],
"run_at": "document_start"
}],
// devtools页面入口,注意只能指向一个HTML文件
"devtools_page": "devcreate.html",
// Chrome40以前的插件配置页写法
"options_page": "options.html",
// 权限申请
"permissions": [
"storage",
"webRequest",
"tabs",
"webRequestBlocking",
""
]
}

2. background script



后台,可以认为是一个常驻的页面,权限很高,几乎可以调用所有的API,可以与popup、content script等通信



3. content script



chrome插件向页面注入脚本的一种形式(js和css都可以)



4. popup



popup是点击browser_action或者page_action图标时打开的一个小窗口网页,焦点离开网页就立即关闭。



比如我们要用vue做的页面。


image.png


2. 改写vue.config.js



manifest.json对文件引用的结构基本决定了打包后的文件路径



打包后的路径


// dist目录用来chrome扩展导入

├── dist
│ ├── favicon.ico
│ ├── img
│ │ └── logo.png
│ ├── js
│ │ ├── background.js
│ │ ├── chunk-vendors.js
│ │ ├── content.js
│ │ ├── hot-reload.js
│ │ └── popup.js
│ ├── manifest.json
│ └── popup.html


源码目录



├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ └── js
│ └── hot-reload.js
├── src
│ ├── assets
│ │ ├── 01.png
│ │ ├── disabled.png
│ │ └── logo.png
│ ├── background
│ │ └── background.js
│ ├── content
│ │ └── content.js
│ ├── manifest.json
│ ├── popup
│ │ ├── App.vue
│ │ ├── main.js
│ │ ├── router.js
│ │ └── views
│ │ ├── viewA.vue
│ │ ├── viewB.vue
│ │ └── viewC.vue
│ └── utils
│ ├── base.js
│ ├── fixCaton.js
│ └── storage.js
└── vue.config.js



修改vue.config.js



主需要稍微改造变成可以多页打包,注意输出的目录结构就可以了




const CopyWebpackPlugin = require('copy-webpack-plugin')
const path = require('path')
// 这里考虑可以添加多页
const pagesObj = {}
const chromeName = ['popup']
const plugins = [
{
from: path.resolve('src/manifest.json'),
to: `${path.resolve('dist')}/manifest.json`
},
{
from: path.resolve('src/assets/logo.png'),
to: `${path.resolve('dist')}/img/logo.png`
},
{
from: path.resolve('src/background/background.js'),
to: `${path.resolve('dist')}/js/background.js`
},
{
from: path.resolve('src/content/content.js'),
to: `${path.resolve('dist')}/js/content.js`
},
]

chromeName.forEach(name => {
pagesObj[name] = {
css: {
loaderOptions: {
less: {
modifyVars: {},
javascriptEnabled: true
}
}
},
entry: `src/${name}/main.js`,
filename: `${name}.html`
}
})

const vueConfig = {
lintOnSave:false, //关闭eslint检查
pages: pagesObj,
configureWebpack: {
entry: {},
output: {
filename: 'js/[name].js'
},
plugins: [new CopyWebpackPlugin(plugins)]
},
filenameHashing: false,
productionSourceMap: false
}

module.exports = vueConfig



3. 热刷新



我们希望修改插件源代码进行打包之后,chrome插件对应的页面能主动更新。为什么叫热刷新而不是热更新呢?因为它其实是全局刷新页面,并不会保存状态。



这里推荐一个github上的解决方案crx-hotreload


4. 完成小例子编写


new.gif


文件目录结构



├── popup
│ ├── App.vue
│ ├── main.js
│ ├── router.js
│ └── views
│ ├── viewA.vue
│ ├── viewB.vue
│ └── viewC.vue



main.js



import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
router,
render: h => h(App)
}).$mount('#app')



router.js


import Vue from 'vue'
import Router from 'vue-router'

import ViewA from './views/viewA.vue'
import ViewB from './views/viewB.vue'
import ViewC from './views/viewC.vue'

Vue.use(Router)

export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
redirect: '/view/a'
},
{
path: '/view/a',
name: 'viewA',
component: ViewA,
},
{
path: '/view/b',
name: 'viewB',
component: ViewB,
},
{
path: '/view/c',
name: 'viewC',
component: ViewC,
},
]
})

App.vue









viewA、viewB、viewC



三个页面基本长得是一样的,只有背景色和文案内容不一样,这里我就只贴viewA的代码了。



需要注意的是这里会演示popup与background,通过sendMessage方法获取background后台数据










background.js


const customUa = 'hello world ua'
// 请求发送前拦截
const onBeforeSendCallback = (details) => {
for (var i = 0; i < details.requestHeaders.length; ++i) {
if (details.requestHeaders[i].name === 'User-Agent') {
details.requestHeaders.splice(i, 1);
break;
}
}
// 修改请求UA为hello world ua
details.requestHeaders.push({
name: 'User-Agent',
value: customUa
});

return { requestHeaders: details.requestHeaders };
}

// 前面的sendMessage获取getCustomUserAgent,会被这里监听
const onRuntimeMessageListener = () => {
chrome.runtime.onMessage.addListener(function (msg, sender, callback) {
if (msg.type === 'getCustomUserAgent') {
callback({
customUa
});
}
});
}

const init = () => {
onRuntimeMessageListener()
onBeforeSendHeadersListener()
}

init()


content.js



演示如何往网页中插入代码




function setScript({ code = '', needRemove = true } = params) {
let textNode = document.createTextNode(code)
let script = document.createElement('script')

script.appendChild(textNode)
script.remove()

let parentNode = document.head || document.documentElement

parentNode.appendChild(script)
needRemove && parentNode.removeChild(script)
}

setScript({
code: `alert ('hello world')`,
})

ua3.gif


关于一键设置ua插件



大体上和小例子差不都,只是功能相对复杂一些,会涉及到





  1. 数据本地存储chrome.storage.sync.get|setchrome.tabs.query等API




  2. popup与background通信、content与background通信




  3. 拦截请求修改UA




  4. 其他的大体就是常规的vue代码编写啦!




这里就不贴详细的代码实现了。



链接:https://juejin.cn/post/7001998089938534437

收起阅读 »

跨浏览器窗口通讯 ,7种方式,你还知道几种呢?

前言 为什么会扯到这个话题,最初是源于听 y.qq.com/ QQ音乐, 播放器处于单独的一个页面 当你在另外的一个页面搜索到你满意的歌曲的时候,点击播放或添加到播放队列 你会发现,播放器页面做出了响应的响应 这里我又联想到了商城的购物车的场景,体验确实有...
继续阅读 »

前言


为什么会扯到这个话题,最初是源于听 y.qq.com/ QQ音乐,



  • 播放器处于单独的一个页面

  • 当你在另外的一个页面搜索到你满意的歌曲的时候,点击播放或添加到播放队列

  • 你会发现,播放器页面做出了响应的响应


这里我又联想到了商城的购物车的场景,体验确实有提升。

刚开始,我怀疑的是Web Socket作妖,结果通过分析网络请求和看源码,并没有。 最后发现是localStore的storage事件作妖,哈哈。




回归正题,其实在一般正常的知识储备的情况下,我们会想到哪些方案呢?


先抛开如下方式:



  1. 各自对服务器进行轮询或者长轮询

  2. 同源策略下,一方是另一方的 opener


演示和源码


多页面通讯的demo, 为了正常运行,请用最新的chrome浏览器打开。

demo的源码地址



两个浏览器窗口间通信


WebSocket


这个没有太多解释,WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。当然是有代价的,需要服务器来支持。

js语言,现在比较成熟稳定当然是 socket.iows. 也还有轻量级的ClusterWS


你可以在The WebSocket API (WebSockets)
看到更多的关于Web Socket的信息。


定时器 + 客户端存储


定时器:setTimeout/setInterval/requestAnimationFrame

客户端存储: cookie/localStorage/sessionStorage/indexDB/chrome的FileSystem


定时器没啥好说的,关于客户端存储。



  • cookie: 每次会带到服务端,并且能存的并不大,4kb?,记得不太清楚

  • localStorage/sessionStorage 应该是5MB, sessionStorage关闭浏览器就和你说拜拜。

  • indexDB 这玩意就强大了,不过读取都是异步的,还能存 Blob文件,真的是很high。

  • chrome的FileSystem ,Filesystem & FileWriter API,主要是chrome和opera支持。这玩意就是文件系统。


postMessage


Cross-document messaging 这玩意的支持率98.9%。 好像还能发送文件,哈哈,强大。

不过仔细一看 window.postMessage(),就注定了你首先得拿到window这个对象。 也注定他使用的限制, 两个窗体必须建立起联系。 常见建立联系的方式:



  • window.open

  • window.opener

  • iframe


提到上面的window.open, open后你能获得被打开窗体的句柄,当然也可以直接操作窗体了。




到这里,我觉得一般的前端人员能想到的比较正经的方案应该是上面三种啦。

当然,我们接下来说说可能不是那么常见的另外三种方式。


StorageEvent


Page 1


localStorage.setItem('message',JSON.stringify({
message: '消息',
from: 'Page 1',
date: Date.now()
}))

Page 2


window.addEventListener("storage", function(e) {
console.log(e.key, e.newValue, e.oldValue)
});

如上, Page 1设置消息, Page 2注册storage事件,就能监听到数据的变化啦。


上面的e就是StorageEvent,有下面特有的属性(都是只读):



  • key :代表属性名发生变化.当被clear()方法清除之后所有属性名变为null

  • newValue:新添加进的值.当被clear()方法执行过或者键名已被删除时值为null

  • oldValue:原始值.而被clear()方法执行过,或在设置新值之前并没有设置初始值时则返回null

  • storageArea:被操作的storage对象

  • url:key发生改变的对象所在文档的URL地址


Broadcast Channel


这玩意主要就是给多窗口用的,Service Woker也可以使用。 firefox,chrome, Opera均支持,有时候真的是很讨厌Safari,浏览器支持77%左右。


使用起来也很简单, 创建BroadcastChannel, 然后监听事件。 只需要注意一点,渠道名称一致就可以。

Page 1


    var channel = new BroadcastChannel("channel-BroadcastChannel");
channel.postMessage('Hello, BroadcastChannel!')

Page 2


    var channel = new BroadcastChannel("channel-BroadcastChannel");
channel.addEventListener("message", function(ev) {
console.log(ev.data)
});

SharedWorker


这是Web Worker之后出来的共享的Worker,不通页面可以共享这个Worker。

MDN这里给了一个比较完整的例子simple-shared-worker


这里来个插曲,Safari有几个版本支持这个特性,后来又不支持啦,还是你Safari,真是6。


虽然,SharedWorker本身的资源是共享的,但是要想达到多页面的互相通讯,那还是要做一些手脚的。
先看看MDN给出的例子的ShareWoker本身的代码:


onconnect = function(e) {
var port = e.ports[0];

port.onmessage = function(e) {
var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
port.postMessage(workerResult);
}

}

上面的代码其实很简单,port是关键,这个port就是和各个页面通讯的主宰者,既然SharedWorker资源是共享的,那好办,把port存起来就是啦。

看一下,如下改造的代码:

SharedWorker就成为一个纯粹的订阅发布者啦,哈哈。


var portList = [];

onconnect = function(e) {
var port = e.ports[0];
ensurePorts(port);
port.onmessage = function(e) {
var data = e.data;
disptach(port, data);
};
port.start();
};

function ensurePorts(port) {
if (portList.indexOf(port) < 0) {
portList.push(port);
}
}

function disptach(selfPort, data) {
portList
.filter(port => selfPort !== port)
.forEach(port => port.postMessage(data));
}


MessageChannel


Channel Messaging API的 MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个MessagePort 属性发送数据。


其需要先通过 postMessage先建立联系。


MessageChannel的基本使用:


var channel = new MessageChannel();
var para = document.querySelector('p');

var ifr = document.querySelector('iframe');
var otherWindow = ifr.contentWindow;

ifr.addEventListener("load", iframeLoaded, false);

function iframeLoaded() {
otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);
}

channel.port1.onmessage = handleMessage;
function handleMessage(e) {
para.innerHTML = e.data;
}

至于在线的例子,MDN官方有一个版本 MessageChannel 通讯



链接:https://juejin.cn/post/7002012595200720927

收起阅读 »

更新需要提示用户,需要控制应用是否更新

更新需要提示用户,需要控制应用是否更新1. 方案一在检测到更新后提示用户,让用户选择更新。设置autoDownload参数为false,让应用检测到更新不自动下载,改成手动下载更新包。通过在钩子update-available中,加入对话框提示用户,让用户选择...
继续阅读 »

更新需要提示用户,需要控制应用是否更新

1. 方案一

在检测到更新后提示用户,让用户选择更新。

设置autoDownload参数为false,让应用检测到更新不自动下载,改成手动下载更新包。

通过在钩子update-available中,加入对话框提示用户,让用户选择。

response为0用户选择确定,触发downloadUpdate方法下载应用更新包进行后续更新操作。否则,不下载更新包。

如果我们不配置autoDownload为false,那么问题来了:在弹出对话框的同时,用户还来不及选择,应用自动下载并且更新完成,做不到阻塞。

本文首发于公众号「全栈大佬的修炼之路」,欢迎关注。

重要代码如下:

autoUpdater.autoDownload = false

update-available钩子中弹出对话框

autoUpdater.on('update-available', (ev, info) => {
// // 不可逆过程
const options = {
type: 'info',
buttons: ['确定', '取消'],
title: '更新提示',
// ${info.version} Cannot read property 'version' of undefined
message: '发现有新版本,是否更新?',
cancelId: 1
}
dialog.showMessageBox(options).then(res => {
if (res.response === 0) {
autoUpdater.downloadUpdate()
logger.info('下载更新包成功')
sendStatusToWindow('下载更新包成功');
} else {
return;
}
})
})

2. 方案二

在更新下载完后提示用户,让用户选择更新。

先配置参数autoInstallOnAppQuit为false,阻止应用在检测到更新包后自动更新。

在钩子update-downloaded中加入对话框提示用户,让用户选择。

response为0用户选择确定,更新应用。否则,当前应用不更新。

如果我们不配置autoInstallOnAppQuit为false,那么问题是:虽然第一次应用不更新,但是第二次打开应用,应用马上关闭,还没让我们看到主界面,应用暗自更新,重点是更新完后不重启应用。

重要代码如下:

// 表示下载包不自动更新
autoUpdater.autoInstallOnAppQuit = false
在update-downloaded钩子中弹出对话框
autoUpdater.on('update-downloaded', (ev, releaseNotes, releaseName) => {
logger.info('下载完成,更新开始')
sendStatusToWindow('下载完成,更新开始');
// Wait 5 seconds, then quit and install
// In your application, you don't need to wait 5 seconds.
// You could call autoUpdater.quitAndInstall(); immediately
const options = {
type: 'info',
buttons: ['确定', '取消'],
title: '应用更新',
message: process.platform === 'win32' ? releaseNotes : releaseName,
detail: '发现有新版本,是否更新?'
}
dialog.showMessageBox(options).then(returnVal => {
if (returnVal.response === 0) {
logger.info('开始更新')
setTimeout(() => {
autoUpdater.quitAndInstall()
}, 5000);
} else {
logger.info('取消更新')
return
}
})
});

3. 源码分析

未打包目录位于: electron-builder/packages/electron-updater/src/AppUpdater.ts中。 打包后在electron-updater\out\AppUpdater.d.ts中

  1. 首先进入checkForUpdates()方法,开始检测更新
  2. 正在更新不需要进入
  3. 开始更新前判断autoDownload,为true自动下载,为false不下载等待应用通知。
export declare abstract class AppUpdater extends EventEmitter {
/**
* 当被发现有更新时,是否要自动下载更新
* 场景:可以适用于electron检查更新包提示,用户操作是否需要更新
*/
autoDownload: boolean;
/**
* 在app.quit()后,是否自动将下载下载的更新包更新
* 场景:可以适用于electron下载完更新包提示,用户操作是否需要更新。在第二次打开应用,应用不会自动更新。
*/
autoInstallOnAppQuit: boolean;
}


/**
* 检测是否需要更新
*/
checkForUpdates(): Promise < UpdateCheckResult > {
let checkForUpdatesPromise = this.checkForUpdatesPromise
// 正在检测更新跳过
if (checkForUpdatesPromise != null) {
this._logger.info("Checking for update (already in progress)")
return checkForUpdatesPromise
}

const nullizePromise = () => this.checkForUpdatesPromise = null
// 开始检测更新
this._logger.info("Checking for update")
checkForUpdatesPromise = this.doCheckForUpdates()
.then(it => {
nullizePromise()
return it
})
.catch(e => {
nullizePromise()
this.emit("error", e, `Cannot check for updates: ${(e.stack || e).toString()}`)
throw e
})

this.checkForUpdatesPromise = checkForUpdatesPromise
return checkForUpdatesPromise
}
// 检测更新具体函数
private async doCheckForUpdates(): Promise < UpdateCheckResult > {
// 触发 checking-for-update 钩子
this.emit("checking-for-update")
// 取更新信息
const result = await this.getUpdateInfoAndProvider()
const updateInfo = result.info
// 判断更新信息是否有效
if (!await this.isUpdateAvailable(updateInfo)) {
this._logger.info(`Update for version ${this.currentVersion} is not available (latest version: ${updateInfo.version}, downgrade is ${this.allowDowngrade ? "allowed" : "disallowed"}).`)
this.emit("update-not-available", updateInfo)
return {
versionInfo: updateInfo,
updateInfo,
}
}

this.updateInfoAndProvider = result
this.onUpdateAvailable(updateInfo)

const cancellationToken = new CancellationToken()
//noinspection ES6MissingAwait
// 如果设置autoDownload为true,则开始自动下载更新包,否则不下载
return {
versionInfo: updateInfo,
updateInfo,
cancellationToken,
downloadPromise: this.autoDownload ? this.downloadUpdate(cancellationToken) : null
}
}

如果需要配置updater中的其他参数达到某种功能,我们可以仔细查看其中的配置项。

export abstract class AppUpdater extends EventEmitter {
/**
* 当被发现有更新时,是否要自动下载更新
* 场景:可以适用于electron检查更新包提示,用户操作是否需要更新
*/
autoDownload: boolean;
/**
* 在app.quit()后,是否自动将下载下载的更新包更新
* 场景:可以适用于electron下载完更新包提示,用户操作是否需要更新。在第二次打开应用,应用不会自动更新。
*/
autoInstallOnAppQuit: boolean;
/**
* GitHub提供者。
是否允许升级到预发布版本。
如果应用程序版本包含预发布组件,默认为“true”。0.12.1-alpha.1,这里alpha是预发布组件),否则“false”。
allowDowngrade设置为true,则应用允许降级。
*/
allowPrerelease: boolean;
/**
* GitHub提供者。
获取所有发布说明(从当前版本到最新版本),而不仅仅是最新版本。
@default false
*/
fullChangelog: boolean;
/**
*是否允许版本降级(当用户从测试通道想要回到稳定通道时)。
*仅当渠道不同时考虑(根据语义版本控制的预发布版本组件)。
* @default false
*/
allowDowngrade: boolean;
/**
* 当前应用的版本
*/
readonly currentVersion: SemVer;
private _channel;
protected downloadedUpdateHelper: DownloadedUpdateHelper | null;
/**
* 获取更新通道。
不适用于GitHub。
从更新配置不返回“channel”,仅在之前设置的情况下。
*/
get channel(): string | null;
/**
* 设置更新通道。
不适用于GitHub。
覆盖更新配置中的“channel”。
“allowDowngrade”将自动设置为“true”。
如果这个行为不适合你,明确后简单设置“allowDowngrade”。
*/
set channel(value: string | null);
/**
* 请求头
*/
requestHeaders: OutgoingHttpHeaders | null;
protected _logger: Logger;
get netSession(): Session;
/**
* The logger. You can pass [electron-log](https://github.com/megahertz/electron-log), [winston](https://github.com/winstonjs/winston) or another logger with the following interface: `{ info(), warn(), error() }`.
* Set it to `null` if you would like to disable a logging feature.
* 日志,类型有:info、warn、error
*/
get logger(): Logger | null;
set logger(value: Logger | null);
/**
* For type safety you can use signals, e.g.
为了类型安全,可以使用signals。
例如:
`autoUpdater.signals.updateDownloaded(() => {})` instead of `autoUpdater.on('update-available', () => {})`
*/
readonly signals: UpdaterSignal;
private _appUpdateConfigPath;
/**
* test only
* @private
*/
set updateConfigPath(value: string | null);
private clientPromise;
protected readonly stagingUserIdPromise: Lazy<string>;
private checkForUpdatesPromise;
protected readonly app: AppAdapter;
protected updateInfoAndProvider: UpdateInfoAndProvider | null;
protected constructor(
options: AllPublishOptions | null | undefined,
app?: AppAdapter
);
/**
* 获取当前更新的url
*/
getFeedURL(): string | null | undefined;
/**
* Configure update provider. If value is `string`, [GenericServerOptions](/configuration/publish#genericserveroptions) will be set with value as `url`.
* @param options If you want to override configuration in the `app-update.yml`.
*
* 配置更新提供者。通过提供url
* @param options 如果你想覆盖' app-update.yml '中的配置。
*/
setFeedURL(options: PublishConfiguration | AllPublishOptions | string): void;
/**
* 检查服务其是否有更新
*/
checkForUpdates(): Promise<UpdateCheckResult>;
isUpdaterActive(): boolean;
/**
*
* @param downloadNotification 询问服务器是否有更新,下载并通知更新是否可用
*/
checkForUpdatesAndNotify(
downloadNotification?: DownloadNotification
): Promise<UpdateCheckResult | null>;
private static formatDownloadNotification;
private isStagingMatch;
private computeFinalHeaders;
private isUpdateAvailable;
protected getUpdateInfoAndProvider(): Promise<UpdateInfoAndProvider>;
private createProviderRuntimeOptions;
private doCheckForUpdates;
protected onUpdateAvailable(updateInfo: UpdateInfo): void;
/**
*
* 作用:开始下载更新包
*
* 如果将`autoDownload`选项设置为false,就可以使用这个方法。
*
* @returns {Promise<string>} Path to downloaded file.
*/
downloadUpdate(cancellationToken?: CancellationToken): Promise<any>;
protected dispatchError(e: Error): void;
protected dispatchUpdateDownloaded(event: UpdateDownloadedEvent): void;
protected abstract doDownloadUpdate(
downloadUpdateOptions: DownloadUpdateOptions
): Promise<Array<string>>;
/**
* 作用:下载后重新启动应用程序并安装更新。
*只有在' update- downloads '被触发后才会调用。
*
* 注意:如果在update-downloaded钩子中,让用户选择是否更新应用,选择不更新,那就是没有执行autoUpdater.quitAndInstall()方法。
* 虽然应用没有更新,但是当第二次打开应用的时候,应用检测到本地有更新包,他就会直接更新,最后不会重启更新后的应用。
*
* 为了解决这个问题,需要设置`autoInstallOnAppQuit`为false。关闭应用自动更新。
*
* **Note:** ' autoUpdater.quitAndInstall() '将首先关闭所有的应用程序窗口,然后只在' app '上发出' before-quit '事件。
*这与正常的退出事件序列不同。
*
* @param isSilent 仅Windows以静默模式运行安装程序。默认为false。
* @param isForceRunAfter 即使无提示安装也可以在完成后运行应用程序。不适用于macOS。忽略是否isSilent设置为false。
*/
abstract quitAndInstall(isSilent?: boolean, isForceRunAfter?: boolean): void;
private loadUpdateConfig;
private computeRequestHeaders;
private getOrCreateStagingUserId;
private getOrCreateDownloadHelper;
protected executeDownload(
taskOptions: DownloadExecutorTask
): Promise<Array<string>>;
}

最后,希望大家一定要点赞三连。


链接:https://juejin.cn/post/7001682043104919565

收起阅读 »

iOS Reveal

iOS Reveal一、概述Reveal是一款UI调试神器,对iOS开发非常有帮助。这里以版本Version 4 (8796)演示二、安装2.1 Mac端安装Reveal官网直接下载安装,可以用试用版。2.2 手机端安装1.cydia直接安装Reveal Lo...
继续阅读 »

iOS Reveal


一、概述

Reveal是一款UI调试神器,对iOS开发非常有帮助。这里以版本Version 4 (8796)演示

二、安装

2.1 Mac端安装

Reveal官网直接下载安装,可以用试用版。

2.2 手机端安装

1.cydia直接安装Reveal Loader插件





打开手机"设置->Reveal-> Enabled Applications"打开需要分析的App


  1. 我这里打开微信

2.3 配置环境

  1. 打开电脑端的Reveal->help->Show Reveal Library in Finder




  1. RevealServer保存到手机中
    进入到Library/目录:

zaizai:~ root# cd /Library/
zaizai:/Library root#

创建RHRevealLoader目录:

zaizai:/Library root# mkdir RHRevealLoader
zaizai:/Library root# cd RHRevealLoader/
zaizai:/Library/RHRevealLoader root# pwd
/Library/RHRevealLoader

RevealServer拷贝到该目录下:

scp -P 12345 RevealServer root@localhost://Library/RHRevealLoader/libReveal.dylib

需要改名为libReveal.dylib


手机端确认:

zaizai:/Library/RHRevealLoader root# ls
libReveal.dylib*

3.重启SpringBoardkill SpringBoard

zaizai:~ root# ps -A | grep SpringBoard
20973 ?? 4:01.57 /System/Library/CoreServices/SpringBoard.app/SpringBoard
23213 ttys000 0:00.01 grep SpringBoard
zaizai:~ root# kill 20973
zaizai:~ root#

2.4 调试微信

重新打开电脑端Reveal和微信,这个时候微信就出现了:



发现页面中微信钱包金额是每一位都是一个UILabel。。。

修改下LabelText




这样余额就改了。并且Revealcycript一样不会阻塞进程。

总结

    1. iOS安装插件
    1. Mac安装App
    1. 动态库导入iPhone

作者:HotPotCat
链接:https://www.jianshu.com/p/ca0a4b73a986


收起阅读 »

objc_msgSend 消息快速查找(cache查找)

一、CacheLookup 查找缓存1.1 CacheLookup源码分析传递的参数是NORMAL, _objc_msgSend, __objc_msgSend_uncached://NORMAL, _objc_msgSend, __objc_msgSend_...
继续阅读 »

一、CacheLookup 查找缓存

1.1 CacheLookup源码分析

传递的参数是NORMAL, _objc_msgSend, __objc_msgSend_uncached

//NORMAL, _objc_msgSend, __objc_msgSend_uncached
.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant
// requirements:
// //缓存不存在返回NULL,x0设置为0
// GETIMP:
// The cache-miss is just returning NULL (setting x0 to 0)
// 参数说明
// NORMAL and LOOKUP:
// - x0 contains the receiver
// - x1 contains the selector
// - x16 contains the isa
// - other registers are set as per calling conventions
//
//调用过来的p16存储的是cls,将cls存储在x15.
mov x15, x16 // stash the original isa
//_objc_msgSend
LLookupStart\Function:
// p1 = SEL, p16 = isa
//arm64 64 OSX/SIMULATOR
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
//isa->cache,首地址也就是_bucketsAndMaybeMask
ldr p10, [x16, #CACHE] // p10 = mask|buckets
//lsr逻辑右移 p11 = _bucketsAndMaybeMask >> 48 也就是 mask
lsr p11, p10, #48 // p11 = mask
//p10 = _bucketsAndMaybeMask & 0xffffffffffff = buckets(保留后48位)
and p10, p10, #0xffffffffffff // p10 = buckets
//x12 = cmd & mask w1为第二个参数cmd(self,cmd...),w11也就是p11 也就是执行cache_hash。这里没有>>7位的操作
and w12, w1, w11 // x12 = _cmd & mask
//arm64 64 真机这里p11计算后是_bucketsAndMaybeMask
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
ldr p11, [x16, #CACHE] // p11 = mask|buckets
//arm64 + iOS + !模拟器 + 非mac应用
#if CONFIG_USE_PREOPT_CACHES
//iphone 12以后指针验证
#if __has_feature(ptrauth_calls)
//tbnz 测试位不为0则跳转。与tbz对应。 p11 第0位不为0则跳转 LLookupPreopt\Function。
tbnz p11, #0, LLookupPreopt\Function
//p10 = _bucketsAndMaybeMask & 0x0000ffffffffffff = buckets
and p10, p11, #0x0000ffffffffffff // p10 = buckets
#else
//p10 = _bucketsAndMaybeMask & 0x0000fffffffffffe = buckets
and p10, p11, #0x0000fffffffffffe // p10 = buckets
//p11 第0位不为0则跳转 LLookupPreopt\Function。
tbnz p11, #0, LLookupPreopt\Function
#endif
//eor 逻辑异或(^) 格式为:EOR{S}{cond} Rd, Rn, Operand2
//p12 = selector ^ (selector >> 7) select 右移7位&自己给到p12
eor p12, p1, p1, LSR #7
//p12 = p12 & (_bucketsAndMaybeMask >> 48) = index & mask值 = buckets中的下标
and p12, p12, p11, LSR #48 // x12 = (_cmd ^ (_cmd >> 7)) & mask
#else
//p10 = _bucketsAndMaybeMask & 0x0000ffffffffffff = buckets
and p10, p11, #0x0000ffffffffffff // p10 = buckets
//p12 = selector & (_bucketsAndMaybeMask >>48) = sel & mask = buckets中的下标
and p12, p1, p11, LSR #48 // x12 = _cmd & mask
#endif // CONFIG_USE_PREOPT_CACHES
//arm64 32
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
//后4位为mask前置0的个数的case
ldr p11, [x16, #CACHE] // p11 = mask|buckets
and p10, p11, #~0xf // p10 = buckets 相当于后4位置为0,取前32位
and p11, p11, #0xf // p11 = maskShift 取的是后4位,为mask前置位的0的个数
mov p12, #0xffff
lsr p11, p12, p11 // p11 = mask = 0xffff >> p11
and p12, p1, p11 // x12 = _cmd & mask
#else
#error Unsupported cache mask storage for ARM64.
#endif
//通过上面的计算 p10 = buckets,p11 = mask(arm64真机是_bucketsAndMaybeMask), p12 = index
// p13(bucket_t) = buckets + 下标 << 4 PTRSHIFT arm64 为3. <<4 位为16字节 buckets + 下标 *16 = buckets + index *16 也就是直接平移到了第几个元素的地址。
add p13, p10, p12, LSL #(1+PTRSHIFT)
// p13 = buckets + ((_cmd & mask) << (1+PTRSHIFT))
//这里就直接遍历查找了,因为arm64下cache_next相当于遍历(这里只扫描了前面)
// do {
//p17 = imp, p9 = sel
1: ldp p17, p9, [x13], #-BUCKET_SIZE // {imp, sel} = *bucket--
//sel - _cmd != 0 则跳转 3:,也就意味着没有找到就跳转到__objc_msgSend_uncached
cmp p9, p1 // if (sel != _cmd) {
b.ne 3f // scan more
// } else {
//找到则调用或者返回imp,Mode为 NORMAL
2: CacheHit \Mode // hit: call or return imp 命中
// }
//__objc_msgSend_uncached
//缓存中找不到方法就走__objc_msgSend_uncached逻辑了。
//cbz 为0跳转 sel == nil 跳转 \MissLabelDynamic
3: cbz p9, \MissLabelDynamic // if (sel == 0) goto Miss; 有空位没有找到说明没有缓存
//bucket_t - buckets 由于是递减操作
cmp p13, p10 // } while (bucket >= buckets) //⚠️ 这里一直是往前找,后面的元素在后面还有一次循环。
//无符号大于等于 则跳转1:f b 分别代表front与back
b.hs 1b

//没有命中cache 查找 p13 = mask对应的元素,也就是倒数第二个
#if CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16_BIG_ADDRS
//p13 = buckets + (mask << 4) 平移找到对应mask的bucket_t。UXTW 将w11扩展为64位后左移4
add p13, p10, w11, UXTW #(1+PTRSHIFT)
// p13 = buckets + (mask << 1+PTRSHIFT)
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
//p13 = buckets + (mask >> 44) 这里右移44位,少移动4位就不用再左移了。因为maskZeroBits的存在 就找到了mask对应元素的地址
add p13, p10, p11, LSR #(48 - (1+PTRSHIFT))
// p13 = buckets + (mask << 1+PTRSHIFT)
// see comment about maskZeroBits
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_LOW_4
//p13 = buckets + (mask << 4) 找到对应mask的bucket_t。
add p13, p10, p11, LSL #(1+PTRSHIFT)
// p13 = buckets + (mask << 1+PTRSHIFT)
#else
#error Unsupported cache mask storage for ARM64.
#endif
//p12 = buckets + (p12<<4) index对应的bucket_t
add p12, p10, p12, LSL #(1+PTRSHIFT)
// p12 = first probed bucket

//之前已经往前查找过了,这里从后往index查找
// do {
//p17 = imp p9 = sel
4: ldp p17, p9, [x13], #-BUCKET_SIZE // {imp, sel} = *bucket--
//sel - _cmd
cmp p9, p1 // if (sel == _cmd)
//sel == _cmd跳转CacheHit
b.eq 2b // goto hit
//sel != nil
cmp p9, #0 // } while (sel != 0 &&
//
ccmp p13, p12, #0, ne // bucket > first_probed)
//有值跳转4:
b.hi 4b

LLookupEnd\Function:
LLookupRecover\Function:
//仍然没有找到缓存,缓存彻底不存在 __objc_msgSend_uncached()
b \MissLabelDynamic

核心逻辑:

  • 根据不同架构找到bucketssel对应的indexp10 = buckets,p11 = mask / _bucketsAndMaybeMask(arm64_64 是 _bucketsAndMaybeMask),p12 = index
    • arm64_64的情况下如果_bucketsAndMaybeMask0位为1则执行LLookupPreopt\Function
  • p13 = buckets + index << 4找到cls对应的buckets地址,地址平移找到对应bucket_t
  • do-while循环扫描buckets[index]的前半部分(后半部分逻辑不在这里)。
    • 如果存在sel为空,则说明是没有缓存的,就直接__objc_msgSend_uncached()
    • 命中直接CacheHit \Mode,这里ModeNORMAL
  • 平移获得p13 = buckets[mask]对应的元素,也就是最后一个元素(arm64下最后一个不存自身地址,也就相当于buckets[count - 1])。
  • p13 = buckets + mask << 4找到mask对应的buckets地址,地址平移找到对应bucket_t
  • do-while循环扫描buckets[mask]的前面元素,直到index(不包含index)。
    • 命中CacheHit \Mode
    • 如果存在sel为空,则说明是没有缓存的,就直接结束循环。
  • 最终仍然没有找到则执行__objc_msgSend_uncached()
  1. CACHEcache_t相对isa的偏移。 #define CACHE (2 * __SIZEOF_POINTER__)
  2. maskZeroBits始终是40p13 = buckets + (_bucketsAndMaybeMask >> 44)右移44位后就不用再<<4找到对应bucket_t的地址了。这是因为maskZeroBitsarm64_64下存在的原因。
  3. f b 分别代表frontback,往下往上的意思。

1.2 CacheLookup 伪代码实现


//NORMAL, _objc_msgSend, __objc_msgSend_uncached
void CacheLookup(Mode,Function,MissLabelDynamic,MissLabelConstant) {
//1. 根据架构不同集算sel在buckets中的index
if (arm64_64 && OSX/SIMULATOR) {
p10 = isa->cache //_bucketsAndMaybeMask
p11 = _bucketsAndMaybeMask >> 48//mask
p10 = _bucketsAndMaybeMask & 0xffffffffffff//buckets
x12 = sel & mask //index 也就是执行cache_hash
} else if (arm64_64) {//真机 //这个分支下没有计算mask
p11 = isa->cache //_bucketsAndMaybeMask
if (arm64 + iOS + !模拟器 + 非mac应用) {
if (开启指针验证 ) {
if (_bucketsAndMaybeMask 第0位 != 0) {
goto LLookupPreopt\Function
} else {
p10 = _bucketsAndMaybeMask & 0x0000ffffffffffff//buckets
}
} else {
p10 = _bucketsAndMaybeMask & 0x0000fffffffffffe //buckets
if (_bucketsAndMaybeMask 第0位 != 0) {
goto LLookupPreopt\Function
}
}
//计算index
p12 = selector ^ (selector >> 7)
p12 = p12 & (_bucketsAndMaybeMask & 48) = p12 & mask//index
} else {
p10 = _bucketsAndMaybeMask & 0x0000ffffffffffff //buckets
p12 = selector & (_bucketsAndMaybeMask >>48) //index
}
} else if (arm64_32) {
p11 = _bucketsAndMaybeMask
p10 = _bucketsAndMaybeMask &~0xf//buckets 相当于后4位置为0,取前32位
p11 = _bucketsAndMaybeMask & 0xf //mask前置位0的个数
p11 = 0xffff >> p11 //获取到mask的值
x12 = selector & mask //index
} else {
#error Unsupported cache mask storage for ARM64.
}

//通过上面的计算 p10 = buckets,p11 = mask/_bucketsAndMaybeMask, p12 = index
p13 = buckets + index << 4 //找到cls对应的buckets地址。地址平移找到对应bucket_t。

//2.找缓存(这里只扫描了前面)
do {
p13 = *bucket-- //赋值后指向前一个bucket
p17 = bucket.imp
p9 = bucket.sel
if (p9 != selector) {
if (p9 == 0) {//说明没有缓存
__objc_msgSend_uncached()
}
} else {//缓存命中,走命中逻辑 call or return imp
CacheHit \Mode
}
} while(bucket >= buckets) //buckets是首地址,bucket是index对应的buckct往前移动

//查找完后还没有缓存?
//查找 p13 = mask对应的元素,也就是最后一个元素
if (arm64_64 && OSX/SIMULATOR) {
p13 = buckets + (mask << 4)
} else if (arm64_64) {//真机
p13 = buckets + (_bucketsAndMaybeMask >> 44)//这里右移44位,少移动4位就不用再左移了。这里就找到了对应index的bucket_t。
} else if (arm64_32) {
p13 = buckets + (mask << 4)
} else {
#error Unsupported cache mask storage for ARM64.
}

//index的bucket_t 从mask对应的buckets开始再往前找
p12 = buckets + (index<<4)
do {
p17 = imp;
p9 = sel;
*p13--;
if (p9 == selector) {//命中
CacheHit \Mode
}
} while (p9 != nil && bucket > p12)//从后往前 p9位nil则证明没有存,也就不存在缓存了。

//仍然没有找到缓存,缓存彻底不存在。
__objc_msgSend_uncached()
}

二、LLookupPreopt\Function

arm64_64真机的情况下,如果_bucketsAndMaybeMask的第0位为1则会执行LLookupPreopt\Function的逻辑。简单看了下汇编发现与cache_t 中的_originalPreoptCache有关。

2.1 LLookupPreopt\Function 源码分析

LLookupPreopt\Function:
#if __has_feature(ptrauth_calls)
//p10 = _bucketsAndMaybeMask & 0x007ffffffffffffe = buckets
and p10, p11, #0x007ffffffffffffe // p10 = x
//buckets x16为cls 验证
autdb x10, x16 // auth as early as possible
#endif

// x12 = (_cmd - first_shared_cache_sel)
//(_cmd >> 12 + PAGE) << 12 + PAGEOFF 第一个sel
adrp x9, _MagicSelRef@PAGE
ldr p9, [x9, _MagicSelRef@PAGEOFF]
//差值index
sub p12, p1, p9

// w9 = ((_cmd - first_shared_cache_sel) >> hash_shift & hash_mask)
#if __has_feature(ptrauth_calls)
// bits 63..60 of x11 are the number of bits in hash_mask
// bits 59..55 of x11 is hash_shift

// 取到 hash_shift...
lsr x17, x11, #55 // w17 = (hash_shift, ...)
//w9 = index >> hash_shift
lsr w9, w12, w17 // >>= shift
//x17 = _bucketsAndMaybeMask >>60 //mask_bits
lsr x17, x11, #60 // w17 = mask_bits
mov x11, #0x7fff
//x11 = 0x7fff >> mask_bits //mask
lsr x11, x11, x17 // p11 = mask (0x7fff >> mask_bits)
//x9 = x9 & mask
and x9, x9, x11 // &= mask
#else
// bits 63..53 of x11 is hash_mask
// bits 52..48 of x11 is hash_shift
lsr x17, x11, #48 // w17 = (hash_shift, hash_mask)
lsr w9, w12, w17 // >>= shift
and x9, x9, x11, LSR #53 // &= mask
#endif
//x17 = el_offs | (imp_offs << 32)
ldr x17, [x10, x9, LSL #3] // x17 == sel_offs | (imp_offs << 32)
// cmp x12 x17 是否找到sel
cmp x12, w17, uxtw

.if \Mode == GETIMP
b.ne \MissLabelConstant // cache miss
//imp = isa - (sel_offs >> 32)
sub x0, x16, x17, LSR #32 // imp = isa - imp_offs
//注册imp
SignAsImp x0
ret
.else
b.ne 5f // cache miss
//imp(x17) = (isa - sel_offs>> 32)
sub x17, x16, x17, LSR #32 // imp = isa - imp_offs
.if \Mode == NORMAL
//跳转imp
br x17
.elseif \Mode == LOOKUP
//x16 = isa | 3 //这里为或的意思
orr x16, x16, #3 // for instrumentation, note that we hit a constant cache
//注册imp
SignAsImp x17
ret
.else
.abort unhandled mode \Mode
.endif
//x9 = buckets-1
5: ldursw x9, [x10, #-8] // offset -8 is the fallback offset
//计算回调isa x16 = x16 + x9
add x16, x16, x9 // compute the fallback isa
//使用新isa重新查找缓存
b LLookupStart\Function // lookup again with a new isa
.endif
  • 找到imp就跳转/返回。
  • 没有找到返回下一个isa重新CacheLookup
  • 这块进入的查找共享缓存, 与cache_t_originalPreoptCache有关。maskZeroBits4位就是用来判断是否有_originalPreoptCache的。

⚠️@TODO 真机调试的时候进不到这块流程,这块分析的还不是很透彻,后面再补充。

三、CacheHit

在查找缓存命中后会执行CacheHit

3.1 CacheHit源码分析

#define NORMAL 0
#define GETIMP 1
#define LOOKUP 2

// CacheHit: x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
.macro CacheHit
//这里传入的为NORMAL
.if $0 == NORMAL
//调用imp TailCallCachedImp(imp,buckets,sel,isa)
TailCallCachedImp x17, x10, x1, x16 // authenticate and call imp
.elseif $0 == GETIMP
//返回imp
mov p0, p17
//imp == nil跳转9:
cbz p0, 9f // don't ptrauth a nil imp
//有imp执行AuthAndResignAsIMP(imp,buckets,sel,isa)最后给到x0返回。
AuthAndResignAsIMP x0, x10, x1, x16 // authenticate imp and re-sign as IMP
9: ret // return IMP
.elseif $0 == LOOKUP
// No nil check for ptrauth: the caller would crash anyway when they
// jump to a nil IMP. We don't care if that jump also fails ptrauth.
//找imp(imp,buckets,sel,isa)
AuthAndResignAsIMP x17, x10, x1, x16 // authenticate imp and re-sign as IMP
//isa与x15比较
cmp x16, x15
//cinc如果相等 就将x16+1,否则就设成0.
cinc x16, x16, ne // x16 += 1 when x15 != x16 (for instrumentation ; fallback to the parent class)
ret // return imp via x17
.else
.abort oops
.endif
.endmacro
  • 这里其实走的是NORMAL逻辑,NORMALcase直接验证并且跳转imp
  • TailCallCachedImp内部执行的是imp^cls,对imp进行了解码。
  • GETIMP返回imp
  • LOOKUP查找注册imp并返回。

3.1 CacheHit伪代码实现

//x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
void CacheHit(Mode) {
if (Mode == NORMAL) {
//imp = imp^cls 解码
TailCallCachedImp x17, x10, x1, x16 // 解码跳转imp
} else if (Mode == GETIMP) {
p0 = IMP
if (p0 == nil) {
return
} else {
AuthAndResignAsIMP(imp,buckets,sel,isa)//resign cached imp as IMP
}
} else if (Mode == LOOKUP) {
AuthAndResignAsIMP(x17, buckets, sel, isa)//resign cached imp as IMP
if (isa == x15) {
x16 += 1
} else {
x16 = 0
}
} else {
.abort oops//报错
}
}

四、__objc_msgSend_uncached

在缓存没有命中的情况下会走到__objc_msgSend_uncached()的逻辑:


STATIC_ENTRY __objc_msgSend_uncached
UNWIND __objc_msgSend_uncached, FrameWithNoSaves

// THIS IS NOT A CALLABLE C FUNCTION
// Out-of-band p15 is the class to search
//查找imp
MethodTableLookup
//跳转imp
TailCallFunctionPointer x17

END_ENTRY __objc_msgSend_uncached
  • MethodTableLookup查找imp
  • TailCallFunctionPointer跳转imp

MethodTableLookup

.macro MethodTableLookup

SAVE_REGS MSGSEND

// lookUpImpOrForward(obj, sel, cls, LOOKUP_INITIALIZE | LOOKUP_RESOLVER)
// receiver and selector already in x0 and x1
//x2 = cls
mov x2, x16
//x3 = LOOKUP_INITIALIZE|LOOKUP_RESOLVER //是否初始化,imp没有实现尝试resolver
//_lookUpImpOrForward(receiver,selector,cls,LOOKUP_INITIALIZE | LOOKUP_RESOLVER)
mov x3, #3
bl _lookUpImpOrForward

// IMP in x0
mov x17, x0

RESTORE_REGS MSGSEND

.endmacro

  • 调用_lookUpImpOrForward查找imp。这里就调用到了c/c++的代码了:
IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior)

最终会调用_lookUpImpOrForward进入c/c++环境逻辑。

对于架构的一些理解
LP64 //64位
x86_64 // interl 64位
i386 // intel 32位
arm // arm指令 32 位
arm64 //arm64指令
arm64 && LP64 //arm64 64位
arm64 && !LP64 //arm64 32 位


⚠️ 当然也可以通过真机跟踪汇编代码读取寄存器进行,与源码分析的是一致的,走其中的一个分支。

五、 objc_msgSend流程图



总结

  • 判断receiver是否存在。
  • 通过isa获取cls
  • cls内存平移0x10获取cache也就是_bucketsAndMaybeMask
  • 通过buckets & bucketsMask获取buckets`地址。
  • 通过bucketsMask >> maskShift获取mask
  • 通过sel & mask获取第一次查找的index
  • buckets + index << 4找到index对应的地址。
  • do-while循环判断找缓存,这次从[index~0]查找imp
  • 取到buckets[mask]继续do-while循环,从[mask~index)查找imp。两次查找过程中如果有sel为空则会结束查找。走__objc_msgSend_uncached的逻辑。
  • 找到imp就解码跳转imp


作者:HotPotCat
链接:https://www.jianshu.com/p/c29c07a1e93d



收起阅读 »

iOS GCD 实现线程安全的多读单写功能

本文测试 demo 都是在 playground 里用 Swift5 完成的. 使用 GCD实现线程安全修改数据源, 示例中的读写都是对一个字典而言, 实际开发中可以是文件的读写(FileManager 是线程安全的), 可以是数组, 根据自己情况而定.先来了...
继续阅读 »

本文测试 demo 都是在 playground 里用 Swift5 完成的. 使用 GCD实现线程安全修改数据源, 示例中的读写都是对一个字典而言, 实际开发中可以是文件的读写(FileManager 是线程安全的), 可以是数组, 根据自己情况而定.

先来了解一下 GCD 中 队列 , 任务 , 线程, 同步, 异步 之间的关系 和 特点 :
  • GCD 默认有两个队列 : 主队列 和 全局队列
  • 主队列是特殊的串行队列, 主队列的任务一定在主线程执行.
  • 全局队列就是普通的并发队列.
  • 队列中的任务遵守先进先出规则, 即 FIFO.
  • 队列只调试任务.
  • 线程来执行任务.
  • 同步执行不具有开启线程的能力
  • 异步执行具有开启线程的能力, 但是不一定会开启新线程
  • 并发队列允许开启新线程 .
  • 串行队列不允许开启新线程的能力.
  • 栅栏函数堵塞的是队列.

注意 : 主队列同步执行会造成死锁.

应用场景


    1. 开启多个任务去修改数据, 保证资源不被抢占. 比如买火车票, 多个窗口同时出票, 在服务器只能是一个一个来, 不能出现两个人同时买到同一个座位号的情况, 所以此时我们就需要保证数据安全, 即同一时间只能有一个任务去修改数据.

    1. 读操作可以允许多个任务同时加入队列, 但是要保证一个一个执行, 此处使用并发同步, 这么做是为了保证按照外部调用顺序去返回结果, 保证当前读操作完成后, 后面的操作才能进行. 其实是个假多读.

初始化代码

// 并发队列
var queue = DispatchQueue(label: "concurrent", attributes: .concurrent)
// 数据
var dictionary: [String: Any] = [:]

/// 数据初始化
func testInit() {
dictionary = [
"name": "Cooci",
"age": 18,
"girl": "xiaoxiannv"
]
}

读写的关键代码

/// 读的过程
func getSafeValueFor(_ key: String) -> Any? {
var result: Any? = nil
// 并发同步读取数据, 实际是假多读
queue.sync {
result = dictionary[key]
}
return result
}

/// 写的过程
func setSafe(_ value: Any, for key: String) {
// 在子线程完成写任务
// 等待前面任务执行完成后开始写
// 写的完成任务后, 才能继续执行后边添加进此队列的任务
queue.async(flags: .barrier) {
dictionary[key] = value
}
}

首先来看看修改数据 -- 写操作

下面是写操作测试代码和执行结果 :

/// 写的过程
func setSafe(_ value: Any, for key: String) {
queue.async(flags: .barrier) {
dictionary[key] = value
let name = dictionary[key] as? String ?? ""
print("save name = \(name) --> \(Thread.current)")
}
}
/// 测试写的过程
func testWrite() {

setSafe("AAAAA", for: "name")

setSafe("BBBBB", for: "name")

setSafe("CCCCC", for: "name")

print("所有写操作后的任务")

sleep(1)
let name4 = dictionary["name"] ?? "失败"
print("for 后边的代码任务 name4 = \(name4)")
}



  • 我们可以看到 A, B, C 三个操作按照入队的顺序依次执行, 修改数据, name4 取到的是最后一次修改的数据, 这正是我们想要的. 使用并发是为了不堵塞当前线程(当前主线程), 当前线程写操作后面的的代码可以继续执行.

  • 你可能会说, 按照 A, B, C 三个任务添加的顺序输出也不是没可能, 那咱们现在给 setSafe 函数添加一个休眠时长的参数, 让 A 操作休眠 3s, B 休眠 2s, C 休眠 0s, 看看执行顺序是怎样的.


func setSafe(_ value: Any, for key: String, sleepNum: UInt32) {
queue.async(flags: .barrier) {
sleep(sleepNum)
dictionary[key] = value
let name = dictionary[key] as? String ?? ""
print("save name = \(name) --> \(Thread.current)")
}
}
/// 测试写的过程
func testWrite() {

setSafe("AAAAA", for: "name", sleepNum: 3)

setSafe("BBBBB", for: "name", sleepNum: 1)

setSafe("CCCCC", for: "name", sleepNum: 0)

print("所有写操作后的任务")

sleep(5)
let name4 = dictionary["name"] ?? "失败"
print("for 后边的代码任务 name4 = \(name4)")
}

多次执行后的结果都是相同的, 如下图所示 :



由此可见, 添加到队列中的写操作任务(即修改数据源), 只能依次按照添加顺序进行修改, 不会出现资源抢夺现象, 保证了多线程修改数据的安全性.

注意: 此处为什么只有一个线程呢 ?
因为每个任务执行完成后, 队列中已经没有其他任务, GCD 为了节约资源开销, 所以并不会开启新的线程. 也没必要去开启.

再来看看数据的读取 -- 写

并发同步读取数据, 保证外部调用顺序. 此时会堵塞当前线程, 当前线程需要等待读取任务执行完成, 才能继续执行后边代码任务

/// 读的过程
func getSafeValueFor(_ key: String) -> Any? {
var result: Any? = nil
// 在调用此函数的线程同步执行所有添加到 queue 队列的读任务,
// 如果前边有写的任务, 由于 barrier 堵塞队列, 只能等待写任务完成
queue.sync {
result = dictionary[key]
}
return result
}

/// 测试读的过程
func testRead() {

for i in 0...11 {
let order = i % 3
switch order {
case 0:
let name = getSafeValueFor("name") as? String ?? ""
print("\(order) - name = \(name)")
case 1:
let age = getSafeValueFor("age") as? Int ?? 0
print("\(order) - age = \(age)")
case 2:
let girl = getSafeValueFor("girl") as? String ?? "---"
print("\(order) - girl = \(girl)")
default:
break
}
}

print("循环后边的任务")
}

并发异步回调方式读取数据, 当你对外部调用顺序没有要求时, 那你可以这么调用.



/// 读的过程
func getSafeValueFor(_ key: String, completion: @escaping (Any?)->Void) {
queue.async {
let result = dictionary[key]
completion(result)
}
}
func testRead() {
for i in 0...10 {
let order = i % 3
switch order {
case 0:
getSafeValueFor("name") { result in
let name = result as? String ?? "--"
print("\(order) - name = \(name) \(Thread.current)")
}
case 1:
getSafeValueFor("age") { result in
let age = result as? Int ?? 0
print("\(order) - age = \(age) \(Thread.current)")
}
case 2:
getSafeValueFor("girl") { result in
let girl = result as? String ?? "--"
print("\(order) - girl = \(girl) \(Thread.current)")
}
default:
break
}
if i == 5 {
setSafe(100, for: "age")
}
}
print("循环后边的任务")
}




作者:AndyGF
链接:https://www.jianshu.com/p/281b37174dd0



收起阅读 »

iOS 性能调优 二

12.2 测量,而不是猜测    于是现在你知道有哪些点可能会影响动画性能,那该如何修复呢?好吧,其实不需要。有很多种诡计来优化动画,但如果盲目使用的话,可能会造成更多性能上的问题,而不是修复。  &...
继续阅读 »

12.2 测量,而不是猜测

    于是现在你知道有哪些点可能会影响动画性能,那该如何修复呢?好吧,其实不需要。有很多种诡计来优化动画,但如果盲目使用的话,可能会造成更多性能上的问题,而不是修复。

    如何正确的测量而不是猜测这点很重要。根据性能相关的知识写出代码不同于仓促的优化。前者很好,后者实际上就是在浪费时间。

    那该如何测量呢?第一步就是确保在真实环境下测试你的程序。

真机测试,而不是模拟器

    当你开始做一些性能方面的工作时,一定要在真机上测试,而不是模拟器。模拟器虽然是加快开发效率的一把利器,但它不能提供准确的真机性能参数。

    模拟器运行在你的Mac上,然而Mac上的CPU往往比iOS设备要快。相反,Mac上的GPU和iOS设备的完全不一样,模拟器不得已要在软件层面(CPU)模拟设备的GPU,这意味着GPU相关的操作在模拟器上运行的更慢,尤其是使用CAEAGLLayer来写一些OpenGL的代码时候。

    这就是说在模拟器上的测试出的性能会高度失真。如果动画在模拟器上运行流畅,可能在真机上十分糟糕。如果在模拟器上运行的很卡,也可能在真机上很平滑。你无法确定。

    另一件重要的事情就是性能测试一定要用发布配置,而不是调试模式。因为当用发布环境打包的时候,编译器会引入一系列提高性能的优化,例如去掉调试符号或者移除并重新组织代码。你也可以自己做到这些,例如在发布环境禁用NSLog语句。你只关心发布性能,那才是你需要测试的点。

    最后,最好在你支持的设备中性能最差的设备上测试:如果基于iOS6开发,这意味着最好在iPhone 3GS或者iPad2上测试。如果可能的话,测试不同的设备和iOS版本,因为苹果在不同的iOS版本和设备中做了一些改变,这也可能影响到一些性能。例如iPad3明显要在动画渲染上比iPad2慢很多,因为渲染4倍多的像素点(为了支持视网膜显示)。

保持一致的帧率

    为了做到动画的平滑,你需要以60FPS(帧每秒)的速度运行,以同步屏幕刷新速率。通过基于NSTimer或者CADisplayLink的动画你可以降低到30FPS,而且效果还不错,但是没办法通过Core Animation做到这点。如果不保持60FPS的速率,就可能随机丢帧,影响到体验。

    你可以在使用的过程中明显感到有没有丢帧,但没办法通过肉眼来得到具体的数据,也没法知道你的做法有没有真的提高性能。你需要的是一系列精确的数据。

    你可以在程序中用CADisplayLink来测量帧率(就像11章“基于定时器的动画”中那样),然后在屏幕上显示出来,但应用内的FPS显示并不能够完全真实测量出Core Animation性能,因为它仅仅测出应用内的帧率。我们知道很多动画都在应用之外发生(在渲染服务器进程中处理),但同时应用内FPS计数的确可以对某些性能问题提供参考,一旦找出一个问题的地方,你就需要得到更多精确详细的数据来定位到问题所在。苹果提供了一个强大的Instruments工具集来帮我们做到这些。

收起阅读 »

iOS 性能调优 一

性能调优代码应该运行的尽量快,而不是更快 - 理查德    在第一和第二部分,我们了解了Core Animation提供的关于绘制和动画的一些特性。Core Animation功能和性能都非常强大,但如果你对背...
继续阅读 »

性能调优

代码应该运行的尽量快,而不是更快 - 理查德

    在第一和第二部分,我们了解了Core Animation提供的关于绘制和动画的一些特性。Core Animation功能和性能都非常强大,但如果你对背后的原理不清楚的话也会降低效率。让它达到最优的状态是一门艺术。在这章中,我们将探究一些动画运行慢的原因,以及如何去修复这些问题。


12.1 CPU VS GPU

    关于绘图和动画有两种处理的方式:CPU(中央处理器)和GPU(图形处理器)。在现代iOS设备中,都有可以运行不同软件的可编程芯片,但是由于历史原因,我们可以说CPU所做的工作都在软件层面,而GPU在硬件层面。

    总的来说,我们可以用软件(使用CPU)做任何事情,但是对于图像处理,通常用硬件会更快,因为GPU使用图像对高度并行浮点运算做了优化。由于某些原因,我们想尽可能把屏幕渲染的工作交给硬件去处理。问题在于GPU并没有无限制处理性能,而且一旦资源用完的话,性能就会开始下降了(即使CPU并没有完全占用)

    大多数动画性能优化都是关于智能利用GPU和CPU,使得它们都不会超出负荷。于是我们首先需要知道Core Animation是如何在这两个处理器之间分配工作的。

动画的舞台

    Core Animation处在iOS的核心地位:应用内和应用间都会用到它。一个简单的动画可能同步显示多个app的内容,例如当在iPad上多个程序之间使用手势切换,会使得多个程序同时显示在屏幕上。在一个特定的应用中用代码实现它是没有意义的,因为在iOS中不可能实现这种效果(App都是被沙箱管理,不能访问别的视图)。

    动画和屏幕上组合的图层实际上被一个单独的进程管理,而不是你的应用程序。这个进程就是所谓的渲染服务。在iOS5和之前的版本是SpringBoard进程(同时管理着iOS的主屏)。在iOS6之后的版本中叫做BackBoard

    当运行一段动画时候,这个过程会被四个分离的阶段被打破:

  • 布局 - 这是准备你的视图/图层的层级关系,以及设置图层属性(位置,背景色,边框等等)的阶段。

  • 显示 - 这是图层的寄宿图片被绘制的阶段。绘制有可能涉及你的-drawRect:-drawLayer:inContext:方法的调用路径。

  • 准备 - 这是Core Animation准备发送动画数据到渲染服务的阶段。这同时也是Core Animation将要执行一些别的事务例如解码动画过程中将要显示的图片的时间点。

  • 提交 - 这是最后的阶段,Core Animation打包所有图层和动画属性,然后通过IPC(内部处理通信)发送到渲染服务进行显示。

    但是这些仅仅阶段仅仅发生在你的应用程序之内,在动画在屏幕上显示之前仍然有更多的工作。一旦打包的图层和动画到达渲染服务进程,他们会被反序列化来形成另一个叫做渲染树的图层树(在第一章“图层树”中提到过)。使用这个树状结构,渲染服务对动画的每一帧做出如下工作:

  • 对所有的图层属性计算中间值,设置OpenGL几何形状(纹理化的三角形)来执行渲染

  • 在屏幕上渲染可见的三角形

    所以一共有六个阶段;最后两个阶段在动画过程中不停地重复。前五个阶段都在软件层面处理(通过CPU),只有最后一个被GPU执行。而且,你真正只能控制前两个阶段:布局和显示。Core Animation框架在内部处理剩下的事务,你也控制不了它。

    这并不是个问题,因为在布局和显示阶段,你可以决定哪些由CPU执行,哪些交给GPU去做。那么改如何判断呢?

GPU相关的操作

    GPU为一个具体的任务做了优化:它用来采集图片和形状(三角形),运行变换,应用纹理和混合然后把它们输送到屏幕上。现代iOS设备上可编程的GPU在这些操作的执行上又很大的灵活性,但是Core Animation并没有暴露出直接的接口。除非你想绕开Core Animation并编写你自己的OpenGL着色器,从根本上解决硬件加速的问题,那么剩下的所有都还是需要在CPU的软件层面上完成。

    宽泛的说,大多数CALayer的属性都是用GPU来绘制。比如如果你设置图层背景或者边框的颜色,那么这些可以通过着色的三角板实时绘制出来。如果对一个contents属性设置一张图片,然后裁剪它 - 它就会被纹理的三角形绘制出来,而不需要软件层面做任何绘制。

    但是有一些事情会降低(基于GPU)图层绘制,比如:

  • 太多的几何结构 - 这发生在需要太多的三角板来做变换,以应对处理器的栅格化的时候。现代iOS设备的图形芯片可以处理几百万个三角板,所以在Core Animation中几何结构并不是GPU的瓶颈所在。但由于图层在显示之前通过IPC发送到渲染服务器的时候(图层实际上是由很多小物体组成的特别重量级的对象),太多的图层就会引起CPU的瓶颈。这就限制了一次展示的图层个数(见本章后续“CPU相关操作”)。

  • 重绘 - 主要由重叠的半透明图层引起。GPU的填充比率(用颜色填充像素的比率)是有限的,所以需要避免重绘(每一帧用相同的像素填充多次)的发生。在现代iOS设备上,GPU都会应对重绘;即使是iPhone 3GS都可以处理高达2.5的重绘比率,并任然保持60帧率的渲染(这意味着你可以绘制一个半的整屏的冗余信息,而不影响性能),并且新设备可以处理更多。

  • 离屏绘制 - 这发生在当不能直接在屏幕上绘制,并且必须绘制到离屏图片的上下文中的时候。离屏绘制发生在基于CPU或者是GPU的渲染,或者是为离屏图片分配额外内存,以及切换绘制上下文,这些都会降低GPU性能。对于特定图层效果的使用,比如圆角,图层遮罩,阴影或者是图层光栅化都会强制Core Animation提前渲染图层的离屏绘制。但这不意味着你需要避免使用这些效果,只是要明白这会带来性能的负面影响。

  • 过大的图片 - 如果视图绘制超出GPU支持的2048x2048或者4096x4096尺寸的纹理,就必须要用CPU在图层每次显示之前对图片预处理,同样也会降低性能。

CPU相关的操作

    大多数工作在Core Animation的CPU都发生在动画开始之前。这意味着它不会影响到帧率,所以很好,但是他会延迟动画开始的时间,让你的界面看起来会比较迟钝。

    以下CPU的操作都会延迟动画的开始时间:

  • 布局计算 - 如果你的视图层级过于复杂,当视图呈现或者修改的时候,计算图层帧率就会消耗一部分时间。特别是使用iOS6的自动布局机制尤为明显,它应该是比老版的自动调整逻辑加强了CPU的工作。

  • 视图懒加载 - iOS只会当视图控制器的视图显示到屏幕上时才会加载它。这对内存使用和程序启动时间很有好处,但是当呈现到屏幕上之前,按下按钮导致的许多工作都会不能被及时响应。比如控制器从数据库中获取数据,或者视图从一个nib文件中加载,或者涉及IO的图片显示(见后续“IO相关操作”),都会比CPU正常操作慢得多。

  • Core Graphics绘制 - 如果对视图实现了-drawRect:方法,或者CALayerDelegate-drawLayer:inContext:方法,那么在绘制任何东西之前都会产生一个巨大的性能开销。为了支持对图层内容的任意绘制,Core Animation必须创建一个内存中等大小的寄宿图片。然后一旦绘制结束之后,必须把图片数据通过IPC传到渲染服务器。在此基础上,Core Graphics绘制就会变得十分缓慢,所以在一个对性能十分挑剔的场景下这样做十分不好。

  • 解压图片 - PNG或者JPEG压缩之后的图片文件会比同质量的位图小得多。但是在图片绘制到屏幕上之前,必须把它扩展成完整的未解压的尺寸(通常等同于图片宽 x 长 x 4个字节)。为了节省内存,iOS通常直到真正绘制的时候才去解码图片(14章“图片IO”会更详细讨论)。根据你加载图片的方式,第一次对图层内容赋值的时候(直接或者间接使用UIImageView)或者把它绘制到Core Graphics中,都需要对它解压,这样的话,对于一个较大的图片,都会占用一定的时间。

    当图层被成功打包,发送到渲染服务器之后,CPU仍然要做如下工作:为了显示屏幕上的图层,Core Animation必须对渲染树种的每个可见图层通过OpenGL循环转换成纹理三角板。由于GPU并不知晓Core Animation图层的任何结构,所以必须要由CPU做这些事情。这里CPU涉及的工作和图层个数成正比,所以如果在你的层级关系中有太多的图层,就会导致CPU没一帧的渲染,即使这些事情不是你的应用程序可控的。

IO相关操作

    还有一项没涉及的就是IO相关工作。上下文中的IO(输入/输出)指的是例如闪存或者网络接口的硬件访问。一些动画可能需要从山村(甚至是远程URL)来加载。一个典型的例子就是两个视图控制器之间的过渡效果,这就需要从一个nib文件或者是它的内容中懒加载,或者一个旋转的图片,可能在内存中尺寸太大,需要动态滚动来加载。

    IO比内存访问更慢,所以如果动画涉及到IO,就是一个大问题。总的来说,这就需要使用聪敏但尴尬的技术,也就是多线程,缓存和投机加载(提前加载当前不需要的资源,但是之后可能需要用到)。这些技术将会在第14章中讨论。

收起阅读 »

iOS 基于定时器的动画 二

11.2 物理模拟即使使用了基于定时器的动画来复制第10章中关键帧的行为,但还是会有一些本质上的区别:在关键帧的实现中,我们提前计算了所有帧,但是在新的解决方案中,我们实际上实在按需要在计算。意义在于我们可以根据用户输入实时修改动画的逻辑,或者和别的实时动画系...
继续阅读 »

11.2 物理模拟

即使使用了基于定时器的动画来复制第10章中关键帧的行为,但还是会有一些本质上的区别:在关键帧的实现中,我们提前计算了所有帧,但是在新的解决方案中,我们实际上实在按需要在计算。意义在于我们可以根据用户输入实时修改动画的逻辑,或者和别的实时动画系统例如物理引擎进行整合。

Chipmunk

我们来基于物理学创建一个真实的重力模拟效果来取代当前基于缓冲的弹性动画,但即使模拟2D的物理效果就已近极其复杂了,所以就不要尝试去实现它了,直接用开源的物理引擎库好了。

我们将要使用的物理引擎叫做Chipmunk。另外的2D物理引擎也同样可以(例如Box2D),但是Chipmunk使用纯C写的,而不是C++,好处在于更容易和Objective-C项目整合。Chipmunk有很多版本,包括一个和Objective-C绑定的“indie”版本。C语言的版本是免费的,所以我们就用它好了。在本书写作的时候6.1.4是最新的版本;你可以从http://chipmunk-physics.net下载它。

Chipmunk完整的物理引擎相当巨大复杂,但是我们只会使用如下几个类:

  • cpSpace - 这是所有的物理结构体的容器。它有一个大小和一个可选的重力矢量
  • cpBody - 它是一个固态无弹力的刚体。它有一个坐标,以及其他物理属性,例如质量,运动和摩擦系数等等。
  • cpShape - 它是一个抽象的几何形状,用来检测碰撞。可以给结构体添加一个多边形,而且cpShape有各种子类来代表不同形状的类型。

在例子中,我们来对一个木箱建模,然后在重力的影响下下落。我们来创建一个Crate类,包含屏幕上的可视效果(一个UIImageView)和一个物理模型(一个cpBody和一个cpPolyShape,一个cpShape的多边形子类来代表矩形木箱)。

用C版本的Chipmunk会带来一些挑战,因为它现在并不支持Objective-C的引用计数模型,所以我们需要准确的创建和释放对象。为了简化,我们把cpShapecpBody的生命周期和Crate类进行绑定,然后在木箱的-init方法中创建,在-dealloc中释放。木箱物理属性的配置很复杂,所以阅读了Chipmunk文档会很有意义。

视图控制器用来管理cpSpace,还有和之前一样的计时器逻辑。在每一步中,我们更新cpSpace(用来进行物理计算和所有结构体的重新摆放)然后迭代对象,然后再更新我们的木箱视图的位置来匹配木箱的模型(在这里,实际上只有一个结构体,但是之后我们将要添加更多)。

Chipmunk使用了一个和UIKit颠倒的坐标系(Y轴向上为正方向)。为了使得物理模型和视图之间的同步更简单,我们需要通过使用geometryFlipped属性翻转容器视图的集合坐标(第3章中有提到),于是模型和视图都共享一个相同的坐标系。

具体的代码见清单11.3。注意到我们并没有在任何地方释放cpSpace对象。在这个例子中,内存空间将会在整个app的生命周期中一直存在,所以这没有问题。但是在现实世界的场景中,我们需要像创建木箱结构体和形状一样去管理我们的空间,封装在标准的Cocoa对象中,然后来管理Chipmunk对象的生命周期。图11.1展示了掉落的木箱。

清单11.3 使用物理学来对掉落的木箱建模

#import "ViewController.h" 
#import
#import "chipmunk.h"

@interface Crate : UIImageView

@property (nonatomic, assign) cpBody *body;
@property (nonatomic, assign) cpShape *shape;

@end

@implementation Crate

#define MASS 100

- (id)initWithFrame:(CGRect)frame
{
if ((self = [super initWithFrame:frame])) {
//set image
self.image = [UIImage imageNamed:@"Crate.png"];
self.contentMode = UIViewContentModeScaleAspectFill;
//create the body
self.body = cpBodyNew(MASS, cpMomentForBox(MASS, frame.size.width, frame.size.height));
//create the shape
cpVect corners[] = {
cpv(0, 0),
cpv(0, frame.size.height),
cpv(frame.size.width, frame.size.height),
cpv(frame.size.width, 0),
};
self.shape = cpPolyShapeNew(self.body, 4, corners, cpv(-frame.size.width/2, -frame.size.height/2));
//set shape friction & elasticity
cpShapeSetFriction(self.shape, 0.5);
cpShapeSetElasticity(self.shape, 0.8);
//link the crate to the shape
//so we can refer to crate from callback later on
self.shape->data = (__bridge void *)self;
//set the body position to match view
cpBodySetPos(self.body, cpv(frame.origin.x + frame.size.width/2, 300 - frame.origin.y - frame.size.height/2));
}
return self;
}

- (void)dealloc
{
//release shape and body
cpShapeFree(_shape);
cpBodyFree(_body);
}

@end

@interface ViewController ()

@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, assign) cpSpace *space;
@property (nonatomic, strong) CADisplayLink *timer;
@property (nonatomic, assign) CFTimeInterval lastStep;

@end

@implementation ViewController

#define GRAVITY 1000

- (void)viewDidLoad
{
//invert view coordinate system to match physics
self.containerView.layer.geometryFlipped = YES;
//set up physics space
self.space = cpSpaceNew();
cpSpaceSetGravity(self.space, cpv(0, -GRAVITY));
//add a crate
Crate *crate = [[Crate alloc] initWithFrame:CGRectMake(100, 0, 100, 100)];
[self.containerView addSubview:crate];
cpSpaceAddBody(self.space, crate.body);
cpSpaceAddShape(self.space, crate.shape);
//start the timer
self.lastStep = CACurrentMediaTime();
self.timer = [CADisplayLink displayLinkWithTarget:self
selector:@selector(step:)];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop]
forMode:NSDefaultRunLoopMode];
}

void updateShape(cpShape *shape, void *unused)
{
//get the crate object associated with the shape
Crate *crate = (__bridge Crate *)shape->data;
//update crate view position and angle to match physics shape
cpBody *body = shape->body;
crate.center = cpBodyGetPos(body);
crate.transform = CGAffineTransformMakeRotation(cpBodyGetAngle(body));
}

- (void)step:(CADisplayLink *)timer
{
//calculate step duration
CFTimeInterval thisStep = CACurrentMediaTime();
CFTimeInterval stepDuration = thisStep - self.lastStep;
self.lastStep = thisStep;
//update physics
cpSpaceStep(self.space, stepDuration);
//update all the shapes
cpSpaceEachShape(self.space, &updateShape, NULL);
}

@end

图11.1

图11.1 一个木箱图片,根据模拟的重力掉落

添加用户交互

下一步就是在视图周围添加一道不可见的墙,这样木箱就不会掉落出屏幕之外。或许你会用另一个矩形的cpPolyShape来实现,就和之前创建木箱那样,但是我们需要检测的是木箱何时离开视图,而不是何时碰撞,所以我们需要一个空心而不是固体矩形。

我们可以通过给cpSpace添加四个cpSegmentShape对象(cpSegmentShape代表一条直线,所以四个拼起来就是一个矩形)。然后赋给空间的staticBody属性(一个不被重力影响的结构体)而不是像木箱那样一个新的cpBody实例,因为我们不想让这个边框矩形滑出屏幕或者被一个下落的木箱击中而消失。

同样可以再添加一些木箱来做一些交互。最后再添加一个加速器,这样可以通过倾斜手机来调整重力矢量(为了测试需要在一台真实的设备上运行程序,因为模拟器不支持加速器事件,即使旋转屏幕)。清单11.4展示了更新后的代码,运行结果见图11.2。

由于示例只支持横屏模式,所以交换加速计矢量的x和y值。如果在竖屏下运行程序,请把他们换回来,不然重力方向就错乱了。试一下就知道了,木箱会沿着横向移动。

清单11.4 使用围墙和多个木箱的更新后的代码

- (void)addCrateWithFrame:(CGRect)frame
{
Crate *crate = [[Crate alloc] initWithFrame:frame];
[self.containerView addSubview:crate];
cpSpaceAddBody(self.space, crate.body);
cpSpaceAddShape(self.space, crate.shape);
}

- (void)addWallShapeWithStart:(cpVect)start end:(cpVect)end
{
cpShape *wall = cpSegmentShapeNew(self.space->staticBody, start, end, 1);
cpShapeSetCollisionType(wall, 2);
cpShapeSetFriction(wall, 0.5);
cpShapeSetElasticity(wall, 0.8);
cpSpaceAddStaticShape(self.space, wall);
}

- (void)viewDidLoad
{
//invert view coordinate system to match physics
self.containerView.layer.geometryFlipped = YES;
//set up physics space
self.space = cpSpaceNew();
cpSpaceSetGravity(self.space, cpv(0, -GRAVITY));
//add wall around edge of view
[self addWallShapeWithStart:cpv(0, 0) end:cpv(300, 0)];
[self addWallShapeWithStart:cpv(300, 0) end:cpv(300, 300)];
[self addWallShapeWithStart:cpv(300, 300) end:cpv(0, 300)];
[self addWallShapeWithStart:cpv(0, 300) end:cpv(0, 0)];
//add a crates
[self addCrateWithFrame:CGRectMake(0, 0, 32, 32)];
[self addCrateWithFrame:CGRectMake(32, 0, 32, 32)];
[self addCrateWithFrame:CGRectMake(64, 0, 64, 64)];
[self addCrateWithFrame:CGRectMake(128, 0, 32, 32)];
[self addCrateWithFrame:CGRectMake(0, 32, 64, 64)];
//start the timer
self.lastStep = CACurrentMediaTime();
self.timer = [CADisplayLink displayLinkWithTarget:self
selector:@selector(step:)];
[self.timer addToRunLoop:[NSRunLoop mainRunLoop]
forMode:NSDefaultRunLoopMode];
//update gravity using accelerometer
[UIAccelerometer sharedAccelerometer].delegate = self;
[UIAccelerometer sharedAccelerometer].updateInterval = 1/60.0;
}

- (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration
{
//update gravity
cpSpaceSetGravity(self.space, cpv(acceleration.y * GRAVITY, -acceleration.x * GRAVITY));
}

图11.2

图11.1 真实引力场下的木箱交互

模拟时间以及固定的时间步长

对于实现动画的缓冲效果来说,计算每帧持续的时间是一个很好的解决方案,但是对模拟物理效果并不理想。通过一个可变的时间步长来实现有着两个弊端:

  • 如果时间步长不是固定的,精确的值,物理效果的模拟也就随之不确定。这意味着即使是传入相同的输入值,也可能在不同场合下有着不同的效果。有时候没多大影响,但是在基于物理引擎的游戏下,玩家就会由于相同的操作行为导致不同的结果而感到困惑。同样也会让测试变得麻烦。

  • 由于性能故常造成的丢帧或者像电话呼入的中断都可能会造成不正确的结果。考虑一个像子弹那样快速移动物体,每一帧的更新都需要移动子弹,检测碰撞。如果两帧之间的时间加长了,子弹就会在这一步移动更远的距离,穿过围墙或者是别的障碍,这样就丢失了碰撞。

我们想得到的理想的效果就是通过固定的时间步长来计算物理效果,但是在屏幕发生重绘的时候仍然能够同步更新视图(可能会由于在我们控制范围之外造成不可预知的效果)。

幸运的是,由于我们的模型(在这个例子中就是Chipmunk的cpSpace中的cpBody)被视图(就是屏幕上代表木箱的UIView对象)分离,于是就很简单了。我们只需要根据屏幕刷新的时间跟踪时间步长,然后根据每帧去计算一个或者多个模拟出来的效果。

我们可以通过一个简单的循环来实现。通过每次CADisplayLink的启动来通知屏幕将要刷新,然后记录下当前的CACurrentMediaTime()。我们需要在一个小增量中提前重复物理模拟(这里用120分之一秒)直到赶上显示的时间。然后更新我们的视图,在屏幕刷新的时候匹配当前物理结构体的显示位置。

清单11.5展示了固定时间步长版本的代码

清单11.5 固定时间步长的木箱模拟

#define SIMULATION_STEP (1/120.0)

- (void)step:(CADisplayLink *)timer
{
//calculate frame step duration
CFTimeInterval frameTime = CACurrentMediaTime();
//update simulation
while (self.lastStep < frameTime) {
cpSpaceStep(self.space, SIMULATION_STEP);
self.lastStep += SIMULATION_STEP;
}

//update all the shapes
cpSpaceEachShape(self.space, &updateShape, NULL);
}

避免死亡螺旋

当使用固定的模拟时间步长时候,有一件事情一定要注意,就是用来计算物理效果的现实世界的时间并不会加速模拟时间步长。在我们的例子中,我们随意选择了120分之一秒来模拟物理效果。Chipmunk很快,我们的例子也很简单,所以cpSpaceStep()会完成的很好,不会延迟帧的更新。

但是如果场景很复杂,比如有上百个物体之间的交互,物理计算就会很复杂,cpSpaceStep()的计算也可能会超出1/120秒。我们没有测量出物理步长的时间,因为我们假设了相对于帧刷新来说并不重要,但是如果模拟步长更久的话,就会延迟帧率。

如果帧刷新的时间延迟的话会变得很糟糕,我们的模拟需要执行更多的次数来同步真实的时间。这些额外的步骤就会继续延迟帧的更新,等等。这就是所谓的死亡螺旋,因为最后的结果就是帧率变得越来越慢,直到最后应用程序卡死了。

我们可以通过添加一些代码在设备上来对物理步骤计算真实世界的时间,然后自动调整固定时间步长,但是实际上它不可行。其实只要保证你给容错留下足够的边长,然后在期望支持的最慢的设备上进行测试就可以了。如果物理计算超过了模拟时间的50%,就需要考虑增加模拟时间步长(或者简化场景)。如果模拟时间步长增加到超过1/60秒(一个完整的屏幕更新时间),你就需要减少动画帧率到一秒30帧或者增加CADisplayLinkframeInterval来保证不会随机丢帧,不然你的动画将会看起来不平滑。

物理模拟

收起阅读 »

JS数字之旅——Number

首先来一段神奇的数字比较的代码 23333333333333333 === 23333333333333332 // output: true 233333333333333330000000000 === 23333333333333333999999999...
继续阅读 »

首先来一段神奇的数字比较的代码


23333333333333333 === 23333333333333332
// output: true
233333333333333330000000000 === 233333333333333339999999999
// output: true

咦?明明不一样的两个数字,为啥是相等的呢?


Number


众所周知,每一种编程语言,都有自己的数字类型,像Java里面,有intfloatlongdouble等,不同的类型有不同的可表示的数字范围。


同理,JavaScript也有Number表示数字,但是没有像C、Java等语言那样有表示不同精度的类型,Number可以用来表示整数也可以表示浮点数。由于Number是在内部被表示为64位的浮点数,所以是有边界值,而这个边界值如下:


Number.MAX_VALUE
// output: 1.7976931348623157e+308
Number.MIN_VALUE
// output: 5e-324

最大正数和最小正数



Number.MAX_VALUE代表的是可表示的最大正数,Number.MIN_VALUE代表的是可表示的最小正数。它们的值分别大约是1.79E+3085e-324



这时很容易想到一个问题,那超过MAX_VALUE会发生什么呢?通过下面的代码和输出可以发现,当超过MAX_VALUE,无论什么数字,都一律认为与MAX_VALUE相等,直到超过一定值之后,就会等于Infinity


Number.MAX_VALUE
// output: 1.7976931348623157e+308
Number.MAX_VALUE+1
// output: 1.7976931348623157e+308
Number.MAX_VALUE+1e291
// output: 1.7976931348623157e+308
Number.MAX_VALUE === Number.MAX_VALUE+1e291
// output: true
Number.MAX_VALUE+1e292
// output: Infinity

很明显,最开始的那段代码里面的数字,是在这两个Number.MAX_VALUENumber.MIN_VALUE,没有出现越界的情况,但为什么会发生比较上错误呢?


安全整数


其实,Number还有另一个概念,叫做安全整数,其中MAX_SAFE_INTEGER的定义是最大的整数n,使得n和n+1都能准确表示。而MIN_SAFE_INTEGER的定义则是最小的整数n,使得n和n-1都能准确表示。如下面代码所示,9007199254740991(2^53 - 1) 和 -9007199254740991(-(2^53 - 1)) 就是符合定义的最大和最小整数。


Number.MAX_SAFE_INTEGER
// output: 9007199254740991
Number.MAX_SAFE_INTEGER+1
// output: 9007199254740992
Number.MAX_SAFE_INTEGER+2
// output: 9007199254740992

Number.MIN_SAFE_INTEGER
// output: -9007199254740991
Number.MIN_SAFE_INTEGER-1
// output: -9007199254740992
Number.MIN_SAFE_INTEGER-2
// output: -9007199254740992

这意味着,在这个范围内的整数,进行计算或比较都是精确的。而超过这个区域的整数,则是不安全、有较大误差的。


现在回头看最开始的代码,很明显这些数字都已经超过MAX_SAFE_INTEGER。另外,也可以使用Number.isSafeInteger方法去判断是否是安全整数


Number.isSafeInteger(23333333333333333)
// output: false

Infinity


关于Infinity的一个有趣的地方是,它是一个数字类型,但既不是NaN,也不是整数。


typeof Infinity
// output: "number"
Number.isNaN(Infinity)
// output: false
Number.isInteger(Infinity)
// output: false

进制


JavaScript里面,除了支持十进制以外,也支持二进制、八进制和十六进制的字面量。



  • 二进制:以0b开头

  • 八进制:以0开头

  • 十六进制:以0x开头


0b11001
// output: 25
0234
// output: 156
0x1A2B
// output: 6699

里面出现的字母是不区分大小写,可以放心使用。那当我们遇到上面的进制以字符串形式出现的时候,如何解决呢?答案是使用parseInt


parseInt('11001', 2)
// output: 25
parseInt('0234', 8)
// output: 156
parseInt('0x1A2B', 16)
// output: 6699

parseInt这个方法实际上支持2-36进制的转换,虽然平时绝大多数情况,它通常被用来转十进制格式的字符串,而不会特别声明第二个参数。需要注意的一个特别的点是,部分浏览器,如果字符串是0开头的话,不带第二个参数的话,会默认以八进制进行换算,从而导致一些意想不到的bug。所以,保险起见,还是应该声明第二个参数。


浮点数计算


前面基本上都是在讨论整数,Number在浮点数计算方面,就显得有些力不从心,经常出现摸不着头脑的情况,最著名的莫过于0.1 + 0.2不等于0.3的精度问题。


0.1 + 0.2 === 0.3
// output: false
0.1 + 0.2
// output: 0.30000000000000004
1.5 * 1.2
// output: 1.7999999999999998

但其实这并非JavaScript特有的现象,像Java等语言也是有这种问题存在。究其原因,是因为我们以十进制的角度去计算,但计算机本身是以二进制运行和计算的,这就会导致在浮点数类型的表示和计算上,会存在一定的偏差,当这种偏差累计足够大的时候,就会导致精度问题。


正所谓解决办法总比困难多,仔细想想还是能找到一些解决方案。


有一种思路是,既然尽管出现误差也只有一点点,那就通过toFixed()强行限制小数位数,让存在误差的数值进行转换从而消除误差,但有一定的局限性,当遇上乘法和除法的时候,需要确认限制小数位数具体要多少个才合适。


另一种思路是,浮点数计算有误差,但是整数计算是准确的,那就把浮点数放大转换为整数,然后进行计算后再转换为浮点数。跟上面的方案类似,同样需要解决放大多少倍,以及后面转换为浮点数时的额外计算。


链接:https://juejin.cn/post/7001183062792863774

收起阅读 »

前端动画lottie-web

lottie是一个跨平台的动画库,通过AE(After Effects)制作动画,再通过AE插件Bodymovin导出Json文件,最终各个终端解析这个Json文件,还原动画。本文中我只介绍前端用到的库lottie-web。 对比三种常规的制作动画方式 Pn...
继续阅读 »

lottie是一个跨平台的动画库,通过AE(After Effects)制作动画,再通过AE插件Bodymovin导出Json文件,最终各个终端解析这个Json文件,还原动画。本文中我只介绍前端用到的库lottie-web。


对比三种常规的制作动画方式



  1. Png序列帧

  2. 2.Gif图

  3. 前端Svg API


先对位图与矢量图有一个基本的概念。



矢量图就是使用直线和曲线来描述的图形,构成这些图形的元素是一些点、线、矩形、多边形、圆和弧线等,它们都是通过数学公式计算获得的,具有编辑后不失真的特点。

位图是由称作像素(图片元素)的单个点组成的,放大会失真。\



Png序列帧


用Png序列帧是也容易理解,用css keyframes操作每一帧需要展示的图片,缺点也很明显,每一帧都是一张图片,占比较大的体积。当然也可以将图片合并成精灵图(Sprites Map),可参考这个方案,使用 gka 一键生成帧动画。Png也是位图,放大会失真,不过可以通过增大图片尺寸,避免模糊。


Gif图


如果之前没有用过动画,用Gif图是最简单的,只需要引入一张图。但是Gif图是位图,不是矢量图,放大会虚。


前端Svg API


Svg API对于动画初学者不太友好,你要实现一个自定义的动画,需要了解Svg的所有的API,虽然它的属性与css的动画有一些相似。它是矢量图,不失真。


lottie


而lottie是一个不太占体积,还原度高,对于初学者友好的库。设计师制作好动画,并且利用Bodymovin插件导出Json文件。而前端直接引用lottie-web库即可,它默认的渲染方式是svg,原理就是用JS操作Svg API。但是前端完全不需要关心动画的过程,Json文件里有每一帧动画的信息,而库会帮我们执行每一帧。


前端安装lottie-web插件


npm install lottie-web

代码调用


import lottie from 'lottie-web';

this.animation = lottie.loadAnimation({
container: this.animationRef.current,
renderer: 'svg',
loop: false,
autoplay: false,
animationData: dataJson,
assetsPath: CDN_URL,
});

介绍一个每个属性的意思。



  • container 当前需要渲染的DOM

  • renderer,渲染方式,默认是Svg,还有Html和Canvas方案。

  • loop 是否循环播放

  • autoplay 是否自动播放

  • animationData AE导出的Json,注意,这里不是路径

  • assetsPath Json文件里资源的绝对路径,webpack项目需要配合这个参数。


动画的播放与暂停,如果动画需要用户触发与暂停,需要有一个切换操作(toggle)


this.animation.play();
this.animation.pause();

动画执行过程中的钩子,可以对动画有一定的控制权



  • complete

  • loopComplete

  • enterFrame

  • segmentStart

  • config_ready(初始配置完成)

  • data_ready(所有动画数据加载完成)

  • DOMLoaded(元素已添加到DOM节点)

  • destroy


// 动画播放完成触发
anm.addEventListener('complete', anmLoaded);

// 当前循环播放完成触发
anm.addEventListener('loopComplete', anmComplete);

// 播放一帧动画的时候触发
anm.addEventListener('enterFrame', enterFrame);

打包时图片资源路径


webpack工程需要注意Json文件如果有图片资源(Png或者Svg),需要将文件放在项目的根目录的static下。这样打包的时候,图片会被打包,并且后缀名不会被改变,当然需要配合assetsPath这个参数,设置图片的绝对路径。而CDN的路径可以通过process.env.CDN_URL从webpack传到前端代码中。


关于源码


关于lottie源码解析,这位老哥已经分析的挺到位了,Lottie原理与源码解析。尽管lottie也一直在迭代,但是顺着这篇解析应该也能理清源码。以及Svg动画的介绍,SVG 动画精髓



链接:https://juejin.cn/post/7001312313953222670

收起阅读 »

居然不知道CSS能做3D?天空盒子了解一下,颠覆想象?

大家好,这次给大家换换口味,我们来点不一样的东西。请不要理解歪了🐶。 上周六和昊神的一聊,然后就有了这篇文章。 通过H5实现3D全景是挺平常的事情了,通过three.js可以很容易实现一个全景图。 可以这个链接来查看,three.js来实现的,戳👇thre...
继续阅读 »

大家好,这次给大家换换口味,我们来点不一样的东西。请不要理解歪了🐶。


image.png


上周六和昊神的一聊,然后就有了这篇文章。


通过H5实现3D全景是挺平常的事情了,通过three.js可以很容易实现一个全景图。


image.png
可以这个链接来查看,three.js来实现的,戳👇three.js全景图DEMO链接


其实我们通过CSS3也能实现类似的效果,而且性能上更好,兼容性更好,支持低端机型。


是不是很惊讶,CSS居然也能做这种事情?


image.png


好了,放放手上的事情,花10多分钟专心致志🐶,羽飞老师的课开始了。


注意⚠️:建议PC端观摩,因为有挺多例子需要查看后理解更好,不过也不太影响,为手机同学准备了比较多的gif图,准备地好疲乏🥱。


由于本文重点在最后章,文中借用了一些DEMO方便快速带入,可能有所纰漏,欢迎各位大佬拍砖🧱、吐槽💬。


〇 背景


17年双十一前夕,其实也前不了多少天(大家都懂),产品找到我,说要做它,赶在双十一前上线,然后就有了它🐶。


开门见山,直接甩上成品给大家看看。


image.png


那......我就开动啦。我们先看看成品是长啥样的。



可以查看这个,👇CSS全景图DEMO链接


image.png


或者通过如上CSS全景图DEMO二维码进行尝试。


如果是“尊贵”的苹果手机用户🐶,在iOS13以上需要允许陀螺仪才可,如下图,得点击屏幕授权通过。iOS13之前都是默认开启的,苹果真的是一点不考虑向下兼容🥲,有点霸道呀。


image.png


扯远了扯远了,收。


这个时候大家就可以通过旋转手机或拖拽来查看整个全景图了。


image.png


是不是还挺神奇的?不是?


image.png


还是不是?🐶。🦢🦢🦢,不能向苹果学习🐶。


回来回来,接下来讲讲原理,先看看前置知识点。


〇 前置知识


看问题先看全貌,我们先来了解下如题中所提的天空盒子是什么概念。


天空盒子


天空盒子其实通俗的理解,可以理解如果把你放到天空中,上下前后左右都是蓝色的天空。而这个天空可以简单的用六边形来实现。


如下图所示,六边组成了一个封闭空间。



如果把你放到这个空间里,然后把每个空间的墙壁弄成天蓝色,而且每面都是纯蓝天色,这样你就分辨不出自己是不是在天上,还是只是在一个封闭的天空盒子里。



细思极恐,让人想到了缸中之脑,没听过的同学可以看看百度百科的缸中之脑解释


好了,回归主题👻。这样一个天空盒子就形成了一个全景空间图。


那CSS是要怎么才能实现一个天空盒子呢?我们继续。


image.png


CSS 3D坐标系


先来了解一下坐标系的概念。


从二维“反降维”到三维,需要理解下这个坐标系。


image.png


我们可以看到增加一个Z纬度的线,平面就变3D了。


这里需要注意的是CSS3D中,上下轴是Y轴,左右轴是X轴,前后轴是Z轴。可以简单理解为在原有竖着的面对我们的平面中,在X和Y轴中间强行插入一根直线,与Y轴和X轴都成90度,这根直线就是Z轴。


通过上面的处理,这样就形成了一个空间坐标系。


这有什么用呢?


image.png


大家可能有点懵逼,感觉二维都没搞定,突然要搞三维了。


可以先看看这个3D坐标系的DEMO,👇链接在此,可以先随意把玩把玩。



可以看到途中绿色线就是Z轴,红色就是X轴,蓝色就是Z轴。


多玩一玩就有点感觉啦,是不是感觉逐渐有了3D空间的感觉。


没有?


image.png


其他同学们,不要他了,我们继续。


image.png


不管你了,辛苦做了好久的DEMO🐶。继续继续。


如果想深入了解此CSS 3D坐标系演示的DEMO,源码可以查看这里,👇链接在此


说到CSS 3D,肯定离不开CSS3D transform,下面开始学习。


CSS 3D transform


3D transform字面意思翻译过来为三维变换。


3D rotate


我们先从rotate 3d(旋转)开始,这个能辅助我们理解3D坐标系。


rotate X


单杠运动员,如果正面对着我们,就是可以理解为围着X转。


image.png


rotate Y


围着钢管转,就可以理解为围着Y轴在转。



rotate Z


如果我们正面对着摩天轮,其实摩天轮就在围着Z轴在做运动,中间那个白点,可以理解为Z轴从这个圆圈穿透过去的点。



如果还没理解的同学,可以通过之前的CSS3D DEMO,👇链接在此,辅助理解3D rotate。


理解了3D rotate后,可以辅助我们理解三维坐标系。下面我们开始讲解perspective,有一些理解的难度哦。


image.png


perspective


perspective是做什么用的呢?字面意思是视角、透视的意思。


有一种我们从小到大看到的想象,可能我们都并不在意了,就是现实生活中的透视。比如同样的电线杆,会进高远低。其实这个现象是有一些规律的:近大远小、近实远虚、近宽远窄。


image.png


因此在素描、建筑的行业,都会通过一种透视的方式来表达现实世界的3D模型。


image.png


而我们在计算机世界怎么表达3D呢?


image.png


上方图可以辅助大家理解3D的透视perspective,黄色的是电脑或手机屏幕,红色是屏幕里的方块。


image.png


再看看上面这个二维图,可以看到,perspective: 800,代表3D物体距离屏幕(中间那个平面)是800px。


这里还有个概念,perspective-origin,可以看到上面perspective-origin是50% 50%,可以理解为眼睛视角的中心点,分别在x轴、y轴(x轴50%,y轴50%)交叉处。


image.png


没事没事,如果上面这些还不够你理解的,可以看看下面这张图。再不懂就不管你了🐶。


「下图来自:CSS 3D - Scrolling on the z-axis | CSS | devNotes
image.png


上图里的Z就是Z轴的值。Z轴如果是正数的离屏幕更近,如果是负数离屏幕更远。


而Z轴的远近和translateZ分不开,下面来讲解translateZ。


image.png


translateZ


这个属性可以帮助我们理解perspective。


可以通过translate的DEMO进行把玩把玩,有助于理解,戳👇DEMO链接在此



translateZ实现了CSS3D世界空间的近大远小。


看一下这个例子,平面上的translateZ的变换,戳👇DEMO链接在此


Kapture 2021-08-18 at 14.06.30.gif


比如,我们设置元素perspective为201px,则其子元素的translateZ值越小,则看着越小;如果translateZ值越大,则看着越大。当translateZ为200px的时候,该元素会撑满屏幕,当超过201px时候,该元素消失了,跑到我们眼睛后面了。


平面上的translateZ感受完了,来试试三维下的,看看这个DEMO,戳👇链接在此



上图中,如果把perspective往左拖,可以发现front面会离我们越来越远,如果往右拖,反之。


通过这么一节,基本translateZ的作用,大家应该都能理解到位了,还没有?回头看看🐶。


image.png


模拟现实3D空间


其实计算机的3D世界就是现实3D世界的模拟。而和计算机的3D世界中,构建3D空间概念很相近的现实场景,是摄像。我们可以考虑一下如果你去拍照,会有几个要素?


第一个:镜头,第二个:拍摄的环境的空间,第三个:要拍摄的物件。


「下图来自搞懂 CSS 3D,你必须理解 perspective(视域)


image.png


而在CSS的3D世界,我们也需要去模仿这三要素。我们用三层div来表示,第一层是摄像镜头、第二层是立体空间或也可叫舞台,第三层是立体空间内的元素。


大致的HTML代码如下。


<div class="camera">
<div class="space">
<div class="box">
</div>
</div>
</div>

下面就是真枪实弹地干了。


image.png


〇 实现天空盒子


已经知道了足够的前置知识,我们来简单实现一下天空盒子。


六面盒子


需要生成前后、左右、上下六个面。首先我们想一下第一面前面应该怎么放?


前面墙


假设我们在天空盒子(是一个正方体1024px*1024px),我们在正方体里面的中心点,那我们要往前面的墙上贴一张图,需要做什么?


我们回顾下坐标系。


image.png


你可以想象自己站在x轴和y轴交叉的中心点,即你在正方体的中心点。则你的前面的墙就是在z为-512px处,因为是前面,我们无需对这个墙进行旋转。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
margin: 0;
overflow: hidden;
background-color: #ccc;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
</style>
</head>

<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
</div>
</div>
</body>
</html>

生成如下页面,演示代码地址:。
image.png


可以看到第一张图被放在了前面。


左面墙


从前面墙放上一张图,然后转向左面墙,需要几步走?


image.png


第一步,需要让平面与前面的墙垂直,这个时候我们需要把左面的图绕着Y轴旋转90度。


左面墙的图本应该放在X轴的-512px位置,但由于做了旋转,所以左面墙对应的坐标系也做了绕着Y轴向下旋转了90度。如果我们想把左侧的图放到对应的位置,我们需要让其在Z轴的-512px位置。


因此代码如下。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
margin: 0;
overflow: hidden;
background-color: #ccc;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
</div>
</div>
</body>
</html>

生成的页面如下,演示代码地址


image.png


可以看到左面墙确实生成在了前面墙的左侧。


底面


类似前面墙、左面墙,我们把底面,做了绕着X轴旋转90度,然后沿着Y轴走-512px。


代码如下。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
margin: 0;
overflow: hidden;
background-color: #ccc;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
.space .bottom {
transform: rotateX(90deg) translateZ(-512px);
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
<img class="bottom" src="//yun.dui88.com/tuia/junhehe/skybox/bottom.jpg" alt="" />
</div>
</div>
</body>
</html>

生成页面如下,演示代码地址


image.png


可以看到我们底部也有了,看看所有面集成后是什么样。


image.png


所有面


类似上面的操作,我们把六个面补全,下面我们就把六个面都集合起来。


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
overflow: hidden;
margin: 0;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;
}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .back {
transform: rotateY(180deg) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
.space .right {
transform: rotateY(-90deg) translateZ(-512px);
}
.space .bottom {
transform: rotateX(90deg) translateZ(-512px);
}
.space .top {
transform: rotateX(-90deg) translateZ(-512px);
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="back" src="//yun.dui88.com/tuia/junhehe/skybox/back.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
<img class="right" src="//yun.dui88.com/tuia/junhehe/skybox/right.jpg" alt="" />
<img class="bottom" src="//yun.dui88.com/tuia/junhehe/skybox/bottom.jpg" alt="" />
<img class="top" src="//yun.dui88.com/tuia/junhehe/skybox/top.jpg" alt="" />
</div>
</div>
</body>
</html>

生成页面如下,演示代码地址


image.png


我们发现看不到后方墙(背面墙)。所以我们打算把整个场景转起来。


image.png


盒子旋转


怎么才能把盒子进行旋转?这里需要对六面墙所在的场景,也即是它们上一层的元素。


我们给.cube加上一个动画效果,绕着Y轴钢管舞🐶,回忆起前置知识里的钢管舞没?


<html>
<head>
<title>CSS3D天空盒子</title>
<style>
html,
body {
overflow: hidden;
margin: 0;
}
.camera {
perspective: 512px;
perspective-origin:50% 50%;

}
.space {
width: 1024px;
height: 1024px;
margin: 0 auto;
transform-style: preserve-3d;
}
.space img {
width: 1024px;
height: 1024px;
position: absolute;
}
.space .front {
/* 正面的图无需旋转 */
transform: rotateZ(0) rotateY(0) rotateZ(0) translateZ(-512px);
}
.space .back {
transform: rotateY(180deg) translateZ(-512px);
}
.space .left {
transform: rotateY(90deg) translateZ(-512px);
}
.space .right {
transform: rotateY(-90deg) translateZ(-512px);
}
.space .bottom {
transform: rotateX(90deg) translateZ(-512px);
}
.space .top {
transform: rotateX(-90deg) translateZ(-512px);
}
@keyframes rot {
0% {
transform: rotateY(0deg)
}

10% {
transform: rotateY(90deg)
}

25% {
transform: rotateY(90deg)
}

35% {
transform: rotateY(180deg)
}

50% {
transform: rotateY(180deg)
}

60% {
transform: rotateY(270deg)
}

75% {
transform: rotateY(270deg)
}

85% {
transform: rotateY(360deg)
}

100% {
transform: rotateY(360deg)
}
}
/*为立方体加上帧动画*/
.space {
animation: rot 8s ease-out 0s infinite forwards;
}
</style>
</head>
<body>
<div class="camera" id="camera">
<div class="space">
<img class="front" src="//yun.dui88.com/tuia/junhehe/skybox/front.jpg" alt="" />
<img class="back" src="//yun.dui88.com/tuia/junhehe/skybox/back.jpg" alt="" />
<img class="left" src="//yun.dui88.com/tuia/junhehe/skybox/left.jpg" alt="" />
<img class="right" src="//yun.dui88.com/tuia/junhehe/skybox/right.jpg" alt="" />
<img class="bottom" src="//yun.dui88.com/tuia/junhehe/skybox/bottom.jpg" alt="" />
<img class="top" src="//yun.dui88.com/tuia/junhehe/skybox/top.jpg" alt="" />
</div>
</div>
</body>
</html>

生成页面动画效果如下,这次用的手机拍摄的更真实一些😂,虽然有点糊,演示代码地址


gif (1).gif


既然能自动旋转,我们是不是可以考虑用手动旋转呢?


image.png


手动旋转


大概原理,就是手动拖拽(手机是touchmove,PC是mousemove),拖拽过去走的多少路程,计算出角度,然后把这个角度通过DOM设置(这个过程通过requestAnimationFrame不停地轮询设置)。


启动手动拖拽的代码。


var curMouseX = 0;
var curMouseY = 0;
var lastMouseX = 0;
var lastMouseY = 0;

if (isAndroid || isiOS) {
document.addEventListener('touchstart', mouseDownHandler);
document.addEventListener('touchmove', mouseMoveHandler);
} else {
document.addEventListener('mousedown', mouseDownHandler);
document.addEventListener('mousemove', mouseMoveHandler);
}

function mouseDownHandler(evt) {
lastMouseX = evt.pageX || evt.targetTouches[0].pageX;
lastMouseY = evt.pageY || evt.targetTouches[0].pageY;
}

function mouseMoveHandler(evt) {
curMouseX = evt.pageX || evt.targetTouches[0].pageX;
curMouseY = evt.pageY || evt.targetTouches[0].pageY;
}

具体的不分析了,不是本次的重点。有兴趣的可以直接看代码深入。


且由于我们想使用在手机上,因此做了rem的适配,适配在手机端。


生成页面动画效果如下,演示代码地址



上面是手机录制的旋转视频。既然我们能通过手触旋转,那我们肯定也可以进行陀螺仪旋转。


陀螺仪旋转


大致原理也是如上,把手动拖拽换成了陀螺仪旋转,然后计算旋转角度。


启动陀螺仪的代码。


window.addEventListener('deviceorientation', motionHandler, false)
function motionHandler(event) {
var x = event.beta;
var y = event.gamma;
}

自开头所说,陀螺仪在IOS13+下需要授权。


var isiOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); // ios??
if (isiOS) {
permission()
}

function permission () {
if ( typeof( DeviceMotionEvent ) !== "undefined" && typeof( DeviceMotionEvent.requestPermission ) === "function" ) {
// (optional) Do something before API request prompt.
DeviceMotionEvent.requestPermission()
.then( response => {
// (optional) Do something after API prompt dismissed.
if ( response == "granted" ) {
window.addEventListener( "devicemotion", (e) => {
// do something for 'e' here.
})
}
})
.catch( console.error )
} else {
alert( "请使用手机浏览器" );
}
}

下面是手机录制展示陀螺仪的例子,生成页面动画效果如下,演示代码地址



这里想深入的同学,可以看一下代码,和上面一样不是本文的重点就不分析了。


有没有感觉写了这么多代码,感觉跟写纯JS操作DOM似的,有没有类似JQuery之类的库呢?


image.png


css3d-engine


上面只是实现了平行旋转,要实现任意角度旋转,我们是基于css3d-engine做了实现。


这一节只是带过,理解了大概的原理后,结合例子去学习这个库还是非常快的。


部分示例代码


文章第一个DEMO就是以这个库为基础进行实践的,地址在这里:github.com/shrekshrek/…


创建stage,stage是舞台,是整个场景的根。


var s = new C3D.Stage();  

创建一个天空盒子的例子,控制各面的素材。


//创建1个立方体放入场景
var c = new C3D.Skybox();
c.size(1024).position(0, 0, 0).material({
front: {image: "images/cube_FR.jpg"},
back: {image: "images/cube_BK.jpg"},
left: {image: "images/cube_LF.jpg"},
right: {image: "images/cube_RT.jpg"},
up: {image: "images/cube_UP.jpg"},
down: {image: "images/cube_DN.jpg"},
}).update();
s.addChild(c);

Tween制作动效


第一个DEMO中动效,是通过Tween.js实现的,地址在这里:github.com/sole/tween.…


为什么DOM元素会有动效,也是因为属性值的变化,而Tween可以控制属性值在一段时间内按规定的规律变化。


下面是一个Tween的示例。


var coords = { x: 0, y: 0 };
var tween = new TWEEN.Tween(coords)
.to({ x: 100, y: 100 }, 1000)
.onUpdate(function() {
console.log(this.x, this.y);
})
.start();

requestAnimationFrame(animate);

function animate(time) {
requestAnimationFrame(animate);
TWEEN.update(time);
}

在最后再体验一下整个处理好后的DEMO,重新感受一下。


image.png


具体的完整版DEMO的源码在此,有兴趣的可以深入研究,由于是之前早几年做的DEMO,代码比较乱,还请见谅,地址在此:github.com/fly0o0/css3…



作者:羽飞
链接:https://juejin.cn/post/6997697496176820255

收起阅读 »

奇思妙想 CSS 3D 动画 | 仅使用 CSS 能制作出多惊艳的动画?

本文将从比较多的方面详细阐述如何利用 CSS 3D 的特性,实现各类有趣、酷炫的动画效果。认真读完,你将会收获到: 了解 CSS 3D 的各种用途 激发你新的灵感,感受动画之美 对于提升 CSS 动画制作水平会有所帮助 CSS 3D 基础知识 本文默认读者...
继续阅读 »

本文将从比较多的方面详细阐述如何利用 CSS 3D 的特性,实现各类有趣、酷炫的动画效果。认真读完,你将会收获到:



  • 了解 CSS 3D 的各种用途

  • 激发你新的灵感,感受动画之美

  • 对于提升 CSS 动画制作水平会有所帮助


CSS 3D 基础知识


本文默认读者掌握一定的 CSS 3D 知识,能够绘制初步的 3D 动画效果。当然这里会再简单过一下 CSS 3D 的基础知识。


使用 transform-style 启用 3D 模式


要利用 CSS3 实现 3D 的效果,最主要的就是借助 transform-style 属性。transform-style 只有两个值可以选择:


// 语法:
transform-style: flat|preserve-3d;

transform-style: flat; // 默认,子元素将不保留其 3D 位置
transform-style: preserve-3d; // 子元素将保留其 3D 位置。

当我们指定一个容器的 transform-style 的属性值为 preserve-3d 时,容器的后代元素便会具有 3D 效果,这样说有点抽象,也就是当前父容器设置了 preserve-3d 值后,它的子元素就可以相对于父元素所在的平面,进行 3D 变形操作。


利用 perspective & perspective-origin 设置 3D视距,实现透视/景深效果


perspective 为一个元素设置三维透视的距离,仅作用于元素的后代,而不是其元素本身。


简单来说,当元素没有设置 perspective 时,也就是当 perspective:none/0 时所有后代元素被压缩在同一个二维平面上,不存在景深的效果。


而如果设置 perspective 后,将会看到三维的效果。


// 语法
perspective: number|none;

// 语法
perspective-origin: x-axis y-axis;
// x-axis : 定义该视图在 x 轴上的位置。默认值:50%
// y-axis : 定义该视图在 y 轴上的位置。默认值:50%

perspective-origin 表示 3D 元素透视视角的基点位置,默认的透视视角中心在容器是 perspective 所在的元素,而不是他的后代元素的中点,也就是 perspective-origin: 50% 50%


通过绘制 Webpack Logo 熟悉 CSS 3D


对于初次接触 CSS 3D 的同学而言,可以通过绘制正方体快速熟悉语法,了解规则。


而 Webpack 的 Logo,正是由 2 个 立方体组成:



以其中一个正方体而言,实现它其实非常容易:



  1. 一个正方体由 6 个面组成,所以首先设定一个父元素 div,然后这个 div 再包含 6 个子 div,同时,父元素设置 transform-style: preserve-3d

  2. 6 个子元素,依次首先旋转不同角度,再通过 translateZ 位移正方体长度的一半距离即可

  3. 父元素可以通过 transformperspective 调整视觉角度


以一个正方体为例子,简单的伪代码如下:


<ul class="cube-inner">
<li class="top"></li>
<li class="bottom"></li>
<li class="front"></li>
<li class="back"></li>
<li class="right"></li>
<li class="left"></li>
</ul>

.cube {
width: 100px;
height: 100px;
transform-style: preserve-3d;
transform-origin: 50px 50px;
transform: rotateX(-33.5deg) rotateY(45deg);

li {
position: absolute;
top: 0;
left: 0;
width: 100px;
height: 100px;
background: rgba(141, 214, 249);
border: 1px solid #fff;
}
.top {
transform: rotateX(90deg) translateZ(50px);
}
.bottom {
transform: rotateX(-90deg) translateZ(50px);
}
.front {
transform: translateZ(50px);
}
.back {
transform: rotateX(-180deg) translateZ(50px);
}
.left {
transform: rotateY(-90deg) translateZ(50px);
}
.right {
transform: rotateY(90deg) translateZ(50px);
}
}

叠加两个,调整颜色和透明度,我们可以非常轻松的实现 Webpack 的 LOGO:



当然,这里的 LOGO 为了保证每条线条视觉上的一致性,其实是没有设置景深效果 perspective 的,我们可以尝试给顶层父容器添加一下如下代码,通过 transformperspective 调整视觉角度,设置景深效果:


.father {
transform-style: preserve-3d;
perspective: 200px;
transform: rotateX(10deg);
}

就可以得到真正的 3D 效果,感受很不一样:



完整的代码,你可以戳这里:CodePen Demo -- Webpack LOGO




OK,热身完毕,接下来,让我们插上想象的翅膀,走进 CSS 3D 的世界。


实现文字的 3D 效果


首先,看看一些有意思的 CSS 3D 文字特效。


要实现文字的 3D 效果,看起来是立体的,通常的方式就是叠加多层。


下面有一些实现一个文字的 3D 效果的方式。


假设我们有如下结构:


<div class="g-container">
<p>Lorem ipsum</p>
</div>

如果什么都不加,文字的展示可能是这样的:



我们可以通过叠加阴影多层,营造 3D 的感觉,主要是合理控制阴影的距离及颜色,核心 CSS 代码如下:


p {
text-shadow:
4px 4px 0 rgba(0, 0, 0, .8),
8px 8px 0 rgba(0, 0, 0, .6),
12px 12px 0 rgba(0, 0, 0, .4),
16px 16px 0 rgba(0, 0, 0, .2),
20px 20px 0 rgba(0, 0, 0, .05);
}


这样,就有了基础的 3D 视觉效果。


3D 氖灯文字效果


基于此,我们可以实现一些 3D 文字效果,来看一个 3D 氖灯文字效果,核心就是:



  • 利用 text-shadow 叠加多层文字阴影

  • 利用 animation 动态改变阴影颜色


<div class="container">
<p class="a">CSS 3D</p>
<p class="b">NEON</p>
<p class="a">EFFECT</p>
</div>

核心 CSS 代码:


.container {
transform: rotateX(25deg) rotateY(-25deg);
}
.a {
color: #88e;
text-shadow: 0 0 0.3em rgba(200, 200, 255, 0.3), 0.04em 0.04em 0 #112,
0.045em 0.045em 0 #88e, 0.09em 0.09em 0 #112, 0.095em 0.095em 0 #66c,
0.14em 0.14em 0 #112, 0.145em 0.145em 0 #44a;
animation: pulsea 300ms ease infinite alternate;
}
.b {
color: #f99;
text-shadow: 0 0 0.3em rgba(255, 100, 200, 0.3), 0.04em 0.04em 0 #112,
0.045em 0.045em 0 #f99, 0.09em 0.09em 0 #112, 0.095em 0.095em 0 #b66,
0.14em 0.14em 0 #112, 0.145em 0.145em 0 #a44;
animation: pulseb 300ms ease infinite alternate;
}
@keyframes pulsea {
// ... 阴影颜色变化
}
@keyframes pulseb {
// ... 阴影颜色变化
}

可以得到如下效果:


4


完整的代码,你可以猛击这里 CSS 灵感 -- 使用阴影实现文字的 3D 氖灯效果


利用 CSS 3D 配合 translateZ 实现真正的文字 3D 效果


当然,上述第一种技巧其实没有运用 CSS 3D。下面我们使用 CSS 3D 配合 translateZ 再进一步。


假设有如下结构:


<div>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
<h1>Glowing 3D TEXT</h1>
</div>我们通过给父元素 div 设置 transform-style: preserve-3d,给每个 <h1> 设定不同的 translateZ() 来达到文字的 3D 效果:

div {
transform-style: preserve-3d;
}
h1:nth-child(2) {
transform: translateZ(5px);
}
h1:nth-child(3) {
transform: translateZ(10px);
}
h1:nth-child(4) {
transform: translateZ(15px);
}
h1:nth-child(5) {
transform: translateZ(20px);
}
h1:nth-child(6) {
transform: translateZ(25px);
}
h1:nth-child(7) {
transform: translateZ(30px);
}
h1:nth-child(8) {
transform: translateZ(35px);
}
h1:nth-child(9) {
transform: translateZ(40px);
}
h1:nth-child(10) {
transform: translateZ(45px);
}

当然,辅助一些旋转,色彩变化,就可以得到更纯粹一些 3D 文字效果:



完整的代码,你可以猛击这里 CSS 灵感 -- 3D 光影变换文字效果


利用距离、角度及光影构建不一样的 3D 效果


还有一种很有意思的技巧,制作的过程需要比较多的调试。


合理的利用距离、角度及光影构建出不一样的 3D 效果。看看下面这个例子,只是简单是设置了三层字符,让它们在 Z 轴上相距一定的距离。


简单的伪代码如下:


<div>
<span class='C'>C</span>
<span class='S'>S</span>
<span class='S'>S</span>
<span></span>
<span class='3'>3</span>
<span class='D'>D</span>
</div>

$bright : #AFA695;
$gold : #867862;
$dark : #746853;
$duration : 10s;
div {
perspective: 2000px;
transform-style: preserve-3d;
animation: fade $duration infinite;
}
span {
transform-style: preserve-3d;
transform: rotateY(25deg);
animation: rotate $duration infinite ease-in;

&:after, &:before {
content: attr(class);
color: $gold;
z-index: -1;
animation: shadow $duration infinite;
}
&:after{
transform: translateZ(-16px);
}
&:before {
transform: translateZ(-8px);
}
}
@keyframes fade {
// 透明度变化
}
@keyframes rotate {
// 字体旋转
}
@keyframes shadow {
// 字体颜色变化
}

简单捋一下,上述代码的核心就是:



  1. 父元素、子元素设置 transform-style: preserve-3d

  2. span 元素的两个伪元素复制两个相同的字,利用 translateZ() 让它们在 Z 轴间隔一定距离

  3. 添加简单的旋转、透明度、字体颜色变化


可以得到这样一种类似电影开片的标题 3D 动画,其实只有 3 层元素,但是由于角度恰当,视觉上的衔接比较完美,看上去就非常的 3D。



为什么上面说需要合理的利用距离、角度及光影呢?


还是同一个动画效果,如果动画的初始旋转角度设置的稍微大一点,整个效果就会穿帮:



可以看到,在前几帧,能看出来简单的分层结构。又或者,简单调整一下 perspective,设置父容器的 perspective2000px 改为 500px,穿帮效果更为明显:


8


也就是说,在恰当的距离,合适的角度,我们仅仅通过很少的元素,就能在视觉上形成比较不错的 3D 效果。


上述的完整代码,你可以猛击这里:CSS 灵感 -- 3D 文字出场动画


3D 计数器


当然,发挥想象,我们还可以利用 3D 文字效果,制作出非常多有意思的效果。


譬如这个,我之前运用在我们业务的可视化看板项目中的 3D 计数器:



代码比较长,就不贴出来了,但是也是使用纯 CSS 可以实现的效果。


完整的代码,你可以猛击这里 CSS 灵感 -- 3D 数字计数动画


空间效果


嗯,上述章节主要是关于文字的 3D 效果,下面我们继续探寻 3D 在营造空间效果上的神奇之处。


优秀的 3D 效果,能让人有一种身临其境的感觉,都说 CSS 3D 其实作用有限,能做的不多,但是不代表它不能实现酷炫逼真的效果。


要营造逼真的 3D 效果,关键是恰当好处的运用 perspective 属性。


简单掌握原理,我们也可以很轻松的利用 CSS 3D 绘制一些非常有空间美感的效果。


这里我带领大家快速绘制一副具有空间美感的 CSS 3D 作品。


空间 3D 效果热身


首先,我们借助 Grid/Flex 等布局,在屏幕上布满格子(item),随意点就好:


<ul class="g-container">
<li></li>
<li></li>
// ... 很多子 li
<li></li>
</ul>


初始背景色为黑色,每个 item 填充为白色




接着,改变下每个 item 的形状,让他变成长条形的,可以改变通过改变 item 宽度,使用渐变填充部分等等方式:



接下来,父容器设置 transform-style: preserve-3dperspective,子元素设置 transform: rotateX(45deg),神奇的事情就发生了:



Wow,仅仅 3 步,我们就初步得到了一副具有空间美感的图形,让我们再回到每个子 item 的颜色设置,给它们随机填充不同的颜色,并且加上一个 transform: translate3d() 的动画,一个简单的 CSS 3D 作品就绘制完成了:



基于这个技巧的变形和延伸,我们就可以绘制非常多类似的效果。


在这里,我再次推荐 CSS-Doodle 这个工具,它可以帮助我们快速的创造复杂 CSS 效果。



CSS-doodle 是一个基于 Web-Component 的库。允许我们快速的创建基于 CSS Grid 布局的页面,以实现各种 CSS 效果(或许可以称之为 CSS 艺术)。



我们可以把上述的线条切换成圆弧:



完整的代码可以戳这里,利用 CSS-Doodle 也就几十行:CodePen Demo - CSS-Doodle Random Circle


又譬如袁川老师创作的 Seeding



利用图片素材


当然,基于上述技巧,有的时候会认为利用 CSS 绘制一些线条、圆弧、方块比较麻烦。可以进一步尝试利用现有的素材基于 CSS 3D 进行二次创作,这里有一个非常有意思的技巧。


假设我们有这样一张图形:



这张图先放着备用。在使用这张图之前,我们会先绘制这样一个图形:


<div class="g-container">
<div class="g-group">
<div class="item item-right"></div>
<div class="item item-left"></div>
<div class="item item-top"></div>
<div class="item item-bottom"></div>
<div class="item item-middle"></div>
</div>
</div>

body {
background: #000;
}
.g-container {
position: relative;
}
.g-group {
position: absolute;
width: 100px;
height: 100px;
left: -50px;
top: -50px;
transform-style: preserve-3d;
}
.item {
position: absolute;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, .5);
}
.item-right {
background: red;
transform: rotateY(90deg) translateZ(50px);
}
.item-left {
background: green;
transform: rotateY(-90deg) translateZ(50px);
}
.item-top {
background: blue;
transform: rotateX(90deg) translateZ(50px);
}
.item-bottom {
background: deeppink;
transform: rotateX(-90deg) translateZ(50px);
}
.item-middle {
background: rgba(255, 255, 255, 0.5);
transform: rotateX(180deg) translateZ(50px);
}

一共设置了 5 个子元素,不过仔细看 CSS 代码,其中 4 个子元素都设置了 rotateX/Y(90deg/-90deg),也就是绕 X 轴或者 Y 轴旋转了 90°,在视觉上是垂直屏幕的一张平面,所以直观视觉上我们是不到的,只能看到一个平面 .item-middle


我将 5 个子 item 设置了不同的背景色,结果如下:



现在看来,好像平平无奇,确实也是。


不过,见证奇迹的时候来了,此时,我们给父元素 .g-container 设置一个极小的 perspective,譬如,设置一个 perspective: 4px,看看效果:


.g-container {
position: relative;
+ perspective: 4px;
}
// ...其余样式保持不变

此时,画风骤变,整个效果就变成了这样:



由于 perspective 生效,原本的平面效果变成了 3D 的效果。接下来,我们使用上面准备好的星空图,替换一下上面的背景颜色,全部都换成同一张图,神奇的事情发生了:



由于设置的 perspective 非常之下,而每个 item 的 transform: translateZ(50px) 设置的又比较大,所以图片在视觉上被拉伸的非常厉害。但是整体是充满整个屏幕的。


接下来,我们只需要让视角动起来,给父元素增加一个动画,通过控制父元素的 translateZ() 进行变化即可:


.g-container{
position: relative;
perspective: 4px;
perspective-origin: 50% 50%;
}

.g-group{
position: absolute;
// ... 一些定位高宽代码
transform-style: preserve-3d;
+ animation: move 8s infinite linear;
}

@keyframes move {
0%{
transform: translateZ(-50px) rotate(0deg);
}
100%{
transform: translateZ(50px) rotate(0deg);
}
}

看看,神奇美妙的星空穿梭的效果就出来了,Amazing:



美中不足之处在于,动画没能无限衔接上,开头和结尾都有很大的问题。


当然,这难不倒我们,我们可以:



  1. 通过叠加两组同样的效果,一组比另一组通过负的 animation-delay 提前行进,使两组动画衔接起来(一组结束的时候另外一组还在行进中)

  2. 再通过透明度的变化,隐藏掉 item-middle 迎面飞来的突兀感

  3. 最后,可以通过父元素的滤镜 hue-rotate 控制图片的颜色变化


我们尝试修改 HTML 结构如下:


<div class="g-container">
<div class="g-group">
<div class="item item-right"></div>
<div class="item item-left"></div>
<div class="item item-top"></div>
<div class="item item-bottom"></div>
<div class="item item-middle"></div>
</div>
<!-- 增加一组动画 -->
<div class="g-group">
<div class="item item-right"></div>
<div class="item item-left"></div>
<div class="item item-top"></div>
<div class="item item-bottom"></div>
<div class="item item-middle"></div>
</div>
</div>

修改后的核心 CSS 如下:


.g-container{
perspective: 4px;
position: relative;
// hue-rotate 变化动画,可以让图片颜色一直变换
animation: hueRotate 21s infinite linear;
}

.g-group{
transform-style: preserve-3d;
animation: move 12s infinite linear;
}
// 设置负的 animation-delay,让第二组动画提前进行
.g-group:nth-child(2){
animation: move 12s infinite linear;
animation-delay: -6s;
}
.item {
background: url(https://z3.ax1x.com/2021/08/20/fLwuMd.jpg);
background-size: cover;
opacity: 1;
// 子元素的透明度变化,减少动画衔接时候的突兀感
animation: fade 12s infinite linear;
animation-delay: 0;
}
.g-group:nth-child(2) .item {
animation-delay: -6s;
}
@keyframes move {
0%{
transform: translateZ(-500px) rotate(0deg);
}
100%{
transform: translateZ(500px) rotate(0deg);
}
}
@keyframes fade {
0%{
opacity: 0;
}
25%,
60%{
opacity: 1;
}
100%{
opacity: 0;
}
}
@keyframes hueRotate {
0% {
filter: hue-rotate(0);
}
100% {
filter: hue-rotate(360deg);
}
}

最终完整的效果如下,星空穿梭的效果,整个动画首尾相连,可以一直无限下去,几乎没有破绽,非常的赞:



上述的完整代码,你可以猛击这里:CSS 灵感 -- 3D 宇宙时空穿梭效果


3D 无限延伸视角动画


OK,当掌握了上述技巧之后,我们可以很容易的对其继续变形发散,实现各种各样的无限延伸的 3D 视角动画。


这里还有一个非常有意思的运用了类似技巧的动画:



原理与上述的星空穿梭大致相同,4 面墙的背景图使用 CSS 渐变可以很轻松的绘制出来,接下来就只是需要考虑如何让动画能无限循环下去,控制好首尾的衔接。


该效果最早见于 jkantner 的 CodePen,在此基础上我对其进行了完善和丰富,完整代码,你可以猛击这里:CSS 灵感 -- 3D 无限延伸视角动画



作者:chokcoco
链接:https://juejin.cn/post/6999801808637919239

收起阅读 »

想了解到底啥是个Web Socket?猛戳这里!!!

什么是 Web Socket WebSocket 协议在2008年诞生,2011年成为国际标准,所有浏览器都已经支持了。其是基于TCP的一种新的网络协议,是 HTML5 开始提供的一种在单个TCP连接上进行全双工通讯的协议,它实现了浏览器与服务器全双工(ful...
继续阅读 »

什么是 Web Socket


WebSocket 协议在2008年诞生,2011年成为国际标准,所有浏览器都已经支持了。其是基于TCP的一种新的网络协议,是 HTML5 开始提供的一种在单个TCP连接上进行全双工通讯的协议,它实现了浏览器与服务器全双工(full-duplex)通信——允许服务器主动发送信息给客户端。


都有http协议了,为什么要用Web Socket


WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后浏览器和服务器之间就形成了一条快速通道,两者之间就直接可以数据互相传送。


HTTP协议是一种无状态、单向的应用层协议,其采用的是请求/响应模型,通信请求只能由客户端发起,服务端对请求做出应答响应,无法实现服务器主动向客户端发起消息,这就注定如果服务端有连续的状态变化,客户端想要获知就非常的麻烦。而大多数Web应用程序通过频繁的异步JavaScript 和 aJax 请求实现长轮询,其效率很低,而且非常的浪费很多的带宽等资源。


HTML5定义的WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。WebSocket 连接允许客户端和服务器之间进行全双工通信,以便任一方都可以通过建立的连接将数据推送到另一端。WebSocket 只需要建立一次连接,就可以一直保持连接状态,这相比于轮询方式的不停建立连接显然效率要大大提高。


特点




  • 服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话




  • 建立在 TCP 协议之上,服务器端的实现比较容易。




  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。




  • 数据格式比较轻量,性能开销小,通信高效。




  • 可以发送文本,也可以发送二进制数据。




  • 没有同源限制,客户端可以与任意服务器通信。




  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。




怎样使用?


执行流程



  • 1 连接建立:客户端向服务端请求建立连接并完成连接建立

  • 2 数据上行:客户端通过已经建立的连接向服务端发送数据

  • 3 数据下行:服务端通过已经建立的连接向客户端发送数据

  • 4 客户端断开:客户端要求断开已经建立的连接

  • 5 服务端断开:服务端要求断开已经建立的连接


客户端


连接建立


连接成功后,会触发 onopen 事件


var ws = new WebSocket("wss://ws.iwhao.top");
ws.onopen = function(evt) {
console.log("Connection open ...");
};

数据上行


  ws.send("Hello WebSockets!");

数据下行


ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
ws.close();
};

客户端断开


ws.close();

服务端断开


ws.onclose = function(evt) {
console.log("closed.");
};

异常报错


如果连接失败,发送、接收数据失败或者处理数据出现错误,browser 会触发 onerror 消息;


ws.onerror = function(evt) {
};

服务端 node


参考



api/浏览器版本兼容性



链接:https://juejin.cn/post/7000579006386929672

收起阅读 »