2018 年 2 月

第 33 卷,第 2 期

C# - 使用可自定义脚本语言编写本机移动应用

作者 Vassili Kaplan

我在 MSDN 杂志 2016 年 2 月刊 中发表过一篇文章 (msdn.com/magazine/mt632273),介绍了如何创建基于“拆分与合并”算法的自定义脚本语言,用于在 C# 中分析数学表达式。我将自己的语言称为“C# 中的可定义脚本 (CSCS)”。最近,我出版了一本电子书 (bit.ly/2yijCod),更详细地介绍了如何创建自定义语言。尽管有一些有意思的应用(例如,游戏作弊),但创建自己的脚本语言最初似乎可能并不是特别有用。我还发现了在 Unity 编程中的一些应用。

接着我便发现了可自定义脚本语言的一项更有意思应用,即编写适用于移动设备的跨平台应用。事实证明,使用 CSCS 编写 Android 和 iOS 应用是可行的(也能轻松添加 Windows Phone)。而且,相同的代码可用于所有平台。我在 CODE 杂志 2017 年 11-12 月刊中发表了一篇文章 (codemag.com/article/1711081),介绍了如何这样做。

在本文中,我将深入探讨并介绍如何使用 CSCS 执行移动设备应用编程。而且,我还将更正 CODE 杂志中那篇文章存在的几处不准确的地方。大家将会发现,可以在本地平台上完成的任何操作,也都能在 CSCS 中完成。此外,我还将介绍如何向 CSCS 快速添加缺少的功能。

必须在 Windows 或 macOS 上安装带有 Xamarin 的 Visual Studio 2017,才能运行本文中的代码。我个人是在 MacBook 上使用了 Visual Studio Community Edition 2017。请注意,必须使用 Mac,才能将 iOS 应用部署到 Apple App Store。

用于移动应用的“Hello, World!”代码

请查看图 1,其中展示了一些基本 CSCS 代码,用于实现文本到语音转换和语音识别。接下来,将逐行探索此代码。

图 1:CSCS 中用于移动应用的“Hello, World!”代码

AutoScale();
voice = "en-US";
locButtonTalk = GetLocation("ROOT", "CENTER", "ROOT", "BOTTOM", 0, 0);
AddButton(locButtonTalk, "buttonTalk", "Click me", 200, 80);
AddAction(buttonTalk,  "talk_click");
function talk_click(sender, arg) {
  ShowToast("Please say your name...");
  VoiceRecognition("voice_recog", voice);
}
function voice_recog(errorStatus, recognized) {
  if (errorStatus != "") {
    AlertDialog("CSCS", "Error: " + errorStatus);
  } else {
    ShowToast("Word recognized: " + recognized);
    Speak("Hello, " + recognized, voice);
  }
}

使用 AutoScale 函数,可以根据实际设备屏幕尺寸,自动调整小组件尺寸。例如,借助 AutoScale,从 640 像素宽设备移到 1280 像素宽设备的小组件的尺寸差为 2 倍。AutoScale 函数的实际签名是:

AutoScale(scale = 1.0);

如果不使用默认参数 scale = 1.0,计算尺寸差时将依据指定的 scale 参数。例如,若要将小组件从 640 像素宽设备移到 1280 像素宽设备,且 scale = 0.5,那么小组件的尺寸差是 1.5 倍(而不是 2 倍),因为新尺寸的计算公式如下:

newSize = size + size * scale * (1280 / 640 - 1) = size * (1 + scale) = size * 1.5.

不过,如果 scale = 2,那么根据计算公式计算出的小组件尺寸差为 3 倍。scale = 0 这一特例也适用本文中的公式,即不会执行任何缩放调整。也就是说,无论设备尺寸如何,小组件尺寸始终完全不变。此缩放参数还可以小组件为单位进行应用,即能够在 GetLocation 函数中将它指定为可选参数。我很快将会介绍如何这样做。

接下来,我将定义语音变量。请注意,由于 CSCS 是一种类似于 Python 的脚本语言(即变量类型可以通过上下文推断出来),因此语音变量在后台表示为 C# 字符串。

