0%

这一章前面部分主要讲解Fragment的基本使用,这点我觉得官方文档关于fragment的知识可能会更好一些,以下是官方的阐述:

主要是平时使用Fragment时,对其使用方法有疑惑,以下或许能解释部分:

为什么使用Fragment

参考自官方:主要是为了在大屏幕手机(如平板电脑)上更加零落的UI设计,可以更方便地组合和交换UI组件。

Fragment的创建

想为Fragment提供布局,则必须实现onCreateView()回调,可以通过xml定义布局资源,为此,onCreateView()提供了一个LayoutInflater对象:

public static class ExampleFragment extends Fragment {
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
        // Inflate the layout for this fragment
        return inflater.inflate(R.layout.example_fragment, container, false);
    }
}

向Activity中添加Fragment

1、在Activity的布局文件中声明:

<?xml version="1.0" encoding="utf-8">
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <fragment android:name="com.example.ListFragment"
            android:id="@+id/list"
            android:layout_weight="1"
            android:layout_width="0dp"
            android:layout_height="match_parent" />

    <fragment android:name="com.example.AticleFragment"
            android:id="@+id/viewer"
            android:layout_weight="2"
            android:layout_width="0dp"
            android:layout_height="match_parent" />
</LinearLayout>

官方解释:Activity初始化布局时,会实例化布局中指定的每个fragment,并为每个Fragment调用onCreateView()方法,系统会直接插入Fragment返回的View来替代元素。

2、通过编程方式将Fragment添加到某个现有的ViewGroup:

可以在Activity运行期间将Fragment添加进去,你只需要指定Fragment要放入哪个ViewGroup,这需要使用FragmentTransaction:

FragmentManager fragmentManager = getFragmentManager();
FragmentTransaction transaction = fragmentManager.beginTransaction();

然后,你可以使用add()方法添加一个fragment:

ExampleFragment fragment = new ExampleFragment();
transaction.add(R.id.fragment_container,fragment);
transaction.commit();

一旦通过FragmentTransaction做出了更改,就必须commit以使更改生效。

3、添加没有UI的Fragment:

你可以使用Fragment为Activity提供后台行为,而不显示额外的UI。使用函数:

add(Fragment,String)

String类型参数为Fragment提供一个唯一的字符串标记,由于Fragment没有雨Activity中的视图关联,因此不会收到onCreate()调用,因此你可以不实现这个方法。如果你稍后想从Activity中获取到这个Fragment,可以使用findFragmentByTag()。

可以在SDK的sample中查看具体用法:/APIDemos/app/src/main/java/com/example/android/apis/app/FragmentRetainInstance.java

执行Fragment事务

需要使用FragmentTransaction,可以使用:

add() 、remove() 、replace()

等方法设置想要执行的更改,然后commit生效。

不过在commit之前你可能想调用 addToBackStack()将其添加到Fragment事务返回栈,允许用户按返回键返回上一Fragment状态。

来个例子:

/**create new fragment and transaction**/

Fragment newFragment = new ExampleFragment();
FragmentTransaction transaction = getFragment().beginTransaction();

/**Replace whatever is in the fragment_container view with this fragment
and add the transaction to the back stack**/

transaction.replace(R.id.fragment_container,newFragment);
transaction.addToBackStack(null);

//commit the transaction
transaction.commit();

向FragmentTransaction添加更改的顺序无关紧要,但有一些注意事项:

commit操作不会立即执行,而是等主线程认为可以执行的时候再运行,不过,如果有必要,你也可以从主线程调用executePendingTransactions() 以立即执行commit。

最后必须调用commit,而且只能在用户离开Activity之前commit,否则会引发异常,如果对于需要commit的更改无关紧要,可以使用commitAllowingStateLoss()。

可以向同一个容器中添加多个fragment,你添加的顺序决定他们在视图层次结构中出现的顺序。

管理Fragment

需要使用FragmentManager,你可以使用它执行以下操作:

  • findFragmentById() (对于在Activity布局中提供UI的Fragment)或者findFragmentByTag()(对于提供或者不提供UI的Fragment都可)。

  • popBackStack() (模拟用户发出的返回命令),将Fragment从返回栈中弹出。

  • addOnBackStackChangedListener() 监听返回栈变化

与Activity通信

Fragment可以通过getActivity()访问Activity实例,并轻松执行诸如在Activity布局中查找视图等任务:

View listView = getActivity().findViewById(R.id.list);

同样,Activity也可以使用findFragmentById 或者 findFragmentByTag,通过从FragmentManager获取Fragment的引用来调用Fragment中的方法:

ExampleFragment fragment = (ExampleFragment) getFragmentManager().findFragmentById(R.id.example_fragment);

一个应用场景例子

一个新闻应用中Activity两个Fragment,Fragment A放列表list,Fragment B 放对应内容,那么A在列表项选定后,告诉Activity,以便Activity通知B显示该新闻。其方案可以这样设计:

在A中声明接口OnArticleSelectedListener :

public static class FragmentA extends ListFragment {
    ...
    // Container Activity must implement this interface
    public interface OnArticleSelectedListener {
        public void onArticleSelected(Uri articleUri);
    }
    ...
}

同事在Activity中实现接口OnArticleSelectedListener,在A的onAttach方法时判断Activity是否这样做了:

public static class FragmentA extends ListFragment {
    OnArticleSelectedListener mListener;
    ...
    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        try {
            mListener = (OnArticleSelectedListener) activity;
        } catch (ClassCastException e) {
            throw new ClassCastException(activity.toString() + " must implement OnArticleSelectedListener");
        }
    }
    ...
}

当有点击事件的时候,A看起来是这样子的:

public static class FragmentA extends ListFragment {
    OnArticleSelectedListener mListener;
    ...
    @Override
    public void onListItemClick(ListView l, View v, int position, long id) {
        // Append the clicked item's row ID with the content provider Uri
        Uri noteUri = ContentUris.withAppendedId(ArticleColumns.CONTENT_URI, id);
        // Send the event and Uri to the host activity
        mListener.onArticleSelected(noteUri);
    }
    ...
}

Fragment的生命周期如下图:

fragment生命周期

其中将fragment进行至fragment的resume状态(即可以跟用户交互)的核心序列如下:

  • onAttach(Activity) :activity与fragment关联的时候调用.

  • onCreate(Bundle) :fragment初始化的时候调用.

  • onCreateView(LayoutInflater, ViewGroup, Bundle) :为fragment创建返回view界面.

  • onActivityCreated(Bundle): 通知fragment它绑定的那个Activity已经执行完了onCreate()操作.

  • onViewStateRestored(Bundle): 通知fragment它保存的view state已经被恢复了.

  • onStart(): fragment对用户可见 (还要取决于包含这个fragment的activity是否已经启动了).

  • onResume(): 使fragment可以和用户交互了 (还要取决于包含这个fragment的activity是否已经resume了).
    如果一个fragment不再使用了,它会执行一系列相反的过程:

  • onPause(): fragment不能与用户交互(可能是由于activity的pause)。

  • onStop(): fragment不可见了(可能是由于activitystop了)。

  • onDestroyView():通知fragment清理与它相关的view资源。

  • onDestroy():在完全清理fragment的状态时调用。

  • onDetach():当fragment与activity解除绑定时调用。

动态加载布局的技巧

使用限定符

如果使用平板就会发现里面的应用基本上是双页模式,但是在手机上限于屏幕大小,都是单页模式。如果判断该使用双页模式还是单页模式,这就要借助限定符(qualifiers)来实现了,我们可以有两个布局文件,一个 layout_single.xml 单页模式布局放在layout目录,一个 layout_double.xml 双页模式布局放在 layout-large 目录,其中的large是个限定符。Android中常用限定符如下:
android限定符1
android限定符2

使用最小限定符

前面解决了单页双页模式,但是到底怎么才算large,我们需要更精确地控制的话,需要最小限定符。我们新建layout-600dp文件夹,将双页布局文件放入其中,这样就会意味着,当程序运行在宽度小于600dp的设备上时,显示的是单页布局,否则使用的是双页布局。

以上两种技巧可以将手机版和pad版都使用同一个app,避免维护多个app,一处改动,需要在两个app中同步改动。注意在代码中区别目前是双页模式还是单页模式,可以用以下方式:

1
2
3
4
5
if(findViewById(R.id.anotherpageid) == null){
//单页
}else{
//双页
}

其中R.id.anotherpageid是在单页中所没有的那个布局的id。

布局文件中如果添加Button,并指定其text为”button”的话,但是显示的是”BUTTON”,全部变为大写了,要去掉这一效果,可以添加属性android:textAllCaps=”false”

RelativeLayout 中还有另外一组对于控件进行定位的属性,android:layout_alignLeft表示让一个控件的左边缘和另一个控件的左边缘对齐,同理,还有android:layout_alignRight、Top、Bottom 。

创建自定义控件

我们所用的所有控件都是直接或者间接继承自View的,所有的布局都是直接或者间接继承ViewGroup,View是Android中最基本的一种UI组件,它可以在屏幕上绘制一块矩形区域,并能响应这块区域的各种事件,因此,我们使用的各种控件其实就是在View的基础上添加各自特有功能;而ViewGroup则是一种特殊的View,它可以包含很多子View和子ViewGroup,是一个防止控件和布局的容器。常用控件和布局的继承结构如下图所示:

view继承关系

App中的标题栏几乎在每个界面都是一样的,除了标题不一样,其他的诸如左边按钮点击就finish当前页面,右边的是菜单按钮,这些功能基本上一样,如果在每个页面都单独为这些按钮重复添加相同的监听,比较繁琐。所以可以将标题栏单独封装成单独的一个TitleLayout的,每次只需要引入到布局中即可。

使用listview

