次の方法で共有


Background Agents - Part 1 of 3

This post has (errr, "These posts have") been a long time coming. They are based off the Mix 11 and TechEd 2011 sessions I gave a loooooong time ago, but despite my best efforts I've not been able to post anything to my blog for some time. Partly it was due to a family vacation in Yellowstone National Park (go there! it's incredible!) and partly it's due to being rather busy getting the finishing touches on Mango and preparing for what's next. But mostly it's due to the fact that every time I sit down to blog about it, I add some new feature to the code or make it more extensible... this is almost (almost! ) a complete app at this stage, but it's not quite good enough to put in the marketplace.

This series of three posts will demonstrate the basics of background agents in Mango, and will throw in a couple of helper libraries as well (that is what parts 2 and 3 are about). The sample app, as seen in the aforementioned Mix / TechEd demos, is a simple Twitter viewer that uses a background agent to periodically show a toast and update the application's tile if there are new tweets since the last tame the app or the agent ran.

The foreground app

The foreground app for this particular demo isn't very interesting. It shows a list of recent tweets for a given search term (the hard-coded default is, of course, wp7dev) and if you tap on any of the items it shows the tweet in full, complete with the user's background image and the ability to follow any links in the tweet. It's not smart enough to link-ify #hashtags or @usernames, etc. so it doesn't suffice as a real twitter client; it's just enough for someone to casually keep up with a search term.

The app looks like this:

image   image

Nothing too special. The only interesting bit on the app is the "enable agent" check box at the bottom-left of the screen, which is where the magic happens, as they say.

When you click the "enable agent" button, it runs this code:

 /// <summary>
/// Starts the search agent and runs it for 1 day
/// </summary>
private void StartSearchAgent()
{
  PeriodicTask task = new PeriodicTask("twitter");
  task.Description = "Periodically checks for new tweets and updates the tile on the start screen." +
    " Will also show a toast during normal 'waking' hours (won't wake you up at night!)";
 
  task.ExpirationTime = DateTime.Now.AddDays(1);
  try
  {
    ScheduledActionService.Add(task);
  }
  catch (InvalidOperationException)
  {
    MessageBox.Show("Can't schedule agent; either there are too many other agents scheduled or you have disabled this agent in Settings.");
  }
}

The code is pretty straightforward:

  1. Create a PeriodicTask, which is the main class you use to describe the behaviour of the background agent. A periodic task runs every 30 minutes for about 25 seconds, give or take.
    • If you'd prefer to run for much longer - but only when the phone is plugged in and on WiFi - you can create a ResourceIntensiveTask instead
  2. Give the task a Description, which will show up in the system UI under settings -> applications -> background tasks. This text will explain to the user what your agent is doing and will help them determine whether or not to disable the agent at some future point in time.
  3. Set the ExpirationTime for 1 day from now. You can specify an expiration date up to 14 days in the future, but in order to be kind to the batter this app defaults to 1 day.
  4. Try to add the task to the ScheduledActionService. This might fail if the user has disabled the agent in settings or if there are already too many other agents running on the phone.
    • There's not really a good way to tell which it is, but the good new is that the remedy is always to tell the user to go to the settings page and enable your agent (and / or disable some other agents).

And that's all there is to it! Now the system will happily run your agent every ~30 minutes and let it do its thing. Hooray!

The background agent

The agent is also pretty simple. As you can read about in the docs, you basically add a background agent project to your solution and then implement the work you want to do in the OnInvoke method of your class. A few notes about agents up-front:

  • Your agent must derive from ScheduledTaskAgent and you must override OnInvoke
  • Your agent must be correctly registered in WMAppManifest.xml of the foreground project
    • Visual Studio does this for you automatically, but if you change the name of the class or of the project you will need to update the XML file
    • Cheat sheet: Specifier = ScheduledTaskAgent, Source = [assembly name] , Type = [fully qualified type name] , Name = [whatever you want]
  • Your agent project (and any projects it references) must be referenced by the foreground project, even if you never use it (this is so that it is correctly added to the XAP)
  • Your foreground app never explicitly calls into the agent (they are separate processes) but if you want you can instantiate an instance of the agent class inside your foreground app in order to share code (using a shared library might be a better approach, though)