然后,我将定义按钮。若要在 CSCS 中定义小组件,始终都需要使用以下两个语句:第一个语句用于指定小组件的位置,第二个语句用于指定小组件的实际定义。在后台,UIButton 小组件用于 iOS,Button 小组件用于 Android。

在屏幕上创建位置的语法通常如下:

GetLocation(ReferenceX, PlacementX, ReferenceY, PlacementY,
            AdjustmentX = 0, AdjustmentY = 0,
            ScaleOption = false, Scale = 0.0, Parent = null);

各参数的含义如下:

  • ReferenceX:水平方向上另一个小组件的名称。值可以是字符串“ROOT”,表示父级小组件或主屏幕。
  • PlacementX:相对于 ReferenceX 指示的小组件的水平点。可取值列于这些参数的末尾。
  • ReferenceY:垂直方向上另一个小组件的名称。值可以是字符串“ROOT”,表示父级小组件或主屏幕。
  • PlacementY:相对于 ReferenceY 指示的小组件的垂直点。可取值列于这些参数的末尾。
  • AdjustmenX:小组件的额外水平位移(以像素为单位)。值也可以是负数;正向是从左到右。
  • AdjustmenY:小组件的额外垂直位移(以像素为单位)。值也可以是负数;正向是从上到下。
  • ScaleOption:指示是否向小组件应用特定的缩放选项。如果此选项为 false 或未指定,将完成 AutoScale 函数中指定的调整。如果此选项已指定,将会根据 Scale 参数修改小组件的调整参数和尺寸。
  • 缩放:用于调整小组件尺寸的度量值。此功能与 AutoScale 函数相同。其实执行的代码也是相同的。
  • Parent:小组件的父级。如果未指定,小组件将会被添加到 Android 上的主布局,或 iOS 上的根视图控制器(特别是 Window.RootViewController.View)。

放置参数的可取值与 Android RelativeLayout.LayoutParams 类非常相似。可取值为:“CENTER”、“LEFT”、“RIGHT”、“TOP”、“BOTTOM”、“ALIGN_LEFT”、“ALIGN_RIGHT”、“ALIGN_TOP”、“ALIGN_BOTTOM”、“ALIGN_PARENT_TOP”、“ALIGN_PARENT_BOTTOM”。

这些参数用于在 iOS 和 Android 上水平和垂直放置小组件。无需掌握任何 XML 或 XAML 知识。也没有 iOS Storyboard 需要处理。

创建位置后,便可以将小组件放置其中。这样做的语法通常如下:

AddWidget(location, widgetName, initParameter, width, height);

AddButton 是此类函数的特例,其中初始化参数就是按钮上显示的文本。小组件函数的其他示例包括 AddLabel、AddView、AddCombobox,大家将会了解更多示例。

AddAction 函数在用户点击按钮时向按钮分配操作。语法通常如下:

AddAction(widgetName, callbackFunction);