可以继承ArrayAdapter简化操作,类似如下(当然,这里在getView的时候没有进行复用优化,仅仅只是示例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FruitAdapter extends ArrayAdapter<Fruit>{
private int resourceId;

public FruitAdapter(Context context,int resourceId,List<Fruit> objects){
super(context,resourceId,objects);
this.resourceId = resourceId;
}

@Override
public View getView(int position,View convertView,ViewGroup parent){
Fruit fruit = getItemt(position);
View view = LayoutInflater.from(getContext()).inflate(resourceId,parent,false);
ImageView ivFruit = view.findViewById(R.id.img);
TextView tvFruit = view.findViewById(R.id.txt);
ivFruit.setImageResource(fruit.getImageId());
tvFruit.setText(fruit.getName());
return view;
}
}

使用更强大的RecyclerView

在设置LayoutManager的时候,可以指定排布的方向比如以下代码:

1
2
LinearLayoutManager manager = new LinearLayoutManager(this);
manager.setOrientation(LinearLayoutManager.HORIZONTAL);//平时一般使用竖直方向,这里特意指定横向

为什么Listview很难或者根本无法实现这种效果呢,其实这主要得益于RecyclerView的出色设计,ListView的布局排列是由自身去管理的,,而RecyclerView则将这个工作交给了LayoutManager,LayoutManager指定了一套可扩展的布局排列接口,自雷只要按照接口的规范来实现,就能定制出不同排列方式的布局了。

实现点击事件,可以在Adapter中的onCreateViewHolder方法中来做到,诸如以下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent,int viewType){
View view = LayoutInflater.from(parent.getContext).inflate(R.layout.fruit_item,parent,false);
final ViewHolder holder = new ViewHolder(view);
holder.ivFruit.setOnclickListener(new View.OnclickListener(){
@Override
public void onClick(View v){
int postion = holder.getAdapterPosition();
//doSomeThing you want
}
});
}

这个问题源于最近做的项目中出现的bug,一个Activity A通过startActivity(intent)的方式(intent中携带了String类型的data)启动另一个Activity B时,发生了崩溃,查看错误日志如下:

启动Activity时的错误日志

可以看到,这是 android.os.TransactionTooLargeException ,字面意思是事务太大。这就很好理解了,因为笔者使用了intent携带数据,在事后分析这个data在传入的时候大约50k,因此导致了这个问题。后来使用SharedPreference将数据捎带过去解决了问题,这个bug本身看懂了报错就很简单,因此不再赘述。后面有空有兴趣之后再补上分析Intent传递数据到底是多大的限制。

在开发过程中碰到很多问题,有些问题在锤子便签中记录了一个概要,有的问题甚至连记录都没有,此次开个头,将碰到的问题记录下来。

今天要写的问题跟Listview有关,顺便复习下ListView的相关复用机制,以及Listview的Adapter中getView方法为什么需要ViewHolder,是怎么提高加载效率的。下面开始进入回忆状态,事情的经过是这样的:

在平时的 Android 开发过程中,我们可能需要去实现以下效果:

使用场景

在 Listview 中使用CheckBox,但是会碰到 CheckBox 选中/非选中 这种状态错乱的问题,笔者最近在项目中就碰到了,比如我选中了 id0、id1、id2 三个 CheckBox ,再想选择 id15 ,这就要求滑动 Listview 了,滑到 id15 CheckBox 将其选中,再滑动到顶部,握草,发现 id0、id1、id2 已经变成 非选中 状态了,莫非是我记错了?再重新来一次,还是一样!这就不科学了,一定是哪里出了问题,我当时的代码是这样的:

@Override
public View getView(final int i, View view, ViewGroup viewGroup) {
    ViewHolder viewHolder = null;
    if(view == null){
        viewHolder = new ViewHolder();
        view = LayoutInflater.from(context).inflate(R.layout.layout,null);
        viewHolder.cb = (CheckBox) view.findViewById(R.id.select_cb);
        viewHolder.tvName = (TextView) view.findViewById(R.id.name_tv);
        view.setTag(viewHolder);
    }else{
        viewHolder = (ViewHolder) view.getTag();
    }
    viewHolder.cb.setChecked(data.get(i).isSlected());
    viewHolder.tvName.setText(data.get(i).getName());
    viewHolder.cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
        @Override
        public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked)     {
            data.get(i).setSlected(isChecked);
        }
    });
    return view;
}

脑子里第一反应是各处的 item 串了,联想到使用 viewholder 来复用 item ,于是就去了解了一番 Listview 对 item 的复用机制。

复用机制

我们知道,listview 需要承载大量的数据,并且需要写一个 Adapter 与其适配,这样数据就能展现出来了,但是不知道大家有没有仔细想过,为什么需要 Adapter 这个东西,它到底起了个什么作用。

从 Adapter 说起

说到底,Android 中控件就是为了展示数据以及交互用,只不过Listview特殊些,它用于展示大量的信息的,但是 Listview 只承担交互和展示工作的,至于数据来自哪里,它不care。这样,listview工作最基本需要一个 Listview 控件和一个数据源,但是数据源可能是数组,可能是集合,甚至可能是数据库表中查询出来的游标,如果 Listview 要去为每一种数据源进行匹配的话,它一定会变得非常臃肿了,于是 Adapter 出现了。

顾名思义,Adapter 是适配器的意思,它在 Listview 与数据源之间起了一个桥梁作用,与之前的情况不同的是,Adapter 的接口都是统一的,因此 Listview 不需要担心任何适配问题。而 Adapter 是个接口(interface),它可以有各种子类,比如 ArrayAdapter 可用于数组和 List 类型的数据源匹配,SimpleCursorAdapter 可以用于游标类型的数据源匹配,这样把适配问题解决了,并且扩展性不错。

RecycleBin 类

在解释复用机制之前,还有必要说一下 RecycleBin 类,因为它是 Listview 能够展现成百上千条数据并且不会 OOM 的关键,RecycleBin 是 AbsListview 的一个内部类,其主要代码如下:

/**
 * The RecycleBin facilitates reuse of views across layouts. The RecycleBin
 * has two levels of storage: ActiveViews and ScrapViews. ActiveViews are
 * those views which were onscreen at the start of a layout. By
 * construction, they are displaying current information. At the end of
 * layout, all views in ActiveViews are demoted to ScrapViews. ScrapViews
 * are old views that could potentially be used by the adapter to avoid
 * allocating views unnecessarily.
 */
class RecycleBin {
    private RecyclerListener mRecyclerListener;

    /**
     * The position of the first view stored in mActiveViews.
     */
    private int mFirstActivePosition;

    /**
     * Views that were on screen at the start of layout. This array is
     * populated at the start of layout, and at the end of layout all view
     * in mActiveViews are moved to mScrapViews. Views in mActiveViews
     * represent a contiguous range of Views, with position of the first
     * view store in mFirstActivePosition.
     */
    private View[] mActiveViews = new View[0];

    /**
     * Unsorted views that can be used by the adapter as a convert view.
     */
    private ArrayList<View>[] mScrapViews;

    private int mViewTypeCount;

    private ArrayList<View> mCurrentScrap;

    /**
     * Fill ActiveViews with all of the children of the AbsListView.
     * 
     * @param childCount
     *            The minimum number of views mActiveViews should hold
     * @param firstActivePosition
     *            The position of the first view that will be stored in
     *            mActiveViews
     */
    void fillActiveViews(int childCount, int firstActivePosition) {
        if (mActiveViews.length < childCount) {
            mActiveViews = new View[childCount];
        }
        mFirstActivePosition = firstActivePosition;
        final View[] activeViews = mActiveViews;
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            AbsListView.LayoutParams lp = (AbsListView.LayoutParams)     child.getLayoutParams();
            // Don't put header or footer views into the scrap heap
            if (lp != null && lp.viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
                // Note: We do place AdapterView.ITEM_VIEW_TYPE_IGNORE in
                // active views.
                // However, we will NOT place them into scrap views.
                activeViews[i] = child;
            }
        }
    }

    /**
     * Get the view corresponding to the specified position. The view will
     * be removed from mActiveViews if it is found.
     * 
     * @param position
     *            The position to look up in mActiveViews
     * @return The view if it is found, null otherwise
     */
    View getActiveView(int position) {
        int index = position - mFirstActivePosition;
        final View[] activeViews = mActiveViews;
        if (index >= 0 && index < activeViews.length) {
            final View match = activeViews[index];
            activeViews[index] = null;
            return match;
        }
        return null;
    }

    /**
     * Put a view into the ScapViews list. These views are unordered.
     * 
     * @param scrap
     *            The view to add
     */
    void addScrapView(View scrap) {
        AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
        if (lp == null) {
            return;
        }
        // Don't put header or footer views or views that should be ignored
        // into the scrap heap
        int viewType = lp.viewType;
        if (!shouldRecycleViewType(viewType)) {
            if (viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
                removeDetachedView(scrap, false);
            }
            return;
        }
        if (mViewTypeCount == 1) {
            dispatchFinishTemporaryDetach(scrap);
            mCurrentScrap.add(scrap);
        } else {
            dispatchFinishTemporaryDetach(scrap);
            mScrapViews[viewType].add(scrap);
        }

        if (mRecyclerListener != null) {
            mRecyclerListener.onMovedToScrapHeap(scrap);
        }
    }

    /**
     * @return A view from the ScrapViews collection. These are unordered.
     */
    View getScrapView(int position) {
        ArrayList<View> scrapViews;
        if (mViewTypeCount == 1) {
            scrapViews = mCurrentScrap;
            int size = scrapViews.size();
            if (size > 0) {
                return scrapViews.remove(size - 1);
            } else {
                return null;
            }
        } else {
            int whichScrap = mAdapter.getItemViewType(position);
            if (whichScrap >= 0 && whichScrap < mScrapViews.length) {
                scrapViews = mScrapViews[whichScrap];
                int size = scrapViews.size();
                if (size > 0) {
                    return scrapViews.remove(size - 1);
                }
            }
        }
        return null;
    }

    public void setViewTypeCount(int viewTypeCount) {
        if (viewTypeCount < 1) {
            throw new IllegalArgumentException("Can't have a viewTypeCount < 1");
        }
        // noinspection unchecked
        ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount];
        for (int i = 0; i < viewTypeCount; i++) {
            scrapViews[i] = new ArrayList<View>();
        }
        mViewTypeCount = viewTypeCount;
        mCurrentScrap = scrapViews[0];
        mScrapViews = scrapViews;
    }

}
  • 注释说 RecycleBin 用于view的reuse,它维持了两个存储空间,ActiveViews 和 ScrapViews,前者存放显示在屏幕上的view,到列表最后的时候,它里面的view都会去到 ScrapViews 中。而后者用于存放 old views ,这些view可能可以直接以convertView的形式直接利用,避免没必要的 allocat 内存,这就是Adapter中convertView的由来。

  • fillActiveViews() :这个方法会根据传入的参数来将 Listview 中指定的元素存储到 mActiveViews 数组中。

  • getActiveView() :跟 fillActiveViews 方法对应,用于从 mActiveViews 中获取数据,需要注意的是,一旦第 position 个数据被获取成功之后,该view就会从 mActiveViews 中移除,下次再获取第position个位置将会返回 null,也就是说mActiveViews不能复用。

  • addScrapView() :用于将一个废弃的view进行缓存,当一个view要废弃的时候(比如滚出屏幕),就调用该方法缓存,以便下次使用。

  • getScrapView() :从 ScrapViews 中取出一个view,这些废弃缓存中的view是没有顺序可言的,因此取的算法也非常简单,获取尾部的就行。

  • setViewTypeCount() :我们知道在 adapter 中我们可以重写 getViewTypeCount() 来表示Listview中有几种类型的数据项,而setViewTypeCount()的作用就是为每种类型的数据项都单独启用一个 RecycleBin 缓存机制。

