Avoid the trouble of Invoke / BeginInvoke in event handling in WinForm for cross-threads?

I am still encountering a background thread in the WinForm user interface. What for? Here are some of the problems:

  • Obviously the most important problem, I cannot change the control if I do not execute the same thread that created it.
  • As you know, Invoke, BeginInvoke, etc. unavailable until a control is created.
  • Even after RequiresInvoke returns true, BeginInvoke can still drop ObjectDisposed, and even if it does not throw, it can never execute code if the control is destroyed.
  • Even after RequiresInvoke returns true, Invoke can hang indefinitely pending execution by using a control that was removed at the same time as Invoke was called.

I am looking for an elegant solution to this problem, but before I find out what I am looking for, I thought that I would clarify the problem. This should take a general problem and set a more concrete example for it. In this example, let's say that we transfer large amounts of data over the Internet. The user interface should be able to display a progress dialog for an already started transfer. The progress dialog should be updated constantly and quickly (updated from 5 to 20 times per second). The user can reject the progress dialog at any time and call it again if necessary. And then, let's pretend to be the argument that if the dialogue is visible, it should handle every progress event. The user can click "Cancel" in the execution dialog box and by changing the event arguments, cancel the operation.

Now I need a solution that will correspond to the following restriction field:

  • Allow the workflow to call the method in the control / form and lock / wait for the execution to complete.
  • Allow the dialog itself to call the same method on initialization or the like (and therefore not use invoke).
  • Do not set the burden on the implementation of the processing method or the triggering event, the solution should only change the event subscription itself.
  • Accordingly, process blocking calls to a dialogue, which may be in the process of disposal. Unfortunately, this is not as simple as checking for IsDisposed.
  • It should be possible to use any type of event (suppose a delegate of type EventHandler)
  • Do not throw exceptions into a TargetInvocationException.
  • The solution should work with .Net 2.0 and higher

So, can this be solved taking into account the limitations above? I searched and dug countless blogs and discussions, and, alas, I'm still empty-handed.

Update: I understand that this question does not have a simple answer. I was on this site for only a couple of days, and I saw some people with great experience answering questions. I hope that one of these people has solved it enough enough so that I do not spend a week or so to make a reasonable decision.

Update No. 2: Well, I will try to describe the problem in more detail and see what (if anything) shakes out. The following properties, which allow us to determine his condition, have a couple of things that cause concern ...

  • Control.InvokeRequired = Documented to return false if executed in the current thread, or if IsHandleCreated returns false for all parents. I am confused by the implementation of InvokeRequired, which has the ability to either throw an ObjectDisposedException, or potentially even recreate an object handle. And since InvokeRequired can return true when we cannot call (Dispose in progress), and it can return false, even if we may need to use invoke (Create in progress), this simply cannot be trusted in all cases. The only time I can see where we can trust InvokeRequired return false is when IsHandleCreated returns true both before and after the call (BTW, MSDN documents for InvokeRequired indicate a check for IsHandleCreated).

  • Control.IsHandleCreated = Returns true if a handle has been assigned to the control; otherwise false. Although IsHandleCreated is a safe call, it can break if the control is in the process of recreating its handle. This potential problem, apparently, can be solved by performing blocking (control) when accessing IsHandleCreated and InvokeRequired.

  • Control.Disposing = Returns true if the control is in the process of disposal.

  • Control.IsDisposed = Returns true if the control is deleted. I am considering subscribing to the Disposed event and checking the IsDisposed property to determine if BeginInvoke will ever end. The big problem here is the lack of synchronization lock, which prevents the transfer of Disposing โ†’ Disposed. Perhaps if you are subscribed to the Disposed event and after that make sure that Disposing == false && IsDisposed == false you can still never see the fire of the Disposed event. This is because the implementation of Dispose sets Disposing = false, and then sets Disposed = true. This gives you the opportunity (albeit a small one) to read both Disposing and IsDisposed as false on the remote control.

