前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >invoke和begininvoke 区别——c#

invoke和begininvoke 区别——c#

作者头像
vv彭
修改2023-09-24 14:55:03
2.5K0
修改2023-09-24 14:55:03
举报
文章被收录于专栏:c#学习笔记

本文转自:https://cloud.tencent.com/developer/article/1759131

https://www.cnblogs.com/worldreason/archive/2008/06/09/1216127.html

invoke和begininvoke 区别

一直对invoke和begininvoke的使用和概念比较混乱,这两天看了些资料,对这两个的用法和原理有了些新的认识和理解。

首先说下,invoke和begininvoke的使用有两种情况:

  1. control中的invoke、begininvoke。
  2. delegrate中的invoke、begininvoke。

这两种情况是不同的,我们这里要讲的是第1种。下面我们在来说下.NET中对invoke和begininvoke的官方定义。

control.invoke(参数delegate)方法:在拥有此控件的基础窗口句柄的线程上执行指定的委托。

control.begininvoke(参数delegate)方法:在创建控件的基础句柄所在线程上异步执行指定委托。

根据这两个概念我们大致理解invoke表是同步、begininvoke表示异步

如果你的后台线程在更新一个UI控件的状态后不需要等待,而是要继续往下处理,那么你就应该使用BeginInvoke来进行异步处理。

如果你的后台线程需要操作UI控件,并且需要等到该操作执行完毕才能继续执行,那么你就应该使用Invoke。

我们来做一个测试。

invoke 例子:

代码语言:javascript
复制
private void button1_Click(object sender, EventArgs e)
{
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+"AAA");
            invokeThread = new Thread(new ThreadStart(StartMethod));
            invokeThread.Start();
            string a = string.Empty;
            for (int i = 0; i < 3; i++)      //调整循环次数,看的会更清楚
            {
                Thread.Sleep(1000);   
                a = a + "B";
            }
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+a);
}

 private void StartMethod()
{
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+"CCC");
            button1.Invoke(new invokeDelegate(invokeMethod));  
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+"DDD");
}

 private void invokeMethod()
{
            //Thread.Sleep(3000);
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString() + "EEE");
} 

结论:我们运行后,看下程序的运行顺序,1AAA->3CCC和1BBB->1EEE ->3DDD 。

解释:主线程运行1AAA,然后1BBB和子线程3CCC同时执行,然后通过invoke来将invokemethod方法提交给主线程,然后子线 程等待主线程执行,直到主线程将invokemethod方法执行完成(期间必须等待主线程的任务执行完成,才会去执行invoke提交的任务),最后执 行子线程3DDD。

begininvoke 例子:

代码语言:javascript
复制
private void button1_Click(object sender, EventArgs e)
{
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+"AAA");
            invokeThread = new Thread(new ThreadStart(StartMethod));
            invokeThread.Start();
            string a = string.Empty;
            for (int i = 0; i < 3; i++)      //调整循环次数,看的会更清楚
            {
                Thread.Sleep(1000);   
                a = a + "B";
            }
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+a);
}

 private void StartMethod()
{
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+"CCC");
            button1.BeginInvoke(new invokeDelegate(invokeMethod));  
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString()+"DDD");
}

 private void beginInvokeMethod()
        {
            //Thread.Sleep(3000);
            MessageBox.Show(Thread.CurrentThread.GetHashCode().ToString() + "EEEEEEEEEEEE");
        }

结论: 我们运行后看看执行的结果:1AAA->1BBB和3CCC->1EEE和3DDD。

解释: 主线程运行1AAA,然后1BBB和子线程3CCC同时执行,然后通过begininvoke来将invokemethod方法提交给主线程,然后主线程执行1EEE(主线程自己的任务执行完成), 同时子线程继续执行3DDD。