扯点view的绘制

Listview 再牛逼,也是继承自view的,而view的执行流程就是3步,onMeasure() 用于测量 view 的大小,onLayout() 用于确定 View 的布局,onDraw() 用于将 view 绘制到界面上。

Listview 最特殊的地方在于 onLayout() ,而这是在它父类 AbsListview 中实现的,它主要就一个重要判断:如果 Listview 的大小或者位置发生了变化,那就要求所有子布局强制重绘。而 layoutchildren() 方法是用来进行子元素布局的,具体由 Listview 自己实现,可以解析下。

刚开始,Listview 中没有任何子view,因此会去调用 fillActiveViews() 方法,这是为了将 Listview 中的子 view 进行缓存的,由于此时子 view 为空,因此会调用 fillFromTop() ,最终调用到 fillDown() 方法,进行 Listview 的填充操作。fillDown() 中有个while循环,当遍历完从 Listview 顶部到底部的距离的item或者 adapter 中的数据遍历结束,while就跳出。在while中,执行 makeAndAddView() ,它会尝试从 RecycleBin 中快速获取 active view ,但此时 RecycleBin 中还未缓存任何view,因此获得null,所以就会尝试调用 obtainView() ,它是可以保证返回一个 view 的,于是将获取到的view立刻传入到了 setupChild() 中。

那到底 obtainView() 怎么保证获取到view的?不夸张地说,Listview 中最重要的内容都在 obtainView() 中了,该方法里首先调用了 RecycleBin 的 getScrapView() 方法尝试获取一个废弃缓存中的 view ,当然这时候是获取不到的,得到null,之后再调用 mAdapter 的 getView() 方法来获取一个 view ,这时候似曾相识了,有 adapter 和 getView() 方法了,对,就是我们平常写的那个 adapter,然后重写的那个 getView(),这时候会传入 position,convertView (此时为null),parent (当然是 this 了)。

捋一下item的复用

一切从 onLayout 开始,当大小或者位置发生了变化,就会调用onLayout,onLayout完毕之后,就剩下 ondraw 去绘制了。onLayout中,(为了叙述方便,不考虑数据不足以填满Listview的情况),首先要拿item的view放到Listview中,先从ActiveViews中拿,如果为空,则打算从 ScrapViews 中拿,还是为空,则利用 adapter 去创造,创造一屏 itemview 填充于 ActiveViews 中,之后 Listview 从 ActiveViews 中取出 itemview ,ActiveViews 删除该 itemview ,如果 itemview 滑动隐藏了,就会丢弃到 ScrapViews 中,这样滑动的时候触发 onLayout ,onLayout 再去找 itemview 填充,如果有现成的就用,没有就创造。

分析源码谈原因

再来看源码,为了更方便,加上toast提示:

@Override
public View getView(final int i, View view, ViewGroup viewGroup) {
    ViewHolder viewHolder = null;
    if(view == null){
        viewHolder = new ViewHolder();
        view = LayoutInflater.from(context).inflate(R.layout.layout,null);
        viewHolder.cb = (CheckBox) view.findViewById(R.id.select_cb);
        viewHolder.tvName = (TextView) view.findViewById(R.id.name_tv);
        view.setTag(viewHolder);
    }else{
        viewHolder = (ViewHolder) view.getTag();
    }
    viewHolder.cb.setChecked(data.get(i).isSlected());
    viewHolder.tvName.setText(data.get(i).getName());
    viewHolder.cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
        @Override
        public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked)     {

        Toast.makeText(context,"这是点击了第" + i + "个" ,Toast.LENGTH_SHORT).show();
            data.get(i).setSlected(isChecked);
        }
    });
    return view;
}

运行之后我们看到整个列表,选中第1个,会弹toast “这是点击了第0个” 接着往上慢慢滑动,直至将第一个item隐藏的时候,发现 toast 弹出来了,显示 这是点击了第0个!而这个时候最下面之前被第一条隐藏的item也展现出来了,综合上面的知识,可以知道,这个隐藏的item是复用了第1个item的view,复用view的时候,由于该隐藏item是未checked,而第一条item是已经checked,因此它执行

viewHolder.cb.setChecked(data.get(i).isSlected());

的时候,会触发 OnCheckedChangeListener ,由于之前第一个 item 设置了监听:

viewHolder.cb.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton compoundButton, boolean     isChecked)     {

            Toast.makeText(context,"这是点击了第" + i + "个" ,Toast.LENGTH_SHORT).show();
                data.get(i).setSlected(isChecked);
            }
        });

这时候就触发了监听事件,因此toast就弹出来了,并且把第一条item的数据也由checked改成unchecked,因此你下次再见到第一个item的时候,状态就变成unchecked了。

结论

最后可以说结论了,这个现象是由于listview中item复用导致,如果你不用viewholder是不会有这问题的,其实这个结论并不重要,重要的是理解这里面的发生机制。当然,说了问题起因,当然得给个解决方案,方法不止一种,我个人用的一种方法是在

viewHolder.cb.setChecked(data.get(i).isSlected());

之前添加一句:

viewHolder.cb.setOnCheckedChangeListener(null);

我想你肯定知道为什么。

注:参考(引用)以下博客劳动成果:

郭霖 : Android ListView工作原理完全解析,带你从源码的角度彻底理解

Intent使用

显式地就不说了,使用隐式的Intent时并不明确指出我们想要启动哪一个活动,而是指定了一系列更为抽象的action和category等信息,然后交由系统去分析这个Intent,并帮我们找出合适的活动去启动。

普通的隐式Intent使用

比如在AndroidManifest.xml中声明activity的时候,可以添加:

1
2
3
4
5
<intent-filter>
<action android:name="com.example.ACTION_START"/>
<category android:name="android.intent.category.DEFAULT"/>
<intent-filter>

在代码中就能以下面代码来启动这个activity了(由于category是DEFAULT,所以在intent中并未指定category了):

1
2
Intent intent = new Intent("com.example.ACTION_START");
startActivity(intent);

如果在AndroidManifest.xml中声明activity的时候同时指定了actioncategory,那么必须要在Intent中严格匹配才能打开,否则可能报错,比如写成:

1
2
3
4
5
6
<intent-filter>
<action android:name="com.example.ACTION_START"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="com.example.MY_CATEGORY"/>
<intent-filter>

则代码中必须添加以下代码才能正确运行。

1
2
3
Intent intent = new Intent("com.example.ACTION_START");
**intent.addCategory("com.example.MY_CATEGORY");**
startActivity(intent);

更多隐式Intent用法

使用隐式的Intent,我们不仅可以启动自己程序内的活动,还可以启动其他程序的活动,比如说要在应用程序中点击一个按钮,然后要在浏览器中打开一个网页,则使用以下代码:

1
2
3
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("http://www.baidu.com"));
startActivity(Intent);

这里面,setData()接收一个Uri对象,主要用于指定当前Intent正在操作的数据,而这些数据通常都是以字符串的形式传入到Uri.parse()方法中解析产生的。那如果我们要自己写个浏览器应用,让其它应用也能像这样利用我们的APP打开网页,又该怎么做呢,这就要求在中添加一个<data标签>,用于更精确地指定当前活动能够响应什么类型的数据。标签中可以配置以下内容:

  • android:scheme。用于指定数据的协议部分,例如上例中的http部分。
  • android:host。用于指定数据的主机名部分,如上例中的www.baidu.com。
  • android:port。用于指定数据的端口部分。
  • android:path。用于指定主机名和端口之后的部分。

所以,如果我们要做一个浏览器,至少要在AndroidManifest.xml对主activity声明:

1
2
3
4
5
6
7
<intent-filter>
<action android:name="com.example.ACTION_START"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="http">

<intent-filter>

下次其他APP需要用http协议打开网页时,我们的APP也会在候选列表中了。除了http协议意外,我们还可以指定很多其他协议,比如geo表示地理位置、tel表示拨打电话。

活动的生命周期

  • onCreate(),活动第一次被创建的时候调用,应该在这里面完成活动的初始化操作。
  • onStart(),在活动由不可见变为课件的时候调用。
  • onResume(),在活动准备好和用户进行交互的时候调用,此时活动一定位于返回栈的栈顶,并且处于运行状态。
  • onPause(),在系统准备去启动或者恢复另一个活动的时候调用,通常会在这个方法中将一些消耗CPU的资源释放掉,以及保存一些关键数据,但是工作不能太多,不然会影响下一个Activity的使用。
  • onStop(),活动完全不可见的时候调用,它和onPause主要的区别在于,如果启动的新活动是一个对话框式的活动,那么onPause方法会得到执行,而onStop不会执行。
  • onDestroy(),活动晓辉之前调用。
  • onRestart(),由停止状态变为运行状态之前调用。一般是由上一个活动返回到当前活动。

Activity声明周期

活动回收了怎么办

想象以下场景,应用中有活动A,在A的基础上启动活动B,活动A此时进入了停止状态,此时由于内存不足,将活动A回收了,然后用户按Back键返回活动A,会出现什么情况呢?其实还是会正常显示A,只不过这是并不会执行onRestart方法,而是会执行活动A的onCreate方法,因为活动A在这种情况下会被重新创建一次。

如果A进程中有输入框,并且已经输入了一些文字了,如果回收被重新创建,那么会丢失输入的信息,影响用户体验。Activity中还提供了一个onSaveInstanceState()回调方法,这个方法可以保证在活动回收之前一定会被调用,这个方法会携带一个Bundle类型的参数,它允许以key-value的形式存取值,我们可以这样将要保存的数据存下来:

1
2
3
4
5
@Override
public void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString("name","glassx");
}

数据已经保存下来了,但是在哪里恢复呢?其实我们一直使用的onCreate方法其实也有一个Bundle类型的参数,这个参数一般情况下是null,如果在活动呗系统回收之前有通过onSaveInstanceState保存的话,这个参数就会带有之前所保存的全部数据,因此通过以下方法取即可:

1
2
3
4
5
6
7
8
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

if(savedInstanceState != null){
String name = savedInstanceState.getString("glassx");
}
}

活动的启动模式