... my head hurts :( I hope that the above information sheds a little more light on the problems for those who have these problems. I appreciate your spare thought cycles on this.

Closing the problem ... Below is half of the Control.DestroyHandle () method:

if (!this.RecreatingHandle && (this.threadCallbackList != null)) { lock (this.threadCallbackList) { Exception exception = new ObjectDisposedException(base.GetType().Name); while (this.threadCallbackList.Count > 0) { ThreadMethodEntry entry = (ThreadMethodEntry) this.threadCallbackList.Dequeue(); entry.exception = exception; entry.Complete(); } } } if ((0x40 & ((int) ((long) UnsafeNativeMethods.GetWindowLong(new HandleRef(this.window, this.InternalHandle), -20)))) != 0) { UnsafeNativeMethods.DefMDIChildProc(this.InternalHandle, 0x10, IntPtr.Zero, IntPtr.Zero); } else { this.window.DestroyHandle(); } 

You will notice that an ObjectDisposedException is thrown in all calls with cross-threads. Shortly after this, this.window.DestroyHandle () is called, which in turn destroys the window and sets its IntPtr.Zero reference handle, thereby preventing further calls in the BeginInvoke method (more precisely, MarshaledInvoke, which process both BeginInvoke and Invoke). The problem here is that after the lock is freed from threadCallbackList, a new record can be inserted before the control thread closes the window handle. It seems like I see, though infrequently, often enough to stop the release.

Update # 4:

Sorry to keep dragging it; however, I thought it was worth documenting here. I managed to solve most of the problems above, and I'm narrowing down the solution that works. I hit another issue that I was worried about, but still hadn't seen in-the-wild.

This problem is due to the genius who wrote the Control.Handle property:

  public IntPtr get_Handle() { if ((checkForIllegalCrossThreadCalls && !inCrossThreadSafeCall) && this.InvokeRequired) { throw new InvalidOperationException(SR.GetString("IllegalCrossThreadCall", new object[] { this.Name })); } if (!this.IsHandleCreated) { this.CreateHandle(); } return this.HandleInternal; } 

This in itself is not so bad (regardless of my opinion on getting {} modifications); however, in combination with the InvokeRequired property or the Invoke / BeginInvoke method, this is bad. Here is the main thread of Invoke:

 if( !this.IsHandleCreated ) throw; ... do more stuff PostMessage( this.Handle, ... ); 

The problem here is that from another thread I can successfully pass the first if statement, after which the handle is destroyed by the control thread, which leads to get from the Handle property re-creating the window handle in my thread. This may cause an exception in the original control flow. In fact, I'm really confused because there is no way to protect myself from this. If they used the InternalHandle property and tested for the IntPtr.Zero result, this was not a problem.

+46
multithreading c # events winforms
Sep 01 '09 at 19:08
source share
13 answers

Your script, as described, neatly fits BackgroundWorker - why not just use this? Your requirements for the solution are too universal and, rather, unfounded - I doubt that there is a solution that will satisfy them all.

+22
Sep 01 '09 at 19:11
source share

I ran into this problem a while ago and came up with a solution that included synchronization contexts. The solution is to add an extension method to the SynchronizationContext, which associates a particular delegate with the thread to which the SynchronizationContext is bound. It will generate a new delegate, which, when called, will march the call to the appropriate thread, and then call the original delegate. This makes it almost impossible for delegate consumers to call it in the wrong context.

Related Blog Post:

+8
Sep 09 '09 at 6:52
source share

Ok, a few days later I finished creating the solution. It solves all of the listed limitations and goals in the initial message. The use is simple and straightforward:

 myWorker.SomeEvent += new EventHandlerForControl<EventArgs>(this, myWorker_SomeEvent).EventHandler; 

When a worker thread raises this event, it processes the requested call to the control thread. It ensures that it will not hang indefinitely and will throw an ObjectDisposedException sequentially if it cannot execute in the control thread. I created other class definitions, one to ignore the error, and the other to directly invoke the delegate if the control is not available. It appears to work well and passes several tests that reproduce the above problems. There is only one problem with a solution that I cannot prevent without violating restriction number 3 above. This problem is the latest (Update No. 4) in the description of the problem, problems with threads in Get Handle. This can lead to unexpected behavior in the original control thread, and I regularly saw InvalidOperationException (), when I called Dispose (), because the handle is in the process of being created in my thread. To resolve this, I guarantee that access to functions that use the Control.Handle property will be blocked. This allows the form to overload the DestroyHandle method and lock until the underlying implementation is called. If this is done, this class should be completely thread safe (as far as I know).

 public class Form : System.Windows.Forms.Form { protected override void DestroyHandle() { lock (this) base.DestroyHandle(); } } 

You may notice that the main aspect of solving a dead lock has become a polling cycle. Initially, I successfully solved the test cases by processing a test event for Disposed and HandleDestroyed and using several wait commands. After a more thorough review, I found that subscribing / unsubscribing to these events are not thread safe. Thus, I decided to poll IsHandleCreated instead, so as not to create unnecessary contradictions in the flow events and thereby avoid the possibility of creating a deadlock state.

Anyway, here is the solution I came up with:

 /// <summary> /// Provies a wrapper type around event handlers for a control that are safe to be /// used from events on another thread. If the control is not valid at the time the /// delegate is called an exception of type ObjectDisposedExcpetion will be raised. /// </summary> [System.Diagnostics.DebuggerNonUserCode] public class EventHandlerForControl<TEventArgs> where TEventArgs : EventArgs { /// <summary> The control who thread we will use for the invoke </summary> protected readonly Control _control; /// <summary> The delegate to invoke on the control </summary> protected readonly EventHandler<TEventArgs> _delegate; /// <summary> /// Constructs an EventHandler for the specified method on the given control instance. /// </summary> public EventHandlerForControl(Control control, EventHandler<TEventArgs> handler) { if (control == null) throw new ArgumentNullException("control"); _control = control.TopLevelControl; if (handler == null) throw new ArgumentNullException("handler"); _delegate = handler; } /// <summary> /// Constructs an EventHandler for the specified delegate converting it to the expected /// EventHandler&lt;TEventArgs> delegate type. /// </summary> public EventHandlerForControl(Control control, Delegate handler) { if (control == null) throw new ArgumentNullException("control"); _control = control.TopLevelControl; if (handler == null) throw new ArgumentNullException("handler"); //_delegate = handler.Convert<EventHandler<TEventArgs>>(); _delegate = handler as EventHandler<TEventArgs>; if (_delegate == null) { foreach (Delegate d in handler.GetInvocationList()) { _delegate = (EventHandler<TEventArgs>) Delegate.Combine(_delegate, Delegate.CreateDelegate(typeof(EventHandler<TEventArgs>), d.Target, d.Method, true) ); } } if (_delegate == null) throw new ArgumentNullException("_delegate"); } /// <summary> /// Used to handle the condition that a control handle is not currently available. This /// can either be before construction or after being disposed. /// </summary> protected virtual void OnControlDisposed(object sender, TEventArgs args) { throw new ObjectDisposedException(_control.GetType().Name); } /// <summary> /// This object will allow an implicit cast to the EventHandler&lt;T> type for easier use. /// </summary> public static implicit operator EventHandler<TEventArgs>(EventHandlerForControl<TEventArgs> instance) { return instance.EventHandler; } /// <summary> /// Handles the 'magic' of safely invoking the delegate on the control without producing /// a dead-lock. /// </summary> public void EventHandler(object sender, TEventArgs args) { bool requiresInvoke = false, hasHandle = false; try { lock (_control) // locked to avoid conflicts with RecreateHandle and DestroyHandle { if (true == (hasHandle = _control.IsHandleCreated)) { requiresInvoke = _control.InvokeRequired; // must remain true for InvokeRequired to be dependable hasHandle &= _control.IsHandleCreated; } } } catch (ObjectDisposedException) { requiresInvoke = hasHandle = false; } if (!requiresInvoke && hasHandle) // control is from the current thread { _delegate(sender, args); return; } else if (hasHandle) // control invoke *might* work { MethodInvokerImpl invocation = new MethodInvokerImpl(_delegate, sender, args); IAsyncResult result = null; try { lock (_control)// locked to avoid conflicts with RecreateHandle and DestroyHandle result = _control.BeginInvoke(invocation.Invoker); } catch (InvalidOperationException) { } try { if (result != null) { WaitHandle handle = result.AsyncWaitHandle; TimeSpan interval = TimeSpan.FromSeconds(1); bool complete = false; while (!complete && (invocation.MethodRunning || _control.IsHandleCreated)) { if (invocation.MethodRunning) complete = handle.WaitOne();//no need to continue polling once running else complete = handle.WaitOne(interval); } if (complete) { _control.EndInvoke(result); return; } } } catch (ObjectDisposedException ode) { if (ode.ObjectName != _control.GetType().Name) throw;// *likely* from some other source... } } OnControlDisposed(sender, args); } /// <summary> /// The class is used to take advantage of a special-case in the Control.InvokeMarshaledCallbackDo() /// implementation that allows us to preserve the exception types that are thrown rather than doing /// a delegate.DynamicInvoke(); /// </summary> [System.Diagnostics.DebuggerNonUserCode] private class MethodInvokerImpl { readonly EventHandler<TEventArgs> _handler; readonly object _sender; readonly TEventArgs _args; private bool _received; public MethodInvokerImpl(EventHandler<TEventArgs> handler, object sender, TEventArgs args) { _received = false; _handler = handler; _sender = sender; _args = args; } public MethodInvoker Invoker { get { return this.Invoke; } } private void Invoke() { _received = true; _handler(_sender, _args); } public bool MethodRunning { get { return _received; } } } } 

If you see something wrong, please let me know.

+6
03 Sep '09 at 22:34
source share

I am not going to write an exhaustive solution for you that meets all your requirements, but I offer a perspective. In general, however, I think you are shooting at the moon with these requirements.

The Invoke / BeginInvoke simply executes the delegate provided in the control UI thread, sending it a Windows message, and the delegate runs the message loop itself. The specific actions of this do not matter, but the fact is that there is no particular reason why you need to use this architecture to synchronize threads with a user interface thread. All you need is some other loop, like in Forms.Timer or something like that that controls the execution of Queue for delegates and does it. It would be quite simple to implement your own, although I donโ€™t know what exactly you can get that Invoke and BeginInvoke do not provide.

+2
Sep 01 '09 at 19:15
source share

This is not an answer to the second part of the question, but I will include it for reference only:

 private delegate object SafeInvokeCallback(Control control, Delegate method, params object[] parameters); public static object SafeInvoke(this Control control, Delegate method, params object[] parameters) { if (control == null) throw new ArgumentNullException("control"); if (control.InvokeRequired) { IAsyncResult result = null; try { result = control.BeginInvoke(new SafeInvokeCallback(SafeInvoke), control, method, parameters); } catch (InvalidOperationException) { /* This control has not been created or was already (more likely) closed. */ } if (result != null) return control.EndInvoke(result); } else { if (!control.IsDisposed) return method.DynamicInvoke(parameters); } return null; } 

This code should avoid the most common errors with Invoke / BeginInvoke and is easy to use. Just turn

 if (control.InvokeRequired) control.Invoke(...) else ... 

at

 control.SafeInvoke(...) 

A similar design is possible for BeginInvoke.

+1
01 Sep '09 at 21:18
source share

Wow, a long question. I will try to organize my answer so that you can correct me if I understand something is wrong, okay?

1) If you have no reason to directly call user interface methods from different threads, do not do this. You can always select a producer / consumer model using event handlers:

 protected override void OnLoad() { //... component.Event += new EventHandler(myHandler); } protected override void OnClosing() { //... component.Event -= new EventHandler(myHandler); } 

myHandler will be launched every time a component in another thread needs to do something in the user interface, for example. In addition, setting up an event handler in OnLoad and unsubscribing in OnClosing ensures that events will only be received / processed by the user interface while its handle is created and ready to process events. You will not even be able to fire events in this dialog box if it is in the process of disposal, because you will no longer subscribe to this event. If another event is fired while it is still being processed, it will be queued.

You can pass all the necessary information in the arguments of the event: do you update the progress, close the window, etc.

2) You do not need InvokeRequired if you use the model proposed above. In this example, you know that the only thing that runs myHandler will be your component, which, for example, lives in a different thread.

 private void myHandler(object sender, EventArgs args) { BeginInvoke(Action(myMethod)); } 

