源码分析篇 - Android绘制流程(三)requestLayout()与invalidate()流程分析

  本文主要探讨能够触发performTraversals()执行的invalidate()、postInvalidate()和requestLayout()方法的流程。invalidate()和postInvalidate()能够触发View的重画,这两个方法最终会调用到performTraversals()中的performDraw()来完成重绘制,但是是否会执行onMeasure()和onLayout()过程要根据标志位的状况来决定;requesetLayout()方法也会调用到performTraversals()方法,但是只会执行measure和layout流程,不会调用到draw流程来触发重画动作。 

一、requestLayout()流程

  直接来看View.requestLayout()代码。

    @CallSuper
    public void requestLayout() {
        if (mMeasureCache != null) mMeasureCache.clear();
          //如果当前的整个View树在进行布局流程的话,则会调用requestLayoutDuringLayout()        //让这次的布局延时执行
        if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) {
            // Only trigger request-during-layout logic if this is the view requesting it,
            // not the views in its parent hierarchy
            ViewRootImpl viewRoot = getViewRootImpl();
            if (viewRoot != null && viewRoot.isInLayout()) {
                if (!viewRoot.requestLayoutDuringLayout(this)) {
                    return;
                }
            }
            mAttachInfo.mViewRequestingLayout = this;
        }
       //PFLAG_FORCE_LAYOUT会在执行View的measure()和layout()方法时判断        //只有设置过该标志位,才会执行measure()和layout()流程
        mPrivateFlags |= PFLAG_FORCE_LAYOUT;
        mPrivateFlags |= PFLAG_INVALIDATED;

        if (mParent != null && !mParent.isLayoutRequested()) {
            mParent.requestLayout();
        }
        if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) {
            mAttachInfo.mViewRequestingLayout = null;
        }
    }

  该方法主要是设置了PFLAG_FORCE_LAYOUT和PFLAG_INVALIDATED到当前View的Flag中,然后调用到当前View(当前View可能是一个控件View,也可能是一个布局View,因为对于这两类View都能调用requestLayout()方法)的父布局View的requestLayout()方法,父布局View是ViewGroup类型,没有重写该requestLayout()方法,所以实际还是调回到View.requestLayout()方法的这套逻辑。这个过程,就是设置当前View标志位后,就不断的向上调用父布局View的requestLayout(),最后调用到根View即DecorView的requestLayout(),而DecorView的mParent变量指向的是当前窗口对应的ViewRootImpl对象,最后一次设置完DecorView标志位后,调用到ViewRootImpl.requestLayout()方法,进入该代码。

    @Override
    public void requestLayout() {     //该boolean变量会在ViewRootImpl.performLayout()开始时置为ture,结束置false        //表示当前不处于Layout过程     if (!mHandlingLayoutInLayoutRequest) {
            checkThread();
            mLayoutRequested = true;
            scheduleTraversals();
        }
    }

  如果当前不是正在执行layout过程,则会调用scheduleTraversals()方法,进入ViewRootImpl.scheduleTraversals()。

    void scheduleTraversals() {
        if (!mTraversalScheduled) {       //在下一段代码处会置回false       //表示在排好这次绘制请求前,不再排其它的绘制请求
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            if (!mUnbufferedInputDispatch) {
                scheduleConsumeBatchedInput();
            }
            notifyRendererOfFramePending();
            pokeDrawLockIfNeeded();
        }
    }

  这里主要是调用到了ViewRootImpl的另一个重要的变量mChoreographer,它是Choreographer类型的,这个对象会请求Vsync信号来控制绘制的进行,实现了按帧进行绘制的机制,这个类会在后面的文章进行介绍。该方法对于绘制的请求经过了Choreographer的编排后,最终会调用回ViewRootImpl.doTraversal()方法。

    void doTraversal() {
        if (mTraversalScheduled) {
            mTraversalScheduled = false;
            mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
               ... //用于调试相关代码

            performTraversals();

            ... //用于调试相关代码
        }
    }

  然后调用到ViewRootImpl.performTraversals()方法。

