动画分析

我首先说哈,可能有人说我写的很烂,很不清楚或者很难懂,等于没讲,哈哈 我这里讲的可能对有些人来说写的好浅显易懂,对有些人只想知道各种细节的人就有些失望了,因为我一贯的思路都是看主干不看细节。有了主干 当想做某件事的时候才会去主干的某一个枝节细看。

android 内有很多类型 这里主要讲过度动画和窗口动画的实现原理,包括动画如何启动以及后续如何更新帧数据。

android 分了各种动画 比如过度动画或者窗口动画 然后过度动画又分了好多种什么Activity 打开动画 关闭动画等等,这里不要被这些花里胡哨的分类给搞昏了头,这里不管分了多少类,其实也只是区别场景而已,比如是app内子activity打开就为TRANSIT_OLD_ACTIVITY_OPEN ,然后应用间切换 其实本质也就是TASK 间切换 就为TRANSIT_OLD_TASK_OPEN(当然还有其他的 这里就只是举一个说明一下 不要咬文嚼字。 这些分类只是为了区分场景而已,不影响动画本身的实现。说到底过度动画和窗口动画实现的本质是一样的。

说了这些就来看一下这个动画实现的本质吧。我记忆力不好,一般我会去把问题看清他的实质,不去记忆一些细节,反正也记不住。

为了说清楚这个动画 我接下来会从下面几个方面来详细说:

  1. 动画作用对象
    也就动画的主体对象
  2. 如何启动动画
  3. 动画的帧回调如何实现的(也就是动画启动后如何持续跑起来直到结束)
  4. 每一帧数据如何更新
    这几个问题说明了 一个动画也就出来了对吧。

首先看动画作用对象吧:
无论是过渡动画还是窗口动画,实质上都是各个窗口容器(如 WindowState ActivityRecord)对自己的WindowContainer对应的SurfaceControl应用动画,然而直接对SurfaceControl做动画 又会存在不稳定的情况,因为动画线程一般不会持有WMS 全局锁(就算持有也不合理,主线程去等各种动画?那还不把system卡死)。也会导致同步困难,如我动画还没做完,systemServer线程就对SurfaceControl做移除相关操作,那就GG了。所以android引入对自己的WindowContainer对应的SurfaceControl引入leash SurfaceControl,动画作用在leash之上,这个leash 很简单就是在各个窗口容器和他的父亲节点之间插入一个节点(简单的说就是在链(本质上是树)上插入一个节点。

好再来看如何启动动画:
过度动画一般是作用在Task ,windowState ,activityrecord等WindowContainer
要启动动画就直接来看WindowContainer 的startAnimation 吧

    void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,@AnimationType int type,@Nullable OnAnimationFinishedCallback animationFinishedCallback) {if (DEBUG_ANIM) {Slog.v(TAG, "Starting animation on " + this + ": type=" + type + ", anim=" + anim);}// TODO: This should use isVisible() but because isVisible has a really weird meaning at// the moment this doesn't work for all animatable window containers.mSurfaceAnimator.startAnimation(t, anim, hidden, type, animationFinishedCallback,mSurfaceFreezer);}

其实就是mSurfaceAnimator.startAnimation(t, anim, hidden, type, animationFinishedCallback,
mSurfaceFreezer);
看看定义

 /*** Starts an animation.** @param anim The object that bridges the controller, {@link SurfaceAnimator}, with the*             component responsible for running the animation. It runs the animation with*             {@link AnimationAdapter#startAnimation} once the hierarchy with*             the Leash has been set up.* @param hidden Whether the container holding the child surfaces is currently visible or not.*               This is important as it will start with the leash hidden or visible before*               handing it to the component that is responsible to run the animation.* @param animationFinishedCallback The callback being triggered when the animation finishes.*/void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,@AnimationType int type,@Nullable OnAnimationFinishedCallback animationFinishedCallback,@Nullable SurfaceFreezer freezer) {

额这些乱七八糟的说明,看不懂也没关系,可以不看 哈哈,我来说吧
Transaction t
这个Transaction 主要是负责保存作用在SurfaceControl的第一帧数据,并在随后适合的时机下发给SurfaceFlinger,了解即可

AnimationAdapter anim
这个比较重要,算是核心了,这个就是动画实际的帧处理(也可以说是实际动画的帧处理回调)也就是后面要说的第四点

int type
这个就是前面说的定义的各种动画类型了,不做过多说明了,没啥意义 ,为了区分动画场景,随你怎么理解了。

其他几个参数就不说了,这里你只需要知道一个关键参数anim 即可,这里这个虽然是anim,实际上他只是一个帧数据处理。我觉得这么叫更为合理。

好,我们继续
下一步就是

    void startAnimation(Transaction t, AnimationAdapter anim, boolean hidden,@AnimationType int type,@Nullable OnAnimationFinishedCallback animationFinishedCallback,@Nullable SurfaceFreezer freezer) {cancelAnimation(t, true /* restarting */, true /* forwardCancel */);mAnimation = anim;mAnimationType = type;mAnimationFinishedCallback = animationFinishedCallback;final SurfaceControl surface = mAnimatable.getSurfaceControl();if (surface == null) {Slog.w(TAG, "Unable to start animation, surface is null or no children.");cancelAnimation();return;}mLeash = freezer != null ? freezer.takeLeashForAnimation() : null;if (mLeash == null) {/**/ *重点:创建leash 对象,就是上一步的动画对象,具体怎么创建的自己看,很简单 没必要说前面也大致说了***mLeash = createAnimationLeash(mAnimatable, surface, t, type,mAnimatable.getSurfaceWidth(), mAnimatable.getSurfaceHeight(), 0 /* x */,0 /* y */, hidden, mService.mTransactionFactory);mAnimatable.onAnimationLeashCreated(t, mLeash);}mAnimatable.onLeashAnimationStarting(t, mLeash);if (mAnimationStartDelayed) {if (DEBUG_ANIM) Slog.i(TAG, "Animation start delayed");return;}// 继续startmAnimation.startAnimation(mLeash, t, type, mInnerAnimationFinishedCallback);}

就是说先创建动画的操作对象leash surfacecontrol

紧接着就是调用前面我们说的那个AnimationAdapter anim的 startAnimation

AnimationAdapter是个接口类,像窗口动画和过度动画大部分场景,其实现类为LocalAnimationAdapter
以窗口动画来说吧,前面为了简单,我们直接从WindowContainer的startAnimation来切入的

主要是为了不去看各个子类(WindowState, Task 等)在启动的时候各种乱七八糟的处理,我们既然是为了看清一个事情的本质,所谓本质我个人的理解就是事物的核心主干,不管一个人的皮囊各式各样,但是他的骨架大致都是一样的,只留下这个骨架我们才能不负重前行,血肉都抛弃了吧,需要的时候我们自己再为骨架添加血肉。

来看看WinddowState启动一个动画 其实主要就是创建一个LocalAnimationAdapter ,这个你也可以实现不同的LocalAnimationAdapter,我们只讲源生的了。
其需要初始化一个AnimationSpec 这里是一个WindowAnimationSpec 这个也是可以根据自己动画需要自己去实现不同的AnimationSpec

    void startAnimation(Animation anim) {// If we are an inset provider, all our animations are driven by the inset client.if (mControllableInsetProvider != null) {return;}final DisplayInfo displayInfo = getDisplayInfo();anim.initialize(mWindowFrames.mFrame.width(), mWindowFrames.mFrame.height(),displayInfo.appWidth, displayInfo.appHeight);anim.restrictDuration(MAX_ANIMATION_DURATION);anim.scaleCurrentDuration(mWmService.getWindowAnimationScaleLocked());final AnimationAdapter adapter = new LocalAnimationAdapter(new WindowAnimationSpec(anim, mSurfacePosition, false /* canSkipFirstFrame */,0 /* windowCornerRadius */),mWmService.mSurfaceAnimationRunner);startAnimation(getPendingTransaction(), adapter);commitPendingTransaction();}

我们就来看就是调用

    private final SurfaceAnimationRunner mAnimator;private final AnimationSpec mSpec;@Overridepublic void startAnimation(SurfaceControl animationLeash, Transaction t,@AnimationType int type, OnAnimationFinishedCallback finishCallback) {mAnimator.startAnimation(mSpec, animationLeash, t,() -> finishCallback.onAnimationFinished(type, this));}

其就调用了SurfaceAnimationRunner (大部分过度动画都是共用的 mWmService.mSurfaceAnimationRunner)的startAnimation 参数传入AnimationSpec(以WindowAnimationSpec为例)

好了简单了,来看SurfaceAnimationRunner

   void startAnimation(AnimationSpec a, SurfaceControl animationLeash, Transaction t,Runnable finishCallback) {synchronized (mLock) {final RunningAnimation runningAnim = new RunningAnimation(a, animationLeash,finishCallback);mPendingAnimations.put(animationLeash, runningAnim);if (!mAnimationStartDeferred) {mChoreographer.postFrameCallback(this::startAnimations);}// Some animations (e.g. move animations) require the initial transform to be applied// immediately.applyTransformation(runningAnim, t, 0 /* currentPlayTime */);}}

好了简单了,只做了三件事
对即将启动的动画加入列表 mPendingAnimations.put(animationLeash, runningAnim);(实际上动画还没启动)
mPendingAnimations.put(animationLeash, runningAnim);
applyTransformation(runningAnim, t, 0 /* currentPlayTime */);
如注释所说就是首先应用动画初始状态,也可以叫初始帧数据,随你怎么叫了,就是第一帧数据这里就设置了。
然后注册了个回调mChoreographer.postFrameCallback(this::startAnimations); 去真正的启动动画

mChoreographer 就是Choreographer ,这个对于熟悉动画的应该知道吧,简单可以理解为vsync的接收和处理回调的即可,动画本质上主要就是向Choreographer 注册回调,待vsync来了后在回调注册的回调去处理动画帧数据,反复循环,直到动画结束。

好了,再看看如何真正启动动画吧:

    @GuardedBy("mLock")private void startAnimationLocked(RunningAnimation a) {final ValueAnimator anim = mAnimatorFactory.makeAnimator();// Animation length is already expected to be scaled.anim.overrideDurationScale(1.0f);anim.setDuration(a.mAnimSpec.getDuration());anim.addUpdateListener(animation -> {synchronized (mCancelLock) {if (!a.mCancelled) {final long duration = anim.getDuration();long currentPlayTime = anim.getCurrentPlayTime();if (currentPlayTime > duration) {currentPlayTime = duration;}applyTransformation(a, mFrameTransaction, currentPlayTime);}}// Transaction will be applied in the commit phase.scheduleApplyTransaction();});anim.addListener(new AnimatorListenerAdapter() {@Overridepublic void onAnimationStart(Animator animation) {synchronized (mCancelLock) {if (!a.mCancelled) {// TODO: change this back to use show instead of alpha when b/138459974 is// fixed.mFrameTransaction.setAlpha(a.mLeash, 1);}}}@Overridepublic void onAnimationEnd(Animator animation) {synchronized (mLock) {mRunningAnimations.remove(a.mLeash);synchronized (mCancelLock) {if (!a.mCancelled) {// Post on other thread that we can push final state without jank.mAnimationThreadHandler.post(a.mFinishCallback);}}}}});a.mAnim = anim;mRunningAnimations.put(a.mLeash, a);anim.start();if (a.mAnimSpec.canSkipFirstFrame()) {// If we can skip the first frame, we start one frame later.anim.setCurrentPlayTime(mChoreographer.getFrameIntervalNanos() / NANOS_PER_MS);}// Immediately start the animation by manually applying an animation frame. Otherwise, the// start time would only be set in the next frame, leading to a delay.anim.doAnimationFrame(mChoreographer.getFrameTime());}

可以看到实际上是启动了个ValueAnimator 继承自Animator,这才是真正的动画发动机(哈哈也许这样说有人反驳,他并不是真正的发动机,因为里面的AnimationHander才是,无所谓了你开心就好,我就要这么讲),就是我们下面要讲的第三点。待会讲

所以启动动画最终就是启动一个Animator(哈哈 PS 竟然不是SurfaceAnimationRunner,我最初一直以为是SurfaceAnimationRunner,不好意思 我最初没怎么了解动画具体实现,所以开始我一直想在SurfaceAnimationRunner里面找到动画的持续回调(也就是第三点要讲的动画过程),然而并找不到,而且我也疑惑这里不是应该可以处理么?那为什么源生没有在这处理,开始我也很奇怪干嘛还要有ValueAnimator,其实啊这么设计是有道理的SurfaceAnimationRunner主要是负责启动动画的帧同步,另一方面如果所有的动画回调都在这处理,性能你能保证么?所以就有了ValueAnimator去处理各种的动画,个人理解哈 可能并不完全正确,但是不影响这个动画实现流程说明)。
然后呢

 anim.addUpdateListener(animation -> {synchronized (mCancelLock) {if (!a.mCancelled) {final long duration = anim.getDuration();long currentPlayTime = anim.getCurrentPlayTime();if (currentPlayTime > duration) {currentPlayTime = duration;}applyTransformation(a, mFrameTransaction, currentPlayTime);}}// Transaction will be applied in the commit phase.scheduleApplyTransaction();});

注册一个帧处理回调
applyTransformation(a, mFrameTransaction, currentPlayTime); 这玩意就是我们要说的第四点。
待会讲。

突然不想讲了,因为到这了基本大部分人应该都明白了对吧。

哈哈还说说吧,那第三点动画怎么循环跑起来
那就得看ValueAnimator 的start

 private void start(boolean playBackwards) {if (Looper.myLooper() == null) {throw new AndroidRuntimeException("Animators may only be run on Looper threads");}mReversing = playBackwards;mSelfPulse = !mSuppressSelfPulseRequested;// Special case: reversing from seek-to-0 should act as if not seeked at all.if (playBackwards && mSeekFraction != -1 && mSeekFraction != 0) {if (mRepeatCount == INFINITE) {// Calculate the fraction of the current iteration.float fraction = (float) (mSeekFraction - Math.floor(mSeekFraction));mSeekFraction = 1 - fraction;} else {mSeekFraction = 1 + mRepeatCount - mSeekFraction;}}mStarted = true;mPaused = false;mRunning = false;mAnimationEndRequested = false;// Resets mLastFrameTime when start() is called, so that if the animation was running,// calling start() would put the animation in the// started-but-not-yet-reached-the-first-frame phase.mLastFrameTime = -1;mFirstFrameTime = -1;mStartTime = -1;addAnimationCallback(0);if (mStartDelay == 0 || mSeekFraction >= 0 || mReversing) {// If there's no start delay, init the animation and notify start listeners right away// to be consistent with the previous behavior. Otherwise, postpone this until the first// frame after the start delay.startAnimation();if (mSeekFraction == -1) {// No seek, start at play time 0. Note that the reason we are not using fraction 0// is because for animations with 0 duration, we want to be consistent with pre-N// behavior: skip to the final value immediately.setCurrentPlayTime(0);} else {setCurrentFraction(mSeekFraction);}}}

这里面一个关键是调用了addAnimationCallback

  private void addAnimationCallback(long delay) {if (!mSelfPulse) {return;}getAnimationHandler().addAnimationFrameCallback(this, delay);}

哈哈 AnimationHandler出场了,没错他才是真正负责Choreographer ,发动机的主件(哈哈我是把他看成发动机的一部分了,无所谓了这玩意你想怎么理解就怎么理解,你说他是发动机也行,对吧,这玩意本身就是个定义,自己觉得怎么样对就怎么样定义吧)。

看一眼吧:

   public void addAnimationFrameCallback(final AnimationFrameCallback callback, long delay) {if (mAnimationCallbacks.size() == 0) {getProvider().postFrameCallback(mFrameCallback);}if (!mAnimationCallbacks.contains(callback)) {mAnimationCallbacks.add(callback);}if (delay > 0) {mDelayedCallbackStartTime.put(callback, (SystemClock.uptimeMillis() + delay));}}

这里就是getProvider().postFrameCallback(mFrameCallback); 不继续说了,有兴趣的自己看,我就说他是向Choreographer 注册回调了

那这里就是注册一个回调,如何让他循环起来呢,秘密就在mFrameCallback

  private final Choreographer.FrameCallback mFrameCallback = new Choreographer.FrameCallback() {@Overridepublic void doFrame(long frameTimeNanos) {doAnimationFrame(getProvider().getFrameTime());if (mAnimationCallbacks.size() > 0) {getProvider().postFrameCallback(this);}}};

这个就是一旦你把回调注册进来了,不移除,hander就会自动给你注册下一次回调。

好了第三点也出来了

第四点就很简单了
前面讲第二点的时候,也就是这里的doAnimationFrame ,就会回调第二点注册的addUpdateListener

最终也就是回调
SurfaceAnimationRunner 里的

    private void applyTransformation(RunningAnimation a, Transaction t, long currentPlayTime) {a.mAnimSpec.apply(t, a.mLeash, currentPlayTime);}

其实也就是前面说的WindowAnimationSpec 的apply,(额不一定是WindowAnimationSpec,严格说就是AnimationSpec哈哈,我相信你懂得)。

就看一眼WindowAnimationSpec的apply呗

    @Overridepublic void apply(Transaction t, SurfaceControl leash, long currentPlayTime) {final TmpValues tmp = mThreadLocalTmps.get();tmp.transformation.clear();mAnimation.getTransformation(currentPlayTime, tmp.transformation);tmp.transformation.getMatrix().postTranslate(mPosition.x, mPosition.y);t.setMatrix(leash, tmp.transformation.getMatrix(), tmp.floats);t.setAlpha(leash, tmp.transformation.getAlpha());boolean cropSet = false;if (mRootTaskClipMode == ROOT_TASK_CLIP_NONE) {if (tmp.transformation.hasClipRect()) {t.setWindowCrop(leash, tmp.transformation.getClipRect());cropSet = true;}} else {mTmpRect.set(mRootTaskBounds);if (tmp.transformation.hasClipRect()) {mTmpRect.intersect(tmp.transformation.getClipRect());}t.setWindowCrop(leash, mTmpRect);cropSet = true;}float cornerRadius = mWindowCornerRadius;if (mActivityThumbnailHelper != null) {final float curScaleX = tmp.floats[Matrix.MSCALE_X];final float curScaleY = tmp.floats[Matrix.MSCALE_Y];float scale = Math.max(curScaleX, curScaleY);float thumbLeashCornerRadius = cornerRadius;boolean isScaledThumbnail = if (scale != 0.0f) {cornerRadius /=scale;}if (scale != 0.0f && isScaledThumbnail) {thumbLeashCornerRadius /= scale;}if (hasScaleWithClipAnimation) {mActivityThumbnailHelper.stepScaleUpDownAnimation(t, tmp.transformation, isScaledThumbnail);} final SurfaceControl tempLeash = mActivityThumbnailHelper.getLeash();if (tempLeash != null && thumbLeashCornerRadius > 0.0f ) {t.setCornerRadius(tempLeash, thumbLeashCornerRadius);}}// END// We can only apply rounded corner if a crop is set, as otherwise the value is meaningless,// since it doesn't have anything it's relative to.}

嗯 简单 了,就是将private Animation mAnimation; 计算出来的数据设置给leash完事了

这样动画就跑起来了对吧。

好吧重点来了,总结下吧,我的简单说:

我就喜欢把这些玩意用自己的一句话说清楚,

不管是过度动画还是窗口动画就是就是把你定义的Animation,以AnimationSpec的形式封装,并创建对应的动画操作leash,然后通过SurfaceAnimationRunner启动一个ValueAnimator让你的动画跑起来就完事了,是不是相当简单。

额 所以问题就来了,窗口或者过度动画本质上是SurfaceCtrol动画,而且动画实质上和窗口以及SurfaceAnimationRunner 没任何鸟关系,我们只需要Animation ValueAnimator 以及leash 其实就完事了。甚至说只需要ValueAnimator 和leash 就完事了 因为ValueAnimator 和Animation 可合并。哈哈 是不是就是常见的动画结构

哈哈 是不是说成这样 觉得尼玛,这只剩下骨头确实有点丑了对吧,啥都不剩了。哈哈 本来就这样 事情你看透了 ,就啥也不是了。

辉哥基于android S 分析过度动画以及窗口动画相关推荐

  1. 怎样取消Windows 10的虚拟桌面切换动画和窗口动画

    怎样取消Windows 10的虚拟桌面切换动画和窗口动画 对于晕3D的人来说这是真的要命. 步骤: 在"这台电脑"上点击右键(如bai何在Win10桌面上显示"du这台电 ...

  2. 基于android单词本分析与实现,基于Android的单词学习系统设计与实现

    摘要: 随着中国国际化程度的提高,英语的普遍性和重要性日益凸显.英语作为一门语言,其基础是词汇,英文词汇量的扩充是提高英文水平的基础.学习者对英文词汇的学习包括遇到生词时的单词查询和有计划的词汇记忆. ...

  3. 4.基于Android 12 分析系统启动过程

    基于Android12 分析系统启动过程 本文基于AOSP Android12的源码分析Android系统的启动流程. 由于这部分内容各版本之间差异不大,同样适用于Android12之前的版本. 1. ...

  4. Android Framework 窗口子系统 (08)窗口动画之动画系统框架

    该系列文章总纲链接:专题分纲目录 Android Framework 窗口子系统 本章关键点总结 & 说明: 导图是不断迭代的,这里主要关注➕ 左上角 Android 窗口动画系统部分(因为导 ...

  5. android wms 窗口,Android6.0 WMS(十一) WMS窗口动画生成及播放

    上一篇我们我们分析到有VSync信号过来,最后会调用WindowAnimator的animateLocked函数来生成和播放动画,这篇我们我们主要从这个函数开始分析. animateLocked函数 ...

  6. android窗口动画和过渡动画(activity和dialog)

    from:http://blog.sina.com.cn/s/blog_ba23fa6f0102v32g.html 窗口动画和过渡动画是指在窗口(activity或dialog)切换时的显示动画,窗口 ...

  7. android 滑动缩放监听,基于Android的ViewPager动画特效实现页面左右滑动效果(实现缩放...

    基于Android的ViewPager动画特效实现页面左右滑动效果(实现缩放 基于Android的ViewPager动画特效实现页面左右滑动效果(实现缩放和透明效果) 在上一个项目的基础上做修改,项目 ...

  8. 基于Android系统的IPv6网络接入分析

                                                                      基于Android系统的IPv6网络接入分析 摘 要:本文深入分析了 ...

  9. 基于Android Q电池服务分析

    基于Android Q的电池服务分析之充电类型判断 开局先说明一下我的需求和我遇到的难题 问题 插入充电没有提示音和图标更新 插入充电没有任何反应和提示,但是确实是在充电 需求 在设置的电池中增加充电 ...

  10. android手机舆情分析,基于Android平台的环境公共舆情监督系统研究

    摘要: 近年来,随着我国社会经济的持续发展和人民生活水平的不断提高,人们的环境保护意识也在不断增长,其中城市环境质量问题逐渐成为了人们普遍关注的焦点,也成为环保部门和环保从业人员的重点研究方向.环境监 ...

最新文章

  1. python脚本获取内网,公网ip
  2. 自己封装JSTL 自定义标签
  3. 《爱上统计学》读书笔记
  4. 【图示】小程序云开发和不使用云开发的区别
  5. 增删改查java代码_程序员:听说你正在为天天写增删改查代码而烦恼
  6. 如何逃过taint droid的跟踪
  7. python中文文本处理_python简单文本处理的方法
  8. mysql5.7 java读取乱码
  9. 常见的Java编程思想有哪些
  10. 推荐几款git管理工具
  11. matlab 模拟电子仿真,基于MATLABSimulink的模拟电子电路仿真
  12. SpringBoot 整合 Editormd(完整版)
  13. QCOM和其他常见芯片平台术语缩写
  14. 讲座记录——科技论文写作及科研方法
  15. win7右击应用程序资源管理器停止工作问题
  16. Mac安装ffmpeg时 Failed to download resource quot;texi2htmlquot; 的解决办法
  17. C++(电子)PPT例6、例7作业提交
  18. 银保监机构保险许可证数据(2007-2022年)
  19. labuladong 公众号的使用方法
  20. 淘宝店群玩法,双十一商家自运营,淘宝店群好处,建淘宝店群门槛条件

热门文章

  1. 阿里云ACE认证之理解CDN技术 1
  2. typora的安装和使用
  3. 通达信手机版分时图指标大全_手机炒股神器通达信公式手机版安装方法
  4. 扫描枪速度测试软件,条码扫描枪怎么测试
  5. LIKE 多字段匹配 效率低下
  6. win10系统计算机如何分盘,win10新电脑怎么合理分盘?给win10电脑合理分盘的设置方法...
  7. 【每天学点心理学第七期】人性定理:人都是以服务于他自己为目的的!
  8. 如果使用git克隆远程创库,pull提示Can‘t update master has no tracked branch
  9. 浏览器大全推荐丨这26款浏览器你用过几个?
  10. HTML网页设计:导航栏