That way, you can always use invoke to make sure you are in the correct stream.

3) Beware of synchronous calls. If you want, you can replace using Invoke instead of BeginInvoke. This will block your component until the event is processed. However, if in the user interface you need to communicate with something that is exclusive to the stream in which your component lives, you may have problems with blocking. (I do not know if I am clear, please let me know). I had problems with exceptions when using reflection (TargetInvocationException) and BeginInvoke (since they start another thread, you lose part of the stack trace), but I donโ€™t remember that there were a lot of problems with Invoke calls, so you should be safe when it comes to to the exceptions.

Hey long answer. If I accidentally missed any of your requirements or misunderstood what you said (English is not my native language, so we are never sure), please let me know.

+1
Sep 04 '09 at 18:55
source share

I try to organize all such calls in the GUI as fire and forget (handling an exception that the GUI may throw due to race conditions when disposing of the form).

Thus, if it is never executed, no harm is done.

If the GUI should respond to a workflow, it has a way to effectively cancel the notification. For simple tasks, BackgroundWorker already does this.

+1
Sep 08 '09 at 23:36
source share

This is a pretty tricky question. As I mention in the commentary, I donโ€™t think it resolved taking into account the documented limitations. You can hack it based on the specific implementation of the .net infrastructure: knowing the implementation of the various member functions can help you fool it by capturing locks here and there, and knowing that "in fact, OKAY, call other member functions in another thread."

