注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

Android知识点之Service(三)

(3)、异进程服务启动绑定流程 由(1)和(2)可以知道,无论是启动服务还是绑定服务,最终是通过SystemServer进程中的ActiveServices对象的bringUpServiceLocked方法来执行,bringUpServiceLocked方法是...
继续阅读 »
(3)、异进程服务启动绑定流程

(1)和(2)可以知道,无论是启动服务还是绑定服务,最终是通过SystemServer进程中的ActiveServices对象的bringUpServiceLocked方法来执行,bringUpServiceLocked方法是进程判断与启动新进程的入口,当在应用的AndroidManifest.xml文件中Service设置为新进程执行,那么就会在bringUpServiceLocked方法中启动一个新的进程


a、SystemServer进程与Zygote进程通信流程

android应用层次的进程都是通过Zygote进程去卵化启动的,Service进程也是一样
frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
····
//启动一个新的进程
if (app == null && !permissionsReviewRequired) {
if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
hostingRecord, ZYGOTE_POLICY_FLAG_EMPTY, false, isolated, false)) == null) {
·····
return msg;
}
if (isolated) {
r.isolatedProc = app;
}
}
····
return null;
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


final ProcessRecord startProcessLocked(String processName,
ApplicationInfo info, boolean knownToBeDead, int intentFlags,
HostingRecord hostingRecord, int zygotePolicyFlags, boolean allowWhileBooting,
boolean isolated, boolean keepIfLarge) {
//mProcessList是一个ProcessList对象
return mProcessList.startProcessLocked(processName, info, knownToBeDead, intentFlags,
hostingRecord, zygotePolicyFlags, allowWhileBooting, isolated, 0 /* isolatedUid */,
keepIfLarge, null /* ABI override */, null /* entryPoint */,
null /* entryPointArgs */, null /* crashHandler */);
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ProcessList.java


boolean startProcessLocked(ProcessRecord app, HostingRecord hostingRecord,
int zygotePolicyFlags, boolean disableHiddenApiChecks, boolean disableTestApiChecks,
boolean mountExtStorageFull, String abiOverride) {
····
//新进程启动的入口类,即是android.app.ActivityThread类的main方法
final String entryPoint = "android.app.ActivityThread";
return startProcessLocked(hostingRecord, entryPoint, app, uid, gids,
runtimeFlags, zygotePolicyFlags, mountExternal, seInfo, requiredAbi,
instructionSet, invokeWith, startTime);
····
}

boolean startProcessLocked(HostingRecord hostingRecord, String entryPoint, ProcessRecord app,
int uid, int[] gids, int runtimeFlags, int zygotePolicyFlags, int mountExternal,
String seInfo, String requiredAbi, String instructionSet, String invokeWith,
long startTime) {
····
final Process.ProcessStartResult startResult = startProcess(hostingRecord,
entryPoint, app,
uid, gids, runtimeFlags, zygotePolicyFlags, mountExternal, seInfo,
requiredAbi, instructionSet, invokeWith, startTime);
····
}


private Process.ProcessStartResult startProcess(HostingRecord hostingRecord, String entryPoint,
ProcessRecord app, int uid, int[] gids, int runtimeFlags, int zygotePolicyFlags,
int mountExternal, String seInfo, String requiredAbi, String instructionSet,
String invokeWith, long startTime) {
····
//SystemServer进程与Zygote进程通信是通过LocalSocket和LocalServerSocket进行通信,并非是Binder
startResult = Process.start(entryPoint,
app.processName, uid, uid, gids, runtimeFlags, mountExternal,
app.info.targetSdkVersion, seInfo, requiredAbi, instructionSet,
app.info.dataDir, invokeWith, app.info.packageName, zygotePolicyFlags,
isTopApp, app.mDisabledCompatChanges, pkgDataInfoMap,
whitelistedAppDataInfoMap, bindMountAppsData, bindMountAppStorageDirs,
new String[]{PROC_START_SEQ_IDENT + app.startSeq});
····
return startResult;
····
}
复制代码

SystemServer进程与Zygote进程通信是通过LocalSocket和LocalServerSocket进行通信,并非是Binder,最终Zygote进程启动好新进程则会调用android.app.ActivityThread类的main方法,此时新进程启动完成


b、Zygote进程与新启动进程通信流程

新进程启动完会调用android.app.ActivityThread类的main方法
frameworks/base/core/java/android/app/ActivityThread.java


public static void main(String[] args) {
····
Looper.prepareMainLooper();
····
//创建一个ActivityThread,同时创建一个ApplicationThread的binder对象,用于跨进程通讯
ActivityThread thread = new ActivityThread();
//启动一个Application
thread.attach(false, startSeq);
····
Looper.loop();
····
}

private void attach(boolean system, long startSeq) {
····
final IActivityManager mgr = ActivityManager.getService();
try {
//跨进程调用AMS的attachApplication方法
mgr.attachApplication(mAppThread, startSeq);
} catch (RemoteException ex) {
throw ex.rethrowFromSystemServer();
}
····
}
复制代码

c、新启动进程与SystemServer进程(AMS)通信流程

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public final void attachApplication(IApplicationThread thread, long startSeq) {
····
synchronized (this) {
····
attachApplicationLocked(thread, callingPid, callingUid, startSeq);
····
}
}

private boolean attachApplicationLocked(@NonNull IApplicationThread thread,
int pid, int callingUid, long startSeq) {
·····
if (!badApp) {
try {
//启动服务,mServices为ActiveServices对象
didSomething |= mServices.attachApplicationLocked(app, processName);
·····
} catch (Exception e) {
·····
}
}
·····
return true;
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


boolean attachApplicationLocked(ProcessRecord proc, String processName)
throws RemoteException {
····
//此时进程已经启动,启动服务
realStartServiceLocked(sr, proc, sr.createdFromFg);
····
return didSomething;
}
复制代码

realStartServiceLocked方法的执行源码流程,可以参考(1)和(2),realStartServiceLocked方法中主要是启动ANR超时任务监测,以及是启动服务还是绑定服务的生命周期调用分配,这样两种不同形式的服务独自执行各自的生命周期


(4)、服务启动超时(ANR)监测处理流程流程


Service生命周期的执行,最终通过ActiveServices对象的realStartServiceLocked方法去调配,此时也是加入了超时任务监测(ANR)


a、启动Server超时任务

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg) throws RemoteException {
····
//启动超时定时ANR任务
bumpServiceExecutingLocked(r, execInFg, "create");
//接下来就是启动Server一系列操作,或者计算在Server中的onCreate()生命周期方法是否有耗时操作
····
try {
····
//进入应用进程启动,移除超时定时ANR任务操作也在此处
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackage(r.serviceInfo.applicationInfo),
app.getReportedProcState());
····
//完成启动
created = true;
} catch (DeadObjectException e) {
····
}
···
}

private final void bumpServiceExecutingLocked(ServiceRecord r, boolean fg, String why) {
····
//开启超时任务
scheduleServiceTimeoutLocked(r.app);
···
}

void scheduleServiceTimeoutLocked(ProcessRecord proc) {
····
//通过Handler发送一个定时任务,当时间到没有移除这个任务,则认为是超时
Message msg = mAm.mHandler.obtainMessage(
ActivityManagerService.SERVICE_TIMEOUT_MSG);
msg.obj = proc;
mAm.mHandler.sendMessageDelayed(msg,
proc.execServicesFg ? SERVICE_TIMEOUT : SERVICE_BACKGROUND_TIMEOUT);
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


final class MainHandler extends Handler {
····
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
····
//当Server超时的时候,回调到此处,准备弹ANR窗口
case SERVICE_TIMEOUT_MSG: {
mServices.serviceTimeout((ProcessRecord)msg.obj);
} break;
···
//当内容提供者超时,回调到此处,准备弹ANR窗口
case CONTENT_PROVIDER_PUBLISH_TIMEOUT_MSG: {
ProcessRecord app = (ProcessRecord)msg.obj;
synchronized (ActivityManagerService.this) {
processContentProviderPublishTimedOutLocked(app);
}
} break;
····
}
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


//前台server超时时间
static final int SERVICE_TIMEOUT = 20 * 1000 * Build.HW_TIMEOUT_MULTIPLIER;
//后台server超时时间
static final int SERVICE_BACKGROUND_TIMEOUT = SERVICE_TIMEOUT * 10;

void serviceTimeout(ProcessRecord proc) {
String anrMessage = null;
synchronized(mAm) {
····
if (timeout != null && mAm.mProcessList.mLruProcesses.contains(proc)) {
····
//超时后来到此处anrMessage赋值
anrMessage = "executing service " + timeout.shortInstanceName;
} else {
//初始化定时,设定超时时间
Message msg = mAm.mHandler.obtainMessage(
ActivityManagerService.SERVICE_TIMEOUT_MSG);
msg.obj = proc;
mAm.mHandler.sendMessageAtTime(msg, proc.execServicesFg
? (nextTime+SERVICE_TIMEOUT) : (nextTime + SERVICE_BACKGROUND_TIMEOUT));
}
}
//当时间达到了server超时时间,则进入此处
if (anrMessage != null) {
//最终通过此处处理ANR
mAm.mAnrHelper.appNotResponding(proc, anrMessage);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/AnrHelper.java


class AnrHelper {
private static class AnrRecord {
····
final ProcessRecord mApp;
····
void appNotResponding(boolean onlyDumpSelf) {
//通过一系列操作最终调用ProcessRecord对象的appNotResponding方法
mApp.appNotResponding(mActivityShortComponentName, mAppInfo,
mParentShortComponentName, mParentProcess, mAboveSystem, mAnnotation,
onlyDumpSelf);
}
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ProcessRecord.java


void appNotResponding(String activityShortComponentName, ApplicationInfo aInfo,
String parentShortComponentName, WindowProcessController parentProcess,
boolean aboveSystem, String annotation, boolean onlyDumpSelf) {
····
synchronized (mService) {
····
//mService对应的是ActivityManagerService对象
if (mService.mUiHandler != null) {
// Bring up the infamous App Not Responding dialog
Message msg = Message.obtain();
msg.what = ActivityManagerService.SHOW_NOT_RESPONDING_UI_MSG;
msg.obj = new AppNotRespondingDialog.Data(this, aInfo, aboveSystem);
//向UiHandler发送一条数据
mService.mUiHandler.sendMessage(msg);
}
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


final class UiHandler extends Handler {
···
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
····
case SHOW_NOT_RESPONDING_UI_MSG: {
//同Handler机制回调到此处弹起ANR窗口
mAppErrors.handleShowAnrUi(msg);
ensureBootCompleted();
} break;
·····
}
}
}
复制代码

b、移除Server超时任务

frameworks/base/core/java/android/app/ActivityThread.java


public final void scheduleCreateService(IBinder token,
ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
updateProcessState(processState, false);
CreateServiceData s = new CreateServiceData();
s.token = token;
s.info = info;
s.compatInfo = compatInfo;
//向主线程发送一条消息
sendMessage(H.CREATE_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case CREATE_SERVICE:
····
//主线程处理Server启动
handleCreateService((CreateServiceData) msg.obj);
····
break;
····
}
}
}

private void handleCreateService(CreateServiceData data) {
····
try {
····
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//调用Server生命周期方法
service.onCreate();
···
try {
//调用AMS的serviceDoneExecuting方法移除Server超时监测任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
} catch (Exception e) {
····
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public void serviceDoneExecuting(IBinder token, int type, int startId, int res) {
synchronized(this) {
//mServices对应的是ActiveServices对象,移除超时任务
mServices.serviceDoneExecutingLocked((ServiceRecord)token, type, startId, res);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


void serviceDoneExecutingLocked(ServiceRecord r, int type, int startId, int res) {
····
if (r != null) {
····
//移除超时任务
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
····
}
···
}

private void serviceDoneExecutingLocked(ServiceRecord r, boolean inDestroying,
boolean finishing) {
····
if (r.executeNesting <= 0) {
if (r.app != null) {
···
if (r.app.executingServices.size() == 0) {
····
//向MainHandler移除SERVICE_TIMEOUT_MSG的超时任务
mAm.mHandler.removeMessages(ActivityManagerService.SERVICE_TIMEOUT_MSG, r.app);
}
····
}
····
}
}
复制代码


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

Android知识点之Service(二)

(2)、绑定服务流程(同进程) a、应用内调用到AMS过程 在Activity环境下调用bindService方法 frameworks/base/core/java/android/content/ContextWrapper.java public boo...
继续阅读 »
(2)、绑定服务流程(同进程)

a、应用内调用到AMS过程

在Activity环境下调用bindService方法
frameworks/base/core/java/android/content/ContextWrapper.java


public boolean bindService(Intent service, ServiceConnection conn,
int flags) {
//mBase就是Activity在启动时在ActivityThread中创建的ContextImpl.java对象
return mBase.bindService(service, conn, flags);
}
复制代码

frameworks/base/core/java/android/app/ContextImpl.java


public boolean bindService(Intent service, ServiceConnection conn, int flags) {
····
return bindServiceCommon(service, conn, flags, null, mMainThread.getHandler(), null,
getUser());
}

private boolean bindServiceCommon(Intent service, ServiceConnection conn, int flags,
String instanceName, Handler handler, Executor executor, UserHandle user) {
IServiceConnection sd;
····
//创建一个IServiceConnection的binder实现对象,内部封装ServiceConnection对象
//该对象传输到AMS所在进程中,AMS通过IServiceConnection的binder实现对象可以调用应用中IServiceConnection实现方法
if (mPackageInfo != null) {
//当在绑定服务调用bindService方法有传入Executor对象,则executor不为null
if (executor != null) {
sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(), executor, flags);
} else {
sd = mPackageInfo.getServiceDispatcher(conn, getOuterContext(), handler, flags);
}
}
····
try {
····
//通过Binder机制跨进程调用AMS的bindIsolatedService方法
//sd为IServiceConnection对象,是一个Binder实现类,通过封装ServiceConnection得到
//sd的作用也是跨进程通信,当通过bindIsolatedService方法进入AMS进程处理时,
//AMS通过需要绑定的服务的`public IBinder onBind(Intent intent)`方法获取到一个自定义的Binder对象实现类,
//通过sd的connected方法将这个自定义的Binder实现类回传到应用进程ServiceConnection类的onServiceConnected方法中
//这个自定义的Binder实现类可以跨进程通信,绑定的服务是不同进程的可以通过这个参数跨进程通信
int res = ActivityManager.getService().bindIsolatedService(
mMainThread.getApplicationThread(), getActivityToken(), service,
service.resolveTypeIfNeeded(getContentResolver()),
sd, flags, instanceName, getOpPackageName(), user.getIdentifier());
····
return res != 0;
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
复制代码

IServiceConnection的binder实现对象创建过程
frameworks/base/core/java/android/app/LoadedApk.java


public final IServiceConnection getServiceDispatcher(ServiceConnection c,
Context context, Handler handler, int flags) {
return getServiceDispatcherCommon(c, context, handler, null, flags);
}

public final IServiceConnection getServiceDispatcher(ServiceConnection c,
Context context, Executor executor, int flags) {
return getServiceDispatcherCommon(c, context, null, executor, flags);
}

private IServiceConnection getServiceDispatcherCommon(ServiceConnection c,
Context context, Handler handler, Executor executor, int flags) {
synchronized (mServices) {
·····
//当在绑定服务调用bindService方法有传入Executor对象,则executor不为null
if (executor != null) {
sd = new ServiceDispatcher(c, context, executor, flags);
} else {
sd = new ServiceDispatcher(c, context, handler, flags);
}
····
return sd.getIServiceConnection();
}
}

static final class ServiceDispatcher {

private final ServiceDispatcher.InnerConnection mIServiceConnection;

//创建出IServiceConnection对象
private static class InnerConnection extends IServiceConnection.Stub {
····
}

ServiceDispatcher(ServiceConnection conn,
Context context, Handler activityThread, int flags) {
mIServiceConnection = new InnerConnection(this);
····
}

ServiceDispatcher(ServiceConnection conn,
Context context, Executor activityExecutor, int flags) {
mIServiceConnection = new InnerConnection(this);
····
}

IServiceConnection getIServiceConnection() {
return mIServiceConnection;
}
}
复制代码

b、AMS处理以及调用ActivityThread过程

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public int bindIsolatedService(IApplicationThread caller, IBinder token, Intent service,
String resolvedType, IServiceConnection connection, int flags, String instanceName,
String callingPackage, int userId) throws TransactionTooLargeException {
····
synchronized(this) {
//mServices对应的是ActiveServices.java对象
//caller为ActivityThread中的ApplicationThread的Binder机制实现类,用于与应用进程通信
//connection为IServiceConnection的Binder机制实现类,用于与应用进程通信
return mServices.bindServiceLocked(caller, token, service,
resolvedType, connection, flags, instanceName, callingPackage, userId);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


int bindServiceLocked(IApplicationThread caller, IBinder token, Intent service,
String resolvedType, final IServiceConnection connection, int flags,
String instanceName, String callingPackage, final int userId)
throws TransactionTooLargeException {
····
//flags在绑定过程的时候传的是Context.BIND_AUTO_CREATE,即是1
if ((flags & Context.BIND_AUTO_CREATE) != 0) {
s.lastActivity = SystemClock.uptimeMillis();
//启动服务
if (bringUpServiceLocked(s, service.getFlags(), callerFg, false,
permissionsReviewRequired) != null) {
return 0;
}
}
····
return 1;
}

private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
····
final boolean isolated = (r.serviceInfo.flags&ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
····
ProcessRecord app;
//判断启动的服务进程是否存在
if (!isolated) {
app = mAm.getProcessRecordLocked(procName, r.appInfo.uid, false);
···
if (app != null && app.thread != null) {
try {
····
//存在则直接启动服务
realStartServiceLocked(r, app, execInFg);
return null;
}
···
}
} else {
//启动的服务进程不存在,需要创建HostingRecord辅助开启创建进程
if ((r.serviceInfo.flags & ServiceInfo.FLAG_USE_APP_ZYGOTE) != 0) {
hostingRecord = HostingRecord.byAppZygote(r.instanceName, r.definingPackageName,
r.definingUid);
}
}
//启动的服务进程不存在,创建一个新的进程
if (app == null && !permissionsReviewRequired) {
//启动一个新进程
if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
hostingRecord, ZYGOTE_POLICY_FLAG_EMPTY, false, isolated, false)) == null) {
····
}
····
}
····
return null;
}

private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg) throws RemoteException {
····
//启动ANR弹窗任务,当启动前台服务超过20s或者后台服务超过200s时,会弹ANR系统窗口提示应用无响应
bumpServiceExecutingLocked(r, execInFg, "create");
····
try {
····
//跨进程调用应用ActivityThread对象,进而调用service的onCreate生命周期方法
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackage(r.serviceInfo.applicationInfo),
app.getReportedProcState());
····
created = true;
} catch (DeadObjectException e) {
····
} finally {
//启动出现异常,那么ANR弹窗任务需要移除掉
if (!created) {
····
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
····
}
}
···
//如果是绑定服务,那么需要把自定义的Binder对象通过IServiceConnection回传
requestServiceBindingsLocked(r, execInFg);
···
}
复制代码

绑定服务是有自定义的Binder对象,这个需要通过AMS回传到应用进程中,也就是回传到ActivityThread里,看到requestServiceBindingsLocked方法的操作
frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


private final void requestServiceBindingsLocked(ServiceRecord r, boolean execInFg)
throws TransactionTooLargeException {
for (int i=r.bindings.size()-1; i>=0; i--) {
//只有是绑定服务当前IntentBindRecord对象才有具体绑定的设置值
IntentBindRecord ibr = r.bindings.valueAt(i);
if (!requestServiceBindingLocked(r, ibr, execInFg, false)) {
break;
}
}
}

private final boolean requestServiceBindingLocked(ServiceRecord r, IntentBindRecord i,
boolean execInFg, boolean rebind) throws TransactionTooLargeException {
····
//防止重复绑定判断
if ((!i.requested || rebind) && i.apps.size() > 0) {
try {
···
//调用应用进程ActivityThread中的ApplicatonThread的scheduleBindService方法,进行数据回传
r.app.thread.scheduleBindService(r, i.intent.getIntent(), rebind,
r.app.getReportedProcState());
//防止重复绑定的限制
if (!rebind) {
i.requested = true;
}
····
} catch (TransactionTooLargeException e) {
····
//绑定出现异常,那么ANR弹窗任务需要移除掉
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
throw e;
} catch (RemoteException e) {
···
//绑定出现异常,那么ANR弹窗任务需要移除掉
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
return false;
}
}
return true;
}
复制代码

c、应用ActivityThread处理过程

service的onCreate生命周期方法流程
frameworks/base/core/java/android/app/ActivityThread.java


public final void scheduleCreateService(IBinder token,
ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
updateProcessState(processState, false);
CreateServiceData s = new CreateServiceData();
s.token = token;
s.info = info;
s.compatInfo = compatInfo;
//向主线程发送一条消息
sendMessage(H.CREATE_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case CREATE_SERVICE:
····
//主线程处理Server启动
handleCreateService((CreateServiceData) msg.obj);
····
break;
····
}
}
}

private void handleCreateService(CreateServiceData data) {
····
try {
····
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//调用Server生命周期方法
service.onCreate();
····
try {
//调用AMS的serviceDoneExecuting方法移除Server超时监测任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
} catch (Exception e) {
····
}
}
复制代码

绑定service的onBind或者onRebind生命周期方法流程
frameworks/base/core/java/android/app/ActivityThread.java


public final void scheduleBindService(IBinder token, Intent intent,
boolean rebind, int processState) {
updateProcessState(processState, false);
BindServiceData s = new BindServiceData();
s.token = token;
s.intent = intent;
s.rebind = rebind;
....
//向主线程发送一条消息
sendMessage(H.BIND_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case BIND_SERVICE:
···
handleBindService((BindServiceData)msg.obj);
···
break;
····
}
}
}

private void handleBindService(BindServiceData data) {
Service s = mServices.get(data.token);
····
if (s != null) {
try {
····
try {
if (!data.rebind) {
//获取到服务自定义的Binder对象,也是调用onBind生命周期方法
IBinder binder = s.onBind(data.intent);
//调用AMS所在进程的publishService方法进一步处理
ActivityManager.getService().publishService(
data.token, data.intent, binder);
} else {
//重复绑定时执行的方法,代表已经绑定过
//调用onRebind生命周期方法
s.onRebind(data.intent);
//调用AMS所在进程的serviceDoneExecuting方法移除ANR任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
}
}
····
} catch (Exception e) {
····
}
}
}
复制代码

d、AMS继续处理绑定服务内的Binder实现类流程

此过程的目的是将绑定的服务内部onBind方法自定义的Binder实现对象回传到应用进程ServiceConnection类的onServiceConnected方法中
frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java


public void publishService(IBinder token, Intent intent, IBinder service) {
····
synchronized(this) {
····
//service是绑定服务内部onBind方法自定义的Binder实现类
//token是ActiveServices中创建的Binder实现类
mServices.publishServiceLocked((ServiceRecord)token, intent, service);
}
}
复制代码

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java


void publishServiceLocked(ServiceRecord r, Intent intent, IBinder service) {
···
try {
····
if (r != null) {
····
if (b != null && !b.received) {
····
for (int conni = connections.size() - 1; conni >= 0; conni--) {
ArrayList<ConnectionRecord> clist = connections.valueAt(conni);
for (int i=0; i<clist.size(); i++) {
····
try {
//调用到应用层注册的IServiceConnection的binder实现对象
//IServiceConnection是在应用层的LoadedApk.java对象内部类ServiceDispatcher中的内部类InnerConnection实现的
//InnerConnection对象继承了IServiceConnection.Stub
c.conn.connected(r.name, service, false);
}
····
}
}
}
//移除ANR超时任务
serviceDoneExecutingLocked(r, mDestroyingServices.contains(r), false);
}
}
····
}
复制代码

e、应用进程处理绑定服务内的Binder实现类流程

frameworks/base/core/java/android/app/LoadedApk.java


static final class ServiceDispatcher {
//跨进程通信Binder实现类
private static class InnerConnection extends IServiceConnection.Stub {

····

//AMS所在的进程调用此方法把绑定服务内部onBind中实现的Binder对象回调到ServiceConnection注册者
public void connected(ComponentName name, IBinder service, boolean dead)
throws RemoteException {
LoadedApk.ServiceDispatcher sd = mDispatcher.get();
if (sd != null) {
sd.connected(name, service, dead);
}
}
}

public void connected(ComponentName name, IBinder service, boolean dead) {
//如果在应用绑定服务在bindService方法中有传Executor对象这个参数,那么mActivityExecutor就不为null
if (mActivityExecutor != null) {
mActivityExecutor.execute(new RunConnection(name, service, 0, dead));
} else if (mActivityThread != null) {
///如果在应用绑定服务在bindService方法中没有传Executor对象这个参数,
// 那么会通过mMainThread.getHandler()获取到ActivityThread中的H对象,此时mActivityThread不为null
mActivityThread.post(new RunConnection(name, service, 0, dead));
} else {
//基本不会调用到这里
doConnected(name, service, dead);
}
}

//定义一个
private final class RunConnection implements Runnable {
···
public void run() {
···
//调用此方法进一步处理
doConnected(mName, mService, mDead);
····
}
····
}

public void doConnected(ComponentName name, IBinder service, boolean dead) {
·····
//mConnection对应ServiceConnection对象,最终通过onServiceConnected将绑定服务的内部Binder对象返回给注册者
if (service != null) {
mConnection.onServiceConnected(name, service);
} else {
// The binding machinery worked, but the remote returned null from onBind().
mConnection.onNullBinding(name);
}
}
}
复制代码


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

Android知识点之Service(一)

1、service 启动流程(Android 11)(1)、启动服务流程(同进程)a、应用内调用到AMS过程在Activity环境下调用startService方法 frameworks/base/core/java/android/content/Conte...
继续阅读 »


1、service 启动流程(Android 11)

(1)、启动服务流程(同进程)

a、应用内调用到AMS过程

在Activity环境下调用startService方法 frameworks/base/core/java/android/content/ContextWrapper.java

public ComponentName startService(Intent service) {
//mBase就是Activity在启动时在ActivityThread中创建的ContextImpl.java对象
return mBase.startService(service);
}

frameworks/base/core/java/android/app/ContextImpl.java

public ComponentName startService(Intent service) {
····
return startServiceCommon(service, false, mUser);
}

private ComponentName startServiceCommon(Intent service, boolean requireForeground,
UserHandle user) {
try {
····
//通过Binder机制跨进程调用AMS的startService方法
ComponentName cn = ActivityManager.getService().startService(
mMainThread.getApplicationThread(), service,
service.resolveTypeIfNeeded(getContentResolver()), requireForeground,
getOpPackageName(), getAttributionTag(), user.getIdentifier());
····
return cn;
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}

frameworks/base/core/java/android/app/ActivityManager.java

public static IActivityManager getService() {
return IActivityManagerSingleton.get();
}

@UnsupportedAppUsage
private static final Singleton<IActivityManager> IActivityManagerSingleton =
new Singleton<IActivityManager>() {
@Override
protected IActivityManager create() {
//IActivityManager是Binder机制实现接口类
//ServiceManager是缓存Binder的实现对象,通过getService()可以获取到跨进程通信的Binder实现对象
//例如WMS、PMS等都可以通过这个ServiceManager对象获取到
final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
final IActivityManager am = IActivityManager.Stub.asInterface(b);
return am;
}
};

至此,启动服务就进入了SystemServer进程继续工作

b、AMS处理以及调用ActivityThread过程

frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java

public ComponentName startService(IApplicationThread caller, Intent service,
String resolvedType, boolean requireForeground, String callingPackage,
String callingFeatureId, int userId)
throws TransactionTooLargeException {
····
synchronized(this) {
···
ComponentName res;
try {
//mServices对应的是ActiveServices.java对象
//caller为ActivityThread中的ApplicationThread的Binder机制实现类,用于与应用进程通信
res = mServices.startServiceLocked(caller, service,
resolvedType, callingPid, callingUid,
requireForeground, callingPackage, callingFeatureId, userId);
}
····
return res;
}
}

frameworks/base/services/core/java/com/android/server/am/ActiveServices.java

ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
int callingPid, int callingUid, boolean fgRequired, String callingPackage,
@Nullable String callingFeatureId, final int userId)
throws TransactionTooLargeException {
return startServiceLocked(caller, service, resolvedType, callingPid, callingUid, fgRequired,
callingPackage, callingFeatureId, userId, false);
}

ComponentName startServiceLocked(IApplicationThread caller, Intent service, String resolvedType,
int callingPid, int callingUid, boolean fgRequired, String callingPackage,
@Nullable String callingFeatureId, final int userId,
boolean allowBackgroundActivityStarts) throws TransactionTooLargeException {
····
//一切准备就绪,调用此处来启动服务
ComponentName cmp = startServiceInnerLocked(smap, service, r, callerFg, addToStarting);
····
return cmp;
}

ComponentName startServiceInnerLocked(ServiceMap smap, Intent service, ServiceRecord r,
boolean callerFg, boolean addToStarting) throws TransactionTooLargeException {
····
//启动服务
String error = bringUpServiceLocked(r, service.getFlags(), callerFg, false, false);
····
return r.name;
}

private String bringUpServiceLocked(ServiceRecord r, int intentFlags, boolean execInFg,
boolean whileRestarting, boolean permissionsReviewRequired)
throws TransactionTooLargeException {
····
final boolean isolated = (r.serviceInfo.flags&ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
····
ProcessRecord app;
//判断启动的服务进程是否存在
if (!isolated) {
app = mAm.getProcessRecordLocked(procName, r.appInfo.uid, false);
···
if (app != null && app.thread != null) {
try {
····
//存在则直接启动服务
realStartServiceLocked(r, app, execInFg);
return null;
}
···
}
} else {
//启动的服务进程不存在,需要创建HostingRecord辅助开启创建进程
if ((r.serviceInfo.flags & ServiceInfo.FLAG_USE_APP_ZYGOTE) != 0) {
hostingRecord = HostingRecord.byAppZygote(r.instanceName, r.definingPackageName,
r.definingUid);
}
}
//启动的服务进程不存在,创建一个新的进程
if (app == null && !permissionsReviewRequired) {
//启动一个新进程
if ((app=mAm.startProcessLocked(procName, r.appInfo, true, intentFlags,
hostingRecord, ZYGOTE_POLICY_FLAG_EMPTY, false, isolated, false)) == null) {
····
}
····
}
····
return null;
}

private final void realStartServiceLocked(ServiceRecord r,
ProcessRecord app, boolean execInFg) throws RemoteException {
····
//启动ANR弹窗任务,当启动前台服务超过20s或者后台服务超过200s时,会弹ANR系统窗口提示应用无响应
bumpServiceExecutingLocked(r, execInFg, "create");
····
try {
····
//跨进程调用应用ActivityThread对象,进而调用service的onCreate生命周期方法
app.thread.scheduleCreateService(r, r.serviceInfo,
mAm.compatibilityInfoForPackage(r.serviceInfo.applicationInfo),
app.getReportedProcState());
····
created = true;
} catch (DeadObjectException e) {
····
} finally {
//启动出现异常,那么ANR弹窗任务需要移除掉
if (!created) {
····
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
····
}
}
//跨进程调用应用ActivityThread对象,进而调用service的onStartCommand生命周期方法
//绑定服务在这个方法中会限制调用,不会调用service的onStartCommand生命周期方法
sendServiceArgsLocked(r, execInFg, true);
····
}

private final void sendServiceArgsLocked(ServiceRecord r, boolean execInFg,
boolean oomAdjusted) throws TransactionTooLargeException {
····
while (r.pendingStarts.size() > 0) {
···
//启动ANR弹窗任务,当启动前台服务超过20s或者后台服务超过200s时,会弹ANR系统窗口提示应用无响应
bumpServiceExecutingLocked(r, execInFg, "start");
···
}
····
try {
//通过跨进程调用应用进程的ActivityThread的ApplicationThread中的scheduleServiceArgs启动服务
r.app.thread.scheduleServiceArgs(r, slice);
} catch (TransactionTooLargeException e) {
···
caughtException = e;
} catch (RemoteException e) {
····
caughtException = e;
} catch (Exception e) {
····
caughtException = e;
}
if (caughtException != null) {
···
for (int i = 0; i < args.size(); i++) {
//启动出现异常,那么ANR弹窗任务需要移除掉
serviceDoneExecutingLocked(r, inDestroying, inDestroying);
}
···
}
}

service启动在SystemServer进程需要判断是否需要创建新进程,还需要监测service启动的超时时间,除此之后还需要判断权限等等,最终通过Binder机制调用应用进程方法调用service生命周期方法

c、应用ActivityThread处理过程

service的onCreate生命周期方法流程 frameworks/base/core/java/android/app/ActivityThread.java

public final void scheduleCreateService(IBinder token,
ServiceInfo info, CompatibilityInfo compatInfo, int processState) {
updateProcessState(processState, false);
CreateServiceData s = new CreateServiceData();
s.token = token;
s.info = info;
s.compatInfo = compatInfo;
//向主线程发送一条消息
sendMessage(H.CREATE_SERVICE, s);
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case CREATE_SERVICE:
····
//主线程处理Server启动
handleCreateService((CreateServiceData) msg.obj);
····
break;
····
}
}
}

private void handleCreateService(CreateServiceData data) {
····
try {
····
service.attach(context, this, data.info.name, data.token, app,
ActivityManager.getService());
//调用Server生命周期方法
service.onCreate();
····
try {
//调用AMS的serviceDoneExecuting方法移除Server超时监测任务
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_ANON, 0, 0);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
} catch (Exception e) {
····
}
}

service的onStartCommand生命周期方法流程 frameworks/base/core/java/android/app/ActivityThread.java

public final void scheduleServiceArgs(IBinder token, ParceledListSlice args) {
List<ServiceStartArgs> list = args.getList();
for (int i = 0; i < list.size(); i++) {
ServiceStartArgs ssa = list.get(i);
ServiceArgsData s = new ServiceArgsData();
s.token = token;
s.taskRemoved = ssa.taskRemoved;
s.startId = ssa.startId;
s.flags = ssa.flags;
s.args = ssa.args;
//向主线程发送一条消息
sendMessage(H.SERVICE_ARGS, s);
}
}

class H extends Handler {
public void handleMessage(Message msg) {
····
switch (msg.what) {
····
case SERVICE_ARGS:
····
//处理服务启动
handleServiceArgs((ServiceArgsData) msg.obj);
····
break;
····
}
}
}

private void handleServiceArgs(ServiceArgsData data) {
Service s = mServices.get(data.token);
if (s != null) {
try {
····
if (!data.taskRemoved) {
//调用service的onStartCommand生命周期方法
res = s.onStartCommand(data.args, data.flags, data.startId);
}
····
try {
//跨进程调用AMS方法将ANR任务移除
ActivityManager.getService().serviceDoneExecuting(
data.token, SERVICE_DONE_EXECUTING_START, data.startId, res);
}
···
} catch (Exception e) {
····
}
}
}

收起阅读 »

Xcode 12 使用技巧

iOS
1 class成员构造函数生成Swift 可以为 struct 合成成员构造函数,但不能为 class 合成。Xcode 可以帮助生成代码,先选中类名,然后选择菜单 Editor —> Refactor —> Generate Memberwise...
继续阅读 »

1 class成员构造函数生成

Swift 可以为 struct 合成成员构造函数,但不能为 class 合成。Xcode 可以帮助生成代码,先选中类名,然后选择菜单 Editor —> Refactor —> Generate Memberwise Initializer。

2 设置App的“外观”

运行 App 到模拟器以后可以找到环境面板,点开它可以设置 Dynamic Type size, 暗黑模式等以查看 App 的变化。

3 选中代码块

选择某个代码块的左侧括号{,然后双击。

4 检查拼写错误

选择 Edit —> Format —> Spelling and Grammar —> Check Spelling While Typing,将检查代码是否有错别字。

5 修复多个错误

程序出现多个错误时,可以选择 Editor —> Fix All Issues 修复多个错误。

6 搜索查看

在 Find navigator 面板里搜索某个内容时,如果出现多个结果,在使用完一个结果时可以使用 Backspace 剔除该结果,这样剩下的都是未操作过的搜索结果。

7 Canvas切换

Canvas 暂停时,按 Opt+Cmd+P 恢复预览。也使用 Opt+Cmd+Return 来完全隐藏画布。

8 模拟器分屏

选中模拟器,进入 Window 菜单,选择 Tile Window To Right Of Screen,然后选择左边的 Xcode 进行屏幕空间分割调整,这样模拟器就一直在右边显示。

9 代码提示宽度

当代码提示出现以后,如果某个方法特别长,可以选中提示面板的边缘,并将其拖动到想要的宽度。

10 快速添加断点

使用 Cmd+\ 在当前行上添加或删除断点。 

11 测试顺序

有时一个测试的输出会影响另一个测试的输入。此时可以进入 Product 菜单,按住 Option,然后点击 Test。在 Info 选项卡中,单击 Options,然后选中 Randomize Execution Order,这样进行测试时每次都会以不同的顺序运行。

12 筛选方法和设备

可以使用 Ctrl+6 快速查看当前文件的方法列表,列表出现以后可以直接输入过滤信息进行方法的筛选,这个操作方式也可以用于模拟器的过滤筛选。

13 查看interface

按住 Ctrl+Cmd+↑,会生成当前文件的 interface,显示当前文件的属性、函数签名和注释。如果存在该文件的测试文件,可以再按一次就会跳转到测试文件。

14 快速补齐文档注释

在某个方法上按住 Option+Cmd+/ 就会生成文档注释。

15 快速查找文件

  • 选中项目或者文件夹,右击选择 Sort By Name,此时文件就会按照 A-Z 的顺序排序。
  • 项目文件的最下方法,有个过滤框,可以输入关键字进行查找。

16 代码变化提醒

Xcode 偏好设置 —> Source Control —> 勾选 Show Source Control changes,然后进行代码的修改,在修改代码的左边会看到一个蓝色的条状提醒,点击它点并选择 Show Change,就会同时显示新旧代码。

17 使用minimap

在浏览长代码时,可以通过 Editor —> Minimap 调出 minimap,方便查看代码。

18 运行最后一次测试

编写失败的测试很常见,Xcode 有一个快捷键可以只运行最后一个测试:Ctrl+Opt+Cmd+G。

19 修改快捷键

Xcode 偏好设置 —> Key Bindings,然后根据需要搜索和修改。

20 查找选项

Show the Find navigator 界面,每个菜单都可以通过点击弹出更多选项,合理搭配可以提高查找的效率。比如可以点击放大镜查看最近的搜索。

21 粘贴代码格式化

有时候从别的地方粘贴代码到项目中时缩进不对,可以使用 Ctrl+I 进行格式化。

22 内购测试

可以在没有 App Store Connect 的情况下测试应用内购买。创建一个新的 StoreKit Config 文件,并添加 IAP。然后进入菜单 Product,按住 Option 然后点击 Run,在弹出窗口的 Options 选项卡中,更改 StoreKit Configuration 为添加的 StoreKit Config 文件,就可以测试添加的 IAP。

23 查看Build Settings含义

一般很难记住 Build Settings 的作用,可以选择其中一项使用 Quick Help 检查器查看大多数 Settings 的文档,或者按住 Option 并双击以获得内联帮助。

24 多文件Canvas预览

当一个视图被分割成不同文件时,Canvas 预览起来有点困难,此时在预览界面,使用底部的图钉来保持当前预览的活动状态,这样可以在预览一个文件的同时更改另一个文件并能及时反馈到预览里。

收起阅读 »

iOS - 数据存储

iOS
Bundle简单理解就是资源文件包,会将许多图片、xib、文本文件组织在一起,打包成一个 Bundle 文件,这样可以在其他项目中引用包内的资源。// 获取当前项目的Bundle let bundle = Bundle.main // 加载资源 let mp...
继续阅读 »

Bundle

简单理解就是资源文件包,会将许多图片、xib、文本文件组织在一起,打包成一个 Bundle 文件,这样可以在其他项目中引用包内的资源。

// 获取当前项目的Bundle
let bundle = Bundle.main

// 加载资源
let mp3 = Bundle.main.path(forResource: "xxx", ofType: "mp3")

沙盒

每一个 App 只能在自己的创建的文件系统(存储区域)中进行文件的操作,不能访问其他 App 的文件系统(存储区域),该文件系统(存储区域)被成为沙盒。所有的非代码文件都要保存在此,例如图像,图标,声音,plist,文本文件等。

沙盒机制保证了 App 的安全性,因为只能访问自己沙盒文件下的文件。

Home目录

沙盒的主目录,可以通过它查看沙盒目录的整体结构。

// 获取程序的Home目录
let homeDirectory = NSHomeDirectory()

Documents目录

保存应用程序运行时生成的持久化数据。可被iTunes备份,可备份到 iCloud。

// 方法1
let documentPaths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let documentPath = documentPaths[0]

// 方法2
let documentPath2 = NSHomeDirectory() + "/Documents"

上面的获取方式最后得到的是String,如果希望获取的是URL,可以通过下面的方式:

let manager = FileManager.default
let urlForDocument = manager.urls(for: .documentDirectory, in:.userDomainMask)
let url: URL = urlForDocument[0]

NSSearchPathForDirectoriesInDomains

  • 访问沙盒目录常用的函数,它返回值为一个数组,在 iOS 中由于只有一个唯一路径,所以直接取数组第一个元素即可。
func NSSearchPathForDirectoriesInDomains(
_ directory: FileManager.SearchPathDirectory,
_ domainMask: FileManager.SearchPathDomainMask,
_ expandTilde: Bool) -> [String]

  • directory:指定搜索的目录名称。
  • domainMask:搜索主目录的位置。userDomainMask 表示搜索的范围限制于当前应用的沙盒目录(参考定义注释)。
  • expandTilde:是否获取完整的路径。
let documentPaths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, false)
let documentPath = documentPaths[0] // ~/Documents

let documentPaths2 = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
let documentPath2 = documentPaths2[0] // /Users/yangfan/Library/Developer/XCPGDevices/982B6CBA-747B-4831-9D87-F82160197333/data/Containers/Data/Application/56C657D5-B36B-449D-AC6C-E2417EA65D00/Documents

Library目录

存储程序的默认设置和其他信息,其下有两个重要目录:

  • Library/Preferences 目录:包含应用程序的偏好设置文件。不应该直接创建偏好设置文件,而是应该使用UserDefaults类来取得和设置应用程序的偏好。
  • Library/Caches 目录:主要存放缓存文件,此目录下文件不会在应用退出时删除。
// Library目录-方法1
let libraryPaths = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true)
let libraryPath = libraryPaths[0]

// Library目录-方法2
let libraryPath2 = NSHomeDirectory() + "/Library"

// Cache目录-方法1
let cachePaths = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)
let cachePath = cachePaths[0]

// Cache目录-方法2
let cachePath2 = NSHomeDirectory() + "/Library/Caches"
  • tmp目录:存储临时文件,当在退出程序或设备重启时,文件会被清除。
// 方法1
let tmpDir = NSTemporaryDirectory()

// 方法2
let tmpDir2 = NSHomeDirectory() + "/tmp"

注意

每次编译代码会生成新的沙盒路径,所以模拟器运行同一个 App 时所得到的沙盒路径是不一样的,但上架的 App 在真机上运行不存在这种情况。

plist读写

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// 获取本地plist
let path = Bundle.main.path(forResource: "cityData", ofType: "plist")
if let path = path {
let root = NSDictionary(contentsOfFile: path) // 借助于NSDictionary
// print(root!.allKeys)
// print(root!.allKeys[31])
// 获取所有数据
let cities = root![root!.allKeys[31]] as! NSArray // 借助于NSArray
// print(cities)
// 沙盒路径
let documentDir = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
let filePath = documentDir! + "/localData.plist"
// 写入沙盒
cities.write(toFile: filePath, atomically: true)
}
}
}

偏好设置

  • 一般用于保存如用户名、密码、版本等轻量级数据。
  • 通过UserDefaults来设置和读取偏好设置。
  • 偏好设置以key-value的方式进行读写操作。
  • 默认情况下数据自动以plist形式存储在沙盒的Library/Preferences目录。

案例

  • 记住密码
class ViewController: UIViewController {
@IBOutlet weak var username: UITextField!
@IBOutlet weak var password: UITextField!
@IBOutlet weak var swit: UISwitch!
// UserDefaults
let userDefaults = UserDefaults.standard

override func viewDidLoad() {
super.viewDidLoad()

// 取出存储的数据
let name = userDefaults.string(forKey: "name")
let pwd = userDefaults.string(forKey: "pwd")
let isOn = userDefaults.standard.bool(forKey: "isOn")
// 填充输入框
username.text = name
password.text = pwd
// 设置开关状态
swit.isOn = isOn
}

@IBAction func login(_ sender: Any) {
print("密码已经记住")
}

@IBAction func remember(_ sender: Any) {
let swit = sender as! UISwitch
// 如果记住密码开关打开
if swit.isOn {
let name = username.text
let pwd = password.text
// 存储用户名和密码
userDefaults.set(name, forKey: "name")
userDefaults.set(pwd, forKey: "pwd")
// 同时存储开关的状态
userDefaults.set(swit.isOn, forKey: "isOn")
// 最后进行同步
userDefaults.synchronize()
}
}
}
  • 新特性界面
class SceneDelegate: UIResponder, UIWindowSceneDelegate { 
var window: UIWindow?
// 当前版本号
var currentVersion: Double!
// UserDefaults
let userDefaults = UserDefaults.standard

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
if isNewVersion {
// 新特性界面
let newVC = UIViewController()
newVC.view.backgroundColor = .green
window?.rootViewController = newVC
// 存储当前版本号
userDefaults.set(currentVersion, forKey: "localVersion")
userDefaults.synchronize()
} else {
// 主界面
let mainVC = UIViewController()
mainVC.view.backgroundColor = .red
window?.rootViewController = mainVC
}

window?.makeKeyAndVisible()
}
}

extension SceneDelegate {
// 是否新版本
private var isNewVersion: Bool {
// 获取当前版本号
currentVersion = Double(Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String)!
// 本地版本号
let localVersion = userDefaults.double(forKey: "localVersion")
// 比较大小
return currentVersion > localVersion
}
}

默认值

如果需要在使用时设置 UserDefaults 的默认值,可以使用register方法。

enum Keys: String {
case name // 名字
case isRem // 记住密码
}

// 设置默认值
UserDefaults.standard.register(defaults: [
Keys.name.rawValue: "UserA",
Keys.isRem.rawValue: false
])

注意:在设置默认值后如果修改了其中的属性值,即使再次执行register方法也不会重置。

跨域

一般情况下使用UserDefaults.standard没有太大问题,但当 App 足够复杂时就会产生几个问题:

  • 需要保证设置数据 key 具有唯一性,防止产生冲突。
  • 同一个 plist 文件越来越大造成的读写效率降低。
  • 无法便捷的清除特定的偏好设置数据。

因此还有另外一种获取 UserDefaults 对象的方法:UserDefaults(suiteName: String?),可以根据传入的 suiteName 参数进行处理:

  • 传入 nil:等同于UserDefaults.standard
  • 传入 App Groups 的 ID:操作共享目录中的 plist 文件,以便在跨 App 或宿主 App 与扩展应用之间(如 App 与 Widget)共享数据。
  • 传入其他值:操作Documents/Library/Preferences目录下以suiteName命名的 plist 文件。

可以通过如下的方式删除指定suiteName的 plist 文件里的全部数据。

let userDefaults = UserDefaults(suiteName: "abc")
userDefaults?.removePersistentDomain(forName: "abc")

归档与反归档

  • 归档(序列化)是把对象转为Data,反归档(反序列化)是从Data还原出对象。
  • 可以存储自定义数据。
  • 存储的数据需要继承自NSObject并遵循NSSecureCoding协议。

案例

  • 自定义对象
class Person: NSObject, NSSecureCoding {   
var name:String?
var age:Int?

override init() {
}

static var supportsSecureCoding: Bool = true

// 编码- 归档调用
func encode(with aCoder: NSCoder) {
aCoder.encode(age, forKey: "age")
aCoder.encode(name, forKey: "name")
}

// 解码-反归档调用
required init?(coder aDecoder: NSCoder) {
super.init()
age = aDecoder.decodeObject(forKey: "age") as? Int
name = aDecoder.decodeObject(forKey: "name") as? String
}
}
  • 归档与反归档
class ViewController: UIViewController {
var data: Data!
var origin: Person!

override func viewDidLoad() {
super.viewDidLoad()
}

// 归档
@IBAction func archiver(_ sender: Any) {
let p = Person()
p.age = 20
p.name = "zhangsan"

do {
try data = NSKeyedArchiver.archivedData(withRootObject: p, requiringSecureCoding: true)
} catch {
print(error)
}
}

// 反归档
@IBAction func unarchiver(_ sender: Any) {
do {
try origin = NSKeyedUnarchiver.unarchivedObject(ofClass: Person.self, from: data)
print(origin!.age!)
print(origin!.name!)
} catch {
print(error)
}
}
}

数据库—sqlite3

由于 Swift 直接操作 sqlite3 非常不方便,所以借助于SQLite.swift的框架。

  • Model
struct Person {    
var name : String = ""
var phone : String = ""
var address : String = ""
}
  • DBTools
import SQLite

struct DBTools {
// 数据库路径
let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first! + "/person.db"
// 数据库连接
var db: Connection!
// 表名与字段
let personTable = Table("t_person") // 表名
let personID = Expression<Int>("id") // id
let personName = Expression<String>("name") // name
let personPhone = Expression<String>("phone") // phone
let personAddress = Expression<String>("address") // address

// MARK: - 构造函数,数据库有则连接 没有就创建后连接
init() {
do {
db = try Connection(dbPath)
print("数据库创建/连接成功")
} catch {
print("数据库创建/连接失败")
}
}

// MARK: - 创建表格,表若存在不会再次创建,直接进入catch
func createTable() {
// 创表
do {
try db.run(personTable.create(block: { t in
t.column(personID, primaryKey: .autoincrement)
t.column(personName)
t.column(personPhone)
t.column(personAddress)
}))
print("数据表创建成功")
} catch {
print("数据表创建失败")
}
}

// MARK: - 插入数据
func insertPerson(person: Person) {
let insert = personTable.insert(personName <- person.name, personPhone <- person.phone, personAddress <- person.address)
// 插入
do {
try db.run(insert)
print("插入数据成功")
} catch {
print("插入数据失败")
}
}

// MARK: - 删除数据
func deletePerson(name: String) {
// 筛选数据
let p = personTable.filter(personName == name)
// 删除
do {
let row = try db.run(p.delete())
if row == 0 {
print("暂无数据删除")
} else {
print("数据删除成功")
}
} catch {
print("删除数据失败")
}
}

// MARK: - 更新数据
func updatePerson(person: Person) {
// 筛选数据
let p = personTable.filter(personName == person.name)
// 更新
do {
let row = try db.run(p.update(personPhone <- person.phone, personAddress <- person.address))
if row == 0 {
print("暂无数据更新")
} else {
print("数据更新成功")
}
} catch {
print("数据更新失败")
}
}

// MARK: - 查询数据
func selectPerson() -> [Person]? {
// 保存查询结果
var response: [Person] = []
// 查询
do {
let select = try db.prepare(personTable)
for person in select {
let p = Person(name: person[personName], phone: person[personPhone], address: person[personAddress])
response.append(p)
}

if !response.isEmpty {
print("数据查询成功")
} else {
print("对不起,暂无数据")
}
return response
} catch {
print("数据查询失败")
return nil
}
}
}
  • ViewController
class ViewController: UIViewController {    
var dbTools: DBTools?

override func viewDidLoad() {
super.viewDidLoad()
}

@IBAction func createDB(_ sender: Any) {
dbTools = DBTools()
}

@IBAction func createTab(_ sender: Any) {
dbTools?.createTable()
}

@IBAction func insertData(_ sender: Any) {
let p = Person(name: "zhangsan", phone: "18888888888", address: "AnHuiWuhu")
dbTools?.insertPerson(person: p)
}

@IBAction func deleteData(_ sender: Any) {
dbTools?.deletePerson(name: "zhangsan")
}

@IBAction func updateData(_ sender: Any) {
let p = Person(name: "zhangsan", phone: "17777777777", address: "JiangSuNanJing")
dbTools?.updatePerson(person: p)
}

@IBAction func selectData(_ sender: Any) {
let person = dbTools?.selectPerson()
if let person = person {
for p in person {
print(p)
}
}
}
}
收起阅读 »

iOS - 触摸与手势识别

iOS
触摸概念UITouch用于描述触摸的窗口、位置、运动和力度。一个手指触摸屏幕,就会生成一个 UITouch 对象,如果多个手指同时触摸,就会生成多个 UITouch 对象。属性 (1)window:触摸时所处的 UIWindow。 (2)view:触摸时所处的...
继续阅读 »

触摸

概念

UITouch

用于描述触摸的窗口、位置、运动和力度。一个手指触摸屏幕,就会生成一个 UITouch 对象,如果多个手指同时触摸,就会生成多个 UITouch 对象。

  • 属性 (1)window:触摸时所处的 UIWindow。 (2)view:触摸时所处的 UIView。 (3)tapCount:短时间内点按屏幕的次数。可据此判断单击和双击操作。 (4)timestamp:时间戳,单位秒。记录了触摸事件产生或变化时的时间。 (5)phase:触摸事件的周期,即触摸开始、触摸点移动、触摸结束和中途取消。

  • 方法

// 返回一个CGPoint类型的值,表示触摸在view上的位置。
// 返回的位置是针对view的坐标系。
// 调用时传入的view参数为空的话,返回的是触摸点在整个窗口的位置 。
open func location(in view: UIView?) -> CGPoint

// 该方法记录了前一个坐标值,返回值的含义与上面一样。
open func previousLocation(in view: UIView?) -> CGPoint

UIEvent

一个完整的触摸操作是一个 UIEvent,它包含一组相关的 UITouch 对象,可以通过 UIEvent 的allTouches属性获得 UITouch 的集合。

UIResponder

  • 响应者对象。
  • 只有继承了 UIResponder 的对象才能接收并处理触摸事件。
  • AppDelegate、UIApplication、UIWindow、UIViewController、UIView 都继承自 UIResponder,因此它们都是响应者对象,都能够接收并处理触摸事件。
  • 响应者通过下列几个方法来响应触摸事件。
// 手指触碰屏幕,触摸开始
open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?)
// 手指在屏幕上移动
open func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
// 手指离开屏幕,触摸结束
open func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?)
// 触摸结束前,某个系统事件中断了触摸,如电话来电
open func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?)

触摸事件传递与响应

当触摸事件产生以后,App 里有很多的 UIView 或 UIViewController,到底应该谁去响应这个事件呢?在响应之前,必须要找到那个最合适的对象(最佳响应者),这个过程称之为事件传递或寻找最佳响应者(Hit-Testing)。

事件传递

  1. 当 iOS 程序中发生触摸事件后,系统会将事件加入到 UIApplication 管理的一个任务队列中。
  2. UIApplication 取出最前面的事件传递给 UIWindow。
  3. UIWindow 接收到事件后,首先判断自己能否响应触摸事件。如果能,那么 UIWindow 会从后往前遍历自己的子 UIView,将事件向下传递。
  4. 遍历每一个子 UIView 时,都会重复上面的操作(判断能否响应触摸事件,能则继续遍历子 UIView,直到找到一个 UIView)直到找到最合适的 UIView。如果没有找到合适的,那么事件不再往下传递,而当前 UIView 就是最合适的对象。

两个方法

寻找最佳响应者的原理是什么?需要借助以下两个方法。

// 寻找最佳响应者的核心方法,传递事件的桥梁
// 1. 判断点是否在当前view的内部(即调用第二个方法)
// 2. 如果在(即返回true)则遍历其子UIView继续
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
}

// 判断点是否在这个View的内部
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
}

  • UIApplication 调用 UIWindow 的hitTest方法将触摸事件传递给 UIWindow,如果 UIWindow 能够响应触摸事件,则调用其子 UIView 的hitTest方法将事件传递给其子 UIView,这样循环寻找与传递下去,直到获取最佳响应者。
  • 通过这两个方法可以做很多事情,其中一个经典的案例是自定义中间有凸起按钮的 UITabBar。此时需要重写 UITabBar 的point方法,判断当前触摸位置是否在中间凸起按钮的坐标范围内,如果在返回 true。这样可以让触摸事件传递到凸起按钮,并让其成为最佳响应者。

事件响应

  1. 当找到最合适的响应者之后,响应者对于触摸事件,有以下 3 种操作: (1)不拦截,事件会沿着默认的响应链自动传递。(默认操作) (2)拦截,事件不再往上传递,重写touchesBegan方法,但不调用父类的touchesBegan方法。 (3)不拦截,事件继续往上传递,重写touchesBegan方法,并调用父类的touchesBegan方法,即super.touchesBegan(touches, with: event)

  2. 响应者对于触摸事件的响应和传递都是在touchesBegan方法中完成的。该方法默认是将事件顺着响应者链向上传递,即将事件交给上一个响应者进行处理。每一个响应者对象都有一个next属性,用来获取下一个响应者。默认的next对象为: (1)UIView:若当前响应者是 UIViewController 的view,则next是 UIViewController,否则上一个响应者是其父 UIView。 (2)UIViewController:若当前响应者是 UIWindow 的rootViewController,则next是 UIWindow;若是被 present 显示的则nextpresentingViewController。 (3)UIWindow:next为 UIApplication。 (4)UIApplication:next为 AppDelegate。 (5)AppDelegate:next为 nil。

事件不响应的原因

  1. 触摸点不在当前范围内。
  2. alpha < 0.01,透明度小于 0.01。
  3. hidden = true,隐藏不可见。
  4. userInteractionEnabled = false,不允许交互。

手势识别

类型

  • UITapGestureRecognizer:轻点手势识别。
  • UILongPressGestureRecognizer:长按手势识别。
  • UIPinchGestureRecognizer:捏合手势识别。
  • UIRotationGestureRecognizer:旋转手势识别。
  • UISwipeGestureRecognizer:轻扫手势识别。
  • UIPanGestureRecognizer:拖动手势识别。
  • UIScreenEdgePanGestureRecognizer:屏幕边缘拖动手势识别。

使用步骤

  1. 创建手势实例,指定回调方法,当手势开始,改变、或结束时,回调方法被调用。
  2. 将手势添加到需要的 UIView 上。每个手势只对应一个 UIView,当屏幕触摸在当前 UIView 里时,如果手势和预定的一样,回调方法就会调用。
  3. 手势可以通过 storyboard 或者纯代码使用。
class ViewController: UIViewController {
@IBOutlet var blueView: UIView!

override func viewDidLoad() {
super.viewDidLoad()

// 创建手势
let tap = UITapGestureRecognizer(target: self, action: #selector(gesture))
// UITapGestureRecognizer可以设置tap次数
tap.numberOfTapsRequired = 2

let longPress = UILongPressGestureRecognizer(target: self, action: #selector(gesture))

let pinch = UIPinchGestureRecognizer(target: self, action: #selector(gesture))

let rotate = UIRotationGestureRecognizer(target: self, action: #selector(gesture))

let swipe = UISwipeGestureRecognizer(target: self, action: #selector(gesture))
// UISwipeGestureRecognizer需要设置direction
swipe.direction = .right

let pan = UIPanGestureRecognizer(target: self, action: #selector(gesture))

let edgePan = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(gesture))
// UIScreenEdgePanGestureRecognizer需要设置edges
edgePan.edges = UIRectEdge.all

// 添加手势
blueView.addGestureRecognizer(edgePan)
}

@objc func gesture(gestureRecognizer: UIGestureRecognizer) {
print(#function)
}
}

代理

class ViewController: UIViewController {    
@IBOutlet weak var blueView: UIView!

override func viewDidLoad() {
super.viewDidLoad()

let gestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(gesture))
// 设置代理
gestureRecognizer.delegate = self
// 添加手势
blueView.addGestureRecognizer(gestureRecognizer)
}

@objc func gesture(gestureRecognizer:UIGestureRecognizer){
print(#function)
}
}

extension ViewController: UIGestureRecognizerDelegate {
// 手势识别器是否解释此次手势
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer.state == .possible {
print("手势开始")
return true
}
else if gestureRecognizer.state == .cancelled {
print("手势结束")
return true
}
return true
}
}

注意

  1. 一个手势只能对应一个 UIView,但是一个 UIView 可以有多个手势。
  2. 继承自 UIControl 的 UIView 都可以通过 Target-Action 方式添加事件,如果同时给它们添加手势识别, 则 Target-Action 的行为会失效,因为手势识别的优先级更高。
收起阅读 »

iOS14开发- 国际化

iOS
介绍如果 App 需要给不同语言的用户使用,需要进行国际化处理。如果 App 需要进行国际化,在开发之初就需要考虑,在开发时统一使用某一种语言(建议英文),待开发完成以后再进行国际化处理。配置国际化语言在进行国际化之前,必须要添加需要国际化的语言,选中国际化的...
继续阅读 »

介绍

如果 App 需要给不同语言的用户使用,需要进行国际化处理。如果 App 需要进行国际化,在开发之初就需要考虑,在开发时统一使用某一种语言(建议英文),待开发完成以后再进行国际化处理。

配置国际化语言

在进行国际化之前,必须要添加需要国际化的语言,选中国际化的项目 —> PROJECT —> Info —> Localizations,点击+添加需要的国际化语言(默认已经存在英文)。

App名国际化

  1. 新建一个Strings File,必须命名为InfoPlist.strings
  2. 在 Xcode 的右侧文件检查器中找到Localization,点击Localize...,然后勾选配置的国际化语言。
  3. InfoPlist.strings左侧多了一个箭头,点击箭头可以展开,Strings File里面都是形如Key = Value的键值对,操作时一定要保证多个国际化文件中Key的一致性
  4. 设置 App 名字的 Key 为"CFBundleName"
// 英文名
"CFBundleName" = "I18N";

// 中文名
"CFBundleName" = "国际化";

文本国际化

  1. 新建一个Strings File,必须命名为Localizable.strings
  2. 在 Xcode 的右侧文件检查器中找到Localization,点击Localize...,然后勾选配置的国际化语言。
  3. Localizable.strings的各个国际化版本中写上需要国际化文本的Key = Value对。
"title" = "Info";
"message" = "This is a Dialog";
"btnTitle" = "Cancel";

"title" = "温馨提示";
"message" = "这是一个对话框";
"btnTitle" = "取消";

  1. 在需要的地方使用NSLocalizedString(key, comment)读取,其中第一个参数就是上面的key
NSLocalizedString("title", comment: "")
NSLocalizedString("message", comment: "")
NSLocalizedString("btnTitle", comment: "")

图片国际化

图片和文本国际化的使用方式一样,首先在Localizable.strings中进行图片名称的设置,然后通过NSLocalizedString(key, comment)来读取图片名,再根据不同的图片名获取不同的图片。

let imageName = NSLocalizedString("img", comment: "")
let image = UIImage(named: imageName)
imageView.image = image

storyboard/xib国际化

  1. 二者使用方式几乎一样,以 storyboard 为例。
  2. 配置国际化语言时,会弹出选择需要国际化的 storyboard 的对话框,选择以后对应的 storyboard 左侧就会多一个箭头,点击箭头可以展开,里面有storyboard名.stringsStrings File
  3. 选中storyboard名.strings,在 Xcode 的右侧文件检查器中找到Localization,点击Localize...
  4. storyboard名.strings文件对应位置填写相应的国际化信息。
/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "bRH-7c-qiE"; */
"bRH-7c-qiE.normalTitle" = "Button";

/* Class = "UILabel"; text = "UserName"; ObjectID = "dp4-Bf-56s"; */
"dp4-Bf-56s.text" = "UserName";

/* Class = "UITextField"; placeholder = "Please input your name"; ObjectID = "fen-IE-aUn"; */
"fen-IE-aUn.placeholder" = "Please input your name";
/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "bRH-7c-qiE"; */
"bRH-7c-qiE.normalTitle" = "登录";

/* Class = "UILabel"; text = "UserName"; ObjectID = "dp4-Bf-56s"; */
"dp4-Bf-56s.text" = "用户名";

/* Class = "UITextField"; placeholder = "Please input your name"; ObjectID = "fen-IE-aUn"; */
"fen-IE-aUn.placeholder" = "请输入用户名";

注意:如果在弹出的对话框中没有勾选 storyboard,也可以选中 storyboard 文件,再在 Xcode 的右侧文件检查器中找到Localization,点击Localize...,选择 Base,点击Localize,然后在Localization中勾选需要的国际化语言,会生成各个国际化语言的Strings File,最后进行国际化信息的填充。

收起阅读 »

Flutter手势探索——原理与实现的背后

作者:闲鱼技术——子东在日常开发中,手势和事件无处不在,比如在 Flutter 应用中点击一个点赞按钮,长按弹出 BottomSheet 和商品列表的滑动等等都存在事件传递和手势识别,Flutter 内部是如何确定哪个控件响应了事件,事件是如何在控件之间传递的...
继续阅读 »

作者:闲鱼技术——子东

在日常开发中,手势和事件无处不在,比如在 Flutter 应用中点击一个点赞按钮,长按弹出 BottomSheet 和商品列表的滑动等等都存在事件传递和手势识别,Flutter 内部是如何确定哪个控件响应了事件,事件是如何在控件之间传递的,包括像 Tap 和 DoubleTap 等手势是如何区分的。为了回答以上的问题,我们接下来深入探索 Flutter 手势的原理。

手势原理

事件分发

Flutter 中的事件是从 Window.onPointerDataPacket 的回调中获取的,将原始事件转化成 PointerEvent 加入到待处理的事件队列中,然后逐个处理队列中的 PointerEvent。

其中 _handlePointerEvent 将生成 HitTestResult 将所有的命中测试结果存在 _path (HitTestResult 中的一个命中测试对象的集合),最后遍历 HitTestResult 的 _path 进行事件分发。

命中测试

那么 HitTestResult 是如何收集这些命中测试结果的呢,与 Native 的 HitTest 类似,Flutter 中也是不断在遍历(调用 HitTest)child 判断 point 和 child 的大小比较直到找到最深一个 child 也就是离我们最近的一个 RenderBox。如果把 Widget 的结构理解成树的结构,那么 _path 中 entry 的顺序正好是从叶子节点往根节点回溯的顺序。

手势识别

了解了 Flutter 的事件分发与命中测试,接下来我们看看手势是如何识别。在 Flutter 提供了一个封装各种手势监听的 Widget —— GestureDetector,其内部实现了各种手势识别器和其回调,然后传给 RawGestureDetector 。在 RawGestureDetector 里监听了 PointerDownEvent 事件,并遍历所有识别器并调用 addPointer 方法。

我们以最简单的识别器 TapGestureRecognizer 为例,先了解 addPointer 的实现中做了哪些事情,最终调用 startTrackingPointer 方法,在事件路由里注册 handleEvent,并将其加入到竞争场(后面会讲手势竞争)中。当事件分发时根据 pointer 调用对应的 handleEvent 方法。在 handleEvent 方法实现中判断 pointer 的移动距离是否超过阈值,这个阈值的默认大小是 18 个像素点。如果超过这个阈值将拒绝事件并停止事件追踪。反之调用 TapGestureRecognizer 识别器实现的 handlePrimaryPointer,最终处理监听的回调。

手势竞争

当我们同时使用多种手势时会产生冲突,为了解决这个问题,Flutter 引入了 GestureArena(手势竞争场)的概念。在处理多种手势时把这些手势加入到竞争场中,胜出的手势会继续响应接下来的事件。 在手势竞争场中胜出者遵循两个规律:

•在竞争场中只存在一个手势识别器时,它将胜出。•当有一个手势识别器胜出,那么其他的都将失败。

举个例子,在一个 Widget 上同时监听 Horizontal 和 Vertical 手势时,当手指按下的时候两者都会进入手势竞争场,当用户手指在水平方向上移动一定距离,Horizontal 手势将胜出并响应事件。相同的,用户手指在垂直方向上移动 Vertical 手势胜出。

小结

上面分析了在 Flutter 中从事件分发到手势识别的原理,其中以 TapGestureRecognizer 为例介绍了手势识别,除了此以外还有 ScaleGestureRecognizer,PanGestureRecognizer 等等,识别这些手势的原理基本相同,重写 handleEvent 实现各自具体手势判断。接下来具体介绍在实际项目中遇到的手势冲突问题以及解决方案。

案例分析

近期团队正在优化图片浏览器的用户体验。我们与 UED 共同梳理了实现一个图片浏览器所包含的功能点:

1.点击关闭图片2.支持左右滑动切换图片3.支持双击放大4.长按唤起更多操作 ... ...

从上面的功能点分析之后,我们采用 Flutter 的系统控件 PageView 作为图片浏览器的基础组件,在其基础之上扩展出图片放大、双击和长按等手势。所以组件的框架图如下所示:

在 PageView 的 ItemView 使用 ImageWrapper 封装之后接管 ItemView 的手势来处理自定义的手势,比如缩放 ScaleGestureRecognizer 和 TapGestureRecognizer 等等。 从上面的框架图看,基于系统控件 PageView 的框架分层比较简单,尽可能利用系统控件原有的功能,即能减少实现复杂逻辑的实现,同时也避免了在多种系统和设备上的兼容性问题。在这个过程中也遇到一些手势冲突的问题。

图片放大滚动与 PageView 滑动的冲突

分析冲突原因:在 ImageWrapper 中使用 ScaleGestureRecognizer 追踪缩放事件。PageView 是在 Scrollable 的基础上实现的,Scrollable 则是利用 HorizontalDragGestureRecognizer 追踪水平拖拽事件来实现滑动。Scale 和 HorizontalDrag 同时存在必然会发生竞争,因为在水平滑动时 HorizontalDrag 手势胜出,图片无法滚动直接滑到下一页。 通过上面的分析,我们需要解决两个问题:

•图片支持滚动•图片滚动到边界时滑到下一页

一个简单的想法是在图片放大时禁止 PageView 滑动(PageView 的 physics 设置为 NeverScrollableScrollPhysics),当放大图片滚动到边界时允许 PageView 滑动下一页。该方案在实现之后,发现滚动到边界时与 PageView 滑动到下一页两者衔接的体验并不流畅。 从上面对 PageView 的源码分析,在 ImageWrapper 中实现 HorizontalDragGestureRecognizer 手势拦截了 PageView 内部的水平拖拽手势,图片放大时通过 Scale 手势回调计算位置(图片移动),当图片移动到边界时,将手势描述(DragStartDetails)传给外部的 PageView,在回调中 PageController 的 ScrollPosition 生成一个 Drag,紧接着 DragUpdateDetails 用于 drag 对象的更新。需要注意在手势事件结束时需要调用 drag.end 保持手势事件的完整性。这种方法较完美的解决了上面冲突的问题,并且通过 Flutter 自身提供的方法实现,在 HorizontalDrag 手势结束时 PageController 会处理这部分滑动的动画。

Scale 手势与 HorizontalDrag 手势的冲突

在极端的情况下,双指不同时接触到屏幕,并且至少有一根手指是横向移动,图片缩放和位置会出现异常。通过上面的竞争分析,在其中一根手指出现横向滑动的时,HorizontalDrag 在竞争中胜出,此处图片的位置会被 HorizontalDrag 手势的回调改变(图片浏览器 ImageWrapper 实现是在 Scale 和 HorizontalDrag 手势回调中协同控制图片的缩放和位移)。 由于两个手势在以上的情况下会互相切换导致异常。首先将 Scale 和 HorizontalDrag 两个手势的职责划分清楚,HorizontalDrag 的回调处理图片滚动到边界时将 Drag 事件抛出给 PageView 的 PageController 处理;Scale 的回调只处理缩放和除边界以外的位移。划分清职责之后,让两个手势同时存在那么就不存在竞争胜出者的切换的问题,那么图片缩放和位置会也就不会出现异常。 通过继承 ScaleGestureRecognizer 重写 rejectGesture 方法强制让 Scale 手势生效。从 GestureArena 的源码分析,rejectGesture 方法只在竞争结束之后收尾处理调用的,所以不会影响竞争场的竞争。并且重写 rejectGesture 方法之后可以继续追踪事件(ScaleGestureRecognizer 中 rejectGesture 实现是停止事件追踪)。

小结

解决完上面两个比较棘手的冲突问题,图片浏览器组件的雏形也有了。由于篇幅原因,很多实现的细节没有一一列举,比如如何去计算边界,图片移动距离计算等等。在解决上面的问题也花费一定的时间,在解决问题没有思路可能要回归到问题本身,拆解问题,再逐个突破。好在 Flutter 是开源的,我们可以通过源码找到问题解决的思路和方法。希望以上的解决方案能帮助到开发者,提供解决问题的思路。

展望

图片浏览器想要更好体验接下来还需要对交互细节和临界状态处理更加细致。比如在图片放大之后滚动支持一定的加速度;图片放大之后滚动到边缘时增加阻尼等等。要想极致的用户体验,这几个内容都是我们将来可能要探索的方向。


作者:闲鱼技术
链接:https://juejin.cn/post/7007996934757548069
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

实践 | Google I/O 应用是如何适配大尺寸屏幕 UI 的?

5 月 18 日至 20 日,我们以完全线上的形式举办了 Google 每年一度的 I/O 开发者大会,其中包括 112 场会议、151 个 Codelab、79 场开发者聚会、29 场研讨会,以及众多令人兴奋的发布。尽管今年的大会没有发布新版的 Google...
继续阅读 »

5 月 18 日至 20 日,我们以完全线上的形式举办了 Google 每年一度的 I/O 开发者大会,其中包括 112 场会议、151 个 Codelab、79 场开发者聚会、29 场研讨会,以及众多令人兴奋的发布。尽管今年的大会没有发布新版的 Google I/O 应用,我们仍然更新了代码库来展示时下 Android 开发最新的一些特性和趋势。


应用在大尺寸屏幕 (平板、可折叠设备甚至是 Chrome OS 和台式个人电脑) 上的使用体验是我们的关注点之一: 在过去的一年中,大尺寸屏幕的设备越来越受欢迎,用户使用率也越来越高,如今已增长到 2.5 亿台活跃设备了。因此,让应用能充分利用额外的屏幕空间显得尤其重要。本文将展示我们为了让 Google I/O 应用在大尺寸屏幕上更好地显示而用到的一些技巧。


响应式导航


在平板电脑这类宽屏幕设备或者横屏手机上,用户们通常握持着设备的两侧,于是用户的拇指更容易触及侧边附近的区域。同时,由于有了额外的横向空间,导航元素从底部移至侧边也显得更加自然。为了实现这种符合人体工程学的改变,我们在用于 Android 平台的 Material Components 中新增了 Navigation rail


△ 左图: 竖屏模式下的底部导航。右图: 横屏模式下的 navigation rail。


△ 左图: 竖屏模式下的底部导航。右图: 横屏模式下的 navigation rail。


Google I/O 应用在主 Activity 中使用了两个不同的布局,其中包含了我们的人体工程学导航。其中在 res/layout 目录下的布局中包含了 BottomNavigationView,而在 res/layout-w720dp 目录下的布局中则包含了 NavigationRailView。在程序运行过程中,我们可以通过 Kotlin 的安全调用操作符 (?.) 来根据当前的设备配置确定呈现给用户哪一个视图。


private lateinit var binding: ActivityMainBinding

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)

// 根据配置不同,可能存在下面两种导航视图之一。
binding.bottomNavigation?.apply {
configureNavMenu(menu)
setupWithNavController(navController)
setOnItemReselectedListener { } // 避免导航到同一目的界面。
}
binding.navigationRail?.apply {
configureNavMenu(menu)
setupWithNavController(navController)
setOnItemReselectedListener { } // 避免导航到同一目的界面。
}
...
}

小贴士: 即使您不需要数据绑定的所有功能,您仍然可以使用 视图绑定 来为您的布局生成绑定类,这样就能避免调用 findViewById 了。


单窗格还是双窗格


在日程功能中,我们用列表-详情的模式来展示信息的层次。在宽屏幕设备上,显示区域被划分为左侧的会议列表和右侧的所选会议详细信息。这种布局方式带来的一个特别的挑战是,同一台设备在不同的配置下可能有不同的最佳显示方式,比如平板电脑竖屏对比横屏显示就有差异。由于 Google I/O 应用使用了 Jetpack Navigation 实现不同界面之间的切换,这个挑战对导航图有怎样的影响,我们又该如何记录当前屏幕上的内容呢?


△ 左图: 平板电脑的竖屏模式 (单窗格)。右图: 平板电脑的横屏模式 (双窗格)。


△ 左图: 平板电脑的竖屏模式 (单窗格)。右图: 平板电脑的横屏模式 (双窗格)。


我们采用了 SlidingPaneLayout,它为上述问题提供了一个直观的解决方案。双窗格会一直存在,但根据屏幕的尺寸,第二窗格可能不会显示在可视范围当中。只有在给定的窗格宽度下仍然有足够的空间时,SlidingPaneLayout 才会同时将两者显示出来。我们分别为会议列表和详情窗格分配了 400dp 和 600dp 的宽度。经过一些实验,我们发现即使是在大屏幕的平板上,竖屏模式同时显示出双窗格内容会使得信息的显示过于密集,所以这两个宽度值可以保证只在横屏模式下才同时展现全部窗格的内容。


至于导航图,日程的目的地页面现在是双窗格 Fragment,而每个窗格中可以展示的目的地都已经被迁移到新的导航图中了。我们可以用某窗格的 NavController 来管理该窗格内包含的各个目的页面,比如会议详情、讲师详情。不过,我们不能直接从会议列表导航到会议详情,因为两者如今已经被放到了不同的窗格中,也就是存在于不同的导航图里。


我们的替代方案是让会议列表和双窗格 Fragment 共享同一个 ViewModel,其中又包含了一个 Kotlin 数据流。每当用户从列表选中一个会议,我们会向数据流发送一个事件,随后双窗格 Fragment 就可以收集此事件,进而转发到会议详情窗格的 NavController:


val detailPaneNavController = 
(childFragmentManager.findFragmentById(R.id.detail_pane) as NavHostFragment)
.navController
scheduleTwoPaneViewModel.selectSessionEvents.collect { sessionId ->
detailPaneNavController.navigate(
ScheduleDetailNavGraphDirections.toSessionDetail(sessionId)
)
// 在窄屏幕设备上,如果会议详情窗格尚未处于最顶端时,将其滑入并遮挡在列表上方。
// 如果两个窗格都已经可见,则不会产生执行效果。
binding.slidingPaneLayout.open()
}

正如上面的代码中调用 slidingPaneLayout.open() 那样,在窄屏幕设备上,滑入显示详情窗格已经成为了导航过程中的用户可见部分。我们也必须要将详情窗格滑出,从而通过其他方式 "返回" 会议列表。由于双窗格 Fragment 中的各个目的页面已经不属于应用主导航图的一部分了,因此我们无法通过按设备上的后退按钮在窗格内自动向后导航,也就是说,我们需要实现这个功能。


上面这些情况都可以在 OnBackPressedCallback 中处理,这个回调在双窗格 Fragment 的 onViewCreated() 方法执行时会被注册 (您可以在这里了解更多关于添加 自定义导航 的内容)。这个回调会监听滑动窗格的移动以及关注各个窗格导航目的页面的变化,因此它能够评估下一次按下返回键时应该如何处理。


class ScheduleBackPressCallback(
private val slidingPaneLayout: SlidingPaneLayout,
private val listPaneNavController: NavController,
private val detailPaneNavController: NavController
) : OnBackPressedCallback(false),
SlidingPaneLayout.PanelSlideListener,
NavController.OnDestinationChangedListener {

init {
// 监听滑动窗格的移动。
slidingPaneLayout.addPanelSlideListener(this)
// 监听两个窗格内导航目的页面的变化。
listPaneNavController.addOnDestinationChangedListener(this)
detailPaneNavController.addOnDestinationChangedListener(this)
}

override fun handleOnBackPressed() {
// 按下返回有三种可能的效果,我们按顺序检查:
// 1. 当前正在详情窗格,从讲师详情返回会议详情。
val listDestination = listPaneNavController.currentDestination?.id
val detailDestination = detailPaneNavController.currentDestination?.id
var done = false
if (detailDestination == R.id.navigation_speaker_detail) {
done = detailPaneNavController.popBackStack()
}
// 2. 当前在窄屏幕设备上,如果详情页正在顶层,尝试将其滑出。
if (!done) {
done = slidingPaneLayout.closePane()
}
// 3. 当前在列表窗格,从搜索结果返回会议列表。
if (!done && listDestination == R.id.navigation_schedule_search) {
listPaneNavController.popBackStack()
}

syncEnabledState()
}

// 对于其他必要的覆写,只需要调用 syncEnabledState()。

private fun syncEnabledState() {
val listDestination = listPaneNavController.currentDestination?.id
val detailDestination = detailPaneNavController.currentDestination?.id
isEnabled = listDestination == R.id.navigation_schedule_search ||
detailDestination == R.id.navigation_speaker_detail ||
(slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen)
}
}

SlidingPaneLayout 最近也针对可折叠设备进行了优化更新。更多关于使用 SlidingPaneLayout 的信息,请参阅: 创建双窗格布局


资源限定符的局限


搜索应用栏也在不同屏幕内容下显示不同内容。当您在搜索时,可以选择不同的标签来过滤需要显示的搜索结果,我们也会把当前生效的过滤标签显示在以下两个位置之一: 窄模式时位于搜索文本框下方,宽模式时位于搜索文本框的后面。可能有些反直觉的是,当平板电脑横屏时属于窄尺寸模式,而当其竖屏使用时属于宽尺寸模式。


△ 平板横屏时的搜索应用栏 (窄模式)


△ 平板横屏时的搜索应用栏 (窄模式)


△ 平板竖屏时的搜索应用栏 (宽模式)


△ 平板竖屏时的搜索应用栏 (宽模式)


此前,我们通过在搜索 Fragment 的视图层次中的应用栏部分使用 <include> 标签,并提供两种不同版本的布局来实现此功能,其中一个被限定为 layout-w720dp 这样的规格。如今此方法行不通了,因为在那种情况下,带有这些限定符的布局或是其他资源文件都会被按照整屏幕宽度解析,但事实上我们只关心那个特定窗格的宽度。


要实现这一特性,请参阅搜索 布局 的应用栏部分代码。请注意两个 ViewStub 元素 (第 27 和 28 行)。


<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
... >

<androidx.appcompat.widget.Toolbar
android:layout_width="match_parent"
android:layout_height="?actionBarSize">

<!-- Toolbar 不支持 layout_weight,所以我们引入一个中间布局 LinearLayout。-->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:showDividers="middle"
... >

<SearchView
android:id="@+id/searchView"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
... />

<!-- 宽尺寸时过滤标签的 ViewStub。-->
<ViewStub
android:id="@+id/active_filters_wide_stub"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
android:layout="@layout/search_active_filters_wide"
... />
</LinearLayout>
</androidx.appcompat.widget.Toolbar>

<!-- 窄尺寸时过滤标签的 ViewStub。-->
<ViewStub
android:id="@+id/active_filters_narrow_stub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/search_active_filters_narrow"
... />
</com.google.android.material.appbar.AppBarLayout>

两个 ViewStub 各自指向不同的布局,但都只包含了一个 RecyclerView (虽然属性略有不同)。这些桩 (stub) 在运行时直到内容 inflate 之前都不会占据可视空间。剩下要做的就是当我们知道窗格有多宽之后,选择要 inflate 的桩。所以我们只需要使用 doOnNextLayout 扩展函数,等待 onViewCreated() 中对 AppBarLayout 进行首次布局即可。


binding.appbar.doOnNextLayout { appbar ->
if (appbar.width >= WIDE_TOOLBAR_THRESHOLD) {
binding.activeFiltersWideStub.viewStub?.apply {
setOnInflateListener { _, inflated ->
SearchActiveFiltersWideBinding.bind(inflated).apply {
viewModel = searchViewModel
lifecycleOwner = viewLifecycleOwner
}
}
inflate()
}
} else {
binding.activeFiltersNarrowStub.viewStub?.apply {
setOnInflateListener { _, inflated ->
SearchActiveFiltersNarrowBinding.bind(inflated).apply {
viewModel = searchViewModel
lifecycleOwner = viewLifecycleOwner
}
}
inflate()
}
}
}

转换空间


Android 一直都可以创建在多种屏幕尺寸上可用的布局,这都是由 match_parent 尺寸值、资源限定符和诸如 ConstraintLayout 的库来实现的。然而,这并不总是能在特定屏幕尺寸下为用户带来最佳的体验。当 UI 元素拉伸过度、相距过远或是过于密集时,往往难以传达信息,触控元素也变得难以辨识,并导致应用的可用性受到影响。


对于类似 "Settings" (设置) 这样的功能,我们的短列表项在宽屏幕上会被拉伸地很严重。由于这些列表项本身不太可能有新的布局方式,我们可以通过 ConstraintLayout 限制列表宽度来解决。


<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintWidth_percent="@dimen/content_max_width_percent">

<!-- 设置项……-->

</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

在第 10 行,@dimen/content_max_width_percent 是一个浮点数类型的尺寸值,根据不同的屏幕宽度可能有不同的值。这些值从小屏幕的 1.0 开始渐渐减少到宽屏幕的 0.6,所以当屏幕变宽,UI 元素也不会因为拉伸过度而产生割裂感。


△ 宽屏幕设备上的设置界面


△ 宽屏幕设备上的设置界面


请您阅读这则关于支持不同屏幕尺寸的 指南,获得常见尺寸分界点的参考信息。


转换内容


Codelabs 功能与设置功能有相似的结构。但我们想要充分利用额外的屏幕空间,而不是限制显示内容的宽度。在窄屏幕设备上,您会看到一列项目,它们会在点击时展开或折叠。在宽尺寸屏幕上,这些列表项会转换为一格一格的卡片,卡片上直接显示了详细的内容。


△ 左图: 窄屏幕显示 Codelabs。右图: 宽屏幕显示 Codelabs。


△ 左图: 窄屏幕显示 Codelabs。右图: 宽屏幕显示 Codelabs。


这些独立的网格卡片是定义在 res/layout-w840dp 下的 备用布局,数据绑定处理信息如何与视图绑定,以及卡片如何响应点击,所以除了不同样式下的差异之外,不需要实现太多内容。另一方面,整个 Fragment 没有备用布局,所以让我们看看在不同的配置下实现所需的样式和交互都用到了哪些技巧吧。


所有的一切都集中在这个 RecyclerView 元素上:


<androidx.recyclerview.widget.RecyclerView
android:id="@+id/codelabs_list"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingHorizontal="@dimen/codelabs_list_item_spacing"
android:paddingVertical="8dp"
app:itemSpacing="@{@dimen/codelabs_list_item_spacing}"
app:layoutManager="@string/codelabs_recyclerview_layoutmanager"
app:spanCount="2"
……其他的布局属性……/>

这里提供了两个资源文件,每一个在我们为备用布局选择的尺寸分界点上都有不同的值:






















资源文件无限定符版本 (默认)-w840dp
@string/codelabs_recyclerview_layoutmanagerLinearLayoutManagerStaggeredGridLayoutManager
@dimen/codelabs_list_item_spacing0dp8dp

我们通过在 XML 文件中把 app:layoutManager 的值设置为刚才的字符串资源,然后同时设置 android:orientationapp:spanCount 实现布局管理器的配置。注意,朝向属性 (orientation) 对两种布局管理器而言是相同的,但是横向跨度 (span count) 只适用于 StaggeredGridLayoutManager,如果被填充的布局管理器是 LinearLayoutManager,那么它会简单地忽略设定的横向跨度值。


用于 android:paddingHorizontal 的尺寸资源同时也被用于另一个属性 app:itemSpacing。它不是 RecyclerView 的标准属性,那它从何而来?这其实是由 Binding Adapter 定义的一个属性,而 Binding Adapter 是我们向数据绑定库提供自定义逻辑的方法。在应用运行时,数据绑定会调用下面的函数,并将解析自资源文件的值作为参数传进去。


@BindingAdapter("itemSpacing")
fun itemSpacing(recyclerView: RecyclerView, dimen: Float) {
val space = dimen.toInt()
if (space > 0) {
recyclerView.addItemDecoration(SpaceDecoration(space, space, space, space))
}
}

SpaceDecorationItemDecoration 的一种简单实现,它在每个元素周围保留一定空间,这也解释了为什么我们会在 840dp 或更宽的屏幕上 (需要为 @dimen/codelabs_list_item_spacing 给定一个正值) 得到始终相同的元素间隔。将 RecyclerView 自身的内边距也设置为相同的值,会使得元素同 RecyclerView 边界的距离与元素间的空隙保持相同的大小,在元素周围形成统一的留白。为了让元素能够一直滚动显示到 RecyclerView 的边缘,需要设置 android:clipToPadding="false"



屏幕越多样越好


Android 一直是个多样化的硬件生态系统。随着更多的平板和可折叠设备在用户中普及,请确保在这些不同尺寸和屏幕比例中测试您的应用,这样一些用户就不会觉得自己被 "冷落" 了。Android Studio 同时提供了 可折叠模拟器自由窗口模式 以简化这些测试过程,因此您可以通过它们来检查您的应用对于上述场景的响应情况。


我们希望这些 Google I/O 应用上的变动能启发您构建充分适配各种形状和尺寸设备的美观、高质量的应用。欢迎您从 Github 下载代码,动手试一试。


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

Android Camera了解一下

Camera 演进简介最近在项目中遇到 Camera相关的场景,之前对这块不是很了解,趁机补了一下盲区。Android Camera 相关也是生态碎片化较为严重的一块,Android FrameWorkt提供Camera API来实现拍照与屏幕录制的能力,目前...
继续阅读 »

Camera 演进简介

最近在项目中遇到 Camera相关的场景,之前对这块不是很了解,趁机补了一下盲区。Android Camera 相关也是生态碎片化较为严重的一块,Android FrameWorkt提供Camera API来实现拍照与屏幕录制的能力,目前Android有三类API

  • Camera (为了便于区分 下面简称 Camera1)

此类是用于控制设备相机的旧版API,在Android5.0以下使用,现已Deprecated

Android 5.0以上升级的方案,控制设备相机的API,并且开放出硬件支持级别的厂商定制

(谷歌开放出官方库CameraView 帮助解决相机兼容性问题,也有其他一些三方库)

JetPack中引入,基与Cmaera2 API封装,简化了开发流程,并增加生命周期控制

\

那么 Camera和Camera2如何使用?Camera2优秀在哪里?官方开发的的CameraView和CameraX又是为了解决什么问题,下面来一个个了解下。

Camera1和Camera2实践

相机开发核心流程如下

  1. 检查权限
  2. 检测设备摄像头,打开相机
  1. 创建预览帧 显示实时画面 (一般是通过 SurfaceView、TextureView进行实时预览),每个相机有支持预览的尺寸比如4:3或者16:9、11:9等,比如定制或者横竖屏场景 需要计算合适的预览尺寸
  2. 设置相机参数,进行拍照监听
  1. 拍照 保存图片或者操作原始数据
  2. 释放相机资源

其中步骤3在Camera1和Camera2中稍有不同,Camera1拍照前必须先开启预览,而Camera2流程做了解耦,可以无需预览直接拍照

Camera1

代码

权限声明

<uses-feature
android:name="android.hardware.camera"
android:required="true" />

<uses-permission android:name="android.permission.CAMERA" />

在Android 6.0以上需要动态申请权限

ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA},
REQUEST_CAMERA_PERMISSION);

打开相机

支持传入id

Camera.open() //默认后置摄像头
Camera.open(int id)

创建预览

private SurfaceView mSurfaceView;
private SurfaceHolder mSurfaceHolder;
...
mSurfaceHolder = mSurfaceView.getHolder();
mSurfaceHolder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
...
startPreview();
}

@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

}

@Override
public void surfaceDestroyed(SurfaceHolder holder) {
releaseCamera();
}
});
...
private void startPreview() {
try {
//设置实时预览
mCamera.setPreviewDisplay(mSurfaceHolder);
//Orientation
setCameraDisplayOrientation();
//开始预览
mCamera.startPreview();
……
} catch (Exception e) {
e.printStackTrace();
}
}

在设置预览时候可以通过 setPreviewCallback( Camera.PreviewCallback

)监听预览数据的数据

设置相机参数

Camera.Parameters

//获取Parameters对象
mParameters = camera?.parameters

设置相机相关参数内容非常丰富,这里介绍几个常用的

  • setFocusMode 设置对焦模式
  • setPreviewSize 设置预览图片大小 (相机支持不同的预览尺寸,比如横竖屏需要计算预览尺寸d可以看下谷歌的CameraView中的CmaeraView1#chooseOptimalSize处理)
  • setPreviewFormat 设置预览格式 默认返回NV21
  • setPictureSize 设置保存图片的大小
  • setPreviewDisplay 设置实时预览 SurfaceHolder
  • setPreviewCallback 监听相机预览数据回调

拍照

mCamera.takePicture

private void takePicture() {
if (null != mCamera) {
mCamera.takePicture(new Camera.ShutterCallback() {
@Override
public void onShutter() {
//按下快门后的回调

}
}, new Camera.PictureCallback() {
@Override
public void onPictureTaken(byte[] data, Camera camera) {
//回调没压缩的 base data
}
}, new Camera.PictureCallback() {
@Override
public void onPictureTaken(final byte[] data, Camera camera) {
mCamera.startPreview();
//save data
}
});
}
}

释放相机资源

mCamera.stopPreview

mCamera.release

private void releaseCamera() {
if (null != mCamera) {
mCamera.stopPreview();
mCamera.stopFaceDetection();
mCamera.setPreviewCallback(null);
mCamera.release();
mCamera = null;
}
}

Camera2

从 Android 5.0 开始,Google 引入了一套全新的相机框架 Camera2(android.hardware.camera2)并且废弃了旧的相机框架 Camera1(android.hardware.Camera) ,相较于Camera1,Camera2架构上发生了变化,主要是将相机设备模拟成一个管道,按照顺序处理每一帧的请求并返回给调用方,API上的使用难度,本节先介绍下Camera2在核心流程上的使用

\

由于Camera2架构设计成了管道,在拍照流程中细分出了通过3个类来协同

  • CaptureRequest

相机捕获图像的设置请求,包含传感器,镜头,闪光灯等

  • CaptureRequest.Builder

CaptureRequest的构造器,使用Builder模式,设置更加方便

  • CameraCaptureSession

请求抓取相机图像帧的会话,会话的建立主要会建立起一个通道。一个CameraDevice一次只能开启一个CameraCaptureSession。

源端是相机,另一端是 Target,Target可以是Preview,也可以是ImageReader。

相机过程中处理数据也做了一些优化,抽象出了

  • ImageReader

用于从相机打开的通道中读取需要的格式的原始图像数据,可以设置多个ImageReader。

  • CameraCharacteristics

主要用于获取相机信息,内部携带大量的信息信息

  • CameraDevice

相机设备类,和Camera1中的Camera同级

  • CmaeraManager

相机系统服务,用于管理和连接相机设备

代码

\

拍摄流程重新抽象

  • 创建一个用于从Pipeline获取图片的CaptureRequest
  • 修改CaptureRequest的配置
  • 创建两个不同尺寸的Surface用于接收图片数据,并且将他们添加到CaptureRequest中
  • 发送配置好的CaptureRequest 到Pieline中等待结果

一个新的 CaptureRequest 会被放入一个被称作 Pending Request Queue 的队列中等待被执行,当 In-Flight Capture Queue 队列空闲的时候就会从 Pending Request Queue 获取若干个待处理的 CaptureRequest,并且根据每一个 CaptureRequest 的配置进行 Capture 操作。最后我们从不同尺寸的 Surface 中获取图片数据并且还会得到一个包含了很多与本次拍照相关的信息的 CaptureResult,流程结束

获取相机服务

CameraManager cameraManager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);

根据相机ID获取相机信息 CameraCharateristics

private boolean chooseCameraIdByFacing() {
try {
int internalFacing = INTERNAL_FACINGS.get(mFacing);
final String[] ids = mCameraManager.getCameraIdList();
……
for (String id : ids) {
CameraCharacteristics characteristics = mCameraManager.getCameraCharacteristics(id);
Integer level = characteristics.get(
CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL);
if (level == null ||
level == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
continue;
}
Integer internal = characteristics.get(CameraCharacteristics.LENS_FACING);
if (internal == null) {
throw new NullPointerException("Unexpected state: LENS_FACING null");
}
if (internal == internalFacing) {
mCameraId = id;
mCameraCharacteristics = characteristics;
return true;
}
}
// Not found
……
// The operation can reach here when the only camera device is an external one.
// We treat it as facing back.
mFacing = Constants.FACING_BACK;
return true;
} catch (CameraAccessException e) {
throw new RuntimeException("Failed to get a list of camera devices", e);
}
}

初始化ImageReader

ImageReader是获取图像数据的重要途径,通过它可以获取到不同格式的图像数据,例如JPEG、YUV、RAW等。通过ImageReader.newInstance(int width, int height, int format, int maxImages)创建ImageReader对象,有4个参数:

  • width:图像数据的宽度
  • height:图像数据的高度
  • format:图像数据的格式,例如ImageFormat.JPEG,ImageFormat.YUV_420_888等
  • maxImages:最大Image个数,Image对象池的大小,指定了能从ImageReader获取Image对象的最大值,过多获取缓冲区可能导致OOM,所以最好按照最少的需要去设置这个值

ImageReader其他相关的方法和回调:

  • ImageReader.OnImageAvailableListener:有新图像数据的回调
  • acquireLatestImage():从ImageReader的队列里面,获取最新的Image,删除旧的,如果没有可用的Image,返回null
  • acquireNextImage():获取下一个最新的可用Image,没有则返回null
  • close():释放与此ImageReader关联的所有资源
  • getSurface():获取为当前ImageReader生成Image的Surface
private void prepareImageReader() {
if (mImageReader != null) {
mImageReader.close();
}
Size largest = mPictureSizes.sizes(mAspectRatio).last();
mImageReader = ImageReader.newInstance(largest.getWidth(), largest.getHeight(),
ImageFormat.JPEG, /* maxImages */ 2);
mImageReader.setOnImageAvailableListener(mOnImageAvailableListener, null);
}

private final ImageReader.OnImageAvailableListener mOnImageAvailableListener
= new ImageReader.OnImageAvailableListener() {

@Override
public void onImageAvailable(ImageReader reader) {
try (Image image = reader.acquireNextImage()) {
Image.Plane[] planes = image.getPlanes();
if (planes.length > 0) {
ByteBuffer buffer = planes[0].getBuffer();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
mCallback.onPictureTaken(data);
}
}
}

};

打开相机设备

cameraManager.openCamera(@NonNull String cameraId,@NonNull final CameraDevice.StateCallback callback, @Nullable Handler handler)的三个参数:

  • cameraId:摄像头的唯一标识
  • callback:设备连接状态变化的回调
  • handler:回调执行的Handler对象,传入null则使用当前的主线程Handler
mCameraManager.openCamera(mCameraId, mCameraDeviceCallback, null);

private final CameraDevice.StateCallback mCameraDeviceCallback
= new CameraDevice.StateCallback() {

@Override
public void onOpened(@NonNull CameraDevice camera) {
//表示相机打开成功,可以真正开始使用相机,创建Capture会话
mCamera = camera;
mCallback.onCameraOpened();
//创建Capture会话
startCaptureSession();
}

@Override
public void onClosed(@NonNull CameraDevice camera) {
//调用Camera.close()后的回调方法
mCallback.onCameraClosed();
}

@Override
public void onDisconnected(@NonNull CameraDevice camera) {
//当相机断开连接时回调该方法,需要进行释放相机的操作
mCamera = null;
}

@Override
public void onError(@NonNull CameraDevice camera, int error) {
//当相机打开失败时,需要进行释放相机的操作
Log.e(TAG, "onError: " + camera.getId() + " (" + error + ")");
mCamera = null;
}

};

创建 CaptureRequest及其target配置

Camera2是通过管道链接 request+target建立会话,首先我们得通过CaptureRequest.Builder配置好

使用 TEMPLATE_STILL_CAPTURE 模板创建一个用于拍照的 CaptureRequest.Builder 对象,并且添加拍照的 Surface 和预览的 Surface 到其中:

captureImageRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
captureImageRequestBuilder.addTarget(previewDataSurface)
captureImageRequestBuilder.addTarget(jpegSurface)

通过CameraDevice.createCaptureRequest()创建CaptureRequest.Builder对象,传入一个templateType参数,templateType用于指定使用何种模板创建CaptureRequest.Builder对象,templateType的取值:

  • TEMPLATE_PREVIEW:预览模式
  • TEMPLATE_STILL_CAPTURE:拍照模式
  • TEMPLATE_RECORD:视频录制模式
  • TEMPLATE_VIDEO_SNAPSHOT:视频截图模式
  • TEMPLATE_MANUAL:手动配置参数模式

除了模式的配置,CaptureRequest还可以配置很多其他信息,例如图像格式、图像分辨率、传感器控制、闪光灯控制、3A(自动对焦-AF、自动曝光-AE和自动白平衡-AWB)控制等。在createCaptureSession的回调中可以进行设置

// Auto focus should be continuous for camera preview.
mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE,
CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
// Flash is automatically enabled when necessary.
setAutoFlash(mPreviewRequestBuilder);

// Finally, we start displaying the camera preview.
mPreviewRequest = mPreviewRequestBuilder.build();

拍照和预览

在Camera2中拍照和预览是解耦开的,有了CaptureRequest之后,需要借助CaptureSession(Capture会话)来描述

mCameraDevice.createCaptureSession()创建Capture会话,它接受了三个参数:

  • outputs:用于接受图像数据的surface集合
  • callback:用于监听 Session 状态的CameraCaptureSession.StateCallback对象
  • handler:用于执行CameraCaptureSession.StateCallback的Handler对象,传入null则使用当前的主线程Handler

\

拍照

mCaptureSession.capture(mPreviewRequestBuilder.build(), mCaptureCallback, mBackgroundHandler);

该方法也有三个参数,和mCaptureSession.setRepeatingRequest一样:

  • request:CaptureRequest对象
  • listener:监听Capture 状态的回调
  • handler:用于执行CameraCaptureSession.CaptureCallback的Handler对象,传入null则使用当前的主线程Handler

这里设置了mCaptureCallback:

   PictureCaptureCallback mCaptureCallback = new PictureCaptureCallback() {

@Override
public void onPrecaptureRequired() {
mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START);
setState(STATE_PRECAPTURE);
try {
mCaptureSession.capture(mPreviewRequestBuilder.build(), this, null);
mPreviewRequestBuilder.set(CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE);
} catch (CameraAccessException e) {
Log.e(TAG, "Failed to run precapture sequence.", e);
}
}

@Override
public void onReady() {
captureStillPicture();
}

};

\

预览

Camera2中,通过连续重复的Capture实现预览功能,每次Capture会把预览画面显示到对应的Surface上。连续重复的Capture操作通过mCaptureSession.setRepeatingRequest(mPreviewRequest,mCaptureCallback, mBackgroundHandler)实现,该方法有三个参数:

  • request:CaptureRequest对象
  • listener:监听Capture 状态的回调
  • handler:用于执行CameraCaptureSession.CaptureCallback的Handler对象,传入null则使用当前的主线程Handler

停止预览使用mCaptureSession.stopRepeating()方法。

\

释放相机资源

先后对CaptureSession,CameraDevice,ImageReader进行close操作,释放资源

void stop() {
if (mCaptureSession != null) {
mCaptureSession.close();
mCaptureSession = null;
}
if (mCamera != null) {
mCamera.close();
mCamera = null;
}
if (mImageReader != null) {
mImageReader.close();
mImageReader = null;
}
}

\

Cmaera2做了哪些优化

架构升级

参考 android 官网 source.android.com/devices/cam…

架构上,Camera2 的 API 模型被设计成一个 Pipeline(管道),它按顺序处理每一帧的请求并返回请求结果给客户端。下面这张来自官方的图展示了 Pipeline 的工作流程

\

在CaptureRequest中设置不同的Surface用于接收不同的图片数据,最后从不同的Surface中获取到图片数据和包含拍照相关信息的CaptureResult

假设我们想要同时拍摄两张不同尺寸的图片,并且在拍摄的过程中闪光灯必须亮起来。整个拍摄流程如下:

  1. 创建一个用于从 Pipeline 获取图片的 CaptureRequest。
  2. 修改 CaptureRequest 的闪光灯配置,让闪光灯在拍照过程中亮起来。
  1. 创建两个不同尺寸的 Surface 用于接收图片数据,并且将它们添加到 CaptureRequest 中。
  2. 发送配置好的 CaptureRequest 到 Pipeline 中等待它返回拍照结果。

一个新的 CaptureRequest 会被放入一个被称作 Pending Request Queue 的队列中等待被执行,当 In-Flight Capture Queue 队列空闲的时候就会从 Pending Request Queue 获取若干个待处理的 CaptureRequest,并且根据每一个 CaptureRequest 的配置进行 Capture 操作。最后我们从不同尺寸的 Surface 中获取图片数据并且还会得到一个包含了很多与本次拍照相关的信息的 CaptureResult,流程结束。

其中Caputure有以下三种工作模式

  • 单次模式 One-shot

指的是一次的Capture操作,例如设置闪光灯、对焦模式、拍一张照片,多个单次模式的Capture会进入队列按照顺序执行

  • 多次模式 Burst

指的是连续多次执行指定的Capture操作,该模式执行期间不允许插入其他Capture操作,如连续拍摄100张照片,在这100张照片拍摄期间任何新的capture都会等待

  • 重复模式 Repeating

指的是不断重复执行指定的Capture操作,当有其他模式的Capture提交会暂停改模式转而执行其他模式的Capture

\

Supported Hardware Level

相机功能的强大与否和硬件息息相关,不同厂商对 Camera2 的支持程度也不同,所以 Camera2 定义了一个叫做 Supported Hardware Level 的重要概念,其作用是将不同设备上的 Camera2 根据功能的支持情况划分成多个不同级别以便开发者能够大概了解当前设备上 Camera2 的支持情况。截止到 Android P 为止,从低到高一共有 LEGACY、LIMITED、FULL 和 LEVEL_3 四个级别:

  • LEGACY:向后兼容的级别,处于该级别的设备意味着它只支持 Camera1 的功能,不具备任何 Camera2 高级特性。
  • LIMITED:除了支持 Camera1 的基础功能之外,还支持部分 Camera2 高级特性的级别。
  • FULL:支持所有 Camera2 的高级特性。
  • LEVEL_3:新增更多 Camera2 高级特性,例如 YUV 数据的后处理等

\

新特性

  • 支持在开启相机前检查相机信息
  • 在不开启预览情况下拍照
  • 一次拍摄多张不同格式和尺寸的图片
  • 控制曝光时间
  • 连拍

\

简化开发谷歌做的努力

其他优秀三方库

github.com/CameraKit/c…

github.com/natario1/Ca…

CameraView

主要是为了解决不同版本Camer使用兼容性问题

根据官方的说明:

API LevelCamera APIPreview View
9-13Camera1SurfaceView
14-20Camera1TextureView
21-23Camera2TextureView
24Camera2SurfaceView

  • Camera 区分:Android5.0(21)以下使用 Camera1,以上使用 Camera2
  • Preview View:Android6.0(23)以上使用SurfaceView(SurfaceView在Android7.0上增加了新特性(平移、旋转等)),这里应该是 Android7.0以上(>23)使用SurfaceView,其他都使用TextureView,最新的源码sdk最低版本要求14。

类图如下

通过桥接模式+ 适配器模式,抽象出相机操作的抽象类CameraViewImpl和预览抽象类PreviewImpl,业务侧只操作接口

public CameraView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
……
// Internal setup
// 1.创建预览视图
final PreviewImpl preview = createPreviewImpl(context);
mCallbacks = new CallbackBridge();
// 2.根据 Android SDK 版本选择不同的 Camera
if (Build.VERSION.SDK_INT < 21) {
mImpl = new Camera1(mCallbacks, preview);
} else if (Build.VERSION.SDK_INT < 23) {
mImpl = new Camera2(mCallbacks, preview, context);
} else {
mImpl = new Camera2Api23(mCallbacks, preview, context);
}
……
// 设置相机 ID,如前置或者后置
setFacing(a.getInt(R.styleable.CameraView_facing, FACING_BACK));
// 设置预览界面的比例,如 4:3 或者 16:9
String aspectRatio = a.getString(R.styleable.CameraView_aspectRatio);
if (aspectRatio != null) {
setAspectRatio(AspectRatio.parse(aspectRatio));
} else {
setAspectRatio(Constants.DEFAULT_ASPECT_RATIO);
}
// 设置对焦方式
setAutoFocus(a.getBoolean(R.styleable.CameraView_autoFocus, true));
// 设置闪光灯
setFlash(a.getInt(R.styleable.CameraView_flash, Constants.FLASH_AUTO));
a.recycle();
// Display orientation detector
// 初始化显示设备(主要指手机屏幕)的旋转监听,主要用来设置相机的旋转方向
mDisplayOrientationDetector = new DisplayOrientationDetector(context) {
@Override
public void onDisplayOrientationChanged(int displayOrientation) {
mImpl.setDisplayOrientation(displayOrientation);
}
};
}

createPreViewImpl实现

private PreviewImpl createPreviewImpl(Context context) {
PreviewImpl preview;
if (Build.VERSION.SDK_INT >= 23) {
preview = new SurfaceViewPreview(context, this);
} else {
preview = new TextureViewPreview(context, this);
}
return preview;
}

使用起来也比较简单

<com.google.android.cameraview.CameraView
android:id="@+id/camera"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:keepScreenOn="true"
android:adjustViewBounds="true"
app:autoFocus="true"
app:aspectRatio="4:3"
app:facing="back"
app:flash="auto"/>

CameraX


CameraX 是一个 Jetpack 支持库,目的是简化Camera的开发工作,它是基于Camera2 API的基础,向后兼容至 Android 5.0(API 级别 21)。
它有以下几个特性:

  • 易用性,只需要几行代码就可以实现预览和拍照
  • 保持设备的一致性,在不同相机设备上,对宽高比、屏幕方向、旋转、预览大小和高分辨率图片大小,做到都可以正常使用
  • 相机特性的扩展,增加人像、HDR、夜间模式和美颜等功能
  • 具备生命周期的管理


作者:渡口一艘船
链接:https://juejin.cn/post/7008579597696499742
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

收起阅读 »

JetPack——ViewModel简析

简介ViewModel以生命周期的方式存储和管理界面相关的数据。让数据在发生屏幕旋转等配置更改后得以继续留存。同时,可以将数据操作从UI控制器(Activity)里分离出来,这样就只需要Activity控制UI逻辑而无需处理数据业务逻辑。在需要进行一些异步操作...
继续阅读 »

简介

ViewModel以生命周期的方式存储和管理界面相关的数据。让数据在发生屏幕旋转等配置更改后得以继续留存。同时,可以将数据操作从UI控制器(Activity)里分离出来,这样就只需要Activity控制UI逻辑而无需处理数据业务逻辑。在需要进行一些异步操作的时候,免去了在Activity里大量的维护工作,并避免了在Activity销毁时潜在的内存泄漏问题。

总结一下主要优点:

可以更容易的将数据操作逻辑与Activity分离。

ViewModel的实现和基本使用

JetPack为UI控制器提供了 ViewModel 辅助程序类,该类负责为界面准备数据。在配置更改期间会自动保留 ViewModel 对象,以便它们存储的数据立即可供下一个 activity 或 fragment 实例使用。

代码如下:

public class MyViewModel extends ViewModel {
private MutableLiveData<List<String>> users;
public LiveData<List<String>> getUsers() {
if (users == null) {
users = new MutableLiveData<List<String>>();
loadUsers();
}
return users;
}

private void loadUsers() {

}
}

使用:


public class MainActivity extends AppCompatActivity {

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);

model.getUsers().observe(this, users -> {

});

}
}

ViewModel的创建

注意在上面的代码中,并没有采用new MyViewModel()的方式去创建ViewModel。而是使用:

MyViewModel model = new ViewModelProvider(this).get(MyViewModel.class);

对应的在kotlin中使用:

private val myViewModel: MyViewModel by viewModels();

先说结论:使用ViewModelProvider去创建ViewModel,确保了重新创建了相同Activity时,它接收的MyViewModel实例与第一个Activity创建的ViewModel实例相同。可以简单的理解为他是一个单例。

接下来看源码是如何实现的:

首先看ViewModelProvider的构造方法和实现:


private final Factory mFactory;

private final ViewModelStore mViewModelStore;

public ViewModelProvider(@NonNull ViewModelStoreOwner owner) {

this(owner.getViewModelStore(), owner instanceof HasDefaultViewModelProviderFactory? ((HasDefaultViewModelProviderFactory) owner).getDefaultViewModelProviderFactory():NewInstanceFactory.getInstance());

}

public ViewModelProvider(@NonNull ViewModelStore store, @NonNull Factory factory) {

mFactory = factory;

mViewModelStore = store;

}

构造方法很简单,创建一个ViewModelStoreFactory

准备工作

ViewModelStore解析

查看ViewModelStore的代码如下:


public class ViewModelStore {

private final HashMap<String, ViewModel> mMap = new HashMap<>();

final void put(String key, ViewModel viewModel) {

ViewModel oldViewModel = mMap.put(key, viewModel);

if (oldViewModel != null) {

oldViewModel.onCleared();

}

}

final ViewModel get(String key) {

return mMap.get(key);

}

Set<String> keys() {

return new HashSet<>(mMap.keySet());
}

public final void clear() {

for (ViewModel vm : mMap.values()) {

vm.clear();
}

mMap.clear();

}

}

代码很简单,就是一个HashMap存储ViewModel的键值对(HashMap源码要点解析)。

ViewModelStore通过接口ViewModelStoreOwnergetViewModelStore()方法获取,以ComponentActivity为例,具体实现如下:


//ComponentActivity.java:

static final class NonConfigurationInstances {

Object custom;

ViewModelStore viewModelStore;

}

@NonNull

@Override

public ViewModelStore getViewModelStore() {

if (getApplication() == null) {

throw new IllegalStateException("Your activity is not yet attached to the "

+ "Application instance. You can't request ViewModel before onCreate call.");

}

ensureViewModelStore();

return mViewModelStore;

}

@SuppressWarnings("WeakerAccess") /* synthetic access */

void ensureViewModelStore() {

if (mViewModelStore == null) {

NonConfigurationInstances nc =(NonConfigurationInstances) getLastNonConfigurationInstance();

if (nc != null) {

mViewModelStore = nc.viewModelStore;
}

if (mViewModelStore == null) {

mViewModelStore = new ViewModelStore();

}

}

}

其中getLastNonConfigurationInstance方法实际上是返回一个activity,也可以理解为是一个ComponentActivity的实例,在这里不做深入讨论,其在Activity中的具体实现为:


@Nullable

public Object getLastNonConfigurationInstance() {

return mLastNonConfigurationInstances != null? mLastNonConfigurationInstances.activity : null;

}

不难发现,每个Activity都会持有一个mViewModelStoreensureViewModelStore()方法确保了所有ViewModel获取到的ViewModelStore都为同一个。也就说:Activity通过持有mViewModelStore,使用HaspMap管理ViewModel。也不难发现,activity和ViewModel是一对多的关系。

Factory解析

Factory顾名思义,就是一个工厂模式。它是一个定义在ViewModelProvider中的接口,代码很简单:


public interface Factory {

@NonNull

<T extends ViewModel> T create(@NonNull Class<T> modelClass);

}

就是用来创建ViewModel的。

AppCompatActivity为例:它通过继承ComponentActivity,实现了ViewModelStoreOwner接口,而在ViewModelProvider初始化时,结合上面的代码,Factory的具体实现为:NewInstanceFactory.getInstance();

具体代码如下:

public static class NewInstanceFactory implements Factory {

private static NewInstanceFactory sInstance;


@NonNull
static NewInstanceFactory getInstance() {
if (sInstance == null) {
sInstance = new NewInstanceFactory();
}
return sInstance;
}

@SuppressWarnings("ClassNewInstance")
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
try {
return modelClass.newInstance();
} catch (InstantiationException e) {
throw new RuntimeException("Cannot create an instance of " + modelClass, e);
} catch (IllegalAccessException e) {
throw new RuntimeException("Cannot create an instance of " + modelClass, e);
}
}
}

一个静态单例,返回一个用来创建ViewModel的工厂类,使用create()方法通过反射创建ViewModel实例。

真正创建

完成了准备工作, 接下来就看ViewModel具体的创建过程:


@NonNull

@MainThread

public <T extends ViewModel> T get(@NonNull Class<T> modelClass) {

String canonicalName = modelClass.getCanonicalName();

if (canonicalName == null) {

throw new IllegalArgumentException("Local and anonymous classes can not be ViewModels");

}

//用类名作为Key值

return get(DEFAULT_KEY + ":" + canonicalName, modelClass);

}

@NonNull

@MainThread

public <T extends ViewModel> T get(@NonNull String key, @NonNull Class<T> modelClass) {

//首先查看Map中是否有当前ViewModel的实例

ViewModel viewModel = mViewModelStore.get(key);

if (modelClass.isInstance(viewModel)) {

if (mFactory instanceof OnRequeryFactory) {

((OnRequeryFactory) mFactory).onRequery(viewModel);

}

return (T) viewModel;

} else {

if (viewModel != null) {
}

}

///没有实例就创建实例

if (mFactory instanceof KeyedFactory) {

viewModel = ((KeyedFactory) mFactory).create(key, modelClass);

} else {

viewModel = mFactory.create(modelClass);

}

mViewModelStore.put(key, viewModel);

return (T) viewModel;

}

可以看到,首先使用类名作为key值,判断Activity所持有的ViewModelStore是否包含当前ViewModel的实例,有就直接拿来使用。如果没有,就通过上文中的Factory.create创建一个实例,并添加到mViewModelStore中。

总结一下:

  1. Activity通过HashMap持有所有的实例化后的ViewModel。

  2. ViewModelProvider通过单例工厂模式和ViewModelStore,也就是HashMap实现对ViewModel的创建和获取,以此保证ViewModel在当前Activity中保持单例。

ViewModel的生命周期

ViewModel 对象存在的时间范围是获取 ViewModel 时传递给 ViewModelProvider 的 Lifecycle。ViewModel 将一直留在内存中,直到限定其存在时间范围的 Lifecycle 永久消失:对于 activity,是在 activity 完成时;而对于 fragment,是在 fragment 分离时。

其生命周期如下图所示:

image.png

通常在Activity的onCreate()方法里创建/获取ViewModel。若在旋转设备屏幕时,再次调用onCreate(),结合上文中ViewModel的创建,其实并不会创建新的ViewModel实例,而是从HashMap中取出本就存在的实例。

因此:ViewModel 存在的时间范围是从您首次请求 ViewModel 直到 activity 完成并销毁。

ViewModel的销毁

销毁源码如下:

@SuppressWarnings("WeakerAccess")
protected void onCleared() {
}

@MainThread
final void clear() {

if (mBagOfTags != null) {
synchronized (mBagOfTags) {
for (Object value : mBagOfTags.values()) {
// see comment for the similar call in setTagIfAbsent
closeWithRuntimeException(value);
}
}
}
onCleared();
}

其中:clear()方法在ViewModelStore中调用,其相关代码在上文中已展示:

public final void clear() {
for (ViewModel vm : mMap.values()) {
vm.clear();
}
mMap.clear();
}

ViewModelStore.clear()则在Activity中被调用:

getLifecycle().addObserver(new LifecycleEventObserver() {
@Override
public void onStateChanged(@NonNull LifecycleOwner source,
@NonNull Lifecycle.Event event) {
if (event == Lifecycle.Event.ON_DESTROY) {
// Clear out the available context
mContextAwareHelper.clearAvailableContext();
// And clear the ViewModelStore
if (!isChangingConfigurations()) {
getViewModelStore().clear();
}
}
}
});

通过Lifecycle管理的Activity的生命周期,在Activity销毁时,也就是onDestory时调用。

带参数的ViewModel实现

既然ViewModel不可以通过new关键字来初始化,那么如果需要再初始化时向ViewModel传参该怎么办呢。例如:

public class MyViewModel extends ViewModel {

private String tag;

public MyViewModel(String tag) {
this.tag = tag;
}

private MutableLiveData<List<String>> users;

public LiveData<List<String>> getUsers() {
if (users == null) {
users = new MutableLiveData<List<String>>();
loadUsers();
}
return users;
}

private void loadUsers() {

}
}

根据上文中的代码解析,不难发现,我们需要重写Factorycreate方法,结合ViewModelProvider的构造函数,可以创建一个工厂类:

class MyViewModelFactory implements ViewModelProvider.Factory {
private String tag;

public MyViewModelFactory(String tag) {
this.tag = tag;
}

@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new MyViewModel(tag);
}
}

具体实现:


MyViewModelFactory myViewModelFactory = new MyViewModelFactory("TAG");

MyViewModel model = new ViewModelProvider(this,myViewModelFactory).get(MyViewModel.class);

当然了,也可以使用匿名内部类的方式实现:

MyViewModel model = new ViewModelProvider(this, new ViewModelProvider.Factory(){
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new MyViewModel("tag");
}
}).get(MyViewModel.class);

在kolin中的扩展

koltin中使用扩展函数简化了ViewModel的实现:

@MainThread
public inline fun <reified VM : ViewModel> Fragment.viewModels(
noinline ownerProducer: () -> ViewModelStoreOwner = { this },
noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> = createViewModelLazy(VM::class, { ownerProducer().viewModelStore }, factoryProducer)

@MainThread
public fun <VM : ViewModel> Fragment.createViewModelLazy(
viewModelClass: KClass<VM>,
storeProducer: () -> ViewModelStore,
factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
val factoryPromise = factoryProducer ?: {
defaultViewModelProviderFactory
}
return ViewModelLazy(viewModelClass, storeProducer, factoryPromise)
}


public class ViewModelLazy<VM : ViewModel> (
private val viewModelClass: KClass<VM>,
private val storeProducer: () -> ViewModelStore,
private val factoryProducer: () -> ViewModelProvider.Factory
) : Lazy<VM> {
private var cached: VM? = null

override val value: VM
get() {
val viewModel = cached
return if (viewModel == null) {
val factory = factoryProducer()
val store = storeProducer()
ViewModelProvider(store, factory).get(viewModelClass.java).also {
cached = it
}
} else {
viewModel
}
}

override fun isInitialized(): Boolean = cached != null
}

其本质上还是用ViewModelProvider创建,只不过加了一些Kotlin的语法糖,简化了操作。

不仅如此,Kotlin还是支持使用注解,这样就可以省去创建Factory的麻烦。刚兴趣的可以自己去探索(Hilt 和 Jetpack 集成

总结

  • Activity持有ViewModel,一个Activity可以有多个类型的ViewModel;

  • 同一个类型的ViewModel在Activity中有且只有一个实例;

  • 通过HashMap和工厂模式保证了单一类型ViewModel的单例;

  • ViewModel生命周期贯穿整个Activity,且不会重复创建。

收起阅读 »

android高仿微信聊天消息列表自由复制文字,双击查看文本内容

掘金地址 github地址SelectTextHelper打造一个全网最逼近微信聊天消息自由复制,双击查看文本内容框架。 汇聚底层TextView框架、原理并加以整理得出的一个实用的Helper。 仅用两个类实现便实现如此强大的功能,用法也超级简单。...
继续阅读 »

掘金地址 github地址

SelectTextHelper打造一个全网最逼近微信聊天消息自由复制,双击查看文本内容框架。 汇聚底层TextView框架、原理并加以整理得出的一个实用的Helper。 仅用两个类实现便实现如此强大的功能,用法也超级简单。

项目演示

消息页效果查看内容效果
1631677218586.gif1631678150191.gif
 
消息页全选消息页自由复制放大镜
demo_1.jpgdemo_2.jpg
 
消息页选中文本查看内容
demo_3.jpgdemo_4.jpg

特点功能:

  • 支持自由选择文本
  • 支持自定义文本有:游标颜色、游标大小、选中文本颜色
  • 支持默认全选文字或选2个文字
  • 支持滑动依然显示弹窗
  • 支持放大镜功能
  • 支持全选情况下自定义弹窗
  • 支持操作弹窗:每行个数、图片、文字、监听回调、弹窗颜色、箭头图片
  • 支持 AndroidX

Demo

下载 APK-Demo

传送门

主要实现

通过 仿照的例子 并改进弹窗坐标位置、大小加上EventBus实现

简单用例

1.导入代码

把该项目里的selecttext Module放入你的项目里面

2.给你的 TextView 创建Helper和加监听

SelectTextHelper mSelectableTextHelper=new SelectTextHelper
.Builder(textView)// 放你的textView到这里!!
.setCursorHandleColor(0xFF1379D6/*mContext.getResources().getColor(R.color.colorAccent)*/)// 游标颜色 default 0xFF1379D6
.setCursorHandleSizeInDp(24)// 游标大小 单位dp default 24
.setSelectedColor(0xFFAFE1F4/*mContext.getResources().getColor(R.color.colorAccentTransparent)*/)// 选中文本的颜色 default 0xFFAFE1F4
.setSelectAll(true)// 初次选中是否全选 default true
.setScrollShow(true)// 滚动时是否继续显示 default true
.setSelectedAllNoPop(true)// 已经全选无弹窗,设置了true在监听会回调 onSelectAllShowCustomPop 方法 default false
.setMagnifierShow(true)// 放大镜 default true
.addItem(0/*item的图标*/,"复制"/*item的描述*/, // 操作弹窗的每个item
()->Log.i("SelectTextHelper","复制")/*item的回调*/)
.build();

mSelectableTextHelper.setSelectListener(new SelectTextHelper.OnSelectListener(){
/**
* 点击回调
*/

@Override
public void onClick(View v){
// clickTextView(textView.getText().toString().trim());
}

/**
* 长按回调
*/

@Override
public void onLongClick(View v){
// postShowCustomPop(SHOW_DELAY);
}

/**
* 选中文本回调
*/

@Override
public void onTextSelected(CharSequence content){
// selectedText = content.toString();
}

/**
* 弹窗关闭回调
*/

@Override
public void onDismiss(){
}

/**
* 点击TextView里的url回调
*/

@Override
public void onClickUrl(String url){
}

/**
* 全选显示自定义弹窗回调
*/

@Override
public void onSelectAllShowCustomPop(){
// postShowCustomPop(SHOW_DELAY);
}

/**
* 重置回调
*/

@Override
public void onReset(){
// SelectTextEventBus.getDefault().dispatch(new SelectTextEvent("dismissOperatePop"));
}

/**
* 解除自定义弹窗回调
*/

@Override
public void onDismissCustomPop(){
// SelectTextEventBus.getDefault().dispatch(new SelectTextEvent("dismissOperatePop"));
}

/**
* 是否正在滚动回调
*/

@Override
public void onScrolling(){
// removeShowSelectView();
}
});

3.demo中提供了查看文本内容的SelectTextDialog 和 消息列表自由复制MainActivity

查看文本内容方法:

  • 该方法比较简单,将textView参照步骤2放入SelectTextHelper中,在dismiss调用SelectTextHelper的reset()即可。
@Override
public void dismiss(){
mSelectableTextHelper.reset();
super.dismiss();
}

高仿微信聊天消息列表自由复制方法:

  • recycleView + adapter + 多布局的使用在这里不阐述,请看本项目demo。

  • 为adapter里text类型ViewHolder中的textView参照步骤2放入SelectTextHelper中,注册SelectTextEventBus。

  • SelectTextEventBus类特别说明、原理: SelectTextEventBus在EventBus基础上加功能。在register时记录下类和方法,方便在Activity/Fragment Destroy时unregister所有SelectTextEventBus的EventBus。

  • text类型ViewHolder 添加EventBus监听

/**
* 自定义SelectTextEvent 隐藏 光标
*/

@Subscribe(threadMode = ThreadMode.MAIN)
public void handleSelector(SelectTextEvent event){
if(null==mSelectableTextHelper){
return;
}
String type=event.getType();
if(TextUtils.isEmpty(type)){
return;
}
switch(type){
case"dismissAllPop":
mSelectableTextHelper.reset();
break;
case"dismissAllPopDelayed":
postReset(RESET_DELAY);
break;
}
}
  • 重写adapter里的onViewRecycled方法,该方法在回收View时调用
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder){
super.onViewRecycled(holder);
if(holder instanceof ViewHolderText){
// 注销
SelectTextEventBus.getDefault().unregister(holder);
}
}
  • 防抖
/**
* 延迟显示CustomPop
* 防抖
*/

private void postShowCustomPop(int duration){
textView.removeCallbacks(mShowCustomPopRunnable);
textView.postDelayed(mShowCustomPopRunnable,duration);
}

private final Runnable mShowCustomPopRunnable=
()->showCustomPop(text_rl_container,textMsgBean);

/**
* 延迟重置
* 为了支持滑动不重置
*/

private void postReset(int duration){
textView.removeCallbacks(mShowSelectViewRunnable);
textView.postDelayed(mShowSelectViewRunnable,duration);
}

private void removeShowSelectView(){
textView.removeCallbacks(mShowSelectViewRunnable);
}

private final Runnable mShowSelectViewRunnable=
()->mSelectableTextHelper.reset();

如果使用 AndroidX 先在 gradle.properties 中添加,两行都不能少噢~

android.useAndroidX=true
android.enableJetifier=true

收起阅读 »

iOS 开发Tips

iOS
开发Tips关于Xcode 12的Tab贡献者:highway不知道有多少同学困惑于Xcode 12的新tab模式,反正我是觉得这种嵌套的tab形式还不如旧版简洁明了。想切回旧版本tab模式的,可以按照此文操作: How to fix the inc...
继续阅读 »

开发Tips

关于Xcode 12的Tab

贡献者:highway

不知道有多少同学困惑于Xcode 12的新tab模式,反正我是觉得这种嵌套的tab形式还不如旧版简洁明了。

想切回旧版本tab模式的,可以按照此文操作: How to fix the incomprehensible tabs in Xcode 12

通过实验发现,Xcode 12下的“子tab”有以下几个特点:

A.当单击文件打开时,tab将显示为斜体,如果双击,则以普通字体显示。斜体表示为“临时”tab,普通字体表示为“静态”tab;

B.双击tab顶部文件名,或者对“临时”tab编辑后,“临时”tab将切换为“静态”tab;

C.如果当前位于“静态”tab,新打开的文件会新起一个tab,并排在当前tab之后;

D.新打开的“临时”文件会在原有的“临时”tab中打开,而不会新起一个“临时”tab;

E.使用Command + Shift + O打开的是“临时”文件。

modalPresentationCapturesStatusBarAppearance

贡献者:beatman423

这边遇到的问题是非全屏present一个导航控制器的时候,咋也控制不了这个导航控制器以及其子控制器的状态栏的style和hidden。后来找到了UIViewController的这个属性,将其设置为YES就可以了。

该属性的描述是:

Specifies whether a view controller, presented non-fullscreen, takes over control of status bar appearance from the presenting view controller. Defaults to NO.

那些Bug

fishhook在某些场景下只生效一次

贡献者:皮拉夫大王在此

问题背景

之前我们监控到钥匙串的API在主线程访问时存在卡死的情况,因此hook 相关API,将访问移到子线程。因此使用到fishook,当时测试并没有发现异常。

问题描述

前段时间在做技术优化时发现我们的hook代码只生效了一次,下次访问API时变成了直接访问系统原方法。

问题原因

由于hook之前没有调用过钥匙串API,因此可能此时并没有做bind,在我们hook后bind信息被替换成我们的函数,因此首次调用hook成功。但是在hook方法中我们又调用了原函数,此时触发了bind,内存中的函数地址又被替换成系统函数,因此第二次调用时hook失败。 解决方案:见https://github.com/facebook/fishhook/issues/36

编程概念

整理编辑:师大小海腾zhangferry

什么是 Homebrew

Homebrew 是一款 Mac OS 平台下的软件包管理工具,拥有安装、卸载、更新、查看、搜索等很多实用的功能。简单的一条指令,就可以实现包管理,而不用你关心各种依赖和文件路径的情况,十分方便快捷。

安装方法:

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

国内镜像:

$ /bin/bash -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"

什么是 Ruby

Ruby 是一种开源的面向对象程序设计的服务器端脚本语言,在 20 世纪 90 年代中期由日本的松本行弘设计并开发。在 Ruby 社区,松本也被称为马茨(Matz)。

Ruby的设计和Objective-C有些类似,都是受Smalltalk的影响。而这也一定程度促进了iOS开发工具较为广泛的使用Ruby写成。

较为知名的几个由Ruby写成的iOS开发工具有:CocoaPods、Fastlane、xcpretty。那这些库为啥使用Ruby开来发呢?

来自CocoaPods的主要作者Eloy Duran的说法:

Ruby和Objective-C具有很多来自Smalltalk的特性,有一定相似性;使用Ruby可以在Bundler和RubyGem之间分享代码;早期阶段MacRuby提供了很多解析Xcode projects的方法;作为CLI工具,Ruby具有强大的字符串处理能力。

来自Fastlane工具链的作者之一Felix的说法:

已经有部分iOS工具选择了Ruby,像是CocoaPods以及给Fastlane的开发带来灵感的nomad-cli。使用Ruby将会更容易与这些工具进行对接。

参考来源:A History of Ruby inside iOS Development

什么是 Rails

Rails(也叫Ruby on Rails)框架首次提出是在 2004 年 7 月,它的研发者是 26 岁的丹麦人 David Heinemeier Hansson。Rails 是使用 Ruby 语言编写的 Web 应用开发框架,目的是通过解决快速开发中的共通问题,简化 Web 应用的开发。与其他编程语言和框架相比,使用 Rails 只需编写更少代码就能实现更多功能。有经验的 Rails 程序员常说,Rails 让 Web 应用开发变得更有趣。

Rails的两大哲学是:不要自我重复(DRY),多约定,少配置。

松本行弘说过:Ruby能拥有现在的人气,基本上都是Ruby on Rails所作出的贡献。

什么是 rbenv

rbenv 和 RVM 都是目前流行的 Ruby 环境管理工具,它们都能提供不同版本的 Ruby 环境管理和切换。

进行 Ruby 版本管理的时候更推荐 rbenv 的方式,你也可以参考 rbenv 官方的 Why choose rbenv over RVM?,当前 rbenv 有两种安装方式:

手动安装

git clone https://github.com/rbenv/rbenv.git ~/.rbenv
# 用来编译安装 ruby
git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
# 用来管理 gemset, 可选, 因为有 bundler 也没什么必要
git clone git://github.com/jamis/rbenv-gemset.git ~/.rbenv/plugins/rbenv-gemset
# 通过 rbenv update 命令来更新 rbenv 以及所有插件, 推荐
git clone git://github.com/rkh/rbenv-update.git ~/.rbenv/plugins/rbenv-update
# 使用 Ruby China 的镜像安装 Ruby, 国内用户推荐
git clone git://github.com/AndorChen/rbenv-china-mirror.git ~/.rbenv/plugins/rbenv-china-mirror

homebrew安装

$ brew install rbenv

配置

安装完成后,把以下的设置信息放到你的 Shell 配置文件里面,以保证每次打开终端的时候都会初始化 rbenv。

export PATH="$HOME/.rbenv/bin:$PATH" 
eval "$(rbenv init -)"
# 下面这句选填
export RUBY_BUILD_MIRROR_URL=https://cache.ruby-china.com

配置Ruby 环境,需重开一个终端。

$ rbenv install --list               # 列出所有 ruby 版本
$ rbenv install 2.7.3 # 安装 2.7.3版本
$ rbenv versions # 列出安装的版本
$ rbenv version # 列出正在使用的版本
# 下面三个命令可以根据需求使用
$ rbenv global 2.7.3 # 默认使用 1.9.3-p392
$ rbenv shell 2.7.3 # 当前的 shell 使用 2.7.3, 会设置一个`RBENV_VERSION` 环境变量
$ rbenv local 2.7.3 # 当前目录使用 2.7.3, 会生成一个 `.rbenv-version` 文件

什么是 RubyGems

The RubyGems software allows you to easily download, install, and use ruby software packages on your system. The software package is called a “gem” which contains a packaged Ruby application or library.

RubyGems 是 Ruby 的一个依赖包管理工具,管理着 Gem。用 Ruby 编写的工具或依赖包都称为 Gem。

RubyGems 还提供了 Ruby 组件的托管服务,可以集中式的查找和安装 library 和 apps。当我们使用 gem install 命令安装 Gem 时,会通过 rubygems.org 来查询对应的 Gem Package。而 iOS 日常中的很多工具都是 Gem 提供的,例如 Bundler,fastlane,jazzy,CocoaPods 等。

在默认情况下 Gems 总是下载 library 的最新版本,这无法确保所安装的 library 版本符合我们预期。因此还需要 Gem Bundler 配合。

参考:版本管理工具及 Ruby 工具链环境

什么是 Bundler

Bundler 是一个管理 Gem 依赖的 Gem,用来检查和安装指定 Gem 的特定版本,它可以隔离不同项目中 Gem 的版本和依赖环境的差异。

你可以执行 gem install bundler 命令安装 Bundler,接着执行 bundle init 就可以生成一个 Gemfile 文件,你可以在该文件中指定 CocoaPods 和 fastlane 等依赖包的特定版本号,比如:

source "https://rubygems.org"
gem "cocoapods", "1.10.0"
gem "fastlane", "> 2.174.0"

然后执行 bundle install 来安装 Gem。 Bundler 会自动生成一个 Gemfile.lock 文件来锁定所安装的 Gem 的版本。

这一步只是安装指定版本的 Gem,使用的时候我们需要在 Gem 命令前增加 bundle exec,以保证我们使用的是项目级别的 Gem 版本(也就是 Gemfile.lock 文件中锁定的 Gem 版本),而不是操作系统级别的 Gem 版本。

$ bundle exec pod install
$ bundle exec fastlane beta

参考:iOS开发进阶

优秀博客

整理编辑:皮拉夫大王在此

1、我在Uber亲历的最严重的工程灾难 -- 来自公众号:infoQ

准备或者已经接入Swfit可以先了解下

2、美团 iOS 工程 zsource 命令背后的那些事儿 -- 来自公众号: 美团技术团队

美团技术团队历史文章,对DWARF文件的另一种应用。文章还原了作者解决问题的思路历程,除了技术本身外,解决问题的思路历程也是值得借鉴的。

3、NSObject方法调用过程详细分析 -- 来自掘金:maniac_kk

字节跳动maniac_kk同学的一篇优质文章,无论深度还是广度都是非常不错的,很多底层知识融会贯通,值得细细品味

4、iOS疑难Crash的寄存器赋值追踪排查技术 -- 来自简书:欧阳大哥

在缺少行号信息时如何通过寄存器赋值推断出具体的问题代码,具有很高的参考价值,在遇到疑难问题时可以考虑是否能借鉴此思路

5、抖音 iOS 工程架构演进 -- 来自掘金:字节跳动技术团队

业务的发展引起工程架构做出调整,文章介绍了抖音的工程架构演进历程。作为日活过亿的产品,其工程架构的演变对多数APP来说都具有一定的借鉴意义。

6、Swift的一次函数式之旅 -- 来自公众号:搜狐技术产品

编程本身是抽象的,编程范例就是我们如何抽象这个世界的方法,而函数式编程就是其中一个编程范例。在函数式编程的世界里一切皆函数,那如何利用这个思想解决实际问题呢?文中给出了两个有趣的例子,希望可以帮你解决对函数式编程的疑惑。

7、Category无法覆写系统方法? -- 来自公众号:iOS成长之路

这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思。解决完全没有头绪的问题,我们应该避免陷入不断的猜测和佐证中。深挖问题,找到正确方向才更容易出现转机。

学习资料

整理编辑:Mimosa

1、CS-Notes

该「Notes」包含技术面试必备基础知识、Leetcode、计算机操作系统、计算机网络、系统设计、Java、Python、C++等内容,知识结构简练,内容扎实。该仓库的内容皆为作者及 Contributors 的原创,目前在 Github 上获 126k Stars。

2、Learn Git Branching

入门级的 Git 使用教程,用图形化的方式来介绍 Git 的各个命令,每一关都有一个小测试来巩固知识点。编者自己过了一遍了,体验很不错,同时填补了我自己一些 Git 知识上的漏洞和误区。

开发利器

整理编辑:brave723

OpenInTerminal

地址:https://github.com/Ji4n1ng/OpenInTerminal

软件状态:免费,开源

使用介绍

OpenInTerminal 是一款开发辅助工具,可以增强 Finder 工具栏以及右键菜单增加在当前位置打开终端的功能。另外还支持:在编辑器中打开当前目录以及在编辑器中打开选择的文件夹或文件

核心功能
  • 在终端(或编辑器)中打开目录或文件
  • 打开自定义应用
  • 支持 终端iTerm

SnippetsLib

地址:https://apps.apple.com/cn/app/snippetslab/id1006087419?mt=12

软件状态:$9.99

使用介绍

SnippetsLab是一款mac代码片段管理工具,使用SnippetsLab可以提高工作效率。它可以帮助您收集和组织有价值的代码片段,您可以随时轻松访问它们

收起阅读 »

iOS 14开发-网络

iOS
基础知识App如何通过网络请求数据?App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。主机通过本次网络请求指...
继续阅读 »

基础知识

App如何通过网络请求数据?

客户服务器模型

  1. App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。
  2. 主机通过本次网络请求指定的端口号找到对应的处理软件,然后将网络请求转发给该软件进行处理(处理的软件会运行在特定的端口)。针对 HTTP(HTTPS)请求,处理的软件会随着开发语言的不同而不同,如 Java 的 Tomcat、PHP 的 Apache、.net 的 IIS、Node.js 的 JavaScript 运行时等)
  3. 处理软件针对本次请求进行分析,分析的内容包括请求的方法、路径以及携带的参数等。然后根据这些信息,进行相应的业务逻辑处理,最后通过主机将处理后的数据返回(返回的数据一般为 JSON 字符串)。
  4. App 接收到主机返回的数据,进行解析处理,最后展示到界面上。
  5. 发送请求获取资源的一方称为客户端。接收请求提供服务的一方称为服务端

基本概念

URL

  • Uniform Resource Locator(统一资源定位符),表示网络资源的地址或位置。
  • 互联网上的每个资源都有一个唯一的 URL,通过它能找到该资源。
  • URL 的基本格式协议://主机地址/路径

HTTP/HTTPS

  • HTTP—HyperTextTransferProtocol:超文本传输协议。
  • HTTPS—Hyper Text Transfer Protocol over Secure Socket Layer 或 Hypertext Transfer Protocol Secure:超文本传输安全协议。

请求方法

  • 在 HTTP/1.1 协议中,定义了 8 种发送 HTTP 请求的方法,分别是GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT
  • 最常用的是 GET 与 POST

响应状态码

状态码描述含义
200Ok请求成功
400Bad Request客户端请求的语法出现错误,服务端无法解析
404Not Found服务端无法根据客户端的请求找到对应的资源
500Internal Server Error服务端内部出现问题,无法完成响应

请求响应过程

请求响应过程

JSON

  • JavaScript Object Notation。
  • 一种轻量级的数据格式,一般用于数据交互。
  • 服务端返回给 App 客户端的数据,一般都是 JSON 格式。

语法

  • 数据以键值对key : value形式存在。
  • 多个数据由,分隔。
  • 花括号{}保存对象。
  • 方括号[]保存数组。

key与value

  • 标准 JSON 数据的 key 必须用双引号""
  • JSON 数据的 value 类型:
    • 数字(整数或浮点数)
    • 字符串("表示)
    • 布尔值(true 或 false)
    • 数组([]表示)
    • 对象({}表示)
    • null

解析

  • 厘清当前 JSON 数据的层级关系(借助于格式化工具)。
  • 明确每个 key 对应的 value 值的类型。
  • 解析技术
    • Codable 协议(推荐)。
    • JSONSerialization。
    • 第三方框架。

URLSession

使用步骤

  1. 创建请求资源的 URL。
  2. 创建 URLRequest,设置请求参数。
  3. 创建 URLSessionConfiguration 用于设置 URLSession 的工作模式和网络设置。
  4. 创建 URLSession。
  5. 通过 URLSession 构建 URLSessionTask,共有 3 种任务。 (1)URLSessionDataTask:请求数据的 Task。  (2)URLSessionUploadTask:上传数据的 Task。 (3)URLSessionDownloadTask:下载数据的 Task。 
  6. 启动任务。
  7. 处理服务端响应,有 2 种方式。 (1)通过 completionHandler(闭包)处理服务端响应。 (2)通过 URLSessionDataDelegate(代理)处理请求与响应过程的事件和接收服务端返回的数据。

基本使用

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// get()
// post()
}

func get() {
// 1. 确定URL
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
// 2. 创建请求
let urlRequest = URLRequest(url: url!)
// cachePolicy: 缓存策略,App最常用的缓存策略是returnCacheDataElseLoad,表示先查看缓存数据,没有缓存再请求
// timeoutInterval:超时时间
// let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let config = URLSessionConfiguration.default
// 3. 创建URLSession
let session = URLSession(configuration: config)
// 4. 创建任务
let task = session.dataTask(with: urlRequest) { data, _, error in
if error != nil {
print(error!)
} else {
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
}
// 5. 启动任务
task.resume()
}

func post() {
let url = URL(string: "http://v.juhe.cn/toutiao/index")
var urlRequest = URLRequest(url: url!)
// 指明请求方法
urlRequest.httpMethod = "POST"
// 指明参数
let params = "type=top&key=申请的key"
// 设置请求体
urlRequest.httpBody = params.data(using: .utf8)
let config = URLSessionConfiguration.default
// delegateQueue决定了代理方法在哪个线程中执行
let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
let task = session.dataTask(with: urlRequest)
task.resume()
}
}

// MARK:- URLSessionDataDelegate
extension ViewController: URLSessionDataDelegate {
// 开始接收数据
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 允许接收服务器的数据,默认情况下请求之后不接收服务器的数据即不会调用后面获取数据的代理方法
completionHandler(URLSession.ResponseDisposition.allow)
}

// 获取数据
// 根据请求的数据量该方法可能会调用多次,这样data返回的就是总数据的一段,此时需要用一个全局的Data进行追加存储
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let result = String(data: data, encoding: .utf8)
if let result = result {
print(result)
}
}

// 获取结束
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
} else {
print("=======成功=======")
}
}
}

注意:如果网络请求是 HTTP 而非 HTTPS,默认情况下,iOS 会阻断该请求,此时需要在 Info.plist 中进行如下配置。

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

URL转码与解码

  • 当请求参数带中文时,必须进行转码操作。
let url = "https://www.baidu.com?name=张三"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(url) // URL中文转码
print(url.removingPercentEncoding!) // URL中文解码

  • 有时候只需要对URL中的中文处理,而不需要针对整个URL。
let str = "阿楚姑娘"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://music.163.com/#/search/m/?s=\(str)&type=1")

下载数据

class ViewController: UIViewController {
// 下载进度
@IBOutlet var downloadProgress: UIProgressView!
// 下载图片
@IBOutlet var downloadImageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

download()
}

func download() {
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/wall.png")!
let request = URLRequest(url: url)
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
let task = session.downloadTask(with: request)
task.resume()
}
}

extension ViewController: URLSessionDownloadDelegate {
// 下载完成
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// 存入沙盒
let savePath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
// 文件类型根据下载的内容决定
let fileName = "\(Int(Date().timeIntervalSince1970)).png"
let filePath = savePath + "/" + fileName
print(filePath)
do {
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: filePath))
// 显示到界面
DispatchQueue.main.async {
self.downloadImageView.image = UIImage(contentsOfFile: filePath)
}
} catch {
print(error)
}
}

// 计算进度
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
DispatchQueue.main.async {
self.downloadProgress.setProgress(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite), animated: true)
}
}
}

上传数据

上传数据需要服务端配合,不同的服务端代码可能会不一样,下面的上传代码适用于本人所写的服务端代码

  • 数据格式。

上传数据格式

  • 实现。
class ViewController: UIViewController {
let YFBoundary = "AnHuiWuHuYungFan"
@IBOutlet var uploadInfo: UILabel!
@IBOutlet var uploadProgress: UIProgressView!

override func viewDidLoad() {
super.viewDidLoad()

upload()
}

func upload() {
// 1. 确定URL
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/UploadServlet")!
// 2. 确定请求
var request = URLRequest(url: url)
// 3. 设置请求头
let head = "multipart/form-data;boundary=\(YFBoundary)"
request.setValue(head, forHTTPHeaderField: "Content-Type")
// 4. 设置请求方式
request.httpMethod = "POST"
// 5. 创建NSURLSession
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
// 6. 获取上传的数据(按照固定格式拼接)
var data = Data()
let header = headerString(mimeType: "image/png", uploadFile: "wall.png")
data.append(header.data(using: .utf8)!)
data.append(uploadData())
let tailer = tailerString()
data.append(tailer.data(using: .utf8)!)
// 7. 创建上传任务 上传的数据来自getData方法
let task = session.uploadTask(with: request, from: data) { _, _, error in
// 上传完毕后
if error != nil {
print(error!)
} else {
DispatchQueue.main.async {
self.uploadInfo.text = "上传成功"
}
}
}
// 8. 执行上传任务
task.resume()
}

// 开始标记
func headerString(mimeType: String, uploadFile: String) -> String {
var data = String()
// --Boundary\r\n
data.append("--" + YFBoundary + "\r\n")
// 文件参数名 Content-Disposition: form-data; name="myfile"; filename="wall.jpg"\r\n
data.append("Content-Disposition:form-data; name=\"myfile\";filename=\"\(uploadFile)\"\r\n")
// Content-Type 上传文件的类型 MIME\r\n\r\n
data.append("Content-Type:\(mimeType)\r\n\r\n")

return data
}

// 结束标记
func tailerString() -> String {
// \r\n--Boundary--\r\n
return "\r\n--" + YFBoundary + "--\r\n"
}

func uploadData() -> Data {
let image = UIImage(named: "wall.png")
let imageData = image!.pngData()
return imageData!
}
}

extension ViewController: URLSessionTaskDelegate {
// 上传进去
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.uploadProgress.setProgress(Float(totalBytesSent) / Float(totalBytesExpectedToSend), animated: true)
}
}

// 上传出错
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
}
}
}

URLCache

  • 网络缓存有很多好处:节省流量、更快加载、断网可用。
  • 使用 URLCache 管理缓存区域的大小和数据。
  • 每一个 App 都默认创建了一个 URLCache 作为缓存管理者,可以通过URLCache.shared获取,也可以自定义。
// 创建URLCache
// memoryCapacity:内存缓存容量
// diskCapacity:硬盘缓存容量
// directory:硬盘缓存路径
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, directory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first)
// 替换默认的缓存管理对象
URLCache.shared = cache

  • 常见属性与方法。
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let cache = URLCache.shared

// 内存缓存大小
cache.memoryCapacity
// 硬盘缓存大小
cache.diskCapacity
// 已用内存缓存大小
cache.currentMemoryUsage
// 已用硬盘缓存大小
cache.currentDiskUsage
// 获取某个请求的缓存
let cacheResponse = cache.cachedResponse(for: urlRequest)
// 删除某个请求的缓存
cache.removeCachedResponse(for: urlRequest)
// 删除某个时间点开始的缓存
cache.removeCachedResponses(since: Date().addingTimeInterval(-60 * 60 * 48))
// 删除所有缓存
cache.removeAllCachedResponses()

WKWebView

  • 用于加载 Web 内容的控件。
  • 使用时必须导入WebKit模块。

基本使用

  • 加载网页。
// 创建URL
let url = URL(string: "https://www.abc.edu.cn")
// 创建URLRequest
let request = URLRequest(url: url!)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载网页
webView.load(request)

  • 加载本地资源。
// 文件夹路径
let basePath = Bundle.main.path(forResource: "localWeb", ofType: nil)!
// 文件夹URL
let baseUrl = URL(fileURLWithPath: basePath, isDirectory: true)
// html路径
let filePath = basePath + "/index.html"
// 转成文件
let fileContent = try? NSString(contentsOfFile: filePath, encoding: String.Encoding.utf8.rawValue)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载html
webView.loadHTMLString(fileContent! as String, baseURL: baseUrl)

注意:如果是本地资源是文件夹,拖进项目时,需要勾选Create folder references,然后用Bundle.main.path(forResource: "文件夹名", ofType: nil)获取资源路径。

与JavaScript交互

创建WKWebView

lazy var webView: WKWebView = {
// 创建WKPreferences
let preferences = WKPreferences()
// 开启JavaScript
preferences.javaScriptEnabled = true
// 创建WKWebViewConfiguration
let configuration = WKWebViewConfiguration()
// 设置WKWebViewConfiguration的WKPreferences
configuration.preferences = preferences
// 创建WKUserContentController
let userContentController = WKUserContentController()
// 配置WKWebViewConfiguration的WKUserContentController
configuration.userContentController = userContentController
// 给WKWebView与Swift交互起一个名字:callbackHandler,WKWebView给Swift发消息的时候会用到
// 此句要求实现WKScriptMessageHandler
configuration.userContentController.add(self, name: "callbackHandler")
// 创建WKWebView
var webView = WKWebView(frame: UIScreen.main.bounds, configuration: configuration)
// 让WKWebView翻动有回弹效果
webView.scrollView.bounces = true
// 只允许WKWebView上下滚动
webView.scrollView.alwaysBounceVertical = true
// 设置代理WKNavigationDelegate
webView.navigationDelegate = self
// 返回
return webView
}()

创建HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,user-scalable=no"/>
</head>
<body>
iOS传过来的值:<span id="name"></span>
<button onclick="responseSwift()">响应iOS</button>
<script type="text/javascript">
// 给Swift调用
function sayHello(name) {
document.getElementById("name").innerHTML = name
return "Swift你也好!"
}
// 调用Swift方法
function responseSwift() {
// 这里的callbackHandler是创建WKWebViewConfiguration是定义的
window.webkit.messageHandlers.callbackHandler.postMessage("JavaScript发送消息给Swift")
}
</script>
</body>
</html>

两个协议

  • WKNavigationDelegate:判断页面加载完成,只有在页面加载完成后才能在实现 Swift 调用 JavaScript。WKWebView 调用 JavaScript:
// 加载完毕以后执行
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 调用JavaScript方法
webView.evaluateJavaScript("sayHello('WebView你好!')") { (result, err) in
// result是JavaScript返回的值
print(result, err)
}
}

  • WKScriptMessageHandler:JavaScript 调用 Swift 时需要用到协议中的一个方法来。JavaScript 调用 WKWebView:
// Swift方法,可以在JavaScript中调用
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}

ViewController

class ViewController: UIViewController {
// 懒加载WKWebView
...

// 加载本地html
let html = try! String(contentsOfFile: Bundle.main.path(forResource: "index", ofType: "html")!, encoding: String.Encoding.utf8)

override func viewDidLoad() {
super.viewDidLoad()
// 标题
title = "WebView与JavaScript交互"
// 加载html
webView.loadHTMLString(html, baseURL: nil)
view.addSubview(webView)
}
}

// 遵守两个协议
extension ViewController: WKNavigationDelegate, WKScriptMessageHandler {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
...
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
}
}

SFSafariViewController

  • iOS 9 推出的一种 UIViewController,用于加载与显示 Web 内容,打开效果类似 Safari 浏览器的效果。
  • 使用时必须导入SafariServices模块。
import SafariServices

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
showSafariViewController()
}

func showSafariViewController() {
// URL
let url = URL(string: "https://www.baidu.com")
// 创建SFSafariViewController
let sf = SFSafariViewController(url: url!)
// 设置代理
sf.delegate = self
// 显示
present(sf, animated: true, completion: nil)
}
}

extension ViewController: SFSafariViewControllerDelegate {
// 点击左上角的完成(done)
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
print(#function)
}

// 加载完成
func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
print(#function)
}
}
收起阅读 »

iOS 14开发-定位与地图

iOS
定位CoreLocation 是 iOS 中用于设备定位的框架。通过这个框架可以实现定位进而获取位置信息如经度、纬度、海拔信息等。模块与常见类定位所包含的类都在CoreLocation模块中,使用时必须导入。CLLocationManager:定位管理器,可以...
继续阅读 »

定位

CoreLocation 是 iOS 中用于设备定位的框架。通过这个框架可以实现定位进而获取位置信息如经度、纬度、海拔信息等。

模块与常见类

  • 定位所包含的类都在CoreLocation模块中,使用时必须导入。
  • CLLocationManager:定位管理器,可以理解为定位不能自己工作,需要有个类对它进行全过程管理。
  • CLLocationManagerDelegate:定位管理代理,不管是定位成功与失败,都会有相应的代理方法进行回调。
  • CLLocation:表示某个位置的地理信息,包含经纬度、海拔等。
  • CLPlacemark:位置信息,包含的信息如国家、城市、街道等。
  • CLGeocoder:地理编码。

工作流程

  1. 创建CLLocationManager,设置代理并发起定位。
  2. 实现CLLocationManagerDelegate中定位成功和失败的代理方法。
  3. 在成功的代理方法中获取CLLocation对象并通过CLGeocoder进行反向地理编码获取对应的位置信息CLPlacemark
  4. 通过CLPlacemark获取具体的位置信息。

权限

授权对话框

  • 程序中调用requestWhenInUseAuthorization发起定位授权。
  • 程序中调用requestAlwaysAuthorization发起定位授权。

前台定位

  • 需要在 Info.plist 中配置Privacy - Location When In Use Usage Description
  • 程序中调用requestWhenInUseAuthorization发起定位授权。
  • 弹出的授权对话框新增了精确位置开关,同时新增了小地图展示当前位置。

后台定位

  • 需要勾选 Capabilities —> Background Modes —> Location updates
  • 程序中允许后台定位:locationManager.allowsBackgroundLocationUpdates = true
  • 此时授权分为 2 种情况: (1)Privacy - Location When In Use Usage Description + requestWhenInUseAuthorization:可以后台定位,但会在设备顶部出现蓝条(刘海屏设备会出现在左边刘海)。 (2)Privacy - Location When In Use Usage Description + Privacy - Location Always and When In Use Usage Description + requestAlwaysAuthorization:可以后台定位,不会出现蓝条。这种方式会出现 2 次授权对话框:第一次和前台定位一样,在同意使用While Using App模式后,继续使用定位才会弹出第二次,询问是否切换到Always模式。

精度控制

  • iOS 14 新增了一种定位精度控制,在定位授权对话框中有一个精度切换开关,可以切换精确和模糊定位(默认精确)。
  • 可以通过CLLocationManageraccuracyAuthorization属性获取当前的定位精度权限。
  • 当已经获得定位权限且当前用户选择的是模糊定位,则可以使用CLLocationManagerrequestTemporaryFullAccuracyAuthorization(withPurposeKey purposeKey: String, completion: ((Error?) -> Void)? = nil)方法申请一次临时精确定位权限,其中purposeKey为 Info.plist 中配置的Privacy - Location Temporary Usage Description Dictionary字段下某个具体原因的 key,可以设置多个 key 以应对不同的定位使用场景。
  • requestTemporaryFullAccuracyAuthorization方法并不能用于申请定位权限,只能用于从模糊定位升级为精确定位;如果没有获得定位权限,直接调用此 API 无效。
  • 如果不想使用精确定位,则可以在 Info.plist 中配置Privacy - Location Default Accuracy ReducedYES,此时申请定位权限的小地图中不再有精度切换开关。需要注意 2 点: (1)如果发现该字段不是 Bool 型,需要以源码形式打开 Info.plist,然后手动修改<key>NSLocationDefaultAccuracyReduced</key>为 Bool 型的值,否则无法生效。 (2)配置该字段后,如果 Info.plist 中还配置了Privacy - Location Temporary Usage Description Dictionary,则仍可以通过requestTemporaryFullAccuracyAuthorization申请临时的精确定位权限,会再次弹出授权对话框进行确认。

模拟器定位

由于定位需要 GPS,一般情况下需要真机进行测试。但对于模拟器,也可以进行虚拟定位,主要有 3 种方式。

  • 方式一
    (1)新建一个gpx文件,可以取名XXX.gpx,然后将自己的定位信息填写进 xml 对应的位置。 (2)gpx文件设置完成以后,首先需要运行一次 App,然后选择Edit Scheme,在Options中选择自己的gpx文件,这样模拟器运行的时候就会读取该文件的位置信息。然后可以选择Debug—>Simulate Location或底部调试栏上的定位按钮进行gpx文件或位置信息的切换。
<?xml version="1.0"?>
<gpx version="1.1" creator="Xcode">
<!--安徽商贸职业技术学院 谷歌地球:31.2906511800,118.3623587000-->
<wpt lat="31.2906511800" lon="118.3623587000">
<name>安徽商贸职业技术学院</name>
<cmt>中国安徽省芜湖市弋江区文昌西路24号 邮政编码: 241002</cmt>
<desc>中国安徽省芜湖市弋江区文昌西路24号 邮政编码: 241002</desc>
</wpt>
</gpx>

  • 方式二:运行程序开始定位 —> 模拟器菜单 —> Features —> Location —> Custom Location —> 输入经纬度。

实现步骤

  1. 导入CoreLocation模块。
  2. 创建CLLcationManager对象,设置参数和代理,配置 Info.plist 并请求定位授权。
  3. 调用CLLcationManager对象的startUpdatingLocation()requestLocation()方法进行定位。
  4. 实现代理方法,在定位成功的方法中进行位置信息的处理。
import CoreLocation
import UIKit

class ViewController: UIViewController {
// CLLocationManager
lazy var locationManager = CLLocationManager()
// CLGeocoder
lazy var gecoder = CLGeocoder()

override func viewDidLoad() {
super.viewDidLoad()

setupManager()
}

func setupManager() {
// 默认情况下每当位置改变时LocationManager就调用一次代理。通过设置distanceFilter可以实现当位置改变超出一定范围时LocationManager才调用相应的代理方法。这样可以达到省电的目的。
locationManager.distanceFilter = 300
// 精度 比如为10 就会尽量达到10米以内的精度
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// 代理
locationManager.delegate = self
// 第一种:能后台定位但是会在顶部出现大蓝条(打开后台定位的开关)
// 允许后台定位
locationManager.allowsBackgroundLocationUpdates = true
locationManager.requestWhenInUseAuthorization()
// 第二种:能后台定位并且不会出现大蓝条
// locationManager.requestAlwaysAuthorization()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 以下2个方法都会调用代理方法
// 1. 发起位置更新(定位)会一直轮询,耗电
locationManager.startUpdatingLocation()
// 2. 只请求一次用户的位置,省电
// locationManager.requestLocation()
}
}

extension ViewController: CLLocationManagerDelegate {
// 定位成功
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let location = locations.last {
// 反地理编码转换成具体的地址
gecoder.reverseGeocodeLocation(location) { placeMarks, _ in
// CLPlacemark -- 国家 城市 街道
if let placeMark = placeMarks?.first {
print(placeMark)
// print("\(placeMark.country!) -- \(placeMark.name!) -- \(placeMark.locality!)")
}
}
}
// 停止位置更新
locationManager.stopUpdatingLocation()
}

// 定位失败
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print(error.localizedDescription)
}
}

地图

  • 地图所包含的类都在MapKit模块中,使用时必须导入。
  • 除了可以显示地图,还支持在地图上进行标记处理。
  • 地图看似很复杂,其实它仅仅是一个控件 MKMapView,就和以前学习过的 UIButton、UITableView 等一样,可以在 storyboard 和代码中使用。
  • 地图上如果想要显示用户的位置,必须与定位配合,那么就需要创建定位管理器、设置权限等(参考定位知识),同时需要通过 storyboard 或者代码设置地图的相关属性。

准备工作

  1. 添加一个地图并设置相关属性。
  2. Info.plist 中配置定位权限。
  3. 创建 CLLocationManager 对象并请求定位权限。

基本使用

显示地图,同时显示用户所处的位置。点击用户的位置,显示一个气泡展示用户位置的具体信息。

import MapKit

class ViewController: UIViewController {
@IBOutlet var mapView: MKMapView!
lazy var locationManager: CLLocationManager = CLLocationManager()

override func viewDidLoad() {
super.viewDidLoad()

setupMapView()
}

func setupManager() {
locationManager.requestWhenInUseAuthorization()
// 不需要发起定位
}

func setupMapView() {
// 设置定位
setupManager()
// 地图类型
mapView.mapType = .hybridFlyover
// 显示兴趣点
mapView.showsPointsOfInterest = true
// 显示指南针
mapView.showsCompass = true
// 显示交通
mapView.showsTraffic = true
// 显示建筑
mapView.showsBuildings = true
// 显示级别
mapView.showsScale = true
// 用户跟踪模式
mapView.userTrackingMode = .followWithHeading
}
}

缩放级别

在之前功能的基础上实现地图的任意视角(“缩放级别”)。

// 设置“缩放级别”
func setRegion() {
if let location = location {
// 设置范围,显示地图的哪一部分以及显示的范围大小
let region = MKCoordinateRegion(center: mapView.userLocation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500)
// 调整范围
let adjustedRegion = mapView.regionThatFits(region)
// 地图显示范围
mapView.setRegion(adjustedRegion, animated: true)
}
}

标注

在地图上可以添加标注来显示一个个关键的信息点,用于对用户的提示。

分类

  • MKPinAnnotationView:系统自带的标注,继承于 MKAnnotationView,形状跟棒棒糖类似,可以设置糖的颜色,和显示的时候是否有动画效果 (Swift 不推荐使用)。
  • MKMarkerAnnotationView:iOS 11 推出,建议使用。
  • MKAnnotationView:可以用指定的图片作为标注的样式,但显示的时候没有动画效果,如果没有指定图片会什么都不显示(自定义时使用)。

创建模型

class MapFlag: NSObject, MKAnnotation {
// 标题
let title: String?
// 副标题
let subtitle: String?
// 经纬度
let coordinate: CLLocationCoordinate2D
// 附加信息
let urlString: String

init(title: String?, subtitle: String?, coordinate: CLLocationCoordinate2D, urlString: String) {
self.title = title
self.subtitle = subtitle
self.coordinate = coordinate
self.urlString = urlString
}
}

添加标注

  • 添加系统标注,点击能够显示标题和副标题。
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let flag = MapFlag(title: "标题", subtitle: "副标题", coordinate: CLLocationCoordinate2D(latitude: 31.2906511800, longitude: 118.3623587000), urlString: "https://www.baidu.com")
mapView.addAnnotation(flag)
}

  • 添加系统标注,点击以气泡形式显示标题、副标题及自定义内容,此时需要重写地图的代理方法,返回标注的样式。
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? MapFlag else {
return nil
}
// 如果是用户的位置,使用默认样式
if annotation == mapView.userLocation {
return nil
}
// 标注的标识符
let identifier = "marker"
// 获取AnnotationView
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) as? MKMarkerAnnotationView
// 判空
if annotationView == nil {
annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 显示气泡
annotationView?.canShowCallout = true
// 左边显示的辅助视图
annotationView?.leftCalloutAccessoryView = UIImageView(image: UIImage(systemName: "heart"))
// 右边显示的辅助视图
let button = UIButton(type: .detailDisclosure, primaryAction: UIAction(handler: { _ in
print(annotation.urlString)
}))
annotationView?.rightCalloutAccessoryView = button
}

return annotationView
}
}

  • 如果希望标注的图标为自定义样式,只需要稍加更改代理方法并设置自己的标注图片即可。
extension ViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? MapFlag else {
return nil
}
// 如果是用户的位置,使用默认样式
if annotation == mapView.userLocation {
return nil
}
// 标注的标识符
let identifier = "custom"
// 标注的自定义图片
let annotationImage = ["pin.circle.fill", "car.circle.fill", "airplane.circle.fill", "cross.circle.fill"]
// 获取AnnotationView
var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
// 判空
if annotationView == nil {
annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: identifier)
// 图标,每次随机取一个
annotationView?.image = UIImage(systemName: annotationImage.randomElement()!)
// 显示气泡
annotationView?.canShowCallout = true
// 左边显示的辅助视图
annotationView?.leftCalloutAccessoryView = UIImageView(image: UIImage(systemName: "heart"))
// 右边显示的辅助视图
let button = UIButton(type: .detailDisclosure, primaryAction: UIAction(handler: { _ in
print(annotation.urlString)
}))
annotationView?.rightCalloutAccessoryView = button
// 弹出的位置偏移
annotationView?.calloutOffset = CGPoint(x: -5.0, y: 5.0)
}

return annotationView
}
}

// 点击地图插入一个标注,标注的标题和副标题显示的是标注的具体位置
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
let touchPoint = touches.first?.location(in: mapView)
// 将坐标转换成为经纬度,然后赋值给标注
let coordinate = mapView.convert(touchPoint!, toCoordinateFrom: mapView)
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
let gecoder = CLGeocoder()
// 反地理编码转换成具体的地址
gecoder.reverseGeocodeLocation(location) { placeMarks, _ in
let placeMark = placeMarks?.first
if let placeMark = placeMark {
let flag = MapFlag(title: placeMark.locality, subtitle: placeMark.subLocality, coordinate: coordinate, urlString: "https://www.baidu.com")
self.mapView.addAnnotation(flag)
}
}
}
收起阅读 »

iOS 14开发- 通知

iOS
iOS 中的通知主要分为 2 种,本地通知和远程通知。本地通知使用步骤导入UserNotifications模块。申请权限。创建通知内容UNMutableNotificationContent,可以设置: (1)title:通知标题。 (2)subtitle:...
继续阅读 »

iOS 中的通知主要分为 2 种,本地通知和远程通知。

本地通知

使用步骤

  1. 导入UserNotifications模块。
  2. 申请权限。
  3. 创建通知内容UNMutableNotificationContent,可以设置: (1)title:通知标题。 (2)subtitle:通知副标题。 (3)body:通知体。 (4)sound:声音。 (5)badge:角标。 (6)userInfo:额外信息。 (7)categoryIdentifier:分类唯一标识符。 (8)attachments:附件,可以是图片、音频和视频,通过下拉通知显示。
  4. 指定本地通知触发条件,有 3 种触发方式: (1)UNTimeIntervalNotificationTrigger:一段时间后触发。 (2)UNCalendarNotificationTrigger:指定日期时间触发。 (3)UNLocationNotificationTrigger:根据位置触发。
  5. 根据通知内容和触发条件创建UNNotificationRequest
  6. UNNotificationRequest添加到UNUserNotificationCenter

案例

  • 申请授权(异步操作)。
import UserNotifications

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 请求通知权限
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { // 横幅,声音,标记
(accepted, error) in
if !accepted {
print("用户不允许通知")
}
}

return true
}

  • 发送通知。
import CoreLocation
import UIKit
import UserNotifications

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

// 一段时间后触发
@IBAction func timeInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.subtitle = "Hi"
content.body = "这是一条基于时间间隔的测试通知"
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "feiji.wav"))
content.badge = 1
content.userInfo = ["username": "YungFan", "career": "Teacher"]
content.categoryIdentifier = "testUserNotifications1"
setupAttachment(content: content)

// 设置通知触发器
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)

// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications2"
// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}

// 指定日期时间触发
@IBAction func dateInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.body = "这是一条基于日期的测试通知"

// 时间
var components = DateComponents()
components.year = 2021
components.month = 5
components.day = 20
// 每周一上午8点
// var components = DateComponents()
// components.weekday = 2 // 周一
// components.hour = 8 // 上午8点
// components.minute = 30 // 30分
// 设置通知触发器
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)

// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications3"
// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}

// 根据位置触发
@IBAction func locationInterval(_ sender: Any) {
// 设置推送内容
let content = UNMutableNotificationContent()
content.title = "你好"
content.body = "这是一条基于位置的测试通知"

// 位置
let coordinate = CLLocationCoordinate2D(latitude: 31.29065118, longitude: 118.3623587)
let region = CLCircularRegion(center: coordinate, radius: 500, identifier: "center")
region.notifyOnEntry = true // 进入此范围触发
region.notifyOnExit = false // 离开此范围不触发
// 设置触发器
let trigger = UNLocationNotificationTrigger(region: region, repeats: true)
// 设置请求标识符
let requestIdentifier = "com.abc.testUserNotifications"

// 设置一个通知请求
let request = UNNotificationRequest(identifier: requestIdentifier, content: content, trigger: trigger)
// 将通知请求添加到发送中心
UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
}
}

extension ViewController {
func setupAttachment(content: UNMutableNotificationContent) {
let imageURL = Bundle.main.url(forResource: "img", withExtension: ".png")!
do {
let imageAttachment = try UNNotificationAttachment(identifier: "iamgeAttachment", url: imageURL, options: nil)
content.attachments = [imageAttachment]
} catch {
print(error.localizedDescription)
}
}
}

远程通知(消息推送)

远程通知是指在联网的情况下,由远程服务器推送给客户端的通知,又称 APNs(Apple Push Notification Services)。在联网状态下,所有设备都会与 Apple 服务器建立长连接,因此不管应用是打开还是关闭的情况,都能接收到服务器推送的远程通知。

远程通知流程.png

实现原理

  1. App 打开后首先发送 UDID 和 BundleID 给 APNs 注册,并返回 deviceToken(图中步骤 1,2,3)。
  2. App 获取 deviceToken 后,通过 API 将 App 的相关信息和 deviceToken 发送给应用服务器,服务器将其记录下来。(图中步骤 4)
  3. 当要推送通知时,应用服务器按照 App 的相关信息找到存储的 deviceToken,将通知和 deviceToken 发送给 APNs。(图中步骤 5)
  4. APNs 通过 deviceToken,找到指定设备的指定 App, 并将通知推送出去。(图中步骤 6)

实现步骤

证书方式

  1. 在开发者网站的 Identifiers 中添加 App IDs,并在 Capabilities 中开启 Push Notifications
  2. 在 Certificates 中创建一个 Apple Push Notification service SSL (Sandbox & Production) 的 APNs 证书并关联第一步中的 App IDs,然后将证书下载到本地安装(安装完可以导出 P12 证书)。
  3. 在项目中选择 Capability,接着开启 Push Notifications,然后在 Background Modes 中勾选 Remote notifications
  4. 申请权限。
  5. 通过UIApplication.shared.registerForRemoteNotifications()向 APNs 请求 deviceToken。
  6. 通过func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)获取 deviceToken。如果正常获取到 deviceToken,即表示注册成功,可以进行远程通知的推送,最后需要将其发送给应用服务器。注意:
    • App 重新启动后,deviceToken 不会变化。
    • App 卸载后重新安装,deviceToken 发生变化。
  7. 通知测试。

Token方式

  1. 在开发者网站的 Membership 中找到 Team ID 并记录。
  2. 在 Certificates, Identifiers & Profiles 的 Keys 中注册一个 Key 并勾选 Apple Push Notifications service (APNs) ,最后将生成的 Key ID 记录并将 P8 的 AuthKey 下载到本地(只能下载一次)。
  3. 在项目中选择 Capability,接着开启 Push Notifications,然后在 Background Modes 中勾选 Remote notifications
  4. 申请权限。
  5. 通过UIApplication.shared.registerForRemoteNotifications()向 APNs 请求 deviceToken。
  6. 通过func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)获取 deviceToken。如果正常获取到 deviceToken,即表示注册成功,可以进行远程通知的推送,最后需要将其发送给应用服务器。
  7. 通知测试。

Token Authentication 是 APNs 新推出的推送鉴权方式,它如下优势: (1)同一个开发者账号下的所有 App 无论是测试还是正式版都能使用同一个 Key 来发送而不需要为每个 App 生成证书。 (2)生成 Key 的过程相对简单,不需要繁琐的证书操作过程,并且它不再有过期时间,无需像证书那样需要定期重新生成。。

AppDelegate

import UserNotifications

class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 请求通知权限
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) {
accepted, _ in
if !accepted {
print("用户不允许通知。")
}
}

// 向APNs请求deviceToken
UIApplication.shared.registerForRemoteNotifications()

return true
}

// deviceToken请求成功回调
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
var deviceTokenString = String()
let bytes = [UInt8](deviceToken)
for item in bytes {
deviceTokenString += String(format: "x", item & 0x000000FF)
}

// 打印获取到的token字符串
print(deviceTokenString)

// 通过网络将token发送给服务端
}

// deviceToken请求失败回调
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
print(error.localizedDescription)
}
}

注意:远程通知不支持模拟器(直接进入deviceToken请求失败回调),必须在真机测试。

测试

真机测试

  1. 将 App 安装到真机上。
  2. 通过软件(如 APNs)或者第三方进行测试,但都需要进行相关内容的设置。 (1)证书方式需要:P12 证书 + Bundle Identifier + deviceToken。 (2)Token 方式需要:P8 AuthKey + Team ID + Key ID + Bundle Identifier + deviceToken

模拟器测试—使用JSON文件

  • JSON文件。
{
"aps":{
"alert":{
"title":"测试",
"subtitle":"远程推送",
"body":"这是一条从远处而来的通知"
},
"sound":"default",
"badge":1
}
}

  • 命令。
xcrun simctl push booted developer.yf.TestUIKit /Users/yangfan/Desktop/playload.json

模拟器测试—使用APNS文件

另一种方法是将 APNs 文件直接拖到 iOS 模拟器中。准备一个后缀名为.apns的文件,其内容和上面的 JSON 文件差不多,但是添加了一个Simulator Target Bundle,用于描述 App 的Bundle Identifier

  • APNs文件。
{
"Simulator Target Bundle": "developer.yf.TestUIKit",
"aps":{
"alert":{
"title":"测试",
"subtitle":"远程推送",
"body":"这是一条从远处而来的通知"
},
"sound":"default",
"badge":1
}
}

前台处理

默认情况下,App 只有在后台才能收到通知提醒,在前台无法收到通知提醒,如果前台也需要提醒可以进行如下处理。

  • 创建 UNUserNotificationCenterDelegate。
class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
// 前台展示通知
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
// 前台通知一般不设置badge
completionHandler([.list, .banner, .sound])

// 如果不想显示某个通知,可以直接用 []
// completionHandler([])
}
}

  • 设置代理。
class AppDelegate: UIResponder, UIApplicationDelegate {
// 自定义通知回调类,实现通知代理
let notificationHandler = NotificationHandler()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 设置代理
UNUserNotificationCenter.current().delegate = notificationHandler

return true
}
}

角标设置

  • 不论是本地还是远程通知,前台通知一般不会设置角标提醒,所以只需要针对后台通知处理角标即可。
  • 通知的角标不需要手动设置,会自动根据通知进行设置
// 手动添加角标
UIApplication.shared.applicationIconBadgeNumber = 10

// 清理角标
UIApplication.shared.applicationIconBadgeNumber = 0
收起阅读 »

【kotlin从摸索到探究】- delay函数实现原理

简介这片文章主要讲解kotlin中delay函数的实现原理,delay是一个挂起函数。kotlin携程使用过程中,经常使用到挂起函数,在我学习kotlin携程的时候,一些现象让我很是困惑,所以打算从源码角度来逐一分析。说明在分析delay源码实现过程中,由于对...
继续阅读 »

简介

这片文章主要讲解kotlindelay函数的实现原理,delay是一个挂起函数。kotlin携程使用过程中,经常使用到挂起函数,在我学习kotlin携程的时候,一些现象让我很是困惑,所以打算从源码角度来逐一分析

说明

在分析delay源码实现过程中,由于对kotlin有些语法还不是很熟悉,所以并不会把每一步将得很透彻,只会梳理一个大致的流程,如果讲解有误的地方,欢迎指出。

例子先行

fun main() = runBlocking {
println("${treadName()}======start")
launch {
println("${treadName()}======delay 1s start")
delay(1000)
println("${treadName()}======delay 1s end")
}

println("${treadName()}======delay 3s start")
delay(3000)
println("${treadName()}======delay 3s end")
// 延迟,保活进程
Thread.sleep(500000)
}

输出如下:

main======start
main======delay 3s start
main======delay 1s start
main======delay 1s end
main======delay 3s end

根据日志可以看出:

  1. 日志输出环境是在主线程。
  2. 执行3s延迟函数后,切换到了**launch**携程体执行。
  3. delay挂起函数恢复后执行各自的打印函数。

疑问:

如果真像打印日志输出一样,所以的操作都是在一个线程(主线程)完成,那么问题来了。**第一:按照Java线程知识,单线程执行是按照顺序的,是单条线的。那么不管delay里是何等骚操作,只要没有重新起线程,应该不能够像上面输入的那样吧,你说sleepwait,如果你这么想,那么你可以去补一补Java多线程基础知识了。猜想:**1. 难得真有什么我不知道的骚操作可以在一个线程里面同时执行delay和其它代码,真像很多人说的,携程性能很好,使用挂起函数可以不用启动新的线程,就可以异步执行,那真的就很不错。2. delay启动了新的线程,上面的现象只不过是进行了线程切换,那么如果多次调用 delay那么岂不是要创建很多线程,这性能问题和资源问题怎么解决。3. delay基于某种任务调度策略。

delay源码

public suspend inline fun <T> suspendCancellableCoroutine(
crossinline block: (CancellableContinuation<T>) -> Unit
): T =
suspendCoroutineUninterceptedOrReturn { uCont ->
val cancellable = CancellableContinuationImpl(uCont.intercepted(), resumeMode = MODE_CANCELLABLE)
cancellable.initCancellability()
block(cancellable)
cancellable.getResult()
}

cancellable是一个CancellableContinuationImpl对象,执行 block(cancellable),回到下面函数。

public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return // don't delay
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
// if timeMillis == Long.MAX_VALUE then just wait forever like awaitCancellation, don't schedule.
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}

看一下cont.context.delayget方法

internal val CoroutineContext.delay: Delay get() = get(ContinuationInterceptor) as? Delay ?: DefaultDelay

如果get(ContinuationInterceptor)Delay类型对象,那么直接返回该对象,如果不是返回DefaultDelay变量,看一下DefaultDelay初始化可以知道,它是一个DefaultExecutor对象,继承了EventLoopImplBase类。

runBlocking执行过程中有这样一行代码createCoroutineUnintercepted(receiver, completion).intercepted()会被ContinuationInterceptor进行包装。所以上面cont.context.delay返回的就是被包装的携程体上下文。

查看scheduleResumeAfterDelay方法。

    public override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
val timeNanos = delayToNanos(timeMillis)
if (timeNanos < MAX_DELAY_NS) {
val now = nanoTime()
DelayedResumeTask(now + timeNanos, continuation).also { task ->
continuation.disposeOnCancellation(task)
schedule(now, task)
}
}
}

创建DelayedResumeTask对象,在also执行相关计划任务,看一下schedule方法。

    public fun schedule(now: Long, delayedTask: DelayedTask) {
when (scheduleImpl(now, delayedTask)) {
SCHEDULE_OK -> if (shouldUnpark(delayedTask)) unpark()
SCHEDULE_COMPLETED -> reschedule(now, delayedTask)
SCHEDULE_DISPOSED -> {} // do nothing -- task was already disposed
else -> error("unexpected result")
}
}

这里返回SCHEDULE_OK,执行unpark函数,这里用到了Java提供的LockSupport线程操作相关知识。

读取线程

  val thread = thread
  • 如果delay是当前携程的上下文 那么把延时任务加入到队列后,那么又是怎么达到线程延迟呢。回到runBlocking执行流程,会执行coroutine.joinBlocking()这样一行代码。

      fun joinBlocking(): T {
    registerTimeLoopThread()
    try {
    eventLoop?.incrementUseCount()
    try {
    while (true) {
    @Suppress("DEPRECATION")
    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
    // note: process next even may loose unpark flag, so check if completed before parking
    if (isCompleted) break
    parkNanos(this, parkNanos)
    }
    } finally { // paranoia
    eventLoop?.decrementUseCount()
    }
    } finally { // paranoia
    unregisterTimeLoopThread()
    }
    // now return result
    val state = this.state.unboxState()
    (state as? CompletedExceptionally)?.let { throw it.cause }
    return state as T
    }

    执行:

     val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE

    看一下processNextEvent

      override fun processNextEvent(): Long {
    // unconfined events take priority
    if (processUnconfinedEvent()) return 0
    // queue all delayed tasks that are due to be executed
    val delayed = _delayed.value
    if (delayed != null && !delayed.isEmpty) {
    val now = nanoTime()
    while (true) {
    delayed.removeFirstIf {
    if (it.timeToExecute(now)) {
    enqueueImpl(it)
    } else
    false
    } ?: break // quit loop when nothing more to remove or enqueueImpl returns false on "isComplete"
    }
    }
    // then process one event from queue
    val task = dequeue()
    if (task != null) {
    task.run()
    return 0
    }
    return nextTime
    }

    从延迟队列取任务

    val delayed = _delayed.value

    挂起当前线程

    parkNanos(this, parkNanos)

    这里是一个while循环,当挂起时间到,线程唤醒,继续从任务队列中取任务执行。如果还是延迟任务,这根据当前时间点,计算线程需要挂起的时间,这也是为什么多个延迟任务好像是同时执行的。

  • 如果delay是DefaultExecutor 比如这个例子:携程上下文没有像CoroutineStart.DEFAULT那样进行包装。

    fun main() {
    GlobalScope.launch(start = CoroutineStart.UNDISPATCHED){
    println("${treadName()}======我开始执行了~")
    delay(1000)
    println("${treadName()}======全局携程~")
    }
    println("${treadName()}======我要睡觉~")
    Thread.sleep(3000)
    }

    然后调用DefaultExecutor类中thread的get方法:

      override val thread: Thread
    get() = _thread ?: createThreadSync()

    看一下createThreadSync函数

      private fun createThreadSync(): Thread {
    return _thread ?: Thread(this, THREAD_NAME).apply {
    _thread = this
    isDaemon = true
    start()
    }
    }

    创建一个叫"kotlinx.coroutines.DefaultExecutor的新线程,并且开始运行。这时候会执行DefaultExecutor中的run方法。在run方法中有这样一行代码:

    parkNanos(this, parkNanos)

    点进去看看:

    internal inline fun parkNanos(blocker: Any, nanos: Long) {
    timeSource?.parkNanos(blocker, nanos) ?: LockSupport.parkNanos(blocker, nanos)
    }

    调用Java提供的LockSupport.parkNanos(blocker, nanos)方法,阻塞当前线程,实现挂起,当达到阻塞的时间,恢复线程执行。

查看进行中线程情况方法

fun main() {
println("${treadName()}======doSuspendTwo")
Thread.sleep(500000)
}

运行main,通过命令jps找到对应Java进程(没有特别指定,进程名为文件名)号。

...
3406 KotlinCoreutinesSuspendKt
...

执行jstack 进程号查看进程对应的线程资源。


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

收起阅读 »

【kotlin从摸索到探究】- 协程的执行流程

简介 这篇文章将从源码的角度,分析携程的执行流程,我们创建一个携程,系统是怎么进行调度的,什么时候执行的,是否需要创建新线程等等,带着这些疑问,一起往下看吧。 例子先行 fun main(): Unit = runBlocking { launch {...
继续阅读 »

简介


这篇文章将从源码的角度,分析携程的执行流程,我们创建一个携程,系统是怎么进行调度的,什么时候执行的,是否需要创建新线程等等,带着这些疑问,一起往下看吧。


例子先行


fun main(): Unit = runBlocking {
launch {
println("${treadName()}======1")
}
GlobalScope.launch {
println("${treadName()}======3")
}
launch {
println("${treadName()}======2")
}
println("${treadName()}======4")
Thread.sleep(2000)
}


输出如下:


DefaultDispatcher-worker-1======3
main======4
main======1
main======2

Process finished with exit code 0


根据打印,如果根据单线程执行流程来看,是不是感觉上面的日志打印顺序有点不好理解,下面我们就逐步来进行分解。




  • runBlocking携程体
    这里将其它代码省略到了,我这里都是按照一条简单的执行流程进行讲解。


    public fun <T> runBlocking(context: CoroutineContext = EmptyCoroutineContext, block: suspend CoroutineScope.() -> T): T {

    val eventLoop: EventLoop?
    val newContext: CoroutineContext
    ...
    if (contextInterceptor == null) {
    eventLoop = ThreadLocalEventLoop.eventLoop
    newContext = GlobalScope.newCoroutineContext(context + eventLoop)
    }
    ...
    val coroutine = BlockingCoroutine<T>(newContext, currentThread, eventLoop)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()
    }


    看一下eventLoop的初始化,会 在当前线程(主线程)创建BlockingEventLoop对象。


    internal val eventLoop: EventLoop
    get() = ref.get() ?: createEventLoop().also { ref.set(it) }

    internal actual fun createEventLoop(): EventLoop = BlockingEventLoop(Thread.currentThread())


    看一下newContext初始化,这里会对携程上下文进行组合,返回新的上下文。最后返回的是一个BlockingEventLoop对象。


    public actual fun CoroutineScope.newCoroutineContext(context: CoroutineContext): CoroutineContext {
    val combined = coroutineContext + context
    val debug = if (DEBUG) combined + CoroutineId(COROUTINE_ID.incrementAndGet()) else combined
    return if (combined !== Dispatchers.Default && combined[ContinuationInterceptor] == null)
    debug + Dispatchers.Default else debug
    }


    开始对携程进行调度


     coroutine.start(CoroutineStart.DEFAULT, coroutine, block)


    看一下执行这句代码之前,各变量的值


    image


    而上面的代码最终调用的是CoroutineStart.DEFAULTinvoke方法。


      public operator fun <T> invoke(block: suspend () -> T, completion: Continuation<T>): Unit =
    when (this) {
    DEFAULT -> block.startCoroutineCancellable(completion)
    ATOMIC -> block.startCoroutine(completion)
    UNDISPATCHED -> block.startCoroutineUndispatched(completion)
    LAZY -> Unit // will start lazily
    }


    我们使用的是DEFAULT启动模式。然后会执行resumeCancellableWith方法。


      inline fun resumeCancellableWith(
    result: Result<T>,
    noinline onCancellation: ((cause: Throwable) -> Unit)?
    ) {
    val state = result.toState(onCancellation)
    if (dispatcher.isDispatchNeeded(context)) {
    _state = state
    resumeMode = MODE_CANCELLABLE
    dispatcher.dispatch(context, this)
    } else {
    executeUnconfined(state, MODE_CANCELLABLE) {
    if (!resumeCancelled(state)) {
    resumeUndispatchedWith(result)
    }
    }
    }
    }


    dispatcherBlockingEventLoop对象,没有重写isDispatchNeeded,默认返回true。然后调用dispatch继续进行分发。BlockingEventLoop继承了EventLoopImplBase并调用其dispatch方法。把任务加入到队列中。


    public final override fun dispatch(context: CoroutineContext, block: Runnable) = enqueue(block)


    回到最开始,在coroutine.start(CoroutineStart.DEFAULT, coroutine, block)执行完,还执行了coroutine.joinBlocking()看一下实现。


        fun joinBlocking(): T {
    registerTimeLoopThread()
    try {
    eventLoop?.incrementUseCount()
    try {
    while (true) {
    @Suppress("DEPRECATION")
    if (Thread.interrupted()) throw InterruptedException().also { cancelCoroutine(it) }
    val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE
    // note: process next even may loose unpark flag, so check if completed before parking
    if (isCompleted) break
    parkNanos(this, parkNanos)
    }
    } finally { // paranoia
    eventLoop?.decrementUseCount()
    }
    } finally { // paranoia
    unregisterTimeLoopThread()
    }
    // now return result
    val state = this.state.unboxState()
    (state as? CompletedExceptionally)?.let { throw it.cause }
    return state as T
    }


    执行val parkNanos = eventLoop?.processNextEvent() ?: Long.MAX_VALUE,取出任务进行执行,也就是runBlocking携程体。




  • launch {} 执行流程


    public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
    ): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
    LazyStandaloneCoroutine(newContext, block) else
    StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
    }

    因为launch是直接在runBlocking(父携程体)里新的创建的子携程体,所以执行流程上和之前将的差不多,只不过不会像runBlocking再去创建BlockingEventLoop对象,而是直接用runBlocking(父携程体)的,然后把任务加到里面,所以通过这种方式其实就是单线程对任务的调度而已。所以在runBlocking(父携程体)内通过launch启动再多的携程体,其实都是在同一线程,按照任务队列的顺序执行的。





根据上面日志输出,并没有先执行两个launch携程体,这是为什么呢,根据上面的讲解,应用知道,runBlocking(父携程体)是第一被添加的队列的任务,其次是launch,所以是这样的顺序。那可以让launch立即执行吗?答案是可以的,这就要说携程的启动模式了。





  • CoroutineStart 是协程的启动模式,存在以下4种模式:



    1. DEFAULT 立即调度,可以在执行前被取消

    2. LAZY 需要时才启动,需要start、join等函数触发才可进行调度

    3. ATOMIC 立即调度,协程肯定会执行,执行前不可以被取消

    4. UNDISPATCHED 立即在当前线程执行,直到遇到第一个挂起点(可能切线程)



    我们使用UNDISPATCHED就可以使携程体马上在当前线程执行。看一下是怎么实现的。看一下实现:





使用这种启动模式执行UNDISPATCHED -> block.startCoroutineUndispatched(completion)方法。


internal fun <T> (suspend () -> T).startCoroutineUndispatched(completion: Continuation<T>) {
startDirect(completion) { actualCompletion ->
withCoroutineContext(completion.context, null) {
startCoroutineUninterceptedOrReturn(actualCompletion)
}
}
}

大家可以自己点击去看一下,大概就是会立即执行携程体,而不是将任务放入队列。



但是GlobalScope.launch却不是按照这样的逻辑,这是因为GlobalScope.launch启动的全局携程,是一个独立的携程体了,并不是runBlocking(父携程体)子携程。看一下通过GlobalScope.launch有什么不同。





  • GlobalScope.launch执行流程



    1. 启动全局携程


    GlobalScope.launch

    newCoroutineContext(context)返回Dispatchers.Default对象。而DefaultScheduler继承了ExperimentalCoroutineDispatcher类。看一下ExperimentalCoroutineDispatcher中的dispatch代码:


     override fun dispatch(context: CoroutineContext, block: Runnable): Unit =
    ...
    coroutineScheduler.dispatch(block)
    ...


    看一下coroutineScheduler初始化


    private fun createScheduler() = CoroutineScheduler(corePoolSize, maxPoolSize, idleWorkerKeepAliveNs, schedulerName)

    CoroutineScheduler实现了Executor接口,里面还有两个全局队列和线程池相关的参数。


    @JvmField
    val globalCpuQueue = GlobalQueue()
    @JvmField
    val globalBlockingQueue = GlobalQueue()


    继续调用CoroutineScheduler中的dispatch方法


      fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
    trackTask() // this is needed for virtual time support
    val task = createTask(block, taskContext)
    // try to submit the task to the local queue and act depending on the result
    val currentWorker = currentWorker()
    val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)
    if (notAdded != null) {
    if (!addToGlobalQueue(notAdded)) {
    // Global queue is closed in the last step of close/shutdown -- no more tasks should be accepted
    throw RejectedExecutionException("$schedulerName was terminated")
    }
    }
    val skipUnpark = tailDispatch && currentWorker != null
    // Checking 'task' instead of 'notAdded' is completely okay
    if (task.mode == TASK_NON_BLOCKING) {
    if (skipUnpark) return
    signalCpuWork()
    } else {
    // Increment blocking tasks anyway
    signalBlockingWork(skipUnpark = skipUnpark)
    }
    }




    1. val task = createTask(block, taskContext)包装成TaskImpl对象。




    2. val currentWorker = currentWorker()当前是主线程,运行程序时由进程创建,肯定不是Worker对象,Worker是一个继承了Thread的类 ,并且在初始化时都指定为守护线程


      Worker存在5种状态:
      CPU_ACQUIRED 获取到cpu权限
      BLOCKING 正在执行IO阻塞任务
      PARKING 已处理完所有任务,线程挂起
      DORMANT 初始态
      TERMINATED 终止态







  1. val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)由于currentWorker是null,直接返回task对象。

  2. addToGlobalQueue(notAdded)根据任务是否是阻塞任务,将task添加到全局任务队列中。这里被添加到globalCpuQueue中。

  3. 执行signalCpuWork()来唤醒一个线程或者启动一个新的线程。


    fun signalCpuWork() {
if (tryUnpark()) return
if (tryCreateWorker()) return
tryUnpark()
}


 private fun tryCreateWorker(state: Long = controlState.value): Boolean {  
val created = createdWorkers(state)// 创建的的线程总数
val blocking = blockingTasks(state)// 处理阻塞任务的线程数量
val cpuWorkers = (created - blocking).coerceAtLeast(0)//得到非阻塞任务的线程数量
if (cpuWorkers < corePoolSize) {// 小于核心线程数量,进行线程的创建
val newCpuWorkers = createNewWorker()
if (newCpuWorkers == 1 && corePoolSize > 1) createNewWorker()// 当前非阻塞型线程数量为1,同时核心线程数量大于1时,再进行一个线程的创建,
if (newCpuWorkers > 0) return true
}
return false
}

// 创建线程
private fun createNewWorker(): Int {
synchronized(workers) {
...
val created = createdWorkers(state)// 创建的的线程总数
val blocking = blockingTasks(state)// 阻塞的线程数量
val cpuWorkers = (created - blocking).coerceAtLeast(0) // 得到非阻塞线程数量
if (cpuWorkers >= corePoolSize) return 0//超过最大核心线程数,不能进行新线程创建
if (created >= maxPoolSize) return 0// 超过最大线程数限制,不能进行新线程创建
...
val worker = Worker(newIndex)
workers[newIndex] = worker
require(newIndex == incrementCreatedWorkers())
worker.start()// 线程启动
return cpuWorkers + 1
}
}


那么这里面的任务又是怎么调度的呢,当全局任务被执行的时候,看一下Worker中的run方法:


 override fun run() = runWorker()

执行runWorker方法,该方法会从队列中找到执行任务,然后开始执行。详细代码,可以自行翻阅。



所以GlobalScope.launch使用的就是线程池,没有所谓的性能好。




  • Dispatchers调度器
    Dispatchers是协程中提供的线程调度器,用来切换线程,指定协程所运行的线程。,上面用的是默认调度器Dispatchers.Default



Dispatchers中提供了4种类型调度器:
Default 默认调度器:适合CPU密集型任务调度器 比如逻辑计算;
Main UI调度器
Unconfined 无限制调度器:对协程执行的线程不做限制,协程恢复时可以在任意线程;
IO调度器:适合IO密集型任务调度器 比如读写文件,网络请求等。





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

使用 Kotlin 重写 AOSP 日历应用

两年前,Android 开源项目 (AOSP) 应用 团队开始使用 Kotlin 替代 Java 重构 AOSP 应用。之所以重构主要有两个原因: 一是确保 AOSP 应用能够遵循 Android 最佳实践,另外则是提供优先使用 Kotlin 进行应用开发的良...
继续阅读 »

两年前,Android 开源项目 (AOSP) 应用 团队开始使用 Kotlin 替代 Java 重构 AOSP 应用。之所以重构主要有两个原因: 一是确保 AOSP 应用能够遵循 Android 最佳实践,另外则是提供优先使用 Kotlin 进行应用开发的良好范例。Kotlin 之所以具有强大的吸引力,原因之一是其简洁的语法,很多情况下用 Kotlin 编写的代码块的代码数量相比于功能相同的 Java 代码块要更少一些。此外,Kotlin 这种具有丰富表现力的编程语言还具有其他各种优点,例如:




  • 空安全: 这一概念可以说是根植于 Kotlin 之中,从而帮助避免破坏性的空指针异常;




  • 并发: 正如 Google I/O 2019 中关于 Android 的描述,结构化并发 (structured concurrency) 能够允许使用协程简化后台的任务管理;




  • 兼容 Java: 尤其是在这次的重构项目中,Kotlin 与 Java 语言的兼容性能够让我们一个文件一个文件地进行 Kotlin 转换。




AOSP 团队在去年夏天发表了一篇文章,详细介绍了 AOSP 桌面时钟应用的转换过程。而今年,我们将 AOSP 日历应用从 Java 转换成了 Kotlin。在这次转换之前,应用的代码行数超过 18,000 行,在转换后代码库减少了约 300 行。在这次的转换中,我们沿袭了同 AOSP 桌面时钟转换过程中类似的技术,充分利用了 Kotlin 与 Java 语言的互操作性,对代码文件一一进行了转换,并在过程中使用独立的构建目标将 Java 代码文件替换为对应的 Kotlin 代码文件。因为团队中有两个人在进行此项工作,所以我们在 Android.bp 文件中为每个人创建了一个 exclude_srcs 属性,这样两个人就可以在减少代码合并冲突的前提下,都能够同时进行重构并推送代码。此外,这样还能允许我们进行增量测试,快速定位错误出现在哪些文件。


在转换任意给定的文件时,我们一开始先使用 Android Studio Kotlin 插件中提供的 从 Java 到 Kotlin 的自动转换工具。虽然该插件成功帮助我们转换了大部份的代码,但是还是会遇到一些问题,需要开发者手动解决。需要手动更改的部分,我们将会在本文接下来的章节中列出。


在将每个文件转换为 Kotlin 之后,我们手动测试了日历应用的 UI 界面,运行了单元测试,并运行了 Compatibility Test Suite (CTS) 的子集来进行功能验证,以确保不需要再进行任何的回归测试。


自动转换之后的步骤


上面提到,在使用自动转换工具之后,有一些反复出现的问题需要手动定位解决。在 AOSP 桌面时钟文章中,详细介绍了其中遇到的一些问题以及解决方法。如下列出了一些在进行 AOSP 日历转换过程中遇到的问题。


用 open 关键词标记父类


我们遇到的问题之一是 Kotlin 父类和子类之间的相互调用。在 Kotlin 中,要将一个类标记为可继承,必须得在类的声明中添加 open 关键字,对于父类中被子类覆盖的方法也要这样做。但是在 Java 中的继承是不需要使用到 open 关键字的。由于 Kotlin 和 Java 能够相互调用,这个问题直到大部分代码文件转换到了 Kotlin 才出现。


例如,在下面的代码片段中,声明了一个继承于 SimpleWeeksAdapter 的类:


class MonthByWeekAdapter(context: Context?, params:
    HashMap<String?, Int?>) : SimpleWeeksAdapter(context as Context, params) {//方法体}

由于代码文件的转换过程是一次一个文件进行的,即使是完全将 SimpleWeeksAdapter.kt 文件转换成 Kotlin,也不会在其类的声明中出现 open 关键词,这样就会导致一个错误。所以之后需要手动进行 open 关键词的添加,以便让 SimpleWeeksAdapter 类可以被继承。这个特殊的类声明如下所示:


open class SimpleWeeksAdapter(context: Context, params: HashMap<String?, Int?>?) {//方法体}

override 修饰符


同样地,子类中覆盖父类的方法也必须使用 override 修饰符来进行标记。在 Java 中,这是通过 @Override 注解来实现的。然而,虽然在 Java 中有相应的注解实现版本,但是自动转换过程中并没有为 Kotlin 方法声明中添加 override 修饰符。解决的办法是在所有适当的地方手动添加 override 修饰符。


覆写父类中的属性


在重构过程中,我们还遇到了一个属性覆写的异常问题,当一个子类声明了一个变量,而在父类中存在一个非私有的同名变量时,我们需要添加一个 override 修饰符。然而,即使子类的变量同父类变量的类型不同,也仍然要添加 override 修饰符。在某些情况下,添加 override 仍不能解决问题,尤其是当子类的类型完全不同的时候。事实上,如果类型不匹配,在子类的变量前添加 override 修饰符,并在父类的变量前添加 open 关键字,会导致一个错误:


type of *property name* doesn’t match the type of the overridden var-property

这个报错很让人疑惑,因为在 Java 中,以下代码可以正常编译:


public class Parent {
int num = 0;
}

class Child extends Parent {
String num = "num";
}

而在 Kotlin 中相应的代码就会报上面提到的错误:


class Parent {
var num: Int = 0
}

class Child : Parent() {
var num: String = "num"
}


这个问题很有意思,目前我们通过在子类中对变量重命名来规避了这个冲突。上面的 Java 代码会被 Android Studio 目前提供的代码转换器转换为有问题的 Kotlin 代码,这甚至 被报告为是一个 bug 了。


import 语句


在我们转换的所有文件中,自动转换工具都倾向于将 Java 代码中的所有 import 语句截断为 Kotlin 文件中的第一行。最开始这导致了一些很让人抓狂的错误,编译器会在整个代码中报 "unknown references" 的错误。在意识到这个问题后,我们开始手动地将 Java 中的 import 语句粘贴到 Kotlin 代码文件中,并单独对其进行转换。


暴露成员变量


默认情况下,Kotlin 会自动地为类中的实例变量生成 getter 和 setter 方法。然而,有些时候我们希望一个变量仅仅只是一个简单的 Java 成员变量,这可以通过使用 @JvmField 注解来实现。


@JvmField 注解 的作用是 "指示 Kotlin 编译器不要为这个属性生成 getter 和 setter 方法,并将其作为一个成员变量允许其被公开访问"。这个注解在 CalendarData 类 中特别有用,它包含了两个 static final 变量。通过对使用 val 声明的只读变量使用 @JvmField 注解,我们确保了这些变量可以作为成员变量被其他类访问,从而实现了 Java 和 Kotlin 之间的兼容性。


对象中的静态方法


在 Kotlin 对象中定义的函数必须使用 @JvmStatic 进行标记,以允许在 Java 代码中通过方法名,而非实例化来对它们进行调用。也就是说,这个注解使其具有了类似 Java 的方法行为,即能够通过类名调用方法。根据 Kotlin 的文档,"编译器会为对象的外部类生成一个静态方法,而对于对象本身会生成一个实例方法。"我们在 Utils 文件 中遇到了这个问题,当完成转换后,Java 类就变成了 Kotlin 对象。随后,所有在对象中定义的方法都必须使用 @JvmStatic 标记,这样就允许在其他文件中使用 Utils.method() 这样的语法来进行调用。值得一提的是,在类名和方法名之间使用 .INSTANCE (即 Utils.INSTANCE.method()) 也是一种选择,但是这不太符合常见的 Java 语法,需要改变所有对 Java 静态方法的调用。


性能评估分析


所有的基准测试都是在一台 96 核、176 GiB 内存的机器上进行的。本项目中分析用到的主要指标有所减少的代码行数、目标 APK 的文件大小、构建时间和首屏从启动到显示的时间。在对上述每个因素进行分析的同时,我们还收集了每个参数的数据并以表格的方式进行了展示。


减少的代码行数



从 Java 完全转换到 Kotlin 后,代码行数从 18,004 减少到了 17,729。这比原来的 Java 代码量 减少了大约 1.5%。虽然减少的代码量并不可观,但对于一些大型应用来说,这种转换对于减少代码行数的效果可能更为显著,可参阅 AOSP 桌面时钟 文中所举的例子。


目标 APK 大小



使用 Kotlin 编写的应用 APK 大小是 2.7 MB,而使用 Java 编写的应用 APK 大小是 2.6 MB。可以说这个差异基本可以忽略不计了,由于包含了一些额外的 Kotlin 库,所以 APK 体积上的增加,实际上是可以预期的。这种大小的增加可以通过使用 ProguardR8 来进行优化。


编译时间



Kotlin 和 Java 应用的构建时间是通过取 10 次从零进行完整构建的时间的平均值来计算的 (不包含异常值),Kotlin 应用的平均构建时间为 13 分 27 秒,而 Java 应用的平均构建时间为 12 分 6 秒。据一些资料 (如 "Java 和 Kotlin 的区别" 以及 "Kotlin 和 Java 在编译时间上的对比") 显示,Kotlin 的编译时间事实上比 Java 要更耗时,特别是对于从零开始的构建。一些分析断言,Java 的编译速度会快 10-15%,又有一些分析称这一数据为 15-20%。拿我们的例子进行从零开始完整构建所花费的时间来说,Java 的编译速度比 Kotlin 快 11.2%,尽管这个微小的差异并不在上述范围内,但这有可能是因为 AOSP 日历是一个相对较小的应用,仅有 43 个类。尽管从零开始的完整构建比较慢,但是 Kotlin 仍然在其他方面占有优势,这些优势更应该被考虑到。例如,Kotlin 相对于 Java,更简洁的语法通常可以保证较少的代码量,这使得 Kotlin 代码库更易维护。此外,由于 Kotlin 是一种更为安全有效的编程语言,我们可以认为完整构建时间较慢的问题可以忽略不计。


首屏显示的时间



我们使用了这种 方法 来测试应用从启动到完全显示首屏所需要的时间,经过 10 次试验后我们发现,使用 Kotlin 应用的平均时间约为 197.7 毫秒,而 Java 的则为 194.9 毫秒。这些测试都是在 Pixel 3a XL 设备上进行的。从这个测试结果可以得出结论,与 Kotlin 应用相比,Java 应用可能具有微小的优势;然而,由于平均时间非常接近,这个差异几乎可以忽略不计。因此,可以说 AOSP 日历应用转换到 Kotlin,并没有对应用的初始启动时间产生负面影响。


结论


将 AOSP 日历应用转换为 Kotlin 大约花了 1.5 个月 (6 周) 的时间,由 2 名实习生负责该项目的实施。一旦我们对代码库更加熟悉并更加善于解决反复出现的编译时、运行时和语法问题时,效率肯定会变得更高。总的来说,这个特殊的项目成功地展示了 Kotlin 如何影响现有的 Android 应用,并在对 AOSP 应用进行转换的路途中迈出了坚实的一步。


欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!


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

中秋快乐!来看看满眼都是中秋气息的app页面吧~

前言:看了很久,大家是真的🐂🍺,月球绕地球都整出来了,那我也来给大家整上花活~然后送上中秋祝福:月儿圆又亮,月饼圆又甜,家家团圆相聚,人人欢心甜蜜,祝你家圆人圆事事圆,中秋愉快! 不妨点个赞啦,看到这篇文章的帅哥~ app中秋的引导界面:(完整效果截图在最后哦...
继续阅读 »

前言:看了很久,大家是真的🐂🍺,月球绕地球都整出来了,那我也来给大家整上花活~然后送上中秋祝福:月儿圆又亮,月饼圆又甜,家家团圆相聚,人人欢心甜蜜,祝你家圆人圆事事圆,中秋愉快!


不妨点个赞啦,看到这篇文章的帅哥~


app中秋的引导界面:(完整效果截图在最后哦~)


效果图.gif


功能解析:


1.状态变化:背景和展示出来的诗篇与日期有关,日期不同,背景和诗篇不同


2.文字特效:中秋祝福的诗篇会一字一字慢慢浮现


3.倒计时处理:人性化,用户不想看直接跳过


1.状态变化:


我们定义一个变量date来控制状态,获取当前的日期来进行判断:


int _date = 1; //控制状态
DateTime _dateTime = DateTime.now(); //获取当前时间

然后在初始化时进行判断:


@override
 void initState() {
   super.initState();
   if (_dateTime.day <= 19) {
     ///19号之前,人们都在回家的路上
     _date = 1;
  } else if (_dateTime.day == 20) {
     ///20号,人们回到家中,吃上团圆饭
     _date = 2;
  } else if (_dateTime.day == 21) {
     ///21号,中秋快乐
     _date = 3;
  } else {
     ///中秋过后,亲人回到忙碌的生活,期盼着下一次团聚
     _date = 4;
  }
}

关于flutter如何获取时间,我给大家列出来了(送给新人,大神看了就图一乐~)


DateTime dateTime= DateTime.now();
dateTime.day 今天是几号,int类型
dateTime.month
dateTime.year
dateTime.hour
dateTime.minute
dateTime.second
dateTime.millisecond
dateTime.millisecondsSinceEpoch

2.文字特效


就像开始的gif图显示的一样,文字一个个浮现出来,其实这个很简单,我们可以自己diy,但是,广大热心程序猿给我们提供了插件:animated_text_kit


使用起来也很简单:


AnimatedTextKit(
 animatedTexts: [
   TyperAnimatedText(
     "Test文字",
     textStyle: TextStyle(fontSize: 22),
     speed: const Duration(milliseconds: 200),
  ),
],
 isRepeatingAnimation: false,//不循环播放
)

而且还有很多很多的效果,这里给大家列了出来,需要的可以查看文章最下方的项目源码


当然,在这里也是有难点的,因为flutter的文字无法竖排,网上有改源码的(我觉得复杂了)问了下朋友,说使用RotatedBox这个widget,但是我这看个der啊,你这竖的一个妙啊!


屏幕截图 2021-09-14 190155.jpg


所以最后我选择使用给每个文字后面加上/n 我直接手动换行,求求大神来告诉我解决方法(要不我自己写个插件哈哈)


3.倒计时处理


我们搞前端的必须要做一个人性化的东西给客户是不是


手动跳转加上:


int _countdown = 5;//五秒倒计时
Timer _countdownTimer;//控制倒计时

当然我们需要一个方法来控制倒计时,以及倒计时结束跳转:


void _startRecordTime() {
 _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
   setState(() {
     if (_countdown <= 1) {
       ///此处编写你需要跳转的界面
        _countdownTimer.cancel();
        _countdownTimer = null;
    } else {
       _countdown -= 1;
    }
  });
});
}

当然,在倒计时结束或者跳转时,记得把界面销毁~


@override
void dispose() {
 super.dispose();
 print('启动页面结束');
 if (_countdownTimer != null && _countdownTimer.isActive) {
   _countdownTimer.cancel();
   _countdownTimer = null;
}
}

onTap: () {
 ///点击跳过,在此处可以写跳转
 print("点击跳过,在此处可以写跳转代码,记得销毁界面哦");
},

完整效果:
屏幕截图 2021-09-14 195121.jpg


屏幕截图 2021-09-14 195203.jpg


屏幕截图 2021-09-14 195320.jpg


屏幕截图 2021-09-14 195345.jpg


源码地址:gitee.com/Xiao-Ti/aut…


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

React下一代状态管理库——recoil

引言 对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要...
继续阅读 »

引言


对于react状态管理库,大家比较熟悉的可能是Redux,但是redux虽然设计得比较简洁,但是他却有一些问题,比如需要写大量的模板代码;需要约定新的状态对象是全新的,如果我们不用全新的对象,可能会导致不更新,这是常见的redux状态不更新问题,所以需要开发者自己去保证,所以不得不引入例如immer这类的库;另外,redux本身是框架无关的库,他需要和redux-react结合才能在react中使用。使用我们不得不借助redux toolkit或者rematch这种内置了很多最佳实践的库以及重新设计接口的库,但与此同时也增加了开发者的学习成本。
所以react的状态管理的轮子层出不穷,下面将会介绍面向未来设计的react状态管理库——recoil。


简介


recoil 的 slogan 十分简单:一个react状态管理库(A state management library for React)。它不是一个框架无关的状态库,它是专门为react而生的。


和react一样,recoil也是facebook的开源的库。官方宣称有三个主要的特性:




  1. Minimal andReactish:最小化和react风格的api。




  2. Data-Flow Graph:数据流图。支持派生数据和异步查询都是纯函数,内部都是高效的订阅。




  3. Cross-App Observation: 跨应用监听,能够实现整体状态监听。




基本设计思想


假如有这么一个场景,相应状态改变我们 仅仅需要 更新list中的第二个节点和canvas的第二个节点。

如果没有使用第三外部状态管理库,使用context API可能是这样的:



我们可能需要很多个单独的provider,对应仅仅需要更新的节点,这样实际上使用状态的子节点的和Provider实际上是 耦合 的,我们使用状态的时候需要关心是否有相应的provider。
又假如我们使用的redux,其实如果只是某一个状态更新,其实所有的订阅函数都会重新运行,即使我们最后通过selector浅对比两次状态一样的,阻止更新react树,但是一旦订阅的节点数量非常多,实际上是会有性能问题的。


recoil把状态分为了一个个原子,react组件树只会订阅他们需要的状态。在这个场景中,组件树左边和右边的item订阅了不同的原子,当原子改变,他们只会更新相应的订阅的节点。

同时recoil也支持“派生状态”,也就是说已有的原子组合成一个新的状态(selector),并且新的状态也可以成为其他状态的依赖。

不仅支持同步的selector,recoil也支持异步的selector,recoil对selector的唯一要求就是他们必须是一个纯函数。

Recoil的设计思想就是我们把状态拆分一个一个的原子atom,再由selector派生出更多状态,最后React的组件树订阅自己需要的状态,当有原子状态更新,只有改变的原子及其下游节点有订阅他们的组件才会更新。也就是说,recoil其实构建了一个 有向无环图 ,这个图和react组件树正交,他的状态和react组件树是完全 解耦 的。


简单用法


吹了这么多先来看看简单的用法吧。
区别于redux是与框架无关的状态管理库,既然Recoil是专门为React设计的状态管理库,那么他的API满满的“react风格”。 Recoil 只支持hooks API,在使用上来说可以说十分简洁了。
下面看看 Demo


import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue
} from "recoil";

export default function App() {
return (
<RecoilRoot>
<Demo />
</RecoilRoot>
);
}

const textState = atom({
key: "textState",
default: ""
});

const charCountState = selector({
key:'charCountState',
get: ({get}) => {
// 要求是纯函数
const text = get(textState)
return text.length
}
})

function Demo() {
const [text, setText] = useRecoilState(textState);
const count = useRecoilValue(charCountState)
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<br />
Echo: {text}
<br />
charCount: {count}
</>
);
}



  • 类似于React Redux,recoil也有一个Provider——RecoilRoot,用于全局共享一些方法和状态。




  • atom(原子)是recoil中最小的状态单元,atom表示一个值可以被读、写、订阅,它必须有一个区别于其他atom保持 唯一性和不变性 的key。通过atom可以定义一个数据。




  • Selector 有点像React-Redux中的selector,同样是用来“派生”状态的,不过和React-Redux中不同是:




    • React-redux的selector是一个纯函数,当全局唯一的状态改变,它总是会运行,从全局唯一的状态运算出新的状态。




    • 而在recoil中,selector的 options.的get也要求是一个纯函数,其中传入其中的get方法用来获取其他atom。 当且仅当依赖的 atom 发生改变且有组件订阅selector ,它其实才会重新运算,这意味着计算的值是会被缓存下来的,当依赖没有发生改变,其实直接会从缓存中读取并返回。而selector返回的也是一个atom,这意味着派生状态其实也是一个原子,其实也可以作为其他selector的依赖。






很明显,recoil是通过get函数中的get入参来收集依赖的,recoil支持动态收集依赖,也就是说get可以在条件中调用:


const toggleState = atom({key: 'Toggle', default: false});

const mySelector = selector({
key: 'MySelector',
get: ({get}) => {
const toggle = get(toggleState);
if (toggle) {
return get(selectorA);
} else {
return get(selectorB);
}
},
});

异步


recoil天然支持异步,用法也十分简单,也不需要配置什么异步插件,看看 Demo


const asyncDataState = selector({
key: "asyncData",
get: async ({get}) => {
// 要求是纯函数
return await getAsyncData();
}
});

function AsyncComp() {
const asyncData = useRecoilValue(asyncDataState);
return <>{asyncData}</>;
}
function Demo() {
return (
<React.Suspense fallback={<>loading...</>}>
<AsyncComp />
</React.Suspense>
);
}

由于recoil天然支持react suspense的特性,所以使用useRecoilValue获取数据的时候,如果异步状态pending,那么默认将会抛出该promise,使用时需要外层使用React.Suspense,那么react就会显示fallback里面的内容;如果报错,也会抛出里面的内容,被外层的ErrorBoundary捕获。
如果你不想使用该特性,可以使用useRecoilValueLoadable直接获取异步状态, demo


function AsyncComp() {
const asyncState = useRecoilValueLoadable(asyncDataState);
if (asyncState.state === "loading") {
return <>loading...</>;
}
if (asyncState.state === "hasError") {
return <>has error....</>;
}
if (asyncState.state === "hasValue") {
return <>{asyncState.contents}</>;
}
return null;
}

另外注意默认异步的结果是会被缓存下来,其实所有的selector上游没有改变的结果都会被缓存下来。也就是说如果异步的依赖没有发生改变,那么不会重新执行异步函数,直接返回缓存的值。这也是为什么一直强调selector配置项get是纯函数的原因。


依赖外部变量


我们常常会遇到状态不纯粹的问题,如果状态其实是依赖外部的变量,recoil有selectorFamily支持:


const getUserInfoState = selectorFamily({
key: "userInfo",
get: (userId) => ({ get }) => {
return queryUserState({userId: id, xxx: get(xxx) });
},
});

function MyComponent({ userID }) {

const number = useRecoilValue(getUserInfoState(userID));
//...
}

这里外部的参数和key,会同时生成一个全局唯一的key,用于标识状态,也就是说如果外部变量没有变化或者依赖没有发生变化,不会重新计算状态,而是直接返回缓存值。


源码解析


如果说看到这里,仅仅实现上面那些简单例子的话,大家可能会说“就这”?实现起来应该不太难,这里有一个简单的 实现的版本 ,虽然功能差不多,但是架构完全不一样,recoil的源码继承了react源码的优良传统,就是十分难读。。。


其源码核心功能分为几个部分:




  • Graph 图相关的逻辑




  • Nodeatom和selector在内部统一抽象为node




  • RecoilRoot 主要是就是外部用的一些recoilRoot,




  • RecoilValue 对外部暴露的类型。也就说atom、selector的返回值。




  • hooks 使用的hooks相关的。




  • Snapshot 状态快照,提供状态记录和回滚。




  • 一些其他读不懂的代码。。。




下面就谈谈自己这几天看源码粗浅的认识,欢迎大佬们指正。


Concurrent mode 支持


为了防止把大家绕晕,先讲讲我最关心的问题,recoil是如何支持conccurent的思路,可能不太正确(网上没有资料参考,欢迎讨论)。


Cocurrent mode


先讲一讲什么是react的Cocurrent mode,官网的介绍是,一系列新的特性帮助ract应用保持响应式和并优雅的使用用户设备能力和网络速度。
react迁移到fiber架构就是为了concurrent mode的实现,React 在新的架构下实际上有两个阶段:




  • 渲染(rendering)阶段




  • 提交(commit)阶段




在渲染阶段,react 可以根据任务优先级对组件树进行渲染,所以当前渲染任务可能会因为优先级不够或者当前帧没有剩余时间而被中断。后续调度会重新执行当前任务渲染。


ui和state不一致的问题


因为react现在会放弃控制流,在渲染开始到渲染结束,任何事情都可能发生,一些的钩子被取消就是因为这个原因。而对于第三方状态库来说,比如说有一个异步请求在这段时间把外部的状态改变了,react会继续上一次打断的地方重新渲染,就会读到新的状态值。 就会发生 状态和 UI 不一致 的情况。


recoil的解决办法


整体数据结构



atom


atom实际上是调用baseAtom,baseAtom内部有闭包变量defaultLoadable一个用于记录当前的默认值。声明了getAtom函数和setAtom函数等,最后传给registerNode,完成注册。


function baseAtom(options){
// 默认值
let defaultLoadable = isPromise(options.default) ? xxxx : options.default

function getAtom(store,state){
if(state.atomValues.has(key)){
// 如果当前state里有这个key的值,直接返回。
return state.atomValues.get(key)
}else if(state.novalidtedAtoms.has(key)){
//.. 一些逻辑
}else{
return defaultLoadable;
}
}

function setAtom(store, state, newValue){
if (state.atomValues.has(key)) {
const existing = nullthrows(state.atomValues.get(key));
if (existing.state === 'hasValue' && newValue === existing.contents) {
// 如果相等就返回空map
return new Map();
}
}
//...
// 返回的的是key --> 新的loadableValue的Map
return new Map().set(key, loadableWithValue(newValue));
}

function invalidateAtom(){
//...
}



const node = registerNode(
({
key,
nodeType: 'atom',
get: getAtom,
set: setAtom,
init: initAtom,
invalidate: invalidateAtom,
// 忽略其他配置。。。
}),
);
return node;
}

function registerNode(){
if (nodes.has(node.key)) {
//...
}
nodes.set(node.key, node);

const recoilValue =
node.set == null
? new RecoilValueClasses.RecoilValueReadOnly(node.key)
: new RecoilValueClasses.RecoilState(node.key);

recoilValues.set(node.key, recoilValue);
return recoilValue;
}

selector


由于selector也可以传入set配置项,这里就不分析了。


function selector(options){
const {key, get} = options
const deps = new Set();
function selectorGet(){
// 检测是否有循环依赖
return detectCircularDependencies(() =>
getSelectorValAndUpdatedDeps(store, state),
);
}

function getSelectorValAndUpdatedDeps(){
const cachedVal = getValFromCacheAndUpdatedDownstreamDeps(store, state);
if (cachedVal != null) {
setExecutionInfo(cachedVal, store);
// 如果有缓存值直接返回
return cachedVal;
}
// 解析getter
const [loadable, newDepValues] = evaluateSelectorGetter(
store,
state,
newExecutionId,
);
// 缓存结果
maybeSetCacheWithLoadable(
state,
depValuesToDepRoute(newDepValues),
loadable,
);
//...
return lodable
}

function evaluateSelectorGetter(){
function getRecoilValue(recoilValue){
const { key: depKey } = recoilValue
dpes.add(key);
// 存入graph
setDepsInStore(store, state, deps, executionId);
const depLoadable = getCachedNodeLoadable(store, state, depKey);
if (depLoadable.state === 'hasValue') {
return depLoadable.contents;
}
throw depLoadable.contents;
}
const result = get({get: getRecoilValue});
const lodable = getLodable(result);
//...

return [loadable, depValues];
}

return registerNode<T>({
key,
nodeType: 'selector',
peek: selectorPeek,
get: selectorGet,
init: selectorInit,
invalidate: invalidateSelector,
//...
});
}
}

hooks


useRecoilValue && useRecoilValueLoadable




  • useRecoilValue底层实际上就是依赖useRecoilValueLoadable,如果useRecoilValueLoadable的返回值是promise,那么就把他抛出来。




  • useRecoilValueLoadable 首先是在useEffect里订阅RecoilValue的变化,如果发现变化不太一样,调用forceupdate重新渲染。返回值则是通过调用node的get方法拿到值为lodable类型的,返回出来。




function useRecoilValue<T>(recoilValue: RecoilValue<T>): T {
const storeRef = useStoreRef();
const loadable = useRecoilValueLoadable(recoilValue);
// 如果是promise就是throw出去。
return handleLoadable(loadable, recoilValue, storeRef);
}

function useRecoilValueLoadable_LEGACY(recoilValue){
const storeRef = useStoreRef();
const [_, forceUpdate] = useState([]);

const componentName = useComponentName();

useEffect(() => {
const store = storeRef.current;
const storeState = store.getState();
// 实际上就是在storeState.nodeToComponentSubscriptions里面建立 node --> 订阅函数的映射
const subscription = subscribeToRecoilValue(
store,
recoilValue,
_state => {
// 在代码里通过gkx开启一些特性,方便单元测试和代码迭代。
if (!gkx('recoil_suppress_rerender_in_callback')) {
return forceUpdate([]);
}
const newLoadable = getRecoilValueAsLoadable(
store,
recoilValue,
store.getState().currentTree,
);
// 小小的优化
if (!prevLoadableRef.current?.is(newLoadable)) {
forceUpdate(newLoadable);
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
//...
// release
return subscription.release;
})

// 实际上就是调用node.get方法。然后做一些其他处理
const loadable = getRecoilValueAsLoadable(storeRef.current, recoilValue);

const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

这里一个有意思的点是useComponentName的实现有一点点hack:由于我们通常会约定hooks的命名是use开头,所以可以通过调用栈去找第一个调用函数不是use开头的函数名,就是组件的名称。当然生产环境,由于代码混淆是不可用的。


function useComponentName(): string {
const nameRef = useRef();
if (__DEV__) {
if (nameRef.current === undefined) {
const frames = stackTraceParser(new Error().stack);
for (const {methodName} of frames) {
if (!methodName.match(/\buse[^\b]+$/)) {
return (nameRef.current = methodName);
}
}
nameRef.current = null;
}
return nameRef.current ?? '<unable to determine component name>';
}
return '<component name not available>';
}

useRecoilValueLoadable_MUTABLESOURCE基本上是一样的,除了订阅函数里我们从手动调用foceupdate变成了调用参数callback。


function useRecoilValueLoadable_MUTABLESOURCE(){
//...

const getLoadable = useCallback(() => {
const store = storeRef.current;
const storeState = store.getState();
//...
const treeState = storeState.currentTree;
return getRecoilValueAsLoadable(store, recoilValue, treeState);
}, [storeRef, recoilValue]);

const subscribe = useCallback(
(_storeState, callback) => {
const store = storeRef.current;
const subscription = subscribeToRecoilValue(
store,
recoilValue,
() => {
if (!gkx('recoil_suppress_rerender_in_callback')) {
return callback();
}
const newLoadable = getLoadable();
if (!prevLoadableRef.current.is(newLoadable)) {
callback();
}
prevLoadableRef.current = newLoadable;
},
componentName,
);
return subscription.release;
},
[storeRef, recoilValue, componentName, getLoadable],
);
const source = useRecoilMutableSource();
const loadable = useMutableSource(source, getLoadableWithTesting, subscribe);
const prevLoadableRef = useRef(loadable);
useEffect(() => {
prevLoadableRef.current = loadable;
});
return loadable;
}

useSetRecoilState & setRecoilValue


useSetRecoilState最终其实就是调用queueOrPerformStateUpdate,把更新放入更新队列里面等待时机调用


function useSetRecoilState(recoilState){
const storeRef = useStoreRef();
return useCallback(
(newValueOrUpdater) => {
setRecoilValue(storeRef.current, recoilState, newValueOrUpdater);
},
[storeRef, recoilState],
);
}

function setRecoilValue<T>(
store,
recoilValue,
valueOrUpdater,
) {
queueOrPerformStateUpdate(store, {
type: 'set',
recoilValue,
valueOrUpdater,
});
}

queueOrPerformStateUpdate,之后的操作比较复杂这里做简化为三步,如下;


function queueOrPerformStateUpdate(){
//...
//atomValues中设置值
state.atomValues.set(key, loadable);
// dirtyAtoms 中添加key。
state.dirtyAtoms.add(key);
//通过storeRef拿到。
notifyBatcherOfChange.current()
}

Batcher


recoil内部自己实现了一个批量更新的机制。


function Batcher({
setNotifyBatcherOfChange,
}: {
setNotifyBatcherOfChange: (() => void) => void,
}) {
const storeRef = useStoreRef();

const [_, setState] = useState([]);
setNotifyBatcherOfChange(() => setState({}));

useEffect(() => {
endBatch(storeRef);
});

return null;
}


function endBatch(storeRef) {
const storeState = storeRef.current.getState();
const {nextTree} = storeState;
if (nextTree === null) {
return;
}
// 树交换
storeState.previousTree = storeState.currentTree;
storeState.currentTree = nextTree;
storeState.nextTree = null;

sendEndOfBatchNotifications(storeRef.current);
}

function sendEndOfBatchNotifications(store: Store) {
const storeState = store.getState();
const treeState = storeState.currentTree;
const dirtyAtoms = treeState.dirtyAtoms;
// 拿到所有下游的节点。
const dependentNodes = getDownstreamNodes(
store,
treeState,
treeState.dirtyAtoms,
);
for (const key of dependentNodes) {
const comps = storeState.nodeToComponentSubscriptions.get(key);

if (comps) {
for (const [_subID, [_debugName, callback]] of comps) {
callback(treeState);
}
}
}
}
//...
}

总结


虽然关于react的状态管理库很多,但是recoil的一些思想还是很先进,社区里面对这个新轮子也很多挂关注,目前githubstar14k。因为recoil目前还不是稳定版本,所以npm下载量并不高,也不建议大家在生产环境中使用。不过相信随着react18的发布,recoil也会更新为稳定版本,它的使用将会越来越多,到时候大家可以尝试一下。


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

收起阅读 »

css做‘展开收起’功能,借鉴大佬思路

开局一张图 上图所示,多行文本的展开收起是一个很常见的交互效果。 实现这一类布局和交互难点主要一下几点: 位于多行文本右下角的“展开收起”按钮 “展开”和“收起”两种状态的切换 当文本不超过指定行数时,不显示“展开收起”按钮 在此之前,单独看这个布局,即...
继续阅读 »

开局一张图


more.gif


上图所示,多行文本的展开收起是一个很常见的交互效果。


实现这一类布局和交互难点主要一下几点:



  • 位于多行文本右下角的“展开收起”按钮

  • “展开”和“收起”两种状态的切换

  • 当文本不超过指定行数时,不显示“展开收起”按钮


在此之前,单独看这个布局,即便是配合JavaScript也不那么容易做出好看的交互效果。经过各方学习,发现纯CSS也能完美实现。


第一步,"展开收起"按钮


多行文本截断

假设有如下的一段html结构


<div class='more-text'>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

多行文本超出展示省略号的方式,大家平常也用得蛮多吧,关键代码如下


.more-text {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

image.png


按钮右下角环绕效果

<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>

.more-btn{
float: left;
/*其他装饰样式*/
}

image.png


换为右浮动


.more-btn{
float: right;
/*其他装饰样式*/
}


image.png


再移到右下角


.more-btn{
float: right;
margin-top: 50px;
/*其他装饰样式*/
}

image.png


不难看出,按钮确实到了右下角,但按钮上方空白空间太大了。并不是我们希望的效果。


此时,借鉴伪元素配合多个浮动元素来完成。


.more-text::before {
content: '';
float: right;
width: 10px;
height: 50px;
background: red;
}
.more-btn{
float: right;
clear: both;
/*其他装饰样式*/
}


image.png


如上图,当按钮和伪元素before都浮动,并且按钮clear: both,此时,伪元素before成功将按钮顶到了右下角。让伪元素before的宽度去掉便出现如下效果。


.more-text::before {
content: '';
float: right;
width: 0;
height: 50px;
background: red;
}

image.png


如你所见,按钮环绕效果非~常完美符合预期。


但是before高度是固定的50px,不一定会满足场景所需。还需修改为calc动态计算。


.more-text::before {
content: '';
float: right;
width: 0;
height: calc(100% - 20px);
/*100%减去一个按钮的高度即可*/
background: red;
}

image.png


很可惜,calc并没有达到理想的效果。


为什么呢?打开控制台可以发现,calc计算所得高度为0。怎么会这样呢?原因其实是因为父级元素没有设置高度,calc里面的 100% 便失效了。但问题在于,这里所需要的高度是动态变化的,不可能给父级定下一个固定高度。


至此,我们需要对布局进行修改。利用flex布局。大概的方法就是在 flex 布局 的子项中,可以通过百分比来计算变化高度。


修改如下,给.more-text再包裹一层,再设置 display: flex


<div class='more-wrapper'>
<div class='more-text'>
<div class='more-btn'>展开</div>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

.more-wrapper{
display: flex;
}

这样修改之后,calc的计算高度便能够生效。如下图所示。


image.png


至此,按钮右下角环绕效果就基本完成了。配上一个按钮点击事件就大功告成了。


浏览器兼容性处理


上面的实现是最完美的处理方式。但是,在Firefox浏览器却出现了兼容性问题。


image.png


哦豁。如此就非常尴尬。祸不单行,Safari浏览器也出现了兼容问题。


经过多番查证,发现是display: -webkit-box;属性存在兼容问题。


问题就在于,如果没有display: -webkit-box;怎么实现多行截断呢?如果在知道行数的情况下设置一个最大高度,理论上也能实现多行截断。由此我们通过行高属性line-height去入手。如果需要设置成 3 行,那就将高度设置成为 line-height * 3。


.more-text {
/*
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
*/
overflow: hidden;
line-height: 1.5;
max-height: 4.5em;
}

image.png


此时呢还缺少省略号...。可以利用伪元素实现。


.more-btn::before{
content: '…';
color: #333;
font-size: 14px;
position: absolute;
left: -10px;
transform: translateX(-100%);
}

image.png


大功告成,接下来加上点击切换即可。


点击切换“展开“ 与 ”收起“。


咱们目标是纯CSS完成。那么CSS状态切换就必不可少了,完全可以用input type = "checkbox"这个特性来完成。


要用到input特性就得对html代码进行一些修改。


<div class="more-wrapper">
<input type="checkbox" id="exp" />
<div class="more-text">
<!-- <div>展开</div> -->
<label class="more-btn" for="exp">展开</label>
如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。如何才能做好展开收起交互呢。
</div>
</div>

#exp:checked + .more-text {
-webkit-line-clamp: 999;
max-height: none;
}

more.gif


接下来,就是变换按钮文字,以及展开之后省略号隐藏。此时都可以利用伪元素处理。


<label class="more-btn" for="exp"></label>
<!-- 去掉按钮文字 -->

.more-btn::after {
content: '更多';
}

在:checked状态中


#exp:checked + .more-text .more-btn::after {
content: '收起';
}

省略号隐藏处理。


#exp:checked + .more-text .more-btn::before {
visibility: hidden;
}

more.gif


至此,我们需要的效果便成了。


当然咱们还可以添加一些过渡动画让展开收起效果更加美观。在此就不演示了。


最后,文本行数判断


此前的步骤已经能够满足使用需求。但是还是存在问题。比如当文本内容较少时,此时不会发生截断,便不需要省略号...以及展开收起按钮。


image.png


此时当然可以选择js方式去做判断。但我们的目标是纯CSS。


那CSS没有逻辑判断,咱们只能另辟蹊径,视觉欺骗。或者叫做障眼法


比如在上图中的场景,没有发生截断,那就不需要省略号...展开按钮。这时,如果在文本的最后加上一个元素。并且为了不影响布局,给此元素设置绝对定位。


.more-text::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
background: red;
}

同时,我们把父级的overflow: hidden;先去掉。得到效果如下


image.png


如图可见,红色部分的元素非常完美的挡住了按钮部分。


那我们把红色改成父级一样的背景色,并且恢复父级的overflow: hidden;


more.gif


上图可见,发现展开之后呢,伪元素盖住了收起按钮。所以必须再做一些修改。


#exp:checked + .more-text::after {
visibility: hidden;
}

more.gif


如你所见,非~常的好用。



注:IE10以下就不考虑了哈~




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

收起阅读 »

浅谈前端的状态管理

前言 提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在...
继续阅读 »

前言


提到状态管理大家可能马上就想到:Vuex、Redux、Flux、Mobx等等方案。其实不然,不论哪种方案只要内容一多起来似乎都是令人头疼的问题,也许你有适合自己的解决方案又或者简单的注释和区分模块,今天来聊一聊前端的状态管理,如果你有好的建议或问题欢迎在下方留言提出。


什么是前端状态管理?


举个例子:图书馆里所有人都可以随意进书库借书还书,如果人数不多,这种方式可以提高效率减少流程,一旦人数多起来就容易混乱,书的走向不明确,甚至丢失。所以需要一个图书管理员来专门记录借书的记录,也就是你要委托图书管理员给你借书及还书。


实际上,大多数状态管理方案都是如上思想,通过管理员(比如 Vuex)去规范书库里书本的借还(项目中需要存储的数据)


Vuex


在国内业务使用中 Vuex 的比例应该是最高的,Vuex 也是基于 Flux 思想的产品,Vuex 中的 state 是可以被修改的。原因和 Vue 的运行机制有关系,Vue 基于 ES5 中的 getter/setter 来实现视图和数据的双向绑定,因此 Vuex 中 state 的变更可以通过 setter 通知到视图中对应的指令来实现视图更新。更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。我们以图书馆来作为例子:


const state = {
book: 0
}

const mutations = {
borrow_book(state) {
state.book ++
}
}

//调用时
store.commit('borrow_book')

那还有action呢? 在 mutation 中混合异步调用会导致你的程序很难调试。你怎么知道是哪个先执行完呢?
aciton 可以包含任意异步操作,用法跟上面基本类似,不再叙述。


其实我只是拿 Vuex 来浅入一下相关用法大家应该是都熟悉了,那 Vuex 解决了什么问题呢?



  • 管理多个组件共享状态。

  • 全局状态管理。

  • 状态变更跟踪。

  • 让状态管理形成一种规范,使代码结构更清晰。


实际上大部分程序员都比较懒(狗头保命),只是为了能多个组件共享状态,至于其他的都是事后了。最典型的就是加入购物车的数量,加入一个就通过 Vuex 记录保存最终的总数显示在下栏。


那问题来了,既然你的目的只是共享多个状态,那何不直接用 Bus 总线好了?


Bus 总线


Bus 总线实际上他是一个公共的 Vue 实例,专门处理 emit 和 on 事件。


实际上 Bus 总线十分轻便,他并不存在 Dom 结构,他仅仅只是具有实例方法而已。


Vue.prototype.$Bus = new Vue()

然后,你可以通过 emit 来发送事件, on 来接收事件。


// 发送事件
this.$Bus.$emit('borrow_book', 1)

// 任意组件中接收
this.$Bus.$on('borrow_book', (book) => {
console.log(`借了${book}本书`)
})

当然还有 off(移除)、once(监听一次)等操作感兴趣可以自行搜索引擎。


怎么样?上面对于满足共享一个状态是不是比 Vuex 要简单多了?实际上确实是简单多了,但这也代表他比较适合中小型项目。多于大型项目来说 Bus 只会让你追述更改源时一脸懵逼甚至你都不知道他在哪里改变了。


他的工作原理就是发布订阅者的思想,虽然非常优雅简单,但实际 Vue 并不提倡这种写法,并在3.0版本中移除了大部分相关Api(emit、on等),其实不然,发布订阅模式你也可以自己手写一个去实现:


class Bus {
constructor() {
// 收集订阅信息,调度中心
this.list = {};
}

// 订阅
$on(name, fn) {
this.list[name] = this.list[name] || [];
this.list[name].push(fn);
}

// 发布
$emit(name, data) {
if (this.list[name]) {
this.list[name].forEach((fn) => {
fn(data);
});
}
}

// 取消订阅
$off(name) {
if (this.list[name]) {
delete this.list[name];
}
}
}
export default Bus;

简单吧?你只需要跟用 Vue Bus 一样去实例化然后用就可以了。什么?你想共享两三个甚至更少的状态(一个),那封装一个 Bus 是不是有点没必要了? 行吧,那你用 web storage 吧。


web storage


其实说到这,storage只是数据存储方式,跟状态管理其实没有太大关系,只是共享数据。但是既然都提到了那就顺带说一下(狗头)


web storage 有这三种:cookie、local storage、session storage。


无论这三种的哪种都强烈建议不要将敏感信息放入其中,这里应该是加密或一些不那么重要的数据在里面。


先简单复习一下三者:































类别生命周期存储容量存储位置
cookie默认保存在内存中,随浏览器关闭失效(如果设置过期时间,在到过期时间后失效)4KB保存在客户端,每次请求时都会带上
localStorage理论上永久有效的,除非主动清除。4.98MB(不同浏览器情况不同,safari 2.49M)保存在客户端,不与服务端交互。节省网络流量
sessionStorage仅在当前网页会话下有效,关闭页面或浏览器后会被清除。4.98MB(部分浏览器没有限制)同上

cookie 不必多说,大家发起请求时经常会携带cookie请求一些个人数据等,与我们要探讨的内容没有太大关系。


loaclStorage 可以存储理论上永久有效的数据,如果你要存储状态一般推荐是放在 sessionStorage,localStorage 也有以下局限:



  • 浏览器的大小不统一,并且在 IE8 以上的 IE 版本才支持 localStorage 这个属性。

  • 目前所有的浏览器中都会把localStorage的值类型限定为string类型,这个在对我们日常比较常见的JSON对象类型需要一些转换。

  • localStorage在浏览器的隐私模式下面是不可读取的。

  • localStorage本质上是对字符串的读取,如果存储内容多的话会消耗内存空间,会导致页面变卡。

  • localStorage不能被爬虫抓取到。


localStorage 与 sessionStorage 的唯一一点区别就是 localStorage 属于永久性存储,而 sessionStorage 属于当会话结束的时候,sessionStorage 中的键值对会被清空。


localStorage 本身只支持字符串形式存储,所以你存整数类型,拿出来的会是字符串类型。


sessionStorage 与 localStorage 基本差不多,只是回话关闭时,数据就会清空。


总结


不论哪种方案选择合适自己项目的方案才是最佳实践。没有最好的方案,只有合适自己的方案。


以上只是略微浅谈,也可能不够全面,欢迎下方留言~


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

收起阅读 »

关注 ? ? ? 前端仔也需要懂的nginx内容

tips 如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件和使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景 前言 作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容...
继续阅读 »


tips


如果你已经使用过nginx的,可以跳过介绍,直接看nginx配置文件使用场景,如果你想全局熟悉下nginx,就耐心慢慢看看,在文章结尾会补上nginx的一些常用实战场景


前言


作为一名前端,我们除了node作为服务以外,我们还有什么选择,那么简单容易上手的Nginx可以满足你的一切幻想。学习nginx可以让我们更加清晰前端项目上线的整个流程

作为一个前端,或多或少都会对Nginx有一些经验,那为什么还要学习那? 不系统:以前可能你只会配置某项功能(网上搜集),都是碎片化的知识,不没有形成系统化。这样就导致你服务出现问题时,根本不知道从哪里下手来解决这些问题。


一、Nginx是什么?


nginx官方介绍:



"Nginx是一款轻量级的HTTP服务器,采用事件驱动的异步非阻塞处理方式框架,这让其具有极好的IO性能,时常用于服务端的反向代理和负载均衡。"



nginx的优点



  • 支持海量高并发:采用IO多路复用epoll。官方测试Nginx能够支持5万并发链接,实际生产环境中可以支撑2-4万并发连接数。

  • 内存消耗少

  • 可商业化

  • 配置文件简单


除了这些优点还有很多,比如反向代理功能,灰度发布,负载均衡功能等


二、安装


这里的文章不着重介绍怎么安装nginx,但是也给大家留下了安装的教程地址,自取



如果是centos大家也可以直接用yum安装也是很方便的


yum -y install nginx


nginx.conf 文件是nginx总配置文件也是nginx读取配置的入口。


三、nginx文件介绍


nginx我们最常用到的文件,其实就是nginx的配置文件,其他的文件我们就带过了,当你能熟练编写nginx文件,其实就等于熟练使用nginx了


[wujianrong@localhost ~]# tree /usr/local/nginx
/usr/local/nginx
├── client_body_temp
├── conf # Nginx所有配置文件的目录
│ ├── fastcgi.conf # fastcgi相关参数的配置文件
│ ├── fastcgi.conf.default # fastcgi.conf的原始备份文件
│ ├── fastcgi_params # fastcgi的参数文件
│ ├── fastcgi_params.default
│ ├── koi-utf
│ ├── koi-win
│ ├── mime.types # 媒体类型
│ ├── mime.types.default
│ ├── nginx.conf # Nginx主配置文件
│ ├── nginx.conf.default
│ ├── scgi_params # scgi相关参数文件
│ ├── scgi_params.default
│ ├── uwsgi_params # uwsgi相关参数文件
│ ├── uwsgi_params.default
│ └── win-utf
├── fastcgi_temp # fastcgi临时数据目录
├── html # Nginx默认站点目录
│ ├── 50x.html # 错误页面优雅替代显示文件,例如当出现502错误时会调用此页面
│ └── index.html # 默认的首页文件
├── logs # Nginx日志目录
│ ├── access.log # 访问日志文件
│ ├── error.log # 错误日志文件
│ └── nginx.pid # pid文件,Nginx进程启动后,会把所有进程的ID号写到此文件
├── proxy_temp # 临时目录
├── sbin # Nginx命令目录
│ └── nginx # Nginx的启动命令
├── scgi_temp # 临时目录
└── uwsgi_temp # 临时目录


1. 配置文件(重点)


conf //nginx所有配置文件目录   
nginx.conf //这个是Nginx的核心配置文件,这个文件非常重要,也是我们即将要学习的重点
nginx.conf.default //nginx.conf的备份文件

2. 日志


logs: 记录入门的文件,当nginx服务器启动后
这里面会有 access.log error.log 和nginx.pid三个文件出现。

3. 资源目录


html //存放nginx自带的两个静态的html页面   
50x.html //访问失败后的失败页面
index.html //成功访问的默认首页

4. 备份文件


fastcgi.conf:fastcgi  //相关配置文件
fastcgi.conf.default //fastcgi.conf的备份文件
fastcgi_params //fastcgi的参数文件
fastcgi_params.default //fastcgi的参数备份文件
scgi_params //scgi的参数文件
scgi_params.default //scgi的参数备份文件
uwsgi_params //uwsgi的参数文件
uwsgi_params.default //uwsgi的参数备份文件
mime.types //记录的是HTTP协议中的Content-Type的值和文件后缀名的对应关系
mime.types.default //mime.types的备份文件

5.编码文件


koi-utf、koi-win、win-utf这三个文件都是与编码转换映射相关的配置文件,
用来将一种编码转换成另一种编码

6. 执行文件


sbin: 是存放执行程序文件nginx

7. 命令


nginx: 是用来控制Nginx的启动和停止等相关的命令。

四、nginx常用命令



  1. 常见2种启动命令


> nginx //直接nginx启动,前提是配好nginx环境变量
> systemctl start nginx.service //使用systemctl命令启动


  1. 常见的4种停止命令


> nginx  -s stop //立即停止服务
> nginx -s quit // 从容停止服务 需要进程完成当前工作后再停止
> killall nginx //直接杀死nginx进程
> systemctl stop nginx.service //systemctl停止


  1. 常见的2种重启命令


> nginx -s reload //重启nginx
> systemctl reload nginx.service //systemctl重启nginx


  1. 验证nginx配置文件是否正确


> nginx -t //输出nginx.conf syntax is ok即表示nginx的配置文件正确


五、nginx配置详细介绍


1. 配置文件的结构介绍


为了让大家有个简单的轮廓,这里先对配置文件做一个简单的描述:


worker_processes  1;                			# worker进程的数量
events { # 事件区块开始
worker_connections 1024; # 每个worker进程支持的最大连接数
} # 事件区块结束
http { # HTTP区块开始
include mime.types; # Nginx支持的媒体类型库文件
default_type application/octet-stream; # 默认的媒体类型
sendfile on; # 开启高效传输模式
keepalive_timeout 65; # 连接超时
server { # 第一个Server区块开始,表示一个独立的虚拟主机站点
listen 80; # 提供服务的端口,默认80
server_name localhost; # 提供服务的域名主机名
location / { # 第一个location区块开始
root html; # 站点的根目录,相当于Nginx的安装目录
index index.html index.htm; # 默认的首页文件,多个用空格分开
} # 第一个location区块结果
error_page 500502503504 /50x.html; # 出现对应的http状态码时,使用50x.html回应客户
location = /50x.html { # location区块开始,访问50x.html
root html; # 指定对应的站点目录为html
}
}
......



  1. ngxin.conf 相当于是入口文件,nginx启动后会先从nginx.conf里面读取基础配置

  2. conf 目录下面的各种xxx.conf文件呢,一般就是每一个应用的配置,比如a网站的nginx配置叫a.conf,b网站的叫b.conf,可以方便我们去便于管理

  3. 加载conf目录下的配置,在主配置文件nginx.conf中,一般会有这么一行代码


2. nginx.conf主配置文件详细介绍


image.png


3. xx.conf 子配置文件详细介绍


我们最常改动nginx的,就是子配置文件


image.png


4. 关于location匹配


    #优先级1,精确匹配,根路径
location =/ {
return 400;
}

#优先级2,以某个字符串开头,以av开头的,优先匹配这里,区分大小写
location ^~ /av {
root /data/av/;
}

#优先级3,区分大小写的正则匹配,匹配/media*****路径
location ~ /media {
alias /data/static/;
}

#优先级4 ,不区分大小写的正则匹配,所有的****.jpg|gif|png 都走这里
location ~* .*\.(jpg|gif|png|js|css)$ {
root /data/av/;
}

#优先7,通用匹配
location / {
return 403;
}

更多配置


六、nginx反向代理、负载均衡 简单介绍


1. 反向代理


在聊反向代理之前,我们先看看正向代理,正向代理也是大家最常接触的到的代理模式,我们会从两个方面来说关于正向代理的处理模式,分别从软件方面和生活方面来解释一下什么叫正向代理,也说说正反向代理的区别


正向代理


正向代理,"它代理的是客户端",是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。客户端必须要进行一些特别的设置才能使用正向代理
正向代理的用途:



  • 访问原来无法访问的资源,如Google

  • 可以做缓存,加速访问资源

  • 对客户端访问授权,上网进行认证

  • 代理可以记录用户访问记录(上网行为管理),对外隐藏用户信息


反向代理


反向代理,"它代理的是服务端",主要用于服务器集群分布式部署的情况下,反向代理隐藏了服务器的信息
反向代理的作用:



  • 保证内网的安全,通常将反向代理作为公网访问地址,Web服务器是内网

  • 负载均衡,通过反向代理服务器来优化网站的负载


image.png


2. 负载均衡


服务器接收不同客户端发送的、Nginx反向代理服务器接收到的请求数量,就是我们说的负载量。
这些请求数量按照一定的规则进行分发到不同的服务器处理的规则,就是一种均衡规则。
所以,将服务器接收到的请求按照规则分发的过程,称为负载均衡

负载均衡也分硬件负载均衡和软件负载均衡两种,我们来讲的是软件负载均衡,关于硬件负载均衡的有兴趣的靓仔可以去了解下
负载均衡的算法:



  • 轮询(默认、加权轮询、ip_hash)

  • 插件(fair、url_hash),url_hash和ip_hash大同小异,一个基于ip一个基于url,就不过多介绍了


默认轮询


每个请求按时间顺序逐一分配到不同的后端服务器,如果后端某个服务器宕机,能自动剔除故障系统。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001;
server localhost:10002;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

加权轮询


通过设置weight,值越大分配率越大
到的访问概率越高,主要用于后端每台服务器性能不均衡的情况下。其次是为在主从的情况下设置不同的权值,达到合理有效的地利用主机资源。


# constPolling 作为存放负载均衡的变量
upstream constPolling {
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}
server {
listen 10000;
server_name localhost;
location / {
proxy_pass http://constPolling; #在代理的时候接入constPolling
proxy_redirect default;
}
}

权重越大,被访问的概率越大,比如上面就是33.33%和百分66.66%的访问概率
访问的效果:

localhost:10001、localhost:10002、localhost:10002、localhost:10001、localhost:10002、localhost:10002


ip_hash


每个请求都根据访问ip的hash结果分配,经过这样的处理,每个访客固定访问一个后端服务,如下配置(ip_hash可以和weight配合使用),并且可以有效解决动态网页存在的session共享问题


upstream constPolling {
ip_hash;
server localhost:10001 weight=1;
server localhost:10002 weight=2;
}

fair


个人比较喜欢用的一种负载均衡算法,fair算法可以根据页面大小和加载时间长短智能地进行负载均衡,响应时间短的优先分配。



  1. 安装upstream_fair模块 附上fair安装教程

  2. 哪个服务器的响应速度快,就将请求分配到那个服务器上


upstream constPolling { 
server localhost:10001;
server localhost:10002;
fair;
}

七、nginx错误页面配置、开启Gzip压缩配置


1. nginx错误页面配置


当我们访问的地址不存在的时候,我们可以根据http状态码来做对应的处理,我们以404为例


image.png
当然除了404以为我们还可以根据其他的状态码显示的,比如500、502等,熊猫的公司项目中,因为多个项目的错误页面都是统一的,所以我们有单独维护的一套错误码页面放到了我们公司的中台项目中,然后根据客户端是PC/移动端,跳转到对应的错误页面


2.Gzip压缩


Gzip是网页的一种网页压缩技术,经过gzip压缩后,页面大小可以变为原来的30%甚至更小。更小的网页会让用户浏览的体验更好,速度更快。gzip网页压缩的实现需要浏览器和服务器的支持

gzip是需要服务器和浏览器同时支持的。当浏览器支持gzip压缩时,会在请求消息中包含Accept-Encoding:gzip,这样Nginx就会向浏览器发送听过gzip后的内容,同时在相应信息头中加入Content-Encoding:gzip,声明这是gzip后的内容,告知浏览器要先解压后才能解析输出。
如果项目是在ie或者一些兼容性比较低浏览器上运行的,需要去查阅确定是否浏览器支持gzip


server {

listen 12089;

index index.php index.html;

error_log /var/log/nginx/error.log;

access_log /var/log/nginx/access.log;

root /var/www/html/gzip;
# 开启gzip压缩

gzip on;

# http请求版本

gzip_http_version 1.0;

# 设置什么类型的文件需要压缩

gzip_types text/css text/javascript application/javascript image/png image/jpeg image/gif;

location / {

index index.html index.htm index.php;

autoindex off;

}

}

gzip_types对应需要什么格式,可以去查看content-Type


image.png


Content-Type: text/css

# 成功开启gzip
Content-Encoding: gzip

八、常用全局变量
































































































变量含义
$args这个变量等于请求行中的参数,同$query_string
$content length请求头中的Content-length字段。
$content_type请求头中的Content-Type字段。
$document_root当前请求在root指令中指定的值。
$host请求主机头字段,否则为服务器名称。
$http_user_agent客户端agent信息
$http_cookie客户端cookie信息
$limit_rate这个变量可以限制连接速率。
$request_method客户端请求的动作,通常为GET或POST。
$remote_addr客户端的IP地址。
$remote_port客户端的端口。
$remote_user已经经过Auth Basic Module验证的用户名。
$request_filename当前请求的文件路径,由root或alias指令与URI请求生成。
$schemeHTTP方法(如http,https)。
$server_protocol请求使用的协议,通常是HTTP/1.0或HTTP/1.1。
$server_addr服务器地址,在完成一次系统调用后可以确定这个值。
$server_name服务器名称。
$server_port请求到达服务器的端口号。
$request_uri包含请求参数的原始URI,不包含主机名,如”/foo/bar.php?arg=baz”。
$uri不带请求参数的当前URI,$uri不包含主机名,如”/foo/bar.html”。
$document_uri与$uri相同。



九、nginx使用综合场景(在github里面会持续更新和补充)


1. 同一个域名通过不同目录指定不同项目目录


在开发过程中,有一种场景,比如有项目有多个子系统需要通过同一个域名通过不同目录去访问
在A/B Test 灰度发布等场景也会用上

比如:

访问 a.com/a/*** 访问的是a系统

访问 a.com/b/*** 访问的是b系统


image.png


2. 自动适配PC/移动端页面


image.png


3. 限制只能通过谷歌浏览器访问


image.png


4. 前端单页面应用刷新404问题


image.png


更多:包括防盗链、动静分离、权限控制



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

收起阅读 »

从零开发一款轻量级滑动验证码插件

效果演示 滑动验证组件基本使用和技术实现 上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果...
继续阅读 »

效果演示


slider.gif


滑动验证组件基本使用和技术实现


上图是实现的滑动验证组件的一个效果演示,当然还有很多配置项可以选择,以便支持更多 定制化 的场景。接下来我先介绍一下如何安装和使用这款验证码插件,让大家有一个直观的体验,然后我会详细介绍一下滑动验证码的实现思路,如果大家有一定的技术基础,也可以直接跳到技术实现部分。


基本使用


因为 react-slider-vertify 这款组件我已经发布到 npm 上了,所以大家可以按照如下方式安装和使用:



  1. 安装


# 或者 yarn add @alex_xu/react-slider-vertify
npm i @alex_xu/react-slider-vertify -S


  1. 使用


import React from 'react';
import { Vertify } from '@alex_xu/react-slider-vertify';

export default () => {
return <Vertify
width={320}
height={160}
onSuccess={() => alert('success')}
onFail={() => alert('fail')}
onRefresh={() => alert('refresh')}
/>
};

通过以上两步我们就可以轻松使用这款滑动验证码组件了,是不是很简单?
image.png


当然我也暴露了很多可配置的属性,让大家对组件有更好的控制。参考如下:


image.png


技术实现


在做这个项目之前我也研究了一些滑动验证码的知识以及已有的技术方案,收获很多。接下来我会以我的组件设计思路来和大家介绍如何用 react 来实现和封装滑动验证码组件,如果大家有更好的想法和建议, 也可以在评论区随时和我反馈。


1.组件设计的思路和技巧


每个人都有自己设计组件的方式和风格,但最终目的都是更 优雅 的设计组件。这里我大致列举一下 优雅 组件的设计指标:




  • 可读性(代码格式统一清晰,注释完整,代码结构层次分明,编程范式使用得当)




  • 可用性(代码功能完整,在不同场景都能很好兼容,业务逻辑覆盖率)




  • 复用性(代码可以很好的被其他业务模块复用)




  • 可维护性(代码易于维护和扩展,并有一定的向下/向上兼容性)




  • 高性能




以上是我自己设计组件的考量指标,大家可以参考一下。


另外设计组件之前我们还需要明确需求,就拿滑动验证码组件举例,我们需要先知道它的使用场景(用于登录注册、活动、论坛、短信等高风险业务场景的人机验证服务)和需求(交互逻辑,以什么样的方式验证,需要暴露哪些属性)。


image.png


以上就是我梳理的一个大致的组件开发需求,在开发具体组件之前,如果遇到复杂的业务逻辑,我们还可以将每一个实现步骤列举出来,然后一一实现,这样有助于整理我们的思路和更高效的开发。


2.滑动验证码基本实现原理


在介绍完组件设计思路和需求分析之后,我们来看看滑动验证码的实现原理。


image.png


我们都知道设计验证码的主要目的是为了防止机器非法暴力地入侵我们的应用,其中核心要解决的问题就是判断应用是谁在操作( or 机器),所以通常的解决方案就是随机识别


上图我们可以看到只有用户手动将滑块拖拽到对应的镂空区域,才算验证成功,镂空区域的位置是随机的(随机性测试这里暂时以前端的方式来实现,更安全的做法是通过后端来返回位置和图片)。


基于以上分析我们就可以得出一个基本的滑动验证码设计原理图:


image.png


接下来我们就一起封装这款可扩展的滑动验证码组件。


3.封装一款可扩展的滑动验证码组件


按照我开发组件一贯的风格,我会先基于需求来编写组件的基本框架:


import React, { useRef, useState, useEffect, ReactNode } from 'react';

interface IVertifyProp {
/**
* @description canvas宽度
* @default 320
*/
width:number,
/**
* @description canvas高度
* @default 160
*/
height:number,
/**
* @description 滑块边长
* @default 42
*/
l:number,
/**
* @description 滑块半径
* @default 9
*/
r:number,
/**
* @description 是否可见
* @default true
*/
visible:boolean,
/**
* @description 滑块文本
* @default 向右滑动填充拼图
*/
text:string | ReactNode,
/**
* @description 刷新按钮icon, 为icon的url地址
* @default -
*/
refreshIcon:string,
/**
* @description 用于获取随机图片的url地址
* @default https://picsum.photos/${id}/${width}/${height}, 具体参考https://picsum.photos/, 只需要实现类似接口即可
*/
imgUrl:string,
/**
* @description 验证成功回调
* @default ():void => {}
*/
onSuccess:VoidFunction,
/**
* @description 验证失败回调
* @default ():void => {}
*/
onFail:VoidFunction,
/**
* @description 刷新时回调
* @default ():void => {}
*/
onRefresh:VoidFunction
}

export default ({
width = 320,
height = 160,
l = 42,
r = 9,
imgUrl,
text,
refreshIcon = 'http://yourimgsite/icon.png',
visible = true,
onSuccess,
onFail,
onRefresh
}: IVertifyProp) => {
return <div className="vertifyWrap">
<div className="canvasArea">
<canvas width={width} height={height}></canvas>
<canvas className="block" width={width} height={height}></canvas>
</div>
<div className={sliderClass}>
<div className="sliderMask">
<div className="slider">
<div className="sliderIcon">&rarr;</div>
</div>
</div>
<div className="sliderText">{ textTip }</div>
</div>
<div className="refreshIcon" onClick={handleRefresh}></div>
<div className="loadingContainer">
<div className="loadingIcon"></div>
<span>加载中...</span>
</div>
</div>
}

以上就是我们组件的基本框架结构。从代码中可以发现组件属性一目了然,这都是提前做好需求整理带来的好处,它可以让我们在编写组件时思路更清晰。在编写好基本的 css 样式之后我们看到的界面是这样的:


image.png


接下来我们需要实现以下几个核心功能:



  • 镂空效果的 canvas 图片实现

  • 镂空图案 canvas 实现

  • 滑块移动和验证逻辑实现


上面的描述可能比较抽象,我画张图示意一下:


image.png


因为组件实现完全采用的 react hooks ,如果大家对 hooks 不熟悉也可以参考我之前的文章:



1.实现镂空效果的 canvas 图片


image.png


在开始 coding 之前我们需要对 canvas 有个基本的了解,建议不熟悉的朋友可以参考高效 canvas 学习文档: Canvas of MDN


由上图可知首先要解决的问题就是如何用 canvas 画不规则的图形,这里我简单的画个草图:


image.png


我们只需要使用 canvas 提供的 路径api 画出上图的路径,并将路径填充为任意半透明的颜色即可。建议大家不熟悉的可以先了解如下 api :



  • beginPath() 开始路径绘制

  • moveTo() 移动笔触到指定点

  • arc() 绘制弧形

  • lineTo() 画线

  • stroke() 描边

  • fill() 填充

  • clip() 裁切路径


实现方法如下:


const drawPath  = (ctx:any, x:number, y:number, operation: 'fill' | 'clip') => {
ctx.beginPath()
ctx.moveTo(x, y)
ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI)
ctx.lineTo(x + l, y)
ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI)
ctx.lineTo(x + l, y + l)
ctx.lineTo(x, y + l)
// anticlockwise为一个布尔值。为true时,是逆时针方向,否则顺时针方向
ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true)
ctx.lineTo(x, y)
ctx.lineWidth = 2
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'
ctx.stroke()
ctx.globalCompositeOperation = 'destination-over'
// 判断是填充还是裁切, 裁切主要用于生成图案滑块
operation === 'fill'? ctx.fill() : ctx.clip()
}

这块实现方案也是参考了 yield 大佬的原生 js 实现,这里需要补充的一点是 canvasglobalCompositeOperation 属性,它的主要目的是设置如何将一个源(新的)图像绘制到目标(已有)的图像上。




  • 源图像 = 我们打算放置到画布上的绘图




  • 目标图像 = 我们已经放置在画布上的绘图




w3c上有个形象的例子:


image.png


这里之所以设置该属性是为了让镂空的形状不受背景底图的影响并覆盖在背景底图的上方。如下:


image.png


接下来我们只需要将图片绘制到画布上即可:


const canvasCtx = canvasRef.current.getContext('2d')
// 绘制镂空形状
drawPath(canvasCtx, 50, 50, 'fill')

// 画入图片
canvasCtx.drawImage(img, 0, 0, width, height)

当然至于如何生成随机图片和随机位置,实现方式也很简单,前端实现的话采用 Math.random 即可。


2.实现镂空图案 canvas


上面实现了镂空形状,那么镂空图案也类似,我们只需要使用 clip() 方法将图片裁切到形状遮罩里,并将镂空图案置于画布左边即可。代码如下:


const blockCtx = blockRef.current.getContext('2d')
drawPath(blockCtx, 50, 50, 'clip')
blockCtx.drawImage(img, 0, 0, width, height)

// 提取图案滑块并放到最左边
const y1 = 50 - r * 2 - 1
const ImageData = blockCtx.getImageData(xRef.current - 3, y1, L, L)
// 调整滑块画布宽度
blockRef.current.width = L
blockCtx.putImageData(ImageData, 0, y1)

上面的代码我们用到了 getImageDataputImageData,这两个 api 主要用来获取 canvas 画布场景像素数据和对场景进行像素数据的写入。实现后 的效果如下:


image.png


3.实现滑块移动和验证逻辑


实现滑块移动的方案也比较简单,我们只需要利用鼠标的 event 事件即可:



  • onMouseDown

  • onMouseMove

  • onMouseUp


image.png


以上是一个简单的示意图,具体实现代码如下:


const handleDragMove = (e) => {
if (!isMouseDownRef.current) return false
e.preventDefault()
// 为了支持移动端, 可以使用e.touches[0]
const eventX = e.clientX || e.touches[0].clientX
const eventY = e.clientY || e.touches[0].clientY
const moveX = eventX - originXRef.current
const moveY = eventY - originYRef.current
if (moveX < 0 || moveX + 36 >= width) return false
setSliderLeft(moveX)
const blockLeft = (width - l - 2r) / (width - l) * moveX
blockRef.current.style.left = blockLeft + 'px'
}

当然我们还需要对拖拽停止后的事件做监听,来判断是否验证成功,并埋入成功和失败的回调。代码如下:


const handleDragEnd = (e) => {
if (!isMouseDownRef.current) return false
isMouseDownRef.current = false
const eventX = e.clientX || e.changedTouches[0].clientX
if (eventX === originXRef.current) return false
setSliderClass('sliderContainer')
const { flag, result } = verify()
if (flag) {
if (result) {
setSliderClass('sliderContainer sliderContainer_success')
// 成功后的自定义回调函数
typeof onSuccess === 'function' && onSuccess()
} else {
// 验证失败, 刷新重置
setSliderClass('sliderContainer sliderContainer_fail')
setTextTip('请再试一次')
reset()
}
} else {
setSliderClass('sliderContainer sliderContainer_fail')
// 失败后的自定义回调函数
typeof onFail === 'function' && onFail()
setTimeout(reset.bind(this), 1000)
}
}

实现后的效果如下:


chrome-capture (4).gif


当然还有一些细节需要优化处理,这里在 github 上有完整的代码,大家可以参考学习一下,如果大家想对该组件参与贡献,也可以随时提 issue


4.如何使用 dumi 搭建组件文档


为了让组件能被其他人更好的理解和使用,我们可以搭建组件文档。作为一名热爱开源的前端 coder,编写组件文档也是个很好的开发习惯。接下来我们也为 react-slider-vertify 编写一下组件文档,这里我使用 dumi 来搭建组件文档,当然大家也可以用其他方案(比如storybook)。我们先看一下搭建后的效果:


image.png


image.png


dumi 搭建组件文档非常简单,接下来和大家介绍一下安装使用方式。



  1. 安装


$ npx @umijs/create-dumi-lib        # 初始化一个文档模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib

$ npx @umijs/create-dumi-lib --site # 初始化一个站点模式的组件库开发脚手架
# or
$ yarn create @umijs/dumi-lib --site


  1. 本地运行


npm run dev
# or
yarn dev


  1. 编写文档


dumi 约定式的定义了文档编写的位置和方式,其官网上也有具体的饭介绍,这里简单给大家上一个 dumi 搭建的组件目录结构图:


image.png


我们可以在 docs 下编写组件库文档首页和引导页的说明,在单个组件的文件夹下使用 index.md 来编写组件自身的使用文档,当然整个过程非常简单,我这里举一个文档的例子:


image.png


通过这种方式 dumi 就可以帮我们自动渲染一个组件使用文档。如果大家想学习更多组件文档搭建的内容,也可以在 dumi 官网学习。


5.发布自己第一个npm组件包


最后一个问题就是组件发布。之前很多朋友问我如何将自己的组件发布到 npm 上让更多人使用,这块的知识网上有很多资料可以学习,那今天就以滑动验证码 @alex_xu/react-slider-vertify 的例子,来和大家做一个简单的介绍。



  1. 拥有一个 npm 账号并登录


如果大家之前没有 npm 账号,可以在 npm 官网 注册一个,然后用我们熟悉的 IDE 终端登录一次:


npm login

跟着提示输入完用户名密码之后我们就能通过命令行发布组件包了:


npm publish --access public

之所以指令后面会加 public 参数,是为了避免权限问题导致组件包无法发布成功。我们为了省事也可以把发布命令配置到 package.json 中,在组件打包完成后自动发布:


{
"scripts": {
"start": "dumi dev",
"release": "npm run build && npm publish --access public",
}
}

这样我们就能将组件轻松发布到 npm 上供他人使用啦! 我之前也开源了很多组件库,如果大家对组件打包细节和构建流程有疑问,也可以参考我之前开源项目的方案。 发布到 npm 后的效果:


image.png


最后


如果大家对可视化搭建或者低代码/零代码感兴趣,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端真正的技术。


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

收起阅读 »

Flutter自适应瀑布流

前言:在电商app经常会看到首页商品推荐的瀑布流,或者类似短视频app首页也是瀑布流,这些都是需要自适应的,才能给用户带来好的体验 话不多说先上效果图: 根据效果图可以分为四步: 1.图片自适应 2.自适应标签 3.上拉刷新和下拉加载 4.底部的点赞按钮可以...
继续阅读 »

前言:在电商app经常会看到首页商品推荐的瀑布流,或者类似短视频app首页也是瀑布流,这些都是需要自适应的,才能给用户带来好的体验


话不多说先上效果图:


在这里插入图片描述在这里插入图片描述


根据效果图可以分为四步:


1.图片自适应

2.自适应标签

3.上拉刷新和下拉加载

4.底部的点赞按钮可以去掉或者自己修改样式,我这里使用的like_button库

注:本文使用的库:为啥这么多呢,因为我把图片缓存这样东西都加上了,单纯的瀑布流就用waterfall_flow

waterfall_flow: ^3.0.1
extended_image: any
extended_sliver: any
ff_annotation_route_library: any
http_client_helper: any
intl: any
like_button: any
loading_more_list: any
pull_to_refresh_notification: any
url_launcher: any

1.图片自适应:


Widget image = Stack(
children: <Widget>[
ExtendedImage.network(
item.imageUrl,
shape: BoxShape.rectangle,
//clearMemoryCacheWhenDispose: true,
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(10.0),
),
loadStateChanged: (ExtendedImageState value) {
if (value.extendedImageLoadState == LoadState.loading) {
Widget loadingWidget = Container(
alignment: Alignment.center,
color: Colors.grey.withOpacity(0.8),
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor:
AlwaysStoppedAnimation<Color>(Theme.of(c).primaryColor),
),
);
if (!konwSized) {
//todo: not work in web
loadingWidget = AspectRatio(
aspectRatio: 1.0,
child: loadingWidget,
);
}
return loadingWidget;
} else if (value.extendedImageLoadState == LoadState.completed) {
item.imageRawSize = Size(
value.extendedImageInfo.image.width.toDouble(),
value.extendedImageInfo.image.height.toDouble());
}
return null;
},
),
Positioned(
top: 5.0,
right: 5.0,
child: Container(
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.6),
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
child: Text(
'${index + 1}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: fontSize, color: Colors.white),
),
),
)
],
);
if (konwSized) {
image = AspectRatio(
aspectRatio: item.imageSize.width / item.imageSize.height,
child: image,
);
} else if (item.imageRawSize != null) {
image = AspectRatio(
aspectRatio: item.imageRawSize.width / item.imageRawSize.height,
child: image,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
image,
const SizedBox(
height: 5.0,
),
buildTagsWidget(item),
const SizedBox(
height: 5.0,
),
buildBottomWidget(item),
],
);
}

2.自适应标签:


Widget buildTagsWidget(
TuChongItem item, {
int maxNum = 6,
}) {
const double fontSize = 12.0;
return Wrap(
runSpacing: 5.0,
spacing: 5.0,
children: item.tags.take(maxNum).map<Widget>((String tag) {
final Color color = item.tagColors[item.tags.indexOf(tag)];
return Container(
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: color,
border: Border.all(color: Colors.grey.withOpacity(0.4), width: 1.0),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
child: Text(
tag,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: fontSize,
color: color.computeLuminance() < 0.5
? Colors.white
: Colors.black),
),
);
}).toList());
}

3.上拉刷新和下拉加载


class PullToRefreshHeader extends StatelessWidget {
const PullToRefreshHeader(this.info, this.lastRefreshTime, {this.color});
final PullToRefreshScrollNotificationInfo info;
final DateTime lastRefreshTime;
final Color color;
@override
Widget build(BuildContext context) {
if (info == null) {
return Container();
}
String text = '';
if (info.mode == RefreshIndicatorMode.armed) {
text = 'Release to refresh';
} else if (info.mode == RefreshIndicatorMode.refresh ||
info.mode == RefreshIndicatorMode.snap) {
text = 'Loading...';
} else if (info.mode == RefreshIndicatorMode.done) {
text = 'Refresh completed.';
} else if (info.mode == RefreshIndicatorMode.drag) {
text = 'Pull to refresh';
} else if (info.mode == RefreshIndicatorMode.canceled) {
text = 'Cancel refresh';
}

final TextStyle ts = const TextStyle(
color: Colors.grey,
).copyWith(fontSize: 13);

final double dragOffset = info?.dragOffset ?? 0.0;

final DateTime time = lastRefreshTime ?? DateTime.now();
final double top = -hideHeight + dragOffset;
return Container(
height: dragOffset,
color: color ?? Colors.transparent,
//padding: EdgeInsets.only(top: dragOffset / 3),
//padding: EdgeInsets.only(bottom: 5.0),
child: Stack(
children: <Widget>[
Positioned(
left: 0.0,
right: 0.0,
top: top,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: RefreshImage(top),
margin: const EdgeInsets.only(right: 12.0),
),
),
Column(
children: <Widget>[
Text(
text,
style: ts,
),
Text(
'Last updated:' +
DateFormat('yyyy-MM-dd hh:mm').format(time),
style: ts.copyWith(fontSize: 12),
)
],
),
Expanded(
child: Container(),
),
],
),
)
],
),
);
}
}

class RefreshImage extends StatelessWidget {
const RefreshImage(this.top);
final double top;
@override
Widget build(BuildContext context) {
const double imageSize = 40;
return ExtendedImage.asset(
Assets.assets_fluttercandies_grey_png,
width: imageSize,
height: imageSize,
afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) {
final double imageHeight = image.height.toDouble();
final double imageWidth = image.width.toDouble();
final Size size = rect.size;
final double y = (1 - min(top / (refreshHeight - hideHeight), 1)) *
imageHeight;

canvas.drawImageRect(
image,
Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y),
Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height,
size.width, (imageHeight - y) / imageHeight * size.height),
Paint()
..colorFilter =
const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn)
..isAntiAlias = false
..filterQuality = FilterQuality.low);

//canvas.restore();
},
);
}
}

4.底部的点赞按钮


LikeButton(
size: 18.0,
isLiked: item.isFavorite,
likeCount: item.favorites,
countBuilder: (int count, bool isLiked, String text) {
final ColorSwatch<int> color =
isLiked ? Colors.pinkAccent : Colors.grey;
Widget result;
if (count == 0) {
result = Text(
'love',
style: TextStyle(color: color, fontSize: fontSize),
);
} else {
result = Text(
count >= 1000 ? (count / 1000.0).toStringAsFixed(1) + 'k' : text,
style: TextStyle(color: color, fontSize: fontSize),
);
}
return result;
},
likeCountAnimationType: item.favorites < 1000
? LikeCountAnimationType.part
: LikeCountAnimationType.none,
onTap: (bool isLiked) {
return onLikeButtonTap(isLiked, item);
},
)

这样自适应的瀑布流就完成了。


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

Android 控制 ContentProvider的创建

序言 随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentP...
继续阅读 »

序言


随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentProvider中获取了敏感信息,又没有提供控制方法。我们就很被动。于是我花了点时间研究了怎么hook contentProvider的创建。让其在用户同意后再初始化。


方案1


声明在清单文件中的ContentProvider 会在应用启动后就创建。具体是在 ActivityThread的handleBindApplication方法中。(以下截图为Android 30的ActivityThread)
在这里插入图片描述
具体就在这一句
在这里插入图片描述


installContentProviders实现如下
在这里插入图片描述
最终是通过AppComponentFactory的instantiateProvider方法创建。
在这里插入图片描述
在这里插入图片描述


而AppComponentFactory是Android 28以后系统提供给我们的一个hook的工厂类。可以通过清单文件指定,在这里面可以hook 所有组件的初始化。
在这里插入图片描述
这么指定
在这里插入图片描述


但是在Android 28以下,比如这个截图是Android 25.没有这类,ContentProvider直接通过反射获得。无法通过该类来修改。
在这里插入图片描述


最终方案


为了兼容性,考虑如下方案。在调用installContentProviders前,如果这个data里面的providers为空岂不是不会走installContentProviders方法了吗。
在这里插入图片描述
这个data 是一个AppBindData类型,通过handleBindApplication方法的参数传入。会保存到ActivityThread的 mBoundApplication 字段中。
在这里插入图片描述


于是就可以通过获取这个mBoundApplication 字段中的providers 来保存要初始化的provider。再讲providers置为空即可。到了用户同意以后,再去通过反射调用ActivityThread的installContentProviders方法即可。
在这里插入图片描述


hook时机


这个时机只有Application的attachBaseContext方法中。该方法会比installContentProviders提前执行。


最后的代码App中


public class MyApp extends Application {

static MyApp app;

/**
*用户同意
*/
public static void agree(Action action) {
HookUtil.initProvider(app);
action.doAction();
}

public interface Action {
void doAction();
}


@Override
protected void attachBaseContext(Context base) {
app = this;
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
super.attachBaseContext(base);
}
}


HookUtil


package com.zgh.testcontentprovider;

import android.content.Context;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;


/**
* Created by zhuguohui
* Date: 2021/9/13
* Time: 11:23
* Desc:
*/
public class HookUtil {

private static Object providers;

private static Method installContentProvidersMethod;
private static Object currentActivityThread;

/*
*用户同意后调用
*/
public static void initProvider(Context context){
try {
installContentProvidersMethod.invoke(currentActivityThread,context,providers);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void attachContext() throws Exception {

// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//currentActivityThread是一个static函数所以可以直接invoke,不需要带实例参数
currentActivityThread = currentActivityThreadMethod.invoke(null);

hookInstallContentProvider(activityThreadClass);

}

private static void hookInstallContentProvider(Class activityThreadClass) throws Exception{
Field appDataField = activityThreadClass.getDeclaredField("mBoundApplication");
appDataField.setAccessible(true);
Object appData= appDataField.get(currentActivityThread);
Field providersField= appData.getClass().getDeclaredField("providers");
providersField.setAccessible(true);
providers = providersField.get(appData);
//清空provider,避免有些sdk通过provider来初始化
providersField.set(appData,null);

installContentProvidersMethod = activityThreadClass.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
}
}


搭配


搭配我之前写的工具,可以更完美的实现用户同意之前不初始化任何SDK的目标
通过拦截 Activity的创建 实现APP的隐私政策改造


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

不做跟风党,LiveData,StateFlow,SharedFlow 使用场景对比

Android 常用的分层架构 Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。 「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小...
继续阅读 »

Android 常用的分层架构


Android 中加载 UI 数据不是一件轻松的事,开发者经常需要处理各种边界情况。如各种生命周期和因为「配置更改」导致的 Activity 的销毁与重建。



「配置更改」的场景有很多:屏幕旋转,切换至多窗口模式,调整窗口大小,浅色模式与暗黑模式的切换,更改默认语言,更改字体大小等等



因此普遍处理方式是使用分层的架构。这样开发者就可以编写独立于 UI 的代码,而无需过多考虑生命周期,配置更改等场景。 例如,我们可以在表现层(Presentation Layer)的基础上添加一个领域层(Domain Layer) 来保存业务逻辑,使用数据层(Data Layer)对上层屏蔽数据来源(数据可能来自远程服务,可能是本地数据库)。



表现层可以分成具有不同职责的组件:



  • View:处理生命周期回调,用户事件和页面跳转,Android 中主要是 Activity 和 Fragment

  • Presenter 或 ViewModel:向 View 提供数据,并不了解 View 所处的生命周期,通常生命周期比 View 长


Presenter 和 ViewModel 向 View 提供数据的机制是不同的,简单来说:



  • Presenter 通过持有 View 的引用并直接调用操作 View,以此向 View 提供数据

  • ViewModel 通过将可观察的数据暴露给观察者来向 View 提供数据


官方提供的可观察的数据 组件是 LiveData。Kotlin 1.4.0 正式版发布之后,开发者有了新的选择:StateFlowSharedFlow


最近网上流传出「LiveData 被弃用,应该使用 Flow 替代 LiveData」的声音。


LiveData 真的有那么不堪吗?Flow 真的适合你使用吗?


不人云亦云,只求接近真相。我们今天来讨论一下这两种组件。


ViewModel + LiveData


为了实现高效地加载 UI 数据,获得最佳的用户体验,应实现以下目标:



  • 目标1:已经加载的数据无需在「配置更改」的场景下再次加载

  • 目标2:避免在非活跃状态(不是 STARTEDRESUMED)下加载数据和刷新 UI

  • 目标3:「配置更改」时不会中断的工作


Google 官方在 2017 年发布了架构组件库:使用 ViewModel + LiveData 帮助开发者实现上述目标。



相信很多人在官方文档中见过这个图,ViewModelActivity/Fragment 的生命周期更长,不受「配置更改」导致 Activity/Fragment 重建的影响。刚好满足了目标 1 和目标 3。


LiveData 是可生命周期感知的。 新值仅在生命周期处于 STARTEDRESUMED 状态时才会分配给观察者,并且观察者会自动取消注册,避免了内存泄漏。 LiveData 对实现目标 1 和 目标 2 很有用:它缓存其持有的数据的最新值,并将该值自动分派给新的观察者。


LiveData 的特性


既然有声音说「LiveData 要被弃用了」,那么我们先对 LiveData 进行一个全面的了解。聊聊它能做什么,不能做什么,以及使用过程中有哪些要注意的地方。


LiveData 是 Android Jetpack Lifecycle 组件中的内容。属于官方库的一部分,Kotlin/Java 均可使用。


一句话概括 LiveDataLiveData 是可感知生命周期的,可观察的,数据持有者


它的能力和作用很简单:更新 UI


它有一些可以被认为是优点的特性:



  • 观察者的回调永远发生在主线程

  • 仅持有单个且最新的数据

  • 自动取消订阅

  • 提供「可读可写」和「仅可读」两个版本收缩权限

  • 配合 DataBinding 实现「双向绑定」


观察者的回调永远发生在主线程


这个很好理解,LiveData 被用来更新 UI,因此 ObserveronChanged() 方法在主线程回调。



背后的原理也很简单,LiveDatasetValue() 发生在主线程(非主线程调用会抛异常,postValue() 内部会切换到主线程调用 setValue())。之后遍历所有观察者的 onChanged() 方法。


仅持有单个且最新的数据


作为数据持有者(data holder),LiveData 仅持有 单个最新 的数据。


单个且最新,意味着 LiveData 每次持有一个数据,并且新数据会覆盖上一个。


这个设计很好理解,数据决定了 UI 的展示,绘制 UI 时肯定要使用最新的数据,「过时的数据」应该被忽略。



配合 Lifecycle,观察者只会在活跃状态下(STARTEDRESUMED)接收到 LiveData 持有的最新的数据。在非活跃状态下绘制 UI 没有意义,是一种资源的浪费。



自动取消订阅


这是 LiveData 可感知生命周期的重要表现,自动取消订阅意味着开发者无需手动写那些取消订阅的模板代码,降低了内存泄漏的可能性。


背后原理是在生命周期处于 DESTROYED 时,移除观察者。



提供「可读可写」和「仅可读」两个版本




点击查看代码
public abstract class LiveData<T> {
@MainThread
protected void setValue(T value) {
// ...
}

protected void postValue(T value) {
// ...
}

@Nullable
public T getValue() {
// ...
}
}

public class MutableLiveData<T> extends LiveData<T> {
@Override
public void postValue(T value) {
super.postValue(value);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}
复制代码



抽象类 LiveDatasetValue()postValue() 是 protected,而其实现类 MutableLiveData 均为 public。



LiveData 提供了 mutable(MutableLiveData) 和 immutable(LiveData) 两个类,前者「可读可写」,后者「仅可读」。通过权限的细化,让使用者各取所需,避免由于权限泛滥导致的数据异常。




点击查看代码
class SharedViewModel : ViewModel() {
private val _user : MutableLiveData<User> = MutableLiveData()

val user : LiveData<User> = _user

fun setUser(user: User) {
_user.posetValue(user)
}
}
复制代码


配合 DataBinding 实现「双向绑定」


LiveData 配合 DataBinding 可以实现 更新数据自动驱动 UI 变化,如果使用「双向绑定」还能实现 UI 变化影响数据的变化。




以下也是 LiveData 的特性,但我不会将其归类为「设计缺陷」或「LiveData 的缺点」。作为开发者应了解这些特性并在使用过程中正确处理它们。



  • value 是 nullable 的

  • 在 fragment 订阅时需要传入正确的 lifecycleOwner

  • LiveData 持有的数据是「事件」时,可能会遇到「粘性事件

  • LiveData 是不防抖的

  • LiveDatatransformation 工作在主线程


value 是 nullable 的




点击查看代码
@Nullable
public T getValue() {
Object data = mData;
if (data != NOT_SET) {
return (T) data;
}
return null;
}
复制代码


LiveData#getValue() 是可空的,使用时应该注意判空。


使用正确的 lifecycleOwner


fragment 调用 LiveData#observe() 方法时传入 thisviewLifecycleOwner 是不一样的。


原因之前写过,此处不再赘述。感兴趣的小伙伴可以移步查看


AS 在 lint 检查时会避免开发者犯此类错误。



粘性事件


官方在 [译] 在 SnackBar,Navigation 和其他事件中使用 LiveData(SingleLiveEvent 案例) 一文中描述了一种「数据只会消费一次」的场景。如展示 Snackbar,页面跳转事件或弹出 Dialog。


由于 LiveData 会在观察者活跃时将最新的数据通知给观察者,则会产生「粘性事件」的情况。


如点击 button 弹出一个 Snackbar,在屏幕旋转时,lifecycleOwner 重建,新的观察者会再次调用 Livedata#observe(),因此 Snackbar 会再次弹出。


解决办法是:将事件作为状态的一部分,在事件被消费后,不再通知观察者。这里推荐两种解决方案:



默认不防抖


setValue()/postValue() 传入相同的值多次调用,观察者的 onChanged() 会被多次调用。


严格讲这不算一个问题,看具体的业务场景,处理也很容易,调用 setValue()/postValue() 前判断一下 vlaue 与之前是否相同即可。




点击查看代码
class MainViewModel {
private val _username = MutableLiveData<String>()
val username: LiveData<String> = _username

fun setUsername(username: String) {
if (_username.value != username)
_headerText.postValue(username)
}
}
复制代码


transformation 工作在主线程


有些时候我们从 repository 层拿到的数据需要进行处理,例如从数据库获得 User List,我们想根据 id 获取某个 User。


此时我们可以借助 MediatorLiveDataTransformatoins 来实现:





点击查看代码
class MainViewModel {
val viewModelResult = Transformations.map(repository.getDataForUser()) { data ->
convertDataToMainUIModel(data)
}
}
复制代码


mapswitchMap 内部均是使用 MediatorLiveData#addSource() 方法实现的,而该方法会在主线程调用,使用不当会有性能问题。




点击查看代码
@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}
复制代码


我们可以借助 Kotlin 协程和 RxJava 实现异步任务,最后在主线程上返回 LiveData。如 androidx.lifecycle:lifecycle-livedata-ktx 提供了这样的写法




点击查看代码
val result: LiveData<Result> = liveData {
val data = someSuspendingFunction() // 协程中处理
emit(data)
}
复制代码


LiveData 小结




  • LiveData 作为一个 可感知生命周期的,可观察的,数据持有者,被设计用来更新 UI




  • LiveData 很轻,功能十分克制,克制到需要配合 ViewModel 使用才能显示其价值




  • 由于 LiveData 专注单一功能,因此它的一些方法使用上是有局限性的,即通过设计来强制开发者按正确的方式编码(如观察者仅在主线程回调,避免了开发者在子线程更新 UI 的错误操作)




  • 由于 LiveData 专注单一功能,如果想在表现层之外使用它,MediatorLiveData 的操作数据的能力有限,仅有的 mapswitchMap 发生在主线程。可以在 switchMap 中使用协程或 RxJava 处理异步任务,最后在主线程返回 LiveData。如果项目中使用了 RxJavaAutoDispose,甚至可以不使用 LiveData,关于 Kotlin 协程的 Flow,我们后文介绍。




  • 笔者不喜欢将 LiveData 改造成 bus 使用,让组件做其分内的事(此条属于个人观点)




Flow


Flow 是 Kotlin 语言提供的功能,属于 Kotlin 协程的一部分,仅 Kotlin 使用。


Kotlin 协程被用来处理异步任务,而 Flow 则是处理异步数据流。


那么 suspend 方法和 Flow 的区别是什么?各自的使用场景是哪些?


一次性调用(One-shot Call)与数据流(data stream)



假如我们的 app 的某一屏里显示以下元素,其中红框部分实时性不高,不必很频繁的刷新,转发和点赞属于实时性很高的数据,需要定时刷新。



对于实时性不高的数据,我们可以使用 Kotlin 协程处理(此处数据的请求是异步任务):


suspend fun loadData(): Data

uiScope.launch {
val data = loadData()
updateUI(data)
}

而对于实时性较高的数据,挂起函数就无能为力了。有的小伙伴可能会说:「返回个 List 不就行了嘛」。其实无论返回什么类型,这种操作都是 One-shot Call,一次性的请求,有了结果就结束。


示例中的点赞和转发,需要一个 数据是异步计算的,能够 按顺序 提供 多个值 的结构,在 Kotlin 协程中我们有 Flow。


fun dataStream(): Flow<Data>

uiScope.launch {
dataStream().collect { data ->
updateUI(data)
}
}


当点赞或转发数发生变化时,updateUI() 会被执行,UI 根据最新的数据更新



Flow 的三驾马车


FLow 中有三个重要的概念:



  • 生产者(Producer)

  • 消费者(Consumer)

  • 中介(Intermediaries)


生产者提供数据流中的数据,得益于 Kotlin 协程,Flow 可以 异步地生产数据


消费者消费数据流内的数据,上面的示例中,updateUI() 方法是消费者。


中介可以对数据流中的数据进行更改,甚至可以更改数据流本身,我们可以借助官方视频中的动画来理解:



在 Android 中,数据层的 DataSource/Repository 是 UI 数据的生产者;而 view/ViewModel 是消费者;换一个角度,在表现层中,view 是用户输入事件的生产者(例如按钮的点击),其它层是消费者。


「冷流」与「热流」


你可能见过这样的描述:「流是冷的」



简单来说,冷流指数据流只有在有消费者消费时才会生产数据。


val dataFlow = flow {
// 代码块只有在有消费者 collect 后才会被调用
val data = dataSource.fetchData()
emit(data)
}

...

dataFlow.collect { ... }

有一种特殊的 Flow,如 StateFlow/SharedFlow ,它们是热流。这些流可以在没有活跃消费者的情况下存活,换句话说,数据在流之外生成然后传递到流。



BroadcastChannel 未来会在 Kotlin 1.6.0 中弃用,在 Kotlin 1.7.0 中删除。它的替代者是 StateFlowSharedFlow



StateFlow


StateFlow 也提供「可读可写」和「仅可读」两个版本。


SateFlow 实现了 SharedFlowMutableStateFlow 实现 MutableSharedFlow



StateFlowLiveData 十分像,或者说它们的定位类似。


StateFlowLiveData 有一些相同点:




  • 提供「可读可写」和「仅可读」两个版本(StateFlowMutableStateFlow




  • 它的值是唯一的




  • 它允许被多个观察者共用 (因此是共享的数据流)




  • 它永远只会把最新的值重现给订阅者,这与活跃观察者的数量是无关的




  • 支持 DataBinding




它们也有些不同点:



  • 必须配置初始值

  • value 空安全

  • 防抖


MutableStateFlow 构造方法强制赋值一个非空的数据,而且 value 也是非空的。这意味着 StateFlow 永远有值




StateFlow 的 emit()tryEmit() 方法内部实现是一样的,都是调用 setValue()



StateFlow 默认是防抖的,在更新数据时,会判断当前值与新值是否相同,如果相同则不更新数据。



SharedFlow


SateFlow 一样,SharedFlow 也有两个版本:SharedFlowMutableSharedFlow



那么它们有什么不同?



  • MutableSharedFlow 没有起始值

  • SharedFlow 可以保留历史数据

  • MutableSharedFlow 发射值需要调用 emit()/tryEmit() 方法,没有 setValue() 方法



MutableSharedFlow 不同,MutableSharedFlow 构造器中是不能传入默认值的,这意味着 MutableSharedFlow 没有默认值。


val mySharedFlow = MutableSharedFlow<Int>()
val myStateFlow = MutableStateFlow<Int>(0)
...
mySharedFlow.emit(1)
myStateFlow.emit(1)

SateFlowSharedFlow 还有一个区别是 SateFlow 只保留最新值,即新的订阅者只会获得最新的和之后的数据。


SharedFlow 根据配置可以保留历史数据,新的订阅者可以获取之前发射过的一系列数据。



后文会介绍背后的原理



它们被用来应对不同的场景:UI 数据是状态还是事件


状态(State)与事件(Event)


状态可以是的 UI 组件的可见性,它始终具有一个值(显示/隐藏)


而事件只有在满足一个或多个前提条件时才会触发,不需要也不应该有默认值


为了更好地理解 SateFlowSharedFlow 的使用场景,我们来看下面的示例:



  1. 用户点击登录按钮

  2. 调用服务端验证登录合法性

  3. 登录成功后跳转首页


我们先将步骤 3 视为 状态 来处理:



使用状态管理还有与 LiveData 一样的「粘性事件」问题,如果在 ViewNavigationState 中我们的操作是弹出 snackbar,而且已经弹出一次。在旋转屏幕后,snackbar 会再次弹出。



如果我们将步骤 3 作为 事件 处理:



使用 SharedFlow 不会有「粘性事件」的问题,MutableSharedFlow 构造函数里有一个 replay 的参数,它代表着可以对新订阅者重新发送多个之前已发出的值,默认值为 0。



SharedFlow 在其 replayCache 中保留特定数量的最新值。每个新订阅者首先从 replayCache 中取值,然后获取新发射的值。replayCache 的最大容量是在创建 SharedFlow 时通过 replay 参数指定的。replayCache 可以使用 MutableSharedFlow.resetReplayCache 方法重置。


replay 为 0 时,replayCache size 为 0,新的订阅者获取不到之前的数据,因此不存在「粘性事件」的问题。


StateFlowreplayCache 始终有当前最新的数据:



至此, StateFlowSharedFlow 的使用场景就很清晰了:


状态(State)用 StateFlow ;事件(Event)用 SharedFlow  


StateFlow,SharedFlow 与 LiveData 的使用对比


LiveData StateFlow SharedFlow 在 ViewModel 中的使用



上图分别展示了 LiveDataStateFlowSharedFlowViewModel 中的使用。


其中 LiveDataViewModel 中使用 LiveEventLiveData 处理「粘性事件


FlowViewModel 中使用 SharedFlow 处理「粘性事件


emit() 方法是挂起函数,也可以使用 tryEmit()



LiveData StateFlow SharedFlow 在 Fragment 中的使用



注意:Flow 的 collect 方法不能写在同一个 lifecycleScope


flowWithLifecyclelifecycle-runtime-ktx:2.4.0-alpha01 后提供的扩展方法



Flow 在 fragment 中的使用要比 LiveData 繁琐很多,我们可以封装一个扩展方法来简化:



关于 repeatOnLifecycle 的设计问题,可以移步 设计 repeatOnLifecycle API 背后的故事


使用 collect 方法时要注意一个问题。



这种写法是错误的!


viewModel.headerText.collect 在协程被取消前会一直挂起,这样后面的代码便不会执行。


Flow 与 RxJava


FlowRxJava 的定位很接近,限于篇幅原因,此处不展开讲,本节只罗列一下它们的对应关系:




  • Flow = (cold) Flowable / Observable / Single




  • Channel = Subjects




  • StateFlow = BehaviorSubjects (永远有值)




  • SharedFlow = PublishSubjects (无初始值)




  • suspend function = Single / Maybe / Completable




参考文档与推荐资源



总结




  • LiveData 的主要职责是更新 UI,要充分了解其特性,合理使用




  • Flow 可分为生产者,消费者,中介三个角色




  • 冷流和热流最大的区别是前者依赖消费者 collect 存在,而热流一直存在,直到被取消




  • StateFlowLiveData 定位相似,前者必须配置初始值,value 空安全并且默认防抖




  • StateFlowSharedFlow 的使用场景不同,前者适用于「状态」,后者适用于「事件」




回到文章开头的话题,LiveData 并没有那么不堪,由于其作用单一,功能简单,简单便意味着不易出错。所以在表现层中ViewModel 向 view 暴露 LiveData 是一个不错的选择。而在 RepositoryDataSource 中,我们可以利用 LiveData + 协程来处理数据的转换。当然,我们也可以使用功能更强大的 Flow


LiveDataStateFLowSharedFlow,它们都有着各自的使用场景。并且如果使用不当,都会或多或少地遇到一些所谓的「坑」。因此在使用某个组件时,要充分了解其设计缘由以及相关特性,否则就会掉进陷阱,收到不符合预期的行为。


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

android 事件分发机制

Android 事件分发机制解析1. view的事件分发机制view的事件分发是从 dispatchTouchEvent() 开始的,直接上代码;public boolean dispatchTouchEvent(MotionEvent event) { ...
继续阅读 »

Android 事件分发机制解析

1. view的事件分发机制

view的事件分发是从 dispatchTouchEvent() 开始的,直接上代码;

public boolean dispatchTouchEvent(MotionEvent event) {  
boolean result = false;
// 1. view 是否可以点击 && setOnTouchListener 有值 并且 setOnTouchListener 返回值是true 事件分发 结束

if ( (mViewFlags & ENABLED_MASK) == ENABLED &&
mOnTouchListener != null &&
mOnTouchListener.onTouch(this, event)) {
boolean result = true;

}
// 2.如果上述条件不都成立 执行 OnTouchEvent();
if (!result && onTouchEvent(event)) {
result = true;
}


return result;
}

/**
* 分析1:onTouchEvent()
*/
public boolean onTouchEvent(MotionEvent event) {



// 若该控件可点击,则进入switch判断中
if (((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)) {

// 根据当前事件类型进行判断处理
switch (event.getAction()) {

// a. 事件类型=抬起View(主要分析)
case MotionEvent.ACTION_UP:
performClick();
// ->>分析2
break;

// b. 事件类型=按下View
case MotionEvent.ACTION_DOWN:
postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
break;

// c. 事件类型=结束事件
case MotionEvent.ACTION_CANCEL:
refreshDrawableState();
removeTapCallback();
break;

// d. 事件类型=滑动View
case MotionEvent.ACTION_MOVE:
final int x = (int) event.getX();
final int y = (int) event.getY();

int slop = mTouchSlop;
if ((x < 0 - slop) || (x >= getWidth() + slop) ||
(y < 0 - slop) || (y >= getHeight() + slop)) {
removeTapCallback();
if ((mPrivateFlags & PRESSED) != 0) {
removeLongPressCallback();
mPrivateFlags &= ~PRESSED;
refreshDrawableState();
}
}
break;
}

// 若该控件可点击,就一定返回true
return true;
}
// 若该控件不可点击,就一定返回false
return false;
}

**
* 分析2:performClick()
*/
public boolean performClick() {

if (mOnClickListener != null) {
// 只要通过setOnClickListener()为控件View注册1个点击事件
// 那么就会给mOnClickListener变量赋值(即不为空)
// 则会往下回调onClick() & performClick()返回true
playSoundEffect(SoundEffectConstants.CLICK);
mOnClickListener.onClick(this);
return true;
}
return false;
}

总结:如果你在执行ACTION_DOWN的时候返回了false,后面一系列其它的action就不会再得到执行了。简单的说,就是当dispatchTouchEvent在进行事件分发的时候,只有前一个action返回true,才会触发后一个action。

image.png

2.ViewGroup 事件分发机制

viewGroup事件分发可以分为以下阶段:1.点击事件是down,将mFirstTarget、其他的标记、状态值清空,2.检查当前事件是否被拦截,3.如果被拦截,当前的事件不会分发给子view(firstTarget为空),会交由viewGroup父类的dispatchTouchEvent处理;4.如果不拦截,会找到一个满足条件的子view,分发此次的down事件;5.如果找不到满足条件的子view,firstTouch=null,就会调用自身的dispatchTouchEvent;6.如何当前点击事件是move、up时;6.如果找到了符合条件的子view,把down事件分发给子view,并对firstTouchTarget赋值,down事件分发结束;7.接来下就是move、up事件的分发,如果down事件分发给子view了,会再次判断是否拦截;8.如果不拦截,就会把move、up分发给mFirstTouchTarget对应的子view;9.如果拦截,会分发一个cancel事件给firstTouchTarget对应的子view。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

// 1.将firstTouchTarget置空,其他状态清空
if (actionMasked == MotionEvent.ACTION_DOWN) {
cancelAndClearTouchTargets(ev);
resetTouchState();
}

final boolean intercepted;


/***
* 2.检查是否拦截事件,如果点击事件是down、或者事件已经分发给子view,通过viewGroup的
* onInterceptTouchEvent 判断
*
*/

if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
intercepted = true;
}


TouchTarget newTouchTarget = null;
boolean alreadyDispatchedToNewTouchTarget = false;
// 3. 如果事件没有被拦截,会需找一个满足条件的子view分发事件
if (!canceled && !intercepted) {
if (actionMasked == MotionEvent.ACTION_DOWN
|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {


if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// Child wants to receive touch within its bounds.
mLastTouchDownTime = ev.getDownTime();
if (preorderedList != null) {
// childIndex points into presorted list, find original index
for (int j = 0; j < childrenCount; j++) {
if (children[childIndex] == mChildren[j]) {
mLastTouchDownIndex = j;
break;
}
}
} else {
mLastTouchDownIndex = childIndex;
}
mLastTouchDownX = ev.getX();
mLastTouchDownY = ev.getY();
/***
*4. 如果有子view可以分发当前的事件,对newTouchTarget,firstTouchTarget赋值,记
* 消费本次事件的view
*/
newTouchTarget = addTouchTarget(child, idBitsToAssign);
alreadyDispatchedToNewTouchTarget = true;
break;
}
}
}

if (mFirstTouchTarget == null) {
// 5. 事件交由viewGroup父类的dispatchTouchEvent 处理
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,
TouchTarget.ALL_POINTER_IDS);
} else {
// Dispatch to touch targets, excluding the new touch target if we already
// dispatched to it. Cancel touch targets if necessary.
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
// 6.找到符合条件的子view,该事件分发结束
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)
|| intercepted;
//
if (dispatchTransformedTouchEvent(ev, cancelChild,
target.child, target.pointerIdBits)) {
handled = true;
}

if (cancelChild) { // 如果拦截了事件,清空 firstTouchTarget
if (predecessor == null) {
mFirstTouchTarget = next;
} else {
predecessor.next = next;
}
target.recycle();
target = next;
continue;
}
}
predecessor = target;
target = next;
}
}

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


final int oldAction = event.getAction();
// 如果是子view消费了viewGroup分发的事件,后续事件被viewGroup拦截,viewGroup会发送一
cancel事件给firstTouchTarget对应的子view,该事件结束。下一个事件就不会再分发给子view了。

if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
event.setAction(MotionEvent.ACTION_CANCEL);
if (child == null) {
handled = super.dispatchTouchEvent(event);
} else {
handled = child.dispatchTouchEvent(event);
}
event.setAction(oldAction);
return handled;
}


final int oldPointerIdBits = event.getPointerIdBits();
final int newPointerIdBits = oldPointerIdBits & desiredPointerIdBits;


if (newPointerIdBits == 0) {
return false;
}

if (child == null) {
handled = super.dispatchTouchEvent(transformedEvent);
} else {
final float offsetX = mScrollX - child.mLeft;
final float offsetY = mScrollY - child.mTop;
transformedEvent.offsetLocation(offsetX, offsetY);
if (! child.hasIdentityMatrix()) {
transformedEvent.transform(child.getInverseMatrix());
}

handled = child.dispatchTouchEvent(transformedEvent);
}
transformedEvent.recycle();
return handled;
}

3.滑动冲突

3.1 滑动冲突场景

方向一致:父容器和子view的滑动方向一致,如:scrollView 嵌套一个recyclewView 方向不一致:父容器和子view的滑动方向不一致,如scrollView 嵌套一个 viewPage。

3.2 外部拦截法

子view需要处理事件时,在父容器里面通过onInterceptTouchEvent返回值为false,让事件交由子view处理;当父容器需要处理事件时,让onInterceptTouchEvent返回值未true,让父容器拦截子view的事件,自己处理事件。伪代码如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercepted=false;
int x= (int) event.getX();
int y= (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted=false;//必须不能拦截,否则后续的ACTION_MOME和ACTION_UP事件都会拦截。
break;
case MotionEvent.ACTION_MOVE:
if (父容器需要当前点击事件){
intercepted=true;
}else {
intercepted=false;
}
break;
case MotionEvent.ACTION_UP:
intercepted=false;
break;

default:
break;
}
mLastXIntercept=x;
mLastXIntercept=y;
return intercepted;
}

3.3 内部拦截法

if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}

内部拦截法通过子view(requestDisallowInterceptTouchEvent(disallowIntercept=false),disallowIntercept=false)改变viewGroup的disallowIntercept值,来干预viewGroup是否拦截子view。从上面代码,我们可以知道:disallowIntercept只能控制让viewGroup不拦截子view,拦截子view是通过viewGroup的 onInterceptTouchEvent方法值控制的。所以内部拦截法,就是结合viewGroup的 onInterceptTouchEvent方法和view通过viewgroup.requestDisallowInterceptTouchEvent改变 disallowIntercept值共同来完成。

// 重写 viewGroup  onInterceptTouchEvent方法,down返回值不能为false
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if (action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}

//  重写子view的 dispatchTouchEvent事件 
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN: {
getParent().requestDisallowInterceptTouchEvent(true);
break;
}
case MotionEvent.ACTION_MOVE: {
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
//如果是左右滑动
if (父容器) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
case MotionEvent.ACTION_UP: {
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
}
mLastXIntercept = x;
mLastYIntercept = y;
return super.dispatchTouchEvent(ev);
}


收起阅读 »

自定义可点击可滑动的通用RatingBar

介绍一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View。效果图预览实现自定义属性<declare-styleable name="CommonRatingBar"&g...
继续阅读 »

介绍

一个可以设置间距,设置选中未选中图标及数量,选中图标的类型(整,半,任意),可点击,可滑动选择的类似原生RatingBar的自定义View。

效果图预览

untitled.gif

实现

自定义属性

<declare-styleable name="CommonRatingBar">
<attr name="starCount" format="integer" />
<attr name="starPadding" format="dimension" />
<!-- 默认选中时的图标,可不设置,使用纯色starColor -->
<attr name="starDrawable" format="reference" />
<!-- 默认未选中时的图标 -->
<attr name="starBgDrawable" format="reference" />
<!-- 纯色样式 -->
<attr name="starColor" format="color" />
<attr name="starClickable" format="boolean" />
<attr name="starScrollable" format="boolean" />
<attr name="starType" format="enum">
<enum name="normal" value="0" />
<enum name="half" value="1" />
<enum name="whole" value="2" />
</attr>
</declare-styleable>

测量View

将控件的高度设置为测量高度,测量宽度为星星的数量+每个星星之间的padding

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
starSize = measuredHeight
setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}

绘制ratingbar

  1. 绘制未选中的背景
/**
* 未选中Bitmap
*/

private val starBgBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 绘制星星默认未选中背景
*/

private fun drawStar(canvas: Canvas) {
for (i in 0 until starCount) {
val starLeft = i * (starSize + starPadding)
canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
}
}
  1. 绘制选中图标

这里bitmap宽度使用starSize + starPadding,配合BitmapShader的repeat模式,可以方便绘制出高亮的图标

/**
* 选中icon的Bitmap
*/

private val starBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 绘制高亮图标
*/

private fun drawStarDrawable(canvas: Canvas) {
starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}
  1. 绘制纯色的选中效果

使用离屏缓冲,纯色矩形与未选中背景相交的地方进行显示。具体使用可以参考扔物线大佬的文章

/**
* 星星纯色画笔
*/

private val starPaint = Paint().apply {
isAntiAlias = true
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

canvas?.let {
// xfermode需要使用离屏缓存
val saved = it.saveLayer(null, null)
drawStar(it)
if (starDrawable == -1) {
drawStarBgColor(it)
} else {
drawStarDrawable(it)
}
it.restoreToCount(saved)
}

/**
* 绘制高亮纯颜色
*/

private fun drawStarBgColor(canvas: Canvas) {
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starPaint)
}
  1. 绘制进度

根据type更正显示效果,是取半,取整还是任意取进度。open方法,可以方便修改

/**
* 获取星星绘制宽度
*/

private fun getStarProgressWidth(): Float {
val percent = progress / 100f
val starDrawCount = percent * starCount
return when (starType) {
StarType.HALF.ordinal -> {
ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
StarType.WHOLE.ordinal -> {
ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
else -> {
starDrawCount * starSize + starDrawCount.toInt() * starPadding
}
}
}

/**
* 取整规则
*/

private fun ceilWhole(x: Float): Float {
return ceil(x)
}

/**
* 取半规则
*/

private fun ceilHalf(x: Float): Float {
// 四舍五入 1.3->1+0.5->1.5 1.7->2
val round = round(x)
return when {
round < x -> round + 0.5f
round > x -> round
else -> x
}
}
  1. 点击+滑动

点击+滑动就是重写onTouchEvent事件:

  • 判断点击位置是否在范围内
/**
* 点击的point是否在view范围内
*/

private fun pointInView(x: Float, y: Float): Boolean {
return Rect(0, 0, width, height).contains(x.toInt(), y.toInt())
}
  • 记录按下位置,抬起位置。
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_UP -> {
if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onClickProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
  • 滑动记录手指move
MotionEvent.ACTION_MOVE -> {
if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onScrollProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
  1. 添加监听

添加OnCommonRatingBarListener,监听点击事件以及滑动事件,返回进度

click.gif

完整实现代码

class CommonRatingBar(context: Context, attrs: AttributeSet?) : View(context, attrs) {

/**
* 星星数量
*/

private var starCount = 5

/**
* 星星间隔
*/

private var starPadding = 0f

/**
* 星星大小
*/

private var starSize = 30

/**
* 星星选中背景图
*/

private var starDrawable: Int = -1

/**
* 星星未选中背景图
*/

private var starBgDrawable: Int = -1

/**
* 星星选择类型
*/

private var starType = StarType.NORMAL.ordinal

/**
* 星星颜色
*/

private var starColor: Int = Color.parseColor("#F7B500")

/**
* 星星可点击
*/

private var starClickable = false

/**
* 星星可滑动选择
*/

private var starScrollable = false

/**
* 星星未选中画笔
*/

private val starBgPaint = Paint().apply {
isAntiAlias = true
}

/**
* 星星选中画笔
*/

private val starDrawablePaint = Paint().apply {
isAntiAlias = true
}

/**
* 星星纯色画笔
*/

private val starPaint = Paint().apply {
isAntiAlias = true
xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
}

private var progress = 0

/**
* 选中icon的Bitmap
*/

private val starBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize + starPadding.toInt(), starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

/**
* 未选中Bitmap
*/

private val starBgBitmap: Bitmap by lazy {
val bitmap = Bitmap.createBitmap(starSize, starSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val starDrawable = ContextCompat.getDrawable(context, starBgDrawable)
starDrawable?.setBounds(0, 0, starSize, starSize)
starDrawable?.draw(canvas)
bitmap
}

init {
initView(context, attrs)
starPaint.color = starColor
}

private fun initView(context: Context, attrs: AttributeSet?) {
val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.CommonRatingBar)
starCount = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starCount, 5)
starPadding = obtainStyledAttributes.getDimension(R.styleable.CommonRatingBar_starPadding, 10f)
starDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starDrawable, -1)
starBgDrawable = obtainStyledAttributes.getResourceId(R.styleable.CommonRatingBar_starBgDrawable, -1)
starType = obtainStyledAttributes.getInt(R.styleable.CommonRatingBar_starType, StarType.NORMAL.ordinal)
starColor = obtainStyledAttributes.getColor(R.styleable.CommonRatingBar_starColor, Color.parseColor("#F7B500"))
starClickable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starClickable, false)
starScrollable = obtainStyledAttributes.getBoolean(R.styleable.CommonRatingBar_starScrollable, false)
obtainStyledAttributes.recycle()
}

override fun dispatchTouchEvent(event: MotionEvent?): Boolean {
// super.dispatchTouchEvent(event) -> 当前view的onTouchEvent
// false -> viewGroup的onTouchEvent
return if (starClickable || starScrollable) super.dispatchTouchEvent(event)
else false
}

/**
* 最小触摸范围
*/

private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
private var downX = 0f
private var downY = 0f

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
}
MotionEvent.ACTION_MOVE -> {
if (starScrollable && abs(event.x - downX) - abs(event.y - downY) >= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onScrollProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
MotionEvent.ACTION_UP -> {
if (starClickable && abs(event.y - downY) <= touchSlop && abs(event.x - downX) <= touchSlop && pointInView(event.x, event.y)) {
parent.requestDisallowInterceptTouchEvent(true)
val progress = (event.x / width * 100).toInt()
setProgress(progress)
listener?.onClickProgress(progress)
} else {
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return true
}

/**
* 点击的point是否在view范围内
*/

private fun pointInView(x: Float, y: Float): Boolean {
return Rect(0, 0, width, height).contains(x.toInt(), y.toInt())
}

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
starSize = measuredHeight
setMeasuredDimension(starCount * starSize + (starCount - 1) * starPadding.toInt(), starSize)
}

override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
if (starBgDrawable == -1) {
return
}
canvas?.let {
// xfermode需要使用离屏缓存
val saved = it.saveLayer(null, null)
drawStar(it)
if (starDrawable == -1) {
drawStarBgColor(it)
} else {
drawStarDrawable(it)
}
it.restoreToCount(saved)
}
}

/**
* 绘制星星默认未选中背景
*/

private fun drawStar(canvas: Canvas) {
for (i in 0 until starCount) {
val starLeft = i * (starSize + starPadding)
canvas.drawBitmap(starBgBitmap, starLeft, 0f, starBgPaint)
}
}

/**
* 绘制高亮纯颜色
*/

private fun drawStarBgColor(canvas: Canvas) {
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starPaint)
}

/**
* 绘制高亮图标
*/

private fun drawStarDrawable(canvas: Canvas) {
starDrawablePaint.shader = BitmapShader(starBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
canvas.drawRect(0f, 0f, getStarProgressWidth(), height.toFloat(), starDrawablePaint)
}

/**
* 获取星星绘制宽度
*/

private fun getStarProgressWidth(): Float {
val percent = progress / 100f
val starDrawCount = percent * starCount
return when (starType) {
StarType.HALF.ordinal -> {
ceilHalf(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
StarType.WHOLE.ordinal -> {
ceilWhole(starDrawCount) * starSize + starDrawCount.toInt() * starPadding
}
else -> {
starDrawCount * starSize + starDrawCount.toInt() * starPadding
}
}
}

private fun ceilWhole(x: Float): Float {
return ceil(x)
}

private fun ceilHalf(x: Float): Float {
// 四舍五入 1.3->1+0.5->1.5 1.7->2
val round = round(x)
return when {
round < x -> round + 0.5f
round > x -> round
else -> x
}
}

/**
* 星星的绘制进度
*/

fun setProgress(progress: Int) {
var p = progress
if (p < 0) p = 0
if (p > 100) p = 100
this.progress = p
postInvalidate()
}

fun setProgress(currentValue: Float, totalValue: Float) {
setProgress((currentValue * 100 / totalValue).toInt())
}

fun setOnCommonRatingBarListener(listener: OnCommonRatingBarListener) {
this.listener = listener
}

private var listener: OnCommonRatingBarListener? = null

interface OnCommonRatingBarListener {
fun onClickProgress(progress: Int)
fun onScrollProgress(progress: Int)
}

enum class StarType {
NORMAL, HALF, WHOLE
}

}

image.png

拓展

  • 修改纯色方法配合LinearGradient,可以有渐变的选中效果
收起阅读 »

Android 控制 ContentProvider的创建

序言随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentPr...
继续阅读 »

序言

随着app隐私政策的收紧,现在不经过用户同意,就收集敏感信息的行为一旦被检测出来。很容易造成app下架。但是有些SDK的初始化是通过注册ContentProvider实现自动调用其onCreate()方法,来实现无感初始化的。如果SDK在ContentProvider中获取了敏感信息,又没有提供控制方法。我们就很被动。于是我花了点时间研究了怎么hook contentProvider的创建。让其在用户同意后再初始化。

方案1

声明在清单文件中的ContentProvider 会在应用启动后就创建。具体是在 ActivityThread的handleBindApplication方法中。(以下截图为Android 30的ActivityThread) 在这里插入图片描述 具体就在这一句 在这里插入图片描述

installContentProviders实现如下 在这里插入图片描述 最终是通过AppComponentFactory的instantiateProvider方法创建。 在这里插入图片描述 在这里插入图片描述

而AppComponentFactory是Android 28以后系统提供给我们的一个hook的工厂类。可以通过清单文件指定,在这里面可以hook 所有组件的初始化。 在这里插入图片描述 这么指定 在这里插入图片描述

但是在Android 28以下,比如这个截图是Android 25.没有这类,ContentProvider直接通过反射获得。无法通过该类来修改。 在这里插入图片描述

最终方案

为了兼容性,考虑如下方案。在调用installContentProviders前,如果这个data里面的providers为空岂不是不会走installContentProviders方法了吗。 在这里插入图片描述 这个data 是一个AppBindData类型,通过handleBindApplication方法的参数传入。会保存到ActivityThread的 mBoundApplication 字段中。 在这里插入图片描述

于是就可以通过获取这个mBoundApplication 字段中的providers 来保存要初始化的provider。再讲providers置为空即可。到了用户同意以后,再去通过反射调用ActivityThread的installContentProviders方法即可。 在这里插入图片描述

hook时机

这个时机只有Application的attachBaseContext方法中。该方法会比installContentProviders提前执行。

最后的代码App中

public class MyApp extends Application {

static MyApp app;

/**
*用户同意
*/

public static void agree(Action action) {
HookUtil.initProvider(app);
action.doAction();
}

public interface Action {
void doAction();
}


@Override
protected void attachBaseContext(Context base) {
app = this;
try {
HookUtil.attachContext();
} catch (Exception e) {
e.printStackTrace();
}
super.attachBaseContext(base);
}
}

HookUtil

package com.zgh.testcontentprovider;

import android.content.Context;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;


/**
* Created by zhuguohui
* Date: 2021/9/13
* Time: 11:23
* Desc:
*/

public class HookUtil {

private static Object providers;

private static Method installContentProvidersMethod;
private static Object currentActivityThread;

/*
*用户同意后调用
*/

public static void initProvider(Context context){
try {
installContentProvidersMethod.invoke(currentActivityThread,context,providers);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void attachContext() throws Exception {

// 先获取到当前的ActivityThread对象
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//currentActivityThread是一个static函数所以可以直接invoke,不需要带实例参数
currentActivityThread = currentActivityThreadMethod.invoke(null);

hookInstallContentProvider(activityThreadClass);

}

private static void hookInstallContentProvider(Class activityThreadClass) throws Exception{
Field appDataField = activityThreadClass.getDeclaredField("mBoundApplication");
appDataField.setAccessible(true);
Object appData= appDataField.get(currentActivityThread);
Field providersField= appData.getClass().getDeclaredField("providers");
providersField.setAccessible(true);
providers = providersField.get(appData);
//清空provider,避免有些sdk通过provider来初始化
providersField.set(appData,null);

installContentProvidersMethod = activityThreadClass.getDeclaredMethod("installContentProviders", Context.class, List.class);
installContentProvidersMethod.setAccessible(true);
}
}


收起阅读 »

Jetpack Compose Banner即拿即用

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。 效果图 accompanist组库 accompanist 旨在为Jetpack Compose提供补充功能的组库,里面有非...
继续阅读 »

Jetpack Compose目前没有官方的Banner控件,所以只能自己写,搜了些资料才完成,非常感谢之前分享过这些内容的大佬们。


效果图


gif图.gif


accompanist组库


accompanist


旨在为Jetpack Compose提供补充功能的组库,里面有非常多很好用的实验性功能,之前用过的加载网络图片的rememberImagePainter就是其中之一,而做Banner的话需要用到的是其中的Pager库。


//导入依赖 
implementation "com.google.accompanist:accompanist-pager:$accompanist_pager"

这里我用的是0.16.1,因为其他库也是这个版本,目前最新是0.18.0


关键代码


1、rememberPagerState

用于记录分页状态的变量,一共有5个参数,我们用到了4个,还有一个是initialPageOffset,可以设置偏移量


val pagerState = rememberPagerState(
//总页数
pageCount = list.size,
//预加载的个数
initialOffscreenLimit = 1,
//是否无限循环
infiniteLoop = true,
//初始页面
initialPage = 0
)

2、HorizontalPager

用于创建一个可以横向滑动的分页布局,把上面的rememberPagerState传进去,其他也没啥


HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize(),
) { page ->
Image(
painter = rememberImagePainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

3、让HorizontalPager自己动起来

这里有两个方法可以让HorizontalPager动起来,一个是animateScrollToPage,另一个是scrollToPage,从名字上都可以看出来带animate的是有动画效果的方法,也正是我想要的东西。


//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
pagerState.animateScrollToPage((pagerState.currentPage + 1) % pagerState.pageCount)
}
}

在控件里添加这行代码就可以让控件自动起来了


但这是一段看起来没问题的代码


假设页面总数pagerState.pageCount为2,当((pagerState.currentPage + 1) % pagerState.pageCount) == 0时跳转到第1个页面,但最后的效果是这样的


gif图2.gif
轮播图往左滑了,而且还出现了轮播图中间页面的画面,页面有点闪烁的感觉。


修改后

//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是pagerState的infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}

只修改了animateScrollToPage参数的值,看到这里可能有人会问:pagerState.currentPage + 1不会报错吗?


确实不会!


因为当rememberPagerState中的infiniteLoop(无限循环)参数设置为true时最大页码其实为Int.MAX_VALUE,而currentPage只是当前页面的索引,并不是真实的页码。


也就是说,当Banner有4个页面,这里传个5的时候,并不会报错,而且animateScrollToPage会自动将这个"5"转换为页面索引,以保证下次使用currentPage不会出错。(菜鸟,我!啊吧啊吧看了好一阵子源码没看到这个是哪里转的)


不过有些地方值得注意:



调用pagerState.animateScrollToPage(target)的时候



  • 当target > pageCount 或 target > currentPage的时候,控件向右滑动

  • 当target < pageCount 且 target < currentPage的时候,控件向左滑动

  • 另外如果currentPage和target当两者相差页面大于4的时候只会在动画中显示(currentPage、currentPage + 1、target - 1、target)四个页面



以此类推,如果改为-1的话就是不断往左自动滑动啦


pagerState.animateScrollToPage(pagerState.currentPage - 1)

Banner中定义了几个参数,indicatorAlignment可以设置指示点的位置,默认为底部居中


/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.mipmap.ic_web,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String) -> Unit = {}
)

Alignment.BottomStart

bannerLeft.png


Alignment.BottomEnd

bannerRight.png


发现了个奇怪的问题


//自动滚动
LaunchedEffect(pagerState.currentPage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage - 1)
}
}

这段代码里,由于ReCompose时机是因为pagerState.currentPage这个值产生变化的时候;当我们触摸着HorizontalPager这个控件期间,动画会挂起取消


所以当我们滑动但是不滑动到上一页或下一页,且在本次跳转页面动画触发后才松开手指的时候,就会导致自动滚动停止的问题发生。


像这样


gif图3.gif


问题解决


问题的解决思路也不复杂,只需要在手指按下时记录当前页面索引,手指抬起时判断当前页面索引是否有所改变,如果没有改变的话就手动触发动画。


PointerInput Modifier


这是用于处理手势操作的Modifier,它为我们提供了PointerInputScope作用域,在这个作用域中我们可以使用一些有关于手势的API。


例如:detectDragGestures


我们可以在detectDragGestures中拿到拖动开始/拖动时/拖动取消/拖动结束的回调,但其中的onDrag(拖动时触发回调)是必传的参数,这会导致HorizontalPager控件拖动手势失效。


suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit = { },
onDragEnd: () -> Unit = { },
onDragCancel: () -> Unit = { },
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)

所以最后使用了更基础的API - awaitPointerEvent,我们需要在awaitPointerEventScope方法为我们提供的AwaitPointerEventScope作用域内使用它。


HorizontalPager(
state = pagerState,
modifier = Modifier.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
...
)

另外,由于轮播图可以点击跳转到详情页面,所以还需要区分单击事件和滑动事件,需要用到pagerState.targetPage(当前页面是否有任何滚动/动画正在执行),如果没有的话就会返回null。


但只要用户拖动了Banner,松手的时候targetPage就不会为null。


//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}

gif图4.gif
解决!(gif图切换的时候卡了一下,真机上没问题)


即拿即用


给小林一个star


import androidx.annotation.DrawableRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.delay

/**
* 轮播图
* [timeMillis] 停留时间
* [loadImage] 加载中显示的布局
* [indicatorAlignment] 指示点的的位置,默认是轮播图下方的中间,带一点padding
* [onClick] 轮播图点击事件
*/
@ExperimentalCoilApi
@ExperimentalPagerApi
@Composable
fun Banner(
list: List<BannerData>?,
timeMillis: Long = 3000,
@DrawableRes loadImage: Int = R.mipmap.ic_web,
indicatorAlignment: Alignment = Alignment.BottomCenter,
onClick: (link: String) -> Unit = {}
) {

Box(
modifier = Modifier.background(MaterialTheme.colors.background).fillMaxWidth()
.height(220.dp)
) {

if (list == null) {
//加载中的图片
Image(
painterResource(loadImage),
modifier = Modifier.fillMaxSize(),
contentDescription = null,
contentScale = ContentScale.Crop
)
} else {
val pagerState = rememberPagerState(
//总页数
pageCount = list.size,
//预加载的个数
initialOffscreenLimit = 1,
//是否无限循环
infiniteLoop = true,
//初始页面
initialPage = 0
)

//监听动画执行
var executeChangePage by remember { mutableStateOf(false) }
var currentPageIndex = 0

//自动滚动
LaunchedEffect(pagerState.currentPage, executeChangePage) {
if (pagerState.pageCount > 0) {
delay(timeMillis)
//这里直接+1就可以循环,前提是infiniteLoop == true
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}

HorizontalPager(
state = pagerState,
modifier = Modifier.pointerInput(pagerState.currentPage) {
awaitPointerEventScope {
while (true) {
//PointerEventPass.Initial - 本控件优先处理手势,处理后再交给子组件
val event = awaitPointerEvent(PointerEventPass.Initial)
//获取到第一根按下的手指
val dragEvent = event.changes.firstOrNull()
when {
//当前移动手势是否已被消费
dragEvent!!.positionChangeConsumed() -> {
return@awaitPointerEventScope
}
//是否已经按下(忽略按下手势已消费标记)
dragEvent.changedToDownIgnoreConsumed() -> {
//记录下当前的页面索引值
currentPageIndex = pagerState.currentPage
}
//是否已经抬起(忽略按下手势已消费标记)
dragEvent.changedToUpIgnoreConsumed() -> {
//当页面没有任何滚动/动画的时候pagerState.targetPage为null,这个时候是单击事件
if (pagerState.targetPage == null) return@awaitPointerEventScope
//当pageCount大于1,且手指抬起时如果页面没有改变,就手动触发动画
if (currentPageIndex == pagerState.currentPage && pagerState.pageCount > 1) {
executeChangePage = !executeChangePage
}
}
}
}
}
}
.clickable(onClick = { onClick(list[pagerState.currentPage].linkUrl) })
.fillMaxSize(),
) { page ->
Image(
painter = rememberImagePainter(list[page].imageUrl),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null
)
}

Box(
modifier = Modifier.align(indicatorAlignment)
.padding(bottom = 6.dp, start = 6.dp, end = 6.dp)
) {

//指示点
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
for (i in list.indices) {
//大小
var size by remember { mutableStateOf(5.dp) }
size = if (pagerState.currentPage == i) 7.dp else 5.dp

//颜色
val color =
if (pagerState.currentPage == i) MaterialTheme.colors.primary else Color.Gray

Box(
modifier = Modifier.clip(CircleShape).background(color)
//当size改变的时候以动画的形式改变
.animateContentSize().size(size)
)
//指示点间的间隔
if (i != list.lastIndex) Spacer(
modifier = Modifier.height(0.dp).width(4.dp)
)
}
}

}
}

}

}

/**
* 轮播图数据
*/
data class BannerData(
val imageUrl: String,
val linkUrl: String
)

特别感谢


RugerMc 手势处理


apk下载链接


项目地址


欢迎Star~PlayAndroid


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

三步实现一个自定义任意路径的嫦娥奔月(Flutter版)

前言 可能不少人看到这个标题,心里想的是: 要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧 不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货 要是真这么想的话,我只能说: 下面给大家整个活...
继续阅读 »

前言


可能不少人看到这个标题,心里想的是:


要是被我发现你TM就是个标题党,三步完不成,信不信我堵在你家门口,见一次打一次,你给我去死吧


不就是个平移动画嘛,我上我也行,让我进去骂死这个水文货


要是真这么想的话,我只能说:



我看你是完全不懂哦
真拿你没办法

下面给大家整个活,为大家介绍一下我们“listView是万能的”教会的唯一真主和慈父——ListView,是如何通过自定义,来实现这个需求的;


先放上效果图:


最终效果

前期准备,需要自定义并提供给ListView的部分;


1. 首先,我们需要一个又大又圆的月亮:


这里呢,就先用一个背景图替代,所以把一个背景图放到stack底层中:


Stack(
children: [
Positioned.fill(
child: Image.asset("img/bg_mid_autumn.jpg",fit: BoxFit.cover,),
),
Positioned.fill(
/// 自定义的ListView
/// 先以RecyclerView的形式命个名,毕竟思路参考自Android 的RecyclerView
child: RecyclerView.builder(...),
),
],

2. 以及主人公————嫦娥:


把它以item的形式加入到自定义ListView中


RecyclerView.builder(
...
itemBuilder: (context, index) {
return Container(
width: 100,
alignment: AlignmentDirectional.topCenter,
child: Image.asset("img/img_chang_e.png",fit: BoxFit.cover,width: 100,height: 100,),
);
}
)

3. 搞一个提供规划登月路径的Widget:


class ImageEditor extends CustomPainter {
ImageEditor();

Path? drawPath;

final Paint painter = new Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 10;

void update(Offset offset) {
if (drawPath == null) {
drawPath = Path()..moveTo(offset.dx, offset.dy);
}

drawPath?.lineTo(offset.dx, offset.dy);
}

@override
void paint(Canvas canvas, Size size) {
if (drawPath != null) {
canvas.drawPath(drawPath!, painter);
}
}

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

搞定~正好三步;


大家先别急着骂,先不提这个自定义的ListView以及一堆莫名其妙的东西从哪来的,就说是不是三步吧


虽然要实现这三步,需要做如下工作来实现:


关于奔月动画的实现原理、方式


这块参考自android的RecyclerView的自定义LayoutManager的部分,具体详细步骤可以看这个大佬的文章:


# Android自定义LayoutManager第十一式之飞龙在天


这块仅供提供思路,虽说Flutter中没有RecyclerView这种神器,也没有layoutManager这种东西,甚至onMeasure、onLayout这块的触发时机等方面跟android都不同;


但是下沉到onMeasure、onDraw、onLayout这个层面,其实都是一样的,并非不可参考


分析与实现,需要改造ListView哪些地方:


1. 首先,我们先从 ListView 本身开始:


ListView的结构其实并不复杂,或者嚣张点,大部分可滑动的View,也无非就在那几个类上面修修改改,换句话说:


学姐

当然我知道各位一点都不喜欢看代码(其实是因为这部分太多了……放一篇介绍文章中放不下),那我简化一下,只提一下这次涉及的部分和浅层解析,毕竟这块东西我也是简单了解一下(纯属个人理解,有错误请狠狠的打我脸):



  1. ListView、nestedScrollView、CustomScrollView等滑动View,都是直接或者间接继承自ScrollView,ScrollView这个抽线类,就是黑龙江职业学院,那几个可滑动View都是受ScrollView管控;

  2. ScrollView 中管事的就是Scrollable ,把它当成学生会就行;

  3. 在这次中,Scrollable 中有这么几个类要知道:ViewPortScrollControllScrollPostion


ViewPort负责管理提供可视范围视图(学生会生活部?负责提供我们去哪里查寝)、ScrollPostion负责记录滚动位置、最大最小距离之类的信息(学生会书记?记录一下查寝结果)、ScrollControll负责统筹滚动视图的展示、动画等部分(这个我懂,这个是主席,张美玉学姐好);


2. 打破ListView不可滚动溢出的限制,并控制初始位置:


要是嫌麻烦,直接往listView的item列表的头尾处,加个listView大小的空白页,也是可以实现同样效果的


用于装逼,了解listView逻辑思路的写法:



  1. 按照上面的分析,如果要让listView可以滚动溢出,那么需要做的事,就是去找ViewPort的麻烦;


下面我们来回忆一下,一个控件,想要显示,不可避免要经过的三个步骤是:


1、measure;2、layout;3、draw


要想获取滚动限制、明显是measure或者layout部分的东西,结合ScrollPostion的_minScrollExtent和_maxScrollExtent的来源,可以定位可以修改的位置是在 RenderViewPortperformLayout 方法中,调用 scrollPosition 的 applyContentDimensions 方法的地方;


比如说这样修改,将ListView本身大小作为滚动溢出范围:


do {
assert(offset.pixels != null);
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
offset.pixels + centerOffsetAdjustment);
if (correction != 0.0) {
offset.correctBy(correction);
} else {
if (offset.applyContentDimensions(

/// 在这里调整可溢出范围,比如说下面就把size.width 作为可溢出范围,最小范围减少Size.width,最大范围增加Size.width;
math.min(-size.width, _minScrollExtent + mainAxisExtent * anchor),
math.max(0.0,
_maxScrollExtent - mainAxisExtent * (1.0 - anchor) + size.width),
)) break;
}
count += 1;
} while (count < _maxLayoutCycles);


  1. 然后让ListView的初始展示位置,设置到-Size.width的位置;


在这里我的做法是通过 LayoutBuilder 获取约束范围,然后将约束最大值直接赋值给 ScrollController,例如下面代码:


 LayoutBuilder(builder: (_context, _constraint) {

return RecyclerView.builder(
scrollDirection: Axis.horizontal,

/// 这里将约束的最大值的负数提供到ScrollController的initialScrollOffset中
controller: ScrollController(
initialScrollOffset: -_constraint.maxWidth),
itemCount: 3,
reverse: true,
addRepaintBoundaries: false,
....
)
}

PS : 这块的源码,虽说我们只需要改这么一个小点,但是像override这种方式都会因为一堆私有变量什么的无法获取,所以直接从 RenderViewportBase 到 RenderViewPort 都完整复制出来吧



  1. 最后将自定义好的ViewPort的Render部分,传给ViewPort的Widget部分,最后放到自定义ListView的buildViewPort部分:(在这里,我将这个提供溢出滚动的ViewPort命名为OverScrollViewPort)


@override
Widget buildViewport(BuildContext context, ViewportOffset offset, AxisDirection axisDirection, List<Widget> slivers) {
if (shrinkWrap) {
return OverScrollShrinkWrappingViewport(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
clipBehavior: clipBehavior,
);
}

return OverScrollViewPort(
axisDirection: axisDirection,
offset: offset,
slivers: slivers,
cacheExtent: cacheExtent,
center: center,
anchor: anchor,
clipBehavior: clipBehavior,
);
}

如果我没有遗漏部分的话,这时候运行一下代码,应该是这种效果:


最终效果

3. 修改绘制,按path要求绘制:


如果你做好了准备工作,提供了一个自定义路径出来,那么将这个path传到负责绘制的 RenderObject 中,在paint方法中获取滑动比例对应path的位置,就调整绘制位置:


@override
void paint(PaintingContext context, Offset offset) {
...

/// 在这里处理path。
Path? customPath;
PathMetric? pm;
double? length;
if (layoutManager is PathLayoutManager) {
customPath = layoutManager.path;

var pathMetrics = customPath.computeMetrics(forceClosed: false);
pm = pathMetrics.elementAt(0);
length = pm.length;
}

while (child != null) {
double mainAxisDelta = childMainAxisPosition(child);
final double crossAxisDelta = childCrossAxisPosition(child);
...

/// 关于这块去掉原先 mainAxisDelta < constraints.remainingPaintExtent 部分的原因
/// 是因为之前第一个item会在滚动到边界前就被移除绘制
/// 具体是什么地方修改导致的,忘了(๑>؂<๑)
if (mainAxisDelta + paintExtentOf(child) > 0) {
if (customPath != null) {
var percent = (childOffset.dx + child.size.width) /
(child.size.width + constraints.viewportMainAxisExtent);
var tf = pm!.getTangentForOffset(length! * percent);
print("test :${tf?.position}");

var childItemOffset = childOffset;

if (tf?.position != null) {
/// 这里的50 魔法数,是因为之前设置item的height为100,
/// 因为listView好像强制将item的高度固定为listView的高度(横向情况)
/// 这块找个时间研究下怎么搞
/// 强调下,好孩子不要学我这写法
childItemOffset = Offset(
tf!.position.dx - child.size.width / 2, tf.position.dy - 50);
}

context.pushTransform(
needsCompositing,
childItemOffset,
Matrix4.identity(),
// Pre-transform painting function.
painter,
);
} else {
context.paintChild(child, childOffset);
}
}

...
}

...
}

PS:我这里弄了个LayoutManager,其实就是新建个类,把它从widget传到 renderObject &@&%……#;path的处理这块也是有问题的,不应该放在这里搞,好孩子不要学我这么搞,我这是实验性代码…………


当然,要想做到完美复刻RecyclerView,还有不少地方要改动


比如说,你给item加个点击事件,你会发现……现在这种方式,仅仅是改变了绘制的位置,item本身并未移动:


注意看弹toast前的点击位置,明明是左上角


现存问题

我猜想:这里就要涉及到listView 的 insertAndLayout 部分了,进而涉及到整体的滑动逻辑…………或者是hitTest的部分?(或许这是part 2新篇预告?)


在现在这个基础上,还有可以拓展的方面:


除了嫦娥奔月效果,其实还可以实现一些其他效果,例如:


覆盖翻页效果
覆盖翻页效果


item变换
item变换


另外在ParentData等部分中,也有一些有点意思的东西,个人感觉都挺有用的


题外话,上面正文的做法,为什么我个人并不推荐


在我看来,现在文中的这种自定义方式是不符合flutter的推荐方式的:


在我的理解中,在做flutter的自定义的时候,有个比较重要的一句话是需要遵守的:


万物均为widget


所以,如果可以的话,尽量使用widget来代替回调、方法这种,如果无法避免,也尽量约束到一个widget、及其对应element、renderObject;


所以,现在文中的方式,在我看来,虽然能实现需求,但是是通过各种回调、耦合了各个widget的及其对应的element、renderObject,因此不是flutter的良好代码,


这段代码,应急可以,偷懒也行,用于学习思路,分析步骤也是没问题的,但是,不推荐真这么搞哈


这篇文章的主要目的,是参考Android的实现方式,来分享思路与分析flutter中的listView,以及最重要的:



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

Compose 实现月亮阴晴圆缺动画

效果图 人有悲欢离合,月有阴晴圆缺,此事古难全。 但愿人长久,千里共婵娟。 恰逢中秋佳节,我们今天就使用Compose来实现一下月相变化动画吧~ 感兴趣的同学可以点个Star : Compose 实现月亮阴晴圆缺动画 主要思路 满天繁星 为了实现月相动画...
继续阅读 »

效果图




人有悲欢离合,月有阴晴圆缺,此事古难全。

但愿人长久,千里共婵娟。

恰逢中秋佳节,我们今天就使用Compose来实现一下月相变化动画吧~

感兴趣的同学可以点个Star : Compose 实现月亮阴晴圆缺动画



主要思路


满天繁星


为了实现月相动画,我们首先需要一个背景,因此我们需要一个好看的星空,最好还有闪烁的效果

为为实现星空背景,我们需要做以下几件事



  1. 绘制背景

  2. 生成几十个星星,在背景上随机分布

  3. 通过scalealpha动画,实现每个星星的闪烁效果


我们一起来看下代码


@Composable
fun Stars(starNum: Int) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val list = remember { mutableStateListOf<Star>() }
LaunchedEffect(true) {
for (i in 0..starNum) {
delay(100L)
//添加星星,它们的位置在屏幕上随机
list.add(Star(maxWidth.value * density, maxHeight.value * density))
}
}
list.forEach {
Star(it)
}
}
}

@Composable
fun Star(star: Star) {
var progress: Float by remember { mutableStateOf(0f) }
val infiniteTransition = rememberInfiniteTransition()
....
star.updateStar(progress) // 通过动画更新progress,从而更新star的属性值
Canvas(modifier = Modifier.wrapContentSize()) {
scale(star.scale, Offset(star.x, star.y)) { // 缩放动画
drawCircle(
star.starColor,
star.radius,
center = Offset(star.x, star.y),
alpha = star.alpha // alpha动画
)
}
}
}

月相变化


月相,天文学术语。(phase of the moon)是天文学中对于地球上看到的月球被太阳照明部分的称呼。随着月亮每天在星空中自东向西移动一大段距离,它的形状也在不断地变化着,这就是月亮位相变化,叫做月相。

它的变化过程如下图所示



每个阶段都有各自的名字,如下图所示:


可以看出,月相变化过程还是有些复杂的,那我们怎么实现这个效果呢?


思路分析


为了实现月相变化,首先我们需要画一个圆,代表月亮,最终的满月其实就是这样,比较简单

有了满月,如何在它的基础上,画出其它的月相呢?我们可以通过图像混合模式来实现


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

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


我们为了实现月相动画,主要需要使用以下两种混合模式



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

  • DST_OVER:将目标图像放在源图像上方


我们已经了解了图形混合模式,那么需要在满月上画什么才能实现其它效果呢?

我们可以通过在满月上放一个半圆+一个椭圆来实现



  1. 如上所示,椭圆上水平的线叫长轴,竖直的线叫短轴

  2. 短轴不变,长轴半径从0到满月半径发生变化,再加上一个半圆,就可以实现不同的月相

  3. 比如为了画上蛾眉月,可以通过左半边画半圆,再加上一个椭圆,两都都使用DST_OVER混合模式来实现,就实现了它们两的并集,然后覆盖在下层满月上,就实现了上蛾眉月

  4. 为了画渐盈凸月,则同样就左半边以DST_OVER画半圆,再以DST_OUT画椭圆,就只剩下半圆与椭圆不相交的部分,再与下层的满月混合,就实现了渐盈凸月


这样说可能还是比较抽象,感兴趣的同学可下载源码详细了解下


源码实现


//月亮动画控件
@Composable
fun Moon(modifier: Modifier) {
var progress: Float by remember { mutableStateOf(0f) }
BoxWithConstraints(modifier = modifier) {
Canvas(
modifier = Modifier
.size(canvasSize)
.align(Alignment.TopCenter)
) {
drawMoonCircle(this, progress)
drawIntoCanvas {
it.withSaveLayer(Rect(0f, 0f, size.width, size.height), paint = Paint()) {
if (progress != 1f) {
//必须先画半圆,再画椭圆
drawMoonArc(this, it, paint, progress)
drawMoonOval(this, it, paint, progress)
}
}
}
}
}
}

// 1.首先画一个满月
private fun drawMoonCircle(scope: DrawScope, progress: Float) {
//....
drawCircle(Color(0xfff9dc60))
}

// 2. 画半圆
private fun drawMoonArc(scope: DrawScope, canvas: Canvas, paint: Paint, progress: Float) {
val sweepAngle = when { //从新月到满月在一边画半圆,从满月回到新月则在另一边画半圆
progress <= 0.5f -> 180f
progress <= 1f -> 180f
progress <= 1.5f -> -180f
else -> -180f
}
paint.blendMode = BlendMode.DstOver //半圆的混合模式始终是DstOver
scope.run {
canvas.drawArc(Rect(0f, 0f, size.width, size.height), 90f, sweepAngle, false, paint)
}
}

// 3. 画椭圆
private fun drawMoonOval(scope: DrawScope, canvas: Canvas, paint: Paint, progress: Float) {
val blendMode = when { //椭圆的混合模式会发生变化,这里需要注意下
progress <= 0.5f -> BlendMode.DstOver
progress <= 1f -> BlendMode.DstOut
progress <= 1.5f -> BlendMode.DstOut
else -> BlendMode.DstOver
}
paint.blendMode = blendMode
scope.run {
canvas.drawOval(
Rect(offset = topLeft, size = Size(horizontalAxis, verticalAxis)), //椭圆的长轴会随着动画变化
paint = paint
)
}
}

如上所示:



  1. 主要就是3个步骤,画满月,再画半圆,再画椭圆

  2. 半圆的混合模式始终是DstOver,而椭圆的混合模式会发生变化,它们的颜色都是黑色。

  3. 可以看到半圆与椭圆新建了一个Layer,混合模式的变化,表示的就是最后剩下的是它们的并集,还是Dst不相交的部分,最后覆盖到满月上,所以必须先画半圆

  4. 随着动画的变化,椭圆的长轴会发生变化,这样就可以实现不同的月相


诗歌打字机效果


上面其实已经做得差不多了,我们最后再添加一些诗歌,并为它们添加打字机效果


@Composable
fun PoetryColumn(
list: List<Char>,
offsetX: Float = 0f,
offsetY: Float = 0f
) {
val targetList = remember { mutableStateListOf<Char>() }
LaunchedEffect(list) {
targetList.clear()
list.forEach {
delay(500) //通过在LaunchedEffect中delay实现动画效果
targetList.add(it)
}
}
//将 Jetpack Compose 环境的 Paint 对象转换为原生的 Paint 对象
val textPaint = Paint().asFrameworkPaint().apply {
//...
}
Canvas(modifier = Modifier.wrapContentSize()) {
drawIntoCanvas {
for (i in targetList.indices) {
it.nativeCanvas.drawText(list[i].toString(), x, y, textPaint)
y += delta // 更新文字y轴位置
}
}
}
}

如上所示,代码比较简单



  1. 通过在LaunchedEffect中调用挂起函数,来实现动画效果

  2. 为了实现竖直方向的文字,我们需要使用Paint来绘制Text,而不能使用Text组件

  3. Compose目前还不支持直接绘制Text,所以我们需要调用asFrameworkPaint将其转化为原生的Paint


总结


通过以上步骤,我们就通过Compose实现了月相阴晴圆缺+星空闪耀+诗歌打字机的动画效果

开发起来跟Android自定义绘制其实并没有多大差别,代码量因为Compose强大的API与声明式特点可能还有所减少

在我看来,Compose已经相当成熟了,而且将是Android UI的未来~


开源不易,如果项目对你有所帮助,欢迎点赞,Star,收藏~


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

iOS KVO的基本使用

iOS
iOS - 关于 KVO 的一些总结1. 什么是 KVOKVO的全称是Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发KVO的监...
继续阅读 »

iOS - 关于 KVO 的一些总结

1. 什么是 KVO

  • KVO的全称是Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发KVO的监听方法来通知观察者。KVO是在MVC应用程序中的各层之间进行通信的一种特别有用的技术。
  • KVONSNotification都是iOS中观察者模式的一种实现。
  • KVO可以监听单个属性的变化,也可以监听集合对象的变化。监听集合对象变化时,需要通过KVCmutableArrayValueForKey:等可变代理方法获得集合代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发KVO的监听方法。集合对象包含NSArrayNSSet
  • KVOKVC有着密切的关系,如果想要深入了解KVO,建议先学习KVC


传送门:iOS - 关于 KVC 的一些总结

2. KVO 的基本使用

KVO使用三部曲:添加/注册KVO监听、实现监听方法以接收属性改变通知、 移除KVO监听。

  1. 调用方法addObserver:forKeyPath:options:context: 给被观察对象添加观察者;
  2. 在观察者类中实现observeValueForKeyPath:ofObject:change:context:方法以接收属性改变的通知消息;
  3. 当观察者不需要再监听时,调用removeObserver:forKeyPath:方法将观察者移除。需要注意的是,至少需要在观察者销毁之前,调用此方法,否则可能会导致Crash

2.1 注册方法

/*
** target: 被观察对象
** observer:观察者对象
** keyPath: 被观察对象的属性的关键路径,不能为nil
** options: 观察的配置选项,包括观察的内容(枚举类型):
NSKeyValueObservingOptionNew:观察新值
NSKeyValueObservingOptionOld:观察旧值
NSKeyValueObservingOptionInitial:观察初始值,如果想在注册观察者后,立即接收一次回调,可以加入该枚举值
NSKeyValueObservingOptionPrior:分别在值改变前后触发方法(即一次修改有两次触发)
** context: 可以传入任意数据(任意类型的对象或者C指针),在监听方法中可以接收到这个数据,是KVO中的一种传值方式
如果传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问context就可能导致Crash
*/

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

2.2 监听方法

如果对象被注册成为观察者,则该对象必须能响应以下监听方法,即该对象所属类中必须实现监听方法。当被观察对象属性发生改变时就会调用监听方法。如果没有实现就会导致Crash

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
/*
** keyPath:被观察对象的属性的关键路径
** object: 被观察对象
** change: 字典 NSDictionary,属性值更改的详细信息,根据注册方法中options参数传入的枚举来返回
key为 NSKeyValueChangeKey 枚举类型
{
1.NSKeyValueChangeKindKey:存储本次改变的信息(change字典中默认包含这个key)
{
对应枚举类型 NSKeyValueChange
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
如果是对被观察对象属性(包括集合)进行赋值操作,kind 字段的值为 NSKeyValueChangeSetting
如果被观察的是集合对象,且进行的是(插入、删除、替换)操作,则会根据集合对象的操作方式来设置 kind 字段的值
插入:NSKeyValueChangeInsertion
删除:NSKeyValueChangeRemoval
替换:NSKeyValueChangeReplacement
}
2.NSKeyValueChangeNewKey:存储新值(如果options中传入NSKeyValueObservingOptionNew,change字典中就会包含这个key)
3.NSKeyValueChangeOldKey:存储旧值(如果options中传入NSKeyValueObservingOptionOld,change字典中就会包含这个key)
4.NSKeyValueChangeIndexesKey:如果被观察的是集合对象,且进行的是(插入、删除、替换)操作,则change字典中就会包含这个key,
这个key的value是一个NSIndexSet对象,包含更改关系中的索引
5.NSKeyValueChangeNotificationIsPriorKey:如果options中传入NSKeyValueObservingOptionPrior,则在改变前通知的change字典中会包含这个key。
这个key对应的value是NSNumber包装的YES,我们可以这样来判断是不是在改变前的通知[change[NSKeyValueChangeNotificationIsPriorKey] boolValue] == YES]
}
** context:注册方法中传入的context
*/

}

2.3 移除方法

在调用注册方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期。至少需要在观察者销毁之前,调用以下方法移除观察者,否则如果在观察者被释放后,再次触发KVO监听方法就会导致Crash

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;

2.4 使用示例

以下使用KVOperson对象添加观察者为当前viewController,监听person对象的name属性值的改变。当name值改变时,触发KVO的监听方法。

- (void)viewDidLoad {
[super viewDidLoad];

self.person = [HTPerson new];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld) context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
self.person.name= @"张三";
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
NSLog(@"keyPath:%@",keyPath);
NSLog(@"object:%@",object);
NSLog(@"change:%@",change);
NSLog(@"context:%@",context);
}

- (void)dealloc
{
[self.person removeObserver:self forKeyPath:@"name"];
}

keyPath:name
object:
change:{ kind = 1; new = "\U70b9\U51fb"; old = ""; }
context:(null)

2.5 实际应用

KVO主要用来做键值观察操作,想要一个值发生改变后通知另一个对象,则用KVO实现最为合适。斯坦福大学的iOS教程中有一个很经典的案例,通过KVOModelController之间进行通信。如图所示: 斯坦福大学 KVO示例

2.6 KVO 触发监听方法的方式

KVO触发分为自动触发和手动触发两种方式。

2.6.1 自动触发

① 如果是监听对象特定属性值的改变,通过以下方式改变属性值会触发KVO

  • 使用点语法
  • 使用setter方法
  • 使用KVCsetValue:forKey:方法
  • 使用KVCsetValue:forKeyPath:方法

② 如果是监听集合对象的改变,需要通过KVCmutableArrayValueForKey:等方法获得代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发KVO。集合对象包含NSArrayNSSet

2.6.2 手动触发

① 普通对象属性或是成员变量使用:

- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

② NSArray对象使用:

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;

③ NSSet对象使用:

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;


收起阅读 »

ios Category无法覆写系统方法?

iOS
Category无法覆写系统方法?这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思,另一点就是人多力量大,多人讨论就会获得多种思路。首次提出这个问题的是反向抽烟,他遇到了不能用 Category 覆写系统方法的现象。问题抛到我这,我验...
继续阅读 »

Category无法覆写系统方法?

这是一次非常有趣的解决问题经历,以至于我认为解决方式可能比问题本身更有意思,另一点就是人多力量大,多人讨论就会获得多种思路。

首次提出这个问题的是反向抽烟,他遇到了不能用 Category 覆写系统方法的现象。问题抛到我这,我验证了这个有点奇怪的现象,并决定好好探究一下,重看了 Category 那部分源码仍没有找到合理解释,于是将这个问题抛到开发群里,最后由皮拉夫大王在此给出了最为合理的解释。之后我又顺着他的思路找到了一些更有力的证据。以下是这一过程的经历。

问题提出

以下内容出自反向抽烟:

背景:想为 UITextField 提供单独的属性 placeholderColor ,用来直接设置占位符的颜色,这个时候使用分类设置属性,重写 setter 和 getter,set中直接使用 KVC 的方式对属性的颜色赋值;这个时候就有个bug,如果在其他类中使用 UITextField 这个控件的时候,先设置颜色,再设置文字,会发现占位符的颜色没有发生改变。

解决思路:首先想到 UITextField 中的 Label 是使用的懒加载,当有文字设置的时候,就会初始化这个label,这时候就考虑先设置颜色根本就没起到作用;

解决办法:在分类中 placeholderColor 的 setter 方法中,使用runtime的objc_setAssociatedObject先把颜色保存起来,这样就能保证先设置的颜色不会丢掉,然后需要重写 placeholder的setter方法,让在设置完文字的时候,拿到先前保存的颜色,故要在placeholderColor 的getter中用objc_getAssociatedObject取,这里有个问题点,在分类中重写 placeholder 的setter方法的话,在外面设置 placeholder 的时候,根本不走自己重写的这个 setPlaceholder方法,而走系统自带的,这里我还没研究。然后为了解决这个问题,我自己写了个setDsyPlaceholder方法,在setDsyPlaceholder里面对标签赋值,同时添加已经保存好的颜色,然后与setPlaceholder做交换,bug修复。

这里大家先不要关注解决 placeholderColor 的方式是否正确,以免思路走偏。我们应该避免使用Category 覆写系统方法的,但这里引出了一个问题:如果就是要覆写系统的方法,为啥没被执行?

问题探索

我测试发现自定义类是可以通过 Category 覆写的,只有系统方法不可以。当时选的是 UIViewController 的viewDidLoad 方法,其他几个 UIViewController 方法也试了都不可以。

测试代码如下:

1
2
3
4
5
6
7
8
9
#import "UIViewController+Test.h"

@implementation UIViewController (Test)

- (void)viewDidLoad {
NSLog(@"viewDidLoad");
}

@end

所以猜测:系统方法被做了特殊处理都不能覆写,只有自定义类可以覆写

有一个解释是:系统方法是会被缓存的,方法查找走了缓存,没有查完整的方法表。

这个说法好像能说得通,但是系统缓存是库的层面,方法列表的缓存又是另一个维度了。方法列表的缓存应该是应用间独立进行的,这样才能保证不同应用对系统库的修改不会相互影响,所以这个解释站不住脚。

这时有朋友提出他们之前使用Category 覆写过 UIScreen 的 mainScreen,是可以成功的。我试了下确实可以,观察之后发现该属性是一个类属性。又试了其他几个系统库的类属性,也都是可以的。

所以猜测变成了:只有系统实例方法不能被覆写,类属性,类方法可以覆写

这时已经感觉奇怪了,这个规律也说不通。后来又有朋友测试通过 Xcode10.3 能够覆写系统方法,好嘛。。。

这时的猜测又变成了:苹果在某个特定版本开始才做了系统方法覆写的拦截

可靠的证据

皮拉夫大王在此提出了很关键的信息,他验证了iOS12系统可以覆写系统方法(后来验证iOS13状况相同),iOS14不能覆写。

但iOS14的情况并不是所有的系统方法都覆盖不了,能否覆盖与类方法还是实例方法无关。

例如:UIResponder的分类,重写init 和 isFirstResponderinit可以覆盖,isFirstResponder不能覆盖。在iOS14的系统上NS的类,很多都可以被分类覆盖,但是UIKit的类,在涉及到UI的方法时,很多都无法覆盖。

这里猜测:系统做了白名单,命中白名单的函数会被系统拦截和处理

以下是对 iOS14 状况的验证,覆写isFirstResponder,打印method_list

1
2
3
4
5
6
7
8
unsigned int count;
Method *list = class_copyMethodList(UIResponder.class, &count);
for (int i = 0; i < count; i++) {
Method m = list[i];
if ([NSStringFromSelector(method_getName(m)) isEqualToString:@"isFirstResponder"]) {
IMP imp = method_getImplementation(m);
}
}

isFirstResponder会命中两次,两次po imp的结果是:

1
2
3
4
//第一次
(libMainThreadChecker.dylib`__trampolines + 67272)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])

同样的代码,在iOS12的设备也会命中两次,结果为:

1
2
3
4
//第一次
(SwiftDemo`-[UIResponder(xx) isFirstResponder] at WBOCTest.m:38)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])

所以可以确认的是,分类方法是可以正常添加到系统类的,但在iOS14的系统中,覆写的方法却被libMainThreadChecker.dylib里的方法接管了,导致没有执行。

那么问题来了,这个libMainThreadChecker.dylib库是干嘛的,它做了什么?

这个库对应了Main Thread Checker这个功能,它是在Xcode9新增的,因为开销比较小,只占用1-2%的CPU,启动时间占用时间不到0.1s,所以被默认置为开的状态。它在调试期的作用是帮助我们定位那些应该在主线程执行,却没有放到主线程的代码执行情况。

另外官方文档还有一个解释

The Main Thread Checker tool dynamically replaces system methods that must execute on the main thread with variants that check the current thread. The tool replaces only system APIs with well-known thread requirements, and doesn’t replace all system APIs. Because the replacements occur in system frameworks, Main Thread Checker doesn’t require you to recompile your app.

这个家伙会动态的替换尝试重写需要在主线程执行的系统方法,但也不是所有的系统方法。

终于找到了!这很好的解释了为什么本应被覆盖的系统方法却指向了libMainTreadChecker.dylib这个库,同时也解释了为什么有些方法可以覆写,有些却不可以。

测试发现当我们关闭了这个开关,iOS14的设备就可以正常执行覆写的方法了。

到此基本完事了,但还留有一个小疑问,那就是为什么iOS14之前的设备,不受这个开关的影响?目前没有找到实质的证据表明苹果是如何处理的,但可以肯定的是跟 Main Thread Checker 这个功能有关。

总结

稍微抽象下一开始处理问题的方式:遇到问题 -> 猜想 -> 佐证 -> 推翻猜想 -> 重新猜想 -> 再佐证。

这其实是错误的流程,猜想和佐证可以,但他们一般只会成为一个验证的样例,而不能带给我们答案。所以正确的处理方式是,不要把太多时间浪费在猜想和佐证猜想上,而应该去深挖问题本身。新的解题思路可以是这样的:遇到问题 -> 猜想 -> 深挖 -> 根据挖到的点佐证结果。

链接:https://zhangferry.com/2021/04/21/overwrite_system_category/

收起阅读 »

iOS14开发-网络

iOS
基础知识App如何通过网络请求数据?App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。主机通过本次网络请求指...
继续阅读 »

基础知识

App如何通过网络请求数据?

客户服务器模型

  1. App 通过一个 URL 向特定的主机发送一个网络请求加载需要的资源。URL 一般是使用 HTTP(HTTPS)协议,该协议会通过 IP(或域名)定位到资源所在的主机,然后等待主机处理和响应。
  2. 主机通过本次网络请求指定的端口号找到对应的处理软件,然后将网络请求转发给该软件进行处理(处理的软件会运行在特定的端口)。针对 HTTP(HTTPS)请求,处理的软件会随着开发语言的不同而不同,如 Java 的 Tomcat、PHP 的 Apache、.net 的 IIS、Node.js 的 JavaScript 运行时等)
  3. 处理软件针对本次请求进行分析,分析的内容包括请求的方法、路径以及携带的参数等。然后根据这些信息,进行相应的业务逻辑处理,最后通过主机将处理后的数据返回(返回的数据一般为 JSON 字符串)。
  4. App 接收到主机返回的数据,进行解析处理,最后展示到界面上。
  5. 发送请求获取资源的一方称为客户端。接收请求提供服务的一方称为服务端

基本概念

URL

  • Uniform Resource Locator(统一资源定位符),表示网络资源的地址或位置。
  • 互联网上的每个资源都有一个唯一的 URL,通过它能找到该资源。
  • URL 的基本格式协议://主机地址/路径

HTTP/HTTPS

  • HTTP—HyperTextTransferProtocol:超文本传输协议。
  • HTTPS—Hyper Text Transfer Protocol over Secure Socket Layer 或 Hypertext Transfer Protocol Secure:超文本传输安全协议。

请求方法

  • 在 HTTP/1.1 协议中,定义了 8 种发送 HTTP 请求的方法,分别是GET、POST、HEAD、PUT、DELETE、OPTIONS、TRACE、CONNECT
  • 最常用的是 GET 与 POST

响应状态码

状态码描述含义
200Ok请求成功
400Bad Request客户端请求的语法出现错误,服务端无法解析
404Not Found服务端无法根据客户端的请求找到对应的资源
500Internal Server Error服务端内部出现问题,无法完成响应

请求响应过程

请求响应过程

JSON

  • JavaScript Object Notation。
  • 一种轻量级的数据格式,一般用于数据交互。
  • 服务端返回给 App 客户端的数据,一般都是 JSON 格式。

语法

  • 数据以键值对key : value形式存在。
  • 多个数据由,分隔。
  • 花括号{}保存对象。
  • 方括号[]保存数组。

key与value

  • 标准 JSON 数据的 key 必须用双引号""
  • JSON 数据的 value 类型:
    • 数字(整数或浮点数)
    • 字符串("表示)
    • 布尔值(true 或 false)
    • 数组([]表示)
    • 对象({}表示)
    • null

解析

  • 厘清当前 JSON 数据的层级关系(借助于格式化工具)。
  • 明确每个 key 对应的 value 值的类型。
  • 解析技术
    • Codable 协议(推荐)。
    • JSONSerialization。
    • 第三方框架。

URLSession

使用步骤

  1. 创建请求资源的 URL。
  2. 创建 URLRequest,设置请求参数。
  3. 创建 URLSessionConfiguration 用于设置 URLSession 的工作模式和网络设置。
  4. 创建 URLSession。
  5. 通过 URLSession 构建 URLSessionTask,共有 3 种任务。 (1)URLSessionDataTask:请求数据的 Task。  (2)URLSessionUploadTask:上传数据的 Task。 (3)URLSessionDownloadTask:下载数据的 Task。 
  6. 启动任务。
  7. 处理服务端响应,有 2 种方式。 (1)通过 completionHandler(闭包)处理服务端响应。 (2)通过 URLSessionDataDelegate(代理)处理请求与响应过程的事件和接收服务端返回的数据。

基本使用

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// get()
// post()
}

func get() {
// 1. 确定URL
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
// 2. 创建请求
let urlRequest = URLRequest(url: url!)
// cachePolicy: 缓存策略,App最常用的缓存策略是returnCacheDataElseLoad,表示先查看缓存数据,没有缓存再请求
// timeoutInterval:超时时间
// let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let config = URLSessionConfiguration.default
// 3. 创建URLSession
let session = URLSession(configuration: config)
// 4. 创建任务
let task = session.dataTask(with: urlRequest) { data, _, error in
if error != nil {
print(error!)
} else {
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
}
// 5. 启动任务
task.resume()
}

func post() {
let url = URL(string: "http://v.juhe.cn/toutiao/index")
var urlRequest = URLRequest(url: url!)
// 指明请求方法
urlRequest.httpMethod = "POST"
// 指明参数
let params = "type=top&key=申请的key"
// 设置请求体
urlRequest.httpBody = params.data(using: .utf8)
let config = URLSessionConfiguration.default
// delegateQueue决定了代理方法在哪个线程中执行
let session = URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
let task = session.dataTask(with: urlRequest)
task.resume()
}
}

// MARK:- URLSessionDataDelegate
extension ViewController: URLSessionDataDelegate {
// 开始接收数据
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
// 允许接收服务器的数据,默认情况下请求之后不接收服务器的数据即不会调用后面获取数据的代理方法
completionHandler(URLSession.ResponseDisposition.allow)
}

// 获取数据
// 根据请求的数据量该方法可能会调用多次,这样data返回的就是总数据的一段,此时需要用一个全局的Data进行追加存储
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let result = String(data: data, encoding: .utf8)
if let result = result {
print(result)
}
}

// 获取结束
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
} else {
print("=======成功=======")
}
}
}

注意:如果网络请求是 HTTP 而非 HTTPS,默认情况下,iOS 会阻断该请求,此时需要在 Info.plist 中进行如下配置。

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

URL转码与解码

  • 当请求参数带中文时,必须进行转码操作。
let url = "https://www.baidu.com?name=张三"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
print(url) // URL中文转码
print(url.removingPercentEncoding!) // URL中文解码

  • 有时候只需要对URL中的中文处理,而不需要针对整个URL。
let str = "阿楚姑娘"
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
let url = URL(string: "https://music.163.com/#/search/m/?s=\(str)&type=1")

下载数据

class ViewController: UIViewController {
// 下载进度
@IBOutlet var downloadProgress: UIProgressView!
// 下载图片
@IBOutlet var downloadImageView: UIImageView!

override func viewDidLoad() {
super.viewDidLoad()

download()
}

func download() {
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/wall.png")!
let request = URLRequest(url: url)
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
let task = session.downloadTask(with: request)
task.resume()
}
}

extension ViewController: URLSessionDownloadDelegate {
// 下载完成
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// 存入沙盒
let savePath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!
// 文件类型根据下载的内容决定
let fileName = "\(Int(Date().timeIntervalSince1970)).png"
let filePath = savePath + "/" + fileName
print(filePath)
do {
try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: filePath))
// 显示到界面
DispatchQueue.main.async {
self.downloadImageView.image = UIImage(contentsOfFile: filePath)
}
} catch {
print(error)
}
}

// 计算进度
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
DispatchQueue.main.async {
self.downloadProgress.setProgress(Float(totalBytesWritten) / Float(totalBytesExpectedToWrite), animated: true)
}
}
}

上传数据

上传数据需要服务端配合,不同的服务端代码可能会不一样,下面的上传代码适用于本人所写的服务端代码

  • 数据格式。

上传数据格式

  • 实现。
class ViewController: UIViewController {
let YFBoundary = "AnHuiWuHuYungFan"
@IBOutlet var uploadInfo: UILabel!
@IBOutlet var uploadProgress: UIProgressView!

override func viewDidLoad() {
super.viewDidLoad()

upload()
}

func upload() {
// 1. 确定URL
let url = URL(string: "http://172.20.53.240:8080/AppTestAPI/UploadServlet")!
// 2. 确定请求
var request = URLRequest(url: url)
// 3. 设置请求头
let head = "multipart/form-data;boundary=\(YFBoundary)"
request.setValue(head, forHTTPHeaderField: "Content-Type")
// 4. 设置请求方式
request.httpMethod = "POST"
// 5. 创建NSURLSession
let session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue())
// 6. 获取上传的数据(按照固定格式拼接)
var data = Data()
let header = headerString(mimeType: "image/png", uploadFile: "wall.png")
data.append(header.data(using: .utf8)!)
data.append(uploadData())
let tailer = tailerString()
data.append(tailer.data(using: .utf8)!)
// 7. 创建上传任务 上传的数据来自getData方法
let task = session.uploadTask(with: request, from: data) { _, _, error in
// 上传完毕后
if error != nil {
print(error!)
} else {
DispatchQueue.main.async {
self.uploadInfo.text = "上传成功"
}
}
}
// 8. 执行上传任务
task.resume()
}

// 开始标记
func headerString(mimeType: String, uploadFile: String) -> String {
var data = String()
// --Boundary\r\n
data.append("--" + YFBoundary + "\r\n")
// 文件参数名 Content-Disposition: form-data; name="myfile"; filename="wall.jpg"\r\n
data.append("Content-Disposition:form-data; name=\"myfile\";filename=\"\(uploadFile)\"\r\n")
// Content-Type 上传文件的类型 MIME\r\n\r\n
data.append("Content-Type:\(mimeType)\r\n\r\n")

return data
}

// 结束标记
func tailerString() -> String {
// \r\n--Boundary--\r\n
return "\r\n--" + YFBoundary + "--\r\n"
}

func uploadData() -> Data {
let image = UIImage(named: "wall.png")
let imageData = image!.pngData()
return imageData!
}
}

extension ViewController: URLSessionTaskDelegate {
// 上传进去
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
DispatchQueue.main.async {
self.uploadProgress.setProgress(Float(totalBytesSent) / Float(totalBytesExpectedToSend), animated: true)
}
}

// 上传出错
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print(error)
}
}
}

URLCache

  • 网络缓存有很多好处:节省流量、更快加载、断网可用。
  • 使用 URLCache 管理缓存区域的大小和数据。
  • 每一个 App 都默认创建了一个 URLCache 作为缓存管理者,可以通过URLCache.shared获取,也可以自定义。
// 创建URLCache
// memoryCapacity:内存缓存容量
// diskCapacity:硬盘缓存容量
// directory:硬盘缓存路径
let cache = URLCache(memoryCapacity: 10 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024, directory: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first)
// 替换默认的缓存管理对象
URLCache.shared = cache

  • 常见属性与方法。
let url = URL(string: "http://v.juhe.cn/toutiao/index?type=top&key=申请的key")
let urlRequest = URLRequest(url: url!, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 5)
let cache = URLCache.shared

// 内存缓存大小
cache.memoryCapacity
// 硬盘缓存大小
cache.diskCapacity
// 已用内存缓存大小
cache.currentMemoryUsage
// 已用硬盘缓存大小
cache.currentDiskUsage
// 获取某个请求的缓存
let cacheResponse = cache.cachedResponse(for: urlRequest)
// 删除某个请求的缓存
cache.removeCachedResponse(for: urlRequest)
// 删除某个时间点开始的缓存
cache.removeCachedResponses(since: Date().addingTimeInterval(-60 * 60 * 48))
// 删除所有缓存
cache.removeAllCachedResponses()

WKWebView

  • 用于加载 Web 内容的控件。
  • 使用时必须导入WebKit模块。

基本使用

  • 加载网页。
// 创建URL
let url = URL(string: "https://www.abc.edu.cn")
// 创建URLRequest
let request = URLRequest(url: url!)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载网页
webView.load(request)

  • 加载本地资源。
// 文件夹路径
let basePath = Bundle.main.path(forResource: "localWeb", ofType: nil)!
// 文件夹URL
let baseUrl = URL(fileURLWithPath: basePath, isDirectory: true)
// html路径
let filePath = basePath + "/index.html"
// 转成文件
let fileContent = try? NSString(contentsOfFile: filePath, encoding: String.Encoding.utf8.rawValue)
// 创建WKWebView
let webView = WKWebView(frame: UIScreen.main.bounds)
// 加载html
webView.loadHTMLString(fileContent! as String, baseURL: baseUrl)

注意:如果是本地资源是文件夹,拖进项目时,需要勾选Create folder references,然后用Bundle.main.path(forResource: "文件夹名", ofType: nil)获取资源路径。

与JavaScript交互

创建WKWebView

lazy var webView: WKWebView = {
// 创建WKPreferences
let preferences = WKPreferences()
// 开启JavaScript
preferences.javaScriptEnabled = true
// 创建WKWebViewConfiguration
let configuration = WKWebViewConfiguration()
// 设置WKWebViewConfiguration的WKPreferences
configuration.preferences = preferences
// 创建WKUserContentController
let userContentController = WKUserContentController()
// 配置WKWebViewConfiguration的WKUserContentController
configuration.userContentController = userContentController
// 给WKWebView与Swift交互起一个名字:callbackHandler,WKWebView给Swift发消息的时候会用到
// 此句要求实现WKScriptMessageHandler
configuration.userContentController.add(self, name: "callbackHandler")
// 创建WKWebView
var webView = WKWebView(frame: UIScreen.main.bounds, configuration: configuration)
// 让WKWebView翻动有回弹效果
webView.scrollView.bounces = true
// 只允许WKWebView上下滚动
webView.scrollView.alwaysBounceVertical = true
// 设置代理WKNavigationDelegate
webView.navigationDelegate = self
// 返回
return webView
}()

创建HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0,user-scalable=no"/>
</head>
<body>
iOS传过来的值:<span id="name"></span>
<button onclick="responseSwift()">响应iOS</button>
<script type="text/javascript">
// 给Swift调用
function sayHello(name) {
document.getElementById("name").innerHTML = name
return "Swift你也好!"
}
// 调用Swift方法
function responseSwift() {
// 这里的callbackHandler是创建WKWebViewConfiguration是定义的
window.webkit.messageHandlers.callbackHandler.postMessage("JavaScript发送消息给Swift")
}
</script>
</body>
</html>

两个协议

  • WKNavigationDelegate:判断页面加载完成,只有在页面加载完成后才能在实现 Swift 调用 JavaScript。WKWebView 调用 JavaScript:
// 加载完毕以后执行
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 调用JavaScript方法
webView.evaluateJavaScript("sayHello('WebView你好!')") { (result, err) in
// result是JavaScript返回的值
print(result, err)
}
}

  • WKScriptMessageHandler:JavaScript 调用 Swift 时需要用到协议中的一个方法来。JavaScript 调用 WKWebView:
// Swift方法,可以在JavaScript中调用
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print(message.body)
}

ViewController

class ViewController: UIViewController {
// 懒加载WKWebView
...

// 加载本地html
let html = try! String(contentsOfFile: Bundle.main.path(forResource: "index", ofType: "html")!, encoding: String.Encoding.utf8)

override func viewDidLoad() {
super.viewDidLoad()
// 标题
title = "WebView与JavaScript交互"
// 加载html
webView.loadHTMLString(html, baseURL: nil)
view.addSubview(webView)
}
}

// 遵守两个协议
extension ViewController: WKNavigationDelegate, WKScriptMessageHandler {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
...
}

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
}
}

SFSafariViewController

  • iOS 9 推出的一种 UIViewController,用于加载与显示 Web 内容,打开效果类似 Safari 浏览器的效果。
  • 使用时必须导入SafariServices模块。
import SafariServices

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}

override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
showSafariViewController()
}

func showSafariViewController() {
// URL
let url = URL(string: "https://www.baidu.com")
// 创建SFSafariViewController
let sf = SFSafariViewController(url: url!)
// 设置代理
sf.delegate = self
// 显示
present(sf, animated: true, completion: nil)
}
}

extension ViewController: SFSafariViewControllerDelegate {
// 点击左上角的完成(done)
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
print(#function)
}

// 加载完成
func safariViewController(_ controller: SFSafariViewController, didCompleteInitialLoad didLoadSuccessfully: Bool) {
print(#function)
}
}
收起阅读 »

精益求精!记一次业务代码的优化探索

关键词:需求实现、设计模式、策略模式、程序员成长 承启: 本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。 会介绍一个运用策略模式的实战。 需求和编码本身小于打怪升级成长路径。 文中代码为伪代码。 场景...
继续阅读 »

关键词:需求实现、设计模式、策略模式、程序员成长



承启:


本篇从业务场景出发,介绍了面对一个复杂需求,拆解重难点、编码实现需求、优化代码、思考个人成长的过程。



  • 会介绍一个运用策略模式的实战。

  • 需求和编码本身小于打怪升级成长路径。

  • 文中代码为伪代码。


场景说明:


需求描述:手淘内“充值中心”要投放在饿了么、淘宝极速版、UC浏览器等集团二方APP。
拿到需求之后,来梳理下“充值中心”在他端投放涉及到的核心功能点



  • 通讯录读取


不同客户端、操作系统,JSbridge API实现略有不同。



  • 支付


不同端支付JSbridge调用方式不同。



  • 账号体系:


集团内不同端账号体系可能不同,需要打通。



  • 容器兼容


手淘内采用PHA容器,淘宝极简版本投放H5,饿了么以手淘小程序的方式投放。环境变量、通信方式等需要兼容。



  • 各端个性化诉求


极速版投放极简链路,只保留核心模块等。


解决方案


需求明确了:充值相关核心模块,需要兼容每个APP,本质是提供一个多端投放的解决方案
那么这个场景如何编码实现呢?


1、方案一


首先第一个想法💡,在每个功能点模块用if-else判断客户端环境,编写此端逻辑。
下面以获取通讯录列表功能为例,代码如下:


// 业务代码文件 index.js
/**
* 获取通讯录列表
* @param clientName 端名称
*/
const getContactsList = (clientName) => {
if (clientName === 'eleme') {
getContactsListEleme()
} else if (clientName === 'taobao') {
getContactsListTaobao()
} else if (clientName === 'tianmao') {
getContactsListTianmao()
} else if (clientName === 'zhifubao') {
getContactsListZhifubao()
} else {
// 其他端
}
}

写完之后,review一下代码,思考一下这样编码的利弊。


:逻辑清晰,可快速实现。

:代码不美观、可读性略差,每兼容一个端都要在业务逻辑处改动,改一端测多端。


这时,有的同学就说了:“把if-else改成switch-case的写法,把获取通讯录模块抽象成独立的sdk封装,用户在业务层统一调用”,天才!动手实现一下。


2、方案二


核心功能模块,抽象成独立的sdk,模块内部对不同的端进行兼容,业务逻辑里统一方式调用。


/**
* 获取通讯录列表 sdk caontact.js
* @param clientName 端名称
* @param successCallback 成功回调
* @param failCallback 失败回调
*/
export default function (clientName, successCallback, failCallback) {
switch (clientName) {
case 'eleme':
getContactsListEleme()
break
case 'taobao':
getContactsListTaobao()
break
case 'zhifubao':
getContactsListTianmao()
break
case 'tianmao':
getContactsListZhifubao()
break
default:
// 省略
break
}
}

// 业务调用 index.js
<Contacts onIconClick={handleContactsClick} />

import getContactsList from 'Contacts'
import { clientName } from 'env'
const handleContactsClick = () => {
getContactsList(
clientName,
({ arr }) => {
this.setState({
contactsList: arr
})
},
() => {
alert('获取通讯录失败')
}
)
}

惯例,review一下代码:


:模块分工明确,业务层统一调用,代码可读性较高。

:多端没有解藕,每次迭代,需要各个端回归。


上面的实现,看起来代码可读性提高了不少,是一个不错的设计,可是这样是最优的设计吗?


3、方案三


熟悉设计模式的同学,这时候可能要说了,用策略模式啊,对了,这个场景可以用策略模式。
这里简单解释一下策略模式:
策略模式,英文全称是 Strategy Design Pattern。
在 GoF 的《设计模式》一书中,它是这样定义的:



Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.



翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。


难免有些晦涩,什么意思呢?我个人的理解为:策略模式用来解耦策略的定义、创建、使用。它典型的应用场景就是:避免冗长的if-else或switch分支判断编码。


下面看代码实现:


/**
* 策略定义
*/
const strategies = {
eleme: () => {
getContactsListEleme()
},
taobao: () => {
getContactsListTaobao()
},
tianmao: () => {
// 省略
}
}
/**
* 策略创建
*/
const getContactsStrategy = (clientName) => {
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
}
/**
* 策略使用
*/
import { clientName } from 'env'
getContactsStrategy(clientName)()

策略模式的运用,把策略的定义、创建、使用解耦,符合设计原则中的迪米特法则(LOD),实现“高内聚、松耦合”。
当需要新增一个适配端时,我们只需要修改策略定义Map,其他代码都不需要修改,这样就将代码改动最小化、集中化了。


能做到这里,相信你已经超越了一部分同学了,但是我们还要思考、精益求精,如何更优呢?这个时候单从编码层面思考已经受阻塞了,可否从工程构建角度、性能优化角度、项目迭代流程角度、后期代码维护角度思考一下,相信你会有更好的想法。


下面抛砖,聊聊我自己的思考:


4、方案四


从工程构建和性能优化角度出发:如果每个端独立一个文件,构建的时候shake掉其他端chunk,这样bundle可以变更小,网络请求也变更快。



等等... Tree-Shaking是基于ES静态分析,我们的策略判断,基于运行时,好像没什么用啊。



方案三使用策略模式来编码,本质是策略定义、创建和使用解藕,那可否使用刚才的想法,把每端各个功能模块兼容方法聚合成独立module,从更高维度,将多端业务策略定义、创建和使用解藕?



思考一下这样做的收益是什么?



因为每个端的适配,聚合在一个module,将多端业务策略解藕,某个端策略变更,只需要修改此端module,代码改动较小,且后续测试链路,不需要重复回归其他端。符合“高内聚、松耦合”。


代码实现:


/**
* 饿了么端策略定义module
*/
export const elmcStrategies = {
contacts: () => {
getContactsListEleme()
},
pay: () => {
payEleme()
},
// 其他功能略
}
/**
* 手淘端策略定义module
*/
export const tbStrategies = {
contacts: () => {
getContactsListTaobao()
},
pay: () => {
payTaobao()
},
// 其他功能略
};
// ...... (其他端略)
/**
* 策略创建 index.js
*/
import tbStrategies from './tbStrategies'
import elmcStrategies from './elmcStrategies'
export const getClientStrategy = (clientName) => {
const strategies = {
elmc: elmcStrategies,
tb: tbStrategies
// ...
}
if (!clientName) {
throw new Error('clientName is empty.')
}
return strategies[clientName]
};
/**
* 策略使用 pay
*/
import { clientName } from 'env'
getClientStrategy(clientName).pay()

代码目录如下图所示:index.js是多端策略的入口,其他文件为各端策略实现。




从方案四的推导来看,有时候,判断不一定是对的,但是从多个维度去思考,会打开思路,这时,更优方案往往就找上门来了~



5、方案五


既要解决眼前痛点,也要长远谋划,基于以上四种方案,再深入思考一步,如果业务有投放在第三方(非集团APP)的需求,比如投放在商家APP,且商家APP获取通讯录、支付逻辑等复杂多变,这个时候如何设计编码呢?
例如:拉起别端的唤端策略,受多方因素影响,涉及到产品壁垒,策略攻防,怎样控制代码改动次数,及时提高唤端率呢?
在这里简单抛砖,可以借助近几年很火的serverless,搭建唤端策略的faas函数,动态获取最优唤端策略,是不是一个好的方案呢?


沉淀&思考


以上针对多端兼容的问题,我们学习并运用了设计模式——策略模式。那么我们再来看看策略模式的设计思想是什么:

一提到策略模式,有人就觉得,它的作用是避免 if-else 分支判断逻辑。实际上,这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入 bug 的风险。

实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。

还有一点需要注意,在代码设计时,应该了解他的业务价值和复杂度,避免过度设计,如果一个if-else可以解决的问题,何必大费周折,阔谈设计模式呢?


总结


理一下全文的核心路径,也是我此篇文章想要主要传达的打怪升级成长路径。


接到一个复杂的需求--> 理清需求 --> 拆解技术难点 --> 编码实现 --> 代码优化 --> 设计模式和设计原则学习 --> 举一反三 --> 记录沉淀。


当下,前端工程师在工作中,难免会陷入业务漩涡中,被业务推着走。面对这种风险,我们要思考如何在保障完成业务迭代的基础上,运用适合的技术架构,抽象出通用解决方案,沉淀落地。这样,既能帮助业务更快更稳定增长,又能在这个过程中收获个人成长。


作者:喜橙
链接:https://juejin.cn/post/7006136807263830029

收起阅读 »

使用CSS实现中秋民风民俗-拜月

前言 好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。 看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的...
继续阅读 »

前言


image.png
好像有些粗糙,哈哈哈哈。图片是网络的,我用我浅薄的Photoshop知识做了简单的处理。


看了一圈,感觉大家都好🐂 🍺,有做日地月公转的,有做月全食的,有做日落月出的,等等。可谓是八仙过海,各显神通,通览下来真是“精彩”渐欲迷人眼,但是好像没有做拜月的,那我来吧。


拜月,在我国是一种十分古老的习俗,实际上是源自我国一些地方古人对“月神”的一种崇拜活动。中秋节是上古天象崇拜——敬月习俗的遗痕,祭月作为中秋节重要的祭礼之一,从古代延续至今,逐渐演化为民间的赏月、颂月活动,同时也成为现代人渴望团聚、寄托对生活美好愿望的主要形态。「以上来自百度百科」


不知道大家那边有没有这个习俗,我老家是有的,每次拜月都会准备很多好吃的,可把我高兴坏了,因为第二天就是我生日,也就是八月十六,所以好吃的贼多。


废话不多说,开始进入正题,源码在这里:中秋拜月


HTML


一个大的div里面套了三个div,分别代表月亮,月亮上的嫦娥玉兔,月亮下的拜月人群。


<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>中秋拜月</title>
</head>

<body>
<div class="background">
<div class="moon"></div>
<div class="change"></div>
<div class="table"></div>
</div>
</body>

CSS


星星背景


背景图片是从某位大佬的那里获取的,自己作图能力不行,流汗。很常规,设置了一个长宽适应全屏幕,然后就是背景图。


body {
margin: 0;
}

.background {
width: 100%;
height: calc(100vh);
background: #000 url("https://test-jou.oss-cn-beijing.aliyuncs.com/3760b5e2cc46f556.png") repeat top center;
z-index: 1;
}

image.png


月亮


月亮的颜色用的是#ff0,搭配了一个相符的阴影,之所以没有用白色,是我感觉主色调用黄色更能显示拜月的“神圣性”「非迷信」


 .moon {
position: absolute;
left: 108px;
top: 81px;
width: 180px;
height: 180px;
background-color: #ff0;
border-radius: 50%;
box-shadow: 0 0 20px 20px rgba(247, 247, 9, 0.5);
}

image.png


嫦娥和月兔


把嫦娥和月兔放到月亮上,有种嫦娥就在注视人间的味道


.change {
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/unnamed.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
position: absolute;
left: 100px;
top: 81px;
width: 180px;
height: 180px;
z-index: 99;
}

image.png


拜月人群


我感觉这是最不搭配的图了,如有不适,敬请谅解


  .table{
background-image: url("https://test-jou.oss-cn-beijing.aliyuncs.com/table.png");
background-repeat:no-repeat; background-size:100% 100%;-moz-background-size:100% 100%;
bottom: 0;
width: 640px;
right: 0;
height: 450px;
position: absolute;
}

image.png


总结


好几年中秋没有回家了,也有可能是忙或者其他原因,家里近几年中秋也不拜月了。有的时候会很怀念小时候,长大了,小时候也回不去了,因为是后端的缘故,页面也不是很懂,留作纪念吧……


附言


以前,车马很远,书信很慢,一生只够爱一个人「有感」


作者:Jouzeyu
链接:https://juejin.cn/post/7007288677831278628

收起阅读 »

一顿操作,我把 Table 组件性能提升了十倍

背景 Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。 通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM ...
继续阅读 »

背景


Table 表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。


通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM 渲染的数量。在我们公司的项目中,会选择分页的方式,因为虚拟滚动不能正确的读出行的数量,会有 Accessibility 的问题。


记得 19 年的时候,我在 Zoom 已经推行了基于 Vue.js 的前后端分离的优化方案,并且基于 ElementUI 组件库开发了 ZoomUI。其中我们在重构用户管理页面的时候使用了 ZoomUI 的 Table 组件替换了之前老的用 jQuery 开发的 Table 组件。


因为绝大部分场景 Table 组件都是分页的,所以并不会有性能问题。但是在某个特殊场景下:基于关键词的搜索,可能会出现 200 * 20 条结果且不分页的情况,且表格是有一列是带有 checkbox 的,也就是可以选中某些行进行操作。


当我们去点选其中一行时,发现过了好久才选中,有明显的卡顿感,而之前的 jQuery 版本却没有这类问题,这一比较令人大跌眼镜。难道好好的技术重构,却要牺牲用户体验吗?


Table 组件第一次优化尝试


既然有性能问题,那么我们的第一时间的思路应该是要找出产生性能问题的原因。


列展示优化


首先,ZoomUI 渲染的 DOM 数量是要多于 jQuery 渲染的 Table 的,因此第一个思考方向是让 Table 组件尽可能地减少 DOM 的渲染数量


20 列数据通常在屏幕下是展示不全的,老的 jQuery Table 实现很简单,底部有滚动条,而 ZoomUI 在这种列可滚动的场景下,支持了左右列的固定,这样在左右滑动过程中,可以固定某些列一直展示,用户体验更好,但这样的实现是有一定代价的。


想要实现这种固定列的布局,ElementUI 用了 6 个 table 标签来实现,那么为什么需要 6 个 table 标签呢?


首先,为了让 Table 组件支持丰富的表头功能,表头和表体都是各自用一个 table 标签来实现。因此对于一个表格来说,就会有 2 个 table 标签,那么再加上左侧 fixed 的表格,和右侧 fixed 的表格,总共有 6 个 table 标签。


在 ElementUI 实现中,左侧 fixed 表格和右侧 fixed 表格从 DOM 上都渲染了完整的列,然后从样式上控制它们的显隐:


element.png


element1.png


但这么实现是有性能浪费的,因为完全不需要渲染这么多列,实际上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。ZoomUI 就是这么实现的,效果如下:


zoom-ui.png
当然,仅仅减少 fixed 表格渲染的列,性能的提升还不够明显,有没有办法在列的渲染这个维度继续优化呢?


这就是从业务层面的优化了,对于一个 20 列的表格,往往关键的列并没有多少,那么我们可不可以初次渲染仅仅渲染关键的列,其它列通过配置方式的渲染呢?


根据上述需求,我给 Table 组件添加了如下功能:


zoom-ui1.png


Table 组件新增一个 initDisplayedColumn 属性,通过它可以配置初次渲染的列,同时当用户修改了初次渲染的列,会在前端存储下来,便于下一次的渲染。


通过这种方式,我们就可以少渲染一些列。显然,列渲染少了,表格整体渲染的 DOM 数就会变少,对性能也会有一定的提升。


更新渲染的优化


当然,仅仅通过优化列的渲染还是不够的,我们遇到的问题是当点选某一行引起的渲染卡顿,为什么会引起卡顿呢?


为了定位该问题,我用 Table 组件创建了一个 1000 * 7 的表格,开启了 Chrome 的 Performance 面板记录 checkbox 点选前后的性能。


在经过几次 checkbox 选择框的点选后,可以看到如下火焰图:


element2.png


其中黄色部分是 Scripting 脚本的执行时间,紫色部分是 Rendering 所占的时间。我们再截取一次更新的过程:


element3.png


然后观察 JS 脚本执行的 Call Tree,发现时间主要花在了 Table 组件的更新渲染上


element4.png


我们发现组件的 render to vnode 花费的时间约 600ms;vnode patch to DOM 花费的时间约 160ms。


为什么会需要这么长时间呢,因为点选了 checkbox,在组件内部修改了其维护的选中状态数据,而整个组件的 render 过程中又访问了这个状态数据,因此当这个数据修改后,会引发整个组件的重新渲染。


而又由于有 1000 * 7 条数据,因此整个表格需要循环 1000 * 7 次去创建最内部的 td,整个过程就会耗时较长。


那么循环的内部是不是有优化的空间呢?对于 ElementUI 的 Table 组件,这里有非常大的优化空间。


其实优化思路主要参考我之前写的 《揭秘 Vue.js 九个性能优化技巧》 其中的 Local variables 技巧。举个例子,在 ElementUI 的 Table 组件中,在渲染每个 td 的时候,有这么一段代码:


const data = {
store: this.store,
_self: this.context || this.table.$vnode.context,
column: columnData,
row,
$index
}

这样的代码相信很多小伙伴随手就写了,但却忽视了其内部潜在的性能问题。


由于 Vue.js 响应式系统的设计,在每次访问 this.store 的时候,都会触发响应式数据内部的 getter 函数,进而执行它的依赖收集,当这段代码被循环了 1000 * 7 次,就会执行 this.store 7000 次的依赖收集,这就造成了性能的浪费,而真正的依赖收集只需要执行一次就足够了。


解决这个问题其实也并不难,由于 Table 组件中的 TableBody 组件是用 render 函数写的,我们可以在组件 render 函数的入口处定义一些局部变量:


render(h) {
const { store /*...*/} = this
const context = this.context || this.table.$vnode.context
}

然后在渲染整个 render 的过程中,把局部变量当作内部函数的参数传入,这样在内部渲染 td 的渲染中再次访问这些变量就不会触发依赖收集了:


rowRender({store, context, /* ...其它变量 */}) {
const data = {
store: store,
_self: context,
column: columnData,
row,
$index,
disableTransition,
isSelectedRow
}
}

通过这种方式,我们把类似的代码都做了修改,就实现了 TableBody 组件渲染函数内部访问这些响应式变量,只触发一次依赖收集的效果,从而优化了 render 的性能。


来看一下优化后的火焰图:


zoom-ui2.png


从面积上看似乎 Scripting 的执行时间变少了,我们再来看它一次更新所需要的 JS 执行时间:


zoom-ui3.png


我们发现组件的 render to vnode 花费的时间约 240ms;vnode patch to DOM 花费的时间约 127ms。


可以看到,ZoomUI Table 组件的 render 的时间和 update 的时间都要明显少于 ElementUI 的 Table 组件。render 时间减少是由于响应式变量依赖收集的时间大大减少,update 的时间的减少是因为 fixed 表格渲染的 DOM 数量减少。


从用户的角度来看,DOM 的更新除了 Scripting 的时间,还有 Rendering 的时间,它们是共享一个线程的,当然由于 ZoomUI Table 组件渲染的 DOM 数量更少,执行 Rendering 的时间也更短。


手写 benchmark


仅仅从 Performance 面板的测试并不是一个特别精确的 benchmark,我们可以针对 Table 组件手写一个 benchmark。


我们可以先创建一个按钮,去模拟 Table 组件的选中操作:


<div>
<zm-button @click="toggleSelection(computedData[1])
">切换第二行选中状态
</zm-button>
</div>
<div>
更新所需时间: {{ renderTime }}
</div>

然后实现这个 toggleSelection 函数:


methods: {
toggleSelection(row) {
const s = window.performance.now()
if (row) {
this.$refs.table.toggleRowSelection(row)
}
setTimeout(() => {
this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
})
}
}

我们在点击事件的回调函数中,通过 window.performance.now() 记录起始时间,然后在 setTimeout 的回调函数中,再去通过时间差去计算整个更新渲染需要的时间。


由于 JS 的执行和 UI 渲染占用同一线程,因此在一个宏任务执行过程中,会执行这俩任务,而 setTimeout 0 会把对应的回调函数添加到下一个宏任务中,当该回调函数执行,说明上一个宏任务执行完毕,此时做时间差去计算性能是相对精确的。


基于手写的 benchmark 得到如下测试结果:


element5.png


ElementUI Table 组件一次更新的时间约为 900ms。


zoom-ui4.png


ZoomUI Table 组件一次更新的时间约为 280ms,相比于 ElementUI 的 Table 组件,性能提升了约三倍


v-memo 的启发


经过这一番优化,基本解决了文章开头提到的问题,在 200 * 20 的表格中去选中一列,已经并无明显的卡顿感了,但相比于 jQuery 实现的 Table,效果还是要差了一点。


虽然性能优化了三倍,但我还是有个心结:明明只更新了一行数据的选中状态,却还是重新渲染了整个表格,仍然需要在组件 render 的过程中执行多次的循环,在 patch 的过程中通过 diff 算法来对比更新。


最近我研究了 Vue.js 3.2 v-memo 的实现,看完源码后,我非常激动,因为发现这个优化技巧似乎可以应用到 ZoomUI 的 Table 组件中,尽管我们的组件库是基于 Vue 2 版本开发的。


我花了一个下午的时间,经过一番尝试,果然成功了,那么具体是怎么做的呢?先不着急,我们从 v-memo 的实现原理说起。


v-memo 的实现原理


v-memo 是 Vue.js 3.2 版本新增的指令,它可以用于普通标签,也可以用于列表,结合 v-for 使用,在官网文档中,有这么一段介绍:



v-memo 仅供性能敏感场景的针对性优化,会用到的场景应该很少。渲染 v-for 长列表 (长度大于 1000) 可能是它最有用的场景:



<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>


当组件的 selected 状态发生变化时,即使绝大多数 item 都没有发生任何变化,大量的 VNode 仍将被创建。此处使用的 v-memo 本质上代表着“仅在 item 从未选中变为选中时更新它,反之亦然”。这允许每个未受影响的 item 重用之前的 VNode,并完全跳过差异比较。注意,我们不需要把 item.id 包含在记忆依赖数组里面,因为 Vue 可以自动从 item:key 中把它推断出来。



其实说白了 v-memo 的核心就是复用 vnode,上述模板借助于在线模板编译工具,可以看到其对应的 render 函数:


import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

基于 v-for 的列表内部是通过 renderList 函数来渲染的,来看它的实现:


function renderList(source, renderItem, cache, index) {
let ret
const cached = (cache && cache[index])
if (isArray(source) || isString(source)) {
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
}
else if (typeof source === 'number') {
// source 是数字
}
else if (isObject(source)) {
// source 是对象
}
else {
ret = []
}
if (cache) {
cache[index] = ret
}
return ret
}

我们只分析 source,也就是列表 list 是数组的情况,对于每一个 item,会执行 renderItem 函数来渲染。


从生成的 render 函数中,可以看到 renderItem 的实现如下:


(item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}

renderItem 函数内部,维护了一个 _memo 变量,它就是用来判断是否从缓存里获取 vnode 的条件数组;而第四个参数 _cached 对应的就是 item 对应缓存的 vnode。接下来通过 isMemoSame 函数来判断 memo 是否相同,来看它的实现:


function isMemoSame(cached, memo) {
const prev = cached.memo
if (prev.length != memo.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== memo[i]) {
return false
}
}
// ...
return true
}

isMemoSame 函数内部会通过 cached.memo 拿到缓存的 memo,然后通过遍历对比每一个条件来判断和当前的 memo 是否相同。


而在 renderItem 函数的结尾,就会把 _memo 缓存到当前 itemvnode 中,便于下一次通过 isMemoSame 来判断这个 memo 是否相同,如果相同,说明该项没有变化,直接返回上一次缓存的 vnode


那么这个缓存的 vnode 具体存储到哪里呢,原来在初始化组件实例的时候,就设计了渲染缓存:


const instance = {
// ...
renderCache: []
}

然后在执行 render 函数的时候,把这个缓存当做第二个参数传入:


const { renderCache } = instance
result = normalizeVNode(
render.call(
proxyToUse,
proxyToUse,
renderCache,
props,
setupState,
data,
ctx
)
)

然后在执行 renderList 函数的时候,把 _cahce 作为第三个参数传入:


export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
// renderItem 实现
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}

所以实际上列表缓存的 vnode 都保留在 _cache 中,也就是 instance.renderCache 中。


那么为啥使用缓存的 vnode 就能优化 patch 过程呢,因为在 patch 函数执行的时候,如果遇到新旧 vnode 相同,就直接返回,什么也不用做了。


const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
if(n1 === n2) {
return
}
// ...
}

显然,由于使用缓存的 vnode,它们指向同一个对象引用,直接返回,节约了后续执行 patch 过程的时间。


在 Table 组件的应用


v-memo 的优化思路很简单,就是复用缓存的 vnode,这是一种空间换时间的优化思路。


那么,前面我们提到在表格组件中选择状态没有变化的行,是不是也可以从缓存中获取呢?


顺着这思路,我给 Table 组件设计了 useMemo 这个 prop,它其实是专门用于有选择列的场景。


然后在 TableBody 组件的 created 钩子函数中,创建了用于缓存的对象:


created() {
if (this.table.useMemo) {
if (!this.table.rowKey) {
throw new Error('for useMemo, row-key is required.')
}
this.vnodeCache = []
}
}

这里之所以把 vnodeCache 定义到 created 钩子函数中,是因为它并不需要变成响应式对象。


另外注意,我们会根据每一行的 key 作为缓存的 key,因此 Table 组件的 rowKey 属性是必须的。


然后在渲染每一行的过程中,添加了 useMemo 相关的逻辑:


function rowRender({ /* 各种变量参数 */}) {
let memo
const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
let cached
if (useMemo) {
cached = this.vnodeCache[key]
const currentSelection = store.states.selection
if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
return cached
}
memo = currentSelection.slice()
}
// 渲染 row,返回对应的 vnode
const ret = rowVnode
if (useMemo && columns.length) {
ret.memo = memo
this.vnodeCache[key] = ret
}
return ret
}

这里的 memo 变量用于记录已选中的行数据,并且它也会在函数最后存储到 vnodememo,便于下一次的比对。


在每次渲染 rowvnode 前,会根据 row 对应的 key 尝试从缓存中取;如果缓存中存在,再通过 isRowSelectionChanged 来判断行的选中状态是否改变;如果没有改变,则直接返回缓存的 vnode


如果没有命中缓存或者是行选择状态改变,则会去重新渲染拿到新的 rowVnode,然后更新到 vnodeCache 中。


当然,这种实现相比于 v-memo 没有那么通用,只去对比行选中的状态而不去对比其它数据的变化。你可能会问,如果这一行某列的数据修改了,但选中状态没变,再走缓存不就不对了吗?


确实存在这个问题,但是在我们的使用场景中,遇到数据修改,是会发送一个异步请求到后端,然获取新的数据再来更新表格数据。因此我只需要观测表格数据的变化清空 vnodeCache 即可:


watch: {
'store.states.data'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
}
}

此外,我们支持列的可选则渲染功能,以及在窗口发生变化时,隐藏列也可能发生变化,于是在这两种场景下,也需要清空 vnodeCache


watch:{
'store.states.columns'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
},
columnsHidden(newVal, oldVal) {
if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
this.vnodeCache = []
}
}
}

以上实现就是基于 v-memo 的思路实现表格组件的性能优化。我们从火焰图上看一下它的效果:


zoom-ui6.png


我们发现黄色的 Scripting 时间几乎没有了,再来看它一次更新所需要的 JS 执行时间:


zoom-ui7.png
我们发现组件的 render to vnode 花费的时间约 20ms;vnode patch to DOM 花费的时间约 1ms,整个更新渲染过程,JS 的执行时间大幅减少。


另外,我们通过 benchmark 测试,得到如下结果:


zoom-ui5.png
优化后,ZoomUI Table 组件一次更新的时间约为 80ms,相比于 ElementUI 的 Table 组件,性能提升了约十倍


这个优化效果还是相当惊人的,并且从性能上已经不输 jQuery Table 了,我两年的心结也随之解开了。


总结


Table 表格性能提升主要是三个方面:减少 DOM 数量、优化 render 过程以及复用 vnode。有些时候,我们还可以从业务角度思考,去做一些优化。


虽然 useMemo 的实现还比较粗糙,但它目前已满足我们的使用场景了,并且当数据量越大,渲染的行列数越多,这种优化效果就越明显。如果未来有更多的需求,更新迭代就好。


由于一些原因,我们公司仍然在使用 Vue 2,但这并不妨碍我去学习 Vue 3,了解它一些新特性的实现原理以及设计思想,能让我开拓不少思路。


从分析定位问题到最终解决问题,希望这篇文章能给你在组件的性能优化方面提供一些思路,并应用到日常工作中。


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

收起阅读 »

vue3+typescript 实现一个中秋RPG游戏

前言 又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性....
继续阅读 »

前言


又到了周末时光,在家闲着没事,花了两天时间去构思并制作一个中秋节相关的页面,首先技术栈接地气并且跟的上目前的新技术,所以我考虑使用Vue3+Typescript,其次是中秋主题,我想到的是嫦娥奔月的故事,既然是嫦娥奔月的话,那么页面就得有趣味性和游戏性. 所以我最后选择做类似这种风格的页面.


ChMkJ1tpIkuINzThAAUqitDPvVkAAqf4wHB5i0ABSqi715.jpg


选择好了技术栈和制作主题和风格. 就直接开干了. 肝了一天, 以下是制作完成后的成果


GIF.gif


先说一下剧本,这个剧本是春光灿烂猪八戒后羿(二牛)嫦娥的人物角色加上东成西就大理段王爷飞升桥段. 还有最后一个鬼畜飞升的效果,我先说一下,这个是实在没找到可用的素材,只能凑合的用网上找来的这个动画. o(╥﹏╥)o 好了, 那么就开始说说, 我是怎么实现这个类游戏的页面动画效果的.


页面组织结构


页面使用vite创建出来, 文件的结构是这样的


image.png


由于页面只有一个场景,所以整个页面是放在APP.vue中写的. interface文件夹存放定义的一些接口对象. 组件里边划分出来了4个组件, 依次是



  1. dialogBox: 底部对话框组件

  2. lottie: 输入咒语后的一个彩蛋爆炸效果组件

  3. sprite 精灵图动画组件

  4. typed 输入咒语的打字效果组件


那么我们就按照页面出现的动画效果依次去讲一下吧.


精灵图动画


页面开头首先是二牛角色从左边走上桥头的动画. 这个动画我们先来分析一下, 首先是帧动画, 也就是走路的这个动作的效果, 其次是从左边走上桥头的这个位移动画. 那么我们先说一下帧动画


帧动画



“逐帧动画是一种常见的动画形式(Frame By Frame),其原理是在“连续的关键帧”中分解动画动作,也就是在时间轴的每帧上逐帧绘制不同的内容,使其连续播放而成动画



image.png


用我这个项目举例, 二牛走路的动画其实是一张图片在我们前端这张图也叫雪碧图,图上有4个动作,4个动作在不停的切换的时候,在我们人眼中就形成了走路的动效了. 好的,原理解释清楚了,那么我们现在看一下代码


  <div ref="spriteBox">
<div ref="sprite" class="sprite"></div>
</div>

页面的结构很简单, 就三行html代码, 外边包裹的html其实是用来做位移动画用的, 里边的sprite就是做帧动画的. 下面我们看一下javascript代码


// 样式位置
export interface positionInterface {
left?: string,
top?: string,
bottom?: string,
right?: string
}

export interface spriteInterface {
length: number, // 精灵图的长度
url: string, // 图片的路径
width: number, // 图片的宽度
height: number, // 图片的高度
scale?: number, // 缩放
endPosition: positionInterface // 动画结束站的位置
}

import { Ref } from "vue";
import { positionInterface, spriteInterface } from "../../interface";

/**
* 精灵图实现逐帧动画
* @param spriteObj 精灵对象
* @param target 精灵节点
* @param wrap 精灵父节点 [控制精灵移动]
* @param callback 图片加载好回调函数
* @param moveCallback 移动到对应位置的回调函数
*/
export function useFrameAnimation(
spriteObj: spriteInterface,
target: Ref,
wrap: Ref,
callback: Function,
moveCallback: Function
) {
const { width, length, url, endPosition } = spriteObj;
let index = 0;

var img = new Image();
img.src = url;
img.addEventListener("load", () => {
let time;
(function autoLoop() {
callback && callback();
// 如果到达了指定的位置的话,则停止
if (isEnd(wrap, endPosition)) {
if (time) {
clearTimeout(time);
time = null;
moveCallback && moveCallback();
return;
}
}
if (index >= length) {
index = 0;
}
target.value.style.backgroundPositionX = -(width * index) + "px";
index++;
// 使用setTimeout, requestFrameAnimation 是60HZ进行渲染,部分设备会卡,使用setTimeout可以手动控制渲染时间
time = setTimeout(autoLoop, 160);
})();
});

// 走到了对应的位置
function isEnd(wrap, endPosition: positionInterface) {
let keys = Object.keys(endPosition);
for (let key of keys) {
if (window.getComputedStyle(wrap.value)[key] === endPosition[key]) {
return true;
}
}
return false;
}
}

参数


useFrameAnimation 这个帧动画的函数, 函数参数先传递精灵图的描述对象,它主要描述精灵图上是有几个动作组成的,图片的地址是多少,图片在DOM节点上的对象,以及移动到指定位置后,传递给调用函数的父级的回调函数. 其实在代码中的注释也描述的很清楚了.


图片加载


我们在使用这张图片做帧动画的时候,首先得在这张图片是加载好之后再去处理的. 所以我们得先new Image, 然后给它赋值上src, 然后监听它的load事件,


循环切换动画


在load事件句柄内, 写了一个loop循环切换图片的backgroundPositionX属性达到页面动作图片的切换,由于是循环动画,如果动画走到了最后一张图片的时候,得切回第一张图片


添加回调函数钩子


在图片加载完成的时候,回调一个callback函数,告诉外边图片已经加载完成了,如果有一些需要图片加载完成的事情做的话,可以在这个回调函数里边去写. 代码里边还有一个isEnd函数, 去判断位移动画是否已经完成,如果位移动画完成了的话,则停止帧动画的循环,让它静止下来成为一张图片. 然后再执行moveCallback告诉调用函数的父级,位移动画已经执行完成了. 这个函数大致做的事情就是这些了.


位移动画


位移动画就比较简单了, 我们先看下代码:


<script lang="ts">
import {
computed,
defineComponent,
defineEmit,
PropType,
reactive,
ref,
toRefs,
watchEffect,
} from "vue";
import { spriteInterface } from "../../interface";
import { useFrameAnimation } from "./useFrameAnimation";

export default defineComponent({
props: {
action: {
type: Boolean,
default: false,
},
spriteObj: Object as PropType<spriteInterface>,
},
defineEmit: ["moveEnd"],
setup(props, { emit }) {
const spriteBox = ref(null);
const sprite = ref({ style: "" });
const spriteObj = reactive(props.spriteObj || {}) as spriteInterface;
const { width, height, url, length } = toRefs(spriteObj);
watchEffect(() => {
if (props.action) {
useFrameAnimation(
spriteObj,
sprite,
spriteBox,
() => {
triggerMove();
},
() => {
emit("moveEnd");
}
);
}
});
// 给宽度后边加上单位
const widthRef = computed(() => {
return width.value + "px";
});
// 给高度后边加上单位
const heightRef = computed(() => {
return height.value + "px";
});
// 给背景图片连接添加url
const urlImg = computed(() => {
return `url("${url.value}")`;
});
// 移动到目标位置
function triggerMove() {
if (spriteObj.scale || spriteObj.scale === 0) {
spriteBox.value.style.transform = `scale(${spriteObj.scale})`;
}
if (spriteObj.endPosition) {
Object.keys(spriteObj.endPosition).forEach((o) => {
if (spriteBox.value && sprite.value.style) {
spriteBox.value.style[o] = spriteObj.endPosition[o];
}
});
}
}
return {
widthRef,
heightRef,
urlImg,
length,
sprite,
spriteBox,
triggerMove,
};
},
});
</script>

代码中主要的是这个watchEffect, 根据使用精灵组件传递的props.action去开始决定是否开始帧动画,在调用我们上一段讲的useFrameAnimation函数后,第四个参数回调函数是图片加载完成,图片加载完成的时候,我们可以在这里做位移动画,也就是triggerMove,triggerMove函数里实际上就是把在spriteObj配置好的一些位置以及缩放信息放到对应的DOM节点上,要说动画的话,其实是css去做的. 在监听到位移动画结束后,传递给父级一个moveEnd自定义事件.


<style lang="scss" scoped>
.sprite {
width: v-bind(widthRef);
height: v-bind(heightRef);
background-image: v-bind(urlImg);
background-repeat: no-repeat;
background-position: 0;
background-size: cover;
}
</style>

这里的css只描述了关于精灵图的宽度高度和图片路径,上边这种写法v-bind是vue3后可以使用的一种方式,这样就可以把动态的变量直接写在CSS里边了, 用过的都说好~ 关于精灵图真正的动画效果是写在了APP.vue里边的css里


  .boy {
position: absolute;
bottom: 90px;
left: 10px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}
.girl {
position: absolute;
bottom: 155px;
right: 300px;
transform: translate3d(0, 0, 0, 0);
transition: all 4s cubic-bezier(0.4, 1.07, 0.73, 0.72);
}

上面描述了二牛嫦娥的初始位置,以及动效.


对话框组件


二牛走到嫦娥旁边后,APP.vue就通过前面说的moveEnd自定义事件知晓了动画结束,然后在动画结束后,弹出对话框. 对话的话, 其实就得先想好一个对话的剧本以及对话剧本的格式了.


对话剧本


const dialogueContent = [
{
avatar: "/images/rpg_male.png",
content: "二牛:嫦娥你终于肯和我约会了, 哈哈",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:二牛对不起,我是从月宫来的,我不能和人间的你在一起!",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:今天是中秋节,我只有今天这个机会可以重新回月宫",
},
{
avatar: "/images/rpg_female.png",
content:
"嫦娥:回月宫的条件是找到真心人,让他念起咒语,我才能飞升!",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:而你就是我的真心人,你可以帮我嘛?",
},
{
avatar: "/images/rpg_male.png",
content: "二牛:好的,我明白了! 我会帮你的.",
},
{
avatar: "/images/rpg_female.png",
content: "嫦娥:好的。 谢谢你!",
},
];

以上就是我这个小游戏的剧本了, 因为是别人先说一段,我再说一段,或者别人说了一段,再接着说一段. 这种的话,就是直接按照对话顺序写下来就好了, 然后我们在代码里边就可以通过点击时间的交互来按照顺序一个一个展现出来. 对话的结构主要就人物头像人物内容, 这里我为了省事,把人物的名称也直接在内容里边展现出来, 其实如果需要的话,可以提出来.


结构


我们先看一下它的html结构


  <div v-if="isShow" class="rpg-dialog" @click="increase">
<img :src="dialogue.avatar" class="rpg-dialog__role" />
<div class="rpg-dialog__body">
{{ contentRef.value }}
</div>
</div>

结构其实也很简单,里边就是一个头像和内容,我们用isShow去控制对话框的显示隐藏,用increase去走到下一个对话内容里边.


逻辑实现


    function increase() {
dialogueIndex.value++;
if (dialogueIndex.value >= dialogueArr.length) {
isShow.value = false;
emit("close");
return;
}
// 把下个内容做成打字的效果
contentRef.value = useType(dialogue.value.content);
}

increase方法里边也很简单,点击后,申明的索引(默认是0开始)+1,如果索引等于剧本的长度了的时候, 就把对话框关掉,然后给APP.vue一个close自定义事件, 如果小于剧本的长度的话,则走到下一个剧本内容,并且以打字的效果呈现. 也就是useType方法.


/**
* 打字效果
* @param { Object } content 打字的内容
*/
export default function useTyped(content: string): Ref<string> {
let time: any = null
let i:number = 0
let typed = ref('_')
function autoType() {
if (typed.value.length < content.length) {
time = setTimeout(() =>{
typed.value = content.slice(0, i+1) + '_'
i++
autoType()
}, 200)
} else {
clearTimeout(time)
typed.value = content
}
}
autoType()
return typed
}

打字效果实现也很简单,默认给一个_,然后逐一拿到字符串的每一个字符,一个一个的加在新字符串后边. 如果拿到完整的字符串的时候,则停止循环.


打字框(咒语)组件


在结束了剧本后, APP.vue会拿到组件跑出来的close自定义事件,在这里面,我们可以把诅咒组件给显示出来,


结构


<div v-if="isShow" class="typed-modal">
<div class="typed-box">
<div class="typed-oldFont">{{ incantation }}</div>
<div
@input="inputChange"
ref="incantainerRef"
contenteditable
class="typed-font"
>
{{ font }}
</div>
</div>
</div>

诅咒组件,这里的html结构,我们可以看一下,里边用到了contenteditable这个属性,设置了这个属性后,div就可以变的和输入框类似,我们可以直接在div上面的文字上自由修改. 所以我们就需要在用户修改的时候,监听它的input事件. incantation 这个放的就是底部的提示咒语, font放的就是我们需要输入的咒语.


逻辑实现


export default defineComponent({
components: {
ClickIcon,
},
emits: ["completeOver"],
setup(props, { emit }) {
const isShow = ref(true);
const lottie = ref(null);
const incantainerRef = ref(null);
const defaultOption = reactive(defaultOptions);
const incantation = ref("Happy Mid-autumn Day");
let font = ref("_");

nextTick(() => {
incantainerRef.value.focus();
});

function inputChange(e) {
let text = e.target.innerText.replace("_", "");
if (!incantation.value.startsWith(text)) {
e.target.innerText = font.value;
} else {
if (incantation.value.length === text.length) {
emit("completeOver");
font.value = text;
isShow.value = false;
lottie.value.toggle();
} else {
font.value = text + "_";
}
}
}

return {
font,
inputChange,
incantation,
incantainerRef,
defaultOption,
lottie,
isShow,
};
},
});
</script>

在组件弹窗的时候,我们用incantainerRef.value.focus();让它自动获取焦点. 在inputChange事件里边, 我们去判断输入的咒语是否和提示的咒语相同,如果不同的话,则无法继续输入, 并停留在输入正确的咒语上, 如果都输入正确了的话, 则会自动关闭咒语弹窗,并弹出一个类似恭喜通过的烟花效果. 传入一个completeOver自定义事件给APP.vue.


页面主题APP.vue


页面的话,其实就像一个导演了. 接收到演员的各种回馈后, 然后安排下一个演员就位


  setup() {
let isShow = ref(false); // 对话框窗口开关
let typedShow = ref(false); // 咒语窗口开关
let girlAction = ref(false); // 女孩动作开关, 导演喊一句action后,演员开始演绎
const boy = reactive(boyData);
const girl = reactive(girlData);
const dialogueArr = reactive(dialogueContent);
// 男孩移动动画结束
function boyMoveEnd() {
isShow.value = true;
}
// 完成输入咒语
function completeOver() {
girlAction.value = true;
}
function girlMoveEnd() {}
// 对话窗口关闭
function dialogClose() {
// 对话框关闭后,弹出咒语的窗口,二牛输入咒语后,嫦娥开始飞仙动作
typedShow.value = true;
}
return {
dialogueArr,
boy,
girl,
isShow,
boyMoveEnd,
girlMoveEnd,
girlAction,
dialogClose,
typedShow,
completeOver,
};

大家看看就好,其实没啥特别好说的.


写在最后


关于那个烟花效果的话,我就不讲了,因为我上次的文章如何在vue中使用Lottie已经详细的讲清楚了每一个细节. 并且这一次的这个组件其实就是复用的我这篇文章讲的这个自己封装的组件. 基本的效果就这些,如果大家有兴趣的话,可以参照在我这个基础上再加入一些细节在里边. 比如添加云彩动效,添加水波动效等. 需要源码的可以点这里看看 充实的一天就是过得这么快呀~ 大家下次再见咯. 提前祝大家中秋节快乐!.



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

收起阅读 »

LeetCode第一讲:哈希表相关讲解

哈希表简单说明哈希表的建立需要有哈希地址,那么哈希地址地址的生成需要一个哈希函数,什么是哈希函数呢?哈希函数就是一个精心设计好的函数,该函数可以计算出存储的数据要放在什么位置,举个例子说明:例:有4条电话数据:王二蛋 12345678985李狗蛋 115544...
继续阅读 »

哈希表简单说明

哈希表的建立需要有哈希地址,那么哈希地址地址的生成需要一个哈希函数,什么是哈希函数呢?
哈希函数就是一个精心设计好的函数,该函数可以计算出存储的数据要放在什么位置,举个例子说明:

例:有4条电话数据:
王二蛋 12345678985
李狗蛋 11554456555
赵二狗 18816848615
李桂花 15899484538

如果我想查找王二蛋的电话,我需要拿出这个列表,一个一个找。但我想要通过名字快速查找王二蛋如何做呢?

答:我构建一个哈希表,来快速查找。那么通过名字来存的话,我需要构建一套规则来定位数据的存储位置。那么我构建如下的函数 Addr = H(”姓名“的首字母 ASCII - 65 ),需要一个32大小数组来存储数据。

但是如果按照刚刚的设计,那么”李桂花“与”李狗蛋“的存储就会发生冲突,那么在设计哈希函数有如下几种方式(对于哈希表而言,冲突只能尽可能地少,无法完全避免。)

哈希表构建

1、直接定址法
例如:有一个从1到100岁的人口数字统计表,其中,年龄作为关键字,哈希函数取关键字自身。
2、数字分析法
有学生的生日数据如下:
年.月.日
75.10.03
75.11.23
76.03.02
76.07.12
75.04.21
76.02.15

经分析,第一位,第二位,第三位重复的可能性大,取这三位造成冲突的机会增加,所以尽量不取前三位,取后三位比较好。
3、平方取中法
取关键字平方后的中间几位为哈希地址。
4、折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可以不同),然后取这几部分的叠加和(舍去进位)作为哈希地址,这方法称为折叠法。
例如:每一种西文图书都有一个国际标准图书编号,它是一个10位的十进制数字,若要以它作关键字建立一个哈希表,当馆藏书种类不到10,000时,可采用此法构造一个四位数的哈希函数。
5、除留余数法
取关键字被某个不大于哈希表表长m的数p除后所得余数为哈希地址。
H(key)=key MOD p (p<=m)
6、随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即
H(key)=random(key),其中random为随机函数。通常用于关键字长度不等时采用此法。
若已知哈希函数及冲突处理方法,哈希表的建立步骤如下:
Step1. 取出一个数据元素的关键字key,计算其在哈希表中的存储地址D=H(key)。若存储地址为D的存储空间还没有被占用,则将该数据元素存入;否则发生冲突,执行Step2。
Step2. 根据规定的冲突处理方法,计算关键字为key的数据元素之下一个存储地址。若该存储地址的存储空间没有被占用,则存入;否则继续执行Step2,直到找出一个存储空间没有被占用的存储地址为止。

冲突处理

1、拉链法
拉出一个动态链表代替静态顺序存储结构,可以避免哈希函数的冲突,不过缺点就是链表的设计过于麻烦,增加了编程复杂度。此法可以完全避免哈希函数的冲突。
2、多哈希法
设计二种甚至多种哈希函数,可以避免冲突,但是冲突几率还是有的,函数设计的越好或越多都可以将几率降到最低(除非人品太差,否则几乎不可能冲突)。
3、开放地址法
开放地址法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)
其中,m为哈希表的表长。di 是产生冲突的时候的增量序列。如果di值可能为1,2,3,…m-1,称线性探测再散列。
如果di取1,则每次冲突之后,向后移动1个位置.如果di取值可能为1,-1,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2)
称二次探测再散列。如果di取值可能为伪随机数列。称伪随机探测再散列。
4、建域法
假设哈希函数的值域为[0,m-1],则设向量HashTable[0…m-1]为基本表,另外设立存储空间向量OverTable[0…v]用以存储发生冲突的记录。

题目1:

给定两个数组,编写一个函数来计算它们的交集。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]

示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[9,4]

说明:
输出结果中的每个元素一定是唯一的。
我们可以不考虑输出结果的顺序。

题目地址:https://leetcode-cn.com/problems/intersection-of-two-arrays
来源:力扣(LeetCode)

题目解答:

public static int[] intersection(int[] nums1, int[] nums2) {
Set<Integer> set = new HashSet<>();
for(int i :nums1){
set.add(i);
}
Set<Integer> set2 = new HashSet<>();
for(int j:nums2){
if(set.contains(j)){
set2.add(j);
}
}
int[] st = new int[set2.size()];
Object[] kk = set2.toArray();
for(int i=0;i<kk.length;i++){
st[i] = (int)kk[i];
}
return st;
}

解答说明:
该题由于要算交集,数组还有重复的可能行,那么就采用java中有的HashSet来处理问题,HashSet底层源码是实现了一个hashMap,HashMap实际上就是哈希表的实现,那么可以直接用hash表的特点来去重,再由hash表的特点来查询交集。算法实际时间复杂度为O(m+n) 空间复杂度:O(m+n)

题目2:

写代码,移除未排序链表中的重复节点。保留最开始出现的节点。
示例1:
输入:[1, 2, 3, 3, 2, 1]
输出:[1, 2, 3]

示例2:
输入:[1, 1, 1, 1, 2]
输出:[1, 2]
提示:

链表长度在[0, 20000]范围内。
链表元素在[0, 20000]范围内。
题目地址:https://leetcode-cn.com/problems/remove-duplicate-node-lcci
来源:力扣(LeetCode)

题目解答:

public ListNode removeDuplicateNodes(ListNode head) {
Set<Integer> set = new HashSet<>();
ListNode root = head;
ListNode temp = head;
ListNode pre = null;
while (temp!=null){
if(set.contains(temp.val)){
temp = temp.next;
pre.next = temp;
}else{
set.add(temp.val);
pre = temp;
temp = temp.next;
}
}
return root;
}

解答说明:
采用hash表的hash唯一性来做缓冲区,时间复杂度:O(n) 空间复杂度:O(n)

收起阅读 »

面试再也不怕 Handler 了,消息传递机制全解析

一、为什么要使用 Handler众所周知,Android 不允许在子线程中更新 UI。但是我们在子线程完成耗时的操作之后,需要对界面数据进行更新,又该怎么处理呢?这时候,我们可以使用 Handler 进行 UI 更新。值得注意的是,更新 UI 我们需要把 Me...
继续阅读 »

一、为什么要使用 Handler

众所周知,Android 不允许在子线程中更新 UI。但是我们在子线程完成耗时的操作之后,需要对界面数据进行更新,又该怎么处理呢?这时候,我们可以使用 Handler 进行 UI 更新。值得注意的是,更新 UI 我们需要把 Message 发送到主线程持有的 MessageQueue ,否则程序依然就会发生奔溃。

另外,除了更新 UI,Handler 是 Android 系统的消息传递机制,它定义了一套处理消息的规则,广播、服务以及线程间的通信都需要靠它来完成。

与 Handler 相关的还有 Looper 和 MessageQueue,接下来我们就从它的使用开始分析,对这三剑客一网打尽。

二、Handler 发送消息的流程

Handler 发送消息有两种方式,一种是 sendMessage 的方式,一种是 post 的方式,通过对源码的阅读,post 的方式其实是调用到了 sendMessage 的方式。那我们就来看看 sendMessage 的流程吧。通过调用 sendMessage,最终会走到下面方法中:

image.png

这里做的事情很简单,必须满足 MessageQueue 不能为空,否则程序会抛出异常,接下来看 enqueueMessage 的流程:

image.png

在这里完成了两个重要的流程:

  • 为 msg 的 target 赋值,msg.target = this,因此这个 target 就是调用的 sendMessage 的 Handler。(记住这里的重点)
  • 调用了 MessageQueue 的 enqueueMessage 方法。

到目前为止,流程来到了 MessageQueue 中。现在看 MessageQueue 的 enqueueMessage 方法。

三、MessageQueue 的工作流程

由于 enqueueMessage 的方法比较长,我们这里不截图,直接看下面的代码:(省略部分代码)

boolean enqueueMessage(Message msg, long when) {
// 1、target 不能为空,否则直接抛出异常
if (msg.target == null) {
throw new IllegalArgumentException("Message must have a target.");
}
// 2、加锁,不能有多个 Handler 同时发送消息
synchronized (this) {
msg.when = when;
Message p = mMessages; // 出队列的 msg 的下一个要出队列的 msg
boolean needWake;
// 3、下面这三种情况直接插在 head 节点上,(1)这个队列是一个空队列,
// (2)这个 msg 需要立即处理,(3)是它需要处理的时间比即将出队列的节
// 点的处理时间还要小
if (p == null || when == 0 || when < p.when) {
// New head, wake up the event queue if blocked.
msg.next = p;
mMessages = msg;
needWake = mBlocked;
} else {
needWake = mBlocked && p.target == null && msg.isAsynchronous();

// 4、如果之前第三点的条件不满足,就会从 head 节点开始遍历,
// 插入到一个合适的时间,或者链表的尾部,这个 for 循环做的其实就是
// 链表节点的插入
Message prev;
for (;;) {
prev = p;
p = p.next;
if (p == null || when < p.when) {
break;
}
if (needWake && p.isAsynchronous()) {
needWake = false;
}
}
msg.next = p; // invariant: p == prev.next
prev.next = msg;
}
// 5、是否需要进行唤醒,在 queue.next() 方法中如果没有获取到 msg就会休眠
if (needWake) {
nativeWake(mPtr);
}
}
return true;
}

解释其实已经在上面的代码里了,下面来做一个简单归纳:

  • MessageQueue 其本质上一个单向链表,入队列这个操作进行了加锁的处理,不能多个 msg 同时入队列。
  • 在插入队列的时候,会根据当前队列是否为空,或者处理消息的时间选择合适的插入位置。
  • 最后判断是否需要进行 wake up

到目前为止,我们看了 Handler 的发送消息的流程,以及消息是如何插入链表的,那么消息是如何处理的呢?我们知道,只有调用了 Looper 的 loop() 方法之后,才能处理消息,那接下来看 Looper 的 loop() 方法。

四、Looper 的工作流程

Looper 的 loop() 方法也是相当长,接下来看代码:(省略部分代码)

public static void loop() {
// 1、获取 Looper 对象,定进行判空处理
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}

// 2、获取了 MessageQueue 对象
final MessageQueue queue = me.mQueue;
for (;;) {
// 3、调用 MessageQueue 的 next(),返回值是 msg
Message msg = queue.next(); // might block
if (msg == null) {
return;
}
....
try {
// 4、之前说过,在 SendMessage 的时候设置了 msg 的target,这个 target 就是调用 sendMessage 的 Handler
msg.target.dispatchMessage(msg);
} catch (Exception exception) {
} finally {
}

msg.recycleUnchecked();
}
}

代码本身很长,但是其实做的事情也不多,现在简单归纳一下:

  • 在调用 Looper.loop() 之前,必须先调用 Looper.prepare(),如果没有 Looper 对象的话程序会直接抛异常。
  • 通过调用 MessageQueue 的 next 方法不断的从队列里取消息出来。
  • 最后把 msg 交给 Handler 的 dispatchMessage() 进行处理。

通过源码我们可以发现调用 queue.next() 时可能发生阻塞,那这个方法又做了什么?还有,为什么要先调用 Looper.prepare(),这个方法又做了什么处理?先来看比较简单的吧:

image.png

这个 Looper.prepare() 其实是创建了一个 Looper 对象,并且通过 ThreadLocal 实现每个线程有且仅有一个这样的 Looper 对象。为什么要创建 Looper 呢?没有就不行吗?我们来看 Handler 的构造函数:

image.png

可以看到,如果 Looper 为空的话,程序直接抛异常。这个 myLooper() 是用来获取当前线程的 Looper 对象:

image.png

从时序上说,我们调用 Looper.prepare() 的时机必须在 new Handler() 之前。那么,我们主线程使用 Handler 的时候,并没有调用 Looper.prepare() 这个方法,这又是怎么回事呢?

原来,在 ActivityThread 的 main() 方法中已经为我们进行了处理:

image.png

这个 prepareMainLooper() 在内部调用了 Looper.prepare() 。到目前为止,我们解决了 Looper 的相关问题,说明了必须存在 Looper 的原因。现在还有一个问题没有解决,queue.next() 方法做了什么事情?它为什么发生阻塞呢?

接下来看 MessageQueue 的 next() 方法:(已省略部分代码)

Message next() {

final long ptr = mPtr;
if (ptr == 0) {
return null;
}

int pendingIdleHandlerCount = -1;
int nextPollTimeoutMillis = 0;
for (;;) {
if (nextPollTimeoutMillis != 0) {
Binder.flushPendingCommands();
}
// 1、这是一个 native 方法,如果messageQueue 没有可以处理的消息就会休眠
nativePollOnce(ptr, nextPollTimeoutMillis);
synchronized (this) {
final long now = SystemClock.uptimeMillis();
Message prevMsg = null;
Message msg = mMessages;
if (msg != null && msg.target == null) {
// 2、同步屏障,寻找队列中的下一个异步消息
do {
prevMsg = msg;
msg = msg.next;
} while (msg != null && !msg.isAsynchronous());
}
if (msg != null) {
if (now < msg.when) {
// 3、下一个出队列的这个 msg 还没有到时间,并计算需要阻塞的时间
nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
} else {
// 4、得到一个能够处理的msg,并返回这个 msg
mBlocked = false;
if (prevMsg != null) {
prevMsg.next = msg.next;
} else {
mMessages = msg.next;
}
msg.next = null;
if (DEBUG) Log.v(TAG, "Returning message: " + msg);
msg.markInUse();
return msg;
}
} else {
// No more messages.
nextPollTimeoutMillis = -1;
}
...
}
}
}

重要的点其实已经在上面说了,下面总结一下:

  • 获取 msg 的这个过程有可能会发生阻塞,具体调用到的是 native 的 nativePollOnce 方法
  • 获取消息的时候,有一个同步屏障,也就是对 msg 对应的 target(Handler) 为空的消息进行了过滤。
  • 如果能获取到一个 msg ,那么就返回这个 msg。

四、再看 Handler

先来梳理一下我们现在明白了什么:

  • 在创建 Handler 的时候,必须先创建 Looper 对象,之后还需要调用 Looper.loop() 方法才能让 Handler 开始工作。
  • 通过 Handler sendMessage 发送消息,其实是调用了 queue.enqueueMessage,这个 Queue 其实是一个单向链表,在调用这个方法的时候,会根据当前队列的转态以及 when 把这个 msg 插入到合适的位置。
  • queue.next() 可能会发生休眠,原因是拿到不到合适的 msg,在 queue.enqueueMessgae 的时候会判断是否需要唤醒。

之前我们说过,这个 msg 其实是交给了 Handler 的 dispatchMessage 去处理,下面来看一下 Handler 是怎么处理的:

image.png

  • msg.callback 是我们通过 post 方法传递进来的一个 Runnable 对象,如果我们没有使用 post 的话,就不会走到 handleCallback(msg) 中。
  • mCallback 是一个 CallBack 对象,如果我们在创建 Handler 的时候没有传这个参数,那么 mCallback 也是为null 的。
  • 最后才会走到 handleMessage(msg) 中。

收起阅读 »

在android中如何制作一个方向轮盘

先上效果图原理很简单,其实就是一个自定义的view通过观察,很容易发现,我们自己的轮盘就两个view需要绘制,一个是外面的圆盘,一个就随手指移动的滑块; 外面的圆盘很好绘制,内部的滑块则需要采集手指的位置,根据手指的位置计算出滑块在大圆内的位置; 最后,我们做...
继续阅读 »

先上效果图

Screenrecorder-2021-09-13-09-55-26-155.gif

原理很简单,其实就是一个自定义的view

通过观察,很容易发现,我们自己的轮盘就两个view需要绘制,一个是外面的圆盘,一个就随手指移动的滑块; 外面的圆盘很好绘制,内部的滑块则需要采集手指的位置,根据手指的位置计算出滑块在大圆内的位置; 最后,我们做的UI不是单纯做一个UI吧,肯定还是要用于实际应用中去,所以要加一个通用性很好的回调.

计算滑块位置的原理:

  • 当触摸点在大圆与小圆的半径差之内:
    那么滑块的位置就是触摸点的位置
  • 当触摸点在大圆与小圆的半径差之外:
    已知大圆圆心坐标(cx,cy),大圆半径rout,小圆半径rinside,触摸点的坐标(px,py)
    求小圆的圆心(ax,ay)?

image.png

作为经过九义的你我来说,这不就是一个简简单单的数学题嘛,很容易就求解出小圆的圆心位置了。 利用三角形相似:
\frac{ax-cx}{rout-rinside} = \frac{px-cx}{\sqrt{(px-cx)^2+(py-cy)^2}}
\frac{ay-cy}{rout-rinside} = \frac{py-cy}{\sqrt{(px-cx)^2+(py-cy)^2}}

通用性很好的接口:

滑块在圆中的位置,可以很好的用一个二位向量来表示,也可以用两个浮点的变量来表示;
xratio = \frac{ax-cx}{rout-rinside}
yratio = \frac{ay-cy}{rout-rinside}

这个接口就可以很好的表示了小圆在大圆的位置了,他们的取值范围是[-1,1]

小技巧:

为了小圆能始终在脱手后回到终点位置,我们设计了一个动画,当然,实际情况中有一种情况是,你移动到某个位置后,脱手后位置不能动,那你禁用这个动画即可。

代码部分

tips:代码部分的变量名与原理的变量名有出入

public class ControllerView extends View implements View.OnTouchListener {
private Paint borderPaint = new Paint();//大圆的画笔
private Paint fingerPaint = new Paint();//小圆的画笔
private float radius = 160;//默认大圆的半径
private float centerX = radius;//大圆中心点的位置cx
private float centerY = radius;//大圆中心点的位置cy
private float fingerX = centerX, fingerY = centerY;//小圆圆心的位置(ax,ay)
private float lastX = fingerX, lastY = fingerY;//小圆自动回归中点动画中上一点的位置
private float innerRadius = 30;//默认小圆半径
private float radiusBorder = (radius - innerRadius);//大圆减去小圆的半径
private ValueAnimator positionAnimator;//自动回中的动画
private MoveListener moveListener;//移动回调的接口

public ControllerView(Context context) {
super(context);
init(context, null, 0);
}

public ControllerView(Context context,
@Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs, 0);
}

public ControllerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}

//初始化
private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
if (attrs != null) {
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.ControllerView);
int fingerColor = typedArray.getColor(R.styleable.ControllerView_fingerColor,
Color.parseColor("#3fffffff"));
int borderColor = typedArray.getColor(R.styleable.ControllerView_borderColor,
Color.GRAY);
radius = typedArray.getDimension(R.styleable.ControllerView_radius, 220);
innerRadius = typedArray.getDimension(R.styleable.ControllerView_fingerSize, innerRadius);
borderPaint.setColor(borderColor);
fingerPaint.setColor(fingerColor);
lastX = lastY = fingerX = fingerY = centerX = centerY = radius;
radiusBorder = radius - innerRadius;
typedArray.recycle();
}
setOnTouchListener(this);
positionAnimator = ValueAnimator.ofFloat(1);
positionAnimator.addUpdateListener(animation -> {
Float aFloat = (Float) animation.getAnimatedValue();
changeFingerPosition(lastX + (centerX - lastX) * aFloat, lastY + (centerY - lastY) * aFloat);
});
}

@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(getActualSpec(widthMeasureSpec), getActualSpec(heightMeasureSpec));
}


//处理wrapcontent的测量
//默认wrapcontent,没有做matchParent,指定大小的适配
//view实际的大小是通过大圆半径确定的
public int getActualSpec(int spec) {
int mode = MeasureSpec.getMode(spec);
int len = MeasureSpec.getSize(spec);
switch (mode) {
case MeasureSpec.AT_MOST:
len = (int) (radius * 2);
break;
}
return MeasureSpec.makeMeasureSpec(len, mode);
}

//绘制
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(centerX, centerY, radius, borderPaint);
canvas.drawCircle(fingerX, fingerY, innerRadius, fingerPaint);
}

@Override public boolean onTouch(View v, MotionEvent event) {
float evx = event.getX(), evy = event.getY();
float deltaX = evx - centerX, deltaY = evy - centerY;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//圆外按压不生效
if (deltaX * deltaX + deltaY * deltaY > radius * radius) {
break;
}
case MotionEvent.ACTION_MOVE:
//如果触摸点在圆外
if (Math.abs(deltaX) > radiusBorder || Math.abs(deltaY) > radiusBorder) {
float distance = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY);
changeFingerPosition(centerX + (deltaX * radiusBorder / distance),
centerY + (deltaY * radiusBorder / distance));
} else { //如果触摸点在圆内
changeFingerPosition(evx, evy);
}
positionAnimator.cancel();
break;
case MotionEvent.ACTION_UP:
positionAnimator.setDuration(1000);
positionAnimator.start();
break;
}
return true;
}

/**
* 改变位置的回调出来
*/

private void changeFingerPosition(float fingerX, float fingerY) {
this.fingerX = fingerX;
this.fingerY = fingerY;
if (moveListener != null) {
float r = radius - innerRadius;
if (r == 0) {
invalidate();
return;
}
moveListener.move((fingerX - centerX) / r, (fingerY - centerY) / r);
}
invalidate();
}

@Override protected void finalize() throws Throwable {
super.finalize();
positionAnimator.removeAllListeners();
}

public void setMoveListener(
MoveListener moveListener)
{
this.moveListener = moveListener;
}

/**
*回调事件的接口
*
**/

public interface MoveListener {
void move(float dx, float dy);
}
}

style.xml

name="ControllerView">
name="fingerColor" format="color" />
name="borderColor" format="color" />
name="fingerSize" format="dimension" />
name="radius" format="dimension" />



原文链接:https://juejin.cn/post/7007252815672279053
收起阅读 »

Android 架构师之路 - AOP 面向切面编程

引言相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有...
继续阅读 »


引言

相信很多做过Web的同学对AspectJ都不陌生,Spring的AOP就是基于它而来的。如果说平常我们随便写写程序的时候,基本也不会用到它,需要调试的话无非就是多加一个System.out.printfln()或者Log.d()。但是由于基于面向对象的固有缺陷,导致很多同模块、同一水平上的工作要在许多类中重复出现。比如说:输出日志,监控方法执行时间,修改程序运行时的参数等等这样的事情,其实它们的代码都是可以重用的。

如果在一个大型的项目当中,使用手动修改源码的方式来达到调试、监控的目的,第一,需要插入许多重复代码(打印日志,监控方法执行时间),代码无法复用;第二,修改的成本太高,处处需要手动修改(分分钟累死、眼花)。

  • OOP: 面向对象把所有的事物都当做对象看待,因此每一个对象都有自己的生命周期,都是一个封装的整体。每一个对象都有自己的一套垂直的系列方法和属性,使得我们使用对象的时候不需要太多的关系它的内部细节和实现过程,只需要关注输入和输出,这跟我们的思维方式非常相近,极大的降低了我们的编写代码成本(而不像C那样让人头痛!)。但在现实世界中,并不是所有问题都能完美得划分到模块中。举个最简单而又常见的例子:现在想为每个模块加上日志功能,要求模块运行时候能输出日志。在不知道AOP的情况下,一般的处理都是:先设计一个日志输出模块,这个模块提供日志输出API,比如Android中的Log类。然后,其他模块需要输出日志的时候调用Log类的几个函数,比如e(TAG,…),w(TAG,…),d(TAG,…),i(TAG,…)等。
  • AOP: OOP固然开启另一个编程时代,但是久而久之也显露了它的缺点,最明显的一点就是它无法横向切割某一类方法、属性,当我们需要了解某一类方法、某一类属性的信息时,就必须要在每一个类的方法里面(即便他们是同样的方法,只因是不同的类所以不同)添加监控代码,在代码量庞大的情况下,这是一个不可取的方法。因此,AOP编产生了,基于AOP的编程可以让我们横向的切割某一类方法和属性(不需要关心他是什么类别!),AOP并不是与OOP对立的,而是为了弥补OOP的不足,因为有了AOP我们的调试和监控就变得简单清晰。

1.AspectJ介绍

1.1 AspectJ只是一个代码编译器

AspectJ 意思就是Java的Aspect,Java的AOP。它其实不是一个新的语言,它就是一个代码编译器(ajc,后面以此代替),在Java编译器的基础上增加了一些它自己的关键字识别和编译方法。因此,ajc也可以编译Java代码。它在编译期将开发者编写的Aspect程序编织到目标程序中,对目标程序作了重构,目的就是建立目标程序与Aspect程序的连接(耦合,获得对方的引用(获得的是声明类型,不是运行时类型)和上下文信息),从而达到AOP的目的(这里在编译期还是修改了原来程序的代码,但是是ajc替我们做的)。

1.2 AspectJ是用来做AOP编程的

Cross-cutting concerns(横切关注点): 尽管面向对象模型中大多数类会实现单一特定的功能,但通常也会开放一些通用的附属功能给其他类。例如,我们希望在数据访问层中的类中添加日志,同时也希望当UI层中一个线程进入或者退出调用一个方法时添加日志。尽管每个类都有一个区别于其他类的主要功能,但在代码里,仍然经常需要添加一些相同的附属功能。

  • Advice(通知): 注入到class文件中的代码。典型的 Advice 类型有 before、after 和 around,分别表示在目标方法执行之前、执行后和完全替代目标方法执行的代码。 除了在方法中注入代码,也可能会对代码做其他修改,比如在一个class中增加字段或者接口。
  • Joint point(连接点): 程序中可能作为代码注入目标的特定的点,例如一个方法调用或者方法入口。
  • Pointcut(切入点): 告诉代码注入工具,在何处注入一段特定代码的表达式。例如,在哪些 joint points 应用一个特定的 Advice。切入点可以选择唯一一个,比如执行某一个方法,也可以有多个选择,比如,标记了一个定义成@DebguTrace 的自定义注解的所有方法。
  • Aspect(切面): Pointcut 和 Advice 的组合看做切面。例如,我们在应用中通过定义一个 pointcut 和给定恰当的advice,添加一个日志切面。
  • Weaving(织入): 注入代码(advices)到目标位置(joint points)的过程。

下面这张图简要总结了一下上述这些概念。

传统编程:逐个插入验证用户模块

AOP方案:关注点聚焦

1.3、为什么要用AspectJ?
  • 非侵入式监控: 支持编译期和加载时代码注入,可以在不修监控目标的情况下监控其运行,截获某类方法,甚至可以修改其参数和运行轨迹!
  • 易于使用: 它就是Java,只要会Java就可以用它。
  • 功能强大,可拓展性高: 它就是一个编译器+一个库,可以让开发者最大限度的发挥,实现形形色色的AOP程序!

2、下载AspectJ相关资源与build.gradle配置

2.1、下载地址

下载aspectj的地址http://www.eclipse.org/aspectj/dow…\

2.2、解压aspectj jar包得到aspectjrt.jar
2.3、build.gradle配置

参考build.gradle aspectJ 写法 fernandocejas.com/2014/08/03/…
根目录中build.gradle配置:


buildscript {

repositories {
mavenCentral()
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.0.0'
classpath 'org.aspectj:aspectjtools:1.8.13'
classpath 'org.aspectj:aspectjweaver:1.8.13'

// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}

allprojects {
repositories {
google()
jcenter()
}
}

task clean(type: Delete) {
delete rootProject.buildDir
}

modules中build.gradle配置:


apply plugin: 'com.android.application'
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main


android {
compileSdkVersion 26
defaultConfig {
applicationId "com.haocai.aopdemo"
minSdkVersion 15
targetSdkVersion 26
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->
if (!variant.buildType.isDebuggable()) {
log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
return;
}

JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.8",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)

MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
for (IMessage message : handler.getMessages(null, true)) {
switch (message.getKind()) {
case IMessage.ABORT:
case IMessage.ERROR:
case IMessage.FAIL:
log.error message.message, message.thrown
break;
case IMessage.WARNING:
log.warn message.message, message.thrown
break;
case IMessage.INFO:
log.info message.message, message.thrown
break;
case IMessage.DEBUG:
log.debug message.message, message.thrown
break;
}
}
}
}

dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation 'com.android.support:appcompat-v7:26.1.0'
implementation 'com.android.support.constraint:constraint-layout:1.0.2'
testImplementation 'junit:junit:4.12'
compile files('libs/aspectjrt.jar')
androidTestImplementation 'com.android.support.test:runner:1.0.1'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.1'
// compile 'org.aspectj:aspectjrt:1.8.+'
}
注意:

dependencies 不要忘记添加 compile files('libs/aspectjrt.jar') ,aspectjrt.jar就是上一步解压得到的文件,放到libs文件夹下

3、示例程序

创建注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.CLASS)
public @interface BehaviorTrace {
String value();
int type();
}
Aspect 类

/**
* Created by Xionghu on 2018/1/23.
* Desc: 切面
* 你想要切下来的部分(代码逻辑功能重复模块)
*/
@Aspect
public class BehaviorAspect {
private static final String TAG = "MainAspect";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 根据切点 切成什么样子
*
*/
@Pointcut("execution(@com.haocai.aopdemo.BehaviorTrace * *(..))")
public void annoBehavior() {

}
/**
* 切成什么样子之后,怎么去处理
*
*/

@Around("annoBehavior()")
public Object dealPoint(ProceedingJoinPoint point) throws Throwable{
//方法执行前
MethodSignature methodSignature = (MethodSignature)point.getSignature();
BehaviorTrace behaviorTrace = methodSignature.getMethod().getAnnotation(BehaviorTrace.class);
String contentType = behaviorTrace.value();
int type = behaviorTrace.type();
Log.i(TAG,contentType+"使用时间: "+simpleDateFormat.format(new Date()));
long beagin=System.currentTimeMillis();
//方法执行时
Object object = null;
try{
object = point.proceed();
}catch (Exception e){
e.printStackTrace();
}

//方法执行完成
Log.i(TAG,"消耗时间:"+(System.currentTimeMillis()-beagin)+"ms");
return object;
}
}
调用主程序


package com.haocai.aopdemo;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
public class MainActivity extends AppCompatActivity {
private static final String TAG = "Main";
SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);





}
/**
* 摇一摇的模块
*
* @param view
*/
@BehaviorTrace(value = "摇一摇",type = 1)
public void mShake(View view)
{
//摇一摇的代码逻辑
{
SystemClock.sleep(3000);
Log.i(TAG," 摇到一个红包");

}
}
/**
* 语音的模块
*
* @param view
*/
@BehaviorTrace(value = "语音:",type = 1)
public void mAudio(View view)
{
//语音代码逻辑
{
SystemClock.sleep(3000);
Log.i(TAG,"发语音:我要到一个红包啦");
}
}
/**
* 打字模块
*
* @param view
*/
@BehaviorTrace(value = "打字:",type = 1)
public void mText(View view)
{
//打字模块逻辑
{
SystemClock.sleep(3000);
Log.i(TAG,"打字逻辑,我摇到了一个大红包");

}

}


// /**
// * 摇一摇的模块
// *
// * @param view
// */
// @BehaviorTrace(value = "摇一摇",type = 1)
// public void mShake(View view)
// {
// SystemClock.sleep(3000);
// Log.i(TAG," 摇到一个嫩模: 约不约");
// }
//
// /**
// * 摇一摇的模块
// *
// * @param view
// */
// public void mShake(View view)
// {
//
// long beagin=System.currentTimeMillis();
// Log.i(TAG,"摇一摇: 使用时间: "+simpleDateFormat.format(new Date()));
// //摇一摇的代码逻辑
// {
// SystemClock.sleep(3000);
//
// Log.i(TAG," 摇到一个红包");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
//
// }

// /**
// * 语音的模块
// *
// * @param view
// */
// public void mAudio(View view)
// {
// long beagin=System.currentTimeMillis();
// Log.i(TAG,"语音: 使用时间: "+simpleDateFormat.format(new Date()));
// //语音代码逻辑
// {
// SystemClock.sleep(3000);
//
// Log.i(TAG,"发语音:我要到一个红包啦");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
// }
//
// /**
// * 打字模块
// *
// * @param view
// */
// public void mText(View view)
// {
// //统计用户行为 的逻辑
// Log.i(TAG,"文字: 使用时间: "+simpleDateFormat.format(new Date()));
// long beagin=System.currentTimeMillis();
//
// //打字模块逻辑
// {
// SystemClock.sleep(3000);
// Log.i(TAG,"打字逻辑,我摇到了一个大红包");
//
// }
// //事件统计逻辑
// Log.i(TAG,"消耗时间: "+(System.currentTimeMillis()-beagin)+"ms");
// }

}
注意:下面注释部分为传统写法
运行结果

01-23 19:39:09.579 13051-13051/com.haocai.aopdemo I/MainAspect: 摇一摇使用时间:   2018-01-23 19:39:09
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/Main: 摇到一个红包
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3001ms
01-23 19:39:12.589 13051-13051/com.haocai.aopdemo I/MainAspect: 语音:使用时间: 2018-01-23 19:39:12
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/Main: 发语音:我要到一个红包啦
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3000ms
01-23 19:39:15.599 13051-13051/com.haocai.aopdemo I/MainAspect: 打字:使用时间: 2018-01-23 19:39:15
01-23 19:39:18.609 13051-13051/com.haocai.aopdemo I/Main: 打字逻辑,我摇到了一个大红包
01-23 19:39:18.609 13051-13051/com.haocai.aopdemo I/MainAspect: 消耗时间:3000ms
收起阅读 »

安卓分页加载器——Paging使用指南

一、简介应用开发过程中分页加载时很普遍的需求,它能节省数据流量,提升应用的性能。 Google为了方便开发者完成分页加载而推出了分页组件—Paging。为几种常见的分页机制提供了统一的解决方案。优势分页数据的内存中缓存。该功能可确保应用在处理分页数据时高效利用...
继续阅读 »

一、简介

应用开发过程中分页加载时很普遍的需求,它能节省数据流量,提升应用的性能。 Google为了方便开发者完成分页加载而推出了分页组件—Paging。为几种常见的分页机制提供了统一的解决方案。

  • 优势
    • 分页数据的内存中缓存。该功能可确保应用在处理分页数据时高效利用系统资源。
    • 内置的请求重复信息删除功能,可确保应用高效利用网络带宽和系统资源。
    • 可配置的RecyclerView适配器,会在用户滚动到已加载数据的末尾时自动请求数据。
    • 对Kotlin协程和Flow以及LiveData和RxJava的一流支持。
    • 内置对错误处理功能的支持,包括刷新和重试功能。
  • 数据来源:Paging支持三种数据架构类型
    • 网络:对网络数据进行分页加载是最常见的需求。API接口通常不太一样,Paging提供了三种不同的方案,应对不同的分页机制。Paging不提供任务错误处理功能,发生错误后可重试网络请求。
    • 数据库:数据库进行分页加载和网络类似,推荐使用Room数据库修改和插入数据。
    • 网络+数据库:通常只采用单一数据源作为解决方案,从网络获取数据,直接缓存进数据库,列表直接从数据库中获取数据。

二、核心

2.1 核心类

Paging的工作原理主要涉及三个类:

  1. PagedListAdapter:RecyclerView.Adapter基类,用于在RecyclerView显示来自PagedList的分页数据。
  2. PagedList:PagedList负责通知DataSource何时获取数据,如加载第一页、最后一页及加载数量等。从DataSource获取的数据将存储在PagedList中。
  3. DataSource:执行具体的数据载入工作,数据载入需要在工作线程中进行

以上三个类的关系及数据加载流程如下图:

20181021221030916.gif

当一条新的item插入到数据库,DataSource会被初始化,LiveData后台线程就会创建一个新的PagedList。这个新的PagedList会被发送到UI线程的PagedListAdapter中,PagedListAdapter使用DiffUtil在对比现在的Item和新建Item的差异。当对比结束,PagedListAdapter通过调用RecycleView.Adapter.notifyItemInserted()将新的item插入到适当的位置

2.2 DataSource

根据分页机制的不同,Paing为我们提供了三种DataSource。

  1. PositionalDataSource

适用于可通过任意位置加载数据,且目标数据源数量固定的情况。

  1. PageKeyedDataSource

适合数据源以“页”的方式进行请求的情况。如获取数据携带pagepageSize时。本文代码使用此DataSource

  1. ItemKeyedDataSource

适用于当目标数据的下一页需要依赖上一页数据中的最后一个对象中的某个字段作为key的情况,如评论数据的接口携带参数sincepageSize

三、使用

3.1 构建自己的DataSource

DataSource控制数据加载,包括初始化加载,加载上页数据,加载下页数据。此处我们以PageKeyedDataSource为例

//泛型参数未Key Value,Key就是每页的标志,此处为Long,Value为数据类型
class ListDataSource : PageKeyedDataSource<Long, Item>() {
//重试加载时的参数
private var lastLoadParam: Pair<LoadParams<Long>, LoadCallback<Long, Item>>? = null

}

其中的关键点在于,每次Key的选定以及loadInitialloadBeforeloadAfter三个函数的重写。PageKeyedDataSource的Key一般依赖与服务端返回的数据。

3.2 构建PagedList

companion object{

private const val TAG = "List"
const val PAGE_SIZE = 5
const val FETCH_DIS = 1

}
val ListData: LiveData<PagedList<Item>> = LivePagedListBuilder(
dataSourceFactory,
Config(
PAGE_SIZE,
FETCH_DIS,
true
)
).build()

其中PAGE_SIZE是每页的数量,FETCH_DIS是距离最后一个数据item还有多少距离就触发加载动作。

此处ListData是LiveData类型,因此可以在Activity中进行监听,当发生数据变化时,则刷新adapter:

ListViewModel.ListData.observe(this) {
adapter.submitList(it)
}

3.3 构建自己的PagedListAdapter

一定要继承PagedListAdapter<Item, RecyclerView.ViewHolder>(``POST_COMPARATOR``)POST_COMPARATOR就是DiffUtil,PagedListAdapter使用DiffUtil在对比现在的Item和新建Item的差异。

typealias ItemClickListener = (Item) -> Unit
typealias onClickListener = () -> Unit

class ListAdapter(
pri
}
}

可以看到基本写法和普通的RecyclerView.Adapter是差不多的,只是多了DiffUtil,使用起来也是一样:

adapter = ListAdapter(
this,
onItemClickListener,
headRetryClickListener,
footRetryClickListener
)
list_rv.adapter = adapter

四、Paging 3.0

Paging3与旧版Paging存在很大区别。Paging2.x运行起来的效果无限滑动还不错,不过代码写起来有点麻烦,功能也不是太完善,比如下拉刷新的方法都没有提供,我们还得自己去调用DataSource#invalidate()方法重置数据来实现。Paging3.0功能更加强大,用起来更简单。

4.1 区别

  • DataSource

Paing2中的DataSource有三种,Paging3中将它们合并到了PagingSource中,实现load()和getRefreshKey(),在Paging3中,所有加载方法参数被一个LoadParams密封类替代,该类中包含了每个加载类型所对应的子类。如果需要区分load()中的加载类型,需要检查传入了LoadParams的哪个子类

  • PagedListAdapter

Adapter不在继承PagedListAdapter,而是由PagingDataAdapter替代,其它不变。

class ArticleAdapter : PagingDataAdapter<Article,ArticleViewHolder>(POST_COMPARATOR){

companion object{

val POST_COMPARATOR = object : DiffUtil.ItemCallback<Article>() {
override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem == newItem

override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean =
oldItem.id == newItem.id
}
}

override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
holder.tvName.text = getItem(position)?.title
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
return ArticleViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.list_item,parent,false))
}
}

class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView){
val tvName: TextView = itemView.findViewById(R.id.tvname)
}

4.2 获取数据并设置给Adapter

google提倡我使用三层架构来完成数据到Adapter的设置,如下图

image.png

代码库层

代码库层中的主要 Paging 库组件是 PagingSource。每个 PagingSource 对象都定义了数据源,以及如何从该数据源检索数据。PagingSource 对象可以从任何单个数据源(包括网络来源和本地数据库)加载数据。可使用的另一个 Paging 库组件是 RemoteMediatorRemoteMediator 对象会处理来自分层数据源(例如具有本地数据库缓存的网络数据源)的分页。

ViewModel 层

Pager 组件提供了一个公共 API,基于 PagingSource 对象和 PagingConfig 配置对象来构造在响应式流中公开的 PagingData 实例。将 ViewModel 层连接到界面的组件是 PagingData。 PagingData 对象是用于存放分页数据快照的容器。它会查询 PagingSource 对象并存储结果。

界面层

界面层中的主要 Paging 库组件是 PagingDataAdapter

收起阅读 »

安卓-Glidel图片加载框架学习笔记

引用地址: muyangmin.github.io/glide-docs-… 以glide Version = '4.12.0'为例 1.Gradle配置 此处配置在子模块里(要添加到app主模块也可以),非app主模块里 //glide图片加载框架 impl...
继续阅读 »

引用地址:


muyangmin.github.io/glide-docs-…


以glide Version = '4.12.0'为例


1.Gradle配置


此处配置在子模块里(要添加到app主模块也可以),非app主模块里


//glide图片加载框架
implementation "com.github.bumptech.glide:annotations:${rootProject.glideVersion}"
api "com.github.bumptech.glide:glide:${rootProject.glideVersion}"
annotationProcessor "com.github.bumptech.glide:compiler:${rootProject.glideVersion}"

app模块只需要添加下面一句,然后app主模块引用上面的子模块


implementation project(":CommonModule")
annotationProcessor "com.github.bumptech.glide:compiler:${rootProject.glideVersion}"

2.添加权限声明


添加到子模块AndroidManifest.xml里即可(非app主模块)


<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

3.如果使用到Proguard


如果你有使用到 proguard,那么请把以下代码添加到你的 proguard.cfg 文件中:


-keep public class * implements com.bumptech.glide.module.GlideModule
-keep public class * extends com.bumptech.glide.module.AppGlideModule
-keep public enum com.bumptech.glide.load.ImageHeaderParser$** {
**[] $VALUES;
public *;
}

4.@GlideModule 定义AppGlideModule子类和LibraryGlideModule子类


定义一个AppGlideModule子类,定义在app主模块里,而且只能定义在app主模块里。


package com.example.myapp;

import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;

@GlideModule
public final class MyAppGlideModule extends AppGlideModule {}

定义LibraryGlideModule,定义在非app子模块里。


import android.content.Context;
import android.util.Log;

import androidx.annotation.NonNull;

import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.LibraryGlideModule;

@GlideModule
public class MyLibraryGlideModule extends LibraryGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
super.registerComponents(context, glide, registry);
Log.d("MyLibraryGlideModule","MyLibraryGlideModule");
}
}

定义完成后,不要忘记了 Build ->Make Project,然后会生成会在app\build\generated\ap_generated_sources\debug\out的目录下生成相应的文件,如GlideApp.java,LibraryGlideModule也会自动在生成相应的文件。


5.使用 Generated API


Generated API 默认名为 GlideApp ,与 Application 模块中 AppGlideModule的子类包名相同。在 Application 模块中将 Glide.with() 替换为 GlideApp.with(),即可使用该 API 去完成加载工作:


GlideApp.with(fragment)
.load(myUrl)
.placeholder(R.drawable.placeholder)
.fitCenter()
.into(imageView);

占位符:.placeholder(placeholder) 加载中的占位符


加载失败占位符:.error(android.R.drawable.stat_notify_error)


RequestOptions在多个请求之间共享配置


RequestOptions sharedOptions = 
new RequestOptions()
.placeholder(placeholder)
.fitCenter();

Glide.with(fragment)
.load(myUrl)
.apply(sharedOptions)
.into(imageView1);

Glide.with(fragment)
.load(myUrl)
.apply(sharedOptions)
.into(imageView2);

6.在 ListView 和 RecyclerView 中的使用


在 ListView 或 RecyclerView 中加载图片的代码和在单独的 View 中加载完全一样。Glide 已经自动处理了 View 的复用和请求的取消:


View 调用 clear()into(View),表明在此之前的加载操作会被取消,并且在方法调用完成后,Glide 不会改变 view 的内容。如果你忘记调用 clear(),而又没有开启新的加载操作,那么就会出现这种情况,你已经为一个 view 设置好了一个 Drawable,但该 view 在之前的位置上使用 Glide 进行过加载图片的操作,Glide 加载完毕后可能会将这个 view 改回成原来的内容。


这里的代码以 RecyclerView 的使用为例,但规则同样适用于 ListView。


正确用法1


@Override
public void onBindViewHolder(ViewHolder holder, int position) {
String url = urls.get(position);
Glide.with(fragment)
.load(url)
.into(holder.imageView);
}

正确用法2


@Override
public void onBindViewHolder(ViewHolder holder, int position) {
if (isImagePosition(position)) {
String url = urls.get(position);
Glide.with(fragment)
.load(url)
.into(holder.imageView);
} else {
Glide.with(fragment).clear(holder.imageView);
holder.imageView.setImageDrawable(specialDrawable);
}
}

7.非 View 目标


除了将 BitmapDrawable 加载到 View 之外,你也可以开始异步加载到你的自定义 Target 中:


Glide.with(context
.load(url)
.into(new CustomTarget<Drawable>() {
@Override
public void onResourceReady(Drawable resource, Transition<Drawable> transition) {
// Do something with the Drawable here.
}

@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
// Remove the Drawable provided in onResourceReady from any Views and ensure
// no references to it remain.
}
});

8.后台线程


后台线程下载,


FutureTarget<Bitmap> futureTarget =
Glide.with(context)
.asBitmap()
.load(url)
.submit(width, height);

Bitmap bitmap = futureTarget.get();

// Do something with the Bitmap and then when you're done with it:
Glide.with(context).clear(futureTarget);

后台同步实现图片下载


 //不能直接在主线程里调用,会直接ANR
FutureTarget<File> futureTarget = Glide.with(GlideImageLoadFragment.this)
.asFile()
.load("https://dss3.bdstatic.com/iPoZeXSm1A5BphGlnYG/skin/822.jpg?2")
.addListener(new RequestListener<File>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e,
Object model, Target<File> target,
boolean isFirstResource) {
return false;
}

@Override
public boolean onResourceReady(File resource, Object model, Target<File> target, DataSource dataSource, boolean isFirstResource) {
return false;
}
}).submit();

//下载到的文件没有扩展名。
File file = futureTarget.get();

异常下载图片并回调 .asFile()


GlideApp.with(this)
.asFile()
.load("https://img.soogif.com/rSlMSm7msQagXhSSgIQ0LtqTusCK712l.gif")
.into(new CustomTarget<File>() {
@Override
public void onResourceReady(@NonNull File resource, @Nullable Transition<? super File> transition) {
Log.d("", "");
}

@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
Log.d("", "");
}
});

8.Glide扩展 GlideExtension


@GlideExtension 注解用于标识一个扩展 Glide API 的类。任何扩展 Glide API 的类都必须使用这个注解来标记,否则其中被注解的方法就会被忽略。


@GlideExtension 注解的类应以工具类的思维编写。这种类应该有一个私有的、空的构造方法,应为 final 类型,并且仅包含静态方法。被注解的类可以含有静态变量,可以引用其他的类或对象。


在 Application 模块中可以根据需求实现任意多个被 @GlideExtension 注解的类,在 Library 模块中同样如此。当 AppGlideModule 被发现时,所有有效的 Glide 扩展类 会被合并,所有的选项在 API 中均可以被调用。合并冲突会导致 Glide 的 Annotation Processor 抛出编译错误。


@GlideExtention 注解的类有两种扩展方式:



  1. GlideOption - 为 RequestOptions 添加一个自定义的选项。

  2. GlideType - 添加对新的资源类型的支持(GIF,SVG 等等)。


@GlideExtension


@GlideExtension只能在app模块里使用。


//@GlideExtension 注解到类上面,只能在app模块里使用。
@GlideExtension
public class MyAppExtension {
// Size of mini thumb in pixels.
private static final int MINI_THUMB_SIZE = 100;

private MyAppExtension() { } // utility class

//@GlideOption注解到方法里面。标记的方法应该为静态方法
@NonNull
@GlideOption
public static BaseRequestOptions<?> miniThumb(BaseRequestOptions<?> options) {
return options
.fitCenter()
.override(MINI_THUMB_SIZE);
}

// 你可以为方法任意添加参数,但要保证第一个参数为 RequestOptions。 标记的方法应该为静态方法
@GlideOption
public static BaseRequestOptions<?> miniThumb(BaseRequestOptions<?> options, int size) {
return options
.fitCenter()
.override(size);
}
}

添加完成后 执行 Build ->Make Project,可以看到app\build\generated\ap_generated_sources\debug\out\my\android\architecture\samples\glide\GlideOptions.java


里生成了下面的方法


@SuppressWarnings("unchecked")
@CheckResult
@NonNull
public GlideOptions miniThumb() {
return (GlideOptions) MyAppExtension.miniThumb(this);
}

/**
* @see MyAppExtension#miniThumb(BaseRequestOptions, int)
*/
@SuppressWarnings("unchecked")
@CheckResult
@NonNull
public GlideOptions miniThumb(int size) {
return (GlideOptions) MyAppExtension.miniThumb(this, size);
}

调用


GlideApp.with(fragment)
.load(url)
.miniThumb(thumbnailSize)
.into(imageView);

GlideType


@GlideType 注解的静态方法用于扩展 RequestManager 。被 @GlideType 注解的方法允许你添加对新的资源类型的支持,包括指定默认选项。


例如,为添加对 GIF 的支持,你可以添加一个被 @GlideType 注解的方法:


@GlideExtension
public class MyAppExtension {
private static final RequestOptions DECODE_TYPE_GIF = decodeTypeOf(GifDrawable.class).lock();

@NonNull
@GlideType(GifDrawable.class)
public static RequestBuilder<GifDrwable> asGif(RequestBuilder<GifDrawable> requestBuilder) {
return requestBuilder
.transition(new DrawableTransitionOptions())
.apply(DECODE_TYPE_GIF);
}
}

这样会生成一个包含对应方法的 RequestManager


public class GlideRequests extends RequesetManager {

public GlideRequest<GifDrawable> asGif() {
return (GlideRequest<GifDrawable> MyAppExtension.asGif(this.as(GifDrawable.class));
}

...
}

9.占位符


Glide允许用户指定三种不同类型的占位符,分别在三种不同场景使用:


placeholder
error
fallback


占位符(Placeholder)


占位符是当请求正在执行时被展示的 Drawable 。当请求成功完成时,占位符会被请求到的资源替换。如果被请求的资源是从内存中加载出来的,那么占位符可能根本不会被显示。如果请求失败并且没有设置 error Drawable ,则占位符将被持续展示。类似地,如果请求的url/model为 null ,并且 error Drawablefallback 都没有设置,那么占位符也会继续显示。


错误符(Error)


error Drawable 在请求永久性失败时展示。error Drawable 同样也在请求的url/model为 null ,且并没有设置 fallback Drawable 时展示。


后备回调符(Fallback)


fallback Drawable 在请求的url/model为 null 时展示。设计 fallback Drawable 的主要目的是允许用户指示 null 是否为可接受的正常情况。例如,一个 null 的个人资料 url 可能暗示这个用户没有设置头像,因此应该使用默认头像。然而,null 也可能表明这个元数据根本就是不合法的,或者取不到。 默认情况下Glide将 null 作为错误处理,所以可以接受 null 的应用应当显式地设置一个 fallback Drawable


占位符是异步加载的吗?

No。占位符是在主线程从Android Resources加载的。我们通常希望占位符比较小且容易被系统资源缓存机制缓存起来。


变换是否会被应用到占位符上?

No。Transformation仅被应用于被请求的资源,而不会对任何占位符使用。


在应用中包含必须在运行时做变换才能使用的图片资源是很不划算的。相反,在应用中包含一个确切符合尺寸和形状要求的资源版本几乎总是一个更好的办法。假如你正在加载圆形图片,你可能希望在你的应用中包含圆形的占位符。另外你也可以考虑自定义一个View来剪裁(clip)你的占位符,而达到你想要的变换效果。


在多个不同的View上使用相同的Drawable可行么?

通常可以,但不是绝对的。任何无状态(non-stateful)的 Drawable(例如 BitmapDrawable )通常都是ok的。但是有状态的 Drawable 不一样,在同一时间多个 View 上展示它们通常不是很安全,因为多个View会立刻修改(mutate) Drawable 。对于有状态的 Drawable ,建议传入一个资源ID,或者使用 newDrawable() 来给每个请求传入一个新的拷贝。


10.选项


1.请求选项


Glide中的大部分设置项都可以直接应用在 Glide.with() 返回的 RequestBuilder 对象上。


可用的选项包括(但不限于):



  • 占位符(Placeholders)

  • 转换(Transformations)

  • 缓存策略(Caching Strategies)

  • 组件特有的设置项,例如编码质量,或Bitmap的解码配置等。


例如,要应用一个 CenterCrop 转换,你可以使用以下代码:


Glide.with(fragment)
.load(url)
.centerCrop()
.into(imageView);

RequestOptions对象 apply(@NonNull BaseRequestOptions<?> options)


如果你想让你的应用的不同部分之间共享相同的加载选项,你也可以初始化一个新的 RequestOptions 对象,并在每次加载时通过 apply() 方法传入这个对象:


RequestOptions cropOptions = new RequestOptions().centerCrop(context);
...
Glide.with(fragment)
.load(url)
.apply(cropOptions)
.into(imageView);

apply() 方法可以被调用多次,因此 RequestOption 可以被组合使用。如果 RequestOptions 对象之间存在相互冲突的设置,那么只有最后一个被应用的 RequestOptions 会生效。


过渡选项 transition


不同于RequestOptionsTransitionOptions是特定资源类型独有的,你能使用的变换取决于你让Glide加载哪种类型的资源。


这样的结果是,假如你请求加载一个 Bitmap ,你需要使用 BitmapTransitionOptions ,而不是 DrawableTransitionOptions 。同样,当你请求加载 Bitmap时,你只需要做简单的淡入,而不需要做复杂的交叉淡入。


RequestBuilder


使用 RequestBuilder 可以指定:



  • 你想加载的资源类型(Bitmap, Drawable, 或其他)

  • 你要加载的资源地址(url/model)

  • 你想最终加载到的View

  • 任何你想应用的(一个或多个)RequestOption 对象

  • 任何你想应用的(一个或多个)TransitionOption 对象

  • 任何你想加载的缩略图 thumbnail()


选择资源类型


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

Swift 5.5 新特性

iOS
Swift 5.5 内置于 Xcode 13,虽然版本号只增加了 0.1,看似是一个小版本升级,但却带来了非常多的新内容,其中最大的更新是引入了全新的并发编程方式。条件编译支持表达式SwiftUI 在跨平台时会使用到条件 Modifier,之前的解决方案是自己...
继续阅读 »

Swift 5.5 内置于 Xcode 13,虽然版本号只增加了 0.1,看似是一个小版本升级,但却带来了非常多的新内容,其中最大的更新是引入了全新的并发编程方式。

条件编译支持表达式

SwiftUI 在跨平台时会使用到条件 Modifier,之前的解决方案是自己写一套判断体系, Swift 5.5 以后,原生支持条件编译表达式,跨平台更加方便。

struct ContentView: View {
var body: some View {
Text("SwiftUI")
#if os(iOS)
.foregroundColor(.blue)
#elseif os(macOS)
.foregroundColor(.green)
#else
.foregroundColor(.pink)
#endif
}
}
复制代码

CGFloat与Double支持隐式转换

let number1: CGFloat = 12.34
let number2: Double = 56.78
let result = number1 + number2 // result为Double类型
复制代码

下面的代码在 Swift 5.5 之前会报错,因为scale为 Double 类型,而 SwiftUI 中需要绑定 CGFloat 类型。

struct ContentView: View {
@State private var scale = 1.0 // Double类型

var body: some View {
VStack {
Image(systemName: "heart")
.scaleEffect(scale) // 隐式转换为CGFloat

Slider(value: $scale, in: 0 ... 1)
}
}
}
复制代码

在通用上下文中扩展静态成员查找(static member lookup)

这个新特性使得 SwiftUI 中的部分语法更加简洁好用。

struct ContentView: View {
@Binding var name: String

var body: some View {
HStack {
Text(name)

TextField("", text: $name)
// .textFieldStyle(RoundedBorderTextFieldStyle()) // 以前写法
.textFieldStyle(.roundedBorder) // 新写法,更简洁
}
}
}
复制代码

局部变量支持lazy

func lazyInLocalContext() {
print("lazy之前")
lazy var swift = "Hello Swift 5.5"
print("lazy之后")

print(swift)
}

// 调用
lazyInLocalContext()

/* 输出
lazy之前
lazy之后
Hello Swift 5.5
*/
复制代码

函数和闭包参数支持属性包装

  • Swift 5.1 中引入了属性包装。
  • Swift 5.4 将属性包装支持到局部变量。
  • Swift 5.5 将属性包装支持到函数和闭包参数。
@propertyWrapper struct Trimmed {
private var value: String = ""

var wrappedValue: String {
get { value }
set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
}

init(wrappedValue initialValue: String) {
wrappedValue = initialValue
}
}

struct Post {
func trimed(@Trimmed content: String) { // 函数参数支持PropertyWrapper
print(content)
}
}

let post = Post()
post.trimed(content: " Swift 5.5 Property Wrappers ")
复制代码

带有关联值的枚举支持Codable

有了该功能之后,枚举就可以像结构体、类一样用来作为数据模型了。

  • 枚举到 JSON。
// 定义带有关联值的枚举
enum Score: Codable {
case number(score: Double)
case letter(score: String)
}

// 创建对象
let scores: [Score] = [.number(score: 98.5), .letter(score: "优")]

// 转JSON
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let result = try encoder.encode(scores)
let json = String(decoding: result, as: UTF8.self)
print(json)
} catch {
print(error.localizedDescription)
}
复制代码
  • JSON 到枚举。
enum Score: Codable {
case number(score: Double)
case letter(score: String)
}

// JSON
let json = """
[
{
"number" : {
"score" : 98.5
}
},
{
"letter" : {
"score" : "优"
}
}
]
"""

// 转枚举
let decoder = JSONDecoder()
do {
let scores = try decoder.decode([Score].self, from: json.data(using: .utf8)!)
for score in scores {
switch score {
case let .number(value):
print(value)
case let .letter(value):
print(value)
}
}
} catch {
print(error.localizedDescription)
}
复制代码

并发编程

内容较多且尚不稳定,后面会单独出《Swift 5.5 Concurrency》

收起阅读 »

iOS - Core Graphics快速入门——从一行代码说起

iOS
Core Graphics入门想必每个第一次接触Core Graphics的开发者都被无数的API、混乱的代码逻辑折腾得头疼不已,甚至望而却步。即使是绘制一个简单的矩形也看上去非常繁琐。本文换一个角度,整理一下有关Core Graphics的知识,也算作是这段...
继续阅读 »

Core Graphics入门

想必每个第一次接触Core Graphics的开发者都被无数的API、混乱的代码逻辑折腾得头疼不已,甚至望而却步。即使是绘制一个简单的矩形也看上去非常繁琐。本文换一个角度,整理一下有关Core Graphics的知识,也算作是这段时间学习的总结。

Core Graphics和UIKit的区别

首先从概念上了解一下:


根据苹果的描述,UIKit是我们最容易也是最常接触到的框架。绝大多数图形界面都由UIKit完成。但是UIKit依赖于Core Graphics框架,也是基于Core Graphics框架实现的。如果想要完成某些更底层的功能或者追求极致的性能,那么依然推荐使用Core Graphics完成。

Core Graphics和UIKit在实际使用中也存在以下这些差异:

  1. Core Graphics其实是一套基于C的API框架,使用了Quartz作为绘图引擎。这也就意味着Core Graphics不是面向对象的。
  2. Core Graphics需要一个图形上下文(Context)。所谓的图形上下文(Context),说白了就是一张画布。这一点非常容易理解,Core Graphics提供了一系列绘图API,自然需要指定在哪里画图。因此很多API都需要一个上下文(Context)参数。
  3. Core Graphics的图形上下文(Context)是堆栈式的。只能在栈顶的上下文(画布)上画图。
  4. Core Graphics中有一些API,名称不同却有着相似的功能,新手只需要掌握一种,并能够看懂其他的即可。

从一行代码说起

下面这行代码应该是很多人最早也是最常写的代码。它简单到我们根本不用思考它的本质。

[self.view addSubview:myButton];

细想一下,UIButton也是继承自UIView。这段代码表示,UIKit绘图的基本思想是通过UIView的叠加实现最终的整体效果。它主要涉及三个内容:画布、被添加的控件和添加方法。这里的self.view其实就充当了一张画布。通过添加不同的UI控件达到最终效果。我们顺着这个线索整理一下Core Graphics的编程思路。

Core Graphics的基本使用

为了使用Core Graphics来绘图,最简单的方法就是自定义一个类继承自UIView,并重写子类的drawRect方法。在这个方法中绘制图形。
Core Graphics必须一个画布,才能把东西画在这个画布上。在drawRect方法方法中,我们可以直接获取当前栈顶的上下文(Context)。下面的代码演示了具体操作步骤:

- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
}

现在我们已经完成了Core Graphics绘图的三分之一——创建一个画布。
接下来需要考虑被画上去的东西。这在UIKit中往往是一个UI控件,如Button、Label等。而在Core Graphics中通常表现为一些基本图形:三角形、矩形、圆形、以及这些图形的边框等。

这通常会涉及到非常多的API,但是如果总结一下不难发现,任何一个要绘制的东西(为了避免混淆就不称为对象了)一定有一个边框,或者称为边界。在一个几英寸的屏幕上画出无界的图形是不可能的。所以一旦确定了一个边框,我们就可以设置边框的各种绘图属性、边框内部区域的绘图属性、绘制边框还是内部区域等。

这就引出了Core Graphics中的路径(Path)的概念。在前一段代码的基础上演示路径的使用:

- (void)drawSomething{
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
CGPathMoveToPoint(path, nil, 20, 50);//移动到指定位置(设置路径起点)
CGPathAddLineToPoint(path, nil, 20, 100);//绘制直线(从起始位置开始)
CGContextAddPath(context, path);//把路径添加到上下文(画布)中
}

这里通过CGPathCreateMutable方法创建了一个路径。路径的外在表现就像一条折线。为了绘制一条路径,需要用CGPathMoveToPoint函数指定路径的起点。CGPathAddLineToPoint函数表示在路径的最后结束点和新的点之间再加一条直线。相当于拓展了原来路径。通过这样的简单的点的累加,可以绘制非常复杂的折线。

但这存在两个问题:

  1. 绘制矩形等规则多边形的过程过于繁琐
  2. 无法绘制曲线。

这些问题Core Graphics早已提供了解决办法。注意到之前我们添加了一个非常普通的自定义路径。Core Graphics中还提供了很多预先设置好的路径。不妨在drawRect方法中输入“cgcontextadd”试试看。

技术分享

这些方法由Core Graphics提供,可以用来绘制圆形、椭圆、矩形、二次曲线等路径。创建完路径后还要记得调用CGContextAddPath方法将路径添加到上下文中。路径只是我们画的一条线而已,不把他画到上,他就没有什么卵用。

添加好路径后,就要开始画图了。正如前面提出的问题所说,画图的时候需要考虑画不画边框、画不画边框内部的区域,边框的粗细、颜色、内部区域颜色等问题。Core Graphics提供了另一个方法集合”CGContextSet”来进行这些设置。常见的设置内容如下:

- (void)drawSomething{
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
CGPathMoveToPoint(path, nil, 20, 50);//移动到指定位置(设置路径起点)
CGPathAddLineToPoint(path, nil, 20, 100);//绘制直线(从起始位置开始)
CGContextAddPath(context, path);//把路径添加到上下文(画布)中

//设置图形上下文状态属性
CGContextSetRGBStrokeColor(context, 1.0, 0, 0, 1);//设置笔触颜色
CGContextSetRGBFillColor(context, 0, 1.0, 0, 1);//设置填充色
CGContextSetLineWidth(context, 2.0);//设置线条宽度
CGContextSetLineCap(context, kCGLineCapRound);//设置顶点样式
CGContextSetLineJoin(context, kCGLineJoinRound);//设置连接点样式
CGFloat lengths[2] = { 18, 9 };
CGContextSetLineDash(context, 0, lengths, 2);
CGContextSetShadowWithColor(context, CGSizeMake(2, 2), 0, [UIColor blackColor].CGColor);
CGContextDrawPath(context, kCGPathFillStroke);//最后一个参数是填充类型
}

设置属性的前三行就不再解释了,看一些注释足矣。顶点指的是路径的起始点和结束点,连接点指的是路径中的转折点(折现才有)。SetLineDash用于绘制虚线,具体用法参见——《IOS中使用Quartz 2D绘制虚线》。SetShadow方法用于绘制阴影,第二个参数是一个CGSize对象,用于表示阴影偏移量,第三个参数表示模糊度,数值越大,阴影越模糊,第一个参数是一个CGColor,表示阴影颜色,需要由UIColor转换得到。

至此,我们完成了Core Graphics绘图的第二步,也是最复杂的一部分:设置绘图内容。这相当于此前那行代码的中的UI控件。

设置好了绘图的属性之后,就可以调用CGContextDrawPath方法绘图了。第一个参数表示要在哪一个上下文中绘图,第二个参数表示填充类型。在填充类型中可以选择只绘制边框、只填充、同时绘制边框和填充内部区域、奇偶规则填充等。


从方法名不难看出,但是也需要注意的是,这些设置都是对上下文(context)生效的。这样会导致,所有的边框颜色、粗细都一样。一个简单的解决办法就是在需要修改设置之前调用一次CGContextDrawPath方法绘图。再修改设置,修改设置之后再次绘制。

图画完了,还得做一下清理工作。CGPathCreateMutable方法返回的路径是一个Core Fundation Object。而这并不在ARC的管理范围之内。所以需要手动释放对象。

- (void)drawRect:(CGRect)rect {
CGContextRef context = UIGraphicsGetCurrentContext();//获取上下文
CGMutablePathRef path = CGPathCreateMutable();//创建路径
/*
绘图
*/

CGPathRelease(path);
}

这样就完成了Core Graphics绘图的第三部分——开始绘图。
再总结一下使用Core Graphics绘图的步骤:

  1. 获取上下文(画布)
  2. 创建路径(自定义或者调用系统的API)并添加到上下文中。
  3. 进行绘图内容的设置(画笔颜色、粗细、填充区域颜色、阴影、连接点形状等)
  4. 开始绘图(CGContextDrawPath)
  5. 释放路径(CGPathRelease)
收起阅读 »

iOS - 绘图框架CoreGraphics分析

iOS
由于CoreGraphics框架有太多的API,对于初次接触或者对该框架不是十分了解的人,在绘图时,对API的选择会感到有些迷茫,甚至会觉得iOS的图形绘制有些繁琐。因此,本文主要介绍一下iOS的绘图方法和分析一下CoreGraphics框架的绘图原理。一、绘...
继续阅读 »

由于CoreGraphics框架有太多的API,对于初次接触或者对该框架不是十分了解的人,在绘图时,对API的选择会感到有些迷茫,甚至会觉得iOS的图形绘制有些繁琐。因此,本文主要介绍一下iOS的绘图方法和分析一下CoreGraphics框架的绘图原理。

一、绘图系统简介

iOS的绘图框架有多种,我们平常最常用的就是UIKit,其底层是依赖CoreGraphics实现的,而且绝大多数的图形界面也都是由UIKit完成,并且UIImage、NSString、UIBezierPath、UIColor等都知道如何绘制自己,也提供了一些方法来满足我们常用的绘图需求。除了UIKit,还有CoreGraphics、Core Animation,Core Image,OpenGL ES等多种框架,来满足不同的绘图要求。各个框架的大概介绍如下:

  • UIKit:最常用的视图框架,封装度最高,都是OC对象

  • CoreGraphics:主要绘图系统,常用于绘制自定义视图,纯C的API,使用Quartz2D做引擎

  • CoreAnimation:提供强大的2D和3D动画效果

  • CoreImage:给图片提供各种滤镜处理,比如高斯模糊、锐化等

  • OpenGL-ES:主要用于游戏绘制,但它是一套编程规范,具体由设备制造商实现

绘图系统


二、绘图方式

实际的绘图包括两部分:视图绘制视图布局,它们实现的功能是不同的,在理解这两个概念之前,需要了解一下什么是绘图周期,因为都是在绘图周期中进行绘制的。

绘图周期:

  • iOS在运行循环中会整合所有的绘图请求,并一次将它们绘制出来

  • 不能在子线程中绘制,也不能进行复杂的操作,否则会造成主线程卡顿

1.视图绘制

调用UIView的drawRect:方法进行绘制。如果调用一个视图的setNeedsDisplay方法,那么该视图就被标记为重新绘制,并且会在下一次绘制周期中重新绘制,自动调用drawRect:方法。

2.视图布局

调用UIView的layoutSubviews方法。如果调用一个视图的setNeedsLayout方法,那么该视图就被标记为需要重新布局,UIKit会自动调用layoutSubviews方法及其子视图的layoutSubviews方法。

在绘图时,我们应该尽量多使用布局,少使用绘制,是因为布局使用的是GPU,而绘制使用的是CPU。GPU对于图形处理有优势,而CPU要处理的事情较多,且不擅长处理图形,所以尽量使用GPU来处理图形。

三、绘图状态切换

iOS的绘图有多种对应的状态切换,比如:pop/push、save/restore、context/imageContext和CGPathRef/UIBezierPath等,下面分别进行介绍:

1.pop / push

设置绘图的上下文环境(context)

push:UIGraphicsPushContext(context)把context压入栈中,并把context设置为当前绘图上下文

pop:UIGraphicsPopContext将栈顶的上下文弹出,恢复先前的上下文,但是绘图状态不变

下面绘制的视图是黑色

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
UIGraphicsPushContext(UIGraphicsGetCurrentContext());
[[UIColor blackColor] setFill];
UIGraphicsPopContext();
UIRectFill(CGRectMake(90, 340, 100, 100)); // black color
}

2.save / restore

设置绘图的状态(state)

save:CGContextSaveGState 压栈当前的绘图状态,仅仅是绘图状态,不是绘图上下文

restore:恢复刚才保存的绘图状态

下面绘制的视图是红色

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] setFill];
CGContextSaveGState(UIGraphicsGetCurrentContext());
[[UIColor blackColor] setFill];
CGContextRestoreGState(UIGraphicsGetCurrentContext());
UIRectFill(CGRectMake(90, 200, 100, 100)); // red color
}

3.context / imageContext

iOS的绘图必须在一个上下文中绘制,所以在绘图之前要获取一个上下文。如果是绘制图片,就需要获取一个图片的上下文;如果是绘制其它视图,就需要一个非图片上下文。对于上下文的理解,可以认为就是一张画布,然后在上面进行绘图操作。

context:图形上下文,可以通过UIGraphicsGetCurrentContext:获取当前视图的上下文

imageContext:图片上下文,可以通过UIGraphicsBeginImageContextWithOptions:获取一个图片上下文,然后绘制完成后,调用UIGraphicsGetImageFromCurrentImageContext获取绘制的图片,最后要记得关闭图片上下文UIGraphicsEndImageContext。

4.CGPathRef / UIBezierPath

图形的绘制需要绘制一个路径,然后再把路径渲染出来,而CGPathRef就是CoreGraphics框架中的路径绘制类,UIBezierPath是封装CGPathRef的面向OC的类,使用更加方便,但是一些高级特性还是不及CGPathRef。

四、具体绘图方法

由于iOS常用的绘图框架有UIKit和CoreGraphics两个,所以绘图的方法也有多种,下面介绍一下iOS的几种常用的绘图方法。

1.图片类型的上下文

图片上下文的绘制不需要在drawRect:方法中进行,在一个普通的OC方法中就可以绘制

使用UIKit实现

// 获取图片上下文
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
// 绘图
UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
// 从图片上下文中获取绘制的图片
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();

使用CoreGraphics实现

// 获取图片上下文
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
// 绘图
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
// 从图片上下文中获取绘制的图片
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
// 关闭图片上下文
UIGraphicsEndImageContext();

2.drawRect:

在UIView子类的drawRect:方法中实现图形重新绘制,绘图步骤如下:

  • 获取上下文

  • 绘制图形

  • 渲染图形

UIKit方法

- (void) drawRect: (CGRect) rect {
UIBezierPath* p = [UIBezierPathbezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
}

CoreGraphics

- (void) drawRect: (CGRect) rect {
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
}

3.drawLayer:inContext:

在UIView子类的drawLayer:inContext:方法中也可以实现绘图任务,它是一个图层的代理方法,而为了能够调用该方法,需要给图层的delegate设置代理对象,其中代理对象不能是UIView对象,因为UIView对象已经是它内部根层(隐式层)的代理对象,再将它设置为另一个层的代理对象就会出问题。

一个view被添加到其它view上时,图层的变化如下:

  • 先隐式地把此view的layer的CALayerDelegate设置成此view

  • 调用此view的self.layer的drawInContext方法

  • 由于drawLayer方法的注释:If defined, called by the default implementation of -drawInContext:说明了drawInContext里if([self.delegate responseToSelector:@selector(drawLayer:inContext:)])就执行drawLayer:inContext:方法,这里我们因为实现了drawLayer:inContext:所以会执行

  • [super drawLayer:layer inContext:ctx]会让系统自动调用此view的drawRect:方法,至此self.layer画出来了

  • 在self.layer上再加一个子layer,当调用[layer setNeedsDisplay];时会自动调用此layer的drawInContext方法

  • 如果drawRect不重写,就不会调用其layer的drawInContext方法,也就不会调用drawLayer:inContext方法

调用内部根层的drawLayer:inContext:

//如果drawRect不重写,就不会调用其layer的drawInContext方法,也就不会调用drawLayer:inContext方法
-(void)drawRect:(CGRect)rect{
NSLog(@"2-drawRect:");
NSLog(@"drawRect里的CGContext:%@",UIGraphicsGetCurrentContext());
//得到的当前图形上下文正是drawLayer中传递过来的
[super drawRect:rect];
}
#pragma mark - CALayerDelegate
-(void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
NSLog(@"1-drawLayer:inContext:");
NSLog(@"drawLayer里的CGContext:%@",ctx);
// 如果去掉此句就不会执行drawRect!!!!!!!!
[super drawLayer:layer inContext:ctx];
}

调用外部代理对象的drawLayer:inContext:

由于不能把UIView对象设置为CALayerDelegate的代理,所以我们需要创建一个NSObject对象,然后实现drawLayer:inContext:方法,这样就可以在代理对象里绘制所需图形。另外,在设置代理时,不需要遵守CALayerDelegate的代理协议,即这个方法是NSObject的,不需要显式地指定协议。

// MyLayerDelegate.m
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
{
CGContextAddEllipseInRect(ctx, CGRectMake(100,100,100,100));
CGContextSetFillColorWithColor(ctx, [UIColor blueColor].CGColor);
CGContextFillPath(ctx);
}
// ViewController.m
@interface ViewController () @property (nonatomic, strong) id myLayerDelegate;
@end
@implementation ViewController
- (void)viewDidLoad {
// 设置layer的delegate为NSObject子类对象
_myLayerDelegate = [[MyLayerDelegate alloc] init];
MyView *myView = [[MyView alloc] initWithFrame:self.view.bounds];
[self.view addSubview:myView];
CALayer *layer = [CALayer layer];
layer.backgroundColor = [UIColor magentaColor].CGColor;
layer.bounds = CGRectMake(0, 0, 300, 500);
layer.anchorPoint = CGPointZero;
layer.delegate = _myLayerDelegate;
[layer setNeedsDisplay];
[myView.layer addSublayer:layer];
}

详细实现过程

当UIView需要显示时,它内部的层会准备好一个CGContextRef(图形上下文),然后调用delegate(这里就是UIView)的drawLayer:inContext:方法,并且传入已经准备好的CGContextRef对象。而UIView在drawLayer:inContext:方法中又会调用自己的drawRect:方法。平时在drawRect:中通过UIGraphicsGetCurrentContext()获取的就是由层传入的CGContextRef对象,在drawRect:中完成的所有绘图都会填入层的CGContextRef中,然后被拷贝至屏幕。

iOS绘图框架分析如上,如有不足之处,欢迎指出,共同进步。(本文图片来自互联网,版权归原作者所有)

收起阅读 »