Just in case you missed it (and for search-engine-friendliness):

  • If you change your agent's project name, namespace, or class name, you must update WMAppManifest.xml for the new metadata
  • If you reference another assembly from the agent, you must also reference it from the foreground app to ensure it makes it into the XAP

If you fail to do this, your agent will not load at all. If you run it under the debugger with the break on all exceptions turned on, you will get a FileNotFoundException stating that it can't find your assembly or one of the assemblies it relies on.

Anyway, back to our story. The agent's work looks like this (we'll get into the guts of it later):

 /// <summary>
/// Called by the system when there is work to be done
/// </summary>
/// <param name="Task">The task representing the work to be done</param>
protected override void OnInvoke(ScheduledTask Task)
{
  // Read the search term from IsoStore settings; in a more complex scenario you might
  // have multiple tiles and read each tile's term from a different setting
  IsolatedStorageSettings settings = IsolatedStorageSettings.ApplicationSettings;
  TweetSearchTerm = settings["searchTerm"].ToString();
 
  // Get the tweets from the library
  TwitterHelper.GetFirstTweet(TweetSearchTerm, tweet =>
    {
      // Don't do any work if this is invalid or the same as the most recently-seen tweet
      if (tweet.Id != Tweet.InvalidId && tweet.Id != settings["lastTweetId"].ToString())
      {
        settings["lastTweetId"] = tweet.Id;
        settings.Save();
 
        // Show the toast
        ToastHelper.ShowToast(tweet.AuthorAlias, tweet.TweetText, new Uri("/DetailsPage.xaml?id=" + tweet.Id, UriKind.Relative));
 
        // Update the tile
        TileHelper.UpdateTile(new ExtendedTileData
          {
            FrontText = "latest tweet",
            FrontTitle = TweetSearchTerm,
            BackText = tweet.TweetText,
            BackTitle = tweet.AuthorAlias,
            BackImage = tweet.AvatarUri,
          });
      }
 
      // Done!
      NotifyComplete();
    });
}

What's going on here?

  • First we get the search term from the ApplicationSettings (this is set in the foreground app)
    • As noted above, this is currently set to wp7dev
  • Then we call a handy-dandy method GetFirstTweet to retrieve the first tweet that matches our search term
    • This is an asynchronous call, of course, since it goes out to the web
  • When the call returns, we check if the tweet is valid and whether it is different from the last tweet we saw (which is also retrieved from ApplicationSettings)
    • If the tweet is invalid or not new, we skip to the end
  • The current tweet is saved as the last-seen tweet, so we don't show it again next time
  • We use a helper method to show a toast to the user with the tweet's author and text. We also use a deep link into the application so that tapping on the toast will launch directly into the details page for that toast
  • We use another helper method to update the primary tile with the tweet information, including the author's avatar (this will appear on the back of the tile)
  • Finally, we call the all-important NotifyCompletemethod to let the system know we completed successfully.
    • If we had hit an unrecoverable error, we could call Abort instead

The importance of calling NotifyComplete at the right time cannot be overstated!

    • Failure to call NotifyComplete at all will cause the system to think you timed-out, and then in the foreground if you query LastExitReason you will get a failure code ExecutionTimeExceeded
    • Calling NotifyComplete too early will immediately terminate your process, leaving any remaining work on background threads incomplete (although the system will happily report that you Completed successfully!)

Luckily there is a simple way to deal with this, as we shall see in Part 2 of the post.

A handy tip for agent debugging

One of the problems with debugging agents is that the very act of using the debugger changes the way your agent executes. In particular, when the debugger is attached both the runtime quota and the memory quota are ignored, leaving you with infinite time and memory to (ab)use. This is necessary for the debugger to work correctly (imagine trying to complete a debug session in only 25 seconds!) but introduces issues if the thing you're trying to debug is a memory and / or execution time issue.