So my main answer at the moment is "No." I hate to say that this is impossible because I really believe in the .Net infrastructure. In addition, I am relatively new to not learning the framework at all or CS, but the Internet is open (even to ignorant people like me)!

In another topic, the argument can be posed and well supported: "You will never need Invoke , use only BeginInvoke , shoot and forget." I will not try to support him or even say that this is a correct statement, but I will say that the general implementation is incorrect and represents a working (I hope) one.

Here's a general implementation (taken from another answer here):

 protected override void OnLoad() { //... component.Event += new EventHandler(myHandler); } protected override void OnClosing() { //... component.Event -= new EventHandler(myHandler); } 

It is not thread safe. The component could easily start calling the call list immediately before canceling the subscription, and only after we finish the deletion, the handler will be called. The real point is that it did not document how each component should use the event mechanism in .Net, and, frankly, it should not unsubscribe you at all: as soon as you issued your phone number, no one should have erased it!

Better:

 protected override void OnLoad(System.EventArgs e) { component.Event += new System.EventHandler(myHandler); } protected override void OnFormClosing(FormClosedEventArgs e) { component.Event -= new System.EventHandler(myHandler); lock (lockobj) { closing = true; } } private void Handler(object a, System.EventArgs e) { lock (lockobj) { if (closing) return; this.BeginInvoke(new System.Action(HandlerImpl)); } } /*Must be called only on GUI thread*/ private void HandlerImpl() { this.Hide(); } private readonly object lockobj = new object(); private volatile bool closing = false; 

Please let me know if I missed something.

+1
Nov 22 '11 at 18:05
source share

If you don't like BackgroundWoker (as described in @Pavel), you can check out this library http://www.wintellect.com/PowerThreading.aspx .

0
01 Sep '09 at 19:14
source share

If I understand this, why do you ever need to delete the progress dialog while the application is running? Why not just show and hide it at the user's request? It seems like this will make your problem even a little easier.

0
Sep 01 '09 at 19:18
source share

Why not just hide the dialog when the user fires it? This should work fine if you don't show this dialog modally. (use show instead of showdialog). I believe that you can save your progress dialog on top of your own window (if you need to) by passing the host to the dialog when calling show.

0
Sep 04 '09 at 18:13
source share

Using System.ComponentModel.ISynchronizeInvoke nice when creating System.ComponentModel.Component , like BackgroundWorker . The next piece of code is how FileSystemWater handles events.

  ''' <summary> ''' Gets or sets the object used to marshal the event handler calls issued as a result of finding a file in a search. ''' </summary> <IODescription(SR.FSS_SynchronizingObject), DefaultValue(CType(Nothing, String))> _ Public Property SynchronizingObject() As System.ComponentModel.ISynchronizeInvoke Get If (_synchronizingObject Is Nothing) AndAlso (MyBase.DesignMode) Then Dim oHost As IDesignerHost = DirectCast(MyBase.GetService(GetType(IDesignerHost)), IDesignerHost) If (Not (oHost Is Nothing)) Then Dim oRootComponent As Object = oHost.RootComponent If (Not (oRootComponent Is Nothing)) AndAlso (TypeOf oRootComponent Is ISynchronizeInvoke) Then _synchronizingObject = DirectCast(oRootComponent, ISynchronizeInvoke) End If End If End If Return _synchronizingObject End Get Set(ByVal Value As System.ComponentModel.ISynchronizeInvoke) _synchronizingObject = Value End Set End Property Private _onStartupHandler As EventHandler Protected Sub OnStartup(ByVal e As EventArgs) If ((Not Me.SynchronizingObject Is Nothing) AndAlso Me.SynchronizingObject.InvokeRequired) Then Me.SynchronizingObject.BeginInvoke(_onStartupHandler, New Object() {Me, e}) Else _onStartupHandler.Invoke(Me, e) End If End Sub 
0
Sep 10 '09 at 14:27
source share

Here is what I am using now. It is based on the use of the SynchronizationContext and was inspired by the JaredPar blog article - see His answer above. This may not be ideal, but it avoids some of the OP issues that I also experienced.

  // Homemade Action-style delegates to provide .Net 2.0 compatibility, since .Net 2.0 does not // include a non-generic Action delegate nor Action delegates with more than one generic type // parameter. (The DMethodWithOneParameter<T> definition is not needed, could be Action<T> // instead, but is defined for consistency.) Some interesting observations can be found here: // http://geekswithblogs.net/BlackRabbitCoder/archive/2011/11/03/c.net-little-wonders-the-generic-action-delegates.aspx public delegate void DMethodWithNoParameters(); public delegate void DMethodWithOneParameter<T>(T parameter1); public delegate void DMethodWithTwoParameters<T1, T2>(T1 parameter1, T2 parameter2); public delegate void DMethodWithThreeParameters<T1, T2, T3>(T1 parameter1, T2 parameter2, T3 parameter3); /// <summary> /// Class containing support code to use the SynchronizationContext mechanism to dispatch the /// execution of a method to the WinForms UI thread, from another thread. This can be used as an /// alternative to the Control.BeginInvoke() mechanism which can be problematic under certain /// conditions. See for example the discussion here: /// http://stackoverflow.com/questions/1364116/avoiding-the-woes-of-invoke-begininvoke-in-cross-thread-winform-event-handling /// /// As currently coded this works with methods that take zero, one, two or three arguments, but /// it is a trivial job to extend the code for methods taking more arguments. /// </summary> public class WinFormsHelper { // An arbitrary WinForms control associated with thread 1, used to check that thread-switching // with the SynchronizationContext mechanism should be OK private readonly Control _thread1Control = null; // SynchronizationContext for the WinForms environment UI thread private readonly WindowsFormsSynchronizationContext _synchronizationContext; /// <summary> /// Constructor. This must be called on the WinForms UI thread, typically thread 1. (Unless /// running under the Visual Studio debugger, then the thread number is arbitrary.) /// /// The provided "thread 1 control" must be some WinForms control that will remain in /// existence for as long as this object is going to be used, for example the main Form /// control for the application. /// </summary> /// <param name="thread1Control">see above</param> public WinFormsHelper(Control thread1Control) { _thread1Control = thread1Control; if (thread1Control.InvokeRequired) throw new Exception("Not called on thread associated with WinForms controls."); _synchronizationContext = SynchronizationContext.Current as WindowsFormsSynchronizationContext; if (_synchronizationContext == null) // Should not be possible? throw new Exception("SynchronizationContext.Current = null or wrong type."); } // The following BeginInvoke() methods follow a boilerplate pattern for how these methods // should be implemented - they differ only in the number of arguments that the caller wants // to provide. public void BeginInvoke(DMethodWithNoParameters methodWithNoParameters) { _synchronizationContext.Post((object stateNotUsed) => { if (!_thread1Control.IsDisposed) methodWithNoParameters(); }, null); } public void BeginInvoke<T>(DMethodWithOneParameter<T> methodWithOneParameter, T parameter1) { _synchronizationContext.Post((object stateNotUsed) => { if (!_thread1Control.IsDisposed) methodWithOneParameter(parameter1); }, null); } public void BeginInvoke<T1, T2>(DMethodWithTwoParameters<T1, T2> methodWithTwoParameters, T1 parameter1, T2 parameter2) { _synchronizationContext.Post((object stateNotUsed) => { if (!_thread1Control.IsDisposed) methodWithTwoParameters(parameter1, parameter2); }, null); } public void BeginInvoke<T1, T2, T3>(DMethodWithThreeParameters<T1, T2, T3> methodWithThreeParameters, T1 parameter1, T2 parameter2, T3 parameter3) { _synchronizationContext.Post((object stateNotUsed) => { if (!_thread1Control.IsDisposed) methodWithThreeParameters(parameter1, parameter2, parameter3); }, null); } } 
0
04 . '17 11:09
source share



All Articles