在实际项目中我们应该根据特定的需求为每个活动指定恰当的启动模式,启动模式一种四种:standard、singleTop、singleTask、singleInstance

  • standard是默认的启动模式。每次启动都会创建一个新的实例。

  • singleTop:有些情况下,可能会觉得standard不太合理,活动明明已经在栈顶了,为毛还要再创建新的实例呢?singleTop模式可以解决这个问题,当活动以该模式启动时,如果发现返回栈的栈顶已经是该活动,那就直接使用它,不创建新的实例,并且调用栈顶实例的onNewIntent方法;如果栈顶不是该活动,就创建该活动的新的实例。

  • singleTask:如果活动的启动模式指定为singleTask,那么每次启动该活动时系统首先会在返回栈中检查是否存在该活动的实例,如果有,把这个活动之上的所有活动统统出栈,并且直接使用该实例,并调用该实例的onNewIntent方法??(存疑,等会实践下)。反之没有的话就创建该活动的实例。

  • singleInstance:指定为singleInstance模式的活动会启用一个新的返回栈来管理这个活动(其实如果singleTask模式指定了不同的taskAffinity,也会启动一个新的返回栈)。

    那么这样做有什么意义呢?想象以下场景,我们的程序中有一个活动是允许其他程序调用的,如果我们想实现其他程序和我们的程序可以共享这个活动的实例,应该如何实现呢?前面3中模式做不到,因为每个应用程序都会有自己的返回栈,同一个活动在不同的返回栈中入栈必然是创建了新的实例。而使用singleInstance模式就可以解决这个问题,在这种模式下会有一个单独的返回栈来管理这个活动,不管哪个应用来访问这个活动,都公用一个返回栈,也就解决了共享活动实例的问题。

注意:如果三个活动,A和C都是standard模式,B是singleInstance模式,那么A启动B,B启动C后,在C界面按返回键是回退到A,再按返回键回退到B,接着按返回键才会退出应用,因为A和C是同一个回退栈中,B单独在一个栈中,可以用如下图来理解这一过程

Activity声明周期

活动的最佳实践

知晓当前是在哪一个活动

建一个BaseActivity,在onCreate的时候打印出来当前实例的类名,之后其他的activity都继承这个activity即可:

1
2
3
4
5
6
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

Log.d("BaseActivity",getClass().getSimpleName());
}

随时随地退出app

如果你在第三个activity界面,这个时候想要退出App是非常不方便的,可以新建一个类来管理所有Activity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ActivityController{
public static List<Activity> activities = new ArrayList<Activity>();

public static void addActivity(Activity ac){
activities.add(ac);
}

public static void removeActivity(Activity ac){
activities.remove(ac);
}

public static void finishAll(){
for(Activity ac : activities){
ac.finish();
}
}
}

这样只需要在BaseActivity的onCreate里面执行ActivityController的addActivity方法,即可把Activity添加进去,在BaseActivity的onDestroy方法中执行removeActivity,将其移除,在需要退出app的时候,只需要执行finishAll即可。

启动活动最佳写法

每个Activity中都写上启动自己的方法:

1
2
3
4
5
6
7
8
9

public class TestActivity extends BaseActivity{
public static void actionStart(Context context,String name,String sex){
Intent intent = new Intent(context,TestActivity.class);
intent.putExtra("name",name);
intent.putExtra("sex",sex);
context.startActivity(intent);
}
}

这样做的一个好处就是,启动这个activity所需要的参数一目了然,而无需阅读这个activity的源码就可以直接调用方法就能避免漏掉参数。

Android系统架构

  1. 最底层是Linux内核层。

    为Android设备各种硬件提供了底层驱动,如显示驱动,音频驱动等。

  2. 系统运行库层。

    这层通过一些C/C++库来为Android系统提供主要的特性支持。如Sqlite库提供了数据库支持,OpenGL|ES提供提供3D绘图等。

  3. 应用框架层。

    主要提供了构建应用程序可能用到的各种API。

  4. 应用层。

    所有安装在手机上的应用程序都属于这一层。比如系统自带的联系人、短信等程序,自己开发的应用。

Android系统层次

Android应用开发特色

四大组件

四大组件分别是活动(Activity)、服务(Service)、广播接收器(Broadcast Receiver)和内容提供器(Content Provider)

build.gradle 文件中,compileSdkVersion用于指定项目的编译版本;buildToolsVersion用于指定项目构建工具的版本;applicationId用于指定项目的包名,并且它的优先级高于在 AndroidManifest.xml
中指定的包名;minSdkVersion用于指定项目最低兼容的Android版本;targetSdkVersion表明你在该目标版本上做过了充分的测试,系统将为你的应用程序启用一些最新的功能和特性。比如说Android6.0 系统中引入了运行时权限这个功能,如果你将targetSdkVersion指定成23或者更高,那么系统就会为你的程序启用运行时权限功能;否则就不会启用运行时权限功能。

而在build.gradle的dependencies闭包中,声明了当前项目所有的依赖关系,Android studio 项目一共有3中依赖方式,本地依赖、库依赖和远程依赖,本地依赖可以对本地的jar包或者目录添加依赖关系,形式如:compile fileTree(dir: ‘libs’, include: [‘*.jar’]);库依赖可以对项目中的库模块添加依赖关系,如compile(name: ‘testsdk’, ext: ‘aar’);远程依赖则可以对jcenter库上的开源项目添加依赖关系,如compile ‘com.android.support.constraint:constraint-layout:1.0.2’,其中,com.android.support.constraint是域名,用于和其他公司的库作区分,constraint-layout是组名称,用于和同一个公司其他库作区分。

关于日志:不用System.out.println(),因为Log系统可以对日志分级,可以展示打印时间,可以添加过滤器等等。Log快捷键:如果要打Log.d,则输入logd,按tab键即可,同理Log.i只需要logi之后按Tab键,以此类推,四种级别的日志都能快捷打出来。还有,如果在oncreate方法外面输入logt,然后按下Tab键,就会以当前的类名作为值自动生成一个类似下面的TAG常量:

1
2
3
4
5

public class HelloWorldActivity extends AppCompatActivity{
private static final String TAG = "HelloWorldActivity";
}

如果有两个进程A和B需要将扫描的文档记录到CD上,进程A请求扫描仪并被授权使用,请求CD时发现已被B占用了,于是被拒绝,同理,B请求扫描仪也会被拒绝,于是产生死锁。在一个数据库系统中,为了避免竞争,可对若干记录加锁,如果进程A对R1加锁了,进程B对R2加了锁,接着这两个进程又试图把各自对方的记录也加锁,这是也会产生死锁。所以,软硬件资源都可能出现死锁

资源和死锁条件

资源分为两类,一类是可抢占资源(preemptable resource) 可以从拥有它的进程中枪战而不产生任何副作用,比如存储器。一个系统拥有256M的用户内存和一条打印机,如果有两个256M内存的进程都想打印,进程A获得了打印机,而B战友内存,但是幸运的是可以通过把进程B患处内存、把进程A换入内存可以实现抢占B的内存,这样,进程A继续运行并执行打印任务,然后释放打印机和内存。另一类是不可抢占资源(nonpreemptable resource)是指在不引起相关的计算失败的情况下,无法把它从占有它的进程中抢占过来,如CD刻录机。如果一个进程已经在开始刻盘,突然将刻录机分配给另一个进程,那么将划坏CD盘。

因此,总的来说,死锁和不可抢占资源有关,有关可抢占资源的潜在死锁通常可以通过在进程之间重新分配资源而化解,所以我们主要关注不可抢占资源上。当然,Coffman等人总结了发生(资源)死锁需要具备四个必要条件:

  • 不可抢占

    已经分配给一个进程的资源不能强制性地被抢占,他只能被占有它的进程显式地释放。

  • 互斥条件。

    每个资源要么已经分配了一个进程,要么就是可用的。

  • 占有和等待

    已经得到了某个资源的进程还可以再请求新的资源。

  • 环路等待

    死锁发生时,系统中一定有由两个或者以上的进程组成一条环路,该环路中每个进程都在等待着下一个进程所占有的资源。

注意:死锁发生时,以上四个条件一定是同时满足的。任何一个不成立就不会发生死锁

死锁建模

在讨论死锁解决方案之前,讨论如何对死锁建模是有意义的。有个叫Holt的人指出可以利用有向图建立死锁四个条件的模型——在有向图中有两类节点:用圆形表示进程,用方形表示资源。从资源节点到进程节点的有向边代表该资源已被请求、授权并被进程占用,由进程节点到资源节点的有向边表明当前进程正在请求该资源。以下是一个示意图:

死锁建模示意图

图中,当前资源R整备进程A占用,进程B正等待着资源S,图c)进入了死锁状态,进程C等待着资源T,资源T被进程D占用,进程D又等待着由进程C占用的资源U。

处理方法

总而言之,有四种处理死锁的策略:

  • 忽略该问题。也许如果你忽略它,他也会忽略你。

  • 检测死锁并恢复。

    让死锁发生,检测它们是否发生,一旦发生死锁,采取行动解决问题。

  • 仔细对资源进行分配,动态地避免死锁。

  • 破坏引起死锁的四个必要条件之一,防止死锁产生。

以下对这四种策略分别阐述。

忽略问题

最简单的方法是鸵鸟算法:把头埋进沙子里,假装根本没有问题发生。不同人对于该方法的看法不同,数学家认为这种方法不能接受,不管代价多大,都要彻底防止死锁产生;而对于工程师,它们会考量死锁发生的频率和严重性,如果平均5年一次死锁,那么大多数工程师不会以性能损失和可用性代价去防止死锁。

检测死锁和死锁恢复

每种类型一个资源的死锁检测

从最简单的例子开始,假定每种类型的资源只有一个,即排除了同时有两台打印机的情况。我们假设一个系统包括A到G共7个进程,R到W共6中资源,资源的占有情况和进程对资源的请求情况如下:
(1)A进程持有R资源,且需要S资源。
(2)B进程不持有任何资源,但需要T资源。
(3)C进程不吃油任何资源,但需要S资源。
(4)D进程持有U资源,且需要S资源和T资源。
(5)E进程持有T资源,且需要V资源。
(6)F进程持有W资源,且需要S资源。
(7)G进程持有V资源,且需要U资源。

问:系统是否存在死锁?如果存在,涉及哪些进程?

回答这一问题,初看很难,但是建模构造一张资源分配图之后,可以直观地看到图中包含了一个环,如下图所示:

对问题建模

在换种,可以看出进程D、E、G已经死锁,A、C、F没有死锁,因为可以把资源S分配给它们中的任意一个。

每种类型有多个资源的死锁检测