通过这个两段代码的测试比较,我们会发现其实invoke和begininvoke所提交的委托方法都是在主线程中执行的,其实根据我invoke 和begininvoke的定义我们要在子线程中来看这个问题,在invoke例子中我们会发现invoke所提交的委托方法执行完成后,才能继续执行 DDD;在begininvoke例子中我们会发现begininvoke所提交的委托方法后,子线程讲继续执行DDD,不需要等待委托方法的完成。 那么现在我们在回想下invoke(同步)和begininvoke(异步)的概念,其实它们所说的意思是相对于子线程而言的,其实对于控件的调用总是由 主线程来执行的。我们很多人搞不清这个同步和异步,主要还是因为我们把参照物选错了。其实有时候光看概念是很容易理解错误的。

解决从不是创建控件的线程访问它

在多线程编程中,我们经常要在工作线程中去更新界面显示,而在多线程中直接调用界面控件的方法是错误的做法,Invoke 和 BeginInvoke 就是为了解决这个问题而出现的,使你在多线程中安全的更新界面显示。

正确的做法是将工作线程中涉及更新界面的代码封装为一个方法,通过 Invoke 或者 BeginInvoke 去调用,两者的区别就是一个导致工作线程等待,而另外一个则不会。

而所谓的“一面响应操作,一面添加节点”永远只能是相对的,使 UI 线程的负担不至于太大而已,因为界面的正确更新始终要通过 UI 线程去做,我们要做的事情是在工作线程中包揽大部分的运算,而将对纯粹的界面更新放到 UI 线程中去做,这样也就达到了减轻 UI 线程负担的目的了。

举个简单例子说明下使用方法,比如你在启动一个线程,在线程的方法中想更新窗体中的一个TextBox..

using System.Threading;

//启动一个线程

Thread thread=new Thread(new ThreadStart(DoWork));

thread.Start();

//线程方法

private void DoWork()

{

this.TextBox1.Text="我是一个文本框";

}

如果你像上面操作,在VS2005或2008里是会有异常的...

正确的做法是用Invoke\BeginInvoke

using System.Threading;

namespace test

{

public partial class Form1 : Form

{

public delegate void MyInvoke(string str1,string str2);

public Form1()

{

InitializeComponent();

}

public void DoWork()

{

MyInvoke mi = new MyInvoke(UpdateForm);

this.BeginInvoke(mi, new Object[] {"我是文本框","haha"});

}

public void UpdateForm(string param1,string parm2)

{

this.textBox1.Text = param1+parm2;

}

private void button1_Click(object sender, EventArgs e)

{

Thread thread = new Thread(new ThreadStart(DoWork));

thread.Start();

}

}

}

注意代理的使用!

后面再次补充

在 WinForm开发过程中经常会用到线程,有时候还往往需要在线程中访问线程外的控件,比如:设置textbox的Text属性等等。如果直接设置程序必 定会报出:从不是创建控件的线程访问它,这个异常。通常我们可以采用两种方法来解决。一是通过设置control的属性。二是通过delegate,而通 过delegate也有两种方式,一种是常用的方式,另一种就是匿名方式。下面分别加以说明.

首先,通过设置control的一个属性值为false.我们可以在Form_Load方法中添加:Control.CheckForIllegalCrossThreadCalls=false;来解决。设置为false表示不对错误线程的调用进行捕获。这样在线程中对textbox的Text属性进行设置时就不会再报错了。

其次,通过delegate的方法来解决。

普通的委托方法例如:

代码语言:javascript
复制
delegate void SafeSetText(string strMsg);
private void SetText(string strMsg)
{
 if(textbox1.InvokeRequired)
 {
            SafeSetText objSet=new SafeSetText(SetText);
            textbox1.Invoke(objSet,new object[]{strMsg});

 }
 else
 {
   textbox1.Text=strMsg;
 }
}

在线程内需要设置textbox的值时调用SetText方法既可。我们还可以采用另一种委托的方式来实现,那就是匿名代理,例如:

代码语言:javascript
复制
delegate void SafeSetText(string strMsg);
private void SetText2(string strMsg)
{
  SafeSetText objSet = delegate(string str)
   {
       textBox1.Text = str;
   }
   textBox1.Invoke(objSet,new object[]{strMsg});
}