二、invalidate()与postInvalidate()流程 

  invalidate()与postInvalidate()都是用于被调用来触发View的更新(重画)动作,区别在于invalidate()方法是在UI线程自身中使用,而postInvalidate()是非UI线程中使用。 首先来看View.postInvalidate()。

  public void postInvalidate() {
        postInvalidateDelayed(0);
    }

    public void postInvalidateDelayed(long delayMilliseconds) {
        // We try only with the AttachInfo because there‘s no point in invalidating
        // if we are not attached to our window
        final AttachInfo attachInfo = mAttachInfo;
        if (attachInfo != null) {
            attachInfo.mViewRootImpl.dispatchInvalidateDelayed(this, delayMilliseconds);
        }
    }

  调用到了对应的ViewRootImpl对象的dispatchInvalidateDelayed()方法,进入该代码。

    public void dispatchInvalidateDelayed(View view, long delayMilliseconds) {
        Message msg = mHandler.obtainMessage(MSG_INVALIDATE, view);
        mHandler.sendMessageDelayed(msg, delayMilliseconds);
    }

  这里实现了一个消息机制,发送了MSG_INVSLIDSTE。进入处理消息的ViewRootImpl.handleMessage()方法。

       @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MSG_INVALIDATE:
                ((View) msg.obj).invalidate();
                break;
             ...
    }       

  这里实际上就是调回了调用postInvalidate()方法的View的invalidate()方法。由于invalidate()方法只能在UI线程执行,所以postInvalidate只是实现了一个消息机制,让用户能够在非UI线程使用,最终还是调用到invalidate()方法来触发重画,实现界面更新动作。继续来看View.invalidate()方法,该方法逻辑的实际实际上时调用到invalidateInternal()方法来实现的。

   public void invalidate() {
        invalidate(true);
    }

    void invalidate(boolean invalidateCache) {     //mLeft、mRigth、mTop、mBottom记录的是当前View边界距离其父布局View边界的距离
        invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
    }

    void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,
            boolean fullInvalidate) {
        if (mGhostView != null) {
            mGhostView.invalidate(true);
            return;
        }
     //如果当前视图为不可见状态且没有动画正在执行,且其父布局也没有过渡动画执行,则跳过
        if (skipInvalidate()) {
            return;
        }
     //当前View没有正在执行该方法     //或绘制缓存可用或未重绘过或透明度发生改变     //PFLAG_DRAWN会在该方法内去改标志位     //PFLAG_INVALIDATED会在View.draw()方法执行时去掉该标志位     if ((mPrivateFlags & (PFLAG_DRAWN | PFLAG_HAS_BOUNDS)) == (PFLAG_DRAWN | PFLAG_HAS_BOUNDS)
                || (invalidateCache && (mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == PFLAG_DRAWING_CACHE_VALID)
                || (mPrivateFlags & PFLAG_INVALIDATED) != PFLAG_INVALIDATED
                || (fullInvalidate && isOpaque() != mLastIsOpaque)) {
                   //如果需要全部重绘,invalidate()未传参调用时默认为true       if (fullInvalidate) {
                mLastIsOpaque = isOpaque();
                mPrivateFlags &= ~PFLAG_DRAWN;
            }

            mPrivateFlags |= PFLAG_DIRTY;

            if (invalidateCache) {
                mPrivateFlags |= PFLAG_INVALIDATED;
                mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
            }

            // Propagate the damage rectangle to the parent view.       //damage记录的区域是需要更新的dirty区域,当前的坐标时相对于自身来设置的       //通过不断调用到父类的invalidateChild()方法,来不断更新dirty区域的相对坐标       final AttachInfo ai = mAttachInfo;
            final ViewParent p = mParent;
            if (p != null && ai != null && l < r && t < b) {
                final Rect damage = ai.mTmpInvalRect;
                damage.set(l, t, r, b);
                p.invalidateChild(this, damage);
            }

            // Damage the entire projection receiver, if necessary.
            if (mBackground != null && mBackground.isProjected()) {
                final View receiver = getProjectionReceiver();
                if (receiver != null) {
                    receiver.damageInParent();
                }
            }

            // Damage the entire IsolatedZVolume receiving this view‘s shadow.
            if (isHardwareAccelerated() && getZ() != 0) {
                damageShadowReceiver();
            }
        }
    }

  这里会通过调用mParent的invalidateChild()方法,来触发父类对于dirty区域的调整(可能会调整可能还是原区域)及改区域相对坐标的调整。进入ViewGroup.invalidateChild()方法。