如果一类资源可能存在多个,就需要采用另一个方法来检测死锁。现在我们提供一种基于矩阵的算法来检测从P1到Pn这n个进程中的死锁。假设资源类型数为m,E1代表资源类型1,E2代表资源类型2,以此类推。E是现有资源向量,代表每种已存在的资源总数,比如资源类型1代表磁带机,那么E1=2就表示系统有两台磁带机。

在任意时刻,某些资源已被分配所以不可用,假设A是可用资源向量,那么Ai表示当前可供使用的资源数(即没有被分配的资源)。如果仅有的两台磁带机都已经分配出去了,那么A1的值为0 。

现在我们需要两个数组:C代表当前当前分配矩阵,R代表请求矩阵。C的第i行代表Pi当前所持有的每一种类型资源的资源数,所以Cij代表进程i所持有的资源j的数量,同理Rij代表Pi所需要的资源j的数量,数据结构如下图:

矩阵数据结构

这四种数据结构之间有一个重要的恒等式,具体地说,某种资源要么已分配,要么可用,这个结论意味着:

资源等式

换言之,如果我们将所有已分配的资源j的数量加起来在和所有可供使用的资源数相加,结果就是该类资源的资源总数。死锁检测算法就是给予向量的比较,我们定义向量A和向量B之间的关系为A小于或等于B以表明A的每一个分量要么等于要么小于和B向量对应的分量。

每个进程起初是没有被标记的,算法开始会对进程做标记,进程被标记后就表明它们能够被执行,不会进入死锁,死锁检测算法如下:

  1. 寻找一个没有标记的进程Pi,对于它而言,R矩阵的第i行向量小于或等于A。
  2. 如果找到了这样一个进程,那么将C矩阵的第i行向量加到A中,标记该进程,并转到第1步。
  3. 如果没有这样的进程,算法终止。

算法的第1步是寻找可以运行完毕的进程,该进程有资源请求并且该请求可被当前的可用资源满足。这一选中的进程随后就被运行完毕,在这段时间内它释放自己持有的所有资源并将它们返回到可用资源库中,然后,这一进程被标记为完成,如果所有的进程最终都能运行完的话,就不存在死锁,如果进程一直不能被运行,那它们就是死锁进程。

从死锁恢复

我们讨论各种从死锁中恢复的方法,尽管这些方法看起来都不那么令人满意:

  • 利用抢占恢复。

    临时将资源从当前所有者哪里转移到另一个进程,许多情况下这是需要人工敢于的。比如,要将激光打印机从它持有的进程那里拿走,管理员可以收集已打印好的文档,然后该进程被挂起,接着打印机被分配给另一个进程。

  • 利用回滚恢复。

    周期性地将进程的状态写入一个文件以备重启。一旦检测到死锁,拥有所需要资源的进程会回滚到一个时间点,在此时间点之前该进程获得了一些其他资源,在该检查点之后所做的工作都丢失。实际上,是将该进程复位到一个更早的状态,那时候它还没取得所需的这个资源。接着就把这个资源分配给一个死锁进程。

  • 杀死进程恢复。

    杀死环中的一个进程。或者杀死环外带有该资源的一个进程。

动态避免死锁

利用资源轨迹图、安全状态和不安全状态、银行家算法去解决。这里略复杂,暂时先不深入研究。

破坏引起死锁的四个条件之一

  • 破坏互斥条件,如果资源不被一个进程独占,那么死锁肯定不会产生。

    当然,允许两个进程同时使用打印机会造成混乱,通过采用假脱机(spooling printer)技术可以允许若干个进程同时产生输出。该模型中唯一真正请求使用物理打印机的进程是打印机守护进程,由于守护进程绝不会请求其他资源,因此不会产生死锁。

  • 破坏占有和等待条件。只要禁止已持有资源的进程再等待其他资源便可以消除死锁。

    一种实现方法是规定所有进程在开始执行前请求所需的全部资源。如果所需的全部资源可用,那么就将它们分配给这个进程,于是该进程肯定能够运行结束。如果一个或者多个资源正被使用,那么就不分配,进程等待。另一种方案是,要求当一个进程请求资源时,先暂时释放其当前占用的所有资源,然后再尝试一次获得所需要的全部资源。

  • 破坏不可抢占条件。

    假如一个进程已分配到一台打印机且正在进行打印输出,如果由于它需要的绘图仪无法获得而强制性把打印机抢占掉,则会引起混乱。但是,一些资源可以通过虚拟化的形式来避免发生这样的情况,假脱机打印机向磁盘输出,并且只允许打印机守护进程访问真正的物理打印机,就可以消除涉及打印机的死锁。然而,不是所有资源都可以进行类似的虚拟化,比如数据库中的记录在操作的时候必须要锁定,因此存在死锁的可能

  • 破坏环路等待。

    消除环路有几种方法。比较靠谱的方案是,对所有的资源统一编号,现在的规则是,进程可以在任何时刻提出资源请求,但是所有请求必须按照资源编号的顺序(升序)提出,如下图所示,进程可以先请求打印机后请求磁带机,但不可以先请求绘图仪后请求打印机。

资源排序

最后,总结一张图用于预防死锁:

预防死锁的方法汇总

进程是操作系统提供的最古老也是最重要的抽象概念之一。即使CPU只有一个,但他们也支持(伪)并发操作的能力。他们将一个单独的CPU变换成多个虚拟的CPU,没有进程的抽象,现代计算将不复存在。

进程

在任何多道程序设计系统中,CPU由一个进程快速切换到另一个进程,使每个进程各运行几十或者几百个毫秒,严格地来说,在某一个瞬间,CPU只能运行一个进程,但在1秒钟期间,它可能运行多个进程,这样就能产生并行的错觉,这就是伪并行。伪并行概念用来区分多处理器系统(系统有两个或者更多CPU并共享同一个物理内存)的真正硬件并行。

进程模型

一个进程就是一个正在执行的程序的实例,包括程序计数器、寄存器和变量当前值。从概念上讲,每个进程拥有它自己的CPU,当然,真正的CPU在各进程间来回切换,这种快速的切换称作多道程序设计。在多道程序计算机内存中有若干道程序,这些程序被抽象为若干个各自拥有自己控制流程(即每个程序自己的逻辑程序计数器)的进程,并且每个程序都独立地运行。当然,实际上只有一个物理程序计数器,所以程序运行时,它的逻辑程序计数器被装入实际的程序计数器,当该程序结束(或暂停)执行时,物理程序计数器被保存在内存中该进程的逻辑程序计数器中。

由于CPU在各进程之间来回快速切换,所以每个进程执行器运算的速度是不确定的,并且当同一进程再次运行时,器运算速度通常也不可再现。例如,考虑一个 I/O 进程,它执行一个10000次的空循环以等待磁带机达到正常速度,然后发出命令读取第一个记录。如果CPU决定在空循环期间切换到其他进程,则磁带机进程可能在第一条记录通过磁头之后还未被再次执行。

进程和程序间的却别是微妙的,但是非常重要。想象以为父亲正在为他的女儿烘制生日蛋糕,则做蛋糕的食谱就是程序(即用适当形式描述的算法),这位父亲是处理器(CPU),而做蛋糕各种原料(面粉、糖、鸡蛋等)就是输入的数据,进程就是他阅读食谱、取各种原料以及烘制蛋糕等一系列动作的总和。现假设他的儿子被蜜蜂蛰了哭着跑进来,父亲于是就记录下当前照着食谱做到哪里了(保存进程当前状态),拿出急救手册,为儿子处理蜇伤,我们看到处理机从一个进程(做蛋糕)切换到另一个优先级高的进程(医疗救治),每个进程有各自的程序(食谱和急救手册)。当急救完成之后,父亲又继续做蛋糕,从离开时的那一步继续做下去。值得注意的是,我们可能经常两次去启动同一个字处理软件,即一个程序运行了两遍,这也要算作两个进程。

创建进程

有4中主要事件导致进程创建:

  • 系统初始化

    启动操作系统时,通常会创建若干进程,这些进程中有些是前台进程,用于同用户(人类)交互。其他的是后台进程,后台进程一般具有某些专门的功能,例如,设计一个后台进程接收发来的电子邮件,这个进程在一天的大部分时间都在睡眠,但是当电子邮件达到时就被唤醒了。停留在后台处理诸如电子邮件、web页面、新闻、打印之类的活动的进程称为守护进程(daemon)

  • 正在运行的进程进行系统调用创建

    正在运行的进程发出系统调用,以便创建一个或者多个线程协助其工作。

  • 用户请求创建一个新的进程。

    用户双击图标或者输入命令行,启动一个新的程序。

  • 一个批处理作业的初始化

    仅在大型机的批处理系统中应用,用户在这种系统中提交批处理作业,在操作系统认为有资源科运行另一个作业时,它创建一个新的进程,并运行其输入队列中的下一个作业。

在Unix系统中,只有一个系统调用可以创建新的进程:fork。它会创建一个与调用进程相同的副本,这两个进程(父进程和子进程)拥有相同的存储镜像、同样的环境字符串和同样的打开文件,这就是全部情形。通常,子进程接着执行execve或者一个类似的系统调用,以修改器存储镜像并运行一个新的程序。例如,当一个用户在shell中输入sort时,shell就创建一个子进程,然后这个子进程执行sort。在Windows中,情形正相反,一个win32函数调用 CreateProcess既处理进程的创建,也负责把正确的程序装入新的进程。不论在Unix还是Windows中,进程创建后,父进程和子进程有各自不同的地址空间。

进程的终止

进程终止,通常由下列条件引起:

  • 正常退出(自愿)

    进程完成了自己的工作,调用系统调用,通知工作已经完成。

  • 出错退出(自愿)

    进程发现了严重错误,如要编译某个文件,但是该文件不存在,于是编译器就会退出。再给出了错误参数时,面向屏幕的交互式进程通常不退出,相反,这些程序会弹出一个对话框,并要求用户再试一次。

  • 严重错误(非自愿)

    进程引起的错误,通常是由于程序中的错误导致,例如执行了非法指令,引用不存在的内存等。在这类错误中,进程会收到信号(被中断),而不是在这类错误出现时终止。

  • 被其他进程杀死(非自愿)

进程的状态

尽管每个进程是一个独立的实体,有其自己的程序计数器和内部状态,但进城之间经常需要相互作用,一个进程的输出结果可能作为另一个进程的输入,在shell命令:

cat chapter1 chapter2 chapter3 | grep tree