这样同样可以实现。

个人觉得还是采用代理好些。

在C# 3.0及以后的版本中有了Lamda表达式,像上面这种匿名委托有了更简洁的写法。.NET Framework 3.5及以后版本更能用Action封装方法。例如以下写法可以看上去非常简洁:

void ButtonOnClick(object sender,EventArgs e)

{

代码语言:txt
复制
this.Invoke(new Action(()=>
代码语言:txt
复制
{
代码语言:txt
复制
    button.Text="关闭";
代码语言:txt
复制
}));

}

最新:

Invoke(() =>

{

button.Text="关闭";

});

一、为什么Control类提供了Invoke和BeginInvoke机制?

关于这个问题的最主要的原因已经是dotnet程序员众所周知的,我在此费点笔墨再次记录到自己的日志,以便日后提醒一下自己。

1、windows程序消息机制

Windows GUI程序是基于消息机制的,有个主线程维护着一个消息泵。这个消息泵让windows程序生生不息。

代码语言:txt
复制
                                              Windows GUI程序的消息循环

Windows程序有个消息队列,窗体上的所有消息是这个队列里面消息的最主要来源。这里的while循环使用了GetMessage()这个方法,这是个阻塞方法,也就是队列为空时方法就会被阻塞,从而这个while循环停止运动,这避免了一个程序把cpu无缘无故地耗尽,让其它程序难以得到响应。当然在某些需要cpu最大限度运动的程序里面就可以使用另外的方法,例如某些3d游戏或者及时战略游戏中,一般会使用PeekMessage()这个方法,它不会被windows阻塞,从而保证整个游戏的流畅和比较高的帧速。

这个主线程维护着整个窗体以及上面的子控件。当它得到一个消息,就会调用DispatchMessage方法派遣消息,这会引起对窗体上的窗口过程的调用。窗口过程里面当然是程序员提供的窗体数据更新代码和其它代码。

2、dotnet里面的消息循环

public static void Main(string[] args)

{

Form f = new Form();

Application.Run(f);

}

Dotnet窗体程序封装了上述的while循环,这个循环就是通过Application.Run方法启动的。

3、线程外操作GUI控件的问题

如果从另外一个线程操作windows窗体上的控件,就会和主线程产生竞争,造成不可预料的结果,甚至死锁。因此windows GUI编程有一个规则,就是只能通过创建控件的线程来操作控件的数据,否则就可能产生不可预料的结果。

因此,dotnet里面,为了方便地解决这些问题,Control类实现了ISynchronizeInvoke接口,提供了Invoke和BeginInvoke方法来提供让其它线程更新GUI界面控件的机制。

public interface ISynchronizeInvoke

{

代码语言:txt
复制
    [HostProtection(SecurityAction.LinkDemand, Synchronization=true, ExternalThreading=true)]
代码语言:txt
复制
    IAsyncResult BeginInvoke(Delegate method, object[] args);
代码语言:txt
复制
    object EndInvoke(IAsyncResult result);
代码语言:txt
复制
    object Invoke(Delegate method, object[] args);
代码语言:txt
复制
    bool InvokeRequired { get; }

}

}

如果从线程外操作windows窗体控件,那么就需要使用Invoke或者BeginInvoke方法,通过一个委托把调用封送到控件所属的线程上执行。

二、消息机制---线程间和进程间通信机制

1、window消息发送

Windows消息机制是windows平台上的线程或者进程间通信机制之一。Windows消息值其实就是定义的一个数据结构,最重要的是消息的类型,它就是一个整数;然后就是消息的参数。消息的参数可以表示很多东西。

Windows提供了一些api用来向一个线程的消息队列发送消息。因此,一个线程可以向另一个线程的消息队列发送消息从而告诉对方做什么,这样就完成了线程间的通信。有些api发送消息需要一个窗口句柄,这种函数可以把消息发送到指定窗口的主线程消息队列;而有些则可以直接通过线程句柄,把消息发送到该线程消息队列中。