@Override
    public final void invalidateChild(View child, final Rect dirty) {
        ViewParent parent = this;

        final AttachInfo attachInfo = mAttachInfo;
        if (attachInfo != null) {
            // If the child is drawing an animation, we want to copy this flag onto
            // ourselves and the parent to make sure the invalidate request goes
            // through       //drawAnimation记录调用该方法的子View是否正在执行动画
            final boolean drawAnimation = (child.mPrivateFlags & PFLAG_DRAW_ANIMATION)
                    == PFLAG_DRAW_ANIMATION;

            // Check whether the child that requests the invalidate is fully opaque
            // Views being animated or transformed are not considered opaque because we may
            // be invalidating their old position and need the parent to paint behind them.       //调用该方法的子View是否不透明:处于不透明状态且没有在执行动画且变化矩阵没有变化       //Matrix可以用于View的平移、缩放、扩放、旋转等操作,比如某些应用上的双指缩放功能
            Matrix childMatrix = child.getMatrix();
            final boolean isOpaque = child.isOpaque() && !drawAnimation &&
                    child.getAnimation() == null && childMatrix.isIdentity();
            // Mark the child as dirty, using the appropriate flag
            // Make sure we do not set both flags at the same time
            int opaqueFlag = isOpaque ? PFLAG_DIRTY_OPAQUE : PFLAG_DIRTY;

            if (child.mLayerType != LAYER_TYPE_NONE) {
                mPrivateFlags |= PFLAG_INVALIDATED;
                mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
            }
       final int[] location = attachInfo.mInvalidateChildLocation;            //记录子View边界距离父View左边界和上边界的距离到Location中,用于下一段代码中的计算
            location[CHILD_LEFT_INDEX] = child.mLeft;
            location[CHILD_TOP_INDEX] = child.mTop;       //如果子View设置了变换矩阵,则根据变换矩阵调整dirty区域
            if (!childMatrix.isIdentity() ||
                    (mGroupFlags & ViewGroup.FLAG_SUPPORT_STATIC_TRANSFORMATIONS) != 0) {
                RectF boundingRect = attachInfo.mTmpTransformRect;
                boundingRect.set(dirty);
                Matrix transformMatrix;
                if ((mGroupFlags & ViewGroup.FLAG_SUPPORT_STATIC_TRANSFORMATIONS) != 0) {
                    Transformation t = attachInfo.mTmpTransformation;
                    boolean transformed = getChildStaticTransformation(child, t);
                    if (transformed) {
                        transformMatrix = attachInfo.mTmpMatrix;
                        transformMatrix.set(t.getMatrix());
                        if (!childMatrix.isIdentity()) {
                            transformMatrix.preConcat(childMatrix);
                        }
                    } else {
                        transformMatrix = childMatrix;
                    }
                } else {
                    transformMatrix = childMatrix;
                }
                transformMatrix.mapRect(boundingRect);
                dirty.set((int) Math.floor(boundingRect.left),
                        (int) Math.floor(boundingRect.top),
                        (int) Math.ceil(boundingRect.right),
                        (int) Math.ceil(boundingRect.bottom));
            }
       //这是一个从当前的布局View向上不断遍历当前布局View的父布局,最后遍历到ViewRootImpl的循环
            do {
                View view = null;          //parent可能为ViewGroup类型,也可能为ViewRootImpl类型                //最后一次循环执行时为ViewRootImpl类型
                if (parent instanceof View) {
                    view = (View) parent;
                }
          //如果子View正在执行动画,设置遍历的父布局View的动画标识
                if (drawAnimation) {
                    if (view != null) {
                        view.mPrivateFlags |= PFLAG_DRAW_ANIMATION;
                    } else if (parent instanceof ViewRootImpl) {
                        ((ViewRootImpl) parent).mIsAnimating = true;
                    }
                }

                // If the parent is dirty opaque or not dirty, mark it dirty with the opaque
                // flag coming from the child that initiated the invalidate          //设置当前ViewGroup的Dirty标识,表示当前的ViewGroup需要重绘
                if (view != null) {
                    if ((view.mViewFlags & FADING_EDGE_MASK) != 0 &&
                            view.getSolidColor() == 0) {
                        opaqueFlag = PFLAG_DIRTY;
                    }
                    if ((view.mPrivateFlags & PFLAG_DIRTY_MASK) != PFLAG_DIRTY) {
                        view.mPrivateFlags = (view.mPrivateFlags & ~PFLAG_DIRTY_MASK) | opaqueFlag;
                    }
                }
          //调用当前布局View的invalidateChildParent()方法,返回的值为当前布局View的父布局          //通过循环向上调用,最后返回的根布局是ViewRootImpl对象          parent = parent.invalidateChildInParent(location, dirty);
                if (view != null) {
                    // Account for transform on current parent
                    Matrix m = view.getMatrix();
                    if (!m.isIdentity()) {
                        RectF boundingRect = attachInfo.mTmpTransformRect;
                        boundingRect.set(dirty);
                        m.mapRect(boundingRect);
                        dirty.set((int) Math.floor(boundingRect.left),
                                (int) Math.floor(boundingRect.top),
                                (int) Math.ceil(boundingRect.right),
                                (int) Math.ceil(boundingRect.bottom));
                    }
                }
            } while (parent != null);
        }
    }

  在do-while循环中会调用到parent = parent.invalidateChildInParent(location, dirty),这里执行到ViewGroup.invalidateChildInParent()方法。