Now back in the olden days - when we used to have to walk to school uphill in both directions - we didn't have fancy-schmancy graphical debuggers. We had printf! Now printf (or its modern debugging equivalent, Debug.WriteLine) doesn't really help with an agent if you can't have the debugger attached, so there's not much you can do. Obviously you can write out logs to a log file and then read them off the device with the Isolated Storage Explorer tool, or if you're brave enough you can enable console spew from the emulator, but if you just want to display a tiny bit of text - like, say, the amount of memory you're currently using or the amount of time you've been executing... why not use a toast?

The agent includes a method WriteDebugStats that is used to write some memory statistics to a toast (and to a tile for good measure!). Because toasts "stack up" in the shell and are displayed for several seconds before being replaced by the next toast, you can actually queue up several messages inside toasts that can be used to convey debug information while not under the debugger.

The method looks like this, using the same helper methods as before to show the toast and a tile update (the memory values are all based on calls to ApplicationPeakMemoryUsage API):

 /// <summary>
/// Writes out debug stats to a toast and a secondary tile (if it exists)
/// </summary>
void WriteDebugStats()
{
  const double ONE_MEGABYTE = 1024 * 1024;
  double initial = (double)initialMemory / ONE_MEGABYTE;
  double beforeTile = (double)beforeTileMemory / ONE_MEGABYTE;
  double final = (double)finalMemory / ONE_MEGABYTE;
  TimeSpan duration = DateTime.Now - startTime;
 
  // Show a toast
  ToastHelper.ShowToast("Mem / time", string.Format("{0:#.#}-{1:#.#}-{2:#.#}MB / {3:#.#}s", initial, beforeTile, final, duration.TotalSeconds), null);
 
  // Update the debug tile (if it is pinned)
  TileHelper.UpdateTile("DEBUG_TILE", "debug info", "debug info", string.Format(TweetSearchTerm + ": {0:#.#}MB, {1:#.#}s", final, duration.TotalSeconds));
}

If the app is running in debug mode, it will display a "debug" button on the main page that will pin a secondary tile to start that is used to display the debug info.

Another handy hint - use the new LaunchForTest API to launch your agent whenever you want - this replaces the old (Beta 1) behaviour of launching the agent whenever Add or Find was called and the debugger was attached (it was a rather annoying "feature"). You can even call LaunchForTest from the background agent itself, letting it run in perpetuity (but, of course, only on side-loaded dev projects; a shipping marketplace app can't call this method). If you run the project in "Debug" mode you will see button that lets you run the agent immediately (there are is a short delay giving you enough time to exit the app so that the toast will appear).

That's it for Part 1 - the project is zipped up below, and we'll discuss more of the project in parts 2 and 3.

BackgroundAgentDemo.zip

Comments

  • Anonymous
    August 09, 2011
    The comment has been removed

  • Anonymous
    August 09, 2011
    Thanks Iurii - yes the HintPath was relying on the tilt assembly being somewhere else. I updated the ZIP to include the DLL directly.

  • Anonymous
    August 14, 2011
    Hi peter, nice article as usual! I noticed that you update the IsolatedStorageSettings in your bgAgent code. I do something similar in my app but sometimes after the bgAgent completes and my app starts , IsolatedStorageSettings is empty. What can cause this? Maybe if the bgAgent is forced to stop while settings.save is in progress?

  • Anonymous
    August 16, 2011
    It might be due to that, yes. One option would be to use a different file (not AppSettings) and then do a copy / re-name operation to avoid partial updates.

  • Anonymous
    August 21, 2011
    Hi Peter, Great guide but i just have one question. I thought i heard somewhere that the longest an agent can run for is 2 weeks. I need to be able to run a task once every n days but that could be more than 14 days. It could also be yearly. For example an app that updates a tile once or twice a year to remind you of something. Is it possible to do this?

  • Anonymous
    August 22, 2011
    Hi nitro52, an agent can only run for 14 days at a time, but you can renew your 14 day subscription every time the foreground application is run. If you want to remind people of something, I suggest you look into the Alarms and Reminders feature, which I discuss here: blogs.msdn.com/.../alarms-and-notifications-in-mango.aspx