用消息机制通信

SendMessage是windows api,用来把一个消息发送到一个窗口的消息队列。这个方法是个阻塞方法,也就是操作系统会确保消息的确发送到目的消息队列,并且该消息被处理完毕以后,该函数才返回。返回之前,调用者将会被暂时阻塞。

PostMessage也是一个用来发送消息到窗口消息队列的api函数,但这个方法是非阻塞的。也就是它会马上返回,而不管消息是否真的发送到目的地,也就是调用者不会被阻塞。

2、Invoke and BeginInvoke

代码语言:txt
复制
                                                    Invoke or BeginInvoke

Invoke或者BeginInvoke方法都需要一个委托对象作为参数。委托类似于回调函数的地址,因此调用者通过这两个方法就可以把需要调用的函数地址封送给界面线程。这些方法里面如果包含了更改控件状态的代码,那么由于最终执行这个方法的是界面线程,从而避免了竞争条件,避免了不可预料的问题。如果其它线程直接操作界面线程所属的控件,那么将会产生竞争条件,造成不可预料的结果。

使用Invoke完成一个委托方法的封送,就类似于使用SendMessage方法来给界面线程发送消息,是一个同步方法。也就是说在Invoke封送的方法被执行完毕前,Invoke方法不会返回,从而调用者线程将被阻塞。

使用BeginInvoke方法封送一个委托方法,类似于使用PostMessage进行通信,这是一个异步方法。也就是该方法封送完毕后马上返回,不会等待委托方法的执行结束,调用者线程将不会被阻塞。但是调用者也可以使用EndInvoke方法或者其它类似WaitHandle机制等待异步操作的完成。

但是在内部实现上,Invoke和BeginInvoke都是用了PostMessage方法,从而避免了SendMessage带来的问题。而Invoke方法的同步阻塞是靠WaitHandle机制来完成的。

3、使用场合问题

如果你的后台线程在更新一个UI控件的状态后不需要等待,而是要继续往下处理,那么你就应该使用BeginInvoke来进行异步处理。

如果你的后台线程需要操作UI控件,并且需要等到该操作执行完毕才能继续执行,那么你就应该使用Invoke。否则,在后台线程和主截面线程共享某些状态数据的情况下,如果不同步调用,而是各自继续执行的话,可能会造成执行序列上的问题,虽然不发生死锁,但是会出现不可预料的显示结果或者数据处理错误。

可以看到ISynchronizeInvoke有一个属性,InvokeRequired。这个属性就是用来在编程的时候确定,一个对象访问UI控件的时候是否需要使用Invoke或者BeginInvoke来进行封送。如果不需要那么就可以直接更新。在调用者对象和UI对象同属一个线程的时候这个属性返回false。在后面的代码分析中我们可以看到,Control类对这一属性的实现就是在判断调用者和控件是否属于同一个线程的。

三、Delegate.BeginInvoke

通过一个委托来进行同步方法的异步调用,也是.net提供的异步调用机制之一。但是Delegate.BeginInvoke方法是从ThreadPool取出一个线程来执行这个方法,以获得异步执行效果的。也就是说,如果采用这种方式提交多个异步委托,那么这些调用的顺序无法得到保证。而且由于是使用线程池里面的线程来完成任务,使用频繁,会对系统的性能造成影响。

Delegate.BeginInvoke也是讲一个委托方法封送到其它线程,从而通过异步机制执行一个方法。调用者线程则可以在完成封送以后去继续它的工作。但是这个方法封送到的最终执行线程是运行库从ThreadPool里面选取的一个线程。

这里需要纠正一个误区,那就是Control类上的异步调用BeginInvoke并没有开辟新的线程完成委托任务,而是让界面控件的所属线程完成委托任务的。看来异步操作就是开辟新线程的说法不一定准确。

四、用Reflector察看一些相关代码

1、Control.BeginInvoke and Control.Invoke