中,第一个进程运行cat,将三个文件链接并输出,第二个进程运行grep,它从输入中选择所有包含单词tree的那些行。根据这两个进程的相对速度,可能发生这种情况:grep准备就绪可以运行,但输入还没有完成,于是必须阻塞grep;还有可能是:一个概念上能够运行的进程被迫停止,因为操作系统调度另一个进程占用了CPU。下图可以看到显示进程的三种状态的状态图:

进程三种状态

运行态和就绪态在逻辑上是类似的,二者都可以运行,只是后者还没有CPU分配给它,阻塞态就完全不同,处于该状态的进程不能运行,即使CPU空闲也不行。在操作系统发现进程不能继续运行下去时,发生转换1;转换2和3是由于进程调度引起的;当进程等待的一个外部事件发生时(如一些输入到达),则发生转换4。

进程的实现

操作系统维护者一张表格,即进程表(process table)。每个进程占用一个进程表项,该表项包含了进程状态的重要信息,诸如程序计数器、堆栈指针,内存分配状况、所打开的文件的状态、账号和调度信息等。下图展示了典型系统中的关键字段:

典型的进程表项字段

多道程序设计模型

采用多道程序设计可以提高CPU利用率,从概率的角度来看cpu的利用率是比较好。假设一个进程等待I/O操作的时间与其停留在内存中的时间比是p,当内存中有n个进程时,则所有n个进程都在等待I/O(此时CPU空转)的概率是p的n次方,CPU的利用率由一下公式给出:

CPU利用率公式

从完全精确的角度考虑,应该指出此概率模型只是描述了一个大致的状况。它假设所有n个进程都是独立的,即内存中的5个进程中,3个运行,2个等待是完全可以接受的,但在单cpu中,不能同时运行3个进程,所以当CPU忙时,已就绪的进程必须等待CPU,因而,进程不是独立的,更精确的模型应该使用排队论构造,但它仍然是具有参考意义的,下图以n为变量的函数表示CPU的利用率,n称为多道程序设计的道数(degree of multiprogramming)

CPU利用率-道数的关系

从图中可以看到,如果进程花费80%的时间等待I/O,为使CPU的浪费低于10%,至少要有10个进程同时在内存中。当读者认识到一个等待用户从终端输入的交互式进程是处于I/O等待状态时,很明显,80%甚至更多的I/O等待时间是普遍的,即使是在服务器中,做大量磁盘I/O操作的进程也会花费同样或更多的等待时间。虽然图中的模型很粗略,但它依然对预测CPU的性能很有效。例如,假设计算机有512MB内存,操作系统占128MB,每个用户程序占128MB,那么这些内存允许3个用户程序同时驻留内存,若80%时间用于I/O等待,则CPU利用率大约是1-0.80.80.8 ,大约49%,在增加512MB内存后,CPU利用率可以提高到79%,换而言之,第二个512M内存提高了30%的吞吐量。但是增加第三个512MB内存只能讲CPU利用率提高到91%,吞吐量仅提高12%,因此可以确定第一次增加内存是合算的投资,第二个则不是。

线程

线程的使用

人们认为需要使用线程的理由如下:

  • 并行的线程可以共享同一个地址空间和所有可用数据的。

    对于某些应用而言,这是必需的,二者正式多进程模型(它们具有不同的地址空间)所无法表达的。

  • 线程比进程更轻量级,更快捷地创建和撤销。

    在许多系统中,创建一个线程较一个进程要快10~100倍。在有大量线程需要动态和快速修改时,这一特性很有用。

  • 若多个线程都是CPU密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的I/O处理,拥有多个线程允许这些活动彼此重叠进行,从而加快应用程序执行速度。

  • 在多CPU系统中,多线程是有益的,在这样的系统中,真正的并行有了实现的可能。

假如字处理软件被编写成含有3个线程的程序,一个与用户交互,另一个在后台重新进行格式处理,一旦有某一句语句被删掉,交互线程就通知格式化线程对整个文档重新进行处理,同时交互线程继续监控键盘和鼠标,并相应诸如滚动到第一页之类的简单命令。剩下那个每隔一段时间进行磁盘备份,防止程序崩溃或者系统崩溃。拥有三个线程的情形如下图所示:

三个线程协同

如果程序是单线程的,那么在进行磁盘备份时,来自键盘和鼠标的命令就会被忽略,直到备份工作完成为止。并且,很显然在这里三个不同的进程是不能工作的,这是因为三个线程都需要在同一个文件上进行操作,通过让三个线程代替三个进程,三个线程共享公共内存,于是它们都可以访问同一个正在编辑的文件。

现在考虑另一个多线程发挥作用的例子,一个web服务器,某些页面较其它页面相比,有更多的访问,比如对主页的访问比其它更深层次的页面访问次数更多。利用这一事实,web服务器可以把获得大量访问的页面集合保存在内存中,这样的一种页面集合称为高速缓存(cache),高速缓存也应用在其它很多场合。一种组织web服务器的方式如下图所示:

多线程web服务器

上图中,一个称为分派程序(dispatcher)的线程从网络中读入工作请求,在检查请求之后,分派现成挑选一个空转的(即被阻塞的)工作线程(worker thread)提交该请求。接着分派现成唤醒该工作线程,将它由阻塞状态转变为就绪状态。工作线程被唤醒后,它检查有关的请求是否在web页面的cache中,这个高速缓存是所有线程都可以访问的,如果没有,该线程开始执行从磁盘中调入页面的I/O操作,并且进入阻塞直到磁盘操作完成。当上述工作线程阻塞在磁盘操作时,为了完成更多操作,分派线程可能挑选另一个工作线程运行,也可以把另一个已经就绪的工作线程投入运行。

这种模型允许把服务器编写为一个集合,在分派线程的程序中包含一个无限循环,该循环用来获得工作请求并把工作请求派给工作线程。每个工作线程的代码包含一个从分派线程接收请求的无限循环,收到请求后,如果页面存在,则返回给客户机,接着该工作线程阻塞,等待一个新的请求到来。

在没有多线程的情况下,编写web服务器。一种可能的方式是:web服务器的主循环获得请求,检查请求,并在取下一个请求之前完成整个工作。在等待磁盘操作时,服务器空转,不处理任何到来的其他请求。课件线程较好地改善了web服务器的性能。如果对于这种性能的降低不可接受,那如果使用read系统调用,则还有一种可能的方式。在请求到来时,这个唯一的线程对请求进行考察,如果请求能在高速缓存中得到满足,那么一切都好,否则,就启动一个非阻塞的磁盘操作。服务器在表格中记录当前请求的状态,然后去处理下一个事件。下一个事件可能是一个新工作的请求,或是磁盘对先前操作的回答。如果是新工作的请求,就开始该工作;如果是磁盘的回答,就从表格中取出对应的信息,并处理该回答。对于非阻塞磁盘I/O而言,这种回答多数会以信号或者中断的形式出现。注意,这种情况下,“顺序进程”模型消失了,每次服务器从为某个请求工作的状态切换到另一个状态时,都必须显式地保存或者重新装入相应的计算状态。这里,每个计算都有一个被保存的状态,存在一个会发生且使得相关状态发生改变的事件集合,我们把这类设计称为有限状态机(finite-state machine)

现在很清楚多线程必须提供的是什么了,多线程使得“顺序进程”的思想得以保留下来,这种顺序进程阻塞了系统调用(如磁盘I/O),但是仍旧实现了并行性。

构造服务器三种方法

经典线程模型

进程用于把资源集中到一起,而线程是在CPU上被调度执行的实体。在同一个进程环境中,多个线程共享一个地址空间和其他资源,这意味着线程间共享同样的全局变量,一个线程也可以读、写甚至清除另一个线程的堆栈,线程间是没有保护的,因为这是没有必要的,因为同一个进程中的多个线程是合作而不是竞争关系。线程之间对于资源的持有如下图所示:

线程与资源

和传统进程一样(即只有一个线程的进程),线程可以处于若干种状态的任何一个:运行、阻塞、就绪或者终止。认识到每个线程有其自己的堆栈很重要,下图展示这一状况:

每个线程有自己的堆栈

每个线程的堆栈有一帧,供各个被调用但是还没有从中返回的过程使用。例如,如果过程X调用过程Y,而Y又调用Z,那么当Z执行时,供X、Y和Z使用的帧会全部存在堆栈中。通常每个线程会调用不同的过程,从而有一个各自不同的执行历史,这就是为什么每个线程需要有自己的堆栈的原因。这里还可以了解线程的join操作和yield操作。

POSIX 线程

POSIX线程是线程的POSIX(Portable Operating System Interface of UNIX,可移植操作系统接口)标准。它定义的线程包叫做Pthread。有两种主要的方法实现线程包:在用户空间中和在内核中。这两种方法互有利弊,不过混合实现方式也是可能的。

在用户空间中实现线程

这种方法是把整个线程包放在用户空间,内核对线程包一无所知,从内核角度考虑,就是按单线程进程方式管理。这种方法的优点:

  • 最明显的优点是:用户级线程包可以在不支持线程的操作系统上实现。

    线程在一个运行时系统的顶部运行,这个运行时系统是一个管理线程的过程的集合。在用户空间管理线程时,每个进程需要有其专用的线程表(thread table),用来跟踪该进程中的线程。这些表和内核中的进程表类似,不过它仅仅记录各个线程的属性,如每个线程的程序计数器、堆栈指针、寄存器等。该线程表由运行时系统管理,当一个线程转换到就绪态或者阻塞态时,需要在线程表中存放重新启动该线程所需的信息。如下图可以对比不同方式实现的线程包:

用户级和内核级线程包

  • 可以方便地在进程内调度切换到另一个线程,并且线程切换非常快捷。

    在线程运行完成,例如,线程调用thread_yield ,就可以把线程的信息保存在线程表中,进而,线程调度程序来选择另一个要运行的线程。保存该线程状态的过程和调度程序都只是本地过程,所以启动它们比进行内核调用效率更高。

  • 允许进程有自己定制的调度算法。

    例如,那些有垃圾收集线程的应用程序就不用担心线程会在不合适的时刻停止。

尽管用户级线程具有上述的优点,但是它的缺点也明显:

  • 第一个问题是如何实现阻塞系统调用。

    假设在还没有任何按键动作之前,一个线程读取键盘,让该线程时机进行该系统调用时不可接受的,因为这会停止所有的线程。使用线程的一个主要目标是:首先要允许每个线程使用阻塞调用,但是还要避免被阻塞的线程影响其他的线程。有了阻塞系统的调用,这个目标不太容易实现。

  • 页面故障问题。

    如果某个程序调用或者跳转到了一条不在内存的指令上,而操作系统将到磁盘上取回这个丢失的指令(和该指令的“邻居们”),这就称为页面故障。如果有一个线程引起页面故障,内核甚至不知道有线程存在,通常会把整个进程阻塞,直到磁盘I/O完成为止,尽管其他线程是可以运行的。

  • 如果一个线程开始运行,那么在该进程中的其他线程就不能运行,除非第一个线程自动放弃CPU。

    在一个单独的进程内部,没有时间中断,所以不可能用轮转调度(轮流)的方式调度进程。

  • 可能反对用户级线程的最大负面意见是,程序员通常在经常发生线程阻塞的应用中才希望使用多个线程。