CSCS 中的回调函数始终需要使用以下两个参数,发送程序参数和上下文参数(这借用了 C# 中的概念)。

在 talk_click 函数内部,我将先调用 ShowToast 函数,以便在 Android 上调用本机 Toast 实现,并在 iOS 上调用类似于 Toast 的自定义实现。iOS 实现只是构造了包含消息的小框架,并在超时后销毁它。

最后,我将调用语音识别函数:

VoiceRecognition("voice_recog", voice = "en-US");

第一个参数是在语音识别完成时要调用的回调函数的名称。第二个参数是 voice。此为可选参数,默认值为英语(美国)。指定 voice 参数时,使用 ISO 639-1 代码表示语言名称,使用 ISO 3166-1 alpha-2 表示国家/地区代码。例如,“en-US”表示英语(美国),“es-MX”表示西班牙语(墨西哥),“pt-BR”表示“葡萄牙语(巴西)等等。

语音识别回调函数的签名如下:

function voice_recog(errorStatus, recognized)

如果成功,errorStatus 参数为空字符串;如果失败,errorStatus 参数为错误说明。如果函数成功,识别的字词作为第二个参数传递。否则,用户会看到警报对话框(实现为 iOS 上的 UIAlertController,以及 Android 上的 AlertDialog.Builder)。如果语音识别成功,将会调用文本到语音转换函数 Speak。它的签名如下:

Speak(wordToPronounce, voice = "en-US");

图 2 展示了图 1 中脚本的运行结果。左图为用户读出字词时的 iPhone 成功语音识别屏幕截图。右图为语音识别失败的 Android 屏幕截图,原因是没有在系统中安装麦克风(使用模拟器时的常见情况)。

示例 - 在 iPhone(左)和 Android(右)上运行“Hello, World!” 脚本
图 2:示例 - 在 iPhone(左)和 Android(右)上运行“Hello, World!” 脚本

项目的常规结构

CSCS 代码在工作流中的哪个阶段执行?此问题的答案因 iOS 和 Android 项目而异。下面对此进行了介绍,但全部详情收录在本文随附下载的源代码 (github.com/vassilych/mobile) 中。

这两个平台使用的通用代码位于共享项目部分 scripting.Shared 中,其中包含分析 CSCS 代码所需的全部 C# 文件。每个平台的专用代码位于 scripting.iOS 和 scripting.Droid 项目中。请参阅图 3 中的示例项目结构。

使用 CSCS 脚本的 Xamarin 项目的常规结构
图 3:使用 CSCS 脚本的 Xamarin 项目的常规结构

实际 CSCS 脚本位于 scripting.Shared 项目中“资源”文件夹下的 msdnScript.cscs 文件内。请注意,可以添加其他 CSCS 文件,具体方法是调用以下 CSCS 函数:

ImportFile("anotherScriptFile.cscs");

对于 Android 项目,我是从 scripting.Droid 的“资产”文件夹设置指向 msdnScript.cscs 文件的链接;对于 iOS 项目,我是从 scripting.iOS 的“资源”文件夹设置此类链接。也可以通过其他许多方式引用脚本,如在各个平台上保留不同版本的脚本。

CommonFunctions.cs 文件包含 iOS 和 Android 的通用功能。特别是,它保留 msdnScripting.cscs 脚本的执行方法,如图 4 所示。请注意,为了区分 iOS 和 Android 专用代码,我使用的是预处理程序指令 __IOS__ 和 __ANDROID__。平台专用代码大多位于相应项目(scripting.iOS 或 scripting.Droid)中。

图 4:执行 CSCS 脚本

public static void RunScript()
{
  RegisterFunctions();
  string fileName = "msdnScript.cscs";
  string script = "";
#if __ANDROID__
  Android.Content.Res.AssetManager assets = MainActivity.TheView.Assets;
  using (StreamReader sr = new StreamReader(assets.Open(fileName))) {
    script = sr.ReadToEnd();
  }
#endif
#if __IOS__
  string[] lines = System.IO.File.ReadAllLines(fileName);
  script = string.Join("\n", lines);
#endif
  Variable result = null;
  try {
    result = Interpreter.Instance.Process(script);
  } catch (Exception exc) {
    Console.WriteLine("Exception: " + exc.Message);
    Console.WriteLine(exc.StackTrace);
    ParserFunction.InvalidateStacksAfterLevel(0);
    throw;
  }
}

从哪里调用 RunScript 函数呢?只有在全局布局初始化后,才能调用此函数,以便于向它添加小组件。

事实证明,此操作在 Android 上比在 iOS 上更棘手:直接在 MainActivity.OnCreate 函数结束时调用 RunScript 函数会失败,因为一些变量尚未初始化。因此,必须在主要活动实际开始运行前直接调用 RunScript。Android 活动生命周期文档 (goo.gl/yF8dTZ) 有所提示:必须在 MainActivity.OnResume 方法完成后紧接着调用 RunScript。即使在 OnResume 方法结束时,有些全局变量(例如,屏幕尺寸、屏幕方向等)也尚未初始化。所以,诀窍就是在 OnResume 方法结束时,注册在全局布局构造完成就触发的全局布局观察程序:

protected override void OnResume()
{
  base.OnResume();
  if (!m_scriptRun) {
    ViewTreeObserver vto = TheLayout.ViewTreeObserver;
    vto.AddOnGlobalLayoutListener(new LayoutListener());
    m_scriptRun = true;
  }
}

请注意,为了确保脚本只运行一次,我使用的是特殊布尔变量 m_scriptRun。然后,布局侦听器中的 OnGlobalLayout 方法执行脚本:

public class LayoutListener : 
  Java.Lang.Object, ViewTreeObserver.IOnGlobalLayoutListener
{
  public void OnGlobalLayout()
  {
    var vto = MainActivity.TheLayout.ViewTreeObserver;
    vto.RemoveOnGlobalLayoutListener(this);
    CommonFunctions.RunScript();
  }
}

对于 iOS,情况要简单一点,可以直接在 AppDelegate.FinishedLaunching 方法结束时运行脚本。

文本到语音转换

接下来,将以文本到语音转换为例,介绍如何向 CSCS 添加某功能。

首先,我需要创建派生自 ParserFunction 类的类,并重写它的受保护虚拟 Evaluate 方法,如图 5**** 所示。

图 5:Speak 函数实现

public class SpeakFunction : ParserFunction
{
  protected override Variable Evaluate(ParsingScript script)
  {
    bool isList = false;
    List<Variable> args = Utils.GetArgs(script,
         Constants.START_ARG, Constants.END_ARG, out isList);
    Utils.CheckArgs(args.Count, 1, m_name);
    TTS.Init();
    string phrase = args[0].AsString();
    TTS.Voice     = Utils.GetSafeString(args, 1, TTS.Voice);
    TTS.Speak(phrase);
    return Variable.EmptyInstance;
  }
}

此类只是包装了实际的文本到语音转换实现。对于 iOS,文本到语音转换实现如图 6 所示。虽然 Android 实现与此类似,但需要的编码会多一点。可以在随附下载的源代码中进行查看。

图 6:iOS 文本到语音转换实现(片段)

using AVFoundation;
namespace scripting.iOS
{
  public class TTS
  {
    static AVSpeechSynthesizer g_synthesizer = new AVSpeechSynthesizer();
    static public float  SpeechRate { set; get; }      = 0.5f;
    static public float  Volume { set; get; }          = 0.7f;
    static public float  PitchMultiplier { set; get; } = 1.0f;
    static public string Voice { set; get; }           = "en-US";
    static bool m_initDone;
    public static void Init()
    {
      if (m_initDone) {
        return;
      }
      m_initDone = true;
      // Set the audio session category, then it will speak
      // even if the mute switch is on.
      AVAudioSession.SharedInstance().Init();
      AVAudioSession.SharedInstance().SetCategory(AVAudioSessionCategory.Playback,
         AVAudioSessionCategoryOptions.DefaultToSpeaker);
    }
    public static void Speak(string text)
    {
      if (g_synthesizer.Speaking) {
        g_synthesizer.StopSpeaking(AVSpeechBoundary.Immediate);
      }
      var speechUtterance = new AVSpeechUtterance(text) {
        Rate = SpeechRate * AVSpeechUtterance.MaximumSpeechRate,
        Voice = AVSpeechSynthesisVoice.FromLanguage(Voice),
        Volume = Volume,
        PitchMultiplier = PitchMultiplier
      };
      g_synthesizer.SpeakUtterance(speechUtterance);
    }
  }
}

有了实现后,便需要将它插入分析程序。这是在共享项目中的 CommonFunctions.RegisterFunctions 静态方法内完成(亦如图 3**** 所示):

ParserFunction.RegisterFunction("Speak", new SpeakFunction());

语音识别

对于语音识别,我需要使用回调函数,以告知用户实际识别的字词(或报告错误,如图 2 所示)。

我要实现两个语音识别函数:一个用于启动语音识别,另一个用于取消语音识别。这两个函数是向分析程序进行注册,就像我在上一部分中注册文本到语音转换一样:

ParserFunction.RegisterFunction("VoiceRecognition", new VoiceFunction());
ParserFunction.RegisterFunction("StopVoiceRecognition", new StopVoiceFunction());

图 7**** 展示了这两个 iOS 函数实现。对于 Android,实现与此类似,但请注意,语音识别仅添加到 iOS 版本 10.0,因此我必须检查设备版本,并在必要时通知用户,iOS 版本低于 10.0 的设备不支持语音识别。

图 7:语音识别实现

public class VoiceFunction : ParserFunction
{
  static STT m_speech = null;
  public static  STT LastRecording { get { return m_speech; }}
  protected override Variable Evaluate(ParsingScript script)
  {
    bool isList = false;
    List<Variable> args = Utils.GetArgs(script,
                          Constants.START_ARG, Constants.END_ARG, out isList);
    Utils.CheckArgs(args.Count, 1, m_name);
    string strAction = args[0].AsString();
    STT.Voice = Utils.GetSafeString(args, 1, STT.Voice).Replace('_', '-');
    bool speechEnabled = UIDevice.CurrentDevice.CheckSystemVersion(10, 0);
    if (!speechEnabled) {
      UIVariable.GetAction(strAction, "\"" +
       string.Format("Speech recognition requires iOS 10.0 or higher.
       You have iOS {0}",
                     UIDevice.CurrentDevice.SystemVersion) + "\"", "");
      return Variable.EmptyInstance;
    }
    if (!STT.Init()) {
      // The user didn't authorize accessing the microphone.
      return Variable.EmptyInstance;
    }
    UIViewController controller = AppDelegate.GetCurrentController();
    m_speech = new STT(controller);
    m_speech.SpeechError += (errorStr) => {
      Console.WriteLine(errorStr);
      controller.InvokeOnMainThread(() => {
        UIVariable.GetAction(strAction, "\"" + errorStr + "\"", "");
      });
    };
    m_speech.SpeechOK += (recognized) => {
      Console.WriteLine("Recognized: " + recognized);
      controller.InvokeOnMainThread(() => {
        UIVariable.GetAction(strAction, "", "\"" + recognized + "\"");
      });
    };
    m_speech.StartRecording(STT.Voice);
    return Variable.EmptyInstance;
  }
}
public class StopVoiceFunction : ParserFunction
{
  protected override Variable Evaluate(ParsingScript script)
  {
    VoiceFunction.LastRecording?.StopRecording();
    script.MoveForwardIf(Constants.END_ARG);
    return Variable.EmptyInstance;
  }
}

实际的语音识别代码位于 SST 类中。代码有点太长了,无法在本文中展示,而且代码也因 iOS 和 Android 而异。大家可以在随附的源代码中进行查看。

我没有对文本到语音转换使用回调函数,但可以通过类似方式添加一个,告知用户文本到语音转换何时完成(或是否有错)。CSCS 代码回调是通过调用 UIVariable.GetAction 方法而执行:

public static Variable GetAction(string funcName, string senderName, string eventArg)
{
  if (senderName == "") {
    senderName = "\"\"";
  }
  if (eventArg == "") {
    eventArg = "\"\"";
  }
  string body = string.Format("{0}({1},{2});", funcName, senderName, eventArg);
  ParsingScript tempScript = new ParsingScript(body);
  Variable result = tempScript.ExecuteTo();
  return result;
}

有关此函数的具体运用,可以参阅图 7。

例如:货币换算器

接下来,将从头开始创建一个应用(即货币换算器),以此为例介绍如何使用不同的 CSCS 功能进行跨平台应用开发。

必须使用联机服务,才能在应用中获取最新汇率。我选择了 exchangerate-api.com。此网站提供了一项易用的 Web 服务,每月前 1,000 个请求是免费的,应该足够应用启动时使用了。注册后,将会获得唯一密钥。此密钥必须随每个请求一起提供。

我的应用视图因纵向和横向模式而异。图 8 和图 9 分别展示了在纵向模式和横向模式下的 iPhone 和 Android 视图。

iPhone(左)和 Android(右)上处于纵向模式的货币换算器
图 8:iPhone(左)和 Android(右)上处于纵向模式的货币换算器

iPhone(上)和 Android(下)上处于横向模式的货币换算器
图 9:iPhone(上)和 Android(下)上处于横向模式的货币换算器

图 10 展示了货币换算器应用的整个 CSCS 实现。

图 10:货币换算器应用的 CSCS 实现

function on_about(sender, arg) {
  OpenUrl("http://www.exchangerate-api.com");
}
function on_refresh(sender, arg) {
  currency1 = GetText(cbCurrency1);
  currency2 = GetText(cbCurrency2);
  currency_request(currency1, currency2);
}
function currency_request(currency1, currency2) {
  if (currency1 == currency2) {
    time = Now("HH:mm:ss");
    date = Now("yyyy/MM/dd");
    rate = 1;
  } else {
    url = apiUrl + currency1 + "/" + currency2;
    try {
      data = WebRequest(url);
    } catch(exception) {
      WriteConsole(exception.Stack);
      ShowToast("Couldn't get rates. " + exception);
      SetText(labelRateValue, "Error");
      return;
    }
    try {
      timestamp = StrBetween(data, "\"timestamp\":", ",");
      time      = Timestamp(timestamp, "HH:mm:ss");
      date      = Timestamp(timestamp, "yyyy/MM/dd");
      rate      = StrBetween(data, "\"rate\":", "}");
    } catch(exception) {
      ShowToast("Couldn't get rates. " + exception);
      SetText(labelRateValue, "Error");
      return;
    }
  }
  SetText(labelRateValue, rate);
  SetText(labelDateValue, date);
  SetText(labelTimeValue, time);
}
function init() {
  currencies = {"EUR", "USD", "GBP", "CHF", "JPY", "CNY", "MXN", "RUB", "BRL", "SAR"};
  flags      = {"eu_EU", "en_US", "en_GB", "de_CH", "ja_JP", "zh_CN",
                "es_MX", "ru_RU", "pt_BR", "ar_SA"};
  AddWidgetData(cbCurrency1, currencies);
  AddWidgetImages(cbCurrency1, flags);
  SetSize(cbCurrency1, 80, 40);
  SetText(cbCurrency1, "USD");
  AddWidgetData(cbCurrency2, currencies);
  AddWidgetImages(cbCurrency2, flags);
  SetSize(cbCurrency2, 80, 40);
  SetText(cbCurrency2, "MXN");
  SetImage(buttonRefresh,     "coins");
  AddAction(buttonRefresh,    "on_refresh");
  SetFontColor(buttonRefresh, "white");
  SetFontSize(buttonRefresh,  20);
  AddAction(aboutButton,      "on_about");
}
function on_portrait(sender, arg) {
  AddOrSelectTab("Rates", "rates_active.png", "rates_inactive.png");
  SetBackground("us_bg.png");
  locCurrency1 = GetLocation("ROOT", "LEFT", "ROOT", "TOP", 10, 80);
  AddCombobox(locCurrency1, "cbCurrency1", "", 280, 100);
  locCurrency2 = GetLocation("ROOT", "RIGHT", cbCurrency1, "CENTER", -10);
  AddCombobox(locCurrency2, "cbCurrency2", "", 280, 100);
  locRateLabel = GetLocation("ROOT", "CENTER", cbCurrency2, "BOTTOM", -80, 60);
  AddLabel(locRateLabel, "labelRate", "Rate:", 200, 80);
  locRateValue = GetLocation("ROOT", "CENTER", labelRate, "CENTER", 100);
  AddLabel(locRateValue, "labelRateValue", "", 240, 80);
  locDateLabel = GetLocation("ROOT", "CENTER", labelRate, "BOTTOM", -80);
  AddLabel(locDateLabel, "labelDate", "Date:", 200, 80);
  locDateValue = GetLocation("ROOT", "CENTER", labelDate, "CENTER", 100);
  AddLabel(locDateValue, "labelDateValue", "", 240, 80);
  locTimeLabel = GetLocation("ROOT", "CENTER", labelDate, "BOTTOM", -80);
  AddLabel(locTimeLabel, "labelTime", "Time:", 200, 80);
  locTimeValue = GetLocation("ROOT", "CENTER", labelTime, "CENTER", 100);
  AddLabel(locTimeValue, "labelTimeValue", "", 240, 80);
  locRefresh = GetLocation("ROOT", "CENTER", "ROOT", "BOTTOM", 0, -4);
  AddButton(locRefresh, "buttonRefresh", "Convert", 200, 100);
  AddOrSelectTab("Settings", "settings_active.png", "settings_inactive.png");
  locAbout = GetLocation("ROOT", "CENTER", "ROOT", "BOTTOM", -4);
  AddButton(locAbout, "aboutButton", "Powered by exchangerate-api.com", 360, 100);
}
function on_landscape(sender, arg) {
  AddOrSelectTab("Rates", "rates_active.png", "rates_inactive.png");
  SetBackground("us_w_bg.png");
  locCurrency1 = GetLocation("ROOT", "LEFT", "ROOT", "CENTER", 50);
  AddCombobox(locCurrency1, "cbCurrency1", "", 200, 120);
  locCurrency2 = GetLocation(cbCurrency1, "RIGHT", "ROOT", "CENTER", 40);
  AddCombobox(locCurrency2, "cbCurrency2", "", 200, 120);
  locDateLabel = GetLocation(cbCurrency2, "RIGHT", "ROOT", "CENTER", 60);
  AddLabel(locDateLabel, "labelDate", "Date:", 180, 80);
  locDateValue = GetLocation(labelDate, "RIGHT", labelDate, "CENTER", 10);
  AddLabel(locDateValue, "labelDateValue", "", 220, 80);
  locRateLabel = GetLocation(cbCurrency2, "RIGHT", labelDate, "TOP", 60);
  AddLabel(locRateLabel, "labelRate", "Rate:", 180, 80);
  locRateValue = GetLocation(labelRate, "RIGHT", labelRate, "CENTER", 10);
  AddLabel(locRateValue, "labelRateValue", "", 220, 80);
  locTimeLabel = GetLocation(cbCurrency2, "RIGHT", labelDate, "BOTTOM", 60);
  AddLabel(locTimeLabel, "labelTime", "Time:", 180, 80);
  locTimeValue = GetLocation(labelTime, "RIGHT", labelTime, "CENTER", 10);
  AddLabel(locTimeValue, "labelTimeValue", "", 220, 80);
  locRefresh = GetLocation("ROOT", "CENTER", "ROOT", "BOTTOM", 0, -4);
  AddButton(locRefresh, "buttonRefresh", "Convert", 180, 90);
  AddOrSelectTab("Settings", "settings_active.png", "settings_inactive.png");
  locAbout = GetLocation("ROOT", "CENTER", "ROOT", "BOTTOM", -4);
  AddButton(locAbout, "aboutButton", "Powered by exchangerate-api.com", 360, 100);
}
AutoScale();
apiUrl = "https://v3.exchangerate-api.com/pair/c2cd68c6d7b852231b6d69ee/";
RegisterOrientationChange("on_portrait", "on_landscape");
init();
if (Orientation == "Portrait") {
  on_portrait("", "");
} else {
  on_landscape("", "");
}
SelectTab(0);

函数 on_about 和 on_refresh 都是在用户单击按钮时执行的回调函数。

on_about 方法在用户单击“设置”选项卡(图 8 和图 9**** 未显示此选项)中的“技术支持方”按钮时执行,从而让 OpenUrl 函数在默认浏览器中打开 exchangerate-api.com 主页。on_refresh 方法在用户单击“转换”按钮时执行。然后,将会获得选定货币,并调用 CSCS currency_request 函数以执行实际汇率换算。

currency_request 函数先检查两种货币是否相同。在此示例中,我已经知道汇率为 1,这样就无需调用 Web 服务了(我希望节省此服务每月本就不多的免费使用次数)。如果不同,将会调用 WebRequest 函数。此函数对 iOS 和 Android 通用,它的实现如图 11 所示。请注意,无需在 C# 代码中执行异常处理。如果异常抛出(例如,如果服务不可用),异常会传播到捕获到它的 CSCS 代码。还请注意,WebRequest 函数采用同步实现。也可以将它设为异步,具体方法是提供要在请求完成时调用的回调函数(类似于我前面介绍过的语音识别功能)。

图 11:WebRequestFunction Evaluate 方法的 C# 实现

public class WebRequestFunction : ParserFunction
{
  protected override Variable Evaluate(ParsingScript script)
  {
    bool isList = false;
    List<Variable> args = Utils.GetArgs(script,
                          Constants.START_ARG, Constants.END_ARG, out isList);
    Utils.CheckArgs(args.Count, 1, m_name);
    string uri = args[0].AsString();
    string responseFromServer = "";
    WebRequest request = WebRequest.Create(uri);
    using (WebResponse response = request.GetResponse()) {
      Console.WriteLine("{0} status: {1}", uri,
                        ((HttpWebResponse)response).StatusDescription);
      using (StreamReader sr = new StreamReader(response.GetResponseStream())) {
        responseFromServer = sr.ReadToEnd();
      }
    }
    return new Variable(responseFromServer);
  }
}

接下来,将继续分析图 10**** 中的 CSCS 代码。我刚才介绍了 currency_request 函数的工作原理。我从 exchangerate-api.com 获取的 JSON 响应如下所示:

{"result":"success","timestamp":1511464063,"from":"USD","to":"CHF",­"rate":­0.99045395}

时间戳是自 1970 年 1 月 1 日开始计算的秒数。CSCS 函数 Timestamp(format) 将此秒数转换为指定的日期或时间格式。

StrBetween(data, strStart, strEnd) 是便捷函数,用于从介于 strStart1 和 strStart2 字符串之间的数据字符串中提取子字符串。

提取汇率、日期和时间后,我便使用 SetText(widgetName, text) 函数将它们设置为相应的标签。

在 init 函数中,我初始化了数据,并能添加其他要换算的货币。

让布局因屏幕方向而异很简单,只需向 RegisterOrientationChange 函数注册屏幕方向更改回调即可。每当设备的屏幕方向改变时,都会调用 on_portrait 和 on_landscape 函数。正如图 10 末尾所示,设置方法为调用以下代码:

RegisterOrientationChange("on_portrait", "on_landscape");

通常情况下是使用第一部分中“Hello, World!”示例提到的逻辑,将小组件添加到屏幕内的特定位置。大家可能已经注意到,手机背景因横向模式和纵向模式而异。为此,请使用 SetBackground(imageName) CSCS 函数。

AddOrSelectTab 函数的签名如下:

AddOrSelectTab(Tab_Name, Picture_when_active, Picture_when_inactive);

将会添加选项卡(如果尚不存在的话)。否则,将会选择选项卡,并将所有小组件连续添加到此选项卡。图 12 展示了选项卡在启用和停用模式下的外观。

iOS 上处于启用和停用模式的选项卡
图 12:iOS 上处于启用和停用模式的选项卡

总结

阅读完本文,大家了解到通过 CSCS 可以使用脚本语言编写移动应用。脚本通过 C# 解释器和 Xamarin Framework 转换为本机代码。CSCS 脚本可以完成 C# 能够执行的所有任务(在 Xamarin C# 中,可以完成所有本机应用开发任务)。

我已经发布了一个完全用 CSCS 编写的应用。有关此应用的 iOS 版本,请访问 apple.co/2yixGxZ;有关此应用的 Android 版本,请访问 goo.gl/zADtNb

移动应用 CSCS 脚本远未完成。若要向 CSCS 添加新功能,请创建派生自 ParserFunction 类的新类,并重写它的 Evaluate 方法。然后,向分析程序注册此类,同时提供它的 CSCS 名称:

ParserFunction.RegisterFunction("CSCS_Name", new MyNewCustomFunction())

使用 CSCS,可以编程方式放置所有小组件,且代码也不因 Android 和 iOS 而异。为此,无需使用任何 XAML,就像使用 Xamarin.Forms 时一样。

还可以将 CSCS 与现有 C# 代码结合使用,因为通过 CSCS 调用 C# 代码很简单,我在 codemag.com/article/1711081 中介绍过。在那篇文章中,还可以查看在 CSCS 中实现的功能列表。不过,对于最新的 CSCS 功能和特性,请访问 github.com/vassilych/mobile

遗憾的是,由于篇幅有限,无法介绍 CSCS 支持的其他一些炫酷功能(如应用内购买和结算、应用内广告、安排一次性事件和重复性事件等),但可以在随附下载的源代码中进行了解。

Vassili Kaplan 是前 Microsoft Lync 开发人员。他热衷于 C#、C++ 和 Python 编程,现热衷于 CSCS 编程。现居住于瑞士的苏黎世,作为一名自由职业者供职于各银行。可通过 iLanguage.ch 与他联系**

衷心感谢以下 Microsoft 技术专家对本文的审阅:James McCaffrey
ScriptoJames McCaffrey 供职于华盛顿地区雷蒙德市沃什湾的 Microsoft Research。他参与过多个 Microsoft 产品的工作,包括 Internet Explorer 和必应。Scripto可通过 jamccaff@microsoft.com 与 McCaffrey 取得联系。


在 MSDN 杂志论坛讨论这篇文章