代码语言:javascript
复制
public IAsyncResult BeginInvoke(Delegate method, params object[] args)
代码语言:javascript
复制
{
代码语言:javascript
复制
    using (new MultithreadSafeCallScope())
代码语言:javascript
复制
    {
代码语言:javascript
复制
        return (IAsyncResult) this.FindMarshalingControl().MarshaledInvoke(this, method, args, false);
代码语言:javascript
复制
    }
代码语言:javascript
复制
}
代码语言:javascript
复制
public object Invoke(Delegate method, params object[] args)
代码语言:javascript
复制
{
代码语言:javascript
复制
    using (new MultithreadSafeCallScope())
代码语言:javascript
复制
    {
代码语言:javascript
复制
        return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true);
代码语言:javascript
复制
    }
代码语言:javascript
复制
}

这里的FindMarshalingControl方法通过一个循环向上回溯,从当前控件开始回溯父控件,直到找到最顶级的父控件,用它作为封送对象。例如,我们调用窗体上一个进度条的Invoke方法封送委托,但是实际上会回溯到主窗体,通过这个控件对象来封送委托。因为主窗体是主线程消息队列相关的,发送给主窗体的消息才能发送到界面主线程消息队列。

我们可以看到Invoke和BeginInvoke方法使用了同样的实现,只是MarshaledInvoke方法的最后一个参数值不一样。

2、MarshaledInvoke

代码语言:javascript
复制
private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)
代码语言:javascript
复制
{
代码语言:javascript
复制
    int num;
代码语言:javascript
复制
    if (!this.IsHandleCreated)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        throw new InvalidOperationException(SR.GetString("ErrorNoMarshalingThread"));
代码语言:javascript
复制
    }
代码语言:javascript
复制
    if (((ActiveXImpl) this.Properties.GetObject(PropActiveXImpl)) != null)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        IntSecurity.UnmanagedCode.Demand();
代码语言:javascript
复制
    }
代码语言:javascript
复制
    bool flag = false;
代码语言:javascript
复制
    if ((SafeNativeMethods.GetWindowThreadProcessId(new HandleRef(this, this.Handle), out num) == SafeNativeMethods.GetCurrentThreadId()) && synchronous)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        flag = true;
代码语言:javascript
复制
    }
代码语言:javascript
复制
    ExecutionContext executionContext = null;
代码语言:javascript
复制
    if (!flag)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        executionContext = ExecutionContext.Capture();
代码语言:javascript
复制
    }
代码语言:javascript
复制
    ThreadMethodEntry entry = new ThreadMethodEntry(caller, method, args, synchronous, executionContext);
代码语言:javascript
复制
    lock (this)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        if (this.threadCallbackList == null)
代码语言:javascript
复制
        {
代码语言:javascript
复制
            this.threadCallbackList = new Queue();
代码语言:javascript
复制
        }
代码语言:javascript
复制
    }
代码语言:javascript
复制
    lock (this.threadCallbackList)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        if (threadCallbackMessage == 0)
代码语言:javascript
复制
        {
代码语言:javascript
复制
            threadCallbackMessage = SafeNativeMethods.RegisterWindowMessage(Application.WindowMessagesVersion + "_ThreadCallbackMessage");
代码语言:javascript
复制
        }
代码语言:javascript
复制
        this.threadCallbackList.Enqueue(entry);
代码语言:javascript
复制
    }
代码语言:javascript
复制
    if (flag)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        this.InvokeMarshaledCallbacks();
代码语言:javascript
复制
    }
代码语言:javascript
复制
    else
代码语言:javascript
复制
    {            //终于找到你了,PostMessage
代码语言:javascript
复制
        UnsafeNativeMethods.PostMessage(new HandleRef(this, this.Handle), threadCallbackMessage, IntPtr.Zero, IntPtr.Zero);
代码语言:javascript
复制
    }
代码语言:javascript
复制
    if (!synchronous) //如果是异步,那么马上返回吧