在内核中实现线程

由上面在用户空间实现的线程和在内核空间实现的线程对比图可以看出,在内核中实现线程不在需要运行时系统,另外,每个进程中也没有线程表。所有能够阻塞线程的调用都以系统调用的形式实现,这与运行时系统过程相比,代价是相当可观的。当一个线程阻塞时,内核根据其选择,可以运行同一个进程中的另一个线程或者运行另一个进程中的线程。而在用户级线程中,运行时始终运行自己进程的线程,直到内核剥夺它的CPU。如果某个进程中的线程引起页面故障,内核可以很方便地检查该进程是否还有其他可运行的线程,如果有,在等待所需要的页面从磁盘读入时,就选择一个线程运行。

在内核中实现线程有以下缺点:

  • 但是内核中创建、撤销、切换线程操作代价都比较大。

    在内核中尽量减少创建和撤销线程,最好能实现复用(在执行完成后,标记为不可运行,当要创建新线程时,再将其启用)。

  • 信号是发给进程而不是线程,当信号达到时要考虑将信号交给哪个线程处理。

    即使线程可以注册感兴趣的信号,但是多个线程注册同一个信号如何处理也是个问题。

混合实现

有试图将用户级线程的优点与内核级线程的优点结合起来的方法,一种方法是使用内核级线程,然后将用户级线程与某些内核线程多路复用起来,如下图所示:

用户级和内核级线程混合实现

进程间通信

这个部分看了两次都不甚明白,先跳过。

调度

进程行为

几乎所有进程的(磁盘)I/O请求或计算都是交替突发的。如下图所示,CPU不停顿地运行一段时间,然后发出一个系统调用以便读写文件。如下图所示:

突发进行

图中有一件事值得注意,某些进程花了大多数时间在计算上,称为计算密集型;有些在等待I/O上花了大多数啊时间,称为I/O密集型。有必要指出,随着CPU越来越快,更多的进程倾向于I/O密集型,这里的基本思想是,如果需要运行I/O密集型进程,那么就应该让它尽快得到机会,以便发出磁盘请求并保持磁盘始终忙碌。

何时调度

  • 创建新进程后,决定运行父进程还是子进程。
  • 在一个进程退出时必须做出调度决策。
  • 一个进程阻塞时,必须选择另一个进程运行。
  • 发生I/O中断时,做出调度决策。

调度算法的目标

在讨论目标前,有必要了解我们会处于那些环境中,在不同的应用领域有不同的环境,可以将其分为三类:

  • 批处理
  • 交互式
  • 实时

下图针对所有环境列出不同的目标:

不同环境不同目标

批处理系统的调度算法

先来先服务
最短作业优先
最短剩余时间优先

交互式系统调度算法

轮转调度
优先级调度
多级队列调度:
最短进程优先
保证调度
彩票调度
公平分享调度

前面讨论的基本认证方式和摘要认证以及报文完整性检查都是轻量级的方法,但对于重要的如银行业务处理,大规模网上购物来说,这还不够,我们需要一种能够提供下列功能的HTTP安全技术:

  • 服务器验证,客户端验证服务器是真的还是伪造的
  • 客户端认证,服务器验证客户端是真的还是伪造的
  • 完整性,客户端和服务器的数据不被修改
  • 加密,无需担心会话被窃听
  • 效率,算法的运行要求足够快
  • 普适性,基本上所有的客户端和服务器都支持这种协议
  • 其他

https

https是最流行的HTTP安全形式,它的URL以** https:// ** 而不是 http:// 开头。所有的HTTP请求和响应数据在发送到网络之前都要进行加密,HTTPS在HTTP下面提供了一个安全层,可以使用SSL,也可以使用其后继者TSL(Transport Layer Security,传输层安全),由于二者十分类似,所以一般不太严格地用SSL来表示SSL和TSL。

http与https的层次

数字加密

概念

  • 密码:对文本进行编码的算法。

  • 密钥:改变密码行为的数字化参数

  • 对称密钥加密系统:编/解码使用相同密钥的算法

  • 不对称密钥加密系统:编/解码使用不同密钥的算法

  • 公开密钥加密算法:一种能够使数百万计算机便捷地发送机密报文的系统

  • 数字签名:用来验证报文是否被改动的校验和。

  • 数字证书:由可信的组织验证和签发的识别信息

假如使用rot3(循环移位3字符)方式对报文加密,则 明文 meet 加密后为 phhw,示意图如下:

循环移位3字符

那在概念中,密码就是 “循环移位N字符” ,N的值是由密钥控制的,在这里N的值是3。 改变密钥的值就能产生不同的密文。编码算法和编码机器都可能落入敌人手中,但是只要没有正确的号盘设置(密钥值),也无法实现解码。这种属于使用了密钥的密码。

相同密码不同密钥

数字密码:数字密码只是一些数字,这些数字密钥值是编码/解码算法的输入,编码算法就是一些函数,这些函数会读取一块数据,并根据算法和密钥的值对其进行编码/解码。给定一段明文P、一个编码函数E和一个数字编码密钥e,就可以生成一段经过编码的密文C,通过解码函数D和解码密钥d,就可以将密文C解码为原始的明文P,当然,编码/解码函数互为反函数。

对称密钥加密技术

对称密钥加密在编码时候使用的密钥值和解码时候的密钥值一样的。保持密钥的机密很重要,在很多情况下,编码解码算法都是众所周知的,因此密钥是唯一保密的东西了。可用密钥的数量取决于密钥中的位数,以及可能的密钥中有多少是有效的。就对称加密而言,通常所有的密钥值都是有效的(知名的非对称密钥加密系统RSA中,有效密钥必须以某种方式与质数相关,因此并非所有的密钥都有效)。8位的密钥只有256个可能,40位的密钥可以有2的40次方个可能的密钥值等等。

密钥长度与破解难度

流行的对称密钥加密算法有:DES/Triple-DES、RC2、RC4

缺点:发送者和接收者在对话前一定要有一个共享的保密密钥,服务器与每个客户端都需要有一个独立的密钥,以致如果客户端数量过多的情况下,服务器保存的密钥数量可观。如果N个节点,每个节点都要和其他所有N-1个节点通信,总共大概会保存N的平方个密钥,这将是一个管理噩梦。

公开密钥加密技术

公开密钥加密技术使用了两个非对称密钥,一个用来对报文编码,一个用来对报文解码。编码密钥是可以公之于众的,每个不同的客户端可以用相同的编码密钥进行加密,但是只有主机自己才知道私有的解密密钥,只有解密密钥才能解码。

对称加密与公开密钥技术

在引入公钥加密机制之前,可以先看两个问题:

问题1:314159265358979 × 314159265358979 的结果是多少?
问题2: 3912571506419387090594828508241 的平方根是多少?

如果不用计算器,第一个问题,相信大多数人花上一两个小时用纸笔能够计算出来,而第二个问题,即使花上一两天,估计也基本上没人能解出来。虽然平方和开方互为逆运算,但是它们的复杂度差异却很大,这种不对称性构成了公钥密码体系的基础。一种叫做RSA的公钥机制表明,对计算机来说,大数的乘法比对大数进行因式分解要容易得多。所有公开密钥非对称加密系统的要求是,即便拥有以下线索:

  • 公开密钥(共有的,每个人都可以获得)
  • 一小片拦截下来的密文(可以网络嗅探获得)
  • 一条报文以及与之相关的密文(对一段文本使用公钥加密就可以得到)

也无法计算出保密的私有密钥。

RSA 就是满足这些条件的流行的公开机密要加密系统。RSA的算法是公开的,源代码也可以获得,破解的难度与一个极大的数字进行质因数分解的难度一样。

混合加密系统和会话密钥

任何人只要知道了公开密钥,就可以向一台公共服务器发送安全报文,所以非对称的公开密钥加密系统是很好用的,两个节点无须为了进行安全的通信而先交换私有密钥。但公开密钥加密算法的计算可能会很慢,比较常见的做法是在两个节点之间通过便捷的公开密钥加密技术建立起安全通信,然后再利用那条安全通道产生并发送临时的随机对称密钥,通过更快的对称加密技术对其余的数据进行加密。

数字签名

除了加密/解密报文之外,还可以用加密系统对报文进行签名(sign),以说明是谁编写的报文,同时证明报文未被篡改过,这种技术被称为数字签名(digital signing)。签名是加了密的校验和,使用数字签名有以下两个好处:

  • 签名可以证明是作者编写了这条报文。

    只有作者才有最机密的私有密钥,因此只有作者才能计算出这些校验和。

  • 签名可以防止报文被篡改。

    如果恶意攻击者在传输过程中修改了报文,则校验和就不再匹配了,攻击者没有私钥,无法为篡改的报文伪造正确的验证码。

以下例子说明了节点A是如何向节点B发送一条报文,并对其进行签名:

  1. 节点A将变长报文提取为定长摘要。
  2. 节点A对摘要应用一个“签名”函数,这个函数会将用户的私有秘钥作为参数。因为只有A知道私有密钥,所以正确的签名函数会说明签名者就是所有者。在下图中,由于解码函数D中包含了用户的私有密码,所以我们将其作为签名函数使用。
  3. 一旦计算出签名,节点A就将其附加在报文末尾,并将报文和签名都发给B
  4. 在接收端,如果B需要确定报文确实是A写的,而且没有被篡改过,节点B就可以对签名进行检查。节点B接收经过私有秘钥扰码的签名,并应用了使用公开密钥的反函数,如果拆包后的摘要与节点B自己的摘要版本不匹配,就说明要么被篡改了,要么发送者不是A。

报文签名例子

数字证书

数字证书都是由可信任的颁发机构颁发,它通常包括 对象的名称、过期时间,证书颁发者,来自证书发布者的数字签名、通常还包括对象的公开密钥以及对象使用签名算法的描述性信息。典型的数字签名格式如下:

典型的数字签名

数字证书没有单一的全球标准,但现在大多数证书都以一种标准格式 ——X.509 v3 描述。

用证书对服务器进行认证