@Override
    public ViewParent invalidateChildInParent(final int[] location, final Rect dirty) {     //
        if ((mPrivateFlags & PFLAG_DRAWN) == PFLAG_DRAWN ||
                (mPrivateFlags & PFLAG_DRAWING_CACHE_VALID) == PFLAG_DRAWING_CACHE_VALID) {        //如果ViewGroup有没有动画执行或者动画已经完成
            if ((mGroupFlags & (FLAG_OPTIMIZE_INVALIDATE | FLAG_ANIMATION_DONE)) !=
                        FLAG_OPTIMIZE_INVALIDATE) {          //dirty记录的是最开始调到invalidate()的View的区域                //dirty的四个坐标值值在执行下面代码是相对于当前循环到上一个ViewGroup来确定的          //这里做了一个偏移动作,偏移的量是当前上一个ViewGroup相对于现在ViewGroup的偏移值          //做完下面的偏移操作后,dirty的四个坐标就是想对于当前ViewGroup的坐标值了
                dirty.offset([CHILD_LEFT_INDEX] - mScrollX,
                        location[CHILD_TOP_INDEX] - mScrollY);          //如果当前ViewGroup需要裁剪View          //则将当前ViewGroup的区域与View的区域做求并集的操作
                if ((mGroupFlags & FLAG_CLIP_CHILDREN) == 0) {
                    dirty.union(0, 0, mRight - mLeft, mBottom - mTop);
                }

                final int left = mLeft;
                final int top = mTop;
          //如果当前ViewGroup需要裁剪View,且ViewGroup区域与View区域没有并集,则dirty置空
                if ((mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN) {
                    if (!dirty.intersect(0, 0, mRight - left, mBottom - top)) {
                        dirty.setEmpty();
                    }
                }
                mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
              //用于循环到下一个ViewGroup时做offset操作
                location[CHILD_LEFT_INDEX] = left;
                location[CHILD_TOP_INDEX] = top;

                if (mLayerType != LAYER_TYPE_NONE) {
                    mPrivateFlags |= PFLAG_INVALIDATED;
                }

                return mParent;

            } else {//如果当前ViewGroup中有动画要执行
                mPrivateFlags &= ~PFLAG_DRAWN & ~PFLAG_DRAWING_CACHE_VALID;

                location[CHILD_LEFT_INDEX] = mLeft;
                location[CHILD_TOP_INDEX] = mTop;          //如果需要对子View裁剪则设置dirty为当前ViewGroup区域                //如果不需要则求当前ViewGroup区域与原ditry区域并集          if ((mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN) {
                    dirty.set(0, 0, mRight - mLeft, mBottom - mTop);
                } else {
                    // in case the dirty rect extends outside the bounds of this container
                    dirty.union(0, 0, mRight - mLeft, mBottom - mTop);
                }

                if (mLayerType != LAYER_TYPE_NONE) {
                    mPrivateFlags |= PFLAG_INVALIDATED;
                }

                return mParent;
            }
        }

        return null;
    }

  invalidateChildInParent()主要是完成了dirty区域在调用该方法的ViewGroup中的更新,dirty指示的区域就是需要重绘制的区域。如果ViewGroup没有动画在执行,则dirty区域还是原来的区域,只需要通过偏移操作更改该区域的坐标值从相对于上一个ViewGroup(父ViewGroup),到相对于当前ViewGroup;如果有动画要执行,则表示当前整个ViewGroup都需要重绘,更改dirty值为当前ViewGroup 区域。

  do-while最后一次循环最后会调用到ViewRootImpl.invalidateChildInParent()方法,进入该代码。

    @Override
    public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
        checkThread();
        if (DEBUG_DRAW) Log.v(mTag, "Invalidate child: " + dirty);
     //如果传入一个null drity,则表示要重绘当前ViewRootImpl指示的整个区域     //如果传入一个empty dirty,则表示经过计算需要重绘的区域不需要绘制     if (dirty == null) {
            invalidate();
            return null;
        } else if (dirty.isEmpty() && !mIsAnimating) {
            return null;
        }
     ...

        invalidateRectOnScreen(dirty);

        return null;
    }

  调用到了ViewRootImpl.invalidateRectOnScreen()方法,进入该代码。

    private void invalidateRectOnScreen(Rect dirty) {     //mDirty记录的是当前ViewRootImpl里还未进行重绘需要重绘的区域        //mDirty会在ViewRootImpl.draw()方法结尾处设置为empty     final Rect localDirty = mDirty;
        if (!localDirty.isEmpty() && !localDirty.contains(dirty)) {
            mAttachInfo.mSetIgnoreDirtyState = true;
            mAttachInfo.mIgnoreDirtyState = true;
        }

        // Add the new dirty rect to the current one        //当前已有的dirty区域与此次dirty区域做并集
        localDirty.union(dirty.left, dirty.top, dirty.right, dirty.bottom);
        // Intersect with the bounds of the window to skip
        // updates that lie outside of the visible region
        final float appScale = mAttachInfo.mApplicationScale;     //处理窗口缩放与做完并集的localDirty做交集
        final boolean intersected = localDirty.intersect(0, 0,
                (int) (mWidth * appScale + 0.5f), (int) (mHeight * appScale + 0.5f));
        //如果没有交集     if (!intersected) {
            localDirty.setEmpty();
        }

     //mWillDrawSoon在performTraversals()方法开始时置为true,结束时置false     //如果没有在执行performTraversals &&(intersected || 正在执行动画)
        if (!mWillDrawSoon && (intersected || mIsAnimating)) {
            scheduleTraversals();
        }
    }

  最后会调用到scheduleTraversals()方法,后续在请求到Vsync信号后,便会调用到peformTraversals()方法。