代码语言:javascript
复制
    {
代码语言:javascript
复制
        return entry;
代码语言:javascript
复制
    }
代码语言:javascript
复制
    if (!entry.IsCompleted) //同步调用没结束,阻塞起来等待吧
代码语言:javascript
复制
    {
代码语言:javascript
复制
        this.WaitForWaitHandle(entry.AsyncWaitHandle);
代码语言:javascript
复制
    }
代码语言:javascript
复制
    if (entry.exception != null)
代码语言:javascript
复制
    {
代码语言:javascript
复制
        throw entry.exception;
代码语言:javascript
复制
    }
代码语言:javascript
复制
    return entry.retVal;
代码语言:javascript
复制
}

怎么样,我们终于看到PostMessage了吧?通过windows消息机制实现了封送。而需要封送的委托方法作为消息的参数进行了传递。关于其它的代码这里不作进一步解释。

3、InvokeRequired

代码语言:javascript
复制
public bool InvokeRequired
代码语言:javascript
复制
{
代码语言:javascript
复制
    get
代码语言:javascript
复制
    {
代码语言:javascript
复制
        using (new MultithreadSafeCallScope())
代码语言:javascript
复制
        {
代码语言:javascript
复制
            HandleRef ref2;
代码语言:javascript
复制
            int num;
代码语言:javascript
复制
            if (this.IsHandleCreated)
代码语言:javascript
复制
            {
代码语言:javascript
复制
                ref2 = new HandleRef(this, this.Handle);
代码语言:javascript
复制
            }
代码语言:javascript
复制
            else
代码语言:javascript
复制
            {
代码语言:javascript
复制
                Control wrapper = this.FindMarshalingControl();
代码语言:javascript
复制
                if (!wrapper.IsHandleCreated)
代码语言:javascript
复制
                {
代码语言:javascript
复制
                    return false;
代码语言:javascript
复制
                }
代码语言:javascript
复制
                ref2 = new HandleRef(wrapper, wrapper.Handle);
代码语言:javascript
复制
            }
代码语言:javascript
复制
            int windowThreadProcessId = SafeNativeMethods.GetWindowThreadProcessId(ref2, out num);
代码语言:javascript
复制
            int currentThreadId = SafeNativeMethods.GetCurrentThreadId();
代码语言:javascript
复制
            return (windowThreadProcessId != currentThreadId);
代码语言:javascript
复制
        }
代码语言:javascript
复制
    }

}

终于看到了,这是在判断windows窗体线程和当前的调用者线程是否是同一个,如果是同一个就没有必要封送了,直接访问这个GUI控件吧。否则,就不要那么直接表白了,就需要Invoke或者BeginInvoke做媒了。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2020-12-09 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 本文转自:https://cloud.tencent.com/developer/article/1759131
  • invoke和begininvoke 区别
  • 解决从不是创建控件的线程访问它
  • 后面再次补充
  • 一、为什么Control类提供了Invoke和BeginInvoke机制?
    • 1、windows程序消息机制
      • 2、dotnet里面的消息循环
        • 3、线程外操作GUI控件的问题
          • 二、消息机制---线程间和进程间通信机制
            • 1、window消息发送
              • 2、Invoke and BeginInvoke
                • 3、使用场合问题
                  • 三、Delegate.BeginInvoke
                    • 四、用Reflector察看一些相关代码
                      • 1、Control.BeginInvoke and Control.Invoke
                        • 2、MarshaledInvoke
                          • 3、InvokeRequired
                          相关产品与服务
                          消息队列 CMQ 版
                          消息队列 CMQ 版(TDMQ for CMQ,简称 TDMQ CMQ 版)是一款分布式高可用的消息队列服务,它能够提供可靠的,基于消息的异步通信机制,能够将分布式部署的不同应用(或同一应用的不同组件)中的信息传递,存储在可靠有效的 CMQ 队列中,防止消息丢失。TDMQ CMQ 版支持多进程同时读写,收发互不干扰,无需各应用或组件始终处于运行状态。
                          领券
                          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档