通过https建立一个安全的web事务之后,现代的浏览器会自动获取所连接服务器的数字证书,如果如武器没有证书,安全连接就会失败,服务器证书中包含很多字段,包括:

  • web站点的名称和主机名;
  • web站点的公开密钥;
  • 颁发机构的名称;
  • 颁发机构的签名。

浏览器收到证书时会对签名颁发机构进行检查,如果这个机构是很权威的公司,那浏览器一般已经知道其公开密钥了(浏览器会预先安装很多签名颁发机构的证书),就能像之前那样验证签名了。如果对签名颁发机构一无所知,浏览器就无法确定是否应该信任这个签名颁发机构,它通常会向用户展示一个对话框,看用户是否相信这个签名发布者,因为发布者可能是本地IT部门。以下展示了验证签名的过程:

验证签名

HTTPS-细节介绍

https就是在安全的传输层上发送的http,它在将http报文发送给TCP之前,先将其发送给一个安全层,对其进行加密。对web服务器发起请求时,我们需要一种方式来告知web服务器去执行http的安全协议版本,这是在URL的方案中实现的,对web资源执行某事务时,它会检查URL方案:

  1. 如果URL方案是http,客户端会打开一条到服务器端口80的连接(默认情况下),并发送http命令。
  2. 如果URL的方案是https,客户端就会打开一条到服务器端口443(默认情况)的连接,然后与服务器握手,以二进制格式与服务器交换一些SSL安全参数,附上加密的http命令。

在https中,客户端首先打开一条到web服务器端口443(默认情况)的连接,一旦建立了TCP连接,客户端和服务器就会初始化SSL层,对加密参数进行沟通,并交换密钥,握手完成后,SSL就初始化完成了,客户端就可以将请求报文发送给安全层了,在将这些报文发送给TCP之前,要先对其进行加密。http和https的对比:

验证签名

SSL握手

在发送已加密的HTTP报文之前,客户端和服务端要进行一次SSL握手,主要完成以下工作:

  • 交换协议版本号
  • 选择一个两端都了解的密码
  • 对两端身份进行验证
  • 生成临时会话密钥,以便加密信道

SSL握手简化版示意图如下:

SSL简化版握手

在一个web服务器上执行安全事务,比如提交银行卡信息时,总是希望在于你所认为的那个组织对话,因此https事务总是要求使用服务器证书的。站点证书有效性检查步骤如下:

  1. 日期检测。检查证书的起始日期和结束日期。
  2. 签名颁发者可信度检测。每个证书是由某个证书颁发机构(CA)签发的,它们负责位服务器担保,证书有不同的等级,每种证书都要求不同级别的背景验证。比如申请某个电子商务服务证书,通常要求提供一个营业的合法证明。
  3. 签名检测。一旦判定签名授权是可信的,浏览器就要对签名使用颁发机构的公开密钥,并将其与校验码比较,以查看证书的完整性。
  4. 站点身份检测。为防止服务器复制其他人的证书,或拦截他人流量,大部分浏览器都会试着去验证证书中的域名与它们所对话的服务器的域名是否匹配。

虚拟主机与证书

对于虚拟主机(一台服务器上有多个主机名),有些流行的web服务器程序只支持一个证书,如果用户请求的是虚拟主机名,与证书名称并不完全匹配,浏览器就会显示警告框。比如cajun-shop.com网站,站点的托管服务提供商提供的官方名称位 cajun-shop.securesites.com。 用户进入http://www.cajun-shop.com时,服务器证书中列出的官方主机名(*.securesites.com)与用户浏览的虚拟主机名(www.cajun-shop.com)不匹配,以至于出现警告。

为了防止出现这个问题,cajun-shop.com的所有者会在开始处理安全事务时,将所有用户都重定向到cajun-shop.securesites.com。

通多代理以隧道形式传输安全流量

很多公司都会在公司网络和公共因特网的安全边界上放置一个代理,代理是防火墙路由器唯一允许进行http流量交换的设备,它可能会进行病毒检测或者其他的内容控制工作。

公司的代理

但只要客户端开始用服务器的公开密钥对发往服务器的数据进行加密,代理就再也不能读取http首部了!代理不能读取首部,意味着无法知道应该将请求转向何处。

代理不能转发

这种情况一种常用的技术就是https SSL 隧道协议,客户端首先要告知代理,他想要连接的安全主机和端口,这是在开始加密之前以明文形式告知的,所以代理可以理解这条信息。

http通过新的名为connect的扩展方法来发送明文形式的端点信息,connect方法会告诉代理,打开一条到期望主机和端口号的连接,这项工作完成之后,直接在客户端和服务器之间以隧道形式传输数据。

以下示意了一个connet:

connect实例

第12章——基本认证机制

当访问某些需要授权才能访问的资源时,服务器会返回401要求登录认证,web服务器会将受保护的文档组织成一个 安全域(security realm),每个安全域可以有不同的授权用户集。

一个例子

假设web服务器建立了两个安全域,一个用于公司的财务信息,一个用于个人家庭的文档,那么公司的CEO应当能够访问销售额预测资料,但不应该允许CEO访问员工和其家人度假的照片。

下面是一个假想的基本认证质询,它指定了一个域:

HTTP/1.0 401 Unauthorized
WWW-Authenticate:Basic realm=”Family”

域应该有一个描述性字符名,比如 Family(员工个人家庭照片),以帮助用户了解应该使用哪个用户名和密码。

基本认证实例

缺点

基本认证的机制很简单,但是存在以下主要的安全隐患:

  • 基本认证以 username:pwd 的形式将用户名密码拼接起来,并且通过Base-64的加密后通过网络发送用户名和密码,这基本上相当于明文传输(base-64很容易破解)。
  • 即使使用其他更难解密的方式加密,也没有机制防止重放攻击。
  • 没有针对中间节点的防护,头部不被更改,能通过认证,但是报文内容更改了也能造成很大的危害。

第13章——摘要认证

摘要认证试图修复基本认证协议的严重缺陷,它遵循的箴言是“绝不通过网络发送密码”,相对基本认证,它做了如下改进:

  • 永远不以明文在网络上发送密码
  • 可以防止恶意用户捕获并且重放的握手过程
  • 可以选择性地防止对报文内容的篡改
  • 防范其他常见形式的攻击

原理

摘要认证的主要原理是“对信息主体的浓缩”,它认证的主要流程如下:

  1. 客户端请求了某个受保护的文档。
  2. 在客户端能够证明身份前,服务器拒绝提供文档,并向客户端发起质询,询问用户名和摘要形式的密码。
  3. 客户端传递用户名和密码的摘要。服务器知道所有用户的密码,将收到的摘要与自己用密码计算出来的摘要对比,即可校验用户身份真伪。
  4. 如果验证通过,则开始向客户端提供文档。

注意:整个过程都没有在网络上发送密码!而是发送密码的“摘要”或者说是指纹

整个过程图示如下:

摘要认证流程

摘要

摘要是一种单向函数,主要用于将无限的输入值转换为有限的浓缩输出值,有时也将摘要函数称为加密的校验和、单向散列函数或者指纹函数。常见的摘要函数是 MD5 ,会将任意长度的字节序列转换为一个128位的摘要。MD5输出的128位的摘要通常会被写成32个16进制的字符,每个字符表示4位。

重放攻击

使用单向摘要就无需以明文发送密码了,但是别有用心的人还是可以截获摘要,并一遍遍地重放给服务器,进行重放攻击,在这点上,摘要和密码一样好用。

为了防止重放攻击,服务器可以向客户端发送一个称为 随机数(nonce)的特殊令牌,这个数会经常发生变化(根据具体规则来定,可以每次认证都变化),客户端在计算摘要之前要先将这个随机数令牌附加到密码上去。在密码中加入随机数就会使摘要随着随机数的每次变化而变化,没有密码就无法计算出正确的摘要。

摘要认证的握手机制

摘要认证的握手步骤流程如下:

  1. 客户端请求被保护的文档。
  2. 服务器计算出一个随机数,放入质询报文(WWW-Authenticate)中,与服务器支持的算法列表一同发给客户端。
  3. 客户端选择其中一个算法,计算出密码和其他数据的摘要。并将摘要放在认证报文(Authorization)中发回服务器,如果客户端要对服务器进行质询,可以发送客户端的随机数。
  4. 服务器接受摘要、选中的算法以及支撑数据,在本地生成摘要,并与客户端发来的摘要对比验证。如果客户端有对服务器进行质询,就会创建服务端摘要。

摘要认证会话过程优化

预授权

普通的认证方式中,事务结束前,每条请求都要有一次 请求/质询 的循环,如果客户端事先知道下一个随机数是什么,就可以取消这个 请求/质询 循环,这样客户端就可以在服务端发出请求之前,正确地生成Authorization首部了。这样就能减少报文的数量,对性能也有很大的提升,如图:

预授权减少报文数量

此外,还有几种预授权的方式:

  • 服务器预先在Authentication-info成功首部中发送下一个随机数。

    这虽然避免了 请求/质询 循环,但是它也破坏了对同一条服务器的多条请求进行管道化的功能,因为在发布下一条请求之前,一定要收到下一个随机值才行。

  • 服务器允许在一段时间内使用同一个随机数。

    可能会有一定次数的重放攻击的可能性。

  • 客户端和服务器使用同步的、可以预测的随机数生成方法。

报文完整性保护

如果使用了完整性保护(qop=”auth-init”),对应的内容就是对实体主体部分,而不是报文主体部分的散列,对于发送者,要在应用任意传输编码方式之前计算,而对于接收者,则应在去除所有传输编码之后计算。

总结安全隐患和相应解决方案

  • 重放攻击。

    用生成随机数解决,可能可以包括IP地址、时间戳、资源Etag等计算摘要。

  • 多重认证机制。

    比如同时存在基本认证和摘要认证时。可以考虑使用最强认证方案。

  • 首部篡改。

    防范方式:要么端到端加密,要么对首部进行数字签名,最好二者结合。

  • 词典攻击。

    没有好的对策,设置合理的密码过期策略,和难以猜测和破译的密码吧。

  • 恶意代理攻击和中间人攻击。

    没有更好的方法,唯一方式是使用SSL。

  • 选择明文攻击。

    利用摘要词典获取密码明文,或者暴力枚举可能的密码。

  • 存储密码。

    如果摘要认证密码文件被入侵,攻击者就获取到域中所有文件,而无需进行解码了。消除这个问题的方法:(1)加强保护 (2)确保域名在所有域中是唯一的。如果密码文件被入侵,所造成的破坏也局限于某一特定域。

摘要加密没有为内容的安全提供保障,可能可以知道内容是否被篡改,真正安全的事务是通过SSL才能实现。