时间: 2024-10-18 09:34:17

源码分析篇 - Android绘制流程(三)requestLayout()与invalidate()流程分析的相关文章

源码分析篇 - Android绘制流程(二)measure、layout、draw流程

performTraversals方法会经过measure.layout和draw三个流程才能将一帧View需要显示的内容绘制到屏幕上,用最简化的方式看ViewRootImpl.performTraversals()方法,如下. private void performTraversals() { ... performMeasure(childWidthMeasureSpec, childHeightMeasureSpec); ... performLayout(lp, mWidth, mHe

从源码角度看Android系统SystemServer进程启动过程

copy frome :https://blog.csdn.net/salmon_zhang/article/details/93208135 SystemServer进程是由Zygote进程fork生成,进程名为system_server,主要用于创建系统服务. 备注:本文将结合Android8.0的源码看SystemServer进程的启动过程以及SystemServer进程做了哪些重要工作. 1. SystemServer进程启动的起点从<从源码角度看Android系统Zygote进程启动过

android7.x Launcher3源码解析(3)---workspace和allapps加载流程

Launcher系列目录: 一.android7.x Launcher3源码解析(1)-启动流程 二.android7.x Launcher3源码解析(2)-框架结构 三.android7.x Launcher3源码解析(3)-workspace和allapps加载流程 前两篇博客分别对Lancher的启动和Launcher的框架结构进行了一些分析,这一篇,将着重开始分析界面的加载流程. 1.整体流程 先上一张整体的流程图吧.(图片看不清可以下载下来看或者右击新开个页面查看图片) 先从Launc

从源码中浅析Android中如何利用attrs和styles定义控件

一直有个问题就是,Android中是如何通过布局文件,就能实现控件效果的不同呢?比如在布局文件中,我设置了一个TextView,给它设置了textColor,它就能够改变这个TextView的文本的颜色.这是如何做到的呢?我们分3个部分来看这个问题1.attrs.xml  2.styles.xml  3.看组件的源码. 1.attrs.xml: 我们知道Android的源码中有attrs.xml这个文件,这个文件实际上定义了所有的控件的属性,就是我们在布局文件中设置的各类属性 你可以找到attr

【源码】基于Android和蓝牙的单片机温度采集系统

如需转载请标明出处:http://blog.csdn.net/itas109 STC89C52单片机通过HC-06蓝牙模块与Android手机通信实例- 基于Android和蓝牙的单片机温度采集系统 整个工程下载:http://download.csdn.net/detail/itas109/7539057 其中包括, 1.下位机电路原理图 2.下位机采集温度.控制发送.自动纠错代码 3.Android端接收温度并显示代码 文件截图 这个是我当年毕业设计做的东西,虽然比较简单,但是还是有一定的参

minidlna源码初探(二)—— SSDP设备发现的大致流程

前言: 之前有专文介绍了minidlna中的UPNP功能,内中介绍其中包含的SSDP(简单发现协议),SOAP(简单对象访问协议)等几个协议(http://blog.csdn.net/sakaue/article/details/19070735).本文将根据minidlna的程序流程,概述SSDP的流程,为下一部分ACE实现做铺垫. 设备发现的大致流程: 首先,根据UPNP的规范: 在设备加入网络,UPnP发现协议允许设备向控制点广告它的服务.它使用向一个标准地址和端口多址传送发现消息来实现.

Android view中的requestLayout和invalidate方法

Android view中的requestLayout和invalidate方法 requestLayout:当view确定自身已经不再适合现有的区域时,该view本身调用这个方法要求parent view重新调用他的onMeasure onLayout来对重新设置自己位置. 特别的当view的layoutparameter发生改变,并且它的值还没能应用到view上,这时候适合调用这个方法. invalidate:View本身调用迫使view重画

android源码解析(二十六)--&gt;截屏事件流程

今天这篇文章我们主要讲一下Android系统中的截屏事件处理流程.用过android系统手机的同学应该都知道,一般的android手机按下音量减少键和电源按键就会触发截屏事件(国内定制机做个修改的这里就不做考虑了).那么这里的截屏事件是如何触发的呢?触发之后android系统是如何实现截屏操作的呢?带着这两个问题,开始我们的源码阅读流程. 我们知道这里的截屏事件是通过我们的按键操作触发的,所以这里就需要我们从android系统的按键触发模块开始看起,由于我们在不同的App页面,操作音量减少键和电

“Tornado源码解析篇”导读索引

最近花了2周时间断断续续地阅读了 Tornado 的源码,写了“Tornado源码解析”这个系列专题.由于写得比较散,这里简单做一个索引与导读. 为什么要选择 Tornado 这个框架?先给大家讲一个小故事:赌王娱乐城 "[web.py inspired the] web framework we use at FriendFeed [and] the webapp framework that ships with App Engine..." — Brett Taylor, co-